Skip to content

gsd-sdk query commit is invisible to PostToolUse hooks because git is invoked via spawnSync, not the shell #3653

@diakula

Description

@diakula

GSD Version

1.42.3

Runtime

Claude Code

Operating System

Linux (Ubuntu/Debian)

Node.js Version

22.22.2

Shell

GNU bash 5.2.21

Installation Method

npx get-shit-done-cc@latest (fresh run)

What happened?

A project-local PostToolUse hook (.claude/hooks/gsd-graphify-update.sh, configured in .claude/settings.json with "matcher": "Bash") is supposed to trigger a graphify rebuild every time HEAD on main advances. The hook's trigger gate inspects the shell command string surfaced through tool_input.command:

case "$COMMAND" in
  *"git commit"*|*"git merge"*|*"git pull"*|*"git rebase --continue"*|*"git cherry-pick"*) ;;
  *) exit 0 ;;
esac

During Phase 109 execution the hook fired correctly for orchestrator-issued git merge calls (wave merges) and the few raw git commit -m … calls I issued by hand (CR-01 fix, collapsible-description fix). But the GSD-managed commits — the ones that move the project's tracked state, including phase.complete and several roadmap.update-plan-progress writes — never matched, and their commits were silently never reflected in graphify-out/ or .planning/graphs/.

Inspecting sdk/dist/query/commit.js makes the failure mode concrete:

import { spawnSync } from 'node:child_process';
// ...
const addResult = spawnSync('git', ['-C', projectDir, 'add', '--', ...fileArgs], { stdio: 'pipe', encoding: 'utf-8' });
const commitResult = spawnSync('git', ['-C', projectDir, 'commit', '-m', sanitized, '--', ...fileArgs], { stdio: 'pipe', encoding: 'utf-8' });

The git binary is invoked directly via spawnSync('git', [...args])never via a shell, and never with 'git commit' appearing as a substring of any shell command. The user-facing tool call that triggers the hook is gsd-sdk query commit "..." --files ..., and that's the only string tool_input.command carries. The hook's regex literally cannot match.

The same blind spot applies to every downstream caller of the SDK's commit helper:

  • gsd-sdk query phase.complete
  • gsd-sdk query roadmap.update-plan-progress
  • gsd-sdk query state.begin-phase
  • workflow templates that emit gsd-sdk query commit "<msg>" --files <files> (the bulk of orchestrator tracking writes)

Why is this GSD and not user issue

The hook is project-local and the project owns its trigger contract, so one could argue the project should adapt. But the contract was authored against the only documented way to commit from a GSD workflow at the time (bare git commit), and GSD has since introduced gsd-sdk query commit as the canonical workflow-side commit interface — without any visible side-channel (env var, marker file, exit-code signal, no-op git shell call) that a PostToolUse hook can subscribe to. From the project's vantage point GSD has changed how it commits in a way that is invisible to the only observability gate Claude Code exposes (PostToolUse).

It's a bug in the integration contract between gsd-sdk query commit and Claude Code hooks, owned by GSD because:

  1. The change in commit path (exec git ...spawnSync('git', [...])) was made on GSD's side.
  2. GSD does not publish a template hook recipe that handles the gsd-sdk query indirection. The shipped reference hook patterns assume shell-visible git commands.
  3. Every consumer project that built a PostToolUse hook against bare git commit is silently degraded by an SDK release — with no warning in changelogs or migration docs.

The downstream effect on this project was: a stale knowledge graph after every phase that ended on a gsd-sdk query commit (which is almost every phase). The graph drifted by 2–3 minutes by the end of Phase 109, and would have kept drifting on each subsequent phase if not caught manually.

What did you expect?

Two reasonable expectations, either one would close the bug:

  1. gsd-sdk query commit is observable to PostToolUse hooks. Either by invoking git through execSync('git commit ...', { shell: true }) so the shell command string is visible in tool_input.command, or by emitting a documented side-channel — e.g., a child shell call bash -c "echo 'gsd-sdk-commit:advanced HEAD'" — that a hook can subscribe to without scraping internals.

  2. Documented and templated hook patterns that match both the shell-git and the SDK-git paths. A reference gsd-graphify-update.sh (or its successor) in GSD's templates whose case branch handles both *"git commit"* AND *"gsd-sdk query commit"* AND *"gsd-sdk query phase.complete"* AND *"gsd-sdk query roadmap"* — i.e., recognizing that any of those tool calls may have advanced HEAD.

The first is cleaner: it removes the need for downstream hooks to enumerate SDK command shapes. The second is the lowest-effort compatibility shim.

