agent-control-plane-core 0.11.2 → 0.13.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
@@ -182,6 +182,56 @@ on a non-blocking pipe. The entries ship as `acp-claude-hook`, `acp-codex-hook`,
182
182
  set `ACP_CODEX_VERSION` to the Codex version your launcher pins (or call
183
183
  `createCodexAdapter({ version })`); without it, Codex calls parse advisory.
184
184
 
185
+ A judge may return its verdict or a promise of one, so a judge can ask a
186
+ remote monitor before it answers. `renderHookResponse` returns a promise.
187
+ `adapterFor(id, options)` takes the options its `LAUNCHER_OPTIONS[id]` row
188
+ names, so a launcher passes a pinned version to whichever agents read one
189
+ without naming them.
190
+
191
+ A consumer that cannot import these modules (a bash hook wrapper, a Python
192
+ monitor) reads `schema/control-plane/v1/agents.json` instead. It is generated
193
+ from the adapters, with one row per agent: its block exit code and reason
194
+ stream, whether a block needs a reason, whether it has an ask tier, the entry's
195
+ default response when the hook breaks, its hook coverage, the verdict
196
+ fields an allow cannot carry, and two pre-tool facts:
197
+
198
+ - `pre_tool_response`: `hook_specific_output_keys` lists the
199
+ `hookSpecificOutput` keys the host reads on an exit-0 pre-tool answer.
200
+ `unread_key_fails_open` says what any other key does there: `true` means the
201
+ host discards the whole body and runs the call (Codex), `false` means the key
202
+ is ignored, `null` means unknown. The conformance harness fails an adapter
203
+ whose pre-tool render emits a key outside the list. Neither field covers the
204
+ body's top-level keys, which Codex also parses strictly.
205
+ - `pre_tool_filing`: how the pre-tool hook files a model's tool call, so a
206
+ monitor can match the model's calls against the hook's. It is keyed by model
207
+ tool name: the call's `name`, prefixed with its `namespace` with no
208
+ separator, unless the namespace is empty or Codex's default `functions`
209
+ (`flatToolName` in `/contract`). Each value is one of:
210
+ - `null`: no pre-tool hook fires for this call.
211
+ - `{"tool_name": T, "input": I}`: the hook files `tool_name` T, and builds
212
+ `tool_input` from I. I is either `"$"`, meaning the call's whole arguments
213
+ value, or an object whose values are each `"$"` or `"$.<key>"`, meaning
214
+ that top-level argument. `<key>` is one literal key, non-empty and with no
215
+ `.`. If a `"$.<key>"` does not resolve (the arguments are not an object,
216
+ lack the key, or hold `null` there), the hook files nothing.
217
+
218
+ A function call the map does not list is filed with the model's own name and
219
+ arguments. A freeform (custom) call the map does not list files nothing; of
220
+ the shipped hosts only Codex makes them. The map describes calls the host
221
+ dispatches: Codex files nothing for a call it rejects first, e.g. a name it
222
+ has no tool for, a call kind the tool does not take, or arguments a handler
223
+ refuses before its hook (an `apply_patch` patch that does not parse, an
224
+ `exec_command` with a non-string `workdir`). On Codex the arguments value is a function call's
225
+ `arguments` parsed as JSON (an empty or all-whitespace string reads as `{}`,
226
+ unparseable text stays the raw string), or a freeform call's raw `input`
227
+ string. `fileModelCall` in `/contract` is the reference implementation; it
228
+ takes the call's kind, `"function"` or `"freeform"`. The map does not model
229
+ Codex's MCP names: Codex files an MCP call as `mcp__<namespace>__<tool>`,
230
+ which can differ from the flat name.
231
+
232
+ Read the file from the installed package, so it always matches the adapters
233
+ that version ships.
234
+
185
235
  Each entry fails the way its host expects when the hook itself breaks: claude,
