bareloop 0.1.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 +425 -2
- package/NOTICE +13 -0
- package/README.md +8 -6
- package/bareloop.context.md +334 -19
- package/package.json +4 -3
- package/src/extract.js +14 -6
- package/src/index.js +16 -2
- package/src/interpret.js +538 -46
- package/src/job.js +368 -0
- package/src/ledger.js +266 -0
- package/src/ralph.js +251 -14
- package/src/run.js +478 -0
- package/src/text.js +65 -8
- package/src/validate.js +175 -16
- package/types/extract.d.ts +4 -2
- package/types/index.d.ts +5 -2
- package/types/interpret.d.ts +44 -6
- package/types/job.d.ts +88 -0
- package/types/ledger.d.ts +76 -0
- package/types/ralph.d.ts +123 -17
- package/types/run.d.ts +58 -0
- package/types/text.d.ts +35 -1
- package/types/validate.d.ts +51 -7
package/bareloop.context.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# bareloop — Integration Guide
|
|
2
2
|
|
|
3
|
-
> **
|
|
4
|
-
> fill in as build-ladder rungs land (PRD §10). What is
|
|
5
|
-
> the architecture, the refusals, the constraints — is settled
|
|
6
|
-
> Per LIBRARY_CONVENTIONS §3 this file ships with the package and is the
|
|
7
|
-
> 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.
|
|
8
8
|
|
|
9
9
|
## What this is
|
|
10
10
|
|
|
@@ -25,19 +25,133 @@ inherited rule carries the green that minted it and the contrast that attributed
|
|
|
25
25
|
|
|
26
26
|
## Minimal usage
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
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
|
+
```
|
|
30
58
|
|
|
31
59
|
## All options
|
|
32
60
|
|
|
33
|
-
|
|
34
|
-
|
|
61
|
+
Two documents, two validators — the schema is the option surface; config-red before
|
|
62
|
+
tokens burn. The arbiter split is guarded from both sides by INEXPRESSIBILITY: the
|
|
63
|
+
workflow config cannot say `close`/`provider`; the job spec cannot say
|
|
64
|
+
`hooks`/`loop`/`memory`, any minting claim, or the shell-owned retry cap — all
|
|
65
|
+
unknown-field reds.
|
|
66
|
+
|
|
67
|
+
**Job spec (`schema: "job-v1"`, operator-owned — the arbiter's rulebook):**
|
|
68
|
+
|
|
69
|
+
| field | shape | notes |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `job` | kebab-case slug | |
|
|
72
|
+
| `description` | non-empty string | |
|
|
73
|
+
| `provider` | `anthropic-api` | menu; part of the lineage key by definition |
|
|
74
|
+
| `conditions` | `{ providerPath?, closeVerbosity?, taskFraming?, scaffold? }` | declared keys only, string values — the environment label (consumed by the N3 lineage key; recorded on spines from run one) |
|
|
75
|
+
| `cadence` | `{ unit: hour\|day\|week, every: 1..30 }` | validated now, consumed at N5 (Scheduler) |
|
|
76
|
+
| `budgetUsd` | `0 < n <= shell cap` | ceiling chain: workflow ≤ job ≤ shell — each layer may tighten, never exceed |
|
|
77
|
+
| `writeScope` | array of contained globs | the operator's outer fence; same containment law as the workflow layer, same code |
|
|
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`) |
|
|
81
|
+
| `escalation` | `{ mode: "decision-ready" }` | the pain channel is not optional |
|
|
82
|
+
|
|
83
|
+
**Close types and the hierarchy** (a close is data, never code; verdict-class laundering
|
|
84
|
+
is a named red `close-hierarchy`):
|
|
85
|
+
|
|
86
|
+
| type | fields (exact — extras red) | legal class |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `predicate` | `cmd`, `expect` (int exit code), `judged?`, `gapKeep?` | `hard` |
|
|
89
|
+
| `gold` | `expected`, `compare: exact\|json-equal` | `hard` |
|
|
90
|
+
| `rubric` | `criteria` | `soft` only — can never mint automatically |
|
|
91
|
+
| `hitl` | `prompt` | `hitl` — a human IS the close |
|
|
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
|
+
|
|
143
|
+
Red vocabulary (both validators): `parse-error`, `unknown-field`, `missing-required`,
|
|
144
|
+
`invalid-value`, `bounds`, `duplicate-id`, `close-type`, `close-hierarchy`,
|
|
145
|
+
`secret-literal`, `scope-escape`, `fence-invalid` (a malformed `jobWriteScope` fence — attributed to `jobWriteScope`, never the workflow config), plus the workflow-side verb reds (`verb-illegal`,
|
|
146
|
+
`verb-placement`, `verb-params`, `slot-overflow`). The `secret-literal` sweep is
|
|
147
|
+
defense-in-depth against known token shapes — env-only loading remains the law, not the
|
|
148
|
+
sweep.
|
|
35
149
|
|
|
36
150
|
## Public API
|
|
37
151
|
|
|
38
|
-
*Landed through
|
|
39
|
-
|
|
40
|
-
|
|
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).*
|
|
41
155
|
|
|
42
156
|
### `makeSpine(file)` → `emit(type, data?)` — `src/spine.js`
|
|
43
157
|
|
|
@@ -45,29 +159,206 @@ Append-only JSONL event emitter bound to one file. `seq` monotonic per spine, `t
|
|
|
45
159
|
last. Consumers are pure listeners; nothing reads the file back. Returns each event as
|
|
46
160
|
written.
|
|
47
161
|
|
|
48
|
-
### `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`
|
|
49
163
|
|
|
50
164
|
The dumb outer shell: `while close-red and under-cap: run the middle`. `close` is an argv
|
|
51
165
|
whose exit code is truth (`runClose` is also exported); the red gap text feeds the next
|
|
52
|
-
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
|
|
53
209
|
category, never merged with "wrong". A thrown middle is relayed by its `category`
|
|
54
|
-
property (`cap-halt`, `gate-red`, …); an unnamed throw is `interpreter-red`.
|
|
210
|
+
property (`cap-halt`, `gate-red`, …); an unnamed throw is `interpreter-red`. Close output
|
|
211
|
+
is scrubbed at capture (an injected `redact`, wired to bareguard by `interpret` with the
|
|
212
|
+
validators' full secret-shape inventory — Bearer/sk-/ghp_/github_pat_/AKIA/xox) so a
|
|
213
|
+
secret a checked command echoes never enters the append-only spine or a worker prompt —
|
|
214
|
+
a benign gap is byte-identical (secrets hard line; design law #7 intact).
|
|
55
215
|
|
|
56
|
-
### `validateConfig(input, { shellCapUsd? })` → `{ ok, reds }` — `src/validate.js`
|
|
216
|
+
### `validateConfig(input, { shellCapUsd?, jobWriteScope? })` → `{ ok, reds, config }` — `src/validate.js`
|
|
57
217
|
|
|
58
218
|
Deterministic schema-v1 predicate; never throws. Every failure is a named red
|
|
59
|
-
`{ code, path, detail? }` — reds before tokens burn.
|
|
219
|
+
`{ code, path, detail? }` — reds before tokens burn. Returns the parsed config on ok
|
|
220
|
+
(single parse; `null` on any red). Pass the job spec's `writeScope` as `jobWriteScope`
|
|
221
|
+
and every workflow scope must fit inside the fence (path-boundary aware: `src2` is not
|
|
222
|
+
inside `src`) or it reds `scope-escape`; pass `min(shell cap, job budgetUsd)` as
|
|
223
|
+
`shellCapUsd` to complete the ceiling chain. Verb vocabulary bound from litectx
|
|
60
224
|
(`LOOP_SHAPES`, `SLOTS`, `VERBS` exported). `diffPaths(a, b)` returns changed JSON paths —
|
|
61
225
|
the one-knob mutation checker.
|
|
62
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
|
+
|
|
233
|
+
### `validateJob(input, { shellCapUsd? })` → `{ ok, reds, job }` — `src/job.js`
|
|
234
|
+
|
|
235
|
+
The operator-owned sibling (never an extension) of `validateConfig`: validates a
|
|
236
|
+
`job-v1` spec — see **All options** for the full schema, close types, and hierarchy.
|
|
237
|
+
Never throws on JSON text or plain parsed data (the ingest contract); returns the
|
|
238
|
+
parsed spec on ok, `null` on any red. Menus exported:
|
|
239
|
+
`CLOSE_TYPES`, `CLASSES`, `CLASS_BY_CLOSE`, `GOLD_COMPARE`, `CADENCE_UNITS`,
|
|
240
|
+
`PROVIDERS`, `CONDITION_KEYS`, `STEP_MODES`, `TOOL_MENU`, `LOCKED_TOOLS`.
|
|
241
|
+
|
|
242
|
+
### `jobSpecHash(job)` / `checkApproval(job, approvals)` — `src/job.js`
|
|
243
|
+
|
|
244
|
+
The pure half of **human-signs-always**: an agent may draft a job spec, but no job runs
|
|
245
|
+
until a human approves that exact version. `jobSpecHash` is sha256 over canonical JSON
|
|
246
|
+
(key-order independent) — any edit changes the hash, so an edited spec is unapproved by
|
|
247
|
+
construction. `checkApproval(job, approvals)` is a pure predicate over
|
|
248
|
+
`{ specHash, signer, ts }` records; the approval record lives OUTSIDE the document it
|
|
249
|
+
signs and is shell/human territory, never agent-writable. The N2 runner enforces it.
|
|
250
|
+
Reserved spine vocabulary (V7, machinery-free until job #1 surfaces one):
|
|
251
|
+
`coordination-red` — a failure between units (scope contention, step order, store
|
|
252
|
+
races), never to be folded into worker/interpreter reds.
|
|
253
|
+
|
|
63
254
|
### `interpret(configRaw, opts)` → `'green' | 'escalated' | 'config-red'` — `src/interpret.js`
|
|
64
255
|
|
|
65
256
|
The only code that reads a config. Composes bareguard `Gate` (write scopes, USD budget),
|
|
66
257
|
litectx (recall/compress/stash/remember hook ops), and the bareagent `Loop` under `ralph`.
|
|
67
258
|
The provider and the close arrive from the shell, never the config. Optional `revisor`
|
|
68
259
|
seam fires once after `STALL_REDS` consecutive close reds; the interpreter owns acceptance
|
|
69
|
-
(arbiter-touch / cap-touch / validation reds)
|
|
70
|
-
config (design law #2) — on every
|
|
260
|
+
(arbiter-touch / cap-touch / validation reds), judged and installed on the candidate's
|
|
261
|
+
PARSED form. Emits `config-final` — the run-as-executed config (design law #2) — on every
|
|
262
|
+
run. Pass the job spec's fence as `jobWriteScope` (or `null`/omit for no fence): it is
|
|
263
|
+
enforced HERE — the one choke point where a config becomes a Gate — on entry AND on every
|
|
264
|
+
revision candidate, so a workflow scope outside the operator's fence reds before tokens.
|
|
265
|
+
An enforcement belt resolves every scope and refuses to build a Gate that escapes the
|
|
266
|
+
workdir, independent of validator correctness (law #1).
|
|
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.
|
|
71
362
|
|
|
72
363
|
### `extractRules({ config, provider, priorRules, revisionDiff? })` — `src/extract.js`
|
|
73
364
|
|
|
@@ -75,6 +366,30 @@ One sealed LLM call distilling a lineage's rules from ledger facts after a green
|
|
|
75
366
|
`MAX_RULES`/`MAX_RULE_CHARS` bounds enforced mechanically post-call, rejected whole.
|
|
76
367
|
Malformed output is a red as data (`rules: null`) — the caller keeps prior rules.
|
|
77
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
|
+
|
|
78
393
|
## Architecture
|
|
79
394
|
|
|
80
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",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"files": [
|
|
40
40
|
"src/",
|
|
41
41
|
"types/",
|
|
42
|
+
"NOTICE",
|
|
42
43
|
"bareloop.context.md",
|
|
43
44
|
"CHANGELOG.md"
|
|
44
45
|
],
|
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
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
// bareloop public surface — grows one rung at a time (PRD §10). N0: the
|
|
2
2
|
// token-free spine. The five modules compose bottom-up: makeSpine feeds ralph,
|
|
3
3
|
// interpret is the only config reader, extractRules distills a green run.
|
|
4
|
+
// N1: the operator-owned job spec (job-v1) — validateJob is validate.js's
|
|
5
|
+
// sibling (never an extension); jobSpecHash/checkApproval are the pure half
|
|
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).
|
|
4
12
|
|
|
5
13
|
export { makeSpine } from './spine.js';
|
|
6
|
-
|
|
7
|
-
|
|
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';
|
|
8
20
|
export { interpret, STALL_REDS } from './interpret.js';
|
|
9
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';
|