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>
395 lines
15 KiB
JavaScript
395 lines
15 KiB
JavaScript
'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}`,
|
|
);
|
|
});
|
|
}
|
|
});
|