diff --git a/.changeset/sturdy-lemurs-roar.md b/.changeset/sturdy-lemurs-roar.md new file mode 100644 index 000000000..a3a255f7b --- /dev/null +++ b/.changeset/sturdy-lemurs-roar.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 707 +--- +**`/gsd:graphify`, `/gsd:import`, and planning agents now resolve `gsd-tools` on global/shim-only installs** — agent and command surfaces that invoked a hardcoded `$HOME/.claude/...gsd-tools.cjs` path now route through the resolved `gsd_run` launcher, so the step no longer reports the tool "not found" when there is no project-local runtime. diff --git a/agents/gsd-phase-researcher.md b/agents/gsd-phase-researcher.md index 8c3368436..0cf27e1f6 100644 --- a/agents/gsd-phase-researcher.md +++ b/agents/gsd-phase-researcher.md @@ -632,7 +632,8 @@ ls .planning/graphs/graph.json 2>/dev/null If graph.json exists, check freshness: ```bash -node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" graphify status +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi +gsd_run graphify status ``` If the status response has `stale: true`, note for later: "Graph is {age_hours}h old -- treat semantic relationships as approximate." Include this annotation inline with any graph context injected below. @@ -640,7 +641,7 @@ If the status response has `stale: true`, note for later: "Graph is {age_hours}h Query the graph for each major capability in the phase scope (2-3 queries per D-05, discovery-focused): ```bash -node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" graphify query "" --budget 1500 +gsd_run graphify query "" --budget 1500 ``` Derive query terms from the phase goal and requirement descriptions. Examples: diff --git a/agents/gsd-planner.md b/agents/gsd-planner.md index 135ae1494..2a6a9aaac 100644 --- a/agents/gsd-planner.md +++ b/agents/gsd-planner.md @@ -818,39 +818,10 @@ If exists, load relevant documents by phase type: -Check for knowledge graph: - -```bash -ls .planning/graphs/graph.json 2>/dev/null -``` - -If graph.json exists, check freshness: - -```bash -node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" graphify status -``` - -If the status response has `stale: true`, note for later: "Graph is {age_hours}h old -- treat semantic relationships as approximate." Include this annotation inline with any graph context injected below. - -Query the graph for phase-relevant dependency context (single query per D-06): - -```bash -node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" graphify query "" --budget 2000 -``` - -(graphify is not exposed on `gsd-tools query` yet; use `gsd-tools.cjs` for graphify only.) - -Use the keyword that best captures the phase goal. Examples: -- Phase "User Authentication" -> query term "auth" -- Phase "Payment Integration" -> query term "payment" -- Phase "Database Migration" -> query term "migration" - -If the query returns nodes and edges, incorporate as dependency context for planning: -- Which modules/files are semantically related to this phase's domain -- Which subsystems may be affected by changes in this phase -- Cross-document relationships that inform task ordering and wave structure - -If no results or graph.json absent, continue without graph context. +Read `gsd-core/references/planner-load-graph-context.md` and execute it. It checks for a +knowledge graph and, if `.planning/graphs/graph.json` exists, reads freshness and +phase-relevant dependency context via the `gsd_run` launcher and incorporates the results +into planning. If the graph is absent, skip and continue without graph context. diff --git a/commands/gsd/graphify.md b/commands/gsd/graphify.md index 9a025eecd..5780a148c 100644 --- a/commands/gsd/graphify.md +++ b/commands/gsd/graphify.md @@ -79,7 +79,8 @@ Modes: Run: ```bash -node $HOME/.claude/gsd-core/bin/gsd-tools.cjs graphify query +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi +gsd_run graphify query ``` Parse the JSON output and display results: @@ -95,7 +96,8 @@ Parse the JSON output and display results: Run: ```bash -node $HOME/.claude/gsd-core/bin/gsd-tools.cjs graphify status +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi +gsd_run graphify status ``` Parse the JSON output and display: @@ -119,7 +121,8 @@ Surface both so the agent can choose. Run: ```bash -node $HOME/.claude/gsd-core/bin/gsd-tools.cjs graphify diff +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi +gsd_run graphify diff ``` Parse the JSON output and display: @@ -137,7 +140,8 @@ If no snapshot exists, suggest running `build` twice (first to create, second to Run the pre-flight check first: ```bash -node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" graphify build +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi +gsd_run graphify build ``` Parse the JSON output: @@ -156,12 +160,13 @@ GSD > Building knowledge graph... Run the build, copy artifacts, write the diff snapshot, and report the summary in a single foreground Bash call so the whole pipeline survives to completion. Use a `timeout` of `600000` ms (10 minutes), which covers the `graphify.build_timeout` ceiling (default 300 s) with margin: ```bash +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi graphify update . \ && cp graphify-out/graph.json .planning/graphs/graph.json \ && { [ -f graphify-out/graph.html ] && cp graphify-out/graph.html .planning/graphs/graph.html || true; } \ && cp graphify-out/GRAPH_REPORT.md .planning/graphs/GRAPH_REPORT.md \ - && node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" graphify build snapshot \ - && node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" graphify status + && gsd_run graphify build snapshot \ + && gsd_run graphify status ``` Do NOT pass `run_in_background: true`. Typical builds complete in 15-60 seconds and the entire chain must run foreground. diff --git a/commands/gsd/import.md b/commands/gsd/import.md index b9c1370e4..2012bdbcf 100644 --- a/commands/gsd/import.md +++ b/commands/gsd/import.md @@ -33,8 +33,12 @@ $ARGUMENTS If `--from-gsd2` is in $ARGUMENTS: -Run: `node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" from-gsd2` -Pass `--path ` if provided. Present the migration result to the user. +Run the reverse-migration (append `--path ` if provided): +```bash +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi +gsd_run from-gsd2 +``` +Present the migration result to the user. Stop here (do not run the standard import workflow). Otherwise, execute the import workflow end-to-end. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 64303c715..5953c397f 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -1,5 +1,5 @@ { - "generated": "2026-06-02", + "generated": "2026-06-05", "families": { "agents": [ "gsd-advisor-researcher", @@ -227,6 +227,7 @@ "planner-graphify-auto-update.md", "planner-human-verify-mode.md", "planner-interface-context.md", + "planner-load-graph-context.md", "planner-mvp-mode.md", "planner-reviews.md", "planner-revision.md", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 3f059653a..122e143ba 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -264,7 +264,7 @@ Full roster at `gsd-core/workflows/*.md`. Workflows are thin orchestrators that --- -## References (63 shipped) +## References (64 shipped) Full roster at `gsd-core/references/*.md`. References are shared knowledge documents that workflows and agents `@-reference`. The groupings below match [`docs/ARCHITECTURE.md`](ARCHITECTURE.md#references-gsd-corereferencesmd) — core, workflow, thinking-model clusters, and the modular planner decomposition. @@ -358,6 +358,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t | `planner-human-verify-mode.md` | Rules for `workflow.human_verify_mode = end-of-phase`: suppress `checkpoint:human-verify` task emission and route deferred items via ``. | | `planner-graphify-auto-update.md` | How `load_graph_context` surfaces `.last-build-status.json` auto-update state (running / failed / stale head) alongside the existing staleness annotation. Opt-in via `graphify.auto_update` (#3347). | | `planner-interface-context.md` | Interface context rules for executors — how to extract key interfaces/types/exports from existing code and document new interfaces that downstream plans will consume. | +| `planner-load-graph-context.md` | Planner's load_graph_context step: knowledge-graph freshness + dependency-context query via the gsd_run launcher (extracted from gsd-planner.md). | | `skeleton-template.md` | SKELETON.md template emitted for new-project Walking Skeleton (Phase 1 + `--mvp`). | | `user-story-template.md` | User story format for MVP planning — "As a / I want to / So that" structured fields. | | `spidr-splitting.md` | SPIDR splitting decomposition rules for handling large user stories in MVP mode. | diff --git a/gsd-core/references/planner-load-graph-context.md b/gsd-core/references/planner-load-graph-context.md new file mode 100644 index 000000000..74f0fe5c4 --- /dev/null +++ b/gsd-core/references/planner-load-graph-context.md @@ -0,0 +1,36 @@ +# Planner — Load Graph Context + +> Loaded by `gsd-planner` at the `load_graph_context` step. + +Check for knowledge graph: + +```bash +ls .planning/graphs/graph.json 2>/dev/null +``` + +If graph.json exists, check freshness: + +```bash +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi +gsd_run graphify status +``` + +If the status response has `stale: true`, note for later: "Graph is {age_hours}h old -- treat semantic relationships as approximate." Include this annotation inline with any graph context injected below. + +Query the graph for phase-relevant dependency context (single query per D-06): + +```bash +gsd_run graphify query "" --budget 2000 +``` + +Use the keyword that best captures the phase goal. Examples: +- Phase "User Authentication" -> query term "auth" +- Phase "Payment Integration" -> query term "payment" +- Phase "Database Migration" -> query term "migration" + +If the query returns nodes and edges, incorporate as dependency context for planning: +- Which modules/files are semantically related to this phase's domain +- Which subsystems may be affected by changes in this phase +- Cross-document relationships that inform task ordering and wave structure + +If no results or graph.json absent, continue without graph context. diff --git a/tests/bug-622-graphify-optional-graph-html.test.cjs b/tests/bug-622-graphify-optional-graph-html.test.cjs index ad71f0877..a3a206a5e 100644 --- a/tests/bug-622-graphify-optional-graph-html.test.cjs +++ b/tests/bug-622-graphify-optional-graph-html.test.cjs @@ -41,8 +41,9 @@ const GRAPHIFY_MD = path.join(__dirname, '..', 'commands', 'gsd', 'graphify.md') */ function extractStep3Block() { const content = fs.readFileSync(GRAPHIFY_MD, 'utf-8'); - // Capture from the `graphify update .` line through the next closing ``` fence. - const match = content.match(/```bash\r?\n(graphify update \.[^\0]*?)```/); + // Capture the full body of the ```bash fence that CONTAINS `graphify update .` + // (including any leading preamble line), without crossing into other fences. + const match = content.match(/```bash\r?\n((?:(?!```)[\s\S])*?graphify update \.(?:(?!```)[\s\S])*?)\r?\n```/); return match ? match[1].trim() : null; } diff --git a/tests/graphify-visualization.test.cjs b/tests/graphify-visualization.test.cjs index f6d77bf9c..ee0a7aad4 100644 --- a/tests/graphify-visualization.test.cjs +++ b/tests/graphify-visualization.test.cjs @@ -469,8 +469,8 @@ describe('regressions', () => { 'a bash code block must invoke `graphify update .`' ); assert.ok( - bashBlocks.some(b => /gsd-tools\.cjs["']?\s+graphify build snapshot/.test(b.content)), - 'a bash code block must invoke `gsd-tools.cjs graphify build snapshot`' + bashBlocks.some(b => /gsd_run\s+graphify build snapshot/.test(b.content)), + 'a bash code block must invoke `gsd_run graphify build snapshot`' ); }); diff --git a/tests/no-hardcoded-home-gsd-tools.test.cjs b/tests/no-hardcoded-home-gsd-tools.test.cjs new file mode 100644 index 000000000..637853a5b --- /dev/null +++ b/tests/no-hardcoded-home-gsd-tools.test.cjs @@ -0,0 +1,121 @@ +// allow-test-rule: source-text-is-the-product +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..'); +const SCAN_DIRS = ['agents', 'commands', path.join('gsd-core', 'references')]; + +// Fix #3: broaden to catch backtick-delimited and split-quoted forms. +// Matches: node ($HOME|${HOME}|~) gsd-tools.cjs +// PREAMBLE_SKIP_RE is still applied before this to exclude preamble lines. +const HARDCODED_RE = /node\s+[`"']?(?:\$HOME|\$\{HOME\}|~)[^\n]*?gsd-tools\.cjs/; +const PREAMBLE_SKIP_RE = /_GSD_SHIM_NAME|GSD_TOOLS=|\[ -f/; + +function collectMdFiles(dir) { + const results = []; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...collectMdFiles(full)); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + results.push(full); + } + } + return results; +} + +function extractBashBlocks(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + const lines = content.split(/\r?\n/); + const blocks = []; + let inBash = false; + let blockLines = []; + let blockStart = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!inBash) { + // Fix #1: match bash/sh as the language tag regardless of any trailing info string. + if (/^```(?:bash|sh)(?:\s.*)?$/.test(line)) { + inBash = true; + blockLines = []; + blockStart = i + 1; + } + // Plain ``` fences (no language) are ignored + } else { + if (/^```\s*$/.test(line)) { + blocks.push({ lines: blockLines, startLine: blockStart }); + inBash = false; + blockLines = []; + } else { + blockLines.push({ text: line, lineNum: i + 1 }); + } + } + } + + // Fix #2: if file ends while still inside a bash block, push the accumulated block. + if (inBash && blockLines.length > 0) { + blocks.push({ lines: blockLines, startLine: blockStart }); + } + + return blocks; +} + +// Fix #4: scan returns per-directory block counts so we can assert each dir contributed. +function scanAll() { + const violations = []; + const perDirCounts = {}; + + for (const dir of SCAN_DIRS) { + const absDir = path.join(ROOT, dir); + perDirCounts[dir] = { exists: fs.existsSync(absDir), bashBlockCount: 0 }; + if (!perDirCounts[dir].exists) continue; + const files = collectMdFiles(absDir); + for (const file of files) { + const blocks = extractBashBlocks(file); + perDirCounts[dir].bashBlockCount += blocks.length; + const relPath = path.relative(ROOT, file).replace(/\\/g, '/'); + for (const block of blocks) { + for (const { text, lineNum } of block.lines) { + if (HARDCODED_RE.test(text) && !PREAMBLE_SKIP_RE.test(text)) { + violations.push(`${relPath}:${lineNum}: ${text.trim()}`); + } + } + } + } + } + + return { violations, perDirCounts }; +} + +test('no hardcoded $HOME gsd-tools.cjs in bash blocks of agents/, commands/, and gsd-core/references/', () => { + const { violations } = scanAll(); + assert.equal( + violations.length, + 0, + `Found ${violations.length} hardcoded invocation(s):\n${violations.join('\n')}` + ); +}); + +// Fix #4: per-directory floor — each scan dir must exist and contribute >= 1 bash block. +test('each scan dir (agents/, commands/, gsd-core/references/) exists and contains at least one bash block', () => { + const { perDirCounts } = scanAll(); + const failures = []; + for (const [dir, { exists, bashBlockCount }] of Object.entries(perDirCounts)) { + if (!exists) { + failures.push(` ${dir}/: directory does not exist`); + } else if (bashBlockCount < 1) { + failures.push(` ${dir}/: exists but contains 0 bash blocks`); + } + } + assert.equal( + failures.length, + 0, + `Per-directory bash-block floor failed:\n${failures.join('\n')}\nCheck that each scan dir is non-empty and contains bash fences.` + ); +});