@crewhaus/spec-patch 0.3.2 → 0.4.2

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
@@ -91,6 +91,59 @@ export declare function applySpecPatch(yamlText: string, patch: SpecPatch): Appl
91
91
  * via `parseSpec`.
92
92
  */
93
93
  export declare function validatePatch(spec: Spec, patch: SpecPatch): void;
94
+ /**
95
+ * Loop contract 0.4 (Batch B, G40) — one edit in an `applySpecEdits` batch.
96
+ *
97
+ * Where `SpecPatch` is the optimizer's IMPERATIVE op (`add` vs `replace` vs
98
+ * `remove`, each with a strict presence pre-check), `SpecEdit` is the
99
+ * DECLARATIVE surface shared by spec authors (the studio's form fields, the
100
+ * compiler-worker) and the optimizer: "make `path` carry `value`" (upsert —
101
+ * creates missing intermediate collections) or, when `value` is `undefined`,
102
+ * "make `path` absent" (idempotent — deleting an already-absent path is a
103
+ * no-op, so a cleared form field never needs to track prior presence).
104
+ *
105
+ * Paths may address sequence items by non-negative integer index (numeric
106
+ * strings work too — the CST coerces them for sequences): `["steps", 0,
107
+ * "instructions"]`. Index `length` appends; anything past that is an error
108
+ * (never null-padding), and a brand-new sequence can only start at index 0.
109
+ */
110
+ export type SpecEditPathSegment = string | number;
111
+ export type SpecEdit = {
112
+ readonly path: ReadonlyArray<SpecEditPathSegment>;
113
+ /** New value at `path` (upsert). `undefined` — or omitting the key — DELETES the path. */
114
+ readonly value?: unknown;
115
+ /** Optional rationale string for audit / write-back commit messages. */
116
+ readonly rationale?: string;
117
+ };
118
+ export type ApplySpecEditsOptions = {
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.
124
+ */
125
+ readonly restrictToOptimizable?: boolean;
126
+ };
127
+ export type ApplySpecEditsResult = {
128
+ /** The mutated YAML text, with comments and key order preserved. */
129
+ readonly yaml: string;
130
+ /** The re-parsed Spec after the whole batch. */
131
+ readonly spec: Spec;
132
+ /** Edits that changed the document — idempotent deletes of absent paths don't count. */
133
+ readonly applied: number;
134
+ };
135
+ /**
136
+ * Apply a batch of `SpecEdit`s to a YAML spec source ATOMICALLY: all edits
137
+ * mutate one CST in order, the result is re-validated via `parseSpec` once,
138
+ * and any failure — a malformed edit, an out-of-bounds sequence index, or a
139
+ * batch that produces an invalid spec — throws `SpecPatchError` without
140
+ * yielding a partially-edited text. Comments and key order survive exactly
141
+ * as in `applySpecPatch` (same `yaml`-package CST underneath).
142
+ *
143
+ * A zero-edit batch is a validated no-op: the input text is returned
144
+ * byte-identical (no CST round-trip reformatting).
145
+ */
146
+ export declare function applySpecEdits(yamlText: string, edits: ReadonlyArray<SpecEdit>, opts?: ApplySpecEditsOptions): ApplySpecEditsResult;
94
147
  /**
95
148
  * Per-target whitelist of mutation paths the active optimizer is
96
149
  * allowed to touch. Adding a new field here is the explicit signal that
@@ -98,6 +151,22 @@ export declare function validatePatch(spec: Spec, patch: SpecPatch): void;
98
151
  * only mutate prompts (the default), preserving the "spec safety floor"
99
152
  * that an optimizer can't accidentally rewrite security-critical fields
100
153
  * like `permissions.mode` or `model_router` rules.
154
+ *
155
+ * "Watch me" (`watchme:` on cli/channel/managed, design/watch-me.md §4.3) —
156
+ * EVERY `watchme.*` path is deliberately EXCLUDED; none may be whitelisted
157
+ * piecemeal later without revisiting that design section:
158
+ * - `watchme.enabled` / `watchme.capture` — the observer must not be tuned
159
+ * by the loop it observes (self-referential optimization), and capture
160
+ * fidelity is a consent/data-plane posture, not a quality dial.
161
+ * - `watchme.judge.model` — the model roster is never auto-patched (the
162
+ * standing `agent.model` / model-roster exclusion).
163
+ * - `watchme.judge.sample_rate` / `watchme.judge.budget_usd` — spend-class
164
+ * knobs, human-only (sample_rate MULTIPLIES judge calls, so it is a
165
+ * spend dial wearing a quality dial's clothes).
166
+ * - `watchme.scope` / `watchme.share` — privacy/trust-boundary switches
167
+ * (`share` crosses the harness boundary to Thredz).
168
+ * The paired `crewhaus advise` watchme rule is text-only for exactly this
169
+ * reason: there is no whitelisted path for it to patch.
101
170
  */
