alphacouncil-agent 1.0.1 → 1.0.3

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 (48) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +82 -0
  5. package/README.md +24 -15
  6. package/README.zh-CN.md +18 -10
  7. package/data/authored/core-seats.mjs +11 -7
  8. package/data/authored/quant-seats.mjs +2 -2
  9. package/data/authored/value-seats.mjs +4 -4
  10. package/data/build-profile.v1.json +1 -1
  11. package/data/persona-v3-build-specs.v1.mjs +1 -1
  12. package/docs/plans/v1.1-say-something-worth-reading.md +116 -0
  13. package/knowledge/ai-assisted-solo/experiments/runs/a.json +2 -2
  14. package/knowledge/ai-assisted-solo/experiments/runs/b.json +2 -2
  15. package/knowledge/ai-assisted-solo/experiments/runs/c.json +2 -2
  16. package/knowledge/ai-assisted-solo/experiments/runs/d13.json +15 -15
  17. package/knowledge/ai-assisted-solo/experiments/runs/d26.json +27 -27
  18. package/knowledge/ai-assisted-solo/experiments/runs/e-d13.json +18 -18
  19. package/knowledge/ai-assisted-solo/experiments/runs/e-d26.json +30 -30
  20. package/knowledge/ai-assisted-solo/experiments/runs/h_ai_reference.json +31 -31
  21. package/knowledge/ai-assisted-solo/experiments/simulation-input.json +9 -9
  22. package/knowledge/ai-assisted-solo/experiments/simulation-manifest.json +19 -19
  23. package/knowledge/solo-test/masters/master_bogle/decision_policy.json +4 -3
  24. package/knowledge/solo-test/masters/master_bogle/provisional-index.json +3 -0
  25. package/knowledge/solo-test/masters/master_bogle/sources.jsonl +1 -1
  26. package/knowledge/solo-test/masters/master_graham/decision_policy.json +1 -1
  27. package/knowledge/solo-test/masters/master_klarman/decision_policy.json +1 -1
  28. package/knowledge/solo-test/masters/master_pabrai/decision_policy.json +1 -1
  29. package/mcp/lib/basket-news.mjs +181 -0
  30. package/mcp/lib/breadth.mjs +24 -0
  31. package/mcp/lib/constants.mjs +3 -0
  32. package/mcp/lib/cross-market.mjs +180 -0
  33. package/mcp/lib/fundamentals.mjs +11 -0
  34. package/mcp/lib/grounding.mjs +101 -3
  35. package/mcp/lib/industry.mjs +23 -2
  36. package/mcp/lib/insider-ownership.mjs +23 -12
  37. package/mcp/lib/instrument-facts.mjs +3 -0
  38. package/mcp/lib/markdown.mjs +41 -8
  39. package/mcp/lib/orchestrator.mjs +10 -2
  40. package/mcp/lib/personas/engine.mjs +66 -9
  41. package/mcp/lib/personas-v3/deterministic-executor.mjs +39 -2
  42. package/mcp/lib/personas-v3/grounding-adapter.mjs +143 -0
  43. package/mcp/lib/personas-v3/runtime.mjs +9 -2
  44. package/mcp/lib/screen.mjs +27 -1
  45. package/mcp/lib/sec.mjs +80 -17
  46. package/mcp/lib/voice-from-decision.mjs +211 -0
  47. package/package.json +1 -1
  48. package/scripts/lib/persona-v3-solo-formula-pipeline.mjs +11 -0
@@ -781,6 +781,17 @@ export function deriveFundamentals({ companyFacts, asOf = null, maintenanceCapex
781
781
  });
782
782
  }
783
783
 
784
+ /**
785
+ * True when a registrant has filed no XBRL at all.
786
+ *
787
+ * SEC's own ticker file maps XOM to ExxonMobil Holdings Corp -- a newly formed entity with zero
788
+ * us-gaap tags -- while the operating history sits under a different CIK. Every metric then
789
+ * reported as missing, individually, and nothing said the registrant itself was empty.
790
+ */
791
+ export function hasNoXbrlHistory(companyFacts) {
792
+ return Object.keys(companyFacts?.facts?.["us-gaap"] || {}).length === 0;
793
+ }
794
+
784
795
  /**
785
796
  * Thin fetching wrapper: resolve the filer, pull Company Facts through the throttled and
786
797
  * User-Agent-bearing sec.mjs client, then hand the JSON to the pure function above.
@@ -5,7 +5,28 @@ import { fetchMacroSeries } from "./fred.mjs";
5
5
  import { fetchFundamentals } from "./fundamentals.mjs";
6
6
  import { fetchInsiderOwnership } from "./insider-ownership.mjs";
7
7
  import { INDEX_PROXIES, normalizeIndexSymbol } from "./index-aggregate.mjs";
8
+ import { SECTOR_SPDRS, fetchCrossMarket, fetchSectorDispersion } from "./cross-market.mjs";
9
+ import { fetchBasketNews } from "./basket-news.mjs";
8
10
  import { gatherInstrumentFacts, LOOK_THROUGH_FACT_IDS } from "./instrument-facts.mjs";
11
+
12
+ /** The index a US company is measured against when a method asks what the market costs. */
13
+ const BROAD_MARKET_INDEX = "^GSPC";
14
+
15
+ /**
16
+ * Facts that describe the market rather than the subject.
17
+ *
18
+ * Everything else the index block produces -- leverage, growth, breadth of a basket -- is about
19
+ * a portfolio the company is not, and forwarding it would answer a different question in the
20
+ * subject's name.
21
+ */
22
+ const MARKET_LEVEL_FACTS = new Set([
23
+ "index.aggregate_pe_ttm",
24
+ "index.aggregate_pe_forward",
25
+ "index.aggregate_earnings_yield",
26
+ "index.dividend_yield",
27
+ "valuation.implied_erp",
28
+ "cycle.valuation_percentile",
29
+ ]);
9
30
  import { fetchOptionsChain } from "./options.mjs";
10
31
  import { screenTicker } from "./screen.mjs";
11
32
  import { resolveIndustry, industryCoverage } from "./industry.mjs";
@@ -93,6 +114,7 @@ export async function gatherGrounding({
93
114
  now = new Date(),
94
115
  language = "English",
95
116
  signal,
117
+ budgetMs = null,
96
118
  } = {}) {
97
119
  const snapshotPolicy = liveSnapshotPolicy(asOf, { now });
98
120
  const gatheredAt = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
@@ -200,6 +222,22 @@ export async function gatherGrounding({
200
222
  }));
