bareloop 0.4.0 → 0.5.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/src/interpret.js CHANGED
@@ -43,18 +43,38 @@ const PERSONA = 'You are a senior engineer. Reply with ONLY the complete content
43
43
  // compound: that run's context grew 2k → 121k tokens and its last round cost $0.25.
44
44
  // Telling the worker it will be re-run with the close's verdict makes an early,
45
45
  // cheap, wrong attempt the rational move — which is exactly what the loop wants.
46
- const PERSONA_TOOLS = 'You are a senior engineer working in a repository through file tools. '
46
+ export const PERSONA_TOOLS = 'You are a senior engineer working in a repository through file tools. '
47
47
  + 'ALWAYS use absolute paths — relative paths resolve against the process, not the repository, and will be denied. '
48
48
  + 'You are ONE attempt inside an automated loop: when you finish, a test suite runs and, if it still fails, you are called again with its output. '
49
49
  + 'So do not try to be certain before acting. Read only what you need to form your best hypothesis, make the change with the write tool, and stop. '
50
50
  + 'A wrong cheap attempt is corrected by the next round; exhaustive reading is not — every file you read is re-sent on every later round and the run has a hard budget it can exhaust before you ever write. '
51
51
  + 'Make the required changes with the write tool, then reply with a short summary of what you changed. Never put file contents in your reply.';
52
52
 
53
+ // The retrieval verbs only pay if the worker reaches for them INSTEAD of paging a file.
54
+ // F18 measured the failure they exist to fix: the worker read one 117 KB file NINE times
55
+ // and dragged 1.37 MB of source through context to reach an 8-line function. The tool
56
+ // descriptions carry the mechanics; the persona has to carry the STRATEGY, or a model
57
+ // with a familiar `read` and an unfamiliar `recall` will simply keep reading.
58
+ // (Module-level + exported since Layer 2: the plan-step worker shares the SAME
59
+ // personas/strategies — two spellings of the loop contract would drift, F16.)
60
+ export const RETRIEVAL_STRATEGY = '\nYou also have a repository index. To read a function, do NOT read its whole file: '
61
+ + 'call ctx_recall with the function name to get a pointer, then ctx_get with that pointer to read that function alone '
62
+ + '(it comes with its doc-comment, which states what the function is SUPPOSED to do — compare it against what the code does). '
63
+ + 'Reserve shell_read for whole files that are genuinely small, and for files you cannot name a symbol in yet. '
64
+ + 'Search only finds what you can NAME: a failing test names the symptom, not the cause, so read the failing test first, '
65
+ + 'see which function it calls, and recall THAT.';
66
+ // BA-13: like retrieval, the edit verb only pays if the worker reaches for it INSTEAD
67
+ // of a whole-file rewrite. F31 measured the default: 4 of 5 big-file whole-writes
68
+ // broke the tree, and every one was a rewrite to change ~one line. The tool
69
+ // description carries the mechanics; the persona carries the strategy.
70
+ export const EDIT_STRATEGY = '\nPrefer the edit tool over whole-file writes: quote the EXACT text to change (it must match exactly once) and its replacement. '
71
+ + 'Rewriting a whole file to change one line is how trees get broken and budgets get burned — reserve the write tool for genuinely new files.';
72
+
53
73
  // ---- tool mode (2b): the spec-side grant menu mapped to the underlying tools ----
54
74
  // read/grep/write are bare-agent's shell tools; recall/get are litectx's retrieval
55
75
  // verbs (F19), composed from the SAME LiteCtx the memory hooks already use.
56
- const TOOL_BY_VERB = Object.freeze({ read: 'shell_read', grep: 'shell_grep', write: 'shell_write', edit: 'shell_edit', recall: 'ctx_recall', get: 'ctx_get' });
57
- const CTX_TOOLS = Object.freeze(['ctx_recall', 'ctx_get']);
76
+ export const TOOL_BY_VERB = Object.freeze({ read: 'shell_read', grep: 'shell_grep', write: 'shell_write', edit: 'shell_edit', recall: 'ctx_recall', get: 'ctx_get' });
77
+ export const CTX_TOOLS = Object.freeze(['ctx_recall', 'ctx_get']);
58
78
 