Steps to reproduce

  1. Project ~/Workspace/system/mono has the graphify hook wired and .planning/config.json sets "graphify": { "enabled": true }.
  2. Phase 109 ran via /gsd:execute-phase 109 and shipped 5 plans across 3 waves; orchestrator landed on main between every wave.
  3. After every wave the orchestrator emitted gsd-sdk query commit "docs(phase-109): update tracking after wave N (...)" --files .planning/ROADMAP.md .planning/STATE.md.
  4. At the end of the phase the orchestrator emitted gsd-sdk query phase.complete 109 followed by gsd-sdk query commit "docs(phase-109): complete phase execution" --files .planning/ROADMAP.md .planning/STATE.md .planning/REQUIREMENTS.md and gsd-sdk query commit "docs(phase-109): evolve PROJECT.md after phase completion" --files .planning/PROJECT.md.
  5. Comparing git log -1 --format=%ct HEAD vs stat -c %Y graphify-out/graph.json:
HEAD epoch: 1778946571 (17:49:31 — final phase-completion commit)
graph.json:  1778946406 (17:46:46 — last successful hook run, off by 2m45s and 2 commits)

The two commits between 17:46:46 and 17:49:31 were both issued through gsd-sdk query commit and never moved the graph.

Error output / logs

There is no error output. The hook silently exits via `case` non-match — by design — and the user has no signal that the rebuild did not happen.

The graphify-update hook even self-documents the silent-degrade contract in its header:

> Worst case the graph lags by one tool-call cycle behind a commit; the planner's load_graph_context step already annotates "treat as approximate" when the staleness check trips.

But this contract was written with "lags by one tool-call cycle" in mind, not "lags indefinitely for every `gsd-sdk query commit` for the rest of the project's life."

GSD Configuration

`.planning/config.json` (the bit that matters):

{
  "graphify": { "enabled": true },
  "workflow": { "research": true, "plan_check": true, "verifier": true }
}


`.claude/settings.json` PostToolUse entry (before this incident; this is the standard recipe most projects copy):

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "bash .claude/hooks/gsd-graphify-update.sh", "timeout": 5 }
        ]
      }
    ]
  }
}


`sdk/dist/query/commit.js` (lines 322 and 328) — the smoking gun:

spawnSync('git', ['-C', projectDir, 'add', '--', ...fileArgs], { stdio: 'pipe', encoding: 'utf-8' });
spawnSync('git', ['-C', projectDir, 'commit', '-m', sanitized, '--', ...fileArgs], { stdio: 'pipe', encoding: 'utf-8' });

GSD State (if relevant)

Runtime settings.json (if relevant)

How often does this happen?

Every time (100% reproducible)

Impact

Moderate — Feature is broken but I have a workaround

Workaround (if any)

Impact on work

  • Knowledge-graph staleness: the planner's load_graph_context step reads from .planning/graphs/graph.json, which is what gsd-graphify-update.sh is supposed to keep current. After every phase, the planner sees a graph that is N minutes and 2–5 commits stale.
  • Compounding drift: because gsd-sdk query phase.complete is the last commit in a phase and never triggers a rebuild, the next workflow that runs (e.g., gsd:plan-phase NEXT) starts on a graph that's missing the entire phase that just shipped. New phase planning grep-walks for things that already exist in the graph but the graph doesn't know about them yet.
  • Observability loss for other hooks: the same defect blocks any future PostToolUse-driven automation that wants to react to "HEAD just advanced via GSD." Examples I would otherwise wire: auto-deploy preview environments, auto-update an external task tracker, notify a Slack channel on phase completion. None of these can use PostToolUse today; they would have to scrape git or set up a separate watcher.
  • Hidden state divergence: orchestrators that consume the graph for gsd-codebase-mapper-style work pre-fetch a stale snapshot. If the user trusts the agent's "I checked the graph" claim, decisions get made against last-phase reality.

Severity: moderate. Doesn't corrupt anything, but corrodes a load-bearing piece of the GSD planning loop (graph freshness) silently and for everyone.

Workaround used

Three workarounds, in increasing order of intrusiveness:

  1. End-of-workflow manual rebuild. Run graphify update . && cp graphify-out/{graph.json,graph.html,GRAPH_REPORT.md} .planning/graphs/ && cp .planning/graphs/graph.json .planning/graphs/.last-build-snapshot.json at the close of every gsd:execute-phase run. Reliable but easy to forget. Used today as the closing step of Phase 109.

  2. Widen the project hook's case statement. Add *"gsd-sdk query commit"*|*"gsd-sdk query phase.complete"*|*"gsd-sdk query roadmap"*|*"gsd-sdk query state."*) to the trigger. This re-triggers the rebuild for SDK paths. Cost: false positives (some gsd-sdk query calls don't touch git) cause spurious rebuilds, and the project carries an SDK-version-coupled enumeration of SDK command shapes that GSD can break with any release.

  3. Wrap gsd-sdk itself. Symlink gsd-sdk-real and ship a one-line bash wrapper that runs the SDK then git rev-parse HEAD to a marker file, and have the hook diff the marker. Heaviest workaround; would not recommend.

