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/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
- export const CLASSES = Object.freeze(['hard', 'soft', 'hitl']);
24
- /** the hierarchy: which verdict classes a close type may claim (PRD §7) */
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
- export const TOOL_MENU = Object.freeze(['read', 'grep', 'write', 'edit', 'recall', 'get']);
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
- const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'conditions', 'cadence', 'budgetUsd', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'checks', 'tools'];
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 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) {
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
- const seen = new Set();
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 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.
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
- else if (!isObj(spec.close)) red('invalid-value', 'close', 'a close object (type + its fixed fields)');
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
- 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
- }
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) && !['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');
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
- * Content hash of a job spec — what an approval record binds to. Any edit to
478
- * the spec changes the hash, so an edited spec is unapproved by construction
479
- * (interview decision #1: human signs, always, once per spec VERSION).
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
- const VERB_RE = new RegExp(`\\b(${VERBS.join('|')})\\b`);
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), hitl-close
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