@sema-agent/settings-schema 12.3.0 → 13.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +188 -0
  2. package/README.md +33 -4
  3. package/conformance/VERSION +1 -1
  4. package/conformance/cases/c01-entry-salvage-then-typo/expected.json +1 -3
  5. package/conformance/cases/c03-literal-dotted-top-key/expected.json +4 -12
  6. package/conformance/cases/c10-record-key-secret-redacted-twice/expected.json +1 -3
  7. package/conformance/cases/c23-mixed-validity-models/expected.json +2 -6
  8. package/conformance/cases/c24-valid-entries-with-unknown-keys/expected.json +2 -6
  9. package/conformance/cases/c25-published-roundtrip-zero-warnings/expected.json +2 -6
  10. package/conformance/cases/c30-version-triple-a-first/expected.json +2 -6
  11. package/conformance/cases/c31-version-triple-b-changed/expected.json +2 -6
  12. package/conformance/cases/c32-version-triple-a-restored/expected.json +2 -6
  13. package/conformance/cases/c40-s712-identity-only-what-reads-out/expected.json +1 -3
  14. package/conformance/cases/c41-s792-encoded-pem-identity/expected.json +1 -3
  15. package/conformance/cases/c42-s792-unknown-key-secret-redacted/expected.json +2 -6
  16. package/conformance/cases/c43-s792-form-and-deep-encoded-pem/expected.json +1 -3
  17. package/conformance/cases/c44-s792-utf8-escape-glued-token/expected.json +1 -3
  18. package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/expected.json +1 -3
  19. package/conformance/cases/c47-s811-roles-key-url-userinfo-withheld/expected.json +1 -3
  20. package/conformance/cases/c48-s935-model-headers-transported/expected.json +6 -18
  21. package/conformance/cases/c49-s935-model-headers-secret-entry-dropped/expected.json +1 -3
  22. package/conformance/cases/c69-deep-encoded-token-past-every-reading/expected.json +1 -3
  23. package/conformance/cases/c70-rewritten-token-in-front-of-encoded-pem-header/expected.json +1 -3
  24. package/conformance/cases/c72-dangling-reference-write-seat-refuses/expected.json +1 -3
  25. package/conformance/cases/cc03-model-compat-unknown-subkey/expected.json +2 -6
  26. package/conformance/cases/cli01-model-name-nfc-vs-nfd/expected.json +1 -3
  27. package/conformance/cases/cli02-control-and-zero-width-chars/expected.json +1 -3
  28. package/conformance/cases/cli05-long-and-prefix-colliding-names/expected.json +6 -18
  29. package/conformance/cases/t01-absent-domain-key-not-null/expected.json +1 -3
  30. package/conformance/cases/t03-prototype-name-key-not-swallowed/expected.json +2 -6
  31. package/conformance/cases/t04-secret-literal-never-in-warning-value/expected.json +1 -3
  32. package/dist/hooks-config.d.ts +243 -0
  33. package/dist/hooks-config.js +466 -0
  34. package/dist/hooks.d.ts +16 -154
  35. package/dist/hooks.js +17 -111
  36. package/dist/http-hook-allowlists.d.ts +63 -0
  37. package/dist/http-hook-allowlists.js +73 -0
  38. package/dist/index.d.ts +5 -0
  39. package/dist/index.js +6 -1
  40. package/dist/mcp-server-policy.d.ts +75 -0
  41. package/dist/mcp-server-policy.js +84 -0
  42. package/dist/safety-merge-spec.d.ts +13 -3
  43. package/dist/safety-merge-spec.js +10 -3
  44. package/dist/settings-sources.d.ts +91 -0
  45. package/dist/settings-sources.js +107 -0
  46. package/dist/types.d.ts +6 -6
  47. package/dist/types.js +9 -5
  48. package/dist/ui-language.d.ts +40 -0
  49. package/dist/ui-language.js +37 -0
  50. package/dist/url-pattern.d.ts +33 -0
  51. package/dist/url-pattern.js +239 -0
  52. package/docs/API-SURFACE.md +1395 -0
  53. package/docs/CC-DIVERGENCE.md +42 -0
  54. package/docs/CONFORMANCE.md +344 -0
  55. package/docs/CONSUMER-INTEGRATION.md +282 -0
  56. package/docs/openapi-auth.yaml +357 -0
  57. package/docs/openapi-scopes.yaml +281 -0
  58. package/llms-full.txt +656 -0
  59. package/llms.txt +174 -0
  60. package/package.json +4 -1