102
171
  export declare const OPTIMIZABLE_PATHS: Readonly<Record<Spec["target"], ReadonlyArray<ReadonlyArray<string>>>>;
103
172
  /**
package/dist/index.js CHANGED
@@ -32,7 +32,7 @@
32
32
  */
33
33
  import { CrewhausError } from "@crewhaus/errors";
34
34
  import { parseSpec } from "@crewhaus/spec";
35
- import { parse, parseDocument } from "yaml";
35
+ import { isSeq, parse, parseDocument } from "yaml";
36
36
  import { z } from "zod";
37
37
  import { REDACTED_VALUE, isCredentialKey, maskCredentialTokens } from "./redact";
38
38
  // Re-exported so downstream renderers (the CLI changelog, future reporters)
@@ -152,6 +152,135 @@ export function validatePatch(spec, patch) {
152
152
  function formatPath(path) {
153
153
  return path.join(".");
154
154
  }
155
+ const specEditSchema = z.object({
156
+ path: z.array(z.union([z.string().min(1), z.number().int().nonnegative()])).min(1),
157
+ value: z.unknown().optional(),
158
+ rationale: z.string().optional(),
159
+ });
160
+ /**
161
+ * Apply a batch of `SpecEdit`s to a YAML spec source ATOMICALLY: all edits
162
+ * mutate one CST in order, the result is re-validated via `parseSpec` once,
163
+ * and any failure — a malformed edit, an out-of-bounds sequence index, or a
164
+ * batch that produces an invalid spec — throws `SpecPatchError` without
165
+ * yielding a partially-edited text. Comments and key order survive exactly
166
+ * as in `applySpecPatch` (same `yaml`-package CST underneath).
167
+ *
168
+ * A zero-edit batch is a validated no-op: the input text is returned
169
+ * byte-identical (no CST round-trip reformatting).
170
+ */
171
+ export function applySpecEdits(yamlText, edits, opts = {}) {
172
+ edits.forEach((edit, i) => {
173
+ const parsed = specEditSchema.safeParse(edit);
174
+ if (!parsed.success) {
175
+ throw new SpecPatchError(`edit #${i} shape is invalid: ${parsed.error.message}`);
176
+ }
177
+ });
178
+ if (edits.length === 0) {
179
+ let spec;
180
+ try {
181
+ spec = parseSpec(yamlText);
182
+ }
183
+ catch (err) {
184
+ throw new SpecPatchError(`input YAML failed spec validation: ${err.message}`, err);
185
+ }
186
+ return { yaml: yamlText, spec, applied: 0 };
187
+ }
188
+ let doc;
189
+ try {
190
+ doc = parseDocument(yamlText);
191
+ }
192
+ catch (err) {
193
+ throw new SpecPatchError("input YAML is not parseable", err);
194
+ }
195
+ // `parseDocument` collects syntax errors on `doc.errors` instead of
196
+ // throwing — without this check they'd only surface as the CST's opaque
197
+ // "Document with errors cannot be stringified" at toString() time.
198
+ if (doc.errors.length > 0) {
199
+ throw new SpecPatchError(`input YAML is not parseable: ${doc.errors[0]?.message ?? "unknown error"}`);
200
+ }
201
+ if (opts.restrictToOptimizable === true) {
202
+ const docTarget = doc.getIn(["target"]);
203
+ if (typeof docTarget !== "string" || !(docTarget in OPTIMIZABLE_PATHS)) {
204
+ throw new SpecPatchError(`cannot restrict to optimizable paths: spec target ${JSON.stringify(docTarget)} is not a known target`);
205
+ }
206
+ edits.forEach((edit, i) => {
207
+ if (!isOptimizable(docTarget, edit.path)) {
208
+ throw new SpecPatchError(`edit #${i}: path ${formatEditPath(edit.path)} is not listed in OPTIMIZABLE_PATHS for target "${docTarget}"; add it to packages/spec-patch/src/index.ts if it's intended to be tunable`);
209
+ }
210
+ });
211
+ }
212
+ let applied = 0;
213
+ edits.forEach((edit, i) => {
214
+ const path = [...edit.path];
215
+ const label = `edit #${i} (${formatEditPath(path)})`;
216
+ if (edit.value === undefined) {
217
+ // Delete-on-undefined. hasIn is false both for an absent leaf and for a
218
+ // scalar intermediate, so the deleteIn below can never hit the CST's
219
+ // "Expected YAML collection" throw — absent paths are a clean no-op.
220
+ if (doc.hasIn(path)) {
221
+ doc.deleteIn(path);
222
+ applied += 1;
223
+ }
224
+ return;
225
+ }
226
+ guardSequenceIndices(doc, path, label);
227
+ try {
228
+ doc.setIn(path, edit.value);
229
+ }
230
+ catch (err) {
231
+ throw new SpecPatchError(`${label} failed: ${err.message}`, err);
232
+ }
233
+ applied += 1;
234
+ });
235
+ const newYaml = doc.toString();
236
+ let spec;
237
+ try {
238
+ spec = parseSpec(newYaml);
239
+ }
240
+ catch (err) {
241
+ throw new SpecPatchError(`edited YAML failed spec validation: ${err.message}`, err);
242
+ }
243
+ return { yaml: newYaml, spec, applied };
244
+ }
245
+ /**
246
+ * Pre-flight for `setIn` along a path that may address sequences: the CST
247
+ * happily null-pads a sequence up to any index (`steps[5]` on a 2-step list
248
+ * yields three `null` steps), which is never what an author or optimizer
249
+ * meant. Reject indices past `length` (index === `length` appends) and
250
+ * refuse to CREATE a sequence anywhere but at index 0. Map keys and the
251
+ * CST's own errors (negative / non-integer index, scalar intermediates)
252
+ * pass through untouched — the caller wraps those with the edit label.
253
+ */
254
+ function guardSequenceIndices(doc, path, label) {
255
+ for (let i = 0; i < path.length; i++) {
256
+ const seg = path[i];
257
+ const parent = i === 0 ? doc.contents : doc.getIn(path.slice(0, i), true);
258
+ const asIndex = typeof seg === "number" ? seg : /^\d+$/.test(seg) ? Number.parseInt(seg, 10) : undefined;
259
+ if (isSeq(parent)) {
260
+ if (asIndex !== undefined && asIndex > parent.items.length) {
261
+ throw new SpecPatchError(`${label}: index ${asIndex} is out of bounds for the sequence at ${i === 0 ? "(root)" : formatEditPath(path.slice(0, i))} (length ${parent.items.length}; index ${parent.items.length} appends)`);
262
+ }
263
+ }
264
+ else if (parent === undefined && typeof seg === "number" && seg !== 0) {
265
+ throw new SpecPatchError(`${label}: cannot create a new sequence at ${i === 0 ? "(root)" : formatEditPath(path.slice(0, i))} starting at index ${seg} — a new sequence starts at index 0`);
266
+ }
267
+ }
268
+ }
269
+ /** Render an edit path for messages: string keys dot-joined, integer
270
+ * sequence indices as `[i]` — `["steps", 0, "instructions"]` →
271
+ * `steps[0].instructions`. */
272
+ function formatEditPath(path) {
273
+ let out = "";
274
+ for (const seg of path) {
275
+ if (typeof seg === "number") {
276
+ out += `[${seg}]`;
277
+ }
278
+ else {
279
+ out += out === "" ? seg : `.${seg}`;
280
+ }
281
+ }
282
+ return out;
283
+ }
155
284
  /**
156
285
  * Per-target whitelist of mutation paths the active optimizer is
157
286
  * allowed to touch. Adding a new field here is the explicit signal that
@@ -159,6 +288,22 @@ function formatPath(path) {
159
288
  * only mutate prompts (the default), preserving the "spec safety floor"
160
289
  * that an optimizer can't accidentally rewrite security-critical fields
161
290
  * like `permissions.mode` or `model_router` rules.
291
+ *
292
+ * "Watch me" (`watchme:` on cli/channel/managed, design/watch-me.md §4.3) —
293
+ * EVERY `watchme.*` path is deliberately EXCLUDED; none may be whitelisted
294
+ * piecemeal later without revisiting that design section:
295
+ * - `watchme.enabled` / `watchme.capture` — the observer must not be tuned
296
+ * by the loop it observes (self-referential optimization), and capture
297
+ * fidelity is a consent/data-plane posture, not a quality dial.
298
+ * - `watchme.judge.model` — the model roster is never auto-patched (the
299
+ * standing `agent.model` / model-roster exclusion).
300
+ * - `watchme.judge.sample_rate` / `watchme.judge.budget_usd` — spend-class
301
+ * knobs, human-only (sample_rate MULTIPLIES judge calls, so it is a
302
+ * spend dial wearing a quality dial's clothes).
303
+ * - `watchme.scope` / `watchme.share` — privacy/trust-boundary switches
304
+ * (`share` crosses the harness boundary to Thredz).
305
+ * The paired `crewhaus advise` watchme rule is text-only for exactly this
306
+ * reason: there is no whitelisted path for it to patch.
162
307
  */
163
308
  export const OPTIMIZABLE_PATHS = Object.freeze({
164
309
  cli: Object.freeze([
@@ -167,7 +312,15 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
167
312
  // per-turn output-token cap when max_tokens truncations recur; safe to
168
313
  // autotune (a bigger cap can only trade cost for completeness).
169
314
  Object.freeze(["agent", "max_tokens"]),
315
+ // Loop contract 0.4 (Batch A) — the explicit thinking-token budget is a
316
+ // pure quality/cost dial (>= 1024 enforced by the spec); the optimizer
317
+ // may raise/lower it but never flips the thinking FORM (effort presets
318
+ // stay human-owned via the exactly-one-form superRefine).
319
+ Object.freeze(["agent", "thinking", "budget_tokens"]),
170
320
  Object.freeze(["failure_taxonomy"]),
321
+ // Loop contract 0.4 (Batch A) — real as of the `compaction.threshold`
322
+ // spec field (0.5–0.99): when to trigger autocompaction is a classic
323
+ // token-cost vs context-completeness dial.
171
324
  Object.freeze(["compaction", "threshold"]),
172
325
  // Pillar 2 active context curation — eval-optimizer can flip the
173
326
  // semantic-dedupe + relevance-reorder pass on/off and tune its
@@ -175,10 +328,16 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
175
328
  Object.freeze(["compaction", "curate"]),
176
329
  Object.freeze(["compaction", "dedupeThreshold"]),
177
330
  Object.freeze(["compaction", "relevanceTopK"]),
178
- // Pillar 3 sink-side fabricegress policy + intent-gate thresholds
179
- // are tunable so the eval-optimizer can find the sweet spot between
180
- // false-positive denials and false-negative exfil bypasses.
181
- Object.freeze(["security", "egressPolicy"]),
331
+ // Loop contract 0.4 (Batch A) tool-iteration ceiling: quality (task
332
+ // completion) vs runaway-cost dial. NOTE ["security","egressPolicy"]
333
+ // was REMOVED here: the spec never grew that key (strict schemas
334
+ // reject it), and the OPTIMIZABLE_PATHS guard test requires every
335
+ // listed path to round-trip through parseSpec. Re-add it WITH the spec
336
+ // field when the egress-fabric FRs ship it.
337
+ Object.freeze(["limits", "max_tool_iterations"]),
338
+ // Pillar 3 intent gate — tunable so the eval-optimizer can find the
339
+ // sweet spot between false-positive denials and false-negative exfil
340
+ // bypasses.
182
341
  Object.freeze(["security", "justification"]),
183
342
  // §47 blockchain subsystem (slice 0). Whole-block replacement so the
184
343
  // optimizer can tune `chains[*].finality.count`, `chains[*].rpcPolicy`,
@@ -212,26 +371,63 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
212
371
  // the volatile tail block (token cost vs
213
372
  // plan/ledger completeness)
214
373
  // Deliberately EXCLUDED — the optimizer tunes quality, never semantics:
215
- // thredz.* (credentials), memory.backend (store flip), memory.dream.every
216
- // + .mode (side-effecting schedule / model-spend switch), continuity
374
+ // thredz.* (credentials; thredz.messaging = a destructive send-tool
375
+ // switch), memory.backend (store flip), memory.dream.every + .mode
376
+ // (side-effecting schedule / model-spend switch), continuity
217
377
  // .proof/.scope/.enabled (behavioral switches), compaction
218
378
  // .preserve_user_messages (safety), learning.sources (allowlist =
219
- // security).
379
+ // security). Batch G adds more of the same category: expose.* (G30 — the
380
+ // deployment/exposure surface, not a quality knob), plugins (G32 — a
381
+ // capability allowlist = supply-chain security, human-owned like
382
+ // learning.sources / the model roster), and sub_agents.*.federation (G31
383
+ // — cross-deployment wiring / trust boundary). None are optimizer-reachable.
220
384
  Object.freeze(["memory", "recallK"]),
221
385
  Object.freeze(["memory", "autoCaptureThreshold"]),
222
386
  Object.freeze(["memory", "ttl"]),
223
387
  Object.freeze(["memory", "wiki", "recallK"]),
224
388
  Object.freeze(["memory", "dream", "budget_usd"]),
225
389
  Object.freeze(["continuity", "focusMaxChars"]),
390
+ // Loop contract 0.4 (Batch B, G40) — the in-loop evaluation dials. The
391
+ // pass bar (llm_judge graders only — the spec rejects threshold on
392
+ // deterministic graders, so a mis-aimed patch fails the re-parse) and
393
+ // the retry cap are classic quality-vs-cost knobs. Deliberately NOT
394
+ // ["evaluation"] wholesale and NOT ["evaluation","grader"]: the grader
395
+ // (criteria/type) and on_fail behaviour are human-owned semantics —
396
+ // the optimizer tunes how strict the gate is, never what it judges.
397
+ Object.freeze(["evaluation", "threshold"]),
398
+ Object.freeze(["evaluation", "max_retries"]),
399
+ // Loop contract 0.4 (Batch E) — the agent-shape RAG (`knowledge:`) dials
400
+ // + the per-turn recall cadence. All scalars that survive lower() 1:1
401
+ // with their bounds owned by the spec schema, so an out-of-bounds patch
402
+ // fails applySpecPatch's re-parse. `default_k` (hits/turn) and the chunker
403
+ // window are classic recall-quality vs token-cost dials; `refreshEvery`
404
+ // trades recall freshness against per-turn recall cost. Deliberately NOT
405
+ // ["knowledge"] wholesale and NOT ["knowledge","sources"]: the corpus
406
+ // (an allowlist of what the agent may read) is human-owned, mirroring the
407
+ // learning.sources / model-roster exclusions.
408
+ Object.freeze(["knowledge", "default_k"]),
409
+ Object.freeze(["knowledge", "chunk", "size"]),
410
+ Object.freeze(["knowledge", "chunk", "overlap"]),
411
+ Object.freeze(["memory", "refreshEvery"]),
226
412
  ]),
227
413
  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.
228
419
  Object.freeze(["steps"]),
229
420
  Object.freeze(["failure_taxonomy"]),
230
421
  Object.freeze(["chains"]),
231
422
  Object.freeze(["transaction_policy"]),
423
+ // Loop contract 0.4 (Batch A) — see the cli entry.
424
+ Object.freeze(["limits", "max_tool_iterations"]),
232
425
  ]) /* whole-step replacement allowed */,
233
426
  channel: Object.freeze([
234
427
  Object.freeze(["agent", "instructions"]),
428
+ // Loop contract 0.4 (Batch A) — see the cli entries.
429
+ Object.freeze(["agent", "thinking", "budget_tokens"]),
430
+ Object.freeze(["limits", "max_tool_iterations"]),
235
431
  Object.freeze(["failure_taxonomy"]),
236
432
  Object.freeze(["chains"]),
237
433
  Object.freeze(["transaction_policy"]),
@@ -246,15 +442,28 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
246
442
  Object.freeze(["memory", "wiki", "recallK"]),
247
443
  Object.freeze(["memory", "dream", "budget_usd"]),
248
444
  Object.freeze(["continuity", "focusMaxChars"]),
445
+ // Loop contract 0.4 (Batch B, G40) — evaluation dials; see the cli entry.
446
+ Object.freeze(["evaluation", "threshold"]),
447
+ Object.freeze(["evaluation", "max_retries"]),
448
+ // Loop contract 0.4 (Batch E) — knowledge/recall dials; see the cli entry.
449
+ Object.freeze(["knowledge", "default_k"]),
450
+ Object.freeze(["knowledge", "chunk", "size"]),
451
+ Object.freeze(["knowledge", "chunk", "overlap"]),
452
+ Object.freeze(["memory", "refreshEvery"]),
249
453
  ]),
250
454
  graph: Object.freeze([
251
455
  Object.freeze(["nodes"]),
252
456
  Object.freeze(["failure_taxonomy"]),
253
457
  Object.freeze(["chains"]),
254
458
  Object.freeze(["transaction_policy"]),
459
+ // Loop contract 0.4 (Batch A) — see the cli entry.
460
+ Object.freeze(["limits", "max_tool_iterations"]),
255
461
  ]),
256
462
  managed: Object.freeze([
257
463
  Object.freeze(["agent", "instructions"]),
464
+ // Loop contract 0.4 (Batch A) — see the cli entries.
465
+ Object.freeze(["agent", "thinking", "budget_tokens"]),
466
+ Object.freeze(["limits", "max_tool_iterations"]),
258
467
  Object.freeze(["failure_taxonomy"]),
259
468
  // Adaptive model routing — pool policy knobs only (see the cli entry).
260
469
  Object.freeze(["agent", "model_pool", "policy"]),
@@ -267,6 +476,14 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
267
476
  Object.freeze(["memory", "wiki", "recallK"]),
268
477
  Object.freeze(["memory", "dream", "budget_usd"]),
269
478
  Object.freeze(["continuity", "focusMaxChars"]),
479
+ // Loop contract 0.4 (Batch B, G40) — evaluation dials; see the cli entry.
480
+ Object.freeze(["evaluation", "threshold"]),
481
+ Object.freeze(["evaluation", "max_retries"]),
482
+ // Loop contract 0.4 (Batch E) — knowledge/recall dials; see the cli entry.
483
+ Object.freeze(["knowledge", "default_k"]),
484
+ Object.freeze(["knowledge", "chunk", "size"]),
485
+ Object.freeze(["knowledge", "chunk", "overlap"]),
486
+ Object.freeze(["memory", "refreshEvery"]),
270
487
  ]),
271
488
  pipeline: Object.freeze([
272
489
  Object.freeze(["agent", "instructions"]),
@@ -276,10 +493,17 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
276
493
  Object.freeze(["retrieve", "defaultK"]),
277
494
  ]),
278
495
  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.
279
501
  Object.freeze(["roles"]),
280
502
  Object.freeze(["failure_taxonomy"]),
281
503
  Object.freeze(["chains"]),
282
504
  Object.freeze(["transaction_policy"]),
505
+ // Loop contract 0.4 (Batch A) — see the cli entry.
506
+ Object.freeze(["limits", "max_tool_iterations"]),
283
507
  // 0.3.0 memory/continuity quality knobs — types/bounds at the cli entry.
284
508
  Object.freeze(["memory", "recallK"]),
285
509
  Object.freeze(["memory", "autoCaptureThreshold"]),
@@ -287,13 +511,20 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
287
511
  Object.freeze(["memory", "wiki", "recallK"]),
288
512
  Object.freeze(["memory", "dream", "budget_usd"]),
289
513
  Object.freeze(["continuity", "focusMaxChars"]),
514
+ // Loop contract 0.4 (Batch E) — per-turn recall cadence; see the cli entry.
515
+ Object.freeze(["memory", "refreshEvery"]),
290
516
  ]) /* whole-role replacement */,
