aiki-cli 0.3.1 → 0.3.3

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 (59) hide show
  1. package/CHANGELOG.md +80 -5
  2. package/README.md +73 -15
  3. package/dist/bench/idea-v3-bench.js +104 -5
  4. package/dist/bench/idea-v3-rating.js +159 -4
  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 +76 -5
  9. package/dist/cli/serve.js +48 -0
  10. package/dist/config/config.js +7 -2
  11. package/dist/council/view.js +64 -15
  12. package/dist/orchestration/auto-profile.js +97 -0
  13. package/dist/orchestration/claim-groups.js +56 -0
  14. package/dist/orchestration/context.js +69 -6
  15. package/dist/orchestration/decision-dossier.js +450 -89
  16. package/dist/orchestration/decision-graph.js +33 -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/legacy-idea-adapter.js +3 -0
  21. package/dist/orchestration/modes.js +18 -10
  22. package/dist/orchestration/preflight.js +36 -3
  23. package/dist/orchestration/quick-analysis.js +29 -7
  24. package/dist/orchestration/sanitize-paths.js +10 -0
  25. package/dist/orchestration/stages/s10-render.js +196 -84
  26. package/dist/orchestration/stages/s4-analyze.js +10 -2
  27. package/dist/orchestration/stages/s4b-challenge.js +97 -0
  28. package/dist/orchestration/stages/s5-drift.js +13 -3
  29. package/dist/orchestration/stages/s6-positions.js +18 -0
  30. package/dist/orchestration/stages/s7-decision-graph.js +3 -0
  31. package/dist/orchestration/stages/s8-verify.js +66 -12
  32. package/dist/orchestration/stages/s8b-rebuttal.js +11 -4
  33. package/dist/orchestration/stages/s9-judge.js +52 -14
  34. package/dist/orchestration/stages/s9b-plan.js +227 -48
  35. package/dist/orchestration/url-sources.js +21 -0
  36. package/dist/providers/adapter-core.js +1 -1
  37. package/dist/providers/claude.js +18 -0
  38. package/dist/schemas/index.js +112 -3
  39. package/dist/serve/flight-deck.js +830 -0
  40. package/dist/serve/followup.js +50 -0
  41. package/dist/serve/frames.js +168 -0
  42. package/dist/serve/gates.js +72 -0
  43. package/dist/serve/projections.js +283 -0
  44. package/dist/serve/server.js +219 -0
  45. package/dist/serve/threads.js +145 -0
  46. package/dist/serve-ui/Five.png +0 -0
  47. package/dist/serve-ui/app.js +820 -0
  48. package/dist/serve-ui/index.html +171 -0
  49. package/dist/serve-ui/workspace.css +662 -0
  50. package/dist/skills/idea-refinement/analyst.md +5 -5
  51. package/dist/skills/idea-refinement/chair.md +18 -0
  52. package/dist/skills/idea-refinement/economics-delivery.md +11 -0
  53. package/dist/skills/idea-refinement/market-adoption.md +12 -0
  54. package/dist/skills/idea-refinement/planner.md +25 -19
  55. package/dist/skills/idea-refinement/rebuttal.md +15 -0
  56. package/dist/skills/idea-refinement/verifier.md +17 -0
  57. package/dist/storage/runs.js +2 -1
  58. package/dist/workflows/idea-refinement.js +130 -24
  59. package/package.json +2 -2
@@ -3,7 +3,8 @@ 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 { readerClaimLabel, readerClaimRefs, renderDecisionDossierMarkdown, stripReaderClaimIds } from '../orchestration/decision-dossier.js';
6
+ import { sanitizeLocalPaths } from '../orchestration/sanitize-paths.js';
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';
9
10
  import { adaptIdeaOutput, adaptLegacyDecisionGraph } from '../orchestration/legacy-idea-adapter.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;
