@crewhaus/spec-patch 0.5.7 → 0.6.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/index.d.ts CHANGED
@@ -117,10 +117,12 @@ export type SpecEdit = {
117
117
  };
118
118
  export type ApplySpecEditsOptions = {
119
119
  /**
120
- * The optimizer surface: every edit path must fall under the spec target's
121
- * `OPTIMIZABLE_PATHS` whitelist (exact or prefix match, same rule as
122
- * `validatePatch`). Author surfaces leave this off and may edit any field —
123
- * the atomic `parseSpec` re-validation is their safety floor.
120
+ * The optimizer surface: every edit path must be admitted by the spec
121
+ * target's `OPTIMIZABLE_PATHS` whitelist through {@link isOptimizable}
122
+ * exact match (wildcards = one segment each), the structural
123
+ * `model_pool` / `judge` / `sub_agents` / `temperature` rule, then prefix;
124
+ * the same rule as `validatePatch`. Author surfaces leave this off and may
125
+ * edit any field — the atomic `parseSpec` re-validation is their safety floor.
124
126
  */
125
127
  readonly restrictToOptimizable?: boolean;
126
128
  };
@@ -144,6 +146,29 @@ export type ApplySpecEditsResult = {
144
146
  * byte-identical (no CST round-trip reformatting).
145
147
  */
146
148
  export declare function applySpecEdits(yamlText: string, edits: ReadonlyArray<SpecEdit>, opts?: ApplySpecEditsOptions): ApplySpecEditsResult;
149
+ /**
150
+ * 0.6.0 (plan §10.3, mechanism 1) — the WILDCARD segment. Inside an
151
+ * `OPTIMIZABLE_PATHS` entry, `"*"` matches EXACTLY ONE key segment of the
152
+ * patched path (a `models:` profile name, a `steps` index, a `nodes` / `roles`
153
+ * name, a `rules` index) — never zero segments, never more than one, never a
154
+ * literal `"*"` key. Two placement rules make the wildcard safe, pinned by
155
+ * {@link wildcardPlacementIssues} (a CI guard, not a load-time throw):
156
+ *
157
+ * 1. a wildcard is never the LAST segment — `["models","*"]` would admit a
158
+ * whole profile (its `model`, `tools`, `permissions`, …) by prefix;
159
+ * 2. a wildcard never sits ABOVE a human-owned key — the concrete tail after
160
+ * the last `"*"` must end in a dial, never in one of
161
+ * {@link WILDCARD_FORBIDDEN_LEAVES} (the identity / security / prompt keys
162
+ * the §10.3 table excludes) and never in a structural segment.
163
+ */
164
+ export declare const WILDCARD_SEGMENT = "*";
165
+ /**
166
+ * Keys a wildcard-bearing entry may never terminate in: the §10.3 exclusions
167
+ * that live UNDER a dynamic key (`models.<name>`, `candidates[i]`,
168
+ * `rules[i]`, `sub_agents.<name>`). A dial entry that ended in one of these
169
+ * would whitelist identity or security surface across every profile at once.
170
+ */
171
+ export declare const WILDCARD_FORBIDDEN_LEAVES: ReadonlyArray<string>;
147
172
  /**
148
173
  * Per-target whitelist of mutation paths the active optimizer is
149
174
  * allowed to touch. Adding a new field here is the explicit signal that
@@ -169,6 +194,127 @@ export declare function applySpecEdits(yamlText: string, edits: ReadonlyArray<Sp
169
194
  * reason: there is no whitelisted path for it to patch.
170
195
  */
171
196
  export declare const OPTIMIZABLE_PATHS: Readonly<Record<Spec["target"], ReadonlyArray<ReadonlyArray<string>>>>;
197
+ /**
198
+ * 0.6.0 (plan §10.3, mechanism 2) — the STRUCTURAL rule: a path that passes
199
+ * through any of these segments is optimizable only by EXACT match against a
200
+ * listed entry, never by prefix. The whole-block entries `["steps"]`,
201
+ * `["nodes"]` and `["roles"]` exist because the step index / node name / role
202
+ * name is positional, and whole-block replacement was meant to span
203
+ * `model`/`instructions` — not the human-owned structure that now hangs off
204
+ * every routed block:
205
+ *
206
+ * - `model_pool` — the candidate ROSTER (mirroring the standing
207
+ * `["agent","model"]` exclusion), and from 0.6.0 the hybrid container:
208
+ * `rules.*.use` (rule targets), `strategy.*` role slots and
209
+ * `model_directed` (registers the `Escalate`/`Consult` tool surface),
210
+ * `reward.*` (the floor), `directives` (admits user-directed input),
211
+ * `classifier.{model,labels}` (judge identity), `scope`. The plan's
212
+ * verdict table classifies every one of these as excluded.
213
+ * - `judge` — a `kind: judge` gate's `escalate_to` (a routing target),
214
+ * `judges` (judge identity), `temperature`/`target`.
215
+ * - `sub_agents` — a sub-agent definition is a tool-surface / identity block
216
+ * (`tools`, `permissions`, `model`, `inherit_bypass`, and from 0.6.0
217
+ * `allowed_profiles`, `budget_share`, `inherit_routing`, its own routing).
218
+ * - `temperature` — a quality dial the plan lists as optimizable, but ONLY
219
+ * via the exact entries (`["agent","temperature"]`, the per-step / node /
220
+ * role wildcard entries, `models.*.temperature`): every OTHER
221
+ * `temperature` leaf — a candidate's, a sub-agent's, a judge's pinned
222
+ * sampling temperature — is human-owned, and a prefix-reached patch
223
+ * would land on one of those.
224
+ * - `model_tiers`, `model_fallbacks`, `circuit_breaker` — the three
225
+ * pre-pool routing blocks (§11.1 adds them to graph nodes and sub-agents
226
+ * in 0.6.0; steps and roles carried them before). `model_tiers` is a
227
+ * two-slot roster (`fast`/`default` are model identity, mirroring the
228
+ * standing `agent.model` exclusion), `model_fallbacks` is the failover
229
+ * chain and `circuit_breaker` decides when an arm is withdrawn from
230
+ * service — the same verdicts the §10.3 table gives their `models.*`
231
+ * twins. No exact entry names any of them, so the rule closes the
232
+ * whole-block prefix route (`["nodes"]` → `nodes.a.circuit_breaker`)
233
+ * without removing anything the table promises.
234
+ *
235
+ * One rule, no negative-list semantics: the exact entries
236
+ * `["agent","model_pool",{policy,routing,learning}]` keep working, and a
237
+ * whole-block `["steps"]` replacement (whose PATH carries none of these
238
+ * segments) is unchanged. The wildcard segment ({@link WILDCARD_SEGMENT}) and
239
+ * the exact entries the §10.3 table promises (`poolDials`, `judgeGateDials`,
240
+ * `MODEL_PROFILE_DIALS`) ride on top of this rule — it is what makes that
241
+ * table true for the positional shapes. Note the reconciliation the exact
242
+ * match performs: `["agent","temperature"]` IS listed, so the structural
243
+ * `temperature` segment blocks only the prefix route, never the entry.
244
+ */
245
+ export declare const STRUCTURAL_SEGMENTS: ReadonlyArray<string>;
246
+ /**
247
+ * 0.6.0 §10.3 — the EXCLUDED half of the verdict table, stated in code rather
248
+ * than left to the whitelist's default-deny (the 0.3.0 discipline). Each row
249
+ * is a segment pattern matched as a CONTIGUOUS sub-sequence ANYWHERE in a leaf
250
+ * path (`"*"` = any one segment), so one row covers the same key on every
251
+ * host: `["model_pool","rules","*","use"]` excludes `agent.model_pool.rules[i].use`
252
+ * on cli and `steps[i].model_pool.rules[j].use` on workflow alike. A row that
253
+ * names a block (`["model_pool","candidates"]`) excludes every leaf under it.
254
+ *
255
+ * This table is NOT consulted by {@link isOptimizable} — the whitelist is the
256
+ * only admission rule, and anything it does not list is refused. The rows
257
+ * exist so the table's closing invariant can be TESTED: every leaf the
258
+ * 0.6.0 spec delta introduced (enumerated from the zod schema) must be
259
+ * classified exactly once — matched by the whitelist XOR by a row here — and
260
+ * the test fails on a leaf that is unclassified or claimed twice, so this
261
+ * table, the whitelist and the schema cannot drift apart. The Hangar spec
262
+ * editor and `crewhaus advise` read the reasons.
263
+ */
264
+ export declare const HUMAN_OWNED_PATHS: ReadonlyArray<{
265
+ readonly pattern: ReadonlyArray<string>;
266
+ readonly reason: string;
267
+ }>;
268
+ /** A leaf path is human-owned when some {@link HUMAN_OWNED_PATHS} row matches it. */
269
+ export declare function humanOwnedReason(path: ReadonlyArray<SpecEditPathSegment>): string | undefined;
270
+ /**
271
+ * The wildcard placement guard (plan §10.3, mechanism 1): every issue is a
272
+ * whitelist entry that would let `"*"` sit above a human-owned key. Returns
273
+ * `[]` for the shipped table — pinned by the spec-patch tests, so a future
274
+ * entry that breaks the rule fails CI rather than widening the surface.
275
+ */
276
+ export declare function wildcardPlacementIssues(table?: Readonly<Record<string, ReadonlyArray<ReadonlyArray<string>>>>): string[];
277
+ /**
278
+ * The single admission rule behind `validatePatch`, `applySpecEdits({
279
+ * restrictToOptimizable })`, the Hangar spec editor's trust badge, the
280
+ * feedback / watch-me advice tiers and the orchestrator's stage guard:
281
+ *
282
+ * 1. an EXACT match against a listed entry admits the path (wildcards
283
+ * match one segment each);
284
+ * 2. a path through a STRUCTURAL segment ({@link STRUCTURAL_SEGMENTS}) is
285
+ * admitted by exact match ONLY — never by prefix;
286
+ * 3. otherwise a listed entry that is a PREFIX of the path admits it
287
+ * (`["nodes","0","instructions"]` under `["nodes"]`), so the optimizer
288
+ * can make fine-grained updates without listing every sub-path.
289
+ *
290
+ * Exported so every consumer shares one matcher — a hand-rolled prefix copy
291
+ * would neither honour the wildcard nor close the structural leak.
292
+ */
293
+ /**
294
+ * 0.6.0 §6.1 (PR 12) — the leaves the whitelist admits BY PREFIX but the
295
+ * optimizer must never move, matched as a contiguous sub-sequence anywhere in
296
+ * a path (the {@link HUMAN_OWNED_PATHS} matcher) and ENFORCED by
297
+ * {@link isOptimizable}.
298
+ *
299
+ * There is exactly one row, and it exists because `model_pool.learning` is
300
+ * deliberately whitelisted WHOLESALE: `crewhaus advise` mines the reward
301
+ * scoreboard into that block and patches it AS a block, so a narrower
302
+ * enumeration would break the advisor. PR 19 shipped the verdict table with
303
+ * the `learning.seed` exclusion documented as an accepted gap "until the eval
304
+ * runner (PR 12) needs it"; it needs it now — a routed eval PINS
305
+ * `learning.seed`, so an optimizer free to patch the seed could produce a
306
+ * measured delta of guaranteed zero, the one mutation that always "wins"
307
+ * because it changes nothing the measurement can see.
308
+ *
309
+ * This is an enforcement narrower than the whitelist, NOT a second
310
+ * classification: the `learning` BLOCK stays legitimately optimizable (which
311
+ * is what §10.3's verdict table and its closing-invariant test record), and
312
+ * `validatePatch` additionally refuses a block patch that would CHANGE the
313
+ * seed — a block patch that preserves it, as the advisor's spread does,
314
+ * passes.
315
+ */
316
+ export declare const OPTIMIZER_REFUSED_LEAVES: ReadonlyArray<ReadonlyArray<string>>;
317
+ export declare function isOptimizable(target: Spec["target"], path: ReadonlyArray<SpecEditPathSegment>): boolean;
172
318
  /**
173
319
  * Format a YAML header comment to prepend to a written-back file. The
174
320
  * orchestrator's `--write-back` writes this above the original spec so
package/dist/index.js CHANGED
@@ -148,6 +148,52 @@ export function validatePatch(spec, patch) {
148
148
  if (!isOptimizable(spec.target, patch.path)) {
149
149
  throw new SpecPatchError(`path ${formatPath(patch.path)} is not listed in OPTIMIZABLE_PATHS for target "${spec.target}"; add it to packages/spec-patch/src/index.ts if it's intended to be tunable`);
150
150
  }
151
+ // 0.6.0 §6.1 — the block-level way around `OPTIMIZER_REFUSED_LEAVES`:
152
+ // `model_pool.learning` is whitelisted WHOLESALE (the advisor patches the
153
+ // block), so a patch at the block carrying a `seed` key would move a leaf
154
+ // the path check just refused. A routed eval pins that seed; a patch to it
155
+ // would produce a measured delta of guaranteed zero.
156
+ const moved = refusedBlockValueKey(spec, patch.path, patch.value);
157
+ if (moved !== undefined) {
158
+ throw new SpecPatchError(`patch at ${formatPath(patch.path)} would change "${moved}", which is not optimizable: a routed eval PINS model_pool.learning.seed, so a patch to it measures a guaranteed-zero delta. Re-issue the block patch carrying the spec's existing seed (a block patch that PRESERVES it is fine).`);
159
+ }
160
+ }
161
+ /**
162
+ * The refused LEAF a block-level patch's value would CHANGE, when the patch
163
+ * lands on the parent block of an {@link OPTIMIZER_REFUSED_LEAVES} row.
164
+ *
165
+ * Deliberately "change", not "carry": `crewhaus advise`'s pool rules propose a
166
+ * whole-`learning`-block replace built by SPREADING the spec's current block,
167
+ * so a preserved seed must pass. Only adding, removing or altering the value
168
+ * is refused — the residual the path-level check cannot see.
169
+ */
170
+ function refusedBlockValueKey(spec, path, value) {
171
+ if (value === null || typeof value !== "object" || Array.isArray(value))
172
+ return undefined;
173
+ const segs = path.map((s) => String(s));
174
+ for (const pattern of OPTIMIZER_REFUSED_LEAVES) {
175
+ const parent = pattern.slice(0, -1);
176
+ const leaf = pattern[pattern.length - 1];
177
+ const endsWithParent = segs.length >= parent.length &&
178
+ parent.every((p, i) => segs[segs.length - parent.length + i] === p);
179
+ if (!endsWithParent)
180
+ continue;
181
+ const proposed = value[leaf];
182
+ const current = readSpecPath(spec, [...path, leaf]);
183
+ if (proposed !== current)
184
+ return `${segs.join(".")}.${leaf}`;
185
+ }
186
+ return undefined;
187
+ }
188
+ /** Read a plain path out of a parsed spec. `undefined` for any missing segment. */
189
+ function readSpecPath(spec, path) {
190
+ let cur = spec;
191
+ for (const seg of path) {
192
+ if (cur === null || typeof cur !== "object")
193
+ return undefined;
194
+ cur = cur[String(seg)];
195
+ }
196
+ return cur;
151
197
  }
152
198
  function formatPath(path) {
153
199
  return path.join(".");
@@ -281,6 +327,135 @@ function formatEditPath(path) {
281
327
  }
282
328
  return out;
283
329
  }
330
+ /**
331
+ * 0.6.0 (plan §10.3, mechanism 1) — the WILDCARD segment. Inside an
332
+ * `OPTIMIZABLE_PATHS` entry, `"*"` matches EXACTLY ONE key segment of the
333
+ * patched path (a `models:` profile name, a `steps` index, a `nodes` / `roles`
334
+ * name, a `rules` index) — never zero segments, never more than one, never a
335
+ * literal `"*"` key. Two placement rules make the wildcard safe, pinned by
336
+ * {@link wildcardPlacementIssues} (a CI guard, not a load-time throw):
337
+ *
338
+ * 1. a wildcard is never the LAST segment — `["models","*"]` would admit a
339
+ * whole profile (its `model`, `tools`, `permissions`, …) by prefix;
340
+ * 2. a wildcard never sits ABOVE a human-owned key — the concrete tail after
341
+ * the last `"*"` must end in a dial, never in one of
342
+ * {@link WILDCARD_FORBIDDEN_LEAVES} (the identity / security / prompt keys
343
+ * the §10.3 table excludes) and never in a structural segment.
344
+ */
345
+ export const WILDCARD_SEGMENT = "*";
346
+ /**
347
+ * Keys a wildcard-bearing entry may never terminate in: the §10.3 exclusions
348
+ * that live UNDER a dynamic key (`models.<name>`, `candidates[i]`,
349
+ * `rules[i]`, `sub_agents.<name>`). A dial entry that ended in one of these
350
+ * would whitelist identity or security surface across every profile at once.
351
+ */
352
+ export const WILDCARD_FORBIDDEN_LEAVES = Object.freeze([
353
+ "model",
354
+ "models",
355
+ "tags",
356
+ "tools",
357
+ "tool_config",
358
+ "permissions",
359
+ "rate_limits",
360
+ "cost",
361
+ "requires",
362
+ "capabilities",
363
+ "fallbacks",
364
+ "circuit_breaker",
365
+ "instructions",
366
+ "caching",
367
+ "candidates",
368
+ "when",
369
+ "use",
370
+ "labels",
371
+ "reward",
372
+ "directives",
373
+ "model_directed",
374
+ "allowed_profiles",
375
+ "inherit_routing",
376
+ "budget_share",
377
+ "tool_flags",
378
+ "escalate_to",
379
+ "judges",
380
+ "criteria",
381
+ "on_fail",
382
+ "seed",
383
+ ]);
384
+ /**
385
+ * 0.6.0 §10.3 — the per-profile DIALS: quality/cost knobs with no identity,
386
+ * security or routing meaning, tunable on every `models:` profile of every
387
+ * shape (`models:` is attached to all 14 schemas). `thinking.budget_tokens`
388
+ * follows the standing rule for the agent block — the budget is a dial, the
389
+ * thinking FORM (`effort`) stays human-owned. Every other profile field
390
+ * (`model`, `tags`, `tools`, `tool_config`, `permissions`, `rate_limits`,
391
+ * `cost`, `requires`, `capabilities`, `fallbacks`, `circuit_breaker`,
392
+ * `instructions`, `caching`) is EXCLUDED — see {@link HUMAN_OWNED_PATHS}.
393
+ */
394
+ const MODEL_PROFILE_DIALS = Object.freeze([
395
+ Object.freeze(["models", WILDCARD_SEGMENT, "max_tokens"]),
396
+ Object.freeze(["models", WILDCARD_SEGMENT, "thinking", "budget_tokens"]),
397
+ Object.freeze(["models", WILDCARD_SEGMENT, "temperature"]),
398
+ Object.freeze(["models", WILDCARD_SEGMENT, "limits", "model_call_timeout_ms"]),
399
+ ]);
400
+ /**
401
+ * 0.6.0 §10.3 — the `model_pool` entries for one routed block (`agent`, a
402
+ * `steps[*]` step, a `nodes.*` node, a `roles.*` role). Because the
403
+ * structural rule ({@link STRUCTURAL_SEGMENTS}) admits a `model_pool` path
404
+ * ONLY by exact match, every tunable pool key needs its own entry — nothing
405
+ * under `model_pool` is reached by prefix any more, on any shape:
406
+ *
407
+ * - `policy` / `routing` / `learning` — the pre-0.6.0 policy knobs
408
+ * (`routing` and `learning` deliberately WHOLESALE: `advise` mines the
409
+ * scoreboard into them). On the positional shapes these were reached by
410
+ * the whole-block `["steps"]` / `["nodes"]` / `["roles"]` prefix until the
411
+ * structural rule closed prefix reach into `model_pool`; the exact
412
+ * wildcard entries restore exactly the G37 intent and nothing more.
413
+ * - `rules[*].enabled` — a switch, not a target (`rules[*].when` / `.use`
414
+ * / `.id` stay human-owned).
415
+ * - `strategy.shadow.sample_rate`, `strategy.guide.max_tokens`,
416
+ * `strategy.max_escalations`, `classifier.max_tokens` — pure cost dials.
417
+ * - `strategy.cascade.clean_prompt` — a prompt-shape dial (draft kept or
418
+ * dropped before the escalation re-run).
419
+ *
420
+ * `candidates` (incl. `enabled`), `rules[*].{when,use,id}`,
421
+ * `classifier.{model,labels}`, every `strategy.*` role / model slot,
422
+ * `strategy.model_directed`, `reward.*`, `directives`, `scope` and
423
+ * `objective` are EXCLUDED — see {@link HUMAN_OWNED_PATHS}.
424
+ */
425
+ function poolDials(block) {
426
+ const pool = [...block, "model_pool"];
427
+ return Object.freeze([
428
+ Object.freeze([...pool, "policy"]),
429
+ Object.freeze([...pool, "routing"]),
430
+ Object.freeze([...pool, "learning"]),
431
+ Object.freeze([...pool, "rules", WILDCARD_SEGMENT, "enabled"]),
432
+ Object.freeze([...pool, "strategy", "shadow", "sample_rate"]),
433
+ Object.freeze([...pool, "strategy", "guide", "max_tokens"]),
434
+ Object.freeze([...pool, "strategy", "max_escalations"]),
435
+ Object.freeze([...pool, "strategy", "cascade", "clean_prompt"]),
436
+ Object.freeze([...pool, "classifier", "max_tokens"]),
437
+ ]);
438
+ }
439
+ /**
440
+ * 0.6.0 §10.3 — a `kind: judge` gate's dials on a positional block (`steps[*]`
441
+ * / `nodes.*`): `threshold` and `max_retries` mirror the agent-shape
442
+ * `evaluation.threshold` / `evaluation.max_retries` entries (the structural
443
+ * `judge` segment closed their prefix reach; these restore it exactly), and
444
+ * `repeats` mirrors `evaluation.grader.repeats`. `criteria`, `model`,
445
+ * `judges`, `temperature`, `target`, `on_fail` and `escalate_to` are EXCLUDED.
446
+ */
447
+ function judgeGateDials(block) {
448
+ const judge = [...block, "judge"];
449
+ return Object.freeze([
450
+ Object.freeze([...judge, "threshold"]),
451
+ Object.freeze([...judge, "max_retries"]),
452
+ Object.freeze([...judge, "repeats"]),
453
+ ]);
454
+ }
455
+ const STEP = Object.freeze(["steps", WILDCARD_SEGMENT]);
456
+ const NODE = Object.freeze(["nodes", WILDCARD_SEGMENT]);
457
+ const ROLE = Object.freeze(["roles", WILDCARD_SEGMENT]);
458
+ const AGENT = Object.freeze(["agent"]);
284
459
  /**
285
460
  * Per-target whitelist of mutation paths the active optimizer is
286
461
  * allowed to touch. Adding a new field here is the explicit signal that
@@ -351,9 +526,21 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
351
526
  // ["agent","model_pool","candidates"]: the candidate ROSTER is
352
527
  // human-owned, mirroring the standing ["agent","model"] exclusion —
353
528
  // learning tunes selection within the declared set, never the set.
354
- Object.freeze(["agent", "model_pool", "policy"]),
355
- Object.freeze(["agent", "model_pool", "routing"]),
356
- Object.freeze(["agent", "model_pool", "learning"]),
529
+ // 0.6.0 §10.3 — the exact pool entries (policy/routing/learning plus the
530
+ // hybrid dials); see `poolDials`. `["agent","model_pool","routing"]` and
531
+ // `["agent","model_pool","learning"]` stay WHOLESALE — a narrower
532
+ // enumeration would break `advise`'s scoreboard mining, which patches the
533
+ // `learning` BLOCK. §6.1's `learning.seed` exclusion is therefore enforced
534
+ // beside this table rather than inside it: see OPTIMIZER_REFUSED_LEAVES
535
+ // (PR 12 closed the gap PR 19 documented).
536
+ ...poolDials(AGENT),
537
+ // 0.6.0 §10.3 — `agent.temperature`: a threshold-shaped leaf like the
538
+ // existing params (exclusive with `thinking` on one block — the spec
539
+ // refine rejects a patch that lands both, so the optimizer can move one
540
+ // dial only when the other is absent).
541
+ Object.freeze(["agent", "temperature"]),
542
+ // 0.6.0 §10.3 — per-profile dials on every `models:` profile.
543
+ ...MODEL_PROFILE_DIALS,
357
544
  // 0.3.0 memory release (design §7.5, PR 20) — the memory/continuity
358
545
  // QUALITY knobs, registered on the five emit-wired memory shapes (cli,
359
546
  // channel, managed, research, crew — mirrored below with a pointer to
@@ -396,6 +583,10 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
396
583
  // the optimizer tunes how strict the gate is, never what it judges.
397
584
  Object.freeze(["evaluation", "threshold"]),
398
585
  Object.freeze(["evaluation", "max_retries"]),
586
+ // 0.6.0 §10.3 — `evaluation.grader.repeats`: a measurement dial (verdicts
587
+ // per judge, folded by median). `judges`, `temperature`, `target`,
588
+ // `criteria`, `model` and `on_fail` / `allow_self_judge` stay human-owned.
589
+ Object.freeze(["evaluation", "grader", "repeats"]),
399
590
  // Loop contract 0.4 (Batch E) — the agent-shape RAG (`knowledge:`) dials
400
591
  // + the per-turn recall cadence. All scalars that survive lower() 1:1
401
592
  // with their bounds owned by the spec schema, so an out-of-bounds patch
@@ -411,12 +602,21 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
411
602
  Object.freeze(["memory", "refreshEvery"]),
412
603
  ]),
413
604
  workflow: Object.freeze([
414
- // Whole-step replacement — this ALSO reaches item 9's (G37) per-step
415
- // model routing (model_pool policy/routing/learning, tiers, fallbacks):
416
- // no narrower entry is possible (the roster/step index is positional) and
417
- // whole-step replacement already spans model/instructions, so the policy
418
- // knobs ride here rather than as standalone paths.
605
+ // Whole-step replacement — a whole-step VALUE may carry item 9's (G37)
606
+ // per-step model routing (a pool-bearing step round-trips, see the G37
607
+ // tests) because the path `["steps"]` crosses no structural segment.
608
+ // The sub-path route is narrower: the structural rule stops prefix reach
609
+ // at `model_pool` / `judge` / `temperature` / `model_tiers` /
610
+ // `model_fallbacks` / `circuit_breaker`, so `steps[i].model_tiers.fast`
611
+ // is refused and only the exact dials below are admitted under a step.
419
612
  Object.freeze(["steps"]),
613
+ // 0.6.0 §10.3 — the structural rule closed prefix reach into a step's
614
+ // `model_pool` / `judge` / `temperature`; these exact wildcard entries
615
+ // (`*` = one step index) are the dials the table promises per step.
616
+ ...poolDials(STEP),
617
+ ...judgeGateDials(STEP),
618
+ Object.freeze([...STEP, "temperature"]),
619
+ ...MODEL_PROFILE_DIALS,
420
620
  Object.freeze(["failure_taxonomy"]),
421
621
  Object.freeze(["chains"]),
422
622
  Object.freeze(["transaction_policy"]),
@@ -431,10 +631,10 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
431
631
  Object.freeze(["failure_taxonomy"]),
432
632
  Object.freeze(["chains"]),
433
633
  Object.freeze(["transaction_policy"]),
434
- // Adaptive model routing pool policy knobs only (see the cli entry).
435
- Object.freeze(["agent", "model_pool", "policy"]),
436
- Object.freeze(["agent", "model_pool", "routing"]),
437
- Object.freeze(["agent", "model_pool", "learning"]),
634
+ // Adaptive model routing + 0.6.0 hybrid dials see the cli entry.
635
+ ...poolDials(AGENT),
636
+ Object.freeze(["agent", "temperature"]),
637
+ ...MODEL_PROFILE_DIALS,
438
638
  // 0.3.0 memory/continuity quality knobs — types/bounds at the cli entry.
439
639
  Object.freeze(["memory", "recallK"]),
440
640
  Object.freeze(["memory", "autoCaptureThreshold"]),
@@ -445,6 +645,7 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
445
645
  // Loop contract 0.4 (Batch B, G40) — evaluation dials; see the cli entry.
446
646
  Object.freeze(["evaluation", "threshold"]),
447
647
  Object.freeze(["evaluation", "max_retries"]),
648
+ Object.freeze(["evaluation", "grader", "repeats"]),
448
649
  // Loop contract 0.4 (Batch E) — knowledge/recall dials; see the cli entry.
449
650
  Object.freeze(["knowledge", "default_k"]),
450
651
  Object.freeze(["knowledge", "chunk", "size"]),
@@ -453,6 +654,11 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
453
654
  ]),
454
655
  graph: Object.freeze([
455
656
  Object.freeze(["nodes"]),
657
+ // 0.6.0 §10.3 — per-node dials (`*` = one node name); see the workflow entry.
658
+ ...poolDials(NODE),
659
+ ...judgeGateDials(NODE),
660
+ Object.freeze([...NODE, "temperature"]),
661
+ ...MODEL_PROFILE_DIALS,
456
662
  Object.freeze(["failure_taxonomy"]),
457
663
  Object.freeze(["chains"]),
458
664
  Object.freeze(["transaction_policy"]),
@@ -465,10 +671,10 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
465
671
  Object.freeze(["agent", "thinking", "budget_tokens"]),
466
672
  Object.freeze(["limits", "max_tool_iterations"]),
467
673
  Object.freeze(["failure_taxonomy"]),
468
- // Adaptive model routing pool policy knobs only (see the cli entry).
469
- Object.freeze(["agent", "model_pool", "policy"]),
470
- Object.freeze(["agent", "model_pool", "routing"]),
471
- Object.freeze(["agent", "model_pool", "learning"]),
674
+ // Adaptive model routing + 0.6.0 hybrid dials see the cli entry.
675
+ ...poolDials(AGENT),
676
+ Object.freeze(["agent", "temperature"]),
677
+ ...MODEL_PROFILE_DIALS,
472
678
  // 0.3.0 memory/continuity quality knobs — types/bounds at the cli entry.
473
679
  Object.freeze(["memory", "recallK"]),
474
680
  Object.freeze(["memory", "autoCaptureThreshold"]),
@@ -479,6 +685,7 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
479
685
  // Loop contract 0.4 (Batch B, G40) — evaluation dials; see the cli entry.
480
686
  Object.freeze(["evaluation", "threshold"]),
481
687
  Object.freeze(["evaluation", "max_retries"]),
688
+ Object.freeze(["evaluation", "grader", "repeats"]),
482
689
  // Loop contract 0.4 (Batch E) — knowledge/recall dials; see the cli entry.
483
690
  Object.freeze(["knowledge", "default_k"]),
484
691
  Object.freeze(["knowledge", "chunk", "size"]),
@@ -487,18 +694,29 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
487
694
  ]),
488
695
  pipeline: Object.freeze([
489
696
  Object.freeze(["agent", "instructions"]),
697
+ // 0.6.0 §10.3 — the pipeline agent carries a `model_pool` and a
698
+ // `temperature` (params/overlay only per §11.3): the same policy knobs
699
+ // and dials as the cli agent.
700
+ ...poolDials(AGENT),
701
+ Object.freeze(["agent", "temperature"]),
702
+ ...MODEL_PROFILE_DIALS,
490
703
  Object.freeze(["failure_taxonomy"]),
491
704
  Object.freeze(["indexing", "chunkSize"]),
492
705
  Object.freeze(["indexing", "chunkOverlap"]),
493
706
  Object.freeze(["retrieve", "defaultK"]),
494
707
  ]),
495
708
  crew: Object.freeze([
496
- // Whole-role replacement — this ALSO reaches item 9's (G37) per-role
497
- // model routing (model_pool policy/routing/learning, tiers, fallbacks):
498
- // the role name is a dynamic map key, so no static narrower path exists,
499
- // and whole-role replacement already spans model/instructions, so the
500
- // policy knobs ride here rather than as standalone paths.
709
+ // Whole-role replacement — a whole-role VALUE may carry item 9's (G37)
710
+ // per-role model routing (the role name is a dynamic map key; see the
711
+ // G37 tests). The sub-path route is narrower, as at the workflow entry:
712
+ // `roles.<r>.model_tiers` / `model_fallbacks` / `circuit_breaker` /
713
+ // `model_pool.*` are refused by the structural rule; only the exact
714
+ // dials below are admitted under a role.
501
715
  Object.freeze(["roles"]),
716
+ // 0.6.0 §10.3 — per-role dials (`*` = one role name); see the workflow entry.
717
+ ...poolDials(ROLE),
718
+ Object.freeze([...ROLE, "temperature"]),
719
+ ...MODEL_PROFILE_DIALS,
502
720
  Object.freeze(["failure_taxonomy"]),
503
721
  Object.freeze(["chains"]),
504
722
  Object.freeze(["transaction_policy"]),
@@ -516,6 +734,11 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
516
734
  ]) /* whole-role replacement */,
517
735
  research: Object.freeze([
518
736
  Object.freeze(["agent", "instructions"]),
737
+ // 0.6.0 §10.3 — the pooled single-agent shapes (research, batch, browser)
738
+ // carry `agent.model_pool` + `agent.temperature`: same dials as cli.
739
+ ...poolDials(AGENT),
740
+ Object.freeze(["agent", "temperature"]),
741
+ ...MODEL_PROFILE_DIALS,
519
742
  Object.freeze(["failure_taxonomy"]),
520
743
  // NOTE ["retrieve","maxDepth"] was REMOVED here (Batch A): the research
521
744
  // retrieve block never grew a maxDepth field (strict schema rejects
@@ -537,6 +760,10 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
537
760
  ]),
538
761
  batch: Object.freeze([
539
762
  Object.freeze(["agent", "instructions"]),
763
+ // 0.6.0 §10.3 — see the research entry.
764
+ ...poolDials(AGENT),
765
+ Object.freeze(["agent", "temperature"]),
766
+ ...MODEL_PROFILE_DIALS,
540
767
  Object.freeze(["failure_taxonomy"]),
541
768
  Object.freeze(["chains"]),
542
769
  Object.freeze(["transaction_policy"]),
@@ -545,21 +772,32 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
545
772
  ]),
546
773
  voice: Object.freeze([
547
774
  Object.freeze(["agent", "instructions"]),
775
+ // 0.6.0 §10.3 — `models:` is attached to every shape; the profile dials
776
+ // are the only 0.6.0 entries here (voice has no pool and no temperature).
777
+ ...MODEL_PROFILE_DIALS,
548
778
  Object.freeze(["failure_taxonomy"]),
549
779
  ]),
550
780
  browser: Object.freeze([
551
781
  Object.freeze(["agent", "instructions"]),
782
+ // 0.6.0 §10.3 — see the research entry.
783
+ ...poolDials(AGENT),
784
+ Object.freeze(["agent", "temperature"]),
785
+ ...MODEL_PROFILE_DIALS,
552
786
  Object.freeze(["failure_taxonomy"]),
553
787
  // Loop contract 0.4 (Batch A) — see the cli entry.
554
788
  Object.freeze(["limits", "max_tool_iterations"]),
555
789
  ]),
556
790
  eval: Object.freeze([
557
791
  Object.freeze(["agent", "instructions"]),
792
+ // 0.6.0 §10.3 — profile dials only (see the voice entry).
793
+ ...MODEL_PROFILE_DIALS,
558
794
  Object.freeze(["failure_taxonomy"]),
559
795
  ]),
560
796
  // §47 onchain daemon: full cross-cutting blocks are optimizable.
561
797
  onchain: Object.freeze([
562
798
  Object.freeze(["agent", "instructions"]),
799
+ // 0.6.0 §10.3 — profile dials only (see the voice entry).
800
+ ...MODEL_PROFILE_DIALS,
563
801
  Object.freeze(["failure_taxonomy"]),
564
802
  Object.freeze(["chains"]),
565
803
  Object.freeze(["triggers"]),
@@ -570,41 +808,438 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
570
808
  // the productive knobs; move-timeout-ms is the realtime quality knob.
571
809
  "onchain-game": Object.freeze([
572
810
  Object.freeze(["agent", "instructions"]),
811
+ // 0.6.0 §10.3 — profile dials only (see the voice entry).
812
+ ...MODEL_PROFILE_DIALS,
573
813
  Object.freeze(["failure_taxonomy"]),
574
814
  Object.freeze(["game"]),
575
815
  Object.freeze(["transaction_policy"]),
576
816
  ]),
577
817
  });
578
- function isOptimizable(target, path) {
579
- const allowed = OPTIMIZABLE_PATHS[target];
580
- for (const ok of allowed) {
581
- if (ok.length !== path.length)
582
- continue;
583
- let match = true;
584
- for (let i = 0; i < ok.length; i++) {
585
- if (ok[i] !== path[i]) {
586
- match = false;
587
- break;
818
+ /**
819
+ * 0.6.0 (plan §10.3, mechanism 2) — the STRUCTURAL rule: a path that passes
820
+ * through any of these segments is optimizable only by EXACT match against a
821
+ * listed entry, never by prefix. The whole-block entries `["steps"]`,
822
+ * `["nodes"]` and `["roles"]` exist because the step index / node name / role
823
+ * name is positional, and whole-block replacement was meant to span
824
+ * `model`/`instructions` not the human-owned structure that now hangs off
825
+ * every routed block:
826
+ *
827
+ * - `model_pool` — the candidate ROSTER (mirroring the standing
828
+ * `["agent","model"]` exclusion), and from 0.6.0 the hybrid container:
829
+ * `rules.*.use` (rule targets), `strategy.*` role slots and
830
+ * `model_directed` (registers the `Escalate`/`Consult` tool surface),
831
+ * `reward.*` (the floor), `directives` (admits user-directed input),
832
+ * `classifier.{model,labels}` (judge identity), `scope`. The plan's
833
+ * verdict table classifies every one of these as excluded.
834
+ * - `judge` — a `kind: judge` gate's `escalate_to` (a routing target),
835
+ * `judges` (judge identity), `temperature`/`target`.
836
+ * - `sub_agents` — a sub-agent definition is a tool-surface / identity block
837
+ * (`tools`, `permissions`, `model`, `inherit_bypass`, and from 0.6.0
838
+ * `allowed_profiles`, `budget_share`, `inherit_routing`, its own routing).
839
+ * - `temperature` — a quality dial the plan lists as optimizable, but ONLY
840
+ * via the exact entries (`["agent","temperature"]`, the per-step / node /
841
+ * role wildcard entries, `models.*.temperature`): every OTHER
842
+ * `temperature` leaf — a candidate's, a sub-agent's, a judge's pinned
843
+ * sampling temperature — is human-owned, and a prefix-reached patch
844
+ * would land on one of those.
845
+ * - `model_tiers`, `model_fallbacks`, `circuit_breaker` — the three
846
+ * pre-pool routing blocks (§11.1 adds them to graph nodes and sub-agents
847
+ * in 0.6.0; steps and roles carried them before). `model_tiers` is a
848
+ * two-slot roster (`fast`/`default` are model identity, mirroring the
849
+ * standing `agent.model` exclusion), `model_fallbacks` is the failover
850
+ * chain and `circuit_breaker` decides when an arm is withdrawn from
851
+ * service — the same verdicts the §10.3 table gives their `models.*`
852
+ * twins. No exact entry names any of them, so the rule closes the
853
+ * whole-block prefix route (`["nodes"]` → `nodes.a.circuit_breaker`)
854
+ * without removing anything the table promises.
855
+ *
856
+ * One rule, no negative-list semantics: the exact entries
857
+ * `["agent","model_pool",{policy,routing,learning}]` keep working, and a
858
+ * whole-block `["steps"]` replacement (whose PATH carries none of these
859
+ * segments) is unchanged. The wildcard segment ({@link WILDCARD_SEGMENT}) and
860
+ * the exact entries the §10.3 table promises (`poolDials`, `judgeGateDials`,
861
+ * `MODEL_PROFILE_DIALS`) ride on top of this rule — it is what makes that
862
+ * table true for the positional shapes. Note the reconciliation the exact
863
+ * match performs: `["agent","temperature"]` IS listed, so the structural
864
+ * `temperature` segment blocks only the prefix route, never the entry.
865
+ */
866
+ export const STRUCTURAL_SEGMENTS = Object.freeze([
867
+ "model_pool",
868
+ "judge",
869
+ "sub_agents",
870
+ "temperature",
871
+ "model_tiers",
872
+ "model_fallbacks",
873
+ "circuit_breaker",
874
+ ]);
875
+ /**
876
+ * 0.6.0 §10.3 — the EXCLUDED half of the verdict table, stated in code rather
877
+ * than left to the whitelist's default-deny (the 0.3.0 discipline). Each row
878
+ * is a segment pattern matched as a CONTIGUOUS sub-sequence ANYWHERE in a leaf
879
+ * path (`"*"` = any one segment), so one row covers the same key on every
880
+ * host: `["model_pool","rules","*","use"]` excludes `agent.model_pool.rules[i].use`
881
+ * on cli and `steps[i].model_pool.rules[j].use` on workflow alike. A row that
882
+ * names a block (`["model_pool","candidates"]`) excludes every leaf under it.
883
+ *
884
+ * This table is NOT consulted by {@link isOptimizable} — the whitelist is the
885
+ * only admission rule, and anything it does not list is refused. The rows
886
+ * exist so the table's closing invariant can be TESTED: every leaf the
887
+ * 0.6.0 spec delta introduced (enumerated from the zod schema) must be
888
+ * classified exactly once — matched by the whitelist XOR by a row here — and
889
+ * the test fails on a leaf that is unclassified or claimed twice, so this
890
+ * table, the whitelist and the schema cannot drift apart. The Hangar spec
891
+ * editor and `crewhaus advise` read the reasons.
892
+ */
893
+ export const HUMAN_OWNED_PATHS = Object.freeze([
894
+ // -- the roster (already excluded today; the 0.6.0 per-candidate fields
895
+ // ride inside it, including `enabled`) --
896
+ {
897
+ pattern: ["agent", "model"],
898
+ reason: "the model roster is human-owned (the standing agent.model exclusion)",
899
+ },
900
+ {
901
+ pattern: ["model_pool", "candidates"],
902
+ reason: "the candidate roster — every inline profile field and `enabled` included — is human-owned; learning tunes selection within the declared set, never the set",
903
+ },
904
+ {
905
+ pattern: ["model_pool", "objective"],
906
+ reason: "the reward weights decide the quality/cost/latency trade-off the learner optimises — a spend policy, not a dial (sibling of reward)",
907
+ },
908
+ // -- the pre-pool routing blocks on every routed host (agent, steps[i],
909
+ // nodes.<n>, roles.<r>, sub_agents.<n>): roster, failover, withdrawal --
910
+ {
911
+ pattern: ["model_tiers"],
912
+ reason: "the two-tier router is a two-slot roster (fast/default are model identity) plus its thresholds — human-owned like agent.model; the pool's exact routing entry is the tunable generalisation",
913
+ },
914
+ {
915
+ pattern: ["model_fallbacks"],
916
+ reason: "the failover chain decides which model answers when the primary is down",
917
+ },
918
+ {
919
+ pattern: ["circuit_breaker"],
920
+ reason: "the breaker decides when an arm is withdrawn from service",
921
+ },
922
+ // -- models.<name>: identity, security surface, routing semantics, prompt --
923
+ {
924
+ pattern: ["models", "*", "model"],
925
+ reason: "a profile's model is identity — the roster is never auto-patched",
926
+ },
927
+ {
928
+ pattern: ["models", "*", "tags"],
929
+ reason: "tags decide what `strong` means: the default floor arm and the target of `strongest`",
930
+ },
931
+ {
932
+ pattern: ["models", "*", "tools"],
933
+ reason: "a profile's toolset is tool surface — security, not quality",
934
+ },
935
+ { pattern: ["models", "*", "tool_config"], reason: "tool config decides what tools may reach" },
936
+ {
937
+ pattern: ["models", "*", "permissions"],
938
+ reason: "permissions are the security floor; a profile may only narrow them, and only a human decides how",
939
+ },
940
+ {
941
+ pattern: ["models", "*", "rate_limits"],
942
+ reason: "rate limits are a spend / abuse control, never a quality dial",
943
+ },
944
+ { pattern: ["models", "*", "cost"], reason: "a per-profile spend cap is human-owned policy" },
945
+ {
946
+ pattern: ["models", "*", "requires"],
947
+ reason: "capability requirements are routing semantics (N1 eligibility)",
948
+ },
949
+ {
950
+ pattern: ["models", "*", "capabilities"],
951
+ reason: "a declared capability override is a fact about a model, not a dial",
952
+ },
953
+ {
954
+ pattern: ["models", "*", "fallbacks"],
955
+ reason: "the failover chain decides which provider answers when the first is down",
956
+ },
957
+ {
958
+ pattern: ["models", "*", "circuit_breaker"],
959
+ reason: "the breaker decides when an arm is withdrawn from service",
960
+ },
961
+ {
962
+ pattern: ["models", "*", "instructions"],
963
+ reason: "prompt text stays human-owned like evaluation.grader — an accepted inconsistency with agent.instructions, documented rather than widened",
964
+ },
965
+ {
966
+ pattern: ["models", "*", "caching"],
967
+ reason: "cache posture is a cost/latency contract with the provider, classified explicitly",
968
+ },
969
+ {
970
+ pattern: ["models", "*", "thinking", "effort"],
971
+ reason: "the thinking FORM stays human-owned (only the explicit budget is a dial — the standing agent.thinking rule)",
972
+ },
973
+ // -- model_pool: rule targets, judge identity, the floor, the two switches
974
+ // that admit model- or user-directed input --
975
+ { pattern: ["model_pool", "rules", "*", "id"], reason: "a rule id is persisted identity" },
976
+ {
977
+ pattern: ["model_pool", "rules", "*", "when"],
978
+ reason: "a rule's condition decides which turns leave the cheap lane — a routing target, human-owned",
979
+ },
980
+ {
981
+ pattern: ["model_pool", "rules", "*", "use"],
982
+ reason: "a rule's target names which arm serves — the roster decision, human-owned",
983
+ },
984
+ { pattern: ["model_pool", "classifier", "model"], reason: "judge identity" },
985
+ {
986
+ pattern: ["model_pool", "classifier", "labels"],
987
+ reason: "the label text is the classifier's prompt and its verdict vocabulary",
988
+ },
989
+ {
990
+ pattern: ["model_pool", "strategy", "cascade", "draft"],
991
+ reason: "a strategy role slot names a roster member",
992
+ },
993
+ {
994
+ pattern: ["model_pool", "strategy", "cascade", "escalate_to"],
995
+ reason: "a strategy role slot names a roster member",
996
+ },
997
+ { pattern: ["model_pool", "strategy", "guide", "model"], reason: "a strategy model slot" },
998
+ {
999
+ pattern: ["model_pool", "strategy", "guide", "every"],
1000
+ reason: "guide cadence decides a transcript-cache rewrite per turn — classified explicitly",
1001
+ },
1002
+ {
1003
+ pattern: ["model_pool", "strategy", "guide", "budget_usd"],
1004
+ reason: "a spend cap is human-owned policy",
1005
+ },
1006
+ {
1007
+ pattern: ["model_pool", "strategy", "shadow", "candidate"],
1008
+ reason: "the audition candidate is a roster decision",
1009
+ },
1010
+ { pattern: ["model_pool", "strategy", "shadow", "grade_with"], reason: "judge identity" },
1011
+ {
1012
+ pattern: ["model_pool", "strategy", "committee"],
1013
+ reason: "committee membership, its judge and its tie-breaker are roster and judge identity",
1014
+ },
1015
+ {
1016
+ pattern: ["model_pool", "strategy", "model_directed"],
1017
+ reason: "registers the Escalate / Consult tools — a tool-surface decision that admits model-directed input",
1018
+ },
1019
+ {
1020
+ pattern: ["model_pool", "reward"],
1021
+ reason: "the reward block (quality source, priors, the floor, reset) bounds what the learner may exploit — never tuned by the loop it bounds",
1022
+ },
1023
+ {
1024
+ pattern: ["model_pool", "directives"],
1025
+ reason: "admits user-directed per-message steering — a trust-boundary switch",
1026
+ },
1027
+ {
1028
+ pattern: ["model_pool", "scope"],
1029
+ reason: "the scoped routeKey prefix is arm identity (stamped by the compiler)",
1030
+ },
1031
+ // -- evaluation / judge gates: what is judged and by whom --
1032
+ {
1033
+ pattern: ["evaluation", "grader", "type"],
1034
+ reason: "the grader kind is human-owned semantics (the optimizer tunes how strict the gate is, never what it judges)",
1035
+ },
1036
+ { pattern: ["evaluation", "grader", "criteria"], reason: "prompt text — what the judge judges" },
1037
+ {
1038
+ pattern: ["evaluation", "grader", "value"],
1039
+ reason: "a deterministic grader's match rule is what it judges",
1040
+ },
1041
+ { pattern: ["evaluation", "grader", "model"], reason: "judge identity" },
1042
+ { pattern: ["evaluation", "grader", "judges"], reason: "judge identity (the panel)" },
1043
+ {
1044
+ pattern: ["evaluation", "grader", "temperature"],
1045
+ reason: "the judge's pinned sampling temperature is measurement integrity, not a quality dial",
1046
+ },
1047
+ {
1048
+ pattern: ["evaluation", "grader", "target"],
1049
+ reason: "what the judge grades (output vs trajectory) is measurement semantics",
1050
+ },
1051
+ {
1052
+ pattern: ["evaluation", "on_fail"],
1053
+ reason: "the below-threshold behaviour (incl. escalate) is human-owned semantics",
1054
+ },
1055
+ {
1056
+ pattern: ["evaluation", "allow_self_judge"],
1057
+ reason: "a measurement-integrity waiver, never a knob",
1058
+ },
1059
+ { pattern: ["judge", "criteria"], reason: "prompt text — what the gate judges" },
1060
+ { pattern: ["judge", "model"], reason: "judge identity" },
1061
+ { pattern: ["judge", "judges"], reason: "judge identity (the panel)" },
1062
+ {
1063
+ pattern: ["judge", "temperature"],
1064
+ reason: "the judge's pinned sampling temperature is measurement integrity",
1065
+ },
1066
+ { pattern: ["judge", "target"], reason: "what the gate grades is measurement semantics" },
1067
+ {
1068
+ pattern: ["judge", "on_fail"],
1069
+ reason: "the below-threshold behaviour is human-owned semantics",
1070
+ },
1071
+ {
1072
+ pattern: ["judge", "escalate_to"],
1073
+ reason: "the re-run's forced arm is a roster decision",
1074
+ },
1075
+ // -- budget: spend policy, whole block --
1076
+ { pattern: ["budget", "usd"], reason: "the run cap is human-owned spend policy" },
1077
+ { pattern: ["budget", "judge_share"], reason: "a spend split is human-owned policy" },
1078
+ {
1079
+ pattern: ["budget", "scope"],
1080
+ reason: "run vs session bounds a cap's blast radius — an operator's call",
1081
+ },
1082
+ {
1083
+ pattern: ["budget", "on_exceed"],
1084
+ reason: "what happens at the cap (stop / degrade to a model) is roster and spend policy",
1085
+ },
1086
+ // -- sub-agents: a tool-surface / identity block, wholesale --
1087
+ {
1088
+ pattern: ["sub_agents"],
1089
+ reason: "a sub-agent definition is tool surface, identity and (0.6.0) its own routing, budget share, inheritance and profile allowlist — human-owned wholesale",
1090
+ },
1091
+ // -- MCP trust flags, crew router model --
1092
+ {
1093
+ pattern: ["tool_flags"],
1094
+ reason: "MCP trust flags narrow a tool's trust posture — security surface",
1095
+ },
1096
+ {
1097
+ pattern: ["routing", "model"],
1098
+ reason: "the crew llm router's model is a roster decision",
1099
+ },
1100
+ // -- observability.slo: production alerting and mitigation thresholds --
1101
+ {
1102
+ pattern: ["observability", "slo"],
1103
+ reason: "an SLO threshold decides when production is alerted, paused or rolled back — an operator's call, never an eval loop's (the routing rates added in 0.6.0 follow the existing keys)",
1104
+ },
1105
+ ]);
1106
+ /** A leaf path is human-owned when some {@link HUMAN_OWNED_PATHS} row matches it. */
1107
+ export function humanOwnedReason(path) {
1108
+ for (const row of HUMAN_OWNED_PATHS) {
1109
+ if (containsPattern(path, row.pattern))
1110
+ return row.reason;
1111
+ }
1112
+ return undefined;
1113
+ }
1114
+ /** One segment of a pattern against one segment of a path (`"*"` matches any one segment). */
1115
+ function segmentMatches(pattern, actual) {
1116
+ return pattern === WILDCARD_SEGMENT || pattern === String(actual);
1117
+ }
1118
+ /** `pattern` appears as a contiguous sub-sequence of `path` (wildcards honoured). */
1119
+ function containsPattern(path, pattern) {
1120
+ if (pattern.length === 0 || pattern.length > path.length)
1121
+ return false;
1122
+ outer: for (let start = 0; start + pattern.length <= path.length; start++) {
1123
+ for (let i = 0; i < pattern.length; i++) {
1124
+ if (!segmentMatches(pattern[i], path[start + i])) {
1125
+ continue outer;
588
1126
  }
589
1127
  }
590
- if (match)
591
- return true;
1128
+ return true;
592
1129
  }
593
- // Allow patches with a prefix that matches an optimizable path
594
- // (e.g. `["nodes", "0", "instructions"]` if `["nodes"]` is whitelisted)
595
- // so the optimizer can do fine-grained updates without listing every
596
- // sub-path.
597
- for (const ok of allowed) {
598
- if (path.length < ok.length)
599
- continue;
600
- let match = true;
601
- for (let i = 0; i < ok.length; i++) {
602
- if (ok[i] !== path[i]) {
603
- match = false;
604
- break;
1130
+ return false;
1131
+ }
1132
+ /** `entry` matches the first `entry.length` segments of `path` (wildcards honoured). */
1133
+ function entryIsPrefixOf(entry, path) {
1134
+ if (path.length < entry.length)
1135
+ return false;
1136
+ for (let i = 0; i < entry.length; i++) {
1137
+ if (!segmentMatches(entry[i], path[i]))
1138
+ return false;
1139
+ }
1140
+ return true;
1141
+ }
1142
+ /**
1143
+ * The wildcard placement guard (plan §10.3, mechanism 1): every issue is a
1144
+ * whitelist entry that would let `"*"` sit above a human-owned key. Returns
1145
+ * `[]` for the shipped table — pinned by the spec-patch tests, so a future
1146
+ * entry that breaks the rule fails CI rather than widening the surface.
1147
+ */
1148
+ export function wildcardPlacementIssues(table = OPTIMIZABLE_PATHS) {
1149
+ const issues = [];
1150
+ for (const [target, entries] of Object.entries(table)) {
1151
+ for (const entry of entries) {
1152
+ const stars = entry.filter((seg) => seg === WILDCARD_SEGMENT).length;
1153
+ if (stars === 0)
1154
+ continue;
1155
+ const label = `${target}: [${entry.join(", ")}]`;
1156
+ const last = entry[entry.length - 1];
1157
+ if (last === WILDCARD_SEGMENT) {
1158
+ issues.push(`${label} ends in a wildcard — it would admit a whole dynamic subtree by prefix`);
1159
+ continue;
1160
+ }
1161
+ if (WILDCARD_FORBIDDEN_LEAVES.includes(last)) {
1162
+ issues.push(`${label} terminates in the human-owned key "${last}" under a wildcard — the §10.3 table excludes it`);
1163
+ }
1164
+ // `temperature` is the one structural segment that is a scalar LEAF (it
1165
+ // is structural to block the prefix route, not because it is a block),
1166
+ // so a wildcard entry may terminate in it; the block segments may not.
1167
+ if (STRUCTURAL_SEGMENTS.includes(last) && last !== "temperature") {
1168
+ issues.push(`${label} terminates in the structural block "${last}" under a wildcard — a structural block is never whitelisted wholesale`);
1169
+ }
1170
+ if (entry.length < 3) {
1171
+ issues.push(`${label} is too short to name a dial — a wildcard entry needs a concrete parent AND a concrete leaf`);
605
1172
  }
606
1173
  }
607
- if (match)
1174
+ }
1175
+ return issues;
1176
+ }
1177
+ /**
1178
+ * The single admission rule behind `validatePatch`, `applySpecEdits({
1179
+ * restrictToOptimizable })`, the Hangar spec editor's trust badge, the
1180
+ * feedback / watch-me advice tiers and the orchestrator's stage guard:
1181
+ *
1182
+ * 1. an EXACT match against a listed entry admits the path (wildcards
1183
+ * match one segment each);
1184
+ * 2. a path through a STRUCTURAL segment ({@link STRUCTURAL_SEGMENTS}) is
1185
+ * admitted by exact match ONLY — never by prefix;
1186
+ * 3. otherwise a listed entry that is a PREFIX of the path admits it
1187
+ * (`["nodes","0","instructions"]` under `["nodes"]`), so the optimizer
1188
+ * can make fine-grained updates without listing every sub-path.
1189
+ *
1190
+ * Exported so every consumer shares one matcher — a hand-rolled prefix copy
1191
+ * would neither honour the wildcard nor close the structural leak.
1192
+ */
1193
+ /**
1194
+ * 0.6.0 §6.1 (PR 12) — the leaves the whitelist admits BY PREFIX but the
1195
+ * optimizer must never move, matched as a contiguous sub-sequence anywhere in
1196
+ * a path (the {@link HUMAN_OWNED_PATHS} matcher) and ENFORCED by
1197
+ * {@link isOptimizable}.
1198
+ *
1199
+ * There is exactly one row, and it exists because `model_pool.learning` is
1200
+ * deliberately whitelisted WHOLESALE: `crewhaus advise` mines the reward
1201
+ * scoreboard into that block and patches it AS a block, so a narrower
1202
+ * enumeration would break the advisor. PR 19 shipped the verdict table with
1203
+ * the `learning.seed` exclusion documented as an accepted gap "until the eval
1204
+ * runner (PR 12) needs it"; it needs it now — a routed eval PINS
1205
+ * `learning.seed`, so an optimizer free to patch the seed could produce a
1206
+ * measured delta of guaranteed zero, the one mutation that always "wins"
1207
+ * because it changes nothing the measurement can see.
1208
+ *
1209
+ * This is an enforcement narrower than the whitelist, NOT a second
1210
+ * classification: the `learning` BLOCK stays legitimately optimizable (which
1211
+ * is what §10.3's verdict table and its closing-invariant test record), and
1212
+ * `validatePatch` additionally refuses a block patch that would CHANGE the
1213
+ * seed — a block patch that preserves it, as the advisor's spread does,
1214
+ * passes.
1215
+ */
1216
+ export const OPTIMIZER_REFUSED_LEAVES = Object.freeze([
1217
+ Object.freeze(["model_pool", "learning", "seed"]),
1218
+ ]);
1219
+ /** True when `path` contains one of {@link OPTIMIZER_REFUSED_LEAVES} as a contiguous run. */
1220
+ function isRefusedLeaf(path) {
1221
+ const segs = path.map((s) => String(s));
1222
+ return OPTIMIZER_REFUSED_LEAVES.some((pattern) => segs.some((_s, i) => pattern.every((p, j) => segs[i + j] === p)));
1223
+ }
1224
+ export function isOptimizable(target, path) {
1225
+ // 0.6.0 §6.1 — the enforced refusals win over every admission rule below.
1226
+ if (isRefusedLeaf(path))
1227
+ return false;
1228
+ const allowed = OPTIMIZABLE_PATHS[target];
1229
+ if (allowed === undefined || path.length === 0)
1230
+ return false;
1231
+ for (const ok of allowed) {
1232
+ if (ok.length === path.length && entryIsPrefixOf(ok, path))
1233
+ return true;
1234
+ }
1235
+ // The structural rule (see STRUCTURAL_SEGMENTS): past this point only a
1236
+ // PREFIX match can admit the path, and a path through a human-owned
1237
+ // structural block is never admitted by prefix.
1238
+ if (path.some((seg) => typeof seg === "string" && STRUCTURAL_SEGMENTS.includes(seg))) {
1239
+ return false;
1240
+ }
1241
+ for (const ok of allowed) {
1242
+ if (entryIsPrefixOf(ok, path))
608
1243
  return true;
609
1244
  }
610
1245
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/spec-patch",
3
- "version": "0.5.7",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Pillar-2 patch infrastructure — apply a SpecPatch to a YAML source preserving comments and key order via the yaml CST. Drives the active eval optimizer's spec-level mutation loop.",
6
6
  "main": "dist/index.js",
@@ -15,8 +15,8 @@
15
15
  "test": "bun test src"
16
16
  },
17
17
  "dependencies": {
18
- "@crewhaus/errors": "0.5.7",
19
- "@crewhaus/spec": "0.5.7",
18
+ "@crewhaus/errors": "0.6.0",
19
+ "@crewhaus/spec": "0.6.0",
20
20
  "yaml": "^2.6.0",
21
21
  "zod": "^3.23.8"
22
22
  },