bareloop 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,331 @@
1
+ // Workflow-config validator — a deterministic predicate that runs before any
2
+ // tokens burn (PRD §4: schema-validated, config-red before tokens). Every
3
+ // failure is a distinct named red { code, path, detail } so the spine can carry
4
+ // `config-red: missing-required:gate.writeScope`. An invalid config is a red,
5
+ // not a crash: this module never throws on bad input.
6
+ //
7
+ // The verb vocabulary is BOUND from litectx (adaptlearn F1 — consume, don't
8
+ // build; design law #10); this schema exposes the adaptlearn-proven 4-verb
9
+ // subset. The close and the provider are deliberately not expressible here:
10
+ // they arrive as unknown-field reds — the shell owns both (design law #1).
11
+
12
+ import { COMPRESS_LEVELS, KINDS, WRITE_KINDS } from 'litectx';
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
+ const MAX_OPS_PER_SLOT = 2;
19
+
20
+ // per-verb parameter contracts: name → check(value) (op field itself excluded).
21
+ // remember's kinds are NARROWER than recall's: bound from litectx WRITE_KINDS
22
+ // (the set remember() itself validates against — adaptlearn F5's drift lesson:
23
+ // the export you bind can be wider than the function you call), minus doc:
24
+ // the doc/upload axis stays gated out deliberately.
25
+ const REMEMBER_KINDS = WRITE_KINDS.filter((k) => k !== 'doc');
26
+ const VERB_PARAMS = {
27
+ recall: { k: (v) => Number.isInteger(v) && v >= 1 && v <= 20, kinds: isKinds },
28
+ compress: { level: (v) => COMPRESS_LEVELS.includes(v) },
29
+ stash: {},
30
+ remember: { kind: (v) => REMEMBER_KINDS.includes(v) },
31
+ };
32
+
33
+ /**
34
+ * Map a schema writeScope entry to its enforcement prefix. bareguard
35
+ * fs.writeScope is prefix-containment, not glob (adaptlearn F4/F9): the
36
+ * trailing "/**" | "/*" form maps to its directory prefix. The validator's
37
+ * legality rule and the interpreter's enforcement mapping BOTH go through this
38
+ * one helper — if they ever used different transforms, a scope could validate
39
+ * green and then gate-red every write at runtime (the F9 red-class).
40
+ * @param {string} scope
41
+ */
42
+ export function globToPrefix(scope) {
43
+ // One canonical spelling for equivalent paths — leading "./", interior "/./",
44
+ // doubled "//", trailing "/" all collapse — so the legality rule, the fence
45
+ // comparison, and bareguard enforcement agree on what a scope NAMES. Without
46
+ // this, a validateJob-green fence like "src/" would reject every workflow
47
+ // scope inside it (the F9 red-class across two documents).
48
+ //
49
+ // Order is load-bearing: "//" collapses and interior "/./" drops BEFORE the
50
+ // leading "./" strip, so ".//src" → "src" and never "/src". Stripping first
51
+ // would MINT an absolute prefix from a relative scope — a design-law-#1
52
+ // containment escape (resolve(workdir, "/src") ignores workdir entirely).
53
+ //
54
+ // Deliberately NOT path.posix.normalize: it resolves ".." segments
55
+ // (normalize("src/../etc") === "etc"), which must stay VISIBLE so
56
+ // scopeContained can reject them — the whole point of the containment law.
57
+ let p = scope.replace(/\/\*\*?$/, '');
58
+ p = p.replace(/\/{2,}/g, '/').replace(/\/\.(?=\/|$)/g, '').replace(/^(?:\.\/)+/, '').replace(/\/+$/, '');
59
+ return p;
60
+ }
61
+
62
+ /**
63
+ * A scope may never reach the arbiter's inputs (design law #1): it must
64
+ * resolve to a PROPER subdirectory of the run directory. Absolute paths and
65
+ * ".." segments escape it; "." / "./**" cover the whole run directory — where
66
+ * the close suite lives. Windows spellings ("..\", "C:\") count as escapes.
67
+ * Exported for the job validator: the operator's outer fence obeys the SAME
68
+ * law through the same code — two containment transforms would be the F9
69
+ * red-class one level up.
70
+ * @param {string} s
71
+ */
72
+ export function scopeContained(s) {
73
+ if (s.startsWith('/') || s.includes('\\') || /^[a-zA-Z]:/.test(s)) return false;
74
+ const prefix = globToPrefix(s);
75
+ // Belt: reject a prefix that is empty, the run dir, absolute, or a drive —
76
+ // checked on the NORMALIZED prefix, not just the raw string, so a spelling
77
+ // that normalizes to something absolute can never pass (defense in depth
78
+ // against a future globToPrefix regression; the un-gameable gate, law #1).
79
+ if (prefix === '' || prefix === '.' || prefix.startsWith('/') || /^[a-zA-Z]:/.test(prefix)) return false;
80
+ return prefix.split('/').every((seg) => seg !== '..');
81
+ }
82
+
83
+ /**
84
+ * The per-entry write-scope legality law, one home for all three call sites
85
+ * (workflow gate.writeScope, the job fence, the operator's job-v1 writeScope):
86
+ * a non-empty string, no mid-path wildcard (enforcement is prefix-containment,
87
+ * F9), contained under the run dir (law #1). The chains that need per-check
88
+ * reds still spell the three steps; fenceOk uses this so a fence can never be
89
+ * blessed that the job validator would reject.
90
+ * @param {unknown} s
91
+ */
92
+ function legalScopeEntry(s) {
93
+ return isNonEmptyString(s) && !globToPrefix(s).includes('*') && scopeContained(s);
94
+ }
95
+
96
+ // Each verb is legal ONLY in the slot where it has effect: recall/compress
97
+ // build the attempt context (consumed by before-attempt only), stash parks the
98
+ // gap (which exists only after-red), remember is verdict-gated retention
99
+ // (on-green only, design law #2). An op that validates green but is inert at
100
+ // runtime would still emit hook-op events — a live-looking knob with zero
101
+ // effect, polluting the contrast evidence the extractor attributes by
102
+ // (design law #3; the F16 credit-loss class).
103
+ const VERB_SLOT = { recall: 'before-attempt', compress: 'before-attempt', stash: 'after-red', remember: 'on-green' };
104
+
105
+ function isKinds(v) {
106
+ return Array.isArray(v) && v.length > 0 && v.every((k) => KINDS.includes(k));
107
+ }
108
+ /** Shared with the job validator — one definition of "a JSON object" for both
109
+ * documents (the sibling-validator drift class).
110
+ * @param {unknown} v */
111
+ export function isObj(v) {
112
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
113
+ }
114
+ /** @param {unknown} v @returns {v is string} */
115
+ export function isNonEmptyString(v) {
116
+ return typeof v === 'string' && v.length > 0;
117
+ }
118
+
119
+ // Secrets never enter the tree/spine/configs (hard line) — BOTH config
120
+ // documents get the same sweep: the agent-authored workflow config is the
121
+ // riskier entry point (machine-written), the operator's job spec the other.
122
+ // Known token shapes only, left-bounded so hyphenated words ("flask-sqlalchemy")
123
+ // never red — defense-in-depth; env-only loading is the law, not this regex.
124
+ // ONE shape inventory for the whole system: detection (this sweep) and the
125
+ // spine redactor (interpret wires these into bareguard's redact) must never
126
+ // disagree on what a secret looks like — a shape the validator reds but the
127
+ // redactor passes is a leak on the very output the validator was guarding.
128
+ export const SECRET_PATTERNS = [
129
+ /(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{16,}/,
130
+ /(?<![A-Za-z0-9_-])ghp_[A-Za-z0-9]{20,}/,
131
+ /(?<![A-Za-z0-9_-])github_pat_[A-Za-z0-9_]{20,}/,
132
+ /(?<![A-Za-z0-9_-])AKIA[0-9A-Z]{16}/,
133
+ /(?<![A-Za-z0-9_-])xox[bap]-[A-Za-z0-9-]{10,}/,
134
+ ];
135
+ const SECRET_RE = new RegExp(SECRET_PATTERNS.map((r) => r.source).join('|'));
136
+
137
+ /**
138
+ * Red every string in a config tree that carries a known secret-token shape.
139
+ * @param {any} root
140
+ * @param {(code: string, path: string, detail?: string) => void} red
141
+ */
142
+ export function sweepSecretLiterals(root, red) {
143
+ (/** @type {(node: any, at: string) => void} */
144
+ function sweep(node, at) {
145
+ if (typeof node === 'string') {
146
+ if (SECRET_RE.test(node)) red('secret-literal', at, 'secrets load from the environment; an append-only record that captures a key captures it forever');
147
+ return;
148
+ }
149
+ if (Array.isArray(node)) node.forEach((v, i) => sweep(v, `${at}.${i}`));
150
+ else if (isObj(node)) {
151
+ for (const [k, v] of Object.entries(node)) {
152
+ const path = at ? `${at}.${k}` : k;
153
+ // keys too: inside free-form values (a gold expected, a conditions map)
154
+ // a key is unconstrained, so a token can ride a KEY onto the spine
155
+ if (SECRET_RE.test(k)) red('secret-literal', path, 'secrets load from the environment; an append-only record that captures a key captures it forever');
156
+ sweep(v, path);
157
+ }
158
+ }
159
+ })(root, '');
160
+ }
161
+
162
+ /**
163
+ * Validate a workflow config against schema v1. Returns the parsed config on
164
+ * ok (single parse — callers never re-parse; null on any red).
165
+ * @param {object|string} input parsed config, or raw JSON text (parse failures are a red)
166
+ * @param {{ shellCapUsd?: number, jobWriteScope?: string[] }} [opts] the shell's
167
+ * ceilings — a config may tighten either, never exceed. `shellCapUsd`: at N2+
168
+ * the shell passes min(shell cap, job budgetUsd) — the ceiling chain
169
+ * workflow ≤ job ≤ shell. `jobWriteScope`: the job spec's outer fence
170
+ * (operator law, job-v1) — every workflow scope must fit inside it.
171
+ * @returns {{ ok: boolean, reds: Array<{code: string, path: string, detail?: string}>, config: object|null }}
172
+ */
173
+ export function validateConfig(input, { shellCapUsd = 2, jobWriteScope } = {}) {
174
+ /** @type {Array<{code: string, path: string, detail?: string}>} */
175
+ const reds = [];
176
+ /** @type {(code: string, path: string, detail?: string) => void} */
177
+ const red = (code, path, detail) => { reds.push(detail ? { code, path, detail } : { code, path }); };
178
+
179
+ let c = input;
180
+ if (typeof c === 'string') {
181
+ try { c = JSON.parse(c); } catch (e) {
182
+ return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: String(/** @type {Error} */ (e).message) }], config: null };
183
+ }
184
+ }
185
+ if (!isObj(c)) return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: 'config must be a JSON object' }], config: null };
186
+
187
+ // 1. shape — unknown top-level fields red here (smuggled close/provider included)
188
+ for (const key of Object.keys(c)) {
189
+ if (!TOP_FIELDS.includes(key)) red('unknown-field', key);
190
+ }
191
+ if (c.schema === undefined) red('missing-required', 'schema');
192
+ else if (c.schema !== 'v1') red('invalid-value', 'schema', `expected "v1", got ${JSON.stringify(c.schema)}`);
193
+
194
+ // 2. required bindings + 3. bounds, section by section
195
+ const loop = isObj(c.loop) ? c.loop : {};
196
+ if (loop.shape === undefined) red('missing-required', 'loop.shape');
197
+ else if (!LOOP_SHAPES.includes(loop.shape)) red('invalid-value', 'loop.shape', `menu: ${LOOP_SHAPES.join('|')}`);
198
+ if (loop.maxIterations !== undefined
199
+ && !(Number.isInteger(loop.maxIterations) && loop.maxIterations >= 1 && loop.maxIterations <= 8)) {
200
+ red('bounds', 'loop.maxIterations', '1..8');
201
+ }
202
+
203
+ const memory = isObj(c.memory) ? c.memory : {};
204
+ if (memory.store === undefined) red('missing-required', 'memory.store');
205
+ else if (memory.store !== 'litectx') red('invalid-value', 'memory.store', 'v1 binds "litectx"');
206
+ if (isObj(memory.recall)) {
207
+ const { k, kinds } = memory.recall;
208
+ if (k !== undefined && !(Number.isInteger(k) && k >= 1 && k <= 20)) red('bounds', 'memory.recall.k', '1..20');
209
+ if (kinds !== undefined && !isKinds(kinds)) red('invalid-value', 'memory.recall.kinds', `subset of ${KINDS.join('|')}`);
210
+ }
211
+ if (memory.compressLevel !== undefined && !COMPRESS_LEVELS.includes(memory.compressLevel)) {
212
+ red('invalid-value', 'memory.compressLevel', COMPRESS_LEVELS.join('|'));
213
+ }
214
+
215
+ const gate = isObj(c.gate) ? c.gate : {};
216
+ if (gate.budgetUsd === undefined) red('missing-required', 'gate.budgetUsd');
217
+ else if (!(typeof gate.budgetUsd === 'number' && gate.budgetUsd > 0 && gate.budgetUsd <= shellCapUsd)) {
218
+ red('bounds', 'gate.budgetUsd', `0 < budget <= shell cap ${shellCapUsd}`);
219
+ }
220
+ if (gate.writeScope === undefined) red('missing-required', 'gate.writeScope');
221
+ else if (!(Array.isArray(gate.writeScope) && gate.writeScope.length > 0
222
+ && gate.writeScope.every(isNonEmptyString))) {
223
+ red('invalid-value', 'gate.writeScope', 'non-empty array of glob strings');
224
+ } else if (!gate.writeScope.every((s) => !globToPrefix(s).includes('*'))) {
225
+ // adaptlearn F9: enforcement (bareguard fs.writeScope) is prefix-containment — a wildcard
226
+ // anywhere but a trailing /** or /* is inexpressible there, so it would validate green
227
+ // and then gate-red EVERY write at runtime. Reds-before-tokens means rejecting it here.
228
+ red('invalid-value', 'gate.writeScope', 'wildcards only as a trailing "/**" or "/*" (enforcement is prefix-containment, adaptlearn F9)');
229
+ } else if (!gate.writeScope.every(scopeContained)) {
230
+ // A scope that can reach the arbiter's inputs is the config-level
231
+ // fit-to-pass surface (design law #1). Reds-before-tokens.
232
+ 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)');
233
+ } else if (jobWriteScope !== undefined && jobWriteScope !== null) {
234
+ // Two-layer fence: the job spec's writeScope is operator law; the authored
235
+ // config may tighten it, never exceed it (the budget pattern, on paths).
236
+ // null/undefined are the legitimate "no fence" spellings (job optional at
237
+ // N2, and the only forms that survive JSON transit) — the layer is skipped,
238
+ // never a deadlock. A PRESENT-but-malformed fence fails CLOSED with its own
239
+ // red, attributed to jobWriteScope (NOT the innocent workflow field — the
240
+ // ledger's contrast attribution must not charge the agent's config for the
241
+ // operator's broken spec).
242
+ const fenceOk = Array.isArray(jobWriteScope) && jobWriteScope.length > 0 && jobWriteScope.every(legalScopeEntry);
243
+ if (!fenceOk) {
244
+ 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)
245
+ red('fence-invalid', 'jobWriteScope', `not a validateJob-green fence (${shown}) — validate the job spec before threading its fence`);
246
+ } else {
247
+ // Boundary-aware: "src2" is not inside "src" — prefix means PATH prefix.
248
+ const fence = jobWriteScope.map(globToPrefix);
249
+ const inside = (/** @type {string} */ p) => fence.some((f) => p === f || p.startsWith(f + '/'));
250
+ gate.writeScope.forEach((/** @type {string} */ s, /** @type {number} */ i) => {
251
+ if (!inside(globToPrefix(s))) {
252
+ 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`);
253
+ }
254
+ });
255
+ }
256
+ }
257
+
258
+ const escalation = isObj(c.escalation) ? c.escalation : {};
259
+ if (escalation.mode === undefined) red('missing-required', 'escalation.mode');
260
+ else if (escalation.mode !== 'decision-ready') red('invalid-value', 'escalation.mode', 'must be "decision-ready"');
261
+
262
+ // 4. verb legality inside the slots
263
+ if (c.hooks !== undefined) {
264
+ const hooks = isObj(c.hooks) ? c.hooks : {};
265
+ if (!isObj(c.hooks)) red('invalid-value', 'hooks', 'must be an object of slots');
266
+ for (const [slot, ops] of Object.entries(hooks)) {
267
+ const at = `hooks.${slot}`;
268
+ if (!SLOTS.includes(slot)) { red('unknown-field', at); continue; }
269
+ if (!Array.isArray(ops)) { red('invalid-value', at, 'must be an array of ops'); continue; }
270
+ if (ops.length > MAX_OPS_PER_SLOT) { red('slot-overflow', at, `max ${MAX_OPS_PER_SLOT} ops`); continue; }
271
+ ops.forEach((op, i) => {
272
+ const opAt = `${at}.${i}`;
273
+ if (!isObj(op) || !VERBS.includes(op.op)) { red('verb-illegal', opAt, `verbs: ${VERBS.join('|')}`); return; }
274
+ if (VERB_SLOT[op.op] !== slot) {
275
+ 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)`);
276
+ return;
277
+ }
278
+ const params = VERB_PARAMS[op.op];
279
+ for (const [key, value] of Object.entries(op)) {
280
+ if (key === 'op') continue;
281
+ // Object.hasOwn, not `in`: params named after Object.prototype members
282
+ // ("constructor", "toString") must not smuggle past the unknown-param red.
283
+ if (!Object.hasOwn(params, key)) red('verb-params', `${opAt}.${key}`, `unknown param for ${op.op}`);
284
+ else if (!params[key](value)) red('verb-params', `${opAt}.${key}`, `invalid value for ${op.op}.${key}`);
285
+ }
286
+ });
287
+ }
288
+ }
289
+
290
+ // 5. secrets sweep — same guard as the job spec (the agent-authored document
291
+ // is the riskier one; a green config is a ledger fact and inherits forever)
292
+ sweepSecretLiterals(c, red);
293
+
294
+ return { ok: reds.length === 0, reds, config: reds.length === 0 ? c : null };
295
+ }
296
+
297
+ /**
298
+ * Changed JSON paths between two configs — the one-knob mutation checker
299
+ * (a legal mutant has exactly one). A subtree present on only one side counts
300
+ * as ONE path (its root), so "add an op to a slot" is one knob, not two params.
301
+ * @param {object} a
302
+ * @param {object} b
303
+ * @returns {string[]} sorted changed paths, dot-notation with array indices
304
+ */
305
+ export function diffPaths(a, b) {
306
+ /** @type {string[]} */
307
+ const paths = [];
308
+ walk(a, b, '');
309
+ return paths.sort();
310
+
311
+ /**
312
+ * @param {any} x
313
+ * @param {any} y
314
+ * @param {string} at
315
+ */
316
+ function walk(x, y, at) {
317
+ if (x === y) return;
318
+ const bothObj = isObj(x) && isObj(y);
319
+ const bothArr = Array.isArray(x) && Array.isArray(y);
320
+ if (!bothObj && !bothArr) {
321
+ if (JSON.stringify(x) !== JSON.stringify(y)) paths.push(at || '$');
322
+ return;
323
+ }
324
+ const keys = new Set([...Object.keys(x), ...Object.keys(y)]);
325
+ for (const key of keys) {
326
+ const p = at ? `${at}.${key}` : key;
327
+ if (!(key in x) || !(key in y)) paths.push(p); // added/removed subtree = one knob
328
+ else walk(x[key], y[key], p);
329
+ }
330
+ }
331
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Distill/update a lineage's rules from one green run's ledger facts.
3
+ * @param {object} opts
4
+ * @param {object} opts.config the config that went green
5
+ * @param {object} opts.provider a bareagent provider — SHELL-owned, sealed
6
+ * @param {string[]|null} opts.priorRules the lineage's current rules, if any
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}>}
9
+ */
10
+ export function extractRules({ config, provider, priorRules, revisionDiff }: {
11
+ config: object;
12
+ provider: object;
13
+ priorRules: string[] | null;
14
+ revisionDiff?: string[] | undefined;
15
+ }): Promise<{
16
+ rules: string[] | null;
17
+ valid: boolean;
18
+ reds: Array<object>;
19
+ costUsd: number;
20
+ raw: string;
21
+ }>;
22
+ export const MAX_RULES: 5;
23
+ export const MAX_RULE_CHARS: 200;
@@ -0,0 +1,6 @@
1
+ export { makeSpine } from "./spine.js";
2
+ export { ralph, runClose } from "./ralph.js";
3
+ export { validateConfig, diffPaths, globToPrefix, LOOP_SHAPES, SLOTS, VERBS } from "./validate.js";
4
+ export { validateJob, jobSpecHash, checkApproval, CLOSE_TYPES, CLASSES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS } from "./job.js";
5
+ export { interpret, STALL_REDS } from "./interpret.js";
6
+ export { extractRules, MAX_RULES, MAX_RULE_CHARS } from "./extract.js";
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Execute a workflow config against one task under the dumb shell.
3
+ *
4
+ * @param {object|string} configRaw schema v1 config (object or raw JSON text)
5
+ * @param {object} opts
6
+ * @param {string} opts.task implement instruction shown to the worker
7
+ * @param {string} opts.target absolute path the artifact is written to
8
+ * @param {string[]} opts.close argv whose exit code is truth (shell-owned)
9
+ * @param {string} opts.workdir run directory (litectx root, gate audit, scope base)
10
+ * @param {number} opts.capRuns shell iteration budget; the config may tighten via loop.maxIterations, never exceed
11
+ * @param {(type: string, data?: object) => object} opts.emit spine emitter
12
+ * @param {object} opts.provider a bareagent provider — SHELL-owned binding (adaptlearn F8: an unsealed binding is a gate bypass)
13
+ * @param {number} [opts.shellCapUsd=2] the shell's USD cap; config budgetUsd is clamped by validation
14
+ * @param {string[]} [opts.jobWriteScope] the job spec's outer write fence (operator law,
15
+ * job-v1) — enforced HERE, the one choke point where a config becomes a Gate:
16
+ * every workflow scope must fit inside it (scope-escape config-red otherwise),
17
+ * on entry validation and on every revision candidate alike
18
+ * @param {(o: {config: object, gaps: string[], policy: any, onLlmResult: any}) => Promise<{candidate: object|null, parseError?: string|null, costUsd?: number}>} [opts.revisor]
19
+ * optional mid-run revision seam. Fires ONCE per run after STALL_REDS consecutive
20
+ * close reds. The interpreter — never the revisor — owns acceptance: the candidate
21
+ * must validate, and gate/escalation/loop.maxIterations must be unchanged
22
+ * (arbiter-touch / cap-touch revision-reds otherwise; the run continues on the old
23
+ * config). Revisor spend rides the run's own gate handlers — same budget axis as
24
+ * the worker.
25
+ * @returns {Promise<'green'|'escalated'|'config-red'>}
26
+ */
27
+ export function interpret(configRaw: object | string, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd, jobWriteScope, revisor }: {
28
+ task: string;
29
+ target: string;
30
+ close: string[];
31
+ workdir: string;
32
+ capRuns: number;
33
+ emit: (type: string, data?: object) => object;
34
+ provider: object;
35
+ shellCapUsd?: number | undefined;
36
+ jobWriteScope?: string[] | undefined;
37
+ revisor?: ((o: {
38
+ config: object;
39
+ gaps: string[];
40
+ policy: any;
41
+ onLlmResult: any;
42
+ }) => Promise<{
43
+ candidate: object | null;
44
+ parseError?: string | null;
45
+ costUsd?: number;
46
+ }>) | undefined;
47
+ }): Promise<"green" | "escalated" | "config-red">;
48
+ /** @typedef {Error & {category?: string}} CategorizedError the failure map's carrier: ralph relays by `category` */
49
+ export const STALL_REDS: 2;
50
+ /**
51
+ * the failure map's carrier: ralph relays by `category`
52
+ */
53
+ export type CategorizedError = Error & {
54
+ category?: string;
55
+ };
56
+ /**
57
+ * litectx recall hit — body present only with `{body: true}`
58
+ */
59
+ export type RecallHit = {
60
+ body?: string | null;
61
+ text?: string | null;
62
+ };
package/types/job.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ /** @typedef {{code: string, path: string, detail?: string}} Red */
2
+ /**
3
+ * Validate an operator-owned job spec (`schema: "job-v1"`). Never throws on
4
+ * JSON text or plain parsed data — the ingest contract; a live object with a
5
+ * hostile accessor is outside it. Every failure is a named red. Returns the
6
+ * parsed spec on ok (single parse), null on any red.
7
+ * @param {object|string} input parsed spec, or raw JSON text (parse failures are a red)
8
+ * @param {{ shellCapUsd?: number }} [opts] the shell's hard ceiling — the job
9
+ * budget may tighten it, never exceed it (ceiling chain: workflow ≤ job ≤ shell)
10
+ * @returns {{ ok: boolean, reds: Red[], job: object|null }}
11
+ */
12
+ export function validateJob(input: object | string, { shellCapUsd }?: {
13
+ shellCapUsd?: number;
14
+ }): {
15
+ ok: boolean;
16
+ reds: Red[];
17
+ job: object | null;
18
+ };
19
+ /**
20
+ * Content hash of a job spec — what an approval record binds to. Any edit to
21
+ * the spec changes the hash, so an edited spec is unapproved by construction
22
+ * (interview decision #1: human signs, always, once per spec VERSION).
23
+ * @param {object} job
24
+ * @returns {string} sha256 hex
25
+ */
26
+ export function jobSpecHash(job: object): string;
27
+ /**
28
+ * Does an approval record cover this exact spec version? Pure predicate — the
29
+ * N2 runner refuses to run without it; nothing here writes or prompts. The
30
+ * approval record lives OUTSIDE the document it signs (a spec never contains
31
+ * its own signature) and is shell/human territory, never agent-writable.
32
+ * @param {object} job
33
+ * @param {unknown} approvals array of { specHash, signer, ts } records
34
+ * @returns {boolean}
35
+ */
36
+ export function checkApproval(job: object, approvals: unknown): boolean;
37
+ export const CLOSE_TYPES: readonly string[];
38
+ export const CLASSES: readonly string[];
39
+ /** the hierarchy: which verdict classes a close type may claim (PRD §7) */
40
+ export const CLASS_BY_CLOSE: Readonly<{
41
+ predicate: readonly string[];
42
+ gold: readonly string[];
43
+ rubric: readonly string[];
44
+ hitl: readonly string[];
45
+ }>;
46
+ export const GOLD_COMPARE: readonly string[];
47
+ export const CADENCE_UNITS: readonly string[];
48
+ export const PROVIDERS: readonly string[];
49
+ /** V3 environment label: declared keys only — every field is a lineage-key
50
+ * candidate at N3. `provider` is part of the key by definition (top-level,
51
+ * not duplicated here). */
52
+ export const CONDITION_KEYS: readonly string[];
53
+ export type Red = {
54
+ code: string;
55
+ path: string;
56
+ detail?: string;
57
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Run a close command; its exit code is the verdict (hard green, PRD §4).
3
+ * 0 → satisfied; nonzero → needs_revision (gap fed back); spawn error → failed (terminal).
4
+ *
5
+ * `redact` scrubs the close's own output at the SOURCE — before the gap becomes
6
+ * a spine event or a worker prompt — so a secret a checked command echoes
7
+ * (a 401 dumping an `Authorization: Bearer …` header) never enters the
8
+ * append-only spine (the hard line). It is INJECTED, not imported: the shell
9
+ * stays stdlib-only and un-gameable, and the redactor is a fixed shell
10
+ * primitive (bareguard's, wired by the interpreter), never an emergent
11
+ * component — so V4 holds. The shell's canonical emission IS the redacted text;
12
+ * a benign gap is returned byte-identical (default: identity).
13
+ * @param {string[]} close argv, e.g. ['node', '--test', 'close/']
14
+ * @param {(s: string) => string} [redact] source scrubber; identity by default
15
+ */
16
+ export function runClose(close: string[], redact?: (s: string) => string): {
17
+ verdict: string;
18
+ detail: string;
19
+ gap?: undefined;
20
+ exitCode?: undefined;
21
+ } | {
22
+ verdict: string;
23
+ detail?: undefined;
24
+ gap?: undefined;
25
+ exitCode?: undefined;
26
+ } | {
27
+ verdict: string;
28
+ gap: string;
29
+ exitCode: number | null;
30
+ detail?: undefined;
31
+ };
32
+ /**
33
+ * The loop: `while close-red and under-cap: run the middle`. Stops at first
34
+ * green (within-run tuning past a visible close is the fit-to-pass surface,
35
+ * PRD §2). The two honest terminals are green and a decision-ready escalation;
36
+ * cap-halt means "not under cap", its own category, never merged with "wrong"
37
+ * (design law #8). A broken close escalates immediately — a broken arbiter
38
+ * must not masquerade as a bad harness.
39
+ *
40
+ * A middle that throws is read through the failure map: a throw carrying
41
+ * `category: 'cap-halt'` (the USD gate tripping inside the middle) escalates
42
+ * as cap-halt; any other named category is relayed as-is; an unnamed throw is
43
+ * an interpreter-red — a broken interpreter must not masquerade as a bad
44
+ * harness. Ralph never interprets, only relays.
45
+ *
46
+ * @param {object} opts
47
+ * @param {(iteration: number, gap?: string) => void|Promise<void>} opts.middle the emergent middle; never sees close/cap
48
+ * @param {string[]} opts.close argv whose exit code is truth
49
+ * @param {number} opts.capRuns budget: max middle runs
50
+ * @param {(type: string, data?: object) => object} opts.emit a spine emitter
51
+ * @param {(s: string) => string} [opts.redact] source scrubber for close output
52
+ * (secrets never enter the spine); injected so the shell stays stdlib-only
53
+ * @returns {Promise<'green'|'escalated'>}
54
+ */
55
+ export function ralph({ middle, close, capRuns, emit, redact }: {
56
+ middle: (iteration: number, gap?: string) => void | Promise<void>;
57
+ close: string[];
58
+ capRuns: number;
59
+ emit: (type: string, data?: object) => object;
60
+ redact?: ((s: string) => string) | undefined;
61
+ }): Promise<"green" | "escalated">;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Create an emitter bound to one JSONL file.
3
+ * @param {string} file absolute path; created on first emit
4
+ * @returns {(type: string, data?: object) => object} emit — returns the event as written
5
+ */
6
+ export function makeSpine(file: string): (type: string, data?: object) => object;
@@ -0,0 +1 @@
1
+ export function stripFences(t: string): string;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Map a schema writeScope entry to its enforcement prefix. bareguard
3
+ * fs.writeScope is prefix-containment, not glob (adaptlearn F4/F9): the
4
+ * trailing "/**" | "/*" form maps to its directory prefix. The validator's
5
+ * legality rule and the interpreter's enforcement mapping BOTH go through this
6
+ * one helper — if they ever used different transforms, a scope could validate
7
+ * green and then gate-red every write at runtime (the F9 red-class).
8
+ * @param {string} scope
9
+ */
10
+ export function globToPrefix(scope: string): string;
11
+ /**
12
+ * A scope may never reach the arbiter's inputs (design law #1): it must
13
+ * resolve to a PROPER subdirectory of the run directory. Absolute paths and
14
+ * ".." segments escape it; "." / "./**" cover the whole run directory — where
15
+ * the close suite lives. Windows spellings ("..\", "C:\") count as escapes.
16
+ * Exported for the job validator: the operator's outer fence obeys the SAME
17
+ * law through the same code — two containment transforms would be the F9
18
+ * red-class one level up.
19
+ * @param {string} s
20
+ */
21
+ export function scopeContained(s: string): boolean;
22
+ /** Shared with the job validator — one definition of "a JSON object" for both
23
+ * documents (the sibling-validator drift class).
24
+ * @param {unknown} v */
25
+ export function isObj(v: unknown): boolean;
26
+ /** @param {unknown} v @returns {v is string} */
27
+ export function isNonEmptyString(v: unknown): v is string;
28
+ /**
29
+ * Red every string in a config tree that carries a known secret-token shape.
30
+ * @param {any} root
31
+ * @param {(code: string, path: string, detail?: string) => void} red
32
+ */
33
+ export function sweepSecretLiterals(root: any, red: (code: string, path: string, detail?: string) => void): void;
34
+ /**
35
+ * Validate a workflow config against schema v1. Returns the parsed config on
36
+ * ok (single parse — callers never re-parse; null on any red).
37
+ * @param {object|string} input parsed config, or raw JSON text (parse failures are a red)
38
+ * @param {{ shellCapUsd?: number, jobWriteScope?: string[] }} [opts] the shell's
39
+ * ceilings — a config may tighten either, never exceed. `shellCapUsd`: at N2+
40
+ * the shell passes min(shell cap, job budgetUsd) — the ceiling chain
41
+ * workflow ≤ job ≤ shell. `jobWriteScope`: the job spec's outer fence
42
+ * (operator law, job-v1) — every workflow scope must fit inside it.
43
+ * @returns {{ ok: boolean, reds: Array<{code: string, path: string, detail?: string}>, config: object|null }}
44
+ */
45
+ export function validateConfig(input: object | string, { shellCapUsd, jobWriteScope }?: {
46
+ shellCapUsd?: number;
47
+ jobWriteScope?: string[];
48
+ }): {
49
+ ok: boolean;
50
+ reds: Array<{
51
+ code: string;
52
+ path: string;
53
+ detail?: string;
54
+ }>;
55
+ config: object | null;
56
+ };
57
+ /**
58
+ * Changed JSON paths between two configs — the one-knob mutation checker
59
+ * (a legal mutant has exactly one). A subtree present on only one side counts
60
+ * as ONE path (its root), so "add an op to a slot" is one knob, not two params.
61
+ * @param {object} a
62
+ * @param {object} b
63
+ * @returns {string[]} sorted changed paths, dot-notation with array indices
64
+ */
65
+ export function diffPaths(a: object, b: object): string[];
66
+ export const LOOP_SHAPES: readonly string[];
67
+ export const SLOTS: readonly string[];
68
+ export const VERBS: readonly string[];
69
+ export const SECRET_PATTERNS: RegExp[];