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:
6
.changeset/3347-graphify-auto-update-hook.md
Normal file
6
.changeset/3347-graphify-auto-update-hook.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
152
hooks/gsd-graphify-update.sh
Executable 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
|
||||||
65
hooks/lib/gsd-graphify-rebuild.sh
Executable file
65
hooks/lib/gsd-graphify-rebuild.sh
Executable 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
|
||||||
@@ -68,5 +68,8 @@
|
|||||||
"ship": {
|
"ship": {
|
||||||
"pr_body_sections": []
|
"pr_body_sections": []
|
||||||
},
|
},
|
||||||
|
"graphify": {
|
||||||
|
"auto_update": false
|
||||||
|
},
|
||||||
"agent_skills": {}
|
"agent_skills": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
149
tests/feat-3347-graphify-auto-update-config.test.cjs
Normal file
149
tests/feat-3347-graphify-auto-update-config.test.cjs
Normal 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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
394
tests/feat-3347-graphify-auto-update-hook.test.cjs
Normal file
394
tests/feat-3347-graphify-auto-update-hook.test.cjs
Normal 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}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user