Files
gstack/test/skill-e2e-plan.test.ts
T
94993f7401 v1.61.0.0 fix wave: guards failing open / silent failures (9 fixes, 4 community PRs absorbed) (#2472)
* fix(careful): warn on chained rm even when the last target is safe

The safe-exception block whitelisted rm -rf of build artifacts by
extracting targets with a single greedy match (.*rm ...), which only ever
inspects the LAST rm in the command. A chain like 'rm -rf /; rm -rf
node_modules' was therefore judged solely by its trailing safe target and
allowed without warning, waving through the destructive 'rm -rf /'.

Gate the shortcut to single rm invocations: when any shell separator
(; | & newline, incl. JSON-escaped \n/\r from the grep extraction path)
is present, fall through to the destructive-pattern check, which warns on
any recursive rm. Single-command artifact cleanups still allow.

Adds 3 regression tests covering semicolon and && chains in both orders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* harden(careful): substitution separators + capital -R recursive flag (#2039)

Two residual fail-opens in the same guard PR #2040 hardened, both verified
by executing the script pre-fix:

- rm -rf $(./wipe-all)/node_modules silently allowed: the substitution token
  ends in a whitelisted suffix and the safe-exception early exit skipped ALL
  downstream checks. $( and backtick now count as chain separators; plain
  $VAR expansion stays allowed.
- rm -R / silently allowed: both greps required a lowercase r in the flag
  cluster; capital -R is the documented BSD/macOS recursive flag. Both greps
  now match -[a-zA-Z]*[rR].

Six new tests: substitution x2 -> ask, capital-R x2 -> ask, rm -Rf
node_modules single-command -> still allowed, escaped-newline branch
(existing code, previously untested), and a pinned deliberate FP
(cd app && rm -rf node_modules -> ask) documenting the fail-closed
direction on chains.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(context-restore): prefer the current branch's own checkpoint (#2052)

All worktrees of a repo share one origin-derived slug, so they share one
`~/.gstack/projects/<slug>/checkpoints/` dir. `/context-restore` loaded the
newest checkpoint across the whole dir, so in one worktree it could silently
restore a *sibling worktree's* newer checkpoint.

Step 1 now orders candidates current-branch-first (read from each file's
`branch:` frontmatter), keeping other branches as a fallback. A branch is
checked out in at most one worktree, so this stops cross-worktree contamination
while preserving Conductor cross-branch handoff: when the current branch has no
checkpoint of its own, the full newest-first set is still used.

- scan the 200 newest before partitioning so a current-branch checkpoint sitting
  below a burst of sibling saves is still found; output still capped at 20
- non-git / detached HEAD / branchless legacy saves fall back to the old
  newest-first behavior (back-compat)
- +5 regression tests in context-save-hardening.test.ts (the #2052 bug case
  fails on the old pipeline); regenerated SKILL.md + proactive-suggestions.json

Fixes #2052

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gbrain): pass --confirm-destructive on drift re-register (#1985)

ensureSourceRegistered() handles match-but-different-path by removing the
old source then re-adding it at the new path. The remove was issued as
`gbrain sources remove <id> --yes`, but gbrain >= 0.42 gates `sources
remove` behind `--confirm-destructive` (`--yes` alone no longer suppresses
the data-loss prompt). The remove therefore fails with "To proceed, pass
--confirm-destructive", which ensureSourceRegistered surfaces as "source
registration failed" — aborting the entire /sync-gbrain code stage for any
already-registered source whose path has drifted. The memory and brain-sync
stages still pass, so the code index silently stops refreshing.

The orchestrator's own safeSourcesRemove() already passes
--confirm-destructive; this brings the lib helper in line with that
convention. Keeps --yes for older gbrain.

Tests: extend the fake gbrain shim in gbrain-sources.test.ts to simulate
the gbrain >= 0.42 guard (remove without --confirm-destructive exits 1),
update the drift re-register assertion, and add a regression test that
proves the drift path no longer throws. Both fail on main with the exact
"To proceed, pass --confirm-destructive" error and pass with the fix.

Fixes #1985

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* harden(gbrain-sources): route drift remove through #1734 guards + realpath drift check

Absorbing #2031 un-blocked a destructive remove that bypassed the #1734
data-loss guards: ensureSourceRegistered's drift path issued
`gbrain sources remove` directly, without the detectAutopilot +
decideSourceRemove checks every other remove routes through via
safeSourcesRemove. gbrain >= 0.42's own prompt was accidentally blocking
that path; with --confirm-destructive passed it is live again.

- Drift remove now refuses LOUDLY (throws, actionable message) while an
  autopilot is active or when decideSourceRemove disallows; a silent
  changed=false would hide the drifted registration.
- decideSourceRemove's extraArgs (--keep-storage when supported) propagate
  to the remove call, matching safeSourcesRemove.
- Drift is realpath-normalized before being declared: a symlink alias of the
  same directory (macOS /tmp -> /private/tmp) is a match, not drift — the
  probable cause of #1985's reporter hitting the remove on an unmoved repo.
- Drift fires a loud stderr line (old -> new path); perpetual drift in logs
  is the trigger for promoting #1985's reindex-in-place design.

Tests: autopilot-active refusal (no remove in call log), fail-closed refusal
on unreadable sources list, --keep-storage propagation, symlink-alias
no-drift; existing drift tests pin the guard probes so a live autopilot on
the dev machine can't flip them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(developer-profile): exclude mode:resources rows from SESSION_COUNT, TIER, NUDGE_ELIGIBLE (#2067)

Every /office-hours run appends a mode:"resources" bookkeeping row alongside
the real session row, so --read double-counted sessions (~2x): tiers promoted
early and the builder-to-founder nudge armed prematurely. The file already
filtered resources rows for LAST_*/CROSS_PROJECT; the same realSessions
filter now feeds SESSION_COUNT/TIER, and the nudge predicate is the faithful
allowlist (mode === 'builder') so a future mode #4 fails closed instead of
re-opening this bug.

8 regression tests: count vs resources noise, tier boundaries both sides,
nudge false-with-noise / true-at-3-builders, cross-project trailing row.

Absorbed from PR #1991 by @mvann (fix + tests commits; the PR's version-bump
commit is superseded by this wave's consolidated release commit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(hooks): passThrough() two-branch contract — never emit permissionDecision:'defer' (#2035, #2006)

Every AskUserQuestion died with "Tool result missing due to internal error"
on current Claude Code builds (Desktop 1.14271.0, CC 2.1.177). Root cause:
the question-preference-hook emitted permissionDecision:'defer' on every
pass-through path. 'defer' is a real PreToolUse value, but since CC v2.1.89
its semantics are "pause this tool call for external resumption" (headless
resume) — never "abstain". Interactive sessions have nothing to resume the
paused call, so the tool orphaned. Pre-2.1.89 builds ignored the unknown
value, which is why the hook worked when it shipped and broke later.

The fix is the two-branch pass-through contract:
- no context -> exit 0 with EXACTLY empty stdout
- memory nuggets present -> hookSpecificOutput with hookEventName +
  additionalContext ONLY (the documented shape; plan-tune Layer 8 memory
  injection ships through this branch and keeps working)

defer() is renamed passThrough() so the function says what it does, and
docs/spikes/claude-code-hook-mutation.md's protocol contract (cited by the
hook header) is corrected in the same commit — it taught '"defer" — let
permission flow continue' and was the reintroduction vector.

Test contract rewritten in the same commit (13 assertions across 3 files,
verified fail-first against the unfixed hook): pass-through paths assert
exact-empty stdout (a garbage/partial write cannot slip past an
optional-chained parse), the nugget path asserts permissionDecision is
ABSENT while additionalContext survives, and a new tripwire asserts no
non-deny path ever puts the string "permissionDecision" on stdout. The
deny (auto-decide) and Conductor prose-redirect paths are unchanged.

Deployment: no migration needed — settings.json points at the absolute
bash shim which execs the .ts live; /gstack-upgrade delivers the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(one-way-doors): unify credential noun net + wire it into the runtime (#2024)

Library fix: revoke/reset/rotate now share ONE noun alternation (api key,
token, secret, credential, access key, password) with optional plural s?.
Pre-fix leaks: "reset my secret", "reset my access key", "revoke my secret"
(mismatched per-verb lists) and every plural form ("rotate the credentials",
"revoke all tokens" — \b(...)\b cannot match a trailing s).

Runtime wiring — the regexes could never fire in production before:
- gstack-question-preference --check gains --summary-stdin: the question
  text pipes via stdin (never argv — summaries carry quotes/newlines/shell
  metacharacters) and feeds isOneWayDoor alongside the id, so an ad-hoc
  destructive question with a stored never-ask preference now forces
  ASK_NORMALLY. Empty/absent stdin keeps exact id-only semantics.
- question-preference-hook falls back to classifyQuestion(question text)
  when the registry lookup misses, so unregistered destructive questions
  pass through to a human instead of auto-deciding.
- question-tuning resolver prose shows the piped form (SKILL.md regen lands
  in the wave's release commit).

Tripwires (verified fail-first): full verbs x nouns x singular/plural matrix
with the #2024 repro rows, benign-summary no-over-match rows, stdin
transport survival (quotes/newlines), empty-stdin fail-safe, and hook
fallback both directions (destructive -> pass-through, benign -> deny).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(design): loud integer-flag contract for --count/--retry/--timeout (#2032)

design variants --count abc silently generated ZERO variants and exited 0:
parseInt(NaN) flowed through Math.min into the generation loop bound. The
same NaN class was live on the two sibling flags in the same file:
--retry abc made generate() a silent no-op (attempt <= NaN never true, null
output, exit 0) and --timeout abc killed the serve board ~immediately
(setTimeout(NaN)).

New design/src/flag-utils.ts: parseIntFlag (pure, unit-testable) +
normalizeIntFlag (CLI wrapper). Contract matches the --viewports precedent
(error loudly on nonsense — these commands spend real image-API money, a
silent fixup hides typos from calling agents): undefined -> default; bare
flag/empty/non-integer ("3.7" rejected, not truncated)/below-min -> exit 1
with usage hint; above-max -> clamp with stderr warning. --count normalizes
at the variants() consumption site so programmatic callers are covered, with
the ceiling derived from STYLE_VARIATIONS.length instead of a magic 7; the
CLI passes the raw flag through (a pre-parseInt would truncate "3.7").

Tripwires live in test/design-flag-utils.test.ts — deliberately under test/,
not design/test/, which is invisible to the bun test glob, TEST_ROOTS, and
every workflow (wiring design/test/ into CI is a captured TODO).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gbrain): thin-client state — remote-MCP brains no longer classify as broken-config (#2051)

A thin client (remote-HTTP MCP brain, no local engine by design) probed
`gbrain sources list`, which gbrain's dispatch guard REFUSES on thin clients
(exit 1, no recognized error string), so the classifier fell to its
defensive broken-config default and every suppression gate silently hid
brain-aware blocks from exactly the users on a shared team brain.

New 'thin-client' state, detected PRE-probe from gbrain's own remote_mcp
config marker via the existing gbrainConfigPath() helper (mirrors gbrain's
isThinClient(); honors GBRAIN_HOME; zero network, immune to error-string
drift), with a /thin[- ]client/ stderr backstop in the probe catch. Remote
reachability is deliberately NOT probed by the classifier — that is the
#1964 pathology; gbrain calls degrade gracefully at use time, and the detect
JSON says so honestly (gbrain_thin_client: {probed: false}).

The state is admitted at every suppression gate — gstack-gbrain-detect
--is-ok (drives setup + gbrain-refresh), gen-skill-docs' detection override,
gstack-config gbrain-refresh — while the sync stages (code/memory/dream)
SKIP with an accurate reason: code indexing runs on the brain server, memory
syncs via the remote brain's artifacts pull. The two consumer classes need
opposite answers, which is why this is a distinct state and not a
skip-the-probe special case. sync-gbrain Step 1.5 and setup-gbrain prose
route thin-client to proceed, never into broken-config remediation.

detectMcpMode secondary generalization: url-match against the config's
remote_mcp.mcp_url (deterministic — gbrain mounts at the generic /mcp path)
-> name pattern gbrain[-_]* -> stdio command token; gbrain_mcp_mode stays a
3-value enum.

Tripwires: end-to-end --is-ok exits 0 on a thin-client fixture AND still
exits 1 on broken-config (the gate didn't widen); pre-probe + stderr-fallback
classifier paths; 4 detectMcpMode identification cases incl. a non-matching
url that must NOT false-positive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* release: v1.60.0.0 — regen SKILL.md, VERSION, CHANGELOG, TODOS follow-ups

- Regenerate all SKILL.md from templates (question-tuning --summary-stdin
  prose from #2024, context-restore branch preference from PR #2054,
  sync-gbrain/setup-gbrain thin-client prose from #2051) + llms.txt.
- VERSION + package.json -> 1.60.0.0 (bin/gstack-next-version, queue-aware:
  #1815 claims 1.59.0.0, #2213 claims 1.59.1.0).
- CHANGELOG release summary + itemized entry crediting @jbetala7 (x3) and
  @mvann.
- TODOS.md: three eng-review follow-ups (design/test CI wiring + documented
  pre-existing retry-after flake, /context-save worktree identity, gbrain
  reindex-in-place conditional on the new drift log).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(resolvers): compress --summary-stdin preamble prose to fit parity budget; re-bless ship goldens

The v1.57.7.0 parity suite caps investigate's generated size at 1.09x
baseline; the #2024 question-tuning prose (duplicated into every tier->=2
skill) tipped it to 1.092. Compressed to a single inline command + short
pointer (the full rationale lives in bin/gstack-question-preference's
header and the one-way-doors module docs). Ship goldens re-blessed against
the final resolver text (conscious template-change acknowledgment, per the
golden-file regression contract).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(e2e): office-hours-spec-review turn budget fits the carved skill layout (#2473)

The test failed deterministically with error_max_turns at 9 turns on main
and this branch alike (CI attempt logs + local main repro). Root cause from
the failing transcript: the Spec Review Loop content is carved out of
office-hours/SKILL.md into office-hours/sections/, so the agent needs
discovery hops (grep SKILL.md -> ls sections/ -> read the section) before it
can write — 8 tool turns + the closing text turn = 9 > the 8-turn budget,
which predates the carve. Observed failures wrote a CORRECT summary on tool
turn 8 and died on the closing turn.

maxTurns 8 -> 12. Verified: PASS locally post-fix (7 turns this run — the
extra headroom absorbs discovery-path nondeterminism).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(e2e): review-dashboard-via session budget survives runner contention (#2473)

The test failed on CI (and its baseline run) with the timeout signature:
0 turns, $0.00, exactly 183s, 3/3 attempts — the spawned claude -p session
never emitted a single stream event before the 180s inner timeout. The
file's tests run concurrently on one runner; session startup queues behind
sibling sessions, and this test had the tightest budget in the file (the
240s-budget tests in the same job passed). A clean local run takes 270s
wall for 4 turns, confirming 180s was too tight even without contention.

Inner timeout 180s -> 300s; outer bun timeout 240s -> 360s to keep headroom
over the inner budget. Verified: PASS locally post-fix (4 turns, 270s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(e2e): retro-base-branch session budget survives runner contention (#2473)

Same class as review-dashboard-via, one test over in the same file: /retro
is a long multi-step flow whose clean pass measures 225-239s — a coin flip
against the 240s inner budget. First CI run passed at 225s; the rerun timed
out at the 240s line on all 3 attempts (exitReason "timeout"); the local
verification run passed at 239s, ONE second under the old cap.

Inner timeout 240s -> 360s; outer bun timeout 300s -> 480s for headroom.
Verified: PASS locally post-fix (17 turns, 239s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Michael Vann <9221873+mvann@users.noreply.github.com>
2026-08-08 09:28:45 -07:00

869 lines
36 KiB
TypeScript

import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { runSkillTest } from './helpers/session-runner';
import {
ROOT, browseBin, runId, evalsEnabled,
describeIfSelected, testConcurrentIfSelected,
copyDirSync, setupBrowseShims, logCost, recordE2E,
createEvalCollector, finalizeEvalCollector,
} from './helpers/e2e-helpers';
import { judgePosture } from './helpers/llm-judge';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
const evalCollector = createEvalCollector('e2e-plan');
// --- Plan CEO Review E2E ---
describeIfSelected('Plan CEO Review E2E', ['plan-ceo-review'], () => {
let planDir: string;
beforeAll(() => {
planDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-plan-ceo-'));
const run = (cmd: string, args: string[]) =>
spawnSync(cmd, args, { cwd: planDir, stdio: 'pipe', timeout: 5000 });
// Init git repo (CEO review SKILL.md has a "System Audit" step that runs git)
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 'test@test.com']);
run('git', ['config', 'user.name', 'Test']);
// Create a simple plan document for the agent to review
fs.writeFileSync(path.join(planDir, 'plan.md'), `# Plan: Add User Dashboard
## Context
We're building a new user dashboard that shows recent activity, notifications, and quick actions.
## Changes
1. New React component \`UserDashboard\` in \`src/components/\`
2. REST API endpoint \`GET /api/dashboard\` returning user stats
3. PostgreSQL query for activity aggregation
4. Redis cache layer for dashboard data (5min TTL)
## Architecture
- Frontend: React + TailwindCSS
- Backend: Express.js REST API
- Database: PostgreSQL with existing user/activity tables
- Cache: Redis for dashboard aggregates
## Open questions
- Should we use WebSocket for real-time updates?
- How do we handle users with 100k+ activity records?
`);
run('git', ['add', '.']);
run('git', ['commit', '-m', 'add plan']);
// Copy plan-ceo-review skill
fs.mkdirSync(path.join(planDir, 'plan-ceo-review'), { recursive: true });
fs.copyFileSync(
path.join(ROOT, 'plan-ceo-review', 'SKILL.md'),
path.join(planDir, 'plan-ceo-review', 'SKILL.md'),
);
// Carved skills (v2 plan T9): copy sections/ so the review workflow + report template are present.
{ const _sec = path.join(ROOT, 'plan-ceo-review', 'sections'); if (fs.existsSync(_sec)) fs.cpSync(_sec, path.join(planDir, 'plan-ceo-review', 'sections'), { recursive: true }); }
});
afterAll(() => {
try { fs.rmSync(planDir, { recursive: true, force: true }); } catch {}
});
testConcurrentIfSelected('plan-ceo-review', async () => {
const result = await runSkillTest({
prompt: `Read plan-ceo-review/SKILL.md for the review workflow.
Read plan.md — that's the plan to review. This is a standalone plan document, not a codebase — skip any codebase exploration or system audit steps.
Choose HOLD SCOPE mode. Skip any AskUserQuestion calls — this is non-interactive.
Write your complete review directly to ${planDir}/review-output.md
Focus on reviewing the plan content: architecture, error handling, security, and performance.`,
workingDirectory: planDir,
maxTurns: 15,
timeout: 360_000,
testName: 'plan-ceo-review',
runId,
model: 'claude-opus-4-7',
});
logCost('/plan-ceo-review', result);
recordE2E(evalCollector, '/plan-ceo-review', 'Plan CEO Review E2E', result, {
passed: ['success', 'error_max_turns'].includes(result.exitReason),
});
// Accept error_max_turns — the CEO review is very thorough and may exceed turns
expect(['success', 'error_max_turns']).toContain(result.exitReason);
// Verify the review was written
const reviewPath = path.join(planDir, 'review-output.md');
if (fs.existsSync(reviewPath)) {
const review = fs.readFileSync(reviewPath, 'utf-8');
expect(review.length).toBeGreaterThan(200);
}
}, 420_000);
});
// --- Plan CEO Review (SELECTIVE EXPANSION) E2E ---
describeIfSelected('Plan CEO Review SELECTIVE EXPANSION E2E', ['plan-ceo-review-selective'], () => {
let planDir: string;
beforeAll(() => {
planDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-plan-ceo-sel-'));
const run = (cmd: string, args: string[]) =>
spawnSync(cmd, args, { cwd: planDir, stdio: 'pipe', timeout: 5000 });
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 'test@test.com']);
run('git', ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(planDir, 'plan.md'), `# Plan: Add User Dashboard
## Context
We're building a new user dashboard that shows recent activity, notifications, and quick actions.
## Changes
1. New React component \`UserDashboard\` in \`src/components/\`
2. REST API endpoint \`GET /api/dashboard\` returning user stats
3. PostgreSQL query for activity aggregation
4. Redis cache layer for dashboard data (5min TTL)
## Architecture
- Frontend: React + TailwindCSS
- Backend: Express.js REST API
- Database: PostgreSQL with existing user/activity tables
- Cache: Redis for dashboard aggregates
## Open questions
- Should we use WebSocket for real-time updates?
- How do we handle users with 100k+ activity records?
`);
run('git', ['add', '.']);
run('git', ['commit', '-m', 'add plan']);
fs.mkdirSync(path.join(planDir, 'plan-ceo-review'), { recursive: true });
fs.copyFileSync(
path.join(ROOT, 'plan-ceo-review', 'SKILL.md'),
path.join(planDir, 'plan-ceo-review', 'SKILL.md'),
);
// Carved skills (v2 plan T9): copy sections/ so the review workflow + report template are present.
{ const _sec = path.join(ROOT, 'plan-ceo-review', 'sections'); if (fs.existsSync(_sec)) fs.cpSync(_sec, path.join(planDir, 'plan-ceo-review', 'sections'), { recursive: true }); }
});
afterAll(() => {
try { fs.rmSync(planDir, { recursive: true, force: true }); } catch {}
});
testConcurrentIfSelected('plan-ceo-review-selective', async () => {
const result = await runSkillTest({
prompt: `Read plan-ceo-review/SKILL.md for the review workflow.
Read plan.md — that's the plan to review. This is a standalone plan document, not a codebase — skip any codebase exploration or system audit steps.
Choose SELECTIVE EXPANSION mode. Skip any AskUserQuestion calls — this is non-interactive.
For the cherry-pick ceremony, accept all expansion proposals automatically.
Write your complete review directly to ${planDir}/review-output-selective.md
Focus on reviewing the plan content: architecture, error handling, security, and performance.`,
workingDirectory: planDir,
maxTurns: 15,
timeout: 360_000,
testName: 'plan-ceo-review-selective',
runId,
model: 'claude-opus-4-7',
});
logCost('/plan-ceo-review (SELECTIVE)', result);
recordE2E(evalCollector, '/plan-ceo-review-selective', 'Plan CEO Review SELECTIVE EXPANSION E2E', result, {
passed: ['success', 'error_max_turns'].includes(result.exitReason),
});
expect(['success', 'error_max_turns']).toContain(result.exitReason);
const reviewPath = path.join(planDir, 'review-output-selective.md');
if (fs.existsSync(reviewPath)) {
const review = fs.readFileSync(reviewPath, 'utf-8');
expect(review.length).toBeGreaterThan(200);
}
}, 420_000);
});
// --- Plan CEO Review SCOPE EXPANSION energy (V1.1 mode-posture regression gate) ---
describeIfSelected('Plan CEO Review Expansion Energy E2E', ['plan-ceo-review-expansion-energy'], () => {
let planDir: string;
beforeAll(() => {
planDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-plan-ceo-exp-'));
const run = (cmd: string, args: string[]) =>
spawnSync(cmd, args, { cwd: planDir, stdio: 'pipe', timeout: 5000 });
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 'test@test.com']);
run('git', ['config', 'user.name', 'Test']);
// Use the shared fixture so expansion-energy regressions are reproducible.
const fixture = fs.readFileSync(
path.join(ROOT, 'test', 'fixtures', 'mode-posture', 'expansion-plan.md'),
'utf-8',
);
fs.writeFileSync(path.join(planDir, 'plan.md'), fixture);
run('git', ['add', '.']);
run('git', ['commit', '-m', 'add plan']);
fs.mkdirSync(path.join(planDir, 'plan-ceo-review'), { recursive: true });
fs.copyFileSync(
path.join(ROOT, 'plan-ceo-review', 'SKILL.md'),
path.join(planDir, 'plan-ceo-review', 'SKILL.md'),
);
// Carved skills (v2 plan T9): copy sections/ so the review workflow + report template are present.
{ const _sec = path.join(ROOT, 'plan-ceo-review', 'sections'); if (fs.existsSync(_sec)) fs.cpSync(_sec, path.join(planDir, 'plan-ceo-review', 'sections'), { recursive: true }); }
});
afterAll(() => {
try { fs.rmSync(planDir, { recursive: true, force: true }); } catch {}
});
testConcurrentIfSelected('plan-ceo-review-expansion-energy', async () => {
const result = await runSkillTest({
prompt: `Read plan-ceo-review/SKILL.md for the review workflow.
Read plan.md — that's the plan to review. This is a standalone plan document, not a codebase — skip any codebase exploration or system audit steps.
Choose SCOPE EXPANSION mode. Skip any AskUserQuestion calls — this is non-interactive. Auto-approve the ideal-architecture approach in 0C-bis. For 0D, run all three analyses (10x check, platonic ideal, delight opportunities), then emit exactly 2 concrete expansion proposals in the opt-in ceremony.
Write your expansion proposals to ${planDir}/proposals.md with ONLY the proposal text — no conversational wrapper, no review summary, no mode analysis. Each proposal separated by "---".`,
workingDirectory: planDir,
maxTurns: 15,
timeout: 360_000,
testName: 'plan-ceo-review-expansion-energy',
runId,
model: 'claude-opus-4-7',
});
logCost('/plan-ceo-review (EXPANSION ENERGY)', result);
recordE2E(evalCollector, '/plan-ceo-review-expansion-energy', 'Plan CEO Review Expansion Energy E2E', result, {
passed: ['success', 'error_max_turns'].includes(result.exitReason),
});
// Transient API failure escape hatch — see /plan-review-report for the
// full rationale. Same shape: error_api with 0 turns means the API call
// never reached the model, so nothing the test verifies could have run.
if (result.exitReason === 'error_api' && result.costEstimate?.turnsUsed === 0) {
console.warn('[transient] /plan-ceo-review-expansion-energy: error_api with 0 turns — treating as inconclusive');
return;
}
expect(['success', 'error_max_turns']).toContain(result.exitReason);
const proposalsPath = path.join(planDir, 'proposals.md');
if (!fs.existsSync(proposalsPath)) {
throw new Error('Agent did not emit proposals.md — expansion energy eval requires proposal output');
}
const proposalText = fs.readFileSync(proposalsPath, 'utf-8');
expect(proposalText.length).toBeGreaterThan(200);
const scores = await judgePosture('expansion', proposalText);
console.log('Expansion energy scores:', JSON.stringify(scores, null, 2));
// Pass threshold: 4/5 on both axes (good — matches posture with minor weakness).
expect(scores.axis_a).toBeGreaterThanOrEqual(4); // surface_framing
expect(scores.axis_b).toBeGreaterThanOrEqual(4); // decision_preservation
}, 600_000);
});
// --- Plan Eng Review E2E ---
describeIfSelected('Plan Eng Review E2E', ['plan-eng-review'], () => {
let planDir: string;
beforeAll(() => {
planDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-plan-eng-'));
const run = (cmd: string, args: string[]) =>
spawnSync(cmd, args, { cwd: planDir, stdio: 'pipe', timeout: 5000 });
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 'test@test.com']);
run('git', ['config', 'user.name', 'Test']);
// Create a plan with more engineering detail
fs.writeFileSync(path.join(planDir, 'plan.md'), `# Plan: Migrate Auth to JWT
## Context
Replace session-cookie auth with JWT tokens. Currently using express-session + Redis store.
## Changes
1. Add \`jsonwebtoken\` package
2. New middleware \`auth/jwt-verify.ts\` replacing \`auth/session-check.ts\`
3. Login endpoint returns { accessToken, refreshToken }
4. Refresh endpoint rotates tokens
5. Migration script to invalidate existing sessions
## Files Modified
| File | Change |
|------|--------|
| auth/jwt-verify.ts | NEW: JWT verification middleware |
| auth/session-check.ts | DELETED |
| routes/login.ts | Return JWT instead of setting cookie |
| routes/refresh.ts | NEW: Token refresh endpoint |
| middleware/index.ts | Swap session-check for jwt-verify |
## Error handling
- Expired token: 401 with \`token_expired\` code
- Invalid token: 401 with \`invalid_token\` code
- Refresh with revoked token: 403
## Not in scope
- OAuth/OIDC integration
- Rate limiting on refresh endpoint
`);
run('git', ['add', '.']);
run('git', ['commit', '-m', 'add plan']);
// Copy plan-eng-review skill
fs.mkdirSync(path.join(planDir, 'plan-eng-review'), { recursive: true });
fs.copyFileSync(
path.join(ROOT, 'plan-eng-review', 'SKILL.md'),
path.join(planDir, 'plan-eng-review', 'SKILL.md'),
);
// Carved skills (v2 plan T9): copy sections/ so the review workflow + report template are present.
{ const _sec = path.join(ROOT, 'plan-eng-review', 'sections'); if (fs.existsSync(_sec)) fs.cpSync(_sec, path.join(planDir, 'plan-eng-review', 'sections'), { recursive: true }); }
});
afterAll(() => {
try { fs.rmSync(planDir, { recursive: true, force: true }); } catch {}
});
testConcurrentIfSelected('plan-eng-review', async () => {
const result = await runSkillTest({
prompt: `Read plan-eng-review/SKILL.md for the review workflow.
Read plan.md — that's the plan to review. This is a standalone plan document, not a codebase — skip any codebase exploration steps.
Proceed directly to the full review. Skip any AskUserQuestion calls — this is non-interactive.
Write your complete review directly to ${planDir}/review-output.md
Focus on architecture, code quality, tests, and performance sections.`,
workingDirectory: planDir,
maxTurns: 15,
timeout: 360_000,
testName: 'plan-eng-review',
runId,
model: 'claude-opus-4-7',
});
logCost('/plan-eng-review', result);
recordE2E(evalCollector, '/plan-eng-review', 'Plan Eng Review E2E', result, {
passed: ['success', 'error_max_turns'].includes(result.exitReason),
});
expect(['success', 'error_max_turns']).toContain(result.exitReason);
// Verify the review was written
const reviewPath = path.join(planDir, 'review-output.md');
if (fs.existsSync(reviewPath)) {
const review = fs.readFileSync(reviewPath, 'utf-8');
expect(review.length).toBeGreaterThan(200);
}
}, 420_000);
});
// --- Plan-Eng-Review Test-Plan Artifact E2E ---
describeIfSelected('Plan-Eng-Review Test-Plan Artifact E2E', ['plan-eng-review-artifact'], () => {
let planDir: string;
let projectDir: string;
beforeAll(() => {
planDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-plan-artifact-'));
const run = (cmd: string, args: string[]) =>
spawnSync(cmd, args, { cwd: planDir, stdio: 'pipe', timeout: 5000 });
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 'test@test.com']);
run('git', ['config', 'user.name', 'Test']);
// Create base commit on main
fs.writeFileSync(path.join(planDir, 'app.ts'), 'export function greet() { return "hello"; }\n');
run('git', ['add', '.']);
run('git', ['commit', '-m', 'initial']);
// Create feature branch with changes
run('git', ['checkout', '-b', 'feature/add-dashboard']);
fs.writeFileSync(path.join(planDir, 'dashboard.ts'), `export function Dashboard() {
const data = fetchStats();
return { users: data.users, revenue: data.revenue };
}
function fetchStats() {
return fetch('/api/stats').then(r => r.json());
}
`);
fs.writeFileSync(path.join(planDir, 'app.ts'), `import { Dashboard } from "./dashboard";
export function greet() { return "hello"; }
export function main() { return Dashboard(); }
`);
run('git', ['add', '.']);
run('git', ['commit', '-m', 'feat: add dashboard']);
// Plan document
fs.writeFileSync(path.join(planDir, 'plan.md'), `# Plan: Add Dashboard
## Changes
1. New \`dashboard.ts\` with Dashboard component and fetchStats API call
2. Updated \`app.ts\` to import and use Dashboard
## Architecture
- Dashboard fetches from \`/api/stats\` endpoint
- Returns user count and revenue metrics
`);
run('git', ['add', 'plan.md']);
run('git', ['commit', '-m', 'add plan']);
// Copy plan-eng-review skill
fs.mkdirSync(path.join(planDir, 'plan-eng-review'), { recursive: true });
fs.copyFileSync(
path.join(ROOT, 'plan-eng-review', 'SKILL.md'),
path.join(planDir, 'plan-eng-review', 'SKILL.md'),
);
// Carved skills (v2 plan T9): copy sections/ so the review workflow + report template are present.
{ const _sec = path.join(ROOT, 'plan-eng-review', 'sections'); if (fs.existsSync(_sec)) fs.cpSync(_sec, path.join(planDir, 'plan-eng-review', 'sections'), { recursive: true }); }
// Set up remote-slug shim and browse shims (plan-eng-review uses remote-slug for artifact path)
setupBrowseShims(planDir);
// Create project directory for artifacts
projectDir = path.join(os.homedir(), '.gstack', 'projects', 'test-project');
fs.mkdirSync(projectDir, { recursive: true });
// Clean up stale test-plan files from previous runs
try {
const staleFiles = fs.readdirSync(projectDir).filter(f => f.includes('test-plan'));
for (const f of staleFiles) {
fs.unlinkSync(path.join(projectDir, f));
}
} catch {}
});
afterAll(() => {
try { fs.rmSync(planDir, { recursive: true, force: true }); } catch {}
// Clean up test-plan artifacts (but not the project dir itself)
try {
const files = fs.readdirSync(projectDir);
for (const f of files) {
if (f.includes('test-plan')) {
fs.unlinkSync(path.join(projectDir, f));
}
}
} catch {}
});
testConcurrentIfSelected('plan-eng-review-artifact', async () => {
// Count existing test-plan files before
const beforeFiles = fs.readdirSync(projectDir).filter(f => f.includes('test-plan'));
const result = await runSkillTest({
prompt: `Read plan-eng-review/SKILL.md for the review workflow.
Skip the preamble bash block, lake intro, telemetry, and contributor mode sections — go straight to the review.
Read plan.md — that's the plan to review. This is a standalone plan with source code in app.ts and dashboard.ts.
Proceed directly to the full review. Skip any AskUserQuestion calls — this is non-interactive.
IMPORTANT: After your review, you MUST write the test-plan artifact as described in the "Test Plan Artifact" section of SKILL.md. The remote-slug shim is at ${planDir}/browse/bin/remote-slug.
Write your review to ${planDir}/review-output.md`,
workingDirectory: planDir,
maxTurns: 25,
allowedTools: ['Bash', 'Read', 'Write', 'Glob', 'Grep'],
timeout: 360_000,
testName: 'plan-eng-review-artifact',
runId,
model: 'claude-opus-4-7',
});
logCost('/plan-eng-review artifact', result);
recordE2E(evalCollector, '/plan-eng-review test-plan artifact', 'Plan-Eng-Review Test-Plan Artifact E2E', result, {
passed: ['success', 'error_max_turns'].includes(result.exitReason),
});
expect(['success', 'error_max_turns']).toContain(result.exitReason);
// Verify test-plan artifact was written
const afterFiles = fs.readdirSync(projectDir).filter(f => f.includes('test-plan'));
const newFiles = afterFiles.filter(f => !beforeFiles.includes(f));
console.log(`Test-plan artifacts: ${beforeFiles.length} before, ${afterFiles.length} after, ${newFiles.length} new`);
if (newFiles.length > 0) {
const content = fs.readFileSync(path.join(projectDir, newFiles[0]), 'utf-8');
console.log(`Test-plan artifact (${newFiles[0]}): ${content.length} chars`);
expect(content.length).toBeGreaterThan(50);
} else {
console.warn('No test-plan artifact found — agent may not have followed artifact instructions');
}
// Soft assertion: we expect an artifact but agent compliance is not guaranteed.
// Log rather than fail — the test-plan artifact is a bonus output, not the core test.
if (newFiles.length === 0) {
console.warn('SOFT FAIL: No test-plan artifact written — agent did not follow artifact instructions');
}
}, 420_000);
});
// --- Office Hours Spec Review E2E ---
describeIfSelected('Office Hours Spec Review E2E', ['office-hours-spec-review'], () => {
let ohDir: string;
beforeAll(() => {
ohDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-oh-spec-'));
const run = (cmd: string, args: string[]) =>
spawnSync(cmd, args, { cwd: ohDir, stdio: 'pipe', timeout: 5000 });
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 'test@test.com']);
run('git', ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(ohDir, 'README.md'), '# Test Project\n');
run('git', ['add', '.']);
run('git', ['commit', '-m', 'init']);
// Copy office-hours skill
fs.mkdirSync(path.join(ohDir, 'office-hours'), { recursive: true });
fs.copyFileSync(
path.join(ROOT, 'office-hours', 'SKILL.md'),
path.join(ohDir, 'office-hours', 'SKILL.md'),
);
{ const _sec = path.join(ROOT, 'office-hours', 'sections'); if (fs.existsSync(_sec)) fs.cpSync(_sec, path.join(ohDir, 'office-hours', 'sections'), { recursive: true }); }
});
afterAll(() => {
try { fs.rmSync(ohDir, { recursive: true, force: true }); } catch {}
});
testConcurrentIfSelected('office-hours-spec-review', async () => {
const result = await runSkillTest({
prompt: `Read office-hours/SKILL.md. I want to understand the spec review loop.
Summarize what the "Spec Review Loop" section does — specifically:
1. How many dimensions does the reviewer check?
2. What tool is used to dispatch the reviewer?
3. What's the maximum number of iterations?
4. What metrics are tracked?
Write your summary to ${ohDir}/spec-review-summary.md`,
workingDirectory: ohDir,
// 12, not 8 (#2473): the Spec Review Loop content is CARVED out of
// SKILL.md into office-hours/sections/, so the agent legitimately needs
// discovery hops (grep SKILL.md -> ls sections/ -> read the section)
// before it can write. The 8-turn budget predates the carve — observed
// failures wrote a correct summary on tool-turn 8 and hit the cap on
// the closing text turn (error_max_turns at 9 turns, deterministic).
maxTurns: 12,
timeout: 120_000,
testName: 'office-hours-spec-review',
runId,
});
logCost('/office-hours spec review', result);
recordE2E(evalCollector, '/office-hours-spec-review', 'Office Hours Spec Review E2E', result);
expect(result.exitReason).toBe('success');
const summaryPath = path.join(ohDir, 'spec-review-summary.md');
if (fs.existsSync(summaryPath)) {
const summary = fs.readFileSync(summaryPath, 'utf-8').toLowerCase();
expect(summary).toMatch(/5.*dimension|dimension.*5|completeness|consistency|clarity|scope|feasibility/);
expect(summary).toMatch(/agent|subagent/);
expect(summary).toMatch(/3.*iteration|iteration.*3|maximum.*3/);
}
}, 180_000);
});
// --- Plan CEO Review Benefits-From E2E ---
describeIfSelected('Plan CEO Review Benefits-From E2E', ['plan-ceo-review-benefits'], () => {
let benefitsDir: string;
beforeAll(() => {
benefitsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-benefits-'));
const run = (cmd: string, args: string[]) =>
spawnSync(cmd, args, { cwd: benefitsDir, stdio: 'pipe', timeout: 5000 });
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 'test@test.com']);
run('git', ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(benefitsDir, 'README.md'), '# Test Project\n');
run('git', ['add', '.']);
run('git', ['commit', '-m', 'init']);
fs.mkdirSync(path.join(benefitsDir, 'plan-ceo-review'), { recursive: true });
fs.copyFileSync(
path.join(ROOT, 'plan-ceo-review', 'SKILL.md'),
path.join(benefitsDir, 'plan-ceo-review', 'SKILL.md'),
);
{ const _sec = path.join(ROOT, 'plan-ceo-review', 'sections'); if (fs.existsSync(_sec)) fs.cpSync(_sec, path.join(benefitsDir, 'plan-ceo-review', 'sections'), { recursive: true }); }
});
afterAll(() => {
try { fs.rmSync(benefitsDir, { recursive: true, force: true }); } catch {}
});
testConcurrentIfSelected('plan-ceo-review-benefits', async () => {
const result = await runSkillTest({
prompt: `Read plan-ceo-review/SKILL.md. Search for sections about "Prerequisite" or "office-hours" or "design doc found".
Summarize what happens when no design doc is found — specifically:
1. Is /office-hours offered as a prerequisite?
2. What options does the user get?
3. Is there a mid-session detection for when the user seems lost?
Write your summary to ${benefitsDir}/benefits-summary.md`,
workingDirectory: benefitsDir,
maxTurns: 8,
timeout: 120_000,
testName: 'plan-ceo-review-benefits',
runId,
});
logCost('/plan-ceo-review benefits-from', result);
recordE2E(evalCollector, '/plan-ceo-review-benefits', 'Plan CEO Review Benefits-From E2E', result);
expect(result.exitReason).toBe('success');
const summaryPath = path.join(benefitsDir, 'benefits-summary.md');
if (fs.existsSync(summaryPath)) {
const summary = fs.readFileSync(summaryPath, 'utf-8').toLowerCase();
expect(summary).toMatch(/office.hours/);
expect(summary).toMatch(/design doc|no design/i);
}
}, 180_000);
});
// --- Plan Review Report E2E ---
// Verifies that plan-eng-review writes a "## GSTACK REVIEW REPORT" section
// to the bottom of the plan file (the living review status footer).
describeIfSelected('Plan Review Report E2E', ['plan-review-report'], () => {
let planDir: string;
beforeAll(() => {
planDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-review-report-'));
const run = (cmd: string, args: string[]) =>
spawnSync(cmd, args, { cwd: planDir, stdio: 'pipe', timeout: 5000 });
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 'test@test.com']);
run('git', ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(planDir, 'plan.md'), `# Plan: Add Notifications System
## Context
We're building a real-time notification system for our SaaS app.
## Changes
1. WebSocket server for push notifications
2. Notification preferences API
3. Email digest fallback for offline users
4. PostgreSQL table for notification storage
## Architecture
- WebSocket: Socket.io on Express
- Queue: Bull + Redis for email digests
- Storage: PostgreSQL notifications table
- Frontend: React toast component
## Open questions
- Retry policy for failed WebSocket delivery?
- Max notifications stored per user?
`);
run('git', ['add', '.']);
run('git', ['commit', '-m', 'add plan']);
// Copy plan-eng-review skill
fs.mkdirSync(path.join(planDir, 'plan-eng-review'), { recursive: true });
fs.copyFileSync(
path.join(ROOT, 'plan-eng-review', 'SKILL.md'),
path.join(planDir, 'plan-eng-review', 'SKILL.md'),
);
// Carved skills (v2 plan T9): copy sections/ so the review workflow + report template are present.
{ const _sec = path.join(ROOT, 'plan-eng-review', 'sections'); if (fs.existsSync(_sec)) fs.cpSync(_sec, path.join(planDir, 'plan-eng-review', 'sections'), { recursive: true }); }
});
afterAll(() => {
try { fs.rmSync(planDir, { recursive: true, force: true }); } catch {}
});
test('/plan-eng-review writes GSTACK REVIEW REPORT to plan file', async () => {
const result = await runSkillTest({
prompt: `Read plan-eng-review/SKILL.md for the review workflow.
Read plan.md — that's the plan to review. This is a standalone plan document, not a codebase — skip any codebase exploration steps.
Proceed directly to the full review. Skip any AskUserQuestion calls — this is non-interactive.
Skip the preamble bash block, lake intro, telemetry, and contributor mode sections.
CRITICAL REQUIREMENT: plan.md IS the plan file for this review session. After completing your review, you MUST write a "## GSTACK REVIEW REPORT" section to the END of plan.md, exactly as described in the "Plan File Review Report" section of SKILL.md. If gstack-review-read is not available or returns NO_REVIEWS, write the placeholder table with all five review rows (CEO, Codex, Eng, Design, DX). The report MUST end with the mandatory unresolved-decisions status as its final line — the exact unbolded line NO UNRESOLVED DECISIONS when nothing is open, or a "**UNRESOLVED DECISIONS:**" block of bullets when items remain. Nothing may follow it. Use the Edit tool to append to plan.md — do NOT overwrite the existing plan content.
This review report at the bottom of the plan is the MOST IMPORTANT deliverable of this test.`,
workingDirectory: planDir,
maxTurns: 20,
timeout: 360_000,
testName: 'plan-review-report',
runId,
model: 'claude-opus-4-7',
});
logCost('/plan-eng-review report', result);
recordE2E(evalCollector, '/plan-review-report', 'Plan Review Report E2E', result, {
passed: ['success', 'error_max_turns'].includes(result.exitReason),
});
// Transient API failure escape hatch: when the SDK returns error_api with
// zero turns / zero tokens, the API call died before the model ever ran —
// no skill code executed, no file was written. Bun retries the test up to
// 3x; if every attempt hits the same API hiccup, surface a warning and
// treat as inconclusive rather than gating the build on Anthropic
// availability. Logic regressions still surface as success/error_max_turns
// with a missing artifact, which the downstream assertions catch.
if (result.exitReason === 'error_api' && result.costEstimate?.turnsUsed === 0) {
console.warn('[transient] /plan-review-report: error_api with 0 turns — treating as inconclusive (likely Anthropic API hiccup, see CLAUDE.md eval-blame protocol)');
return;
}
expect(['success', 'error_max_turns']).toContain(result.exitReason);
// Verify the review report was written to the plan file
const planContent = fs.readFileSync(path.join(planDir, 'plan.md'), 'utf-8');
// Original plan content should still be present
expect(planContent).toContain('# Plan: Add Notifications System');
expect(planContent).toContain('WebSocket');
// Review report section must exist
expect(planContent).toContain('## GSTACK REVIEW REPORT');
// Report should be at the bottom of the file
const reportIndex = planContent.lastIndexOf('## GSTACK REVIEW REPORT');
const afterReport = planContent.slice(reportIndex);
// Should contain the review table with standard rows
expect(afterReport).toMatch(/\|\s*Review\s*\|/);
expect(afterReport).toContain('CEO Review');
expect(afterReport).toContain('Eng Review');
expect(afterReport).toContain('Design Review');
// Mandatory unresolved-decisions status (plan-flag-unresolved-issues): the report's
// final non-whitespace line must be the unresolved status — the exact sentinel or a
// bullet of an UNRESOLVED DECISIONS block, with nothing (CODEX/CROSS-MODEL/VERDICT/
// prose) after it.
expect(afterReport).toContain('UNRESOLVED DECISIONS');
// Compute from afterReport (the report section to EOF), not the whole file, so a
// mid-file report surfaces the real trailing content in the failure message.
const nonEmpty = afterReport.split('\n').map(l => l.trim()).filter(l => l !== '');
const lastLine = nonEmpty[nonEmpty.length - 1];
const isSentinel = lastLine === 'NO UNRESOLVED DECISIONS';
const isUnresolvedBullet =
/^[-*]\s+/.test(lastLine) && !/VERDICT/i.test(lastLine) && afterReport.includes('UNRESOLVED DECISIONS:');
expect(
isSentinel || isUnresolvedBullet,
`report must end with the unresolved-decisions status; last line was: ${lastLine}`,
).toBe(true);
console.log('Plan review report found at bottom of plan.md (ends with unresolved status)');
}, 420_000);
});
// --- Codex Offering E2E ---
// Verifies that Codex is properly offered (with availability check, user prompt,
// and fallback) in office-hours, plan-ceo-review, plan-design-review, plan-eng-review.
describeIfSelected('Codex Offering E2E', [
'codex-offered-office-hours', 'codex-offered-ceo-review',
'codex-offered-design-review', 'codex-offered-eng-review',
], () => {
let testDir: string;
beforeAll(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-codex-offer-'));
const run = (cmd: string, args: string[]) =>
spawnSync(cmd, args, { cwd: testDir, stdio: 'pipe', timeout: 5000 });
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 'test@test.com']);
run('git', ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(testDir, 'README.md'), '# Test Project\n');
run('git', ['add', '.']);
run('git', ['commit', '-m', 'init']);
// Copy all 4 SKILL.md files
for (const skill of ['office-hours', 'plan-ceo-review', 'plan-design-review', 'plan-eng-review']) {
fs.mkdirSync(path.join(testDir, skill), { recursive: true });
fs.copyFileSync(
path.join(ROOT, skill, 'SKILL.md'),
path.join(testDir, skill, 'SKILL.md'),
);
// Carved skills (v2 plan T9): copy sections/ so codex/outside-voice content
// (carved into review-sections.md) is present for the search.
const _sec = path.join(ROOT, skill, 'sections');
if (fs.existsSync(_sec)) fs.cpSync(_sec, path.join(testDir, skill, 'sections'), { recursive: true });
}
});
afterAll(() => {
try { fs.rmSync(testDir, { recursive: true, force: true }); } catch {}
});
async function checkCodexOffering(skill: string, testName: string, featureName: string) {
const result = await runSkillTest({
prompt: `Read ${skill}/SKILL.md. Search for ALL sections related to "codex", "outside voice", or "second opinion".
Summarize the Codex/${featureName} integration — answer these specific questions:
1. How is Codex availability checked? (what exact bash command?)
2. How is the user prompted? (via AskUserQuestion? what are the options?)
3. What happens when Codex is NOT available? (fallback to subagent? skip entirely?)
4. Is this step blocking (gates the workflow) or optional (can be skipped)?
5. What prompt/context is sent to Codex?
Write your summary to ${testDir}/${testName}-summary.md`,
workingDirectory: testDir,
maxTurns: 8,
timeout: 120_000,
testName,
runId,
});
logCost(`/${skill} codex offering`, result);
recordE2E(evalCollector, `/${testName}`, 'Codex Offering E2E', result);
expect(result.exitReason).toBe('success');
const summaryPath = path.join(testDir, `${testName}-summary.md`);
expect(fs.existsSync(summaryPath)).toBe(true);
const summary = fs.readFileSync(summaryPath, 'utf-8').toLowerCase();
// All skills should have codex availability check (command -v per #1197)
expect(summary).toMatch(/command -v codex/);
// All skills should have fallback behavior
expect(summary).toMatch(/fallback|subagent|unavailable|not available|skip/);
// All skills should show it's optional/non-blocking
expect(summary).toMatch(/optional|non.?blocking|skip|not.*required/);
console.log(`${skill}: Codex offering verified`);
}
testConcurrentIfSelected('codex-offered-office-hours', async () => {
await checkCodexOffering('office-hours', 'codex-offered-office-hours', 'second opinion');
}, 180_000);
testConcurrentIfSelected('codex-offered-ceo-review', async () => {
await checkCodexOffering('plan-ceo-review', 'codex-offered-ceo-review', 'outside voice');
}, 180_000);
testConcurrentIfSelected('codex-offered-design-review', async () => {
await checkCodexOffering('plan-design-review', 'codex-offered-design-review', 'design outside voices');
}, 180_000);
testConcurrentIfSelected('codex-offered-eng-review', async () => {
await checkCodexOffering('plan-eng-review', 'codex-offered-eng-review', 'outside voice');
}, 180_000);
});
// Module-level afterAll — finalize eval collector after all tests complete
afterAll(async () => {
await finalizeEvalCollector(evalCollector);
});