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.
- package/CHANGELOG.md +204 -0
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +123 -11
- package/bareloop.context.md +182 -0
- package/package.json +41 -3
- package/src/extract.js +95 -0
- package/src/index.js +13 -0
- package/src/interpret.js +261 -0
- package/src/job.js +252 -0
- package/src/ralph.js +122 -0
- package/src/spine.js +29 -0
- package/src/text.js +9 -0
- package/src/validate.js +331 -0
- package/types/extract.d.ts +23 -0
- package/types/index.d.ts +6 -0
- package/types/interpret.d.ts +62 -0
- package/types/job.d.ts +57 -0
- package/types/ralph.d.ts +61 -0
- package/types/spine.d.ts +6 -0
- package/types/text.d.ts +1 -0
- package/types/validate.d.ts +69 -0
package/src/validate.js
ADDED
|
@@ -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;
|
package/types/index.d.ts
ADDED
|
@@ -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
|
+
};
|
package/types/ralph.d.ts
ADDED
|
@@ -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">;
|
package/types/spine.d.ts
ADDED
|
@@ -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;
|
package/types/text.d.ts
ADDED
|
@@ -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[];
|