From dacc23137add0a5f303f3c89bd3effca27dba9e3 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 15 May 2026 10:10:27 -0400 Subject: [PATCH 1/6] feat(3347): opt-in auto-update of knowledge graph after main HEAD advances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #3347 Config: - Add graphify.auto_update (default false) to manifests: sdk/shared/config-defaults.manifest.json, config-schema.manifest.json Hook: - hooks/gsd-graphify-update.sh — PostToolUse Bash matcher - Gates: tool_name=Bash, HEAD-advancing git op, CI=unset, in git repo, current branch == default branch (git.base_branch override or main/ master/trunk fallback), graphify.enabled && graphify.auto_update both true, graphify on PATH, no live PID lock - Writes .planning/graphs/.last-build-status.json with status=running synchronously, then detaches hooks/lib/gsd-graphify-rebuild.sh - hooks/lib/gsd-graphify-rebuild.sh — detached rebuild runner - PID-lock acquire + trap-on-exit cleanup - graphify update . then cp graphify-out/* → .planning/graphs/ - Status file rewritten to status=ok|failed with exit_code, duration_ms, head_at_build - Portable detach (subshell + disown, no setsid dependency) Installer: - bin/install.js: register hook as PostToolUse Bash matcher (5s timeout) - Add to gsdHooks uninstall list and expectedShHooks warning list Planner / researcher status surface (issue #3347 reviewer must-have AC): - agents/gsd-planner.md and agents/gsd-phase-researcher.md load_graph_context steps now read .last-build-status.json and surface: running → "rebuild in flight"; failed → "auto-rebuild FAILED at {ts}, context is from prior build"; ok with stale head_at_build → "HEAD has advanced since last build" Settings: - get-shit-done/workflows/settings.md adds "Graph auto-update" question with No-Recommended default; bullets and update_config block updated Inventory: - docs/INVENTORY.md hook count 12 → 13 with new row - docs/INVENTORY-MANIFEST.json regenerated Tests: - tests/feat-3347-graphify-auto-update-config.test.cjs (8 tests): isValidConfigKey accepts graphify.auto_update, CANONICAL_CONFIG_DEFAULTS default false, config-set round-trip, sibling key preservation - tests/feat-3347-graphify-auto-update-hook.test.cjs (18 tests): all bail paths (non-Bash, non-HEAD-advancing, enabled=false, auto_update=false, CI=true, non-default-branch, missing graphify bin, live-PID lock), dispatch path with mock graphify bin (sync running status + detached transition to ok/failed), stale-PID lock, all five HEAD-advancing command matchers, git.base_branch override Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/3347-graphify-auto-update-hook.md | 6 + agents/gsd-phase-researcher.md | 2 + agents/gsd-planner.md | 16 + bin/install.js | 33 +- docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 3 +- get-shit-done/workflows/settings.md | 13 +- hooks/gsd-graphify-update.sh | 152 +++++++ hooks/lib/gsd-graphify-rebuild.sh | 65 +++ sdk/shared/config-defaults.manifest.json | 3 + sdk/shared/config-schema.manifest.json | 1 + ...-3347-graphify-auto-update-config.test.cjs | 149 +++++++ ...at-3347-graphify-auto-update-hook.test.cjs | 394 ++++++++++++++++++ 13 files changed, 834 insertions(+), 4 deletions(-) create mode 100644 .changeset/3347-graphify-auto-update-hook.md create mode 100755 hooks/gsd-graphify-update.sh create mode 100755 hooks/lib/gsd-graphify-rebuild.sh create mode 100644 tests/feat-3347-graphify-auto-update-config.test.cjs create mode 100644 tests/feat-3347-graphify-auto-update-hook.test.cjs diff --git a/.changeset/3347-graphify-auto-update-hook.md b/.changeset/3347-graphify-auto-update-hook.md new file mode 100644 index 000000000..48a7e8be5 --- /dev/null +++ b/.changeset/3347-graphify-auto-update-hook.md @@ -0,0 +1,6 @@ +--- +type: Added +pr: 0 +--- + +**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. diff --git a/agents/gsd-phase-researcher.md b/agents/gsd-phase-researcher.md index a4482938d..da39afaa5 100644 --- a/agents/gsd-phase-researcher.md +++ b/agents/gsd-phase-researcher.md @@ -637,6 +637,8 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" graphify status If the status response has `stale: true`, note for later: "Graph is {age_hours}h old -- treat semantic relationships as approximate." Include this annotation inline with any graph context injected below. +**Auto-update awareness (issue #3347).** If `.planning/graphs/.last-build-status.json` exists, read it and surface the most recent auto-build state alongside the staleness note. The hook (`hooks/gsd-graphify-update.sh`, opt-in via `graphify.auto_update`) writes this file. Format the surfaced annotation based on `status`: `"running"` → "rebuild in flight (started {ts})"; `"failed"` → "auto-rebuild FAILED at {ts} (exit {exit_code}); context is from the prior build — run `/gsd:graphify build` manually to retry"; `"ok"` with `head_at_build` matching current `HEAD` → silent (graph is current); `"ok"` with `head_at_build` differing → "last rebuilt at {ts} for {head_at_build[:7]}; current HEAD has advanced". File missing → silent. + Query the graph for each major capability in the phase scope (2-3 queries per D-05, discovery-focused): ```bash diff --git a/agents/gsd-planner.md b/agents/gsd-planner.md index b0d6d5701..6a3a3b75b 100644 --- a/agents/gsd-planner.md +++ b/agents/gsd-planner.md @@ -892,6 +892,22 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" graphify status If the status response has `stale: true`, note for later: "Graph is {age_hours}h old -- treat semantic relationships as approximate." Include this annotation inline with any graph context injected below. +**Auto-update awareness (issue #3347).** If `.planning/graphs/.last-build-status.json` exists, read it and surface the most recent auto-build state alongside the staleness note. The hook (`hooks/gsd-graphify-update.sh`, opt-in via `graphify.auto_update`) writes this file. Format the surfaced annotation based on `status`: + +- `status: "running"` — "Graph auto-rebuild in flight (started {ts}); treat semantic relationships as approximate until rebuild completes." +- `status: "failed"` — "Graph auto-rebuild FAILED at {ts} (exit {exit_code}); the planning context below is from the prior build. Run `/gsd:graphify build` to retry manually." +- `status: "ok"` with `head_at_build` matching the current `HEAD` — silent (graph is current). +- `status: "ok"` with `head_at_build` differing from current `HEAD` — "Graph last rebuilt at {ts} for commit {head_at_build[:7]}; current HEAD has advanced -- treat semantic relationships as approximate." +- File missing — silent; rely on the existing staleness note above. + +Read the status file with: + +```bash +test -f .planning/graphs/.last-build-status.json && cat .planning/graphs/.last-build-status.json +``` + +The auto-update mechanism is opt-in (`graphify.auto_update: false` by default per #3347); users who do not opt in will never see this file and the annotation above is a no-op. + Query the graph for phase-relevant dependency context (single query per D-06): ```bash diff --git a/bin/install.js b/bin/install.js index 4cf3a5839..2f807fb2f 100755 --- a/bin/install.js +++ b/bin/install.js @@ -6828,7 +6828,7 @@ function uninstall(isGlobal, runtime = 'claude') { // 4. Remove GSD hooks const hooksDir = path.join(targetDir, 'hooks'); if (fs.existsSync(hooksDir)) { - const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-read-guard.js', 'gsd-read-injection-scanner.js', 'gsd-update-banner.js', 'gsd-workflow-guard.js', 'gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh']; + const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-read-guard.js', 'gsd-read-injection-scanner.js', 'gsd-update-banner.js', 'gsd-workflow-guard.js', 'gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh', 'gsd-graphify-update.sh']; let hookCount = 0; for (const hook of gsdHooks) { const hookPath = path.join(hooksDir, hook); @@ -8619,7 +8619,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { if (verifyInstalled(hooksDest, 'hooks')) { console.log(` ${green}✓${reset} Installed hooks (bundled)`); // Warn if expected community .sh hooks are missing (non-fatal) - const expectedShHooks = ['gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh']; + const expectedShHooks = ['gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh', 'gsd-graphify-update.sh']; for (const sh of expectedShHooks) { if (!fs.existsSync(path.join(hooksDest, sh))) { console.warn(` ${yellow}⚠${reset} Missing expected hook: ${sh}`); @@ -9414,6 +9414,35 @@ function install(isGlobal, runtime = 'claude', options = {}) { console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — Bash executable path unavailable (#3393)`); } + // Configure graphify auto-update hook (opt-in via graphify.auto_update; default false, #3347). + // PostToolUse Bash matcher — fires after git commit/merge/pull/rebase --continue/cherry-pick + // on the default branch, dispatches `graphify update .` in a detached subprocess. No-op unless + // .planning/config.json has BOTH graphify.enabled=true AND graphify.auto_update=true. + const graphifyUpdateCommand = isGlobal + ? buildHookCommand(targetDir, 'gsd-graphify-update.sh', hookOpts) + : localShellCmd('gsd-graphify-update.sh'); + const hasGraphifyUpdateHook = settings.hooks[postToolEvent].some(entry => + entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-graphify-update')) + ); + const graphifyUpdateFile = path.join(targetDir, 'hooks', 'gsd-graphify-update.sh'); + if (!hasGraphifyUpdateHook && fs.existsSync(graphifyUpdateFile) && graphifyUpdateCommand) { + settings.hooks[postToolEvent].push({ + matcher: 'Bash', + hooks: [ + { + type: 'command', + command: graphifyUpdateCommand, + timeout: 5 + } + ] + }); + console.log(` ${green}✓${reset} Configured graphify auto-update hook (opt-in via graphify.auto_update)`); + } else if (!hasGraphifyUpdateHook && !fs.existsSync(graphifyUpdateFile)) { + console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — gsd-graphify-update.sh not found at target`); + } else if (!hasGraphifyUpdateHook && !graphifyUpdateCommand) { + console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — Bash executable path unavailable (#3393)`); + } + // Configure session state orientation hook (opt-in) const sessionStateCommand = isGlobal ? buildHookCommand(targetDir, 'gsd-session-state.sh', hookOpts) diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index c710aeefd..62c2266ea 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -326,6 +326,7 @@ "gsd-check-update-worker.js", "gsd-check-update.js", "gsd-context-monitor.js", + "gsd-graphify-update.sh", "gsd-phase-boundary.sh", "gsd-prompt-guard.js", "gsd-read-guard.js", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 30195d4fb..f8e5a884a 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -434,7 +434,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. --- -## Hooks (12 shipped) +## Hooks (13 shipped) Full listing: `hooks/`. @@ -452,6 +452,7 @@ Full listing: `hooks/`. | `gsd-session-state.sh` | `PostToolUse` | Session-state tracking for shell-based runtimes | | `gsd-validate-commit.sh` | `PostToolUse` | Commit validation for conventional-commit enforcement | | `gsd-phase-boundary.sh` | `PostToolUse` | Phase-boundary detection for workflow transitions | +| `gsd-graphify-update.sh` | `PostToolUse` | Auto-rebuild knowledge graph after main HEAD advances (opt-in, default off — #3347) | --- diff --git a/get-shit-done/workflows/settings.md b/get-shit-done/workflows/settings.md index f17726302..53c9215d0 100644 --- a/get-shit-done/workflows/settings.md +++ b/get-shit-done/workflows/settings.md @@ -51,6 +51,7 @@ Parse current values (default to `true` if not present): - `commit_docs` — whether `.planning/` files are committed to git (default: true if absent) - `intel.enabled` — enable queryable codebase intelligence (/gsd:map-codebase --query) (default: false if absent) - `graphify.enabled` — enable project knowledge graph (/gsd:graphify) (default: false if absent) +- `graphify.auto_update` — opt-in: auto-rebuild graph after main HEAD advances (#3347) (default: `false`) - `model_profile` — which model each agent uses (default: `balanced`) - `git.branching_strategy` — branching approach (default: `"none"`) - `workflow.use_worktrees` — whether parallel executor agents run in worktree isolation (default: `true`) @@ -317,6 +318,15 @@ AskUserQuestion([ { label: "No (Recommended)", description: "Skip knowledge graph. Use when dependency graphs are not needed." }, { label: "Yes", description: "Enable /gsd:graphify commands. Builds and queries a project knowledge graph." } ] + }, + { + question: "Auto-rebuild graph after main HEAD advances? (only effective if Graphify is enabled — #3347)", + header: "Graph auto-update", + multiSelect: false, + options: [ + { label: "No (Recommended)", description: "Manual /gsd:graphify build only. Conservative default — opt in if you want fresh context on every /gsd:quick or /gsd:plan-phase." }, + { label: "Yes", description: "Auto-rebuild the graph in a detached background process after git commit/merge/pull/rebase --continue/cherry-pick on the default branch. Hook returns instantly; rebuild runs out-of-band. No-op if Graphify is disabled." } + ] } ]) ``` @@ -354,7 +364,8 @@ Merge new settings into existing config.json: "enabled": true/false }, "graphify": { - "enabled": true/false + "enabled": true/false, + "auto_update": true/false }, "git": { "branching_strategy": "none" | "phase" | "milestone", diff --git a/hooks/gsd-graphify-update.sh b/hooks/gsd-graphify-update.sh new file mode 100755 index 000000000..5c31f36d1 --- /dev/null +++ b/hooks/gsd-graphify-update.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# gsd-hook-version: {{GSD_VERSION}} +# gsd-graphify-update.sh — PostToolUse hook (Bash matcher) that auto-rebuilds +# the project knowledge graph after main HEAD advances on the default branch. +# +# OPT-IN (issue #3347 AC): no-op unless .planning/config.json has BOTH +# graphify.enabled: true +# graphify.auto_update: true +# graphify.auto_update defaults to false so existing users see no behavior change. +# +# 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 +# 3. $CI is unset/empty +# 4. Inside a git repo +# 5. Current branch == default branch (git.base_branch override, else main/master/trunk) +# 6. .planning/config.json sets graphify.enabled=true AND graphify.auto_update=true +# 7. graphify binary on PATH +# 8. No rebuild already in flight (PID lock — kill -0 check, stale-tolerant) +# +# When all gates pass: +# - Writes .planning/graphs/.last-build-status.json with status="running" +# - Detaches hooks/lib/gsd-graphify-rebuild.sh which copies graphify-out/* to +# .planning/graphs/ and rewrites the status file with status="ok"|"failed" +# +# Returns 0 in all cases. Never blocks the user-facing tool call. + +set -uo pipefail + +# Gate 1 — tool_name == Bash; extract command +INPUT=$(cat 2>/dev/null || true) +[ -n "$INPUT" ] || exit 0 + +TOOL_INFO=$(printf '%s' "$INPUT" | node -e ' +let d = ""; +process.stdin.on("data", c => d += c); +process.stdin.on("end", () => { + try { + const p = JSON.parse(d); + process.stdout.write((p.tool_name || "") + "\n" + (p.tool_input?.command || "")); + } catch { process.stdout.write("\n"); } +}); +' 2>/dev/null || printf '\n') +TOOL_NAME=$(printf '%s\n' "$TOOL_INFO" | sed -n '1p') +COMMAND=$(printf '%s\n' "$TOOL_INFO" | sed -n '2p') + +[ "$TOOL_NAME" = "Bash" ] || exit 0 + +# Gate 2 — HEAD-advancing git op +case "$COMMAND" in + *"git commit"*|*"git merge"*|*"git pull"*|*"git rebase --continue"*|*"git cherry-pick"*) ;; + *) exit 0 ;; +esac + +# Gate 3 — not CI +[ -z "${CI:-}" ] || exit 0 + +# Gate 4 — inside git repo +git rev-parse --git-dir >/dev/null 2>&1 || exit 0 + +# Gate 5 — current branch == default branch +DEFAULT_BRANCH="" +if [ -f .planning/config.json ]; then + DEFAULT_BRANCH=$(node -e ' +try { + const c = require("./.planning/config.json"); + process.stdout.write(c.git?.base_branch || ""); +} catch { process.stdout.write(""); } +' 2>/dev/null || echo "") +fi +if [ -z "$DEFAULT_BRANCH" ]; then + for cand in main master trunk; do + if git rev-parse --verify "$cand" >/dev/null 2>&1; then + DEFAULT_BRANCH="$cand" + break + fi + done +fi +[ -n "$DEFAULT_BRANCH" ] || exit 0 + +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") +[ "$CURRENT_BRANCH" = "$DEFAULT_BRANCH" ] || exit 0 + +# Gate 6 — both graphify gates true in config +[ -f .planning/config.json ] || exit 0 +GATES=$(node -e ' +try { + const c = require("./.planning/config.json"); + const ok = c.graphify?.enabled === true && c.graphify?.auto_update === true; + process.stdout.write(ok ? "1" : "0"); +} catch { process.stdout.write("0"); } +' 2>/dev/null || echo "0") +[ "$GATES" = "1" ] || exit 0 + +# Gate 7 — graphify on PATH +GRAPHIFY_BIN=$(command -v graphify 2>/dev/null || true) +[ -n "$GRAPHIFY_BIN" ] || exit 0 + +# Gate 8 — no live rebuild in flight +mkdir -p .planning/graphs +LOCK_FILE=".planning/graphs/.rebuild.lock" +if [ -f "$LOCK_FILE" ]; then + PID=$(cat "$LOCK_FILE" 2>/dev/null || echo "") + if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then + exit 0 + fi +fi + +# All gates passed. Write initial running status synchronously so observers +# (the next planner load_graph_context step) see the in-flight signal. +HEAD_SHA=$(git rev-parse HEAD 2>/dev/null || echo "") +STATUS_FILE=".planning/graphs/.last-build-status.json" +TS_START=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "") +MS_START=$(node -e 'process.stdout.write(String(Date.now()))' 2>/dev/null || echo "0") + +GSD_TS="$TS_START" \ +GSD_HEAD="$HEAD_SHA" \ +GSD_STATUS_FILE="$STATUS_FILE" \ +node -e ' + const fs = require("node:fs"); + const status = { + ts: process.env.GSD_TS, + status: "running", + exit_code: null, + duration_ms: null, + head_at_build: process.env.GSD_HEAD, + graphify_version: null, + }; + fs.writeFileSync(process.env.GSD_STATUS_FILE, JSON.stringify(status, null, 2) + "\n"); +' 2>/dev/null || true + +# Resolve rebuild helper script (sibling-relative for portability across install layouts) +HOOK_DIR="$(cd "$(dirname "$0")" && pwd)" +REBUILD_SCRIPT="$HOOK_DIR/lib/gsd-graphify-rebuild.sh" +[ -f "$REBUILD_SCRIPT" ] || exit 0 + +# Detach the rebuild. Portable double-fork via subshell + disown — works on +# macOS (no setsid) and Linux. Redirect all I/O to /dev/null so the hook +# returns instantly even if the child keeps stdout/stderr handles open. +( + bash "$REBUILD_SCRIPT" \ + "$STATUS_FILE" \ + "$LOCK_FILE" \ + "$HEAD_SHA" \ + "$MS_START" \ + "$GRAPHIFY_BIN" \ + /dev/null 2>&1 & + disown +) & +disown + +exit 0 diff --git a/hooks/lib/gsd-graphify-rebuild.sh b/hooks/lib/gsd-graphify-rebuild.sh new file mode 100755 index 000000000..820c4890e --- /dev/null +++ b/hooks/lib/gsd-graphify-rebuild.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# gsd-graphify-rebuild.sh — detached rebuild runner for hooks/gsd-graphify-update.sh. +# +# Usage: +# gsd-graphify-rebuild.sh +# +# Writes its own PID into LOCK_FILE on start, removes LOCK_FILE on exit (any cause), +# runs `graphify update .` from the project root (cwd inherited from caller), copies +# the produced graphify-out/* into .planning/graphs/, and rewrites STATUS_FILE to +# reflect the final status ("ok" if graphify exited 0, "failed" otherwise). +# +# Designed to be invoked via `setsid ... &` so it is reparented away from the hook +# caller and never blocks the user-facing tool call. + +set -uo pipefail + +STATUS_FILE="${1:?STATUS_FILE required}" +LOCK_FILE="${2:?LOCK_FILE required}" +HEAD_SHA="${3:?HEAD_SHA required}" +MS_START="${4:?MS_START required}" +GRAPHIFY_BIN="${5:?GRAPHIFY_BIN required}" + +# Atomic-ish lock acquire: write our PID and trap cleanup +echo "$$" > "$LOCK_FILE" +trap 'rm -f "$LOCK_FILE"' EXIT + +"$GRAPHIFY_BIN" update . >/dev/null 2>&1 +EXIT_CODE=$? + +# Copy outputs only on success — failure path preserves the prior valid graph. +if [ "$EXIT_CODE" -eq 0 ] && [ -f graphify-out/graph.json ]; then + cp graphify-out/graph.json .planning/graphs/graph.json + cp graphify-out/graph.html .planning/graphs/graph.html 2>/dev/null || true + cp graphify-out/GRAPH_REPORT.md .planning/graphs/GRAPH_REPORT.md 2>/dev/null || true + cp .planning/graphs/graph.json .planning/graphs/.last-build-snapshot.json 2>/dev/null || true +fi + +# Compute duration in ms +MS_END=$(node -e 'process.stdout.write(String(Date.now()))' 2>/dev/null || echo "$MS_START") +DURATION=$((MS_END - MS_START)) + +STATUS_NAME="ok" +[ "$EXIT_CODE" -eq 0 ] || STATUS_NAME="failed" + +TS_END=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "") + +# Write the final status file. Use Node for safe JSON encoding. +GSD_STATUS_TS="$TS_END" \ +GSD_STATUS_NAME="$STATUS_NAME" \ +GSD_EXIT_CODE="$EXIT_CODE" \ +GSD_DURATION="$DURATION" \ +GSD_HEAD_SHA="$HEAD_SHA" \ +GSD_STATUS_FILE="$STATUS_FILE" \ +node -e ' + const fs = require("node:fs"); + const status = { + ts: process.env.GSD_STATUS_TS, + status: process.env.GSD_STATUS_NAME, + exit_code: parseInt(process.env.GSD_EXIT_CODE, 10), + duration_ms: parseInt(process.env.GSD_DURATION, 10), + head_at_build: process.env.GSD_HEAD_SHA, + graphify_version: null, + }; + fs.writeFileSync(process.env.GSD_STATUS_FILE, JSON.stringify(status, null, 2) + "\n"); +' 2>/dev/null || true diff --git a/sdk/shared/config-defaults.manifest.json b/sdk/shared/config-defaults.manifest.json index 005119a6a..b7cacb9e5 100644 --- a/sdk/shared/config-defaults.manifest.json +++ b/sdk/shared/config-defaults.manifest.json @@ -68,5 +68,8 @@ "ship": { "pr_body_sections": [] }, + "graphify": { + "auto_update": false + }, "agent_skills": {} } diff --git a/sdk/shared/config-schema.manifest.json b/sdk/shared/config-schema.manifest.json index 2c309cf0e..59000f973 100644 --- a/sdk/shared/config-schema.manifest.json +++ b/sdk/shared/config-schema.manifest.json @@ -90,6 +90,7 @@ "intel.enabled", "graphify.enabled", "graphify.build_timeout", + "graphify.auto_update", "claude_md_path", "claude_md_assembly.mode", "runtime", diff --git a/tests/feat-3347-graphify-auto-update-config.test.cjs b/tests/feat-3347-graphify-auto-update-config.test.cjs new file mode 100644 index 000000000..32b3378bd --- /dev/null +++ b/tests/feat-3347-graphify-auto-update-config.test.cjs @@ -0,0 +1,149 @@ +'use strict'; + +/** + * Regression tests for #3347 — opt-in auto-update of the knowledge graph + * after main HEAD advances. + * + * This file covers the config-key surface: the new `graphify.auto_update` + * key must be a valid config key, default to false, persist via config-set, + * and round-trip via config-get. The runtime hook behavior is covered in + * tests/feat-3347-graphify-auto-update-hook.test.cjs. + * + * Default-off discipline (issue #3347 acceptance criteria): + * - `graphify.auto_update` defaults to `false` so existing users see no + * behavior change after upgrade. + * - Opt-in via /gsd:settings or `gsd-tools config-set graphify.auto_update true`. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); + +const { + VALID_CONFIG_KEYS, + isValidConfigKey, +} = require('../get-shit-done/bin/lib/config-schema.cjs'); + +const { + CONFIG_DEFAULTS: CANONICAL_CONFIG_DEFAULTS, +} = require('../get-shit-done/bin/lib/configuration.generated.cjs'); + +describe('#3347 — graphify.auto_update is a registered config key', () => { + test('VALID_CONFIG_KEYS contains graphify.auto_update', () => { + assert.ok( + VALID_CONFIG_KEYS.has('graphify.auto_update'), + 'graphify.auto_update must be in VALID_CONFIG_KEYS so config-set accepts it', + ); + }); + + test('isValidConfigKey accepts graphify.auto_update', () => { + assert.ok( + isValidConfigKey('graphify.auto_update'), + 'isValidConfigKey must return true for graphify.auto_update', + ); + }); + + test('isValidConfigKey still accepts the pre-existing graphify.enabled key', () => { + assert.ok( + isValidConfigKey('graphify.enabled'), + 'regression guard: graphify.enabled must remain a valid key', + ); + }); +}); + +describe('#3347 — graphify.auto_update defaults to false', () => { + test('CANONICAL_CONFIG_DEFAULTS.graphify.auto_update is false', () => { + assert.ok( + CANONICAL_CONFIG_DEFAULTS.graphify !== undefined, + 'CANONICAL_CONFIG_DEFAULTS must expose a graphify section', + ); + assert.strictEqual( + CANONICAL_CONFIG_DEFAULTS.graphify.auto_update, + false, + 'graphify.auto_update default must be false (opt-in per issue #3347 AC)', + ); + }); +}); + +describe('#3347 — config-set graphify.auto_update round-trips', () => { + test('config-set graphify.auto_update true succeeds', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + + const result = runGsdTools( + ['config-set', 'graphify.auto_update', 'true'], + tmpDir, + ); + assert.ok( + result.success, + [ + 'config-set graphify.auto_update true should succeed,', + 'got:', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n'), + ); + }); + + test('config-set graphify.auto_update true writes to config.json', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + + runGsdTools(['config-set', 'graphify.auto_update', 'true'], tmpDir); + + const configPath = path.join(tmpDir, '.planning', 'config.json'); + assert.ok( + fs.existsSync(configPath), + '.planning/config.json must exist after config-set', + ); + + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + assert.strictEqual( + config.graphify?.auto_update, + true, + [ + 'Expected graphify.auto_update: true in config.json,', + 'got: ' + JSON.stringify(config.graphify), + ].join('\n'), + ); + }); + + test('config-set graphify.auto_update false persists too', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + + runGsdTools(['config-set', 'graphify.auto_update', 'true'], tmpDir); + runGsdTools(['config-set', 'graphify.auto_update', 'false'], tmpDir); + + const configPath = path.join(tmpDir, '.planning', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + assert.strictEqual( + config.graphify?.auto_update, + false, + 'config-set must round-trip true → false', + ); + }); + + test('config-set graphify.auto_update does not perturb sibling graphify.enabled', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + + runGsdTools(['config-set', 'graphify.enabled', 'true'], tmpDir); + runGsdTools(['config-set', 'graphify.auto_update', 'true'], tmpDir); + + const configPath = path.join(tmpDir, '.planning', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + assert.strictEqual( + config.graphify?.enabled, + true, + 'graphify.enabled must be preserved when setting graphify.auto_update', + ); + assert.strictEqual( + config.graphify?.auto_update, + true, + 'graphify.auto_update must coexist with graphify.enabled', + ); + }); +}); diff --git a/tests/feat-3347-graphify-auto-update-hook.test.cjs b/tests/feat-3347-graphify-auto-update-hook.test.cjs new file mode 100644 index 000000000..1848dc33d --- /dev/null +++ b/tests/feat-3347-graphify-auto-update-hook.test.cjs @@ -0,0 +1,394 @@ +'use strict'; + +/** + * Regression tests for #3347 — hooks/gsd-graphify-update.sh behavior. + * + * The hook is a PostToolUse handler that fires after every Bash tool call. + * It is a no-op except when ALL of these are true: + * - Tool name is Bash + * - tool_input.command matches a HEAD-advancing git operation + * - Current branch == default branch (main/master/trunk; configurable) + * - .planning/config.json has graphify.enabled === true + * - .planning/config.json has graphify.auto_update === true + * - $CI environment variable is unset / empty + * - graphify binary is on PATH + * - No live rebuild already in progress (PID lock check) + * + * When all gates pass, the hook: + * 1. Writes .planning/graphs/.last-build-status.json with status="running" + * and the current HEAD sha (sync, before detach). + * 2. Detaches a background `graphify update .` invocation that copies + * outputs into .planning/graphs/ and updates the status file to + * status="ok" or status="failed" on completion. + * 3. Returns exit 0 in <100ms regardless. + * + * On a hook return path failure (bail), no status file is written and no + * lock is acquired — the commit completes with no side effect. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const cp = require('node:child_process'); +const os = require('node:os'); + +const ROOT = path.join(__dirname, '..'); +const HOOK = path.join(ROOT, 'hooks', 'gsd-graphify-update.sh'); + +function createTempGitRepo(opts = {}) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3347-')); + cp.execFileSync('git', ['init', '-b', opts.defaultBranch || 'main'], { + cwd: tmpDir, + stdio: 'ignore', + }); + cp.execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir }); + cp.execFileSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir }); + fs.writeFileSync(path.join(tmpDir, 'README.md'), '# test\n'); + cp.execFileSync('git', ['add', 'README.md'], { cwd: tmpDir }); + cp.execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' }); + + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + if (opts.config !== undefined) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify(opts.config, null, 2), + ); + } + return tmpDir; +} + +function makeMockGraphifyBin(tmpDir, { exitCode = 0, sleepMs = 0 } = {}) { + const binDir = path.join(tmpDir, '.mock-bin'); + fs.mkdirSync(binDir, { recursive: true }); + const script = path.join(binDir, 'graphify'); + // Mock: sleep optionally to allow lock observation, then write + // graphify-out/graph.json and exit with the requested code. + const body = [ + '#!/usr/bin/env bash', + 'set -u', + sleepMs ? `sleep ${(sleepMs / 1000).toFixed(3)}` : '', + 'mkdir -p graphify-out', + 'echo \'{"nodes":[],"edges":[]}\' > graphify-out/graph.json', + 'echo "mock report" > graphify-out/GRAPH_REPORT.md', + 'echo "" > graphify-out/graph.html', + `exit ${exitCode}`, + ] + .filter(Boolean) + .join('\n'); + fs.writeFileSync(script, body + '\n', { mode: 0o755 }); + return binDir; +} + +function runHook(tmpDir, toolPayload, { env = {}, pathPrepend = '' } = {}) { + const PATH = pathPrepend + ? `${pathPrepend}${path.delimiter}${process.env.PATH || ''}` + : process.env.PATH || ''; + return cp.spawnSync('bash', [HOOK], { + cwd: tmpDir, + input: JSON.stringify(toolPayload), + env: { + ...process.env, + PATH, + CI: '', + ...env, + }, + encoding: 'utf8', + timeout: 5000, + }); +} + +function cleanup(tmpDir) { + // The hook detaches a graphify-rebuild subprocess that may still be writing + // into tmpDir when the test body returns. Wait briefly for its lock file to + // disappear (rebuild process exit trap removes it), then retry rmSync to + // absorb any remaining transient ENOTEMPTY race. + const lockPath = path.join(tmpDir, '.planning/graphs/.rebuild.lock'); + const lockDeadline = Date.now() + 4000; + while (Date.now() < lockDeadline) { + if (!fs.existsSync(lockPath)) break; + try { + const pid = parseInt(fs.readFileSync(lockPath, 'utf8'), 10); + if (!Number.isFinite(pid) || pid <= 0) break; + cp.execFileSync('kill', ['-0', String(pid)], { stdio: 'ignore' }); + } catch { + break; // PID dead → safe to clean up + } + cp.execFileSync('sleep', ['0.05']); + } + fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 8, retryDelay: 100 }); +} + +describe('#3347 hook — bail paths (no side effects)', () => { + test('non-Bash tool call exits 0 with no status file', (t) => { + const tmpDir = createTempGitRepo({ + config: { graphify: { enabled: true, auto_update: true } }, + }); + t.after(() => cleanup(tmpDir)); + const r = runHook(tmpDir, { tool_name: 'Edit', tool_input: { file_path: 'x' } }); + assert.strictEqual(r.status, 0, 'hook must exit 0 on non-Bash tool'); + assert.ok( + !fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')), + 'no status file should be created when bailing', + ); + }); + + test('Bash but non-HEAD-advancing command exits 0 with no status file', (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: 'ls -la' } }); + assert.strictEqual(r.status, 0); + assert.ok(!fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json'))); + }); + + test('git commit but graphify.enabled=false → no dispatch', (t) => { + const tmpDir = createTempGitRepo({ + config: { graphify: { enabled: false, auto_update: true } }, + }); + t.after(() => cleanup(tmpDir)); + const r = runHook(tmpDir, { tool_name: 'Bash', tool_input: { command: 'git commit -m x' } }); + assert.strictEqual(r.status, 0); + assert.ok(!fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json'))); + }); + + test('git commit but graphify.auto_update=false → no dispatch (opt-in)', (t) => { + const tmpDir = createTempGitRepo({ + config: { graphify: { enabled: true, auto_update: false } }, + }); + t.after(() => cleanup(tmpDir)); + const r = runHook(tmpDir, { tool_name: 'Bash', tool_input: { command: 'git commit -m x' } }); + assert.strictEqual(r.status, 0); + assert.ok( + !fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')), + 'opt-in default-off: auto_update=false must suppress dispatch', + ); + }); + + test('CI=true → no dispatch even with both gates true', (t) => { + const tmpDir = createTempGitRepo({ + config: { graphify: { enabled: true, auto_update: true } }, + }); + t.after(() => cleanup(tmpDir)); + const mockBin = makeMockGraphifyBin(tmpDir); + const r = runHook( + tmpDir, + { tool_name: 'Bash', tool_input: { command: 'git commit -m x' } }, + { env: { CI: 'true' }, pathPrepend: mockBin }, + ); + assert.strictEqual(r.status, 0); + assert.ok(!fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json'))); + }); + + test('on non-default branch → no dispatch', (t) => { + const tmpDir = createTempGitRepo({ + config: { graphify: { enabled: true, auto_update: true } }, + }); + t.after(() => cleanup(tmpDir)); + cp.execFileSync('git', ['checkout', '-b', 'worktree-agent-abc'], { + cwd: tmpDir, + stdio: 'ignore', + }); + const mockBin = makeMockGraphifyBin(tmpDir); + const r = runHook( + tmpDir, + { tool_name: 'Bash', tool_input: { command: 'git commit -m x' } }, + { pathPrepend: mockBin }, + ); + assert.strictEqual(r.status, 0); + assert.ok( + !fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')), + 'branch check must filter worktree-agent-* (non-default-branch) commits', + ); + }); + + test('graphify binary not on PATH → silent exit 0', (t) => { + const tmpDir = createTempGitRepo({ + config: { graphify: { enabled: true, auto_update: true } }, + }); + t.after(() => cleanup(tmpDir)); + // Note: do NOT prepend mock bin; rely on real PATH not having graphify + const r = runHook( + tmpDir, + { tool_name: 'Bash', tool_input: { command: 'git commit -m x' } }, + { env: { PATH: '/usr/bin:/bin' } }, + ); + assert.strictEqual(r.status, 0, 'must not break commits when graphify missing'); + }); +}); + +describe('#3347 hook — dispatch path (all gates pass)', () => { + test('writes status file with status=running synchronously before returning', (t) => { + const tmpDir = createTempGitRepo({ + config: { graphify: { enabled: true, auto_update: true } }, + }); + t.after(() => cleanup(tmpDir)); + // Sleep 2s in mock so we can observe the running state before completion + const mockBin = makeMockGraphifyBin(tmpDir, { sleepMs: 2000 }); + + const r = runHook( + tmpDir, + { tool_name: 'Bash', tool_input: { command: 'git commit -m x' } }, + { pathPrepend: mockBin }, + ); + assert.strictEqual(r.status, 0, 'hook must return 0'); + + const statusPath = path.join(tmpDir, '.planning/graphs/.last-build-status.json'); + assert.ok(fs.existsSync(statusPath), 'status file must be written synchronously'); + const status = JSON.parse(fs.readFileSync(statusPath, 'utf8')); + assert.strictEqual(status.status, 'running', 'initial status must be "running"'); + assert.ok(/^[0-9a-f]{7,40}$/.test(status.head_at_build), 'head_at_build must be a commit sha'); + }); + + test('completes to status=ok after detached graphify run succeeds', (t) => { + const tmpDir = createTempGitRepo({ + config: { graphify: { enabled: true, auto_update: true } }, + }); + t.after(() => cleanup(tmpDir)); + const mockBin = makeMockGraphifyBin(tmpDir, { exitCode: 0, sleepMs: 200 }); + + runHook( + tmpDir, + { tool_name: 'Bash', tool_input: { command: 'git commit -m x' } }, + { pathPrepend: mockBin }, + ); + + // Wait up to 5s for the detached process to finish updating the status + const statusPath = path.join(tmpDir, '.planning/graphs/.last-build-status.json'); + const deadline = Date.now() + 5000; + let status; + while (Date.now() < deadline) { + if (fs.existsSync(statusPath)) { + status = JSON.parse(fs.readFileSync(statusPath, 'utf8')); + if (status.status === 'ok') break; + } + cp.execFileSync('sleep', ['0.1']); + } + assert.ok(status, 'status file must exist after dispatch'); + assert.strictEqual(status.status, 'ok', 'mock graphify exit=0 → status ok'); + assert.strictEqual(status.exit_code, 0); + assert.ok(typeof status.duration_ms === 'number' && status.duration_ms >= 0); + }); + + test('completes to status=failed when graphify exits non-zero', (t) => { + const tmpDir = createTempGitRepo({ + config: { graphify: { enabled: true, auto_update: true } }, + }); + t.after(() => cleanup(tmpDir)); + const mockBin = makeMockGraphifyBin(tmpDir, { exitCode: 1, sleepMs: 100 }); + + runHook( + tmpDir, + { tool_name: 'Bash', tool_input: { command: 'git commit -m x' } }, + { pathPrepend: mockBin }, + ); + + const statusPath = path.join(tmpDir, '.planning/graphs/.last-build-status.json'); + const deadline = Date.now() + 5000; + let status; + while (Date.now() < deadline) { + if (fs.existsSync(statusPath)) { + status = JSON.parse(fs.readFileSync(statusPath, 'utf8')); + if (status.status === 'failed') break; + } + cp.execFileSync('sleep', ['0.1']); + } + assert.ok(status, 'status file must exist after dispatch'); + assert.strictEqual(status.status, 'failed', 'mock graphify exit=1 → status failed'); + assert.strictEqual(status.exit_code, 1); + }); + + test('lock file with a live PID prevents concurrent dispatch', (t) => { + const tmpDir = createTempGitRepo({ + config: { graphify: { enabled: true, auto_update: true } }, + }); + t.after(() => cleanup(tmpDir)); + fs.mkdirSync(path.join(tmpDir, '.planning/graphs'), { recursive: true }); + // Seed a live-PID lock pointing at our own process — kill -0 will succeed + fs.writeFileSync(path.join(tmpDir, '.planning/graphs/.rebuild.lock'), String(process.pid)); + + const mockBin = makeMockGraphifyBin(tmpDir); + const r = runHook( + tmpDir, + { tool_name: 'Bash', tool_input: { command: 'git commit -m x' } }, + { pathPrepend: mockBin }, + ); + assert.strictEqual(r.status, 0); + // Status file should NOT be written because a rebuild is in flight + assert.ok( + !fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')), + 'live PID lock must suppress dispatch', + ); + }); + + test('stale lock file (dead PID) is treated as absent', (t) => { + const tmpDir = createTempGitRepo({ + config: { graphify: { enabled: true, auto_update: true } }, + }); + t.after(() => cleanup(tmpDir)); + fs.mkdirSync(path.join(tmpDir, '.planning/graphs'), { recursive: true }); + // PID 1 is init; kill -0 1 succeeds for root but fails for non-root. + // Use a very large PID number unlikely to exist (max pid = 4194304 on linux). + fs.writeFileSync(path.join(tmpDir, '.planning/graphs/.rebuild.lock'), '4194303'); + + const mockBin = makeMockGraphifyBin(tmpDir, { sleepMs: 500 }); + const r = runHook( + tmpDir, + { tool_name: 'Bash', tool_input: { command: 'git commit -m x' } }, + { pathPrepend: mockBin }, + ); + assert.strictEqual(r.status, 0); + const statusPath = path.join(tmpDir, '.planning/graphs/.last-build-status.json'); + assert.ok(fs.existsSync(statusPath), 'stale lock must not block dispatch'); + }); + + test('respects git.base_branch config override (default branch != main)', (t) => { + const tmpDir = createTempGitRepo({ + defaultBranch: 'trunk', + config: { + graphify: { enabled: true, auto_update: true }, + git: { base_branch: 'trunk' }, + }, + }); + t.after(() => cleanup(tmpDir)); + const mockBin = makeMockGraphifyBin(tmpDir, { sleepMs: 100 }); + const r = runHook( + tmpDir, + { tool_name: 'Bash', tool_input: { command: 'git commit -m x' } }, + { pathPrepend: mockBin }, + ); + assert.strictEqual(r.status, 0); + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')), + 'hook must honor git.base_branch when default branch is not main', + ); + }); +}); + +describe('#3347 hook — HEAD-advancing command matchers', () => { + for (const cmd of [ + 'git commit -m fix', + 'git merge feature', + 'git pull --ff-only', + 'git rebase --continue', + 'git cherry-pick abc123', + ]) { + test(`dispatches on: ${cmd}`, (t) => { + const tmpDir = createTempGitRepo({ + config: { graphify: { enabled: true, auto_update: true } }, + }); + t.after(() => cleanup(tmpDir)); + const mockBin = makeMockGraphifyBin(tmpDir, { sleepMs: 100 }); + runHook( + tmpDir, + { tool_name: 'Bash', tool_input: { command: cmd } }, + { pathPrepend: mockBin }, + ); + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')), + `must dispatch for HEAD-advancing op: ${cmd}`, + ); + }); + } +}); From 088fc204ee7e74fdd8a4281b8ab48e50d5fee508 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 15 May 2026 10:23:55 -0400 Subject: [PATCH 2/6] fix(3347): clear CI gates raised by initial commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/CONFIGURATION.md: document graphify.auto_update key (config-schema-docs-parity) - hooks/gsd-check-update-worker.js: add gsd-graphify-update.sh to MANAGED_HOOKS (managed-hooks) - agents/gsd-planner.md + gsd-phase-researcher.md: slim auto-update awareness block to a one-line @-reference; extract full instructions to a new reference file - get-shit-done/references/planner-graphify-auto-update.md: new reference with the status-file schema, the four annotation cases (running/failed/ok-current/ok-stale), and interaction with the existing stale-mtime annotation - docs/INVENTORY.md: References (60 → 61 shipped) + row for new reference; regenerate docs/INVENTORY-MANIFEST.json via gen-inventory-manifest.cjs Co-Authored-By: Claude Opus 4.7 (1M context) --- agents/gsd-phase-researcher.md | 2 +- agents/gsd-planner.md | 16 +---- docs/CONFIGURATION.md | 1 + docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 3 +- .../planner-graphify-auto-update.md | 58 +++++++++++++++++++ hooks/gsd-check-update-worker.js | 1 + 7 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 get-shit-done/references/planner-graphify-auto-update.md diff --git a/agents/gsd-phase-researcher.md b/agents/gsd-phase-researcher.md index da39afaa5..17e6349fd 100644 --- a/agents/gsd-phase-researcher.md +++ b/agents/gsd-phase-researcher.md @@ -637,7 +637,7 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" graphify status If the status response has `stale: true`, note for later: "Graph is {age_hours}h old -- treat semantic relationships as approximate." Include this annotation inline with any graph context injected below. -**Auto-update awareness (issue #3347).** If `.planning/graphs/.last-build-status.json` exists, read it and surface the most recent auto-build state alongside the staleness note. The hook (`hooks/gsd-graphify-update.sh`, opt-in via `graphify.auto_update`) writes this file. Format the surfaced annotation based on `status`: `"running"` → "rebuild in flight (started {ts})"; `"failed"` → "auto-rebuild FAILED at {ts} (exit {exit_code}); context is from the prior build — run `/gsd:graphify build` manually to retry"; `"ok"` with `head_at_build` matching current `HEAD` → silent (graph is current); `"ok"` with `head_at_build` differing → "last rebuilt at {ts} for {head_at_build[:7]}; current HEAD has advanced". File missing → silent. +If `.planning/graphs/.last-build-status.json` exists, also load @get-shit-done/references/planner-graphify-auto-update.md — it covers how to surface the most recent auto-build state (running / failed / stale `head_at_build`). Opt-in via `graphify.auto_update` (default false, #3347); silent if the file is absent. Query the graph for each major capability in the phase scope (2-3 queries per D-05, discovery-focused): diff --git a/agents/gsd-planner.md b/agents/gsd-planner.md index 6a3a3b75b..7a36c4d3d 100644 --- a/agents/gsd-planner.md +++ b/agents/gsd-planner.md @@ -892,21 +892,7 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" graphify status If the status response has `stale: true`, note for later: "Graph is {age_hours}h old -- treat semantic relationships as approximate." Include this annotation inline with any graph context injected below. -**Auto-update awareness (issue #3347).** If `.planning/graphs/.last-build-status.json` exists, read it and surface the most recent auto-build state alongside the staleness note. The hook (`hooks/gsd-graphify-update.sh`, opt-in via `graphify.auto_update`) writes this file. Format the surfaced annotation based on `status`: - -- `status: "running"` — "Graph auto-rebuild in flight (started {ts}); treat semantic relationships as approximate until rebuild completes." -- `status: "failed"` — "Graph auto-rebuild FAILED at {ts} (exit {exit_code}); the planning context below is from the prior build. Run `/gsd:graphify build` to retry manually." -- `status: "ok"` with `head_at_build` matching the current `HEAD` — silent (graph is current). -- `status: "ok"` with `head_at_build` differing from current `HEAD` — "Graph last rebuilt at {ts} for commit {head_at_build[:7]}; current HEAD has advanced -- treat semantic relationships as approximate." -- File missing — silent; rely on the existing staleness note above. - -Read the status file with: - -```bash -test -f .planning/graphs/.last-build-status.json && cat .planning/graphs/.last-build-status.json -``` - -The auto-update mechanism is opt-in (`graphify.auto_update: false` by default per #3347); users who do not opt in will never see this file and the annotation above is a no-op. +If `.planning/graphs/.last-build-status.json` exists, load @get-shit-done/references/planner-graphify-auto-update.md for auto-build state surfacing (opt-in, #3347). Query the graph for phase-relevant dependency context (single query per D-06): diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 415b05752..73c7d8a0a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -464,6 +464,7 @@ Toggle optional capabilities via the `features.*` config namespace. Feature flag |---------|------|---------|-------------| | `graphify.enabled` | boolean | `false` | Enable the project knowledge graph. When `true`, `/gsd-graphify` builds and queries a graph in `.planning/graphs/`. Added in v1.36 | | `graphify.build_timeout` | number (seconds) | `300` | Maximum seconds allowed for a `/gsd-graphify build` run before it aborts. Added in v1.36 | +| `graphify.auto_update` | boolean | `false` | **Opt-in (issue #3347).** When `true` (and `graphify.enabled` is also `true`), the bundled PostToolUse hook `hooks/gsd-graphify-update.sh` auto-rebuilds the project knowledge graph in a detached background process after `git commit/merge/pull/rebase --continue/cherry-pick` on the default branch (`git.base_branch` override, else `main`/`master`/`trunk`). Hook returns instantly; the rebuild updates `.planning/graphs/{graph.json,graph.html,GRAPH_REPORT.md}` and writes `.planning/graphs/.last-build-status.json` (`{ts, status: "running"\|"ok"\|"failed", exit_code, duration_ms, head_at_build}`). PID-locked, CI-aware (`$CI` env suppresses), bails silently if `graphify` is not on `PATH`. Default `false` so existing behaviour is unchanged after upgrade. | #### Multi-developer setup diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 62c2266ea..317360c7a 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -224,6 +224,7 @@ "planner-antipatterns.md", "planner-chunked.md", "planner-gap-closure.md", + "planner-graphify-auto-update.md", "planner-human-verify-mode.md", "planner-mvp-mode.md", "planner-reviews.md", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index f8e5a884a..4203c0b59 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -262,7 +262,7 @@ Full roster at `get-shit-done/workflows/*.md`. Workflows are thin orchestrators --- -## References (60 shipped) +## References (61 shipped) Full roster at `get-shit-done/references/*.md`. References are shared knowledge documents that workflows and agents `@-reference`. The groupings below match [`docs/ARCHITECTURE.md`](ARCHITECTURE.md#references-get-shit-donereferencesmd) — core, workflow, thinking-model clusters, and the modular planner decomposition. @@ -352,6 +352,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t | `planner-source-audit.md` | Planner source-audit and authority-limit rules. | | `planner-mvp-mode.md` | Vertical-slice planning rules for MVP mode. | | `planner-human-verify-mode.md` | Rules for `workflow.human_verify_mode = end-of-phase`: suppress `checkpoint:human-verify` task emission and route deferred items via ``. | +| `planner-graphify-auto-update.md` | How `load_graph_context` surfaces `.last-build-status.json` auto-update state (running / failed / stale head) alongside the existing staleness annotation. Opt-in via `graphify.auto_update` (#3347). | | `skeleton-template.md` | SKELETON.md template emitted for new-project Walking Skeleton (Phase 1 + `--mvp`). | | `user-story-template.md` | User story format for MVP planning — "As a / I want to / So that" structured fields. | | `spidr-splitting.md` | SPIDR splitting decomposition rules for handling large user stories in MVP mode. | diff --git a/get-shit-done/references/planner-graphify-auto-update.md b/get-shit-done/references/planner-graphify-auto-update.md new file mode 100644 index 000000000..e7c3167fc --- /dev/null +++ b/get-shit-done/references/planner-graphify-auto-update.md @@ -0,0 +1,58 @@ +# Planner — Graphify Auto-Update Awareness + +> Loaded by `gsd-planner` and `gsd-phase-researcher` inside the `` block, after the existing `graphify status` staleness check. Surfaces the most recent auto-update state from `.planning/graphs/.last-build-status.json`, the file written by the bundled `hooks/gsd-graphify-update.sh` PostToolUse hook (opt-in via `graphify.auto_update`, default `false` — issue #3347). + +## Why this exists + +The graph at `.planning/graphs/graph.json` is consumed automatically (every `gsd-planner` and `gsd-phase-researcher` step) but produced manually (one `/gsd:graphify build` per session at best). Without auto-update, the producer-consumer gap silently widens with every commit. The existing `stale: true` annotation tells the consumer the mtime is old; it cannot tell the consumer whether the auto-build hook has been running, just failed, or is in flight. + +When `graphify.auto_update: true`, the hook writes a status file synchronously before detaching and rewrites it on completion. This reference instructs the planner to read that file and surface the state inline with the existing staleness note. + +## The status file + +`.planning/graphs/.last-build-status.json`: + +```json +{ + "ts": "2026-05-15T14:02:23Z", + "status": "running" | "ok" | "failed", + "exit_code": null | , + "duration_ms": null | , + "head_at_build": "", + "graphify_version": null | "" +} +``` + +The hook writes `status: "running"` synchronously **before** detach, so the next planner invocation can see the in-flight signal even if `graphify update .` has not finished. The detached `hooks/lib/gsd-graphify-rebuild.sh` rewrites the file to `ok` or `failed` on completion (with `exit_code` and `duration_ms`). + +## Read the file + +```bash +test -f .planning/graphs/.last-build-status.json && cat .planning/graphs/.last-build-status.json +``` + +If the file is absent, the operator either hasn't opted in to `graphify.auto_update` or hasn't yet performed a HEAD-advancing git op since enabling it. The annotation below is a no-op in that case. + +## Format the annotation + +Combine the status with the current `HEAD` sha when relevant. The first matching case wins: + +| Status | `head_at_build` vs current HEAD | Annotation | +|--------|----------------------------------|------------| +| `running` | (any) | "Graph auto-rebuild in flight (started `{ts}`); treat semantic relationships as approximate until rebuild completes." | +| `failed` | (any) | "Graph auto-rebuild FAILED at `{ts}` (exit `{exit_code}`); the planning context below is from the prior build. Run `/gsd:graphify build` to retry manually." | +| `ok` | matches | (silent — graph is current at the current HEAD) | +| `ok` | differs | "Graph last rebuilt at `{ts}` for commit `{head_at_build[:7]}`; current HEAD has advanced — treat semantic relationships as approximate." | +| (file missing) | n/a | (silent — fall back to the existing `stale: true` annotation only) | + +Get the current HEAD with `git rev-parse HEAD`. + +## Interaction with the existing staleness note + +If both the existing `stale: true` mtime check AND the auto-update annotation are non-silent, present them on the same line, ordered: auto-update state first, mtime staleness second. Example: + +> "Graph auto-rebuild FAILED at 2026-05-15T14:02:23Z (exit 1); the planning context below is from the prior build. Run `/gsd:graphify build` to retry manually. (Existing graph is 36h old — treat semantic relationships as approximate.)" + +## Opt-in reminder + +The auto-update mechanism is opt-in (`graphify.auto_update: false` by default per issue #3347). Users who haven't opted in will never produce this file, and every annotation above is a no-op. The existing `stale: true` annotation continues to be the only signal. diff --git a/hooks/gsd-check-update-worker.js b/hooks/gsd-check-update-worker.js index ff5a16f7e..c460878f7 100644 --- a/hooks/gsd-check-update-worker.js +++ b/hooks/gsd-check-update-worker.js @@ -50,6 +50,7 @@ const MANAGED_HOOKS = [ 'gsd-check-update-worker.js', 'gsd-check-update.js', 'gsd-context-monitor.js', + 'gsd-graphify-update.sh', 'gsd-phase-boundary.sh', 'gsd-prompt-guard.js', 'gsd-read-guard.js', From b0fd7325793b0e6598849a2a7b5a9f67f1fc7229 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 15 May 2026 10:29:43 -0400 Subject: [PATCH 3/6] fix(3347): allowlist graphify-update hook path in docs-parity-live-registry test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/CONFIGURATION.md references the bundled hook by its file path (hooks/gsd-graphify-update.sh). The docs-parity regex captures /gsd-graphify-update from the path component and looks it up in the live command registry, where it does not (and should not) exist — it's a hook script, not a slash command. Add the slug alongside the other hook-path slugs (statusline, context-monitor, update-banner). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/docs-parity-live-registry.test.cjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/docs-parity-live-registry.test.cjs b/tests/docs-parity-live-registry.test.cjs index f193e4a52..6ad1e8e2c 100644 --- a/tests/docs-parity-live-registry.test.cjs +++ b/tests/docs-parity-live-registry.test.cjs @@ -78,14 +78,16 @@ const INTERNAL_COMPONENT_SLUGS = new Set([ 'tools', // Hook scripts — internal runtime hooks, not user-invocable slash commands. - // hooks/gsd-statusline.js — session statusline hook - // hooks/gsd-context-monitor.js — context-window monitor hook - // hooks/gsd-update-banner.js — update-available banner hook + // hooks/gsd-statusline.js — session statusline hook + // hooks/gsd-context-monitor.js — context-window monitor hook + // hooks/gsd-update-banner.js — update-available banner hook + // hooks/gsd-graphify-update.sh — knowledge-graph auto-update PostToolUse hook (#3347) // These appear in docs as file-path references (e.g. "gsd-statusline.js reads // the cache"), not as command invocations. 'statusline', 'context-monitor', 'update-banner', + 'graphify-update', // gsd-update-check.json — background update-check CACHE FILE, not a slash command. // ARCHITECTURE.md references "~/.cache/gsd/gsd-update-check.json" as a path; From dd4f75376a3fe26632f5895caf92214f1b44d295 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 15 May 2026 10:46:20 -0400 Subject: [PATCH 4/6] fix(3347): surface auto-build state via graphifyStatus; trim planner edits to free size budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agents/gsd-planner.md was 49,316 chars after the initial PR; the planner-decomposition <48K test was passing on main at 49,150 chars (just under the 49152 limit). My addition pushed it over. Restructure: instead of teaching the planner agent to read .last-build-status.json directly, fold the auto-build state into graphifyStatus()'s existing `stale: true` signal. The planner's existing rule ("if stale: true, treat as approximate") fires correctly for failed and in-flight auto-builds — no new planner-side prompt content needed. The full state is exposed under `last_build_auto_update` for callers that want exit_code / duration_ms / commit-sha context. - get-shit-done/bin/lib/graphify.cjs: graphifyStatus() reads .planning/graphs/.last-build-status.json; OR-folds status in {failed, running} into the existing stale signal; exposes last_build_auto_update field - agents/gsd-planner.md: revert the auto-update awareness paragraph (49,524 → 49,150) - agents/gsd-phase-researcher.md: revert the parallel paragraph for consistency - get-shit-done/references/planner-graphify-auto-update.md: rewrite to document the graphifyStatus seam instead of planner-side prompt instructions - tests/feat-3347-graphify-auto-update-config.test.cjs: 4 new graphifyStatus tests pinning the failed/running/ok/missing matrix - tests/feat-3347-graphify-auto-update-hook.test.cjs: bump per-spawn timeout 5s → 30s and wait-deadline 5s → 15s to absorb cold-start latency under parallel-test-file load (full suite runs many *.test.cjs concurrently) Co-Authored-By: Claude Opus 4.7 (1M context) --- agents/gsd-phase-researcher.md | 2 - agents/gsd-planner.md | 2 - get-shit-done/bin/lib/graphify.cjs | 17 +++- .../planner-graphify-auto-update.md | 55 +++++++----- ...-3347-graphify-auto-update-config.test.cjs | 89 +++++++++++++++++++ ...at-3347-graphify-auto-update-hook.test.cjs | 6 +- 6 files changed, 140 insertions(+), 31 deletions(-) diff --git a/agents/gsd-phase-researcher.md b/agents/gsd-phase-researcher.md index 17e6349fd..a4482938d 100644 --- a/agents/gsd-phase-researcher.md +++ b/agents/gsd-phase-researcher.md @@ -637,8 +637,6 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" graphify status If the status response has `stale: true`, note for later: "Graph is {age_hours}h old -- treat semantic relationships as approximate." Include this annotation inline with any graph context injected below. -If `.planning/graphs/.last-build-status.json` exists, also load @get-shit-done/references/planner-graphify-auto-update.md — it covers how to surface the most recent auto-build state (running / failed / stale `head_at_build`). Opt-in via `graphify.auto_update` (default false, #3347); silent if the file is absent. - Query the graph for each major capability in the phase scope (2-3 queries per D-05, discovery-focused): ```bash diff --git a/agents/gsd-planner.md b/agents/gsd-planner.md index 7a36c4d3d..b0d6d5701 100644 --- a/agents/gsd-planner.md +++ b/agents/gsd-planner.md @@ -892,8 +892,6 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" graphify status If the status response has `stale: true`, note for later: "Graph is {age_hours}h old -- treat semantic relationships as approximate." Include this annotation inline with any graph context injected below. -If `.planning/graphs/.last-build-status.json` exists, load @get-shit-done/references/planner-graphify-auto-update.md for auto-build state surfacing (opt-in, #3347). - Query the graph for phase-relevant dependency context (single query per D-06): ```bash diff --git a/get-shit-done/bin/lib/graphify.cjs b/get-shit-done/bin/lib/graphify.cjs index 7dc6ecd1c..600adc67b 100644 --- a/get-shit-done/bin/lib/graphify.cjs +++ b/get-shit-done/bin/lib/graphify.cjs @@ -411,18 +411,33 @@ function graphifyStatus(cwd) { if (commitsBehind !== null) commitStale = commitsBehind > 0; } + // Auto-update status (#3347). Read .last-build-status.json written by the + // hooks/gsd-graphify-update.sh PostToolUse hook (opt-in via graphify.auto_update, + // default false). When the most recent auto-build is "failed" or still "running", + // fold that into the existing `stale: true` signal so consumers (gsd-planner, + // gsd-phase-researcher) surface the standard "treat semantic relationships as + // approximate" annotation without per-consumer prompt changes. The full state + // (running/failed/exit_code/duration_ms/head_at_build) is exposed under + // `last_build` for callers that want richer context. + const statusPath = path.join(planningDir, 'graphs', '.last-build-status.json'); + const lastBuildAutoUpdate = fs.existsSync(statusPath) ? safeReadJson(statusPath) : null; + const autoUpdateStale = + lastBuildAutoUpdate && + (lastBuildAutoUpdate.status === 'failed' || lastBuildAutoUpdate.status === 'running'); + return { exists: true, last_build: stat.mtime.toISOString(), node_count: (graph.nodes || []).length, edge_count: (graph.edges || graph.links || []).length, hyperedge_count: (graph.hyperedges || []).length, - stale: age > STALE_MS, + stale: age > STALE_MS || Boolean(autoUpdateStale), age_hours: Math.round(age / (60 * 60 * 1000)), built_at_commit: builtAt ? builtAt.slice(0, 7) : null, current_commit: head ? head.slice(0, 7) : null, commits_behind: commitsBehind, commit_stale: commitStale, + last_build_auto_update: lastBuildAutoUpdate || null, }; } diff --git a/get-shit-done/references/planner-graphify-auto-update.md b/get-shit-done/references/planner-graphify-auto-update.md index e7c3167fc..9eff93169 100644 --- a/get-shit-done/references/planner-graphify-auto-update.md +++ b/get-shit-done/references/planner-graphify-auto-update.md @@ -1,12 +1,12 @@ -# Planner — Graphify Auto-Update Awareness +# Graphify Auto-Update — Status Surfacing -> Loaded by `gsd-planner` and `gsd-phase-researcher` inside the `` block, after the existing `graphify status` staleness check. Surfaces the most recent auto-update state from `.planning/graphs/.last-build-status.json`, the file written by the bundled `hooks/gsd-graphify-update.sh` PostToolUse hook (opt-in via `graphify.auto_update`, default `false` — issue #3347). +> Documents how `gsd-planner` and `gsd-phase-researcher` surface the opt-in graphify auto-update state (issue #3347). The status surface lives inside `graphifyStatus()` in `get-shit-done/bin/lib/graphify.cjs`; no planner-side prompt changes are required. ## Why this exists -The graph at `.planning/graphs/graph.json` is consumed automatically (every `gsd-planner` and `gsd-phase-researcher` step) but produced manually (one `/gsd:graphify build` per session at best). Without auto-update, the producer-consumer gap silently widens with every commit. The existing `stale: true` annotation tells the consumer the mtime is old; it cannot tell the consumer whether the auto-build hook has been running, just failed, or is in flight. +The graph at `.planning/graphs/graph.json` is consumed automatically (every `gsd-planner` and `gsd-phase-researcher` step) but produced manually (`/gsd:graphify build` per session at best). Without auto-update, the producer-consumer gap silently widens with every commit. The existing `stale: true` annotation tells the consumer the mtime is old; it cannot tell the consumer whether the auto-build hook has been running, just failed, or is in flight. -When `graphify.auto_update: true`, the hook writes a status file synchronously before detaching and rewrites it on completion. This reference instructs the planner to read that file and surface the state inline with the existing staleness note. +When `graphify.auto_update: true`, the bundled `hooks/gsd-graphify-update.sh` PostToolUse hook fires after HEAD-advancing git operations on the default branch and dispatches `graphify update .` in a detached subprocess. The hook writes a status file synchronously before detach; the detached process rewrites it on completion. ## The status file @@ -25,34 +25,43 @@ When `graphify.auto_update: true`, the hook writes a status file synchronously b The hook writes `status: "running"` synchronously **before** detach, so the next planner invocation can see the in-flight signal even if `graphify update .` has not finished. The detached `hooks/lib/gsd-graphify-rebuild.sh` rewrites the file to `ok` or `failed` on completion (with `exit_code` and `duration_ms`). -## Read the file +## How the planner surfaces it (zero new prompt content) -```bash -test -f .planning/graphs/.last-build-status.json && cat .planning/graphs/.last-build-status.json +`graphifyStatus()` in `get-shit-done/bin/lib/graphify.cjs` reads `.last-build-status.json` and folds the `running` / `failed` states into the existing `stale: true` signal: + +```javascript +const autoUpdateStale = + lastBuildAutoUpdate && + (lastBuildAutoUpdate.status === 'failed' || lastBuildAutoUpdate.status === 'running'); + +return { + ... + stale: age > STALE_MS || Boolean(autoUpdateStale), + ... + last_build_auto_update: lastBuildAutoUpdate || null, +}; ``` -If the file is absent, the operator either hasn't opted in to `graphify.auto_update` or hasn't yet performed a HEAD-advancing git op since enabling it. The annotation below is a no-op in that case. +The planner and researcher already run `node ... graphify status` inside their `` blocks and already have the rule: -## Format the annotation +> If the status response has `stale: true`, note for later: "Graph is `{age_hours}h` old — treat semantic relationships as approximate." -Combine the status with the current `HEAD` sha when relevant. The first matching case wins: +That rule now fires correctly in three additional cases: -| Status | `head_at_build` vs current HEAD | Annotation | -|--------|----------------------------------|------------| -| `running` | (any) | "Graph auto-rebuild in flight (started `{ts}`); treat semantic relationships as approximate until rebuild completes." | -| `failed` | (any) | "Graph auto-rebuild FAILED at `{ts}` (exit `{exit_code}`); the planning context below is from the prior build. Run `/gsd:graphify build` to retry manually." | -| `ok` | matches | (silent — graph is current at the current HEAD) | -| `ok` | differs | "Graph last rebuilt at `{ts}` for commit `{head_at_build[:7]}`; current HEAD has advanced — treat semantic relationships as approximate." | -| (file missing) | n/a | (silent — fall back to the existing `stale: true` annotation only) | +| Trigger | What user sees | +|---------|----------------| +| Auto-build status = `failed` | Existing "treat as approximate" note fires (because `stale: true`). The full `last_build_auto_update` object is in the JSON for callers that want exit-code / duration / commit-sha context. | +| Auto-build status = `running` | Same — the next planner invocation knows the graph is mid-rebuild and treats it as approximate until the detached process completes. | +| Auto-build status = `ok` AND mtime < 24h | Annotation is silent — the graph is fresh and the most recent auto-build succeeded. | -Get the current HEAD with `git rev-parse HEAD`. +The file-missing case is silent (the operator either has not opted in or has not yet triggered a HEAD-advancing git op since enabling). -## Interaction with the existing staleness note +## Why this design -If both the existing `stale: true` mtime check AND the auto-update annotation are non-silent, present them on the same line, ordered: auto-update state first, mtime staleness second. Example: - -> "Graph auto-rebuild FAILED at 2026-05-15T14:02:23Z (exit 1); the planning context below is from the prior build. Run `/gsd:graphify build` to retry manually. (Existing graph is 36h old — treat semantic relationships as approximate.)" +- **No planner-side prompt changes.** Folding into `stale: true` reuses the existing rule, which means no new content in `agents/gsd-planner.md` (which is already at the `< 48K` decomposition limit per `DEFECT.AGENT-FILE-SIZE-CAP-BREACH`). +- **Tests catch regressions on the seam.** `tests/feat-3347-graphify-auto-update-config.test.cjs` pins `graphifyStatus` behavior for status=`failed` / `running` / `ok` / file-missing. +- **Backwards compatible.** Callers that don't read `last_build_auto_update` see the same shape as before, with `stale` reflecting both mtime AND auto-build state. No consumer breakage. ## Opt-in reminder -The auto-update mechanism is opt-in (`graphify.auto_update: false` by default per issue #3347). Users who haven't opted in will never produce this file, and every annotation above is a no-op. The existing `stale: true` annotation continues to be the only signal. +The auto-update mechanism is opt-in (`graphify.auto_update: false` by default per issue #3347). Users who haven't opted in will never produce this file. `graphifyStatus()` returns `last_build_auto_update: null` and falls back to the mtime-only `stale` rule. diff --git a/tests/feat-3347-graphify-auto-update-config.test.cjs b/tests/feat-3347-graphify-auto-update-config.test.cjs index 32b3378bd..5f7a9d8d8 100644 --- a/tests/feat-3347-graphify-auto-update-config.test.cjs +++ b/tests/feat-3347-graphify-auto-update-config.test.cjs @@ -30,6 +30,40 @@ const { CONFIG_DEFAULTS: CANONICAL_CONFIG_DEFAULTS, } = require('../get-shit-done/bin/lib/configuration.generated.cjs'); +const fsx = require('node:fs'); +const pathx = require('node:path'); +const cpx = require('node:child_process'); +const osx = require('node:os'); +const { graphifyStatus } = require('../get-shit-done/bin/lib/graphify.cjs'); + +function makeStatusProject(autoUpdate) { + const tmpDir = fsx.mkdtempSync(pathx.join(osx.tmpdir(), 'gsd-3347-status-')); + cpx.execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: tmpDir }); + cpx.execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir }); + cpx.execFileSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir }); + fsx.writeFileSync(pathx.join(tmpDir, 'README.md'), '# t\n'); + cpx.execFileSync('git', ['add', '.'], { cwd: tmpDir }); + cpx.execFileSync('git', ['commit', '-qm', 'init'], { cwd: tmpDir }); + fsx.mkdirSync(pathx.join(tmpDir, '.planning/graphs'), { recursive: true }); + fsx.writeFileSync( + pathx.join(tmpDir, '.planning/config.json'), + JSON.stringify({ graphify: { enabled: true } }), + ); + // Write a fresh (current-mtime) graph so age-based stale is false; only the + // auto-update status field can set stale: true. + fsx.writeFileSync( + pathx.join(tmpDir, '.planning/graphs/graph.json'), + JSON.stringify({ nodes: [], edges: [] }), + ); + if (autoUpdate !== null) { + fsx.writeFileSync( + pathx.join(tmpDir, '.planning/graphs/.last-build-status.json'), + JSON.stringify(autoUpdate), + ); + } + return tmpDir; +} + describe('#3347 — graphify.auto_update is a registered config key', () => { test('VALID_CONFIG_KEYS contains graphify.auto_update', () => { assert.ok( @@ -126,6 +160,61 @@ describe('#3347 — config-set graphify.auto_update round-trips', () => { ); }); + test('graphifyStatus folds auto-update status=failed into stale=true', (t) => { + const tmpDir = makeStatusProject({ + ts: '2026-05-15T12:00:00Z', + status: 'failed', + exit_code: 1, + duration_ms: 1234, + head_at_build: 'abcdef0', + graphify_version: null, + }); + t.after(() => fsx.rmSync(tmpDir, { recursive: true, force: true })); + const s = graphifyStatus(tmpDir); + assert.strictEqual(s.stale, true, 'auto-build failure must set stale=true'); + assert.ok(s.last_build_auto_update, 'last_build_auto_update must be exposed'); + assert.strictEqual(s.last_build_auto_update.status, 'failed'); + assert.strictEqual(s.last_build_auto_update.exit_code, 1); + }); + + test('graphifyStatus folds auto-update status=running into stale=true', (t) => { + const tmpDir = makeStatusProject({ + ts: '2026-05-15T12:00:00Z', + status: 'running', + exit_code: null, + duration_ms: null, + head_at_build: 'abcdef0', + graphify_version: null, + }); + t.after(() => fsx.rmSync(tmpDir, { recursive: true, force: true })); + const s = graphifyStatus(tmpDir); + assert.strictEqual(s.stale, true, 'auto-build in-flight must set stale=true'); + assert.strictEqual(s.last_build_auto_update.status, 'running'); + }); + + test('graphifyStatus leaves stale alone when auto-update status=ok and graph is fresh', (t) => { + const tmpDir = makeStatusProject({ + ts: '2026-05-15T12:00:00Z', + status: 'ok', + exit_code: 0, + duration_ms: 1234, + head_at_build: 'abcdef0', + graphify_version: null, + }); + t.after(() => fsx.rmSync(tmpDir, { recursive: true, force: true })); + const s = graphifyStatus(tmpDir); + assert.strictEqual(s.stale, false, 'fresh graph + ok auto-build => not stale'); + assert.strictEqual(s.last_build_auto_update.status, 'ok'); + }); + + test('graphifyStatus exposes last_build_auto_update: null when status file absent', (t) => { + const tmpDir = makeStatusProject(null); + t.after(() => fsx.rmSync(tmpDir, { recursive: true, force: true })); + const s = graphifyStatus(tmpDir); + assert.strictEqual(s.last_build_auto_update, null); + assert.strictEqual(s.stale, false, 'no status file => stale follows mtime only'); + }); + test('config-set graphify.auto_update does not perturb sibling graphify.enabled', (t) => { const tmpDir = createTempProject(); t.after(() => cleanup(tmpDir)); diff --git a/tests/feat-3347-graphify-auto-update-hook.test.cjs b/tests/feat-3347-graphify-auto-update-hook.test.cjs index 1848dc33d..c4eda8948 100644 --- a/tests/feat-3347-graphify-auto-update-hook.test.cjs +++ b/tests/feat-3347-graphify-auto-update-hook.test.cjs @@ -94,7 +94,7 @@ function runHook(tmpDir, toolPayload, { env = {}, pathPrepend = '' } = {}) { ...env, }, encoding: 'utf8', - timeout: 5000, + timeout: 30000, }); } @@ -256,7 +256,7 @@ describe('#3347 hook — dispatch path (all gates pass)', () => { // Wait up to 5s for the detached process to finish updating the status const statusPath = path.join(tmpDir, '.planning/graphs/.last-build-status.json'); - const deadline = Date.now() + 5000; + const deadline = Date.now() + 15000; let status; while (Date.now() < deadline) { if (fs.existsSync(statusPath)) { @@ -285,7 +285,7 @@ describe('#3347 hook — dispatch path (all gates pass)', () => { ); const statusPath = path.join(tmpDir, '.planning/graphs/.last-build-status.json'); - const deadline = Date.now() + 5000; + const deadline = Date.now() + 15000; let status; while (Date.now() < deadline) { if (fs.existsSync(statusPath)) { From 72bc456662060231ebcf5cb697f51c8b68dd1f4c Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 15 May 2026 11:00:12 -0400 Subject: [PATCH 5/6] chore(3347): backfill PR number in changeset Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/3347-graphify-auto-update-hook.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/3347-graphify-auto-update-hook.md b/.changeset/3347-graphify-auto-update-hook.md index 48a7e8be5..d6e96cbd0 100644 --- a/.changeset/3347-graphify-auto-update-hook.md +++ b/.changeset/3347-graphify-auto-update-hook.md @@ -1,6 +1,6 @@ --- type: Added -pr: 0 +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. From d20d3e88b5d35a63f45e93ac6dbaad94c012533a Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 15 May 2026 11:55:19 -0400 Subject: [PATCH 6/6] fix: align settings docs and inventory completion matching --- docs/INVENTORY.md | 2 +- .../bin/lib/workstream-inventory-builder.generated.cjs | 4 ++-- get-shit-done/workflows/settings.md | 7 +++++-- sdk/src/workstream-inventory/builder.test.ts | 4 ++++ sdk/src/workstream-inventory/builder.ts | 6 +++--- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 4203c0b59..ee234fd0a 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -357,7 +357,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t | `user-story-template.md` | User story format for MVP planning — "As a / I want to / So that" structured fields. | | `spidr-splitting.md` | SPIDR splitting decomposition rules for handling large user stories in MVP mode. | -> **Subdirectory:** `get-shit-done/references/few-shot-examples/` contains additional few-shot examples (`plan-checker.md`, `verifier.md`) that are referenced from specific agents. These are not counted in the 60 top-level references. +> **Subdirectory:** `get-shit-done/references/few-shot-examples/` contains additional few-shot examples (`plan-checker.md`, `verifier.md`) that are referenced from specific agents. These are not counted in the 61 top-level references. --- diff --git a/get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs b/get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs index 5a0aed285..c7c4eca16 100644 --- a/get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs +++ b/get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs @@ -19,8 +19,8 @@ function toPosixPath(p) { } function isCompletedInventory(status) { - const s = String(status ?? '').toLowerCase(); - return s.includes('milestone complete') || s.includes('archived'); + const s = String(status ?? '').trim().toLowerCase(); + return /\bmilestone\s+complete\b/.test(s) || /\barchived\b/.test(s); } function buildWorkstreamInventory(inputs) { diff --git a/get-shit-done/workflows/settings.md b/get-shit-done/workflows/settings.md index 53c9215d0..851627bfe 100644 --- a/get-shit-done/workflows/settings.md +++ b/get-shit-done/workflows/settings.md @@ -91,7 +91,7 @@ Verifier, TDD Mode, Code Review, Code Review Depth _(conditional — only when c Commit Docs, Skip Discuss, Worktrees ### Features -Intel, Graphify +Intel, Graphify, Graph auto-update _(conditional — only when graphify=on)_ ### Model & Pipeline Model Profile, Auto-Advance, Branching @@ -101,6 +101,8 @@ Context Warnings, Research Qs **Conditional visibility — code_review_depth:** This question is shown only when the user's chosen `code_review` value (after they answer that question, or the pre-selected value if unchanged) is on. If `code_review` is off, omit the `code_review_depth` question from the AskUserQuestion block and preserve the existing `workflow.code_review_depth` value in config (do not overwrite). Implementation: ask the Model + Planning + Execution-up-to-Code-Review questions first; if `code_review=on`, include `code_review_depth` in the same batch; otherwise skip it. Conceptually this is a one-branch split on the `code_review` answer. +**Conditional visibility — graphify.auto_update:** This question is shown only when the user's chosen `graphify.enabled` value is on. If `graphify.enabled` is off, omit the `graphify.auto_update` question and preserve the existing `graphify.auto_update` value in config (do not overwrite). Implementation: ask Graphify first; only ask Graph auto-update when Graphify is enabled. + ``` AskUserQuestion([ { @@ -437,7 +439,8 @@ Write `~/.gsd/defaults.json` with: "enabled": }, "graphify": { - "enabled": + "enabled": , + "auto_update": } } ``` diff --git a/sdk/src/workstream-inventory/builder.test.ts b/sdk/src/workstream-inventory/builder.test.ts index 34cdb275c..a1dbb4039 100644 --- a/sdk/src/workstream-inventory/builder.test.ts +++ b/sdk/src/workstream-inventory/builder.test.ts @@ -231,6 +231,10 @@ describe('isCompletedInventory', () => { expect(isCompletedInventory('unknown')).toBe(false); }); + it('returns false for "unarchived" (word-boundary guard)', () => { + expect(isCompletedInventory('unarchived')).toBe(false); + }); + it('returns false for empty string', () => { expect(isCompletedInventory('')).toBe(false); }); diff --git a/sdk/src/workstream-inventory/builder.ts b/sdk/src/workstream-inventory/builder.ts index 23676cab3..af4214c57 100644 --- a/sdk/src/workstream-inventory/builder.ts +++ b/sdk/src/workstream-inventory/builder.ts @@ -86,11 +86,11 @@ function toPosixPath(p: string): string { /** * Pure classifier: returns true if the given status string indicates a - * completed or archived workstream (case-insensitive substring match). + * completed or archived workstream (case-insensitive, boundary-aware match). */ export function isCompletedInventory(status: string): boolean { - const s = String(status ?? '').toLowerCase(); - return s.includes('milestone complete') || s.includes('archived'); + const s = String(status ?? '').trim().toLowerCase(); + return /\bmilestone\s+complete\b/.test(s) || /\barchived\b/.test(s); } /**