@@ -377,19 +384,23 @@ function renderDossierIdeaBody(report) {
377
384
  const criticalUnknowns = report.criticalUnknowns?.length ? report.criticalUnknowns : report.openQuestions.slice(0, 3);
378
385
  const tone = dossier.recommendation.status === 'ACCEPTED' ? 'good'
379
386
  : dossier.recommendation.status === 'REJECTED' ? 'risk' : 'caution';
380
- const conditions = dossier.recommendation.conditions.length
381
- ? `<details class="decision-details"><summary>Conditions and decision state</summary><div><p><strong>Internal state:</strong> ${escapeHtml(dossier.recommendation.status)}</p><ul>${dossier.recommendation.conditions.map((condition) => `<li>${escapeHtml(condition.text)}</li>`).join('')}</ul></div></details>`
387
+ const labelFor = claimLookup(report);
388
+ // Substitute ids then dedupe before escaping, mirroring the markdown renderer: old stored artifacts
389
+ // may carry duplicate / bare-G# condition strings.
390
+ const conditionItems = [...new Set(dossier.recommendation.conditions.map((condition) => stripReaderClaimIds(condition.text, labelFor)))];
391
+ const conditions = conditionItems.length
392
+ ? `<details class="decision-details"><summary>Conditions and decision state</summary><div><p><strong>Internal state:</strong> ${escapeHtml(dossier.recommendation.status)}</p><ul>${conditionItems.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul></div></details>`
382
393
  : '';
383
394
  const startHere = dossier.experiments.actions[0]?.action ?? 'No executable next step was produced.';
384
- const recommendationLead = stripReaderClaimIds(dossier.recommendation.reason);
385
- const recommendationDetail = stripReaderClaimIds(dossier.recommendation.summary);
395
+ const recommendationLead = stripReaderClaimIds(dossier.recommendation.reason, labelFor);
396
+ const recommendationDetail = stripReaderClaimIds(dossier.recommendation.summary, labelFor);
386
397
  const factLabels = ['Decisive result', 'Consequence', 'Supporting signal'];
387
398
  const facts = keyFindings.slice(0, 3).map((finding, index) => `<article class="decision-fact"><span>${factLabels[index]}</span><p>${escapeHtml(finding)}</p></article>`).join('');
388
399
  const snapshot = report.decisionSnapshot;
389
400
  const decisiveNumbers = snapshot ? `<div class="decision-numbers">
390
401
  <span class="section-eyebrow">Decisive numbers</span>
391
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>
392
- ${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>` : ''}
393
404
  </div>` : '';
394
405
  const optionComparison = snapshot ? `<div class="option-comparison">
395
406
  <span class="section-eyebrow">Options at a glance</span>
@@ -465,12 +476,16 @@ function renderDossierIdeaBody(report) {
465
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)]))}`;
466
477
  const receipt = `<div class="receipt">
467
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>` : ''}
468
480
  <span>discovery ${report.receipt.categories.discovery}</span><span>verification ${report.receipt.categories.verification}</span>
469
481
  <span>repair ${report.receipt.categories.repair}</span><span>planning ${report.receipt.categories.planning}</span>
470
482
  <span>by provider ${escapeHtml(Object.entries(report.receipt.byProvider).map(([provider, count]) => `${providerName(provider)} ${count}`).join(', ') || 'none')}</span>
471
483
  <span>model time ${(report.receipt.modelTimeMs / 1000).toFixed(1)}s</span>
472
484
  </div>${report.flags.length ? `<div class="warns">${report.flags.map((flag) => `<span class="warn">⚑ ${escapeHtml(flag)}</span>`).join('')}</div>` : '<p class="muted">No degradation flags.</p>'}`;
473
- const risks = dossierTable(['Risk', 'Severity'], report.risks.map((item) => [item.risk, item.severity]));
485
+ const shownRisks = report.risks.slice(0, 8);
486
+ const risks = `${dossierTable(['Risk', 'Severity'], shownRisks.map((item) => [item.risk, item.severity]))}${report.risks.length > shownRisks.length
487
+ ? `<p class="muted">${report.risks.length - shownRisks.length} lower-severity items — more in the technical audit (full list in the stored JSON).</p>`
488
+ : ''}`;
474
489
  const questions = report.openQuestions.length
