alphacouncil-agent 1.0.4 → 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 (79) 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 +282 -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-selection.mjs +79 -1
  60. package/mcp/lib/gates.mjs +42 -0
  61. package/mcp/lib/grounding.mjs +27 -1
  62. package/mcp/lib/industry.mjs +9 -1
  63. package/mcp/lib/markdown.mjs +25 -1
  64. package/mcp/lib/orchestrator.mjs +187 -24
  65. package/mcp/lib/personas/engine.mjs +57 -12
  66. package/mcp/lib/personas-v3/runtime.mjs +28 -0
  67. package/mcp/lib/prompts.mjs +72 -3
  68. package/mcp/lib/rpc.mjs +93 -7
  69. package/mcp/lib/run-store.mjs +3 -0
  70. package/mcp/lib/screen.mjs +46 -46
  71. package/mcp/lib/sec.mjs +25 -10
  72. package/mcp/lib/voice-from-decision.mjs +13 -5
  73. package/mcp/lib/weights.mjs +9 -2
  74. package/mcp/lib/xbrl-series.mjs +21 -2
  75. package/package.json +2 -1
  76. package/personas/analysts/insider_sec.md +38 -0
  77. package/scripts/lib/persona-v3-solo-test-packs.mjs +14 -8
  78. package/scripts/review-persona-v3-solo-formulas.mjs +5 -3
  79. package/skills/alphacouncil-agent/SKILL.md +80 -19
@@ -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));
@@ -91,7 +91,67 @@ function fundOrIndexTaskInstruction(task, instrument, language) {
91
91
  });
92
92
  }
93
93
 
