bareloop 0.0.1 → 0.2.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 +204 -0
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +123 -11
- package/bareloop.context.md +182 -0
- package/package.json +41 -3
- package/src/extract.js +95 -0
- package/src/index.js +13 -0
- package/src/interpret.js +261 -0
- package/src/job.js +252 -0
- package/src/ralph.js +122 -0
- package/src/spine.js +29 -0
- package/src/text.js +9 -0
- package/src/validate.js +331 -0
- package/types/extract.d.ts +23 -0
- package/types/index.d.ts +6 -0
- package/types/interpret.d.ts +62 -0
- package/types/job.d.ts +57 -0
- package/types/ralph.d.ts +61 -0
- package/types/spine.d.ts +6 -0
- package/types/text.d.ts +1 -0
- package/types/validate.d.ts +69 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# bareloop — Integration Guide
|
|
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.
|
|
7
|
+
|
|
8
|
+
## What this is
|
|
9
|
+
|
|
10
|
+
**"Automate this job — I don't know the best workflow."** bareloop runs tasks that are
|
|
11
|
+
repeated, long, and verifiable: you describe the job and its checkpoints; an agent authors
|
|
12
|
+
the workflow scaffolding (a constrained, validated config — never freeform code); runs
|
|
13
|
+
execute under an un-gameable outer gate; the scaffolding improves across runs through
|
|
14
|
+
verdict-gated, run-as-executed inheritance with ledger-counted attribution. Every
|
|
15
|
+
inherited rule carries the green that minted it and the contrast that attributed it.
|
|
16
|
+
|
|
17
|
+
## What bareloop is and is not
|
|
18
|
+
|
|
19
|
+
- **Is:** a place where repeated, verifiable jobs get better at themselves — job model,
|
|
20
|
+
authored-workflow lineages, verdict classes, inheritance with receipts, a panel to
|
|
21
|
+
operate it.
|
|
22
|
+
- **Is not:** a general agent, a swarm, or an orchestrator framework. One-off or small
|
|
23
|
+
jobs are out of scope — that's a CLI session, not a bareloop job.
|
|
24
|
+
|
|
25
|
+
## Minimal usage
|
|
26
|
+
|
|
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.*
|
|
29
|
+
|
|
30
|
+
## All options
|
|
31
|
+
|
|
32
|
+
Two documents, two validators — the schema is the option surface; config-red before
|
|
33
|
+
tokens burn. The arbiter split is guarded from both sides by INEXPRESSIBILITY: the
|
|
34
|
+
workflow config cannot say `close`/`provider`; the job spec cannot say
|
|
35
|
+
`hooks`/`loop`/`memory`, any minting claim, or the shell-owned retry cap — all
|
|
36
|
+
unknown-field reds.
|
|
37
|
+
|
|
38
|
+
**Job spec (`schema: "job-v1"`, operator-owned — the arbiter's rulebook):**
|
|
39
|
+
|
|
40
|
+
| field | shape | notes |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `job` | kebab-case slug | |
|
|
43
|
+
| `description` | non-empty string | |
|
|
44
|
+
| `provider` | `anthropic-api` | menu; part of the lineage key by definition |
|
|
45
|
+
| `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) |
|
|
46
|
+
| `cadence` | `{ unit: hour\|day\|week, every: 1..30 }` | validated now, consumed at N5 (Scheduler) |
|
|
47
|
+
| `budgetUsd` | `0 < n <= shell cap` | ceiling chain: workflow ≤ job ≤ shell — each layer may tighten, never exceed |
|
|
48
|
+
| `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 |
|
|
50
|
+
| `escalation` | `{ mode: "decision-ready" }` | the pain channel is not optional |
|
|
51
|
+
|
|
52
|
+
**Close types and the hierarchy** (a close is data, never code; verdict-class laundering
|
|
53
|
+
is a named red `close-hierarchy`):
|
|
54
|
+
|
|
55
|
+
| type | fields (exact — extras red) | legal class |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `predicate` | `cmd`, `expect` (int exit code) | `hard` |
|
|
58
|
+
| `gold` | `expected`, `compare: exact\|json-equal` | `hard` |
|
|
59
|
+
| `rubric` | `criteria` | `soft` only — can never mint automatically |
|
|
60
|
+
| `hitl` | `prompt` | `hitl` — a human IS the close |
|
|
61
|
+
|
|
62
|
+
Red vocabulary (both validators): `parse-error`, `unknown-field`, `missing-required`,
|
|
63
|
+
`invalid-value`, `bounds`, `duplicate-id`, `close-type`, `close-hierarchy`,
|
|
64
|
+
`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`,
|
|
65
|
+
`verb-placement`, `verb-params`, `slot-overflow`). The `secret-literal` sweep is
|
|
66
|
+
defense-in-depth against known token shapes — env-only loading remains the law, not the
|
|
67
|
+
sweep.
|
|
68
|
+
|
|
69
|
+
## Public API
|
|
70
|
+
|
|
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).*
|
|
74
|
+
|
|
75
|
+
### `makeSpine(file)` → `emit(type, data?)` — `src/spine.js`
|
|
76
|
+
|
|
77
|
+
Append-only JSONL event emitter bound to one file. `seq` monotonic per spine, `ts` stamped
|
|
78
|
+
last. Consumers are pure listeners; nothing reads the file back. Returns each event as
|
|
79
|
+
written.
|
|
80
|
+
|
|
81
|
+
### `ralph({ middle, close, capRuns, emit })` → `'green' | 'escalated'` — `src/ralph.js`
|
|
82
|
+
|
|
83
|
+
The dumb outer shell: `while close-red and under-cap: run the middle`. `close` is an argv
|
|
84
|
+
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
|
|
86
|
+
category, never merged with "wrong". A thrown middle is relayed by its `category`
|
|
87
|
+
property (`cap-halt`, `gate-red`, …); an unnamed throw is `interpreter-red`. Close output
|
|
88
|
+
is scrubbed at capture (an injected `redact`, wired to bareguard by `interpret` with the
|
|
89
|
+
validators' full secret-shape inventory — Bearer/sk-/ghp_/github_pat_/AKIA/xox) so a
|
|
90
|
+
secret a checked command echoes never enters the append-only spine or a worker prompt —
|
|
91
|
+
a benign gap is byte-identical (secrets hard line; design law #7 intact).
|
|
92
|
+
|
|
93
|
+
### `validateConfig(input, { shellCapUsd?, jobWriteScope? })` → `{ ok, reds, config }` — `src/validate.js`
|
|
94
|
+
|
|
95
|
+
Deterministic schema-v1 predicate; never throws. Every failure is a named red
|
|
96
|
+
`{ code, path, detail? }` — reds before tokens burn. Returns the parsed config on ok
|
|
97
|
+
(single parse; `null` on any red). Pass the job spec's `writeScope` as `jobWriteScope`
|
|
98
|
+
and every workflow scope must fit inside the fence (path-boundary aware: `src2` is not
|
|
99
|
+
inside `src`) or it reds `scope-escape`; pass `min(shell cap, job budgetUsd)` as
|
|
100
|
+
`shellCapUsd` to complete the ceiling chain. Verb vocabulary bound from litectx
|
|
101
|
+
(`LOOP_SHAPES`, `SLOTS`, `VERBS` exported). `diffPaths(a, b)` returns changed JSON paths —
|
|
102
|
+
the one-knob mutation checker.
|
|
103
|
+
|
|
104
|
+
### `validateJob(input, { shellCapUsd? })` → `{ ok, reds, job }` — `src/job.js`
|
|
105
|
+
|
|
106
|
+
The operator-owned sibling (never an extension) of `validateConfig`: validates a
|
|
107
|
+
`job-v1` spec — see **All options** for the full schema, close types, and hierarchy.
|
|
108
|
+
Never throws on JSON text or plain parsed data (the ingest contract); returns the
|
|
109
|
+
parsed spec on ok, `null` on any red. Menus exported:
|
|
110
|
+
`CLOSE_TYPES`, `CLASSES`, `CLASS_BY_CLOSE`, `GOLD_COMPARE`, `CADENCE_UNITS`,
|
|
111
|
+
`PROVIDERS`, `CONDITION_KEYS`.
|
|
112
|
+
|
|
113
|
+
### `jobSpecHash(job)` / `checkApproval(job, approvals)` — `src/job.js`
|
|
114
|
+
|
|
115
|
+
The pure half of **human-signs-always**: an agent may draft a job spec, but no job runs
|
|
116
|
+
until a human approves that exact version. `jobSpecHash` is sha256 over canonical JSON
|
|
117
|
+
(key-order independent) — any edit changes the hash, so an edited spec is unapproved by
|
|
118
|
+
construction. `checkApproval(job, approvals)` is a pure predicate over
|
|
119
|
+
`{ specHash, signer, ts }` records; the approval record lives OUTSIDE the document it
|
|
120
|
+
signs and is shell/human territory, never agent-writable. The N2 runner enforces it.
|
|
121
|
+
Reserved spine vocabulary (V7, machinery-free until job #1 surfaces one):
|
|
122
|
+
`coordination-red` — a failure between units (scope contention, step order, store
|
|
123
|
+
races), never to be folded into worker/interpreter reds.
|
|
124
|
+
|
|
125
|
+
### `interpret(configRaw, opts)` → `'green' | 'escalated' | 'config-red'` — `src/interpret.js`
|
|
126
|
+
|
|
127
|
+
The only code that reads a config. Composes bareguard `Gate` (write scopes, USD budget),
|
|
128
|
+
litectx (recall/compress/stash/remember hook ops), and the bareagent `Loop` under `ralph`.
|
|
129
|
+
The provider and the close arrive from the shell, never the config. Optional `revisor`
|
|
130
|
+
seam fires once after `STALL_REDS` consecutive close reds; the interpreter owns acceptance
|
|
131
|
+
(arbiter-touch / cap-touch / validation reds), judged and installed on the candidate's
|
|
132
|
+
PARSED form. Emits `config-final` — the run-as-executed config (design law #2) — on every
|
|
133
|
+
run. Pass the job spec's fence as `jobWriteScope` (or `null`/omit for no fence): it is
|
|
134
|
+
enforced HERE — the one choke point where a config becomes a Gate — on entry AND on every
|
|
135
|
+
revision candidate, so a workflow scope outside the operator's fence reds before tokens.
|
|
136
|
+
An enforcement belt resolves every scope and refuses to build a Gate that escapes the
|
|
137
|
+
workdir, independent of validator correctness (law #1).
|
|
138
|
+
|
|
139
|
+
### `extractRules({ config, provider, priorRules, revisionDiff? })` — `src/extract.js`
|
|
140
|
+
|
|
141
|
+
One sealed LLM call distilling a lineage's rules from ledger facts after a green run.
|
|
142
|
+
`MAX_RULES`/`MAX_RULE_CHARS` bounds enforced mechanically post-call, rejected whole.
|
|
143
|
+
Malformed output is a red as data (`rules: null`) — the caller keeps prior rules.
|
|
144
|
+
|
|
145
|
+
## Architecture
|
|
146
|
+
|
|
147
|
+
Three layers. An **outer shell** (dumb, permanent): per-run budget cap via bareguard,
|
|
148
|
+
retry cap, verdict collection, escalation routing — stateless across runs; nothing inside
|
|
149
|
+
negotiates with it. An **emergent middle**: the authored workflow config — steps, per-step
|
|
150
|
+
verdict class, memory binding, write scopes — schema-validated. A **floor**: append-only
|
|
151
|
+
JSONL spine (single source for every UI), litectx store per job, per-run ledger. Built on
|
|
152
|
+
the bare suite: bareagent, bareguard, litectx, barebrowse, baremobile — the full surface
|
|
153
|
+
is disclosed to the authoring agent; only admitted verbs are callable per job.
|
|
154
|
+
|
|
155
|
+
## What's NOT in bareloop, and why
|
|
156
|
+
|
|
157
|
+
- **No agent-authored arbiters.** Closes, budgets, caps, merge/publish decisions live
|
|
158
|
+
outside the emergent part, permanently — the product's trust story depends on the gate
|
|
159
|
+
being un-gameable (adaptlearn's no-breach record is the evidence).
|
|
160
|
+
- **No freeform code as scaffolding.** Configs are schema-validated; config-red before
|
|
161
|
+
tokens burn.
|
|
162
|
+
- **No self-adjusted budgets — ever.** Hard cap per run, cap-not-estimate.
|
|
163
|
+
- **No swarm / orchestrator frameworks; one process per run.** Fewer moving parts is the
|
|
164
|
+
point of the name.
|
|
165
|
+
- **No local shims over baresuite gaps.** A missing/broken primitive is fixed upstream in
|
|
166
|
+
its own package and consumed by version bump.
|
|
167
|
+
- **Merge stays human, forever.** For repo jobs the PR is the escalation artifact; a
|
|
168
|
+
human is the close.
|
|
169
|
+
|
|
170
|
+
## Gotchas
|
|
171
|
+
|
|
172
|
+
*TBD from real adopter friction; recorded here as they're found (repo-side friction goes
|
|
173
|
+
to `docs/FINDINGS.md`).*
|
|
174
|
+
|
|
175
|
+
## Constraints
|
|
176
|
+
|
|
177
|
+
- **Node >= 20** (bareguard's floor governs the suite).
|
|
178
|
+
- **Pure ESM + JSDoc**; generated `.d.ts` ship, never hand-written (LIBRARY_CONVENTIONS §2).
|
|
179
|
+
- **Secrets load from the environment** and never enter the spine, configs, or ledger —
|
|
180
|
+
an append-only record that captures a key captures it forever.
|
|
181
|
+
- **The spine is append-only** and the single source of truth; every UI (panel included)
|
|
182
|
+
is a pure observer of it.
|
package/package.json
CHANGED
|
@@ -1,10 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bareloop",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.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
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./types/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./types/index.d.ts",
|
|
11
|
+
"default": "./src/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"license": "Apache-2.0",
|
|
6
15
|
"author": "hamr0",
|
|
7
|
-
"
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"build:types": "tsc",
|
|
23
|
+
"prepublishOnly": "npm run build:types"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"bare-agent": "^0.26.2",
|
|
27
|
+
"bareguard": "^0.12.0",
|
|
28
|
+
"litectx": "^0.28.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^22.0.0",
|
|
32
|
+
"typescript": "^5.5.0"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/hamr0/bareloop.git"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/hamr0/bareloop#readme",
|
|
39
|
+
"files": [
|
|
40
|
+
"src/",
|
|
41
|
+
"types/",
|
|
42
|
+
"NOTICE",
|
|
43
|
+
"bareloop.context.md",
|
|
44
|
+
"CHANGELOG.md"
|
|
45
|
+
],
|
|
8
46
|
"keywords": [
|
|
9
47
|
"agent",
|
|
10
48
|
"workflow",
|
package/src/extract.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The rules extractor — the lineage's inheritance representation. One sealed
|
|
2
|
+
// LLM call after a green run distills/updates the lineage's rules list from
|
|
3
|
+
// LEDGER FACTS only: the green config, the prior rules, and the revision diff
|
|
4
|
+
// if the run recovered mid-run (the diff is the lesson — failure-transition
|
|
5
|
+
// evidence, free in the event log). It never sees the close, the tests, or the
|
|
6
|
+
// worker's code: rules describe the WORKFLOW, and showing the judge would open
|
|
7
|
+
// the config-level fit-to-pass surface (PRD §2).
|
|
8
|
+
//
|
|
9
|
+
// N3 extends this with the contrast-bit rule (design law #3: verdict admits,
|
|
10
|
+
// contrast attributes — the extractor may claim a knob only with ≥1
|
|
11
|
+
// ledger-counted contrast bit; adaptlearn F16/F18 showed bare greens lose
|
|
12
|
+
// working features). At N0 the extractor is the adaptlearn-proven distiller.
|
|
13
|
+
//
|
|
14
|
+
// Honesty invariants:
|
|
15
|
+
// - One shot, no retry: malformed output is a red as DATA — the caller keeps
|
|
16
|
+
// the lineage's prior rules; there is never a silent empty inheritance.
|
|
17
|
+
// - The ≤MAX_RULES/≤MAX_RULE_CHARS bound is stated to the model but ENFORCED
|
|
18
|
+
// mechanically post-call, and enforcement rejects whole — never trims
|
|
19
|
+
// (rejecting a half-applicable output beats silently part-applying it).
|
|
20
|
+
// - Extractor spend is carried in costUsd and lands on the run's cost line —
|
|
21
|
+
// a representation whose upkeep rides free corrupts the ranking (design
|
|
22
|
+
// law #4: green gates, cost ranks).
|
|
23
|
+
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
import { stripFences } from './text.js';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const { Loop } = require('bare-agent');
|
|
30
|
+
|
|
31
|
+
export const MAX_RULES = 5;
|
|
32
|
+
export const MAX_RULE_CHARS = 200;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Distill/update a lineage's rules from one green run's ledger facts.
|
|
36
|
+
* @param {object} opts
|
|
37
|
+
* @param {object} opts.config the config that went green
|
|
38
|
+
* @param {object} opts.provider a bareagent provider — SHELL-owned, sealed
|
|
39
|
+
* @param {string[]|null} opts.priorRules the lineage's current rules, if any
|
|
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}>}
|
|
42
|
+
*/
|
|
43
|
+
export async function extractRules({ config, provider, priorRules, revisionDiff }) {
|
|
44
|
+
const loop = new Loop({ provider, system: 'You emit exactly one JSON document and nothing else.' });
|
|
45
|
+
const prompt = `An automated coding workflow just completed a run that PASSED its hidden judgement.
|
|
46
|
+
You maintain this workflow lineage's inherited rules: short, general lessons about how to
|
|
47
|
+
configure the workflow for this task family. You never see the tasks' tests or code — only
|
|
48
|
+
workflow facts.
|
|
49
|
+
|
|
50
|
+
The config that went green:
|
|
51
|
+
${JSON.stringify(config, null, 2)}
|
|
52
|
+
${revisionDiff && revisionDiff.length > 0 ? `
|
|
53
|
+
This run stalled and recovered after revising these config paths mid-run (the revision that
|
|
54
|
+
turned red into green — strong evidence about what mattered):
|
|
55
|
+
${revisionDiff.join(', ')}
|
|
56
|
+
` : ''}${priorRules && priorRules.length > 0 ? `
|
|
57
|
+
The lineage's current rules — revise them: keep what still holds, drop what this run
|
|
58
|
+
contradicts, add at most what this run actually evidences:
|
|
59
|
+
${JSON.stringify(priorRules, null, 2)}
|
|
60
|
+
` : `
|
|
61
|
+
The lineage has no rules yet. Write only what this single run actually evidences.
|
|
62
|
+
`}
|
|
63
|
+
Output ONLY a JSON array of strings: at most ${MAX_RULES} rules, each at most ${MAX_RULE_CHARS}
|
|
64
|
+
characters. No markdown fences, no commentary.`;
|
|
65
|
+
|
|
66
|
+
// Never throw (the module contract): a provider blip during post-green
|
|
67
|
+
// extraction must not lose the green run's bookkeeping — it degrades to a
|
|
68
|
+
// red as data and the caller keeps the lineage's prior rules. Loop defaults
|
|
69
|
+
// throwOnError: true, so a transient 500 WOULD reject without this.
|
|
70
|
+
let r;
|
|
71
|
+
try {
|
|
72
|
+
r = await loop.run([{ role: 'user', content: prompt }]);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return { rules: null, valid: false, reds: [{ code: 'provider-error', detail: String(/** @type {Error} */ (e).message ?? e) }], costUsd: 0, raw: '' };
|
|
75
|
+
}
|
|
76
|
+
const costUsd = r.metrics?.costUsd ?? r.cost ?? 0;
|
|
77
|
+
const raw = stripFences(r.text ?? '');
|
|
78
|
+
if (r.error) {
|
|
79
|
+
return { rules: null, valid: false, reds: [{ code: 'provider-error', detail: String(r.error) }], costUsd, raw };
|
|
80
|
+
}
|
|
81
|
+
/** @type {(code: string, detail: string) => {rules: null, valid: false, reds: Array<object>, costUsd: number, raw: string}} */
|
|
82
|
+
const red = (code, detail) => ({ rules: null, valid: false, reds: [{ code, detail }], costUsd, raw });
|
|
83
|
+
|
|
84
|
+
let rules;
|
|
85
|
+
try { rules = JSON.parse(raw); } catch (e) {
|
|
86
|
+
return red('parse-error', String(/** @type {Error} */ (e).message));
|
|
87
|
+
}
|
|
88
|
+
if (!Array.isArray(rules) || rules.length === 0 || !rules.every((x) => typeof x === 'string')) {
|
|
89
|
+
return red('rules-shape', 'expected a non-empty JSON array of strings');
|
|
90
|
+
}
|
|
91
|
+
if (rules.length > MAX_RULES || rules.some((x) => x.length > MAX_RULE_CHARS)) {
|
|
92
|
+
return red('rules-bound', `max ${MAX_RULES} rules of ${MAX_RULE_CHARS} chars — rejected whole, never trimmed`);
|
|
93
|
+
}
|
|
94
|
+
return { rules, valid: true, reds: [], costUsd, raw };
|
|
95
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// bareloop public surface — grows one rung at a time (PRD §10). N0: the
|
|
2
|
+
// token-free spine. The five modules compose bottom-up: makeSpine feeds ralph,
|
|
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
|
+
|
|
8
|
+
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';
|
|
12
|
+
export { interpret, STALL_REDS } from './interpret.js';
|
|
13
|
+
export { extractRules, MAX_RULES, MAX_RULE_CHARS } from './extract.js';
|
package/src/interpret.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// The interpreter — the ONLY code that reads a workflow config (PRD §4's
|
|
2
|
+
// emergent middle, executed). It composes the suite, never invents: litectx is
|
|
3
|
+
// the store, bareguard is the leash, bareagent is the worker loop (design law
|
|
4
|
+
// #10). The provider arrives from the SHELL (never the config — it is
|
|
5
|
+
// arbiter-adjacent), and the close never runs here: the shell runs it and
|
|
6
|
+
// feeds the verdict back as `gap`.
|
|
7
|
+
//
|
|
8
|
+
// Two traps this encodes, both paid for in adaptlearn: `onLlmResult` is a Loop
|
|
9
|
+
// CONSTRUCTOR option — passed to run() it is silently ignored and the budget
|
|
10
|
+
// axis goes blind (F3); and a budget-exhausted gate deny must surface as
|
|
11
|
+
// cap-halt, its own category, never a generic error (design law #8).
|
|
12
|
+
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { writeFileSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { join, resolve, sep } from 'node:path';
|
|
16
|
+
import { Gate, redact } from 'bareguard';
|
|
17
|
+
import { LiteCtx, compress } from 'litectx';
|
|
18
|
+
import { validateConfig, diffPaths, globToPrefix, SECRET_PATTERNS } from './validate.js';
|
|
19
|
+
import { ralph } from './ralph.js';
|
|
20
|
+
|
|
21
|
+
/** @typedef {Error & {category?: string}} CategorizedError the failure map's carrier: ralph relays by `category` */
|
|
22
|
+
|
|
23
|
+
// consecutive close reds that count as a stall; one revision per run
|
|
24
|
+
export const STALL_REDS = 2;
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const { Loop, wireGate, HaltError } = require('bare-agent');
|
|
28
|
+
|
|
29
|
+
import { stripFences } from './text.js';
|
|
30
|
+
|
|
31
|
+
/** @typedef {{body?: string|null, text?: string|null}} RecallHit litectx recall hit — body present only with `{body: true}` */
|
|
32
|
+
|
|
33
|
+
const PERSONA = 'You are a senior engineer. Reply with ONLY the complete contents of the requested JavaScript file — no markdown fences, no commentary. ESM.';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Execute a workflow config against one task under the dumb shell.
|
|
37
|
+
*
|
|
38
|
+
* @param {object|string} configRaw schema v1 config (object or raw JSON text)
|
|
39
|
+
* @param {object} opts
|
|
40
|
+
* @param {string} opts.task implement instruction shown to the worker
|
|
41
|
+
* @param {string} opts.target absolute path the artifact is written to
|
|
42
|
+
* @param {string[]} opts.close argv whose exit code is truth (shell-owned)
|
|
43
|
+
* @param {string} opts.workdir run directory (litectx root, gate audit, scope base)
|
|
44
|
+
* @param {number} opts.capRuns shell iteration budget; the config may tighten via loop.maxIterations, never exceed
|
|
45
|
+
* @param {(type: string, data?: object) => object} opts.emit spine emitter
|
|
46
|
+
* @param {object} opts.provider a bareagent provider — SHELL-owned binding (adaptlearn F8: an unsealed binding is a gate bypass)
|
|
47
|
+
* @param {number} [opts.shellCapUsd=2] the shell's USD cap; config budgetUsd is clamped by validation
|
|
48
|
+
* @param {string[]} [opts.jobWriteScope] the job spec's outer write fence (operator law,
|
|
49
|
+
* job-v1) — enforced HERE, the one choke point where a config becomes a Gate:
|
|
50
|
+
* every workflow scope must fit inside it (scope-escape config-red otherwise),
|
|
51
|
+
* on entry validation and on every revision candidate alike
|
|
52
|
+
* @param {(o: {config: object, gaps: string[], policy: any, onLlmResult: any}) => Promise<{candidate: object|null, parseError?: string|null, costUsd?: number}>} [opts.revisor]
|
|
53
|
+
* optional mid-run revision seam. Fires ONCE per run after STALL_REDS consecutive
|
|
54
|
+
* close reds. The interpreter — never the revisor — owns acceptance: the candidate
|
|
55
|
+
* must validate, and gate/escalation/loop.maxIterations must be unchanged
|
|
56
|
+
* (arbiter-touch / cap-touch revision-reds otherwise; the run continues on the old
|
|
57
|
+
* config). Revisor spend rides the run's own gate handlers — same budget axis as
|
|
58
|
+
* the worker.
|
|
59
|
+
* @returns {Promise<'green'|'escalated'|'config-red'>}
|
|
60
|
+
*/
|
|
61
|
+
export async function interpret(configRaw, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd = 2, jobWriteScope, revisor }) {
|
|
62
|
+
// Normalize ONCE: a trailing slash or a relative spelling must mean the same
|
|
63
|
+
// directory everywhere below — the enforcement belt compares string prefixes,
|
|
64
|
+
// and "/run/" vs "/run" would false-red every legal scope (release review).
|
|
65
|
+
workdir = resolve(workdir);
|
|
66
|
+
const v = validateConfig(configRaw, { shellCapUsd, jobWriteScope });
|
|
67
|
+
emit('config-validate', { ok: v.ok, reds: v.reds });
|
|
68
|
+
if (!v.ok) {
|
|
69
|
+
for (const r of v.reds) emit('config-red', r);
|
|
70
|
+
emit('run-end', { outcome: 'config-red', iterations: 0 });
|
|
71
|
+
return 'config-red';
|
|
72
|
+
}
|
|
73
|
+
let config = /** @type {any} */ (v.config); // single parse — validateConfig returns the parsed config on ok
|
|
74
|
+
|
|
75
|
+
const lc = new LiteCtx({ root: workdir });
|
|
76
|
+
// Enforcement belt (law #1, un-gameable gate): resolve every scope and prove
|
|
77
|
+
// it stays under workdir BEFORE building the Gate. validateConfig already
|
|
78
|
+
// rejects escaping scopes, so this is defense in depth — a future globToPrefix
|
|
79
|
+
// regression (a spelling that normalizes to an absolute path) can never reach
|
|
80
|
+
// a live Gate fence. The interpreter and the validator must never disagree (F9).
|
|
81
|
+
const resolvedScopes = config.gate.writeScope.map((/** @type {string} */ g) => resolve(workdir, globToPrefix(g)));
|
|
82
|
+
// equality counts as escaped: no legal scope resolves to workdir itself (the
|
|
83
|
+
// close lives there), so a scope normalizing to ''/'.' is a regression, not a grant
|
|
84
|
+
const escaped = resolvedScopes.filter((/** @type {string} */ abs) => !abs.startsWith(workdir + sep));
|
|
85
|
+
if (escaped.length) {
|
|
86
|
+
for (const abs of escaped) emit('config-red', { code: 'scope-escape', path: 'gate.writeScope', detail: `resolved scope ${abs} escapes the run directory` });
|
|
87
|
+
emit('run-end', { outcome: 'config-red', iterations: 0 });
|
|
88
|
+
return 'config-red';
|
|
89
|
+
}
|
|
90
|
+
const gate = new Gate({
|
|
91
|
+
// bareguard fs.writeScope is prefix-containment, not glob (adaptlearn F4); globToPrefix
|
|
92
|
+
// is the ONE transform shared with the validator's legality rule — mid-path wildcards
|
|
93
|
+
// and workdir-escaping scopes were already rejected up front (adaptlearn F9, law #1).
|
|
94
|
+
fs: { writeScope: resolvedScopes },
|
|
95
|
+
budget: { maxCostUsd: config.gate.budgetUsd },
|
|
96
|
+
limits: { maxTurns: 8 * (capRuns + 1) },
|
|
97
|
+
audit: { path: join(workdir, 'gate-audit.jsonl') },
|
|
98
|
+
humanChannel: async () => ({ decision: 'terminate' }), // no human mid-run: a tripped cap terminates → decision-ready escalation
|
|
99
|
+
});
|
|
100
|
+
await gate.init();
|
|
101
|
+
const { policy, onLlmResult } = wireGate(gate);
|
|
102
|
+
const loop = new Loop({ provider, system: PERSONA, policy, onLlmResult });
|
|
103
|
+
|
|
104
|
+
/** @param {string} slot */
|
|
105
|
+
const slotOps = (slot) => config.hooks?.[slot] ?? [];
|
|
106
|
+
/** @param {{kinds?: string[]}} op */
|
|
107
|
+
const recallKinds = (op) => op.kinds ?? config.memory.recall?.kinds ?? ['fact'];
|
|
108
|
+
|
|
109
|
+
/** @param {string} prompt */
|
|
110
|
+
async function ask(prompt) {
|
|
111
|
+
let r;
|
|
112
|
+
try {
|
|
113
|
+
r = await loop.run([{ role: 'user', content: prompt }]);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (e instanceof HaltError) /** @type {CategorizedError} */ (e).category = 'cap-halt'; // belt: bare-agent's contract could change
|
|
116
|
+
throw e;
|
|
117
|
+
}
|
|
118
|
+
// bare-agent NEVER throws HaltError out of run() — a governance halt comes
|
|
119
|
+
// back as an error RETURN ({text: '', error: 'halt:<rule>'}; loop.js: "no
|
|
120
|
+
// throw even when throwOnError: true"). Read it, or the failure map goes
|
|
121
|
+
// blind and a cap story masquerades as a worker result (design law #8).
|
|
122
|
+
if (r.error) {
|
|
123
|
+
const err = /** @type {CategorizedError} */ (new Error(`worker loop: ${r.error}`));
|
|
124
|
+
err.category = r.error.startsWith('halt:') ? 'cap-halt' : 'interpreter-red';
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
return r;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {string} slot
|
|
132
|
+
* @param {{iteration?: number, gap?: string, context?: any}} o
|
|
133
|
+
*/
|
|
134
|
+
async function runOps(slot, { iteration, gap, context }) {
|
|
135
|
+
for (const op of slotOps(slot)) {
|
|
136
|
+
if (op.op === 'recall') {
|
|
137
|
+
/** @type {RecallHit[]} */
|
|
138
|
+
const hits = [];
|
|
139
|
+
for (const kind of recallKinds(op)) {
|
|
140
|
+
hits.push(...await lc.recall(task, { kind, n: op.k ?? config.memory.recall?.k ?? 5, body: true }));
|
|
141
|
+
}
|
|
142
|
+
context.text = hits.map((h) => h.body ?? h.text ?? '').filter(Boolean).join('\n');
|
|
143
|
+
context.level = null;
|
|
144
|
+
emit('hook-op', { slot, op: 'recall', hits: hits.length, iteration });
|
|
145
|
+
} else if (op.op === 'compress') {
|
|
146
|
+
const level = op.level ?? config.memory.compressLevel ?? 'verbatim';
|
|
147
|
+
if (context.text) context.text = await compress({ text: context.text, format: 'js' }, { level });
|
|
148
|
+
emit('hook-op', { slot, op: 'compress', level, iteration });
|
|
149
|
+
} else if (op.op === 'stash') {
|
|
150
|
+
if (gap) lc.stash(`gap-${iteration}`, gap);
|
|
151
|
+
emit('hook-op', { slot, op: 'stash', iteration });
|
|
152
|
+
} else if (op.op === 'remember') {
|
|
153
|
+
await lc.remember(`green-${iteration ?? 'final'}-${target.split('/').at(-1)}`, readFileSync(target, 'utf8'), { kind: op.kind ?? 'fact' });
|
|
154
|
+
emit('hook-op', { slot, op: 'remember' });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Mid-run revision: interpreter-owned acceptance — a revisor cannot vouch for
|
|
160
|
+
// its own output. The gate is already constructed and the iteration budget
|
|
161
|
+
// already snapshotted, so gate/escalation (arbiter) and loop.maxIterations
|
|
162
|
+
// (cap) must be byte-identical; anything else that validates is a legal
|
|
163
|
+
// free-axis revision.
|
|
164
|
+
/** @param {any} candidate
|
|
165
|
+
* @returns {{ red: {code: string, reds?: object[]} } | { red?: undefined, config: any }} */
|
|
166
|
+
const acceptRevision = (candidate) => {
|
|
167
|
+
if (!candidate) return { red: { code: 'parse-error' } };
|
|
168
|
+
const cv = validateConfig(candidate, { shellCapUsd, jobWriteScope });
|
|
169
|
+
if (!cv.ok) return { red: { code: 'validation', reds: cv.reds } };
|
|
170
|
+
// judged and installed on the PARSED form (single-parse contract) — a
|
|
171
|
+
// string candidate compared raw would false-red arbiter-touch (its .gate
|
|
172
|
+
// is undefined), and installing it raw would crash every later read
|
|
173
|
+
const cand = /** @type {any} */ (cv.config);
|
|
174
|
+
if (JSON.stringify(cand.gate) !== JSON.stringify(config.gate)
|
|
175
|
+
|| JSON.stringify(cand.escalation) !== JSON.stringify(config.escalation)) {
|
|
176
|
+
return { red: { code: 'arbiter-touch' } };
|
|
177
|
+
}
|
|
178
|
+
if (cand.loop?.maxIterations !== config.loop.maxIterations) return { red: { code: 'cap-touch' } };
|
|
179
|
+
return { config: cand };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** @type {string[]} */
|
|
183
|
+
const gaps = [];
|
|
184
|
+
let revised = false;
|
|
185
|
+
/**
|
|
186
|
+
* @param {number} iteration
|
|
187
|
+
* @param {string} [gap]
|
|
188
|
+
*/
|
|
189
|
+
const middle = async (iteration, gap) => {
|
|
190
|
+
if (gap) gaps.push(gap);
|
|
191
|
+
if (revisor && !revised && gaps.length >= STALL_REDS) {
|
|
192
|
+
emit('stall-detected', { iteration, consecutiveReds: gaps.length });
|
|
193
|
+
revised = true; // one revision per run, spent even if rejected
|
|
194
|
+
let rv;
|
|
195
|
+
try {
|
|
196
|
+
// the run's own gate handlers ride along: revisor spend hits the same
|
|
197
|
+
// budget axis as the worker; a budget halt mid-revision is a cap story,
|
|
198
|
+
// not a revision bug
|
|
199
|
+
rv = await revisor({ config, gaps: [...gaps], policy, onLlmResult });
|
|
200
|
+
} catch (e) {
|
|
201
|
+
if (e instanceof HaltError) /** @type {CategorizedError} */ (e).category = 'cap-halt';
|
|
202
|
+
throw e;
|
|
203
|
+
}
|
|
204
|
+
const rr = acceptRevision(rv.candidate);
|
|
205
|
+
if (rr.red) {
|
|
206
|
+
emit('revision-red', { iteration, ...rr.red, detail: rv.parseError ?? undefined, costUsd: rv.costUsd ?? 0 });
|
|
207
|
+
} else {
|
|
208
|
+
emit('revision-accepted', { iteration, changedPaths: diffPaths(config, rr.config), costUsd: rv.costUsd ?? 0 });
|
|
209
|
+
config = rr.config;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (gap) await runOps('after-red', { iteration, gap, context: {} });
|
|
213
|
+
const context = {};
|
|
214
|
+
await runOps('before-attempt', { iteration, context });
|
|
215
|
+
const parts = [task, context.text && `Possibly relevant notes:\n${context.text}`, gap && `Previous attempt failed the test suite:\n${gap}`];
|
|
216
|
+
|
|
217
|
+
if (config.loop.shape === 'plan') {
|
|
218
|
+
// plan-then-execute: one call to decompose, one to implement following the plan
|
|
219
|
+
const p = await ask([`Produce a SHORT numbered implementation plan (2-4 steps) for this task. Plan only, no code.`, ...parts.slice(1), parts[0]].filter(Boolean).join('\n\n'));
|
|
220
|
+
emit('worker-plan', { iteration, costUsd: p.metrics?.costUsd ?? p.cost ?? null }); // metrics.costUsd carries the honest null when unpriced; cost sums priced rounds only
|
|
221
|
+
parts.push(`Follow this plan:\n${p.text}`);
|
|
222
|
+
}
|
|
223
|
+
const r = await ask(parts.filter(Boolean).join('\n\n'));
|
|
224
|
+
emit('worker-result', { iteration, costUsd: r.metrics?.costUsd ?? r.cost ?? null, tokens: r.usage?.outputTokens ?? null });
|
|
225
|
+
|
|
226
|
+
const decision = await gate.check({ type: 'write', path: target, args: { bytes: r.text.length } });
|
|
227
|
+
if (decision.outcome !== 'allow') {
|
|
228
|
+
const err = /** @type {CategorizedError} */ (new Error(`gate ${decision.outcome} write to ${target} (${decision.rule ?? 'no rule'})`));
|
|
229
|
+
err.category = decision.severity === 'halt' ? 'cap-halt' : 'gate-red';
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
writeFileSync(target, stripFences(r.text));
|
|
233
|
+
emit('artifact-written', { iteration, path: target });
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// the config may tighten the shell's iteration budget, never exceed it (mirrors budgetUsd)
|
|
237
|
+
const effectiveCap = Math.min(capRuns, config.loop.maxIterations ?? capRuns);
|
|
238
|
+
// Secrets never enter the spine (hard line): the shell scrubs close output at
|
|
239
|
+
// the source with bareguard's redactor — BG-1 defaults (Bearer/sk-…) PLUS the
|
|
240
|
+
// validator's whole shape inventory (SECRET_PATTERNS), so redaction can never
|
|
241
|
+
// pass a shape detection reds (a git close echoing a ghp_ token was the gap).
|
|
242
|
+
// Injected here (the layer that owns bareguard) so ralph stays stdlib-only and
|
|
243
|
+
// the scrub is a fixed shell primitive, not an emergent component (V4 holds).
|
|
244
|
+
const outcome = await ralph({ middle, close, capRuns: effectiveCap, emit, redact: (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS }) });
|
|
245
|
+
if (outcome === 'green') {
|
|
246
|
+
// The close already passed — a retention hiccup must not un-green a real
|
|
247
|
+
// green (it would corrupt the learning curve). It degrades loudly:
|
|
248
|
+
// retention-red on the spine means this green mints NO inheritance, but
|
|
249
|
+
// the delivery stands (adaptlearn F5).
|
|
250
|
+
try {
|
|
251
|
+
await runOps('on-green', {});
|
|
252
|
+
} catch (e) {
|
|
253
|
+
emit('retention-red', { category: 'retention-red', detail: String(/** @type {Error} */ (e).message || e) });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Design law #2 (adaptlearn F18): the run-as-executed record. A mid-run
|
|
257
|
+
// revision changes `config`; without this event the revised config dies with
|
|
258
|
+
// the run and every inheritance channel reads only the config-as-authored.
|
|
259
|
+
emit('config-final', { config, revised });
|
|
260
|
+
return outcome;
|
|
261
|
+
}
|