@sema-agent/settings-schema 1.9.0 → 2.0.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/dist/hooks.js CHANGED
@@ -2,10 +2,15 @@
2
2
  * `hooks` — the CC-parity hooks contract (incl. stop attribution).
3
3
  *
4
4
  * ONE definition of the FULL hook-event set — 27 CC 2.1.198 events (REF: the `jP` array in
5
- * claude-cli-2.1.198.pretty.js) + 4 post-198 events the shell already ships (CwdChanged / FileChanged /
6
- * MessageDisplay, and DirectoryAdded from CC 2.1.219). Per the product ruling: the CONTRACT is the full set
7
- * from day one; the engine hook-runner (service) and the shell light events up in PHASES — adding an event
8
- * later is pure implementation, never a schema change.
5
+ * claude-cli-2.1.198.pretty.js) + 6 post-198 events (CwdChanged / FileChanged / MessageDisplay, DirectoryAdded
6
+ * from CC 2.1.219, and the model-switch pair PreModelSwitch / PostModelSwitch from CC 2.1.259). Per the product
7
+ * ruling: the CONTRACT is the full set from day one; the engine hook-runner (service) and the shell light events
8
+ * up in PHASES — adding an event later is pure implementation, never a schema change.
9
+ *
10
+ * DRIFT: this package OWNS the hooks contract, so it owns noticing that CC moved. `test/upstream-drift.test.ts`
11
+ * compares {@link HOOK_EVENTS} against a CC anchor snapshot that `scripts/extract-cc-hook-events.mjs` extracts
12
+ * from the CLI bundle — a word CC has and this set does not is a RED test naming it, instead of a consumer
13
+ * discovering it the hard way (the 1.3.0 / 1.6.0 / 1.8.0 / 1.11.0 pattern).
9
14
  *
10
15
  * CONSUMERS
11
16
  * - service (the worker server): `TaskRequest.settings.hooks` wire face = `HooksConfig` below; the worker-side
@@ -56,12 +61,18 @@ export const CC_198_HOOK_EVENTS = [
56
61
  "InstructionsLoaded",
57
62
  ];
58
63
  /**
59
- * Post-CC-2.1.198 additions the shell already ships (sema superset; CC added them after 198).
64
+ * Post-CC-2.1.198 additions (sema superset; CC added them after 198).
60
65
  * APPEND-ONLY: new members go at the tail (1.3.0 `DirectoryAdded`, CC 2.1.219 — CC's own tuple places it
61
66
  * between FileChanged and MessageDisplay; the declaration order here is not normative, the enum is a set).
67
+ * 1.11.0 appends the model-switch pair (`PreModelSwitch` / `PostModelSwitch`, CC 2.1.259 — absent from the
68
+ * 2.1.250 corpus, so 250→259 is when they landed upstream).
69
+ *
70
+ * 🔴 This list is no longer maintained by hand-reading a CLI bundle: `test/upstream-drift.test.ts` fails
71
+ * (naming the missing words) whenever the CC anchor snapshot in `test/fixtures/cc-hook-events.json` holds an
72
+ * event this set does not, and that snapshot is produced by `scripts/extract-cc-hook-events.mjs`, never typed.
62
73
  */
63
- export const POST_198_HOOK_EVENTS = ["CwdChanged", "FileChanged", "MessageDisplay", "DirectoryAdded"];
64
- /** The full 31-event contract set = CC 198's 27 + the 4 post-198 shell events. */
74
+ export const POST_198_HOOK_EVENTS = ["CwdChanged", "FileChanged", "MessageDisplay", "DirectoryAdded", "PreModelSwitch", "PostModelSwitch"];
75
+ /** The full 33-event contract set = CC 198's 27 + the 6 post-198 events. */
65
76
  export const HOOK_EVENTS = [...CC_198_HOOK_EVENTS, ...POST_198_HOOK_EVENTS];
66
77
  export const HookEventName = z.enum(HOOK_EVENTS);
67
78
  // ── ownership (owner-single) ─────────────────────────────────────────────────────────────────────
@@ -124,6 +135,10 @@ export const HOOK_EVENT_MATCHER_FIELD = {
124
135
  FileChanged: "file_path", // matched against basename(file_path)
125
136
  MessageDisplay: null,
126
137
  DirectoryAdded: null, // the shell's getMatchingHooks switch has no arm for it → every entry matches (CwdChanged family)
138
+ // CC 2.1.259 matches both model-switch events against the RESOLVED target model id (`to_model`), so a
139
+ // matcher can scope a hook to "only when switching TO this model".
140
+ PreModelSwitch: "to_model",
141
+ PostModelSwitch: "to_model",
127
142
  };
