bareloop 0.7.0 → 0.9.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
@@ -14,7 +14,8 @@
14
14
  // judgment call. Minting policy is product doctrine, not job-authorable.
15
15
 
16
16
  import { createHash } from 'node:crypto';
17
- import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals } from './validate.js';
17
+ import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals, hasNestedQuantifier } from './validate.js';
18
+ import { validateCloseDecl, DECLARED_CLOSE_CLASSES } from './declaredclose.js';
18
19
 
19
20
  // The menus below ARE the close-authoring hierarchy (PRD §7) and ship frozen:
20
21
  // they are read at call time, so a mutable export would let adopter code
@@ -25,7 +26,20 @@ export const CLOSE_TYPES = Object.freeze(['predicate', 'gold', 'rubric', 'hitl']
25
26
  * type cannot legally claim (green-on-rubric is laundering). The `CLASSES`
26
27
  * vocabulary export went with steps[] (PRD v1.32): a class was a STEP field, and
27
28
  * nothing declares one now. */
28
- export const CLASS_BY_CLOSE = Object.freeze({ predicate: Object.freeze(['hard']), gold: Object.freeze(['hard']), rubric: Object.freeze(['soft']), hitl: Object.freeze(['hitl']) });
29
+ export const CLASS_BY_CLOSE = Object.freeze({
30
+ predicate: Object.freeze(['hard']),
31
+ gold: Object.freeze(['hard']),
32
+ rubric: Object.freeze(['soft']),
33
+ hitl: Object.freeze(['hitl']),
34
+ // The AUTHORED close (M4, `closeDecl`) is not a `close.type` — it is the other
35
+ // field a plan-shape spec may carry — but it claims a verdict class exactly
36
+ // like one, so it belongs in the SAME table rather than in a second constant
37
+ // beside it. Hard by construction: every live kind is a mechanical command
38
+ // whose measurement is the truth, and the two judgment kinds are locked out at
39
+ // the catalogue and absent from the executor. The suite pins the key set
40
+ // against CLOSE_TYPES so the two can never drift apart silently.
41
+ declared: DECLARED_CLOSE_CLASSES,
42
+ });
29
43
  export const GOLD_COMPARE = Object.freeze(['exact', 'json-equal']);
30
44
  /** the tool grant menu (2b interview #1): read/grep/write, plus the two litectx
31
45
  * RETRIEVAL verbs (F19) — `run` stays locked-but-listed; a spec requesting it
@@ -108,11 +122,11 @@ export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', '
108
122
  // `steps` stays in the field list ONLY so a retired spec reds by name
109
123
  // (`shape-retired`) instead of falling through to a generic unknown-field —
110
124
  // 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'];
125
+ const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools'];
112
126
  /** the four-field plan shape's core (decision 5) — presence of any of these
113
127
  * declares the shape; `tools` (the ceiling) rides the shape but alone does not
114
128
  * declare it, so a legacy spec carrying it gets a pointed red, not a conflict */
115
- const PLAN_CORE_FIELDS = ['goal', 'verdictType', 'close', 'checks'];
129
+ const PLAN_CORE_FIELDS = ['goal', 'verdictType', 'close', 'closeDecl', 'checks'];
116
130
  /** exact field set per close type — anything else is an unknown-field red
117
131
  * (freeform code, script bodies, and minting claims all land there) */
118
132
  const CLOSE_FIELDS = {
@@ -123,7 +137,12 @@ const CLOSE_FIELDS = {
123
137
  };
124
138
  const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
125
139
 
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) */
140
+ /** @typedef {{code: string, path: string, detail?: string, verb?: string, lib?: string}} Red — `verb` and `lib` ride request-reds as structured data (the ledger keys on them, never on prose).
141
+ * `lib` is the TERRITORY the demand lands against, stamped HERE at the emit site
142
+ * (the typed-lib rule): one code, two catalogues — a locked TOOL verb is demand
143
+ * against the worker-surface package, a locked VERDICT type is demand against
144
+ * bareloop's OWN menu. Inferring it downstream is how a bareloop refusal gets
145
+ * filed as an upstream bug (the BA-2 misattribution class). */
127
146
 
128
147
  /**
129
148
  * Validate an operator-owned job spec (`schema: "job-v1"`). Never throws on
@@ -278,6 +297,21 @@ function validateClose(close, at, red) {
278
297
  return true;
279
298
  }
280
299
 
300
+ /**
301
+ * The F49 reject's OPERATOR-side detail. ONE spelling for both regex fields —
302
+ * they differ only in which untimed evaluator the pattern reaches — so the two
303
+ * reds can never drift into saying different things about the same hazard.
304
+ *
305
+ * The second sentence is the part an operator cannot derive: on the agent side
306
+ * F49 is self-DoS, but these two patterns are compiled and run in BARELOOP'S OWN
307
+ * PROCESS, where the hang blocks the event loop the in-process stall fuse lives
308
+ * on (F67 — a guard sharing the fate of the process it guards). The run then
309
+ * dies to the outside watchdog and reads as a model stall, so without this red a
310
+ * bad regex in a SIGNED spec is diagnosed as a provider problem.
311
+ * @param {string} evaluator the consumer that would run it, named concretely
312
+ */
313
+ const redosDetail = (evaluator) => `nested unbounded quantifier (e.g. (a+)+ , (\\d*)* , (x+){1,}) — a catastrophic-backtracking footgun that can hang ${evaluator}, untimed (F49). It runs in bareloop's own process, so the hang blocks the event loop the stall fuse lives on (F67) and the run dies to the outside watchdog looking like a model stall; rewrite without a repeated group inside a repeat`;
314
+
281
315
  /**
282
316
  * The predicate BODY contract — cmd/expect/judged/gapKeep. Shared verbatim by
283
317
  * the predicate close and the Layer 2 named checks (decision 1: a check runs
@@ -330,6 +364,13 @@ function predicateBody(o, at, red) {
330
364
  // the fake green this floor exists to catch). Alternation stays
331
365
  // fully expressible with non-capturing branches: `(?:a|b) (\d+)`.
332
366
  else if (groups > 1) red('invalid-value', `${at}.judged.pattern`, `${groups} capture groups — the count is read from group 1 only; use exactly one capture group and non-capturing (?:…) branches`);
367
+ // F49, operator side: `runClose` execs this against the close's whole
368
+ // stdout+stderr with no timeout. Gated on `groups >= 0` — that is the
369
+ // COMPILED flag in disguise (the catch sets -1), and the scan documents
370
+ // that it assumes syntactically valid input.
371
+ if (groups >= 0 && hasNestedQuantifier(j.pattern)) {
372
+ red('invalid-value', `${at}.judged.pattern`, redosDetail('the close reader (runClose)'));
373
+ }
333
374
  }
334
375
  if (!Number.isInteger(j.min) || j.min < 1) {
335
376
  red('invalid-value', `${at}.judged.min`, 'integer >= 1 — a floor of 0 is satisfied by judging nothing, which is the check it is meant to make');
@@ -348,8 +389,15 @@ function predicateBody(o, at, red) {
348
389
  if (o.gapKeep !== undefined) {
349
390
  if (!isNonEmptyString(o.gapKeep)) red('invalid-value', `${at}.gapKeep`, 'regex source string — lines matching it survive the gap bound (F28)');
350
391
  else {
351
- try { new RegExp(o.gapKeep, 'm'); }
392
+ let compiled = false;
393
+ try { new RegExp(o.gapKeep, 'm'); compiled = true; }
352
394
  catch { red('invalid-value', `${at}.gapKeep`, 'must compile as a RegExp'); }
395
+ // F49, operator side and the measured one: `boundGap` runs this per LINE
396
+ // over the whole close stream, untimed. Measured: `(a+)+$` against a
397
+ // 40-char line does not finish in 20s.
398
+ if (compiled && hasNestedQuantifier(o.gapKeep)) {
399
+ red('invalid-value', `${at}.gapKeep`, redosDetail('the gap bound (boundGap), once per line of close output'));
400
+ }
353
401
  }
354
402
  }
355
403
  }
@@ -486,8 +534,9 @@ function validatePlanShape(spec, red, reds) {
486
534
  else if (LOCKED_VERDICTS.includes(spec.verdictType)) {
487
535
  // declared-but-locked (disclosure ≠ admission, the tool-menu pattern):
488
536
  // the type rides as a structured field — the ledger keys admission demand
489
- // on it, never on prose
490
- reds.push({ code: 'request-red', path: 'verdictType', verb: spec.verdictType, detail: `"${spec.verdictType}" is declared-but-locked — not at this rung (v1 admits green only); this red IS the admission evidence, never a grant` });
537
+ // on it, never on prose. `lib: 'bareloop'`: the refused catalogue is OURS
538
+ // (VERDICT_TYPES), so this demand is never an upstream ask.
539
+ reds.push({ code: 'request-red', path: 'verdictType', verb: spec.verdictType, lib: 'bareloop', detail: `"${spec.verdictType}" is declared-but-locked — not at this rung (v1 admits green only); this red IS the admission evidence, never a grant` });
491
540
  demanded = CLASS_BY_VERDICT[spec.verdictType];
492
541
  } else if (!VERDICT_TYPES.includes(spec.verdictType)) {
493
542
  red('invalid-value', 'verdictType', `menu: ${VERDICT_TYPES.join('|')} — an unknown type is a typo, never a request`);
@@ -495,7 +544,37 @@ function validatePlanShape(spec, red, reds) {
495
544
  demanded = CLASS_BY_VERDICT[spec.verdictType];
496
545
  }
497
546
 
498
- if (spec.close === undefined) red('missing-required', 'close', 'declared green with nothing to run it — preflight validates the declaration, never infers');
547
+ // THE TWO CLOSE FIELDS, and they are ALTERNATIVES (M4). `close` is the
548
+ // operator's own command stages; `closeDecl` is the AUTHORED declaration over
549
+ // owned kinds — the whole point of the close-authoring rung, and the field a
550
+ // user who is not an engineer ends up with. Both at once is refused rather
551
+ // than merged or precedence-ordered: a spec carrying two closes has two
552
+ // arbiters, and picking one silently is how the signed artefact stops meaning
553
+ // what the signer read.
554
+ if (spec.close !== undefined && spec.closeDecl !== undefined) {
555
+ red('close-duplicated', 'closeDecl', 'a job declares EITHER a command close (close) OR an authored declaration '
556
+ + '(closeDecl) — never both. Two closes are two arbiters, and the close is the only truth');
557
+ } else if (spec.closeDecl !== undefined) {
558
+ // The DECLARED close. The tree-grounded half of the gate (hamr's listing
559
+ // rule, and the scoped-job derivation that arms the F84 one-population law)
560
+ // is DEFERRED here and re-run by the runner against the real seed before any
561
+ // stage executes — a job spec is validated with no repository in hand, and a
562
+ // validator that quietly skipped those two rules would report a declaration
563
+ // it never examined. `grounded: false` is what the runner keys on.
564
+ // the class rides along: the mandatory guard battery keys off the verdict
565
+ // class the user picked (PRD v1.57 §2), so the gate cannot check D5 without
566
+ // it. An unknown or locked class is refused there, on its own axis, beside
567
+ // the counted `request-red` above.
568
+ const cd = validateCloseDecl(spec.closeDecl, { at: 'closeDecl', deferListing: true, verdictType: spec.verdictType });
569
+ for (const r of cd.reds) reds.push(r);
570
+ if (demanded !== undefined && !CLASS_BY_CLOSE.declared.includes(demanded)) {
571
+ // the same laundering guard as the close-type hierarchy, one level up: a
572
+ // declared close is HARD, so a soft/hitl verdict can never ride one
573
+ red('close-hierarchy', 'verdictType', `verdictType ${spec.verdictType} demands a ${demanded}-class close; an `
574
+ + `authored declaration admits ${CLASS_BY_CLOSE.declared.join('|')} only — every live kind is a mechanical `
575
+ + 'measurement, and the two judgment kinds are locked out of the catalogue (PRD §7)');
576
+ }
577
+ } else if (spec.close === undefined) red('missing-required', 'close', 'declared green with nothing to run it — preflight validates the declaration, never infers');
499
578
  // The staged close (PRD v1.28): an ordered list of named command stages, from
500
579
  // which the check menu derives. The object form remains ONLY for the
501
580
  // declared-but-locked verdict classes (gold/rubric/hitl), which name no
@@ -529,7 +608,9 @@ function validatePlanShape(spec, red, reds) {
529
608
  red('invalid-value', 'tools', `non-empty unique subset of ${TOOL_MENU.join('|')}`);
530
609
  } else {
531
610
  for (const t of spec.tools.filter((/** @type {string} */ t) => LOCKED_TOOLS.includes(t))) {
532
- reds.push({ code: 'request-red', path: 'tools', verb: t, detail: `"${t}" is locked-but-listed — this red IS the admission evidence, never a grant; granted menu: ${TOOL_MENU.join('|')}` });
611
+ // `lib: 'bare-agent'`: a locked TOOL verb is demand against the
612
+ // worker-surface package that would have to expose it (the original case)
613
+ reds.push({ code: 'request-red', path: 'tools', verb: t, lib: 'bare-agent', detail: `"${t}" is locked-but-listed — this red IS the admission evidence, never a grant; granted menu: ${TOOL_MENU.join('|')}` });
533
614
  }
534
615
  const unknown = spec.tools.filter((/** @type {string} */ t) => !TOOL_MENU.includes(t) && !LOCKED_TOOLS.includes(t));
535
616
  if (unknown.length) red('invalid-value', 'tools', `unknown tool(s) ${unknown.join(', ')} — menu: ${TOOL_MENU.join('|')}`);