diff --git a/.changeset/humble-tigers-tumble.md b/.changeset/humble-tigers-tumble.md new file mode 100644 index 000000000..ebc2429a8 --- /dev/null +++ b/.changeset/humble-tigers-tumble.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 642 +--- +**`/gsd-import`, `/gsd-plan-review-convergence`, and `/gsd-spec-phase` now run on global installs** — these workflows resolve `gsd-tools` via the runtime launcher instead of a hardcoded `$HOME` path, so they no longer falsely report the tool as "not found" (and stop short) when only a global/shim install is present and no project-local runtime exists. diff --git a/gsd-core/workflows/ingest-docs.md b/gsd-core/workflows/ingest-docs.md index 06985a4e3..f654cd004 100644 --- a/gsd-core/workflows/ingest-docs.md +++ b/gsd-core/workflows/ingest-docs.md @@ -52,7 +52,8 @@ If `PATH_NOT_FOUND` or `MANIFEST_NOT_FOUND`: display error and exit. Run the init query: ```bash -INIT=$(node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" init ingest-docs) +_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 +INIT=$(gsd_run init ingest-docs) if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi ``` @@ -295,7 +296,7 @@ Preview the merge diff to the user and gate via approve-revise-abort before writ Commit the ingest results: ```bash -node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" commit \ +gsd_run commit \ "docs: ingest {N} docs from {SCAN_PATH} (#2387)" --files \ .planning/PROJECT.md \ .planning/REQUIREMENTS.md \ diff --git a/gsd-core/workflows/plan-review-convergence.md b/gsd-core/workflows/plan-review-convergence.md index 374bf8aac..eaacfefeb 100644 --- a/gsd-core/workflows/plan-review-convergence.md +++ b/gsd-core/workflows/plan-review-convergence.md @@ -63,7 +63,7 @@ Then re-run: /gsd:plan-review-convergence {PHASE} ## 2. Initialize ```bash -INIT=$(node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" init plan-phase "$PHASE") +INIT=$(gsd_run init plan-phase "$PHASE") if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi ``` @@ -76,7 +76,7 @@ Set `TEXT_MODE=true` if `--text` is present in $ARGUMENTS OR `text_mode` from in ## 3. Validate Phase + Pre-flight Gate ```bash -PHASE_INFO=$(node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" roadmap get-phase "${PHASE}") +PHASE_INFO=$(gsd_run roadmap get-phase "${PHASE}") ``` **If `found` is false:** Error with available phases. Exit. @@ -230,7 +230,7 @@ fi **If HIGH_COUNT == 0 (converged):** ```bash -node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" state planned-phase --phase "${PHASE}" --name "${phase_name}" --plans "${PLAN_COUNT}" +gsd_run state planned-phase --phase "${PHASE}" --name "${phase_name}" --plans "${PLAN_COUNT}" ``` Display: diff --git a/gsd-core/workflows/spec-phase.md b/gsd-core/workflows/spec-phase.md index 620b132f3..5ae0f3d16 100644 --- a/gsd-core/workflows/spec-phase.md +++ b/gsd-core/workflows/spec-phase.md @@ -56,7 +56,8 @@ Rotate through these perspectives — each naturally surfaces different blindspo ## Step 1: Initialize ```bash -INIT=$(node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" init phase-op "${PHASE}") +_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 +INIT=$(gsd_run init phase-op "${PHASE}") if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi ``` diff --git a/tests/bug-2801-ingest-docs-handler.test.cjs b/tests/bug-2801-ingest-docs-handler.test.cjs index 5627b5b2b..7ed979676 100644 --- a/tests/bug-2801-ingest-docs-handler.test.cjs +++ b/tests/bug-2801-ingest-docs-handler.test.cjs @@ -126,19 +126,21 @@ describe('bug-2801: ingest-docs.md workflow calls gsd-tools not gsd-sdk', () => ); }); - test('ingest-docs.md init step uses canonical node-path gsd-tools.cjs invocation', () => { + test('ingest-docs.md init step uses the gsd_run launcher (#637)', () => { const content = fs.readFileSync(WORKFLOW_FILE, 'utf-8'); // Parse fenced bash blocks structurally — do not match raw markdown text. const codeBlockRe = /```bash\r?\n([\s\S]*?)```/g; const bashLines = [...content.matchAll(codeBlockRe)] .flatMap((m) => m[1].split('\n')) .filter((l) => !/^\s*#/.test(l)); - // Per #2851 the only valid form is the absolute-path node invocation; the - // legacy bare `gsd-tools` is the bug being fixed and must not be accepted. + // #637 routes ingest-docs through the resolved `gsd_run` launcher instead of + // the hardcoded `node "$HOME/.../gsd-tools.cjs"` path (which misses global + // installs). The legacy bare `gsd-tools` form remains the bug and is still + // rejected by bug-2851's repo-wide guard. const initLine = bashLines.find((l) => - /\bnode\s+["']?\$HOME\/\.claude\/gsd-core\/bin\/gsd-tools\.cjs["']?\s+init\s+ingest-docs\b/.test(l) + /\bgsd_run\s+init\s+ingest-docs\b/.test(l) ); - assert.ok(initLine, 'workflow must invoke init ingest-docs via canonical node-path gsd-tools.cjs'); + assert.ok(initLine, 'workflow must invoke init ingest-docs via the gsd_run launcher (#637)'); }); test('cmdInitIngestDocs is exported from init.cjs', () => { diff --git a/tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs b/tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs new file mode 100644 index 000000000..91ce02e47 --- /dev/null +++ b/tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs @@ -0,0 +1,67 @@ +// allow-test-rule: source-text-is-the-product +// Workflow .md text IS what the runtime loads and the agent executes, so +// asserting on its shell invocations tests the deployed contract directly. +// +// Repo-wide regression guard for #637 (generalizes the plan-phase-only guard +// from #621): NO workflow .md may invoke gsd-tools via a hardcoded +// `node "$HOME/.../gsd-tools.cjs"` path. On a global/shim-only install with no +// project-local runtime, that path can miss a working install, so the step +// reports the tool "not found" instead of resolving it. Every invocation must +// go through the `gsd_run` launcher (defined once per file in the canonical +// preamble, which resolves RUNTIME_DIR → .claude → PATH → $HOME in order). +// +// The parity test (runtime-launcher-parity) guards the retired $GSD_SDK and +// bare /gsd-tools tokens but NOT this hardcoded-node form — which is exactly +// how it survived across plan-phase.md (#621) and three more files (#637). + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); + +// Hardcoded direct invocation form. Distinct from the canonical preamble, which +// references $HOME only inside a `[ -f "$HOME/..." ]` probe / `GSD_TOOLS=` +// assignment and always invokes `node "$GSD_TOOLS"` — never `node "$HOME/..."`. +const HARDCODED_HOME_INVOCATION = /node\s+"\$HOME\/[^"]*gsd-tools\.cjs"/; + +function collectWorkflowMarkdown(dir) { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...collectWorkflowMarkdown(full)); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + out.push(full); + } + } + return out; +} + +describe('bug #637: no workflow .md hardcodes a $HOME gsd-tools invocation', () => { + test('every gsd-core/workflows/**/*.md resolves gsd-tools via gsd_run, not a hardcoded $HOME path', () => { + const files = collectWorkflowMarkdown(WORKFLOWS_DIR); + assert.ok(files.length > 0, 'expected workflow markdown files to exist'); + + const offenders = []; + for (const file of files) { + const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); + lines.forEach((line, i) => { + if (HARDCODED_HOME_INVOCATION.test(line)) { + offenders.push(`${path.relative(WORKFLOWS_DIR, file)}:${i + 1}: ${line.trim()}`); + } + }); + } + + assert.deepStrictEqual( + offenders, + [], + 'Workflow files must invoke gsd-tools via the resolved `gsd_run` launcher, ' + + 'not a hardcoded `node "$HOME/.../gsd-tools.cjs"` path. Offenders:\n' + + offenders.join('\n'), + ); + }); +});