bareloop 0.5.1 → 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 +722 -1
- package/README.md +13 -3
- package/bareloop.context.md +311 -239
- 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 +266 -20
- package/src/planrun.js +870 -101
- 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 +71 -7
- package/types/planrun.d.ts +41 -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/validate.js
CHANGED
|
@@ -1,48 +1,16 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// The shared validation primitives — the fence transform, containment, the
|
|
2
|
+
// secret-shape inventory, and the small type guards. Both validators
|
|
3
|
+
// (`validateJob`, `validatePlan`) go through this ONE module, because a scope
|
|
4
|
+
// that validated under one transform and enforced under another is the F9
|
|
5
|
+
// red-class: green at the gate, gate-red on every write.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// This file WAS the config-v1 workflow-config validator. config-v1 died in F22
|
|
8
|
+
// (of its 7 knobs only `loop.shape` was ever live, on a never-green run) and
|
|
9
|
+
// was DELETED with the operator-authored steps[] path on 2026-07-26 (PRD
|
|
10
|
+
// v1.32). What remains is what the live path imports — the primitives were
|
|
11
|
+
// housed here, they were never part of the dead schema.
|
|
11
12
|
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
export const LOOP_SHAPES = Object.freeze(['refine', 'plan']);
|
|
15
|
-
export const SLOTS = Object.freeze(['before-attempt', 'after-red', 'on-green']);
|
|
16
|
-
export const VERBS = Object.freeze(['recall', 'compress', 'stash', 'remember']);
|
|
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
|
-
});
|
|
32
|
-
const MAX_OPS_PER_SLOT = 2;
|
|
33
|
-
|
|
34
|
-
// per-verb parameter contracts: name → check(value) (op field itself excluded).
|
|
35
|
-
// remember's kinds are NARROWER than recall's: bound from litectx WRITE_KINDS
|
|
36
|
-
// (the set remember() itself validates against — adaptlearn F5's drift lesson:
|
|
37
|
-
// the export you bind can be wider than the function you call), minus doc:
|
|
38
|
-
// the doc/upload axis stays gated out deliberately.
|
|
39
|
-
export const REMEMBER_KINDS = WRITE_KINDS.filter((k) => k !== 'doc'); // exported: the drafting prompt advertises THIS menu (one source, no drift)
|
|
40
|
-
const VERB_PARAMS = {
|
|
41
|
-
recall: { k: (v) => Number.isInteger(v) && v >= 1 && v <= 20, kinds: isKinds },
|
|
42
|
-
compress: { level: (v) => COMPRESS_LEVELS.includes(v) },
|
|
43
|
-
stash: {},
|
|
44
|
-
remember: { kind: (v) => REMEMBER_KINDS.includes(v) },
|
|
45
|
-
};
|
|
13
|
+
import { redact } from 'bareguard';
|
|
46
14
|
|
|
47
15
|
/**
|
|
48
16
|
* Map a schema writeScope entry to its enforcement prefix. bareguard
|
|
@@ -94,31 +62,6 @@ export function scopeContained(s) {
|
|
|
94
62
|
return prefix.split('/').every((seg) => seg !== '..');
|
|
95
63
|
}
|
|
96
64
|
|
|
97
|
-
/**
|
|
98
|
-
* The per-entry write-scope legality law, one home for all three call sites
|
|
99
|
-
* (workflow gate.writeScope, the job fence, the operator's job-v1 writeScope):
|
|
100
|
-
* a non-empty string, no mid-path wildcard (enforcement is prefix-containment,
|
|
101
|
-
* F9), contained under the run dir (law #1). The chains that need per-check
|
|
102
|
-
* reds still spell the three steps; fenceOk uses this so a fence can never be
|
|
103
|
-
* blessed that the job validator would reject.
|
|
104
|
-
* @param {unknown} s
|
|
105
|
-
*/
|
|
106
|
-
function legalScopeEntry(s) {
|
|
107
|
-
return isNonEmptyString(s) && !globToPrefix(s).includes('*') && scopeContained(s);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Each verb is legal ONLY in the slot where it has effect: recall/compress
|
|
111
|
-
// build the attempt context (consumed by before-attempt only), stash parks the
|
|
112
|
-
// gap (which exists only after-red), remember is verdict-gated retention
|
|
113
|
-
// (on-green only, design law #2). An op that validates green but is inert at
|
|
114
|
-
// runtime would still emit hook-op events — a live-looking knob with zero
|
|
115
|
-
// effect, polluting the contrast evidence the extractor attributes by
|
|
116
|
-
// (design law #3; the F16 credit-loss class).
|
|
117
|
-
const VERB_SLOT = { recall: 'before-attempt', compress: 'before-attempt', stash: 'after-red', remember: 'on-green' };
|
|
118
|
-
|
|
119
|
-
function isKinds(v) {
|
|
120
|
-
return Array.isArray(v) && v.length > 0 && v.every((k) => KINDS.includes(k));
|
|
121
|
-
}
|
|
122
65
|
/** Shared with the job validator — one definition of "a JSON object" for both
|
|
123
66
|
* documents (the sibling-validator drift class).
|
|
124
67
|
* @param {unknown} v */
|
|
@@ -152,8 +95,13 @@ const SECRET_RE = new RegExp(SECRET_PATTERNS.map((r) => r.source).join('|'));
|
|
|
152
95
|
* Scan a RAW text stream (a spine file, a close's output, a transcript) for
|
|
153
96
|
* known secret shapes and return the literal matches. The ONE spelling of the
|
|
154
97
|
* 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
|
-
*
|
|
98
|
+
* hand-rolled copy that misses a shape is a leak on the very output it guards.
|
|
99
|
+
* The dedup is now COMPLETE and the count is grepped, not remembered: all 11
|
|
100
|
+
* call sites in scripts/ go through this function, and zero hand-rolled copies
|
|
101
|
+
* of the expression remain anywhere in the repo. (F40 landed the helper and
|
|
102
|
+
* converted 2 of 7 sites; the 5 it left alone plus 4 that accreted afterwards
|
|
103
|
+
* were converted in one sweep, byte-identical behaviour — measured equivalent
|
|
104
|
+
* on 900 spiked real spines, 0 divergences.)
|
|
157
105
|
* `sweepSecretLiterals` is the config-tree twin — same shapes, tree walk.
|
|
158
106
|
* Never throws; returns [] for a missing stream.
|
|
159
107
|
* @param {unknown} raw
|
|
@@ -166,6 +114,23 @@ export function scanSecrets(raw) {
|
|
|
166
114
|
return SECRET_PATTERNS.flatMap((re) => text.match(new RegExp(re.source, `${re.flags.replace('g', '')}g`)) ?? []);
|
|
167
115
|
}
|
|
168
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Mask known secret shapes in a text. The REDACTION twin of `scanSecrets` and
|
|
119
|
+
* the ONE spelling of it: detection and redaction read the same inventory, so a
|
|
120
|
+
* shape the validator reds can never be a shape the redactor passes (a divergence
|
|
121
|
+
* there is a leak on the very output the validator was guarding). Housed beside
|
|
122
|
+
* the inventory rather than at each consumer for the same reason `scanSecrets`
|
|
123
|
+
* is — a hand-rolled copy is how the two drift.
|
|
124
|
+
* Consumers: the plan flow's spine/close/prompt `scrub`, and the isolate verbs,
|
|
125
|
+
* which write model-authored text into `<workdir>/.litectx` — a file INSIDE the
|
|
126
|
+
* tree that outlives the run and reads back through ctx_recall.
|
|
127
|
+
* @param {unknown} raw
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
export function redactSecrets(raw) {
|
|
131
|
+
return redact(String(raw ?? ''), { patterns: SECRET_PATTERNS });
|
|
132
|
+
}
|
|
133
|
+
|
|
169
134
|
/**
|
|
170
135
|
* Red every string in a config tree that carries a known secret-token shape.
|
|
171
136
|
* @param {any} root
|
|
@@ -190,183 +155,3 @@ export function sweepSecretLiterals(root, red) {
|
|
|
190
155
|
}
|
|
191
156
|
})(root, '');
|
|
192
157
|
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Validate a workflow config against schema v1. Returns the parsed config on
|
|
196
|
-
* ok (single parse — callers never re-parse; null on any red).
|
|
197
|
-
* @param {object|string} input parsed config, or raw JSON text (parse failures are a red)
|
|
198
|
-
* @param {{ shellCapUsd?: number, jobWriteScope?: string[] }} [opts] the shell's
|
|
199
|
-
* ceilings — a config may tighten either, never exceed. `shellCapUsd`: at N2+
|
|
200
|
-
* the shell passes min(shell cap, job budgetUsd) — the ceiling chain
|
|
201
|
-
* workflow ≤ job ≤ shell. `jobWriteScope`: the job spec's outer fence
|
|
202
|
-
* (operator law, job-v1) — every workflow scope must fit inside it.
|
|
203
|
-
* @returns {{ ok: boolean, reds: Array<{code: string, path: string, detail?: string}>, config: object|null }}
|
|
204
|
-
*/
|
|
205
|
-
export function validateConfig(input, { shellCapUsd = 2, jobWriteScope } = {}) {
|
|
206
|
-
/** @type {Array<{code: string, path: string, detail?: string}>} */
|
|
207
|
-
const reds = [];
|
|
208
|
-
/** @type {(code: string, path: string, detail?: string) => void} */
|
|
209
|
-
const red = (code, path, detail) => { reds.push(detail ? { code, path, detail } : { code, path }); };
|
|
210
|
-
|
|
211
|
-
let c = input;
|
|
212
|
-
if (typeof c === 'string') {
|
|
213
|
-
try { c = JSON.parse(c); } catch (e) {
|
|
214
|
-
return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: String(/** @type {Error} */ (e).message) }], config: null };
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
if (!isObj(c)) return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: 'config must be a JSON object' }], config: null };
|
|
218
|
-
|
|
219
|
-
// 1. shape — unknown top-level fields red here (smuggled close/provider included)
|
|
220
|
-
for (const key of Object.keys(c)) {
|
|
221
|
-
if (!TOP_FIELDS.includes(key)) red('unknown-field', key);
|
|
222
|
-
}
|
|
223
|
-
if (c.schema === undefined) red('missing-required', 'schema');
|
|
224
|
-
else if (c.schema !== 'v1') red('invalid-value', 'schema', `expected "v1", got ${JSON.stringify(c.schema)}`);
|
|
225
|
-
|
|
226
|
-
// 2. required bindings + 3. bounds, section by section
|
|
227
|
-
const loop = isObj(c.loop) ? c.loop : {};
|
|
228
|
-
if (loop.shape === undefined) red('missing-required', 'loop.shape');
|
|
229
|
-
else if (!LOOP_SHAPES.includes(loop.shape)) red('invalid-value', 'loop.shape', `menu: ${LOOP_SHAPES.join('|')}`);
|
|
230
|
-
if (loop.maxIterations !== undefined
|
|
231
|
-
&& !(Number.isInteger(loop.maxIterations) && loop.maxIterations >= 1 && loop.maxIterations <= 8)) {
|
|
232
|
-
red('bounds', 'loop.maxIterations', '1..8');
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const memory = isObj(c.memory) ? c.memory : {};
|
|
236
|
-
if (memory.store === undefined) red('missing-required', 'memory.store');
|
|
237
|
-
else if (memory.store !== 'litectx') red('invalid-value', 'memory.store', 'v1 binds "litectx"');
|
|
238
|
-
if (isObj(memory.recall)) {
|
|
239
|
-
const { k, kinds } = memory.recall;
|
|
240
|
-
if (k !== undefined && !(Number.isInteger(k) && k >= 1 && k <= 20)) red('bounds', 'memory.recall.k', '1..20');
|
|
241
|
-
if (kinds !== undefined && !isKinds(kinds)) red('invalid-value', 'memory.recall.kinds', `subset of ${KINDS.join('|')}`);
|
|
242
|
-
}
|
|
243
|
-
if (memory.compressLevel !== undefined && !COMPRESS_LEVELS.includes(memory.compressLevel)) {
|
|
244
|
-
red('invalid-value', 'memory.compressLevel', COMPRESS_LEVELS.join('|'));
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
const gate = isObj(c.gate) ? c.gate : {};
|
|
248
|
-
if (gate.budgetUsd === undefined) red('missing-required', 'gate.budgetUsd');
|
|
249
|
-
else if (!(typeof gate.budgetUsd === 'number' && gate.budgetUsd > 0 && gate.budgetUsd <= shellCapUsd)) {
|
|
250
|
-
red('bounds', 'gate.budgetUsd', `0 < budget <= shell cap ${shellCapUsd}`);
|
|
251
|
-
}
|
|
252
|
-
if (gate.writeScope === undefined) red('missing-required', 'gate.writeScope');
|
|
253
|
-
else if (!(Array.isArray(gate.writeScope) && gate.writeScope.length > 0
|
|
254
|
-
&& gate.writeScope.every(isNonEmptyString))) {
|
|
255
|
-
red('invalid-value', 'gate.writeScope', 'non-empty array of glob strings');
|
|
256
|
-
} else if (!gate.writeScope.every((s) => !globToPrefix(s).includes('*'))) {
|
|
257
|
-
// adaptlearn F9: enforcement (bareguard fs.writeScope) is prefix-containment — a wildcard
|
|
258
|
-
// anywhere but a trailing /** or /* is inexpressible there, so it would validate green
|
|
259
|
-
// and then gate-red EVERY write at runtime. Reds-before-tokens means rejecting it here.
|
|
260
|
-
red('invalid-value', 'gate.writeScope', 'wildcards only as a trailing "/**" or "/*" (enforcement is prefix-containment, adaptlearn F9)');
|
|
261
|
-
} else if (!gate.writeScope.every(scopeContained)) {
|
|
262
|
-
// A scope that can reach the arbiter's inputs is the config-level
|
|
263
|
-
// fit-to-pass surface (design law #1). Reds-before-tokens.
|
|
264
|
-
red('invalid-value', 'gate.writeScope', 'must be a proper subdirectory of the run directory — no absolute paths, no ".." segments, not the run dir itself (design law #1)');
|
|
265
|
-
} else if (jobWriteScope !== undefined && jobWriteScope !== null) {
|
|
266
|
-
// Two-layer fence: the job spec's writeScope is operator law; the authored
|
|
267
|
-
// config may tighten it, never exceed it (the budget pattern, on paths).
|
|
268
|
-
// null/undefined are the legitimate "no fence" spellings (job optional at
|
|
269
|
-
// N2, and the only forms that survive JSON transit) — the layer is skipped,
|
|
270
|
-
// never a deadlock. A PRESENT-but-malformed fence fails CLOSED with its own
|
|
271
|
-
// red, attributed to jobWriteScope (NOT the innocent workflow field — the
|
|
272
|
-
// ledger's contrast attribution must not charge the agent's config for the
|
|
273
|
-
// operator's broken spec).
|
|
274
|
-
const fenceOk = Array.isArray(jobWriteScope) && jobWriteScope.length > 0 && jobWriteScope.every(legalScopeEntry);
|
|
275
|
-
if (!fenceOk) {
|
|
276
|
-
const shown = JSON.stringify(jobWriteScope).slice(0, 200); // bound: a malformed fence is untrusted caller input, and this rides the append-only spine (ralph's 2000-char precedent)
|
|
277
|
-
red('fence-invalid', 'jobWriteScope', `not a validateJob-green fence (${shown}) — validate the job spec before threading its fence`);
|
|
278
|
-
} else {
|
|
279
|
-
// Boundary-aware: "src2" is not inside "src" — prefix means PATH prefix.
|
|
280
|
-
const fence = jobWriteScope.map(globToPrefix);
|
|
281
|
-
const inside = (/** @type {string} */ p) => fence.some((f) => p === f || p.startsWith(f + '/'));
|
|
282
|
-
gate.writeScope.forEach((/** @type {string} */ s, /** @type {number} */ i) => {
|
|
283
|
-
if (!inside(globToPrefix(s))) {
|
|
284
|
-
red('scope-escape', `gate.writeScope.${i}`, `"${s}" is outside the job's fence [${jobWriteScope.join(', ')}] — the workflow may tighten the operator's bound, never exceed it`);
|
|
285
|
-
}
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const escalation = isObj(c.escalation) ? c.escalation : {};
|
|
291
|
-
if (escalation.mode === undefined) red('missing-required', 'escalation.mode');
|
|
292
|
-
else if (escalation.mode !== 'decision-ready') red('invalid-value', 'escalation.mode', 'must be "decision-ready"');
|
|
293
|
-
|
|
294
|
-
// 4. verb legality inside the slots
|
|
295
|
-
if (c.hooks !== undefined) {
|
|
296
|
-
const hooks = isObj(c.hooks) ? c.hooks : {};
|
|
297
|
-
if (!isObj(c.hooks)) red('invalid-value', 'hooks', 'must be an object of slots');
|
|
298
|
-
for (const [slot, ops] of Object.entries(hooks)) {
|
|
299
|
-
const at = `hooks.${slot}`;
|
|
300
|
-
if (!SLOTS.includes(slot)) { red('unknown-field', at); continue; }
|
|
301
|
-
if (!Array.isArray(ops)) { red('invalid-value', at, 'must be an array of ops'); continue; }
|
|
302
|
-
if (ops.length > MAX_OPS_PER_SLOT) { red('slot-overflow', at, `max ${MAX_OPS_PER_SLOT} ops`); continue; }
|
|
303
|
-
ops.forEach((op, i) => {
|
|
304
|
-
const opAt = `${at}.${i}`;
|
|
305
|
-
if (!isObj(op) || !VERBS.includes(op.op)) { red('verb-illegal', opAt, `verbs: ${VERBS.join('|')}`); return; }
|
|
306
|
-
if (VERB_SLOT[op.op] !== slot) {
|
|
307
|
-
red('verb-placement', opAt, `${op.op} is legal only in ${VERB_SLOT[op.op]} — the one slot where it has effect (an inert op is a fake knob in the contrast evidence)`);
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
const params = VERB_PARAMS[op.op];
|
|
311
|
-
for (const [key, value] of Object.entries(op)) {
|
|
312
|
-
if (key === 'op') continue;
|
|
313
|
-
// Object.hasOwn, not `in`: params named after Object.prototype members
|
|
314
|
-
// ("constructor", "toString") must not smuggle past the unknown-param red.
|
|
315
|
-
if (!Object.hasOwn(params, key)) red('verb-params', `${opAt}.${key}`, `unknown param for ${op.op}`);
|
|
316
|
-
else if (!params[key](value)) red('verb-params', `${opAt}.${key}`, `invalid value for ${op.op}.${key}`);
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
}
|
|
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
|
-
|
|
331
|
-
// 5. secrets sweep — same guard as the job spec (the agent-authored document
|
|
332
|
-
// is the riskier one; a green config is a ledger fact and inherits forever)
|
|
333
|
-
sweepSecretLiterals(c, red);
|
|
334
|
-
|
|
335
|
-
return { ok: reds.length === 0, reds, config: reds.length === 0 ? c : null };
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Changed JSON paths between two configs — the one-knob mutation checker
|
|
340
|
-
* (a legal mutant has exactly one). A subtree present on only one side counts
|
|
341
|
-
* as ONE path (its root), so "add an op to a slot" is one knob, not two params.
|
|
342
|
-
* @param {object} a
|
|
343
|
-
* @param {object} b
|
|
344
|
-
* @returns {string[]} sorted changed paths, dot-notation with array indices
|
|
345
|
-
*/
|
|
346
|
-
export function diffPaths(a, b) {
|
|
347
|
-
/** @type {string[]} */
|
|
348
|
-
const paths = [];
|
|
349
|
-
walk(a, b, '');
|
|
350
|
-
return paths.sort();
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* @param {any} x
|
|
354
|
-
* @param {any} y
|
|
355
|
-
* @param {string} at
|
|
356
|
-
*/
|
|
357
|
-
function walk(x, y, at) {
|
|
358
|
-
if (x === y) return;
|
|
359
|
-
const bothObj = isObj(x) && isObj(y);
|
|
360
|
-
const bothArr = Array.isArray(x) && Array.isArray(y);
|
|
361
|
-
if (!bothObj && !bothArr) {
|
|
362
|
-
if (JSON.stringify(x) !== JSON.stringify(y)) paths.push(at || '$');
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
|
-
const keys = new Set([...Object.keys(x), ...Object.keys(y)]);
|
|
366
|
-
for (const key of keys) {
|
|
367
|
-
const p = at ? `${at}.${key}` : key;
|
|
368
|
-
if (!(key in x) || !(key in y)) paths.push(p); // added/removed subtree = one knob
|
|
369
|
-
else walk(x[key], y[key], p);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
}
|
package/types/clock.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F64 — is this throw the run's OWN deadline coming back dressed as a provider
|
|
3
|
+
* error? `callTimeoutMs()` hands the provider a bound derived from the wall, so
|
|
4
|
+
* when the wall is what shrank that bound, the resulting `TimeoutError` is a
|
|
5
|
+
* GOVERNANCE stop, not a casualty. Filing it as `provider-red` would discard it
|
|
6
|
+
* (a casualty is never evidence, F45/F48) and attach the F44
|
|
7
|
+
* `spendComplete:false` floor to a run that simply ran out of time.
|
|
8
|
+
*
|
|
9
|
+
* The discriminator is the wall itself, not the derived number: on a bounded
|
|
10
|
+
* clock a trip can only precede expiry when the bound came from the provider's
|
|
11
|
+
* own default (`remainingMs() > PROVIDER_TIMEOUT_MS`), and that case is transport
|
|
12
|
+
* by construction. So "already expired" IS the wall-derived set. An unbounded run
|
|
13
|
+
* can never produce one, and needs no separate `bounded` term to say so —
|
|
14
|
+
* `expired()` is false by construction with no cap. (A mutation run proved the
|
|
15
|
+
* extra term inert; the unbounded case is guarded by its own test instead, which
|
|
16
|
+
* is where that invariant belongs.)
|
|
17
|
+
*
|
|
18
|
+
* A genuine dead socket on a run whose wall has ALSO passed reads as a wall-halt.
|
|
19
|
+
* That is deliberate and honest: the run is out of time either way, the remedy is
|
|
20
|
+
* the same (raise `maxWallMs`), and the alternative — calling a governance stop
|
|
21
|
+
* transport on the chance that it was — is the failure this closes.
|
|
22
|
+
*
|
|
23
|
+
* @param {any} err the throw as caught
|
|
24
|
+
* @param {Clock} clock the run's clock
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
export function isWallTimeout(err: any, clock: Clock): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {object} Clock
|
|
30
|
+
* @property {boolean} bounded whether the operator set a cap at all
|
|
31
|
+
* @property {number|null} requestedMs the operator's number; `null` when unbounded
|
|
32
|
+
* @property {number} closeStages how many close stages one verdict can pay for (W5)
|
|
33
|
+
* @property {() => number} elapsedMs wall time since the clock was created
|
|
34
|
+
* @property {() => number} remainingMs what is left, floored at 0; `Infinity` when unbounded
|
|
35
|
+
* @property {() => boolean} expired whether the deadline has passed (always false when unbounded)
|
|
36
|
+
* @property {(closeTimeoutMs: number) => number|null} enforcedMs the HONEST worst case
|
|
37
|
+
* @property {(providerDefaultMs?: number) => number} callTimeoutMs the derived per-call bound
|
|
38
|
+
* @property {(closeTimeoutMs: number) => object} report one spine-safe shape carrying both numbers
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* Start a wall clock for one run.
|
|
42
|
+
* @param {{ maxWallMs?: number|null, closeStages?: number, now?: () => number }} [opts]
|
|
43
|
+
* `maxWallMs`: the signed operator cap, or absent/null for an honestly unbounded
|
|
44
|
+
* run. `closeStages`: how many stages the job's close runs (W5) — each one gets
|
|
45
|
+
* the FULL `closeTimeoutMs`, so this is the multiplier on the overshoot, not a
|
|
46
|
+
* decoration. Defaults to 1, the single-predicate close. `now`: injected for
|
|
47
|
+
* tests — the default is the real clock.
|
|
48
|
+
* @returns {Clock}
|
|
49
|
+
*/
|
|
50
|
+
export function createClock({ maxWallMs, closeStages, now }?: {
|
|
51
|
+
maxWallMs?: number | null;
|
|
52
|
+
closeStages?: number;
|
|
53
|
+
now?: () => number;
|
|
54
|
+
}): Clock;
|
|
55
|
+
/** BA-18's default request/idle timeout (`AnthropicProvider.timeoutMs`), the
|
|
56
|
+
* ceiling every derived per-call timeout starts from. Kept in sync deliberately:
|
|
57
|
+
* a value ABOVE the provider's own default would be inert. */
|
|
58
|
+
export const PROVIDER_TIMEOUT_MS: 600000;
|
|
59
|
+
/**
|
|
60
|
+
* The floor for a derived per-call timeout. Below this a call cannot plausibly
|
|
61
|
+
* succeed, and a timeout that is certain to trip manufactures a `provider-red`
|
|
62
|
+
* out of a time budget — a transport CASUALTY, which is never evidence
|
|
63
|
+
* (F45/F48). The deadline check is what must stop a run out of time; the per-call
|
|
64
|
+
* timeout exists to bound a HANG, not to enforce the budget's last second.
|
|
65
|
+
*/
|
|
66
|
+
export const MIN_CALL_TIMEOUT_MS: 30000;
|
|
67
|
+
/** bare-agent's rejection code when its request/idle timeout trips (BA-18,
|
|
68
|
+
* `provider-http.js` → `TimeoutError`). The error carries a `code` and NO
|
|
69
|
+
* `category`, so every consumer that defaults an uncategorised throw would file
|
|
70
|
+
* it as transport. */
|
|
71
|
+
export const TIMEOUT_CODE: "ETIMEDOUT";
|
|
72
|
+
export type Clock = {
|
|
73
|
+
/**
|
|
74
|
+
* whether the operator set a cap at all
|
|
75
|
+
*/
|
|
76
|
+
bounded: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* the operator's number; `null` when unbounded
|
|
79
|
+
*/
|
|
80
|
+
requestedMs: number | null;
|
|
81
|
+
/**
|
|
82
|
+
* how many close stages one verdict can pay for (W5)
|
|
83
|
+
*/
|
|
84
|
+
closeStages: number;
|
|
85
|
+
/**
|
|
86
|
+
* wall time since the clock was created
|
|
87
|
+
*/
|
|
88
|
+
elapsedMs: () => number;
|
|
89
|
+
/**
|
|
90
|
+
* what is left, floored at 0; `Infinity` when unbounded
|
|
91
|
+
*/
|
|
92
|
+
remainingMs: () => number;
|
|
93
|
+
/**
|
|
94
|
+
* whether the deadline has passed (always false when unbounded)
|
|
95
|
+
*/
|
|
96
|
+
expired: () => boolean;
|
|
97
|
+
/**
|
|
98
|
+
* the HONEST worst case
|
|
99
|
+
*/
|
|
100
|
+
enforcedMs: (closeTimeoutMs: number) => number | null;
|
|
101
|
+
/**
|
|
102
|
+
* the derived per-call bound
|
|
103
|
+
*/
|
|
104
|
+
callTimeoutMs: (providerDefaultMs?: number) => number;
|
|
105
|
+
/**
|
|
106
|
+
* one spine-safe shape carrying both numbers
|
|
107
|
+
*/
|
|
108
|
+
report: (closeTimeoutMs: number) => object;
|
|
109
|
+
};
|
package/types/index.d.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
export { makeSpine } from "./spine.js";
|
|
2
2
|
export { runPlan } from "./planrun.js";
|
|
3
3
|
export { runJob } from "./run.js";
|
|
4
|
-
export { ralph, runClose, CLOSE_FAULTS } from "./ralph.js";
|
|
5
|
-
export {
|
|
6
|
-
export { validateJob, jobSpecHash, checkApproval,
|
|
7
|
-
export { validatePlan, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, WRITE_VERBS } from "./plan.js";
|
|
4
|
+
export { ralph, runClose, runStages, CLOSE_FAULTS } from "./ralph.js";
|
|
5
|
+
export { globToPrefix, scanSecrets } from "./validate.js";
|
|
6
|
+
export { validateJob, jobSpecHash, checkApproval, checkMenu, CLOSE_TYPES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS, TOOL_MENU, LOCKED_TOOLS, STORE_VERBS, VERDICT_TYPES, LOCKED_VERDICTS } from "./job.js";
|
|
7
|
+
export { validatePlan, stageClose, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, WRITE_VERBS } from "./plan.js";
|
|
8
8
|
export { snapshotScope, evalExits } from "./exits.js";
|
|
9
|
-
export { interpret, STALL_REDS } from "./interpret.js";
|
|
10
|
-
export { extractRules, MAX_RULES, MAX_RULE_CHARS } from "./extract.js";
|
|
11
9
|
export { classifyIncidents, foldLedger, ledgerDeltas, updateLedger, LEDGER_CLASSES } from "./ledger.js";
|
package/types/job.d.ts
CHANGED
|
@@ -17,9 +17,52 @@ export function validateJob(input: object | string, { shellCapUsd }?: {
|
|
|
17
17
|
job: object | null;
|
|
18
18
|
};
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
21
|
-
* the
|
|
22
|
-
*
|
|
20
|
+
* The staged close (PRD v1.28) — the close is an ORDERED LIST of named stages,
|
|
21
|
+
* and the check menu DERIVES from it. The principle: *the user authors the
|
|
22
|
+
* destination, never the road.* A user who cannot describe a workflow can still
|
|
23
|
+
* say what done looks like, and everything downstream of that is derived — which
|
|
24
|
+
* is why `checks[]` no longer exists as a field (hamr: *"there shouldn't be user
|
|
25
|
+
* authoring anywhere, that defies the point of bareloop"*).
|
|
26
|
+
*
|
|
27
|
+
* Two stage classes cannot be handed to the agent as a mid-build ruler, and F58
|
|
28
|
+
* measured both against a real close:
|
|
29
|
+
* - a PRECONDITION ("the seed commit exists") is not a ruler for the work; run
|
|
30
|
+
* as a check it passes instantly and teaches nothing. It sets `offer: false`.
|
|
31
|
+
* - a stage that reads what an EARLIER stage built ("the public API matches the
|
|
32
|
+
* emitted declarations") reds on its own for a reason that has nothing to do
|
|
33
|
+
* with the worker's edit. It names `needs`, and picking it runs that chain first.
|
|
34
|
+
* Everything else is offerable: derivation gave job #5 SEVEN rulers where the
|
|
35
|
+
* operator had hand-carved three (F58) — removing the operator does not cost the
|
|
36
|
+
* agent rulers, it hands it more.
|
|
37
|
+
*
|
|
38
|
+
* The `needs` expansion is TRANSITIVE (fixed point, not one level): a
|
|
39
|
+
* prerequisite may itself name one, and running an incomplete chain reproduces
|
|
40
|
+
* the exact false-red `needs` exists to remove, one level deeper. The expanded
|
|
41
|
+
* chain is ordered by the CLOSE, never by the order names were typed —
|
|
42
|
+
* validateStagedClose forces `needs` to name EARLIER stages, so close order IS
|
|
43
|
+
* a topological order. A `needs` cycle is inexpressible in a validated close for
|
|
44
|
+
* that same reason; the walk is still cycle-safe by construction (each stage is
|
|
45
|
+
* collected at most once), because a pure exported helper must terminate on
|
|
46
|
+
* whatever it is handed rather than hang the runner before any token burns.
|
|
47
|
+
*
|
|
48
|
+
* @param {any[]} close the validated stage list
|
|
49
|
+
* @returns {{name: string, run: any[]}[]} offerable stages, each with the ordered
|
|
50
|
+
* chain that must run for it (prerequisites first, the stage itself last)
|
|
51
|
+
*/
|
|
52
|
+
export function checkMenu(close: any[]): {
|
|
53
|
+
name: string;
|
|
54
|
+
run: any[];
|
|
55
|
+
}[];
|
|
56
|
+
/**
|
|
57
|
+
* Content hash of a job spec — what an approval record binds to. Any SEMANTIC
|
|
58
|
+
* edit changes the hash, so a spec whose meaning moved is unapproved by
|
|
59
|
+
* construction (interview decision #1: human signs, always, once per spec
|
|
60
|
+
* VERSION). Not every BYTE edit: the hash is taken over the RESOLVED spec
|
|
61
|
+
* (resolveSpec), so writing today's full TOOL_MENU into a spec that omitted
|
|
62
|
+
* `tools` is hash-neutral — the two spell the same ceiling, and that identity
|
|
63
|
+
* is the point of MED-1, since it is what pins WHICH menu the signature covers.
|
|
64
|
+
* The direction that matters still bites: the menu itself changing (or the
|
|
65
|
+
* explicit list changing) moves the resolved form, and the hash with it.
|
|
23
66
|
* @param {object} job
|
|
24
67
|
* @returns {string} sha256 hex
|
|
25
68
|
*/
|
|
@@ -35,8 +78,11 @@ export function jobSpecHash(job: object): string;
|
|
|
35
78
|
*/
|
|
36
79
|
export function checkApproval(job: object, approvals: unknown): boolean;
|
|
37
80
|
export const CLOSE_TYPES: readonly string[];
|
|
38
|
-
|
|
39
|
-
|
|
81
|
+
/** the hierarchy: which verdict classes a close type may claim (PRD §7). Read by
|
|
82
|
+
* the verdictType check — a declared verdict may not demand a class its close
|
|
83
|
+
* type cannot legally claim (green-on-rubric is laundering). The `CLASSES`
|
|
84
|
+
* vocabulary export went with steps[] (PRD v1.32): a class was a STEP field, and
|
|
85
|
+
* nothing declares one now. */
|
|
40
86
|
export const CLASS_BY_CLOSE: Readonly<{
|
|
41
87
|
predicate: readonly string[];
|
|
42
88
|
gold: readonly string[];
|
|
@@ -44,8 +90,6 @@ export const CLASS_BY_CLOSE: Readonly<{
|
|
|
44
90
|
hitl: readonly string[];
|
|
45
91
|
}>;
|
|
46
92
|
export const GOLD_COMPARE: readonly string[];
|
|
47
|
-
/** step middle modes (2b): text = single-target artifact; tools = Gate-governed file tools */
|
|
48
|
-
export const STEP_MODES: readonly string[];
|
|
49
93
|
/** the tool grant menu (2b interview #1): read/grep/write, plus the two litectx
|
|
50
94
|
* RETRIEVAL verbs (F19) — `run` stays locked-but-listed; a spec requesting it
|
|
51
95
|
* reds, and that red IS the request-red evidence admission waits on (curation
|
|
@@ -65,8 +109,39 @@ export const STEP_MODES: readonly string[];
|
|
|
65
109
|
* WRITE-class verb judged by the SAME writeScope fence as `write` (bareguard
|
|
66
110
|
* action type 'edit'). Admitted because whole-file rewrite is measurably
|
|
67
111
|
* unreliable at size (F31: 4 of 5 big-file whole-writes broke the tree) and
|
|
68
|
-
* taxes output tokens ∝ file size. It admits no execution either.
|
|
112
|
+
* taxes output tokens ∝ file size. It admits no execution either.
|
|
113
|
+
*
|
|
114
|
+
* P (design record 2026-07-28, hamr: full catalog) widened the menu to the four
|
|
115
|
+
* components — write · select · compress · isolate — every verb an EXISTING
|
|
116
|
+
* litectx implementation (menu-is-inventory). impact/related/recent/compress/
|
|
117
|
+
* peek are read-only like the retrieval pair; stash/remember/forget are
|
|
118
|
+
* STORE-writes (`.litectx`, never the tree — own gate action types, and the U
|
|
119
|
+
* runner deletes the store at reset so cold stays cold). None admits execution:
|
|
120
|
+
* the `run` lock is untouched. */
|
|
69
121
|
export const TOOL_MENU: readonly string[];
|
|
122
|
+
/** the write-class split the validator and the scout filter read: tree writes
|
|
123
|
+
* (writeScope fence) vs store writes (isolate) — NEITHER is read-capable. */
|
|
124
|
+
export const WRITE_VERBS: readonly string[];
|
|
125
|
+
export const STORE_VERBS: readonly string[];
|
|
126
|
+
/**
|
|
127
|
+
* The floor for `maxWallMs`: one default close timeout (`runClose`'s 120_000).
|
|
128
|
+
* Addendum 1 MEASURED that a wall deadline can only be read BETWEEN rounds —
|
|
129
|
+
* `loop.stop()` does not cut an in-flight call (fired at 500ms, returned at
|
|
130
|
+
* 4,018ms) — so enforcement is `maxWallMs + closeStages × closeTimeoutMs` (W5: a
|
|
131
|
+
* staged close runs every stage under the FULL timeout, ralph.js `runStages`).
|
|
132
|
+
* Under this floor the overshoot exceeds the budget itself and the advertised
|
|
133
|
+
* number would be more wrong than right, which is the advertised-must-equal-
|
|
134
|
+
* enforced line.
|
|
135
|
+
*
|
|
136
|
+
* The VALUE is a one-stage, default-timeout floor and is deliberately left alone —
|
|
137
|
+
* a threshold is operator territory, not something this module re-derives from a
|
|
138
|
+
* spec's own stage count. It therefore bounds only the degenerate case; a job with
|
|
139
|
+
* many stages or a raised `closeTimeoutMs` can still sit far above it while its
|
|
140
|
+
* overshoot dwarfs its cap. The clock reports that honestly (`closeStages` and
|
|
141
|
+
* `enforcedMs` are both on the `wall-clock` record) rather than the floor pretending
|
|
142
|
+
* to prevent it.
|
|
143
|
+
*/
|
|
144
|
+
export const MIN_WALL_MS: 120000;
|
|
70
145
|
/** locked-but-listed tools: real capabilities deliberately outside the grant
|
|
71
146
|
* menu. Requesting one is a DISTINCT `request-red` (module 4) — the ledger
|
|
72
147
|
* counts admission demand, and a generic invalid-value would bury it as a typo. */
|
package/types/ledger.d.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* bareloop vocabulary). Deliberate exclusions — bare cap-halt (a budget story),
|
|
4
4
|
* close-verdict reds and artifact-red (worker stories, the §5b line), gate-red
|
|
5
5
|
* (governance working as intended), pr-red (operator environment, not a suite
|
|
6
|
-
* lib), smoke-red escalations (already counted via primitive-smoke),
|
|
6
|
+
* lib), smoke-red escalations (already counted via primitive-smoke), step-stalled
|
|
7
|
+
* (our own fuse firing on an ABSENCE of beats — no observed lib failure to file),
|
|
8
|
+
* hitl-close
|
|
7
9
|
* and close-unsupported (by design) — classify to nothing.
|
|
8
10
|
* @param {any[]} events one spine's parsed events, in seq order
|
|
9
11
|
* @param {{spine?: string}} [opts] spine id stamped into samples
|