bareloop 0.2.0 → 0.3.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,131 @@ 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
+ ### `runJob(spec, { approvals, workdir, provider, emit, target?, capRuns?, shellCapUsd?, closeTimeoutMs?, execCmd? })` → outcome — `src/run.js`
289
+
290
+ The N2 runner — the shell's top layer; composes everything below it and interprets
291
+ nothing itself. Sequence: **approval gate** (human-signs-always — refuses an unapproved
292
+ spec before ANY provider call: `unapproved-spec`) → **primitive smoke** (litectx
293
+ known-answer round-trip before tokens: `smoke-red` — silent degradation throws nothing)
294
+ → **sequential per-step interpret loops** under the ONE cumulative ledger (each step's
295
+ ceiling is `min(job budget − spent, shell cap)`; a step that cannot green stops the job:
296
+ `step-red:<id>`) → the **hitl step** opens a draft PR deterministically (branch → stage
297
+ the job fence ONLY, so spines/audit logs never enter the PR → commit → push →
298
+ `gh pr create --draft`) and ends `escalated` BY DESIGN, the PR URL riding the
299
+ decision-ready escalation; a PR failure is `pr-red` and the escalation still fires with
300
+ the error. Model tools never touch git — `execCmd` is the shell-owned process seam
301
+ (defaults to real spawnSync). **The hitl step checks the fence first**: an
302
+ affirmatively-clean `git status --porcelain -- <fence>` means nothing changed, so there
303
+ is nothing for a human to review — `pr-skipped` + `step-end: already-green`, the job ends
304
+ `green`, no PR and no escalation (a cadenced no-op run is silent and free). A FAILED
305
+ check falls through to the PR path and reds there — an unknown fence state is never a
306
+ green. The PR step **hands the checkout back**: the starting branch is read before
307
+ anything moves and restored on every path; a failed restore is a loud `workdir-red`
308
+ naming the stranded branch (it never un-opens a real PR). Outcomes: `green | escalated | unapproved-spec | job-red |
309
+ smoke-red | config-red | pricing-red | provider-red | cap-halt | close-unsupported |
310
+ step-red:<id>` — `provider-red` is a transport throw from the drafting call, OR a worker
311
+ round the API cut off mid-generation (`truncated:max_tokens`, bare-agent 0.27.0/BA-6 —
312
+ before which it laundered into a clean finish, F25); in both cases no verdict exists and the
313
+ failed round's spend is only partly known (F6). `cap-halt` is drafting spend consuming the whole job budget before a
314
+ valid config existed. Both are decision-ready escalations with a terminal `job-end`:
315
+ the spine never dangles. A text-mode job invoked without `opts.target` is a `job-red`
316
+ before ANY provider call (and `interpret` itself throws a TypeError for direct
317
+ callers) — reds-before-tokens applies to the call, not just the spec.
318
+
319
+ **Resume-to-cap (close-first skip):** every predicate step runs its close FIRST, before
320
+ any tokens (`close-precheck` on the spine, output scrubbed at capture like every close).
321
+ Already-green skips the step for zero tokens as a DISTINCT record — `step-end` outcome
322
+ `already-green`, never plain `green`: nothing was done, so it mints no learning credit
323
+ and runs no on-green retention. A close that cannot RUN is a `broken-close` escalation
324
+ before any provider call. **Config drafting is deferred** to the first step that
325
+ actually needs a worker — the sealed one-shot + one-redraft is unchanged, and a config
326
+ is always drafted fresh per run (never inherited), but a rerun whose closes all green
327
+ pays ZERO provider calls. So the resume story is: a `cap-halt` stop is the checkpoint
328
+ (the workdir + the closes), the human raises `budgetUsd` (a new spec hash — re-sign),
329
+ and the rerun picks up exactly where the budget died.
330
+
331
+ **Unpriced is never free (F6/F12):** the ledger meters **per ROUND** (`worker-round`, at
332
+ bare-agent's `onLlmResult` seam) — money is counted as it is spent, never at the end of an
333
+ attempt: a tool-mode attempt that HALTS never returns, so accounting its result event lost
334
+ the whole attempt's spend (a real run bought $1.4375 of tokens and the ledger reported
335
+ $0.0048). Any round whose cost is the honest null halts `pricing-red`, decision-ready; a
336
+ null never accumulates as $0, so the hard cap cannot be gamed by an unpriced provider path.
337
+ The drafting call is metered on its own priced path, and the shell **reserves a drafting
338
+ allowance** from the job budget (F9): the ceiling advertised to the drafter is
339
+ `(budgetUsd − reserve) ÷ predicate steps`, so a drafter that claims the ceiling it is given
340
+ always validates (the prompt and the validator must never enforce different numbers).
341
+
342
+ The **÷ steps** is F40: the config is drafted once and re-validated at every step against
343
+ what is LEFT, so a ceiling sized to the whole pot goes stale the moment step 1 spends and
344
+ the next step reds `bounds` on an unchanged config with money still available. A per-step
345
+ share still fits after its predecessors have spent (the shares sum to `budgetUsd − reserve`).
346
+ Single-predicate-step jobs are arithmetically unchanged. This is a DRAFTING bound, not an
347
+ enforcement one: cap-not-estimate is intact, and a step that genuinely needs more than its
348
+ share still cap-halts cleanly with a resume point rather than silently borrowing from the
349
+ steps after it.
350
+
351
+ **What the worker is told (tool mode):** the absolute repository root (F10 — bare-agent's
352
+ shell tools resolve relative paths against the PROCESS cwd, so a worker with no root is
353
+ blind), the close's CURRENT output as the tree's state (F13 — never framed as "your previous
354
+ attempt"; the `run` verb is locked, so the worker cannot see the failure any other way), and
355
+ the loop contract (F16 — it is ONE attempt inside `while close-red and under-cap` and will be
356
+ re-run with the close's verdict; without this a model one-shots and can eat the budget in
357
+ reads before ever writing). It is NOT allowed to read the run's own machinery (F14): the gate
358
+ audit, the smoke store and the litectx store are denied — the agent neither authors the
359
+ arbiter nor reads its books. N2 bounds (honest): `gold`/`rubric` closes refuse
360
+ `close-unsupported` (execution lands at N4); `target` is required only for text-mode
361
+ steps.
362
+
139
363
  ### `extractRules({ config, provider, priorRules, revisionDiff? })` — `src/extract.js`
140
364
 
141
365
  One sealed LLM call distilling a lineage's rules from ledger facts after a green run.
142
366
  `MAX_RULES`/`MAX_RULE_CHARS` bounds enforced mechanically post-call, rejected whole.
143
367
  Malformed output is a red as data (`rules: null`) — the caller keeps prior rules.
144
368
 
369
+ ### `updateLedger({ ledgerFile, spineFiles })` → `{ appended, fold }` — `src/ledger.js`
370
+
371
+ The upstream ledger: spines fold into ONE append-only incident JSONL both the consumer
372
+ (workflow health) and the maintainer (upstream asks) read. Spines stay ground truth —
373
+ the ledger is derived and reconstructible (delete it, re-run the collector: same fold).
374
+ **Pass the FULL spine corpus each time**: counts are totals computed from what you pass,
375
+ a `lib-incident` row appends only when a key is new or its count grew (idempotent over
376
+ the same corpus), and `seq` continues monotonically across appends. Keys are
377
+ `lib:verb:class:sig` — `sig` hashes the path/number-normalized detail, so the same bug
378
+ across runs dedupes and distinct bugs in one verb don't merge. Classes, worst-first
379
+ (`LEDGER_CLASSES`, frozen): `silent-degradation` (a failed `primitive-smoke` — the class
380
+ failures can't derive), `runtime-red`, `provider-red`, `pricing-red` (F6), possibly-dormant
381
+ `capability-gap` (cap-halt + request-red in one spine), `broken-close` (consumer-attributed),
382
+ `request-red` (admission demand for a locked verb — keyed on the red's structured
383
+ `verb` field, prose-quoted verb as legacy fallback), `retention-red`, `config-red`
384
+ (drafting friction — attributed to bareloop's own schema/prompt). Deliberate exclusions:
385
+ bare `cap-halt` (a budget story), `close-verdict`/`artifact-red` (worker stories),
386
+ `gate-red` (governance working as intended), `pr-red` (operator environment).
387
+ `suggestedAsk` on every row is a template seed for an upstream ask — filing stays human;
388
+ status rows (`open → filed → fixed → consumed`) are human-appended, and the fold shows
389
+ the latest per key. Pure pieces exported for custom folds: `classifyIncidents(events,
390
+ {spine?})`, `foldLedger(rows)`, `ledgerDeltas(fold, occurrences)`. CLI lands at N5; the
391
+ panel reads the same file at N6.
392
+
145
393
  ## Architecture
146
394
 
147
395
  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.3.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';