bareloop 0.3.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/CHANGELOG.md +194 -0
- package/README.md +12 -5
- package/bareloop.context.md +167 -16
- package/package.json +2 -2
- package/src/exits.js +163 -0
- package/src/index.js +7 -1
- package/src/interpret.js +192 -25
- package/src/job.js +231 -88
- package/src/plan.js +237 -0
- package/src/planrun.js +677 -0
- package/src/ralph.js +25 -7
- package/src/root.js +305 -0
- package/src/run.js +88 -22
- package/types/exits.d.ts +51 -0
- package/types/index.d.ts +4 -1
- package/types/interpret.d.ts +107 -1
- package/types/job.d.ts +12 -0
- package/types/plan.d.ts +41 -0
- package/types/planrun.d.ts +49 -0
- package/types/ralph.d.ts +21 -3
- package/types/root.d.ts +96 -0
- package/types/run.d.ts +26 -3
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
|
-
|
|
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.
|
|
161
|
-
|
|
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 (!
|
|
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.
|
package/src/plan.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// plan-v1 validator — the AGENT-authored half of the two-doc story (Layer 2,
|
|
2
|
+
// design record 2026-07-21; PRD v1.12). The plan is the ONLY document the
|
|
3
|
+
// emergent middle authors: an ordered list of bounded steps, each with a tool
|
|
4
|
+
// grant, a rounds bound, a deliverable target, and a form-check exit. This
|
|
5
|
+
// validator gates it before tokens burn, against the SIGNED job spec:
|
|
6
|
+
//
|
|
7
|
+
// verbs ⊆ the spec's tool ceiling → `verb-escape` (the ceiling
|
|
8
|
+
// exists and the plan overreached — distinct from the operator-side
|
|
9
|
+
// `request-red`, which is admission demand against the product menu)
|
|
10
|
+
// bounds ≤ the shell caps → `bounds`
|
|
11
|
+
// scopes/targets inside the signed fence → `scope-escape` / `invalid-value`
|
|
12
|
+
// exits from the closed menu only → `exit-illegal` (arbiter
|
|
13
|
+
// inexpressibility: an exit is declarative data the shell evaluates with
|
|
14
|
+
// its own fixed code — `run` cannot be laundered through it)
|
|
15
|
+
// check references resolve against the SIGNED checks menu → `check-unknown`
|
|
16
|
+
// (decision 1: a check the spec doesn't sign does not exist)
|
|
17
|
+
//
|
|
18
|
+
// Like its siblings (validate.js, job.js) it never throws on JSON text or
|
|
19
|
+
// plain parsed data; every failure is a named {code, path, detail} red.
|
|
20
|
+
|
|
21
|
+
import { TOOL_MENU, LOCKED_TOOLS } from './job.js';
|
|
22
|
+
import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals } from './validate.js';
|
|
23
|
+
|
|
24
|
+
/** the closed exit menu (PRD v1.12 §3 + decision 1's `check-passes`): the
|
|
25
|
+
* shell evaluates every form with its own fixed code, never a command — the
|
|
26
|
+
* same both-directions inexpressibility guard as the two-validator split */
|
|
27
|
+
export const EXIT_TYPES = Object.freeze(['artifact-written', 'tree-changed', 'json-valid', 'check-passes']);
|
|
28
|
+
/** decision 8: AND-only conjunction, bounded — no OR (a weak arm would
|
|
29
|
+
* launder the exit), no NOT */
|
|
30
|
+
export const MAX_EXITS_PER_STEP = 2;
|
|
31
|
+
/** plan size bound — per-step rounds bound the spend, but an unbounded step
|
|
32
|
+
* COUNT is still an unbounded claim on the wallet; same 8 as loop.maxIterations */
|
|
33
|
+
export const MAX_PLAN_STEPS = 8;
|
|
34
|
+
/** the write-class verbs (BA-13: `edit` is judged by the same fence as
|
|
35
|
+
* `write`) — a step granting one is a WRITE step for the F17 pairing rule
|
|
36
|
+
* and the v1.18 target requirement */
|
|
37
|
+
export const WRITE_VERBS = Object.freeze(['write', 'edit']);
|
|
38
|
+
const PLAN_FIELDS = ['schema', 'steps'];
|
|
39
|
+
// decision 7: NO dependsOn — v1 is strictly sequential, array order IS the
|
|
40
|
+
// order; a field nothing consumes is a live-looking knob with zero effect
|
|
41
|
+
// (the F16 inert-op class). The arbiter's own vocabulary (close, budget,
|
|
42
|
+
// fence) is absent by construction — unknown-field at every depth.
|
|
43
|
+
const STEP_FIELDS = ['id', 'action', 'tools', 'rounds', 'target', 'exit'];
|
|
44
|
+
const EXIT_FIELDS = {
|
|
45
|
+
'artifact-written': ['type', 'path', 'pattern'],
|
|
46
|
+
'tree-changed': ['type', 'scope'],
|
|
47
|
+
'json-valid': ['type', 'path'],
|
|
48
|
+
'check-passes': ['type', 'name'],
|
|
49
|
+
};
|
|
50
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
51
|
+
|
|
52
|
+
/** @typedef {{code: string, path: string, detail?: string, verb?: string}} Red */
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate an agent-authored plan (`schema: "plan-v1"`) against the SIGNED
|
|
56
|
+
* job spec. Never throws on JSON text or plain parsed data; every failure is
|
|
57
|
+
* a named red. Returns the parsed plan on ok (single parse), null on any red.
|
|
58
|
+
* @param {object|string} input parsed plan, or raw JSON text (parse failures are a red)
|
|
59
|
+
* @param {{ job?: any, maxStepRounds?: number }} [opts] `job`: the
|
|
60
|
+
* validateJob-GREEN four-field spec (the ceiling, the fence, and the checks
|
|
61
|
+
* menu all come from it — a missing or non-plan-shape job fails CLOSED);
|
|
62
|
+
* `maxStepRounds`: the shell's per-step rounds ceiling (interpret's
|
|
63
|
+
* tool-mode TURNS_PER_ATTEMPT) — a step may tighten it, never exceed it.
|
|
64
|
+
* @returns {{ ok: boolean, reds: Red[], plan: object|null }}
|
|
65
|
+
*/
|
|
66
|
+
export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
|
|
67
|
+
/** @type {Red[]} */
|
|
68
|
+
const reds = [];
|
|
69
|
+
/** @type {(code: string, path: string, detail?: string) => void} */
|
|
70
|
+
const red = (code, path, detail) => { reds.push(detail ? { code, path, detail } : { code, path }); };
|
|
71
|
+
|
|
72
|
+
// The signed side, fail-CLOSED (the validate.js fence-invalid pattern): the
|
|
73
|
+
// ceiling/fence/menu are meaningless without a plan-shape spec, and an open
|
|
74
|
+
// gate on a malformed one would validate a plan against nothing.
|
|
75
|
+
if (!isObj(job) || !isNonEmptyString(/** @type {any} */ (job).goal) || !Array.isArray(/** @type {any} */ (job).writeScope)) {
|
|
76
|
+
return { ok: false, reds: [{ code: 'job-invalid', path: 'job', detail: 'a plan validates only against a validateJob-green plan shape spec (goal/verdictType/close/checks[]) — validate the job first' }], plan: null };
|
|
77
|
+
}
|
|
78
|
+
const spec = /** @type {Record<string, any>} */ (job);
|
|
79
|
+
/** the signed tool ceiling — validateJob permits omission, meaning the full menu */
|
|
80
|
+
const ceiling = Array.isArray(spec.tools) ? spec.tools : [...TOOL_MENU];
|
|
81
|
+
const fence = spec.writeScope.map(globToPrefix);
|
|
82
|
+
const insideFence = (/** @type {string} */ p) => fence.some((f) => p === f || p.startsWith(f + '/'));
|
|
83
|
+
const checkNames = Array.isArray(spec.checks) ? spec.checks.map((/** @type {any} */ c) => c?.name).filter(isNonEmptyString) : [];
|
|
84
|
+
|
|
85
|
+
let p = input;
|
|
86
|
+
if (typeof p === 'string') {
|
|
87
|
+
try { p = JSON.parse(p); } catch (e) {
|
|
88
|
+
return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: String(/** @type {Error} */ (e).message) }], plan: null };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!isObj(p)) return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: 'plan must be a JSON object' }], plan: null };
|
|
92
|
+
const plan = /** @type {Record<string, any>} */ (p);
|
|
93
|
+
|
|
94
|
+
// 1. shape — unknown top-level fields red (a smuggled close/checks/budget
|
|
95
|
+
// lands here: the arbiter is inexpressible in the plan vocabulary)
|
|
96
|
+
for (const key of Object.keys(plan)) {
|
|
97
|
+
if (!PLAN_FIELDS.includes(key)) red('unknown-field', key, 'the plan vocabulary is steps only — the arbiter (close/budget/fence/checks) is signed, never planned');
|
|
98
|
+
}
|
|
99
|
+
if (plan.schema === undefined) red('missing-required', 'schema');
|
|
100
|
+
else if (plan.schema !== 'plan-v1') red('invalid-value', 'schema', `expected "plan-v1", got ${JSON.stringify(plan.schema)}`);
|
|
101
|
+
|
|
102
|
+
// 2. steps — ordered array (decision 7: order IS the order), bounded count
|
|
103
|
+
if (!Array.isArray(plan.steps) || plan.steps.length === 0) {
|
|
104
|
+
red('missing-required', 'steps', 'non-empty ordered array — v1 executes strictly sequentially, array order is the order');
|
|
105
|
+
} else if (plan.steps.length > MAX_PLAN_STEPS) {
|
|
106
|
+
red('bounds', 'steps', `max ${MAX_PLAN_STEPS} steps — per-step rounds bound the spend, the step count bounds the claim on the wallet`);
|
|
107
|
+
} else {
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
plan.steps.forEach((/** @type {any} */ s, /** @type {number} */ i) => {
|
|
110
|
+
const at = `steps.${i}`;
|
|
111
|
+
if (!isObj(s)) { red('invalid-value', at, 'step must be an object'); return; }
|
|
112
|
+
for (const key of Object.keys(s)) {
|
|
113
|
+
if (!STEP_FIELDS.includes(key)) red('unknown-field', `${at}.${key}`, key === 'dependsOn' ? 'no dependsOn in v1 — strictly sequential, array order is the order (an inert knob is a fake contrast lever)' : 'not a step field — the arbiter is inexpressible here');
|
|
114
|
+
}
|
|
115
|
+
if (!isNonEmptyString(s.id) || !SLUG_RE.test(s.id)) red('invalid-value', `${at}.id`, 'kebab-case slug');
|
|
116
|
+
else if (seen.has(s.id)) red('duplicate-id', `${at}.id`, s.id);
|
|
117
|
+
else seen.add(s.id);
|
|
118
|
+
|
|
119
|
+
if (!isNonEmptyString(s.action)) red('missing-required', `${at}.action`, 'the step\'s task text — a step without one is an unaimed worker');
|
|
120
|
+
|
|
121
|
+
// tools ⊆ the SIGNED ceiling: a verb beyond it is verb-escape with the
|
|
122
|
+
// verb as structured data (the ledger counts overreach per verb) — an
|
|
123
|
+
// unknown string stays a typo (invalid-value), never an escape
|
|
124
|
+
let writeStep = false;
|
|
125
|
+
if (s.tools === undefined) red('missing-required', `${at}.tools`, 'every step declares its grant — the narrowed menu is the step boundary');
|
|
126
|
+
else if (!(Array.isArray(s.tools) && s.tools.length > 0
|
|
127
|
+
&& s.tools.every((/** @type {unknown} */ t) => typeof t === 'string')
|
|
128
|
+
&& new Set(s.tools).size === s.tools.length)) {
|
|
129
|
+
red('invalid-value', `${at}.tools`, `non-empty unique subset of the spec ceiling [${ceiling.join(', ')}]`);
|
|
130
|
+
} else {
|
|
131
|
+
// three disjoint classes: a LOCKED verb (run — never in any ceiling) is
|
|
132
|
+
// an escape the ledger must never bury as a typo; a menu verb outside
|
|
133
|
+
// the SIGNED ceiling is an escape too (overreach, not admission demand);
|
|
134
|
+
// a string in neither list is a typo (invalid-value)
|
|
135
|
+
const unknown = s.tools.filter((/** @type {string} */ t) => !TOOL_MENU.includes(t) && !LOCKED_TOOLS.includes(t));
|
|
136
|
+
if (unknown.length) red('invalid-value', `${at}.tools`, `unknown tool(s) ${unknown.join(', ')} — menu: ${TOOL_MENU.join('|')}`);
|
|
137
|
+
for (const t of s.tools.filter((/** @type {string} */ t) => LOCKED_TOOLS.includes(t))) {
|
|
138
|
+
reds.push({ code: 'verb-escape', path: `${at}.tools`, verb: t, detail: `"${t}" is locked at every layer — a worker that can run commands can run its own close` });
|
|
139
|
+
}
|
|
140
|
+
for (const t of s.tools.filter((/** @type {string} */ t) => TOOL_MENU.includes(t) && !ceiling.includes(t))) {
|
|
141
|
+
reds.push({ code: 'verb-escape', path: `${at}.tools`, verb: t, detail: `"${t}" is outside the signed ceiling [${ceiling.join(', ')}] — the plan may narrow the grant, never widen it` });
|
|
142
|
+
}
|
|
143
|
+
writeStep = s.tools.some((/** @type {string} */ t) => WRITE_VERBS.includes(t));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// rounds ≤ the shell cap (cap-not-estimate; the step bound IS maxTurns)
|
|
147
|
+
if (!(Number.isInteger(s.rounds) && s.rounds >= 1 && s.rounds <= maxStepRounds)) {
|
|
148
|
+
red('bounds', `${at}.rounds`, `integer 1..${maxStepRounds} — the step bound is the Gate's maxTurns, it may tighten the shell cap, never exceed it`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// target (v1.18): the per-step deliverable — required on write steps,
|
|
152
|
+
// always inside the signed fence when present
|
|
153
|
+
if (s.target === undefined || s.target === '') {
|
|
154
|
+
if (writeStep) red('missing-required', `${at}.target`, 'a write-granted step declares its deliverable path (v1.18) — an untargeted write step is unattributable');
|
|
155
|
+
} else if (!isNonEmptyString(s.target) || !scopeContained(s.target)) {
|
|
156
|
+
red('invalid-value', `${at}.target`, 'a relative path inside the run dir — no absolute paths, no ".." segments');
|
|
157
|
+
} else if (!insideFence(globToPrefix(s.target))) {
|
|
158
|
+
red('scope-escape', `${at}.target`, `"${s.target}" is outside the signed fence [${spec.writeScope.join(', ')}]`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
validateExit(s, at, red, { checkNames, fence: spec.writeScope, insideFence, writeStep });
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 3. secrets sweep — the agent-authored document is the riskier entry point
|
|
166
|
+
sweepSecretLiterals(plan, red);
|
|
167
|
+
|
|
168
|
+
return { ok: reds.length === 0, reds, plan: reds.length === 0 ? plan : null };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A step's exit: AND-only conjunction from the closed menu, max 2 (decision
|
|
173
|
+
* 8). Every form is declarative data the shell evaluates with its own fixed
|
|
174
|
+
* code — `exit-illegal` is the arbiter-inexpressibility red.
|
|
175
|
+
* @param {Record<string, any>} s the step
|
|
176
|
+
* @param {string} at step path prefix
|
|
177
|
+
* @param {(code: string, path: string, detail?: string) => void} red
|
|
178
|
+
* @param {{ checkNames: string[], fence: string[], insideFence: (p: string) => boolean, writeStep: boolean }} ctx
|
|
179
|
+
*/
|
|
180
|
+
function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep }) {
|
|
181
|
+
if (!Array.isArray(s.exit) || s.exit.length === 0) {
|
|
182
|
+
red('missing-required', `${at}.exit`, `non-empty array from the closed menu ${EXIT_TYPES.join('|')} — ALL listed exits must pass (AND-only); a step without an exit has no progress gate`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (s.exit.length > MAX_EXITS_PER_STEP) {
|
|
186
|
+
red('exit-illegal', `${at}.exit`, `max ${MAX_EXITS_PER_STEP} exits — AND-only conjunction, bounded (decision 8)`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
let hasTreeChanged = false;
|
|
190
|
+
let hasCheck = false;
|
|
191
|
+
s.exit.forEach((/** @type {any} */ e, /** @type {number} */ i) => {
|
|
192
|
+
const eAt = `${at}.exit.${i}`;
|
|
193
|
+
if (!isObj(e) || !EXIT_TYPES.includes(e.type)) {
|
|
194
|
+
red('exit-illegal', eAt, `menu: ${EXIT_TYPES.join('|')} — an exit is declarative data the shell evaluates with its own fixed code, never a command`);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
for (const key of Object.keys(e)) {
|
|
198
|
+
if (!EXIT_FIELDS[e.type].includes(key)) red('unknown-field', `${eAt}.${key}`, `not a ${e.type} field (commands and script bodies land here)`);
|
|
199
|
+
}
|
|
200
|
+
if (e.type === 'check-passes') {
|
|
201
|
+
hasCheck = true;
|
|
202
|
+
if (!isNonEmptyString(e.name)) red('invalid-value', `${eAt}.name`, 'the signed check\'s name');
|
|
203
|
+
else if (!checkNames.includes(e.name)) {
|
|
204
|
+
// decision 1: a check the spec doesn't sign DOES NOT EXIST — and the
|
|
205
|
+
// detail names the signed menu so the replan can aim, not guess
|
|
206
|
+
red('check-unknown', eAt, `"${e.name}" is not a signed check — the agent references checks, never authors them; signed menu: [${checkNames.join(', ') || 'none'}]`);
|
|
207
|
+
}
|
|
208
|
+
} else if (e.type === 'tree-changed') {
|
|
209
|
+
hasTreeChanged = true;
|
|
210
|
+
if (!isNonEmptyString(e.scope) || !scopeContained(e.scope) || globToPrefix(e.scope).includes('*')) {
|
|
211
|
+
red('invalid-value', `${eAt}.scope`, 'a contained scope (trailing /** or /* only, no ".." or absolute)');
|
|
212
|
+
} else if (!insideFence(globToPrefix(e.scope))) {
|
|
213
|
+
red('scope-escape', `${eAt}.scope`, `"${e.scope}" is outside the signed fence [${fence.join(', ')}]`);
|
|
214
|
+
}
|
|
215
|
+
} else { // artifact-written | json-valid — a named file path inside the fence
|
|
216
|
+
if (!isNonEmptyString(e.path) || !scopeContained(e.path)) {
|
|
217
|
+
red('invalid-value', `${eAt}.path`, 'a relative file path inside the run dir — no absolute paths, no ".." segments');
|
|
218
|
+
} else if (!insideFence(globToPrefix(e.path))) {
|
|
219
|
+
red('scope-escape', `${eAt}.path`, `"${e.path}" is outside the signed fence [${fence.join(', ')}]`);
|
|
220
|
+
}
|
|
221
|
+
if (e.type === 'artifact-written' && e.pattern !== undefined) {
|
|
222
|
+
if (!isNonEmptyString(e.pattern)) red('invalid-value', `${eAt}.pattern`, 'regex source string');
|
|
223
|
+
else {
|
|
224
|
+
try { new RegExp(e.pattern, 'm'); }
|
|
225
|
+
catch { red('invalid-value', `${eAt}.pattern`, 'must compile as a RegExp'); }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
// The F17 pairing rule: the seed tree is green, so a lone check-passes on a
|
|
231
|
+
// write-granted step would pass on the UNTOUCHED repo — an unearned exit.
|
|
232
|
+
// The POC's changed-from-seed stage existed for exactly this; here it is a
|
|
233
|
+
// validation law, stated in the plan itself (never hardwired shell code).
|
|
234
|
+
if (writeStep && hasCheck && !hasTreeChanged) {
|
|
235
|
+
red('exit-illegal', `${at}.exit`, 'check-passes on a write-granted step requires the tree-changed conjunct — the seed tree is green, so a lone check would pass on the untouched repo (F17/F46 already-green trap)');
|
|
236
|
+
}
|
|
237
|
+
}
|