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/job.js
CHANGED
|
@@ -20,12 +20,13 @@ import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiter
|
|
|
20
20
|
// they are read at call time, so a mutable export would let adopter code
|
|
21
21
|
// enable verdict-class laundering process-wide with one push().
|
|
22
22
|
export const CLOSE_TYPES = Object.freeze(['predicate', 'gold', 'rubric', 'hitl']);
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
/** the hierarchy: which verdict classes a close type may claim (PRD §7). Read by
|
|
24
|
+
* the verdictType check — a declared verdict may not demand a class its close
|
|
25
|
+
* type cannot legally claim (green-on-rubric is laundering). The `CLASSES`
|
|
26
|
+
* vocabulary export went with steps[] (PRD v1.32): a class was a STEP field, and
|
|
27
|
+
* nothing declares one now. */
|
|
25
28
|
export const CLASS_BY_CLOSE = Object.freeze({ predicate: Object.freeze(['hard']), gold: Object.freeze(['hard']), rubric: Object.freeze(['soft']), hitl: Object.freeze(['hitl']) });
|
|
26
29
|
export const GOLD_COMPARE = Object.freeze(['exact', 'json-equal']);
|
|
27
|
-
/** step middle modes (2b): text = single-target artifact; tools = Gate-governed file tools */
|
|
28
|
-
export const STEP_MODES = Object.freeze(['text', 'tools']);
|
|
29
30
|
/** the tool grant menu (2b interview #1): read/grep/write, plus the two litectx
|
|
30
31
|
* RETRIEVAL verbs (F19) — `run` stays locked-but-listed; a spec requesting it
|
|
31
32
|
* reds, and that red IS the request-red evidence admission waits on (curation
|
|
@@ -45,8 +46,39 @@ export const STEP_MODES = Object.freeze(['text', 'tools']);
|
|
|
45
46
|
* WRITE-class verb judged by the SAME writeScope fence as `write` (bareguard
|
|
46
47
|
* action type 'edit'). Admitted because whole-file rewrite is measurably
|
|
47
48
|
* unreliable at size (F31: 4 of 5 big-file whole-writes broke the tree) and
|
|
48
|
-
* taxes output tokens ∝ file size. It admits no execution either.
|
|
49
|
-
|
|
49
|
+
* taxes output tokens ∝ file size. It admits no execution either.
|
|
50
|
+
*
|
|
51
|
+
* P (design record 2026-07-28, hamr: full catalog) widened the menu to the four
|
|
52
|
+
* components — write · select · compress · isolate — every verb an EXISTING
|
|
53
|
+
* litectx implementation (menu-is-inventory). impact/related/recent/compress/
|
|
54
|
+
* peek are read-only like the retrieval pair; stash/remember/forget are
|
|
55
|
+
* STORE-writes (`.litectx`, never the tree — own gate action types, and the U
|
|
56
|
+
* runner deletes the store at reset so cold stays cold). None admits execution:
|
|
57
|
+
* the `run` lock is untouched. */
|
|
58
|
+
export const TOOL_MENU = Object.freeze(['read', 'grep', 'write', 'edit', 'recall', 'get', 'impact', 'related', 'recent', 'compress', 'peek', 'stash', 'remember', 'forget']);
|
|
59
|
+
/** the write-class split the validator and the scout filter read: tree writes
|
|
60
|
+
* (writeScope fence) vs store writes (isolate) — NEITHER is read-capable. */
|
|
61
|
+
export const WRITE_VERBS = Object.freeze(['write', 'edit']);
|
|
62
|
+
export const STORE_VERBS = Object.freeze(['stash', 'remember', 'forget']);
|
|
63
|
+
/**
|
|
64
|
+
* The floor for `maxWallMs`: one default close timeout (`runClose`'s 120_000).
|
|
65
|
+
* Addendum 1 MEASURED that a wall deadline can only be read BETWEEN rounds —
|
|
66
|
+
* `loop.stop()` does not cut an in-flight call (fired at 500ms, returned at
|
|
67
|
+
* 4,018ms) — so enforcement is `maxWallMs + closeStages × closeTimeoutMs` (W5: a
|
|
68
|
+
* staged close runs every stage under the FULL timeout, ralph.js `runStages`).
|
|
69
|
+
* Under this floor the overshoot exceeds the budget itself and the advertised
|
|
70
|
+
* number would be more wrong than right, which is the advertised-must-equal-
|
|
71
|
+
* enforced line.
|
|
72
|
+
*
|
|
73
|
+
* The VALUE is a one-stage, default-timeout floor and is deliberately left alone —
|
|
74
|
+
* a threshold is operator territory, not something this module re-derives from a
|
|
75
|
+
* spec's own stage count. It therefore bounds only the degenerate case; a job with
|
|
76
|
+
* many stages or a raised `closeTimeoutMs` can still sit far above it while its
|
|
77
|
+
* overshoot dwarfs its cap. The clock reports that honestly (`closeStages` and
|
|
78
|
+
* `enforcedMs` are both on the `wall-clock` record) rather than the floor pretending
|
|
79
|
+
* to prevent it.
|
|
80
|
+
*/
|
|
81
|
+
export const MIN_WALL_MS = 120_000;
|
|
50
82
|
/** locked-but-listed tools: real capabilities deliberately outside the grant
|
|
51
83
|
* menu. Requesting one is a DISTINCT `request-red` (module 4) — the ledger
|
|
52
84
|
* counts admission demand, and a generic invalid-value would bury it as a typo. */
|
|
@@ -73,7 +105,10 @@ export const PROVIDERS = Object.freeze(['anthropic-api', 'clipipe-subscription']
|
|
|
73
105
|
* candidate at N3. `provider` is part of the key by definition (top-level,
|
|
74
106
|
* not duplicated here). */
|
|
75
107
|
export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', 'taskFraming', 'scaffold']);
|
|
76
|
-
|
|
108
|
+
// `steps` stays in the field list ONLY so a retired spec reds by name
|
|
109
|
+
// (`shape-retired`) instead of falling through to a generic unknown-field —
|
|
110
|
+
// the operator gets told what happened, not just that something is wrong.
|
|
111
|
+
const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'checks', 'tools'];
|
|
77
112
|
/** the four-field plan shape's core (decision 5) — presence of any of these
|
|
78
113
|
* declares the shape; `tools` (the ceiling) rides the shape but alone does not
|
|
79
114
|
* declare it, so a legacy spec carrying it gets a pointed red, not a conflict */
|
|
@@ -86,10 +121,6 @@ const CLOSE_FIELDS = {
|
|
|
86
121
|
rubric: ['type', 'criteria'],
|
|
87
122
|
hitl: ['type', 'prompt'],
|
|
88
123
|
};
|
|
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'];
|
|
93
124
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
94
125
|
|
|
95
126
|
/** @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) */
|
|
@@ -159,6 +190,19 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
|
|
|
159
190
|
red('bounds', 'budgetUsd', `0 < budget <= shell cap ${shellCapUsd} (cap-not-estimate; no self-adjusted budgets, ever)`);
|
|
160
191
|
}
|
|
161
192
|
|
|
193
|
+
// T (PRD v1.27/v1.29) — TIME, with exactly budgetUsd's status: operator input,
|
|
194
|
+
// the agent may tighten and never widen, never self-raised. OPTIONAL AND WITH NO
|
|
195
|
+
// DEFAULT, decided by hamr 2026-07-26: a defaulted cap is a silent second
|
|
196
|
+
// ceiling, which is the F45 failure (a money rule with no wired detector let
|
|
197
|
+
// four cut rows be classed as evidence). A spec without it is time-unbounded by
|
|
198
|
+
// explicit operator CHOICE — so nothing here mints a fallback, and the runner
|
|
199
|
+
// reports the absence rather than letting it be inferred.
|
|
200
|
+
if (spec.maxWallMs !== undefined
|
|
201
|
+
&& !(typeof spec.maxWallMs === 'number' && Number.isInteger(spec.maxWallMs) && spec.maxWallMs >= MIN_WALL_MS)) {
|
|
202
|
+
red('bounds', 'maxWallMs',
|
|
203
|
+
`integer milliseconds >= ${MIN_WALL_MS} (one close timeout). Enforcement is a between-round deadline, measured as maxWallMs + closeStages x closeTimeoutMs (design addendum 1: loop.stop() cannot cut an in-flight call; W5: every close stage runs under the full timeout), so a budget under one close cannot fund its own close and the advertised number would be more wrong than right`);
|
|
204
|
+
}
|
|
205
|
+
|
|
162
206
|
// 3. the outer write fence — operator law (interview decision #4), same
|
|
163
207
|
// containment rules as the workflow layer THROUGH THE SAME CODE (F9: two
|
|
164
208
|
// transforms would let a fence validate green and mean something else).
|
|
@@ -181,75 +225,18 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
|
|
|
181
225
|
if (esc.mode !== 'decision-ready') red('invalid-value', 'escalation.mode', 'must be "decision-ready"');
|
|
182
226
|
}
|
|
183
227
|
|
|
184
|
-
// 4. the
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
|
|
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) {
|
|
200
|
-
red('missing-required', 'steps', 'non-empty array — a job without closes is ungated spend');
|
|
228
|
+
// 4. the plan shape — the ONLY shape (PRD v1.32, 2026-07-26). The legacy
|
|
229
|
+
// operator-authored steps[] chain was deleted with the rest of that path:
|
|
230
|
+
// hand-writing the workflow is the exact thing the product removes, and its
|
|
231
|
+
// co-existence forced every downstream schema decision to carry two spellings.
|
|
232
|
+
// `steps` is now an unknown field like any other, so an old spec reds by name
|
|
233
|
+
// rather than half-running.
|
|
234
|
+
if (spec.steps !== undefined) {
|
|
235
|
+
red('shape-retired', 'steps', 'operator-authored steps[] was deleted (PRD v1.32) — a job declares goal/verdictType/close and the AGENT authors the steps. hitl and soft-green return as a Layer 3 decision, rebuilt in this shape');
|
|
236
|
+
} else if (PLAN_CORE_FIELDS.some((f) => spec[f] !== undefined) || spec.tools !== undefined) {
|
|
237
|
+
validatePlanShape(spec, red, reds);
|
|
201
238
|
} else {
|
|
202
|
-
|
|
203
|
-
spec.steps.forEach((/** @type {any} */ s, /** @type {number} */ i) => {
|
|
204
|
-
const at = `steps.${i}`;
|
|
205
|
-
if (!isObj(s)) { red('invalid-value', at, 'step must be an object'); return; }
|
|
206
|
-
for (const key of Object.keys(s)) {
|
|
207
|
-
if (!['id', 'close', 'class', 'mode', 'tools'].includes(key)) red('unknown-field', `${at}.${key}`);
|
|
208
|
-
}
|
|
209
|
-
// mode/tools — the spec-side tool grant (2b decision #2: the human job
|
|
210
|
-
// spec owns mode + menu; the drafted config cannot express either)
|
|
211
|
-
const isHitl = isObj(s.close) && s.close.type === 'hitl';
|
|
212
|
-
if ((s.mode !== undefined || s.tools !== undefined) && isHitl) {
|
|
213
|
-
red('invalid-value', `${at}.mode`, 'hitl steps run no loop — mode/tools do not apply');
|
|
214
|
-
} else {
|
|
215
|
-
if (s.mode !== undefined && !STEP_MODES.includes(s.mode)) red('invalid-value', `${at}.mode`, STEP_MODES.join('|'));
|
|
216
|
-
if (s.tools !== undefined) {
|
|
217
|
-
if (s.mode !== 'tools') red('invalid-value', `${at}.tools`, 'a tool grant requires mode "tools" — a grant without the mode is incoherent');
|
|
218
|
-
else if (!(Array.isArray(s.tools) && s.tools.length > 0
|
|
219
|
-
&& s.tools.every((/** @type {unknown} */ t) => typeof t === 'string')
|
|
220
|
-
&& new Set(s.tools).size === s.tools.length)) {
|
|
221
|
-
red('invalid-value', `${at}.tools`, `non-empty unique subset of ${TOOL_MENU.join('|')}`);
|
|
222
|
-
} else {
|
|
223
|
-
// locked-but-listed asks red DISTINCTLY: request-red is the admission
|
|
224
|
-
// evidence the ledger tallies (two-red routing); a typo stays invalid-value
|
|
225
|
-
for (const t of s.tools.filter((/** @type {string} */ t) => LOCKED_TOOLS.includes(t))) {
|
|
226
|
-
// verb as a structured field: the ledger keys admission demand on it;
|
|
227
|
-
// parsing it back out of the prose detail would break on a rewording
|
|
228
|
-
reds.push({ code: 'request-red', path: `${at}.tools`, verb: t, detail: `"${t}" is locked-but-listed — this red IS the admission evidence, never a grant; granted menu: ${TOOL_MENU.join('|')}` });
|
|
229
|
-
}
|
|
230
|
-
const unknown = s.tools.filter((/** @type {string} */ t) => !TOOL_MENU.includes(t) && !LOCKED_TOOLS.includes(t));
|
|
231
|
-
if (unknown.length) red('invalid-value', `${at}.tools`, `unknown tool(s) ${unknown.join(', ')} — menu: ${TOOL_MENU.join('|')}`);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
if (!isNonEmptyString(s.id) || !SLUG_RE.test(s.id)) red('invalid-value', `${at}.id`, 'kebab-case slug');
|
|
236
|
-
else if (seen.has(s.id)) red('duplicate-id', `${at}.id`, s.id);
|
|
237
|
-
else seen.add(s.id);
|
|
238
|
-
|
|
239
|
-
if (s.class === undefined) red('missing-required', `${at}.class`);
|
|
240
|
-
else if (!CLASSES.includes(s.class)) red('invalid-value', `${at}.class`, CLASSES.join('|'));
|
|
241
|
-
|
|
242
|
-
if (!isObj(s.close)) { red('missing-required', `${at}.close`, 'every step names its close — a step without one is ungated spend'); return; }
|
|
243
|
-
const close = s.close;
|
|
244
|
-
if (!validateClose(close, `${at}.close`, red)) return;
|
|
245
|
-
// the hierarchy: one check covers both directions — a class outside the
|
|
246
|
-
// close type's menu is laundering (rubric-as-hard) or delegation
|
|
247
|
-
// (hitl-class on a script close); hitl ⇔ hitl falls out of the menus.
|
|
248
|
-
const legal = CLASS_BY_CLOSE[close.type];
|
|
249
|
-
if (CLASSES.includes(s.class) && !legal.includes(s.class)) {
|
|
250
|
-
red('close-hierarchy', `${at}.class`, `${close.type} close admits class ${legal.join('|')} only (a rubric can never be hard; a human IS the hitl close — PRD §7)`);
|
|
251
|
-
}
|
|
252
|
-
});
|
|
239
|
+
red('missing-required', 'goal', 'a job declares goal/verdictType/close — the agent authors the steps');
|
|
253
240
|
}
|
|
254
241
|
|
|
255
242
|
// 5. secrets sweep — the SAME shared guard the workflow validator runs
|
|
@@ -368,10 +355,123 @@ function predicateBody(o, at, red) {
|
|
|
368
355
|
}
|
|
369
356
|
|
|
370
357
|
/**
|
|
371
|
-
* The
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
358
|
+
* The staged close (PRD v1.28) — the close is an ORDERED LIST of named stages,
|
|
359
|
+
* and the check menu DERIVES from it. The principle: *the user authors the
|
|
360
|
+
* destination, never the road.* A user who cannot describe a workflow can still
|
|
361
|
+
* say what done looks like, and everything downstream of that is derived — which
|
|
362
|
+
* is why `checks[]` no longer exists as a field (hamr: *"there shouldn't be user
|
|
363
|
+
* authoring anywhere, that defies the point of bareloop"*).
|
|
364
|
+
*
|
|
365
|
+
* Two stage classes cannot be handed to the agent as a mid-build ruler, and F58
|
|
366
|
+
* measured both against a real close:
|
|
367
|
+
* - a PRECONDITION ("the seed commit exists") is not a ruler for the work; run
|
|
368
|
+
* as a check it passes instantly and teaches nothing. It sets `offer: false`.
|
|
369
|
+
* - a stage that reads what an EARLIER stage built ("the public API matches the
|
|
370
|
+
* emitted declarations") reds on its own for a reason that has nothing to do
|
|
371
|
+
* with the worker's edit. It names `needs`, and picking it runs that chain first.
|
|
372
|
+
* Everything else is offerable: derivation gave job #5 SEVEN rulers where the
|
|
373
|
+
* operator had hand-carved three (F58) — removing the operator does not cost the
|
|
374
|
+
* agent rulers, it hands it more.
|
|
375
|
+
*
|
|
376
|
+
* The `needs` expansion is TRANSITIVE (fixed point, not one level): a
|
|
377
|
+
* prerequisite may itself name one, and running an incomplete chain reproduces
|
|
378
|
+
* the exact false-red `needs` exists to remove, one level deeper. The expanded
|
|
379
|
+
* chain is ordered by the CLOSE, never by the order names were typed —
|
|
380
|
+
* validateStagedClose forces `needs` to name EARLIER stages, so close order IS
|
|
381
|
+
* a topological order. A `needs` cycle is inexpressible in a validated close for
|
|
382
|
+
* that same reason; the walk is still cycle-safe by construction (each stage is
|
|
383
|
+
* collected at most once), because a pure exported helper must terminate on
|
|
384
|
+
* whatever it is handed rather than hang the runner before any token burns.
|
|
385
|
+
*
|
|
386
|
+
* @param {any[]} close the validated stage list
|
|
387
|
+
* @returns {{name: string, run: any[]}[]} offerable stages, each with the ordered
|
|
388
|
+
* chain that must run for it (prerequisites first, the stage itself last)
|
|
389
|
+
*/
|
|
390
|
+
export function checkMenu(close) {
|
|
391
|
+
if (!Array.isArray(close)) return [];
|
|
392
|
+
const named = close.filter((s) => isObj(s) && isNonEmptyString(s.name));
|
|
393
|
+
const byName = new Map(named.map((s) => [s.name, s]));
|
|
394
|
+
const orderOf = new Map(named.map((s, i) => [s, i]));
|
|
395
|
+
/** the transitive prerequisite closure of one stage, in close order */
|
|
396
|
+
const chain = (/** @type {any} */ stage) => {
|
|
397
|
+
/** @type {Set<any>} */
|
|
398
|
+
const collected = new Set();
|
|
399
|
+
const walk = (/** @type {any} */ s) => {
|
|
400
|
+
for (const n of Array.isArray(s.needs) ? s.needs : []) {
|
|
401
|
+
const p = byName.get(n);
|
|
402
|
+
// already collected (a diamond) or the picked stage itself (a cycle):
|
|
403
|
+
// the walk stops here, so it visits each stage at most once
|
|
404
|
+
if (!p || p === stage || collected.has(p)) continue;
|
|
405
|
+
collected.add(p);
|
|
406
|
+
walk(p);
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
walk(stage);
|
|
410
|
+
return [...collected].sort((a, b) => (orderOf.get(a) ?? 0) - (orderOf.get(b) ?? 0));
|
|
411
|
+
};
|
|
412
|
+
return close
|
|
413
|
+
.filter((s) => isObj(s) && s.offer !== false && isNonEmptyString(s.name))
|
|
414
|
+
.map((s) => ({ name: s.name, run: [...chain(s), s] }));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** the fields a close STAGE may carry — anything else is a smuggle channel */
|
|
418
|
+
const STAGE_FIELDS = ['name', 'cmd', 'expect', 'judged', 'gapKeep', 'offer', 'needs'];
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Validate the staged close. Every stage is a predicate BODY under the same
|
|
422
|
+
* rules the single close object has always used (one contract, never a second
|
|
423
|
+
* copy — the F9 two-transforms class applied to the arbiter itself).
|
|
424
|
+
* @param {any[]} stages
|
|
425
|
+
* @param {(code: string, path: string, detail?: string) => void} red
|
|
426
|
+
*/
|
|
427
|
+
function validateStagedClose(stages, red) {
|
|
428
|
+
if (stages.length === 0) {
|
|
429
|
+
red('missing-required', 'close', 'a non-empty ordered list of named stages — a job with nothing to run is ungated spend');
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const seen = new Set();
|
|
433
|
+
stages.forEach((s, i) => {
|
|
434
|
+
const at = `close.${i}`;
|
|
435
|
+
if (!isObj(s)) { red('invalid-value', at, 'a stage is an object { name, cmd, expect, ... }'); return; }
|
|
436
|
+
for (const key of Object.keys(s)) {
|
|
437
|
+
if (!STAGE_FIELDS.includes(key)) red('unknown-field', `${at}.${key}`, `not a stage field (script bodies, close types and minting claims land here); fields: ${STAGE_FIELDS.join(', ')}`);
|
|
438
|
+
}
|
|
439
|
+
if (!isNonEmptyString(s.name) || !SLUG_RE.test(s.name)) red('invalid-value', `${at}.name`, 'kebab-case slug — the plan references stages by name (check-passes(name))');
|
|
440
|
+
else if (seen.has(s.name)) red('duplicate-id', `${at}.name`, `"${s.name}" is already a stage name — check-passes(name) would be ambiguous`);
|
|
441
|
+
else seen.add(s.name);
|
|
442
|
+
// every stage is a COMMAND whose exit code is truth: a rubric or hitl stage
|
|
443
|
+
// is inexpressible here by construction, which is the hierarchy enforced by
|
|
444
|
+
// shape rather than by a check
|
|
445
|
+
predicateBody(s, at, red);
|
|
446
|
+
if (s.offer !== undefined && typeof s.offer !== 'boolean') {
|
|
447
|
+
red('invalid-value', `${at}.offer`, 'boolean — omit it (the default) unless the stage cannot stand alone as a ruler');
|
|
448
|
+
}
|
|
449
|
+
if (s.needs !== undefined) {
|
|
450
|
+
const earlier = new Set(stages.slice(0, i).filter((p) => isObj(p) && isNonEmptyString(p.name)).map((p) => p.name));
|
|
451
|
+
if (!Array.isArray(s.needs) || s.needs.length === 0 || !s.needs.every((n) => typeof n === 'string')) {
|
|
452
|
+
red('invalid-value', `${at}.needs`, 'non-empty array of EARLIER stage names — omit the field when the stage stands alone');
|
|
453
|
+
} else if (s.offer === false) {
|
|
454
|
+
red('invalid-value', `${at}.needs`, 'a stage that is never offered has no chain to run — needs and offer:false are incoherent together');
|
|
455
|
+
} else {
|
|
456
|
+
const bad = s.needs.filter((n) => !earlier.has(n));
|
|
457
|
+
if (bad.length) red('invalid-value', `${at}.needs`, `must name stages declared BEFORE this one (a prerequisite that has not run yet cannot be one): ${bad.join(', ')} — earlier stages: ${[...earlier].join(', ') || 'none'}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The plan-shaped job spec (design record 2026-07-21, decision 5): goal /
|
|
465
|
+
* verdictType / close (+ the top-level tool ceiling). The fourth field of the
|
|
466
|
+
* original four, `checks`, is RETIRED — the check menu derives from the close's
|
|
467
|
+
* stages instead (PRD v1.28/v1.32), and a spec that still declares it gets the
|
|
468
|
+
* `checks-derived` red below rather than a bare unknown-field.
|
|
469
|
+
*
|
|
470
|
+
* v1 admits only verdictType `green`; soft-green/hitl are declared-but-locked
|
|
471
|
+
* and red as `request-red` — admission demand the ledger counts, never a grant.
|
|
472
|
+
* The close is validated one level up as well: a declared verdict demands a
|
|
473
|
+
* close CLASS, so green-on-rubric reds as `close-hierarchy` rather than
|
|
474
|
+
* laundering a soft judgement into a hard one.
|
|
375
475
|
* @param {Record<string, any>} spec
|
|
376
476
|
* @param {(code: string, path: string, detail?: string) => void} red
|
|
377
477
|
* @param {Red[]} reds direct access for structured request-reds
|
|
@@ -396,7 +496,12 @@ function validatePlanShape(spec, red, reds) {
|
|
|
396
496
|
}
|
|
397
497
|
|
|
398
498
|
if (spec.close === undefined) red('missing-required', 'close', 'declared green with nothing to run it — preflight validates the declaration, never infers');
|
|
399
|
-
|
|
499
|
+
// The staged close (PRD v1.28): an ordered list of named command stages, from
|
|
500
|
+
// which the check menu derives. The object form remains ONLY for the
|
|
501
|
+
// declared-but-locked verdict classes (gold/rubric/hitl), which name no
|
|
502
|
+
// command and which the plan flow refuses at runtime (close-unsupported).
|
|
503
|
+
else if (Array.isArray(spec.close)) validateStagedClose(spec.close, red);
|
|
504
|
+
else if (!isObj(spec.close)) red('invalid-value', 'close', 'an ordered list of named stages [{ name, cmd, expect, ... }], or a close object for a locked verdict class');
|
|
400
505
|
else if (validateClose(spec.close, 'close', red) && demanded !== undefined
|
|
401
506
|
&& !CLASS_BY_CLOSE[spec.close.type].includes(demanded)) {
|
|
402
507
|
// the hierarchy one level up: the declared verdict demands a close class
|
|
@@ -404,23 +509,15 @@ function validatePlanShape(spec, red, reds) {
|
|
|
404
509
|
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
510
|
}
|
|
406
511
|
|
|
512
|
+
// `checks[]` is RETIRED (PRD v1.28/v1.32): the check menu DERIVES from the
|
|
513
|
+
// close's stages, so no operator authors checks per job, ever. The red names
|
|
514
|
+
// what happened rather than leaving a bare unknown-field — and the hazard it
|
|
515
|
+
// removes is measured, not theoretical: job #5's three hand-written checks
|
|
516
|
+
// were re-implementations of three stages the close already ran, and a
|
|
517
|
+
// hand-carved copy can drift LENIENT (the worker passes the operator's ruler
|
|
518
|
+
// and fails the real inspection). Derivation removes that class by construction.
|
|
407
519
|
if (spec.checks !== undefined) {
|
|
408
|
-
|
|
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
|
-
}
|
|
520
|
+
red('checks-derived', 'checks', 'hand-authored checks are retired — declare the close as an ordered list of named stages and the check menu derives from it (the agent references them with check-passes(name)); a stage that cannot stand alone sets offer:false, one that needs an earlier stage names it in needs');
|
|
424
521
|
}
|
|
425
522
|
|
|
426
523
|
// the tool CEILING (plan-v1 anchor: every plan step's verbs ⊆ this) — same
|
|
@@ -440,8 +537,8 @@ function validatePlanShape(spec, red, reds) {
|
|
|
440
537
|
// scout surveys read-only (the write-class verbs are filtered out of its
|
|
441
538
|
// menu), so a write-only ceiling hands the scout an EMPTY menu and it
|
|
442
539
|
// surveys blind — a degradation the validator catches at sign time.
|
|
443
|
-
else if (!spec.tools.some((/** @type {string} */ t) => TOOL_MENU.includes(t) && !
|
|
444
|
-
red('invalid-value', 'tools', 'a plan-shape ceiling must grant ≥1 read-capable verb
|
|
540
|
+
else if (!spec.tools.some((/** @type {string} */ t) => TOOL_MENU.includes(t) && !WRITE_VERBS.includes(t) && !STORE_VERBS.includes(t))) {
|
|
541
|
+
red('invalid-value', 'tools', 'a plan-shape ceiling must grant ≥1 read-capable verb — the scout surveys read-only; write-class and store-class verbs leave it blind');
|
|
445
542
|
}
|
|
446
543
|
}
|
|
447
544
|
}
|
|
@@ -474,9 +571,32 @@ function canon(v) {
|
|
|
474
571
|
}
|
|
475
572
|
|
|
476
573
|
/**
|
|
477
|
-
*
|
|
478
|
-
*
|
|
479
|
-
*
|
|
574
|
+
* The spec as the RUNTIME reads it — the form both hash paths canonize (MED-1).
|
|
575
|
+
* `tools` is optional and its absence means the whole TOOL_MENU: plan.js and
|
|
576
|
+
* planrun.js resolve it with this exact predicate, so an omitted-`tools` spec's
|
|
577
|
+
* ceiling GROWS whenever the menu does while its bytes never move. Hashing the
|
|
578
|
+
* unresolved form leaves that widening unsigned; hashing the resolved form pins
|
|
579
|
+
* WHICH menu was signed, so a menu change flips the hash into the existing
|
|
580
|
+
* refuse-until-reapproved machinery. A spec that named its own ceiling is
|
|
581
|
+
* untouched — no migration for a signature already in flight.
|
|
582
|
+
* @param {any} job
|
|
583
|
+
* @returns {any}
|
|
584
|
+
*/
|
|
585
|
+
function resolveSpec(job) {
|
|
586
|
+
if (!isObj(job) || Array.isArray(job.tools)) return job;
|
|
587
|
+
return { ...job, tools: [...TOOL_MENU] };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Content hash of a job spec — what an approval record binds to. Any SEMANTIC
|
|
592
|
+
* edit changes the hash, so a spec whose meaning moved is unapproved by
|
|
593
|
+
* construction (interview decision #1: human signs, always, once per spec
|
|
594
|
+
* VERSION). Not every BYTE edit: the hash is taken over the RESOLVED spec
|
|
595
|
+
* (resolveSpec), so writing today's full TOOL_MENU into a spec that omitted
|
|
596
|
+
* `tools` is hash-neutral — the two spell the same ceiling, and that identity
|
|
597
|
+
* is the point of MED-1, since it is what pins WHICH menu the signature covers.
|
|
598
|
+
* The direction that matters still bites: the menu itself changing (or the
|
|
599
|
+
* explicit list changing) moves the resolved form, and the hash with it.
|
|
480
600
|
* @param {object} job
|
|
481
601
|
* @returns {string} sha256 hex
|
|
482
602
|
*/
|
|
@@ -485,8 +605,9 @@ export function jobSpecHash(job) {
|
|
|
485
605
|
// directly on the raw job object, so a non-JSON value (BigInt, a cycle, a
|
|
486
606
|
// throwing getter) must yield a hash, not crash the signer. canon handles
|
|
487
607
|
// JSON-representable inputs; the guard covers cycles and hostile getters.
|
|
608
|
+
// resolveSpec sits INSIDE the guard: it spreads, and a spread runs getters.
|
|
488
609
|
let c;
|
|
489
|
-
try { c = canon(job); } catch { c = '\u0000unhashable'; }
|
|
610
|
+
try { c = canon(resolveSpec(job)); } catch { c = '\u0000unhashable'; }
|
|
490
611
|
return createHash('sha256').update(c).digest('hex');
|
|
491
612
|
}
|
|
492
613
|
|
|
@@ -503,9 +624,11 @@ export function checkApproval(job, approvals) {
|
|
|
503
624
|
if (!Array.isArray(approvals)) return false;
|
|
504
625
|
// NOT jobSpecHash: its un-hashable sentinel would make every un-hashable
|
|
505
626
|
// spec hash-equal — an approval minted for one would authorize any other.
|
|
506
|
-
// Here un-hashable simply means unapproved, full stop.
|
|
627
|
+
// Here un-hashable simply means unapproved, full stop. The RESOLVED spec is
|
|
628
|
+
// what gets canonized, same as the signer's (MED-1) — a gate reading a
|
|
629
|
+
// different form than the mint would unapprove every omitted-`tools` spec.
|
|
507
630
|
let c;
|
|
508
|
-
try { c = canon(job); } catch { return false; }
|
|
631
|
+
try { c = canon(resolveSpec(job)); } catch { return false; }
|
|
509
632
|
const h = createHash('sha256').update(c).digest('hex');
|
|
510
633
|
return approvals.some((a) => isObj(a) && /** @type {Record<string, unknown>} */ (a).specHash === h);
|
|
511
634
|
}
|
package/src/ledger.js
CHANGED
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
import { readFileSync, appendFileSync, existsSync } from 'node:fs';
|
|
23
23
|
import { basename } from 'node:path';
|
|
24
24
|
import { createHash } from 'node:crypto';
|
|
25
|
-
import { VERBS } from './validate.js';
|
|
26
25
|
|
|
27
26
|
/** severity order, worst-first (the fold's display order; frequency ranks within) */
|
|
28
27
|
export const LEDGER_CLASSES = Object.freeze([
|
|
@@ -50,7 +49,14 @@ const normalizeDetail = (/** @type {unknown} */ s) => String(s ?? '')
|
|
|
50
49
|
.slice(0, DETAIL_MAX);
|
|
51
50
|
const sigOf = (/** @type {string} */ normalized) => createHash('sha256').update(normalized).digest('hex').slice(0, 8);
|
|
52
51
|
|
|
53
|
-
|
|
52
|
+
// The prose fallback for attributing a store failure to a verb. This list came
|
|
53
|
+
// from config-v1's hook vocabulary, which was deleted with that path (PRD
|
|
54
|
+
// v1.32); it is relocated here VERBATIM rather than re-derived, so classification
|
|
55
|
+
// behaviour is byte-identical across the deletion. Re-aiming it at litectx's own
|
|
56
|
+
// verb names would be a behavioural change and belongs in its own commit — two
|
|
57
|
+
// levers in one diff make the delta unattributable to either.
|
|
58
|
+
const SNIFF_VERBS = Object.freeze(['recall', 'compress', 'stash', 'remember']);
|
|
59
|
+
const VERB_RE = new RegExp(`\\b(${SNIFF_VERBS.join('|')})\\b`);
|
|
54
60
|
const QUOTED_VERB_RE = /"([a-z0-9-]+)"/;
|
|
55
61
|
|
|
56
62
|
/** The deliberate escalation exclusions as a RUNTIME set, not prose: anything
|
|
@@ -59,6 +65,16 @@ const QUOTED_VERB_RE = /"([a-z0-9-]+)"/;
|
|
|
59
65
|
* explains WHY each one classifies to nothing. */
|
|
60
66
|
const EXCLUDED_ESCALATIONS = new Set([
|
|
61
67
|
'cap-halt', // a budget story, not a lib bug
|
|
68
|
+
'wall-halt', // the TIME budget's version of the same story (T, PRD v1.27)
|
|
69
|
+
// A stall is the ABSENCE of beats, not an observed provider failure: nothing
|
|
70
|
+
// saw the transport fail, so the typed-lib route rendered a live ask reading
|
|
71
|
+
// "bare-agent: the provider path failed — worker stalled…" and aimed a bug at
|
|
72
|
+
// the wrong package. Excluded on the wall-halt shape (operator ruling
|
|
73
|
+
// 2026-07-31): the F66 fuse firing is OUR governance working, and the stall
|
|
74
|
+
// evidence still rides the spine's own escalation event, which is where a
|
|
75
|
+
// real upstream ask would be sourced from anyway.
|
|
76
|
+
'step-stalled',
|
|
77
|
+
'step-variance', // A's replan trigger: a planning story, never a lib bug
|
|
62
78
|
'gate-red', // governance working as intended
|
|
63
79
|
'smoke-red', // already counted via primitive-smoke
|
|
64
80
|
'hitl-close', // by design: a human is the close
|
|
@@ -86,7 +102,9 @@ const ASKS = /** @type {Record<string, (o: {lib: string, verb: string, detail: s
|
|
|
86
102
|
* bareloop vocabulary). Deliberate exclusions — bare cap-halt (a budget story),
|
|
87
103
|
* close-verdict reds and artifact-red (worker stories, the §5b line), gate-red
|
|
88
104
|
* (governance working as intended), pr-red (operator environment, not a suite
|
|
89
|
-
* lib), smoke-red escalations (already counted via primitive-smoke),
|
|
105
|
+
* lib), smoke-red escalations (already counted via primitive-smoke), step-stalled
|
|
106
|
+
* (our own fuse firing on an ABSENCE of beats — no observed lib failure to file),
|
|
107
|
+
* hitl-close
|
|
90
108
|
* and close-unsupported (by design) — classify to nothing.
|
|
91
109
|
* @param {any[]} events one spine's parsed events, in seq order
|
|
92
110
|
* @param {{spine?: string}} [opts] spine id stamped into samples
|
|
@@ -127,6 +145,13 @@ export function classifyIncidents(events, { spine = 'spine' } = {}) {
|
|
|
127
145
|
} else if (ev.category === 'pricing-red') {
|
|
128
146
|
add(ev, 'pricing-red', 'bare-agent', 'pricing', ev.decision ?? ev.detail);
|
|
129
147
|
} else if (ev.category === 'interpreter-red') {
|
|
148
|
+
// `step-stalled` (the F66 stall fuse) used to ride this branch on a
|
|
149
|
+
// typed-lib argument. It is now a named EXCLUSION: StallError stamps
|
|
150
|
+
// `lib:'bare-agent'` at the throw site, so the ask rendered as
|
|
151
|
+
// "bare-agent: the provider path failed — worker stalled…" — but a stall
|
|
152
|
+
// is the ABSENCE of beats; nothing observed the provider path fail, and
|
|
153
|
+
// the upstream package got aimed at for our own governance firing.
|
|
154
|
+
//
|
|
130
155
|
// the design split: a store verb in the detail → runtime-red (verb→lib
|
|
131
156
|
// map); a worker-loop/provider failure → provider-red; neither is
|
|
132
157
|
// still counted (runtime-red, lib unknown) — never silently dropped
|