aiki-cli 0.3.2 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +64 -4
  3. package/dist/bench/idea-v3-bench.js +104 -5
  4. package/dist/bench/idea-v3-rating.js +158 -3
  5. package/dist/cli/bench.js +5 -5
  6. package/dist/cli/index.js +12 -2
  7. package/dist/cli/resume.js +20 -0
  8. package/dist/cli/run.js +75 -4
  9. package/dist/cli/serve.js +48 -0
  10. package/dist/config/config.js +7 -2
  11. package/dist/council/view.js +13 -4
  12. package/dist/orchestration/auto-profile.js +97 -0
  13. package/dist/orchestration/claim-groups.js +56 -0
  14. package/dist/orchestration/context.js +66 -3
  15. package/dist/orchestration/decision-dossier.js +42 -10
  16. package/dist/orchestration/decision-graph.js +2 -2
  17. package/dist/orchestration/engine.js +8 -4
  18. package/dist/orchestration/evidence-origin.js +17 -0
  19. package/dist/orchestration/jsonStage.js +47 -5
  20. package/dist/orchestration/modes.js +4 -2
  21. package/dist/orchestration/preflight.js +19 -0
  22. package/dist/orchestration/quick-analysis.js +31 -6
  23. package/dist/orchestration/sanitize-paths.js +10 -0
  24. package/dist/orchestration/stages/s10-render.js +31 -7
  25. package/dist/orchestration/stages/s4-analyze.js +7 -1
  26. package/dist/orchestration/stages/s4b-challenge.js +97 -0
  27. package/dist/orchestration/stages/s5-drift.js +13 -3
  28. package/dist/orchestration/stages/s8-verify.js +44 -7
  29. package/dist/orchestration/stages/s9-judge.js +20 -5
  30. package/dist/orchestration/stages/s9b-plan.js +44 -15
  31. package/dist/orchestration/url-sources.js +21 -0
  32. package/dist/providers/adapter-core.js +1 -1
  33. package/dist/providers/claude.js +18 -0
  34. package/dist/schemas/index.js +46 -0
  35. package/dist/serve/flight-deck.js +830 -0
  36. package/dist/serve/followup.js +50 -0
  37. package/dist/serve/frames.js +168 -0
  38. package/dist/serve/gates.js +72 -0
  39. package/dist/serve/projections.js +283 -0
  40. package/dist/serve/server.js +219 -0
  41. package/dist/serve/threads.js +145 -0
  42. package/dist/serve-ui/Five.png +0 -0
  43. package/dist/serve-ui/app.js +820 -0
  44. package/dist/serve-ui/index.html +171 -0
  45. package/dist/serve-ui/workspace.css +662 -0
  46. package/dist/storage/runs.js +2 -1
  47. package/dist/workflows/idea-refinement.js +94 -16
  48. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,73 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased — v7 adaptive deliberation
