@crewhaus/spec-patch 0.3.1 → 0.4.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
@@ -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
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
@@ -167,7 +296,15 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
167
296
  // per-turn output-token cap when max_tokens truncations recur; safe to
168
297
  // autotune (a bigger cap can only trade cost for completeness).
169
298
  Object.freeze(["agent", "max_tokens"]),
299
+ // Loop contract 0.4 (Batch A) — the explicit thinking-token budget is a
300
+ // pure quality/cost dial (>= 1024 enforced by the spec); the optimizer
301
+ // may raise/lower it but never flips the thinking FORM (effort presets
302
+ // stay human-owned via the exactly-one-form superRefine).
303
+ Object.freeze(["agent", "thinking", "budget_tokens"]),
170
304
  Object.freeze(["failure_taxonomy"]),
305
+ // Loop contract 0.4 (Batch A) — real as of the `compaction.threshold`
306
+ // spec field (0.5–0.99): when to trigger autocompaction is a classic
307
+ // token-cost vs context-completeness dial.
171
308
  Object.freeze(["compaction", "threshold"]),
172
309
  // Pillar 2 active context curation — eval-optimizer can flip the
173
310
  // semantic-dedupe + relevance-reorder pass on/off and tune its
@@ -175,10 +312,16 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
175
312
  Object.freeze(["compaction", "curate"]),
176
313
  Object.freeze(["compaction", "dedupeThreshold"]),
177
314
  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"]),
315
+ // Loop contract 0.4 (Batch A) tool-iteration ceiling: quality (task
316
+ // completion) vs runaway-cost dial. NOTE ["security","egressPolicy"]
317
+ // was REMOVED here: the spec never grew that key (strict schemas
318
+ // reject it), and the OPTIMIZABLE_PATHS guard test requires every
319
+ // listed path to round-trip through parseSpec. Re-add it WITH the spec
320
+ // field when the egress-fabric FRs ship it.
321
+ Object.freeze(["limits", "max_tool_iterations"]),
322
+ // Pillar 3 intent gate — tunable so the eval-optimizer can find the
323
+ // sweet spot between false-positive denials and false-negative exfil
324
+ // bypasses.
182
325
  Object.freeze(["security", "justification"]),
183
326
  // §47 blockchain subsystem (slice 0). Whole-block replacement so the
184
327
  // optimizer can tune `chains[*].finality.count`, `chains[*].rpcPolicy`,
@@ -212,26 +355,63 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
212
355
  // the volatile tail block (token cost vs
213
356
  // plan/ledger completeness)
214
357
  // 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
358
+ // thredz.* (credentials; thredz.messaging = a destructive send-tool
359
+ // switch), memory.backend (store flip), memory.dream.every + .mode
360
+ // (side-effecting schedule / model-spend switch), continuity
217
361
  // .proof/.scope/.enabled (behavioral switches), compaction
218
362
  // .preserve_user_messages (safety), learning.sources (allowlist =
219
- // security).
363
+ // security). Batch G adds more of the same category: expose.* (G30 — the
364
+ // deployment/exposure surface, not a quality knob), plugins (G32 — a
365
+ // capability allowlist = supply-chain security, human-owned like
366
+ // learning.sources / the model roster), and sub_agents.*.federation (G31
367
+ // — cross-deployment wiring / trust boundary). None are optimizer-reachable.
220
368
  Object.freeze(["memory", "recallK"]),
221
369
  Object.freeze(["memory", "autoCaptureThreshold"]),
222
370
  Object.freeze(["memory", "ttl"]),
223
371
  Object.freeze(["memory", "wiki", "recallK"]),
224
372
  Object.freeze(["memory", "dream", "budget_usd"]),
225
373
  Object.freeze(["continuity", "focusMaxChars"]),
374
+ // Loop contract 0.4 (Batch B, G40) — the in-loop evaluation dials. The
375
+ // pass bar (llm_judge graders only — the spec rejects threshold on
376
+ // deterministic graders, so a mis-aimed patch fails the re-parse) and
377
+ // the retry cap are classic quality-vs-cost knobs. Deliberately NOT
378
+ // ["evaluation"] wholesale and NOT ["evaluation","grader"]: the grader
379
+ // (criteria/type) and on_fail behaviour are human-owned semantics —
380
+ // the optimizer tunes how strict the gate is, never what it judges.
381
+ Object.freeze(["evaluation", "threshold"]),
382
+ Object.freeze(["evaluation", "max_retries"]),
383
+ // Loop contract 0.4 (Batch E) — the agent-shape RAG (`knowledge:`) dials
384
+ // + the per-turn recall cadence. All scalars that survive lower() 1:1
385
+ // with their bounds owned by the spec schema, so an out-of-bounds patch
386
+ // fails applySpecPatch's re-parse. `default_k` (hits/turn) and the chunker
387
+ // window are classic recall-quality vs token-cost dials; `refreshEvery`
388
+ // trades recall freshness against per-turn recall cost. Deliberately NOT
389
+ // ["knowledge"] wholesale and NOT ["knowledge","sources"]: the corpus
390
+ // (an allowlist of what the agent may read) is human-owned, mirroring the
391
+ // learning.sources / model-roster exclusions.
392
+ Object.freeze(["knowledge", "default_k"]),
393
+ Object.freeze(["knowledge", "chunk", "size"]),
394
+ Object.freeze(["knowledge", "chunk", "overlap"]),
395
+ Object.freeze(["memory", "refreshEvery"]),
226
396
  ]),
