diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 382cfb15..6715e2d6 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -81,7 +81,7 @@ accepts that expected skip. ## Cache behavior -The workflow installs Bun with `oven-sh/setup-bun`, pinned per the supply-chain rule below. The action reads the Bun version from `packageManager` in `package.json`, needs no version input, and caches only the Bun executable. A separate official `actions/cache` step caches `~/.bun/install/cache`; its exact key binds runner identity, `bun.lock`, and `package.json`. Caches are hints only: frozen installs and `ci.mjs` gates decide correctness. `resolve-shards` restores no cache and installs no dependencies; it needs only a checkout and Node. (verify: `grep -n "setup-bun@\|actions/cache@" .github/workflows/ci.yml`) +The workflow installs Bun with `oven-sh/setup-bun`, pinned per the supply-chain rule below. The action reads the Bun version from `packageManager` in `package.json`, needs no version input, and caches only the Bun executable. A separate official `actions/cache` step caches `~/.bun/install/cache`; its exact key binds runner identity, `bun.lock`, and `package.json`. Caches are hints only: frozen installs and `ci.mjs` gates decide correctness. `resolve-shards` restores no cache and installs no dependencies; it needs only a checkout and Node, because it runs `node scripts/ci-target.mjs shards` and installs no Bun. (verify: `grep -n "setup-bun@\|actions/cache@" .github/workflows/ci.yml`) ## Hosted cost capture @@ -106,7 +106,7 @@ Mitigations against npm and GitHub Actions supply-chain attacks. Each is load-be - **Dependency caches use the official `actions/cache`**, with the Bun key bound to `bun.lock` and `package.json`. Bun itself comes from `oven-sh/setup-bun`, which reads the `packageManager` pin and caches only the executable. - **`claude-code` is pinned rather than installed globally**: it is an exact-version devDependency in `package.json` and hash-locked in `bun.lock`, including its platform-binary optional dependencies. `minimumReleaseAge` in `bunfig.toml` quarantines fresh publishes (verify: `grep -n minimumReleaseAge bunfig.toml`); bump the dependency only to a version older than that quarantine. - **Lifecycle scripts are denied in `package.json` with `"trustedDependencies": []`.** The empty array is the only spelling that denies every lifecycle script. Omitting the field instead trusts Bun's built-in list, which includes `@anthropic-ai/claude-code`. -- **Every PR validation shard, the targeting-contracts job, and every non-PR `validate` execution** run `bun install --frozen-lockfile`, then `node node_modules/@anthropic-ai/claude-code/install.cjs` to materialize the CLI binary whose lifecycle script was denied, and put `node_modules/.bin` on PATH so `ci.mjs`'s `claude plugin validate` resolves. The PR `validate` job is only the authoritative lane-result join. Node is the validator runtime (version: root `AGENTS.md` Commands); Bun is the package manager and script runner. +- **Every PR validation shard, the targeting-contracts job, and every non-PR `validate` execution** run `bun install --frozen-lockfile`, then `node node_modules/@anthropic-ai/claude-code/install.cjs` to materialize the CLI binary whose lifecycle script was denied, and put `node_modules/.bin` on PATH so `ci.mjs`'s `claude plugin validate` resolves. The PR `validate` job is only the authoritative lane-result join. Bun runs the gate and the author tooling, and it is the package manager. Node runs the shipped-file contracts, `install.cjs`, and `npm audit signatures`, so every job keeps setup-node. Versions: root `AGENTS.md` Commands. - **`npm audit signatures`** remains the non-blocking signature check after every install because Bun has no signature-verification equivalent. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16f47374..4239833f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,7 +142,7 @@ jobs: printf '{"step":"add node_modules/.bin to PATH (so ci.mjs finds the pinned claude)","at_ms":%s}\n' "$(date +%s%3N)" >> "$RUNNER_TEMP/docks-hosted-timings.jsonl" - name: "run validation lane" run: | - node scripts/ci.mjs --lane "${{ matrix.lane }}" --timings-json "$RUNNER_TEMP/docks-ci-timings.json" + bun scripts/ci.mjs --lane "${{ matrix.lane }}" --timings-json "$RUNNER_TEMP/docks-ci-timings.json" printf '{"step":"run validation lane","at_ms":%s}\n' "$(date +%s%3N)" >> "$RUNNER_TEMP/docks-hosted-timings.jsonl" - name: "publish hosted timing artifact" if: always() @@ -207,7 +207,7 @@ jobs: printf '{"step":"add node_modules/.bin to PATH (so ci.mjs finds the pinned claude)","at_ms":%s}\n' "$(date +%s%3N)" >> "$RUNNER_TEMP/docks-hosted-timings.jsonl" - name: "run non-unit plugin-targeting contracts" run: | - node scripts/tests/ci-plugin-targeting.mjs + bun scripts/tests/ci-plugin-targeting.mjs printf '{"step":"run non-unit plugin-targeting contracts","at_ms":%s}\n' "$(date +%s%3N)" >> "$RUNNER_TEMP/docks-hosted-timings.jsonl" - name: "publish hosted timing artifact" if: always() @@ -242,7 +242,7 @@ jobs: - name: resolve CI target id: target if: github.event_name == 'push' - run: node scripts/ci-target.mjs release-tag "$GITHUB_REF_NAME" --github-output "$GITHUB_OUTPUT" + run: bun scripts/ci-target.mjs release-tag "$GITHUB_REF_NAME" --github-output "$GITHUB_OUTPUT" - name: "start hosted step timing" if: github.event_name != 'pull_request' run: | @@ -292,10 +292,10 @@ jobs: # leg runs the repo-wide shard too — once, here, over the exact bytes being # released — and the targeted plugin gate still has the last word. if [ "${{ github.event_name }}" = "push" ]; then - DOCKS_CI_MEMO=0 node scripts/ci.mjs --lane repo - node scripts/ci.mjs --plugin "${{ steps.target.outputs.plugin }}" --timings-json "$RUNNER_TEMP/docks-ci-timings.json" + DOCKS_CI_MEMO=0 bun scripts/ci.mjs --lane repo + bun scripts/ci.mjs --plugin "${{ steps.target.outputs.plugin }}" --timings-json "$RUNNER_TEMP/docks-ci-timings.json" else - node scripts/ci.mjs --timings-json "$RUNNER_TEMP/docks-ci-timings.json" + bun scripts/ci.mjs --timings-json "$RUNNER_TEMP/docks-ci-timings.json" fi printf '{"step":"run the authoritative gate (scripts/ci.mjs)","at_ms":%s}\n' "$(date +%s%3N)" >> "$RUNNER_TEMP/docks-hosted-timings.jsonl" - name: "publish hosted timing artifact" diff --git a/AGENTS.md b/AGENTS.md index 158cad5c..ae58b8f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,11 +8,11 @@ This root file stays **repo-wide**. Per-area authoring details - skill/agent fro ```bash bun install --frozen-lockfile # one-time setup -node scripts/ci.mjs --plugin # authoritative gate for one plugin and its owned release tooling -node scripts/ci.mjs # full gate for repo-wide, shared, or multi-plugin changes +bun scripts/ci.mjs --plugin # authoritative gate for one plugin and its owned release tooling +bun scripts/ci.mjs # full gate for repo-wide, shared, or multi-plugin changes ``` -Node is the validator runtime; its version is the `node-version` in `.github/workflows/ci.yml`. Bun is the package manager; its version is the `packageManager` pin in `package.json` (verify: `grep -n node-version .github/workflows/ci.yml; grep -n packageManager package.json`). +Bun runs this repository's tooling and is the package manager; its version is the `packageManager` pin in `package.json`. Node runs the shipped plugin files that the gate tests, because consumers run them with `node`; its version is the `node-version` in `.github/workflows/ci.yml` (verify: `grep -n node-version .github/workflows/ci.yml; grep -n packageManager package.json`). ## Repository scope @@ -46,7 +46,7 @@ Per-area conventions load lazily from nested `AGENTS.md` nodes. A node is a sing | `scripts/AGENTS.md` | validators, edit→release workflow, double-layer gating, versioning | | `.github/AGENTS.md` | CI trigger model, keep-in-sync with `ci.mjs` | -The table must name every `AGENTS.md` below the root, and every row must resolve. `scripts/tree/guard.mjs` enforces both, plus the node contract (size cap, no legacy `CLAUDE.md`) (verify: delete one row from a scratch copy, then run `node scripts/tree/guard.mjs `; it must fail naming the node). The `context-tree` skill (`plugins/docks/skills/productivity/context-tree/`) scaffolds, audits, and refreshes these nodes. +The table must name every `AGENTS.md` below the root, and every row must resolve. `scripts/tree/guard.mjs` enforces both, plus the node contract (size cap, no legacy `CLAUDE.md`) (verify: delete one row from a scratch copy, then run `bun scripts/tree/guard.mjs `; it must fail naming the node). The `context-tree` skill (`plugins/docks/skills/productivity/context-tree/`) scaffolds, audits, and refreshes these nodes. ## Authoring agents @@ -62,8 +62,8 @@ The `agents/` folder deliberately carries **no context-tree node** (hence its ab - **Description (CSO):** lead with "Use when …" AND include a "Not …" exclusion clause (both required by `scripts/agents/guard.mjs`); ≥80 and ≤500 chars; concrete triggers; no slop words. - **Frontmatter:** `name` (required, kebab-case, matches filename, no `anthropic`/`claude` substring); `description` (required, with the "Not …" clause); **no `model` key** - `scripts/agents/guard.mjs` rejects any value, `inherit` included. Claude Code resolves an omitted `model` through its subagent model order (ending at the main conversation model), and omp falls back to the parent session model, so omission is the only spelling both runtimes agree on; every literal (`inherit`, `sonnet`, `claude-*`) is handed to omp as a model ID, resolves to nothing, and kills the spawn with "No model selected" (a plugin agent carrying `model: inherit` fails to spawn; the identical file without the key runs). Consumers pin per-agent models their own way - Claude via `CLAUDE_CODE_SUBAGENT_MODEL` (a default below frontmatter; add `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1` to override every subagent), omp via `task.agentModelOverrides.`. `tools` (allowlist; omitted = inherit all). For plugin-shipped agents, `hooks`/`mcpServers`/`permissionMode` are silently ignored for security - define the agent in the consuming project's own .claude/agents folder when you need those. - **Body:** hard cap 500 lines (`scripts/agents/guard.mjs`); scorer sweet spot 60–300 lines (`scripts/agents/score.mjs`). Same patterns as skills (`` blocks - up to 2 rewarded - lookup tables, BAD/GOOD, gotchas, validation loop); structure as context-acknowledgment (step 1), then `## Workflow`, `## Output Format`, `## Anti-Hallucination Checks`, `## Success Criteria`. -- **No author-script refs (consumer-safety):** a plugin-shipped agent body must not name docks plugin-author scripts (`scripts/ci.mjs`, `scripts/skills/*`, `scripts/agents/*`, `scripts/tree/*`, …) as a step - they don't ship to a consumer's project. Refer to "the project's CI / validators, if present", or make the check self-contained. `scripts/skills/no-author-scripts.mjs` scans agent bodies alongside shipped skills (verify: append a line naming `node scripts/ci.mjs` to a non-allowlisted skill body → the guard run must FAIL naming that file; revert). -- **Validators:** `node scripts/agents/guard.mjs` (structural) + `node scripts/agents/score.mjs --per-file` (quality score; the rubric maximum is defined in `scripts/agents/score.mjs`). The per-file floor lives in `scripts/config/scoring.json` (verify: `node scripts/config/read-floor.mjs agents`). Use up to two `` blocks when they express distinct load-bearing invariants, not to manage score slack. Both validators run inside `scripts/ci.mjs`; the validator table lives in `scripts/AGENTS.md`. +- **No author-script refs (consumer-safety):** a plugin-shipped agent body must not name docks plugin-author scripts (`scripts/ci.mjs`, `scripts/skills/*`, `scripts/agents/*`, `scripts/tree/*`, …) as a step - they don't ship to a consumer's project. Refer to "the project's CI / validators, if present", or make the check self-contained. `scripts/skills/no-author-scripts.mjs` scans agent bodies alongside shipped skills (verify: append a line naming `bun scripts/ci.mjs` to a non-allowlisted skill body → the guard run must FAIL naming that file; revert). +- **Validators:** `bun scripts/agents/guard.mjs` (structural) + `bun scripts/agents/score.mjs --per-file` (quality score; the rubric maximum is defined in `scripts/agents/score.mjs`). The per-file floor lives in `scripts/config/scoring.json` (verify: `bun scripts/config/read-floor.mjs agents`). Use up to two `` blocks when they express distinct load-bearing invariants, not to manage score slack. Both validators run inside `scripts/ci.mjs`; the validator table lives in `scripts/AGENTS.md`. - **Sources:** [sub-agents](https://code.claude.com/docs/en/sub-agents) · [plugins-reference](https://code.claude.com/docs/en/plugins-reference). ## Plans @@ -95,8 +95,8 @@ CI always runs `scripts/ci.mjs`: in full, per `--lane `, or per `--plugin ## Tool-agnostic rules -- Run focused checks while implementing. Before committing, pushing, or releasing a change owned by exactly one plugin, run `node scripts/ci.mjs --plugin `; that selected-plugin gate is authoritative for the plugin payload and its descriptor-owned author, source, and release-contract tooling. -- Run full `node scripts/ci.mjs` for repo-wide validation/tooling, shared infrastructure used by multiple plugins, registry or CI-topology changes, changes spanning multiple plugins, and manual full-gate requests. Do not run it merely because a single-plugin release is imminent. +- Run focused checks while implementing. Before committing, pushing, or releasing a change owned by exactly one plugin, run `bun scripts/ci.mjs --plugin `; that selected-plugin gate is authoritative for the plugin payload and its descriptor-owned author, source, and release-contract tooling. +- Run full `bun scripts/ci.mjs` for repo-wide validation/tooling, shared infrastructure used by multiple plugins, registry or CI-topology changes, changes spanning multiple plugins, and manual full-gate requests. Do not run it merely because a single-plugin release is imminent. - Reuse a green gate only while its validated implementation bytes are unchanged. A relevant source change requires rerunning the same smallest authoritative gate; plan-issue-only lifecycle changes do not. - Don't loosen validator floors to pass; fix the file instead - Manifest version numbers stay in lockstep per plugin; `scripts/AGENTS.md` (Versioning) owns the rule, its enforcement, and its verify command diff --git a/README.md b/README.md index 1e13e82e..4795d99b 100644 --- a/README.md +++ b/README.md @@ -131,17 +131,19 @@ Four validators mirror the kit-side conventions: ```bash bun install --frozen-lockfile -node scripts/skills/guard.mjs # Codex + Claude skill compatibility + reference hygiene +bun scripts/skills/guard.mjs # Codex + Claude skill compatibility + reference hygiene node plugins/docks/skills/productivity/write-skill/scripts/skill-guard.mjs score --per-file # skill quality score (max 16) -node scripts/agents/guard.mjs # frontmatter, "Use when…" / "Not…" CSO, no `model` key, tools declared, Workflow + Success Criteria -node scripts/agents/score.mjs # quality score (max 15) - no `model` key, tools declared, Workflow + Success Criteria +bun scripts/agents/guard.mjs # frontmatter, "Use when…" / "Not…" CSO, no `model` key, tools declared, Workflow + Success Criteria +bun scripts/agents/score.mjs # quality score (max 15) - no `model` key, tools declared, Workflow + Success Criteria ``` -Node 24 remains the validator runtime and matches CI's `node-version`; Bun 1.4.0 is the package manager pinned through `packageManager`. +Bun runs this repository's own tooling: `scripts/`, `tests/`, and the plan-lifecycle selftest. Bun is also the package manager. Its version is the `packageManager` pin in `package.json`. -`--per-file` on a scorer prints one ` ` line per item - useful for spotting drift after an edit. `node scripts/ci.mjs` runs the full local gate (guards + scorers + manifest + idempotency); `ci.yml` runs that same file on CI. +Node runs the shipped plugin files, also inside the gate, because consumers run them with `node`. The `skill-guard.mjs` scorer line above is one of them. Node's version is the `node-version` in `.github/workflows/ci.yml`. -On a PR to `main`, CI runs only the shards the changed paths resolve to - the repo-wide checks always, plus the lane owning any plugin you touched. On a `--v` release tag (docks and plan-lifecycle tag independently), CI resolves the tag to one registry plugin, runs `node scripts/ci.mjs --lane repo` once over the released bytes, then runs `node scripts/ci.mjs --plugin ` as the authoritative gate. The `--plugin` gate alone skips repo-wide checks. See `.github/workflows/ci.yml`; full trigger model below. +`--per-file` on a scorer prints one ` ` line per item - useful for spotting drift after an edit. `bun scripts/ci.mjs` runs the full local gate (guards + scorers + manifest + idempotency); `ci.yml` runs that same file on CI. + +On a PR to `main`, CI runs only the shards the changed paths resolve to - the repo-wide checks always, plus the lane owning any plugin you touched. On a `--v` release tag (docks and plan-lifecycle tag independently), CI resolves the tag to one registry plugin, runs `bun scripts/ci.mjs --lane repo` once over the released bytes, then runs `bun scripts/ci.mjs --plugin ` as the authoritative gate. The `--plugin` gate alone skips repo-wide checks. See `.github/workflows/ci.yml`; full trigger model below. ## Versioning + releases @@ -153,13 +155,13 @@ Versions are per plugin. Each plugin's `version` in `plugins//.claude-plug `scripts/release.mjs` releases one plugin per run and wraps the full dance in one command (`--plugin` defaults to `docks`; `--dry-run` previews the bump + manifest diff without tagging): ```bash -node scripts/release.mjs patch # docks: 0.1.0 → 0.1.1 -node scripts/release.mjs minor # docks: 0.1.0 → 0.2.0 -node scripts/release.mjs --plugin plan-lifecycle major # plan-lifecycle: 0.1.0 → 1.0.0 -node scripts/release.mjs --plugin docks 0.2.0 # explicit +bun scripts/release.mjs patch # docks: 0.1.0 → 0.1.1 +bun scripts/release.mjs minor # docks: 0.1.0 → 0.2.0 +bun scripts/release.mjs --plugin plan-lifecycle major # plan-lifecycle: 0.1.0 → 1.0.0 +bun scripts/release.mjs --plugin docks 0.2.0 # explicit ``` -The script runs `node scripts/ci.mjs --plugin ` as a preflight, bumps that plugin's Claude and Codex manifests plus its entry in the versioned Claude marketplace catalog, commits + pushes, runs `claude plugin tag --push` for the `--v` tag, **waits for the tag-CI run to pass** (`.github/workflows/ci.yml` is triggered by tag pushes), then calls `gh release create` with notes generated from `git log` since the previous tag. If CI fails, the GitHub Release is NOT created - the tag stays as a marker that the release was attempted, and the script prints recovery steps. Released versions appear at https://github.com/DocksDocks/docks/releases. +The script runs `bun scripts/ci.mjs --plugin ` as a preflight, bumps that plugin's Claude and Codex manifests plus its entry in the versioned Claude marketplace catalog, commits + pushes, runs `claude plugin tag --push` for the `--v` tag, **waits for the tag-CI run to pass** (`.github/workflows/ci.yml` is triggered by tag pushes), then calls `gh release create` with notes generated from `git log` since the previous tag. If CI fails, the GitHub Release is NOT created - the tag stays as a marker that the release was attempted, and the script prints recovery steps. Released versions appear at https://github.com/DocksDocks/docks/releases. CI runs only on (a) PRs to main, (b) tag pushes matching `--v`, and (c) manual `workflow_dispatch`. Pushes to main don't re-trigger CI - PR validation gates merges, tag-CI gates releases. diff --git a/package.json b/package.json index 87906523..45d5b74f 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,18 @@ { "name": "docks-plugin-tooling", "private": true, - "packageManager": "bun@1.4.0", + "packageManager": "bun@1.4.4", "description": "Author-side validation tooling for the Docks plugin.", "scripts": { - "ci": "node scripts/ci.mjs", - "guard:skills": "node scripts/skills/guard.mjs plugins/docks/skills", + "ci": "bun scripts/ci.mjs", + "guard:skills": "bun scripts/skills/guard.mjs plugins/docks/skills", "score:skills": "node plugins/docks/skills/productivity/write-skill/scripts/skill-guard.mjs score --per-file plugins/docks/skills", "format:js": "biome format --write scripts tests plugins/plan-lifecycle/test plugins/docks/hooks package.json biome.json", "lint:js": "biome lint scripts tests plugins/plan-lifecycle/test plugins/docks/hooks plugins/plan-lifecycle/skills/productivity/plan-manager/scripts plugins/docks/skills/productivity/write-skill/scripts", "check:js": "biome ci scripts tests plugins/plan-lifecycle/test plugins/docks/hooks package.json biome.json && biome lint plugins/plan-lifecycle/skills/productivity/plan-manager/scripts plugins/docks/skills/productivity/write-skill/scripts", - "test:unit": "node --test --test-concurrency=4 scripts/tests/unit/*.test.mjs", - "test:observability": "node scripts/tests/ci-observability.mjs", - "test:contracts": "node scripts/tests/test-contracts.mjs", + "test:unit": "bun test --parallel=4 --timeout=180000 ./scripts/tests/unit/*.test.mjs", + "test:observability": "bun scripts/tests/ci-observability.mjs", + "test:contracts": "bun scripts/tests/test-contracts.mjs", "test:coverage": "node --test --experimental-test-coverage --test-coverage-include='plugins/plan-lifecycle/skills/productivity/plan-manager/scripts/plan.mjs' --test-coverage-exclude='**/scripts/tests/**' --test-coverage-exclude='**/plugins/**/test/**' scripts/tests/plan-cli.mjs" }, "trustedDependencies": [], diff --git a/plugins/docks/README.md b/plugins/docks/README.md index e176b359..896e061c 100644 --- a/plugins/docks/README.md +++ b/plugins/docks/README.md @@ -96,7 +96,7 @@ Quality gates live in the marketplace repo's `scripts/` directory and are NOT sh - `scripts/skills/guard.mjs` + the bundled `write-skill/scripts/skill-guard.mjs` (`score`) — Codex + Claude compatibility and quality (max 16) - `scripts/agents/guard.mjs` / `scripts/agents/score.mjs` — structural + quality (max 15) -`node scripts/ci.mjs` runs the full local gate. CI gates merges (PRs to main) and releases (`docks--v*` tag pushes). See [the marketplace repo](https://github.com/DocksDocks/docks) for contributor docs. +`bun scripts/ci.mjs` runs the full local gate. CI gates merges (PRs to main) and releases (`docks--v*` tag pushes). See [the marketplace repo](https://github.com/DocksDocks/docks) for contributor docs. ## License diff --git a/plugins/docks/skills/AGENTS.md b/plugins/docks/skills/AGENTS.md index b92e5b0d..690be190 100644 --- a/plugins/docks/skills/AGENTS.md +++ b/plugins/docks/skills/AGENTS.md @@ -5,11 +5,11 @@ Skills are the cross-tool payload — every skill here surfaces in Claude Code, Pointers here name concepts, not coordinates — if a path or symbol moved, trust the stated purpose and re-locate it (grep the symbol) before acting. -During skill iteration, run the narrow validators and checks relevant to the change. After a meaningful batch, you may run the owning plugin gate (`node scripts/ci.mjs --plugin docks` here); do not rerun unrelated plugins during the edit loop. Reserve full `node scripts/ci.mjs` for the final relevant implementation tree before commit, push, or release. Don't loosen validator floors to make a file pass; fix the file. The validator/CI contract lives in `scripts/AGENTS.md`. +During skill iteration, run the narrow validators and checks relevant to the change. After a meaningful batch, you may run the owning plugin gate (`bun scripts/ci.mjs --plugin docks` here); do not rerun unrelated plugins during the edit loop. Reserve full `bun scripts/ci.mjs` for the final relevant implementation tree before commit, push, or release. Don't loosen validator floors to make a file pass; fix the file. The validator/CI contract lives in `scripts/AGENTS.md`. -After changing a skill's meaning, run `node scripts/skills/content-hash.mjs --backfill`. It re-syncs `content_hash` and stamps `metadata.updated` in the same write, and it writes only when the hash actually differs — so a formatting-only or otherwise meaning-preserving edit leaves both fields alone and the recorded date keeps describing the last real change. That is the point: readers use `metadata.updated` to judge staleness, so the date must never be bumped by a formatting pass. CI's idempotency check fails if the stored `content_hash` drifts from the body + references. Editing only `updated:` does not change the hash. +After changing a skill's meaning, run `bun scripts/skills/content-hash.mjs --backfill`. It re-syncs `content_hash` and stamps `metadata.updated` in the same write, and it writes only when the hash actually differs — so a formatting-only or otherwise meaning-preserving edit leaves both fields alone and the recorded date keeps describing the last real change. That is the point: readers use `metadata.updated` to judge staleness, so the date must never be bumped by a formatting pass. CI's idempotency check fails if the stored `content_hash` drifts from the body + references. Editing only `updated:` does not change the hash. @@ -32,7 +32,7 @@ One-shot whole-repo setup and audit go through the `agent-first-setup` skill. ## Description (the thing that gets matched) -1. **Lead with "Use when …"** — the guard enforces this prefix (verify: temporarily reword one description to drop the prefix → `node scripts/skills/guard.mjs` must fail on it; revert). +1. **Lead with "Use when …"** — the guard enforces this prefix (verify: temporarily reword one description to drop the prefix → `bun scripts/skills/guard.mjs` must fail on it; revert). 2. **Key use case first** — Claude Code truncates the combined description in the listing (limit: Skills docs in `## Sources`); the first ~100 chars matter most. 3. **≤500 chars** for full scorer credit; the hard cap is 1,024 (agentskills.io spec). Point values: `plugins/docks/skills/productivity/write-skill/scripts/skill-guard.mjs` (verify: `node plugins/docks/skills/productivity/write-skill/scripts/skill-guard.mjs score --per-file | grep `). 4. **Say what the skill does as well as when.** After the "Use when" trigger, name the job the skill does (for example "produces a tiered fix plan"). The agent picks a skill from the description alone, so a description with triggers but no job cannot be told apart from a neighbor. Move long "Covers X, Y, Z" enumerations into the body, but keep one clause that states the job. Source: the agentskills.io spec `description` field (`## Sources`). @@ -71,7 +71,7 @@ Conciseness test: "would removing this line cause Claude to make mistakes? If no | Validation loop | do → run validator → fix → repeat | | `references/.md` | when body crosses ~310 lines, split detail out (30–150 lines each); a long reference with several headings needs a `## Contents` TOC (thresholds: `scripts/skills/refs-guard.mjs`, Anthropic best-practice) | | `scripts/` / `assets/` bundle | executable helpers every invocation would re-derive (execution is token-free) / copy-only output templates; neither is content-hashed — bump `metadata.updated` manually when they change | -| Durable anchors | reference code as `` `path` — `symbol` — purpose (verify: `command`) ``, never a live `path:NN` line anchor. CI's repo-wide durable-anchors guard fails on any `path:NN` whose path resolves (verify: append a `:1` anchor to a body → `node scripts/skills/durable-anchors.mjs` exits 1 naming it; revert). Rules for volatile facts and behavior claims: `plugins/docks/skills/productivity/write-skill/references/durable-anchors.md` | +| Durable anchors | reference code as `` `path` — `symbol` — purpose (verify: `command`) ``, never a live `path:NN` line anchor. CI's repo-wide durable-anchors guard fails on any `path:NN` whose path resolves (verify: append a `:1` anchor to a body → `bun scripts/skills/durable-anchors.mjs` exits 1 naming it; revert). Rules for volatile facts and behavior claims: `plugins/docks/skills/productivity/write-skill/references/durable-anchors.md` | Body sweet spot **80–310 lines** (scorer; ≤500 hard cap). Past ~310, post-compaction re-attachment may silently drop content (Cross-tool wording, item 1). @@ -116,7 +116,7 @@ Skills run in both runtimes; phrase for both. Sources: the Claude Code skills do ## Scoring -`node plugins/docks/skills/productivity/write-skill/scripts/skill-guard.mjs score --per-file | grep ` prints the skill's score; the rubric and its maximum live in that script. Per-file floor by category: `scripts/config/scoring.json` (verify: `node -p "JSON.stringify(require('./scripts/config/scoring.json').skills)"`). Aim 14+ on new skills. Structural gate: `node scripts/skills/guard.mjs`. To author a new skill from scratch, use the `write-skill` skill. +`node plugins/docks/skills/productivity/write-skill/scripts/skill-guard.mjs score --per-file | grep ` prints the skill's score; the rubric and its maximum live in that script. Per-file floor by category: `scripts/config/scoring.json` (verify: `bun -p "JSON.stringify(require('./scripts/config/scoring.json').skills)"`). Aim 14+ on new skills. Structural gate: `bun scripts/skills/guard.mjs`. To author a new skill from scratch, use the `write-skill` skill. **Single source, not a mirror:** the scorer lives ONCE in the bundled `write-skill/scripts/skill-guard.mjs` (`score` / `validate`), shipped so it runs in consumer repos AND used by this kit's own `ci.mjs` to score (`skill-maintenance` points at it too). There is no author-side `score.sh` to keep in sync — one rubric. Bundled `scripts/` sit outside the `content_hash` surface, so bump write-skill's `metadata.updated` when the rubric changes. diff --git a/plugins/docks/skills/productivity/write-skill/SKILL.md b/plugins/docks/skills/productivity/write-skill/SKILL.md index a063c5e0..acfc2571 100644 --- a/plugins/docks/skills/productivity/write-skill/SKILL.md +++ b/plugins/docks/skills/productivity/write-skill/SKILL.md @@ -4,8 +4,8 @@ description: "Use when authoring a new skill for the docks plugin skill tree or user-invocable: true metadata: pattern: meta-skill - updated: "2026-09-24" - content_hash: "fab05be9a14c1ccbe2d2def62d7b1c2c64732bec49b332216ddcbba4b35b0eef" + updated: "2026-10-23" + content_hash: "637c4dae9c986682675dc37480598f3734c313271253f95f1423b3571609de4b" --- # Write a Skill (docks conventions) @@ -81,10 +81,10 @@ metadata: 4. **Baseline check.** Re-run the step 0 prompts with the skill and compare (see "Behavioral checks"). A gap that stays open means the body does not reach it; fix the body, not the prompt. 5. **Trigger check.** Run the query set against the description (see "Behavioral checks"). 6. **Score check.** `node /scripts/skill-guard.mjs score --per-file | grep ` — the bundled scorer the kit CI also scores with (one rubric, no mirror). Validate one skill: `node …/skill-guard.mjs validate `. If < 14, find the missing point in the rubric. -7. **Structural check.** Kit: `node scripts/skills/guard.mjs` (frontmatter for both runtimes + `refs-guard.mjs`: broken `references/` links, orphan reference files, the long-reference TOC rule below). Elsewhere: `node skill-guard.mjs validate --strict` covers the portable subset. Failures are non-negotiable — fix them. +7. **Structural check.** Kit: `bun scripts/skills/guard.mjs` (frontmatter for both runtimes + `refs-guard.mjs`: broken `references/` links, orphan reference files, the long-reference TOC rule below). Elsewhere: `node skill-guard.mjs validate --strict` covers the portable subset. Failures are non-negotiable — fix them. 8. **Docs check.** Run the self-check in `references/durable-anchors.md` on the body and every reference. If the skill writes docs, check rows A–C of "Durable docs and agent-first output" below against the docs it emits; if it writes into a user repo, check row D too. -9. **Targeted CI while iterating.** In a multi-plugin kit, run the owning plugin's gate after each meaningful change — in this kit, `node scripts/ci.mjs --plugin `. Do not rerun unrelated plugin gates during the edit loop. -10. **Full CI once.** Run `node scripts/ci.mjs` at the final pre-commit/release gate, or earlier only when shared/repo-wide files changed. It must be green before commit. +9. **Targeted CI while iterating.** In a multi-plugin kit, run the owning plugin's gate after each meaningful change — in this kit, `bun scripts/ci.mjs --plugin `. Do not rerun unrelated plugin gates during the edit loop. +10. **Full CI once.** Run `bun scripts/ci.mjs` at the final pre-commit/release gate, or earlier only when shared/repo-wide files changed. It must be green before commit. 11. **Stop when the behavioral checks pass.** Ship when the baseline check shows the step 0 gaps closed and the trigger check passes. The score is a minimum, not the goal: meet the category floor, but do not add tables or constraint blocks only to gain points, because a format proxy does not tell you whether the agent behaves better. ## Behavioral checks diff --git a/plugins/plan-lifecycle/skills/AGENTS.md b/plugins/plan-lifecycle/skills/AGENTS.md index 01862fd5..f2c2834a 100644 --- a/plugins/plan-lifecycle/skills/AGENTS.md +++ b/plugins/plan-lifecycle/skills/AGENTS.md @@ -8,20 +8,20 @@ read-only reviewer wrappers named in Roles (verify: It is self-versioned: manifests live in `.claude-plugin/` and `.codex-plugin/`, and `compatibility.json` is a closed declaration (keys `schema` and `minimum_docks_major`) that `test/selftest.mjs` verifies against docks' parsed -major (verify: `node plugins/plan-lifecycle/test/selftest.mjs`). Do not replace +major (verify: `bun plugins/plan-lifecycle/test/selftest.mjs`). Do not replace it with prose or a same-major convention. During skill iteration, run the narrow validators relevant to the change. After a meaningful batch, you may run the owning plugin gate -(`node scripts/ci.mjs --plugin plan-lifecycle` here); reserve full -`node scripts/ci.mjs` for the final implementation tree. Do not loosen validator +(`bun scripts/ci.mjs --plugin plan-lifecycle` here); reserve full +`bun scripts/ci.mjs` for the final implementation tree. Do not loosen validator floors to make a file pass; fix the file. The validator/CI contract lives in `scripts/AGENTS.md`. -After changing a skill's meaning, run `node scripts/skills/content-hash.mjs +After changing a skill's meaning, run `bun scripts/skills/content-hash.mjs --backfill plugins/plan-lifecycle/skills`. It re-syncs `content_hash` and stamps `metadata.updated` in the same write, and it writes only when the hash actually differs, so a formatting-only edit leaves both fields alone and the recorded date @@ -89,8 +89,8 @@ compares the copies. Change the paragraph only in lockstep across every copy Same rubric as every kit skill: `node plugins/docks/skills/productivity/write-skill/scripts/skill-guard.mjs score --per-file plugins/plan-lifecycle/skills`. Per-file floors live in -`scripts/config/scoring.json` (verify: `node scripts/config/read-floor.mjs skills productivity`; -agents: `node scripts/config/read-floor.mjs agents`). Skills surface as +`scripts/config/scoring.json` (verify: `bun scripts/config/read-floor.mjs skills productivity`; +agents: `bun scripts/config/read-floor.mjs agents`). Skills surface as `plan-lifecycle:` from `name` in `plugins/plan-lifecycle/.claude-plugin/plugin.json`. Pointers here name concepts, not coordinates — if a path or symbol moved, trust the stated purpose and re-locate it (grep the symbol) before acting. diff --git a/plugins/plan-lifecycle/test/selftest.mjs b/plugins/plan-lifecycle/test/selftest.mjs index 0d22a551..c23e7a7b 100755 --- a/plugins/plan-lifecycle/test/selftest.mjs +++ b/plugins/plan-lifecycle/test/selftest.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun // Check plugin packaging and compatibility. The author smoke covers the helper. import assert from 'node:assert/strict'; import fs from 'node:fs'; diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 95ec84ce..41577c88 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -1,14 +1,14 @@ # Plugin-author tooling (scripts/) -These scripts validate and release the repo's plugins. They are **author-side only** — never shipped to consumers. All tooling is Node `.mjs` — including `release.mjs` (`--dry-run` supported) and the cross-tool `context-tree-nudge` PostToolUse hook; the repository ships no shell scripts of its own. `ci.mjs` is the local gate, and `.github/workflows/ci.yml` invokes that same gate in full or with its supported `--lane` or `--plugin` target; `ci-target.mjs` resolves both targets — a release tag to one plugin, and a pull-request diff to the shard set the matrix runs. +These scripts validate and release the repo's plugins. They are **author-side only** — never shipped to consumers. All tooling is `.mjs` that runs on Bun, including `release.mjs` (`--dry-run` supported); the repository ships no shell scripts of its own. The cross-tool `context-tree-nudge` PostToolUse hook is `.mjs` too, but it ships, so consumers run it with `node`. Two gate steps stay on Node because consumers run the shipped files with `node`: `scripts/tests/plan-cli.mjs` drives the shipped `plan.mjs` (its fake `gh` runs on Bun), and the gate scores skills with the shipped `plugins/docks/skills/productivity/write-skill/scripts/skill-guard.mjs`. In `ci.mjs`, `node()`/`nodeOk()` run exactly these two; `bun()`/`bunOk()` run every other script. Runtimes are chosen by command name, not by `bun --bun` or bunfig `run.bun`. Such an alias makes every `node` in the process tree mean Bun, so the gate could no longer test the shipped files on Node. `ci.mjs` is the local gate, and `.github/workflows/ci.yml` invokes that same gate in full or with its supported `--lane` or `--plugin` target; `ci-target.mjs` resolves both targets — a release tag to one plugin, and a pull-request diff to the shard set the matrix runs. -Gate choice (focused checks while implementing, `node scripts/ci.mjs --plugin ` for a change owned by one plugin, full `node scripts/ci.mjs` otherwise, reuse of a green gate) is repo-wide policy in the root `AGENTS.md` (Tool-agnostic rules). Don't loosen validator floors to make a problematic file pass; fix the file. +Gate choice (focused checks while implementing, `bun scripts/ci.mjs --plugin ` for a change owned by one plugin, full `bun scripts/ci.mjs` otherwise, reuse of a green gate) is repo-wide policy in the root `AGENTS.md` (Tool-agnostic rules). Don't loosen validator floors to make a problematic file pass; fix the file. ## Multi-plugin model (`scripts/lib/plugins.mjs`) -The repo hosts **multiple plugins** under `plugins/` (verify: `node scripts/ci.mjs --list`). `scripts/lib/plugins.mjs` is the **single source of truth**: a `PLUGINS` array of descriptors, each declaring paths + capabilities. **Adding a plugin = adding one descriptor** — no edits to `ci.mjs`/`release.mjs`. +The repo hosts **multiple plugins** under `plugins/` (verify: `bun scripts/ci.mjs --list`). `scripts/lib/plugins.mjs` is the **single source of truth**: a `PLUGINS` array of descriptors, each declaring paths + capabilities. **Adding a plugin = adding one descriptor** — no edits to `ci.mjs`/`release.mjs`. The `Fields:` header comment in `scripts/lib/plugins.mjs` defines every descriptor field (verify: `sed -n '/^\/\/ Fields:/,/^import/p' scripts/lib/plugins.mjs`). Update that comment in the same change that adds or changes a field. @@ -38,7 +38,7 @@ The canonical issue-body contract lives in 2. **One descriptor** appended to `PLUGINS` in `lib/plugins.mjs` — assign required `ciLane` ownership (`core`; a diff under `root` then selects that shard), declare only capabilities that exist (`agents`/`selftest` take `null`, `extraJson`/`authorChecks`/`releaseContracts` use `[]` when absent), and declare the exact data-only `release` policy. 3. **Two catalog entries**: `.claude-plugin/marketplace.json` (name/source/version — version in lockstep with both manifests) and `.agents/plugins/marketplace.json` (local-source + policy block) for Codex. 4. **Optional context node** (a single `plugins//AGENTS.md`) when the plugin carries conventions of its own — `tree/guard` enforces the node contract; the durable-anchors guard scans it. -5. Verify: `node scripts/ci.mjs --list` shows the plugin and full `node scripts/ci.mjs` is green. Every plugin uses the generic positional release command. +5. Verify: `bun scripts/ci.mjs --list` shows the plugin and full `bun scripts/ci.mjs` is green. Every plugin uses the generic positional release command. Plugin behavior stays registry-driven: extend descriptor capabilities rather than adding orchestrator branches. @@ -56,7 +56,7 @@ Plugin behavior stays registry-driven: extend descriptor capabilities rather tha | `skills/no-author-scripts.mjs` | shipped SKILL.md + references/ + agent bodies must not name docks author scripts — incl. the `.mjs` entry points `scripts/ci.mjs`/`scripts/release.mjs` (verify: plant one in a non-allowlisted body → the guard must fail naming it; revert); allowlist: `scaffold`, `write-skill`. Takes ` [agents-dir]` args so `gatePlugin` scopes it per-plugin (agents scanned only when given) | pass/fail | | `skills/durable-anchors.mjs` | repo-wide (runs once): long-lived docs — every shipped skill body/reference + every AGENTS.md node outside docs/plans/ (point-in-time by contract) — carry no LIVE `file:line` anchors (a `path:NN` whose path resolves in the repo fails; fictional example paths pass by non-resolution). Fix = the durable grammar: `` `path` — `symbol` — purpose (verify: `command`) `` | pass/fail | | `agents/guard.mjs` | agent frontmatter, "Use when…"/"Not…" CSO, **no `model` key** (reason: root `AGENTS.md` (Authoring agents)) | pass/fail | -| `agents/score.mjs` | agent quality (rubric maximum defined in the script) | per-file floor from `scoring.json` (verify: `node scripts/config/read-floor.mjs agents`); total = N × per-file floor | +| `agents/score.mjs` | agent quality (rubric maximum defined in the script) | per-file floor from `scoring.json` (verify: `bun scripts/config/read-floor.mjs agents`); total = N × per-file floor | | `tree/guard.mjs` | context-tree nodes (AGENTS.md ≤500; no legacy CLAUDE.md; the root AGENTS.md routing table names every nested node and every row resolves; every backticked repo pointer — a `/` path whose first segment is a tracked top-level dir — resolves from the node's folder or the repo root) | pass/fail | | `plans/no-bespoke-gates.mjs` | shipped code carries no bespoke per-plan verification gate (an exported findings-accumulator that can pass vacuously) | pass/fail | | `config/read-floor.mjs` | reads per-file floors from `scoring.json` | — | @@ -71,14 +71,15 @@ Plugin behavior stays registry-driven: extend descriptor capabilities rather tha `tests/author-tooling.mjs` compares this table with the scripts on disk: a validator script without a row fails, and a row whose script is missing fails. A `lib/` module may have a row but does not need one. -`--per-file` prints `/ `. Total floors are count-derived (`artifact_count × per-file_floor`) — adding/removing an artifact moves the floor automatically. Per-file floors are the true gate. Skill frontmatter parsing uses Node + the npm `yaml` package installed by `bun install --frozen-lockfile`. +`--per-file` prints `/ `. Total floors are count-derived (`artifact_count × per-file_floor`) — adding/removing an artifact moves the floor automatically. Per-file floors are the true gate. Skill frontmatter parsing uses Bun + the npm `yaml` package installed by `bun install --frozen-lockfile`. -The `test:coverage` package command measures test coverage on demand. `scripts/ci.mjs` deliberately does not run it because coverage is a diagnostic, not a gate. +The `test:coverage` package command measures test coverage on demand. `scripts/ci.mjs` deliberately does not run it because coverage is a diagnostic, not a gate. It runs on Node (`node --test --experimental-test-coverage`), because Bun coverage does not count code in child processes, and `plan.mjs` runs in child processes. **Shared author-side libs (`scripts/lib/`):** `skills-walk.mjs` (SKILL.md traversal — `findSkillFiles`/`eachSkillDir`/`eachSkillCandidateDir`/`findSkillByName`) and `skills-parse.mjs` (frontmatter/body line helpers — `splitLines`/`bodyAfterFrontmatter`/`countLines`/`anyLine`/`slopCount`) are imported by the author-side validators so the walk + body-line method live once. The bundled `write-skill/scripts/skill-guard.mjs` keeps its OWN copies on purpose — it ships standalone into consumer repos. -`ci-background-task.mjs` owns asynchronous Node-task capture for `ci.mjs`. It -launches each background child through a separate short-lived Node process. That +`ci-background-task.mjs` owns asynchronous task capture for `ci.mjs`. It +launches each background child through a separate short-lived process of the +runtime that runs the gate (`process.execPath`), not specifically Node. That launcher measures its own child with one monotonic clock and owns both log files, so neither this gate's blocked event loop nor pipe backpressure can inflate a duration; each recorded duration is the real spawn-to-exit lifetime. A @@ -141,13 +142,13 @@ For example, an 8 GB, 6-core swapless host with tmpfs `/tmp` prints 2. Run focused checks while iterating. Once the relevant implementation tree is final, run the smallest authoritative gate the root `AGENTS.md` (Tool-agnostic rules) names. 3. Local Claude Code test (no push): `claude --plugin-dir ./plugins/` (then `/reload-plugins`). 4. PR to main → PR-CI gates the merge. -5. After merge, release **one plugin** with the generic positional command: `node scripts/release.mjs [--plugin ] patch|minor|major|` (`--dry-run` previews). +5. After merge, release **one plugin** with the generic positional command: `bun scripts/release.mjs [--plugin ] patch|minor|major|` (`--dry-run` previews). ## Generic ordinary-plugin release flow (double-layered gating) ```text -final implementation tree → node scripts/ci.mjs --plugin (LAYER 1 — local, selected plugin) - → node scripts/release.mjs [--plugin ] (one plugin) +final implementation tree → bun scripts/ci.mjs --plugin (LAYER 1 — local, selected plugin) + → bun scripts/release.mjs [--plugin ] (one plugin) ├── runs ci.mjs -q --plugin as the selected-plugin preflight ├── bumps THIS plugin's plugin.json (+ codex mirror) + its marketplace entry ├── commits + pushes (chore(release): v) @@ -161,14 +162,14 @@ The release tag, not the manifest number, is the fact that a version was release Every plugin uses this positional flow: bump resolution, local and tag CI gates, commit/push/tag, release notes, and a read-only dry run. -Pull-request sharding, manual dispatch, and tag-push CI behavior live in `.github/AGENTS.md`. PR sharding never touches the release path: tag CI runs the repo lane, then `node scripts/ci.mjs --plugin ` as the authoritative selected-plugin gate (`.github/AGENTS.md` (Trigger model)), and targeted `--plugin` runs skip the repo-wide sections. +Pull-request sharding, manual dispatch, and tag-push CI behavior live in `.github/AGENTS.md`. PR sharding never touches the release path: tag CI runs the repo lane, then `bun scripts/ci.mjs --plugin ` as the authoritative selected-plugin gate (`.github/AGENTS.md` (Trigger model)), and targeted `--plugin` runs skip the repo-wide sections. -Before `node scripts/release.mjs`, run the smallest authoritative gate for the final implementation tree: `node scripts/ci.mjs --plugin ` for one plugin and its descriptor-owned tooling, otherwise full `node scripts/ci.mjs`. The selected release path reruns the same plugin gate before mutation, and tag CI reruns it authoritatively after push. +Before `bun scripts/release.mjs`, run the smallest authoritative gate for the final implementation tree: `bun scripts/ci.mjs --plugin ` for one plugin and its descriptor-owned tooling, otherwise full `bun scripts/ci.mjs`. The selected release path reruns the same plugin gate before mutation, and tag CI reruns it authoritatively after push. ## Versioning -Versions are **per-plugin and independent** — each plugin bumps separately, and the Claude marketplace catalog holds one entry per plugin (matched by `name`). Within a single plugin, both its `plugin.json`s (`.claude-plugin/`, `.codex-plugin/`) and its marketplace entry carry a `version` that must agree — `release.mjs` keeps that plugin's triple in lockstep, and `ci.mjs`'s per-plugin gate fails on disagreement; `claude plugin tag` validates it too (verify: bump one manifest's version alone → `node scripts/ci.mjs --plugin ` must fail on the disagreement; revert). The Codex marketplace catalog has no plugin version field but is still validated for JSON shape. Without an explicit plugin `version`, every commit counts as a new "update" to consumers (noisy prompts), so always tag explicit semver bumps. Tag format: `--v`, with the double-dash separator from `claude plugin tag`. +Versions are **per-plugin and independent** — each plugin bumps separately, and the Claude marketplace catalog holds one entry per plugin (matched by `name`). Within a single plugin, both its `plugin.json`s (`.claude-plugin/`, `.codex-plugin/`) and its marketplace entry carry a `version` that must agree — `release.mjs` keeps that plugin's triple in lockstep, and `ci.mjs`'s per-plugin gate fails on disagreement; `claude plugin tag` validates it too (verify: bump one manifest's version alone → `bun scripts/ci.mjs --plugin ` must fail on the disagreement; revert). The Codex marketplace catalog has no plugin version field but is still validated for JSON shape. Without an explicit plugin `version`, every commit counts as a new "update" to consumers (noisy prompts), so always tag explicit semver bumps. Tag format: `--v`, with the double-dash separator from `claude plugin tag`. Pointers here name concepts, not coordinates — if a path or symbol moved, trust the stated purpose and re-locate it (grep the symbol) before acting. diff --git a/scripts/agents/guard.mjs b/scripts/agents/guard.mjs index 454c35d1..add6fe90 100644 --- a/scripts/agents/guard.mjs +++ b/scripts/agents/guard.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun // agents/guard.mjs — structural validation of agent // markdown. Usage: agents/guard.mjs [path-or-file] import fs from 'node:fs'; diff --git a/scripts/agents/score.mjs b/scripts/agents/score.mjs index 280d4933..ab946694 100644 --- a/scripts/agents/score.mjs +++ b/scripts/agents/score.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun // Mechanical agent quality scorer. Max per-file: 15. // Output: single total, or ` ` per agent with --per-file. // Parse helpers shared via scripts/lib/skills-parse.mjs. diff --git a/scripts/capture-tdd-red.mjs b/scripts/capture-tdd-red.mjs index 3fc09767..ab93197b 100755 --- a/scripts/capture-tdd-red.mjs +++ b/scripts/capture-tdd-red.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import { spawnSync } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; diff --git a/scripts/ci-target.mjs b/scripts/ci-target.mjs index 2a2cd638..0e084f1a 100755 --- a/scripts/ci-target.mjs +++ b/scripts/ci-target.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import { CI_LANES, parseReleaseTag, resolveShardSelection } from './lib/ci-targeting.mjs'; diff --git a/scripts/ci.mjs b/scripts/ci.mjs index a7127a2a..8015972b 100644 --- a/scripts/ci.mjs +++ b/scripts/ci.mjs @@ -1,10 +1,10 @@ -#!/usr/bin/env node +#!/usr/bin/env bun // ci.mjs — local mirror of .github/workflows/ci.yml. Run before releasing. // REGISTRY-DRIVEN: repo-wide checks run once, then every plugin in // scripts/lib/plugins.mjs is gated through the same capability-driven // gatePlugin() (a check runs only when the descriptor declares that capability). // Adding a plugin = one registry entry; no edits here. -// Usage: node scripts/ci.mjs [-q] [--memo] [--plugin | --lane ] [--timings-json ] [--list] +// Usage: bun scripts/ci.mjs [-q] [--memo] [--plugin | --lane ] [--timings-json ] [--list] import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -125,19 +125,25 @@ const runCommand = (id, [command, ...args], commandOptions = {}) => { }); } }; +// Author scripts run on Bun. plan-cli.mjs (which drives the shipped plan.mjs) and the +// shipped skill scorer run on Node: consumers invoke those files with `node`, so the +// gate must prove them there. const node = (args, options = {}) => runCommand(`node ${args.join(' ')}`, ['node', ...args], { encoding: 'utf8', ...options }); -// A failing check must name its own cause. Every `nodeOk` site asserts success, so a -// non-zero exit is always a defect worth reading: print the child's captured output +const bun = (args, options = {}) => + runCommand(`bun ${args.join(' ')}`, ['bun', ...args], { encoding: 'utf8', ...options }); +// A failing check must name its own cause. Every `nodeOk`/`bunOk` site asserts success, +// so a non-zero exit is always a defect worth reading: print the child's captured output // rather than only the caller's one-line summary. Without this, a check that passes // locally and fails on a runner is undiagnosable except by bisecting through CI. -const nodeOk = (args, options) => { - const result = node(args, options); +const succeededOrPrintOutput = (result) => { if ((result.status ?? 1) === 0) return true; const detail = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim(); if (detail) console.error(detail); return false; }; +const nodeOk = (args, options) => succeededOrPrintOutput(node(args, options)); +const bunOk = (args, options) => succeededOrPrintOutput(bun(args, options)); const readJSON = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); @@ -150,7 +156,7 @@ const floorCache = new Map(); const floorOf = (kind, cat) => { const key = `${kind}/${cat ?? ''}`; if (floorCache.has(key)) return floorCache.get(key); - const result = node(['scripts/config/read-floor.mjs', kind, ...(cat ? [cat] : [])]); + const result = bun(['scripts/config/read-floor.mjs', kind, ...(cat ? [cat] : [])]); const parsed = result.status === 0 ? parseInt(result.stdout.trim(), 10) : null; const floor = Number.isInteger(parsed) ? parsed : null; floorCache.set(key, floor); @@ -268,7 +274,7 @@ if (repoWide) { strict: true, uniqueKeys: true, }); - doc.errors.length ? fail(`${workflowPath} YAML invalid`) : ok(`${workflowPath} parses (node yaml)`); + doc.errors.length ? fail(`${workflowPath} YAML invalid`) : ok(`${workflowPath} parses (yaml package)`); } catch { fail(`${workflowPath} YAML invalid`); } @@ -281,30 +287,30 @@ if (repoWide) { else warn(`${CODEX_MARKETPLACE} missing — Codex distribution not configured (optional)`); section('repo-wide guards'); - nodeOk(['scripts/tree/guard.mjs']) + bunOk(['scripts/tree/guard.mjs']) ? ok('tree/guard passed (context-tree nodes)') - : fail("tree/guard failed (run 'node scripts/tree/guard.mjs')"); - nodeOk(['scripts/plans/no-bespoke-gates.mjs']) + : fail('tree/guard failed (run: bun scripts/tree/guard.mjs)'); + bunOk(['scripts/plans/no-bespoke-gates.mjs']) ? ok('plans/no-bespoke-gates passed (no bespoke per-plan verification gate)') - : fail("plans/no-bespoke-gates failed (run 'node scripts/plans/no-bespoke-gates.mjs')"); - nodeOk(['scripts/skills/durable-anchors.mjs']) + : fail('plans/no-bespoke-gates failed (run: bun scripts/plans/no-bespoke-gates.mjs)'); + bunOk(['scripts/skills/durable-anchors.mjs']) ? ok('durable-anchors passed (no live file:line anchors in long-lived docs)') - : fail("durable-anchors failed (run 'node scripts/skills/durable-anchors.mjs')"); - nodeOk(['scripts/tests/author-tooling.mjs']) + : fail('durable-anchors failed (run: bun scripts/skills/durable-anchors.mjs)'); + bunOk(['scripts/tests/author-tooling.mjs']) ? ok('author tooling contracts passed') - : fail('author tooling contracts failed (run: node scripts/tests/author-tooling.mjs)'); - nodeOk(['scripts/tests/ci-observability.mjs']) + : fail('author tooling contracts failed (run: bun scripts/tests/author-tooling.mjs)'); + bunOk(['scripts/tests/ci-observability.mjs']) ? ok('CI observability contracts passed') - : fail('CI observability contracts failed (run: node scripts/tests/ci-observability.mjs)'); - nodeOk(['scripts/tests/test-contracts.mjs']) + : fail('CI observability contracts failed (run: bun scripts/tests/ci-observability.mjs)'); + bunOk(['scripts/tests/test-contracts.mjs']) ? ok('test-contract registry passed') - : fail('test-contract registry failed (run: node scripts/tests/test-contracts.mjs)'); + : fail('test-contract registry failed (run: bun scripts/tests/test-contracts.mjs)'); (runCommand('bun run test:unit', ['bun', 'run', 'test:unit'], { encoding: 'utf8' }).status ?? 1) === 0 ? ok('unit tests passed') : fail('unit tests failed (run: bun run test:unit)'); section('CI targeting contract'); - const ciTargeting = node(['scripts/tests/ci-plugin-targeting.mjs', '--unit']); + const ciTargeting = bun(['scripts/tests/ci-plugin-targeting.mjs', '--unit']); if ((ciTargeting.status ?? 1) === 0) { ok('CI targeting, tag resolution, and cache contract passed'); } else { @@ -313,16 +319,16 @@ if (repoWide) { .filter(Boolean) .join('\n'); fail( - `CI targeting contract failed (run: node scripts/tests/ci-plugin-targeting.mjs --unit)${detail ? `\n${detail}` : ''}`, + `CI targeting contract failed (run: bun scripts/tests/ci-plugin-targeting.mjs --unit)${detail ? `\n${detail}` : ''}`, ); } } if (authorChecks.has('idempotency')) { section('skill-maintainer idempotency'); - nodeOk(['tests/idempotency.mjs']) + bunOk(['tests/idempotency.mjs']) ? ok('skill content_hash determinism; maintainer re-run is a no-op') - : fail('skill-maintainer idempotency failed (run: node tests/idempotency.mjs)'); + : fail('skill-maintainer idempotency failed (run: bun tests/idempotency.mjs)'); } // shell lint — shellHooks(p) collects each plugin's hooks/*.sh plus a rust @@ -331,7 +337,7 @@ if (authorChecks.has('idempotency')) { if (targets.length > 0) { section('shell lint'); const bashFiles = targets.flatMap(shellHooks); - if (bashFiles.length === 0) ok('no bash to lint (all tooling is Node .mjs)'); + if (bashFiles.length === 0) ok('no bash to lint (all tooling is .mjs)'); else { const shellcheck = runCommand( `shellcheck -S warning ${bashFiles.join(' ')}`, @@ -363,9 +369,9 @@ if (remainingCollisionTargets.length > 0) { if (collisionGroups.length > 0) { section('skill trigger collisions'); for (const { label, roots } of collisionGroups) { - nodeOk(['tests/skill-trigger-collision.mjs', ...roots]) + bunOk(['tests/skill-trigger-collision.mjs', ...roots]) ? ok(`${label} no unrouted high-overlap skill pair`) - : fail(`${label} trigger-collision (node tests/skill-trigger-collision.mjs ${roots.join(' ')})`); + : fail(`${label} trigger-collision (bun tests/skill-trigger-collision.mjs ${roots.join(' ')})`); } } @@ -428,9 +434,9 @@ function gatePlugin(p) { if (!fs.existsSync(p.agents)) { fail(`${p.name} declared agents root missing: ${p.agents}`); } else { - nodeOk(['scripts/agents/guard.mjs', p.agents]) + bunOk(['scripts/agents/guard.mjs', p.agents]) ? ok(`${p.name} agents/guard passed`) - : fail(`${p.name} agents/guard failed (run: node scripts/agents/guard.mjs ${p.agents})`); + : fail(`${p.name} agents/guard failed (run: bun scripts/agents/guard.mjs ${p.agents})`); const floor = floorOf('agents'); const agentFiles = fs .readdirSync(p.agents) @@ -438,8 +444,8 @@ function gatePlugin(p) { .sort(); const onDisk = agentFiles.map((name) => name.replace(/\.md$/u, '')); const scoreArgs = ['scripts/agents/score.mjs', '--per-file', p.agents]; - const scoreCommand = `node ${scoreArgs.join(' ')}`; - const scoreRun = node(scoreArgs); + const scoreCommand = `bun ${scoreArgs.join(' ')}`; + const scoreRun = bun(scoreArgs); if ((scoreRun.status ?? 1) !== 0) { const detail = `${scoreRun.stdout ?? ''}${scoreRun.stderr ?? ''}`.trim(); if (detail) console.error(detail); @@ -507,19 +513,19 @@ function gatePlugin(p) { } if (p.distributionContract) { - nodeOk([p.distributionContract]) + bunOk([p.distributionContract]) ? ok(`${p.name} distribution contract passed (${path.basename(p.distributionContract)})`) - : fail(`${p.name} distribution contract failed (run: node ${p.distributionContract})`); + : fail(`${p.name} distribution contract failed (run: bun ${p.distributionContract})`); } for (const check of p.sourceChecks ?? []) { const args = [check.path, ...(check.args ?? [])]; - const outcome = node(args); + const outcome = bun(args); const label = args.join(' '); if ((outcome.status ?? 1) !== 0) { const detail = `${outcome.stdout ?? ''}${outcome.stderr ?? ''}`.trim(); if (detail) console.error(detail); - fail(`${p.name} source check failed (run: node ${args.join(' ')})`); + fail(`${p.name} source check failed (run: bun ${args.join(' ')})`); continue; } // A source check may legitimately decline to run: a check whose host prerequisites are @@ -532,15 +538,15 @@ function gatePlugin(p) { } for (const contract of p.releaseContracts ?? []) { - nodeOk([contract]) + bunOk([contract]) ? ok(`${p.name} release contract passed (${path.basename(contract)})`) - : fail(`${p.name} release contract failed (run: node ${contract})`); + : fail(`${p.name} release contract failed (run: bun ${contract})`); } if (p.selftest) { - nodeOk([p.selftest]) + bunOk([p.selftest]) ? ok(`${p.name} self-test passed (${path.basename(p.selftest)})`) - : fail(`${p.name} self-test failed (run: node ${p.selftest})`); + : fail(`${p.name} self-test failed (run: bun ${p.selftest})`); } } @@ -560,20 +566,20 @@ function gateSkills(p, manifest) { } if (layoutOk) ok(`${p.name} skill categories declared in plugin.json all exist; no stray skills`); - nodeOk(['scripts/skills/guard.mjs', p.skills]) + bunOk(['scripts/skills/guard.mjs', p.skills]) ? ok(`${p.name} skill frontmatter valid`) - : fail(`${p.name} skill frontmatter invalid (node scripts/skills/guard.mjs ${p.skills})`); + : fail(`${p.name} skill frontmatter invalid (bun scripts/skills/guard.mjs ${p.skills})`); const naArgs = ['scripts/skills/no-author-scripts.mjs', p.skills, ...(p.agents ? [p.agents] : [])]; - nodeOk(naArgs) + bunOk(naArgs) ? ok(`${p.name} no shipped skill/agent names docks author scripts`) - : fail(`${p.name} names docks author scripts (node ${naArgs.join(' ')})`); - nodeOk(['scripts/skills/content-hash.mjs', '--check-only', p.skills]) + : fail(`${p.name} names docks author scripts (bun ${naArgs.join(' ')})`); + bunOk(['scripts/skills/content-hash.mjs', '--check-only', p.skills]) ? ok(`${p.name} skill content_hash in sync`) - : fail(`${p.name} skill content_hash drift (node scripts/skills/content-hash.mjs --backfill ${p.skills})`); + : fail(`${p.name} skill content_hash drift (bun scripts/skills/content-hash.mjs --backfill ${p.skills})`); if (p.transformGuard) - nodeOk(['scripts/skills/transform-guard.mjs']) + bunOk(['scripts/skills/transform-guard.mjs']) ? ok(`repo-wide transform-guard passed (owned by ${p.name})`) - : fail(`repo-wide transform-guard failed (node scripts/skills/transform-guard.mjs)`); + : fail(`repo-wide transform-guard failed (bun scripts/skills/transform-guard.mjs)`); // The scorer's stdout is the ONLY input to both loops below, so a scorer that // crashed, was killed, wrote a diagnostic to stderr, or silently skipped skills diff --git a/scripts/config/read-floor.mjs b/scripts/config/read-floor.mjs index 3acec366..0269f877 100644 --- a/scripts/config/read-floor.mjs +++ b/scripts/config/read-floor.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun // Read per-file score floor from scripts/config/scoring.json. // read-floor.mjs # categorized kinds (skills) // read-floor.mjs # flat kinds (agents) diff --git a/scripts/lib/ci-background-task.mjs b/scripts/lib/ci-background-task.mjs index 146432d3..b5852dec 100644 --- a/scripts/lib/ci-background-task.mjs +++ b/scripts/lib/ci-background-task.mjs @@ -14,7 +14,7 @@ export const RUN_ORIGIN_NS = process.hrtime.bigint(); // process, so the kernel schedules it independently of this loop. It measures its own child with one // monotonic clock and reports the lifetime; the parent only has to place that interval on its own // timeline. Verify: run a 1 s child behind a 2.5 s blocking `spawnSync` and read the recorded -// duration (`node --test scripts/tests/unit/ci-background-task.test.mjs`). +// duration (`bun test ./scripts/tests/unit/ci-background-task.test.mjs`). const LAUNCHER_SOURCE = [ "import { spawn } from 'node:child_process';", "import fs from 'node:fs';", diff --git a/scripts/lib/ci-targeting.mjs b/scripts/lib/ci-targeting.mjs index 8f8d7371..a2ead47d 100644 --- a/scripts/lib/ci-targeting.mjs +++ b/scripts/lib/ci-targeting.mjs @@ -396,7 +396,7 @@ export const AUTHORITATIVE_GATE_STEP_NAME = 'run the authoritative gate (scripts const PUSH_EVENT_TEST = /github\.event_name\s*\}\}"\s*=\s*"push"/; -// Every `node scripts/ci.mjs` invocation in the step's shell, each tagged with the +// Every `bun scripts/ci.mjs` invocation in the step's shell, each tagged with the // event branch that reaches it. Conditions are tracked as a stack so an invocation // added to the wrong leg - or to no leg at all - is visible rather than averaged away. export function extractCiGateInvocations(runScript) { @@ -424,14 +424,14 @@ export function extractCiGateInvocations(runScript) { stack.pop(); continue; } - const invoked = /^(.*?)node\s+scripts\/ci\.mjs\b(.*)$/.exec(line); + const invoked = /^(.*?)bun\s+scripts\/ci\.mjs\b(.*)$/.exec(line); if (invoked === null) continue; // Only environment assignments may precede the command. Anything else - a `printf` // that merely prints the text, a wrapper that swallows the exit status - means the // line names the gate without running it, which is the whole failure mode here. if (!/^([A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s"']*)\s+)*$/.test(invoked[1])) throw new Error( - `${AUTHORITATIVE_GATE_STEP_NAME} names scripts/ci.mjs behind \`${invoked[1].trim()}\`, which does not run it`, + `${AUTHORITATIVE_GATE_STEP_NAME} names bun scripts/ci.mjs behind \`${invoked[1].trim()}\`, which does not run it`, ); const eventFrames = stack.filter((frame) => PUSH_EVENT_TEST.test(frame.condition)); const pushFrames = eventFrames.filter((frame) => !frame.elseSide); @@ -446,7 +446,7 @@ export function extractCiGateInvocations(runScript) { }); } if (stack.length !== 0) throw new Error(`${AUTHORITATIVE_GATE_STEP_NAME} leaves ${stack.length} \`if\` unclosed`); - if (invocations.length === 0) throw new Error(`${AUTHORITATIVE_GATE_STEP_NAME} never invokes scripts/ci.mjs`); + if (invocations.length === 0) throw new Error(`${AUTHORITATIVE_GATE_STEP_NAME} never invokes bun scripts/ci.mjs`); return invocations; } @@ -458,7 +458,7 @@ export function assertAuthoritativeGateCoversReleasedBytes(runScript) { for (const invocation of invocations) { if (invocation.branch === 'unconditional') throw new Error( - `${AUTHORITATIVE_GATE_STEP_NAME} invokes scripts/ci.mjs outside any event branch: ${invocation.args}`, + `${AUTHORITATIVE_GATE_STEP_NAME} invokes bun scripts/ci.mjs outside any event branch: ${invocation.args}`, ); if (invocation.branch === 'non-push') { // Manual dispatch runs the whole gate; a narrowing flag here would silently drop diff --git a/scripts/lib/gate-memo.mjs b/scripts/lib/gate-memo.mjs index 17e23d45..d5dfe45a 100644 --- a/scripts/lib/gate-memo.mjs +++ b/scripts/lib/gate-memo.mjs @@ -357,7 +357,7 @@ export function renderGateCost(report, { memoRequested = false, top = 3, scopes return ' Working tree is clean, so no targeted scope is cheaper; --memo is the only lever left (opt-in: a memo that enables itself can hide a stale pass).'; } if (scopes.plugins.length === 1) { - return ` Cheaper next time: node scripts/ci.mjs --plugin ${scopes.plugins[0]} — the only plugin this working tree touches.`; + return ` Cheaper next time: bun scripts/ci.mjs --plugin ${scopes.plugins[0]} — the only plugin this working tree touches.`; } return ` This working tree touches ${scopes.plugins.length} plugins (${scopes.plugins.join(', ')}); one --plugin run covers one of them, so no single invocation is both cheaper and complete.`; }; diff --git a/scripts/lib/plugin-release.mjs b/scripts/lib/plugin-release.mjs index 1efe5372..b0155969 100644 --- a/scripts/lib/plugin-release.mjs +++ b/scripts/lib/plugin-release.mjs @@ -365,10 +365,10 @@ export async function runGenericPluginRelease({ argv, repo, plugins, io }) { io.log('To recover:'); io.log(` 1. Investigate: gh run view ${runId} --log-failed`); io.log(' 2. Fix on a follow-up commit, then either:'); - io.log(` a) bump version again: node scripts/release.mjs --plugin ${plugin.name} patch`); + io.log(` a) bump version again: bun scripts/release.mjs --plugin ${plugin.name} patch`); io.log(' b) or move the tag (loses immutability):'); io.log( - ` git tag -d ${tag} && git push origin :refs/tags/${tag} && node scripts/release.mjs --plugin ${plugin.name} ${newVersion}`, + ` git tag -d ${tag} && git push origin :refs/tags/${tag} && bun scripts/release.mjs --plugin ${plugin.name} ${newVersion}`, ); throw reportedFailure(`CI failed for ${tag}`); } @@ -473,7 +473,7 @@ export function createGenericPluginReleaseIo({ repo, plugins }) { return capture('git', ['rev-parse', `${tag}^{commit}`]).stdout.trim(); }, runSelectedCi(_plugin, ciArgs) { - return spawnSync('node', [path.join(repo, 'scripts/ci.mjs'), ...ciArgs], { stdio: 'inherit' }); + return spawnSync('bun', [path.join(repo, 'scripts/ci.mjs'), ...ciArgs], { stdio: 'inherit' }); }, // Reaching origin is IO, so it belongs in the closed set: a caller that cannot stub it // drags the network into every test of the surrounding decision. Behaviour is unchanged. diff --git a/scripts/lib/validate-skills.mjs b/scripts/lib/validate-skills.mjs index 0a019484..65a87daf 100755 --- a/scripts/lib/validate-skills.mjs +++ b/scripts/lib/validate-skills.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import { parseDocument } from 'yaml'; diff --git a/scripts/plans/no-bespoke-gates.mjs b/scripts/plans/no-bespoke-gates.mjs index a0caae4c..d1259fcb 100755 --- a/scripts/plans/no-bespoke-gates.mjs +++ b/scripts/plans/no-bespoke-gates.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun // Guard: no bespoke per-plan verification gate. // // A plan invented an "accepted-class sweep": a gate with its own ledger schema, digest diff --git a/scripts/release.mjs b/scripts/release.mjs index 58d4a429..dfdb58e2 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -1,8 +1,8 @@ -#!/usr/bin/env node +#!/usr/bin/env bun // release.mjs — dispatch the generic plugin release lane. // // Generic positional lane (Docks and Plan Lifecycle): -// node scripts/release.mjs [--dry-run] [--plugin ] patch|minor|major| +// bun scripts/release.mjs [--dry-run] [--plugin ] patch|minor|major| // (--plugin defaults to "docks") // // The shared dispatcher validates every closed release policy before running a diff --git a/scripts/skills/codex-facts.mjs b/scripts/skills/codex-facts.mjs index 61e7d4e6..39d743e0 100644 --- a/scripts/skills/codex-facts.mjs +++ b/scripts/skills/codex-facts.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun // codex-facts.mjs — pin the Codex platform facts asserted by // skill-agent-pipeline to hard-coded sets. It does not fetch the Codex docs: // when the docs change, update codex-agents-builder.md and these sets together. diff --git a/scripts/skills/content-hash.mjs b/scripts/skills/content-hash.mjs index 2c0c1c4d..70a4eac6 100644 --- a/scripts/skills/content-hash.mjs +++ b/scripts/skills/content-hash.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import crypto from 'node:crypto'; // content-hash.mjs — deterministic skill content hash. // The hash covers a skill's MEANING: normalized frontmatter (excluding the diff --git a/scripts/skills/durable-anchors.mjs b/scripts/skills/durable-anchors.mjs index 74f40125..a54109f9 100644 --- a/scripts/skills/durable-anchors.mjs +++ b/scripts/skills/durable-anchors.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun // durable-anchors.mjs — long-lived artifacts (shipped skill bodies/references, // AGENTS.md context nodes) must not carry LIVE file:line anchors: a `path:NN` // whose path resolves in this repo rots on the next edit above that line and diff --git a/scripts/skills/guard.mjs b/scripts/skills/guard.mjs index 0a10ae75..9366e7cb 100644 --- a/scripts/skills/guard.mjs +++ b/scripts/skills/guard.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun // guard.mjs — run the combined skill frontmatter validator, the Codex-fact // drift guard, and the reference-hygiene guard. Usage: guard.mjs [skills-dir] import { spawnSync } from 'node:child_process'; @@ -9,10 +9,14 @@ const REPO_DIR = path.resolve(SCRIPT_DIR, '../..'); const DIR = process.argv[2] || ''; function run(args) { - const result = spawnSync('node', args, { stdio: 'inherit' }); - const command = `node ${args.join(' ')}`; + const result = spawnSync('bun', args, { stdio: 'inherit' }); + const command = `bun ${args.join(' ')}`; if (result.error) { - console.error(`FAIL: ${command} could not start: ${result.error.message}`); + // Lead with the errno code: it is the same on every runtime, while the message text is + // not (Node prints `spawnSync bun ENOENT`; Bun prints `Executable not found in $PATH`). + console.error( + `FAIL: ${command} could not start: ${result.error.code ?? 'unknown error'} (${result.error.message})`, + ); process.exit(1); } if (result.signal !== null || result.status === null) { diff --git a/scripts/skills/no-author-scripts.mjs b/scripts/skills/no-author-scripts.mjs index 5c6638b7..6719f0c6 100644 --- a/scripts/skills/no-author-scripts.mjs +++ b/scripts/skills/no-author-scripts.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun // no-author-scripts.mjs — shipped skill/agent // bodies must not name docks plugin-author scripts as steps a consumer runs. // Scope: SKILL.md + references/*.md + agents/*.md. Allowlist: tooling-authoring diff --git a/scripts/skills/refs-guard.mjs b/scripts/skills/refs-guard.mjs index 5d63cb50..b9925d53 100644 --- a/scripts/skills/refs-guard.mjs +++ b/scripts/skills/refs-guard.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun // refs-guard.mjs — reference-hygiene guard for skills. Three checks the // mechanical scorer and the YAML guards don't cover: // @@ -19,7 +19,7 @@ // (`docs/plans/_assets/…`) are NOT local links and are correctly ignored — only // link targets relative to THIS skill's dir are checked. // -// Usage: node scripts/skills/refs-guard.mjs [skills-root ...] +// Usage: bun scripts/skills/refs-guard.mjs [skills-root ...] import fs from 'node:fs'; import path from 'node:path'; import { findSkillFiles } from '../lib/skills-walk.mjs'; diff --git a/scripts/skills/transform-guard.mjs b/scripts/skills/transform-guard.mjs index ff69484b..63f15b61 100644 --- a/scripts/skills/transform-guard.mjs +++ b/scripts/skills/transform-guard.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun // transform-guard.mjs — curated content-transforming // skills must carry a preservation + a "## Verification" block, so a // future edit can't silently strip a transforming skill's data-loss guard. diff --git a/scripts/tests/author-tooling.mjs b/scripts/tests/author-tooling.mjs index 57c5d971..fa226bf1 100755 --- a/scripts/tests/author-tooling.mjs +++ b/scripts/tests/author-tooling.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; @@ -243,7 +243,7 @@ function testSkillsGuardSpawnFailure() { assert.match( result.stderr, new RegExp( - `^FAIL: node ${path.join(ROOT, 'scripts/lib/validate-skills.mjs')} --runtime all ${target} could not start: .*ENOENT`, + `^FAIL: bun ${path.join(ROOT, 'scripts/lib/validate-skills.mjs')} --runtime all ${target} could not start: ENOENT \\(`, ), ); } diff --git a/scripts/tests/ci-observability.mjs b/scripts/tests/ci-observability.mjs index 53c532e5..a02a198a 100755 --- a/scripts/tests/ci-observability.mjs +++ b/scripts/tests/ci-observability.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; diff --git a/scripts/tests/ci-plugin-targeting.mjs b/scripts/tests/ci-plugin-targeting.mjs index 6c05a87b..3f16be2f 100755 --- a/scripts/tests/ci-plugin-targeting.mjs +++ b/scripts/tests/ci-plugin-targeting.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import assert from 'node:assert/strict'; import { execFile, spawnSync } from 'node:child_process'; import fs from 'node:fs'; @@ -328,14 +328,14 @@ const passThroughGit = shape.length === args.length && shape.every((token, index) => token === null || token === args[index]), ) && !args.includes('-w'); -// The release runs the selected-plugin gate through \`node scripts/ci.mjs\`. Executing it here +// The release runs the selected-plugin gate through \`bun scripts/ci.mjs\`. Executing it here // would spawn a descendant with the real PATH, and every git call that descendant makes would -// escape this shim and its log, so the one Node argv the release is allowed to use is stubbed +// escape this shim and its log, so the one Bun argv the release is allowed to use is stubbed // green instead. The gate is covered by the gate's own CI job. With no descendant left, the // three shapes above are the only way any git command runs at all, and anything else exits 97. const ciStub = ['-q', '--plugin']; const isSelectedCiGate = - tool === 'node' && + tool === 'bun' && args.length === 4 && args[0].endsWith('/scripts/ci.mjs') && args[1] === ciStub[0] && @@ -380,10 +380,10 @@ if (tool === 'node' && args[0] === 'plugins/docks/skills/productivity/write-skil } if (rows.length) process.stdout.write(\`\${rows.join('\\n')}\\n\`); } -if (tool === 'node' && args[0] === 'scripts/agents/score.mjs' && args[1] === '--per-file') { +if (tool === 'bun' && args[0] === 'scripts/agents/score.mjs' && args[1] === '--per-file') { process.stdout.write('code-reviewer 14\\nplan-reviewer 14\\n'); } -if (tool === 'node' && args[0] === 'scripts/config/read-floor.mjs') process.stdout.write('10\\n'); +if (tool === 'bun' && args[0] === 'scripts/config/read-floor.mjs') process.stdout.write('10\\n'); process.exit(0); `; fs.writeFileSync(path.join(directory, name), script, { mode: 0o755 }); @@ -471,10 +471,10 @@ async function testFocusedCiCommandSelection() { timingPath, }; }; - const invokesNode = (calls, script, expectedArg = null) => + const invokesBun = (calls, script, expectedArg = null) => calls.some( ({ tool, args: callArgs }) => - tool === 'node' && callArgs[0] === script && (expectedArg === null || callArgs.includes(expectedArg)), + tool === 'bun' && callArgs[0] === script && (expectedArg === null || callArgs.includes(expectedArg)), ); const countToolInvocation = (calls, tool, expectedArgs) => calls.filter( @@ -518,7 +518,7 @@ async function testFocusedCiCommandSelection() { assert.equal(targeted.result.status, 0, `${targeted.result.stdout}\n${targeted.result.stderr}`); for (const script of repoWideCommands) { assert.equal( - invokesNode(targeted.calls, script), + invokesBun(targeted.calls, script), false, `targeted CI must not invoke repo-wide command ${script}`, ); @@ -530,7 +530,7 @@ async function testFocusedCiCommandSelection() { /workflow YAML|marketplace catalogs|repo-wide guards|CI targeting contract/, ); assert.equal( - invokesNode(targeted.calls, 'scripts/skills/guard.mjs', 'plugins/plan-lifecycle/skills'), + invokesBun(targeted.calls, 'scripts/skills/guard.mjs', 'plugins/plan-lifecycle/skills'), true, 'targeted CI must retain the selected plugin gate', ); @@ -540,9 +540,9 @@ async function testFocusedCiCommandSelection() { const docksCollisionArgv = ['tests/skill-trigger-collision.mjs', 'plugins/docks/skills']; const planLifecycleCollisionArgv = ['tests/skill-trigger-collision.mjs', 'plugins/plan-lifecycle/skills']; - assert.equal(countToolInvocation(targeted.calls, 'node', planLifecycleCollisionArgv), 1); + assert.equal(countToolInvocation(targeted.calls, 'bun', planLifecycleCollisionArgv), 1); assert.equal( - countToolInvocation(targeted.calls, 'node', docksCollisionArgv), + countToolInvocation(targeted.calls, 'bun', docksCollisionArgv), 0, 'a Plan Lifecycle target must not run the Docks trigger-collision contract', ); @@ -559,7 +559,7 @@ async function testFocusedCiCommandSelection() { `${ciArgs.length === 0 ? 'full' : 'Docks-targeted'} CI must run the plan CLI contract once`, ); assert.equal( - countToolInvocation(selected.calls, 'node', docksCollisionArgv), + countToolInvocation(selected.calls, 'bun', docksCollisionArgv), 1, `${ciArgs.length === 0 ? 'full' : 'Docks-targeted'} CI must audit Docks once`, ); @@ -588,16 +588,16 @@ async function testFocusedCiCommandSelection() { /workflow YAML|marketplace catalogs|repo-wide guards|CI targeting contract/, ); for (const script of repoWideCommands) { - assert.equal(invokesNode(core.calls, script), false, `core CI must not invoke repo-wide command ${script}`); + assert.equal(invokesBun(core.calls, script), false, `core CI must not invoke repo-wide command ${script}`); } assert.match(core.result.stdout, /plan orchestration/); assert.match(core.result.stdout, /plugin: docks/); assert.match(core.result.stdout, /plugin: plan-lifecycle/); assert.doesNotMatch(core.result.stdout, /partition passed/); assert.equal(countToolInvocation(core.calls, 'node', planCliArgv), 1); - assert.equal(countToolInvocation(core.calls, 'node', docksCollisionArgv), 1); - assert.equal(countToolInvocation(core.calls, 'node', planLifecycleCollisionArgv), 1); - assert.equal(countToolInvocation(core.calls, 'node', ['plugins/plan-lifecycle/test/selftest.mjs']), 1); + assert.equal(countToolInvocation(core.calls, 'bun', docksCollisionArgv), 1); + assert.equal(countToolInvocation(core.calls, 'bun', planLifecycleCollisionArgv), 1); + assert.equal(countToolInvocation(core.calls, 'bun', ['plugins/plan-lifecycle/test/selftest.mjs']), 1); assert.equal(timedCore.result.status, 0, `${timedCore.result.stdout}\n${timedCore.result.stderr}`); assert.equal(countToolInvocation(timedCore.calls, 'bun', ['run', 'check:js']), 0); @@ -644,13 +644,13 @@ async function testFocusedCiCommandSelection() { ['scripts/config/read-floor.mjs', 'agents'], ]) { assert.equal( - countToolInvocation(core.calls, 'node', floorArgs), + countToolInvocation(core.calls, 'bun', floorArgs), 1, `core CI must read floor ${floorArgs.slice(1).join('/')} exactly once; observed ${JSON.stringify(observedFloorCalls)}`, ); } assert.equal( - countToolInvocation(core.calls, 'node', [ + countToolInvocation(core.calls, 'bun', [ 'scripts/agents/score.mjs', '--per-file', 'plugins/plan-lifecycle/agents', @@ -659,12 +659,12 @@ async function testFocusedCiCommandSelection() { 'core CI must launch one per-file agent score command', ); assert.equal( - countToolInvocation(core.calls, 'node', ['scripts/agents/score.mjs', 'plugins/plan-lifecycle/agents']), + countToolInvocation(core.calls, 'bun', ['scripts/agents/score.mjs', 'plugins/plan-lifecycle/agents']), 0, 'core CI must derive the agent total without a second score command', ); assert.equal( - countToolInvocation(core.calls, 'node', ['scripts/agents/score.mjs', '--per-file', 'plugins/docks/agents']), + countToolInvocation(core.calls, 'bun', ['scripts/agents/score.mjs', '--per-file', 'plugins/docks/agents']), 0, 'docks no longer ships agents, so core CI must not score a docks agents dir', ); @@ -674,9 +674,9 @@ async function testFocusedCiCommandSelection() { // this census it stops running on a pull request that skips both plugin shards. assert.equal(repoWide.result.status, 0, `${repoWide.result.stdout}\n${repoWide.result.stderr}`); for (const script of repoWideCommands) { - assert.equal(invokesNode(repoWide.calls, script), true, `repo CI must invoke repo-wide command ${script}`); + assert.equal(invokesBun(repoWide.calls, script), true, `repo CI must invoke repo-wide command ${script}`); } - assert.equal(countToolInvocation(repoWide.calls, 'node', ['scripts/plans/no-bespoke-gates.mjs']), 1); + assert.equal(countToolInvocation(repoWide.calls, 'bun', ['scripts/plans/no-bespoke-gates.mjs']), 1); assert.equal(countToolInvocation(repoWide.calls, 'bun', ['run', 'test:unit']), 1); assert.doesNotMatch(repoWide.result.stdout, /plugin: docks|plugin: plan-lifecycle/); assert.equal(countToolInvocation(repoWide.calls, 'bun', ['run', 'check:js']), 0); @@ -809,7 +809,7 @@ function genericReleaseIo(repo, options = {}) { return 'a'.repeat(40); }, runSelectedCi(plugin, ciArgs) { - record('node', [path.join(repo, 'scripts/ci.mjs'), ...ciArgs]); + record('bun', [path.join(repo, 'scripts/ci.mjs'), ...ciArgs]); assert.equal(plugin.name, ciArgs.at(-1)); return { status: 0, stdout: '', stderr: '' }; }, @@ -904,7 +904,7 @@ async function testGenericReleaseModuleContract( assert.ok( fixture.calls.some( ({ tool, args: callArgs }) => - tool === 'node' && + tool === 'bun' && callArgs[0] === path.join(ROOT, 'scripts/ci.mjs') && callArgs.slice(1).join(' ') === `-q --plugin ${plugin.name}`, ), @@ -1364,7 +1364,7 @@ async function testDryRunReleaseSafety() { fs.mkdirSync(shimDir, { mode: 0o700 }); fs.writeFileSync(callLog, '', { mode: 0o600 }); try { - for (const name of ['node', 'git', 'claude', 'gh']) writeReleaseShim(shimDir, name); + for (const name of ['node', 'bun', 'git', 'claude', 'gh']) writeReleaseShim(shimDir, name); const result = spawnSync(process.execPath, ['scripts/release.mjs', '--dry-run', '--plugin', 'docks', 'patch'], { cwd: ROOT, encoding: 'utf8', @@ -1390,7 +1390,7 @@ async function testDryRunReleaseSafety() { assert.ok( calls.some( ({ tool, args: callArgs }) => - tool === 'node' && + tool === 'bun' && callArgs[0] === path.join(ROOT, 'scripts/ci.mjs') && callArgs.slice(1).join(' ') === '-q --plugin docks', ), @@ -1398,11 +1398,18 @@ async function testDryRunReleaseSafety() { ); // The structural safety claim: the gate argv is stubbed green, so no descendant process // exists with the real PATH, and therefore every git call the release makes had to match - // one of the three read-only shapes to run at all. A second Node argv would break that. + // one of the three read-only shapes to run at all. A second Bun argv would break that. The + // release runs nothing on Node, so the Node shim exists only to make a spawn that drifted + // back to it visible here. assert.deepEqual( - calls.filter(({ tool }) => tool === 'node').map(({ args: callArgs }) => callArgs.slice(1).join(' ')), + calls.filter(({ tool }) => tool === 'bun').map(({ args: callArgs }) => callArgs.slice(1).join(' ')), ['-q --plugin docks'], - 'the release may run exactly one Node argv, the stubbed selected-plugin gate', + 'the release may run exactly one Bun argv, the stubbed selected-plugin gate', + ); + assert.deepEqual( + calls.filter(({ tool }) => tool === 'node'), + [], + 'the release must spawn no Node argv at all', ); assert.ok( calls.some(({ tool, args: callArgs }) => tool === 'git' && callArgs[0] === 'hash-object'), @@ -1483,7 +1490,7 @@ async function testDryRunReleaseSafety() { assert.ok( pluginCalls.some( ({ tool, args: callArgs }) => - tool === 'node' && + tool === 'bun' && callArgs[0] === path.join(ROOT, 'scripts/ci.mjs') && callArgs.slice(1).join(' ') === `-q --plugin ${pluginName}`, ), @@ -1931,7 +1938,7 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'docks-ci-targeting-')); try { const githubOutput = path.join(tmp, 'github-output'); const cli = spawnSync( - 'node', + process.execPath, ['scripts/ci-target.mjs', 'release-tag', 'plan-lifecycle--v0.11.2', '--github-output', githubOutput], { cwd: ROOT, encoding: 'utf8' }, ); @@ -1939,7 +1946,7 @@ try { assert.equal(cli.stdout, ''); assert.equal(fs.readFileSync(githubOutput, 'utf8'), 'mode=targeted\nplugin=plan-lifecycle\n'); - const malformed = spawnSync('node', ['scripts/ci-target.mjs', 'release-tag', 'docks--v1.2.3;echo-owned'], { + const malformed = spawnSync(process.execPath, ['scripts/ci-target.mjs', 'release-tag', 'docks--v1.2.3;echo-owned'], { cwd: ROOT, encoding: 'utf8', }); @@ -1971,18 +1978,22 @@ try { RUNNER_ARCH: 'X64', }; const [targeted, hosted] = await Promise.all([ - execFileResult('node', ['scripts/ci.mjs', '--plugin', 'plan-lifecycle', '--timings-json', timingPath], { + execFileResult(process.execPath, ['scripts/ci.mjs', '--plugin', 'plan-lifecycle', '--timings-json', timingPath], { cwd: ROOT, encoding: 'utf8', env: hostFreeEnv, timeout: 120_000, }), - execFileResult('node', ['scripts/ci.mjs', '--plugin', 'plan-lifecycle', '--timings-json', hostedTimingPath], { - cwd: ROOT, - encoding: 'utf8', - env: hostedEnv, - timeout: 120_000, - }), + execFileResult( + process.execPath, + ['scripts/ci.mjs', '--plugin', 'plan-lifecycle', '--timings-json', hostedTimingPath], + { + cwd: ROOT, + encoding: 'utf8', + env: hostedEnv, + timeout: 120_000, + }, + ), ]); assert.equal(targeted.status, 0, `${targeted.stdout}\n${targeted.stderr}`); assert.doesNotMatch(targeted.stdout, /skill-maintainer idempotency|plugin: docks/); @@ -2173,7 +2184,7 @@ for (const name of [ assert.equal(shardSteps[0].with['persist-credentials'], false); assert.equal(shardStep('setup Node 24').with['node-version'], '24'); const shardGateRun = shardStep('run validation lane').run; -assert.match(shardGateRun, /^node scripts\/ci\.mjs --lane "\$\{\{ matrix\.lane \}\}" --timings-json /u); +assert.match(shardGateRun, /^bun scripts\/ci\.mjs --lane "\$\{\{ matrix\.lane \}\}" --timings-json /u); const targetingJob = validation.jobs['targeting-contracts']; assert.deepEqual(Object.keys(targetingJob), ['name', 'if', 'permissions', 'runs-on', 'timeout-minutes', 'steps']); @@ -2221,7 +2232,7 @@ assert.deepEqual(targetingSteps[0], shardSteps[0]); assert.deepEqual(targetingStep('run non-unit plugin-targeting contracts'), { name: 'run non-unit plugin-targeting contracts', run: - `node scripts/tests/ci-plugin-targeting.mjs\n` + + `bun scripts/tests/ci-plugin-targeting.mjs\n` + `printf '{"step":"run non-unit plugin-targeting contracts","at_ms":%s}\\n'` + ` "$(date +%s%3N)" >> "$RUNNER_TEMP/docks-hosted-timings.jsonl"\n`, }); @@ -2326,12 +2337,12 @@ assert.match(authoritativeGateRun, /if \[ "\$\{\{ github\.event_name \}\}" = "pu // asserting different things. const gateCoverage = assertAuthoritativeGateCoversReleasedBytes(authoritativeGateRun); assert.equal(gateCoverage.pushLegs, 1, 'the single release-tag push leg must be covered'); -assert.match(authoritativeGateRun, /DOCKS_CI_MEMO=0 node scripts\/ci\.mjs --lane repo/); -assert.match(authoritativeGateRun, /node scripts\/ci\.mjs --plugin "\$\{\{ steps\.target\.outputs\.plugin \}\}"/); +assert.match(authoritativeGateRun, /DOCKS_CI_MEMO=0 bun scripts\/ci\.mjs --lane repo/); +assert.match(authoritativeGateRun, /bun scripts\/ci\.mjs --plugin "\$\{\{ steps\.target\.outputs\.plugin \}\}"/); // Each mutation is a way the release path could stop gating the bytes it publishes. // They run here so the assertion above is proven load-bearing rather than merely present. -const repoLaneCall = 'DOCKS_CI_MEMO=0 node scripts/ci.mjs --lane repo'; -const untargetedCall = 'node scripts/ci.mjs --timings-json "$RUNNER_TEMP/docks-ci-timings.json"'; +const repoLaneCall = 'DOCKS_CI_MEMO=0 bun scripts/ci.mjs --lane repo'; +const untargetedCall = 'bun scripts/ci.mjs --timings-json "$RUNNER_TEMP/docks-ci-timings.json"'; for (const [label, mutated, expected] of [ [ 'the push leg does not run the repo-wide shard', @@ -2353,7 +2364,7 @@ for (const [label, mutated, expected] of [ ], [ 'the repo-wide shard may be satisfied by a cached memo', - authoritativeGateRun.replace(repoLaneCall, 'node scripts/ci.mjs --lane repo'), + authoritativeGateRun.replace(repoLaneCall, 'bun scripts/ci.mjs --lane repo'), /DOCKS_CI_MEMO=0/, ], [ @@ -2364,14 +2375,14 @@ for (const [label, mutated, expected] of [ [ 'the targeted plugin gate no longer has the last word', authoritativeGateRun.replace( - /( *)DOCKS_CI_MEMO=0 node scripts\/ci\.mjs --lane repo\n( *)(node scripts\/ci\.mjs --plugin [^\n]*\n)/, + /( *)DOCKS_CI_MEMO=0 bun scripts\/ci\.mjs --lane repo\n( *)(bun scripts\/ci\.mjs --plugin [^\n]*\n)/, (_matched, repoIndent, pluginIndent, pluginCall) => `${pluginIndent}${pluginCall}${repoIndent}${repoLaneCall}\n`, ), /last word/, ], [ 'the non-push leg is narrowed to one plugin', - authoritativeGateRun.replace(untargetedCall, 'node scripts/ci.mjs --plugin docks'), + authoritativeGateRun.replace(untargetedCall, 'bun scripts/ci.mjs --plugin docks'), /must run the untargeted gate/, ], ]) { @@ -2685,7 +2696,7 @@ function testReleaseShimRefusalMatrix() { try { const callLog = path.join(shimRoot, 'calls.jsonl'); fs.writeFileSync(callLog, '', { mode: 0o600 }); - for (const name of ['node', 'git', 'claude', 'gh']) writeReleaseShim(shimRoot, name); + for (const name of ['node', 'bun', 'git', 'claude', 'gh']) writeReleaseShim(shimRoot, name); const invoke = (tool, args, options = {}) => spawnSync(path.join(shimRoot, tool), args, { cwd: ROOT, @@ -2714,7 +2725,7 @@ function testReleaseShimRefusalMatrix() { // the real PATH, whose git calls would bypass this shim entirely. The script path below // must not exist, so a regression back to executing this argv fails to load and is caught // here rather than quietly reintroducing the descendant. - ['node', [path.join(ROOT, 'no-such-directory/scripts/ci.mjs'), '-q', '--plugin', 'docks'], {}], + ['bun', [path.join(ROOT, 'no-such-directory/scripts/ci.mjs'), '-q', '--plugin', 'docks'], {}], ]; for (const [tool, args, options] of allowed) { const result = invoke(tool, args, options); @@ -2727,8 +2738,11 @@ function testReleaseShimRefusalMatrix() { ['git', ['update-ref', 'refs/heads/main', 'HEAD']], // A near miss must refuse too: the shapes match argument for argument, not by subcommand. ['git', ['status']], - ['node', ['-e', 'process.exit(0)']], - ['node', [path.join(ROOT, 'scripts/ci.mjs'), '--plugin', 'docks']], + ['bun', ['-e', 'process.exit(0)']], + ['bun', [path.join(ROOT, 'scripts/ci.mjs'), '--plugin', 'docks']], + // The gate shape is stubbed only on the runtime the release runs it under; the same argv + // on Node is refused like any other. + ['node', [path.join(ROOT, 'no-such-directory/scripts/ci.mjs'), '-q', '--plugin', 'docks']], ['claude', ['plugin', 'tag', '--push']], ['gh', ['release', 'create', 'docks--v9.9.9']], ]; diff --git a/scripts/tests/fixtures/gh-stub/gh b/scripts/tests/fixtures/gh-stub/gh index cd8e7fb0..786fa3ea 100755 --- a/scripts/tests/fixtures/gh-stub/gh +++ b/scripts/tests/fixtures/gh-stub/gh @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun const fs = require('node:fs'); const statePath = process.env.GH_STUB_STATE; diff --git a/scripts/tests/test-contracts.mjs b/scripts/tests/test-contracts.mjs index b2f99917..4958ebda 100755 --- a/scripts/tests/test-contracts.mjs +++ b/scripts/tests/test-contracts.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -153,7 +153,8 @@ function unitDescriptors(packageJson) { .sort(); const unitCommand = packageJson.scripts?.['test:unit']; assert.equal(typeof unitCommand, 'string', 'package.json must declare scripts.test:unit'); - const executesUnitGlob = unitCommand.includes('scripts/tests/unit/*.test.mjs'); + // Only Bun's test runner registers the files' test() calls; `bun ` would run one file as a plain script. + const executesUnitGlob = /^bun test\b/u.test(unitCommand) && unitCommand.includes('./scripts/tests/unit/*.test.mjs'); return unitFiles.map((file) => { const suite = `scripts/tests/unit/${file}`; const source = fs.readFileSync(path.join(REPO, suite), 'utf8'); @@ -208,7 +209,7 @@ function executableCodePositions(source) { function gateInvocationArguments(ciSource) { const bySuite = new Map(); const codePositions = executableCodePositions(ciSource); - const invocationPattern = /\bnode(?:Ok)?\(\[\s*['"](?[^'"]+\.mjs)['"](?[^[]*?)\]\)/gu; + const invocationPattern = /\b(?:node|bun)(?:Ok)?\(\[\s*['"](?[^'"]+\.mjs)['"](?[^[]*?)\]\)/gu; for (const match of ciSource.matchAll(invocationPattern)) { if (codePositions[match.index] !== 1) continue; const args = [...match.groups.args.matchAll(/,\s*(['"])([^'"]*)\1/gu)].map((argument) => argument[2]); diff --git a/scripts/tests/unit/agent-score-vacuity.test.mjs b/scripts/tests/unit/agent-score-vacuity.test.mjs index f0469343..a8e248ad 100644 --- a/scripts/tests/unit/agent-score-vacuity.test.mjs +++ b/scripts/tests/unit/agent-score-vacuity.test.mjs @@ -31,7 +31,7 @@ import path from 'node:path'; const tool = ${JSON.stringify(name)}; const args = process.argv.slice(2); if (tool === 'claude') process.stdout.write('Validation passed\\n'); -if (tool === 'node' && args[0] === 'scripts/config/read-floor.mjs') process.stdout.write('10\\n'); +if (tool === 'bun' && args[0] === 'scripts/config/read-floor.mjs') process.stdout.write('10\\n'); if (tool === 'node' && args[0] === 'plugins/docks/skills/productivity/write-skill/scripts/skill-guard.mjs' && args[1] === 'score') { const root = args.at(-1); const rows = []; @@ -47,7 +47,7 @@ if (tool === 'node' && args[0] === 'plugins/docks/skills/productivity/write-skil } if (rows.length) process.stdout.write(\`\${rows.join('\\n')}\\n\`); } -if (tool === 'node' && args[0] === 'scripts/agents/score.mjs' && args[1] === '--per-file') { +if (tool === 'bun' && args[0] === 'scripts/agents/score.mjs' && args[1] === '--per-file') { const root = args.at(-1); const rows = fs .readdirSync(root) @@ -72,7 +72,9 @@ const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docks-agent-score-vacuity for (const tool of ['node', 'bun', 'claude', 'shellcheck', 'cargo']) writeShim(shimDir, tool); // Make the parent gate observe either an empty agents directory or a declared root that does -// not exist. Child checks are shimmed; the same hook is harmless in those short-lived processes. +// not exist. The hook loads through a `--import` argument on the gate process only: Node and +// Bun both accept that flag, but Bun ignores `--import` inside NODE_OPTIONS. Child checks are +// shimmed, so they never need it. const fixtureHook = path.join(shimDir, 'payload-root-fixture.mjs'); fs.writeFileSync( fixtureHook, @@ -93,23 +95,27 @@ delete baseEnv.GITHUB_ACTIONS; delete baseEnv.NODE_OPTIONS; const runCi = (mode) => { - const result = spawnSync(process.execPath, ['scripts/ci.mjs', '--plugin', TARGET], { - cwd: REPO, - encoding: 'utf8', - timeout: 180_000, - env: { - ...baseEnv, - PATH: `${shimDir}${path.delimiter}${process.env.PATH ?? ''}`, - DOCKS_AGENT_SCORE_MODE: mode, - ...(mode === 'empty-directory' || mode.startsWith('missing-') - ? { - NODE_OPTIONS: `--import=${fixtureHook}`, - DOCKS_MISSING_ROOT: - mode === 'missing-skills-root' ? SKILLS_ROOT : mode === 'missing-agents-root' ? AGENTS_ROOT : '', - } - : {}), + const needsFixture = mode === 'empty-directory' || mode.startsWith('missing-'); + const result = spawnSync( + process.execPath, + [...(needsFixture ? [`--import=${fixtureHook}`] : []), 'scripts/ci.mjs', '--plugin', TARGET], + { + cwd: REPO, + encoding: 'utf8', + timeout: 180_000, + env: { + ...baseEnv, + PATH: `${shimDir}${path.delimiter}${process.env.PATH ?? ''}`, + DOCKS_AGENT_SCORE_MODE: mode, + ...(needsFixture + ? { + DOCKS_MISSING_ROOT: + mode === 'missing-skills-root' ? SKILLS_ROOT : mode === 'missing-agents-root' ? AGENTS_ROOT : '', + } + : {}), + }, }, - }); + ); return { status: result.status, output: `${result.stdout ?? ''}${result.stderr ?? ''}` }; }; @@ -151,7 +157,7 @@ test('an agent scorer that exits non-zero fails the gate naming the command', () const { status, output } = runCi('crash'); assert.notEqual(status, 0); assert.match(output, rx(`${TARGET} agent scorer exited 3 — no score set to gate`)); - assert.match(output, rx(`node scripts/agents/score.mjs --per-file ${AGENTS_ROOT}`)); + assert.match(output, rx(`bun scripts/agents/score.mjs --per-file ${AGENTS_ROOT}`)); assert.doesNotMatch(output, rx(`${TARGET} agents per-file all ≥ 10`)); }); diff --git a/scripts/tests/unit/gate-memo.test.mjs b/scripts/tests/unit/gate-memo.test.mjs index 73bf928c..92a0305d 100644 --- a/scripts/tests/unit/gate-memo.test.mjs +++ b/scripts/tests/unit/gate-memo.test.mjs @@ -378,7 +378,7 @@ test('the cost summary ranks the full run and names the scope the tree actually ' 87.9% 320.5s plugin: docks', ' 6.5% 23.8s plan orchestration', ' 1.2% 4.2s plugin: plan-lifecycle', - ' Cheaper next time: node scripts/ci.mjs --plugin plan-lifecycle — the only plugin this working tree touches.', + ' Cheaper next time: bun scripts/ci.mjs --plugin plan-lifecycle — the only plugin this working tree touches.', ].join('\n'), ); // A failing gate still gets the cost breakdown: the operator about to re-run is @@ -392,7 +392,7 @@ test('the advice follows the changed paths, never the dearest phase', () => { const advise = (scopes) => renderGateCost(FULL_RUN, { memoRequested: true, scopes }).split('\n').at(-1); assert.equal( advise(CHANGED_ONE), - ' Cheaper next time: node scripts/ci.mjs --plugin plan-lifecycle — the only plugin this working tree touches.', + ' Cheaper next time: bun scripts/ci.mjs --plugin plan-lifecycle — the only plugin this working tree touches.', ); assert.doesNotMatch(advise(CHANGED_ONE), /--plugin docks\b/, 'never hand over a command for the untouched plugin'); @@ -403,7 +403,7 @@ test('the advice follows the changed paths, never the dearest phase', () => { many, ' This working tree touches 2 plugins (docks, plan-lifecycle); one --plugin run covers one of them, so no single invocation is both cheaper and complete.', ); - assert.doesNotMatch(many, /node scripts\/ci\.mjs --plugin/, 'no copy-pasteable half-scope'); + assert.doesNotMatch(many, /scripts\/ci\.mjs --plugin/, 'no copy-pasteable half-scope'); // A change outside every plugin root: the full gate IS the right scope, and the // line must not push anyone off a gate they need. diff --git a/scripts/tests/unit/skill-score-vacuity.test.mjs b/scripts/tests/unit/skill-score-vacuity.test.mjs index cb3a1823..bb2ae799 100644 --- a/scripts/tests/unit/skill-score-vacuity.test.mjs +++ b/scripts/tests/unit/skill-score-vacuity.test.mjs @@ -43,7 +43,7 @@ import path from 'node:path'; const tool = ${JSON.stringify(name)}; const args = process.argv.slice(2); if (tool === 'claude') process.stdout.write('Validation passed\\n'); -if (tool === 'node' && args[0] === 'scripts/config/read-floor.mjs') process.stdout.write('10\\n'); +if (tool === 'bun' && args[0] === 'scripts/config/read-floor.mjs') process.stdout.write('10\\n'); if (tool === 'node' && args[0] === 'plugins/docks/skills/productivity/write-skill/scripts/skill-guard.mjs' && args[1] === 'score') { const root = args.at(-1); const rows = []; diff --git a/scripts/tests/unit/tag-ci-run-identity.test.mjs b/scripts/tests/unit/tag-ci-run-identity.test.mjs index cee38542..c8331d88 100644 --- a/scripts/tests/unit/tag-ci-run-identity.test.mjs +++ b/scripts/tests/unit/tag-ci-run-identity.test.mjs @@ -48,8 +48,15 @@ exit 64 ); // The poll sleeps between attempts; a no-op keeps the 30-attempt exhaustion case fast. fs.writeFileSync(path.join(binDir, 'sleep'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); -process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ''}`; -process.on('exit', () => fs.rmSync(stubRoot, { recursive: true, force: true })); +// Restore PATH when this file's tests end: a runner that shares one process across test +// files (`bun test`) would otherwise hand the stub `gh` and `sleep` to every later file. +const inheritedPath = process.env.PATH; +process.env.PATH = `${binDir}${path.delimiter}${inheritedPath ?? ''}`; +test.after(() => { + if (inheritedPath === undefined) delete process.env.PATH; + else process.env.PATH = inheritedPath; + fs.rmSync(stubRoot, { recursive: true, force: true }); +}); const io = createGenericPluginReleaseIo({ repo: REPO, plugins: PLUGINS }); const run = (overrides) => ({ diff --git a/scripts/tree/guard.mjs b/scripts/tree/guard.mjs index 21f0e4f6..b7373c7f 100644 --- a/scripts/tree/guard.mjs +++ b/scripts/tree/guard.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun // Guard: validate context-tree nodes. // A node is a directory carrying AGENTS.md; AGENTS.md must stay <= 500 lines. // Any CLAUDE.md (incl. .claude/CLAUDE.md) fails: this repo keeps one home for instructions, diff --git a/tests/idempotency.mjs b/tests/idempotency.mjs index 3173ad95..fd0007ff 100644 --- a/tests/idempotency.mjs +++ b/tests/idempotency.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun // idempotency.mjs — re-running the // skill-maintainer on unchanged skills is a no-op: the content hash is // deterministic, and every stored content_hash is in sync (--check-only exits 0). @@ -8,7 +8,7 @@ import path from 'node:path'; const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); const HASH = path.join(ROOT, 'scripts/skills/content-hash.mjs'); -const hash = (arg) => spawnSync('node', [HASH, arg], { encoding: 'utf8' }); +const hash = (arg) => spawnSync(process.execPath, [HASH, arg], { encoding: 'utf8' }); let fail = 0; // 1. determinism — same input twice yields the same hash @@ -27,7 +27,7 @@ if (!h1 || h1 !== h2) { } else console.log(`ok: deterministic hash (${sample})`); // 2. idempotency — the maintainer would bump nothing -const check = spawnSync('node', [HASH, '--check-only'], { encoding: 'utf8' }); +const check = spawnSync(process.execPath, [HASH, '--check-only'], { encoding: 'utf8' }); if ((check.status ?? 1) === 0) { const unchanged = (check.stdout.match(/^unchanged /gm) || []).length; const upstream = fs @@ -41,7 +41,7 @@ if ((check.status ?? 1) === 0) { } else { console.log('FAIL: skills out of sync — maintainer would bump:'); console.log((check.stdout.match(/^would-bump .*/gm) || []).map((l) => ` ${l}`).join('\n')); - console.log(' fix: node scripts/skills/content-hash.mjs --backfill (and bump metadata.updated on changed skills)'); + console.log(' fix: bun scripts/skills/content-hash.mjs --backfill (and bump metadata.updated on changed skills)'); fail = 1; } diff --git a/tests/skill-trigger-collision.mjs b/tests/skill-trigger-collision.mjs index 9c076795..b342e07c 100644 --- a/tests/skill-trigger-collision.mjs +++ b/tests/skill-trigger-collision.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun // skill-trigger-collision.mjs — cross-skill trigger-overlap audit. // // No mechanical scorer can see two skills claiming the SAME trigger surface — @@ -14,7 +14,7 @@ // the real near-misses. Tune OVERLAP_FAIL only to admit a genuinely-new routed // pair — never to silence an unrouted collision. // -// Usage: node tests/skill-trigger-collision.mjs [--report] [skills-root ...] +// Usage: bun tests/skill-trigger-collision.mjs [--report] [skills-root ...] // --report print the full ranked overlap matrix (informational), always exit 0 import fs from 'node:fs'; import path from 'node:path';