alphacouncil-agent 0.5.0

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 (139) hide show
  1. package/.claude/agents/alphacouncil-bear_researcher.md +39 -0
  2. package/.claude/agents/alphacouncil-bull_researcher.md +40 -0
  3. package/.claude/agents/alphacouncil-earnings_deep_dive.md +49 -0
  4. package/.claude/agents/alphacouncil-forward_expectations.md +48 -0
  5. package/.claude/agents/alphacouncil-ib_event_analysis.md +40 -0
  6. package/.claude/agents/alphacouncil-insider_sec.md +39 -0
  7. package/.claude/agents/alphacouncil-macro_regime.md +31 -0
  8. package/.claude/agents/alphacouncil-market_data.md +37 -0
  9. package/.claude/agents/alphacouncil-market_narrative.md +45 -0
  10. package/.claude/agents/alphacouncil-news_industry_management.md +49 -0
  11. package/.claude/agents/alphacouncil-portfolio_manager.md +22 -0
  12. package/.claude/agents/alphacouncil-quant_factor.md +38 -0
  13. package/.claude/agents/alphacouncil-social_pulse.md +49 -0
  14. package/.claude/agents/alphacouncil-valuation_long_short.md +45 -0
  15. package/.claude-plugin/marketplace.json +30 -0
  16. package/.claude-plugin/plugin.json +28 -0
  17. package/.codex-plugin/plugin.json +32 -0
  18. package/.grok/agents/alphacouncil-bear_researcher.md +41 -0
  19. package/.grok/agents/alphacouncil-bull_researcher.md +42 -0
  20. package/.grok/agents/alphacouncil-earnings_deep_dive.md +50 -0
  21. package/.grok/agents/alphacouncil-forward_expectations.md +49 -0
  22. package/.grok/agents/alphacouncil-ib_event_analysis.md +41 -0
  23. package/.grok/agents/alphacouncil-insider_sec.md +40 -0
  24. package/.grok/agents/alphacouncil-macro_regime.md +32 -0
  25. package/.grok/agents/alphacouncil-market_data.md +38 -0
  26. package/.grok/agents/alphacouncil-market_narrative.md +46 -0
  27. package/.grok/agents/alphacouncil-news_industry_management.md +50 -0
  28. package/.grok/agents/alphacouncil-portfolio_manager.md +24 -0
  29. package/.grok/agents/alphacouncil-quant_factor.md +39 -0
  30. package/.grok/agents/alphacouncil-social_pulse.md +50 -0
  31. package/.grok/agents/alphacouncil-valuation_long_short.md +46 -0
  32. package/.grok/config.toml +4 -0
  33. package/.mcp.json +11 -0
  34. package/.opencode/agent/alphacouncil-bear_researcher.md +45 -0
  35. package/.opencode/agent/alphacouncil-bull_researcher.md +46 -0
  36. package/.opencode/agent/alphacouncil-earnings_deep_dive.md +54 -0
  37. package/.opencode/agent/alphacouncil-forward_expectations.md +53 -0
  38. package/.opencode/agent/alphacouncil-ib_event_analysis.md +45 -0
  39. package/.opencode/agent/alphacouncil-insider_sec.md +44 -0
  40. package/.opencode/agent/alphacouncil-macro_regime.md +36 -0
  41. package/.opencode/agent/alphacouncil-market_data.md +42 -0
  42. package/.opencode/agent/alphacouncil-market_narrative.md +50 -0
  43. package/.opencode/agent/alphacouncil-news_industry_management.md +54 -0
  44. package/.opencode/agent/alphacouncil-portfolio_manager.md +28 -0
  45. package/.opencode/agent/alphacouncil-quant_factor.md +43 -0
  46. package/.opencode/agent/alphacouncil-social_pulse.md +54 -0
  47. package/.opencode/agent/alphacouncil-valuation_long_short.md +50 -0
  48. package/AGENTS.md +96 -0
  49. package/CHANGELOG.md +112 -0
  50. package/CLAUDE.md +144 -0
  51. package/LICENSE +21 -0
  52. package/README.ja.md +282 -0
  53. package/README.md +361 -0
  54. package/README.zh-CN.md +282 -0
  55. package/data/industry-map.json +74 -0
  56. package/docs/attribution.md +99 -0
  57. package/docs/personas.md +59 -0
  58. package/docs/report-contract.md +110 -0
  59. package/mcp/lib/codex.mjs +152 -0
  60. package/mcp/lib/constants.mjs +139 -0
  61. package/mcp/lib/council-options.mjs +111 -0
  62. package/mcp/lib/errors.mjs +46 -0
  63. package/mcp/lib/feeds.mjs +140 -0
  64. package/mcp/lib/fsutil.mjs +69 -0
  65. package/mcp/lib/gates.mjs +208 -0
  66. package/mcp/lib/grounding.mjs +252 -0
  67. package/mcp/lib/headings.mjs +65 -0
  68. package/mcp/lib/industry.mjs +208 -0
  69. package/mcp/lib/lang.mjs +24 -0
  70. package/mcp/lib/macro.mjs +167 -0
  71. package/mcp/lib/markdown.mjs +337 -0
  72. package/mcp/lib/markets-kr-jp.mjs +180 -0
  73. package/mcp/lib/markets.mjs +261 -0
  74. package/mcp/lib/narrative.mjs +235 -0
  75. package/mcp/lib/options.mjs +216 -0
  76. package/mcp/lib/orchestrator.mjs +541 -0
  77. package/mcp/lib/output-modes.mjs +144 -0
  78. package/mcp/lib/packets.mjs +256 -0
  79. package/mcp/lib/personas/frontmatter.mjs +80 -0
  80. package/mcp/lib/personas/registry.mjs +215 -0
  81. package/mcp/lib/preflight.mjs +174 -0
  82. package/mcp/lib/prompts.mjs +151 -0
  83. package/mcp/lib/quotes.mjs +108 -0
  84. package/mcp/lib/rpc.mjs +737 -0
  85. package/mcp/lib/run-store.mjs +140 -0
  86. package/mcp/lib/screen.mjs +283 -0
  87. package/mcp/lib/sec.mjs +131 -0
  88. package/mcp/lib/social.mjs +261 -0
  89. package/mcp/lib/tables.mjs +150 -0
  90. package/mcp/lib/text.mjs +27 -0
  91. package/mcp/lib/weights.mjs +150 -0
  92. package/mcp/server.mjs +92 -0
  93. package/opencode.json +20 -0
  94. package/package.json +61 -0
  95. package/personas/_shared/_debate_base.md +46 -0
  96. package/personas/_shared/_evidence_base.md +56 -0
  97. package/personas/_shared/_master_base.md +77 -0
  98. package/personas/analysts/earnings_deep_dive.md +118 -0
  99. package/personas/analysts/forward_expectations.md +115 -0
  100. package/personas/analysts/ib_event_analysis.md +100 -0
  101. package/personas/analysts/insider_sec.md +98 -0
  102. package/personas/analysts/macro_regime.md +66 -0
  103. package/personas/analysts/market_data.md +94 -0
  104. package/personas/analysts/market_narrative.md +94 -0
  105. package/personas/analysts/news_industry_management.md +117 -0
  106. package/personas/analysts/quant_factor.md +97 -0
  107. package/personas/analysts/social_pulse.md +102 -0
  108. package/personas/analysts/valuation_long_short.md +109 -0
  109. package/personas/debate/bear_researcher.md +96 -0
  110. package/personas/debate/bull_researcher.md +98 -0
  111. package/personas/debate/portfolio_manager.md +64 -0
  112. package/personas/masters/masters-adversarial/burry.md +137 -0
  113. package/personas/masters/masters-adversarial/dalio.md +139 -0
  114. package/personas/masters/masters-adversarial/druckenmiller.md +144 -0
  115. package/personas/masters/masters-adversarial/short_seller.md +145 -0
  116. package/personas/masters/masters-adversarial/soros.md +137 -0
  117. package/personas/masters/masters-modern/aschenbrenner.md +147 -0
  118. package/personas/masters/masters-options/natenberg.md +168 -0
  119. package/personas/masters/masters-options/sinclair.md +166 -0
  120. package/personas/masters/masters-options/taleb.md +163 -0
  121. package/personas/masters/masters-quant/asness.md +142 -0
  122. package/personas/masters/masters-quant/simons.md +143 -0
  123. package/personas/masters/masters-quant/thorp.md +143 -0
  124. package/personas/masters/masters-value/buffett.md +123 -0
  125. package/personas/masters/masters-value/duan_yongping.md +142 -0
  126. package/personas/masters/masters-value/li_lu.md +146 -0
  127. package/personas/masters/masters-value/munger.md +117 -0
  128. package/personas/masters/masters-value-classic/fisher.md +140 -0
  129. package/personas/masters/masters-value-classic/graham.md +137 -0
  130. package/personas/masters/masters-value-classic/klarman.md +138 -0
  131. package/personas/masters/masters-value-classic/lynch.md +133 -0
  132. package/personas/masters/masters-value-classic/marks.md +145 -0
  133. package/personas/verifiers/rederivation.md +97 -0
  134. package/personas/verifiers/refuter.md +99 -0
  135. package/personas/verifiers/source_fidelity.md +91 -0
  136. package/skills/agent-skills-governance/SKILL.md +42 -0
  137. package/skills/alphacouncil-agent/SKILL.md +259 -0
  138. package/skills/investment-banking/SKILL.md +70 -0
  139. package/skills/public-equity-investing/SKILL.md +74 -0