475
490
  ? `<ul class="checks">${report.openQuestions.map((question) => `<li>${escapeHtml(question)}</li>`).join('')}</ul>`
476
491
  : '<p class="muted">No verdict-flipping open question was recorded.</p>';
@@ -500,7 +515,37 @@ function renderDossierIdeaBody(report) {
500
515
  section('08', 'What the council added', `${shared}${unique}<h3>Verified unique contributions</h3>${contributions}`, 340),
501
516
  section('09', 'Run details', runDetails, 380),
502
517
  ].join('');
503
- return `${stripReaderClaimIds(readerBody)}${section('10', 'Technical audit', technical, 420)}`;
518
+ return `${stripReaderClaimIds(readerBody, labelFor)}${section('10', 'Technical audit', technical, 420)}`;
519
+ }
520
+ function renderReaderBriefIdeaBody(report) {
521
+ const dossier = report.dossier;
522
+ const projection = buildReaderProjection(report);
523
+ const tone = dossier.recommendation.status === 'ACCEPTED' ? 'good'
524
+ : dossier.recommendation.status === 'REJECTED' ? 'risk' : 'caution';
525
+ const hero = `<section class="verdict tone-${tone} reveal" style="animation-delay:60ms">
526
+ <span class="pill">Recommendation</span>
527
+ <p class="verdict-text">${escapeHtml(projection.headline)}</p>
528
+ <p class="verdict-detail">${escapeHtml(projection.bottomLine)}</p>
529
+ </section>`;
530
+ const warnings = projection.warnings.length || projection.notices.length
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>`
532
+ : '';
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) : '';
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
535
+ ? `<ul class="reasons">${item.bullets.map((bullet) => `<li>${escapeHtml(bullet)}</li>`).join('')}</ul>`
536
+ : ''}`, 100 + index * 40)).join('');
537
+ const backlog = projection.featureBacklog;
538
+ const features = backlog ? section('', 'Feature priorities', `<div class="feature-groups">${[
539
+ ['MUST', 'Build for the first useful release', backlog.must],
540
+ ['SHOULD', 'Add after the golden path is stable', backlog.should],
541
+ ['LATER', 'Keep outside the current build', backlog.later],
542
+ ].filter(([, , items]) => items.length).map(([priority, description, items]) => `<section class="feature-group priority-${priority.toLowerCase()}"><header><div><span>${priority}</span><h4>${description}</h4></div><strong>${items.length}</strong></header><ul>${items.map((item) => `<li><div class="feature-title"><strong>${escapeHtml(item.feature)}</strong><span>${item.effort}</span></div><p>${escapeHtml(item.user_value)}</p><small>${escapeHtml(item.rationale)}</small></li>`).join('')}</ul></section>`).join('')}</div>${backlog.wont.length ? `<h3>Not in this scope</h3><ul class="checks">${backlog.wont.map((item) => `<li><strong>${escapeHtml(item.feature)}</strong> — ${escapeHtml(item.reason)}</li>`).join('')}</ul>` : ''}`, 340) : '';
543
+ const buildPlan = projection.implementationPlan ? section('', 'Build plan', `<ol class="milestone-list">${projection.implementationPlan.milestones.map((milestone) => `<li><div class="milestone-marker"><span>${String(milestone.order).padStart(2, '0')}</span><small>${escapeHtml(milestone.timebox)}</small></div><article><h4>${escapeHtml(milestone.outcome)}</h4><ul>${milestone.tasks.map((task) => `<li>${escapeHtml(task)}</li>`).join('')}</ul><div class="acceptance"><span>Done when</span><p>${escapeHtml(milestone.acceptance_test)}</p></div></article></li>`).join('')}</ol>`, 380) : '';
544
+ const caveats = projection.caveats.length ? section('', 'Top caveats', `<ul class="checks">${projection.caveats.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul>`, 420) : '';
545
+ const sources = projection.sources.length ? section('', 'Sources', `<ul class="agree">${projection.sources.map((source) => `<li><p>${source.url ? `<a href="${escapeHtml(source.url)}" rel="noopener noreferrer">${escapeHtml(source.label)}</a>` : escapeHtml(source.label)}</p>${source.citedFor.length ? `<small>Cited for: ${source.citedFor.map(escapeHtml).join('; ')}</small>` : ''}</li>`).join('')}</ul>`, 460) : '';
546
+ const nextStep = section('', 'Next step', `<div class="action-callout"><span>Do this now</span><p>${escapeHtml(projection.nextStep)}</p></div>`, 500);
547
+ const audit = `<details class="fold council-audit"><summary>Council audit — reasoning, evidence, dissent, and run receipt</summary><div class="fold-body">${renderDossierIdeaBody(report)}</div></details>`;
548
+ return `${hero}${warnings}${snapshot}${editorial}${features}${buildPlan}${caveats}${sources}${nextStep}${audit}`;
504
549
  }