291
517
  research: Object.freeze([
292
518
  Object.freeze(["agent", "instructions"]),
293
519
  Object.freeze(["failure_taxonomy"]),
294
- Object.freeze(["retrieve", "maxDepth"]),
520
+ // NOTE ["retrieve","maxDepth"] was REMOVED here (Batch A): the research
521
+ // retrieve block never grew a maxDepth field (strict schema rejects
522
+ // it), and the OPTIMIZABLE_PATHS guard test requires every listed path
523
+ // to round-trip through parseSpec. Re-add it WITH the spec field.
295
524
  Object.freeze(["chains"]),
296
525
  Object.freeze(["transaction_policy"]),
526
+ // Loop contract 0.4 (Batch A) — see the cli entry.
527
+ Object.freeze(["limits", "max_tool_iterations"]),
297
528
  // 0.3.0 memory/continuity quality knobs — types/bounds at the cli entry.
298
529
  Object.freeze(["memory", "recallK"]),
299
530
  Object.freeze(["memory", "autoCaptureThreshold"]),
@@ -301,12 +532,16 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
301
532
  Object.freeze(["memory", "wiki", "recallK"]),
302
533
  Object.freeze(["memory", "dream", "budget_usd"]),
303
534
  Object.freeze(["continuity", "focusMaxChars"]),
535
+ // Loop contract 0.4 (Batch E) — per-turn recall cadence; see the cli entry.
536
+ Object.freeze(["memory", "refreshEvery"]),
304
537
  ]),