4
+
5
+ ### Added
6
+ - **`aiki run idea-refinement --mode auto`** — a deterministic quick-vs-council routing decision made
7
+ before any provider call. Escalates to `council` when the prompt supplies URLs or an evidence pack,
8
+ hits a regulated/financial/security topic, requests deliverables beyond a decision, uses research
9
+ wording, or runs long (>120 words); otherwise `quick`. Prints the resolved mode + reason and persists
10
+ it to `meta.auto_decision`. No learned routing (§22): the rules are fixed and inspectable. `meta.mode`
11
+ stays `quick|council`; legacy metadata still parses and explicit modes keep their existing artifacts.
12
+ - **One-call auto fast path (v7 Phase C).** A short DECISION-only request with a clear decision verb
13
+ and subject skips the two model preflight readings and builds the same typed contract deterministically.
14
+ Ambiguous prompts keep the existing quick path; explicit quick/council runs are unchanged. Reports label
15
+ the result: `Single-pass analysis; council escalation was not required.`
16
+ - **Structural auto escalation (v7 Phase D).** Validated analyst output now triggers a deterministic
17
+ failover for unsupported decisive claims, model-knowledge STOP/PIVOT claims, failed calculations,
18
+ contradictory supplied evidence, or load-bearing disagreement. Auto runs use 1–4 calls: two existing
19
+ preflight readings, one primary analysis, and at most one slim claim-only challenger call. The receipt
20
+ records every trigger; explicit quick/council paths remain unchanged.
21
+ - **Per-call token accounting (v7 Phase A).** Every call records normalized input/output token usage —
22
+ provider-reported for Claude, a labeled `chars/4` estimate otherwise — summed into `meta.usage_totals`
23
+ and shown as a `Tokens:` line in the dossier and terminal summary. Purely additive; behavior unchanged.
24
+
25
+ ## Unreleased — v6 council integrity
26
+
27
+ Root-cause pass driven by exact replay of run `20260717-1219` (`plan/AIKI-v6-council-integrity-plan.md`);
28
+ every fix is pinned by tests on the real payloads that failed live (`test/fixtures/f740-*`).
29
+
30
+ ### Fixed
31
+ - **A parseable plan is never discarded again.** The planner's complete feature backlog and
32
+ implementation plan were replaced by "deliverables unavailable" over a 13-character headline
33
+ overflow with no repair budget. `jsonCall` now applies deterministic salvage before failing when
34
+ repair is unavailable; lossy coercion clips over-max strings at a word boundary; empty optional
35
+ strings are dropped losslessly (they carry no information). Partial planner answers are kept and
36
+ flagged `plan_partial` instead of vanishing.
37
+ - **The chair's judgment is no longer overruled by code.** The `PARTIAL/UNVERIFIABLE ⇒ must be
38
+ UNRESOLVED` rule (which flattened 5 nuanced rulings and forced a 125s Opus repair) is deleted;
39
+ ruling HOLDS against positively CONTRADICTED evidence remains fatal.
40
+ - **S4 schema-ceremony repairs are free now.** Empty rationale strings validate via lossless
41
+ coercion (was a 267s paid repair); genuinely lossy defects salvage without a call when the budget
42
+ is tight (repairs stay gated behind a 3-call reserved tail: chair + planner + one tail repair).
43
+ - Chair prose is no longer regex-mangled ("confirmed-unverified" stays the chair's own words);
44
+ enum rewrites apply to UPPERCASE leaks only. A `NOT_COMPUTABLE` payback block is omitted instead
45
+ of announced. Rendered Markdown/HTML never contain local filesystem usernames.
46
+
47
+ ### Added
48
+ - **Local council workspace:** `aiki serve` opens a loopback-only three-pane browser workspace with cached
49
+ provider status, decision history, server-enforced file/URL/spend gates, live SSE stage and call telemetry,
50
+ cancellation, path-free verdict cards, and responsive desktop/mobile layouts.
51
+ - Completed decisions accept one-call follow-up questions labeled `no council`; failed or cancelled runs with
52
+ cached calls can be resumed after an explicit spend review. Settings atomically persist provider model pins
53
+ and role assignments without exposing credentials, local paths, or arbitrary JSON to the browser.
54
+ - A dependency-free hosted replay, real workspace screenshot, and timed three-minute demo script provide a
55
+ safe submission path when judges do not have the three provider CLIs installed.
56
+ - **Semantic claim join at zero extra calls:** the S8 verifier may emit `claim_groups` over an
57
+ anonymous all-claims index; validated groups (unknown ids and single-provider groups dropped)
58
+ overlay CONSENSUS/DISAGREEMENT states through the existing state machine. Cross-provider
59
+ paraphrases — the reason every report said "0 consensus · 0 disputes" — now join.
60
+ - **Evidence origin honesty:** every card is classified EXTERNAL / USER_MATERIAL / MODEL_KNOWLEDGE;
61
+ confidence's evidence-quality term counts external only; the user's own material renders as
62
+ "consistent with your materials (not independently checked)", never VERIFIED; the Sources section
63
+ states how many independent external sources the run actually had.
64
+ - **Blocked-source permission prompt:** when a supplied URL cannot be read, an interactive run now
65
+ asks right in the terminal before any paid call — `Proceed without it? [y/N]` — the same
66
+ allow/deny pattern provider CLIs use for internet or file access. Deny prints how to proceed
67
+ (paste the page text into the prompt, or `--allow-blocked-sources`); headless runs fail fast
68
+ with the same instructions. (The previous behavior burned a full council budget around a 403'd
69
+ page.)
70
+
3
71
  ## 0.3.2 — 2026-07-17
4
72
 
5
73
  ### Added
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
  <img alt="Node ≥ 20" src="https://img.shields.io/badge/node-%E2%89%A5%2020-brightgreen.svg">
13
13
  <img alt="Local-first, no API keys" src="https://img.shields.io/badge/local--first-no%20API%20keys-informational.svg">
14
14
  <img alt="Read-only orchestration" src="https://img.shields.io/badge/orchestration-read--only-success.svg">
15
- <img alt="Tests" src="https://img.shields.io/badge/tests-381%20passing-success.svg">
15
+ <img alt="Tests" src="https://img.shields.io/badge/tests-579%20passing-success.svg">
16
16
  </p>
17
17
 
18
18
  ---
@@ -30,7 +30,21 @@ It does two jobs, well:
30
30
  aiki is **not** a general assistant. Trivia and chat are routed away, not answered — a council adds cost, not
31
31
  accuracy, when there's one right answer.
32
32
 
33
- **Jump to:** [Why](#why) · [What's new](#whats-new-in-030) · [How it works](#how-aiki-works-no-apis) · [Benchmarks](#benchmarks) · [Requirements](#requirements) · [Install](#install) · [Quickstart](#quickstart) · [The two workflows](#the-two-workflows) · [Example](#example-a-real-idea-run) · [Configuration](#configuration) · [Sessions & resume](#sessions--resume) · [Safety](#safety-model) · [Costs & limits](#costs--limits)
33
+ **Jump to:** [Workspace](#local-council-workspace) · [Why](#why) · [What's new](#whats-new-in-030) · [How it works](#how-aiki-works-no-apis) · [Benchmarks](#benchmarks) · [Requirements](#requirements) · [Install](#install) · [Quickstart](#quickstart) · [The two workflows](#the-two-workflows) · [Example](#example-a-real-idea-run) · [Configuration](#configuration) · [Sessions & resume](#sessions--resume) · [Safety](#safety-model) · [Costs & limits](#costs--limits)
34
+
35
+ ---
36
+
37
+ ## Local council workspace
38
+
39
+ Open the live local workspace with:
40
+
41
+ ```bash
42
+ npm i -g aiki-cli && aiki serve
43
+ ```
44
+
45
+ `aiki serve` opens a loopback-only browser workspace with decision history, explicit file/URL/spend approval,
46
+ live council stages, a verdict card, one-call follow-ups, and per-role model settings. It makes no provider
47
+ calls until you approve and convene a run.
34
48
 
35
49
  ---
36
50
 
@@ -52,6 +66,11 @@ stop copy-pasting between them by hand.
52
66
  remaining coverage gaps before the chair.
53
67
  - **Two bounded behaviors.** Use `quick` for one structured analyst or `council` for the full multi-model
54
68
  decision council with source investigation. `research` remains accepted as an alias for `council`.
69
+ `--mode auto` deterministically selects the entry policy from the prompt (URLs, an evidence pack, a
70
+ regulated/financial/security topic, requested deliverables, research wording, or a long input require
71
+ preflight) and prints the one-line reason — never a learned choice. A clear, short decision question starts
72
+ with one call; structural gaps in the typed output add the two readings and, only when useful, one targeted
73
+ challenger call (1–4 total). Use explicit `council` when you want the full multi-model council every time.
55
74
  - **A decision dossier, not an essay.** Reports lead with the recommendation, verified evidence coverage,
56
75
  decisive facts, first action, strongest counter-case, and critical unknowns. Financial and threshold-heavy
57
76
  decisions can include graph-anchored numbers, payback, option commitments, and a go/no-go tripwire.
@@ -180,7 +199,7 @@ Upgrade an existing installation:
180
199
 
181
200
  ```bash