94
- export function taskPrompt(task, symbol, asOfDate, userPrompt = "", language = "auto", grounding = null) {
94
+ /**
95
+ * Output shaping for a depth tier.
96
+ *
97
+ * A tier is a timeout, and a timeout is not a plan. Sending the identical prompt with a shorter
98
+ * fuse does not buy a faster good packet -- it buys a packet the worker could not finish, which
99
+ * arrives as `degraded` or not at all. For an LLM call the wall clock is dominated by the tokens
100
+ * it generates, so the way to run faster without losing information is to ask for the same
101
+ * information in less prose.
102
+ *
103
+ * The line every tier holds: claims, numbers, scoped source IDs, required report sections and
104
+ * the decision itself are never what gets cut. What `fast` removes is restatement -- re-quoting
105
+ * evidence the packet can cite by ID, recapping an opponent before answering, methodology
106
+ * preambles. What `slow` buys back is room to write a derivation out in full.
107
+ */
108
+ export function paceShapingInstruction(pace, role, chinese) {
109
+ const tier = String(pace || "normal").toLowerCase();
110
+ if (tier !== "fast" && tier !== "slow") return "";
111
+ const isReport = role === "portfolio_manager";
112
+ if (tier === "fast") {
113
+ if (chinese) {
114
+ return [
115
+ "本轮按 fast 档运行。要压缩的是叙述,不是内容:",
116
+ "- 每一条仍必须带具体数字和作用域来源 ID(`<task>:S<n>`)。少写一条论点比少写一个来源 ID 更可接受,凭记忆补数字则永远不可接受。",
117
+ "- 不要复述证据原文——引用来源 ID 即可。不要在回答前复述对手立场。不要写方法论开场或结尾总结。",
118
+ "- 每条论点压到一到两句,只保留「主张 + 数字 + 来源 + 它错在哪」。",
119
+ isReport
120
+ ? "- 报告的每一个必需章节仍必须齐备且非空:简洁只能来自散文,不能来自删章节。价位阶梯与失效条件不得压缩成一句话,它们是这份报告唯一可执行的部分。"
121
+ : "- 论点条数上限 6 条,取信息量最高的 6 条,其余舍弃而不是缩写成半句。",
122
+ "- 如果时间不足以完成,宁可交一份明确标注缺口的短包,也不要交一份看起来完整但数字来自记忆的包。",
123
+ ].join("\n");
124
+ }
125
+ return [
126
+ "This round runs at the fast tier. Compress the prose, not the content:",
127
+ "- Every item still carries its figure and its scoped source ID (`<task>:S<n>`). Dropping one argument is acceptable; dropping a source ID is not, and filling a number from memory never is.",
128
+ "- Do not re-quote evidence -- cite the source ID. Do not recap the opponent before answering. No methodology preamble and no closing summary.",
129
+ "- One or two sentences per item: claim, figure, source, and what would break it.",
130
+ isReport
131
+ ? "- Every required report section must still be present and non-empty: terseness comes out of prose, never out of sections. Price levels and invalidation conditions may not be compressed to one line -- they are the only actionable part of the report."
132
+ : "- At most 6 arguments. Keep the six highest-information ones and drop the rest rather than shortening every one into a fragment.",
133
+ "- If there is not enough time to finish, hand in a short packet that names the gap rather than a complete-looking one built from memory.",
134
+ ].join("\n");
135
+ }
136
+ if (chinese) {
137
+ return [
138
+ "本轮按 slow 档运行。这一档买到的是把推导写完整的空间:",
139
+ "- 多步推算要逐步写出,把每一步的口径、假设与它对结论的敏感度都写明,而不是只给结果。",
140
+ "- 逐条处置对手的论点:哪一条你接受、哪一条你反驳、反驳依据的是哪个来源 ID 与哪个数字。",
141
+ "- 明确写出你自己的证伪条件:什么读数会让你放弃这条论点。",
142
+ "- 更长不等于更好:每一段要么带来新的数字,要么带来新的反驳。重复已说过的内容仍然要删。",
143
+ ].join("\n");
144
+ }
145
+ return [
146
+ "This round runs at the slow tier. What that budget buys is room to write the derivation out:",
147
+ "- Show multi-step arithmetic step by step, with the basis, the assumption and the sensitivity of the conclusion to each step, not just the result.",
148
+ "- Handle the opponent's arguments one by one: which you accept, which you refute, and on which source ID and figure the refutation rests.",
149
+ "- State your own falsification conditions explicitly: which reading would make you drop the argument.",
150
+ "- Longer is not better. Every paragraph must add either a new figure or a new refutation; repetition of what you already said still gets cut.",
151
+ ].join("\n");
152
+ }
153
+
154
+ export function taskPrompt(task, symbol, asOfDate, userPrompt = "", language = "auto", grounding = null, pace = null) {
95
155
  const resolvedLanguage = resolveLanguage({ language, prompt: userPrompt });
96
156
  const chinese = isChineseLanguage(resolvedLanguage);
97
157
  const reg = registry();
@@ -112,7 +172,13 @@ export function taskPrompt(task, symbol, asOfDate, userPrompt = "", language = "
112
172
  // which facts are already settled, or it reads them as the whole assignment.
113
173
  const instrumentOverride = fundOrIndexTaskInstruction(task, grounding?.instrument, resolvedLanguage);
114
174
  const grounded = groundingBlock(grounding, resolvedLanguage);
115
- return [`${base}\n\n${chinese ? "任务:" : "Task: "}${task}\n${body}`, instrumentOverride, grounded].filter(Boolean).join("\n\n");
175
+ return [
176
+ `${base}\n\n${chinese ? "任务:" : "Task: "}${task}\n${body}`,
177
+ instrumentOverride,
178
+ grounded,
179
+ // Last, so it is the final word on form after the role brief and the settled facts.
180
+ paceShapingInstruction(pace, task, chinese),
181
+ ].filter(Boolean).join("\n\n");
116
182
  }
117
183
 
118
184
  export function debatePrompt(role, run, context = {}) {
@@ -190,6 +256,8 @@ export function debatePrompt(role, run, context = {}) {
190
256
  ].filter(Boolean).join("\n\n")
191
257
  : "",
192
258
  role === "portfolio_manager" && !quick ? outputModeInstruction(context.outputMode || "chat", language) : "",
259
+ // The tier's shaping is the final word on form. Quick has its own shaping already.
260
+ quick ? "" : paceShapingInstruction(run.council_pace, role, chinese),
193
261
  `Evidence JSON: ${evidenceJson}`,
194
262
  ].filter(Boolean).join("\n\n");
195
263
  }
@@ -269,8 +337,9 @@ export function masterVoicePrompt(masterId, run, frozenOpinion) {
269
337
  ].join(" "),
270
338
  `\`position_intent\` MUST be one of: ${intentsForStance(frozenOpinion?.stance).join(" | ")}. Those are the only intents the frozen stance admits; anything else is rejected without changing run state.`,
271
339
  "Return ONLY one valid JSON object, no Markdown fence. Schema: {\"master\":\"stable id\",\"acknowledged_stance\":\"constructive|cautious|opposed|out_of_scope\",\"position_intent\":\"one of the allowed intents above\",\"voice\":{\"what_i_see\":\"string\",\"how_my_method_reads_it\":\"string\",\"would_i_act\":\"string\",\"what_changes_my_mind\":\"string\",\"where_i_disagree\":\"string\"},\"key_findings\":[\"string\"],\"disagreements\":[\"string\"],\"what_would_change_my_mind\":[\"string\"],\"source_ids\":[\"task:S1\"],\"confidence\":\"high|medium|low\"}.",
340
+ paceShapingInstruction(run.council_pace, masterId, isChineseLanguage(language)),
272
341
  `Bounded shared evidence JSON: ${evidence}`,
273
- ].join("\n\n");
342
+ ].filter(Boolean).join("\n\n");
274
343
  }
275
344
 
276
345
  /** The master ids a run has selected, from an explicit list or a roster name. */