bareloop 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +343 -0
- package/README.md +8 -6
- package/bareloop.context.md +262 -14
- package/package.json +3 -3
- package/src/extract.js +14 -6
- package/src/index.js +13 -3
- package/src/interpret.js +488 -29
- package/src/job.js +119 -3
- package/src/ledger.js +266 -0
- package/src/ralph.js +239 -15
- package/src/run.js +478 -0
- package/src/text.js +65 -8
- package/src/validate.js +42 -1
- package/types/extract.d.ts +4 -2
- package/types/index.d.ts +5 -3
- package/types/interpret.d.ts +39 -6
- package/types/job.d.ts +32 -1
- package/types/ledger.d.ts +76 -0
- package/types/ralph.d.ts +110 -17
- package/types/run.d.ts +58 -0
- package/types/text.d.ts +35 -1
- package/types/validate.d.ts +13 -0
package/src/run.js
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
// The N2 runner — the shell's top layer. It owns everything the emergent parts
|
|
2
|
+
// must never touch: the approval gate (human-signs-always), the primitive
|
|
3
|
+
// smoke, the ONE budget ledger (cap-not-estimate; unpriced is never free, F6),
|
|
4
|
+
// step sequencing, and the decision-ready stops. It composes the pieces below
|
|
5
|
+
// it (drafting → validateConfig → per-step interpret loops) and interprets
|
|
6
|
+
// nothing itself. Design record: docs/plans/2026-07-12-n2-headless-loop-design.md.
|
|
7
|
+
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { LiteCtx, COMPRESS_LEVELS, KINDS } from 'litectx';
|
|
12
|
+
import { redact } from 'bareguard';
|
|
13
|
+
import { validateJob, jobSpecHash, checkApproval } from './job.js';
|
|
14
|
+
import { validateConfig, LOOP_SHAPES, SLOTS, VERBS, globToPrefix, SECRET_PATTERNS, REMEMBER_KINDS } from './validate.js';
|
|
15
|
+
import { interpret } from './interpret.js';
|
|
16
|
+
import { runClose, CLOSE_FAULTS } from './ralph.js';
|
|
17
|
+
import { extractArtifact, priceOf } from './text.js';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const { Loop } = require('bare-agent');
|
|
21
|
+
|
|
22
|
+
/** @typedef {{code: string, path: string, detail?: string}} Red */
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The shell's own drafting allowance, carved out of the job budget (F9).
|
|
26
|
+
* Drafting is a paid shell call that happens BEFORE the config it produces is
|
|
27
|
+
* validated — so a ceiling advertised as the whole job budget is invalidated by
|
|
28
|
+
* the very call that answers it, and a rational drafter (one that claims the
|
|
29
|
+
* ceiling it is given) deadlocks every run. The shell therefore reserves this
|
|
30
|
+
* slice for itself and advertises `budget − reserve` as the ceiling the config
|
|
31
|
+
* may claim. It is a CAP, not an estimate: overspend it and the run stops on
|
|
32
|
+
* the budget story it is, never on a config-red blaming the drafter.
|
|
33
|
+
*/
|
|
34
|
+
const DRAFT_RESERVE_FRAC = 0.05;
|
|
35
|
+
/** truncate (never round up) — an advertised ceiling must be one the shell can honour */
|
|
36
|
+
const floor4 = (/** @type {number} */ n) => Math.floor(n * 10_000) / 10_000;
|
|
37
|
+
|
|
38
|
+
// The drafting prompt: a schema DESCRIPTION built from the live validator
|
|
39
|
+
// menus — never a copyable example config (the drafter must author, not echo;
|
|
40
|
+
// the N2 drafting probe proved a frontier model greens on this, F6). The
|
|
41
|
+
// DRAFT-CONFIG sentinel is fixed shell vocabulary, not content.
|
|
42
|
+
// REMEMBER_KINDS comes from the validator: the prompt advertises the SAME menu
|
|
43
|
+
// the validator enforces — one source, no drift.
|
|
44
|
+
/** @param {any} job @param {Red[]|null} reds @param {number} ceiling the budget the config may actually claim (F9) */
|
|
45
|
+
function draftPrompt(job, reds, ceiling) {
|
|
46
|
+
const doc = `DRAFT-CONFIG
|
|
47
|
+
You are drafting a workflow config (schema "v1") for the job spec below. The config is
|
|
48
|
+
pure declarative JSON validated by a strict schema; ANY unknown field, wrong enum value,
|
|
49
|
+
or out-of-bounds number is rejected. Output ONLY the JSON object, no fences, no commentary.
|
|
50
|
+
|
|
51
|
+
Required top-level fields (no others exist):
|
|
52
|
+
- "schema": must be exactly "v1"
|
|
53
|
+
- "loop": { "shape": one of ${JSON.stringify([...LOOP_SHAPES])}, "maxIterations": optional integer 1..8 }
|
|
54
|
+
- "memory": { "store": must be "litectx",
|
|
55
|
+
"recall": optional { "k": integer 1..20, "kinds": optional non-empty subset of ${JSON.stringify([...KINDS])} },
|
|
56
|
+
"compressLevel": optional, one of ${JSON.stringify([...COMPRESS_LEVELS])} }
|
|
57
|
+
- "gate": { "budgetUsd": number, 0 < n <= the ceiling stated below, "writeScope": array of path-prefix globs like "src/**" }
|
|
58
|
+
- "escalation": { "mode": must be "decision-ready" }
|
|
59
|
+
- "hooks": optional; keys from ${JSON.stringify([...SLOTS])}, each an array of ops.
|
|
60
|
+
Each op is { "op": one of ${JSON.stringify([...VERBS])}, ...params }. STRICT slot legality:
|
|
61
|
+
"recall" (optional "k", "kinds") and "compress" (optional "level") ONLY in "before-attempt";
|
|
62
|
+
"stash" (no params) ONLY in "after-red"; "remember" (optional "kind", one of
|
|
63
|
+
${JSON.stringify(REMEMBER_KINDS)}) ONLY in "on-green".
|
|
64
|
+
|
|
65
|
+
Hard constraints from the job spec (violations are rejected):
|
|
66
|
+
- Every writeScope entry must fit INSIDE the job's writeScope (${JSON.stringify(job.writeScope)}).
|
|
67
|
+
No "..", no absolute paths, no mid-path wildcards.
|
|
68
|
+
- gate.budgetUsd must be <= ${ceiling}. (This is ONE STEP's share of the job's
|
|
69
|
+
$${job.budgetUsd} budget, minus the shell's own drafting allowance: this config is drafted
|
|
70
|
+
once and reused for every step, so the number must still fit after earlier steps have
|
|
71
|
+
spent. Claiming exactly ${ceiling} is legal and validates.)
|
|
72
|
+
- The config CANNOT contain close commands, provider choice, retry caps, or minting claims.
|
|
73
|
+
|
|
74
|
+
Job spec:
|
|
75
|
+
${JSON.stringify(job, null, 2)}`;
|
|
76
|
+
return reds
|
|
77
|
+
? `${doc}\n\nYour previous draft was REJECTED with these reds (code:path):\n${JSON.stringify(reds)}\nFix every red. Output ONLY the corrected JSON object.`
|
|
78
|
+
: doc;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Known-answer smoke on the primitive the run is about to trust (PRD v1.5 §4,
|
|
83
|
+
* adaptlearn A3): a silently-degraded store throws nothing — only a
|
|
84
|
+
* remember→recall round-trip with a KNOWN answer can red it, and it must run
|
|
85
|
+
* BEFORE tokens. Scratch namespace under the workdir so the real lineage store
|
|
86
|
+
* is never polluted with probe facts.
|
|
87
|
+
* @param {string} workdir
|
|
88
|
+
*/
|
|
89
|
+
async function primitiveSmoke(workdir) {
|
|
90
|
+
try {
|
|
91
|
+
const lc = new LiteCtx({ root: join(workdir, '.smoke') });
|
|
92
|
+
await lc.remember('smoke-known-answer', 'bareloop primitive smoke: the answer is 42', { kind: 'fact' });
|
|
93
|
+
const hits = await lc.recall('bareloop primitive smoke known answer', { kind: 'fact', n: 5, body: true });
|
|
94
|
+
const ok = hits.some((h) => String(h.body ?? '').includes('the answer is 42'));
|
|
95
|
+
return { ok, primitive: 'litectx', detail: ok ? `round-trip returned the known answer (${hits.length} hit(s))` : 'remember→recall round-trip lost the known answer' };
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return { ok: false, primitive: 'litectx', detail: String(/** @type {Error} */ (e)?.message || e) };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @typedef {(argv: string[], opts?: {cwd?: string}) => {status: number|null, stdout: string, stderr: string}} ExecCmd */
|
|
102
|
+
|
|
103
|
+
/** default process runner for the hitl PR mechanics — real spawnSync, no shell
|
|
104
|
+
* @type {ExecCmd} */
|
|
105
|
+
const defaultExec = (argv, { cwd } = {}) => {
|
|
106
|
+
const r = spawnSync(argv[0], argv.slice(1), { cwd, encoding: 'utf8', timeout: 60_000 });
|
|
107
|
+
if (r.error) return { status: null, stdout: '', stderr: String(r.error) };
|
|
108
|
+
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The hitl middle's work (2b): branch → stage the job fence ONLY → commit →
|
|
113
|
+
* push → draft PR. Deterministic runner code, never model tools (the model
|
|
114
|
+
* never sees a git or gh surface — design addendum 2026-07-12b). Staging is
|
|
115
|
+
* fence-scoped so spines, audit logs, and anything else living in the workdir
|
|
116
|
+
* never enter the PR. Any failure is an honest red carrying the failed argv;
|
|
117
|
+
* the caller still escalates — a broken PR path must never swallow the
|
|
118
|
+
* escalation (the pain channel, law #7).
|
|
119
|
+
* The checkout is handed back exactly as it was found: the starting branch is
|
|
120
|
+
* read BEFORE anything moves and restored on EVERY path, success or failure
|
|
121
|
+
* (found by the real job #1 run, 2026-07-13 — the workdir was left sitting on
|
|
122
|
+
* the bareloop branch, so the next cadenced run would branch off yesterday's
|
|
123
|
+
* unmerged branch and judge its close against that state). A restore that
|
|
124
|
+
* itself fails is loud (`strandedOn`), never silent.
|
|
125
|
+
*
|
|
126
|
+
* @param {{workdir: string, branch: string, title: string, body: string, addPaths: string[], exec: ExecCmd}} o
|
|
127
|
+
* @returns {Promise<{url: string|null, red: {argv: string, detail: string}|null, strandedOn: string|null}>}
|
|
128
|
+
*/
|
|
129
|
+
async function openDraftPr({ workdir, branch, title, body, addPaths, exec }) {
|
|
130
|
+
// Subprocess output is scrubbed AT CAPTURE with the ONE shape inventory —
|
|
131
|
+
// the same doctrine as the close path (interpret wires SECRET_PATTERNS into
|
|
132
|
+
// ralph): git/gh error text can echo a credentialed remote URL, and the
|
|
133
|
+
// detail lands on the append-only spine (pr-red + the escalation's pr.error).
|
|
134
|
+
const scrub = (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS });
|
|
135
|
+
const head = exec(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: workdir });
|
|
136
|
+
const startBranch = head.status === 0 ? head.stdout.trim() : '';
|
|
137
|
+
/** put the checkout back where it started; report the branch it is stranded on if that fails */
|
|
138
|
+
const restore = () => {
|
|
139
|
+
if (!startBranch) return null; // never knew where we started — nothing honest to restore to
|
|
140
|
+
const r = exec(['git', 'checkout', startBranch], { cwd: workdir });
|
|
141
|
+
return r.status === 0 ? null : branch;
|
|
142
|
+
};
|
|
143
|
+
/** @param {{url?: string|null, red?: {argv: string, detail: string}|null}} o */
|
|
144
|
+
const done = ({ url = null, red = null }) => ({ url, red, strandedOn: restore() });
|
|
145
|
+
|
|
146
|
+
const steps = [
|
|
147
|
+
['git', 'checkout', '-b', branch],
|
|
148
|
+
['git', 'add', '--', ...addPaths],
|
|
149
|
+
['git', 'commit', '-m', title],
|
|
150
|
+
['git', 'push', '-u', 'origin', branch],
|
|
151
|
+
['gh', 'pr', 'create', '--draft', '--title', title, '--body', body],
|
|
152
|
+
];
|
|
153
|
+
for (const argv of steps) {
|
|
154
|
+
const r = exec(argv, { cwd: workdir });
|
|
155
|
+
if (r.status !== 0) {
|
|
156
|
+
return done({ red: { argv: argv.join(' '), detail: `${argv.slice(0, 2).join(' ')} failed: ${scrub((r.stderr || r.stdout || `exit ${r.status}`)).trim().slice(0, 500)}` } });
|
|
157
|
+
}
|
|
158
|
+
if (argv[0] === 'gh') {
|
|
159
|
+
const url = (r.stdout.match(/https?:\/\/\S+/) ?? [null])[0];
|
|
160
|
+
// a "successful" gh with no link is not a PR the human can act on
|
|
161
|
+
return url ? done({ url }) : done({ red: { argv: argv.join(' '), detail: 'no PR URL in gh output' } });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return done({ red: { argv: 'gh pr create', detail: 'PR step never ran' } }); // unreachable while gh is last; belt for edits
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Run an approved job end to end: approval gate → primitive smoke → sequential
|
|
169
|
+
* per-step interpret loops under the one ledger → the hitl step opens the draft
|
|
170
|
+
* PR (deterministic git, never model tools) and becomes the decision-ready
|
|
171
|
+
* escalation. Every predicate step runs its close FIRST (close-first skip,
|
|
172
|
+
* resume model): already-green skips the step for zero tokens as a distinct
|
|
173
|
+
* spine record, so a stopped run reruns from where it died — the workdir plus
|
|
174
|
+
* the closes are the checkpoint. The sealed config draft (one shot + one
|
|
175
|
+
* redraft) is deferred to the first step that actually needs a worker; a clean
|
|
176
|
+
* rerun pays zero provider calls.
|
|
177
|
+
*
|
|
178
|
+
* N2 bounds (honest, documented): text-mode steps write a single `target`
|
|
179
|
+
* artifact; tool-mode steps (2b) work multi-file through the gated shell tools,
|
|
180
|
+
* granted per step by the SPEC (mode/tools — the drafted config cannot express
|
|
181
|
+
* either); `gold`/`rubric` closes refuse `close-unsupported` (execution lands
|
|
182
|
+
* with the verdict classes, N4); the pricing-red halt lands at the step
|
|
183
|
+
* boundary — the Gate still caps within a step, but an unpriced result cannot
|
|
184
|
+
* be summed, so the run stops rather than counting it $0 (F6).
|
|
185
|
+
*
|
|
186
|
+
* @param {object|string} rawSpec the job spec (job-v1), text or parsed
|
|
187
|
+
* @param {object} opts
|
|
188
|
+
* @param {unknown} opts.approvals `{ specHash, signer, ts }` records (from OUTSIDE the spec)
|
|
189
|
+
* @param {string} opts.workdir the run directory (the fence's root)
|
|
190
|
+
* @param {string} [opts.target] the step artifact path (text-mode steps; unused by tool-mode steps)
|
|
191
|
+
* @param {any} opts.provider shell-owned LLM binding (never the config's)
|
|
192
|
+
* @param {(type: string, data?: object) => object} opts.emit spine emitter
|
|
193
|
+
* @param {number} [opts.capRuns] shell-owned per-step iteration cap
|
|
194
|
+
* @param {number} [opts.shellCapUsd] the shell's hard USD ceiling
|
|
195
|
+
* @param {number} [opts.closeTimeoutMs] close wall-clock cap (shell territory)
|
|
196
|
+
* @param {ExecCmd} [opts.execCmd] process runner for the hitl PR mechanics
|
|
197
|
+
* (shell-owned seam, same doctrine as the provider binding)
|
|
198
|
+
* @returns {Promise<string>} outcome: 'green' | 'escalated' | 'unapproved-spec' |
|
|
199
|
+
* 'job-red' | 'smoke-red' | 'config-red' | 'pricing-red' | 'provider-red' |
|
|
200
|
+
* 'cap-halt' | 'close-unsupported' | `step-red:<id>`
|
|
201
|
+
*/
|
|
202
|
+
export async function runJob(rawSpec, { approvals, workdir, target, provider, emit, capRuns = 3, shellCapUsd = 2, closeTimeoutMs, execCmd = defaultExec }) {
|
|
203
|
+
// 1. human-signs-always — before ANY provider call (N1 decision #1)
|
|
204
|
+
if (!checkApproval(rawSpec, approvals)) {
|
|
205
|
+
emit('job-end', { outcome: 'unapproved-spec', detail: 'no approval record matches this exact spec version' });
|
|
206
|
+
return 'unapproved-spec';
|
|
207
|
+
}
|
|
208
|
+
const jv = validateJob(rawSpec, { shellCapUsd });
|
|
209
|
+
if (!jv.ok) {
|
|
210
|
+
for (const r of jv.reds) emit('job-red', r);
|
|
211
|
+
emit('job-end', { outcome: 'job-red' });
|
|
212
|
+
return 'job-red';
|
|
213
|
+
}
|
|
214
|
+
const job = /** @type {any} */ (jv.job);
|
|
215
|
+
// Reds-before-tokens: a text-mode predicate step writes ONE artifact — with
|
|
216
|
+
// no target it would burn a draft + a worker call and then crash inside the
|
|
217
|
+
// middle as a misfiled interpreter-red. The spec is fine; the CALL is not.
|
|
218
|
+
if ((typeof target !== 'string' || !target)
|
|
219
|
+
&& job.steps.some((/** @type {any} */ s) => s.close.type === 'predicate' && (s.mode ?? 'text') === 'text')) {
|
|
220
|
+
emit('job-red', { code: 'missing-required', path: 'opts.target', detail: 'text-mode steps write ONE artifact — pass opts.target (reds-before-tokens)' });
|
|
221
|
+
emit('job-end', { outcome: 'job-red' });
|
|
222
|
+
return 'job-red';
|
|
223
|
+
}
|
|
224
|
+
emit('job-start', { job: job.job, specHash: jobSpecHash(job), budgetUsd: job.budgetUsd, steps: job.steps.map((/** @type {any} */ s) => s.id) });
|
|
225
|
+
|
|
226
|
+
// 2. known-answer smoke before tokens (A3: silent degradation throws nothing)
|
|
227
|
+
const smoke = await primitiveSmoke(workdir);
|
|
228
|
+
emit('primitive-smoke', smoke);
|
|
229
|
+
if (!smoke.ok) {
|
|
230
|
+
emit('escalation', { category: 'smoke-red', decisionReady: true, decision: `The ${smoke.primitive} primitive failed its known-answer check — no run verdict is trustworthy on a degraded primitive.`, options: ['fix the primitive/store', 'abandon the run'], detail: smoke.detail });
|
|
231
|
+
emit('job-end', { outcome: 'smoke-red' });
|
|
232
|
+
return 'smoke-red';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 3. the ONE ledger. Unpriced is never free (F6): a null cost can't be
|
|
236
|
+
// summed, so it flags a stop instead of accumulating $0.
|
|
237
|
+
let spentUsd = 0;
|
|
238
|
+
let unpriced = false;
|
|
239
|
+
/** @param {number|null|undefined} c */
|
|
240
|
+
const account = (c) => { if (typeof c === 'number' && Number.isFinite(c)) spentUsd += c; else unpriced = true; };
|
|
241
|
+
/** @type {(type: string, data?: object) => object} */
|
|
242
|
+
const meter = (type, data) => {
|
|
243
|
+
// The ledger counts ROUNDS, not attempts (F12). `worker-round` fires as each
|
|
244
|
+
// round is bought — including the round that trips the gate — so an attempt
|
|
245
|
+
// that HALTS mid-flight (loop.run never returns, no worker-result, no
|
|
246
|
+
// worker-plan) still lands its real spend here. Accounting the attempt-level
|
|
247
|
+
// events instead made the real run report $0.0048 of a $1.4375 spend.
|
|
248
|
+
// Round-level is also the ONLY level that cannot double-count: worker-result
|
|
249
|
+
// and worker-plan are attempt TOTALS of these same rounds — they stay on the
|
|
250
|
+
// spine for display and are deliberately NOT accounted.
|
|
251
|
+
// `account` reds the run on ANY unpriced round (a null cost is the honest
|
|
252
|
+
// unknown, never $0 — F6): per-round metering means a partially-unpriced run
|
|
253
|
+
// is caught natively, round by round, with no separate unpricedRounds tally.
|
|
254
|
+
if (type === 'worker-round') account(/** @type {any} */ (data)?.costUsd);
|
|
255
|
+
return emit(type, data);
|
|
256
|
+
};
|
|
257
|
+
const pricingRed = () => {
|
|
258
|
+
emit('escalation', { category: 'pricing-red', decisionReady: true, decision: 'A provider result carried no priced cost — the hard cap cannot govern spend it cannot see (unpriced is never free, F6).', options: ['bind a priced provider/model', 'abandon the run'], spentUsd });
|
|
259
|
+
emit('job-end', { outcome: 'pricing-red' });
|
|
260
|
+
return 'pricing-red';
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// 4. sealed drafting: one shot + one redraft, reds fed back, PRICED path
|
|
264
|
+
// (through Loop, the same accounting the worker uses — never around it, F6)
|
|
265
|
+
const drafter = new Loop({ provider, system: 'You draft workflow configs as pure JSON. Output only the JSON object.' });
|
|
266
|
+
/** @type {string|null} a transport throw from the drafting call — spend UNKNOWN, terminal */
|
|
267
|
+
let transportRed = null;
|
|
268
|
+
/** @type {string|null} a drafting round the API TRUNCATED (BA-6, bare-agent 0.27.0) — provider-red like
|
|
269
|
+
* a throw, but the round WAS metered so spend is known; must never launder into a config-red (F25). */
|
|
270
|
+
let draftTruncated = null;
|
|
271
|
+
/** @param {Red[]|null} reds */
|
|
272
|
+
const draft = async (reds) => {
|
|
273
|
+
let r;
|
|
274
|
+
try {
|
|
275
|
+
r = await drafter.run([{ role: 'user', content: draftPrompt(job, reds, ceilingNow()) }]);
|
|
276
|
+
} catch (e) {
|
|
277
|
+
// Loop defaults throwOnError: true — a misconfigured binding or a bare
|
|
278
|
+
// transient 500 REJECTS here. The spend is UNKNOWN (F6), and the spine
|
|
279
|
+
// must still terminate: never an unhandled rejection out of the runner.
|
|
280
|
+
transportRed = String(/** @type {Error} */ (e)?.message ?? e);
|
|
281
|
+
emit('draft-result', { costUsd: null, redraft: reds !== null, error: transportRed });
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
const { costUsd, unpricedRounds } = priceOf(r); // the ONE honest-null cost read (F6)
|
|
285
|
+
account(costUsd);
|
|
286
|
+
if (unpricedRounds > 0) unpriced = true;
|
|
287
|
+
emit('draft-result', { costUsd, redraft: reds !== null, error: r.error ?? null });
|
|
288
|
+
// BA-6: the API cut the drafting round off mid-JSON. That is provider-red (the API's
|
|
289
|
+
// fault, not the drafter's), so it must NOT be laundered into a config-red blaming the
|
|
290
|
+
// drafter (F25's class) and must NOT consume the redraft — retrying a truncation blindly
|
|
291
|
+
// just burns budget; the human decides. Metered above, so unlike transportRed spend is known.
|
|
292
|
+
if (typeof r.error === 'string' && r.error.startsWith('truncated:')) { draftTruncated = r.error; return null; }
|
|
293
|
+
return r.error ? null : (extractArtifact(r.text).code ?? '');
|
|
294
|
+
};
|
|
295
|
+
const providerRed = () => {
|
|
296
|
+
emit('escalation', { category: 'provider-red', decisionReady: true, decision: 'The provider path threw before a result existed — spend for the failed call is unknown (F6), and no drafting verdict exists.', options: ['fix the provider binding', 'retry the run', 'abandon the run'], detail: transportRed, spentUsd });
|
|
297
|
+
emit('job-end', { outcome: 'provider-red' });
|
|
298
|
+
return 'provider-red';
|
|
299
|
+
};
|
|
300
|
+
const capNow = () => Math.min(shellCapUsd, job.budgetUsd - spentUsd);
|
|
301
|
+
// The ceiling the CONFIG may claim (F9): the job budget minus the shell's own
|
|
302
|
+
// drafting allowance, and never above what is actually left. Advertised in the
|
|
303
|
+
// prompt and enforced by the validator — ONE number, so a drafter that claims
|
|
304
|
+
// the ceiling it was given always validates. (Before: the prompt advertised the
|
|
305
|
+
// whole job budget while the validator enforced budget − drafting-spend, a bound
|
|
306
|
+
// the drafter was never told and could not satisfy.)
|
|
307
|
+
// ...and a PER-STEP share of it, not the whole pot. The config is drafted ONCE
|
|
308
|
+
// and re-validated at every step against what is LEFT, so a ceiling sized to the
|
|
309
|
+
// whole budget is stale the moment step 1 spends: step 2 then reds `bounds` on an
|
|
310
|
+
// unchanged config with plenty of money still in the pot (review 2026-07-18).
|
|
311
|
+
// Dividing by the steps that will actually run a worker makes the number the
|
|
312
|
+
// drafter is handed one that still fits after its predecessors have spent —
|
|
313
|
+
// sum(shares) = budget − reserve, so every step can claim its share. This is a
|
|
314
|
+
// DRAFTING change, not an enforcement one: cap-not-estimate is untouched, and a
|
|
315
|
+
// step that needs more than its share still cap-halts cleanly with a resume
|
|
316
|
+
// point. Single-step jobs (every shipped job today) are arithmetically unchanged.
|
|
317
|
+
const loopSteps = Math.max(1, job.steps.filter((s) => s.close.type === 'predicate').length);
|
|
318
|
+
const workerCeiling = floor4(job.budgetUsd * (1 - DRAFT_RESERVE_FRAC) / loopSteps);
|
|
319
|
+
const ceilingNow = () => Math.min(workerCeiling, capNow());
|
|
320
|
+
const cOpts = () => ({ shellCapUsd: ceilingNow(), jobWriteScope: job.writeScope });
|
|
321
|
+
// Drafting is DEFERRED to the first step that needs a worker (resume model,
|
|
322
|
+
// design addendum 2026-07-13): a step whose close already greens skips for
|
|
323
|
+
// zero tokens, so a clean rerun may need no config at all — drafting one no
|
|
324
|
+
// interpret will ever read is spend for nothing (F6's spirit). Decision #3
|
|
325
|
+
// holds: when a config IS needed, it is drafted fresh THIS run, never inherited.
|
|
326
|
+
/** @type {any} the validated config, drafted on first need */
|
|
327
|
+
let config = null;
|
|
328
|
+
/** @returns {Promise<string|null>} a terminal job outcome, or null with `config` set */
|
|
329
|
+
const ensureConfig = async () => {
|
|
330
|
+
if (config) return null;
|
|
331
|
+
let text = await draft(null);
|
|
332
|
+
let cv = validateConfig(text ?? '', cOpts());
|
|
333
|
+
emit('config-validate', { ok: cv.ok, reds: cv.reds, phase: 'draft-1' });
|
|
334
|
+
// the redraft only fires when it can still help: not over a transport red,
|
|
335
|
+
// not over unpriced spend, and never over an already-blown budget (a paid
|
|
336
|
+
// call against a negative cap can only red again — budget story, not drafter)
|
|
337
|
+
if (!cv.ok && !unpriced && !transportRed && !draftTruncated && capNow() > 0) {
|
|
338
|
+
text = await draft(cv.reds);
|
|
339
|
+
cv = validateConfig(text ?? '', cOpts());
|
|
340
|
+
emit('config-validate', { ok: cv.ok, reds: cv.reds, phase: 'draft-2' });
|
|
341
|
+
}
|
|
342
|
+
if (transportRed) return providerRed();
|
|
343
|
+
if (draftTruncated) {
|
|
344
|
+
// A truncated draft is provider-red, NOT config-red — the API cut it off; the drafter is
|
|
345
|
+
// not on trial. Mirrors the worker path (interpret.js) and the human's options: the round
|
|
346
|
+
// was metered, so this is a fault with a known cost, not a spend-unknown transport throw.
|
|
347
|
+
emit('escalation', { category: 'provider-red', decisionReady: true, decision: 'The drafting round was truncated at the output cap (BA-6) — the API cut the config off mid-generation, so no valid config exists. Not the drafter\'s competence (never a config-red), and not silently retried.', options: ['raise the drafting cap and rerun', 'retry the run', 'abandon the run'], detail: draftTruncated, spentUsd });
|
|
348
|
+
emit('job-end', { outcome: 'provider-red' });
|
|
349
|
+
return 'provider-red';
|
|
350
|
+
}
|
|
351
|
+
if (unpriced) return pricingRed();
|
|
352
|
+
if (!cv.ok) {
|
|
353
|
+
if (capNow() <= 0) {
|
|
354
|
+
// drafting consumed the whole job budget before a valid config existed —
|
|
355
|
+
// the honest stop is a cap story, never a config-red blaming the drafter
|
|
356
|
+
emit('cap-halt', { category: 'cap-halt', meaning: 'not under cap — not "can\'t"', spentUsd, budgetUsd: job.budgetUsd });
|
|
357
|
+
emit('escalation', { category: 'cap-halt', decisionReady: true, decision: `Drafting spend ($${spentUsd.toFixed(4)}) consumed the job budget ($${job.budgetUsd}) before a valid config existed.`, options: ['raise the job budget and rerun', 'abandon the run'], spentUsd });
|
|
358
|
+
emit('job-end', { outcome: 'cap-halt' });
|
|
359
|
+
return 'cap-halt';
|
|
360
|
+
}
|
|
361
|
+
for (const r of cv.reds) emit('config-red', r);
|
|
362
|
+
emit('job-end', { outcome: 'config-red' });
|
|
363
|
+
return 'config-red';
|
|
364
|
+
}
|
|
365
|
+
config = cv.config;
|
|
366
|
+
return null;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// 5. sequential per-step loops under the one ledger; a step-red stops the
|
|
370
|
+
// job and the stop is a result (ladder discipline applied to steps)
|
|
371
|
+
for (const step of job.steps) {
|
|
372
|
+
if (step.close.type === 'hitl') {
|
|
373
|
+
// Nothing changed in the fence → nothing for a human to review (found by
|
|
374
|
+
// the real job #1 run, 2026-07-13): a cadenced run whose steps all skip
|
|
375
|
+
// already-green would otherwise branch/add/commit, and `git commit`
|
|
376
|
+
// correctly fails ("nothing added to commit") — a broken-PR escalation
|
|
377
|
+
// every single day. A hitl close is a human decision point; with no
|
|
378
|
+
// changes there IS no decision. Only an AFFIRMATIVE clean answer skips:
|
|
379
|
+
// a failed check (not a repo, broken git) falls through to the PR path
|
|
380
|
+
// and reds honestly there — an unknown fence state is never a green.
|
|
381
|
+
const fence = job.writeScope.map((/** @type {string} */ g) => globToPrefix(g));
|
|
382
|
+
const st = execCmd(['git', 'status', '--porcelain', '--', ...fence], { cwd: workdir });
|
|
383
|
+
if (st.status === 0 && st.stdout.trim() === '') {
|
|
384
|
+
emit('pr-skipped', { step: step.id, reason: 'no changes in the job fence — nothing for a human to review' });
|
|
385
|
+
emit('step-end', { step: step.id, outcome: 'already-green', spentUsd });
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
// the hitl middle's work (2b): deterministic branch/commit/draft-PR —
|
|
389
|
+
// model tools never touch git; a PR failure is red + escalation, never a
|
|
390
|
+
// swallowed escalation
|
|
391
|
+
const branch = `bareloop/${job.job}-${Date.now().toString(36)}`;
|
|
392
|
+
const title = `bareloop draft: ${job.job}`;
|
|
393
|
+
const pr = await openDraftPr({
|
|
394
|
+
workdir, branch, title,
|
|
395
|
+
body: `${job.description}\n\nstep: ${step.id}\nprompt: ${step.close.prompt}\nspent: $${spentUsd.toFixed(4)} of $${job.budgetUsd}\n\nDraft by bareloop — merge stays human, forever.`,
|
|
396
|
+
addPaths: job.writeScope.map((/** @type {string} */ g) => globToPrefix(g)),
|
|
397
|
+
exec: execCmd,
|
|
398
|
+
});
|
|
399
|
+
if (pr.red) emit('pr-red', { step: step.id, ...pr.red });
|
|
400
|
+
else emit('pr-opened', { step: step.id, branch, url: pr.url });
|
|
401
|
+
// a checkout left on a bareloop branch poisons the NEXT cadenced run's
|
|
402
|
+
// baseline — loud, and never allowed to un-open a real PR
|
|
403
|
+
if (pr.strandedOn) emit('workdir-red', { step: step.id, branch: pr.strandedOn, detail: 'the run could not return the checkout to its starting branch — the next run would build on this one' });
|
|
404
|
+
emit('escalation', { category: 'hitl-close', decisionReady: true, step: step.id, prompt: step.close.prompt, spentUsd, decision: step.close.prompt, options: ['approve', 'reject'], pr: { url: pr.url, branch, error: pr.red?.detail ?? null } });
|
|
405
|
+
emit('job-end', { outcome: 'escalated', step: step.id, spentUsd });
|
|
406
|
+
return 'escalated'; // by design: the human acts outside the run, forever
|
|
407
|
+
}
|
|
408
|
+
if (step.close.type !== 'predicate') {
|
|
409
|
+
// honest refusal: gold/rubric EXECUTION lands with the verdict classes
|
|
410
|
+
// (N4); a fake verdict here would poison every contrast downstream
|
|
411
|
+
emit('escalation', { category: 'close-unsupported', decisionReady: true, step: step.id, decision: `Step "${step.id}" has a ${step.close.type} close — N2 executes predicate and hitl closes only.`, options: ['restate the close as a predicate', 'wait for the verdict-classes rung'] });
|
|
412
|
+
emit('job-end', { outcome: 'close-unsupported', step: step.id });
|
|
413
|
+
return 'close-unsupported';
|
|
414
|
+
}
|
|
415
|
+
emit('step-start', { step: step.id, remainingUsd: job.budgetUsd - spentUsd, spentUsd, mode: step.mode ?? 'text' });
|
|
416
|
+
// Close-first skip (resume model): the workdir + the close ARE the
|
|
417
|
+
// checkpoint. Run the step's own arbiter BEFORE any tokens — already-green
|
|
418
|
+
// means the step's work is done, and the step skips as a DISTINCT record
|
|
419
|
+
// (already-green, never plain green: nothing was done, so it mints no
|
|
420
|
+
// learning credit and runs no on-green retention). Same instrument as
|
|
421
|
+
// ralph's verdict (runClose), same at-capture scrub as every close output.
|
|
422
|
+
// trim before splitting: validateJob reds whitespace-padded cmds, but an
|
|
423
|
+
// empty argv[0] would THROW at spawn — belt for any path around the validator
|
|
424
|
+
const closeArgv = step.close.cmd.trim().split(/\s+/);
|
|
425
|
+
const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir, expect: step.close.expect, judged: step.close.judged, gapKeep: step.close.gapKeep };
|
|
426
|
+
const pre = runClose(closeArgv, (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS }), closeOpts);
|
|
427
|
+
emit('close-precheck', { step: step.id, ...pre });
|
|
428
|
+
if (pre.verdict === 'satisfied') {
|
|
429
|
+
emit('step-end', { step: step.id, outcome: 'already-green', spentUsd });
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
// reds-before-tokens: the forbidden zone is checked BEFORE the draft and the
|
|
433
|
+
// worker call. A close that cannot run, never finished, died by signal, or
|
|
434
|
+
// judged nothing rendered NO VERDICT — escalate by its own name (the SAME
|
|
435
|
+
// map ralph uses; two maps would be two instruments), never spend on it.
|
|
436
|
+
const fault = Object.hasOwn(CLOSE_FAULTS, pre.verdict) ? CLOSE_FAULTS[pre.verdict] : undefined;
|
|
437
|
+
if (fault) {
|
|
438
|
+
emit('escalation', { category: fault.category, decisionReady: true, step: step.id, decision: fault.decision, options: fault.options, detail: pre.detail, spentUsd });
|
|
439
|
+
emit('job-end', { outcome: 'step-red', step: step.id, cause: fault.category, spentUsd });
|
|
440
|
+
return `step-red:${step.id}`;
|
|
441
|
+
}
|
|
442
|
+
// The precheck's gap goes to the worker as what it IS: the close's output on
|
|
443
|
+
// the tree as it stands (F13). It is never framed as an attempt — no attempt
|
|
444
|
+
// has happened — so the contrast evidence stays clean. Withholding it (the
|
|
445
|
+
// original call) left the worker unable to see the failure it was hired to
|
|
446
|
+
// fix: `run` is a locked verb, so it cannot execute the close itself.
|
|
447
|
+
const halted = await ensureConfig();
|
|
448
|
+
if (halted) return halted;
|
|
449
|
+
let outcome;
|
|
450
|
+
try {
|
|
451
|
+
outcome = await interpret(config, {
|
|
452
|
+
task: `${job.description} — step: ${step.id}`,
|
|
453
|
+
target, close: closeArgv, workdir, capRuns,
|
|
454
|
+
emit: meter, provider, ...cOpts(), closeTimeoutMs, closeState: pre.gap,
|
|
455
|
+
// the SPEC's grant, threaded verbatim (2b decision #2) — the drafted
|
|
456
|
+
// config cannot express mode or tools, so there is nothing to merge.
|
|
457
|
+
// expect/judged/gapKeep ride the same rail for the same reason: they are
|
|
458
|
+
// arbiter territory (PRD v1.11; F28 for gapKeep).
|
|
459
|
+
mode: step.mode ?? 'text', tools: step.tools,
|
|
460
|
+
closeExpect: step.close.expect, closeJudged: step.close.judged, closeGapKeep: step.close.gapKeep,
|
|
461
|
+
});
|
|
462
|
+
} catch (e) {
|
|
463
|
+
// ralph belts throws INSIDE the loop; this belts the interpreter's own
|
|
464
|
+
// setup (gate.init, store ctor) — the spine must terminate, never dangle
|
|
465
|
+
emit('escalation', { category: 'interpreter-red', decisionReady: true, step: step.id, decision: 'The interpreter broke outside the loop — no harness verdict is trustworthy until it is fixed.', options: ['fix the interpreter/run directory', 'abandon the run'], detail: String(/** @type {Error} */ (e)?.message ?? e), spentUsd });
|
|
466
|
+
emit('job-end', { outcome: 'step-red', step: step.id, cause: 'interpreter-red', spentUsd });
|
|
467
|
+
return `step-red:${step.id}`;
|
|
468
|
+
}
|
|
469
|
+
emit('step-end', { step: step.id, outcome, spentUsd });
|
|
470
|
+
if (unpriced) return pricingRed();
|
|
471
|
+
if (outcome !== 'green') {
|
|
472
|
+
emit('job-end', { outcome: 'step-red', step: step.id, cause: outcome, spentUsd });
|
|
473
|
+
return `step-red:${step.id}`;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
emit('job-end', { outcome: 'green', spentUsd });
|
|
477
|
+
return 'green';
|
|
478
|
+
}
|
package/src/text.js
CHANGED
|
@@ -1,9 +1,66 @@
|
|
|
1
|
-
// Shared
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// Shared model-output helpers. extractArtifact is the ONE parser for every
|
|
2
|
+
// model-output extraction (interpret's artifact, extract's rules, the
|
|
3
|
+
// runner's drafted config) — two parsers that disagree about the same output
|
|
4
|
+
// are two instruments (F2/F21, the adaptlearn instrument caveat). It replaced
|
|
5
|
+
// N0's stripFences when the fence-robust upgrade landed at N2. priceOf is the
|
|
6
|
+
// ONE spelling of the honest-null cost read for the same reason (F6).
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
// A fence counts as the artifact's WRAPPER only when it opens within this many
|
|
9
|
+
// lines of the response — the chatty-preamble shape ("Here's the fix:\n```…").
|
|
10
|
+
// A fence buried deeper inside an unfenced reply is the artifact's OWN content
|
|
11
|
+
// (a doc generator's example block): extracting it truncated a whole module to
|
|
12
|
+
// its 2-line fragment with red:null (review 2026-07-13, confirmed by execution).
|
|
13
|
+
// The trade-off is documented and pinned: past the window, prose + fence is
|
|
14
|
+
// treated as the artifact verbatim — with the no-fences persona, long-preamble
|
|
15
|
+
// replies are the rare shape; truncated artifacts corrupted the close signal.
|
|
16
|
+
const FENCE_PREAMBLE_LINES = 5;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Fence-robust artifact extraction (F2 port requirements #1/#2):
|
|
20
|
+
* - a fenced block opening within the first {@link FENCE_PREAMBLE_LINES} lines
|
|
21
|
+
* is the artifact — prose-wrapped and preamble fences extract clean (the F21
|
|
22
|
+
* instrument caveat: prose written to the target corrupted the close signal);
|
|
23
|
+
* - a fence opening deeper is the artifact's own content — the trimmed whole
|
|
24
|
+
* text is the artifact (fence-heavy artifacts belong in tool mode, where the
|
|
25
|
+
* worker writes files directly and nothing is parsed);
|
|
26
|
+
* - no fence → the trimmed whole text, with N0's unclosed-leading-fence strip
|
|
27
|
+
* as the fallback (reference parity);
|
|
28
|
+
* - nothing extractable → `code: null` plus a named reason: artifact-red
|
|
29
|
+
* material for the caller's OWN axis, never a silent empty write.
|
|
30
|
+
* Never throws.
|
|
31
|
+
* @param {string|null|undefined} t
|
|
32
|
+
* @returns {{ code: string|null, red: string|null }}
|
|
33
|
+
*/
|
|
34
|
+
export function extractArtifact(t) {
|
|
35
|
+
const text = String(t ?? '');
|
|
36
|
+
const m = text.match(/```[^\n`]*\n?([\s\S]*?)```/);
|
|
37
|
+
if (m) {
|
|
38
|
+
if (text.slice(0, /** @type {number} */ (m.index)).split('\n').length - 1 < FENCE_PREAMBLE_LINES) {
|
|
39
|
+
const code = m[1].trim();
|
|
40
|
+
return code ? { code, red: null } : { code: null, red: 'empty fenced block' };
|
|
41
|
+
}
|
|
42
|
+
// deep fence = the artifact's own content: verbatim, NO fence strips — the
|
|
43
|
+
// N0 fallback below would eat a trailing example fence off a real artifact
|
|
44
|
+
return { code: text.trim(), red: null };
|
|
45
|
+
}
|
|
46
|
+
const bare = text.trim().replace(/^```[a-z]*\n?/i, '').replace(/\n?```\s*$/, '').trim();
|
|
47
|
+
return bare ? { code: bare, red: null } : { code: null, red: 'empty response' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The ONE spelling of the F6 honest-null cost read on a bare-agent result.
|
|
52
|
+
* `metrics.costUsd` is the honest null when NOTHING priced — never fall
|
|
53
|
+
* through to `cost` when metrics exist: `cost` sums priced rounds only, so an
|
|
54
|
+
* all-unpriced run reports cost 0 and `?? cost` would launder the explicit
|
|
55
|
+
* unknown into a silent $0 (the F6 class — four shipped launderings were this
|
|
56
|
+
* exact fallback chain hand-spelled). `unpricedRounds` makes a PARTIALLY
|
|
57
|
+
* unpriced run visible (costUsd finite but an under-count).
|
|
58
|
+
* @param {any} r a Loop.run() result (or null)
|
|
59
|
+
* @returns {{ costUsd: number|null, unpricedRounds: number }}
|
|
60
|
+
*/
|
|
61
|
+
export function priceOf(r) {
|
|
62
|
+
return {
|
|
63
|
+
costUsd: r?.metrics ? r.metrics.costUsd : (r?.cost ?? null),
|
|
64
|
+
unpricedRounds: r?.metrics?.unpricedRounds ?? 0,
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/validate.js
CHANGED
|
@@ -15,6 +15,20 @@ export const LOOP_SHAPES = Object.freeze(['refine', 'plan']);
|
|
|
15
15
|
export const SLOTS = Object.freeze(['before-attempt', 'after-red', 'on-green']);
|
|
16
16
|
export const VERBS = Object.freeze(['recall', 'compress', 'stash', 'remember']);
|
|
17
17
|
const TOP_FIELDS = ['schema', 'loop', 'memory', 'hooks', 'gate', 'escalation'];
|
|
18
|
+
// Unknown-field guard, PER SECTION — not just at the top (F17). The arbiter
|
|
19
|
+
// split is guarded "both directions by inexpressibility": the drafted config
|
|
20
|
+
// must be UNABLE to say certain things. A guard that stops at the top level
|
|
21
|
+
// does not deliver that — `gate: { judged: {...} }` (the close's own honesty
|
|
22
|
+
// check) validated green, as would `gate.maxCostUsd`. Nothing consumed those
|
|
23
|
+
// keys, so nothing broke; the guarantee is that nothing ever can. Hook ops are
|
|
24
|
+
// guarded separately by VERB_PARAMS.
|
|
25
|
+
const SECTION_FIELDS = Object.freeze({
|
|
26
|
+
loop: ['shape', 'maxIterations'],
|
|
27
|
+
memory: ['store', 'recall', 'compressLevel'],
|
|
28
|
+
'memory.recall': ['k', 'kinds'],
|
|
29
|
+
gate: ['budgetUsd', 'writeScope'],
|
|
30
|
+
escalation: ['mode'],
|
|
31
|
+
});
|
|
18
32
|
const MAX_OPS_PER_SLOT = 2;
|
|
19
33
|
|
|
20
34
|
// per-verb parameter contracts: name → check(value) (op field itself excluded).
|
|
@@ -22,7 +36,7 @@ const MAX_OPS_PER_SLOT = 2;
|
|
|
22
36
|
// (the set remember() itself validates against — adaptlearn F5's drift lesson:
|
|
23
37
|
// the export you bind can be wider than the function you call), minus doc:
|
|
24
38
|
// the doc/upload axis stays gated out deliberately.
|
|
25
|
-
const REMEMBER_KINDS = WRITE_KINDS.filter((k) => k !== 'doc');
|
|
39
|
+
export const REMEMBER_KINDS = WRITE_KINDS.filter((k) => k !== 'doc'); // exported: the drafting prompt advertises THIS menu (one source, no drift)
|
|
26
40
|
const VERB_PARAMS = {
|
|
27
41
|
recall: { k: (v) => Number.isInteger(v) && v >= 1 && v <= 20, kinds: isKinds },
|
|
28
42
|
compress: { level: (v) => COMPRESS_LEVELS.includes(v) },
|
|
@@ -134,6 +148,24 @@ export const SECRET_PATTERNS = [
|
|
|
134
148
|
];
|
|
135
149
|
const SECRET_RE = new RegExp(SECRET_PATTERNS.map((r) => r.source).join('|'));
|
|
136
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Scan a RAW text stream (a spine file, a close's output, a transcript) for
|
|
153
|
+
* known secret shapes and return the literal matches. The ONE spelling of the
|
|
154
|
+
* text-side scan, for the same reason SECRET_PATTERNS is the ONE inventory: a
|
|
155
|
+
* hand-rolled copy that misses a shape is a leak on the very output it guards
|
|
156
|
+
* (seven copies of this expression lived in scripts/ before it landed here).
|
|
157
|
+
* `sweepSecretLiterals` is the config-tree twin — same shapes, tree walk.
|
|
158
|
+
* Never throws; returns [] for a missing stream.
|
|
159
|
+
* @param {unknown} raw
|
|
160
|
+
* @returns {string[]} every match, in stream order per pattern
|
|
161
|
+
*/
|
|
162
|
+
export function scanSecrets(raw) {
|
|
163
|
+
const text = String(raw ?? '');
|
|
164
|
+
// a fresh global clone per call: a shared /g regex carries lastIndex between
|
|
165
|
+
// calls and would skip matches on the next stream
|
|
166
|
+
return SECRET_PATTERNS.flatMap((re) => text.match(new RegExp(re.source, `${re.flags.replace('g', '')}g`)) ?? []);
|
|
167
|
+
}
|
|
168
|
+
|
|
137
169
|
/**
|
|
138
170
|
* Red every string in a config tree that carries a known secret-token shape.
|
|
139
171
|
* @param {any} root
|
|
@@ -287,6 +319,15 @@ export function validateConfig(input, { shellCapUsd = 2, jobWriteScope } = {}) {
|
|
|
287
319
|
}
|
|
288
320
|
}
|
|
289
321
|
|
|
322
|
+
// 4b. inexpressibility, at every depth the arbiter can be reached from
|
|
323
|
+
for (const [section, allowed] of Object.entries(SECTION_FIELDS)) {
|
|
324
|
+
const obj = section === 'memory.recall' ? memory.recall : c[section];
|
|
325
|
+
if (!isObj(obj)) continue;
|
|
326
|
+
for (const key of Object.keys(obj)) {
|
|
327
|
+
if (!allowed.includes(key)) red('unknown-field', `${section}.${key}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
290
331
|
// 5. secrets sweep — same guard as the job spec (the agent-authored document
|
|
291
332
|
// is the riskier one; a green config is a ledger fact and inherits forever)
|
|
292
333
|
sweepSecretLiterals(c, red);
|
package/types/extract.d.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* @param {object} opts.provider a bareagent provider — SHELL-owned, sealed
|
|
6
6
|
* @param {string[]|null} opts.priorRules the lineage's current rules, if any
|
|
7
7
|
* @param {string[]} [opts.revisionDiff] changed config paths, when the run recovered mid-run
|
|
8
|
-
* @returns {Promise<{rules: string[]|null, valid: boolean, reds: Array<object>, costUsd: number, raw: string}>}
|
|
8
|
+
* @returns {Promise<{rules: string[]|null, valid: boolean, reds: Array<object>, costUsd: number|null, raw: string}>}
|
|
9
|
+
* costUsd is null when the spend could not be priced — the explicit-unknown
|
|
10
|
+
* signal (F6: unpriced is never free); callers must not coerce it to 0
|
|
9
11
|
*/
|
|
10
12
|
export function extractRules({ config, provider, priorRules, revisionDiff }: {
|
|
11
13
|
config: object;
|
|
@@ -16,7 +18,7 @@ export function extractRules({ config, provider, priorRules, revisionDiff }: {
|
|
|
16
18
|
rules: string[] | null;
|
|
17
19
|
valid: boolean;
|
|
18
20
|
reds: Array<object>;
|
|
19
|
-
costUsd: number;
|
|
21
|
+
costUsd: number | null;
|
|
20
22
|
raw: string;
|
|
21
23
|
}>;
|
|
22
24
|
export const MAX_RULES: 5;
|