505
550
  function renderLegacyIdeaBody(view) {
506
551
  const risks = view.risks ?? [];
@@ -891,23 +936,27 @@ function renderTechnical(view) {
891
936
  export function renderCouncilHtml(view) {
892
937
  const isIdea = view.workflow !== 'code-review';
893
938
  const quick = isIdea && view.mode === 'quick';
939
+ const hasReaderBrief = Boolean(view.decisionReport?.dossier.readerBrief);
894
940
  const kicker = quick ? 'aiki · quick analysis' : isIdea ? 'aiki · idea refinement' : 'aiki · code review';
895
941
  const title = isIdea && view.topic ? cleanTopic(view.topic) : (isIdea ? 'Idea refinement' : 'Code review');
896
942
  const panel = view.columns.map((c) => c.title);
897
- const metaBits = [
943
+ const metaBits = hasReaderBrief ? [] : [
898
944
  panel.length ? `${quick ? 'Analyst' : 'Panel'}: ${panel.join(' · ')}` : '',
899
945
  !quick && view.moderator ? `Chair: ${view.moderator}` : '',
900
946
  view.calls,
901
947
  ].filter(Boolean);
902
- const flags = view.flags.length
948
+ const flags = !hasReaderBrief && view.flags.length
903
949
  ? `<div class="warns">${view.flags.map((f) => `<span class="warn">⚑ ${escapeHtml(f.replaceAll('_', ' '))}</span>`).join('')}</div>`
904
950
  : '';
905
951
  const body = isIdea && view.decisionReport?.dossier
906
- ? renderDossierIdeaBody(view.decisionReport)
952
+ ? view.decisionReport.dossier.readerBrief
953
+ ? renderReaderBriefIdeaBody(view.decisionReport)
954
+ : renderDossierIdeaBody(view.decisionReport)
907
955
  : quick ? renderQuickIdeaBody(view) : isIdea ? renderIdeaBody(view) : renderReviewBody(view);
908
956
  // Embed the report as Markdown for the Copy button. Escape "<" so a "</script>" in content can't break out.
909
957
  const md = JSON.stringify(councilMarkdown(view)).replace(/</g, '\\u003c');
910
- return `<!doctype html>
958
+ // v6: the whole rendered page is path-sanitized — no local usernames in any artifact (T8).
959
+ return sanitizeLocalPaths(`<!doctype html>
911
960
  <html lang="en">
912
961
  <head>
913
962
  <meta charset="utf-8">
@@ -928,14 +977,14 @@ html{-webkit-text-size-adjust:100%;}
928
977
  body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--sans);font-size:16px;line-height:1.6;}
929
978
  main{max-width:940px;margin:0 auto;padding:34px 24px 90px;}
930
979
  a{color:var(--accent);}
931
- h1{font-family:var(--serif);font-weight:600;letter-spacing:-.025em;}
980
+ h1{font-family:var(--sans);font-weight:650;letter-spacing:-.01em;}
932
981
  h2,h3,h4{font-family:var(--sans);font-weight:650;letter-spacing:-.01em;}
933
982
  p{margin:0 0 .6em;}
934
983
 
935
984
  /* masthead */
936
985
  .mast{border-bottom:1px solid var(--ink);padding-bottom:22px;margin-bottom:30px;}
937
986
  .kicker{font-family:var(--mono);font-size:11.5px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);}
938
- .mast h1{font-size:clamp(32px,5.4vw,50px);line-height:1.05;margin:12px 0 18px;max-width:18ch;}
987
+ .mast h1{font-size:clamp(28px,4.6vw,42px);line-height:1.12;margin:12px 0 16px;}
939
988
  .mmeta{display:flex;flex-wrap:wrap;gap:7px;}
940
989
  .mmeta span{font-family:var(--mono);font-size:11.5px;color:var(--soft);border:1px solid var(--line);background:var(--panel);border-radius:100px;padding:3px 10px;}
941
990
  .warns{margin-top:12px;display:flex;flex-wrap:wrap;gap:8px;}
@@ -1190,7 +1239,7 @@ async function copyReport(btn){
1190
1239
  document.getElementById('copy-report').addEventListener('click',function(){ copyReport(this); });
1191
1240
  </script>
1192
1241
  </body>
1193
- </html>`;
1242
+ </html>`);
1194
1243
  }
