feat: crew PRs carry their brief's Plan line; ship-queue ready and land check it (GST-1751) (#4118)

* feat: crew PRs carry their brief's Plan line; ship-queue ready and land check it (GST-1751)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat: /ship finish and train ready/land check the Plan line (GST-1751)

finish --plan resolves the brief's Plan line before stage 1; train ready,
base-red ready, train land and stamp land read and write the READY note's
plan record like direct ready and land. Review fixes: grant and exception
rows never pass as plan approvals, render --plan stays short when redacted,
no explicit changelog-only exemption, off mode still records a resolved line.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Review-Finding: p1/red-team#2 reader=red-team
Review-Finding: p1/red-team#3 reader=red-team
Review-Finding: p1/specialist-batched#1 reader=testing
Review-Finding: bin/gstack-approvals:56:functional-contract reader=qa
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: cover the plan check at ready, train ready, land-direct, finish and the policy key (GST-1751)

Review-Finding: p1/specialist-batched#3 reader=testing
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: the Plan line stands for the gate's real state, and a broken store never throws (GST-1751)

A row id from an autoplan run resolves to that run's newest gate revision; a
run id needs the plan approval answered and no decision left unanswered.
readStore skips malformed rows and unreadable files; with the key off the
check never throws or prints. Train land rechecks the Plan line before its
merge on every path. /ship's Step 19-C renders Approvals with --plan;
DRIVER.md back under its size limit.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: train land refuses a slot with no plan record under require (GST-1751)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Step 8 plan discovery (lib/ship-audit.ts and the plan-completion sections) skips the Plan record line, so a later Plan: <path> binding is still found; render --plan refuses a line it cannot parse. The review skill's pins drop by 4 bytes.

Review-Finding: p1/red-team#1 reader=red-team
Review-Finding: scripts/resolvers/plan-gates.ts:206:functional-contract reader=qa
Review-Finding: p1/red-team#3 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: changelog fragment passes the prose lint (GST-1751)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: captain.md describes the READY PLAN: line (GST-1751)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: plan discovery splits the Plan line on whitespace and skips record kinds in any case (GST-1751)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: lower the gstack-review size pins after the Step 8 binding shrank 4 bytes (GST-1751)

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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-07 09:53:38 +00:00
1 parent fd0e3390e9
commit 901f246d74
44 files changed
+870 -46

No files matched your search

