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/dist/cli/index.js CHANGED
@@ -12,6 +12,7 @@ import { sessionsCommand } from './sessions.js';
12
12
  import { config } from './config.js';
13
13
  import { modelsCommand } from './models.js';
14
14
  import { benchCommand } from './bench.js';
15
+ import { serveCommand } from './serve.js';
15
16
  import { VERSION } from './version.js';
16
17
  import { ConfigError, loadLayeredConfig } from '../config/config.js';
17
18
  import { resolveRunsRoot } from '../storage/paths.js';
@@ -51,7 +52,8 @@ program
51
52
  .option('--head <ref>', 'code-review: head git ref to diff to (default HEAD)')
52
53
  .option('--diff <file>', 'code-review: review a patch file instead of computing a git diff')
53
54
  .option('--evidence <path>', 'idea-refinement: local source file/directory (stores paths + hashes, not copies)')
54
- .option('--mode <mode>', 'idea-refinement: quick | council (research is an alias for council)')
55
+ .option('--mode <mode>', 'idea-refinement: quick | council | auto (research is an alias for council)')
56
+ .option('--allow-blocked-sources', 'idea-refinement: proceed even when a supplied URL cannot be read (default: stop and ask)')
55
57
  .option('--cheap', 'code-review: Gemini+Codex review, Claude judges only disputes (~⅓ the Opus; experimental)')
56
58
  .option('--yes', 'skip the run-cost confirmation prompt')
57
59
  .action(async (workflow, input, opts) => {
@@ -93,7 +95,7 @@ program
93
95
  .command('bench')
94
96
  .description('Run code-review, idea lane, or frozen idea-v3 benchmark arms; writes bench/results/*.json.')
95
97
  .argument('<workflow>', 'code-review | idea-refinement | idea-v3')
96
- .option('--arms <list>', 'comma-separated arms to run (code review defaults A,B,C,D; idea-v3 defaults B,C,D2,R on build)')
98
+ .option('--arms <list>', 'comma-separated arms to run (code review defaults A,B,C,D; idea-v3 defaults B,C,D2,R; Phase F adds explicit A,B2)')
97
99
  .option('--set <name>', 'task set: build | holdout', 'build')
98
100
  .option('--resume', 'continue the latest results file: keep already-scored case×arm pairs, retry the rest')
99
101
  .option('--yes', 'actually run; without it, print the pre-run Opus-call estimate and exit')
@@ -123,6 +125,14 @@ program
123
125
  campaign: opts.campaign,
124
126
  }));
125
127
  });
128
+ program
129
+ .command('serve')
130
+ .description('Open the chat workspace in the browser (localhost only; no paid calls until you convene a run).')
131
+ .option('--port <n>', 'port to bind (default: first free in 4173–4183)', (v) => parseInt(v, 10))
132
+ .option('--no-open', 'do not open the browser automatically')
133
+ .action(async (opts) => {
134
+ process.exit(await serveCommand({ port: opts.port, open: opts.open }));
135
+ });
126
136
  program
127
137
  .command('models')
128
138
  .description('Show configurable models per provider (lists via the CLI where supported) + your current pins.')
@@ -9,6 +9,7 @@ import { resolveRunsRoot } from '../storage/paths.js';
9
9
  import { buildReplayCache } from '../storage/replay.js';
10
10
  import { findSession } from '../storage/sessions.js';
11
11
  import { readJsonArtifact, resolveRunId, runDir } from '../storage/runs-read.js';
12
+ import { UrlSourceSet } from '../schemas/index.js';
12
13
  import { EvidencePack } from '../orchestration/evidence-pack.js';
