alphacouncil-agent 0.9.2 → 0.9.4

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 (79) hide show
  1. package/.claude/commands/alpha.md +22 -3
  2. package/.claude-plugin/marketplace.json +2 -2
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.grok/commands/alpha.md +22 -3
  6. package/.opencode/command/alpha.md +22 -3
  7. package/AGENTS.md +27 -4
  8. package/CHANGELOG.md +67 -0
  9. package/CLAUDE.md +41 -7
  10. package/README.ja.md +21 -11
  11. package/README.md +35 -11
  12. package/README.zh-CN.md +31 -11
  13. package/commands/alpha.md +22 -3
  14. package/data/build-profile.v1.json +1 -1
  15. package/data/master-selector-method-locales.v1.mjs +286 -0
  16. package/docs/INSTALL.md +43 -6
  17. package/docs/releases/v0.9.3.md +86 -0
  18. package/docs/releases/v0.9.4.md +79 -0
  19. package/docs/report-contract.md +50 -1
  20. package/knowledge/ai-assisted-solo/experiments/runs/a.json +4 -4
  21. package/knowledge/ai-assisted-solo/experiments/runs/b.json +2 -2
  22. package/knowledge/ai-assisted-solo/experiments/runs/c.json +2 -2
  23. package/knowledge/ai-assisted-solo/experiments/runs/d13.json +16 -16
  24. package/knowledge/ai-assisted-solo/experiments/runs/d26.json +29 -29
  25. package/knowledge/ai-assisted-solo/experiments/runs/e-d13.json +19 -19
  26. package/knowledge/ai-assisted-solo/experiments/runs/e-d26.json +32 -32
  27. package/knowledge/ai-assisted-solo/experiments/runs/h_ai_reference.json +33 -33
  28. package/knowledge/ai-assisted-solo/experiments/simulation-input.json +79 -79
  29. package/knowledge/ai-assisted-solo/experiments/simulation-manifest.json +19 -19
  30. package/knowledge/solo-test/masters/master_ackman/manifest.json +14 -4
  31. package/knowledge/solo-test/masters/master_aschenbrenner/manifest.json +14 -4
  32. package/knowledge/solo-test/masters/master_asness/manifest.json +14 -4
  33. package/knowledge/solo-test/masters/master_buffett/manifest.json +14 -4
  34. package/knowledge/solo-test/masters/master_burry/manifest.json +14 -4
  35. package/knowledge/solo-test/masters/master_cathie_wood/manifest.json +14 -4
  36. package/knowledge/solo-test/masters/master_dalio/manifest.json +14 -4
  37. package/knowledge/solo-test/masters/master_damodaran/manifest.json +14 -4
  38. package/knowledge/solo-test/masters/master_druckenmiller/manifest.json +14 -4
  39. package/knowledge/solo-test/masters/master_duan_yongping/manifest.json +14 -4
  40. package/knowledge/solo-test/masters/master_fisher/manifest.json +14 -4
  41. package/knowledge/solo-test/masters/master_forensic_short/manifest.json +14 -4
  42. package/knowledge/solo-test/masters/master_graham/manifest.json +14 -4
  43. package/knowledge/solo-test/masters/master_jhunjhunwala/manifest.json +14 -4
  44. package/knowledge/solo-test/masters/master_klarman/manifest.json +14 -4
  45. package/knowledge/solo-test/masters/master_li_lu/manifest.json +14 -4
  46. package/knowledge/solo-test/masters/master_lynch/manifest.json +14 -4
  47. package/knowledge/solo-test/masters/master_marks/manifest.json +14 -4
  48. package/knowledge/solo-test/masters/master_munger/manifest.json +14 -4
  49. package/knowledge/solo-test/masters/master_natenberg/manifest.json +14 -4
  50. package/knowledge/solo-test/masters/master_pabrai/manifest.json +14 -4
  51. package/knowledge/solo-test/masters/master_simons/manifest.json +14 -4
  52. package/knowledge/solo-test/masters/master_sinclair/manifest.json +14 -4
  53. package/knowledge/solo-test/masters/master_soros/manifest.json +14 -4
  54. package/knowledge/solo-test/masters/master_taleb/manifest.json +14 -4
  55. package/knowledge/solo-test/masters/master_thorp/manifest.json +14 -4
  56. package/mcp/lib/codex.mjs +3 -3
  57. package/mcp/lib/constants.mjs +61 -25
  58. package/mcp/lib/council-options.mjs +95 -40
  59. package/mcp/lib/council-selection.mjs +95 -9
  60. package/mcp/lib/gates.mjs +78 -11
  61. package/mcp/lib/lang.mjs +109 -2
  62. package/mcp/lib/markdown.mjs +444 -135
  63. package/mcp/lib/master-catalog.mjs +28 -3
  64. package/mcp/lib/orchestrator.mjs +952 -265
  65. package/mcp/lib/packets.mjs +173 -20
  66. package/mcp/lib/personas/engine.mjs +30 -30
  67. package/mcp/lib/personas-v3/compiler.mjs +2 -0
  68. package/mcp/lib/personas-v3/loader.mjs +10 -7
  69. package/mcp/lib/personas-v3/source-acquisition.mjs +10 -3
  70. package/mcp/lib/prompts.mjs +33 -0
  71. package/mcp/lib/rpc.mjs +75 -33
  72. package/mcp/lib/run-store.mjs +23 -0
  73. package/package.json +2 -2
  74. package/schemas/persona-v3.schema.json +37 -2
  75. package/scripts/lib/packaged-host-parity.mjs +54 -5
  76. package/scripts/lib/persona-v3-solo-test-packs.mjs +31 -5
  77. package/scripts/package-smoke.mjs +31 -12
  78. package/scripts/run-tests.mjs +23 -2
  79. package/skills/alphacouncil-agent/SKILL.md +50 -7
