bareloop 0.5.0 → 0.6.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 +746 -1
- package/README.md +13 -3
- package/bareloop.context.md +311 -234
- package/package.json +3 -3
- package/src/clock.js +181 -0
- package/src/exits.js +8 -2
- package/src/index.js +23 -17
- package/src/job.js +231 -108
- package/src/ledger.js +28 -3
- package/src/plan.js +356 -20
- package/src/planrun.js +978 -98
- package/src/ralph.js +226 -7
- package/src/root.js +32 -12
- package/src/run.js +61 -390
- package/src/stall.js +252 -0
- package/src/tools.js +611 -0
- package/src/validate.js +35 -250
- package/types/clock.d.ts +109 -0
- package/types/index.d.ts +4 -6
- package/types/job.d.ts +83 -8
- package/types/ledger.d.ts +3 -1
- package/types/plan.d.ts +107 -7
- package/types/planrun.d.ts +51 -3
- package/types/ralph.d.ts +62 -4
- package/types/root.d.ts +10 -2
- package/types/run.d.ts +9 -17
- package/types/stall.d.ts +80 -0
- package/types/tools.d.ts +262 -0
- package/types/validate.d.ts +21 -38
- package/src/extract.js +0 -103
- package/src/interpret.js +0 -887
- package/types/extract.d.ts +0 -25
- package/types/interpret.d.ts +0 -201
package/src/interpret.js
DELETED
|
@@ -1,887 +0,0 @@
|
|
|
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 { createHash } from 'node:crypto';
|
|
16
|
-
import { join, resolve, sep } from 'node:path';
|
|
17
|
-
import { homedir } from 'node:os';
|
|
18
|
-
import { Gate, redact } from 'bareguard';
|
|
19
|
-
import { LiteCtx, compress } from 'litectx';
|
|
20
|
-
import { validateConfig, diffPaths, globToPrefix, SECRET_PATTERNS } from './validate.js';
|
|
21
|
-
import { ralph } from './ralph.js';
|
|
22
|
-
import { createRoot } from './root.js';
|
|
23
|
-
|
|
24
|
-
/** @typedef {Error & {category?: string, lib?: string}} CategorizedError the failure map's carrier: ralph relays by `category`, and by `lib` when the thrower knows which library owns the failure (the ledger prefers it over prose) */
|
|
25
|
-
|
|
26
|
-
// consecutive close reds that count as a stall; one revision per run
|
|
27
|
-
export const STALL_REDS = 2;
|
|
28
|
-
|
|
29
|
-
const require = createRequire(import.meta.url);
|
|
30
|
-
const { Loop, wireGate, HaltError } = require('bare-agent');
|
|
31
|
-
const { createShellTools } = require('bare-agent/tools');
|
|
32
|
-
|
|
33
|
-
import { extractArtifact, priceOf } from './text.js';
|
|
34
|
-
|
|
35
|
-
/** @typedef {{body?: string|null, text?: string|null}} RecallHit litectx recall hit — body present only with `{body: true}` */
|
|
36
|
-
|
|
37
|
-
const PERSONA = 'You are a senior engineer. Reply with ONLY the complete contents of the requested JavaScript file — no markdown fences, no commentary. ESM.';
|
|
38
|
-
// The tool persona states the LOOP CONTRACT (F16): the worker is one attempt inside
|
|
39
|
-
// `while close-red and under-cap`, not a one-shot. Without knowing that, a model does
|
|
40
|
-
// the rational one-shot thing — read everything, be certain, then act — and the real
|
|
41
|
-
// run spent its ENTIRE budget on 12 rounds of reading without one write, never once
|
|
42
|
-
// reaching the close. Every round re-pays for every earlier tool result, so reads
|
|
43
|
-
// compound: that run's context grew 2k → 121k tokens and its last round cost $0.25.
|
|
44
|
-
// Telling the worker it will be re-run with the close's verdict makes an early,
|
|
45
|
-
// cheap, wrong attempt the rational move — which is exactly what the loop wants.
|
|
46
|
-
export const PERSONA_TOOLS = 'You are a senior engineer working in a repository through file tools. '
|
|
47
|
-
+ 'ALWAYS use absolute paths — relative paths resolve against the process, not the repository, and will be denied. '
|
|
48
|
-
+ 'You are ONE attempt inside an automated loop: when you finish, a test suite runs and, if it still fails, you are called again with its output. '
|
|
49
|
-
+ 'So do not try to be certain before acting. Read only what you need to form your best hypothesis, make the change with the write tool, and stop. '
|
|
50
|
-
+ 'A wrong cheap attempt is corrected by the next round; exhaustive reading is not — every file you read is re-sent on every later round and the run has a hard budget it can exhaust before you ever write. '
|
|
51
|
-
+ 'Make the required changes with the write tool, then reply with a short summary of what you changed. Never put file contents in your reply.';
|
|
52
|
-
|
|
53
|
-
// The retrieval verbs only pay if the worker reaches for them INSTEAD of paging a file.
|
|
54
|
-
// F18 measured the failure they exist to fix: the worker read one 117 KB file NINE times
|
|
55
|
-
// and dragged 1.37 MB of source through context to reach an 8-line function. The tool
|
|
56
|
-
// descriptions carry the mechanics; the persona has to carry the STRATEGY, or a model
|
|
57
|
-
// with a familiar `read` and an unfamiliar `recall` will simply keep reading.
|
|
58
|
-
// (Module-level + exported since Layer 2: the plan-step worker shares the SAME
|
|
59
|
-
// personas/strategies — two spellings of the loop contract would drift, F16.)
|
|
60
|
-
export const RETRIEVAL_STRATEGY = '\nYou also have a repository index. To read a function, do NOT read its whole file: '
|
|
61
|
-
+ 'call ctx_recall with the function name to get a pointer, then ctx_get with that pointer to read that function alone '
|
|
62
|
-
+ '(it comes with its doc-comment, which states what the function is SUPPOSED to do — compare it against what the code does). '
|
|
63
|
-
+ 'Reserve shell_read for whole files that are genuinely small, and for files you cannot name a symbol in yet. '
|
|
64
|
-
+ 'Search only finds what you can NAME: a failing test names the symptom, not the cause, so read the failing test first, '
|
|
65
|
-
+ 'see which function it calls, and recall THAT.';
|
|
66
|
-
// BA-13: like retrieval, the edit verb only pays if the worker reaches for it INSTEAD
|
|
67
|
-
// of a whole-file rewrite. F31 measured the default: 4 of 5 big-file whole-writes
|
|
68
|
-
// broke the tree, and every one was a rewrite to change ~one line. The tool
|
|
69
|
-
// description carries the mechanics; the persona carries the strategy.
|
|
70
|
-
export const EDIT_STRATEGY = '\nPrefer the edit tool over whole-file writes: quote the EXACT text to change (it must match exactly once) and its replacement. '
|
|
71
|
-
+ 'Rewriting a whole file to change one line is how trees get broken and budgets get burned — reserve the write tool for genuinely new files.';
|
|
72
|
-
|
|
73
|
-
// ---- tool mode (2b): the spec-side grant menu mapped to the underlying tools ----
|
|
74
|
-
// read/grep/write are bare-agent's shell tools; recall/get are litectx's retrieval
|
|
75
|
-
// verbs (F19), composed from the SAME LiteCtx the memory hooks already use.
|
|
76
|
-
export const TOOL_BY_VERB = Object.freeze({ read: 'shell_read', grep: 'shell_grep', write: 'shell_write', edit: 'shell_edit', recall: 'ctx_recall', get: 'ctx_get' });
|
|
77
|
-
export const CTX_TOOLS = Object.freeze(['ctx_recall', 'ctx_get']);
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* The retrieval pair (F19). `shell_read` cannot seek — it starts at byte zero — so a
|
|
81
|
-
* pointer at a symbol was inert and the worker paged whole files to reach one function.
|
|
82
|
-
* `ctx_recall` hands back a POINTER (no body: a search index returns pointers, and bodies
|
|
83
|
-
* on every hit rebuild the bloat — measured, a 5-hit recall dumps ~15k tokens unbidden);
|
|
84
|
-
* `ctx_get` trades that pointer for ONE chunk. A chunk starts at its doc-comment, so the
|
|
85
|
-
* body arrives WITH the docstring that says what it was supposed to do.
|
|
86
|
-
* @param {any} lc the run's LiteCtx (rooted at workdir)
|
|
87
|
-
* @param {string} workdir
|
|
88
|
-
* @param {(type: string, data: object) => void} emit the spine — a retrieval verb whose
|
|
89
|
-
* RESULT is invisible cannot be judged: a `ctx_get` that silently reds (stale pointer,
|
|
90
|
-
* bad range) looks exactly like one that worked, and the worker's fallback to a
|
|
91
|
-
* whole-file read looks like a free choice instead of a forced one.
|
|
92
|
-
*/
|
|
93
|
-
export function createCtxTools(lc, workdir, emit) {
|
|
94
|
-
return [
|
|
95
|
-
{
|
|
96
|
-
name: 'ctx_recall',
|
|
97
|
-
description: 'Search the repository index for a symbol or phrase. Returns POINTERS (path, symbol, line range) — not code. '
|
|
98
|
-
+ 'Pass a pointer to ctx_get to read that one function. Search finds what you can NAME: it will not find a bug from a failing '
|
|
99
|
-
+ "test's output (the symptom and the cause live in different files) — read the failing test first, then recall the function it calls.",
|
|
100
|
-
parameters: {
|
|
101
|
-
type: 'object',
|
|
102
|
-
properties: {
|
|
103
|
-
query: { type: 'string', description: 'Symbol name or phrase, e.g. "keywords" or "stopword filter".' },
|
|
104
|
-
n: { type: 'integer', description: 'Max pointers to return (default 5, max 20).' },
|
|
105
|
-
},
|
|
106
|
-
required: ['query'],
|
|
107
|
-
},
|
|
108
|
-
execute: async (/** @type {{query: string, n?: number}} */ { query, n }) => {
|
|
109
|
-
const hits = await lc.recall(String(query), { kind: 'code', n: Math.min(Math.max(Number(n) || 5, 1), 20) });
|
|
110
|
-
const out = hits.length
|
|
111
|
-
? hits.map((/** @type {any} */ h) => (h.chunk
|
|
112
|
-
? `${h.path}\t${h.chunk.symbol ?? '(anonymous)'}\t${h.chunk.nodeType}\tlines ${h.chunk.startLine}-${h.chunk.endLine}`
|
|
113
|
-
: `${h.path}\t(whole file — no chunk)`)).join('\n')
|
|
114
|
-
: 'no hits';
|
|
115
|
-
emit('ctx-tool', { tool: 'ctx_recall', query: String(query), hits: hits.length, paths: hits.map((/** @type {any} */ h) => h.path), bytes: Buffer.byteLength(out) });
|
|
116
|
-
return out;
|
|
117
|
-
},
|
|
118
|
-
},
|
|
119
|
-
{
|
|
120
|
-
name: 'ctx_get',
|
|
121
|
-
description: 'Read ONE function by the line range ctx_recall gave you — code plus its doc-comment, without the rest of the file. '
|
|
122
|
-
+ 'The line range is a HANDLE you copy from ctx_recall, never one you compute: a range that is not a chunk boundary is refused, '
|
|
123
|
-
+ 'and a file edited since it was indexed is refused (re-run ctx_recall for a fresh pointer).',
|
|
124
|
-
parameters: {
|
|
125
|
-
type: 'object',
|
|
126
|
-
properties: {
|
|
127
|
-
path: { type: 'string', description: 'Repository-relative path, exactly as ctx_recall printed it.' },
|
|
128
|
-
startLine: { type: 'integer', description: 'From ctx_recall.' },
|
|
129
|
-
endLine: { type: 'integer', description: 'From ctx_recall.' },
|
|
130
|
-
},
|
|
131
|
-
required: ['path', 'startLine', 'endLine'],
|
|
132
|
-
},
|
|
133
|
-
execute: async (/** @type {{path: string, startLine: number, endLine: number}} */ { path: p, startLine, endLine }) => {
|
|
134
|
-
// The path is spelled as recall printed it (repo-relative); the gate judges the
|
|
135
|
-
// resolved absolute path, exactly as toolAction resolves it. Boundary-aware
|
|
136
|
-
// prefix (workdir + sep, the line-247 pattern): bare startsWith would garble a
|
|
137
|
-
// sibling like `${workdir}-backup/x` into `ackup/x` — the gate independently
|
|
138
|
-
// denies such a path before execute() runs, so this is defense in depth only.
|
|
139
|
-
const rel = String(p).startsWith(workdir + sep) ? String(p).slice(workdir.length + 1) : String(p);
|
|
140
|
-
try {
|
|
141
|
-
const item = lc.get(rel, { startLine: Number(startLine), endLine: Number(endLine) });
|
|
142
|
-
if (!item?.text) {
|
|
143
|
-
emit('ctx-tool', { tool: 'ctx_get', path: rel, startLine, endLine, outcome: 'no-chunk', bytes: 0 });
|
|
144
|
-
return `no chunk at ${startLine}-${endLine} in "${rel}" — copy a line range from ctx_recall, do not compute one`;
|
|
145
|
-
}
|
|
146
|
-
emit('ctx-tool', { tool: 'ctx_get', path: rel, startLine, endLine, outcome: 'ok', bytes: Buffer.byteLength(item.text) });
|
|
147
|
-
return item.text;
|
|
148
|
-
} catch (e) {
|
|
149
|
-
// StalePointerError: the file changed after indexing, so these lines now describe
|
|
150
|
-
// DIFFERENT code. Its message IS the recovery instruction — hand it to the worker.
|
|
151
|
-
const detail = String(/** @type {Error} */ (e)?.message || e);
|
|
152
|
-
emit('ctx-tool', { tool: 'ctx_get', path: rel, startLine, endLine, outcome: 'stale', bytes: 0, detail });
|
|
153
|
-
return `stale pointer: ${detail}`;
|
|
154
|
-
}
|
|
155
|
-
},
|
|
156
|
-
},
|
|
157
|
-
];
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// The load-bearing containment line (2b POC, scenario B): bare-agent's built-in
|
|
161
|
-
// tools are deliberately ungated and their action type is their own NAME, which
|
|
162
|
-
// never trips bareguard's fs primitives — this translator maps them onto
|
|
163
|
-
// write/read actions so the SAME fence text mode enforces manually governs
|
|
164
|
-
// every tool call. Paths resolve exactly as the tools resolve them
|
|
165
|
-
// (path.resolve(expandHome(p)), POC scenario F) so the gate judges the same
|
|
166
|
-
// file the tool would touch; a relative spelling resolves against the process
|
|
167
|
-
// cwd and reds at the fence, and the deny reason teaches the retry.
|
|
168
|
-
/** @param {string} p */
|
|
169
|
-
const expandHome = (p) => (p === '~' || p.startsWith('~/')) ? join(homedir(), p.slice(1)) : p;
|
|
170
|
-
/** @param {string} name @param {any} args @param {string} [workdir] */
|
|
171
|
-
export const toolAction = (name, args, workdir) => {
|
|
172
|
-
if (name === 'shell_write') return { type: 'write', path: resolve(expandHome(String(args?.path ?? ''))), args: { bytes: String(args?.content ?? '').length } };
|
|
173
|
-
// shell_edit (BA-13) is judged as bareguard's own 'edit' action type — the SAME
|
|
174
|
-
// fs.writeScope fence as write, decided upstream (bareguard FS_TYPES). `bytes` is
|
|
175
|
-
// the replacement's size so the audit can read the edit economy (a 40-byte splice
|
|
176
|
-
// vs an 8KB rewrite is the variable BA-13 exists to move — F18 blindness rule).
|
|
177
|
-
if (name === 'shell_edit') return { type: 'edit', path: resolve(expandHome(String(args?.path ?? ''))), args: { bytes: String(args?.newText ?? '').length } };
|
|
178
|
-
// `tool` rides the action so the AUDIT can tell the read tools apart. Without it every
|
|
179
|
-
// read tool collapses to {type:'read', path} and a whole-file shell_read is
|
|
180
|
-
// indistinguishable from a bounded ctx_get chunk — the ledger cannot see the one
|
|
181
|
-
// variable the retrieval arm exists to test (the F18 blindness, repeated).
|
|
182
|
-
if (name === 'shell_read' || name === 'shell_grep') return { type: 'read', path: resolve(expandHome(String(args?.path ?? ''))), args: { tool: name } };
|
|
183
|
-
// The retrieval verbs are READS and are judged as reads by the SAME fence (F14/F19):
|
|
184
|
-
// ctx_get names a file, so the gate judges that file — the deny list (gate audit,
|
|
185
|
-
// primitive smoke, the litectx store itself) applies to a chunk read exactly as it
|
|
186
|
-
// applies to shell_read, or the worker would read the arbiter's books through the
|
|
187
|
-
// other door. ctx_recall names no file: it is a read OF THE INDEX, judged against the
|
|
188
|
-
// run directory, so it is contained by readScope and cannot reach outside the repo.
|
|
189
|
-
if (name === 'ctx_get') return { type: 'read', path: resolve(String(workdir ?? ''), expandHome(String(args?.path ?? ''))), args: { tool: name } };
|
|
190
|
-
if (name === 'ctx_recall') return { type: 'read', path: resolve(String(workdir ?? '')), args: { tool: name } };
|
|
191
|
-
return { type: name, args };
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Execute a workflow config against one task under the dumb shell.
|
|
196
|
-
*
|
|
197
|
-
* @param {object|string} configRaw schema v1 config (object or raw JSON text)
|
|
198
|
-
* @param {object} opts
|
|
199
|
-
* @param {string} opts.task implement instruction shown to the worker
|
|
200
|
-
* @param {string} [opts.target] absolute path the artifact is written to —
|
|
201
|
-
* required in text mode; unused in tool mode (the worker writes through
|
|
202
|
-
* the gated tools, wherever the fence allows)
|
|
203
|
-
* @param {string[]} opts.close argv whose exit code is truth (shell-owned)
|
|
204
|
-
* @param {number} [opts.closeExpect] the exit code the SIGNED spec calls success
|
|
205
|
-
* @param {{pattern: string, min: number}} [opts.closeJudged] the signed spec's
|
|
206
|
-
* judgment-rendered signal — proof the close actually judged something before
|
|
207
|
-
* its exit code is believed in EITHER direction (PRD v1.11; the drafted config
|
|
208
|
-
* cannot express it, and must not: it is the arbiter's own honesty check)
|
|
209
|
-
* @param {string} [opts.closeGapKeep] the signed spec's kept-failures pattern (F28):
|
|
210
|
-
* close-output lines matching it survive the gap bound, so a large suite's
|
|
211
|
-
* `not ok` names reach the worker instead of being elided into "N fail" — also
|
|
212
|
-
* arbiter territory, inexpressible in the drafted config
|
|
213
|
-
* @param {string} opts.workdir run directory (litectx root, gate audit, scope base)
|
|
214
|
-
* @param {number} opts.capRuns shell iteration budget; the config may tighten via loop.maxIterations, never exceed
|
|
215
|
-
* @param {(type: string, data?: object) => object} opts.emit spine emitter
|
|
216
|
-
* @param {object} opts.provider a bareagent provider — SHELL-owned binding (adaptlearn F8: an unsealed binding is a gate bypass)
|
|
217
|
-
* @param {number} [opts.shellCapUsd=2] the shell's USD cap; a config budgetUsd above it REDS at validation (bounds — no silent clamping)
|
|
218
|
-
* @param {number} [opts.closeTimeoutMs] close wall-clock cap, threaded to the shell
|
|
219
|
-
* (shell/operator territory — the workflow config cannot express it)
|
|
220
|
-
* @param {string[]} [opts.jobWriteScope] the job spec's outer write fence (operator law,
|
|
221
|
-
* job-v1) — enforced HERE, the one choke point where a config becomes a Gate:
|
|
222
|
-
* every workflow scope must fit inside it (scope-escape config-red otherwise),
|
|
223
|
-
* on entry validation and on every revision candidate alike
|
|
224
|
-
* @param {(o: {config: object, gaps: string[], policy: any, onLlmResult: any}) => Promise<{candidate: object|null, parseError?: string|null, costUsd?: number}>} [opts.revisor]
|
|
225
|
-
* optional mid-run revision seam. Fires ONCE per run after STALL_REDS consecutive
|
|
226
|
-
* close reds. The interpreter — never the revisor — owns acceptance: the candidate
|
|
227
|
-
* must validate, and gate/escalation/loop.maxIterations must be unchanged
|
|
228
|
-
* (arbiter-touch / cap-touch revision-reds otherwise; the run continues on the old
|
|
229
|
-
* config). Revisor spend rides the run's own gate handlers — same budget axis as
|
|
230
|
-
* the worker.
|
|
231
|
-
* @param {string} [opts.closeState] the close's CURRENT output on the tree as it stands
|
|
232
|
-
* (the shell's pre-token close check, F13) — shown to the first attempt only, and
|
|
233
|
-
* never framed as an attempt: the worker cannot run the close itself (`run` is a
|
|
234
|
-
* locked verb), so without this it is asked to fix a failure it cannot see
|
|
235
|
-
* @param {'text'|'tools'} [opts.mode] middle mode (2b): 'text' (default) writes the ONE
|
|
236
|
-
* target from the response artifact; 'tools' gives the worker Gate-governed file
|
|
237
|
-
* tools — SPEC-side territory (the step declares it; the config cannot express it)
|
|
238
|
-
* @param {boolean} [opts.layerRoot=false] Layer R (the within-run ratchet, src/root.js):
|
|
239
|
-
* shell-assembled fixation detection over the run's own books, injecting an
|
|
240
|
-
* escalating summary→verbatim note when consecutive attempts rewrite the same
|
|
241
|
-
* files without moving the reds. Inert when the worker is not stuck (RSI §3.3).
|
|
242
|
-
* Defaults OFF (decided 2026-07-21): fixation is extinct on every current job
|
|
243
|
-
* (F41), so ON is unproven — the field read defers to Layer 2. `true` is the
|
|
244
|
-
* ON/experimental arm; never a worker-visible knob
|
|
245
|
-
* @param {string[]} [opts.tools] the spec's tool grant (subset of read|grep|write,
|
|
246
|
-
* job-v1 validated); defaults to the full menu in tool mode
|
|
247
|
-
* @returns {Promise<'green'|'escalated'|'config-red'>}
|
|
248
|
-
*/
|
|
249
|
-
export async function interpret(configRaw, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd = 2, jobWriteScope, revisor, closeTimeoutMs, mode = 'text', tools, closeState, closeExpect, closeJudged, closeGapKeep, layerRoot = false }) {
|
|
250
|
-
// Reds-before-tokens: text mode writes ONE artifact — a missing target is a
|
|
251
|
-
// caller bug that must be loud NOW, not a TypeError after a paid worker call
|
|
252
|
-
// that ralph would misfile as interpreter-red (the gate skips an absent path,
|
|
253
|
-
// so nothing downstream catches it before writeFileSync(undefined)).
|
|
254
|
-
if (mode === 'text' && (typeof target !== 'string' || !target)) {
|
|
255
|
-
throw new TypeError('interpret: text mode requires target (the absolute artifact path)');
|
|
256
|
-
}
|
|
257
|
-
// Normalize ONCE: a trailing slash or a relative spelling must mean the same
|
|
258
|
-
// directory everywhere below — the enforcement belt compares string prefixes,
|
|
259
|
-
// and "/run/" vs "/run" would false-red every legal scope (release review).
|
|
260
|
-
workdir = resolve(workdir);
|
|
261
|
-
const v = validateConfig(configRaw, { shellCapUsd, jobWriteScope });
|
|
262
|
-
emit('config-validate', { ok: v.ok, reds: v.reds });
|
|
263
|
-
if (!v.ok) {
|
|
264
|
-
for (const r of v.reds) emit('config-red', r);
|
|
265
|
-
emit('run-end', { outcome: 'config-red', iterations: 0 });
|
|
266
|
-
return 'config-red';
|
|
267
|
-
}
|
|
268
|
-
let config = /** @type {any} */ (v.config); // single parse — validateConfig returns the parsed config on ok
|
|
269
|
-
|
|
270
|
-
const lc = new LiteCtx({ root: workdir });
|
|
271
|
-
// Enforcement belt (law #1, un-gameable gate): resolve every scope and prove
|
|
272
|
-
// it stays under workdir BEFORE building the Gate. validateConfig already
|
|
273
|
-
// rejects escaping scopes, so this is defense in depth — a future globToPrefix
|
|
274
|
-
// regression (a spelling that normalizes to an absolute path) can never reach
|
|
275
|
-
// a live Gate fence. The interpreter and the validator must never disagree (F9).
|
|
276
|
-
const resolvedScopes = config.gate.writeScope.map((/** @type {string} */ g) => resolve(workdir, globToPrefix(g)));
|
|
277
|
-
// equality counts as escaped: no legal scope resolves to workdir itself (the
|
|
278
|
-
// close lives there), so a scope normalizing to ''/'.' is a regression, not a grant
|
|
279
|
-
const escaped = resolvedScopes.filter((/** @type {string} */ abs) => !abs.startsWith(workdir + sep));
|
|
280
|
-
if (escaped.length) {
|
|
281
|
-
for (const abs of escaped) emit('config-red', { code: 'scope-escape', path: 'gate.writeScope', detail: `resolved scope ${abs} escapes the run directory` });
|
|
282
|
-
emit('run-end', { outcome: 'config-red', iterations: 0 });
|
|
283
|
-
return 'config-red';
|
|
284
|
-
}
|
|
285
|
-
// The attempt's round bound — ONE number for the Gate's run-wide maxTurns and the
|
|
286
|
-
// per-attempt cutoff below (two literals here once drifted apart in kind: the
|
|
287
|
-
// advertised/enforced class). 24→40 on hamr's word 2026-07-16 (TESTGEN amendment
|
|
288
|
-
// 2026-07-16e): the curve measured that prompting cannot buy readability at 24 —
|
|
289
|
-
// the read-first prelude eats the window before the first write.
|
|
290
|
-
const TURNS_PER_ATTEMPT = mode === 'tools' ? 40 : 8;
|
|
291
|
-
const gate = new Gate({
|
|
292
|
-
// bareguard fs.writeScope is prefix-containment, not glob (adaptlearn F4); globToPrefix
|
|
293
|
-
// is the ONE transform shared with the validator's legality rule — mid-path wildcards
|
|
294
|
-
// and workdir-escaping scopes were already rejected up front (adaptlearn F9, law #1).
|
|
295
|
-
// Tool mode adds readScope: the worker's reads stay inside the run directory —
|
|
296
|
-
// the stray-read secrets channel (~/.ssh, /etc) closes with one field (2b POC D).
|
|
297
|
-
// Tool mode adds readScope (the stray-read secrets channel, 2b POC D) AND
|
|
298
|
-
// deny (F14): readScope is the whole workdir, which CONTAINS the run's own
|
|
299
|
-
// machinery — the gate's audit ledger, the primitive-smoke store, the litectx
|
|
300
|
-
// memory store. The real run's worker read its own gate audit and spine. The
|
|
301
|
-
// emergent middle does not author the arbiter, and it does not get to read the
|
|
302
|
-
// arbiter's books either: that is an invitation to fit-to-pass and it fills the
|
|
303
|
-
// context with the run's own bookkeeping instead of the repository's code.
|
|
304
|
-
fs: {
|
|
305
|
-
writeScope: resolvedScopes,
|
|
306
|
-
...(mode === 'tools'
|
|
307
|
-
? { readScope: [workdir], deny: [join(workdir, 'gate-audit.jsonl'), join(workdir, '.smoke'), join(workdir, '.litectx')] }
|
|
308
|
-
: {}),
|
|
309
|
-
},
|
|
310
|
-
budget: { maxCostUsd: config.gate.budgetUsd },
|
|
311
|
-
// text mode is ~1-2 rounds per attempt; tool mode is N rounds (read→write→…).
|
|
312
|
-
// maxTurns ticks on every gate.RECORD (bareguard limits.js:88), and this
|
|
313
|
-
// number means "LLM rounds" ONLY because the sole record path is the LLM one
|
|
314
|
-
// (onLlmResult → gate.record{type:'llm'}); tool calls take gate.CHECK, which
|
|
315
|
-
// does not tick. That invariant is load-bearing: wiring wireGate's own
|
|
316
|
-
// onToolResult into gate.record would silently start ticking maxTurns on
|
|
317
|
-
// tools and halve the LLM budget (the F37 lower-silent-ceiling class). It is
|
|
318
|
-
// pinned by the "every record is type:llm" guard test — do not wire tool
|
|
319
|
-
// records without switching this bound (bareguard offers no llm-only counter;
|
|
320
|
-
// it has maxTurns=all and maxToolRounds=tools-only, neither of which is
|
|
321
|
-
// llm-only once tools also record). Cap semantics are arbiter territory.
|
|
322
|
-
limits: { maxTurns: TURNS_PER_ATTEMPT * (capRuns + 1) },
|
|
323
|
-
audit: { path: join(workdir, 'gate-audit.jsonl') },
|
|
324
|
-
humanChannel: async () => ({ decision: 'terminate' }), // no human mid-run: a tripped cap terminates → decision-ready escalation
|
|
325
|
-
});
|
|
326
|
-
await gate.init();
|
|
327
|
-
// The crash-attribution instrument (F32): which files has the worker written this
|
|
328
|
-
// run? Answered from the arbiter's OWN books — the gate audit's allow-decision
|
|
329
|
-
// write lines, run_id-scoped because the audit file appends across steps and runs.
|
|
330
|
-
// Both middles land here: tool-mode writes are policy-checked, text-mode writes go
|
|
331
|
-
// through gate.check. Read on demand (only ever at a crashed verdict) and
|
|
332
|
-
// fail-closed: an unreadable audit attributes nothing, so the crash escalates
|
|
333
|
-
// exactly as it did before F32 — attribution can only ADD a recovery path, never
|
|
334
|
-
// swallow an instrument stop.
|
|
335
|
-
const auditPath = join(workdir, 'gate-audit.jsonl');
|
|
336
|
-
// NOTE (Finding 4, deferred): this re-reads and re-parses the whole audit each
|
|
337
|
-
// call. That is O(1) reads when Layer R is OFF (called only at the rare crashed
|
|
338
|
-
// verdict), but O(attempts) when Layer R is ON, and the audit can accumulate
|
|
339
|
-
// across steps/runs. An incremental byte-cursor reader would make it O(total)
|
|
340
|
-
// — deferred as cleanup-tier (only bites the OFF-by-default arm) until it can
|
|
341
|
-
// carry its own multi-path-accumulation tests; the simple full-parse is kept
|
|
342
|
-
// because it is obviously correct.
|
|
343
|
-
const workerWrites = () => {
|
|
344
|
-
try {
|
|
345
|
-
const paths = new Set();
|
|
346
|
-
for (const line of readFileSync(auditPath, 'utf8').split('\n')) {
|
|
347
|
-
if (!line) continue;
|
|
348
|
-
let rec;
|
|
349
|
-
try { rec = JSON.parse(line); } catch { continue; }
|
|
350
|
-
if (rec.run_id === gate.runId && rec.phase === 'gate' && rec.decision === 'allow'
|
|
351
|
-
&& (rec.action?.type === 'write' || rec.action?.type === 'edit')
|
|
352
|
-
&& typeof rec.action.path === 'string') paths.add(rec.action.path);
|
|
353
|
-
}
|
|
354
|
-
return [...paths];
|
|
355
|
-
} catch { return []; }
|
|
356
|
-
};
|
|
357
|
-
// Layer R — the root (within-run ratchet, design record 2026-07-19). Shell-
|
|
358
|
-
// assembled from the arbiter's own books: per-attempt write-sets come from
|
|
359
|
-
// the SAME workerWrites audit read (allow-decision only, so a denied write is
|
|
360
|
-
// never counted), red-sets from the close's kept-failure lines. The verbatim
|
|
361
|
-
// stage needs the content the audit deliberately does not keep (bytes-only),
|
|
362
|
-
// so it is teed below at the one seam that sees it — memory-only, scrubbed,
|
|
363
|
-
// never the spine.
|
|
364
|
-
const root = layerRoot ? createRoot({ gapKeep: closeGapKeep, redact: (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS }), writesInformative: mode === 'tools' }) : null;
|
|
365
|
-
// The tee rides the translator and resolves paths exactly as toolAction does,
|
|
366
|
-
// so tee keys match audit paths byte-for-byte. The translator fires BEFORE the
|
|
367
|
-
// gate decides (wireGate calls translate() inside its policy, then awaits
|
|
368
|
-
// gate.check), so it can only STAGE: the audit-path filter downstream is not
|
|
369
|
-
// enough on its own, because the tee is last-write-wins PER PATH and a path the
|
|
370
|
-
// audit legitimately lists as allowed can end the attempt holding the content
|
|
371
|
-
// of a LATER write the gate rejected — the verbatim note would then swear that
|
|
372
|
-
// code landed when it is not in the file (Finding 6, 2026-07-20). Settlement
|
|
373
|
-
// is split across the two seams that actually know (Finding 7): the gate
|
|
374
|
-
// verdict can only DISCARD (a deny means the tool never runs), and the
|
|
375
|
-
// post-execution seam supplies the outcome an allow cannot — see `policy` and
|
|
376
|
-
// `onToolOutcome` below.
|
|
377
|
-
/** @param {string} n @param {any} a */
|
|
378
|
-
const teeingTranslator = (n, a) => {
|
|
379
|
-
// Classify ONCE and decide by the action TYPE, not a hardcoded name list — a
|
|
380
|
-
// third enumeration of the write verbs (workerWrites already filters on
|
|
381
|
-
// action.type write|edit) would let a future granted write-class verb enter
|
|
382
|
-
// the gate audit but silently bypass the tee (the recurring blind-instrument
|
|
383
|
-
// class). Reuse act.path so the tee key matches the audit path byte-for-byte
|
|
384
|
-
// (both from this same resolution). Content is read off the RAW args and never
|
|
385
|
-
// put on the action object — the gate audit stays bytes-only (the secrets law:
|
|
386
|
-
// what an append-only log captures, it captures forever).
|
|
387
|
-
const act = toolAction(n, a, workdir);
|
|
388
|
-
if (root && (act.type === 'write' || act.type === 'edit')) {
|
|
389
|
-
// act.path is always present on the write/edit branch (cast: toolAction's
|
|
390
|
-
// object literals widen `type` to string, so tsc cannot narrow the union)
|
|
391
|
-
const path = /** @type {string} */ (act.path);
|
|
392
|
-
const content = String((act.type === 'edit' ? a?.newText : a?.content) ?? '');
|
|
393
|
-
root.stageWrite(path, content);
|
|
394
|
-
// Finding 7 — the OUTCOME probe. The gate's allow is an INTENT record
|
|
395
|
-
// written before the tool runs; shell_edit returns an anchor miss as a
|
|
396
|
-
// refusal RESULT (bare-agent tools/shell.js:190) and a byte-cap overflow
|
|
397
|
-
// as a throw (:204), both leaving the file untouched with the allow
|
|
398
|
-
// already on the audit. Snapshot the file hash HERE (pre-execution) so the
|
|
399
|
-
// post-execution seam can settle the note against ground truth. Carry the
|
|
400
|
-
// action TYPE: for the file-unchanged case, a write and an edit answer
|
|
401
|
-
// "did it land?" differently (Finding 2). Raw content, never the scrubbed
|
|
402
|
-
// copy — the comparison is against real bytes; transient, never on a
|
|
403
|
-
// prompt, the spine, or the audit.
|
|
404
|
-
pendingProbe = { path, content, type: act.type, before: fileHash(path) };
|
|
405
|
-
}
|
|
406
|
-
return act;
|
|
407
|
-
};
|
|
408
|
-
/**
|
|
409
|
-
* Content hash of a file, or null when it does not exist / cannot be read —
|
|
410
|
-
* null is the honest unknown here, and a missing file BEFORE a write is the
|
|
411
|
-
* normal new-file case (it compares unequal to the hash after, which is
|
|
412
|
-
* exactly right: the write landed).
|
|
413
|
-
* @param {string} p
|
|
414
|
-
*/
|
|
415
|
-
const fileHash = (p) => {
|
|
416
|
-
try { return createHash('sha256').update(readFileSync(p)).digest('hex'); } catch { return null; }
|
|
417
|
-
};
|
|
418
|
-
/** @type {{path: string, content: string, type: string, before: string|null}|null} the write-class action awaiting its OUTCOME */
|
|
419
|
-
let pendingProbe = null;
|
|
420
|
-
const { policy: gatePolicy, onLlmResult } = wireGate(gate, mode === 'tools' ? { actionTranslator: teeingTranslator } : {});
|
|
421
|
-
// Settle the stage on the verdict. wireGate's policy returns true on allow,
|
|
422
|
-
// a string on deny, and THROWS HaltError on halt — and bare-agent processes
|
|
423
|
-
// tool calls strictly sequentially (`for (const tc of result.toolCalls) { …
|
|
424
|
-
// await this.policy(…) }`, loop.js), so at most one stage is ever in flight.
|
|
425
|
-
// The catch discards and RE-THROWS: cap-halt routing reads that throw, and a
|
|
426
|
-
// swallowed halt would launder a tripped budget into a plain deny. A non-write
|
|
427
|
-
// action stages nothing, so both settlements are no-ops for it.
|
|
428
|
-
const policy = root && mode === 'tools'
|
|
429
|
-
? async (/** @type {string} */ n, /** @type {any} */ a, /** @type {any} */ c) => {
|
|
430
|
-
try {
|
|
431
|
-
const verdict = await gatePolicy(n, a, c);
|
|
432
|
-
// A DENY is settled here and only here: the tool never executes, so the
|
|
433
|
-
// outcome seam below never fires for it. An ALLOW is deliberately NOT
|
|
434
|
-
// settled — the bytes have not been written yet, and settling on the
|
|
435
|
-
// verdict is precisely the Finding 7 defect (validated 2026-07-20: three
|
|
436
|
-
// allowed edits, zero bytes changed, and the note swore they landed).
|
|
437
|
-
if (verdict !== true) { root.discardWrite(); pendingProbe = null; }
|
|
438
|
-
return verdict;
|
|
439
|
-
} catch (e) {
|
|
440
|
-
root.discardWrite();
|
|
441
|
-
pendingProbe = null;
|
|
442
|
-
throw e;
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
: gatePolicy;
|
|
446
|
-
/**
|
|
447
|
-
* Finding 7 — settle the staged write on what the tool ACTUALLY did. Fires
|
|
448
|
-
* after every `tool.execute` (bare-agent loop.js:986), success or error, and
|
|
449
|
-
* bare-agent processes tool calls strictly sequentially, so exactly one probe
|
|
450
|
-
* is ever in flight. A probe that never reaches here (the BA-12 spin-guard
|
|
451
|
-
* early return, or a HaltError out of a tool body) leaves the stage pending
|
|
452
|
-
* and unsettled — correct, because in both cases nothing landed, and the
|
|
453
|
-
* attempt boundary drops it.
|
|
454
|
-
*
|
|
455
|
-
* NOT wired: `wireGate`'s own `onToolResult`, which would start feeding
|
|
456
|
-
* `gate.record` per-tool records. That changes what `limits.maxTurns` /
|
|
457
|
-
* `maxToolRounds` count and therefore when a run halts (the guarded
|
|
458
|
-
* llm-only-turns invariant at the Gate config above) — cap semantics are
|
|
459
|
-
* arbiter territory, so it is deliberately not wired here.
|
|
460
|
-
* @param {{result?: any}} [info] the tool's return value (bare-agent loop.js:988)
|
|
461
|
-
*/
|
|
462
|
-
const onToolOutcome = async (info) => {
|
|
463
|
-
if (!root || !pendingProbe) return;
|
|
464
|
-
const { path: p, content, type, before } = pendingProbe;
|
|
465
|
-
pendingProbe = null;
|
|
466
|
-
// ONE read serves both the after-hash and the content check (Finding 5: the
|
|
467
|
-
// old path read the file twice). null = unreadable/absent.
|
|
468
|
-
let after = null;
|
|
469
|
-
try { after = readFileSync(p, 'utf8'); } catch { /* absent → after stays null */ }
|
|
470
|
-
let landed = after !== null && createHash('sha256').update(after, 'utf8').digest('hex') !== before;
|
|
471
|
-
if (!landed && after !== null) {
|
|
472
|
-
// The bytes did not change — but the action may still have left `content`
|
|
473
|
-
// in the file, and write vs edit answer that differently (Finding 2):
|
|
474
|
-
// - write: a whole-file write always writes; identical bytes means the
|
|
475
|
-
// content genuinely IS the file (exact equality, never a substring —
|
|
476
|
-
// substring false-positived when newText appeared elsewhere).
|
|
477
|
-
// - edit: a no-byte-change edit is EITHER an idempotent apply (newText===
|
|
478
|
-
// oldText, "edited …: 1 replacement") OR a missed anchor ("shell_edit:
|
|
479
|
-
// … no change made") — indistinguishable on disk, so the tool RESULT is
|
|
480
|
-
// the only honest signal. An anchor miss is NOT landed.
|
|
481
|
-
if (type === 'write') landed = after === content;
|
|
482
|
-
else landed = typeof info?.result === 'string' && info.result.startsWith('edited ');
|
|
483
|
-
}
|
|
484
|
-
root.settleWrite(landed);
|
|
485
|
-
};
|
|
486
|
-
// Money is metered as it is SPENT — per ROUND, not per attempt (F12). A
|
|
487
|
-
// multi-round attempt that halts (or throws) never returns, so its rounds
|
|
488
|
-
// never reach `worker-result`: the real run bought $1.4375 of tokens inside a
|
|
489
|
-
// halted attempt and the job ledger reported $0.0048. `onLlmResult` is the one
|
|
490
|
-
// seam that sees every round, and it fires BEFORE the gate records the round —
|
|
491
|
-
// so even the round that trips the cap lands on the spine. Emitted first, then
|
|
492
|
-
// forwarded verbatim: the gate's own accounting is never altered. costUsd/
|
|
493
|
-
// pricing ride AS-IS (a null is the honest unknown, never $0 — F6).
|
|
494
|
-
/** @type {number|undefined} the attempt a round belongs to (display only) */
|
|
495
|
-
let roundIteration;
|
|
496
|
-
// F20 — THE ATTEMPT HAD NO BOUND, so ralph never ralphed. `limits.maxTurns` is bareguard's,
|
|
497
|
-
// and the Gate is constructed ONCE for the whole RUN: it is a run-wide HALT, not a
|
|
498
|
-
// per-attempt cap. Nothing else ended an attempt — a tool-mode attempt ran until the model
|
|
499
|
-
// CHOSE to stop calling tools. A worker that has never been told it is wrong does not stop:
|
|
500
|
-
// measured, attempt #1 ran 55 rounds, spent the entire $1.50 budget, and the close NEVER RAN
|
|
501
|
-
// ONCE (zero verdicts, zero gaps, zero writes). The loop's whole premise — a cheap wrong
|
|
502
|
-
// attempt corrected by the next round — was unreachable, and the persona's promise ("a test
|
|
503
|
-
// suite runs and you are called again") was a lie the shell never kept.
|
|
504
|
-
// `loop.stop()` breaks at the round boundary and — since bare-agent 0.27.0 (BA-3/BA-5) —
|
|
505
|
-
// returns the transcript with `error: null` and the produced text preserved, so the attempt
|
|
506
|
-
// ends cleanly, its summary stands, and the close renders the verdict that feeds the next
|
|
507
|
-
// attempt. (Before 0.27.0 stop() fell through to a HARD_ROUND_LIMIT return carrying a bogus
|
|
508
|
-
// error, which bareloop had to un-lie behind a `stoppedByBound` flag — the upgrade removed
|
|
509
|
-
// both the lie and the shim; see ask().) Per-attempt, because each attempt is a FRESH
|
|
510
|
-
// conversation (the context resets), which is what keeps four bounded attempts inside a
|
|
511
|
-
// budget one unbounded attempt exhausts.
|
|
512
|
-
/** rounds spent inside the CURRENT attempt (reset per attempt, unlike the gate's run-wide tick) */
|
|
513
|
-
let roundsThisAttempt = 0;
|
|
514
|
-
/** @type {number|undefined} the attempt that was cut short by the bound (spine + gap note) */
|
|
515
|
-
let attemptBounded;
|
|
516
|
-
/** true while the REVISOR holds the metered handler: its rounds are real money on
|
|
517
|
-
* the run's axis (F12) but they are not the WORKER's rounds, and charging them to
|
|
518
|
-
* the per-attempt bound silently handed the worker 40−R turns while the prompt
|
|
519
|
-
* still advertised 40 (advertised == enforced, PRD; a revisor that burned the whole
|
|
520
|
-
* bound stopped the worker loop before its first tool call) */
|
|
521
|
-
let inRevisor = false;
|
|
522
|
-
// `tokens` alone cannot answer the question the ledger exists to answer.
|
|
523
|
-
// bare-agent's `inputTokens` is the UNCACHED prompt remainder — re-sent context
|
|
524
|
-
// is billed as a cache READ and never appears in it. So a round that re-pays for
|
|
525
|
-
// half the repo and a round that reads it fresh can carry the same `tokens`, at
|
|
526
|
-
// very different cost. Record the four tiers the provider actually prices
|
|
527
|
-
// separately, and record the `kind` (a summarizer fold is an LLM call too, and
|
|
528
|
-
// must never hide inside the worker's own numbers).
|
|
529
|
-
/** @param {{costUsd?: number|null, pricing?: string|null, usage?: any, kind?: string}} arg */
|
|
530
|
-
const meteredOnLlmResult = async (arg) => {
|
|
531
|
-
const u = arg?.usage ?? {};
|
|
532
|
-
emit('worker-round', {
|
|
533
|
-
iteration: roundIteration,
|
|
534
|
-
kind: arg?.kind ?? 'turn',
|
|
535
|
-
costUsd: arg?.costUsd ?? null,
|
|
536
|
-
pricing: arg?.pricing ?? null,
|
|
537
|
-
tokens: (u.inputTokens ?? 0) + (u.outputTokens ?? 0),
|
|
538
|
-
usage: {
|
|
539
|
-
inputTokens: u.inputTokens ?? 0,
|
|
540
|
-
outputTokens: u.outputTokens ?? 0,
|
|
541
|
-
cacheReadTokens: u.cacheReadTokens ?? 0,
|
|
542
|
-
cacheCreationTokens: u.cacheCreationTokens ?? 0,
|
|
543
|
-
},
|
|
544
|
-
});
|
|
545
|
-
// The attempt's own bound (F20). A summarizer fold is an LLM call but not a worker
|
|
546
|
-
// ROUND — counting it would let a fold shorten the attempt that paid for it. The
|
|
547
|
-
// revisor is the same case wearing `kind: 'turn'`: metered above, never charged here.
|
|
548
|
-
if ((arg?.kind ?? 'turn') === 'turn' && mode === 'tools' && !inRevisor) {
|
|
549
|
-
roundsThisAttempt += 1;
|
|
550
|
-
if (roundsThisAttempt >= TURNS_PER_ATTEMPT) {
|
|
551
|
-
// Clean stop at the round boundary: since 0.27.0 (BA-3/BA-5) run() returns the
|
|
552
|
-
// transcript with error=null and the text preserved, so this is an attempt that
|
|
553
|
-
// ENDED, not one that FAILED. The close now runs and its verdict becomes the next
|
|
554
|
-
// attempt's gap — which is the entire point.
|
|
555
|
-
attemptBounded = roundIteration;
|
|
556
|
-
emit('attempt-bounded', { iteration: roundIteration, rounds: roundsThisAttempt, cap: TURNS_PER_ATTEMPT });
|
|
557
|
-
loop.stop();
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
return onLlmResult(arg);
|
|
561
|
-
};
|
|
562
|
-
// The offered tools ARE the grant (2b decision #2): an ungranted tool is never
|
|
563
|
-
// in the menu the model sees — a call to it is "unknown tool", not a deny.
|
|
564
|
-
const toolDefs = mode === 'tools'
|
|
565
|
-
? await (async () => {
|
|
566
|
-
const granted = new Set((tools ?? Object.keys(TOOL_BY_VERB)).map((v) => /** @type {Record<string, string>} */ (TOOL_BY_VERB)[v]));
|
|
567
|
-
const shell = createShellTools().tools.filter((/** @type {{name: string}} */ t) => granted.has(t.name));
|
|
568
|
-
const ctx = CTX_TOOLS.some((t) => granted.has(t))
|
|
569
|
-
? createCtxTools(lc, workdir, emit).filter((t) => granted.has(t.name))
|
|
570
|
-
: [];
|
|
571
|
-
// A retrieval verb with no index is a tool that always answers "no hits". Index is
|
|
572
|
-
// incremental (unchanged files skipped) and BM25-only: measured on the real repo, the
|
|
573
|
-
// semantic tier costs 26s of embedding and does NOT find a symbol the lexical tier
|
|
574
|
-
// misses (for code it only RE-RANKS a BM25-gated pool — it cannot nominate), and it
|
|
575
|
-
// demoted the exact-symbol hit from rank 1 to rank 4. Lexical is the better instrument here.
|
|
576
|
-
if (ctx.length) await lc.index();
|
|
577
|
-
return [...shell, ...ctx];
|
|
578
|
-
})()
|
|
579
|
-
: [];
|
|
580
|
-
|
|
581
|
-
const usesCtx = toolDefs.some((/** @type {{name: string}} */ t) => CTX_TOOLS.includes(t.name));
|
|
582
|
-
const usesEdit = toolDefs.some((/** @type {{name: string}} */ t) => t.name === 'shell_edit');
|
|
583
|
-
const loop = new Loop({
|
|
584
|
-
provider,
|
|
585
|
-
system: mode === 'tools' ? PERSONA_TOOLS + (usesEdit ? EDIT_STRATEGY : '') + (usesCtx ? RETRIEVAL_STRATEGY : '') : PERSONA,
|
|
586
|
-
policy,
|
|
587
|
-
onLlmResult: meteredOnLlmResult,
|
|
588
|
-
onToolResult: onToolOutcome,
|
|
589
|
-
});
|
|
590
|
-
|
|
591
|
-
/** @param {string} slot */
|
|
592
|
-
const slotOps = (slot) => config.hooks?.[slot] ?? [];
|
|
593
|
-
/** @param {{kinds?: string[]}} op */
|
|
594
|
-
const recallKinds = (op) => op.kinds ?? config.memory.recall?.kinds ?? ['fact'];
|
|
595
|
-
|
|
596
|
-
/**
|
|
597
|
-
* @param {string} prompt
|
|
598
|
-
* @param {typeof toolDefs} [defs] the tool menu for THIS call — defaults to the
|
|
599
|
-
* step's grant; the plan-only call passes [] (its own prompt says "no code",
|
|
600
|
-
* and the menu is the grant: a tool it isn't offered cannot be called)
|
|
601
|
-
*/
|
|
602
|
-
async function ask(prompt, defs = toolDefs) {
|
|
603
|
-
let r;
|
|
604
|
-
try {
|
|
605
|
-
// cacheMessages (BA-1, bare-agent 0.27.0): roll an Anthropic cache breakpoint onto the
|
|
606
|
-
// transcript so a tool loop stops re-buying its whole growing history at full price every
|
|
607
|
-
// round — F18 measured 754k full-price tokens, $1.55, and the job died at the cap without
|
|
608
|
-
// one write. Opt-in and provider-routed (loop.run forwards options to generate): a
|
|
609
|
-
// non-Anthropic binding ignores it, and it is safe because bareloop wires no trim/compaction
|
|
610
|
-
// fold — the one interaction (a fold that rewrites the cached prefix) that would defeat it.
|
|
611
|
-
// maxTokens (F30): the provider's 4096 default cannot hold a whole-file
|
|
612
|
-
// shell_write of a real source file — the API cuts the round, BA-6 surfaces
|
|
613
|
-
// `truncated:max_tokens`, and doctrine reads it provider-red: run over.
|
|
614
|
-
// Battery pass 1 lost 3/3 rows to exactly this. Output budget is shell
|
|
615
|
-
// territory (never the config's); 32k fits any single-file write the
|
|
616
|
-
// writeScope admits, and output tokens are only paid when generated.
|
|
617
|
-
r = await loop.run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000 });
|
|
618
|
-
} catch (e) {
|
|
619
|
-
const err = /** @type {CategorizedError} */ (e);
|
|
620
|
-
// A throw OUT OF loop.run() is provider/loop territory by definition — the
|
|
621
|
-
// interpreter's own code is not on that stack. The real run died `read
|
|
622
|
-
// ENETUNREACH` mid-call and was filed interpreter-red ("fix the middle"),
|
|
623
|
-
// when the honest decision was "the network failed — retry" (F11). A
|
|
624
|
-
// governance halt keeps its own category; everything else is provider-red,
|
|
625
|
-
// the same class the drafting path already names.
|
|
626
|
-
err.category = e instanceof HaltError ? 'cap-halt' : (err.category ?? 'provider-red');
|
|
627
|
-
throw err;
|
|
628
|
-
}
|
|
629
|
-
// bare-agent NEVER throws HaltError out of run() — a governance halt comes
|
|
630
|
-
// back as an error RETURN ({text: '', error: 'halt:<rule>'}; loop.js: "no
|
|
631
|
-
// throw even when throwOnError: true"). Read it, or the failure map goes
|
|
632
|
-
// blind and a cap story masquerades as a worker result (design law #8).
|
|
633
|
-
// A denial streak ('denied:<tool>', BA-11) is a governance deny, not a
|
|
634
|
-
// broken interpreter — gate-red, same category as text mode's fence deny.
|
|
635
|
-
// A TRUNCATED round ('truncated:max_tokens', BA-6, bare-agent 0.27.0) is the
|
|
636
|
-
// API cutting the generation off at the output cap — not a middle fault. It is
|
|
637
|
-
// provider-red (retry), the same class as an ENETUNREACH transport failure
|
|
638
|
-
// (F11): no verdict exists and the failed round's spend is only partly known.
|
|
639
|
-
// Before 0.27.0 this round was laundered into a clean finish with error:null
|
|
640
|
-
// (F25) — every prior sonnet arm ran on that bug; the fix surfaces it as its
|
|
641
|
-
// own error, so the middle can escalate instead of scoring an empty attempt.
|
|
642
|
-
// OUR OWN stop is not a fault (F20), and since 0.27.0 (BA-3/BA-5) `loop.stop()`
|
|
643
|
-
// returns `error: null` with the run's text preserved — so a bounded attempt
|
|
644
|
-
// lands on the clean-return path below with no special-casing (the 0.26.2
|
|
645
|
-
// `stoppedByBound` shim that un-lied the HARD_ROUND_LIMIT return is gone).
|
|
646
|
-
if (r.error) {
|
|
647
|
-
const err = /** @type {CategorizedError} */ (new Error(`worker loop: ${r.error}`));
|
|
648
|
-
err.category = r.error.startsWith('halt:') ? 'cap-halt'
|
|
649
|
-
: r.error.startsWith('denied:') ? 'gate-red'
|
|
650
|
-
: r.error.startsWith('truncated:') ? 'provider-red'
|
|
651
|
-
: 'interpreter-red';
|
|
652
|
-
// Attribute at the THROW site, where the owner is known, instead of
|
|
653
|
-
// leaving the ledger to sniff the prose downstream: this error came out
|
|
654
|
-
// of bare-agent's loop, whatever words it happens to carry. A litectx
|
|
655
|
-
// hook throw (runOps) reaches the same escalation with no lib field and
|
|
656
|
-
// keeps its verb→lib mapping. Review 2026-07-18: prose-sniffing billed a
|
|
657
|
-
// transport failure to litectx whenever the text said "recall".
|
|
658
|
-
err.lib = 'bare-agent';
|
|
659
|
-
throw err;
|
|
660
|
-
}
|
|
661
|
-
return r;
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
/**
|
|
665
|
-
* @param {string} slot
|
|
666
|
-
* @param {{iteration?: number, gap?: string, context?: any}} o
|
|
667
|
-
*/
|
|
668
|
-
async function runOps(slot, { iteration, gap, context }) {
|
|
669
|
-
for (const op of slotOps(slot)) {
|
|
670
|
-
if (op.op === 'recall') {
|
|
671
|
-
/** @type {RecallHit[]} */
|
|
672
|
-
const hits = [];
|
|
673
|
-
for (const kind of recallKinds(op)) {
|
|
674
|
-
hits.push(...await lc.recall(task, { kind, n: op.k ?? config.memory.recall?.k ?? 5, body: true }));
|
|
675
|
-
}
|
|
676
|
-
context.text = hits.map((h) => h.body ?? h.text ?? '').filter(Boolean).join('\n');
|
|
677
|
-
context.level = null;
|
|
678
|
-
// paths ride on the event: a recall whose CONTENT is invisible blinds every
|
|
679
|
-
// downstream instrument to what the worker was handed (F33 — P5's green looked
|
|
680
|
-
// like an impossible sight-unseen edit until recall_log forensics explained it)
|
|
681
|
-
emit('hook-op', { slot, op: 'recall', hits: hits.length, paths: hits.map((h) => /** @type {any} */ (h).path).filter(Boolean), iteration });
|
|
682
|
-
} else if (op.op === 'compress') {
|
|
683
|
-
const level = op.level ?? config.memory.compressLevel ?? 'verbatim';
|
|
684
|
-
if (context.text) context.text = await compress({ text: context.text, format: 'js' }, { level });
|
|
685
|
-
emit('hook-op', { slot, op: 'compress', level, iteration });
|
|
686
|
-
} else if (op.op === 'stash') {
|
|
687
|
-
if (gap) lc.stash(`gap-${iteration}`, gap);
|
|
688
|
-
emit('hook-op', { slot, op: 'stash', iteration });
|
|
689
|
-
} else if (op.op === 'remember') {
|
|
690
|
-
// tool mode has no single target to read back — the green's retained
|
|
691
|
-
// form is the worker's own change summary (its final loop text), which
|
|
692
|
-
// is what a future recall can actually use
|
|
693
|
-
const content = mode === 'tools' ? (lastText ?? '') : readFileSync(/** @type {string} */ (target), 'utf8');
|
|
694
|
-
await lc.remember(`green-${iteration ?? 'final'}-${mode === 'tools' ? 'tools' : /** @type {string} */ (target).split('/').at(-1)}`, content, { kind: op.kind ?? 'fact' });
|
|
695
|
-
emit('hook-op', { slot, op: 'remember' });
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
// Mid-run revision: interpreter-owned acceptance — a revisor cannot vouch for
|
|
701
|
-
// its own output. The gate is already constructed and the iteration budget
|
|
702
|
-
// already snapshotted, so gate/escalation (arbiter) and loop.maxIterations
|
|
703
|
-
// (cap) must be byte-identical; anything else that validates is a legal
|
|
704
|
-
// free-axis revision.
|
|
705
|
-
/** @param {any} candidate
|
|
706
|
-
* @returns {{ red: {code: string, reds?: object[]} } | { red?: undefined, config: any }} */
|
|
707
|
-
const acceptRevision = (candidate) => {
|
|
708
|
-
if (!candidate) return { red: { code: 'parse-error' } };
|
|
709
|
-
const cv = validateConfig(candidate, { shellCapUsd, jobWriteScope });
|
|
710
|
-
if (!cv.ok) return { red: { code: 'validation', reds: cv.reds } };
|
|
711
|
-
// judged and installed on the PARSED form (single-parse contract) — a
|
|
712
|
-
// string candidate compared raw would false-red arbiter-touch (its .gate
|
|
713
|
-
// is undefined), and installing it raw would crash every later read
|
|
714
|
-
const cand = /** @type {any} */ (cv.config);
|
|
715
|
-
if (JSON.stringify(cand.gate) !== JSON.stringify(config.gate)
|
|
716
|
-
|| JSON.stringify(cand.escalation) !== JSON.stringify(config.escalation)) {
|
|
717
|
-
return { red: { code: 'arbiter-touch' } };
|
|
718
|
-
}
|
|
719
|
-
if (cand.loop?.maxIterations !== config.loop.maxIterations) return { red: { code: 'cap-touch' } };
|
|
720
|
-
return { config: cand };
|
|
721
|
-
};
|
|
722
|
-
|
|
723
|
-
/** @type {string[]} */
|
|
724
|
-
const gaps = [];
|
|
725
|
-
let revised = false;
|
|
726
|
-
/** @type {string|undefined} set on artifact-red, consumed by the next attempt's prompt */
|
|
727
|
-
let artifactNote;
|
|
728
|
-
/** @type {string|undefined} tool mode: the last attempt's summary text (retention source) */
|
|
729
|
-
let lastText;
|
|
730
|
-
/**
|
|
731
|
-
* @param {number} iteration
|
|
732
|
-
* @param {string} [gap]
|
|
733
|
-
*/
|
|
734
|
-
const middle = async (iteration, gap) => {
|
|
735
|
-
roundIteration = iteration; // stamps every round of this attempt (F12)
|
|
736
|
-
roundsThisAttempt = 0; // the bound is PER ATTEMPT — a fresh conversation, a fresh budget (F20)
|
|
737
|
-
// Layer R: finalize the previous attempt from the books and ask the ratchet.
|
|
738
|
-
// Inert (null) unless consecutive attempts rewrote the same files without
|
|
739
|
-
// moving the reds; the event carries stage/paths/counts, NEVER content.
|
|
740
|
-
const rootInj = root ? root.observe({ iteration, gap, writes: workerWrites() }) : null;
|
|
741
|
-
if (rootInj) emit('root-injected', rootInj.event);
|
|
742
|
-
if (gap) gaps.push(gap);
|
|
743
|
-
if (revisor && !revised && gaps.length >= STALL_REDS) {
|
|
744
|
-
emit('stall-detected', { iteration, consecutiveReds: gaps.length });
|
|
745
|
-
revised = true; // one revision per run, spent even if rejected
|
|
746
|
-
let rv;
|
|
747
|
-
try {
|
|
748
|
-
// the run's own gate handlers ride along: revisor spend hits the same
|
|
749
|
-
// budget axis as the worker; a budget halt mid-revision is a cap story,
|
|
750
|
-
// not a revision bug
|
|
751
|
-
// the METERED handler: revisor rounds are real money on the same axis (F12)
|
|
752
|
-
inRevisor = true;
|
|
753
|
-
rv = await revisor({ config, gaps: [...gaps], policy, onLlmResult: meteredOnLlmResult });
|
|
754
|
-
} catch (e) {
|
|
755
|
-
if (e instanceof HaltError) /** @type {CategorizedError} */ (e).category = 'cap-halt';
|
|
756
|
-
throw e;
|
|
757
|
-
} finally {
|
|
758
|
-
// `finally`, not a trailing assignment: a revisor that throws (cap-halt is
|
|
759
|
-
// the expected one) must not leave the worker's rounds uncounted for the
|
|
760
|
-
// rest of the run — that would turn this fix into an unbounded attempt.
|
|
761
|
-
inRevisor = false;
|
|
762
|
-
}
|
|
763
|
-
const rr = acceptRevision(rv.candidate);
|
|
764
|
-
if (rr.red) {
|
|
765
|
-
// `?? null`, never `?? 0`: an unpriced revisor's spend is UNKNOWN, and
|
|
766
|
-
// the spine is the permanent record (F6 — the cap is governed honestly
|
|
767
|
-
// by the metered worker-round path, but $0 on the record is a lie).
|
|
768
|
-
emit('revision-red', { iteration, ...rr.red, detail: rv.parseError ?? undefined, costUsd: rv.costUsd ?? null });
|
|
769
|
-
} else {
|
|
770
|
-
emit('revision-accepted', { iteration, changedPaths: diffPaths(config, rr.config), costUsd: rv.costUsd ?? null });
|
|
771
|
-
config = rr.config;
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
if (gap) await runOps('after-red', { iteration, gap, context: {} });
|
|
775
|
-
const context = {};
|
|
776
|
-
await runOps('before-attempt', { iteration, context });
|
|
777
|
-
// F10 (first real-model run): a tool-mode worker must be TOLD where the
|
|
778
|
-
// repository is. The persona demands absolute paths, but bare-agent's shell
|
|
779
|
-
// tools resolve relative paths against the PROCESS cwd — not the workdir —
|
|
780
|
-
// so a worker with no root is working blind: the real run groped for the repo
|
|
781
|
-
// (/home/hamr, the runner's own directory, then /) and the fence denied every
|
|
782
|
-
// guess until the denial streak stopped it. Containment held; the task was
|
|
783
|
-
// impossible. Text mode is told nothing: it has no tools to point anywhere,
|
|
784
|
-
// and the shell alone chooses its one target.
|
|
785
|
-
// (parts[0] stays the task: the plan shape re-orders around it)
|
|
786
|
-
const parts = [
|
|
787
|
-
task,
|
|
788
|
-
mode === 'tools' && `Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root — a relative path resolves against a different directory and will be denied by the gate.`,
|
|
789
|
-
// F13: what the close says about the tree RIGHT NOW — the state a human
|
|
790
|
-
// maintainer reads first. Given only until an attempt of our own exists
|
|
791
|
-
// (then `gap` is the truer, attributable evidence). The real run withheld
|
|
792
|
-
// this on the grounds that "no attempt has happened", and the worker — which
|
|
793
|
-
// cannot run the suite itself, the `run` verb being locked — groped through
|
|
794
|
-
// the repository and burned the entire cap without one write. Attributing an
|
|
795
|
-
// attempt and describing the tree are different claims; this is the second.
|
|
796
|
-
!gap && closeState && `The close is currently failing. This is its output on the tree as it stands (not an attempt of yours):\n${closeState}`,
|
|
797
|
-
context.text && `Possibly relevant notes:\n${context.text}`, gap && `Previous attempt failed the test suite:\n${gap}`,
|
|
798
|
-
// Layer R: the ratchet note rides directly after the gap it qualifies —
|
|
799
|
-
// same channel discipline as the gap itself (bounded, trims announced)
|
|
800
|
-
rootInj && rootInj.note,
|
|
801
|
-
artifactNote && `Previous attempt never reached the close:\n${artifactNote}`,
|
|
802
|
-
// F20: a bounded attempt must SAY it was bounded. Otherwise the worker reads its own
|
|
803
|
-
// truncated transcript as a finished one, learns nothing from being cut off, and spends
|
|
804
|
-
// the next attempt investigating exactly as far and stopping exactly as short.
|
|
805
|
-
attemptBounded !== undefined && attemptBounded === iteration - 1
|
|
806
|
-
&& `Your previous attempt was CUT OFF after ${TURNS_PER_ATTEMPT} tool rounds without making a change. Reading is bounded; writing is not. `
|
|
807
|
-
+ `Form a hypothesis EARLY and make the edit — a wrong cheap edit is corrected by the next round, but an attempt that never writes teaches this loop nothing.`];
|
|
808
|
-
|
|
809
|
-
if (config.loop.shape === 'plan') {
|
|
810
|
-
// plan-then-execute: one call to decompose, one to implement following the plan
|
|
811
|
-
// Plan only, no code — so no tools either: the menu IS the grant (2b), and a
|
|
812
|
-
// plan call offered shell_write can mutate the tree before the implement
|
|
813
|
-
// round exists (release review 2026-07-19; unexercised combination until then)
|
|
814
|
-
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'), []);
|
|
815
|
-
emit('worker-plan', { iteration, ...priceOf(p) }); // priceOf: the ONE honest-null cost read (F6)
|
|
816
|
-
parts.push(`Follow this plan:\n${p.text}`);
|
|
817
|
-
}
|
|
818
|
-
const r = await ask(parts.filter(Boolean).join('\n\n'));
|
|
819
|
-
emit('worker-result', { iteration, ...priceOf(r), toolCalls: r.metrics?.toolCalls ?? 0, tokens: r.usage?.outputTokens ?? null });
|
|
820
|
-
|
|
821
|
-
// Tool mode: the worker already wrote through the gated tools (every call
|
|
822
|
-
// policy-checked against the fence); its final text is a change summary,
|
|
823
|
-
// not an artifact — there is nothing to extract and artifact-red genuinely
|
|
824
|
-
// does not exist here (2b decision #3). The close judges the tree as-is.
|
|
825
|
-
if (mode === 'tools') {
|
|
826
|
-
lastText = r.text ?? '';
|
|
827
|
-
return;
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
// Fence-robust extraction BEFORE the gate: what gets written is the
|
|
831
|
-
// artifact, not the response (F2 #2). A response with no artifact reds on
|
|
832
|
-
// its OWN axis — artifact-red — and writes nothing: the close will red
|
|
833
|
-
// against the stale/missing target, but the spine names the true cause,
|
|
834
|
-
// so the contrast evidence stays clean (F21's instrument caveat).
|
|
835
|
-
const ex = extractArtifact(r.text);
|
|
836
|
-
if (ex.red) {
|
|
837
|
-
emit('artifact-red', { iteration, category: 'artifact-red', reason: ex.red });
|
|
838
|
-
artifactNote = `your response was rejected as a non-artifact (${ex.red}) — emit ONLY the code artifact`;
|
|
839
|
-
return; // retryable: the next attempt carries the note; ralph's cap still governs
|
|
840
|
-
}
|
|
841
|
-
artifactNote = undefined;
|
|
842
|
-
const code = /** @type {string} */ (ex.code); // non-null: ex.red was checked above
|
|
843
|
-
const t = /** @type {string} */ (target); // text mode contract: target is required
|
|
844
|
-
const decision = await gate.check({ type: 'write', path: t, args: { bytes: code.length } });
|
|
845
|
-
if (decision.outcome !== 'allow') {
|
|
846
|
-
const err = /** @type {CategorizedError} */ (new Error(`gate ${decision.outcome} write to ${t} (${decision.rule ?? 'no rule'})`));
|
|
847
|
-
err.category = decision.severity === 'halt' ? 'cap-halt' : 'gate-red';
|
|
848
|
-
throw err;
|
|
849
|
-
}
|
|
850
|
-
writeFileSync(t, code);
|
|
851
|
-
root?.noteWrite(t, code); // Layer R tee, text mode: same seam role as the translator's
|
|
852
|
-
emit('artifact-written', { iteration, path: t });
|
|
853
|
-
};
|
|
854
|
-
|
|
855
|
-
// the config may tighten the shell's iteration budget, never exceed it (mirrors budgetUsd)
|
|
856
|
-
const effectiveCap = Math.min(capRuns, config.loop.maxIterations ?? capRuns);
|
|
857
|
-
// Secrets never enter the spine (hard line): the shell scrubs close output at
|
|
858
|
-
// the source with bareguard's redactor — BG-1 defaults (Bearer/sk-…) PLUS the
|
|
859
|
-
// validator's whole shape inventory (SECRET_PATTERNS), so redaction can never
|
|
860
|
-
// pass a shape detection reds (a git close echoing a ghp_ token was the gap).
|
|
861
|
-
// Injected here (the layer that owns bareguard) so ralph stays stdlib-only and
|
|
862
|
-
// the scrub is a fixed shell primitive, not an emergent component (V4 holds).
|
|
863
|
-
// cwd: workdir — the close judges the tree the work happened in (F8). A close
|
|
864
|
-
// is a repository command (`npm test`); run from anywhere else it silently
|
|
865
|
-
// judges another repo and exit-code-is-truth stops being true.
|
|
866
|
-
// expect/judged/gapKeep are the SIGNED spec's, threaded verbatim — the drafted
|
|
867
|
-
// config cannot express any of them (they are arbiter territory, PRD v1.11; F28
|
|
868
|
-
// for gapKeep, which keeps a large suite's failure NAMES in the gap the worker
|
|
869
|
-
// sees instead of letting the bound elide them into a bare "N fail").
|
|
870
|
-
const outcome = await ralph({ middle, close, capRuns: effectiveCap, emit, closeTimeoutMs, cwd: workdir, expect: closeExpect, judged: closeJudged, gapKeep: closeGapKeep, workerWrites, redact: (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS }) });
|
|
871
|
-
if (outcome === 'green') {
|
|
872
|
-
// The close already passed — a retention hiccup must not un-green a real
|
|
873
|
-
// green (it would corrupt the learning curve). It degrades loudly:
|
|
874
|
-
// retention-red on the spine means this green mints NO inheritance, but
|
|
875
|
-
// the delivery stands (adaptlearn F5).
|
|
876
|
-
try {
|
|
877
|
-
await runOps('on-green', {});
|
|
878
|
-
} catch (e) {
|
|
879
|
-
emit('retention-red', { category: 'retention-red', detail: String(/** @type {Error} */ (e).message || e) });
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
// Design law #2 (adaptlearn F18): the run-as-executed record. A mid-run
|
|
883
|
-
// revision changes `config`; without this event the revised config dies with
|
|
884
|
-
// the run and every inheritance channel reads only the config-as-authored.
|
|
885
|
-
emit('config-final', { config, revised });
|
|
886
|
-
return outcome;
|
|
887
|
-
}
|