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
@@ -80,10 +80,10 @@
80
80
  "identity": {
81
81
  "maturity": "operator_lens",
82
82
  "operator_label": {
83
- "en": "Simons Lens Provisional Operator Lens",
84
- "ja": "Simons Lens 暫定オペレーター・レンズ",
85
- "ko": "Simons Lens 임시 오퍼레이터 렌즈",
86
- "zh": "西蒙斯视角临时操作视角"
83
+ "en": "Simons Lens",
84
+ "ja": "Simons Lens・メソッドレンズ",
85
+ "ko": "Simons Lens 방법론 렌즈",
86
+ "zh": "西蒙斯视角"
87
87
  },
88
88
  "persona_id": "master_simons",
89
89
  "public_label": {
@@ -123,10 +123,10 @@
123
123
  "zh": "系统化信号、统计套利、组合研究与执行成本。"
124
124
  },
125
125
  "identity": {
126
- "en": "Simons Lens; project-derived and not human reviewed",
127
- "ja": "Simons Lens。プロジェクト派生で、方法帰属について人による審査を受けていない暫定オペレーター・レンズ。本人の発言や現在の見解ではない。",
128
- "ko": "Simons Lens. 프로젝트에서 파생되었고 방법 귀속에 대한 인적 심사를 거치지 않은 임시 오퍼레이터 렌즈다. 본인의 발언이나 현재 견해가 아니다.",
129
- "zh": "西蒙斯视角;项目派生、未经人工审定"
126
+ "en": "Simons Lens; a method model, not the person's own words",
127
+ "ja": "Simons Lens。メソッドモデルであり、本人の発言や現在の見解ではない。",
128
+ "ko": "Simons Lens. 방법론 모델이며, 본인의 발언이나 현재 견해가 아니다.",
129
+ "zh": "西蒙斯视角;方法模型,非本人言论"
130
130
  },
131
131
  "method": {
132
132
  "en": "Admit only statistically testable signals that survive leakage controls, multiple testing, out-of-sample evaluation, turnover and trading costs.",
@@ -78,10 +78,10 @@
78
78
  "identity": {
79
79
  "maturity": "operator_lens",
80
80
  "operator_label": {
81
- "en": "Sinclair Lens (Volatility Trading and Execution) Provisional Operator Lens",
82
- "ja": "Sinclair Lens (Volatility Trading and Execution) 暫定オペレーター・レンズ",
83
- "ko": "Sinclair Lens (Volatility Trading and Execution) 임시 오퍼레이터 렌즈",
84
- "zh": "辛克莱视角(波动率交易与执行)临时操作视角"
81
+ "en": "Sinclair Lens (Volatility Trading and Execution)",
82
+ "ja": "Sinclair Lens (Volatility Trading and Execution)・メソッドレンズ",
83
+ "ko": "Sinclair Lens (Volatility Trading and Execution) 방법론 렌즈",
84
+ "zh": "辛克莱视角(波动率交易与执行)"
85
85
  },
86
86
  "persona_id": "master_sinclair",
87
87
  "public_label": {
@@ -121,10 +121,10 @@
121
121
  "zh": "波动率预测、期权执行、优势度量与仓位管理。"
122
122
  },
123
123
  "identity": {
124
- "en": "Sinclair Lens (Volatility Trading and Execution); project-derived and not human reviewed",
125
- "ja": "Sinclair Lens (Volatility Trading and Execution)。プロジェクト派生で、方法帰属について人による審査を受けていない暫定オペレーター・レンズ。本人の発言や現在の見解ではない。",
126
- "ko": "Sinclair Lens (Volatility Trading and Execution). 프로젝트에서 파생되었고 방법 귀속에 대한 인적 심사를 거치지 않은 임시 오퍼레이터 렌즈다. 본인의 발언이나 현재 견해가 아니다.",
127
- "zh": "辛克莱视角(波动率交易与执行);项目派生、未经人工审定"
124
+ "en": "Sinclair Lens (Volatility Trading and Execution); a method model, not the person's own words",
125
+ "ja": "Sinclair Lens (Volatility Trading and Execution)。メソッドモデルであり、本人の発言や現在の見解ではない。",
126
+ "ko": "Sinclair Lens (Volatility Trading and Execution). 방법론 모델이며, 본인의 발언이나 현재 견해가 아니다.",
127
+ "zh": "辛克莱视角(波动率交易与执行);方法模型,非本人言论"
128
128
  },
129
129
  "method": {
130
130
  "en": "Forecast realized volatility, compare it with implied volatility, subtract spreads and slippage, and size only a robust executable edge.",
@@ -79,10 +79,10 @@
79
79
  "identity": {
80
80
  "maturity": "operator_lens",
81
81
  "operator_label": {
82
- "en": "Soros Lens Provisional Operator Lens",
83
- "ja": "Soros Lens 暫定オペレーター・レンズ",
84
- "ko": "Soros Lens 임시 오퍼레이터 렌즈",
85
- "zh": "索罗斯视角临时操作视角"
82
+ "en": "Soros Lens",
83
+ "ja": "Soros Lens・メソッドレンズ",
84
+ "ko": "Soros Lens 방법론 렌즈",
85
+ "zh": "索罗斯视角"
86
86
  },
87
87
  "persona_id": "master_soros",
88
88
  "public_label": {
@@ -122,10 +122,10 @@
122
122
  "zh": "全球宏观、反身性、繁荣萧条循环与拥挤交易。"
123
123
  },
124
124
  "identity": {
125
- "en": "Soros Lens; project-derived and not human reviewed",
126
- "ja": "Soros Lens。プロジェクト派生で、方法帰属について人による審査を受けていない暫定オペレーター・レンズ。本人の発言や現在の見解ではない。",
127
- "ko": "Soros Lens. 프로젝트에서 파생되었고 방법 귀속에 대한 인적 심사를 거치지 않은 임시 오퍼레이터 렌즈다. 본인의 발언이나 현재 견해가 아니다.",
128
- "zh": "索罗斯视角;项目派生、未经人工审定"
125
+ "en": "Soros Lens; a method model, not the person's own words",
126
+ "ja": "Soros Lens。メソッドモデルであり、本人の発言や現在の見解ではない。",
127
+ "ko": "Soros Lens. 방법론 모델이며, 본인의 발언이나 현재 견해가 아니다.",
128
+ "zh": "索罗斯视角;方法模型,非本人言论"
129
129
  },
130
130
  "method": {
131
131
  "en": "Map reflexive feedback among price, financing, fundamentals, policy and participant beliefs, then identify the observable break condition.",
@@ -82,10 +82,10 @@
82
82
  "identity": {
83
83
  "maturity": "operator_lens",
84
84
  "operator_label": {
85
- "en": "Taleb Lens (Convexity and Tails) Provisional Operator Lens",
86
- "ja": "Taleb Lens (Convexity and Tails) 暫定オペレーター・レンズ",
87
- "ko": "Taleb Lens (Convexity and Tails) 임시 오퍼레이터 렌즈",
88
- "zh": "塔勒布视角(凸性与尾部)临时操作视角"
85
+ "en": "Taleb Lens (Convexity and Tails)",
86
+ "ja": "Taleb Lens (Convexity and Tails)・メソッドレンズ",
87
+ "ko": "Taleb Lens (Convexity and Tails) 방법론 렌즈",
88
+ "zh": "塔勒布视角(凸性与尾部)"
89
89
  },
90
90
  "persona_id": "master_taleb",
91
91
  "public_label": {
@@ -125,10 +125,10 @@
125
125
  "zh": "尾部风险、凸性、期权与脆弱性分析。"
126
126
  },
127
127
  "identity": {
128
- "en": "Taleb Lens (Convexity and Tails); project-derived and not human reviewed",
129
- "ja": "Taleb Lens (Convexity and Tails)。プロジェクト派生で、方法帰属について人による審査を受けていない暫定オペレーター・レンズ。本人の発言や現在の見解ではない。",
130
- "ko": "Taleb Lens (Convexity and Tails). 프로젝트에서 파생되었고 방법 귀속에 대한 인적 심사를 거치지 않은 임시 오퍼레이터 렌즈다. 본인의 발언이나 현재 견해가 아니다.",
131
- "zh": "塔勒布视角(凸性与尾部);项目派生、未经人工审定"
128
+ "en": "Taleb Lens (Convexity and Tails); a method model, not the person's own words",
129
+ "ja": "Taleb Lens (Convexity and Tails)。メソッドモデルであり、本人の発言や現在の見解ではない。",
130
+ "ko": "Taleb Lens (Convexity and Tails). 방법론 모델이며, 본인의 발언이나 현재 견해가 아니다.",
131
+ "zh": "塔勒布视角(凸性与尾部);方法模型,非本人言论"
132
132
  },
133
133
  "method": {
134
134
  "en": "Reject ruin and hidden concavity first, then evaluate whether a payoff is robust or positively convex after liquidity, tail pricing and execution friction.",
@@ -82,10 +82,10 @@
82
82
  "identity": {
83
83
  "maturity": "operator_lens",
84
84
  "operator_label": {
85
- "en": "Thorp Lens Provisional Operator Lens",
86
- "ja": "Thorp Lens 暫定オペレーター・レンズ",
87
- "ko": "Thorp Lens 임시 오퍼레이터 렌즈",
88
- "zh": "索普视角临时操作视角"
85
+ "en": "Thorp Lens",
86
+ "ja": "Thorp Lens・メソッドレンズ",
87
+ "ko": "Thorp Lens 방법론 렌즈",
88
+ "zh": "索普视角"
89
89
  },
90
90
  "persona_id": "master_thorp",
91
91
  "public_label": {
@@ -125,10 +125,10 @@
125
125
  "zh": "概率优势、仓位管理、套利与破产风险。"
126
126
  },
127
127
  "identity": {
128
- "en": "Thorp Lens; project-derived and not human reviewed",
129
- "ja": "Thorp Lens。プロジェクト派生で、方法帰属について人による審査を受けていない暫定オペレーター・レンズ。本人の発言や現在の見解ではない。",
130
- "ko": "Thorp Lens. 프로젝트에서 파생되었고 방법 귀속에 대한 인적 심사를 거치지 않은 임시 오퍼레이터 렌즈다. 본인의 발언이나 현재 견해가 아니다.",
131
- "zh": "索普视角;项目派生、未经人工审定"
128
+ "en": "Thorp Lens; a method model, not the person's own words",
129
+ "ja": "Thorp Lens。メソッドモデルであり、本人の発言や現在の見解ではない。",
130
+ "ko": "Thorp Lens. 방법론 모델이며, 본인의 발언이나 현재 견해가 아니다.",
131
+ "zh": "索普视角;方法模型,非本人言论"
132
132
  },
133
133
  "method": {
134
134
  "en": "Independently estimate edge and odds, then constrain Kelly-style sizing by estimation error, dependence, liquidity and risk of ruin.",
@@ -174,12 +174,27 @@ export const LIMITS = Object.freeze({
174
174
  SIGKILL_GRACE_MS: 5000,
175
175
  /** Default per-subagent Codex timeout. */
176
176
  CODEX_TIMEOUT_MS: Number(process.env.ALPHACOUNCIL_AGENT_TIMEOUT_MS) || 600000,
177
- /** Non-overridable public ceiling for a plugin-managed full council. */
178
- FULL_HARD_MAX_MS: 30 * 60 * 1000,
179
- /** Default full-council queue-to-persistence budget. Operators may only lower it. */
177
+ /**
178
+ * Non-overridable public ceiling for a plugin-managed full council: the slowest pace's
179
+ * budget. The ceiling a given call is actually held to is its pace's `total_ms`, which is
180
+ * lower for `fast` and `normal`; this is only the outer bound of the schema.
181
+ */
182
+ FULL_HARD_MAX_MS: 60 * 60 * 1000,
183
+ /**
184
+ * An operator cap on the full-council budget, or null when unset.
185
+ *
186
+ * This used to double as the default budget with a hard 30-minute clamp, which silently held
187
+ * the 60-minute pace to 30. The default now comes from the selected pace; this only ever
188
+ * lowers it, which is what "operators may only lower it" was always meant to say.
189
+ */
190
+ FULL_TOTAL_OVERRIDE_MS: Number.isFinite(Number(process.env.ALPHACOUNCIL_FULL_TOTAL_MS))
191
+ && Number(process.env.ALPHACOUNCIL_FULL_TOTAL_MS) > 0
192
+ ? Math.max(1_000, Number(process.env.ALPHACOUNCIL_FULL_TOTAL_MS))
193
+ : null,
194
+ /** Fallback budget for a code path that has no run budget to read. Not a ceiling. */
180
195
  FULL_TOTAL_MS: Math.max(1_000, Math.min(
181
196
  Number(process.env.ALPHACOUNCIL_FULL_TOTAL_MS) || 30 * 60 * 1000,
182
- 30 * 60 * 1000,
197
+ 60 * 60 * 1000,
183
198
  )),
184
199
  /** Deterministic grounding is useful, but it may not hold the whole run hostage. */
185
200
  FULL_GROUNDING_MS: 30 * 1000,
@@ -268,3 +283,66 @@ export const LIMITS = Object.freeze({
268
283
  /** Age after which a leftover Codex output file is swept at startup. */
269
284
  STALE_OUTPUT_MS: 24 * 60 * 60 * 1000,
270
285
  });
286
+
287
+ /**
288
+ * Full-council pace profiles.
289
+ *
290
+ * A total budget alone does not change how deep an analysis goes. What bounds each worker is
291
+ * its per-stage cap, so raising only the total leaves a 60-minute run finishing in twenty
292
+ * minutes with forty idle, and lowering only the total starves the later stages and terminates
293
+ * `incomplete` with the debate missing. Each pace therefore carries a complete, self-consistent
294
+ * set of caps, and `COUNCIL_PACE_STAGE_TOTAL` proves the stages fit inside the total.
295
+ *
296
+ * Bull and bear run together inside a round, so three rounds cost `3 * debate_ms`, not six.
297
+ *
298
+ * `slow` is where depth actually comes from: six minutes per side per round instead of 150
299
+ * seconds, and twelve minutes per evidence seat instead of six.
300
+ */
301
+ export const COUNCIL_PACES = Object.freeze({
302
+ fast: Object.freeze({
303
+ pace: "fast",
304
+ total_ms: 15 * 60 * 1000,
305
+ grounding_ms: 20 * 1000,
306
+ evidence_ms: 210 * 1000,
307
+ master_ms: 60 * 1000,
308
+ debate_ms: 90 * 1000,
309
+ pm_ms: 120 * 1000,
310
+ finalize_reserve_ms: 45 * 1000,
311
+ }),
312
+ normal: Object.freeze({
313
+ pace: "normal",
314
+ total_ms: 30 * 60 * 1000,
315
+ grounding_ms: 30 * 1000,
316
+ evidence_ms: 6 * 60 * 1000,
317
+ master_ms: 2 * 60 * 1000,
318
+ debate_ms: 150 * 1000,
319
+ pm_ms: 180 * 1000,
320
+ finalize_reserve_ms: 45 * 1000,
321
+ }),
322
+ slow: Object.freeze({
323
+ pace: "slow",
324
+ total_ms: 60 * 60 * 1000,
325
+ grounding_ms: 45 * 1000,
326
+ evidence_ms: 12 * 60 * 1000,
327
+ master_ms: 4 * 60 * 1000,
328
+ debate_ms: 6 * 60 * 1000,
329
+ pm_ms: 8 * 60 * 1000,
330
+ finalize_reserve_ms: 60 * 1000,
331
+ }),
332
+ });
333
+
334
+ export const DEFAULT_COUNCIL_PACE = "normal";
335
+ export const COUNCIL_PACE_NAMES = Object.freeze(Object.keys(COUNCIL_PACES));
336
+
337
+ /** Serial worst case for one full council at a pace: grounding, evidence wave, method wave, three debate rounds, PM, persistence. */
338
+ export const COUNCIL_PACE_STAGE_TOTAL = (profile) => profile.grounding_ms
339
+ + profile.evidence_ms
340
+ + profile.master_ms
341
+ + (3 * profile.debate_ms)
342
+ + profile.pm_ms
343
+ + profile.finalize_reserve_ms;
344
+
345
+ /** The pace a call runs at. An unknown or absent name is the default, never an error. */
346
+ export function councilPaceProfile(name) {
347
+ return COUNCIL_PACES[String(name || "").toLowerCase()] || COUNCIL_PACES[DEFAULT_COUNCIL_PACE];
348
+ }
@@ -100,11 +100,15 @@ export function councilOptions({ language = "English" } = {}) {
100
100
  best_for: field(v3Selection.best_for, "best_for"),
101
101
  } : selectorCard(persona, language)),
102
102
  maturity: v3?.maturity || pack?.kind || "prompt_lens",
103
+ // The label names what a seat IS. It used to lead with "provisional", which read as a
104
+ // standing warning next to all twenty-seven names in the selector and repeated a review
105
+ // status the assurance section already reports. The machine-verified admission level is
106
+ // unchanged and still published as `admission_level` below.
103
107
  maturity_label: copy({
104
- en: v3?.maturity === "method_model" ? "Validated method model" : v3?.maturity === "candidate" ? "Candidate method" : "Provisional operator lens",
105
- zh: v3?.maturity === "method_model" ? "已验证方法模型" : v3?.maturity === "candidate" ? "候选方法" : "临时操作视角",
106
- ja: v3?.maturity === "method_model" ? "検証済みメソッドモデル" : v3?.maturity === "candidate" ? "候補メソッド" : "暫定オペレーター・レンズ",
107
- ko: v3?.maturity === "method_model" ? "검증된 방법론 모델" : v3?.maturity === "candidate" ? "후보 방법론" : "임시 오퍼레이터 렌즈",
108
+ en: v3?.maturity === "method_model" ? "Validated method model" : v3?.maturity === "candidate" ? "Candidate method" : "Operator method lens",
109
+ zh: v3?.maturity === "method_model" ? "已验证方法模型" : v3?.maturity === "candidate" ? "候选方法" : "方法透镜",
110
+ ja: v3?.maturity === "method_model" ? "検証済みメソッドモデル" : v3?.maturity === "candidate" ? "候補メソッド" : "メソッド・レンズ",
111
+ ko: v3?.maturity === "method_model" ? "검증된 방법론 모델" : v3?.maturity === "candidate" ? "후보 방법론" : "방법론 렌즈",
108
112
  }),
109
113
  runtime_level: v3?.admission?.level || (pack ? "v2_operator" : "v1_prompt"),
110
114
  admission_level: v3?.admission?.level || (pack ? "operator_lens" : "prompt_lens"),
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { COUNCIL_MODES, LIMITS, SELECTIONS_DIR } from "./constants.mjs";
4
+ import { COUNCIL_MODES, COUNCIL_PACE_NAMES, COUNCIL_PACE_STAGE_TOTAL, DEFAULT_COUNCIL_PACE, LIMITS, SELECTIONS_DIR, councilPaceProfile } from "./constants.mjs";
5
5
  import { councilOptions } from "./council-options.mjs";
6
6
  import { invalidParams } from "./errors.mjs";
7
7
  import { readJson, writeJson } from "./fsutil.mjs";
@@ -128,6 +128,36 @@ export function catalogSnapshot(language = "English") {
128
128
  return { ...catalog, catalog_hash: digest(catalog) };
129
129
  }
130
130
 
131
+ /**
132
+ * The pace menu shown at the selection gate, with what each tier costs and what it buys.
133
+ *
134
+ * `total_ms` is a hard ceiling, not a forecast, so both are published: `expected_ms` is the
135
+ * serial worst case if every stage uses its whole cap, which is the honest answer to "how long
136
+ * will this take". A reader given only the ceiling reads it as the estimate and thinks every
137
+ * fast run takes fifteen minutes.
138
+ */
139
+ export function councilPaceMenu(mode = "full") {
140
+ if (councilMode(mode) === "quick") return [];
141
+ return COUNCIL_PACE_NAMES.map((name) => {
142
+ const profile = councilPaceProfile(name);
143
+ return {
144
+ pace: name,
145
+ is_default: name === DEFAULT_COUNCIL_PACE,
146
+ hard_ceiling_ms: profile.total_ms,
147
+ hard_ceiling_minutes: Math.round(profile.total_ms / 60000),
148
+ expected_ms: COUNCIL_PACE_STAGE_TOTAL(profile),
149
+ expected_minutes: Math.round(COUNCIL_PACE_STAGE_TOTAL(profile) / 60000),
150
+ evidence_seconds_per_seat: Math.round(profile.evidence_ms / 1000),
151
+ debate_seconds_per_round: Math.round(profile.debate_ms / 1000),
152
+ // Same contract at every tier: this is what changes and what does not.
153
+ buys: {
154
+ en: `${Math.round(profile.evidence_ms / 60000 * 10) / 10} min per evidence seat, ${Math.round(profile.debate_ms / 1000)}s per debate round per side`,
155
+ zh: `每个证据席 ${Math.round(profile.evidence_ms / 60000 * 10) / 10} 分钟,每轮辩论每侧 ${Math.round(profile.debate_ms / 1000)} 秒`,
156
+ },
157
+ };
158
+ });
159
+ }
160
+
131
161
  export function beginCouncilSelection(args = {}, { now = Date.now() } = {}) {
132
162
  ensureStore();
133
163
  // Bound expiry cleanup to selections/ and selections/receipts/. It is intentionally
@@ -160,6 +190,10 @@ export function beginCouncilSelection(args = {}, { now = Date.now() } = {}) {
160
190
  selected_master_ids: [],
161
191
  preselected_master_ids: preselected,
162
192
  selection_mode: null,
193
+ // A pace named in the request is a prefill, exactly like a named master: it highlights the
194
+ // row and never confirms it. The confirmed value lands here at confirm time.
195
+ preselected_council_pace: COUNCIL_PACE_NAMES.includes(String(args.council_pace || "")) ? String(args.council_pace) : null,
196
+ council_pace: null,
163
197
  selection_receipt: null,
164
198
  created_at: createdAt,
165
199
  updated_at: createdAt,
@@ -183,6 +217,11 @@ export function beginCouncilSelection(args = {}, { now = Date.now() } = {}) {
183
217
  masters: catalog.masters,
184
218
  master_rosters: catalog.master_rosters,
185
219
  preselected_master_ids: preselected,
220
+ // Ask the pace in the same interaction as the catalog: two decisions, one question. Quick
221
+ // gets an empty menu because it is a smaller contract rather than a slower one.
222
+ pace_options: councilPaceMenu(mode),
223
+ default_council_pace: mode === "quick" ? null : DEFAULT_COUNCIL_PACE,
224
+ preselected_council_pace: record.preselected_council_pace,
186
225
  actions: mode === "quick" ? ["explicit_selection"] : ["explicit_selection", "select_all"],
187
226
  };
188
227
  }
@@ -341,6 +380,27 @@ function confirmCouncilSelectionUnlocked(args = {}, { now = Date.now() } = {}) {
341
380
  reason: "MASTER_CATALOG_NOT_ACKNOWLEDGED",
342
381
  });
343
382
  }
383
+ // The pace is the second decision taken at this gate. Quick has no pace to take, and an
384
+ // unrecognised name is rejected rather than quietly run at some other depth -- a user who
385
+ // asked for fifteen minutes must not silently get an hour, or the reverse.
386
+ const quickSelection = (record.council_mode || "full") === "quick";
387
+ if (args.council_pace !== undefined && args.council_pace !== null) {
388
+ if (quickSelection) {
389
+ throw invalidParams("council_pace applies to the full council only. Quick is a smaller contract, not a slower one.", {
390
+ reason: "QUICK_PACE_FORBIDDEN",
391
+ });
392
+ }
393
+ if (!COUNCIL_PACE_NAMES.includes(String(args.council_pace))) {
394
+ throw invalidParams(`council_pace must be one of ${COUNCIL_PACE_NAMES.join(", ")}.`, {
395
+ reason: "INVALID_COUNCIL_PACE",
396
+ allowed: COUNCIL_PACE_NAMES,
397
+ pace_options: councilPaceMenu(record.council_mode),
398
+ });
399
+ }
400
+ }
401
+ const chosenPace = quickSelection
402
+ ? null
403
+ : String(args.council_pace || record.preselected_council_pace || DEFAULT_COUNCIL_PACE);
344
404
  const resolved = resolveConfirmation(args, record);
345
405
  if (record.council_mode === "quick" && resolved.ids.length > QUICK_MASTER_MAX) {
346
406
  throw invalidParams(`Quick council accepts at most ${QUICK_MASTER_MAX} selected masters.`, {
@@ -378,6 +438,7 @@ function confirmCouncilSelectionUnlocked(args = {}, { now = Date.now() } = {}) {
378
438
  selection_receipt: receipt,
379
439
  confirmed_at: confirmedAt,
380
440
  updated_at: confirmedAt,
441
+ council_pace: chosenPace,
381
442
  });
382
443
  const receiptRecord = {
383
444
  schema_version: 1,
@@ -386,6 +447,10 @@ function confirmCouncilSelectionUnlocked(args = {}, { now = Date.now() } = {}) {
386
447
  status: "confirmed",
387
448
  symbol: record.symbol,
388
449
  council_mode: record.council_mode || "full",
450
+ // The pace is part of what the user approved for this run, so it travels inside the receipt
451
+ // and is checked at consumption like every other bound field. A caller cannot approve a
452
+ // 15-minute run and then execute an hour of it.
453
+ council_pace: chosenPace,
389
454
  catalog_hash: record.catalog_hash,
390
455
  request_hash: record.request_hash,
391
456
  intent_hash: record.intent_hash,
@@ -435,6 +500,7 @@ function confirmationResult(record) {
435
500
  .map((master) => [master.id, master.pack_hash]),
436
501
  ),
437
502
  selected_count: record.selected_master_ids.length,
503
+ council_pace: record.council_pace || null,
438
504
  expires_at: record.expires_at,
439
505
  };
440
506
  }
@@ -460,6 +526,7 @@ function consumeCouncilSelectionUnlocked(args = {}, { now = Date.now() } = {}) {
460
526
  ["selection_receipt", receipt.selection_receipt, selection.selection_receipt],
461
527
  ["symbol", receipt.symbol, selection.symbol],
462
528
  ["council_mode", receipt.council_mode || "full", selection.council_mode || "full"],
529
+ ["council_pace", receipt.council_pace || null, selection.council_pace || null],
463
530
  ["catalog_hash", receipt.catalog_hash, selection.catalog_hash],
464
531
  ["request_hash", receipt.request_hash, selection.request_hash],
465
532
  ["intent_hash", receipt.intent_hash, selection.intent_hash],
@@ -532,8 +599,16 @@ function consumeCouncilSelectionUnlocked(args = {}, { now = Date.now() } = {}) {
532
599
  }
533
600
  const intentHash = digest({ symbol, language, prompt, council_mode: mode });
534
601
  if (receipt.intent_hash !== intentHash || selection.intent_hash !== intentHash) {
602
+ // Say which field moved. The receipt binds symbol, language, prompt and mode verbatim, so a
603
+ // single reworded character invalidates it -- and without this the caller cannot tell a
604
+ // retyped prompt from a wrong symbol, and retries the same broken call.
535
605
  throw invalidParams("The selection receipt belongs to a different prompt or language intent.", {
536
606
  reason: "MASTER_SELECTION_INTENT_MISMATCH",
607
+ expected_intent_hash: receipt.intent_hash || selection.intent_hash || null,
608
+ submitted_intent_hash: intentHash,
609
+ bound_fields: ["symbol", "language", "prompt", "council_mode"],
610
+ submitted: { symbol, language, council_mode: mode, prompt_length: prompt.length },
611
+ remedy: "Re-send the exact symbol, prompt, language and council_mode used in begin_council_selection, or start a new selection.",
537
612
  });
538
613
  }
539
614
  if (expired(receipt, now) || expired(selection, now)) {
@@ -593,6 +668,9 @@ function consumedResult(selection, receipt) {
593
668
  intent_hash: selection.intent_hash,
594
669
  selection_mode: selection.selection_mode,
595
670
  council_mode: selection.council_mode || "full",
671
+ // The pace approved at the gate must survive consumption, or the run silently falls back to
672
+ // the default and the approved tier is lost with nothing in the record showing the switch.
673
+ council_pace: selection.council_pace || null,
596
674
  selected_master_ids: [...selection.selected_master_ids],
597
675
  selected_master_pack_hashes: { ...(receipt.selected_master_pack_hashes || {}) },
598
676
  selected_count: selection.selected_master_ids.length,
@@ -182,17 +182,40 @@ function ownerEarnings(series, cik, gaps, proxy) {
182
182
  });
183
183
  }
184
184
 
185
+ /**
186
+ * Total liabilities, tagged where the filer tags it and reconstructed exactly where not.
187
+ *
188
+ * Many filers never tag `Liabilities`; they tag the balance-sheet total and equity. The
189
+ * subtraction is only safe against the INCLUDING-non-controlling-interests equity tag -- with
190
+ * the parent-only tag, minority interest lands inside "liabilities" and nothing says so. When
191
+ * that tag is absent the figure stays missing, which is why this returns null rather than
192
+ * reaching for the nearest number.
193
+ */
194
+ function totalLiabilitiesFor(series, gaps, factId) {
195
+ const direct = alignLatest(series, ["current_assets", "total_liabilities"]);
196
+ if (direct.ok) {
197
+ return { alignment: direct, value: direct.entries.total_liabilities.value, basis: "filed_total_liabilities" };
198
+ }
199
+ const derived = alignLatest(series, ["current_assets", "liabilities_and_equity", "equity_including_nci"]);
200
+ if (!derived.ok) {
201
+ gaps.push(alignmentGap(factId, direct, ["current_assets", "total_liabilities"]));
202
+ return null;
203
+ }
204
+ return {
205
+ alignment: derived,
206
+ value: derived.entries.liabilities_and_equity.value - derived.entries.equity_including_nci.value,
207
+ basis: "balance_sheet_total_less_equity_including_noncontrolling_interests",
208
+ };
209
+ }
210
+
185
211
  /** 2. Graham NCAV: current assets - total liabilities. */
186
212
  function netCurrentAssetValue(series, cik, gaps) {
187
213
  const factId = "financial.net_current_asset_value";
188
- const concepts = ["current_assets", "total_liabilities"];
189
- const alignment = alignLatest(series, concepts);
190
- if (!alignment.ok) {
191
- gaps.push(alignmentGap(factId, alignment, concepts));
192
- return null;
193
- }
214
+ const resolved = totalLiabilitiesFor(series, gaps, factId);
215
+ if (!resolved) return null;
216
+ const alignment = resolved.alignment;
194
217
  const currentAssets = alignment.entries.current_assets.value;
195
- const liabilities = alignment.entries.total_liabilities.value;
218
+ const liabilities = resolved.value;
196
219
  return buildMetric({
197
220
  factId,
198
221
  valueKind: "monetary",
@@ -207,6 +230,9 @@ function netCurrentAssetValue(series, cik, gaps) {
207
230
  assumptions: [
208
231
  "Graham NCAV as filed: current assets less ALL liabilities, with no haircut applied to receivables or inventory",
209
232
  "an unclassified balance sheet (banks, insurers, many REITs) files no AssetsCurrent, which is reported as a gap rather than substituted",
233
+ resolved.basis === "filed_total_liabilities"
234
+ ? "total liabilities taken from the filer's own Liabilities tag"
235
+ : "the filer tags no Liabilities total, so it is the balance-sheet total less equity INCLUDING non-controlling interests, which is exact rather than approximate",
210
236
  ],
211
237
  });
212
238
  }
@@ -465,10 +491,24 @@ function trailingYearOverYear(companyFacts, asOf) {
465
491
  },
466
492
  };
467
493
  }
468
- const latest = fourQuarters(quarters, quarters.length - 1);
469
- const prior = fourQuarters(quarters, quarters.length - 5);
494
+ // Search backwards for the most recent pair of clean four-quarter blocks rather than
495
+ // insisting the last eight filings are them. One restated quarter, one stub period or one
496
+ // duplicated filing at the end used to discard a company's entire revenue history -- INTC and
497
+ // GLW both failed here while holding years of usable quarters behind the ragged edge. Each
498
+ // window is still checked exactly as strictly; only the search moved.
499
+ let latest = null;
500
+ let prior = null;
501
+ let offset = 0;
502
+ for (; offset <= quarters.length - 8; offset += 1) {
503
+ const end = quarters.length - 1 - offset;
504
+ latest = fourQuarters(quarters, end);
505
+ prior = fourQuarters(quarters, end - 4);
506
+ if (latest && prior) break;
507
+ latest = null;
508
+ prior = null;
509
+ }
470
510
  if (!latest || !prior) {
471
- return { ...empty, gap: { code: "period_misaligned", detail: "the eight most recent quarters are not contiguous four-quarter blocks" } };
511
+ return { ...empty, gap: { code: "period_misaligned", detail: `no contiguous pair of four-quarter blocks in the ${quarters.length} tagged quarters` } };
472
512
  }
473
513
  if (!(prior.total > 0)) {
474
514
  return { ...empty, gap: { code: "non_positive_base", detail: `prior-year four-quarter revenue is ${prior.total}` } };
package/mcp/lib/gates.mjs CHANGED
@@ -260,6 +260,48 @@ function readerLanguageAudit(assigned, language) {
260
260
  };
261
261
  }
262
262
 
263
+ /**
264
+ * Which contract sections an author still owes, checked against a submitted report body.
265
+ *
266
+ * `validateFinalReport` runs against the assembled report, after the system has appended the
267
+ * price snapshot, the master bench and any instrument-structure block, so it can only answer
268
+ * once the report already exists. A PM that submits no report body at all cannot be told so
269
+ * until then: on a real run the first submission carried no `report_markdown`, the report was
270
+ * assembled from the summary fallback, and the gate came back with 21 missing sections after
271
+ * the whole PM turn had been spent. This answers the same question at submission time, and
272
+ * deliberately ignores the sections the system owns.
273
+ */
274
+ const SYSTEM_OWNED_REPORT_SECTIONS = new Set(["master_bench", "instrument_structure"]);
275
+
276
+ export function authoredReportSectionGaps(markdown, run) {
277
+ const gaps = validateFinalReport(markdown, run).missing
278
+ .filter((entry) => /^(missing|placeholder) section: |^section too thin: /.test(entry));
279
+ return gaps.filter((entry) => ![...SYSTEM_OWNED_REPORT_SECTIONS]
280
+ .some((id) => entry.includes(`section: ${id}`) || entry.includes(`section: ${id} (`)));
281
+ }
282
+
283
+ /** The heading a report must carry for each contract section, in the run's language. */
284
+ export function requiredReportSectionAliases(run) {
285
+ const quick = run?.council_mode === "quick";
286
+ const sections = quick ? QUICK_REPORT_SECTIONS : REPORT_SECTIONS;
287
+ const benchRan = ((run?.masters || []).length > 0) || ((run?.master_opinions || []).length > 0);
288
+ const fundOrIndex = isFundOrIndex(run?.grounding?.instrument);
289
+ const key = languageKey(run?.language);
290
+ const index = { zh: 0, en: 1, ja: 2, ko: 3 }[key] ?? 1;
291
+ return sections
292
+ .filter((section) => !SYSTEM_OWNED_REPORT_SECTIONS.has(section.id))
293
+ .filter((section) => !section.when_masters || benchRan)
294
+ .filter((section) => !section.when_fund_or_index || fundOrIndex)
295
+ .map((section) => ({
296
+ id: section.id,
297
+ minimum_body_characters: section.min_body,
298
+ // The catalog lists zh, en, ja and ko variants; offer the one the run reads in, and fall
299
+ // back to the English alias so the message is never empty.
300
+ suggested_heading: section.aliases[index] || section.aliases[1] || section.aliases[0],
301
+ accepted_headings: section.aliases,
302
+ }));
303
+ }
304
+
263
305
  export function validateFinalReport(markdown, run) {
264
306
  const text = String(markdown || "");
265
307
  const headings = parseHeadings(text);
@@ -357,8 +357,25 @@ export async function gatherGrounding({
357
357
  // Only the market-level block. A company's own leverage and growth come from its filings,
358
358
  // and taking them from the index would be silently answering a different question.
359
359
  const marketFacts = (r.value.facts || []).filter((fact) => MARKET_LEVEL_FACTS.has(fact.fact_id));
360
- if (marketFacts.length) out.market_valuation = { ...r.value, facts: marketFacts };
360
+ if (marketFacts.length) { out.market_valuation = { ...r.value, facts: marketFacts }; return; }
361
+ // A fetch that succeeds but yields no market-level fact used to vanish here. Downstream,
362
+ // Marks and Damodaran declined with `unmet: index.aggregate_earnings_yield` and the reader
363
+ // was never told why the yardstick was missing -- an unexplained abstention reads as a
364
+ // verdict. Record it as the data gap it is.
365
+ out.unavailable.push(
366
+ `market valuation for ${BROAD_MARKET_INDEX}: fetched, but none of ${[...MARKET_LEVEL_FACTS].join(", ")} was derivable`
367
+ + " (seats that measure a company against the market will decline for lack of grounding, not for lack of a view)",
368
+ );
361
369
  }));
370
+ } else if (symbol && !isFundOrIndex(out.instrument) && symbolMarket?.id === "US") {
371
+ // Every other block on this path names the cutoff it could not serve; this one used to
372
+ // skip in silence, so a historical run lost the yardstick with nothing said about it and
373
+ // Marks and Damodaran again declined for a reason the reader could not see.
374
+ out.unavailable.push(
375
+ `market valuation for ${BROAD_MARKET_INDEX}: the index aggregate is published only as a current snapshot`
376
+ + " with no point-in-time archive, so it was not fetched for a historical cutoff"
377
+ + " (seats that measure a company against the market will decline for lack of grounding, not for lack of a view)",
378
+ );
362
379
  }
363
380
 
364
381
  // A fund or index has no issuer financials, so this is where its evidence comes from
@@ -389,6 +406,15 @@ export async function gatherGrounding({
389
406
  if (news.value.available) out.basket_news = news.value;
390
407
  out.unavailable.push(...(news.value.unavailable || []));
391
408
  })());
409
+ } else if (symbol && isFundOrIndex(out.instrument)) {
410
+ // The same silence as the market-valuation block above, and worse here: a basket's
411
+ // holdings, look-through aggregates and news are the whole of its evidence, so a
412
+ // historical cutoff left the seats with a price and nothing to explain the emptiness.
413
+ out.unavailable.push(
414
+ `holdings, look-through aggregates and basket news for ${symbol}: issuer holdings and index`
415
+ + " aggregates are published only as current snapshots with no point-in-time archive,"
416
+ + " so they were not fetched for a historical cutoff",
417
+ );
392
418
  }
393
419
 
394
420
  if (symbol && isFundOrIndex(out.instrument)) {
@@ -169,7 +169,12 @@ export function sicGroupFor(sic) {
169
169
  return matches.sort((a, b) => (a.range[1] - a.range[0]) - (b.range[1] - b.range[0]))[0];
170
170
  }
171
171
 
172
+ // Refreshed on the same cadence as the TWSE dataset: a resident MCP process that never
173
+ // re-reads the SEC ticker file silently loses every new listing and rename from peer
174
+ // matching, with no gap recorded anywhere.
175
+ const UNIVERSE_TTL_MS = 6 * 60 * 60 * 1000;
172
176
  let universeCache = null;
177
+ let universeCachedAt = 0;
173
178
 
174
179
  /**
175
180
  * Every US filer whose SIC matches a query, with no curation involved.
@@ -186,7 +191,10 @@ export async function peersBySic({ cik, limit = 25 } = {}) {
186
191
  }
187
192
  const group = sicGroupFor(anchor.sic);
188
193
 
189
- if (!universeCache) universeCache = await fetchUniverse();
194
+ if (!universeCache || Date.now() - universeCachedAt > UNIVERSE_TTL_MS) {
195
+ universeCache = await fetchUniverse();
196
+ universeCachedAt = Date.now();
197
+ }
190
198
  // Company names are the only universe-wide signal available without 10k requests.
191
199
  const words = anchor.name.toLowerCase().split(/\s+/).filter((w) => w.length > 4 && !/corp|inc|company|holdings|group|technologies/.test(w));
192
200
  const nameMatched = words.length