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.
@@ -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.1",
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
- "license": "MIT",
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
- "files": [],
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';
@@ -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
+ }