@moda-ai/cli 1.44.1 → 1.45.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.
package/README.md CHANGED
@@ -274,24 +274,36 @@ rendered = moda.prompt("support.triage").render({
274
274
  })
275
275
  ```
276
276
 
277
- ## Push prompts, skills, and tools without a repo scan
277
+ ## Register your harness for replays
278
278
 
279
279
  When the GitHub App or `moda harness analyze` isn't an option, have your coding
280
280
  agent write `.moda/registry.json` from the codebase and push it. Only what the
281
281
  manifest lists is uploaded. It needs just an API key.
282
282
 
283
283
  ```bash
284
- moda registry template --out=.moda/registry.json # starter manifest (moda_registry.v1)
284
+ moda registry prompt # brief to hand your coding agent
285
+ moda registry template --out=.moda/registry.json # starter manifest (moda_registry.v2)
285
286
  moda registry validate # local validation, no network
286
287
  moda registry push --dry-run # server-side preview
287
- moda registry push # prompts, skills, and tools
288
+ moda registry push # prompts, skills, tools, then agent wiring
289
+ moda registry status # registered harness and version
290
+ moda registry coverage --days=7 # recent traces vs the registry
288
291
  ```
289
292
 
290
293
  Prompts go to the prompt registry, skills to the skill library (pass `--live`
291
294
  or set `"live": true` to serve them), and tools to the tool registry as OpenAI
292
- function schemas. The format is in the bundled `moda-cli` skill. This fills
293
- the registries only: the harness graph (agents and how they connect) still
294
- comes from `moda harness analyze`, the GitHub App, or an approved report.
295
+ function schemas. The v2 sections describe how the harness runs:
296
+
297
+ - `agents`: each agent's prompt, model and params, tools, skills, handoffs,
298
+ and trace names.
299
+ - `routing`: how the harness switches prompts.
300
+ - `context`: what the runtime injects into prompts.
301
+ - `runtime` and `mcpServers`.
302
+
303
+ Replay rebuilds the agent that answered each replayed turn from these, and
304
+ switches agents when the model hands off. The format is in the bundled
305
+ `moda-cli` skill. The analyzer's citation graph (the dashboard Harness map)
306
+ still comes from `moda harness analyze`, the GitHub App, or an approved report.
295
307
 
296
308
  ## Anchors and message windows
297
309
 
package/dist/cli.js CHANGED
@@ -2062,9 +2062,539 @@ async function runSyncCommand(context) {
2062
2062
  }
2063
2063
 
2064
2064
  // src/registry.ts
2065
+ import { execFileSync } from "node:child_process";
2065
2066
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, realpathSync as realpathSync2, statSync, writeFileSync as writeFileSync2 } from "node:fs";
2066
2067
  import { dirname as dirname2, isAbsolute as isAbsolute2, relative, resolve as resolve2 } from "node:path";
2067
- var REGISTRY_SCHEMA = "moda_registry.v1";
2068
+
2069
+ // src/registry-harness.ts
2070
+ var HARNESS_SECTIONS = ["runtime", "agents", "routing", "context", "mcpServers"];
2071
+ var ROUTING_STRATEGIES = ["single", "trace_attribute", "handoff", "router", "rules"];
2072
+ var HISTORY_STRATEGIES = ["full", "window", "summary", "truncate_tokens"];
2073
+ var SKILL_LOADING_MODES = ["tool", "system_prompt", "none"];
2074
+ var CONTEXT_SOURCES = ["static", "clock", "trace", "tool", "memory", "retrieval", "unknown"];
2075
+ var CONTEXT_TARGETS = ["prompt_variable", "system_append"];
2076
+ var CLOCK_FORMATS = ["date", "datetime", "iso"];
2077
+ var MCP_TRANSPORTS = ["stdio", "http", "sse", "streamable_http"];
2078
+ var AGENT_KEY_PATTERN = /^[A-Za-z0-9_.-]{1,128}$/;
2079
+ var TOOL_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
2080
+ var VARIABLE_PATTERN = /^[A-Za-z_][A-Za-z0-9_.]{0,127}$/;
2081
+ var MAX_AGENTS = 100;
2082
+ var MAX_CONTEXT = 200;
2083
+ var MAX_MCP_SERVERS = 50;
2084
+ var MAX_HARNESS_DOCUMENT_BYTES = 1e6;
2085
+ var AGENT_FIELDS = {
2086
+ key: "string",
2087
+ name: "string",
2088
+ description: "string",
2089
+ entry: "boolean",
2090
+ prompt: "string",
2091
+ model: "object",
2092
+ tools: "string[]",
2093
+ skills: "string[]",
2094
+ handoffs: "object[]",
2095
+ subagents: "object[]",
2096
+ context: "string[]",
2097
+ maxToolRoundsPerTurn: "number",
2098
+ responseSchema: "object",
2099
+ match: "object"
2100
+ };
2101
+ var MODEL_FIELDS = {
2102
+ id: "string",
2103
+ provider: "string",
2104
+ temperature: "number",
2105
+ maxTokens: "number",
2106
+ topP: "number",
2107
+ reasoningEffort: "string"
2108
+ };
2109
+ var HANDOFF_FIELDS = { to: "string", tool: "string", description: "string" };
2110
+ var SUBAGENT_FIELDS = { agent: "string", tool: "string", description: "string" };
2111
+ var MATCH_FIELDS = { agentNames: "string[]", promptKeys: "string[]" };
2112
+ var ROUTING_FIELDS = { strategy: "string", default: "string", notes: "string" };
2113
+ var RUNTIME_FIELDS = {
2114
+ framework: "string",
2115
+ language: "string",
2116
+ maxToolRoundsPerTurn: "number",
2117
+ parallelToolCalls: "boolean",
2118
+ history: "object",
2119
+ skillLoading: "string",
2120
+ notes: "string"
2121
+ };
2122
+ var HISTORY_FIELDS = { strategy: "string", maxMessages: "number", maxTokens: "number" };
2123
+ var CONTEXT_FIELDS = {
2124
+ key: "string",
2125
+ variable: "string",
2126
+ source: "string",
2127
+ value: "string",
2128
+ format: "string",
2129
+ target: "string",
2130
+ description: "string"
2131
+ };
2132
+ var MCP_FIELDS = {
2133
+ name: "string",
2134
+ transport: "string",
2135
+ url: "string",
2136
+ command: "string",
2137
+ tools: "string[]",
2138
+ description: "string"
2139
+ };
2140
+ function handoffToolName(handoff) {
2141
+ return handoff.tool?.trim() || `transfer_to_${handoff.to.replace(/[^A-Za-z0-9_-]/g, "_")}`.slice(0, 64);
2142
+ }
2143
+ function hasHarnessSections(input) {
2144
+ return HARNESS_SECTIONS.some((section) => input[section] !== undefined);
2145
+ }
2146
+ function validateHarnessSections(input, known, errors, warnings) {
2147
+ if (!hasHarnessSections(input))
2148
+ return;
2149
+ const document = {};
2150
+ if (input.runtime !== undefined) {
2151
+ if (checkObject("runtime", input.runtime, RUNTIME_FIELDS, errors)) {
2152
+ const runtime = input.runtime;
2153
+ checkPositiveInt("runtime.maxToolRoundsPerTurn", runtime.maxToolRoundsPerTurn, errors);
2154
+ checkEnum("runtime.skillLoading", runtime.skillLoading, SKILL_LOADING_MODES, errors);
2155
+ if (runtime.history !== undefined && checkObject("runtime.history", runtime.history, HISTORY_FIELDS, errors)) {
2156
+ checkEnum("runtime.history.strategy", runtime.history.strategy, HISTORY_STRATEGIES, errors);
2157
+ checkPositiveInt("runtime.history.maxMessages", runtime.history.maxMessages, errors);
2158
+ checkPositiveInt("runtime.history.maxTokens", runtime.history.maxTokens, errors);
2159
+ }
2160
+ document.runtime = runtime;
2161
+ }
2162
+ }
2163
+ const contextKeys = new Set;
2164
+ if (input.context !== undefined) {
2165
+ const entries = listOf("context", input.context, MAX_CONTEXT, errors);
2166
+ document.context = [];
2167
+ entries.forEach((raw, index) => {
2168
+ const label = `context[${index}]`;
2169
+ if (!checkObject(label, raw, CONTEXT_FIELDS, errors))
2170
+ return;
2171
+ const entry = raw;
2172
+ if (!nonEmpty(entry.key))
2173
+ errors.push(`${label}.key is required`);
2174
+ else if (contextKeys.has(entry.key))
2175
+ errors.push(`${label}: duplicate context key "${entry.key}"`);
2176
+ else
2177
+ contextKeys.add(entry.key);
2178
+ if (!nonEmpty(entry.source))
2179
+ errors.push(`${label}.source is required (one of ${CONTEXT_SOURCES.join(", ")})`);
2180
+ else
2181
+ checkEnum(`${label}.source`, entry.source, CONTEXT_SOURCES, errors);
2182
+ checkEnum(`${label}.target`, entry.target, CONTEXT_TARGETS, errors);
2183
+ checkEnum(`${label}.format`, entry.format, CLOCK_FORMATS, errors);
2184
+ const target = entry.target ?? "prompt_variable";
2185
+ if (target === "prompt_variable") {
2186
+ if (!nonEmpty(entry.variable))
2187
+ errors.push(`${label}.variable is required for target "prompt_variable"`);
2188
+ else if (!VARIABLE_PATTERN.test(entry.variable))
2189
+ errors.push(`${label}.variable "${entry.variable}" is not a valid template variable name`);
2190
+ }
2191
+ if (entry.source === "static" && typeof entry.value !== "string")
2192
+ errors.push(`${label}.value is required for source "static"`);
2193
+ if (entry.source !== "static" && entry.value !== undefined) {
2194
+ errors.push(`${label}.value is only allowed for source "static" (record runtime values in traces instead)`);
2195
+ }
2196
+ document.context.push(entry);
2197
+ });
2198
+ }
2199
+ const agentKeys = new Set;
2200
+ const agents = input.agents === undefined ? [] : listOf("agents", input.agents, MAX_AGENTS, errors);
2201
+ for (const raw of agents) {
2202
+ if (isRecord(raw) && nonEmpty(raw.key) && AGENT_KEY_PATTERN.test(raw.key))
2203
+ agentKeys.add(raw.key);
2204
+ }
2205
+ if (input.agents !== undefined) {
2206
+ document.agents = [];
2207
+ const seen = new Set;
2208
+ let entries = 0;
2209
+ agents.forEach((raw, index) => {
2210
+ const label = `agents[${index}]`;
2211
+ if (!checkObject(label, raw, AGENT_FIELDS, errors))
2212
+ return;
2213
+ const agent = raw;
2214
+ if (!nonEmpty(agent.key))
2215
+ errors.push(`${label}.key is required`);
2216
+ else if (!AGENT_KEY_PATTERN.test(agent.key))
2217
+ errors.push(`${label}.key "${agent.key}" may only contain letters, digits, ".", "_" and "-" (max 128)`);
2218
+ else if (seen.has(agent.key))
2219
+ errors.push(`${label}: duplicate agent key "${agent.key}"`);
2220
+ else
2221
+ seen.add(agent.key);
2222
+ if (agent.entry === true)
2223
+ entries += 1;
2224
+ if (agent.model !== undefined && checkObject(`${label}.model`, agent.model, MODEL_FIELDS, errors)) {
2225
+ const model = agent.model;
2226
+ if (typeof model.temperature === "number" && (model.temperature < 0 || model.temperature > 2)) {
2227
+ errors.push(`${label}.model.temperature must be between 0 and 2`);
2228
+ }
2229
+ if (typeof model.topP === "number" && (model.topP <= 0 || model.topP > 1))
2230
+ errors.push(`${label}.model.topP must be in (0, 1]`);
2231
+ checkPositiveInt(`${label}.model.maxTokens`, model.maxTokens, errors);
2232
+ }
2233
+ checkPositiveInt(`${label}.maxToolRoundsPerTurn`, agent.maxToolRoundsPerTurn, errors);
2234
+ if (agent.match !== undefined)
2235
+ checkObject(`${label}.match`, agent.match, MATCH_FIELDS, errors);
2236
+ const handoffTools = new Set;
2237
+ (Array.isArray(agent.handoffs) ? agent.handoffs : []).forEach((handoff, hIndex) => {
2238
+ const hLabel = `${label}.handoffs[${hIndex}]`;
2239
+ if (!checkObject(hLabel, handoff, HANDOFF_FIELDS, errors))
2240
+ return;
2241
+ if (!nonEmpty(handoff.to)) {
2242
+ errors.push(`${hLabel}.to is required`);
2243
+ return;
2244
+ }
2245
+ if (!agentKeys.has(handoff.to))
2246
+ errors.push(`${hLabel}.to "${handoff.to}" is not an agent key in this manifest`);
2247
+ if (handoff.to === agent.key)
2248
+ errors.push(`${hLabel}: an agent cannot hand off to itself`);
2249
+ const tool = handoffToolName(handoff);
2250
+ if (!TOOL_NAME_PATTERN.test(tool))
2251
+ errors.push(`${hLabel}.tool "${tool}" must match ${TOOL_NAME_PATTERN.source}`);
2252
+ else if (handoffTools.has(tool))
2253
+ errors.push(`${hLabel}: duplicate handoff tool "${tool}"`);
2254
+ else if ((agent.tools ?? []).includes(tool))
2255
+ errors.push(`${hLabel}: handoff tool "${tool}" collides with a tool in ${label}.tools`);
2256
+ handoffTools.add(tool);
2257
+ });
2258
+ (Array.isArray(agent.subagents) ? agent.subagents : []).forEach((subagent, sIndex) => {
2259
+ const sLabel = `${label}.subagents[${sIndex}]`;
2260
+ if (!checkObject(sLabel, subagent, SUBAGENT_FIELDS, errors))
2261
+ return;
2262
+ if (!nonEmpty(subagent.agent))
2263
+ errors.push(`${sLabel}.agent is required`);
2264
+ else if (!agentKeys.has(subagent.agent))
2265
+ errors.push(`${sLabel}.agent "${subagent.agent}" is not an agent key in this manifest`);
2266
+ if (!nonEmpty(subagent.tool))
2267
+ errors.push(`${sLabel}.tool is required (the tool name the parent calls)`);
2268
+ else if (!(agent.tools ?? []).includes(subagent.tool)) {
2269
+ errors.push(`${sLabel}.tool "${subagent.tool}" must also be listed in ${label}.tools (with its schema in tools[])`);
2270
+ }
2271
+ });
2272
+ for (const key of agent.context ?? []) {
2273
+ if (!contextKeys.has(key))
2274
+ errors.push(`${label}.context "${key}" is not a key in context[]`);
2275
+ }
2276
+ if (nonEmpty(agent.prompt) && !known.prompts.has(agent.prompt)) {
2277
+ warnings.push(`${label}.prompt "${agent.prompt}" is not in prompts[]; it must already exist in Moda`);
2278
+ }
2279
+ for (const tool of agent.tools ?? []) {
2280
+ if (!known.tools.has(tool))
2281
+ warnings.push(`${label}.tools "${tool}" is not in tools[]; it must already exist in Moda`);
2282
+ }
2283
+ for (const skill of agent.skills ?? []) {
2284
+ if (!known.skills.has(skill))
2285
+ warnings.push(`${label}.skills "${skill}" is not in skills[]; it must already exist in Moda`);
2286
+ }
2287
+ document.agents.push(agent);
2288
+ });
2289
+ if (entries > 1)
2290
+ errors.push('agents: at most one agent may set "entry": true');
2291
+ }
2292
+ if (input.routing !== undefined) {
2293
+ if (checkObject("routing", input.routing, ROUTING_FIELDS, errors)) {
2294
+ const routing = input.routing;
2295
+ checkEnum("routing.strategy", routing.strategy, ROUTING_STRATEGIES, errors);
2296
+ if (routing.default !== undefined && !agentKeys.has(routing.default)) {
2297
+ errors.push(`routing.default "${routing.default}" is not an agent key in agents[]`);
2298
+ }
2299
+ document.routing = routing;
2300
+ }
2301
+ }
2302
+ if (input.mcpServers !== undefined) {
2303
+ document.mcpServers = [];
2304
+ const names = new Set;
2305
+ listOf("mcpServers", input.mcpServers, MAX_MCP_SERVERS, errors).forEach((raw, index) => {
2306
+ const label = `mcpServers[${index}]`;
2307
+ if (!checkObject(label, raw, MCP_FIELDS, errors))
2308
+ return;
2309
+ const server = raw;
2310
+ if (!nonEmpty(server.name))
2311
+ errors.push(`${label}.name is required`);
2312
+ else if (names.has(server.name))
2313
+ errors.push(`${label}: duplicate MCP server name "${server.name}"`);
2314
+ else
2315
+ names.add(server.name);
2316
+ checkEnum(`${label}.transport`, server.transport, MCP_TRANSPORTS, errors);
2317
+ if (server.url !== undefined && hasUrlCredentials(server.url)) {
2318
+ errors.push(`${label}.url must not contain credentials (user info or token query parameters)`);
2319
+ }
2320
+ document.mcpServers.push(server);
2321
+ });
2322
+ }
2323
+ if (Buffer.byteLength(JSON.stringify(document), "utf8") > MAX_HARNESS_DOCUMENT_BYTES) {
2324
+ errors.push(`harness sections exceed ${MAX_HARNESS_DOCUMENT_BYTES} bytes`);
2325
+ }
2326
+ return document;
2327
+ }
2328
+ function hasUrlCredentials(raw) {
2329
+ try {
2330
+ const url = new URL(raw);
2331
+ if (url.username || url.password)
2332
+ return true;
2333
+ return [...url.searchParams.keys()].some((key) => /token|key|secret|password|auth/i.test(key));
2334
+ } catch {
2335
+ return false;
2336
+ }
2337
+ }
2338
+ function listOf(label, value, max, errors) {
2339
+ if (!Array.isArray(value)) {
2340
+ errors.push(`${label} must be an array`);
2341
+ return [];
2342
+ }
2343
+ if (value.length > max)
2344
+ errors.push(`${label} has ${value.length} entries; the limit is ${max}`);
2345
+ return value;
2346
+ }
2347
+ function checkObject(label, value, spec, errors) {
2348
+ if (!isRecord(value)) {
2349
+ errors.push(`${label} must be an object`);
2350
+ return false;
2351
+ }
2352
+ let typed = true;
2353
+ for (const [key, item] of Object.entries(value)) {
2354
+ const expected = spec[key];
2355
+ if (!expected) {
2356
+ errors.push(`${label}: unknown field "${key}" (allowed: ${Object.keys(spec).join(", ")})`);
2357
+ continue;
2358
+ }
2359
+ const ok = expected === "string[]" ? Array.isArray(item) && item.every((entry) => typeof entry === "string") : expected === "object[]" ? Array.isArray(item) && item.every(isRecord) : expected === "object" ? isRecord(item) : expected === "number" ? typeof item === "number" && Number.isFinite(item) : typeof item === expected;
2360
+ if (!ok) {
2361
+ errors.push(`${label}.${key} must be ${describe(expected)}`);
2362
+ typed = false;
2363
+ }
2364
+ }
2365
+ return typed;
2366
+ }
2367
+ function describe(type) {
2368
+ if (type === "string[]")
2369
+ return "an array of strings";
2370
+ if (type === "object[]")
2371
+ return "an array of objects";
2372
+ return type === "object" ? "an object" : `a ${type}`;
2373
+ }
2374
+ function checkEnum(label, value, allowed, errors) {
2375
+ if (value === undefined || typeof value !== "string")
2376
+ return;
2377
+ if (!allowed.includes(value))
2378
+ errors.push(`${label} must be one of ${allowed.join(", ")} (got "${value}")`);
2379
+ }
2380
+ function checkPositiveInt(label, value, errors) {
2381
+ if (value === undefined || typeof value !== "number")
2382
+ return;
2383
+ if (!Number.isInteger(value) || value <= 0)
2384
+ errors.push(`${label} must be a positive integer`);
2385
+ }
2386
+ function isRecord(value) {
2387
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2388
+ }
2389
+ function nonEmpty(value) {
2390
+ return typeof value === "string" && value.trim() !== "";
2391
+ }
2392
+
2393
+ // src/registry-prompt.ts
2394
+ var REGISTRY_AGENT_BRIEF = `# Register this repo's AI harness with Moda for faithful replays
2395
+
2396
+ You are working in the repository that runs our production AI agent. Moda
2397
+ replays recorded production conversations against our agent to test prompt,
2398
+ tool, and skill changes. A replay is only as faithful as what Moda knows about
2399
+ how our agent actually runs. Your job: describe our harness to Moda exactly,
2400
+ push it with the \`moda\` CLI, and make sure our production traces say which
2401
+ agent and prompt handled each LLM call.
2402
+
2403
+ ## Ground rules
2404
+
2405
+ - Read the code; do not guess. Every value you register must come from the
2406
+ repo (cite the file in \`sourcePath\` or in a note). If something is decided
2407
+ at runtime and you can't find it, say so in your final report.
2408
+ - Copy prompt text, tool names, tool descriptions, and parameter schemas
2409
+ verbatim. For templated prompts, keep the template with its placeholders
2410
+ (\`{{name}}\`, \`{name}\`, \`\${name}\`). Never register a rendered example.
2411
+ - Never put secrets in the manifest: no API keys, tokens, auth headers, env var
2412
+ values, or customer data.
2413
+ - Do not change runtime behaviour. Part 5 adds trace metadata only. Show the
2414
+ diff and get my OK before committing it.
2415
+ - Only \`.moda/registry.json\` (plus the files it references) is uploaded.
2416
+ Nothing else in the repo leaves this machine.
2417
+
2418
+ ## Part 0: setup
2419
+
2420
+ 1. Run \`moda --version\`. You need 1.44.0 or newer. If it's older, run
2421
+ \`npm install -g @moda-ai/cli@latest\`.
2422
+ 2. Auth: \`MODA_API_KEY\` must be set, or run \`moda auth login --api-key=<key>\`.
2423
+ Ask me for the key if neither is available. Check with \`moda doctor\`.
2424
+ 3. Run \`moda registry template\` to see a complete example manifest, and
2425
+ \`moda registry status\` to see what is already registered.
2426
+
2427
+ ## Part 1: inventory the harness
2428
+
2429
+ Find every place the code calls an LLM: direct SDK calls (OpenAI, Anthropic,
2430
+ Gemini, Bedrock, OpenRouter), framework agents, and anything else. For each
2431
+ call site, write down:
2432
+
2433
+ - **Agent**: the logical agent it belongs to, with a stable key such as
2434
+ \`triage\` or \`billing\`. Different system prompts, tool sets, or models mean
2435
+ different agents. A one-off call (summariser, classifier, title generator)
2436
+ is its own agent if it runs during a conversation.
2437
+ - **Prompt**: the system prompt or messages it sends, how they're built
2438
+ (static text, template plus variables, or code that assembles them), and
2439
+ the file.
2440
+ - **Model and settings**: model id, provider, temperature, max tokens, top_p,
2441
+ and reasoning effort, wherever they're set (code, config, env defaults).
2442
+ - **Tools**: every tool the model can call at that site, with the exact JSON
2443
+ schema the model sees. Include MCP tools and agents exposed as tools.
2444
+ - **Skills**: SKILL.md files or instruction docs the agent loads, and HOW it
2445
+ loads them (a tool such as skill_read or read_file, inlined into the system
2446
+ prompt, or not at all).
2447
+ - **Loop limits**: max tool rounds or steps per turn, parallel tool calls, and
2448
+ how history is truncated or summarised.
2449
+
2450
+ Framework hints:
2451
+
2452
+ - **OpenAI Agents SDK**: \`Agent(name, instructions, model, model_settings,
2453
+ tools, handoffs)\`. \`name\` is the trace agent name. Handoffs become
2454
+ \`transfer_to_<agent>\` tools.
2455
+ - **Claude Agent SDK / Claude Code SDK**: \`ClaudeAgentOptions(system_prompt,
2456
+ model, allowed_tools, mcp_servers, agents=...)\`. Subagents are \`agents\`,
2457
+ and skills live in \`.claude/skills/*/SKILL.md\`.
2458
+ - **LangGraph / LangChain**: each node that binds an LLM (\`bind_tools\`,
2459
+ \`create_react_agent\`) is an agent. Conditional edges are the routing.
2460
+ - **Vercel AI SDK**: \`generateText\` / \`streamText({ system, tools, model,
2461
+ maxSteps | stopWhen })\`.
2462
+ - **Raw SDK calls**: the system message, \`tools=\`, and \`model=\` arguments.
2463
+
2464
+ ## Part 2: work out prompt switching
2465
+
2466
+ Work out how the harness decides which agent and prompt answers a given turn.
2467
+ That becomes \`routing\` and the agents' \`handoffs\`:
2468
+
2469
+ - **single**: one agent always answers. Prompt versions still change over
2470
+ time; Part 5 handles that.
2471
+ - **handoff**: the model transfers control by calling a tool (OpenAI Agents
2472
+ handoffs, a "transfer_to_*" function, or a supervisor delegating). Add
2473
+ \`handoffs: [{ "to": "<agent key>", "tool": "<exact tool name>",
2474
+ "description": "<when>" }]\` to the source agent.
2475
+ - **router / rules**: code picks the agent (by channel, intent classifier,
2476
+ conversation stage, user plan, or feature flag). Describe the rule in
2477
+ \`routing.notes\` and make sure Part 5's attribution names the chosen agent
2478
+ on every call.
2479
+ - **trace_attribute**: the agent is already identified on every trace.
2480
+ - **subagents** (agents as tools): the parent calls a tool that runs another
2481
+ agent and returns its result. Register that tool's schema in \`tools[]\`,
2482
+ list it in the parent's \`tools\`, and add \`subagents: [{ "agent": "<child
2483
+ key>", "tool": "<tool name>" }]\`.
2484
+
2485
+ Set \`routing.default\` to the agent that answers when nothing else decides,
2486
+ and mark the first agent a conversation reaches with \`"entry": true\`.
2487
+
2488
+ ## Part 3: list the injected context
2489
+
2490
+ For every placeholder in every prompt, and anything appended to the prompt at
2491
+ runtime, add a \`context[]\` entry:
2492
+
2493
+ - \`source: "static"\`: the same value every time (put it in \`value\`).
2494
+ - \`source: "clock"\`: the current date or time (\`format\`: date, datetime, or
2495
+ iso). Replay uses the original conversation's time.
2496
+ - \`source: "trace"\`: a per-user or per-conversation value, such as the
2497
+ profile, account, plan, or locale.
2498
+ - \`source: "memory" | "retrieval" | "tool"\`: memory recall, RAG results, or a
2499
+ tool fetched before the call.
2500
+ - \`target: "prompt_variable"\` (with \`variable\` set to the placeholder name,
2501
+ without braces) for template variables, or \`target: "system_append"\` for
2502
+ blocks the code appends.
2503
+
2504
+ List context keys on an agent with \`context: [...]\` when only some agents get
2505
+ them.
2506
+
2507
+ ## Part 4: write, validate, and push the manifest
2508
+
2509
+ Write \`.moda/registry.json\` with \`"schema": "moda_registry.v2"\`:
2510
+
2511
+ - \`prompts[]\`: \`{ key, file | content | systemPrompt | messages, name,
2512
+ description, variables, modelConfig, responseSchema, sourcePath }\`. If the
2513
+ repo already uses \`moda prompts\` (it has a \`.moda/prompts.lock.json\`),
2514
+ reuse those keys.
2515
+ - \`tools[]\`: \`{ schema: { name, description, parameters } }\`, exactly as
2516
+ sent to the model.
2517
+ - \`skills[]\`: \`{ key, file: "path/to/SKILL.md" }\`. Set \`"live": true\` only
2518
+ if I ask.
2519
+ - \`agents[]\`: \`{ key, name, entry, prompt, model: { id, provider,
2520
+ temperature, maxTokens, topP, reasoningEffort }, tools, skills, handoffs,
2521
+ subagents, context, maxToolRoundsPerTurn, responseSchema, match: {
2522
+ agentNames, promptKeys } }\`. \`match.agentNames\` are the names this agent
2523
+ carries in traces; \`match.promptKeys\` are prompt keys that identify it.
2524
+ - \`routing\`: \`{ strategy, default, notes }\`.
2525
+ - \`runtime\`: \`{ framework, language, maxToolRoundsPerTurn,
2526
+ parallelToolCalls, history: { strategy, maxMessages, maxTokens },
2527
+ skillLoading: "tool" | "system_prompt" | "none" }\`.
2528
+ - \`context[]\`: from Part 3.
2529
+ - \`mcpServers[]\`: \`{ name, transport, url | command, tools, description }\`,
2530
+ with no credentials. Their tools still go in \`tools[]\`.
2531
+
2532
+ Then run:
2533
+
2534
+ moda registry validate # local; fix every error and read every warning
2535
+ moda registry push --dry-run # server-side preview, writes nothing
2536
+ moda registry push --message="<what you registered or changed>"
2537
+
2538
+ Re-pushing an unchanged manifest is a no-op. A changed prompt adds a new
2539
+ version, and a changed harness adds a new harness version.
2540
+
2541
+ ## Part 5: make traces name the agent and prompt version
2542
+
2543
+ Replay picks the agent and prompt version that handled the replayed turn from
2544
+ attributes on each LLM call. Auto-instrumented calls don't carry them, so add
2545
+ them at each call site from Part 1:
2546
+
2547
+ | What | OTel span attribute | Vercel AI SDK \`experimental_telemetry.metadata\` | \`/v1/ingest\` event field |
2548
+ |---|---|---|---|
2549
+ | Agent | \`gen_ai.agent.name\` (or \`moda.agent_name\`) | \`moda.agent_name\` | \`agent_name\` |
2550
+ | Prompt key | \`moda.prompt_key\` | \`moda.prompt_key\` | \`prompt_name\` |
2551
+ | Prompt version | \`moda.prompt_version_id\` | \`moda.prompt_version_id\` | \`prompt_version_id\` |
2552
+
2553
+ - The agent value must be the agent's \`key\` or one of its
2554
+ \`match.agentNames\`. Frameworks that already set \`gen_ai.agent.name\`
2555
+ (OpenAI Agents SDK, many OpenInference/OpenLLMetry instrumentations) only
2556
+ need \`match.agentNames\` to list those names.
2557
+ - Get the prompt version id from \`.moda/prompts.lock.json\` (written by
2558
+ \`moda prompts sync\`), or leave it out. Replay then uses the prompt's prod
2559
+ version.
2560
+ - Node with the Moda SDK: \`Moda.withLLMCall(..., ({ span }) =>
2561
+ span.rawSpan.setAttribute(...))\`, or pass Vercel metadata through
2562
+ \`Moda.getVercelAITelemetry({ metadata })\`.
2563
+ - Python: set the attributes in your OpenTelemetry instrumentation on the LLM
2564
+ span, or send \`agent_name\` / \`prompt_*\` on \`/v1/ingest\` events.
2565
+ - Show me the diff before committing it.
2566
+
2567
+ ## Part 6: verify
2568
+
2569
+ moda registry status
2570
+ moda registry coverage --days=7
2571
+ moda registry log # each push is a commit; review what changed
2572
+
2573
+ Coverage compares the last N days of production traces with what you
2574
+ registered: the share of LLM calls naming an agent or prompt, agent names and
2575
+ prompt keys that match no registered agent, tools used in production but not
2576
+ registered, and registered agents never seen. Fix the manifest (or the
2577
+ attribution) and push again until there is nothing unexplained left. Attribution
2578
+ added in Part 5 only shows up on traces created after it ships.
2579
+
2580
+ ## Final report
2581
+
2582
+ Reply with:
2583
+
2584
+ 1. The agents you registered: key, prompt, model, tool count, skill count, and
2585
+ handoffs.
2586
+ 2. The routing strategy, and how you verified it.
2587
+ 3. Context entries whose values replay can't reproduce (\`trace\`, \`memory\`,
2588
+ \`retrieval\`, \`tool\`).
2589
+ 4. The attribution diff (or that it's pending my OK).
2590
+ 5. The \`moda registry coverage\` result, plus anything unexplained you
2591
+ couldn't resolve.
2592
+ 6. Anything you found but couldn't express in the manifest.
2593
+ `;
2594
+
2595
+ // src/registry.ts
2596
+ var REGISTRY_SCHEMA = "moda_registry.v2";
2597
+ var REGISTRY_SCHEMA_V1 = "moda_registry.v1";
2068
2598
  var DEFAULT_REGISTRY_PATH = ".moda/registry.json";
