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.
- package/CHANGELOG.md +343 -0
- package/README.md +8 -6
- package/bareloop.context.md +262 -14
- package/package.json +3 -3
- package/src/extract.js +14 -6
- package/src/index.js +13 -3
- package/src/interpret.js +488 -29
- package/src/job.js +119 -3
- package/src/ledger.js +266 -0
- package/src/ralph.js +239 -15
- package/src/run.js +478 -0
- package/src/text.js +65 -8
- package/src/validate.js +42 -1
- package/types/extract.d.ts +4 -2
- package/types/index.d.ts +5 -3
- package/types/interpret.d.ts +39 -6
- package/types/job.d.ts +32 -1
- package/types/ledger.d.ts +76 -0
- package/types/ralph.d.ts +110 -17
- package/types/run.d.ts +58 -0
- package/types/text.d.ts +35 -1
- package/types/validate.d.ts +13 -0
package/bareloop.context.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# bareloop — Integration Guide
|
|
2
2
|
|
|
3
|
-
> **Current through
|
|
4
|
-
>
|
|
5
|
-
>
|
|
6
|
-
>
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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.
|
|
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
|
+
"bare-agent": "^0.29.0",
|
|
27
27
|
"bareguard": "^0.12.0",
|
|
28
|
-
"litectx": "^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 {
|
|
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
|
-
|
|
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
|
|
77
|
-
const
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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';
|