mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
454423aeb3
* test: extract classifyVisible() + permission-dialog filter in PTY runner
Pure classifier extracted from runPlanSkillObservation's polling loop so
unit tests can exercise the actual branch order with synthetic input
strings. Runner gains:
- env? passthrough on runPlanSkillObservation (forwarded to launchClaudePty).
gstack-config does not yet honor env overrides; plumbing is in place for a
future change to make tests hermetic.
- TAIL_SCAN_BYTES = 1500 exported constant. Replaces a duplicated magic
number in test/skill-e2e-plan-ceo-mode-routing.test.ts so tuning stays
in sync.
- isPermissionDialogVisible: the bare phrase "Do you want to proceed?" now
requires a file-edit context co-trigger. Other clauses unchanged. Skill
questions that contain the bare phrase are no longer mis-classified.
- classifyVisible(visible): pure function. Branch order silent_write →
plan_ready → asked → null. Permission dialogs filtered out of the
'asked' classification so a permission prompt cannot pose as a Step 0
skill question.
Adds 24 unit tests covering all classifier branches, edge cases, and the
co-trigger contract.
* test: tighten plan-ceo-review smoke to require Step 0 fires first
Assertion narrows from ['asked', 'plan_ready'] to 'asked' only. Reaching
plan_ready first means the agent skipped Step 0 entirely and went
straight to ExitPlanMode — the regression we want to catch.
Why plan-ceo is special: unlike plan-eng / plan-design / plan-devex
(whose smokes legitimately reach plan_ready on certain branches without
asking), plan-ceo-review's template mandates Step 0A premise challenge
plus Step 0F mode selection BEFORE any plan write. There is no
legitimate path to plan_ready that does not first emit a skill-question
numbered prompt.
Failure message now branches on outcome (plan_ready vs timeout vs
silent_write) with a tailored diagnosis line per case. References the
skill template by section name ("Step 0 STOP rules", "One issue = one
AskUserQuestion call") instead of line numbers, so it survives template
edits.
Passes env: { QUESTION_TUNING: 'false', EXPLAIN_LEVEL: 'default' }
through the runner. Today this is advisory — gstack-config reads only
~/.gstack/config.yaml, not env vars — but the wiring is in place for a
future change. Documented honestly in the docstring.
Verified across 4 PTY runs: 3 pre-refactor + 1 post-refactor, all PASS.
* chore: capture v1.21.1.0 follow-ups in TODOS.md
- P2: per-finding AskUserQuestion count assertion (V2)
- P3: honor env vars in gstack-config so test isolation env actually works
- P3: path-confusion hardening on SANCTIONED_WRITE_SUBSTRINGS
All three surfaced during the v1.21.1.0 plan-eng-review and adversarial
review passes. Captured here so the design intent persists.
* chore: bump version and changelog (v1.21.1.0)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: extract MODE_RE + optionsSignature into PTY runner exports
Refactor prep for the upcoming per-finding AskUserQuestion count test
across plan-{ceo,eng,design,devex}-review. Both new tests and the existing
mode-routing test need the same mode regex and the same option-list
fingerprint dedupe — pulling them into one source of truth in
test/helpers/claude-pty-runner.ts so a fifth mode (or a tweak to the
fingerprint shape) updates everywhere instead of drifting per-test.
Mechanical: no behavior change in the mode-routing test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add per-finding count primitives + unit tests
Pure helpers landing ahead of runPlanSkillCounting:
- parseQuestionPrompt(visible) — extract the 1-3 line prompt above
the latest "❯ 1." cursor, normalize to a 240-char snippet
- auqFingerprint(prompt, opts) — Bun.hash of normalized prompt + sorted
options signature; distinct prompts with shared option labels
(the generic A/B/C TODO menu) get distinct fingerprints
- COMPLETION_SUMMARY_RE — terminal-signal regex matching all four
plan-review skills' completion / verdict markers
- assertReviewReportAtBottom(content) — checks "## GSTACK REVIEW
REPORT" is present and is the last "## " heading in a plan file
- Step0BoundaryPredicate type + four per-skill predicates
(ceo / eng / design / devex) — fire on the answered AUQ's
fingerprint, marking the end of Step 0 deterministically
(event-based, not content-based, per Codex F7)
Plus 37 deterministic unit tests covering option-label collision
regression, prompt extraction edge cases, predicate positive AND
negative cases, and review-report-at-bottom triple-check
(missing / mid-file / multiple trailing).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add runPlanSkillCounting PTY helper
Drives a plan-* skill end-to-end and counts distinct review-phase
AskUserQuestions. Composes the primitives from the previous commit:
- Boot + auto-trust handler (existing launchClaudePty)
- Send slash command alone, sleep 3s, send plan content as follow-up
message (proven pattern from skill-e2e-plan-design-with-ui)
- Poll loop with permission-dialog auto-grant, same-redraw skip,
empty-prompt re-poll
- Event-based Step-0 boundary via isLastStep0AUQ predicate fired on
the answered AUQ's fingerprint (Codex F7 — boundary is observed
event, not later rendered content)
- Multi-signal terminals: hard ceiling, COMPLETION_SUMMARY_RE,
plan_ready, silent_write, exited, timeout
Empty-prompt fingerprints are skipped per the contract documented in
auqFingerprint's unit tests — fingerprinting them would re-introduce
the option-label collision regression Codex F1 caught.
No E2E tests yet — those land in commit 5 with the four skill fixtures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: register four finding-count tests in touchfiles + tier map
Each new test depends on its skill template, the runner, and three
preamble resolvers (preamble.ts, generate-ask-user-format.ts,
generate-completion-status.ts) — those affect question cadence and
completion rendering, which is exactly what the test asserts on.
All four classified periodic. Sequential execution during calibration;
opt-in to concurrent only after measured comparison agrees (plan §D15).
Updated touchfiles.test.ts: plan-ceo-review/** now selects 19 tests
(was 18) because plan-ceo-finding-count joins the family.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add four per-finding count E2E tests (plan-ceo + eng + design + devex)
Each test drives its plan-* skill through Step 0 then asserts the
review-phase AskUserQuestion count falls in [N-1, N+2] for an N=5
seeded plan, plus D19: produced plan file ends with
"## GSTACK REVIEW REPORT" as its last "## " heading.
plan-ceo also runs a paired-finding positive control: 2 deliberately
related findings should still produce 2 distinct AUQs, not 1 batched.
Periodic-tier (gate-skipped without EVALS=1, EVALS_TIER=periodic).
Sequential execution by plan §D15. Each fixture is inline TypeScript
content delivered as a follow-up message after the slash command, per
the proven pattern at skill-e2e-plan-design-with-ui.test.ts.
Calibration loop (5 runs per skill) and the manual pre-merge negative
check (D7 + D12) are required before merge per plan §Verification.
NOT yet run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: fix parseNumberedOptions for inline-cursor box-layout AUQs
Calibration run 1 timed out with step0=0 review=0 because the parser
could not find the cursor in /plan-ceo-review's scope-selection AUQ.
The TTY's box-layout rendering inlines divider + header + prompt +
"1." onto one logical line — cursor escapes get stripped, leaving
text crushed onto a single line.
Cursor anchor regex changed from anchored to unanchored so it matches
mid-line. Cursor-line option extraction uses a non-anchored regex;
subsequent options stay with the original start-of-line parser.
parseQuestionPrompt picks up the inline prompt text BEFORE the cursor
on the cursor line (after stripping box-drawing chars + sigil) and
appends it after any walked-up multi-line prompt above.
Three new unit tests: clean-cursor still works, inline-cursor
extracts all 7 options, prompt extraction strips box chars.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add firstAUQPick + plan-ceo skip-interview routing
Calibration run 1 surfaced a second issue beyond the parser bug: the
default pick of 1 on /plan-ceo-review's scope-selection AUQ routes
the agent to "branch diff vs main" — so it reviews the gstack PR
itself (recursive!) instead of the seeded fixture plan we sent.
Added firstAUQPick callback to runPlanSkillCounting. Override applies
only to the FIRST AUQ; subsequent presses keep using defaultPick.
ceoStep0Boundary now fires on either the mode-pick AUQ (existing path)
or any AUQ containing "Skip interview and plan immediately" — which
is the scope-selection AUQ. Picking that option bypasses Step 0 and
routes straight to review-phase using the chat-paste plan as context.
Plan-ceo test wires firstAUQPick = pickSkipInterview which finds the
"Skip interview" option by label. Falls back to "describe inline" if
the option labels change.
Two new unit tests: ceoStep0Boundary fires on the scope-selection
fixture; existing mode-pick fixture still fires.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
213 lines
8.4 KiB
TypeScript
213 lines
8.4 KiB
TypeScript
/**
|
||
* /plan-ceo-review mode-routing E2E (periodic, paid, real-PTY).
|
||
*
|
||
* Asserts: when /plan-ceo-review reaches its Step 0F mode-selection
|
||
* AskUserQuestion and the user picks HOLD SCOPE or SCOPE EXPANSION,
|
||
* the downstream rendered output reflects that mode's distinctive
|
||
* posture language.
|
||
*
|
||
* Why this exists: existing tests verify that the question fires. Nothing
|
||
* verifies the answer actually routes. A regression where Step 0F shows
|
||
* the question but the agent ignores the choice (e.g. always defaults
|
||
* to EXPANSION) would not be caught by any prior test.
|
||
*
|
||
* Tier: periodic (not gate). Each run navigates 8-12 prior AskUserQuestions (telemetry,
|
||
* proactive, routing, vendoring, brain, office-hours, premise×3, approach)
|
||
* before reaching Step 0F. At ~30s per AskUserQuestion that's a 4-6 min navigation
|
||
* phase per case. The full 2-case suite runs ~12-15 min, $3-4. Too slow
|
||
* for gate-tier; weekly is fine.
|
||
*
|
||
* Mode coverage: HOLD SCOPE + SCOPE EXPANSION cover the two posture poles
|
||
* (rigor vs ambition). SELECTIVE EXPANSION and SCOPE REDUCTION are V2 once
|
||
* the navigation phase is shorter or has a deterministic fast-path through
|
||
* Step 0A/0C-bis.
|
||
*
|
||
* Posture assertions: each mode has distinct downstream language. The
|
||
* checks below are deliberately permissive — they catch the binary
|
||
* "did the mode posture even apply" question, not Opus-specific phrasing.
|
||
*
|
||
* HOLD SCOPE — "rigor" or "bulletproof" or "hold scope"
|
||
* SCOPE EXPANSION — "expansion" or "10x" or "delight" or "dream"
|
||
*/
|
||
|
||
import { describe, test } from 'bun:test';
|
||
import {
|
||
launchClaudePty,
|
||
isNumberedOptionListVisible,
|
||
isPermissionDialogVisible,
|
||
parseNumberedOptions,
|
||
isPlanReadyVisible,
|
||
MODE_RE,
|
||
optionsSignature,
|
||
TAIL_SCAN_BYTES,
|
||
type ClaudePtySession,
|
||
} from './helpers/claude-pty-runner';
|
||
|
||
const shouldRun = !!process.env.EVALS && process.env.EVALS_TIER === 'periodic';
|
||
const describeE2E = shouldRun ? describe : describe.skip;
|
||
|
||
interface ModeCase {
|
||
mode: 'HOLD SCOPE' | 'SCOPE EXPANSION';
|
||
/** Regex applied to visible-since-mode-pick text. At least one must match. */
|
||
postureRe: RegExp;
|
||
}
|
||
|
||
const CASES: ModeCase[] = [
|
||
{ mode: 'HOLD SCOPE', postureRe: /\b(rigor|bulletproof|hold\s*scope|maximum\s+rigor)\b/i },
|
||
{ mode: 'SCOPE EXPANSION', postureRe: /\b(expansion|10x|delight|dream|cathedral|opt[\s-]?in)\b/i },
|
||
];
|
||
|
||
/**
|
||
* Navigate prior AskUserQuestions by picking option 1 until we hit an AskUserQuestion whose
|
||
* options match one of the 4 mode names. Returns the option index
|
||
* matching `targetMode`, with the buffer marker pointing AT that AskUserQuestion.
|
||
*
|
||
* Throws if we don't reach the mode AskUserQuestion within `maxNav` prior AskUserQuestions or
|
||
* the overall budget.
|
||
*/
|
||
async function navigateToModeAskUserQuestion(
|
||
session: ClaudePtySession,
|
||
since: number,
|
||
targetMode: ModeCase['mode'],
|
||
opts: { maxNav?: number; budgetMs?: number } = {},
|
||
): Promise<{ modeIndex: number; visibleAtMode: string }> {
|
||
// /plan-ceo-review's mode AskUserQuestion (Step 0F) sits behind several preamble
|
||
// and Step 0A-0C-bis gates: telemetry, proactive, routing, vendoring,
|
||
// brain privacy, office-hours offer, premise challenge (3 questions),
|
||
// approach selection. 12 hops is the conservative ceiling.
|
||
const maxNav = opts.maxNav ?? 12;
|
||
const budgetMs = opts.budgetMs ?? 420_000;
|
||
const start = Date.now();
|
||
let priorAnswered = 0;
|
||
let lastSeenList: Array<{ index: number; label: string }> = [];
|
||
|
||
while (Date.now() - start < budgetMs) {
|
||
if (session.exited()) {
|
||
throw new Error(
|
||
`claude exited (code=${session.exitCode()}) during nav.\n` +
|
||
`Last visible:\n${session.visibleSince(since).slice(-2000)}`,
|
||
);
|
||
}
|
||
await Bun.sleep(2000);
|
||
const visible = session.visibleSince(since);
|
||
if (!isNumberedOptionListVisible(visible)) continue;
|
||
const opts = parseNumberedOptions(visible);
|
||
if (opts.length < 2) continue;
|
||
|
||
// Has the rendered list changed since last poll? If not, we're seeing
|
||
// the same prompt and shouldn't double-press.
|
||
const sig = optionsSignature(opts);
|
||
const lastSig = optionsSignature(lastSeenList);
|
||
if (sig === lastSig) continue;
|
||
lastSeenList = opts;
|
||
|
||
// Is THIS the mode AskUserQuestion?
|
||
if (opts.some(o => MODE_RE.test(o.label))) {
|
||
const target = opts.find(o => o.label.toUpperCase().includes(targetMode));
|
||
if (!target) {
|
||
throw new Error(
|
||
`Mode AskUserQuestion rendered but target "${targetMode}" not in option labels:\n` +
|
||
opts.map(o => ` ${o.index}. ${o.label}`).join('\n'),
|
||
);
|
||
}
|
||
return { modeIndex: target.index, visibleAtMode: visible };
|
||
}
|
||
|
||
// Permission dialog? Grant with "1" but don't count it against nav budget.
|
||
// Classify on the recent tail only — old permission text persists in
|
||
// visibleSince and would re-trigger forever.
|
||
//
|
||
// Note: runPlanSkillObservation has its own permission-dialog filter that
|
||
// simply skips classification (since it observes, doesn't drive). This nav
|
||
// loop drives the PTY directly via launchClaudePty and so owns its own
|
||
// dialog handling — granting with "1" so the workflow advances. Both
|
||
// paths share TAIL_SCAN_BYTES as the recent-tail window so tuning stays
|
||
// in sync.
|
||
if (isPermissionDialogVisible(visible.slice(-TAIL_SCAN_BYTES))) {
|
||
session.send('1\r');
|
||
await Bun.sleep(1500);
|
||
continue;
|
||
}
|
||
|
||
// Not the mode AskUserQuestion — answer with option 1 (recommended) and continue.
|
||
if (priorAnswered >= maxNav) {
|
||
throw new Error(
|
||
`Navigated ${maxNav} prior AskUserQuestions without reaching the mode AskUserQuestion. ` +
|
||
`Last list:\n${opts.map(o => ` ${o.index}. ${o.label}`).join('\n')}`,
|
||
);
|
||
}
|
||
priorAnswered++;
|
||
session.send('1\r');
|
||
// Give the agent a beat to advance before re-polling.
|
||
await Bun.sleep(2000);
|
||
}
|
||
throw new Error(`Mode AskUserQuestion not reached within ${budgetMs}ms`);
|
||
}
|
||
|
||
describeE2E('/plan-ceo-review mode routing (gate)', () => {
|
||
for (const c of CASES) {
|
||
test(
|
||
`mode "${c.mode}" routes to its distinctive posture`,
|
||
async () => {
|
||
const session = await launchClaudePty({
|
||
permissionMode: 'plan',
|
||
timeoutMs: 540_000,
|
||
});
|
||
try {
|
||
await Bun.sleep(8000);
|
||
const since = session.mark();
|
||
session.send('/plan-ceo-review\r');
|
||
|
||
const { modeIndex } = await navigateToModeAskUserQuestion(session, since, c.mode);
|
||
|
||
// Snapshot the visible buffer at mode-pick time, then send the index.
|
||
const sincePick = session.rawOutput().length;
|
||
session.send(`${modeIndex}\r`);
|
||
|
||
// Wait for downstream evidence: either next AskUserQuestion or plan_ready or
|
||
// a posture-distinctive substring shows up.
|
||
const budgetMs = 240_000;
|
||
const start = Date.now();
|
||
let postureMatched = false;
|
||
let downstreamSnapshot = '';
|
||
while (Date.now() - start < budgetMs) {
|
||
await Bun.sleep(2500);
|
||
if (session.exited()) {
|
||
throw new Error(
|
||
`claude exited (code=${session.exitCode()}) after mode pick.\n` +
|
||
`Downstream:\n${session.visibleSince(sincePick).slice(-2000)}`,
|
||
);
|
||
}
|
||
downstreamSnapshot = session.visibleSince(sincePick);
|
||
if (c.postureRe.test(downstreamSnapshot)) {
|
||
postureMatched = true;
|
||
break;
|
||
}
|
||
// Don't bail early on plan_ready alone — the posture text may
|
||
// arrive as the agent finishes writing the plan. Only break
|
||
// once we either match posture or run the clock.
|
||
if (
|
||
isPlanReadyVisible(downstreamSnapshot) &&
|
||
isNumberedOptionListVisible(downstreamSnapshot) &&
|
||
!c.postureRe.test(downstreamSnapshot)
|
||
) {
|
||
// Plan-ready AND a follow-up AskUserQuestion are both visible but
|
||
// posture text has not appeared yet. Keep polling for a bit.
|
||
}
|
||
}
|
||
if (!postureMatched) {
|
||
throw new Error(
|
||
`Mode "${c.mode}" routing FAILED: no posture match for ${c.postureRe.source}.\n` +
|
||
`--- downstream visible since mode pick (last 3KB) ---\n` +
|
||
downstreamSnapshot.slice(-3000),
|
||
);
|
||
}
|
||
} finally {
|
||
await session.close();
|
||
}
|
||
},
|
||
600_000,
|
||
);
|
||
}
|
||
});
|