alphacouncil-agent 1.0.3 → 1.0.13

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 (81) hide show
  1. package/.claude/agents/alphacouncil-insider_sec.md +19 -0
  2. package/.claude/commands/alpha.md +48 -19
  3. package/.claude-plugin/marketplace.json +2 -2
  4. package/.claude-plugin/plugin.json +1 -1
  5. package/.codex-plugin/plugin.json +1 -1
  6. package/.grok/agents/alphacouncil-insider_sec.md +19 -0
  7. package/.grok/commands/alpha.md +48 -19
  8. package/.opencode/agent/alphacouncil-insider_sec.md +19 -0
  9. package/.opencode/command/alpha.md +48 -19
  10. package/CHANGELOG.md +307 -0
  11. package/CLAUDE.md +55 -13
  12. package/README.ja.md +42 -16
  13. package/README.md +70 -27
  14. package/README.zh-CN.md +53 -20
  15. package/SECURITY.md +11 -1
  16. package/commands/alpha.md +48 -19
  17. package/data/build-profile.v1.json +1 -1
  18. package/docs/INSTALL.md +9 -6
  19. package/docs/examples/final_report.SOX.zh.md +442 -0
  20. package/docs/report-contract.md +27 -3
  21. package/knowledge/ai-assisted-solo/experiments/runs/a.json +4 -4
  22. package/knowledge/ai-assisted-solo/experiments/runs/b.json +2 -2
  23. package/knowledge/ai-assisted-solo/experiments/runs/c.json +2 -2
  24. package/knowledge/ai-assisted-solo/experiments/runs/d13.json +16 -16
  25. package/knowledge/ai-assisted-solo/experiments/runs/d26.json +30 -30
  26. package/knowledge/ai-assisted-solo/experiments/runs/e-d13.json +19 -19
  27. package/knowledge/ai-assisted-solo/experiments/runs/e-d26.json +33 -33
  28. package/knowledge/ai-assisted-solo/experiments/runs/h_ai_reference.json +34 -34
  29. package/knowledge/ai-assisted-solo/experiments/simulation-input.json +84 -84
  30. package/knowledge/ai-assisted-solo/experiments/simulation-manifest.json +19 -19
  31. package/knowledge/solo-test/masters/master_ackman/manifest.json +8 -8
  32. package/knowledge/solo-test/masters/master_aschenbrenner/manifest.json +8 -8
  33. package/knowledge/solo-test/masters/master_asness/manifest.json +8 -8
  34. package/knowledge/solo-test/masters/master_bogle/manifest.json +8 -8
  35. package/knowledge/solo-test/masters/master_buffett/manifest.json +8 -8
  36. package/knowledge/solo-test/masters/master_burry/manifest.json +8 -8
  37. package/knowledge/solo-test/masters/master_cathie_wood/manifest.json +8 -8
  38. package/knowledge/solo-test/masters/master_dalio/manifest.json +8 -8
  39. package/knowledge/solo-test/masters/master_damodaran/manifest.json +8 -8
  40. package/knowledge/solo-test/masters/master_druckenmiller/manifest.json +8 -8
  41. package/knowledge/solo-test/masters/master_duan_yongping/manifest.json +8 -8
  42. package/knowledge/solo-test/masters/master_fisher/manifest.json +8 -8
  43. package/knowledge/solo-test/masters/master_forensic_short/manifest.json +8 -8
  44. package/knowledge/solo-test/masters/master_graham/manifest.json +8 -8
  45. package/knowledge/solo-test/masters/master_jhunjhunwala/manifest.json +8 -8
  46. package/knowledge/solo-test/masters/master_klarman/manifest.json +8 -8
  47. package/knowledge/solo-test/masters/master_li_lu/manifest.json +8 -8
  48. package/knowledge/solo-test/masters/master_lynch/manifest.json +8 -8
  49. package/knowledge/solo-test/masters/master_marks/manifest.json +8 -8
  50. package/knowledge/solo-test/masters/master_munger/manifest.json +8 -8
  51. package/knowledge/solo-test/masters/master_natenberg/manifest.json +8 -8
  52. package/knowledge/solo-test/masters/master_pabrai/manifest.json +8 -8
  53. package/knowledge/solo-test/masters/master_simons/manifest.json +8 -8
  54. package/knowledge/solo-test/masters/master_sinclair/manifest.json +8 -8
  55. package/knowledge/solo-test/masters/master_soros/manifest.json +8 -8
  56. package/knowledge/solo-test/masters/master_taleb/manifest.json +8 -8
  57. package/knowledge/solo-test/masters/master_thorp/manifest.json +8 -8
  58. package/mcp/lib/constants.mjs +82 -4
  59. package/mcp/lib/council-options.mjs +8 -4
  60. package/mcp/lib/council-selection.mjs +79 -1
  61. package/mcp/lib/fundamentals.mjs +50 -10
  62. package/mcp/lib/gates.mjs +42 -0
  63. package/mcp/lib/grounding.mjs +27 -1
  64. package/mcp/lib/industry.mjs +9 -1
  65. package/mcp/lib/markdown.mjs +25 -1
  66. package/mcp/lib/orchestrator.mjs +187 -24
  67. package/mcp/lib/personas/engine.mjs +57 -12
  68. package/mcp/lib/personas-v3/runtime.mjs +28 -0
  69. package/mcp/lib/prompts.mjs +72 -3
  70. package/mcp/lib/rpc.mjs +93 -7
  71. package/mcp/lib/run-store.mjs +3 -0
  72. package/mcp/lib/screen.mjs +46 -46
  73. package/mcp/lib/sec.mjs +25 -10
  74. package/mcp/lib/voice-from-decision.mjs +13 -5
  75. package/mcp/lib/weights.mjs +9 -2
  76. package/mcp/lib/xbrl-series.mjs +31 -5
  77. package/package.json +2 -1
  78. package/personas/analysts/insider_sec.md +38 -0
  79. package/scripts/lib/persona-v3-solo-test-packs.mjs +14 -8
  80. package/scripts/review-persona-v3-solo-formulas.mjs +5 -3
  81. package/skills/alphacouncil-agent/SKILL.md +80 -19
@@ -13,6 +13,16 @@ import { intentLabel, VOICE_FIELDS, voiceDisclaimer, voiceFieldLabel } from "./v
13
13
  import { agentState, appendEvent, artifactPaths, runPath, taskState } from "./run-store.mjs";
