bareloop 0.2.0 → 0.4.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.
@@ -1,9 +1,10 @@
1
1
  # bareloop — Integration Guide
2
2
 
3
- > **Current through N1** (job/close schema); API sections fill in as build-ladder rungs
4
- > land (PRD §10). What is settled — the boundary, the architecture, the refusals, the
5
- > constraints — is settled for good. Per LIBRARY_CONVENTIONS §3 this file ships with the
6
- > package and is the complete adopter contract; the README is only the pitch.
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.
7
8
 
8
9
  ## What this is
9
10
 
@@ -24,8 +25,36 @@ inherited rule carries the green that minted it and the contrast that attributed
24
25
 
25
26
  ## Minimal usage
26
27
 
27
- *TBD at N2 (first headless single-job loop). Will show: define a job (description,
28
- checkpoints + verdict classes, budget, cadence, provider) → run → watch the spine.*
28
+ ```js
29
+ import { runJob, jobSpecHash, makeSpine } from 'bareloop';
30
+ import { AnthropicProvider } from 'bare-agent';
31
+
32
+ const spec = {
33
+ schema: 'job-v1', job: 'my-maintainer',
34
+ description: 'fix src until the suite greens, then PR',
35
+ provider: 'anthropic-api',
36
+ cadence: { unit: 'day', every: 1 }, budgetUsd: 1.5,
37
+ writeScope: ['src/**'],
38
+ steps: [
39
+ { id: 'fix', mode: 'tools', tools: ['read', 'grep', 'write'],
40
+ close: { type: 'predicate', cmd: 'npm test', expect: 0 }, class: 'hard' },
41
+ { id: 'pr', close: { type: 'hitl', prompt: 'PR opened — review and merge?' }, class: 'hitl' },
42
+ ],
43
+ escalation: { mode: 'decision-ready' },
44
+ };
45
+
46
+ // human-signs-always: the approval record lives OUTSIDE the spec (a file the
47
+ // human writes); an edited spec is unapproved by construction
48
+ const approvals = [{ specHash: jobSpecHash(spec), signer: 'you', ts: new Date().toISOString() }];
49
+
50
+ const outcome = await runJob(spec, {
51
+ approvals, workdir: '/path/to/checkout',
52
+ provider: new AnthropicProvider({ model: 'claude-sonnet-5' }), // key from env
53
+ emit: makeSpine('/path/to/checkout/run.jsonl'),
54
+ });
55
+ // 'escalated' at the hitl step is the happy path: the draft PR URL rides the
56
+ // decision-ready escalation on the spine; merge stays human, forever.
57
+ ```
29
58
 
30
59
  ## All options
31
60
 
@@ -46,7 +75,9 @@ unknown-field reds.
46
75
  | `cadence` | `{ unit: hour\|day\|week, every: 1..30 }` | validated now, consumed at N5 (Scheduler) |
47
76
  | `budgetUsd` | `0 < n <= shell cap` | ceiling chain: workflow ≤ job ≤ shell — each layer may tighten, never exceed |
48
77
  | `writeScope` | array of contained globs | the operator's outer fence; same containment law as the workflow layer, same code |
49
- | `steps` | array of `{ id, close, class }` | unique slug ids; every step names its close |
78
+ | `steps` | array of `{ id, close, class, mode?, tools? }` | unique slug ids; every step names its close |
79
+ | `steps[].mode` | `text` (default) \| `tools` | `text`: the worker returns ONE artifact written to the shell's `target`; `tools`: the worker drives Gate-governed file tools (multi-file). Illegal on `hitl` steps (they run no loop) |
80
+ | `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`) |
50
81
  | `escalation` | `{ mode: "decision-ready" }` | the pain channel is not optional |
51
82
 
52
83
  **Close types and the hierarchy** (a close is data, never code; verdict-class laundering
@@ -54,11 +85,61 @@ is a named red `close-hierarchy`):
54
85
 
55
86
  | type | fields (exact — extras red) | legal class |
56
87
  |---|---|---|
57
- | `predicate` | `cmd`, `expect` (int exit code) | `hard` |
88
+ | `predicate` | `cmd`, `expect` (int exit code), `judged?`, `gapKeep?` | `hard` |
58
89
  | `gold` | `expected`, `compare: exact\|json-equal` | `hard` |
