diff --git a/.changeset/3347-graphify-auto-update-hook.md b/.changeset/3347-graphify-auto-update-hook.md index d6e96cbd0..4014b4123 100644 --- a/.changeset/3347-graphify-auto-update-hook.md +++ b/.changeset/3347-graphify-auto-update-hook.md @@ -3,4 +3,6 @@ type: Added pr: 3557 --- -**Opt-in: auto-rebuild knowledge graph after main HEAD advances** — new config key `graphify.auto_update` (default `false`) and bundled PostToolUse hook `hooks/gsd-graphify-update.sh` keep the `.planning/graphs/graph.json` consumed by `gsd-planner` and `gsd-phase-researcher` current without manual `/gsd:graphify build` runs. When both `graphify.enabled` and `graphify.auto_update` are `true`, the hook fires after Bash tool calls matching HEAD-advancing git ops (`commit`, `merge`, `pull`, `rebase --continue`, `cherry-pick`) on the default branch, writes a synchronous `running` status to `.planning/graphs/.last-build-status.json`, then dispatches `graphify update .` in a detached subprocess that updates the status file to `ok` (with `duration_ms` + `head_at_build`) or `failed` (with `exit_code`). The planner and researcher's `load_graph_context` steps now surface the auto-build state alongside the existing staleness annotation — including the must-have failure-surface case from the issue review (`"auto-rebuild FAILED at {ts}; context is from the prior build"`). PID-locked against concurrent rebuilds (stale-PID tolerant via `kill -0`), CI-aware (`$CI` env suppresses), and bails silently if `graphify` is not on `PATH` or the current branch is not the default. `/gsd:settings` adds a "Graph auto-update" question gated on Graphify being enabled. Closes #3347. +**Opt-in: auto-rebuild knowledge graph after main HEAD advances** — new config key `graphify.auto_update` (default `false`) and bundled PostToolUse hook `hooks/gsd-graphify-update.sh` keep the `.planning/graphs/graph.json` consumed by `gsd-planner` and `gsd-phase-researcher` current without manual `/gsd:graphify build` runs. When both `graphify.enabled` and `graphify.auto_update` are `true`, the hook fires after Bash tool calls matching HEAD-advancing git ops (`commit`, `merge`, `pull`, `rebase --continue`, `cherry-pick`) or the exact `gsd-sdk query commit` command shape on the default branch, writes a synchronous `running` status to `.planning/graphs/.last-build-status.json`, then dispatches `graphify update .` in a detached subprocess that updates the status file to `ok` (with `duration_ms` + `head_at_build`) or `failed` (with `exit_code`). The planner and researcher's `load_graph_context` steps now surface the auto-build state alongside the existing staleness annotation — including the must-have failure-surface case from the issue review (`"auto-rebuild FAILED at {ts}; context is from the prior build"`). PID-locked against concurrent rebuilds (stale-PID tolerant via `kill -0`), CI-aware (`$CI` env suppresses), and bails silently if `graphify` is not on `PATH` or the current branch is not the default. `/gsd:settings` adds a "Graph auto-update" question gated on Graphify being enabled. Closes #3347. + + diff --git a/.changeset/3653-graphify-hook-sdk-commit-visibility.md b/.changeset/3653-graphify-hook-sdk-commit-visibility.md new file mode 100644 index 000000000..8fdacc386 --- /dev/null +++ b/.changeset/3653-graphify-hook-sdk-commit-visibility.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3658 +--- +**`gsd-graphify-update.sh` now matches `gsd-sdk query commit` (#3653)** — the PostToolUse auto-update hook previously substring-matched only direct shell git ops in `tool_input.command`. Because `gsd-sdk query commit` invokes git via `spawnSync('git', [...])` rather than through a shell, the literal `git commit` never appears in the Bash tool's command string, and the hook silently skipped every SDK-issued commit — including the `phase.complete`-following commit that closes every phase. Result: `.planning/graphs/` drifted stale by one or more commits at the end of every phase, with no error and no log. Gate 2 now also accepts the exact `gsd-sdk query commit` command shape, which is the user-facing invocation that triggers the SDK-internal `spawnSync('git', 'commit', ...)`, without matching sibling verbs such as `commit-to-subrepo`. Other `gsd-sdk query` verbs (`phase.complete`, `roadmap.update-plan-progress`, `state.begin-phase`) do not invoke git themselves and remain non-matching to avoid spurious rebuilds per state mutation. diff --git a/hooks/gsd-graphify-update.sh b/hooks/gsd-graphify-update.sh index 122a864f0..ac5de6cb6 100755 --- a/hooks/gsd-graphify-update.sh +++ b/hooks/gsd-graphify-update.sh @@ -10,7 +10,10 @@ # # Gates (in fast-fail order — each shaves work off the common non-dispatch path): # 1. Stdin payload present and tool_name == "Bash" -# 2. tool_input.command matches a HEAD-advancing git op +# 2. tool_input.command matches a HEAD-advancing git op (shell-direct or +# the exact `gsd-sdk query commit` command shape; the SDK command invokes +# git internally, so the literal "git commit" substring never appears — +# see #3653) # 3. $CI is unset/empty # 4. Inside a git repo # 5. Current branch == default branch (git.base_branch override, else main/master/trunk) @@ -46,9 +49,10 @@ COMMAND=$(printf '%s\n' "$TOOL_INFO" | sed -n '2p') [ "$TOOL_NAME" = "Bash" ] || exit 0 -# Gate 2 — HEAD-advancing git op +# Gate 2 — HEAD-advancing git op (shell-direct or exact `gsd-sdk query commit`) case "$COMMAND" in *"git commit"*|*"git merge"*|*"git pull"*|*"git rebase --continue"*|*"git cherry-pick"*) ;; + *"gsd-sdk query commit"|*"gsd-sdk query commit "*) ;; *) exit 0 ;; esac diff --git a/tests/feat-3347-graphify-auto-update-hook.test.cjs b/tests/feat-3347-graphify-auto-update-hook.test.cjs index 9d46878e2..51d309620 100644 --- a/tests/feat-3347-graphify-auto-update-hook.test.cjs +++ b/tests/feat-3347-graphify-auto-update-hook.test.cjs @@ -389,6 +389,11 @@ describe('#3347 hook — HEAD-advancing command matchers', 'git pull --ff-only', 'git rebase --continue', 'git cherry-pick abc123', + // #3653 — `gsd-sdk query commit` invokes git via spawnSync('git', [...]), + // so the substring "git commit" never appears in tool_input.command. + // The hook must match the user-facing SDK invocation directly. + 'gsd-sdk query commit "docs: probe" --files .planning/STATE.md', + 'npx gsd-sdk query commit "docs: probe" --files .planning/STATE.md', ]) { test(`dispatches on: ${cmd}`, (t) => { const tmpDir = createTempGitRepo({ @@ -407,4 +412,50 @@ describe('#3347 hook — HEAD-advancing command matchers', ); }); } + + test('does NOT dispatch on SDK commit-to-subrepo prefix collision', (t) => { + const tmpDir = createTempGitRepo({ + config: { graphify: { enabled: true, auto_update: true } }, + }); + t.after(() => cleanup(tmpDir)); + const mockBin = makeMockGraphifyBin(tmpDir, { sleepMs: 100 }); + const r = runHook( + tmpDir, + { + tool_name: 'Bash', + tool_input: { + command: 'gsd-sdk query commit-to-subrepo "msg" --files packages/foo', + }, + }, + { pathPrepend: mockBin }, + ); + assert.strictEqual(r.status, 0); + assert.ok( + !fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')), + 'must NOT dispatch for commit-to-subrepo, which does not advance the outer repo HEAD', + ); + }); + + // #3653 — only the SDK `commit` verb invokes git internally. Other + // `gsd-sdk query` verbs (phase.complete, roadmap.update-plan-progress, + // state.begin-phase) mutate .md files but do NOT advance HEAD; matching + // them would cause a spurious rebuild per state mutation. + for (const cmd of [ + 'gsd-sdk query phase.complete 109', + 'gsd-sdk query roadmap.update-plan-progress 109 W001', + 'gsd-sdk query state.begin-phase 110', + ]) { + test(`does NOT dispatch on non-HEAD-advancing SDK verb: ${cmd}`, (t) => { + const tmpDir = createTempGitRepo({ + config: { graphify: { enabled: true, auto_update: true } }, + }); + t.after(() => cleanup(tmpDir)); + const r = runHook(tmpDir, { tool_name: 'Bash', tool_input: { command: cmd } }); + assert.strictEqual(r.status, 0); + assert.ok( + !fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')), + `must NOT dispatch for non-HEAD-advancing SDK verb: ${cmd}`, + ); + }); + } });