I implemented (1) for now and saved a memory entry (feedback_graphify_hook_gap.md) so the orchestrator does it automatically at the end of every workflow.

Additional context

Proposed fixes

In order of preference and increasing-effort:

Fix A (smallest, GSD-side) — emit a side-channel after every HEAD-advancing SDK call.

In sdk/dist/query/commit.js, after a successful commit, emit a marker line on stdout (or to a small canonical file like .planning/.last-sdk-commit):

// After commitResult.status === 0:
process.stdout.write(`gsd-sdk:HEAD-advanced ${newHash}\n`);

This costs zero hook-side enumeration: any hook matching *"gsd-sdk:HEAD-advanced"* in tool output triggers cleanly. Symmetric extension to phase.complete, roadmap.update-plan-progress, state.begin-phase. PostToolUse hooks already get tool stdout in tool_response, so the hook just changes its grep from tool_input.command to tool_response.stdout.

Fix B (smallest, hook-side, project-owned) — but published as a GSD template.

Ship templates/hooks/graphify-update.sh in the GSD distribution with the widened case statement:

case "$COMMAND" in
  *"git commit"*|*"git merge"*|*"git pull"*|*"git rebase --continue"*|*"git cherry-pick"*) ;;
  *"gsd-sdk query commit"*|*"gsd-sdk query phase.complete"*) ;;
  *"gsd-sdk query roadmap"*|*"gsd-sdk query state."*) ;;
  *) exit 0 ;;
esac

This forces GSD to own the enumeration. Any new SDK command that advances HEAD gets added to this case at the same time it's added to the SDK. Reduces the failure mode to "GSD ships a new SDK verb that touches git and forgets to add it to the case" — a normal release-discipline problem rather than a silent stale-graph defect.

Fix C (cleanest, GSD-side, but biggest change) — go through the shell.

In sdk/dist/query/commit.js, replace spawnSync('git', [...]) with execSync(git -C ${shellEscape(projectDir)} commit ..., { shell: true }). The shell command string becomes visible to Claude Code's Bash tool wrapping, and hooks see git commit literally. Trade-off: requires careful shell escaping of the commit message and file paths. The current spawnSync form has the nice property that no escaping is needed because args are passed as an array; reverting that gains observability at the cost of having to harden the shell-escape logic. Probably not worth the risk for this one ergonomics win — Fix A is strictly better.

Other context

The reason this bit during Phase 109 specifically: the phase happens to have 5 plans with several tracking commits each, plus a code-review iteration, plus a UAT-driven UX patch. The hook fired 5–6 times correctly (one per git merge and bare git commit), so the graph rebuilt frequently enough that everything seemed fine — until the final gsd-sdk query phase.complete + PROJECT.md commits, which were the last two commits and they were invisible. The asymmetry made the bug look like a "lock-contention race" before I read the SDK source.

The audit log I added to assess graph-vs-grep agent behavior (.claude/hooks/gsd-research-audit.sh, marked TEMPORARY) is unrelated to this defect but lives on the same PostToolUse plumbing — so any fix that improves hook coverage benefits both. That hook is small enough to remove inline.

Related issues observed before

  • reference_gsd_roadmap_details_bug (upstream issue phase complete silently skips roadmap updates and mis-reports is_last_phase when the in-progress milestone is wrapped in <details> #2005): GSD's <details> parser bug in ROADMAP.md. Same family — silent SDK behavior that downstream tooling can't see and can't compensate for without scraping internals. Pattern: SDK changes shape; consumers degrade silently.
  • feedback_no_subagent_stash: verification/analysis subagents must not run git stash. Adjacent integration concern — surfaces the same "GSD's contract with the surrounding tools is under-documented and the failure mode is silent data loss."
  • feedback_worktree_resurrection_check_too_broad: execute-phase merge cleanup deletes new SUMMARY.md files as "resurrected." Same root pattern as this issue — GSD reaches around the project tree (here, worktree cleanup; in this issue, the shell-command observability gate) in ways that bypass the integration points downstream projects rely on.

Privacy Checklist

  • I have reviewed all pasted output for PII (usernames, paths, API keys) and redacted where necessary

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingconfirmed-bugVerified reproducible bug

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions