Files
gstack/test/ship-plan-completion-invariants.test.ts
Garry Tanandcapy-ai[bot] 21277291c8 v1.91.345.0 feat(ship): compact driver plus preflight/release bins over the restored reviewers (GSTA-167) (#3648)
* feat(ship): GSTA-167 turn levers on the restored reviewers: ship preflight/release bins, own-jam meter, dispatch-pair metering, ship/DRIVER.md

Ports the non-reviewer parts of #3633 (gsta-167-pr34) and #3637 (gsta-167-pr5 @2914400)
onto main ae3d58c (v1.91.331.0). Reviewer briefs, checklists, the fix-check prompt and
the depth rules are byte-identical to main and now pinned in
test/review-packets-removed.test.ts.

Left out: gstack-review-packet and its libs, parallel/batched fanout, --like, collect,
the audit prompt split, the docs-audit packet, review-army/adversarial/outside-voice
edits, the gate-scan summary-only block and the review-depth scope-error change.
Packet-bytes reader pricing is dropped from gstack-ship-budget; handoff READ ranges
send each section whole; the driver runs the audit, review, adversarial and
documentation sections as written.

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

* test(context-budget): raise the capy/skill/gstack eager ceiling to its captured value after the main merge (own-jam meter, driver and reviewer-batch prose, GSTA-167)

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

* test(ship-driver): name ship/DRIVER.md.tmpl so the pregate maps the template to its test lane (GSTA-167)

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

* fix(ship-driver): keep the skill generator off lib/ship-budget so a minimal setup checkout still renders (GSTA-167)

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

* test(windows): keep test/ship-release.test.ts off the Windows subset; it launches the shebang bin and sh stubs directly (GSTA-167)

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

* feat(ship): variant E orchestration: one program per review pass and Step 14.5 audit, the review child starts at Step 9, a metered dispatch:docs role, and the driver's record headings (GSTA-167)

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

* feat(ship): variant G orchestration: one call per driver phase (9 for a clean pass, at most 12), gstack-ship-preflight --audit for single reads and deterministic audits, an observe-mode ledger with note and credits, 240 s wait pings, and small-model audit readers (GSTA-167)

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

* fix(ship-budget): a Capy handoff child needs no jam id: verify-handoff with no reading makes it parent-metered (METER: parent), it never delegates on an unknown charge, and reconcile charges the parent's counter delta (GSTA-167 replay 7e)

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

* fix(ship): a poll settles a reader only at done, failed or a final summary, never a fresh idle; return settles the child's open step reservation (GSTA-167 replay 7e)

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

* fix(ship-budget): return releases an open step reservation under the estimate meter instead of booking its projection; measured meters still settle it (GSTA-167 replay 7e)

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

* fix(review): a clean fix-check after below-floor fixes is done; Step 11 and fix-check below-floor findings are notes at pass 1

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

* feat(ship): verbatim reviewer briefs and loss-free collection (gstack-review-dispatch render/sent/collect/judge/verify)

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

* fix: pre-landing review fixes

- verify requires each output's brief rendered and sent verbatim, reruns judge
  against the recorded judged.jsonl hash, and flags outputs collected after judge
- judge drops self-folds, fold cycles and chains
- prose findings parse a leading label or a label heading, not any severity word
- releasePaths: manifests count as release metadata only for version-only edits,
  lockfiles never; TODOS.md stays out of lane-evidence exemptions
- push stops when the sealed-output guard did not run
- ship-audit reads the test tree once instead of one git grep per file
- drop the uncalled --delegate measured mode; doc/help step-number fixes

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

* fix: pre-landing review fixes (pass 2)

- collect keeps a finding wherever its label stands (headings, table rows,
  mid-line, Informational and upper-case labels); only top-level list items
  inherit a label heading, and any heading or bold-only line ends it
- judge treats a merged file missing any collected id as a drop; verify flags
  merged_ids_missing and a sent reader never collected (NOT_COLLECTED)
- finish computes release paths after --build, so a build that rewrites
  package.json is a build-input change

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

* fix(review-dispatch): verify per render, voided superseded mismatches

- verify treats each render as one dispatch of its reader, so a two-pass
  ledger compares pass-1 sends with pass-1 briefs (later renders print as
  <reader>@2); outputs bind to the latest send of their own render
- a mismatched send re-sent byte-exact before anything was collected is void
  and counted (voided=N on the verdict line); one that produced an output
  stays MISMATCH
- an indented bold sub-line no longer ends a label section (fix-check)
- docs: a failed reader is collected from its failure text

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

* fix(review-dispatch): only the newest render of a path checks its file; absolute ledger paths

- a later render into the same briefs directory overwrites the earlier brief by
  design, so verify checks a brief file only for the newest render of that path
- collect and judge record absolute output, merged and judged paths, so verify
  reads them from any directory
- docs: a failed reader's failure text is parsed like any output

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

* chore: changelog and TODOS

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

* fix(ship-release): the changelog_fragments fragment is release metadata

Fix-check 4 (CRITICAL) after the main merge: releasePaths omitted
<changelog_fragments.dir>/<branch-slug>.md, so finish stopped on the
fragment Step 13 writes under gstack's policy, or routed it to re-review.
version now names the fragment in its Step 13 NEXT line instead of
[Unreleased], DRIVER.md Step 13 writes and lints where NEXT says, and the
preflight POLICY line says Steps 12-16.

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

* chore: changelog and TODOS

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>
2026-10-31 01:09:31 +00:00

440 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'node:os';
import { spawnSync } from 'node:child_process';
import { ACCEPTANCE_EDITED, generatePlanCompletionAuditReview, generatePlanCompletionAuditShip, generatePlanCompletionGateShip, generatePlanVerificationExec } from '../scripts/resolvers/plan-gates';
import { HOST_PATHS } from '../scripts/resolvers/types';
import { between, expectAbsent, expectMentions, expectOrdered, expectTokens } from './helpers/prompt-structure';
const SHIP_DIR = path.join(__dirname, '..', 'ship');
describe('authored ship-only plan verification handoff', () => {
const ctx = { skillName: 'ship', tmplPath: 'ship/SKILL.md.tmpl', host: 'claude' as const, paths: HOST_PATHS.claude };
test('local execution-only checks remain required in Step 8.1/9 outside implementation counts', () => {
const audit = generatePlanCompletionAuditShip(ctx);
const extraction = audit.slice(audit.indexOf('### Actionable Item Extraction'), audit.indexOf('### Verification Mode')).replace(/\s+/g, ' ');
expect(extraction).toMatch(/execution-only verification/i);
expect(extraction).toContain('Step 8.1/9');
expect(extraction).toMatch(/outside implementation counts/i);
expect(extraction).toMatch(/never DONE from static inspection/);
expectMentions(extraction, [['do not', 'checks', 'waive']], 'extraction');
expect(fs.readFileSync(path.join(SHIP_DIR, 'sections/plan-completion.md.tmpl'), 'utf8')).toContain('exactly these seven fields');
const gate = generatePlanCompletionGateShip(ctx);
expect(gate).toContain('Any NOT DONE items');
expect(gate).toMatch(/per-item confirmation/i);
});
test('review-mode extraction retains its existing categories and has no ship-only routing', () => {
const audit = generatePlanCompletionAuditReview({ ...ctx, skillName: 'review' });
expect(audit).toContain('**Test requirements:**');
expect(audit).not.toMatch(/execution-only verification/i);
expect(audit).not.toContain('Step 8.1/9');
expect(audit).toContain('EXTERNAL-STATE');
});
test('preparation hands every explicit check to the report-only execution owner before Fix-First', () => {
const text = generatePlanVerificationExec(ctx).replace(/\s+/g, ' ');
expect(text).toContain('Collect now; execute in Step 9');
expectMentions(text, [['do not', 'invoke', 'entire']], 'text');
for (const heading of ['Verification', 'Test plan', 'Testing', 'How to test', 'Manual testing']) {
expect(text).toContain(`\`${heading}\``);
}
expect(text).toContain('Step 8.2');
expect(text).toContain('Handoff to Step 9.2.1');
expect(text).toMatch(/before Fix-First/);
expect(text).toMatch(/never silently waive/i);
expect(text).toMatch(/noninteractive runs return blocked/i);
expect(text).toContain('VERIFY_RESULT=pass only if all selected items pass, skipped only if none exist, otherwise fail');
expect(text).toContain('`## Verification Results`');
expect(text.indexOf('Collect now')).toBeLessThan(text.indexOf('Handoff to Step 9.2.1'));
expect(text.indexOf('Handoff to Step 9.2.1')).toBeLessThan(text.indexOf('After execution'));
});
test('the authored parent validates all seven fields and settles failed children before fallback', () => {
const audit = fs.readFileSync(path.join(SHIP_DIR, 'sections/plan-completion.md.tmpl'), 'utf8');
const parent = audit.slice(audit.indexOf('**Parent processing:**')).replace(/\s+/g, ' ');
expect(parent).toMatch(/valid LAST-line JSON, use the audit-failure fallback/i);
expect(parent).toContain('exactly the seven declared fields');
expect(parent).toContain('classification sum equals `total_items`');
expect(parent).toContain('a string `summary`');
expectMentions(parent, [['no', 'no-plan/no-actionable', 'reports']], 'parent');
expect(parent).toContain('~10 minutes');
expectMentions(parent, [['stop', 'confirm', 'stopped']], 'parent');
expect(parent).toMatch(/never race a late result/i);
expectMentions(parent, [['stop', 'recommended/default', 'audit']], 'parent');
const contract = audit.split('\n').find(line => line.startsWith('{"total_items":N,'))!;
expect(Object.keys(JSON.parse(contract.replace(/:N([,}])/g, ':0$1'))).sort())
.toEqual(['total_items', 'done', 'changed', 'partial', 'not_done', 'unverifiable', 'summary'].sort());
expect(generatePlanCompletionGateShip(ctx)).toContain('Only PARTIAL items (no NOT DONE, no UNVERIFIABLE)');
expect(generatePlanCompletionGateShip(ctx)).toMatch(/not blocking/i);
});
});
// Carved (v2 plan T9): the Plan Completion gate moved into sections/plan-completion.md.
// Read the skeleton + sections union so these invariants follow the content.
function readShipUnion(): string {
let t = fs.readFileSync(path.join(SHIP_DIR, 'SKILL.md'), 'utf8');
const secDir = path.join(SHIP_DIR, 'sections');
if (fs.existsSync(secDir)) {
for (const f of fs.readdirSync(secDir).sort()) {
if (f.endsWith('.md')) t += '\n' + fs.readFileSync(path.join(secDir, f), 'utf8');
}
}
return t;
}
describe('ship/SKILL.md — Plan Completion gate invariants', () => {
const skill = readShipUnion();
test('Path concreteness rule: filesystem-pathed items must be test -f checked', () => {
expect(skill).toContain('**Path concreteness rule.**');
expect(skill).toMatch(/concrete filesystem path/);
expect(skill).toMatch(/classified DONE or NOT DONE based on `\[ -f/);
});
test('Validator detection: project package.json validate-* scripts are auto-run', () => {
expect(skill).toContain('**Validator detection.**');
expect(skill).toMatch(/package\.json/);
expect(skill).toMatch(/validate-\*/);
});
test('Per-item UNVERIFIABLE confirmation: blanket-confirm is forbidden', () => {
expect(skill).toMatch(/per-item confirmation/i);
expectMentions(skill, [['do not', 'askuserquestion', 'blanket-confirm']], 'skill');
});
test('Subagent failure: fail-closed, not silent fail-open', () => {
expect(skill).not.toMatch(/block \/ship on subagent failure/i);
// The audit-failure fallback still forbids a silent fail-open (meaning, not the old incident ID).
const fallback = skill.slice(skill.indexOf('**Audit-failure fallback:**'), skill.indexOf('**Audit-failure fallback:**') + 800);
expect(fallback).toMatch(/fail[- ]open/i);
expectMentions(skill, [['stop', 'audit']], 'skill');
});
test('parent rejects audit errors and malformed counts instead of treating them as no plan', () => {
const audit = fs.readFileSync(path.join(SHIP_DIR, 'sections/plan-completion.md'), 'utf8');
expect(audit.indexOf('**Parent processing:**')).toBeGreaterThan(-1);
expect(audit.indexOf('**Parent processing:**')).toBeLessThan(audit.indexOf('**Audit-failure fallback:**'));
const parent = audit.slice(audit.indexOf('**Parent processing:**'), audit.indexOf('**Audit-failure fallback:**')).replace(/\s+/g, ' ');
expect(parent).toContain('seven declared fields');
expect(parent).toMatch(/audit-failure fallback/i);
});
test('successful plan audits use the declared seven-field contract without an error field', () => {
const audit = fs.readFileSync(path.join(SHIP_DIR, 'sections/plan-completion.md'), 'utf8');
const line = audit.split('\n').find(line => line.startsWith('{"total_items":N,'));
expect(line).toBeDefined();
const contract = JSON.parse(line!.replace(/:N([,}])/g, ':0$1'));
expect(Object.keys(contract).sort()).toEqual(['total_items', 'done', 'changed', 'partial', 'not_done', 'unverifiable', 'summary'].sort());
expect(contract).not.toHaveProperty('error');
expect(audit).toContain('exactly these seven fields on the LAST LINE');
expect(audit).not.toContain('A non-null `error`');
});
test('CONTENT-SHAPE dispatch invokes validator before falling back to UNVERIFIABLE', () => {
expect(skill).toMatch(/CONTENT-SHAPE in another repo.*validator/s);
expect(skill).toMatch(/passing validator promotes the item from UNVERIFIABLE to DONE/);
});
test('approved deferrals reach Step 14 without duplicating earlier P0 entries', () => {
const entry = fs.readFileSync(path.join(SHIP_DIR, 'SKILL.md'), 'utf8');
const todos = entry.slice(entry.indexOf('## Step 14:'), entry.indexOf('## Step 15:'));
expect(todos).toContain('Add approved deferrals');
expect(todos).toMatch(/Step 2[^\n]+P1/);
expect(todos).toMatch(/Step 8[^\n]+P1[^\n]+plan/);
expect(todos).toMatch(/Step 5[^\n]+P0[^\n]+deduplicate/);
expect(todos.indexOf('Add approved deferrals')).toBeLessThan(todos.indexOf('Detect completed TODOs'));
});
test('CHANGELOG uses the normal workflow without checkpoint context or squash prerequisites', () => {
const changelog = fs.readFileSync(path.join(SHIP_DIR, 'sections/changelog.md'), 'utf8');
expect(changelog).toContain('**Write the CHANGELOG entry**');
expect(changelog).not.toMatch(/WIP:|gstack-context|checkpoint|squash|Step 15\.0/);
});
test('live evidence recovery distinguishes bookkeeping failure from stale inputs', () => {
const entry = fs.readFileSync(path.join(SHIP_DIR, 'SKILL.md'), 'utf8');
const gate = entry.slice(entry.indexOf('## Step 16:'), entry.indexOf('## Step 17:'));
const text = gate.replace(/\s+/g, ' ');
expect(text).toContain('STALE/MISSING');
expect(text).toContain('| Only receipt storage/readback failed |');
expect(text).toContain('**ledger unavailable**, never FRESH');
const storage = text.slice(text.indexOf('| Only receipt storage/readback failed |')).split('|')[2];
expect(storage).not.toContain('gstack-evidence run');
expect(storage).toMatch(/without that proof, use STALE\/MISSING/i);
expect(text).toMatch(/\*\*New, changed or unwaived test failure:\*\* stop publication\. Run Steps 5–15/i);
expectMentions(text, [['only', 'pre-existing', 'verified']], 'text');
expect(text).toMatch(/make evidence STALE/);
});
test('ship contract precedes base detection and fresh remote facts precede distribution decisions', () => {
const entry = fs.readFileSync(path.join(SHIP_DIR, 'SKILL.md'), 'utf8');
expect(entry.indexOf('# Ship:')).toBeLessThan(entry.indexOf('## Step 0:'));
const preflight = entry.slice(entry.indexOf('## Step 1:'), entry.indexOf('## Step 2:'));
expect(preflight).toContain('git fetch origin <base>');
expect(preflight).toMatch(/fetch fails[^\n]+\bstop\b/i);
expect(entry).not.toContain('auto-generate and commit, or flag');
expect(entry).toContain('Step 15 commits those tests');
});
test('bisectable commits proceed directly to verification without rewriting existing history', () => {
const entry = fs.readFileSync(path.join(SHIP_DIR, 'SKILL.md'), 'utf8');
const commit = entry.slice(entry.indexOf('## Step 15:'), entry.indexOf('## Step 16:'));
expect(commit).toMatch(/bisectable commits/i);
expect(commit).toContain('continue to Step 16');
expectMentions(commit, [['never', 'create', 'commit']], 'commit');
expect(commit).not.toMatch(/checkpoint|WIP|squash|git rebase|git reset/);
expect(entry).not.toMatch(/Step 15\.[012]/);
});
test('a rejected push stops publication and routes changed content back through verification', () => {
const entry = fs.readFileSync(path.join(SHIP_DIR, 'SKILL.md'), 'utf8');
const push = entry.slice(entry.indexOf('## Step 17:'), entry.indexOf('## Step 20:'));
const recovery = push.replace(/\s+/g, ' ');
expect(push).toMatch(/push fails[^\n]+\bstop\b/i);
expect(recovery).toContain('**Non-fast-forward push:**');
expectMentions(recovery, [['before', 'returning', 'steps']], 'recovery');
expect(recovery).toMatch(/never rewrite history/i);
expect(recovery).toContain('**Authentication, hook or network failure:**');
expect(push).toMatch(/never force.push/i);
expect(push).toMatch(/only a successful push/i);
});
});
// GSTA-167 PR 4: Step 17's idempotency block moved into gstack-ship-release push; this runs the
// bin the prose names, with the same remote states the block was tested against.
test('push idempotency requires the live remote SHA and fails closed on transport errors', () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-push-state-'));
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-push-root-'));
fs.writeFileSync(path.join(state, '.redact-prepush-prompted'), '');
const env = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GSTACK_STATE_ROOT: state, GSTACK_HOME: state,
GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 'test@example.invalid',
GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@example.invalid' };
const git = (...args: string[]) => {
const r = spawnSync('git', args, { cwd, env, encoding: 'utf8', timeout: 30_000 });
if (r.status !== 0) throw new Error(r.stderr || String(r.error));
};
try {
git('init', '-b', 'feature');
fs.writeFileSync(path.join(cwd, 'app'), 'base\n');
git('add', 'app'); git('commit', '-m', 'base');
const remote = path.join(cwd, '.git/remote.git');
git('init', '--bare', remote); git('remote', 'add', 'origin', remote);
const source = fs.readFileSync(path.join(SHIP_DIR, 'SKILL.md.tmpl'), 'utf8');
const step17 = source.slice(source.indexOf('## Step 17:'), source.indexOf('## Step 18:'));
expect(step17).toContain('gstack-ship-release push');
expect(step17).toContain('a failed lookup STOPs');
const push = () => spawnSync(path.join(SHIP_DIR, '..', 'bin', 'gstack-ship-release'), ['push', '--branch', 'feature'], { cwd, env, encoding: 'utf8', timeout: 60_000 });
expect(push().stdout).toMatch(/PUSH: pushed [0-9a-f]{12} to origin\/feature/);
expect(push().stdout).toContain('PUSH: ALREADY_PUSHED');
fs.appendFileSync(path.join(cwd, 'app'), 'new\n'); git('commit', '-am', 'new');
expect(push().stdout).toMatch(/PUSH: pushed /);
fs.renameSync(remote, remote + '-offline');
const unavailable = push();
expect(unavailable.status).toBe(3);
expect(unavailable.stdout).not.toContain('ALREADY_PUSHED');
expect(unavailable.stdout).toContain('STOP: cannot verify the remote branch');
} finally { fs.rmSync(cwd, { recursive: true, force: true }); fs.rmSync(state, { recursive: true, force: true }); }
}, 120_000);
// F1 (#2768, #2800): the Plan Completion Audit used to fall back to "the newest
// plan file from the last day" and to any plan naming the repo, so a branch
// with no plan was audited against an unrelated one, and repo-committed
// docs/designs/ was never searched. The rendered discovery block now binds
// explicitly, lists docs/designs/ candidates, and never auto-picks.
describe('F1: plan binding, docs/designs candidates, exact not-run line', () => {
const ROOT = path.join(__dirname, '..');
const NOT_RUN = 'Plan completion audit: not run (no plan is bound to this branch and no docs/designs/ file matches). Fix: add "Plan: <path>" to the PR body, or run /autoplan.';
const sections = {
ship: path.join(ROOT, 'ship', 'sections', 'plan-completion.md'),
review: path.join(ROOT, 'review', 'sections', 'plan-completion.md'),
};
function discovery(file: string): string {
const md = fs.readFileSync(file, 'utf8');
return md.slice(md.indexOf('### Plan File Discovery')).match(/```bash\n([\s\S]*?)```/)![1]
.replaceAll('~/.claude/skills/gstack', ROOT).replaceAll('<base>', 'main');
}
function fixture(opts: { design?: boolean; prBody?: string }) {
const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'plan-bind-')));
const repo = path.join(tmp, 'repo');
fs.mkdirSync(repo);
const env = { ...process.env, HOME: tmp, GSTACK_HOME: path.join(tmp, '.gstack'), GIT_CONFIG_GLOBAL: '/dev/null', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' } as Record<string, string>;
delete env.GSTACK_STATE_ROOT; delete env.GSTACK_PROJECT_SLUG;
const git = (...a: string[]) => spawnSync('git', a, { cwd: repo, env, encoding: 'utf8', timeout: 30_000 });
git('init', '-q', '-b', 'main');
fs.mkdirSync(path.join(repo, 'docs', 'designs'), { recursive: true });
fs.writeFileSync(path.join(repo, 'docs', 'designs', 'OLD_FEATURE.md'), '# an older, unrelated design\n');
git('add', '-A'); git('commit', '-qm', 'base');
git('update-ref', 'refs/remotes/origin/main', 'HEAD');
git('checkout', '-qb', 'feat-pricing');
fs.writeFileSync(path.join(repo, 'src.ts'), 'x\n');
if (opts.design) fs.writeFileSync(path.join(repo, 'docs', 'designs', 'PRICING.md'), '# pricing plan\n');
git('add', '-A'); git('commit', '-qm', 'work');
// An unrelated plan-mode file from today, naming neither branch nor feature.
fs.mkdirSync(path.join(tmp, '.claude', 'plans'), { recursive: true });
fs.writeFileSync(path.join(tmp, '.claude', 'plans', 'recent-unrelated.md'), '# refactor the billing page for repo\n');
const bin = path.join(tmp, 'bin');
fs.mkdirSync(bin);
if (opts.prBody !== undefined) fs.writeFileSync(path.join(tmp, 'pr-body.txt'), opts.prBody);
fs.writeFileSync(path.join(bin, 'gh'), opts.prBody === undefined ? '#!/bin/sh\nexit 1\n' : `#!/bin/sh\ncat '${path.join(tmp, 'pr-body.txt')}'\n`, { mode: 0o755 });
env.PATH = `${bin}:${process.env.PATH}`;
return { tmp, repo, env };
}
function run(which: 'ship' | 'review', f: ReturnType<typeof fixture>) {
return spawnSync('bash', ['-c', discovery(sections[which])], { cwd: f.repo, env: f.env, encoding: 'utf8', timeout: 30_000 });
}
for (const which of ['ship', 'review'] as const) {
test(`${which}: a docs/designs file changed on this branch is a candidate; an unrelated recent plan is not`, () => {
const f = fixture({ design: true });
try {
const out = run(which, f).stdout;
expect(out).toContain(`PLAN_CANDIDATE: ${path.join(f.repo, 'docs', 'designs', 'PRICING.md')}`);
expect(out).not.toContain('OLD_FEATURE.md');
expect(out).not.toContain('recent-unrelated.md');
expect(out).not.toContain('PLAN_FILE:');
} finally { fs.rmSync(f.tmp, { recursive: true, force: true }); }
});
test(`${which}: nothing bound and nothing matching yields no plan at all`, () => {
const f = fixture({});
try {
const out = run(which, f).stdout;
expect(out).not.toContain('PLAN_CANDIDATE:');
expect(out).not.toContain('PLAN_FILE:');
expect(out).not.toContain('recent-unrelated.md');
} finally { fs.rmSync(f.tmp, { recursive: true, force: true }); }
});
test(`${which}: a "Plan:" line in the PR body is printed as the binding`, () => {
const f = fixture({ prBody: 'Summary\n\nPlan: `docs/designs/PRICING.md`\n' });
try {
expect(run(which, f).stdout).toContain('PLAN_BINDING: docs/designs/PRICING.md');
} finally { fs.rmSync(f.tmp, { recursive: true, force: true }); }
});
test(`${which}: binding precedence and the exact not-run line`, () => {
const md = fs.readFileSync(sections[which], 'utf8');
const text = md.slice(md.indexOf('### Plan File Discovery')).replace(/\s+/g, ' ');
expect(text).toContain(NOT_RUN);
const order = ['Conversation context (primary)', 'PR body binding', 'Content-based search (fallback)', 'No binding and no chosen candidate'];
const at = order.map(o => text.indexOf(o));
expect(at.every(i => i >= 0)).toBe(true);
expect([...at].sort((a, b) => a - b)).toEqual(at);
expect(text).toMatch(/never pick one silently/i);
expect(text).toContain('No plan: skip the audit');
});
}
test('ship binds the plan in the parent before dispatch; the child never searches', () => {
const md = fs.readFileSync(sections.ship, 'utf8');
expect(md.indexOf('### Plan File Discovery')).toBeLessThan(md.indexOf('````text'));
const child = md.slice(md.indexOf('````text'), md.lastIndexOf('````'));
expect(child).not.toContain('### Plan File Discovery');
expectMentions(child, [['do not', 'another', 'search']], 'child');
expect(md).toMatch(/not-run line, skip dispatch/);
});
});
// RH-10 (docs/designs/HONEST_WORK_GATE_INTEGRITY.md W5): editing the plan's acceptance
// text is not progress. The parent diffs the acceptance-bearing sections of the
// working-tree plan against a baseline and reports ACCEPTANCE_EDITED beside the
// child's classes; the child's seven-field contract is untouched.
describe('ACCEPTANCE_EDITED: parent-computed acceptance-text drift', () => {
const ctx = { skillName: 'ship', tmplPath: 'ship/SKILL.md.tmpl', host: 'claude' as const, paths: HOST_PATHS.claude };
const rendered = fs.readFileSync(path.join(SHIP_DIR, 'sections', 'plan-completion.md'), 'utf8');
test('the check renders through the :acceptance arg with the baseline order and the never-CHANGED rule', () => {
const text = generatePlanCompletionGateShip(ctx, ['acceptance']);
expect(text).toStartWith('### Acceptance edits (parent-computed)');
expectOrdered(text, ['origin/<base>', 'first commit on this', 'baseline unavailable'], 'baseline order');
expectTokens(text, ['ACCEPTANCE_BASELINE: unavailable', `${ACCEPTANCE_EDITED}: none`, `${ACCEPTANCE_EDITED}: yes`, `[${ACCEPTANCE_EDITED}]`, 'Acceptance edits: baseline unavailable', '## Plan Completion'], 'acceptance check');
expectMentions(text, [['never', 'fold', 'changed'], ['original obligation', 'audited'], ['same commit', 'introduced', 'invisible']], 'acceptance check');
const gate = generatePlanCompletionGateShip(ctx);
expect(gate).toContain(`**${ACCEPTANCE_EDITED} items**`);
expectMentions(gate, [['never counted as changed']], 'gate');
expect(gate).toContain('`Acceptance edits:` line');
});
test('the template runs it in the parent after the child reports, before Gate Logic, outside the child prompt', () => {
const parent = between(rendered, '**Parent processing:**', '**Audit-failure fallback:**');
expectOrdered(parent, ['1. Check the task', `\`${ACCEPTANCE_EDITED}\` beside the child's classes`, 'Apply Gate Logic below', '### Acceptance edits (parent-computed)'], 'parent processing');
expect(rendered.indexOf('### Acceptance edits (parent-computed)')).toBeLessThan(rendered.indexOf('### Gate Logic'));
expectAbsent(between(rendered, '````text', '````\n\n**Parent processing:**'), [ACCEPTANCE_EDITED], 'child prompt');
const contract = rendered.split('\n').find(line => line.startsWith('{"total_items":N,'))!;
expect(Object.keys(JSON.parse(contract.replace(/:N([,}])/g, ':0$1')))).toHaveLength(7);
});
function block(planPath: string): string {
return between(rendered, '### Acceptance edits (parent-computed)', '### Gate Logic').match(/```bash\n([\s\S]*?)```/)![1]
.replaceAll('<plan-path>', planPath).replaceAll('<base>', 'main');
}
function repo() {
const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'acceptance-edited-')));
const env = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t.invalid', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t.invalid' };
const git = (...args: string[]) => {
const r = spawnSync('git', args, { cwd: tmp, env, encoding: 'utf8', timeout: 30_000 });
if (r.status !== 0) throw new Error(r.stderr);
return r.stdout.trim();
};
const run = (planPath: string) => spawnSync('bash', ['-c', block(planPath)], { cwd: tmp, env, encoding: 'utf8', timeout: 30_000 });
const plan = (criteria: string, context = 'the old context') => `# Plan\n\n## Context\n\n${context}\n\n## Acceptance Criteria\n\n${criteria}\n\n## Rollback\n\nrevert\n`;
git('init', '-q', '-b', 'main');
fs.writeFileSync(path.join(tmp, 'README.md'), 'base\n');
git('add', '-A'); git('commit', '-qm', 'base');
return { tmp, git, run, plan };
}
test('a plan on origin/<base>: an acceptance edit in the working tree is yes with the diff; a context-only edit is none', () => {
const f = repo();
try {
fs.mkdirSync(path.join(f.tmp, 'docs/designs'), { recursive: true });
const file = path.join(f.tmp, 'docs/designs/PLAN.md');
fs.writeFileSync(file, f.plan('1. Orders older than 30 days return HTTP 410 for all 4 user roles'));
f.git('add', '-A'); f.git('commit', '-qm', 'plan');
f.git('update-ref', 'refs/remotes/origin/main', 'HEAD');
f.git('checkout', '-qb', 'feature');
fs.writeFileSync(file, f.plan('1. Orders older than 30 days return HTTP 410 for all 4 user roles', 'rewritten context'));
const none = f.run(file);
expect(none.status).toBe(0);
expect(none.stdout).toContain('ACCEPTANCE_BASELINE: origin/main');
expect(none.stdout).toContain(`${ACCEPTANCE_EDITED}: none`);
fs.writeFileSync(file, f.plan('1. Orders older than 30 days return HTTP 410 for the admin role'));
const yes = f.run(file);
expect(yes.status).toBe(0);
expect(yes.stdout).toContain(`${ACCEPTANCE_EDITED}: yes`);
expect(yes.stdout).toContain('-1. Orders older than 30 days return HTTP 410 for all 4 user roles');
expect(yes.stdout).toContain('+1. Orders older than 30 days return HTTP 410 for the admin role');
expect(yes.stdout).not.toContain(`${ACCEPTANCE_EDITED}: none`);
} finally { fs.rmSync(f.tmp, { recursive: true, force: true }); }
});
test('a plan introduced on the branch uses its first commit as the baseline; an untracked plan is baseline unavailable', () => {
const f = repo();
try {
f.git('update-ref', 'refs/remotes/origin/main', 'HEAD');
f.git('checkout', '-qb', 'feature');
fs.mkdirSync(path.join(f.tmp, 'docs/designs'), { recursive: true });
const file = path.join(f.tmp, 'docs/designs/PLAN.md');
fs.writeFileSync(file, f.plan('1. Query time under 100ms'));
f.git('add', '-A'); f.git('commit', '-qm', 'add plan');
const first = f.git('rev-parse', 'HEAD');
fs.writeFileSync(file, f.plan('1. Query time under 100ms\n2. Added later'));
f.git('add', '-A'); f.git('commit', '-qm', 'edit plan');
const out = f.run(file);
expect(out.status).toBe(0);
expect(out.stdout).toContain(`ACCEPTANCE_BASELINE: first commit on branch (${first})`);
expect(out.stdout).toContain(`${ACCEPTANCE_EDITED}: yes`);
expect(out.stdout).toContain('+2. Added later');
const outside = path.join(f.tmp, 'outside-plan.md');
fs.writeFileSync(outside, f.plan('1. anything'));
const unavailable = f.run(outside);
expect(unavailable.status).toBe(0);
expect(unavailable.stdout).toContain('ACCEPTANCE_BASELINE: unavailable');
expect(unavailable.stdout).not.toContain(`${ACCEPTANCE_EDITED}:`);
} finally { fs.rmSync(f.tmp, { recursive: true, force: true }); }
});
});