1195
1244
  export async function writeCouncilHtml(runId, dir) {
1196
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
+ }
@@ -15,7 +15,7 @@ import { extractJson } from '../providers/adapter-core.js';
15
15
  import { detect } from '../providers/detect.js';
16
16
  import { probeFlags } from '../providers/probe.js';
17
17
  import { replayKey } from '../storage/replay.js';
18
- import { callCategory, defaultBudgetFor, IDEA_MODE_PLANS, isOptionalStage, LEGACY_DEFAULT_BUDGET } from './modes.js';
18
+ import { callCategory, defaultBudgetFor, defaultDeadlineFor, IDEA_MODE_PLANS, isOptionalStage, LEGACY_DEFAULT_BUDGET } from './modes.js';
19
19
  /** Bracket a stage call with the TUI's start/end events (no-op headless). A thrown StageError marks
20
20
  * the row `failed` and re-propagates unchanged — the engine's failure handling is untouched. */
21
21
  export async function runStage(ctx, id, fn) {
@@ -39,7 +39,7 @@ export const DEFAULT_DEADLINE_MS = 20 * 60 * 1000; // wall-clock cap
39
39
  // (2026-07-13) after the spawn timeout became actually enforced and killed a LEGITIMATE deep call: codex's
40
40
  // S4 analysis of a hard build case ran ~10 min to a valid output (run 20260713-1341, 13:44→13:54). 900s
41
41
  // per attempt covers observed deep work; the wall-clock deadline above remains the outer bound.
42
- const DEFAULT_CALL_TIMEOUT_MS = 900_000;
42
+ export const DEFAULT_CALL_TIMEOUT_MS = 900_000;
43
43
  export class BudgetExceeded extends Error {
44
44
  constructor(limit) {
45
45
  super(`call budget exhausted (limit ${limit})`);
@@ -74,6 +74,9 @@ export class RunCtx {
74
74
  cwd;
75
75
  events;
76
76
  evidencePack;
77
+ allowBlockedSources;
78
+ urlSources;
79
+ isAuto;
77
80
  budget;
78
81
  calls = [];
79
82
  /** Logical provider-call stages, including resume cache hits. Used by bounded protocol caps. */
@@ -87,6 +90,8 @@ export class RunCtx {
87
90
  deadlineAt;
88
91
  now;
89
92
  replay; // resume replay cache (V6.3)
93
+ autoDecision; // v7 Phase B/D: routing record + output escalation receipt
94
+ fastPathActive;
90
95
  seq = 0; // monotonic per-call counter for raw/ filenames (== budget.used on a fresh run)
91
96
  constructor(opts) {
92
97
  this.runId = opts.runId;
@@ -97,13 +102,36 @@ export class RunCtx {
97
102
  this.cwd = opts.cwd;
98
103
  this.events = opts.events;
99
104
  this.evidencePack = opts.evidencePack;
105
+ this.allowBlockedSources = opts.allowBlockedSources;
106
+ this.urlSources = opts.urlSources;
107
+ this.isAuto = opts.autoDecision !== undefined;
108
+ this.fastPathActive = this.mode === 'quick'
109
+ && opts.autoDecision?.resolved === 'quick'
110
+ && opts.autoDecision.fast_path === true;
100
111
  this.budget = { limit: opts.budget ?? defaultBudgetFor(opts.workflow, this.mode), used: 0 };
101
112
  this.handles = new Map(opts.handles.map((h) => [h.id, h]));
102
113
  this.signal = opts.signal;
103
- this.deadlineMs = opts.deadlineMs ?? DEFAULT_DEADLINE_MS;
114
+ this.deadlineMs = opts.deadlineMs ?? defaultDeadlineFor(opts.workflow, this.mode);
104
115
  this.now = opts.now ?? Date.now;
105
116
  this.deadlineAt = this.now() + this.deadlineMs;
106
117
  this.replay = opts.replay;
118
+ this.autoDecision = opts.autoDecision ? { ...opts.autoDecision } : undefined;
119
+ }
120
+ get fastPath() {
121
+ return this.fastPathActive;
122
+ }
123
+ get autoEscalationReasons() {
124
+ return this.autoDecision?.escalation_reasons ?? [];
125
+ }
126
+ /** Phase D: retain initial fast-path eligibility in meta, but stop rendering it as single-pass. */
127
+ markAutoEscalated(reasons) {
128
+ if (!this.autoDecision || reasons.length === 0)
129
+ return;
130
+ this.fastPathActive = false;
131
+ this.autoDecision = {
132
+ ...this.autoDecision,
133
+ escalation_reasons: [...new Set([...(this.autoDecision.escalation_reasons ?? []), ...reasons])],
134
+ };
107
135
  }
108
136
  /** Provider ids available this run (READY at setup). */
109
137
  available() {
@@ -135,6 +163,8 @@ export class RunCtx {
135
163
  optionalCallsRemaining() {
136
164
  if (this.workflow !== 'idea-refinement')
137
165
  return 0;
166
+ if (this.isAuto)
167
+ return 0; // Phase D adaptive topology owns its single optional challenge directly.
138
168
  const plan = IDEA_MODE_PLANS[this.mode];
139
169
  const logicalUsed = this.attemptedStages.filter(isOptionalStage).length;
140
170
  const protocolRoom = Math.max(0, plan.optionalCalls - logicalUsed);
@@ -160,13 +190,16 @@ export class RunCtx {
160
190
  // Resume (V6.3): if this exact (provider, prompt) already succeeded in the run we're resuming,
161
191
  // replay its output — no real call, no budget spend. Only never-completed calls hit the model.
162
192
  const cachedOut = this.replay?.get(replayKey(handle.id, req.prompt));
193
+ const category = callCategory(stage);
194
+ const replayed = cachedOut !== undefined;
195
+ if (!replayed && this.budget.used + 1 > this.budget.limit)
196
+ throw new BudgetExceeded(this.budget.limit);
197
+ this.events?.onCallStart?.(handle.id, stage, category, replayed);
163
198
  let res;
164
199
  if (cachedOut !== undefined) {
165
200
  res = { ok: true, text: cachedOut, json: req.expectJson ? extractJson(cachedOut) : undefined, durationMs: 0 };
166
201
  }
167
202
  else {
168
- if (this.budget.used + 1 > this.budget.limit)
169
- throw new BudgetExceeded(this.budget.limit);
170
203
  this.budget.used++;
171
204
  res = await handle.adapter.run({
172
205
  prompt: req.prompt,
@@ -174,7 +207,7 @@ export class RunCtx {
174
207
  timeoutMs: req.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS,
175
208
  expectJson: req.expectJson,
176
209
  readOnly: true,
177
- research: this.mode === 'research' && stage.startsWith('S4'),
210
+ research: this.workflow === 'idea-refinement' && this.mode !== 'quick' && stage.startsWith('S4'),
178
211
  signal: this.signal, // Ctrl+C kills the in-flight child (T8); undefined headless
179
212
  }, handle.flags);
180
213
  // Only REAL calls count toward the ledger/budget; a replayed call is free and already recorded in
@@ -184,10 +217,12 @@ export class RunCtx {
184
217
  stage,
185
218
  category: callCategory(stage),
186
219
  durationMs: res.durationMs,
220
+ usage: (res.ok && res.usage) || estimateUsage(req.prompt, res.ok ? res.text : ''),
187
221
  ...(res.ok ? {} : { error: res.error }),
188
222
  });
189
223
  }
190
224
  await this.writer.writeRaw(`${stage}-${handle.id}-${seq}.out`, res.ok ? res.text : `[${res.error}]\n${res.stderrTail}`);
225
+ this.events?.onCallEnd?.(handle.id, stage, res.durationMs, res.ok, replayed);
191
226
  return res;
192
227
  }
193
228
  /** Assemble the run's `meta.json` payload (§15) from the handles + call ledger accumulated so
@@ -211,6 +246,7 @@ export class RunCtx {
211
246
  this.roles.s4.forEach((id, i) => (roles[`s4_${i + 1}`] = id));
212
247
  // Fold stage-raised flags in with any explicitly passed by the caller; dedupe.
213
248
  const allFlags = [...new Set([...(flags ?? []), ...this.flags])];
249
+ const usage_totals = this.sumUsage();
214
250
  return {
215
251
  run_id: this.runId,
216
252
  workflow: this.workflow,
@@ -223,11 +259,38 @@ export class RunCtx {
223
259
  call_count: this.calls.length,
224
260
  budget: { limit: this.budget.limit, used: this.budget.used },
225
261
  receipt: this.receipt(),
262
+ ...(usage_totals ? { usage_totals } : {}),
263
+ ...(this.autoDecision ? { auto_decision: this.autoDecision } : {}),
226
264
  exit_status: exitStatus,
227
265
  aborted,
228
266
  ...(allFlags.length ? { flags: allFlags } : {}),
229
267
  };
230
268
  }
269
+ /** Sum per-call usage into run totals. Undefined when no call carries usage (empty run). */
270
+ sumUsage() {
271
+ let inputTokens = 0, outputTokens = 0, reportedCalls = 0, estimatedCalls = 0, reportedCostUsd = 0, anyCost = false;
272
+ for (const c of this.calls) {
273
+ if (!c.usage)
274
+ continue;
275
+ inputTokens += c.usage.inputTokens ?? 0;
276
+ outputTokens += c.usage.outputTokens ?? 0;
277
+ if (c.usage.estimated)
278
+ estimatedCalls++;
279
+ else
280
+ reportedCalls++;
281
+ if (c.usage.reportedCostUsd !== undefined) {
282
+ reportedCostUsd += c.usage.reportedCostUsd;
283
+ anyCost = true;
284
+ }
285
+ }
286
+ if (reportedCalls === 0 && estimatedCalls === 0)
287
+ return undefined;
288
+ return { inputTokens, outputTokens, reportedCalls, estimatedCalls, ...(anyCost ? { reportedCostUsd } : {}) };
289
+ }
290
+ }
291
+ /** ponytail: chars/4 heuristic, labeled estimated — good enough until a provider reports. */
292
+ function estimateUsage(prompt, out) {
293
+ return { inputTokens: Math.ceil(prompt.length / 4), outputTokens: Math.ceil(out.length / 4), estimated: true };
231
294
  }
232
295
  /** Run-fatal errors abort the whole run; everything else (e.g. a single provider failure in a
233
296
  * fan-out) is handled locally by the stage (drop provider, check quorum). */