@@ -0,0 +1,215 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { join, relative } from "node:path";
4
+ import { DEBATE_ROLES, DEFAULT_TASKS } from "../constants.mjs";
5
+ import { parsePersonaFile } from "./frontmatter.mjs";
6
+
7
+ export const PERSONA_KINDS = ["analyst", "debate", "master", "verifier"];
8
+ const OUTPUT_CONTRACTS = ["evidence_packet", "debate_packet", "master_opinion", "verifier_verdict", "none"];
9
+ const ID_PATTERN = /^[a-z0-9_]{2,48}$/;
10
+
11
+ /** Resolved from import.meta.url. Hosts launch the server from arbitrary cwds. */
12
+ export function defaultPersonaDir() {
13
+ return process.env.ALPHACOUNCIL_PERSONAS_DIR
14
+ || fileURLToPath(new URL("../../../personas/", import.meta.url));
15
+ }
16
+
17
+ /** Node 18 has no readdirSync({recursive:true}); ten lines beats a version floor bump. */
18
+ function walk(dir, out = []) {
19
+ for (const entry of readdirSync(dir)) {
20
+ const path = join(dir, entry);
21
+ if (statSync(path).isDirectory()) walk(path, out);
22
+ else if (entry.endsWith(".md")) out.push(path);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ function validate(meta, bodies, file, errors) {
28
+ const fail = (message) => errors.push(`${file}: ${message}`);
29
+
30
+ if (meta.schema_version !== 1) fail(`schema_version must be 1, got ${JSON.stringify(meta.schema_version)}`);
31
+ if (typeof meta.id !== "string" || !ID_PATTERN.test(meta.id)) {
32
+ fail(`id must match ${ID_PATTERN}, got ${JSON.stringify(meta.id)}`);
33
+ }
34
+ if (!PERSONA_KINDS.includes(meta.kind)) fail(`kind must be one of ${PERSONA_KINDS.join("|")}, got ${JSON.stringify(meta.kind)}`);
35
+ if (!Number.isFinite(meta.order)) fail("order must be a finite number");
36
+ if (typeof meta.enabled !== "boolean") fail("enabled must be a boolean");
37
+ if (!Array.isArray(meta.rosters) || meta.rosters.some((r) => typeof r !== "string")) {
38
+ fail("rosters must be an array of strings");
39
+ }
40
+ if (!Array.isArray(meta.langs) || meta.langs.length === 0) fail("langs must be a non-empty array");
41
+ else {
42
+ for (const lang of meta.langs) {
43
+ // A declared language with no body is the drift this format exists to prevent.
44
+ if (!bodies[lang] || !bodies[lang].trim()) fail(`declares langs "${lang}" but has no non-empty <!-- lang:${lang} --> section`);
45
+ }
46
+ for (const lang of Object.keys(bodies)) {
47
+ if (!meta.langs.includes(lang)) fail(`has a <!-- lang:${lang} --> section that is not declared in langs`);
48
+ }
49
+ if (!meta.langs.includes(meta.default_lang)) fail(`default_lang ${JSON.stringify(meta.default_lang)} is not in langs`);
50
+ }
51
+ if (meta.output_contract !== undefined && !OUTPUT_CONTRACTS.includes(meta.output_contract)) {
52
+ fail(`output_contract must be one of ${OUTPUT_CONTRACTS.join("|")}, got ${JSON.stringify(meta.output_contract)}`);
53
+ }
54
+ if (meta.default_weight !== undefined) {
55
+ if (!Number.isFinite(meta.default_weight) || meta.default_weight <= 0) {
56
+ fail(`default_weight must be a positive number, got ${JSON.stringify(meta.default_weight)}`);
57
+ }
58
+ }
59
+
60
+ if (meta.kind === "master") {
61
+ // A master persona is a point of view with an explicit failure condition. Without
62
+ // disqualifiers it degrades into generic commentary that agrees with everything.
63
+ if (!Array.isArray(meta.philosophy_tags) || meta.philosophy_tags.length === 0) {
64
+ fail("a master persona must declare philosophy_tags");
65
+ }
66
+ if (!Array.isArray(meta.disqualifiers) || meta.disqualifiers.length === 0) {
67
+ fail("a master persona must declare disqualifiers: what would make this master walk away");
68
+ }
69
+ if (typeof meta.era !== "string" || !meta.era) fail("a master persona must declare era");
70
+ if (typeof meta.holding_period !== "string" || !meta.holding_period) fail("a master persona must declare holding_period");
71
+ }
72
+
73
+ if (meta.kind === "verifier") {
74
+ // A verifier whose verdict space is undefined cannot be aggregated: the caller has
75
+ // no way to decide what "2 of 3 confirmed" means.
76
+ if (!Array.isArray(meta.verdict_values) || meta.verdict_values.length < 2) {
77
+ fail("a verifier persona must declare verdict_values with at least two outcomes");
78
+ }
79
+ if (meta.output_contract !== "verifier_verdict") {
80
+ fail(`a verifier persona must use output_contract "verifier_verdict", got ${JSON.stringify(meta.output_contract)}`);
81
+ }
82
+ }
83
+
84
+ if (meta.source !== undefined && meta.source !== null) {
85
+ const s = meta.source;
86
+ if (typeof s !== "object" || !s.name || !s.license) {
87
+ fail("source must be null or an object with at least { name, license } -- adapted content must carry its attribution");
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Load every persona under `dir` into a frozen registry.
94
+ *
95
+ * Collects ALL errors and throws once, so a malformed set is fixed in one pass rather
96
+ * than one file per run. Deliberately does not degrade: once the inline prompt literals
97
+ * are gone there is no fallback text, and a "graceful" degrade would silently ship empty
98
+ * prompts -- exactly the class of bug this layer exists to remove.
99
+ */
100
+ export function loadPersonas({ dir = defaultPersonaDir() } = {}) {
101
+ let files;
102
+ try {
103
+ files = walk(dir).sort();
104
+ } catch (error) {
105
+ throw new Error(`persona directory is unreadable: ${dir} (${error.code || error.message}). Set ALPHACOUNCIL_PERSONAS_DIR to override.`);
106
+ }
107
+ if (files.length === 0) throw new Error(`no persona files found under ${dir}`);
108
+
109
+ const errors = [];
110
+ const byId = new Map();
111
+ for (const file of files) {
112
+ const label = relative(dir, file);
113
+ let parsed;
114
+ try {
115
+ parsed = parsePersonaFile(readFileSync(file, "utf8"), label);
116
+ } catch (error) {
117
+ errors.push(error.message);
118
+ continue;
119
+ }
120
+ validate(parsed.meta, parsed.bodies, label, errors);
121
+ const id = parsed.meta.id;
122
+ if (typeof id === "string") {
123
+ if (byId.has(id)) errors.push(`${label}: duplicate persona id "${id}" (also in ${byId.get(id).file})`);
124
+ else byId.set(id, { ...parsed.meta, bodies: parsed.bodies, file: label });
125
+ }
126
+ }
127
+
128
+ // Closure check: the task and role lists the server ships must all resolve.
129
+ for (const task of DEFAULT_TASKS) {
130
+ const persona = byId.get(task);
131
+ if (!persona) errors.push(`DEFAULT_TASKS includes "${task}" but no persona defines it`);
132
+ else if (persona.kind !== "analyst") errors.push(`"${task}" is a default task but its persona kind is "${persona.kind}"`);
133
+ }
134
+ for (const role of DEBATE_ROLES) {
135
+ const persona = byId.get(role);
136
+ if (!persona) errors.push(`DEBATE_ROLES includes "${role}" but no persona defines it`);
137
+ else if (persona.kind !== "debate") errors.push(`"${role}" is a debate role but its persona kind is "${persona.kind}"`);
138
+ }
139
+
140
+ if (errors.length) {
141
+ throw new Error(`invalid persona set in ${dir}:\n- ${errors.join("\n- ")}`);
142
+ }
143
+
144
+ return Object.freeze({
145
+ dir,
146
+ personas: byId,
147
+ get: (id) => byId.get(id),
148
+ all: () => [...byId.values()],
149
+ ids: (kind) => [...byId.values()]
150
+ .filter((p) => (kind ? p.kind === kind : true) && p.enabled)
151
+ .sort((a, b) => a.order - b.order || a.id.localeCompare(b.id))
152
+ .map((p) => p.id),
153
+ });
154
+ }
155
+
156
+ let cached = null;
157
+
158
+ /** Loaded once per server process; personas are static and hosts restart the server. */
159
+ export function registry() {
160
+ if (!cached) cached = loadPersonas();
161
+ return cached;
162
+ }
163
+
164
+ /** Tests only. */
165
+ export function resetRegistry() {
166
+ cached = null;
167
+ }
168
+
169
+ export function selectRoster(reg, { kind, roster, ids } = {}) {
170
+ let list = reg.all();
171
+ if (ids?.length) {
172
+ const wanted = new Set(ids);
173
+ list = list.filter((p) => wanted.has(p.id));
174
+ const missing = ids.filter((id) => !reg.get(id));
175
+ if (missing.length) throw new Error(`unknown persona id(s): ${missing.join(", ")}`);
176
+ } else {
177
+ list = list.filter((p) => p.enabled);
178
+ if (kind) list = list.filter((p) => p.kind === kind);
179
+ if (roster) list = list.filter((p) => p.rosters.includes(roster));
180
+ }
181
+ return list.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
182
+ }
183
+
184
+ /**
185
+ * The prompt body for a persona in the requested language.
186
+ *
187
+ * Falls back to the persona's default_lang, then to any body it has, so a persona that
188
+ * has not been translated yet still produces a usable prompt instead of an empty one.
189
+ */
190
+ export function personaPrompt(persona, lang) {
191
+ if (!persona) return "";
192
+ const key = personaLangKey(persona, lang);
193
+ return persona.bodies[key] || "";
194
+ }
195
+
196
+ /** Map a resolved display language ("中文", "English", ...) onto a persona body key. */
197
+ export function personaLangKey(persona, lang) {
198
+ const text = String(lang || "");
199
+ const wanted = /中文|chinese|zh/i.test(text) ? "zh"
200
+ : /日本語|japanese|ja/i.test(text) ? "ja"
201
+ : /한국어|korean|ko/i.test(text) ? "ko"
202
+ : "en";
203
+ if (persona.bodies[wanted]) return wanted;
204
+ if (persona.bodies[persona.default_lang]) return persona.default_lang;
205
+ return Object.keys(persona.bodies)[0];
206
+ }
207
+
208
+ /** Localized title, falling back through the same chain as the body. */
209
+ export function personaTitle(persona, lang) {
210
+ if (!persona) return "";
211
+ const title = persona.title;
212
+ if (typeof title === "string") return title;
213
+ if (!title || typeof title !== "object") return persona.id;
214
+ return title[personaLangKey(persona, lang)] || title[persona.default_lang] || persona.id;
215
+ }
@@ -0,0 +1,174 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import os from "node:os";
4
+ import { registry, selectRoster } from "./personas/registry.mjs";
5
+
6
+ /**
7
+ * Network-permission preflight.
8
+ *
9
+ * The failure this exists for: background subagents cannot raise an interactive
10
+ * permission prompt. If WebSearch is not allowed, their searches are blocked silently and
11
+ * the agent falls back on training knowledge -- while still filling in every section of
12
+ * the report template. The output looks complete and is entirely unsourced, and nothing
13
+ * in the run signals it.
14
+ *
15
+ * The check is deliberately three-valued, and the bar for "blocked" is high:
16
+ *
17
+ * blocked the host explicitly denies the tool. Definitive.
18
+ * ok bypassPermissions is set, or the tool is on an allowlist.
19
+ * unknown no persistent grant found. A foreground session prompts and works; a
20
+ * background fan-out does not. This is a warning, not a stop.
21
+ *
22
+ * An earlier version treated "an allowlist exists but omits the tool" as blocked. That
23
+ * was wrong and fired on ordinary setups: an allowlist full of Bash entries says nothing
24
+ * about network access, and a machine with defaultMode bypassPermissions -- where
25
+ * everything is in fact granted -- was reported as blocked.
26
+ */
27
+
28
+ /** Host config files that can carry a tool permission allowlist. */
29
+ export function permissionSources({ cwd = process.cwd(), home = os.homedir() } = {}) {
30
+ return [
31
+ join(cwd, ".claude", "settings.local.json"),
32
+ join(cwd, ".claude", "settings.json"),
33
+ join(home, ".claude", "settings.local.json"),
34
+ join(home, ".claude", "settings.json"),
35
+ join(cwd, "opencode.json"),
36
+ join(home, ".config", "opencode", "opencode.json"),
37
+ ];
38
+ }
39
+
40
+ /** Normalize the tool names the two hosts use so one allowlist check covers both. */
41
+ export function canonicalTool(name) {
42
+ const text = String(name || "").trim().toLowerCase().replace(/[^a-z]/g, "");
43
+ if (text.startsWith("websearch")) return "websearch";
44
+ if (text.startsWith("webfetch")) return "webfetch";
45
+ if (text.startsWith("getquote")) return "get_quote";
46
+ return String(name || "").trim().toLowerCase();
47
+ }
48
+
49
+ function readPermissions(path) {
50
+ if (!existsSync(path)) return null;
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(readFileSync(path, "utf8"));
54
+ } catch (error) {
55
+ return { path, unreadable: error.message };
56
+ }
57
+ // Claude Code: { permissions: { allow: [...], deny: [...] } }
58
+ // OpenCode: { permission: { websearch: "allow" | "ask" | "deny", ... } }
59
+ const claude = parsed?.permissions;
60
+ const opencode = parsed?.permission || parsed?.permissions;
61
+ const allow = new Set();
62
+ const deny = new Set();
63
+ let declared = false;
64
+ // bypassPermissions grants everything without prompting, including in background
65
+ // subagents. Ignoring it made this check report "blocked" on a fully permissive setup.
66
+ const bypass = claude?.defaultMode === "bypassPermissions";
67
+
68
+ if (Array.isArray(claude?.allow)) {
69
+ declared = true;
70
+ for (const entry of claude.allow) allow.add(canonicalTool(entry));
71
+ }
72
+ if (Array.isArray(claude?.deny)) {
73
+ declared = true;
74
+ for (const entry of claude.deny) deny.add(canonicalTool(entry));
75
+ }
76
+ if (opencode && !Array.isArray(opencode) && typeof opencode === "object") {
77
+ for (const [key, value] of Object.entries(opencode)) {
78
+ if (typeof value !== "string") continue;
79
+ declared = true;
80
+ if (value === "allow") allow.add(canonicalTool(key));
81
+ else if (value === "deny") deny.add(canonicalTool(key));
82
+ }
83
+ }
84
+ return { path, allow, deny, declared, bypass };
85
+ }
86
+
87
+ /** Every tool the personas in a roster say they need. */
88
+ export function requiredTools({ reg = registry(), roster = "default", kind = "analyst" } = {}) {
89
+ const personas = selectRoster(reg, { kind, roster });
90
+ const tools = new Set();
91
+ for (const persona of personas) {
92
+ for (const tool of persona.tools_hint || []) tools.add(canonicalTool(tool));
93
+ }
94
+ // get_quote is served by this MCP server itself; it never needs a host allowlist entry.
95
+ tools.delete("get_quote");
96
+ return [...tools].sort();
97
+ }
98
+
99
+ const REMEDY = {
100
+ claude: 'Add the tools to permissions.allow in .claude/settings.local.json, e.g. {"permissions":{"allow":["WebSearch","WebFetch"]}}',
101
+ opencode: 'Set them to "allow" in opencode.json, e.g. {"permission":{"websearch":"allow","webfetch":"allow"}}. Note OpenCode gates websearch behind its own provider or OPENCODE_ENABLE_EXA=1.',
102
+ };
103
+
104
+ /**
105
+ * @returns {{status:"ok"|"blocked"|"unknown", required:string[], missing:string[],
106
+ * denied:string[], checked:object[], remedy:string, message:string}}
107
+ */
108
+ export function preflightNetworkPermissions(options = {}) {
109
+ const required = options.required || requiredTools(options);
110
+ const sources = permissionSources(options).map(readPermissions).filter(Boolean);
111
+ const allow = new Set();
112
+ const deny = new Set();
113
+ let bypass = false;
114
+ for (const source of sources) {
115
+ if (source.bypass) bypass = true;
116
+ for (const tool of source.allow || []) allow.add(tool);
117
+ for (const tool of source.deny || []) deny.add(tool);
118
+ }
119
+
120
+ const denied = required.filter((tool) => deny.has(tool));
121
+ // Only an explicit denial is "blocked". An allowlist that happens to exist for other
122
+ // tools says nothing about network access: a foreground session still prompts and
123
+ // works. Treating that as blocked was a false positive on ordinary setups.
124
+ const ungranted = required.filter((tool) => !deny.has(tool) && !allow.has(tool));
125
+
126
+ const checked = sources.map((s) => ({
127
+ path: s.path,
128
+ declares_permissions: Boolean(s.declared),
129
+ ...(s.unreadable ? { unreadable: s.unreadable } : {}),
130
+ }));
131
+
132
+ if (denied.length) {
133
+ return {
134
+ status: "blocked",
135
+ required,
136
+ missing: ungranted,
137
+ denied,
138
+ checked,
139
+ remedy: REMEDY.claude,
140
+ message: `The host explicitly denies ${denied.join(", ")}. Evidence agents cannot search, and a background subagent cannot prompt to ask. Do not run the council until this is changed: it will produce a complete-looking report with no sources behind it.`,
141
+ };
142
+ }
143
+ if (bypass) {
144
+ return {
145
+ status: "ok",
146
+ required,
147
+ missing: [],
148
+ denied: [],
149
+ checked,
150
+ remedy: "",
151
+ message: `Permission mode is bypassPermissions, so all required tools (${required.join(", ") || "none"}) are granted without prompting.`,
152
+ };
153
+ }
154
+ if (ungranted.length === 0 && required.length > 0) {
155
+ return {
156
+ status: "ok",
157
+ required,
158
+ missing: [],
159
+ denied: [],
160
+ checked,
161
+ remedy: "",
162
+ message: `All required network tools are on the allowlist: ${required.join(", ")}.`,
163
+ };
164
+ }
165
+ return {
166
+ status: "unknown",
167
+ required,
168
+ missing: ungranted,
169
+ denied: [],
170
+ checked,
171
+ remedy: REMEDY.claude,
172
+ message: `${ungranted.join(", ") || "The required tools"} are not on any persistent allowlist. A FOREGROUND session will prompt you and work normally. BACKGROUND subagents cannot prompt: their searches are blocked silently and they will answer from training knowledge while still filling in every report section. Allowlist them before a background fan-out, or run the evidence agents in the foreground.`,
173
+ };
174
+ }
@@ -0,0 +1,151 @@
1
+ import { join } from "node:path";
2
+ import { isChineseLanguage, resolveLanguage } from "./lang.mjs";
3
+ import { runPath } from "./run-store.mjs";
4
+ import { compactEvidence, compactMasterOpinions } from "./packets.mjs";
5
+ import { outputModeInstruction } from "./output-modes.mjs";
6
+ import { resolveSeatWeights, weightTableMarkdown } from "./weights.mjs";
7
+ import { groundingBlock } from "./grounding.mjs";
8
+ import { personaPrompt, personaTitle, registry, selectRoster } from "./personas/registry.mjs";
9
+
10
+ /**
11
+ * Prompt text lives in personas/, not here.
12
+ *
13
+ * It used to be two parallel blocks of string literals inside a 2000-line module -- one
14
+ * Chinese, one English -- which had already drifted apart, and which neither a human
15
+ * reviewer nor a host could see. These functions now only compose: preamble + persona
16
+ * body + run-specific context.
17
+ */
18
+
19
+ /** Fill {{placeholders}} in a persona body. Unknown keys are left alone, not blanked. */
20
+ function render(template, values) {
21
+ return String(template || "").replace(/\{\{(\w+)\}\}/g, (match, key) =>
22
+ (Object.prototype.hasOwnProperty.call(values, key) ? String(values[key] ?? "") : match));
23
+ }
24
+
25
+ export function taskPrompt(task, symbol, asOfDate, userPrompt = "", language = "auto", grounding = null) {
26
+ const resolvedLanguage = resolveLanguage({ language, prompt: userPrompt });
27
+ const chinese = isChineseLanguage(resolvedLanguage);
28
+ const reg = registry();
29
+
30
+ const base = [
31
+ render(personaPrompt(reg.get("_evidence_base"), resolvedLanguage), {
32
+ symbol,
33
+ as_of: asOfDate,
34
+ language: resolvedLanguage,
35
+ }),
36
+ userPrompt ? (chinese ? `用户目标:${userPrompt}` : `User objective: ${userPrompt}`) : "",
37
+ ].filter(Boolean).join("\n");
38
+
39
+ const body = render(personaPrompt(reg.get(task), resolvedLanguage), { symbol, as_of: asOfDate, language: resolvedLanguage })
40
+ || (chinese ? "收集与投资决策相关的证据。" : "Collect evidence relevant to the investment decision.");
41
+
42
+ // Grounding goes AFTER the role brief: the analyst must know its job before it is told
43
+ // which facts are already settled, or it reads them as the whole assignment.
44
+ const grounded = groundingBlock(grounding, resolvedLanguage);
45
+ return [`${base}\n\n${chinese ? "任务:" : "Task: "}${task}\n${body}`, grounded].filter(Boolean).join("\n\n");
46
+ }
47
+
48
+ export function debatePrompt(role, run, context = {}) {
49
+ const evidencePath = join(runPath(run.run_id), "evidence.json");
50
+ const evidenceJson = JSON.stringify(compactEvidence(run));
51
+ const language = run.language || "English";
52
+ const chinese = isChineseLanguage(language);
53
+ const reg = registry();
54
+
55
+ const base = render(personaPrompt(reg.get("_debate_base"), language), {
56
+ symbol: run.symbol,
57
+ as_of: run.as_of,
58
+ evidence_path: evidencePath,
59
+ language,
60
+ role,
61
+ });
62
+
63
+ const roleText = render(personaPrompt(reg.get(role), language), { symbol: run.symbol, as_of: run.as_of, language, role })
64
+ || (chinese ? "产出投资组合辩论 memo。" : "Produce a portfolio debate memo.");
65
+
66
+ const roundThreeInstruction = context.round === 3
67
+ ? (chinese
68
+ ? "本轮为问答轮:在 `questions` 数组里给出恰好 3 个针对对方的尖锐问题,并在 `questions_answered` 数组里逐条回答对方提出的问题。"
69
+ : "This is the Q&A round: in a `questions` array list exactly 3 sharp questions for the other side, and in a `questions_answered` array answer the 3 questions the other side asked you.")
70
+ : "";
71
+
72
+ return [
73
+ // The original spread the preamble's lines as separate array elements, so they are
74
+ // separated by blank lines in the final prompt. Preserve that exactly.
75
+ ...base.split("\n"),
76
+ roleText,
77
+ roundThreeInstruction,
78
+ context.round ? `Debate round: ${context.round}` : "",
79
+ context.brief ? `Brief length for round 1: ${context.brief}` : "",
80
+ context.otherCaseR1 ? `Opponent prior-round case JSON: ${JSON.stringify(context.otherCaseR1)}` : "",
81
+ context.questionsForYou ? `Questions you must answer JSON: ${JSON.stringify(context.questionsForYou)}` : "",
82
+ // The masters ran before the debate; the bull and bear must argue with their
83
+ // disagreements rather than restate the evidence unopposed.
84
+ (run.master_opinions || []).length
85
+ ? `Master seat opinions JSON (read the disagreements; you must engage with them, not ignore them): ${JSON.stringify(compactMasterOpinions(run))}`
86
+ : "",
87
+ context.bull ? `Bull argument JSON: ${JSON.stringify(context.bull)}` : "",
88
+ context.bear ? `Bear argument JSON: ${JSON.stringify(context.bear)}` : "",
89
+ // The PM must reproduce the weighting rather than average the seats silently.
90
+ role === "portfolio_manager"
91
+ ? [
92
+ chinese
93
+ ? "各席位权重如下。你的最终裁决必须按这个权重加权,并且必须在报告里原样复现这张表(含核验调整原因)。权重为 0 的席位(自述超出判断范围)不计入。若你的结论与高权重席位相反,必须明确说明为什么。"
94
+ : "Seat weights follow. Weight your verdict by them, and reproduce this table verbatim in the report, including the adjustment reasons. Seats at weight 0 declared themselves out of scope and do not count. If your conclusion opposes a high-weight seat, say explicitly why.",
95
+ weightTableMarkdown(resolveSeatWeights(run, run.seat_weight_overrides || {}), language),
96
+ ].filter(Boolean).join("\n\n")
97
+ : "",
98
+ role === "portfolio_manager" ? outputModeInstruction(context.outputMode || "chat", language) : "",
99
+ `Evidence JSON: ${evidenceJson}`,
100
+ ].filter(Boolean).join("\n\n");
101
+ }
102
+
103
+ /**
104
+ * A master seat reads the finished evidence through one philosophy.
105
+ *
106
+ * Masters deliberately run after the evidence stage and before the debate: they are a
107
+ * judgment layer, not an evidence layer, and their disagreements are what the bull and
108
+ * bear then have to argue with.
109
+ */
110
+ export function masterPrompt(masterId, run) {
111
+ const reg = registry();
112
+ const persona = reg.get(masterId);
113
+ if (!persona || persona.kind !== "master") throw new Error(`unknown master persona: ${masterId}`);
114
+ const language = run.language || "English";
115
+ const values = { symbol: run.symbol, as_of: run.as_of, language };
116
+
117
+ const chinese = isChineseLanguage(language);
118
+ // Masters see the same established facts the analysts saw, not only what the analysts
119
+ // chose to report. A master's value is a different selection from the same facts --
120
+ // Munger looking at incentives, Burry at the notes -- and reading only the analysts'
121
+ // packets destroys exactly that. It also means one weak packet would bias all 21 seats
122
+ // identically, which is the worst kind of error: large and perfectly correlated.
123
+ const grounded = groundingBlock(run.grounding, language);
124
+ const packetLabel = chinese
125
+ ? "以下是分析师席位的证据包。这是**其他席位对同一批事实的解读**,不是事实本身。"
126
+ + "你可以不同意他们的读法,但必须说明你依据的是上面哪一条原始事实。"
127
+ : "Below are the analyst seats' evidence packets. These are **other seats' readings of the "
128
+ + "same facts**, not the facts themselves. You may disagree with a reading, but say which "
129
+ + "established fact above your disagreement rests on.";
130
+
131
+ return [
132
+ render(personaPrompt(reg.get("_master_base"), language), values),
133
+ `Master: ${personaTitle(persona, language)} (${persona.id})`,
134
+ render(personaPrompt(persona, language), values),
135
+ `Walk-away conditions you must check explicitly: ${(persona.disqualifiers || []).join(" | ")}`,
136
+ grounded,
137
+ `${packetLabel}\nEvidence JSON: ${JSON.stringify(compactEvidence(run))}`,
138
+ ].filter(Boolean).join("\n\n");
139
+ }
140
+
141
+ /** The master ids a run has selected, from an explicit list or a roster name. */
142
+ export function selectedMasters(run) {
143
+ const reg = registry();
144
+ if (Array.isArray(run.masters) && run.masters.length) {
145
+ return selectRoster(reg, { ids: run.masters }).filter((p) => p.kind === "master").map((p) => p.id);
146
+ }
147
+ if (run.masters_roster) {
148
+ return selectRoster(reg, { kind: "master", roster: run.masters_roster }).map((p) => p.id);
149
+ }
150
+ return [];
151
+ }
@@ -0,0 +1,108 @@
1
+ import { LIMITS } from "./constants.mjs";
2
+ import { invalidParams } from "./errors.mjs";
3
+
4
+ // ---- Keyless delayed market data (Yahoo primary, Stooq fallback) ------------
5
+ export const MARKET_ALIASES = {
6
+ kospi: "^KS11", "韩股": "^KS11", "韩国综合": "^KS11", kospi200: "^KS200",
7
+ "标普": "^GSPC", "标普500": "^GSPC", sp500: "^GSPC", spx: "^GSPC",
8
+ "纳指": "^IXIC", "纳斯达克": "^IXIC", "道指": "^DJI", "道琼斯": "^DJI",
9
+ "罗素2000": "^RUT", "恒生": "^HSI", "恒指": "^HSI",
10
+ "上证": "000001.SS", "上证指数": "000001.SS", "深证": "399001.SZ",
11
+ "沪深300": "000300.SS", "创业板": "399006.SZ",
12
+ "日经": "^N225", "日经225": "^N225", "台股": "^TWII", "台湾加权": "^TWII",
13
+ "德指": "^GDAXI", dax: "^GDAXI", "富时100": "^FTSE", ftse: "^FTSE",
14
+ "标普期货": "ES=F", "纳指期货": "NQ=F", "道指期货": "YM=F", "罗素期货": "RTY=F",
15
+ "原油": "CL=F", wti: "CL=F", "布伦特": "BZ=F", "黄金": "GC=F", "白银": "SI=F",
16
+ "天然气": "NG=F", "铜": "HG=F",
17
+ vix: "^VIX", "恐慌指数": "^VIX", "10年美债": "^TNX", "美债10年": "^TNX",
18
+ "美债30年": "^TYX", "美元指数": "DX-Y.NYB", dxy: "DX-Y.NYB",
19
+ "美元日元": "JPY=X", "欧元美元": "EURUSD=X", "美元人民币": "CNY=X",
20
+ "比特币": "BTC-USD", btc: "BTC-USD", "以太坊": "ETH-USD",
21
+ };
22
+
23
+ export function resolveMarketSymbol(input) {
24
+ const raw = String(input || "").trim();
25
+ if (!raw) return "";
26
+ const key = raw.toLowerCase();
27
+ return Object.prototype.hasOwnProperty.call(MARKET_ALIASES, key) ? MARKET_ALIASES[key] : raw;
28
+ }
29
+
30
+ export function parseYahooChart(json, requested) {
31
+ const meta = json?.chart?.result?.[0]?.meta;
32
+ if (!meta || typeof meta.regularMarketPrice !== "number") throw new Error("no price in chart payload");
33
+ const prev = typeof meta.chartPreviousClose === "number" ? meta.chartPreviousClose
34
+ : (typeof meta.previousClose === "number" ? meta.previousClose : null);
35
+ const price = meta.regularMarketPrice;
36
+ const change = prev != null ? price - prev : null;
37
+ const changePct = prev ? (change / prev) * 100 : null;
38
+ return {
39
+ query: requested,
40
+ symbol: meta.symbol || requested,
41
+ price,
42
+ previous_close: prev,
43
+ change: change != null ? Number(change.toFixed(4)) : null,
44
+ change_pct: changePct != null ? Number(changePct.toFixed(2)) : null,
45
+ currency: meta.currency || null,
46
+ exchange: meta.exchangeName || null,
47
+ market_state: meta.marketState || null,
48
+ quote_time: meta.regularMarketTime ? new Date(meta.regularMarketTime * 1000).toISOString() : null,
49
+ source: "yahoo",
50
+ note: "delayed (~15m), not a real-time feed",
51
+ };
52
+ }
53
+
54
+ export function parseStooqCsv(csv, requested) {
55
+ const lines = String(csv || "").trim().split("\n");
56
+ if (lines.length < 2) throw new Error("empty stooq csv");
57
+ const cols = lines[1].split(",");
58
+ const close = Number(cols[6]);
59
+ if (!Number.isFinite(close)) throw new Error("no stooq close");
60
+ return {
61
+ query: requested, symbol: cols[0] || requested, price: close,
62
+ previous_close: null, change: null, change_pct: null,
63
+ currency: null, exchange: "stooq", market_state: null,
64
+ quote_time: (cols[1] && cols[2]) ? `${cols[1]}T${cols[2]}` : null,
65
+ source: "stooq", note: "delayed / EOD fallback, not real-time",
66
+ };
67
+ }
68
+
69
+ export async function fetchText(url, timeoutMs = LIMITS.QUOTE_FETCH_MS) {
70
+ const ctrl = new AbortController();
71
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
72
+ try {
73
+ const res = await fetch(url, { signal: ctrl.signal, headers: { "User-Agent": "Mozilla/5.0 (AlphaCouncil)" } });
74
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
75
+ return await res.text();
76
+ } finally {
77
+ clearTimeout(timer);
78
+ }
79
+ }
80
+
81
+ export async function fetchQuote(input) {
82
+ const sym = resolveMarketSymbol(input);
83
+ if (!sym) return { query: input, error: "empty symbol" };
84
+ try {
85
+ const txt = await fetchText(`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(sym)}?range=1d&interval=1d`);
86
+ return parseYahooChart(JSON.parse(txt), sym);
87
+ } catch (e1) {
88
+ try {
89
+ const txt = await fetchText(`https://stooq.com/q/l/?s=${encodeURIComponent(sym.toLowerCase())}&f=sd2t2ohlcv&h&e=csv`);
90
+ return parseStooqCsv(txt, sym);
91
+ } catch (e2) {
92
+ return { query: input, symbol: sym, error: `live data unavailable (${e1.message}; ${e2.message})`, note: "fall back to WebSearch and mark open_questions" };
93
+ }
94
+ }
95
+ }
96
+
97
+ export async function getQuotes(args) {
98
+ const list = Array.isArray(args?.symbols) ? args.symbols : (args?.symbol ? [args.symbol] : []);
99
+ if (list.length === 0) throw invalidParams("get_quote requires symbols[] or symbol.");
100
+ const quotes = await Promise.all(
101
+ list.slice(0, LIMITS.QUOTE_MAX_SYMBOLS).map((s) => fetchQuote(s).catch((e) => ({ query: s, error: String((e && e.message) || e) }))),
102
+ );
103
+ return {
104
+ as_of: new Date().toISOString(),
105
+ quotes,
106
+ disclaimer: "Keyless delayed market data (Yahoo/Stooq, ~15m or EOD). Not real-time, not investment advice. Missing/errored symbols are data gaps -> open_questions.",
107
+ };
108
+ }