Files
gstack/test/skill-e2e-autoplan-guard-pty.test.ts
Garry Tanandcapy-ai[bot] a2a6cabfa6 v1.91.134.0 feat(evals): readiness-gated paid dispatch (R1), verdict reuse (R2a/R2), receipt store that restores (#3262)
* feat(evals): readiness-gated paid dispatch (R1), restamp-only gate reuse (R2a), and a receipt store that restores

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

* feat(evals): R2 verdict reuse across main moves; review fixes (newer-run classification, receipt parser, manual acceptance)

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

* fix(evals): R2 own-diff signature covers release-output content and CHANGELOG deletions and preamble

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

* fix(evals): R2 normalises release outputs by stamp pattern only; compares stamp-file modes; unreadable blobs refuse

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

* chore: stamp v1.91.134.0

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

* fix(evals): autoplan-guard-pty actor continues through the phase boundary; name PHASE_BOUNDARY_STOP

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-18 06:03:18 +00:00

80 lines
5.2 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.
/**
* /autoplan publication guard, live (gate tier, rule kind, one trial, haiku).
*
* On the pinned Claude Code, in an interactive PTY session with default
* settings, a project skill carrying /autoplan's own hook block enters Phase 1,
* dispatches the CEO reviewer, reads a prepared close packet, publishes the Phase 1 report in a message
* whose only tool call is `true autoplan-published ceo`, enters Phase 2 in a
* later message and dispatches the Phase 2 reviewer. The reviewer dispatch sends run_in_background, which the
* 2.1.29x default fork-subagent schema strips. Claude Code 2.1.29x can write a tool call to the journal only
* after PreToolUse returns, so this is where the guard's payload path and its
* flush rule meet the real CLI. The outcome is read from the guard decision
* log, never the screen.
*
* The reviewer runs in the background, so its completion notice starts a new
* turn and Claude Code stops applying the skill's allowed tools. Every later
* Read of /autoplan's own files (the close packet and the Phase 2 reviewer's
* input in the project's .gstack/tmp/autoplan/, the installed Phase 2 section)
* must still raise no permission card.
*/
import { expect } from 'bun:test';
import { describeIfSelected, testIfSelected } from './helpers/e2e-helpers';
import { launchClaudePty, selectPtyNumberedOption } from './helpers/claude-pty-runner';
import { closePacketReadAfterNotice, prepareGuardSession, stoppedAtPhaseBoundary } from './helpers/autoplan-guard-pty';
const MODEL = 'claude-haiku-4-5-20251001';
describeIfSelected('autoplan publication guard live PTY', ['autoplan-guard-pty'], () => {
testIfSelected('autoplan-guard-pty', async () => {
if (!process.env.ANTHROPIC_API_KEY) throw new Error('autoplan-guard-pty requires ANTHROPIC_API_KEY; refusing to skip');
const s = prepareGuardSession(process.env.ANTHROPIC_API_KEY);
const session = await launchClaudePty({ permissionMode: null, model: MODEL, cwd: s.project, env: s.env, timeoutMs: 540_000, observeScreen: true });
const started = performance.now();
const prompts: string[] = [];
let phase1Ms: number | null = null;
try {
await Bun.sleep(5_000);
session.send(s.prompt); await Bun.sleep(400); session.send('\r');
const deadline = performance.now() + 480_000;
while (performance.now() < deadline) {
await Bun.sleep(1_000);
const log = s.readLog();
if (phase1Ms === null && log.length) phase1Ms = Math.round(performance.now() - started);
// A permission card is recorded and approved so the run can finish; any card fails the case.
const screen = await session.currentScreen();
if (/Do you want to proceed\?/.test(screen) && /❯\s*1\.\s*Yes/.test(screen)) {
prompts.push(screen);
await selectPtyNumberedOption(session, 1);
await Bun.sleep(1_500);
}
if (log.some(e => e.decision === 'deny') || (log.length >= 4 && s.noticeEvidence().notices >= 2)) break;
// The actor ended its turn at the Phase 1 publication; no input is coming, so it cannot reach Phase 2.
if (log.length < 4 && stoppedAtPhaseBoundary(screen)) break;
if (session.exited()) break;
}
await Bun.sleep(3_000);
const log = s.readLog(), evidence = s.noticeEvidence();
const summary = { prompts: prompts.length, phase1Ms, totalMs: Math.round(performance.now() - started), evidence,
decisions: log.map(e => ({ decision: e.decision, code: e.code, path: e.path, version: e.claude_code_version })) };
console.log(`[autoplan-guard-pty] ${JSON.stringify(summary)}`);
if (log.length < 4 && stoppedAtPhaseBoundary(session.visibleText())) console.log('[autoplan-guard-pty] PHASE_BOUNDARY_STOP: the actor ended its turn after "Phase 1 complete." instead of going on to step 8');
if (log.length !== 4 || log.some(e => e.decision === 'deny') || prompts.length)
console.log(`[autoplan-guard-pty] journal:\n${s.journalSummary().join('\n')}\n[autoplan-guard-pty] screen:\n${session.visibleText().slice(-2_000)}\n[autoplan-guard-pty] last non-blank screen:\n${session.lastScreen?.() ?? ''}`);
for (const prompt of prompts) console.log(`[autoplan-guard-pty] permission card approved:\n${prompt}`);
expect(log.filter(e => e.decision === 'deny')).toEqual([]);
// Phase 1 entry, the CEO reviewer dispatch (Cause A: run_in_background sent, stripped from the payload),
// the Phase 2 transition and the Phase 2 reviewer dispatch: four guarded allows, each verified.
expect(log.map(e => [e.decision, e.disposition])).toEqual(Array(4).fill(['allow', 'allow']));
// The CEO reviewer's notice started a new turn before the close packet, the Phase 2 entry and the Phase 2 dispatch.
expect(closePacketReadAfterNotice(evidence.afterNotice), evidence.afterNotice.join('\n')).toBe(true);
expect(evidence.afterNotice.filter(c => /^Read .*autoplan\/sections\/design-phase\.md$/.test(c))).not.toEqual([]);
expect(evidence.afterNotice).toContain('Agent Design review');
expect(evidence.reviewerReads.filter(f => /\.gstack\/tmp\/autoplan\/autoplan-design-[^/]+\/native-prompt\.md$/.test(f))).not.toEqual([]);
expect(prompts).toEqual([]);
} finally {
await session.close();
s.cleanup();
}
}, 600_000);
});