fix(#705): route hardcoded $HOME gsd-tools invocations in agents/commands through gsd_run (#707)

* fix(#705): route hardcoded $HOME gsd-tools invocations in agents/commands through gsd_run

The hardcoded `node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" <cmd>` form
(fixed for workflows in #621/#637) survived in agent/command surfaces and
misresolves on global/shim-only installs. Route every agent-executed
invocation through the resolved `gsd_run` launcher in gsd-phase-researcher,
gsd-planner (load_graph_context extracted to a shared reference to stay under
the planner size budget), import, and graphify. Add a regression guard over
agents/ + commands/ + gsd-core/references/ bash blocks. User-facing display
messages and docs are intentionally left untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#705): use repo changeset fragment format (type: Fixed, pr: 707)

The hand-written fragment used the standard changesets package format
(package: bump) which lacks the type:/pr: frontmatter the repo's
docs-required lint consumes (fail_malformed_fragment / missing_type).
Regenerated via scripts/changeset/new.cjs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-05 09:26:29 -04:00
committed by GitHub
parent 0fbce0fbc7
commit 0e259a589c
11 changed files with 195 additions and 49 deletions

View File

@@ -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.

View File

@@ -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 "<capability-keyword>" --budget 1500
gsd_run graphify query "<capability-keyword>" --budget 1500
```
Derive query terms from the phase goal and requirement descriptions. Examples:

View File

@@ -818,39 +818,10 @@ If exists, load relevant documents by phase type:
</step>
<step name="load_graph_context">
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 "<phase-goal-keyword>" --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.
</step>
<step name="identify_phase">

View File

@@ -79,7 +79,8 @@ Modes:
Run:
```bash
node $HOME/.claude/gsd-core/bin/gsd-tools.cjs graphify query <term>
_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 <term>
```
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.

View File

@@ -33,8 +33,12 @@ $ARGUMENTS
<process>
If `--from-gsd2` is in $ARGUMENTS:
Run: `node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" from-gsd2`
Pass `--path <dir>` if provided. Present the migration result to the user.
Run the reverse-migration (append `--path <dir>` 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.

View File

@@ -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",

View File

@@ -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 `<verify><human-check>`. |
| `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. |

View File

@@ -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 "<phase-goal-keyword>" --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.

View File

@@ -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;
}

View File

@@ -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`'
);
});

View File

@@ -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 <optional-quote/backtick> ($HOME|${HOME}|~) <any non-newline chars> 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.`
);
});