bareloop 0.5.0 → 0.5.1
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 +24 -0
- package/bareloop.context.md +14 -9
- package/package.json +1 -1
- package/src/plan.js +91 -1
- package/src/planrun.js +118 -7
- package/src/run.js +1 -1
- package/types/plan.d.ts +36 -0
- package/types/planrun.d.ts +11 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ feature lands, **patch** = docs, fixes, scaffolding.
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.1] — 2026-07-24
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Layer R wired into the plan-v1 flow** (F50). The within-run ratchet (`layerRoot`) now
|
|
14
|
+
engages in `runPlan` — one root per EXECUTE step (red-set = the exit evaluator's own gap)
|
|
15
|
+
AND in the outer close-fix loop (red-set = the close's own `gapKeep`) — not only on the
|
|
16
|
+
legacy `steps[]` path. So a plan-shape job can emit `root-injected` and the pre-registered
|
|
17
|
+
ON-vs-OFF default-flip read becomes possible on the accepted surface. The write-tee is wired
|
|
18
|
+
so same-path target rewrites are visible to the detector. Still OFF by default (`layerRoot:
|
|
19
|
+
false`, F41). Excluded on native/clipipe (no `onToolResult` seam — F48 fallback surface).
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- **F49 (security-hardening): the agent-authored `artifact-written` regex can no longer hang
|
|
23
|
+
the exit evaluator.** `plan.js` rejects nested-quantifier ReDoS patterns (`(a+)+`, `(\d*)*`,
|
|
24
|
+
`(x+){1,}`, and redundant-wrapper forms like `((a+))+` / `(?:(a+))*` / `(((a+)))+`) at the
|
|
25
|
+
validation gate, before any tokens burn. The wrapper class was a review-caught false negative
|
|
26
|
+
in the first (flat-only) scan — each measured to hang `RegExp.test` >8s on ~29 chars — closed
|
|
27
|
+
by propagating an inner repeat up through the enclosing group; the change is MONOTONIC (it only
|
|
28
|
+
ever adds rejections, the fail-safe direction, and cannot introduce a new false negative). LOW
|
|
29
|
+
self-DoS, no arbiter compromise; input-bounding was rejected as theater (a 33-char body hangs)
|
|
30
|
+
and a JS regex timeout as disproportionate (worker/RE2 dep).
|
|
31
|
+
- **F50: `runJob` no longer silently ignores `layerRoot` for plan-shape jobs** (was accepted
|
|
32
|
+
and dropped — an advertised no-op).
|
|
33
|
+
|
|
10
34
|
## [0.5.0] — 2026-07-23
|
|
11
35
|
|
|
12
36
|
### Added
|
package/bareloop.context.md
CHANGED
|
@@ -173,7 +173,7 @@ middle writes; `validatePlan` gates it against the SIGNED job spec before tokens
|
|
|
173
173
|
| `steps[].tools` | non-empty unique subset of the SPEC ceiling | a verb beyond the ceiling reds `verb-escape` with the verb as structured data (overreach, distinct from the operator-side `request-red`); `run` is `verb-escape` at every layer |
|
|
174
174
|
| `steps[].rounds` | int 1..shell cap (default 40) | the step's per-attempt tool-round bound (the Gate's `maxTurns` natively) |
|
|
175
175
|
| `steps[].target` | path inside the fence | v1.18 deliverable; REQUIRED on write-granted steps |
|
|
176
|
-
| `steps[].exit` | 1..2 items (`MAX_EXITS_PER_STEP`), ALL must pass (AND-only, no OR/NOT) | closed menu (`EXIT_TYPES`): `artifact-written(path, pattern?)` · `tree-changed(scope)` · `json-valid(path)` · `check-passes(name)`. `check-passes` must name a SIGNED check (`check-unknown` red names the signed menu); on a write-granted step it must be paired with `tree-changed` (`exit-illegal` — the seed tree is green, a lone check would pass untouched, F17/F46). Exits verify FORM, not truth — progress gates; the operator's close stays the one arbiter |
|
|
176
|
+
| `steps[].exit` | 1..2 items (`MAX_EXITS_PER_STEP`), ALL must pass (AND-only, no OR/NOT) | closed menu (`EXIT_TYPES`): `artifact-written(path, pattern?)` · `tree-changed(scope)` · `json-valid(path)` · `check-passes(name)`. `check-passes` must name a SIGNED check (`check-unknown` red names the signed menu); on a write-granted step it must be paired with `tree-changed` (`exit-illegal` — the seed tree is green, a lone check would pass untouched, F17/F46). `artifact-written.pattern` must compile AND survive a ReDoS shape check — an unbounded quantifier over a group that itself repeats unboundedly (`(a+)+`, `(\d*)*`, even wrapped: `((a+))+`) is an `invalid-value` red (F49, catastrophic-backtracking footgun; rewrite without a repeated group inside a repeat). Exits verify FORM, not truth — progress gates; the operator's close stays the one arbiter |
|
|
177
177
|
|
|
178
178
|
Red vocabulary (all three validators): `parse-error`, `unknown-field`, `missing-required`,
|
|
179
179
|
`invalid-value`, `bounds`, `duplicate-id`, `close-type`, `close-hierarchy`,
|
|
@@ -376,14 +376,19 @@ through `Loop`'s `onToolResult`). So a repeat that never applied is told its anc
|
|
|
376
376
|
missed, and only content actually in the file is ever described as having landed. Spine event `root-injected` carries
|
|
377
377
|
`{stage, mode, streak, paths, redSetSize}` — counts and paths only, NEVER content (the
|
|
378
378
|
spine is append-only). State dies with the run: this is within-run scratch, not
|
|
379
|
-
across-run memory. `layerRoot: true`
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
379
|
+
across-run memory. `layerRoot: true` is the ON/experimental arm; the default is OFF. It is
|
|
380
|
+
wired on BOTH surfaces (F50): the legacy `steps[]` path (`interpret`) and the accepted
|
|
381
|
+
plan-v1 flow (`runPlan`, one root per EXECUTE step plus one in the outer close-fix loop). On
|
|
382
|
+
the plan flow the per-step red-set is the exit evaluator's OWN gap (the whole normalized
|
|
383
|
+
complaint) rather than a single close's gapKeep, while the fix loop uses the close's own
|
|
384
|
+
gapKeep (raw close output); the write-tee makes same-path target rewrites visible to the
|
|
385
|
+
detector. It is NOT wired on the native/clipipe worker (no `onToolResult` seam — F48 fallback). Status (F41):
|
|
386
|
+
the ratchet is MEASURED inert on every current job — two frozen probes + an archive sweep
|
|
387
|
+
read 0 fixated pairs in 14 (the F21 repetition disease was a broken-loop symptom, since
|
|
388
|
+
cured). Because ON has therefore never won its own A/B, it ships **OFF by default (decided
|
|
389
|
+
2026-07-21)** — armed and correct, but not default-enabled until a stuck job shows ON beats
|
|
390
|
+
OFF. Flip the default to `true` the day that evidence lands; a `root-injected` event on your
|
|
391
|
+
spine (when you pass `layerRoot: true`) is a signal worth reading, not noise.
|
|
387
392
|
|
|
388
393
|
### `runJob(spec, { approvals, workdir, provider, nativeProvider?, emit, target?, capRuns?, shellCapUsd?, closeTimeoutMs?, execCmd?, layerRoot? })` → outcome — `src/run.js`
|
|
389
394
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bareloop",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
package/src/plan.js
CHANGED
|
@@ -49,6 +49,89 @@ const EXIT_FIELDS = {
|
|
|
49
49
|
};
|
|
50
50
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
51
51
|
|
|
52
|
+
/** Length of an UNBOUNDED quantifier token at `src[i]` (`*`, `+`, or `{n,}`,
|
|
53
|
+
* plus an optional trailing lazy `?`), or 0. Bounded forms (`?`, `{n}`,
|
|
54
|
+
* `{n,m}`) return 0: they cannot drive exponential backtracking (a bounded
|
|
55
|
+
* outer repeat is polynomial at worst, and the body is self-authored — F49).
|
|
56
|
+
* @param {string} src @param {number} i */
|
|
57
|
+
function unboundedQuantLen(src, i) {
|
|
58
|
+
let len = src[i] === '*' || src[i] === '+' ? 1 : 0;
|
|
59
|
+
if (!len && src[i] === '{') { const m = /^\{\d+,\}/.exec(src.slice(i)); if (m) len = m[0].length; }
|
|
60
|
+
if (len && src[i + len] === '?') len++; // the lazy modifier is part of the same token
|
|
61
|
+
return len;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* F49 — a heuristic reject for the catastrophic-backtracking footgun: an
|
|
66
|
+
* unbounded quantifier applied to a group whose body itself repeats unboundedly
|
|
67
|
+
* (`(a+)+`, `(\d*)*`, `([a-z]+){1,}`) — including through a redundant wrapping
|
|
68
|
+
* group (`((a+))+`, `(?:(a+))*`), which is the SAME exponential class as `(a+)+`
|
|
69
|
+
* and is caught by propagating the inner repeat up through the wrapper. Such a
|
|
70
|
+
* pattern can hang `RegExp.test` for
|
|
71
|
+
* seconds on a short crafted body, and `evalExits` runs the agent-authored
|
|
72
|
+
* `artifact-written` pattern with NO timeout. This is self-DoS only — the agent
|
|
73
|
+
* authors both the pattern and (via the worker) the artifact, so a hang burns
|
|
74
|
+
* only its own run's wall-clock; there is NO arbiter compromise (it cannot
|
|
75
|
+
* escape the fence, forge a green, or leak a secret). So the reject targets the
|
|
76
|
+
* dominant exponential class and fails it as a mechanical gap at the validation
|
|
77
|
+
* gate, before any tokens burn. A full ReDoS analyzer needs a real regex engine
|
|
78
|
+
* (an external native dep we do not take for a LOW issue); exotic
|
|
79
|
+
* overlapping-alternation blowup is out of scope by decision.
|
|
80
|
+
*
|
|
81
|
+
* SCOPE IS ASYMMETRIC, both directions named on purpose:
|
|
82
|
+
* - false NEGATIVE: overlapping-alternation blowup (`(a|ab)+`-class) is not
|
|
83
|
+
* detected — out of scope (self-DoS only, no arbiter compromise).
|
|
84
|
+
* - false POSITIVE: a group whose repetitions are disambiguated by a literal
|
|
85
|
+
* anchor/delimiter (`(?:^- .+$\n?)+`, `(?:CHANGELOG:.+\n)+`) is FLAGGED even
|
|
86
|
+
* though a real engine runs it linearly — the scan sees the nested-quantifier
|
|
87
|
+
* SHAPE, not the disambiguation. Rejecting it is the FAIL-SAFE direction (it
|
|
88
|
+
* never admits an unsafe pattern), and the cost is bounded: the plan drafter
|
|
89
|
+
* gets a mechanical gap and rewrites (drop the outer `+`, or match once).
|
|
90
|
+
* Detecting "safe because anchored" needs the same real engine we declined,
|
|
91
|
+
* and guessing it wrong would ADMIT an exponential pattern — so the shape
|
|
92
|
+
* reject stands, and the over-rejection is a named, accepted limitation.
|
|
93
|
+
*
|
|
94
|
+
* The input is guaranteed to compile as a RegExp (checked first by the caller),
|
|
95
|
+
* so this scan assumes valid, balanced JS regex syntax.
|
|
96
|
+
* @param {string} src a compiled regex source string
|
|
97
|
+
* @returns {boolean} true iff a nested unbounded quantifier is present
|
|
98
|
+
*/
|
|
99
|
+
export function hasNestedQuantifier(src) {
|
|
100
|
+
/** @type {{ quant: boolean }[]} */
|
|
101
|
+
const stack = [];
|
|
102
|
+
for (let i = 0; i < src.length; i++) {
|
|
103
|
+
const c = src[i];
|
|
104
|
+
if (c === '\\') { i++; continue; } // escaped atom — the next char is a literal
|
|
105
|
+
if (c === '[') { // character class: quantifier chars inside are literals
|
|
106
|
+
i++;
|
|
107
|
+
if (src[i] === '^') i++;
|
|
108
|
+
if (src[i] === ']') i++; // a leading ] is a literal member, not the close
|
|
109
|
+
while (i < src.length && src[i] !== ']') { if (src[i] === '\\') i++; i++; }
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (c === '(') { stack.push({ quant: false }); continue; }
|
|
113
|
+
if (c === ')') {
|
|
114
|
+
const g = stack.pop();
|
|
115
|
+
const qlen = unboundedQuantLen(src, i + 1);
|
|
116
|
+
if (g && g.quant && qlen) return true; // group repeats unboundedly AND its body did too
|
|
117
|
+
// The group is an unbounded-repeated atom of its parent when it is directly
|
|
118
|
+
// re-quantified (qlen) OR its own body already repeats unboundedly (g.quant):
|
|
119
|
+
// a redundant wrapper — ((a+))+ , (?:(a+))* , (((\d*)))+ — is the SAME
|
|
120
|
+
// exponential class as (a+)+, so an inner repeat must propagate THROUGH the
|
|
121
|
+
// enclosing group or the outer quantifier's close never sees it. Propagation
|
|
122
|
+
// is MONOTONIC (it only ever SETS quant=true → only ever adds rejections): it
|
|
123
|
+
// can widen over-rejection — the named FAIL-SAFE direction — but can never
|
|
124
|
+
// introduce a false negative, which is the dangerous one (F49).
|
|
125
|
+
if ((qlen || (g && g.quant)) && stack.length) stack[stack.length - 1].quant = true;
|
|
126
|
+
i += qlen;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const qlen = unboundedQuantLen(src, i); // a quantifier applying to the preceding atom, inside the current group
|
|
130
|
+
if (qlen) { if (stack.length) stack[stack.length - 1].quant = true; i += qlen - 1; }
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
52
135
|
/** @typedef {{code: string, path: string, detail?: string, verb?: string}} Red */
|
|
53
136
|
|
|
54
137
|
/**
|
|
@@ -221,8 +304,15 @@ function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep })
|
|
|
221
304
|
if (e.type === 'artifact-written' && e.pattern !== undefined) {
|
|
222
305
|
if (!isNonEmptyString(e.pattern)) red('invalid-value', `${eAt}.pattern`, 'regex source string');
|
|
223
306
|
else {
|
|
224
|
-
|
|
307
|
+
let compiled = false;
|
|
308
|
+
try { new RegExp(e.pattern, 'm'); compiled = true; }
|
|
225
309
|
catch { red('invalid-value', `${eAt}.pattern`, 'must compile as a RegExp'); }
|
|
310
|
+
// F49: a compiled-but-catastrophic pattern (nested unbounded
|
|
311
|
+
// quantifier) can hang the untimed exit evaluator — reject it here as
|
|
312
|
+
// a mechanical gap so the replan rewrites it, before any tokens burn.
|
|
313
|
+
if (compiled && hasNestedQuantifier(e.pattern)) {
|
|
314
|
+
red('invalid-value', `${eAt}.pattern`, `nested unbounded quantifier (e.g. (a+)+ , (\\d*)* , (x+){1,}) — a catastrophic-backtracking footgun that can hang the exit evaluator (F49); rewrite without a repeated group inside a repeat`);
|
|
315
|
+
}
|
|
226
316
|
}
|
|
227
317
|
}
|
|
228
318
|
}
|
package/src/planrun.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { createRequire } from 'node:module';
|
|
17
17
|
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
18
19
|
import { join, resolve } from 'node:path';
|
|
19
20
|
import { Gate, redact } from 'bareguard';
|
|
20
21
|
import { LiteCtx } from 'litectx';
|
|
@@ -22,6 +23,7 @@ import { runClose, ralph, CLOSE_FAULTS } from './ralph.js';
|
|
|
22
23
|
import { validatePlan } from './plan.js';
|
|
23
24
|
import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS } from './plan.js';
|
|
24
25
|
import { snapshotScope, evalExits } from './exits.js';
|
|
26
|
+
import { createRoot } from './root.js';
|
|
25
27
|
import { TOOL_MENU } from './job.js';
|
|
26
28
|
import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, RETRIEVAL_STRATEGY, EDIT_STRATEGY } from './interpret.js';
|
|
27
29
|
import { globToPrefix, SECRET_PATTERNS } from './validate.js';
|
|
@@ -127,11 +129,20 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
127
129
|
* @param {number} [opts.capRuns] shell-owned per-step attempt cap
|
|
128
130
|
* @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory)
|
|
129
131
|
* @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
|
|
132
|
+
* @param {boolean} [opts.layerRoot=false] Layer R — the within-run ratchet (src/root.js),
|
|
133
|
+
* scoped PER STEP's ralph loop (each micro-wheel is the Layer-1 atom). Shell-assembled from
|
|
134
|
+
* the step's own books: per-attempt write-sets from the F32 workerWrites audit (teed for
|
|
135
|
+
* same-path rewrites, which the cumulative audit alone cannot see) and the red-set from the
|
|
136
|
+
* exit evaluator's own gap. Defaults OFF (decided 2026-07-21, F41): fixation is extinct on
|
|
137
|
+
* every current job, so ON has never won its A/B; `true` is the ON/experimental arm, and the
|
|
138
|
+
* first plan-flow job to emit `root-injected` runs the pre-registered ON-vs-OFF acceptance
|
|
139
|
+
* read (the Layer R default-flip, LAYERS.md ⚠). Excluded on native (clipipe): the native
|
|
140
|
+
* worker has no onToolResult seam, so the tee cannot settle and same-path rewrites are blind.
|
|
130
141
|
* @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
|
|
131
142
|
* 'check-red' | 'close-red' | 'close-unsupported' | 'pricing-red' | 'cap-halt' |
|
|
132
143
|
* 'provider-red' | 'interpreter-red' | `step-red:<id>`
|
|
133
144
|
*/
|
|
134
|
-
export async function runPlan(job, { workdir, provider, nativeProvider, emit, remainingUsd, isUnpriced = () => false, capRuns = 3, closeTimeoutMs, maxStepRounds = 40 }) {
|
|
145
|
+
export async function runPlan(job, { workdir, provider, nativeProvider, emit, remainingUsd, isUnpriced = () => false, capRuns = 3, closeTimeoutMs, maxStepRounds = 40, layerRoot = false }) {
|
|
135
146
|
workdir = resolve(workdir);
|
|
136
147
|
const scrub = (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS });
|
|
137
148
|
|
|
@@ -228,9 +239,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
228
239
|
* denied, the wallet as its budget), granted tools only (the menu IS the
|
|
229
240
|
* grant), per-attempt round bound via loop.stop() (F20), every round metered
|
|
230
241
|
* with a phase label (F12).
|
|
231
|
-
* @param {{granted: string[], phase: string, attemptRounds: number, attempts: number, writable: boolean}} o
|
|
242
|
+
* @param {{granted: string[], phase: string, attemptRounds: number, attempts: number, writable: boolean, root?: ReturnType<typeof createRoot>|null}} o
|
|
243
|
+
* `root` (Layer R): when present, the worker's write-class actions are teed
|
|
244
|
+
* for the within-run ratchet — staged before the gate decides, discarded on
|
|
245
|
+
* deny/halt, settled to landed-or-not after execution (the two axes, F43/F7).
|
|
246
|
+
* Wired ONLY on the Loop path: the native session exposes no onToolResult seam.
|
|
232
247
|
*/
|
|
233
|
-
async function mkWorker({ granted, phase, attemptRounds, attempts, writable }) {
|
|
248
|
+
async function mkWorker({ granted, phase, attemptRounds, attempts, writable, root = null }) {
|
|
234
249
|
const gate = new Gate({
|
|
235
250
|
fs: {
|
|
236
251
|
writeScope: writable ? fencePrefixes : [],
|
|
@@ -259,7 +274,69 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
259
274
|
return [...paths];
|
|
260
275
|
} catch { return []; }
|
|
261
276
|
};
|
|
262
|
-
|
|
277
|
+
// Layer R tee (design record 2026-07-19; created per-step by executeStep).
|
|
278
|
+
// The translator STAGES write/edit content and snapshots the pre-write hash
|
|
279
|
+
// BEFORE the gate decides (Finding 6); the policy wrapper DISCARDS on a deny
|
|
280
|
+
// or a halt (the tool never runs); onToolOutcome SETTLES landed-vs-not after
|
|
281
|
+
// execution (Finding 7 — the gate's allow is intent, the file is outcome).
|
|
282
|
+
// All no-ops when root is null: every scout/drafter and every native worker.
|
|
283
|
+
const fileHash = (/** @type {string} */ p) => {
|
|
284
|
+
try { return createHash('sha256').update(readFileSync(p)).digest('hex'); } catch { return null; }
|
|
285
|
+
};
|
|
286
|
+
/** @type {{path: string, content: string, type: string, before: string|null}|null} the write-class action awaiting its OUTCOME */
|
|
287
|
+
let pendingProbe = null;
|
|
288
|
+
const teeingTranslator = (/** @type {string} */ n, /** @type {any} */ a) => {
|
|
289
|
+
// classify by action TYPE, never a name list (the workerWrites filter uses
|
|
290
|
+
// the same write|edit test — a third enumeration would let a future
|
|
291
|
+
// write-class verb bypass the tee, the blind-instrument class)
|
|
292
|
+
const act = toolAction(n, a, workdir);
|
|
293
|
+
if (root && (act.type === 'write' || act.type === 'edit')) {
|
|
294
|
+
const path = /** @type {string} */ (act.path);
|
|
295
|
+
const content = String((act.type === 'edit' ? a?.newText : a?.content) ?? '');
|
|
296
|
+
root.stageWrite(path, content); // content read off RAW args, never onto the action (the audit stays bytes-only)
|
|
297
|
+
pendingProbe = { path, content, type: act.type, before: fileHash(path) };
|
|
298
|
+
}
|
|
299
|
+
return act;
|
|
300
|
+
};
|
|
301
|
+
const { policy: gatePolicy, onLlmResult } = wireGate(gate, { actionTranslator: teeingTranslator });
|
|
302
|
+
// Settle the stage on the verdict. A DENY/HALT means the tool never runs, so
|
|
303
|
+
// the outcome seam below never fires for it — discard here (an allow is NOT
|
|
304
|
+
// settled here: the bytes are not written yet; settling on the verdict is the
|
|
305
|
+
// exact Finding 7 defect). The catch RE-THROWS so cap-halt routing still reads
|
|
306
|
+
// the throw. Non-write actions stage nothing, so both settlements are no-ops.
|
|
307
|
+
const policy = root
|
|
308
|
+
? async (/** @type {string} */ n, /** @type {any} */ a, /** @type {any} */ c) => {
|
|
309
|
+
try {
|
|
310
|
+
const verdict = await gatePolicy(n, a, c);
|
|
311
|
+
if (verdict !== true) { root.discardWrite(); pendingProbe = null; }
|
|
312
|
+
return verdict;
|
|
313
|
+
} catch (e) { root.discardWrite(); pendingProbe = null; throw e; }
|
|
314
|
+
}
|
|
315
|
+
: gatePolicy;
|
|
316
|
+
/**
|
|
317
|
+
* Finding 7 — settle the staged write on what the tool ACTUALLY did. Fires
|
|
318
|
+
* after every tool.execute (bare-agent loop.js), success or error, and tool
|
|
319
|
+
* calls run strictly sequentially, so exactly one probe is ever in flight. A
|
|
320
|
+
* probe that never reaches here (a halt out of a tool body) leaves the stage
|
|
321
|
+
* unsettled — correct: nothing landed, and the attempt boundary drops it.
|
|
322
|
+
* @param {{result?: any}} [info] the tool's return value
|
|
323
|
+
*/
|
|
324
|
+
const onToolOutcome = async (info) => {
|
|
325
|
+
if (!root || !pendingProbe) return;
|
|
326
|
+
const { path: p, content, type, before } = pendingProbe;
|
|
327
|
+
pendingProbe = null;
|
|
328
|
+
let after = null;
|
|
329
|
+
try { after = readFileSync(p, 'utf8'); } catch { /* absent → after stays null */ }
|
|
330
|
+
let landed = after !== null && createHash('sha256').update(after, 'utf8').digest('hex') !== before;
|
|
331
|
+
if (!landed && after !== null) {
|
|
332
|
+
// no-byte-change: a write always writes (identical bytes ⇒ content IS the
|
|
333
|
+
// file, exact-equal never substring); a no-op edit is an idempotent apply
|
|
334
|
+
// OR a missed anchor — the tool RESULT is the only honest signal (F2)
|
|
335
|
+
if (type === 'write') landed = after === content;
|
|
336
|
+
else landed = typeof info?.result === 'string' && info.result.startsWith('edited ');
|
|
337
|
+
}
|
|
338
|
+
root.settleWrite(landed);
|
|
339
|
+
};
|
|
263
340
|
/** @type {number|string|undefined} */
|
|
264
341
|
let roundIteration;
|
|
265
342
|
let roundsThisAttempt = 0;
|
|
@@ -383,7 +460,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
383
460
|
}
|
|
384
461
|
return onLlmResult(arg);
|
|
385
462
|
};
|
|
386
|
-
const loop = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered });
|
|
463
|
+
const loop = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome });
|
|
387
464
|
/** @param {string} prompt @param {typeof toolDefs} [defs] */
|
|
388
465
|
const ask = async (prompt, defs = toolDefs) => {
|
|
389
466
|
let r;
|
|
@@ -507,13 +584,33 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
507
584
|
for (const e of step.exit) {
|
|
508
585
|
if (e.type === 'tree-changed') for (const [k, v] of await snapshotScope(workdir, e.scope)) snapshot.set(k, v);
|
|
509
586
|
}
|
|
510
|
-
|
|
587
|
+
// Layer R — one root per step's ralph loop (each micro-wheel is the Layer-1
|
|
588
|
+
// atom, LAYERS.md). The red-set is the exit evaluator's OWN gap: fixation
|
|
589
|
+
// here means "the worker rewrote the same file(s) AND the exit evaluator's
|
|
590
|
+
// complaint is byte-identical" — so the whole normalized gap is the
|
|
591
|
+
// comparable set (gapKeep `\S` = every non-blank line). That is more robust
|
|
592
|
+
// than reusing a check's `^`-anchored gapKeep, which the exit wrapper
|
|
593
|
+
// (`check "x" red: …`) would break. writesInformative is true (tool mode,
|
|
594
|
+
// always) but never fires on write-overlap ALONE — a plan step rewrites its
|
|
595
|
+
// one target every attempt, so only a KNOWN-unmoved red-set can distinguish
|
|
596
|
+
// repetition from progress (Finding 3). Native is excluded (no onToolResult
|
|
597
|
+
// seam → the tee cannot settle → same-path rewrites are blind).
|
|
598
|
+
const root = layerRoot && !native
|
|
599
|
+
? createRoot({ gapKeep: '\\S', redact: scrub, writesInformative: true })
|
|
600
|
+
: null;
|
|
601
|
+
const w = await mkWorker({ granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts: capRuns, writable: true, root });
|
|
511
602
|
let lastText = '';
|
|
512
603
|
let iterationNow = 0;
|
|
513
604
|
/** @param {number} iteration @param {string} [gap] */
|
|
514
605
|
const middle = async (iteration, gap) => {
|
|
515
606
|
w.setIteration(iteration);
|
|
516
607
|
iterationNow = iteration;
|
|
608
|
+
// Layer R observe: finalize the prior attempt from the books (workerWrites
|
|
609
|
+
// audit + teed same-path rewrites), run the fixation detector, and inject
|
|
610
|
+
// its escalating note into THIS attempt's prompt (null = inert). The event
|
|
611
|
+
// carries counts and paths only — never content (the spine is forever).
|
|
612
|
+
const rootInj = root ? root.observe({ iteration, gap, writes: w.workerWrites() }) : null;
|
|
613
|
+
if (rootInj) emit('root-injected', { step: step.id, ...rootInj.event });
|
|
517
614
|
const r = await w.ask([
|
|
518
615
|
step.action,
|
|
519
616
|
`Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root — a relative path resolves against a different directory and will be denied by the gate.`,
|
|
@@ -522,6 +619,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
522
619
|
gap && `Previous attempt failed this step's checks:\n${gap}`,
|
|
523
620
|
w.wasBounded() === iteration - 1
|
|
524
621
|
&& `Your previous attempt was CUT OFF after ${step.rounds} tool rounds. Reading is bounded; writing is not. Form a hypothesis EARLY and make the change.`,
|
|
622
|
+
rootInj && rootInj.note,
|
|
525
623
|
].filter(Boolean).join('\n\n'));
|
|
526
624
|
lastText = scrub(r.text ?? '').slice(0, ARTIFACT_MAX);
|
|
527
625
|
};
|
|
@@ -651,16 +749,29 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
651
749
|
emit('fix-loop', { gapBytes: Buffer.byteLength(post.gap ?? '') });
|
|
652
750
|
let fixOutcome;
|
|
653
751
|
try {
|
|
654
|
-
|
|
752
|
+
// Layer R for the close-fix loop — the plan flow's single ralph loop judged
|
|
753
|
+
// by the REAL close (the closest analog to interpret.js's loop, and the
|
|
754
|
+
// likeliest place fixation manifests: the fix worker has the full menu and
|
|
755
|
+
// is judged by a command, not a form-only exit). The red-set is the CLOSE's
|
|
756
|
+
// own gapKeep (the gap here is the raw close output, unwrapped — so the
|
|
757
|
+
// `^`-anchored pattern matches, unlike the exec steps' exit-eval gap). Same
|
|
758
|
+
// native exclusion (no onToolResult seam ⇒ the tee cannot settle).
|
|
759
|
+
const fixRoot = layerRoot && !native
|
|
760
|
+
? createRoot({ gapKeep: job.close.gapKeep, redact: scrub, writesInformative: true })
|
|
761
|
+
: null;
|
|
762
|
+
const w = await mkWorker({ granted: ceiling, phase: 'fix', attemptRounds: maxStepRounds, attempts: capRuns, writable: true, root: fixRoot });
|
|
655
763
|
/** @param {number} iteration @param {string} [gap] */
|
|
656
764
|
const middle = async (iteration, gap) => {
|
|
657
765
|
w.setIteration(iteration);
|
|
766
|
+
const rootInj = fixRoot ? fixRoot.observe({ iteration, gap, writes: w.workerWrites() }) : null;
|
|
767
|
+
if (rootInj) emit('root-injected', { phase: 'fix', ...rootInj.event });
|
|
658
768
|
await w.ask([
|
|
659
769
|
'The job\'s final verification is failing. Fix the repository so it passes.',
|
|
660
770
|
`Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
|
|
661
771
|
artifacts.length > 0 && `Working context (read-only) — the plan's steps produced:\n${artifacts.map((a) => `[${a.id}] ${a.text}`).join('\n\n')}`,
|
|
662
772
|
!gap && post.gap && `The verification's output on the tree as it stands (not an attempt of yours):\n${post.gap}`,
|
|
663
773
|
gap && `Previous attempt failed the verification:\n${gap}`,
|
|
774
|
+
rootInj && rootInj.note,
|
|
664
775
|
].filter(Boolean).join('\n\n'));
|
|
665
776
|
};
|
|
666
777
|
fixOutcome = await ralph({
|
package/src/run.js
CHANGED
|
@@ -303,7 +303,7 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, na
|
|
|
303
303
|
// money contract is identical to the legacy path.
|
|
304
304
|
if (planShape) {
|
|
305
305
|
const outcome = await runPlan(job, {
|
|
306
|
-
workdir, provider, nativeProvider, emit: meter, capRuns, closeTimeoutMs,
|
|
306
|
+
workdir, provider, nativeProvider, emit: meter, capRuns, closeTimeoutMs, layerRoot,
|
|
307
307
|
remainingUsd: () => Math.min(shellCapUsd, job.budgetUsd - spentUsd),
|
|
308
308
|
isUnpriced: () => unpriced, // F6: let the plan flow bail in-flight, not just after it returns
|
|
309
309
|
});
|
package/types/plan.d.ts
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F49 — a heuristic reject for the catastrophic-backtracking footgun: an
|
|
3
|
+
* unbounded quantifier applied to a group whose body itself repeats unboundedly
|
|
4
|
+
* (`(a+)+`, `(\d*)*`, `([a-z]+){1,}`) — including through a redundant wrapping
|
|
5
|
+
* group (`((a+))+`, `(?:(a+))*`), which is the SAME exponential class as `(a+)+`
|
|
6
|
+
* and is caught by propagating the inner repeat up through the wrapper. Such a
|
|
7
|
+
* pattern can hang `RegExp.test` for
|
|
8
|
+
* seconds on a short crafted body, and `evalExits` runs the agent-authored
|
|
9
|
+
* `artifact-written` pattern with NO timeout. This is self-DoS only — the agent
|
|
10
|
+
* authors both the pattern and (via the worker) the artifact, so a hang burns
|
|
11
|
+
* only its own run's wall-clock; there is NO arbiter compromise (it cannot
|
|
12
|
+
* escape the fence, forge a green, or leak a secret). So the reject targets the
|
|
13
|
+
* dominant exponential class and fails it as a mechanical gap at the validation
|
|
14
|
+
* gate, before any tokens burn. A full ReDoS analyzer needs a real regex engine
|
|
15
|
+
* (an external native dep we do not take for a LOW issue); exotic
|
|
16
|
+
* overlapping-alternation blowup is out of scope by decision.
|
|
17
|
+
*
|
|
18
|
+
* SCOPE IS ASYMMETRIC, both directions named on purpose:
|
|
19
|
+
* - false NEGATIVE: overlapping-alternation blowup (`(a|ab)+`-class) is not
|
|
20
|
+
* detected — out of scope (self-DoS only, no arbiter compromise).
|
|
21
|
+
* - false POSITIVE: a group whose repetitions are disambiguated by a literal
|
|
22
|
+
* anchor/delimiter (`(?:^- .+$\n?)+`, `(?:CHANGELOG:.+\n)+`) is FLAGGED even
|
|
23
|
+
* though a real engine runs it linearly — the scan sees the nested-quantifier
|
|
24
|
+
* SHAPE, not the disambiguation. Rejecting it is the FAIL-SAFE direction (it
|
|
25
|
+
* never admits an unsafe pattern), and the cost is bounded: the plan drafter
|
|
26
|
+
* gets a mechanical gap and rewrites (drop the outer `+`, or match once).
|
|
27
|
+
* Detecting "safe because anchored" needs the same real engine we declined,
|
|
28
|
+
* and guessing it wrong would ADMIT an exponential pattern — so the shape
|
|
29
|
+
* reject stands, and the over-rejection is a named, accepted limitation.
|
|
30
|
+
*
|
|
31
|
+
* The input is guaranteed to compile as a RegExp (checked first by the caller),
|
|
32
|
+
* so this scan assumes valid, balanced JS regex syntax.
|
|
33
|
+
* @param {string} src a compiled regex source string
|
|
34
|
+
* @returns {boolean} true iff a nested unbounded quantifier is present
|
|
35
|
+
*/
|
|
36
|
+
export function hasNestedQuantifier(src: string): boolean;
|
|
1
37
|
/** @typedef {{code: string, path: string, detail?: string, verb?: string}} Red */
|
|
2
38
|
/**
|
|
3
39
|
* Validate an agent-authored plan (`schema: "plan-v1"`) against the SIGNED
|
package/types/planrun.d.ts
CHANGED
|
@@ -23,11 +23,20 @@
|
|
|
23
23
|
* @param {number} [opts.capRuns] shell-owned per-step attempt cap
|
|
24
24
|
* @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory)
|
|
25
25
|
* @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
|
|
26
|
+
* @param {boolean} [opts.layerRoot=false] Layer R — the within-run ratchet (src/root.js),
|
|
27
|
+
* scoped PER STEP's ralph loop (each micro-wheel is the Layer-1 atom). Shell-assembled from
|
|
28
|
+
* the step's own books: per-attempt write-sets from the F32 workerWrites audit (teed for
|
|
29
|
+
* same-path rewrites, which the cumulative audit alone cannot see) and the red-set from the
|
|
30
|
+
* exit evaluator's own gap. Defaults OFF (decided 2026-07-21, F41): fixation is extinct on
|
|
31
|
+
* every current job, so ON has never won its A/B; `true` is the ON/experimental arm, and the
|
|
32
|
+
* first plan-flow job to emit `root-injected` runs the pre-registered ON-vs-OFF acceptance
|
|
33
|
+
* read (the Layer R default-flip, LAYERS.md ⚠). Excluded on native (clipipe): the native
|
|
34
|
+
* worker has no onToolResult seam, so the tee cannot settle and same-path rewrites are blind.
|
|
26
35
|
* @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
|
|
27
36
|
* 'check-red' | 'close-red' | 'close-unsupported' | 'pricing-red' | 'cap-halt' |
|
|
28
37
|
* 'provider-red' | 'interpreter-red' | `step-red:<id>`
|
|
29
38
|
*/
|
|
30
|
-
export function runPlan(job: any, { workdir, provider, nativeProvider, emit, remainingUsd, isUnpriced, capRuns, closeTimeoutMs, maxStepRounds }: {
|
|
39
|
+
export function runPlan(job: any, { workdir, provider, nativeProvider, emit, remainingUsd, isUnpriced, capRuns, closeTimeoutMs, maxStepRounds, layerRoot }: {
|
|
31
40
|
workdir: string;
|
|
32
41
|
provider: any;
|
|
33
42
|
nativeProvider?: ((o: {
|
|
@@ -42,6 +51,7 @@ export function runPlan(job: any, { workdir, provider, nativeProvider, emit, rem
|
|
|
42
51
|
capRuns?: number | undefined;
|
|
43
52
|
closeTimeoutMs?: number | undefined;
|
|
44
53
|
maxStepRounds?: number | undefined;
|
|
54
|
+
layerRoot?: boolean | undefined;
|
|
45
55
|
}): Promise<string>;
|
|
46
56
|
export type CategorizedError = Error & {
|
|
47
57
|
category?: string;
|