@@ -4,14 +4,14 @@ import { COUNCIL_MODES, DEBATE_ROLES, DEFAULT_TASKS, LIMITS, OUTPUT_MODES, QUICK
4
4
  import { invalidParams } from "./errors.mjs";
5
5
  import { readJson, writeJson } from "./fsutil.mjs";
6
6
  import { registry } from "./personas/registry.mjs";
7
- import { isChineseLanguage, resolveLanguage } from "./lang.mjs";
7
+ import { assertReaderLanguage, isChineseLanguage, localized, resolveLanguage } from "./lang.mjs";
8
8
  import { cleanLog } from "./text.mjs";
9
9
  import { completenessStatus, verificationStatus } from "./gates.mjs";
10
10
  import { agentState, appendEvent, artifactPaths, existingDebate, runPath, runId, safeSymbol, saveRun, taskState, today, updateAgent, updateTask, writeSourceManifest, writeStatus } from "./run-store.mjs";
11
11
  import { writeAllAgentsMarkdown, writeAnalystMarkdownFiles, writeArtifactIndex, writeFinalArtifacts } from "./markdown.mjs";
12
- import { debateFromCodex, debateQnaGate, dryDebate, dryPacket, extractJson, firstFailedDebateResult, managerFallback, mergeDebateRounds, normalizeDebate, normalizeMasterOpinion, normalizePacket, rawRecordText } from "./packets.mjs";
12
+ import { debateFailurePacket, debateFromCodex, debateQnaGate, dryDebate, dryPacket, extractJson, firstFailedDebateResult, managerFallback, mergeDebateRounds, normalizeDebate, normalizeMasterOpinion, normalizeMasterVoice, normalizePacket, rawRecordText } from "./packets.mjs";
13
13
  import { mapLimit, runCodex } from "./codex.mjs";
14
- import { debatePrompt, masterPrompt, selectedMasters, taskPrompt } from "./prompts.mjs";
14
+ import { debatePrompt, masterPrompt, masterVoicePrompt, selectedMasters, taskPrompt } from "./prompts.mjs";
15
15
  import { resolveSeatWeights } from "./weights.mjs";
16
16
  import { completedMasterOpinion, declinedMasterOpinion, planMasterSeats, reconcileMasterOpinion } from "./personas/engine.mjs";
17
17
  import { gatherGrounding } from "./grounding.mjs";
@@ -28,40 +28,67 @@ function plannedTasks(args = {}) {
28
28
  return DEFAULT_TASKS;
29
29
  }
30
30
 
31
- function quickTiming(args, startedAt) {
32
- if (councilMode(args) !== "quick") {
33
- return { council_mode: "full", debate_format: "three_round_cross_exam", time_budget_ms: null, deadline_at: null };
34
- }
31
+ function councilTiming(args, startedAt) {
32
+ const mode = councilMode(args);
35
33
  const requested = Number(args.total_timeout_ms);
34
+ const hardMaximum = mode === "quick" ? LIMITS.QUICK_HARD_MAX_MS : LIMITS.FULL_HARD_MAX_MS;
35
+ const defaultBudget = mode === "quick" ? LIMITS.QUICK_TOTAL_MS : LIMITS.FULL_TOTAL_MS;
36
36
  const timeBudgetMs = Number.isFinite(requested) && requested > 0
37
- ? Math.min(requested, LIMITS.QUICK_HARD_MAX_MS)
38
- : LIMITS.QUICK_TOTAL_MS;
37
+ ? Math.min(requested, hardMaximum)
38
+ : defaultBudget;
39
39
  const deadlineAt = args.queued_run?.deadline_at
40
40
  || new Date(Date.parse(startedAt) + timeBudgetMs).toISOString();
41
41
  return {
42
- council_mode: "quick",
43
- debate_format: "single_round_parallel",
42
+ council_mode: mode,
43
+ debate_format: mode === "quick" ? "single_round_parallel" : "three_round_cross_exam_parallel_per_round",
44
44
  time_budget_ms: timeBudgetMs,
45
45
  deadline_at: deadlineAt,
46
+ deadline_enforced: true,
47
+ };
48
+ }
49
+
50
+ function visibleCouncilTiming(args) {
51
+ const mode = councilMode(args);
52
+ return {
53
+ council_mode: mode,
54
+ debate_format: mode === "quick" ? "single_round_parallel" : "host_managed_visible_debate",
55
+ time_budget_ms: null,
56
+ deadline_at: null,
57
+ deadline_enforced: false,
46
58
  };
47
59
  }
48
60
 
49
- function remainingQuickBudget(run, capMs) {
50
- if (run.council_mode !== "quick" || !run.deadline_at) return capMs;
61
+ function remainingCouncilBudget(run, capMs) {
62
+ if (!run.deadline_at) return capMs;
63
+ const configuredReserve = run.council_mode === "quick"
64
+ ? LIMITS.QUICK_FINALIZE_RESERVE_MS
65
+ : LIMITS.FULL_FINALIZE_RESERVE_MS;
51
66
  const reserve = Math.min(
52
- LIMITS.QUICK_FINALIZE_RESERVE_MS,
53
- Math.max(100, Math.floor(Number(run.time_budget_ms || LIMITS.QUICK_TOTAL_MS) * 0.1)),
67
+ configuredReserve,
68
+ Math.max(100, Math.floor(Number(run.time_budget_ms || LIMITS.FULL_TOTAL_MS) * 0.1)),
54
69
  );
55
- const usable = Date.parse(run.deadline_at) - Date.now() - reserve;
70
+ const killGrace = councilKillGrace(run);
71
+ // runCodex may need one SIGKILL grace after its timeout. Budget that grace before
72
+ // launching a child so the queue-to-persistence deadline remains the outer boundary.
73
+ const usable = Date.parse(run.deadline_at) - Date.now() - reserve - killGrace;
56
74
  return Math.max(0, Math.min(capMs, usable));
57
75
  }
58
76
 
59
- function deadlineResult() {
77
+ function councilKillGrace(run) {
78
+ const total = Math.max(1_000, Number(run?.time_budget_ms || LIMITS.FULL_TOTAL_MS));
79
+ // A fixed five-second grace would consume tiny operator-supplied budgets before the
80
+ // first worker. Production budgets retain the full grace; small budgets scale it down.
81
+ // The exact same value is passed to runCodex, so forced settlement remains inside the
82
+ // queue-to-persistence deadline rather than becoming an unaccounted overrun.
83
+ return Math.min(LIMITS.SIGKILL_GRACE_MS, Math.max(50, Math.floor(total * 0.02)));
84
+ }
85
+
86
+ function deadlineResult(run) {
60
87
  return {
61
88
  ok: false,
62
89
  code: null,
63
90
  text: "",
64
- stderr: "quick council global deadline exhausted",
91
+ stderr: `${run?.council_mode || "council"} global deadline exhausted`,
65
92
  stdout: "",
66
93
  timedOut: true,
67
94
  deadline_exhausted: true,
@@ -169,7 +196,10 @@ export function visibleRun(args) {
169
196
  const dir = runPath(id);
170
197
  mkdirSync(dir, { recursive: true });
171
198
  const startedAt = new Date().toISOString();
172
- const timing = quickTiming(args, startedAt);
199
+ // External host threads are outside this MCP process, so the plugin cannot stop them or
200
+ // make a truthful queue-to-persistence SLA claim. Only analyze_symbol gets the enforced
201
+ // thirty-minute full-council deadline.
202
+ const timing = visibleCouncilTiming(args);
173
203
  const run = {
174
204
  run_id: id,
175
205
  symbol,
@@ -188,6 +218,18 @@ export function visibleRun(args) {
188
218
  tasks,
189
219
  task_status: Object.fromEntries(tasks.map((task) => [task, { task, status: "pending" }])),
190
220
  agent_status: Object.fromEntries(DEBATE_ROLES.map((role) => [role, { role, status: "pending" }])),
221
+ // Visible hosts call record_visible_decision once per role and round. Keep those
222
+ // packets separate: overwriting bull_researcher.json three times destroys the audit
223
+ // chain and makes a one-round shortcut indistinguishable from a full cross-exam.
224
+ visible_debate: {
225
+ contract: "role_round_audit_v1",
226
+ rounds_expected: councilMode(args) === "quick" ? 1 : 3,
227
+ rounds: { bull_researcher: {}, bear_researcher: {} },
228
+ qna_gate: {
229
+ status: councilMode(args) === "quick" ? "not_run" : "pending",
230
+ errors: [],
231
+ },
232
+ },
191
233
  packets: [],
192
234
  // The receipt resolves the user's exact choice once. This frozen list, not a dynamic
193
235
  // roster lookup, is the run truth and every seat is part of the completeness gate.
@@ -220,9 +262,19 @@ export function visibleRun(args) {
220
262
  export function visibleAgentSpecs(run, userPrompt = "") {
221
263
  const evidence_agents = run.tasks.map((task) => ({
222
264
  role: task,
223
- title: isChineseLanguage(run.language) ? `AlphaCouncil Agent ${run.symbol} ${task} 证据子代理` : `AlphaCouncil Agent ${run.symbol} ${task} evidence subagent`,
265
+ title: localized(run.language, {
266
+ en: `AlphaCouncil Agent ${run.symbol} ${task} evidence subagent`,
267
+ zh: `AlphaCouncil Agent ${run.symbol} ${task} 证据子代理`,
268
+ ja: `AlphaCouncil Agent ${run.symbol} ${task} 証拠サブエージェント`,
269
+ ko: `AlphaCouncil Agent ${run.symbol} ${task} 증거 하위 에이전트`,
270
+ }),
224
271
  prompt: taskPrompt(task, run.symbol, run.as_of, userPrompt, run.language, run.grounding),
225
- output_contract: isChineseLanguage(run.language) ? "只返回一个 JSON evidence packet。" : `Return one JSON evidence packet with reader-facing fields in ${run.language}.`,
272
+ output_contract: localized(run.language, {
273
+ en: `Return one JSON evidence packet with reader-facing fields in ${run.language}.`,
274
+ zh: "只返回一个 JSON evidence packet。",
275
+ ja: "読者向けフィールドを日本語にした JSON evidence packet を1つだけ返してください。",
276
+ ko: "독자용 필드를 한국어로 작성한 JSON evidence packet 하나만 반환하십시오.",
277
+ }),
226
278
  }));
227
279
  const debate_agents = DEBATE_ROLES.map((role) => ({
228
280
  role,
@@ -230,11 +282,13 @@ export function visibleAgentSpecs(run, userPrompt = "") {
230
282
  prompt_template: [
231
283
  debatePrompt(role, run),
232
284
  "",
233
- isChineseLanguage(run.language) ? "主线程必须先粘贴已完成的 Evidence JSON,再运行这个可见代理。" : "The main thread must paste the completed Evidence JSON before running this visible agent.",
234
- role === "bear_researcher" ? (isChineseLanguage(run.language) ? "主线程还必须粘贴 Bull argument JSON。" : "The main thread must also paste Bull argument JSON.") : "",
235
- role === "portfolio_manager" ? (isChineseLanguage(run.language) ? "主线程还必须粘贴 Bull 和 Bear argument JSON。" : "The main thread must also paste Bull and Bear argument JSON.") : "",
285
+ localized(run.language, {
286
+ en: "The main thread must paste the completed Evidence JSON before running this visible agent.", zh: "主线程必须先粘贴已完成的 Evidence JSON,再运行这个可见代理。", ja: "メインスレッドは、この可視エージェントを実行する前に完成済みの Evidence JSON を貼り付ける必要があります。", ko: "메인 스레드는 표시형 에이전트를 실행하기 전에 완료된 Evidence JSON 붙여 넣어야 합니다.",
287
+ }),
288
+ role === "bear_researcher" ? localized(run.language, { en: "The main thread must also paste Bull argument JSON.", zh: "主线程还必须粘贴 Bull argument JSON。", ja: "メインスレッドは Bull argument JSON も貼り付ける必要があります。", ko: "메인 스레드는 Bull argument JSON도 붙여 넣어야 합니다." }) : "",
289
+ role === "portfolio_manager" ? localized(run.language, { en: "The main thread must also paste Bull and Bear argument JSON.", zh: "主线程还必须粘贴 Bull 和 Bear argument JSON。", ja: "メインスレッドは Bull と Bear の argument JSON も貼り付ける必要があります。", ko: "메인 스레드는 Bull 및 Bear argument JSON도 붙여 넣어야 합니다." }) : "",
236
290
  ].filter(Boolean).join("\n"),
237
- output_contract: isChineseLanguage(run.language) ? "只返回一个 JSON debate packet。" : `Return one JSON debate packet with reader-facing fields in ${run.language}.`,
291
+ output_contract: localized(run.language, { en: `Return one JSON debate packet with reader-facing fields in ${run.language}.`, zh: "只返回一个 JSON debate packet。", ja: "読者向けフィールドを日本語にした JSON debate packet を1つだけ返してください。", ko: "독자용 필드를 한국어로 작성한 JSON debate packet 하나만 반환하십시오." }),
238
292
  }));
239
293
  // The deterministic pass runs first and settles, for free, every seat whose method cannot
240
294
  // reach this security. Spawning an agent for a lens that has already declined is how the
@@ -253,13 +307,19 @@ export function visibleAgentSpecs(run, userPrompt = "") {
253
307
  "",
254
308
  decision ? deterministicVerdictBlock(decision, zh) : "",
255
309
  "",
256
- zh
257
- ? "主线程必须先粘贴已完成的 Evidence JSON,再运行这个大师议席;大师在证据之后、辩论之前运行。"
258
- : "The main thread must paste the completed Evidence JSON first. Masters run after the evidence stage and before the debate.",
310
+ localized(run.language, {
311
+ en: "The main thread must paste the completed Evidence JSON first. Masters run after the evidence stage and before the debate.",
312
+ zh: "主线程必须先粘贴已完成的 Evidence JSON,再运行这个大师议席;大师在证据之后、辩论之前运行。",
313
+ ja: "メインスレッドは先に完成済みの Evidence JSON を貼り付ける必要があります。メソッド席は証拠段階の後、討論の前に実行します。",
314
+ ko: "메인 스레드는 먼저 완료된 Evidence JSON을 붙여 넣어야 합니다. 방법론 좌석은 증거 단계 이후, 토론 이전에 실행합니다.",
315
+ }),
259
316
  ].filter(Boolean).join("\n"),
260
- output_contract: zh
261
- ? "只返回一个 JSON master opinion。"
262
- : `Return one JSON master opinion with reader-facing fields in ${run.language}.`,
317
+ output_contract: localized(run.language, {
318
+ en: `Return one JSON master opinion with reader-facing fields in ${run.language}.`,
319
+ zh: "只返回一个 JSON master opinion。",
320
+ ja: "読者向けフィールドを日本語にした JSON master opinion を1つだけ返してください。",
321
+ ko: "독자용 필드를 한국어로 작성한 JSON master opinion 하나만 반환하십시오.",
322
+ }),
263
323
  }));
264
324
  // A declined seat is settled here rather than skipped: it is written straight into the
265
325
  // run as an out_of_scope opinion, so the completeness gate is satisfied and no agent is
@@ -402,6 +462,7 @@ export function recordMasterOpinion(args) {
402
462
  run,
403
463
  rawRecordText(args.packet),
404
464
  );
465
+ assertVisibleReaderLanguage(masterReaderText(normalized), run, `visible master ${args.master}`);
405
466
  // A narrated stance that disagrees with the arithmetic does not get to win quietly. The
406
467
  // deterministic verdict stands and the disagreement is preserved on the record.
407
468
  const reconciled = reconcileMasterOpinion(run, args.master, normalized);
@@ -453,6 +514,7 @@ export function recordVisiblePacket(args) {
453
514
  thread_title: args.thread_title,
454
515
  execution_mode: "visible_host_threads",
455
516
  }, task, run.symbol, run.as_of, rawRecordText(args.packet));
517
+ assertVisibleReaderLanguage(visibleEvidenceReaderText(packet), run, `visible evidence ${task}`);
456
518
  const byTask = new Map(run.packets.map((item) => [item.task, item]));
457
519
  byTask.set(task, packet);
458
520
  run.packets = run.tasks.map((item) => byTask.get(item)).filter(Boolean);
@@ -472,70 +534,360 @@ export function recordVisiblePacket(args) {
472
534
  return run;
473
535
  }
474
536
 
475
- export function recordVisibleDecision(args) {
476
- const run = readJson(join(runPath(args.run_id), "evidence.json"));
477
- if (run.execution_mode !== "visible_host_threads") {
478
- throw invalidParams("record_visible_decision requires a run created by plan_visible_run.");
537
+ function visibleDebateState(run) {
538
+ const expected = run.council_mode === "quick" ? 1 : 3;
539
+ run.visible_debate = run.visible_debate && typeof run.visible_debate === "object"
540
+ ? run.visible_debate
541
+ : {};
542
+ run.visible_debate.contract = "role_round_audit_v1";
543
+ run.visible_debate.rounds_expected = expected;
544
+ run.visible_debate.rounds = run.visible_debate.rounds && typeof run.visible_debate.rounds === "object"
545
+ ? run.visible_debate.rounds
546
+ : {};
547
+ for (const role of ["bull_researcher", "bear_researcher"]) {
548
+ run.visible_debate.rounds[role] = run.visible_debate.rounds[role]
549
+ && typeof run.visible_debate.rounds[role] === "object"
550
+ ? run.visible_debate.rounds[role]
551
+ : {};
552
+ }
553
+ run.visible_debate.qna_gate = run.visible_debate.qna_gate && typeof run.visible_debate.qna_gate === "object"
554
+ ? run.visible_debate.qna_gate
555
+ : { status: expected === 1 ? "not_run" : "pending", errors: [] };
556
+ return run.visible_debate;
557
+ }
558
+
559
+ function visibleRoundEntry(state, role, round) {
560
+ return state?.rounds?.[role]?.[String(round)] || null;
561
+ }
562
+
563
+ function visibleRoundPacket(state, role, round) {
564
+ return visibleRoundEntry(state, role, round)?.packet || null;
565
+ }
566
+
567
+ function visibleRoundNumbers(state, role) {
568
+ return Object.keys(state?.rounds?.[role] || {})
569
+ .map(Number)
570
+ .filter(Number.isInteger)
571
+ .sort((a, b) => a - b);
572
+ }
573
+
574
+ function visibleDecisionContentHash(packet) {
575
+ const content = JSON.parse(JSON.stringify(packet || {}));
576
+ delete content.raw_text;
577
+ delete content.thread_id;
578
+ delete content.thread_title;
579
+ delete content.execution_mode;
580
+ return sha256(content);
581
+ }
582
+
583
+ function roundGateArgs(state, role, round, candidate) {
584
+ const get = (candidateRole, candidateRound) => (
585
+ candidateRole === role && candidateRound === round
586
+ ? candidate
587
+ : visibleRoundPacket(state, candidateRole, candidateRound)
588
+ );
589
+ return {
590
+ bullR2: get("bull_researcher", 2),
591
+ bearR2: get("bear_researcher", 2),
592
+ bullR3: get("bull_researcher", 3),
593
+ bearR3: get("bear_researcher", 3),
594
+ };
595
+ }
596
+
597
+ function rejectVisibleDecision(run, reason, message, data = {}) {
598
+ throw invalidParams(message, { reason, run_id: run.run_id, ...data });
599
+ }
600
+
601
+ function assertVisibleRoundOrder(run, state, role, round) {
602
+ if (round === 1) return;
603
+ const previous = round - 1;
604
+ const missing = ["bull_researcher", "bear_researcher"]
605
+ .filter((side) => !visibleRoundEntry(state, side, previous))
606
+ .map((side) => `${side}:${previous}`);
607
+ if (missing.length) {
608
+ rejectVisibleDecision(
609
+ run,
610
+ "VISIBLE_DEBATE_ROUND_OUT_OF_ORDER",
611
+ `Cannot record ${role} round ${round} before both round ${previous} packets are recorded.`,
612
+ { role, round, missing_prerequisite_rounds: missing },
613
+ );
479
614
  }
615
+ }
616
+
617
+ function assertVisibleRoundQna(run, state, role, round, packet) {
618
+ if (run.council_mode === "quick" || round === 1) return;
619
+ const gate = debateQnaGate(roundGateArgs(state, role, round, packet));
620
+ const ownErrors = gate.errors.filter((error) => error.startsWith(`${role} round ${round}`));
621
+ if (ownErrors.length) {
622
+ rejectVisibleDecision(
623
+ run,
624
+ "VISIBLE_DEBATE_QNA_INVALID",
625
+ `Rejected ${role} round ${round}: ${ownErrors.join("; ")}`,
626
+ { role, round, qna_gate: { status: "failed", errors: ownErrors } },
627
+ );
628
+ }
629
+ const other = role === "bull_researcher" ? "bear_researcher" : "bull_researcher";
630
+ if (round === 3 && visibleRoundEntry(state, other, 3) && gate.status !== "passed") {
631
+ rejectVisibleDecision(
632
+ run,
633
+ "VISIBLE_DEBATE_QNA_INVALID",
634
+ `Rejected ${role} round 3 because the completed cross-exam failed the exact Q&A gate.`,
635
+ { role, round, qna_gate: gate },
636
+ );
637
+ }
638
+ }
639
+
640
+ function visibleRoundAgentPatch(run, state, role) {
641
+ const dir = runPath(run.run_id);
642
+ const rounds = visibleRoundNumbers(state, role);
643
+ const latest = rounds.length ? visibleRoundEntry(state, role, rounds.at(-1)) : null;
644
+ return {
645
+ rounds_completed: rounds,
646
+ last_completed_round: rounds.at(-1) || null,
647
+ round_status: rounds.length ? "completed" : "pending",
648
+ qna_gate: state.qna_gate.status,
649
+ thread_id: latest?.thread_id,
650
+ thread_title: latest?.thread_title,
651
+ output: join(dir, `${role}.json`),
652
+ };
653
+ }
654
+
655
+ function recordVisibleDebateRound(run, args) {
480
656
  const role = args.role;
481
- if (!DEBATE_ROLES.includes(role)) throw invalidParams(`Unknown decision role: ${role}`);
657
+ const expected = run.council_mode === "quick" ? [1] : [1, 2, 3];
658
+ if (!Number.isInteger(args.round) || !expected.includes(args.round)) {
659
+ rejectVisibleDecision(
660
+ run,
661
+ "VISIBLE_DEBATE_ROUND_REQUIRED",
662
+ `${role} requires an explicit round=${expected.join("|")}.`,
663
+ { role, supplied_round: args.round ?? null, allowed_rounds: expected },
664
+ );
665
+ }
666
+ const round = args.round;
482
667
  const dir = runPath(run.run_id);
668
+ const state = visibleDebateState(run);
669
+ const packet = {
670
+ ...normalizeDebate({
671
+ ...(args.packet || {}),
672
+ thread_id: args.thread_id,
673
+ thread_title: args.thread_title,
674
+ execution_mode: "visible_host_threads",
675
+ }, role, run, rawRecordText(args.packet)),
676
+ round,
677
+ // A caller records exactly one round. Nested supplied rounds would create a second,
678
+ // unaudited history inside the packet and are therefore discarded.
679
+ debate_rounds: [],
680
+ };
681
+ assertVisibleReaderLanguage(debateReaderText(packet), run, `visible debate ${role} round ${round}`);
682
+ const contentHash = visibleDecisionContentHash(packet);
683
+ const existing = visibleRoundEntry(state, role, round);
684
+ if (existing) {
685
+ if (existing.content_hash !== contentHash) {
686
+ rejectVisibleDecision(
687
+ run,
688
+ "VISIBLE_DEBATE_ROUND_CONFLICT",
689
+ `Conflicting replay for ${role} round ${round}.`,
690
+ { role, round, existing_content_hash: existing.content_hash, submitted_content_hash: contentHash },
691
+ );
692
+ }
693
+ return { run, decision: existing.packet, idempotent_replay: true };
694
+ }
695
+
696
+ assertVisibleRoundOrder(run, state, role, round);
697
+ assertVisibleRoundQna(run, state, role, round, packet);
698
+
699
+ const completedAt = new Date().toISOString();
700
+ const roundFile = join(dir, `${role}.round-${round}.json`);
701
+ state.rounds[role][String(round)] = {
702
+ round,
703
+ role,
704
+ content_hash: contentHash,
705
+ packet,
706
+ thread_id: args.thread_id,
707
+ thread_title: args.thread_title,
708
+ output: roundFile,
709
+ completed_at: completedAt,
710
+ };
711
+ writeJson(roundFile, packet);
712
+ const merged = mergeDebateRounds(visibleRoundNumbers(state, role)
713
+ .map((item) => visibleRoundPacket(state, role, item)));
714
+ writeJson(join(dir, `${role}.json`), merged);
715
+
716
+ const quick = run.council_mode === "quick";
717
+ const hasBothFinalRounds = ["bull_researcher", "bear_researcher"]
718
+ .every((side) => visibleRoundEntry(state, side, quick ? 1 : 3));
719
+ if (quick) {
720
+ state.qna_gate = { status: "not_run", errors: [], checked_at: completedAt };
721
+ updateAgent(run, role, "completed", {
722
+ ...visibleRoundAgentPatch(run, state, role),
723
+ completed_at: completedAt,
724
+ });
725
+ } else if (hasBothFinalRounds) {
726
+ const qnaGate = debateQnaGate(roundGateArgs(state, role, round, packet));
727
+ // The candidate was validated before persistence. This guard protects future state
728
+ // migrations or a manually corrupted run file from manufacturing a passed audit.
729
+ if (qnaGate.status !== "passed") {
730
+ delete state.rounds[role][String(round)];
731
+ rejectVisibleDecision(
732
+ run,
733
+ "VISIBLE_DEBATE_QNA_INVALID",
734
+ "The completed visible cross-exam failed the exact Q&A gate.",
735
+ { role, round, qna_gate: qnaGate },
736
+ );
737
+ }
738
+ state.qna_gate = { ...qnaGate, checked_at: completedAt };
739
+ for (const side of ["bull_researcher", "bear_researcher"]) {
740
+ updateAgent(run, side, "completed", {
741
+ ...visibleRoundAgentPatch(run, state, side),
742
+ completed_at: completedAt,
743
+ });
744
+ }
745
+ appendEvent(run, "debate_qna_gate", state.qna_gate);
746
+ } else {
747
+ updateAgent(run, role, "waiting", visibleRoundAgentPatch(run, state, role));
748
+ }
749
+
750
+ run.status = "running";
751
+ run.phase = "visible_debate";
752
+ run.completed_at = null;
753
+ saveRun(run);
754
+ appendEvent(run, "visible_debate_round_recorded", {
755
+ role,
756
+ round,
757
+ content_hash: contentHash,
758
+ output: roundFile,
759
+ qna_gate: state.qna_gate.status,
760
+ });
761
+ writeAnalystMarkdownFiles(run, existingDebate(dir));
762
+ writeArtifactIndex(run, existingDebate(dir));
763
+ writeAllAgentsMarkdown(run, existingDebate(dir));
764
+ return { run, decision: packet, idempotent_replay: false };
765
+ }
766
+
767
+ function visiblePmPrerequisites(run, state) {
768
+ const missingEvidence = (run.tasks || [])
769
+ .filter((task) => taskState(run, task).status !== "completed");
770
+ const recordedMasters = new Set((run.master_opinions || []).map((opinion) => opinion.master));
771
+ const missingMasters = selectedMasters(run).filter((master) => !recordedMasters.has(master));
772
+ const expected = run.council_mode === "quick" ? [1] : [1, 2, 3];
773
+ const missingRounds = ["bull_researcher", "bear_researcher"].flatMap((role) => (
774
+ expected.filter((round) => !visibleRoundEntry(state, role, round)).map((round) => `${role}:${round}`)
775
+ ));
776
+ const missingSides = ["bull_researcher", "bear_researcher"]
777
+ .filter((role) => agentState(run, role).status !== "completed");
778
+ const qnaRequired = run.council_mode !== "quick";
779
+ return {
780
+ missing_evidence: missingEvidence,
781
+ missing_masters: missingMasters,
782
+ missing_debate_rounds: missingRounds,
783
+ missing_debate_sides: missingSides,
784
+ qna_gate: state.qna_gate,
785
+ passed: missingEvidence.length === 0
786
+ && missingMasters.length === 0
787
+ && missingRounds.length === 0
788
+ && missingSides.length === 0
789
+ && (!qnaRequired || state.qna_gate.status === "passed"),
790
+ };
791
+ }
792
+
793
+ function recordVisiblePortfolioManager(run, args) {
794
+ if (args.round !== undefined && args.round !== null) {
795
+ rejectVisibleDecision(
796
+ run,
797
+ "VISIBLE_PM_ROUND_FORBIDDEN",
798
+ "portfolio_manager is the final decision and does not accept a debate round.",
799
+ { supplied_round: args.round },
800
+ );
801
+ }
802
+ const dir = runPath(run.run_id);
803
+ const state = visibleDebateState(run);
483
804
  const packet = normalizeDebate({
484
805
  ...(args.packet || {}),
485
806
  thread_id: args.thread_id,
486
807
  thread_title: args.thread_title,
487
808
  execution_mode: "visible_host_threads",
488
- }, role, run, rawRecordText(args.packet));
489
- const file = role === "portfolio_manager" ? "manager_synthesis.json" : `${role}.json`;
490
- writeJson(join(dir, file), packet);
491
- // Mark the agent completed BEFORE evaluating the gates. The completeness gate now
492
- // counts portfolio_manager, so reading it first would report every finished run as
493
- // incomplete -- the PM would never be recorded by the time it was checked.
494
- updateAgent(run, role, "completed", {
495
- completed_at: new Date().toISOString(),
809
+ }, "portfolio_manager", run, rawRecordText(args.packet));
810
+ assertVisibleReaderLanguage(debateReaderText(packet), run, "visible portfolio_manager decision");
811
+ const contentHash = visibleDecisionContentHash(packet);
812
+ if (state.portfolio_manager) {
813
+ if (state.portfolio_manager.content_hash !== contentHash) {
814
+ rejectVisibleDecision(run, "VISIBLE_PM_CONFLICT", "Conflicting portfolio_manager replay.", {
815
+ existing_content_hash: state.portfolio_manager.content_hash,
816
+ submitted_content_hash: contentHash,
817
+ });
818
+ }
819
+ return { run, decision: state.portfolio_manager.packet, idempotent_replay: true };
820
+ }
821
+
822
+ const prerequisites = visiblePmPrerequisites(run, state);
823
+ if (!prerequisites.passed) {
824
+ rejectVisibleDecision(
825
+ run,
826
+ "VISIBLE_PM_PREREQUISITES_INCOMPLETE",
827
+ "portfolio_manager rejected: complete evidence, every selected master, all required bull/bear rounds, and the exact Q&A gate are mandatory first.",
828
+ prerequisites,
829
+ );
830
+ }
831
+
832
+ const completedAt = new Date().toISOString();
833
+ const file = join(dir, "manager_synthesis.json");
834
+ state.portfolio_manager = {
835
+ content_hash: contentHash,
836
+ packet,
837
+ thread_id: args.thread_id,
838
+ thread_title: args.thread_title,
839
+ output: file,
840
+ completed_at: completedAt,
841
+ };
842
+ writeJson(file, packet);
843
+ writeJson(join(dir, "decision.json"), packet);
844
+ updateAgent(run, "portfolio_manager", "completed", {
845
+ completed_at: completedAt,
496
846
  thread_id: args.thread_id,
497
847
  thread_title: args.thread_title,
498
- output: join(dir, file),
848
+ output: file,
849
+ debate_qna_gate: state.qna_gate.status,
499
850
  });
500
- let finalArtifacts = {};
501
- if (role === "portfolio_manager") {
502
- const gate = verificationStatus(run);
503
- const completeness = completenessStatus(run);
504
- writeJson(join(dir, "decision.json"), packet);
505
- if (completeness.completeness === "incomplete") {
506
- run.status = "incomplete";
507
- run.phase = "incomplete";
508
- appendEvent(run, "incomplete", {
509
- missing_evidence: completeness.missing_evidence,
510
- missing_debate: completeness.missing_debate,
511
- missing_masters: completeness.missing_masters,
512
- });
513
- } else if (gate.verification === "needs_verification") {
514
- run.status = "needs_verification";
515
- run.phase = "needs_verification";
516
- appendEvent(run, "needs_verification", { missing: gate.missing_claim_source_ids.length });
517
- } else {
518
- run.status = "complete";
519
- run.phase = "complete";
520
- }
521
- run.completed_at = new Date().toISOString();
522
- } else {
523
- run.status = "running";
524
- run.phase = "visible_debate";
851
+
852
+ const gate = verificationStatus(run);
853
+ const completeness = completenessStatus(run);
854
+ if (completeness.completeness === "incomplete") {
855
+ // Defensive only: visiblePmPrerequisites rejects all known incomplete states before
856
+ // this point. Never create a final report if a future completeness field disagrees.
857
+ rejectVisibleDecision(run, "VISIBLE_PM_PREREQUISITES_INCOMPLETE", "portfolio_manager completeness gate failed.", {
858
+ missing_evidence: completeness.missing_evidence,
859
+ missing_debate: completeness.missing_debate,
860
+ missing_masters: completeness.missing_masters,
861
+ });
525
862
  }
526
- saveRun(run);
527
- writeJson(join(dir, "evidence.json"), run);
528
- if (role === "portfolio_manager") {
529
- finalArtifacts = writeFinalArtifacts(run, existingDebate(dir));
530
- writeJson(join(dir, "evidence.json"), run);
531
- if (run.status === "complete") appendEvent(run, "run_complete", { decision: packet.rating, winner: packet.winner });
863
+ if (gate.verification === "needs_verification") {
864
+ run.status = "needs_verification";
865
+ run.phase = "needs_verification";
866
+ appendEvent(run, "needs_verification", { missing: gate.missing_claim_source_ids.length });
532
867
  } else {
533
- writeAnalystMarkdownFiles(run, existingDebate(dir));
534
- writeArtifactIndex(run, existingDebate(dir));
868
+ run.status = "complete";
869
+ run.phase = "complete";
535
870
  }
871
+ run.completed_at = completedAt;
872
+ saveRun(run);
873
+ const finalArtifacts = writeFinalArtifacts(run, existingDebate(dir));
874
+ saveRun(run);
875
+ if (run.status === "complete") appendEvent(run, "run_complete", { decision: packet.rating, winner: packet.winner });
536
876
  writeStatus(run);
537
877
  writeAllAgentsMarkdown(run, existingDebate(dir));
538
- return { run, decision: packet, ...finalArtifacts };
878
+ return { run, decision: packet, idempotent_replay: false, ...finalArtifacts };
879
+ }
880
+
881
+ export function recordVisibleDecision(args) {
882
+ const run = readJson(join(runPath(args.run_id), "evidence.json"));
883
+ if (run.execution_mode !== "visible_host_threads") {
884
+ throw invalidParams("record_visible_decision requires a run created by plan_visible_run.");
885
+ }
886
+ const role = args.role;
887
+ if (!DEBATE_ROLES.includes(role)) throw invalidParams(`Unknown decision role: ${role}`);
888
+ return role === "portfolio_manager"
889
+ ? recordVisiblePortfolioManager(run, args)
890
+ : recordVisibleDebateRound(run, args);
539
891
  }
540
892
 
541
893
  export function isDryRun(args = {}) {
@@ -550,30 +902,51 @@ export function isDryRun(args = {}) {
550
902
  * but is not a sourced claim and must never enter evidence.json or downstream debate.
551
903
  */
552
904
  export function workerFailureArtifacts({ task, symbol, asOfDate, language, timeoutMs, result, failureKind, parseError }) {
553
- const chinese = isChineseLanguage(language);
905
+ const copy = localized(language, {
906
+ en: {
907
+ parse: `Evidence worker ${task} returned output that violated the JSON contract; it produced no evidence usable for an investment decision.`,
908
+ language: `Evidence worker ${task} returned reader-facing content in the wrong language; it produced no evidence usable for an investment decision.`,
909
+ failed: `Evidence worker ${task} timed out or failed; it produced no evidence usable for an investment decision.`,
910
+ inspect: `Inspect the separate ${task} failure diagnostic and retry; do not use its partial transcript as evidence.`,
911
+ },
912
+ zh: {
913
+ parse: `证据席位 ${task} 的输出不符合 JSON 契约;未生成可用于投资判断的证据。`,
914
+ language: `证据席位 ${task} 返回了错误语言的读者内容;未生成可用于投资判断的证据。`,
915
+ failed: `证据席位 ${task} 执行超时或失败;未生成可用于投资判断的证据。`,
916
+ inspect: `检查 ${task} 的独立失败诊断并重试;不得用该席位的部分对话补齐证据。`,
917
+ },
918
+ ja: {
919
+ parse: `証拠席 ${task} の出力は JSON 契約に違反しており、投資判断に使用できる証拠は生成されませんでした。`,
920
+ language: `証拠席 ${task} は指定と異なる言語の読者向け内容を返したため、投資判断に使用できる証拠は生成されませんでした。`,
921
+ failed: `証拠席 ${task} はタイムアウトまたは失敗し、投資判断に使用できる証拠は生成されませんでした。`,
922
+ inspect: `${task} の分離された失敗診断を確認して再実行してください。部分的な対話を証拠として使用してはいけません。`,
923
+ },
924
+ ko: {
925
+ parse: `증거 좌석 ${task}의 출력이 JSON 계약을 위반해 투자 판단에 사용할 수 있는 증거를 생성하지 못했습니다.`,
926
+ language: `증거 좌석 ${task}가 지정과 다른 언어의 독자용 내용을 반환해 투자 판단에 사용할 수 있는 증거를 생성하지 못했습니다.`,
927
+ failed: `증거 좌석 ${task}가 시간 초과 또는 실패로 투자 판단에 사용할 수 있는 증거를 생성하지 못했습니다.`,
928
+ inspect: `${task}의 분리된 실패 진단을 확인한 뒤 다시 실행하십시오. 부분 대화를 증거로 사용해서는 안 됩니다.`,
929
+ },
930
+ });
554
931
  const timedOut = result?.timedOut === true;
555
932
  const parseFailed = failureKind === "parse_failed";
556
- const status = parseFailed ? "parse_failed" : (timedOut ? "timed_out" : "failed");
933
+ const languageMismatch = failureKind === "reader_language_mismatch";
934
+ const outputContractFailed = parseFailed || languageMismatch;
935
+ const status = outputContractFailed ? failureKind : (timedOut ? "timed_out" : "failed");
557
936
  const exitLabel = Number.isInteger(result?.code) ? `exit code ${result.code}` : "worker error";
558
937
  const parseMessage = cleanLog(parseError?.message || parseError || "subagent did not return valid JSON", 1_000);
559
- const reason = parseFailed ? parseMessage : (timedOut ? `timeout after ${timeoutMs}ms` : exitLabel);
938
+ const reason = outputContractFailed ? parseMessage : (timedOut ? `timeout after ${timeoutMs}ms` : exitLabel);
560
939
  const rawOutput = String(result?.text || "");
561
- const positionMatch = parseFailed ? /\bposition\s+(\d+)\b/i.exec(parseMessage) : null;
940
+ const positionMatch = outputContractFailed ? /\bposition\s+(\d+)\b/i.exec(parseMessage) : null;
562
941
  const parsePosition = positionMatch ? Number(positionMatch[1]) : null;
563
942
  const contextStart = Number.isInteger(parsePosition) ? Math.max(0, parsePosition - 500) : 0;
564
943
  const contextEnd = Number.isInteger(parsePosition) ? Math.min(rawOutput.length, parsePosition + 500) : 0;
565
944
  const packet = normalizePacket({
566
945
  summary: parseFailed
567
- ? (chinese
568
- ? `证据席位 ${task} 的输出不符合 JSON 契约;未生成可用于投资判断的证据。`
569
- : `Evidence worker ${task} returned output that violated the JSON contract; it produced no evidence usable for an investment decision.`)
570
- : (chinese
571
- ? `证据席位 ${task} 执行超时或失败;未生成可用于投资判断的证据。`
572
- : `Evidence worker ${task} timed out or failed; it produced no evidence usable for an investment decision.`),
946
+ ? copy.parse
947
+ : languageMismatch ? copy.language : copy.failed,
573
948
  claims: [],
574
- open_questions: [chinese
575
- ? `检查 ${task} 的独立失败诊断并重试;不得用该席位的部分对话补齐证据。`
576
- : `Inspect the separate ${task} failure diagnostic and retry; do not use its partial transcript as evidence.`],
949
+ open_questions: [copy.inspect],
577
950
  confidence: "low",
578
951
  }, task, symbol, asOfDate, "");
579
952
  const diagnostic = {
@@ -586,11 +959,11 @@ export function workerFailureArtifacts({ task, symbol, asOfDate, language, timeo
586
959
  timeout_ms: timeoutMs,
587
960
  exit_code: Number.isInteger(result?.code) ? result.code : null,
588
961
  reason,
589
- diagnostic_excerpt: cleanLog(parseFailed
962
+ diagnostic_excerpt: cleanLog(outputContractFailed
590
963
  ? (rawOutput || result?.stderr || result?.stdout || reason)
591
964
  : (result?.stderr || result?.stdout || rawOutput || reason)),
592
- ...(parseFailed ? {
593
- parse_error: parseMessage,
965
+ ...(outputContractFailed ? {
966
+ ...(parseFailed ? { parse_error: parseMessage } : { reader_language_error: parseMessage }),
594
967
  parse_position: Number.isInteger(parsePosition) ? parsePosition : null,
595
968
  parse_context: Number.isInteger(parsePosition)
596
969
  ? cleanLog(rawOutput.slice(contextStart, contextEnd), 1_000)
@@ -603,6 +976,68 @@ export function workerFailureArtifacts({ task, symbol, asOfDate, language, timeo
603
976
  return { packet, diagnostic };
604
977
  }
605
978
 
979
+ function evidenceReaderText(packet) {
980
+ return [
981
+ packet?.summary,
982
+ ...(packet?.claims || []).flatMap((claim) => [claim?.claim, claim?.evidence]),
983
+ ...(packet?.open_questions || []),
984
+ ].filter(Boolean).join("\n");
985
+ }
986
+
987
+ function readerStrings(value, skipKeys = new Set()) {
988
+ if (typeof value === "string") return value.trim() ? [value] : [];
989
+ if (Array.isArray(value)) return value.flatMap((item) => readerStrings(item, skipKeys));
990
+ if (!value || typeof value !== "object") return [];
991
+ return Object.entries(value).flatMap(([key, item]) => (
992
+ skipKeys.has(key) ? [] : readerStrings(item, skipKeys)
993
+ ));
994
+ }
995
+
996
+ function visibleEvidenceReaderText(packet) {
997
+ const machine = new Set([
998
+ "task", "symbol", "as_of", "source_ids", "id", "url", "published_at", "retrieved_at",
999
+ "confidence", "information_richness", "thread_id", "execution_mode", "raw_text",
1000
+ ]);
1001
+ return [
1002
+ ...readerStrings(packet, machine),
1003
+ ].filter(Boolean).join("\n");
1004
+ }
1005
+
1006
+ function masterReaderText(opinion) {
1007
+ const machine = new Set([
1008
+ "master", "symbol", "as_of", "stance", "source_ids", "confidence", "thread_id", "raw_text",
1009
+ "engine", "voice_status", "voice_language", "dedicated_worker", "runtime_provenance",
1010
+ ]);
1011
+ return readerStrings(opinion, machine).join("\n");
1012
+ }
1013
+
1014
+ function debateReaderText(packet) {
1015
+ const machine = new Set([
1016
+ "role", "symbol", "as_of", "decision_available", "rating", "winner", "source_ids", "confidence",
1017
+ "thread_id", "execution_mode", "raw_text", "round", "debate_rounds",
1018
+ ]);
1019
+ return readerStrings(packet, machine).join("\n");
1020
+ }
1021
+
1022
+ function assertVisibleReaderLanguage(text, run, label) {
1023
+ try {
1024
+ return assertReaderLanguage(text, run.language, label);
1025
+ } catch (error) {
1026
+ if (error?.code !== "READER_LANGUAGE_MISMATCH") throw error;
1027
+ throw invalidParams(error.message, {
1028
+ reason: "READER_LANGUAGE_MISMATCH",
1029
+ label,
1030
+ ...error.data,
1031
+ });
1032
+ }
1033
+ }
1034
+
1035
+ function outputFailureKind(error) {
1036
+ return error?.code === "READER_LANGUAGE_MISMATCH"
1037
+ ? "reader_language_mismatch"
1038
+ : "parse_failed";
1039
+ }
1040
+
606
1041
  /**
607
1042
  * A production headless run must not silently become a prompt-only council because its
608
1043
  * caller omitted `grounding`. Visible runs already collect it in rpc.mjs; this gives the
@@ -669,7 +1104,7 @@ export function queueHeadlessRun(args) {
669
1104
  const dir = runPath(id);
670
1105
  mkdirSync(dir, { recursive: true });
671
1106
  const startedAt = new Date().toISOString();
672
- const timing = quickTiming(args, startedAt);
1107
+ const timing = councilTiming(args, startedAt);
673
1108
  const run = {
674
1109
  run_id: id,
675
1110
  symbol,
@@ -723,21 +1158,32 @@ export async function collectEvidence(args) {
723
1158
  const language = resolveLanguage(args);
724
1159
  const frozen = frozenMasterSelection(args);
725
1160
  const startedAt = args.queued_run?.started_at || new Date().toISOString();
726
- const timing = quickTiming(args, startedAt);
1161
+ const timing = councilTiming(args, startedAt);
727
1162
  const requestedTimeoutMs = Number.isFinite(args.timeout_ms) ? args.timeout_ms : LIMITS.CODEX_TIMEOUT_MS;
728
1163
  const timeoutMs = timing.council_mode === "quick"
729
1164
  ? Math.min(requestedTimeoutMs, LIMITS.QUICK_EVIDENCE_MS)
730
- : requestedTimeoutMs;
731
- const defaultConcurrency = timing.council_mode === "quick" ? QUICK_TASKS.length : LIMITS.CONCURRENCY_DEFAULT;
732
- const maxConcurrency = Math.max(LIMITS.CONCURRENCY_MIN, Math.min(LIMITS.CONCURRENCY_MAX, Number(args.max_concurrency || defaultConcurrency)));
1165
+ : Math.min(requestedTimeoutMs, LIMITS.FULL_EVIDENCE_MS);
1166
+ const defaultConcurrency = timing.council_mode === "quick"
1167
+ ? QUICK_TASKS.length
1168
+ : Math.min(tasks.length, LIMITS.FULL_EVIDENCE_CONCURRENCY);
1169
+ // The evidence topology is contractual: every planned evidence seat starts in the same
1170
+ // wave. A legacy low max_concurrency override may not silently turn the full fan-out back
1171
+ // into the old three-wave, ~30-minute evidence stage. Higher values have no effect once
1172
+ // every task can launch.
1173
+ const requestedConcurrency = Number(args.max_concurrency || defaultConcurrency);
1174
+ const maxConcurrency = Math.max(
1175
+ defaultConcurrency,
1176
+ Math.min(LIMITS.CONCURRENCY_MAX, Number.isFinite(requestedConcurrency) ? requestedConcurrency : defaultConcurrency),
1177
+ );
733
1178
  const grounding = await groundingForHeadlessRun({
734
1179
  symbol,
735
1180
  asOf: asOfDate,
736
1181
  grounding: args.grounding,
737
1182
  dryRun,
738
- timeoutMs: timing.council_mode === "quick"
739
- ? remainingQuickBudget(timing, LIMITS.QUICK_GROUNDING_MS)
740
- : undefined,
1183
+ timeoutMs: remainingCouncilBudget(
1184
+ timing,
1185
+ timing.council_mode === "quick" ? LIMITS.QUICK_GROUNDING_MS : LIMITS.FULL_GROUNDING_MS,
1186
+ ),
741
1187
  });
742
1188
  const dir = runPath(id);
743
1189
  mkdirSync(dir, { recursive: true });
@@ -847,15 +1293,15 @@ export async function collectEvidence(args) {
847
1293
  return packet;
848
1294
  }
849
1295
  const workerStartedAt = Date.now();
850
- const runAttempt = async (workerPrompt, budgetMs, attempt) => {
851
- const allowedMs = remainingQuickBudget(run, budgetMs);
852
- if (allowedMs <= 0) return { ...deadlineResult(), budget_ms: 0 };
1296
+ const runAttempt = async (workerPrompt, budgetMs, attempt, { search = true } = {}) => {
1297
+ const allowedMs = remainingCouncilBudget(run, budgetMs);
1298
+ if (allowedMs <= 0) return { ...deadlineResult(run), budget_ms: 0 };
853
1299
  const result = await runCodex(workerPrompt, allowedMs, ({ pid, output }) => {
854
1300
  updateTask(run, task, "running", { pid, output, attempts: attempt });
855
1301
  }, ({ pid, output, elapsed_ms }) => {
856
1302
  updateTask(run, task, "running", { pid, output, attempts: attempt });
857
1303
  appendEvent(run, "task_heartbeat", { task, pid, output, elapsed_ms, attempt });
858
- });
1304
+ }, { search, sigkillGraceMs: councilKillGrace(run) });
859
1305
  return { ...result, budget_ms: allowedMs };
860
1306
  };
861
1307
  const commitFailure = ({ failedResult, budgetMs, attempts, failureKind, parseError, retryDiagnostic }) => {
@@ -882,8 +1328,8 @@ export async function collectEvidence(args) {
882
1328
  ...(retryDiagnostic ? { retry_diagnostic: retryDiagnostic } : {}),
883
1329
  attempts,
884
1330
  deadline_exhausted: failedResult.deadline_exhausted === true,
885
- error: failureKind === "parse_failed"
886
- ? "parse_failed"
1331
+ error: ["parse_failed", "reader_language_mismatch"].includes(failureKind)
1332
+ ? failureKind
887
1333
  : (failedResult.deadline_exhausted ? "global_deadline" : failedResult.timedOut ? "timeout" : `exit code ${failedResult.code}`),
888
1334
  });
889
1335
  return failure.packet;
@@ -896,10 +1342,12 @@ export async function collectEvidence(args) {
896
1342
  let packet;
897
1343
  try {
898
1344
  packet = normalizePacket(extractJson(result.text), task, symbol, asOfDate, result.text);
1345
+ assertReaderLanguage(evidenceReaderText(packet), language, `evidence worker ${task}`);
899
1346
  commitPacket(packet);
900
1347
  updateTask(run, task, "completed", { completed_at: new Date().toISOString(), output: join(dir, `${task}.json`), attempts: 1 });
901
1348
  return packet;
902
1349
  } catch (firstParseError) {
1350
+ const firstFailureKind = outputFailureKind(firstParseError);
903
1351
  const firstFailure = workerFailureArtifacts({
904
1352
  task,
905
1353
  symbol,
@@ -907,19 +1355,23 @@ export async function collectEvidence(args) {
907
1355
  language,
908
1356
  timeoutMs,
909
1357
  result,
910
- failureKind: "parse_failed",
1358
+ failureKind: firstFailureKind,
911
1359
  parseError: firstParseError,
912
1360
  });
913
1361
  const retryDiagnostic = join(dir, `${task}.attempt-1.failure.json`);
914
1362
  writeJson(retryDiagnostic, firstFailure.diagnostic, { mode: 0o600 });
915
1363
  const elapsedMs = Date.now() - workerStartedAt;
916
- const retryTimeoutMs = Math.min(timeoutMs - elapsedMs, remainingQuickBudget(run, timeoutMs));
1364
+ const retryTimeoutMs = Math.min(
1365
+ LIMITS.PARSE_REPAIR_MS,
1366
+ timeoutMs - elapsedMs,
1367
+ remainingCouncilBudget(run, LIMITS.PARSE_REPAIR_MS),
1368
+ );
917
1369
  if (retryTimeoutMs <= 0) {
918
1370
  return commitFailure({
919
1371
  failedResult: result,
920
1372
  budgetMs: result.budget_ms ?? timeoutMs,
921
1373
  attempts: 1,
922
- failureKind: "parse_failed",
1374
+ failureKind: firstFailureKind,
923
1375
  parseError: firstParseError,
924
1376
  retryDiagnostic,
925
1377
  });
@@ -929,13 +1381,22 @@ export async function collectEvidence(args) {
929
1381
  task,
930
1382
  attempt: 2,
931
1383
  max_attempts: 2,
932
- reason: "parse_failed",
1384
+ reason: firstFailureKind,
933
1385
  retry_diagnostic: retryDiagnostic,
934
1386
  remaining_ms: retryTimeoutMs,
935
1387
  });
936
1388
  updateTask(run, task, "running", { attempts: 2, retry_diagnostic: retryDiagnostic });
937
- const retryPrompt = `${prompt}\n\nTRANSPORT RETRY ONLY: Your previous final response violated the required JSON transport contract. Return exactly one JSON object matching the schema above, with no prose, Markdown fence, second object, or trailing text. Do not infer or repair facts from the prior malformed response; perform the same source-bounded task again.`;
938
- result = await runAttempt(retryPrompt, retryTimeoutMs, 2);
1389
+ const malformed = String(result.text || "").slice(0, LIMITS.PARSE_REPAIR_INPUT_CHARS);
1390
+ const retryPrompt = [
1391
+ "PARSE-ONLY TRANSPORT REPAIR. Do not search, browse, fetch, add facts, or redo the research.",
1392
+ `Target task: ${task}; symbol: ${symbol}; as_of: ${asOfDate}; reader language: ${language}.`,
1393
+ firstFailureKind === "reader_language_mismatch"
1394
+ ? "Translate only the reader-facing strings in the supplied valid JSON into the requested language. Preserve the evidence packet schema, claims, numbers, source IDs, URLs, dates, explicit gaps and uncertainty. Return exactly one JSON object and nothing else."
1395
+ : "Convert only the supplied malformed worker output into exactly one valid JSON object matching the evidence packet schema. Preserve claims, numbers, source IDs, URLs, dates, explicit gaps and uncertainty. If a field cannot be recovered, use an empty value and record the loss in open_questions. Return JSON only.",
1396
+ `Write every reader-facing value in ${language}. Translation is permitted only to repair the language mismatch; do not alter facts, numbers, dates, source IDs or uncertainty.`,
1397
+ `Malformed worker output:\n${malformed}`,
1398
+ ].join("\n\n");
1399
+ result = await runAttempt(retryPrompt, retryTimeoutMs, 2, { search: false });
939
1400
  if (!result.ok) {
940
1401
  return commitFailure({
941
1402
  failedResult: result,
@@ -946,6 +1407,7 @@ export async function collectEvidence(args) {
946
1407
  }
947
1408
  try {
948
1409
  packet = normalizePacket(extractJson(result.text), task, symbol, asOfDate, result.text);
1410
+ assertReaderLanguage(evidenceReaderText(packet), language, `evidence worker ${task} repair`);
949
1411
  commitPacket(packet);
950
1412
  updateTask(run, task, "completed", {
951
1413
  completed_at: new Date().toISOString(),
@@ -955,11 +1417,12 @@ export async function collectEvidence(args) {
955
1417
  });
956
1418
  return packet;
957
1419
  } catch (secondParseError) {
1420
+ const secondFailureKind = outputFailureKind(secondParseError);
958
1421
  return commitFailure({
959
1422
  failedResult: result,
960
1423
  budgetMs: result.budget_ms ?? retryTimeoutMs,
961
1424
  attempts: 2,
962
- failureKind: "parse_failed",
1425
+ failureKind: secondFailureKind,
963
1426
  parseError: secondParseError,
964
1427
  retryDiagnostic,
965
1428
  });
@@ -1022,11 +1485,14 @@ export async function runHeadlessMasters(run, args = {}) {
1022
1485
  const requestedTimeoutMs = Number.isFinite(args.timeout_ms) ? args.timeout_ms : LIMITS.CODEX_TIMEOUT_MS;
1023
1486
  const timeoutMs = run.council_mode === "quick"
1024
1487
  ? Math.min(requestedTimeoutMs, LIMITS.QUICK_MASTER_MS)
1025
- : requestedTimeoutMs;
1026
- const defaultConcurrency = run.council_mode === "quick" ? 4 : LIMITS.CONCURRENCY_DEFAULT;
1488
+ : Math.min(requestedTimeoutMs, LIMITS.FULL_MASTER_MS);
1489
+ const defaultConcurrency = run.council_mode === "quick"
1490
+ ? 4
1491
+ : Math.min(selected.length, LIMITS.FULL_MASTER_CONCURRENCY);
1492
+ const requestedConcurrency = Number(args.max_concurrency || defaultConcurrency);
1027
1493
  const maxConcurrency = Math.max(
1028
- LIMITS.CONCURRENCY_MIN,
1029
- Math.min(LIMITS.CONCURRENCY_MAX, Number(args.max_concurrency || defaultConcurrency)),
1494
+ defaultConcurrency,
1495
+ Math.min(LIMITS.CONCURRENCY_MAX, Number.isFinite(requestedConcurrency) ? requestedConcurrency : defaultConcurrency),
1030
1496
  );
1031
1497
  const plan = planMasterSeats(run, selected);
1032
1498
  const byId = new Map((run.master_opinions || []).map((opinion) => [opinion.master, opinion]));
@@ -1045,40 +1511,6 @@ export async function runHeadlessMasters(run, args = {}) {
1045
1511
  blocked: plan.blocked.length,
1046
1512
  });
1047
1513
 
1048
- for (const item of plan.declined) {
1049
- const opinion = attachMasterRuntimeProvenance(
1050
- run,
1051
- item.id,
1052
- declinedMasterOpinion(run, item),
1053
- item.engine,
1054
- );
1055
- byId.set(item.id, opinion);
1056
- writeJson(join(dir, `${item.id}.json`), opinion);
1057
- updateMasterStatus(run, item.id, "completed", {
1058
- engine: item.engine || opinion.engine,
1059
- deterministic_decline: true,
1060
- completed_at: new Date().toISOString(),
1061
- });
1062
- }
1063
-
1064
- for (const item of plan.completed) {
1065
- const opinion = attachMasterRuntimeProvenance(
1066
- run,
1067
- item.id,
1068
- completedMasterOpinion(run, item),
1069
- item.engine,
1070
- );
1071
- byId.set(item.id, opinion);
1072
- writeJson(join(dir, `${item.id}.json`), opinion);
1073
- updateMasterStatus(run, item.id, "completed", {
1074
- engine: item.engine,
1075
- deterministic_execution: true,
1076
- policy_execution_hash: opinion.policy_execution_hash,
1077
- frozen_decision_hash: opinion.frozen_decision_hash,
1078
- completed_at: new Date().toISOString(),
1079
- });
1080
- }
1081
-
1082
1514
  for (const item of plan.blocked) {
1083
1515
  updateMasterStatus(run, item.id, "failed", {
1084
1516
  engine: item.engine,
@@ -1087,48 +1519,155 @@ export async function runHeadlessMasters(run, args = {}) {
1087
1519
  });
1088
1520
  }
1089
1521
 
1090
- const outcomes = await mapLimit(plan.to_run, maxConcurrency, async ({ id, decision, engine }) => {
1091
- const prompt = [
1092
- masterPrompt(id, run),
1093
- decision ? deterministicVerdictBlock(decision, isChineseLanguage(run.language)) : "",
1094
- ].filter(Boolean).join("\n\n");
1095
- updateMasterStatus(run, id, "running", { started_at: new Date().toISOString() });
1522
+ const workerItems = [
1523
+ ...plan.declined.map((item) => ({
1524
+ ...item,
1525
+ frozenOpinion: attachMasterRuntimeProvenance(run, item.id, declinedMasterOpinion(run, item), item.engine),
1526
+ deterministic_decline: true,
1527
+ })),
1528
+ ...plan.completed.map((item) => ({
1529
+ ...item,
1530
+ frozenOpinion: attachMasterRuntimeProvenance(run, item.id, completedMasterOpinion(run, item), item.engine),
1531
+ deterministic_execution: true,
1532
+ })),
1533
+ ...plan.to_run,
1534
+ ];
1535
+ for (const item of workerItems) {
1536
+ if (!item.frozenOpinion) continue;
1537
+ writeJson(join(dir, `${item.id}.deterministic.json`), item.frozenOpinion);
1538
+ }
1539
+
1540
+ const outcomes = await mapLimit(workerItems, maxConcurrency, async ({ id, decision, engine, frozenOpinion, deterministic_decline, deterministic_execution }) => {
1541
+ const prompt = frozenOpinion
1542
+ ? masterVoicePrompt(id, run, frozenOpinion)
1543
+ : [
1544
+ masterPrompt(id, run),
1545
+ decision ? deterministicVerdictBlock(decision, isChineseLanguage(run.language)) : "",
1546
+ ].filter(Boolean).join("\n\n");
1547
+ updateMasterStatus(run, id, "running", {
1548
+ started_at: new Date().toISOString(),
1549
+ worker_kind: frozenOpinion ? "dedicated_method_voice" : "dedicated_method_judgment",
1550
+ deterministic_decline: deterministic_decline || undefined,
1551
+ deterministic_execution: deterministic_execution || undefined,
1552
+ });
1096
1553
 
1097
1554
  if (run.dry_run) {
1555
+ if (frozenOpinion) {
1556
+ return {
1557
+ id,
1558
+ engine,
1559
+ opinion: {
1560
+ ...frozenOpinion,
1561
+ deterministic_summary: frozenOpinion.summary,
1562
+ voice_statement: frozenOpinion.summary,
1563
+ voice_status: "dry_run",
1564
+ dedicated_worker: { status: "dry_run", language: run.language, execution_mode: "dry_run" },
1565
+ },
1566
+ };
1567
+ }
1098
1568
  const normalized = normalizeMasterOpinion({
1099
1569
  verdict: "DRY_RUN",
1100
1570
  stance: "out_of_scope",
1101
- summary: "Dry run: the master prompt was planned but no model judgment was executed.",
1571
+ summary: "Dry run: the dedicated method worker was planned but no model judgment was executed.",
1102
1572
  what_would_change_my_mind: ["Run without dry_run to obtain a method judgment."],
1103
1573
  confidence: "low",
1104
1574
  }, id, run, prompt);
1105
1575
  const reconciled = reconcileMasterOpinion(run, id, normalized);
1106
1576
  const resolvedEngine = engine || reconciled.engine || (decision ? "v2_method_model" : "v1_prompt");
1107
- return { id, opinion: attachMasterRuntimeProvenance(run, id, reconciled.opinion, resolvedEngine), engine: resolvedEngine };
1108
- }
1109
-
1110
- const allowedMs = remainingQuickBudget(run, timeoutMs);
1111
- const result = allowedMs <= 0
1112
- ? deadlineResult()
1113
- : await runCodex(prompt, allowedMs, ({ pid, output }) => {
1114
- updateMasterStatus(run, id, "running", { pid, output });
1115
- }, ({ pid, output, elapsed_ms }) => {
1116
- updateMasterStatus(run, id, "running", { pid, output, elapsed_ms });
1117
- });
1118
- if (!result.ok) {
1119
1577
  return {
1120
1578
  id,
1121
- error: result.timedOut ? "timeout" : `exit code ${result.code}`,
1122
- raw: cleanLog(result.stderr || result.stdout || result.text || "master execution failed"),
1579
+ opinion: attachMasterRuntimeProvenance(run, id, {
1580
+ ...reconciled.opinion,
1581
+ voice_status: "dry_run",
1582
+ dedicated_worker: { status: "dry_run", language: run.language, execution_mode: "dry_run" },
1583
+ }, resolvedEngine),
1584
+ engine: resolvedEngine,
1123
1585
  };
1124
1586
  }
1125
- try {
1587
+
1588
+ let pid = null;
1589
+ const execute = async (workerPrompt, budgetMs, attempt) => {
1590
+ const allowedMs = remainingCouncilBudget(run, budgetMs);
1591
+ if (allowedMs <= 0) return { ...deadlineResult(run), budget_ms: 0 };
1592
+ const result = await runCodex(workerPrompt, allowedMs, ({ pid: workerPid, output }) => {
1593
+ pid = workerPid;
1594
+ updateMasterStatus(run, id, "running", { pid: workerPid, output, attempts: attempt });
1595
+ }, ({ pid: workerPid, output, elapsed_ms }) => {
1596
+ updateMasterStatus(run, id, "running", { pid: workerPid, output, elapsed_ms, attempts: attempt });
1597
+ }, { search: false, sigkillGraceMs: councilKillGrace(run) });
1598
+ return { ...result, budget_ms: allowedMs };
1599
+ };
1600
+ const parse = (result) => {
1601
+ if (frozenOpinion) {
1602
+ const voice = normalizeMasterVoice(extractJson(result.text), id, run, frozenOpinion, result.text);
1603
+ assertReaderLanguage([
1604
+ voice.statement,
1605
+ ...(voice.key_findings || []),
1606
+ ...(voice.disagreements || []),
1607
+ ...(voice.what_would_change_my_mind || []),
1608
+ ].filter(Boolean).join("\n"), run.language, `master voice ${id}`);
1609
+ return attachMasterRuntimeProvenance(run, id, {
1610
+ ...frozenOpinion,
1611
+ deterministic_summary: frozenOpinion.summary,
1612
+ summary: voice.statement,
1613
+ voice_statement: voice.statement,
1614
+ voice_status: "completed",
1615
+ voice_language: run.language,
1616
+ key_findings: voice.key_findings.length ? voice.key_findings : frozenOpinion.key_findings,
1617
+ disagreements: voice.disagreements,
1618
+ what_would_change_my_mind: voice.what_would_change_my_mind.length
1619
+ ? voice.what_would_change_my_mind
1620
+ : frozenOpinion.what_would_change_my_mind,
1621
+ source_ids: [...new Set([...(frozenOpinion.source_ids || []), ...voice.source_ids])],
1622
+ confidence: voice.confidence,
1623
+ dedicated_worker: { status: "completed", pid, language: run.language, execution_mode: "codex_exec" },
1624
+ }, engine);
1625
+ }
1126
1626
  const normalized = normalizeMasterOpinion(extractJson(result.text), id, run, result.text);
1627
+ assertReaderLanguage([
1628
+ normalized.summary,
1629
+ normalized.verdict,
1630
+ ...(normalized.key_findings || []),
1631
+ ...(normalized.disagreements || []),
1632
+ ...(normalized.what_would_change_my_mind || []),
1633
+ ].filter(Boolean).join("\n"), run.language, `legacy master voice ${id}`);
1127
1634
  const reconciled = reconcileMasterOpinion(run, id, normalized);
1128
1635
  const resolvedEngine = engine || reconciled.engine || (decision ? "v2_method_model" : "v1_prompt");
1129
- return { id, opinion: attachMasterRuntimeProvenance(run, id, reconciled.opinion, resolvedEngine), engine: resolvedEngine };
1130
- } catch (error) {
1131
- return { id, error: "parse_failed", raw: cleanLog(result.text || String(error?.message || error)) };
1636
+ return attachMasterRuntimeProvenance(run, id, {
1637
+ ...reconciled.opinion,
1638
+ voice_statement: reconciled.opinion.summary || reconciled.opinion.verdict,
1639
+ voice_status: "completed",
1640
+ voice_language: run.language,
1641
+ dedicated_worker: { status: "completed", pid, language: run.language, execution_mode: "codex_exec" },
1642
+ }, resolvedEngine);
1643
+ };
1644
+
1645
+ let result = await execute(prompt, timeoutMs, 1);
1646
+ if (!result.ok) {
1647
+ return { id, error: result.deadline_exhausted ? "global_deadline" : result.timedOut ? "timeout" : `exit code ${result.code}`, raw: cleanLog(result.stderr || result.stdout || "method worker failed") };
1648
+ }
1649
+ try {
1650
+ return { id, opinion: parse(result), engine };
1651
+ } catch (firstParseError) {
1652
+ const firstFailureKind = outputFailureKind(firstParseError);
1653
+ const repairBudget = Math.min(LIMITS.PARSE_REPAIR_MS, remainingCouncilBudget(run, LIMITS.PARSE_REPAIR_MS));
1654
+ if (repairBudget <= 0) return { id, error: firstFailureKind, raw: cleanLog(firstParseError?.message || result.text) };
1655
+ const repairPrompt = [
1656
+ "PARSE-ONLY TRANSPORT REPAIR. Do not browse, search, add facts or change the frozen method stance.",
1657
+ `Master ID: ${id}; required acknowledged stance: ${frozenOpinion?.stance || "use the original stance"}; output language: ${run.language}.`,
1658
+ frozenOpinion
1659
+ ? "Return one JSON object with master, acknowledged_stance, statement, key_findings, disagreements, what_would_change_my_mind, source_ids and confidence."
1660
+ : "Return one JSON object matching the master_opinion schema from the original prompt.",
1661
+ `Write every reader-facing value in ${run.language}. Translation is allowed only to repair language; preserve the frozen stance, facts, numbers and source IDs.`,
1662
+ `Malformed output:\n${String(result.text || "").slice(0, LIMITS.PARSE_REPAIR_INPUT_CHARS)}`,
1663
+ ].join("\n\n");
1664
+ result = await execute(repairPrompt, repairBudget, 2);
1665
+ if (!result.ok) return { id, error: result.deadline_exhausted ? "global_deadline" : result.timedOut ? "timeout" : `exit code ${result.code}`, raw: cleanLog(result.stderr || "method repair failed") };
1666
+ try {
1667
+ return { id, opinion: parse(result), engine };
1668
+ } catch (secondParseError) {
1669
+ return { id, error: outputFailureKind(secondParseError), raw: cleanLog(secondParseError?.message || result.text) };
1670
+ }
1132
1671
  }
1133
1672
  }, (error, { id }) => ({
1134
1673
  id,
@@ -1138,13 +1677,37 @@ export async function runHeadlessMasters(run, args = {}) {
1138
1677
 
1139
1678
  for (const outcome of outcomes) {
1140
1679
  if (!outcome.opinion) {
1141
- updateMasterStatus(run, outcome.id, "failed", { error: outcome.error, output: outcome.raw });
1680
+ const diagnosticPath = join(dir, `${outcome.id}.failure.json`);
1681
+ writeJson(diagnosticPath, {
1682
+ master: outcome.id,
1683
+ failure_kind: outcome.error,
1684
+ diagnostic: outcome.raw,
1685
+ public_summary: outcome.error === "reader_language_mismatch"
1686
+ ? localized(run.language, {
1687
+ en: "The dedicated method worker returned reader-facing content in the wrong language; no method-seat statement is available.",
1688
+ zh: "专属方法席 worker 返回了错误语言的读者内容;没有可用的方法席发言。",
1689
+ ja: "専用メソッド席ワーカーは指定と異なる言語の読者向け内容を返したため、利用可能なメソッド席の発言はありません。",
1690
+ ko: "전용 방법론 좌석 워커가 지정과 다른 언어의 독자용 내용을 반환해 사용할 수 있는 방법론 좌석 발언이 없습니다.",
1691
+ })
1692
+ : localized(run.language, {
1693
+ en: "The dedicated method worker did not complete; no method-seat statement is available.",
1694
+ zh: "专属方法席 worker 未完成;没有可用的方法席发言。",
1695
+ ja: "専用メソッド席ワーカーが完了せず、利用可能なメソッド席の発言はありません。",
1696
+ ko: "전용 방법론 좌석 워커가 완료되지 않아 사용할 수 있는 방법론 좌석 발언이 없습니다.",
1697
+ }),
1698
+ }, { mode: 0o600 });
1699
+ updateMasterStatus(run, outcome.id, "failed", { error: outcome.error, diagnostic: diagnosticPath });
1142
1700
  continue;
1143
1701
  }
1144
1702
  byId.set(outcome.id, outcome.opinion);
1145
1703
  writeJson(join(dir, `${outcome.id}.json`), outcome.opinion);
1146
1704
  updateMasterStatus(run, outcome.id, "completed", {
1147
1705
  engine: outcome.opinion.engine || outcome.engine,
1706
+ worker_kind: outcome.opinion.dedicated_worker?.execution_mode === "dry_run"
1707
+ ? "dedicated_method_voice_dry_run"
1708
+ : "dedicated_method_worker",
1709
+ worker_pid: outcome.opinion.dedicated_worker?.pid || null,
1710
+ voice_status: outcome.opinion.voice_status || "completed",
1148
1711
  completed_at: new Date().toISOString(),
1149
1712
  output: join(dir, `${outcome.id}.json`),
1150
1713
  });
@@ -1162,50 +1725,112 @@ export async function runHeadlessMasters(run, args = {}) {
1162
1725
  export async function runDebateRole(run, role, context, timeoutMs) {
1163
1726
  const prompt = debatePrompt(role, run, context);
1164
1727
  updateAgent(run, role, "running", { started_at: new Date().toISOString(), round: context.round });
1165
- const result = timeoutMs <= 0
1166
- ? deadlineResult()
1728
+ let result = timeoutMs <= 0
1729
+ ? deadlineResult(run)
1167
1730
  : await runCodex(prompt, timeoutMs, ({ pid, output }) => {
1168
1731
  updateAgent(run, role, "running", { pid, output, round: context.round });
1169
1732
  }, ({ pid, output, elapsed_ms }) => {
1170
1733
  updateAgent(run, role, "running", { pid, output, round: context.round });
1171
1734
  appendEvent(run, "agent_heartbeat", { role, round: context.round, pid, output, elapsed_ms });
1172
- });
1173
- const packet = debateFromCodex(result, role, run, prompt);
1735
+ }, { search: false, sigkillGraceMs: councilKillGrace(run) });
1736
+ const enforceLanguage = (candidate) => {
1737
+ if (candidate?.failure_kind) return candidate;
1738
+ try {
1739
+ assertReaderLanguage([
1740
+ candidate?.summary,
1741
+ ...(candidate?.long_thesis || []),
1742
+ ...(candidate?.short_thesis || []),
1743
+ ...(candidate?.catalysts || []),
1744
+ ...(candidate?.risks || []),
1745
+ candidate?.position,
1746
+ ...(candidate?.invalidation || []),
1747
+ ...(candidate?.questions || []),
1748
+ ...(candidate?.questions_answered || []).flatMap((item) => [item?.question, item?.answer]),
1749
+ candidate?.report_markdown,
1750
+ ].filter(Boolean).join("\n"), run.language, `${role} debate output`);
1751
+ return candidate;
1752
+ } catch (error) {
1753
+ return debateFailurePacket(role, run, "reader_language_mismatch");
1754
+ }
1755
+ };
1756
+ let packet = enforceLanguage(debateFromCodex(result, role, run, prompt));
1757
+ if (result.ok && ["parse_failed", "reader_language_mismatch"].includes(packet.failure_kind)) {
1758
+ const repairBudget = Math.min(LIMITS.PARSE_REPAIR_MS, remainingCouncilBudget(run, LIMITS.PARSE_REPAIR_MS));
1759
+ if (repairBudget > 0) {
1760
+ const repairReason = packet.failure_kind;
1761
+ appendEvent(run, "agent_parse_repair", { role, round: context.round, budget_ms: repairBudget, reason: repairReason });
1762
+ const repairPrompt = [
1763
+ "PARSE-ONLY TRANSPORT REPAIR. Do not search, browse, add facts or redo the analysis.",
1764
+ `Role: ${role}; symbol: ${run.symbol}; as_of: ${run.as_of}; reader language: ${run.language}; round: ${context.round || "final"}.`,
1765
+ repairReason === "reader_language_mismatch"
1766
+ ? "Translate only the reader-facing strings in the supplied valid debate-packet JSON. Preserve exact round-2 questions, exact round-3 question bindings, facts, numbers, source IDs and uncertainty. Return one JSON object only."
1767
+ : "Convert only the supplied malformed output into one valid debate-packet JSON object. Preserve exact round-2 questions and exact round-3 question bindings when present. Return JSON only.",
1768
+ `Write every reader-facing value in ${run.language}. Translation is allowed only to repair language; preserve facts, numbers, source IDs and exact Q&A bindings.`,
1769
+ `Malformed output:\n${String(result.text || "").slice(0, LIMITS.PARSE_REPAIR_INPUT_CHARS)}`,
1770
+ ].join("\n\n");
1771
+ result = await runCodex(repairPrompt, repairBudget, ({ pid, output }) => {
1772
+ updateAgent(run, role, "running", { pid, output, round: context.round, attempts: 2 });
1773
+ }, () => {}, { search: false, sigkillGraceMs: councilKillGrace(run) });
1774
+ packet = enforceLanguage(debateFromCodex(result, role, run, repairPrompt));
1775
+ }
1776
+ }
1174
1777
  const roundCompletedAt = new Date().toISOString();
1175
- // A role can run three times. Leaving it marked `running` after one awaited invocation
1176
- // returned made sequential rounds look concurrent in status.json. Record the dependency
1177
- // boundary explicitly while reserving `completed` for the merged three-round artifact.
1178
- updateAgent(run, role, "waiting", {
1179
- round: context.round,
1180
- round_status: "completed",
1181
- last_completed_round: context.round,
1182
- round_completed_at: roundCompletedAt,
1183
- pid: null,
1184
- output: null,
1185
- });
1186
- appendEvent(run, "agent_round_completed", {
1187
- role,
1188
- round: context.round,
1189
- ok: result.ok,
1190
- timed_out: result.timedOut === true,
1191
- verdict: packet.verdict,
1192
- question_count: packet.questions.length,
1193
- answered_count: packet.questions_answered.length,
1194
- });
1778
+ if (Number.isInteger(context.round)) {
1779
+ // A role can run three times. Leaving it marked `running` after one awaited invocation
1780
+ // returned made sequential rounds look concurrent in status.json. Record the dependency
1781
+ // boundary explicitly while reserving `completed` for the merged three-round artifact.
1782
+ updateAgent(run, role, "waiting", {
1783
+ round: context.round,
1784
+ round_status: "completed",
1785
+ last_completed_round: context.round,
1786
+ round_completed_at: roundCompletedAt,
1787
+ pid: null,
1788
+ output: null,
1789
+ });
1790
+ appendEvent(run, "agent_round_completed", {
1791
+ role,
1792
+ round: context.round,
1793
+ ok: result.ok,
1794
+ timed_out: result.timedOut === true,
1795
+ verdict: packet.verdict,
1796
+ failure_kind: packet.failure_kind,
1797
+ question_count: packet.questions.length,
1798
+ answered_count: packet.questions_answered.length,
1799
+ });
1800
+ } else {
1801
+ updateAgent(run, role, "waiting", {
1802
+ synthesis_status: "completed",
1803
+ synthesis_completed_at: roundCompletedAt,
1804
+ pid: null,
1805
+ output: null,
1806
+ });
1807
+ appendEvent(run, "agent_role_completed", {
1808
+ role,
1809
+ ok: result.ok,
1810
+ timed_out: result.timedOut === true,
1811
+ verdict: packet.verdict,
1812
+ failure_kind: packet.failure_kind,
1813
+ });
1814
+ }
1195
1815
  return { packet, result };
1196
1816
  }
1197
1817
 
1198
1818
  function debateFailure(step) {
1199
- if (step.result.ok && step.packet.verdict !== "PARSE_FAILED") return undefined;
1819
+ if (!step.result.ok) {
1820
+ if (step.result.deadline_exhausted) return "global_deadline";
1821
+ if (step.result.timedOut) return "timeout";
1822
+ if (Number.isInteger(step.result.code)) return `exit code ${step.result.code}`;
1823
+ return "unexpected_error";
1824
+ }
1825
+ if (step.packet.failure_kind) return step.packet.failure_kind;
1200
1826
  if (step.packet.verdict === "PARSE_FAILED") return "parse_failed";
1201
- if (step.result.deadline_exhausted) return "global_deadline";
1202
- return step.result.timedOut ? "timeout" : `exit code ${step.result.code ?? "unknown"}`;
1827
+ return undefined;
1203
1828
  }
1204
1829
 
1205
1830
  async function synthesizeQuickDecision(run, args, timeoutMs, outputMode) {
1206
1831
  const dir = runPath(run.run_id);
1207
1832
  appendEvent(run, "debate_round", { round: 1, format: "single_round_parallel" });
1208
- const sideBudget = remainingQuickBudget(run, timeoutMs);
1833
+ const sideBudget = remainingCouncilBudget(run, timeoutMs);
1209
1834
  const [bullOutcome, bearOutcome] = await Promise.allSettled([
1210
1835
  runDebateRole(run, "bull_researcher", { round: 1, brief: "quick long case" }, sideBudget),
1211
1836
  runDebateRole(run, "bear_researcher", { round: 1, brief: "quick short case" }, sideBudget),
@@ -1249,21 +1874,11 @@ async function synthesizeQuickDecision(run, args, timeoutMs, outputMode) {
1249
1874
  });
1250
1875
  writeAllAgentsMarkdown(run, { bull, bear });
1251
1876
 
1252
- const managerPrompt = debatePrompt("portfolio_manager", run, { bull, bear, outputMode });
1253
- updateAgent(run, "portfolio_manager", "running", { started_at: new Date().toISOString() });
1254
- const managerBudget = remainingQuickBudget(run, timeoutMs);
1255
- const managerResult = managerBudget <= 0
1256
- ? deadlineResult()
1257
- : await runCodex(managerPrompt, managerBudget, ({ pid, output }) => {
1258
- updateAgent(run, "portfolio_manager", "running", { pid, output });
1259
- }, ({ pid, output, elapsed_ms }) => {
1260
- updateAgent(run, "portfolio_manager", "running", { pid, output });
1261
- appendEvent(run, "agent_heartbeat", { role: "portfolio_manager", pid, output, elapsed_ms });
1262
- });
1263
- const manager = managerResult.ok
1264
- ? debateFromCodex(managerResult, "portfolio_manager", run, managerPrompt)
1265
- : managerFallback(run, args.prompt || "");
1266
- const managerOk = managerResult.ok && manager.verdict !== "PARSE_FAILED";
1877
+ const managerBudget = remainingCouncilBudget(run, timeoutMs);
1878
+ const managerStep = await runDebateRole(run, "portfolio_manager", { bull, bear, outputMode }, managerBudget);
1879
+ const managerError = debateFailure(managerStep);
1880
+ const managerOk = !managerError;
1881
+ const manager = managerOk ? managerStep.packet : managerFallback(run, args.prompt || "");
1267
1882
  writeJson(join(dir, "manager_synthesis.json"), manager);
1268
1883
  writeJson(join(dir, "decision.json"), manager);
1269
1884
  updateAgent(run, "portfolio_manager", managerOk ? "completed" : "failed", {
@@ -1271,9 +1886,7 @@ async function synthesizeQuickDecision(run, args, timeoutMs, outputMode) {
1271
1886
  output: join(dir, "manager_synthesis.json"),
1272
1887
  error: managerOk
1273
1888
  ? undefined
1274
- : manager.verdict === "PARSE_FAILED"
1275
- ? "parse_failed"
1276
- : managerResult.deadline_exhausted ? "global_deadline" : managerResult.timedOut ? "timeout" : `exit code ${managerResult.code ?? "unknown"}`,
1889
+ : managerError,
1277
1890
  });
1278
1891
 
1279
1892
  const gate = verificationStatus(run);
@@ -1340,6 +1953,47 @@ function finalizeBeforeDebate(run, args, reason) {
1340
1953
  return { bull: null, bear: null, manager, ...finalArtifacts };
1341
1954
  }
1342
1955
 
1956
+ function finalizeAfterDebateFailure(run, args, reason, bullRounds = [], bearRounds = []) {
1957
+ const dir = runPath(run.run_id);
1958
+ const bull = mergeDebateRounds(bullRounds.map((step) => step?.packet).filter(Boolean));
1959
+ const bear = mergeDebateRounds(bearRounds.map((step) => step?.packet).filter(Boolean));
1960
+ if (bull) writeJson(join(dir, "bull_researcher.json"), bull);
1961
+ if (bear) writeJson(join(dir, "bear_researcher.json"), bear);
1962
+ updateAgent(run, "bull_researcher", "failed", {
1963
+ error: reason,
1964
+ completed_at: new Date().toISOString(),
1965
+ output: bull ? join(dir, "bull_researcher.json") : undefined,
1966
+ });
1967
+ updateAgent(run, "bear_researcher", "failed", {
1968
+ error: reason,
1969
+ completed_at: new Date().toISOString(),
1970
+ output: bear ? join(dir, "bear_researcher.json") : undefined,
1971
+ });
1972
+ updateAgent(run, "portfolio_manager", "skipped", {
1973
+ error: reason,
1974
+ completed_at: new Date().toISOString(),
1975
+ });
1976
+ const manager = managerFallback(run, args.prompt || "");
1977
+ writeJson(join(dir, "manager_synthesis.json"), manager);
1978
+ writeJson(join(dir, "decision.json"), manager);
1979
+ run.phase = "incomplete";
1980
+ run.status = "incomplete";
1981
+ run.completed_at = new Date().toISOString();
1982
+ const completeness = completenessStatus(run);
1983
+ appendEvent(run, "incomplete", {
1984
+ reason,
1985
+ downstream_model_calls_skipped: true,
1986
+ missing_evidence: completeness.missing_evidence,
1987
+ missing_debate: completeness.missing_debate,
1988
+ missing_masters: completeness.missing_masters,
1989
+ });
1990
+ const finalArtifacts = writeFinalArtifacts(run, { bull, bear, manager });
1991
+ writeJson(join(dir, "evidence.json"), run);
1992
+ writeStatus(run);
1993
+ writeAllAgentsMarkdown(run, { bull, bear, manager });
1994
+ return { bull, bear, manager, ...finalArtifacts };
1995
+ }
1996
+
1343
1997
  export async function synthesizeDecision(run, args) {
1344
1998
  const dir = runPath(run.run_id);
1345
1999
  const requestedSynthesisMs = Number.isFinite(args.synthesis_timeout_ms)
@@ -1347,7 +2001,7 @@ export async function synthesizeDecision(run, args) {
1347
2001
  : Number(args.timeout_ms || LIMITS.CODEX_TIMEOUT_MS);
1348
2002
  const timeoutMs = run.council_mode === "quick"
1349
2003
  ? Math.min(requestedSynthesisMs, LIMITS.QUICK_SYNTHESIS_MS)
1350
- : requestedSynthesisMs;
2004
+ : Math.min(requestedSynthesisMs, LIMITS.FULL_DEBATE_MS);
1351
2005
  const outputMode = OUTPUT_MODES.includes(args.output_mode) ? args.output_mode : "public_equity";
1352
2006
  run.phase = "debate";
1353
2007
  run.status = "running";
@@ -1403,28 +2057,66 @@ export async function synthesizeDecision(run, args) {
1403
2057
  return synthesizeQuickDecision(run, args, timeoutMs, outputMode);
1404
2058
  }
1405
2059
 
1406
- // Three-round debate: R1 cases, R2 cross-rebuttal, R3 Q&A.
1407
- appendEvent(run, "debate_round", { round: 1 });
1408
- const bullR1 = await runDebateRole(run, "bull_researcher", { round: 1, brief: "long" }, timeoutMs);
1409
- const bearR1 = await runDebateRole(run, "bear_researcher", { round: 1, brief: "short", bull: bullR1.packet }, timeoutMs);
1410
-
1411
- appendEvent(run, "debate_round", { round: 2 });
1412
- const bullR2 = await runDebateRole(run, "bull_researcher", { round: 2, otherCaseR1: bearR1.packet }, timeoutMs);
1413
- const bearR2 = await runDebateRole(run, "bear_researcher", { round: 2, otherCaseR1: bullR1.packet }, timeoutMs);
1414
-
1415
- appendEvent(run, "debate_round", { round: 3 });
1416
- const bullR3 = await runDebateRole(run, "bull_researcher", {
1417
- round: 3,
1418
- otherCaseR1: bearR2.packet,
1419
- questionsYouAsked: bullR2.packet.questions,
1420
- questionsForYou: bearR2.packet.questions,
1421
- }, timeoutMs);
1422
- const bearR3 = await runDebateRole(run, "bear_researcher", {
1423
- round: 3,
1424
- otherCaseR1: bullR2.packet,
1425
- questionsYouAsked: bearR2.packet.questions,
1426
- questionsForYou: bullR2.packet.questions,
1427
- }, timeoutMs);
2060
+ const rejectedStep = (reason, role) => {
2061
+ const result = {
2062
+ ok: false, code: null, text: "", stderr: cleanLog(reason?.message || reason), stdout: "",
2063
+ timedOut: false, unexpected_error: true,
2064
+ };
2065
+ return { result, packet: debateFromCodex(result, role, run, "unexpected debate failure") };
2066
+ };
2067
+ const parallelRound = async (round, bullContext, bearContext) => {
2068
+ const budget = remainingCouncilBudget(run, timeoutMs);
2069
+ appendEvent(run, "debate_round", { round, format: "parallel_per_round", budget_ms: budget });
2070
+ const [bullOutcome, bearOutcome] = await Promise.allSettled([
2071
+ runDebateRole(run, "bull_researcher", { round, ...bullContext }, budget),
2072
+ runDebateRole(run, "bear_researcher", { round, ...bearContext }, budget),
2073
+ ]);
2074
+ return {
2075
+ bull: bullOutcome.status === "fulfilled" ? bullOutcome.value : rejectedStep(bullOutcome.reason, "bull_researcher"),
2076
+ bear: bearOutcome.status === "fulfilled" ? bearOutcome.value : rejectedStep(bearOutcome.reason, "bear_researcher"),
2077
+ };
2078
+ };
2079
+ const failedRound = (round) => [debateFailure(round.bull), debateFailure(round.bear)].filter(Boolean);
2080
+
2081
+ // Three-round debate with a strict inter-round barrier and parallel sides per round.
2082
+ const r1 = await parallelRound(1, { brief: "long" }, { brief: "short" });
2083
+ if (failedRound(r1).length) {
2084
+ return finalizeAfterDebateFailure(run, args, `debate_round_1_failed:${failedRound(r1).join(",")}`, [r1.bull], [r1.bear]);
2085
+ }
2086
+
2087
+ const r2 = await parallelRound(2,
2088
+ { otherCaseR1: r1.bear.packet },
2089
+ { otherCaseR1: r1.bull.packet });
2090
+ if (failedRound(r2).length) {
2091
+ return finalizeAfterDebateFailure(run, args, `debate_round_2_failed:${failedRound(r2).join(",")}`, [r1.bull, r2.bull], [r1.bear, r2.bear]);
2092
+ }
2093
+ const exactlyThreeQuestions = (packet) => Array.isArray(packet?.questions)
2094
+ && packet.questions.length === 3
2095
+ && packet.questions.every((question) => typeof question === "string" && question.trim());
2096
+ if (!exactlyThreeQuestions(r2.bull.packet) || !exactlyThreeQuestions(r2.bear.packet)) {
2097
+ appendEvent(run, "debate_qna_gate", { status: "failed", errors: ["round 2 did not produce exactly three questions per side"] });
2098
+ return finalizeAfterDebateFailure(run, args, "debate_round_2_questions_incomplete", [r1.bull, r2.bull], [r1.bear, r2.bear]);
2099
+ }
2100
+
2101
+ const r3 = await parallelRound(3, {
2102
+ otherCaseR1: r2.bear.packet,
2103
+ questionsYouAsked: r2.bull.packet.questions,
2104
+ questionsForYou: r2.bear.packet.questions,
2105
+ }, {
2106
+ otherCaseR1: r2.bull.packet,
2107
+ questionsYouAsked: r2.bear.packet.questions,
2108
+ questionsForYou: r2.bull.packet.questions,
2109
+ });
2110
+ if (failedRound(r3).length) {
2111
+ return finalizeAfterDebateFailure(run, args, `debate_round_3_failed:${failedRound(r3).join(",")}`, [r1.bull, r2.bull, r3.bull], [r1.bear, r2.bear, r3.bear]);
2112
+ }
2113
+
2114
+ const bullR1 = r1.bull;
2115
+ const bearR1 = r1.bear;
2116
+ const bullR2 = r2.bull;
2117
+ const bearR2 = r2.bear;
2118
+ const bullR3 = r3.bull;
2119
+ const bearR3 = r3.bear;
1428
2120
 
1429
2121
  const bull = mergeDebateRounds([bullR1.packet, bullR2.packet, bullR3.packet]);
1430
2122
  const bear = mergeDebateRounds([bearR1.packet, bearR2.packet, bearR3.packet]);
@@ -1435,6 +2127,9 @@ export async function synthesizeDecision(run, args) {
1435
2127
  bearR3: bearR3.packet,
1436
2128
  });
1437
2129
  appendEvent(run, "debate_qna_gate", qnaGate);
2130
+ if (qnaGate.status !== "passed") {
2131
+ return finalizeAfterDebateFailure(run, args, "debate_round_3_qna_incomplete", [bullR1, bullR2, bullR3], [bearR1, bearR2, bearR3]);
2132
+ }
1438
2133
  const bullQnaErrors = qnaGate.errors.filter((error) => error.startsWith("bull_researcher"));
1439
2134
  const bearQnaErrors = qnaGate.errors.filter((error) => error.startsWith("bear_researcher"));
1440
2135
  const bullTransportOk = [bullR1, bullR2, bullR3].every((step) => step.result.ok);
@@ -1472,24 +2167,17 @@ export async function synthesizeDecision(run, args) {
1472
2167
  });
1473
2168
  writeAllAgentsMarkdown(run, { bull, bear });
1474
2169
 
1475
- const managerPrompt = debatePrompt("portfolio_manager", run, { bull, bear, outputMode });
1476
- updateAgent(run, "portfolio_manager", "running", { started_at: new Date().toISOString() });
1477
- const managerResult = await runCodex(managerPrompt, timeoutMs, ({ pid, output }) => {
1478
- updateAgent(run, "portfolio_manager", "running", { pid, output });
1479
- }, ({ pid, output, elapsed_ms }) => {
1480
- updateAgent(run, "portfolio_manager", "running", { pid, output });
1481
- appendEvent(run, "agent_heartbeat", { role: "portfolio_manager", pid, output, elapsed_ms });
1482
- });
1483
- const manager = managerResult.ok
1484
- ? debateFromCodex(managerResult, "portfolio_manager", run, managerPrompt)
1485
- : managerFallback(run, args.prompt || "");
2170
+ const pmBudget = remainingCouncilBudget(run, Math.min(requestedSynthesisMs, LIMITS.FULL_PM_MS));
2171
+ const managerStep = await runDebateRole(run, "portfolio_manager", { bull, bear, outputMode }, pmBudget);
2172
+ const managerOk = !debateFailure(managerStep);
2173
+ const manager = managerOk ? managerStep.packet : managerFallback(run, args.prompt || "");
1486
2174
  const gate = verificationStatus(run);
1487
2175
  writeJson(join(dir, "manager_synthesis.json"), manager);
1488
2176
  writeJson(join(dir, "decision.json"), manager);
1489
- updateAgent(run, "portfolio_manager", managerResult.ok && manager.verdict !== "PARSE_FAILED" ? "completed" : "failed", {
2177
+ updateAgent(run, "portfolio_manager", managerOk ? "completed" : "failed", {
1490
2178
  completed_at: new Date().toISOString(),
1491
2179
  output: join(dir, "manager_synthesis.json"),
1492
- error: managerResult.ok ? undefined : (managerResult.timedOut ? "timeout" : `exit code ${managerResult.code}`),
2180
+ error: managerOk ? undefined : debateFailure(managerStep),
1493
2181
  });
1494
2182
  const completeness = completenessStatus(run);
1495
2183
  run.completed_at = new Date().toISOString();
@@ -1532,7 +2220,7 @@ export async function analyzeSymbol(args) {
1532
2220
  artifacts: debate.artifacts || artifactPaths(run),
1533
2221
  };
1534
2222
  }
1535
- if (run.council_mode === "quick" && remainingQuickBudget(run, 1) <= 0) {
2223
+ if (remainingCouncilBudget(run, 1) <= 0) {
1536
2224
  const debate = finalizeBeforeDebate(run, args, "global_deadline_before_masters");
1537
2225
  return {
1538
2226
  run,
@@ -1546,8 +2234,7 @@ export async function analyzeSymbol(args) {
1546
2234
  }
1547
2235
  await runHeadlessMasters(run, args);
1548
2236
  gate = completenessStatus(run);
1549
- if (gate.missing_masters.length > 0
1550
- || (run.council_mode === "quick" && remainingQuickBudget(run, 1) <= 0)) {
2237
+ if (gate.missing_masters.length > 0 || remainingCouncilBudget(run, 1) <= 0) {
1551
2238
  const debate = finalizeBeforeDebate(run, args,
1552
2239
  gate.missing_masters.length ? "master_gate_failed" : "global_deadline_before_debate");
1553
2240
  return {