2069
2599
  var SKILL_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
2070
2600
  var MAX_ITEMS_PER_SECTION = 500;
@@ -2075,7 +2605,7 @@ var REGISTRY_TEMPLATE = {
2075
2605
  key: "support.triage",
2076
2606
  name: "Support triage system prompt",
2077
2607
  description: "Routes inbound support messages",
2078
- content: "You are a support triage agent. Classify the request and ...",
2608
+ content: "You are a support triage agent for {{company_name}}. Today is {{current_date}}. Classify the request and ...",
2079
2609
  sourcePath: "src/agents/triage/prompt.ts"
2080
2610
  },
2081
2611
  {
@@ -2108,12 +2638,49 @@ description: How to process refund requests
2108
2638
  }
2109
2639
  }
2110
2640
  }
2641
+ ],
2642
+ runtime: {
2643
+ framework: "openai-agents",
2644
+ language: "python",
2645
+ maxToolRoundsPerTurn: 6,
2646
+ history: { strategy: "window", maxMessages: 40 },
2647
+ skillLoading: "tool"
2648
+ },
2649
+ agents: [
2650
+ {
2651
+ key: "triage",
2652
+ name: "Triage",
2653
+ entry: true,
2654
+ prompt: "support.triage",
2655
+ model: { id: "openai/gpt-5.1", temperature: 0.2, maxTokens: 1024 },
2656
+ tools: ["lookup_customer"],
2657
+ handoffs: [{ to: "reply", description: "Hand off once the request is classified" }],
2658
+ match: { agentNames: ["TriageAgent"] }
2659
+ },
2660
+ {
2661
+ key: "reply",
2662
+ name: "Reply writer",
2663
+ prompt: "support.reply",
2664
+ model: { id: "anthropic/claude-sonnet-5-5", temperature: 0.4, maxTokens: 2048 },
2665
+ skills: ["refund-policy"],
2666
+ match: { agentNames: ["ReplyAgent"] }
2667
+ }
2668
+ ],
2669
+ routing: { strategy: "handoff", default: "triage" },
2670
+ context: [
2671
+ { key: "today", variable: "current_date", source: "clock", format: "date" },
2672
+ { key: "company", variable: "company_name", source: "static", value: "Acme" }
2111
2673
  ]
