aiki-cli 0.3.2 → 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 (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 +8 -3
  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 +45 -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
@@ -7,7 +7,8 @@ import { ReaderBrief as ReaderBriefSchema, readerBriefIssues } from '../../schem
7
7
  import { DISPLAY_NAME } from '../../providers/types.js';
8
8
  import { overlap, tokenize } from '../cluster.js';
9
9
  import { interpretClaimOutcome } from '../decision-graph.js';
10
- import { buildReaderProjection, renderDecisionDossierMarkdown, sanitizeReaderText } from '../decision-dossier.js';
10
+ import { evidenceOrigin } from '../evidence-origin.js';
11
+ import { buildReaderProjection, formatTokenLine, renderDecisionDossierMarkdown, sanitizeReaderText } from '../decision-dossier.js';
11
12
  import { callCategory } from '../modes.js';
12
13
  /** Pure graph-backed decision audit. */
13
14
  export function deriveAudit(graph, judgeReport) {
@@ -90,8 +91,10 @@ export function computeConfidence(graph, flags) {
90
91
  ? verificationClaims.filter((claim) => claim.evidence_state === 'SUPPORTED').length / verificationClaims.length : 0;
91
92
  const independentConvergence = graph.claims.length
92
93
  ? graph.claims.filter((claim) => claim.state === 'CONSENSUS' || claim.state === 'SHARED_CONCERN').length / graph.claims.length : 0;
94
+ // v6: only independent EXTERNAL evidence counts as quality — the user's own material restated
95
+ // as cards inflated f740's coverage to theater (8 of 12 cards were the user's idea-brief).
93
96
  const evidenceQuality = graph.evidence.length
94
- ? graph.evidence.filter((card) => card.source_kind !== 'MODEL_KNOWLEDGE').length / graph.evidence.length : 0;
97
+ ? graph.evidence.filter((card) => evidenceOrigin(card) === 'EXTERNAL').length / graph.evidence.length : 0;
95
98
  const stability = Math.max(0, 1 - 0.25 * DEGRADATION_FLAGS.filter((flag) => flags.has(flag)).length);
96
99
  const criticalRiskPenalty = Math.min(20, 5 * loadBearing.filter((claim) => claim.if_false === 'STOP' && claim.evidence_state !== 'SUPPORTED').length);
97
100
  let score = Math.round(verificationCoverage * 40 + independentConvergence * 25 + evidenceQuality * 20 + stability * 15 - criticalRiskPenalty);
@@ -440,6 +443,8 @@ function buildDossier(args) {
440
443
  export function buildDecisionReport(ctx, args) {
441
444
  const { contract, seats, graph, verifications, judgeReport, actionPlan } = args;
442
445
  const mode = ctx.mode ?? 'council';
446
+ const adaptiveAuto = ctx.isAuto === true;
447
+ const autoEscalationReasons = ctx.autoEscalationReasons ?? [];
443
448
  const flags = new Set(ctx.flags);
444
449
  const newDecisionContract = 'success_bar' in contract;
445
450
  const hasReaderBrief = Boolean(actionPlan && !('kind' in actionPlan) && actionPlan.reader_brief);
@@ -494,7 +499,7 @@ export function buildDecisionReport(ctx, args) {
494
499
  .filter((position) => position.stance === 'OPPOSE')
495
500
  .map((position) => ({ provider: disp(position.provider), proposition: position.proposition }));
496
501
  const unresolvedDecisive = disagreements.some((d) => d.status === 'UNRESOLVED' && claimById.get(d.id)?.sensitivity === 'DECISIVE');
497
- const consensusType = mode === 'quick' ? 'single_analyst' : disagreements.length === 0
502
+ const consensusType = adaptiveAuto || mode === 'quick' ? 'single_analyst' : disagreements.length === 0
498
503
  ? (claims.some((claim) => claim.ruling === 'UNRESOLVED') ? 'convergent_with_unresolved_claims' : 'unanimous')
499
504
  : disagreements.every((d) => d.status === 'RESOLVED') ? 'majority_with_dissent' : 'contested';
500
505
  // Frame the slot as what it is — a decisive assumption that is NOT proven. Echoing the bare
@@ -538,16 +543,29 @@ export function buildDecisionReport(ctx, args) {
538
543
  });
539
544
  const byProvider = {};
540
545
  let modelTimeMs = 0;
546
+ let inputTokens = 0, outputTokens = 0, estimatedCalls = 0;
541
547
  for (const call of ctx.calls) {
542
548
  byProvider[call.provider] = (byProvider[call.provider] ?? 0) + 1;
543
549
  modelTimeMs += call.durationMs;
550
+ if (call.usage) {
551
+ inputTokens += call.usage.inputTokens ?? 0;
552
+ outputTokens += call.usage.outputTokens ?? 0;
553
+ if (call.usage.estimated)
554
+ estimatedCalls++;
555
+ }
544
556
  }
545
557
  const roles = ctx.roles;
546
- const reportProviders = mode === 'quick' ? [...new Set(seats.map((seat) => seat.provider))] : ctx.available();
558
+ const reportProviders = adaptiveAuto
559
+ ? [...new Set([...seats.map((seat) => seat.provider), ...ctx.calls.map((call) => call.provider)])]
560
+ : mode === 'quick' ? [...new Set(seats.map((seat) => seat.provider))] : ctx.available();
547
561
  const models = reportProviders.map((provider) => ({
548
562
  provider,
549
563
  name: disp(provider),
550
- roles: mode === 'quick' ? ['single analyst'] : [
564
+ roles: adaptiveAuto ? [
565
+ ...(seats.some((seat) => seat.provider === provider) ? ['primary analyst'] : []),
566
+ ...(ctx.calls.some((call) => call.provider === provider && call.stage.startsWith('P0-')) ? ['preflight reader'] : []),
567
+ ...(ctx.calls.some((call) => call.provider === provider && call.stage === 'S4b') ? ['delta challenger'] : []),
568
+ ] : mode === 'quick' ? ['single analyst'] : [
551
569
  ...(roles.analyst === provider ? ['analyst'] : []),
552
570
  ...(roles.judge === provider ? ['judge'] : []),
553
571
  ...(roles.verifier === provider ? ['verifier'] : []),
@@ -619,6 +637,10 @@ export function buildDecisionReport(ctx, args) {
619
637
  reportId: ctx.runId,
620
638
  generatedAt: new Date().toISOString(),
621
639
  mode,
640
+ ...(ctx.fastPath ? { fastPath: true } : {}),
641
+ ...(adaptiveAuto ? { adaptiveAuto: true } : {}),
642
+ ...(autoEscalationReasons.length ? { autoEscalationReasons } : {}),
643
+ ...(adaptiveAuto && ctx.attemptedStages?.includes('S4b') ? { autoChallengeUsed: true } : {}),
622
644
  task: {
623
645
  original: args.original ?? contract.task,
624
646
  normalized: contract.task,
@@ -669,7 +691,7 @@ export function buildDecisionReport(ctx, args) {
669
691
  : [],
670
692
  openQuestions,
671
693
  flags: [...flags],
672
- receipt: { calls: ctx.calls.length, budget: ctx.budget.limit, byProvider, modelTimeMs, categories: receiptCategories(ctx) },
694
+ receipt: { calls: ctx.calls.length, budget: ctx.budget.limit, byProvider, modelTimeMs, categories: receiptCategories(ctx), tokens: { inputTokens, outputTokens, estimatedCalls } },
673
695
  dossier,
674
696
  };
675
697
  }
@@ -690,7 +712,8 @@ export function renderTerminalSummary(report, paths) {
690
712
  const takeaways = [...new Set(candidates.map(terminalLine).filter(Boolean))].slice(0, 3);
691
713
  const nextStep = projection?.nextStep ?? report.recommendedActions[0]?.action ?? 'Open the report and choose the smallest decisive next action.';
692
714
  return [
693
- report.mode === 'quick' ? 'AIKI · SINGLE-MODEL DECISION' : 'AIKI · COUNCIL DECISION',
715
+ report.adaptiveAuto ? 'AIKI · ADAPTIVE DECISION'
716
+ : report.mode === 'quick' ? 'AIKI · SINGLE-MODEL DECISION' : 'AIKI · COUNCIL DECISION',
694
717
  RULE,
695
718
  `Verdict: ${terminalLine(projection?.headline ?? report.verdict.summary)}`,
696
719
  terminalLine(projection?.bottomLine ?? report.verdict.primaryReason),
@@ -700,6 +723,7 @@ export function renderTerminalSummary(report, paths) {
700
723
  ...takeaways.map((item) => `- ${item}`),
701
724
  '',
702
725
  `Next step: ${terminalLine(nextStep)}`,
726
+ ...(report.receipt.tokens ? [`Tokens: ${formatTokenLine(report.receipt.tokens)}`] : []),
703
727
  `Report: ${paths.markdownPath}`,
704
728
  ].join('\n');
705
729
  }
@@ -16,7 +16,13 @@ import { IDEA_LANES } from '../idea-lanes.js';
16
16
  /** Run one analyst seat → validated `RoleOutput`, persisted to 04-role-outputs/<label>.json.
17
17
  * `label` doubles as the artifact filename, so a resample uses a distinct label. */
18
18
  async function runSeat(ctx, seat, label, lane, prompt) {
19
- const model = await jsonCall(ctx, ctx.handle(seat), `S4-${label}`, prompt, IdeaRoleOutputModel, { salvage: salvageIdeaRoleOutputModel });
19
+ // v6 tail guard: an S4 repair may spend only when the worst case (both seats repairing) still
20
+ // leaves the reserved tail — 2 seat calls + 2 repairs + 3 (chair, planner, one tail repair).
21
+ // When disallowed, jsonCall's salvage floor keeps the seat alive (proven on the f740 payloads).
22
+ const model = await jsonCall(ctx, ctx.handle(seat), `S4-${label}`, prompt, IdeaRoleOutputModel, {
23
+ salvage: salvageIdeaRoleOutputModel,
24
+ repair: ctx.budget.limit - ctx.budget.used >= 7,
25
+ });
20
26
  const output = { workflow: 'idea-refinement', ...model };
21
27
  await ctx.writer.writeRoleOutput(label, output);
22
28
  return { provider: seat, sample: label, lane, output };
@@ -0,0 +1,97 @@
1
+ // v7 Phase D — one auto-only delta challenge over hard-gated claim packets.
2
+ import { ChallengeDeltaSet as ChallengeDeltaSetSchema, } from '../../schemas/index.js';
3
+ import { canProduceNewInformation, } from '../auto-profile.js';
4
+ import { isFatal, StageError } from '../context.js';
5
+ import { jsonCall } from '../jsonStage.js';
6
+ import { buildClaimPackets } from './s8-verify.js';
7
+ const CHALLENGE_PROMPT = `ROLE: Independent delta challenger. You see only the targeted anonymous claim
8
+ packets below, not the full task. Confirm, counter, narrow, replace, or leave each claim unresolved.
9
+
10
+ Output ONLY JSON:
11
+ {"deltas":[{"claimId":"<G-id>","response":"CONFIRM|COUNTER|NARROW|REPLACE|UNRESOLVED",
12
+ "reasoning":"<precise reason>","newEvidenceIds":["<E-id>"],
13
+ "changedDecisionImpact":"<what changes, or why the decision stays stable>"}]}
14
+
15
+ Return exactly one delta per packet. Cite only evidence IDs shown in that packet. Do not invent a
16
+ source, URL, claim ID, or evidence ID. JSON only.
17
+ TARGET CLAIM PACKETS: {{PACKETS_JSON}}`;
18
+ export function buildChallengePrompt(packets) {
19
+ return CHALLENGE_PROMPT.replace('{{PACKETS_JSON}}', JSON.stringify(packets, null, 2));
20
+ }
21
+ function validateAndTranslate(result, targetIds, evidenceRefs, allowedEvidence) {
22
+ const expected = new Set(targetIds);
23
+ const seen = new Set();
24
+ const issues = [];
25
+ for (const delta of result.deltas) {
26
+ if (!expected.has(delta.claimId))
27
+ issues.push(`unknown claim id: ${delta.claimId}`);
28
+ if (seen.has(delta.claimId))
29
+ issues.push(`duplicate claim delta: ${delta.claimId}`);
30
+ seen.add(delta.claimId);
31
+ const allowed = allowedEvidence.get(delta.claimId) ?? new Set();
32
+ for (const id of delta.newEvidenceIds)
33
+ if (!allowed.has(id))
34
+ issues.push(`invalid evidence id for ${delta.claimId}: ${id}`);
35
+ }
36
+ for (const id of expected)
37
+ if (!seen.has(id))
38
+ issues.push(`missing claim delta: ${id}`);
39
+ if (issues.length)
40
+ throw new StageError('S4b', 'BAD_OUTPUT', issues.join('; '));
41
+ return {
42
+ deltas: result.deltas.map((delta) => ({
43
+ ...delta,
44
+ newEvidenceIds: delta.newEvidenceIds.map((id) => evidenceRefs.get(id)),
45
+ })),
46
+ };
47
+ }
48
+ function challengerProvider(ctx, primaryProvider) {
49
+ return [...new Set([ctx.roles.verifier, ...ctx.roles.s4, ...ctx.available()])]
50
+ .find((provider) => provider !== primaryProvider && ctx.available().includes(provider));
51
+ }
52
+ /** At most one provider call; no repair call and no work for an information-free target. */
53
+ export async function s4bChallenge(ctx, graph, gates, primaryProvider) {
54
+ const byClaim = new Map(gates.map((gate) => [gate.claimId, gate]));
55
+ const targets = [...byClaim.values()]
56
+ .filter((gate) => graph.claims.some((claim) => claim.id === gate.claimId))
57
+ .filter((gate) => canProduceNewInformation(graph, gate.claimId))
58
+ .slice(0, 3);
59
+ const provider = challengerProvider(ctx, primaryProvider);
60
+ if (!targets.length || !provider)
61
+ return { deltas: [] };
62
+ const packets = buildClaimPackets(graph, targets.map((gate) => ({
63
+ claim_id: gate.claimId,
64
+ kind: gate.kind,
65
+ })));
66
+ try {
67
+ const result = await jsonCall(ctx, ctx.handle(provider), 'S4b', buildChallengePrompt(packets.packets), ChallengeDeltaSetSchema, { repair: false });
68
+ const translated = validateAndTranslate(result, targets.map((gate) => gate.claimId), packets.evidenceRefs, packets.allowedEvidence);
69
+ await ctx.writer.writeJson('challenge-deltas', translated);
70
+ return translated;
71
+ }
72
+ catch (error) {
73
+ if (isFatal(error))
74
+ throw error;
75
+ const empty = { deltas: [] };
76
+ await ctx.writer.writeJson('challenge-deltas', empty);
77
+ return empty;
78
+ }
79
+ }
80
+ /** Derived graph overlay; the stored S7 artifact and every non-target claim stay untouched. */
81
+ export function overlayChallengeDeltas(graph, deltas) {
82
+ if (!deltas.length)
83
+ return graph;
84
+ const stateById = new Map(deltas.map((delta) => [delta.claimId,
85
+ delta.response === 'CONFIRM' ? 'CONSENSUS'
86
+ : delta.response === 'COUNTER' ? 'DISAGREEMENT'
87
+ : 'UNCERTAIN']));
88
+ return {
89
+ ...graph,
90
+ claims: graph.claims.map((claim) => {
91
+ const state = stateById.get(claim.id);
92
+ if (!state || (state === 'CONSENSUS' && claim.state === 'DISAGREEMENT'))
93
+ return claim;
94
+ return { ...claim, state };
95
+ }),
96
+ };
97
+ }
@@ -2,17 +2,27 @@
2
2
  // contract's task? Two code checks (no model call — the §601/§532 T6 "deterministic core"; the §9
3
3
  // "verifier spot-check" is deferred): (1) the output produced ≥1 position (an analyst that
4
4
  // produced none did not engage the task), and (2) its `task_echo` is similar enough to the contract
5
- // task (overlap coefficient ≥ DRIFT_MIN_SIMILARITY). Drifted outputs are excluded from everything
5
+ // task + constraints + success_criteria (overlap coefficient ≥ DRIFT_MIN_SIMILARITY). Drifted
6
+ // outputs are excluded from everything
6
7
  // downstream and logged; if exclusion drops the survivor count below quorum (2), the run aborts.
7
8
  import { StageError } from '../context.js';
8
9
  import { overlapCoefficient, tokenize } from '../cluster.js';
9
- /** `task_echo` must share ≥ this fraction of its tokens with the contract task to count as on-task.
10
+ /** `task_echo` must share ≥ this fraction of its tokens with the contract to count as on-task.
10
11
  * Overlap coefficient (not Jaccard) so a short echo is not penalized against a longer contract
11
12
  * paragraph. Lenient by design — this catches an analyst that wandered off-task, not paraphrase
12
13
  * distance. Tunable (same footnote class as the S2 clustering threshold). */
13
14
  export const DRIFT_MIN_SIMILARITY = 0.3;
15
+ /** The reference token set is the contract the seat was told to address — task + constraints +
16
+ * success_criteria — NOT just the one-sentence task. Run 626e (2026-07-18, REAL QUORUM kill):
17
+ * the prompt says "restate the task" and both seats paraphrased accurately, but against the
18
+ * 17-token model-authored task sentence they scored 0.294/0.250 < 0.3 and the run died with 18
19
+ * good positions on disk. Against the full contract the same echoes score 0.680/0.500 while a
20
+ * genuinely off-task echo stays ≤0.08 — paraphrase passes, drift still fails, threshold untouched. */
21
+ function contractTokens(contract) {
22
+ return tokenize([contract.task, ...contract.constraints, ...contract.success_criteria].join(' '));
23
+ }
14
24
  export async function s5Drift(ctx, contract, seats, minSeats = 2) {
15
- const taskTokens = tokenize(contract.task);
25
+ const taskTokens = contractTokens(contract);
16
26
  const entries = [];
17
27
  const kept = [];
18
28
  const excluded = [];
@@ -1,6 +1,7 @@
1
1
  // S8 — independently verify graph-selected claims from stored evidence/calculation references.
2
2
  // Provider identity stays hidden; model output is translated back to graph evidence IDs before S9.
3
3
  import { selectEscalations } from '../decision-graph.js';
4
+ import { sanitizeClaimGroups } from '../claim-groups.js';
4
5
  import { ClaimVerificationSet } from '../../schemas/index.js';
5
6
  import { isFatal, StageError } from '../context.js';
6
7
  import { jsonCall } from '../jsonStage.js';
@@ -9,12 +10,18 @@ const S8_PROMPT = `ROLE: Independent verifier. Check each anonymous decision cla
9
10
  provided evidence cards and deterministic calculation checks. Output ONLY JSON:
10
11
  {"verifications": [{"claim_id": "<G-id>", "status": "VERIFIED|PARTIAL|CONTRADICTED|UNVERIFIABLE",
11
12
  "reasoning": "<concise reason>", "evidence_ids": ["<E-id>"],
12
- "calculation_check": "PASS|FAIL|NOT_APPLICABLE", "missing_evidence": ["<gap>"]}]}
13
+ "calculation_check": "PASS|FAIL|NOT_APPLICABLE", "missing_evidence": ["<gap>"]}],
14
+ "claim_groups": [{"ids": ["<G-id>", "<G-id>"], "relation": "SAME|OPPOSES"}]}
13
15
  VERIFIED = the proposition is supported. CONTRADICTED = it is refuted. PARTIAL = evidence is mixed or
14
16
  incomplete. UNVERIFIABLE = the supplied sources cannot decide. Cite only supplied evidence IDs. Model
15
17
  knowledge cannot verify a current, numeric, legal, medical, financial, market, or regulatory fact.
16
- Judge each item independently; no verdict quota. JSON only.{{SKILL}}
17
- ITEMS: {{ITEMS_JSON}}`;
18
+ Judge each item independently; no verdict quota.
19
+ claim_groups: scan ALL CLAIMS below (not only ITEMS). Group ids that assert the SAME proposition in
20
+ different words (relation "SAME") and pair ids that directly contradict each other (relation
21
+ "OPPOSES"). Only group claims from different seats; use existing claim ids only; omit the field when
22
+ there are none. JSON only.{{SKILL}}
23
+ ITEMS: {{ITEMS_JSON}}
24
+ ALL CLAIMS (id, proposition, seat — for claim_groups only): {{CLAIMS_JSON}}`;
18
25
  export function selectVerificationTargets(claims, max) {
19
26
  return claims
20
27
  .map((claim, index) => ({ claim, index }))
@@ -39,14 +46,18 @@ function evidenceIdsForClaim(graph, claimId) {
39
46
  .flatMap((calculation) => calculation.inputs.flatMap((input) => input.evidence_ids));
40
47
  return new Set([...positionEvidence, ...calculationEvidence]);
41
48
  }
42
- function verifierInput(graph, skill = '') {
49
+ /** Slim, reusable packet seam: selected claims plus only their linked evidence/calculations. */
50
+ export function buildClaimPackets(graph, targets = selectVerificationEscalations(graph)) {
43
51
  const positionById = new Map(graph.positions.map((position) => [position.id, position]));
44
52
  const evidenceRefs = new Map(graph.evidence.map((evidence, index) => [`E${index + 1}`, evidence.id]));
45
53
  const aliasByEvidence = new Map([...evidenceRefs].map(([alias, id]) => [id, alias]));
46
54
  const evidenceById = new Map(graph.evidence.map((evidence) => [evidence.id, evidence]));
47
- const items = selectVerificationEscalations(graph).map((escalation) => {
55
+ const allowedEvidence = new Map();
56
+ const packets = targets.map((escalation) => {
48
57
  const claim = graph.claims.find((item) => item.id === escalation.claim_id);
49
58
  const linkedEvidenceIds = evidenceIdsForClaim(graph, claim.id);
59
+ allowedEvidence.set(claim.id, new Set([...linkedEvidenceIds]
60
+ .map((id) => aliasByEvidence.get(id)).filter((id) => id !== undefined)));
50
61
  return {
51
62
  id: claim.id,
52
63
  kind: escalation.kind,
@@ -78,9 +89,26 @@ function verifierInput(graph, skill = '') {
78
89
  evidence_hole: graph.holes.evidence.find((hole) => hole.claim_id === claim.id)?.reason,
79
90
  };
80
91
  });
92
+ return { packets, evidenceRefs, allowedEvidence };
93
+ }
94
+ function verifierInput(graph, skill = '') {
95
+ const positionById = new Map(graph.positions.map((position) => [position.id, position]));
96
+ const { packets, evidenceRefs } = buildClaimPackets(graph);
97
+ // Anonymous seat aliases (provider identity stays hidden): S1, S2, … by first appearance.
98
+ const seatAlias = new Map();
99
+ for (const position of graph.positions) {
100
+ if (!seatAlias.has(position.provider))
101
+ seatAlias.set(position.provider, `S${seatAlias.size + 1}`);
102
+ }
103
+ const claimsIndex = graph.claims.map((claim) => ({
104
+ id: claim.id,
105
+ proposition: claim.proposition,
106
+ seats: [...new Set(claim.position_ids.map((id) => seatAlias.get(positionById.get(id)?.provider ?? '') ?? '?'))],
107
+ }));
81
108
  const prompt = S8_PROMPT
82
109
  .replace('{{SKILL}}', skill ? `\n\n${skill}` : '')
83
- .replace('{{ITEMS_JSON}}', JSON.stringify(items, null, 2));
110
+ .replace('{{ITEMS_JSON}}', JSON.stringify(packets, null, 2))
111
+ .replace('{{CLAIMS_JSON}}', JSON.stringify(claimsIndex, null, 2));
84
112
  return { prompt, evidenceRefs };
85
113
  }
86
114
  export function buildVerifierPrompt(graph, skill = '') {
@@ -89,12 +117,19 @@ export function buildVerifierPrompt(graph, skill = '') {
89
117
  /** Validate every S8 claim/evidence reference before the chair can see it. */
90
118
  export function claimVerificationRefIssues(graph, verifications, expectedIds = selectVerificationEscalations(graph).map((item) => item.claim_id)) {
91
119
  const expected = new Set(expectedIds);
120
+ const known = new Set(graph.claims.map((claim) => claim.id));
92
121
  const evidence = new Map(graph.evidence.map((item) => [item.id, item]));
93
122
  const seen = new Set();
94
123
  const issues = [];
95
124
  for (const verification of verifications.verifications) {
96
- if (!expected.has(verification.claim_id))
125
+ // A claim id absent from the graph is a dangling reference (fatal even when the caller's
126
+ // expected set is derived from the verifications themselves, as S9's is).
127
+ if (!known.has(verification.claim_id)) {
97
128
  issues.push(`unknown claim id: ${verification.claim_id}`);
129
+ continue;
130
+ }
131
+ if (!expected.has(verification.claim_id))
132
+ issues.push(`unexpected claim verification: ${verification.claim_id}`);
98
133
  if (seen.has(verification.claim_id))
99
134
  issues.push(`duplicate claim verification: ${verification.claim_id}`);
100
135
  seen.add(verification.claim_id);
@@ -158,11 +193,13 @@ export async function s8Verify(ctx, graph) {
158
193
  const input = verifierInput(graph, loadSkill('idea-refinement', 'verifier'));
159
194
  // Optional work gets no model repair: one logical optional call must remain one call.
160
195
  const result = await jsonCall(ctx, ctx.handle(ctx.roles.verifier), 'S8', input.prompt, ClaimVerificationSet, { repair: false });
196
+ const claimGroups = sanitizeClaimGroups(graph, result.claim_groups);
161
197
  const translated = {
162
198
  verifications: result.verifications.map((verification) => ({
163
199
  ...verification,
164
200
  evidence_ids: verification.evidence_ids.map((id) => input.evidenceRefs.get(id) ?? id),
165
201
  })),
202
+ ...(claimGroups.length ? { claim_groups: claimGroups } : {}),
166
203
  };
167
204
  const issues = claimVerificationRefIssues(graph, translated, escalations.map((item) => item.claim_id));
168
205
  if (issues.length)
@@ -5,7 +5,7 @@ import { isFatal, StageError } from '../context.js';
5
5
  import { jsonCall } from '../jsonStage.js';
6
6
  import { claimShortLabel } from '../decision-dossier.js';
7
7
  import { loadSkill } from '../skills.js';
8
- import { claimVerificationRefIssues, selectVerificationEscalations } from './s8-verify.js';
8
+ import { claimVerificationRefIssues } from './s8-verify.js';
9
9
  const EMPTY_REBUTTALS = {
10
10
  round: 1,
11
11
  selected_claim_ids: [],
@@ -148,10 +148,14 @@ export function adjudicationEvidenceViolations(report, verifications) {
148
148
  return [`${item.id}: missing evidence_ids`];
149
149
  const allowed = new Set(verification.evidence_ids);
150
150
  const bad = item.evidence_ids.filter((id) => !allowed.has(id));
151
- const unsettled = (verification.status === 'PARTIAL' || verification.status === 'UNVERIFIABLE') && item.ruling !== 'UNRESOLVED';
151
+ // v6: PARTIAL/UNVERIFIABLE no longer forces UNRESOLVED the chair may rule on judgment with
152
+ // the caveat carried in its reasoning (run f740 lost 5 nuanced HOLDS rulings and a 125s Opus
153
+ // repair to the old rule). The one indefensible combination stays fatal: ruling HOLDS on
154
+ // positively CONTRADICTED evidence.
155
+ const contradicted = verification.status === 'CONTRADICTED' && (item.ruling === 'HOLDS' || item.ruling === 'UPHOLD');
152
156
  return [
153
157
  ...(bad.length ? [`${item.id}: invalid evidence ids ${bad.join(', ')}`] : []),
154
- ...(unsettled ? [`${item.id}: ${verification.status} verification requires UNRESOLVED`] : []),
158
+ ...(contradicted ? [`${item.id}: CONTRADICTED verification cannot rule HOLDS`] : []),
155
159
  ];
156
160
  });
157
161
  }
@@ -216,7 +220,10 @@ function judgeInput(contract, graph, verifications, rubric, rebuttals, judgeProv
216
220
  ])];
217
221
  const evidenceRefs = new Map(citedEvidence.map((id, index) => [`E${index + 1}`, id]));
218
222
  const aliasByEvidence = new Map([...evidenceRefs].map(([alias, id]) => [id, alias]));
219
- const selectedIds = selectVerificationEscalations(graph).map((item) => item.claim_id);
223
+ // Chair scope = the claims S8 actually verified (see s9Judge) recomputing the escalation
224
+ // selection here on the overlaid graph would ask the chair to rule on claims that have no
225
+ // verifier record, which adjudicationEvidenceViolations then rejects after a paid call.
226
+ const selectedIds = verifications.verifications.map((item) => item.claim_id);
220
227
  const eligibleIds = judgeProvider ? adjudicableClaimIds(graph, selectedIds, judgeProvider) : selectedIds;
221
228
  const escalationIds = new Set(eligibleIds);
222
229
  const escalations = graph.claims.filter((claim) => escalationIds.has(claim.id)).map((claim) => {
@@ -265,7 +272,15 @@ function judgeInput(contract, graph, verifications, rubric, rebuttals, judgeProv
265
272
  return { prompt, evidenceRefs };
266
273
  }
267
274
  export async function s9Judge(ctx, contract, graph, verifications, rubric, rebuttals = EMPTY_REBUTTALS) {
268
- const selectedIds = selectVerificationEscalations(graph).map((item) => item.claim_id);
275
+ // The chair adjudicates what S8 ACTUALLY verified (persisted in 08, replay-safe) never a
276
+ // re-ranked selection. S8 picks targets from the pre-overlay graph and validates its own output
277
+ // against them; overlayClaimGroups (built from S8's OWN claim_groups) then shifts claim states,
278
+ // so re-running selectVerificationEscalations on the joined graph can disagree with a selection
279
+ // S8 could not have predicted. Run e1ce (2026-07-18): the re-selection dropped G2 / added G7 and
280
+ // rejected a fully-compliant verification set. The chair also structurally requires a
281
+ // verification per adjudicated claim (adjudicationEvidenceViolations), so the verified set IS
282
+ // the chair's scope; dangling/duplicate/evidence-linkage guards below still apply.
283
+ const selectedIds = verifications.verifications.map((item) => item.claim_id);
269
284
  const verificationIssues = claimVerificationRefIssues(graph, verifications, selectedIds);
270
285
  if (verificationIssues.length)
271
286
  throw new StageError('S9', 'BAD_OUTPUT', `invalid verification references: ${verificationIssues.join('; ')}`);
@@ -1,10 +1,10 @@
1
1
  // S9b — idea validation plan. This is the one report-v3 model call after the judge: it turns the
2
2
  // adjudicated risks, blind spots, and open questions into anchored validation actions. Rendering stays
3
3
  // deterministic; if the planner fails or produces unanchored actions, we write flagged unavailability.
4
- import { ActionPlan, FeatureBacklog, ImplementationPlan, ReaderBrief, readerBriefIssues } from '../../schemas/index.js';
4
+ import { ActionPlan, FeatureBacklog, ImplementationPlan, ReaderBrief } from '../../schemas/index.js';
5
5
  import { z } from 'zod';
6
6
  import { BudgetExceeded, isFatal } from '../context.js';
7
- import { jsonCall } from '../jsonStage.js';
7
+ import { coerceToSchema, jsonCall } from '../jsonStage.js';
8
8
  import { loadSkill } from '../skills.js';
9
9
  import { mergeOpenQuestions } from './s10-render.js';
10
10
  import { interpretClaimOutcome } from '../decision-graph.js';
@@ -235,19 +235,21 @@ function normalizeSourceIds(ids, allowed) {
235
235
  });
236
236
  return [...new Set(normalized)];
237
237
  }
238
+ /** v6: requested deliverables the accepted plan still lacks — the caller decides whether that is
239
+ * worth a repair call or an honest `plan_partial` flag. Never a reason to discard the plan. */
240
+ export function missingDeliverables(plan, requestedOutputs) {
241
+ return requestedOutputs.filter((output) => (output === 'FEATURE_BACKLOG' && !plan.feature_backlog)
242
+ || (output === 'IMPLEMENTATION_PLAN' && !plan.implementation_plan));
243
+ }
238
244
  export function anchoredActionPlan(plan, anchors, requestedOutputs = [], requireReaderBrief = false) {
239
- if (requestedOutputs.includes('FEATURE_BACKLOG') && !plan.feature_backlog)
240
- return null;
241
- if (requestedOutputs.includes('IMPLEMENTATION_PLAN') && !plan.implementation_plan)
242
- return null;
243
245
  if (requireReaderBrief && !plan.reader_brief)
244
246
  return null;
247
+ // v6: a reader brief citing known claim ids is KEPT — sanitizeReaderText already substitutes
248
+ // labels at render time; rejecting here cost run f740 a paid repair for zero reader benefit.
245
249
  const readerBrief = plan.reader_brief ? {
246
250
  ...plan.reader_brief,
247
251
  source_ids: normalizeSourceIds(plan.reader_brief.source_ids, anchors.sourceIds),
248
252
  } : undefined;
249
- if (readerBrief && readerBriefIssues(readerBrief, anchors.knownReaderIds ?? anchors.claimIds).length)
250
- return null;
251
253
  const actions = plan.actions
252
254
  .filter((a) => validAnchor(a.validates, anchors))
253
255
  .sort((a, b) => a.order - b.order)
@@ -288,7 +290,7 @@ function hasExplicitSchedule(context) {
288
290
  }));
289
291
  }
290
292
  export function normalizePlannerOutput(plan, calendarAllowed = true) {
291
- return ActionPlan.parse({
293
+ const built = {
292
294
  actions: plan.actions.map((action, i) => ({
293
295
  ...action,
294
296
  order: i + 1,
@@ -303,7 +305,16 @@ export function normalizePlannerOutput(plan, calendarAllowed = true) {
303
305
  })),
304
306
  } } : {}),
305
307
  ...(plan.reader_brief ? { reader_brief: plan.reader_brief } : {}),
306
- });
308
+ };
309
+ const parsed = ActionPlan.safeParse(built);
310
+ if (parsed.success)
311
+ return parsed.data;
312
+ // v6 deterministic floor for offline/replay callers (jsonCall applies the same floor live):
313
+ // clip over-cap strings, truncate over-cap arrays — never discard a complete plan over cosmetics.
314
+ const eased = ActionPlan.safeParse(coerceToSchema(ActionPlan, built, true));
315
+ if (eased.success)
316
+ return eased.data;
317
+ return ActionPlan.parse(built);
307
318
  }
308
319
  function unavailablePlan(reason, contract, risks, blindSpots, openQuestions) {
309
320
  const unresolved = openQuestions.length ? openQuestions : [
@@ -341,18 +352,31 @@ export async function s9bPlan(ctx, contract, seats, graph, judgeReport, original
341
352
  return fallback('plan_skipped');
342
353
  const prompt = buildActionPlannerPrompt(answerContext, loadSkill('idea-refinement', 'planner'));
343
354
  const calendarAllowed = hasExplicitSchedule(answerContext);
355
+ // v6: a plan the model actually produced is never replaced by PlannerUnavailable. Complete →
356
+ // accept; missing a requested deliverable → one repair when budget allows, else accept partial
357
+ // with an honest `plan_partial` flag. PlannerUnavailable is reserved for nothing-parseable.
358
+ const accept = async (plan) => {
359
+ if (missingDeliverables(plan, requestedOutputs).length)
360
+ ctx.addFlag('plan_partial');
361
+ await ctx.writer.writeJson('action-plan', plan);
362
+ return plan;
363
+ };
364
+ let anchored = null;
344
365
  try {
345
366
  const first = normalizePlannerOutput(await jsonCall(ctx, ctx.handle(ctx.roles.judge), 'S9b-plan', prompt, PlannerOutput, {
346
367
  repair: ctx.budget.limit - ctx.budget.used >= 2,
347
368
  }), calendarAllowed);
348
- const anchored = anchoredActionPlan(first, anchors, requestedOutputs, requireReaderBrief);
349
- if (anchored) {
369
+ anchored = anchoredActionPlan(first, anchors, requestedOutputs, requireReaderBrief);
370
+ if (anchored && missingDeliverables(anchored, requestedOutputs).length === 0) {
350
371
  await ctx.writer.writeJson('action-plan', anchored);
351
372
  return anchored;
352
373
  }
353
- if (ctx.budget.limit - ctx.budget.used < 1)
374
+ if (ctx.budget.limit - ctx.budget.used < 1) {
375
+ if (anchored)
376
+ return accept(anchored);
354
377
  return fallback('plan_fallback');
355
- const repair = `${prompt}\n\n---\nYour previous response had invalid anchors, omitted a requested deliverable or reader_brief, used internal audit language, or cited an unknown source id.\n` +
378
+ }
379
+ const repair = `${prompt}\n\n---\nYour previous response had invalid anchors, omitted a requested deliverable or reader_brief, or cited an unknown source id.\n` +
356
380
  `Valid graph claim ids: ${anchors.claimIds.join(', ') || '(none)'}\n` +
357
381
  `Valid blind spots: ${anchors.blindSpots.join(' | ') || '(none)'}\n` +
358
382
  `Valid open questions: ${anchors.openQuestions.join(' | ') || '(none)'}\n` +
@@ -361,15 +385,20 @@ export async function s9bPlan(ctx, contract, seats, graph, judgeReport, original
361
385
  `Output ONLY corrected JSON with every action anchored, reader_brief present, and every requested deliverable present.`;
362
386
  const repaired = normalizePlannerOutput(await jsonCall(ctx, ctx.handle(ctx.roles.judge), 'S9b-anchor-repair', repair, PlannerOutput, { repair: false }), calendarAllowed);
363
387
  const repairedAnchored = anchoredActionPlan(repaired, anchors, requestedOutputs, requireReaderBrief);
364
- if (repairedAnchored) {
388
+ if (repairedAnchored && missingDeliverables(repairedAnchored, requestedOutputs).length === 0) {
365
389
  await ctx.writer.writeJson('action-plan', repairedAnchored);
366
390
  return repairedAnchored;
367
391
  }
392
+ const best = repairedAnchored ?? anchored;
393
+ if (best)
394
+ return accept(best);
368
395
  return fallback('plan_fallback');
369
396
  }
370
397
  catch (e) {
371
398
  if (isFatal(e) && !(e instanceof BudgetExceeded))
372
399
  throw e;
400
+ if (anchored)
401
+ return accept(anchored);
373
402
  return fallback('plan_fallback');
374
403
  }
375
404
  }
@@ -56,6 +56,13 @@ async function assertPublicUrl(url, resolveHostname) {
56
56
  throw new Error('private or local URLs are not allowed');
57
57
  }
58
58
  }
59
+ /** Validate an attached URL before the browser shows a FETCH_URL permission card. This performs
60
+ * the same public-network guard as snapshotting, but does not fetch the page. */
61
+ export async function validatePublicUrl(raw, resolveHostname = true) {
62
+ const url = new URL(raw);
63
+ await assertPublicUrl(url, resolveHostname);
64
+ return url.toString();
65
+ }
59
66
  function npmRegistryUrl(url) {
60
67
  if (url.hostname !== 'npmjs.com' && url.hostname !== 'www.npmjs.com')
61
68
  return undefined;
@@ -198,3 +205,17 @@ export async function snapshotUrlSources(input, options = {}) {
198
205
  const sources = await Promise.all(extractPublicUrls(input).map((url, index) => snapshotOne(url, index, options)));
199
206
  return UrlSourceSet.parse({ sources });
200
207
  }
208
+ /** v6 T10: a URL the user attached is presumptively decision-relevant — if it cannot be read, the
209
+ * run must stop BEFORE any paid call and ask, instead of spending the full council budget on a
210
+ * conditional verdict (run f740 burned 12 calls around a 403'd hackathon page). Returns the stop
211
+ * message, or null to proceed. Quick mode and an explicit override proceed as before. */
212
+ export function blockedSourceStop(sources, mode, allowBlockedSources) {
213
+ if (mode === 'quick' || allowBlockedSources)
214
+ return null;
215
+ const unreadable = sources.sources.filter((source) => source.status !== 'FETCHED');
216
+ if (unreadable.length === 0)
217
+ return null;
218
+ const details = unreadable.map((source) => `${source.url} (${source.status}${source.error ? `: ${source.error}` : ''})`).join('; ');
219
+ return `a source you attached could not be read — ${details}. The council would have to decide without the fact you attached it for. `
220
+ + 'Paste the relevant text into your idea input and rerun, or rerun with --allow-blocked-sources to proceed without it (the report will be conditional).';
221
+ }
@@ -118,7 +118,7 @@ export async function runAdapter(spec, req, flags, deps = {}) {
118
118
  return { ok: false, error: 'BAD_OUTPUT', stderrTail: raw.stderr, durationMs: raw.durationMs };
119
119
  }
120
120
  }
121
- return { ok: true, text, json, durationMs: raw.durationMs, providerMeta: spec.meta?.(raw.stdout) };
121
+ return { ok: true, text, json, durationMs: raw.durationMs, providerMeta: spec.meta?.(raw.stdout), usage: spec.usage?.(raw.stdout, raw.stderr) };
122
122
  };
123
123
  const first = await attempt();
124
124
  if (first.ok)
@@ -1,4 +1,8 @@
1
1
  import { runAdapter } from './adapter-core.js';
2
+ /** Only accept a finite non-negative number; anything else → undefined (field omitted). */
3
+ function num(v) {
4
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : undefined;
5
+ }
2
6
  function envelope(stdout) {
3
7
  try {
4
8
  const v = JSON.parse(stdout);
@@ -49,6 +53,20 @@ const claudeSpec = {
49
53
  usage: env.usage,
50
54
  };
51
55
  },
56
+ usage(stdout) {
57
+ const env = envelope(stdout);
58
+ if (!env)
59
+ return undefined;
60
+ const u = (env.usage ?? {});
61
+ return {
62
+ inputTokens: num(u.input_tokens),
63
+ outputTokens: num(u.output_tokens),
64
+ cacheReadTokens: num(u.cache_read_input_tokens),
65
+ cacheWriteTokens: num(u.cache_creation_input_tokens),
66
+ estimated: false,
67
+ reportedCostUsd: num(env.total_cost_usd),
68
+ };
69
+ },
52
70
  };
53
71
  export const claude = {
54
72
  id: 'claude',