201
223
  }
202
224
 
225
+ // What else is this a bet on. Only for baskets: a single company's correlation to KOSPI is a
226
+ // fact about its sector, not about the company, and the seats that reason about crowding and
227
+ // position size are asking about the basket.
228
+ if (symbol && isFundOrIndex(out.instrument)) {
229
+ jobs.push(safely("cross-market", () => fetchCrossMarket(symbol, { signal })).then((r) => {
230
+ if (!r.ok) { out.unavailable.push(r.error); return; }
231
+ if (r.value.facts.length) out.cross_market = r.value.facts;
232
+ out.unavailable.push(...r.value.unavailable);
233
+ }));
234
+ jobs.push(safely("sector dispersion", () => fetchSectorDispersion(SECTOR_SPDRS, { signal })).then((r) => {
235
+ if (!r.ok) { out.unavailable.push(r.error); return; }
236
+ if (r.value.available) out.sector_dispersion = r.value;
237
+ out.unavailable.push(...(r.value.unavailable || []));
238
+ }));
239
+ }
240
+
203
241
  if (cik) {
204
242
  if (snapshotPolicy.allowed) {
205
243
  // Resolve the registrant before deciding whether Company Facts applies. Scheduling
@@ -302,11 +340,32 @@ export async function gatherGrounding({
302
340
  out.unavailable.push(`structured financials for ${symbol}: this adapter is not point-in-time versioned; current data was not fetched for a historical cutoff`);
303
341
  }
304
342
 
343
+ // The market's own valuation, for every US subject rather than only for baskets.
344
+ //
345
+ // Three seats require `index.aggregate_earnings_yield` and reported it missing on every
346
+ // single-company run this product has ever done. They were right to ask: the market's
347
+ // aggregate earnings yield is not a property of the subject, it is the yardstick the subject
348
+ // is measured against. Damodaran's implied premium needs it, Asness's Fed-model critique IS
349
+ // about it, and Marks reads it as where the cycle stands. The subject decides whose holdings
350
+ // get read; it does not decide whether the market has a price-earnings ratio.
351
+ if (symbol && !isFundOrIndex(out.instrument) && symbolMarket?.id === "US" && snapshotPolicy.allowed) {
352
+ jobs.push(safely("market valuation", () => gatherInstrumentFacts({
353
+ symbol: BROAD_MARKET_INDEX, instrument: { research_model: "index_aggregate", index_like: true },
354
+ asOf, signal, lookThroughFactIds: [],
355
+ })).then((r) => {
356
+ if (!r.ok) { out.unavailable.push(r.error); return; }
357
+ // Only the market-level block. A company's own leverage and growth come from its filings,
358
+ // and taking them from the index would be silently answering a different question.
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 };
361
+ }));
362
+ }
363
+
305
364
  // A fund or index has no issuer financials, so this is where its evidence comes from
306
365
  // instead: published holdings, index-level valuation and the look-through aggregates that
307
366
  // let an operating-company method run against a basket at all.
308
367
  if (symbol && isFundOrIndex(out.instrument) && snapshotPolicy.allowed) {
309
- jobs.push(safely("instrument aggregate", () => gatherInstrumentFacts({
368
+ const instrumentJob = safely("instrument aggregate", () => gatherInstrumentFacts({
310
369
  symbol, instrument: out.instrument, asOf, signal,
311
370
  // The operating-company facts a basket can supply at all: everything the method seats
312
371
  // ask of a company, aggregated by weight across the constituents that publish it.
@@ -315,7 +374,21 @@ export async function gatherGrounding({
315
374
  if (!r.ok) { out.unavailable.push(r.error); return; }
316
375
  out.instrument_aggregate = r.value;
317
376
  out.unavailable.push(...(r.value.unavailable || []));
318
- }));
377
+ });
378
+ jobs.push(instrumentJob);
379
+ // A basket has no press office and files nothing, so its news comes from what it holds.
380
+ // Chained after the instrument pass rather than run beside it, because the holdings are
381
+ // what name the industry -- fetching them twice to avoid the wait would cost more than it
382
+ // saves.
383
+ jobs.push((async () => {
384
+ await instrumentJob;
385
+ const holdings = out.instrument_aggregate?.holdings;
386
+ if (!holdings?.length) return;
387
+ const news = await safely("basket news", () => fetchBasketNews(holdings, { asOf, signal }));
388
+ if (!news.ok) { out.unavailable.push(news.error); return; }
389
+ if (news.value.available) out.basket_news = news.value;
390
+ out.unavailable.push(...(news.value.unavailable || []));
391
+ })());
319
392
  }
320
393
 
321
394
  if (symbol && isFundOrIndex(out.instrument)) {
@@ -347,7 +420,32 @@ export async function gatherGrounding({
347
420
  out.unavailable.push("industry map: the curated map is not publication-versioned and was excluded from the historical information set");
348
421
  }
349
422
 
350
- await Promise.all(jobs);
423
+ // Return what arrived, not nothing.
424
+ //
425
+ // A caller that raced this whole function against a timer threw away a completed quote, a
426
+ // completed screen and a completed filing set because one slow feed had not landed -- and the
427
+ // analysts downstream, handed an empty object, reported "no ticker was provided". The symbol
428
+ // had been provided; the fetch had not finished. Settling at the budget keeps everything that
429
+ // did arrive and names what did not.
430
+ if (Number.isFinite(budgetMs) && budgetMs > 0) {
431
+ let settled = false;
432
+ const all = Promise.all(jobs).then(() => { settled = true; });
433
+ let timer;
434
+ await Promise.race([
435
+ all,
436
+ new Promise((resolve) => { timer = setTimeout(resolve, budgetMs); }),
437
+ ]);
438
+ clearTimeout(timer);
439
+ if (!settled) {
440
+ out.partial = true;
441
+ out.unavailable.push(
442
+ `grounding budget of ${Math.round(budgetMs)}ms elapsed before every source answered;`
443
+ + " this run carries what arrived by then and names the rest as gaps rather than discarding it",
444
+ );
445
+ }
446
+ } else {
447
+ await Promise.all(jobs);
448
+ }
351
449
 
352
450
  // Coverage across every symbol in play, so a report cannot quietly become US-only.
353
451
  const inPlay = [symbol, ...(out.industry?.participants || []).map((p) => p.symbol)].filter(Boolean);
@@ -119,24 +119,45 @@ export const SIC_GROUPS = [
119
119
  { id: "mining_energy", range: [1000, 1499], title: { zh: "采矿与能源", en: "Mining and energy" } },
120
120
  { id: "construction", range: [1500, 1799], title: { zh: "建筑", en: "Construction" } },
121
121
  { id: "food_beverage", range: [2000, 2199], title: { zh: "食品饮料", en: "Food and beverage" } },
122
+ { id: "textiles_apparel", range: [2200, 2399], title: { zh: "纺织服装", en: "Textiles and apparel" } },
123
+ { id: "paper_packaging", range: [2400, 2679], title: { zh: "造纸与包装", en: "Paper and packaging" } },
124
+ { id: "publishing", range: [2700, 2799], title: { zh: "出版印刷", en: "Publishing and printing" } },
122
125
  { id: "chemicals", range: [2800, 2899], title: { zh: "化工", en: "Chemicals" } },
123
126
  { id: "pharma_biotech", range: [2833, 2836], title: { zh: "医药与生物科技", en: "Pharma and biotech" } },
124
127
  { id: "energy_refining", range: [2900, 2999], title: { zh: "炼化", en: "Refining" } },
128
+ { id: "rubber_plastics", range: [3000, 3199], title: { zh: "橡胶塑料", en: "Rubber and plastics" } },
129
+ { id: "building_materials", range: [3200, 3299], title: { zh: "建材", en: "Building materials" } },
125
130
  { id: "metals", range: [3300, 3399], title: { zh: "金属", en: "Metals" } },
131
+ { id: "fabricated_metal", range: [3400, 3499], title: { zh: "金属制品", en: "Fabricated metal products" } },
126
132
  { id: "machinery", range: [3500, 3569], title: { zh: "机械", en: "Machinery" } },
127
133
  { id: "computers_hardware", range: [3570, 3579], title: { zh: "计算机硬件", en: "Computers and hardware" } },
134
+ { id: "industrial_equipment", range: [3580, 3599], title: { zh: "工业设备", en: "Industrial equipment" } },
128
135
  { id: "electronics", range: [3600, 3673], title: { zh: "电子", en: "Electronics" } },
129
136
  { id: "semiconductors", range: [3674, 3674], title: { zh: "半导体", en: "Semiconductors" } },
130
- { id: "instruments_medical", range: [3820, 3873], title: { zh: "仪器与医疗器械", en: "Instruments and medical devices" } },
137
+ { id: "electrical_equipment", range: [3675, 3699], title: { zh: "电气设备", en: "Electrical equipment" } },
138
+ { id: "autos", range: [3700, 3719], title: { zh: "汽车", en: "Automobiles" } },
139
+ { id: "aerospace_defense", range: [3720, 3799], title: { zh: "航空航天与国防", en: "Aerospace and defense" } },
140
+ { id: "instruments_medical", range: [3800, 3879], title: { zh: "仪器与医疗器械", en: "Instruments and medical devices" } },
141
+ { id: "manufacturing_other", range: [3880, 3999], title: { zh: "其他制造", en: "Other manufacturing" } },
131
142
  { id: "transportation", range: [4000, 4799], title: { zh: "运输", en: "Transportation" } },
132
143
  { id: "telecom", range: [4800, 4899], title: { zh: "电信", en: "Telecom" } },
133
144
  { id: "utilities", range: [4900, 4999], title: { zh: "公用事业", en: "Utilities" } },
145
+ { id: "wholesale", range: [5000, 5199], title: { zh: "批发分销", en: "Wholesale and distribution" } },
134
146
  { id: "retail", range: [5200, 5999], title: { zh: "零售", en: "Retail" } },
135
- { id: "banks", range: [6020, 6199], title: { zh: "银行", en: "Banks" } },
147
+ { id: "banks", range: [6000, 6199], title: { zh: "银行", en: "Banks" } },
148
+ { id: "brokers_asset_managers", range: [6200, 6299], title: { zh: "券商与资产管理", en: "Brokers and asset managers" } },
136
149
  { id: "insurance", range: [6300, 6411], title: { zh: "保险", en: "Insurance" } },
150
+ { id: "financial_services", range: [6412, 6499], title: { zh: "金融服务", en: "Financial services" } },
137
151
  { id: "real_estate", range: [6500, 6799], title: { zh: "房地产", en: "Real estate" } },
152
+ { id: "hospitality_leisure", range: [7000, 7099], title: { zh: "酒店与休闲", en: "Hospitality and leisure" } },
153
+ { id: "business_services", range: [7100, 7369], title: { zh: "商业服务", en: "Business services" } },
138
154
  { id: "software", range: [7370, 7379], title: { zh: "软件与IT服务", en: "Software and IT services" } },
155
+ { id: "consumer_services", range: [7380, 7799], title: { zh: "消费服务", en: "Consumer services" } },
156
+ { id: "media_entertainment", range: [7800, 7999], title: { zh: "影视与娱乐", en: "Media and entertainment" } },
139
157
  { id: "healthcare_services", range: [8000, 8099], title: { zh: "医疗服务", en: "Healthcare services" } },
158
+ { id: "professional_services", range: [8100, 8730], title: { zh: "专业服务", en: "Professional services" } },
159
+ { id: "research_services", range: [8731, 8734], title: { zh: "研究服务", en: "Research services" } },
160
+ { id: "other_services", range: [8735, 8999], title: { zh: "其他服务", en: "Other services" } },
140
161
  ];
141
162
 
142
163
  export function sicGroupFor(sic) {
@@ -26,6 +26,9 @@ const OWNERSHIP_FORMS = new Set(["3", "4", "5", "3/A", "4/A", "5/A"]);
26
26
  /** A registrant with more insiders than this is not a governance question, it is a bad parse. */
27
27
  const MAX_DISTINCT_OWNERS = 60;
28
28
 
29
+ /** Filings read per round. SEC's ~10 req/s guidance is enforced by the client's own throttle. */
30
+ const OWNERSHIP_BATCH = 8;
31
+
29
32
  /** Below this share of the register the aggregate says nothing, so it refuses instead. */
30
33
  export const MIN_OWNER_COVERAGE = 1;
31
34
 
@@ -105,19 +108,27 @@ export async function fetchInsiderOwnership(cik, { sharesOutstanding, asOf = nul
105
108
  const byOwner = new Map();
106
109
  const sources = [];
107
110
  let newestFiling = null;
108
- for (const filing of ownershipFilings) {
109
- if (byOwner.size >= MAX_DISTINCT_OWNERS) break;
110
- let document;
111
- try {
112
- document = await fetchFilingDocument(index.cik, filing.accession, filing.primary_document, { signal });
113
- } catch {
114
- continue;
111
+ // Read in bounded concurrent batches rather than one filing at a time. Sequentially this was
112
+ // up to sixty throttled round trips on the critical path of every company run, which is what
113
+ // pushed grounding past its budget -- and a budget overrun used to discard the whole run's
114
+ // evidence. Newest-first order is preserved across batches, so an owner is still taken from
115
+ // their most recent filing.
116
+ const candidates = ownershipFilings.slice(0, MAX_DISTINCT_OWNERS * 3);
117
+ for (let offset = 0; offset < candidates.length && byOwner.size < MAX_DISTINCT_OWNERS; offset += OWNERSHIP_BATCH) {
118
+ const batch = candidates.slice(offset, offset + OWNERSHIP_BATCH);
119
+ const documents = await Promise.all(batch.map((filing) => (
120
+ fetchFilingDocument(index.cik, filing.accession, filing.primary_document, { signal })
121
+ .then((document) => ({ filing, document }))
122
+ .catch(() => null)
123
+ )));
124
+ for (const entry of documents) {
125
+ if (!entry) continue;
126
+ const parsed = parseOwnershipDocument(entry.document.text);
127
+ if (!parsed || byOwner.has(parsed.owner_cik)) continue;
128
+ byOwner.set(parsed.owner_cik, { ...parsed, filing_date: entry.filing.filing_date, url: entry.document.url });
129
+ sources.push(`sec:ownership:${index.cik}:${entry.filing.accession}`);
130
+ if (!newestFiling || entry.filing.filing_date > newestFiling) newestFiling = entry.filing.filing_date;
115
131
  }
116
- const parsed = parseOwnershipDocument(document.text);
117
- if (!parsed || byOwner.has(parsed.owner_cik)) continue;
118
- byOwner.set(parsed.owner_cik, { ...parsed, filing_date: filing.filing_date, url: document.url });
119
- sources.push(`sec:ownership:${index.cik}:${filing.accession}`);
120
- if (!newestFiling || filing.filing_date > newestFiling) newestFiling = filing.filing_date;
121
132
  }
122
133
  if (byOwner.size < MIN_OWNER_COVERAGE) {
123
134
  return gap(`insider ownership: no Section 16 document for ${index.name || cik} could be parsed`);
@@ -839,6 +839,9 @@ export async function gatherInstrumentFacts({
839
839
  symbol,
840
840
  research_model: instrument?.research_model || "market_instrument",
841
841
  provenance,
842
+ // The disclosed positions themselves, so a caller that needs the basket's identity rather
843
+ // than its aggregates -- news, for one -- does not fetch the holdings file a second time.
844
+ holdings: holdings?.holdings || null,
842
845
  facts,
843
846
  // Several facts have more than one route -- the implied ERP and the valuation percentile
844
847
  // are computed from the published workbook when the index feed cannot supply them -- and
@@ -118,25 +118,33 @@ const MASTER_STATEMENT_COPY = Object.freeze({
118
118
  heading: "\u9010\u5e2d\u65b9\u6cd5\u8f93\u51fa", acted: "\u6709\u5224\u65ad\u7684\u5e2d\u4f4d", abstained: "\u8bf4\u8fd9\u4e0d\u5f52\u5b83\u7ba1\u7684\u5e2d\u4f4d",
119
119
  stance: "\u7acb\u573a", intent: "\u610f\u5411", origin: "\u9648\u8bcd\u6765\u6e90", statement: "\u672c\u8f6e\u53d1\u8a00\uff08\u4e0d\u662f\u672c\u4eba\u5f15\u8bed\uff09",
120
120
  findings: "\u5173\u952e\u53d1\u73b0", disagreements: "\u4e0e\u5206\u6790\u5e08\u5206\u6b67", change: "\u6539\u53d8\u5224\u65ad\u6761\u4ef6", sources: "\u6765\u6e90\u6216\u660e\u786e\u7f3a\u53e3",
121
- abstainLead: (n) => `\u53e6\u6709 ${n} \u5e2d\u5728\u672c\u8f6e\u4e0d\u7ed9\u65b9\u5411\uff0c\u5404\u81ea\u7f3a\u7684\u662f\u65b9\u6cd5\u5fc5\u9700\u7684\u8f93\u5165\uff0c\u8fd9\u4e0d\u662f\u770b\u7a7a\u7968\uff1a`,
121
+ abstainLead: (n) => `\u53e6\u6709 ${n} \u5e2d\u672c\u8f6e\u672a\u80fd\u53d6\u5f97\u5176\u65b9\u6cd5\u5fc5\u9700\u7684\u8f93\u5165\uff0c\u56e0\u6b64\u6ca1\u6709\u7ed9\u51fa\u65b9\u5411\u3002\u8fd9\u662f\u6570\u636e\u7f3a\u53e3\uff0c\u4e0d\u662f\u770b\u7a7a\u7968\uff1a`,
122
+ declined: "\u770b\u8fc7\u4e4b\u540e\u51b3\u5b9a\u4e0d\u53c2\u4e0e\u7684\u5e2d\u4f4d",
123
+ declinedLead: (n) => `\u4ee5\u4e0b ${n} \u5e2d\u7684\u65b9\u6cd5\u8dd1\u5b8c\u4e86\uff0c\u5e76\u4e14\u5f97\u51fa\u4e86\u201c\u4e0d\u662f\u8fd9\u4e2a\u201d\u3002\u8fd9\u662f\u5224\u65ad\uff0c\u4e0d\u662f\u7f3a\u6570\u636e\uff1a`,
122
124
  },
123
125
  en: {
124
126
  heading: "Method-Seat Outputs", acted: "Seats with a view", abstained: "Seats that say this is not theirs to call",
125
127
  stance: "Stance", intent: "Intent", origin: "Statement source", statement: "Recorded statement (not a quote)",
126
128
  findings: "Key findings", disagreements: "Disagreements", change: "What would change the view", sources: "Sources or explicit gaps",
127
- abstainLead: (n) => `A further ${n} seat(s) issue no direction this round, each missing a method-critical input. These are not bearish votes:`,
129
+ abstainLead: (n) => `A further ${n} seat(s) issue no direction because a method-critical input did not arrive this round. This is a data gap, not a bearish vote:`,
130
+ declined: "Seats whose method examined this and declined",
131
+ declinedLead: (n) => `${n} seat(s) ran their method to completion and it returned "not this one". These are judgments, not missing data:`,
128
132
  },
129
133
  ja: {
130
134
  heading: "\u30e1\u30bd\u30c3\u30c9\u5e2d\u3054\u3068\u306e\u51fa\u529b", acted: "\u5224\u65ad\u3092\u793a\u3057\u305f\u5e2d", abstained: "\u81ea\u5206\u306e\u62c5\u5f53\u3067\u306f\u306a\u3044\u3068\u3057\u305f\u5e2d",
131
135
  stance: "\u30b9\u30bf\u30f3\u30b9", intent: "\u610f\u5411", origin: "\u898b\u89e3\u306e\u751f\u6210\u5143", statement: "\u4eca\u56de\u306e\u767a\u8a00\uff08\u672c\u4eba\u306e\u5f15\u7528\u3067\u306f\u3042\u308a\u307e\u305b\u3093\uff09",
132
136
  findings: "\u4e3b\u306a\u6240\u898b", disagreements: "\u5206\u6790\u62c5\u5f53\u3068\u306e\u76f8\u9055", change: "\u5224\u65ad\u304c\u5909\u308f\u308b\u6761\u4ef6", sources: "\u51fa\u5178\u307e\u305f\u306f\u660e\u793a\u7684\u306a\u6b20\u843d",
133
- abstainLead: (n) => `\u4ed6\u306b ${n} \u5e2d\u306f\u4eca\u56de\u65b9\u5411\u6027\u3092\u793a\u3057\u307e\u305b\u3093\u3002\u3044\u305a\u308c\u3082\u30e1\u30bd\u30c3\u30c9\u306b\u5fc5\u8981\u306a\u5165\u529b\u3092\u6b20\u3044\u3066\u304a\u308a\u3001\u5f31\u6c17\u7968\u3067\u306f\u3042\u308a\u307e\u305b\u3093\uff1a`,
137
+ abstainLead: (n) => `\u4ed6\u306b ${n} \u5e2d\u306f\u3001\u30e1\u30bd\u30c3\u30c9\u306b\u5fc5\u8981\u306a\u5165\u529b\u304c\u4eca\u56de\u5c4a\u304b\u306a\u304b\u3063\u305f\u305f\u3081\u65b9\u5411\u6027\u3092\u793a\u3057\u307e\u305b\u3093\u3002\u30c7\u30fc\u30bf\u306e\u6b20\u843d\u3067\u3042\u308a\u3001\u5f31\u6c17\u7968\u3067\u306f\u3042\u308a\u307e\u305b\u3093\uff1a`,
138
+ declined: "\u691c\u8a0e\u3057\u305f\u4e0a\u3067\u898b\u9001\u3063\u305f\u5e2d",
139
+ declinedLead: (n) => `\u6b21\u306e ${n} \u5e2d\u306f\u30e1\u30bd\u30c3\u30c9\u3092\u6700\u5f8c\u307e\u3067\u5b9f\u884c\u3057\u3001\u300c\u3053\u308c\u3067\u306f\u306a\u3044\u300d\u3068\u7d50\u8ad6\u3057\u307e\u3057\u305f\u3002\u30c7\u30fc\u30bf\u4e0d\u8db3\u3067\u306f\u306a\u304f\u5224\u65ad\u3067\u3059\uff1a`,
134
140
  },
135
141
  ko: {
136
142
  heading: "\ubc29\ubc95\ub860 \uc88c\uc11d\ubcc4 \ucd9c\ub825", acted: "\ud310\ub2e8\uc744 \ub0b8 \uc88c\uc11d", abstained: "\uc790\uae30 \uc18c\uad00\uc774 \uc544\ub2c8\ub77c\uace0 \ubc1d\ud78c \uc88c\uc11d",
137
143
  stance: "\uc785\uc7a5", intent: "\uc758\ud5a5", origin: "\ubc1c\uc5b8 \ucd9c\ucc98", statement: "\uc774\ubc88 \ubc1c\uc5b8(\ubcf8\uc778 \uc778\uc6a9\uc774 \uc544\ub2d8)",
138
144
  findings: "\ud575\uc2ec \ubc1c\uacac", disagreements: "\ubd84\uc11d\uac00\uc640\uc758 \uc774\uacac", change: "\ud310\ub2e8 \ubcc0\uacbd \uc870\uac74", sources: "\ucd9c\ucc98 \ub610\ub294 \uba85\uc2dc\uc801 \ub370\uc774\ud130 \uacf5\ubc31",
139
- abstainLead: (n) => `\uadf8 \uc678 ${n}\uac1c \uc88c\uc11d\uc740 \uc774\ubc88 \ud68c\ucc28\uc5d0 \ubc29\ud5a5\uc744 \uc81c\uc2dc\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uac01\uac01 \ubc29\ubc95\ub860\uc5d0 \ud544\uc694\ud55c \uc785\ub825\uc774 \uc5c6\uc73c\uba70 \uc57d\uc138 \ud22c\ud45c\uac00 \uc544\ub2d9\ub2c8\ub2e4:`,
145
+ abstainLead: (n) => `\uadf8 \uc678 ${n}\uac1c \uc88c\uc11d\uc740 \ubc29\ubc95\ub860\uc5d0 \ud544\uc694\ud55c \uc785\ub825\uc774 \uc774\ubc88 \ud68c\ucc28\uc5d0 \ub3c4\ucc29\ud558\uc9c0 \uc54a\uc544 \ubc29\ud5a5\uc744 \uc81c\uc2dc\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \ub370\uc774\ud130 \uacf5\ubc31\uc774\uba70 \uc57d\uc138 \ud22c\ud45c\uac00 \uc544\ub2d9\ub2c8\ub2e4:`,
146
+ declined: "\uac80\ud1a0 \ud6c4 \ucc38\uc5ec\ud558\uc9c0 \uc54a\uae30\ub85c \ud55c \uc88c\uc11d",
147
+ declinedLead: (n) => `\ub2e4\uc74c ${n}\uac1c \uc88c\uc11d\uc740 \ubc29\ubc95\ub860\uc744 \ub05d\uae4c\uc9c0 \uc218\ud589\ud588\uace0 "\uc774\uac83\uc740 \uc544\ub2c8\ub2e4"\ub77c\ub294 \uacb0\ub860\uc5d0 \ub3c4\ub2ec\ud588\uc2b5\ub2c8\ub2e4. \ub370\uc774\ud130 \ubd80\uc871\uc774 \uc544\ub2c8\ub77c \ud310\ub2e8\uc785\ub2c8\ub2e4:`,
140
148
  },
141
149
  });
142
150
 
@@ -183,12 +191,25 @@ function renderMasterStatements(run) {
183
191
  const sections = [`### ${copy.heading}`, voiceDisclaimer(run.language)];
184
192
  if (acted.length) sections.push(`#### ${copy.acted}`, acted.map(seatBlock).join("\n\n"));
185
193
  if (abstained.length) {
186
- // One paragraph, not one row per seat. The stable IDs stay visible so the gate and the
187
- // reader can both account for every selected seat without twenty-five identical lines.
188
- const merged = abstained
194
+ // Two reasons wear the same word and they are not the same event. A seat whose method
195
+ // examined the subject and declined has ANSWERED -- Graham finding no asset floor, a
196
+ // volatility seat finding no testable observation -- and reporting that as "missing an
197
+ // input" tells a reader the system broke when the method spoke. A seat whose required
198
+ // fact never arrived genuinely is a gap. Rendering them together made every run read as
199
+ // the second kind, which is the complaint this split exists to answer.
200
+ // One paragraph each, not one row per seat: the stable IDs stay visible so the gate and
201
+ // the reader can both account for every selected seat.
202
+ const merged = (group) => group
189
203
  .map((opinion) => `${masterTitle(opinion.master, run.language)} (\`${opinion.master}\`) \u2014 ${opinion.voice_statement}`)
190
204
  .join(" ");
191
- sections.push(`#### ${copy.abstained}`, `${copy.abstainLead(abstained.length)}\n\n${merged}`);
205
+ const declined = abstained.filter((opinion) => methodDeclined(opinion));
206
+ const ungrounded = abstained.filter((opinion) => !methodDeclined(opinion));
207
+ if (declined.length) {
208
+ sections.push(`#### ${copy.declined}`, `${copy.declinedLead(declined.length)}\n\n${merged(declined)}`);
209
+ }
210
+ if (ungrounded.length) {
211
+ sections.push(`#### ${copy.abstained}`, `${copy.abstainLead(ungrounded.length)}\n\n${merged(ungrounded)}`);
212
+ }
192
213
  }
193
214
  return sections.join("\n\n");
194
215
  }
@@ -202,6 +223,18 @@ function renderMasterStatements(run) {
202
223
  * weakest thing a council produces and the dissenting seat is the informative one. Stating
203
224
  * this next to the opinions is the difference between a bench and a vote count.
204
225
  */
226
+ /**
227
+ * Did the seat's own method reach this, or did the data never arrive?
228
+ *
229
+ * A fired veto and a false eligibility condition are both the method running to completion and
230
+ * returning "not this one". Anything else -- an unmet required fact type, a coverage shortfall,
231
+ * an executor refusal -- is the run failing to give the method what it asked for.
232
+ */
233
+ export function methodDeclined(opinion) {
234
+ const reason = String(opinion?.decision?.reason || opinion?.reason || "");
235
+ return reason === "veto" || reason === "eligibility";
236
+ }
237
+
205
238
  export function masterCorrelationNote(run) {
206
239
  const opinions = run?.master_opinions || [];
207
240
  if (!opinions.length) return "";
@@ -1200,7 +1200,15 @@ export async function groundingForHeadlessRun({ symbol, asOf, grounding, dryRun,
1200
1200
  let timer;
1201
1201
  const controller = Number.isFinite(timeoutMs) ? new AbortController() : null;
1202
1202
  try {
1203
- const work = gather({ symbol, asOf, ...(controller ? { signal: controller.signal } : {}) });
1203
+ // The budget is handed DOWN so grounding can settle and return what it has. Racing it from
1204
+ // out here discarded a completed quote and a completed screen whenever one feed was slow,
1205
+ // and the analysts then reported a missing ticker for a symbol that had been supplied.
1206
+ // The outer race stays as a backstop, with headroom, for a call that hangs entirely.
1207
+ const work = gather({
1208
+ symbol, asOf,
1209
+ ...(Number.isFinite(timeoutMs) ? { budgetMs: timeoutMs } : {}),
1210
+ ...(controller ? { signal: controller.signal } : {}),
1211
+ });
1204
1212
  if (!Number.isFinite(timeoutMs)) return await work;
1205
1213
  return await Promise.race([
1206
1214
  work,
@@ -1209,7 +1217,7 @@ export async function groundingForHeadlessRun({ symbol, asOf, grounding, dryRun,
1209
1217
  const error = new Error(`quick grounding timed out after ${Math.round(timeoutMs)}ms`);
1210
1218
  reject(error);
1211
1219
  controller?.abort(error);
1212
- }, timeoutMs);
1220
+ }, timeoutMs + LIMITS.GROUNDING_SETTLE_HEADROOM_MS);
1213
1221
  }),
1214
1222
  ]);
1215
1223
  } catch (error) {
@@ -13,6 +13,8 @@ import { typedFactPackFromGrounding } from "../personas-v3/grounding-adapter.mjs
13
13
  import { buildAnonymousPreDecision, freezeAnonymousDecision } from "../personas-v3/runtime.mjs";
14
14
  import { executeDeterministicPersonaPolicy } from "../personas-v3/deterministic-executor.mjs";
15
15
  import { languageKey, localized } from "../lang.mjs";
16
+ import { displayMasterLabel } from "../markdown.mjs";
17
+ import { voiceFromDecision, voiceFromDecline } from "../voice-from-decision.mjs";
16
18
  import {
17
19
  declinedOpinion as v2DeclinedOpinion,
18
20
  planMasters as planLegacyMasters,
@@ -77,7 +79,19 @@ function planV3Seat(run, id, pack) {
77
79
  pack,
78
80
  };
79
81
  }
80
- if (preDecision.eligibility.status !== "ready") {
82
+ // A seat with SOME of its required facts still runs its own policy.
83
+ //
84
+ // Several seats are authored with a veto that says, in the method's own words, what an absent
85
+ // fact means -- Pabrai passing without a downside floor, Graham without an asset floor. Those
86
+ // facts are also tool inputs, so a missing one used to end the run at this gate and the veto
87
+ // written for exactly that case never executed. The seat then reported "missing X", which is
88
+ // the runtime describing itself rather than the method answering.
89
+ //
90
+ // Vetoes are evaluated before scoring, and every tool, veto and rule already declares its own
91
+ // `on_missing` and `on_uncomputable` behaviour, so letting the policy run does not invent an
92
+ // answer: it reaches the authored one. A seat with NONE of its required facts is still a hard
93
+ // decline -- there is no method left to run.
94
+ if (preDecision.eligibility.status === "out_of_scope") {
81
95
  const frozenDecision = freezeAnonymousDecision(preDecision);
82
96
  return {
83
97
  id,
@@ -106,9 +120,27 @@ function planV3Seat(run, id, pack) {
106
120
  decision: v3DecisionRecord(id, pack, preDecision, frozenDecision),
107
121
  };
108
122
  } catch (error) {
109
- // A malformed policy, unknown operation, missing declared fact, or arithmetic failure
110
- // blocks this physical v3 seat. The seat still owns the ID and never falls through to a
111
- // legacy prompt or an LLM-authored stance.
123
+ // A seat that could not run because a fact it needs is absent has DECLINED, not broken.
124
+ // Partially grounded seats now execute so their authored vetoes can answer, and a seat
125
+ // without such a veto reaches the same clean abstention it always did -- reporting that as
126
+ // a blocked policy would turn "there was nothing to compute with" into "the system
127
+ // failed", which is the confusion this whole pass exists to remove.
128
+ if (preDecision?.eligibility?.status === "insufficient_grounding" && error?.code === "MISSING_TOOL_INPUT") {
129
+ const frozenDecision = freezeAnonymousDecision(preDecision);
130
+ return {
131
+ id,
132
+ engine: "v3_method_runtime",
133
+ declined: true,
134
+ reason: preDecision.eligibility.status,
135
+ pack,
136
+ preDecision,
137
+ frozenDecision,
138
+ decision: v3DecisionRecord(id, pack, preDecision, frozenDecision),
139
+ };
140
+ }
141
+ // A malformed policy, unknown operation, or arithmetic failure blocks this physical v3
142
+ // seat. The seat still owns the ID and never falls through to a legacy prompt or an
143
+ // LLM-authored stance.
112
144
  return {
113
145
  id,
114
146
  engine: "v3_method_runtime",
@@ -191,8 +223,12 @@ export function completedMasterOpinion(run, item) {
191
223
  }
192
224
  const result = item.frozenDecision.structured_decision.result;
193
225
  const locale = languageKey(run.language);
194
- const label = item.pack.admitted_label?.[locale]
195
- || item.pack.admitted_label?.en || item.id;
226
+ // The pack's admitted label carries a maturity suffix -- "provisional operator lens" and its
227
+ // translations -- which belongs in the assurance section, not inside a sentence about the
228
+ // company. Left in, every statement read "using the X provisional operator lens method",
229
+ // which is both ungrammatical and a second copy of a disclosure the report already makes.
230
+ const label = displayMasterLabel(item.pack.admitted_label?.[locale]
231
+ || item.pack.admitted_label?.en || item.id);
196
232
  const copy = localized(run.language, {
197
233
  en: { withheld: (pct) => `${pct}% coverage; score withheld`, unscored: "not scored", verdict: (stance, reason) => `${label} frozen decision: ${stance} (${reason})`, summary: (score) => `Using the ${label} method on ${run.symbol}, the PersonaPack v3 deterministic policy executed with score ${score}. Typed facts, hard vetoes, and score bands produced this stance; no language model selected it.`, hit: (id) => `score hit: ${id}`, miss: (id) => `score miss: ${id}`, eligibility: (id) => `eligibility condition ${id} becomes satisfied`, veto: (id) => `hard veto ${id} no longer triggers`, score: (id) => `score condition ${id} becomes satisfied` },
198
234
  zh: { withheld: (pct) => `覆盖率 ${pct}%,分数被保留不发布`, unscored: "未评分", verdict: (stance, reason) => `${label}的冻结结论:${stance}(${reason})`, summary: (score) => `按${label}方法审视 ${run.symbol}:PersonaPack v3 确定性政策已执行,得分 ${score}。该立场由结构化事实、硬否决和评分带产生,没有让语言模型选择立场。`, hit: (id) => `评分命中:${id}`, miss: (id) => `评分未命中:${id}`, eligibility: (id) => `资格条件 ${id} 变为满足`, veto: (id) => `硬否决 ${id} 不再触发`, score: (id) => `评分条件 ${id} 变为满足` },
@@ -223,6 +259,16 @@ export function completedMasterOpinion(run, item) {
223
259
  verdict: copy.verdict(result.stance, result.reason),
224
260
  summary: deterministicStatement,
225
261
  voice_statement: deterministicStatement,
262
+ // The five fields, composed from the frozen decision rather than written about it: which
263
+ // facts were read and their values, what the tools produced, which scoring conditions held
264
+ // and which did not, and what would move the verdict. A one-line statement told a reader
265
+ // the stance and nothing about how it was reached.
266
+ voice: voiceFromDecision({
267
+ result,
268
+ policy: item.pack.components?.decision_policy,
269
+ factPack: item.preDecision?.fact_pack,
270
+ language: run.language,
271
+ }),
226
272
  voice_status: "deterministic_fallback",
227
273
  voice_language: run.language,
228
274
  statement_origin: "deterministic_policy_fallback",
@@ -263,8 +309,12 @@ export function declinedMasterOpinion(run, item) {
263
309
  if (item?.engine !== "v3_method_runtime") return v2DeclinedOpinion(run, item.id, item.decision);
264
310
  const eligibility = item.preDecision.eligibility;
265
311
  const locale = languageKey(run.language);
266
- const label = item.pack.admitted_label?.[locale]
267
- || item.pack.admitted_label?.en || item.id;
312
+ // The pack's admitted label carries a maturity suffix -- "provisional operator lens" and its
313
+ // translations -- which belongs in the assurance section, not inside a sentence about the
314
+ // company. Left in, every statement read "using the X provisional operator lens method",
315
+ // which is both ungrammatical and a second copy of a disclosure the report already makes.
316
+ const label = displayMasterLabel(item.pack.admitted_label?.[locale]
317
+ || item.pack.admitted_label?.en || item.id);
268
318
  const instrument = run?.grounding?.instrument;
269
319
  const fundOrIndex = instrument?.fund_like === true || instrument?.index_like === true
270
320
  || ["etf", "mutual_fund", "index"].includes(instrument?.asset_type);
@@ -284,7 +334,14 @@ export function declinedMasterOpinion(run, item) {
284
334
  verdict: copy.verdict,
285
335
  summary: copy.summary(missing),
286
336
  voice_statement: deterministicStatement,
287
- voice_status: "deterministic_fallback",
337
+ // The five fields, composed from the frozen decision rather than written about it: which
338
+ // facts were read and their values, what the tools produced, which scoring conditions held
339
+ // and which did not, and what would move the verdict. A one-line statement told a reader
340
+ // the stance and nothing about how it was reached.
341
+ // An abstention is readable too: what the method did have, what it needed, and why it will
342
+ // not substitute a proxy for the difference.
343
+ voice: voiceFromDecline({ eligibility, language: run.language }),
344
+ voice_status: "deterministic_scope",
288
345
  voice_language: run.language,
289
346
  statement_origin: "deterministic_scope_fallback",
290
347
  key_findings: [deterministicStatement],
@@ -701,9 +701,33 @@ function nativeMetrics(policy, facts, outputs) {
701
701
  return { metrics, status };
702
702
  }
703
703
 
704
+ /** True when a condition reads only typed facts and literals, so no tool has to run first. */
705
+ function readsOnlyFacts(node) {
706
+ if (!node || typeof node !== "object") return true;
707
+ if (Object.hasOwn(node, "output_id")) return false;
708
+ return Object.values(node).every((value) => (
709
+ Array.isArray(value) ? value.every(readsOnlyFacts) : readsOnlyFacts(value)
710
+ ));
711
+ }
712
+
704
713
  function executeValidatedPolicy(preDecision, policy, tools, pipeline) {
705
714
  const facts = new Map(preDecision.fact_pack.facts.map((fact) => [fact.fact_id, fact]));
706
715
  const outputs = new Map();
716
+
717
+ // A veto that reads only facts is evaluated before any tool runs.
718
+ //
719
+ // Several seats are authored with a veto that says what an ABSENT fact means -- Pabrai
720
+ // passing without a downside floor, Graham without an asset floor. That same fact is usually
721
+ // a tool input, and a tool declaring `on_missing: "fail"` aborts the whole policy before any
722
+ // veto is reached, so the seat reported a missing input and the author's answer never ran.
723
+ // Hoisting fact-only vetoes changes no arithmetic: they need nothing a tool produces, and a
724
+ // veto that reads a tool output still waits for it.
725
+ const vetoedOnFactsAlone = policy.hard_vetoes.some((record, index) => {
726
+ if (!readsOnlyFacts(record.condition)) return false;
727
+ const early = evaluateCondition(record.condition, facts, outputs, `decision_policy.hard_vetoes[${index}].condition`);
728
+ return early.computable ? Boolean(early.value) : record.on_uncomputable.action === "trigger";
729
+ });
730
+
707
731
  const toolById = new Map(tools.map((tool) => [tool.id, tool]));
708
732
  const toolTrace = [];
709
733
  for (const toolId of pipeline) {
@@ -712,7 +736,12 @@ function executeValidatedPolicy(preDecision, policy, tools, pipeline) {
712
736
  const resolved = tool.inputs.map((operand) => resolveOperand(operand, facts, outputs));
713
737
  const missing = unique(resolved.flatMap((input) => input.missing_input_ids));
714
738
  if (missing.length) {
715
- if (tool.on_missing === "fail") policyFail("MISSING_TOOL_INPUT", `tool ${tool.id} is missing an input`, { tool_id: tool.id, missing_input_ids: missing });
739
+ // A tool that cannot run is fatal, EXCEPT when a fact-only veto has already decided this
740
+ // seat. Then the arithmetic it would have fed is irrelevant to the outcome, and aborting
741
+ // reports a missing input in place of the answer the author wrote for exactly this case.
742
+ if (tool.on_missing === "fail" && !vetoedOnFactsAlone) {
743
+ policyFail("MISSING_TOOL_INPUT", `tool ${tool.id} is missing an input`, { tool_id: tool.id, missing_input_ids: missing });
744
+ }
716
745
  toolTrace.push({
717
746
  tool_id: tool.id,
718
747
  tool_version: tool.version,
@@ -894,7 +923,15 @@ function executeValidatedPolicy(preDecision, policy, tools, pipeline) {
894
923
  /** Execute and freeze one ready anonymous pre-decision. */
895
924
  export function executeDeterministicPersonaPolicy(preDecision) {
896
925
  if (!isObject(preDecision) || preDecision.phase !== "anonymous_pre_decision") policyFail("INVALID_PRE_DECISION", "expected an anonymous_pre_decision payload");
897
- if (preDecision.eligibility?.status !== "ready") policyFail("PRE_DECISION_NOT_READY", `typed-fact gate is ${preDecision.eligibility?.status || "invalid"}`);
926
+ // `insufficient_grounding` runs. Every tool, veto and rule declares its own `on_missing` and
927
+ // `on_uncomputable` behaviour, and those declarations are how a method states what an absent
928
+ // input means -- several seats are authored with a veto that answers exactly that case.
929
+ // Refusing to execute reported this gate instead of the method's own answer. Nothing is
930
+ // invented: a fact that is absent stays absent, and every reader of it takes its declared
931
+ // path. `out_of_scope` -- no required fact present at all -- is still a hard stop.
932
+ if (!["ready", "insufficient_grounding"].includes(preDecision.eligibility?.status)) {
933
+ policyFail("PRE_DECISION_NOT_READY", `typed-fact gate is ${preDecision.eligibility?.status || "invalid"}`);
934
+ }
898
935
  const contract = preDecision.anonymous_method_contract;
899
936
  const policy = contract?.decision_policy;
900
937
  const tools = contract?.tools;