aiki-cli 0.2.1 → 0.3.0

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 (60) hide show
  1. package/CHANGELOG.md +99 -7
  2. package/README.md +109 -30
  3. package/dist/bench/arms.js +10 -9
  4. package/dist/bench/harness.js +13 -10
  5. package/dist/bench/idea-lane-rotation.js +237 -0
  6. package/dist/bench/idea-v3-bench.js +506 -0
  7. package/dist/bench/idea-v3-rating.js +582 -0
  8. package/dist/bench/results.js +10 -3
  9. package/dist/bench/scoring/decision-insights.js +112 -0
  10. package/dist/bench/scoring/seeded-bugs.js +4 -0
  11. package/dist/cli/bench.js +180 -3
  12. package/dist/cli/doctor.js +56 -24
  13. package/dist/cli/index.js +32 -7
  14. package/dist/cli/resolve.js +7 -6
  15. package/dist/cli/resume.js +18 -0
  16. package/dist/cli/run.js +63 -8
  17. package/dist/cli/version.js +8 -0
  18. package/dist/council/view.js +378 -109
  19. package/dist/orchestration/calculations.js +97 -0
  20. package/dist/orchestration/context.js +37 -9
  21. package/dist/orchestration/decision-dossier.js +262 -0
  22. package/dist/orchestration/decision-graph.js +256 -0
  23. package/dist/orchestration/engine.js +5 -2
  24. package/dist/orchestration/evidence-pack.js +46 -0
  25. package/dist/orchestration/idea-lanes.js +20 -0
  26. package/dist/orchestration/jsonStage.js +72 -0
  27. package/dist/orchestration/legacy-idea-adapter.js +102 -0
  28. package/dist/orchestration/modes.js +33 -0
  29. package/dist/orchestration/preflight.js +183 -0
  30. package/dist/orchestration/quick-analysis.js +81 -0
  31. package/dist/orchestration/stages/cr-ladder.js +80 -0
  32. package/dist/orchestration/stages/s10-render.js +562 -150
  33. package/dist/orchestration/stages/s4-analyze.js +12 -7
  34. package/dist/orchestration/stages/s5-drift.js +9 -9
  35. package/dist/orchestration/stages/s6-positions.js +10 -0
  36. package/dist/orchestration/stages/s7-decision-graph.js +76 -0
  37. package/dist/orchestration/stages/s8-verify.js +153 -46
  38. package/dist/orchestration/stages/s8b-rebuttal.js +208 -0
  39. package/dist/orchestration/stages/s9-judge.js +329 -108
  40. package/dist/orchestration/stages/s9b-plan.js +85 -75
  41. package/dist/providers/codex.js +2 -1
  42. package/dist/providers/spawn.js +5 -0
  43. package/dist/schemas/index.js +572 -13
  44. package/dist/skills/idea-refinement/analyst.md +18 -14
  45. package/dist/skills/idea-refinement/economics-delivery.md +7 -0
  46. package/dist/skills/idea-refinement/market-adoption.md +7 -0
  47. package/dist/storage/runs.js +11 -4
  48. package/dist/tui/app.js +37 -13
  49. package/dist/tui/format.js +4 -5
  50. package/dist/tui/smart-entry.js +17 -1
  51. package/dist/tui/timeline.js +2 -4
  52. package/dist/workflows/code-review.js +4 -2
  53. package/dist/workflows/idea-refinement.js +110 -46
  54. package/package.json +12 -4
  55. package/dist/orchestration/stages/s0-grill.js +0 -79
  56. package/dist/orchestration/stages/s1-intent.js +0 -25
  57. package/dist/orchestration/stages/s2-misread.js +0 -76
  58. package/dist/orchestration/stages/s3-prompts.js +0 -55
  59. package/dist/orchestration/stages/s6-claims.js +0 -56
  60. package/dist/orchestration/stages/s7-disagreement.js +0 -134
