mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-13 11:57:19 +01:00
fix: preserve planning decisions and terminal evidence
Load installed host reviews at autoplan phase entry and wait for completed reviewers and saved artifacts. Reuse approved remedies while preserving individual finding decisions. Drive interactive evals from the current terminal viewport, bind native questions across scrolling, and require complete native report evidence. Cover captured stale menus, permission lifecycles, setup classification, and disabled-review tool availability with deterministic regressions. Advance release metadata and the upgrade migration to the unclaimed 1.83.0.0 slot.
This commit is contained in:
1 parent
4df031177b
commit
4781852947
53 files changed
+2331
-285
No files matched your search
@@ -68,3 +68,61 @@ describe('autoplan phase order (Eng always last)', () => {
|
||||
expect(ceo).toContain('Final');
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoplan phase execution checkpoints', () => {
|
||||
const tmpl = read('autoplan/SKILL.md.tmpl');
|
||||
const phases = ['ceo', 'design', 'dx', 'eng'];
|
||||
|
||||
test('loads full review skills at phase entry instead of prefetching future phases', () => {
|
||||
const intake = tmpl.split('### Step 3:')[1]?.split('## Phase 0.5:')[0] ?? '';
|
||||
expect(intake).toContain('At the start of each applicable phase');
|
||||
expect(intake).toContain('read its full review SKILL.md');
|
||||
expect(intake).toContain('Do not prefetch future phase sections or review skills');
|
||||
for (const phase of phases) {
|
||||
expect(read(`autoplan/sections/${phase}-phase.md.tmpl`)).toMatch(/^Read \{\{AUTOPLAN_REVIEW_FILE:plan-[a-z-]+\}\} in full now/);
|
||||
}
|
||||
});
|
||||
|
||||
for (const phase of phases) {
|
||||
test(`${phase} places explicit foreground dispatch and its completion barrier before the outside invocation`, () => {
|
||||
const section = read(`autoplan/sections/${phase}-phase.md.tmpl`);
|
||||
const native = section.indexOf(`**{{NATIVE_LABEL}} ${phase === 'design' ? 'design' : phase === 'dx' ? 'DX' : phase === 'ceo' ? 'CEO' : 'eng'} subagent**`);
|
||||
const outside = section.indexOf('{{OUTSIDE_INVOCATION:autoplan}}');
|
||||
expect(native).toBeGreaterThan(-1);
|
||||
expect(native).toBeLessThan(outside);
|
||||
const dispatch = section.slice(native, outside);
|
||||
expect(dispatch).toContain('"run_in_background": false');
|
||||
expect(dispatch).toContain('isAsync: true');
|
||||
expect(dispatch).toContain('wait for that same agent');
|
||||
expect(dispatch).toContain('before outside dispatch or parent review');
|
||||
// Provider preflight, timeout and native fallback remain at every call.
|
||||
expect(section).toContain('Outer tool timeout: 720000ms');
|
||||
expect(section).toContain('Disabled skips the outside invocation; it retains the native pass.');
|
||||
expect(section).toContain(`{{OUTSIDE_PROVENANCE:${phase}}}`);
|
||||
});
|
||||
}
|
||||
|
||||
test('each completed phase announces only after persisted full outputs and settled reviewers', () => {
|
||||
for (const [phase, number] of [['ceo', '1'], ['design', '2'], ['dx', '2.5'], ['eng', '3']]) {
|
||||
const section = read(`autoplan/sections/${phase}-phase.md.tmpl`);
|
||||
const barrier = section.indexOf('**Close this phase before continuing:**');
|
||||
const announcement = section.indexOf(`\n**Phase ${number} complete.**\n`);
|
||||
expect(barrier).toBeGreaterThan(-1);
|
||||
expect(barrier).toBeLessThan(announcement);
|
||||
const checkpoint = section.slice(barrier, announcement);
|
||||
expect(checkpoint).toContain('successful Write/Edit results');
|
||||
expect(checkpoint).toContain('terminal status');
|
||||
expect(checkpoint).toContain('actual assistant message');
|
||||
expect(checkpoint).toContain('Do not announce completion while required work remains');
|
||||
}
|
||||
});
|
||||
|
||||
test('Design hands off to conditional DX and DX never requests a future Eng result', () => {
|
||||
const design = read('autoplan/sections/design-phase.md.tmpl');
|
||||
const dx = read('autoplan/sections/dx-phase.md.tmpl');
|
||||
expect(design).toContain('Passing to Phase 2.5 (DX Review) if DX scope was detected; otherwise Phase 3');
|
||||
expect(design).not.toContain('> Passing to Phase 3.');
|
||||
expect(dx).toContain("Design: <insert Design consensus summary, or 'skipped, no UI scope'>");
|
||||
expect(dx).not.toContain('Eng: <insert Eng consensus summary>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { ALL_HOST_CONFIGS } from '../hosts';
|
||||
import { generateAutoplanReviewFile } from '../scripts/resolvers/composition';
|
||||
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
|
||||
import { E2E_TOUCHFILES } from './helpers/touchfiles';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const REVIEWS = ['plan-ceo-review', 'plan-design-review', 'plan-devex-review', 'plan-eng-review'];
|
||||
let owned: string;
|
||||
let rendered: string;
|
||||
|
||||
function installFile(source: string, destination: string, mode: 'copy' | 'symlink') {
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
if (mode === 'copy') fs.copyFileSync(source, destination);
|
||||
else fs.symlinkSync(source, destination);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
owned = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-autoplan-discovery-'));
|
||||
rendered = path.join(owned, 'rendered');
|
||||
const result = spawnSync(process.execPath, ['run', 'scripts/gen-skill-docs.ts', '--host', 'all', '--out-dir', rendered], {
|
||||
cwd: ROOT, encoding: 'utf8', timeout: 120_000,
|
||||
});
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => { if (owned) fs.rmSync(owned, { recursive: true, force: true }); });
|
||||
|
||||
describe('autoplan reads installed host methodology', () => {
|
||||
for (const host of ALL_HOST_CONFIGS) {
|
||||
for (const mode of (process.platform === 'win32' ? ['copy'] : ['copy', 'symlink']) as Array<'copy' | 'symlink'>) {
|
||||
test(`${host.name} reads its generated review files from ${host.name === 'claude' ? 'the canonical global path' : 'local and global paths'} in ${mode} installations`, () => {
|
||||
const generatedRoot = host.name === 'claude' ? rendered : path.join(rendered, host.hostSubdir, 'skills');
|
||||
const entryName = host.name === 'claude' ? 'autoplan' : 'gstack-autoplan';
|
||||
const generatedEntry = path.join(generatedRoot, entryName, 'SKILL.md');
|
||||
const body = fs.readFileSync(generatedEntry, 'utf8');
|
||||
if (host.name === 'claude') {
|
||||
for (const review of REVIEWS) expect(body).toContain(`~/.claude/skills/gstack/${review}/SKILL.md`);
|
||||
} else {
|
||||
// Read the path actually emitted in the entrypoint. Resolving it from
|
||||
// the installed entrypoint works for copied installs and symlinked ones.
|
||||
const refs = [...body.matchAll(/`(\.\.\/gstack-plan-[a-z-]+\/SKILL\.md)`/g)].map(match => match[1]!);
|
||||
expect([...new Set(refs)].sort()).toEqual(REVIEWS.map(name => `../gstack-${name}/SKILL.md`).sort());
|
||||
for (const review of REVIEWS) expect(body).not.toContain(`$GSTACK_ROOT/${review}/SKILL.md`);
|
||||
expect(body).toContain('same installed skill registry as /autoplan');
|
||||
}
|
||||
const roots = host.name === 'claude'
|
||||
? [path.join(owned, host.name, mode, 'home', host.globalRoot)]
|
||||
: [path.join(owned, host.name, mode, 'repo', path.dirname(host.localSkillRoot)), path.join(owned, host.name, mode, 'home', path.dirname(host.globalRoot))];
|
||||
if (host.name === 'codex') roots.push(path.join(owned, 'codex', mode, 'custom-codex-home', 'skills'));
|
||||
for (const registry of roots) {
|
||||
const entry = path.join(registry, entryName, 'SKILL.md');
|
||||
installFile(generatedEntry, entry, mode);
|
||||
for (const review of REVIEWS) {
|
||||
const reviewName = host.name === 'claude' ? review : `gstack-${review}`;
|
||||
const generatedReview = path.join(generatedRoot, reviewName, 'SKILL.md');
|
||||
installFile(generatedReview, path.join(registry, reviewName, 'SKILL.md'), mode);
|
||||
// A runtime root may be an unrelated checkout. Its old canonical
|
||||
// review file must never be selected instead of the installed host.
|
||||
const stale = path.join(registry, 'gstack', review, 'SKILL.md');
|
||||
fs.mkdirSync(path.dirname(stale), { recursive: true });
|
||||
fs.writeFileSync(stale, 'STALE FOREIGN HARNESS SKILL');
|
||||
const reference = host.name === 'claude' ? `../${review}/SKILL.md` : [...body.matchAll(/`(\.\.\/gstack-plan-[a-z-]+\/SKILL\.md)`/g)].find(match => match[1] === `../gstack-${review}/SKILL.md`)![1]!;
|
||||
const loaded = fs.readFileSync(path.resolve(path.dirname(entry), reference), 'utf8');
|
||||
expect(loaded).toBe(fs.readFileSync(generatedReview, 'utf8'));
|
||||
expect(loaded).not.toContain('STALE FOREIGN HARNESS SKILL');
|
||||
if (host.name === 'codex') expect(loaded).toContain('"outside_provider":"claude-code"');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('host identity, not the model overlay, selects the registry; invalid skills fail closed', () => {
|
||||
for (const host of ALL_HOST_CONFIGS) {
|
||||
const ctx = { skillName: 'autoplan', tmplPath: '', host: host.name, paths: HOST_PATHS[host.name] } as TemplateContext;
|
||||
for (const review of REVIEWS) {
|
||||
expect(generateAutoplanReviewFile({ ...ctx, model: 'gpt' }, [review])).toBe(generateAutoplanReviewFile({ ...ctx, model: 'claude' }, [review]));
|
||||
}
|
||||
expect(() => generateAutoplanReviewFile(ctx, ['../foreign'])).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test('the new discovery contract selects the affected live autoplan workflows', () => {
|
||||
for (const name of ['autoplan-chain-pty', 'autoplan-dual-voice', 'carve-section-loading']) {
|
||||
expect(E2E_TOUCHFILES[name]).toContain('test/autoplan-review-discovery.test.ts');
|
||||
expect(E2E_TOUCHFILES[name]).toContain('scripts/resolvers/composition.ts');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -137,7 +137,7 @@ describe('autoplan routing setup handling', () => {
|
||||
'No thanks, delete the file manually',
|
||||
'No thanks, manual data migration',
|
||||
'No thanks, invoke the deploy manually',
|
||||
'Manual invocation',
|
||||
'Manual deployment invocation',
|
||||
'Accept recommendation',
|
||||
'No thanks, manual invocation then delete CLAUDE.md',
|
||||
]) {
|
||||
@@ -270,3 +270,50 @@ describe('autoplan routing setup handling', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Source-G's retry remained at this actual captured menu until shard timeout.
|
||||
// The action is intact; cumulative ANSI stripping loses the courtesy's 'o'.
|
||||
// A real xterm replay retains it in the prior screen cell.
|
||||
const G_ROUTING_CAPTURE = [
|
||||
'☐Routingrules',
|
||||
"│gstackworksbestwhenyourproject'sCLAUDE.mdincludesskillroutingrules.Wouldyouliketoaddthem?",
|
||||
'❯1.AddroutingrulestoCLAUDE.md',
|
||||
'AppendsstandardskillroutingrulestoCLAUDE.md(creatingitifabsent)andcommits.Meansgstackskillslike',
|
||||
'/autoplan,/ship,/qaetc.getinvokedautomaticallywhenthetaskmatches.(recommended)',
|
||||
"2. N thanks, I'll invokeskillsmanually",
|
||||
'Skiprouting setup. You can re-enable later by removing the routing_declined flag.',
|
||||
'3.Typesomething.',
|
||||
'4.Chataboutthis',
|
||||
'Enter toselect · ↑/↓ to navigate · Esc to cancel',
|
||||
].join('\r');
|
||||
|
||||
describe('autoplan routing action survives courtesy repaint', () => {
|
||||
test('selects the explicit Add action once in the captured G menu, in both orders', () => {
|
||||
const seen = new Set<string>();
|
||||
expect(autoplanRoutingSetupInput(G_ROUTING_CAPTURE, seen)).toBe('1\r');
|
||||
expect(autoplanRoutingSetupInput(G_ROUTING_CAPTURE, seen)).toBeNull();
|
||||
const reversed = G_ROUTING_CAPTURE.replace('❯1.AddroutingrulestoCLAUDE.md', "❯1.N thanks, I'll invokeskillsmanually")
|
||||
.replace("2. N thanks, I'll invokeskillsmanually", '2.AddroutingrulestoCLAUDE.md');
|
||||
expect(autoplanRoutingSetupInput(reversed, new Set())).toBe('2\r');
|
||||
});
|
||||
|
||||
test('the actual manual-invocation action needs no courtesy formula', () => {
|
||||
for (const action of ['Manual invocation', 'Invoke skills manually', "I'll invoke skills manually", 'Thanks, invoke manually']) {
|
||||
expect(autoplanRoutingSetupInput(G_ROUTING_CAPTURE.replace("N thanks, I'll invokeskillsmanually", action), new Set()), action).toBe('1\r');
|
||||
}
|
||||
});
|
||||
|
||||
test('still requires exact opposed setup actions and a genuine routing premise', () => {
|
||||
for (const label of [
|
||||
'N thanks', 'Invoke the deployment manually', 'N thanks, manual data migration',
|
||||
'Delete CLAUDE.md, invoke skills manually', 'No thanks, invoke skills manually then delete CLAUDE.md',
|
||||
'Skip the review, invoke skills manually', 'Skip the review thanks, invoke skills manually',
|
||||
]) expect(autoplanRoutingSetupInput(G_ROUTING_CAPTURE.replace("N thanks, I'll invokeskillsmanually", label), new Set()), label).toBeNull();
|
||||
for (const frame of [
|
||||
G_ROUTING_CAPTURE.replace("gstackworksbestwhenyourproject'sCLAUDE.mdincludesskillroutingrules.Wouldyouliketoaddthem?", 'Which application router should we implement?'),
|
||||
G_ROUTING_CAPTURE.replace('AddroutingrulestoCLAUDE.md', 'AddrutingrulestoCLAUDE.md'),
|
||||
G_ROUTING_CAPTURE.replace('3.Typesomething.', '3.Invoke skills manually'),
|
||||
G_ROUTING_CAPTURE.replace('3.Typesomething.', '3.Add routing rules'),
|
||||
]) expect(autoplanRoutingSetupInput(frame, new Set()), frame).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -112,6 +112,27 @@ describe('CEO mode navigation replay', () => {
|
||||
expect(nextCeoModeNavigation(`${permission}\r${actual}`, 'HOLD SCOPE', seen).kind).toBe('question');
|
||||
});
|
||||
|
||||
test('file permission lifecycle is shared by navigation and posture without becoming an AUQ', () => {
|
||||
const seen = new Set<string>();
|
||||
const permission = 'Do you want to overwrite CLAUDE.md?\n❯1.Yes\n2.No\nEsc to cancel · Tab to amend';
|
||||
const transcript: PlanCountTranscript = { status: 'missing', calls: [], assistantMessages: [] };
|
||||
expect(nextCeoModeNavigation(permission, 'HOLD SCOPE', seen).kind).toBe('permission');
|
||||
expect(nextCeoModeNavigation(permission, 'HOLD SCOPE', seen).kind).toBe('wait');
|
||||
expect(nextCeoPostureContinuation(permission, transcript, 'HOLD SCOPE', 0, seen, true)).toBeNull();
|
||||
const completed = permission + '\n⎿ Added1line\n';
|
||||
expect(nextCeoPostureContinuation(completed, transcript, 'HOLD SCOPE', 0, seen, true)).toBeNull();
|
||||
expect(nextCeoModeNavigation(completed, 'HOLD SCOPE', seen).kind).toBe('wait');
|
||||
const again = completed + permission;
|
||||
expect(nextCeoPostureContinuation(again, transcript, 'HOLD SCOPE', 0, seen, true)).toBe('permission');
|
||||
expect(nextCeoModeNavigation(again, 'HOLD SCOPE', seen).kind).toBe('wait');
|
||||
expect(seen.size).toBe(0);
|
||||
const question = '☐ Approaches\nWhich storage strategy?\n❯1.Server\n2.Local';
|
||||
expect(nextCeoModeNavigation(again + '\n' + question, 'HOLD SCOPE', seen).kind).toBe('question');
|
||||
expect(seen.size).toBe(1);
|
||||
// Different sessions retain independent permission state.
|
||||
expect(nextCeoModeNavigation(permission, 'HOLD SCOPE', new Set()).kind).toBe('permission');
|
||||
});
|
||||
|
||||
test('selects the intended mode from the observed menu, preserving its index', () => {
|
||||
const frame = '☐ReviewMode\rWhat review posture should I use?\r❯1.SELECTIVEEXPANSION(recommended)\r2.HOLDSCOPE\r3.SCOPEEXPANSION\r4.SCOPEREDUCTION';
|
||||
for (const [mode, index] of [['HOLD SCOPE', 2], ['SCOPE EXPANSION', 3]] as const) {
|
||||
|
||||
@@ -166,7 +166,7 @@ describe('Claude wrapper installed-name migration', () => {
|
||||
expect(setup).toContain('export GSTACK_DEFER_CLAUDE_RENAME_PRUNE=1');
|
||||
expect(setup).toContain('unset GSTACK_DEFER_CLAUDE_RENAME_PRUNE');
|
||||
expect(setup).toContain('[ "$n" = "gstack-claude" ]');
|
||||
const version = path.join(ROOT, 'gstack-upgrade/migrations/v1.82.0.0.sh');
|
||||
const version = path.join(ROOT, 'gstack-upgrade/migrations/v1.83.0.0.sh');
|
||||
expect(fs.statSync(version).mode & 0o111).not.toBe(0);
|
||||
expect(fs.readFileSync(version, 'utf8')).toContain('gstack-migrate-claude-code');
|
||||
});
|
||||
|
||||
@@ -38,6 +38,37 @@ describe('disabled outside-plan live oracle', () => {
|
||||
test('requires a real disabled preflight with Agent available and valid completion', () => {
|
||||
expect(oracle(completed(), '').passed).toBe(true);
|
||||
});
|
||||
test('captured native init exposes the requested subagent as Task on Claude Code 2.1.257', () => {
|
||||
const result = completed();
|
||||
// G's actual init canonicalized the requested Agent tool to Task.
|
||||
result.transcript[0] = {
|
||||
type: 'system', subtype: 'init',
|
||||
tools: ['Task', 'Bash', 'Glob', 'Grep', 'Read', 'Write'],
|
||||
model: 'claude-sonnet-4-6', claude_code_version: '2.1.257',
|
||||
};
|
||||
expect(oracle(result)).toMatchObject({ passed: true, completed: true,
|
||||
agentAvailable: true, persistedDisabled: true, fallbackCalls: [] });
|
||||
});
|
||||
test('unknown, malformed and lookalike tools do not establish subagent availability', () => {
|
||||
for (const tools of [undefined, null, 'Agent', {}, [], ['Bash', 'Read'],
|
||||
['AgentTool'], ['TaskCreate', 'TaskList', 'TaskOutput', 'TaskStop'],
|
||||
['agent', 'task'], ['mcp__custom__Agent'], [{ name: 'Agent' }]]) {
|
||||
const result = completed(); result.transcript[0].tools = tools;
|
||||
expect(oracle(result)).toMatchObject({ passed: false, agentAvailable: false });
|
||||
}
|
||||
});
|
||||
test('subagent availability must come from the native system init', () => {
|
||||
for (const init of [
|
||||
{ type: 'assistant', subtype: 'init', tools: ['Task'] },
|
||||
{ type: 'system', subtype: 'other', tools: ['Agent'] },
|
||||
{ type: 'system', subtype: 'init' },
|
||||
]) {
|
||||
const result = completed(); result.transcript[0] = init;
|
||||
expect(oracle(result)).toMatchObject({ passed: false, agentAvailable: false });
|
||||
}
|
||||
const result = completed(); result.transcript.shift();
|
||||
expect(oracle(result)).toMatchObject({ passed: false, agentAvailable: false });
|
||||
});
|
||||
test('claimed disabled status without matching successful tool evidence cannot pass', () => {
|
||||
for (const mutate of [
|
||||
(r: ReturnType<typeof completed>) => { r.transcript.splice(1, 2); },
|
||||
@@ -67,9 +98,11 @@ describe('disabled outside-plan live oracle', () => {
|
||||
}
|
||||
});
|
||||
test('Agent/Task fallback dispatch fails even when the parent reports disabled', () => {
|
||||
for (const tool of ['Agent', 'Task']) {
|
||||
const result = completed(); result.transcript.splice(-1, 0, dispatch(tool, { prompt: 'Review the plan independently' }));
|
||||
for (const available of ['Agent', 'Task']) for (const tool of ['Agent', 'Task']) {
|
||||
const result = completed(); result.transcript[0].tools = ['Bash', 'Read', available];
|
||||
result.transcript.splice(-1, 0, dispatch(tool, { prompt: 'Review the plan independently' }));
|
||||
const evidence = oracle(result, '');
|
||||
expect(evidence.agentAvailable).toBe(true);
|
||||
expect(evidence.passed).toBe(false);
|
||||
expect(evidence.fallbackCalls).toHaveLength(1);
|
||||
}
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"source": "Captured source-G synthetic eval session,2026-09-08; actual prior viewport plus following VT redraw.",
|
||||
"cols": 120,
|
||||
"rows": 40,
|
||||
"initial": [
|
||||
"",
|
||||
" ▐▛███▛█ Claude Code v2.1.257",
|
||||
"▝▜██████▀ Sonnet 4.6 · API Usage Billing",
|
||||
" ▝▝ ▝▝ /tmp/gstack-paid-shard-W6Y9pi/tmp/gstack-autoplan-chain-n4IM84",
|
||||
"",
|
||||
"",
|
||||
"❯ /autoplan",
|
||||
"",
|
||||
"● Starting the autoplan pipeline — running the preamble first.",
|
||||
"",
|
||||
" Ran 1 shell command",
|
||||
"",
|
||||
"● I have the preamble. There's a one-time routing instruction to handle, and I need to understand the repo before",
|
||||
" proceeding. Let me run context recovery and explore the repo structure in parallel.",
|
||||
"",
|
||||
" Listed 1 directory, ran 2 shell commands",
|
||||
"",
|
||||
"● There's a plan file to review. Let me read it along with the README.",
|
||||
"",
|
||||
" Read 2 files",
|
||||
"",
|
||||
"● Good — there's a plan at .claude/plans/ui-heavy-feature.md (User Dashboard page) to review. Before starting the",
|
||||
" pipeline, I need to handle the routing-injection onboarding step from the preamble.",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"· Philosophising… (1m 32s · ↓ 3.5k tokens)",
|
||||
" ⎿ Tip: Use /btw to ask a quick side question without interrupting Claude's current work",
|
||||
" ✔ Update installed · Restart to update",
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
|
||||
"❯ ",
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
|
||||
" ⏸ plan mode on (shift+tab to cycle) · esc to interrupt · ← for agents"
|
||||
],
|
||||
"cursor": {
|
||||
"x": 2,
|
||||
"y": 37
|
||||
},
|
||||
"update": "\u001b[H\u001b[2G▐▛███▛█\u001b[12GClaude\u001b[19GCode\u001b[24Gv2.1.257\r\u001b[1B▝▜█\u001b[7G█\u001b[9G▀\u001b[12GSonnet\u001b[19G4.6 · API Usage\u001b[35GBilling\r\u001b[1B ▝▝ ▝▝ \u001b[12G/tmp/gstack-paid-sh\u001b[32Grd-W6Y9pi/tmp/gstack-autoplan-chain-n4IM84\r\u001b[2C\u001b[1B\u001b[K\r\u001b[2B❯\u001b[3G/autoplan\r\u001b[1B\u001b[K\r\u001b[1B●\u001b[3GStarting\u001b[12Gthe\u001b[16Gautoplan\u001b[25Gpipeline\u001b[34G—\u001b[36Grunning\u001b[44Gthe\u001b[48Gpreamble\u001b[57Gfirst.\r\u001b[1B\u001b[K\r\u001b[2C\u001b[1BRan\u001b[7G1\u001b[9Gshell\u001b[15Gcommand\r\u001b[2C\u001b[1B\u001b[K\r\u001b[1B●\u001b[3GI\u001b[5Ghave\u001b[10Gthe\u001b[14Gpreamble.\u001b[24GThere's\u001b[32Ga\u001b[34Gone-time\u001b[43Grouting\u001b[51Ginstruction\u001b[63Gto\u001b[66Ghandle,\u001b[74Gand\u001b[78GI\u001b[80Gneed\u001b[85Gto\u001b[88Gunderstand\u001b[99Gthe\u001b[103Grepo\u001b[108Gbefore\r\u001b[1B \u001b[3Gproce\u001b[9Gding. L\u001b[17Gt me run context\u001b[34Grecovery\u001b[43Gand explore the repo structure\u001b[74Gi\u001b[76G parallel.\u001b[K\r\u001b[2C\u001b[1B\u001b[K\r\u001b[2C\u001b[1BListed\u001b[10G1\u001b[12Gdirectory,\u001b[23Gran\u001b[27G2\u001b[29Gshell\u001b[35Gcommands\r\u001b[2C\u001b[1B\u001b[K\r\u001b[1B●\u001b[3GThere's\u001b[11Ga\u001b[13Gplan\u001b[18Gfile\u001b[23Gto\u001b[26Greview.\u001b[34GLet\u001b[38Gme\u001b[41Gread\u001b[46Git\u001b[49Galong\u001b[55Gwith\u001b[60Gthe\u001b[64GREADME.\r\u001b[1B\u001b[K\r\u001b[2C\u001b[1BRead\u001b[8G2\u001b[10Gfiles\r\u001b[2C\u001b[1B\u001b[K\r\u001b[1B●\u001b[3GGood\u001b[8G—\u001b[10Gthere's\u001b[18Ga\u001b[20Gplan\u001b[25Gat\u001b[28G.claude/plans/ui-heavy-feature.md\u001b[62G(User\u001b[68GDashboard\u001b[78Gpage)\u001b[84Gto\u001b[87Greview.\u001b[95GBefore\u001b[102Gstarting\u001b[111Gthe\r\u001b[1B \u001b[3Gpipeline, I need\u001b[20Gto handle the routi\u001b[40Gg-injection onboarding st\u001b[66Gp\u001b[68Gfrom the preamble.\u001b[87G\u001b[K\r\u001b[1B────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r\u001b[1BPlanning:\r\u001b[1B/tmp/gstack-paid-shard-W6Y9pi/tmp/gstack-hermetic-3071829-QCsZIh/with-skills/.claude/plans/quirky-tumbling-sky.md\r\u001b[1B────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r\u001b[1C\u001b[1B☐\u001b[4GRouting\u001b[12Grules\r\u001b[2B│\u001b[3Ggstack\u001b[10Gworks\u001b[16Gbest\u001b[21Gwhen\u001b[26Gyour\u001b[31Gproject's\u001b[41GCLAUDE.md\u001b[51Gincludes\u001b[60Gskill\u001b[66Grouting\u001b[74Grules.\u001b[81GWould\u001b[87Gyou\u001b[91Glike\u001b[96Gto\u001b[99Gadd\u001b[103Gthem?\r\u001b[2B❯\u001b[3G1.\u001b[6GAdd\u001b[10Grouting\u001b[18Grules\u001b[24Gto\u001b[27GCLAUDE.md\r\u001b[5C\u001b[1BAppends\u001b[14Gstandard\u001b[23Gskill\u001b[29Grouting\u001b[37Grules\u001b[43Gto\u001b[46GCLAUDE.md\u001b[56G(creating\u001b[66Git\u001b[69Gif\u001b[72Gabsent)\u001b[80Gand\u001b[84Gcommits.\u001b[93GMeans\u001b[99Ggstack\u001b[106Gskills\u001b[113Glike\r\u001b[5C\u001b[1B/autoplan,\u001b[17G/ship,\u001b[24G/qa\u001b[28Getc.\u001b[33Gget\u001b[37Ginvoked\u001b[45Gautomatically\u001b[59Gwhen\u001b[64Gthe\u001b[68Gtask\u001b[73Gmatches.\u001b[82G(recommended)\r\u001b[1B \u001b[3G2. N\u001b[8G thanks, I'll invoke\u001b[29Gskills\u001b[36Gmanually\r\u001b[2C\u001b[1B \u001b[5G Skip\u001b[11Grouting setup. You can re-enabl\u001b[43G later by removing the routing_\u001b[76Gclined flag.\u001b[K\r\u001b[2C\u001b[1B3.\u001b[6GType\u001b[11Gsomething.\u001b[81G\u001b[K\r\u001b[2B 4.\u001b[6GChat\u001b[11Gabout\u001b[17Gthis\r\u001b[1B\u001b[K\r\u001b[1BEnter to\u001b[10Gsel\u001b[14Gct · ↑/↓ \u001b[24Go navigate · Esc\u001b[41Gto cancel\u001b[K\u001b[40;1H\u001b[31;1H",
|
||||
"expected": [
|
||||
" ▐▛███▛█ Claude Code v2.1.257",
|
||||
"▝▜██████▀ Sonnet 4.6 · API Usage Billing",
|
||||
" ▝▝ ▝▝ /tmp/gstack-paid-shard-W6Y9pi/tmp/gstack-autoplan-chain-n4IM84",
|
||||
"",
|
||||
"",
|
||||
"❯ /autoplan",
|
||||
"",
|
||||
"● Starting the autoplan pipeline — running the preamble first.",
|
||||
"",
|
||||
" Ran 1 shell command",
|
||||
" ",
|
||||
"● I have the preamble. There's a one-time routing instruction to handle, and I need to understand the repo before",
|
||||
" proceeding. Let me run context recovery and explore the repo structure in parallel.",
|
||||
"",
|
||||
" Listed 1 directory, ran 2 shell commands",
|
||||
" ",
|
||||
"● There's a plan file to review. Let me read it along with the README.",
|
||||
"",
|
||||
" Read 2 files",
|
||||
" ",
|
||||
"● Good — there's a plan at .claude/plans/ui-heavy-feature.md (User Dashboard page) to review. Before starting the",
|
||||
" pipeline, I need to handle the routing-injection onboarding step from the preamble.",
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
|
||||
"Planning:",
|
||||
"/tmp/gstack-paid-shard-W6Y9pi/tmp/gstack-hermetic-3071829-QCsZIh/with-skills/.claude/plans/quirky-tumbling-sky.md",
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
|
||||
" ☐ Routing rules",
|
||||
"",
|
||||
"│ gstack works best when your project's CLAUDE.md includes skill routing rules. Would you like to add them?",
|
||||
"",
|
||||
"❯ 1. Add routing rules to CLAUDE.md",
|
||||
" Appends standard skill routing rules to CLAUDE.md (creating it if absent) and commits. Means gstack skills like",
|
||||
" /autoplan, /ship, /qa etc. get invoked automatically when the task matches. (recommended)",
|
||||
" 2. No thanks, I'll invoke skills manually",
|
||||
" Skip routing setup. You can re-enable later by removing the routing_declined flag.",
|
||||
" 3. Type something.",
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
|
||||
" 4. Chat about this",
|
||||
"",
|
||||
"Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
]
|
||||
}
|
||||
Vendored
+95
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"source": "Captured source-G synthetic eval session,2026-09-08; actual prior viewport plus following VT redraw.",
|
||||
"cols": 120,
|
||||
"rows": 40,
|
||||
"initial": [
|
||||
"",
|
||||
"❯ /plan-design-review ",
|
||||
" ",
|
||||
" Ran 1 shell command",
|
||||
"",
|
||||
"● Scope gate: plan mode active — auto-selecting B (reviewing PLAN.md in the working directory).",
|
||||
"",
|
||||
" Skipping routing injection — this is a review fixture repo. Now running the pre-review audit.",
|
||||
" ",
|
||||
" Read 2 files, ran 1 shell command",
|
||||
"",
|
||||
"● Now let me read the review sections file as required by the skill.",
|
||||
"",
|
||||
" Read 1 file, ran 1 shell command",
|
||||
" ",
|
||||
"● No prior learnings found. Cross-project config is not set — will ask once I have the user's attention.",
|
||||
"",
|
||||
" Pre-Review System Audit:",
|
||||
" - DESIGN.md: exists and comprehensive (spacing, typography, color, interaction patterns all specified)",
|
||||
" - UI scope: APP UI classifier — account-settings form with Profile + Notifications sections",
|
||||
" - 5 gaps explicitly listed in the plan; all have corresponding spec in DESIGN.md",
|
||||
" - No prior design reviews in the review log",
|
||||
" - No TODOS.md exists",
|
||||
"",
|
||||
" Step 0 Assessment:",
|
||||
"",
|
||||
" Initial score: 5/10. The plan correctly identifies 5 real gaps and preserves strong accessibility and responsive specs",
|
||||
" inherited from the existing behavior description. A 10/10 would specify HOW each gap is resolved (concrete component",
|
||||
" changes, token values, before/after layout), not just name the problem. The DESIGN.md already has all correct values;",
|
||||
" the plan just needs to reference them.",
|
||||
"",
|
||||
" Writing the initial plan file now before asking Step 0D.",
|
||||
"",
|
||||
"✽ Undulating… (1m 59s · ↓ 5.4k tokens)",
|
||||
" ⎿ Tip: Use /btw to ask a quick side question without interrupting Claude's current work",
|
||||
" ✔ Update installed · Restart to update",
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
|
||||
"❯ ",
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
|
||||
" ⏸ plan mode on (shift+tab to cycle) · esc to interrupt · ← for agents"
|
||||
],
|
||||
"cursor": {
|
||||
"x": 2,
|
||||
"y": 37
|
||||
},
|
||||
"update": "\u001b[H\u001b[3G14\u001b[6G-\u001b[8G**Initial\u001b[18Gscore:**\u001b[27G5/10\u001b[32G—\u001b[34Gplan\u001b[39Gcorrectly\u001b[49Gnames\u001b[55G5\u001b[57Ggaps\u001b[62Gbut\u001b[66Gdoes\u001b[71Gnot\u001b[75Gspecify\u001b[83GHOW\u001b[87Geach\u001b[92Gis\u001b[95Gresolved.\r\u001b[1B \u001b[3G15\u001b[K\r\u001b[2C\u001b[1B16\u001b[6G##\u001b[9GStep\u001b[14G0:\u001b[17GDesign\u001b[24GScope\u001b[30GAssessment\r\u001b[2C\u001b[1B17\u001b[K\r\u001b[2C\u001b[1B18\u001b[6G**Initial\u001b[16Grating:\u001b[24G5/10**\r\u001b[1B \u001b[3G19\u001b[K\r\u001b[2C\u001b[1B20\u001b[6GThe\u001b[10Gplan\u001b[15Gearns\u001b[21Ga\u001b[23G5\u001b[25Gbecause:\r\u001b[2C\u001b[1B21 - It correctly id\u001b[24Gn\u001b[27Gfies\u001b[32G5 real,\u001b[40Gspecific\u001b[49Ggaps (Visual Hiera\u001b[68Gchy, Spacing, Color, Typography,\u001b[101GMotion)\r\u001b[2C\u001b[1B22\u001b[6G-\u001b[8GIt\u001b[11Gpreserves\u001b[21Gstrong\u001b[28Gaccessibility\u001b[42Gand\u001b[46Gresponsive\u001b[57Gspecs\u001b[63Gfrom\u001b[68Gthe\u001b[72Gexisting\u001b[81Gbehavior\u001b[90Gdescription\r\u001b[2C\u001b[1B23 -\u001b[8GDESIGN.md exist\u001b[24G and\u001b[29Gsupplies\u001b[38Gcorrect\u001b[46Gvalues\u001b[53Gfor\u001b[57Gall\u001b[61G5\u001b[63Ggaps\r\u001b[2C\u001b[1B24\r\u001b[1B \u001b[3G25 A 10/10\u001b[14Gwould:\u001b[K\r\u001b[2C\u001b[1B26\u001b[6G-\u001b[8GSpecify\u001b[16Gthe\u001b[20Gexact\u001b[26Gchange\u001b[33Gfor\u001b[37Geach\u001b[42Ggap\u001b[46G(concrete\u001b[56Gtoken,\u001b[63Gbefore\u001b[70G→\u001b[72Gafter)\r\u001b[2C\u001b[1B27 -\u001b[8GAdd inte\u001b[18Gction state table\u001b[36G(loading,\u001b[46Gempty,\u001b[53Gerror,\u001b[60Gsuccess,\u001b[69Gpartial)\r\u001b[2C\u001b[1B28\u001b[6G-\u001b[8GStoryboard\u001b[19Gthe\u001b[23Guser\u001b[28Gjourney\r\u001b[1B \u001b[3G29\u001b[6G- Call out which DESIGN.md tokens each fix applies\u001b[57G\u001b[K\r\u001b[2C\u001b[1B30\u001b[6G-\u001b[8GResolve\u001b[16Gall\u001b[20Gimplementation\u001b[35Gambiguities\u001b[47Gbefore\u001b[54Ga\u001b[56Gdeveloper\u001b[66Gpicks\u001b[72Git\u001b[75Gup\r\u001b[2C\u001b[1B31\u001b[K\r\u001b[2C\u001b[1B32 **DESIGN.md status:** Pres\u001b[33Gnt and detailed. Spacing scale, c\u001b[67Glor system, typography,\u001b[91Ginteract\u001b[100Gon patterns,\u001b[113Gand\u001b[117Gc\r\u001b[2C\u001b[1B \u001b[5G omponent\u001b[15Gbehavior \u001b[25Gre all specified. This review cal\u001b[59Gbrates against it.\u001b[K\r\u001b[2C\u001b[1B33\u001b[K\r\u001b[2C\u001b[1B34 **Ex\u001b[11Gsting design l\u001b[26Gverage:**\u001b[36G\u001b[K\r\u001b[2C\u001b[1B35 -\u001b[8GButton, Field, InlineStatus,\u001b[37GErrorSummary,\u001b[51GConfirmationDialog\u001b[70Gcomponents\u001b[81Greused\u001b[88Gas-is\r\u001b[2C\u001b[1B36\u001b[6G-\u001b[8GFocus\u001b[14Gring:\u001b[20G2px\u001b[24Gsolid\u001b[30G#1d4ed8,\u001b[39Goffset\u001b[46G2px\u001b[50G(already\u001b[59Gspecified)\r\u001b[2C\u001b[1B37 -\u001b[8G8px ba\u001b[15Ge spacing\u001b[25Gscale\u001b[31Galready\u001b[39Gestablished\r\u001b[2C\u001b[1B38\r\u001b[2C\u001b[1B39 ---\u001b[K\r\u001b[2C\u001b[1B40\u001b[K\r\u001b[2C\u001b[1B41 ## Review Passes\u001b[K\r\u001b[2C\u001b[1B42\u001b[K\r\u001b[2C\u001b[1B43\u001b[6G*(Populated\u001b[18Gprogressively\u001b[32Gas\u001b[35Gthe\u001b[39Greview\u001b[46Gproceeds)*\r\u001b[2C\u001b[1B44\u001b[K\r\u001b[1B╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌\r\u001b[1B Do you wa\u001b[12Gt to create gstack-test-plan-design.md?\r\u001b[1C\u001b[1B❯ 1. Yes\u001b[11G\u001b[K\r\u001b[3C\u001b[1B2.\u001b[7GYes,\u001b[12Gand\u001b[16Gswitch\u001b[23Gto\u001b[26Gaccept\u001b[33Gedits\u001b[39G(auto-approve\u001b[53Gfile\u001b[58Gedits\u001b[64Gand\u001b[68Gcommon\u001b[75Gfile\u001b[80Gcommands)\u001b[90Gfor this session; Yes,\u001b[113Gan\u001b[116G\u001b[K\r\u001b[1B always allow access to /tmp/gstack-paid-shard-jPAIoN/tmp/gstack-e2e-plan-design-ga2LJt for this session\u001b[K\r\u001b[1B \u001b[4G3.\u001b[7GNohift+tab)\r\u001b[1B\u001b[K\r\u001b[1C\u001b[1BEsc to canc\u001b[14Gl ·\u001b[18GTab to amend\u001b[K\u001b[40;1H\u001b[35;2H",
|
||||
"completed": "\u001b(B\u000f\u001b[?1000h\u001b[?1002h\u001b[?1003h\u001b[?1006h\u001b]0;◐ Claude Code\u0007\u001b[H\u001b[3G\u001b[K\r\u001b[2C\u001b[1BRan\u001b[7G1\u001b[9Gshell\u001b[15Gcommand\r\u001b[2C\u001b[1B\u001b[K\r\u001b[1B●\u001b[3GScope\u001b[9Ggate:\u001b[15Gplan\u001b[20Gmode\u001b[25Gactive\u001b[32G—\u001b[34Gauto-selecting\u001b[49GB\u001b[51G(reviewing\u001b[62GPLAN.md\u001b[70Gin\u001b[73Gthe\u001b[77Gworking\u001b[85Gdirectory).\r\u001b[2C\u001b[1B\u001b[K\r\u001b[2C\u001b[1BSkipping\u001b[12Grouting\u001b[20Ginjection\u001b[30G—\u001b[32Gthis\u001b[37Gis\u001b[40Ga\u001b[42Greview\u001b[49Gfixture\u001b[57Grepo.\u001b[63GNow\u001b[67Grunning\u001b[75Gthe\u001b[79Gpre-review\u001b[90Gaudit.\r\u001b[2C\u001b[1B\u001b[K\r\u001b[2C\u001b[1BRead\u001b[8G2 files, ran\u001b[21G1 shell command\u001b[K\r\u001b[2C\u001b[1B\u001b[K\r\u001b[1B●\u001b[3GNow let me read the review sect\u001b[35Gons fil\u001b[43G as required by the\u001b[63Gskill.\r\u001b[2C\u001b[1B\u001b[K\r\u001b[2C\u001b[1BRead\u001b[9G file, ran 1\u001b[22Gshell\u001b[28Gcommand\r\u001b[2C\u001b[1B\u001b[K\r\u001b[1B●\u001b[3GNo\u001b[6Gprior\u001b[12Glearnings found. Cross-project config is\u001b[53Gnot set — will ask once I\u001b[79Ghave\u001b[84Gthe\u001b[88Guser's\u001b[95Gattention.\r\u001b[2C\u001b[1B\u001b[K\r\u001b[2C\u001b[1BPre\u001b[7GReview System Audit:\u001b[K\r\u001b[2C\u001b[1B- DESIGN.md:\u001b[16Gexists and comprehensive (spacing, typography, color, interaction\u001b[82Gpatterns\u001b[91Gall\u001b[95Gspecified)\r\u001b[2C\u001b[1B- UI\u001b[8Gscope:\u001b[15GAPP\u001b[19GUI\u001b[22Gclassifier\u001b[33G—\u001b[35Gaccount-settings\u001b[52Gform\u001b[57Gwith\u001b[62GProfile\u001b[70G+\u001b[72GNotifications\u001b[86Gsections\r\u001b[2C\u001b[1B- 5 gaps explici\u001b[20Gly listed in the plan; \u001b[44Gl\u001b[46G have corresponding spec\u001b[71Gin DESIGN.md\u001b[K\r\u001b[2C\u001b[1B-\u001b[5GN\u001b[7G \u001b[9Grior design reviews in\u001b[32Gth\u001b[35G review\u001b[43Glog\u001b[K\r\u001b[2C\u001b[1B- No\u001b[8GTODOS.md\u001b[17Gexists\r\u001b[2C\u001b[1B\u001b[K\r\u001b[2C\u001b[1BStep\u001b[8G0 Assessment:\u001b[K\r\u001b[2C\u001b[1B\u001b[K\r\u001b[2C\u001b[1BInitial scor\u001b[16G: 5/10. The plan correctly identifies\u001b[54G5\u001b[56Greal\u001b[61Ggaps\u001b[66Gand\u001b[70Gpreserves\u001b[80Gstrong\u001b[87Gaccessibility\u001b[101Gand\u001b[105Gresponsive\u001b[116Gspecs\r\u001b[2C\u001b[1Binherited\u001b[13Gfrom\u001b[18Gthe\u001b[22Gexisting\u001b[31Gbehavior\u001b[40Gdescription.\u001b[53GA\u001b[55G10/10\u001b[61Gwould\u001b[67Gspecify\u001b[75GHOW\u001b[79Geach\u001b[84Ggap\u001b[88Gis\u001b[91Gresolved\u001b[100G(concrete\u001b[110Gcomponent\r\u001b[2C\u001b[1Bchanges,\u001b[12Gtoken\u001b[18Gvalues,\u001b[26Gbefore/after\u001b[39Glayout),\u001b[48Gnot\u001b[52Gjust\u001b[57Gname\u001b[62Gthe\u001b[66Gproblem.\u001b[75GThe\u001b[79GDESIGN.md\u001b[89Galready\u001b[97Ghas\u001b[101Gall\u001b[105Gcorrect\u001b[113Gvalues;\r\u001b[2C\u001b[1Bthe\u001b[7Gplan\u001b[12Gjust\u001b[17Gneeds\u001b[23Gto\u001b[26Greference\u001b[36Gthem.\r\u001b[2C\u001b[1B\u001b[K\r\u001b[2C\u001b[1BWriting\u001b[11Gthe\u001b[15Ginitial\u001b[23Gplan\u001b[28Gfile\u001b[33Gnow\u001b[37Gbefore\u001b[44Gasking\u001b[51GStep\u001b[56G0D.\r\u001b[2C\u001b[1B\u001b[K\r\u001b[1B●\u001b[3GWrite(/tmp/gstack-paid-shard-jPAIoN/tmp/gstack-e2e-plan-design-ga2LJt/gstack-test-plan-design.md)\r\u001b[1B\u001b[K\r\u001b[1B✢ Undulati\u001b[12Gg… (2m 0s ·\u001b[24G↓ 5.4\u001b[30G \u001b[32Gokens)\u001b[K\r\u001b[1C\u001b[1B ⎿ Tip:\u001b[11GUse\u001b[15G/btw\u001b[20Gto\u001b[23Gask\u001b[27Ga\u001b[29Gquick\u001b[35Gside\u001b[40Gquestion\u001b[49Gwithout\u001b[57Ginterrupting\u001b[70GClaude's\u001b[79Gcurrent\u001b[87Gwork\r\u001b[3C\u001b[1B \u001b[7G \u001b[12G \u001b[16G \u001b[23G \u001b[26G \u001b[33G \u001b[39G \u001b[53G \u001b[58G \u001b[64G \u001b[68G \u001b[75G \u001b[80G \u001b[90G \u001b[94G \u001b[99G ● high · /effort\r\u001b[1B────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r\u001b[1B❯ \u001b[4G\u001b[K\r\u001b[1B────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r\u001b[1C\u001b[1B ⏸ plan mod\u001b[14G on\u001b[18G(shift+tab to\u001b[32Gcycle)\u001b[39G·\u001b[41Gesc\u001b[45Gto\u001b[48Ginterrupt\uLine truncated
|
||||
"expected": [
|
||||
" 14 - **Initial score:** 5/10 — plan correctly names 5 gaps but does not specify HOW each is resolved.",
|
||||
" 15",
|
||||
" 16 ## Step 0: Design Scope Assessment",
|
||||
" 17",
|
||||
" 18 **Initial rating: 5/10**",
|
||||
" 19",
|
||||
" 20 The plan earns a 5 because:",
|
||||
" 21 - It correctly identifies 5 real, specific gaps (Visual Hierarchy, Spacing, Color, Typography, Motion)",
|
||||
" 22 - It preserves strong accessibility and responsive specs from the existing behavior description",
|
||||
" 23 - DESIGN.md exists and supplies correct values for all 5 gaps",
|
||||
" 24",
|
||||
" 25 A 10/10 would:",
|
||||
" 26 - Specify the exact change for each gap (concrete token, before → after)",
|
||||
" 27 - Add interaction state table (loading, empty, error, success, partial)",
|
||||
" 28 - Storyboard the user journey",
|
||||
" 29 - Call out which DESIGN.md tokens each fix applies ",
|
||||
" 30 - Resolve all implementation ambiguities before a developer picks it up",
|
||||
" 31",
|
||||
" 32 **DESIGN.md status:** Present and detailed. Spacing scale, color system, typography, interaction patterns, and c",
|
||||
" omponent behavior are all specified. This review calibrates against it.",
|
||||
" 33",
|
||||
" 34 **Existing design leverage:**",
|
||||
" 35 - Button, Field, InlineStatus, ErrorSummary, ConfirmationDialog components reused as-is",
|
||||
" 36 - Focus ring: 2px solid #1d4ed8, offset 2px (already specified)",
|
||||
" 37 - 8px base spacing scale already established",
|
||||
" 38",
|
||||
" 39 ---",
|
||||
" 40",
|
||||
" 41 ## Review Passes",
|
||||
" 42",
|
||||
" 43 *(Populated progressively as the review proceeds)*",
|
||||
" 44",
|
||||
"╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌",
|
||||
" Do you want to create gstack-test-plan-design.md?",
|
||||
" ❯ 1. Yes",
|
||||
" 2. Yes, and switch to accept edits (auto-approve file edits and common file commands) for this session; Yes, and",
|
||||
" always allow access to /tmp/gstack-paid-shard-jPAIoN/tmp/gstack-e2e-plan-design-ga2LJt for this session",
|
||||
" 3. Nohift+tab)",
|
||||
"",
|
||||
" Esc to cancel · Tab to amend"
|
||||
]
|
||||
}
|
||||
@@ -22,6 +22,12 @@ export function autoplanRoutingSetupInput(visible: string, seen: Set<string>): s
|
||||
// migration, deletion, or unrelated workflow is not the opposed action.
|
||||
const decline = options.filter(option => {
|
||||
const title = option.title.replace(/\(Recommended\)$/i, '');
|
||||
// The action can stand alone or follow a short courtesy ('No thanks').
|
||||
// Cursor redraws can damage that courtesy while leaving 'invoke skills
|
||||
// manually' intact. Match the complete action, not the spelling of No;
|
||||
// arbitrary preceding instructions and extra trailing actions still fail.
|
||||
const manual = title.replace(/^[a-z]{0,3}thanks[,—–-]/i, '');
|
||||
if (/^(?:manualinvocation|(?:I['’]ll)?invoke(?:skills)?manually)$/i.test(manual)) return true;
|
||||
const prefix = /^(?:Nothanks|Skip)(?:[,—–-])?/i.exec(title);
|
||||
if (!prefix) return false;
|
||||
// 'No thanks' can be followed by the same explicit Skip action. Strip
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/** Match CEO mode labels after the PTY capture strips cursor-spacing escapes. */
|
||||
import {
|
||||
capturePlanCountQuestion,
|
||||
createPlanCountPermissionGuard,
|
||||
parseQuestionPrompt,
|
||||
parseNumberedOptions,
|
||||
auqFingerprint,
|
||||
classifyPlanCountFrame,
|
||||
isNumberedOptionListVisible,
|
||||
planCountSubmissionInput,
|
||||
@@ -42,14 +46,37 @@ type ModeNavigationAction =
|
||||
| { kind: 'question'; question: AskUserQuestionFingerprint }
|
||||
| { kind: 'mode'; index: number; question: AskUserQuestionFingerprint };
|
||||
|
||||
// Permission state follows the navigation session without entering its AUQ
|
||||
// dedup set. A file-tool result can reopen an identical permission prompt.
|
||||
const permissionStates = new WeakMap<Set<string>, {
|
||||
file: ReturnType<typeof createPlanCountPermissionGuard>;
|
||||
other: Set<string>;
|
||||
}>();
|
||||
function ceoPermissionAction(visible: string, seenQuestions: Set<string>): 'grant' | 'handled' | null {
|
||||
let state = permissionStates.get(seenQuestions);
|
||||
if (!state) {
|
||||
state = { file: createPlanCountPermissionGuard(), other: new Set() };
|
||||
permissionStates.set(seenQuestions, state);
|
||||
}
|
||||
const file = state.file(visible);
|
||||
if (file !== null) return file;
|
||||
if (classifyPlanCountFrame(visible) !== 'permission') return null;
|
||||
const signature = auqFingerprint(parseQuestionPrompt(visible), parseNumberedOptions(visible));
|
||||
if (state.other.has(signature)) return 'handled';
|
||||
state.other.add(signature);
|
||||
return 'grant';
|
||||
}
|
||||
|
||||
/** Handle native controls before deduping actual navigation questions. */
|
||||
export function nextCeoModeNavigation(
|
||||
visible: string,
|
||||
targetMode: CeoMode,
|
||||
seenQuestions: Set<string>,
|
||||
): ModeNavigationAction {
|
||||
const permission = ceoPermissionAction(visible, seenQuestions);
|
||||
if (permission !== null) return permission === 'grant'
|
||||
? { kind: 'permission', input: '1\r' } : { kind: 'wait' };
|
||||
const frame = classifyPlanCountFrame(visible);
|
||||
if (frame === 'permission') return { kind: 'permission', input: '1\r' };
|
||||
const submission = frame === null ? planCountSubmissionInput(visible) : null;
|
||||
if (submission !== null) return { kind: 'submission', input: submission };
|
||||
if (!isNumberedOptionListVisible(visible)) return { kind: 'wait' };
|
||||
@@ -150,9 +177,8 @@ export function nextCeoPostureContinuation(
|
||||
seenQuestions: Set<string>,
|
||||
alreadyContinued: boolean,
|
||||
): 'permission' | 'question' | null {
|
||||
if (classifyPlanCountFrame(visible) === 'permission') {
|
||||
return capturePlanCountQuestion(visible, seenQuestions, 0, true) ? 'permission' : null;
|
||||
}
|
||||
const permission = ceoPermissionAction(visible, seenQuestions);
|
||||
if (permission !== null) return permission === 'grant' ? 'permission' : null;
|
||||
if (alreadyContinued || !nativeCeoModeAnswer(transcript, targetMode, selectionStartedAt)) return null;
|
||||
const action = nextCeoModeNavigation(visible, targetMode, seenQuestions);
|
||||
return action.kind === 'question' ? 'question' : null;
|
||||
|
||||
@@ -32,6 +32,7 @@ import { createPlanCountFixture } from './plan-count-fixture';
|
||||
import { createPlanCountSnapshotWriter } from './plan-count-artifacts';
|
||||
import { readPlanCountTranscript, unresolvedPlanQuestionCalls, type NativePlanQuestionCall, type PlanCountTranscript } from './plan-count-transcript';
|
||||
import { trustDialogInput } from './pty-trust-dialog';
|
||||
import { createPtyScreen } from './pty-screen';
|
||||
|
||||
/** Strip ANSI escapes for pattern-matching against visible text. */
|
||||
export function stripAnsi(s: string): string {
|
||||
@@ -95,6 +96,8 @@ export interface ClaudePtyOptions {
|
||||
/** Terminal size. Default 120x40. Plan-mode UI lays out cleanly at this size. */
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
/** Opt in only when input targeting needs the actual VT viewport. */
|
||||
observeScreen?: boolean;
|
||||
/** Working directory. Default: process.cwd(). The repo cwd has the gstack
|
||||
* skill registry and trusted-folder cookie, so most tests want this. */
|
||||
cwd?: string;
|
||||
@@ -113,6 +116,8 @@ export interface ClaudePtySession {
|
||||
rawOutput(): string;
|
||||
/** Visible (ANSI-stripped) output for the entire session. For pattern matching. */
|
||||
visibleText(): string;
|
||||
/** Flush the opted-in terminal parser and return only its current viewport. */
|
||||
currentScreen(): Promise<string>;
|
||||
/**
|
||||
* Mark the current buffer position. Subsequent waitForAny / visibleSince
|
||||
* calls only look at output AFTER this mark. Use to scope assertions to
|
||||
@@ -1226,6 +1231,82 @@ export function auqFingerprint(
|
||||
return (Bun as any).hash(normalized + '||' + sig).toString(16);
|
||||
}
|
||||
|
||||
/** Permission and question capture inspect the same cursor-anchored window. */
|
||||
function planCountPermissionMenu(visible: string): {
|
||||
normalized: string; cursorAt: number; prompt: string; menu: string;
|
||||
} | null {
|
||||
const normalized = stripPtyResidue(visible).replace(/\r+\n?/g, '\n');
|
||||
const start = Math.max(0, normalized.length - 4096);
|
||||
const cursor = [...normalized.slice(start).matchAll(/❯\s*1\./g)].at(-1);
|
||||
if (!cursor) return null;
|
||||
const cursorAt = start + cursor.index;
|
||||
const before = normalized.slice(start, cursorAt);
|
||||
const header = [...before.matchAll(/(?:^|\n)[\t │┃]*[☐□]([^\n│]*)/g)].at(-1);
|
||||
const menu = normalized.slice(cursorAt);
|
||||
// The native question panel has its own header and navigation footer.
|
||||
// Its actual finding may discuss file creation or permission policy.
|
||||
if (header && !/❯\s*[1-9]\./.test(before.slice(header.index)) &&
|
||||
/Enter\s*to\s*select\s*·\s*↑\/↓\s*to\s*navigate\s*·\s*Esc\s*to\s*cancel/i.test(menu)) return null;
|
||||
// Anchor redraw identity to its question line; prior tool output and
|
||||
// the preceding menu's footer must not change a permission signature.
|
||||
const prompt = [...before.matchAll(/^[^\n]*\?[^\n]*$/gm)].at(-1)?.[0].trim()
|
||||
?? parseQuestionPrompt(normalized);
|
||||
if (!prompt || !isPermissionDialogVisible(prompt + '\n' + menu)) return null;
|
||||
return { normalized, cursorAt, prompt, menu };
|
||||
}
|
||||
|
||||
/** A scrolled question must retain its entire visible suffix, choices and footer. */
|
||||
function matchesClippedNativeQuestion(visible: string, call: NativePlanQuestionCall): boolean {
|
||||
const cursor = [...visible.matchAll(/❯\s*1\./g)].at(-1);
|
||||
if (!cursor) return false;
|
||||
const before = visible.slice(0, cursor.index);
|
||||
// This is the top of the actual viewport, not a selected historical
|
||||
// snippet. A header, preceding menu or blank top is not clipped identity.
|
||||
if (!before.split('\n')[0]?.trim() || /[☐□❯]/.test(before)) return false;
|
||||
const suffix = before.replace(/^[ \t]*[│┃] ?|[│┃][ \t]*$/gm, '').trim();
|
||||
const exact = (value: string) => value.replace(/\s+/g, '');
|
||||
const question = call.questions[0]!;
|
||||
const native = exact(question.question);
|
||||
const displayed = exact(suffix);
|
||||
// Require substantial positive question text, including all visible
|
||||
// pre-menu lines. Shared option labels or a generic short tail cannot
|
||||
// borrow an unrelated pending call's routing policy.
|
||||
if (suffix.split('\n').filter(line => line.trim()).length < 2 || displayed.length < 160 ||
|
||||
displayed.length > native.length || !native.endsWith(displayed)) return false;
|
||||
const menu = visible.slice(cursor.index);
|
||||
const footer = /Enter\s*to\s*select\s*·\s*↑\/↓\s*to\s*navigate\s*·\s*Esc\s*to\s*cancel/i.exec(menu);
|
||||
if (!footer || !/^[\s│┃─━└┘]*$/.test(menu.slice(footer.index + footer[0].length))) return false;
|
||||
const options = parseNumberedOptions(visible);
|
||||
return options.length === question.options.length && options.every((option, index) =>
|
||||
option.index === index + 1 && exact(option.label) === exact(question.options[index]!.label));
|
||||
}
|
||||
|
||||
/** Match native question identity before permission text can choose an answer. */
|
||||
export function matchesNativePlanQuestion(visible: string, call: NativePlanQuestionCall): boolean {
|
||||
if (call.failed || call.questions.length !== 1) return false;
|
||||
const normalized = stripPtyResidue(visible).replace(/\r+\n?/g, '\n');
|
||||
const tail = normalized.slice(-4096);
|
||||
const cursor = [...tail.matchAll(/❯\s*1\./g)].at(-1);
|
||||
if (!cursor) return false;
|
||||
const before = tail.slice(0, cursor.index);
|
||||
const header = [...before.matchAll(/(?:^|\n)[\t │┃]*[☐□]([^\n│]*)/g)].at(-1);
|
||||
const compact = (value: string) => value.replace(/\s+/g, '').toLowerCase();
|
||||
const question = call.questions[0]!;
|
||||
if (!header) return matchesClippedNativeQuestion(normalized, call);
|
||||
if (compact(header[1]!) !== compact(question.header) || /❯\s*[1-9]\./.test(before.slice(header.index))) return false;
|
||||
const identity = question.question.match(/<gstack-qid:[^>]+>/i)?.[0] ?? question.question;
|
||||
if (!compact(before.slice(header.index)).includes(compact(identity))) return false;
|
||||
// Preserve the captured damaged-option path when the native panel's
|
||||
// complete footer is intact. With a damaged footer, require the full
|
||||
// question and every offered label instead of guessing from keywords.
|
||||
if (/Enter\s*to\s*select\s*·\s*↑\/↓\s*to\s*navigate\s*·\s*Esc\s*to\s*cancel/i.test(tail.slice(cursor.index))) return true;
|
||||
const displayed = before.slice(header.index).replace(/^[\t ─━┄┅┈┉┌┐└┘├┤┬┴┼│┃☐□■]+/gm, '');
|
||||
const options = parseNumberedOptions(visible);
|
||||
return compact(displayed) === compact(`${question.header} ${question.question}`) &&
|
||||
options.length === question.options.length && options.every((option, index) =>
|
||||
option.index === index + 1 && compact(option.label) === compact(question.options[index]!.label));
|
||||
}
|
||||
|
||||
/** Capture each distinct question once, including questions sharing a menu. */
|
||||
export function capturePlanCountQuestion(
|
||||
visible: string,
|
||||
@@ -1241,36 +1322,25 @@ export function capturePlanCountQuestion(
|
||||
// parser's still-visible historical question and queue spurious input.
|
||||
if (!cursor) return null;
|
||||
|
||||
if (pending && !pending.answered && !pending.failed && pending.questions.length === 1) {
|
||||
const beforeCursor = tail.slice(0, cursor.index);
|
||||
const header = [...beforeCursor.matchAll(/(?:^|\n)[\t │┃]*[☐□]([^\n│]*)/g)].at(-1);
|
||||
const compact = (value: string) => value.replace(/\s+/g, '').toLowerCase();
|
||||
const question = pending.questions[0]!;
|
||||
const questionId = question.question.match(/<gstack-qid:[^>]+>/i)?.[0];
|
||||
const identity = questionId ?? question.question;
|
||||
const footer = /Enter\s*to\s*select\s*·\s*↑\/↓\s*to\s*navigate\s*·\s*Esc\s*to\s*cancel/i;
|
||||
// A captured B menu lost the dot in option 2. The native call supplies
|
||||
// its actual choices, but only after the matching boxed question,
|
||||
// cursor, and complete navigation footer establish the active UI.
|
||||
if (header && compact(header[1]!) === compact(question.header) &&
|
||||
!/❯\s*[1-9]\./.test(beforeCursor.slice(header.index)) &&
|
||||
compact(beforeCursor.slice(header.index)).includes(compact(identity)) &&
|
||||
footer.test(tail.slice(cursor.index))) {
|
||||
const fp = nativePlanCallFingerprint(pending, observedAtMs, preReview);
|
||||
const renderedOptions = parseNumberedOptions(visible);
|
||||
const renderedPrompt = parseQuestionPrompt(visible);
|
||||
const renderedSignature = renderedOptions.length >= 2 && renderedPrompt
|
||||
? auqFingerprint(renderedPrompt, renderedOptions) : null;
|
||||
const alreadyHandled = seen.has(fp.signature) || Boolean(renderedSignature && seen.has(renderedSignature));
|
||||
// Once answered, the same menu may linger while the native call is
|
||||
// no longer pending, or the native record may arrive after the UI
|
||||
// answer. Bind both identities before testing either direction.
|
||||
if (renderedSignature) seen.add(renderedSignature);
|
||||
seen.add(fp.signature);
|
||||
if (alreadyHandled) return null;
|
||||
return fp;
|
||||
}
|
||||
if (pending && !pending.answered && !pending.failed && matchesNativePlanQuestion(visible, pending)) {
|
||||
const fp = nativePlanCallFingerprint(pending, observedAtMs, preReview);
|
||||
const renderedOptions = parseNumberedOptions(visible);
|
||||
const renderedPrompt = parseQuestionPrompt(visible);
|
||||
const renderedSignature = renderedOptions.length >= 2 && renderedPrompt
|
||||
? auqFingerprint(renderedPrompt, renderedOptions) : null;
|
||||
const alreadyHandled = seen.has(fp.signature) || Boolean(renderedSignature && seen.has(renderedSignature));
|
||||
// Once answered, the same menu may linger while the native call is
|
||||
// no longer pending, or the native record may arrive after the UI
|
||||
// answer. Bind both identities before testing either direction.
|
||||
if (renderedSignature) seen.add(renderedSignature);
|
||||
seen.add(fp.signature);
|
||||
if (alreadyHandled) return null;
|
||||
return fp;
|
||||
}
|
||||
// A permission cursor can outlive the short frame-classifier window.
|
||||
// Never turn it into an ordinary prompt answer, even when its question
|
||||
// was damaged by a redraw or its original grant was not observed.
|
||||
if (planCountPermissionMenu(visible)) return null;
|
||||
const options = parseNumberedOptions(visible);
|
||||
if (options.length < 2) return null;
|
||||
const promptSnippet = parseQuestionPrompt(visible);
|
||||
@@ -1287,23 +1357,28 @@ export function capturePlanCountQuestion(
|
||||
* Its text is not a new answer request. The same file can ask again after
|
||||
* a successful native Write/Edit result, so content-only dedup is too broad.
|
||||
*/
|
||||
export function createPlanCountPermissionGuard(): (visible: string) => 'grant' | 'handled' | null {
|
||||
export function createPlanCountPermissionGuard(): (visible: string, completionHistory?: string) => 'grant' | 'handled' | null {
|
||||
let granted: { signature: string; completedAt: number } | undefined;
|
||||
return (visible) => {
|
||||
const normalized = stripPtyResidue(visible).replace(/\r+\n?/g, '\n');
|
||||
const start = Math.max(0, normalized.length - 4096);
|
||||
const cursor = [...normalized.slice(start).matchAll(/❯\s*1\./g)].at(-1);
|
||||
if (!cursor) return null;
|
||||
const cursorAt = start + cursor.index;
|
||||
const question = [...normalized.slice(start, cursorAt).matchAll(/^[\t │]*(Do\s*you\s*want\s*to\s*(?:create|overwrite|edit|make\s*this\s*edit\s*to)[^\n?]*\?)/gim)].at(-1)?.[1];
|
||||
if (!question || !isPermissionDialogVisible(question + '\n' + normalized.slice(cursorAt))) return null;
|
||||
const signature = question.replace(/\s+/g, '');
|
||||
return (visible, completionHistory = visible) => {
|
||||
// Only the current viewport can establish an actionable permission.
|
||||
// Historical file results release a later identical grant, never a menu.
|
||||
const candidate = planCountPermissionMenu(visible);
|
||||
if (!candidate) return null;
|
||||
const { normalized, cursorAt, prompt, menu } = candidate;
|
||||
// File permission identity comes from the action or native approval
|
||||
// choices, not a brittle exact rendering of "Do you want to ...".
|
||||
// Other permission kinds retain the existing caller policy.
|
||||
if (!/(?:create|overwrite|edit)/i.test(prompt.replace(/\s+/g, '')) &&
|
||||
!/(?:acceptedits|allowalledits|auto-approvefileedits)/i.test(menu.replace(/\s+/g, ''))) return null;
|
||||
const signature = prompt.replace(/\s+/g, '');
|
||||
|
||||
// Match actual native file-tool results, not proposed diff rows, tool
|
||||
// headers, or tips. A diff repaint after the menu is still pending.
|
||||
const completed = [...normalized.matchAll(/^[\t ]*⎿[\t \u00a0]*(?:Wrote\s*\d+\s*lines?|Added\s*\d+\s*lines?|Removed\s*\d+\s*lines?|Updated\b|Edited\b)/gm)].at(-1);
|
||||
const completedAt = completed?.index ?? -1;
|
||||
if (completedAt > cursorAt) return 'handled';
|
||||
const completionPattern = /^[\t ]*⎿[\t \u00a0]*(?:Wrote\s*\d+\s*lines?|Added\s*\d+\s*lines?|Removed\s*\d+\s*lines?|Updated\b|Edited\b)/gm;
|
||||
const visibleCompletedAt = [...normalized.matchAll(completionPattern)].at(-1)?.index ?? -1;
|
||||
if (visibleCompletedAt > cursorAt) return 'handled';
|
||||
const history = stripPtyResidue(completionHistory).replace(/\r+\n?/g, '\n');
|
||||
const completedAt = [...history.matchAll(completionPattern)].at(-1)?.index ?? -1;
|
||||
if (granted?.signature === signature && completedAt <= granted.completedAt) return 'handled';
|
||||
granted = { signature, completedAt };
|
||||
return 'grant';
|
||||
@@ -1348,6 +1423,7 @@ export function classifyPlanCountFrame(
|
||||
const permissionTail = visible.slice(Math.max(visible.length - TAIL_SCAN_BYTES, activeQuestionStart, 0));
|
||||
if (isNumberedOptionListVisible(visible) &&
|
||||
isPermissionDialogVisible(permissionTail) &&
|
||||
planCountPermissionMenu(visible) &&
|
||||
(!report || report.index <= menuCursor)) {
|
||||
return 'permission';
|
||||
}
|
||||
@@ -1462,10 +1538,15 @@ export function hasNativePlanCompletion(
|
||||
lastLine !== `Plan written to: ${expectedPlanPath}`) return false;
|
||||
const prose = text.replace(/```[\s\S]*?```|`[^`]*`/g, '');
|
||||
if (/\?|\b(?:awaiting|waiting for|please (?:choose|answer|confirm))\b/i.test(prose)) return false;
|
||||
return hasCompletePlanReport(expectedPlanPath, Math.max(startedAt, ...answerTimes), finishedAt);
|
||||
}
|
||||
|
||||
function hasCompletePlanReport(expectedPlanPath: string, minimumMtime: number, maximumMtime: number): boolean {
|
||||
if (!path.isAbsolute(expectedPlanPath)) return false;
|
||||
try {
|
||||
const stat = fs.lstatSync(expectedPlanPath);
|
||||
if (!stat.isFile() || stat.size > 2 * 1024 * 1024 ||
|
||||
stat.mtimeMs < Math.max(startedAt, ...answerTimes) || stat.mtimeMs > finishedAt) return false;
|
||||
stat.mtimeMs < minimumMtime || stat.mtimeMs > maximumMtime) return false;
|
||||
// A template/example inside Markdown code is not the completed report.
|
||||
// Fences open with 3+ identical backticks or tildes, indented at most
|
||||
// three spaces. A close uses the same marker, at least the opening
|
||||
@@ -1491,11 +1572,101 @@ export function hasNativePlanCompletion(
|
||||
if (!assertReviewReportAtBottom(content).ok) return false;
|
||||
const report = content.slice(content.indexOf('## GSTACK REVIEW REPORT'));
|
||||
// Reject a provisional heading; retain both clean and issues-open reports.
|
||||
return /^\|[^\n]*Review[^\n]*\|/m.test(report) &&
|
||||
/^(?:\*\*)?VERDICT:/m.test(report) && /(?:NO )?UNRESOLVED DECISIONS/.test(report);
|
||||
const rows = report.split('\n');
|
||||
const table = rows.findIndex(line => /^\|[^\n]*Review[^\n]*\|/.test(line));
|
||||
const completeTable = table >= 0 && /^\|[ :|-]+\|$/.test(rows[table + 1] ?? '') &&
|
||||
/^\|.*[a-zA-Z].*\|$/.test(rows[table + 2] ?? '');
|
||||
const decisions = rows.findIndex(line => /^\*\*UNRESOLVED DECISIONS:\*\*$/.test(line));
|
||||
const trailing = rows.slice(decisions + 1).filter(line => line.trim());
|
||||
const closed = rows.filter(line => line.trim()).at(-1) === 'NO UNRESOLVED DECISIONS' ||
|
||||
(decisions >= 0 && trailing.length > 0 && trailing.every(line => /^[-*] \S|^\+ \d+ unresolved from prior reviews$/.test(line)));
|
||||
return completeTable && /^(?:[-*] )?(?:\*\*)?VERDICT:(?:\*\*)?[ \t]*[A-Za-z]/m.test(report) && closed;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/** Native report completion is independent of the terminal's streamed headings. */
|
||||
export function hasNativePlanTerminal(
|
||||
transcript: PlanCountTranscript,
|
||||
expectedPlanPath: string,
|
||||
startedAt: number,
|
||||
frame: 'completion_summary' | 'plan_ready',
|
||||
administrativeCalls: ReadonlySet<string> = new Set(),
|
||||
): boolean {
|
||||
if (transcript.status !== 'ready' || !transcript.calls.length ||
|
||||
transcript.calls.some(c => (!c.answered && !c.failed) || (c.answered && c.failed)) ||
|
||||
unresolvedPlanQuestionCalls(transcript.calls).length) return false;
|
||||
const sessions = new Set([...transcript.calls.map(c => c.sessionId),
|
||||
...transcript.assistantMessages.map(m => m.sessionId),
|
||||
...(transcript.planReadyRequests ?? []).map(r => r.sessionId)]);
|
||||
if (sessions.size !== 1) return false;
|
||||
const answered = transcript.calls.filter(c => c.answered);
|
||||
if (!answered.length) return false;
|
||||
const answerTimes = answered.map(c => Date.parse(c.answeredAt ?? ''));
|
||||
if (!answerTimes.every(Number.isFinite)) return false;
|
||||
const latestAnswer = Math.max(...answerTimes);
|
||||
const latestReady = [...(transcript.planReadyRequests ?? [])]
|
||||
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)).at(-1);
|
||||
if (latestReady?.failed) return false;
|
||||
const modifyingAnswers = answered.filter(c =>
|
||||
!administrativeCalls.has(`${c.sessionId}:${c.toolUseId}`) && !isCompletedDxHandoff(c))
|
||||
.map(c => Date.parse(c.answeredAt!));
|
||||
if (!modifyingAnswers.length || !hasCompletePlanReport(expectedPlanPath,
|
||||
Math.max(startedAt, ...modifyingAnswers), Date.now())) return false;
|
||||
|
||||
if (frame === 'plan_ready') {
|
||||
// A real pending ExitPlanMode call is the approval gate. Do not answer
|
||||
// it or mistake its failed tool result for a completed review.
|
||||
return Boolean(latestReady && Date.parse(latestReady.timestamp) > latestAnswer &&
|
||||
Date.parse(latestReady.timestamp) <= Date.now());
|
||||
}
|
||||
const final = [...transcript.assistantMessages]
|
||||
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)).at(-1);
|
||||
if (!final || !Number.isFinite(Date.parse(final.timestamp)) || Date.parse(final.timestamp) <= latestAnswer) return false;
|
||||
const text = final.text.trim();
|
||||
// Only finished, fixture-scoped native prose supplies this evidence. A
|
||||
// rendered heading, quoted example, proposed write, or pending question
|
||||
// cannot substitute for an actual completion message.
|
||||
if (/^(?:>|`{3,}|~{3,}|(?:example|sample|template|quoted)\b)/i.test(text) ||
|
||||
/\b(?:cannot|can't|unable to)\s+(?:complete|finish)|\b(?:awaiting|waiting for|please (?:choose|answer|confirm))\b/i.test(text) ||
|
||||
text.endsWith('?')) return false;
|
||||
if (/^(?:CEO|Eng(?:ineering)?|Design|DX) review complete[.!](?:\s|$)/i.test(text)) return true;
|
||||
// A full native summary can precede the final report write; the independent
|
||||
// fresh-file check above prevents the observed heading-before-Write race.
|
||||
const lines: string[] = [];
|
||||
let fence: { marker: string; length: number } | undefined;
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const delimiter = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
||||
if (delimiter) {
|
||||
if (!fence) fence = { marker: delimiter[1]![0]!, length: delimiter[1]!.length };
|
||||
else if (delimiter[1]![0] === fence.marker && delimiter[1]!.length >= fence.length && !delimiter[2]!.trim()) fence = undefined;
|
||||
continue;
|
||||
}
|
||||
if (!fence && !/^(?: {4}|\t| {0,3}>)/.test(line)) lines.push(line);
|
||||
}
|
||||
if (fence) return false;
|
||||
const summary = lines.findIndex(line => /^(?:#{1,6}\s*)?(?:\*\*)?Completion\s+summary(?:\*\*)?\s*:?[ \t]*$/i.test(line.trim()));
|
||||
const preceding = lines.slice(0, summary).filter(line => line.trim()).at(-1) ?? '';
|
||||
if (summary < 0 || /\b(?:example|sample|template|quote|emit|print)\b.*[::]\s*$/i.test(preceding)) return false;
|
||||
const rows = lines.slice(summary + 1).filter(line => /^\s*(?:[-*] |\|)/.test(line));
|
||||
return rows.length > 0 && rows.some(line => /\b(?:review|issues?|findings?|gaps?|resolved|scope)\b/i.test(line));
|
||||
}
|
||||
|
||||
/** G's post-report DX next-step menu changes orchestration, not plan decisions. */
|
||||
function isCompletedDxHandoff(call: NativePlanQuestionCall): boolean {
|
||||
if (!call.answered || call.failed || call.questions.length !== 1 ||
|
||||
!Array.isArray(call.unansweredQuestionIndices) || call.unansweredQuestionIndices.length) return false;
|
||||
const q = call.questions[0]!;
|
||||
if (q.multiSelect || !/^next\s+steps?$/i.test(q.header.trim()) ||
|
||||
!/^DX review (?:done|complete)[.!]/i.test(q.question) ||
|
||||
!/<gstack-qid:devex-next-steps>/.test(q.question)) return false;
|
||||
const labels = q.options.map(o => o.label.trim().replace(/\s*\(recommended\)\s*$/i, ''));
|
||||
const allowed = (label: string) => /^(?:Run \/plan-eng-review|Ready to implement|Skip, handle manually)$/.test(label);
|
||||
return labels.every(allowed) && labels.includes('Run /plan-eng-review') && labels.includes('Skip, handle manually') &&
|
||||
q.options.some(o => o.label === call.answers?.[q.question]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Test helper: if `obs.planFile` was set, read it and assert
|
||||
* `## GSTACK REVIEW REPORT` is the last `## ` section. Throws on
|
||||
@@ -1573,28 +1744,42 @@ export const ceoFirstReviewAUQ: Step0BoundaryPredicate = (fp) =>
|
||||
/\bfinding\b|\bmissing\b|\bambiguous\b|\bundefined\b|doesn['’]t\s+(?:define|specify|cover|mention)/i.test(title);
|
||||
}) ?? false;
|
||||
|
||||
/** Native setup identity comes from answered question IDs/actions, not display wording. */
|
||||
/** Native setup needs an answered scope decision, not a particular model-chosen qid. */
|
||||
export const engSetupAUQ: Step0BoundaryPredicate = (fp) => {
|
||||
const call = fp.nativeCall;
|
||||
if (!call?.answered || call.failed) return false;
|
||||
const answered = call.questions.filter(q => Boolean(call.answers?.[q.question]));
|
||||
// A mixed packet containing an answered finding is not wholly setup.
|
||||
return answered.length > 0 && answered.every(q => {
|
||||
if (!q.options.some(option => option.label === call.answers?.[q.question])) return false;
|
||||
const actions = q.options.map(option => option.label.trim().replace(/^[A-Z][.)]\s+/i, ''));
|
||||
const id = /<gstack-qid:\s*([a-z0-9-]+)\s*>/i.exec(q.question)?.[1]?.toLowerCase();
|
||||
const body = q.question.replace(/<gstack-qid:[^>]*>/gi, '').trim()
|
||||
.replace(/^D\s*\d+\s*[—–:-]\s*/i, '');
|
||||
// A finding about one component or a TODO remains substantive even if
|
||||
// it cites the plan's size or offers to reduce that individual issue.
|
||||
if (/\b(?:issue|finding|gap|TODO)\b/i.test(q.header) ||
|
||||
/^(?:(?:architecture|code quality|test|performance|security)\s+)?(?:issue|finding|gap|TODO)\b/i.test(body)) return false;
|
||||
const learningPremise = /\bcross[- ]project\s+(?:learnings|lessons)\b|\b(?:learnings|lessons)\b[^.!?]{0,100}\b(?:other|all)\s+(?:projects|repositories)\b/i.test(body);
|
||||
const crossProject = id === 'cross-project-learnings' || id === 'preamble-cross-project-learnings' ||
|
||||
(!id && /^cross[- ]project$/i.test(q.header.trim()));
|
||||
(!id && /^cross[- ]project$/i.test(q.header.trim())) || learningPremise;
|
||||
if (crossProject) {
|
||||
return q.options.some((enabled, i) => /^enable\s+cross[- ]project\b/i.test(enabled.label.trim()) &&
|
||||
q.options.some((scoped, j) => j !== i && /\bproject[- ]scoped\b/i.test(scoped.label)));
|
||||
return actions.some((enabled, i) =>
|
||||
(/^enable\s+cross[- ]project\b/i.test(enabled) ||
|
||||
(learningPremise && /^enable(?:\s*\(recommended\))?$/i.test(enabled))) &&
|
||||
actions.some((scoped, j) => j !== i && /\bproject[- ]scoped\b/i.test(scoped)));
|
||||
}
|
||||
const scopeComplexity = id === 'plan-eng-scope-complexity' || id === 'plan-eng-review-scope-reduce' ||
|
||||
// Older native captures omitted qids. Require whole-plan class/file scope
|
||||
// and its opposed scope actions, never an individual implementation issue.
|
||||
(!id && /\bplan\b/i.test(q.question) && /\bclasses\b/i.test(q.question) && /\bfiles\b/i.test(q.question));
|
||||
// The skill's complexity gate concerns the whole plan and a concrete
|
||||
// file/class/service count. Its qid and header can vary across native runs.
|
||||
const scopeHeading = /^scope(?:\s+(?:challenge|reduction|complexity))?\s*:/i.test(body);
|
||||
const wholePlanStatement = /(?:^|\n)(?:ELI10:\s*)?(?:this|the|whole|entire)\s+plan\s+(?:touches|spans|covers|changes|introduces|involves)\b/i.test(body);
|
||||
const wholePlanScope = (/^(?:(?:scope(?:\s+(?:challenge|reduction|complexity))?|complexity\s+check)\s*:\s*)?(?:this|the|whole|entire)\s+plan\s+(?:touches|spans|covers|changes|introduces|involves)\b/i.test(body) ||
|
||||
(scopeHeading && wholePlanStatement)) && /\b\d+\s+(?:new\s+)?(?:files|classes|services)\b/i.test(body);
|
||||
const scopeComplexity = id === 'plan-eng-scope-complexity' || id === 'plan-eng-review-scope-reduce' || wholePlanScope;
|
||||
return scopeComplexity &&
|
||||
q.options.some((proceed, i) =>
|
||||
(/^proceed\s+as[- ]is\b/i.test(proceed.label.trim()) || /^accept\b.*\bdesign\b.*\bfocus\b.*\bquality\b/i.test(proceed.label.trim())) &&
|
||||
q.options.some((reduce, j) => j !== i && /^(?:reduce\b|flag\s+scope\s+reduction\b)/i.test(reduce.label.trim())));
|
||||
actions.some((proceed, i) =>
|
||||
(/^proceed\s+as[- ]is\b/i.test(proceed) || /^accept\b.*\bdesign\b.*\bfocus\b.*\bquality\b/i.test(proceed)) &&
|
||||
actions.some((reduce, j) => j !== i && /^(?:reduce\b|flag\s+scope\s+reduction\b)/i.test(reduce)));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1711,29 +1896,40 @@ export async function launchClaudePty(
|
||||
}
|
||||
}
|
||||
|
||||
// Construction must succeed before any CLI can be spawned.
|
||||
const screen = opts.observeScreen ? await createPtyScreen(cols, rows) : undefined;
|
||||
let screenClosing: Promise<void> | undefined;
|
||||
let screenFailure: unknown;
|
||||
const disposeScreen = () => screenClosing ??= (screen?.dispose() ?? Promise.resolve()).catch(error => { screenFailure = error; });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const proc = (Bun as any).spawn([claudePath, ...args], {
|
||||
let proc: any;
|
||||
try { proc = (Bun as any).spawn([claudePath, ...args], {
|
||||
terminal: {
|
||||
cols,
|
||||
rows,
|
||||
data(_t: unknown, chunk: Buffer) {
|
||||
buffer += chunk.toString('utf-8');
|
||||
const text = chunk.toString('utf-8');
|
||||
buffer += text;
|
||||
if (screen && !screenClosing) screen.write(text);
|
||||
},
|
||||
},
|
||||
cwd,
|
||||
env: childEnv,
|
||||
});
|
||||
}); } catch (error) { await disposeScreen(); throw error; }
|
||||
|
||||
// Track exit so waitForAny can fail fast if claude crashes.
|
||||
let exitedPromise: Promise<void> = Promise.resolve();
|
||||
if (proc.exited && typeof proc.exited.then === 'function') {
|
||||
exitedPromise = proc.exited
|
||||
.then((code: number | null) => {
|
||||
.then(async (code: number | null) => {
|
||||
exitCodeCaptured = code;
|
||||
exited = true;
|
||||
await disposeScreen();
|
||||
})
|
||||
.catch(() => {
|
||||
.catch(async () => {
|
||||
exited = true;
|
||||
await disposeScreen();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1853,7 +2049,7 @@ export async function launchClaudePty(
|
||||
clearTimeout(trustWatcherStop);
|
||||
clearInterval(trustWatcher);
|
||||
for (const timer of trustInputTimers) clearTimeout(timer);
|
||||
if (exited) return;
|
||||
if (exited) { await disposeScreen(); return; }
|
||||
try {
|
||||
proc.kill?.('SIGINT');
|
||||
} catch {
|
||||
@@ -1869,6 +2065,7 @@ export async function launchClaudePty(
|
||||
}
|
||||
await Promise.race([exitedPromise, Bun.sleep(1000)]);
|
||||
}
|
||||
await disposeScreen();
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1876,6 +2073,12 @@ export async function launchClaudePty(
|
||||
sendKey,
|
||||
rawOutput: () => buffer,
|
||||
visibleText: () => stripAnsi(buffer),
|
||||
currentScreen: async () => {
|
||||
if (!screen) throw new Error('PTY screen observation was not enabled for this session.');
|
||||
if (screenClosing) await screenClosing;
|
||||
if (screenFailure) throw new Error('PTY screen observation failed.', { cause: screenFailure });
|
||||
return screen.read();
|
||||
},
|
||||
mark,
|
||||
visibleSince,
|
||||
waitForAny,
|
||||
@@ -2389,7 +2592,7 @@ export async function runPlanSkillCounting(opts: {
|
||||
* Native-bound selection uses activeCapture, whose metadata is present only
|
||||
* when capturePlanCountQuestion matched the currently visible native question. */
|
||||
pickAUQ?: (fp: AskUserQuestionFingerprint, activeCapture: AskUserQuestionFingerprint) => number | null;
|
||||
/** Opt-in native completion fallback, validated against this caller-owned report path. */
|
||||
/** Require native completion plus this caller-owned final report before accepting a soft terminal. */
|
||||
expectedPlanPath?: string;
|
||||
/** Additional versioned files available in the isolated fixture before the skill starts. */
|
||||
fixtureFiles?: Record<string, string>;
|
||||
@@ -2433,6 +2636,7 @@ export async function runPlanSkillCounting(opts: {
|
||||
env: { ...opts.env, ...fixture.env },
|
||||
model: opts.model,
|
||||
seedSkills: true,
|
||||
observeScreen: true,
|
||||
});
|
||||
} catch (error) {
|
||||
fixture.cleanup();
|
||||
@@ -2443,6 +2647,7 @@ export async function runPlanSkillCounting(opts: {
|
||||
const seen = new Set<string>();
|
||||
const countedCalls = new Set<string>();
|
||||
const filePermission = createPlanCountPermissionGuard();
|
||||
let lastMatchedNativeQuestion: NativePlanQuestionCall | undefined;
|
||||
let transcript: PlanCountTranscript = { status: 'missing', calls: [], assistantMessages: [] };
|
||||
let boundaryFired = false;
|
||||
let step0Count = 0;
|
||||
@@ -2451,9 +2656,10 @@ export async function runPlanSkillCounting(opts: {
|
||||
let isFirstAUQ = true;
|
||||
const saveSnapshot = createPlanCountSnapshotWriter();
|
||||
let lastCheckpointAt = Date.now();
|
||||
let viewport = '';
|
||||
|
||||
const capture = (observation: object) => saveSnapshot({
|
||||
skillName: opts.skillName, observation, raw: session.rawOutput(), visible: session.visibleText(),
|
||||
skillName: opts.skillName, observation, raw: session.rawOutput(), visible: session.visibleText(), viewport,
|
||||
cwd: fixture.cwd, claudeConfigDir: session.hermeticConfigDir,
|
||||
});
|
||||
|
||||
@@ -2488,13 +2694,13 @@ export async function runPlanSkillCounting(opts: {
|
||||
|
||||
try {
|
||||
await Bun.sleep(8000); // boot grace + auto-trust handler window
|
||||
const since = session.mark();
|
||||
session.mark();
|
||||
session.send(`${opts.slashCommand}\r`);
|
||||
|
||||
const budgetStart = Date.now();
|
||||
while (Date.now() - budgetStart < timeoutMs) {
|
||||
await Bun.sleep(2000);
|
||||
const visible = session.visibleSince(since);
|
||||
const visible = viewport = await session.currentScreen();
|
||||
transcript = session.hermeticConfigDir
|
||||
? readPlanCountTranscript(session.hermeticConfigDir, fixture.cwd)
|
||||
: { status: 'error', calls: [], assistantMessages: [], error: 'Claude count session has no isolated transcript directory' };
|
||||
@@ -2552,8 +2758,33 @@ export async function runPlanSkillCounting(opts: {
|
||||
);
|
||||
}
|
||||
|
||||
const frame = classifyPlanCountFrame(visible);
|
||||
const permission = filePermission(visible);
|
||||
const pending = transcript.calls.find(c => !c.answered && !c.failed);
|
||||
const newlyMatched = pending && matchesNativePlanQuestion(visible, pending);
|
||||
if (newlyMatched) lastMatchedNativeQuestion = pending;
|
||||
const renderedFrame = classifyPlanCountFrame(visible);
|
||||
const administrative = new Set(fingerprints.filter(fp => fp.administrative).map(fp => fp.signature));
|
||||
// A long completed summary may scroll its heading off the viewport.
|
||||
// With no active input UI, retain the existing native/report validator;
|
||||
// neither display text nor a missing heading supplies completion evidence.
|
||||
const nativeCompletion = opts.expectedPlanPath && hasNativePlanCompletion(transcript, opts.expectedPlanPath, startedAt);
|
||||
const nativeSummary = !nativeCompletion && renderedFrame === null && opts.expectedPlanPath &&
|
||||
!isNumberedOptionListVisible(visible) && !isPermissionDialogVisible(visible) && !isProseAUQVisible(visible) &&
|
||||
hasNativePlanTerminal(transcript, opts.expectedPlanPath, startedAt, 'completion_summary', administrative);
|
||||
const terminalFrame = nativeSummary ? 'completion_summary' : renderedFrame;
|
||||
const isTerminalHint = terminalFrame === 'completion_summary' || terminalFrame === 'plan_ready';
|
||||
const verifiedTerminal = nativeSummary || (opts.expectedPlanPath && isTerminalHint &&
|
||||
hasNativePlanTerminal(transcript, opts.expectedPlanPath, startedAt, terminalFrame, administrative));
|
||||
// A streamed heading cannot dismiss the last bound native question.
|
||||
// Only an accepted terminal may supersede its answered redraw; otherwise
|
||||
// permission wording inside that question could queue a stray answer.
|
||||
const acceptedTerminal = isTerminalHint && (!opts.expectedPlanPath || verifiedTerminal);
|
||||
const nativeQuestionVisible = newlyMatched || (!acceptedTerminal &&
|
||||
lastMatchedNativeQuestion && matchesNativePlanQuestion(visible, lastMatchedNativeQuestion));
|
||||
const terminalHint = nativeQuestionVisible ? null : terminalFrame;
|
||||
let frame = terminalHint;
|
||||
// Clear unverified hints before routing active permissions and Submit.
|
||||
if (opts.expectedPlanPath && isTerminalHint && !verifiedTerminal) frame = null;
|
||||
const permission = nativeQuestionVisible ? null : filePermission(visible, session.visibleText());
|
||||
if (frame === 'permission' || (permission === 'grant' && frame === null)) {
|
||||
if (permission !== 'handled') session.send(`${defaultPick}\r`);
|
||||
await Bun.sleep(1500);
|
||||
@@ -2585,8 +2816,11 @@ export async function runPlanSkillCounting(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
// Soft terminal signals — check before AUQ processing so a final
|
||||
// completion-summary doesn't get misclassified as a bonus AUQ.
|
||||
if (opts.expectedPlanPath && verifiedTerminal && (frame === 'completion_summary' || frame === 'plan_ready')) {
|
||||
return snapshot(frame, `native ${frame} and final report verified (step0=${step0Count}, review=${reviewCount})`, visible);
|
||||
}
|
||||
|
||||
// Legacy callers without a report contract retain their terminal policy.
|
||||
if (frame === 'completion_summary') {
|
||||
if (transcript.status !== 'ready' || transcript.calls.some(c => !c.answered && !c.failed) ||
|
||||
unresolvedPlanQuestionCalls(transcript.calls).length) {
|
||||
@@ -2610,17 +2844,21 @@ export async function runPlanSkillCounting(opts: {
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.expectedPlanPath && hasNativePlanCompletion(transcript, opts.expectedPlanPath, startedAt)) {
|
||||
if (opts.expectedPlanPath && !transcript.planReadyRequests?.length && !isNumberedOptionListVisible(visible) &&
|
||||
hasNativePlanCompletion(transcript, opts.expectedPlanPath, startedAt)) {
|
||||
return snapshot('completion_summary',
|
||||
`native review completion and final report verified (step0=${step0Count}, review=${reviewCount})`, visible);
|
||||
}
|
||||
|
||||
// Never submit a plan-approval menu while its final report/native gate
|
||||
// is unverified: choosing option 1 would enter implementation.
|
||||
if (opts.expectedPlanPath && terminalHint === 'plan_ready') continue;
|
||||
|
||||
// A dismissed or repainted permission is never a native question.
|
||||
if (permission === 'handled') continue;
|
||||
|
||||
// Dedupe the complete question, not just its answer labels: separate
|
||||
// findings often reuse the same Add to plan / Defer / Skip menu.
|
||||
const pending = transcript.calls.find(c => !c.answered && !c.failed);
|
||||
const fp = capturePlanCountQuestion(visible, seen, Date.now() - startedAt, !boundaryFired, pending);
|
||||
if (!fp) continue;
|
||||
// Press to advance — first AUQ may use the override pick.
|
||||
@@ -2645,7 +2883,7 @@ export async function runPlanSkillCounting(opts: {
|
||||
return snapshot(
|
||||
'timeout',
|
||||
`no terminal outcome within ${timeoutMs}ms (step0=${step0Count}, review=${reviewCount})`,
|
||||
session.visibleSince(since),
|
||||
viewport = await session.currentScreen(),
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
COMPLETION_SUMMARY_RE,
|
||||
classifyPlanCountFrame,
|
||||
capturePlanCountQuestion,
|
||||
matchesNativePlanQuestion,
|
||||
createPlanCountPermissionGuard,
|
||||
planCountPrerequisitePick,
|
||||
planCountSubmissionInput,
|
||||
@@ -1751,7 +1752,7 @@ describe('Step0BoundaryPredicate per-skill', () => {
|
||||
for (const alter of [
|
||||
(call: any) => { call.answered = false; },
|
||||
(call: any) => { call.failed = true; },
|
||||
(call: any) => { call.questions[0].header = 'Architecture'; },
|
||||
(call: any) => { call.questions[0].header = 'Architecture issue'; call.questions[0].question = 'D1 — Architecture issue: should the cross-project feature use shared storage?'; },
|
||||
(call: any) => { call.questions[0].options = [{ label: 'Enable cross-project search' }, { label: 'Disable all search' }]; },
|
||||
(call: any) => { call.questions[0].options = [{ label: 'Enable cross-project search with project-scoped storage' }, { label: 'Discuss later' }]; },
|
||||
(call: any) => { call.questions[0].question = 'How should concurrent AuthCache writes across projects be serialized?';
|
||||
@@ -1936,7 +1937,7 @@ describe('Step0BoundaryPredicate per-skill', () => {
|
||||
unansweredScope.nativeCall!.answers = { 'Separate answered setup question': 'Continue' };
|
||||
expect(engStep0Boundary(unansweredScope)).toBe(false);
|
||||
for (const alter of [
|
||||
(q: any) => { q.question = q.question.replace('plan-eng-scope-complexity', 'plan-eng-cache-complexity'); },
|
||||
(q: any) => { q.question = 'D1 — Architecture issue: the plan touches 12 files and introduces 4 classes, but AuthCache has a race. Reduce cache scope? <gstack-qid:plan-eng-cache-complexity>'; },
|
||||
(q: any) => { q.options = [{ label: 'Change cache size' }, { label: 'Keep cache size' }]; },
|
||||
]) {
|
||||
const unrelated = structuredClone(original);
|
||||
@@ -2388,3 +2389,297 @@ describe('native Eng first packet and registry identities', () => {
|
||||
expect(engFirstReviewAUQ(nativePlanCallFingerprint(callWith([q], { [q.question]: q.options[0]!.label }), 0, true))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('native Eng setup semantics (captured G)', () => {
|
||||
// Exact native question stems, offered actions and answers. Five findings
|
||||
// and both substantive TODO decisions must remain in review coverage.
|
||||
const rows = [
|
||||
{
|
||||
"header": "Cross-project",
|
||||
"question": "gstack can search learnings from your other projects on this machine to find patterns that might apply to this auth refactor review. This stays local \u2014 no data leaves your machine. Enable cross-project learnings? <gstack-qid:cross-project-learnings>",
|
||||
"options": [
|
||||
"Enable (recommended)",
|
||||
"Project-scoped only"
|
||||
],
|
||||
"answer": "Enable (recommended)"
|
||||
},
|
||||
{
|
||||
"header": "Scope",
|
||||
"question": "D1 \u2014 Scope challenge: this plan touches 12 files and introduces 4 new classes. Reduce scope or proceed as-is? <gstack-qid:plan-eng-scope-challenge>",
|
||||
"options": [
|
||||
"Reduce: phase it (recommended)",
|
||||
"Proceed as-is",
|
||||
"Investigate first"
|
||||
],
|
||||
"answer": "Reduce: phase it (recommended)"
|
||||
},
|
||||
{
|
||||
"header": "Arch: cache wiring",
|
||||
"question": "D2 \u2014 Architecture issue 1: AuthBroker and SessionMint share a global mutable AuthCache via module-level export. Module-level singletons prevent test isolation and break in multi-process deployments (cluster, serverless, worker threads). How should AuthCache be wired? <gstack-qid:plan-eng-arch-cache-wiring>",
|
||||
"options": [
|
||||
"Dependency injection (recommended)",
|
||||
"Keep module-level export"
|
||||
],
|
||||
"answer": "Dependency injection (recommended)"
|
||||
},
|
||||
{
|
||||
"header": "Arch: concurrency",
|
||||
"question": "D3 \u2014 Architecture issue 2: the plan explicitly states mutations to AuthCache are not serialized. With two services writing concurrently (e.g., AuthBroker evicting a token while SessionMint reads-then-writes it), you get classic check-then-act races. How should this be handled? <gstack-qid:plan-eng-arch-mutation-serialization>",
|
||||
"options": [
|
||||
"Make mutations idempotent + last-write-wins (recommended)",
|
||||
"Add an async mutex per cache key",
|
||||
"Document and defer"
|
||||
],
|
||||
"answer": "Make mutations idempotent + last-write-wins (recommended)"
|
||||
},
|
||||
{
|
||||
"header": "Code quality",
|
||||
"question": "D4 \u2014 Code quality issue 1: validateAndDispatch() is 60 lines with three nested try/catch blocks, each silently swallowing a different error class. Swallowed errors mean silent failures in production \u2014 a token validation error looks identical to a dispatch error from the outside. Fix approach? <gstack-qid:plan-eng-cq-error-swallowing>",
|
||||
"options": [
|
||||
"Extract + typed errors (recommended)",
|
||||
"Add structured logging before swallowing",
|
||||
"Proceed as-is"
|
||||
],
|
||||
"answer": "Extract + typed errors (recommended)"
|
||||
},
|
||||
{
|
||||
"header": "Tests",
|
||||
"question": "D5 \u2014 Test issue: all 13 new code paths in AuthCache and AuthBroker are untested (0% coverage planned). The plan mentions unit and integration coverage for success/error paths, but specifics are absent. Add explicit test requirements to the plan now? <gstack-qid:plan-eng-test-coverage>",
|
||||
"options": [
|
||||
"Add explicit test plan (recommended)",
|
||||
"Keep plan vague, trust implementation"
|
||||
],
|
||||
"answer": "Add explicit test plan (recommended)"
|
||||
},
|
||||
{
|
||||
"header": "Performance",
|
||||
"question": "D6 \u2014 Performance issue: token validation makes 5 sequential IDP API calls. The plan notes they are independent and could be parallelized via Promise.all trivially. Fix in this PR or defer? <gstack-qid:plan-eng-perf-idp-parallelization>",
|
||||
"options": [
|
||||
"Fix in this PR with Promise.all (recommended)",
|
||||
"Defer to follow-up TODO"
|
||||
],
|
||||
"answer": "Fix in this PR with Promise.all (recommended)"
|
||||
},
|
||||
{
|
||||
"header": "TODO: PR 2",
|
||||
"question": "D7 \u2014 TODO: capture PR 2 scope (SessionMint, TokenStore, RequestPolicy) in TODOS.md so it doesn\u2019t get lost after PR 1 ships. Add it? <gstack-qid:plan-eng-todo-pr2-scope>",
|
||||
"options": [
|
||||
"Add to TODOS.md (recommended)",
|
||||
"Skip \u2014 not valuable enough"
|
||||
],
|
||||
"answer": "Add to TODOS.md (recommended)"
|
||||
},
|
||||
{
|
||||
"header": "TODO: IDP retry",
|
||||
"question": "D8 \u2014 TODO: IDP circuit breaker. Token validation makes 5 IDP calls (now parallelized). If the IDP is degraded, all 5 fail together \u2014 no retry, no fallback, no circuit breaker in the plan. Add a TODO to add circuit breaker / exponential retry logic around IDP calls? <gstack-qid:plan-eng-todo-idp-circuit-breaker>",
|
||||
"options": [
|
||||
"Add to TODOS.md (recommended)",
|
||||
"Build it now in this PR",
|
||||
"Skip \u2014 not valuable enough"
|
||||
],
|
||||
"answer": "Add to TODOS.md (recommended)"
|
||||
}
|
||||
];
|
||||
const callAt = (index: number) => {
|
||||
const row = rows[index]!;
|
||||
return { sessionId: 'captured-g-eng', toolUseId: `g-${index}`, answered: true, failed: false,
|
||||
questions: [{ header: row.header, question: row.question, options: row.options.map(label => ({ label })) }],
|
||||
answers: { [row.question]: row.answer } };
|
||||
};
|
||||
const setup = (call: ReturnType<typeof callAt>) => engSetupAUQ(nativePlanCallFingerprint(call, 0, false));
|
||||
const change = (index: number, question: string, labels?: string[]) => {
|
||||
const call = callAt(index); const q = call.questions[0]!;
|
||||
q.question = question;
|
||||
if (labels) q.options = labels.map(label => ({ label }));
|
||||
call.answers = { [question]: q.options[0]!.label };
|
||||
return call;
|
||||
};
|
||||
test('two setup calls precede five findings and two substantive TODO calls', () => {
|
||||
for (const order of [[0, 1], [1, 0]]) {
|
||||
let started = false;
|
||||
const phases = [...order, 2, 3, 4, 5, 6, 7, 8].map(index => {
|
||||
const phase = planCountQuestionPhase(nativePlanCallFingerprint(callAt(index), 0, !started),
|
||||
started, engStep0Boundary, engFirstReviewAUQ, engSetupAUQ);
|
||||
started = phase.reviewStarted; return phase;
|
||||
});
|
||||
expect(phases.map(p => p.preReview)).toEqual([true, true, false, false, false, false, false, false, false]);
|
||||
expect(phases.filter(p => !p.preReview)).toHaveLength(7);
|
||||
}
|
||||
expect(setup(callAt(0))).toBe(true);
|
||||
expect(setup(callAt(1))).toBe(true);
|
||||
for (const index of [2, 3, 4, 5, 6, 7, 8]) expect(setup(callAt(index))).toBe(false);
|
||||
});
|
||||
test('whole-plan scope uses its premise and opposed actions, not a model-chosen qid or header', () => {
|
||||
for (const question of [
|
||||
'D1 — Scope challenge: this plan touches 12 files and introduces 4 new classes. Reduce scope or proceed as-is?',
|
||||
'The plan introduces 4 new services. Reduce the scope or proceed as-is? <gstack-qid:plan-eng-unfamiliar-size-check>',
|
||||
'Complexity check: the plan spans 12 files. Reduce scope or proceed as-is? <gstack-qid:another-scope-name>',
|
||||
]) {
|
||||
const call = change(1, question); call.questions[0]!.header = 'Review setup';
|
||||
expect(setup(call)).toBe(true);
|
||||
call.questions[0]!.options.reverse();
|
||||
call.answers = { [question]: call.questions[0]!.options[0]!.label };
|
||||
expect(setup(call)).toBe(true);
|
||||
}
|
||||
});
|
||||
test('abbreviated enable requires explicit cross-project learnings and an opposed project-scoped action', () => {
|
||||
for (const question of [
|
||||
'Should gstack search learnings from your other projects on this machine?',
|
||||
'Enable cross-project learnings for this review? <gstack-qid:model-selected-learning-scope>',
|
||||
]) {
|
||||
const call = change(0, question); call.questions[0]!.header = 'Review setup';
|
||||
expect(setup(call)).toBe(true);
|
||||
call.questions[0]!.options.reverse();
|
||||
call.answers = { [question]: call.questions[0]!.options[0]!.label };
|
||||
expect(setup(call)).toBe(true);
|
||||
}
|
||||
for (const question of ['Enable the new feature?', 'Choose setup. <gstack-qid:cross-project-learnings>']) {
|
||||
expect(setup(change(0, question))).toBe(false);
|
||||
}
|
||||
expect(setup(change(0, rows[0]!.question, ['Enable (recommended)', 'Discuss later']))).toBe(false);
|
||||
});
|
||||
test('individual issues and TODOs cannot borrow whole-plan scope or cross-project words', () => {
|
||||
for (const [index, question, header] of [
|
||||
[1, 'D1 — Architecture issue: this plan touches 12 files and adds 4 classes, but the AuthCache has a race. Reduce scope or proceed as-is?', 'Architecture issue'],
|
||||
[1, 'D1 — Scope challenge: this cache spans 12 files and introduces 4 classes. Reduce scope or proceed as-is?', 'Scope'],
|
||||
[1, 'D1 — Test gap: the plan spans 12 files. Reduce test scope or proceed as-is?', 'Tests'],
|
||||
[1, 'The plan spans 12 files. Reduce scope or proceed as-is?', 'TODO: deferred implementation'],
|
||||
[0, 'D1 — Security issue: cross-project learnings leak client data. Enable the feature or use project-scoped storage?', 'Security issue'],
|
||||
] as const) {
|
||||
const call = change(index, question); call.questions[0]!.header = header;
|
||||
expect(setup(call)).toBe(false);
|
||||
}
|
||||
expect(setup(change(1, rows[1]!.question, ['Reduce token scope', 'Investigate']))).toBe(false);
|
||||
});
|
||||
test('the captured retry scope heading and letter-prefixed native actions remain setup', () => {
|
||||
const retry = {
|
||||
"header": "Scope",
|
||||
"question": "D2 \u2014 Scope reduction: 12 files + 4 new classes exceeds the complexity threshold\nProject/branch/task: Multi-tenant Auth Refactor on main\nELI10: The plan introduces 4 new classes (TokenStore, SessionMint, AuthCache, RequestPolicy) across 12 files. That\u2019s a lot of new surface area at once. Auth refactors are already high-risk (broken auth = all users locked out). Adding 4 new abstractions simultaneously makes the blast radius of a mistake much larger. A simpler split \u2014 just AuthBroker + SessionMint, collapsing TokenStore into AuthCache and inlining RequestPolicy \u2014 would achieve the same goal with 2 new classes and fewer files touched.\nStakes if we pick wrong: With 4 new classes landing together, a single bug in any one of them could take down auth for all tenants simultaneously. Fewer classes = smaller blast radius, easier rollback, faster onboarding for the next engineer.\nRecommendation: B (proceed as-is) \u2014 the plan already has the existing cache adapter retained, and the 4-class split may reflect genuine domain separations the plan description doesn\u2019t fully explain. Review the design at full scope, flag individual issues per section.\nCompleteness: Note: options differ in kind, not coverage \u2014 no completeness score.\nPros / cons:\nA) Reduce scope \u2014 collapse TokenStore into AuthCache, inline RequestPolicy\n \u2705 Fewer moving parts: 2 new classes instead of 4, smaller blast radius if auth fails\n \u2705 Easier to review, test, and roll back each piece independently\n \u274c May discard intentional domain separation the plan author had in mind\n \u274c Requires re-planning before implementation can start\nB) Proceed as-is with full review (recommended)\n \u2705 Respects the planned architecture and lets the full review surface real issues per section\n \u2705 Faster path to implementation if the 4-class split turns out to be justified\n \u274c Higher blast radius: 4 simultaneous new classes touching 12 files is more fragile to ship\n \u274c The legacyAuthFlow rewrite without a regression test is a landmine that needs explicit attention\nNet: You\u2019re trading blast-radius safety (fewer classes) against re-planning delay. Recommend B \u2014 proceed at full scope, but treat each class boundary and the missing regression test as explicit issues in the review. <gstack-qid:plan-eng-review-scope-challenge>",
|
||||
"options": [
|
||||
"A) Reduce scope",
|
||||
"B) Proceed as-is (Recommended)"
|
||||
],
|
||||
"answer": "A) Reduce scope"
|
||||
};
|
||||
const call = change(1, retry.question, retry.options);
|
||||
call.questions[0]!.header = retry.header;
|
||||
call.answers = { [retry.question]: retry.answer };
|
||||
expect(setup(call)).toBe(true);
|
||||
// A numerical component issue cannot borrow the whole-plan sentence in
|
||||
// the explanatory body and the same Reduce/Proceed choices.
|
||||
const issue = change(1, retry.question.replace('Scope reduction:', 'AuthCache issue:'), retry.options);
|
||||
expect(setup(issue)).toBe(false);
|
||||
const partial = change(1, retry.question.replace('ELI10: The plan introduces', 'ELI10: AuthCache introduces'), retry.options);
|
||||
expect(setup(partial)).toBe(false);
|
||||
const missingOpposition = change(1, retry.question, ['A) Reduce scope', 'B) Investigate']);
|
||||
expect(setup(missingOpposition)).toBe(false);
|
||||
});
|
||||
test('late setup cannot hide earlier or later substantive findings', () => {
|
||||
const order = [2, 1, 3, 0, 4, 5, 6, 7, 8];
|
||||
let started = false;
|
||||
const phases = order.map(index => {
|
||||
const phase = planCountQuestionPhase(nativePlanCallFingerprint(callAt(index), 0, !started),
|
||||
started, engStep0Boundary, engFirstReviewAUQ, engSetupAUQ);
|
||||
started = phase.reviewStarted; return phase;
|
||||
});
|
||||
expect(phases.map(p => p.preReview)).toEqual([false, true, false, true, false, false, false, false, false]);
|
||||
expect(phases.filter(p => !p.preReview)).toHaveLength(7);
|
||||
});
|
||||
test('pending, failed, uncertain and mixed answered calls never become wholly setup', () => {
|
||||
for (const index of [0, 1]) {
|
||||
const original = callAt(index);
|
||||
expect(engSetupAUQ({ ...nativePlanCallFingerprint(original, 0, false), nativeCall: undefined })).toBe(false);
|
||||
for (const mutate of [
|
||||
(c: ReturnType<typeof callAt>) => { c.answered = false; },
|
||||
(c: ReturnType<typeof callAt>) => { c.failed = true; },
|
||||
(c: ReturnType<typeof callAt>) => { c.answers = {}; },
|
||||
(c: ReturnType<typeof callAt>) => { c.answers = { [c.questions[0]!.question]: 'Uncertain; I have not selected a choice' }; },
|
||||
]) { const c = structuredClone(original); mutate(c); expect(setup(c)).toBe(false); }
|
||||
const mixed = structuredClone(original); const finding = callAt(2).questions[0]!;
|
||||
mixed.questions.push(finding);
|
||||
mixed.answers[finding.question] = finding.options[0]!.label;
|
||||
expect(setup(mixed)).toBe(false);
|
||||
expect(planCountQuestionPhase(nativePlanCallFingerprint(mixed, 0, true), false,
|
||||
engStep0Boundary, engFirstReviewAUQ, engSetupAUQ).preReview).toBe(false);
|
||||
mixed.answers = { [finding.question]: finding.options[0]!.label };
|
||||
expect(setup(mixed)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('completed permission cannot become a queued review answer (captured G)', () => {
|
||||
// Exact captured post-Write frame; the temporary repository path is sanitized.
|
||||
// The damaged "wat" came from the CLI redraw, not the underlying question.
|
||||
const captured = " real,specificgaps (Visual Hierachy, Spacing, Color, Typography,Motion)\r22-Itpreservesstrongaccessibilityandresponsivespecsfromtheexistingbehaviordescription\r23 -DESIGN.md exist andsuppliescorrectvaluesforall5gaps\r24\r 25 A 10/10would:\r26-Specifytheexactchangeforeachgap(concretetoken,before→after)\r27 -Add intection state table(loading,empty,error,success,partial)\r28-Storyboardtheuserjourney\r 29- Call out which DESIGN.md tokens each fix applies\r30-Resolveallimplementationambiguitiesbeforeadeveloperpicksitup\r31\r32 **DESIGN.md status:** Presnt and detailed. Spacing scale, clor system, typography,interacton patterns,andc\r omponentbehavior re all specified. This review calbrates against it.\r33\r34 **Exsting design lverage:**\r35 -Button, Field, InlineStatus,ErrorSummary,ConfirmationDialogcomponentsreusedas-is\r36-Focusring:2pxsolid#1d4ed8,offset2px(alreadyspecified)\r37 -8px bae spacingscalealreadyestablished\r38\r39 ---\r40\r41 ## Review Passes\r42\r43*(Populatedprogressivelyasthereviewproceeds)*\r44\r╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌\r Do you wat to create gstack-test-plan-design.md?\r❯ 1. Yes\r2.Yes,andswitchtoacceptedits(auto-approvefileeditsandcommonfilecommands)for this session; Yes,an\r always allow access to /tmp/fixture for this session\r 3.Nohift+tab)\r\rEsc to cancl ·Tab to amend\u000f\u001b[?1000h\u001b[?1002h\u001b[?1003h\u001b[?1006h\rRan1shellcommand\r\r●Scopegate:planmodeactive—auto-selectingB(reviewingPLAN.mdintheworkingdirectory).\r\rSkippingroutinginjection—thisisareviewfixturerepo.Nowrunningthepre-reviewaudit.\r\rRead2 files, ran1 shell command\r\r●Now let me read the review sectons fil as required by theskill.\r\rRead file, ran 1shellcommand\r\r●Nopriorlearnings found. Cross-project config isnot set — will ask once Ihavetheuser'sattention.\r\rPreReview System Audit:\r- DESIGN.md:exists and comprehensive (spacing, typography, color, interactionpatternsallspecified)\r- UIscope:APPUIclassifier—account-settingsformwithProfile+Notificationssections\r- 5 gaps explicily listed in the plan; l have corresponding specin DESIGN.md\r-N rior design reviews inth reviewlog\r- NoTODOS.mdexists\r\rStep0 Assessment:\r\rInitial scor: 5/10. The plan correctly identifies5realgapsandpreservesstrongaccessibilityandresponsivespecs\rinheritedfromtheexistingbehaviordescription.A10/10wouldspecifyHOWeachgapisresolved(concretecomponent\rchanges,tokenvalues,before/afterlayout),notjustnametheproblem.TheDESIGN.mdalreadyhasallcorrectvalues;\rtheplanjustneedstoreferencethem.\r\rWritingtheinitialplanfilenowbeforeaskingStep0D.\r\r●Write(/tmp/fixture/gstack-test-plan-design.md)\r\r✢ Undulatig… (2m 0s ·↓ 5.4 okens)\r ⎿ Tip:Use/btwtoaskaquicksidequestionwithoutinterruptingClaude'scurrentwork\r ● high · /effort\r────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r❯ \r────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r ⏸ plan mod on(shift+tab tocycle)·esctointerrupt·←foragents\u001b[?25h\u001b[?25l\r●No prior earnings found.Cross-projectconfigisnotset—willaskonceIhavetheuser'sattention.\r Pre-Review System Audit:\r-DESIGN.md:existsandcomprehensive(spacing,typography,color,interactionpatternsallspecified)\r- UI scope: APP UI classifier — account-settings fomwith Prfile + Notifications sctions\r-5gapsexplicitlylistedintheplan;allhavecorrespondingspecinDESIGN.md\r- Noprior desigreviwsin the reviewlog\r-NoTODOS.mdexists\r\rStep0Assessment:\r\rInitialscore:5/10.Theplancorrectlyidentifies5realgapsandpreservesstrongaccessibilityandresponsivespecs\r inheted from theexisting behavir desription.A 10/10would specify HOWechgapi eolved (ccretecomponent\rchanges,tokenvalues,before/afterlayout),notjustnametheproblem.TheDESIGN.mdalreadyhasallcorrectvalues;\rth plan just neds to referencethem.\r\rWriting theinitial plan lenow before ask Step 0D.\r\r●Write(/tmp/fixture/gstack-test-plan-design.md)\r⎿ Wrote 44 lines";
|
||||
|
||||
test('a missed grant followed by native Write completion sends no stale answer', () => {
|
||||
const guard = createPlanCountPermissionGuard();
|
||||
expect(classifyPlanCountFrame(captured)).toBeNull();
|
||||
expect(guard(captured)).toBe('handled');
|
||||
expect(capturePlanCountQuestion(captured, new Set(), 0, true)).toBeNull();
|
||||
});
|
||||
|
||||
test('the same damaged active file permission grants once and never counts as a finding', () => {
|
||||
const menu = captured.slice(captured.indexOf('Do you wat'), captured.indexOf('Esc to cancl')) + 'Esc to cancl ·Tab to amend';
|
||||
const guard = createPlanCountPermissionGuard();
|
||||
expect(guard(menu)).toBe('grant');
|
||||
expect(guard(menu)).toBe('handled');
|
||||
expect(capturePlanCountQuestion(menu, new Set(), 0, false)).toBeNull();
|
||||
const completed = menu + '\n⎿ Wrote 44 lines';
|
||||
expect(guard(completed)).toBe('handled');
|
||||
expect(guard(completed + '\n' + menu)).toBe('grant');
|
||||
});
|
||||
|
||||
test('plain legacy file decisions remain questions without native permission controls', () => {
|
||||
const frame = 'Do you want to create first.md?\n❯1.Create the reviewed file\n2.Keep the current layout';
|
||||
expect(classifyPlanCountFrame(frame)).toBeNull();
|
||||
expect(createPlanCountPermissionGuard()(frame)).toBeNull();
|
||||
expect(capturePlanCountQuestion(frame, new Set(), 0, false)?.options).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('a matching native finding can discuss file permissions without being consumed', () => {
|
||||
const question = {
|
||||
header: 'File policy',
|
||||
question: 'Should we create a file that documents always allow access to the project?',
|
||||
options: [{ label: 'Create it' }, { label: 'Keep current policy' }],
|
||||
};
|
||||
const pending = { sessionId: 'file-policy', toolUseId: 'file-finding', answered: false, questions: [question] };
|
||||
const frame = captured + '\n☐ ' + question.header + '\n' + question.question +
|
||||
'\n❯1.Create it\n2.Keep current policy\nEnter to select · ↑/↓ to navigate · Esc to cancel';
|
||||
expect(classifyPlanCountFrame(frame)).toBeNull();
|
||||
expect(createPlanCountPermissionGuard()(frame)).toBeNull();
|
||||
expect(capturePlanCountQuestion(frame, new Set(), 0, false, pending)?.nativeCall).toBe(pending);
|
||||
expect(capturePlanCountQuestion(frame, new Set(), 0, false)?.promptSnippet).toContain('File policy');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('native question identity outranks permission wording', () => {
|
||||
const question = {
|
||||
header: 'File policy',
|
||||
question: 'D1 — Should we create a file that documents always allow access to the project? <gstack-qid:plan-eng-review-file-policy>',
|
||||
options: [{ label: 'Create it' }, { label: 'Keep current policy' }],
|
||||
};
|
||||
const pending = { sessionId: 'file-policy', toolUseId: 'finding', answered: false, questions: [question] };
|
||||
const frame = '☐ File policy\n' + question.question + '\n❯1.Create it\n2.Keep current policy\nEnter to select · ↑/↓ to navigte · Esc to cancel';
|
||||
test('full native question and every option establish identity despite a damaged footer', () => {
|
||||
// This is the formerly conflicting pure classifier result. The counting
|
||||
// loop must consult native identity before taking its permission action.
|
||||
expect(classifyPlanCountFrame(frame)).toBe('permission');
|
||||
expect(matchesNativePlanQuestion(frame, pending)).toBe(true);
|
||||
const seen = new Set<string>();
|
||||
expect(capturePlanCountQuestion(frame, seen, 0, false, pending)?.nativeCall).toBe(pending);
|
||||
expect(capturePlanCountQuestion(frame, seen, 1, false, pending)).toBeNull();
|
||||
});
|
||||
test('same header, changed choices, missing identity and an overlaid real permission cannot borrow a native call', () => {
|
||||
for (const different of [
|
||||
frame.replace('Should we create a file', 'Should we delete the file'),
|
||||
frame.replace('2.Keep current policy', '2.Allow all edits'),
|
||||
frame.replace(question.question, 'A different question with the same header?'),
|
||||
frame + '\nDo you want to create actual.md?\n❯1.Yes\n2.Yes, and switch to accept edits\n3.No\nEsc to cancel · Tab to amend',
|
||||
]) expect(matchesNativePlanQuestion(different, pending)).toBe(false);
|
||||
expect(capturePlanCountQuestion(frame, new Set(), 0, false, { ...pending, failed: true })).toBeNull();
|
||||
expect(capturePlanCountQuestion(frame, new Set(), 0, false)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -90,7 +90,9 @@ export function disabledPlanReviewEvidence(result: {
|
||||
|| result.output.split('\n').some(line => /\b(?:outside(?:\s+(?:voice|review))?|codex(?:\s+review)?)\b/i.test(line)
|
||||
&& /\bdisabled\b/i.test(line) && !/\bnot\s+disabled\b/i.test(line));
|
||||
const falseCompletion = /\b(?:both reviewers agree|outside_status["'`*]*\s*[:=]\s*["'`*]*completed)\b/i.test(result.output);
|
||||
const agentAvailable = Array.isArray(init?.tools) && init.tools.includes('Agent');
|
||||
// Native CLI releases expose the requested subagent as Agent or Task.
|
||||
// Availability never permits dispatch: fallbackCalls rejects both names.
|
||||
const agentAvailable = Array.isArray(init?.tools) && init.tools.some((tool: unknown) => tool === 'Agent' || tool === 'Task');
|
||||
let records: any[] = [];
|
||||
let malformedLog = false;
|
||||
try { records = reviewLog.trim() ? reviewLog.trim().split('\n').map(line => JSON.parse(line)) : []; }
|
||||
|
||||
@@ -7,6 +7,7 @@ interface PlanCountSnapshot {
|
||||
observation: object;
|
||||
raw: string;
|
||||
visible: string;
|
||||
viewport?: string;
|
||||
cwd: string;
|
||||
claudeConfigDir: string | null;
|
||||
}
|
||||
@@ -31,6 +32,7 @@ export function createPlanCountSnapshotWriter(env: NodeJS.ProcessEnv = process.e
|
||||
};
|
||||
write('terminal.raw.log', input.raw);
|
||||
write('terminal.visible.log', input.visible);
|
||||
if (input.viewport !== undefined) write('terminal.screen.log', input.viewport);
|
||||
write('observation.json', JSON.stringify({
|
||||
...input.observation, artifactDir,
|
||||
capture: { skill: input.skillName, runId: env.EVALS_RUN_ID, cwd: input.cwd,
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface PlanCountTranscript {
|
||||
status: 'missing' | 'ready' | 'error';
|
||||
calls: NativePlanQuestionCall[];
|
||||
assistantMessages: Array<{ sessionId: string; text: string; timestamp: string }>;
|
||||
/** Actual native plan-mode approval requests; pending is the UI gate, never an AUQ. */
|
||||
planReadyRequests?: Array<{ sessionId: string; toolUseId: string; timestamp: string; failed: boolean }>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -57,6 +59,7 @@ function validQuestions(value: unknown): value is NativePlanQuestion[] {
|
||||
export function readPlanCountTranscript(configDir: string, cwd: string): PlanCountTranscript {
|
||||
const calls = new Map<string, NativePlanQuestionCall>();
|
||||
const assistantMessages: PlanCountTranscript['assistantMessages'] = [];
|
||||
const planReadyRequests = new Map<string, NonNullable<PlanCountTranscript['planReadyRequests']>[number]>();
|
||||
let matched = false;
|
||||
let bytes = 0;
|
||||
let files = 0;
|
||||
@@ -89,6 +92,12 @@ export function readPlanCountTranscript(configDir: string, cwd: string): PlanCou
|
||||
typeof block.text === 'string' && block.text.trim() && validTimestamp(record.timestamp)) {
|
||||
assistantMessages.push({ sessionId: record.sessionId, text: block.text, timestamp: record.timestamp });
|
||||
}
|
||||
if (record.message.role === 'assistant' && block.type === 'tool_use' && block.name === 'ExitPlanMode' &&
|
||||
typeof block.id === 'string' && validTimestamp(record.timestamp)) {
|
||||
const key = `${record.sessionId}:${block.id}`;
|
||||
if (!planReadyRequests.has(key)) planReadyRequests.set(key, { sessionId: record.sessionId,
|
||||
toolUseId: block.id, timestamp: record.timestamp, failed: false });
|
||||
}
|
||||
if (record.message.role === 'assistant' && block.type === 'tool_use' && block.name === 'AskUserQuestion' &&
|
||||
typeof block.id === 'string' && object(block.input) && validQuestions(block.input.questions)) {
|
||||
const key = `${record.sessionId}:${block.id}`;
|
||||
@@ -100,6 +109,8 @@ export function readPlanCountTranscript(configDir: string, cwd: string): PlanCou
|
||||
questions: block.input.questions, answered: false });
|
||||
} else if (record.message.role === 'user' && block.type === 'tool_result' &&
|
||||
typeof block.tool_use_id === 'string') {
|
||||
const ready = planReadyRequests.get(`${record.sessionId}:${block.tool_use_id}`);
|
||||
if (ready && block.is_error === true) ready.failed = true;
|
||||
const call = calls.get(`${record.sessionId}:${block.tool_use_id}`);
|
||||
const answers = record.toolUseResult?.answers;
|
||||
const validAnswers = call && object(answers) ? Object.fromEntries(call.questions
|
||||
@@ -124,7 +135,8 @@ export function readPlanCountTranscript(configDir: string, cwd: string): PlanCou
|
||||
}
|
||||
}
|
||||
}
|
||||
return { status: matched ? 'ready' : 'missing', calls: [...calls.values()], assistantMessages };
|
||||
return { status: matched ? 'ready' : 'missing', calls: [...calls.values()], assistantMessages,
|
||||
...(planReadyRequests.size ? { planReadyRequests: [...planReadyRequests.values()] } : {}) };
|
||||
} catch (error) {
|
||||
// A failed read cannot silently turn an incomplete transcript into a
|
||||
// complete review. Keep the diagnostic explicit and return no coverage.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
/** The existing xterm package ships this headless source beside its browser bundle. */
|
||||
let terminalConstructor: Promise<any> | undefined;
|
||||
function loadTerminal(): Promise<any> {
|
||||
return terminalConstructor ??= (async () => {
|
||||
try {
|
||||
const source = path.join(path.dirname(createRequire(import.meta.url).resolve('xterm/package.json')), 'src');
|
||||
const entry = path.join(source, 'headless/public/Terminal.ts');
|
||||
if (!fs.existsSync(entry)) throw new Error(`Installed xterm headless source is missing: ${entry}`);
|
||||
const transpiler = new Bun.Transpiler({ loader: 'ts', target: 'bun', trimUnusedImports: true,
|
||||
tsconfig: JSON.stringify({ compilerOptions: { experimentalDecorators: true, useDefineForClassFields: false } }) });
|
||||
const build = await Bun.build({ entrypoints: [entry], target: 'bun', format: 'esm',
|
||||
// xterm5 detects Node by navigator absence. This changes only the
|
||||
// compiled module; Bun's global navigator and other tests stay intact.
|
||||
define: { navigator: 'undefined' },
|
||||
plugins: [{ name: 'installed-xterm-headless', setup(builder) {
|
||||
builder.onResolve({ filter: /^(common|headless)\// }, args => {
|
||||
const stem = path.join(source, args.path);
|
||||
return { path: fs.existsSync(`${stem}.ts`) ? `${stem}.ts` : `${stem}.d.ts` };
|
||||
});
|
||||
builder.onLoad({ filter: /[/\\]xterm[/\\]src[/\\].*\.ts$/ }, async args => ({
|
||||
contents: transpiler.transformSync(await Bun.file(args.path).text()), loader: 'js',
|
||||
}));
|
||||
} }],
|
||||
});
|
||||
if (!build.success) throw new AggregateError(build.logs, 'Installed xterm headless build failed');
|
||||
const encoded = Buffer.from(await build.outputs[0].text()).toString('base64');
|
||||
return (await import(`data:text/javascript;base64,${encoded}`)).Terminal;
|
||||
} catch (cause) {
|
||||
throw new Error('PTY screen unavailable; cannot safely observe terminal input.', { cause });
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export interface PtyScreen {
|
||||
write(text: string): void;
|
||||
read(): Promise<string>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
/** One terminal per session; read only the actual viewport, never scrollback. */
|
||||
export async function createPtyScreen(cols: number, rows: number): Promise<PtyScreen> {
|
||||
const Terminal = await loadTerminal();
|
||||
const terminal = new Terminal({ cols, rows, scrollback: 0, allowProposedApi: true });
|
||||
let pending = 0;
|
||||
let failure: unknown;
|
||||
let final: string | undefined;
|
||||
let closing: Promise<void> | undefined;
|
||||
const waiting = new Set<() => void>();
|
||||
const settled = () => { if (pending === 0) { for (const done of waiting) done(); waiting.clear(); } };
|
||||
const drain = async () => {
|
||||
while (pending > 0) await new Promise<void>(resolve => waiting.add(resolve));
|
||||
if (failure) throw new Error('PTY screen parse failed.', { cause: failure });
|
||||
};
|
||||
const viewport = () => {
|
||||
const buffer = terminal.buffer.active;
|
||||
return Array.from({ length: rows }, (_, i) => buffer.getLine(buffer.baseY + i)?.translateToString(true) ?? '').join('\n');
|
||||
};
|
||||
return {
|
||||
write(text) {
|
||||
if (closing) throw new Error('Cannot write to a disposed PTY screen.');
|
||||
if (!text) return;
|
||||
pending++;
|
||||
try { terminal.write(text, () => { pending--; settled(); }); }
|
||||
catch (error) { failure = error; pending--; settled(); }
|
||||
},
|
||||
async read() {
|
||||
if (closing) { await closing; return final!; }
|
||||
await drain();
|
||||
return viewport();
|
||||
},
|
||||
dispose() {
|
||||
return closing ??= (async () => {
|
||||
try { await drain(); final = viewport(); }
|
||||
finally { terminal.dispose(); }
|
||||
})();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -153,17 +153,17 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
// devex, office-hours + future PR2 carves). One file iterating CARVE_GUARDS;
|
||||
// the selector sets GSTACK_CARVE_SKILL=<name> to scope cost to the changed
|
||||
// skill (D-CODEX A). Touching the registry/helper or sections.ts runs all.
|
||||
'carve-section-loading': ['design-html/**', 'design-shotgun/**', 'qa/**', 'browse/**', 'retro/**', 'autoplan/**', 'spec/**', 'setup-gbrain/**', 'review/**', 'codex/**', 'land-and-deploy/**', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'office-hours/**', 'document-release/**', 'design-consultation/**', 'cso/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'test/session-runner-tools.test.ts', 'test/carve-section-loading.test.ts'],
|
||||
'autoplan-chain-pty': ['autoplan/**', 'plan-ceo-review/**', 'plan-design-review/**', 'plan-eng-review/**', 'plan-devex-review/**', 'test/fixtures/plans/ui-heavy-feature.md', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/autoplan-setup-question.ts', 'test/autoplan-setup-question.test.ts', 'test/helpers/autoplan-phase-observer.ts', 'test/autoplan-phase-observer.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/skill-e2e-autoplan-chain.test.ts'],
|
||||
'carve-section-loading': ['scripts/resolvers/composition.ts', 'test/autoplan-review-discovery.test.ts', 'test/autoplan-phase-order.test.ts', 'design-html/**', 'design-shotgun/**', 'qa/**', 'browse/**', 'retro/**', 'autoplan/**', 'spec/**', 'setup-gbrain/**', 'review/**', 'codex/**', 'land-and-deploy/**', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'office-hours/**', 'document-release/**', 'design-consultation/**', 'cso/**', 'test/helpers/carve-guards.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/redact-doc.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/session-runner.ts', 'test/session-runner-tools.test.ts', 'test/carve-section-loading.test.ts'],
|
||||
'autoplan-chain-pty': ['scripts/resolvers/composition.ts', 'test/autoplan-review-discovery.test.ts', 'test/autoplan-phase-order.test.ts', 'autoplan/**', 'plan-ceo-review/**', 'plan-design-review/**', 'plan-eng-review/**', 'plan-devex-review/**', 'test/fixtures/plans/ui-heavy-feature.md', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/autoplan-setup-question.ts', 'test/autoplan-setup-question.test.ts', 'test/helpers/autoplan-phase-observer.ts', 'test/autoplan-phase-observer.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/skill-e2e-autoplan-chain.test.ts'],
|
||||
|
||||
// Per-finding AskUserQuestion count + review-report-at-bottom assertion.
|
||||
// Each test drives its skill end-to-end; touchfiles include preamble +
|
||||
// completion-status resolvers because they affect question cadence and
|
||||
// terminal output (the regression surface this test catches).
|
||||
'plan-ceo-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-ceo-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/ceo-completion-handoff.ts', 'test/ceo-completion-handoff.test.ts', 'test/fixtures/ceo-completion-handoff-calls.json', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/skill-e2e-plan-ceo-finding-count.test.ts'],
|
||||
'plan-eng-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-eng-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/skill-e2e-plan-eng-finding-count.test.ts'],
|
||||
'plan-design-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-design-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/skill-e2e-plan-design-finding-count.test.ts'],
|
||||
'plan-devex-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-devex-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/plan-count-completion.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/helpers/devex-count-fixture.ts', 'test/devex-count-fixture.test.ts', 'test/skill-e2e-plan-devex-finding-count.test.ts'],
|
||||
'plan-ceo-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-ceo-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/plan-count-completion.test.ts', 'test/helpers/ceo-completion-handoff.ts', 'test/ceo-completion-handoff.test.ts', 'test/fixtures/ceo-completion-handoff-calls.json', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/skill-e2e-plan-ceo-finding-count.test.ts'],
|
||||
'plan-eng-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-eng-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/plan-count-completion.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/skill-e2e-plan-eng-finding-count.test.ts'],
|
||||
'plan-design-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-design-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/plan-count-completion.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/skill-e2e-plan-design-finding-count.test.ts'],
|
||||
'plan-devex-finding-count': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-devex-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/plan-count-completion.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/helpers/devex-count-fixture.ts', 'test/devex-count-fixture.test.ts', 'test/skill-e2e-plan-devex-finding-count.test.ts'],
|
||||
|
||||
// Gate-tier reviewCount-floor counterparts. Catch the May 2026 transcript
|
||||
// bug (model wrote a plan-mode plan and ExitPlanMode'd without firing any
|
||||
@@ -180,8 +180,8 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
// a model fires one AUQ then batches the rest into a "## Decisions to
|
||||
// confirm" plan write. runPlanSkillFloorCheck cannot detect that shape
|
||||
// (it exits on first AUQ); runPlanSkillCounting can.
|
||||
'plan-eng-multi-finding-batching': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-eng-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/review.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-eng-multi-finding-batching.test.ts'],
|
||||
'plan-ceo-split-overflow': ['bin/gstack-config', 'plan-ceo-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'bin/gstack-question-preference', 'test/helpers/claude-pty-runner.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-ceo-split-overflow.test.ts'],
|
||||
'plan-eng-multi-finding-batching': ['bin/gstack-config', 'bin/gstack-skill-start', 'bin/gstack-skill-end', 'plan-eng-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/review.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-eng-multi-finding-batching.test.ts'],
|
||||
'plan-ceo-split-overflow': ['bin/gstack-config', 'plan-ceo-review/**', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'bin/gstack-question-preference', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty-screen.ts', 'test/pty-screen.test.ts', 'test/pty-screen-session.test.ts', 'test/fixtures/pty-screen/**', 'test/helpers/hermetic-skill-runtime.ts', 'test/hermetic-skill-runtime.test.ts', 'test/helpers/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-transcript.ts', 'test/plan-count-transcript.test.ts', 'test/helpers/plan-count-artifacts.ts', 'test/plan-count-artifacts.test.ts', 'test/helpers/eval-store.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-count-fixture.test.ts', 'test/fixtures/forcing-finding-seeds.ts', 'test/skill-e2e-plan-ceo-split-overflow.test.ts'],
|
||||
'brain-privacy-gate': ['bin/gstack-skill-start', 'bin/gstack-skill-end', 'scripts/resolvers/preamble/generate-brain-sync-block.ts', 'scripts/resolvers/preamble.ts', 'bin/gstack-brain-sync', 'bin/gstack-artifacts-init', 'bin/gstack-config', 'test/helpers/agent-sdk-runner.ts', 'test/skill-e2e-brain-privacy-gate.test.ts'],
|
||||
|
||||
// /setup-gbrain Path 4 (Remote MCP) — happy + bad-token end-to-end via
|
||||
@@ -361,7 +361,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
|
||||
|
||||
// Autoplan
|
||||
'autoplan-dual-voice': ['autoplan/**', 'codex/**', 'bin/gstack-codex-probe', 'scripts/resolvers/review.ts', 'scripts/resolvers/design.ts', 'test/skill-e2e-autoplan-dual-voice.test.ts'],
|
||||
'autoplan-dual-voice': ['scripts/resolvers/composition.ts', 'test/autoplan-review-discovery.test.ts', 'test/autoplan-phase-order.test.ts', 'autoplan/**', 'codex/**', 'bin/gstack-codex-probe', 'scripts/resolvers/review.ts', 'scripts/resolvers/design.ts', 'test/skill-e2e-autoplan-dual-voice.test.ts'],
|
||||
|
||||
// Multi-provider benchmark adapters — live API smoke against real claude/codex/gemini CLIs
|
||||
'benchmark-providers-live': ['bin/gstack-model-benchmark', 'test/helpers/providers/**', 'test/helpers/benchmark-runner.ts', 'test/helpers/pricing.ts', 'test/skill-e2e-benchmark-providers.test.ts'],
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { hasNativePlanCompletion, classifyPlanCountFrame } from './helpers/claude-pty-runner';
|
||||
import { hasNativePlanCompletion, hasNativePlanTerminal, classifyPlanCountFrame } from './helpers/claude-pty-runner';
|
||||
import type { PlanCountTranscript } from './helpers/plan-count-transcript';
|
||||
|
||||
const CAPTURED_CALL = {
|
||||
@@ -239,3 +239,255 @@ process.stdin.resume();
|
||||
} finally { clearTimeout(timer); child.kill('SIGKILL'); fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
}, 40000);
|
||||
});
|
||||
|
||||
// Last answered call and last finalized native text at the exact G soft-terminal race.
|
||||
const CAPTURED_ENG_STREAM = {
|
||||
"status": "ready",
|
||||
"calls": [
|
||||
{
|
||||
"sessionId": "4c3d29fb-2a00-4e5d-b3c6-9b584986e340",
|
||||
"toolUseId": "toolu_011KrMyU6fjW7RQ7aAVJ4DfA",
|
||||
"questions": [
|
||||
{
|
||||
"question": "D7 — Performance: 5 sequential IDP calls that could be Promise.all'd\nProject/branch/task: Multi-tenant Auth Refactor on main\nELI10: During token validation, the code makes 5 API calls to the identity provider (IDP) one at a time. But the calls are independent — none uses the result of the previous one. Changing from sequential to Promise.all([…]) takes one line of code and cuts auth latency by ~5x. At 100ms per call, sequential = ~500ms; parallel = ~100ms. This matters most under load: auth is on the critical path of every authenticated request.\nStakes if we pick wrong: Every authenticated request pays a 400ms tax that could be eliminated with one line. Under load, slow auth creates timeout cascades — auth is typically among the first things to degrade.\nRecommendation: A — trivially fix it now. The plan itself says “calls are independent” and “trivially” parallelizable. The only reason not to is if there’s a hidden ordering dependency the plan doesn’t mention.\nCompleteness: Note: options differ in kind, not coverage — no completeness score.\nPros / cons:\nA) Parallelize the 5 IDP calls with Promise.all in this PR (recommended)\n ✅ 5x latency reduction on the auth critical path for free — one line change\n ✅ Under load, parallel calls bound latency to the slowest single call instead of sum of all\n ❌ If any hidden ordering dependency exists (not mentioned in the plan), it will surface as a test failure — easily caught\nB) Defer parallelization to a follow-up performance PR\n ✅ Keeps this PR focused on the refactor\n ❌ The plan already flagged this as trivial; deferring creates a PR that self-documents debt without resolving it\nNet: The plan already identified this as trivial. Doing it now costs 5 minutes and makes the auth path meaningfully faster for all users. <gstack-qid:plan-eng-review-perf-idp-sequential-calls>",
|
||||
"header": "Performance",
|
||||
"options": [
|
||||
{
|
||||
"label": "A) Parallelize IDP calls now (Recommended)",
|
||||
"description": "Promise.all the 5 independent calls. ~5 min, 5x auth latency improvement."
|
||||
},
|
||||
{
|
||||
"label": "B) Defer to a follow-up PR",
|
||||
"description": "Keeps this PR focused on refactor; acknowledged debt."
|
||||
}
|
||||
],
|
||||
"multiSelect": false
|
||||
}
|
||||
],
|
||||
"answered": true,
|
||||
"failed": false,
|
||||
"answers": {
|
||||
"D7 — Performance: 5 sequential IDP calls that could be Promise.all'd\nProject/branch/task: Multi-tenant Auth Refactor on main\nELI10: During token validation, the code makes 5 API calls to the identity provider (IDP) one at a time. But the calls are independent — none uses the result of the previous one. Changing from sequential to Promise.all([…]) takes one line of code and cuts auth latency by ~5x. At 100ms per call, sequential = ~500ms; parallel = ~100ms. This matters most under load: auth is on the critical path of every authenticated request.\nStakes if we pick wrong: Every authenticated request pays a 400ms tax that could be eliminated with one line. Under load, slow auth creates timeout cascades — auth is typically among the first things to degrade.\nRecommendation: A — trivially fix it now. The plan itself says “calls are independent” and “trivially” parallelizable. The only reason not to is if there’s a hidden ordering dependency the plan doesn’t mention.\nCompleteness: Note: options differ in kind, not coverage — no completeness score.\nPros / cons:\nA) Parallelize the 5 IDP calls with Promise.all in this PR (recommended)\n ✅ 5x latency reduction on the auth critical path for free — one line change\n ✅ Under load, parallel calls bound latency to the slowest single call instead of sum of all\n ❌ If any hidden ordering dependency exists (not mentioned in the plan), it will surface as a test failure — easily caught\nB) Defer parallelization to a follow-up performance PR\n ✅ Keeps this PR focused on the refactor\n ❌ The plan already flagged this as trivial; deferring creates a PR that self-documents debt without resolving it\nNet: The plan already identified this as trivial. Doing it now costs 5 minutes and makes the auth path meaningfully faster for all users. <gstack-qid:plan-eng-review-perf-idp-sequential-calls>": "A) Parallelize IDP calls now (Recommended)"
|
||||
},
|
||||
"unansweredQuestionIndices": [],
|
||||
"answeredAt": "2026-09-08T18:46:43.934Z"
|
||||
}
|
||||
],
|
||||
"assistantMessages": [
|
||||
{
|
||||
"sessionId": "4c3d29fb-2a00-4e5d-b3c6-9b584986e340",
|
||||
"text": "Tests decision: add characterization tests for legacyAuthFlow() before rewriting. Moving to Section 4: Performance Review.\n\n**Finding 4 [P2] (confidence: 9/10):** The plan states: \"Token validation issues 5 sequential API calls to the IDP; they could be parallelized via Promise.all trivially (calls are independent).\"\n\nThis is a free 5x latency improvement being left on the table. At 100ms per IDP call (typical), sequential = ~500ms per auth validation. Parallel via Promise.all = ~100ms.",
|
||||
"timestamp": "2026-09-08T18:46:30.531Z"
|
||||
}
|
||||
]
|
||||
};
|
||||
const CAPTURED_DX_HANDOFF = {
|
||||
"sessionId": "cc1b321f-f306-4086-a236-34dd30f457be",
|
||||
"toolUseId": "toolu_01AGy3uosDrjJJUv1aKGkhRY",
|
||||
"questions": [
|
||||
{
|
||||
"question": "DX review done. 5 findings resolved, 7 implementation tasks written. What's next?\n\nEvalKit SDK / DX POLISH review / main\nThe DX review found API design issues (argument order, auth error), a broken getting-started path, a TTHW gap, and a cliff-edge upgrade. All resolved with user decisions. The full output is at gstack-test-plan-devex.md. Eng review is the required gate before shipping.\n<gstack-qid:devex-next-steps>",
|
||||
"header": "Next step",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{
|
||||
"label": "Run /plan-eng-review (Recommended)",
|
||||
"description": "Required gate. The arg-order fix and --skip-ci flag both have architectural implications that eng review should validate."
|
||||
},
|
||||
{
|
||||
"label": "Ready to implement",
|
||||
"description": "Start building the 7 tasks. Run /devex-review after shipping to measure actual TTHW vs the <2-min target."
|
||||
},
|
||||
{
|
||||
"label": "Skip, handle manually",
|
||||
"description": "You'll manage next steps outside this session."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"answered": true,
|
||||
"failed": false,
|
||||
"answers": {
|
||||
"DX review done. 5 findings resolved, 7 implementation tasks written. What's next?\n\nEvalKit SDK / DX POLISH review / main\nThe DX review found API design issues (argument order, auth error), a broken getting-started path, a TTHW gap, and a cliff-edge upgrade. All resolved with user decisions. The full output is at gstack-test-plan-devex.md. Eng review is the required gate before shipping.\n<gstack-qid:devex-next-steps>": "Run /plan-eng-review (Recommended)"
|
||||
},
|
||||
"unansweredQuestionIndices": [],
|
||||
"answeredAt": "2026-09-08T18:46:16.771Z"
|
||||
};
|
||||
|
||||
describe('guarded native terminal and report lifecycle', () => {
|
||||
test('G Eng streamed summary cannot finish while native text/file is still pending', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const visible = '● Performance decision: parallelize the 5 IDP calls. All 4 review sections complete.\n\nCompletionSummary:\n- Step 0: Scope reduced\n- Architecture Review:1 issue found\n- Code Quality Review:1 issue found\n';
|
||||
expect(classifyPlanCountFrame(visible)).toBe('completion_summary');
|
||||
const t: PlanCountTranscript = structuredClone(CAPTURED_ENG_STREAM);
|
||||
const answerTime = Math.max(...t.calls.map(c => Date.parse(c.answeredAt!)));
|
||||
fs.utimesSync(f.file, (answerTime + 1) / 1000, (answerTime + 1) / 1000);
|
||||
expect(hasNativePlanTerminal(t, f.file, f.startedAt, 'completion_summary')).toBe(false);
|
||||
t.assistantMessages.push({ sessionId: t.calls[0]!.sessionId, timestamp: new Date(answerTime + 2).toISOString(),
|
||||
text: 'Completion Summary:\n- Architecture Review: 1 issue resolved\n- Code Quality Review: 1 issue resolved\n- Test Review: 1 gap resolved\n- Performance Review: 1 issue resolved\n' });
|
||||
for (const content of ['', '# Draft\n', '## GSTACK REVIEW REPORT\n', REPORT.replace('NO UNRESOLVED DECISIONS', ''), REPORT.replace('| DX Review | clean | resolved |\n', '')]) {
|
||||
fs.writeFileSync(f.file, content); fs.utimesSync(f.file, (answerTime + 3) / 1000, (answerTime + 3) / 1000);
|
||||
expect(hasNativePlanTerminal(t, f.file, f.startedAt, 'completion_summary'), content).toBe(false);
|
||||
}
|
||||
fs.writeFileSync(f.file, REPORT); fs.utimesSync(f.file, (answerTime + 3) / 1000, (answerTime + 3) / 1000);
|
||||
expect(hasNativePlanTerminal(t, f.file, f.startedAt, 'completion_summary')).toBe(true);
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
|
||||
test('the actual post-report DX handoff does not require rewriting an unchanged report', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const t: PlanCountTranscript = structuredClone(f.transcript);
|
||||
const sessionId = t.calls[0]!.sessionId;
|
||||
t.calls.push({ ...structuredClone(CAPTURED_DX_HANDOFF), sessionId });
|
||||
t.planReadyRequests = [{ sessionId, toolUseId: 'exit', timestamp: '2026-09-08T18:46:27.000Z', failed: false }];
|
||||
expect(hasNativePlanTerminal(t, f.file, f.startedAt, 'plan_ready')).toBe(true);
|
||||
// Unknown, free-form, partly unanswered and substantive menus remain
|
||||
// freshness boundaries, even if they reuse the same next-step header.
|
||||
for (const mutate of [
|
||||
(c: any) => { c.questions[0].question = c.questions[0].question.replace('devex-next-steps', 'new-change'); },
|
||||
(c: any) => { c.answers[c.questions[0].question] = 'Also change the SDK auth behavior'; },
|
||||
(c: any) => { c.questions[0].options.push({ label: 'Change the error message now' }); },
|
||||
(c: any) => { c.unansweredQuestionIndices = [1]; },
|
||||
]) {
|
||||
const changed = structuredClone(t); mutate(changed.calls[1]);
|
||||
expect(hasNativePlanTerminal(changed, f.file, f.startedAt, 'plan_ready')).toBe(false);
|
||||
}
|
||||
t.planReadyRequests[0]!.failed = true;
|
||||
expect(hasNativePlanTerminal(t, f.file, f.startedAt, 'plan_ready')).toBe(false);
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
|
||||
test('pending/refused/mixed calls, stale or incomplete reports and quoted summaries cannot complete', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const t = structuredClone(f.transcript);
|
||||
t.planReadyRequests = [{ sessionId: t.calls[0]!.sessionId, toolUseId: 'exit', timestamp: CAPTURED_FINAL.timestamp, failed: false }];
|
||||
expect(hasNativePlanTerminal(t, f.file, f.startedAt, 'plan_ready')).toBe(true);
|
||||
const retried = structuredClone(t);
|
||||
retried.calls.unshift({ ...structuredClone(retried.calls[0]!), toolUseId: 'failed-before-retry', answered: false, failed: true, answers: undefined });
|
||||
expect(hasNativePlanTerminal(retried, f.file, f.startedAt, 'plan_ready')).toBe(true);
|
||||
retried.calls[0]!.questions[0]!.question = 'A different unresolved question';
|
||||
expect(hasNativePlanTerminal(retried, f.file, f.startedAt, 'plan_ready')).toBe(false);
|
||||
for (const mutate of [
|
||||
(t: any) => { t.calls[0].answered = false; },
|
||||
(t: any) => { t.calls[0].failed = true; },
|
||||
(t: any) => { t.calls[0].sessionId = 'other'; },
|
||||
(t: any) => { t.planReadyRequests[0].failed = true; },
|
||||
(t: any) => { t.planReadyRequests[0].timestamp = t.calls[0].answeredAt; },
|
||||
(t: any) => { t.planReadyRequests = []; },
|
||||
(t: any) => { t.status = 'missing'; },
|
||||
]) { const changed = structuredClone(t); mutate(changed); expect(hasNativePlanTerminal(changed, f.file, f.startedAt, 'plan_ready')).toBe(false); }
|
||||
const summary = 'Completion Summary:\n- Review: 4 findings resolved\n';
|
||||
for (const text of ['> ' + summary, 'Example:\n' + summary, '```text\n' + summary + '```', 'Here is an example:\n\n' + summary, 'Here is a quoted result:\n```text\n' + summary + '```', 'I cannot complete the review.\n' + summary, 'Completion Summary:\n', summary + 'Waiting for your answer.']) {
|
||||
t.assistantMessages[0]!.text = text;
|
||||
expect(hasNativePlanTerminal(t, f.file, f.startedAt, 'completion_summary'), text).toBe(false);
|
||||
}
|
||||
const old = Date.parse('2026-09-08T17:40:00Z') / 1000; fs.utimesSync(f.file, old, old);
|
||||
expect(hasNativePlanTerminal(t, f.file, f.startedAt, 'plan_ready')).toBe(false);
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('real PTY waits through streamed summary, another question and partial report writes', async () => {
|
||||
await Promise.all(['completion_summary', 'plan_ready'].map(async (terminal) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'guarded-terminal-pty-'));
|
||||
const fake = path.join(dir, 'fake-claude');
|
||||
const worker = path.join(dir, 'worker.ts');
|
||||
const output = path.join(dir, 'plan.md');
|
||||
const record = path.join(dir, 'inputs.jsonl');
|
||||
const events = path.join(dir, 'events.jsonl');
|
||||
const result = path.join(dir, 'result.json');
|
||||
const runner = pathToFileURL(path.join(import.meta.dir, 'helpers/claude-pty-runner.ts')).href;
|
||||
fs.writeFileSync(fake, `#!${process.execPath}\n` + String.raw`
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
const sessionId = 'guarded-terminal-fixture';
|
||||
const project = path.join(process.env.CLAUDE_CONFIG_DIR, 'projects', sessionId);
|
||||
fs.mkdirSync(project, { recursive: true });
|
||||
const file = path.join(project, sessionId + '.jsonl');
|
||||
const native = (role, content, extra = {}) => fs.appendFileSync(file, JSON.stringify({
|
||||
cwd: process.cwd(), sessionId, isSidechain: false, timestamp: new Date().toISOString(), message: { role, content }, ...extra,
|
||||
}) + '\n');
|
||||
const event = name => fs.appendFileSync(process.env.PROBE_EVENTS, JSON.stringify({ name, at: Date.now() }) + '\n');
|
||||
const q = { header: 'Tests', question: 'D2 — Test finding: add the missing retry assertion? <gstack-qid:plan-eng-test-missing-retry>', options: [{ label: 'Add retry assertion' }, { label: 'Leave untested' }] };
|
||||
const ask = id => native('assistant', [{ type: 'tool_use', id, name: 'AskUserQuestion', input: { questions: [q] } }]);
|
||||
const answer = id => native('user', [{ type: 'tool_result', tool_use_id: id, content: 'Answered.' }], { toolUseResult: { answers: { [q.question]: q.options[0].label } } });
|
||||
const summary = 'Completion Summary:\n- Architecture Review: 1 issue resolved\n- Test Review: 1 missing test resolved\n';
|
||||
const ready = () => process.stdout.write('\nClaude has written up a plan and is ready to execute. Would you like to proceed?\n❯ 1. Yes, and use auto mode\n 2. Yes, manually approve edits\n 3. Tell Claude what to change\n');
|
||||
let stage = 0;
|
||||
process.stdin.setRawMode?.(true);
|
||||
process.stdin.on('data', data => {
|
||||
fs.appendFileSync(process.env.PROBE_INPUTS, JSON.stringify(data.toString()) + '\n');
|
||||
if (stage === 0) {
|
||||
stage = 1; ask('first'); answer('first');
|
||||
event('streamed-heading'); process.stdout.write('● Completion Summary:\n- Architecture Review: 1 issue resolved\n');
|
||||
setTimeout(() => {
|
||||
ask('second'); event('pending-question');
|
||||
process.stdout.write('\n← ☐ Tests ✔ Submit →\n' + q.question + '\n❯ 1. Add retry assertion\n 2. Leave untested\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel\n');
|
||||
}, 3500);
|
||||
return;
|
||||
}
|
||||
if (stage === 1 && data.toString().includes('1')) {
|
||||
stage = 2; answer('second'); event('answered-second');
|
||||
fs.writeFileSync(process.env.PROBE_PLAN, '# Draft\n');
|
||||
native('assistant', [{ type: 'text', text: summary }], { timestamp: new Date(Date.now() + 1).toISOString() });
|
||||
process.stdout.write('\n● ' + summary);
|
||||
setTimeout(() => {
|
||||
event('partial-report'); fs.writeFileSync(process.env.PROBE_PLAN, process.env.PROBE_REPORT.split('VERDICT:')[0]);
|
||||
}, 3000);
|
||||
setTimeout(() => {
|
||||
event('complete-report'); fs.writeFileSync(process.env.PROBE_PLAN, process.env.PROBE_REPORT);
|
||||
if (process.env.PROBE_TERMINAL === 'plan_ready') {
|
||||
native('assistant', [{ type: 'tool_use', id: 'rejected-ready', name: 'ExitPlanMode', input: {} }]);
|
||||
native('user', [{ type: 'tool_result', tool_use_id: 'rejected-ready', is_error: true, content: 'Gate not ready' }]);
|
||||
ready();
|
||||
setTimeout(() => {
|
||||
event('valid-native-ready'); native('assistant', [{ type: 'tool_use', id: 'valid-ready', name: 'ExitPlanMode', input: {} }]); ready();
|
||||
}, 2500);
|
||||
}
|
||||
}, 6000);
|
||||
} else if (stage === 2) event('unexpected-plan-approval-input');
|
||||
});
|
||||
process.stdin.resume();
|
||||
`);
|
||||
fs.chmodSync(fake, 0o755);
|
||||
fs.writeFileSync(worker, `import { runPlanSkillCounting } from ${JSON.stringify(runner)};\n` +
|
||||
`const result = await runPlanSkillCounting({skillName:'plan-eng-review',slashCommand:'/plan-eng-review',followUpPrompt:'# Completion fixture',expectedPlanPath:${JSON.stringify(output)},isLastStep0AUQ:()=>false,isReviewAUQ:()=>true,reviewCountCeiling:8,timeoutMs:30000,env:${JSON.stringify({PROBE_PLAN:output,PROBE_INPUTS:record,PROBE_EVENTS:events,PROBE_REPORT:REPORT,PROBE_TERMINAL:terminal})}});\n` +
|
||||
`await Bun.write(${JSON.stringify(result)},JSON.stringify(result));\n`);
|
||||
const child = Bun.spawn([process.execPath, worker], { env: { ...process.env,
|
||||
EVALS_HERMETIC: '1', EVALS_RUN_ID: '', BROWSE_TERMINAL_BINARY: fake }, stdout: 'pipe', stderr: 'pipe' });
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), 45000);
|
||||
try {
|
||||
const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
||||
expect(code, stdout + stderr).toBe(0);
|
||||
const observation = JSON.parse(fs.readFileSync(result, 'utf8'));
|
||||
expect(fs.existsSync(output), `D19: report must exist before stopping; ${observation.summary}`).toBe(true);
|
||||
expect(observation.outcome, JSON.stringify(observation)).toBe(terminal);
|
||||
expect(observation.reviewCount).toBe(2);
|
||||
expect(fs.readFileSync(output, 'utf8')).toBe(REPORT);
|
||||
const recorded = fs.readFileSync(events, 'utf8').trim().split('\n').map(line => JSON.parse(line));
|
||||
expect(recorded.map(e => e.name)).toEqual(['streamed-heading', 'pending-question', 'answered-second', 'partial-report', 'complete-report', ...(terminal === 'plan_ready' ? ['valid-native-ready'] : [])]);
|
||||
expect(fs.readFileSync(record, 'utf8').trim().split('\n').map(line => JSON.parse(line))).toEqual(['/plan-eng-review\r', '1\r']);
|
||||
if (terminal === 'plan_ready') expect(observation.transcript.planReadyRequests.map(r => r.failed)).toEqual([true, false]);
|
||||
} finally { clearTimeout(timer); child.kill('SIGKILL'); await child.exited; fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
}));
|
||||
}, 50000);
|
||||
|
||||
// Actual finalized CEO prose uses a relative output name, not the DevEx fallback's
|
||||
// exact “Plan written to:” sentence. Its caller-owned file is checked independently.
|
||||
test('captured completed CEO prose supports the ordinary summary path', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
f.transcript.assistantMessages = [{
|
||||
sessionId: f.transcript.calls[0]!.sessionId,
|
||||
timestamp: "2026-09-08T17:58:52.319Z",
|
||||
text: "CEO review complete. Here's the summary:\n\n**Plan:** Payment Processing — Test Coverage\n**Mode:** HOLD SCOPE | **Verdict:** CLEARED (with 4 additions)\n\n**4 gaps found and resolved through interactive review:**\n\n1. **CRITICAL** — \"assert correct receipt is generated\" is too vague. Spec: assert `charge_id`, `amount`, `currency` (at minimum) match the charge response.\n2. **WARNING** — \"fails clean\" doesn't name what's raised. Spec: `assert raises <PaymentFailedError>` (resolve exact class from error hierarchy at implementation).\n3. **WARNING** — Retry count is implicit. Spec: `assert len(mock_stripe_call_history) == 2` explicitly in Test 2.\n4. **WARNING** — No test file path. Spec: resolve by convention-matching existing test structure.\n\nEverything else (architecture, security, data flow, performance, observability, deployment, trajectory) is clean. Section 11 skipped — no UI scope. Review output written to `gstack-test-plan-ceo-paired.md`."
|
||||
}];
|
||||
expect(hasNativePlanCompletion(f.transcript, f.file, f.startedAt)).toBe(false);
|
||||
expect(hasNativePlanTerminal(f.transcript, f.file, f.startedAt, 'completion_summary')).toBe(true);
|
||||
f.transcript.calls = [];
|
||||
expect(hasNativePlanTerminal(f.transcript, f.file, f.startedAt, 'completion_summary')).toBe(false);
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
@@ -168,6 +168,7 @@ try {
|
||||
{ name: 'design-direct', skillName: 'plan-design-review', prompt: PROMPT, mode: 'direct-finding' },
|
||||
{ name: 'design-batched', skillName: 'plan-design-review', prompt: PROMPT, mode: 'batched-finding' },
|
||||
{ name: 'failed-native', skillName: 'plan-design-review', prompt: PROMPT, mode: 'failed-call' },
|
||||
{ name: 'native-permission-policy', skillName: 'plan-eng-review', prompt: PROMPT, mode: 'native-permission-policy', report: path.join(dir, 'native-policy-report.md') },
|
||||
{ name: 'permission-lifecycle', skillName: 'plan-design-review', prompt: PROMPT, mode: 'permission-lifecycle' },
|
||||
{ name: 'permission', skillName: 'plan-design-review', prompt: PROMPT, mode: 'permission' },
|
||||
{ name: 'missing-transcript', skillName: 'plan-design-review', prompt: PROMPT, mode: 'missing-transcript' },
|
||||
@@ -189,13 +190,17 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
const record = (event) => fs.appendFileSync(process.env.FIXTURE_RECORD, JSON.stringify(event) + '\n');
|
||||
// Older fake menus used CR as a line separator. Emit actual terminal newlines;
|
||||
// real cursor/erase behavior is exercised separately in pty-screen-session.test.ts.
|
||||
const render = text => process.stdout.write(text.replace(/\r(?!\n)/g, '\r\n'));
|
||||
const sessionId = 'fixture-' + process.pid;
|
||||
const project = path.join(process.env.CLAUDE_CONFIG_DIR, 'projects', sessionId);
|
||||
fs.mkdirSync(project, { recursive: true });
|
||||
const native = (role, content, extra = {}) => {
|
||||
if (process.env.FIXTURE_MODE === 'missing-transcript') return;
|
||||
fs.appendFileSync(path.join(project, sessionId + '.jsonl'), JSON.stringify({
|
||||
cwd: process.cwd(), sessionId, isSidechain: false, message: { role, content }, ...extra,
|
||||
cwd: process.cwd(), sessionId, isSidechain: false, message: { role, content },
|
||||
...(process.env.FIXTURE_MODE === 'native-permission-policy' ? { timestamp: new Date().toISOString() } : {}), ...extra,
|
||||
}) + '\n');
|
||||
};
|
||||
native('assistant', [{ type: 'text', text: 'Fixture CLI started.' }]);
|
||||
@@ -240,7 +245,7 @@ record({
|
||||
? fs.readFileSync(path.join(skillDir, 'sections', 'review-sections.md'), 'utf8') : null,
|
||||
});
|
||||
fs.writeFileSync(path.join(process.env.GSTACK_HOME, 'child-owned'), 'isolated');
|
||||
if (process.env.FIXTURE_MODE === 'exit') process.stdout.write('\x1b[?25lSTARTUP_DIAGNOSTIC fixture CLI booted\x1b[?25h\n');
|
||||
if (process.env.FIXTURE_MODE === 'exit') render('\x1b[?25lSTARTUP_DIAGNOSTIC fixture CLI booted\x1b[?25h\n');
|
||||
process.stdin.setRawMode?.(true);
|
||||
let firstInput = true;
|
||||
let completion;
|
||||
@@ -254,12 +259,40 @@ process.stdin.on('data', (data) => {
|
||||
const input = data.toString('utf8');
|
||||
record({ type: 'input', data: input });
|
||||
if (!firstInput) {
|
||||
if (process.env.FIXTURE_MODE === 'native-permission-policy') {
|
||||
if (permissionStage === 'done') { record({ type: 'unexpected-policy-input', input }); return; }
|
||||
selected += input.replace(/\r/g, '');
|
||||
if (input.includes('\r')) {
|
||||
permissionStage = 'done';
|
||||
const label = nativeQuestions[0].options[Number(selected) - 1]?.label;
|
||||
record({ type: 'native-policy-answer', selected, label });
|
||||
native('user', [{ type: 'tool_result', tool_use_id: 'question-' + callId, content: 'Answered.' }],
|
||||
{ toolUseResult: { answers: { [nativeQuestions[0].question]: label } } });
|
||||
// The answered menu survives a provisional heading and partial report.
|
||||
// Neither may turn its permission wording into a fresh default1.
|
||||
const summary = 'Completion Summary:\n- Architecture Review: file policy issue resolved\n';
|
||||
render('\n● ' + summary);
|
||||
native('assistant', [{ type: 'text', text: summary }], { timestamp: new Date(Date.now() + 1).toISOString() });
|
||||
setTimeout(() => {
|
||||
fs.writeFileSync(process.env.FIXTURE_EXPECTED_REPORT, '## GSTACK REVIEW REPORT\n');
|
||||
record({ type: 'partial-policy-report' });
|
||||
}, 3000);
|
||||
completion = setTimeout(() => {
|
||||
fs.writeFileSync(process.env.FIXTURE_EXPECTED_REPORT, '# Reviewed plan\n\n## GSTACK REVIEW REPORT\n\n' +
|
||||
'| Review | Status | Findings |\n|---|---|---|\n| Eng Review | clean | policy resolved |\n\n' +
|
||||
'VERDICT: ENG CLEARED\n\nNO UNRESOLVED DECISIONS\n');
|
||||
record({ type: 'complete-policy-report' });
|
||||
render('\nGSTACK REVIEW REPORT\n');
|
||||
}, 6500);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (process.env.FIXTURE_MODE === 'damaged-menu') {
|
||||
if (question === 0) record({ type: 'input-during-prose', input });
|
||||
else if (input.includes('\r')) {
|
||||
record({ type: 'damaged-menu-answer', input });
|
||||
answer();
|
||||
process.stdout.write('\nGSTACK REVIEW REPORT\n');
|
||||
render('\nGSTACK REVIEW REPORT\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -267,14 +300,14 @@ process.stdin.on('data', (data) => {
|
||||
if (question === 0 && input === '\x1b[Z') {
|
||||
question = 1;
|
||||
record({ type: 'returned-to-unanswered' });
|
||||
process.stdout.write('\r← ☒ Routing setup ☐ Cross-project ✔ Submit →\r' +
|
||||
render('\r← ☒ Routing setup ☐ Cross-project ✔ Submit →\r' +
|
||||
'│ Enable cross-project learnings?\r❯1.Enable cross-project\r2.Keep project-scoped\r');
|
||||
} else if (question === 1) {
|
||||
selected += input.replace(/\r/g, '');
|
||||
if (input.includes('\r')) {
|
||||
question = 2;
|
||||
record({ type: 'answered-remaining-tab', selected });
|
||||
process.stdout.write(submitPanel(true));
|
||||
render(submitPanel(true));
|
||||
}
|
||||
} else if (question === 2 && input === '\r') {
|
||||
// Native JSONL can arrive only after the whole packet is submitted.
|
||||
@@ -285,7 +318,7 @@ process.stdin.on('data', (data) => {
|
||||
[submitQuestions[1].question]: submitQuestions[1].options[Number(selected) - 1]?.label,
|
||||
} } });
|
||||
record({ type: 'submitted-both-answers' });
|
||||
process.stdout.write('\nGSTACK REVIEW REPORT\n');
|
||||
render('\nGSTACK REVIEW REPORT\n');
|
||||
} else record({ type: 'unexpected-submit-input', question, input });
|
||||
return;
|
||||
}
|
||||
@@ -299,22 +332,22 @@ process.stdin.on('data', (data) => {
|
||||
record({ type: 'mode-answer', selected, label });
|
||||
native('user', [{ type: 'tool_result', tool_use_id: 'question-' + callId, content: 'Your questions have been answered.' }],
|
||||
{ toolUseResult: { answers: { [modeQuestion.question]: label } } });
|
||||
process.stdout.write('\nGSTACK REVIEW REPORT\n');
|
||||
render('\nGSTACK REVIEW REPORT\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (['direct-finding', 'failed-call'].includes(process.env.FIXTURE_MODE) && input.includes('\r')) {
|
||||
answer();
|
||||
process.stdout.write('\nGSTACK REVIEW REPORT\n');
|
||||
render('\nGSTACK REVIEW REPORT\n');
|
||||
return;
|
||||
}
|
||||
if (process.env.FIXTURE_MODE === 'batched-finding' && input.includes('\r')) {
|
||||
if (question === 1) {
|
||||
question = 2;
|
||||
process.stdout.write('\r☐Loading\rD2 — Define the loading state <gstack-qid:plan-design-review-loading>\r❯1.Add spinner\r2.Keep blank\r');
|
||||
render('\r☐Loading\rD2 — Define the loading state <gstack-qid:plan-design-review-loading>\r❯1.Add spinner\r2.Keep blank\r');
|
||||
} else {
|
||||
answer();
|
||||
process.stdout.write('\nGSTACK REVIEW REPORT\n');
|
||||
render('\nGSTACK REVIEW REPORT\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -322,33 +355,33 @@ process.stdin.on('data', (data) => {
|
||||
if (permissionStage === 'first') {
|
||||
record({ type: 'permission-grant', number: 1, input });
|
||||
permissionStage = 'pending';
|
||||
process.stdout.write(filePermission()); // Same pending menu, appended redraw.
|
||||
render(filePermission()); // Same pending menu, appended redraw.
|
||||
completion = setTimeout(() => {
|
||||
permissionStage = 'completed';
|
||||
process.stdout.write('\n⎿ Wrote320linesto../fixture/gstack-test-plan-design.md\n' + '·'.repeat(1600));
|
||||
render('\n⎿ Wrote320linesto../fixture/gstack-test-plan-design.md\n' + '·'.repeat(1600));
|
||||
completion = setTimeout(() => {
|
||||
permissionStage = 'second';
|
||||
process.stdout.write(filePermission()); // New request, identical file/text.
|
||||
render(filePermission()); // New request, identical file/text.
|
||||
}, 4500);
|
||||
}, 4500);
|
||||
} else if (permissionStage === 'second') {
|
||||
record({ type: 'permission-grant', number: 2, input });
|
||||
permissionStage = 'question';
|
||||
process.stdout.write('\n⎿ Added2lines\n');
|
||||
render('\n⎿ Added2lines\n');
|
||||
ask([questionMetadata('File policy', 'Do you want to create gstack-test-plan-design.md?', ['Yes', 'No'])]);
|
||||
process.stdout.write('\n☐ File policy\nDo you want to create gstack-test-plan-design.md?\n❯1.Yes\n2.No\n' +
|
||||
render('\n☐ File policy\nDo you want to create gstack-test-plan-design.md?\n❯1.Yes\n2.No\n' +
|
||||
'Enter to select · ↑/↓ to navigate · Esc to cancel\n');
|
||||
} else if (permissionStage === 'question') {
|
||||
record({ type: 'file-policy-answer', input });
|
||||
native('user', [{ type: 'tool_result', tool_use_id: 'question-' + callId, content: 'Your question has been answered.' }],
|
||||
{ toolUseResult: { answers: { [nativeQuestions[0].question]: input.startsWith('2') ? 'No' : 'Yes' } } });
|
||||
permissionStage = 'done';
|
||||
process.stdout.write('\nGSTACK REVIEW REPORT\n');
|
||||
render('\nGSTACK REVIEW REPORT\n');
|
||||
} else record({ type: 'unexpected-permission-input', stage: permissionStage, input });
|
||||
return;
|
||||
}
|
||||
if (process.env.FIXTURE_MODE === 'permission' && input.includes('\r')) {
|
||||
process.stdout.write('\nGSTACK REVIEW REPORT\n');
|
||||
render('\nGSTACK REVIEW REPORT\n');
|
||||
return;
|
||||
}
|
||||
if (process.env.FIXTURE_MODE !== 'prerequisite') return;
|
||||
@@ -360,40 +393,40 @@ process.stdin.on('data', (data) => {
|
||||
const labels = ['Run /office-hours now', 'Skip — proceed with standard review'];
|
||||
if (process.env.FIXTURE_SKIP_INDEX === '1') labels.reverse();
|
||||
ask([questionMetadata('Prerequisite', 'No design doc found. Run /office-hours first?', labels)]);
|
||||
process.stdout.write('\r☐ Prerequisite\rNo design doc found. Run /office-hours first?\r❯1.' + labels[0] + '\r2.' + labels[1] + '\r');
|
||||
render('\r☐ Prerequisite\rNo design doc found. Run /office-hours first?\r❯1.' + labels[0] + '\r2.' + labels[1] + '\r');
|
||||
return;
|
||||
}
|
||||
selected += input.replace(/\r/g, '');
|
||||
if (input.includes('\r')) {
|
||||
answer();
|
||||
record({ type: 'prerequisite-answer', selected });
|
||||
process.stdout.write(selected === process.env.FIXTURE_SKIP_INDEX ? '\nGSTACK REVIEW REPORT\n' : '\nWRONG_PREREQUISITE_CHOICE\n');
|
||||
render(selected === process.env.FIXTURE_SKIP_INDEX ? '\nGSTACK REVIEW REPORT\n' : '\nWRONG_PREREQUISITE_CHOICE\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
firstInput = false;
|
||||
if (process.env.FIXTURE_MODE === 'exit') process.exit(7);
|
||||
if (process.env.FIXTURE_MODE === 'damaged-menu') {
|
||||
process.stdout.write('☐Stripe event types\nWhich event should the handler accept?\n❯1.Specify one canonical event\n2.Accept all events\n' +
|
||||
render('☐Stripe event types\nWhich event should the handler accept?\n❯1.Specify one canonical event\n2.Accept all events\n' +
|
||||
'·'.repeat(4200) + '\nMinimum required test cases:\n1.Happy path\n2.Email failure\n3.DB timeout\n4.Unknown event\n5.Unknown user\n❯1\n');
|
||||
completion = setTimeout(() => {
|
||||
question = 1;
|
||||
ask([questionMetadata('Test scope', 'Should the plan include an integration test? <gstack-qid:plan-ceo-test-scope>',
|
||||
['Unit tests only for now (recommended)', 'Unit tests + one integration test', 'Integration test only'])]);
|
||||
process.stdout.write('\n☐Test scope\nShould the plan include an integration test? <gstack-qid:plan-ceo-test-scope>\n' +
|
||||
render('\n☐Test scope\nShould the plan include an integration test? <gstack-qid:plan-ceo-test-scope>\n' +
|
||||
'❯1.Unittestsonlyfornow(recommended)\n2Uni tsts + one integration test\n3.Integrationtestonly\n' +
|
||||
'Enter to select · ↑/↓ to navigate · Esc to cancel\n');
|
||||
}, 4100);
|
||||
return;
|
||||
}
|
||||
if (process.env.FIXTURE_MODE === 'damaged-submit') {
|
||||
process.stdout.write(submitPanel(false));
|
||||
render(submitPanel(false));
|
||||
return;
|
||||
}
|
||||
if (['late-mode', 'batched-mode'].includes(process.env.FIXTURE_MODE)) {
|
||||
if (process.env.FIXTURE_MODE === 'batched-mode') ask([modeQuestion,
|
||||
questionMetadata('Separate decision', 'Which fixture target should be used?', ['First target', 'Second target'])]);
|
||||
process.stdout.write('\r☐ Review mode\r' + modeQuestion.question + '\r❯1.DX EXPANSION\r2.DX POLISH\r3.DX TRIAGE\r');
|
||||
render('\r☐ Review mode\r' + modeQuestion.question + '\r❯1.DX EXPANSION\r2.DX POLISH\r3.DX TRIAGE\r');
|
||||
return;
|
||||
}
|
||||
if (['direct-finding', 'batched-finding', 'failed-call'].includes(process.env.FIXTURE_MODE)) {
|
||||
@@ -405,27 +438,33 @@ process.stdin.on('data', (data) => {
|
||||
const questions = [questionMetadata('Button style', 'D1 — How should the four header buttons differ? <gstack-qid:plan-design-review-button-hierarchy>', ['Filled primary', 'Ghost buttons'])];
|
||||
if (process.env.FIXTURE_MODE === 'batched-finding') questions.push(questionMetadata('Loading', 'D2 — Define the loading state <gstack-qid:plan-design-review-loading>', ['Add spinner', 'Keep blank']));
|
||||
ask(questions);
|
||||
process.stdout.write('\r☐Buttonstyle\r│D1—Howshouldthe4headerbuttonsbedifferentiated?<gstack-qid:plan-design-review-butn-hierarchy>\r❯1.Filledprimary\r2.Ghostbuttons\r');
|
||||
render('\r☐Buttonstyle\r│D1—Howshouldthe4headerbuttonsbedifferentiated?<gstack-qid:plan-design-review-butn-hierarchy>\r❯1.Filledprimary\r2.Ghostbuttons\r');
|
||||
return;
|
||||
}
|
||||
if (process.env.FIXTURE_MODE === 'prerequisite') {
|
||||
question = 1;
|
||||
const suffix = process.env.FIXTURE_CUSTOM === 'true' ? ' Context '.repeat(50) + 'routing-proof-after-240' : '';
|
||||
ask([questionMetadata('Setup', 'Which fixture setup should be used?' + suffix, ['First setup', 'Second setup'])]);
|
||||
process.stdout.write('\r☐ Setup\rWhich fixture setup should be used?\r❯1.First setup\r2.Second setup\r');
|
||||
render('\r☐ Setup\rWhich fixture setup should be used?\r❯1.First setup\r2.Second setup\r');
|
||||
return;
|
||||
}
|
||||
if (process.env.FIXTURE_MODE === 'native-permission-policy') {
|
||||
const prompt = 'D1 — Should we create a file that documents always allow access to the project? <gstack-qid:plan-eng-review-file-policy>';
|
||||
ask([questionMetadata('File policy', prompt, ['Create it', 'Keep current policy'])]);
|
||||
render('\n☐ File policy\n' + prompt + '\n❯1.Create it\n2.Keep current policy\nEnter to select · ↑/↓ to navigte · Esc to cancel\n');
|
||||
return;
|
||||
}
|
||||
if (process.env.FIXTURE_MODE === 'permission-lifecycle') {
|
||||
process.stdout.write(filePermission());
|
||||
render(filePermission());
|
||||
return;
|
||||
}
|
||||
if (process.env.FIXTURE_MODE === 'permission') {
|
||||
process.stdout.write('\rDo you want to create PLAN.md?\r❯1.Yes\r2.Yes, and switch to accept edits\r3.No\r');
|
||||
render('\rDo you want to create PLAN.md?\r❯1.Yes\r2.Yes, and switch to accept edits\r3.No\r');
|
||||
return;
|
||||
}
|
||||
// Longer than the old 3 s delayed fixture send: record that regression
|
||||
// even if the helper would otherwise return on our completion marker.
|
||||
completion = setTimeout(() => process.stdout.write('\nGSTACK REVIEW REPORT\n'), 4100);
|
||||
completion = setTimeout(() => render('\nGSTACK REVIEW REPORT\n'), 4100);
|
||||
});
|
||||
process.on('SIGINT', () => {
|
||||
clearTimeout(completion);
|
||||
@@ -456,16 +495,19 @@ const results = await Promise.all(cases.map(async (item) => ({
|
||||
slashCommand: '/' + item.skillName,
|
||||
followUpPrompt: item.prompt,
|
||||
fixtureFiles: item.files,
|
||||
expectedPlanPath: item.report,
|
||||
isLastStep0AUQ: () => false,
|
||||
isFirstReviewAUQ: ['direct-finding', 'batched-finding', 'failed-call'].includes(item.mode) ? designFirstReviewAUQ : undefined,
|
||||
isReviewAUQ: item.custom ? fp => fp.promptSnippet.includes('routing-proof-after-240') : undefined,
|
||||
pickAUQ: ['late-mode', 'batched-mode'].includes(item.mode) ? devexReviewModePick
|
||||
pickAUQ: item.mode === 'native-permission-policy' ? () => 2
|
||||
: ['late-mode', 'batched-mode'].includes(item.mode) ? devexReviewModePick
|
||||
: item.custom ? fp => fp.promptSnippet.includes('routing-proof-after-240') ? 1 : null : undefined,
|
||||
reviewCountCeiling: 8,
|
||||
timeoutMs: item.mode === 'permission-lifecycle' ? 22000 : 15000,
|
||||
firstAUQPick: () => ['late-mode', 'batched-mode'].includes(item.mode) ? 1 : 2,
|
||||
env: {
|
||||
FIXTURE_RECORD: item.record, FIXTURE_SKILL: item.skillName, FIXTURE_MODE: item.mode,
|
||||
FIXTURE_EXPECTED_REPORT: item.report ?? '',
|
||||
FIXTURE_CUSTOM: String(item.custom ?? false),
|
||||
FIXTURE_SKIP_INDEX: String(item.skipIndex ?? ''), FIXTURE_CONFIG_BIN: ${JSON.stringify(path.join(ROOT, 'bin/gstack-config'))},
|
||||
GSTACK_HOME: ${JSON.stringify(hostState)}, GSTACK_STATE_ROOT: ${JSON.stringify(hostState)},
|
||||
@@ -528,7 +570,7 @@ await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ results, onboard
|
||||
: item.mode === 'permission-lifecycle' ? '1\r1\r2\r'
|
||||
: item.mode === 'damaged-submit' ? '\x1b[Z2\r\r'
|
||||
: item.mode === 'batched-finding' ? '2\r1\r' : item.mode === 'batched-mode' ? '1\r'
|
||||
: ['direct-finding', 'failed-call', 'permission', 'late-mode', 'damaged-menu'].includes(item.mode) ? '2\r' : ''));
|
||||
: ['direct-finding', 'failed-call', 'permission', 'late-mode', 'damaged-menu', 'native-permission-policy'].includes(item.mode) ? '2\r' : ''));
|
||||
expect(fs.existsSync(startup.cwd)).toBe(false);
|
||||
expect(fs.existsSync(startup.stateRoot)).toBe(false);
|
||||
expect(() => process.kill(startup.pid, 0)).toThrow();
|
||||
@@ -559,6 +601,15 @@ await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ results, onboard
|
||||
expect(result.observation.transcript.calls[0].failure).toContain('is_error');
|
||||
expect(result.observation.transcript.calls[0].answered).toBe(false);
|
||||
}
|
||||
if (item.mode === 'native-permission-policy') {
|
||||
expect(events.filter(event => event.type === 'unexpected-policy-input')).toEqual([]);
|
||||
expect(events.filter(event => event.type.endsWith('-policy-report')).map(event => event.type))
|
||||
.toEqual(['partial-policy-report', 'complete-policy-report']);
|
||||
expect(fs.readFileSync(item.report!, 'utf8')).toContain('NO UNRESOLVED DECISIONS');
|
||||
expect(events.filter(event => event.type === 'native-policy-answer').map(event => event.selected)).toEqual(['2']);
|
||||
expect(result.observation.transcript.calls).toHaveLength(1);
|
||||
expect(Object.values(result.observation.transcript.calls[0].answers)).toEqual(['Keep current policy']);
|
||||
}
|
||||
if (item.mode === 'permission-lifecycle') {
|
||||
expect(events.filter(event => event.type === 'unexpected-permission-input')).toEqual([]);
|
||||
expect(events.filter(event => event.type === 'permission-grant').map(event => event.input)).toEqual(['1\r', '1\r']);
|
||||
|
||||
@@ -218,3 +218,35 @@ describe('native plan-count transcripts', () => {
|
||||
expect(f.read().calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('native plan approval request evidence', () => {
|
||||
test('captures only a scoped complete ExitPlanMode request and retains error results', () => {
|
||||
const f = fixture();
|
||||
// Exact native tool shape from G Design's pending approval gate (its
|
||||
// zero-question workflow remains a separate failed count observation).
|
||||
const ready = f.record('assistant', [{ type: 'tool_use', id: 'plan-ready', name: 'ExitPlanMode', input: {}, caller: { type: 'direct' } }]);
|
||||
const line = JSON.stringify(ready);
|
||||
fs.writeFileSync(f.file, line);
|
||||
expect(f.read().planReadyRequests).toBeUndefined();
|
||||
fs.appendFileSync(f.file, '\n');
|
||||
expect(f.read().planReadyRequests).toEqual([{ sessionId: 'session-a', toolUseId: 'plan-ready', timestamp: ready.timestamp, failed: false }]);
|
||||
f.append(f.record('user', [{ type: 'tool_result', tool_use_id: 'plan-ready', is_error: true, content: 'Plan not accepted' }]));
|
||||
expect(f.read().planReadyRequests![0]!.failed).toBe(true);
|
||||
f.append(ready); // duplicate request cannot clear a recorded failure
|
||||
expect(f.read().planReadyRequests![0]!.failed).toBe(true);
|
||||
});
|
||||
test('foreign, sidechain, quoted, lookalike and untimestamped requests add no readiness', () => {
|
||||
const f = fixture();
|
||||
const block = { type: 'tool_use', id: 'ready', name: 'ExitPlanMode', input: {} };
|
||||
f.append(f.record('assistant', [block], { cwd: f.cwd + '-other' }),
|
||||
f.record('assistant', [block], { sessionId: 'foreign' }),
|
||||
f.record('assistant', [block], { isSidechain: true }),
|
||||
f.record('assistant', [block], { timestamp: 'invalid' }),
|
||||
f.record('assistant', [{ ...block, name: 'example_ExitPlanMode' }]),
|
||||
f.record('assistant', [{ type: 'text', text: JSON.stringify(block) }]));
|
||||
expect(f.read().planReadyRequests).toBeUndefined();
|
||||
fs.appendFileSync(f.file, 'bad JSON\n');
|
||||
expect(f.read().status).toBe('error');
|
||||
expect(f.read().planReadyRequests).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const runner = pathToFileURL(path.join(ROOT, 'test/helpers/claude-pty-runner.ts')).href;
|
||||
|
||||
function fixture() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-screen-session-'));
|
||||
const fake = path.join(dir, 'fake-claude');
|
||||
const dispatched = path.join(dir, 'dispatched');
|
||||
fs.writeFileSync(fake, `#!${process.execPath}\n` + `
|
||||
import * as fs from 'node:fs';
|
||||
fs.writeFileSync(${JSON.stringify(dispatched)}, 'started');
|
||||
process.stdin.setRawMode?.(true);
|
||||
process.stdout.write('\\x1b[?1049h\\x1b[2J\\x1b[HDo you want to create plan.md?\\r\\n❯1.Yes\\r\\n2.No\\r\\nEsc to cancel · Tab to amend');
|
||||
process.stdin.on('data', data => {
|
||||
const input = data.toString();
|
||||
if(input.includes('exit')) process.exit(0);
|
||||
if(input.includes('1')) process.stdout.write('\\x1b[2J\\x1b[H⎿ Wrote 44 lines\\r\\n❯ ');
|
||||
});
|
||||
setInterval(() => {}, 1000);
|
||||
`, { mode: 0o755 });
|
||||
return { dir, fake, dispatched };
|
||||
}
|
||||
function run(dir: string, fake: string, source: string) {
|
||||
const worker = path.join(dir, 'worker.ts');
|
||||
fs.writeFileSync(worker, source);
|
||||
return spawnSync(process.execPath, [worker], { cwd: ROOT, encoding: 'utf8', timeout: 12_000,
|
||||
env: { ...process.env, BROWSE_TERMINAL_BINARY: fake, EVALS_HERMETIC: '1' } });
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('real fake-PTY viewport lifecycle', () => {
|
||||
test('a completed Write clears actionable permission but preserves raw history and final screen after exit', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const result = run(f.dir, f.fake, `
|
||||
import { launchClaudePty, capturePlanCountQuestion, isPermissionDialogVisible } from ${JSON.stringify(runner)};
|
||||
const session = await launchClaudePty({ cwd:${JSON.stringify(f.dir)}, observeScreen:true, cols:60, rows:10, timeoutMs:5000 });
|
||||
const until = async (predicate) => { for(let i=0;i<100;i++){const screen=await session.currentScreen();if(predicate(screen))return screen;await Bun.sleep(20);}throw new Error('fixture screen timeout'); };
|
||||
try {
|
||||
const active=await until(s=>s.includes('Do you want'));
|
||||
if(!isPermissionDialogVisible(active))throw new Error('active permission lost');
|
||||
session.send('1\\r');
|
||||
const completed=await until(s=>s.includes('Wrote 44 lines'));
|
||||
if(completed.includes('Do you want') || capturePlanCountQuestion(completed,new Set(),0,false))throw new Error('stale permission remained actionable');
|
||||
if(!session.rawOutput().includes('Do you want') || !session.visibleText().includes('Do you want'))throw new Error('raw history lost');
|
||||
if(completed.split('\\n').length!==10)throw new Error('wrong terminal dimensions');
|
||||
session.send('exit\\r');
|
||||
for(let i=0;i<100 && !session.exited();i++)await Bun.sleep(20);
|
||||
if(!session.exited())throw new Error('fake did not exit');
|
||||
if(await session.currentScreen()!==completed)throw new Error('final screen changed after disposal');
|
||||
process.stdout.write('screen-cleared;raw-retained;natural-exit-disposed');
|
||||
} finally { await session.close(); await session.close(); }
|
||||
`);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toBe('screen-cleared;raw-retained;natural-exit-disposed');
|
||||
} finally { fs.rmSync(f.dir, { recursive: true, force: true }); }
|
||||
}, 15_000);
|
||||
|
||||
test('wall timeout disposes its screen and non-opted-in callers retain their existing raw API', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const result = run(f.dir, f.fake, `
|
||||
import { launchClaudePty } from ${JSON.stringify(runner)};
|
||||
const timed=await launchClaudePty({cwd:${JSON.stringify(f.dir)},observeScreen:true,cols:60,rows:10,timeoutMs:150});
|
||||
try {
|
||||
for(let i=0;i<100 && !timed.exited();i++)await Bun.sleep(20);
|
||||
if(!timed.exited())throw new Error('wall timeout did not stop child');
|
||||
if((await timed.currentScreen()).split('\\n').length!==10)throw new Error('final timeout viewport unavailable');
|
||||
} finally {await timed.close();}
|
||||
const legacy=await launchClaudePty({cwd:${JSON.stringify(f.dir)},timeoutMs:2000});
|
||||
try {
|
||||
let rejected=false;try {await legacy.currentScreen();}catch(error){rejected=String(error).includes('not enabled');}
|
||||
if(!rejected)throw new Error('legacy opted in unexpectedly');
|
||||
await legacy.waitFor('Do you want',{timeoutMs:1500,pollMs:20});
|
||||
if(!legacy.visibleText().includes('Do you want'))throw new Error('legacy raw API changed');
|
||||
process.stdout.write('timeout-disposed;legacy-unchanged');
|
||||
}finally{await legacy.close();}
|
||||
`);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toBe('timeout-disposed;legacy-unchanged');
|
||||
} finally { fs.rmSync(f.dir, { recursive: true, force: true }); }
|
||||
}, 15_000);
|
||||
|
||||
test('a full native summary still completes after its heading scrolls beyond forty rows', async () => {
|
||||
const f = fixture();
|
||||
const output = path.join(f.dir, 'plan.md');
|
||||
const report = '# Reviewed plan\n\n## GSTACK REVIEW REPORT\n\n| Review | Status | Findings |\n|---|---|---|\n| CEO Review | clean | resolved |\n\nVERDICT: CEO CLEARED\n\nNO UNRESOLVED DECISIONS\n';
|
||||
fs.writeFileSync(f.fake, `#!${process.execPath}\n` + `
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
const id='long-native-summary';
|
||||
const dir=path.join(process.env.CLAUDE_CONFIG_DIR,'projects',id);fs.mkdirSync(dir,{recursive:true});
|
||||
const native=(role,content,extra={})=>fs.appendFileSync(path.join(dir,id+'.jsonl'),JSON.stringify({cwd:process.cwd(),sessionId:id,isSidechain:false,timestamp:new Date().toISOString(),message:{role,content},...extra})+'\\n');
|
||||
let sent=false;process.stdin.setRawMode?.(true);
|
||||
process.stdin.on('data',()=>{if(sent)return;sent=true;
|
||||
const q={header:'Architecture',question:'Fix the independent finding?',options:[{label:'Fix it'},{label:'Defer'}]};
|
||||
native('assistant',[{type:'tool_use',id:'finding',name:'AskUserQuestion',input:{questions:[q]}}]);
|
||||
native('user',[{type:'tool_result',tool_use_id:'finding',content:'Answered.'}],{timestamp:new Date(Date.now()-100).toISOString(),toolUseResult:{answers:{[q.question]:'Fix it'}}});
|
||||
fs.writeFileSync(${JSON.stringify(output)},${JSON.stringify(report)});
|
||||
const text='## Completion Summary\\n'+Array.from({length:48},(_,i)=>'- Finding '+(i+1)+': examined and resolved.').join('\\n')+'\\nNo unresolved issues.';
|
||||
native('assistant',[{type:'text',text}],{timestamp:new Date(Date.now()+5).toISOString()});
|
||||
process.stdout.write('\\x1b[2J\\x1b[H'+text.replace(/\\n/g,'\\r\\n')+'\\r\\n❯ ');
|
||||
});process.stdin.resume();
|
||||
`, {mode:0o755});
|
||||
const worker = path.join(f.dir, 'long-summary-worker.ts');
|
||||
fs.writeFileSync(worker, `import {runPlanSkillCounting} from ${JSON.stringify(runner)};
|
||||
const result=await runPlanSkillCounting({skillName:'plan-ceo-review',slashCommand:'/plan-ceo-review',followUpPrompt:'# Long summary fixture',expectedPlanPath:${JSON.stringify(output)},isLastStep0AUQ:()=>false,isReviewAUQ:()=>true,reviewCountCeiling:8,timeoutMs:15000});
|
||||
if(result.outcome!=='completion_summary'||result.reviewCount!==1||result.evidence.includes('## Completion Summary'))throw new Error(JSON.stringify(result));
|
||||
process.stdout.write('native-summary-complete-after-heading-scrolled');`);
|
||||
const child = Bun.spawn([process.execPath, worker], {cwd:ROOT,env:{...process.env,EVALS_HERMETIC:'1',EVALS_RUN_ID:'',BROWSE_TERMINAL_BINARY:f.fake},stdout:'pipe',stderr:'pipe'});
|
||||
const timer=setTimeout(()=>child.kill('SIGKILL'),30000);
|
||||
try {
|
||||
const [code,stdout,stderr]=await Promise.all([child.exited,new Response(child.stdout).text(),new Response(child.stderr).text()]);
|
||||
expect(code,stderr).toBe(0);
|
||||
expect(stdout).toBe('native-summary-complete-after-heading-scrolled');
|
||||
} finally {clearTimeout(timer);child.kill('SIGKILL');fs.rmSync(f.dir,{recursive:true,force:true});}
|
||||
}, 32000);
|
||||
|
||||
test('missing installed headless source fails explicitly before the CLI can spawn', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const copied = path.join(f.dir, 'missing-headless.ts');
|
||||
fs.copyFileSync(path.join(ROOT, 'test/helpers/pty-screen.ts'), copied);
|
||||
const packageDir = path.join(f.dir, 'node_modules/xterm');
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(packageDir, 'package.json'), '{"name":"xterm","version":"fixture"}');
|
||||
const clone = path.join(f.dir, 'launch-with-missing-screen.ts');
|
||||
const source = fs.readFileSync(path.join(ROOT, 'test/helpers/claude-pty-runner.ts'), 'utf8')
|
||||
.replace(/from (['"])(\.\.?\/[^'"]+)\1/g, (_match, _quote, relative) => {
|
||||
const target = relative === './pty-screen' ? copied : path.resolve(ROOT, 'test/helpers', relative + '.ts');
|
||||
return 'from ' + JSON.stringify(pathToFileURL(target).href);
|
||||
});
|
||||
fs.writeFileSync(clone, source);
|
||||
const result = run(f.dir, f.fake, `
|
||||
const {launchClaudePty}=await import(${JSON.stringify(pathToFileURL(clone).href)});
|
||||
try {const unexpected=await launchClaudePty({cwd:${JSON.stringify(f.dir)},observeScreen:true});await unexpected.close();throw new Error('launch unexpectedly succeeded');}
|
||||
catch(error){if(!String(error).includes('PTY screen unavailable') || !String(error.cause).includes('headless source is missing'))throw error;process.stdout.write('failed-before-CLI-spawn');}
|
||||
`);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toBe('failed-before-CLI-spawn');
|
||||
expect(fs.existsSync(f.dispatched)).toBe(false);
|
||||
} finally { fs.rmSync(f.dir, { recursive: true, force: true }); }
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { createPtyScreen } from './helpers/pty-screen';
|
||||
import { capturePlanCountQuestion, createPlanCountPermissionGuard, isPermissionDialogVisible, matchesNativePlanQuestion, nativePlanCallFingerprint, stripAnsi } from './helpers/claude-pty-runner';
|
||||
import { autoplanRoutingSetupInput } from './helpers/autoplan-setup-question';
|
||||
import { createPlanCountSnapshotWriter } from './helpers/plan-count-artifacts';
|
||||
import { pickCeoCompletionHandoff } from './helpers/ceo-completion-handoff';
|
||||
import type { NativePlanQuestionCall } from './helpers/plan-count-transcript';
|
||||
|
||||
function seed(frame: { initial: string[]; cursor: { x: number; y: number } }): string {
|
||||
return '\x1b[?1049h\x1b[2J' + frame.initial.map((line, i) => `\x1b[${i + 1};1H${line}\x1b[K`).join('') +
|
||||
`\x1b[${frame.cursor.y + 1};${frame.cursor.x + 1}H`;
|
||||
}
|
||||
const fixture = (name: string) => JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures/pty-screen', name + '.json'), 'utf8'));
|
||||
|
||||
describe('owned PTY viewport', () => {
|
||||
test('preserves cursor positioning, carriage return, erase, and alternate-screen restoration', async () => {
|
||||
const screen = await createPtyScreen(20, 4);
|
||||
try {
|
||||
screen.write('normal');
|
||||
screen.write('\x1b[?1049h\x1b[2J\x1b[Habcdef\rX\x1b[4G!\x1b[K');
|
||||
expect((await screen.read()).split('\n')[0]).toBe('Xbc!');
|
||||
screen.write('\x1b[2;1Hsecond\x1b[2J\x1b[Hready');
|
||||
expect(await screen.read()).toBe('ready\n\n\n');
|
||||
screen.write('\x1b[?1049l');
|
||||
expect((await screen.read()).split('\n')[0]).toBe('normal');
|
||||
} finally { await screen.dispose(); }
|
||||
});
|
||||
|
||||
test('uses the requested dimensions and excludes scrollback', async () => {
|
||||
const screen = await createPtyScreen(12, 3);
|
||||
try {
|
||||
screen.write('old permission\r\n1\r\n2\r\n3\r\n4');
|
||||
const current = await screen.read();
|
||||
expect(current.split('\n')).toHaveLength(3);
|
||||
expect(current).not.toContain('old permission');
|
||||
expect(current).toBe('2\n3\n4');
|
||||
} finally { await screen.dispose(); }
|
||||
});
|
||||
|
||||
test('sessions isolate state and dispose flushes pending writes once without changing navigator', async () => {
|
||||
const navigatorBefore = globalThis.navigator;
|
||||
const a = await createPtyScreen(30, 4);
|
||||
const b = await createPtyScreen(30, 4);
|
||||
a.write('first'); b.write('second');
|
||||
const closing = a.dispose();
|
||||
await closing;
|
||||
expect(await a.read()).toBe('first\n\n\n');
|
||||
expect(await b.read()).toBe('second\n\n\n');
|
||||
expect(a.dispose()).toBe(closing);
|
||||
expect(() => a.write('late')).toThrow('disposed');
|
||||
await b.dispose();
|
||||
expect(globalThis.navigator).toBe(navigatorBefore);
|
||||
});
|
||||
|
||||
test('retains a long genuine question when its options are visible and its header has scrolled away', async () => {
|
||||
const screen = await createPtyScreen(120, 40);
|
||||
try {
|
||||
screen.write('☐ API consistency\r\n' + 'Existing context for this independent issue.\r\n'.repeat(45) +
|
||||
'Which argument order should run_eval/run_batch use?\r\n❯1.Use the same argument order\r\n2.Keep the inconsistent order\r\n' +
|
||||
'Enter to select · ↑/↓ to navigate · Esc to cancel');
|
||||
const current = await screen.read();
|
||||
expect(current).not.toContain('☐ API consistency');
|
||||
const question = capturePlanCountQuestion(current, new Set(), 0, false);
|
||||
expect(question).not.toBeNull();
|
||||
expect(question?.options[0].label).toBe('Use the same argument order');
|
||||
expect(question?.options).toHaveLength(2);
|
||||
} finally { await screen.dispose(); }
|
||||
});
|
||||
|
||||
test('clipped native handoff preserves its bound manual choice in either option order', async () => {
|
||||
for (const contextLines of [36, 41]) for (const reverse of [false, true]) {
|
||||
const options = [{ label: 'Run /plan-eng-review next' }, { label: "Done — I'll handle reviews manually" }];
|
||||
if (reverse) options.reverse();
|
||||
const call: NativePlanQuestionCall = { sessionId: 'long-handoff', toolUseId: 'pending', answered: false,
|
||||
questions: [{ header: 'Next review',
|
||||
question: 'CEO review is complete. Which next review should run? <gstack-qid:plan-ceo-next-steps>\n' +
|
||||
Array.from({ length: contextLines }, (_, i) => `Review context line ${i + 1}: the existing requirements remain approved.`).join('\n'),
|
||||
options }] };
|
||||
const q = call.questions[0]!;
|
||||
const screen = await createPtyScreen(120, 40);
|
||||
try {
|
||||
screen.write(`☐ ${q.header}\r\n${q.question.replace(/\n/g, '\r\n')}\r\n` +
|
||||
`❯ 1. ${options[0]!.label}\r\n 2. ${options[1]!.label}\r\nEnter to select · ↑/↓ to navigate · Esc to cancel`);
|
||||
const current = await screen.read();
|
||||
expect(current).not.toContain('☐ Next review');
|
||||
expect(current.includes('<gstack-qid:')).toBe(contextLines === 36);
|
||||
expect(matchesNativePlanQuestion(current, call)).toBe(true);
|
||||
const seen = new Set<string>();
|
||||
const capture = capturePlanCountQuestion(current, seen, 0, false, call);
|
||||
expect(capture?.nativeCall).toBe(call);
|
||||
expect(pickCeoCompletionHandoff(nativePlanCallFingerprint(call, 0, false), capture!)).toBe(reverse ? 1 : 2);
|
||||
expect(capturePlanCountQuestion(current, seen, 1, false, call)).toBeNull();
|
||||
for (const unrelated of [
|
||||
// Same choices are not identity, even with an intact native footer.
|
||||
current.slice(current.indexOf('❯ 1.')),
|
||||
current.replace('Review context line', 'Unrelated context line'),
|
||||
'A different question with shared context?\n' + current,
|
||||
current.replace('2. ' + options[1]!.label, '2. Approve a new implementation task'),
|
||||
current.replace('↑/↓ to navigate', '↑/↓ to navigte'),
|
||||
current + '\nDo you want to create another.md?\n❯ 1. Yes\n2. No\nEsc to cancel · Tab to amend',
|
||||
]) {
|
||||
expect(matchesNativePlanQuestion(unrelated, call)).toBe(false);
|
||||
const other = capturePlanCountQuestion(unrelated, new Set(), 0, false, call);
|
||||
expect(other?.nativeCall).toBeUndefined();
|
||||
if (other) expect(pickCeoCompletionHandoff(nativePlanCallFingerprint(call, 0, false), other)).toBeNull();
|
||||
}
|
||||
expect(capturePlanCountQuestion(current, new Set(), 0, false, { ...call, failed: true })?.nativeCall).toBeUndefined();
|
||||
} finally { await screen.dispose(); }
|
||||
}
|
||||
});
|
||||
|
||||
for (const chunkSize of [37, 4096]) {
|
||||
test(`captured routing redraw recovers the intact manual option (${chunkSize}-character chunks)`, async () => {
|
||||
const frame = fixture('autoplan');
|
||||
const screen = await createPtyScreen(frame.cols, frame.rows);
|
||||
try {
|
||||
screen.write(seed(frame));
|
||||
for (let i = 0; i < frame.update.length; i += chunkSize) screen.write(frame.update.slice(i, i + chunkSize));
|
||||
const current = await screen.read();
|
||||
expect(current.split('\n').map(line => line.trimEnd())).toEqual(frame.expected.map((line: string) => line.trimEnd()));
|
||||
expect(current).toContain("2. No thanks, I'll invoke skills manually");
|
||||
expect(stripAnsi(frame.update)).toContain("2. N thanks, I'll invokeskillsmanually");
|
||||
expect(autoplanRoutingSetupInput(current, new Set())).toBe('1\r');
|
||||
} finally { await screen.dispose(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('historical file results release a new current permission but cannot activate stale scrollback', () => {
|
||||
const menu = 'Do you want to create plan.md?\n❯1.Yes\n2.No\nEsc to cancel · Tab to amend';
|
||||
const completed = menu + '\n⎿ Wrote 44 lines';
|
||||
const guard = createPlanCountPermissionGuard();
|
||||
expect(guard(menu, menu)).toBe('grant');
|
||||
expect(guard(menu, menu)).toBe('handled');
|
||||
expect(guard('⎿ Wrote 44 lines\n❯ ', completed)).toBeNull();
|
||||
// The actual new menu has no old Write in its viewport. The separate
|
||||
// history establishes a new completed-file epoch for this same target.
|
||||
expect(guard(menu, completed + '\n' + menu)).toBe('grant');
|
||||
expect(guard(menu, completed + '\n' + menu)).toBe('handled');
|
||||
expect(guard('❯ ', completed + '\n' + menu)).toBeNull();
|
||||
expect(guard(menu + '\n⎿ Wrote 1 line', completed + '\n' + menu + '\n⎿ Wrote 1 line')).toBe('handled');
|
||||
});
|
||||
|
||||
test('captured successful Write removes the old permission; raw and viewport evidence stay separate', async () => {
|
||||
const frame = fixture('design');
|
||||
const screen = await createPtyScreen(frame.cols, frame.rows);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'screen-evidence-'));
|
||||
try {
|
||||
const raw = seed(frame) + frame.update;
|
||||
screen.write(raw);
|
||||
const active = await screen.read();
|
||||
expect(active.split('\n').map(line => line.trimEnd())).toEqual(frame.expected.map((line: string) => line.trimEnd()));
|
||||
expect(active).toContain('Do you want to create gstack-test-plan-design.md?');
|
||||
expect(isPermissionDialogVisible(active)).toBe(true);
|
||||
screen.write(frame.completed);
|
||||
const current = await screen.read();
|
||||
expect(current).toContain('Wrote 44 lines');
|
||||
expect(current).not.toContain('Do you want');
|
||||
expect(isPermissionDialogVisible(current)).toBe(false);
|
||||
expect(capturePlanCountQuestion(current, new Set(), 0, false)).toBeNull();
|
||||
const preservedRaw = raw + frame.completed;
|
||||
expect(stripAnsi(preservedRaw)).toContain('Do you wat');
|
||||
const native = { calls: [{ toolUseId: 'native-question', answered: true }] };
|
||||
const save = createPlanCountSnapshotWriter({ EVALS_RUN_ID: 'screen-proof', GSTACK_EVAL_DIR: dir });
|
||||
const saved = save({ skillName: 'plan-design-review', cwd: dir, claudeConfigDir: null,
|
||||
raw: preservedRaw, visible: stripAnsi(preservedRaw), viewport: current, observation: { native } });
|
||||
expect(saved.artifactError).toBeUndefined();
|
||||
expect(fs.readFileSync(path.join(saved.artifactDir!, 'terminal.raw.log'), 'utf8')).toBe(preservedRaw);
|
||||
expect(fs.readFileSync(path.join(saved.artifactDir!, 'terminal.screen.log'), 'utf8')).toBe(current);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(saved.artifactDir!, 'observation.json'), 'utf8')).native).toEqual(native);
|
||||
} finally { await screen.dispose(); fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
@@ -79,12 +79,14 @@ describeE2E('/autoplan chain ordering (periodic)', () => {
|
||||
cwd: tempDir,
|
||||
timeoutMs: 1_080_000, // 18 min, slightly above test budget
|
||||
seedSkills: true,
|
||||
observeScreen: true,
|
||||
});
|
||||
|
||||
let hits: AutoplanPhaseHit[] = [];
|
||||
let transcript: PlanCountTranscript = { status: 'missing', calls: [], assistantMessages: [] };
|
||||
let outcome: 'chain_complete' | 'plan_ready' | 'timeout' | 'exited' = 'timeout';
|
||||
let evidence = '';
|
||||
let viewport = '';
|
||||
let fullSessionEvidence = '';
|
||||
let exitCode: number | null = null;
|
||||
let commandStartedAt = Date.now();
|
||||
@@ -99,14 +101,14 @@ describeE2E('/autoplan chain ordering (periodic)', () => {
|
||||
const capture = (state: string) => {
|
||||
artifacts = saveSnapshot({
|
||||
skillName: 'autoplan', cwd: tempDir, claudeConfigDir: session.hermeticConfigDir,
|
||||
raw: session.rawOutput(), visible: session.visibleText(),
|
||||
raw: session.rawOutput(), visible: session.visibleText(), viewport,
|
||||
observation: { state, hits, native: transcript, exitCode: session.exitCode() },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await Bun.sleep(8000);
|
||||
const since = session.mark();
|
||||
session.mark();
|
||||
commandStartedAt = Date.now();
|
||||
session.send('/autoplan\r');
|
||||
|
||||
@@ -117,6 +119,7 @@ describeE2E('/autoplan chain ordering (periodic)', () => {
|
||||
const seenSetupQuestions = new Set<string>();
|
||||
while (Date.now() - start < budgetMs) {
|
||||
await Bun.sleep(5000);
|
||||
viewport = await session.currentScreen();
|
||||
observe();
|
||||
if (Date.now() - lastCheckpointAt >= 30_000) {
|
||||
capture('in_progress');
|
||||
@@ -124,10 +127,10 @@ describeE2E('/autoplan chain ordering (periodic)', () => {
|
||||
}
|
||||
if (session.exited()) {
|
||||
outcome = 'exited';
|
||||
evidence = session.visibleSince(since).slice(-3000);
|
||||
evidence = viewport.slice(-3000);
|
||||
break;
|
||||
}
|
||||
const visible = session.visibleSince(since);
|
||||
const visible = viewport;
|
||||
|
||||
// Auto-grant any permission dialog so autoplan can keep moving
|
||||
// through its phases. The autoplan template auto-decides review
|
||||
@@ -172,11 +175,13 @@ describeE2E('/autoplan chain ordering (periodic)', () => {
|
||||
} finally {
|
||||
// Preserve boot failures omitted by mark(), and observe the real exit
|
||||
// status before close() deliberately terminates a live session.
|
||||
exitCode = session.exitCode();
|
||||
fullSessionEvidence = diagnosticTail(session.visibleText());
|
||||
observe();
|
||||
capture(outcome);
|
||||
await session.close();
|
||||
try {
|
||||
exitCode = session.exitCode();
|
||||
viewport = await session.currentScreen();
|
||||
fullSessionEvidence = diagnosticTail(session.visibleText());
|
||||
observe();
|
||||
capture(outcome);
|
||||
} finally { await session.close(); }
|
||||
}
|
||||
|
||||
if (outcome === 'exited' || outcome === 'timeout') {
|
||||
|
||||
@@ -152,6 +152,7 @@ describeE2E('/plan-ceo-review per-finding AskUserQuestion count (periodic)', ()
|
||||
skillName: 'plan-ceo-review',
|
||||
slashCommand: '/plan-ceo-review',
|
||||
followUpPrompt: planCeo5Findings(planPath),
|
||||
expectedPlanPath: planPath,
|
||||
isLastStep0AUQ: ceoStep0Boundary,
|
||||
isFirstReviewAUQ: ceoFirstReviewAUQ,
|
||||
isCompletionHandoffAUQ: isCeoCompletionHandoff,
|
||||
@@ -238,6 +239,7 @@ describeE2E('/plan-ceo-review per-finding AskUserQuestion count (periodic)', ()
|
||||
skillName: 'plan-ceo-review',
|
||||
slashCommand: '/plan-ceo-review',
|
||||
followUpPrompt: planCeo2PairedFindings(planPath),
|
||||
expectedPlanPath: planPath,
|
||||
isLastStep0AUQ: ceoStep0Boundary,
|
||||
isFirstReviewAUQ: ceoFirstReviewAUQ,
|
||||
isCompletionHandoffAUQ: isCeoCompletionHandoff,
|
||||
|
||||
@@ -171,6 +171,7 @@ describeE2E('/plan-design-review per-finding AskUserQuestion count (periodic)',
|
||||
skillName: 'plan-design-review',
|
||||
slashCommand: '/plan-design-review',
|
||||
followUpPrompt: planDesign5Findings(planPath),
|
||||
expectedPlanPath: planPath,
|
||||
isLastStep0AUQ: designStep0Boundary,
|
||||
isFirstReviewAUQ: designFirstReviewAUQ,
|
||||
fixtureFiles: { 'DESIGN.md': designSystem },
|
||||
|
||||
@@ -83,6 +83,7 @@ describeE2E('/plan-eng-review per-finding AskUserQuestion count (periodic)', ()
|
||||
skillName: 'plan-eng-review',
|
||||
slashCommand: '/plan-eng-review',
|
||||
followUpPrompt: planEng5Findings(planPath),
|
||||
expectedPlanPath: planPath,
|
||||
isLastStep0AUQ: engStep0Boundary,
|
||||
isSetupAUQ: engSetupAUQ,
|
||||
isFirstReviewAUQ: engFirstReviewAUQ,
|
||||
|
||||
Reference in new issue
Block a user