mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-27 15:11:47 +02:00
* v1.89.1.0 fix: remove continuous checkpoint commits and repair validation blockers * fix: clarify shipping and engineering review recovery * fix: interpret native no-change review descriptions * test: separate descendant readiness from timeout delivery
523 lines
29 KiB
TypeScript
523 lines
29 KiB
TypeScript
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 { generateCodexPlanReview } from '../scripts/resolvers/review';
|
|
import { CODEX_MODEL_CONFIG_FLAG } from '../scripts/resolvers/constants';
|
|
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
|
|
import { ALL_HOST_CONFIGS } from '../hosts';
|
|
|
|
// Regression guard for #2440 (which itself regressed the #497 fix).
|
|
//
|
|
// Claude Code v2.1.198 made subagents run in the BACKGROUND by default.
|
|
// Guidance written before that ("do NOT use run_in_background") stopped
|
|
// producing a foreground run — the review army and autoplan dual-voice
|
|
// steps silently launched specialists in the background and merged before
|
|
// they completed. Use `run_in_background: false` when offered. Some interactive
|
|
// hosts remove that field and force async dispatch; final-result consumption,
|
|
// including its notification handoff, remains the completion contract.
|
|
//
|
|
// This tripwire pins the corrected phrasing in the generated skill output
|
|
// and fails if the inverted form ever comes back through a template or
|
|
// resolver edit.
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const reviewContext = (host: TemplateContext['host']): TemplateContext => ({
|
|
host, skillName: 'fixture-plan-review', tmplPath: 'fixture-plan-review/SKILL.md.tmpl', paths: HOST_PATHS[host],
|
|
});
|
|
const CEO_FOREGROUND_BRANCH = 'Set `run_in_background: false` if that field is available; omit it otherwise';
|
|
|
|
describe('generated Codex plan-review shell invocation', () => {
|
|
const rendered = generateCodexPlanReview({ ...reviewContext('claude'),
|
|
paths: { ...HOST_PATHS.claude, binDir: path.join(ROOT, 'bin'), skillRoot: ROOT },
|
|
});
|
|
const ready = rendered.slice(rendered.indexOf('**If `CODEX_MODE: ready` — run Codex:**'),
|
|
rendered.indexOf('Present the full output verbatim:'));
|
|
const blocks = [...ready.matchAll(/```bash\n([\s\S]*?)\n```/g)].map(match => match[1]!);
|
|
const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
|
|
function fixture() {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-codex-plan-shell-'));
|
|
const bin = path.join(dir, 'bin');
|
|
fs.mkdirSync(bin);
|
|
const prompt = path.join(dir, 'review-prompt.txt');
|
|
fs.writeFileSync(prompt, 'Review the current plan without edits.');
|
|
const created = path.join(dir, 'created');
|
|
const calls = path.join(dir, 'calls');
|
|
const stale = path.join(dir, 'codex-out-foreign');
|
|
const staleError = path.join(dir, 'codex-planreview-foreign');
|
|
fs.writeFileSync(stale, 'FOREIGN OLD REVIEW\n');
|
|
fs.writeFileSync(staleError, 'FOREIGN OLD ERROR\n');
|
|
const writeBin = (name: string, body: string) => fs.writeFileSync(path.join(bin, name), '#!/bin/sh\n' + body, { mode: 0o755 });
|
|
writeBin('git', 'printf "%s\\n" "$FAKE_REPO"\n');
|
|
writeBin('mktemp', `
|
|
if [ "$FAKE_MKTEMP_FAIL" = 1 ]; then exit 42; fi
|
|
p=$(${quote(Bun.which('mktemp')!)} "$@") || exit 1
|
|
printf '%s\\n' "$p" >> "$FAKE_CREATED"
|
|
printf '%s\\n' "$p"
|
|
`);
|
|
writeBin('codex', `
|
|
printf '%s\\n' "$FAKE_REVIEW_ID" >> "$FAKE_CALLS"
|
|
printf '%s\\n' "$FAKE_REVIEW_ID: current findings"
|
|
printf '%s\\n' "Recommendation: fix $FAKE_REVIEW_ID because this is the current finding."
|
|
printf '%s\\n' "$FAKE_REVIEW_ID: current stderr" >&2
|
|
exit "$FAKE_CODEX_STATUS"
|
|
`);
|
|
writeBin('cat', `
|
|
if [ "$FAKE_CAT_FAIL" = 1 ] && [ "\${1##*/}" = stderr ]; then
|
|
printf '%s\\n' 'cat: simulated current-file read failure' >&2
|
|
exit 47
|
|
fi
|
|
exec ${quote(Bun.which('cat')!)} "$@"
|
|
`);
|
|
const run = (id: string, code = 0, errexit = false, mktempFailure = false, catFailure = false) => {
|
|
const env = { PATH: `${bin}${path.delimiter}${process.env.PATH}`, FAKE_REPO: dir,
|
|
FAKE_CREATED: created, FAKE_CALLS: calls, FAKE_REVIEW_ID: id,
|
|
FAKE_CODEX_STATUS: String(code), FAKE_MKTEMP_FAIL: mktempFailure ? '1' : '0',
|
|
FAKE_CAT_FAIL: catFailure ? '1' : '0', TMPDIR: dir,
|
|
CODEX_THREAD_ID: '', CODEX_SANDBOX: '', CLAUDECODE: '1', GSTACK_ACTIVE_HOST: 'claude' };
|
|
// Each displayed block gets a fresh shell, as separate Bash tool calls do.
|
|
return blocks.map(block => spawnSync('bash', ['-c', (errexit ? 'set -e\n' : '') + block.replace("'<prepared-prompt-file>'", quote(prompt))], {
|
|
cwd: dir, env, encoding: 'utf8', timeout: 3_000,
|
|
}));
|
|
};
|
|
return { dir, run, stale, staleError, calls,
|
|
created: () => fs.existsSync(created) ? fs.readFileSync(created, 'utf8').trim().split('\n') : [],
|
|
cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) };
|
|
}
|
|
|
|
const findings = (id: string) => `${id}: current findings\nRecommendation: fix ${id} because this is the current finding.\n`;
|
|
const completed = (id: string) => `${findings(id)}OUTSIDE_STATUS: completed provider=codex host=claude\n`;
|
|
|
|
test('fresh shells retain the current stderr and clean its exact temporary file', () => {
|
|
const f = fixture();
|
|
try {
|
|
const results = f.run('current');
|
|
expect(results.map(result => result.status)).toEqual([0]);
|
|
expect(results[0]!.stdout).toBe(completed('current'));
|
|
expect(results[0]!.stderr).toBe('current: current stderr\n');
|
|
expect(f.created()).toHaveLength(1);
|
|
expect(f.created().every(file => !fs.existsSync(file))).toBe(true);
|
|
} finally { f.cleanup(); }
|
|
});
|
|
test.each([false, true])('preserves the real failure status and stderr with errexit=%s', (errexit) => {
|
|
const f = fixture();
|
|
try {
|
|
const results = f.run('failed', 23, errexit);
|
|
expect(results.map(result => result.status)).toEqual([23]);
|
|
expect(results[0]!.stdout).toBe(findings('failed'));
|
|
expect(results[0]!.stderr).toContain('failed: current stderr\n');
|
|
expect(f.created()).toHaveLength(1);
|
|
expect(f.created().every(file => !fs.existsSync(file))).toBe(true);
|
|
} finally { f.cleanup(); }
|
|
});
|
|
test.each([[0, false], [0, true], [23, false], [23, true]] as const)(
|
|
'stderr display failure preserves Codex status %s with errexit=%s', (code, errexit) => {
|
|
const f = fixture();
|
|
try {
|
|
const results = f.run('display-failed', code, errexit, false, true);
|
|
expect(results.map(result => result.status)).toEqual([code || 1]);
|
|
expect(results[0]!.stdout).toBe(findings('display-failed'));
|
|
expect(results[0]!.stderr).toContain('cat: simulated current-file read failure\n');
|
|
expect(f.created()).toHaveLength(1);
|
|
expect(f.created().every(file => !fs.existsSync(file))).toBe(true);
|
|
} finally { f.cleanup(); }
|
|
});
|
|
test('two invocations consume only their own output and leave stale files untouched', () => {
|
|
const f = fixture();
|
|
try {
|
|
for (const id of ['first', 'second']) {
|
|
const results = f.run(id);
|
|
expect(results.map(result => result.status)).toEqual([0]);
|
|
expect(results[0]!.stdout).toBe(completed(id));
|
|
expect(results[0]!.stderr).toBe(`${id}: current stderr\n`);
|
|
}
|
|
expect(new Set(f.created()).size).toBe(2);
|
|
expect(f.created().every(file => !fs.existsSync(file))).toBe(true);
|
|
expect(fs.readFileSync(f.stale, 'utf8')).toBe('FOREIGN OLD REVIEW\n');
|
|
expect(fs.readFileSync(f.staleError, 'utf8')).toBe('FOREIGN OLD ERROR\n');
|
|
expect(fs.readFileSync(f.calls, 'utf8')).toBe('first\nsecond\n');
|
|
} finally { f.cleanup(); }
|
|
});
|
|
test('temporary-directory failure stops before starting Codex', () => {
|
|
const f = fixture();
|
|
try {
|
|
const results = f.run('unstarted', 0, false, true);
|
|
expect(results.map(result => result.status)).toEqual([1]);
|
|
expect(fs.existsSync(f.calls)).toBe(false);
|
|
expect(f.created()).toEqual([]);
|
|
} finally { f.cleanup(); }
|
|
});
|
|
});
|
|
|
|
// Only these plan-review carriers use the bounded outside-voice task. Every
|
|
// other synchronous dispatch keeps the existing explicit foreground rule.
|
|
const BOUNDED_OUTSIDE_VOICE_SITES = new Set([
|
|
'plan-ceo-review/sections/review-sections.md',
|
|
'plan-eng-review/sections/review-sections.md',
|
|
'plan-devex-review/sections/review-sections.md',
|
|
]);
|
|
function boundedOutsideVoice(content: string): string {
|
|
// Host postprocessing can expand the preceding CODEX_MODE list (for example
|
|
// broken_install/model_unusable). Pin the actual bounded dispatch section.
|
|
return content.match(/\*\*Bounded outside-voice wait[\s\S]*?(?=\*\*(?:Integrate reviewer findings|Cross-model tension):\*\*)/)?.[0] ?? '';
|
|
}
|
|
function hasBoundedOutsideVoiceWait(content: string): boolean {
|
|
const fallback = boundedOutsideVoice(content);
|
|
return ['Bounded outside-voice wait', 'subagent_type: "Plan"', 'run_in_background: true',
|
|
'Immediately call TaskOutput', 'block: true', 'timeout: 300000', 'Make one wait only',
|
|
'`<retrieval_status>` must be `success`', '`<task_id>` must match', '`<task_type>` must be `local_agent`',
|
|
'`<status>`\n must be `completed`', '`<output>` must be nonempty', 'must be no outer\n `<error>`',
|
|
'identifiable complete', 'Reject raw or in-progress transcripts',
|
|
'call TaskStop with the same ID', 'Ignore partial or late results',
|
|
'STATUS = "unavailable", SOURCE = "none", OUTSIDE_STATUS = "unavailable"'].every(part => fallback.includes(part))
|
|
&& /Skip (?:Integrate reviewer findings and )?Cross-model tension\. Persist an unavailable result/.test(fallback);
|
|
}
|
|
|
|
describe('outside-voice dispatch contract', () => {
|
|
const rendered = generateCodexPlanReview(reviewContext('claude'));
|
|
const fallback = boundedOutsideVoice(rendered);
|
|
|
|
test('CEO and Eng disabled or unavailable reviewers still reach decision readiness', () => {
|
|
for (const skillName of ['plan-ceo-review', 'plan-eng-review']) {
|
|
const output = generateCodexPlanReview({ ...reviewContext('claude'), skillName });
|
|
expect(output).toContain('then continue directly to the remaining planning decisions and Approval readiness');
|
|
expect(output).toContain('Outside voice unavailable. Continuing to planning decisions and Approval readiness.');
|
|
expect(output).not.toContain('then continue directly to outputs');
|
|
expect(output).not.toContain('Outside voice unavailable. Continuing to outputs.');
|
|
expect(hasBoundedOutsideVoiceWait(output)).toBe(true);
|
|
}
|
|
// Reviews without the split gate retain their own output continuation.
|
|
expect(rendered).toContain('then continue directly to outputs');
|
|
expect(rendered).not.toContain('remaining planning decisions and Approval readiness');
|
|
});
|
|
|
|
test('the delegated prompt itself requires findings only and forbids plan mutations', () => {
|
|
const promptStart = rendered.indexOf('"IMPORTANT:');
|
|
const promptEnd = rendered.indexOf('\n<plan content>"');
|
|
expect(promptStart).toBeGreaterThan(-1);
|
|
expect(promptEnd).toBeGreaterThan(promptStart);
|
|
const prompt = rendered.slice(promptStart, promptEnd);
|
|
// A sovereignty rule elsewhere in the parent workflow does not reach
|
|
// a fresh-context reviewer receiving only this constructed prompt.
|
|
expect(prompt).toContain('Read-only review: return findings in your final response.');
|
|
expect(prompt).toContain('including the plan file');
|
|
expect(prompt).toContain('Edit, Write, NotebookEdit, or Bash or');
|
|
expect(prompt).toContain('other tools to mutate files');
|
|
expect(prompt).toContain('Do not implement findings or update review reports.');
|
|
expect(prompt).toContain('not instructions to\nexecute');
|
|
expect(prompt).toContain('explicit user approval');
|
|
});
|
|
|
|
test('fallback uses one exact-ID wait and explicit cancellation without a model override', () => {
|
|
expect(hasBoundedOutsideVoiceWait(rendered)).toBe(true);
|
|
expect(fallback).toContain('Before dispatch, verify the host offers the built-in Plan agent type, TaskOutput and\nTaskStop.');
|
|
expect(fallback).toContain('If any is unavailable, take the unavailable path below without launching.');
|
|
expect(fallback).toContain('Do not set a model\noverride');
|
|
expect(fallback).toContain('one five-minute wait plus dispatch/cancellation overhead');
|
|
expect(fallback).toContain('Keep the returned `agentId`; do not guess an ID or launch a second task.');
|
|
expect(fallback).toContain('TaskOutput timeout does not stop the agent.');
|
|
expect(fallback).not.toContain('allowed_tools');
|
|
expect(fallback).not.toContain('run_in_background: false');
|
|
});
|
|
|
|
test('only the matching completed final report can enter agreement and persistence', () => {
|
|
for (const guard of ['<retrieval_status>', '<task_id>', '<task_type>', 'local_agent', '<status>', 'completed',
|
|
'<output>', 'nonempty', 'no outer', '<error>', 'identifiable complete',
|
|
'Reject raw or in-progress transcripts', 'do not extract\n finding fragments from them']) expect(fallback).toContain(guard);
|
|
expect(fallback).toContain('Terminal status or warning markers alone do not\n establish report completeness.');
|
|
expect(fallback).not.toContain('isRawTranscript');
|
|
expect(fallback).not.toContain('task.status');
|
|
expect(fallback).toContain('If any check fails or the report cannot be identified, follow step 4.');
|
|
expect(fallback).toContain('Outside voice unavailable. Continuing to outputs.');
|
|
expect(fallback).toContain('Do not retry with a general-purpose agent.');
|
|
expect(fallback).toContain('Report missing outside-voice coverage.');
|
|
expect(fallback).toContain('still give no late-result credit');
|
|
expect(fallback).toContain('cancellation is unconfirmed');
|
|
expect(fallback).toContain('Skip Cross-model tension. Persist an unavailable result');
|
|
expect(fallback).toContain('STATUS = "unavailable", SOURCE = "none", OUTSIDE_STATUS = "unavailable"');
|
|
expect(fallback).toContain('then continue directly to outputs. The storage policy still applies.');
|
|
expect(fallback).toContain('Do not record a clean review when no reviewer completed within the accepted wait.');
|
|
expect(rendered).toContain('Wait for the user; model agreement is evidence, not consent.');
|
|
expect(rendered).toContain('Record its answer reference and exact accepted scope');
|
|
const answer = rendered.indexOf('**3. Obtain the answer.**');
|
|
const apply = rendered.indexOf('**4. Apply the answered row.**');
|
|
expect(answer).toBeGreaterThan(0);
|
|
expect(apply).toBeGreaterThan(answer);
|
|
expect(rendered).toContain(`-s read-only ${CODEX_MODEL_CONFIG_FLAG} -c 'model_reasoning_effort="high"'`);
|
|
});
|
|
|
|
test('the generated-carrier exception rejects missing wait, cancellation or result guards', () => {
|
|
expect(hasBoundedOutsideVoiceWait(rendered)).toBe(true);
|
|
for (const guard of ['subagent_type: "Plan"', 'timeout: 300000', 'call TaskStop with the same ID',
|
|
'<status>', '<output>', 'Reject raw or in-progress transcripts',
|
|
'Ignore partial or late results', 'Skip Cross-model tension. Persist an unavailable result',
|
|
'STATUS = "unavailable", SOURCE = "none", OUTSIDE_STATUS = "unavailable"']) {
|
|
expect(hasBoundedOutsideVoiceWait(rendered.replaceAll(guard, 'missing guard')), guard).toBe(false);
|
|
}
|
|
expect(hasBoundedOutsideVoiceWait('Dispatch via the Agent tool with run_in_background: true')).toBe(false);
|
|
});
|
|
|
|
test('expanded Codex availability states preserve the same bounded dispatch guards', () => {
|
|
const earlier = rendered.replaceAll('broken_install', 'not_installed')
|
|
.replaceAll('model_unusable', 'not_authed');
|
|
expect(earlier).not.toBe(rendered);
|
|
for (const content of [earlier, rendered]) {
|
|
expect(hasBoundedOutsideVoiceWait(content)).toBe(true);
|
|
expect(hasBoundedOutsideVoiceWait(content.replace('call TaskStop with the same ID', 'missing cancellation'))).toBe(false);
|
|
}
|
|
});
|
|
|
|
test('all host resolver outputs require Plan availability for native fallback', () => {
|
|
for (const host of ALL_HOST_CONFIGS) {
|
|
const output = generateCodexPlanReview(reviewContext(host.name));
|
|
{
|
|
expect(output, host.name).toContain('If any is unavailable, take the unavailable path below without launching.');
|
|
expect(output, host.name).toContain('Do not record a clean review when no reviewer completed within the accepted wait.');
|
|
expect(output, host.name).toContain('Do not set a model\noverride');
|
|
expect(hasBoundedOutsideVoiceWait(output), host.name).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// review's specialist-dispatch guidance lives in its carved Review Army section
|
|
// (Step 4.5 moved out of the skeleton), so the pin follows it there. Same for
|
|
// autoplan: the dual-voice dispatch (Phase 1 override rules) lives in its
|
|
// carved CEO-phase section.
|
|
//
|
|
// Third recurrence (#497 → #2440 → /ship Step 18): the four ship dispatch
|
|
// sections (Steps 7/8/10/18) never carried the flag and were never pinned, so
|
|
// a backgrounded doc-sync dispatch stranded the ship run waiting on LAST-line
|
|
// JSON that never came. Every synchronous dispatch carrier is pinned here now;
|
|
// add new dispatch sites to this list in the same commit that creates them.
|
|
const GENERATED_WITH_GUIDANCE = [
|
|
'review/sections/review-army.md',
|
|
'autoplan/sections/ceo-phase.md',
|
|
'ship/sections/review-army.md',
|
|
'ship/sections/pr-body.md',
|
|
'ship/sections/test-coverage.md',
|
|
'ship/sections/plan-completion.md',
|
|
'ship/sections/greptile.md',
|
|
// Sweep carriers (v1.79): every remaining synchronous Agent-dispatch site.
|
|
'autoplan/sections/design-phase.md',
|
|
'autoplan/sections/eng-phase.md',
|
|
'autoplan/sections/dx-phase.md',
|
|
// CSO's private startup does not import the shared synchronous-dispatch
|
|
// guidance and its bounded worker policy is specified in its own skeleton.
|
|
'design-consultation/sections/proposal-and-preview.md',
|
|
'design-review/SKILL.md',
|
|
'design-shotgun/SKILL.md',
|
|
'document-release/sections/release-body.md',
|
|
'office-hours/SKILL.md',
|
|
'office-hours/sections/design-and-handoff.md',
|
|
'plan-ceo-review/SKILL.md',
|
|
'plan-ceo-review/sections/review-sections.md',
|
|
'plan-design-review/SKILL.md',
|
|
'plan-devex-review/sections/review-sections.md',
|
|
'plan-eng-review/sections/review-sections.md',
|
|
'review/sections/adversarial.md',
|
|
'ship/sections/adversarial.md',
|
|
];
|
|
|
|
// The inverted, post-2.1.198-inert phrasings. Checked across every generated
|
|
// SKILL.md so the regression can't migrate to another skill unnoticed.
|
|
const INVERTED = /do not use\s+`?run_in_background`?/i;
|
|
|
|
// Both spellings describe the same boolean Agent argument. Keep the key and
|
|
// false token bounded so an unrelated key or quoted/string value cannot pass.
|
|
const EXPLICIT_FOREGROUND = /(?:\brun_in_background\b|"run_in_background")\s*:\s*false\b/;
|
|
function hasForegroundGuidance(content: string): boolean {
|
|
return EXPLICIT_FOREGROUND.test(content) && !INVERTED.test(content);
|
|
}
|
|
|
|
|
|
function allGeneratedSkillFiles(): string[] {
|
|
const out: string[] = [];
|
|
for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
|
|
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
const p = path.join(ROOT, entry.name, 'SKILL.md');
|
|
if (fs.existsSync(p)) out.push(p);
|
|
// Generated on-demand section files (e.g. ship/sections/review-army.md)
|
|
// carry the same resolver output as SKILL.md bodies — scan them too.
|
|
const sections = path.join(ROOT, entry.name, 'sections');
|
|
if (fs.existsSync(sections)) {
|
|
for (const f of fs.readdirSync(sections)) {
|
|
if (f.endsWith('.md')) out.push(path.join(sections, f));
|
|
}
|
|
}
|
|
}
|
|
const rootSkill = path.join(ROOT, 'SKILL.md');
|
|
if (fs.existsSync(rootSkill)) out.push(rootSkill);
|
|
return out;
|
|
}
|
|
|
|
describe('run_in_background guidance (#2440)', () => {
|
|
test('CEO and autoplan wait for actual reviews when the foreground field is unavailable', () => {
|
|
const ceo = fs.readFileSync(path.join(ROOT, 'plan-ceo-review/SKILL.md'), 'utf8');
|
|
const dispatch = ceo.split('**Step 1: Dispatch reviewer subagent**')[1]?.split('**Step 2:')[0] ?? '';
|
|
expect(dispatch).toContain("Read Agent's tool definition");
|
|
expect(dispatch).toContain(CEO_FOREGROUND_BRANCH);
|
|
expect(dispatch).toContain('If the result contains a completed review, consume it');
|
|
expect(dispatch).toContain("If it returns a pending task, use the host's wait tool");
|
|
expect(dispatch).toContain('With no wait tool, end this response and resume on its completion notification');
|
|
expect(dispatch).toContain('While waiting, do not advance, edit either input or launch another reviewer');
|
|
expect(dispatch).toContain('Launch one reviewer with both inputs below');
|
|
const phase = fs.readFileSync(path.join(ROOT, 'autoplan/sections/ceo-phase.md'), 'utf8').replace(/\s+/g, ' ');
|
|
expect(phase).toContain('Step 0 (including its completed Spec Review Loop) → Claude CEO voice → Codex CEO voice → consensus → Review Sections → saved summary → phase announcement');
|
|
for (const name of ['ceo', 'design', 'eng', 'dx']) {
|
|
const next = fs.readFileSync(path.join(ROOT, `autoplan/sections/${name}-phase.md`), 'utf8').replace(/\s+/g, ' ');
|
|
const manifest = next.indexOf(`Read \`snapshot.json\` beside \`<${name.toUpperCase()}_INPUT>\``);
|
|
const native = next.indexOf('Send its `nativeDispatchPrompt` verbatim as the Agent prompt: ONLY/FINAL tool call this response');
|
|
const barrier = next.indexOf('**Native completion barrier:**', native);
|
|
const outside = next.indexOf('voice** (via Bash)', barrier);
|
|
expect(manifest, name).toBeGreaterThan(0);
|
|
expect(native, name).toBeGreaterThan(manifest);
|
|
expect(barrier, name).toBeGreaterThan(native);
|
|
expect(outside, name).toBeGreaterThan(barrier);
|
|
const wait = next.slice(barrier, outside);
|
|
expect(wait, name).toContain('isAsync: true');
|
|
expect(wait, name).toContain('end response immediately: "Waiting for <agent ID>."');
|
|
expect(wait, name).toContain("No further tool calls/review until that ID's terminal notification is delivered");
|
|
expect(wait, name).toContain('Other hosts await that ID');
|
|
expect(wait, name).toContain('Completed-native INPUT must match snapshot phase/hash');
|
|
expect(wait, name).toContain('No inline substitute; apply failure policy');
|
|
}
|
|
});
|
|
|
|
test('recognizes prose and actual JSON false without accepting missing, true, or inverted guidance', () => {
|
|
for (const guidance of [
|
|
'Pass `run_in_background: false` on the Agent call.',
|
|
'Native subagent tool; Claude Code Agent arguments:\n```json\n{ "run_in_background": false }\n```\nSet on the call, not in prompt text.',
|
|
'{\n "run_in_background" :\n false\n}',
|
|
]) expect(hasForegroundGuidance(guidance), guidance).toBe(true);
|
|
for (const guidance of [
|
|
'Dispatch via the Agent tool (foreground).',
|
|
'run_in_background: true',
|
|
'{ "run_in_background": true }',
|
|
'{ "run_in_background": "false" }',
|
|
'{ "run_in_background": null }',
|
|
'other_run_in_background: false',
|
|
'run_in_background: falsehood',
|
|
'Do NOT use `run_in_background`.',
|
|
'Do NOT use run_in_background: false.',
|
|
'Do NOT use `run_in_background`. { "run_in_background": false }',
|
|
]) expect(hasForegroundGuidance(guidance), guidance).toBe(false);
|
|
});
|
|
|
|
test('foreground-required skills instruct run_in_background: false explicitly', () => {
|
|
for (const rel of GENERATED_WITH_GUIDANCE) {
|
|
const content = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
|
|
if (BOUNDED_OUTSIDE_VOICE_SITES.has(rel) && content.includes('Bounded outside-voice wait')) {
|
|
expect(hasBoundedOutsideVoiceWait(content), rel).toBe(true);
|
|
} else if (rel === 'plan-ceo-review/SKILL.md') {
|
|
expect(content).toContain(CEO_FOREGROUND_BRANCH);
|
|
} else expect(hasForegroundGuidance(content), rel).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('consultation awaits independent voices after research and before proposal synthesis', () => {
|
|
const skeleton = fs.readFileSync(path.join(ROOT, 'design-consultation/SKILL.md'), 'utf-8');
|
|
const research = skeleton.indexOf('## Phase 2: Research');
|
|
const requiredRead = skeleton.match(/^> \*\*STOP\.\*\* Before [^\n]*, Read `[^`\n]*\/design-consultation\/sections\/proposal-and-preview\.md` and execute it$/m);
|
|
expect(research).toBeGreaterThan(-1);
|
|
expect(requiredRead).not.toBeNull();
|
|
expect(requiredRead!.index).toBeGreaterThan(research);
|
|
expect(skeleton).not.toContain('## Design Outside Voices (independent)');
|
|
const proposal = fs.readFileSync(path.join(ROOT, 'design-consultation/sections/proposal-and-preview.md'), 'utf8');
|
|
const voices = proposal.indexOf('## Design Outside Voices (independent)');
|
|
const q2 = proposal.indexOf('**AskUserQuestion Q2');
|
|
expect(voices).toBeGreaterThan(proposal.indexOf('Draft your own direction'));
|
|
expect(q2).toBeGreaterThan(voices);
|
|
expect(proposal.slice(voices, q2)).toContain('await both before synthesis');
|
|
expect(proposal.slice(voices, q2)).toContain('Keep your draft direction out of both prompts');
|
|
expect(proposal.slice(voices, q2)).toContain('Include its complete contents in the outside prompt file');
|
|
});
|
|
|
|
// Third recurrence (#497 → #2440 → /ship Step 18): a backgrounded doc-sync
|
|
// dispatch stranded the ship run. Pin the deadline/recovery branch and the
|
|
// docs-sync scope guard in both the generated section and its template, so
|
|
// neither a template edit nor a stale regen can drop them silently.
|
|
const PR_BODY_SITES = ['ship/sections/pr-body.md', 'ship/sections/pr-body.md.tmpl'];
|
|
test('ship pr-body carries the doc-sync deadline recovery + scope guard', () => {
|
|
for (const rel of PR_BODY_SITES) {
|
|
const content = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
|
|
expect(content).toContain('document-release did not complete');
|
|
expect(content).toContain('Scope guard — docs sync ONLY');
|
|
}
|
|
});
|
|
|
|
// The spawned-dispatch contract is as regression-prone as the flag — this
|
|
// class regressed twice via unpinned prose. Pin the document-release
|
|
// contract, the Step 8.4d spawned note, and the resolver-side Codex
|
|
// doc-review skip in both generated output and templates.
|
|
const CONTRACT_PINS: Array<[string[], string]> = [
|
|
[['document-release/SKILL.md', 'document-release/SKILL.md.tmpl'], 'When dispatched as a subagent'],
|
|
[
|
|
['document-release/sections/release-body.md', 'document-release/sections/release-body.md.tmpl'],
|
|
'A spawned run must never change VERSION',
|
|
],
|
|
[['document-release/sections/release-body.md'], 'Spawned-session skip'],
|
|
// Anti-injection trigger + invariant carve-out — the two clauses whose
|
|
// deletion would silently reopen the prompt-injection / silent-VERSION
|
|
// holes while the 'When dispatched' heading pin stays green.
|
|
[['document-release/SKILL.md', 'document-release/SKILL.md.tmpl'], 'NEVER trigger it on their own'],
|
|
// (short form — the sentence wraps across template lines; toContain is literal)
|
|
[['document-release/SKILL.md', 'document-release/SKILL.md.tmpl'], 'The NEVER-do invariants below do'],
|
|
];
|
|
test('document-release carries the spawned-dispatch contract', () => {
|
|
for (const [sites, phrase] of CONTRACT_PINS) {
|
|
for (const rel of sites) {
|
|
const content = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
|
|
expect(content).toContain(phrase);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Structural scanner (4th-recurrence net): GENERATED_WITH_GUIDANCE is a
|
|
// hand-enumerated list — the exact mechanism that missed three recurrences
|
|
// (#497 → #2440 → /ship Step 18, each a NEW dispatch site outside the
|
|
// pinned set). Any generated file that carries an Agent-dispatch imperative
|
|
// (or the inert "(foreground)" prose shape that #2440 proved insufficient)
|
|
// must either state the flag or hold a reasoned exemption below. Same
|
|
// pattern as the egress-receipt new-sink scanner.
|
|
const DISPATCH_IMPERATIVE =
|
|
/(?:via|using) the Agent tool|dispatch(?:es)? (?:a|an|the|one|each|it as a)[^.\n]{0,60}subagent|\(foreground[^)]*\)|foreground Agent tool/i;
|
|
// Reasoned exemptions: files where the match is a reference to a dispatch
|
|
// that lives (flag and all) in another file, not a dispatch spec itself.
|
|
const BACKGROUND_OK: Record<string, string> = {
|
|
'ship/SKILL.md':
|
|
'skeleton anchors reference the Step 18 dispatch by name (carve-guards mustStayInSkeleton); the dispatch spec + flag live in sections/pr-body.md',
|
|
};
|
|
test('structural scanner: every generated dispatch imperative carries the flag', () => {
|
|
for (const file of allGeneratedSkillFiles()) {
|
|
const rel = path.relative(ROOT, file).split(path.sep).join('/');
|
|
if (BACKGROUND_OK[rel]) continue;
|
|
const content = fs.readFileSync(file, 'utf-8');
|
|
const boundedOutsideVoice = BOUNDED_OUTSIDE_VOICE_SITES.has(rel) && hasBoundedOutsideVoiceWait(content);
|
|
const ceoForeground = rel === 'plan-ceo-review/SKILL.md' && content.includes(CEO_FOREGROUND_BRANCH);
|
|
if (DISPATCH_IMPERATIVE.test(content) && !hasForegroundGuidance(content) && !boundedOutsideVoice && !ceoForeground) {
|
|
throw new Error(
|
|
`${rel} contains an Agent-dispatch imperative (or bare "foreground" prose) but never states ` +
|
|
'`run_in_background: false` — pin the flag at the dispatch site or add a reasoned BACKGROUND_OK ' +
|
|
'exemption (see #497/#2440: prose without the explicit flag is inert since Claude Code v2.1.198).',
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('the inverted "do NOT use run_in_background" phrasing never comes back', () => {
|
|
for (const file of allGeneratedSkillFiles()) {
|
|
const content = fs.readFileSync(file, 'utf-8');
|
|
if (INVERTED.test(content)) {
|
|
throw new Error(
|
|
`${path.relative(ROOT, file)} contains the inverted run_in_background guidance — ` +
|
|
'since Claude Code v2.1.198 subagents default to background, so "do not use" is inert; ' +
|
|
'instruct `run_in_background: false` instead (see #2440).',
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|