59
90
  | `rubric` | `criteria` | `soft` only — can never mint automatically |
60
91
  | `hitl` | `prompt` | `hitl` — a human IS the close |
61
92
 
93
+ **`judged` — the judgment-rendered signal (optional, `predicate` only; PRD v1.11 / F17).**
94
+ `{ pattern: string, min: int >= 1 }` — a regex with **exactly one** capture group (zero and
95
+ more-than-one both red at validation), run over the close's own (redacted) output to extract
96
+ how many things it actually judged, against a declared floor. The count is read from group 1
97
+ only, so an alternation must use non-capturing branches — `(?:tests|passed:) (\d+)`, never
98
+ `(?:tests (\d+)|passed: (\d+))`, whose second branch would leave group 1 undefined and stamp
99
+ an exit-0 green as `crashed` (F40). **Why it exists:** an exit code cannot distinguish "the suite ran and failed" from
100
+ "the suite crashed at load" — and it cannot distinguish a real green from a close that ran
101
+ **no tests at all**. Pointed at a tree with no suite, `node --test` exits 0, and without
102
+ `judged` the arbiter returns `satisfied`: a fake green (law #8's only real failure). The
103
+ floor is checked on **both** bands. Below it, the verdict is `crashed`, whatever the exit
104
+ code said.
105
+
106
+ It is a **floor, not a zero-check**: `node --test` reports a crashed file as ONE failing
107
+ test, so "zero executed" never fires. Declare it against the suite's real size
108
+ (`{ pattern: "^ℹ tests (\\d+)$", min: 300 }` for a 391-test suite, with
109
+ `--test-reporter=spec`, which prints both the counts and the failures at the end). It
110
+ catches *"the arbiter did not run"* — wrong tree, broken argv, a failed shared import — not
111
+ *"one test file is broken"*, which is an honest red the worker should fix.
112
+
113
+ **Capture the count of tests EXECUTED, never tests PASSED.** A passed-count pattern
114
+ conflates "did the close judge?" with "did the tests pass?", so a genuinely red tree —
115
+ exactly the state a fix job starts from — falls under the floor and is escalated as an
116
+ instrument crash at precheck, before the worker it hired ever runs. `# tests (\d+)` and
117
+ `^ℹ tests (\d+)$` hold on a red tree; `(\d+) passed` does not (F40).
118
+
119
+ Omitting it is legal (a linter, a `hitl` close, have nothing to count) and stamps
120
+ `unaudited: true` on the verdict plus a `close-unaudited` spine event: **the blind spot is
121
+ named, never assumed away.** The agent-drafted workflow config cannot express `judged` —
122
+ it is the arbiter's own honesty check (unknown-field red, enforced per section at every
123
+ depth).
124
+
125
+ **`gapKeep` — the kept-failures pattern (optional, `predicate` only; F28).** A regex
126
+ **source** string (e.g. `"^not ok"`). The close's output is the worker's ONLY feedback
127
+ channel, and `ralph`'s gap bound keeps a head sample + an elided middle + the tail — but a
128
+ large TAP suite prints its `not ok` lines in the **middle**, exactly where the bound elides.
129
+ The first real firing of the loop delivered a 1,927-char gap with **zero** failure lines:
130
+ the worker was told "5 fail" and never *which*, three attempts running (F28). `gapKeep`
131
+ preserves every close-output line matching it in a clearly-delimited **kept-failures block**
132
+ between head and tail, so the failing-test NAMES — the causal input navigation runs on —
133
+ reach the worker. It is **hard-capped** (max 50 kept lines / 8192 bytes, whichever binds
134
+ first) so a pathological close cannot rebuild the very bloat the bound exists to prevent;
135
+ when the cap trims matches it **says so** with an explicit marker (silent truncation is the
136
+ disease this cures, never a cure that truncates silently). Validated like `judged.pattern`:
137
+ a non-empty string that must **compile as a RegExp**, else a spec red before any tokens.
138
+ Omitting it is exactly today's bound. Like `judged`, it is **arbiter territory** — the
139
+ drafted workflow config cannot express it (unknown-field red). The gap path also combines
140
+ **both** streams (stdout + stderr), so a failure printed to stdout survives stderr noise
141
+ (F28's adjacent hazard — the old `err || out` returned stderr alone and lost it).
142
+
62
143
  Red vocabulary (both validators): `parse-error`, `unknown-field`, `missing-required`,
63
144
  `invalid-value`, `bounds`, `duplicate-id`, `close-type`, `close-hierarchy`,
64
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`,
@@ -68,9 +149,9 @@ sweep.
68
149
 
69
150
  ## Public API
70
151
 
71
- *Landed through N0 (spine + shell + validator + interpreter + extractor). Still TBD:
72
- N1 (job/close schema), N2 (headless job loop), N3 (contrast-bit extractor), N4 (verdict
73
- classes), N5 (scheduler + budget ops), N6 (panel).*
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).*
74
155
 
75
156
  ### `makeSpine(file)` → `emit(type, data?)` — `src/spine.js`
76
157
 
@@ -78,11 +159,53 @@ Append-only JSONL event emitter bound to one file. `seq` monotonic per spine, `t
78
159
  last. Consumers are pure listeners; nothing reads the file back. Returns each event as
79
160
  written.
80
161
 
81
- ### `ralph({ middle, close, capRuns, emit })` → `'green' | 'escalated'` — `src/ralph.js`
162
+ ### `ralph({ middle, close, capRuns, emit, redact?, closeTimeoutMs?, cwd?, expect?, judged?, gapKeep?, workerWrites? })` → `'green' | 'escalated'` — `src/ralph.js`
82
163
 
83
164
  The dumb outer shell: `while close-red and under-cap: run the middle`. `close` is an argv
84
165
  whose exit code is truth (`runClose` is also exported); the red gap text feeds the next
85
- iteration. Escalations are decision-ready (category, options, spend); cap-halt is its own
166
+ iteration, tail-biased when bounded (400 head + 1500 tail — the assertion diff lives at
167
+ the end). **`cwd` is where the close RUNS, and it is load-bearing (F8):** a close is a
168
+ repository command (`npm test`, `make check`) and every one of them is cwd-relative — run
169
+ it anywhere but the workdir and the arbiter judges the wrong tree. `interpret`/`runJob`
170
+ always pass the workdir. **Corollary for job authors (F15/F28):** the gap bound keeps the
171
+ head and the TAIL, so a close whose failures print mid-stream (a 391-subtest TAP dump) tells
172
+ the worker only the summary counts — pick a reporter whose failures land at the end, **or**
173
+ set the close's `gapKeep` (job-spec, arbiter territory) to a regex like `"^not ok"` so the
174
+ matching failure lines are preserved in a capped kept-failures block regardless of where
175
+ they print (F28: the first real firing delivered a gap with zero failure names). The gap
176
+ also combines stdout+stderr, so a stdout failure survives stderr noise.
177
+ `closeTimeoutMs` caps the close's wall clock (default 120s) — shell/operator
178
+ territory, inexpressible in any config.
179
+
180
+ **The forbidden zone (PRD v1.11 / F17).** `runClose` returns a verdict ONLY when judgment
181
+ was rendered. `expect` (the signed exit code, default 0) and `judged` define the two clean
182
+ bands; everything else is **not a verdict**, gets its own name, and is **never retried** —
183
+ retrying a broken arbiter is the violation this closes. `CLOSE_FAULTS` (exported; the
184
+ runner's pre-token precheck uses the *same* map — two maps would be two instruments):
185
+
186
+ | verdict | what happened | escalation | the human's real options |
187
+ |---|---|---|---|
188
+ | `failed` | the close cannot RUN | `broken-close` | fix the argv |
189
+ | `timed-out` | ran, never finished judging | `close-timeout` | raise the timeout / make it faster |
190
+ | `killed` | died by signal (`status === null`, no spawn error) | `close-killed` | re-run / fix the environment (OOM) |
191
+ | `crashed` | ran, exited, judged nothing (see `judged`) | `close-crashed` | fix the crash / fix the argv / lower the floor |
192
+
193
+ `close-timeout` is deliberately **not** pooled into `broken-close`: "raise the timeout" and
194
+ "fix the command" are different human answers, and pooling them erases the decision
195
+ information the escalation exists to carry.
196
+
197
+ **Worker-crash attribution (F32).** One carve-out from the table above: a `crashed` verdict
198
+ does **not** escalate when the injected `workerWrites?: () => string[]` seam reports the
199
+ worker has written files this run — with a clean precheck baseline (`runJob` escalates a
200
+ crash-at-precheck before any tokens), that crash is the worker's own broken edit, the most
201
+ recoverable red there is. The routed verdict is the DISTINCT `worker-crash` (spine event
202
+ `worker-crash` with the file list; never plain `crashed`, never `needs_revision`), and the
203
+ gap tells the worker which files it wrote and to fix or revert. `interpret` wires the seam
204
+ to the gate audit's allow-decision write/edit lines (run_id-scoped); no seam or zero writes
205
+ keeps the old behavior — an instrument crash still escalates `close-crashed`, never retried.
206
+ Measured motivation: battery pass 1 (F31) lost 4 of 7 rows to exactly this escalation.
207
+
208
+ Escalations are decision-ready (category, options, spend); cap-halt is its own
86
209
  category, never merged with "wrong". A thrown middle is relayed by its `category`
87
210
  property (`cap-halt`, `gate-red`, …); an unnamed throw is `interpreter-red`. Close output
88
211
  is scrubbed at capture (an injected `redact`, wired to bareguard by `interpret` with the
@@ -101,6 +224,12 @@ inside `src`) or it reds `scope-escape`; pass `min(shell cap, job budgetUsd)` as
101
224
  (`LOOP_SHAPES`, `SLOTS`, `VERBS` exported). `diffPaths(a, b)` returns changed JSON paths —
102
225
  the one-knob mutation checker.
103
226
 
227
+ `scanSecrets(raw)` → `string[]` is the same module's text-side scan: every known
228
+ secret-shape match in a raw stream (a spine file, a close's output), `[]` when clean,
229
+ never throws. Use it instead of re-deriving a scan from `SECRET_PATTERNS` — detection
230
+ and redaction share ONE shape inventory, and a hand-rolled copy that misses a shape is
231
+ a leak on the very output it was guarding.
232
+
104
233
  ### `validateJob(input, { shellCapUsd? })` → `{ ok, reds, job }` — `src/job.js`
105
234
 
106
235
  The operator-owned sibling (never an extension) of `validateConfig`: validates a
@@ -108,7 +237,7 @@ The operator-owned sibling (never an extension) of `validateConfig`: validates a
108
237
  Never throws on JSON text or plain parsed data (the ingest contract); returns the
109
238
  parsed spec on ok, `null` on any red. Menus exported:
110
239
  `CLOSE_TYPES`, `CLASSES`, `CLASS_BY_CLOSE`, `GOLD_COMPARE`, `CADENCE_UNITS`,
111
- `PROVIDERS`, `CONDITION_KEYS`.
240
+ `PROVIDERS`, `CONDITION_KEYS`, `STEP_MODES`, `TOOL_MENU`, `LOCKED_TOOLS`.
112
241
 
113
242
  ### `jobSpecHash(job)` / `checkApproval(job, approvals)` — `src/job.js`
114
243
 
@@ -136,12 +265,164 @@ revision candidate, so a workflow scope outside the operator's fence reds before
136
265
  An enforcement belt resolves every scope and refuses to build a Gate that escapes the
137
266
  workdir, independent of validator correctness (law #1).
138
267
 
268
+ Two middles, chosen by the step (`mode`/`tools` opts — SPEC-side territory threaded by
269
+ the runner, never the config's): **text** (default) extracts ONE artifact from the
270
+ response (`artifact-red` on a non-artifact: writes nothing, names its own axis on the
271
+ spine, the retry is told why) and writes it to `target` behind a manual gate check —
272
+ a fence counts as the artifact's wrapper only when it opens within the first 5 lines
273
+ (the chatty-preamble shape); deeper fences are the artifact's OWN content and the whole
274
+ reply is the artifact (fence-heavy artifacts — doc generators, markdown emitters —
275
+ belong in tool mode, where nothing is parsed);
276
+ **tools** offers only the granted tools (`read|grep|write|edit|recall|get`) to the
277
+ worker's Loop, every call policy-checked against the SAME fence (tool-call paths resolve
278
+ exactly as the tools resolve them — workers must use absolute paths; a relative spelling
279
+ reds at the fence and the deny reason teaches the retry), reads pinned to the workdir, a
280
+ denial streak stopping as `gate-red`. `edit` maps to bare-agent's `shell_edit` (BA-13,
281
+ 0.29.0): an anchored exact-once replace judged as bareguard's `edit` action under the same
282
+ writeScope — and the persona carries the strategy (prefer the edit verb; whole-file
283
+ rewrites are how trees get broken, F31). In tool mode there is no artifact to extract —
284
+ the close judges the tree; on-green `remember` retains the worker's change summary.
285
+ Worker-caused close crashes feed back as `worker-crash` gaps via the gate audit (F32 —
286
+ see `ralph` above).
287
+
288
+ **Layer R — the within-run ratchet (`layerRoot`, default `false`; design record
289
+ 2026-07-19).** The shell mechanically detects fixation across attempts — consecutive
290
+ attempts that rewrote the same file(s) without moving the close's kept-failure set
291
+ (`closeGapKeep` lines, duration stamps stripped before comparing; without a gapKeep the
292
+ detector degrades to write-overlap alone and the spine event names the mode) — and
293
+ injects an escalating note into the next prompt: first a capped summary naming the
294
+ repeated files, then (if still stuck) the worker's OWN prior failed edit content
295
+ verbatim, scrubbed and capped with trims announced. Inert unless stuck; the worker
296
+ authors nothing and gains no verb. Two axes, deliberately separate (F43): the DETECTOR
297
+ reads INTENT (the gate audit's allow-set — an edit whose anchor missed is still the
298
+ worker reaching for that file again, and a tree-diff detector measured blind to exactly
299
+ that), while the NOTE's claims about file contents read OUTCOME (settled post-execution
300
+ through `Loop`'s `onToolResult`). So a repeat that never applied is told its anchor
301
+ missed, and only content actually in the file is ever described as having landed. Spine event `root-injected` carries
302
+ `{stage, mode, streak, paths, redSetSize}` — counts and paths only, NEVER content (the
303
+ 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`
314
+
315
+ The N2 runner — the shell's top layer; composes everything below it and interprets
316
+ nothing itself. Sequence: **approval gate** (human-signs-always — refuses an unapproved
317
+ spec before ANY provider call: `unapproved-spec`) → **primitive smoke** (litectx
318
+ known-answer round-trip before tokens: `smoke-red` — silent degradation throws nothing)
319
+ → **sequential per-step interpret loops** under the ONE cumulative ledger (each step's
320
+ ceiling is `min(job budget − spent, shell cap)`; a step that cannot green stops the job:
321
+ `step-red:<id>`) → the **hitl step** opens a draft PR deterministically (branch → stage
322
+ the job fence ONLY, so spines/audit logs never enter the PR → commit → push →
323
+ `gh pr create --draft`) and ends `escalated` BY DESIGN, the PR URL riding the
324
+ decision-ready escalation; a PR failure is `pr-red` and the escalation still fires with
325
+ the error. Model tools never touch git — `execCmd` is the shell-owned process seam
326
+ (defaults to real spawnSync). **The hitl step checks the fence first**: an
327
+ affirmatively-clean `git status --porcelain -- <fence>` means nothing changed, so there
328
+ is nothing for a human to review — `pr-skipped` + `step-end: already-green`, the job ends
329
+ `green`, no PR and no escalation (a cadenced no-op run is silent and free). A FAILED
330
+ check falls through to the PR path and reds there — an unknown fence state is never a
331
+ green. The PR step **hands the checkout back**: the starting branch is read before
332
+ anything moves and restored on every path; a failed restore is a loud `workdir-red`
333
+ naming the stranded branch (it never un-opens a real PR). Outcomes: `green | escalated | unapproved-spec | job-red |
334
+ smoke-red | config-red | pricing-red | provider-red | cap-halt | close-unsupported |
335
+ step-red:<id>` — `provider-red` is a transport throw from the drafting call, OR a worker
336
+ round the API cut off mid-generation (`truncated:max_tokens`, bare-agent 0.27.0/BA-6 —
337
+ before which it laundered into a clean finish, F25); in both cases no verdict exists and the
338
+ failed round's spend is only partly known (F6). `cap-halt` is drafting spend consuming the whole job budget before a
339
+ valid config existed. Both are decision-ready escalations with a terminal `job-end`:
340
+ the spine never dangles. **Every `job-end` carries the money, on every path**:
341
+ `{ outcome, spentUsd, spendComplete }` (plus `step`/`cause`/`detail` where the outcome has
342
+ them). `spentUsd` is the accumulated sum of PRICED rounds ONLY — never an estimate from
343
+ token counts or averages (cap-not-estimate) — and it is stated even on the pre-token reds,
344
+ where the honest figure is a real `0`. `spendComplete` says whether that figure is EXACT:
345
+ `false` means one or more rounds came back unpriced (F6), so `spentUsd` is a FLOOR
346
+ ("at least $X", true total unknowable) and must not be read as a total. Both fields are
347
+ present on all outcomes, so a consumer never branches on field presence and never has to
348
+ launder a missing `spentUsd` into `$0`. A text-mode job invoked without `opts.target` is a `job-red`
349
+ before ANY provider call (and `interpret` itself throws a TypeError for direct
350
+ callers) — reds-before-tokens applies to the call, not just the spec.
351
+
352
+ **Resume-to-cap (close-first skip):** every predicate step runs its close FIRST, before
353
+ any tokens (`close-precheck` on the spine, output scrubbed at capture like every close).
354
+ Already-green skips the step for zero tokens as a DISTINCT record — `step-end` outcome
355
+ `already-green`, never plain `green`: nothing was done, so it mints no learning credit
356
+ and runs no on-green retention. A close that cannot RUN is a `broken-close` escalation
357
+ before any provider call. **Config drafting is deferred** to the first step that
358
+ actually needs a worker — the sealed one-shot + one-redraft is unchanged, and a config
359
+ is always drafted fresh per run (never inherited), but a rerun whose closes all green
360
+ pays ZERO provider calls. So the resume story is: a `cap-halt` stop is the checkpoint
361
+ (the workdir + the closes), the human raises `budgetUsd` (a new spec hash — re-sign),
362
+ and the rerun picks up exactly where the budget died.
363
+
364
+ **Unpriced is never free (F6/F12):** the ledger meters **per ROUND** (`worker-round`, at
365
+ bare-agent's `onLlmResult` seam) — money is counted as it is spent, never at the end of an
366
+ attempt: a tool-mode attempt that HALTS never returns, so accounting its result event lost
367
+ the whole attempt's spend (a real run bought $1.4375 of tokens and the ledger reported
368
+ $0.0048). Any round whose cost is the honest null halts `pricing-red`, decision-ready; a
369
+ null never accumulates as $0, so the hard cap cannot be gamed by an unpriced provider path.
370
+ The drafting call is metered on its own priced path, and the shell **reserves a drafting
371
+ allowance** from the job budget (F9): the ceiling advertised to the drafter is
372
+ `(budgetUsd − reserve) ÷ predicate steps`, so a drafter that claims the ceiling it is given
373
+ always validates (the prompt and the validator must never enforce different numbers).
374
+
375
+ The **÷ steps** is F40: the config is drafted once and re-validated at every step against
376
+ what is LEFT, so a ceiling sized to the whole pot goes stale the moment step 1 spends and
377
+ the next step reds `bounds` on an unchanged config with money still available. A per-step
378
+ share still fits after its predecessors have spent (the shares sum to `budgetUsd − reserve`).
379
+ Single-predicate-step jobs are arithmetically unchanged. This is a DRAFTING bound, not an
380
+ enforcement one: cap-not-estimate is intact, and a step that genuinely needs more than its
381
+ share still cap-halts cleanly with a resume point rather than silently borrowing from the
382
+ steps after it.
383
+
384
+ **What the worker is told (tool mode):** the absolute repository root (F10 — bare-agent's
385
+ shell tools resolve relative paths against the PROCESS cwd, so a worker with no root is
386
+ blind), the close's CURRENT output as the tree's state (F13 — never framed as "your previous
387
+ attempt"; the `run` verb is locked, so the worker cannot see the failure any other way), and
388
+ the loop contract (F16 — it is ONE attempt inside `while close-red and under-cap` and will be
389
+ re-run with the close's verdict; without this a model one-shots and can eat the budget in
390
+ reads before ever writing). It is NOT allowed to read the run's own machinery (F14): the gate
391
+ audit, the smoke store and the litectx store are denied — the agent neither authors the
392
+ arbiter nor reads its books. N2 bounds (honest): `gold`/`rubric` closes refuse
393
+ `close-unsupported` (execution lands at N4); `target` is required only for text-mode
394
+ steps.
395
+
139
396
  ### `extractRules({ config, provider, priorRules, revisionDiff? })` — `src/extract.js`
140
397
 
141
398
  One sealed LLM call distilling a lineage's rules from ledger facts after a green run.
142
399
  `MAX_RULES`/`MAX_RULE_CHARS` bounds enforced mechanically post-call, rejected whole.
143
400
  Malformed output is a red as data (`rules: null`) — the caller keeps prior rules.
144
401
 
402
+ ### `updateLedger({ ledgerFile, spineFiles })` → `{ appended, fold }` — `src/ledger.js`
403
+
404
+ The upstream ledger: spines fold into ONE append-only incident JSONL both the consumer
405
+ (workflow health) and the maintainer (upstream asks) read. Spines stay ground truth —
406
+ the ledger is derived and reconstructible (delete it, re-run the collector: same fold).
407
+ **Pass the FULL spine corpus each time**: counts are totals computed from what you pass,
408
+ a `lib-incident` row appends only when a key is new or its count grew (idempotent over
409
+ the same corpus), and `seq` continues monotonically across appends. Keys are
410
+ `lib:verb:class:sig` — `sig` hashes the path/number-normalized detail, so the same bug
411
+ across runs dedupes and distinct bugs in one verb don't merge. Classes, worst-first
412
+ (`LEDGER_CLASSES`, frozen): `silent-degradation` (a failed `primitive-smoke` — the class
413
+ failures can't derive), `runtime-red`, `provider-red`, `pricing-red` (F6), possibly-dormant
414
+ `capability-gap` (cap-halt + request-red in one spine), `broken-close` (consumer-attributed),
415
+ `request-red` (admission demand for a locked verb — keyed on the red's structured
416
+ `verb` field, prose-quoted verb as legacy fallback), `retention-red`, `config-red`
417
+ (drafting friction — attributed to bareloop's own schema/prompt). Deliberate exclusions:
418
+ bare `cap-halt` (a budget story), `close-verdict`/`artifact-red` (worker stories),
419
+ `gate-red` (governance working as intended), `pr-red` (operator environment).
420
+ `suggestedAsk` on every row is a template seed for an upstream ask — filing stays human;
421
+ status rows (`open → filed → fixed → consumed`) are human-appended, and the fold shows
422
+ the latest per key. Pure pieces exported for custom folds: `classifyIncidents(events,
423
+ {spine?})`, `foldLedger(rows)`, `ledgerDeltas(fold, occurrences)`. CLI lands at N5; the
424
+ panel reads the same file at N6.
425
+
145
426
  ## Architecture
146
427
 
147
428
  Three layers. An **outer shell** (dumb, permanent): per-run budget cap via bareguard,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
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,9 +23,9 @@
23
23
  "prepublishOnly": "npm run build:types"
24
24
  },
25
25
  "dependencies": {
26
- "bare-agent": "^0.26.2",
26
+ "bare-agent": "^0.29.0",
27
27
  "bareguard": "^0.12.0",
28
- "litectx": "^0.28.0"
28
+ "litectx": "^0.29.1"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^22.0.0",
package/src/extract.js CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { createRequire } from 'node:module';
25
25
 
26
- import { stripFences } from './text.js';
26
+ import { extractArtifact, priceOf } from './text.js';
27
27
 
28
28
  const require = createRequire(import.meta.url);
29
29
  const { Loop } = require('bare-agent');
@@ -38,7 +38,9 @@ export const MAX_RULE_CHARS = 200;
38
38
  * @param {object} opts.provider a bareagent provider — SHELL-owned, sealed
39
39
  * @param {string[]|null} opts.priorRules the lineage's current rules, if any
40
40
  * @param {string[]} [opts.revisionDiff] changed config paths, when the run recovered mid-run
41
- * @returns {Promise<{rules: string[]|null, valid: boolean, reds: Array<object>, costUsd: number, raw: string}>}
41
+ * @returns {Promise<{rules: string[]|null, valid: boolean, reds: Array<object>, costUsd: number|null, raw: string}>}
42
+ * costUsd is null when the spend could not be priced — the explicit-unknown
43
+ * signal (F6: unpriced is never free); callers must not coerce it to 0
42
44
  */
43
45
  export async function extractRules({ config, provider, priorRules, revisionDiff }) {
44
46
  const loop = new Loop({ provider, system: 'You emit exactly one JSON document and nothing else.' });
@@ -71,15 +73,21 @@ characters. No markdown fences, no commentary.`;
71
73
  try {
72
74
  r = await loop.run([{ role: 'user', content: prompt }]);
73
75
  } catch (e) {
74
- return { rules: null, valid: false, reds: [{ code: 'provider-error', detail: String(/** @type {Error} */ (e).message ?? e) }], costUsd: 0, raw: '' };
76
+ // a transport throw means the spend is UNKNOWN, not zero (F6)
77
+ return { rules: null, valid: false, reds: [{ code: 'provider-error', detail: String(/** @type {Error} */ (e).message ?? e) }], costUsd: null, raw: '' };
75
78
  }
76
- const costUsd = r.metrics?.costUsd ?? r.cost ?? 0;
77
- const raw = stripFences(r.text ?? '');
79
+ const { costUsd } = priceOf(r); // the ONE honest-null cost read (F6)
80
+ const ex = extractArtifact(r.text ?? '');
81
+ const raw = ex.code ?? '';
78
82
  if (r.error) {
79
83
  return { rules: null, valid: false, reds: [{ code: 'provider-error', detail: String(r.error) }], costUsd, raw };
80
84
  }
81
- /** @type {(code: string, detail: string) => {rules: null, valid: false, reds: Array<object>, costUsd: number, raw: string}} */
85
+ /** @type {(code: string, detail: string) => {rules: null, valid: false, reds: Array<object>, costUsd: number|null, raw: string}} */
82
86
  const red = (code, detail) => ({ rules: null, valid: false, reds: [{ code, detail }], costUsd, raw });
87
+ // ONE-parser doctrine: extractArtifact's red field is part of its contract —
88
+ // dropping it here buried 'empty response' under JSON.parse's generic
89
+ // "Unexpected end of JSON input" (interpret.js consumes ex.red the same way)
90
+ if (ex.red) return red('artifact-red', ex.red);
83
91
 
84
92
  let rules;
85
93
  try { rules = JSON.parse(raw); } catch (e) {
package/src/index.js CHANGED
@@ -4,10 +4,20 @@
4
4
  // N1: the operator-owned job spec (job-v1) — validateJob is validate.js's
5
5
  // sibling (never an extension); jobSpecHash/checkApproval are the pure half
6
6
  // of human-signs-always (the N2 runner enforces).
7
+ // N2: runJob — the runner: approval gate → primitive smoke → sealed priced
8
+ // draft → sequential per-step loops under the ONE ledger (unpriced is never
9
+ // free, F6) → the hitl step is the decision-ready escalation. Module 4: the
10
+ // upstream ledger — spines fold into one append-only incident file; filing
11
+ // stays human (suggestedAsk is a seed, never an auto-file).
7
12
 
8
13
  export { makeSpine } from './spine.js';
9
- export { ralph, runClose } from './ralph.js';
10
- export { validateConfig, diffPaths, globToPrefix, LOOP_SHAPES, SLOTS, VERBS } from './validate.js';
11
- export { validateJob, jobSpecHash, checkApproval, CLOSE_TYPES, CLASSES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS } from './job.js';
14
+ // CLOSE_FAULTS/scanSecrets: bareloop.context.md documents both as public API —
15
+ // the exports make the adopter contract true (release review 2026-07-19; the
16
+ // exports map admits only ".", so a deep import cannot reach them).
17
+ export { ralph, runClose, CLOSE_FAULTS } from './ralph.js';
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';
12
20
  export { interpret, STALL_REDS } from './interpret.js';
13
21
  export { extractRules, MAX_RULES, MAX_RULE_CHARS } from './extract.js';
22
+ export { runJob } from './run.js';
23
+ export { classifyIncidents, foldLedger, ledgerDeltas, updateLedger, LEDGER_CLASSES } from './ledger.js';