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
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);
@@ -68,9 +68,28 @@ export const FUNDAMENTAL_CONCEPTS = Object.freeze({
68
68
  },
69
69
  // Debt buckets are non-overlapping by construction so they can be summed. `debt_combined`
70
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.
71
84
  debt_combined: { tags: ["DebtLongtermAndShorttermCombinedAmount"], unit: "USD" },
72
- debt_long_term_noncurrent: { tags: ["LongTermDebtNoncurrent", "LongTermDebtAndCapitalLeaseObligations"], unit: "USD" },
73
- 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
+ },
74
93
  debt_short_term: { tags: ["ShortTermBorrowings", "OtherShortTermBorrowings", "CommercialPaper"], unit: "USD" },
75
94
  tax_expense: { tags: ["IncomeTaxExpenseBenefit"], unit: "USD" },
76
95
  pretax_income: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alphacouncil-agent",
3
- "version": "1.0.4",
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/",
@@ -55,6 +55,25 @@
55
55
  四、**股权稀释**
56
56
  在外流通股数的多年变化。持续稀释会让每股口径的所有增长打折,而这一点在讨论增长时常被忽略。
57
57
 
58
+ ## 怎么取到原文(EDGAR 会限流,这一节决定这个席位有没有真凭据)
59
+
60
+ EDGAR 对没有描述性 User-Agent 的客户端返回 **HTTP 429**,而且 `www.sec.gov` 比 `data.sec.gov` 限得更狠。**连续换 URL 猛试只会让限流变严**——一次真实运行里连试 8 个不同 URL 全部 429,最后一份原文都没读到。
61
+
62
+ 按这个顺序取,取到就停:
63
+
64
+ 1. `https://data.sec.gov/submissions/CIK##########.json`(10 位补零)—— 申报索引,JSON API 最宽容,一次拿到全部表单类型、accession 与 filed date。
65
+ 2. `https://data.sec.gov/api/xbrl/companyfacts/CIK##########.json` —— 股数、股东权益这类 XBRL 数字。
66
+ 3. `https://www.sec.gov/Archives/edgar/data/<无前导零CIK>/<去横线accession>/<主文档>` —— 具体那一份 Form 4 / 8-K 原文。
67
+ 4. EDGAR 全文检索与 `cgi-bin/browse-edgar` **放到最后**:它们最容易被限流,且能给的信息前三步都能给。
68
+
69
+ 遇到 429:**停下来等几秒再重试同一个 URL**,不要立刻换别的 URL。同一份文档最多重试三次;三次都 429 就记为取回失败,进入下面的降级纪律。
70
+
71
+ **降级纪律(必须照做,不许含糊)**:一份原文都没读到时,
72
+ - `summary` 第一句就写明「本包未直读任何 EDGAR 原文,全部内容经二级来源交叉核实」;
73
+ - `confidence` 最高只能是 `low`;
74
+ - 在 `open_questions` 里逐条列出失败的 URL 与状态码;
75
+ - 每条来自镜像/聚合站的内容都标注它是二手,**绝不能把镜像内容写成「已按申报原文核实」**。
76
+
58
77
  ## 硬纪律
59
78
 
60
79
  - **每条必须带申报链接和申报日期**(filed date,不是期间结束日)。
@@ -87,6 +106,25 @@ Key items: 1.01 material agreement, 2.02 results, 4.01 auditor change, 4.02 prio
87
106
  4. **Dilution**
88
107
  Shares outstanding across several years. Persistent dilution discounts every per-share growth figure, and that is routinely ignored in growth discussions.
89
108
 
109
+ ## How to actually retrieve the filings (EDGAR throttles; this section decides whether this seat has primary evidence at all)
110
+
111
+ EDGAR answers **HTTP 429** to a client without a descriptive User-Agent, and `www.sec.gov` is throttled harder than `data.sec.gov`. **Rapidly trying different URLs makes the throttling worse, not better** -- on one real run eight different URLs were tried in a row, all returned 429, and not a single filing was read.
112
+
113
+ Retrieve in this order and stop as soon as you have what you need:
114
+
115
+ 1. `https://data.sec.gov/submissions/CIK##########.json` (CIK zero-padded to ten digits) -- the filing index. The most tolerant endpoint, and one call returns every form type, accession number and filed date.
116
+ 2. `https://data.sec.gov/api/xbrl/companyfacts/CIK##########.json` -- XBRL numbers such as share count and stockholders' equity.
117
+ 3. `https://www.sec.gov/Archives/edgar/data/<CIK without leading zeros>/<accession without dashes>/<primary document>` -- the specific Form 4 or 8-K text.
118
+ 4. EDGAR full-text search and `cgi-bin/browse-edgar` **last**: they are throttled the hardest and tell you nothing the first three cannot.
119
+
120
+ On a 429: **wait a few seconds and retry the same URL** rather than immediately switching to a different one. Retry one document at most three times; if all three are limited, record the retrieval as failed and follow the degradation rule below.
121
+
122
+ **Degradation rule (mandatory, and not to be softened)** -- when no filing was read directly:
123
+ - the first sentence of `summary` must state that no EDGAR original was read and that everything was cross-checked through secondary sources instead;
124
+ - `confidence` is capped at `low`;
125
+ - `open_questions` lists each failed URL with its status code;
126
+ - every item taken from a mirror or aggregator is labelled second-hand. **Never write mirror content as "verified against the filing".**
127
+
90
128
  ## Hard rules