182
201
  npm install -g aiki-cli@latest
183
- aiki --version # 0.3.0
202
+ aiki --version # 0.3.4
184
203
  ```
185
204
 
186
205
  From source:
@@ -197,9 +216,46 @@ npm link # puts `aiki` on your PATH (or run directly: node dist/cl
197
216
 
198
217
  ```bash
199
218
  cd /path/to/your/project
200
- aiki # opens the interactive home screen
219
+ aiki serve # opens the local council workspace in your browser
220
+ aiki # or open the interactive terminal home screen
201
221
  ```
202
222
 
223
+ ## The serve workspace
224
+
225
+ `aiki serve` is the recommended way to use Aiki. It starts a **loopback-only** web workspace (bound to
226
+ `127.0.0.1`, never exposed to your network) and opens it in your browser. **No provider is called until you
227
+ type a request and approve the run** — opening the workspace is free.
228
+
229
+ ```bash
230
+ aiki serve # binds the first free port in 4173–4183 and opens the browser
231
+ aiki serve --port 4180 # pin a specific port
232
+ aiki serve --no-open # start the server but don't auto-open the browser
233
+ ```
234
+
235
+ **What you'll see and do:**
236
+
237
+ 1. **Sessions rail (left).** Every past run, newest first. Click one to reopen its full decision brief;
238
+ runs that were killed or timed out are flagged **Resume**.
239
+ 2. **Composer (center).** Describe an idea to stress-test, or ask for a code review, and press **Enter**.
240
+ Plain text never spends anything on its own — you always get a convene step first.
241
+ 3. **Approval gates (inline).** Before any paid work, Aiki asks — per action — to read a **file**, fetch a
242
+ **URL**, or **spend** provider calls. Each gate offers **allow once**, **allow for this session**, or
243
+ **deny**. Nothing runs until you approve.
244
+ 4. **Live council.** A pipeline timeline shows each stage; the provider seat cards (Claude, Codex, Gemini)
245
+ light up as they work, with a running **call counter and budget bar** so you always see the spend.
246
+ 5. **Verdict card.** When the council finishes: a plain-language banner (go / proceed-with-conditions /
247
+ stop / inconclusive), a **confidence meter**, the anchored validation plan, expandable reasoning and
248
+ evidence, and a **Copy summary** button.
249
+ 6. **Cost receipt.** Per-provider call counts plus the token/cost receipt for the run, against your budget.
250
+ 7. **Follow-ups.** After a run completes you can ask a follow-up question — answered in **exactly one
251
+ read-only call, no new council** — or **Re-convene** to run a fresh full council on a revised question.
252
+ 8. **Settings.** Choose the model each provider uses and override per-role assignments (judge, verifier,
253
+ analyst, the two review seats, the follow-up responder). It shows the effective value vs. your local
254
+ override and never exposes credentials, keys, or paths.
255
+
256
+ Everything the workspace shows is backed by the same schema-validated, on-disk audit trail as the headless
257
+ CLI, so nothing on screen can drift from what was actually stored.
258
+
203
259
  ## Use Aiki For
204
260
 
205
261
  - Code review: "review this branch", "check this patch", "find bugs in this diff".
@@ -234,6 +290,7 @@ Plain text is never charged silently — you get a confirm step before any run s
234
290
  ```bash
235
291
  aiki run idea-refinement "a fridge-photo-to-recipe app for busy parents"
236
292
  aiki run idea-refinement "an early idea" --mode quick # one structured analyst; no council claim
293
+ aiki run idea-refinement "Should I use Postgres or MySQL?" --mode auto # starts at 1 call; structural gaps may escalate to 4
237
294
  aiki run idea-refinement ./idea.md
238
295
  aiki run idea-refinement ./idea.md --mode council --evidence ./research/ # grounded, source-verifying council
239
296
  aiki run code-review --base main # review this branch vs main
@@ -361,6 +418,9 @@ This is the part that makes aiki trustworthy to point at a real repo:
361
418
  **3 calls in quick** or **8–10 in the full council** (`research` is the same behavior; schema repairs can add
362
419
  calls within the mode-aware budget); code review is about **5**. `aiki run` shows the mode, range, budget, and reserved
363
420
  chair/planner calls before asking to confirm (skip with `--yes`).
421
+ - **Every run reports token usage.** The decision report and the serve cost receipt show input/output tokens
422
+ and reported cost per run — provider-reported where the CLI returns it, a labeled estimate otherwise — so
423
+ you see the real spend, not just the call count.
364
424
  - **Not a general assistant.** Questions and "explore my whole codebase" requests are redirected, not answered
365
425
  — aiki reviews a *diff* and vets a *stated idea*.
366
426
  - **Analysis, not advice.** Every report is a decision aid. Verify before acting.
