Files
gstack/test/autoplan-driver.test.ts
T
garrytanandcapy-ai[bot] 8eba8b322e feat(autoplan): durable checkpoint and resume --from; Capy checklist (Lane B: B12)
With GSTACK_AUTOPLAN_CHECKPOINT_DIR set, every command that leaves a run at
rest (gate_pending, amend_pending, complete, incomplete) publishes
<dir>/<run>/: checkpoint.json listing every file's sha256, the run under run/,
the owner's plan file under plan-input/ when it lives outside the run, and
restore.md as the human index. Publication swaps in a fully written sibling.
Checkpointing at every rest state, not only gate_pending, keeps accepted
decisions from rolling back on restore.

`gstack-autoplan resume --out <new dir> --from <checkpoint>` checks
containment (no absolute or .. paths, regular files only) and every hash
before publishing the run with one rename. It restores a missing plan file
into the run, journals `restored` with both hosts, and takes the lock.
CHECKPOINT_INVALID and CHECKPOINT_TARGET_EXISTS each get a result-codes row
and a troubleshooting anchor. Journal and revise staging paths are now
run-relative, so a moved run recovers.

DRIVER.md and the Capy skill's autoplan section get a short Capy checklist:
always drive gstack-autoplan, never present a hand review as an autoplan run,
relay every gate page with rec as the default, pass --owner-reply, and point
the checkpoint at the private project-user Drive folder.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-15 00:38:19 +00:00

111 lines
7.0 KiB
TypeScript