13
14
  export async function resumeCommand(runArg, opts = {}) {
14
15
  if (!runArg) {
@@ -72,6 +73,22 @@ export async function resumeCommand(runArg, opts = {}) {
72
73
  }
73
74
  evidencePack = parsed.data;
74
75
  }
76
+ // v6 T10: the URL snapshot + the user's proceed-past-blocked decision are run inputs, like
77
+ // inputs/idea.md and evidence-pack.json. Reusing the ORIGINAL snapshot keeps every prompt that
78
+ // embeds it byte-identical (replay cache hits, no refetch); and a non-empty replay cache proves
79
+ // the old run cleared the S0 gate, so a blocked source in the snapshot means consent was given
80
+ // (interactive `y` or --allow-blocked-sources) — restore that decision instead of re-asking.
81
+ // (Real failure: resume c46e of 626e died SOURCE_UNREADABLE re-gating a consented run.)
82
+ let urlSources;
83
+ const savedUrlSources = await readJsonArtifact(oldDir, '00a-url-sources.json');
84
+ if (savedUrlSources) {
85
+ const parsedSources = UrlSourceSet.safeParse(savedUrlSources);
86
+ if (!parsedSources.success) {
87
+ process.stderr.write(`cannot validate 00a-url-sources.json for ${oldId} — nothing to resume.\n`);
88
+ return 1;
89
+ }
90
+ urlSources = parsedSources.data;
91
+ }
75
92
  const replay = await buildReplayCache(oldDir);
76
93
  if (replay.size === 0) {
77
94
  process.stderr.write(`no completed calls found for ${oldId} — start a fresh run instead.\n`);
@@ -100,6 +117,9 @@ export async function resumeCommand(runArg, opts = {}) {
100
117
  resumedFrom: oldId,
101
118
  providerModels: cfg.models,
102
119
  evidencePack,
120
+ urlSources,
121
+ allowBlockedSources: urlSources?.sources.some((s) => s.status !== 'FETCHED') ?? false,
122
+ autoDecision: previousMeta.auto_decision,
103
123
  });
104
124
  if (outcome.ok) {
105
125
  process.stdout.write(`\n ✔ resumed run ${outcome.runId} complete — ${outcome.callCount} new provider call(s)\n artifacts: ${outcome.dir}\n\n`);
package/dist/cli/run.js CHANGED
@@ -3,6 +3,9 @@
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { createInterface } from 'node:readline';
6
+ import { blockedSourceStop, extractPublicUrls, snapshotUrlSources } from '../orchestration/url-sources.js';
7
+ import { buildTaskProfile, resolveAutoMode } from '../orchestration/auto-profile.js';
8
+ import { requestedOutputsFor } from '../orchestration/preflight.js';
6
9
  import { renderTerminalSummary } from '../orchestration/stages/s10-render.js';
7
10
  import { run as runEngine } from '../orchestration/engine.js';
8
11
  import { ConfigError, loadLayeredConfig } from '../config/config.js';
@@ -16,6 +19,10 @@ const WORKFLOWS = ['idea-refinement', 'code-review'];
16
19
  export function estimateRun(workflow, opts = {}) {
17
20
  if (workflow === 'code-review')
18
21
  return { calls: 5, opus: opts.cheap ? 1 : 2 };
22
+ if (opts.auto)
23
+ return { calls: 4, opus: 1, minCalls: opts.fastPath ? 1 : 3, reserved: 0 };
24
+ if (opts.fastPath)
25
+ return { calls: 1, opus: 1, minCalls: 1, reserved: 0 };
19
26
  const mode = opts.mode ?? 'council';
20
27
  const plan = IDEA_MODE_PLANS[mode];
21
28
  return {
@@ -35,6 +42,19 @@ function confirm(question) {
35
42
  });
36
43
  });
37
44
  }
45
+ /** One question, one trimmed answer. Resolve BEFORE close — closing fires the EOF handler, and
46
+ * the live "typed Y, got cancelled" bug was that handler resolving '' first. EOF alone (Ctrl+D)
47
+ * still resolves '' = the safe deny default. Exported (with injectable streams) for the test. */
48
+ export function ask(question, io = { input: process.stdin, output: process.stdout }) {
49
+ return new Promise((resolve) => {
50
+ const rl = createInterface({ input: io.input, output: io.output });
51
+ rl.on('close', () => resolve(''));
52
+ rl.question(question, (a) => {
53
+ resolve(a.trim());
54
+ rl.close();
55
+ });
56
+ });
57
+ }
38
58
  /** Resolve an idea-refinement input: an existing file path → its contents, else the arg as inline text. */