2112
2674
  };
2113
2675
  var REGISTRY_SUBCOMMAND_FLAGS = {
2114
2676
  template: ["out", "dry-run"],
2115
2677
  validate: ["file", "manifest"],
2116
- push: ["file", "manifest", "live", "dry-run"]
2678
+ push: ["file", "manifest", "live", "dry-run", "message"],
2679
+ log: ["limit"],
2680
+ show: [],
2681
+ status: [],
2682
+ coverage: ["days"],
2683
+ prompt: []
2117
2684
  };
2118
2685
  async function runRegistryCommand(context) {
2119
2686
  const subcommand = context.positionals[0] ?? "push";
@@ -2123,18 +2690,50 @@ async function runRegistryCommand(context) {
2123
2690
  return writeTemplate(context);
2124
2691
  case "validate": {
2125
2692
  const registry = loadRegistry(context);
2693
+ printWarnings(context, registry.warnings);
2126
2694
  context.output.writeData({
2127
- schema: REGISTRY_SCHEMA,
2695
+ schema: registry.schema,
2128
2696
  manifest: displayPath(context, registry.manifestPath),
2129
2697
  valid: true,
2130
- counts: countsOf(registry)
2698
+ counts: countsOf(registry),
2699
+ warnings: registry.warnings
2131
2700
  });
2132
2701
  return { exitCode: 0 };
2133
2702
  }
2134
2703
  case "push":
2135
2704
  return pushRegistry(context);
2705
+ case "status":
2706
+ return readRegistry(context, "/harness-registry");
2707
+ case "log": {
2708
+ const limit = context.flags.limit;
2709
+ if (limit !== undefined && !/^\d+$/.test(limit)) {
2710
+ throw new CliInputError("--limit must be a whole number.", "Usage: moda registry log [--limit=20]");
2711
+ }
2712
+ return readRegistry(context, `/registry/commits${limit ? `?limit=${limit}` : ""}`);
2713
+ }
2714
+ case "show": {
2715
+ const ref = context.positionals[1];
2716
+ if (!ref) {
2717
+ throw new CliInputError("Missing commit.", 'Usage: moda registry show <commit> (a hash from `moda registry log`, or "working")');
2718
+ }
2719
+ return readRegistry(context, ref === "working" ? "/registry/compare?to=working" : `/registry/commits/${encodeURIComponent(ref)}`);
2720
+ }
2721
+ case "coverage": {
2722
+ const days = context.flags.days;
2723
+ if (days !== undefined && !/^\d+$/.test(days)) {
2724
+ throw new CliInputError("--days must be a whole number of days.", "Usage: moda registry coverage [--days=7]");
2725
+ }
2726
+ return readRegistry(context, `/harness-registry/coverage${days ? `?days=${days}` : ""}`);
2727
+ }
2728
+ case "prompt":
2729
+ if (context.outputMode === "human")
2730
+ process.stdout.write(`${REGISTRY_AGENT_BRIEF}
2731
+ `);
2732
+ else
2733
+ context.output.writeData({ prompt: REGISTRY_AGENT_BRIEF });
2734
+ return { exitCode: 0 };
2136
2735
  default:
2137
- throw new CliInputError(`Unknown registry command '${subcommand}'.`, "Usage: moda registry template|validate|push [--file=.moda/registry.json] [--dry-run] [--live]");
2736
+ throw new CliInputError(`Unknown registry command '${subcommand}'.`, "Usage: moda registry template|validate|push|status|coverage|log|show|prompt [--file=.moda/registry.json] [--dry-run] [--live]");
2138
2737
  }
2139
2738
  }
