From dacc23137add0a5f303f3c89bd3effca27dba9e3 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 15 May 2026 10:10:27 -0400 Subject: [PATCH] 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}`, + ); + }); + } +});