14
14
  import { personaTitle, registry } from "./personas/registry.mjs";
15
15
 
16
+ /**
17
+ * Per-seat statement budget in the handoff.
18
+ *
19
+ * The governance contract requires every selected seat's statement in the handoff, so this is
20
+ * the seat's only appearance there. At the old one-line budget a seat's conclusion was always
21
+ * the part that got cut, which is how seven seats that all spoke read as seven seats that had
22
+ * not. The complete statement stays in the report and in `master_<id>.md`.
23
+ */
24
+ const MASTER_STATEMENT_CHARS = 2000;
25
+
16
26
  export function renderPacketMarkdown(packet, index = 0, language = packet?.language) {
17
27
  const key = languageKey(language);
18
28
  const label = {
@@ -1170,7 +1180,21 @@ export function userResponseMarkdown(run, manager) {
1170
1180
  if (!opinion) return `- ${masterTitle(id, run.language)} (\`${id}\`) [${localizedDisplayValue(state.status, run.language)}]: ${localizedFailure(state.error, run.language) || copy.missing}`;
1171
1181
  const statementSource = opinion.dedicated_worker?.status || opinion.voice_status || "unknown";
1172
1182
  const frozenRecord = opinion.statement_origin || opinion.reason || opinion.engine || "recorded";
1173
- return `- ${masterTitle(id, run.language)} (\`${id}\`) ${clipAtBoundary(opinion.voice_statement || opinion.summary || opinion.verdict, 520)} [${copy.record}: ${localizedDisplayValue(opinion.stance, run.language)}/${localizedDisplayValue(opinion.confidence || "low", run.language)}; ${copy.worker}: ${localizedDisplayValue(statementSource, run.language)}; ${frozenRecord}]`;
1183
+ // A seat's statement opens with the evidence it read and closes with what it would do, so
1184
+ // clipping the opening to one line spent the whole budget on background and cut the
1185
+ // conclusion. Lead with the reading that decided it and the action it implies -- both are
1186
+ // already composed from the frozen decision -- then quote the statement at a budget that
1187
+ // survives a paragraph, and leave provenance to its own line instead of the same sentence.
1188
+ const lead = [opinion.voice?.how_my_method_reads_it, opinion.voice?.would_i_act]
1189
+ .filter((part) => typeof part === "string" && part.trim().length)
1190
+ .join(" ");
1191
+ const statement = opinion.voice_statement || opinion.summary || opinion.verdict || "";
1192
+ return [
1193
+ `- ${masterTitle(id, run.language)} (\`${id}\`)`,
1194
+ lead ? ` - ${clipAtBoundary(lead, 700)}` : "",
1195
+ statement ? ` - ${clipAtBoundary(statement, MASTER_STATEMENT_CHARS)}` : "",
1196
+ ` - [${copy.record}: ${localizedDisplayValue(opinion.stance, run.language)}/${localizedDisplayValue(opinion.confidence || "low", run.language)}; ${copy.worker}: ${localizedDisplayValue(statementSource, run.language)}; ${frozenRecord}]`,
1197
+ ].filter(Boolean).join("\n");
1174
1198
  }).join("\n") || `- ${copy.missing}`;
1175
1199
  const analystLines = (run.tasks || []).map((task) => {
1176
1200
  const packet = (run.packets || []).find((item) => item.task === task);
@@ -1,19 +1,19 @@
1
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { COUNCIL_MODES, DEBATE_ROLES, DEFAULT_TASKS, LIMITS, OUTPUT_MODES, QUICK_TASKS } from "./constants.mjs";
3
+ import { COUNCIL_MODES, DEBATE_ROLES, DEFAULT_TASKS, LIMITS, OUTPUT_MODES, QUICK_TASKS, councilPaceProfile } from "./constants.mjs";
4
4
  import { invalidParams } from "./errors.mjs";
5
5
  import { readJson, writeJson } from "./fsutil.mjs";
6
6
  import { registry } from "./personas/registry.mjs";
7
7
  import { assertReaderLanguage, isChineseLanguage, localized, resolveLanguage } from "./lang.mjs";
8
8
  import { cleanLog } from "./text.mjs";
9
- import { completenessStatus, masterSeatIncomplete, verificationStatus } from "./gates.mjs";
9
+ import { authoredReportSectionGaps, completenessStatus, masterSeatIncomplete, requiredReportSectionAliases, 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
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
14
  import { debatePrompt, masterPrompt, masterVoicePrompt, selectedMasters, taskPrompt } from "./prompts.mjs";
15
15
  import { resolveSeatWeights } from "./weights.mjs";
16
- import { completedMasterOpinion, declinedMasterOpinion, planMasterSeats, reconcileMasterOpinion } from "./personas/engine.mjs";
16
+ import { completedMasterOpinion, declinedMasterOpinion, needsMethodVoiceWorker, planMasterSeats, reconcileMasterOpinion } from "./personas/engine.mjs";
17
17
  import { gatherGrounding } from "./grounding.mjs";
18
18
  import { councilOptions } from "./council-options.mjs";
19
19
  import { sha256 } from "./personas-v3/canonical.mjs";
@@ -28,11 +28,28 @@ function plannedTasks(args = {}) {
28
28
  return DEFAULT_TASKS;
29
29
  }
30
30
 
31
+ /**
32
+ * The pace a run executes at, and the per-stage caps that come with it.
33
+ *
34
+ * Quick has one fixed shape and does not take a pace: it is a smaller contract, not a slower
35
+ * one. Full carries the pace on the run so every stage reads the same profile and `status.json`
36
+ * records which one was used.
37
+ */
38
+ export function runPace(run) {
39
+ if (run?.council_mode === "quick") return null;
40
+ return councilPaceProfile(run?.council_pace);
41
+ }
42
+
31
43
  function councilTiming(args, startedAt) {
32
44
  const mode = councilMode(args);
33
45
  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;
46
+ const profile = mode === "quick" ? null : councilPaceProfile(args.council_pace);
47
+ // The ceiling a call is held to is its own pace's total, not the outer bound of the schema.
48
+ // A caller may still lower it; `LIMITS.FULL_TOTAL_MS` keeps the operator env override.
49
+ const hardMaximum = mode === "quick" ? LIMITS.QUICK_HARD_MAX_MS : profile.total_ms;
50
+ const defaultBudget = mode === "quick"
51
+ ? LIMITS.QUICK_TOTAL_MS
52
+ : Math.min(profile.total_ms, LIMITS.FULL_TOTAL_OVERRIDE_MS ?? profile.total_ms);
36
53
  const timeBudgetMs = Number.isFinite(requested) && requested > 0
37
54
  ? Math.min(requested, hardMaximum)
38
55
  : defaultBudget;
@@ -40,6 +57,7 @@ function councilTiming(args, startedAt) {
40
57
  || new Date(Date.parse(startedAt) + timeBudgetMs).toISOString();
41
58
  return {
42
59
  council_mode: mode,
60
+ council_pace: profile?.pace || null,
43
61
  debate_format: mode === "quick" ? "single_round_parallel" : "three_round_cross_exam_parallel_per_round",
44
62
  time_budget_ms: timeBudgetMs,
45
63
  deadline_at: deadlineAt,
@@ -62,7 +80,7 @@ function remainingCouncilBudget(run, capMs) {
62
80
  if (!run.deadline_at) return capMs;
63
81
  const configuredReserve = run.council_mode === "quick"
64
82
  ? LIMITS.QUICK_FINALIZE_RESERVE_MS
65
- : LIMITS.FULL_FINALIZE_RESERVE_MS;
83
+ : runPace(run).finalize_reserve_ms;
66
84
  const reserve = Math.min(
67
85
  configuredReserve,
68
86
  Math.max(100, Math.floor(Number(run.time_budget_ms || LIMITS.FULL_TOTAL_MS) * 0.1)),
@@ -268,7 +286,7 @@ export function visibleAgentSpecs(run, userPrompt = "") {
268
286
  ja: `AlphaCouncil Agent ${run.symbol} ${task} 証拠サブエージェント`,
269
287
  ko: `AlphaCouncil Agent ${run.symbol} ${task} 증거 하위 에이전트`,
270
288
  }),
271
- prompt: taskPrompt(task, run.symbol, run.as_of, userPrompt, run.language, run.grounding),
289
+ prompt: taskPrompt(task, run.symbol, run.as_of, userPrompt, run.language, run.grounding, run.council_pace),
272
290
  output_contract: localized(run.language, {
273
291
  en: `Return one JSON evidence packet with reader-facing fields in ${run.language}.`,
274
292
  zh: "只返回一个 JSON evidence packet。",
@@ -374,16 +392,23 @@ export function visibleAgentSpecs(run, userPrompt = "") {
374
392
  item.engine,
375
393
  ));
376
394
  }
395
+ // A seat that executed its policy and still abstained is in the same position as a
396
+ // declined seat: the frozen stance is out_of_scope, no worker may change it, and the
397
+ // deterministic statement already names the gate that closed and says an abstention is
398
+ // not a bearish vote. Only seats that reached a stance have a reading to explain.
399
+ const requiresVisibleVoice = needsMethodVoiceWorker(byId.get(item.id));
377
400
  const alreadyVoiced = run.master_status?.[item.id]?.status === "completed"
378
401
  && byId.get(item.id)?.voice_status === "completed";
379
402
  run.master_status[item.id] = {
380
403
  ...(run.master_status[item.id] || {}),
381
404
  master: item.id,
382
- status: alreadyVoiced ? "completed" : "waiting",
405
+ status: alreadyVoiced || !requiresVisibleVoice ? "completed" : "waiting",
383
406
  engine: item.engine,
384
407
  deterministic_execution: true,
385
- voice_required: true,
386
- completed_at: alreadyVoiced ? run.master_status[item.id].completed_at : null,
408
+ voice_required: requiresVisibleVoice,
409
+ completed_at: alreadyVoiced || !requiresVisibleVoice
410
+ ? (run.master_status[item.id]?.completed_at || new Date().toISOString())
411
+ : null,
387
412
  };
388
413
  }
389
414
  run.master_opinions = selectedMasters(run).map((id) => byId.get(id)).filter(Boolean);
@@ -402,6 +427,7 @@ export function visibleAgentSpecs(run, userPrompt = "") {
402
427
  const v3VoiceAgents = plan.completed
403
428
  .filter((item) => item.engine === "v3_method_runtime")
404
429
  .filter((item) => run.master_status?.[item.id]?.status !== "completed")
430
+ .filter((item) => needsMethodVoiceWorker(frozenById.get(item.id)))
405
431
  .map((item) => {
406
432
  const frozenOpinion = frozenById.get(item.id);
407
433
  return {
@@ -432,10 +458,21 @@ export function visibleAgentSpecs(run, userPrompt = "") {
432
458
  .map((id) => master_agents.find((agent) => agent.role === id))
433
459
  .filter(Boolean);
434
460
  saveRun(run);
461
+ const prompts = externalizeVisiblePrompts(run, [
462
+ { kind: "evidence", agents: evidence_agents },
463
+ { kind: "master", agents: orderedMasterAgents },
464
+ { kind: "debate", agents: debate_agents },
465
+ ]);
435
466
  return {
436
- evidence_agents,
437
- master_agents: orderedMasterAgents,
438
- debate_agents,
467
+ // Every planned prompt is on disk either way. `prompts_inline` says whether this result
468
+ // also carries them, so a host never has to guess whether `prompt_template` is missing
469
+ // because the plan failed or because it was too large to return.
470
+ prompts_inline: prompts.inline,
471
+ prompt_dir: prompts.prompt_dir,
472
+ prompt_chars_total: prompts.prompt_chars_total,
473
+ evidence_agents: prompts.byKind.get("evidence"),
474
+ master_agents: prompts.byKind.get("master"),
475
+ debate_agents: prompts.byKind.get("debate"),
439
476
  // Recorded, not hidden: a reader must be able to tell a method that judged from a method
440
477
  // that could not look, and neither from a seat that was never offered.
441
478
  master_decisions: plan.decisions,
@@ -468,6 +505,67 @@ export function visibleAgentSpecs(run, userPrompt = "") {
468
505
  };
469
506
  }
470
507
 
508
+ /**
509
+ * Total prompt characters a visible plan may return inline.
510
+ *
511
+ * The plan carries one full prompt per agent, and every prompt embeds the grounding and
512
+ * evidence JSON. A real eight-seat run returned 311,007 characters in a single tool result and
513
+ * the host rejected the whole thing for exceeding its result ceiling -- the plan was produced
514
+ * correctly and then thrown away. Below this budget nothing changes for an existing host;
515
+ * above it the prompts move to disk and each agent carries the path instead.
516
+ */
517
+ const VISIBLE_PLAN_INLINE_PROMPT_CHARS = 120000;
518
+
519
+ /**
520
+ * The size that decides this is the grounding each prompt embeds, not the number of seats: a
521
+ * bench-wide plan with no grounding is well inside the budget, while eight seats against a
522
+ * full macro series is what produced the 311k result. Operators may lower the budget for a
523
+ * host with a tighter ceiling; they may not raise it past what that host would reject anyway.
524
+ */
525
+ function inlinePromptBudget(env = process.env) {
526
+ const requested = Number(env?.ALPHACOUNCIL_VISIBLE_INLINE_PROMPT_CHARS);
527
+ if (!Number.isFinite(requested) || requested < 0) return VISIBLE_PLAN_INLINE_PROMPT_CHARS;
528
+ return Math.min(requested, VISIBLE_PLAN_INLINE_PROMPT_CHARS);
529
+ }
530
+
531
+ /**
532
+ * Write every planned prompt to the run directory, and drop the inline copies when returning
533
+ * them all at once would exceed what a host will accept.
534
+ */
535
+ function externalizeVisiblePrompts(run, groups) {
536
+ const dir = join(runPath(run.run_id), "prompts");
537
+ mkdirSync(dir, { recursive: true });
538
+ // Roles are generated ids, but a filename is still a filename: keep it to one path segment.
539
+ const fileSafe = (role) => String(role || "agent").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80) || "agent";
540
+ // Evidence agents carry `prompt`; master and debate agents carry `prompt_template`. Reading
541
+ // only one of them wrote empty files for the other group.
542
+ const promptKey = (agent) => (typeof agent.prompt_template === "string" ? "prompt_template" : "prompt");
543
+ const total = groups.reduce((sum, { agents }) => sum + agents
544
+ .reduce((inner, agent) => inner + String(agent[promptKey(agent)] || "").length, 0), 0);
545
+ const inline = total <= inlinePromptBudget();
546
+ const externalized = groups.map(({ kind, agents }) => ({
547
+ kind,
548
+ agents: agents.map((agent) => {
549
+ const key = promptKey(agent);
550
+ const file = join(dir, `${kind}.${fileSafe(agent.role)}.prompt.md`);
551
+ const prompt = String(agent[key] || "");
552
+ writeFileSync(file, prompt.endsWith("\n") ? prompt : `${prompt}\n`, { encoding: "utf8", mode: 0o600 });
553
+ return {
554
+ ...agent,
555
+ prompt_file: file,
556
+ prompt_chars: prompt.length,
557
+ ...(inline ? {} : { [key]: null }),
558
+ };
559
+ }),
560
+ }));
561
+ return {
562
+ inline,
563
+ prompt_dir: dir,
564
+ prompt_chars_total: total,
565
+ byKind: new Map(externalized.map(({ kind, agents }) => [kind, agents])),
566
+ };
567
+ }
568
+
471
569
  /**
472
570
  * The stance is already decided. The prompt says so, in the run's language, so the model
473
571
  * writes an explanation rather than a verdict.
@@ -941,14 +1039,48 @@ function recordVisiblePortfolioManager(run, args) {
941
1039
  execution_mode: "visible_host_threads",
942
1040
  }, "portfolio_manager", run, rawRecordText(args.packet));
943
1041
  assertVisibleReaderLanguage(debateReaderText(packet), run, "visible portfolio_manager decision");
1042
+ // Reject a report body that cannot pass the report gate before the packet takes the
1043
+ // idempotency lock, and say which headings are owed. Previously the gate ran only after the
1044
+ // report was assembled, so a submission with no `report_markdown` at all was accepted, the
1045
+ // report was built from the summary fallback, and the author learned about 21 missing
1046
+ // sections only after the entire PM turn had been spent.
1047
+ const authoredGaps = authoredReportSectionGaps(packet.report_markdown, run);
1048
+ if (authoredGaps.length) {
1049
+ rejectVisibleDecision(
1050
+ run,
1051
+ "VISIBLE_PM_REPORT_SECTIONS_MISSING",
1052
+ packet.report_markdown
1053
+ ? "portfolio_manager rejected: report_markdown does not carry every required report-contract section."
1054
+ : "portfolio_manager rejected: packet.report_markdown is required and must be the complete report body, not an execution note.",
1055
+ {
1056
+ report_contract: run.council_mode === "quick" ? "quick_v1" : "full_v2",
1057
+ report_markdown_characters: String(packet.report_markdown || "").length,
1058
+ gaps: authoredGaps,
1059
+ required_sections: requiredReportSectionAliases(run),
1060
+ },
1061
+ );
1062
+ }
944
1063
  const contentHash = visibleDecisionContentHash(packet);
945
1064
  if (state.portfolio_manager) {
946
- if (state.portfolio_manager.content_hash !== contentHash) {
1065
+ // A report that failed the structure gate must stay revisable. The first submission takes
1066
+ // the idempotency lock before the gate runs, so a PM whose report_markdown was thin left the
1067
+ // run stuck at needs_revision forever: the fix was rejected as a conflicting replay and there
1068
+ // was no other way in. A passed report is still frozen -- revision is for repairing a
1069
+ // rejected report, not for changing a verdict that already stands.
1070
+ const priorQualityPath = join(dir, "report_quality.json");
1071
+ const priorQuality = existsSync(priorQualityPath) ? readJson(priorQualityPath) : null;
1072
+ const revisable = priorQuality && priorQuality.status !== "passed";
1073
+ if (state.portfolio_manager.content_hash !== contentHash && !revisable) {
947
1074
  rejectVisibleDecision(run, "VISIBLE_PM_CONFLICT", "Conflicting portfolio_manager replay.", {
948
1075
  existing_content_hash: state.portfolio_manager.content_hash,
949
1076
  submitted_content_hash: contentHash,
1077
+ report_quality: priorQuality?.status || null,
950
1078
  });
951
1079
  }
1080
+ // Revision accepted: drop the lock so the normal record path below runs again.
1081
+ if (state.portfolio_manager.content_hash !== contentHash) state.portfolio_manager = null;
1082
+ }
1083
+ if (state.portfolio_manager) {
952
1084
  const handoffPath = join(dir, "user_response.md");
953
1085
  const reportPath = join(dir, "final_report.md");
954
1086
  const qualityPath = join(dir, "report_quality.json");
@@ -1138,8 +1270,12 @@ function readerStrings(value, skipKeys = new Set()) {
1138
1270
  }
1139
1271
 
1140
1272
  function visibleEvidenceReaderText(packet) {
1273
+ // `title` joins the machine set for the same reason `url` is already there: a source title is
1274
+ // the publisher's own words. Counting English headlines against a Chinese run's language ratio
1275
+ // rejected packets whose every authored sentence was Chinese, and the only way to pass was to
1276
+ // translate the citation -- which is falsifying the source, not localising the report.
1141
1277
  const machine = new Set([
1142
- "task", "symbol", "as_of", "source_ids", "id", "url", "published_at", "retrieved_at",
1278
+ "task", "symbol", "as_of", "source_ids", "id", "url", "title", "published_at", "retrieved_at",
1143
1279
  "confidence", "information_richness", "thread_id", "execution_mode", "raw_text",
1144
1280
  ]);
1145
1281
  return [
@@ -1314,7 +1450,7 @@ export async function collectEvidence(args) {
1314
1450
  const requestedTimeoutMs = Number.isFinite(args.timeout_ms) ? args.timeout_ms : LIMITS.CODEX_TIMEOUT_MS;
1315
1451
  const timeoutMs = timing.council_mode === "quick"
1316
1452
  ? Math.min(requestedTimeoutMs, LIMITS.QUICK_EVIDENCE_MS)
1317
- : Math.min(requestedTimeoutMs, LIMITS.FULL_EVIDENCE_MS);
1453
+ : Math.min(requestedTimeoutMs, councilPaceProfile(timing.council_pace).evidence_ms);
1318
1454
  const defaultConcurrency = timing.council_mode === "quick"
1319
1455
  ? QUICK_TASKS.length
1320
1456
  : Math.min(tasks.length, LIMITS.FULL_EVIDENCE_CONCURRENCY);
@@ -1334,7 +1470,7 @@ export async function collectEvidence(args) {
1334
1470
  dryRun,
1335
1471
  timeoutMs: remainingCouncilBudget(
1336
1472
  timing,
1337
- timing.council_mode === "quick" ? LIMITS.QUICK_GROUNDING_MS : LIMITS.FULL_GROUNDING_MS,
1473
+ timing.council_mode === "quick" ? LIMITS.QUICK_GROUNDING_MS : councilPaceProfile(timing.council_pace).grounding_ms,
1338
1474
  ),
1339
1475
  });
1340
1476
  const dir = runPath(id);
@@ -1436,7 +1572,7 @@ export async function collectEvidence(args) {
1436
1572
  : "QUICK COUNCIL PRIORITY: return only the 4-6 highest-information claims needed for a directional read. Keep the packet concise, source every claim, and make unknowns explicit."
1437
1573
  : "";
1438
1574
  const workerObjective = [args.prompt || "", quickPriority].filter(Boolean).join("\n\n");
1439
- const prompt = taskPrompt(task, symbol, asOfDate, workerObjective, language, run.grounding);
1575
+ const prompt = taskPrompt(task, symbol, asOfDate, workerObjective, language, run.grounding, run.council_pace);
1440
1576
  updateTask(run, task, "running", { started_at: new Date().toISOString() });
1441
1577
  if (dryRun) {
1442
1578
  const packet = dryPacket(task, symbol, asOfDate, prompt, language);
@@ -1637,7 +1773,7 @@ export async function runHeadlessMasters(run, args = {}) {
1637
1773
  const requestedTimeoutMs = Number.isFinite(args.timeout_ms) ? args.timeout_ms : LIMITS.CODEX_TIMEOUT_MS;
1638
1774
  const timeoutMs = run.council_mode === "quick"
1639
1775
  ? Math.min(requestedTimeoutMs, LIMITS.QUICK_MASTER_MS)
1640
- : Math.min(requestedTimeoutMs, LIMITS.FULL_MASTER_MS);
1776
+ : Math.min(requestedTimeoutMs, runPace(run).master_ms);
1641
1777
  const defaultConcurrency = run.council_mode === "quick"
1642
1778
  ? 4
1643
1779
  : Math.min(selected.length, LIMITS.FULL_MASTER_CONCURRENCY);
@@ -1684,12 +1820,39 @@ export async function runHeadlessMasters(run, args = {}) {
1684
1820
  })),
1685
1821
  ...plan.to_run,
1686
1822
  ];
1823
+ // A frozen abstention has no reading for a worker to explain, and its deterministic statement
1824
+ // already carries what the contract asks of an out_of_scope seat. Publish it directly instead
1825
+ // of spending one model turn per seat to restate it.
1826
+ const abstainedWithoutWorker = [];
1827
+ const votingWorkerItems = workerItems.filter((item) => {
1828
+ if (!item.frozenOpinion || needsMethodVoiceWorker(item.frozenOpinion)) return true;
1829
+ abstainedWithoutWorker.push(item);
1830
+ return false;
1831
+ });
1687
1832
  for (const item of workerItems) {
1688
1833
  if (!item.frozenOpinion) continue;
1689
1834
  writeJson(join(dir, `${item.id}.deterministic.json`), item.frozenOpinion);
1690
1835
  }
1691
1836
 
1692
- const outcomes = await mapLimit(workerItems, maxConcurrency, async ({ id, decision, engine, frozenOpinion, deterministic_decline, deterministic_execution }) => {
1837
+ const abstainedOutcomes = abstainedWithoutWorker.map(({ id, engine, frozenOpinion }) => ({
1838
+ id,
1839
+ engine,
1840
+ opinion: {
1841
+ ...frozenOpinion,
1842
+ deterministic_summary: frozenOpinion.summary,
1843
+ voice_statement: frozenOpinion.voice_statement || frozenOpinion.summary,
1844
+ voice_status: "deterministic_scope",
1845
+ voice_language: run.language,
1846
+ statement_origin: "deterministic_scope_fallback",
1847
+ dedicated_worker: {
1848
+ status: "not_required_frozen_abstention",
1849
+ language: run.language,
1850
+ execution_mode: "deterministic_only",
1851
+ },
1852
+ },
1853
+ }));
1854
+
1855
+ const outcomes = await mapLimit(votingWorkerItems, maxConcurrency, async ({ id, decision, engine, frozenOpinion, deterministic_decline, deterministic_execution }) => {
1693
1856
  const prompt = frozenOpinion
1694
1857
  ? masterVoicePrompt(id, run, frozenOpinion)
1695
1858
  : [
@@ -1827,7 +1990,7 @@ export async function runHeadlessMasters(run, args = {}) {
1827
1990
  raw: cleanLog(error?.message || error),
1828
1991
  }));
1829
1992
 
1830
- for (const outcome of outcomes) {
1993
+ for (const outcome of [...abstainedOutcomes, ...outcomes]) {
1831
1994
  if (!outcome.opinion) {
1832
1995
  const diagnosticPath = join(dir, `${outcome.id}.failure.json`);
1833
1996
  writeJson(diagnosticPath, {
@@ -2153,7 +2316,7 @@ export async function synthesizeDecision(run, args) {
2153
2316
  : Number(args.timeout_ms || LIMITS.CODEX_TIMEOUT_MS);
2154
2317
  const timeoutMs = run.council_mode === "quick"
2155
2318
  ? Math.min(requestedSynthesisMs, LIMITS.QUICK_SYNTHESIS_MS)
2156
- : Math.min(requestedSynthesisMs, LIMITS.FULL_DEBATE_MS);
2319
+ : Math.min(requestedSynthesisMs, runPace(run).debate_ms);
2157
2320
  const outputMode = OUTPUT_MODES.includes(args.output_mode) ? args.output_mode : "public_equity";
2158
2321
  run.phase = "debate";
2159
2322
  run.status = "running";
@@ -2319,7 +2482,7 @@ export async function synthesizeDecision(run, args) {
2319
2482
  });
2320
2483
  writeAllAgentsMarkdown(run, { bull, bear });
2321
2484
 
2322
- const pmBudget = remainingCouncilBudget(run, Math.min(requestedSynthesisMs, LIMITS.FULL_PM_MS));
2485
+ const pmBudget = remainingCouncilBudget(run, Math.min(requestedSynthesisMs, runPace(run).pm_ms));
2323
2486
  const managerStep = await runDebateRole(run, "portfolio_manager", { bull, bear, outputMode }, pmBudget);
2324
2487
  const managerOk = !debateFailure(managerStep);
2325
2488
  const manager = managerOk ? managerStep.packet : managerFallback(run, args.prompt || "");
@@ -10,7 +10,7 @@
10
10
  import { compiledPersonaPacks } from "../personas-v3/registry.mjs";
11
11
  import { buildFactPack } from "../personas-v3/typed-facts.mjs";
12
12
  import { typedFactPackFromGrounding } from "../personas-v3/grounding-adapter.mjs";
13
- import { buildAnonymousPreDecision, freezeAnonymousDecision } from "../personas-v3/runtime.mjs";
13
+ import { buildAnonymousPreDecision, freezeAnonymousDecision, technicalIdReadableMap } from "../personas-v3/runtime.mjs";
14
14
  import { executeDeterministicPersonaPolicy } from "../personas-v3/deterministic-executor.mjs";
15
15
  import { languageKey, localized } from "../lang.mjs";
16
16
  import { displayMasterLabel } from "../markdown.mjs";
@@ -216,6 +216,25 @@ function uniqueStrings(values) {
216
216
  return [...new Set(values.filter((value) => typeof value === "string" && value.length))];
217
217
  }
218
218
 
219
+ /**
220
+ * Whether a frozen seat still needs a model worker to become readable.
221
+ *
222
+ * A seat that reached a stance has a reading worth explaining: which condition decided it, at
223
+ * what number, and what would move it. A seat whose gate never opened has no reading. Its
224
+ * deterministic statement already names the condition that closed the gate and says an
225
+ * abstention is not a bearish vote, which is the whole of what an out_of_scope seat is asked
226
+ * to say -- so an isolated worker there buys prose, not information. The declined path reached
227
+ * this conclusion earlier for the same reason; this applies it to a seat that executed its
228
+ * policy and abstained. On a real seven-seat run four such seats took most of the method phase
229
+ * to report, at length, that they had no opinion.
230
+ *
231
+ * Set ALPHACOUNCIL_VOICE_ABSTAINING_SEATS=1 to give every seat a worker again.
232
+ */
233
+ export function needsMethodVoiceWorker(opinion, { env = process.env } = {}) {
234
+ if (env?.ALPHACOUNCIL_VOICE_ABSTAINING_SEATS === "1") return true;
235
+ return (opinion?.stance || "out_of_scope") !== "out_of_scope";
236
+ }
237
+
219
238
  /** Render a post-freeze, model-free opinion from the DSL result. */
220
239
  export function completedMasterOpinion(run, item) {
221
240
  if (item?.engine !== "v3_method_runtime" || !item?.completed) {
@@ -230,15 +249,21 @@ export function completedMasterOpinion(run, item) {
230
249
  const label = displayMasterLabel(item.pack.admitted_label?.[locale]
231
250
  || item.pack.admitted_label?.en || item.id);
232
251
  const copy = localized(run.language, {
233
- en: { withheld: (pct) => `${pct}% coverage; score withheld`, unscored: "not scored", verdict: (stance, reason) => `${label} frozen decision: ${stance} (${reason})`, summary: (score) => `Using the ${label} method on ${run.symbol}, the PersonaPack v3 deterministic policy executed with score ${score}. Typed facts, hard vetoes, and score bands produced this stance; no language model selected it.`, hit: (id) => `score hit: ${id}`, miss: (id) => `score miss: ${id}`, eligibility: (id) => `eligibility condition ${id} becomes satisfied`, veto: (id) => `hard veto ${id} no longer triggers`, score: (id) => `score condition ${id} becomes satisfied` },
234
- zh: { withheld: (pct) => `覆盖率 ${pct}%,分数被保留不发布`, unscored: "未评分", verdict: (stance, reason) => `${label}的冻结结论:${stance}(${reason})`, summary: (score) => `按${label}方法审视 ${run.symbol}:PersonaPack v3 确定性政策已执行,得分 ${score}。该立场由结构化事实、硬否决和评分带产生,没有让语言模型选择立场。`, hit: (id) => `评分命中:${id}`, miss: (id) => `评分未命中:${id}`, eligibility: (id) => `资格条件 ${id} 变为满足`, veto: (id) => `硬否决 ${id} 不再触发`, score: (id) => `评分条件 ${id} 变为满足` },
235
- ja: { withheld: (pct) => `カバレッジ ${pct}% のためスコアは非公開`, unscored: "未採点", verdict: (stance, reason) => `${label}の凍結済み判断:${stance}(${reason})`, summary: (score) => `${label}の方法で ${run.symbol} を評価し、PersonaPack v3 の決定論的ポリシーを実行した結果、スコアは ${score}。構造化事実、ハード拒否条件、スコア帯がこの立場を生成しており、言語モデルは立場を選択していない。`, hit: (id) => `採点条件を満たす:${id}`, miss: (id) => `採点条件を満たさない:${id}`, eligibility: (id) => `適格条件 ${id} が満たされる`, veto: (id) => `ハード拒否条件 ${id} が解除される`, score: (id) => `採点条件 ${id} が満たされる` },
236
- ko: { withheld: (pct) => `커버리지 ${pct}%로 점수를 공개하지 않음`, unscored: "미채점", verdict: (stance, reason) => `${label}의 동결된 판단: ${stance}(${reason})`, summary: (score) => `${label} 방법으로 ${run.symbol}을 검토하여 PersonaPack v3 결정론적 정책을 실행했으며 점수는 ${score}입니다. 구조화된 사실, 하드 거부 조건, 점수 구간이 이 입장을 만들었고 언어 모델은 입장을 선택하지 않았습니다.`, hit: (id) => `점수 조건 충족: ${id}`, miss: (id) => `점수 조건 미충족: ${id}`, eligibility: (id) => `적격 조건 ${id} 충족`, veto: (id) => `하드 거부 조건 ${id} 해제`, score: (id) => `점수 조건 ${id} 충족` },
252
+ en: { abstain: (reasons) => `Using the ${label} method, I do not issue a directional view on ${run.symbol} in this run: the method gate did not open (${reasons}). This is neither bearish nor a vote against the asset; the seat reassesses only once that gate can be satisfied.`, withheld: (pct) => `${pct}% coverage; score withheld`, unscored: "not scored", verdict: (stance, reason) => `${label} frozen decision: ${stance} (${reason})`, summary: (score) => `Using the ${label} method on ${run.symbol}, the PersonaPack v3 deterministic policy executed with score ${score}. Typed facts, hard vetoes, and score bands produced this stance; no language model selected it.`, hit: (id) => `score hit: ${id}`, miss: (id) => `score miss: ${id}`, eligibility: (id) => `eligibility condition ${id} becomes satisfied`, veto: (id) => `hard veto ${id} no longer triggers`, score: (id) => `score condition ${id} becomes satisfied` },
253
+ zh: { abstain: (reasons) => `按${label}方法审视 ${run.symbol},本轮不作方向判断:方法闸门未打开(${reasons})。这不是看空,也不是一张反对票;只有该闸门可被满足后,该席位才会重新评估。`, withheld: (pct) => `覆盖率 ${pct}%,分数被保留不发布`, unscored: "未评分", verdict: (stance, reason) => `${label}的冻结结论:${stance}(${reason})`, summary: (score) => `按${label}方法审视 ${run.symbol}:PersonaPack v3 确定性政策已执行,得分 ${score}。该立场由结构化事实、硬否决和评分带产生,没有让语言模型选择立场。`, hit: (id) => `评分命中:${id}`, miss: (id) => `评分未命中:${id}`, eligibility: (id) => `资格条件 ${id} 变为满足`, veto: (id) => `硬否决 ${id} 不再触发`, score: (id) => `评分条件 ${id} 变为满足` },
254
+ ja: { abstain: (reasons) => `${label}の方法では、メソッドのゲートが開かなかったため(${reasons})、今回は ${run.symbol} の方向判断を出しません。弱気判断や反対票ではなく、そのゲートが満たせるようになった時点で再評価します。`, withheld: (pct) => `カバレッジ ${pct}% のためスコアは非公開`, unscored: "未採点", verdict: (stance, reason) => `${label}の凍結済み判断:${stance}(${reason})`, summary: (score) => `${label}の方法で ${run.symbol} を評価し、PersonaPack v3 の決定論的ポリシーを実行した結果、スコアは ${score}。構造化事実、ハード拒否条件、スコア帯がこの立場を生成しており、言語モデルは立場を選択していない。`, hit: (id) => `採点条件を満たす:${id}`, miss: (id) => `採点条件を満たさない:${id}`, eligibility: (id) => `適格条件 ${id} が満たされる`, veto: (id) => `ハード拒否条件 ${id} が解除される`, score: (id) => `採点条件 ${id} が満たされる` },
255
+ ko: { abstain: (reasons) => `${label} 방법으로는 방법론 게이트가 열리지 않아(${reasons}) 이번 실행에서 ${run.symbol}의 방향 판단을 내리지 않습니다. 이는 약세 판단이나 반대표가 아니며, 해당 게이트가 충족될 수 있을 때에만 재평가합니다.`, withheld: (pct) => `커버리지 ${pct}%로 점수를 공개하지 않음`, unscored: "미채점", verdict: (stance, reason) => `${label}의 동결된 판단: ${stance}(${reason})`, summary: (score) => `${label} 방법으로 ${run.symbol}을 검토하여 PersonaPack v3 결정론적 정책을 실행했으며 점수는 ${score}입니다. 구조화된 사실, 하드 거부 조건, 점수 구간이 이 입장을 만들었고 언어 모델은 입장을 선택하지 않았습니다.`, hit: (id) => `점수 조건 충족: ${id}`, miss: (id) => `점수 조건 미충족: ${id}`, eligibility: (id) => `적격 조건 ${id} 충족`, veto: (id) => `하드 거부 조건 ${id} 해제`, score: (id) => `점수 조건 ${id} 충족` },
237
256
  });
238
- const vetoIds = (result.vetoes_triggered || []).map((veto) => veto.veto_id);
239
- const unmet = result.eligibility?.unmet_condition_ids || [];
240
- const hitIds = (result.score?.hits || []).map((rule) => rule.rule_id);
241
- const missIds = (result.score?.misses || []).map((rule) => rule.rule_id);
257
+ // Every condition id in a frozen decision was hashed before the policy ran so the decision
258
+ // layer could not recognise the seat. Past this point the seat is named in the report and in
259
+ // the explanation worker's own prompt, so the hash only stops the seat from telling a reader
260
+ // which condition decided it. Resolve each id back to the one the pack declares.
261
+ const readableIds = technicalIdReadableMap(item.pack);
262
+ const readable = (id) => readableIds.get(id) || id;
263
+ const vetoIds = (result.vetoes_triggered || []).map((veto) => readable(veto.veto_id));
264
+ const unmet = (result.eligibility?.unmet_condition_ids || []).map(readable);
265
+ const hitIds = (result.score?.hits || []).map((rule) => readable(rule.rule_id));
266
+ const missIds = (result.score?.misses || []).map((rule) => readable(rule.rule_id));
242
267
  const sourceIds = uniqueStrings([
243
268
  ...(result.eligibility?.checks || []).flatMap((check) => check.source_ids || []),
244
269
  ...(result.vetoes_triggered || []).flatMap((veto) => veto.source_ids || []),
@@ -250,14 +275,29 @@ export function completedMasterOpinion(run, item) {
250
275
  : result.score
251
276
  ? `${result.score.score}/${result.score.max_possible} (${Math.round((result.ratio || 0) * 100)}%)`
252
277
  : copy.unscored;
253
- const deterministicStatement = copy.summary(scoreText);
278
+ // An eligibility gate that closes produces a frozen out_of_scope with no score band, and the
279
+ // generic execution sentence says nothing a reader can use: not which condition closed the
280
+ // gate, and not that an abstention is not a bearish vote. The decline path has said both for
281
+ // a while; say the same here so no seat has to spend a model worker to state it.
282
+ const abstained = result.stance === "out_of_scope";
283
+ const abstainReasons = uniqueStrings([
284
+ ...(result.eligibility?.unmet_condition_ids || []).map(readable),
285
+ ...(result.eligibility?.uncomputable_condition_ids || []).map(readable),
286
+ ...(result.eligibility?.missing_required_fact_types || []),
287
+ ]);
288
+ // `summary` stays the provenance sentence -- it is what records that no language model chose
289
+ // the stance. Only the reader-facing statement changes, exactly as the decline path splits it.
290
+ const provenanceSummary = copy.summary(scoreText);
291
+ const deterministicStatement = abstained
292
+ ? copy.abstain(abstainReasons.join("; ") || result.reason || copy.unscored)
293
+ : provenanceSummary;
254
294
  return {
255
295
  master: item.id,
256
296
  symbol: run.symbol,
257
297
  as_of: run.as_of,
258
298
  stance: result.stance,
259
299
  verdict: copy.verdict(result.stance, result.reason),
260
- summary: deterministicStatement,
300
+ summary: provenanceSummary,
261
301
  voice_statement: deterministicStatement,
262
302
  // The five fields, composed from the frozen decision rather than written about it: which
263
303
  // facts were read and their values, what the tools produced, which scoring conditions held
@@ -267,6 +307,7 @@ export function completedMasterOpinion(run, item) {
267
307
  result,
268
308
  policy: item.pack.components?.decision_policy,
269
309
  factPack: item.preDecision?.fact_pack,
310
+ readableIds,
270
311
  language: run.language,
271
312
  }),
272
313
  voice_status: "deterministic_fallback",
@@ -291,7 +332,11 @@ export function completedMasterOpinion(run, item) {
291
332
  decision_reason: result.reason,
292
333
  native_decision: result.native_decision,
293
334
  native_state: result.native_decision.state,
294
- common_projection: result.common_projection,
335
+ // The frozen decision keeps its anonymised ids -- they are inside the hash. This is the
336
+ // reader-facing copy, so resolve the veto ids here and leave the frozen artifact alone.
337
+ common_projection: result.common_projection
338
+ ? { ...result.common_projection, veto_ids: (result.common_projection.veto_ids || []).map(readable) }
339
+ : result.common_projection,
295
340
  pack_hash: item.pack.pack_hash,
296
341
  corpus_hash: item.pack.corpus_hash,
297
342
  policy_hash: item.pack.policy_hash,
@@ -153,6 +153,34 @@ function technicalIdentityAliases(value, compiledPack) {
153
153
  return aliases;
154
154
  }
155
155
 
156
+ /**
157
+ * Reverse the pre-freeze aliasing for anything downstream of the freeze.
158
+ *
159
+ * The aliasing exists so the decision layer cannot recognise the seat it is deciding for. A
160
+ * reader is not the decision layer, and neither is the post-freeze explanation worker: its
161
+ * prompt opens by naming the persona, so an `anon_<hash>` there hides nothing and costs a
162
+ * great deal. It cost exactly this: two seats whose hard veto had been hashed guessed in
163
+ * their own recorded statements which condition had vetoed them, and both guessed wrong.
164
+ *
165
+ * Keyed by alias so a caller can resolve the ids that appear in a frozen decision. Only the
166
+ * seat's own declared condition ids are mapped; nothing else crosses back.
167
+ */
168
+ export function technicalIdReadableMap(compiledPack) {
169
+ const policy = compiledPack?.components?.decision_policy || {};
170
+ const declared = [
171
+ ...(policy.scoring?.rules || []).map((rule) => rule?.rule_id),
172
+ ...(policy.hard_vetoes || []).map((veto) => veto?.veto_id),
173
+ ...(policy.eligibility?.all || [])
174
+ .map((check) => check?.condition_id || check?.check_id || check?.id),
175
+ // The computed values a seat cites are named by the tool that produced them, so a hashed
176
+ // output id turns "it computed credit_spread_gap = -0.038" into "it computed
177
+ // anon_69f0... = -0.038" -- a number with no stated meaning.
178
+ ...(compiledPack?.components?.tools || []).flatMap((tool) => [tool?.output_id, tool?.id]),
179
+ ].filter((id) => nonEmptyString(id));
180
+ const aliases = technicalIdentityAliases(declared, compiledPack);
181
+ return new Map([...aliases].map(([physical, alias]) => [alias, physical]));
182
+ }
183
+
156
184
  function applyTechnicalAliases(value, aliases) {
157
185
  if (typeof value === "string") return aliases.get(value) || value;
158
186
  if (Array.isArray(value)) return value.map((child) => applyTechnicalAliases(child, aliases));