39
59
  async function resolveInput(arg) {
40
60
  if (!arg)
@@ -90,11 +110,15 @@ export async function runCommand(workflow, input, opts = {}) {
90
110
  return 1;
91
111
  }
92
112
  let mode;
113
+ let autoRequested = false; // v7 Phase B: `--mode auto` → resolved to quick|council once inputs are known
93
114
  if (workflow === 'idea-refinement') {
94
- if (opts.mode !== undefined) {
115
+ if (opts.mode === 'auto') {
116
+ autoRequested = true;
117
+ }
118
+ else if (opts.mode !== undefined) {
95
119
  const parsed = IdeaModeSchema.safeParse(opts.mode);
96
120
  if (!parsed.success) {
97
- process.stderr.write(`unknown idea mode "${opts.mode}". Available: quick, council, research\n`);
121
+ process.stderr.write(`unknown idea mode "${opts.mode}". Available: quick, council, research, auto\n`);
98
122
  return 1;
99
123
  }
100
124
  mode = parsed.data;
@@ -120,7 +144,8 @@ export async function runCommand(workflow, input, opts = {}) {
120
144
  return 1;
121
145
  }
122
146
  text = resolved;
123
- mode ??= inferIdeaMode(text);
147
+ if (!autoRequested)
148
+ mode ??= inferIdeaMode(text);
124
149
  }
125
150
  if (opts.evidence) {
126
151
  if (workflow !== 'idea-refinement') {
@@ -135,6 +160,19 @@ export async function runCommand(workflow, input, opts = {}) {
135
160
  return 1;
136
161
  }
137
162
  }
163
+ // v7 Phase B: resolve `--mode auto` here, before any RunCtx/engine work, so meta.mode stays quick|council
164
+ // and the estimate below reflects the resolved mode. urlCount is fetch-free (extract only, no snapshot).
165
+ let autoDecision;
166
+ if (autoRequested) {
167
+ const resolved = resolveAutoMode(buildTaskProfile(text, {
168
+ urlCount: extractPublicUrls(text).length,
169
+ hasEvidencePack: !!evidencePack,
170
+ requestedOutputs: requestedOutputsFor(text),
171
+ }));
172
+ mode = resolved.mode;
173
+ autoDecision = { resolved: resolved.mode, reasons: resolved.reasons, ...(resolved.fastPath ? { fast_path: true } : {}) };
174
+ process.stdout.write(` auto → ${resolved.mode}: ${resolved.reasons.join(', ')}.\n`);
175
+ }
138
176
  // Precedence (§10/T9): --budget flag > config.budget > built-in default. roles/deadline/models are
139
177
  // config-only (layered: global ~/.aiki base, project .aiki override).
140
178
  let cfg;
@@ -158,7 +196,7 @@ export async function runCommand(workflow, input, opts = {}) {
158
196
  process.stderr.write('--cheap only applies to code-review; ignoring for this workflow.\n');
159
197
  }
160
198
  // Run-cost preview (V5): show the estimate; confirm interactively unless --yes or non-interactive.
161
- const est = estimateRun(workflow, { cheap: opts.cheap, mode });
199
+ const est = estimateRun(workflow, { cheap: opts.cheap, mode, auto: !!autoDecision, fastPath: autoDecision?.fast_path });
162
200
  const callEstimate = est.minCalls !== undefined && est.minCalls !== est.calls ? `${est.minCalls}–${est.calls}` : `${est.calls}`;
163
201
  const resolvedBudget = opts.budget ?? cfg.budget ?? defaultBudgetFor(workflow, mode);
164
202
  const reservation = est.reserved ? `; ${est.reserved} call(s) reserved for chair + planner` : '';
@@ -173,6 +211,36 @@ export async function runCommand(workflow, input, opts = {}) {
173
211
  else {
174
212
  process.stdout.write(`${note}\n`);
175
213
  }
214
+ // v6 T10b: check supplied URLs BEFORE any provider spend. A blocked source in a TTY gets a
215
+ // simple permission prompt — Y allow, anything else deny. Headless keeps the fail-fast gate.
216
+ let allowBlockedSources = opts.allowBlockedSources ?? false;
217
+ let urlSources;
218
+ if (workflow === 'idea-refinement') {
219
+ urlSources = await snapshotUrlSources(text);
220
+ const stopMessage = blockedSourceStop(urlSources, mode ?? 'council', allowBlockedSources);
221
+ if (stopMessage && process.stdin.isTTY && process.stdout.isTTY) {
222
+ const unreadable = urlSources.sources.filter((item) => item.status !== 'FETCHED');
223
+ const noun = unreadable.length > 1 ? 'these pages' : 'this page';
224
+ for (const source of unreadable) {
225
+ process.stdout.write(`\n ⚠ Couldn't read a page you attached:\n ${source.url}\n reason: ${source.error ?? source.status}\n`);
226
+ }
227
+ process.stdout.write(`\n Do you want to run without ${noun}?\n`);
228
+ process.stdout.write(` y = yes, run anyway (facts from ${noun} will be marked unverified)\n`);
229
+ process.stdout.write(' n = no, cancel — paste the page text into your prompt and rerun\n\n');
230
+ const answer = await ask(' Choice [y/N]: ');
231
+ if (/^y/i.test(answer)) {
232
+ allowBlockedSources = true;
233
+ }
234
+ else {
235
+ process.stdout.write('\n Cancelled. Paste the page text into your prompt and rerun, or rerun with --allow-blocked-sources.\n');
236
+ return 0;
237
+ }
238
+ }
239
+ else if (stopMessage) {
240
+ process.stderr.write(` ✖ ${stopMessage}\n`);
241
+ return 1;
242
+ }
243
+ }
176
244
  const outcome = await runEngine(workflow, text, {
177
245
  mode,
178
246
  budget: resolvedBudget,
@@ -182,6 +250,9 @@ export async function runCommand(workflow, input, opts = {}) {
182
250
  runsRoot: await resolveRunsRoot(), // hybrid: repo .aiki when in a repo, else ~/.aiki
183
251
  providerModels: cfg.models, // V8: per-provider model → CLI --model
184
252
  evidencePack,
253
+ allowBlockedSources,
254
+ urlSources,
255
+ autoDecision,
185
256
  });
186
257
  if (outcome.ok) {
187
258
  process.stdout.write(`\n ✔ run ${outcome.runId} complete — ${outcome.callCount} provider call(s)\n artifacts: ${outcome.dir}\n\n`);
@@ -0,0 +1,48 @@
1
+ // `aiki serve` — open the chat workspace on localhost. Resolves the built serve-ui assets, builds a
2
+ // FlightDeck over the hybrid runs root, starts the server, and (unless --no-open) opens the browser.
3
+ // The process stays up until Ctrl+C; there are no paid calls until the user convenes a run in the UI.
4
+ import { existsSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { FlightDeck } from '../serve/flight-deck.js';
8
+ import { startServer } from '../serve/server.js';
9
+ import { openInBrowser } from '../council/open.js';
10
+ import { resolveRunsRoot } from '../storage/paths.js';
11
+ import { VERSION } from './version.js';
12
+ /** Locate the serve-ui assets: dist/serve-ui next to this module (built), else repo-root serve-ui (dev). */
13
+ function resolveStaticDir() {
14
+ const here = dirname(fileURLToPath(import.meta.url)); // dist/cli
15
+ const built = join(here, '..', 'serve-ui'); // dist/serve-ui
16
+ if (existsSync(join(built, 'index.html')))
17
+ return built;
18
+ return join(here, '..', '..', 'serve-ui'); // repo-root serve-ui (running from src)
19
+ }
20
+ export async function serveCommand(opts = {}) {
21
+ const runsRoot = await resolveRunsRoot();
22
+ const log = (line) => {
23
+ const time = new Date().toLocaleTimeString('en-US', { hour12: false });
24
+ process.stdout.write(` \x1b[2m${time}\x1b[0m ${line}\n`);
25
+ };
26
+ const flightDeck = new FlightDeck({ runsRoot, version: VERSION, log });
27
+ const staticDir = resolveStaticDir();
28
+ let server;
29
+ try {
30
+ server = await startServer({ flightDeck, staticDir, port: opts.port });
31
+ }
32
+ catch (e) {
33
+ process.stderr.write(`aiki serve: ${e instanceof Error ? e.message : String(e)}\n`);
34
+ return 1;
35
+ }
36
+ const url = `http://127.0.0.1:${server.port}`;
37
+ process.stdout.write(`\n aiki workspace → ${url}\n (Ctrl+C to stop)\n\n`);
38
+ if (opts.open !== false)
39
+ openInBrowser(url);
40
+ await new Promise((resolve) => {
41
+ const shutdown = () => {
42
+ server.close().finally(() => resolve());
43
+ };
44
+ process.on('SIGINT', shutdown);
45
+ process.on('SIGTERM', shutdown);
46
+ });
47
+ return 0;
48
+ }
@@ -22,6 +22,7 @@ const ConfigRoles = z
22
22
  judge: ProviderIdSchema.optional(),
23
23
  verifier: ProviderIdSchema.optional(),
24
24
  s4: z.array(ProviderIdSchema).min(1).optional(),
25
+ responder: ProviderIdSchema.optional(),
25
26
  })
26
27
  .strict();
27
28
  /** Per-provider model override (V8). Each CLI runs its own model families, so model choice is per
@@ -96,7 +97,11 @@ export function mergeConfig(base, over) {
96
97
  }
97
98
  /** Layered config: global `~/.aiki/config.json` (base) overlaid by the project `.aiki/config.json`. What
98
99
  * the CLI actually uses (V8) — a user can set defaults once in the global file and override per project. */
99
- export async function loadLayeredConfig() {
100
- const [global, project] = await Promise.all([loadConfig(homeAikiRoot()), loadConfig('.aiki')]);
100
+ export async function loadLayeredConfig(projectRoot = '.aiki') {
101
+ const globalRoot = homeAikiRoot();
102
+ const global = await loadConfig(globalRoot);
103
+ if (projectRoot === globalRoot)
104
+ return global;
105
+ const project = await loadConfig(projectRoot);
101
106
  return mergeConfig(global, project);
102
107
  }
@@ -3,6 +3,7 @@ import { basename, join } from 'node:path';
3
3
  import { DISPLAY_NAME } from '../providers/types.js';
4
4
  import { RoleOutput as RoleOutputSchema } from '../schemas/index.js';
5
5
  import { deriveAudit, deriveScorecard, mergeOpenQuestions } from '../orchestration/stages/s10-render.js';
6
+ import { sanitizeLocalPaths } from '../orchestration/sanitize-paths.js';
6
7
  import { buildReaderProjection, claimLookup, readerClaimLabel, readerClaimRefs, renderDecisionDossierMarkdown, stripReaderClaimIds } from '../orchestration/decision-dossier.js';
7
8
  import { IDEA_RUBRIC } from '../workflows/idea-refinement.js';
8
9
  import { listArtifacts, readJsonArtifact } from '../storage/runs-read.js';
@@ -356,6 +357,12 @@ function dossierCoverageLabel(value) {
356
357
  return value >= 0.75 ? 'High' : value >= 0.5 ? 'Medium' : 'Low';
357
358
  }
358
359
  function dossierCouncilRead(report) {
360
+ if (report.fastPath)
361
+ return 'Single-pass analysis; council escalation was not required.';
362
+ if (report.adaptiveAuto)
363
+ return report.autoEscalationReasons?.length
364
+ ? `The primary analysis tripped structural gates; two task readings checked the interpretation${report.autoChallengeUsed ? ', and one targeted challenger checked eligible claims' : '; no challenger could add information'}. No full council was convened.`
365
+ : 'Two task readings informed one primary decision analyst. No full council was convened.';
359
366
  if (report.mode === 'quick')
360
367
  return 'One structured analyst produced this result; no council, consensus, or independent-verification claim is being made.';
361
368
  const scouts = report.models.filter((model) => model.roles.includes('scout')).length;
@@ -393,7 +400,7 @@ function renderDossierIdeaBody(report) {
393
400
  const decisiveNumbers = snapshot ? `<div class="decision-numbers">
394
401
  <span class="section-eyebrow">Decisive numbers</span>
395
402
  <div class="table-wrap"><table class="snapshot-table decisive-table"><thead><tr><th>Metric</th><th>Value</th><th>What it means</th></tr></thead><tbody>${snapshot.decisiveNumbers.map((item) => `<tr><td>${escapeHtml(item.label)}</td><td class="number-value">${escapeHtml(item.value)}</td><td>${escapeHtml(item.meaning)}</td></tr>`).join('')}</tbody></table></div>
396
- ${snapshot.payback ? `<div class="payback-result"><span>Payback · ${escapeHtml(snapshot.payback.status.replaceAll('_', ' '))}</span><strong>${escapeHtml(snapshot.payback.result)}</strong><p>${escapeHtml(snapshot.payback.basis)}</p></div>` : ''}
403
+ ${snapshot.payback && snapshot.payback.status !== 'NOT_COMPUTABLE' ? `<div class="payback-result"><span>Payback · ${escapeHtml(snapshot.payback.status.replaceAll('_', ' '))}</span><strong>${escapeHtml(snapshot.payback.result)}</strong><p>${escapeHtml(snapshot.payback.basis)}</p></div>` : ''}
397
404
  </div>` : '';
398
405
  const optionComparison = snapshot ? `<div class="option-comparison">
399
406
  <span class="section-eyebrow">Options at a glance</span>
@@ -469,6 +476,7 @@ function renderDossierIdeaBody(report) {
469
476
  const contributions = `${dossierWarning(report, ['verification_skipped', 'single_model', 'low_diversity'])}<p class="lede">Only unique claims that survived independent verification receive credit.</p>${dossierTable(['Provider', 'Verified unique contributions', 'Count'], dossier.contributions.map((item) => [item.name, readerClaimRefs(report, item.verifiedUniqueClaimIds), String(item.verifiedUniqueClaimIds.length)]))}`;
470
477
  const receipt = `<div class="receipt">
471
478
  <span>mode ${escapeHtml(report.mode)}</span><span>${report.receipt.calls}/${report.receipt.budget} provider calls</span>
479
+ ${report.autoEscalationReasons?.length ? `<span>adaptive escalation ${escapeHtml(report.autoEscalationReasons.join('; '))}</span>` : ''}
472
480
  <span>discovery ${report.receipt.categories.discovery}</span><span>verification ${report.receipt.categories.verification}</span>
473
481
  <span>repair ${report.receipt.categories.repair}</span><span>planning ${report.receipt.categories.planning}</span>
474
482
  <span>by provider ${escapeHtml(Object.entries(report.receipt.byProvider).map(([provider, count]) => `${providerName(provider)} ${count}`).join(', ') || 'none')}</span>
@@ -522,7 +530,7 @@ function renderReaderBriefIdeaBody(report) {
522
530
  const warnings = projection.warnings.length || projection.notices.length
523
531
  ? `<div class="warns">${projection.warnings.map((warning) => `<span class="warn">⚑ ${escapeHtml(warning.message)}</span>`).join('')}${projection.notices.map((notice) => `<span class="warn">ⓘ ${escapeHtml(notice.message)}</span>`).join('')}</div>`
524
532
  : '';
525
- const snapshot = projection.snapshot ? section('', 'Decision numbers', `${dossierTable(['Metric', 'Value', 'What it means'], projection.snapshot.decisiveNumbers.map((item) => [item.label, item.value, item.meaning]))}${projection.snapshot.payback ? `<div class="action-callout"><span>Payback · ${escapeHtml(projection.snapshot.payback.status.replaceAll('_', ' '))}</span><p>${escapeHtml(projection.snapshot.payback.result)} — ${escapeHtml(projection.snapshot.payback.basis)}</p></div>` : ''}<h3>Options at a glance</h3>${dossierTable(['Path', 'Commitment', 'Basis', 'Trade-off'], projection.snapshot.options.map((item) => [item.label, item.commitment, item.commitmentKind.replace('_', ' '), item.tradeoff]))}${projection.snapshot.tripwire ? `<div class="action-callout"><span>Go/no-go tripwire</span><p><strong>${escapeHtml(projection.snapshot.tripwire.metric)}: ${escapeHtml(projection.snapshot.tripwire.threshold)}</strong> — ${escapeHtml(projection.snapshot.tripwire.decisionRule)}</p></div>` : ''}`, 80) : '';
533
+ const snapshot = projection.snapshot ? section('', 'Decision numbers', `${dossierTable(['Metric', 'Value', 'What it means'], projection.snapshot.decisiveNumbers.map((item) => [item.label, item.value, item.meaning]))}${projection.snapshot.payback && projection.snapshot.payback.status !== 'NOT_COMPUTABLE' ? `<div class="action-callout"><span>Payback · ${escapeHtml(projection.snapshot.payback.status.replaceAll('_', ' '))}</span><p>${escapeHtml(projection.snapshot.payback.result)} — ${escapeHtml(projection.snapshot.payback.basis)}</p></div>` : ''}<h3>Options at a glance</h3>${dossierTable(['Path', 'Commitment', 'Basis', 'Trade-off'], projection.snapshot.options.map((item) => [item.label, item.commitment, item.commitmentKind.replace('_', ' '), item.tradeoff]))}${projection.snapshot.tripwire ? `<div class="action-callout"><span>Go/no-go tripwire</span><p><strong>${escapeHtml(projection.snapshot.tripwire.metric)}: ${escapeHtml(projection.snapshot.tripwire.threshold)}</strong> — ${escapeHtml(projection.snapshot.tripwire.decisionRule)}</p></div>` : ''}`, 80) : '';
526
534
  const editorial = projection.sections.map((item, index) => section(String(index + 1).padStart(2, '0'), item.heading, `<p class="lede">${escapeHtml(item.summary)}</p>${item.bullets.length
527
535
  ? `<ul class="reasons">${item.bullets.map((bullet) => `<li>${escapeHtml(bullet)}</li>`).join('')}</ul>`
528
536
  : ''}`, 100 + index * 40)).join('');
@@ -947,7 +955,8 @@ export function renderCouncilHtml(view) {
947
955
  : quick ? renderQuickIdeaBody(view) : isIdea ? renderIdeaBody(view) : renderReviewBody(view);
948
956
  // Embed the report as Markdown for the Copy button. Escape "<" so a "</script>" in content can't break out.
949
957
  const md = JSON.stringify(councilMarkdown(view)).replace(/</g, '\\u003c');
950
- return `<!doctype html>
958
+ // v6: the whole rendered page is path-sanitized — no local usernames in any artifact (T8).
959
+ return sanitizeLocalPaths(`<!doctype html>
951
960
  <html lang="en">
952
961
  <head>
953
962
  <meta charset="utf-8">
@@ -1230,7 +1239,7 @@ async function copyReport(btn){
1230
1239
  document.getElementById('copy-report').addEventListener('click',function(){ copyReport(this); });
1231
1240
  </script>
1232
1241
  </body>
1233
- </html>`;
1242
+ </html>`);
1234
1243
  }
1235
1244
  export async function writeCouncilHtml(runId, dir) {
1236
1245
  const view = await loadCouncilView(runId, dir);
@@ -0,0 +1,97 @@
1
+ // v7 Phase B — `--mode auto`: a deterministic quick-vs-council routing decision made in the CLI
2
+ // BEFORE the engine runs. No model call, no learned routing (§22 forbids that). The rules err toward
3
+ // council: a false council only costs more calls, a false quick under-scrutinizes a risky decision.
4
+ // ponytail: v1 keyword heuristics, not NLP. Upgrade path = a scored intent matcher if precision matters.
5
+ const SENSITIVE = /\b(?:regulat|complian|legal|lawsuit|licens|liabilit|hipaa|gdpr|pci|soc\s?2|financ|fintech|invest|tax(?:es|ation)?|loan|mortgage|insuran|securities|security|vulnerab|exploit|breach|malware|phishing|credential|medical|patient|clinical|diagnos)\w*/i;
6
+ const RESEARCH = /\b(?:research|investigat|competitive analysis|market size|find (?:sources|evidence|data)|look up|up[- ]to[- ]date|latest data|current (?:data|figures|numbers|pricing)|cite sources?)\w*/i;
7
+ // ponytail: v1 decision-question heuristic; upgrade path = the same scored matcher as SENSITIVE/RESEARCH.
8
+ const DECISION_INTENT = /\b(?:should (?:i|we|you)|is it worth|worth (?:it|building|doing|switching|the)|do i (?:need|use)|(?:choose|pick|decide|select) (?:between|whether)|go with|switch(?:ing)? (?:to|from)|migrate (?:to|from)|which\b[^.?!]*\bor\b)/i;
9
+ export function buildTaskProfile(input, opts) {
10
+ const wordCount = input.trim().split(/\s+/).filter(Boolean).length;
11
+ return {
12
+ wordCount,
13
+ urlCount: opts.urlCount,
14
+ hasEvidencePack: opts.hasEvidencePack,
15
+ deliverablesBeyondDecision: opts.requestedOutputs.some((o) => o !== 'DECISION'),
16
+ hasSensitiveKeyword: SENSITIVE.test(input),
17
+ hasResearchWording: RESEARCH.test(input),
18
+ hasDecisionIntent: wordCount >= 4 && DECISION_INTENT.test(input),
19
+ };
20
+ }
21
+ /** Ordered deterministic rules: council if ANY signal fires, else quick. `fastPath` (Phase C, quick-only)
22
+ * is the 1-call single-pass path — eligible when quick AND the input reads as a direct decision question. */
23
+ export function resolveAutoMode(profile) {
24
+ const reasons = [];
25
+ if (profile.urlCount > 0)
26
+ reasons.push('URLs supplied');
27
+ if (profile.hasEvidencePack)
28
+ reasons.push('evidence pack supplied');
29
+ if (profile.hasSensitiveKeyword)
30
+ reasons.push('regulated/financial/security topic');
31
+ if (profile.deliverablesBeyondDecision)
32
+ reasons.push('deliverables beyond a decision requested');
33
+ if (profile.hasResearchWording)
34
+ reasons.push('research wording detected');
35
+ if (profile.wordCount > 120)
36
+ reasons.push('long or complex input (>120 words)');
37
+ if (reasons.length > 0)
38
+ return { mode: 'council', reasons, fastPath: false };
39
+ return { mode: 'quick', reasons: ['plain single-decision prompt'], fastPath: profile.hasDecisionIntent };
40
+ }
41
+ function evidenceForClaim(graph, claimId) {
42
+ const ids = new Set(graph.edges
43
+ .filter((edge) => edge.to === claimId && (edge.type === 'SUPPORTS' || edge.type === 'ATTACKS'))
44
+ .map((edge) => edge.from));
45
+ return graph.evidence.filter((evidence) => ids.has(evidence.id));
46
+ }
47
+ /** Phase D hard gates over validated graph structure; ordered for stable receipts and fixtures. */
48
+ export function structuralEscalationGates(graph) {
49
+ const gates = [];
50
+ const seen = new Set();
51
+ const add = (claimId, kind, reason) => {
52
+ const key = `${claimId}:${kind}`;
53
+ if (seen.has(key))
54
+ return;
55
+ seen.add(key);
56
+ gates.push({ claimId, kind, reason });
57
+ };
58
+ for (const claim of graph.claims) {
59
+ const evidence = evidenceForClaim(graph, claim.id);
60
+ if (claim.sensitivity === 'DECISIVE'
61
+ && !evidence.some((item) => item.source_kind === 'PRIMARY' || item.source_kind === 'SECONDARY')) {
62
+ add(claim.id, 'NO_INDEPENDENT_EVIDENCE', 'decisive claim has no independent evidence');
63
+ }
64
+ }
65
+ for (const claim of graph.claims) {
66
+ const evidence = evidenceForClaim(graph, claim.id);
67
+ if ((claim.if_false === 'STOP' || claim.if_false === 'PIVOT')
68
+ && evidence.length > 0
69
+ && evidence.every((item) => item.source_kind === 'MODEL_KNOWLEDGE')) {
70
+ add(claim.id, 'MODEL_KNOWLEDGE_DECISION', 'STOP/PIVOT claim rests on model knowledge');
71
+ }
72
+ }
73
+ for (const check of graph.calculation_checks) {
74
+ if (check.status === 'FAIL')
75
+ add(check.claim_id, 'FAILED_CALCULATION', 'deterministic calculation failed');
76
+ }
77
+ for (const claim of graph.claims) {
78
+ if (claim.load_bearing && evidenceForClaim(graph, claim.id)
79
+ .some((item) => item.source_kind === 'USER' && item.support === 'CONTRADICTS')) {
80
+ add(claim.id, 'SUPPLIED_SOURCE_CONTRADICTION', 'user-supplied evidence contradicts a load-bearing claim');
81
+ }
82
+ }
83
+ for (const claim of graph.claims) {
84
+ if (claim.load_bearing && claim.state === 'DISAGREEMENT') {
85
+ add(claim.id, 'LOAD_BEARING_DISAGREEMENT', 'load-bearing claims are mutually inconsistent');
86
+ }
87
+ }
88
+ return gates;
89
+ }
90
+ /** One challenge call is useful only when the stored graph contains something new to inspect. */
91
+ export function canProduceNewInformation(graph, claimId) {
92
+ if (!graph.claims.some((claim) => claim.id === claimId))
93
+ return false;
94
+ return evidenceForClaim(graph, claimId).some((item) => item.source_kind !== 'MODEL_KNOWLEDGE')
95
+ || graph.calculation_checks.some((check) => check.claim_id === claimId && check.status === 'FAIL')
96
+ || graph.edges.some((edge) => edge.type === 'DEPENDS_ON' && (edge.from === claimId || edge.to === claimId));
97
+ }
@@ -0,0 +1,56 @@
1
+ // v6 semantic claim join (plan/AIKI-v6-council-integrity-plan.md T5). The lexical ≥0.8 grouping in
2
+ // S7 cannot join cross-provider paraphrases, so "0 consensus · 0 disputes" was structural (run
3
+ // f740: codex G3 and agy G13 both said "build `aiki serve`" and sat as two UNIQUE claims). S8 now
4
+ // emits `claim_groups`; this module validates them deterministically and OVERLAYS states onto the
5
+ // compiled graph — claim ids, propositions, edges, and stored artifacts are never mutated, and the
6
+ // state itself is computed by the SAME `classifyClaimState` machine the lexical path uses.
7
+ import { classifyClaimState } from './decision-graph.js';
8
+ /** Drop anything the model got wrong: unknown claim ids, duplicate ids, and groups whose claims
9
+ * all come from ONE provider — a model agreeing with itself is not consensus. */
10
+ export function sanitizeClaimGroups(graph, groups) {
11
+ if (!groups?.length)
12
+ return [];
13
+ const positionById = new Map(graph.positions.map((position) => [position.id, position]));
14
+ const claimById = new Map(graph.claims.map((claim) => [claim.id, claim]));
15
+ return groups.flatMap((group) => {
16
+ const ids = [...new Set(group.ids)].filter((id) => claimById.has(id));
17
+ if (ids.length < 2)
18
+ return [];
19
+ const providers = new Set(ids.flatMap((id) => claimById.get(id).position_ids.map((positionId) => positionById.get(positionId)?.provider)
20
+ .filter((provider) => provider !== undefined)));
21
+ if (providers.size < 2)
22
+ return [];
23
+ return [{ ids, relation: group.relation }];
24
+ });
25
+ }
26
+ /** Overlay group-derived states onto a copy of the graph. SAME → the union of the member claims'
27
+ * positions is re-classified by the existing state machine (CONSENSUS/SHARED_CONCERN/…);
28
+ * OPPOSES → DISAGREEMENT, which wins over any SAME assignment. Old artifacts (no groups) pass
29
+ * through unchanged, so every pre-v6 run keeps its exact rendering. */
30
+ export function overlayClaimGroups(graph, groups) {
31
+ const sane = sanitizeClaimGroups(graph, groups);
32
+ if (!sane.length)
33
+ return graph;
34
+ const positionById = new Map(graph.positions.map((position) => [position.id, position]));
35
+ const claimById = new Map(graph.claims.map((claim) => [claim.id, claim]));
36
+ const stateById = new Map();
37
+ for (const group of sane) {
38
+ if (group.relation === 'OPPOSES') {
39
+ for (const id of group.ids)
40
+ stateById.set(id, 'DISAGREEMENT');
41
+ continue;
42
+ }
43
+ const union = group.ids
44
+ .flatMap((id) => claimById.get(id).position_ids)
45
+ .map((id) => positionById.get(id))
46
+ .filter((position) => position !== undefined);
47
+ const state = classifyClaimState(union);
48
+ for (const id of group.ids)
49
+ if (stateById.get(id) !== 'DISAGREEMENT')
50
+ stateById.set(id, state);
51
+ }
52
+ return {
53
+ ...graph,
54
+ claims: graph.claims.map((claim) => (stateById.has(claim.id) ? { ...claim, state: stateById.get(claim.id) } : claim)),
55
+ };
56
+ }