@@ -0,0 +1,256 @@
1
+ import { evaluateCalculation } from './calculations.js';
2
+ export function positionId(provider, localId, sourceId = provider) {
3
+ return `${sourceId}/${localId}`;
4
+ }
5
+ function classify(positions) {
6
+ const providers = new Set(positions.map((position) => position.provider));
7
+ const stances = new Set(positions.map((position) => position.stance));
8
+ const supporters = positions.filter((position) => position.stance === 'SUPPORT').map((position) => position.provider);
9
+ const opponents = positions.filter((position) => position.stance === 'OPPOSE').map((position) => position.provider);
10
+ if (supporters.some((supporter) => opponents.some((opponent) => opponent !== supporter)))
11
+ return 'DISAGREEMENT';
12
+ if (stances.has('SUPPORT') && stances.has('OPPOSE'))
13
+ return 'UNCERTAIN';
14
+ if (providers.size >= 2 && stances.size === 1 && stances.has('OPPOSE'))
15
+ return 'SHARED_CONCERN';
16
+ if (providers.size >= 2 && stances.size === 1 && !stances.has('UNKNOWN'))
17
+ return 'CONSENSUS';
18
+ if ([...stances].every((stance) => stance === 'UNKNOWN' || stance === 'MIXED'))
19
+ return 'UNCERTAIN';
20
+ if (providers.size === 1)
21
+ return 'UNIQUE';
22
+ return 'UNCERTAIN';
23
+ }
24
+ const restrictedClaim = /\b(current|today|latest|now|law|legal|regulat\w*|medic\w*|health\w*|financ\w*|market\w*|prices?|costs?|revenue|fees?|percent(?:age)?)\b|\d/i;
25
+ function needsIndependentEvidence(positions, evidenceById) {
26
+ const evidence = positions.flatMap((position) => position.evidence_ids
27
+ .map((id) => evidenceById.get(positionId(position.provider, id, position.source_id)))
28
+ .filter((item) => item !== undefined));
29
+ return positions.some((position) => restrictedClaim.test(`${position.dimension_id} ${position.proposition}`))
30
+ || evidence.some((item) => item.freshness === 'CURRENT');
31
+ }
32
+ function evidenceState(positions, evidenceById) {
33
+ const requiresIndependent = needsIndependentEvidence(positions, evidenceById);
34
+ let unresolved = false;
35
+ for (const position of positions) {
36
+ const allowed = position.evidence_ids
37
+ .map((id) => evidenceById.get(positionId(position.provider, id, position.source_id)))
38
+ .filter((item) => item !== undefined)
39
+ .filter((item) => !requiresIndependent || item.source_kind !== 'MODEL_KNOWLEDGE');
40
+ const directions = new Set(allowed.map((item) => item.support));
41
+ const expected = position.stance === 'SUPPORT' ? 'SUPPORTS' : position.stance === 'OPPOSE' ? 'CONTRADICTS' : undefined;
42
+ if (!expected || !directions.has(expected))
43
+ unresolved = true;
44
+ const opposite = expected === 'SUPPORTS' ? 'CONTRADICTS' : expected === 'CONTRADICTS' ? 'SUPPORTS' : undefined;
45
+ if (opposite && directions.has(opposite))
46
+ return 'CONFLICTED';
47
+ }
48
+ return unresolved ? 'UNVERIFIED' : 'SUPPORTED';
49
+ }
50
+ const ifFalseRank = { STOP: 0, PIVOT: 1, CONDITION: 2, MINOR: 3 };
51
+ function sensitivity(positions) {
52
+ if (!positions.some((position) => position.load_bearing))
53
+ return 'LOW';
54
+ const consequence = positions.map((position) => position.if_false).sort((a, b) => ifFalseRank[a] - ifFalseRank[b])[0];
55
+ return consequence === 'STOP' || consequence === 'PIVOT' ? 'DECISIVE' : consequence === 'CONDITION' ? 'MATERIAL' : 'LOW';
56
+ }
57
+ /** Required dimensions without an explicit anchored COVERED or reasoned NOT_APPLICABLE entry. */
58
+ export function coverageHoleQueue(submissions, rubric) {
59
+ const covered = new Set();
60
+ for (const { submission } of submissions) {
61
+ const positions = new Map(submission.positions.map((position) => [position.local_id, position]));
62
+ for (const entry of submission.coverage) {
63
+ if (entry.status === 'NOT_APPLICABLE' && entry.rationale?.trim())
64
+ covered.add(entry.dimension_id);
65
+ if (entry.status === 'COVERED' && entry.position_ids.some((id) => positions.get(id)?.dimension_id === entry.dimension_id)) {
66
+ covered.add(entry.dimension_id);
67
+ }
68
+ }
69
+ }
70
+ return rubric.filter((item) => !covered.has(item.id)).map(({ id, label }) => ({ dimension_id: id, label }));
71
+ }
72
+ /** Compile validated analyst positions into stance-aware claims without rewriting proposition text. */
73
+ export function compileDecisionGraph(submissions, rubric, semanticGroups = []) {
74
+ const positions = submissions.flatMap(({ provider, source_id = provider, submission }) => submission.positions.map((position) => ({
75
+ ...position,
76
+ id: positionId(provider, position.local_id, source_id),
77
+ provider,
78
+ source_id,
79
+ })));
80
+ const evidence = submissions.flatMap(({ provider, source_id = provider, submission }) => submission.evidence.map((item) => ({
81
+ ...item,
82
+ id: positionId(provider, item.id, source_id),
83
+ provider,
84
+ source_id,
85
+ })));
86
+ const byId = new Map(positions.map((position) => [position.id, position]));
87
+ const evidenceById = new Map(evidence.map((item) => [item.id, item]));
88
+ const grouped = new Set();
89
+ const groups = semanticGroups.flatMap((group) => {
90
+ const valid = group.filter((id) => byId.has(id) && !grouped.has(id));
91
+ if (valid.length < 2)
92
+ return [];
93
+ valid.forEach((id) => grouped.add(id));
94
+ return [valid];
95
+ });
96
+ for (const position of positions)
97
+ if (!grouped.has(position.id))
98
+ groups.push([position.id]);
99
+ const evidenceHoles = [];
100
+ let claims = groups.map((ids, index) => {
101
+ const members = ids.map((id) => byId.get(id));
102
+ const evidence_state = evidenceState(members, evidenceById);
103
+ const id = `G${index + 1}`;
104
+ if (members.some((member) => member.load_bearing) && evidence_state !== 'SUPPORTED') {
105
+ const reason = evidence_state === 'CONFLICTED'
106
+ ? 'load-bearing claim has conflicting evidence'
107
+ : needsIndependentEvidence(members, evidenceById)
108
+ ? 'claim requires independently checkable evidence'
109
+ : 'load-bearing claim has no settling evidence';
110
+ evidenceHoles.push({ claim_id: id, reason });
111
+ }
112
+ const baseState = classify(members);
113
+ return {
114
+ id,
115
+ proposition: members[0].proposition,
116
+ position_ids: ids,
117
+ state: evidence_state === 'UNVERIFIED' || evidence_state === 'CONFLICTED' ? 'UNCERTAIN' : baseState,
118
+ evidence_state,
119
+ load_bearing: members.some((member) => member.load_bearing),
120
+ if_false: members.map((member) => member.if_false).sort((a, b) => ifFalseRank[a] - ifFalseRank[b])[0],
121
+ sensitivity: sensitivity(members),
122
+ };
123
+ });
124
+ const claimByPosition = new Map(claims.flatMap((claim) => claim.position_ids.map((id) => [id, claim.id])));
125
+ const calculations = submissions.flatMap(({ provider, source_id = provider, submission }) => (submission.calculations ?? []).flatMap((calculation) => {
126
+ const claim_id = claimByPosition.get(positionId(provider, calculation.claim_id, source_id));
127
+ if (!claim_id)
128
+ return [];
129
+ return [{
130
+ ...calculation,
131
+ id: positionId(provider, calculation.id, source_id),
132
+ claim_id,
133
+ inputs: calculation.inputs.map((input) => ({
134
+ ...input,
135
+ evidence_ids: input.evidence_ids.map((id) => positionId(provider, id, source_id)),
136
+ })),
137
+ provider,
138
+ source_id,
139
+ }];
140
+ }));
141
+ const calculationChecks = calculations.map(evaluateCalculation);
142
+ const failedCalculationClaims = new Set(calculationChecks.filter((check) => check.status === 'FAIL').map((check) => check.claim_id));
143
+ for (const claimId of failedCalculationClaims) {
144
+ const existing = evidenceHoles.find((hole) => hole.claim_id === claimId);
145
+ if (existing)
146
+ existing.reason += '; deterministic calculation failed';
147
+ else
148
+ evidenceHoles.push({ claim_id: claimId, reason: 'deterministic calculation failed' });
149
+ }
150
+ claims = claims.map((claim) => failedCalculationClaims.has(claim.id)
151
+ ? { ...claim, state: 'UNCERTAIN', evidence_state: 'UNVERIFIED' }
152
+ : claim);
153
+ const edges = [];
154
+ const edgeKeys = new Set();
155
+ const addEdge = (edge) => {
156
+ const key = `${edge.from}:${edge.to}:${edge.type}`;
157
+ if (!edgeKeys.has(key)) {
158
+ edgeKeys.add(key);
159
+ edges.push(edge);
160
+ }
161
+ };
162
+ for (const position of positions) {
163
+ const from = claimByPosition.get(position.id);
164
+ for (const dependency of position.depends_on) {
165
+ const to = claimByPosition.get(dependency.includes('/') ? dependency : positionId(position.provider, dependency, position.source_id));
166
+ if (to && to !== from)
167
+ addEdge({ from, to, type: 'DEPENDS_ON' });
168
+ }
169
+ for (const evidenceId of position.evidence_ids) {
170
+ const item = evidenceById.get(positionId(position.provider, evidenceId, position.source_id));
171
+ if (!item)
172
+ continue;
173
+ addEdge({ from: item.id, to: from, type: item.support === 'CONTRADICTS' ? 'ATTACKS' : 'SUPPORTS' });
174
+ }
175
+ }
176
+ const coverageHoles = coverageHoleQueue(submissions, rubric);
177
+ return {
178
+ positions,
179
+ evidence,
180
+ calculations,
181
+ calculation_checks: calculationChecks,
182
+ claims,
183
+ edges,
184
+ holes: { coverage: coverageHoles, evidence: evidenceHoles },
185
+ };
186
+ }
187
+ function decisionCritical(claim) {
188
+ return claim.if_false !== 'MINOR' && claim.sensitivity !== 'LOW';
189
+ }
190
+ /** Select graph nodes that warrant independent verification, ordered by decision value. */
191
+ export function selectEscalations(graph, limits) {
192
+ const selected = [];
193
+ const seen = new Set();
194
+ const add = (claim, reason, kind) => {
195
+ if (seen.has(claim.id) || selected.length >= limits.max)
196
+ return;
197
+ seen.add(claim.id);
198
+ selected.push({ claim_id: claim.id, reason, kind });
199
+ };
200
+ for (const claim of graph.claims) {
201
+ if (claim.state === 'DISAGREEMENT' && decisionCritical(claim)) {
202
+ add(claim, 'opposing provider stances', 'DISAGREEMENT');
203
+ }
204
+ }
205
+ for (const claim of graph.claims) {
206
+ if (claim.load_bearing && decisionCritical(claim) && claim.evidence_state === 'CONFLICTED') {
207
+ add(claim, 'conflicting evidence on a load-bearing claim', 'EVIDENCE_CONFLICT');
208
+ }
209
+ }
210
+ for (const claim of graph.claims) {
211
+ if (claim.state === 'UNIQUE' && claim.load_bearing && decisionCritical(claim)) {
212
+ add(claim, 'load-bearing unique claim', 'INDEPENDENT_CHALLENGE');
213
+ }
214
+ }
215
+ for (const hole of graph.holes.evidence) {
216
+ const claim = graph.claims.find((item) => item.id === hole.claim_id);
217
+ if (claim?.load_bearing && decisionCritical(claim)) {
218
+ add(claim, hole.reason, 'EVIDENCE_HOLE');
219
+ }
220
+ }
221
+ return selected;
222
+ }
223
+ /** R5's stricter queue: only verdict-flipping nodes may spend an optional rebuttal call. */
224
+ export function selectRebuttalEscalations(graph, verifications, limits) {
225
+ const selected = [];
226
+ const seen = new Set();
227
+ const verificationById = new Map(verifications.verifications.map((item) => [item.claim_id, item]));
228
+ const add = (claim, reason, kind) => {
229
+ if (seen.has(claim.id) || selected.length >= limits.maxNodes)
230
+ return;
231
+ seen.add(claim.id);
232
+ selected.push({ claim_id: claim.id, reason, kind });
233
+ };
234
+ for (const claim of graph.claims) {
235
+ if (claim.state === 'DISAGREEMENT' && decisionCritical(claim)) {
236
+ add(claim, 'opposing provider stances on a decision-critical claim', 'DISAGREEMENT');
237
+ }
238
+ }
239
+ for (const claim of graph.claims) {
240
+ if (claim.load_bearing && decisionCritical(claim) && verificationById.get(claim.id)?.status === 'CONTRADICTED') {
241
+ add(claim, 'independent verification contradicted a load-bearing claim', 'EVIDENCE_CONFLICT');
242
+ }
243
+ }
244
+ for (const claim of graph.claims) {
245
+ if (claim.state === 'UNIQUE' && claim.load_bearing && decisionCritical(claim)) {
246
+ add(claim, 'load-bearing unique claim needs an independent challenge', 'INDEPENDENT_CHALLENGE');
247
+ }
248
+ }
249
+ for (const hole of graph.holes.evidence) {
250
+ const claim = graph.claims.find((item) => item.id === hole.claim_id);
251
+ if (claim?.load_bearing && decisionCritical(claim)) {
252
+ add(claim, `decision-critical evidence hole: ${hole.reason}`, 'EVIDENCE_HOLE');
253
+ }
254
+ }
255
+ return selected;
256
+ }
@@ -50,13 +50,14 @@ export async function executeRun(ctx, input, fn) {
50
50
  */
51
51
  export async function run(workflow, input, opts = {}) {
52
52
  const handles = await setupProviders(opts.providerModels);
53
- if (handles.length < 2) {
53
+ const requiredProviders = workflow === 'idea-refinement' && opts.mode === 'quick' ? 1 : 2;
54
+ if (handles.length < requiredProviders) {
54
55
  return {
55
56
  ok: false,
56
57
  runId: '(none)',
57
58
  dir: '',
58
59
  callCount: 0,
59
- error: { code: 'QUORUM', message: `need ≥2 providers, found ${handles.length} — run \`aiki doctor\`` },
60
+ error: { code: 'QUORUM', message: `need ≥${requiredProviders} provider${requiredProviders === 1 ? '' : 's'}, found ${handles.length} — run \`aiki doctor\`` },
60
61
  };
61
62
  }
62
63
  const runId = makeRunId(workflow);
@@ -65,6 +66,7 @@ export async function run(workflow, input, opts = {}) {
65
66
  const ctx = new RunCtx({
66
67
  runId,
67
68
  workflow,
69
+ mode: opts.mode,
68
70
  handles,
69
71
  roles,
70
72
  writer,
@@ -74,6 +76,7 @@ export async function run(workflow, input, opts = {}) {
74
76
  signal: opts.signal,
75
77
  events: opts.events,
76
78
  replay: opts.replay,
79
+ evidencePack: opts.evidencePack,
77
80
  });
78
81
  // Register the session (V6.3) so `aiki sessions`/`resume` can find it from anywhere. run() is a
79
82
  // real-CLI entry (setupProviders); tests use executeRun directly and never touch the global registry.
@@ -0,0 +1,46 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, readFile, readdir, realpath } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join, resolve, sep } from 'node:path';
5
+ import { z } from 'zod';
6
+ export const EvidencePack = z.object({
7
+ root: z.string().min(1),
8
+ files: z.array(z.object({
9
+ path: z.string().min(1),
10
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
11
+ }).strict()).min(1),
12
+ }).strict();
13
+ const CREDENTIAL_DIRS = ['.Codex', '.codex', '.gemini', '.antigravity'].map((name) => join(homedir(), name));
14
+ function assertSafePath(path) {
15
+ if (CREDENTIAL_DIRS.some((dir) => path === dir || path.startsWith(`${dir}${sep}`))) {
16
+ throw new Error(`refusing evidence path inside credential directory: ${path}`);
17
+ }
18
+ }
19
+ async function collectFiles(path) {
20
+ assertSafePath(path);
21
+ const info = await lstat(path);
22
+ if (info.isSymbolicLink())
23
+ throw new Error(`evidence packs do not follow symbolic links: ${path}`);
24
+ if (info.isFile())
25
+ return [path];
26
+ if (!info.isDirectory())
27
+ return [];
28
+ const entries = await readdir(path, { withFileTypes: true });
29
+ const nested = await Promise.all(entries.sort((a, b) => a.name.localeCompare(b.name)).map((entry) => collectFiles(join(path, entry.name))));
30
+ return nested.flat();
31
+ }
32
+ /** Resolve a user-scoped file/directory into a paths+hashes manifest; file contents are never copied. */
33
+ export async function buildEvidencePack(inputPath) {
34
+ const requested = resolve(inputPath);
35
+ assertSafePath(requested); // reject credential paths before even resolving/reading them
36
+ const root = await realpath(requested);
37
+ assertSafePath(root);
38
+ const paths = await collectFiles(root);
39
+ if (paths.length === 0)
40
+ throw new Error(`evidence pack contains no regular files: ${root}`);
41
+ const files = await Promise.all(paths.map(async (path) => ({
42
+ path,
43
+ sha256: createHash('sha256').update(await readFile(path)).digest('hex'),
44
+ })));
45
+ return EvidencePack.parse({ root, files });
46
+ }
@@ -0,0 +1,20 @@
1
+ import { loadSkill } from './skills.js';
2
+ export const IDEA_LANES = ['market-adoption', 'economics-delivery'];
3
+ const CORE_OWNERSHIP = {
4
+ 'market-adoption': new Set(['R1', 'R2', 'R3', 'R9', 'R10', 'R13']),
5
+ 'economics-delivery': new Set(['R4', 'R5', 'R6', 'R7', 'R8', 'R11', 'R12']),
6
+ };
7
+ export function buildLanePrompts(basePrompt, rubric) {
8
+ const domainIds = rubric.filter((item) => item.id.startsWith('D')).map((item) => item.id);
9
+ const prompt = (lane, domainParity) => {
10
+ const owned = rubric.filter((item) => {
11
+ const domainIndex = domainIds.indexOf(item.id);
12
+ return CORE_OWNERSHIP[lane].has(item.id) || (domainIndex >= 0 && domainIndex % 2 === domainParity);
13
+ });
14
+ return `${basePrompt}\n\nLANE: ${lane}\nOWNED DIMENSIONS — include one explicit coverage entry for every item:\n${owned.map((item) => `- ${item.id}: ${item.label}`).join('\n')}\n\n${loadSkill('idea-refinement', lane)}`;
15
+ };
16
+ return {
17
+ 'market-adoption': prompt('market-adoption', 0),
18
+ 'economics-delivery': prompt('economics-delivery', 1),
19
+ };
20
+ }
@@ -6,7 +6,65 @@
6
6
  // instruction, exactly once. A second failure → StageError('BAD_OUTPUT'), which the stage turns
7
7
  // into its §9 failure handling (S1/S3 abort the run).
8
8
  import { StageError } from './context.js';
9
+ /** Generic deterministic shape coercion — the §14 boundary floor for EVERY jsonCall stage.
10
+ * Two live failures shaped it (run 20260715-1516): the S9 chair's only defect was `dissent` as a
11
+ * string, and the paid repair it triggered flipped the verdict PIVOT→PWC and died on 7 conditions
12
+ * vs max 6. Policy:
13
+ * - LOSSLESS (always): a lone value where an array is expected becomes a one-element array. Runs
14
+ * BEFORE the paid repair — a wrappable-only defect costs zero extra calls.
15
+ * - LOSSY (`lossy: true`, last resort after the repair failed): arrays beyond their schema max are
16
+ * truncated in order — one extra condition costs one condition, never the run.
17
+ * Never invents a value. The full schema still validates the result; anything else stays invalid. */
18
+ export function coerceToSchema(schema, value, lossy) {
19
+ const def = schema?._def;
20
+ if (!def)
21
+ return value;
22
+ const kind = def.typeName;
23
+ if (kind === 'ZodOptional' || kind === 'ZodNullable' || kind === 'ZodDefault' || kind === 'ZodReadonly' || kind === 'ZodCatch') {
24
+ return value === undefined || value === null ? value : coerceToSchema(def.innerType, value, lossy);
25
+ }
26
+ if (kind === 'ZodEffects')
27
+ return coerceToSchema(def.schema, value, lossy); // refine/preprocess wrappers
28
+ if (kind === 'ZodPipeline')
29
+ return coerceToSchema(def.in, value, lossy);
30
+ if (kind === 'ZodArray') {
31
+ if (value === undefined || value === null)
32
+ return value;
33
+ const wrapped = Array.isArray(value) ? value : [value];
34
+ const max = def.maxLength?.value;
35
+ const bounded = lossy && typeof max === 'number' && wrapped.length > max ? wrapped.slice(0, max) : wrapped;
36
+ return bounded.map((item) => coerceToSchema(def.type, item, lossy));
37
+ }
38
+ if (kind === 'ZodObject') {
39
+ if (!value || typeof value !== 'object' || Array.isArray(value))
40
+ return value;
41
+ const shape = typeof def.shape === 'function' ? def.shape() : def.shape;
42
+ const out = { ...value };
43
+ for (const key of Object.keys(shape)) {
44
+ if (key in out)
45
+ out[key] = coerceToSchema(shape[key], out[key], lossy);
46
+ }
47
+ return out;
48
+ }
49
+ return value;
50
+ }
9
51
  export async function jsonCall(ctx, handle, stage, prompt, schema, opts = {}) {
52
+ // Deterministic last resort once the repair path is spent: stage-specific salvage first, then the
53
+ // generic lossy floor (truncate over-cap arrays), then both composed. No extra provider call.
54
+ const trySalvage = (json) => {
55
+ const candidates = [];
56
+ if (opts.salvage) {
57
+ const staged = opts.salvage(json);
58
+ candidates.push(staged, coerceToSchema(schema, staged, true));
59
+ }
60
+ candidates.push(coerceToSchema(schema, json, true));
61
+ for (const candidate of candidates) {
62
+ const saved = schema.safeParse(candidate);
63
+ if (saved.success)
64
+ return saved.data;
65
+ }
66
+ return undefined;
67
+ };
10
68
  const first = await ctx.call(handle, { prompt, expectJson: true, cwd: opts.cwd }, stage);
11
69
  if (!first.ok) {
12
70
  // AUTH/QUOTA/NOT_FOUND fail fast; TIMEOUT/CRASH/BAD_OUTPUT were already retried once by the adapter.
@@ -15,16 +73,30 @@ export async function jsonCall(ctx, handle, stage, prompt, schema, opts = {}) {
15
73
  const parsed = schema.safeParse(first.json);
16
74
  if (parsed.success)
17
75
  return parsed.data;
76
+ // Lossless coercion BEFORE the paid repair: a wrappable-only defect never costs a second call
77
+ // (and never lets the repair rewrite an already-complete answer — run 20260715-1516's verdict flip).
78
+ const wrapped = schema.safeParse(coerceToSchema(schema, first.json, false));
79
+ if (wrapped.success)
80
+ return wrapped.data;
81
+ if (opts.repair === false) {
82
+ throw new StageError(stage, 'BAD_OUTPUT', `output failed validation: ${zodMessage(parsed.error)}`);
83
+ }
18
84
  // §14 repair retry — one attempt, same provider.
19
85
  const repairPrompt = `${prompt}\n\n---\nYour previous output failed validation:\n${zodMessage(parsed.error)}\n` +
20
86
  `Output ONLY the corrected JSON, nothing else.`;
21
87
  const second = await ctx.call(handle, { prompt: repairPrompt, expectJson: true, cwd: opts.cwd }, `${stage}-repair`);
22
88
  if (!second.ok) {
89
+ const saved = trySalvage(first.json); // quota can kill the repair call itself; the first output may still salvage
90
+ if (saved !== undefined)
91
+ return saved;
23
92
  throw new StageError(stage, second.error, `repair retry failed (${second.error})`);
24
93
  }
25
94
  const reparsed = schema.safeParse(second.json);
26
95
  if (reparsed.success)
27
96
  return reparsed.data;
97
+ const saved = trySalvage(second.json) ?? trySalvage(first.json);
98
+ if (saved !== undefined)
99
+ return saved;
28
100
  throw new StageError(stage, 'BAD_OUTPUT', `output failed validation after repair: ${zodMessage(reparsed.error)}`);
29
101
  }
30
102
  function zodMessage(err) {
@@ -0,0 +1,102 @@
1
+ import { IdeaRoleOutput, LegacyIdeaRoleOutput, } from '../schemas/index.js';
2
+ /** Read old assumption/attack artifacts without treating an assumption or self-attack as a stance. */
3
+ export function adaptIdeaOutput(input) {
4
+ const current = IdeaRoleOutput.safeParse(input);
5
+ if (current.success)
6
+ return current.data;
7
+ const legacy = LegacyIdeaRoleOutput.parse(input);
8
+ const positions = legacy.assumptions.map((assumption) => ({
9
+ local_id: assumption.id,
10
+ proposition: assumption.statement,
11
+ dimension_id: 'R12',
12
+ stance: 'UNKNOWN',
13
+ basis: 'ASSUMPTION',
14
+ load_bearing: assumption.load_bearing,
15
+ if_false: assumption.load_bearing ? 'STOP' : 'MINOR',
16
+ reasoning: `Legacy ${assumption.type.toLowerCase()} assumption; no stance or evidence was recorded.`,
17
+ evidence_ids: [],
18
+ depends_on: [],
19
+ }));
20
+ const assumptionIds = new Set(positions.map((position) => position.local_id));
21
+ const decision_questions = [
22
+ ...legacy.open_questions.map((question, index) => ({ id: `Q${index + 1}`, question, claim_ids: [] })),
23
+ ...legacy.attacks.map((attack) => ({
24
+ id: attack.id,
25
+ question: attack.argument,
26
+ claim_ids: assumptionIds.has(attack.target_assumption) ? [attack.target_assumption] : [],
27
+ })),
28
+ ];
29
+ return IdeaRoleOutput.parse({
30
+ workflow: 'idea-refinement',
31
+ task_echo: legacy.task_echo,
32
+ strongest_version: legacy.strongest_version,
33
+ positions,
34
+ evidence: [],
35
+ coverage: positions.length > 0
36
+ ? [{ dimension_id: 'R12', status: 'COVERED', position_ids: positions.map((position) => position.local_id), rationale: 'Migrated legacy assumptions.' }]
37
+ : [],
38
+ decision_questions,
39
+ });
40
+ }
41
+ /** Convert pre-R2 disagreement artifacts for read-only display/regression compatibility. */
42
+ export function adaptLegacyDecisionGraph(map) {
43
+ const contradictionByClaim = new Map(map.contradictions.flatMap((item) => item.claim_ids.map((id) => [id, item])));
44
+ const claims = [...map.consensus, ...map.unique].map((legacy) => {
45
+ const dispute = contradictionByClaim.get(legacy.id);
46
+ const supporting = legacy.providers.map((provider) => ({
47
+ id: `${provider}/${legacy.id}`,
48
+ provider,
49
+ source_id: provider,
50
+ local_id: legacy.id,
51
+ proposition: legacy.statement,
52
+ dimension_id: 'R12',
53
+ stance: 'SUPPORT',
54
+ basis: 'ASSUMPTION',
55
+ load_bearing: false,
56
+ if_false: 'MINOR',
57
+ reasoning: 'Migrated legacy assumption.',
58
+ evidence_ids: [],
59
+ depends_on: [],
60
+ }));
61
+ const opposing = (dispute?.attacks ?? []).map((attack, index) => ({
62
+ id: `${attack.provider}/${dispute.id}-X${index + 1}`,
63
+ provider: attack.provider,
64
+ source_id: attack.provider,
65
+ local_id: `${dispute.id}-X${index + 1}`,
66
+ proposition: legacy.statement,
67
+ dimension_id: 'R12',
68
+ stance: 'OPPOSE',
69
+ basis: 'ASSUMPTION',
70
+ load_bearing: false,
71
+ if_false: 'MINOR',
72
+ reasoning: attack.argument,
73
+ evidence_ids: [],
74
+ depends_on: [],
75
+ }));
76
+ return {
77
+ claim: {
78
+ id: dispute?.id ?? legacy.id,
79
+ proposition: legacy.statement,
80
+ position_ids: [...supporting, ...opposing].map((position) => position.id),
81
+ state: dispute ? 'DISAGREEMENT' : legacy.providers.length >= 2 ? 'CONSENSUS' : 'UNIQUE',
82
+ evidence_state: 'UNVERIFIED',
83
+ load_bearing: false,
84
+ if_false: 'MINOR',
85
+ sensitivity: 'LOW',
86
+ },
87
+ positions: [...supporting, ...opposing],
88
+ };
89
+ });
90
+ return {
91
+ positions: claims.flatMap((item) => item.positions),
92
+ evidence: [],
93
+ calculations: [],
94
+ calculation_checks: [],
95
+ claims: claims.map((item) => item.claim),
96
+ edges: [],
97
+ holes: {
98
+ coverage: map.blind_spots.map((label) => ({ dimension_id: label, label })),
99
+ evidence: [],
100
+ },
101
+ };
102
+ }
@@ -0,0 +1,33 @@
1
+ const MINUTE = 60 * 1000;
2
+ /** Nominal calls exclude schema repairs; the adaptive default leaves a small repair cushion.
3
+ * `deadlineMs` is the wall-clock outer bound — research gets more headroom because it does 2-3× the
4
+ * work (repairs + coverage-fill + verify + rebuttal + chair + planner). The per-call timeout (900s)
5
+ * stays the real runaway guard; this only bounds the SUM. 45 min matches the bench's proven ceiling
6
+ * for a hard research case (run 20260715-1404 died at S9 on the flat 20-min cap after valid work). */
7
+ export const IDEA_MODE_PLANS = {
8
+ quick: { baseCalls: 3, optionalCalls: 0, maxCalls: 3, reservedCalls: 0, defaultBudget: 4, deadlineMs: 20 * MINUTE },
9
+ council: { baseCalls: 6, optionalCalls: 2, maxCalls: 8, reservedCalls: 2, defaultBudget: 10, deadlineMs: 20 * MINUTE },
10
+ research: { baseCalls: 6, optionalCalls: 4, maxCalls: 10, reservedCalls: 2, defaultBudget: 12, deadlineMs: 45 * MINUTE },
11
+ };
12
+ export const LEGACY_DEFAULT_BUDGET = 18;
13
+ export const LEGACY_DEADLINE_MS = 20 * MINUTE;
14
+ export function defaultBudgetFor(workflow, mode = 'council') {
15
+ return workflow === 'idea-refinement' ? IDEA_MODE_PLANS[mode].defaultBudget : LEGACY_DEFAULT_BUDGET;
16
+ }
17
+ /** Mode-aware wall-clock default, mirroring defaultBudgetFor. Code-review keeps the legacy 20-min cap. */
18
+ export function defaultDeadlineFor(workflow, mode = 'council') {
19
+ return workflow === 'idea-refinement' ? IDEA_MODE_PLANS[mode].deadlineMs : LEGACY_DEADLINE_MS;
20
+ }
21
+ export function callCategory(stage) {
22
+ if (stage.toLowerCase().includes('repair'))
23
+ return 'repair';
24
+ if (stage.startsWith('S9b'))
25
+ return 'planning';
26
+ if (stage.startsWith('S8') || stage === 'S9' || stage.startsWith('S9-'))
27
+ return 'verification';
28
+ return 'discovery';
29
+ }
30
+ /** Logical optional work. Replayed attempts still count so resume cannot widen the protocol. */
31
+ export function isOptionalStage(stage) {
32
+ return stage === 'S7-coverage-fill' || stage === 'S8' || stage.startsWith('S8b-');
33
+ }