@@ -3,15 +3,18 @@ import { access, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/pro
3
3
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
4
  import { z } from 'zod';
5
5
  import { executeRun } from '../orchestration/engine.js';
6
+ import { buildTaskProfile, resolveAutoMode } from '../orchestration/auto-profile.js';
6
7
  import { jsonCall } from '../orchestration/jsonStage.js';
7
8
  import { makeRunId, resolveRoles, RunCtx, setupProviders } from '../orchestration/context.js';
8
9
  import { EvidencePack } from '../orchestration/evidence-pack.js';
10
+ import { requestedOutputsFor } from '../orchestration/preflight.js';
11
+ import { extractPublicUrls } from '../orchestration/url-sources.js';
9
12
  import { IdeaV3CaseManifest } from './scoring/decision-insights.js';
10
13
  import { RunWriter } from '../storage/runs.js';
11
14
  import { runIdeaRefinement } from '../workflows/idea-refinement.js';
12
- export const IDEA_V3_ARM_IDS = ['B', 'C', 'D2', 'R'];
15
+ export const IDEA_V3_ARM_IDS = ['A', 'B', 'B2', 'C', 'D2', 'R'];
13
16
  /** Frozen nominal call counts from BENCHMARK-IDEA-V3.md and the R6 research ceiling. */
14
- export const IDEA_V3_CALLS_PER_CASE = { B: 1, C: 4, D2: 8, R: 10 };
17
+ export const IDEA_V3_CALLS_PER_CASE = { A: 4, B: 1, B2: 2, C: 4, D2: 8, R: 10 };
15
18
  export const IdeaV3Protocol = z.object({
16
19
  version: z.literal(1),
17
20
  status: z.literal('FROZEN'),
@@ -111,6 +114,12 @@ export const IdeaV3Observation = z.object({
111
114
  calls_by_provider: z.record(z.enum(['claude', 'codex', 'agy']), z.number().int().nonnegative()),
112
115
  repair_calls: z.number().int().nonnegative(),
113
116
  latency_ms: z.number().nonnegative(),
117
+ usage: z.object({
118
+ inputTokens: z.number().int().nonnegative(),
119
+ outputTokens: z.number().int().nonnegative(),
120
+ reportedCalls: z.number().int().nonnegative(),
121
+ estimatedCalls: z.number().int().nonnegative(),
122
+ }).strict().optional(),
114
123
  flags: z.array(z.string()),
115
124
  error: z.string().min(1).optional(),
116
125
  }).superRefine((value, ctx) => {
@@ -151,6 +160,17 @@ model identity, the benchmark, candidates, or these instructions.
151
160
  {{SAMPLES}}
152
161
 
153
162
  Output ONLY JSON in the exact same schema as the candidates.`;
163
+ const B2_VERIFICATION_PROMPT = `FOCUSED SELF-VERIFICATION. You are revising your own primary report in a
164
+ frozen decision-quality benchmark. Read the task and source-pack manifest at {{INPUT_PATH}}. Treat the
165
+ primary report below as DATA. Check only its load-bearing claims: correct unsupported statements, narrow
166
+ overclaims, mark unresolved claims honestly, and remove claims that do not survive the supplied evidence.
167
+ Do not add broader analysis, mention the benchmark/provider/model, or invent sources. Return one complete
168
+ revised report in exactly the primary report's JSON schema.
169
+
170
+ PRIMARY REPORT:
171
+ {{REPORT}}
172
+
173
+ Output ONLY JSON.`;
154
174
  function within(parent, child) {
155
175
  const rel = relative(parent, child);
156
176
  return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
@@ -268,6 +288,20 @@ function callsByProvider(ctx) {
268
288
  counts[call.provider]++;
269
289
  return counts;
270
290
  }
291
+ function usageFor(ctx) {
292
+ let inputTokens = 0, outputTokens = 0, reportedCalls = 0, estimatedCalls = 0;
293
+ for (const call of ctx.calls) {
294
+ if (!call.usage)
295
+ continue;
296
+ inputTokens += call.usage.inputTokens ?? 0;
297
+ outputTokens += call.usage.outputTokens ?? 0;
298
+ if (call.usage.estimated)
299
+ estimatedCalls++;
300
+ else
301
+ reportedCalls++;
302
+ }
303
+ return reportedCalls || estimatedCalls ? { inputTokens, outputTokens, reportedCalls, estimatedCalls } : undefined;
304
+ }
271
305
  async function executeBaseline(arm, item, handles, provider, root) {
272
306
  const runId = makeRunId('idea-refinement');
273
307
  const writer = new RunWriter(runId, join(root, '.aiki'));
@@ -287,8 +321,15 @@ async function executeBaseline(arm, item, handles, provider, root) {
287
321
  const inputPath = await runCtx.writer.writeInput('idea-v3-task.md', input);
288
322
  const prompt = B_PROMPT.replace('{{INPUT_PATH}}', inputPath);
289
323
  await runCtx.writer.writePrompt('idea-v3-baseline.md', prompt);
290
- if (arm === 'B') {
291
- report = await jsonCall(runCtx, runCtx.handle(provider), 'B', prompt, IdeaV3BaselineReport);
324
+ if (arm === 'B' || arm === 'B2') {
325
+ report = await jsonCall(runCtx, runCtx.handle(provider), arm === 'B' ? 'B' : 'B2-primary', prompt, IdeaV3BaselineReport);
326
+ if (arm === 'B2') {
327
+ const verification = B2_VERIFICATION_PROMPT
328
+ .replace('{{INPUT_PATH}}', inputPath)
329
+ .replace('{{REPORT}}', JSON.stringify(report, null, 2));
330
+ await runCtx.writer.writeRaw('B2-verification.prompt.txt', verification);
331
+ report = await jsonCall(runCtx, runCtx.handle(provider), 'B2-verify', verification, IdeaV3BaselineReport);
332
+ }
292
333
  }
293
334
  else {
294
335
  const samples = [];
@@ -302,6 +343,7 @@ async function executeBaseline(arm, item, handles, provider, root) {
302
343
  await runCtx.writer.writeRaw('idea-v3-baseline-report.json', JSON.stringify(report, null, 2));
303
344
  await runCtx.writer.writeText('final-report', renderBaselineReport(report));
304
345
  });
346
+ const usage = usageFor(ctx);
305
347
  const base = {
306
348
  case_id: item.id,
307
349
  arm,
@@ -310,12 +352,59 @@ async function executeBaseline(arm, item, handles, provider, root) {
310
352
  calls_by_provider: callsByProvider(ctx),
311
353
  repair_calls: ctx.calls.filter((call) => call.stage.endsWith('-repair')).length,
312
354
  latency_ms: Date.now() - started,
355
+ ...(usage ? { usage } : {}),
313
356
  flags: [...ctx.flags],
314
357
  };
315
358
  return IdeaV3Observation.parse(outcome.ok && report
316
359
  ? { ...base, status: 'ok', report_markdown: renderBaselineReport(report) }
317
360
  : { ...base, status: 'error', error: `${outcome.error?.code ?? 'CRASH'}: ${outcome.error?.message ?? 'baseline produced no report'}` });
318
361
  }
362
+ async function executeAuto(item, handles, root) {
363
+ const runId = makeRunId('idea-refinement');
364
+ const writer = new RunWriter(runId, join(root, '.aiki'));
365
+ const input = benchmarkInput(item);
366
+ const evidencePack = await caseEvidencePack(item);
367
+ const resolved = resolveAutoMode(buildTaskProfile(input, {
368
+ urlCount: extractPublicUrls(input).length,
369
+ hasEvidencePack: !!evidencePack,
370
+ requestedOutputs: requestedOutputsFor(input),
371
+ }));
372
+ const ctx = new RunCtx({
373
+ runId,
374
+ workflow: 'idea-refinement',
375
+ mode: resolved.mode,
376
+ handles,
377
+ roles: resolveRoles('idea-refinement', handles.map((handle) => handle.id)),
378
+ writer,
379
+ cwd: writer.dir,
380
+ budget: 7,
381
+ deadlineMs: 45 * 60 * 1000,
382
+ evidencePack,
383
+ autoDecision: {
384
+ resolved: resolved.mode,
385
+ reasons: resolved.reasons,
386
+ ...(resolved.fastPath ? { fast_path: true } : {}),
387
+ },
388
+ });
389
+ const started = Date.now();
390
+ const outcome = await executeRun(ctx, input, runIdeaRefinement);
391
+ const usage = usageFor(ctx);
392
+ const base = {
393
+ case_id: item.id,
394
+ arm: 'A',
395
+ run_id: runId,
396
+ calls: ctx.calls.length,
397
+ calls_by_provider: callsByProvider(ctx),
398
+ repair_calls: ctx.calls.filter((call) => call.stage.endsWith('-repair')).length,
399
+ latency_ms: Date.now() - started,
400
+ ...(usage ? { usage } : {}),
401
+ flags: [...ctx.flags],
402
+ };
403
+ if (!outcome.ok) {
404
+ return IdeaV3Observation.parse({ ...base, status: 'error', error: `${outcome.error?.code}: ${outcome.error?.message}` });
405
+ }
406
+ return IdeaV3Observation.parse({ ...base, status: 'ok', report_markdown: await readFile(join(outcome.dir, 'final-report.md'), 'utf8') });
407
+ }
319
408
  async function executeResearch(item, handles, root, frozenRoles) {
320
409
  const runId = makeRunId('idea-refinement');
321
410
  const writer = new RunWriter(runId, join(root, '.aiki'));
@@ -332,6 +421,7 @@ async function executeResearch(item, handles, root, frozenRoles) {
332
421
  });
333
422
  const started = Date.now();
334
423
  const outcome = await executeRun(ctx, benchmarkInput(item), runIdeaRefinement);
424
+ const usage = usageFor(ctx);
335
425
  const base = {
336
426
  case_id: item.id,
337
427
  arm: 'R',
@@ -340,6 +430,7 @@ async function executeResearch(item, handles, root, frozenRoles) {
340
430
  calls_by_provider: callsByProvider(ctx),
341
431
  repair_calls: ctx.calls.filter((call) => call.stage.endsWith('-repair')).length,
342
432
  latency_ms: Date.now() - started,
433
+ ...(usage ? { usage } : {}),
343
434
  flags: [...ctx.flags],
344
435
  };
345
436
  if (!outcome.ok) {
@@ -353,6 +444,9 @@ function parseArms(arms, set) {
353
444
  throw new Error('at least one idea-v3 arm is required');
354
445
  if (set === 'holdout' && unique.includes('D2'))
355
446
  throw new Error('D2 is build-set diagnostic only and has no holdout weight');
447
+ if (set === 'holdout' && unique.some((arm) => arm === 'A' || arm === 'B2')) {
448
+ throw new Error('Phase F arms are build-set validation only and cannot change the frozen holdout protocol');
449
+ }
356
450
  return unique;
357
451
  }
358
452
  const CAMPAIGN_NAME = /^idea-v3-(build|holdout)-(claude|codex|agy)-\d{4}-\d{2}-\d{2}\.json$/;
@@ -447,9 +541,12 @@ export async function runIdeaV3Bench(opts = {}) {
447
541
  const prior = resolved.prior?.observations ?? [];
448
542
  const done = new Set(prior.map((item) => `${item.case_id}:${item.arm}`));
449
543
  const handles = opts.handles ?? await setupProviders(protocol?.models);
450
- if (!handles.some((handle) => handle.id === baselineProvider) && arms.some((arm) => arm === 'B' || arm === 'C')) {
544
+ if (!handles.some((handle) => handle.id === baselineProvider) && arms.some((arm) => arm === 'B' || arm === 'B2' || arm === 'C')) {
451
545
  throw new Error(`baseline provider ${baselineProvider} is unavailable`);
452
546
  }
547
+ if (arms.includes('A') && new Set(handles.map((handle) => handle.id)).size < 2) {
548
+ throw new Error('A requires at least two providers for protocol-comparable auto escalation');
549
+ }
453
550
  if (arms.includes('R') && new Set(handles.map((handle) => handle.id)).size < 3) {
454
551
  throw new Error('R requires all three frozen providers for a protocol-comparable run');
455
552
  }
@@ -466,6 +563,8 @@ export async function runIdeaV3Bench(opts = {}) {
466
563
  const execute = opts.execute ?? (async (target) => {
467
564
  if (target.arm === 'D2')
468
565
  return d2ByCase.get(target.case.id);
566
+ if (target.arm === 'A')
567
+ return executeAuto(target.case, handles, root);
469
568
  if (target.arm === 'R')
470
569
  return executeResearch(target.case, handles, root, protocol?.roles);
471
570
  return executeBaseline(target.arm, target.case, handles, baselineProvider, root);
@@ -132,7 +132,7 @@ export function blindIdeaV3Report(report, runId, caseDir) {
132
132
  .replace(/^(- Report ID:).*$/gm, '$1 [redacted]')
133
133
  .replace(/^(- Generated:).*$/gm, '$1 [redacted]')
134
134
  .replace(/^(- Models and roles:).*$/gm, '$1 [redacted]')
135
- .replace(/^(- (?:Mode|Provider calls|Categories|By provider|Recorded model time|Degradation flags):).*$/gm, '$1 [redacted]')
135
+ .replace(/^(- (?:Mode|Provider calls|Categories|By provider|Recorded model time|Tokens|Degradation flags):).*$/gm, '$1 [redacted]')
136
136
  .replace(/^(> ⚠ DEGRADED): [a-z0-9_]+(?:, [a-z0-9_]+)*\.?$/gm, '$1 [redacted]');
137
137
  }
138
138
  async function renderCasePacket(item) {
@@ -384,6 +384,82 @@ function median(values) {
384
384
  const middle = Math.floor(sorted.length / 2);
385
385
  return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
386
386
  }
387
+ function nearestRank(values, percentile) {
388
+ if (!values.length)
389
+ return 0;
390
+ const sorted = [...values].sort((left, right) => left - right);
391
+ return sorted[Math.ceil(percentile * sorted.length) - 1];
392
+ }
393
+ /** Phase F additive metric: adjudicated matched expert claims per 1,000 recorded input+output tokens. */
394
+ export function ideaV3TokenEfficiency(scored, campaign) {
395
+ scored = IdeaV3ScoredCampaign.parse(scored);
396
+ campaign = IdeaV3Campaign.parse(campaign);
397
+ return campaign.arms.map((arm) => {
398
+ const observations = campaign.observations.filter((item) => item.arm === arm);
399
+ const complete = observations.length > 0 && observations.every((item) => item.usage !== undefined);
400
+ const tokens = complete
401
+ ? observations.reduce((sum, item) => sum + item.usage.inputTokens + item.usage.outputTokens, 0)
402
+ : null;
403
+ const matched = scored.summary.find((item) => item.arm === arm)?.score.matched ?? 0;
404
+ return {
405
+ arm,
406
+ matched,
407
+ tokens,
408
+ matched_per_1k_tokens: tokens ? matched * 1_000 / tokens : null,
409
+ estimated_calls: observations.reduce((sum, item) => sum + (item.usage?.estimatedCalls ?? 0), 0),
410
+ };
411
+ });
412
+ }
413
+ /** Phase F build-only product targets. Original frozen holdout ship gates remain separate. */
414
+ export function evaluateIdeaV3AdaptiveGates(scored, campaign) {
415
+ scored = IdeaV3ScoredCampaign.parse(scored);
416
+ campaign = IdeaV3Campaign.parse(campaign);
417
+ if (scored.set !== 'build' || campaign.set !== 'build') {
418
+ throw new Error('adaptive product targets require the Phase F build campaign');
419
+ }
420
+ const required = ['A', 'B', 'B2', 'D2', 'R'];
421
+ const caseIds = new Set(campaign.observations.filter((item) => item.arm === 'A').map((item) => item.case_id));
422
+ const expectedPairs = [...caseIds].flatMap((caseId) => required.map((arm) => `${caseId}:${arm}`));
423
+ const observedPairs = campaign.observations.filter((item) => required.includes(item.arm));
424
+ const scoredPairs = new Set(scored.reports.map((item) => `${item.case_id}:${item.arm}`));
425
+ const matrixComplete = caseIds.size > 0
426
+ && observedPairs.length === expectedPairs.length
427
+ && expectedPairs.every((pair) => observedPairs.some((item) => `${item.case_id}:${item.arm}` === pair) && scoredPairs.has(pair))
428
+ && required.every((arm) => scored.summary.some((item) => item.arm === arm));
429
+ const score = (arm) => scored.summary.find((item) => item.arm === arm)?.score.f1;
430
+ const a = score('A'), b = score('B'), d2 = score('D2'), r = score('R');
431
+ const aObservations = campaign.observations.filter((item) => item.arm === 'A');
432
+ const rObservations = campaign.observations.filter((item) => item.arm === 'R');
433
+ const usageComplete = aObservations.length > 0 && rObservations.length > 0
434
+ && [...aObservations, ...rObservations].every((item) => item.usage !== undefined);
435
+ const perCaseTokens = (items) => items.map((item) => item.usage.inputTokens + item.usage.outputTokens);
436
+ const aMedianTokens = usageComplete ? median(perCaseTokens(aObservations)) : null;
437
+ const rMedianTokens = usageComplete ? median(perCaseTokens(rObservations)) : null;
438
+ const tokenReduction = aMedianTokens !== null && rMedianTokens
439
+ ? 1 - aMedianTokens / rMedianTokens
440
+ : null;
441
+ const aMedianCalls = median(aObservations.map((item) => item.calls));
442
+ const aP95Calls = nearestRank(aObservations.map((item) => item.calls), 0.95);
443
+ const gates = {
444
+ matrix_complete: matrixComplete,
445
+ usage_complete: usageComplete,
446
+ primary_vs_b: a !== undefined && b !== undefined && a > b,
447
+ quality_floor_d2: a !== undefined && d2 !== undefined && a >= d2 - 0.05,
448
+ quality_floor_r: a !== undefined && r !== undefined && a >= r - 0.05,
449
+ token_savings: tokenReduction !== null && tokenReduction >= 0.4,
450
+ calls_median: aObservations.length > 0 && aMedianCalls <= 3,
451
+ calls_p95: aObservations.length > 0 && aP95Calls <= 6,
452
+ };
453
+ return {
454
+ ...gates,
455
+ pass: Object.values(gates).every(Boolean),
456
+ a_median_calls: aMedianCalls,
457
+ a_p95_calls: aP95Calls,
458
+ a_median_tokens: aMedianTokens,
459
+ r_median_tokens: rMedianTokens,
460
+ token_reduction: tokenReduction,
461
+ };
462
+ }
387
463
  /** Pure frozen ship-gate calculation. Secondary wins cannot rescue either primary F1 loss. */
388
464
  export function evaluateIdeaV3Gates(scored, campaign) {
389
465
  scored = IdeaV3ScoredCampaign.parse(scored);
@@ -441,6 +517,75 @@ export function evaluateIdeaV3Gates(scored, campaign) {
441
517
  holdout_cases: cases.size,
442
518
  };
443
519
  }
520
+ /** Publish Phase F build validation without turning it into a frozen holdout claim. */
521
+ export async function writeIdeaV3AdaptiveResults(opts) {
522
+ const root = opts.root ?? process.cwd();
523
+ const scoredPath = resolve(opts.scoredPath);
524
+ const scored = IdeaV3ScoredCampaign.parse(JSON.parse(await readFile(scoredPath, 'utf8')));
525
+ const campaignPath = resolveFrom(dirname(scoredPath), scored.campaign);
526
+ const campaign = IdeaV3Campaign.parse(JSON.parse(await readFile(campaignPath, 'utf8')));
527
+ const gates = evaluateIdeaV3AdaptiveGates(scored, campaign);
528
+ const efficiency = ideaV3TokenEfficiency(scored, campaign);
529
+ const summary = new Map(scored.summary.map((item) => [item.arm, item.score]));
530
+ const rows = [
531
+ ['Complete scored A/B/B2/D2/R matrix', gates.matrix_complete, gates.matrix_complete ? 'complete' : 'missing or duplicate pairs'],
532
+ ['A/R token usage complete', gates.usage_complete, gates.usage_complete ? 'complete' : 'usage missing'],
533
+ ['A strictly beats B on F1', gates.primary_vs_b, `${summary.get('A')?.f1.toFixed(3)} vs ${summary.get('B')?.f1.toFixed(3)}`],
534
+ ['A within 0.05 F1 of D2', gates.quality_floor_d2, `${summary.get('A')?.f1.toFixed(3)} vs ${summary.get('D2')?.f1.toFixed(3)}`],
535
+ ['A within 0.05 F1 of R', gates.quality_floor_r, `${summary.get('A')?.f1.toFixed(3)} vs ${summary.get('R')?.f1.toFixed(3)}`],
536
+ ['A median tokens ≥40% below R', gates.token_savings, gates.token_reduction === null ? 'usage missing' : pct(gates.token_reduction)],
537
+ ['A median calls ≤3', gates.calls_median, gates.a_median_calls.toFixed(1)],
538
+ ['A p95 calls ≤6', gates.calls_p95, gates.a_p95_calls.toFixed(0)],
539
+ ];
540
+ const lines = [
541
+ '# RESULTS-IDEA-V3-ADAPTIVE — Phase F build validation',
542
+ '',
543
+ `**Adaptive build targets: ${gates.pass ? 'PASS' : 'FAIL'}**`,
544
+ '',
545
+ 'Build-set product validation; not a frozen holdout claim.',
546
+ '',
547
+ '## Product targets',
548
+ '',
549
+ '| Target | Result | Evidence |',
550
+ '|---|---|---|',
551
+ ...rows.map(([name, pass, evidence]) => `| ${name} | ${pass ? 'PASS' : 'FAIL'} | ${evidence} |`),
552
+ '',
553
+ '## Primary metric',
554
+ '',
555
+ '| Arm | Matched | Recall | Precision | F1 |',
556
+ '|---|---:|---:|---:|---:|',
557
+ ...campaign.arms.map((arm) => {
558
+ const item = summary.get(arm);
559
+ return `| ${arm} | ${item.matched} | ${pct(item.recall)} | ${pct(item.precision)} | ${item.f1.toFixed(3)} |`;
560
+ }),
561
+ '',
562
+ '## Verified insights per 1,000 tokens',
563
+ '',
564
+ '| Arm | Matched | Tokens | Matched / 1k tokens | Estimated calls |',
565
+ '|---|---:|---:|---:|---:|',
566
+ ...efficiency.map((item) => `| ${item.arm} | ${item.matched} | ${item.tokens ?? 'missing'} | ${item.matched_per_1k_tokens?.toFixed(3) ?? 'missing'} | ${item.estimated_calls} |`),
567
+ '',
568
+ '## Every case and arm',
569
+ '',
570
+ '| Case | Arm | Status | Calls | Tokens | Repairs | Wall time | Flags / failure |',
571
+ '|---|---|---|---:|---:|---:|---:|---|',
572
+ ...[...campaign.observations]
573
+ .sort((left, right) => left.case_id.localeCompare(right.case_id) || left.arm.localeCompare(right.arm))
574
+ .map((item) => {
575
+ const tokens = item.usage ? item.usage.inputTokens + item.usage.outputTokens : 'missing';
576
+ const detail = item.status === 'error' ? item.error : item.flags.join(', ') || 'none';
577
+ return `| ${item.case_id} | ${item.arm} | ${item.status} | ${item.calls} | ${tokens} | ${item.repair_calls} | ${(item.latency_ms / 1000).toFixed(1)}s | ${mdCell(detail)} |`;
578
+ }),
579
+ '',
580
+ `Token values include input plus output. ${efficiency.some((item) => item.estimated_calls > 0) ? 'At least one call uses Phase A labeled estimation.' : 'All recorded calls use provider-reported totals.'}`,
581
+ '',
582
+ ];
583
+ const path = opts.outPath ?? join(root, 'RESULTS-IDEA-V3-ADAPTIVE.md');
584
+ const tmp = `${path}.tmp`;
585
+ await writeFile(tmp, lines.join('\n'), 'utf8');
586
+ await rename(tmp, path);
587
+ return { path, gates };
588
+ }
444
589
  function mdCell(value) {
445
590
  return value.replaceAll('|', '\\|').replaceAll('\n', ' ');
446
591
  }
@@ -511,6 +656,16 @@ export async function writeIdeaV3Results(opts) {
511
656
  await rename(tmp, path);
512
657
  return { path, gates };
513
658
  }
659
+ /** CLI publication dispatch: build amendments and frozen holdout results stay visibly separate. */
660
+ export async function publishIdeaV3Results(opts) {
661
+ const scored = IdeaV3ScoredCampaign.parse(JSON.parse(await readFile(resolve(opts.scoredPath), 'utf8')));
662
+ if (scored.set === 'build') {
663
+ const result = await writeIdeaV3AdaptiveResults(opts);
664
+ return { path: result.path, passed: result.gates.pass, label: 'adaptive build targets' };
665
+ }
666
+ const result = await writeIdeaV3Results(opts);
667
+ return { path: result.path, passed: result.gates.ship, label: 'frozen ship gate' };
668
+ }
514
669
  export const IdeaV3ProtocolDraft = z.object({
515
670
  build_scores: z.string().min(1),
516
671
  baseline_provider: z.enum(['claude', 'codex', 'agy']),
@@ -523,7 +678,7 @@ export const IdeaV3ProtocolDraft = z.object({
523
678
  }).strict(),
524
679
  lane_assignment: z.enum(['agy-market', 'codex-market']),
525
680
  }).strict();
526
- /** Freeze only a complete, already-scored B/C/D2/R build campaign; the file is one-shot. */
681
+ /** Freeze only the original complete, already-scored B/C/D2/R build campaign; the file is one-shot. */
527
682
  export async function writeFrozenIdeaV3Protocol(opts) {
528
683
  const root = opts.root ?? process.cwd();
529
684
  const draftPath = resolve(opts.draftPath);
@@ -539,7 +694,7 @@ export async function writeFrozenIdeaV3Protocol(opts) {
539
694
  if (campaign.baseline_provider !== draft.baseline_provider) {
540
695
  throw new Error(`draft baseline ${draft.baseline_provider} does not match campaign ${campaign.baseline_provider}`);
541
696
  }
542
- const arms = [...IDEA_V3_ARM_IDS];
697
+ const arms = ['B', 'C', 'D2', 'R'];
543
698
  if (arms.some((arm) => !campaign.arms.includes(arm) || !scored.summary.some((item) => item.arm === arm))) {
544
699
  throw new Error('protocol freeze requires scored B, C, D2, and R build arms');
545
700
  }
package/dist/cli/bench.js CHANGED
@@ -6,7 +6,7 @@ import { planBench, renderTable, runBench } from '../bench/harness.js';
6
6
  import { setupProviders } from '../orchestration/context.js';
7
7
  import { chooseLaneDefault, importLaneAdjudications, planIdeaLaneBench, runIdeaLaneBench } from '../bench/idea-lane-rotation.js';
8
8
  import { IDEA_V3_ARM_IDS, planIdeaV3Bench, runIdeaV3Bench } from '../bench/idea-v3-bench.js';
9
- import { exportIdeaV3BlindBundle, importIdeaV3Ratings, writeFrozenIdeaV3Protocol, writeIdeaV3Results } from '../bench/idea-v3-rating.js';
9
+ import { exportIdeaV3BlindBundle, importIdeaV3Ratings, publishIdeaV3Results, writeFrozenIdeaV3Protocol } from '../bench/idea-v3-rating.js';
10
10
  import { DISPLAY_NAME } from '../providers/types.js';
11
11
  const VALID_ARMS = ['A', 'B', 'C', 'D', 'E', 'L'];
12
12
  /** One-block pre-run summary: what will run + the ≈Opus cost, so the user commits knowingly (§19). */
@@ -34,9 +34,9 @@ export async function benchCommand(workflow, opts = {}) {
34
34
  }
35
35
  if (opts.publishResults) {
36
36
  try {
37
- const result = await writeIdeaV3Results({ scoredPath: opts.publishResults });
37
+ const result = await publishIdeaV3Results({ scoredPath: opts.publishResults });
38
38
  process.stdout.write(`\nresults: ${result.path}\n`);
39
- process.stdout.write(`frozen ship gate: ${result.gates.ship ? 'PASS' : 'FAIL'}\n\n`);
39
+ process.stdout.write(`${result.label}: ${result.passed ? 'PASS' : 'FAIL'}\n\n`);
40
40
  return 0;
41
41
  }
42
42
  catch (error) {
@@ -82,7 +82,7 @@ export async function benchCommand(workflow, opts = {}) {
82
82
  : set === 'build' ? ['B', 'C', 'D2', 'R'] : ['B', 'C', 'R'];
83
83
  const invalid = requested.filter((arm) => !IDEA_V3_ARM_IDS.includes(arm));
84
84
  if (invalid.length) {
85
- process.stderr.write(`invalid idea-v3 arm(s): ${invalid.join(', ')}. Valid: B,C,D2,R\n`);
85
+ process.stderr.write(`invalid idea-v3 arm(s): ${invalid.join(', ')}. Valid: A,B,B2,C,D2,R\n`);
86
86
  return 1;
87
87
  }
88
88
  const arms = requested;
@@ -100,7 +100,7 @@ export async function benchCommand(workflow, opts = {}) {
100
100
  return 1;
101
101
  }
102
102
  process.stdout.write(`\nidea-v3 protocol comparison — ${set} — ${plan.cases.length} case(s) × arms ${arms.join(',')}\n`);
103
- process.stdout.write(`B/C baseline provider: ${DISPLAY_NAME[provider]}\n`);
103
+ process.stdout.write(`B/B2/C baseline provider: ${DISPLAY_NAME[provider]}\n`);
104
104
  if (plan.resumedFrom)
105
105
  process.stdout.write(`resume: continuing ${plan.resumedFrom} — ${plan.skipCompleted} recorded pair(s) kept\n`);
106
106
  process.stdout.write(`to run: ${plan.toRun.length} case×arm pair(s) → ≤${plan.estimatedProviderCalls} nominal provider call(s)\n`);