Merge pull request #3557 from gsd-build/feat/3347-auto-update-knowledge-graph-after-main-h
feat(3347): opt-in auto-update of knowledge graph after main HEAD advances
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: 3557
|
||||
---
|
||||
|
||||
**Opt-in: auto-rebuild knowledge graph after main HEAD advances** — new config key `graphify.auto_update` (default `false`) and bundled PostToolUse hook `hooks/gsd-graphify-update.sh` keep the `.planning/graphs/graph.json` consumed by `gsd-planner` and `gsd-phase-researcher` current without manual `/gsd:graphify build` runs. When both `graphify.enabled` and `graphify.auto_update` are `true`, the hook fires after Bash tool calls matching HEAD-advancing git ops (`commit`, `merge`, `pull`, `rebase --continue`, `cherry-pick`) on the default branch, writes a synchronous `running` status to `.planning/graphs/.last-build-status.json`, then dispatches `graphify update .` in a detached subprocess that updates the status file to `ok` (with `duration_ms` + `head_at_build`) or `failed` (with `exit_code`). The planner and researcher's `load_graph_context` steps now surface the auto-build state alongside the existing staleness annotation — including the must-have failure-surface case from the issue review (`"auto-rebuild FAILED at {ts}; context is from the prior build"`). PID-locked against concurrent rebuilds (stale-PID tolerant via `kill -0`), CI-aware (`$CI` env suppresses), and bails silently if `graphify` is not on `PATH` or the current branch is not the default. `/gsd:settings` adds a "Graph auto-update" question gated on Graphify being enabled. Closes #3347.
|
||||
@@ -6828,7 +6828,7 @@ function uninstall(isGlobal, runtime = 'claude') {
|
||||
// 4. Remove GSD hooks
|
||||
const hooksDir = path.join(targetDir, 'hooks');
|
||||
if (fs.existsSync(hooksDir)) {
|
||||
const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-read-guard.js', 'gsd-read-injection-scanner.js', 'gsd-update-banner.js', 'gsd-workflow-guard.js', 'gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh'];
|
||||
const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-read-guard.js', 'gsd-read-injection-scanner.js', 'gsd-update-banner.js', 'gsd-workflow-guard.js', 'gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh', 'gsd-graphify-update.sh'];
|
||||
let hookCount = 0;
|
||||
for (const hook of gsdHooks) {
|
||||
const hookPath = path.join(hooksDir, hook);
|
||||
@@ -8619,7 +8619,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
if (verifyInstalled(hooksDest, 'hooks')) {
|
||||
console.log(` ${green}✓${reset} Installed hooks (bundled)`);
|
||||
// Warn if expected community .sh hooks are missing (non-fatal)
|
||||
const expectedShHooks = ['gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh'];
|
||||
const expectedShHooks = ['gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh', 'gsd-graphify-update.sh'];
|
||||
for (const sh of expectedShHooks) {
|
||||
if (!fs.existsSync(path.join(hooksDest, sh))) {
|
||||
console.warn(` ${yellow}⚠${reset} Missing expected hook: ${sh}`);
|
||||
@@ -9414,6 +9414,35 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — Bash executable path unavailable (#3393)`);
|
||||
}
|
||||
|
||||
// Configure graphify auto-update hook (opt-in via graphify.auto_update; default false, #3347).
|
||||
// PostToolUse Bash matcher — fires after git commit/merge/pull/rebase --continue/cherry-pick
|
||||
// on the default branch, dispatches `graphify update .` in a detached subprocess. No-op unless
|
||||
// .planning/config.json has BOTH graphify.enabled=true AND graphify.auto_update=true.
|
||||
const graphifyUpdateCommand = isGlobal
|
||||
? buildHookCommand(targetDir, 'gsd-graphify-update.sh', hookOpts)
|
||||
: localShellCmd('gsd-graphify-update.sh');
|
||||
const hasGraphifyUpdateHook = settings.hooks[postToolEvent].some(entry =>
|
||||
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-graphify-update'))
|
||||
);
|
||||
const graphifyUpdateFile = path.join(targetDir, 'hooks', 'gsd-graphify-update.sh');
|
||||
if (!hasGraphifyUpdateHook && fs.existsSync(graphifyUpdateFile) && graphifyUpdateCommand) {
|
||||
settings.hooks[postToolEvent].push({
|
||||
matcher: 'Bash',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: graphifyUpdateCommand,
|
||||
timeout: 5
|
||||
}
|
||||
]
|
||||
});
|
||||
console.log(` ${green}✓${reset} Configured graphify auto-update hook (opt-in via graphify.auto_update)`);
|
||||
} else if (!hasGraphifyUpdateHook && !fs.existsSync(graphifyUpdateFile)) {
|
||||
console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — gsd-graphify-update.sh not found at target`);
|
||||
} else if (!hasGraphifyUpdateHook && !graphifyUpdateCommand) {
|
||||
console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — Bash executable path unavailable (#3393)`);
|
||||
}
|
||||
|
||||
// Configure session state orientation hook (opt-in)
|
||||
const sessionStateCommand = isGlobal
|
||||
? buildHookCommand(targetDir, 'gsd-session-state.sh', hookOpts)
|
||||
|
||||
@@ -464,6 +464,7 @@ Toggle optional capabilities via the `features.*` config namespace. Feature flag
|
||||
|---------|------|---------|-------------|
|
||||
| `graphify.enabled` | boolean | `false` | Enable the project knowledge graph. When `true`, `/gsd-graphify` builds and queries a graph in `.planning/graphs/`. Added in v1.36 |
|
||||
| `graphify.build_timeout` | number (seconds) | `300` | Maximum seconds allowed for a `/gsd-graphify build` run before it aborts. Added in v1.36 |
|
||||
| `graphify.auto_update` | boolean | `false` | **Opt-in (issue #3347).** When `true` (and `graphify.enabled` is also `true`), the bundled PostToolUse hook `hooks/gsd-graphify-update.sh` auto-rebuilds the project knowledge graph in a detached background process after `git commit/merge/pull/rebase --continue/cherry-pick` on the default branch (`git.base_branch` override, else `main`/`master`/`trunk`). Hook returns instantly; the rebuild updates `.planning/graphs/{graph.json,graph.html,GRAPH_REPORT.md}` and writes `.planning/graphs/.last-build-status.json` (`{ts, status: "running"\|"ok"\|"failed", exit_code, duration_ms, head_at_build}`). PID-locked, CI-aware (`$CI` env suppresses), bails silently if `graphify` is not on `PATH`. Default `false` so existing behaviour is unchanged after upgrade. |
|
||||
|
||||
#### Multi-developer setup
|
||||
|
||||
|
||||
@@ -224,6 +224,7 @@
|
||||
"planner-antipatterns.md",
|
||||
"planner-chunked.md",
|
||||
"planner-gap-closure.md",
|
||||
"planner-graphify-auto-update.md",
|
||||
"planner-human-verify-mode.md",
|
||||
"planner-mvp-mode.md",
|
||||
"planner-reviews.md",
|
||||
@@ -326,6 +327,7 @@
|
||||
"gsd-check-update-worker.js",
|
||||
"gsd-check-update.js",
|
||||
"gsd-context-monitor.js",
|
||||
"gsd-graphify-update.sh",
|
||||
"gsd-phase-boundary.sh",
|
||||
"gsd-prompt-guard.js",
|
||||
"gsd-read-guard.js",
|
||||
|
||||
@@ -262,7 +262,7 @@ Full roster at `get-shit-done/workflows/*.md`. Workflows are thin orchestrators
|
||||
|
||||
---
|
||||
|
||||
## References (60 shipped)
|
||||
## References (61 shipped)
|
||||
|
||||
Full roster at `get-shit-done/references/*.md`. References are shared knowledge documents that workflows and agents `@-reference`. The groupings below match [`docs/ARCHITECTURE.md`](ARCHITECTURE.md#references-get-shit-donereferencesmd) — core, workflow, thinking-model clusters, and the modular planner decomposition.
|
||||
|
||||
@@ -352,11 +352,12 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
|
||||
| `planner-source-audit.md` | Planner source-audit and authority-limit rules. |
|
||||
| `planner-mvp-mode.md` | Vertical-slice planning rules for MVP mode. |
|
||||
| `planner-human-verify-mode.md` | Rules for `workflow.human_verify_mode = end-of-phase`: suppress `checkpoint:human-verify` task emission and route deferred items via `<verify><human-check>`. |
|
||||
| `planner-graphify-auto-update.md` | How `load_graph_context` surfaces `.last-build-status.json` auto-update state (running / failed / stale head) alongside the existing staleness annotation. Opt-in via `graphify.auto_update` (#3347). |
|
||||
| `skeleton-template.md` | SKELETON.md template emitted for new-project Walking Skeleton (Phase 1 + `--mvp`). |
|
||||
| `user-story-template.md` | User story format for MVP planning — "As a / I want to / So that" structured fields. |
|
||||
| `spidr-splitting.md` | SPIDR splitting decomposition rules for handling large user stories in MVP mode. |
|
||||
|
||||
> **Subdirectory:** `get-shit-done/references/few-shot-examples/` contains additional few-shot examples (`plan-checker.md`, `verifier.md`) that are referenced from specific agents. These are not counted in the 60 top-level references.
|
||||
> **Subdirectory:** `get-shit-done/references/few-shot-examples/` contains additional few-shot examples (`plan-checker.md`, `verifier.md`) that are referenced from specific agents. These are not counted in the 61 top-level references.
|
||||
|
||||
---
|
||||
|
||||
@@ -434,7 +435,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
|
||||
|
||||
---
|
||||
|
||||
## Hooks (12 shipped)
|
||||
## Hooks (13 shipped)
|
||||
|
||||
Full listing: `hooks/`.
|
||||
|
||||
@@ -452,6 +453,7 @@ Full listing: `hooks/`.
|
||||
| `gsd-session-state.sh` | `PostToolUse` | Session-state tracking for shell-based runtimes |
|
||||
| `gsd-validate-commit.sh` | `PostToolUse` | Commit validation for conventional-commit enforcement |
|
||||
| `gsd-phase-boundary.sh` | `PostToolUse` | Phase-boundary detection for workflow transitions |
|
||||
| `gsd-graphify-update.sh` | `PostToolUse` | Auto-rebuild knowledge graph after main HEAD advances (opt-in, default off — #3347) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -411,18 +411,33 @@ function graphifyStatus(cwd) {
|
||||
if (commitsBehind !== null) commitStale = commitsBehind > 0;
|
||||
}
|
||||
|
||||
// Auto-update status (#3347). Read .last-build-status.json written by the
|
||||
// hooks/gsd-graphify-update.sh PostToolUse hook (opt-in via graphify.auto_update,
|
||||
// default false). When the most recent auto-build is "failed" or still "running",
|
||||
// fold that into the existing `stale: true` signal so consumers (gsd-planner,
|
||||
// gsd-phase-researcher) surface the standard "treat semantic relationships as
|
||||
// approximate" annotation without per-consumer prompt changes. The full state
|
||||
// (running/failed/exit_code/duration_ms/head_at_build) is exposed under
|
||||
// `last_build` for callers that want richer context.
|
||||
const statusPath = path.join(planningDir, 'graphs', '.last-build-status.json');
|
||||
const lastBuildAutoUpdate = fs.existsSync(statusPath) ? safeReadJson(statusPath) : null;
|
||||
const autoUpdateStale =
|
||||
lastBuildAutoUpdate &&
|
||||
(lastBuildAutoUpdate.status === 'failed' || lastBuildAutoUpdate.status === 'running');
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
last_build: stat.mtime.toISOString(),
|
||||
node_count: (graph.nodes || []).length,
|
||||
edge_count: (graph.edges || graph.links || []).length,
|
||||
hyperedge_count: (graph.hyperedges || []).length,
|
||||
stale: age > STALE_MS,
|
||||
stale: age > STALE_MS || Boolean(autoUpdateStale),
|
||||
age_hours: Math.round(age / (60 * 60 * 1000)),
|
||||
built_at_commit: builtAt ? builtAt.slice(0, 7) : null,
|
||||
current_commit: head ? head.slice(0, 7) : null,
|
||||
commits_behind: commitsBehind,
|
||||
commit_stale: commitStale,
|
||||
last_build_auto_update: lastBuildAutoUpdate || null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ function toPosixPath(p) {
|
||||
}
|
||||
|
||||
function isCompletedInventory(status) {
|
||||
const s = String(status ?? '').toLowerCase();
|
||||
return s.includes('milestone complete') || s.includes('archived');
|
||||
const s = String(status ?? '').trim().toLowerCase();
|
||||
return /\bmilestone\s+complete\b/.test(s) || /\barchived\b/.test(s);
|
||||
}
|
||||
|
||||
function buildWorkstreamInventory(inputs) {
|
||||
|
||||
67
get-shit-done/references/planner-graphify-auto-update.md
Normal file
67
get-shit-done/references/planner-graphify-auto-update.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Graphify Auto-Update — Status Surfacing
|
||||
|
||||
> Documents how `gsd-planner` and `gsd-phase-researcher` surface the opt-in graphify auto-update state (issue #3347). The status surface lives inside `graphifyStatus()` in `get-shit-done/bin/lib/graphify.cjs`; no planner-side prompt changes are required.
|
||||
|
||||
## Why this exists
|
||||
|
||||
The graph at `.planning/graphs/graph.json` is consumed automatically (every `gsd-planner` and `gsd-phase-researcher` step) but produced manually (`/gsd:graphify build` per session at best). Without auto-update, the producer-consumer gap silently widens with every commit. The existing `stale: true` annotation tells the consumer the mtime is old; it cannot tell the consumer whether the auto-build hook has been running, just failed, or is in flight.
|
||||
|
||||
When `graphify.auto_update: true`, the bundled `hooks/gsd-graphify-update.sh` PostToolUse hook fires after HEAD-advancing git operations on the default branch and dispatches `graphify update .` in a detached subprocess. The hook writes a status file synchronously before detach; the detached process rewrites it on completion.
|
||||
|
||||
## The status file
|
||||
|
||||
`.planning/graphs/.last-build-status.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"ts": "2026-05-15T14:02:23Z",
|
||||
"status": "running" | "ok" | "failed",
|
||||
"exit_code": null | <int>,
|
||||
"duration_ms": null | <int>,
|
||||
"head_at_build": "<commit-sha>",
|
||||
"graphify_version": null | "<version>"
|
||||
}
|
||||
```
|
||||
|
||||
The hook writes `status: "running"` synchronously **before** detach, so the next planner invocation can see the in-flight signal even if `graphify update .` has not finished. The detached `hooks/lib/gsd-graphify-rebuild.sh` rewrites the file to `ok` or `failed` on completion (with `exit_code` and `duration_ms`).
|
||||
|
||||
## How the planner surfaces it (zero new prompt content)
|
||||
|
||||
`graphifyStatus()` in `get-shit-done/bin/lib/graphify.cjs` reads `.last-build-status.json` and folds the `running` / `failed` states into the existing `stale: true` signal:
|
||||
|
||||
```javascript
|
||||
const autoUpdateStale =
|
||||
lastBuildAutoUpdate &&
|
||||
(lastBuildAutoUpdate.status === 'failed' || lastBuildAutoUpdate.status === 'running');
|
||||
|
||||
return {
|
||||
...
|
||||
stale: age > STALE_MS || Boolean(autoUpdateStale),
|
||||
...
|
||||
last_build_auto_update: lastBuildAutoUpdate || null,
|
||||
};
|
||||
```
|
||||
|
||||
The planner and researcher already run `node ... graphify status` inside their `<step name="load_graph_context">` blocks and already have the rule:
|
||||
|
||||
> If the status response has `stale: true`, note for later: "Graph is `{age_hours}h` old — treat semantic relationships as approximate."
|
||||
|
||||
That rule now fires correctly in three additional cases:
|
||||
|
||||
| Trigger | What user sees |
|
||||
|---------|----------------|
|
||||
| Auto-build status = `failed` | Existing "treat as approximate" note fires (because `stale: true`). The full `last_build_auto_update` object is in the JSON for callers that want exit-code / duration / commit-sha context. |
|
||||
| Auto-build status = `running` | Same — the next planner invocation knows the graph is mid-rebuild and treats it as approximate until the detached process completes. |
|
||||
| Auto-build status = `ok` AND mtime < 24h | Annotation is silent — the graph is fresh and the most recent auto-build succeeded. |
|
||||
|
||||
The file-missing case is silent (the operator either has not opted in or has not yet triggered a HEAD-advancing git op since enabling).
|
||||
|
||||
## Why this design
|
||||
|
||||
- **No planner-side prompt changes.** Folding into `stale: true` reuses the existing rule, which means no new content in `agents/gsd-planner.md` (which is already at the `< 48K` decomposition limit per `DEFECT.AGENT-FILE-SIZE-CAP-BREACH`).
|
||||
- **Tests catch regressions on the seam.** `tests/feat-3347-graphify-auto-update-config.test.cjs` pins `graphifyStatus` behavior for status=`failed` / `running` / `ok` / file-missing.
|
||||
- **Backwards compatible.** Callers that don't read `last_build_auto_update` see the same shape as before, with `stale` reflecting both mtime AND auto-build state. No consumer breakage.
|
||||
|
||||
## Opt-in reminder
|
||||
|
||||
The auto-update mechanism is opt-in (`graphify.auto_update: false` by default per issue #3347). Users who haven't opted in will never produce this file. `graphifyStatus()` returns `last_build_auto_update: null` and falls back to the mtime-only `stale` rule.
|
||||
@@ -51,6 +51,7 @@ Parse current values (default to `true` if not present):
|
||||
- `commit_docs` — whether `.planning/` files are committed to git (default: true if absent)
|
||||
- `intel.enabled` — enable queryable codebase intelligence (/gsd:map-codebase --query) (default: false if absent)
|
||||
- `graphify.enabled` — enable project knowledge graph (/gsd:graphify) (default: false if absent)
|
||||
- `graphify.auto_update` — opt-in: auto-rebuild graph after main HEAD advances (#3347) (default: `false`)
|
||||
- `model_profile` — which model each agent uses (default: `balanced`)
|
||||
- `git.branching_strategy` — branching approach (default: `"none"`)
|
||||
- `workflow.use_worktrees` — whether parallel executor agents run in worktree isolation (default: `true`)
|
||||
@@ -90,7 +91,7 @@ Verifier, TDD Mode, Code Review, Code Review Depth _(conditional — only when c
|
||||
Commit Docs, Skip Discuss, Worktrees
|
||||
|
||||
### Features
|
||||
Intel, Graphify
|
||||
Intel, Graphify, Graph auto-update _(conditional — only when graphify=on)_
|
||||
|
||||
### Model & Pipeline
|
||||
Model Profile, Auto-Advance, Branching
|
||||
@@ -100,6 +101,8 @@ Context Warnings, Research Qs
|
||||
|
||||
**Conditional visibility — code_review_depth:** This question is shown only when the user's chosen `code_review` value (after they answer that question, or the pre-selected value if unchanged) is on. If `code_review` is off, omit the `code_review_depth` question from the AskUserQuestion block and preserve the existing `workflow.code_review_depth` value in config (do not overwrite). Implementation: ask the Model + Planning + Execution-up-to-Code-Review questions first; if `code_review=on`, include `code_review_depth` in the same batch; otherwise skip it. Conceptually this is a one-branch split on the `code_review` answer.
|
||||
|
||||
**Conditional visibility — graphify.auto_update:** This question is shown only when the user's chosen `graphify.enabled` value is on. If `graphify.enabled` is off, omit the `graphify.auto_update` question and preserve the existing `graphify.auto_update` value in config (do not overwrite). Implementation: ask Graphify first; only ask Graph auto-update when Graphify is enabled.
|
||||
|
||||
```
|
||||
AskUserQuestion([
|
||||
{
|
||||
@@ -317,6 +320,15 @@ AskUserQuestion([
|
||||
{ label: "No (Recommended)", description: "Skip knowledge graph. Use when dependency graphs are not needed." },
|
||||
{ label: "Yes", description: "Enable /gsd:graphify commands. Builds and queries a project knowledge graph." }
|
||||
]
|
||||
},
|
||||
{
|
||||
question: "Auto-rebuild graph after main HEAD advances? (only effective if Graphify is enabled — #3347)",
|
||||
header: "Graph auto-update",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: "No (Recommended)", description: "Manual /gsd:graphify build only. Conservative default — opt in if you want fresh context on every /gsd:quick or /gsd:plan-phase." },
|
||||
{ label: "Yes", description: "Auto-rebuild the graph in a detached background process after git commit/merge/pull/rebase --continue/cherry-pick on the default branch. Hook returns instantly; rebuild runs out-of-band. No-op if Graphify is disabled." }
|
||||
]
|
||||
}
|
||||
])
|
||||
```
|
||||
@@ -354,7 +366,8 @@ Merge new settings into existing config.json:
|
||||
"enabled": true/false
|
||||
},
|
||||
"graphify": {
|
||||
"enabled": true/false
|
||||
"enabled": true/false,
|
||||
"auto_update": true/false
|
||||
},
|
||||
"git": {
|
||||
"branching_strategy": "none" | "phase" | "milestone",
|
||||
@@ -426,7 +439,8 @@ Write `~/.gsd/defaults.json` with:
|
||||
"enabled": <current>
|
||||
},
|
||||
"graphify": {
|
||||
"enabled": <current>
|
||||
"enabled": <current>,
|
||||
"auto_update": <current>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -50,6 +50,7 @@ const MANAGED_HOOKS = [
|
||||
'gsd-check-update-worker.js',
|
||||
'gsd-check-update.js',
|
||||
'gsd-context-monitor.js',
|
||||
'gsd-graphify-update.sh',
|
||||
'gsd-phase-boundary.sh',
|
||||
'gsd-prompt-guard.js',
|
||||
'gsd-read-guard.js',
|
||||
|
||||
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": {
|
||||
"pr_body_sections": []
|
||||
},
|
||||
"graphify": {
|
||||
"auto_update": false
|
||||
},
|
||||
"agent_skills": {}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
"intel.enabled",
|
||||
"graphify.enabled",
|
||||
"graphify.build_timeout",
|
||||
"graphify.auto_update",
|
||||
"claude_md_path",
|
||||
"claude_md_assembly.mode",
|
||||
"runtime",
|
||||
|
||||
@@ -231,6 +231,10 @@ describe('isCompletedInventory', () => {
|
||||
expect(isCompletedInventory('unknown')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for "unarchived" (word-boundary guard)', () => {
|
||||
expect(isCompletedInventory('unarchived')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty string', () => {
|
||||
expect(isCompletedInventory('')).toBe(false);
|
||||
});
|
||||
|
||||
@@ -86,11 +86,11 @@ function toPosixPath(p: string): string {
|
||||
|
||||
/**
|
||||
* Pure classifier: returns true if the given status string indicates a
|
||||
* completed or archived workstream (case-insensitive substring match).
|
||||
* completed or archived workstream (case-insensitive, boundary-aware match).
|
||||
*/
|
||||
export function isCompletedInventory(status: string): boolean {
|
||||
const s = String(status ?? '').toLowerCase();
|
||||
return s.includes('milestone complete') || s.includes('archived');
|
||||
const s = String(status ?? '').trim().toLowerCase();
|
||||
return /\bmilestone\s+complete\b/.test(s) || /\barchived\b/.test(s);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,14 +78,16 @@ const INTERNAL_COMPONENT_SLUGS = new Set([
|
||||
'tools',
|
||||
|
||||
// Hook scripts — internal runtime hooks, not user-invocable slash commands.
|
||||
// hooks/gsd-statusline.js — session statusline hook
|
||||
// hooks/gsd-context-monitor.js — context-window monitor hook
|
||||
// hooks/gsd-update-banner.js — update-available banner hook
|
||||
// hooks/gsd-statusline.js — session statusline hook
|
||||
// hooks/gsd-context-monitor.js — context-window monitor hook
|
||||
// hooks/gsd-update-banner.js — update-available banner hook
|
||||
// hooks/gsd-graphify-update.sh — knowledge-graph auto-update PostToolUse hook (#3347)
|
||||
// These appear in docs as file-path references (e.g. "gsd-statusline.js reads
|
||||
// the cache"), not as command invocations.
|
||||
'statusline',
|
||||
'context-monitor',
|
||||
'update-banner',
|
||||
'graphify-update',
|
||||
|
||||
// gsd-update-check.json — background update-check CACHE FILE, not a slash command.
|
||||
// ARCHITECTURE.md references "~/.cache/gsd/gsd-update-check.json" as a path;
|
||||
|
||||
238
tests/feat-3347-graphify-auto-update-config.test.cjs
Normal file
238
tests/feat-3347-graphify-auto-update-config.test.cjs
Normal file
@@ -0,0 +1,238 @@
|
||||
'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');
|
||||
|
||||
const fsx = require('node:fs');
|
||||
const pathx = require('node:path');
|
||||
const cpx = require('node:child_process');
|
||||
const osx = require('node:os');
|
||||
const { graphifyStatus } = require('../get-shit-done/bin/lib/graphify.cjs');
|
||||
|
||||
function makeStatusProject(autoUpdate) {
|
||||
const tmpDir = fsx.mkdtempSync(pathx.join(osx.tmpdir(), 'gsd-3347-status-'));
|
||||
cpx.execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: tmpDir });
|
||||
cpx.execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir });
|
||||
cpx.execFileSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir });
|
||||
fsx.writeFileSync(pathx.join(tmpDir, 'README.md'), '# t\n');
|
||||
cpx.execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
||||
cpx.execFileSync('git', ['commit', '-qm', 'init'], { cwd: tmpDir });
|
||||
fsx.mkdirSync(pathx.join(tmpDir, '.planning/graphs'), { recursive: true });
|
||||
fsx.writeFileSync(
|
||||
pathx.join(tmpDir, '.planning/config.json'),
|
||||
JSON.stringify({ graphify: { enabled: true } }),
|
||||
);
|
||||
// Write a fresh (current-mtime) graph so age-based stale is false; only the
|
||||
// auto-update status field can set stale: true.
|
||||
fsx.writeFileSync(
|
||||
pathx.join(tmpDir, '.planning/graphs/graph.json'),
|
||||
JSON.stringify({ nodes: [], edges: [] }),
|
||||
);
|
||||
if (autoUpdate !== null) {
|
||||
fsx.writeFileSync(
|
||||
pathx.join(tmpDir, '.planning/graphs/.last-build-status.json'),
|
||||
JSON.stringify(autoUpdate),
|
||||
);
|
||||
}
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
describe('#3347 — graphify.auto_update is a registered config key', () => {
|
||||
test('VALID_CONFIG_KEYS contains graphify.auto_update', () => {
|
||||
assert.ok(
|
||||
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('graphifyStatus folds auto-update status=failed into stale=true', (t) => {
|
||||
const tmpDir = makeStatusProject({
|
||||
ts: '2026-05-15T12:00:00Z',
|
||||
status: 'failed',
|
||||
exit_code: 1,
|
||||
duration_ms: 1234,
|
||||
head_at_build: 'abcdef0',
|
||||
graphify_version: null,
|
||||
});
|
||||
t.after(() => fsx.rmSync(tmpDir, { recursive: true, force: true }));
|
||||
const s = graphifyStatus(tmpDir);
|
||||
assert.strictEqual(s.stale, true, 'auto-build failure must set stale=true');
|
||||
assert.ok(s.last_build_auto_update, 'last_build_auto_update must be exposed');
|
||||
assert.strictEqual(s.last_build_auto_update.status, 'failed');
|
||||
assert.strictEqual(s.last_build_auto_update.exit_code, 1);
|
||||
});
|
||||
|
||||
test('graphifyStatus folds auto-update status=running into stale=true', (t) => {
|
||||
const tmpDir = makeStatusProject({
|
||||
ts: '2026-05-15T12:00:00Z',
|
||||
status: 'running',
|
||||
exit_code: null,
|
||||
duration_ms: null,
|
||||
head_at_build: 'abcdef0',
|
||||
graphify_version: null,
|
||||
});
|
||||
t.after(() => fsx.rmSync(tmpDir, { recursive: true, force: true }));
|
||||
const s = graphifyStatus(tmpDir);
|
||||
assert.strictEqual(s.stale, true, 'auto-build in-flight must set stale=true');
|
||||
assert.strictEqual(s.last_build_auto_update.status, 'running');
|
||||
});
|
||||
|
||||
test('graphifyStatus leaves stale alone when auto-update status=ok and graph is fresh', (t) => {
|
||||
const tmpDir = makeStatusProject({
|
||||
ts: '2026-05-15T12:00:00Z',
|
||||
status: 'ok',
|
||||
exit_code: 0,
|
||||
duration_ms: 1234,
|
||||
head_at_build: 'abcdef0',
|
||||
graphify_version: null,
|
||||
});
|
||||
t.after(() => fsx.rmSync(tmpDir, { recursive: true, force: true }));
|
||||
const s = graphifyStatus(tmpDir);
|
||||
assert.strictEqual(s.stale, false, 'fresh graph + ok auto-build => not stale');
|
||||
assert.strictEqual(s.last_build_auto_update.status, 'ok');
|
||||
});
|
||||
|
||||
test('graphifyStatus exposes last_build_auto_update: null when status file absent', (t) => {
|
||||
const tmpDir = makeStatusProject(null);
|
||||
t.after(() => fsx.rmSync(tmpDir, { recursive: true, force: true }));
|
||||
const s = graphifyStatus(tmpDir);
|
||||
assert.strictEqual(s.last_build_auto_update, null);
|
||||
assert.strictEqual(s.stale, false, 'no status file => stale follows mtime only');
|
||||
});
|
||||
|
||||
test('config-set graphify.auto_update does not perturb sibling graphify.enabled', (t) => {
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
|
||||
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: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
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() + 15000;
|
||||
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() + 15000;
|
||||
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