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
@@ -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. */
package/mcp/lib/rpc.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import readline from "node:readline";
4
- import { COUNCIL_MODES, LIMITS, MASTER_STANCES, OUTPUT_MODES, QUICK_TASKS, SERVER_NAME, VERSION } from "./constants.mjs";
4
+ import { COUNCIL_MODES, COUNCIL_PACE_NAMES, DEFAULT_COUNCIL_PACE, LIMITS, MASTER_STANCES, OUTPUT_MODES, QUICK_TASKS, SERVER_NAME, VERSION, councilPaceProfile } from "./constants.mjs";
5
5
  import { RpcCode, methodNotFound, invalidParams, toRpcError } from "./errors.mjs";
6
6
  import { readJson, readJsonl, writeJson } from "./fsutil.mjs";
7
7
  import { localized, resolveLanguage } from "./lang.mjs";
@@ -147,6 +147,36 @@ export function jsonContent(text, structuredContent = {}) {
147
147
  };
148
148
  }
149
149
 
150
+ // Series history and per-fact lineage are the two unbounded parts of a grounding object: on one
151
+ // real run they were 2.33 MB of a 2.54 MB plan payload, which exceeded the host's tool-result
152
+ // limit and pushed the entire plan to a scratch file. They are already saved in evidence.json and
153
+ // baked into every seat prompt, so the plan response replaces them with a pointer and a count.
154
+ const BULK_GROUNDING_FIELDS = ["macro_series", "typed_fact_sources"];
155
+
156
+ /**
157
+ * The grounding a plan response carries: the same object minus its two unbounded fields.
158
+ * Shape, flags and gap lists are preserved so a host can still read what was established.
159
+ */
160
+ export function compactGrounding(run) {
161
+ const g = run?.grounding;
162
+ if (!g || typeof g !== "object") return g;
163
+ const compact = { ...g };
164
+ const omitted = [];
165
+ for (const field of BULK_GROUNDING_FIELDS) {
166
+ if (compact[field] === undefined) continue;
167
+ const count = Array.isArray(compact[field]) ? compact[field].length : Object.keys(compact[field] || {}).length;
168
+ omitted.push(`${field} (${count} entries)`);
169
+ delete compact[field];
170
+ }
171
+ if (!omitted.length) return compact;
172
+ compact.omitted_from_this_response = {
173
+ fields: omitted,
174
+ reason: "kept out of the plan payload for size; every seat prompt already carries them",
175
+ saved_to: join(runPath(run.run_id), "evidence.json"),
176
+ };
177
+ return compact;
178
+ }
179
+
150
180
  export function tool(name, description, inputSchema, annotations = {}) {
151
181
  return { name, description, inputSchema, annotations };
152
182
  }
@@ -197,12 +227,34 @@ function startValidation(args, entryTool) {
197
227
  maximum_ms: LIMITS.QUICK_HARD_MAX_MS,
198
228
  });
199
229
  }
