fix(3347): surface auto-build state via graphifyStatus; trim planner edits to free size budget

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) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-15 10:46:20 -04:00
parent b0fd732579
commit dd4f75376a
6 changed files with 140 additions and 31 deletions

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
# Planner — Graphify Auto-Update Awareness
# Graphify Auto-Update — Status Surfacing
> Loaded by `gsd-planner` and `gsd-phase-researcher` inside the `<step name="load_graph_context">` 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 `<step name="load_graph_context">` 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.

View File

@@ -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));

View File

@@ -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)) {