128
143
  // ── shared enums (CC-verbatim) ─────────────────────────────────────────────────────────────────────────
129
144
  export const PermissionBehavior = z.enum(["allow", "deny", "ask"]);
@@ -398,7 +413,49 @@ export const DirectoryAddedHookInput = BaseHookInput.extend({
398
413
  directory: z.string(),
399
414
  source: z.enum(DIRECTORY_ADDED_SOURCES),
400
415
  });
401
- /** Any hook input — discriminated on `hook_event_name` (all 31 events). */
416
+ /**
417
+ * What triggered a model switch (CC 2.1.259). The two events carry DIFFERENT vocabularies and that is
418
+ * deliberate upstream: `PreModelSwitch` only fires for a REQUESTED switch (slash command / picker / headless
419
+ * set_model), while `PostModelSwitch` also reports changes nobody requested (`auto` = automatic fallback,
420
+ * `resume` = model restored while resuming a session) — those have no "before" to gate.
421
+ */
422
+ export const MODEL_SWITCH_SOURCES = {
423
+ pre: ["command", "picker", "sdk"],
424
+ post: ["command", "picker", "sdk", "auto", "resume"],
425
+ };
426
+ /** The fields both model-switch events carry (CC-verbatim): the models, what was asked for, and the
427
+ * cache-forfeit estimate a hook needs to decide whether the switch is worth it. */
428
+ const ModelSwitchFields = {
429
+ /** Resolved model id the session was running before the switch. */
430
+ from_model: z.string(),
431
+ /** Resolved model id the session runs after the switch (also the `matcher` field for both events). */
432
+ to_model: z.string(),
433
+ /** What was asked for — an alias such as `"opus"`, a full id, or `null` for "default". NULLABLE, not optional. */
434
+ requested_model: z.string().nullable(),
435
+ /** Prompt tokens the next request re-sends (0 before the first response). */
436
+ context_tokens: z.number(),
437
+ /** Whether the current model's prompt cache is likely still warm (a switch then forfeits it). */
438
+ prompt_cache_warm: z.boolean(),
439
+ cache_ttl: z.enum(["5m", "1h"]),
440
+ /** Estimated cost of re-caching `context_tokens` on `to_model` at its cache-write rate (excludes the response). */
441
+ estimated_cache_write_usd: z.number(),
442
+ /** `configured` = managed modelPricing; `catalog` = list price; `default` = `to_model` unknown, default tier assumed. */
443
+ pricing: z.enum(["configured", "catalog", "default"]),
444
+ };
445
+ /** Fires BEFORE a requested model switch; its `hookSpecificOutput.permissionDecision` can deny/ask (same
446
+ * contract as PreToolUse — DESIGN POINT ① applies: the engine clamps `allow` to the policy floor). */
447
+ export const PreModelSwitchHookInput = BaseHookInput.extend({
448
+ hook_event_name: z.literal("PreModelSwitch"),
449
+ ...ModelSwitchFields,
450
+ source: z.enum(MODEL_SWITCH_SOURCES.pre),
451
+ });
452
+ /** Fires AFTER the session model changed, whatever the cause (informational; may add context). */
453
+ export const PostModelSwitchHookInput = BaseHookInput.extend({
454
+ hook_event_name: z.literal("PostModelSwitch"),
455
+ ...ModelSwitchFields,
456
+ source: z.enum(MODEL_SWITCH_SOURCES.post),
457
+ });
458
+ /** Any hook input — discriminated on `hook_event_name` (all 33 events). */
402
459
  export const HookInput = z.discriminatedUnion("hook_event_name", [
403
460
  PreToolUseHookInput,
404
461
  PostToolUseHookInput,
@@ -431,6 +488,8 @@ export const HookInput = z.discriminatedUnion("hook_event_name", [
431
488
  FileChangedHookInput,
432
489
  MessageDisplayHookInput,
433
490
  DirectoryAddedHookInput,
491
+ PreModelSwitchHookInput,
492
+ PostModelSwitchHookInput,
434
493
  ]);
435
494
  // ── hook output / decision semantics (CC-verbatim, with the service-receipt design points pinned) ────────
436
495
  //
@@ -502,6 +561,15 @@ export const HookSpecificOutput = z.union([
502
561
  z.object({ hookEventName: z.literal("CwdChanged"), watchPaths: z.array(z.string()).optional() }),
503
562
  z.object({ hookEventName: z.literal("FileChanged"), watchPaths: z.array(z.string()).optional() }),
504
563
  z.object({ hookEventName: z.literal("WorktreeCreate"), worktreePath: z.string() }),
564
+ z.object({
565
+ hookEventName: z.literal("PreModelSwitch"),
566
+ /** CC: `allow` proceeds (skipping the interactive cache-miss confirm), `deny` cancels the switch, `ask`
567
+ * asks the user to confirm (a headless session refuses instead). Same wording as PreToolUse — DESIGN
568
+ * POINT ① applies: a hook `allow` never overrides a policy floor. */
569
+ permissionDecision: PermissionBehavior.optional(),
570
+ permissionDecisionReason: z.string().optional(),
571
+ }),
572
+ z.object({ hookEventName: z.literal("PostModelSwitch"), additionalContext: z.string().optional() }),
505
573
  ]);
506
574
  /**
507
575
  * Synchronous hook JSON output (stdout of a command hook, CC-verbatim). Exit-code semantics ride
@@ -61,7 +61,7 @@ export declare const RemoteExecSpec: z.ZodDiscriminatedUnion<"provider", [z.ZodO
61
61
  memory?: string | undefined;
62
62
  workdir?: string | undefined;
63
63
  cpus?: number | undefined;
64
- network?: "host" | "none" | "bridge" | undefined;
64
+ network?: "none" | "host" | "bridge" | undefined;
65
65
  mountPath?: string | undefined;
66
66
  }, {
67
67
  provider: "local-docker";
@@ -69,7 +69,7 @@ export declare const RemoteExecSpec: z.ZodDiscriminatedUnion<"provider", [z.ZodO
69
69
  memory?: string | undefined;
70
70
  workdir?: string | undefined;
71
71
  cpus?: number | undefined;
72
- network?: "host" | "none" | "bridge" | undefined;
72
+ network?: "none" | "host" | "bridge" | undefined;
73
73
  mountPath?: string | undefined;
74
74
  }>, z.ZodObject<{
75
75
  /** The remote docker endpoint — a DOCKER_HOST url: `ssh://user@host` or `tcp://host:2376`. A `user@` is OK
@@ -96,7 +96,7 @@ export declare const RemoteExecSpec: z.ZodDiscriminatedUnion<"provider", [z.ZodO
96
96
  memory?: string | undefined;
97
97
  workdir?: string | undefined;
98
98
  cpus?: number | undefined;
99
- network?: "host" | "none" | "bridge" | undefined;
99
+ network?: "none" | "host" | "bridge" | undefined;
100
100
  mountPath?: string | undefined;
101
101
  tlsCertEnv?: string | undefined;
102
102
  tlsKeyEnv?: string | undefined;
@@ -109,7 +109,7 @@ export declare const RemoteExecSpec: z.ZodDiscriminatedUnion<"provider", [z.ZodO
109
109
  memory?: string | undefined;
110
110
  workdir?: string | undefined;
111
111
  cpus?: number | undefined;
112
- network?: "host" | "none" | "bridge" | undefined;
112
+ network?: "none" | "host" | "bridge" | undefined;
113
113
  mountPath?: string | undefined;
114
114
  tlsCertEnv?: string | undefined;
115
115
  tlsKeyEnv?: string | undefined;
@@ -28,7 +28,7 @@ import { ENV_NAME, K8S_QUANTITY, NON_SECRET_URL, TCP_PORT, containsSecretToken,
28
28
  * byte uniformly — a NUL validates here but makes Node throw at spawn/boot (fail-OPEN → runtime crash). R20. */
29
29
  const execStr = z.string().min(1).refine((s) => !hasNul(s), "must not contain a NUL byte");
30
30
  /** Container knobs shared by the two docker lanes (local-docker / remote-docker). `network` defaults to
31
- * `none` at the adapter (the safe posture for untrusted code — see DUAL-MODE-DESIGN.md). */
31
+ * `none` at the adapter (the safe posture for untrusted code — see the dual-mode design note). */
32
32
  const DOCKER_FIELDS = {
33
33
  /** Sandbox image (required — a run must be reproducible). */
34
34
  image: execStr,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Per-worker roster resolution (docs/MULTI-ROSTER.md). Turns the GLOBAL effective config + a worker name
2
+ * Per-worker roster resolution (the multi-roster design note). Turns the GLOBAL effective config + a worker name
3
3
  * into THAT worker's effective config: its `models` domain is the roster's view (model subset + role-map +
4
4
  * @-allowlist override), everything else stays global. The `rosters` domain is control-plane-only and is
5
5
  * always stripped from the wire payload (no /effective consumer reads it).
@@ -54,13 +54,23 @@ export interface SchedulerStore {
54
54
  }
55
55
  /** Structural validation of one record (required fields + a well-formed `when`). Unknown fields are allowed. */
56
56
  export declare function isSchedulerRecord(v: unknown): v is SchedulerRecord;
57
- /** One record dropped by {@link parseSchedulerStore} for failing {@link isSchedulerRecord} (malformed shape).
58
- * `index` = its position in the parsed array (for correlating with the source file); `record` = the raw
59
- * (unvalidated) value, for diagnostics. Additive — passing `onWarning` is optional. */
57
+ /** Something {@link parseSchedulerStore} DID to the data it read, rather than reading it verbatim. `index` =
58
+ * the record's position in the parsed array (for correlating with the source file). Additive — passing
59
+ * `onWarning` is optional, and a new `kind` is an additive union member (a consumer with an exhaustive
60
+ * `switch` gains an arm to write).
61
+ * - `malformed-record` — the record failed {@link isSchedulerRecord} and was dropped; `record` = the
62
+ * raw (unvalidated) value, for diagnostics.
63
+ * - `durable-scope-rekeyed` — the durable-scope compat arm rewrote this row's `scope` (1.11.0; before that
64
+ * the rewrite happened silently every parse). */
60
65
  export type SchedulerParseWarning = {
61
66
  index: number;
62
67
  kind: 'malformed-record';
63
68
  record: unknown;
69
+ } | {
70
+ index: number;
71
+ kind: 'durable-scope-rekeyed';
72
+ from: string;
73
+ to: string;
64
74
  };
65
75
  export declare function parseSchedulerStore(text: string, onWarning?: (w: SchedulerParseWarning) => void): SchedulerRecord[];
66
76
  /** Serialize the store to its canonical on-disk form (stable, pretty JSON). The WRITE half of the format contract. */
@@ -55,6 +55,11 @@ export function isSchedulerRecord(v) {
55
55
  *
56
56
  * 窄读判据:`lifetime` 不在本契约的显式键上(server 写侧作为 forward-compat 字段随行),按未知
57
57
  * 字段窄读——`"session"` 行是会话链键,零触碰;其余(缺席=durable 默认)按 durable 归一。
58
+ *
59
+ * 落日(1.11.0):删除它 = **持久数据**面的破坏(仍按旧键存着的 durable 行会就此找不回),所以不能在
60
+ * minor 上一删了事 —— 归下一个 MAJOR,按持久数据的规矩办(响亮拒收 + 一次性迁移)。在那之前,本版把
61
+ * 这次重写从**静默**改成**出声**(`durable-scope-rekeyed`,带改写前后):关窗的前提是有人能证明它
62
+ * 已经不再触发,而不是有人记得它还在。
58
63
  */
59
64
  function normalizeDurableScope(r) {
60
65
  if (r.lifetime === 'session')
@@ -76,10 +81,15 @@ export function parseSchedulerStore(text, onWarning) {
76
81
  return parsed.filter(isSchedulerRecord).map(normalizeDurableScope);
77
82
  const out = [];
78
83
  parsed.forEach((record, index) => {
79
- if (isSchedulerRecord(record))
80
- out.push(normalizeDurableScope(record));
81
- else
84
+ if (!isSchedulerRecord(record)) {
82
85
  onWarning({ index, kind: 'malformed-record', record });
86
+ return;
87
+ }
88
+ const normalized = normalizeDurableScope(record);
89
+ // 兼容臂真改写了这一行才出声(未改写时 normalizeDurableScope 返回同一个引用)。
90
+ if (normalized !== record)
91
+ onWarning({ index, kind: 'durable-scope-rekeyed', from: record.scope, to: normalized.scope });
92
+ out.push(normalized);
83
93
  });
84
94
  return out;
85
95
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Skills content-addressing (docs/MCP-SKILLS.md §3.1, slice 2). The /effective payload ships skills as a
2
+ * Skills content-addressing (the mcp/skills design note §3.1, slice 2). The /effective payload ships skills as a
3
3
  * MANIFEST — each skill's markdown `content` replaced by a `contentHash` — so the frequent 60s refresh stays
4
4
  * small; the service pulls each body ONCE (at boot) via GET /api/config/skills/content/<hash>, caches it by
5
5
  * hash, and re-pulls only when the hash changes (skills rarely change → big win). Bodies are immutable +