From 8442d984b978d621fe0d442326a76c6d2a9f8be2 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 24 Aug 2026 11:47:37 -0400 Subject: [PATCH] fix(#3809): route runtime-loaded markdown through the gsd_run launcher (#3815) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#3809): generalize dead-ref guard into a rule table (failing first) The #2020 guard hardcoded `sdk/(src|dist|handlers)/` — the three dead paths that had caused that storm. That proved those three paths were gone and said nothing about the class, so #3809 reproduced the identical Windows find.exe storm under a different token and the guard could not see it. Replaces the single regex with a rule table over the same runtime-loaded markdown surface, adds `commands/` to the scan set (previously uncovered), and adds rule B: the runtime shim filename must never appear in command position, because it is not a PATH command and an agent that meets it falls back to locating the file. Rule B's matcher is deliberately lenient — the launcher's own resolver assignment, `node /` calls, bare paths, and prose that names the file all stay unflagged, each pinned by a negative-space row. This commit is expected to FAIL: 50 offenders across 23 files remain in the tree. The remediation lands next. Refs #3809 Co-Authored-By: Claude Opus 5 * fix(#3809): route every workflow call through the gsd_run launcher 50 places across 23 runtime-loaded workflow, agent, reference, and command files instructed the agent to run the runtime shim by filename. That filename is not on PATH under any name -- package.json ships gsd-core, gsd-tools, gsd_run and gsd-mcp-server -- so the call exited 127, the file-shaped token sent the agent looking for the file, and on Git Bash for Windows the resulting `find /` walked the entire drive (7268 CPU-seconds in the report) until somebody killed it by hand. CONTEXT.md -> Runtime Launcher Module already makes gsd_run the single entry point: "Canonical space-safe shell preamble (`gsd_run`) used by every workflow bash block to invoke the GSD runtime CLI." These sites predate that rule -- they trace to 0e6907050 (docs(#195): migrate workflow markdown off gsd-sdk query), which swapped one non-PATH token for another. Two further instances of the same class surfaced during remediation and are fixed here rather than left for later: - references/model-profiles.md prescribed `node effort sync` with no path at all; node resolves a bare filename against cwd, so it fails the same way. - references/universal-anti-patterns.md rule 25 instructed every agent to "use " when shelling out. That rule did not contain the defect, it prescribed it repo-wide. Five "(or legacy )" parentheticals left dangling by the substitution are removed; after the rewrite they offered the non-resolving form as an alternative. The guard from the previous commit now passes. Its node-prefix exemption was tightened to require a path separator, which is what exposed model-profiles. Fixes #3809 Co-Authored-By: Claude Opus 5 * fix(#3809): key the guard on the CLI's whole verb roster, not observed usage Review found the first cut of rule B repeating the very mistake it exists to prevent. Its verb set held query, commit and effort -- the verbs that happened to appear in the tree -- so it could not see ` phase add`, ` state load`, ` verify ...` or twenty-odd other real single-word subcommands. A guard that only recognises yesterday's offenders is not a guard. The set is now the CLI's full advertised roster, unioned from the usage banner and HOST_COMMAND_ROUTERS (which carries verification, planning, uat, stats, todo and windows, all absent from the banner). Widening it immediately caught a live offender the first pass had missed: references/planning-config.md prescribed `node worktree set-baseref` with no path. Fixed here. Also drops the "a hyphen or a dot means subcommand" heuristic, which was unsound for prose -- it flagged `built-in` and `v1.2`. Detection now keys entirely on the roster, testing the first dot-segment so that phase.add and state.patch still match while prose does not. Both false positives are pinned as negative-space rows. Guard verified against the pre-fix tree at origin/next: 52 offenders across 25 files, and 0 after this branch's remediation. Refs #3809 Co-Authored-By: Claude Opus 5 * fix(#3809): derive the verb roster from the router; repair launcher parity Standards review caught the guard repeating the defect it exists to prevent. Its verb list was a hand-copied literal -- and worse, transcribed from an INSTALLED older binary, so it was missing 22 verbs this tree actually ships (websearch, windows, state-snapshot, context-predicates and the dispatch-* family among them). gsd-tools.cjs already carries three hand-maintained rosters whose drift is a named defect pinned by the parity test in tests/commands.test.cjs; a hand-copied fourth was that same defect wearing a guard's clothes. The roster is now derived from HOST_COMMAND_ROUTERS + TOP_LEVEL_USAGE, lazily and memoised, with `query` supplemented explicitly -- it dispatches through the routing hub ahead of the host-router table, so it appears in neither export, yet 45 of the 50 offenders used it. A parity test pins the derivation. Two regressions this branch introduced, both caught by the remote runner: - runtime-launcher-parity: rewriting a comment in gsd-research-synthesizer.md put a `gsd_run` token at line 65 while the canonical preamble sits at 158, breaking "exactly ONE preamble, before the first gsd_run call". The comment is descriptive and needs no command token at all; it now names none. - The #2751 guard's PROSE_ALLOWLIST entry for that same line went stale once the line stopped carrying a bare mention. Pruned, exactly as that guard's own stale-entry test instructs. Also corrects git-planning-commit.md, where the first pass rewrote only the trailing "legacy" clause and left the sentence reading backwards. Note the #2751 guard and this one are complementary, not duplicates: its regex requires whitespace immediately after `gsd-tools`, so it cannot match the `.cjs` form, and this one only matches the `.cjs` form. Refs #3809 Co-Authored-By: Claude Opus 5 * fix(#2751): extend the bare-command guard to references/ and commands/ The #2751 guard has only ever scanned agents/ and gsd-core/workflows/. Two runtime-loaded directories were never in its scan set, and 47 bare `gsd-tools ` calls had accumulated there unseen -- the same defect that guard exists to catch, in the rooms it never entered. - gsd-core/references/: 37 calls, all rewritten to gsd_run. references are fragments inlined into a parent that defines the launcher, which is why 21 of the 22 files already using gsd_run carry no local preamble. - commands/gsd/: 10 operative calls rewritten. The remaining 10 are descriptive prose ("resolved inside the workflow via ...") and are allowlisted with reasons, bringing PROSE_ALLOWLIST to 15. commands/ also came under launcher propagation. sync-runtime-launcher.cjs walked only WORKFLOWS_DIR and AGENTS_DIR, so every preamble under commands/ was a hand-pasted copy nothing propagated and no test checked -- graphify.md had accumulated five. It now walks COMMANDS_DIR too, which collapses those five to the canonical one-per-file, and runtime-launcher-parity gains a (B-commands) arm mirroring (B-agents) exactly so the placement stays honest. The parity arm keys on shell blocks, so commands/gsd/workstreams.md and config.md -- which name gsd_run only in inline backtick prose -- are exempt, as they should be. gsd_run is itself a shipped npm bin, so those inline instructions resolve from PATH exactly as the gsd-tools form they replace did. skills/ is deliberately NOT added to either guard's scan set: it is generated from commands/ and pinned by lint:generated-sync, so guarding the source guards both, and scanning the mirror would double-report every future offender. Regenerated here. Refs #2751, #3809 Co-Authored-By: Claude Opus 5 * test(#3809): acknowledge the one emitted file this change grows The emitted-attribution gate failed on the previous sha: gsd-research-synthesizer.md grew 3 bytes (13847 -> 13850) with no acknowledgment. The substitution SHRANK the other 19 emitted files, which is why the growth arm was not expected to fire at all. The 3 bytes are unavoidable. Line 65 is a descriptive comment inside a fenced block; naming any command there puts a gsd_run token ahead of the file's canonical preamble at line 158, which runtime-launcher-parity's (B-agents) arm correctly rejects. So the comment names no command and says where the config is actually loaded instead, which reads longer than the token it replaced. Acks only the path the gate reported, per the fragment rules. Refs #3809 Co-Authored-By: Claude Opus 5 * revert(#2751): drop the commands/ half — three contracts pin it in place The remote runner refuted the commands/ extension outright. Reverting it and keeping the references/ conversion, which passed. What broke, all of it caused by bringing commands/ under launcher propagation: - graphify.md's five per-block preambles are LOAD-BEARING, not accumulated drift. tests/graphify-visualization.test.cjs extracts individual Step-3 shell chains and executes them standalone, so each fenced block needs its own definition of gsd_run. Collapsing them to the canonical one-per-file produced `bash: gsd_run: command not found`, exit 127, across four tests. The "define once per file" contract holds for workflows and agents because nothing extracts their blocks in isolation; commands/ is not like that. - explore.md broke "the preamble that DEFINES gsd_run must appear before the first USE of gsd_run anywhere in the file". - tests/gsd-tools-path-refs.test.cjs (#1766) ASSERTS that commands/gsd/workstreams.md contains the literal string `gsd-tools query workstream.list`. Rewriting it to gsd_run contradicts a test that pins the opposite, so the two guards disagree about that file by construction. So commands/ is not a scan-set widening. It needs those contracts reconciled first, and that is its own change. SCAN_DIRS keeps gsd-core/references/ and drops commands/, the ten commands/ allowlist entries go with it (back to 5), and the reasoning is recorded in the guard itself so the next person does not rediscover it by burning a matrix run. commands/gsd/import.md keeps its #3809 fix — that one is the .cjs form this PR exists to remove, and it is untouched by any of the above. Refs #2751, #3809 Co-Authored-By: Claude Opus 5 * revert(#3809): restore explore.md's Step 1 preamble placement Running the launcher sync script processed workflows/ and agents/ too, not just the commands/ directory the run was aimed at, and it MOVED gsd-core/workflows/explore.md's preamble from Step 1 down to Step 3. The script inserts into the first bash block that USES gsd_run. explore.md's Step 1 block only DEFINES it, and that placement is deliberate -- the file says so on the line above: "Placed in Step 1 rather than Step 3 so declining the research offer cannot leave Step 5's commit call unbootstrapped." tests/explore-command.test.cjs pins it. explore.md carried no #3809 offender, so reverting it costs this fix nothing. This was collateral from invoking the sync script at all, not from the COMMANDS_DIR change, which is why the earlier commands/ revert did not catch it. Refs #3809 Co-Authored-By: Claude Opus 5 * chore(#3809): backfill PR number into changeset fragments pr:0 -> pr:3815 for both fragments now that the PR exists. Refs #3809 Co-Authored-By: Claude Opus 5 * fix(#3809): drop the hand-rolled regex escaper CodeQL flagged CodeQL raised js/incomplete-sanitization (HIGH) on the guard's pattern build: `SHIM.replace(/\./g, '\\.')` escapes the dot and nothing else, so it does not escape backslashes. It blocked PR #3815. The repo already bans this shape -- local/no-adhoc-regex-escape exists exactly to stop hand-rolled escapers, with the canonical one in src/pattern.cts. Rather than reach for that helper, the pattern now carries no escaping logic at all: SHIM is a compile-time constant whose only metacharacter is the dot, so the regex source is spelled out literally. The generated source string is byte-identical to what the replace() produced, verified before and after -- 0 offenders on this tree, 52 against origin/next, unchanged. A drift pin asserts SHIM_PATTERN still matches SHIM exactly, and that the dot is escaped rather than acting as a wildcard, so the two cannot separate. Refs #3809 Co-Authored-By: Claude Opus 5 --------- Co-authored-by: sim Co-authored-by: Claude Opus 5 --- .changeset/agile-geese-squeak.md | 5 + .changeset/eager-yaks-wander.md | 5 + agents/gsd-research-synthesizer.md | 2 +- commands/gsd/import.md | 2 +- .../references/autonomous-smart-discuss.md | 6 +- .../references/decimal-phase-calculation.md | 10 +- .../execute-phase-between-wave-reset.md | 2 +- gsd-core/references/git-integration.md | 10 +- gsd-core/references/git-planning-commit.md | 6 +- gsd-core/references/model-profiles.md | 2 +- gsd-core/references/phase-argument-parsing.md | 6 +- gsd-core/references/planner-revision.md | 2 +- gsd-core/references/planning-config.md | 14 +- gsd-core/references/runtime-aware-dispatch.md | 2 +- .../references/universal-anti-patterns.md | 4 +- gsd-core/references/verify-mvp-mode.md | 2 +- gsd-core/references/workstream-flag.md | 22 +- gsd-core/workflows/add-phase.md | 4 +- gsd-core/workflows/complete-milestone.md | 12 +- .../workflows/discuss-phase/modes/text.md | 2 +- gsd-core/workflows/execute-phase.md | 4 +- gsd-core/workflows/execute-plan.md | 6 +- gsd-core/workflows/insert-phase.md | 8 +- gsd-core/workflows/manager.md | 2 +- gsd-core/workflows/plan-phase.md | 2 +- .../steps/adr-ingest-express-path.md | 2 +- gsd-core/workflows/profile-user.md | 6 +- gsd-core/workflows/progress.md | 8 +- gsd-core/workflows/quick.md | 2 +- gsd-core/workflows/remove-phase.md | 6 +- gsd-core/workflows/settings-advanced.md | 8 +- gsd-core/workflows/stats.md | 2 +- gsd-core/workflows/thread.md | 4 +- gsd-core/workflows/transition.md | 8 +- gsd-core/workflows/update.md | 4 +- gsd-core/workflows/verify-work.md | 2 +- skills/gsd-import/SKILL.md | 2 +- .../3809-gsd-run-launcher-normalization.json | 9 + ...o-bare-gsd-tools-command-position.test.cjs | 24 +- tests/no-dead-sdk-refs.test.cjs | 303 ++++++++++++++++-- 40 files changed, 416 insertions(+), 116 deletions(-) create mode 100644 .changeset/agile-geese-squeak.md create mode 100644 .changeset/eager-yaks-wander.md create mode 100644 tests/emitted-drift-acks/3809-gsd-run-launcher-normalization.json diff --git a/.changeset/agile-geese-squeak.md b/.changeset/agile-geese-squeak.md new file mode 100644 index 000000000..e5c6e6c8e --- /dev/null +++ b/.changeset/agile-geese-squeak.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3815 +--- +**Workflows no longer send AI runtimes hunting the filesystem for the GSD shim** — 50 places across 23 runtime-loaded workflow, agent, reference, and command files told the agent to run `gsd-tools.cjs` by filename, which is not on PATH under any name. The agent got "command not found", fell back to locating the file, and on Git Bash for Windows `find /` walked the entire drive until someone killed it. Every one now calls the canonical `gsd_run` launcher. (#3809) diff --git a/.changeset/eager-yaks-wander.md b/.changeset/eager-yaks-wander.md new file mode 100644 index 000000000..3988afe95 --- /dev/null +++ b/.changeset/eager-yaks-wander.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3815 +--- +**`gsd-core/references/` is now covered by the bare-command guard** — the #2751 guard only ever scanned `agents/` and `gsd-core/workflows/`, so 37 bare `gsd-tools ` calls sat unguarded in a directory it never looked at. They now call the canonical `gsd_run` launcher, and the guard scans references too. (#2751) diff --git a/agents/gsd-research-synthesizer.md b/agents/gsd-research-synthesizer.md index cb7c6be32..c95f4f281 100644 --- a/agents/gsd-research-synthesizer.md +++ b/agents/gsd-research-synthesizer.md @@ -62,7 +62,7 @@ cat .planning/research/FEATURES.md cat .planning/research/ARCHITECTURE.md cat .planning/research/PITFALLS.md -# Planning config loaded via gsd-tools query (or gsd-tools.cjs) in commit step +# Planning config is loaded by the commit step below, after the launcher preamble ``` Parse each file to extract: diff --git a/commands/gsd/import.md b/commands/gsd/import.md index a87fa56a1..1aace3667 100644 --- a/commands/gsd/import.md +++ b/commands/gsd/import.md @@ -17,7 +17,7 @@ allowed-tools: Import external plan files into the GSD planning system with conflict detection against PROJECT.md decisions. - **--from**: Import an external plan file, detect conflicts, write as GSD PLAN.md, validate via gsd-plan-checker. -- **--from-gsd2**: Reverse-migrate a GSD-2 project (`.gsd/` directory) back to GSD v1 (`.planning/`) format. Runs `gsd-tools.cjs from-gsd2`. Pass `--path ` to migrate a project at a different path. +- **--from-gsd2**: Reverse-migrate a GSD-2 project (`.gsd/` directory) back to GSD v1 (`.planning/`) format. Runs `gsd_run from-gsd2`. Pass `--path ` to migrate a project at a different path. diff --git a/gsd-core/references/autonomous-smart-discuss.md b/gsd-core/references/autonomous-smart-discuss.md index 1e5554df7..357be94f0 100644 --- a/gsd-core/references/autonomous-smart-discuss.md +++ b/gsd-core/references/autonomous-smart-discuss.md @@ -5,7 +5,7 @@ Smart discuss is the autonomous-optimized variant of `gsd-discuss-phase`. It pro **Inputs:** `PHASE_NUM` from execute_phase. Run init to get phase paths: ```bash -PHASE_STATE=$(gsd-tools query init.phase-op ${PHASE_NUM}) +PHASE_STATE=$(gsd_run query init.phase-op ${PHASE_NUM}) ``` Parse from JSON: `phase_dir`, `phase_slug`, `padded_phase`, `phase_name`. @@ -94,7 +94,7 @@ Read the 3-5 most relevant files to understand existing patterns. **Get phase details:** ```bash -DETAIL=$(gsd-tools query roadmap.get-phase ${PHASE_NUM}) +DETAIL=$(gsd_run query roadmap.get-phase ${PHASE_NUM}) ``` Extract `goal`, `requirements`, `success_criteria` from the JSON response. @@ -266,7 +266,7 @@ Write the file. **Commit:** ```bash -gsd-tools query commit "docs(${PADDED_PHASE}): smart discuss context" --files "${phase_dir}/${padded_phase}-CONTEXT.md" +gsd_run query commit "docs(${PADDED_PHASE}): smart discuss context" --files "${phase_dir}/${padded_phase}-CONTEXT.md" ``` Display confirmation: diff --git a/gsd-core/references/decimal-phase-calculation.md b/gsd-core/references/decimal-phase-calculation.md index 995475c1a..1917bf682 100644 --- a/gsd-core/references/decimal-phase-calculation.md +++ b/gsd-core/references/decimal-phase-calculation.md @@ -6,7 +6,7 @@ Calculate the next decimal phase number for urgent insertions. ```bash # Get next decimal phase after phase 6 -gsd-tools query phase.next-decimal 6 +gsd_run query phase.next-decimal 6 ``` Output: @@ -32,13 +32,13 @@ With existing decimals: ## Extract Values ```bash -DECIMAL_PHASE=$(gsd-tools query phase.next-decimal "${AFTER_PHASE}" --pick next) -BASE_PHASE=$(gsd-tools query phase.next-decimal "${AFTER_PHASE}" --pick base_phase) +DECIMAL_PHASE=$(gsd_run query phase.next-decimal "${AFTER_PHASE}" --pick next) +BASE_PHASE=$(gsd_run query phase.next-decimal "${AFTER_PHASE}" --pick base_phase) ``` Or with --raw flag: ```bash -DECIMAL_PHASE=$(gsd-tools query phase.next-decimal "${AFTER_PHASE}" --raw) +DECIMAL_PHASE=$(gsd_run query phase.next-decimal "${AFTER_PHASE}" --raw) # Returns just: 06.1 ``` @@ -56,7 +56,7 @@ DECIMAL_PHASE=$(gsd-tools query phase.next-decimal "${AFTER_PHASE}" --raw) Decimal phase directories use the full decimal number: ```bash -SLUG=$(gsd-tools query generate-slug "$DESCRIPTION" --raw) +SLUG=$(gsd_run query generate-slug "$DESCRIPTION" --raw) PHASE_DIR=".planning/phases/${DECIMAL_PHASE}-${SLUG}" mkdir -p "$PHASE_DIR" ``` diff --git a/gsd-core/references/execute-phase-between-wave-reset.md b/gsd-core/references/execute-phase-between-wave-reset.md index 508ee5d51..c32c5ec02 100644 --- a/gsd-core/references/execute-phase-between-wave-reset.md +++ b/gsd-core/references/execute-phase-between-wave-reset.md @@ -1,5 +1,5 @@ 7b. **Pre-wave dependency check (waves 2+ only):** - Before wave N+1, run `gsd-tools.cjs query verify.key-links {phase_dir}/{plan}-PLAN.md` for each upcoming plan. + Before wave N+1, run `gsd_run query verify.key-links {phase_dir}/{plan}-PLAN.md` for each upcoming plan. If any PRIOR-wave artifact link fails, present: - `## Cross-Plan Wiring Gap` with plan/link/from/pattern rows - Options: investigate+fix before continue, or continue with cascade risk diff --git a/gsd-core/references/git-integration.md b/gsd-core/references/git-integration.md index 846ffc06a..ad8771ce3 100644 --- a/gsd-core/references/git-integration.md +++ b/gsd-core/references/git-integration.md @@ -51,7 +51,7 @@ Phases: What to commit: ```bash -gsd-tools query commit "docs: initialize [project-name] ([N] phases)" --files .planning/ +gsd_run query commit "docs: initialize [project-name] ([N] phases)" --files .planning/ ``` @@ -136,7 +136,7 @@ SUMMARY: .planning/phases/XX-name/{phase}-{plan}-SUMMARY.md What to commit: ```bash -gsd-tools query commit "docs({phase}-{plan}): complete [plan-name] plan" --files .planning/phases/XX-name/{phase}-{plan}-PLAN.md .planning/phases/XX-name/{phase}-{plan}-SUMMARY.md .planning/STATE.md .planning/ROADMAP.md +gsd_run query commit "docs({phase}-{plan}): complete [plan-name] plan" --files .planning/phases/XX-name/{phase}-{plan}-PLAN.md .planning/phases/XX-name/{phase}-{plan}-SUMMARY.md .planning/STATE.md .planning/ROADMAP.md ``` **Note:** Code files NOT included - already committed per-task. @@ -156,7 +156,7 @@ Current: [task name] What to commit: ```bash -gsd-tools query commit "wip: [phase-name] paused at task [X]/[Y]" --files .planning/ +gsd_run query commit "wip: [phase-name] paused at task [X]/[Y]" --files .planning/ ``` @@ -279,7 +279,7 @@ Set `commit_docs: false` so planning docs stay local and are not committed to an 1. **Auto-detection:** During `/gsd:new-project`, directories with their own `.git` folder are detected and offered for selection as sub-repos. On subsequent runs, `loadConfig` auto-syncs the `sub_repos` list with the filesystem — adding newly created repos and removing deleted ones. This means `config.json` may be rewritten automatically when repos change on disk. 2. **File grouping:** Code files are grouped by their sub-repo prefix (e.g., `backend/src/api/users.ts` belongs to the `backend/` repo). -3. **Independent commits:** Each sub-repo receives its own atomic commit via `gsd-tools.cjs commit-to-subrepo`. File paths are made relative to the sub-repo root before staging. +3. **Independent commits:** Each sub-repo receives its own atomic commit via `gsd_run commit-to-subrepo`. File paths are made relative to the sub-repo root before staging. 4. **Planning stays local:** The `.planning/` directory is not committed; it acts as cross-repo coordination. ### Commit Routing @@ -287,7 +287,7 @@ Set `commit_docs: false` so planning docs stay local and are not committed to an Instead of the standard `commit` command, use `commit-to-subrepo` when `sub_repos` is configured: ```bash -gsd-tools query commit-to-subrepo "feat(02-01): add user API" \ +gsd_run query commit-to-subrepo "feat(02-01): add user API" \ --files backend/src/api/users.ts backend/src/types/user.ts frontend/src/components/UserForm.tsx ``` diff --git a/gsd-core/references/git-planning-commit.md b/gsd-core/references/git-planning-commit.md index 97fdae8dd..06d097e16 100644 --- a/gsd-core/references/git-planning-commit.md +++ b/gsd-core/references/git-planning-commit.md @@ -1,6 +1,6 @@ # Git Planning Commit -Commit planning artifacts via `gsd-tools query commit`, which checks `commit_docs` config and gitignore status (same behavior as legacy `gsd-tools.cjs commit`). +Commit planning artifacts via `gsd_run query commit`, which checks `commit_docs` config and gitignore status. ## Commit via CLI @@ -9,7 +9,7 @@ Pass the message first, then file paths via `--files`. Both `commit` and `commit Always use this for `.planning/` files — it handles `commit_docs` and gitignore checks automatically: ```bash -gsd-tools query commit "docs({scope}): {description}" --files .planning/STATE.md .planning/ROADMAP.md +gsd_run query commit "docs({scope}): {description}" --files .planning/STATE.md .planning/ROADMAP.md ``` The CLI will return `skipped` (with reason) if `commit_docs` is `false`, `.planning/` is gitignored, or a per-phase `phase_commit_docs.` override resolves `false` for the phase being committed. No manual conditional checks needed. @@ -19,7 +19,7 @@ The CLI will return `skipped` (with reason) if `commit_docs` is `false`, `.plann To fold `.planning/` file changes into the previous commit: ```bash -gsd-tools query commit "" --files .planning/codebase/*.md --amend +gsd_run query commit "" --files .planning/codebase/*.md --amend ``` ## Commit Message Patterns diff --git a/gsd-core/references/model-profiles.md b/gsd-core/references/model-profiles.md index 5133aba90..9743ac5bc 100644 --- a/gsd-core/references/model-profiles.md +++ b/gsd-core/references/model-profiles.md @@ -228,7 +228,7 @@ the next spawn. `effort` (claude runtime) has its own cascade install time into the `effort:` frontmatter key of `~/.claude/agents/gsd-*.md` — Claude Code's Agent tool has no per-spawn effort parameter, so per-agent frontmatter is the only channel. An effort -config change has no effect until `node gsd-tools.cjs effort sync --apply` +config change has no effect until `gsd_run effort sync --apply` re-syncs the agent files. Codex agents instead pin `model_reasoning_effort` in `~/.codex/agents/*.toml` at install time. diff --git a/gsd-core/references/phase-argument-parsing.md b/gsd-core/references/phase-argument-parsing.md index 4d6c60e71..dea3ba534 100644 --- a/gsd-core/references/phase-argument-parsing.md +++ b/gsd-core/references/phase-argument-parsing.md @@ -14,7 +14,7 @@ From `$ARGUMENTS`: The `find-phase` command handles normalization and validation in one step: ```bash -PHASE_INFO=$(gsd-tools query find-phase "${PHASE}") +PHASE_INFO=$(gsd_run query find-phase "${PHASE}") ``` Returns JSON with: @@ -45,7 +45,7 @@ fi Use `roadmap get-phase` to validate phase exists: ```bash -PHASE_CHECK=$(gsd-tools query roadmap.get-phase "${PHASE}" --pick found) +PHASE_CHECK=$(gsd_run query roadmap.get-phase "${PHASE}" --pick found) if [ "$PHASE_CHECK" = "false" ]; then echo "ERROR: Phase ${PHASE} not found in roadmap" exit 1 @@ -57,5 +57,5 @@ fi Use `find-phase` for directory lookup: ```bash -PHASE_DIR=$(gsd-tools query find-phase "${PHASE}" --raw) +PHASE_DIR=$(gsd_run query find-phase "${PHASE}" --raw) ``` diff --git a/gsd-core/references/planner-revision.md b/gsd-core/references/planner-revision.md index d1066f45b..af2cc2e06 100644 --- a/gsd-core/references/planner-revision.md +++ b/gsd-core/references/planner-revision.md @@ -55,7 +55,7 @@ Group by plan, dimension, severity. ### Step 6: Commit ```bash -gsd-tools query commit "fix($PHASE): revise plans based on checker feedback" --files .planning/phases/$PHASE-*/$PHASE-*-PLAN.md +gsd_run query commit "fix($PHASE): revise plans based on checker feedback" --files .planning/phases/$PHASE-*/$PHASE-*-PLAN.md ``` ### Step 7: Return Revision Summary diff --git a/gsd-core/references/planning-config.md b/gsd-core/references/planning-config.md index 5644834c5..6338fbfd7 100644 --- a/gsd-core/references/planning-config.md +++ b/gsd-core/references/planning-config.md @@ -63,15 +63,15 @@ Configuration options for `.planning/` directory behavior. ```bash # Commit with automatic commit_docs + gitignore checks: -gsd-tools query commit "docs: update state" --files .planning/STATE.md +gsd_run query commit "docs: update state" --files .planning/STATE.md # Load config via state load (returns JSON): -INIT=$(gsd-tools query state.load) +INIT=$(gsd_run query state.load) if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi # commit_docs is available in the JSON output # Or use init commands which include commit_docs: -INIT=$(gsd-tools query init.execute-phase "1") +INIT=$(gsd_run query init.execute-phase "1") if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi # commit_docs is included in all init command outputs ``` @@ -83,7 +83,7 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi **Commit via CLI (handles checks automatically):** ```bash -gsd-tools query commit "docs: update state" --files .planning/STATE.md +gsd_run query commit "docs: update state" --files .planning/STATE.md ``` The CLI checks `commit_docs` config and gitignore status internally — no manual conditionals needed. @@ -171,14 +171,14 @@ To use uncommitted mode: Use `init execute-phase` which returns all config as JSON: ```bash -INIT=$(gsd-tools query init.execute-phase "1") +INIT=$(gsd_run query init.execute-phase "1") if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi # JSON output includes: branching_strategy, phase_branch_template, milestone_branch_template ``` Or use `state load` for the config values: ```bash -INIT=$(gsd-tools query state.load) +INIT=$(gsd_run query state.load) if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi # Parse branching_strategy, phase_branch_template, milestone_branch_template from JSON ``` @@ -401,7 +401,7 @@ Several config fields affect each other or trigger special behavior: 8. **`sub_repos` auto-sync** -- On every config load, GSD scans for child directories with `.git` and updates the `sub_repos` array if the filesystem has changed. Legacy `multiRepo: true` is automatically migrated to a detected `sub_repos` array. -9. **`workflow.use_worktrees` and branch divergence** -- When `use_worktrees` is `true` (default), executor worktrees are forked from `origin/HEAD` -- by the host's own harness on `dispatch.isolation: harness-worktree` runtimes (Claude Code, Cursor), or by GSD itself on `orchestrator-worktree` runtimes (Codex, OpenCode, Kimi, Kimi Code). The divergence behavior below is identical either way, because the fork base is a property of the repository rather than of whoever creates the worktree. If your current branch has commits that `origin/HEAD` does not (for example an unmerged milestone or feature branch), GSD automatically degrades to sequential execution for that run and prints a one-line `⚠ Worktree base mismatch` warning. To restore parallel execution permanently, set `worktree.baseRef:"head"` in `.claude/settings.local.json` (run `node gsd-tools.cjs worktree set-baseref`). This makes the harness fork worktrees from the live HEAD instead of `origin/HEAD`. Both fresh installs and upgrades of GSD Core set this automatically (no-clobber) when `use_worktrees` is enabled; you can also run the command manually at any time. Setting `workflow.use_worktrees: false` is the alternative if worktrees are not needed at all. On a runtime whose declared `dispatch.isolation` is `none`, an explicit `true` is a config the execution workflows fail closed on; `/gsd:health` reports it as warning `W025` and `/gsd:settings` offers to repair it (#2486). +9. **`workflow.use_worktrees` and branch divergence** -- When `use_worktrees` is `true` (default), executor worktrees are forked from `origin/HEAD` -- by the host's own harness on `dispatch.isolation: harness-worktree` runtimes (Claude Code, Cursor), or by GSD itself on `orchestrator-worktree` runtimes (Codex, OpenCode, Kimi, Kimi Code). The divergence behavior below is identical either way, because the fork base is a property of the repository rather than of whoever creates the worktree. If your current branch has commits that `origin/HEAD` does not (for example an unmerged milestone or feature branch), GSD automatically degrades to sequential execution for that run and prints a one-line `⚠ Worktree base mismatch` warning. To restore parallel execution permanently, set `worktree.baseRef:"head"` in `.claude/settings.local.json` (run `gsd_run worktree set-baseref`). This makes the harness fork worktrees from the live HEAD instead of `origin/HEAD`. Both fresh installs and upgrades of GSD Core set this automatically (no-clobber) when `use_worktrees` is enabled; you can also run the command manually at any time. Setting `workflow.use_worktrees: false` is the alternative if worktrees are not needed at all. On a runtime whose declared `dispatch.isolation` is `none`, an explicit `true` is a config the execution workflows fail closed on; `/gsd:health` reports it as warning `W025` and `/gsd:settings` offers to repair it (#2486). --- diff --git a/gsd-core/references/runtime-aware-dispatch.md b/gsd-core/references/runtime-aware-dispatch.md index 3d55fad72..7c9ccfea1 100644 --- a/gsd-core/references/runtime-aware-dispatch.md +++ b/gsd-core/references/runtime-aware-dispatch.md @@ -19,7 +19,7 @@ preserves named-dispatch behavior on older GSD installs that lack the query. The persona rides `${AGENT_SKILLS_}` (Phase 3 / #2510) regardless of the resolved type — on non-Claude runtimes with no `agent_skills` config, -`gsd-tools query agent-skills ` returns the installed agent prompt as +`gsd_run query agent-skills ` returns the installed agent prompt as the block. So a coder dispatch with the planner persona injected gives kimi-code the planner's behavior in the coder built-in's process. diff --git a/gsd-core/references/universal-anti-patterns.md b/gsd-core/references/universal-anti-patterns.md index 7a3cfe28c..9e35fe99f 100644 --- a/gsd-core/references/universal-anti-patterns.md +++ b/gsd-core/references/universal-anti-patterns.md @@ -34,7 +34,7 @@ Reference: `gsd-core/references/questioning.md` for the full anti-pattern list. ## State Management Anti-Patterns -15. **No direct Write/Edit to STATE.md or ROADMAP.md for mutations.** Always use `gsd-tools query` for registered state/roadmap handlers (e.g. `state.update`, `state.advance-plan`, `roadmap.update-plan-progress`), or legacy `node …/gsd-tools.cjs` for CLI-only commands. Direct Write tool usage bypasses safe update logic and is unsafe in multi-session environments. Exception: first-time creation of STATE.md from template is allowed. +15. **No direct Write/Edit to STATE.md or ROADMAP.md for mutations.** Always use `gsd_run query` for registered state/roadmap handlers (e.g. `state.update`, `state.advance-plan`, `roadmap.update-plan-progress`), or legacy `node …/gsd-tools.cjs` for CLI-only commands. Direct Write tool usage bypasses safe update logic and is unsafe in multi-session environments. Exception: first-time creation of STATE.md from template is allowed. ## Behavioral Rules @@ -53,7 +53,7 @@ Reference: `gsd-core/references/questioning.md` for the full anti-pattern list. ## GSD-Specific Rules 24. **Do not** check for `mode === 'auto'` or `mode === 'autonomous'` -- GSD uses `yolo` config flag. Check `yolo: true` for autonomous mode, absence or `false` for interactive mode. -25. **Prefer `gsd-tools query`** for orchestration when a handler exists; when shelling out to the legacy CLI, use **`gsd-tools.cjs`** (not `gsd-tools.js` or any other filename) — GSD ships the programmatic API as CommonJS for Node.js CLI compatibility. +25. **Prefer `gsd_run query`** for orchestration when a handler exists; when shelling out to the legacy CLI, go through the same `gsd_run` launcher rather than naming the shim file. The shim is not on PATH under any name ending in `.cjs`, and an agent that meets the bare filename falls back to searching the filesystem for it — on Git Bash for Windows that is a full-drive `find.exe` traversal (#3809). `gsd_run` resolves the CommonJS shim itself across every runtime home. 26. **Plan files MUST follow `{padded_phase}-{NN}-PLAN.md` pattern** (e.g., `01-01-PLAN.md`). Never use `PLAN-01.md`, `plan-01.md`, or any other variation -- gsd-tools detection depends on this exact pattern. 27. **Do not start executing the next plan before writing the SUMMARY.md for the current plan** -- downstream plans may reference it via `@` includes. diff --git a/gsd-core/references/verify-mvp-mode.md b/gsd-core/references/verify-mvp-mode.md index c876db847..e66194d82 100644 --- a/gsd-core/references/verify-mvp-mode.md +++ b/gsd-core/references/verify-mvp-mode.md @@ -14,7 +14,7 @@ The user-flow form mirrors what a real user does: open, fill, click, see. No HTT ## When this framing applies The framing fires when: -- The phase under verification has `**Mode:** mvp` in ROADMAP.md (parsed via `gsd-tools query roadmap.get-phase --pick mode`). +- The phase under verification has `**Mode:** mvp` in ROADMAP.md (parsed via `gsd_run query roadmap.get-phase --pick mode`). - AND the phase has a user-story-formatted goal (set by `/gsd mvp-phase` per Phase 2): "As a [user role], I want to [capability], so that [outcome]." If the phase has `mode: mvp` but the goal is NOT in user-story format, the verifier surfaces this as a discrepancy and asks the user to run `/gsd mvp-phase` to reformat the goal — same pattern as the planner agent under MVP_MODE (per `gsd-core/references/planner-mvp-mode.md`). diff --git a/gsd-core/references/workstream-flag.md b/gsd-core/references/workstream-flag.md index 624945440..ac3515a33 100644 --- a/gsd-core/references/workstream-flag.md +++ b/gsd-core/references/workstream-flag.md @@ -109,19 +109,19 @@ This ensures workstream scope chains automatically through the workflow: ## CLI Usage ```bash -# All gsd-tools query commands accept --ws -gsd-tools query state.json --ws feature-a -gsd-tools query find-phase 3 --ws feature-b +# All gsd_run query commands accept --ws +gsd_run query state.json --ws feature-a +gsd_run query find-phase 3 --ws feature-b # Session-local switching without --ws on every command -GSD_SESSION_KEY=my-terminal-a gsd-tools query workstream.set feature-a -GSD_SESSION_KEY=my-terminal-a gsd-tools query state.json -GSD_SESSION_KEY=my-terminal-b gsd-tools query workstream.set feature-b -GSD_SESSION_KEY=my-terminal-b gsd-tools query state.json +GSD_SESSION_KEY=my-terminal-a gsd_run query workstream.set feature-a +GSD_SESSION_KEY=my-terminal-a gsd_run query state.json +GSD_SESSION_KEY=my-terminal-b gsd_run query workstream.set feature-b +GSD_SESSION_KEY=my-terminal-b gsd_run query state.json # Workstream CRUD -gsd-tools query workstream.create -gsd-tools query workstream.list -gsd-tools query workstream.status -gsd-tools query workstream.complete +gsd_run query workstream.create +gsd_run query workstream.list +gsd_run query workstream.status +gsd_run query workstream.complete ``` diff --git a/gsd-core/workflows/add-phase.md b/gsd-core/workflows/add-phase.md index 419cda316..d04dbf282 100644 --- a/gsd-core/workflows/add-phase.md +++ b/gsd-core/workflows/add-phase.md @@ -43,7 +43,7 @@ Exit. -**Delegate the phase addition to `gsd-tools.cjs query phase.add`:** +**Delegate the phase addition to `gsd_run query phase.add`:** ```bash RESULT=$(gsd_run query phase.add "${description}") @@ -107,7 +107,7 @@ Roadmap updated: .planning/ROADMAP.md -- [ ] `gsd-tools.cjs query phase.add` executed successfully +- [ ] `gsd_run query phase.add` executed successfully - [ ] Phase directory created - [ ] Roadmap updated with new phase entry - [ ] STATE.md updated with roadmap evolution note diff --git a/gsd-core/workflows/complete-milestone.md b/gsd-core/workflows/complete-milestone.md index 70ed996cf..75ddf44c9 100644 --- a/gsd-core/workflows/complete-milestone.md +++ b/gsd-core/workflows/complete-milestone.md @@ -61,7 +61,7 @@ These items are open. Choose an action: ``` If user chooses [A] (Acknowledge): -1. Re-run `gsd-tools.cjs query audit-open --json` to get structured data. +1. Re-run `gsd_run query audit-open --json` to get structured data. 2. Acknowledge every open item through the `audit-open acknowledge` CLI writer — this is what actually suppresses each item starting at the NEXT `audit-open` scan; the STATE.md table in step 3 is a disclosure record only, it is no longer the suppression mechanism. Every acknowledge call's exit status is accumulated (`ACK_FAILURES`); the step HALTS before closing if any failed — a refusal (`unsupported_heading_shape`, `ambiguous`, `not_found`, missing file, etc.) must never be silently discarded and let the close proceed as if everything were suppressed. `AUDIT_JSON` uses the same `@file:` large-payload sentinel handling `INIT_MANAGER` uses in `verify_readiness` below — `io.output` swaps any JSON payload over 50000 chars for a `@file:` marker, and feeding that literal string to `jq` would silently make every loop body below iterate zero times: ```bash AUDIT_JSON=$(gsd_run query audit-open --json) @@ -162,8 +162,8 @@ If user chooses [A] (Acknowledge): exit 1 fi ``` - `todos` is the only category the scanner caps (5 entries per scan, with a remainder count for the rest). Re-run `gsd-tools.cjs query audit-open --json` (through the same `@file:` handling above) and repeat the `todos` block until it reports no `todos` items — every other category always returns its full open set in one pass. -3. Re-run `gsd-tools.cjs query audit-open --json` once more and write the items just acknowledged as new rows to STATE.md under `## Deferred Items` — append to the existing table (creating the section if absent) rather than overwriting it, preserving rows recorded at earlier milestone closes: + `todos` is the only category the scanner caps (5 entries per scan, with a remainder count for the rest). Re-run `gsd_run query audit-open --json` (through the same `@file:` handling above) and repeat the `todos` block until it reports no `todos` items — every other category always returns its full open set in one pass. +3. Re-run `gsd_run query audit-open --json` once more and write the items just acknowledged as new rows to STATE.md under `## Deferred Items` — append to the existing table (creating the section if absent) rather than overwriting it, preserving rows recorded at earlier milestone closes: ```markdown ## Deferred Items @@ -189,7 +189,7 @@ Acknowledging is verdict-preserving and self-invalidating: it never rewrites the If output shows all clear (no open items): set `closeout_type=verified_closeout`. If the audit JSON's `acknowledged.total` is `0`, print `All artifact types clear.` and proceed. Otherwise the close is clean only because `{acknowledged.total}` item(s) acknowledged at an earlier milestone close are still being suppressed, not because everything was fixed this time — print `All artifact types clear ({acknowledged.total} previously acknowledged item(s) still suppressed — see STATE.md Deferred Items).` and record `Known verification overrides: 0 newly acknowledged, {acknowledged.total} carried forward from a prior close (see STATE.md Deferred Items)` in the MILESTONES.md entry before proceeding. -SECURITY: Audit JSON output is structured data from the `audit-open` query handler (same JSON contract as legacy `gsd-tools.cjs audit-open`) — validated and sanitized at source. The `audit-open acknowledge` writer is the only path that sets the `audit_acknowledged` suppression marker — it snapshots each artifact's current state itself from the identifiers passed on the command line, so this workflow never hand-authors the marker. When writing the STATE.md disclosure table, item identifiers, statuses, and deferred-item text are sanitized via `sanitizeForDisplay()` before inclusion. Never inject raw user-supplied content into STATE.md without sanitization. +SECURITY: Audit JSON output is structured data from the `audit-open` query handler (same JSON contract as legacy `gsd_run audit-open`) — validated and sanitized at source. The `audit-open acknowledge` writer is the only path that sets the `audit_acknowledged` suppression marker — it snapshots each artifact's current state itself from the identifiers passed on the command line, so this workflow never hand-authors the marker. When writing the STATE.md disclosure table, item identifiers, statuses, and deferred-item text are sanitized via `sanitizeForDisplay()` before inclusion. Never inject raw user-supplied content into STATE.md without sanitization. @@ -353,7 +353,7 @@ Key accomplishments for this milestone: -**Note:** MILESTONES.md entry is now created automatically by `gsd-tools.cjs query milestone.complete` in the archive_milestone step. The entry includes version, date, phase/plan/task counts, and accomplishments extracted from SUMMARY.md files. +**Note:** MILESTONES.md entry is now created automatically by `gsd_run query milestone.complete` in the archive_milestone step. The entry includes version, date, phase/plan/task counts, and accomplishments extracted from SUMMARY.md files. If additional details are needed (e.g., user-provided "Delivered" summary, git range, LOC stats), add them manually after the CLI creates the base entry. @@ -517,7 +517,7 @@ AskUserQuestion: "Archive completed quick tasks into this milestone too?" with o If "Yes": set `ARCHIVE_QUICK_FLAG="--archive-quick"`. If "Skip" (or `.planning/quick/` is empty): set `ARCHIVE_QUICK_FLAG=""`. -**Delegate archival to `gsd-tools.cjs query milestone.complete`:** +**Delegate archival to `gsd_run query milestone.complete`:** ```bash ARCHIVE=$(gsd_run query milestone.complete "v[X.Y]" --name "[Milestone Name]" $ARCHIVE_QUICK_FLAG) diff --git a/gsd-core/workflows/discuss-phase/modes/text.md b/gsd-core/workflows/discuss-phase/modes/text.md index 84309d099..9208aae51 100644 --- a/gsd-core/workflows/discuss-phase/modes/text.md +++ b/gsd-core/workflows/discuss-phase/modes/text.md @@ -18,7 +18,7 @@ Claude App cannot forward TUI menu selections back to the host. - Per-session: pass `--text` flag to any command (e.g., `/gsd:discuss-phase --text`) -- Per-project: `gsd-tools.cjs query config-set workflow.text_mode true` +- Per-project: `gsd_run query config-set workflow.text_mode true` Text mode applies to ALL workflows in the session, not just discuss-phase. diff --git a/gsd-core/workflows/execute-phase.md b/gsd-core/workflows/execute-phase.md index cc34faee2..4f8bcceb6 100644 --- a/gsd-core/workflows/execute-phase.md +++ b/gsd-core/workflows/execute-phase.md @@ -431,7 +431,7 @@ CROSS_AI_TIMEOUT=$(gsd_run query config-get workflow.cross_ai_timeout 2>/dev/nul **If no plans are marked for cross-AI:** Skip to execute_waves. **If plans are marked but `cross_ai_command` is empty:** Error — tell user to set -`workflow.cross_ai_command` via `gsd-tools.cjs query config-set workflow.cross_ai_command ""`. +`workflow.cross_ai_command` via `gsd_run query config-set workflow.cross_ai_command ""`. **For each cross-AI plan (sequentially):** @@ -1530,7 +1530,7 @@ For 1M+ context models, consider: -- **Quota / rate-limit (any runtime — #3095):** Agent return body contains a sentinel like `usage limit`, `rate limit`, `429`, `too many requests`, `RESOURCE_EXHAUSTED`, `usage_limit_reached`. Route via `gsd-tools.cjs query agent.classify-failure` → `class: "quota-exceeded"`. Do not offer retry-now; the right action is wait-for-reset and resume. +- **Quota / rate-limit (any runtime — #3095):** Agent return body contains a sentinel like `usage limit`, `rate limit`, `429`, `too many requests`, `RESOURCE_EXHAUSTED`, `usage_limit_reached`. Route via `gsd_run query agent.classify-failure` → `class: "quota-exceeded"`. Do not offer retry-now; the right action is wait-for-reset and resume. - **classifyHandoffIfNeeded false failure:** Agent reports "failed" but error is `classifyHandoffIfNeeded is not defined` → Claude Code bug, not GSD. Spot-check (SUMMARY exists, commits present) → if pass, treat as success - **Agent fails mid-plan:** Missing SUMMARY.md → report, ask user how to proceed - **Dependency chain breaks:** Wave 1 fails → Wave 2 dependents likely fail → user chooses attempt or skip diff --git a/gsd-core/workflows/execute-plan.md b/gsd-core/workflows/execute-plan.md index cda873c02..5c15ae19a 100644 --- a/gsd-core/workflows/execute-plan.md +++ b/gsd-core/workflows/execute-plan.md @@ -68,7 +68,7 @@ Find first PLAN without matching SUMMARY. Decimal phases supported (`01.1-hotfix ```bash PHASE=$(echo "$PLAN_PATH" | grep -oE '[0-9]+(\.[0-9]+)?-[0-9]+') -# config settings can be fetched via gsd-tools.cjs query config-get if needed +# config settings can be fetched via gsd_run query config-get if needed ``` @@ -428,7 +428,7 @@ Next: more plans → "Ready for {next-plan}" | last → "Phase complete, ready f handles STATE.md/ROADMAP.md updates centrally after merging worktrees to avoid merge conflicts). -Update STATE.md using gsd-tools.cjs query (or legacy gsd-tools) state mutations: +Update STATE.md using gsd_run query (or legacy gsd-tools) state mutations: ```bash # Auto-detect parallel mode: .git is a file in worktrees, a directory in main repo @@ -465,7 +465,7 @@ gsd_run query state.add-blocker --text-file "${BLOCKER_TEXT_FILE}" -Update session info using gsd-tools.cjs query (or legacy gsd-tools): +Update session info using gsd_run query (or legacy gsd-tools): ```bash gsd_run query state.record-session \ diff --git a/gsd-core/workflows/insert-phase.md b/gsd-core/workflows/insert-phase.md index 30db7239d..88f32fd9c 100644 --- a/gsd-core/workflows/insert-phase.md +++ b/gsd-core/workflows/insert-phase.md @@ -47,7 +47,7 @@ Exit. -**Delegate the phase insertion to `gsd-tools.cjs query phase.insert`:** +**Delegate the phase insertion to `gsd_run query phase.insert`:** ```bash RESULT=$(gsd_run query phase.insert "${after_phase}" "${description}") @@ -143,10 +143,10 @@ Project state updated: .planning/STATE.md Phase insertion is complete when: -- [ ] `gsd-tools.cjs query phase.insert` executed successfully +- [ ] `gsd_run query phase.insert` executed successfully - [ ] Phase directory created - [ ] Roadmap updated with new phase entry (includes "(INSERTED)" marker) -- [ ] `gsd-tools.cjs query state.add-roadmap-evolution ...` returned `{ added: true }` or `{ added: false, reason: "duplicate" }` -- [ ] `gsd-tools.cjs query state.patch` returned matched next-phase pointer field(s) +- [ ] `gsd_run query state.add-roadmap-evolution ...` returned `{ added: true }` or `{ added: false, reason: "duplicate" }` +- [ ] `gsd_run query state.patch` returned matched next-phase pointer field(s) - [ ] User informed of next steps and dependency implications diff --git a/gsd-core/workflows/manager.md b/gsd-core/workflows/manager.md index c4c46e026..a1c015119 100644 --- a/gsd-core/workflows/manager.md +++ b/gsd-core/workflows/manager.md @@ -33,7 +33,7 @@ Parse JSON for: `milestone_version`, `milestone_name`, `phase_count`, `completed - `manager_flags.plan` — appended to plan agent init command - `manager_flags.execute` — appended to execute agent init command -These are empty strings by default. Set via: `gsd-tools.cjs query config-set manager.flags.discuss "--auto --analyze"` +These are empty strings by default. Set via: `gsd_run query config-set manager.flags.discuss "--auto --analyze"` **If error:** Display the error message and exit. diff --git a/gsd-core/workflows/plan-phase.md b/gsd-core/workflows/plan-phase.md index f3416856b..3b1dabe07 100644 --- a/gsd-core/workflows/plan-phase.md +++ b/gsd-core/workflows/plan-phase.md @@ -602,7 +602,7 @@ VALIDATION_EXISTS=$(ls "${PHASE_DIR}"/*-VALIDATION.md 2>/dev/null | head -1) If missing and Nyquist is still enabled/applicable — ask user: 1. Re-run: `/gsd:plan-phase {PHASE} --research ${GSD_WS}` 2. Disable Nyquist with the exact command: - `gsd-tools.cjs query config-set workflow.nyquist_validation false` + `gsd_run query config-set workflow.nyquist_validation false` 3. Continue anyway (plans fail Dimension 8) Proceed to Step 7.8 (or Step 8 if pattern mapper is disabled) only if user selects 2 or 3. diff --git a/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md b/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md index 1a46967c1..fd1c18601 100644 --- a/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +++ b/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md @@ -9,7 +9,7 @@ 3. Status gate: reject `superseded`/`rejected`/`deprecated`; warn on `proposed`; missing status defaults to `accepted`. 4. Empty-decisions fallback: if all parsed ADRs have zero `decisions[]`, emit `ADR ingest produced no locked decisions; fall back to discuss-phase for this phase.` and exit with `/gsd:discuss-phase {N}` guidance. 5. Generate CONTEXT.md using ``, ``, ``, ``, ``, ``, map `consequences_positive[]` to Success Criteria and `consequences_negative[]` to Risk Summary, and include `**Source:** ADR Ingest Express Path ({INGEST_PATH})`. -6. Commit with `gsd-tools.cjs query commit "docs(${padded_phase}): generate context from ADR ingest" --files "${phase_dir}/${padded_phase}-CONTEXT.md"` and set `context_content`; continue to step 5. +6. Commit with `gsd_run query commit "docs(${padded_phase}): generate context from ADR ingest" --files "${phase_dir}/${padded_phase}-CONTEXT.md"` and set `context_content`; continue to step 5. **Effect:** This bypasses step 4 (Load CONTEXT.md) since CONTEXT.md was synthesized from ADR input. diff --git a/gsd-core/workflows/profile-user.md b/gsd-core/workflows/profile-user.md index 8458756d8..4e56a631f 100644 --- a/gsd-core/workflows/profile-user.md +++ b/gsd-core/workflows/profile-user.md @@ -1,7 +1,7 @@ Orchestrate the full developer profiling flow: consent, session analysis (or questionnaire fallback), profile generation, result display, and artifact creation. -This workflow wires Phase 1 (session pipeline) and Phase 2 (profiling engine) into a cohesive user-facing experience. All heavy lifting is done by existing `gsd-tools.cjs query` handlers (with legacy `gsd-tools.cjs` parity where needed) and the gsd-user-profiler agent -- this workflow orchestrates the sequence, handles branching, and provides the UX. +This workflow wires Phase 1 (session pipeline) and Phase 2 (profiling engine) into a cohesive user-facing experience. All heavy lifting is done by existing `gsd_run query` handlers and the gsd-user-profiler agent -- this workflow orchestrates the sequence, handles branching, and provides the UX. @@ -386,7 +386,7 @@ gsd_run query generate-claude-profile --analysis "$ANALYSIS_PATH" --global --jso Display: "✓ Added profile section to $HOME/.claude/CLAUDE.md" -**Error handling:** If any `gsd-tools.cjs query` or gsd-tools.cjs call fails, display the error message and use AskUserQuestion to offer "Retry" or "Skip this artifact". On retry, re-run the command. On skip, continue to next artifact. +**Error handling:** If any `gsd_run query` call fails, display the error message and use AskUserQuestion to offer "Retry" or "Skip this artifact". On retry, re-run the command. On skip, continue to next artifact. --- @@ -461,7 +461,7 @@ rm -f "$ANALYSIS_PATH" 2>/dev/null - [ ] Profile written to USER-PROFILE.md via write-profile subcommand - [ ] Result display shows report card table and highlight reel with evidence - [ ] Artifact selection uses multiSelect with all options pre-selected -- [ ] Artifacts generated sequentially via gsd-tools.cjs query (or gsd-tools.cjs) subcommands +- [ ] Artifacts generated sequentially via `gsd_run query` subcommands - [ ] Refresh diff shows changed dimensions when --refresh was used - [ ] Temp files cleaned up on completion diff --git a/gsd-core/workflows/progress.md b/gsd-core/workflows/progress.md index 6fbc1bcfe..fb9c74da2 100644 --- a/gsd-core/workflows/progress.md +++ b/gsd-core/workflows/progress.md @@ -44,11 +44,11 @@ If missing both ROADMAP.md and PROJECT.md: suggest `/gsd:new-project`. -**Use structured extraction from `gsd-tools.cjs query` (or legacy gsd-tools.cjs):** +**Use structured extraction from `gsd_run query`:** Instead of reading full files, use targeted tools to get only the data needed for the report: -- `ROADMAP=$(gsd-tools.cjs query roadmap.analyze)` -- `STATE=$(gsd-tools.cjs query state-snapshot)` +- `ROADMAP=$(gsd_run query roadmap.analyze)` +- `STATE=$(gsd_run query state-snapshot)` This minimizes orchestrator context usage. @@ -96,7 +96,7 @@ Use this instead of manually reading/parsing ROADMAP.md. > blocks are a secondary config aid that may be significantly stale — do NOT use the > CLAUDE.md project description as a source for any progress report field. -**Generate progress bar from `gsd-tools.cjs query progress` / `progress.json`, then present rich status report:** +**Generate progress bar from `gsd_run query progress` / `progress.json`, then present rich status report:** ```bash # Get formatted progress bar diff --git a/gsd-core/workflows/quick.md b/gsd-core/workflows/quick.md index a85683b1b..623ba6706 100644 --- a/gsd-core/workflows/quick.md +++ b/gsd-core/workflows/quick.md @@ -647,7 +647,7 @@ Use Edit tool to make these changes atomically **Step 8: Final commit and completion** -Stage and commit quick task artifacts. This step MUST always run — even if the executor already committed some files (e.g. when running without worktree isolation). The `gsd-tools.cjs query commit` command (or legacy `gsd-tools.cjs` commit) handles already-committed files gracefully. +Stage and commit quick task artifacts. This step MUST always run — even if the executor already committed some files (e.g. when running without worktree isolation). The `gsd_run query commit` command handles already-committed files gracefully. Build file list: - `${QUICK_DIR}/${quick_id}-PLAN.md` diff --git a/gsd-core/workflows/remove-phase.md b/gsd-core/workflows/remove-phase.md index bdd24a8ee..c3c497d4d 100644 --- a/gsd-core/workflows/remove-phase.md +++ b/gsd-core/workflows/remove-phase.md @@ -78,7 +78,7 @@ Wait for confirmation. -**Delegate the entire removal operation to `gsd-tools.cjs query phase.remove`:** +**Delegate the entire removal operation to `gsd_run query phase.remove`:** ```bash RESULT=$(gsd_run query phase.remove "${target}") @@ -141,7 +141,7 @@ Would you like to: - Don't remove completed phases (have SUMMARY.md files) without --force - Don't remove current or past phases -- Don't manually renumber — use `gsd-tools.cjs query phase.remove` which handles all renumbering +- Don't manually renumber — use `gsd_run query phase.remove` which handles all renumbering - Don't add "removed phase" notes to STATE.md — git commit is the record - Don't modify completed phase directories @@ -150,7 +150,7 @@ Would you like to: Phase removal is complete when: - [ ] Target phase validated as future/unstarted -- [ ] `gsd-tools.cjs query phase.remove` executed successfully +- [ ] `gsd_run query phase.remove` executed successfully - [ ] Changes committed with descriptive message - [ ] User informed of changes diff --git a/gsd-core/workflows/settings-advanced.md b/gsd-core/workflows/settings-advanced.md index c6b98de89..5818ab8ce 100644 --- a/gsd-core/workflows/settings-advanced.md +++ b/gsd-core/workflows/settings-advanced.md @@ -434,7 +434,7 @@ AskUserQuestion([ If "Other (Group B or custom)" is selected, prompt the user to enter the runtime name as a free-text string. If the selected runtime differs from the stored `runtime` key, update `runtime` via -`gsd-tools.cjs query config-set runtime ` before proceeding to Step C. +`gsd_run query config-set runtime ` before proceeding to Step C. **Step C — Configure tier overrides for the selected runtime:** @@ -494,7 +494,7 @@ change. Merge the new settings into the existing config at `$GSD_CONFIG_PATH`. This merge is the core correctness invariant: **preserve every unrelated key** — do not clobber siblings. -Apply each selected value via `gsd-tools.cjs query config-set ` so the central +Apply each selected value via `gsd_run query config-set ` so the central validator (`isValidConfigKey`) accepts the write and the deep-merge preserves unrelated keys and sibling sub-objects. @@ -567,7 +567,7 @@ anything not listed in Sections 1–8 MUST survive the update): ``` Never emit a full overwrite of the file that omits keys the user did not touch. Always -route each write through `gsd-tools.cjs query config-set` so sibling preservation is handled by +route each write through `gsd_run query config-set` so sibling preservation is handled by the central setter. @@ -807,7 +807,7 @@ UI/AI phase gates), use /gsd:settings. - [ ] Numeric inputs validated — non-numeric rejected and re-prompted - [ ] Branch-template inputs validated — non-default must contain a placeholder - [ ] Null-allowed fields accept an empty input as a clear -- [ ] Writes routed through `gsd-tools.cjs query config-set` so unrelated keys are preserved +- [ ] Writes routed through `gsd_run query config-set` so unrelated keys are preserved - [ ] Section 7 shows current runtime and built-in tier table - [ ] Group B runtimes display "(no built-in default — your runtime handles model selection)" - [ ] Override set/clear/keep paths all work correctly for each tier diff --git a/gsd-core/workflows/stats.md b/gsd-core/workflows/stats.md index 9d3a3f4cd..ffe1f6ccf 100644 --- a/gsd-core/workflows/stats.md +++ b/gsd-core/workflows/stats.md @@ -53,7 +53,7 @@ If no `.planning/` directory exists, inform the user to run `/gsd:new-project` f -**MVP phase summary.** Read all phases via `gsd-tools.cjs query roadmap.analyze` (Phase 1's `cmdRoadmapAnalyze` surfaces a `mode` field per phase). Count phases by mode: +**MVP phase summary.** Read all phases via `gsd_run query roadmap.analyze` (Phase 1's `cmdRoadmapAnalyze` surfaces a `mode` field per phase). Count phases by mode: ```bash ANALYZE=$(gsd_run query roadmap.analyze) diff --git a/gsd-core/workflows/thread.md b/gsd-core/workflows/thread.md index 6fce00545..68ffeb71f 100644 --- a/gsd-core/workflows/thread.md +++ b/gsd-core/workflows/thread.md @@ -221,6 +221,6 @@ updated: {today ISO date} - Slugs from $ARGUMENTS are sanitized before use in file paths: only [a-z0-9-] allowed, max 60 chars, reject ".." and "/" - File names from readdir/ls are sanitized before display: strip non-printable chars and ANSI sequences - Artifact content (thread titles, goal sections, next steps) rendered as plain text only — never executed or passed to agent prompts without DATA_START/DATA_END boundaries -- Status fields read via gsd-tools.cjs query frontmatter.get — never eval'd or shell-expanded -- The generate-slug call for new threads runs through gsd-tools.cjs query (or gsd-tools) which sanitizes input — keep that pattern +- Status fields read via gsd_run query frontmatter.get — never eval'd or shell-expanded +- The generate-slug call for new threads runs through gsd_run query (or gsd-tools) which sanitizes input — keep that pattern diff --git a/gsd-core/workflows/transition.md b/gsd-core/workflows/transition.md index afcc7e95c..6a9a71268 100644 --- a/gsd-core/workflows/transition.md +++ b/gsd-core/workflows/transition.md @@ -131,7 +131,7 @@ Resolve before transition. Review: `/gsd:audit-uat` ``` This preliminary check blocks obviously unresolved verification early, ahead -of the authoritative gate below. `gsd-tools.cjs query phase.complete` (in +of the authoritative gate below. `gsd_run query phase.complete` (in `update_roadmap_and_state`) remains the authoritative stale-aware gate and fail-closes unless canonical verification status is `passed`. @@ -197,7 +197,7 @@ If found, delete them — phase is complete, handoffs are stale. -**Delegate ROADMAP.md and STATE.md updates to `gsd-tools.cjs query phase.complete`:** +**Delegate ROADMAP.md and STATE.md updates to `gsd_run query phase.complete`:** ```bash TRANSITION=$(gsd_run query phase.complete "${current_phase}") @@ -333,7 +333,7 @@ This step is fully delegated to `graduation.md`. It handles guard checks (featur -**Note:** Basic position updates (Current Phase, Status, Current Plan, Last Activity) were already handled by `gsd-tools.cjs query phase.complete` in the update_roadmap_and_state step. +**Note:** Basic position updates (Current Phase, Status, Current Plan, Last Activity) were already handled by `gsd_run query phase.complete` in the update_roadmap_and_state step. Verify the updates are correct by reading STATE.md. If the progress bar needs updating, use: @@ -437,7 +437,7 @@ Resume file: None **MANDATORY: Verify milestone status before presenting next steps.** -**Use the transition result from `gsd-tools.cjs query phase.complete`:** +**Use the transition result from `gsd_run query phase.complete`:** The `is_last_phase` field from the phase complete result tells you directly: - `is_last_phase: false` → More phases remain → Go to **Route A** diff --git a/gsd-core/workflows/update.md b/gsd-core/workflows/update.md index 76805bede..569417430 100644 --- a/gsd-core/workflows/update.md +++ b/gsd-core/workflows/update.md @@ -296,8 +296,8 @@ installer does not know about and will delete during the wipe. **Do not use bash path-stripping (`${filepath#$RUNTIME_DIR/}`) or `node -e require()` inline** — those patterns fail when `$RUNTIME_DIR` is unset and the stripped relative path may not match manifest key format, which causes CUSTOM_COUNT=0 -even when custom files exist (bug #1997). Use `gsd-tools.cjs query detect-custom-files` -or the bundled `gsd-tools.cjs detect-custom-files` path — both resolve paths +even when custom files exist (bug #1997). Use `gsd_run query detect-custom-files` +or the bundled `gsd_run detect-custom-files` path — both resolve paths reliably with Node.js `path.relative()`. First, resolve the config directory (`RUNTIME_DIR`) from the install scope diff --git a/gsd-core/workflows/verify-work.md b/gsd-core/workflows/verify-work.md index 13656de97..db89a3b43 100644 --- a/gsd-core/workflows/verify-work.md +++ b/gsd-core/workflows/verify-work.md @@ -635,7 +635,7 @@ All tests passed. Phase {phase} marked complete. Run phase artifact scan to surface any open items before marking phase verified: -`audit-open` is CJS-only until registered on `gsd-tools.cjs query`: +`audit-open` is CJS-only until registered on `gsd_run query`: ```bash gsd_run query audit-open --json diff --git a/skills/gsd-import/SKILL.md b/skills/gsd-import/SKILL.md index 638148c4d..93405af2f 100644 --- a/skills/gsd-import/SKILL.md +++ b/skills/gsd-import/SKILL.md @@ -18,7 +18,7 @@ allowed-tools: Import external plan files into the GSD planning system with conflict detection against PROJECT.md decisions. - **--from**: Import an external plan file, detect conflicts, write as GSD PLAN.md, validate via gsd-plan-checker. -- **--from-gsd2**: Reverse-migrate a GSD-2 project (`.gsd/` directory) back to GSD v1 (`.planning/`) format. Runs `gsd-tools.cjs from-gsd2`. Pass `--path ` to migrate a project at a different path. +- **--from-gsd2**: Reverse-migrate a GSD-2 project (`.gsd/` directory) back to GSD v1 (`.planning/`) format. Runs `gsd_run from-gsd2`. Pass `--path ` to migrate a project at a different path. diff --git a/tests/emitted-drift-acks/3809-gsd-run-launcher-normalization.json b/tests/emitted-drift-acks/3809-gsd-run-launcher-normalization.json new file mode 100644 index 000000000..a7c42e26c --- /dev/null +++ b/tests/emitted-drift-acks/3809-gsd-run-launcher-normalization.json @@ -0,0 +1,9 @@ +{ + "$comment": "Growth ack (#2914 fragment). Reason: #3809 routes every command-position shim reference in runtime-loaded markdown through the canonical gsd_run launcher. The substitution SHRANK 19 emitted files; this is the only one that grew. Its line 65 is a descriptive comment inside a fenced block, and rewriting it to name a command at all would place a gsd_run token ahead of the file's canonical preamble at line 158, which runtime-launcher-parity's (B-agents) arm correctly rejects. The comment therefore names no command and explains where the config is actually loaded instead, costing 3 bytes. gsd-research-synthesizer.md 13847 -> 13850 LF bytes (+3).", + "version": 1, + "paths": { + "gsd-research-synthesizer.md": { + "reason": "descriptive comment reworded to name no command, so the file's first gsd_run token stays behind its canonical preamble (runtime-launcher-parity B-agents); +3 bytes" + } + } +} diff --git a/tests/no-bare-gsd-tools-command-position.test.cjs b/tests/no-bare-gsd-tools-command-position.test.cjs index a46930819..ff294e411 100644 --- a/tests/no-bare-gsd-tools-command-position.test.cjs +++ b/tests/no-bare-gsd-tools-command-position.test.cjs @@ -37,7 +37,28 @@ const path = require('node:path'); const ROOT = path.resolve(__dirname, '..'); const ROUTER_PATH = path.join(ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs'); -const SCAN_DIRS = ['agents', path.join('gsd-core', 'workflows')]; +// #3809 widened this from agents/ + workflows/ to include gsd-core/references/, +// which was carrying 37 bare calls the guard simply never looked at. +// +// commands/ is deliberately NOT here, and that is a finding rather than an +// oversight. Its files cannot use the shared launcher the way workflows and agents +// do: tests/graphify-visualization.test.cjs extracts individual Step-3 shell chains +// and runs them standalone, so each fenced block needs its OWN preamble — +// graphify.md carries five on purpose, and collapsing them to one produces +// `gsd_run: command not found` (exit 127). tests/gsd-tools-path-refs.test.cjs +// (#1766) separately pins commands/gsd/workstreams.md to the literal string +// `gsd-tools query workstream.list`. Bringing commands/ under this guard therefore +// needs those two contracts reconciled first; it is not a scan-set widening. +// +// skills/ is absent for a different reason: it is generated from commands/ by +// scripts/gen-plugin-skills.cjs and pinned by lint:generated-sync, so guarding the +// source guards both, and scanning the generated mirror would double-report every +// future offender. +const SCAN_DIRS = [ + 'agents', + path.join('gsd-core', 'workflows'), + path.join('gsd-core', 'references'), +]; // Derive the verb set the bare-call guard matches against. Most top-level // verbs live in the host-command router table as `'verb': routeHandler` entries @@ -87,7 +108,6 @@ const PROSE_ALLOWLIST = [ { file: 'agents/gsd-roadmapper.md', line: 642, reason: 'parenthetical "e.g." naming SDK queries a user *could* run; not an agent instruction' }, { file: 'agents/gsd-intel-updater.md', line: 40, reason: 'cross-platform note names the `gsd-tools intel ` CLI surface descriptively ("CLI invocations go through..."); not an agent instruction' }, { file: 'gsd-core/workflows/execute-plan.md', line: 415, reason: 'describes the downstream SDK validation step (`validated downstream by ...`); names the mechanism, does not instruct the agent to type it' }, - { file: 'agents/gsd-research-synthesizer.md', line: 65, reason: 'a code comment inside a fenced block explaining what the commit step loads (`# Planning config loaded via gsd-tools query ...`); descriptive, not an invocation — and explicitly names gsd-tools.cjs as the alternative' }, ]; // Resolver-snippet definition lines / probes that must never be flagged. A line diff --git a/tests/no-dead-sdk-refs.test.cjs b/tests/no-dead-sdk-refs.test.cjs index eaee21245..60fa09d64 100644 --- a/tests/no-dead-sdk-refs.test.cjs +++ b/tests/no-dead-sdk-refs.test.cjs @@ -1,27 +1,150 @@ 'use strict'; -// Regression guard for #2020 — dead SDK file references (sdk/src/..., sdk/dist/...) -// in runtime-loaded markdown cause AI runtimes to `find` them; on Git Bash for -// Windows `find /` traverses the whole drive (14h+, orphaned find.exe, 4M+ handles). -// The SDK package was retired (ADR-0174), so these paths never resolve. +// Guard: runtime-loaded markdown must not carry a reference an AI runtime will try to +// LOCATE on the filesystem. When a runtime meets a file-shaped token it cannot resolve, +// it falls back to searching for it — and on Git Bash for Windows `find /` maps to the +// drive root, so `find.exe` traverses the whole disk (orphaned processes, handle leak, +// a pegged core until someone reaps it by hand). // -// Scans the markdown a runtime loads + tries to locate references in -// (agents/, workflows/, references/) and fails on any sdk/(src|dist|handlers) -// file-path reference. Code-comment mentions in *.cjs (historical prose, not -// locatable file refs) are out of scope. +// The guard is a RULE TABLE, deliberately, because the first version of it was not. +// +// #2020 shipped a guard hardcoded to `sdk/(src|dist|handlers)/` — the three dead paths +// that had caused the storm. That is an instance fix wearing a regression test: it +// proved those three paths were gone and said nothing about the class. Seven weeks +// later #3809 reproduced the identical storm under a different token, and the guard +// was structurally incapable of seeing it. Adding a rule here must stay a one-entry +// change, so the next recurrence is a table row rather than a third incident. +// +// Scope: the markdown a runtime actually loads and resolves references against — +// agents/, gsd-core/workflows/, gsd-core/references/, commands/. Prose mentions inside +// *.cjs sources are out of scope: nothing tries to locate those. const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); +const fc = require('fast-check'); const fs = require('fs'); const path = require('path'); const ROOT = path.join(__dirname, '..'); -// Runtime-loaded markdown surfaces (the issue is about references a runtime -// tries to LOCATE as files — agents/workflows/references, not source comments). -const SCAN_DIRS = ['agents', 'gsd-core/workflows', 'gsd-core/references']; -// A dead SDK file-path reference: sdk/src|sdk/dist|sdk/handlers followed by a path. +const SCAN_DIRS = ['agents', 'gsd-core/workflows', 'gsd-core/references', 'commands']; + +// --------------------------------------------------------------------------- +// Rule A (#2020) — a dead SDK file-path reference. The SDK package was retired +// by ADR-0174, so these paths never resolve and a runtime will hunt for them. +// --------------------------------------------------------------------------- const DEAD_SDK_REF = /sdk\/(?:src|dist|handlers)\//; +// --------------------------------------------------------------------------- +// Rule B (#3809) — the runtime shim named in COMMAND position. +// +// The shim filename is not a command on any platform. package.json `bin` ships +// `gsd-core`, `gsd-tools`, `gsd_run`, `gsd-mcp-server`; the .cjs file exists only at +// /gsd-core/bin/. CONTEXT.md -> Runtime Launcher Module makes `gsd_run` +// the single sanctioned entry point: "Canonical space-safe shell preamble (`gsd_run`) +// used by every workflow bash block to invoke the GSD runtime CLI." +// +// So a workflow that says ` query phase.add` instructs the agent to run something +// that exits 127, after which the file-shaped token sends it looking for the file. +// +// What separates an INVOCATION from the four legitimate ways this filename appears is +// the token that follows it. Being lenient here is the entire point — the guard must +// not flag the launcher's own resolver, a real `node /` call, a bare path, +// or prose that simply names the file. See the negative-space rows below, each of which +// is a form that exists in the tree today and must keep working. +// --------------------------------------------------------------------------- + +// Built at runtime so this line is not itself an invocation the guard would flag. +const SHIM = ['gsd-tools', '.cjs'].join(''); + +// The pattern is spelled out rather than escaped from SHIM at build time. A +// `SHIM.replace(/\./g, '\\.')` here is a hand-rolled escaper: it handles the dot and +// nothing else, which CodeQL flags as js/incomplete-sanitization (it does not escape +// backslashes) and which local/no-adhoc-regex-escape bans outright — the canonical +// escaper lives in src/pattern.cts. Since SHIM is a compile-time constant whose only +// metacharacter is the dot, the honest fix is to carry no escaping logic at all. +// SHIM_PATTERN and SHIM are pinned to each other by a test below so they cannot drift. +const SHIM_PATTERN = 'gsd-tools\\.cjs'; +const SHIM_RE = new RegExp( + // not preceded by a path separator, word char, or hyphen (excludes `/`) + `(? s.trim()).filter(Boolean), + // `query` dispatches through the Command Routing Hub ahead of the host-router + // table, so it appears in neither export — yet it is the form 45 of the 50 #3809 + // offenders used. Verified live in this tree: bare `query` is a usage error while + // `query state-snapshot` dispatches and returns JSON. + 'query', + ]); + return _cliVerbs; +} + +/** + * Subcommand tokens invoked on the bare shim in one line of markdown. + * Returns [] for every legitimate form. Pure — no filesystem access. + */ +function findShimInvocations(line) { + // The canonical launcher's own single source of truth assigns the filename. + if (line.includes('_GSD_SHIM_NAME=')) return []; + + const found = []; + let m; + SHIM_RE.lastIndex = 0; + while ((m = SHIM_RE.exec(line)) !== null) { + // `node / ` is resolvable and fine. `node ` is NOT — + // node resolves a bare filename against cwd, so it fails exactly like the bare form. + // The exemption therefore requires a real path separator before the shim. + if (/\bnode[ \t]+["']?[^ \t"']*[/\\]$/.test(line.slice(0, m.index))) continue; + // A dotted subcommand is always `.` and the family is itself a roster + // verb, so testing the first segment covers `phase.add` and `state.patch` without + // resorting to "contains a dot", which also matches prose like `v1.2`. + const token = m[1]; + if (cliVerbs().has(token.split('.')[0])) { + found.push(token); + } + } + return found; +} + +const RULES = [ + { + id: '#2020', + label: 'dead SDK file reference', + remedy: 'the SDK package was retired (ADR-0174) — point at a live path', + scan: (line) => (DEAD_SDK_REF.test(line) ? ['sdk/'] : []), + }, + { + id: '#3809', + label: `bare \`${SHIM}\` invocation`, + remedy: 'call the canonical launcher instead: `gsd_run `', + scan: findShimInvocations, + }, +]; + function walkMd(dir, out = []) { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } @@ -34,21 +157,159 @@ function walkMd(dir, out = []) { return out; } -describe('#2020 — no dead SDK file references in runtime-loaded markdown', () => { +/** Offenders for one rule across every runtime-loaded markdown file. */ +function scanTree(rule) { const offenders = []; for (const rel of SCAN_DIRS) { - const absDir = path.join(ROOT, rel); - for (const file of walkMd(absDir)) { - const content = fs.readFileSync(file, 'utf8'); - const lines = content.split(/\r?\n/); + for (const file of walkMd(path.join(ROOT, rel))) { + const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); lines.forEach((line, i) => { - if (DEAD_SDK_REF.test(line)) offenders.push(`${path.relative(ROOT, file)}:${i + 1}`); + for (const hit of rule.scan(line)) { + offenders.push(`${path.relative(ROOT, file)}:${i + 1} (${hit})`); + } }); } } + return offenders; +} - test('agents/workflows/references contain no sdk/src|sdk/dist|sdk/handlers references', () => { - assert.deepEqual(offenders, [], - `Dead SDK file references found (runtimes \`find\` these → #2020 Windows find.exe storm):\n${offenders.join('\n')}`); +describe('runtime-loaded markdown carries no unresolvable reference', () => { + for (const rule of RULES) { + test(`${rule.id} — no ${rule.label} in ${SCAN_DIRS.join(', ')}`, () => { + const offenders = scanTree(rule); + assert.deepEqual( + offenders, + [], + `${rule.id}: ${offenders.length} ${rule.label}(s) found. Runtimes resolve these by ` + + `filesystem search — on Git Bash for Windows that is a full-drive find.exe storm.\n` + + `Remedy: ${rule.remedy}.\n${offenders.join('\n')}`, + ); + }); + } +}); + +describe('#3809 — what counts as a bare shim invocation', () => { + // Positive space: forms that send an agent hunting for the file. + const INVOCATIONS = [ + ['inline code in prose', `**Delegate the phase addition to \`${SHIM} query phase.add\`:**`, 'query'], + ['command substitution', `- \`ROADMAP=$(${SHIM} query roadmap.analyze)\``, 'query'], + ['dotted subcommand', `\`${SHIM} query state.add-roadmap-evolution ...\``, 'query'], + ['hyphenated subcommand', `Use \`${SHIM} detect-custom-files\``, 'detect-custom-files'], + ['bare verb, no backticks', `# config settings can be fetched via ${SHIM} query config-get`, 'query'], + ['commit verb', `via \`${SHIM} commit-to-subrepo\`. File paths are relative`, 'commit-to-subrepo'], + ]; + + for (const [name, line, expected] of INVOCATIONS) { + test(`flags ${name}`, () => { + assert.deepEqual(findShimInvocations(line), [expected]); + }); + test(`flags ${name} with a CRLF line ending`, () => { + // A trailing \r must not defeat the match — this repo has a documented + // bug class of regexes that only ever saw \n. + assert.deepEqual(findShimInvocations(`${line}\r`), [expected]); + }); + } + + // Negative space: every legitimate way the filename appears in the tree today. + // Each row is a real line; flagging any of them would be an over-broad fix. + const LEGITIMATE = [ + ['the launcher resolver assignment', `_GSD_SHIM_NAME="${SHIM}"; _GSD_RUNTIME_ROOT="\${RUNTIME_DIR:-$(pwd)}"`], + ['a node-prefixed invocation', ` node /gsd-core/bin/${SHIM} restore-custom-files \\`], + ['a quoted node-prefixed invocation', `node "$GSD_DIR/gsd-core/bin/${SHIM}" query commit`], + ['a qualified path with no subcommand', ` "$PREFERRED_CONFIG_DIR/gsd-core/bin/${SHIM}" \\`], + ['prose naming the file', `# Resolve ${SHIM} WITHOUT yet knowing GSD_DIR. The running workflow lives`], + ['prose whose next token is an English word', `# shim-only install (${SHIM} present, \`gsd-tools\` not on PATH) the bare call exits`], + ['prose with a lowercase English word after', `the ${SHIM} file lives under gsd-core/bin`], + ['the filename at end of line', `authoritative tool for THIS install is ${SHIM}`], + ['prose with a hyphenated English word after', `the ${SHIM} built-in helper does X`], + ['prose with a version number after', `the ${SHIM} v1.2 release notes`], + ]; + + for (const [name, line] of LEGITIMATE) { + test(`ignores ${name}`, () => { + assert.deepEqual(findShimInvocations(line), []); + }); + } + + // `node ` with no directory is NOT exempt: node resolves a bare filename + // against cwd, so it fails exactly like the bare form (found at + // gsd-core/references/model-profiles.md:231). + // The verb roster is the whole advertised command surface, not the subset that happens + // to appear in the tree — a bare verb nobody has written yet must still be caught. + for (const verb of ['phase', 'state', 'verify', 'roadmap', 'milestone', 'worktree']) { + test(`flags the bare verb \`${verb}\`, which appears nowhere in the tree today`, () => { + assert.deepEqual(findShimInvocations(`run \`${SHIM} ${verb} list\``), [verb]); + }); + } + + test('flags a node-prefixed shim that carries no path', () => { + assert.deepEqual(findShimInvocations(`\`node ${SHIM} effort sync --apply\``), ['effort']); + }); + + // Boundary: the separator between the filename and the subcommand. + test('zero separating spaces is not an invocation (limit-1)', () => { + assert.deepEqual(findShimInvocations(`\`${SHIM}query\``), []); + }); + test('exactly one separating space is an invocation (limit)', () => { + assert.deepEqual(findShimInvocations(`\`${SHIM} query\``), ['query']); + }); + test('more than one separating space is still an invocation (limit+1)', () => { + assert.deepEqual(findShimInvocations(`\`${SHIM} query\``), ['query']); + assert.deepEqual(findShimInvocations(`\`${SHIM}\tquery\``), ['query']); + }); + + // Properties — the matcher is a parser, so pin its two directional invariants. + test('property: a path-qualified or node-prefixed shim is never flagged', () => { + fc.assert( + fc.property( + fc.constantFrom('node ', 'node "', "node '", '/', './', '../', 'gsd-core/bin/', '$DIR/'), + fc.constantFrom('query', 'commit', 'phase.add', 'audit-open', 'from-gsd2'), + (prefix, verb) => { + const line = prefix.endsWith('/') + ? ` ${prefix}${SHIM} ${verb}` + : ` ${prefix}gsd-core/bin/${SHIM} ${verb}`; + assert.deepEqual(findShimInvocations(line), []); + }, + ), + { numRuns: 200 }, + ); + }); + + test('property: a bare shim followed by a subcommand is always flagged', () => { + fc.assert( + fc.property( + fc.constantFrom('', '`', '$(', '- ', 'run ', '**via '), + fc.constantFrom('query', 'commit', 'phase.add', 'audit-open', 'from-gsd2', 'commit-to-subrepo'), + fc.integer({ min: 1, max: 4 }), + (prefix, verb, spaces) => { + const line = `${prefix}${SHIM}${' '.repeat(spaces)}${verb}`; + assert.deepEqual(findShimInvocations(line), [verb]); + }, + ), + { numRuns: 300 }, + ); + }); +}); + +describe('#3809 — the verb roster is derived, not copied', () => { + test('SHIM_PATTERN matches SHIM exactly, so the two cannot drift', () => { + assert.equal(new RegExp(`^${SHIM_PATTERN}$`).test(SHIM), true, + `SHIM_PATTERN (${SHIM_PATTERN}) no longer matches SHIM (${SHIM})`); + // And prove the pattern's dot is escaped rather than a wildcard. + assert.equal(new RegExp(`^${SHIM_PATTERN}$`).test('gsd-toolsXcjs'), false, + 'the dot in SHIM_PATTERN must be escaped, not a wildcard'); + }); + + test('covers every command gsd-tools.cjs actually dispatches', () => { + const { HOST_COMMAND_ROUTERS } = require('../gsd-core/bin/gsd-tools.cjs'); + const missing = Object.keys(HOST_COMMAND_ROUTERS).filter((v) => !cliVerbs().has(v)); + assert.deepEqual(missing, [], `verb(s) dispatched by gsd-tools.cjs but invisible to this guard: ${missing.join(', ')}`); + }); + + test('recognises verbs that no hand-copied list had', () => { + // These ship in this tree but were absent from the hand-copied first cut. + for (const verb of ['websearch', 'windows', 'state-snapshot', 'context-predicates']) { + assert.equal(cliVerbs().has(verb), true, `roster is missing ${verb}`); + } }); });