From 2d314c3a281f78502809feee46a587ec04b41479 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 28 Jun 2026 22:42:23 -0400 Subject: [PATCH] fix(#1772): read full multi-line command in graphify-update hook Gate 2 (#1815) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#1772): read full multi-line command in graphify-update hook Gate 2 The PostToolUse hook joined tool_name + newline + tool_input.command and extracted the command with sed -n '2p' — line 2 only. Agent runtimes (Claude Code's Bash tool among them) routinely emit HEAD-advancing commits as multi-line scripts ('cd /path', then 'git add', then 'git commit …'), so line 2 is the 'cd', Gate 2's *"git commit"* match failed, and the rebuild silently no-op'd on real commits despite graphify.auto_update: true. Capture line 2 through EOF (sed -n '2,$p') so the case glob sees the full multi-line command string. Single-line behavior is unchanged (the match only widens); non-HEAD-advancing multi-line commands still no-op cleanly. Regression tests cover multi-line commit/merge/pull dispatch plus a multi-line no-op no-regression guard. * docs(#1772): add changeset fragment for graphify-update multi-line fix * test(#1772): regenerate golden-install-parity fixtures for hook change gsd-graphify-update.sh ships to 9 graphify-aware runtimes; widening the sed range (2p -> 2,$p) shifts its shipped hash. Recapture the 9 affected fixtures via UPDATE_GOLDEN=1 — each changes exactly one line (the hook hash). --- ...1772-graphify-update-multi-line-command.md | 5 ++ hooks/gsd-graphify-update.sh | 8 ++- .../golden-install-parity/antigravity.json | 2 +- .../golden-install-parity/augment.json | 2 +- .../golden-install-parity/claude.json | 2 +- .../golden-install-parity/codebuddy.json | 2 +- .../golden-install-parity/gemini.json | 2 +- .../golden-install-parity/hermes.json | 2 +- .../fixtures/golden-install-parity/kilo.json | 2 +- .../golden-install-parity/opencode.json | 2 +- .../fixtures/golden-install-parity/qwen.json | 2 +- tests/graphify-auto-update.slow.test.cjs | 56 +++++++++++++++++++ 12 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 .changeset/1772-graphify-update-multi-line-command.md diff --git a/.changeset/1772-graphify-update-multi-line-command.md b/.changeset/1772-graphify-update-multi-line-command.md new file mode 100644 index 000000000..7e188785c --- /dev/null +++ b/.changeset/1772-graphify-update-multi-line-command.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1815 +--- +**`gsd-graphify-update.sh` now reads the full multi-line command in Gate 2 (#1772)** — the PostToolUse auto-update hook joined `tool_name` + `\n` + `tool_input.command` and extracted the command with `sed -n '2p'` (line 2 only). Agent runtimes (Claude Code's Bash tool among them) routinely emit HEAD-advancing commits as multi-line scripts (`cd /path`, then `git add`, then `git commit …`), so line 2 was the `cd`, Gate 2's `*"git commit"*` match failed, and the rebuild silently no-op'd on real commits even with `graphify.auto_update: true`. The failure was invisible in manual probes because a single-line `git commit -m x` passes line 2 verbatim. The hook now captures line 2 through EOF (`sed -n '2,$p'`) so the `case` glob sees the full command string; single-line behavior is unchanged and multi-line commands without a HEAD-advancing op still no-op cleanly. diff --git a/hooks/gsd-graphify-update.sh b/hooks/gsd-graphify-update.sh index a62b51e6d..e65af559d 100755 --- a/hooks/gsd-graphify-update.sh +++ b/hooks/gsd-graphify-update.sh @@ -45,7 +45,13 @@ process.stdin.on("end", () => { }); ' 2>/dev/null || printf '\n') TOOL_NAME=$(printf '%s\n' "$TOOL_INFO" | sed -n '1p') -COMMAND=$(printf '%s\n' "$TOOL_INFO" | sed -n '2p') +# Capture the FULL command (line 2 through EOF). Agent runtimes routinely emit +# HEAD-advancing commits as multi-line scripts (`cd /path` then `git add` then +# `git commit …`); reading only line 2 (`sed -n '2p'`) missed a `git commit` +# that was not on the first command line and silently no-op'd the rebuild +# (#1772). Line 2..EOF preserves embedded newlines; the `case` glob below +# matches the substring anywhere in the multi-line string. +COMMAND=$(printf '%s\n' "$TOOL_INFO" | sed -n '2,$p') [ "$TOOL_NAME" = "Bash" ] || exit 0 diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index 8b3fbd085..60863fea4 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -306,7 +306,7 @@ "hooks/gsd-cursor-post-tool.js": "d61ee04f6ee7858c", "hooks/gsd-cursor-session-start.js": "148b8ec4e2c97f00", "hooks/gsd-ensure-canonical-path.js": "83e02e841e123037", - "hooks/gsd-graphify-update.sh": "396ebda3c6705dc9", + "hooks/gsd-graphify-update.sh": "845aeecb0d82dd6e", "hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9", "hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d", "hooks/gsd-read-guard.js": "b602f88f046a7551", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index 38291f89d..0442c8d93 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -375,7 +375,7 @@ "hooks/gsd-cursor-post-tool.js": "d61ee04f6ee7858c", "hooks/gsd-cursor-session-start.js": "148b8ec4e2c97f00", "hooks/gsd-ensure-canonical-path.js": "3499b6e6b453dc59", - "hooks/gsd-graphify-update.sh": "396ebda3c6705dc9", + "hooks/gsd-graphify-update.sh": "845aeecb0d82dd6e", "hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9", "hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d", "hooks/gsd-read-guard.js": "b602f88f046a7551", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index f2a888378..e835da86c 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -305,7 +305,7 @@ "hooks/gsd-cursor-post-tool.js": "dd1b12f795de8d72", "hooks/gsd-cursor-session-start.js": "a93095ac609a3ea6", "hooks/gsd-ensure-canonical-path.js": "34f4522a23cc5f41", - "hooks/gsd-graphify-update.sh": "396ebda3c6705dc9", + "hooks/gsd-graphify-update.sh": "845aeecb0d82dd6e", "hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9", "hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d", "hooks/gsd-read-guard.js": "b602f88f046a7551", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index 4ea935a5b..beb73b7b9 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -375,7 +375,7 @@ "hooks/gsd-cursor-post-tool.js": "d61ee04f6ee7858c", "hooks/gsd-cursor-session-start.js": "148b8ec4e2c97f00", "hooks/gsd-ensure-canonical-path.js": "6900c226c1c28a44", - "hooks/gsd-graphify-update.sh": "396ebda3c6705dc9", + "hooks/gsd-graphify-update.sh": "845aeecb0d82dd6e", "hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9", "hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d", "hooks/gsd-read-guard.js": "b602f88f046a7551", diff --git a/tests/fixtures/golden-install-parity/gemini.json b/tests/fixtures/golden-install-parity/gemini.json index 426c81ae4..6f9c35449 100644 --- a/tests/fixtures/golden-install-parity/gemini.json +++ b/tests/fixtures/golden-install-parity/gemini.json @@ -375,7 +375,7 @@ "hooks/gsd-cursor-post-tool.js": "d61ee04f6ee7858c", "hooks/gsd-cursor-session-start.js": "148b8ec4e2c97f00", "hooks/gsd-ensure-canonical-path.js": "a19947cac42002d4", - "hooks/gsd-graphify-update.sh": "396ebda3c6705dc9", + "hooks/gsd-graphify-update.sh": "845aeecb0d82dd6e", "hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9", "hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d", "hooks/gsd-read-guard.js": "b602f88f046a7551", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index c5e386558..2f1dae88f 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -306,7 +306,7 @@ "hooks/gsd-cursor-post-tool.js": "dd1b12f795de8d72", "hooks/gsd-cursor-session-start.js": "a93095ac609a3ea6", "hooks/gsd-ensure-canonical-path.js": "c49fb011c3b97f4a", - "hooks/gsd-graphify-update.sh": "396ebda3c6705dc9", + "hooks/gsd-graphify-update.sh": "845aeecb0d82dd6e", "hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9", "hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d", "hooks/gsd-read-guard.js": "474bc1800d34456f", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index e5cfdf6a7..8daa73294 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -375,7 +375,7 @@ "hooks/gsd-cursor-post-tool.js": "d61ee04f6ee7858c", "hooks/gsd-cursor-session-start.js": "148b8ec4e2c97f00", "hooks/gsd-ensure-canonical-path.js": "a5c67a1a7abc90c0", - "hooks/gsd-graphify-update.sh": "396ebda3c6705dc9", + "hooks/gsd-graphify-update.sh": "845aeecb0d82dd6e", "hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9", "hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d", "hooks/gsd-read-guard.js": "b602f88f046a7551", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 10966dba8..704970c27 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -375,7 +375,7 @@ "hooks/gsd-cursor-post-tool.js": "d61ee04f6ee7858c", "hooks/gsd-cursor-session-start.js": "148b8ec4e2c97f00", "hooks/gsd-ensure-canonical-path.js": "5f8be5b0a01a88ea", - "hooks/gsd-graphify-update.sh": "396ebda3c6705dc9", + "hooks/gsd-graphify-update.sh": "845aeecb0d82dd6e", "hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9", "hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d", "hooks/gsd-read-guard.js": "b602f88f046a7551", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index faeb29fc5..0c61006b9 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -306,7 +306,7 @@ "hooks/gsd-cursor-post-tool.js": "dd1b12f795de8d72", "hooks/gsd-cursor-session-start.js": "a93095ac609a3ea6", "hooks/gsd-ensure-canonical-path.js": "4c1626ed20ab7a75", - "hooks/gsd-graphify-update.sh": "396ebda3c6705dc9", + "hooks/gsd-graphify-update.sh": "845aeecb0d82dd6e", "hooks/gsd-phase-boundary.sh": "6aa3ba9af3d465d9", "hooks/gsd-prompt-guard.js": "4b08c2dce0233e2d", "hooks/gsd-read-guard.js": "9d1b227b6fcb6dba", diff --git a/tests/graphify-auto-update.slow.test.cjs b/tests/graphify-auto-update.slow.test.cjs index 51ffed363..ecf5a5b51 100644 --- a/tests/graphify-auto-update.slow.test.cjs +++ b/tests/graphify-auto-update.slow.test.cjs @@ -613,6 +613,62 @@ describe('auto-update', () => { ); }); + // #1772 — agent runtimes (Claude Code's Bash tool among them) routinely + // emit HEAD-advancing commits as multi-line scripts (`cd /path` then + // `git add` then `git commit …`). The hook joins tool_name + "\n" + + // tool_input.command and must match the command across ALL its lines + // (line 2 through EOF), not just line 2 — otherwise a `git commit` that + // is not on the first command line silently no-ops the rebuild. + for (const cmd of [ + "cd /tmp/repo\ngit add .\ngit commit -m 'multi-line commit'", + "cd /tmp/repo\ngit merge feature-branch", + "git fetch origin\ngit pull --ff-only", + ]) { + test(`dispatches on multi-line command (#1772): ${cmd.split('\n').slice(0, 2).join(' ⏎ ')}…`, async (t) => { + const tmpDir = createTempGitRepo({ + config: { graphify: { enabled: true, auto_update: true } }, + }); + t.after(() => cleanupHookRepo(tmpDir)); + const mockBin = makeMockGraphifyBin(tmpDir, { sleepMs: 100 }); + runHook( + tmpDir, + { tool_name: 'Bash', tool_input: { command: cmd } }, + { pathPrepend: mockBin }, + ); + const statusPath = path.join(tmpDir, '.planning/graphs/.last-build-status.json'); + await waitForBuildStatus(statusPath, new Set(['ok', 'failed'])); + assert.ok( + fs.existsSync(statusPath), + `must dispatch for multi-line command where the HEAD-advancing op is not on line 1 (#1772): ${cmd}`, + ); + }); + } + + test('multi-line command with NO HEAD-advancing op still no-ops (#1772 no-regression)', (t) => { + const tmpDir = createTempGitRepo({ + config: { graphify: { enabled: true, auto_update: true } }, + }); + t.after(() => cleanupHookRepo(tmpDir)); + const mockBin = makeMockGraphifyBin(tmpDir, { sleepMs: 100 }); + const r = runHook( + tmpDir, + { + tool_name: 'Bash', + tool_input: { + // Multi-line, but only non-HEAD-advancing ops. Widening line + // extraction to 2..EOF must not cause a spurious dispatch. + command: 'cd /tmp/repo\nls -la\necho done', + }, + }, + { pathPrepend: mockBin }, + ); + assert.strictEqual(r.status, 0); + assert.ok( + !fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')), + 'multi-line command without a HEAD-advancing git op must still no-op (#1772)', + ); + }); + // #3653 — only the SDK `commit` verb invokes git internally. Other // `gsd-tools query` verbs (phase.complete, roadmap.update-plan-progress, // state.begin-phase) mutate .md files but do NOT advance HEAD; matching