From dd4f75376a3fe26632f5895caf92214f1b44d295 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 15 May 2026 10:46:20 -0400 Subject: [PATCH] fix(3347): surface auto-build state via graphifyStatus; trim planner edits to free size budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agents/gsd-planner.md was 49,316 chars after the initial PR; the planner-decomposition <48K test was passing on main at 49,150 chars (just under the 49152 limit). My addition pushed it over. Restructure: instead of teaching the planner agent to read .last-build-status.json directly, fold the auto-build state into graphifyStatus()'s existing `stale: true` signal. The planner's existing rule ("if stale: true, treat as approximate") fires correctly for failed and in-flight auto-builds — no new planner-side prompt content needed. The full state is exposed under `last_build_auto_update` for callers that want exit_code / duration_ms / commit-sha context. - get-shit-done/bin/lib/graphify.cjs: graphifyStatus() reads .planning/graphs/.last-build-status.json; OR-folds status in {failed, running} into the existing stale signal; exposes last_build_auto_update field - agents/gsd-planner.md: revert the auto-update awareness paragraph (49,524 → 49,150) - agents/gsd-phase-researcher.md: revert the parallel paragraph for consistency - get-shit-done/references/planner-graphify-auto-update.md: rewrite to document the graphifyStatus seam instead of planner-side prompt instructions - tests/feat-3347-graphify-auto-update-config.test.cjs: 4 new graphifyStatus tests pinning the failed/running/ok/missing matrix - tests/feat-3347-graphify-auto-update-hook.test.cjs: bump per-spawn timeout 5s → 30s and wait-deadline 5s → 15s to absorb cold-start latency under parallel-test-file load (full suite runs many *.test.cjs concurrently) Co-Authored-By: Claude Opus 4.7 (1M context) --- agents/gsd-phase-researcher.md | 2 - agents/gsd-planner.md | 2 - get-shit-done/bin/lib/graphify.cjs | 17 +++- .../planner-graphify-auto-update.md | 55 +++++++----- ...-3347-graphify-auto-update-config.test.cjs | 89 +++++++++++++++++++ ...at-3347-graphify-auto-update-hook.test.cjs | 6 +- 6 files changed, 140 insertions(+), 31 deletions(-) diff --git a/agents/gsd-phase-researcher.md b/agents/gsd-phase-researcher.md index 17e6349fd..a4482938d 100644 --- a/agents/gsd-phase-researcher.md +++ b/agents/gsd-phase-researcher.md @@ -637,8 +637,6 @@ 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 `.planning/graphs/.last-build-status.json` exists, also load @get-shit-done/references/planner-graphify-auto-update.md — it covers how to surface the most recent auto-build state (running / failed / stale `head_at_build`). Opt-in via `graphify.auto_update` (default false, #3347); silent if the file is absent. - Query the graph for each major capability in the phase scope (2-3 queries per D-05, discovery-focused): ```bash diff --git a/agents/gsd-planner.md b/agents/gsd-planner.md index 7a36c4d3d..b0d6d5701 100644 --- a/agents/gsd-planner.md +++ b/agents/gsd-planner.md @@ -892,8 +892,6 @@ 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 `.planning/graphs/.last-build-status.json` exists, load @get-shit-done/references/planner-graphify-auto-update.md for auto-build state surfacing (opt-in, #3347). - Query the graph for phase-relevant dependency context (single query per D-06): ```bash diff --git a/get-shit-done/bin/lib/graphify.cjs b/get-shit-done/bin/lib/graphify.cjs index 7dc6ecd1c..600adc67b 100644 --- a/get-shit-done/bin/lib/graphify.cjs +++ b/get-shit-done/bin/lib/graphify.cjs @@ -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, }; } diff --git a/get-shit-done/references/planner-graphify-auto-update.md b/get-shit-done/references/planner-graphify-auto-update.md index e7c3167fc..9eff93169 100644 --- a/get-shit-done/references/planner-graphify-auto-update.md +++ b/get-shit-done/references/planner-graphify-auto-update.md @@ -1,12 +1,12 @@ -# Planner — Graphify Auto-Update Awareness +# Graphify Auto-Update — Status Surfacing -> Loaded by `gsd-planner` and `gsd-phase-researcher` inside the `` block, after the existing `graphify status` staleness check. Surfaces the most recent auto-update state from `.planning/graphs/.last-build-status.json`, the file written by the bundled `hooks/gsd-graphify-update.sh` PostToolUse hook (opt-in via `graphify.auto_update`, default `false` — issue #3347). +> 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 (one `/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. +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 hook writes a status file synchronously before detaching and rewrites it on completion. This reference instructs the planner to read that file and surface the state inline with the existing staleness note. +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 @@ -25,34 +25,43 @@ When `graphify.auto_update: true`, the hook writes a status file synchronously b 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`). -## Read the file +## How the planner surfaces it (zero new prompt content) -```bash -test -f .planning/graphs/.last-build-status.json && cat .planning/graphs/.last-build-status.json +`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, +}; ``` -If the file is absent, the operator either hasn't opted in to `graphify.auto_update` or hasn't yet performed a HEAD-advancing git op since enabling it. The annotation below is a no-op in that case. +The planner and researcher already run `node ... graphify status` inside their `` blocks and already have the rule: -## Format the annotation +> If the status response has `stale: true`, note for later: "Graph is `{age_hours}h` old — treat semantic relationships as approximate." -Combine the status with the current `HEAD` sha when relevant. The first matching case wins: +That rule now fires correctly in three additional cases: -| Status | `head_at_build` vs current HEAD | Annotation | -|--------|----------------------------------|------------| -| `running` | (any) | "Graph auto-rebuild in flight (started `{ts}`); treat semantic relationships as approximate until rebuild completes." | -| `failed` | (any) | "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." | -| `ok` | matches | (silent — graph is current at the current HEAD) | -| `ok` | differs | "Graph last rebuilt at `{ts}` for commit `{head_at_build[:7]}`; current HEAD has advanced — treat semantic relationships as approximate." | -| (file missing) | n/a | (silent — fall back to the existing `stale: true` annotation only) | +| 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. | -Get the current HEAD with `git rev-parse HEAD`. +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). -## Interaction with the existing staleness note +## Why this design -If both the existing `stale: true` mtime check AND the auto-update annotation are non-silent, present them on the same line, ordered: auto-update state first, mtime staleness second. Example: - -> "Graph auto-rebuild FAILED at 2026-05-15T14:02:23Z (exit 1); the planning context below is from the prior build. Run `/gsd:graphify build` to retry manually. (Existing graph is 36h old — treat semantic relationships as approximate.)" +- **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, and every annotation above is a no-op. The existing `stale: true` annotation continues to be the only signal. +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. diff --git a/tests/feat-3347-graphify-auto-update-config.test.cjs b/tests/feat-3347-graphify-auto-update-config.test.cjs index 32b3378bd..5f7a9d8d8 100644 --- a/tests/feat-3347-graphify-auto-update-config.test.cjs +++ b/tests/feat-3347-graphify-auto-update-config.test.cjs @@ -30,6 +30,40 @@ 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( @@ -126,6 +160,61 @@ describe('#3347 — config-set graphify.auto_update round-trips', () => { ); }); + 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)); diff --git a/tests/feat-3347-graphify-auto-update-hook.test.cjs b/tests/feat-3347-graphify-auto-update-hook.test.cjs index 1848dc33d..c4eda8948 100644 --- a/tests/feat-3347-graphify-auto-update-hook.test.cjs +++ b/tests/feat-3347-graphify-auto-update-hook.test.cjs @@ -94,7 +94,7 @@ function runHook(tmpDir, toolPayload, { env = {}, pathPrepend = '' } = {}) { ...env, }, encoding: 'utf8', - timeout: 5000, + timeout: 30000, }); } @@ -256,7 +256,7 @@ describe('#3347 hook — dispatch path (all gates pass)', () => { // 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; + const deadline = Date.now() + 15000; let status; while (Date.now() < deadline) { if (fs.existsSync(statusPath)) { @@ -285,7 +285,7 @@ describe('#3347 hook — dispatch path (all gates pass)', () => { ); const statusPath = path.join(tmpDir, '.planning/graphs/.last-build-status.json'); - const deadline = Date.now() + 5000; + const deadline = Date.now() + 15000; let status; while (Date.now() < deadline) { if (fs.existsSync(statusPath)) {