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/ledger.js CHANGED
@@ -79,6 +79,14 @@ const EXCLUDED_ESCALATIONS = new Set([
79
79
  'smoke-red', // already counted via primitive-smoke
80
80
  'hitl-close', // by design: a human is the close
81
81
  'close-unsupported', // honest refusal, by design
82
+ // Close authoring (M4, D13) — the interview refusing a job we cannot close: a
83
+ // genre we do not run, a job with no seed to measure against, a locked kind.
84
+ // Honest refusal, exactly like `close-unsupported` above, and excluded for a
85
+ // SECOND reason that matters more: the demand is already counted once, as the
86
+ // `request-red` the same refusal emits with `lib: 'bareloop'` stamped at its
87
+ // emit site. Counting the escalation too would double every refusal in the one
88
+ // number the admission path exists to measure.
89
+ 'close-unauthorable',
82
90
  // Layer 3 (D2 split): the bridge load gate refused a wrong-KIND recipe at the door.
83
91
  // That is the mechanism WORKING — every recipe from a different day is a candidate for
84
92
  // refusal, and the run stopped having spent nothing. Filing it upstream would aim a bug
@@ -99,6 +107,11 @@ const EXCLUDED_ESCALATIONS = new Set([
99
107
  // in the registry. Both are the operator's own state speaking (a stale signature, an
100
108
  // edited registry), and both stop having run nothing.
101
109
  'resume-red',
110
+ // v1.57 §3 — the work branch could not be created: the patient is not a git checkout,
111
+ // or its branch namespace is too crowded to find a free name. The operator's own state
112
+ // speaking, exactly like `registry-red` below, and the run stopped having spent nothing.
113
+ // Filing it upstream would aim a bug report at a library for a patient nobody prepared.
114
+ 'branch-red',
102
115
  'registry-red', // the operator-supplied registry path does not exist — a typo, not a bug
103
116
  'envelope-red', // the envelope does not compose with the signed spec — operator input
104
117
  // the reuse runner also STOPS on the three answers no further try could change, and
@@ -117,9 +130,15 @@ const ASKS = /** @type {Record<string, (o: {lib: string, verb: string, detail: s
117
130
  'runtime-red': (o) => `${o.lib}: \`${o.verb}\` threw at runtime — ${o.detail}`,
118
131
  'provider-red': (o) => `bare-agent: the provider path failed — ${o.detail}`,
119
132
  'pricing-red': (o) => `bare-agent: a provider result carried no priced cost (unpriced is never free, F6) — ${o.detail}`,
120
- 'capability-gap': (o) => `bare-agent: locked \`${o.verb}\` was requested and the run cap-halted — admission candidate`,
133
+ // same rule as request-red below (capability-gap is its cap-halted form): the
134
+ // ask is seeded at the STAMPED target, so a bareloop-catalogue refusal cannot
135
+ // render as "bare-agent: …" — fixing the occurrence's lib alone would leave the
136
+ // misattribution intact in the one field a human actually files from
137
+ 'capability-gap': (o) => `${o.lib}: locked \`${o.verb}\` was requested and the run cap-halted — admission candidate`,
121
138
  'broken-close': (o) => `job owner: the close itself cannot run — ${o.detail}`,
122
- 'request-red': (o) => `admission: locked \`${o.verb}\` requested by a job spec — demand evidence, never a grant`,
139
+ // the target is the STAMPED lib, never a hardcoded package: a bareloop-catalogue
140
+ // refusal (a locked verdict type) must not seed an ask that reads "bare-agent: …"
141
+ 'request-red': (o) => `${o.lib}: admission — locked \`${o.verb}\` requested by a job spec — demand evidence, never a grant`,
123
142
  'retention-red': (o) => `litectx: on-green retention failed — ${o.detail}`,
124
143
  'config-red': (o) => `bareloop: drafting friction at ${o.verb} — ${o.detail}`,
125
144
  });
@@ -160,9 +179,20 @@ export function classifyIncidents(events, { spine = 'spine' } = {}) {
160
179
  // a repeated signature indicts the drafting prompt/schema, i.e. bareloop
161
180
  add(ev, 'config-red', 'bareloop', String(ev.path ?? 'config').split('.')[0] || 'config', `${ev.code} at ${ev.path}: ${ev.detail ?? ''}`);
162
181
  } else if (ev.type === 'job-red' && ev.code === 'request-red') {
163
- // the structured field wins; the prose-quoted verb stays as the fallback
164
- // for spines written before the field existed
165
- add(ev, 'request-red', 'bare-agent', ev.verb ?? (String(ev.detail ?? '').match(QUOTED_VERB_RE) ?? [])[1] ?? 'unknown', ev.detail);
182
+ // the structured fields win; the prose-quoted verb stays as the fallback
183
+ // for spines written before the field existed, and the hardcoded
184
+ // 'bare-agent' lib stays as the fallback for the same reason — it was the
185
+ // only territory `request-red` had when the field did not exist.
186
+ //
187
+ // One code, TWO territories: a locked TOOL verb is demand against the
188
+ // worker-surface package; a locked VERDICT type (and, when D13 lands, a
189
+ // genre refusal) is demand against bareloop's OWN catalogue and must never
190
+ // seed an upstream ask. The lib is stamped at the emit site (src/job.js)
191
+ // per the typed-lib rule — deriving it here from the code or the path is
192
+ // exactly the BA-2 misattribution class. A new bareloop-territory
193
+ // request-red therefore routes correctly by stamping its own lib, with no
194
+ // change to this branch.
195
+ add(ev, 'request-red', ev.lib ?? 'bare-agent', ev.verb ?? (String(ev.detail ?? '').match(QUOTED_VERB_RE) ?? [])[1] ?? 'unknown', ev.detail);
166
196
  } else if (ev.type === 'escalation') {
167
197
  if (ev.category === 'broken-close') {
168
198
  add(ev, 'broken-close', 'consumer', 'close', ev.detail);
@@ -211,10 +241,41 @@ export function classifyIncidents(events, { spine = 'spine' } = {}) {
211
241
 
212
242
  if (capHalted) {
213
243
  // capability-gap: the run hit its cap in a spine that also asked for a
214
- // locked verb — the strongest admission evidence there is (design table)
215
- for (const verb of new Set(occs.filter((o) => o.class === 'request-red').map((o) => o.verb))) {
216
- const halt = events.find((e) => e.type === 'cap-halt');
217
- add(halt, 'capability-gap', 'bare-agent', verb, `locked "${verb}" requested and the run cap-halted`);
244
+ // locked verb — the strongest admission evidence there is (design table).
245
+ // STILL DORMANT, exactly as the module header says: a request-red is a
246
+ // job-validation red, validateJob returns not-ok on any red and run.js
247
+ // returns on a validation red, so a request-red and a cap-halt cannot share
248
+ // one spine today. What follows is a latent-consistency fix at a documented
249
+ // dormant seam, not a live bug fix.
250
+ //
251
+ // The territory comes from the OCCURRENCE, never from a hardcoded package.
252
+ // capability-gap inherits request-red's two territories: a locked TOOL verb
253
+ // is demand against the worker-surface package, a locked VERDICT type is
254
+ // demand against bareloop's OWN catalogue. The request-red rows above
255
+ // already carry the lib stamped at their emit site (src/job.js), so reading
256
+ // it here is the typed-lib rule; re-hardcoding 'bare-agent' would seed an
257
+ // upstream ask for our own refusal — the BA-2 misattribution class. The
258
+ // sibling request-red site was fixed on this branch and this one was missed;
259
+ // a fix landing at one of two identical sites is the class already paid for
260
+ // once here (the ripgrep install that landed in ci.yml but not publish.yml).
261
+ //
262
+ // The dedup key is (verb, lib), not verb alone. The ledger's own occurrence
263
+ // key already carries lib, so folding on the verb would merge two
264
+ // territories into one row and attribute it to whichever lib the Set
265
+ // happened to hold first — an arbitrary target, which IS the defect. One
266
+ // gap per (territory, verb) costs nothing and each carries an honest one.
267
+ // The key is JSON, not a joined string: a delimiter that can appear in
268
+ // either half makes ("a-b","c") and ("a","b-c") the same key, and picking
269
+ // an "impossible" delimiter is a guess about names neither of these two
270
+ // vocabularies has promised to keep.
271
+ const halt = events.find((e) => e.type === 'cap-halt');
272
+ const seen = new Set();
273
+ // .filter() snapshots before add() appends, so the loop cannot feed itself
274
+ for (const o of occs.filter((x) => x.class === 'request-red')) {
275
+ const dedup = JSON.stringify([o.lib, o.verb]);
276
+ if (seen.has(dedup)) continue;
277
+ seen.add(dedup);
278
+ add(halt, 'capability-gap', o.lib, o.verb, `locked "${o.verb}" requested and the run cap-halted`);
218
279
  }
219
280
  }
220
281
  return occs;
package/src/plan.js CHANGED
@@ -25,7 +25,8 @@
25
25
  // plain parsed data; every failure is a named {code, path, detail} red.
26
26
 
27
27
  import { TOOL_MENU, LOCKED_TOOLS, WRITE_VERBS, checkMenu } from './job.js';
28
- import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals } from './validate.js';
28
+ import { declaredStages } from './declaredclose.js';
29
+ import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals, hasNestedQuantifier } from './validate.js';
29
30
 
30
31
  /** the closed exit menu (PRD v1.12 §3 + decision 1's `check-passes`): the
31
32
  * shell evaluates every form with its own fixed code, never a command — the
@@ -102,8 +103,28 @@ const STEP_FIELDS = ['id', 'action', 'tools', 'rounds', 'target', 'exit', 'model
102
103
  * construction — the agent picks a tier, never names an arbitrary model string
103
104
  * (the tier→model mapping and any effort params are the RUNNER's, per provider).
104
105
  * opus is deliberately absent: reserved for fresh builds hamr explicitly assigns,
105
- * never plan-selectable. */
106
- export const STEP_MODELS = Object.freeze(['sonnet', 'haiku']);
106
+ * never plan-selectable.
107
+ *
108
+ * 2026-08-06 — haiku dropped, on hamr's order: "haiku should be dropped to see if
109
+ * it passes, if it's agent poor work or harness, like always." This is a REVERSIBLE
110
+ * ATTRIBUTION PROBE, not a finding that haiku is incapable. What the $0 archive read
111
+ * (186 spines / 71 accepted plans / 255 steps) actually shows is a CONFOUND, in both
112
+ * directions: only 20 steps ever declared a tier at all — haiku 12 (9 of them on a
113
+ * REPLANNED plan, 6 the LAST step of their plan, bounds 3–12 rounds, 2 ever green)
114
+ * against explicitly-declared sonnet 8 (bounds 14–32, 5 green). The planner picks
115
+ * haiku for steps it judges mechanical, and replanned steps sit on runs already in
116
+ * trouble, so neither column is a controlled read. All three bareagent-u runs of
117
+ * 2026-08-06 tiered the REPLANNED step down (haiku/6, haiku/8, haiku/12) and all
118
+ * three failed, against sonnet initial steps at 30–40 rounds — a confound sitting
119
+ * on the programme's most recent capability read. Removing the tier is how it gets
120
+ * attributed: with only one tier expressible, a still-failing replan is agent or
121
+ * harness, not the tier.
122
+ *
123
+ * The `model` FIELD and its validator branch stay: a one-entry menu means a plan (or
124
+ * a stored bridge) declaring `model:"sonnet"` still validates, and restoring haiku is
125
+ * a one-token edit. This narrows only what the AGENT may express — `--model haiku`
126
+ * remains the OPERATOR's probe knob (PRD v1.36), untouched in the runner scripts. */
127
+ export const STEP_MODELS = Object.freeze(['sonnet']);
107
128
  const EXIT_FIELDS = {
108
129
  'artifact-written': ['type', 'path', 'pattern'],
109
130
  'tree-changed': ['type', 'scope'],
@@ -112,103 +133,14 @@ const EXIT_FIELDS = {
112
133
  };
113
134
  const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
114
135
 
115
- /** Length of an UNBOUNDED quantifier token at `src[i]` (`*`, `+`, or `{n,}`,
116
- * plus an optional trailing lazy `?`), or 0. Bounded forms (`?`, `{n}`,
117
- * `{n,m}`) return 0: they cannot drive exponential backtracking (a bounded
118
- * outer repeat is polynomial at worst, and the body is self-authored — F49).
119
- * @param {string} src @param {number} i */
120
- function unboundedQuantLen(src, i) {
121
- let len = src[i] === '*' || src[i] === '+' ? 1 : 0;
122
- if (!len && src[i] === '{') { const m = /^\{\d+,\}/.exec(src.slice(i)); if (m) len = m[0].length; }
123
- if (len && src[i + len] === '?') len++; // the lazy modifier is part of the same token
124
- return len;
125
- }
126
-
127
- /**
128
- * F49 — a heuristic reject for the catastrophic-backtracking footgun: an
129
- * unbounded quantifier applied to a group whose body itself repeats unboundedly
130
- * (`(a+)+`, `(\d*)*`, `([a-z]+){1,}`) — including through a redundant wrapping
131
- * group (`((a+))+`, `(?:(a+))*`), which is the SAME exponential class as `(a+)+`
132
- * and is caught by propagating the inner repeat up through the wrapper. Such a
133
- * pattern can hang `RegExp.test` for
134
- * seconds on a short crafted body, and `evalExits` runs the agent-authored
135
- * `artifact-written` pattern with NO timeout. This is self-DoS only — the agent
136
- * authors both the pattern and (via the worker) the artifact, so a hang burns
137
- * only its own run's wall-clock; there is NO arbiter compromise (it cannot
138
- * escape the fence, forge a green, or leak a secret). So the reject targets the
139
- * dominant exponential class and fails it as a mechanical gap at the validation
140
- * gate, before any tokens burn. A full ReDoS analyzer needs a real regex engine
141
- * (an external native dep we do not take for a LOW issue); exotic
142
- * overlapping-alternation blowup is out of scope by decision.
143
- *
144
- * SCOPE IS ASYMMETRIC, both directions named on purpose:
145
- * - false NEGATIVE: overlapping-alternation blowup (`(a|ab)+`-class) is not
146
- * detected — out of scope (self-DoS only, no arbiter compromise).
147
- * - false POSITIVE: a group whose repetitions are disambiguated by a literal
148
- * anchor/delimiter (`(?:^- .+$\n?)+`, `(?:CHANGELOG:.+\n)+`) is FLAGGED even
149
- * though a real engine runs it linearly — the scan sees the nested-quantifier
150
- * SHAPE, not the disambiguation. Rejecting it is the FAIL-SAFE direction (it
151
- * never admits an unsafe pattern), and the cost is bounded: the plan drafter
152
- * gets a mechanical gap and rewrites (drop the outer `+`, or match once).
153
- * Detecting "safe because anchored" needs the same real engine we declined,
154
- * and guessing it wrong would ADMIT an exponential pattern — so the shape
155
- * reject stands, and the over-rejection is a named, accepted limitation.
156
- *
157
- * The input is guaranteed to compile as a RegExp (checked first by the caller),
158
- * so this scan assumes valid, balanced JS regex syntax.
159
- * @param {string} src a compiled regex source string
160
- * @returns {boolean} true iff a nested unbounded quantifier is present
161
- */
162
- export function hasNestedQuantifier(src) {
163
- /** @type {{ quant: boolean }[]} */
164
- const stack = [];
165
- for (let i = 0; i < src.length; i++) {
166
- const c = src[i];
167
- if (c === '\\') { i++; continue; } // escaped atom — the next char is a literal
168
- if (c === '[') { // character class: quantifier chars inside are literals
169
- i++;
170
- if (src[i] === '^') i++;
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".
187
- while (i < src.length && src[i] !== ']') { if (src[i] === '\\') i++; i++; }
188
- continue;
189
- }
190
- if (c === '(') { stack.push({ quant: false }); continue; }
191
- if (c === ')') {
192
- const g = stack.pop();
193
- const qlen = unboundedQuantLen(src, i + 1);
194
- if (g && g.quant && qlen) return true; // group repeats unboundedly AND its body did too
195
- // The group is an unbounded-repeated atom of its parent when it is directly
196
- // re-quantified (qlen) OR its own body already repeats unboundedly (g.quant):
197
- // a redundant wrapper — ((a+))+ , (?:(a+))* , (((\d*)))+ — is the SAME
198
- // exponential class as (a+)+, so an inner repeat must propagate THROUGH the
199
- // enclosing group or the outer quantifier's close never sees it. Propagation
200
- // is MONOTONIC (it only ever SETS quant=true → only ever adds rejections): it
201
- // can widen over-rejection — the named FAIL-SAFE direction — but can never
202
- // introduce a false negative, which is the dangerous one (F49).
203
- if ((qlen || (g && g.quant)) && stack.length) stack[stack.length - 1].quant = true;
204
- i += qlen;
205
- continue;
206
- }
207
- const qlen = unboundedQuantLen(src, i); // a quantifier applying to the preceding atom, inside the current group
208
- if (qlen) { if (stack.length) stack[stack.length - 1].quant = true; i += qlen - 1; }
209
- }
210
- return false;
211
- }
136
+ /** F49's catastrophic-backtracking reject. RE-EXPORTED from validate.js, never
137
+ * re-declared: THREE untimed regex fields are split across the two documents —
138
+ * this one (`artifact-written.pattern`, agent-authored) and the operator's
139
+ * `judged.pattern` + `gapKeep` in job.js. plan.js imports job.js, so job.js
140
+ * cannot import plan.js; housing the scan in the shared primitives module is
141
+ * what lets both reject through ONE inventory (the SECRET_PATTERNS precedent —
142
+ * a second copy is a shape one side rejects and the other admits). */
143
+ export { hasNestedQuantifier };
212
144
 
213
145
  /**
214
146
  * The close, as the ordered STAGE LIST the plan flow actually executes (PRD
@@ -239,6 +171,30 @@ export function stageClose(close) {
239
171
  return [{ name: 'close', cmd: close.cmd, expect: close.expect, judged: close.judged, gapKeep: close.gapKeep }];
240
172
  }
241
173
 
174
+ /**
175
+ * THE ONE STAGING, one level up: a job's close stages, from EITHER of the two
176
+ * fields a spec may carry.
177
+ *
178
+ * `stageClose` reads a command close; `declaredStages` reads an authored
179
+ * declaration (M4). A spec declares exactly one of them — the job validator reds
180
+ * `close-duplicated` on both — so this is a selection, never a merge.
181
+ *
182
+ * Every consumer calls THIS rather than `stageClose` directly, for the reason
183
+ * W4 wrote `stageClose` in the first place: the drafting prompt, the plan
184
+ * validator, the runner, the bridge registry and the reuse envelope must all see
185
+ * the same stage list, and a consumer that reads only `close` sees a declared
186
+ * job as having no close at all — an empty check menu offered to a drafter whose
187
+ * plan the validator would then red for referencing a stage nobody offered.
188
+ * @param {any} job the job spec, any shape
189
+ * @returns {any[]|null} the stage list, or null when the job names no close
190
+ */
191
+ export function closeStagesOf(job) {
192
+ if (!isObj(job)) return null;
193
+ const declared = declaredStages(job.closeDecl);
194
+ if (declared) return declared;
195
+ return stageClose(job.close);
196
+ }
197
+
242
198
  /** @typedef {{code: string, path: string, detail?: string, verb?: string}} Red */
243
199
 
244
200
  /**
@@ -304,7 +260,7 @@ export function validatePlan(input, { job, maxStepRounds = 40, scopes, seedRed,
304
260
  // (gold/rubric/hitl) stages nothing, so it offers nothing — the runner
305
261
  // escalates `close-unsupported` on the same null, and an empty menu is already
306
262
  // the honest reading here (every check-passes becomes a check-unknown).
307
- const checkNames = checkMenu(stageClose(spec.close) ?? []).map((m) => m.name);
263
+ const checkNames = checkMenu(closeStagesOf(spec) ?? []).map((m) => m.name);
308
264
  // The offered scope menu. A caller-supplied menu must be the same one the
309
265
  // prompt enumerated; with none, derive from the signed fence — fail-CLOSED,
310
266
  // so omitting the option narrows the agent's choices and never widens them.