2140
2739
  function writeTemplate(context) {
@@ -2159,6 +2758,9 @@ function writeTemplate(context) {
2159
2758
  return { exitCode: 0 };
2160
2759
  }
2161
2760
  async function pushRegistry(context) {
2761
+ const message = context.flags.message;
2762
+ if (message === "true")
2763
+ throw new CliInputError("Missing text for --message.", 'Usage: moda registry push --message="Add billing agent"');
2162
2764
  const registry = loadRegistry(context);
2163
2765
  const profileOptions = { profile: context.profile, env: context.env };
2164
2766
  const hasKey = Boolean(resolveApiKey(profileOptions));
@@ -2191,7 +2793,20 @@ async function pushRegistry(context) {
2191
2793
  await push("prompts", "/prompts/sync", registry.prompts.length, { prompts: registry.prompts });
2192
2794
  await push("skills", "/skills/sync", registry.skills.length, { skills: registry.skills });
2193
2795
  await push("tools", "/tool-definitions/sync", registry.tools.length, { tools: registry.tools });
2796
+ if (registry.harness) {
2797
+ await push("harness", "/harness-registry/sync", 1, { schema: registry.schema, document: registry.harness });
2798
+ }
2799
+ let commit;
2800
+ let commitError;
2801
+ if (!context.dryRun && sections.some((section) => section.status === "synced")) {
2802
+ try {
2803
+ commit = await callControlAPI("/registry/commits", { method: "POST", body: JSON.stringify({ ...message ? { message } : {}, ...gitInfo(context.cwd) }) }, profileOptions);
2804
+ } catch (error) {
2805
+ commitError = error instanceof Error ? error.message : String(error);
2806
+ }
2807
+ }
2194
2808
  const failed = sections.filter((section) => section.status === "error");
2809
+ printWarnings(context, registry.warnings);
2195
2810
  if (context.outputMode === "human" && !context.quiet) {
2196
2811
  for (const section of sections) {
2197
2812
  const detail = section.status === "error" ? ` — ${section.error}` : describeActions(section.response);
@@ -2200,13 +2815,26 @@ async function pushRegistry(context) {
2200
2815
  if (sections.some((section) => section.section === "tools" && section.status === "synced")) {
2201
2816
  console.error(" Replays now give your agent these tools with their real schemas and descriptions.");
2202
2817
  }
2818
+ if (sections.some((section) => section.section === "harness" && section.status === "synced")) {
2819
+ console.error(" Replays now rebuild the agent live at each replayed turn. Check `moda registry coverage`.");
2820
+ }
2821
+ const recorded = isRecord2(commit) && isRecord2(commit.commit) ? commit.commit : undefined;
2822
+ if (recorded) {
2823
+ const label = commit && isRecord2(commit) && commit.action === "unchanged" ? "no registry changes since" : "commit";
2824
+ console.error(` ${label} ${String(recorded.id).slice(0, 7)} (#${String(recorded.seq)}) ${String(recorded.summary ?? "")}`);
2825
+ } else if (commitError) {
2826
+ console.error(` commit: not recorded — ${commitError}`);
2827
+ }
2203
2828
  }
2204
2829
  context.output.writeData({
2205
- schema: REGISTRY_SCHEMA,
2830
+ schema: registry.schema,
2206
2831
  manifest: displayPath(context, registry.manifestPath),
2207
2832
  dryRun: context.dryRun,
2208
2833
  counts: countsOf(registry),
2209
- sections
2834
+ sections,
2835
+ warnings: registry.warnings,
2836
+ ...commit !== undefined ? { commit } : {},
2837
+ ...commitError ? { commitError } : {}
2210
2838
  }, {
2211
2839
  status: failed.length > 0 ? "error" : "success",
2212
2840
  ...failed.length > 0 ? {
@@ -2219,11 +2847,39 @@ async function pushRegistry(context) {
2219
2847
  });
2220
2848
  return { exitCode: failed.length > 0 ? 1 : 0 };
2221
2849
  }
2850
+ function gitInfo(cwd) {
2851
+ const git = (...args) => execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).toString().trim();
2852
+ try {
2853
+ const gitCommit = git("rev-parse", "HEAD");
2854
+ const branch = git("rev-parse", "--abbrev-ref", "HEAD");
2855
+ return {
2856
+ gitCommit,
2857
+ ...branch && branch !== "HEAD" ? { gitBranch: branch } : {},
2858
+ gitDirty: git("status", "--porcelain").length > 0
2859
+ };
2860
+ } catch {
2861
+ return {};
2862
+ }
2863
+ }
2864
+ async function readRegistry(context, endpoint) {
2865
+ const profileOptions = { profile: context.profile, env: context.env };
2866
+ if (!resolveApiKey(profileOptions)) {
2867
+ throw new CliInputError("No Moda API key found.", "Set MODA_API_KEY, run `moda auth login --api-key=<key>`, or select a profile with an API key.");
2868
+ }
2869
+ context.output.writeData(await callControlAPI(endpoint, { method: "GET" }, profileOptions));
2870
+ return { exitCode: 0 };
2871
+ }
2872
+ function printWarnings(context, warnings) {
2873
+ if (context.outputMode !== "human" || context.quiet)
2874
+ return;
2875
+ for (const warning of warnings)
2876
+ console.error(` warning: ${warning}`);
2877
+ }
2222
2878
  function describeActions(response) {
2223
- const synced = isRecord(response) && Array.isArray(response.synced) ? response.synced : [];
2879
+ const synced = isRecord2(response) && Array.isArray(response.synced) ? response.synced : [];
2224
2880
  const counts = new Map;
2225
2881
  for (const item of synced) {
2226
- if (!isRecord(item))
2882
+ if (!isRecord2(item))
2227
2883
  continue;
2228
2884
  const action = typeof item.action === "string" ? `${item.action}${typeof item.reason === "string" ? ` (${item.reason})` : ""}` : item.changed === false ? "unchanged" : item.policyChanged === true && item.changed === true ? "changed (live setting)" : item.changed === true ? "changed" : "synced";
2229
2885
  counts.set(action, (counts.get(action) ?? 0) + 1);
@@ -2298,13 +2954,17 @@ function checkFields(label, item, spec, errors) {
2298
2954
  }
2299
2955
  function resolveRegistry(input, options) {
2300
2956
  const errors = [];
2301
- if (!isRecord(input)) {
2957
+ const warnings = [];
2958
+ if (!isRecord2(input)) {
2302
2959
  throw new CliInputError("Registry manifest must be a JSON object with prompts, skills, and/or tools arrays.");
2303
2960
  }
2304
- if (input.schema !== undefined && input.schema !== REGISTRY_SCHEMA) {
2305
- errors.push(`schema must be "${REGISTRY_SCHEMA}" (got ${JSON.stringify(input.schema)})`);
2961
+ if (input.schema !== undefined && input.schema !== REGISTRY_SCHEMA && input.schema !== REGISTRY_SCHEMA_V1) {
2962
+ errors.push(`schema must be "${REGISTRY_SCHEMA}" or "${REGISTRY_SCHEMA_V1}" (got ${JSON.stringify(input.schema)})`);
2963
+ }
2964
+ if (input.schema === REGISTRY_SCHEMA_V1 && hasHarnessSections(input)) {
2965
+ errors.push(`${HARNESS_SECTIONS.join(", ")} need schema "${REGISTRY_SCHEMA}"`);
2306
2966
  }
2307
- const known = new Set(["schema", "prompts", "skills", "tools"]);
2967
+ const known = new Set(["schema", "prompts", "skills", "tools", ...HARNESS_SECTIONS]);
2308
2968
  for (const key of Object.keys(input)) {
2309
2969
  if (!known.has(key))
2310
2970
  errors.push(`unknown top-level field "${key}"`);
@@ -2336,7 +2996,7 @@ function resolveRegistry(input, options) {
2336
2996
  const sourcePathFor = (file) => relative(options.cwd, resolve2(options.cwd, file)).split("\\").join("/");
2337
2997
  const prompts = arrayOf(input.prompts, "prompts", errors).map((raw, index) => {
2338
2998
  const label = `prompts[${index}]`;
2339
- if (!isRecord(raw)) {
2999
+ if (!isRecord2(raw)) {
2340
3000
  errors.push(`${label} must be an object`);
2341
3001
  return;
2342
3002
  }
@@ -2361,7 +3021,7 @@ function resolveRegistry(input, options) {
2361
3021
  });
2362
3022
  const skills = arrayOf(input.skills, "skills", errors).map((raw, index) => {
2363
3023
  const label = `skills[${index}]`;
2364
- if (!isRecord(raw)) {
3024
+ if (!isRecord2(raw)) {
2365
3025
  errors.push(`${label} must be an object`);
2366
3026
  return;
2367
3027
  }
@@ -2403,13 +3063,13 @@ function resolveRegistry(input, options) {
2403
3063
  const toolNames = new Set;
2404
3064
  const tools = arrayOf(input.tools, "tools", errors).map((raw, index) => {
2405
3065
  const label = `tools[${index}]`;
2406
- if (!isRecord(raw) || !isRecord(raw.schema)) {
3066
+ if (!isRecord2(raw) || !isRecord2(raw.schema)) {
2407
3067
  errors.push(`${label}.schema must be an object (OpenAI function schema)`);
2408
3068
  return;
2409
3069
  }
2410
3070
  checkFields(label, raw, TOOL_FIELDS, errors);
2411
3071
  const tool = raw;
2412
- const inner = tool.schema.type === "function" && isRecord(tool.schema.function) ? tool.schema.function : tool.schema;
3072
+ const inner = tool.schema.type === "function" && isRecord2(tool.schema.function) ? tool.schema.function : tool.schema;
2413
3073
  const schemaName = typeof inner.name === "string" ? inner.name.trim() : undefined;
2414
3074
  const name = (typeof tool.name === "string" ? tool.name.trim() : "") || schemaName;
2415
3075
  if (typeof tool.name === "string" && tool.name.trim() && schemaName && tool.name.trim() !== schemaName) {
@@ -2428,9 +3088,9 @@ function resolveRegistry(input, options) {
2428
3088
  errors.push(`${label}: schema.description must be a string`);
2429
3089
  }
2430
3090
  if (inner.parameters !== undefined) {
2431
- if (!isRecord(inner.parameters) || inner.parameters.type !== "object") {
3091
+ if (!isRecord2(inner.parameters) || inner.parameters.type !== "object") {
2432
3092
  errors.push(`${label}: schema.parameters.type must be "object"`);
2433
- } else if (inner.parameters.properties !== undefined && !isRecord(inner.parameters.properties)) {
3093
+ } else if (inner.parameters.properties !== undefined && !isRecord2(inner.parameters.properties)) {
2434
3094
  errors.push(`${label}: schema.parameters.properties must be an object`);
2435
3095
  }
2436
3096
  }
@@ -2452,6 +3112,7 @@ function resolveRegistry(input, options) {
2452
3112
  skillKeys.add(skill.key);
2453
3113
  }
2454
3114
  }
3115
+ const harness = validateHarnessSections(input, { prompts: promptKeys, tools: toolNames, skills: skillKeys }, errors, warnings);
2455
3116
  if (errors.length > 0) {
2456
3117
  throw new CliInputError(`Registry manifest is invalid:
2457
3118
  - ${errors.join(`
@@ -2459,12 +3120,15 @@ function resolveRegistry(input, options) {
2459
3120
  }
2460
3121
  const resolved = {
2461
3122
  manifestPath: options.manifestPath,
3123
+ schema: typeof input.schema === "string" ? input.schema : harness ? REGISTRY_SCHEMA : REGISTRY_SCHEMA_V1,
2462
3124
  prompts: prompts.filter(isDefined),
2463
3125
  skills: skills.filter(isDefined),
2464
- tools: tools.filter(isDefined)
3126
+ tools: tools.filter(isDefined),
3127
+ ...harness ? { harness } : {},
3128
+ warnings
2465
3129
  };
2466
- if (resolved.prompts.length + resolved.skills.length + resolved.tools.length === 0) {
2467
- throw new CliInputError("Registry manifest has no prompts, skills, or tools to push.");
3130
+ if (resolved.prompts.length + resolved.skills.length + resolved.tools.length === 0 && !harness) {
3131
+ throw new CliInputError("Registry manifest has no prompts, skills, tools, or agents to push.");
2468
3132
  }
2469
3133
  return resolved;
2470
3134
  }
@@ -2481,7 +3145,16 @@ function arrayOf(value, label, errors) {
2481
3145
  return value;
2482
3146
  }
2483
3147
  function countsOf(registry) {
2484
- return { prompts: registry.prompts.length, skills: registry.skills.length, tools: registry.tools.length };
3148
+ return {
3149
+ prompts: registry.prompts.length,
3150
+ skills: registry.skills.length,
3151
+ tools: registry.tools.length,
3152
+ ...registry.harness ? {
3153
+ agents: registry.harness.agents?.length ?? 0,
3154
+ context: registry.harness.context?.length ?? 0,
3155
+ mcpServers: registry.harness.mcpServers?.length ?? 0
3156
+ } : {}
3157
+ };
2485
3158
  }
2486
3159
  function displayPath(context, path) {
2487
3160
  const rel = relative(context.cwd, path);
@@ -2491,7 +3164,7 @@ function isInside(root, target) {
2491
3164
  const rel = relative(resolve2(root), target);
2492
3165
  return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
2493
3166
  }
2494
- function isRecord(value) {
3167
+ function isRecord2(value) {
2495
3168
  return typeof value === "object" && value !== null && !Array.isArray(value);
2496
3169
  }
2497
3170
  function nonEmptyString(value) {
@@ -7198,7 +7871,7 @@ Commands:
7198
7871
  skills sync Push local .claude/skills/**/SKILL.md up to Moda
7199
7872
  skills sync --dry-run Show what would be pushed without writing
7200
7873
  skills list|show|policy List pushed skills, show one, or set --live/--local
7201
- registry push Push prompts, skills, and tools from one JSON manifest
7874
+ registry push Push prompts, skills, tools, and agent wiring from one JSON manifest
7202
7875
 
7203
7876
  Options:
7204
7877
  --help Show this help message
@@ -7993,7 +8666,7 @@ async function searchFallbackToTraces(params) {
7993
8666
  last_timestamp: row.last_timestamp ?? null
7994
8667
  }));
7995
8668
  const first = asString(rows[0].conversation_id);
7996
- const describe = literal ? "trace-level keyword matches (each trace contains the words)" : `trace-level ${traceMode}-ranked related traces (they may not contain the exact words; fallback.total is the candidate pool, not a match count)`;
8669
+ const describe2 = literal ? "trace-level keyword matches (each trace contains the words)" : `trace-level ${traceMode}-ranked related traces (they may not contain the exact words; fallback.total is the candidate pool, not a match count)`;
7997
8670
  const nextCommands = [];
7998
8671
  if (first) {
7999
8672
  nextCommands.push({ command: `moda audit ${first}`, purpose: "Open the top trace to locate the relevant message.", mutability: "read", requires_approval: false });
@@ -8018,7 +8691,7 @@ async function searchFallbackToTraces(params) {
8018
8691
  ...hasMore ? { next_offset: nextOffset } : {}
8019
8692
  }
8020
8693
  },
8021
- warning: `Message-level search returned 0 results, so these are ${describe}, from \`moda traces --search\`. ` + `Each result is a whole trace, not a message, with no score or message index. ` + `Open one with \`moda audit <conversation_id>\` or \`moda context <conversation_id>\` to find the matching turn.` + (hasMore ? " More exist: page with the suggested `moda traces --search ... --offset` command." : ""),
8694
+ warning: `Message-level search returned 0 results, so these are ${describe2}, from \`moda traces --search\`. ` + `Each result is a whole trace, not a message, with no score or message index. ` + `Open one with \`moda audit <conversation_id>\` or \`moda context <conversation_id>\` to find the matching turn.` + (hasMore ? " More exist: page with the suggested `moda traces --search ... --offset` command." : ""),
8022
8695
  nextCommands
8023
8696
  };
8024
8697
  }
@@ -9681,17 +10354,19 @@ var commandRegistry = createCommandRegistry([
9681
10354
  },
9682
10355
  {
9683
10356
  name: "registry",
9684
- description: "Push prompts, skills, and tools to Moda from one agent-written JSON manifest (no repo scan)",
10357
+ description: "Push prompts, skills, tools, and agent wiring (agents, routing, context) to Moda from one agent-written JSON manifest, so replays rebuild your harness (no repo scan)",
9685
10358
  examples: [
10359
+ "moda registry prompt",
9686
10360
  "moda registry template --out=.moda/registry.json",
9687
10361
  "moda registry validate",
9688
10362
  "moda registry push --dry-run",
9689
- "moda registry push --file=.moda/registry.json"
10363
+ "moda registry push --file=.moda/registry.json",
10364
+ "moda registry coverage --days=7"
9690
10365
  ],
9691
10366
  subcommands: [
9692
10367
  {
9693
10368
  name: "template",
9694
- description: "Print (or write with --out) an example moda_registry.v1 manifest",
10369
+ description: "Print (or write with --out) an example moda_registry.v2 manifest",
9695
10370
  usage: "moda registry template [--out=<path>] [--yes]",
9696
10371
  flags: [
9697
10372
  { name: "--out=<path>", description: "Write the template to a file instead of stdout (refuses to overwrite without --yes)." }
@@ -9707,18 +10382,51 @@ var commandRegistry = createCommandRegistry([
9707
10382
  },
9708
10383
  {
9709
10384
  name: "push",
9710
- description: "Upload the manifest's prompts, skills, and tools to the API key's tenant",
9711
- usage: "moda registry push [--file=<path>] [--dry-run] [--live]",
10385
+ description: "Upload the manifest's prompts, skills, tools, and harness sections to the API key's tenant",
10386
+ usage: "moda registry push [--file=<path>] [--dry-run] [--live] [--message=<text>]",
9712
10387
  flags: [
9713
10388
  { name: "--file=<path>", description: "Manifest path (default .moda/registry.json)." },
10389
+ { name: "--message=<text>", description: "Message for the registry commit this push records (shown in the dashboard Registry history)." },
9714
10390
  { name: "--dry-run", description: "Server-side preview; nothing is written. Without an API key, validates locally only." },
9715
10391
  { name: "--live", description: "Mark every pushed skill live (included in the served skill surface)." }
9716
10392
  ],
9717
10393
  examples: ["moda registry push --dry-run", "moda registry push --live"]
10394
+ },
10395
+ {
10396
+ name: "status",
10397
+ description: "Show the registered harness (agents, routing, context) and its version",
10398
+ usage: "moda registry status",
10399
+ examples: ["moda registry status"]
10400
+ },
10401
+ {
10402
+ name: "log",
10403
+ description: "Registry history: one commit per push, with what changed",
10404
+ usage: "moda registry log [--limit=20]",
10405
+ flags: [{ name: "--limit=<n>", description: "Commits to list (default 50)." }],
10406
+ examples: ["moda registry log --limit=10"]
10407
+ },
10408
+ {
10409
+ name: "show",
10410
+ description: `A registry commit's file diffs, or "working" for changes since the last push`,
10411
+ usage: "moda registry show <commit|working>",
10412
+ examples: ["moda registry show 3f9a2c1", "moda registry show working"]
10413
+ },
10414
+ {
10415
+ name: "coverage",
10416
+ description: "Compare recent production traces with the registry: attribution rate, unmatched agents and prompts, unregistered tools",
10417
+ usage: "moda registry coverage [--days=7]",
10418
+ flags: [{ name: "--days=<n>", description: "Look-back window in days (default 7, max 30)." }],
10419
+ examples: ["moda registry coverage --days=7"]
10420
+ },
10421
+ {
10422
+ name: "prompt",
10423
+ description: "Print the brief to hand your coding agent so it inventories and registers your harness",
10424
+ usage: "moda registry prompt",
10425
+ examples: ["moda registry prompt | pbcopy", 'claude "$(moda registry prompt)"']
9718
10426
  }
9719
10427
  ],
9720
10428
  auth: "api-key",
9721
- authNote: "template and validate are local; push needs the API key (push --dry-run without a key validates locally only).",
10429
+ authNote: "template, validate, and prompt are local; push, status, log, show, and coverage need the API key (push --dry-run without a key validates locally only).",
9722
10430
  outputModes: ["human", "json", "agent"],
9723
10431
  mutability: "write",
9724
10432
  validateConfigBeforeRun: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.44.1",
3
+ "version": "1.45.0",
4
4
  "description": "CLI for Moda - AI agent analytics and observability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": "moda.skill_index.v1",
3
- "bundled_at": "2026-10-09T09:20:18.058Z",
4
- "cli_version": "1.44.1",
3
+ "bundled_at": "2026-10-09T19:33:51.744Z",
4
+ "cli_version": "1.45.0",
5
5
  "skills": [
6
6
  {
7
7
  "id": "integration-cloudflare-think",
@@ -39,8 +39,9 @@ Activate this skill when the user wants to:
39
39
  - List/filter traces by user, environment, cluster, outcome, or time
40
40
  - Manage code-first prompt versions when the user explicitly asks for prompt
41
41
  sync, prompt status, or prompt promotion
42
- - Push their prompts, skills, and tool definitions to Moda without a repo
43
- scan — `moda registry push` (section 7b)
42
+ - Push their prompts, skills, tool definitions, and agent wiring (agents,
43
+ routing, injected context) to Moda without a repo scan, so replays rebuild
44
+ their harness — `moda registry push` (section 7b)
44
45
 
45
46
  **Do not** activate for: writing code that calls the Moda Data API directly
46
47
  (use the API docs instead), changing application instrumentation unless the
@@ -832,41 +833,66 @@ Page with `--cursor=<nextCursor>`. Playouts exist only for runs whose
832
833
  playouts were captured; `playoutCount: 0` on `run` means there is no
833
834
  transcript to read.
834
835
 
835
- ### 7b. Push prompts, skills, and tools without a repo scan (`moda registry`)
836
+ ### 7b. Register the harness for replays (`moda registry`)
836
837
 
837
- Use when the user wants their prompts, skills, and tools visible in Moda but
838
- cannot (or does not want to) connect the GitHub App or run `moda harness
839
- analyze`. This fills the prompt, skill, and tool registries only. It does not
840
- create the harness graph (agents and how they connect), which still comes from
841
- `moda harness analyze`, the GitHub App, or an approved report via
842
- `moda harness sync --from-report`. **You** are the analyst: read the codebase you already have access
843
- to, write one manifest, and push exactly what it lists. Nothing else in the
844
- repo is uploaded.
838
+ Use when the user wants their prompts, skills, tools, or agent wiring in Moda
839
+ so replays rebuild their harness faithfully, without the GitHub App or `moda
840
+ harness analyze`. **You** are the analyst: read the codebase you already have,
841
+ write one manifest, and push exactly what it lists. Nothing else in the repo is
842
+ uploaded. For a full guided pass (inventory, prompt switching, context,
843
+ attribution, and verification), run `moda registry prompt` and follow the
844
+ brief it prints.
845
845
 
846
846
  ```bash
847
- moda registry template --out=.moda/registry.json # starter manifest
847
+ moda registry prompt # the step-by-step brief for this job
848
+ moda registry template --out=.moda/registry.json # starter manifest (moda_registry.v2)
848
849
  # ...fill in .moda/registry.json from the codebase...
849
850
  moda registry validate # local only, no network
850
851
  moda registry push --dry-run # server-side preview, writes nothing
851
852
  moda registry push # upload
853
+ moda registry status # what is registered (harness version)
854
+ moda registry coverage --days=7 # production traces vs the registry
855
+ moda registry log # history: one commit per push
856
+ moda registry show <commit|working> # a commit's file diffs / changes since the last push
852
857
  ```
853
858
 
854
- Manifest (`moda_registry.v1`; every section optional, ≤500 entries each):
859
+ Every successful `push` records a registry **commit**: the whole registry
860
+ (prompts, tools, skills, agents, harness settings) snapshotted as files, with
861
+ your repo's git SHA, branch, and dirty flag. Pass `--message="..."` to label
862
+ it. The dashboard **Registry** page shows these commits git-style: a log,
863
+ per-file diffs, a file browser, and uncommitted changes made outside a push
864
+ (dashboard edits, MCP discovery, `moda prompts sync`).
865
+
866
+ Manifest (`moda_registry.v2`; `moda_registry.v1` with only prompts, skills, and
867
+ tools still works; every section optional, ≤500 entries in prompts, skills, and tools):
855
868
 
856
869
  ```json
857
870
  {
858
- "schema": "moda_registry.v1",
871
+ "schema": "moda_registry.v2",
859
872
  "prompts": [
860
- { "key": "support.triage", "name": "Triage", "description": "...",
861
- "content": "You are ...", "sourcePath": "src/agents/triage.ts" },
862
- { "key": "support.reply", "file": "prompts/reply.md" }
873
+ { "key": "support.triage", "file": "prompts/triage.md" },
874
+ { "key": "support.billing", "content": "You handle billing for {{company_name}}..." }
863
875
  ],
864
876
  "skills": [
865
- { "key": "refund-policy", "description": "...", "file": ".claude/skills/refund-policy/SKILL.md" }
877
+ { "key": "refund-policy", "file": ".claude/skills/refund-policy/SKILL.md" }
866
878
  ],
867
879
  "tools": [
868
880
  { "schema": { "name": "lookup_customer", "description": "...",
869
881
  "parameters": { "type": "object", "properties": { "email": { "type": "string" } } } } }
882
+ ],
883
+ "runtime": { "framework": "openai-agents", "maxToolRoundsPerTurn": 6, "skillLoading": "tool" },
884
+ "agents": [
885
+ { "key": "triage", "entry": true, "prompt": "support.triage",
886
+ "model": { "id": "openai/gpt-5.1", "temperature": 0.2, "maxTokens": 1024 },
887
+ "tools": ["lookup_customer"],
888
+ "handoffs": [{ "to": "billing", "tool": "transfer_to_billing" }],
889
+ "match": { "agentNames": ["TriageAgent"] } },
890
+ { "key": "billing", "prompt": "support.billing", "skills": ["refund-policy"] }
891
+ ],
892
+ "routing": { "strategy": "handoff", "default": "triage" },
893
+ "context": [
894
+ { "key": "company", "variable": "company_name", "source": "static", "value": "Acme" },
895
+ { "key": "today", "variable": "current_date", "source": "clock", "format": "date" }
870
896
  ]
871
897
  }
872
898
  ```
@@ -881,24 +907,64 @@ Manifest (`moda_registry.v1`; every section optional, ≤500 entries each):
881
907
  - **Skills:** `key` (letters, digits, `_`, `-`) plus `skillMd` or `file` (full
882
908
  SKILL.md). Pushed skills are *local* by default. Set `"live": true`, or pass
883
909
  `--live`, to include them in the served skill surface. Leaving `live` out
884
- never demotes a skill that is already live.
910
+ never demotes a skill that is already live. Replays load the skills an agent
911
+ lists in `agents[].skills`, whether or not they are live.
885
912
  - **Tools:** an OpenAI function `schema` (a wrapped
886
913
  `{"type":"function","function":{...}}` is accepted), plus optional
887
914
  `isActive`. Copy names and parameter schemas exactly as the agent registers
888
915
  them with the model. Tools are upserted by name and never overwrite
889
916
  MCP-server-managed tools (those report `skipped`).
917
+ - **Agents:** one per distinct prompt, tool set, or model. Fields: `key`,
918
+ `name`, `description`, `entry`, `prompt` (prompt key), `model` (`id`,
919
+ `provider`, `temperature`, `maxTokens`, `topP`, `reasoningEffort`), `tools`
920
+ (tool names), `skills` (skill keys), `handoffs` (`to`, `tool`,
921
+ `description`), `subagents` (`agent`, `tool`; the tool must also be in
922
+ `tools`), `context` (context keys), `maxToolRoundsPerTurn`,
923
+ `responseSchema`, `match` (`agentNames` as they appear in traces,
924
+ `promptKeys`).
925
+ - **Routing (prompt switching):** `strategy` is `single`, `handoff`, `router`,
926
+ `rules`, or `trace_attribute`; `default` is an agent key; put code-level
927
+ rules in `notes`. Replay runs the agent named on the replayed turn's trace
928
+ (agent name, then prompt key), else `default`, else the `entry` agent. When
929
+ the replayed model calls a handoff tool, replay switches to the target
930
+ agent's prompt, tools, model, and params.
931
+ - **Context:** what the runtime injects. `source` is `static` (with `value`),
932
+ `clock` (`format`: date, datetime, or iso; replay uses the source
933
+ conversation's time), or `trace`, `memory`, `retrieval`, `tool`, or
934
+ `unknown`. Those last ones are reported as unresolved in replay fidelity,
935
+ never invented. `target` is `prompt_variable` (needs `variable`) or
936
+ `system_append`.
937
+ - **Runtime:** `framework`, `language`, `maxToolRoundsPerTurn`,
938
+ `parallelToolCalls`, `history` (`strategy`, `maxMessages`, `maxTokens`),
939
+ and `skillLoading` (`tool`, `system_prompt`, or `none`).
940
+ - **MCP servers:** `name`, `transport`, `url` or `command`, `tools`, and
941
+ `description`, with no credentials. List their tools in `tools[]` too.
890
942
  - `file` paths are relative to the directory you run the command from and must
891
943
  stay inside it (symlinks included).
892
944
 
945
+ **Traces must name the agent.** For per-turn prompt switching, each LLM call
946
+ should carry `gen_ai.agent.name` (or `moda.agent_name`; with the Vercel AI SDK
947
+ use `experimental_telemetry.metadata["moda.agent_name"]`; `agent_name` on
948
+ `/v1/ingest`), set to an agent `key` or one of its `match.agentNames`. It
949
+ should also carry `moda.prompt_key` / `moda.prompt_version_id`. Only add these
950
+ to application code when the user asks for it or approves the diff.
951
+ `moda registry coverage` shows how many recent calls resolve to a registered
952
+ agent, which agent names, prompt keys, and tools are unregistered, and which
953
+ agents were never seen.
954
+
893
955
  `push` needs an API key. `push --dry-run` without one only validates locally
894
956
  (status `validated`). Each section goes to its own endpoint and reports
895
957
  `sections[].status` (`synced`, `dry_run`, `validated`, `skipped`, or `error`).
896
- If any section fails, `push` exits `1`, lists the failures in `errors[]`, and
897
- still pushes the other sections. The whole manifest is validated before any
898
- request, with every error listed at once. Unknown fields and wrong types are
899
- rejected, matching what the server accepts. Skill keys are stricter than the
900
- server (letters, digits, `_`, `-`) because they double as `.claude/skills/<key>/`
901
- directory names.
958
+ The harness sections (`runtime`, `agents`, `routing`, `context`, and
959
+ `mcpServers`) are pushed last, as one versioned document. Re-pushing an
960
+ unchanged one reports `unchanged`. If any section fails, `push` exits `1`,
961
+ lists the failures in `errors[]`, and still pushes the other sections. The
962
+ whole manifest is validated before any request, with every error listed at
963
+ once. `warnings[]` names agent references to prompts, tools, or skills that
964
+ the manifest doesn't define; they must already exist in Moda. Unknown fields
965
+ and wrong types are rejected, matching what the server accepts. Skill keys are
966
+ stricter than the server (letters, digits, `_`, `-`) because they double as
967
+ `.claude/skills/<key>/` directory names.
902
968
 
903
969
  ### 8. Fixing detected Problems
904
970
 
@@ -1226,7 +1292,7 @@ npx: `npx -p @moda-ai/cli moda <command>`.
1226
1292
  | `moda prompts diff` | Read-only: only prompts changed/new/deleted since last sync (hash-level, not a text diff) |
1227
1293
  | `moda prompts sync` | Upload changed prompt versions and update lockfile |
1228
1294
  | `moda prompts promote <key>` | Move a remote prompt label |
1229
- | `moda registry template\|validate\|push` | Push prompts, skills, and tools from one agent-written manifest (`--file`, `--dry-run`, `--live`); no repo scan |
1295
+ | `moda registry template\|validate\|push\|status\|coverage\|log\|show\|prompt` | Register prompts, skills, tools, and agent wiring from one agent-written manifest (`--file`, `--dry-run`, `--live`, `--message`, `--days`); every push is a commit in the Registry history; no repo scan |
1230
1296
  | `moda users` / `moda user <id>` | End users with health/activity; one user's profile |
1231
1297
  | `moda problem-reopen <pid>` | Reopen a Problem marked fixed |
1232
1298
  | `moda signals <create\|update\|activate\|pause\|delete\|examples\|refine\|revise\|proposals\|accept\|reject\|search\|preview>` | Manage custom signals (writes take `--dry-run`; LLM-backed ones are rate-limited per key) |