fix: count completed native review questions in evals

This commit is contained in:
Garry Tan committed 2026-09-08 16:07:57 +00:00
1 parent 042daeee71
commit d43f04fb05
15 files changed
+1451 -92

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
import { describe, expect, test } from 'bun:test';
import type { AskUserQuestionFingerprint } from './helpers/claude-pty-runner';
import {
DEVEX_COUNT_FILES,
planDevexCountFixture,
isDevexReviewIssue,
devexReviewModePick,
} from './helpers/devex-count-fixture';
let nextCall = 0;
function call(question: string, labels = ['Add to plan', 'Defer']): AskUserQuestionFingerprint {
const toolUseId = `tool-${++nextCall}`;
return {
signature: `session:${toolUseId}`, promptSnippet: question,
options: labels.map((label, i) => ({ index: i + 1, label })),
observedAtMs: 0, preReview: true,
nativeCall: {
sessionId: 'session', toolUseId, answered: true,
answers: { [question]: labels[0]! },
questions: [{ header: 'DX decision', question, options: labels.map(label => ({ label })) }],
},
};
}
const issues = [
['CI gate', 'Journey Stage: HELLO WORLD. The mandatory five-minute CI gate blocks the first local evaluation. Remove the gate or make it optional for local runs?'],
['Argument order', 'run_eval(dataset, evaluator) and run_batch(evaluator, dataset) reverse the positional order. Should we standardize these signatures or require keyword arguments?'],
['Authentication error', 'An invalid API key raises AuthError("request failed"), with no explanation or recovery guidance. How should we replace this opaque error?'],
['Packaged example', 'The quickstart tells developers to run examples/first_eval.py, but it is absent from the published package. Include the example or fix the documented command?'],
['Breaking rename', 'Version 2 removes Client.evaluate and replaces it with Client.run without a migration guide or deprecation warning. Add a compatibility alias or a migration path?'],
] as const;
describe('DevEx substantive finding coverage', () => {
test('the observed mandatory confirmations alone contribute zero findings', () => {
const confirmations = [
'No design doc found. Run /office-hours first? <gstack-qid:plan-devex-review-office-hours-preflight>',
'Who is your primary target developer? <gstack-qid:plan-devex-review-persona>',
'Does the empathy narrative match reality? <gstack-qid:plan-devex-review-empathy-check>',
// A concrete defect in a benchmark recap does not make the target
// confirmation itself a resolution decision for that defect.
'Remove the mandatory CI wait before first eval to reach the agreed benchmark. Which tier do you confirm? <gstack-qid:plan-devex-review-tthw-tier>',
'What should the magical first-eval moment look like? <gstack-qid:plan-devex-review-magical-moment>',
'How deep should this DX review go? <gstack-qid:plan-devex-review-mode>',
'Confusion report reviewed. Which items should be addressed? <gstack-qid:plan-devex-review-confusion-report>',
'Which onboarding setup should run next? <gstack-qid:future-setup-choice>',
];
expect(confirmations.map(question => call(question)).filter(isDevexReviewIssue)).toEqual([]);
});
test.each(issues)('%s is a finding in investigation or scoring', (_name, question) => {
const fp = call(question);
expect(isDevexReviewIssue(fp)).toBe(true);
fp.preReview = false;
expect(isDevexReviewIssue(fp)).toBe(true);
});
test('full native question evidence survives a short diagnostic snippet', () => {
const fp = call('Context from the SDK audit. '.repeat(20) + issues[3][1]);
fp.promptSnippet = fp.promptSnippet.slice(0, 240);
expect(fp.promptSnippet).not.toContain('examples/first_eval.py');
expect(isDevexReviewIssue(fp)).toBe(true);
});
test('a real argument-order decision does not need a particular resolution verb', () => {
const fp = call('Which argument order should run_eval and run_batch use?', [
'Dataset first in both functions', 'Evaluator first in both functions',
]);
expect(isDevexReviewIssue(fp)).toBe(true);
});
test.each([
'Design doc', 'Target persona', 'Narrative check', 'TTHW target',
'Magic delivery', 'Review mode', 'Fix scope',
])('observed administrative header %s cannot borrow a defect from its recap', header => {
const fp = call(`${issues[0][1]} This is the context for our confirmation.`);
fp.nativeCall!.questions[0]!.header = header;
expect(isDevexReviewIssue(fp)).toBe(false);
});
test('a CI issue stays substantive when it references persona and TTHW evidence', () => {
const fp = call('The target persona confirmed our TTHW target. The mandatory CI gate blocks the first eval. Which local bypass should the SDK support?');
fp.nativeCall!.questions[0]!.header = 'CI gate fix';
expect(isDevexReviewIssue(fp)).toBe(true);
});
test('one call batching the defects does not become five finding decisions', () => {
const distinct = issues.map(([, question]) => call(question));
expect(distinct.filter(isDevexReviewIssue)).toHaveLength(5);
const batched = call('Review these issues together.');
batched.nativeCall!.questions = distinct.flatMap(fp => fp.nativeCall!.questions);
batched.nativeCall!.answers = Object.assign({}, ...distinct.map(fp => fp.nativeCall!.answers));
expect([batched].filter(isDevexReviewIssue)).toHaveLength(1);
});
test('an unanswered issue tab cannot turn an administrative answer into coverage', () => {
const admin = call('How deep should this DX review go? <gstack-qid:plan-devex-review-mode>');
const issue = call(issues[0][1]);
admin.nativeCall!.questions.push(issue.nativeCall!.questions[0]!);
admin.nativeCall!.unansweredQuestionIndices = [1];
expect(isDevexReviewIssue(admin)).toBe(false);
Object.assign(admin.nativeCall!.answers!, issue.nativeCall!.answers);
admin.nativeCall!.unansweredQuestionIndices = [];
expect(isDevexReviewIssue(admin)).toBe(true);
});
test.each([
'Which files should I review? <gstack-qid:unknown-administrative-choice>',
'I noted the mandatory CI gate before first eval. Can we continue the setup?',
'Should I add a developer community Slack channel?',
'Should the plan reference run_eval and run_batch?',
'The package includes examples/first_eval.py. Shall I read it?',
'Authentication errors already include a cause and a fix. Ready to continue?',
])('unknown or unsupported prompts do not count: %s', question => {
expect(isDevexReviewIssue(call(question))).toBe(false);
});
test('a generic question cannot borrow issue evidence from its option labels', () => {
expect(isDevexReviewIssue(call('What should I inspect next?', [issues[0][1], issues[1][1]]))).toBe(false);
});
});
describe('DevEx count review-mode selection', () => {
const modeQuestion = 'D6 — How deep should this DX review go? <gstack-qid:plan-devex-review-mode>';
test('selects POLISH from the actual menu that previously chose EXPANSION', () => {
expect(devexReviewModePick(call(modeQuestion, [
'DX EXPANSION (Recommended)', 'DX POLISH', 'DX TRIAGE',
]))).toBe(2);
});
test('retains the observed POLISH index after menu reordering', () => {
expect(devexReviewModePick(call(modeQuestion, [
'DX TRIAGE', 'DX EXPANSION', 'DX POLISH (Recommended)',
]))).toBe(3);
});
test('recognizes the same mode question without a question ID', () => {
expect(devexReviewModePick(call('HowdeepshouldthisDXreviewgo?', [
'DXEXPANSION(Recommended)', 'DXPOLISH', 'DXTRIAGE',
]))).toBe(2);
});
test('unrelated questions cannot select a mode from quoted labels', () => {
expect(devexReviewModePick(call('Which documentation example should be included?', [
'DX EXPANSION', 'DX POLISH', 'DX TRIAGE',
]))).toBeNull();
expect(devexReviewModePick(call(issues[0][1]))).toBeNull();
});
test('missing or ambiguous mode menus keep the existing choice policy', () => {
expect(devexReviewModePick(call(modeQuestion, ['DX EXPANSION', 'DX TRIAGE']))).toBeNull();
expect(devexReviewModePick(call(modeQuestion, [
'DX EXPANSION', 'DX POLISH', 'DX POLISH', 'DX TRIAGE',
]))).toBeNull();
expect(devexReviewModePick(call(modeQuestion, [
'DX EXPANSION │ DX POLISH', 'Example │ DX POLISH', 'DX TRIAGE',
]))).toBeNull();
});
test('a multi-question call is not treated as a single mode menu', () => {
const fp = call(modeQuestion, ['DX EXPANSION', 'DX POLISH', 'DX TRIAGE']);
fp.nativeCall!.questions.push(call(issues[0][1]).nativeCall!.questions[0]!);
expect(devexReviewModePick(fp)).toBeNull();
});
});
describe('DevEx calibrated fixture instructions', () => {
test('keeps the reviewed artifact path without telling the model an expected count', () => {
const plan = planDevexCountFixture('/tmp/owned-plan.md');
expect(plan).toContain('write your plan-mode plan to /tmp/owned-plan.md');
const suppliedContext = [plan, ...Object.values(DEVEX_COUNT_FILES)].join('\n');
expect(suppliedContext).not.toMatch(/(?:exactly|at least|at most)\s+(?:five|5)|(?:five|5)[- ]findings?|4[-–]7|reviewCount|CEILING|FLOOR/i);
});
});
+170 -43
View File
@@ -28,6 +28,8 @@ import * as path from 'path';
import { stripVTControlCharacters } from 'node:util';
import { hermeticChildEnv, hermeticSkillsConfigDir, isHermeticEnabled } from './hermetic-env';
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';
/** Strip ANSI escapes for pattern-matching against visible text. */
@@ -1058,6 +1060,22 @@ export interface AskUserQuestionFingerprint {
observedAtMs: number;
/** True if observed BEFORE the Step-0 boundary fired. */
preReview: boolean;
/** Lossless source metadata for counted calls; UI-only fingerprints omit it. */
nativeCall?: NativePlanQuestionCall;
}
/** Full question text feeds routing/phase predicates before diagnostics truncate it. */
export function nativePlanCallFingerprint(call: NativePlanQuestionCall, observedAtMs: number, preReview: boolean): AskUserQuestionFingerprint {
// An unanswered mode tab cannot establish the selected review mode. Keep
// every question in nativeCall, but classify and summarize only the
// actually answered questions once a native call has completed.
const questions = call.answered ? call.questions.filter(q => call.answers?.[q.question]) : call.questions;
return {
signature: `${call.sessionId}:${call.toolUseId}`,
promptSnippet: questions.map(q => `${q.header} ${q.question}`).join('\n\n'),
options: questions.flatMap(q => q.options.map((o, i) => ({ index: i + 1, label: o.label }))),
observedAtMs, preReview, nativeCall: call,
};
}
/**
@@ -1074,10 +1092,21 @@ export type Step0BoundaryPredicate = (
answeredFingerprint: AskUserQuestionFingerprint,
) => boolean;
/** A first finding starts review immediately; a final setup question does not count as a finding. */
export function planCountQuestionPhase(
fp: AskUserQuestionFingerprint,
reviewStarted: boolean,
isLastStep0AUQ: Step0BoundaryPredicate,
isFirstReviewAUQ?: Step0BoundaryPredicate,
): { preReview: boolean; reviewStarted: boolean } {
const inReview = reviewStarted || Boolean(isFirstReviewAUQ?.(fp));
return { preReview: !inReview, reviewStarted: inReview || isLastStep0AUQ(fp) };
}
/**
* Parse the rendered question prompt out of a visible TTY buffer. The prompt
* is the 1–3 lines of text immediately ABOVE the latest `❯ 1.` cursor line —
* not part of the option list, not the permission-dialog header.
* starts at the active boxed question header when available, otherwise at
* the lines immediately ABOVE the latest `❯ 1.` cursor line.
*
* Returns the prompt normalized to a single-spaced 240-char snippet (strip
* ANSI residue, collapse internal whitespace, trim) — short enough to use as
@@ -1124,6 +1153,23 @@ export function parseQuestionPrompt(visible: string): string {
}
}
// Native boxed AUQs can contain many paragraphs separated by a lone │.
// Walking upward from the options stops at that border (or the six-line
// limit) and loses the question's identity. Start at the latest header
// inside the same fixed, cursor-anchored context window instead. Reject
// earlier menus / Planning chrome so an old header cannot label a new
// unboxed dialog after its question has been dismissed.
const questionStart = [...tail.matchAll(/(?:^|\n)[\t │┃]*[☐□][\t ]*(?=\S)/g)].at(-1);
if (questionStart) {
const question = tail.slice(questionStart.index, tail.length - cursor[0].length);
if (!/❯\s*[1-9]\./.test(question) && !/(?:^|\n)\s*Plan+ing\s*:/i.test(question)) {
const prompt = question
.replace(/^[\t ─━┄┅┈┉┌┐└┘├┤┬┴┼│┃☐□■]+/gm, '')
.replace(/\s+/g, ' ').trim();
if (prompt) return prompt.slice(0, 240);
}
}
// Walk up at most 6 lines collecting prompt text. Stop at:
// - a blank line preceded by another blank line (paragraph break)
// - top of buffer
@@ -1246,9 +1292,11 @@ export function planCountSubmissionInput(visible: string): string | null {
const bar = bars.at(-1);
if (!bar) return null;
const panel = visible.slice(bar.index + bar[0].length).replace(/\s+/g, '');
const cursor = [...panel.matchAll(/❯1\./g)].at(-1);
if (!/Reviewyouranswers/i.test(panel) || !cursor ||
!/^❯1\.Submit/i.test(panel.slice(cursor.index))) return null;
// Captured cursor redraws can omit the dot after 1 and the e in answers.
// The native tab bar and explicit Submit label still identify this control.
const cursor = [...panel.matchAll(/❯1\.?/g)].at(-1);
if (!/Reviewyouranswe?rs/i.test(panel) || !cursor ||
!/^❯1\.?Submit/i.test(panel.slice(cursor.index))) return null;
const tabs = [...bar[0].matchAll(/[☐☒]/g)].map((match) => match[0]);
const unanswered = tabs.indexOf('☐');
// At the Submit tab, move back to the first unanswered question. Only
@@ -1368,6 +1416,17 @@ export const ceoStep0Boundary: Step0BoundaryPredicate = (fp) =>
// directly to review-phase. Boundary fires on the scope AUQ itself.
fp.options.some((o) => /skip\s+interview|plan\s+immediately/i.test(o.label));
/** Native finding evidence when CEO mode selection is omitted or left unanswered. */
export const ceoFirstReviewAUQ: Step0BoundaryPredicate = (fp) =>
fp.nativeCall?.questions.some(q => {
if (fp.nativeCall?.answered && !fp.nativeCall.answers?.[q.question]) return false;
const id = /<gstack-qid:\s*(?:plan-)?ceo-(?:review-)?([a-z0-9-]+)/i.exec(q.question)?.[1];
if (!id || /(?:^|-)(?:scope|mode|approach|routing|office-hours|prerequisites?|setup|next-steps|completion)(?:-|$)/i.test(id)) return false;
const title = q.question.split('\n')[0];
return /^(?:D\s*\d+\s*[—–-]|(?:Finding|Section)\s*\d+)/i.test(title) &&
/\bfinding\b|\bmissing\b|\bambiguous\b|\bundefined\b|doesn['’]t\s+(?:define|specify|cover|mention)/i.test(title);
}) ?? false;
export const engStep0Boundary: Step0BoundaryPredicate = (fp) =>
/scope\s*reduction\s*recommendation|cross[\s-]*project\s*learnings/i.test(
fp.promptSnippet,
@@ -1386,6 +1445,19 @@ export const designStep0Boundary: Step0BoundaryPredicate = (fp) =>
fp.promptSnippet,
);
/** Positive review identity when a design run goes directly to findings without a focus AUQ. */
export const designFirstReviewAUQ: Step0BoundaryPredicate = (fp) => {
// A numbered setup decision is not sufficient. Require the design review's
// question ID as well, and exclude its scope/focus/onboarding identities.
const id = /<gstack-qid:\s*plan-design-review-([a-z0-9-]+)/i.exec(fp.promptSnippet)?.[1];
if (id && /(?:^|[│\s])D\s*\d+\s*[—–-]/i.test(fp.promptSnippet) &&
!/(?:^|-)(?:scope|focus|setup|routing|onboarding|posture|mockups?|target)(?:-|$)/i.test(id) &&
!designStep0Boundary(fp)) return true;
// Explicit pass headings are also review evidence; an initial assessment
// that merely mentions reviewing seven passes does not match this shape.
return /(?:^|│)\s*Pass\s*[1-7]\s*(?:\([^)]*\)\s*)?[—–:]/i.test(fp.promptSnippet);
};
export const devexStep0Boundary: Step0BoundaryPredicate = (fp) =>
/developer\s*persona|target\s*persona|persona\s*selection|TTHW\s*target/i.test(
fp.promptSnippet,
@@ -2018,7 +2090,7 @@ export async function runPlanSkillObservation(opts: {
// ────────────────────────────────────────────────────────────────────────────
// runPlanSkillCounting — drives a plan-* skill end-to-end through Step 0 then
// counts distinct review-phase AskUserQuestion fingerprints. The actual
// counts completed review-phase AskUserQuestion calls. The actual
// product asserted by the per-finding-count tests.
// ────────────────────────────────────────────────────────────────────────────
@@ -2028,11 +2100,15 @@ export async function runPlanSkillObservation(opts: {
* dumps when an assertion fails.
*/
export interface PlanSkillCountObservation {
/** Durable full raw/visible PTY output plus JSON observation, when EVALS_RUN_ID is set. */
artifactDir?: string;
artifactError?: string;
outcome:
| 'plan_ready'
| 'completion_summary'
| 'ceiling_reached'
| 'silent_write'
| 'transcript_unavailable'
| 'exited'
| 'timeout';
summary: string;
@@ -2042,6 +2118,8 @@ export interface PlanSkillCountObservation {
elapsedMs: number;
/** All distinct AskUserQuestions observed, in observation order. */
fingerprints: AskUserQuestionFingerprint[];
/** Actual native calls, including unanswered/failed ones that add no coverage. */
transcript: PlanCountTranscript;
/** Count of fingerprints with `preReview === true`. */
step0Count: number;
/** Count of fingerprints with `preReview === false`. */
@@ -2065,16 +2143,15 @@ export interface PlanSkillCountObservation {
* review incorrectly starts against the operator's live branch.
* 3. Poll loop:
* - Skip permission dialogs (auto-grant with `defaultPick`).
* - On a new numbered-option list, parse prompt + options, build
* fingerprint via `auqFingerprint`. Empty-prompt parses are skipped
* and re-polled (avoids the empty-prompt collision documented in
* the auqFingerprint contract).
* - First time we see a fingerprint: push it, classify as Step 0 or
* review-phase based on `boundaryFired`, press `defaultPick` to
* advance. Decline only the recognized optional office-hours
* - Read fixture-scoped native JSONL. Count each AskUserQuestion call
* once after its matching successful answer record, regardless of
* how many questions the call batches. Full native metadata feeds
* phase predicates; ANSI redraws and permissions cannot add counts.
* - On a new numbered-option list, keep the existing PTY answer driver.
* Decline only the recognized optional office-hours
* prerequisite by label so a different skill does not rewrite the
* seeded plan before this review begins.
* - After pressing, evaluate `isLastStep0AUQ(fingerprint)`. If true,
* - After the native answer, evaluate `isLastStep0AUQ(fingerprint)`. If true,
* all subsequent AUQs are review-phase.
* - Hard ceiling: if `reviewCount >= reviewCountCeiling`, return
* `ceiling_reached`. This bounds runaway counts; tests should set
@@ -2089,10 +2166,9 @@ export interface PlanSkillCountObservation {
* where Step-0-final and Section-1-first AUQs straddle a section header
* regex match.
*
* Fingerprint composition (D9): `auqFingerprint(prompt, options)` mixes
* normalized prompt text with the options signature so distinct findings
* with shared menu structure (the generic A/B/C TODO menu) get distinct
* fingerprints.
* UI fingerprints still dedupe redraws. Counted fingerprints use the
* native session/tool call IDs, so shared answer labels never collapse
* different calls and one multi-question call remains one finding.
*/
export async function runPlanSkillCounting(opts: {
/** Skill name, e.g. 'plan-ceo-review'. Used for diagnostic strings only. */
@@ -2103,6 +2179,14 @@ export async function runPlanSkillCounting(opts: {
followUpPrompt: string;
/** Per-skill predicate: which answered AUQ is the last Step-0 question. */
isLastStep0AUQ: Step0BoundaryPredicate;
/** Optional positive identity for a first finding when no final setup AUQ was emitted. */
isFirstReviewAUQ?: Step0BoundaryPredicate;
/** Optional issue classifier across phases; receives full native call metadata. */
isReviewAUQ?: (fp: AskUserQuestionFingerprint) => boolean;
/** Narrow caller-specific selection; null retains the normal answer policy. */
pickAUQ?: (fp: AskUserQuestionFingerprint) => number | null;
/** Additional versioned files available in the isolated fixture before the skill starts. */
fixtureFiles?: Record<string, string>;
/** Hard cap on review-phase count; helper returns when reached. Should be
* set ABOVE the test's assertion ceiling so the test sees the cap as a
* failure rather than a silent stop. */
@@ -2133,7 +2217,7 @@ export async function runPlanSkillCounting(opts: {
const defaultPick = opts.defaultPick ?? 1;
const timeoutMs = opts.timeoutMs ?? 1_500_000;
const fixture = createPlanCountFixture(opts.followUpPrompt, { nativeReviewOnly: true });
const fixture = createPlanCountFixture(opts.followUpPrompt, { nativeReviewOnly: true, files: opts.fixtureFiles });
let session: ClaudePtySession;
try {
session = await launchClaudePty({
@@ -2151,10 +2235,19 @@ export async function runPlanSkillCounting(opts: {
const fingerprints: AskUserQuestionFingerprint[] = [];
const seen = new Set<string>();
const countedCalls = new Set<string>();
let transcript: PlanCountTranscript = { status: 'missing', calls: [], assistantMessages: [] };
let boundaryFired = false;
let step0Count = 0;
let reviewCount = 0;
let isFirstAUQ = true;
const saveSnapshot = createPlanCountSnapshotWriter();
let lastCheckpointAt = Date.now();
const capture = (observation: object) => saveSnapshot({
skillName: opts.skillName, observation, raw: session.rawOutput(), visible: session.visibleText(),
cwd: fixture.cwd, claudeConfigDir: session.hermeticConfigDir,
});
function snapshot(
outcome: PlanSkillCountObservation['outcome'],
@@ -2164,7 +2257,7 @@ export async function runPlanSkillCounting(opts: {
const clean = (text: string) => stripVTControlCharacters(text)
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
const failed = outcome === 'exited' || outcome === 'timeout';
return {
const observation: PlanSkillCountObservation = {
outcome,
summary,
evidence: failed
@@ -2173,9 +2266,15 @@ export async function runPlanSkillCounting(opts: {
: visible.slice(-3000),
elapsedMs: Date.now() - startedAt,
fingerprints,
transcript,
step0Count,
reviewCount,
};
const artifacts = capture(observation);
Object.assign(observation, artifacts);
if (artifacts.artifactDir) observation.evidence += `\nFull PTY artifacts: ${artifacts.artifactDir}`;
if (artifacts.artifactError) observation.evidence += `\nPTY artifact write failed: ${artifacts.artifactError}`;
return observation;
}
try {
@@ -2187,6 +2286,39 @@ export async function runPlanSkillCounting(opts: {
while (Date.now() - budgetStart < timeoutMs) {
await Bun.sleep(2000);
const visible = session.visibleSince(since);
transcript = session.hermeticConfigDir
? readPlanCountTranscript(session.hermeticConfigDir, fixture.cwd)
: { status: 'error', calls: [], assistantMessages: [], error: 'Claude count session has no isolated transcript directory' };
if (transcript.status === 'error') {
return snapshot('transcript_unavailable', transcript.error!, visible);
}
for (const call of transcript.calls) {
const signature = `${call.sessionId}:${call.toolUseId}`;
if (!call.answered || countedCalls.has(signature)) continue;
const fp = nativePlanCallFingerprint(call, Date.now() - startedAt, !boundaryFired);
const phase = planCountQuestionPhase(fp, boundaryFired, opts.isLastStep0AUQ, opts.isFirstReviewAUQ);
fp.preReview = opts.isReviewAUQ ? !opts.isReviewAUQ(fp) : phase.preReview;
fp.promptSnippet = fp.promptSnippet.replace(/\s+/g, ' ').slice(0, 240);
fingerprints.push(fp);
countedCalls.add(signature);
if (fp.preReview) step0Count += 1;
else reviewCount += 1;
boundaryFired = phase.reviewStarted;
}
if (reviewCount >= opts.reviewCountCeiling) {
if (unresolvedPlanQuestionCalls(transcript.calls).length) {
return snapshot('transcript_unavailable', 'Question count reached its ceiling with unresolved failed native calls', visible);
}
return snapshot('ceiling_reached', `review-phase AUQ count reached ceiling (${opts.reviewCountCeiling})`, visible);
}
// An outer test timeout/cancellation may prevent a terminal snapshot.
// Keep bounded-cadence evidence without adding a timer to clean up.
if (Date.now() - lastCheckpointAt >= 30_000) {
lastCheckpointAt = Date.now();
const saved = capture({ state: 'in_progress', elapsedMs: Date.now() - startedAt,
fingerprints, step0Count, reviewCount, transcript });
if (saved.artifactError) console.error(`PTY artifact write failed: ${saved.artifactError}`);
}
// Process exited?
if (session.exited()) {
@@ -2240,6 +2372,10 @@ 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 (frame === 'completion_summary') {
if (transcript.status !== 'ready' || transcript.calls.some(c => !c.answered && !c.failed) ||
unresolvedPlanQuestionCalls(transcript.calls).length) {
return snapshot('transcript_unavailable', 'Completion has no complete native question transcript', visible);
}
return snapshot(
'completion_summary',
`skill emitted completion summary / verdict / status line (step0=${step0Count}, review=${reviewCount})`,
@@ -2247,6 +2383,10 @@ export async function runPlanSkillCounting(opts: {
);
}
if (frame === 'plan_ready') {
if (transcript.status !== 'ready' || transcript.calls.some(c => !c.answered && !c.failed) ||
unresolvedPlanQuestionCalls(transcript.calls).length) {
return snapshot('transcript_unavailable', 'Plan-ready gate has no complete native question transcript', visible);
}
return snapshot(
'plan_ready',
`skill emitted plan-mode "Ready to execute" confirmation (step0=${step0Count}, review=${reviewCount})`,
@@ -2261,33 +2401,20 @@ export async function runPlanSkillCounting(opts: {
// findings often reuse the same Add to plan / Defer / Skip menu.
const fp = capturePlanCountQuestion(visible, seen, Date.now() - startedAt, !boundaryFired);
if (!fp) continue;
fingerprints.push(fp);
if (boundaryFired) reviewCount += 1;
else step0Count += 1;
// Press to advance — first AUQ may use the override pick.
const prerequisitePick = planCountPrerequisitePick(fp);
const pickIdx = prerequisitePick ??
(isFirstAUQ && opts.firstAUQPick ? opts.firstAUQPick(fp) : defaultPick);
const pending = transcript.calls.find(c => !c.answered && !c.failed);
const routing = pending?.questions.length === 1
? nativePlanCallFingerprint(pending, fp.observedAtMs, fp.preReview) : fp;
const prerequisitePick = planCountPrerequisitePick(routing);
// Native tool records may flush only after the answer. Let a guarded
// caller recognize that visible menu, but never override a known packet.
const callerPick = !pending || pending.questions.length === 1 ? opts.pickAUQ?.(routing) ?? null : null;
const pickIdx = prerequisitePick ?? callerPick ??
(isFirstAUQ && opts.firstAUQPick ? opts.firstAUQPick(routing) : defaultPick);
isFirstAUQ = false;
if (prerequisitePick !== null) await selectPtyNumberedOption(session, pickIdx);
if (prerequisitePick !== null || callerPick !== null) await selectPtyNumberedOption(session, pickIdx);
else session.send(`${pickIdx}\r`);
// Evaluate boundary AFTER pressing — if THIS AUQ was the last Step 0
// question, all subsequent AUQs go to reviewCount.
if (!boundaryFired && opts.isLastStep0AUQ(fp)) {
boundaryFired = true;
}
// Hard ceiling — runaway protection.
if (reviewCount >= opts.reviewCountCeiling) {
return snapshot(
'ceiling_reached',
`review-phase AUQ count reached ceiling (${opts.reviewCountCeiling})`,
session.visibleSince(since),
);
}
// Give the agent a beat to advance to the next state.
await Bun.sleep(2000);
}
+136
View File
@@ -36,6 +36,7 @@ import {
TAIL_SCAN_BYTES,
optionsSignature,
parseQuestionPrompt,
stripAnsi,
auqFingerprint,
COMPLETION_SUMMARY_RE,
classifyPlanCountFrame,
@@ -46,6 +47,8 @@ import {
ceoStep0Boundary,
engStep0Boundary,
designStep0Boundary,
designFirstReviewAUQ,
planCountQuestionPhase,
devexStep0Boundary,
type ClaudePtyOptions,
type AskUserQuestionFingerprint,
@@ -114,6 +117,18 @@ describe('isPermissionDialogVisible', () => {
expect(isPermissionDialogVisible(sample.replace('Esctocancel·Tabtoamend', 'Enter to select'))).toBe(false);
});
test('the captured paired-CEO Edit grant is permission, not another review finding', () => {
const sample = [
'Do youwt to makehis dittogstack-test-plan-ceo-paired.md?',
'❯1.Yes',
'2.Yes,andswitchtoacceptedits(auto-approvefileeditsandcommonfilecommands)forthissession;Yes,and',
'alwaysallowaccessto/tmp/gstack-paid-shard-EbUl9j/tmp/gstack-e2e-plan-ceo-paired-gkjAd5forthissession',
'(shift+tab)', '3.No', 'Esctocancel·Tabtoamend',
].join('\r');
expect(isPermissionDialogVisible(sample)).toBe(true);
expect(classifyPlanCountFrame(sample)).toBe('permission');
});
test('recognizes permission labels whose cursor-positioning spaces disappeared', () => {
expect(isPermissionDialogVisible('Yes,andalwaysallowaccessto/tmp/fixtureforthissession')).toBe(true);
expect(isPermissionDialogVisible('Yes,allowalleditsduringthissession')).toBe(true);
@@ -866,6 +881,58 @@ describe('optionsSignature', () => {
});
describe('parseQuestionPrompt', () => {
test('keeps the captured boxed learnings header across native CR and blank borders', () => {
// Exact active-menu bytes from the targeted-a engineering batching run.
// Its answered setup AUQ lost the title at the standalone box border,
// leaving every later finding classified as preReview.
const raw = "☐ Learnings\u001b[K\r\u001b[1B\u001b[K\r\u001b[1B│ D1 — Cross-project learnings scope <gstack-qid:learnings-cross-project>\u001b[K\r\u001b[1B│\u001b[3G\u001b[K\r\r\n│\u001b[3Ggstack\u001b[10Gcan\u001b[14Gsearch\u001b[21Glearnings\u001b[31Gfrom\u001b[36Gyour\u001b[41Gother\u001b[47Gprojects\u001b[56Gon\u001b[59Gthis\u001b[64Gmachine\u001b[72Gto\u001b[75Gfind\u001b[80Gpatterns\u001b[89Gthat\u001b[94Gmight\u001b[100Gapply\u001b[106Ghere.\u001b[112GThis\r\r\n│\u001b[3Gstays\u001b[9Glocal\u001b[15G—\u001b[17Gno\u001b[20Gdata\u001b[25Gleaves\u001b[32Gyour\u001b[37Gmachine.\u001b[46GRecommended\u001b[58Gfor\u001b[62Gsolo\u001b[67Gdevelopers.\u001b[79GSkip\u001b[84Gif\u001b[87Gyou\u001b[91Gwork\u001b[96Gon\u001b[99Gmultiple\u001b[108Gclient\r\r\n│\u001b[3Gcodebases\u001b[13Gwhere\u001b[19Gcross-contamination\u001b[39Gwould\u001b[45Gbe\u001b[48Ga\u001b[50Gconcern.\r\r\n\r\r\n❯\u001b[3G1.\u001b[6GEnable\u001b[13Gcross-project\u001b[27Glearnings\u001b[37G(Recommended)\r\r\n\u001b[6GSearch\u001b[13Glearnings\u001b[23Gfrom\u001b[28Gall\u001b[32Gprojects\u001b[41Gon\u001b[44Gthis\u001b[49Gmachine\u001b[57G—\u001b[59Gsurfaces\u001b[68Gpatterns\u001b[77Gand\u001b[81Gpitfalls\u001b[90Gfrom\u001b[95Gprior\u001b[101Gsessions.\r\r\n\u001b[3G2.\u001b[6GKeep\u001b[11Glearnings\u001b[21Gproject-scoped\u001b[36Gonly\r\r\n\u001b[6GOnly\u001b[11Guse\u001b[15Glearnings\u001b[25Gfrom\u001b[30Gthis\u001b[35Gproject.\u001b[44GSafe\u001b[49Gfor\u001b[53Gmulti-client\u001b[66Genvironments.\r\r\n\u001b[3G3.\u001b[6GType\u001b[11Gsomething.\r\r\n────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r\r\n\u001b[3G4.\u001b[6GChat\u001b[11Gabout\u001b[17Gthis\r\r\n\r\r\nEnter\u001b[7Gto\u001b[10Gselect\u001b[17G·\u001b[19G↑/↓\u001b[23Gto\u001b[26Gnavigate\u001b[35G·\u001b[37GEsc\u001b[41Gto\u001b[44Gcancel";
const visible = stripAnsi(raw);
const question = capturePlanCountQuestion(visible, new Set(), 0, true)!;
expect(question.promptSnippet).toStartWith('Learnings D1 — Cross-project learnings scope');
expect(question.promptSnippet).toContain('<gstack-qid:learnings-cross-project>');
expect(engStep0Boundary(question)).toBe(true);
const phase = planCountQuestionPhase(question, false, engStep0Boundary);
expect(phase).toEqual({ preReview: true, reviewStarted: true });
});
test('keeps a long boxed question identity instead of its closing recommendation', () => {
const frame = [
'Planning: /tmp/hermetic/.claude/plans/review.md',
'─'.repeat(120),
'☐ Architecture',
'│ D2 — Architecture: custom retry scheduler vs library built-in <gstack-qid:arch-custom-retry-vs-library>',
'│',
...Array.from({ length: 12 }, (_, i) => `│ Review context line ${i}: the proposed retry behavior and its tradeoffs.`),
'│',
'│ Net: If the library hook is configurable, use the existing implementation.',
'❯1.Use library built-in (Recommended)',
'2.Extract shared retry envelope',
].join('\r\r\n');
const seen = new Set<string>();
const question = capturePlanCountQuestion(frame, seen, 0, false)!;
expect(question.promptSnippet).toStartWith('Architecture D2 — Architecture: custom retry scheduler');
expect(question.promptSnippet).toContain('<gstack-qid:arch-custom-retry-vs-library>');
expect(question.promptSnippet).not.toContain('Planning:');
expect(question.promptSnippet.length).toBeLessThanOrEqual(240);
expect(capturePlanCountQuestion(frame + '\n' + '·'.repeat(6000), seen, 1, false)).toBeNull();
});
test('does not reuse an old boxed header for a later unboxed menu', () => {
const visible = [
'☐ Old setup',
'D1 — Cross-project learnings scope',
'❯1.Enable',
'2.Skip',
'Planning: /tmp/hermetic/.claude/plans/review.md',
'D2 — Choose the retry behavior',
'❯1.Use library built-in',
'2.Extract shared retry envelope',
].join('\n');
const prompt = parseQuestionPrompt(visible);
expect(prompt).toBe('D2 — Choose the retry behavior');
expect(prompt).not.toContain('Old setup');
});
test('captures 1-line prompt above the cursor', () => {
const visible = `
D1 — Pick a mode
@@ -1290,6 +1357,27 @@ describe('classifyPlanCountFrame replay', () => {
});
describe('planCountSubmissionInput replay', () => {
test('the captured mode Submit panel with a damaged caption and dotless cursor returns to its unanswered tab', () => {
// Exact final active panel from targeted-a's SCOPE EXPANSION retry.
const captured = [
'← ☒ Routing rule ☐ Design doc ✔ Submit →',
'',
'Review your answrs',
'⚠ You hvenot answered all questions',
" ● Add gstack skill routing rules tothisproject'sCLAUDE.md?",
'→dd routing rues (Recommnded)',
'',
'Ready to submit your answers?',
'',
'❯1Submit answers',
' 2. Cancel',
].join('\r');
expect(planCountSubmissionInput(captured)).toBe('\x1b[Z');
const answered = captured.replace('☐ Design doc', '☒ Design doc').replace('⚠ You hvenot answered all questions', '');
expect(planCountSubmissionInput(answered)).toBe('\r');
expect(planCountSubmissionInput(captured + '\r☐ Design doc\rRun office hours?\r❯1Run now\r2.Skip')).toBeNull();
});
const incomplete = [
'← ☒ Learnings scope ☐ Approach ✔ Submit →',
'Review your answers',
@@ -1507,6 +1595,54 @@ describe('Step0BoundaryPredicate per-skill', () => {
});
});
describe('design review begins without an optional focus question', () => {
// Captured in the second07:53 paid attempt: real D1-D7 questions were
// all incorrectly marked preReview, producing reviewCount=0 at completion.
const questions = [
'☐Buttonstyle │D1—Howshouldthe4headerbuttons(Save,Reset,Cancel,Export)bevisuallydifferentiated? │<gstack-qid:plan-design-review-butn-hierarchy>',
'☐ Loading UX │D2—Whatloadingindicatorshouldappearduringthe2-5secondSaveoperation? │<gtack-qid:plan-esign-review-loadig-indicator>',
'☐ Spacing │D3—Whichspacingscaleshouldthesettingspagestandardizeon?<gstack-qid:plan-design-review-spacing-scale>',
'☐Typography │D4—Which2-sizetypographysystemshouldthesettingspageuse?<gstack-qid:plan-design-review-type-system>',
'☐Mobile layout │D5 — On obile (<768px), how should the4-buton header behave?<gstack-qid:plan-design-review-mobile-header>',
'☐DEIGN.md TODO │D6 — TODO: Create a DESIGN.md file codifying the5 decisions mdein this revew <gstack-qid:plan-design-review-todo-designmd>',
'☐PartialfailTODO │D7—TODO:Specifythepartial-failurestate—whatdoestheuserseeifSavesucceedsforsomefieldsbutfailsfor others? <gstack-qid:plan-design-review-todo-partialfail>',
];
test('counts the first captured finding and every subsequent finding', () => {
let reviewStarted = false;
const phases = questions.map(question => {
const phase = planCountQuestionPhase(fp(question, ['Apply', 'Defer']), reviewStarted,
designStep0Boundary, designFirstReviewAUQ);
reviewStarted = phase.reviewStarted;
return phase.preReview;
});
expect(phases).toEqual([false, false, false, false, false, false, false]);
});
test('keeps the observed focus gate separate when it is emitted', () => {
const focus = fp("☐ Focus areas │ I've rated this plan2/10 on design completeness. Review all7 dimensions?", ['All7dimensions', 'Priority gaps']);
const setup = planCountQuestionPhase(focus, false, designStep0Boundary, designFirstReviewAUQ);
expect(setup).toEqual({ preReview: true, reviewStarted: true });
expect(planCountQuestionPhase(fp(questions[0], ['Apply', 'Defer']), setup.reviewStarted,
designStep0Boundary, designFirstReviewAUQ)).toEqual({ preReview: false, reviewStarted: true });
});
test('requires review identity, not just a D1 label or setup question ID', () => {
for (const question of [
'☐ Setup │D1—Enable cross-project learnings?',
'☐ Review target │D1—Which plan should I review?<gstack-qid:plan-design-review-scope>',
'☐ Focus │D1—What should this design review focus on?<gstack-qid:plan-design-review-focus-areas>',
'☐ Scope │I will review Pass1 through Pass7 after setup. Proceed?',
]) expect(designFirstReviewAUQ(fp(question, ['Yes', 'No']))).toBe(false);
expect(designFirstReviewAUQ(fp('☐ Page structure │ Pass1 — Information Architecture: what page structure should this use?', ['Standard', 'Sidebar']))).toBe(true);
});
test('leaves callers without a first-review predicate unchanged', () => {
expect(planCountQuestionPhase(fp(questions[0], ['Apply', 'Defer']), false, designStep0Boundary))
.toEqual({ preReview: true, reviewStarted: false });
});
});
describe('devexStep0Boundary', () => {
test('FIRES on developer persona selection', () => {
const f = fp('Pick the target persona for this review', ['Senior backend', 'Junior frontend', 'Other']);
+168
View File
@@ -0,0 +1,168 @@
import type { AskUserQuestionFingerprint } from './claude-pty-runner';
/** Concrete product decisions, separate from the skill's mandatory Step-0 confirmations. */
export const DEVEX_COUNT_FILES: Record<string, string> = {
'README.md': `# EvalKit SDK
EvalKit is a Python SDK for ML engineers evaluating LLM responses. The primary
developer writes Python daily, uses a terminal, and wants a local result before
connecting the SDK to production CI. The agreed review posture is DX POLISH:
improve the existing SDK's touchpoints within the beta release scope.
## Getting started
Install with \`python -m pip install evalkit==2.0.0b1\`, set EVALKIT_API_KEY,
then follow the quickstart's command: \`python examples/first_eval.py\`.
The published package inventory is in docs/package-contents.txt.
The chosen first-success experience is an included, copy-paste demo command:
\`python -m evalkit.demo\`. It evaluates bundled sample responses and prints
real per-example scores plus an overall score. It needs no hosted playground
or new interactive UI. Like every first evaluation, it currently waits for the
mandatory CI check described in docs/current-contracts.md.
See docs/api.md for public API and upgrade behavior, and docs/benchmarks.md for
the completed onboarding study. These documents describe the existing SDK's
behavior; its runtime is maintained separately from this release-planning repo.
`,
'docs/benchmarks.md': `# Completed onboarding study
The internal comparison measured Python SDK onboarding with the same developer
and machine. Peer SDK A took 2 minutes, B took 4 minutes, and C took 3 minutes.
EvalKit took 6 minutes, including the mandatory 5-minute CI wait. The measurement
starts before installation and ends at the first real evaluation result.
The agreed target is under 2 minutes. The study, target persona, and terminal
demo delivery vehicle are already approved. Timing instrumentation and the
post-beta feedback survey exist and will continue unchanged.
`,
'docs/current-contracts.md': `# Existing SDK contracts
On a developer's first local evaluation, the SDK requires a successful remote
CI check and blocks for five minutes before returning an evaluation result.
There is no skip flag or offline first-run path. The beta plan retains this gate.
Authentication errors behave exactly as documented in docs/api.md. All other
errors already identify the cause, relevant argument or file, and an actionable
fix. Errors redact secrets. API timeouts, cancellation, rate limits, and retries
are bounded and documented; evaluation IDs prevent duplicate submitted jobs.
The SDK supports Python 3.10+, macOS, Linux, and Windows without Docker. Its
type annotations, offline sample data, mock transport, noninteractive CI mode,
API reference, support contact, changelog, and contributor guide already work.
Telemetry is opt-in. No new hosted service, language binding, or community
program is proposed in this release.
`,
'docs/api.md': `# Public API retained by the beta plan
The two evaluation functions accept positional arguments:
- \`run_eval(dataset, evaluator)\`
- \`run_batch(evaluator, dataset)\`
Both argument names describe the same concepts. The reversed positional order
is intentional in the current draft; neither function requires keyword arguments.
For an invalid API key, the SDK raises \`AuthError("request failed")\`.
There is no authentication error code, explanation of the cause, or instruction
for replacing the key. The plan retains this message.
Version 1 exposes \`Client.evaluate()\`. Version 2 replaces it with
\`Client.run()\` and removes the old name immediately. No compatibility alias,
deprecation warning, migration guide, or codemod is provided. Other public APIs
retain their existing behavior, and the release changelog is otherwise complete.
`,
'docs/package-contents.txt': `Published evalkit 2.0.0b1 package inventory:
evalkit/__init__.py
evalkit/client.py
evalkit/demo.py
evalkit/sample_responses.json
README.md
The quickstart references examples/first_eval.py, but that file is absent from
both the published package and the release examples archive. The terminal demo
module and its sample data are included and work as documented.
`,
};
export function planDevexCountFixture(planPath: string): string {
return [
`Please review this plan thoroughly. As you go, write your plan-mode plan to ${planPath} (use Edit/Write to that exact path).`,
'',
'# Plan: EvalKit SDK beta release polish',
'',
'The primary developer, onboarding benchmark, and terminal demo experience',
'are settled in README.md and docs/benchmarks.md. Use DX POLISH for the',
'existing release scope. Review the actual documented contracts and proposed',
'behavior, including the first-run CI requirement, public function signatures,',
'authentication error, packaged quickstart, and v1-to-v2 client upgrade.',
'',
'The current draft ships the behavior in docs/current-contracts.md and',
'docs/api.md unchanged, using the package inventory in docs/package-contents.txt.',
'Recommendations that repair those developer-facing contracts belong in this',
'plan. Existing working contracts remain the baseline for the review.',
].join('\n');
}
function questionRecords(fp: AskUserQuestionFingerprint, answeredOnly = false): Array<{ header: string; question: string }> {
if (!fp.nativeCall) return [{ header: '', question: fp.promptSnippet }];
return fp.nativeCall.questions.filter(q => !answeredOnly
|| (fp.nativeCall!.answered && Boolean(fp.nativeCall!.answers?.[q.question])));
}
const ADMINISTRATIVE_HEADERS = new Set([
'design doc', 'prerequisite', 'routing rules', 'routing setup', 'cross-project',
'target persona', 'developer persona', 'persona selection', 'empathy check',
'narrative check', 'tthw target', 'competitive benchmark', 'benchmark confirmation',
'magic delivery', 'review mode', 'fix scope', 'confusion scope',
]);
function administrativeQuestion(header: string, question: string): boolean {
// These decisions establish the review's evidence and scope. Mentioning a
// defect in their recap does not turn a confirmation into a finding.
if (ADMINISTRATIVE_HEADERS.has(header.toLowerCase().replace(/\s+/g, ' ').trim())) return true;
const id = [...question.matchAll(/<gstack-qid:([^>]+)>/gi)].at(-1)?.[1];
if (id && /^(?:routing-injection|cross-project-learnings|plan-devex-review-(?:office-hours-preflight|prereq|persona|empathy(?:-check|-narrative)?|tthw-tier|competitive-tier|benchmark-tier|magical-moment|mode|confusion-report))$/i.test(id)) return true;
return /how deep should this dx review|which (?:dx )?review mode|\b(?:can|shall|should) we (?:continue|proceed|begin)(?: (?:the )?(?:setup|review)| now)?\?\s*$/i.test(question);
}
/** The answered native call proves a decision; its content must identify a concrete problem. */
function substantiveIssue({ header, question }: { header: string; question: string }): boolean {
if (administrativeQuestion(header, question)) return false;
const normalized = `${header} ${question}`.replace(/\s+/g, ' ');
const ciGate = /\b(?:CI|continuous integration)\b/i.test(normalized)
&& /\b(?:first[- ]run|first eval(?:uation)?|local eval(?:uation)?|hello world)\b/i.test(normalized)
&& /\b(?:mandatory|required|blocks?|five[- ]minute|5[- ]min(?:ute)?|wait|gate)\b/i.test(normalized);
const argumentsReversed = /\brun_eval\b/i.test(normalized) && /\brun_batch\b/i.test(normalized)
&& /\b(?:revers\w*|inconsisten\w*|swapp\w*|different|order|positional)\b/i.test(normalized);
const opaqueAuth = /\b(?:AuthError|API[- ]?key|authentication|invalid key)\b/i.test(normalized)
&& /request failed|\b(?:opaque|generic|unactionable|cryptic)\b|no (?:cause|guidance|fix|explanation|instruction)|doesn.t (?:explain|guide)/i.test(normalized);
const missingExample = /examples\/first_eval\.py|\b(?:packaged|quickstart|quick-start) example\b/i.test(normalized)
&& /\b(?:missing|absent|omitted|FileNotFoundError)\b|not (?:included|packaged|shipped)|doesn.t (?:exist|ship)/i.test(normalized);
const breakingRename = /Client\.evaluate|Client\.run|\bmethod rename\b/i.test(normalized)
&& /\b(?:breaking|remov\w*|renam\w*)\b/i.test(normalized)
&& /\b(?:migration|deprecation|compatibility|alias|codemod)\b/i.test(normalized);
return ciGate || argumentsReversed || opaqueAuth || missingExample || breakingRename;
}
/** A batched native call remains one decision; the caller owns call-ID deduplication. */
export function isDevexReviewIssue(fp: AskUserQuestionFingerprint): boolean {
return questionRecords(fp, true).some(substantiveIssue);
}
/** Select POLISH only on the recognized mode menu; leave all other answers unchanged. */
export function devexReviewModePick(fp: AskUserQuestionFingerprint): number | null {
if (fp.nativeCall && fp.nativeCall.questions.length !== 1) return null;
const record = questionRecords(fp)[0];
const text = record ? `${record.header} ${record.question}` : '';
if (!/<gstack-qid:plan-devex-review-mode>/i.test(text)
&& !/how\s*deep\s*should\s*this\s*dx\s*review|which\s*(?:dx\s*)?review\s*mode/i.test(text)) return null;
const modes = fp.options.map(option => ({
index: option.index,
mode: /^(?:[A-C][.)])?DX(POLISH|EXPANSION|TRIAGE)(?:$|[^A-Z])/.exec(
option.label.split(/[│┌\r\n]/, 1)[0]!.replace(/\s+/g, '').toUpperCase(),
)?.[1],
}));
if (!['POLISH', 'EXPANSION', 'TRIAGE'].every(mode => modes.filter(option => option.mode === mode).length === 1)) return null;
return modes.find(option => option.mode === 'POLISH')!.index;
}
+51
View File
@@ -0,0 +1,51 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getProjectEvalDir } from './eval-store';
interface PlanCountSnapshot {
skillName: string;
observation: object;
raw: string;
visible: string;
cwd: string;
claudeConfigDir: string | null;
}
/** One owned directory per count attempt; periodic captures replace files atomically. */
export function createPlanCountSnapshotWriter(env: NodeJS.ProcessEnv = process.env):
(input: PlanCountSnapshot) => { artifactDir?: string; artifactError?: string } {
let artifactDir: string | undefined;
return (input) => {
if (!env.EVALS_RUN_ID) return {};
try {
if (!artifactDir) {
const segment = (text: string) => text.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 120) || 'run';
const root = path.resolve(env.GSTACK_EVAL_DIR || getProjectEvalDir(), 'pty-count', segment(env.EVALS_RUN_ID));
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
artifactDir = fs.mkdtempSync(path.join(root, `${segment(input.skillName)}-${Date.now()}-`));
}
const write = (name: string, content: string) => {
const target = path.join(artifactDir!, name);
fs.writeFileSync(`${target}.tmp`, content, { mode: 0o600 });
fs.renameSync(`${target}.tmp`, target);
};
write('terminal.raw.log', input.raw);
write('terminal.visible.log', input.visible);
write('observation.json', JSON.stringify({
...input.observation, artifactDir,
capture: { skill: input.skillName, runId: env.EVALS_RUN_ID, cwd: input.cwd,
claudeConfigDir: input.claudeConfigDir, at: new Date().toISOString() },
}, null, 2) + '\n');
return { artifactDir };
} catch (error) {
// Preserve any partial evidence and the original test outcome; make the
// write failure visible instead of claiming diagnostics were retained.
return { artifactDir, artifactError: String(error) };
}
};
}
/** Keep a single snapshot outside the temporary fixture that setup later removes. */
export function persistPlanCountSnapshot(input: PlanCountSnapshot, env: NodeJS.ProcessEnv = process.env) {
return createPlanCountSnapshotWriter(env)(input);
}
+18 -2
View File
@@ -10,7 +10,7 @@ import { getHermeticDirs } from './hermetic-env';
* bare slash command starts: a later message can remain queued behind the
* skill's first AskUserQuestion and leave it reviewing the live branch.
*/
export function createPlanCountFixture(prompt: string, opts: { nativeReviewOnly?: boolean } = {}): {
export function createPlanCountFixture(prompt: string, opts: { nativeReviewOnly?: boolean; files?: Record<string, string> } = {}): {
cwd: string;
env: Record<string, string>;
cleanup(): void;
@@ -26,6 +26,17 @@ export function createPlanCountFixture(prompt: string, opts: { nativeReviewOnly?
}
};
try {
const files = Object.entries(opts.files ?? {});
for (const [name] of files) {
const parts = name.split(/[\\/]/);
// The fixture owns its seed plan, instructions and Git metadata. Extra
// context must be an ordinary relative file, never an overwrite/escape.
if (path.isAbsolute(name) || path.win32.isAbsolute(name) || parts.some(part =>
part === '' || part === '.' || part === '..' || part.toLowerCase() === '.git') ||
/^(?:plan|claude)\.md$/i.test(name)) {
throw new Error(`Invalid plan-count fixture file: ${name}`);
}
}
if (opts.nativeReviewOnly) {
// Seeded-N count bands measure the main review's finding cadence.
// An outside review can legitimately add findings beyond that band;
@@ -63,6 +74,11 @@ export function createPlanCountFixture(prompt: string, opts: { nativeReviewOnly?
prompt,
'',
].join('\n'));
for (const [name, content] of files) {
const target = path.join(cwd, name);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, content);
}
const git = (args: string[]) => {
const result = spawnSync('git', args, {
@@ -75,7 +91,7 @@ export function createPlanCountFixture(prompt: string, opts: { nativeReviewOnly?
}
};
git(['init', '-b', 'main']);
git(['add', 'PLAN.md', 'CLAUDE.md']);
git(['add', '--', 'PLAN.md', 'CLAUDE.md', ...files.map(([name]) => name)]);
git(['-c', 'user.name=Plan Count Fixture', '-c', 'user.email=plan-count@example.test',
'-c', 'commit.gpgsign=false', 'commit', '--no-verify', '-m', 'Seed review plan']);
git(['update-ref', 'refs/remotes/origin/main', 'HEAD']);
+133
View File
@@ -0,0 +1,133 @@
/** Lossless, read-only question metadata from one isolated Claude fixture. */
import * as fs from 'node:fs';
import * as path from 'node:path';
export interface NativePlanQuestion {
header: string;
question: string;
options: Array<{ label: string; description?: string }>;
multiSelect?: boolean;
}
export interface NativePlanQuestionCall {
sessionId: string;
toolUseId: string;
questions: NativePlanQuestion[];
answered: boolean;
failed?: boolean;
failure?: string;
answers?: Record<string, string>;
unansweredQuestionIndices?: number[];
answeredAt?: string;
}
export interface PlanCountTranscript {
status: 'missing' | 'ready' | 'error';
calls: NativePlanQuestionCall[];
assistantMessages: Array<{ sessionId: string; text: string; timestamp: string }>;
error?: string;
}
/** A rejected/refused call needs an actual later answer, not unrelated progress. */
export function unresolvedPlanQuestionCalls(calls: NativePlanQuestionCall[]): NativePlanQuestionCall[] {
return calls.filter((call, index) => call.failed && !call.questions.every(q =>
calls.slice(index + 1).some(later => later.answered && later.answers?.[q.question])));
}
const MAX_BYTES = 32 * 1024 * 1024;
const MAX_FILES = 64;
const object = (value: unknown): value is Record<string, any> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const validTimestamp = (value: unknown): value is string =>
typeof value === 'string' && Number.isFinite(Date.parse(value));
function validQuestions(value: unknown): value is NativePlanQuestion[] {
return Array.isArray(value) && value.length > 0 && value.every(q =>
object(q) && typeof q.header === 'string' && typeof q.question === 'string' && q.question.trim() &&
Array.isArray(q.options) && q.options.length >= 2 && q.options.every((o: unknown) =>
object(o) && typeof o.label === 'string' && o.label.trim()));
}
/**
* Count callers consume each answered (sessionId, toolUseId) once, regardless
* of questions[].length. A batched tool call must never become N findings.
* Partial final lines remain pending; missing/foreign/sidechain records add
* no coverage. Traversal stays inside the owned config's projects directory.
*/
export function readPlanCountTranscript(configDir: string, cwd: string): PlanCountTranscript {
const calls = new Map<string, NativePlanQuestionCall>();
const assistantMessages: PlanCountTranscript['assistantMessages'] = [];
let matched = false;
let bytes = 0;
let files = 0;
const projects = path.join(configDir, 'projects');
try {
if (!fs.existsSync(projects)) return { status: 'missing', calls: [], assistantMessages: [] };
const dirs = fs.readdirSync(projects, { withFileTypes: true }).filter(d => d.isDirectory());
if (dirs.length > MAX_FILES) throw new Error('too many project directories');
for (const dir of dirs) {
const project = path.join(projects, dir.name);
for (const entry of fs.readdirSync(project, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
if (++files > MAX_FILES) throw new Error('too many transcript files');
const file = path.join(project, entry.name);
bytes += fs.statSync(file).size;
if (bytes > MAX_BYTES) throw new Error('transcript exceeds 32 MiB read limit');
const text = fs.readFileSync(file, 'utf8');
// Claude appends JSONL during rendering; an unfinished record is not
// evidence of a call or an answer until its newline has been written.
for (const line of text.slice(0, text.lastIndexOf('\n') + 1).split('\n')) {
if (!line.trim()) continue;
const record = JSON.parse(line);
if (!object(record) || record.cwd !== cwd || record.isSidechain !== false ||
typeof record.sessionId !== 'string' || entry.name !== `${record.sessionId}.jsonl` ||
!object(record.message) || !Array.isArray(record.message.content)) continue;
matched = true;
for (const block of record.message.content) {
if (!object(block)) continue;
if (record.message.role === 'assistant' && block.type === 'text' &&
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 === 'AskUserQuestion' &&
typeof block.id === 'string' && object(block.input) && validQuestions(block.input.questions)) {
const key = `${record.sessionId}:${block.id}`;
const prior = calls.get(key);
if (prior && JSON.stringify(prior.questions) !== JSON.stringify(block.input.questions)) {
throw new Error('conflicting question metadata for one tool call');
}
if (!prior) calls.set(key, { sessionId: record.sessionId, toolUseId: block.id,
questions: block.input.questions, answered: false });
} else if (record.message.role === 'user' && block.type === 'tool_result' &&
typeof block.tool_use_id === 'string') {
const call = calls.get(`${record.sessionId}:${block.tool_use_id}`);
const answers = record.toolUseResult?.answers;
const validAnswers = call && object(answers) ? Object.fromEntries(call.questions
.filter(q => typeof answers[q.question] === 'string' && answers[q.question].trim())
.map(q => [q.question, answers[q.question]])) : {};
if (call && block.is_error !== true && Object.keys(validAnswers).length > 0) {
// The CLI allows submitting a multi-question packet with
// unanswered tabs. This completes ONE call, not N questions.
call.answered = true;
call.failed = false;
delete call.failure;
call.answers = validAnswers;
call.unansweredQuestionIndices = call.questions.flatMap((q, i) => q.question in validAnswers ? [] : [i]);
call.answeredAt = validTimestamp(record.timestamp) ? record.timestamp : undefined;
} else if (call) {
if (call.answered) throw new Error('conflicting successful and failed results for one question call');
call.failed = true;
call.failure = block.is_error === true ? 'Native question tool returned is_error' : 'Native question returned no matching nonempty answers';
}
}
}
}
}
}
return { status: matched ? 'ready' : 'missing', calls: [...calls.values()], assistantMessages };
} catch (error) {
// A failed read cannot silently turn an incomplete transcript into a
// complete review. Keep the diagnostic explicit and return no coverage.
return { status: 'error', calls: [], assistantMessages: [], error: `Claude question transcript: ${String(error)}` };
}
}
+6 -6
View File
@@ -160,10 +160,10 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
// 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/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.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/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.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/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.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/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.ts', 'test/helpers/plan-count-fixture.ts', 'test/plan-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/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-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/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/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/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/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/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.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/claude-pty-runner.unit.test.ts', 'test/helpers/pty-trust-dialog.ts', 'test/pty-trust-dialog.test.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/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/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
+81
View File
@@ -0,0 +1,81 @@
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 { createPlanCountSnapshotWriter, persistPlanCountSnapshot } from './helpers/plan-count-artifacts';
const input = {
skillName: 'plan-design-review', observation: { outcome: 'timeout', reviewCount: 3 },
raw: '\x1b[2JSTART\r' + 'raw-frame\r'.repeat(2000) + '\x1b[31mEND',
visible: 'START\r' + 'visible-frame\r'.repeat(2000) + 'END',
cwd: '/temporary/count-fixture', claudeConfigDir: '/temporary/claude-config',
};
describe('plan-count diagnostic artifacts', () => {
test('refreshes one in-progress attempt through final outcome without keeping old copies', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-count-artifacts-'));
try {
const save = createPlanCountSnapshotWriter({ EVALS_RUN_ID: 'checkpoint', GSTACK_EVAL_DIR: root });
const progress = save({ ...input, observation: { state: 'in_progress', reviewCount: 1 } });
const recordPath = path.join(progress.artifactDir!, 'observation.json');
expect(JSON.parse(fs.readFileSync(recordPath, 'utf8'))).toMatchObject({ state: 'in_progress', reviewCount: 1 });
const final = save({ ...input, raw: input.raw + '\nlast question', observation: { outcome: 'completion_summary', reviewCount: 5 } });
expect(final.artifactDir).toBe(progress.artifactDir);
const record = JSON.parse(fs.readFileSync(recordPath, 'utf8'));
expect(record).toMatchObject({ outcome: 'completion_summary', reviewCount: 5 });
expect(record.state).toBeUndefined();
expect(fs.readdirSync(final.artifactDir!).sort()).toEqual(['observation.json', 'terminal.raw.log', 'terminal.visible.log']);
expect(fs.readFileSync(path.join(final.artifactDir!, 'terminal.raw.log'), 'utf8')).toBe(input.raw + '\nlast question');
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test('keeps complete output and distinct retry records in the configured eval directory', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-count-artifacts-'));
try {
const env = { EVALS_RUN_ID: 'same-run', GSTACK_EVAL_DIR: root };
const first = persistPlanCountSnapshot(input, env);
const second = persistPlanCountSnapshot({ ...input, raw: 'retry' }, env);
expect(first.artifactError).toBeUndefined();
expect(second.artifactError).toBeUndefined();
expect(first.artifactDir).not.toBe(second.artifactDir);
expect(path.relative(root, first.artifactDir!)).toStartWith(path.join('pty-count', 'same-run'));
expect(fs.readFileSync(path.join(first.artifactDir!, 'terminal.raw.log'), 'utf8')).toBe(input.raw);
expect(fs.readFileSync(path.join(first.artifactDir!, 'terminal.visible.log'), 'utf8')).toBe(input.visible);
const record = JSON.parse(fs.readFileSync(path.join(first.artifactDir!, 'observation.json'), 'utf8'));
expect(record).toMatchObject({ ...input.observation, artifactDir: first.artifactDir,
capture: { skill: input.skillName, cwd: input.cwd, claudeConfigDir: input.claudeConfigDir } });
expect(fs.readFileSync(path.join(second.artifactDir!, 'terminal.raw.log'), 'utf8')).toBe('retry');
if (process.platform !== 'win32') {
expect(fs.statSync(path.join(first.artifactDir!, 'terminal.raw.log')).mode & 0o777).toBe(0o600);
}
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test('does not persist ordinary free runs without EVALS_RUN_ID', () => {
const missing = path.join(os.tmpdir(), `unused-count-artifacts-${crypto.randomUUID()}`);
expect(persistPlanCountSnapshot(input, { GSTACK_EVAL_DIR: missing })).toEqual({});
expect(fs.existsSync(missing)).toBe(false);
});
test('keeps run and skill identifiers within the owned artifact directory', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-count-artifacts-'));
try {
const saved = persistPlanCountSnapshot({ ...input, skillName: '../../other' }, {
EVALS_RUN_ID: '../outside', GSTACK_EVAL_DIR: root,
});
expect(saved.artifactError).toBeUndefined();
expect(path.relative(root, saved.artifactDir!)).not.toStartWith('..');
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test('reports a diagnostic write failure without replacing the original observation', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-count-artifacts-'));
const file = path.join(root, 'file');
fs.writeFileSync(file, 'unchanged');
try {
const result = persistPlanCountSnapshot(input, { EVALS_RUN_ID: 'run', GSTACK_EVAL_DIR: file });
expect(result.artifactError).toBeDefined();
expect(input.observation).toEqual({ outcome: 'timeout', reviewCount: 3 });
expect(fs.readFileSync(file, 'utf8')).toBe('unchanged');
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
});
+175 -8
View File
@@ -83,6 +83,37 @@ describe('plan-count fixtures', () => {
expect(fs.existsSync(second.cwd)).toBe(false);
});
test('additional design context exists in the initial clean fixture commit', () => {
const design = '# Approved design system\nUse the existing components.\n' + PROMPT;
const fixture = createPlanCountFixture(PROMPT, { files: { 'DESIGN.md': design, 'docs/accepted behavior.md': 'Save is atomic.\n' } });
try {
const committed = spawnSync('git', ['show', 'HEAD:DESIGN.md'], { cwd: fixture.cwd, encoding: 'utf8', timeout: 10_000 });
expect(committed.status, committed.stderr).toBe(0);
expect(committed.stdout).toBe(design);
expect(fs.readFileSync(path.join(fixture.cwd, 'docs/accepted behavior.md'), 'utf8')).toBe('Save is atomic.\n');
expect(spawnSync('git', ['status', '--porcelain'], { cwd: fixture.cwd, encoding: 'utf8', timeout: 10_000 }).stdout).toBe('');
} finally {
fixture.cleanup();
}
expect(fs.existsSync(fixture.cwd)).toBe(false);
});
test('additional context cannot escape the fixture or replace its instructions and Git metadata', () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-count-outside-'));
const sentinel = path.join(outside, 'untouched.md');
fs.writeFileSync(sentinel, 'user-owned');
try {
for (const name of [sentinel, '../escape.md', 'nested/../escape.md', '..\\escape.md',
'C:\\escape.md', '/escape.md', '.git/config', 'nested/.git/config', 'PLAN.md', 'CLAUDE.md']) {
expect(() => createPlanCountFixture(PROMPT, { files: { [name]: 'overwrite' } }))
.toThrow('Invalid plan-count fixture file:');
}
expect(fs.readFileSync(sentinel, 'utf8')).toBe('user-owned');
} finally {
fs.rmSync(outside, { recursive: true, force: true });
}
});
test.skipIf(process.platform === 'win32')('cleans the fixture when the PTY executable cannot launch', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-count-launch-failure-'));
const fixtureTmp = path.join(dir, 'fixtures');
@@ -133,11 +164,19 @@ try {
fs.mkdirSync(hostState);
fs.writeFileSync(path.join(hostState, 'config.yaml'), hostConfig);
const cases = [
{ name: 'design', skillName: 'plan-design-review', prompt: PROMPT, mode: 'complete' },
{ name: 'design', skillName: 'plan-design-review', prompt: PROMPT, mode: 'complete', files: { 'DESIGN.md': '# Approved design\nKeep the existing layout.\n' } },
{ 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', 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' },
{ name: 'exited', skillName: 'plan-eng-review', prompt: '# Early-exit plan\nStill clean up.', mode: 'exit' },
{ name: 'skip-first', skillName: 'plan-devex-review', prompt: '# Native DX plan', mode: 'prerequisite', skipIndex: 1 },
{ name: 'skip-second', skillName: 'plan-devex-review', prompt: '# Native DX plan', mode: 'prerequisite', skipIndex: 2 },
{ name: 'caller-policy', skillName: 'plan-devex-review', prompt: '# Native DX plan', mode: 'prerequisite', skipIndex: 2, custom: true },
{ name: 'late-mode', skillName: 'plan-devex-review', prompt: '# Native DX plan', mode: 'late-mode' },
{ name: 'batched-mode', skillName: 'plan-devex-review', prompt: '# Native DX plan', mode: 'batched-mode' },
].map((item) => ({ ...item, record: path.join(dir, `${item.name}.jsonl`) }));
// The fake snapshots its environment BEFORE installing the first stdin
@@ -147,6 +186,26 @@ 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');
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,
}) + '\n');
};
native('assistant', [{ type: 'text', text: 'Fixture CLI started.' }]);
let callId = 0;
let nativeQuestions = [];
const ask = (questions) => {
nativeQuestions = questions;
native('assistant', [{ type: 'tool_use', id: 'question-' + (++callId), name: 'AskUserQuestion', input: { questions } }]);
};
const answer = () => native('user', [{ type: 'tool_result', tool_use_id: 'question-' + callId, content: 'Your questions have been answered.' }],
{ toolUseResult: { answers: Object.fromEntries(nativeQuestions.map(q => [q.question, q.options[0].label])) } });
const questionMetadata = (header, question, labels) => ({ header, question, options: labels.map(label => ({ label })) });
const modeQuestion = questionMetadata('Review mode', 'How deep should this DX review go? <gstack-qid:plan-devex-review-mode>', ['DX EXPANSION', 'DX POLISH', 'DX TRIAGE']);
const skillDir = path.join(process.env.CLAUDE_CONFIG_DIR, 'skills', process.env.FIXTURE_SKILL);
const planPath = path.join(process.cwd(), 'PLAN.md');
const contextPath = path.join(process.cwd(), 'CLAUDE.md');
@@ -164,6 +223,7 @@ record({
plan: fs.existsSync(planPath) ? fs.readFileSync(planPath, 'utf8') : null,
context: fs.existsSync(contextPath) ? fs.readFileSync(contextPath, 'utf8') : null,
inheritedDesign: fs.existsSync('DESIGN.md'), inheritedTodos: fs.existsSync('TODOS.md'),
design: fs.existsSync('DESIGN.md') ? fs.readFileSync('DESIGN.md', 'utf8') : null,
skill: fs.existsSync(path.join(skillDir, 'SKILL.md')) ? fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8') : null,
sections: fs.existsSync(path.join(skillDir, 'sections', 'review-sections.md'))
? fs.readFileSync(path.join(skillDir, 'sections', 'review-sections.md'), 'utf8') : null,
@@ -179,17 +239,54 @@ process.stdin.on('data', (data) => {
const input = data.toString('utf8');
record({ type: 'input', data: input });
if (!firstInput) {
if (['late-mode', 'batched-mode'].includes(process.env.FIXTURE_MODE)) {
selected += input.replace(/\r/g, '');
if (input.includes('\r')) {
// The real CLI can defer its native call record until after the UI
// answer. A known multi-question call must still block the override.
if (process.env.FIXTURE_MODE === 'late-mode') ask([modeQuestion]);
const label = modeQuestion.options[Number(selected) - 1]?.label;
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');
}
return;
}
if (['direct-finding', 'failed-call'].includes(process.env.FIXTURE_MODE) && input.includes('\r')) {
answer();
process.stdout.write('\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');
} else {
answer();
process.stdout.write('\nGSTACK REVIEW REPORT\n');
}
return;
}
if (process.env.FIXTURE_MODE === 'permission' && input.includes('\r')) {
process.stdout.write('\nGSTACK REVIEW REPORT\n');
return;
}
if (process.env.FIXTURE_MODE !== 'prerequisite') return;
if (question === 1 && input.includes('\r')) {
answer();
record({ type: 'generic-answer', input });
question = 2;
selected = '';
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');
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');
}
@@ -197,11 +294,35 @@ process.stdin.on('data', (data) => {
}
firstInput = false;
if (process.env.FIXTURE_MODE === 'exit') process.exit(7);
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');
return;
}
if (['direct-finding', 'batched-finding', 'failed-call'].includes(process.env.FIXTURE_MODE)) {
question = 1;
if (process.env.FIXTURE_MODE === 'failed-call') {
ask([questionMetadata('Missing answer', 'Should the save retry be idempotent?', ['Yes', 'No'])]);
native('user', [{ type: 'tool_result', tool_use_id: 'question-' + callId, is_error: true, content: 'Question rejected' }]);
}
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');
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');
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;
}
// 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);
@@ -216,9 +337,11 @@ process.stdin.resume();
fs.chmodSync(fakePath, 0o755);
const runnerUrl = pathToFileURL(path.join(ROOT, 'test/helpers/claude-pty-runner.ts')).href;
const hermeticUrl = pathToFileURL(path.join(ROOT, 'test/helpers/hermetic-env.ts')).href;
const devexUrl = pathToFileURL(path.join(ROOT, 'test/helpers/devex-count-fixture.ts')).href;
fs.writeFileSync(workerPath, `
import { runPlanSkillCounting } from ${JSON.stringify(runnerUrl)};
import { runPlanSkillCounting, designFirstReviewAUQ } from ${JSON.stringify(runnerUrl)};
import { getHermeticDirs } from ${JSON.stringify(hermeticUrl)};
import { devexReviewModePick } from ${JSON.stringify(devexUrl)};
import * as fs from 'node:fs';
import * as path from 'node:path';
const shared = getHermeticDirs().gstackHome;
@@ -232,12 +355,18 @@ const results = await Promise.all(cases.map(async (item) => ({
skillName: item.skillName,
slashCommand: '/' + item.skillName,
followUpPrompt: item.prompt,
fixtureFiles: item.files,
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
: item.custom ? fp => fp.promptSnippet.includes('routing-proof-after-240') ? 1 : null : undefined,
reviewCountCeiling: 8,
timeoutMs: 15000,
firstAUQPick: () => 2,
firstAUQPick: () => ['late-mode', 'batched-mode'].includes(item.mode) ? 1 : 2,
env: {
FIXTURE_RECORD: item.record, FIXTURE_SKILL: item.skillName, FIXTURE_MODE: item.mode,
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)},
},
@@ -252,7 +381,8 @@ await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ results, onboard
const startedAt = Date.now();
const child = Bun.spawn([process.execPath, workerPath], {
cwd: ROOT,
env: { ...process.env, BROWSE_TERMINAL_BINARY: fakePath, EVALS_HERMETIC: '1', GSTACK_HOME: hostState, GSTACK_STATE_ROOT: hostState },
env: { ...process.env, BROWSE_TERMINAL_BINARY: fakePath, EVALS_HERMETIC: '1', GSTACK_HOME: hostState, GSTACK_STATE_ROOT: hostState,
EVALS_RUN_ID: 'fixture-integration', GSTACK_EVAL_DIR: path.join(dir, 'eval-artifacts') },
stdout: 'pipe',
stderr: 'pipe',
});
@@ -288,17 +418,43 @@ await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ results, onboard
expect(startup.context).toContain('PLAN.md');
expect(startup.context).toContain(item.prompt);
expect(startup.argv[startup.argv.indexOf('--permission-mode') + 1]).toBe('plan');
expect(startup.inheritedDesign).toBe(false);
expect(startup.inheritedDesign).toBe(Boolean(item.files?.['DESIGN.md']));
expect(startup.design).toBe(item.files?.['DESIGN.md'] ?? null);
expect(startup.inheritedTodos).toBe(false);
expect(startup.skill).toContain(`name: ${item.skillName}`);
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' ? `2\r${item.skipIndex}\r` : ''));
.toBe(`/${item.skillName}\r` + (item.mode === 'prerequisite' ? `${item.custom ? 1 : 2}\r${item.skipIndex}\r`
: item.mode === 'batched-finding' ? '2\r1\r' : item.mode === 'batched-mode' ? '1\r'
: ['direct-finding', 'failed-call', 'permission', 'late-mode'].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();
const result = results.find((result) => result.name === item.name);
expect(result.observation.outcome).toBe(item.mode === 'exit' ? 'exited' : 'completion_summary');
expect(result.observation.outcome, `${item.name}: ${JSON.stringify(result.observation)}`).toBe(item.mode === 'exit' ? 'exited'
: ['missing-transcript', 'failed-call'].includes(item.mode) ? 'transcript_unavailable' : 'completion_summary');
const artifacts = result.observation.artifactDir;
expect(result.observation.artifactError).toBeUndefined();
expect(fs.existsSync(artifacts)).toBe(true); // Survives the temporary fixture's cleanup.
const captured = JSON.parse(fs.readFileSync(path.join(artifacts, 'observation.json'), 'utf8'));
expect(captured.outcome).toBe(result.observation.outcome);
expect(captured.capture.cwd).toBe(startup.cwd);
expect(fs.readFileSync(path.join(artifacts, 'terminal.raw.log'), 'utf8')).toContain(item.mode === 'exit' ? 'STARTUP_DIAGNOSTIC' : 'GSTACK REVIEW REPORT');
expect(fs.readFileSync(path.join(artifacts, 'terminal.visible.log'), 'utf8')).toContain(item.mode === 'exit' ? 'STARTUP_DIAGNOSTIC' : 'GSTACK REVIEW REPORT');
if (['direct-finding', 'batched-finding', 'failed-call'].includes(item.mode)) {
expect(result.observation.reviewCount).toBe(1);
expect(result.observation.step0Count).toBe(0);
expect(result.observation.fingerprints).toHaveLength(1);
expect(result.observation.fingerprints[0].nativeCall.questions).toHaveLength(item.mode === 'batched-finding' ? 2 : 1);
}
if (item.mode === 'failed-call') {
expect(result.observation.transcript.calls[0].failure).toContain('is_error');
expect(result.observation.transcript.calls[0].answered).toBe(false);
}
if (['permission', 'missing-transcript'].includes(item.mode)) {
expect(result.observation.reviewCount).toBe(0);
expect(result.observation.step0Count).toBe(0);
}
if (item.mode === 'exit') {
expect(result.observation.evidence).toContain('exitCode=7');
expect(result.observation.evidence).toContain('STARTUP_DIAGNOSTIC fixture CLI booted');
@@ -307,8 +463,19 @@ await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ results, onboard
if (item.mode === 'complete') expect(events.at(-1).type).toBe('closed');
if (item.mode === 'prerequisite') {
expect(events.find(event => event.type === 'prerequisite-answer').selected).toBe(String(item.skipIndex));
expect(result.observation.step0Count).toBe(2);
expect(result.observation.step0Count).toBe(item.custom ? 1 : 2);
expect(result.observation.reviewCount).toBe(item.custom ? 1 : 0);
}
if (['late-mode', 'batched-mode'].includes(item.mode)) {
const expectedLabel = item.mode === 'late-mode' ? 'DX POLISH' : 'DX EXPANSION';
expect(events.find(event => event.type === 'mode-answer').label).toBe(expectedLabel);
expect(result.observation.step0Count).toBe(1);
expect(result.observation.reviewCount).toBe(0);
expect(result.observation.transcript.calls).toHaveLength(1);
const call = result.observation.transcript.calls[0];
expect(call.answers[call.questions[0].question]).toBe(expectedLabel);
expect(call.questions).toHaveLength(item.mode === 'late-mode' ? 1 : 2);
expect(call.unansweredQuestionIndices).toEqual(item.mode === 'late-mode' ? [] : [1]);
}
cwds.add(startup.cwd);
stateRoots.add(startup.stateRoot);
+220
View File
@@ -0,0 +1,220 @@
import { afterEach, 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 { readPlanCountTranscript, unresolvedPlanQuestionCalls } from './helpers/plan-count-transcript';
import { nativePlanCallFingerprint, planCountQuestionPhase, engStep0Boundary, ceoStep0Boundary, ceoFirstReviewAUQ } from './helpers/claude-pty-runner';
const dirs: string[] = [];
afterEach(() => { for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
function fixture() {
const config = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-count-transcript-'));
dirs.push(config);
const cwd = path.join(config, 'fixture');
const project = path.join(config, 'projects', 'fixture');
fs.mkdirSync(project, { recursive: true });
const sessionId = 'session-a';
const file = path.join(project, `${sessionId}.jsonl`);
const record = (role: string, content: object[], extra = {}) => ({
cwd, sessionId, isSidechain: false, timestamp: '2026-09-08T15:31:13.607Z',
message: { role, content }, ...extra,
});
const question = (text = 'D1 — Cross-project learnings scope <gstack-qid:learnings-cross-project>') => ({
header: 'Learnings', question: text,
options: [{ label: 'Enable cross-project learnings (Recommended)' }, { label: 'Keep project-scoped' }],
});
const ask = (id: string, questions = [question()], extra = {}) => record('assistant', [
{ type: 'tool_use', id, name: 'AskUserQuestion', input: { questions } },
], extra);
const answer = (id: string, questions = [question()], extra = {}) => record('user', [
{ type: 'tool_result', tool_use_id: id, content: 'Your questions have been answered.' },
], { toolUseResult: { answers: Object.fromEntries(questions.map(q => [q.question, q.options[0].label])) }, ...extra });
const append = (...records: object[]) => fs.appendFileSync(file, records.map(r => JSON.stringify(r) + '\n').join(''));
const read = () => readPlanCountTranscript(config, cwd);
return { config, cwd, project, file, question, ask, answer, append, read, record };
}
describe('native plan-count transcripts', () => {
test('the actual partially answered CEO setup packet counts once without selecting its unanswered mode', () => {
const f = fixture();
// Native question/header/label metadata from targeted-a's paired CEO
// call. The successful tool_result answered only routing and approach.
const packet = [{"header":"Routing","question":"gstack works best when your project's CLAUDE.md includes skill routing rules — should I add them? <gstack-qid:routing-injection>","options":[{"label":"Add routing rules (Recommended)"},{"label":"Skip for now"}]},{"header":"Prerequisites","question":"No design doc found for this branch. Run /office-hours to capture structured problem context first, or proceed directly to the plan review? <gstack-qid:plan-ceo-review-office-hours>","options":[{"label":"Skip — review the plan directly (Recommended)"},{"label":"Run /office-hours first"}]},{"header":"Test Scope","question":"Which implementation approach should the tests follow? This shapes the review scope. <gstack-qid:plan-ceo-review-approach>\n\nD1 — Approach selection for processPayment() test coverage\nProject: Payment Processing — adding missing unit tests\nELI10: The plan calls for exactly 2 tests. Adding a few more for the most common real-world Stripe failures (card declined, rate limit) costs ~10 extra minutes with CC but closes the gaps users actually hit. The question is whether to stay at 2 or expand to ~5-6 tests.\nStakes if we pick wrong: Choosing minimal leaves 402 (card declined) untested — the most common production failure. Choosing full adds ~5 min of CC work.\nRecommendation: B (Core Stripe paths) because 402 card declined is the highest-volume real-world failure and CC compresses the extra work to near-zero.\nCompleteness: A=7/10, B=9/10","options":[{"label":"Minimal — 2 tests as planned (7/10)"},{"label":"Core paths — ~5 tests (9/10) (Recommended)"}]},{"header":"Review Mode","question":"D2 — Review mode for this plan?\nProject: Payment Processing test coverage — adding missing unit tests for processPayment()\nELI10: The plan adds 2 missing unit tests. HOLD SCOPE means: take the scope as given, review it with maximum rigor — catch every ambiguity, failure mode, edge case, observability gap. SELECTIVE EXPANSION means: do all that AND surface cherry-pick expansions (additional test scenarios, receipt schema validation, etc.) one at a time for your approval.\nStakes if we pick wrong: HOLD keeps the review tight and fast. SELECTIVE surfaces more opportunities but adds round-trips.\nRecommendation: HOLD SCOPE — this is a focused gap-fill, and rigor matters more than ambition here.\nNote: options differ in kind, not coverage — no completeness score. <gstack-qid:plan-ceo-review-mode>","options":[{"label":"HOLD SCOPE — maximum rigor (Recommended)"},{"label":"SELECTIVE EXPANSION — rigor + cherry-picks"}]}];
f.append(f.ask('actual-packet', packet), f.answer('actual-packet', [packet[0], packet[2]]));
const [call] = f.read().calls;
expect(call.answered).toBe(true);
expect(call.failed).toBe(false);
expect(call.unansweredQuestionIndices).toEqual([1, 3]);
expect(f.read().calls.filter(c => c.answered)).toHaveLength(1);
const setup = nativePlanCallFingerprint(call, 0, true);
expect(setup.options.some(o => /HOLD SCOPE/.test(o.label))).toBe(false);
expect(ceoStep0Boundary(setup)).toBe(false);
expect(ceoFirstReviewAUQ(setup)).toBe(false);
const finding = [{"header":"Receipt Schema","question":"D3 — The plan says 'assert correct receipt is generated' but doesn't define what a correct receipt looks like. Without a schema, the test will pass even if the receipt is missing critical fields.\n\nELI10: Right now the test could assert `receipt != nil` and call it a day. That test passes even if the receipt has the wrong amount or no charge ID. We need to specify what fields a correct receipt must have.\n\nStakes if we pick wrong: Tests pass in CI but fail to catch a real receipt bug — e.g., wrong charge_id linked to wrong customer.\n\nRecommendation: A — specify the receipt schema in the plan now; costs ~5 min, prevents a class of silent bugs.\nCompleteness: A=9/10, B=7/10, C=3/10 <gstack-qid:plan-ceo-receipt-schema>","options":[{"label":"Specify schema in plan (Recommended)"},{"label":"Specify during implementation"},{"label":"Skip — too much detail for a plan"}]}];
f.append(f.ask('receipt-schema', finding), f.answer('receipt-schema', finding));
const review = nativePlanCallFingerprint(f.read().calls[1], 1, true);
expect(planCountQuestionPhase(review, false, ceoStep0Boundary, ceoFirstReviewAUQ))
.toEqual({ preReview: false, reviewStarted: true });
// A refused/empty packet must not receive the same completion credit.
f.append(f.ask('empty-packet', packet), f.answer('empty-packet', []));
const empty = f.read().calls[2];
expect(empty.answered).toBe(false);
expect(empty.failure).toContain('no matching nonempty answers');
});
test('CEO first-finding fallback requires native finding identity and rejects setup decisions', () => {
const f = fixture();
for (const [id, text] of [
['setup', 'D1 — Missing context: choose a scope <gstack-qid:plan-ceo-review-scope>'],
['mode', 'D1 — Missing context: choose a mode <gstack-qid:plan-ceo-review-mode>'],
['unscoped', 'D1 — Missing receipt schema'],
['arbitrary', 'D1 — Pick an option <gstack-qid:plan-ceo-review-choice>'],
['next', 'D1 — Missing engineering review: what next? <gstack-qid:plan-ceo-next-steps>'],
]) {
const questions = [f.question(text)];
f.append(f.ask(id, questions), f.answer(id, questions));
}
for (const call of f.read().calls) expect(ceoFirstReviewAUQ(nativePlanCallFingerprint(call, 0, true))).toBe(false);
});
test('requires a matching successful answer and preserves full native question metadata', () => {
const f = fixture();
const questions = [f.question('D1 — Cross-project learnings scope\n' + 'full context '.repeat(40) + '<gstack-qid:learnings-cross-project>')];
f.append(f.ask('one', questions));
expect(f.read().calls[0].answered).toBe(false);
f.append(f.answer('one', questions));
const before = fs.readFileSync(f.file, 'utf8');
const observed = f.read();
expect(observed.status).toBe('ready');
expect(observed.calls).toHaveLength(1);
expect(observed.calls[0].answered).toBe(true);
expect(observed.calls[0].questions).toEqual(questions);
const fp = nativePlanCallFingerprint(observed.calls[0], 1, true);
expect(fp.promptSnippet.length).toBeGreaterThan(240);
expect(fp.promptSnippet).toContain('<gstack-qid:learnings-cross-project>');
expect(planCountQuestionPhase(fp, false, engStep0Boundary)).toEqual({ preReview: true, reviewStarted: true });
expect(fs.readFileSync(f.file, 'utf8')).toBe(before);
});
test('one completed batched AskUserQuestion call remains one count even with unanswered questions', () => {
const f = fixture();
const questions = [f.question('D1 — Retry behavior?'), f.question('D2 — Error handling?')];
f.append(f.ask('batch', questions), f.answer('batch', [questions[0]]));
expect(f.read().calls.filter(c => c.answered)).toHaveLength(1);
expect(f.read().calls[0].unansweredQuestionIndices).toEqual([1]);
f.append(f.answer('batch', questions));
expect(f.read().calls.filter(c => c.answered)).toHaveLength(1);
expect(f.read().calls[0].questions).toHaveLength(2);
});
test('partial JSONL appends add no call or completion until the final newline', () => {
const f = fixture();
const call = JSON.stringify(f.ask('one'));
fs.writeFileSync(f.file, call.slice(0, 80));
expect(f.read()).toEqual({ status: 'missing', calls: [], assistantMessages: [] });
fs.appendFileSync(f.file, call.slice(80));
expect(f.read().calls).toEqual([]);
fs.appendFileSync(f.file, '\n');
expect(f.read().calls[0].answered).toBe(false);
const result = JSON.stringify(f.answer('one'));
fs.appendFileSync(f.file, result.slice(0, 80));
expect(f.read().calls[0].answered).toBe(false);
fs.appendFileSync(f.file, result.slice(80) + '\n');
expect(f.read().calls[0].answered).toBe(true);
});
test('repeated records and polls dedupe by tool ID, while identical questions in distinct calls remain distinct', () => {
const f = fixture();
f.append(f.ask('one'), f.ask('one'), f.answer('one'), f.answer('one'), f.ask('two'), f.answer('two'));
const first = f.read();
expect(first.calls.filter(c => c.answered)).toHaveLength(2);
expect(f.read()).toEqual(first);
expect(first.calls.map(c => nativePlanCallFingerprint(c, 0, false).signature)).toEqual(['session-a:one', 'session-a:two']);
});
test('wrong cwd, sidechain, session, tool ID, and missing scope cannot provide an answer', () => {
const f = fixture();
f.append(f.ask('one'));
for (const extra of [{ cwd: '/other' }, { isSidechain: true }, { sessionId: 'other' }, { cwd: undefined }, { isSidechain: undefined }]) {
f.append(f.answer('one', undefined, extra), f.ask('foreign', undefined, extra));
}
f.append(f.answer('wrong-id'));
expect(f.read().calls).toHaveLength(1);
expect(f.read().calls[0].answered).toBe(false);
fs.writeFileSync(path.join(f.project, 'other.jsonl'), JSON.stringify(f.answer('one', undefined, { sessionId: 'other' })) + '\n');
expect(f.read().calls[0].answered).toBe(false);
});
test('native permissions, schema failures, errors and refusals do not become completed questions', () => {
const f = fixture();
f.append(f.record('assistant', [{ type: 'tool_use', id: 'write', name: 'Write', input: { file_path: 'PLAN.md' } }]));
f.append(f.answer('write'));
f.append(f.record('assistant', [{ type: 'tool_use', id: 'bad', name: 'AskUserQuestion', input: { questions: [] } }]));
f.append(f.answer('bad'));
f.append(f.ask('error'));
f.append(f.record('user', [{ type: 'tool_result', tool_use_id: 'error', is_error: true, content: 'Rejected' }],
{ toolUseResult: { answers: { [f.question().question]: 'Yes' } } }));
f.append(f.ask('refusal'), f.answer('refusal', undefined, { toolUseResult: { answers: {} } }));
expect(f.read().calls).toHaveLength(2);
expect(f.read().calls.filter(c => c.answered)).toHaveLength(0);
expect(f.read().calls.every(c => c.failed)).toBe(true);
});
test('unrelated answered questions cannot conceal a refusal, but an actual retry can resolve it', () => {
const f = fixture();
const missing = [f.question('Should retries preserve idempotency?')];
const unrelated = [f.question('Should errors be logged?')];
f.append(f.ask('refusal', missing), f.answer('refusal', [], { toolUseResult: { answers: {} } }));
f.append(f.ask('unrelated', unrelated), f.answer('unrelated', unrelated));
expect(unresolvedPlanQuestionCalls(f.read().calls).map(c => c.toolUseId)).toEqual(['refusal']);
f.append(f.ask('retry', missing), f.answer('retry', missing));
expect(unresolvedPlanQuestionCalls(f.read().calls)).toEqual([]);
expect(f.read().calls.filter(c => c.answered)).toHaveLength(2);
});
test('missing transcripts and files from another fixture produce no positive coverage', () => {
const f = fixture();
expect(f.read()).toEqual({ status: 'missing', calls: [], assistantMessages: [] });
f.append(f.ask('other', undefined, { cwd: '/different-fixture' }), f.answer('other', undefined, { cwd: '/different-fixture' }));
expect(f.read()).toEqual({ status: 'missing', calls: [], assistantMessages: [] });
expect(readPlanCountTranscript(path.join(f.config, 'absent'), f.cwd)).toEqual({ status: 'missing', calls: [], assistantMessages: [] });
});
test('invalid completed JSON and excessive bytes fail explicitly without retaining partial coverage', () => {
const f = fixture();
f.append(f.ask('one'), f.answer('one'));
fs.appendFileSync(f.file, 'not JSON\n');
expect(f.read().status).toBe('error');
expect(f.read().calls).toEqual([]);
fs.writeFileSync(f.file, '');
fs.truncateSync(f.file, 32 * 1024 * 1024 + 1);
expect(f.read().error).toContain('32 MiB');
expect(f.read().calls).toEqual([]);
});
test('assistant posture evidence is scoped, timestamped text rather than tool results or partial records', () => {
const f = fixture();
f.append(f.record('assistant', [{ type: 'text', text: 'Earlier assistant posture.' }], { timestamp: '2026-09-08T15:31:10.000Z' }));
f.append(f.ask('mode'), f.answer('mode'));
f.append(f.record('assistant', [{ type: 'text', text: 'Later assistant posture.' }], { timestamp: '2026-09-08T15:31:20.000Z' }));
for (const extra of [{ cwd: '/other' }, { isSidechain: true }, { sessionId: 'other' }, { timestamp: 'invalid' }, { timestamp: undefined }]) {
f.append(f.record('assistant', [{ type: 'text', text: 'Not evidence.' }], extra));
}
f.append(f.record('user', [{ type: 'text', text: 'User prose is not assistant posture.' }]));
f.append(f.record('user', [{ type: 'tool_result', tool_use_id: 'other', content: 'Tool result posture is not evidence.' }]));
const observed = f.read();
expect(observed.assistantMessages).toEqual([
{ sessionId: 'session-a', text: 'Earlier assistant posture.', timestamp: '2026-09-08T15:31:10.000Z' },
{ sessionId: 'session-a', text: 'Later assistant posture.', timestamp: '2026-09-08T15:31:20.000Z' },
]);
expect(observed.calls[0].answeredAt).toBe('2026-09-08T15:31:13.607Z');
fs.appendFileSync(f.file, JSON.stringify(f.record('assistant', [{ type: 'text', text: 'Still writing.' }])));
expect(f.read().assistantMessages).toEqual(observed.assistantMessages);
fs.appendFileSync(f.file, '\ninvalid JSON\n');
expect(f.read().assistantMessages).toEqual([]);
expect(f.read().calls).toEqual([]);
});
});
+22 -3
View File
@@ -23,6 +23,7 @@ import * as path from 'node:path';
import {
runPlanSkillCounting,
ceoStep0Boundary,
ceoFirstReviewAUQ,
assertReviewReportAtBottom,
type AskUserQuestionFingerprint,
} from './helpers/claude-pty-runner';
@@ -65,8 +66,8 @@ const FLOOR_PAIRED = 2;
const CEILING_PAIRED = 4;
// Keep the five seeded defects distinct from already-satisfied surrounding
// contracts. The live control correctly found extra signature, deduplication,
// concurrency, and naming gaps when those baseline facts were unspecified.
// contracts. Live controls correctly found extra ingress, missing-user,
// observability, and rollout gaps when those baseline facts were unspecified.
const planCeo5Findings = (planPath: string) => [
`Please review this plan thoroughly. As you go, write your plan-mode plan to ${planPath} (use Edit/Write to that exact path).`,
'',
@@ -79,6 +80,12 @@ const planCeo5Findings = (planPath: string) => [
'and an existing per-user transaction lock serializes payment updates.',
'The new handler runs inside those unchanged guards; this plan does not',
'replace signature verification, event deduplication, or update locking.',
'The existing lookup-result guard acknowledges unknown/deleted users with',
'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.',
'The deployment already has a handler feature flag and a documented, tested',
'rollback to the prior handler; this change uses that existing rollout path.',
'',
'## Architecture',
"We're adding a new `StripePaymentWebhookHandler` class that will handle Stripe webhooks.",
@@ -106,10 +113,20 @@ const planCeo2PairedFindings = (planPath: string) => [
'',
'# Plan: Payment Processing — Test Coverage',
'',
'## Existing coverage and test infrastructure retained',
'This changes unit tests only; processPayment() production behavior stays as-is.',
'The Stripe adapter suite already covers network timeouts, card declines (402),',
'rate limits (429), and recovery when an initial 502 is followed by a successful',
'charge. Receipt-builder failure behavior has its own passing regression tests.',
'The payment test factory explicitly configures max_retries=1 and exposes the',
'Stripe mock call history. Its injected virtual sleeper records backoff without',
'real delays, so an exhausted 502 operation makes exactly two charge attempts.',
'These existing helpers and regression suites remain in use for this change.',
'',
'## Tests',
'We need test coverage for `processPayment()`. Specifically:',
'1. The happy path (successful Stripe charge — assert correct receipt is generated).',
'2. The error/timeout path (Stripe returns 502 — assert retry-with-backoff fires once, then fails clean).',
'2. The 502 error path (Stripe returns 502 — assert retry-with-backoff fires once, then fails clean).',
'',
'Currently neither has a unit test. These are deliberately separate concerns:',
'the success path is correctness, the failure path is graceful degradation.',
@@ -131,6 +148,7 @@ describeE2E('/plan-ceo-review per-finding AskUserQuestion count (periodic)', ()
slashCommand: '/plan-ceo-review',
followUpPrompt: planCeo5Findings(planPath),
isLastStep0AUQ: ceoStep0Boundary,
isFirstReviewAUQ: ceoFirstReviewAUQ,
reviewCountCeiling: CEILING_DISTINCT + 1, // hard cap above assertion ceiling
firstAUQPick: pickSkipInterview, // bypass scope-selection, route to review
timeoutMs: 1_500_000, // 25 min
@@ -214,6 +232,7 @@ describeE2E('/plan-ceo-review per-finding AskUserQuestion count (periodic)', ()
slashCommand: '/plan-ceo-review',
followUpPrompt: planCeo2PairedFindings(planPath),
isLastStep0AUQ: ceoStep0Boundary,
isFirstReviewAUQ: ceoFirstReviewAUQ,
reviewCountCeiling: CEILING_PAIRED + 1,
timeoutMs: 1_500_000,
env: { QUESTION_TUNING: 'false', EXPLAIN_LEVEL: 'default' },
@@ -16,6 +16,7 @@ import * as path from 'node:path';
import {
runPlanSkillCounting,
designStep0Boundary,
designFirstReviewAUQ,
assertReviewReportAtBottom,
} from './helpers/claude-pty-runner';
@@ -25,11 +26,79 @@ const N = 5;
const FLOOR = N - 1;
const CEILING = N + 2;
// A known surrounding design prevents missing layout/journey/state contracts
// from becoming legitimate extra findings unrelated to the five seeded gaps.
const designSystem = [
'# Approved account-settings design system',
'',
'Reuse the existing single-column settings shell: persistent app navigation,',
'page title and description, action group, then Profile and Notifications fieldsets.',
'The form has a 640px maximum width. Reuse existing Button, Field, InlineStatus,',
'ErrorSummary and ConfirmationDialog components; no new component family is needed.',
'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.',
'',
'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',
'existing confirmation dialog, whose default is Cancel. All targets are at least 44px.',
'Spacing uses an 8px base: sections 32px, field groups 24px, label-to-input 8px.',
'Typography has two roles: 16px body/form labels/helper text, 20px section headings.',
'Use error.text #991b1b on error.surface #fef2f2 with an icon and explicit text.',
'All text must meet WCAG AA contrast; never communicate status through color alone.',
'Focus-visible is the existing 2px solid #1d4ed8 outline, offset 2px on white;',
'its measured contrast exceeds 3:1. Reuse it on all controls and dialog actions.',
'',
'The established pending-action pattern is an inline spinner beside “Saving…”',
'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.',
'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”.',
'Validation errors appear beside fields and in a linked error summary; focus the',
'first invalid field. Network errors preserve edits and show Retry in that status area.',
].join('\n');
const planDesign5Findings = (planPath: string) => [
`Please review this plan thoroughly. As you go, write your plan-mode plan to ${planPath} (use Edit/Write to that exact path).`,
// The skill explicitly supports this opt-out. The cadence test measures
// review findings; generated-variant preference questions have separate evals.
'Text-only review; skip mockups. Review all seven design dimensions.',
'',
'# Plan: Settings Page UI redesign',
'',
'## Existing product and accepted behavior',
'This updates an existing account-settings form using the checked-in DESIGN.md.',
'The shell and components already exist. Profile and Notifications are the only',
'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.',
'',
'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.',
'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',
'not exposed. Field validation, network failure, and successful-save feedback',
'use the exact existing DESIGN.md patterns. Preserve unsaved values after errors.',
'Disable repeat Save submissions while pending. Reset restores saved values only',
'after confirmation; Cancel confirms discarding dirty edits before returning to',
'the previous page; Export downloads the current saved preferences as JSON.',
'',
'Responsive behavior: above 640px keep the header action group in one row; at',
'640px and below, place full-width Save first and the three secondary actions',
'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.',
'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.',
'',
'## Planned implementation gaps',
'The proposed form still has the following inconsistencies with that design:',
'',
'## Visual Hierarchy',
'The "Save" button is rendered with the same size, weight, and color as',
'three other buttons in the page header (Reset, Cancel, Export). Nothing',
@@ -68,6 +137,8 @@ describeE2E('/plan-design-review per-finding AskUserQuestion count (periodic)',
slashCommand: '/plan-design-review',
followUpPrompt: planDesign5Findings(planPath),
isLastStep0AUQ: designStep0Boundary,
isFirstReviewAUQ: designFirstReviewAUQ,
fixtureFiles: { 'DESIGN.md': designSystem },
reviewCountCeiling: CEILING + 1,
timeoutMs: 1_500_000,
env: { QUESTION_TUNING: 'false', EXPLAIN_LEVEL: 'default' },
+14 -30
View File
@@ -2,7 +2,9 @@
* /plan-devex-review per-finding AskUserQuestion count (periodic, paid, real-PTY).
*
* Same shape as skill-e2e-plan-ceo-finding-count: drives /plan-devex-review
* against a 5-finding seeded plan and asserts review-phase AUQ count ∈ [N-1, N+2].
* against a 5-finding seeded plan and asserts substantive issue AUQ count ∈ [N-1, N+2].
* DevEx deliberately resolves friction during Step 0, before scoring passes;
* count those decisions too, while excluding administrative confirmations.
* Plus D19: review report at bottom of produced plan file.
*
* Tier: periodic (~25 min, ~$5/run). Sequential by default per plan §D15.
@@ -18,6 +20,12 @@ import {
devexStep0Boundary,
assertReviewReportAtBottom,
} from './helpers/claude-pty-runner';
import {
DEVEX_COUNT_FILES,
planDevexCountFixture,
isDevexReviewIssue,
devexReviewModePick,
} from './helpers/devex-count-fixture';
const describeE2E = describeE2ETier('periodic');
@@ -25,36 +33,9 @@ const N = 5;
const FLOOR = N - 1;
const CEILING = N + 2;
const planDevex5Findings = (planPath: string) => [
`Please review this plan thoroughly. As you go, write your plan-mode plan to ${planPath} (use Edit/Write to that exact path).`,
'',
'# Plan: Public SDK Beta Launch',
'',
'## Persona',
"The plan doesn't specify which developer persona is the target — we're",
"shipping for \"everyone,\" which means we tune for nobody.",
'',
'## TTHW (time to hello world)',
'Time-to-hello-world is not measured. No benchmark data referenced. We',
"don't know if first-run takes 5 minutes or 50.",
'',
'## Friction Point',
'First-run currently requires a 5-minute mandatory CI step before the',
'developer can run their first eval. There is no way to skip it.',
'',
'## Magical Moment',
'Getting-started flow has no delight beat. Pure documentation, no',
'interactive demo, no "ah-ha" moment that makes the developer trust us.',
'',
'## Competitive Blind Spot',
"The plan doesn't reference how peer SDKs (LangChain, Semantic Kernel,",
'OpenAI) handle this DX surface. We may be reinventing worse versions',
'of solved problems.',
].join('\n');
describeE2E('/plan-devex-review per-finding AskUserQuestion count (periodic)', () => {
test(
`5-finding plan emits ${FLOOR}-${CEILING} review-phase AskUserQuestions`,
`5-finding plan emits ${FLOOR}-${CEILING} substantive issue AskUserQuestions`,
async () => {
// Per-run artifact dir: a hardcoded shared /tmp path collides under
// --retry, EVALS_JOBS>1, or concurrent worktrees (a sibling's finally-
@@ -66,8 +47,11 @@ describeE2E('/plan-devex-review per-finding AskUserQuestion count (periodic)', (
const obs = await runPlanSkillCounting({
skillName: 'plan-devex-review',
slashCommand: '/plan-devex-review',
followUpPrompt: planDevex5Findings(planPath),
followUpPrompt: planDevexCountFixture(planPath),
fixtureFiles: DEVEX_COUNT_FILES,
isLastStep0AUQ: devexStep0Boundary,
isReviewAUQ: isDevexReviewIssue,
pickAUQ: devexReviewModePick,
reviewCountCeiling: CEILING + 1,
timeoutMs: 1_500_000,
env: { QUESTION_TUNING: 'false', EXPLAIN_LEVEL: 'default' },
@@ -25,11 +25,23 @@ const N = 5;
const FLOOR = N - 1; // 4
const CEILING = N + 2; // 7
// Native controls found separate cache-validity, tenant-key, and new-code
// coverage gaps when these surrounding contracts were omitted. The shared
// mutable state and missing legacy regression below remain deliberate defects.
const planEng5Findings = (planPath: string) => [
`Please review this plan thoroughly. As you go, write your plan-mode plan to ${planPath} (use Edit/Write to that exact path).`,
'',
'# Plan: Multi-tenant Auth Refactor',
'',
'## Existing contracts retained',
'The existing cache adapter keys entries by tenant ID, issuer, audience,',
'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.',
'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.',
'',
'## Architecture',
'Two new services (`AuthBroker` and `SessionMint`) share a global mutable',
'`AuthCache` instance via module-level export. Both services mutate it.',