186
236
  codex and gemini run the call, and amp asks. Set `ACP_FAIL_CLOSED=1` to block
187
237
  instead, on every host. The entry then exits 2 with the reason on stderr when
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane-core",
3
- "version": "0.11.2",
3
+ "version": "0.13.0",
4
4
  "description": "Vendor-neutral control-plane contract for coding agents: a normalized ToolCallEvent/Verdict schema with per-agent adapters (Claude Code, Codex, Amp, Gemini CLI) and a conformance suite.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,228 @@
1
+ {
2
+ "claude": {
3
+ "integration_mode": "external_hook",
4
+ "native_ask_tier": true,
5
+ "block": {
6
+ "exit_code": 2,
7
+ "reason_stream": "stderr",
8
+ "reason_required": false
9
+ },
10
+ "host_failure": {
11
+ "exit_code": 0,
12
+ "enforced": false
13
+ },
14
+ "coverage": {
15
+ "builtin": "covered",
16
+ "mcp": "covered",
17
+ "subagent": "covered",
18
+ "resumed": "covered"
19
+ },
20
+ "unrendered_fields_on_allow": {
21
+ "pre_tool": [
22
+ "mutated_output"
23
+ ],
24
+ "post_tool": [
25
+ "mutated_input"
26
+ ],
27
+ "prompt_submit": [
28
+ "mutated_input",
29
+ "mutated_output"
30
+ ],
31
+ "session_start": [
32
+ "mutated_input",
33
+ "mutated_output"
34
+ ],
35
+ "unknown": [
36
+ "additional_context",
37
+ "mutated_input",
38
+ "mutated_output"
39
+ ]
40
+ },
41
+ "pre_tool_filing": {},
42
+ "pre_tool_response": {
43
+ "hook_specific_output_keys": [
44
+ "additionalContext",
45
+ "hookEventName",
46
+ "permissionDecision",
47
+ "permissionDecisionReason",
48
+ "updatedInput"
49
+ ],
50
+ "unread_key_fails_open": null
51
+ }
52
+ },
53
+ "codex": {
54
+ "integration_mode": "external_hook",
55
+ "native_ask_tier": false,
56
+ "block": {
57
+ "exit_code": 2,
58
+ "reason_stream": "stderr",
59
+ "reason_required": true
60
+ },
61
+ "host_failure": {
62
+ "exit_code": 0,
63
+ "enforced": false
64
+ },
65
+ "coverage": {
66
+ "builtin": "partial",
67
+ "mcp": "partial",
68
+ "subagent": "partial",
69
+ "resumed": "partial"
70
+ },
71
+ "unrendered_fields_on_allow": {
72
+ "pre_tool": [
73
+ "additional_context",
74
+ "mutated_output"
75
+ ],
76
+ "post_tool": [
77
+ "mutated_input",
78
+ "mutated_output"
79
+ ],
80
+ "prompt_submit": [
81
+ "additional_context",
82
+ "mutated_input",
83
+ "mutated_output"
84
+ ],
85
+ "session_start": [
86
+ "additional_context",
87
+ "mutated_input",
88
+ "mutated_output"
89
+ ],
90
+ "unknown": [
91
+ "additional_context",
92
+ "mutated_input",
93
+ "mutated_output"
94
+ ]
95
+ },
96
+ "pre_tool_filing": {
97
+ "exec_command": {
98
+ "tool_name": "Bash",
99
+ "input": {
100
+ "command": "$.cmd"
101
+ }
102
+ },
103
+ "apply_patch": {
104
+ "tool_name": "apply_patch",
105
+ "input": {
106
+ "command": "$"
107
+ }
108
+ },
109
+ "multi_agent_v1spawn_agent": {
110
+ "tool_name": "spawn_agent",
111
+ "input": "$"
112
+ },
113
+ "write_stdin": null,
114
+ "wait": null,
115
+ "exec": null
116
+ },
117
+ "pre_tool_response": {
118
+ "hook_specific_output_keys": [
119
+ "additionalContext",
120
+ "hookEventName",
121
+ "permissionDecision",
122
+ "permissionDecisionReason",
123
+ "updatedInput"
124
+ ],
125
+ "unread_key_fails_open": true
126
+ }
127
+ },
128
+ "amp": {
129
+ "integration_mode": "external_hook",
130
+ "native_ask_tier": true,
131
+ "block": {
132
+ "exit_code": 2,
133
+ "reason_stream": "stderr",
134
+ "reason_required": false
135
+ },
136
+ "host_failure": {
137
+ "exit_code": 1,
138
+ "enforced": false
139
+ },
140
+ "coverage": {
141
+ "builtin": "covered",
142
+ "mcp": "covered",
143
+ "subagent": "covered",
144
+ "resumed": "unknown"
145
+ },
146
+ "unrendered_fields_on_allow": {
147
+ "pre_tool": [
148
+ "additional_context",
149
+ "mutated_input",
150
+ "mutated_output"
151
+ ],
152
+ "post_tool": [
153
+ "additional_context",
154
+ "mutated_input",
155
+ "mutated_output"
156
+ ],
157
+ "prompt_submit": [
158
+ "additional_context",
159
+ "mutated_input",
160
+ "mutated_output"
161
+ ],
162
+ "session_start": [
163
+ "additional_context",
164
+ "mutated_input",
165
+ "mutated_output"
166
+ ],
167
+ "unknown": [
168
+ "additional_context",
169
+ "mutated_input",
170
+ "mutated_output"
171
+ ]
172
+ },
173
+ "pre_tool_filing": {},
174
+ "pre_tool_response": {
175
+ "hook_specific_output_keys": [],
176
+ "unread_key_fails_open": false
177
+ }
178
+ },
179
+ "gemini": {
180
+ "integration_mode": "external_hook",
181
+ "native_ask_tier": false,
182
+ "block": {
183
+ "exit_code": 2,
184
+ "reason_stream": "stderr",
185
+ "reason_required": false
186
+ },
187
+ "host_failure": {
188
+ "exit_code": 0,
189
+ "enforced": false
190
+ },
191
+ "coverage": {
192
+ "builtin": "covered",
193
+ "mcp": "unknown",
194
+ "subagent": "unknown",
195
+ "resumed": "unknown"
196
+ },
197
+ "unrendered_fields_on_allow": {
198
+ "pre_tool": [
199
+ "mutated_output"
200
+ ],
201
+ "post_tool": [
202
+ "mutated_input",
203
+ "mutated_output"
204
+ ],
205
+ "prompt_submit": [
206
+ "mutated_input",
207
+ "mutated_output"
208
+ ],
209
+ "session_start": [
210
+ "additional_context",
211
+ "mutated_input",
212
+ "mutated_output"
213
+ ],
214
+ "unknown": [
215
+ "additional_context",
216
+ "mutated_input",
217
+ "mutated_output"
218
+ ]
219
+ },
220
+ "pre_tool_filing": {},
221
+ "pre_tool_response": {
222
+ "hook_specific_output_keys": [
223
+ "tool_input"
224
+ ],
225
+ "unread_key_fails_open": false
226
+ }
227
+ }
228
+ }
@@ -169,6 +169,23 @@ export function parse(native) {
169
169
  */
170
170
  export const NATIVE_ASK_TIER = true;
171
171
 
172
+ /**
173
+ * No rename is modelled: no source shows Amp filing a call under a
174
+ * name or input other than the model's.
175
+ * @type {import("../control-plane.mjs").PreToolFiling}
176
+ */
177
+ export const PRE_TOOL_FILING = Object.freeze({});
178
+
179
+ /**
180
+ * Amp reads only the hook's exit code and no body, so no key in one can change
181
+ * the outcome.
182
+ * @type {import("../control-plane.mjs").PreToolResponse}
183
+ */
184
+ export const PRE_TOOL_RESPONSE = Object.freeze({
185
+ hook_specific_output_keys: Object.freeze([]),
186
+ unread_key_fails_open: false,
187
+ });
188
+
172
189
  /**
173
190
  * The EXHAUSTIVE (decision × `this_call_vetoable`) → exit-code table for Amp's
174
191
  * pure exit-code transport: 0 allow / 1 ask / 2 reject.
@@ -288,6 +305,8 @@ export const ampAdapter = {
288
305
  UNRENDERED_FIELDS,
289
306
  NATIVE_ASK_TIER,
290
307
  NATIVE_EVENT_FOR,
308
+ PRE_TOOL_FILING,
309
+ PRE_TOOL_RESPONSE,
291
310
  parse,
292
311
  render,
293
312
  };
@@ -125,6 +125,32 @@ export const UNRENDERED_FIELDS = Object.freeze({
125
125
  */
126
126
  export const NATIVE_ASK_TIER = true;
127
127
 
128
+ /**
129
+ * No rename is modelled: no source shows Claude Code filing a call under a
130
+ * name or input other than the model's.
131
+ * @type {import("../control-plane.mjs").PreToolFiling}
132
+ */
133
+ export const PRE_TOOL_FILING = Object.freeze({});
134
+
135
+ /**
136
+ * The `hookSpecificOutput` keys Claude Code's hooks reference documents for
137
+ * `PreToolUse` (https://code.claude.com/docs/en/hooks). Whether an unread key
138
+ * fails open is NOT established: the reference says an exit-0 body that fails
139
+ * schema validation lets the call go ahead, but not whether an extra key fails
140
+ * it, and the host's source is not public.
141
+ * @type {import("../control-plane.mjs").PreToolResponse}
142
+ */
143
+ export const PRE_TOOL_RESPONSE = Object.freeze({
144
+ hook_specific_output_keys: Object.freeze([
145
+ "additionalContext",
146
+ "hookEventName",
147
+ "permissionDecision",
148
+ "permissionDecisionReason",
149
+ "updatedInput",
150
+ ]),
151
+ unread_key_fails_open: null,
152
+ });
153
+
128
154
  /** Claude Code native hook event names (the `hook_event_name` field). */
129
155
  export const HookEvent = Object.freeze({
130
156
  PRE_TOOL_USE: "PreToolUse",
@@ -339,6 +365,8 @@ export const claudeAdapter = {
339
365
  UNRENDERED_FIELDS,
340
366
  NATIVE_ASK_TIER,
341
367
  NATIVE_EVENT_FOR,
368
+ PRE_TOOL_FILING,
369
+ PRE_TOOL_RESPONSE,
342
370
  parse,
343
371
  render,
344
372
  };
@@ -39,6 +39,7 @@ import {
39
39
  asObject,
40
40
  asString,
41
41
  asStringOrNull,
42
+ WHOLE_ARGUMENTS,
42
43
  } from "../control-plane.mjs";
43
44
 
44
45
  /** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
@@ -130,6 +131,67 @@ export const UNRENDERED_FIELDS = Object.freeze({
130
131
  [EventKind.POST_TOOL]: readonlySet(["mutated_input", "mutated_output"]),
131
132
  });
132
133
 
134
+ /**
135
+ * How Codex's `PreToolUse` hook files a model tool call ([X7], read from
136
+ * Codex's source at `rust-v0.166.0`). A function tool is filed under its flat
137
+ * name (namespace + name) with its parsed arguments by default; these
138
+ * handlers supply their own payload or none:
139
+ * - `exec_command` → `Bash` with `{command: <cmd>}`; arguments Codex cannot
140
+ * parse as `ExecCommandArgs` file nothing, which this table models only for
141
+ * a missing or null `cmd` or scalar arguments. Not modelled: other type
142
+ * errors, and a positional array, which serde reads (`["ls"]` is `cmd: "ls"`).
143
+ * - `apply_patch` (a freeform tool) → `apply_patch` with its raw patch text as
144
+ * `command`.
145
+ * - `spawn_agent` in the `multi_agent_v1` namespace → `spawn_agent`.
146
+ * - `write_stdin`, code mode's `wait`, and code mode's freeform `exec` → no hook.
147
+ * These rules assume the named tool owns the name; a client-defined tool
148
+ * called `wait` with code mode off takes the default payload.
149
+ * The default payload is function-only, so a freeform extension tool files
150
+ * nothing too — the mini-language's rule for an unlisted freeform call.
151
+ * NOT MODELLED: Codex files an MCP call as `mcp__<namespace>__<tool>`, with
152
+ * `_` trimmed from the namespace's end and the tool's start (`mcp.rs`
153
+ * `hook_tool_name`), which can differ from the flat name — a rule keyed by
154
+ * one name cannot express that.
155
+ * @type {import("../control-plane.mjs").PreToolFiling}
156
+ */
157
+ export const PRE_TOOL_FILING = Object.freeze({
158
+ exec_command: Object.freeze({
159
+ tool_name: "Bash",
160
+ input: Object.freeze({ command: "$.cmd" }),
161
+ }),
162
+ apply_patch: Object.freeze({
163
+ tool_name: "apply_patch",
164
+ input: Object.freeze({ command: WHOLE_ARGUMENTS }),
165
+ }),
166
+ multi_agent_v1spawn_agent: Object.freeze({
167
+ tool_name: "spawn_agent",
168
+ input: WHOLE_ARGUMENTS,
169
+ }),
170
+ write_stdin: null,
171
+ wait: null,
172
+ exec: null,
173
+ });
174
+
175
+ /**
176
+ * Codex's `PreToolUse` output parser ([X8], `rust-v0.166.0`) deserializes
177
+ * `hookSpecificOutput` into a `deny_unknown_fields` struct of exactly these
178
+ * five keys. Any other key fails the whole body: the run is marked failed, no
179
+ * block is set, and the call RUNS. A known key can still void the body by its
180
+ * combination (`updatedInput` without `permissionDecision: "allow"`), which
181
+ * this declaration does not describe.
182
+ * @type {import("../control-plane.mjs").PreToolResponse}
183
+ */
184
+ export const PRE_TOOL_RESPONSE = Object.freeze({
185
+ hook_specific_output_keys: Object.freeze([
186
+ "additionalContext",
187
+ "hookEventName",
188
+ "permissionDecision",
189
+ "permissionDecisionReason",
190
+ "updatedInput",
191
+ ]),
192
+ unread_key_fails_open: true,
193
+ });
194
+
133
195
  /** Minimum Codex version whose hook can actually veto a tool call. */
134
196
  export const MIN_ENFORCING_VERSION = Object.freeze([0, 135]);
135
197
 
@@ -411,16 +473,28 @@ function postToolBody(hookEventName, vd, enforced) {
411
473
  return body;
412
474
  }
413
475
 
476
+ /** The launcher option names {@link createCodexAdapter} reads. */
477
+ export const OPTION_NAMES = Object.freeze(["version"]);
478
+
414
479
  /**
415
480
  * Build a Codex adapter whose `parse` judges every payload against `version`,
416
481
  * the Codex version the launcher pinned (Codex's hook stdin carries none).
417
482
  * Below {@link MIN_ENFORCING_VERSION} every event parses observe-only and a
418
483
  * deny renders advisory. With no `version`, `parse` falls back to a payload
419
- * `version` (see {@link parse}).
484
+ * `version` (see {@link parse}). An option outside {@link OPTION_NAMES} throws,
485
+ * so a misspelled setting is never dropped in silence.
420
486
  * @param {{ version?: string }} [options]
421
487
  * @returns {import("../control-plane.mjs").Adapter}
422
488
  */
423
- export function createCodexAdapter({ version } = {}) {
489
+ export function createCodexAdapter(options = {}) {
490
+ const unread = Object.keys(options).filter(
491
+ (name) => !OPTION_NAMES.includes(name),
492
+ );
493
+ if (unread.length > 0)
494
+ throw new Error(
495
+ `codex: no option ${unread.map((name) => JSON.stringify(name)).join(", ")} (reads: ${OPTION_NAMES.join(", ")})`,
496
+ );
497
+ const { version } = options;
424
498
  return {
425
499
  AGENT,
426
500
  INTEGRATION_MODE,
@@ -428,6 +502,8 @@ export function createCodexAdapter({ version } = {}) {
428
502
  UNRENDERED_FIELDS,
429
503
  NATIVE_ASK_TIER,
430
504
  NATIVE_EVENT_FOR,
505
+ PRE_TOOL_FILING,
506
+ PRE_TOOL_RESPONSE,
431
507
  parse: (native) => parse(native, { version }),
432
508
  render,
433
509
  };
@@ -357,6 +357,28 @@ export function parse(native) {
357
357
  */
358
358
  export const NATIVE_ASK_TIER = false;
359
359
 
360
+ /**
361
+ * No rename is modelled. Gemini CLI hands `BeforeTool` the invocation's tool
362
+ * name beside an `originalRequestName`, so an aliased call may be filed under
363
+ * a name the model did not use; that is unverified, so not published.
364
+ * @type {import("../control-plane.mjs").PreToolFiling}
365
+ */
366
+ export const PRE_TOOL_FILING = Object.freeze({});
367
+
368
+ /**
369
+ * Gemini CLI keeps `hookSpecificOutput` as a `Record<string, unknown>` and its
370
+ * `BeforeTool` path reads only `tool_input` from it, by `in` lookup, so any
371
+ * other key is ignored (`packages/core/src/hooks/types.ts`
372
+ * `BeforeToolHookOutput`; both firing paths, `core/coreToolHookTriggers.ts`
373
+ * and `scheduler/hook-utils.ts` `evaluateBeforeToolHook`, read at gemini-cli
374
+ * `62bab5b2`).
375
+ * @type {import("../control-plane.mjs").PreToolResponse}
376
+ */
377
+ export const PRE_TOOL_RESPONSE = Object.freeze({
378
+ hook_specific_output_keys: Object.freeze(["tool_input"]),
379
+ unread_key_fails_open: false,
380
+ });
381
+
360
382
  /**
361
383
  * Render into Gemini CLI's native external-hook transport. An enforceable deny
362
384
  * renders as exit 2 (the System Block on BeforeTool; documented on BeforeAgent
@@ -477,6 +499,8 @@ export const geminiAdapter = {
477
499
  UNRENDERED_FIELDS,
478
500
  NATIVE_ASK_TIER,
479
501
  NATIVE_EVENT_FOR,
502
+ PRE_TOOL_FILING,
503
+ PRE_TOOL_RESPONSE,
480
504
  parse,
481
505
  render,
482
506
  };
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The per-agent manifest: what each agent's hook transport does, published as
3
+ * plain JSON at `schema/control-plane/v1/agents.json`.
4
+ *
5
+ * PROBLEM CLASS — a consumer that cannot call these adapters (a bash hook
6
+ * wrapper, a Python validator) re-types each agent's rules by hand: which exit
7
+ * code blocks, whether a block needs a reason, whether an `ask` has a tier.
8
+ * Each copy is one more place an agent's next release has to be chased into.
9
+ * `agents.json` is the copy they read instead, generated from the adapters'
10
+ * exports, so an adapter change reaches them on the next `pnpm gen:schema`.
11
+ *
12
+ * `pnpm gen:schema` writes every {@link generatedDocuments} entry, and
13
+ * `json-schema.test.mjs` regenerates each in-process and byte-compares.
14
+ */
15
+
16
+ import { EventKind, blockTransportFor, lookup } from "./control-plane.mjs";
17
+ import { jsonSchemaDocuments, SCHEMA_DIR } from "./json-schema.mjs";
18
+ import { ADAPTERS, AGENT_IDS } from "./registry.mjs";
19
+ import { HOST_FAILURE } from "./runtime.mjs";
20
+
21
+ /**
22
+ * One agent's manifest row. Each field is read from the export it names:
23
+ * - `integration_mode`: the adapter's `INTEGRATION_MODE`.
24
+ * - `native_ask_tier`: `NATIVE_ASK_TIER`. `false` means an `ask` the host
25
+ * receives does not stop the call, so a consumer must block instead.
26
+ * - `block`: the `BLOCK_TRANSPORT` row: the exit code the host blocks on, the
27
+ * stream it reads the reason from, and whether an empty reason runs the call.
28
+ * - `host_failure`: the `HOST_FAILURE` row, the hook entry's DEFAULT response
29
+ * when the hook breaks (exit 0 runs the call; amp's exit 1 asks the human).
30
+ * `ACP_FAIL_CLOSED=1` replaces it with `block`.
31
+ * - `coverage`: `COVERAGE`, per call class.
32
+ * - `unrendered_fields_on_allow`: `UNRENDERED_FIELDS`, each set as a sorted
33
+ * list. It describes the allow path only: a deny can drop more.
34
+ * - `pre_tool_filing`: `PRE_TOOL_FILING`, how the pre-tool hook files a model
35
+ * tool call, in the mini-language the `PreToolFiling` typedef specifies.
36
+ * - `pre_tool_response`: `PRE_TOOL_RESPONSE`, the `hookSpecificOutput` keys the
37
+ * host reads on an exit-0 pre-tool answer, and `unread_key_fails_open`
38
+ * (`true`: any other key voids the body and the call runs; `null`: unknown).
39
+ * @param {string} id
40
+ * @returns {Record<string, unknown>}
41
+ */
42
+ function manifestRow(id) {
43
+ const adapter = ADAPTERS[id];
44
+ const failure = HOST_FAILURE[id];
45
+ return {
46
+ integration_mode: adapter.INTEGRATION_MODE,
47
+ native_ask_tier: adapter.NATIVE_ASK_TIER,
48
+ block: blockTransportFor(id),
49
+ host_failure: { exit_code: failure.exit_code, enforced: failure.enforced },
50
+ coverage: adapter.COVERAGE,
51
+ unrendered_fields_on_allow: Object.fromEntries(
52
+ // The conformance harness refuses an adapter missing a row, and spreading
53
+ // a missing one throws here, so a gap never publishes as "every field renders".
54
+ Object.values(EventKind).map((kind) => [
55
+ kind,
56
+ [
57
+ .../** @type {ReadonlySet<string>} */ (
58
+ lookup(adapter.UNRENDERED_FIELDS, kind)
59
+ ),
60
+ ].sort(),
61
+ ]),
62
+ ),
63
+ pre_tool_filing: adapter.PRE_TOOL_FILING,
64
+ pre_tool_response: adapter.PRE_TOOL_RESPONSE,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Every generated file, keyed by its package-relative path: the JSON Schema
70
+ * documents and their data maps, plus `agents.json`, keyed by agent id in
71
+ * {@link AGENT_IDS} order. The SSOT the generator writes and the freshness test
72
+ * reads, so neither carries its own list of filenames.
73
+ * @returns {Record<string, Record<string, unknown>>}
74
+ */
75
+ export function generatedDocuments() {
76
+ return {
77
+ ...jsonSchemaDocuments(),
78
+ [`${SCHEMA_DIR}/agents.json`]: Object.fromEntries(
79
+ AGENT_IDS.map((id) => [id, manifestRow(id)]),
80
+ ),
81
+ };
82
+ }