227
397
  workflow: Object.freeze([
398
+ // Whole-step replacement — this ALSO reaches item 9's (G37) per-step
399
+ // model routing (model_pool policy/routing/learning, tiers, fallbacks):
400
+ // no narrower entry is possible (the roster/step index is positional) and
401
+ // whole-step replacement already spans model/instructions, so the policy
402
+ // knobs ride here rather than as standalone paths.
228
403
  Object.freeze(["steps"]),
229
404
  Object.freeze(["failure_taxonomy"]),
230
405
  Object.freeze(["chains"]),
231
406
  Object.freeze(["transaction_policy"]),
407
+ // Loop contract 0.4 (Batch A) — see the cli entry.
408
+ Object.freeze(["limits", "max_tool_iterations"]),
232
409
  ]) /* whole-step replacement allowed */,
233
410
  channel: Object.freeze([
234
411
  Object.freeze(["agent", "instructions"]),
412
+ // Loop contract 0.4 (Batch A) — see the cli entries.
413
+ Object.freeze(["agent", "thinking", "budget_tokens"]),
414
+ Object.freeze(["limits", "max_tool_iterations"]),
235
415
  Object.freeze(["failure_taxonomy"]),
236
416
  Object.freeze(["chains"]),
237
417
  Object.freeze(["transaction_policy"]),
@@ -246,15 +426,28 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
246
426
  Object.freeze(["memory", "wiki", "recallK"]),
247
427
  Object.freeze(["memory", "dream", "budget_usd"]),
248
428
  Object.freeze(["continuity", "focusMaxChars"]),
429
+ // Loop contract 0.4 (Batch B, G40) — evaluation dials; see the cli entry.
430
+ Object.freeze(["evaluation", "threshold"]),
431
+ Object.freeze(["evaluation", "max_retries"]),
432
+ // Loop contract 0.4 (Batch E) — knowledge/recall dials; see the cli entry.
433
+ Object.freeze(["knowledge", "default_k"]),
434
+ Object.freeze(["knowledge", "chunk", "size"]),
435
+ Object.freeze(["knowledge", "chunk", "overlap"]),
436
+ Object.freeze(["memory", "refreshEvery"]),
249
437
  ]),
250
438
  graph: Object.freeze([
251
439
  Object.freeze(["nodes"]),
252
440
  Object.freeze(["failure_taxonomy"]),
253
441
  Object.freeze(["chains"]),
254
442
  Object.freeze(["transaction_policy"]),
443
+ // Loop contract 0.4 (Batch A) — see the cli entry.
444
+ Object.freeze(["limits", "max_tool_iterations"]),
255
445
  ]),
256
446
  managed: Object.freeze([
257
447
  Object.freeze(["agent", "instructions"]),
448
+ // Loop contract 0.4 (Batch A) — see the cli entries.
449
+ Object.freeze(["agent", "thinking", "budget_tokens"]),
450
+ Object.freeze(["limits", "max_tool_iterations"]),
258
451
  Object.freeze(["failure_taxonomy"]),
259
452
  // Adaptive model routing — pool policy knobs only (see the cli entry).
260
453
  Object.freeze(["agent", "model_pool", "policy"]),
@@ -267,6 +460,14 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
267
460
  Object.freeze(["memory", "wiki", "recallK"]),
268
461
  Object.freeze(["memory", "dream", "budget_usd"]),
269
462
  Object.freeze(["continuity", "focusMaxChars"]),
463
+ // Loop contract 0.4 (Batch B, G40) — evaluation dials; see the cli entry.
464
+ Object.freeze(["evaluation", "threshold"]),
465
+ Object.freeze(["evaluation", "max_retries"]),
466
+ // Loop contract 0.4 (Batch E) — knowledge/recall dials; see the cli entry.
467
+ Object.freeze(["knowledge", "default_k"]),
468
+ Object.freeze(["knowledge", "chunk", "size"]),
469
+ Object.freeze(["knowledge", "chunk", "overlap"]),
470
+ Object.freeze(["memory", "refreshEvery"]),
270
471
  ]),