@@ -0,0 +1,466 @@
1
+ /**
2
+ * `hooks-config` — the hooks CONFIG surface: what a settings document's `hooks` key may carry (the shape `TaskRequest.settings.hooks`
3
+ * carries on the wire and the shell reads from settings JSON: `{ [eventName]: [{ matcher?, hooks: [entry, …] }] }`).
4
+ * Re-exported whole by `./hooks` (and the main barrel) — this file is where the surface is DEFINED, `./hooks` is where it is imported from.
5
+ *
6
+ * ONE READER. "Can this document's hooks be loaded, and which pieces must go?" has one answer: {@link classifyHooksConfig}. The zod faces
7
+ * below ({@link HooksConfig}, {@link HookMatcherEntry}, {@link HookEntry} and the per-type five) are its PROJECTIONS — accepted ⇔ the
8
+ * classifier says `"loaded"`, the parsed value is what it loaded, each issue is one of its pieces (code + coordinate). There is no second
9
+ * statement of what a valid hook entry is: the per-type field table {@link HOOK_ENTRY_FIELDS} is read by the classifier at run time and by
10
+ * the entry TYPES at compile time (they are derived from it).
11
+ *
12
+ * GRANULARITY = CC 2.1.296 (`cli296.js:237470`, `Yy` → `on`): four levels of piece — the `hooks` key, an event, a matcher, an entry.
13
+ * · Outside the guard events, a bad piece is DROPPED alone (the rest of the document — its `permissions.deny`, its `env` — still applies).
14
+ * · The GUARD events are `PreToolUse` and `PermissionRequest` (CC's `SV`): their hooks may be what guards the permissions declared beside
15
+ * them, so ANY bad piece in a guard event REJECTS the whole document (nothing in it is applied), except a guard event whose value is
16
+ * `null` — that one is dropped (CC: a warning, the key is removed).
17
+ * · A GUARD SIGN is an object member keyed by a guard-event name whose value is not null / undefined / `[]`, or a matcher-shaped object
18
+ * (`{ matcher?, hooks }` holding a non-empty `hooks` array, or a single entry under `hooks` with `matcher` present or a string `type`) in
19
+ * any place other than a matcher's legal place (an element of the array under an event name). String VALUES are not scanned: a value
20
+ * that merely spells `PreToolUse` is not a sign. A piece holding a sign is never dropped — dropping it could make a misplaced guard vanish —
21
+ * the document is rejected instead, wherever the sign sits: outside `hooks`, under an unknown event, in a non-array event value, in a matcher's
22
+ * or an entry's fields (including fields the contract does not know and would otherwise not carry).
23
+ * · `field` — ONE LAW for drops and rejections alike: when the bad piece is a matcher or an entry (an object), `field` is the KEY on it that makes it
24
+ * bad — a contract field whose value is wrong, or, for a piece rejected because it holds a guard sign, the member that holds the sign (an unknown
25
+ * field the contract would strip; a contract field carrying more than its kind, such as `headers` or `input`; `hooks` for an entry that is itself
26
+ * matcher-shaped). Absent only when the piece is not an object — and for the pieces above matchers, which have no fields to name. One piece, one
27
+ * `field`: the first wrong contract field, or the first sign in document order; a sign on the piece itself decides before a wrong contract field
28
+ * of the same piece does (it is why the piece is rejected rather than dropped).
29
+ *
30
+ * CC 2.1.296 DIVERGENCES (each in the stricter direction — a document CC would load may be rejected here, never the reverse): listed once,
31
+ * in `docs/CC-DIVERGENCE.md` (section "hooks classifier", H1–H7) — the whole-subtree guard-sign scan is the first of them.
32
+ *
33
+ * COORDINATES are raw {@link ConfigPath} segments (the keys as written). An unknown event name or a key outside `hooks` is user text and may be
34
+ * anything, a pasted credential included: pass a coordinate through `outgoingPath` before it leaves the process (log, UI, wire). The classifier
35
+ * echoes no values and writes no sentences — the sentences are the consumers' (same law as `DROPPED_ENTRY_REASON_CODES`).
36
+ *
37
+ * SELF-CONTAINED. {@link classifyHooksConfig} and the module-private declarations it uses reference nothing outside themselves except
38
+ * {@link HOOK_EVENTS} and JavaScript built-ins (no zod): a consumer that copies the function into its own bundle (client-core's generator
39
+ * does, with the private declarations it uses) copies one closure plus the event vocabulary. The hooks-classify test file (group ⑤) holds this.
40
+ *
41
+ * Input is a settings document as parsed from JSON. Values JSON cannot produce (accessors, class instances, cycles) are not part of the
42
+ * contract; cycles are tolerated, a throwing accessor propagates.
43
+ */
44
+ import { z } from "zod";
45
+ import { buildQuotedIssue, fixed } from "./quoted-issue.js";
46
+ // ── event vocabulary ───────────────────────────────────────────────────────────────────────────────────────
47
+ // The two halves below are where each word came from (CC 2.1.198's own table, then the later additions) — provenance for the
48
+ // reader of this file, not a contract: they are module-private, and the ONE exported closed set is {@link HOOK_EVENTS}.
49
+ /** CC 2.1.198 full hook-event set (jP array) — 27 events. Order matches the shell's HOOK_EVENTS. */
50
+ const CC_198_HOOK_EVENTS = [
51
+ "PreToolUse",
52
+ "PostToolUse",
53
+ "PostToolUseFailure",
54
+ "PostToolBatch",
55
+ "Notification",
56
+ "UserPromptSubmit",
57
+ "UserPromptExpansion",
58
+ "SessionStart",
59
+ "SessionEnd",
60
+ "Stop",
61
+ "StopFailure",
62
+ "SubagentStart",
63
+ "SubagentStop",
64
+ "PreCompact",
65
+ "PostCompact",
66
+ "PermissionRequest",
67
+ "PermissionDenied",
68
+ "Setup",
69
+ "TeammateIdle",
70
+ "TaskCreated",
71
+ "TaskCompleted",
72
+ "Elicitation",
73
+ "ElicitationResult",
74
+ "ConfigChange",
75
+ "WorktreeCreate",
76
+ "WorktreeRemove",
77
+ "InstructionsLoaded",
78
+ ];
79
+ /**
80
+ * Post-CC-2.1.198 additions (sema superset; CC added them after 198).
81
+ * APPEND-ONLY: new members go at the tail (1.3.0 `DirectoryAdded`, CC 2.1.219 — CC's own tuple places it
82
+ * between FileChanged and MessageDisplay; the declaration order here is not normative, the enum is a set).
83
+ * 2.0.0 appends the model-switch pair (`PreModelSwitch` / `PostModelSwitch`, CC 2.1.259 — absent from the
84
+ * 2.1.250 corpus, so 250→259 is when they landed upstream).
85
+ *
86
+ * 🔴 This list is no longer maintained by hand-reading a CLI bundle: `test/upstream-drift.test.ts` fails
87
+ * (naming the missing words) whenever the CC anchor snapshot in `test/fixtures/cc-hook-events.json` holds an
88
+ * event this set does not, and that snapshot is produced by `scripts/extract-cc-hook-events.mjs`, never typed.
89
+ */
90
+ const POST_198_HOOK_EVENTS = ["CwdChanged", "FileChanged", "MessageDisplay", "DirectoryAdded", "PreModelSwitch", "PostModelSwitch"];
91
+ /** The full 33-event contract set = CC 198's 27 + the 6 post-198 events — the one exported closed set of hook event names. */
92
+ export const HOOK_EVENTS = [...CC_198_HOOK_EVENTS, ...POST_198_HOOK_EVENTS];
93
+ /** The guard events (CC 2.1.296 `SV`): their hooks may guard the permissions declared beside them — see the file header. */
94
+ const HOOK_GUARD_EVENTS = ["PreToolUse", "PermissionRequest"];
95
+ // ── entry vocabulary and the one field table ───────────────────────────────────────────────────────────────
96
+ /**
97
+ * The hook entry `type` closed set — CC 2.1.296's five (`cli296.js:237454`): `command` (shell command on the host that runs the hook),
98
+ * `prompt` (one-shot LLM evaluation of the hook input), `agent` (agentic verifier loop), `http` (POST the hook input to a URL),
99
+ * `mcp_tool` (call a tool on an already-configured MCP server). An entry of another type is a bad piece — and in a guard event that rejects
100
+ * the whole document, which is why `test/upstream-drift.test.ts` ①b holds this set against the CC anchor snapshot.
101
+ */
102
+ export const HOOK_ENTRY_TYPES = ["command", "prompt", "agent", "http", "mcp_tool"];
103
+ /** `shell` values: 'bash' = the user's $SHELL family; 'powershell' = pwsh. Default bash. */
104
+ const HOOK_SHELLS = ["bash", "powershell"];
105
+ /** Fields every entry type takes: `if` = permission-rule syntax (e.g. "Bash(git *)") evaluated against tool_name/tool_input before the hook
106
+ * runs (tool events only; shell superset field, CC-compatible); `timeout` = seconds for this entry (default `DEFAULT_HOOK_TIMEOUT_SECONDS`);
107
+ * `statusMessage` = spinner status message; `once` = run once, then remove. */
108
+ const HOOK_ENTRY_COMMON_FIELDS = { if: "string?", timeout: "seconds?", statusMessage: "string?", once: "boolean?" };
109
+ /**
110
+ * THE field table of a hook entry, per type — read by {@link classifyHooksConfig} (which field is bad, what is carried) and by the entry
111
+ * types below (derived from it). Field order = the order a loaded entry's keys come out in.
112
+ * · command: `shell` (see above), `async` = run in background without blocking, `asyncRewake` = background + wake the model on exit 2;
113
+ * · prompt / agent: `prompt` ($ARGUMENTS = the hook input JSON), `model`;
114
+ * · http: `url`, `headers` (values may reference $NAMEs listed in `allowedEnvVars` — env-NAME interpolation only, the config never carries a
115
+ * secret value; `interpolateHookEnvVars` is the one reading), `allowedEnvVars`;
116
+ * · mcp_tool: `server` (an already-configured MCP server's name), `tool`, `input` (the tool's arguments; string values may interpolate
117
+ * `${path}` from the hook input JSON — CC 2.1.296 semantics, the runner's business).
118
+ */
119
+ const HOOK_ENTRY_FIELDS = {
120
+ command: { command: "text", shell: "shell?", async: "boolean?", asyncRewake: "boolean?", ...HOOK_ENTRY_COMMON_FIELDS },
121
+ prompt: { prompt: "text", model: "string?", ...HOOK_ENTRY_COMMON_FIELDS },
122
+ agent: { prompt: "text", model: "string?", ...HOOK_ENTRY_COMMON_FIELDS },
123
+ http: { url: "text", headers: "stringRecord?", allowedEnvVars: "stringList?", ...HOOK_ENTRY_COMMON_FIELDS },
124
+ mcp_tool: { server: "string", tool: "string", input: "record?", ...HOOK_ENTRY_COMMON_FIELDS },
125
+ };
126
+ // ── classification vocabulary ──────────────────────────────────────────────────────────────────────────────
127
+ /** What happened to a document's hooks: all loaded / loaded with pieces dropped / the document rejected (nothing in it applies). */
128
+ export const HOOKS_CLASSIFICATION_OUTCOMES = ["loaded", "loaded-partial", "rejected"];
129
+ /**
130
+ * Why a piece was DROPPED (closed; one word per level of piece, no `other`):
131
+ * · `hooks-not-object` — `hooks` is null / a scalar / an array holding only scalars ⇒ the whole `hooks` key goes;
132
+ * · `unknown-event` — a key under `hooks` that is not a {@link HOOK_EVENTS} name;
133
+ * · `event-not-array` — a known event whose value is not an array (for a guard event: only `null`);
134
+ * · `matcher-invalid` — a matcher that is not an object, whose `matcher` is not a string or whose `hooks` is not an array (non-guard event);
135
+ * · `entry-invalid` — an entry that is not an object, has no / an unknown `type`, or a field outside its kind (non-guard event).
136
+ */
137
+ export const HOOKS_DROPPED_PIECE_CODES = ["hooks-not-object", "unknown-event", "event-not-array", "matcher-invalid", "entry-invalid"];
138
+ /**
139
+ * Why the document was REJECTED (closed; each is "a guard may be in this piece"):
140
+ * · `guard-outside-hooks` — a guard sign outside `hooks` (top level, under another key, or the document itself matcher-shaped);
141
+ * · `hooks-is-matcher` — `hooks` is an array holding objects / arrays, a single matcher, or a single entry;
142
+ * · `guard-under-unknown-event` — an unknown event name holding a guard sign;
143
+ * · `guard-event-not-array` — a guard event whose value is not an array (and not null), or a known event whose non-array value holds a sign;
144
+ * · `guard-matcher-invalid` — a bad matcher in a guard event, or a matcher holding a sign (in any event);
145
+ * · `guard-entry-invalid` — a bad entry in a guard event, or an entry holding a sign (in any event).
146
+ */
147
+ export const HOOKS_REJECTION_CODES = [
148
+ "guard-outside-hooks",
149
+ "hooks-is-matcher",
150
+ "guard-under-unknown-event",
151
+ "guard-event-not-array",
152
+ "guard-matcher-invalid",
153
+ "guard-entry-invalid",
154
+ ];
155
+ /** A JSON object: not null, not an array, prototype `Object.prototype` or null. */
156
+ function hookIsRecord(value) {
157
+ if (value === null || typeof value !== "object" || Array.isArray(value))
158
+ return false;
159
+ const proto = Object.getPrototypeOf(value);
160
+ return proto === Object.prototype || proto === null;
161
+ }
162
+ /** The first own key of `node` (in key order) that names a guard event with a value other than null / undefined / `[]` (CC `ei`). */
163
+ function hookGuardKeyOf(node) {
164
+ for (const key of Object.keys(node)) {
165
+ if (!HOOK_GUARD_EVENTS.includes(key))
166
+ continue;
167
+ const value = node[key];
168
+ if (value !== null && value !== undefined && !(Array.isArray(value) && value.length === 0))
169
+ return key;
170
+ }
171
+ return undefined;
172
+ }
173
+ /** Matcher-shaped: `hooks` is a non-empty array, or a single object with `matcher` present beside it or a string `type` inside it. */
174
+ function hookLooksLikeMatcher(value) {
175
+ if (!hookIsRecord(value))
176
+ return false;
177
+ const inner = Object.hasOwn(value, "hooks") ? value.hooks : undefined;
178
+ if (Array.isArray(inner))
179
+ return inner.length > 0;
180
+ return hookIsRecord(inner) && (Object.hasOwn(value, "matcher") || typeof inner.type === "string");
181
+ }
182
+ /**
183
+ * The coordinate of the first guard sign (document order) in `root`'s subtree, or undefined. `place` is where `root` sits: `matcherList` =
184
+ * the array under an event name (its elements are in a matcher's legal place), `matcher` = one such element, `other` = anywhere else.
185
+ * A guard-event member's coordinate ends with its key; a matcher-shaped object's is the object's own. Iterative, whole subtree, each object
186
+ * visited once per place (cycles terminate).
187
+ */
188
+ function hookGuardSignIn(root, rootPath, place) {
189
+ const frames = [];
190
+ const pending = [[root, -1, place]];
191
+ const seen = { matcherList: new Set(), matcher: new Set(), other: new Set() };
192
+ const pathTo = (frame, tail) => {
193
+ const segments = [...tail];
194
+ for (let f = frame; f >= 0; f = frames[f].parent)
195
+ segments.unshift(frames[f].key);
196
+ return [...rootPath, ...segments];
197
+ };
198
+ while (pending.length > 0) {
199
+ const [node, frame, at] = pending.pop();
200
+ if (node === null || typeof node !== "object" || seen[at].has(node))
201
+ continue;
202
+ seen[at].add(node);
203
+ if (Array.isArray(node)) {
204
+ for (let i = node.length - 1; i >= 0; i--) {
205
+ frames.push({ key: i, parent: frame });
206
+ pending.push([node[i], frames.length - 1, at === "matcherList" ? "matcher" : "other"]);
207
+ }
208
+ continue;
209
+ }
210
+ const guardKey = hookGuardKeyOf(node);
211
+ if (guardKey !== undefined)
212
+ return pathTo(frame, [guardKey]);
213
+ if (at !== "matcher" && hookLooksLikeMatcher(node))
214
+ return pathTo(frame, []);
215
+ const keys = Object.keys(node);
216
+ for (let i = keys.length - 1; i >= 0; i--) {
217
+ frames.push({ key: keys[i], parent: frame });
218
+ pending.push([node[keys[i]], frames.length - 1, "other"]);
219
+ }
220
+ }
221
+ return undefined;
222
+ }
223
+ /** The key of the object piece at `at` that holds `sign` (a coordinate at or below `at`, as {@link hookGuardSignIn} reports it): the first segment
224
+ * below `at`; `hooks` when the sign IS the piece itself (a matcher-shaped object — its `hooks` member is what makes it one). */
225
+ function hookSignKeyOf(at, sign) {
226
+ const key = sign[at.length];
227
+ return key === undefined ? "hooks" : String(key);
228
+ }
229
+ /** Does `value` fit the field kind `base` (the kind without its `?`)? */
230
+ function hookFieldFits(base, value) {
231
+ switch (base) {
232
+ case "text":
233
+ return typeof value === "string" && value.length > 0 && !value.includes("\0");
234
+ case "string":
235
+ return typeof value === "string";
236
+ case "shell":
237
+ return typeof value === "string" && HOOK_SHELLS.includes(value);
238
+ case "boolean":
239
+ return typeof value === "boolean";
240
+ case "seconds":
241
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
242
+ case "stringRecord":
243
+ return hookIsRecord(value) && Object.values(value).every((v) => typeof v === "string");
244
+ case "stringList":
245
+ if (!Array.isArray(value))
246
+ return false;
247
+ for (let i = 0; i < value.length; i++)
248
+ if (typeof value[i] !== "string")
249
+ return false;
250
+ return true;
251
+ case "record":
252
+ return hookIsRecord(value);
253
+ default: {
254
+ const unknownKind = base;
255
+ throw new Error(`hook entry field table: unknown field kind '${String(unknownKind)}'`);
256
+ }
257
+ }
258
+ }
259
+ /** One entry → `{ entry }` (its contract fields, in table order; records and lists copied) or `{ field? }` (the first field that is wrong). */
260
+ function hookEntryRead(entry) {
261
+ if (!hookIsRecord(entry))
262
+ return {};
263
+ const type = Object.hasOwn(entry, "type") ? entry.type : undefined;
264
+ if (typeof type !== "string" || !Object.hasOwn(HOOK_ENTRY_FIELDS, type))
265
+ return { field: "type" };
266
+ const fields = HOOK_ENTRY_FIELDS[type];
267
+ const out = { type };
268
+ for (const field of Object.keys(fields)) {
269
+ const kind = fields[field];
270
+ const optional = kind.endsWith("?");
271
+ const value = Object.hasOwn(entry, field) ? entry[field] : undefined;
272
+ if (value === undefined) {
273
+ if (optional)
274
+ continue;
275
+ return { field };
276
+ }
277
+ const base = (optional ? kind.slice(0, -1) : kind);
278
+ if (!hookFieldFits(base, value))
279
+ return { field };
280
+ out[field] = Array.isArray(value) ? value.slice() : hookIsRecord(value) ? Object.fromEntries(Object.entries(value)) : value;
281
+ }
282
+ return { entry: out };
283
+ }
284
+ /** One matcher (at `at`, under a guard event or not) → what loads of it, reporting its bad pieces / signs into the two sinks. */
285
+ function hookMatcherRead(matcher, at, guardEvent, reject, drop) {
286
+ if (!hookIsRecord(matcher)) {
287
+ if (guardEvent || hookGuardSignIn(matcher, at, "matcher") !== undefined)
288
+ reject("guard-matcher-invalid", at);
289
+ else
290
+ drop("matcher-invalid", at);
291
+ return undefined;
292
+ }
293
+ const entries = Object.hasOwn(matcher, "hooks") ? matcher.hooks : undefined;
294
+ // a sign at the matcher level: a guard-event key on the matcher itself, or anywhere in its fields other than a well-formed entry list;
295
+ // the member that holds the first one (document order — the order hookGuardSignIn reports) is the rejection's `field`
296
+ const signField = hookGuardKeyOf(matcher) ??
297
+ Object.keys(matcher).find((key) => !(key === "hooks" && Array.isArray(entries)) && hookGuardSignIn(matcher[key], [...at, key], "other") !== undefined);
298
+ if (signField !== undefined) {
299
+ reject("guard-matcher-invalid", at, signField);
300
+ return undefined;
301
+ }
302
+ const entrySigns = [];
303
+ if (Array.isArray(entries))
304
+ for (let j = 0; j < entries.length; j++)
305
+ entrySigns.push(hookGuardSignIn(entries[j], [...at, "hooks", j], "other"));
306
+ const name = Object.hasOwn(matcher, "matcher") ? matcher.matcher : undefined;
307
+ const badField = name !== undefined && typeof name !== "string" ? "matcher" : !Array.isArray(entries) ? "hooks" : undefined;
308
+ if (badField !== undefined || !Array.isArray(entries)) {
309
+ if (guardEvent || entrySigns.some((sign) => sign !== undefined))
310
+ reject("guard-matcher-invalid", at, badField);
311
+ else
312
+ drop("matcher-invalid", at, badField);
313
+ return undefined;
314
+ }
315
+ const kept = [];
316
+ for (let j = 0; j < entries.length; j++) {
317
+ const where = [...at, "hooks", j];
318
+ const sign = entrySigns[j];
319
+ if (sign !== undefined) {
320
+ reject("guard-entry-invalid", where, hookIsRecord(entries[j]) ? hookSignKeyOf(where, sign) : undefined);
321
+ continue;
322
+ }
323
+ const read = hookEntryRead(entries[j]);
324
+ if (read.entry !== undefined)
325
+ kept.push(read.entry);
326
+ else if (guardEvent)
327
+ reject("guard-entry-invalid", where, read.field);
328
+ else
329
+ drop("entry-invalid", where, read.field);
330
+ }
331
+ return typeof name === "string" ? { matcher: name, hooks: kept } : { hooks: kept };
332
+ }
333
+ /** The `hooks` value → what loads of it (undefined when nothing does), reporting its bad pieces / signs into the two sinks. */
334
+ function hookBlockRead(hooks, reject, drop) {
335
+ if (!hookIsRecord(hooks)) {
336
+ if (Array.isArray(hooks) && hooks.some((x) => x !== null && typeof x === "object"))
337
+ reject("hooks-is-matcher", ["hooks"]);
338
+ else
339
+ drop("hooks-not-object", ["hooks"]);
340
+ return undefined;
341
+ }
342
+ if (hookLooksLikeMatcher(hooks) || typeof hooks.type === "string") {
343
+ reject("hooks-is-matcher", ["hooks"]);
344
+ return undefined;
345
+ }
346
+ const loaded = {};
347
+ for (const event of Object.keys(hooks)) {
348
+ const value = hooks[event];
349
+ const at = ["hooks", event];
350
+ if (!HOOK_EVENTS.includes(event)) {
351
+ if (hookGuardSignIn(value, at, Array.isArray(value) ? "matcherList" : "other") !== undefined)
352
+ reject("guard-under-unknown-event", at);
353
+ else
354
+ drop("unknown-event", at);
355
+ continue;
356
+ }
357
+ const guardEvent = HOOK_GUARD_EVENTS.includes(event);
358
+ if (!Array.isArray(value)) {
359
+ if ((guardEvent && value !== null) || hookGuardSignIn(value, at, "other") !== undefined)
360
+ reject("guard-event-not-array", at);
361
+ else
362
+ drop("event-not-array", at);
363
+ continue;
364
+ }
365
+ const matchers = [];
366
+ for (let i = 0; i < value.length; i++) {
367
+ const read = hookMatcherRead(value[i], [...at, i], guardEvent, reject, drop);
368
+ if (read !== undefined)
369
+ matchers.push(read);
370
+ }
371
+ loaded[event] = matchers; // a known event name — never a prototype key
372
+ }
373
+ return loaded;
374
+ }
375
+ /**
376
+ * THE one reading of a settings document's hooks (see the file header for the law): which pieces drop, whether the document is rejected,
377
+ * and what loads. Pure: reads `settingsDoc` (the WHOLE document — guard signs outside `hooks` count), never modifies it, no I/O, no sentences.
378
+ * Rejections and drops are listed in document order.
379
+ *
380
+ * COPYING THIS FUNCTION (client-core's generator, or any consumer that emits it as source): its one free name besides JavaScript built-ins is
381
+ * {@link HOOK_EVENTS}. The copy's scope must be GIVEN that name, bound to this package's runtime export `HOOK_EVENTS` (the upstream constant —
382
+ * the generator injects it as a registered value, it does not re-declare it). Never paste a literal event list into the generated text: that is
383
+ * a second event table, which the upstream drift gate (it watches `HOOK_EVENTS`) cannot see, and which goes stale the first time CC adds an event.
384
+ * The module-private declarations the closure uses (the `HOOK_*` constants and `hook*` helpers below the classifier line) are copied with it;
385
+ * the hooks-classify test file (group ⑤) pins that the free names are exactly these.
386
+ */
387
+ export function classifyHooksConfig(settingsDoc) {
388
+ const rejections = [];
389
+ const dropped = [];
390
+ const reject = (code, path, field) => {
391
+ rejections.push(field === undefined ? { code, path } : { code, path, field });
392
+ };
393
+ const drop = (code, path, field) => {
394
+ dropped.push(field === undefined ? { code, path } : { code, path, field });
395
+ };
396
+ let hooks;
397
+ if (settingsDoc !== null && typeof settingsDoc === "object" && !Array.isArray(settingsDoc)) {
398
+ const doc = settingsDoc;
399
+ for (const key of Object.keys(doc)) {
400
+ const value = doc[key];
401
+ if (key === "hooks") {
402
+ hooks = hookBlockRead(value, reject, drop);
403
+ continue;
404
+ }
405
+ // the document itself written as a matcher: its top-level `matcher` key is the evidence, reported at that key's place
406
+ const sign = key === "matcher" && hookLooksLikeMatcher(doc)
407
+ ? [key]
408
+ : HOOK_GUARD_EVENTS.includes(key) && value !== null && value !== undefined && !(Array.isArray(value) && value.length === 0)
409
+ ? [key]
410
+ : hookGuardSignIn(value, [key], "other");
411
+ if (sign !== undefined)
412
+ reject("guard-outside-hooks", sign);
413
+ }
414
+ }
415
+ else {
416
+ const sign = hookGuardSignIn(settingsDoc, [], "other");
417
+ if (sign !== undefined)
418
+ reject("guard-outside-hooks", sign);
419
+ }
420
+ if (rejections.length > 0)
421
+ return { outcome: "rejected", hooks: undefined, dropped: [], rejections };
422
+ if (dropped.length > 0)
423
+ return { outcome: "loaded-partial", hooks, dropped, rejections: [] };
424
+ return { outcome: "loaded", hooks, dropped: [], rejections: [] };
425
+ }
426
+ // ── zod faces: projections of the classifier ───────────────────────────────────────────────────────────────
427
+ /**
428
+ * A zod face that READS {@link classifyHooksConfig} (never a second judgement): `carry(value)` places the value into a carrier settings
429
+ * document — a matcher / an entry goes under `Stop`, a non-guard event, so a bad piece is a drop with its own coordinate rather than a rejection
430
+ * of the carrier — `strip` is how many leading coordinate segments are the carrier's, `take(hooks)` picks the value back out of what loaded.
431
+ * Accepted ⇔ `"loaded"`; each piece becomes one custom issue: message = its code, path = its coordinate below the carrier + its `field`.
432
+ */
433
+ function hooksFace(carry, strip, take) {
434
+ return z.unknown().transform((value, ctx) => {
435
+ const c = classifyHooksConfig(carry(value));
436
+ if (c.outcome === "loaded")
437
+ return take(c.hooks ?? {});
438
+ for (const piece of [...c.rejections, ...c.dropped]) {
439
+ ctx.addIssue({ code: "custom", path: [...piece.path.slice(strip), ...(piece.field === undefined ? [] : [piece.field])], ...buildQuotedIssue `${fixed(piece.code)}` });
440
+ }
441
+ return z.NEVER;
442
+ });
443
+ }
444
+ /** The carrier for one entry of type `type`: the value itself when it says that type, else an untyped object (⇒ the issue names `type`). */
445
+ function entryOfType(value, type) {
446
+ return value !== null && typeof value === "object" && value.type === type ? value : {};
447
+ }
448
+ const entryFace = (type) => hooksFace((v) => ({ hooks: { Stop: [{ hooks: [entryOfType(v, type)] }] } }), 5, (h) => h.Stop[0].hooks[0]);
449
+ export const CommandHookEntry = entryFace("command");
450
+ export const PromptHookEntry = entryFace("prompt");
451
+ export const AgentHookEntry = entryFace("agent");
452
+ export const HttpHookEntry = entryFace("http");
453
+ export const McpToolHookEntry = entryFace("mcp_tool");
454
+ /** One hook entry of any {@link HOOK_ENTRY_TYPES} type (projection of the classifier). */
455
+ export const HookEntry = hooksFace((v) => ({ hooks: { Stop: [{ hooks: [v] }] } }), 5, (h) => h.Stop[0].hooks[0]);
456
+ /** One matcher group (projection of the classifier). */
457
+ export const HookMatcherEntry = hooksFace((v) => ({ hooks: { Stop: [v] } }), 3, (h) => h.Stop[0]);
458
+ /**
459
+ * The hooks config record (projection of the classifier): accepted ⇔ {@link classifyHooksConfig} loads all of it; the parsed value is what it
460
+ * loaded (entries carry their contract fields only). Strict on purpose — a wire / an API carrying hooks refuses a bad piece outright (each
461
+ * issue = one piece: message = its code, path = its coordinate); a settings file loader asks the classifier itself and drops what it may.
462
+ *
463
+ * 🔴 The event-name set is closed: an event this package has not adopted yet is an unknown event (dropped from a settings file, refused here).
464
+ * That blast radius is why {@link HOOK_EVENTS} and {@link HOOK_ENTRY_TYPES} have an upstream drift gate rather than a human promise.
465
+ */
466
+ export const HooksConfig = hooksFace((v) => ({ hooks: v }), 1, (h) => h);