+1
View File
@@ -227,6 +227,7 @@ describe('import --autoplan', () => {
const rows = importAutoplan(run, 'b', 'Garry');
if ('code' in rows) throw new Error(rows.message);
expect(rows.map(r => [r.by, r.words])).toEqual([['Garry, relayed by agent:GSTA-36', 'Do it'], ["agent:GSTA-36, its own decision (not the owner's words)", 'Accepted on the standing approval']]);
expect(rows.map(r => r.words_by ?? null)).toEqual([null, 'agent:GSTA-36']);
});
test('a default run id whose YYYYMMDDHHMMSS stamp passes Luhn still imports (capy-captain-flow flake)', () => {
+1 -1
View File
@@ -93,7 +93,7 @@ describe('/captain skill', () => {
for (const h of ['## Lane discipline', '## Plan before build', '## Contributions from outside the crew']) expect(doc).toContain(h);
expect(doc).toMatch(/## Plan before build[^]*?reports `APPROVED`[^]*?## Contributions from outside the crew/);
const brief = read('captain/templates/brief.md');
expect(brief).toContain('Plan: required | approved: <autoplan run dir, or its checkpoint dir> | exempt: <reason');
expect(brief).toContain('Plan: required | approved: <autoplan run id or approval row id> | exempt: <reason');
expect(brief).toContain('gstack-autoplan answer --out <run dir> --gate-rev <n> --reply <tokens>');
expect(brief).toMatch(/^1\. Plan[^]*?^2\. Build after the word[^]*?^3\. Ship[^]*?^Done when:/m);
expect(brief).toContain('gstack-approvals import --autoplan');
+1 -1
View File
@@ -660,7 +660,7 @@ Make bisectable commits; if already committed, continue to Step 16. Never create
it. Content changes during or after verification restart at
stage 1 and complete all five stages before Step 17. Content-preserving commits keep evidence valid.
**One call for stages 1–4:** `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --reviewed-base <mb> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>'`
**One call for stages 1–4:** `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --reviewed-base <mb> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>' [--plan "<Plan line>"]`
also makes Step 15's item-3 commit and runs the pregate (`--docs-rechecked`,
`--no-lanes-approved` record stage 2.2 and Step 5's approval).
+1 -1
View File
@@ -659,7 +659,7 @@ Make bisectable commits; if already committed, continue to Step 16. Never create
it. Content changes during or after verification restart at
stage 1 and complete all five stages before Step 17. Content-preserving commits keep evidence valid.
**One call for stages 1–4:** `~/.claude/skills/gstack/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --reviewed-base <mb> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>'`
**One call for stages 1–4:** `~/.claude/skills/gstack/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --reviewed-base <mb> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>' [--plan "<Plan line>"]`
also makes Step 15's item-3 commit and runs the pregate (`--docs-rechecked`,
`--no-lanes-approved` record stage 2.2 and Step 5's approval).
+1 -1
View File
@@ -662,7 +662,7 @@ Make bisectable commits; if already committed, continue to Step 16. Never create
it. Content changes during or after verification restart at
stage 1 and complete all five stages before Step 17. Content-preserving commits keep evidence valid.
**One call for stages 1–4:** `$GSTACK_ROOT/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --reviewed-base <mb> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>'`
**One call for stages 1–4:** `$GSTACK_ROOT/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --reviewed-base <mb> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>' [--plan "<Plan line>"]`
also makes Step 15's item-3 commit and runs the pregate (`--docs-rechecked`,
`--no-lanes-approved` record stage 2.2 and Step 5's approval).
+1 -1
View File
@@ -664,7 +664,7 @@ Make bisectable commits; if already committed, continue to Step 16. Never create
it. Content changes during or after verification restart at
stage 1 and complete all five stages before Step 17. Content-preserving commits keep evidence valid.
**One call for stages 1–4:** `$GSTACK_ROOT/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --reviewed-base <mb> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>'`
**One call for stages 1–4:** `$GSTACK_ROOT/bin/gstack-ship-release finish --base <base> --reviewed-tree <tree> --reviewed-base <mb> --docs-post <audit-id>-post.json --lane <label>=<Step 5 command>... [--build <cmd>]... --trailer '<trailer>' [--plan "<Plan line>"]`
also makes Step 15's item-3 commit and runs the pregate (`--docs-rechecked`,
`--no-lanes-approved` record stage 2.2 and Step 5's approval).
+9 -9
View File
@@ -6,52 +6,52 @@
"agy/gstack-plan-design-review": 109100,
"agy/gstack-plan-devex-review": 114928,
"agy/gstack-plan-eng-review": 143581,
"agy/gstack-review": 104752,
"agy/gstack-review": 104744,
"codex/gstack-autoplan": 108344,
"codex/gstack-plan-design-review": 110457,
"codex/gstack-plan-devex-review": 116436,
"codex/gstack-plan-eng-review": 144746,
"codex/gstack-review": 104275,
"codex/gstack-review": 104267,
"copilot/gstack-autoplan": 109044,
"copilot/gstack-plan-design-review": 109366,
"copilot/gstack-plan-devex-review": 114390,
"copilot/gstack-plan-eng-review": 143746,
"copilot/gstack-review": 104333,
"copilot/gstack-review": 104325,
"cursor/gstack-autoplan": 108276,
"cursor/gstack-plan-design-review": 107908,
"cursor/gstack-plan-devex-review": 113660,
"cursor/gstack-plan-eng-review": 142336,
"cursor/gstack-review": 122805,
"cursor/gstack-review": 122797,
"factory/gstack-autoplan": 110554,
"factory/gstack-plan-design-review": 111006,
"factory/gstack-plan-devex-review": 117023,
"factory/gstack-plan-eng-review": 145444,
"factory/gstack-review": 126578,
"factory/gstack-review": 126570,
"gbrain/gstack-autoplan": 108683,
"gbrain/gstack-plan-eng-review": 124963,
"gemini/gstack-autoplan": 108834,
"gemini/gstack-plan-design-review": 109194,
"gemini/gstack-plan-devex-review": 114218,
"gemini/gstack-plan-eng-review": 143579,
"gemini/gstack-review": 104155,
"gemini/gstack-review": 104147,
"hermes/gstack-autoplan": 108637,
"hermes/gstack-plan-eng-review": 124999,
"kiro/gstack-autoplan": 108050,
"kiro/gstack-plan-design-review": 107754,
"kiro/gstack-plan-devex-review": 113512,
"kiro/gstack-plan-eng-review": 142198,
"kiro/gstack-review": 122629,
"kiro/gstack-review": 122621,
"openclaw/gstack-autoplan": 108506,
"openclaw/gstack-plan-eng-review": 123478,
"opencode/gstack-autoplan": 109025,
"opencode/gstack-plan-design-review": 109222,
"opencode/gstack-plan-devex-review": 114268,
"opencode/gstack-plan-eng-review": 143605,
"opencode/gstack-review": 123427,
"opencode/gstack-review": 123419,
"slate/gstack-autoplan": 108163,
"slate/gstack-plan-design-review": 107831,
"slate/gstack-plan-devex-review": 113586,
"slate/gstack-plan-eng-review": 142267,
"slate/gstack-review": 122717
"slate/gstack-review": 122709
}
}
+5
View File
@@ -9,6 +9,7 @@ import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { EXIT } from '../lib/headless-artifacts';
import { BUMP_NEVER_KEY_VERSION, CHANGELOG_FRAGMENTS_KEY_VERSION, DEFAULT_POLICY, POLICY_KEYS, POLICY_KEY_INTRODUCED, REVIEW_SCOPE_KEY_VERSION, STAMP_PLACEHOLDERS_KEY_VERSION, globToRegExp, matchesAny, minimumReader, shipConsequences, validatePolicy } from '../lib/ship-policy';
import { PLAN_RECORD_KEY_VERSION } from '../lib/ship-policy-keys';
import { fillDue } from '../lib/ship-queue-train';
import { cmpVersion, parseVersion } from '../lib/version-source';
import { BASE_POLICY, cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
@@ -40,6 +41,10 @@ describe('validatePolicy (schema + containment, pure)', () => {
const earlier = REVIEW_SCOPE_KEY_VERSION.replace(/(\d+)\.0$/, (_, p) => `${Number(p) - 7}.0`);
expect(codes({ review_scope: 'all', min_gstack_version: earlier })).toEqual(['POLICY_INVALID:review_scope']);
expect(codes({ review_scope: 'frozen' })).toEqual([]);
// Value: protects=plan_record accepts only off|warn|require; fails_when=the enum guard is removed or loosened; why_new=no plan_record row; seam=none; no_claim=ready/land enforcement
expect(codes({ plan_record: 'strict' })).toContain('POLICY_INVALID:plan_record');
expect(codes({ plan_record: true })).toContain('POLICY_INVALID:plan_record');
for (const v of ['off', 'warn', 'require']) expect(codes({ plan_record: v, min_gstack_version: PLAN_RECORD_KEY_VERSION })).toEqual([]);
expect(codes([])).toEqual(['POLICY_INVALID:']);
});
+289
View File
@@ -0,0 +1,289 @@
/**
* The plan-record check (lib/plan-record.ts, GST-1751): the PR body's Plan line, resolved against every branch's
* approval rows, the exemptions docs/captain.md lists, the warn and require modes, and land's READY-note check.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { buildRow, locate, type ApprovalRow } from '../lib/approval-record';
import { exemptReason, finishPlan, landPlan, parsePlanLine, planMode, readyPlan, resolvePlan } from '../lib/plan-record';
import { planBinding } from '../lib/ship-audit';
import { loadReady, persist, repoIdentity } from '../lib/ship-queue-train-record';
const ROOT = path.resolve(import.meta.dir, '..');
const dirs: string[] = [];
const tmp = (prefix: string) => { const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); dirs.push(d); return d; };
afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); });
function fixture(files: Record<string, Array<Partial<ApprovalRow> & { ask: string; source?: string; outcome?: string; at?: string }>> = {}) {
const cwd = tmp('plan-repo-');
spawnSync('git', ['init', '-q', '-b', 'capy/feature'], { cwd, timeout: 30_000 });
const state = tmp('plan-state-');
const env = { GSTACK_STATE_ROOT: state, GSTACK_CAPY: '', GSTACK_APPROVALS_DIR: '', GSTACK_PLAN_RECORD: '' } as NodeJS.ProcessEnv;
const loc = locate({ cwd, env, branch: 'probe' });
if ('code' in loc) throw new Error(loc.message);
const dir = path.dirname(loc.file);
if (Object.keys(files).length) fs.mkdirSync(dir, { recursive: true });
const ids: Record<string, string> = {};
for (const [file, rows] of Object.entries(files)) {
const lines = rows.map(r => {
const built = buildRow({ ask: r.ask, words: r.words ?? 'do it', by: r.by ?? 'Garry (via GStack Minder)', scope: r.scope ?? 'the reviewed plan', outcome: r.outcome, source: r.source, pending: r.pending, date: '2026-11-07' }, file, new Date(r.at ?? '2026-11-07T00:00:00Z'));
if ('code' in built) throw new Error(built.message);
const row = r.words_by ? { ...built, words_by: r.words_by } : built;
ids[r.ask] = row.id;
return JSON.stringify(row);
});
fs.writeFileSync(path.join(dir, `${file}.jsonl`), lines.join('\n') + '\n');
}
return { cwd, env, ids };
}
describe('parsePlanLine', () => {
test('reads approved, exempt and required, tolerating spacing and bold, and ignores fenced text', () => {
expect(parsePlanLine('x\nPlan: approved: ap-20261107055041\n')).toEqual({ kind: 'approved', line: 'Plan: approved: ap-20261107055041', token: 'ap-20261107055041' });
expect(parsePlanLine('**Plan:** approved ~/.capy/work/run/ap')).toMatchObject({ kind: 'approved', token: '~/.capy/work/run/ap' });
expect(parsePlanLine('Plan: exempt: bug fix (R65)')).toMatchObject({ kind: 'exempt', reason: 'bug fix (R65)' });
expect(parsePlanLine('Plan: required')).toMatchObject({ kind: 'required' });
expect(parsePlanLine('```\nPlan: exempt: flaky\n```\nno line here')).toBeNull();
expect(parsePlanLine('| Plan: approved: x |')).toBeNull();
expect(parsePlanLine('Plan: /home/u/plan.md\nPlan: approved: ap-1')).toMatchObject({ kind: 'approved', token: 'ap-1' });
});
test('exemptions are the docs/captain.md list or crew:<rule>, with a trailing parenthetical ignored', () => {
expect(exemptReason('bug fix (R65)')).toBe('bug fix');
expect(exemptReason('Flaky')).toBe('flaky');
expect(exemptReason('crew: rule 31')).toBe('crew:rule 31');
expect(exemptReason('light plan')).toBeNull();
expect(exemptReason('feature')).toBeNull();
});
});
describe('planMode', () => {
test('base policy first, then the crew opt-in, then warn in a Capy session, else off', () => {
expect(planMode('require', { GSTACK_CAPY: '1' }).mode).toBe('require');
expect(planMode('off', { GSTACK_PLAN_RECORD: 'require' })).toEqual({ mode: 'require', source: 'GSTACK_PLAN_RECORD' });
expect(planMode('off', { GSTACK_CAPY: '1' })).toEqual({ mode: 'warn', source: 'Capy crew default' });
expect(planMode(undefined, {}).mode).toBe('off');
});
});
describe('resolvePlan', () => {
test('a run id resolves from another branch file, and the newest gate revision decides', () => {
const f = fixture({
main: [{ ask: 'Approve the plan A', source: 'autoplan:ap-1@gate_rev=1' }],
'other-lane': [{ ask: 'Approve the plan B rev1', source: 'autoplan:ap-2@gate_rev=1' }, { ask: 'Approve the plan B rev2', source: 'autoplan:ap-2@gate_rev=2', outcome: 'pending' }],
});
const a = resolvePlan(parsePlanLine('Plan: approved: ap-1'), f);
expect(a.ok && a.record).toMatchObject({ state: 'approved', ref: 'ap-1', row_id: f.ids['Approve the plan A'], resolver: 1 });
const b = resolvePlan(parsePlanLine('Plan: approved: ap-2'), f);
expect(!b.ok && b.failure.code).toBe('PLAN_NOT_APPROVED');
});
test("a gate answered in an agent's own words never counts, structured or legacy", () => {
const f = fixture({ x: [
{ ask: 'own words', source: 'autoplan:ap-3@gate_rev=1', words_by: 'agent:GSTA-1' },
{ ask: 'legacy', source: 'autoplan:ap-4@gate_rev=1', by: "agent:jam_x, its own decision (not the owner's words)" },
] });
for (const run of ['ap-3', 'ap-4']) {
const r = resolvePlan(parsePlanLine(`Plan: approved: ${run}`), f);
expect(!r.ok && r.failure.code).toBe('PLAN_NOT_APPROVED');
expect(!r.ok && r.failure.message).toContain("agent's own words");
}
});
test('a row id resolves an approved plan row, never a ruling or grant row', () => {
const f = fixture({ y: [{ ask: 'R41 A1: cut a skill' }, { ask: 'review_loop_grant abc for #1', scope: 'one fix-check' }, { ask: 'Grant for #2', scope: 'one review_loop_exception at abc' }] });
const ok = resolvePlan(parsePlanLine(`Plan: approved: ${f.ids['R41 A1: cut a skill']}`), f);
expect(ok.ok).toBe(true);
const grant = resolvePlan(parsePlanLine(`Plan: approved: ${f.ids['review_loop_grant abc for #1']}`), f);
expect(!grant.ok && grant.failure.code).toBe('PLAN_UNRESOLVED');
const exception = resolvePlan(parsePlanLine(`Plan: approved: ${f.ids['Grant for #2']}`), f);
expect(!exception.ok && exception.failure.code).toBe('PLAN_UNRESOLVED');
expect(resolvePlan(parsePlanLine('Plan: approved: a-000000000000'), f)).toMatchObject({ ok: false, failure: { code: 'PLAN_UNRESOLVED' } });
});
test('a run directory path resolves through its run.json', () => {
const f = fixture({ z: [{ ask: 'Approve the plan C', source: 'autoplan:ap-5@gate_rev=1' }] });
const run = tmp('plan-run-');
fs.writeFileSync(path.join(run, 'run.json'), JSON.stringify({ run: 'ap-5' }));
const r = resolvePlan(parsePlanLine(`Plan: approved: ${run}`), f);
expect(r.ok && r.record.ref).toBe('ap-5');
});
test('missing, required, unknown exemption, no store, and the changelog-only diff', () => {
const f = fixture({ z: [{ ask: 'something' }] });
expect(resolvePlan(null, f)).toMatchObject({ ok: false, failure: { code: 'PLAN_RECORD_MISSING' } });
expect(resolvePlan(parsePlanLine('Plan: required'), f)).toMatchObject({ ok: false, failure: { code: 'PLAN_NOT_APPROVED' } });
expect(resolvePlan(parsePlanLine('Plan: exempt: light plan'), f)).toMatchObject({ ok: false, failure: { code: 'PLAN_EXEMPT_UNKNOWN' } });
expect(resolvePlan(parsePlanLine('Plan: exempt: bug fix (R65)'), f)).toMatchObject({ ok: true, record: { state: 'exempt', reason: 'bug fix' } });
expect(resolvePlan(parsePlanLine('Plan: approved: ap-1'), fixture())).toMatchObject({ ok: false, failure: { code: 'PLAN_STORE_UNAVAILABLE' } });
expect(resolvePlan(null, { ...f, changed: ['changelog.d/a.md', 'changelog.d/b.md'] })).toMatchObject({ ok: true, record: { reason: 'changelog-only' } });
expect(resolvePlan(null, { ...f, changed: ['changelog.d/a.md', 'lib/x.ts'] })).toMatchObject({ ok: false, failure: { code: 'PLAN_RECORD_MISSING' } });
expect(resolvePlan(parsePlanLine('Plan: exempt: changelog-only'), { ...f, changed: ['lib/x.ts'] })).toMatchObject({ ok: false, failure: { code: 'PLAN_EXEMPT_UNKNOWN' } });
});
});
describe('ready and land', () => {
test('ready warns in warn mode, refuses in require mode, and records a resolved line', () => {
const f = fixture({ main: [{ ask: 'Approve the plan A', source: 'autoplan:ap-1@gate_rev=1' }] });
const base = { cwd: f.cwd, changed: ['lib/x.ts'] };
expect(readyPlan({ ...base, env: f.env, body: '', policyValue: 'off' })).toEqual({});
expect(readyPlan({ ...base, env: f.env, body: 'Plan: approved: ap-1', policyValue: 'off' })).toMatchObject({ record: { ref: 'ap-1' } });
expect(readyPlan({ ...base, env: f.env, body: 'Plan: approved: ap-1', policyValue: 'off' }).line).toBeUndefined();
const warn = readyPlan({ ...base, env: { ...f.env, GSTACK_CAPY: '1' }, body: '', policyValue: 'off' });
expect(warn.failure).toBeUndefined();
expect(warn.line).toContain('PLAN: warn');
expect(warn.line).toContain('PLAN_RECORD_MISSING');
const refused = readyPlan({ ...base, env: f.env, body: '', policyValue: 'require' });
expect(refused.failure?.code).toBe('PLAN_RECORD_MISSING');
const ok = readyPlan({ ...base, env: f.env, body: 'Plan: approved: ap-1', policyValue: 'require' });
expect(ok.record).toMatchObject({ line: 'Plan: approved: ap-1', state: 'approved', ref: 'ap-1' });
});
test('land needs the READY note to carry the PR body\'s current Plan line under require, and never reads the store', () => {
const record = { line: 'Plan: exempt: bug fix (R65)', state: 'exempt' as const, reason: 'bug fix', resolver: 1 };
expect(landPlan({ env: {}, policyValue: 'off', body: '' })).toEqual({});
expect(landPlan({ env: {}, policyValue: 'require', body: '' }).failure?.code).toBe('QUEUE_PLAN_MISMATCH');
expect(landPlan({ env: {}, policyValue: 'require', record, body: 'Plan: exempt: bug fix (R65)' })).toEqual({ line: 'PLAN: exempt bug fix (from the READY note)' });
expect(landPlan({ env: {}, policyValue: 'require', record, body: 'Plan: exempt: flaky' }).failure?.code).toBe('QUEUE_PLAN_MISMATCH');
const warn = landPlan({ env: { GSTACK_CAPY: '1' }, policyValue: 'off', body: '' });
expect(warn.failure).toBeUndefined();
expect(warn.line).toContain('PLAN: warn');
const frag = { line: 'Plan: exempt: changelog-only', state: 'exempt' as const, reason: 'changelog-only', resolver: 1 };
expect(landPlan({ env: {}, policyValue: 'require', record: frag, body: 'no line' }).failure).toBeUndefined();
});
});
describe('gstack-approvals plan-check and render --plan', () => {
test('plan-check prints PLAN: and exits 0, or names the refusal; render --plan prints the line ready reads', () => {
const f = fixture({ main: [{ ask: 'Approve the plan A', source: 'autoplan:ap-1@gate_rev=1' }] });
const run = (args: string[]) => {
const r = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-approvals'), ...args], { cwd: f.cwd, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...f.env } });
return { status: r.status, out: r.stdout ?? '', err: r.stderr ?? '' };
};
const ok = run(['plan-check', '--plan', 'approved: ap-1']);
expect(ok.status).toBe(0);
expect(ok.out).toContain('PLAN: approved ap-1');
const bad = run(['plan-check', '--plan', 'Plan: exempt: light plan']);
expect(bad.status).not.toBe(0);
expect(bad.err).toContain('PLAN_EXEMPT_UNKNOWN');
const body = run(['render', '--full', '--plan', 'Plan: approved: ap-1']).out;
expect(parsePlanLine(body)).toMatchObject({ kind: 'approved', token: 'ap-1' });
expect(body).toContain('Plan check: approved ap-1 row=');
expect(body).not.toContain('Approve the plan A');
});
test('the Plan record line is never Step 8\'s plan-file binding, and render --plan refuses a line it cannot parse', () => {
const f = fixture({ main: [{ ask: 'Approve the plan A', source: 'autoplan:ap-1@gate_rev=1' }] });
const run = (args: string[]) => spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-approvals'), ...args], { cwd: f.cwd, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...f.env } });
const body = `${run(['render', '--plan', 'Plan: approved: ap-1']).stdout}\nPlan: docs/designs/X.md\n`;
expect(planBinding(body)).toBe('docs/designs/X.md');
const sed = fs.readFileSync(path.join(ROOT, 'ship', 'sections', 'plan-completion.md'), 'utf8').split('\n').find(l => l.startsWith('_BOUND=$(gh pr view'))!;
const pipeline = sed.replace(/^_BOUND=\$\(gh pr view --json body -q \.body 2>\/dev\/null/, 'cat').replace(/\)$/, '');
expect(spawnSync('bash', ['-c', pipeline], { input: body, encoding: 'utf8', timeout: 30_000 }).stdout.trim()).toBe('docs/designs/X.md');
const typo = run(['render', '--plan', 'aproved: ap-1']);
expect(typo.status).not.toBe(0);
expect(typo.stdout).toBe('');
expect(typo.stderr).toContain('render --plan needs');
});
const bound = (body: string) => {
const sed = fs.readFileSync(path.join(ROOT, 'ship', 'sections', 'plan-completion.md'), 'utf8').split('\n').find(l => l.startsWith('_BOUND=$(gh pr view'))!;
const pipeline = sed.replace(/^_BOUND=\$\(gh pr view --json body -q \.body 2>\/dev\/null/, 'cat').replace(/\)$/, '');
return { ts: planBinding(body), sh: spawnSync('bash', ['-c', pipeline], { input: body, encoding: 'utf8', timeout: 30_000 }).stdout.trim() };
};
test('a plan file named like a record kind is a binding, not a record line', () => {
for (const file of ['approved-plan.md', 'required.md', 'docs/designs/exempt.md']) {
expect(parsePlanLine(`Plan: ${file}`)).toBeNull();
expect(bound(`Plan: approved: ap-1\nPlan: ${file}\n`)).toEqual({ ts: file, sh: file });
}
});
test('a tab-separated binding resolves without the tab, and record kinds are skipped in any case', () => {
expect(bound('Plan:\tdocs/designs/X.md\n')).toEqual({ ts: 'docs/designs/X.md', sh: 'docs/designs/X.md' });
expect(bound('Plan:\tAPPROVED:\tap-1\nPlan: Required\nPlan:\n\tdocs/designs/Y.md\nPlan:\tdocs/designs/X.md\n')).toEqual({ ts: 'docs/designs/X.md', sh: 'docs/designs/X.md' });
});
});
describe('/ship finish and the READY note', () => {
test('finish --plan refuses before any stage under require, and a READY note round-trips the record land reads', () => {
const f = fixture({ main: [{ ask: 'Approve the plan A', source: 'autoplan:ap-1@gate_rev=1' }] });
expect(finishPlan(f.cwd, 'main', undefined, f.env)).toEqual({});
expect(finishPlan(f.cwd, 'main', undefined, { ...f.env, GSTACK_PLAN_RECORD: 'require' }).failure?.code).toBe('PLAN_RECORD_MISSING');
const ok = finishPlan(f.cwd, 'main', 'approved: ap-1', { ...f.env, GSTACK_PLAN_RECORD: 'require' });
expect(ok.failure).toBeUndefined();
expect(ok.line).toContain('PLAN: approved ap-1');
const state = tmp('plan-notes-');
const prev = process.env.GSTACK_STATE_ROOT;
process.env.GSTACK_STATE_ROOT = state;
try {
const keys = repoIdentity(f.cwd);
const record = readyPlan({ cwd: f.cwd, env: f.env, body: 'Plan: approved: ap-1', policyValue: 'require', changed: ['lib/x.ts'] }).record!;
persist(f.cwd, [{ schema: 1, kind: 'ready', repo_key: keys.key, pr: '7', head: 'a'.repeat(40), base_sha: 'b'.repeat(40), ready_at: 'now', screen: { choice: 'no-screen', policy: null, nonce: null, run: null, merge_sha: null }, updated_at: 'now', plan: record }], { push: false });
const note = loadReady(keys.keys, '7', 'a'.repeat(40));
expect(note?.plan).toEqual(record);
expect(landPlan({ env: {}, policyValue: 'require', record: note?.plan, body: 'Plan: approved: ap-1' }).failure).toBeUndefined();
} finally {
if (prev === undefined) delete process.env.GSTACK_STATE_ROOT; else process.env.GSTACK_STATE_ROOT = prev;
}
});
});
describe('the gate state a Plan line stands for (review blockers, GST-1751)', () => {
test('a row id from an older gate revision does not outlive a rejected newer revision', () => {
const f = fixture({ main: [{ ask: 'Approve the plan C rev1', source: 'autoplan:ap-3@gate_rev=1' }, { ask: 'Approve the plan C rev2', source: 'autoplan:ap-3@gate_rev=2', outcome: 'rejected', at: '2026-11-07T02:00:00Z' }] });
const r = resolvePlan(parsePlanLine(`Plan: approved: ${f.ids['Approve the plan C rev1']}`), f);
expect(!r.ok && r.failure.code).toBe('PLAN_NOT_APPROVED');
expect(!r.ok && r.failure.message).toContain('rejected');
});
test('a run id needs the plan approval answered and no decision left unanswered, whichever answer event came last', () => {
const half = fixture({ main: [{ ask: 'Approve the plan D', source: 'autoplan:ap-4@gate_rev=1', pending: '1 decision(s) unanswered: P2' }] });
const h = resolvePlan(parsePlanLine('Plan: approved: ap-4'), half);
expect(!h.ok && h.failure.code).toBe('PLAN_NOT_APPROVED');
expect(!h.ok && h.failure.message).toContain('unanswered');
const done = fixture({ main: [
{ ask: 'Approve the plan D', source: 'autoplan:ap-4@gate_rev=1', pending: '1 decision(s) unanswered: P2' },
{ ask: 'Approve the plan D (P2 only)', source: 'autoplan:ap-4@gate_rev=1', outcome: 'pending', pending: 'the plan approval itself', at: '2026-11-07T03:00:00Z' },
] });
const d = resolvePlan(parsePlanLine('Plan: approved: ap-4'), done);
expect(d.ok && d.record).toMatchObject({ state: 'approved', ref: 'ap-4', row_id: done.ids['Approve the plan D'] });
});
});
describe('a broken approval store never throws, and changes nothing with the key off', () => {
function broken() {
const f = fixture({ main: [{ ask: 'Approve the plan A', source: 'autoplan:ap-1@gate_rev=1' }] });
const loc = locate({ cwd: f.cwd, env: f.env, branch: 'probe' });
if ('code' in loc) throw new Error(loc.message);
const dir = path.dirname(loc.file);
fs.appendFileSync(path.join(dir, 'main.jsonl'), JSON.stringify({ schema_version: 1, id: 'a-000000000000', ask: 'no source' }) + '\n{torn\n');
fs.mkdirSync(path.join(dir, 'unreadable.jsonl'));
return f;
}
test('key off: ready and finish give the same output as with no line at all, and nothing throws', () => {
const f = broken();
const body = 'Plan: approved: ap-1';
expect(readyPlan({ cwd: f.cwd, env: f.env, body: '', changed: ['lib/x.ts'] })).toEqual({});
const off = readyPlan({ cwd: f.cwd, env: f.env, body, changed: ['lib/x.ts'] });
expect(off.line).toBeUndefined();
expect(off.failure).toBeUndefined();
const fin = finishPlan(f.cwd, 'main', body, f.env);
expect(fin.line).toBeUndefined();
expect(fin.failure).toBeUndefined();
expect(landPlan({ env: f.env, body, record: undefined })).toEqual({});
});
test('warn and require read past the broken rows and still resolve; an unreadable store is a refusal, never a throw', () => {
const f = broken();
const warn = readyPlan({ cwd: f.cwd, env: { ...f.env, GSTACK_CAPY: '1' }, body: 'Plan: approved: ap-1', changed: ['lib/x.ts'] });
expect(warn.line).toContain('PLAN: approved ap-1');
const gone = fixture();
const req = readyPlan({ cwd: gone.cwd, env: { ...gone.env, GSTACK_PLAN_RECORD: 'require' }, body: 'Plan: approved: ap-1', changed: ['lib/x.ts'] });
expect(req.failure?.code).toBe('PLAN_STORE_UNAVAILABLE');
});
});
+33
View File
@@ -536,6 +536,39 @@ describe('land --pin: refusals (nothing merged)', () => {
expect(r.err).toContain('gstack-ship-queue ready --pr 16');
}, 90_000);
// Value: protects=under GSTACK_PLAN_RECORD=require land refuses a READY note with no plan or another line than the PR body, lands a matching one, and a merged rerun skips the check; fails_when=landPlan's wiring in landDirect is dropped or runs after the merge; why_new=plan-record.test.ts covers landPlan only; seam=none; no_claim=ready's resolution (the note is written directly)
test('QUEUE_PLAN_MISMATCH under GSTACK_PLAN_RECORD=require: no plan on the READY note or a stale line refuses and merges nothing; a matching note lands; a merged rerun skips the check', () => {
const fx = setup();
const pin = openPr(fx, '26');
const body = shippedBody('26', pin) + '\nPlan: exempt: bug fix\n';
fx.write(d => { d.prs['26'].body = body; });
const req = (extra: string[] = []) => runBin(fx.repo, 'gstack-ship-queue', ['land', '--pr', '26', '--pin', pin, '--base', 'main', ...extra], { ...fx.env, GSTACK_PLAN_RECORD: 'require' });
const bare = req();
refused(fx, bare, 'QUEUE_PLAN_MISMATCH');
expect(bare.err).toContain('carries no plan record');
const notePlan = (line: string) => {
const prior = process.env.GSTACK_STATE_ROOT;
process.env.GSTACK_STATE_ROOT = path.join(fx.repo.root, 'ready-state');
try {
const now = new Date().toISOString();
persist(fx.repo.work, [{ schema: 1, kind: 'ready', repo_key: repoIdentity(fx.repo.work).key, pr: '26', head: pin, base_sha: fx.repo.git('rev-parse', 'origin/main'), ready_at: now, screen: { choice: 'no-screen', policy: null, nonce: null, run: null, merge_sha: null }, updated_at: now, plan: { line, state: 'exempt', reason: line.replace('Plan: exempt: ', ''), resolver: 1 } } as any]);
} finally { if (prior === undefined) delete process.env.GSTACK_STATE_ROOT; else process.env.GSTACK_STATE_ROOT = prior; }
};
notePlan('Plan: exempt: docs only');
const stale = req();
refused(fx, stale, 'QUEUE_PLAN_MISMATCH');
expect(stale.err).toContain('but the PR body now says "Plan: exempt: bug fix"');
notePlan('Plan: exempt: bug fix');
const ok = req();
expect(ok.status).toBe(EXIT.ok);
expect(ok.out).toContain('PLAN: exempt bug fix (from the READY note)');
fx.write(d => { d.prs['26'].body = shippedBody('26', pin); });
const again = req();
expect(again.status).toBe(EXIT.ok);
expect(again.out).toContain('already merged');
expect(again.out).not.toContain('QUEUE_PLAN_MISMATCH');
}, 120_000);
test('QUEUE_LAND_CHECKS: a red check, a pending check, and a required check GitHub never scheduled', () => {
const fx = setup();
const pin = openPr(fx, '17');
+14
View File
@@ -65,6 +65,20 @@ describe('stamped trains: build and land', () => {
void h2;
}, 300_000);
// Value: protects=under GSTACK_PLAN_RECORD=require train land refuses a slot whose READY receipt has no plan record before pushing a stamp; fails_when=slotPlan's wiring in landUpdate is dropped or runs after the push; why_new=ship-queue-train tests cover train ready only, not train land; seam=none; no_claim=landDirect/stamp-commit slotPlan paths and a matching-plan land (train-built receipts carry no plan)
test('under GSTACK_PLAN_RECORD=require train land refuses a READY receipt with no plan record and pushes nothing', () => {
const { fx, nonce } = stampedTrain();
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const before = fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1');
const refused = queue(fx, ['train', 'land', '--train', nonce, '--pr', '1', '--no-wait'], { GSTACK_PLAN_RECORD: 'require' });
expect(refused.status).toBe(EXIT.refused);
expect(refused.err).toContain('(QUEUE_PLAN_MISMATCH)');
expect(refused.out).toContain('PLAN: refused the READY note carries no plan record');
expect(refused.out).not.toMatch(/^PUSHED: /m);
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(before);
expect(fx.read().prs['1']).not.toMatchObject({ state: 'MERGED' });
}, 300_000);
test('a foreign push to the PR branch is never overwritten; a merge outside the train freezes the queue', () => {
const { fx, nonce } = stampedTrain();
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
+39
View File
@@ -104,6 +104,29 @@ describe('the policy gate', () => {
expect(ok.out).toMatch(new RegExp(`^READY_RECEIPT: .*ship-queue/ready/[^/]+/1-${head}\\.json \\(notes pushed to origin refs/notes/gstack-trains\\)$`, 'm'));
expect(fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-trains')).not.toBe('');
}, 120_000);
// Value: protects=under plan_record require ready refuses a PR body without a Plan line and records no READY receipt; a resolvable line lands in the receipt; fails_when=readyPlan's refusal or the receipt's plan field is dropped; why_new=plan-record.test.ts covers the helper only, not ready's wiring; seam=none; no_claim=approved-row resolution against a real store (exempt line used)
test('under GSTACK_PLAN_RECORD=require ready refuses a body with no Plan line and writes no receipt; an exempt line is recorded on the receipt', () => {
const fx = make({ ...BASE_POLICY, changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' });
const head = openPr(fx, '1');
fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; });
const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim();
const pregate = path.join(fx.repo.root, 'pregate.json');
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
const req = { GSTACK_PLAN_RECORD: 'require' };
const missing = queue(fx, ['ready', '--pr', '1', '--pregate', pregate], req);
expect(missing.status).toBe(EXIT.refused);
expect(line(missing.out, 'PLAN:')).toContain('(PLAN_RECORD_MISSING)');
expect(missing.err).toContain('PLAN_RECORD_MISSING');
expect(missing.out).not.toContain('READY_RECEIPT:');
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready'))).toBe(false);
setPrBody(fx, '1', shippedBody('1', head) + '\nPlan: exempt: bug fix\n');
const ok = queue(fx, ['ready', '--pr', '1', '--pregate', pregate], req);
expect(ok.status).toBe(EXIT.ok);
expect(line(ok.out, 'PLAN:')).toBe('PLAN: exempt bug fix');
const receipt = path.join(fx.state, 'ship-queue', 'ready', repoKey(fx), `1-${head}.json`);
expect(JSON.parse(fs.readFileSync(receipt, 'utf8')).plan).toMatchObject({ line: 'Plan: exempt: bug fix', state: 'exempt', reason: 'bug fix' });
}, 120_000);
});
describe('ready under a train policy: the screen choice', () => {
@@ -135,6 +158,22 @@ describe('ready under a train policy: the screen choice', () => {
expect(dispatches(fx)).toEqual([]);
}, 120_000);
// Value: protects=train ready resolves the Plan line once (one PLAN: line), refuses under require with no receipt, and records the plan train land reads; fails_when=trainPlan's refusal or receipt field is dropped, or both ready paths print PLAN:; why_new=no train-ready plan case; seam=none; no_claim=train land's slotPlan
test('under GSTACK_PLAN_RECORD=require train ready refuses a body with no Plan line and writes nothing; an exempt line prints one PLAN: line and lands on the receipt', () => {
const { fx, head, pregate } = readyFixture();
const req = { GSTACK_PLAN_RECORD: 'require' };
const missing = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen'], req);
expect(missing.status).toBe(EXIT.refused);
expect(missing.err).toContain('(PLAN_RECORD_MISSING)');
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready'))).toBe(false);
setPrBody(fx, '1', shippedBody('1', head) + '\nPlan: exempt: bug fix\n');
const ok = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen'], req);
expect(ok.status).toBe(EXIT.ok);
expect(ok.out.split('\n').filter(l => l.startsWith('PLAN:'))).toEqual(['PLAN: exempt bug fix']);
const receipt = path.join(fx.state, 'ship-queue', 'ready', repoKey(fx), `1-${head}.json`);
expect(JSON.parse(fs.readFileSync(receipt, 'utf8')).plan).toMatchObject({ line: 'Plan: exempt: bug fix', state: 'exempt', reason: 'bug fix' });
}, 120_000);
test("GSTA-67: a lane whose origin is another URL form of the repository (#3331's git.capy.ai) writes its receipt under the owner/repo key", () => {
const { fx, head, pregate } = readyFixture();
githubOrigin(fx, 'https://git.capy.ai/fixture/repo.git');
+20
View File
@@ -172,6 +172,26 @@ describe('finish (Steps 15-16)', () => {
expect(f.git('ls-remote', '--heads', 'origin', 'feat').stdout).toBe('');
});
// Value: protects=finish --plan resolves the brief's Plan line before any stage: require + Plan: required stops with no commit; a resolvable line prints PLAN: first; fails_when=finishPlan runs after the stages, its stop is dropped, or the line is not first; why_new=plan-record.test.ts covers the helper only; seam=none; no_claim=approved-row resolution (exempt line used)
test('finish --plan: under GSTACK_PLAN_RECORD=require "Plan: required" stops before any stage; a resolvable line under warn prints PLAN: first', () => {
const f = fx();
const reviewed = wtree(f);
expect(f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro']).status).toBe(0);
const post = snapshot(f);
const args = ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE];
const head = f.git('rev-parse', 'HEAD').stdout.trim();
const stop = f.run(RELEASE, [...args, '--plan', 'Plan: required'], { GSTACK_PLAN_RECORD: 'require' });
expect(stop.status).toBe(3);
expect(stop.stdout).toMatch(/^PLAN: refused .*\(PLAN_NOT_APPROVED\)/m);
expect(stop.stdout).toMatch(/^STOP: .*\(PLAN_NOT_APPROVED\); fix: /m);
expect(stop.stdout).not.toContain('COMMIT:');
expect(f.git('rev-parse', 'HEAD').stdout.trim()).toBe(head);
const ok = f.run(RELEASE, [...args, '--plan', 'exempt: bug fix'], { GSTACK_PLAN_RECORD: 'warn' });
expect(ok.stdout.split('\n')[0]).toBe('PLAN: exempt bug fix');
expect(ok.stdout).toMatch(/^COMMIT: /m);
expect(ok.status).toBe(0);
});
// Value: protects=under changelog_fragments the branch's own fragment is release metadata: version writes nothing and names it in NEXT, finish commits it as the release commit and the route counts it as metadata, and another fragment is still a feature file; fails_when=finish stops on changelog.d/<slug>.md or routes it to re-review (/ship #3648 fix-check 4, CRITICAL);
// why_new=releasePaths predates changelog_fragments (#3654); seam=gstack-ship-policy show-effective, gstack-wtree; no_claim=the merge step's fold
test('under changelog_fragments the branch fragment is release metadata and Step 12 names it', () => {
@@ -0,0 +1,4 @@
{
"bytes": 48,
"why": "GST-1751: Step 8 plan discovery skips the Plan record line (case-insensitive, whitespace split); Step 16 names finish --plan"
}