mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* feat(bin): gstack-decision-trail and gstack-worktree-audit, ported from pstack (GSTA-123)
Two MIT ports from Lauren Tan's pstack (licenses/MIT-pstack.txt, NOTICE.md):
a TSV decision trail with the header-once append, the formula guard, a start
row per run and a --run admission check; and a read-only worktree audit with
size, merge state, dirty, remote, PR, last chat and a suggested bucket.
Docs: docs/unattended.md (the decision trail and its end-of-run audit),
autoplan/DRIVER.md, docs/CONTRIBUTOR_REFERENCE.md, docs/standalone-binaries.md,
docs/troubleshooting.md, one line in landing-report.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(egress): reasoned scanner exemption for gstack-worktree-audit's base fetch
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review of the pstack ports
bin/gstack-worktree-audit: a worktree path with a space is read whole (sed
on the porcelain 'worktree ' line, not awk's second field); a registered
worktree whose directory is gone prints a 'prunable' row instead of being
skipped; 'safe' now requires a clean tree and either merged into the base
or a closed/merged PR with every commit pushed, so unpushed commits or
untracked files on a merged branch read 'review' (the port's rule called
them safe); fork PRs are dropped and an open PR wins over an older closed
one on a reused branch name; GIT_TERMINAL_PROMPT=0 on the base fetch.
bin/gstack-decision-trail: only a leading -h/--help is help, so a '-h'
cell is appended as data; a trail without a final newline, with CRLF rows
or with a repeated header line is appended to correctly; a run id is
limited to [A-Za-z0-9._:@/-] (exit 2) so a tab or quote cannot break the
start-row idempotence check.
Tests cover each case; the 'without gh' test resolves git from
'git --exec-path' instead of a PATH rebuilt from a tool list. Docs,
CHANGELOG and --help text describe the stricter buckets.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes, pass 2
bin/gstack-worktree-audit: a worktree whose own .git link is gone (git
marks it prunable, or its toplevel is another repository) prints a 'broken'
row in 'review' instead of describing the containing repository as clean
and safe; a locked entry whose directory is gone reads 'review', not
'prunable', since prune would not touch it; a clean tree names its
gitignored entries as 'clean,ignored:<n>' without changing the bucket; the
branch name is read from refs/heads/ by hand so a tag of the same name
cannot hide the PR; porcelain blocks are read with IFS=tab so paths with
leading or trailing spaces survive; a failed git status reads 'unknown',
never 'clean'; the base fetch also sets GIT_ASKPASS=true and ssh BatchMode.
bin/gstack-decision-trail: a file whose first line is not the header (a
blank file, rows written by hand) gets the header appended, and a UTF-8
BOM before the header no longer turns it into a data row.
Tests: the fake gh records its argv and runs the audit's own --jq over
canned JSON with a fork PR when jq is installed; row counts are asserted
before the every() checks; each test starts from the same gh and closed
worktree; the snapshot includes the worktree registry; new fixtures for
locked, broken, tag-shadowed and ignored-only worktrees, a blank trail and
a BOM. Docs and CHANGELOG describe the new DIRTY values and buckets.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes, pass 3
bin/gstack-decision-trail: hasHeader looks for a header line anywhere in
the file, so a blank or hand-written trail gets exactly one header rather
than one per append. bin/gstack-worktree-audit: the broken-worktree check
uses rev-parse --show-cdup and the presence of /.git instead of
comparing two spellings of the same path, which differ on Git for Windows.
Test: the blank-trail case appends twice and asserts a single header.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: adversarial review, pass 5 (rule-23 grant, one finding)
bin/gstack-worktree-audit: git status runs with --untracked-files=normal
--ignore-submodules=none, so a user's status.showUntrackedFiles=no config
cannot hide untracked files and let a worktree holding them read clean and
safe. Test: the fresh worktree under that config via GIT_CONFIG_* still
reads scratch:1 / review (red before the fix).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: documentation audit corrections for the pstack ports
docs/standalone-binaries.md, docs/CONTRIBUTOR_REFERENCE.md,
docs/troubleshooting.md: the audit's safe condition is stated in full
(clean and merged, or PR closed/merged with every commit pushed), the
DIRTY list names missing, broken and unknown, start is a reserved phase
name, and 'read-only' names the base fetch as the one write --no-fetch
skips. CHANGELOG.md says the same (the audit's one decision item).
Audit id gsta123-docs-20261026T221657Z, status updated, no blockers.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* Stamp 1.91.268.0 for #3494 (train slot 1, base d9b0d600)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
112 lines
7.1 KiB
TypeScript
112 lines
7.1 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 at or under 162 lines (160 until the decision
|
|
* trail paragraph joined the coordinator contract section) 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 162 lines and no harness vocabulary', () => {
|
|
expect(doc.split('\n').length).toBeLessThanOrEqual(162);
|
|
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);
|
|
});
|