bareloop 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +722 -1
- package/README.md +13 -3
- package/bareloop.context.md +311 -239
- package/package.json +3 -3
- package/src/clock.js +181 -0
- package/src/exits.js +8 -2
- package/src/index.js +23 -17
- package/src/job.js +231 -108
- package/src/ledger.js +28 -3
- package/src/plan.js +266 -20
- package/src/planrun.js +870 -101
- package/src/ralph.js +226 -7
- package/src/root.js +32 -12
- package/src/run.js +61 -390
- package/src/stall.js +252 -0
- package/src/tools.js +611 -0
- package/src/validate.js +35 -250
- package/types/clock.d.ts +109 -0
- package/types/index.d.ts +4 -6
- package/types/job.d.ts +83 -8
- package/types/ledger.d.ts +3 -1
- package/types/plan.d.ts +71 -7
- package/types/planrun.d.ts +41 -3
- package/types/ralph.d.ts +62 -4
- package/types/root.d.ts +10 -2
- package/types/run.d.ts +9 -17
- package/types/stall.d.ts +80 -0
- package/types/tools.d.ts +262 -0
- package/types/validate.d.ts +21 -38
- package/src/extract.js +0 -103
- package/src/interpret.js +0 -887
- package/types/extract.d.ts +0 -25
- package/types/interpret.d.ts +0 -201
package/src/plan.js
CHANGED
|
@@ -9,16 +9,22 @@
|
|
|
9
9
|
// `request-red`, which is admission demand against the product menu)
|
|
10
10
|
// bounds ≤ the shell caps → `bounds`
|
|
11
11
|
// scopes/targets inside the signed fence → `scope-escape` / `invalid-value`
|
|
12
|
+
// …and, on a step that NARROWED the fence with `scope`, the step's WRITES are
|
|
13
|
+
// judged against that narrowed prefix too → `step-scope-escape` (the runner
|
|
14
|
+
// gates the step from it, so an in-fence-but-out-of-scope target is a write
|
|
15
|
+
// denied on every attempt with no red anywhere — W3). Exits are observations,
|
|
16
|
+
// not writes: only a `tree-changed` scope DISJOINT from the step's scope is
|
|
17
|
+
// unsatisfiable, and the artifact paths are not constrained at all
|
|
12
18
|
// exits from the closed menu only → `exit-illegal` (arbiter
|
|
13
19
|
// inexpressibility: an exit is declarative data the shell evaluates with
|
|
14
20
|
// its own fixed code — `run` cannot be laundered through it)
|
|
15
|
-
// check references resolve against the
|
|
21
|
+
// check references resolve against the menu DERIVED from the signed close → `check-unknown`
|
|
16
22
|
// (decision 1: a check the spec doesn't sign does not exist)
|
|
17
23
|
//
|
|
18
24
|
// Like its siblings (validate.js, job.js) it never throws on JSON text or
|
|
19
25
|
// plain parsed data; every failure is a named {code, path, detail} red.
|
|
20
26
|
|
|
21
|
-
import { TOOL_MENU, LOCKED_TOOLS } from './job.js';
|
|
27
|
+
import { TOOL_MENU, LOCKED_TOOLS, WRITE_VERBS, checkMenu } from './job.js';
|
|
22
28
|
import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals } from './validate.js';
|
|
23
29
|
|
|
24
30
|
/** the closed exit menu (PRD v1.12 §3 + decision 1's `check-passes`): the
|
|
@@ -33,14 +39,71 @@ export const MAX_EXITS_PER_STEP = 2;
|
|
|
33
39
|
export const MAX_PLAN_STEPS = 8;
|
|
34
40
|
/** the write-class verbs (BA-13: `edit` is judged by the same fence as
|
|
35
41
|
* `write`) — a step granting one is a WRITE step for the F17 pairing rule
|
|
36
|
-
* and the v1.18 target requirement
|
|
37
|
-
|
|
42
|
+
* and the v1.18 target requirement. RE-EXPORTED from job.js, never re-declared:
|
|
43
|
+
* ONE inventory (the SECRET_PATTERNS precedent) — a second copy lets a third
|
|
44
|
+
* write-class verb land in one list and stay invisible to the other. */
|
|
45
|
+
export { WRITE_VERBS };
|
|
46
|
+
/** scope-menu bound: the menu is a PROMPT ingredient on every draft and
|
|
47
|
+
* redraft, so an unbounded repo would price the drafting call by its directory
|
|
48
|
+
* count. Shallow entries survive the cap (see `legalScopes`) because they are
|
|
49
|
+
* the useful ones — a deep leaf is reachable via its parent. */
|
|
50
|
+
export const MAX_SCOPE_MENU = 24;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The MENU of legal `tree-changed` scopes — choose-don't-describe (design record
|
|
54
|
+
* §4, hamr's correction: *"did agent choose from a list or not?"*).
|
|
55
|
+
*
|
|
56
|
+
* F60 measured 13 of 18 validator reds as ONE class: the agent writes
|
|
57
|
+
* `scope: "src/*.js"`, the grammar accepts only a trailing `/**` or `/*`, and
|
|
58
|
+
* each red costs a redraft call. Teaching the rule in the prompt or widening the
|
|
59
|
+
* grammar both leave the agent authoring a free-text string and guessing its
|
|
60
|
+
* shape. Enumerating instead makes an illegal scope INEXPRESSIBLE.
|
|
61
|
+
*
|
|
62
|
+
* This is the only producer of scope menus, and it filters by the signed fence —
|
|
63
|
+
* so a menu entry that escapes containment cannot be constructed, rather than
|
|
64
|
+
* being caught by a downstream check. `globToPrefix` is untouched: this changes
|
|
65
|
+
* what the agent is OFFERED, never what the fence ENFORCES.
|
|
66
|
+
*
|
|
67
|
+
* @param {string[]} writeScope the signed fence entries (always offerable — they
|
|
68
|
+
* cover directories a step has not created yet, which `snapshotScope` treats
|
|
69
|
+
* as an empty snapshot)
|
|
70
|
+
* @param {string[]} [dirs] workdir-relative directories discovered under the
|
|
71
|
+
* fence; each becomes `<dir>/**`
|
|
72
|
+
* @param {number} [cap] the menu bound; `Infinity` asks for the UNCAPPED set, so
|
|
73
|
+
* a caller can report how many entries the cap dropped (no silent caps — a menu
|
|
74
|
+
* that reads as complete when it is not is the blind-instrument class)
|
|
75
|
+
* @returns {string[]} the offerable scopes, deduped, shallowest-first, capped
|
|
76
|
+
*/
|
|
77
|
+
export function legalScopes(writeScope, dirs = [], cap = MAX_SCOPE_MENU) {
|
|
78
|
+
const fence = writeScope.filter(isNonEmptyString).map(globToPrefix).filter((p) => p !== '');
|
|
79
|
+
const inside = (/** @type {string} */ p) => fence.some((f) => p === f || p.startsWith(f + '/'));
|
|
80
|
+
/** @type {string[]} */
|
|
81
|
+
const prefixes = [];
|
|
82
|
+
for (const p of [...fence, ...dirs.filter(isNonEmptyString).map(globToPrefix)]) {
|
|
83
|
+
// scopeContained rejects absolutes, drive letters, backslashes and any ".."
|
|
84
|
+
// segment; `inside` rejects a contained-but-out-of-fence dir. Both, in that
|
|
85
|
+
// order, on the NORMALIZED prefix.
|
|
86
|
+
if (!scopeContained(p) || !inside(p) || prefixes.includes(p)) continue;
|
|
87
|
+
prefixes.push(p);
|
|
88
|
+
}
|
|
89
|
+
// depth first, then lexical — a stable order so the same repo yields the same
|
|
90
|
+
// menu (and the same prompt) across drafts, and so the cap drops leaves
|
|
91
|
+
// rather than roots.
|
|
92
|
+
prefixes.sort((a, b) => a.split('/').length - b.split('/').length || a.localeCompare(b));
|
|
93
|
+
return prefixes.slice(0, cap).map((p) => `${p}/**`);
|
|
94
|
+
}
|
|
38
95
|
const PLAN_FIELDS = ['schema', 'steps'];
|
|
39
96
|
// decision 7: NO dependsOn — v1 is strictly sequential, array order IS the
|
|
40
97
|
// order; a field nothing consumes is a live-looking knob with zero effect
|
|
41
98
|
// (the F16 inert-op class). The arbiter's own vocabulary (close, budget,
|
|
42
99
|
// fence) is absent by construction — unknown-field at every depth.
|
|
43
|
-
const STEP_FIELDS = ['id', 'action', 'tools', 'rounds', 'target', 'exit'];
|
|
100
|
+
const STEP_FIELDS = ['id', 'action', 'tools', 'rounds', 'target', 'exit', 'model', 'attempts', 'scope'];
|
|
101
|
+
/** P (design record 2026-07-28): the signed per-step model-tier menu. Closed by
|
|
102
|
+
* construction — the agent picks a tier, never names an arbitrary model string
|
|
103
|
+
* (the tier→model mapping and any effort params are the RUNNER's, per provider).
|
|
104
|
+
* opus is deliberately absent: reserved for fresh builds hamr explicitly assigns,
|
|
105
|
+
* never plan-selectable. */
|
|
106
|
+
export const STEP_MODELS = Object.freeze(['sonnet', 'haiku']);
|
|
44
107
|
const EXIT_FIELDS = {
|
|
45
108
|
'artifact-written': ['type', 'path', 'pattern'],
|
|
46
109
|
'tree-changed': ['type', 'scope'],
|
|
@@ -105,7 +168,22 @@ export function hasNestedQuantifier(src) {
|
|
|
105
168
|
if (c === '[') { // character class: quantifier chars inside are literals
|
|
106
169
|
i++;
|
|
107
170
|
if (src[i] === '^') i++;
|
|
108
|
-
|
|
171
|
+
// NO POSIX leading-`]`-is-a-literal rule: JS is not POSIX here. `[]` is the
|
|
172
|
+
// EMPTY class (matches nothing) and `[^]` the any-char idiom — both close at
|
|
173
|
+
// that first `]`. Skipping it ran the scan past the class's real end and
|
|
174
|
+
// swallowed every quantifier after it, so `x[^](a+)+$` read as safe while
|
|
175
|
+
// RegExp.test on it does not finish in 15s on a 31-char body. A false
|
|
176
|
+
// NEGATIVE is the dangerous direction (F49), so the scan follows JS.
|
|
177
|
+
//
|
|
178
|
+
// Honest limit on the monotonicity claim: this is a PARSE correction, not
|
|
179
|
+
// the "add rejections only" tightening F49's rule contemplates, and it is
|
|
180
|
+
// not strictly rejection-monotonic. Measured over 14,862 compilable
|
|
181
|
+
// generated patterns (2,369 previously rejected): 2 rejections disappear,
|
|
182
|
+
// both pathological `[]`-bearing shapes whose old `true` came from the very
|
|
183
|
+
// misparse being fixed, and both verified to finish RegExp.test in ~0ms.
|
|
184
|
+
// Every entry of the checked-in REDOS_BAD/GOOD/OVERREJECTED corpus is
|
|
185
|
+
// unchanged. So no real hazard was un-rejected — but the claim is
|
|
186
|
+
// "measured, no hazard lost", never "monotonic by construction".
|
|
109
187
|
while (i < src.length && src[i] !== ']') { if (src[i] === '\\') i++; i++; }
|
|
110
188
|
continue;
|
|
111
189
|
}
|
|
@@ -132,6 +210,35 @@ export function hasNestedQuantifier(src) {
|
|
|
132
210
|
return false;
|
|
133
211
|
}
|
|
134
212
|
|
|
213
|
+
/**
|
|
214
|
+
* The close, as the ordered STAGE LIST the plan flow actually executes (PRD
|
|
215
|
+
* v1.28). A staged close is already that list; the legacy object form is the
|
|
216
|
+
* ONE-stage list it stands for, named `close`. Anything else — a gold, rubric or
|
|
217
|
+
* hitl close, which names no command to run — is null, and the runner escalates
|
|
218
|
+
* `close-unsupported` before any tokens.
|
|
219
|
+
*
|
|
220
|
+
* W4 — this is the ONE staging, and it exists because there used to be two. The
|
|
221
|
+
* runner synthesized the single stage and derived its check menu from THAT, while
|
|
222
|
+
* `planPrompt` and `validatePlan` called `checkMenu` on the RAW spec — where an
|
|
223
|
+
* object close is not an array, so the menu came back empty. The result was a
|
|
224
|
+
* runner announcing and preflighting a stage the drafter was never offered and the
|
|
225
|
+
* validator would have redded `check-unknown`. Same class as the two-transforms
|
|
226
|
+
* defects before it: the fix is one derivation every consumer calls, not three
|
|
227
|
+
* copies kept in step by comment.
|
|
228
|
+
*
|
|
229
|
+
* Lives here rather than beside `checkMenu` in job.js only because job.js owns the
|
|
230
|
+
* SIGNED vocabulary and this is the consumer-side reading of it; `plan.js` is the
|
|
231
|
+
* lowest module every check-menu consumer already imports (planrun imports plan,
|
|
232
|
+
* never the reverse), so it is the one place all three can share without a cycle.
|
|
233
|
+
* @param {any} close the spec's `close` field, any shape
|
|
234
|
+
* @returns {any[]|null} the stage list, or null when the close names no command
|
|
235
|
+
*/
|
|
236
|
+
export function stageClose(close) {
|
|
237
|
+
if (Array.isArray(close)) return close;
|
|
238
|
+
if (!isObj(close) || close.type !== 'predicate') return null;
|
|
239
|
+
return [{ name: 'close', cmd: close.cmd, expect: close.expect, judged: close.judged, gapKeep: close.gapKeep }];
|
|
240
|
+
}
|
|
241
|
+
|
|
135
242
|
/** @typedef {{code: string, path: string, detail?: string, verb?: string}} Red */
|
|
136
243
|
|
|
137
244
|
/**
|
|
@@ -139,14 +246,17 @@ export function hasNestedQuantifier(src) {
|
|
|
139
246
|
* job spec. Never throws on JSON text or plain parsed data; every failure is
|
|
140
247
|
* a named red. Returns the parsed plan on ok (single parse), null on any red.
|
|
141
248
|
* @param {object|string} input parsed plan, or raw JSON text (parse failures are a red)
|
|
142
|
-
* @param {{ job?: any, maxStepRounds?: number }} [opts] `job`: the
|
|
249
|
+
* @param {{ job?: any, maxStepRounds?: number, scopes?: string[], capRuns?: number }} [opts] `job`: the
|
|
143
250
|
* validateJob-GREEN four-field spec (the ceiling, the fence, and the checks
|
|
144
251
|
* menu all come from it — a missing or non-plan-shape job fails CLOSED);
|
|
145
252
|
* `maxStepRounds`: the shell's per-step rounds ceiling (interpret's
|
|
146
|
-
* tool-mode TURNS_PER_ATTEMPT) — a step may tighten it, never exceed it
|
|
253
|
+
* tool-mode TURNS_PER_ATTEMPT) — a step may tighten it, never exceed it;
|
|
254
|
+
* `scopes`: the offered `tree-changed` menu from `legalScopes` (the SAME array
|
|
255
|
+
* the drafting prompt enumerated). Omitted, it derives from the signed
|
|
256
|
+
* writeScope — never a free-text fallback (F50).
|
|
147
257
|
* @returns {{ ok: boolean, reds: Red[], plan: object|null }}
|
|
148
258
|
*/
|
|
149
|
-
export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
|
|
259
|
+
export function validatePlan(input, { job, maxStepRounds = 40, scopes, capRuns = 3 } = {}) {
|
|
150
260
|
/** @type {Red[]} */
|
|
151
261
|
const reds = [];
|
|
152
262
|
/** @type {(code: string, path: string, detail?: string) => void} */
|
|
@@ -155,15 +265,36 @@ export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
|
|
|
155
265
|
// The signed side, fail-CLOSED (the validate.js fence-invalid pattern): the
|
|
156
266
|
// ceiling/fence/menu are meaningless without a plan-shape spec, and an open
|
|
157
267
|
// gate on a malformed one would validate a plan against nothing.
|
|
158
|
-
|
|
159
|
-
|
|
268
|
+
// The writeScope's MEMBERS are checked here too, not just its arrayness:
|
|
269
|
+
// `globToPrefix` below calls `.replace` on each one, so a non-string member
|
|
270
|
+
// threw `TypeError: scope.replace is not a function` out of a function whose
|
|
271
|
+
// contract is that plain data only ever yields a named red. Same
|
|
272
|
+
// `isNonEmptyString` question `legalScopes` already asks of the same field.
|
|
273
|
+
if (!isObj(job) || !isNonEmptyString(/** @type {any} */ (job).goal) || !Array.isArray(/** @type {any} */ (job).writeScope)
|
|
274
|
+
|| !(/** @type {any[]} */ (/** @type {any} */ (job).writeScope)).every(isNonEmptyString)) {
|
|
275
|
+
return { ok: false, reds: [{ code: 'job-invalid', path: 'job', detail: 'a plan validates only against a validateJob-green plan shape spec (goal/verdictType/close) — validate the job first' }], plan: null };
|
|
160
276
|
}
|
|
161
277
|
const spec = /** @type {Record<string, any>} */ (job);
|
|
162
278
|
/** the signed tool ceiling — validateJob permits omission, meaning the full menu */
|
|
163
279
|
const ceiling = Array.isArray(spec.tools) ? spec.tools : [...TOOL_MENU];
|
|
164
280
|
const fence = spec.writeScope.map(globToPrefix);
|
|
165
281
|
const insideFence = (/** @type {string} */ p) => fence.some((f) => p === f || p.startsWith(f + '/'));
|
|
166
|
-
|
|
282
|
+
// The check menu DERIVES from the close's stages (PRD v1.28) — the agent
|
|
283
|
+
// references a piece of the operator's own inspection, never a ruler someone
|
|
284
|
+
// hand-carved beside it. One derivation, shared with the runner, so what the
|
|
285
|
+
// prompt offers and what the validator accepts cannot drift apart — which means
|
|
286
|
+
// reading the close through `stageClose`, the same staging the runner EXECUTES
|
|
287
|
+
// (W4: reading the raw field here made the legacy object form's stage invisible
|
|
288
|
+
// to the validator and the prompt while the runner ran it).
|
|
289
|
+
// `?? []` is the null case spelled out: a close that names no command to run
|
|
290
|
+
// (gold/rubric/hitl) stages nothing, so it offers nothing — the runner
|
|
291
|
+
// escalates `close-unsupported` on the same null, and an empty menu is already
|
|
292
|
+
// the honest reading here (every check-passes becomes a check-unknown).
|
|
293
|
+
const checkNames = checkMenu(stageClose(spec.close) ?? []).map((m) => m.name);
|
|
294
|
+
// The offered scope menu. A caller-supplied menu must be the same one the
|
|
295
|
+
// prompt enumerated; with none, derive from the signed fence — fail-CLOSED,
|
|
296
|
+
// so omitting the option narrows the agent's choices and never widens them.
|
|
297
|
+
const scopeMenu = Array.isArray(scopes) && scopes.length ? scopes : legalScopes(spec.writeScope);
|
|
167
298
|
|
|
168
299
|
let p = input;
|
|
169
300
|
if (typeof p === 'string') {
|
|
@@ -205,6 +336,7 @@ export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
|
|
|
205
336
|
// verb as structured data (the ledger counts overreach per verb) — an
|
|
206
337
|
// unknown string stays a typo (invalid-value), never an escape
|
|
207
338
|
let writeStep = false;
|
|
339
|
+
let toolsParsed = false;
|
|
208
340
|
if (s.tools === undefined) red('missing-required', `${at}.tools`, 'every step declares its grant — the narrowed menu is the step boundary');
|
|
209
341
|
else if (!(Array.isArray(s.tools) && s.tools.length > 0
|
|
210
342
|
&& s.tools.every((/** @type {unknown} */ t) => typeof t === 'string')
|
|
@@ -224,6 +356,7 @@ export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
|
|
|
224
356
|
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` });
|
|
225
357
|
}
|
|
226
358
|
writeStep = s.tools.some((/** @type {string} */ t) => WRITE_VERBS.includes(t));
|
|
359
|
+
toolsParsed = true;
|
|
227
360
|
}
|
|
228
361
|
|
|
229
362
|
// rounds ≤ the shell cap (cap-not-estimate; the step bound IS maxTurns)
|
|
@@ -231,6 +364,65 @@ export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
|
|
|
231
364
|
red('bounds', `${at}.rounds`, `integer 1..${maxStepRounds} — the step bound is the Gate's maxTurns, it may tighten the shell cap, never exceed it`);
|
|
232
365
|
}
|
|
233
366
|
|
|
367
|
+
// P (design record 2026-07-28): the widened vocabulary — every field
|
|
368
|
+
// optional, every field tighten-only, every legal set handed over as a
|
|
369
|
+
// MENU (choose-don't-describe: illegal is inexpressible, never described)
|
|
370
|
+
if (s.model !== undefined && !STEP_MODELS.includes(s.model)) {
|
|
371
|
+
red('invalid-value', `${at}.model`, `menu: ${STEP_MODELS.join('|')} — the tier menu is signed; the agent picks a tier, never names a model`);
|
|
372
|
+
}
|
|
373
|
+
if (s.attempts !== undefined && !(Number.isInteger(s.attempts) && s.attempts >= 1 && s.attempts <= capRuns)) {
|
|
374
|
+
red('bounds', `${at}.attempts`, `integer 1..${capRuns} — a step may tighten the shell's attempt cap, never exceed it`);
|
|
375
|
+
}
|
|
376
|
+
if (s.scope !== undefined && !scopeMenu.includes(s.scope)) {
|
|
377
|
+
red('invalid-value', `${at}.scope`, `menu: [${scopeMenu.join(', ')}] — a per-step write scope narrows the fence from the same menu tree-changed uses`);
|
|
378
|
+
}
|
|
379
|
+
// W3 — a step that NARROWS the fence is gated by its own scope, not the
|
|
380
|
+
// signed one: planrun's mkWorker builds this step's Gate writeScope from
|
|
381
|
+
// `globToPrefix(scope)`. So a TARGET that is in-fence but OUTSIDE the step's
|
|
382
|
+
// own scope is a write the gate denies on every attempt — the step burns its
|
|
383
|
+
// whole attempt budget on refusals and no red is raised anywhere, because
|
|
384
|
+
// each half was individually legal and nothing checked the PAIR. Checking it
|
|
385
|
+
// here makes the incoherent pair inexpressible at the validation gate rather
|
|
386
|
+
// than discovered at the runtime one.
|
|
387
|
+
//
|
|
388
|
+
// The rule binds WRITES, not OBSERVATIONS. An exit is evaluated by the shell
|
|
389
|
+
// (src/exits.js), never written by the worker, so the step's gate constrains
|
|
390
|
+
// it only where the evaluator's own semantics say so — measured, not assumed:
|
|
391
|
+
// - `tree-changed` passes iff ANY file under ITS scope changed, so a scope
|
|
392
|
+
// that CONTAINS the step's scope contains the step's writable ground and
|
|
393
|
+
// fires exactly when the narrow one does. Only a DISJOINT scope is
|
|
394
|
+
// unsatisfiable: it can pass only if ground this gate refuses changed.
|
|
395
|
+
// Hence overlap, not containment (and the menu is shallowest-first while
|
|
396
|
+
// the prompt says copy one value, so the widest scope is the LIKELIEST
|
|
397
|
+
// draft — containment here would tax the natural plan, the exact F60
|
|
398
|
+
// class `legalScopes` exists to eliminate).
|
|
399
|
+
// - `artifact-written`/`json-valid` read and parse the named file and ask
|
|
400
|
+
// nothing about who wrote it, so a path outside the step's scope naming a
|
|
401
|
+
// PRIOR step's artifact is legal AND satisfiable (v1 is strictly
|
|
402
|
+
// sequential — that composition is the natural shape). No step-scope red
|
|
403
|
+
// belongs on those arms: it would claim a constraint the evaluator does
|
|
404
|
+
// not impose. A path nothing ever produces still fails at runtime, with
|
|
405
|
+
// the mechanical "<path> was not written (does not exist)" gap.
|
|
406
|
+
//
|
|
407
|
+
// Fence FIRST, narrowed scope SECOND (see the target/scope arms): the menu is
|
|
408
|
+
// fence-filtered by `legalScopes`, so containment in the scope normally
|
|
409
|
+
// implies containment in the fence — but a caller-supplied menu is data, and
|
|
410
|
+
// deriving the fence check FROM it would let a bad menu widen what is
|
|
411
|
+
// accepted. Kept as two ordered checks, the narrowed one can only tighten.
|
|
412
|
+
// An OFF-MENU scope carries no narrowing (it already redded above): one
|
|
413
|
+
// defect, one red — a second red derived from a value the agent cannot use
|
|
414
|
+
// would charge the ledger twice for the same mistake.
|
|
415
|
+
const stepScope = s.scope !== undefined && scopeMenu.includes(s.scope) ? s.scope : null;
|
|
416
|
+
const stepPrefix = stepScope === null ? null : globToPrefix(stepScope);
|
|
417
|
+
/** contained by the step's OWN scope — vacuously true on an unscoped step,
|
|
418
|
+
* which is what keeps every pre-W3 plan byte-identical. The WRITE test. */
|
|
419
|
+
const insideStepScope = (/** @type {string} */ p) => stepPrefix === null
|
|
420
|
+
|| p === stepPrefix || p.startsWith(stepPrefix + '/');
|
|
421
|
+
/** shares ground with the step's own scope — either prefix contains the
|
|
422
|
+
* other. The OBSERVATION test: what `tree-changed` needs to be satisfiable. */
|
|
423
|
+
const overlapsStepScope = (/** @type {string} */ p) => stepPrefix === null
|
|
424
|
+
|| p === stepPrefix || p.startsWith(stepPrefix + '/') || stepPrefix.startsWith(p + '/');
|
|
425
|
+
|
|
234
426
|
// target (v1.18): the per-step deliverable — required on write steps,
|
|
235
427
|
// always inside the signed fence when present
|
|
236
428
|
if (s.target === undefined || s.target === '') {
|
|
@@ -239,9 +431,11 @@ export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
|
|
|
239
431
|
red('invalid-value', `${at}.target`, 'a relative path inside the run dir — no absolute paths, no ".." segments');
|
|
240
432
|
} else if (!insideFence(globToPrefix(s.target))) {
|
|
241
433
|
red('scope-escape', `${at}.target`, `"${s.target}" is outside the signed fence [${spec.writeScope.join(', ')}]`);
|
|
434
|
+
} else if (!insideStepScope(globToPrefix(s.target))) {
|
|
435
|
+
red('step-scope-escape', `${at}.target`, `"${s.target}" is inside the signed fence but outside this step's own scope "${stepScope}" — the step's gate is built from that narrowed prefix, so every write to this target would be denied`);
|
|
242
436
|
}
|
|
243
437
|
|
|
244
|
-
validateExit(s, at, red, { checkNames, fence: spec.writeScope, insideFence, writeStep });
|
|
438
|
+
validateExit(s, at, red, { checkNames, fence: spec.writeScope, insideFence, writeStep, toolsParsed, scopeMenu, stepScope, overlapsStepScope });
|
|
245
439
|
});
|
|
246
440
|
}
|
|
247
441
|
|
|
@@ -258,9 +452,13 @@ export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
|
|
|
258
452
|
* @param {Record<string, any>} s the step
|
|
259
453
|
* @param {string} at step path prefix
|
|
260
454
|
* @param {(code: string, path: string, detail?: string) => void} red
|
|
261
|
-
* @param {{ checkNames: string[], fence: string[], insideFence: (p: string) => boolean, writeStep: boolean }} ctx
|
|
455
|
+
* @param {{ checkNames: string[], fence: string[], insideFence: (p: string) => boolean, writeStep: boolean, toolsParsed: boolean, scopeMenu: string[], stepScope: string|null, overlapsStepScope: (p: string) => boolean }} ctx
|
|
456
|
+
* `stepScope`/`overlapsStepScope` (W3): the step's OWN narrowed fence when it
|
|
457
|
+
* declared a legal `scope`, null/always-true otherwise. Exits are OBSERVATIONS,
|
|
458
|
+
* not writes, so only `tree-changed` is constrained by it and only against
|
|
459
|
+
* DISJOINTness — see the arm for the measured semantics.
|
|
262
460
|
*/
|
|
263
|
-
function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep }) {
|
|
461
|
+
function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep, toolsParsed, scopeMenu, stepScope, overlapsStepScope }) {
|
|
264
462
|
if (!Array.isArray(s.exit) || s.exit.length === 0) {
|
|
265
463
|
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`);
|
|
266
464
|
return;
|
|
@@ -286,16 +484,50 @@ function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep })
|
|
|
286
484
|
else if (!checkNames.includes(e.name)) {
|
|
287
485
|
// decision 1: a check the spec doesn't sign DOES NOT EXIST — and the
|
|
288
486
|
// detail names the signed menu so the replan can aim, not guess
|
|
289
|
-
red('check-unknown', eAt, `"${e.name}" is not
|
|
487
|
+
red('check-unknown', eAt, `"${e.name}" is not an offered close stage — the agent references stages of the operator's close, never authors a ruler; offered: [${checkNames.join(', ') || 'none'}]`);
|
|
290
488
|
}
|
|
291
489
|
} else if (e.type === 'tree-changed') {
|
|
292
490
|
hasTreeChanged = true;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
491
|
+
// choose-don't-describe: membership of the enumerated menu, NOT a shape
|
|
492
|
+
// grammar the agent has to guess (§4; F60's 72%-of-all-reds class). The
|
|
493
|
+
// menu derives from the signed writeScope when the caller passes none, so
|
|
494
|
+
// omitting the option can never restore free-text containment — a
|
|
495
|
+
// silently-ignored optional param is the F50 blind-instrument class. Every
|
|
496
|
+
// menu comes from `legalScopes`, which filters by the fence, so
|
|
497
|
+
// scope-escape here is inexpressible rather than checked.
|
|
498
|
+
// An off-menu scope splits by CAUSE, because the two carry different
|
|
499
|
+
// meaning to the ledger's class table: reaching OUTSIDE the signed fence is
|
|
500
|
+
// a behaviour signal about the agent (`scope-escape`, same code the target
|
|
501
|
+
// and path arms raise), while an in-fence value that simply is not offered
|
|
502
|
+
// is drafting friction (`invalid-value`, and the detail carries the menu so
|
|
503
|
+
// the redraft can choose instead of guess). Collapsing them would launder an
|
|
504
|
+
// attempted fence escape into a typo class.
|
|
505
|
+
if (!isNonEmptyString(e.scope) || !scopeMenu.includes(e.scope)) {
|
|
506
|
+
const contained = isNonEmptyString(e.scope) && scopeContained(e.scope);
|
|
507
|
+
if (contained && !insideFence(globToPrefix(e.scope))) {
|
|
508
|
+
red('scope-escape', `${eAt}.scope`, `"${e.scope}" is outside the signed fence [${fence.join(', ')}]`);
|
|
509
|
+
} else {
|
|
510
|
+
red('invalid-value', `${eAt}.scope`, `one of the offered scopes: [${scopeMenu.join(', ')}]`);
|
|
511
|
+
}
|
|
512
|
+
} else if (!overlapsStepScope(globToPrefix(e.scope))) {
|
|
513
|
+
// W3: an offered, in-fence scope can still be DISJOINT from the step's own
|
|
514
|
+
// narrowed fence. Measured against src/exits.js: `tree-changed` passes iff
|
|
515
|
+
// some file under ITS scope changed against the pre-step snapshot, and that
|
|
516
|
+
// snapshot already holds every earlier step's bytes — so a disjoint scope
|
|
517
|
+
// can only pass if ground this step's gate refuses changed. Unsatisfiable on
|
|
518
|
+
// every attempt, which is exactly the burn W3 closes.
|
|
519
|
+
// A WIDER (containing) scope is NOT redded: it contains the step's writable
|
|
520
|
+
// ground and fires exactly when the narrow one does.
|
|
521
|
+
red('step-scope-escape', `${eAt}.scope`, `"${e.scope}" is disjoint from this step's own scope "${stepScope}" — the step's gate allows writes only under "${stepScope}", and this exit passes only if a file under "${e.scope}" changed, so no write this step can make would ever satisfy it (a scope that contains "${stepScope}" is fine)`);
|
|
297
522
|
}
|
|
298
523
|
} else { // artifact-written | json-valid — a named file path inside the fence
|
|
524
|
+
// NO step-scope check here, deliberately (W3): src/exits.js reads and parses
|
|
525
|
+
// the named file and asks nothing about who wrote it, so a path outside the
|
|
526
|
+
// step's own scope naming a PRIOR step's artifact is legal AND satisfiable —
|
|
527
|
+
// and v1 is strictly sequential, so that composition is the natural shape.
|
|
528
|
+
// A red would claim a constraint the evaluator does not impose. A path nothing
|
|
529
|
+
// ever produces still fails at runtime with the mechanical "was not written"
|
|
530
|
+
// gap — the honest direction, versus taxing a legal draft with a redraft.
|
|
299
531
|
if (!isNonEmptyString(e.path) || !scopeContained(e.path)) {
|
|
300
532
|
red('invalid-value', `${eAt}.path`, 'a relative file path inside the run dir — no absolute paths, no ".." segments');
|
|
301
533
|
} else if (!insideFence(globToPrefix(e.path))) {
|
|
@@ -324,4 +556,18 @@ function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep })
|
|
|
324
556
|
if (writeStep && hasCheck && !hasTreeChanged) {
|
|
325
557
|
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)');
|
|
326
558
|
}
|
|
559
|
+
// The inverse trap, measured (runs ms4l5p6w/ms57zr7c: 4 of 4 drafted plans):
|
|
560
|
+
// a failing check's gap is re-delivered to THIS step's own worker, so a step
|
|
561
|
+
// holding a check must be able to act on it. A read-only "verify" step is a
|
|
562
|
+
// mailbox with no hands — the gap arrives, nothing can edit, the loop stalls
|
|
563
|
+
// to cap on a byte-identical gap. The outer close is the run's verification;
|
|
564
|
+
// forbidding this shape pushes the check onto the step that fixes (the one
|
|
565
|
+
// shape that has ever greened this job).
|
|
566
|
+
// …and only when the grant PARSED: with `tools` missing/malformed the step's
|
|
567
|
+
// hands are unknowable, and a mailbox red derived from the false default would
|
|
568
|
+
// charge the ledger with a violation the agent never committed (the real
|
|
569
|
+
// defect already redded as missing-required/invalid-value). One defect, one red.
|
|
570
|
+
if (toolsParsed && !writeStep && hasCheck) {
|
|
571
|
+
red('exit-illegal', `${at}.exit`, `check-passes on a step with no write-class tool (${WRITE_VERBS.join('|')}) — the check's failure gap comes back to this step's own worker, which cannot edit; attach the check to the step that fixes (the operator's close already verifies the finished run)`);
|
|
572
|
+
}
|
|
327
573
|
}
|