91
129
 
92
130
  - **Every item carries the filing link and the filed date** -- the filed date, not the period end.
@@ -501,21 +501,27 @@ function buildDocuments({ seat, blueprint, rawTools, packVersion, formulaManifes
501
501
  ja: `${publicTitle.en} 単独テスト`,
502
502
  ko: `${publicTitle.en} 단독 테스트`,
503
503
  },
504
+ // The owner reviewed and accepted these method attributions, so the catalog title no
505
+ // longer repeats a review-status warning next to every name. The machine-verified
506
+ // admission level is unchanged and still published; the label is just the method's name.
504
507
  operator_label: {
505
- en: `${publicTitle.en} Provisional Operator Lens`,
506
- zh: `${publicTitle.zh}临时操作视角`,
507
- ja: `${publicTitle.en} 暫定オペレーター・レンズ`,
508
- ko: `${publicTitle.en} 임시 오퍼레이터 렌즈`,
508
+ en: publicTitle.en,
509
+ zh: publicTitle.zh,
510
+ ja: `${publicTitle.en}・メソッドレンズ`,
511
+ ko: `${publicTitle.en} 방법론 렌즈`,
509
512
  },
510
513
  maturity: "operator_lens",
511
514
  source_cutoff: SOURCE_DATE,
512
515
  },
513
516
  selection: {
517
+ // What a seat IS, not its review history. "Not the person's own words" stays because it
518
+ // guards against impersonation; the review-status clause is gone because the owner signed
519
+ // off on these attributions and a warning repeated 27 times stops being a warning.
514
520
  identity: {
515
- en: `${publicTitle.en}; project-derived and not human reviewed`,
516
- zh: `${publicTitle.zh};项目派生、未经人工审定`,
517
- ja: `${publicTitle.en}。プロジェクト派生で、方法帰属について人による審査を受けていない暫定オペレーター・レンズ。本人の発言や現在の見解ではない。`,
518
- ko: `${publicTitle.en}. 프로젝트에서 파생되었고 방법 귀속에 대한 인적 심사를 거치지 않은 임시 오퍼레이터 렌즈다. 본인의 발언이나 현재 견해가 아니다.`,
521
+ en: `${publicTitle.en}; a method model, not the person's own words`,
522
+ zh: `${publicTitle.zh};方法模型,非本人言论`,
523
+ ja: `${publicTitle.en}。メソッドモデルであり、本人の発言や現在の見解ではない。`,
524
+ ko: `${publicTitle.en}. 방법론 모델이며, 본인의 발언이나 현재 견해가 아니다.`,
519
525
  },
520
526
  method: {
521
527
  en: seat.method_scope.planning_hypothesis,
@@ -46,8 +46,8 @@ export function usage() {
46
46
  return [
47
47
  "Usage: node scripts/review-persona-v3-solo-formulas.mjs [--check|--plan|--write] [--json]",
48
48
  "",
49
- "--plan recompute 52 machine review records without reading the review tree",
50
- "--write write 52 reviews plus the manifest below isolated staging",
49
+ "--plan recompute the planned machine review records without reading the review tree",
50
+ "--write write every planned review plus the manifest below isolated staging",
51
51
  "--check require exact physical files and byte-stable recomputation (default)",
52
52
  "--formula-root override the physical solo-test formula tree",
53
53
  "--review-root override the isolated AI review tree",
@@ -93,7 +93,9 @@ export function main(argv = process.argv.slice(2)) {
93
93
  if (args.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
94
94
  else process.stdout.write(compact(result));
95
95
  const okay = result.review_count === PLANNED_TOOL_COUNT
96
- && result.role_count === 156
96
+ // Derived, not hardcoded: the literal 156 (26 seats x 2 tools x 3 roles) survived the
97
+ // twenty-seventh seat and failed a review tree its own output line printed as 162/162.
98
+ && result.role_count === PLANNED_TOOL_COUNT * INDEPENDENT_ROLES_PER_REVIEW
97
99
  && result.mechanical_pass_count === PLANNED_TOOL_COUNT
98
100
  && result.disagreement_count === 0
99
101
  && result.semantic_unknown_count === PLANNED_TOOL_COUNT