200
- if (mode === "full" && args.total_timeout_ms > LIMITS.FULL_HARD_MAX_MS) {
201
- throw invalidParams(`Full council total_timeout_ms cannot exceed ${LIMITS.FULL_HARD_MAX_MS}.`, {
202
- reason: "FULL_TOTAL_TIMEOUT_EXCEEDS_MAX",
203
- maximum_ms: LIMITS.FULL_HARD_MAX_MS,
230
+ if (args.council_pace !== undefined && !COUNCIL_PACE_NAMES.includes(String(args.council_pace))) {
231
+ throw invalidParams(`council_pace must be one of ${COUNCIL_PACE_NAMES.join(", ")}.`, {
232
+ reason: "INVALID_COUNCIL_PACE",
233
+ allowed: COUNCIL_PACE_NAMES,
234
+ });
235
+ }
236
+ if (mode === "quick" && args.council_pace !== undefined) {
237
+ throw invalidParams("council_pace applies to the full council only. Quick is a smaller contract, not a slower one.", {
238
+ reason: "QUICK_PACE_FORBIDDEN",
204
239
  });
205
240
  }
241
+ // A call is held to its own pace's budget, not to the outer bound of the schema: asking for
242
+ // an hour at the fast pace would buy idle time, because what bounds each worker is its
243
+ // per-stage cap and those are set by the pace.
244
+ if (mode === "full" && args.total_timeout_ms !== undefined) {
245
+ const profile = councilPaceProfile(args.council_pace);
246
+ if (args.total_timeout_ms > profile.total_ms) {
247
+ throw invalidParams(
248
+ `Full council total_timeout_ms cannot exceed ${profile.total_ms} at the ${profile.pace} pace. Raise council_pace to buy more time.`,
249
+ {
250
+ reason: "FULL_TOTAL_TIMEOUT_EXCEEDS_MAX",
251
+ council_pace: profile.pace,
252
+ maximum_ms: profile.total_ms,
253
+ paces: Object.fromEntries(COUNCIL_PACE_NAMES.map((name) => [name, councilPaceProfile(name).total_ms])),
254
+ },
255
+ );
256
+ }
257
+ }
206
258
  if (mode === "quick" && args.synthesis === false) {
207
259
  throw invalidParams("Quick council requires its one-round bull/bear and portfolio-manager synthesis.", {
208
260
  reason: "QUICK_SYNTHESIS_REQUIRED",
@@ -263,12 +315,30 @@ function selectedRunArgs(args = {}, entryTool) {
263
315
  prompt: typeof args.prompt === "string" ? args.prompt : "",
264
316
  council_mode: args.council_mode || "full",
265
317
  });
318
+ // The pace was approved at the gate, so the receipt is authoritative: an execution call may
319
+ // repeat it but not change it. Otherwise a user could approve fifteen minutes and get an
320
+ // hour, or the reverse, with nothing in the record showing the switch.
321
+ if (args.council_pace !== undefined
322
+ && selection.council_pace
323
+ && String(args.council_pace) !== String(selection.council_pace)) {
324
+ throw invalidParams(
325
+ `council_pace ${args.council_pace} does not match the pace confirmed at the selection gate (${selection.council_pace}).`,
326
+ {
327
+ reason: "COUNCIL_PACE_RECEIPT_MISMATCH",
328
+ confirmed_council_pace: selection.council_pace,
329
+ submitted_council_pace: String(args.council_pace),
330
+ remedy: "Send the confirmed pace or start a new selection to change it.",
331
+ },
332
+ );
333
+ }
266
334
  return {
267
335
  ...args,
268
336
  run_id: id,
269
337
  entry_tool: entryTool,
270
338
  masters: selection.selected_master_ids,
271
339
  master_selection: selection,
340
+ // The gate's decision wins when the caller omitted it.
341
+ council_pace: args.council_pace ?? selection.council_pace ?? undefined,
272
342
  existing_run: existing,
273
343
  release_run_lock: releaseRunLock,
274
344
  };
@@ -400,10 +470,16 @@ export function tools() {
400
470
  timeout_ms: { type: "number", default: LIMITS.CODEX_TIMEOUT_MS },
401
471
  synthesis: { type: "boolean", default: true, description: "Run bull, bear, and portfolio-manager synthesis after evidence collection." },
402
472
  synthesis_timeout_ms: { type: "number", default: LIMITS.CODEX_TIMEOUT_MS },
473
+ council_pace: {
474
+ type: "string",
475
+ enum: COUNCIL_PACE_NAMES,
476
+ default: DEFAULT_COUNCIL_PACE,
477
+ description: "Full-council depth/time tier. fast = 15 minutes, normal = 30, slow = 60. The tier sets the total budget AND every per-stage cap together: slow gives each evidence seat 12 minutes instead of 6 and each debate round 6 minutes per side instead of 150 seconds, which is where the extra depth comes from. Raising total_timeout_ms alone buys idle time, not depth. Quick rejects this field.",
478
+ },
403
479
  total_timeout_ms: {
404
480
  type: "number",
405
481
  maximum: LIMITS.FULL_HARD_MAX_MS,
406
- description: "Hard queue-to-persistence wall-clock budget. quick defaults to and cannot exceed ten minutes; full defaults to and cannot exceed thirty minutes. Callers may lower the applicable ceiling, never raise it.",
482
+ description: "Hard queue-to-persistence wall-clock budget. Cannot exceed the selected council_pace's total (fast 900000, normal 1800000, slow 3600000) or ten minutes for quick. Callers may lower the applicable ceiling, never raise it.",
407
483
  },
408
484
  output_mode: { type: "string", enum: OUTPUT_MODES, default: "public_equity", description: "Final synthesis target shape." },
409
485
  selection_receipt: { type: "string", description: "One-run receipt returned by confirm_master_selection. Required for every council run and consumed exactly once." },
@@ -426,6 +502,11 @@ export function tools() {
426
502
  items: { type: "string", enum: masterIds },
427
503
  description: "Optional stable IDs inferred from masters explicitly named in the request. They are highlighted only; the user must still submit this run's selection.",
428
504
  },
505
+ council_pace: {
506
+ type: "string",
507
+ enum: COUNCIL_PACE_NAMES,
508
+ description: "Prefill only, when the request already named a speed such as fast or slow. It highlights that tier in the returned pace_options and never confirms one. Full only.",
509
+ },
429
510
  },
430
511
  required: ["symbol"],
431
512
  }, { readOnlyHint: false, destructiveHint: false, openWorldHint: false }),
@@ -438,6 +519,11 @@ export function tools() {
438
519
  selected_master_ids: { type: "array", minItems: 1, uniqueItems: true, items: { type: "string", enum: masterIds } },
439
520
  select_all: { type: "boolean", const: true },
440
521
  selection: { type: "string", minLength: 1, description: "Text fallback: stable numbers/ranges/IDs, or all." },
522
+ council_pace: {
523
+ type: "string",
524
+ enum: COUNCIL_PACE_NAMES,
525
+ description: "The depth tier the user picked from the pace_options this selection returned. Full only; omit to accept the default (normal, ~20 min expected, 30 min ceiling). Binds into the receipt, so an execution call may repeat it but never change it.",
526
+ },
441
527
  },
442
528
  required: ["selection_id", "catalog_hash", "display_ack"],
443
529
  oneOf: [
@@ -793,7 +879,7 @@ export async function handleToolCall(id, params) {
793
879
  + `Network preflight: ${preflight.status}. `
794
880
  + `Established facts: ${run.grounding && !run.grounding.facts_unavailable ? "attached to every seat" : "UNAVAILABLE -- seats will run without filings or quotes, say so in the report"}.`,
795
881
  {
796
- run,
882
+ run: { ...run, grounding: compactGrounding(run) },
797
883
  idempotent_replay: Boolean(runArgs.existing_run),
798
884
  preflight,
799
885
  ...specs,
@@ -70,6 +70,9 @@ export function statusSnapshot(run) {
70
70
  language: run.language,
71
71
  execution_mode: run.execution_mode,
72
72
  council_mode: run.council_mode || "full",
73
+ // Which depth/time tier produced this run. Two runs of the same symbol at different paces
74
+ // are not the same analysis, so the pace belongs in the audit record beside the budget.
75
+ council_pace: run.council_pace || null,
73
76
  debate_format: run.debate_format || "three_round_cross_exam",
74
77
  visible_debate_contract: visibleDebate?.contract || null,
75
78
  visible_debate_rounds_expected: visibleDebate?.rounds_expected || null,
@@ -17,6 +17,21 @@ const pct = (x) => (x === null ? null : Number((x * 100).toFixed(2)));
17
17
  const last = (series, n) => series.slice(-n);
18
18
  const sum = (xs) => xs.reduce((a, b) => a + b, 0);
19
19
 
20
+ /**
21
+ * Pair two annual series by fiscal period end, newest last, capped at n pairs.
22
+ *
23
+ * Pairing by array position instead put every year after a gap onto the wrong
24
+ * counterpart -- a filer that stopped tagging InterestExpense had this year's EBIT
25
+ * divided by a two-year-old interest figure, and an equity series missing one middle
26
+ * year shifted a decade of ROE by one year -- while the provenance block still showed
27
+ * a clean range. A year present on only one side is dropped, never matched to a
28
+ * neighbour; the pair count is what the rule reports as its coverage.
29
+ */
30
+ const pairByEnd = (a, b, n) => {
31
+ const byEnd = new Map(b.map((row) => [row.end, row]));
32
+ return a.filter((row) => byEnd.has(row.end)).slice(-n).map((row) => [row, byEnd.get(row.end)]);
33
+ };
34
+
20
35
  /**
21
36
  * XBRL reports share counts under the `shares` unit, not `USD`. Requesting the default unit
22
37
  * for a share concept returns nothing, so the dilution rule reported `skipped` for every
@@ -117,41 +132,36 @@ export function evaluateRules(facts, { asOf = null } = {}) {
117
132
  };
118
133
 
119
134
  add("roe_10y", { en: "10-year average ROE", zh: "10年平均ROE" }, () => {
120
- const ni = last(netIncome, 10);
121
- const eq = last(equity, 10);
122
- if (ni.length < 5 || eq.length < 5) return null;
123
- const n = Math.min(ni.length, eq.length);
124
- const roes = [];
125
- for (let i = 0; i < n; i += 1) {
126
- const e = eq[eq.length - n + i].val;
127
- if (e > 0) roes.push(ni[ni.length - n + i].val / e);
128
- }
135
+ const pairs = pairByEnd(netIncome, equity, 10);
136
+ if (pairs.length < 5) return null;
137
+ const roes = pairs.filter(([, e]) => e.val > 0).map(([n, e]) => n.val / e.val);
129
138
  if (roes.length < 5) return null;
130
139
  const avg = sum(roes) / roes.length;
131
140
  return {
132
141
  passed: avg >= 0.08, value: pct(avg), unit: "%", threshold: 8, direction: "min", years: roes.length,
133
- ...ruleProvenance([ni.slice(-n), eq.slice(-n)]),
142
+ ...ruleProvenance([pairs.map(([n]) => n), pairs.map(([, e]) => e)]),
134
143
  };
135
144
  });
136
145
 
137
146
  add("fcf_5y", { en: "5-year cumulative free cash flow", zh: "5年累计自由现金流" }, () => {
138
- const o = last(ocf, 5);
139
- const c = last(capex, 5);
140
- if (o.length < 3 || c.length < 3) return null;
141
- const n = Math.min(o.length, c.length);
142
- const total = sum(o.slice(-n).map((x) => x.val)) - sum(c.slice(-n).map((x) => x.val));
147
+ const pairs = pairByEnd(ocf, capex, 5);
148
+ if (pairs.length < 3) return null;
149
+ const total = sum(pairs.map(([o, c]) => o.val - c.val));
143
150
  // Raw dollars, not billions rounded to two places: rounding erased the entire figure
144
151
  // for anything below ~$5m, which is most of the small-cap universe.
145
152
  return {
146
- passed: total >= 0, value: Math.round(total), unit: "USD", threshold: 0, direction: "min", years: n,
147
- ...ruleProvenance([o.slice(-n), c.slice(-n)]),
153
+ passed: total >= 0, value: Math.round(total), unit: "USD", threshold: 0, direction: "min", years: pairs.length,
154
+ ...ruleProvenance([pairs.map(([o]) => o), pairs.map(([, c]) => c)]),
148
155
  };
149
156
  });
150
157
 
151
158
  add("interest_cover", { en: "EBIT / interest cover", zh: "利息保障倍数" }, () => {
152
- const ebit = last(operatingIncome, 1)[0];
153
- const int = last(interest, 1)[0];
154
- if (!ebit || !int || int.val === 0) return null;
159
+ // The interest figure must belong to the same period as the latest EBIT. A filer that
160
+ // stops tagging InterestExpense gets a skip here, not a ratio built on old debt.
161
+ const pairs = pairByEnd(last(operatingIncome, 1), interest, 1);
162
+ if (!pairs.length) return null;
163
+ const [ebit, int] = pairs[0];
164
+ if (int.val === 0) return null;
155
165
  const cover = ebit.val / Math.abs(int.val);
156
166
  return {
157
167
  passed: cover >= 2, value: Number(cover.toFixed(2)), unit: "x", threshold: 2, direction: "min",
@@ -160,48 +170,38 @@ export function evaluateRules(facts, { asOf = null } = {}) {
160
170
  });
161
171
 
162
172
  add("gross_margin", { en: "long-run gross margin", zh: "长期毛利率" }, () => {
163
- const gp = last(grossProfit, 5);
164
- const rev = last(revenue, 5);
165
- if (gp.length < 3 || rev.length < 3) return null;
166
- const n = Math.min(gp.length, rev.length);
167
- const margins = [];
168
- for (let i = 0; i < n; i += 1) {
169
- const r = rev[rev.length - n + i].val;
170
- if (r > 0) margins.push(gp[gp.length - n + i].val / r);
171
- }
173
+ const pairs = pairByEnd(grossProfit, revenue, 5);
174
+ if (pairs.length < 3) return null;
175
+ const margins = pairs.filter(([, r]) => r.val > 0).map(([g, r]) => g.val / r.val);
172
176
  if (!margins.length) return null;
173
177
  const avg = sum(margins) / margins.length;
174
178
  return {
175
179
  passed: avg >= 0.15, value: pct(avg), unit: "%", threshold: 15, direction: "min", years: margins.length,
176
- ...ruleProvenance([gp.slice(-n), rev.slice(-n)]),
180
+ ...ruleProvenance([pairs.map(([g]) => g), pairs.map(([, r]) => r)]),
177
181
  };
178
182
  });
179
183
 
180
184
  add("ocf_over_ni", { en: "5-year OCF / net income", zh: "5年经营现金流/净利" }, () => {
181
- const o = last(ocf, 5);
182
- const ni = last(netIncome, 5);
183
- if (o.length < 3 || ni.length < 3) return null;
184
- const n = Math.min(o.length, ni.length);
185
- const totalNi = sum(ni.slice(-n).map((x) => x.val));
185
+ const pairs = pairByEnd(ocf, netIncome, 5);
186
+ if (pairs.length < 3) return null;
187
+ const totalNi = sum(pairs.map(([, n]) => n.val));
186
188
  if (totalNi <= 0) return null;
187
- const ratio = sum(o.slice(-n).map((x) => x.val)) / totalNi;
189
+ const ratio = sum(pairs.map(([o]) => o.val)) / totalNi;
188
190
  return {
189
- passed: ratio >= 0.7, value: Number(ratio.toFixed(2)), unit: "x", threshold: 0.7, direction: "min", years: n,
190
- ...ruleProvenance([o.slice(-n), ni.slice(-n)]),
191
+ passed: ratio >= 0.7, value: Number(ratio.toFixed(2)), unit: "x", threshold: 0.7, direction: "min", years: pairs.length,
192
+ ...ruleProvenance([pairs.map(([o]) => o), pairs.map(([, n]) => n)]),
191
193
  };
192
194
  });
193
195
 
194
196
  add("net_margin", { en: "long-run net margin", zh: "长期净利率" }, () => {
195
- const ni = last(netIncome, 5);
196
- const rev = last(revenue, 5);
197
- if (ni.length < 3 || rev.length < 3) return null;
198
- const n = Math.min(ni.length, rev.length);
199
- const totalRev = sum(rev.slice(-n).map((x) => x.val));
197
+ const pairs = pairByEnd(netIncome, revenue, 5);
198
+ if (pairs.length < 3) return null;
199
+ const totalRev = sum(pairs.map(([, r]) => r.val));
200
200
  if (totalRev <= 0) return null;
201
- const margin = sum(ni.slice(-n).map((x) => x.val)) / totalRev;
201
+ const margin = sum(pairs.map(([n]) => n.val)) / totalRev;
202
202
  return {
203
- passed: margin >= 0.05, value: pct(margin), unit: "%", threshold: 5, direction: "min", years: n,
204
- ...ruleProvenance([ni.slice(-n), rev.slice(-n)]),
203
+ passed: margin >= 0.05, value: pct(margin), unit: "%", threshold: 5, direction: "min", years: pairs.length,
204
+ ...ruleProvenance([pairs.map(([n]) => n), pairs.map(([, r]) => r)]),
205
205
  };
206
206
  });
207
207
 
package/mcp/lib/sec.mjs CHANGED
@@ -97,20 +97,35 @@ export async function fetchFilingIndex(cik, { signal } = {}) {
97
97
  return { cik: padded, name: data?.name || null, filings: rows };
98
98
  }
99
99
 
100
- /** One filing document, fetched as text. Same throttle and User-Agent as every other call. */
100
+ /**
101
+ * One filing document, fetched as text. Same throttle, User-Agent and 429 backoff as the JSON
102
+ * calls.
103
+ *
104
+ * The backoff was the part missing here. Every JSON endpoint retried a rate-limited response
105
+ * while this one threw on the first 429, so the documents that carry the actual disclosure --
106
+ * a Form 4's transaction table, an 8-K's item text -- were the easiest evidence in the run to
107
+ * lose. `www.sec.gov/Archives` is also throttled harder than `data.sec.gov`, which makes this
108
+ * the path most likely to be limited and was the least protected against it.
109
+ */
101
110
  export async function fetchFilingDocument(cik, accession, document, { signal } = {}) {
102
- await throttle();
103
111
  const stripped = String(cik).replace(/\D/gu, "").replace(/^0+/u, "");
104
112
  const folder = String(accession).replace(/-/gu, "");
105
113
  const url = `https://www.sec.gov/Archives/edgar/data/${stripped}/${folder}/${document}`;
106
- const abort = linkedAbort(LIMITS.QUOTE_FETCH_MS * 2, signal);
107
- try {
108
- const res = await fetch(url, { signal: abort.signal, headers: { "User-Agent": UA } });
109
- if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
110
- return { url, text: await res.text() };
111
- } finally {
112
- abort.cleanup();
113
- }
114
+ const outcome = await withRateLimitRetry(async () => {
115
+ await throttle();
116
+ const abort = linkedAbort(LIMITS.QUOTE_FETCH_MS * 2, signal);
117
+ try {
118
+ const res = await fetch(url, { signal: abort.signal, headers: { "User-Agent": UA } });
119
+ if (isRateLimited(res.status)) {
120
+ return { rateLimited: true, error: new Error(`HTTP ${res.status} for ${url}`) };
121
+ }
122
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
123
+ return { rateLimited: false, value: { url, text: await res.text() } };
124
+ } finally {
125
+ abort.cleanup();
126
+ }
127
+ });
128
+ return outcome.value;
114
129
  }
115
130
 
116
131
  /** The full US listed universe: ~10k entries of {cik, ticker, title}. */
@@ -119,8 +119,11 @@ function copyFor(language) {
119
119
  *
120
120
  * `result` is the frozen `structured_decision.result`; `policy` is the seat's decision policy,
121
121
  * which supplies the human-readable condition ids the result refers to by index.
122
+ * `readableIds` maps each anonymised id back to the declared one exactly; prefer it over the
123
+ * positional fallback, which assumes the executor's hits/misses/uncomputable split happens to
124
+ * come back in declaration order and silently swaps two condition names when it does not.
122
125
  */
123
- export function voiceFromDecision({ result, policy, factPack, language = "English" } = {}) {
126
+ export function voiceFromDecision({ result, policy, factPack, readableIds, language = "English" } = {}) {
124
127
  const copy = copyFor(language);
125
128
  const facts = new Map((factPack?.facts || []).map((fact) => [fact.fact_id, fact]));
126
129
  const stance = result?.common_projection?.stance || "out_of_scope";
@@ -136,7 +139,7 @@ export function voiceFromDecision({ result, policy, factPack, language = "Englis
136
139
  const computed = (result?.computations?.trace || [])
137
140
  .filter((step) => step.status === "computed" && finite(step.value))
138
141
  .slice(0, MAX_ITEMS)
139
- .map((step) => `${step.output_id} = ${Number(step.value.toFixed(4))}`)
142
+ .map((step) => `${readableIds?.get?.(step.output_id) || step.output_id} = ${Number(step.value.toFixed(4))}`)
140
143
  .join("; ");
141
144
 
142
145
  // The executor publishes the rules it evaluated already split into hits, misses and the ones
@@ -157,13 +160,18 @@ export function voiceFromDecision({ result, policy, factPack, language = "Englis
157
160
  .map((rule) => rule.rule_id)
158
161
  .filter(Boolean)
159
162
  .map((id, index) => [id, index]));
163
+ const declaredName = (anon) => (anon && readableIds?.get?.(anon)) || null;
160
164
  const ruleName = (rule) => {
161
165
  const anon = rule.rule_id || rule.id;
162
- const readable = readableRuleIds.get(anonOrder.get(anon));
163
- return readable || anon || "rule";
166
+ return declaredName(anon) || readableRuleIds.get(anonOrder.get(anon)) || anon || "rule";
164
167
  };
165
168
 
166
- const vetoes = (result?.vetoes_triggered || []).map((veto) => veto.veto_id).filter(Boolean);
169
+ // Vetoes went unmapped, so a hashed veto reached the reader while the scoring conditions
170
+ // beside it were readable. That asymmetry is what made two seats speculate about their own
171
+ // hard veto instead of naming it.
172
+ const vetoes = (result?.vetoes_triggered || [])
173
+ .map((veto) => declaredName(veto.veto_id) || veto.veto_id)
174
+ .filter(Boolean);
167
175
 
168
176
  const whatISee = [
169
177
  readList ? copy.readFacts(readList) : copy.noFacts,
@@ -76,8 +76,15 @@ export function resolveSeatWeights(run, overrides = {}) {
76
76
 
77
77
  const debateIds = reg.ids("debate").filter((id) => id !== "portfolio_manager");
78
78
  const masterIds = (run.master_opinions || []).map((o) => o.master);
79
-
80
- for (const id of [...debateIds, ...masterIds]) {
79
+ // Evidence seats belong here too. The contract says a failed verification down-weights the
80
+ // seat that made the claim, but the enumeration stopped at debate and master seats, so a
81
+ // `contradicted` verdict against an analyst was recorded and then silently ignored.
82
+ const evidenceIds = [
83
+ ...(run.tasks || []),
84
+ ...(run.verifier_verdicts || []).map((v) => v?.seat).filter(Boolean),
85
+ ];
86
+
87
+ for (const id of new Set([...debateIds, ...masterIds, ...evidenceIds])) {
81
88
  const persona = reg.get(id);
82
89
  if (!persona) continue;
83
90
  const declared = baseWeight(persona);
@@ -29,9 +29,14 @@ export const MAX_ALIGNMENT_DRIFT_DAYS = 45;
29
29
  /**
30
30
  * Concept aliases, ordered by preference, on top of the ones sec.mjs already publishes.
31
31
  *
32
- * Aliases inside one entry are the same economic quantity under different taxonomies. They
33
- * are never a fallback to a different concept: total liabilities is not reconstructed from
34
- * an equity subtraction, and a missing depreciation tag stays missing.
32
+ * Aliases inside one entry are the same economic quantity under different taxonomies. They are
33
+ * never a fallback to a different concept: a missing depreciation tag stays missing.
34
+ *
35
+ * Total liabilities has one sanctioned reconstruction and it is guarded. Subtracting equity
36
+ * from the balance-sheet total is wrong when the two tags disagree about non-controlling
37
+ * interests -- the difference then lands inside "liabilities", silently. Done against the
38
+ * INCLUDING-NCI equity tag the identity is exact, so that tag, and only that tag, may stand in
39
+ * where a filer never tags `Liabilities` directly. See `totalLiabilitiesFor`.
35
40
  */
36
41
  export const FUNDAMENTAL_CONCEPTS = Object.freeze({
37
42
  revenue: { tags: CONCEPTS.revenue, unit: "USD" },
@@ -50,6 +55,8 @@ export const FUNDAMENTAL_CONCEPTS = Object.freeze({
50
55
  },
51
56
  current_assets: { tags: ["AssetsCurrent"], unit: "USD" },
52
57
  total_liabilities: { tags: ["Liabilities"], unit: "USD" },
58
+ liabilities_and_equity: { tags: ["LiabilitiesAndStockholdersEquity"], unit: "USD" },
59
+ equity_including_nci: { tags: ["StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest"], unit: "USD" },
53
60
  goodwill: { tags: ["Goodwill"], unit: "USD" },
54
61
  intangibles: { tags: ["IntangibleAssetsNetExcludingGoodwill", "FiniteLivedIntangibleAssetsNet"], unit: "USD" },
55
62
  cash: {
@@ -61,9 +68,28 @@ export const FUNDAMENTAL_CONCEPTS = Object.freeze({
61
68
  },
62
69
  // Debt buckets are non-overlapping by construction so they can be summed. `debt_combined`
63
70
  // is the whole of it in one tag and wins outright when a filer publishes it.
71
+ //
72
+ // Within a bucket the tags are ALIASES, not addends: an earlier alias wins a year outright.
73
+ // The convertible aliases are therefore appended last, which makes them strictly additive --
74
+ // a filer that already resolves a bucket resolves it to exactly the same number as before,
75
+ // and only a filer whose sole balance-sheet debt line is a convertible note changes at all.
76
+ //
77
+ // That filer is not rare. ServiceNow's FY2020 and FY2025 balance sheets carry no straight
78
+ // debt tag whatsoever: the only debt instant either year is ConvertibleLongTermNotesPayable
79
+ // ($1.64bn and $1.491bn). Total debt therefore read as unknown, and because unknown debt is
80
+ // correctly refused rather than treated as zero, three separate facts died from one absent
81
+ // alias -- leverage, downside asset value, and incremental return on capital -- which in turn
82
+ // is why a seat requiring incremental return on capital abstained on a company whose debt is
83
+ // plainly stated on the face of its balance sheet.
64
84
  debt_combined: { tags: ["DebtLongtermAndShorttermCombinedAmount"], unit: "USD" },
65
- debt_long_term_noncurrent: { tags: ["LongTermDebtNoncurrent", "LongTermDebtAndCapitalLeaseObligations"], unit: "USD" },
66
- debt_long_term_current: { tags: ["LongTermDebtCurrent", "LongTermDebtAndCapitalLeaseObligationsCurrent"], unit: "USD" },
85
+ debt_long_term_noncurrent: {
86
+ tags: ["LongTermDebtNoncurrent", "LongTermDebtAndCapitalLeaseObligations", "ConvertibleLongTermNotesPayable"],
87
+ unit: "USD",
88
+ },
89
+ debt_long_term_current: {
90
+ tags: ["LongTermDebtCurrent", "LongTermDebtAndCapitalLeaseObligationsCurrent", "ConvertibleNotesPayableCurrent"],
91
+ unit: "USD",
92
+ },
67
93
  debt_short_term: { tags: ["ShortTermBorrowings", "OtherShortTermBorrowings", "CommercialPaper"], unit: "USD" },
68
94
  tax_expense: { tags: ["IncomeTaxExpenseBenefit"], unit: "USD" },
69
95
  pretax_income: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alphacouncil-agent",
3
- "version": "1.0.3",
3
+ "version": "1.0.13",
4
4
  "description": "Multi-agent company, ETF and market-index research workflow for Codex & Claude Code: sourced evidence, method seats, bull/bear debate and portfolio-manager synthesis.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -127,6 +127,7 @@
127
127
  ".opencode/command/",
128
128
  ".grok/commands/",
129
129
  "docs/INSTALL.md",
130
+ "docs/examples/",
130
131
  "docs/plans/",
131
132
  "docs/releases/",
132
133
  "docs/evaluation/",