mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 17:04:27 +01:00
fix: stabilize planning eval navigation and phase reporting
This commit is contained in:
1 parent
eaff1688fb
commit
823bdaf40c
12 files changed
+803
-47
No files matched your search
@@ -0,0 +1,190 @@
|
||||
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 { pathToFileURL } from 'node:url';
|
||||
import { autoplanPhaseCompletions } from './helpers/autoplan-phase-observer';
|
||||
import type { PlanCountTranscript } from './helpers/plan-count-transcript';
|
||||
import { E2E_TOUCHFILES, selectTests } from './helpers/touchfiles';
|
||||
|
||||
const START = Date.parse('2026-09-08T16:00:00.000Z');
|
||||
const transcript = (...messages: Array<[number, string]>): PlanCountTranscript => ({
|
||||
status: 'ready', calls: [],
|
||||
assistantMessages: messages.map(([ms, text]) => ({
|
||||
sessionId: 'autoplan-fixture', timestamp: new Date(START + ms).toISOString(), text,
|
||||
})),
|
||||
});
|
||||
|
||||
describe('native autoplan phase observation', () => {
|
||||
test('retains actual completion timestamps when several phases arrive between polls', () => {
|
||||
expect(autoplanPhaseCompletions(transcript(
|
||||
[1, '**Phase 1 complete.** Codex: 2 concerns. Native: 3 issues.'],
|
||||
[2, 'Phase 2 complete. Design outputs are in the plan.'],
|
||||
[3, '**Phase 2.5 complete.** DX overall: 8/10.'],
|
||||
[4, 'Phase 3 complete. Both engineering reviews finished.'],
|
||||
), START)).toEqual([1, 2, 2.5, 3].map((phase, index) => ({ phase, ts: START + index + 1 })));
|
||||
});
|
||||
|
||||
test('duplicate announcements retain their first timestamp without reordering phases', () => {
|
||||
expect(autoplanPhaseCompletions(transcript(
|
||||
[3, 'Phase 1 complete.'], [2, 'Phase 2 complete.'],
|
||||
[1, '**Phase 1 complete.**'], [4, '**Phase 3 complete.**'],
|
||||
), START)).toEqual([{ phase: 1, ts: START + 1 }, { phase: 2, ts: START + 2 }, { phase: 3, ts: START + 4 }]);
|
||||
});
|
||||
|
||||
test('headings, quoted skill text, examples, tables and planned checklists provide no completion', () => {
|
||||
for (const text of [
|
||||
'## Phase 3 complete.',
|
||||
'**PHASE 3 COMPLETE.** Emit phase-transition summary:',
|
||||
'> **Phase 3 complete.** Codex: [N concerns].',
|
||||
'The section says "**Phase 3 complete.**".',
|
||||
'Example: **Phase 3 complete.**',
|
||||
'Example announcement:\n**Phase 3 complete.**',
|
||||
'Example announcement:\nPhase 2 complete.\nPhase 3 complete.',
|
||||
'The template requires this completion marker:\n\n**Phase 3 complete.**',
|
||||
' **Phase 3 complete.**',
|
||||
'\tPhase 3 complete.',
|
||||
'```markdown\n**Phase 3 complete.**\n```',
|
||||
'```markdown\n```still-code\nPhase 3 complete.\n```',
|
||||
'````markdown\n```\nPhase 3 complete.\n````',
|
||||
'~~~markdown\nPhase 3 complete.\n~~~',
|
||||
'```markdown\nPhase 3 complete.',
|
||||
'| **Phase 3 complete.** | pending |',
|
||||
'- [ ] **Phase 3 complete.**',
|
||||
'1. Phase 3 complete.',
|
||||
'I will announce **Phase 3 complete.** after the review.',
|
||||
'Phase 3 complete when the engineering review ends.',
|
||||
'Phase 3 complete?',
|
||||
'Phase 4 complete.',
|
||||
]) expect(autoplanPhaseCompletions(transcript([1, text]), START), text).toEqual([]);
|
||||
});
|
||||
|
||||
test('a real announcement after a closed example fence still establishes completion', () => {
|
||||
expect(autoplanPhaseCompletions(transcript([1, 'Example:\n```markdown\nPhase 3 complete.\n```\n\n**Phase 1 complete.**']), START))
|
||||
.toEqual([{ phase: 1, ts: START + 1 }]);
|
||||
});
|
||||
|
||||
test('accepts a template-compliant quoted transition with actual consensus while rejecting quoted examples', () => {
|
||||
const actual = '> **Phase 1 complete.** Codex: 2 concerns. Claude subagent: 3 issues.\n' +
|
||||
'> Consensus: 4/6 confirmed, 2 disagreements → surfaced at gate.\n> Passing to Phase 2.';
|
||||
expect(autoplanPhaseCompletions(transcript([1, actual]), START)).toEqual([{ phase: 1, ts: START + 1 }]);
|
||||
expect(autoplanPhaseCompletions(transcript([1, actual.replace('Codex: 2 concerns.', 'Codex: unavailable.')]), START))
|
||||
.toEqual([{ phase: 1, ts: START + 1 }]);
|
||||
for (const quoted of [
|
||||
actual.replace('4/6', 'X/6'),
|
||||
actual.replace('2 concerns', '[N concerns]'),
|
||||
'The template contains this example:\n' + actual,
|
||||
'Example announcement:\n' + actual + '\n' + actual.replace('Phase 1', 'Phase 3'),
|
||||
'> **Phase 3 complete.**',
|
||||
]) expect(autoplanPhaseCompletions(transcript([1, quoted]), START), quoted).toEqual([]);
|
||||
});
|
||||
|
||||
test('missing/error transcripts and pre-command declarations cannot establish coverage', () => {
|
||||
const prior = transcript([-1, '**Phase 3 complete.**']);
|
||||
expect(autoplanPhaseCompletions(prior, START)).toEqual([]);
|
||||
for (const status of ['missing', 'error'] as const) {
|
||||
expect(autoplanPhaseCompletions({ ...transcript([1, '**Phase 3 complete.**']), status }, START)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not repair an out-of-order chain or synthesize omitted phases', () => {
|
||||
expect(autoplanPhaseCompletions(transcript([1, 'Phase 3 complete.'], [2, 'Phase 1 complete.']), START))
|
||||
.toEqual([{ phase: 3, ts: START + 1 }, { phase: 1, ts: START + 2 }]);
|
||||
expect(autoplanPhaseCompletions(transcript([1, 'Phase 2 skipped — no UI scope.']), START)).toEqual([]);
|
||||
});
|
||||
|
||||
test('phase observer changes select the autoplan eval', () => {
|
||||
for (const file of ['test/helpers/autoplan-phase-observer.ts', 'test/autoplan-phase-observer.test.ts']) {
|
||||
expect(selectTests([file], E2E_TOUCHFILES).selected).toEqual(['autoplan-chain-pty']);
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('ANSI-rendered completions use native evidence while displayed Read/source markers do not', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-phase-replay-'));
|
||||
const fake = path.join(dir, 'fake-claude');
|
||||
const worker = path.join(dir, 'worker.ts');
|
||||
const recordFile = path.join(dir, 'events.jsonl');
|
||||
const resultFile = path.join(dir, 'result.json');
|
||||
fs.writeFileSync(fake, `#!${process.execPath}\n` + String.raw`
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
fs.writeFileSync(process.env.PHASE_RECORD, JSON.stringify({pid:process.pid}) + '\n');
|
||||
const sessionId = 'fake-autoplan-session';
|
||||
const dir = path.join(process.env.CLAUDE_CONFIG_DIR, 'projects', 'fixture');
|
||||
fs.mkdirSync(dir, {recursive:true});
|
||||
const base = Date.now();
|
||||
const write = (role, content, offset, extra = {}) => fs.appendFileSync(path.join(dir, sessionId + '.jsonl'), JSON.stringify({
|
||||
sessionId, cwd:process.cwd(), isSidechain:false, timestamp:new Date(base + offset).toISOString(), message:{role,content}, ...extra,
|
||||
}) + '\n');
|
||||
write('user', [{type:'tool_result', tool_use_id:'read', content:'**Phase 3 complete.**'}], 0);
|
||||
write('assistant', [{type:'text', text:'> **Phase 3 complete.** is the quoted source marker.'}], 0);
|
||||
write('assistant', [{type:'text', text:'**Phase 3 complete.**'}], 0, {isSidechain:true});
|
||||
write('assistant', [{type:'text', text:'**Phase 3 complete.**'}], 0, {cwd:path.join(process.cwd(), 'foreign')});
|
||||
process.stdin.setRawMode?.(true);
|
||||
let sent = false;
|
||||
process.stdin.on('data', data => {
|
||||
if (sent || !data.toString().includes('\r')) return;
|
||||
sent = true;
|
||||
process.stdout.write('\x1b[2J\x1b[H');
|
||||
[1, 2, 2.5, 3].forEach((phase, index) => {
|
||||
const message = '**Phase ' + phase + ' complete.**';
|
||||
write('assistant', [{type:'text', text:message}], index + 1);
|
||||
process.stdout.write('● \x1b[1mPhase ' + phase + ' complete.\x1b[22m\n');
|
||||
});
|
||||
process.stdout.write('NATIVE_PHASES_READY\n');
|
||||
});
|
||||
process.stdout.write('Read: autoplan/sections/eng-phase.md\n> **Phase 3 complete.**\nSOURCE_READY\n');
|
||||
process.on('SIGINT', () => process.exit(0));
|
||||
process.stdin.resume();
|
||||
`);
|
||||
fs.chmodSync(fake, 0o755);
|
||||
const moduleUrl = (name: string) => pathToFileURL(path.resolve(import.meta.dir, 'helpers', name)).href;
|
||||
fs.writeFileSync(worker, `
|
||||
import { launchClaudePty } from ${JSON.stringify(moduleUrl('claude-pty-runner.ts'))};
|
||||
import { readPlanCountTranscript } from ${JSON.stringify(moduleUrl('plan-count-transcript.ts'))};
|
||||
import { autoplanPhaseCompletions } from ${JSON.stringify(moduleUrl('autoplan-phase-observer.ts'))};
|
||||
const start = Date.now();
|
||||
const session = await launchClaudePty({cwd:${JSON.stringify(dir)}, timeoutMs:8000, env:{PHASE_RECORD:${JSON.stringify(recordFile)}}});
|
||||
try {
|
||||
await session.waitFor('SOURCE_READY', {timeoutMs:4000, pollMs:20});
|
||||
const read = () => readPlanCountTranscript(session.hermeticConfigDir, ${JSON.stringify(dir)});
|
||||
const sourceOnly = autoplanPhaseCompletions(read(), start);
|
||||
const oldPattern = /\\*\\*Phase\\s+(\\d+(?:\\.\\d+)?)\\s+complete\\.?\\*\\*/g;
|
||||
const sourceFalsePositive = [...session.visibleText().matchAll(oldPattern)].length;
|
||||
const since = session.mark();
|
||||
session.send('\\r');
|
||||
await session.waitFor('NATIVE_PHASES_READY', {timeoutMs:4000, pollMs:20});
|
||||
const rendered = session.visibleSince(since);
|
||||
const oldPatternMissed = [...rendered.matchAll(oldPattern)].length;
|
||||
const hits = autoplanPhaseCompletions(read(), start);
|
||||
await Bun.write(${JSON.stringify(resultFile)}, JSON.stringify({sourceOnly, sourceFalsePositive, oldPatternMissed, hits, rendered}));
|
||||
} finally { await session.close(); }
|
||||
`);
|
||||
const child = Bun.spawn([process.execPath, worker], {
|
||||
env: { ...process.env, BROWSE_TERMINAL_BINARY: fake, EVALS_HERMETIC: '1' }, stdout: 'pipe', stderr: 'pipe',
|
||||
});
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), 12_000);
|
||||
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 result = JSON.parse(fs.readFileSync(resultFile, 'utf8'));
|
||||
expect(result.sourceOnly).toEqual([]);
|
||||
expect(result.sourceFalsePositive).toBe(1);
|
||||
expect(result.oldPatternMissed).toBe(0);
|
||||
expect(result.rendered).toContain('Phase 3 complete.');
|
||||
expect(result.hits.map((hit: { phase: number }) => hit.phase)).toEqual([1, 2, 2.5, 3]);
|
||||
for (let index = 1; index < result.hits.length; index++) {
|
||||
expect(result.hits[index].ts).toBeGreaterThan(result.hits[index - 1].ts);
|
||||
}
|
||||
const pid = JSON.parse(fs.readFileSync(recordFile, 'utf8').split('\n')[0]!).pid;
|
||||
expect(() => process.kill(pid, 0)).toThrow();
|
||||
} finally {
|
||||
clearTimeout(timer); child.kill('SIGKILL');
|
||||
if (fs.existsSync(recordFile)) {
|
||||
const pid = JSON.parse(fs.readFileSync(recordFile, 'utf8').split('\n')[0]!).pid;
|
||||
try { process.kill(pid, 'SIGKILL'); } catch { /* already reaped */ }
|
||||
}
|
||||
fs.rmSync(dir, {recursive:true, force:true});
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -43,7 +43,80 @@ const CURRENT_CAPTURE = [
|
||||
'Entertoselect·↑/↓tonavigate·Esctocancel',
|
||||
].join('\n');
|
||||
|
||||
// Targeted-b's first attempt stayed on this complete setup menu until its
|
||||
// 15-minute deadline. The parser retained the prompt and both labels, but
|
||||
// the setup selector rejected "No thanks, invoke manually".
|
||||
const B_CAPTURE = [
|
||||
' ☐ CLAUDE.md',
|
||||
'',
|
||||
'│ D1 — Add gstack skill routing rules to CLAUDE.md? <gstack-qid:routing-injection>',
|
||||
'│',
|
||||
'│ELI10:ThisprojecthasnoCLAUDE.md.Thatfileiswheregstacklooksforroutingrules—instructionstellingClaude',
|
||||
'│Codewhichskilltoauto-invokeforwhichrequest(e.g."ship→/ship","bugs→/investigate").Withoutityoutype',
|
||||
'│theskillnameeverytime.Withit,gstackcanrecognizeyourintentandrouteautomatically.',
|
||||
'│',
|
||||
'│Stakesifweskip:Noauto-routing;youinvokeskillsmanuallyeachsession.',
|
||||
'│',
|
||||
'│Recommendation:A—one-timesetup,saveskeystrokesoneveryfuturesession.',
|
||||
'│Completeness:A=9/10,B=5/10',
|
||||
'',
|
||||
'❯1.AddroutingrulestoCLAUDE.md(Recommended)',
|
||||
'AppendsthestandardgstackroutingblocktoanewCLAUDE.mdandcommitsit.Doneonce,activeforever.',
|
||||
'2.Nothanks,invokemanually',
|
||||
'SkipCLAUDE.mdsetup.Youcontinuecalling/autoplan,/ship,/qa,etc.bynameeachtime.',
|
||||
'3.Typesomething.',
|
||||
'─'.repeat(120),
|
||||
'4.Chataboutthis',
|
||||
'Entertoselect·↑/↓tonavigate·Esctocancel',
|
||||
].join('\r\r');
|
||||
|
||||
// Fresh broad retry: the complete setup menu uses a noun for the manual
|
||||
// alternative. This is the same opposed setup action as "invoke manually".
|
||||
const FRESH_RETRY_CAPTURE = [
|
||||
'☐Routingsetup',
|
||||
"│gstackworksbestwhenyourproject'sCLAUDE.mdincludesskillroutingrules.Addthemnow?",
|
||||
'❯1.Addroutingrules(Recommended)',
|
||||
'AppendskillroutingrulestoCLAUDE.mdsoClaudeautomaticallyinvokestherightskillforproduct,engineering,',
|
||||
'design,andshipworkflows.Willbedoneafterplanapproval(planmodeisactivenow).',
|
||||
'2.Nothanks,manualinvocation',
|
||||
"Skip—I'llinvokeskillsmanually.Thispromptwon'tappearagain.",
|
||||
'3.Typesomething.',
|
||||
'─'.repeat(120),
|
||||
'4.Chataboutthis',
|
||||
'Entertoselect·↑/↓tonavigate·Esctocancel',
|
||||
].join('\n');
|
||||
|
||||
describe('autoplan routing setup handling', () => {
|
||||
test('answers the fresh retry manual-invocation setup once in either option order', () => {
|
||||
const seen = new Set<string>();
|
||||
expect(autoplanRoutingSetupInput(FRESH_RETRY_CAPTURE, seen)).toBe('1\r');
|
||||
expect(autoplanRoutingSetupInput(FRESH_RETRY_CAPTURE, seen)).toBeNull();
|
||||
const reordered = FRESH_RETRY_CAPTURE.replace('❯1.Addroutingrules(Recommended)', '❯1.Nothanks,manualinvocation')
|
||||
.replace('2.Nothanks,manualinvocation', '2.Addroutingrules(Recommended)');
|
||||
expect(autoplanRoutingSetupInput(reordered, new Set())).toBe('2\r');
|
||||
});
|
||||
|
||||
test('requires opposed manual setup actions and rejects ambiguous or unrelated choices', () => {
|
||||
for (const decline of [
|
||||
'No thanks, delete the file manually',
|
||||
'No thanks, manual data migration',
|
||||
'No thanks, invoke the deploy manually',
|
||||
'Manual invocation',
|
||||
'Accept recommendation',
|
||||
'No thanks, manual invocation then delete CLAUDE.md',
|
||||
]) {
|
||||
const frame = FRESH_RETRY_CAPTURE.replace('Nothanks,manualinvocation', decline);
|
||||
expect(autoplanRoutingSetupInput(frame, new Set()), decline).toBeNull();
|
||||
}
|
||||
expect(autoplanRoutingSetupInput(FRESH_RETRY_CAPTURE.replace('3.Typesomething.', '3.Add routing rules'), new Set())).toBeNull();
|
||||
expect(autoplanRoutingSetupInput(FRESH_RETRY_CAPTURE.replace('3.Typesomething.', '3.Skip—invoke manually'), new Set())).toBeNull();
|
||||
const review = FRESH_RETRY_CAPTURE.replace(
|
||||
"gstackworksbestwhenyourproject'sCLAUDE.mdincludesskillroutingrules.Addthemnow?",
|
||||
'Which product routing design should we ship? <gstack-qid:routing-injection>',
|
||||
);
|
||||
expect(autoplanRoutingSetupInput(review, new Set())).toBeNull();
|
||||
});
|
||||
|
||||
test('answers the captured setup once, using the full question identity', () => {
|
||||
const seen = new Set<string>();
|
||||
expect(autoplanRoutingSetupInput(CAPTURE, seen)).toBe('1\r');
|
||||
@@ -92,6 +165,50 @@ describe('autoplan routing setup handling', () => {
|
||||
expect(autoplanRoutingSetupInput(retry.replace('No thanks, manual', 'No thanks, delete it'), new Set())).toBeNull();
|
||||
});
|
||||
|
||||
test('answers the exact B timeout menu by its routing label, in either order', () => {
|
||||
const seen = new Set<string>();
|
||||
expect(autoplanRoutingSetupInput(B_CAPTURE, seen)).toBe('1\r');
|
||||
expect(autoplanRoutingSetupInput(B_CAPTURE, seen)).toBeNull();
|
||||
const reordered = B_CAPTURE.replace('❯1.AddroutingrulestoCLAUDE.md(Recommended)', '❯1.Nothanks,invokemanually')
|
||||
.replace('2.Nothanks,invokemanually', '2.AddroutingrulestoCLAUDE.md(Recommended)');
|
||||
expect(autoplanRoutingSetupInput(reordered, new Set())).toBe('2\r');
|
||||
expect(autoplanRoutingSetupInput(B_CAPTURE.replace('Nothanks,invokemanually', 'Nothanks,deletethefilemanually'), new Set())).toBeNull();
|
||||
expect(autoplanRoutingSetupInput(B_CAPTURE.replace('Add gstack skill routing rules to CLAUDE.md?', 'Which routing design should the application use?'), new Set())).toBeNull();
|
||||
});
|
||||
|
||||
test('recognizes the setup premise without depending on its closing sentence', () => {
|
||||
const openings = [
|
||||
"gstack works best when your project's CLAUDE.md includes skill routing rules. Would you like to add them?",
|
||||
"gstack works best when your project's CLAUDE.md includes skill routing rules. Enable them for this repository?",
|
||||
'Should we configure skill routing rules for gstack in CLAUDE.md?',
|
||||
'Set up gstack skill routing rules in CLAUDE.md.',
|
||||
];
|
||||
for (const opening of openings) {
|
||||
const frame = CURRENT_CAPTURE.replace('Add gstack skill routing rules to CLAUDE.md? <gstack-qid:routing-injection>', opening);
|
||||
expect(autoplanRoutingSetupInput(frame, new Set()), opening).toBe('1\r');
|
||||
}
|
||||
});
|
||||
|
||||
test('recognizes an intact setup qid with an explicit CLAUDE.md action and opposed manual decline', () => {
|
||||
const frame = CURRENT_CAPTURE.replace('Add gstack skill routing rules to CLAUDE.md?', 'Configure this project’s CLAUDE.md?');
|
||||
expect(autoplanRoutingSetupInput(frame, new Set())).toBe('1\r');
|
||||
expect(autoplanRoutingSetupInput(frame.replace('gstack-qid:routing-injection', 'gstack-qid:product-routing'), new Set())).toBeNull();
|
||||
expect(autoplanRoutingSetupInput(frame.replace('AddtoCLAUDE.md(recommended)', 'Acceptrecommendation'), new Set())).toBeNull();
|
||||
expect(autoplanRoutingSetupInput(frame.replace('Skip—invokemanually', 'Deferthisfinding'), new Set())).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps generic review, quoted premises and different routing targets out of setup handling', () => {
|
||||
for (const question of [
|
||||
'Which dashboard layout should we ship?',
|
||||
'Add routing rules to the application API? <gstack-qid:product-routing>',
|
||||
'The plan quotes gstack CLAUDE.md skill routing rules. Which API design should we use?',
|
||||
'The document references gstack skill routing rules in CLAUDE.md. Should we expand the feature?',
|
||||
]) {
|
||||
const frame = CURRENT_CAPTURE.replace('Add gstack skill routing rules to CLAUDE.md? <gstack-qid:routing-injection>', question);
|
||||
expect(autoplanRoutingSetupInput(frame, new Set()), question).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('waits for complete recognized choices rather than guessing a default', () => {
|
||||
expect(autoplanRoutingSetupInput(CAPTURE.replace('2.Nothanks', '2.Ask me later'), new Set())).toBeNull();
|
||||
expect(autoplanRoutingSetupInput(CAPTURE.replace('Addroutingrules(Recommended)', 'Accept recommendation'), new Set())).toBeNull();
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { PlanCountTranscript } from './plan-count-transcript';
|
||||
|
||||
export interface AutoplanPhaseHit {
|
||||
phase: number;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
function phaseDeclaration(text: string): RegExpExecArray | null {
|
||||
const plain = text.replace(/^\*\*(Phase[ \t]+[\d.]+[ \t]+complete\.?)\*\*/i, '$1');
|
||||
if (/\bEmit\s+phase-transition\s+summary\s*:/i.test(plain)) return null;
|
||||
return /^Phase[ \t]+(1|2(?:\.5)?|3)[ \t]+complete(?:\.(?:[ \t]+.*)?|)$/i.exec(plain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe actual assistant announcements from this fixture's native transcript.
|
||||
* The terminal renders Markdown bold as ANSI, and its Read output can contain
|
||||
* the same source markers. Neither rendered styling nor tool output is evidence
|
||||
* that a phase completed. Native timestamps also preserve order when several
|
||||
* completed messages arrive between two polls.
|
||||
*/
|
||||
export function autoplanPhaseCompletions(
|
||||
transcript: PlanCountTranscript,
|
||||
commandStartedAt: number,
|
||||
): AutoplanPhaseHit[] {
|
||||
if (transcript.status !== 'ready') return [];
|
||||
const hits: AutoplanPhaseHit[] = [];
|
||||
const messages = [...transcript.assistantMessages]
|
||||
.filter(message => Number.isFinite(Date.parse(message.timestamp)) && Date.parse(message.timestamp) >= commandStartedAt)
|
||||
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||
|
||||
for (const message of messages) {
|
||||
let fence: { char: string; length: number } | undefined;
|
||||
let previousLine = '';
|
||||
const lines = message.text.split(/\r?\n/);
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const line = lines[index]!;
|
||||
// Four spaces/a tab creates an indented Markdown code block. Preserve
|
||||
// that distinction before trimming the declaration's whitespace.
|
||||
if (/^(?: {4}|\t)/.test(line)) continue;
|
||||
const text = line.trim();
|
||||
const delimiter = /^(`{3,}|~{3,})/.exec(text)?.[1];
|
||||
if (delimiter) {
|
||||
if (!fence) fence = { char: delimiter[0]!, length: delimiter.length };
|
||||
else if (delimiter[0] === fence.char && delimiter.length >= fence.length &&
|
||||
!text.slice(delimiter.length).trim()) fence = undefined;
|
||||
previousLine = text;
|
||||
continue;
|
||||
}
|
||||
if (fence) continue;
|
||||
// Accept a plain/bold declaration, never a heading, quoted source,
|
||||
// table cell, checklist, or a sentence promising future completion.
|
||||
let match = phaseDeclaration(text);
|
||||
if (/^>\s/.test(text)) {
|
||||
// The skill's transition summary itself is a blockquote. Accept a
|
||||
// filled-in single-phase summary with concrete consensus counts;
|
||||
// a bare quotation or the template's [N]/[X/Y] examples cannot pass.
|
||||
const block: string[] = [];
|
||||
for (let cursor = index; cursor < lines.length && /^ {0,3}>/.test(lines[cursor]!); cursor++) {
|
||||
block.push(lines[cursor]!.replace(/^ {0,3}>\s?/, ''));
|
||||
}
|
||||
const concreteSummary = block.filter(value => phaseDeclaration(value)).length === 1 &&
|
||||
block.some(value => /^Consensus:\s*\d+\s*\/\s*\d+\b/i.test(value)) &&
|
||||
!/\[[^\]]*\]|\{\{/.test(block.join('\n'));
|
||||
if (concreteSummary) match = phaseDeclaration(text.replace(/^>\s?/, ''));
|
||||
}
|
||||
const introducedExample = /\b(?:example|sample|quote(?:d)?|source|template|instruction|marker|expected\s+(?:output|announcement))\b.*[::]\s*$/i.test(previousLine);
|
||||
// Keep an example introduction across all its marker/quoted lines,
|
||||
// rather than allowing its second marker to look like real completion.
|
||||
if (text && !(introducedExample && (match || text.startsWith('>')))) previousLine = text;
|
||||
if (!match || introducedExample) continue;
|
||||
const phase = Number(match[1]);
|
||||
if (!hits.some(hit => hit.phase === phase)) hits.push({ phase, ts: Date.parse(message.timestamp) });
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
@@ -5,21 +5,36 @@ export function autoplanRoutingSetupInput(visible: string, seen: Set<string>): s
|
||||
const question = capturePlanCountQuestion(visible, seen, 0, true);
|
||||
if (!question) return null;
|
||||
|
||||
// Live captures vary in wording and can lose characters from the qid.
|
||||
// Require an explicit CLAUDE.md skill-routing setup question plus both
|
||||
// named setup actions; a routing qid or a generic recommendation alone
|
||||
// cannot authorize answering a review/taste question.
|
||||
const prompt = question.promptSnippet.replace(/\s+/g, '');
|
||||
if (!/gstackworksbestwhenyourproject['’]sCLAUDE\.mdincludesskillroutingrules\.Addthemnow\?/i.test(prompt) &&
|
||||
!/Addgstackskillroutingrulesto(?:thisproject['’]s)?CLAUDE\.md\?/i.test(prompt)) {
|
||||
return null;
|
||||
}
|
||||
// The model rephrases the setup question's closing sentence. Its routing
|
||||
// identity/premise and two opposed setup actions establish what is being
|
||||
// asked; an exact "Add them now?" sentence is not a stable interface.
|
||||
// Keep the actual question/premise separate from its header and later ELI10
|
||||
// prose, which may mention CLAUDE.md even on an unrelated question.
|
||||
const primary = question.promptSnippet.replace(/^(?:Routing\s*rules|CLAUDE\.md)\s*/i, '').split('?', 1)[0]!;
|
||||
const prompt = primary.replace(/\s+/g, '');
|
||||
const options = question.options.map(option => ({
|
||||
index: option.index,
|
||||
title: option.label.split(/[│┌\r\n]/, 1)[0]!.replace(/\s+/g, ''),
|
||||
}));
|
||||
const add = options.find(option => /^Add(?:routingrules(?:toCLAUDE\.md)?|toCLAUDE\.md)(?:\(Recommended\))?$/i.test(option.title));
|
||||
const decline = options.find(option => /^(?:Nothanks(?:,manual|,I['’]llinvokeskillsmanually)?|Skip[—–-]invoke(?:skills)?manually)(?:\(Recommended\))?$/i.test(option.title));
|
||||
if (!add || !decline) return null;
|
||||
return `${add.index}\r`;
|
||||
const add = options.filter(option => /^Add(?:routingrules(?:toCLAUDE\.md)?|toCLAUDE\.md)(?:\(Recommended\))?$/i.test(option.title));
|
||||
// Match the declined setup action, not every English label separately:
|
||||
// No thanks/Skip may stand alone or opt into manual invocation. A manual
|
||||
// migration, deletion, or unrelated workflow is not the opposed action.
|
||||
const decline = options.filter(option => {
|
||||
const title = option.title.replace(/\(Recommended\)$/i, '');
|
||||
const prefix = /^(?:Nothanks|Skip)(?:[,—–-])?/i.exec(title);
|
||||
if (!prefix) return false;
|
||||
const action = title.slice(prefix[0].length);
|
||||
return action === '' || /^(?:manual(?:invocation)?|(?:I['’]ll)?invoke(?:skills)?manually)$/i.test(action);
|
||||
});
|
||||
if (add.length !== 1 || decline.length !== 1 || add[0]!.index === decline[0]!.index) return null;
|
||||
const routingId = /<gstack-qid:routing-injection>/i.test(question.promptSnippet);
|
||||
const routingPremise = /gstack/i.test(prompt) && /CLAUDE\.md/i.test(prompt) && /skillroutingrules/i.test(prompt);
|
||||
// A qid can replace the longer premise, but cannot override a question
|
||||
// about a different target. The Add action and question must agree on
|
||||
// project setup rather than a product routing or taste decision.
|
||||
const claudeTarget = /CLAUDE\.md/i.test(prompt);
|
||||
const quotedPremise = /\b(?:plan|spec|document)\s+(?:quotes?|cites?|references?)\b/i.test(primary);
|
||||
if (!claudeTarget || quotedPremise || (!routingId && !routingPremise)) return null;
|
||||
return `${add[0]!.index}\r`;
|
||||
}
|
||||
@@ -1273,6 +1273,35 @@ export function capturePlanCountQuestion(
|
||||
return { signature, promptSnippet, options, observedAtMs, preReview };
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume file-permission menus separately from native review questions.
|
||||
* A granted menu can be repainted, then linger after the tool completes.
|
||||
* 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 {
|
||||
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, '');
|
||||
|
||||
// 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';
|
||||
if (granted?.signature === signature && completedAt <= granted.completedAt) return 'handled';
|
||||
granted = { signature, completedAt };
|
||||
return 'grant';
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep a seeded count plan intact by declining its optional prerequisite. */
|
||||
export function planCountPrerequisitePick(fp: AskUserQuestionFingerprint): number | null {
|
||||
// Require the recognized prerequisite body AND both opposed labels. A
|
||||
@@ -1472,6 +1501,13 @@ export const ceoFirstReviewAUQ: Step0BoundaryPredicate = (fp) =>
|
||||
}) ?? false;
|
||||
|
||||
export const engStep0Boundary: Step0BoundaryPredicate = (fp) =>
|
||||
// Native onboarding identifies this choice by its header and opposed
|
||||
// scopes; the question body can paraphrase "cross-project learnings".
|
||||
// An answered sibling tab cannot close setup for this unanswered choice.
|
||||
(fp.nativeCall?.answered === true && !fp.nativeCall.failed && fp.nativeCall.questions.some(q =>
|
||||
Boolean(fp.nativeCall?.answers?.[q.question]) && /^cross[- ]project$/i.test(q.header.trim()) &&
|
||||
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))))) ||
|
||||
// The native complexity gate can say "Reduce scope or proceed as-is"
|
||||
// without the historical "scope reduction recommendation" wording.
|
||||
// It closes setup only once that exact scope question has an answer.
|
||||
@@ -2289,6 +2325,7 @@ export async function runPlanSkillCounting(opts: {
|
||||
const fingerprints: AskUserQuestionFingerprint[] = [];
|
||||
const seen = new Set<string>();
|
||||
const countedCalls = new Set<string>();
|
||||
const filePermission = createPlanCountPermissionGuard();
|
||||
let transcript: PlanCountTranscript = { status: 'missing', calls: [], assistantMessages: [] };
|
||||
let boundaryFired = false;
|
||||
let step0Count = 0;
|
||||
@@ -2391,8 +2428,9 @@ export async function runPlanSkillCounting(opts: {
|
||||
}
|
||||
|
||||
const frame = classifyPlanCountFrame(visible);
|
||||
if (frame === 'permission') {
|
||||
session.send(`${defaultPick}\r`);
|
||||
const permission = filePermission(visible);
|
||||
if (frame === 'permission' || (permission === 'grant' && frame === null)) {
|
||||
if (permission !== 'handled') session.send(`${defaultPick}\r`);
|
||||
await Bun.sleep(1500);
|
||||
continue;
|
||||
}
|
||||
@@ -2447,6 +2485,9 @@ export async function runPlanSkillCounting(opts: {
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
COMPLETION_SUMMARY_RE,
|
||||
classifyPlanCountFrame,
|
||||
capturePlanCountQuestion,
|
||||
createPlanCountPermissionGuard,
|
||||
planCountPrerequisitePick,
|
||||
planCountSubmissionInput,
|
||||
assertReviewReportAtBottom,
|
||||
@@ -1597,6 +1598,150 @@ describe('Step0BoundaryPredicate per-skill', () => {
|
||||
};
|
||||
}
|
||||
|
||||
describe('native Cross-project onboarding boundary', () => {
|
||||
// Fresh paid run D, 2026-09-08: native question stems, labels and
|
||||
// successful answers. Long explanatory paragraphs are omitted; they
|
||||
// must not determine this structural setup boundary.
|
||||
const captured = [
|
||||
{
|
||||
"header": "Routing rules",
|
||||
"question": "Should I add gstack skill routing rules to your project's CLAUDE.md? (Note: we're in plan mode — if you pick A, I'll make the edit after we exit plan mode.)",
|
||||
"options": [
|
||||
"Add routing rules (recommended)",
|
||||
"Skip — invoke manually"
|
||||
],
|
||||
"answer": "Add routing rules (recommended)"
|
||||
},
|
||||
{
|
||||
"header": "Scope challenge",
|
||||
"question": "D2 — The plan introduces 4 new classes across 12 files. Should I flag scope reduction as a primary recommendation in the review, or accept the 4-class design and focus findings on quality issues?",
|
||||
"options": [
|
||||
"Accept 4-class design, focus on quality",
|
||||
"Flag scope reduction as primary finding (recommended)"
|
||||
],
|
||||
"answer": "Accept 4-class design, focus on quality"
|
||||
},
|
||||
{
|
||||
"header": "Cross-project",
|
||||
"question": "D3 — Should gstack search learnings from your other projects on this machine when reviewing?",
|
||||
"options": [
|
||||
"Enable cross-project learnings (recommended)",
|
||||
"Keep learnings project-scoped only"
|
||||
],
|
||||
"answer": "Enable cross-project learnings (recommended)"
|
||||
},
|
||||
{
|
||||
"header": "AuthCache race",
|
||||
"question": "D4 — AuthCache is shared mutable state mutated by two services with no serialization. How should we fix it?",
|
||||
"options": [
|
||||
"Single-writer: AuthBroker owns all writes (recommended)",
|
||||
"Immutable cache + versioned replace",
|
||||
"Accept and document the race"
|
||||
],
|
||||
"answer": "Single-writer: AuthBroker owns all writes (recommended)"
|
||||
},
|
||||
{
|
||||
"header": "Double-cache risk",
|
||||
"question": "D5 — The plan introduces a new AuthCache class but doesn't say what happens to the existing cache adapter. Are they running in parallel?",
|
||||
"options": [
|
||||
"AuthCache replaces the adapter — add migration to plan (recommended)",
|
||||
"AuthCache wraps the adapter — adapter stays as storage layer",
|
||||
"Leave ambiguous — clarify in implementation"
|
||||
],
|
||||
"answer": "AuthCache replaces the adapter — add migration to plan (recommended)"
|
||||
},
|
||||
{
|
||||
"header": "Error swallowing",
|
||||
"question": "D6 — validateAndDispatch() swallows three different error classes across nested try/catch blocks. How should this be resolved in the plan?",
|
||||
"options": [
|
||||
"Decompose + typed error results (recommended)",
|
||||
"Keep structure, add logging + rethrow",
|
||||
"Leave as-is — document that swallowing is intentional"
|
||||
],
|
||||
"answer": "Decompose + typed error results (recommended)"
|
||||
},
|
||||
{
|
||||
"header": "Invalidation tests",
|
||||
"question": "D7 — When AuthCache replaces the existing adapter (per D5), the existing invalidation tests (logout, revocation, tenant suspension) become dead — they're testing a retired object. Should the plan explicitly require migrating them?",
|
||||
"options": [
|
||||
"Migrate invalidation tests to AuthCache — add to plan (recommended)",
|
||||
"Scope to new component tests only — leave invalidation as follow-up",
|
||||
"Assume existing tests cover it — no explicit migration step"
|
||||
],
|
||||
"answer": "Migrate invalidation tests to AuthCache — add to plan (recommended)"
|
||||
},
|
||||
{
|
||||
"header": "IDP parallelization",
|
||||
"question": "D8 — The plan identifies 5 sequential IDP calls that are independent and could be parallelized with Promise.all. Should we include the fix in this PR or defer it?",
|
||||
"options": [
|
||||
"Parallelize with Promise.all in this PR (recommended)",
|
||||
"Defer to TODOS.md",
|
||||
"Leave sequential — document as known limitation"
|
||||
],
|
||||
"answer": "Parallelize with Promise.all in this PR (recommended)"
|
||||
}
|
||||
];
|
||||
const fingerprint = (index: number) => {
|
||||
const row = captured[index];
|
||||
return nativePlanCallFingerprint({
|
||||
sessionId: 'fresh-eng-cross-project', toolUseId: `call-${index}`, answered: true,
|
||||
questions: [{ header: row.header, question: row.question, options: row.options.map(label => ({ label })) }],
|
||||
answers: { [row.question]: row.answer },
|
||||
}, index, true);
|
||||
};
|
||||
|
||||
test('keeps D3 as setup and counts each following actual review call', () => {
|
||||
let started = false;
|
||||
const phases = captured.map((_, i) => {
|
||||
const phase = planCountQuestionPhase(fingerprint(i), started, engStep0Boundary);
|
||||
started = phase.reviewStarted;
|
||||
return phase;
|
||||
});
|
||||
expect(phases.slice(0, 3).map(p => p.preReview)).toEqual([true, true, true]);
|
||||
expect(phases[2].reviewStarted).toBe(true);
|
||||
expect(phases.slice(3).map(p => p.preReview)).toEqual([false, false, false, false, false]);
|
||||
});
|
||||
|
||||
test('uses the native opposed scope choices without depending on a prompt suffix', () => {
|
||||
const fp = fingerprint(2);
|
||||
expect(engStep0Boundary(fp)).toBe(true);
|
||||
const q = fp.nativeCall!.questions[0];
|
||||
q.question = 'Should local lessons from other repositories be included during reviews?';
|
||||
q.options.reverse();
|
||||
fp.nativeCall!.answers = { [q.question]: q.options[0].label };
|
||||
expect(engStep0Boundary(nativePlanCallFingerprint(fp.nativeCall!, 0, true))).toBe(true);
|
||||
});
|
||||
|
||||
test("an unanswered Cross-project tab cannot borrow another tab's answer", () => {
|
||||
const call = structuredClone(fingerprint(2).nativeCall!);
|
||||
const other = { header: 'Routing rules', question: 'Add routing rules?', options: [{ label: 'Add rules' }, { label: 'Skip' }] };
|
||||
call.questions.push(other);
|
||||
call.answers = { [other.question]: 'Add rules' };
|
||||
expect(engStep0Boundary(nativePlanCallFingerprint(call, 0, true))).toBe(false);
|
||||
call.answers = { [call.questions[0].question]: call.questions[0].options[1].label };
|
||||
expect(engStep0Boundary(nativePlanCallFingerprint(call, 0, true))).toBe(true);
|
||||
});
|
||||
|
||||
test('pending, failed, missing native metadata and ordinary review questions are not this gate', () => {
|
||||
const fp = fingerprint(2);
|
||||
expect(engStep0Boundary({ ...fp, nativeCall: undefined })).toBe(false);
|
||||
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].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?';
|
||||
call.questions[0].options = [{ label: 'Serialize mutations' }, { label: 'Use project-scoped locks' }]; },
|
||||
]) {
|
||||
const call = structuredClone(fp.nativeCall!);
|
||||
alter(call);
|
||||
call.answers = { [call.questions[0].question]: call.questions[0].options[0].label };
|
||||
expect(engStep0Boundary(nativePlanCallFingerprint(call, 0, true))).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ceoStep0Boundary', () => {
|
||||
test('FIRES on Step 0F mode-pick AUQ (HOLD SCOPE in options)', () => {
|
||||
const f = fp('Pick a mode', ['HOLD SCOPE', 'SCOPE EXPANSION', 'SELECTIVE EXPANSION', 'SCOPE REDUCTION']);
|
||||
@@ -1889,3 +2034,77 @@ describe('Step0BoundaryPredicate per-skill', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('file permission lifecycle replay', () => {
|
||||
const permission = (file = 'gstack-test-plan-design.md') => [
|
||||
`Do you want to make this edit to ${file}?`,
|
||||
'❯ 1. Yes',
|
||||
'2.Yes,andswitchtoacceptedits(auto-approvefileeditsandcommonfilecommands)forthissession;Yes,and',
|
||||
'alwaysallowaccessto/tmp/fixtureforthissession',
|
||||
'3.No',
|
||||
'Esctocancel·Tabtoamend',
|
||||
].join('\n');
|
||||
|
||||
test('ignores the granted menu and its redraw until a new request follows file-tool completion', () => {
|
||||
const guard = createPlanCountPermissionGuard();
|
||||
const first = permission();
|
||||
expect(guard(first)).toBe('grant');
|
||||
expect(guard(first)).toBe('handled');
|
||||
const redraw = first + '\n' + permission();
|
||||
expect(guard(redraw)).toBe('handled');
|
||||
const completed = redraw + '\n●Write(/tmp/fixture/gstack-test-plan-design.md)\n' +
|
||||
'⎿ Wrote320linesto../fixture/gstack-test-plan-design.md\n' + '·'.repeat(1600);
|
||||
expect(classifyPlanCountFrame(completed)).toBeNull();
|
||||
expect(guard(completed)).toBe('handled');
|
||||
expect(guard(completed + '\n' + permission())).toBe('grant');
|
||||
});
|
||||
|
||||
test('singular native Write/Edit results release a fresh identical permission', () => {
|
||||
for (const result of ['⎿ Added1line,removed1line', '⎿ Wrote1lineto../fixture/plan.md', '⎿ Removed1line', '⎿\u00a0Wrote320linesto../fixture/plan.md']) {
|
||||
const guard = createPlanCountPermissionGuard();
|
||||
const first = permission();
|
||||
expect(guard(first)).toBe('grant');
|
||||
const completed = first + '\n' + result;
|
||||
expect(guard(completed)).toBe('handled');
|
||||
expect(guard(completed + '\n' + permission())).toBe('grant');
|
||||
}
|
||||
});
|
||||
|
||||
test('the captured active Edit menu remains a permission behind a long diff repaint', () => {
|
||||
const visible = permission('gstack-test-plan-ceo.md') + '\n' +
|
||||
' 89 +The plan adds StripePaymentWebhookHandler outside WebhookDispatcher.\n'.repeat(40);
|
||||
expect(visible.length).toBeLessThan(4096);
|
||||
expect(classifyPlanCountFrame(visible)).toBeNull(); // The old 1.5 KB scan misses it.
|
||||
const guard = createPlanCountPermissionGuard();
|
||||
expect(guard(visible)).toBe('grant');
|
||||
expect(guard(visible)).toBe('handled');
|
||||
});
|
||||
|
||||
test('a completed Write invalidates an old menu even if polling missed the original grant', () => {
|
||||
const visible = permission() + '\n⎿ Wrote320linesto../fixture/gstack-test-plan-design.md';
|
||||
expect(createPlanCountPermissionGuard()(visible)).toBe('handled');
|
||||
});
|
||||
|
||||
test('proposed results and tool headers do not release the same pending permission', () => {
|
||||
const guard = createPlanCountPermissionGuard();
|
||||
let visible = permission();
|
||||
expect(guard(visible)).toBe('grant');
|
||||
for (const line of ['320 +⎿ Wrote320lines', '●Write(/tmp/fixture/plan.md)', '⎿ Tip: use /btw', '⎿ Error: denied']) {
|
||||
visible += '\n' + line + '\n' + permission();
|
||||
expect(guard(visible)).toBe('handled');
|
||||
}
|
||||
});
|
||||
|
||||
test('a different file and a genuine native file-policy question retain their own input', () => {
|
||||
const guard = createPlanCountPermissionGuard();
|
||||
const first = permission('first.md');
|
||||
expect(guard(first)).toBe('grant');
|
||||
expect(guard(first + '\n' + permission('FIRST.md'))).toBe('grant'); // Targets remain case-sensitive.
|
||||
expect(guard(first + '\n' + permission('second.md'))).toBe('grant');
|
||||
const question = '\n☐ File policy\nDo you want to create first.md?\n❯1.Yes\n2.No\n' +
|
||||
'Enter to select · ↑/↓ to navigate · Esc to cancel';
|
||||
expect(guard(first + question)).toBeNull();
|
||||
expect(capturePlanCountQuestion(first + question, new Set(), 0, true)?.promptSnippet).toContain('File policy');
|
||||
});
|
||||
});
|
||||
@@ -154,7 +154,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
// 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/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/autoplan-setup-question.ts', 'test/autoplan-setup-question.test.ts', 'test/skill-e2e-autoplan-chain.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/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 +
|
||||
|
||||
@@ -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: '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' },
|
||||
{ name: 'ceo', skillName: 'plan-ceo-review', prompt: '# Independent CEO plan\nUnique product context.', mode: 'complete' },
|
||||
@@ -245,6 +246,10 @@ let firstInput = true;
|
||||
let completion;
|
||||
let question = 0;
|
||||
let selected = '';
|
||||
let permissionStage = 'first';
|
||||
const filePermission = () => '\nDo you want to make this edit to gstack-test-plan-design.md?\n' +
|
||||
'❯1.Yes\n2.Yes,andswitchtoacceptedits(auto-approvefileeditsandcommonfilecommands)forthissession;Yes,and\n' +
|
||||
'alwaysallowaccessto/tmp/fixtureforthissession\n3.No\nEsctocancel·Tabtoamend\n';
|
||||
process.stdin.on('data', (data) => {
|
||||
const input = data.toString('utf8');
|
||||
record({ type: 'input', data: input });
|
||||
@@ -313,6 +318,35 @@ process.stdin.on('data', (data) => {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (process.env.FIXTURE_MODE === 'permission-lifecycle' && input.includes('\r')) {
|
||||
if (permissionStage === 'first') {
|
||||
record({ type: 'permission-grant', number: 1, input });
|
||||
permissionStage = 'pending';
|
||||
process.stdout.write(filePermission()); // Same pending menu, appended redraw.
|
||||
completion = setTimeout(() => {
|
||||
permissionStage = 'completed';
|
||||
process.stdout.write('\n⎿ Wrote320linesto../fixture/gstack-test-plan-design.md\n' + '·'.repeat(1600));
|
||||
completion = setTimeout(() => {
|
||||
permissionStage = 'second';
|
||||
process.stdout.write(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');
|
||||
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' +
|
||||
'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');
|
||||
} 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');
|
||||
return;
|
||||
@@ -381,6 +415,10 @@ process.stdin.on('data', (data) => {
|
||||
process.stdout.write('\r☐ Setup\rWhich fixture setup should be used?\r❯1.First setup\r2.Second setup\r');
|
||||
return;
|
||||
}
|
||||
if (process.env.FIXTURE_MODE === 'permission-lifecycle') {
|
||||
process.stdout.write(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');
|
||||
return;
|
||||
@@ -424,7 +462,7 @@ const results = await Promise.all(cases.map(async (item) => ({
|
||||
pickAUQ: ['late-mode', 'batched-mode'].includes(item.mode) ? devexReviewModePick
|
||||
: item.custom ? fp => fp.promptSnippet.includes('routing-proof-after-240') ? 1 : null : undefined,
|
||||
reviewCountCeiling: 8,
|
||||
timeoutMs: 15000,
|
||||
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,
|
||||
@@ -487,6 +525,7 @@ await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ results, onboard
|
||||
expect(startup.sections).toBe(fs.readFileSync(path.join(ROOT, item.skillName, 'sections/review-sections.md'), 'utf8'));
|
||||
expect(events.filter((event) => event.type === 'input').map((event) => event.data).join(''))
|
||||
.toBe(`/${item.skillName}\r` + (item.mode === 'prerequisite' ? `${item.custom ? 1 : 2}\r${item.skipIndex}\r`
|
||||
: 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' : ''));
|
||||
@@ -520,6 +559,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 === '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']);
|
||||
expect(events.filter(event => event.type === 'file-policy-answer').map(event => event.input)).toEqual(['2\r']);
|
||||
expect(result.observation.step0Count).toBe(1);
|
||||
expect(result.observation.reviewCount).toBe(0);
|
||||
expect(result.observation.transcript.calls).toHaveLength(1);
|
||||
expect(result.observation.transcript.calls[0].answers['Do you want to create gstack-test-plan-design.md?']).toBe('No');
|
||||
}
|
||||
if (['permission', 'missing-transcript'].includes(item.mode)) {
|
||||
expect(result.observation.reviewCount).toBe(0);
|
||||
expect(result.observation.step0Count).toBe(0);
|
||||
|
||||
@@ -11,14 +11,12 @@
|
||||
* gate reviews the final amended plan)
|
||||
*
|
||||
* Why this exists: each individual phase has its own plan-mode smoke
|
||||
* test. Nothing verifies the SEQUENCING — that phases don't run in
|
||||
* parallel, that Phase 3 doesn't start before Phase 1 ends, that
|
||||
* conditional phases (Design, DX) are skipped when their scope is absent.
|
||||
* A regression where the autoplan template wires phases concurrently
|
||||
* would not be caught by per-phase tests.
|
||||
* test. This checks cross-phase completion order, including the conditional
|
||||
* Design/DX phases when they run. Completion markers do not establish phase
|
||||
* start times or prove that no work overlapped between phases.
|
||||
*
|
||||
* Approach: tee timestamps as each "**Phase N complete." marker first
|
||||
* appears in the visible buffer. Assert observed ordering. Phase 2 is
|
||||
* Approach: read standalone phase-completion announcements and their native
|
||||
* assistant timestamps from the isolated transcript. Assert observed ordering. Phase 2 is
|
||||
* optional — UI-heavy fixture should make it run; backend-only fixtures
|
||||
* should make it skip.
|
||||
*
|
||||
@@ -41,17 +39,15 @@ import {
|
||||
selectPtyNumberedOption,
|
||||
} from './helpers/claude-pty-runner';
|
||||
import { autoplanRoutingSetupInput } from './helpers/autoplan-setup-question';
|
||||
import { autoplanPhaseCompletions, type AutoplanPhaseHit } from './helpers/autoplan-phase-observer';
|
||||
import { readPlanCountTranscript, type PlanCountTranscript } from './helpers/plan-count-transcript';
|
||||
import { createPlanCountSnapshotWriter } from './helpers/plan-count-artifacts';
|
||||
|
||||
const describeE2E = describeE2ETier('periodic');
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const UI_FIXTURE = path.join(ROOT, 'test', 'fixtures', 'plans', 'ui-heavy-feature.md');
|
||||
|
||||
interface PhaseHit {
|
||||
phase: number;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
function diagnosticTail(text: string): string {
|
||||
return stripVTControlCharacters(text)
|
||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
||||
@@ -60,7 +56,7 @@ function diagnosticTail(text: string): string {
|
||||
|
||||
describeE2E('/autoplan chain ordering (periodic)', () => {
|
||||
test(
|
||||
'phases run sequentially: Phase 1 (CEO) before Phase 3 (Eng), Phase 2 (Design) between when present',
|
||||
'phase completions are ordered: Phase 1 (CEO) before Phase 3 (Eng), Phase 2 (Design) between when present',
|
||||
async () => {
|
||||
// UI-heavy fixture so Phase 2 runs.
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-autoplan-chain-'));
|
||||
@@ -85,29 +81,47 @@ describeE2E('/autoplan chain ordering (periodic)', () => {
|
||||
seedSkills: true,
|
||||
});
|
||||
|
||||
const hits: PhaseHit[] = [];
|
||||
let hits: AutoplanPhaseHit[] = [];
|
||||
let transcript: PlanCountTranscript = { status: 'missing', calls: [], assistantMessages: [] };
|
||||
let outcome: 'chain_complete' | 'plan_ready' | 'timeout' | 'exited' = 'timeout';
|
||||
let evidence = '';
|
||||
let fullSessionEvidence = '';
|
||||
let exitCode: number | null = null;
|
||||
let commandStartedAt = Date.now();
|
||||
const saveSnapshot = createPlanCountSnapshotWriter();
|
||||
let artifacts: { artifactDir?: string; artifactError?: string } = {};
|
||||
const observe = () => {
|
||||
transcript = session.hermeticConfigDir
|
||||
? readPlanCountTranscript(session.hermeticConfigDir, tempDir)
|
||||
: { status: 'error', calls: [], assistantMessages: [], error: 'No isolated autoplan transcript directory' };
|
||||
hits = autoplanPhaseCompletions(transcript, commandStartedAt);
|
||||
};
|
||||
const capture = (state: string) => {
|
||||
artifacts = saveSnapshot({
|
||||
skillName: 'autoplan', cwd: tempDir, claudeConfigDir: session.hermeticConfigDir,
|
||||
raw: session.rawOutput(), visible: session.visibleText(),
|
||||
observation: { state, hits, native: transcript, exitCode: session.exitCode() },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await Bun.sleep(8000);
|
||||
const since = session.mark();
|
||||
commandStartedAt = Date.now();
|
||||
session.send('/autoplan\r');
|
||||
|
||||
const budgetMs = 900_000; // 15 min
|
||||
const start = Date.now();
|
||||
// Phase markers live in autoplan's carved phase sections
|
||||
// (autoplan/sections/{ceo,design,eng,dx}-phase.md — the skeleton
|
||||
// STOP-Reads each one at its phase boundary):
|
||||
// "**Phase 1 complete." / "**Phase 2 complete." / "**Phase 2.5 complete." / "**Phase 3 complete."
|
||||
const phasePattern = /\*\*Phase\s+(\d+(?:\.\d+)?)\s+complete\.?\*\*/g;
|
||||
|
||||
let lastPermSig = '';
|
||||
let lastCheckpointAt = start;
|
||||
const seenSetupQuestions = new Set<string>();
|
||||
while (Date.now() - start < budgetMs) {
|
||||
await Bun.sleep(5000);
|
||||
observe();
|
||||
if (Date.now() - lastCheckpointAt >= 30_000) {
|
||||
capture('in_progress');
|
||||
lastCheckpointAt = Date.now();
|
||||
}
|
||||
if (session.exited()) {
|
||||
outcome = 'exited';
|
||||
evidence = session.visibleSince(since).slice(-3000);
|
||||
@@ -140,16 +154,6 @@ describeE2E('/autoplan chain ordering (periodic)', () => {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Re-scan for any phase markers we haven't yet recorded.
|
||||
phasePattern.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phasePattern.exec(visible)) !== null) {
|
||||
const phaseNum = parseFloat(m[1] ?? '0');
|
||||
if (Number.isNaN(phaseNum)) continue;
|
||||
if (hits.some(h => h.phase === phaseNum)) continue;
|
||||
hits.push({ phase: phaseNum, ts: Date.now() });
|
||||
}
|
||||
|
||||
// Terminal: Phase 3 (Eng) seen — chain reached the required end.
|
||||
if (hits.some(h => h.phase === 3)) {
|
||||
outcome = 'chain_complete';
|
||||
@@ -170,12 +174,15 @@ describeE2E('/autoplan chain ordering (periodic)', () => {
|
||||
// status before close() deliberately terminates a live session.
|
||||
exitCode = session.exitCode();
|
||||
fullSessionEvidence = diagnosticTail(session.visibleText());
|
||||
observe();
|
||||
capture(outcome);
|
||||
await session.close();
|
||||
}
|
||||
|
||||
if (outcome === 'exited' || outcome === 'timeout') {
|
||||
throw new Error(
|
||||
`autoplan chain test FAILED: outcome=${outcome}, exitCode=${exitCode}, hits=${JSON.stringify(hits)}\n` +
|
||||
`Native transcript: ${transcript.status}; artifacts=${JSON.stringify(artifacts)}\n` +
|
||||
`--- post-command evidence (last 3KB) ---\n${diagnosticTail(evidence)}\n` +
|
||||
`--- full-session visible tail, including startup (last 3KB) ---\n${fullSessionEvidence}`,
|
||||
);
|
||||
@@ -188,6 +195,7 @@ describeE2E('/autoplan chain ordering (periodic)', () => {
|
||||
if (!ceo || !eng) {
|
||||
throw new Error(
|
||||
`Required phase markers missing. Saw: ${JSON.stringify(hits)}\n` +
|
||||
`Native transcript: ${transcript.status}; artifacts=${JSON.stringify(artifacts)}\n` +
|
||||
`--- evidence ---\n${evidence}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,6 +84,10 @@ const planCeo5Findings = (planPath: string) => [
|
||||
'HTTP 200, logs the event, and stops before user updates or email fan-out.',
|
||||
'The ingress wrapper already logs event IDs, outcomes, and durations, with',
|
||||
'alerts for failed webhook processing. Those controls remain in place.',
|
||||
'DB lookup/update exceptions propagate to that ingress wrapper, which logs',
|
||||
'the failure and returns HTTP 500 so Stripe retries the event. The existing',
|
||||
'event-ID dedup guard records completion only after the database transaction',
|
||||
'commits; failed or rolled-back database attempts remain retryable.',
|
||||
'The deployment already has a handler feature flag and a documented, tested',
|
||||
'rollback to the prior handler; this change uses that existing rollout path.',
|
||||
'',
|
||||
|
||||
@@ -38,6 +38,17 @@ const designSystem = [
|
||||
'Profile contains Display name (text) and Email (email). Notifications contains',
|
||||
'Weekly digest and Product tips switches. Existing server defaults are the',
|
||||
'account name/email, Weekly digest on, and Product tips off; labels/order stay fixed.',
|
||||
'The page title “Account settings” is h1. Profile and Notifications are h2',
|
||||
'headings that label their fieldsets via aria-labelledby; no heading level is skipped.',
|
||||
'The existing DOM and visual order are:',
|
||||
'```text',
|
||||
'Persistent app navigation',
|
||||
'main: Account settings (h1) + description',
|
||||
' Save | Reset | Cancel | Export',
|
||||
' InlineStatus',
|
||||
' Profile (h2): Display name, Email',
|
||||
' Notifications (h2): Weekly digest, Product tips',
|
||||
'```',
|
||||
'',
|
||||
'Save is the only filled primary action (#1d4ed8 with white text). Reset, Cancel',
|
||||
'and Export are neutral ghost buttons; destructive intent is explained in the',
|
||||
@@ -53,6 +64,11 @@ const designSystem = [
|
||||
'inside the disabled Save button, aria-busy=true, with reduced-motion support.',
|
||||
'Success uses the persistent inline status “Saved” plus the save time (aria-live=polite).',
|
||||
'The existing formatter renders “Saved at HH:mm” in the user’s local 24-hour time.',
|
||||
'Editing a field away from its saved value changes that same persistent status',
|
||||
'to “Unsaved changes” (role=status, aria-live=polite, aria-atomic=true); text',
|
||||
'communicates the dirty state without relying on color or button enabled state.',
|
||||
'Reverting all edits or confirming Reset restores “Saved at HH:mm” for the',
|
||||
'last successful save. Failed saves retain “Unsaved changes” alongside the error.',
|
||||
'Reset dialog: “Discard unsaved changes?” / “Your saved preferences will be restored.”',
|
||||
'Its actions are “Keep editing” (default) and “Discard changes”. Cancel navigation',
|
||||
'uses the same dialog with “Keep editing” (default) and “Discard and leave”.',
|
||||
@@ -74,10 +90,27 @@ const planDesign5Findings = (planPath: string) => [
|
||||
'sections, with visible headings and associated field labels. The page header',
|
||||
'contains the title, a short description, and Save/Reset/Cancel/Export actions.',
|
||||
'Preserve the approved single-column structure and component behavior.',
|
||||
'The page title “Account settings” is h1. Profile and Notifications are h2',
|
||||
'headings that label their fieldsets via aria-labelledby; no heading level is skipped.',
|
||||
'The existing DOM and visual order are:',
|
||||
'```text',
|
||||
'Persistent app navigation',
|
||||
'main: Account settings (h1) + description',
|
||||
' Save | Reset | Cancel | Export',
|
||||
' InlineStatus',
|
||||
' Profile (h2): Display name, Email',
|
||||
' Notifications (h2): Weekly digest, Product tips',
|
||||
'```',
|
||||
'',
|
||||
'Journey: a user arrives from account navigation wanting to adjust preferences,',
|
||||
'edits the labeled fields, saves, and reads the inline Saved timestamp before',
|
||||
'leaving. The feedback preserves confidence that their preferences were stored.',
|
||||
'The persistent InlineStatus has role=status, aria-live=polite, aria-atomic=true.',
|
||||
'After success it reads “Saved at HH:mm” in the user’s local 24-hour time.',
|
||||
'Editing away from a saved value changes its text to “Unsaved changes”, so',
|
||||
'dirty state never relies on color or the Save button being enabled. Reverting',
|
||||
'all edits or confirming Reset restores the last successful save timestamp.',
|
||||
'Failed saves retain “Unsaved changes” alongside the error message.',
|
||||
'Initial loading uses the existing form skeleton. A new account sees useful',
|
||||
'default preferences as specified in DESIGN.md rather than an empty page. Read failures show Retry.',
|
||||
'Save is atomic: all fields persist together or none do, so partial success is',
|
||||
@@ -92,6 +125,8 @@ const planDesign5Findings = (planPath: string) => [
|
||||
'below it, preserving DOM/tab order. The form fits 320px without horizontal scroll.',
|
||||
'All controls have visible focus rings and 44px targets. Use semantic fieldsets,',
|
||||
'labels, a main landmark and heading order; errors link through aria-describedby.',
|
||||
'Focus-visible on every control and dialog action is the existing 2px solid',
|
||||
'#1d4ed8 outline, offset 2px on white, with measured contrast above 3:1.',
|
||||
'Dialogs trap focus and restore it on close; Escape cancels. Export remains a',
|
||||
'clearly labeled button. Respect reduced motion. No additional visual exploration',
|
||||
'or component replacement is part of this established form update.',
|
||||
|
||||
@@ -38,6 +38,9 @@ const planEng5Findings = (planPath: string) => [
|
||||
'and policy version. It evicts expired tokens and invalidates entries on',
|
||||
'logout, token revocation, or tenant suspension. AuthCache retains these',
|
||||
'unchanged validity and tenant-key rules; they do not serialize mutations.',
|
||||
'AuthCache is a service-facing facade over that same existing adapter,',
|
||||
'with one backing cache. The adapter, its invalidation hooks, and their',
|
||||
'existing tests remain in use unchanged.',
|
||||
'Unit and integration coverage is planned for the new components and their',
|
||||
'success/error paths. That coverage does not exercise legacyAuthFlow() or',
|
||||
'assert compatibility with its prior behavior.',
|
||||
|
||||
Reference in new issue
Block a user