59
79
  /**
60
80
  * The retrieval pair (F19). `shell_read` cannot seek — it starts at byte zero — so a
@@ -70,7 +90,7 @@ const CTX_TOOLS = Object.freeze(['ctx_recall', 'ctx_get']);
70
90
  * bad range) looks exactly like one that worked, and the worker's fallback to a
71
91
  * whole-file read looks like a free choice instead of a forced one.
72
92
  */
73
- function createCtxTools(lc, workdir, emit) {
93
+ export function createCtxTools(lc, workdir, emit) {
74
94
  return [
75
95
  {
76
96
  name: 'ctx_recall',
@@ -148,7 +168,7 @@ function createCtxTools(lc, workdir, emit) {
148
168
  /** @param {string} p */
149
169
  const expandHome = (p) => (p === '~' || p.startsWith('~/')) ? join(homedir(), p.slice(1)) : p;
150
170
  /** @param {string} name @param {any} args @param {string} [workdir] */
151
- const toolAction = (name, args, workdir) => {
171
+ export const toolAction = (name, args, workdir) => {
152
172
  if (name === 'shell_write') return { type: 'write', path: resolve(expandHome(String(args?.path ?? ''))), args: { bytes: String(args?.content ?? '').length } };
153
173
  // shell_edit (BA-13) is judged as bareguard's own 'edit' action type — the SAME
154
174
  // fs.writeScope fence as write, decided upstream (bareguard FS_TYPES). `bytes` is
@@ -558,23 +578,6 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
558
578
  })()
559
579
  : [];
560
580
 
561
- // The retrieval verbs only pay if the worker reaches for them INSTEAD of paging a file.
562
- // F18 measured the failure they exist to fix: the worker read one 117 KB file NINE times
563
- // and dragged 1.37 MB of source through context to reach an 8-line function. The tool
564
- // descriptions carry the mechanics; the persona has to carry the STRATEGY, or a model
565
- // with a familiar `read` and an unfamiliar `recall` will simply keep reading.
566
- const RETRIEVAL_STRATEGY = '\nYou also have a repository index. To read a function, do NOT read its whole file: '
567
- + 'call ctx_recall with the function name to get a pointer, then ctx_get with that pointer to read that function alone '
568
- + '(it comes with its doc-comment, which states what the function is SUPPOSED to do — compare it against what the code does). '
569
- + 'Reserve shell_read for whole files that are genuinely small, and for files you cannot name a symbol in yet. '
570
- + 'Search only finds what you can NAME: a failing test names the symptom, not the cause, so read the failing test first, '
571
- + 'see which function it calls, and recall THAT.';
572
- // BA-13: like retrieval, the edit verb only pays if the worker reaches for it INSTEAD
573
- // of a whole-file rewrite. F31 measured the default: 4 of 5 big-file whole-writes
574
- // broke the tree, and every one was a rewrite to change ~one line. The tool
575
- // description carries the mechanics; the persona carries the strategy.
576
- const EDIT_STRATEGY = '\nPrefer the edit tool over whole-file writes: quote the EXACT text to change (it must match exactly once) and its replacement. '
577
- + 'Rewriting a whole file to change one line is how trees get broken and budgets get burned — reserve the write tool for genuinely new files.';
578
581
  const usesCtx = toolDefs.some((/** @type {{name: string}} */ t) => CTX_TOOLS.includes(t.name));
579
582
  const usesEdit = toolDefs.some((/** @type {{name: string}} */ t) => t.name === 'shell_edit');
580
583
  const loop = new Loop({
package/src/job.js CHANGED
@@ -51,13 +51,33 @@ export const TOOL_MENU = Object.freeze(['read', 'grep', 'write', 'edit', 'recall
51
51
  * menu. Requesting one is a DISTINCT `request-red` (module 4) — the ledger
52
52
  * counts admission demand, and a generic invalid-value would bury it as a typo. */
53
53
  export const LOCKED_TOOLS = Object.freeze(['run']);
54
+ /** Layer 2 (design record 2026-07-21, decision 5): the verdict-type radio of
55
+ * the four-field plan shape. v1 ADMITS only `green`; soft-green/hitl are
56
+ * declared-but-locked — declaring one is a `request-red` (the tool-menu
57
+ * pattern: disclosure ≠ admission), so the ledger counts the demand and the
58
+ * non-code rung later fills a declared slot instead of reshaping the schema. */
59
+ export const VERDICT_TYPES = Object.freeze(['green', 'soft-green', 'hitl']);
60
+ export const LOCKED_VERDICTS = Object.freeze(['soft-green', 'hitl']);
61
+ /** which close CLASS a declared verdictType demands — the close hierarchy one
62
+ * level up: the same laundering guard as CLASS_BY_CLOSE, applied to the plan
63
+ * shape's single close (green on a rubric would be a fake-hard verdict). */
64
+ const CLASS_BY_VERDICT = Object.freeze({ green: 'hard', 'soft-green': 'soft', hitl: 'hitl' });
54
65
  export const CADENCE_UNITS = Object.freeze(['hour', 'day', 'week']);
55
- export const PROVIDERS = Object.freeze(['anthropic-api']); // SP-2: API-first; local deferred (PRD §5/§8)
66
+ /** SP-2: API-first; local deferred (PRD §5/§8). `clipipe-subscription` (F42,
67
+ * bare-agent 0.32.0 tool mode): the CLI as a bare turn-provider on a Claude
68
+ * subscription — its costUsd axis is NOTIONAL (API-equivalent value, not
69
+ * billed), so it is a DISTINCT condition: rows never pool with anthropic-api
70
+ * rows on the cost axis, and budgets sized for one do not transfer. */
71
+ export const PROVIDERS = Object.freeze(['anthropic-api', 'clipipe-subscription']);
56
72
  /** V3 environment label: declared keys only — every field is a lineage-key
57
73
  * candidate at N3. `provider` is part of the key by definition (top-level,
58
74
  * not duplicated here). */
59
75
  export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', 'taskFraming', 'scaffold']);
60
- const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'conditions', 'cadence', 'budgetUsd', 'writeScope', 'steps', 'escalation'];
76
+ const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'conditions', 'cadence', 'budgetUsd', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'checks', 'tools'];
77
+ /** the four-field plan shape's core (decision 5) — presence of any of these
78
+ * declares the shape; `tools` (the ceiling) rides the shape but alone does not
79
+ * declare it, so a legacy spec carrying it gets a pointed red, not a conflict */
80
+ const PLAN_CORE_FIELDS = ['goal', 'verdictType', 'close', 'checks'];
61
81
  /** exact field set per close type — anything else is an unknown-field red
62
82
  * (freeform code, script bodies, and minting claims all land there) */
63
83
  const CLOSE_FIELDS = {
@@ -66,6 +86,10 @@ const CLOSE_FIELDS = {
66
86
  rubric: ['type', 'criteria'],
67
87
  hitl: ['type', 'prompt'],
68
88
  };
89
+ /** a named check (decision 1) IS the predicate-close body plus a name — same
90
+ * fields, same validator, same runClose machinery (a second command shape
91
+ * would be the F9 two-transforms class one level up) */
92
+ const CHECK_FIELDS = ['name', 'cmd', 'expect', 'judged', 'gapKeep'];
69
93
  const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
70
94
 
71
95
  /** @typedef {{code: string, path: string, detail?: string, verb?: string}} Red — `verb` rides request-reds as structured data (the ledger keys on it, never on prose) */
@@ -157,8 +181,22 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
157
181
  if (esc.mode !== 'decision-ready') red('invalid-value', 'escalation.mode', 'must be "decision-ready"');
158
182
  }
159
183
 
160
- // 4. steps + the close chain
161
- if (!Array.isArray(spec.steps) || spec.steps.length === 0) {
184
+ // 4. the two shapes (design record 2026-07-21, decisions 5+6): EITHER the
185
+ // legacy operator-authored steps[] chain (co-existing scaffolding, staged
186
+ // sunset — archives alongside config-v1 when the Layer 2 path proves itself)
187
+ // OR the four-field plan shape (goal/verdictType/close/checks[]) where the
188
+ // AGENT authors the step plan at run time. Never both: a spec carrying both
189
+ // would let one shape ride the other as a smuggle channel.
190
+ const hasSteps = spec.steps !== undefined;
191
+ const hasPlanCore = PLAN_CORE_FIELDS.some((f) => spec[f] !== undefined);
192
+ if (hasSteps && hasPlanCore) {
193
+ red('shape-conflict', 'steps', 'EITHER operator-authored steps[] OR the plan shape (goal/verdictType/close/checks[]) — never both (design record 2026-07-21, decision 6)');
194
+ } else if (!hasSteps) {
195
+ if (hasPlanCore || spec.tools !== undefined) validatePlanShape(spec, red, reds);
196
+ else red('missing-required', 'steps', 'one shape is required: operator-authored steps[] (legacy, staged sunset) or the plan shape goal/verdictType/close/checks[]');
197
+ } else if (spec.tools !== undefined) {
198
+ red('invalid-value', 'tools', 'the tool ceiling is a plan-shape field — a legacy steps[] spec grants tools per step');
199
+ } else if (!Array.isArray(spec.steps) || spec.steps.length === 0) {
162
200
  red('missing-required', 'steps', 'non-empty array — a job without closes is ungated spend');
163
201
  } else {
164
202
  const seen = new Set();
@@ -203,90 +241,7 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
203
241
 
204
242
  if (!isObj(s.close)) { red('missing-required', `${at}.close`, 'every step names its close — a step without one is ungated spend'); return; }
205
243
  const close = s.close;
206
- if (!CLOSE_TYPES.includes(close.type)) {
207
- red('close-type', `${at}.close.type`, `menu: ${CLOSE_TYPES.join('|')} — a close is data, never code`);
208
- return;
209
- }
210
- // per-type contracts — fixed menus only
211
- if (close.type === 'predicate') {
212
- if (!isNonEmptyString(close.cmd)) red('missing-required', `${at}.close.cmd`);
213
- // the runner executes cmd as whitespace-split argv with NO shell: quote
214
- // characters imply shell semantics the split cannot honor — honest
215
- // refusal beats silent misparse (N2 design default)
216
- else if (/["']/.test(close.cmd)) red('invalid-value', `${at}.close.cmd`, 'quote characters are inexpressible: cmd runs as whitespace-split argv, no shell');
217
- else if (close.cmd !== close.cmd.trim()) red('invalid-value', `${at}.close.cmd`, 'leading/trailing whitespace — argv splits on whitespace and an empty argv[0] cannot spawn; honest refusal beats a silent misparse');
218
- if (!Number.isInteger(close.expect)) red('invalid-value', `${at}.close.expect`, 'integer exit code');
219
- // The judgment-rendered signal (PRD v1.11, optional). Exit code alone
220
- // cannot separate "the suite ran and failed" from "the suite crashed at
221
- // load" — they are byte-identical at the seam, and against `node --test`
222
- // so are the raw counts (a crashed file is reported as ONE failing test).
223
- // So the signal is a FLOOR, not a zero-check: a close declares how many
224
- // things it must judge before its exit code means anything at all.
225
- if (close.judged !== undefined) {
226
- const j = close.judged;
227
- if (!isObj(j)) red('invalid-value', `${at}.close.judged`, 'object {pattern, min} — proof the close actually judged something');
228
- else {
229
- for (const key of Object.keys(j)) {
230
- if (!['pattern', 'min'].includes(key)) red('unknown-field', `${at}.close.judged.${key}`);
231
- }
232
- if (!isNonEmptyString(j.pattern)) red('missing-required', `${at}.close.judged.pattern`, 'regex over the close output with ONE capture group yielding the count');
233
- else {
234
- let groups = 0;
235
- try {
236
- // `p|` always matches the empty string, so exec() returns an array
237
- // whose length is 1 + the number of capture groups — the only way
238
- // to count groups without executing the pattern against real output.
239
- groups = (new RegExp(`${j.pattern}|`).exec('')?.length ?? 1) - 1;
240
- } catch {
241
- red('invalid-value', `${at}.close.judged.pattern`, 'must compile as a RegExp');
242
- groups = -1;
243
- }
244
- // A pattern with no capture group extracts nothing, so EVERY close
245
- // would read as crashed — a dead arbiter that reds forever. Red the
246
- // spec, not every run.
247
- if (groups === 0) red('invalid-value', `${at}.close.judged.pattern`, 'no capture group — the count is read from group 1, so this pattern would crash every close');
248
- // More than one is the same defect wearing a subtler hat: runClose
249
- // reads group 1 ONLY, so an alternation whose other branch carries
250
- // the count leaves group 1 undefined → NaN → judgedCount null →
251
- // an exit-0 GREEN stamped 'crashed' (a fake crash, the mirror of
252
- // the fake green this floor exists to catch). Alternation stays
253
- // fully expressible with non-capturing branches: `(?:a|b) (\d+)`.
254
- else if (groups > 1) red('invalid-value', `${at}.close.judged.pattern`, `${groups} capture groups — the count is read from group 1 only; use exactly one capture group and non-capturing (?:…) branches`);
255
- }
256
- if (!Number.isInteger(j.min) || j.min < 1) {
257
- red('invalid-value', `${at}.close.judged.min`, 'integer >= 1 — a floor of 0 is satisfied by judging nothing, which is the check it is meant to make');
258
- }
259
- }
260
- }
261
- // The kept-failures pattern (F28, optional). ralph's gap bound head/tail-
262
- // elides a large close stream, and a big TAP suite prints its `not ok`
263
- // lines in the MIDDLE — so the failing-test NAMES (the causal navigation
264
- // input the worker runs on) were deleted in transit and the worker was
265
- // told "5 fail" and never WHICH. gapKeep is a regex SOURCE whose matching
266
- // lines are PRESERVED in the gap in addition to head+tail. Same discipline
267
- // as judged.pattern: a non-empty string that must compile as a RegExp, or
268
- // it is a spec red before any tokens burn (reds-before-tokens) — an invalid
269
- // keep pattern must never surface as a runtime crash inside the arbiter.
270
- if (close.gapKeep !== undefined) {
271
- if (!isNonEmptyString(close.gapKeep)) red('invalid-value', `${at}.close.gapKeep`, 'regex source string — lines matching it survive the gap bound (F28)');
272
- else {
273
- try { new RegExp(close.gapKeep, 'm'); }
274
- catch { red('invalid-value', `${at}.close.gapKeep`, 'must compile as a RegExp'); }
275
- }
276
- }
277
- } else if (close.type === 'gold') {
278
- if (close.expected === undefined) red('missing-required', `${at}.close.expected`);
279
- if (!GOLD_COMPARE.includes(close.compare)) red('invalid-value', `${at}.close.compare`, GOLD_COMPARE.join('|'));
280
- } else if (close.type === 'rubric') {
281
- if (!isNonEmptyString(close.criteria)) red('missing-required', `${at}.close.criteria`);
282
- } else if (close.type === 'hitl') {
283
- if (!isNonEmptyString(close.prompt)) red('missing-required', `${at}.close.prompt`);
284
- }
285
- for (const key of Object.keys(close)) {
286
- if (!CLOSE_FIELDS[close.type].includes(key)) {
287
- red('unknown-field', `${at}.close.${key}`, `not a ${close.type} field (script bodies and minting claims land here)`);
288
- }
289
- }
244
+ if (!validateClose(close, `${at}.close`, red)) return;
290
245
  // the hierarchy: one check covers both directions — a class outside the
291
246
  // close type's menu is laundering (rubric-as-hard) or delegation
292
247
  // (hitl-class on a script close); hitl ⇔ hitl falls out of the menus.
@@ -304,6 +259,194 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
304
259
  return { ok: reds.length === 0, reds, job: reds.length === 0 ? spec : null };
305
260
  }
306
261
 
262
+ /**
263
+ * Per-type close contract — fixed menus only. ONE home for both call sites
264
+ * (a legacy step's close, the plan shape's top-level close): two copies would
265
+ * be the F9 two-transforms class on the arbiter's own contract.
266
+ * @param {Record<string, any>} close
267
+ * @param {string} at path prefix of the close object (e.g. `steps.0.close`, `close`)
268
+ * @param {(code: string, path: string, detail?: string) => void} red
269
+ * @returns {boolean} type-in-menu — false means per-type checks were unreachable
270
+ */
271
+ function validateClose(close, at, red) {
272
+ if (!CLOSE_TYPES.includes(close.type)) {
273
+ red('close-type', `${at}.type`, `menu: ${CLOSE_TYPES.join('|')} — a close is data, never code`);
274
+ return false;
275
+ }
276
+ if (close.type === 'predicate') {
277
+ predicateBody(close, at, red);
278
+ } else if (close.type === 'gold') {
279
+ if (close.expected === undefined) red('missing-required', `${at}.expected`);
280
+ if (!GOLD_COMPARE.includes(close.compare)) red('invalid-value', `${at}.compare`, GOLD_COMPARE.join('|'));
281
+ } else if (close.type === 'rubric') {
282
+ if (!isNonEmptyString(close.criteria)) red('missing-required', `${at}.criteria`);
283
+ } else if (close.type === 'hitl') {
284
+ if (!isNonEmptyString(close.prompt)) red('missing-required', `${at}.prompt`);
285
+ }
286
+ for (const key of Object.keys(close)) {
287
+ if (!CLOSE_FIELDS[close.type].includes(key)) {
288
+ red('unknown-field', `${at}.${key}`, `not a ${close.type} field (script bodies and minting claims land here)`);
289
+ }
290
+ }
291
+ return true;
292
+ }
293
+
294
+ /**
295
+ * The predicate BODY contract — cmd/expect/judged/gapKeep. Shared verbatim by
296
+ * the predicate close and the Layer 2 named checks (decision 1: a check runs
297
+ * under the same runClose machinery, so it validates under the same rules).
298
+ * @param {Record<string, any>} o the close or check carrying the body
299
+ * @param {string} at path prefix (e.g. `steps.0.close`, `close`, `checks.1`)
300
+ * @param {(code: string, path: string, detail?: string) => void} red
301
+ */
302
+ function predicateBody(o, at, red) {
303
+ if (!isNonEmptyString(o.cmd)) red('missing-required', `${at}.cmd`);
304
+ // the runner executes cmd as whitespace-split argv with NO shell: quote
305
+ // characters imply shell semantics the split cannot honor — honest
306
+ // refusal beats silent misparse (N2 design default)
307
+ else if (/["']/.test(o.cmd)) red('invalid-value', `${at}.cmd`, 'quote characters are inexpressible: cmd runs as whitespace-split argv, no shell');
308
+ else if (o.cmd !== o.cmd.trim()) red('invalid-value', `${at}.cmd`, 'leading/trailing whitespace — argv splits on whitespace and an empty argv[0] cannot spawn; honest refusal beats a silent misparse');
309
+ if (!Number.isInteger(o.expect)) red('invalid-value', `${at}.expect`, 'integer exit code');
310
+ // The judgment-rendered signal (PRD v1.11, optional). Exit code alone
311
+ // cannot separate "the suite ran and failed" from "the suite crashed at
312
+ // load" — they are byte-identical at the seam, and against `node --test`
313
+ // so are the raw counts (a crashed file is reported as ONE failing test).
314
+ // So the signal is a FLOOR, not a zero-check: a close declares how many
315
+ // things it must judge before its exit code means anything at all.
316
+ if (o.judged !== undefined) {
317
+ const j = o.judged;
318
+ if (!isObj(j)) red('invalid-value', `${at}.judged`, 'object {pattern, min} — proof the close actually judged something');
319
+ else {
320
+ for (const key of Object.keys(j)) {
321
+ if (!['pattern', 'min'].includes(key)) red('unknown-field', `${at}.judged.${key}`);
322
+ }
323
+ if (!isNonEmptyString(j.pattern)) red('missing-required', `${at}.judged.pattern`, 'regex over the close output with ONE capture group yielding the count');
324
+ else {
325
+ let groups = 0;
326
+ try {
327
+ // `p|` always matches the empty string, so exec() returns an array
328
+ // whose length is 1 + the number of capture groups — the only way
329
+ // to count groups without executing the pattern against real output.
330
+ groups = (new RegExp(`${j.pattern}|`).exec('')?.length ?? 1) - 1;
331
+ } catch {
332
+ red('invalid-value', `${at}.judged.pattern`, 'must compile as a RegExp');
333
+ groups = -1;
334
+ }
335
+ // A pattern with no capture group extracts nothing, so EVERY close
336
+ // would read as crashed — a dead arbiter that reds forever. Red the
337
+ // spec, not every run.
338
+ if (groups === 0) red('invalid-value', `${at}.judged.pattern`, 'no capture group — the count is read from group 1, so this pattern would crash every close');
339
+ // More than one is the same defect wearing a subtler hat: runClose
340
+ // reads group 1 ONLY, so an alternation whose other branch carries
341
+ // the count leaves group 1 undefined → NaN → judgedCount null →
342
+ // an exit-0 GREEN stamped 'crashed' (a fake crash, the mirror of
343
+ // the fake green this floor exists to catch). Alternation stays
344
+ // fully expressible with non-capturing branches: `(?:a|b) (\d+)`.
345
+ else if (groups > 1) red('invalid-value', `${at}.judged.pattern`, `${groups} capture groups — the count is read from group 1 only; use exactly one capture group and non-capturing (?:…) branches`);
346
+ }
347
+ if (!Number.isInteger(j.min) || j.min < 1) {
348
+ red('invalid-value', `${at}.judged.min`, 'integer >= 1 — a floor of 0 is satisfied by judging nothing, which is the check it is meant to make');
349
+ }
350
+ }
351
+ }
352
+ // The kept-failures pattern (F28, optional). ralph's gap bound head/tail-
353
+ // elides a large close stream, and a big TAP suite prints its `not ok`
354
+ // lines in the MIDDLE — so the failing-test NAMES (the causal navigation
355
+ // input the worker runs on) were deleted in transit and the worker was
356
+ // told "5 fail" and never WHICH. gapKeep is a regex SOURCE whose matching
357
+ // lines are PRESERVED in the gap in addition to head+tail. Same discipline
358
+ // as judged.pattern: a non-empty string that must compile as a RegExp, or
359
+ // it is a spec red before any tokens burn (reds-before-tokens) — an invalid
360
+ // keep pattern must never surface as a runtime crash inside the arbiter.
361
+ if (o.gapKeep !== undefined) {
362
+ if (!isNonEmptyString(o.gapKeep)) red('invalid-value', `${at}.gapKeep`, 'regex source string — lines matching it survive the gap bound (F28)');
363
+ else {
364
+ try { new RegExp(o.gapKeep, 'm'); }
365
+ catch { red('invalid-value', `${at}.gapKeep`, 'must compile as a RegExp'); }
366
+ }
367
+ }
368
+ }
369
+
370
+ /**
371
+ * The four-field plan shape (design record 2026-07-21, decision 5): goal /
372
+ * verdictType / close / checks[] (+ the top-level tool ceiling). v1 admits
373
+ * only verdictType `green`; soft-green/hitl are declared-but-locked and red
374
+ * as `request-red` — admission demand the ledger counts, never a grant.
375
+ * @param {Record<string, any>} spec
376
+ * @param {(code: string, path: string, detail?: string) => void} red
377
+ * @param {Red[]} reds direct access for structured request-reds
378
+ */
379
+ function validatePlanShape(spec, red, reds) {
380
+ if (spec.goal === undefined) red('missing-required', 'goal');
381
+ else if (!isNonEmptyString(spec.goal)) red('invalid-value', 'goal', 'non-empty text — the goal is what the agent plans against');
382
+
383
+ /** @type {string|undefined} the close class the declared verdict demands */
384
+ let demanded;
385
+ if (spec.verdictType === undefined) red('missing-required', 'verdictType', `declared radio, never inferred: ${VERDICT_TYPES.join('|')}`);
386
+ else if (LOCKED_VERDICTS.includes(spec.verdictType)) {
387
+ // declared-but-locked (disclosure ≠ admission, the tool-menu pattern):
388
+ // the type rides as a structured field — the ledger keys admission demand
389
+ // on it, never on prose
390
+ reds.push({ code: 'request-red', path: 'verdictType', verb: spec.verdictType, detail: `"${spec.verdictType}" is declared-but-locked — not at this rung (v1 admits green only); this red IS the admission evidence, never a grant` });
391
+ demanded = CLASS_BY_VERDICT[spec.verdictType];
392
+ } else if (!VERDICT_TYPES.includes(spec.verdictType)) {
393
+ red('invalid-value', 'verdictType', `menu: ${VERDICT_TYPES.join('|')} — an unknown type is a typo, never a request`);
394
+ } else {
395
+ demanded = CLASS_BY_VERDICT[spec.verdictType];
396
+ }
397
+
398
+ if (spec.close === undefined) red('missing-required', 'close', 'declared green with nothing to run it — preflight validates the declaration, never infers');
399
+ else if (!isObj(spec.close)) red('invalid-value', 'close', 'a close object (type + its fixed fields)');
400
+ else if (validateClose(spec.close, 'close', red) && demanded !== undefined
401
+ && !CLASS_BY_CLOSE[spec.close.type].includes(demanded)) {
402
+ // the hierarchy one level up: the declared verdict demands a close class
403
+ // its close type cannot legally claim (green-on-rubric is laundering)
404
+ red('close-hierarchy', 'verdictType', `verdictType ${spec.verdictType} demands a ${demanded}-class close; ${spec.close.type} admits ${CLASS_BY_CLOSE[spec.close.type].join('|')} only (a rubric can never be hard — PRD §7)`);
405
+ }
406
+
407
+ if (spec.checks !== undefined) {
408
+ if (!Array.isArray(spec.checks) || spec.checks.length === 0) {
409
+ red('invalid-value', 'checks', 'non-empty array of named checks — omit the field when the job signs none');
410
+ } else {
411
+ const seen = new Set();
412
+ spec.checks.forEach((/** @type {any} */ c, /** @type {number} */ i) => {
413
+ const at = `checks.${i}`;
414
+ if (!isObj(c)) { red('invalid-value', at, 'a check must be an object'); return; }
415
+ for (const key of Object.keys(c)) {
416
+ if (!CHECK_FIELDS.includes(key)) red('unknown-field', `${at}.${key}`, 'not a check field (script bodies land here)');
417
+ }
418
+ if (!isNonEmptyString(c.name) || !SLUG_RE.test(c.name)) red('invalid-value', `${at}.name`, 'kebab-case slug — the plan references checks by name (check-passes(name))');
419
+ else if (seen.has(c.name)) red('duplicate-id', `${at}.name`, c.name);
420
+ else seen.add(c.name);
421
+ predicateBody(c, at, red);
422
+ });
423
+ }
424
+ }
425
+
426
+ // the tool CEILING (plan-v1 anchor: every plan step's verbs ⊆ this) — same
427
+ // grant rules as a step's tools; run stays locked-but-listed (request-red)
428
+ if (spec.tools !== undefined) {
429
+ if (!(Array.isArray(spec.tools) && spec.tools.length > 0
430
+ && spec.tools.every((/** @type {unknown} */ t) => typeof t === 'string')
431
+ && new Set(spec.tools).size === spec.tools.length)) {
432
+ red('invalid-value', 'tools', `non-empty unique subset of ${TOOL_MENU.join('|')}`);
433
+ } else {
434
+ for (const t of spec.tools.filter((/** @type {string} */ t) => LOCKED_TOOLS.includes(t))) {
435
+ reds.push({ code: 'request-red', path: 'tools', verb: t, detail: `"${t}" is locked-but-listed — this red IS the admission evidence, never a grant; granted menu: ${TOOL_MENU.join('|')}` });
436
+ }
437
+ const unknown = spec.tools.filter((/** @type {string} */ t) => !TOOL_MENU.includes(t) && !LOCKED_TOOLS.includes(t));
438
+ if (unknown.length) red('invalid-value', 'tools', `unknown tool(s) ${unknown.join(', ')} — menu: ${TOOL_MENU.join('|')}`);
439
+ // a plan-shape ceiling must grant ≥1 READ-capable verb (review #8): the
440
+ // scout surveys read-only (the write-class verbs are filtered out of its
441
+ // menu), so a write-only ceiling hands the scout an EMPTY menu and it
442
+ // surveys blind — a degradation the validator catches at sign time.
443
+ else if (!spec.tools.some((/** @type {string} */ t) => TOOL_MENU.includes(t) && !['write', 'edit'].includes(t))) {
444
+ red('invalid-value', 'tools', 'a plan-shape ceiling must grant ≥1 read-capable verb (read/grep/recall/get) — the scout surveys read-only; a write-only ceiling leaves it blind');
445
+ }
446
+ }
447
+ }
448
+ }
449
+
307
450
  /**
308
451
  * Canonical JSON — recursive key sort, so the hash binds to CONTENT, not to
309
452
  * the accident of key order a drafting UX or an editor produced.