/**
* Field wave item 13: autoplan/DRIVER.md is the compact, host-neutral entry a
* parent agent reads to drive /autoplan through `gstack-autoplan`. It is
* generated from autoplan/DRIVER.md.tmpl by resolvers that read the code
* (phase order, scope flags, run files, RESULT FORMAT, result codes, the
* coordinator contract), stays under 160 lines with no harness vocabulary,
* is in the generator's normal, out-dir and dry-run inventories, and is
* found from an installed root through its recorded `.source-path`.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { FILES } from '../lib/autoplan-state';
import { PHASES, RESULT_FORMAT } from '../lib/autoplan-prompts';
import { SCOPE_FLAG_TOKENS } from '../lib/autoplan-scope';
import { COORDINATOR_CONTRACT } from '../lib/coordinator-contract';
import { parseReply, type GateList } from '../lib/gate-list';
import { resolveSourceRoot } from '../lib/source-root';
import { AUTOPLAN_DRIVER_COMMANDS, DRIVER_CODES, GATE_REPLY_FORMS } from '../scripts/resolvers/autoplan-driver';
import { runGeneration } from '../scripts/gen-skill-docs';
import { capyHost } from './helpers/capy-host';
import { AUTOPLAN, ROOT, SMALL_PLAN, env } from './helpers/autoplan-run-fixture';
const DRIVER = path.join(ROOT, 'autoplan', 'DRIVER.md');
const doc = fs.readFileSync(DRIVER, 'utf8');
const host = capyHost({ verifiedBun: '1.4.2' });
afterAll(() => host.cleanup());
const hostEnv = () => env({ ...host.env, PATH: host.launchPath, GSTACK_STATE_ROOT: host.stateRoot, GSTACK_HOME: host.stateRoot });
describe('autoplan/DRIVER.md content comes from the code', () => {
test('phase order, scope flags, run files, RESULT FORMAT, result codes and the coordinator contract are rendered', () => {
expect(doc).toContain(`Order: ${PHASES.join(' → ')}.`);
for (const flag of SCOPE_FLAG_TOKENS) expect(doc).toContain(`\`${flag}\``);
for (const file of Object.values(FILES)) expect(doc).toContain(`\`${file}\``);
expect(doc).toContain(RESULT_FORMAT('<phase>', '<snapshot sha256>', '<result file>').replace(/^\n---\n/, '').trimEnd());
for (const code of DRIVER_CODES) expect(doc).toMatch(new RegExp(`^- \`${code}\`: \\S`, 'm'));
expect(doc).toContain(COORDINATOR_CONTRACT);
for (const c of AUTOPLAN_DRIVER_COMMANDS) expect(doc).toContain(`- \`${c.cmd}\``);
for (const f of GATE_REPLY_FORMS) expect(doc).toContain(`- \`${f.reply}\`: `);
});
test('the plan-specified commands and the amendment interlock are present', () => {
for (const token of ['submit --out <dir> --phase <p> --voice native|outside --record', '--attest "<reason>"', '--unavailable|--failed',
'revise --out <dir> --plan <plan v2> --incorporates <decision ids>', '--withdraw <decision id>', '[--scaffold]', '`--no-dx`', '`as-is`', '`rec`', 'render <dir>/gate.json --all', '--settle <decision id|d4>', '--owner-reply', '--from <checkpoint>', 'PLAN_AMEND_PENDING', 'APPLY:']) {
expect(doc).toContain(token);
}
});
test('at most 160 lines and no harness vocabulary', () => {
expect(doc.split('\n').length).toBeLessThanOrEqual(160);
for (const term of ['PreToolUse', 'PostToolUse', 'SessionStart', 'AskUserQuestion', 'Skill tool']) expect(doc).not.toContain(term);
});
test('every flag it names is in gstack-autoplan --help, and the gate forms parse', () => {
const help = spawnSync('bun', [AUTOPLAN, '--help'], { cwd: ROOT, env: hostEnv(), encoding: 'utf8', timeout: 30_000 }).stdout;
const commands = AUTOPLAN_DRIVER_COMMANDS.map(c => c.cmd).join(' ');
for (const flag of new Set(commands.match(/--[a-z][a-z-]*/g)!)) {
expect(help.includes(flag), flag).toBe(true);
}
const gate: GateList = { schema_version: 1, run: 'r', gate_rev: 1, items: ['d3', 'uc1'].map(id => ({ id, title: id, recommended: 'a', kind: 'auto' as const, options: [{ key: 'a', text: 'keep' }, { key: 'b', text: 'change' }] })) };
for (const f of GATE_REPLY_FORMS) expect(parseReply(f.reply, gate, 1).valid, f.reply).toBe(true);
expect(parseReply('yes', gate, 1).valid).toBe(false);
});
});
describe('where a parent finds it', () => {
test('gstack-autoplan --help prints DRIVER: <root>/autoplan/DRIVER.md', () => {
const r = spawnSync('bun', [AUTOPLAN, '--help'], { cwd: ROOT, env: hostEnv(), encoding: 'utf8', timeout: 30_000 });
expect(r.stdout.split('\n')).toContain(`DRIVER: ${DRIVER}`);
});
test('the first next prints the DRIVER line once', () => {
const dir = path.join(host.base, 'first-next');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'plan.md'), SMALL_PLAN);
const out = path.join(dir, 'run');
const first = spawnSync('bun', [AUTOPLAN, 'next', '--out', out, '--plan', path.join(dir, 'plan.md'), '--no-ui'], { cwd: ROOT, env: hostEnv(), encoding: 'utf8', timeout: 120_000 });
expect(first.status, first.stderr).toBe(0);
expect(first.stdout.split('\n').filter(l => l.startsWith('DRIVER: '))).toEqual([`DRIVER: ${DRIVER}`]);
const again = spawnSync('bun', [AUTOPLAN, 'next', '--out', out], { cwd: ROOT, env: hostEnv(), encoding: 'utf8', timeout: 120_000 });
expect(again.stdout).not.toContain('DRIVER: ');
});
test('an installed copy root resolves DRIVER.md through its recorded .source-path; a stale record falls back to the root', () => {
const root = path.join(host.base, 'install-root');
fs.mkdirSync(path.join(root, 'bin'), { recursive: true });
fs.cpSync(path.join(ROOT, 'lib'), path.join(root, 'lib'), { recursive: true });
fs.copyFileSync(AUTOPLAN, path.join(root, 'bin', 'gstack-autoplan'));
fs.copyFileSync(path.join(ROOT, 'bin', 'gstack-autoplan-snapshot.ts'), path.join(root, 'bin', 'gstack-autoplan-snapshot.ts'));
fs.writeFileSync(path.join(root, '.source-path'), `${ROOT}\n`);
const help = spawnSync('bun', [path.join(root, 'bin', 'gstack-autoplan'), '--help'], { cwd: host.base, env: hostEnv(), encoding: 'utf8', timeout: 30_000 });
expect(help.stdout.split('\n')).toContain(`DRIVER: ${DRIVER}`);
expect(resolveSourceRoot(root)).toBe(ROOT);
fs.writeFileSync(path.join(root, '.source-path'), `${path.join(host.base, 'gone')}\n`);
expect(resolveSourceRoot(root)).toBe(root);
});
});
describe('generator inventory', () => {
test('normal, out-dir and dry-run generation all carry autoplan/DRIVER.md, and the committed file is fresh', async () => {
const outDir = path.join(host.base, 'render');
const written = await runGeneration({ host: 'claude', outputRoot: outDir, contentLinkRoot: null });
expect(written.artifacts).toContainEqual({ relativePath: 'autoplan/DRIVER.md', kind: 'asset', host: 'claude' });
expect(fs.readFileSync(path.join(outDir, 'autoplan', 'DRIVER.md'), 'utf8')).toBe(doc);
const dry = await runGeneration({ host: 'claude', dryRun: true });
expect(dry.artifacts).toContainEqual({ relativePath: 'autoplan/DRIVER.md', kind: 'asset', host: 'claude' });
expect(dry.diagnostics.filter(d => d.relativePath === 'autoplan/DRIVER.md')).toEqual([]);
}, 120_000);
});