271
472
  pipeline: Object.freeze([
272
473
  Object.freeze(["agent", "instructions"]),
@@ -276,10 +477,17 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
276
477
  Object.freeze(["retrieve", "defaultK"]),
277
478
  ]),
278
479
  crew: Object.freeze([
480
+ // Whole-role replacement — this ALSO reaches item 9's (G37) per-role
481
+ // model routing (model_pool policy/routing/learning, tiers, fallbacks):
482
+ // the role name is a dynamic map key, so no static narrower path exists,
483
+ // and whole-role replacement already spans model/instructions, so the
484
+ // policy knobs ride here rather than as standalone paths.
279
485
  Object.freeze(["roles"]),
280
486
  Object.freeze(["failure_taxonomy"]),
281
487
  Object.freeze(["chains"]),
282
488
  Object.freeze(["transaction_policy"]),
489
+ // Loop contract 0.4 (Batch A) — see the cli entry.
490
+ Object.freeze(["limits", "max_tool_iterations"]),
283
491
  // 0.3.0 memory/continuity quality knobs — types/bounds at the cli entry.
284
492
  Object.freeze(["memory", "recallK"]),
285
493
  Object.freeze(["memory", "autoCaptureThreshold"]),
@@ -287,13 +495,20 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
287
495
  Object.freeze(["memory", "wiki", "recallK"]),
288
496
  Object.freeze(["memory", "dream", "budget_usd"]),
289
497
  Object.freeze(["continuity", "focusMaxChars"]),
498
+ // Loop contract 0.4 (Batch E) — per-turn recall cadence; see the cli entry.
499
+ Object.freeze(["memory", "refreshEvery"]),
290
500
  ]) /* whole-role replacement */,
291
501
  research: Object.freeze([
292
502
  Object.freeze(["agent", "instructions"]),
293
503
  Object.freeze(["failure_taxonomy"]),
294
- Object.freeze(["retrieve", "maxDepth"]),
504
+ // NOTE ["retrieve","maxDepth"] was REMOVED here (Batch A): the research
505
+ // retrieve block never grew a maxDepth field (strict schema rejects
506
+ // it), and the OPTIMIZABLE_PATHS guard test requires every listed path
507
+ // to round-trip through parseSpec. Re-add it WITH the spec field.
295
508
  Object.freeze(["chains"]),
296
509
  Object.freeze(["transaction_policy"]),
510
+ // Loop contract 0.4 (Batch A) — see the cli entry.
511
+ Object.freeze(["limits", "max_tool_iterations"]),
297
512
  // 0.3.0 memory/continuity quality knobs — types/bounds at the cli entry.
298
513
  Object.freeze(["memory", "recallK"]),
299
514
  Object.freeze(["memory", "autoCaptureThreshold"]),
@@ -301,12 +516,16 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
301
516
  Object.freeze(["memory", "wiki", "recallK"]),
302
517
  Object.freeze(["memory", "dream", "budget_usd"]),
303
518
  Object.freeze(["continuity", "focusMaxChars"]),
519
+ // Loop contract 0.4 (Batch E) — per-turn recall cadence; see the cli entry.
520
+ Object.freeze(["memory", "refreshEvery"]),
304
521
  ]),
305
522
  batch: Object.freeze([
306
523
  Object.freeze(["agent", "instructions"]),
307
524
  Object.freeze(["failure_taxonomy"]),
308
525
  Object.freeze(["chains"]),
309
526
  Object.freeze(["transaction_policy"]),
527
+ // Loop contract 0.4 (Batch A) — see the cli entry.
528
+ Object.freeze(["limits", "max_tool_iterations"]),
310
529
  ]),
311
530
  voice: Object.freeze([
312
531
  Object.freeze(["agent", "instructions"]),
@@ -315,6 +534,8 @@ export const OPTIMIZABLE_PATHS = Object.freeze({
315
534
  browser: Object.freeze([
316
535
  Object.freeze(["agent", "instructions"]),
317
536
  Object.freeze(["failure_taxonomy"]),
537
+ // Loop contract 0.4 (Batch A) — see the cli entry.
538
+ Object.freeze(["limits", "max_tool_iterations"]),
318
539
  ]),
319
540
  eval: Object.freeze([
320
541
  Object.freeze(["agent", "instructions"]),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/spec-patch",
3
- "version": "0.3.1",
3
+ "version": "0.4.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.3.1",
19
- "@crewhaus/spec": "0.3.1",
18
+ "@crewhaus/errors": "0.4.0",
19
+ "@crewhaus/spec": "0.4.0",
20
20
  "yaml": "^2.6.0",
21
21
  "zod": "^3.23.8"
22
22
  },