feat(3347): opt-in auto-update of knowledge graph after main HEAD advances

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) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-15 10:10:27 -04:00
parent bea62862d3
commit dacc23137a
13 changed files with 834 additions and 4 deletions

View File

@@ -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.

View File

@@ -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. 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): Query the graph for each major capability in the phase scope (2-3 queries per D-05, discovery-focused):
```bash ```bash

View File

@@ -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. 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): Query the graph for phase-relevant dependency context (single query per D-06):
```bash ```bash

View File

@@ -6828,7 +6828,7 @@ function uninstall(isGlobal, runtime = 'claude') {
// 4. Remove GSD hooks // 4. Remove GSD hooks
const hooksDir = path.join(targetDir, 'hooks'); const hooksDir = path.join(targetDir, 'hooks');
if (fs.existsSync(hooksDir)) { 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; let hookCount = 0;
for (const hook of gsdHooks) { for (const hook of gsdHooks) {
const hookPath = path.join(hooksDir, hook); const hookPath = path.join(hooksDir, hook);
@@ -8619,7 +8619,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
if (verifyInstalled(hooksDest, 'hooks')) { if (verifyInstalled(hooksDest, 'hooks')) {
console.log(` ${green}✓${reset} Installed hooks (bundled)`); console.log(` ${green}✓${reset} Installed hooks (bundled)`);
// Warn if expected community .sh hooks are missing (non-fatal) // 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) { for (const sh of expectedShHooks) {
if (!fs.existsSync(path.join(hooksDest, sh))) { if (!fs.existsSync(path.join(hooksDest, sh))) {
console.warn(` ${yellow}⚠${reset} Missing expected hook: ${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)`); 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) // Configure session state orientation hook (opt-in)
const sessionStateCommand = isGlobal const sessionStateCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-session-state.sh', hookOpts) ? buildHookCommand(targetDir, 'gsd-session-state.sh', hookOpts)

View File

@@ -326,6 +326,7 @@
"gsd-check-update-worker.js", "gsd-check-update-worker.js",
"gsd-check-update.js", "gsd-check-update.js",
"gsd-context-monitor.js", "gsd-context-monitor.js",
"gsd-graphify-update.sh",
"gsd-phase-boundary.sh", "gsd-phase-boundary.sh",
"gsd-prompt-guard.js", "gsd-prompt-guard.js",
"gsd-read-guard.js", "gsd-read-guard.js",

View File

@@ -434,7 +434,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
--- ---
## Hooks (12 shipped) ## Hooks (13 shipped)
Full listing: `hooks/`. Full listing: `hooks/`.
@@ -452,6 +452,7 @@ Full listing: `hooks/`.
| `gsd-session-state.sh` | `PostToolUse` | Session-state tracking for shell-based runtimes | | `gsd-session-state.sh` | `PostToolUse` | Session-state tracking for shell-based runtimes |
| `gsd-validate-commit.sh` | `PostToolUse` | Commit validation for conventional-commit enforcement | | `gsd-validate-commit.sh` | `PostToolUse` | Commit validation for conventional-commit enforcement |
| `gsd-phase-boundary.sh` | `PostToolUse` | Phase-boundary detection for workflow transitions | | `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) |
--- ---

View File

@@ -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) - `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) - `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.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`) - `model_profile` — which model each agent uses (default: `balanced`)
- `git.branching_strategy` — branching approach (default: `"none"`) - `git.branching_strategy` — branching approach (default: `"none"`)
- `workflow.use_worktrees` — whether parallel executor agents run in worktree isolation (default: `true`) - `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: "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." } { 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 "enabled": true/false
}, },
"graphify": { "graphify": {
"enabled": true/false "enabled": true/false,
"auto_update": true/false
}, },
"git": { "git": {
"branching_strategy": "none" | "phase" | "milestone", "branching_strategy": "none" | "phase" | "milestone",

152
hooks/gsd-graphify-update.sh Executable file
View File

@@ -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 >/dev/null 2>&1 &
disown
) &
disown
exit 0

View File

@@ -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 <STATUS_FILE> <LOCK_FILE> <HEAD_SHA> <MS_START> <GRAPHIFY_BIN>
#
# 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

View File

@@ -68,5 +68,8 @@
"ship": { "ship": {
"pr_body_sections": [] "pr_body_sections": []
}, },
"graphify": {
"auto_update": false
},
"agent_skills": {} "agent_skills": {}
} }

View File

@@ -90,6 +90,7 @@
"intel.enabled", "intel.enabled",
"graphify.enabled", "graphify.enabled",
"graphify.build_timeout", "graphify.build_timeout",
"graphify.auto_update",
"claude_md_path", "claude_md_path",
"claude_md_assembly.mode", "claude_md_assembly.mode",
"runtime", "runtime",

View File

@@ -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',
);
});
});

View File

@@ -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 "<html></html>" > 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}`,
);
});
}
});