bareloop 0.4.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 CHANGED
@@ -7,6 +7,141 @@ 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
+
34
+ ## [0.5.0] — 2026-07-23
35
+
36
+ ### Added
37
+
38
+ - **Layer 2 core — the plan-v1 flow (design record
39
+ `docs/plans/2026-07-21-layer-2-plan-v1-design.md`; premise validated in F46).**
40
+ The semantic converter, built:
41
+ - **job-v1 four-field plan shape** (`src/job.js`): `goal` / `verdictType`
42
+ (`green|soft-green|hitl` frozen radio — v1 admits `green` only; a locked type is a
43
+ `request-red` with the type as a structured `verb` field) / `close` / `checks[]`
44
+ (operator-SIGNED named checks: the predicate-close body + a slug name, same
45
+ validation, same runClose machinery; checks decide nothing and mint nothing) /
46
+ `tools` (the plan ceiling). Exclusive with legacy `steps[]` (`shape-conflict` red);
47
+ `steps[]` is co-existing scaffolding with a staged sunset — archives alongside
48
+ config-v1 when the Layer 2 path proves itself in its battery.
49
+ - **plan-v1 validator** (`src/plan.js`, `validatePlan`): gates the AGENT-authored
50
+ plan against the SIGNED spec — verbs ⊆ ceiling (`verb-escape`, verb as structured
51
+ data), rounds ≤ shell cap, targets/scopes inside the fence, exits from the closed
52
+ menu only (`exit-illegal`), `check-passes` resolving against the signed menu
53
+ (`check-unknown` names the menu), no `dependsOn` (strictly sequential — an inert
54
+ knob is a fake contrast lever), AND-only exit composition max 2, and the F17
55
+ pairing law (check-passes on a write step demands tree-changed — the seed tree is
56
+ green). Fails CLOSED on a missing/non-plan-shape job (`job-invalid`).
57
+ - **exit evaluator** (`src/exits.js`, `snapshotScope`/`evalExits`): the shell's own
58
+ fixed code for `artifact-written` / `tree-changed` / `json-valid` /
59
+ `check-passes`. Outcome, never intent: sha256 snapshots, identical re-writes are
60
+ not changes (F43), git status never consulted (F45). Instrument faults ride out as
61
+ `fault` by runClose verdict name — escalated, never fed to the worker as a gap.
62
+ - **the judge seam** (`src/ralph.js`): `ralph({ judge })` — the PRD v1.12 §4
63
+ generalization; shell-injected, inexpressible in any config or plan. Same verdict
64
+ vocabulary, so the forbidden zone, F32 worker-crash routing (a check crashed by the
65
+ worker's own test feeds back — the F46 mechanism), and cap taxonomy are unchanged.
66
+ - **the plan executor** (`src/planrun.js`, `runPlan`): close precheck
67
+ (`already-green` distinct, F17) → checks preflight ($0, before tokens) → read-only
68
+ SCOUT → PLAN (one redraft with reds fed back) → sequential micro-loops with
69
+ exit-gap feedback → ONE replan (exhaustion only) → the operator's close with one
70
+ bounded fix loop. Every round metered `worker-round` with a phase label (F12);
71
+ prompt contract v1.12 §5 mutation-proven; `plan-executed` (plan-as-executed,
72
+ design law #2) on the spine.
73
+ - **runJob dispatch** (`src/run.js`): a plan-shape spec routes through the ONE
74
+ runJob entry — same approval gate, smoke, ledger, and job-end money contract.
75
+ New outcomes: `already-green | plan-red | check-red | close-red`.
76
+ - 139 new tests (503 total), TDD throughout, 3 targeted mutations fired and killed.
77
+ Built and integration-tested against scripted providers.
78
+ - **Layer 2 rung ACCEPTED — the real-model acceptance battery (F47; prereg
79
+ `docs/02-experiments/TESTGEN-PREREG.md` §2026-07-22a/b).** Job #4 (TESTGEN) run
80
+ through the REAL plan flow (`runJob → runPlan`: scout → the agent DRAFTS the plan →
81
+ validator gates → per-step check-loops → outer grader), on `anthropic-api`,
82
+ claude-sonnet-5, vs F39's baseline (0 conversion) and the F46 POC (hardwired).
83
+ **3/3 valid acting rows converted (≥2/3 bar) → accepted; 3/3 cleared the 45% bar
84
+ (67.5/55/55, vs the POC's 27.5/40/37.5 with 0 at 45); 3/3 the agent composed the
85
+ `check-passes(clean-run)` exit ITSELF** (the one thing the POC could not test). Every
86
+ green driven by the step check-loop alone; all writes fenced, source frozen, secrets
87
+ clean; the F45 spend guard stopped an unpriced casualty. 7 provider-red casualties
88
+ across an Overloaded window (excluded as evidence), $27.36 of a $30 cap. This trips
89
+ the "path closes green end-to-end" milestone: **`steps[]` and config-v1 sunset on
90
+ landing.** Driver `scripts/run-battery-l2accept.mjs` (gained `--need`/`--priorUsd`
91
+ for a multi-run continuation under one governed cap).
92
+ - **Module 4d — native clipipe worker surface (BA-16; `bare-agent` → `^0.33.0`).** The
93
+ plan flow now runs on TWO surfaces: the `Loop` (API, unchanged) and, when
94
+ `job.provider === 'clipipe-subscription'`, the `claude` CLI's NATIVE tool channel — the
95
+ subscription path (no metered API). Since native governance is constructor-time and
96
+ per-worker, the runner takes a `nativeProvider` FACTORY (`{policy, onTurn?, maxTurns,
97
+ hasTools}) => provider`) it calls fresh per worker: `hasTools:true` → native tool mode
98
+ (the SAME `wireGate` fence clips onto the provider — a live POC proved an out-of-scope
99
+ write is DENIED); `hasTools:false` → the toolless drafter runs metered claude-json TEXT
100
+ mode (a native session reports no cost — this path keeps the drafter's spend visible).
101
+ Money reconciles per session (accounted `worker-round` = session total; `worker-turn` =
102
+ attribution); `max_turns` is a bounded attempt, not an escalation; a missing factory is
103
+ `interpreter-red`, never a silent fall-back to the API. 5 native tests + a live
104
+ end-to-end smoke on the real CLI (green, all workers metered, `spendComplete` honest).
105
+
106
+ - **Native read-cap — the CLI truncation fix (F48; `bare-agent` → `^0.33.1`, BA-17
107
+ ranged read).** On the `clipipe-subscription` surface the `claude` CLI truncates a large
108
+ tool result (~40–50KB, measured) BEFORE the model sees it — spilling the remainder to a
109
+ fenced-off `tool-results/` file and wrapping it in a "read in chunks" notice the model
110
+ distrusts as injection — so a whole-file `shell_read` of a large file blinded the native
111
+ worker (0-write stall). The runner now bounds the native `shell_read` result below the CLI
112
+ cap (`NATIVE_READ_CAP`) and returns a TRUSTED notice steering to `ctx_get` ranged retrieval,
113
+ plus a native-only strategy line; the API path is untouched (full result rides into context).
114
+ Measured: **0 → 7 writes** on the real job. Cross-surface verdict (F48): the native surface
115
+ is capable at the STEP but did not carry job #4 to a grade — 0/2 acting rows vs the API's
116
+ 3/3, and a 3.5× budget raise ($8→$28) was refuted (escalated on the F39 semantic-stall at
117
+ $7). IN only as a babysat, $0-marginal-billing fallback; **only the `anthropic-api` surface
118
+ is guaranteed.** Local LLMs remain deferred and unmeasured.
119
+
120
+ ### Fixed
121
+
122
+ - **Layer 2 pre-release review (F48) — 4 fixes, 2 correctness** (TDD, failing-then-passing
123
+ test each; full suite 519/519): (1) a provider-red/gate-red raised DURING a step's micro-loop
124
+ was collapsed to `step-red:<id>` — a transport CASUALTY recorded as a capability failure and
125
+ missing the F44 `spendComplete:false` floor; each terminal category now rides out under its own
126
+ name so the returned outcome and the emitted escalation agree (F11). (2) `tree-changed` counted
127
+ a sibling scope's files as deletions when a step had ≥2 tree-changed exits (merged snapshot),
128
+ falsely passing an unchanged scope — deletions are now scoped to the exit's own prefix. (3)
129
+ abandoned-plan artifacts no longer ride forward as the new plan's "prior steps' results" after a
130
+ replan. (4) dead `isUnpriced()` sub-conditions removed from the replan/cap-halt terminal (the
131
+ step-end guard already returns `pricing-red` first).
132
+ - **Layer 2 whole-branch review — 8 doctrine-restoring fixes to the plan flow** (all in the
133
+ graduated Layer 2 code, none in pre-existing modules; validated against source with 0
134
+ refuted, a failing-then-passing test each): a `gold` close validated under `verdictType:
135
+ green` crashed `runPlan` with no `job-end` (now `close-unsupported` before tokens); the
136
+ `check-passes` gap was re-truncated to 400 chars, deleting the gapKeep failing-test names
137
+ (F28 reintroduced — now carried whole); no in-flight `pricing-red` (F6 — now bails at
138
+ scout/plan/step); the plan drafter's Gate budget was frozen pre-execute and reused for the
139
+ replan (now a fresh drafter per `obtainPlan`); a money-gate halt triggered a replan instead
140
+ of stopping (F45 — now gated on funds, drained → honest `cap-halt`); the step-setup catch
141
+ recorded a category the escalation contradicted (F11 — now agreed); the plan branch dropped
142
+ F44's `spendComplete:false` on a transport-throw `provider-red`; a write-only tool ceiling
143
+ validated, blinding the scout (now requires ≥1 read-capable verb).
144
+
10
145
  ## [0.4.0] — 2026-07-21
11
146
 
12
147
  ### Added
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  <p align="center">
14
14
  <img src="https://img.shields.io/github/package-json/v/hamr0/bareloop?label=version&color=2a4f8c" alt="version (auto from package.json)">
15
15
  <img src="https://img.shields.io/badge/license-Apache%202.0-2a4f8c" alt="license: Apache 2.0">
16
- <img src="https://img.shields.io/badge/status-Layer%20R%20%C2%B7%20ratchet%20armed-2a6d3a" alt="status: Layer R, ratchet armed">
16
+ <img src="https://img.shields.io/badge/status-Layer%202%20%C2%B7%20accepted-2a6d3a" alt="status: Layer 2, accepted">
17
17
  </p>
18
18
 
19
19
  **"Automate this job — I don't know the best workflow."** For tasks that are **repeated,
@@ -25,15 +25,15 @@ inheritance with ledger-counted attribution.
25
25
  The pitch in one line: **workflows that earn their own design, with receipts** — every
26
26
  inherited rule carries the green that minted it and the contrast that attributed it.
27
27
 
28
- > **Status: Layer R — the within-run ratchet is built and armed.** `runJob()` takes a
29
- > signed job spec through draft → per-step worker loops → close verdicts → draft PR,
30
- > under one budget the agent can only tighten; the shell now also detects a worker
31
- > stuck repeating itself and feeds back its own failed attempts (escalating
32
- > summary→verbatim). Two frozen probes found the disease in remission under the current
33
- > loop (F41: 0 fixated pairs in 14, jobs #1/#2/#4), so the ratchet ships inert-by-
34
- > measurement with its field read deferred to the first run that trips it. Next is
35
- > Layer 2 (plan-v1 micro-wheels), then executed inheritance (N3) — the ladder's
36
- > kill-switch question.
28
+ > **Status: Layer 2 — accepted.** The agent now authors its own workflow: `runJob()`
29
+ > takes a signed job spec, surveys the repo, **drafts a validated plan of bounded steps**
30
+ > (each with an operator-signed self-check it may reference but never author), and runs it
31
+ > under an un-gameable outer close and one budget it can only tighten. On the real-model
32
+ > acceptance battery (F47) the emergent flow converted a job the same worker failed 4/4
33
+ > before — 3/3 runs, clearing the owned bar every time, composing its own check exits. The
34
+ > within-run ratchet (Layer R) ships armed-and-inert by measurement (F41). Next: **N3 —
35
+ > executed inheritance**, where the workflow persists and improves across runs (the
36
+ > ladder's kill-switch question).
37
37
 
38
38
  ## Quick start
39
39
 
@@ -1,10 +1,16 @@
1
1
  # bareloop — Integration Guide
2
2
 
3
- > **Current through N2** (headless single-job loop: `runJob`, text + tool middles, the
4
- > draft-PR hitl step); API sections fill in as build-ladder rungs land (PRD §10). What is
5
- > settled — the boundary, the architecture, the refusals, the constraints — is settled
6
- > for good. Per LIBRARY_CONVENTIONS §3 this file ships with the package and is the
7
- > complete adopter contract; the README is only the pitch.
3
+ > **Current through Layer 2 (ACCEPTED, F47)** (headless single-job loop: `runJob`, text +
4
+ > tool middles, the draft-PR hitl step; the plan-v1 flow — four-field job shape, agent-
5
+ > authored validated plans, in-run check exits — validated end-to-end on the real-model
6
+ > acceptance battery: 3/3 conversion, 3/3 over the owned bar, the agent composing its own
7
+ > check exits). The plan shape is the primary path; legacy `steps[]` co-exists but sunsets
8
+ > on landing. Runs on two worker surfaces — the API `Loop` and, for
9
+ > `clipipe-subscription`, the CLI's native tool channel (module 4d). API sections fill in
10
+ > as build-ladder rungs land (PRD §10). What is settled — the boundary, the architecture,
11
+ > the refusals, the constraints — is settled for good. Per LIBRARY_CONVENTIONS §3 this
12
+ > file ships with the package and is the complete adopter contract; the README is only
13
+ > the pitch.
8
14
 
9
15
  ## What this is
10
16
 
@@ -80,6 +86,22 @@ unknown-field reds.
80
86
  | `steps[].tools` | unique subset of `read\|grep\|write\|edit\|recall\|get` | the SPEC-side tool grant (`TOOL_MENU`, frozen; defaults to the full menu) — the drafted config cannot express mode or tools; `edit` (BA-13) is the anchored exact-once replace, judged by the SAME writeScope fence as `write`; requesting `run` (`LOCKED_TOOLS`) reds with the DISTINCT code `request-red` (locked-but-listed: the red IS the admission evidence the ledger tallies; a typo stays `invalid-value`) |
81
87
  | `escalation` | `{ mode: "decision-ready" }` | the pain channel is not optional |
82
88
 
89
+ **The plan shape (Layer 2, design record 2026-07-21).** A job-v1 spec declares EITHER the
90
+ `steps[]` chain above OR the four-field plan shape — never both (`shape-conflict` red).
91
+ In the plan shape the AGENT authors the step plan at run time (gated by `validatePlan`);
92
+ the human signs only:
93
+
94
+ | field | shape | notes |
95
+ |---|---|---|
96
+ | `goal` | non-empty text | what the agent plans against |
97
+ | `verdictType` | `green` \| `soft-green` \| `hitl` | declared radio, never inferred (`VERDICT_TYPES`, frozen). v1 ADMITS only `green`; declaring `soft-green`/`hitl` reds `request-red` with the type as a structured `verb` field (declared-but-locked — the tool-menu pattern) |
98
+ | `close` | a close object (table below) | ONE close, the only truth; `green` demands a hard-class close (`close-hierarchy` red on a rubric/hitl close) |
99
+ | `checks` | optional array of `{ name, cmd, expect, judged?, gapKeep? }` | the operator-SIGNED named-check menu: the predicate-close body plus a kebab slug `name`, validated by the same rules, executed under the same runClose machinery. Plans reference them via the `check-passes(name)` exit; the agent can never author, edit, or compose one. **Checks decide nothing and mint nothing** — a check result is a progress gate and a gap source only |
100
+ | `tools` | optional unique subset of `TOOL_MENU` | the CEILING every plan step's grant must fit inside (defaults to the full menu); `run` reds `request-red` here exactly as on a step grant. On a legacy `steps[]` spec this field is an `invalid-value` red (steps grant tools per step) |
101
+
102
+ `steps[]` is co-existing scaffolding with a staged sunset: it archives alongside
103
+ config-v1 once the Layer 2 path proves itself in its battery — not before.
104
+
83
105
  **Close types and the hierarchy** (a close is data, never code; verdict-class laundering
84
106
  is a named red `close-hierarchy`):
85
107
 
@@ -140,18 +162,36 @@ drafted workflow config cannot express it (unknown-field red). The gap path also
140
162
  **both** streams (stdout + stderr), so a failure printed to stdout survives stderr noise
141
163
  (F28's adjacent hazard — the old `err || out` returned stderr alone and lost it).
142
164
 
143
- Red vocabulary (both validators): `parse-error`, `unknown-field`, `missing-required`,
165
+ **The plan document (`schema: "plan-v1"`, AGENT-authored — the only document the emergent
166
+ middle writes; `validatePlan` gates it against the SIGNED job spec before tokens burn):**
167
+
168
+ | field | shape | notes |
169
+ |---|---|---|
170
+ | `steps` | ordered array, 1..8 (`MAX_PLAN_STEPS`) | strictly sequential — array order IS the order; no `dependsOn` (unknown-field red: an inert knob is a fake contrast lever) |
171
+ | `steps[].id` | kebab slug, unique | |
172
+ | `steps[].action` | non-empty text | the step's task — the worker sees only this step |
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
+ | `steps[].rounds` | int 1..shell cap (default 40) | the step's per-attempt tool-round bound (the Gate's `maxTurns` natively) |
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). `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
+
178
+ Red vocabulary (all three validators): `parse-error`, `unknown-field`, `missing-required`,
144
179
  `invalid-value`, `bounds`, `duplicate-id`, `close-type`, `close-hierarchy`,
145
- `secret-literal`, `scope-escape`, `fence-invalid` (a malformed `jobWriteScope` fence — attributed to `jobWriteScope`, never the workflow config), plus the workflow-side verb reds (`verb-illegal`,
146
- `verb-placement`, `verb-params`, `slot-overflow`). The `secret-literal` sweep is
180
+ `secret-literal`, `scope-escape`, `fence-invalid` (a malformed `jobWriteScope` fence — attributed to `jobWriteScope`, never the workflow config), `shape-conflict` (both job shapes declared),
181
+ `request-red` (locked-but-listed: a locked tool or verdictType — admission demand the
182
+ ledger tallies), plus the workflow-side verb reds (`verb-illegal`,
183
+ `verb-placement`, `verb-params`, `slot-overflow`) and the plan-side reds (`verb-escape`,
184
+ `exit-illegal`, `check-unknown`, `job-invalid` — a plan validated against a missing or
185
+ non-plan-shape job fails CLOSED). The `secret-literal` sweep is
147
186
  defense-in-depth against known token shapes — env-only loading remains the law, not the
148
187
  sweep.
149
188
 
150
189
  ## Public API
151
190
 
152
- *Landed through N2 (spine + shell + both validators + interpreter with text/tool middles
153
- + extractor + runJob). Still TBD: N3 (contrast-bit extractor live), N4 (verdict classes —
154
- gold/rubric close EXECUTION), N5 (scheduler + budget ops + CLI), N6 (panel).*
191
+ *Landed through N2 + the Layer 2 core (spine + shell + three validators + interpreter
192
+ with text/tool middles + the plan executor + extractor + runJob). Still TBD: N3
193
+ (contrast-bit extractor live), N4 (verdict classes — gold/rubric close EXECUTION), N5
194
+ (scheduler + budget ops + CLI), N6 (panel).*
155
195
 
156
196
  ### `makeSpine(file)` → `emit(type, data?)` — `src/spine.js`
157
197
 
@@ -159,7 +199,7 @@ Append-only JSONL event emitter bound to one file. `seq` monotonic per spine, `t
159
199
  last. Consumers are pure listeners; nothing reads the file back. Returns each event as
160
200
  written.
161
201
 
162
- ### `ralph({ middle, close, capRuns, emit, redact?, closeTimeoutMs?, cwd?, expect?, judged?, gapKeep?, workerWrites? })` → `'green' | 'escalated'` — `src/ralph.js`
202
+ ### `ralph({ middle, close?, judge?, capRuns, emit, redact?, closeTimeoutMs?, cwd?, expect?, judged?, gapKeep?, workerWrites? })` → `'green' | 'escalated'` — `src/ralph.js`
163
203
 
164
204
  The dumb outer shell: `while close-red and under-cap: run the middle`. `close` is an argv
165
205
  whose exit code is truth (`runClose` is also exported); the red gap text feeds the next
@@ -205,6 +245,15 @@ to the gate audit's allow-decision write/edit lines (run_id-scoped); no seam or
205
245
  keeps the old behavior — an instrument crash still escalates `close-crashed`, never retried.
206
246
  Measured motivation: battery pass 1 (F31) lost 4 of 7 rows to exactly this escalation.
207
247
 
248
+ **The judge seam (Layer 2, PRD v1.12 §4).** `judge?: async () => {verdict, gap?, detail?}`
249
+ — a SHELL-injected replacement for `runClose`, so a plan step's micro-loop is judged by
250
+ the exit evaluator instead of a command. It returns the same verdict vocabulary, so the
251
+ forbidden zone, F32 worker-crash routing (a check crashed by the worker's own broken test
252
+ feeds back — the F46 mechanism), and the cap taxonomy apply unchanged. `close` becomes
253
+ optional when `judge` is present; the seam is wired by `runPlan` only and is
254
+ inexpressible in any config or plan — the agent never authors its judge any more than
255
+ its close.
256
+
208
257
  Escalations are decision-ready (category, options, spend); cap-halt is its own
209
258
  category, never merged with "wrong". A thrown middle is relayed by its `category`
210
259
  property (`cap-halt`, `gate-red`, …); an unnamed throw is `interpreter-red`. Close output
@@ -235,9 +284,35 @@ a leak on the very output it was guarding.
235
284
  The operator-owned sibling (never an extension) of `validateConfig`: validates a
236
285
  `job-v1` spec — see **All options** for the full schema, close types, and hierarchy.
237
286
  Never throws on JSON text or plain parsed data (the ingest contract); returns the
238
- parsed spec on ok, `null` on any red. Menus exported:
287
+ parsed spec on ok, `null` on any red. Validates BOTH job shapes (legacy `steps[]` and
288
+ the Layer 2 plan shape — see the option tables above). Menus exported:
239
289
  `CLOSE_TYPES`, `CLASSES`, `CLASS_BY_CLOSE`, `GOLD_COMPARE`, `CADENCE_UNITS`,
240
- `PROVIDERS`, `CONDITION_KEYS`, `STEP_MODES`, `TOOL_MENU`, `LOCKED_TOOLS`.
290
+ `PROVIDERS`, `CONDITION_KEYS`, `STEP_MODES`, `TOOL_MENU`, `LOCKED_TOOLS`,
291
+ `VERDICT_TYPES`, `LOCKED_VERDICTS`.
292
+
293
+ ### `validatePlan(input, { job, maxStepRounds? })` → `{ ok, reds, plan }` — `src/plan.js`
294
+
295
+ The third validator: gates the AGENT-authored plan doc (`schema: "plan-v1"`) against the
296
+ SIGNED job spec before tokens burn — the ceiling, the fence, and the checks menu all come
297
+ from `job` (a missing or non-plan-shape job fails CLOSED, `job-invalid`). Never throws;
298
+ same `{ code, path, detail }` red shape as its siblings; `verb-escape` reds carry the
299
+ escaping verb as a structured `verb` field (the ledger keys on it). `maxStepRounds`
300
+ (default 40 — the shell's tool-mode per-attempt bound) ceilings every step's `rounds`.
301
+ Menus exported: `EXIT_TYPES`, `MAX_EXITS_PER_STEP`, `MAX_PLAN_STEPS`, `WRITE_VERBS`.
302
+
303
+ ### `snapshotScope(dir, scope)` / `evalExits(exits, { dir, snapshot?, runCheck? })` — `src/exits.js`
304
+
305
+ The shell's own fixed code for the closed exit menu — nothing here executes
306
+ agent-authored text. `snapshotScope` hashes every file under a scope prefix (the
307
+ "before" side of `tree-changed`; a missing dir snapshots empty). `evalExits` is AND-only
308
+ and never short-circuits — the result names EVERY failing wall (`{ pass, results }`,
309
+ each result `{ type, pass, detail?, fault? }`). `tree-changed` reads OUTCOME (bytes vs
310
+ the snapshot): an identical re-write is NOT a change (F43) and git status is never
311
+ consulted (F45). `artifact-written` rejects zero-byte files. `check-passes` delegates
312
+ through the `runCheck` seam (the runner wires runClose); an unwired or crashed seam
313
+ fails CLOSED with `fault` carrying a runClose verdict name — an instrument fault
314
+ escalates through `CLOSE_FAULTS`, never masquerades as worker feedback. Failing details
315
+ are counts and names only, never file bodies (they ride the append-only spine).
241
316
 
242
317
  ### `jobSpecHash(job)` / `checkApproval(job, approvals)` — `src/job.js`
243
318
 
@@ -301,16 +376,21 @@ through `Loop`'s `onToolResult`). So a repeat that never applied is told its anc
301
376
  missed, and only content actually in the file is ever described as having landed. Spine event `root-injected` carries
302
377
  `{stage, mode, streak, paths, redSetSize}` — counts and paths only, NEVER content (the
303
378
  spine is append-only). State dies with the run: this is within-run scratch, not
304
- across-run memory. `layerRoot: true` (on `interpret` and `runJob`) is the ON/experimental
305
- arm; the default is OFF. Status (F41): the ratchet is MEASURED inert on every current
306
- job — two frozen probes + an archive sweep read 0 fixated pairs in 14 (the F21 repetition
307
- disease was a broken-loop symptom, since cured). Because ON has therefore never won its
308
- own A/B, it ships **OFF by default (decided 2026-07-21)** — armed and correct, but not
309
- default-enabled until a stuck job (Layer 2, or a manufactured-fixation probe) shows ON
310
- beats OFF. Flip the default to `true` the day that evidence lands; a `root-injected` event
311
- on your spine (when you pass `layerRoot: true`) is a signal worth reading, not noise.
312
-
313
- ### `runJob(spec, { approvals, workdir, provider, emit, target?, capRuns?, shellCapUsd?, closeTimeoutMs?, execCmd?, layerRoot? })` → outcome — `src/run.js`
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.
392
+
393
+ ### `runJob(spec, { approvals, workdir, provider, nativeProvider?, emit, target?, capRuns?, shellCapUsd?, closeTimeoutMs?, execCmd?, layerRoot? })` → outcome — `src/run.js`
314
394
 
315
395
  The N2 runner — the shell's top layer; composes everything below it and interprets
316
396
  nothing itself. Sequence: **approval gate** (human-signs-always — refuses an unapproved
@@ -349,6 +429,49 @@ launder a missing `spentUsd` into `$0`. A text-mode job invoked without `opts.ta
349
429
  before ANY provider call (and `interpret` itself throws a TypeError for direct
350
430
  callers) — reds-before-tokens applies to the call, not just the spec.
351
431
 
432
+ **The plan flow (Layer 2).** A plan-shape spec routes through the SAME `runJob` entry —
433
+ same approval gate, same smoke, same metered ledger, same job-end money contract
434
+ (`job-start` carries `shape: 'plan'` + the goal instead of a steps list; no `opts.target`
435
+ needed — plan steps are tool-mode by construction). The flow (`runPlan`, also exported
436
+ for direct callers who own their own ledger): **close precheck** (`already-green` is a
437
+ DISTINCT zero-token outcome; a forbidden-zone verdict escalates before spend) → **checks
438
+ preflight** (every SIGNED check runs once at $0 — an unrunnable check is a `check-red`
439
+ stop before tokens, not a fault mid-plan) → **SCOUT** (read-only by construction: the
440
+ write verbs are not in its menu; hard-bounded rounds) → **PLAN** (the decompose call —
441
+ the planner never sees the repo, only the scout blob; drafted against a schema
442
+ description with check NAMES only; `validatePlan` gates it, one redraft with the reds
443
+ fed back, then `plan-red`) → **EXECUTE** (strictly sequential micro-loops: `ralph` with
444
+ the exit-evaluator judge; tree snapshots at step start; the gap names every failing
445
+ wall — mechanical genre, F46's measured mechanism; artifacts feed forward labeled by
446
+ step id) → **ONE replan**, triggered by exhaustion only (an instrument stop never
447
+ replans) → **the operator's close**, a red feeding ONE bounded fix loop judged by the
448
+ REAL close. `plan-executed` (the plan-as-executed record, design law #2) lands on the
449
+ spine on every path that executed steps. Additional outcomes: `already-green |
450
+ plan-red | check-red | close-red`. Worker prompts hold the v1.12 §5 contract
451
+ (mutation-proven): the absolute repo root, the step's action/target, prior artifacts,
452
+ the gap — NEVER the budget, the close command, a check's command, or the arbiter's books.
453
+
454
+ **Two worker surfaces — API and native clipipe (BA-16, module 4d).** The plan flow is
455
+ provider-agnostic: the close, the checks, and the exit evaluator are commands and form
456
+ checks, so ONLY the worker differs. `job.provider === 'clipipe-subscription'` drives tools
457
+ NATIVELY (the `claude` CLI owns the turn cycle) instead of through the `Loop`; every other
458
+ provider runs the Loop unchanged (byte-identical — the API path cannot regress). Native
459
+ governance is **constructor-time and per-worker**, so the runner cannot reuse one injected
460
+ instance: pass `opts.nativeProvider`, a FACTORY the runner calls fresh per worker as
461
+ `({ policy, onTurn?, maxTurns, hasTools }) => provider`. Return the right mode by `hasTools`:
462
+ `true` → native tool mode (`toolProtocol:'claude-mcp'`, wire the gate's `policy` + `onTurn` +
463
+ `maxTurns` onto the provider — the SAME `wireGate` fence, so a write outside the scope is
464
+ DENIED at the bridge, live-proven); `false` → the toolless drafter, where a native session
465
+ reports NO cost, so return a metered claude-json TEXT provider (`--output-format json`,
466
+ `parse:'claude-json'`) — never an unmetered, invisible-spend session. A missing factory on a
467
+ `clipipe-subscription` job is `interpreter-red`, never a silent fall-back to the metered API.
468
+ Money reconciles PER SESSION (the CLI prices the session, `costUsd` null per turn,
469
+ authoritative at close): the accounted `worker-round` is the session total, per-turn
470
+ `worker-turn` events are attribution only. `maxTurns` (→ CLI `--max-turns`) is the
471
+ per-attempt bound and surfaces `max_turns` as a BOUNDED attempt (judged, gap fed forward),
472
+ the native analog of `loop.stop()`. Live-validated green end-to-end on the real CLI; the
473
+ Loop path is untouched.
474
+
352
475
  **Resume-to-cap (close-first skip):** every predicate step runs its close FIRST, before
353
476
  any tokens (`close-precheck` on the spine, output scrubbed at capture like every close).
354
477
  Already-green skips the step for zero tokens as a DISTINCT record — `step-end` outcome
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.4.0",
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",
@@ -23,7 +23,7 @@
23
23
  "prepublishOnly": "npm run build:types"
24
24
  },
25
25
  "dependencies": {
26
- "bare-agent": "^0.29.0",
26
+ "bare-agent": "^0.33.1",
27
27
  "bareguard": "^0.12.0",
28
28
  "litectx": "^0.29.1"
29
29
  },
package/src/exits.js ADDED
@@ -0,0 +1,163 @@
1
+ // Layer 2: the exit evaluator — the SHELL's own fixed code for the closed
2
+ // exit menu (PRD v1.12 §3). An exit is declarative data; nothing here ever
3
+ // executes agent-authored text. Doctrine wired in, not promised:
4
+ //
5
+ // - exits verify FORM, not truth: progress gates only; the operator's close
6
+ // stays the one arbiter (checks decide nothing, mint nothing).
7
+ // - `tree-changed` reads OUTCOME — real bytes vs a pre-step snapshot taken
8
+ // by `snapshotScope` — never git status (always shows the planted bug,
9
+ // F45) and never gate intent (the F43 identical-refire trap: an allowed
10
+ // write that lands the same bytes is NOT a change).
11
+ // - a failing exit's detail is a MECHANICAL gap (a named wall, a count) —
12
+ // the genre that converts (F38/F46); details carry names and counts only,
13
+ // never file bodies (they ride the spine, which is append-only forever).
14
+ // - `check-passes` delegates through the `runCheck` seam the runner wires
15
+ // to the full runClose machinery; the evaluator itself never spawns. An
16
+ // unwired or throwing check fails CLOSED (the broken-close class: an
17
+ // unrunnable check is a stop, never a silent pass).
18
+
19
+ import { createHash } from 'node:crypto';
20
+ import { readFile, readdir } from 'node:fs/promises';
21
+ import { join } from 'node:path';
22
+ import { globToPrefix } from './validate.js';
23
+
24
+ /** The check gap is ALREADY bounded upstream by runClose's boundGap (head +
25
+ * gapKeep block ≤ 8192 + tail ≈ 10KB, with the failing-test NAMES preserved).
26
+ * The evaluator must NOT re-truncate it: a 400-char clamp deletes exactly those
27
+ * `not ok`/`FAILED` names — the mechanical gap the F46 conversion mechanism
28
+ * feeds the worker (F28 reintroduced). This cap is a defensive backstop above
29
+ * boundGap's own envelope: a normal bounded gap passes through intact, and only
30
+ * a pathological seam that returned an unbounded gap would ever be trimmed. */
31
+ const CHECK_GAP_MAX = 12000;
32
+
33
+ /** @typedef {{ type: string, pass: boolean, detail?: string, fault?: string }} ExitResult
34
+ * `fault` present means the INSTRUMENT could not judge (unwired snapshot/seam,
35
+ * a check in runClose's forbidden zone) — it carries the runClose verdict name
36
+ * ('failed'|'timed-out'|'killed'|'crashed') so the micro-loop escalates through
37
+ * the same CLOSE_FAULTS taxonomy instead of feeding a fake gap to the worker.
38
+ * A plain pass:false with no fault is honest worker feedback. */
39
+
40
+ /**
41
+ * Snapshot the file state under a scope prefix — the "before" side of
42
+ * `tree-changed`. Content hashes, not mtimes: only real bytes count as
43
+ * outcome. A scope directory that does not exist yet snapshots empty (the
44
+ * step creating it IS the change). Never throws on a missing dir.
45
+ * @param {string} dir absolute workdir (the run directory)
46
+ * @param {string} scope a validated scope glob (e.g. `tests/**`)
47
+ * @returns {Promise<Map<string, string>>} relative path → sha256
48
+ */
49
+ export async function snapshotScope(dir, scope) {
50
+ const prefix = globToPrefix(scope);
51
+ /** @type {Map<string, string>} */
52
+ const snap = new Map();
53
+ /** @type {string[]} */
54
+ let entries;
55
+ try {
56
+ entries = /** @type {string[]} */ (await readdir(join(dir, prefix), { recursive: true, encoding: 'utf8' }));
57
+ } catch {
58
+ return snap; // missing scope dir = empty snapshot, never a throw
59
+ }
60
+ for (const rel of entries) {
61
+ const full = join(dir, prefix, rel);
62
+ let body;
63
+ try { body = await readFile(full); } catch { continue; } // directories and vanished files skip
64
+ snap.set(`${prefix}/${rel.replaceAll('\\', '/')}`, createHash('sha256').update(body).digest('hex'));
65
+ }
66
+ return snap;
67
+ }
68
+
69
+ /**
70
+ * Evaluate a step's exits — AND-only (decision 8): the step passes iff EVERY
71
+ * listed exit passes. All exits are evaluated (never short-circuit): the gap
72
+ * must name every wall, not the first one hit.
73
+ * @param {Array<Record<string, any>>} exits validated exit items
74
+ * @param {{ dir: string, snapshot?: Map<string, string>, runCheck?: (name: string) => Promise<{pass: boolean, gap?: string}> }} ctx
75
+ * `dir`: the run directory; `snapshot`: the pre-step `snapshotScope` result
76
+ * (required by tree-changed — absent fails closed); `runCheck`: the runner's
77
+ * runClose-backed seam (required by check-passes — absent fails closed).
78
+ * @returns {Promise<{ pass: boolean, results: ExitResult[] }>}
79
+ */
80
+ export async function evalExits(exits, { dir, snapshot, runCheck }) {
81
+ /** @type {ExitResult[]} */
82
+ const results = [];
83
+ for (const e of exits) {
84
+ results.push(await evalOne(e, { dir, snapshot, runCheck }));
85
+ }
86
+ return { pass: results.every((r) => r.pass), results };
87
+ }
88
+
89
+ /**
90
+ * @param {Record<string, any>} e
91
+ * @param {{ dir: string, snapshot?: Map<string, string>, runCheck?: (name: string) => Promise<{pass: boolean, gap?: string}> }} ctx
92
+ * @returns {Promise<ExitResult>}
93
+ */
94
+ async function evalOne(e, { dir, snapshot, runCheck }) {
95
+ if (e.type === 'artifact-written') {
96
+ let body;
97
+ try { body = await readFile(join(dir, e.path), 'utf8'); } catch {
98
+ return { type: e.type, pass: false, detail: `${e.path} was not written (does not exist)` };
99
+ }
100
+ if (body.length === 0) return { type: e.type, pass: false, detail: `${e.path} is empty — a zero-byte artifact is not progress` };
101
+ if (e.pattern !== undefined && !new RegExp(e.pattern, 'm').test(body)) {
102
+ return { type: e.type, pass: false, detail: `${e.path} exists but does not match the declared pattern ${e.pattern}` };
103
+ }
104
+ return { type: e.type, pass: true };
105
+ }
106
+
107
+ if (e.type === 'tree-changed') {
108
+ if (!(snapshot instanceof Map)) {
109
+ // fail CLOSED as a FAULT: without the before-side this instrument is
110
+ // blind — it must never read "changed" (F45) and never feed the miss to
111
+ // the worker as if it were inaction
112
+ return { type: e.type, pass: false, fault: 'failed', detail: `no pre-step snapshot for ${e.scope} — the tree-changed instrument is unwired (instrument fault, not worker inaction)` };
113
+ }
114
+ const now = await snapshotScope(dir, e.scope);
115
+ // deletions count only WITHIN this exit's own scope: the step's `snapshot`
116
+ // is merged across every tree-changed scope, so an unscoped keys() walk
117
+ // would read a SIBLING scope's files (absent from this re-scan) as deletions
118
+ // and pass an unchanged scope by borrowing the other's change.
119
+ const prefix = globToPrefix(e.scope);
120
+ const underScope = (/** @type {string} */ p) => p === prefix || p.startsWith(`${prefix}/`);
121
+ let changed = 0;
122
+ for (const [path, hash] of now) if (snapshot.get(path) !== hash) changed++;
123
+ for (const path of snapshot.keys()) if (underScope(path) && !now.has(path)) changed++; // deletions are real changes
124
+ if (changed === 0) {
125
+ return { type: e.type, pass: false, detail: `0 files changed under ${e.scope} — the tree is byte-identical to the step start (an identical re-write is not a change)` };
126
+ }
127
+ return { type: e.type, pass: true, detail: `${changed} file(s) changed under ${e.scope}` };
128
+ }
129
+
130
+ if (e.type === 'json-valid') {
131
+ let body;
132
+ try { body = await readFile(join(dir, e.path), 'utf8'); } catch {
133
+ return { type: e.type, pass: false, detail: `${e.path} was not written (does not exist)` };
134
+ }
135
+ try { JSON.parse(body); } catch (err) {
136
+ return { type: e.type, pass: false, detail: `${e.path} is not valid JSON: ${String(/** @type {Error} */ (err).message).slice(0, 200)}` };
137
+ }
138
+ return { type: e.type, pass: true };
139
+ }
140
+
141
+ if (e.type === 'check-passes') {
142
+ if (typeof runCheck !== 'function') {
143
+ return { type: e.type, pass: false, fault: 'failed', detail: `check "${e.name}" cannot run — runCheck is not wired (instrument fault: an unrunnable check is a stop, never a silent pass)` };
144
+ }
145
+ try {
146
+ const r = await runCheck(e.name);
147
+ if (r.pass) return { type: e.type, pass: true };
148
+ // the seam's fault (a runClose forbidden-zone verdict) rides through by
149
+ // NAME: 'crashed' keeps its F32 routing (worker-crash after writes —
150
+ // the F46 broken-test mechanism), 'timed-out' keeps its own decision
151
+ if (typeof (/** @type {any} */ (r).fault) === 'string') {
152
+ return { type: e.type, pass: false, fault: /** @type {any} */ (r).fault, detail: `check "${e.name}": ${(r.gap ?? '').slice(0, CHECK_GAP_MAX)}` };
153
+ }
154
+ return { type: e.type, pass: false, detail: `check "${e.name}" red: ${(r.gap ?? '').slice(0, CHECK_GAP_MAX)}` };
155
+ } catch (err) {
156
+ return { type: e.type, pass: false, fault: 'failed', detail: `check "${e.name}" crashed the seam: ${String(/** @type {Error} */ (err).message).slice(0, 200)}` };
157
+ }
158
+ }
159
+
160
+ // belt — the validator already reds unknown types; the evaluator must not
161
+ // silently pass what it cannot judge
162
+ return { type: String(e.type), pass: false, fault: 'failed', detail: `unknown exit type "${e.type}" — the evaluator cannot judge it (fail closed)` };
163
+ }
package/src/index.js CHANGED
@@ -16,7 +16,13 @@ export { makeSpine } from './spine.js';
16
16
  // exports map admits only ".", so a deep import cannot reach them).
17
17
  export { ralph, runClose, CLOSE_FAULTS } from './ralph.js';
18
18
  export { validateConfig, diffPaths, globToPrefix, scanSecrets, LOOP_SHAPES, SLOTS, VERBS } from './validate.js';
19
- export { validateJob, jobSpecHash, checkApproval, CLOSE_TYPES, CLASSES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS, STEP_MODES, TOOL_MENU, LOCKED_TOOLS } from './job.js';
19
+ export { validateJob, jobSpecHash, checkApproval, CLOSE_TYPES, CLASSES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS, STEP_MODES, TOOL_MENU, LOCKED_TOOLS, VERDICT_TYPES, LOCKED_VERDICTS } from './job.js';
20
+ // Layer 2: the plan-v1 validator — the agent-authored plan doc's gate; the
21
+ // two-doc split's third validator never happens (plan-v1 gates the PLAN, the
22
+ // job spec stays the arbiter's only home).
23
+ export { validatePlan, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, WRITE_VERBS } from './plan.js';
24
+ export { snapshotScope, evalExits } from './exits.js';
25
+ export { runPlan } from './planrun.js';
20
26
  export { interpret, STALL_REDS } from './interpret.js';
21
27
  export { extractRules, MAX_RULES, MAX_RULE_CHARS } from './extract.js';
22
28
  export { runJob } from './run.js';