305
538
  batch: Object.freeze([
306
539
  Object.freeze(["agent", "instructions"]),
307
540
  Object.freeze(["failure_taxonomy"]),
308
541
  Object.freeze(["chains"]),
309
542
  Object.freeze(["transaction_policy"]),
543
+ // Loop contract 0.4 (Batch A) — see the cli entry.
544
+ Object.freeze(["limits", "max_tool_iterations"]),
310
545
  ]),
311
546
  voice: Object.freeze([
312
547
  Object.freeze(["agent", "instructions"]),
@@ -315,6 +550,8 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
315
550
  browser: Object.freeze([
316
551
  Object.freeze(["agent", "instructions"]),
317
552
  Object.freeze(["failure_taxonomy"]),
553
+ // Loop contract 0.4 (Batch A) — see the cli entry.
554
+ Object.freeze(["limits", "max_tool_iterations"]),
318
555
  ]),
319
556
  eval: Object.freeze([
320
557
  Object.freeze(["agent", "instructions"]),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/spec-patch",
3
- "version": "0.3.2",
3
+ "version": "0.4.2",
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.3.2",
19
- "@crewhaus/spec": "0.3.2",
18
+ "@crewhaus/errors": "0.4.2",
19
+ "@crewhaus/spec": "0.4.2",
20
20
  "yaml": "^2.6.0",
21
21
  "zod": "^3.23.8"
22
22
  },