The shebang check added in #3105 falls under structural-regression-guard:
the portability constraint (#!/usr/bin/env bash vs #!/bin/bash) cannot
be caught at runtime on distros that have /bin/bash. Adding the annotation
satisfies the adversarial review finding that new tests cannot use
pending-migration-to-typed-ir as their exemption category.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The three opt-in bash hooks (gsd-phase-boundary.sh, gsd-session-state.sh,
gsd-validate-commit.sh) shipped with #!/bin/bash, which fails on distros
that don't ship bash at /bin/bash (NixOS, minimal Alpine images, some
container runtimes). POSIX guarantees /bin/sh but not /bin/bash.
This is latent in the default install path because Claude Code wires the
hooks as `bash <path>` from settings.json (PATH-resolved — the script's
own shebang is read as a comment by bash). The fix matters when scripts
are run directly: tests, future installer changes, or manual debugging.
Changes:
- hooks/gsd-{phase-boundary,session-state,validate-commit}.sh: shebang
switched to #!/usr/bin/env bash, matching the convention already used
in scripts/*.sh.
- tests/bug-2136-sh-hook-version.test.cjs: assertion updated to expect
the new shebang; comment updated to spell out the rationale.
- tests/bug-2979-hook-absolute-node.test.cjs: doc-comment updated — the
prior wording cited "POSIX std PATH always has /bin" as the reason
bare `bash` is OK. The actual reason is that bare `bash` is
PATH-resolved, which is portable across distros that don't ship
/bin/bash. POSIX std PATH guarantees /bin/sh, not /bin/bash.
- bin/install.js::buildHookCommand: comment block clarifying the same.
No behavior change in this file — bare `bash` was already correct.
- .changeset/portable-bash-shebang-hooks.md: changeset entry.
Verified locally on NixOS:
- npm run build:hooks: hooks/dist/*.sh shebangs propagate correctly.
- node --test tests/bug-2136-*.cjs tests/bug-2979-*.cjs
tests/bug-1817-*.cjs tests/bug-1834-*.cjs tests/bug-1906-*.cjs
tests/bug-2557-*.cjs tests/bug-3017-*.cjs tests/security-scan.test.cjs
tests/hooks-doc-parity.test.cjs: 126/126 pass.
- node scripts/run-tests.cjs (full suite): 6944 pass / 0 fail / 5 skip.
Address CodeRabbit follow-up review on PR #3046. One real bug + two lockdown
gaps + one defensive assertion.
REAL BUG — sibling-asymmetry in <details> attribute tolerance:
extractCurrentMilestone's <details>-aware fallback uses <details\b[^>]*>
to tolerate attributes (#2641 hardening commit). stripShippedMilestones
still used literal <details>, so shipped content wrapped in
`<details open>` (or any attributed tag) leaked through the strip.
This is the failure mode trek-e's review almost caught with the
"<details open>" / extended-attribute test gap I deferred — CodeRabbit
caught the deeper issue: it's not just a test gap, it's an actual
asymmetry between the two functions that handle <details> blocks.
Fix: align stripShippedMilestones's regex with extractCurrentMilestone's
<details\b[^>]*> form. Comment explicitly notes the symmetry contract so
a future change to either function flags the other.
Tests added in stripShippedMilestones describe block:
- removes <details open> blocks
- removes <details class="..." data-..."> blocks
LOCKDOWN — leading-# strip in synthesized heading:
My existing inline-HTML test exercised tag-stripping but didn't directly
exercise the leading-# strip path (`.replace(/^#+\s*/, '')`). Added a
dedicated test with `<summary># v0.9 Hash-Prefixed</summary>` so a
future refactor that drops the strip would fail loudly instead of
producing `## # v0.9 …` (which downstream `#{2,4}` regex parses as a
4-hash header).
DEFENSIVE — toBeDefined guard in roadmapAnalyze regression test:
Added `expect(data.milestones).toBeDefined()` before casting and
calling `.some()`. Failure now reports "expected undefined to be
defined" instead of TypeError.
META: my prior adversarial pass missed the sibling-asymmetry because
the checklist's "sibling consistency" item only audited PARSERS for the
same INPUT field (STATE.md's `milestone:`), not ADJACENT FUNCTIONS that
process the same DATA SHAPE (<details> blocks). The latter is a wider
audit — every adjacent function that touches the data shape my new code
relies on. Will refine the learned rule.
Verification: 51/51 roadmap.test.ts pass (was 48; +3 tests).
FAMP smoke unchanged: roadmap.get-phase 3 returns active milestone phase.
Self-run adversarial pass on PR #3046 before next reviewer round-trip.
Three lockdown tests added — none uncovered new bugs, all lock current
behavior so a future change doesn't silently flip a convention.
1. Single-quote YAML version (`milestone: 'v0.9'`)
Parity with the existing double-quote test. The strip pattern
`/^["']|["']$/g` handles both — locked here so a future change to
either character class doesn't silently regress one form.
2. Heading-anchor wins over <details> fallback (precedence lock)
When a ROADMAP has BOTH `### v0.9` heading AND
`<details><summary>v0.9</summary>` block, the heading-level lookup
matches first and the fallback never fires. Test asserts the heading
slice is returned starting at offset 0 AND the synthesized
`## v0.9 ... details-anchored` heading is NOT prepended (proves
fallback didn't run). Also documented in-test that the heading-anchor
slice naturally includes downstream <details> blocks verbatim — a
property of the heading path, not of this PR's fallback.
3. Multiple <details> blocks for same version → first match wins
`content.match(detailsPattern)` (non-`g`) returns first match in
document order. Locked so a future change to the matcher (e.g.
switching to `matchAll` and picking last) doesn't silently change
which block is treated as active.
Adversarial-checklist coverage on commit 781cc6f8:
- Boundary cases: empty / whitespace / single-char / single-quote /
double-quote / digit-suffix (`v0.91`) / dot-suffix (`v0.9.0`) /
hyphen-suffix (`v0.9-rc.1`, intentional same-milestone match per
existing currentVersionStr convention) — all covered.
- Sibling consistency: parseMilestoneFromState, getMilestoneInfo,
extractCurrentMilestone all strip quotes identically.
- Comment-vs-behavior: walked nested-guard, empty-guard, lookahead,
tag-strip by hand against the regex; all comments accurate.
- Downstream consumers: roadmapAnalyze + roadmapGetPhase both verified
end-to-end via tests + FAMP smoke.
- Failure-mode locality: all fall-through paths produce loud failures
(empty arrays, `{found:false}`); no silent confident-wrong outputs.
48/48 roadmap.test.ts tests pass.
Address trek-e's adversarial review on PR #3046. Two critical merge-blockers
plus four hardening items, all now covered with tests.
CRITICAL #1 — substring-version trap:
`[^<]*${escapedVersion}[^<]*` did substring containment, so
`milestone: v0.1` matched <summary>v0.10 …</summary> and returned the
v0.10 block's body as the active milestone — confidently-wrong content
worse than the pre-PR fall-through. Add `(?![\d.])` non-version-character
lookahead, mirroring the same boundary protection used by the existing
`currentVersionStr` logic on the heading path. Test asserts v0.1 active
with v0.10 sibling block returns v0.1's phases, not v0.10's.
CRITICAL #2 — nested <details> silent truncation:
The lazy `[\s\S]*?</details>` terminates on the FIRST </details>, which
is the inner closer when nesting is present. Prior comment claimed
"would mis-anchor (acceptable; falls through)" — factually wrong: the
match succeeds with truncated body and is returned with a confident
`## ${summary}` heading. Future maintainer investigating a "missing
phase" report would be misled. Add `!detailsMatch[2].includes('<details')`
guard so nesting falls through to stripShippedMilestones (loud failure)
instead of returning truncated content (silent failure). Test locks
the contract: no synthesized v0.9 heading anchored to truncated body.
HARDENING:
- Empty-body guard: `<details><summary>v0.9</summary></details>` would
synthesize `## v0.9\n` (phantom milestone, zero phases, no error
signal). Treat as no-match.
- Inline-HTML in <summary>: rejected by `[^<]*` capture. Widen to
`(?:(?!</summary>).)*?` (non-greedy until close tag) and strip tags
+ leading `#` from the captured summary before promoting to a `##`
heading. Covers GitHub-rendered <em>(active)</em>, <code>v0.9</code>,
<strong>...</strong> patterns.
- JSDoc: rewrote to describe both anchoring strategies and the
synthesized-heading contract; demoted stale "Port of core.cjs lines
1102-1170" to historical context with the divergence list.
- Comment block: rewrote in contract style ("any consumer scanning
/##\s*.*vX.Y/ sees the active milestone") instead of coupling to
specific call sites (roadmapAnalyze, "later in this file"). Adds
explicit regex anatomy + hardening-guards section so future readers
can audit each guard.
OUT OF SCOPE (per trek-e's "Recommended action" tier):
- Debug logging on fall-through paths (Suggestion #10) — adds tracing
surface to a function that doesn't currently use logger; appropriate
for a follow-up if/when other extraction bugs surface.
- Uppercase <DETAILS>/<SUMMARY> + extended attribute coverage
(Test gap #7 last two rows) — already covered by the documented `i`
flag and the existing <details open> test; adding redundant cases
inflates the test set without locking new contracts.
Verification: 45/45 roadmap.test.ts tests pass (was 41/41; added 4
hardening tests). FAMP end-to-end smoke unchanged: roadmap.get-phase 3
returns "Claude Code integration polish", roadmap.analyze surfaces
v0.9 Local-First Bus in data.milestones with phase_count: 4.
Address CodeRabbit review on PR #3046: the prior commit returned only the
body inside <details>...</details>, which fixed the `roadmapGetPhase`
miss but left `roadmapAnalyze`'s downstream `data.milestones` scan
(`/##\s*(.*v(\d+(?:\.\d+)+)[^(\n]*)/gi` at the bottom of roadmap.ts)
without an active-milestone anchor in the returned slice.
Now capture the <summary> text and prepend it as a synthesized `##`
heading on the returned slice. This makes both `data.phases` (the
original bug) AND `data.milestones` (the downstream consumer) surface
the active milestone correctly for <details>-wrapped ROADMAPs.
Also widened the inner tag to `<summary\b[^>]*>` for symmetry with the
outer `<details\b[^>]*>` — both now tolerate attributes.
Verified end-to-end against FAMP's v0.9 ROADMAP:
- Before this commit (after PR #3046 base):
milestones: [{heading: '# Phase 1: ... (v0.5.2 atomic bump)', version: 'v0.5.2'}]
- After this commit:
milestones: [{heading: 'v0.9 Local-First Bus', version: 'v0.9'},
{heading: '# Phase 1: ... (v0.5.2 ...)', version: 'v0.5.2'}]
(The v0.5.2 entry is pre-existing noise from the loose `##\s*` regex
matching the `### Phase 1: famp-bus (v0.5.2 atomic bump)` body heading;
unrelated to this fix and out of scope for this PR.)
Tests:
- Updated the two `<details><summary>` tests to assert the synthesized
`## v0.9 Local-First Bus` heading is present on the returned slice.
- Added a 4th regression test (`roadmapAnalyze`) confirming
`data.milestones` now contains the active milestone for
<details>-wrapped ROADMAPs.
- All 40 roadmap.test.ts tests pass.
Address CodeRabbit review on PR #3046: extractCurrentMilestone read the
`milestone:` value from STATE.md frontmatter via `.trim()` only, while
parseMilestoneFromState() and getMilestoneInfo() both also strip
surrounding YAML quotes via `.replace(/^["']|["']$/g, '')`.
For projects whose STATE.md uses quoted YAML (`milestone: "v0.9"`),
`version` carried literal quotes, `escapedVersion` became `\"v0\.9\"`,
and neither the markdown-heading regex nor the new <details><summary>
fallback could match anything — falling through to
stripShippedMilestones() and reintroducing the same archived-milestone
misrouting this PR addresses.
Strip quotes for parity. Three-line addition + one new test.
All 41 roadmap.test.ts tests pass.
`extractCurrentMilestone` only matched markdown headings (## v0.9, ### v0.9)
to find the active milestone slice. Projects that wrap their active
milestone's phase details inside `<details><summary>vX.Y …</summary>`
(a common GitHub-friendly collapse pattern, e.g. FAMP) fell through to
`stripShippedMilestones`, which strips ALL `<details>` blocks indiscriminately.
Net effect: `roadmapGetPhase` returned `{found:false}` for phases that ARE in
the active ROADMAP. The `init.phase-op` safety guard at `init.ts:133`
('drop archived disk match when phase is in current ROADMAP') depends on
`roadmapPhase.found`, so it didn't fire. `init.phase-op` then returned a
`phase_dir` pointing at an ARCHIVED milestone's same-numbered phase —
silently routing downstream workflows (e.g. /gsd-discuss-phase) into
completed phases.
Fix: when no markdown heading matches the active version, try matching
`<details\b[^>]*><summary>...vX.Y...</summary>`. Returns the inner content
of the matching block. Purely additive — `stripShippedMilestones` behavior
and its tests are unchanged.
The `\b[^>]*>` form tolerates attributes like `<details open>` or
`<details class="...">` (GitHub commonly emits `<details open>` for
default-expanded sections). Lazy `[\s\S]*?` matches up to the first
`</details>`; nested `<details>` inside the active milestone are not
expected and would mis-anchor (acceptable; falls through to the existing
`stripShippedMilestones` path with no regression vs. today's behavior).
Closes#2641. Distinct from the closed#2642 which bundled three orthogonal
changes (parser fix + checkbox-scan fix + STATE.md counting auth) into one
PR; this PR addresses only the parser anchoring bug, leaving
`stripShippedMilestones`, `roadmapAnalyze`, and `initMilestoneOp` untouched.
Tests added (3, all in `roadmap.test.ts`):
- `bug-2641: finds active milestone wrapped in <details><summary>vX.Y …</summary>`
- `bug-2641: finds active milestone in <details open><summary>vX.Y …</summary>`
- `bug-2641: returns found:true for phase inside <details>-wrapped active milestone` (end-to-end via `roadmapGetPhase`)
All existing `roadmap.test.ts` tests pass (39/39). Real-world repro
verified against an FAMP-style ROADMAP: before the fix,
`gsd-sdk query roadmap.get-phase 3` returned `{found:false}` despite the
phase being at line 113 of the active ROADMAP; after the fix, it returns
the correct phase metadata, and `init.phase-op 3` no longer returns the
v0.8 archived `phase_dir`.
Fixes#3164
Validators and find-phase hardcoded phasesDir = .planning/phases/, so projects
using the milestone-archive layout (.planning/milestones/v*-phases/) had an empty
diskPhases set, triggering W006 for every active phase and find-phase returning
found:false.
Add collectDiskPhases(planBase) helper that scans both flat layout and all
.planning/milestones/v*-phases/ subdirs. Wire it into cmdValidateConsistency,
cmdValidateHealth (both the Check 4 validPhases set and Check 8 diskPhases),
and refactor cmdFindPhase to iterate candidate search dirs so it also searches
milestone-archive dirs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes#3168
The Claude Code subagent dispatcher tool is named `Agent` (with `subagent_type`
parameter). The `Task*` namespace (TaskCreate, TaskList, TaskGet, TaskUpdate,
TaskOutput, TaskStop) is the separate task-tracker. GSD's commands, workflows,
and agents were partially migrated and still referenced `- Task` / `Task(` in
55 files, causing orchestrators to silently fall back to inline execution when
no `Task` tool appeared on their tool surface.
Changes:
- `commands/gsd/*.md` allowed-tools: replaced `- Task` with `- Agent` in 24
files; removed duplicate `- Task` from autonomous.md (already had `- Agent`)
- `get-shit-done/workflows/*.md`: replaced dispatcher `Task(` → `Agent(` in
29 workflow files (~133 call sites); TaskCreate/List/Get/Update/Output/Stop
left untouched
- `agents/gsd-debug-session-manager.md`: replaced `Task` → `Agent` in tools
frontmatter (the only remaining agent with the wrong name)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes#3162
`resolve_model_ids` is a documented top-level config key (CONFIGURATION.md)
read by core.cjs and session-runner.ts, but was missing from the CJS and SDK
VALID_CONFIG_KEYS allowlists — causing config-set to reject it with
"Unknown config key".
`workflow._auto_chain_active` is internal runtime state intentionally excluded
from VALID_CONFIG_KEYS by #2530, but plan-phase, execute-phase, discuss-phase,
transition, and new-project workflows all write it via `config-set`. Without
a valid write path these calls emit spurious errors (silenced with `|| true`
but noisy in logs). A new RUNTIME_STATE_KEYS set in config-schema.cjs holds
keys that isValidConfigKey() accepts without exposing them as user-settable
options — preserving the #2530 intent while fixing the runtime error.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs: add Prerelease editions install guidance (Next/Nightly/Insiders/Preview)
Documents the existing <RUNTIME>_CONFIG_DIR override pattern for users on
prerelease runtime editions (Windsurf Next, Cursor Nightly, VS Code Insiders,
Codex preview, JetBrains EAP, etc.) and explicitly states they are best-effort
and not separately tested under release CI — consistent with the free-string
runtime policy in #2517.
Resolves the discoverability gap behind issue #3161 without enumerating each
prerelease channel as a named runtime. Future "add <runtime>-next/-nightly"
requests can be redirected to the new section.
Closes#3172
* chore(changeset): set PR number for prerelease docs fragment
* test(graphify): TDD-red design contract for #3170 commit-staleness signal
Captures the proposed extension to graphifyStatus() as 8 failing
assertions across 3 groups (git-aware, non-git, back-compat). Suite is
describe.skip()'d so npm test stays green on the branch — removing
.skip is the green-light moment when the enhancement is approved and
implementation lands.
Verified against safishamsi/graphify v0.7.0 release notes: the field
on graph.json is built_at_commit (full git HEAD), not commit_hash as
originally guessed in #3170. Tests assert against the verified name.
Design highlights captured in the file's docstring:
- Tri-state commit_stale (true/false/null) — null means "we don't
know" (pre-v0.7 graph or no git), distinct from false ("known fresh")
- Argument-injection fence /^[0-9a-f]{4,40}$/i validates built_at_commit
before it reaches `git` as an argv element
- Existing graphifyStatus() fields (node_count, edge_count, stale,
age_hours, etc.) are unchanged — back-compat fenced
Per the issue's enhancement template: no PR will be opened until the
issue is labeled `approved-enhancement`.
Refs #3170
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(graphify): surface commit-based staleness from graphify v0.7+ built_at_commit
Closes#3170
graphify v0.7+ embeds built_at_commit (full git HEAD) into graph.json at
write time. GSD's existing graphifyStatus() ignored it; staleness was
mtime-only, which is a poor proxy for "does this graph reflect the
current code." A CI-built graph rebuilt minutes ago against an old
checkout reads as FRESH on mtime but is materially stale.
graphifyStatus() now returns four additional fields on the success path:
built_at_commit short hash from graph.built_at_commit, or null
current_commit short hash of git HEAD, or null when no git
commits_behind git rev-list --count <built>..HEAD, or null
commit_stale true | false | null
Tri-state on commit_stale is load-bearing. null means "we don't know"
(pre-v0.7 graph, non-git cwd, unreachable commit) — semantically
distinct from false ("known fresh"). Agents reading null should fall
back to mtime; reading false can confidently skip a rebuild.
Security: built_at_commit is on-disk and user-influenceable. Without
validation, a hostile value (e.g. "--upload-pack=evil") would reach git
as an argv element and be interpreted as an option. The
/^[0-9a-f]{4,40}$/i fence rejects anything else as absent. spawnSync's
array args (no shell) is defense in depth, not the boundary.
Skill (commands/gsd/graphify.md) Step 2b renders one conditional line:
Source commit: abc1234 (5 commits behind HEAD)
Source commit: abc1234 (current)
Source commit: abc1234 (freshness unknown)
Pre-v0.7 graphs omit the line entirely — no confusing "Source commit:
unknown" rendered.
Also documents `graphify hook install` in docs/CONFIGURATION.md for
multi-dev teams who would otherwise hit graph.json merge conflicts on
parallel rebuilds (sub-enhancement 2 from #3170).
TDD red→green: tests/enh-3170-graphify-commit-staleness.test.cjs
(8 assertions across git-aware, non-git, back-compat) was committed
describe.skip()'d in c567f23d when the issue was filed; this commit
removes .skip and lands the implementation that makes them green.
Full suite 7503/7503 passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(graphify): run /gsd-graphify build inline instead of spawning a sub-agent
Closes#3166
graphify v0.7+ split the build into a fast AST-extraction phase (cached)
followed by a separate clustering + report-write phase. The cached
extraction phase survived sub-agent isolation, but the post-extraction
phase was SIGTERM'd when the agent exited, leaving the cache populated
and no graph.json / graph.html / GRAPH_REPORT.md artifacts written to
.planning/graphs/.
The skill now runs `graphify update .`, the three artifact copies, the
snapshot, and the status report as a single foreground Bash call so the
entire pipeline survives to completion. The CLI's `graphify build`
pre-flight still returns `action: "spawn_agent"` so external callers
and existing tests in tests/graphify.test.cjs keep working.
Regression test (tests/bug-3166-graphify-inline-build.test.cjs) parses
the skill's YAML frontmatter and body structurally to fence against
re-introducing Task to allowed-tools or `Task(` invocation syntax — a
future edit cannot regress the fix without tripping the fence.
Verified against safishamsi/graphify v0.7.0–v0.7.8 release notes:
`graphify update .` invocation and output filenames are unchanged in
v0.7+; no GSD-side interface migration is required.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(test): drop yaml dep from bug-3166 fence — replace with inline parser
CI failed with MODULE_NOT_FOUND on `require('yaml')` — the package
resolved locally as a transitive dep but isn't declared in package.json.
The project pattern (see tests/helpers.cjs `parseFrontmatter`) deliberately
avoids pulling in yaml/js-yaml.
Replace with a narrow inline parser that handles the scalar + block-list
subset used in this skill's frontmatter. Verified the fence still trips
when Task is reintroduced to allowed-tools.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(test): parse fenced blocks structurally for #3166 fence
Address CodeRabbit nitpicks on PR #3169: the body assertions used raw
markdown text regex (\bTask\s*\(/, /graphify\s+update\s+\./) which
violates the project's "parse, never grep" testing convention and risks
false-positives on prose.
Replace with extractFencedBlocks(body) which returns
[{lang, content}, ...] tuples per markdown code fence. Body assertions
now run against parsed blocks:
- "no fenced code block contains Task("
→ deepEqual offending blocks to [] (vs. regex on raw body)
- "a bash block invokes graphify update . / build snapshot"
→ filter to lang === 'bash', then substring-check inside parsed content
Substring checks within already-parsed fenced content are structural —
prose mentioning the word "Task" can no longer false-positive, and a
future prose reference to graphify cannot satisfy the positive assertions
either. The frontmatter side already used a parser; both sides now match.
Verified: re-introducing Task( inside a code fence still trips the
assertion. Full suite 7499/7499 passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(test): rename readFileSync-bound var to satisfy lint-no-source-grep
The structural-parse refactor introduced `b.content.includes(...)` calls
on parsed fenced-block records, but `loadSkill()` had also bound
`const content = fs.readFileSync(...)` for the markdown text. The
lint-no-source-grep regex scanner cannot distinguish scopes — it sees
"variable `content` is bound from readFileSync" and "`content.includes`
is called" and flags it as a source-grep test, even though the two
`content`s are different lexical entities.
Rename the readFileSync-bound local to `markdown`. Now `b.content` is
unambiguously a property access on a parsed-block record. Lint passes
(0 violations across 401 test files); behavior unchanged (4/4 tests
still pass, including the negative regression case).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(test): tighten snapshot assertion to gsd-tools.cjs prefix
CodeRabbit nitpick on bug-3166 fence: the snapshot bash assertion accepted
any 'graphify build snapshot' substring. Tighten to require it follows
'gsd-tools.cjs', matching the actual fenced invocation in
commands/gsd/graphify.md (which uses node "$HOME/.../gsd-tools.cjs" graphify
build snapshot — note the closing quote, so a literal 'gsd-tools graphify build
snapshot' substring would not match).
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>