Files
msd-core/hooks/gsd-graphify-update.sh
Tom Boucher dacc23137a 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>
2026-05-15 11:59:43 -04:00

153 lines
4.9 KiB
Bash
Executable File

#!/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