Merge remote-tracking branch 'origin/main' into garrytan/retention-cohorts

# Conflicts:
#	CHANGELOG.md
#	VERSION
#	package.json
This commit is contained in:
Garry Tan
2026-06-24 07:17:35 -07:00
53 changed files with 2381 additions and 250 deletions
+7 -4
View File
@@ -128,6 +128,8 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
maxSkeletonBytes: 90_000,
minUnionBytes: 120_000,
mustContain: ['VERSION', 'CHANGELOG', 'review', 'merge', 'PR'],
// v1.58.5.0: pre-push-guard install (#2077) stacks on the shared first-run-guidance preamble.
maxSizeRatio: 1.08,
},
'plan-ceo-review': {
skill: 'plan-ceo-review',
@@ -161,8 +163,8 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
gateAfterStop: 'EXIT PLAN MODE GATE',
},
behavioral: 'plan',
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
maxSkeletonBytes: 65_000,
// v1.2.0 activation lift (shared first-run-guidance preamble) + #2077 ask-first scope gate.
maxSkeletonBytes: 67_000,
minUnionBytes: 70_000,
mustContain: ['Architecture', 'Code Quality', 'Test', 'Performance'],
// Cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback + the
@@ -186,10 +188,11 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
behavioral: 'plan',
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
// always-loaded AskUserQuestion Format section.
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
maxSkeletonBytes: 86_000,
// v1.2.0 activation lift (shared first-run-guidance preamble) + #2077 ask-first scope gate.
maxSkeletonBytes: 88_000,
minUnionBytes: 70_000,
mustContain: ['design', 'visual'],
maxSizeRatio: 1.07,
},
'plan-devex-review': {
skill: 'plan-devex-review',
+88 -3
View File
@@ -71,6 +71,15 @@ export interface ClaudePtyOptions {
permissionMode?: 'plan' | 'default' | 'acceptEdits' | 'bypassPermissions' | 'auto' | 'dontAsk' | null;
/** Extra args after the permission-mode flag. */
extraArgs?: string[];
/**
* Model for the spawned interactive `claude`. Without an explicit --model the
* child inherits the operator's ~/.claude/settings.json model (e.g.
* claude-fable-5[1m]), which can spend 5+ min in extended thinking on an empty
* plan-mode context and blow every smoke budget. Resolution mirrors
* session-runner.ts:144 exactly: opts.model ?? EVALS_MODEL ?? 'claude-sonnet-4-6'.
* Pushed BEFORE extraArgs so a test-supplied --model still wins (last flag wins).
*/
model?: string;
/** Terminal size. Default 120x40. Plan-mode UI lays out cleanly at this size. */
cols?: number;
rows?: number;
@@ -406,8 +415,10 @@ export function judgePtyState(
const prompt = `You are reading a snapshot of a terminal where Claude Code is running in plan mode for an automated test. Your job: classify the agent's current state.
Pick exactly ONE:
- WAITING — agent surfaced a question or option list and is sitting at the input prompt waiting for user reply. Signs: numbered/lettered options visible (1./2./3. or A)/B)/C)), "Recommendation:" line, cursor at empty input prompt with no recent generation activity.
- WAITING — agent surfaced a question or option list and is sitting at the input prompt waiting for user reply. Signs: numbered/lettered options visible (1./2./3. or A)/B)/C)), "Recommendation:" line, cursor at empty input prompt with no recent generation activity, OR a fully-rendered question + reply-instruction (e.g. "Reply with A, B, or C" / "Recommendation:") is visible.
- WORKING — agent is actively generating or running tools. Signs: spinner glyphs (✻ ✶ ✳ ✢ ✽), "Musing..." or "Churned for ..." text, recent tool-call blocks (Read/Edit/Bash/Grep), in-flight token output.
PRECEDENCE OVERRIDE: if a lettered/numbered option list (A)/B)/1./2.) AND a "Recommendation:" or "Reply with"/"Reply A" instruction are BOTH visible in this snapshot, classify WAITING even when spinner glyphs (✻ ✶ ✳ ✢ ✽) are still animating — Claude Code keeps the spinner up at an idle prose decision, so a spinner alongside a fully-rendered question + reply-instruction is a residual render artifact, not active generation.
- HUNG — agent has stopped without surfacing a question and without any spinner/work activity. Rare; usually means a crash.
Respond with strict JSON ONLY (no markdown fences, no prose):
@@ -503,6 +514,16 @@ ${tail}
* for plan-eng / plan-design / plan-devex prose AUQ
* - 3+ distinct numbered options (1. 2. 3.) at line starts WITHOUT a
* `<spaces>1.` cursor — typical for autoplan / office-hours prose AUQ
* - 3+ markdown bold-bullet options (`- **label**`) following an
* interrogative line — office-hours renders its mode question this way
* (`> - **Building a startup**`), which has no letter/number marker
* - Pattern 4/5 (collapsed-form): a reply-instruction OR recommendation
* marker PLUS 2+ distinct A-D letter markers each punctuated by ) : or (
* anywhere in the tail. stripAnsi destroys the newlines + inter-word
* spaces that the line-anchored patterns above need, so a real prose AUQ
* arrives collapsed ("ReplywithA,B,orC", "A(recommended)", "-B:") and is
* invisible to Patterns 1-3. This is the dominant Shape-B render mode in
* the plan-design smoke + floor timeouts (verified against real run bytes).
*
* Used by classifyVisible and runPlanSkillFloorCheck to return outcome='asked'
* (or auq_observed) instead of letting the harness time out when the model
@@ -546,7 +567,55 @@ export function isProseAUQVisible(visible: string): boolean {
while ((nm = numberedRe.exec(tail)) !== null) {
if (nm[1]) numberedHits.add(nm[1]);
}
return numberedHits.size >= 2;
if (numberedHits.size >= 2) return true;
// Pattern 3: markdown bold-bullet option list. office-hours renders its
// mode question as `> - **Building a startup**` lines under
// --disallowedTools — no letter/number marker, so Patterns 1-2 miss it,
// and the model keeps a spinner up so the Haiku judge scores it 'working'
// and the run times out despite the question being on screen.
// Require both: an interrogative line (the question stem ends in '?') AND
// 3+ bold-bullet markers. The bold (`- **`) requirement is what separates
// an option list from incidental prose bullets; the line anchor is dropped
// because stripAnsi can collapse option lines (see Pattern 1 note), so we
// count markers anywhere in the tail. The ` 1.` cursor gate above already
// excludes a live native list.
if (/\?/.test(tail)) {
const boldBulletHits = (tail.match(/[-*•]\s+\*\*/g) || []).length;
if (boldBulletHits >= 3) return true;
}
// Pattern 4/5: collapsed-form prose AUQ. stripAnsi removes the
// cursor-positioning escapes that render option newlines + inter-word
// spaces, so "Reply with A, B, or C" arrives as "ReplywithA,B,orC" and
// "A) ..." as "A(recommended)" / "-B:" — defeating every line-anchored or
// ')'-anchored pattern above (Patterns 1-3 all return false on the real
// plan-design smoke + floor timeout bytes). Detect via two INDEPENDENT
// signals that must BOTH hold — the corroboration is what separates a real
// AUQ from incidental report prose that happens to mention a recommendation:
// (1) a reply-instruction matched space-insensitively OR a recommendation
// marker, AND
// (2) 2+ distinct A-D letter markers each punctuated by ) : or ( anywhere
// in the tail.
// A single 'B)' + the word "recommendation", or a comma-only collapsed
// "ReplywithA,B,orC" with no )/:/( punctuation on the letters, both stay
// false — the two-signal contract is pinned by unit tests.
const replyOrRec =
/reply\s*(?:with)?\s*[A-D]/i.test(tail) ||
/reply(?:with)?[A-D]/i.test(tail.replace(/\s+/g, '')) ||
/\bRecommendation\s*:/i.test(tail) ||
/\(recommended\)/i.test(tail);
if (replyOrRec) {
const collapsedLetterRe = /\b([A-D])[):(]/g;
const collapsedHits = new Set<string>();
let cm: RegExpExecArray | null;
while ((cm = collapsedLetterRe.exec(tail)) !== null) {
if (cm[1]) collapsedHits.add(cm[1]);
}
if (collapsedHits.size >= 2) return true;
}
return false;
}
/**
@@ -1145,9 +1214,15 @@ export async function launchClaudePty(
let exited = false;
let exitCodeCaptured: number | null = null;
const args: string[] = [];
// Pin the model so smokes don't inherit the operator's settings.json model
// (see ClaudePtyOptions.model). Chain mirrors session-runner.ts:144 so PTY and
// `claude -p` evals always agree. Pushed before extraArgs => a test-supplied
// --model wins (last flag wins).
const model = opts.model ?? process.env.EVALS_MODEL ?? 'claude-sonnet-4-6';
args.push('--model', model);
// Permission mode: 'plan' default, null => omit flag entirely.
const permissionMode = opts.permissionMode === undefined ? 'plan' : opts.permissionMode;
const args: string[] = [];
if (permissionMode !== null) {
args.push('--permission-mode', permissionMode);
}
@@ -1498,6 +1573,9 @@ export async function runPlanSkillObservation(opts: {
* Step 0 reads the prior conversation context so it sees the draft.
*/
initialPlanContent?: string;
/** Override the spawned model. Defaults via launchClaudePty's chain
* (opts.model ?? EVALS_MODEL ?? 'claude-sonnet-4-6'). */
model?: string;
}): Promise<PlanSkillObservation> {
const startedAt = Date.now();
const session = await launchClaudePty({
@@ -1506,6 +1584,7 @@ export async function runPlanSkillObservation(opts: {
timeoutMs: (opts.timeoutMs ?? 180_000) + 30_000,
extraArgs: opts.extraArgs,
env: opts.env,
model: opts.model,
});
try {
@@ -1762,6 +1841,8 @@ export async function runPlanSkillCounting(opts: {
timeoutMs?: number;
/** Extra env merged into the spawned `claude` process. */
env?: Record<string, string>;
/** Override the spawned model. Defaults via launchClaudePty's chain. */
model?: string;
}): Promise<PlanSkillCountObservation> {
const startedAt = Date.now();
const defaultPick = opts.defaultPick ?? 1;
@@ -1772,6 +1853,7 @@ export async function runPlanSkillCounting(opts: {
cwd: opts.cwd,
timeoutMs: timeoutMs + 60_000,
env: opts.env,
model: opts.model,
});
const fingerprints: AskUserQuestionFingerprint[] = [];
@@ -1993,6 +2075,8 @@ export async function runPlanSkillFloorCheck(opts: {
timeoutMs?: number;
/** Extra env merged into the spawned `claude` process. */
env?: Record<string, string>;
/** Override the spawned model. Defaults via launchClaudePty's chain. */
model?: string;
}): Promise<PlanSkillFloorObservation> {
const startedAt = Date.now();
const timeoutMs = opts.timeoutMs ?? 600_000;
@@ -2002,6 +2086,7 @@ export async function runPlanSkillFloorCheck(opts: {
cwd: opts.cwd,
timeoutMs: timeoutMs + 60_000,
env: opts.env,
model: opts.model,
});
try {
+143
View File
@@ -23,6 +23,7 @@
*/
import { describe, test, expect } from 'bun:test';
import { readFileSync } from 'node:fs';
import {
isPermissionDialogVisible,
isNumberedOptionListVisible,
@@ -290,6 +291,109 @@ This refers to (see option B) above and also to point A) earlier.
expect(isProseAUQVisible('Just some plain text output from the model.')).toBe(false);
expect(isProseAUQVisible('')).toBe(false);
});
// Pattern 3: markdown bold-bullet options — office-hours renders its mode
// question this way under --disallowedTools, with no letter/number marker.
test('matches office-hours markdown bold-bullet mode question (Pattern 3)', () => {
const sample = `
> Before we dig in — what's your goal with this?
>
> - **Building a startup** (or thinking about it)
> - **Intrapreneurship** — internal project at a company, need to ship fast
> - **Hackathon / demo** — time-boxed, need to impress
> - **Open source / research** — building for a community
> - **Learning** — teaching yourself to code
`;
expect(isProseAUQVisible(sample)).toBe(true);
});
test('bold-bullets require a preceding interrogative — no "?" => false', () => {
// 3+ bold bullets but no question stem: this is a feature list, not an AUQ.
const sample = `
Here is what shipped:
- **Faster builds** via caching
- **Smaller binaries** through tree-shaking
- **Better errors** with source maps
`;
expect(isProseAUQVisible(sample)).toBe(false);
});
test('a question with fewer than 3 bold bullets stays false (guard)', () => {
const sample = `
Which approach do you prefer?
- **Option one** is simpler
- **Option two** is faster
`;
expect(isProseAUQVisible(sample)).toBe(false);
});
test('plain (non-bold) bullets after a question do not trigger Pattern 3', () => {
// Only bold bullets count — plain "- text" prose lists are too common.
const sample = `
What should we do about this?
- run the tests
- ship the fix
- file a follow-up
`;
expect(isProseAUQVisible(sample)).toBe(false);
});
test('Pattern 3 still defers to a live native cursor list ( 1.)', () => {
const sample = `
> What's your goal?
1. **Building a startup**
2. **Intrapreneurship**
3. **Hackathon**
`;
// The 1. cursor gate fires first — native list handling owns this.
expect(isProseAUQVisible(sample)).toBe(false);
});
// Pattern 4/5: collapsed-form prose AUQ. stripAnsi destroys the newlines +
// inter-word spaces, so a real prose AUQ arrives collapsed and defeats the
// line-anchored Patterns 1-3. These are the dominant Shape-B render mode in
// the plan-design smoke + floor timeouts — verbatim de-spinnered bytes from
// the real failing runs (bdm3sucql.output).
test('matches the real collapsed floor render (colon-delimited, Pattern 4/5)', () => {
const sample =
'The review is blocked on D1—reply withA, B, r Cabovetocontinue:' +
'- A(recommended): Spec thefull P1AskUserQuestioncopy in this review' +
'-B:LeaveP1copytotheimplementerwithstructuralrequirements' +
'C: Add a placeholder template to the plan';
expect(isProseAUQVisible(sample)).toBe(true);
});
test('matches the real collapsed plan-mode render (Recommendation + collapsed A)/B), Pattern 4/5)', () => {
const sample =
'Recommendation:A—writethecopynow.(recommended)A) Writ the fullcopy in thisdesign review— now.' +
'(recommended) Completeness:10/10 B) Leveit to theimplemente — task spec is enough.' +
'Reply withA (write the copy now)orB(leavetoimplementer)';
expect(isProseAUQVisible(sample)).toBe(true);
});
test('collapsed-form requires BOTH signals — single B) + word "recommendation" stays false', () => {
// Only one punctuated letter marker: the two-signal contract is not met.
const sample =
'We should consider option B) here. My recommendation is to do it now.';
expect(isProseAUQVisible(sample)).toBe(false);
});
test('collapsed-form requires letter punctuation — comma-only "ReplywithA,B,orC" stays false', () => {
// Reply-instruction present, but the letters carry no ) : or ( punctuation,
// so they could be incidental enumerations in running prose. Stays false.
const sample = 'ReplywithA,B,orC';
expect(isProseAUQVisible(sample)).toBe(false);
});
test('collapsed-form does not regress the existing FP guard (see option B) ... point A))', () => {
// The classic citation FP: a model referencing prior options in prose.
// No reply-instruction / recommendation marker on its own line, so the
// collapsed-form signal does not fire either.
const sample =
'As noted (see option B) above, and the earlier point A) we discussed, this is fine.';
expect(isProseAUQVisible(sample)).toBe(false);
});
});
describe('classifyVisible (runtime path through the runner classifier)', () => {
@@ -552,6 +656,45 @@ describe('runPlanSkillObservation env passthrough surface', () => {
});
});
describe('launchClaudePty model pin (static tripwire)', () => {
// Why static-grep, not a behavioral assert: the spawn fires immediately
// inside launchClaudePty, so asserting the built args array would require
// extracting an arg-builder seam — which rewrites the exact region kyoto-v5's
// hermetic --strict-mcp-config insertion edits, reintroducing a merge
// conflict the placement deliberately avoids. The end-to-end behavioral proof
// is the live PTY smoke (skill-e2e-plan-*-plan-mode.test.ts) running under the
// pinned model. These grep-level guards stop a refactor from silently
// dropping the pin or reordering it past extraArgs.
const src = readFileSync(new URL('./claude-pty-runner.ts', import.meta.url), 'utf-8');
test('ClaudePtyOptions exposes model?: string', () => {
const opts: ClaudePtyOptions = { model: 'claude-sonnet-4-6' };
expect(opts.model).toBe('claude-sonnet-4-6');
});
test('spawn args push --model from the EVALS_MODEL fallback chain', () => {
expect(src).toContain("args.push('--model', model)");
// opts.model -> EVALS_MODEL -> 'claude-sonnet-4-6' (mirrors session-runner.ts:144)
expect(src).toMatch(
/opts\.model\s*\?\?\s*process\.env\.EVALS_MODEL\s*\?\?\s*'claude-sonnet-4-6'/,
);
});
test('--model is pushed BEFORE extraArgs so a per-test --model override wins', () => {
const modelPush = src.indexOf("args.push('--model', model)");
const extraArgsPush = src.indexOf('if (opts.extraArgs) args.push(...opts.extraArgs)');
expect(modelPush).toBeGreaterThan(-1);
expect(extraArgsPush).toBeGreaterThan(-1);
expect(modelPush).toBeLessThan(extraArgsPush);
});
test('all three plan-skill wrappers forward model to launchClaudePty', () => {
// Count must match the number of wrappers (observation, counting, floor).
const forwards = src.match(/^\s*model: opts\.model,$/gm) ?? [];
expect(forwards.length).toBe(3);
});
});
// ────────────────────────────────────────────────────────────────────────────
// Per-finding count primitives — Section 3 unit tests #1#5, #7, #12.
// ────────────────────────────────────────────────────────────────────────────
+11 -5
View File
@@ -523,10 +523,16 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic'> = {
'plan-eng-coverage-audit': 'gate',
'plan-review-report': 'gate',
// Plan-mode handshake — deterministic safety regression, gate-tier
// Plan-mode handshake. plan-ceo/plan-devex ask-first reliably (gate-tier);
// plan-eng/plan-design run a long explore/audit before their first
// AskUserQuestion, so whether they reach a terminal outcome within the 300s
// budget hinges on stochastic ask-first compliance (~50-67%/run measured).
// Per the "non-deterministic -> periodic" tiering rule they are periodic:
// the hardened ask-first gate + the collapsed-form detector lifted them from
// always-failing to mostly-passing, but they are not deterministic gates.
'plan-ceo-review-plan-mode': 'gate',
'plan-eng-review-plan-mode': 'gate',
'plan-design-review-plan-mode': 'gate',
'plan-eng-review-plan-mode': 'periodic',
'plan-design-review-plan-mode': 'periodic',
'plan-devex-review-plan-mode': 'gate',
'plan-mode-no-op': 'gate',
// v1.21+ auto-mode regression tests
@@ -556,9 +562,9 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic'> = {
'plan-eng-finding-count': 'periodic',
'plan-design-finding-count': 'periodic',
'plan-devex-finding-count': 'periodic',
'plan-eng-finding-floor': 'gate',
'plan-eng-finding-floor': 'periodic', // stochastic ask-first (see plan-mode-handshake note); periodic
'plan-ceo-finding-floor': 'gate',
'plan-design-finding-floor': 'gate',
'plan-design-finding-floor': 'periodic', // stochastic ask-first (see plan-mode-handshake note); periodic
'plan-devex-finding-floor': 'gate',
'plan-eng-multi-finding-batching': 'periodic',
'plan-ceo-split-overflow': 'periodic',