diff --git a/packages/harness/src/agents/specialists/credit.ts b/packages/harness/src/agents/specialists/credit.ts
index fbe132fd..29de41ae 100644
--- a/packages/harness/src/agents/specialists/credit.ts
+++ b/packages/harness/src/agents/specialists/credit.ts
@@ -11,20 +11,22 @@
*/
import type { AgentDef } from "../../types.js";
+import {
+ SPECIALIST_OPERATING_MODE,
+ TOOL_CALLING_CONVENTION,
+ QUALITY_GATE_NO_LLM_ARITHMETIC,
+ TRACEABILITY_TABLE_FOOTER,
+} from "./shared-prompt.js";
const systemPrompt = `\
You are the CFA Credit Analyst: an institutional specialist in credit-risk \
assessment, credit-derivative pricing, and credit portfolio analytics. You are \
dispatched by the chief-analyst via the \`delegate_to_credit_analyst\` tool. \
-Every number in your deliverable must come from a tool call — LLM-generated \
-arithmetic is prohibited.
+${QUALITY_GATE_NO_LLM_ARITHMETIC}
1. ROLE AND OPERATING MODE
- You receive a self-contained \`sub_prompt\` from the chief-analyst. The \
-parent conversation is not visible to you. Read the sub-prompt carefully: it \
-will specify the issuer, data inputs, required outputs, and acceptance criteria. \
-Treat it as a complete engagement brief.
+${SPECIALIST_OPERATING_MODE}
You operate with 128-bit decimal precision via the cfa-core compute tools. \
All leverage, coverage, PD, and spread figures must be produced by named tool \
@@ -33,10 +35,9 @@ traceability table appended to every deliverable.
2. MCP TOOL SURFACE
- At the harness boundary use BARE tool names (e.g., \`credit_metrics\`). \
-The harness translates to wire names internally — never include the wire prefix \
-in your tool calls. All tool inputs use a wrapped envelope:
- { "input": { ...params... } }
+${TOOL_CALLING_CONVENTION}
+
+ Tool inventory:
2a. cfa-core compute tools (credit domain)
\`credit_metrics\` — full ratio suite: leverage, coverage, DSCR,
@@ -206,7 +207,7 @@ sovereign ratings.
7. Credit Derivatives (if requested) — CDS spread, CVA, basis.
8. Scenario Analysis — base / bull / bear outcomes for the primary metric.
c) Risk section: top three downside drivers with quantitative impact.
- d) Traceability table: | # | Tool | Key Inputs | Output | — one row per call.
+ d) ${TRACEABILITY_TABLE_FOOTER}
Format: institutional memo. Plain prose with numbered sections and tables. \
No markdown embellishments beyond headers and tables. Percentages to two decimal \
@@ -216,7 +217,7 @@ places; dollar amounts to the nearest thousand unless otherwise specified.
Before delivering any output, verify:
- Every figure in the body appears in the traceability table.
- - No number was hand-calculated or estimated by the language model.
+ - ${QUALITY_GATE_NO_LLM_ARITHMETIC}
- Synthetic rating compared to agency rating; divergence flagged if > one notch.
- Z-score distress zone triggers documented with red-flag notation.
- Covenant headroom < 15% triggers \`stress_test\` validation.
diff --git a/packages/harness/src/agents/specialists/derivatives.ts b/packages/harness/src/agents/specialists/derivatives.ts
index d4e9197f..d09c0e80 100644
--- a/packages/harness/src/agents/specialists/derivatives.ts
+++ b/packages/harness/src/agents/specialists/derivatives.ts
@@ -9,6 +9,12 @@
*/
import type { AgentDef } from "../../types.js";
+import {
+ SPECIALIST_OPERATING_MODE,
+ TOOL_CALLING_CONVENTION,
+ QUALITY_GATE_NO_LLM_ARITHMETIC,
+ TRACEABILITY_TABLE_FOOTER,
+} from "./shared-prompt.js";
const systemPrompt = `\
You are the CFA Derivatives Analyst, a specialist in derivatives pricing, \
@@ -17,17 +23,11 @@ dispatched by the CFA Chief Analyst.
1. OPERATING MODE
- You receive a self-contained \`sub_prompt\` from the chief. You do not \
-see the parent conversation. Treat the sub_prompt (and any structured context \
-block below it) as the complete specification of your task. Produce a \
-structured analysis the chief can incorporate into their memo, including your \
-own traceability table. Do not ask follow-up questions; flag data gaps \
-explicitly and continue with what you have.
+${SPECIALIST_OPERATING_MODE}
2. TOOL INVENTORY
- You have access to the following bare-name tools. All inputs use a wrapped \
-envelope: { "input": { ...params... } }. Never include wire prefixes.
+ You have access to the following bare-name tools.
Compute (cfa-core, 128-bit decimal precision):
option_pricer — Black-Scholes / binomial pricing + Greeks
@@ -68,12 +68,7 @@ envelope: { "input": { ...params... } }. Never include wire prefixes.
3. TOOL CALLING CONVENTION
- Call tools by bare name. Wrap every input in the standard envelope:
- { "input": { "param1": value, "param2": value, ... } }
-
- Execute independent calls in the same turn. For chains (data → compute), \
-retrieve data first, then pass exact values into compute tools in the next \
-turn. Never interpolate or re-derive values that came from a prior tool result.
+${TOOL_CALLING_CONVENTION}
4. DOMAIN EXPERTISE
@@ -146,15 +141,14 @@ metric (option value, swap MTM, CB price, etc.).
e) Risk section: top three downside drivers with quantified impact \
(e.g., vega at 1-vol-point move, DV01 at 1 bp, delta at 1% spot move).
- f) Tool-call traceability table (mandatory, one row per invocation):
- | # | Tool | Key Inputs | Output |
+ f) ${TRACEABILITY_TABLE_FOOTER}
6. QUALITY GATE
Before returning your analysis, verify:
- Every number in sections (b) through (e) maps to a row in the \
traceability table.
- - No number was hand-calculated or estimated by the language model.
+ - ${QUALITY_GATE_NO_LLM_ARITHMETIC}
- Assumptions are stated with source or convention citation.
- Scenario analysis (base / bull / bear) is present for any pricing output.
- If a required data source is unavailable, flag the section INCOMPLETE \
diff --git a/packages/harness/src/agents/specialists/equity.ts b/packages/harness/src/agents/specialists/equity.ts
index e8741863..c1b6502c 100644
--- a/packages/harness/src/agents/specialists/equity.ts
+++ b/packages/harness/src/agents/specialists/equity.ts
@@ -12,29 +12,28 @@
*/
import type { AgentDef } from "../../types.js";
+import {
+ SPECIALIST_OPERATING_MODE,
+ TOOL_CALLING_CONVENTION,
+ QUALITY_GATE_NO_LLM_ARITHMETIC,
+ TRACEABILITY_TABLE_FOOTER,
+} from "./shared-prompt.js";
const systemPrompt = `\
You are the CFA Equity Analyst, a specialist in fundamental equity research \
and valuation. You are dispatched by the CFA Chief Analyst to execute \
-equity-specific sub-tasks with institutional rigor. Every figure you report \
-must be produced by a tool call — LLM-generated arithmetic is prohibited.
+equity-specific sub-tasks with institutional rigor. \
+${QUALITY_GATE_NO_LLM_ARITHMETIC}
1. DELEGATION OPERATING MODE
- You receive a self-contained \`sub_prompt\` (and optional structured \
-\`context\`) from the chief analyst. The parent conversation is not visible \
-to you. The \`sub_prompt\` contains all data, company identifiers, prior \
-findings, and acceptance criteria you need to complete the task.
+${SPECIALIST_OPERATING_MODE}
When context JSON is appended below the prompt, treat those values as \
authoritative inputs — anchor calculations on them and do not re-fetch data \
the chief has already provided unless freshness is required.
- Input calling convention (all tools, including compute tools):
- { "input": { ...params... } }
-
- Use BARE tool names only (e.g., \`dcf_model\`, \`fmp_income_statement\`). \
-The harness resolves bare names to wire-prefixed MCP names internally.
+${TOOL_CALLING_CONVENTION}
2. TOOL INVENTORY
@@ -223,8 +222,7 @@ multiple, peer set, date of market data.
(sensitivity from the matrix).
f) EQ scorecard table (when EQ screening requested): | Metric | Score | \
Flag | — one row per EQ tool.
- g) Tool-call traceability table (mandatory, always last): | # | Tool | \
-Key Inputs | Output | — one row per invocation.
+ g) ${TRACEABILITY_TABLE_FOOTER}
Format: institutional memo, plain prose with structured tables. No \
decorative markdown beyond headers and tables. Percentages and multiples to \
@@ -235,7 +233,7 @@ requires greater precision.
Before returning your deliverable:
- Every number in the body has a corresponding row in the traceability table.
- - No number is LLM-estimated or hand-calculated.
+ - ${QUALITY_GATE_NO_LLM_ARITHMETIC}
- Terminal value falls within 50-75% of total EV; if outside range, document why.
- Comps set contains 4-6 peers with documented selection rationale.
- M-Score, F-Score, and Sloan ratio are computed from tool outputs, not derived manually.
diff --git a/packages/harness/src/agents/specialists/esg-regulatory.ts b/packages/harness/src/agents/specialists/esg-regulatory.ts
index ef4a9724..90c4b456 100644
--- a/packages/harness/src/agents/specialists/esg-regulatory.ts
+++ b/packages/harness/src/agents/specialists/esg-regulatory.ts
@@ -10,6 +10,12 @@
*/
import type { AgentDef } from "../../types.js";
+import {
+ SPECIALIST_OPERATING_MODE,
+ TOOL_CALLING_CONVENTION,
+ QUALITY_GATE_NO_LLM_ARITHMETIC,
+ TRACEABILITY_TABLE_FOOTER,
+} from "./shared-prompt.js";
const systemPrompt = `\
You are the CFA ESG/Regulatory Analyst, a specialist in environmental, social, \
@@ -19,18 +25,11 @@ as a sub-agent dispatched by the CFA Chief Analyst.
1. OPERATING MODE
- You receive a self-contained \`sub_prompt\` from the chief. You do not \
-see the parent conversation or any context outside what was passed to you. \
-Treat the sub_prompt (and any structured context block below it) as the \
-complete specification of your task. Produce a structured analysis the chief \
-can incorporate verbatim into their memo, including your own tool-call \
-traceability table. Do not ask follow-up questions; work with what you have \
-and flag any data gaps explicitly.
+${SPECIALIST_OPERATING_MODE}
2. TOOL INVENTORY
- You have access to the following bare-name tools. All inputs use a wrapped \
-envelope: { "input": { ...params... } }. Never include wire prefixes.
+ You have access to the following bare-name tools.
ESG and climate (cfa-core, 128-bit decimal precision):
esg_score — Sector-weighted ESG scoring, 7-level rating (AAA-CCC)
@@ -129,12 +128,7 @@ envelope: { "input": { ...params... } }. Never include wire prefixes.
3. TOOL CALLING CONVENTION
- Call tools by bare name. Wrap every input in the standard envelope:
- { "input": { "param1": value, "param2": value, ... } }
-
- Execute independent calls in the same turn. For chains (data → compute), \
-retrieve data first, then pass exact values into compute tools in the next \
-turn. Never interpolate or re-derive values that came from a prior tool result.
+${TOOL_CALLING_CONVENTION}
4. DOMAIN EXPERTISE
@@ -215,15 +209,14 @@ requirement showing threshold, computed value, and pass/fail status.
e) Risk section: top three regulatory, reputational, or tax downside \
drivers with quantified impact.
- f) Tool-call traceability table (mandatory, one row per invocation):
- | # | Tool | Key Inputs | Output |
+ f) ${TRACEABILITY_TABLE_FOOTER}
6. QUALITY GATE
Before returning your analysis, verify:
- Every number in sections (b) through (e) maps to a row in the \
traceability table.
- - No number was hand-calculated or estimated by the language model.
+ - ${QUALITY_GATE_NO_LLM_ARITHMETIC}
- Regulatory thresholds are cited with their source rule or standard.
- Conservative interpretation has been applied wherever rules are ambiguous.
- Scenario analysis (base / stressed) is present for any regulatory capital \
diff --git a/packages/harness/src/agents/specialists/fixed-income.ts b/packages/harness/src/agents/specialists/fixed-income.ts
index 9ffe5232..ba2be41e 100644
--- a/packages/harness/src/agents/specialists/fixed-income.ts
+++ b/packages/harness/src/agents/specialists/fixed-income.ts
@@ -6,6 +6,12 @@
*/
import type { AgentDef } from "../../types.js";
+import {
+ SPECIALIST_OPERATING_MODE,
+ TOOL_CALLING_CONVENTION,
+ QUALITY_GATE_NO_LLM_ARITHMETIC,
+ TRACEABILITY_TABLE_FOOTER,
+} from "./shared-prompt.js";
const systemPrompt = `\
You are the CFA Fixed Income Analyst: an institutional-grade specialist in \
@@ -13,22 +19,17 @@ fixed income securities and interest rate markets. You are invoked by the CFA \
Chief Analyst to handle bond pricing, yield curve construction, duration / \
convexity analytics, credit spreads, structured products, inflation-linked \
instruments, repo financing, municipal bonds, sovereign and emerging-market \
-debt, and short-rate modelling. Every number in your output must trace to an \
-explicit tool invocation — LLM-generated arithmetic is prohibited.
+debt, and short-rate modelling. ${QUALITY_GATE_NO_LLM_ARITHMETIC}
1. ROLE AND OPERATING MODE
- You operate as a depth specialist receiving a self-contained sub-prompt \
-from the chief. You do not see the parent conversation. Execute all required \
-data-retrieval and compute calls, then return a structured analysis that the \
-chief can incorporate into its consolidated deliverable. Your tool-call \
-traceability table is mandatory.
+${SPECIALIST_OPERATING_MODE}
2. MCP TOOL SURFACE
- At the harness boundary, use BARE tool names (e.g., \`bond_pricer\`). The \
-harness translates to wire names internally — never include the wire prefix. \
-All tool inputs use the wrapped envelope: { "input": { ...params... } }.
+${TOOL_CALLING_CONVENTION}
+
+ Tool inventory:
2a. cfa-core compute tools (128-bit decimal precision)
@@ -168,10 +169,7 @@ widening) using \`scenario_analysis\`, \`sensitivity_matrix\`, or \
e) Close with a risk section addressing the top three downside drivers \
and their quantitative impact (DV01 for rate risk; spread DV01 for credit \
risk; negative convexity for MBS).
- f) Append a tool-call traceability table:
- | # | Tool | Key Inputs | Output |
- — one row per tool invocation. Every number in the body must have a \
- corresponding row in this table.
+ f) ${TRACEABILITY_TABLE_FOOTER}
Format: institutional memo, plain prose with structured tables. No \
markdown embellishments beyond headers and tables. Numerical precision: \
@@ -183,7 +181,7 @@ unless context requires finer precision.
Before returning the analysis:
- Verify every number in the body has a row in the traceability table.
- - No number was hand-calculated or estimated by the language model.
+ - ${QUALITY_GATE_NO_LLM_ARITHMETIC}
- Assumptions (settlement date, day count, prepayment speed, discount \
curve) are stated with justification.
- If a required data source is unavailable (API key missing, vendor not \
diff --git a/packages/harness/src/agents/specialists/macro.ts b/packages/harness/src/agents/specialists/macro.ts
index b4f75b7f..63e742a9 100644
--- a/packages/harness/src/agents/specialists/macro.ts
+++ b/packages/harness/src/agents/specialists/macro.ts
@@ -7,25 +7,23 @@
*/
import type { AgentDef } from "../../types.js";
+import {
+ SPECIALIST_OPERATING_MODE,
+ TOOL_CALLING_CONVENTION,
+ QUALITY_GATE_NO_LLM_ARITHMETIC,
+ TRACEABILITY_TABLE_FOOTER,
+} from "./shared-prompt.js";
const systemPrompt = `\
You are the CFA Macro Analyst: an institutional specialist in macroeconomic \
strategy, sovereign risk, FX markets, commodities, and emerging markets. \
You are dispatched by the CFA Chief Analyst to handle macro sub-tasks that \
require a focused tool subset and dedicated domain expertise. You do not \
-delegate to other agents.
-
-Every number in your output must trace to a specific tool call with logged \
-inputs. LLM-generated arithmetic is prohibited. If a required calculation \
-cannot be sourced from a tool invocation, state that gap explicitly and \
-identify the data needed to close it.
+delegate to other agents. ${QUALITY_GATE_NO_LLM_ARITHMETIC}
1. ROLE AND OPERATING MODE
- You receive a self-contained \`sub_prompt\` from the chief-analyst. Treat it \
-as your complete brief — you do not have access to the parent conversation. \
-The \`context\` object may carry identifiers, prior numeric findings, or \
-acceptance criteria; use all of it.
+${SPECIALIST_OPERATING_MODE}
Your deliverable returns to the chief-analyst, who aggregates it into the \
final memo. Write in institutional voice. Preserve full tool-call traceability \
@@ -109,12 +107,7 @@ governance indicators, and trade statistics.
3. TOOL CALLING CONVENTION
- All tool inputs use the wrapped envelope form:
- { "input": { ...params... } }
-
- Use bare tool names (e.g., \`country_risk_premium\`). The harness resolves \
-wire-prefixed names internally — never include the MCP wire prefix in tool \
-calls.
+${TOOL_CALLING_CONVENTION}
Prefer free data tools (FRED, World Bank, ACLED, GDELT) before paid-vendor \
tools (Moody's, LSEG, S&P) unless the task explicitly requires vendor \
@@ -163,9 +156,7 @@ with the key variable changed and the resulting impact.
Geopolitical risk must appear in this section whenever ACLED/GDELT/GDACS data \
show elevated signals.
- TOOL-CALL TRACEABILITY TABLE:
- | # | Tool | Key Inputs | Output |
- One row per tool invocation, in order of execution.
+ ${TRACEABILITY_TABLE_FOOTER}
Numerical precision: percentages and basis points to two decimal places; \
currency rates to four decimal places; index levels to two decimal places.
@@ -174,7 +165,7 @@ currency rates to four decimal places; index levels to two decimal places.
Before returning your memo, verify:
- Every number in the analysis body appears in the traceability table.
- - No number was hand-calculated or LLM-estimated.
+ - ${QUALITY_GATE_NO_LLM_ARITHMETIC}
- Scenarios are present for every macro conclusion (this domain has high \
regime-change risk; scenarios are mandatory, not optional).
- If a required data source is unavailable, the gap is documented with the \
diff --git a/packages/harness/src/agents/specialists/private-markets.ts b/packages/harness/src/agents/specialists/private-markets.ts
index 0a1bbe98..cf3af2fb 100644
--- a/packages/harness/src/agents/specialists/private-markets.ts
+++ b/packages/harness/src/agents/specialists/private-markets.ts
@@ -11,6 +11,12 @@
*/
import type { AgentDef } from "../../types.js";
+import {
+ SPECIALIST_OPERATING_MODE,
+ TOOL_CALLING_CONVENTION,
+ QUALITY_GATE_NO_LLM_ARITHMETIC,
+ TRACEABILITY_TABLE_FOOTER,
+} from "./shared-prompt.js";
const systemPrompt = `\
You are the CFA Private Markets Analyst, a specialist in private equity, \
@@ -19,18 +25,11 @@ by the CFA Chief Analyst.
1. OPERATING MODE
- You receive a self-contained \`sub_prompt\` from the chief. You do not see \
-the parent conversation or any context outside what was passed to you. Treat \
-the sub_prompt (and any structured context block below it) as the complete \
-specification of your task. Produce a structured analysis the chief can \
-incorporate verbatim into their memo, including your own tool-call traceability \
-table. Do not ask follow-up questions; work with what you have and flag any \
-data gaps explicitly.
+${SPECIALIST_OPERATING_MODE}
2. TOOL INVENTORY
- You have access to the following bare-name tools. All inputs use a wrapped \
-envelope: { "input": { ...params... } }. Never include wire prefixes.
+ You have access to the following bare-name tools.
Compute — LBO / PE (cfa-core, 128-bit decimal precision):
lbo_model — Full LBO with multi-tranche debt and cash sweep
@@ -114,13 +113,7 @@ envelope: { "input": { ...params... } }. Never include wire prefixes.
3. TOOL CALLING CONVENTION
- Call tools by bare name. Wrap every input in the standard envelope:
- { "input": { "param1": value, "param2": value, ... } }
-
- Execute independent calls in the same turn. For chains (data → compute), \
-retrieve financials first, then pass exact extracted values into compute tools \
-in the next turn. Never interpolate or re-derive values that came from a \
-prior tool result.
+${TOOL_CALLING_CONVENTION}
4. DOMAIN EXPERTISE
@@ -228,15 +221,14 @@ metric (IRR, MOIC, EPS accretion, or net TVPI).
(e.g., 100 bps increase in financing cost reduces IRR by X%; 1.0x exit \
multiple compression reduces MOIC by Y%).
- f) Tool-call traceability table (mandatory, one row per invocation):
- | # | Tool | Key Inputs | Output |
+ f) ${TRACEABILITY_TABLE_FOOTER}
6. QUALITY GATE
Before returning your analysis, verify:
- Every number in sections (b) through (e) maps to a row in the \
traceability table.
- - No number was hand-calculated or estimated by the language model.
+ - ${QUALITY_GATE_NO_LLM_ARITHMETIC}
- Assumptions are stated with source or convention citation.
- Scenario analysis (base / bull / bear) is present for any return output.
- Sources & uses table balances (total sources = total uses) for any LBO \
diff --git a/packages/harness/src/agents/specialists/quant-risk.ts b/packages/harness/src/agents/specialists/quant-risk.ts
index d440bf13..43322dd2 100644
--- a/packages/harness/src/agents/specialists/quant-risk.ts
+++ b/packages/harness/src/agents/specialists/quant-risk.ts
@@ -9,6 +9,12 @@
*/
import type { AgentDef } from "../../types.js";
+import {
+ SPECIALIST_OPERATING_MODE,
+ TOOL_CALLING_CONVENTION,
+ QUALITY_GATE_NO_LLM_ARITHMETIC,
+ TRACEABILITY_TABLE_FOOTER,
+} from "./shared-prompt.js";
const systemPrompt = `\
You are the CFA Quant Risk Analyst: an institutional specialist in quantitative \
@@ -17,25 +23,22 @@ allocation, and index construction. You are dispatched by the CFA Chief Analyst
via the delegation mechanism — you cannot see the parent conversation and must \
operate solely on the sub-prompt and structured context you receive.
-Every number you produce must originate from an MCP tool call. LLM-generated \
-arithmetic is prohibited. If a required calculation has no corresponding tool, \
-state that explicitly and document what data would be required.
+${QUALITY_GATE_NO_LLM_ARITHMETIC}
1. ROLE AND OPERATING MODE
- You operate in specialist mode: self-contained, single-task focus. The \
-chief-analyst has handed you a specific quantitative or risk sub-task. Execute \
-it fully using the tool subset below, then return a structured analysis with \
-a complete tool-call traceability table. Do not attempt to answer questions \
-outside your domain — escalate gaps back via your output text.
+${SPECIALIST_OPERATING_MODE}
+
+ You operate in specialist mode: self-contained, single-task focus. Execute \
+the task fully using the tool subset below, then return a structured analysis \
+with a complete tool-call traceability table. Do not attempt to answer \
+questions outside your domain — escalate gaps back via your output text.
2. MCP TOOL SURFACE
- All tool calls use bare names (e.g., \`factor_model\`). The harness \
-translates bare names to wire names internally — never include the wire prefix.
+${TOOL_CALLING_CONVENTION}
- All tool inputs use a wrapped envelope:
- { "input": { ...params... } }
+ Tool inventory:
2a. cfa-core compute — quantitative and risk tools (128-bit decimal precision)
@@ -189,8 +192,7 @@ scenario_analysis or sensitivity_matrix.
e) Close with a risk section identifying the top three quantitative risks \
(e.g., factor crowding, tail-risk concentration, model misspecification) and \
their numerical impact.
- f) Append a tool-call traceability table: | # | Tool | Key Inputs | Output |
- — one row per tool invocation.
+ f) ${TRACEABILITY_TABLE_FOOTER}
Format: institutional memo, plain prose with structured tables. No markdown \
embellishments beyond headers and tables. Numerical precision: two decimal \
@@ -214,7 +216,7 @@ dollar figures rounded to the nearest thousand unless context requires more.
Before returning your analysis:
- Every number in the body has a row in the traceability table.
- - No number was hand-calculated or estimated by the language model.
+ - ${QUALITY_GATE_NO_LLM_ARITHMETIC}
- All assumptions are stated with explicit justification.
- If a required tool or data source is unavailable (e.g., FactSet not \
subscribed), state the gap and what the fallback assumption would be.
diff --git a/packages/harness/src/agents/specialists/shared-prompt.ts b/packages/harness/src/agents/specialists/shared-prompt.ts
new file mode 100644
index 00000000..d66e5d3c
--- /dev/null
+++ b/packages/harness/src/agents/specialists/shared-prompt.ts
@@ -0,0 +1,78 @@
+/**
+ * Shared system-prompt fragments interpolated by every CFA specialist
+ * agent. Centralised so governance edits (LLM-arithmetic-prohibited, the
+ * traceability mandate, the tool-calling envelope) are 1-file changes
+ * instead of 8-file copy-paste.
+ */
+
+/**
+ * Operating mode preamble — every specialist agent's first operating-mode
+ * section starts with this paragraph (or incorporates it). Standard across
+ * all 8 specialists.
+ */
+export const SPECIALIST_OPERATING_MODE = `\
+You receive a self-contained \`sub_prompt\` from the chief. You do not see \
+the parent conversation or any context outside what was passed to you. Treat \
+the sub_prompt (and any structured context block below it) as the complete \
+specification of your task. Produce a structured analysis the chief can \
+incorporate into their memo, including your own tool-call traceability table. \
+Do not ask follow-up questions; work with what you have and flag any data \
+gaps explicitly.`;
+
+/**
+ * Tool-calling convention block — specialists MUST use the bare tool name
+ * and the wrapped { "input": { ... } } envelope. Identical across all 8
+ * specialists; centralised to prevent drift.
+ */
+export const TOOL_CALLING_CONVENTION = `\
+Call tools by bare name (e.g., \`option_pricer\`, NOT \
+\`mcp__plugin_cfa-core_cfa-core__option_pricer\`). The harness translates \
+bare names to the wire-prefixed form internally — never include the wire \
+prefix in tool calls. Wrap every input in the standard envelope:
+
+ { "input": { ...params... } }
+
+Execute independent calls in the same turn. For chains (data → compute), \
+retrieve data first, then pass exact values into compute tools in the next \
+turn. Never interpolate or re-derive values that came from a prior tool result.`;
+
+/**
+ * Quality-gate prose — the "no LLM arithmetic" rule. Specialists embed
+ * this sentence in their quality-gate sections.
+ */
+export const QUALITY_GATE_NO_LLM_ARITHMETIC = `\
+LLM-generated arithmetic is prohibited. Every numerical result must be \
+sourced from a tool invocation with logged inputs. If a number cannot be \
+sourced from an available tool, state the gap explicitly and identify the \
+data needed to close it — do not estimate.`;
+
+/**
+ * Traceability table footer — every specialist closes with this mandate.
+ * The chief aggregates specialists' outputs; the table is the audit trail.
+ */
+export const TRACEABILITY_TABLE_FOOTER = `\
+Close every analysis with a tool-call traceability table (mandatory, one \
+row per invocation):
+
+ | # | Tool | Key Inputs | Output |
+
+Every number in the body must have a corresponding row in this table. The \
+chief uses these tables to assemble the final memo's audit appendix.`;
+
+/**
+ * Convenience: the four fragments joined with section markers, for
+ * specialists that prefer to interpolate one block instead of four.
+ */
+export const SPECIALIST_PREAMBLE = [
+ "## Operating mode (delegated)",
+ "",
+ SPECIALIST_OPERATING_MODE,
+ "",
+ "## Tool-calling convention",
+ "",
+ TOOL_CALLING_CONVENTION,
+ "",
+ "## Quality gate",
+ "",
+ QUALITY_GATE_NO_LLM_ARITHMETIC,
+].join("\n");
diff --git a/packages/harness/src/audit/chain.ts b/packages/harness/src/audit/chain.ts
index 490391b0..b9b366e9 100644
--- a/packages/harness/src/audit/chain.ts
+++ b/packages/harness/src/audit/chain.ts
@@ -1,76 +1,27 @@
/**
- * File-based audit sink implementation.
+ * File-based audit sink — Phase 32 Wave 2.
*
- * Each AuditRecord is written as pretty-printed JSON to
/.json.
- * Writes are atomic: temp file written then renamed.
+ * Thin wrapper over FileJsonStore.
*/
-import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises";
-import { join } from "node:path";
import type { AuditRecord, AuditSink } from "../types.js";
-
-function tmpPath(dir: string, auditId: string): string {
- return join(dir, `.${auditId}.json.tmp`);
-}
-
-function finalPath(dir: string, auditId: string): string {
- return join(dir, `${auditId}.json`);
-}
-
-async function ensureDir(dir: string): Promise {
- await mkdir(dir, { recursive: true });
-}
-
-async function parseRecord(dir: string, filename: string): Promise {
- try {
- const raw = await readFile(join(dir, filename), "utf-8");
- return JSON.parse(raw) as AuditRecord;
- } catch {
- return null;
- }
-}
+import { createFileJsonStore } from "../persistence/index.js";
export function createFileAuditSink(opts: { dir: string }): AuditSink {
- const { dir } = opts;
+ const store = createFileJsonStore({
+ dir: opts.dir,
+ idOf: (r) => r.audit_id,
+ compare: (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
+ });
return {
- async write(record: AuditRecord): Promise {
- await ensureDir(dir);
- const tmp = tmpPath(dir, record.audit_id);
- const dest = finalPath(dir, record.audit_id);
- await writeFile(tmp, JSON.stringify(record, null, 2), "utf-8");
- await rename(tmp, dest);
- },
-
- async read(auditId: string): Promise {
- const record = await parseRecord(dir, `${auditId}.json`);
- return record;
- },
-
- async list(filter?: { agentId?: string; since?: Date }): Promise {
- await ensureDir(dir);
- let entries: string[];
- try {
- entries = await readdir(dir);
- } catch {
- return [];
- }
-
- const jsonFiles = entries.filter((f) => f.endsWith(".json") && !f.startsWith("."));
- const records = await Promise.all(jsonFiles.map((f) => parseRecord(dir, f)));
- let valid = records.filter((r): r is AuditRecord => r !== null);
-
- if (filter?.agentId !== undefined) {
- const id = filter.agentId;
- valid = valid.filter((r) => r.agent_id === id);
- }
- if (filter?.since !== undefined) {
- const since = filter.since.getTime();
- valid = valid.filter((r) => new Date(r.timestamp).getTime() >= since);
- }
-
- return valid.sort(
- (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
- );
+ write: (record) => store.save(record),
+ read: (auditId) => store.load(auditId),
+ async list(filter?: { agentId?: string; since?: Date }) {
+ return store.list((r) => {
+ if (filter?.agentId !== undefined && r.agent_id !== filter.agentId) return false;
+ if (filter?.since !== undefined && new Date(r.timestamp).getTime() < filter.since.getTime()) return false;
+ return true;
+ });
},
};
}
diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts
index f3cc62ee..3083daa9 100644
--- a/packages/harness/src/index.ts
+++ b/packages/harness/src/index.ts
@@ -58,6 +58,13 @@ export {
createDelegationTools,
} from "./agents/delegate.js";
+// Phase 32 Wave 2 — generic file-backed JSON store primitive
+export {
+ createFileJsonStore,
+ type FileJsonStore,
+ type FileJsonStoreOptions,
+} from "./persistence/index.js";
+
// Phase 32 Wave 1 — ToolCatalogValidator ACL
export {
filterToolsForAgent,
diff --git a/packages/harness/src/mcp-client/http.ts b/packages/harness/src/mcp-client/http.ts
index badd0364..11190f14 100644
--- a/packages/harness/src/mcp-client/http.ts
+++ b/packages/harness/src/mcp-client/http.ts
@@ -12,6 +12,8 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
import type { MCPClient, CanonicalTool } from "../types.js";
import {
extractBareFromWire,
+ toCanonicalTool,
+ unwrapMcpContent,
type ResolvedTool,
} from "./name-resolver.js";
@@ -30,33 +32,11 @@ export interface HTTPMCPServerConfig {
headers?: Record;
}
-interface SDKToolDef {
- name: string;
- description?: string;
- inputSchema: {
- type: "object";
- properties?: Record;
- required?: string[];
- };
-}
-
interface ServerEntry {
config: HTTPMCPServerConfig;
client: Client;
}
-function sdkToolToCanonical(tool: SDKToolDef, prefix: string): CanonicalTool {
- const bareName = extractBareFromWire(tool.name, prefix);
- return {
- name: bareName,
- description: tool.description ?? "",
- input_schema: {
- type: "object",
- properties: (tool.inputSchema.properties ?? {}) as Record,
- required: tool.inputSchema.required,
- },
- };
-}
/**
* Builds a bare→ResolvedTool map from HTTP server configs and their reported
@@ -121,12 +101,12 @@ export function createHTTPMCPClient(servers: HTTPMCPServerConfig[]): MCPClient {
}
const response = await client.listTools();
- const wireNames = (response.tools as SDKToolDef[]).map((t) => t.name);
+ const wireNames = response.tools.map((t) => t.name);
wireToolsByServer.set(config.name, wireNames);
clientByServer.set(config.name, client);
- for (const tool of response.tools as SDKToolDef[]) {
- toolCatalog.push(sdkToolToCanonical(tool, config.prefix));
+ for (const tool of response.tools) {
+ toolCatalog.push(toCanonicalTool(tool, config.prefix));
}
}
@@ -159,15 +139,8 @@ export function createHTTPMCPClient(servers: HTTPMCPServerConfig[]): MCPClient {
);
// Unwrap text content from the canonical MCP response shape.
- const content = result.content as Array<{ type: string; text?: string }>;
- const firstText = content?.find((c) => c.type === "text" && typeof c.text === "string");
- if (firstText?.text != null) {
- try {
- return JSON.parse(firstText.text) as unknown;
- } catch {
- return firstText.text;
- }
- }
+ const unwrapped = unwrapMcpContent(result);
+ if (unwrapped !== undefined) return unwrapped;
return result;
},
diff --git a/packages/harness/src/mcp-client/name-resolver.ts b/packages/harness/src/mcp-client/name-resolver.ts
index fe5e8cee..3faf9287 100644
--- a/packages/harness/src/mcp-client/name-resolver.ts
+++ b/packages/harness/src/mcp-client/name-resolver.ts
@@ -3,8 +3,12 @@
*
* Bare name: "option_pricer"
* Wire name: "mcp__plugin_cfa-core_cfa-core__option_pricer"
+ *
+ * Also exports shared MCP transport helpers:
+ * - toCanonicalTool — wire-format → CanonicalTool
+ * - unwrapMcpContent — tools/call result → parsed/raw text
*/
-import type { MCPServerConfig } from "../types.js";
+import type { MCPServerConfig, CanonicalTool } from "../types.js";
export interface ResolvedTool {
serverName: string;
@@ -60,3 +64,67 @@ export function extractBareFromWire(wireName: string, prefix: string): string {
}
return wireName;
}
+
+// ---------------------------------------------------------------------------
+// Shared MCP transport helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Wire-format tool descriptor returned by every MCP server's tools/list.
+ * Both the SDK Client (@modelcontextprotocol/sdk) and the stdio JSON-RPC
+ * transport return tools in this shape.
+ */
+export interface WireTool {
+ name: string;
+ description?: string;
+ inputSchema?: {
+ type?: string;
+ properties?: Record;
+ required?: string[];
+ };
+}
+
+/**
+ * Translate a single wire-format tool into the harness's CanonicalTool
+ * shape, stripping the server's prefix from the bare name.
+ *
+ * Used by every MCP client transport (stdio, SSE, StreamableHTTP) so the
+ * agent loop sees a uniform CanonicalTool[] regardless of how the catalog
+ * was fetched.
+ */
+export function toCanonicalTool(tool: WireTool, prefix: string): CanonicalTool {
+ const bareName = extractBareFromWire(tool.name, prefix);
+ const schema: CanonicalTool["input_schema"] = {
+ type: "object",
+ properties: tool.inputSchema?.properties ?? {},
+ };
+ if (tool.inputSchema?.required !== undefined) {
+ schema.required = tool.inputSchema.required;
+ }
+ return {
+ name: bareName,
+ description: tool.description ?? "",
+ input_schema: schema,
+ };
+}
+
+/**
+ * Wire-format tool result returned by every MCP server's tools/call.
+ * Contains an array of content parts. We extract the first text part
+ * and try to JSON-parse its body; otherwise we return the raw string.
+ *
+ * Returns the parsed/raw text content, or undefined when no text part
+ * exists (server returned only resource references, images, etc.).
+ */
+export function unwrapMcpContent(result: unknown): unknown {
+ const r = result as { content?: Array<{ type: string; text?: string }> } | null | undefined;
+ const content = r?.content;
+ if (!Array.isArray(content)) return undefined;
+ const textPart = content.find((c) => c.type === "text" && typeof c.text === "string");
+ if (textPart?.text == null) return undefined;
+ try {
+ return JSON.parse(textPart.text) as unknown;
+ } catch {
+ return textPart.text;
+ }
+}
diff --git a/packages/harness/src/mcp-client/sse.ts b/packages/harness/src/mcp-client/sse.ts
index 394477f3..80d9d3e8 100644
--- a/packages/harness/src/mcp-client/sse.ts
+++ b/packages/harness/src/mcp-client/sse.ts
@@ -22,6 +22,7 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import type { MCPClient, CanonicalTool } from "../types.js";
+import { toCanonicalTool, unwrapMcpContent } from "./name-resolver.js";
// ---------------------------------------------------------------------------
// Public types
@@ -40,16 +41,6 @@ export interface SSEMCPServerConfig {
// Internal types
// ---------------------------------------------------------------------------
-interface SdkTool {
- name: string;
- description?: string;
- inputSchema?: {
- type?: string;
- properties?: Record;
- required?: string[];
- };
-}
-
interface ServerEntry {
config: SSEMCPServerConfig;
client: Client;
@@ -61,25 +52,6 @@ interface ServerEntry {
const TOOL_CALL_TIMEOUT_MS = 30_000;
-function extractBare(wireName: string, prefix: string): string {
- if (prefix && wireName.startsWith(prefix)) {
- return wireName.slice(prefix.length);
- }
- return wireName;
-}
-
-function sdkToolToCanonical(tool: SdkTool, prefix: string): CanonicalTool {
- return {
- name: extractBare(tool.name, prefix),
- description: tool.description ?? "",
- input_schema: {
- type: "object",
- properties: tool.inputSchema?.properties ?? {},
- required: tool.inputSchema?.required,
- },
- };
-}
-
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
@@ -123,10 +95,10 @@ export function createSSEMCPClient(servers: SSEMCPServerConfig[]): MCPClient {
entries.set(config.name, { config, client: sdkClient });
const listResult = await sdkClient.listTools();
- const sdkTools = (listResult.tools ?? []) as SdkTool[];
+ const sdkTools = listResult.tools ?? [];
for (const sdkTool of sdkTools) {
- const canonical = sdkToolToCanonical(sdkTool, config.prefix);
+ const canonical = toCanonicalTool(sdkTool, config.prefix);
if (bareToServer.has(canonical.name)) {
const existing = bareToServer.get(canonical.name)!;
throw new Error(
@@ -182,16 +154,8 @@ export function createSSEMCPClient(servers: SSEMCPServerConfig[]): MCPClient {
const result = await Promise.race([resultPromise, timeoutPromise]);
// MCP spec: result.content is an array of content blocks.
- const content = (result as { content?: Array<{ type: string; text?: string }> })
- .content;
- const first = content?.[0];
- if (first?.type === "text" && typeof first.text === "string") {
- try {
- return JSON.parse(first.text) as unknown;
- } catch {
- return first.text;
- }
- }
+ const unwrapped = unwrapMcpContent(result);
+ if (unwrapped !== undefined) return unwrapped;
return result;
},
diff --git a/packages/harness/src/mcp-client/stdio.ts b/packages/harness/src/mcp-client/stdio.ts
index 08fbeebd..eaa27c23 100644
--- a/packages/harness/src/mcp-client/stdio.ts
+++ b/packages/harness/src/mcp-client/stdio.ts
@@ -10,7 +10,8 @@ import type { MCPClient, MCPServerConfig, CanonicalTool } from "../types.js";
import {
buildNameMap,
resolveBareToWire,
- extractBareFromWire,
+ toCanonicalTool,
+ unwrapMcpContent,
type ResolvedTool,
} from "./name-resolver.js";
@@ -145,18 +146,6 @@ async function listServerTools(state: ServerState): Promise {
return result?.tools ?? [];
}
-function wireToolToCanonical(tool: WireToolDef, prefix: string): CanonicalTool {
- const bareName = extractBareFromWire(tool.name, prefix);
- return {
- name: bareName,
- description: tool.description ?? "",
- input_schema: {
- type: "object",
- properties: tool.inputSchema?.properties ?? {},
- required: tool.inputSchema?.required,
- },
- };
-}
/**
* Creates and returns an MCPClient that connects to all specified MCP servers
@@ -183,7 +172,7 @@ export function createStdioMCPClient(servers: MCPServerConfig[]): MCPClient {
// Build canonical tools for this server
for (const wt of wireTools) {
- toolCatalog.push(wireToolToCanonical(wt, config.prefix));
+ toolCatalog.push(toCanonicalTool(wt, config.prefix));
}
}
@@ -218,15 +207,8 @@ export function createStdioMCPClient(servers: MCPServerConfig[]): MCPClient {
}
// Plugin returns: { content: [{ type: "text", text: "" }] }
- const result = resp.result as { content?: Array<{ type: string; text: string }> };
- const firstContent = result?.content?.[0];
- if (firstContent?.type === "text" && typeof firstContent.text === "string") {
- try {
- return JSON.parse(firstContent.text) as unknown;
- } catch {
- return firstContent.text;
- }
- }
+ const unwrapped = unwrapMcpContent(resp.result);
+ if (unwrapped !== undefined) return unwrapped;
return resp.result;
},
diff --git a/packages/harness/src/memory/session.ts b/packages/harness/src/memory/session.ts
index 603b9659..eb3a8e15 100644
--- a/packages/harness/src/memory/session.ts
+++ b/packages/harness/src/memory/session.ts
@@ -1,89 +1,38 @@
/**
- * File-based SessionStore implementation — Phase 31 Wave 4.
+ * File-based SessionStore — Phase 32 Wave 2.
*
- * Each session is written as pretty-printed JSON at `/.json`.
- * Writes are atomic: data goes to a `.tmp` file then renamed into place.
+ * Thin wrapper over FileJsonStore.
*/
-import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
-import { join } from "node:path";
import type { SessionState, SessionStore } from "../types.js";
+import { createFileJsonStore } from "../persistence/index.js";
export interface FileSessionStoreOptions {
dir: string;
}
-function tmpPath(filePath: string): string {
- return `${filePath}.tmp`;
-}
-
-async function writeAtomic(filePath: string, data: string): Promise {
- const tmp = tmpPath(filePath);
- await writeFile(tmp, data, "utf8");
- await rename(tmp, filePath);
-}
-
-function sessionPath(dir: string, sessionId: string): string {
- return join(dir, `${sessionId}.json`);
-}
-
function nowIso(): string {
return new Date().toISOString();
}
export function createFileSessionStore(opts: FileSessionStoreOptions): SessionStore {
- const { dir } = opts;
- const ready = mkdir(dir, { recursive: true });
-
- async function save(state: SessionState): Promise {
- await ready;
- const updated: SessionState = { ...state, updated_at: nowIso() };
- await writeAtomic(sessionPath(dir, state.session_id), JSON.stringify(updated, null, 2));
- }
-
- async function load(sessionId: string): Promise {
- await ready;
- try {
- const raw = await readFile(sessionPath(dir, sessionId), "utf8");
- return JSON.parse(raw) as SessionState;
- } catch (err) {
- if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
- throw err;
- }
- }
-
- async function list(
- filter?: { agentId?: string; status?: SessionState["status"] },
- ): Promise {
- await ready;
- const entries = await readdir(dir);
- const jsonFiles = entries.filter((f) => f.endsWith(".json"));
-
- const states = await Promise.all(
- jsonFiles.map(async (f) => {
- try {
- const raw = await readFile(join(dir, f), "utf8");
- return JSON.parse(raw) as SessionState;
- } catch {
- return null;
- }
- }),
- );
-
- return states
- .filter((s): s is SessionState => s !== null)
- .filter((s) => !filter?.agentId || s.agent_id === filter.agentId)
- .filter((s) => !filter?.status || s.status === filter.status)
- .sort((a, b) => b.updated_at.localeCompare(a.updated_at));
- }
-
- async function del(sessionId: string): Promise {
- await ready;
- try {
- await rm(sessionPath(dir, sessionId));
- } catch (err) {
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
- }
- }
-
- return { save, load, list, delete: del };
+ const store = createFileJsonStore({
+ dir: opts.dir,
+ idOf: (s) => s.session_id,
+ compare: (a, b) => b.updated_at.localeCompare(a.updated_at),
+ });
+
+ return {
+ async save(state) {
+ await store.save({ ...state, updated_at: nowIso() });
+ },
+ load: (sessionId) => store.load(sessionId),
+ async list(filter?: { agentId?: string; status?: SessionState["status"] }) {
+ return store.list((s) => {
+ if (filter?.agentId !== undefined && s.agent_id !== filter.agentId) return false;
+ if (filter?.status !== undefined && s.status !== filter.status) return false;
+ return true;
+ });
+ },
+ delete: (sessionId) => store.delete(sessionId),
+ };
}
diff --git a/packages/harness/src/persistence/file-json-store.ts b/packages/harness/src/persistence/file-json-store.ts
new file mode 100644
index 00000000..2136d333
--- /dev/null
+++ b/packages/harness/src/persistence/file-json-store.ts
@@ -0,0 +1,93 @@
+/**
+ * Generic file-backed JSON store — Phase 32 Wave 2.
+ *
+ * Each record is written to /. as pretty JSON.
+ * Atomic writes via .tmp + rename. Dir created lazily on first use.
+ */
+import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+
+export interface FileJsonStoreOptions {
+ /** Directory under which records are stored. Created if missing. */
+ dir: string;
+ /** File extension without leading dot. Default: "json". */
+ ext?: string;
+ /** Returns the record's file id (e.g., r => r.audit_id). */
+ idOf: (record: T) => string;
+ /** Optional comparator for list() ordering. */
+ compare?: (a: T, b: T) => number;
+}
+
+export interface FileJsonStore {
+ /** Persist a record. Atomic via tmp + rename. */
+ save(record: T): Promise;
+ /** Load a record by id. Returns null when the file does not exist. */
+ load(id: string): Promise;
+ /** List all records, optionally filtered, sorted by compare if supplied. */
+ list(filter?: (record: T) => boolean): Promise;
+ /** Remove a record by id. No-op when the file does not exist. */
+ delete(id: string): Promise;
+}
+
+export function createFileJsonStore(opts: FileJsonStoreOptions): FileJsonStore {
+ const { dir, idOf, compare } = opts;
+ const ext = opts.ext ?? "json";
+ const ready = mkdir(dir, { recursive: true });
+
+ function filePath(id: string): string {
+ return join(dir, `${id}.${ext}`);
+ }
+
+ async function save(record: T): Promise {
+ await ready;
+ const id = idOf(record);
+ const dest = filePath(id);
+ const tmp = join(dir, `.${id}.${ext}.tmp`);
+ await writeFile(tmp, JSON.stringify(record, null, 2), "utf-8");
+ await rename(tmp, dest);
+ }
+
+ async function load(id: string): Promise {
+ await ready;
+ try {
+ const raw = await readFile(filePath(id), "utf-8");
+ return JSON.parse(raw) as T;
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
+ throw err;
+ }
+ }
+
+ async function list(filter?: (record: T) => boolean): Promise {
+ await ready;
+ const entries = await readdir(dir);
+ const jsonFiles = entries.filter((f) => f.endsWith(`.${ext}`) && !f.startsWith("."));
+
+ const parsed: Array = await Promise.all(
+ jsonFiles.map(async (f): Promise => {
+ try {
+ const raw = await readFile(join(dir, f), "utf-8");
+ return JSON.parse(raw) as T;
+ } catch {
+ return null;
+ }
+ }),
+ );
+
+ let records: T[] = parsed.filter((r): r is T => r !== null);
+ if (filter) records = records.filter(filter);
+ if (compare) records = records.sort(compare);
+ return records;
+ }
+
+ async function del(id: string): Promise {
+ await ready;
+ try {
+ await unlink(filePath(id));
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
+ }
+ }
+
+ return { save, load, list, delete: del };
+}
diff --git a/packages/harness/src/persistence/index.ts b/packages/harness/src/persistence/index.ts
new file mode 100644
index 00000000..3be76f1a
--- /dev/null
+++ b/packages/harness/src/persistence/index.ts
@@ -0,0 +1,8 @@
+/**
+ * Persistence barrel — Phase 32 Wave 2.
+ */
+export {
+ createFileJsonStore,
+ type FileJsonStore,
+ type FileJsonStoreOptions,
+} from "./file-json-store.js";
diff --git a/packages/harness/tests/file-json-store.test.ts b/packages/harness/tests/file-json-store.test.ts
new file mode 100644
index 00000000..6296f28e
--- /dev/null
+++ b/packages/harness/tests/file-json-store.test.ts
@@ -0,0 +1,119 @@
+/**
+ * Tests for the generic FileJsonStore primitive — Phase 32 Wave 2.
+ */
+import { mkdtempSync, rmSync, existsSync, readdirSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { createFileJsonStore } from "../src/persistence/index.js";
+
+interface TestRecord {
+ id: string;
+ value: string;
+ ts: string;
+}
+
+function makeRecord(id: string, value = "v", ts = new Date().toISOString()): TestRecord {
+ return { id, value, ts };
+}
+
+let tmpDir: string;
+
+beforeEach(() => {
+ tmpDir = mkdtempSync(join(tmpdir(), "fjstore-test-"));
+});
+
+afterEach(() => {
+ rmSync(tmpDir, { recursive: true, force: true });
+});
+
+describe("FileJsonStore", () => {
+ it("save + load round-trip", async () => {
+ const store = createFileJsonStore({ dir: join(tmpDir, "data"), idOf: (r) => r.id });
+ const rec = makeRecord("r1", "hello");
+ await store.save(rec);
+ const loaded = await store.load("r1");
+ expect(loaded).toEqual(rec);
+ });
+
+ it("load on missing id returns null", async () => {
+ const store = createFileJsonStore({ dir: join(tmpDir, "data"), idOf: (r) => r.id });
+ expect(await store.load("nonexistent")).toBeNull();
+ });
+
+ it("list returns all records", async () => {
+ const store = createFileJsonStore({ dir: tmpDir, idOf: (r) => r.id });
+ await store.save(makeRecord("a"));
+ await store.save(makeRecord("b"));
+ await store.save(makeRecord("c"));
+ const all = await store.list();
+ expect(all).toHaveLength(3);
+ });
+
+ it("list with filter returns subset", async () => {
+ const store = createFileJsonStore({ dir: tmpDir, idOf: (r) => r.id });
+ await store.save(makeRecord("x1", "keep"));
+ await store.save(makeRecord("x2", "drop"));
+ await store.save(makeRecord("x3", "keep"));
+ const kept = await store.list((r) => r.value === "keep");
+ expect(kept).toHaveLength(2);
+ expect(kept.map((r) => r.id).sort()).toEqual(["x1", "x3"]);
+ });
+
+ it("list with compare returns sorted order", async () => {
+ const store = createFileJsonStore({
+ dir: tmpDir,
+ idOf: (r) => r.id,
+ compare: (a, b) => b.ts.localeCompare(a.ts),
+ });
+ await store.save(makeRecord("a", "v", "2024-01-01T00:00:00.000Z"));
+ await store.save(makeRecord("b", "v", "2024-03-01T00:00:00.000Z"));
+ await store.save(makeRecord("c", "v", "2024-02-01T00:00:00.000Z"));
+ const sorted = await store.list();
+ expect(sorted.map((r) => r.id)).toEqual(["b", "c", "a"]);
+ });
+
+ it("delete removes the file", async () => {
+ const store = createFileJsonStore({ dir: tmpDir, idOf: (r) => r.id });
+ await store.save(makeRecord("del-me"));
+ await store.delete("del-me");
+ expect(await store.load("del-me")).toBeNull();
+ });
+
+ it("delete on missing id is a no-op (does not throw)", async () => {
+ const store = createFileJsonStore({ dir: tmpDir, idOf: (r) => r.id });
+ await expect(store.delete("ghost")).resolves.toBeUndefined();
+ });
+
+ it("mkdir is created automatically when dir is missing", async () => {
+ const newDir = join(tmpDir, "nested", "deep", "dir");
+ const store = createFileJsonStore({ dir: newDir, idOf: (r) => r.id });
+ await store.save(makeRecord("auto-mkdir"));
+ expect(existsSync(newDir)).toBe(true);
+ expect(await store.load("auto-mkdir")).not.toBeNull();
+ });
+
+ it("atomic write — .tmp file does not appear in list()", async () => {
+ const store = createFileJsonStore({ dir: tmpDir, idOf: (r) => r.id });
+ // Introduce a stray .tmp file to simulate a crashed write
+ const { writeFile } = await import("node:fs/promises");
+ await writeFile(join(tmpDir, ".stray.json.tmp"), "{}", "utf-8");
+ await store.save(makeRecord("real"));
+ const all = await store.list();
+ expect(all).toHaveLength(1);
+ expect(all[0].id).toBe("real");
+ });
+
+ it("list with filter and compare composes correctly", async () => {
+ const store = createFileJsonStore({
+ dir: tmpDir,
+ idOf: (r) => r.id,
+ compare: (a, b) => a.id.localeCompare(b.id),
+ });
+ await store.save(makeRecord("z", "keep", "2024-01-01T00:00:00.000Z"));
+ await store.save(makeRecord("a", "keep", "2024-03-01T00:00:00.000Z"));
+ await store.save(makeRecord("m", "drop", "2024-02-01T00:00:00.000Z"));
+ const result = await store.list((r) => r.value === "keep");
+ expect(result.map((r) => r.id)).toEqual(["a", "z"]);
+ });
+});
diff --git a/packages/harness/tests/mcp-client.test.ts b/packages/harness/tests/mcp-client.test.ts
index 2ba56cc6..1a5026f6 100644
--- a/packages/harness/tests/mcp-client.test.ts
+++ b/packages/harness/tests/mcp-client.test.ts
@@ -3,7 +3,13 @@
*/
import { describe, it, expect } from "vitest";
import { existsSync } from "node:fs";
-import { buildNameMap, resolveBareToWire, extractBareFromWire } from "../src/mcp-client/name-resolver.js";
+import {
+ buildNameMap,
+ resolveBareToWire,
+ extractBareFromWire,
+ toCanonicalTool,
+ unwrapMcpContent,
+} from "../src/mcp-client/name-resolver.js";
import { createStdioMCPClient } from "../src/mcp-client/stdio.js";
import type { MCPServerConfig } from "../src/types.js";
@@ -83,6 +89,96 @@ describe("name-resolver", () => {
});
});
+// ---------------------------------------------------------------------------
+// toCanonicalTool unit tests
+// ---------------------------------------------------------------------------
+
+describe("toCanonicalTool", () => {
+ const PREFIX = "mcp__plugin_cfa-core_cfa-core__";
+
+ it("strips the prefix from the tool name", () => {
+ const result = toCanonicalTool(
+ { name: `${PREFIX}option_pricer`, description: "Price an option" },
+ PREFIX,
+ );
+ expect(result.name).toBe("option_pricer");
+ expect(result.description).toBe("Price an option");
+ });
+
+ it("passes the name through unchanged when prefix does not match", () => {
+ const result = toCanonicalTool({ name: "some_bare_tool" }, PREFIX);
+ expect(result.name).toBe("some_bare_tool");
+ });
+
+ it("defaults description to empty string when missing", () => {
+ const result = toCanonicalTool({ name: "a_tool" }, "");
+ expect(result.description).toBe("");
+ });
+
+ it("defaults properties to {} when inputSchema is absent", () => {
+ const result = toCanonicalTool({ name: "a_tool" }, "");
+ expect(result.input_schema.properties).toEqual({});
+ expect(result.input_schema.required).toBeUndefined();
+ });
+
+ it("propagates required array when present in inputSchema", () => {
+ const result = toCanonicalTool(
+ {
+ name: "a_tool",
+ inputSchema: {
+ type: "object",
+ properties: { x: { type: "number" } },
+ required: ["x"],
+ },
+ },
+ "",
+ );
+ expect(result.input_schema.required).toEqual(["x"]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// unwrapMcpContent unit tests
+// ---------------------------------------------------------------------------
+
+describe("unwrapMcpContent", () => {
+ it("parses valid JSON text content to an object", () => {
+ const result = unwrapMcpContent({
+ content: [{ type: "text", text: '{"price":"0.0208"}' }],
+ });
+ expect(result).toEqual({ price: "0.0208" });
+ });
+
+ it("returns the raw string when text content is not valid JSON", () => {
+ const result = unwrapMcpContent({
+ content: [{ type: "text", text: "plain text result" }],
+ });
+ expect(result).toBe("plain text result");
+ });
+
+ it("returns undefined when no text part exists", () => {
+ const result = unwrapMcpContent({
+ content: [{ type: "resource", uri: "file://foo" }],
+ });
+ expect(result).toBeUndefined();
+ });
+
+ it("returns undefined when content array is empty", () => {
+ const result = unwrapMcpContent({ content: [] });
+ expect(result).toBeUndefined();
+ });
+
+ it("skips a leading non-text block and finds the first text part (.find() semantics)", () => {
+ const result = unwrapMcpContent({
+ content: [
+ { type: "resource", uri: "file://ignored" },
+ { type: "text", text: '{"found":true}' },
+ ],
+ });
+ expect(result).toEqual({ found: true });
+ });
+});
+
// ---------------------------------------------------------------------------
// stdio integration test (skipped if plugin dist/server.js is absent)
// ---------------------------------------------------------------------------
diff --git a/packages/harness/tests/shared-prompt.test.ts b/packages/harness/tests/shared-prompt.test.ts
new file mode 100644
index 00000000..1bc901d6
--- /dev/null
+++ b/packages/harness/tests/shared-prompt.test.ts
@@ -0,0 +1,94 @@
+/**
+ * shared-prompt.ts governance tests — Phase 32 Wave 2.
+ *
+ * Verifies that:
+ * 1. Each shared constant is non-empty.
+ * 2. No shared constant contains a literal `${` (template-literal escaping bug).
+ * 3. Every specialist systemPrompt contains the canonical no-LLM-arithmetic sentence.
+ * 4. Every specialist systemPrompt mentions the { "input": { ...params... } } envelope.
+ * 5. Every specialist systemPrompt contains the traceability table mandate.
+ */
+
+import { describe, expect, it } from "vitest";
+import {
+ QUALITY_GATE_NO_LLM_ARITHMETIC,
+ SPECIALIST_OPERATING_MODE,
+ SPECIALIST_PREAMBLE,
+ TOOL_CALLING_CONVENTION,
+ TRACEABILITY_TABLE_FOOTER,
+} from "../src/agents/specialists/shared-prompt.js";
+import { creditAnalyst } from "../src/agents/specialists/credit.js";
+import { derivativesAnalyst } from "../src/agents/specialists/derivatives.js";
+import { equityAnalyst } from "../src/agents/specialists/equity.js";
+import { esgRegulatoryAnalyst } from "../src/agents/specialists/esg-regulatory.js";
+import { fixedIncomeAnalyst } from "../src/agents/specialists/fixed-income.js";
+import { macroAnalyst } from "../src/agents/specialists/macro.js";
+import { privateMarketsAnalyst } from "../src/agents/specialists/private-markets.js";
+import { quantRiskAnalyst } from "../src/agents/specialists/quant-risk.js";
+
+const ALL_SPECIALISTS = [
+ creditAnalyst,
+ derivativesAnalyst,
+ equityAnalyst,
+ esgRegulatoryAnalyst,
+ fixedIncomeAnalyst,
+ macroAnalyst,
+ privateMarketsAnalyst,
+ quantRiskAnalyst,
+];
+
+describe("shared-prompt constants", () => {
+ it("SPECIALIST_OPERATING_MODE is non-empty and has no literal ${", () => {
+ expect(SPECIALIST_OPERATING_MODE.length).toBeGreaterThan(0);
+ expect(SPECIALIST_OPERATING_MODE).not.toContain("${");
+ });
+
+ it("TOOL_CALLING_CONVENTION is non-empty and has no literal ${", () => {
+ expect(TOOL_CALLING_CONVENTION.length).toBeGreaterThan(0);
+ expect(TOOL_CALLING_CONVENTION).not.toContain("${");
+ });
+
+ it("QUALITY_GATE_NO_LLM_ARITHMETIC is non-empty and has no literal ${", () => {
+ expect(QUALITY_GATE_NO_LLM_ARITHMETIC.length).toBeGreaterThan(0);
+ expect(QUALITY_GATE_NO_LLM_ARITHMETIC).not.toContain("${");
+ });
+
+ it("TRACEABILITY_TABLE_FOOTER is non-empty and has no literal ${", () => {
+ expect(TRACEABILITY_TABLE_FOOTER.length).toBeGreaterThan(0);
+ expect(TRACEABILITY_TABLE_FOOTER).not.toContain("${");
+ });
+
+ it("SPECIALIST_PREAMBLE is non-empty and has no literal ${", () => {
+ expect(SPECIALIST_PREAMBLE.length).toBeGreaterThan(0);
+ expect(SPECIALIST_PREAMBLE).not.toContain("${");
+ });
+});
+
+describe("specialist systemPrompts carry all governance prose", () => {
+ it.each(ALL_SPECIALISTS)(
+ "$id: contains canonical no-LLM-arithmetic sentence",
+ (specialist) => {
+ expect(specialist.systemPrompt).toContain(
+ "LLM-generated arithmetic is prohibited"
+ );
+ }
+ );
+
+ it.each(ALL_SPECIALISTS)(
+ '$id: contains { "input": { ...params... } } envelope mention',
+ (specialist) => {
+ expect(specialist.systemPrompt).toContain(
+ '{ "input": { ...params... } }'
+ );
+ }
+ );
+
+ it.each(ALL_SPECIALISTS)(
+ "$id: contains traceability table mandate",
+ (specialist) => {
+ expect(specialist.systemPrompt).toContain(
+ "| # | Tool | Key Inputs | Output |"
+ );
+ }
+ );
+});