bareloop 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +425 -2
- package/NOTICE +13 -0
- package/README.md +8 -6
- package/bareloop.context.md +334 -19
- package/package.json +4 -3
- package/src/extract.js +14 -6
- package/src/index.js +16 -2
- package/src/interpret.js +538 -46
- package/src/job.js +368 -0
- package/src/ledger.js +266 -0
- package/src/ralph.js +251 -14
- package/src/run.js +478 -0
- package/src/text.js +65 -8
- package/src/validate.js +175 -16
- package/types/extract.d.ts +4 -2
- package/types/index.d.ts +5 -2
- package/types/interpret.d.ts +44 -6
- package/types/job.d.ts +88 -0
- package/types/ledger.d.ts +76 -0
- package/types/ralph.d.ts +123 -17
- package/types/run.d.ts +58 -0
- package/types/text.d.ts +35 -1
- package/types/validate.d.ts +51 -7
package/src/validate.js
CHANGED
|
@@ -11,10 +11,24 @@
|
|
|
11
11
|
|
|
12
12
|
import { COMPRESS_LEVELS, KINDS, WRITE_KINDS } from 'litectx';
|
|
13
13
|
|
|
14
|
-
export const LOOP_SHAPES = ['refine', 'plan'];
|
|
15
|
-
export const SLOTS = ['before-attempt', 'after-red', 'on-green'];
|
|
16
|
-
export const VERBS = ['recall', 'compress', 'stash', 'remember'];
|
|
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
17
|
const TOP_FIELDS = ['schema', 'loop', 'memory', 'hooks', 'gate', 'escalation'];
|
|
18
|
+
// Unknown-field guard, PER SECTION — not just at the top (F17). The arbiter
|
|
19
|
+
// split is guarded "both directions by inexpressibility": the drafted config
|
|
20
|
+
// must be UNABLE to say certain things. A guard that stops at the top level
|
|
21
|
+
// does not deliver that — `gate: { judged: {...} }` (the close's own honesty
|
|
22
|
+
// check) validated green, as would `gate.maxCostUsd`. Nothing consumed those
|
|
23
|
+
// keys, so nothing broke; the guarantee is that nothing ever can. Hook ops are
|
|
24
|
+
// guarded separately by VERB_PARAMS.
|
|
25
|
+
const SECTION_FIELDS = Object.freeze({
|
|
26
|
+
loop: ['shape', 'maxIterations'],
|
|
27
|
+
memory: ['store', 'recall', 'compressLevel'],
|
|
28
|
+
'memory.recall': ['k', 'kinds'],
|
|
29
|
+
gate: ['budgetUsd', 'writeScope'],
|
|
30
|
+
escalation: ['mode'],
|
|
31
|
+
});
|
|
18
32
|
const MAX_OPS_PER_SLOT = 2;
|
|
19
33
|
|
|
20
34
|
// per-verb parameter contracts: name → check(value) (op field itself excluded).
|
|
@@ -22,7 +36,7 @@ const MAX_OPS_PER_SLOT = 2;
|
|
|
22
36
|
// (the set remember() itself validates against — adaptlearn F5's drift lesson:
|
|
23
37
|
// the export you bind can be wider than the function you call), minus doc:
|
|
24
38
|
// the doc/upload axis stays gated out deliberately.
|
|
25
|
-
const REMEMBER_KINDS = WRITE_KINDS.filter((k) => k !== 'doc');
|
|
39
|
+
export const REMEMBER_KINDS = WRITE_KINDS.filter((k) => k !== 'doc'); // exported: the drafting prompt advertises THIS menu (one source, no drift)
|
|
26
40
|
const VERB_PARAMS = {
|
|
27
41
|
recall: { k: (v) => Number.isInteger(v) && v >= 1 && v <= 20, kinds: isKinds },
|
|
28
42
|
compress: { level: (v) => COMPRESS_LEVELS.includes(v) },
|
|
@@ -40,7 +54,23 @@ const VERB_PARAMS = {
|
|
|
40
54
|
* @param {string} scope
|
|
41
55
|
*/
|
|
42
56
|
export function globToPrefix(scope) {
|
|
43
|
-
|
|
57
|
+
// One canonical spelling for equivalent paths — leading "./", interior "/./",
|
|
58
|
+
// doubled "//", trailing "/" all collapse — so the legality rule, the fence
|
|
59
|
+
// comparison, and bareguard enforcement agree on what a scope NAMES. Without
|
|
60
|
+
// this, a validateJob-green fence like "src/" would reject every workflow
|
|
61
|
+
// scope inside it (the F9 red-class across two documents).
|
|
62
|
+
//
|
|
63
|
+
// Order is load-bearing: "//" collapses and interior "/./" drops BEFORE the
|
|
64
|
+
// leading "./" strip, so ".//src" → "src" and never "/src". Stripping first
|
|
65
|
+
// would MINT an absolute prefix from a relative scope — a design-law-#1
|
|
66
|
+
// containment escape (resolve(workdir, "/src") ignores workdir entirely).
|
|
67
|
+
//
|
|
68
|
+
// Deliberately NOT path.posix.normalize: it resolves ".." segments
|
|
69
|
+
// (normalize("src/../etc") === "etc"), which must stay VISIBLE so
|
|
70
|
+
// scopeContained can reject them — the whole point of the containment law.
|
|
71
|
+
let p = scope.replace(/\/\*\*?$/, '');
|
|
72
|
+
p = p.replace(/\/{2,}/g, '/').replace(/\/\.(?=\/|$)/g, '').replace(/^(?:\.\/)+/, '').replace(/\/+$/, '');
|
|
73
|
+
return p;
|
|
44
74
|
}
|
|
45
75
|
|
|
46
76
|
/**
|
|
@@ -48,15 +78,35 @@ export function globToPrefix(scope) {
|
|
|
48
78
|
* resolve to a PROPER subdirectory of the run directory. Absolute paths and
|
|
49
79
|
* ".." segments escape it; "." / "./**" cover the whole run directory — where
|
|
50
80
|
* the close suite lives. Windows spellings ("..\", "C:\") count as escapes.
|
|
81
|
+
* Exported for the job validator: the operator's outer fence obeys the SAME
|
|
82
|
+
* law through the same code — two containment transforms would be the F9
|
|
83
|
+
* red-class one level up.
|
|
51
84
|
* @param {string} s
|
|
52
85
|
*/
|
|
53
|
-
function scopeContained(s) {
|
|
86
|
+
export function scopeContained(s) {
|
|
54
87
|
if (s.startsWith('/') || s.includes('\\') || /^[a-zA-Z]:/.test(s)) return false;
|
|
55
88
|
const prefix = globToPrefix(s);
|
|
56
|
-
|
|
89
|
+
// Belt: reject a prefix that is empty, the run dir, absolute, or a drive —
|
|
90
|
+
// checked on the NORMALIZED prefix, not just the raw string, so a spelling
|
|
91
|
+
// that normalizes to something absolute can never pass (defense in depth
|
|
92
|
+
// against a future globToPrefix regression; the un-gameable gate, law #1).
|
|
93
|
+
if (prefix === '' || prefix === '.' || prefix.startsWith('/') || /^[a-zA-Z]:/.test(prefix)) return false;
|
|
57
94
|
return prefix.split('/').every((seg) => seg !== '..');
|
|
58
95
|
}
|
|
59
96
|
|
|
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
|
+
|
|
60
110
|
// Each verb is legal ONLY in the slot where it has effect: recall/compress
|
|
61
111
|
// build the attempt context (consumed by before-attempt only), stash parks the
|
|
62
112
|
// gap (which exists only after-red), remember is verdict-gated retention
|
|
@@ -69,17 +119,90 @@ const VERB_SLOT = { recall: 'before-attempt', compress: 'before-attempt', stash:
|
|
|
69
119
|
function isKinds(v) {
|
|
70
120
|
return Array.isArray(v) && v.length > 0 && v.every((k) => KINDS.includes(k));
|
|
71
121
|
}
|
|
72
|
-
|
|
122
|
+
/** Shared with the job validator — one definition of "a JSON object" for both
|
|
123
|
+
* documents (the sibling-validator drift class).
|
|
124
|
+
* @param {unknown} v */
|
|
125
|
+
export function isObj(v) {
|
|
73
126
|
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
74
127
|
}
|
|
128
|
+
/** @param {unknown} v @returns {v is string} */
|
|
129
|
+
export function isNonEmptyString(v) {
|
|
130
|
+
return typeof v === 'string' && v.length > 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Secrets never enter the tree/spine/configs (hard line) — BOTH config
|
|
134
|
+
// documents get the same sweep: the agent-authored workflow config is the
|
|
135
|
+
// riskier entry point (machine-written), the operator's job spec the other.
|
|
136
|
+
// Known token shapes only, left-bounded so hyphenated words ("flask-sqlalchemy")
|
|
137
|
+
// never red — defense-in-depth; env-only loading is the law, not this regex.
|
|
138
|
+
// ONE shape inventory for the whole system: detection (this sweep) and the
|
|
139
|
+
// spine redactor (interpret wires these into bareguard's redact) must never
|
|
140
|
+
// disagree on what a secret looks like — a shape the validator reds but the
|
|
141
|
+
// redactor passes is a leak on the very output the validator was guarding.
|
|
142
|
+
export const SECRET_PATTERNS = [
|
|
143
|
+
/(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{16,}/,
|
|
144
|
+
/(?<![A-Za-z0-9_-])ghp_[A-Za-z0-9]{20,}/,
|
|
145
|
+
/(?<![A-Za-z0-9_-])github_pat_[A-Za-z0-9_]{20,}/,
|
|
146
|
+
/(?<![A-Za-z0-9_-])AKIA[0-9A-Z]{16}/,
|
|
147
|
+
/(?<![A-Za-z0-9_-])xox[bap]-[A-Za-z0-9-]{10,}/,
|
|
148
|
+
];
|
|
149
|
+
const SECRET_RE = new RegExp(SECRET_PATTERNS.map((r) => r.source).join('|'));
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Scan a RAW text stream (a spine file, a close's output, a transcript) for
|
|
153
|
+
* known secret shapes and return the literal matches. The ONE spelling of the
|
|
154
|
+
* text-side scan, for the same reason SECRET_PATTERNS is the ONE inventory: a
|
|
155
|
+
* hand-rolled copy that misses a shape is a leak on the very output it guards
|
|
156
|
+
* (seven copies of this expression lived in scripts/ before it landed here).
|
|
157
|
+
* `sweepSecretLiterals` is the config-tree twin — same shapes, tree walk.
|
|
158
|
+
* Never throws; returns [] for a missing stream.
|
|
159
|
+
* @param {unknown} raw
|
|
160
|
+
* @returns {string[]} every match, in stream order per pattern
|
|
161
|
+
*/
|
|
162
|
+
export function scanSecrets(raw) {
|
|
163
|
+
const text = String(raw ?? '');
|
|
164
|
+
// a fresh global clone per call: a shared /g regex carries lastIndex between
|
|
165
|
+
// calls and would skip matches on the next stream
|
|
166
|
+
return SECRET_PATTERNS.flatMap((re) => text.match(new RegExp(re.source, `${re.flags.replace('g', '')}g`)) ?? []);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Red every string in a config tree that carries a known secret-token shape.
|
|
171
|
+
* @param {any} root
|
|
172
|
+
* @param {(code: string, path: string, detail?: string) => void} red
|
|
173
|
+
*/
|
|
174
|
+
export function sweepSecretLiterals(root, red) {
|
|
175
|
+
(/** @type {(node: any, at: string) => void} */
|
|
176
|
+
function sweep(node, at) {
|
|
177
|
+
if (typeof node === 'string') {
|
|
178
|
+
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');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (Array.isArray(node)) node.forEach((v, i) => sweep(v, `${at}.${i}`));
|
|
182
|
+
else if (isObj(node)) {
|
|
183
|
+
for (const [k, v] of Object.entries(node)) {
|
|
184
|
+
const path = at ? `${at}.${k}` : k;
|
|
185
|
+
// keys too: inside free-form values (a gold expected, a conditions map)
|
|
186
|
+
// a key is unconstrained, so a token can ride a KEY onto the spine
|
|
187
|
+
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');
|
|
188
|
+
sweep(v, path);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
})(root, '');
|
|
192
|
+
}
|
|
75
193
|
|
|
76
194
|
/**
|
|
77
|
-
* Validate a workflow config against schema v1.
|
|
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).
|
|
78
197
|
* @param {object|string} input parsed config, or raw JSON text (parse failures are a red)
|
|
79
|
-
* @param {{ shellCapUsd?: number }} [opts] the shell's
|
|
80
|
-
*
|
|
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 }}
|
|
81
204
|
*/
|
|
82
|
-
export function validateConfig(input, { shellCapUsd = 2 } = {}) {
|
|
205
|
+
export function validateConfig(input, { shellCapUsd = 2, jobWriteScope } = {}) {
|
|
83
206
|
/** @type {Array<{code: string, path: string, detail?: string}>} */
|
|
84
207
|
const reds = [];
|
|
85
208
|
/** @type {(code: string, path: string, detail?: string) => void} */
|
|
@@ -88,10 +211,10 @@ export function validateConfig(input, { shellCapUsd = 2 } = {}) {
|
|
|
88
211
|
let c = input;
|
|
89
212
|
if (typeof c === 'string') {
|
|
90
213
|
try { c = JSON.parse(c); } catch (e) {
|
|
91
|
-
return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: String(/** @type {Error} */ (e).message) }] };
|
|
214
|
+
return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: String(/** @type {Error} */ (e).message) }], config: null };
|
|
92
215
|
}
|
|
93
216
|
}
|
|
94
|
-
if (!isObj(c)) return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: 'config must be a JSON object' }] };
|
|
217
|
+
if (!isObj(c)) return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: 'config must be a JSON object' }], config: null };
|
|
95
218
|
|
|
96
219
|
// 1. shape — unknown top-level fields red here (smuggled close/provider included)
|
|
97
220
|
for (const key of Object.keys(c)) {
|
|
@@ -128,7 +251,7 @@ export function validateConfig(input, { shellCapUsd = 2 } = {}) {
|
|
|
128
251
|
}
|
|
129
252
|
if (gate.writeScope === undefined) red('missing-required', 'gate.writeScope');
|
|
130
253
|
else if (!(Array.isArray(gate.writeScope) && gate.writeScope.length > 0
|
|
131
|
-
&& gate.writeScope.every(
|
|
254
|
+
&& gate.writeScope.every(isNonEmptyString))) {
|
|
132
255
|
red('invalid-value', 'gate.writeScope', 'non-empty array of glob strings');
|
|
133
256
|
} else if (!gate.writeScope.every((s) => !globToPrefix(s).includes('*'))) {
|
|
134
257
|
// adaptlearn F9: enforcement (bareguard fs.writeScope) is prefix-containment — a wildcard
|
|
@@ -139,6 +262,29 @@ export function validateConfig(input, { shellCapUsd = 2 } = {}) {
|
|
|
139
262
|
// A scope that can reach the arbiter's inputs is the config-level
|
|
140
263
|
// fit-to-pass surface (design law #1). Reds-before-tokens.
|
|
141
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
|
+
}
|
|
142
288
|
}
|
|
143
289
|
|
|
144
290
|
const escalation = isObj(c.escalation) ? c.escalation : {};
|
|
@@ -173,7 +319,20 @@ export function validateConfig(input, { shellCapUsd = 2 } = {}) {
|
|
|
173
319
|
}
|
|
174
320
|
}
|
|
175
321
|
|
|
176
|
-
|
|
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 };
|
|
177
336
|
}
|
|
178
337
|
|
|
179
338
|
/**
|
package/types/extract.d.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* @param {object} opts.provider a bareagent provider — SHELL-owned, sealed
|
|
6
6
|
* @param {string[]|null} opts.priorRules the lineage's current rules, if any
|
|
7
7
|
* @param {string[]} [opts.revisionDiff] changed config paths, when the run recovered mid-run
|
|
8
|
-
* @returns {Promise<{rules: string[]|null, valid: boolean, reds: Array<object>, costUsd: number, raw: string}>}
|
|
8
|
+
* @returns {Promise<{rules: string[]|null, valid: boolean, reds: Array<object>, costUsd: number|null, raw: string}>}
|
|
9
|
+
* costUsd is null when the spend could not be priced — the explicit-unknown
|
|
10
|
+
* signal (F6: unpriced is never free); callers must not coerce it to 0
|
|
9
11
|
*/
|
|
10
12
|
export function extractRules({ config, provider, priorRules, revisionDiff }: {
|
|
11
13
|
config: object;
|
|
@@ -16,7 +18,7 @@ export function extractRules({ config, provider, priorRules, revisionDiff }: {
|
|
|
16
18
|
rules: string[] | null;
|
|
17
19
|
valid: boolean;
|
|
18
20
|
reds: Array<object>;
|
|
19
|
-
costUsd: number;
|
|
21
|
+
costUsd: number | null;
|
|
20
22
|
raw: string;
|
|
21
23
|
}>;
|
|
22
24
|
export const MAX_RULES: 5;
|
package/types/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export { makeSpine } from "./spine.js";
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
2
|
+
export { runJob } from "./run.js";
|
|
3
|
+
export { ralph, runClose, CLOSE_FAULTS } from "./ralph.js";
|
|
4
|
+
export { validateConfig, diffPaths, globToPrefix, scanSecrets, LOOP_SHAPES, SLOTS, VERBS } from "./validate.js";
|
|
5
|
+
export { validateJob, jobSpecHash, checkApproval, CLOSE_TYPES, CLASSES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS, STEP_MODES, TOOL_MENU, LOCKED_TOOLS } from "./job.js";
|
|
4
6
|
export { interpret, STALL_REDS } from "./interpret.js";
|
|
5
7
|
export { extractRules, MAX_RULES, MAX_RULE_CHARS } from "./extract.js";
|
|
8
|
+
export { classifyIncidents, foldLedger, ledgerDeltas, updateLedger, LEDGER_CLASSES } from "./ledger.js";
|
package/types/interpret.d.ts
CHANGED
|
@@ -4,13 +4,30 @@
|
|
|
4
4
|
* @param {object|string} configRaw schema v1 config (object or raw JSON text)
|
|
5
5
|
* @param {object} opts
|
|
6
6
|
* @param {string} opts.task implement instruction shown to the worker
|
|
7
|
-
* @param {string} opts.target absolute path the artifact is written to
|
|
7
|
+
* @param {string} [opts.target] absolute path the artifact is written to —
|
|
8
|
+
* required in text mode; unused in tool mode (the worker writes through
|
|
9
|
+
* the gated tools, wherever the fence allows)
|
|
8
10
|
* @param {string[]} opts.close argv whose exit code is truth (shell-owned)
|
|
11
|
+
* @param {number} [opts.closeExpect] the exit code the SIGNED spec calls success
|
|
12
|
+
* @param {{pattern: string, min: number}} [opts.closeJudged] the signed spec's
|
|
13
|
+
* judgment-rendered signal — proof the close actually judged something before
|
|
14
|
+
* its exit code is believed in EITHER direction (PRD v1.11; the drafted config
|
|
15
|
+
* cannot express it, and must not: it is the arbiter's own honesty check)
|
|
16
|
+
* @param {string} [opts.closeGapKeep] the signed spec's kept-failures pattern (F28):
|
|
17
|
+
* close-output lines matching it survive the gap bound, so a large suite's
|
|
18
|
+
* `not ok` names reach the worker instead of being elided into "N fail" — also
|
|
19
|
+
* arbiter territory, inexpressible in the drafted config
|
|
9
20
|
* @param {string} opts.workdir run directory (litectx root, gate audit, scope base)
|
|
10
21
|
* @param {number} opts.capRuns shell iteration budget; the config may tighten via loop.maxIterations, never exceed
|
|
11
22
|
* @param {(type: string, data?: object) => object} opts.emit spine emitter
|
|
12
23
|
* @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
|
|
24
|
+
* @param {number} [opts.shellCapUsd=2] the shell's USD cap; a config budgetUsd above it REDS at validation (bounds — no silent clamping)
|
|
25
|
+
* @param {number} [opts.closeTimeoutMs] close wall-clock cap, threaded to the shell
|
|
26
|
+
* (shell/operator territory — the workflow config cannot express it)
|
|
27
|
+
* @param {string[]} [opts.jobWriteScope] the job spec's outer write fence (operator law,
|
|
28
|
+
* job-v1) — enforced HERE, the one choke point where a config becomes a Gate:
|
|
29
|
+
* every workflow scope must fit inside it (scope-escape config-red otherwise),
|
|
30
|
+
* on entry validation and on every revision candidate alike
|
|
14
31
|
* @param {(o: {config: object, gaps: string[], policy: any, onLlmResult: any}) => Promise<{candidate: object|null, parseError?: string|null, costUsd?: number}>} [opts.revisor]
|
|
15
32
|
* optional mid-run revision seam. Fires ONCE per run after STALL_REDS consecutive
|
|
16
33
|
* close reds. The interpreter — never the revisor — owns acceptance: the candidate
|
|
@@ -18,17 +35,34 @@
|
|
|
18
35
|
* (arbiter-touch / cap-touch revision-reds otherwise; the run continues on the old
|
|
19
36
|
* config). Revisor spend rides the run's own gate handlers — same budget axis as
|
|
20
37
|
* the worker.
|
|
38
|
+
* @param {string} [opts.closeState] the close's CURRENT output on the tree as it stands
|
|
39
|
+
* (the shell's pre-token close check, F13) — shown to the first attempt only, and
|
|
40
|
+
* never framed as an attempt: the worker cannot run the close itself (`run` is a
|
|
41
|
+
* locked verb), so without this it is asked to fix a failure it cannot see
|
|
42
|
+
* @param {'text'|'tools'} [opts.mode] middle mode (2b): 'text' (default) writes the ONE
|
|
43
|
+
* target from the response artifact; 'tools' gives the worker Gate-governed file
|
|
44
|
+
* tools — SPEC-side territory (the step declares it; the config cannot express it)
|
|
45
|
+
* @param {string[]} [opts.tools] the spec's tool grant (subset of read|grep|write,
|
|
46
|
+
* job-v1 validated); defaults to the full menu in tool mode
|
|
21
47
|
* @returns {Promise<'green'|'escalated'|'config-red'>}
|
|
22
48
|
*/
|
|
23
|
-
export function interpret(configRaw: object | string, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd, revisor }: {
|
|
49
|
+
export function interpret(configRaw: object | string, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd, jobWriteScope, revisor, closeTimeoutMs, mode, tools, closeState, closeExpect, closeJudged, closeGapKeep }: {
|
|
24
50
|
task: string;
|
|
25
|
-
target
|
|
51
|
+
target?: string | undefined;
|
|
26
52
|
close: string[];
|
|
53
|
+
closeExpect?: number | undefined;
|
|
54
|
+
closeJudged?: {
|
|
55
|
+
pattern: string;
|
|
56
|
+
min: number;
|
|
57
|
+
} | undefined;
|
|
58
|
+
closeGapKeep?: string | undefined;
|
|
27
59
|
workdir: string;
|
|
28
60
|
capRuns: number;
|
|
29
61
|
emit: (type: string, data?: object) => object;
|
|
30
62
|
provider: object;
|
|
31
63
|
shellCapUsd?: number | undefined;
|
|
64
|
+
closeTimeoutMs?: number | undefined;
|
|
65
|
+
jobWriteScope?: string[] | undefined;
|
|
32
66
|
revisor?: ((o: {
|
|
33
67
|
config: object;
|
|
34
68
|
gaps: string[];
|
|
@@ -39,14 +73,18 @@ export function interpret(configRaw: object | string, { task, target, close, wor
|
|
|
39
73
|
parseError?: string | null;
|
|
40
74
|
costUsd?: number;
|
|
41
75
|
}>) | undefined;
|
|
76
|
+
closeState?: string | undefined;
|
|
77
|
+
mode?: "text" | "tools" | undefined;
|
|
78
|
+
tools?: string[] | undefined;
|
|
42
79
|
}): Promise<"green" | "escalated" | "config-red">;
|
|
43
|
-
/** @typedef {Error & {category?: string}} CategorizedError the failure map's carrier: ralph relays by `category` */
|
|
80
|
+
/** @typedef {Error & {category?: string, lib?: string}} CategorizedError the failure map's carrier: ralph relays by `category`, and by `lib` when the thrower knows which library owns the failure (the ledger prefers it over prose) */
|
|
44
81
|
export const STALL_REDS: 2;
|
|
45
82
|
/**
|
|
46
|
-
* the failure map's carrier: ralph relays by `category`
|
|
83
|
+
* the failure map's carrier: ralph relays by `category`, and by `lib` when the thrower knows which library owns the failure (the ledger prefers it over prose)
|
|
47
84
|
*/
|
|
48
85
|
export type CategorizedError = Error & {
|
|
49
86
|
category?: string;
|
|
87
|
+
lib?: string;
|
|
50
88
|
};
|
|
51
89
|
/**
|
|
52
90
|
* litectx recall hit — body present only with `{body: true}`
|
package/types/job.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** @typedef {{code: string, path: string, detail?: string, verb?: string}} Red — `verb` rides request-reds as structured data (the ledger keys on it, never on prose) */
|
|
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
|
+
/** step middle modes (2b): text = single-target artifact; tools = Gate-governed file tools */
|
|
48
|
+
export const STEP_MODES: readonly string[];
|
|
49
|
+
/** the tool grant menu (2b interview #1): read/grep/write, plus the two litectx
|
|
50
|
+
* RETRIEVAL verbs (F19) — `run` stays locked-but-listed; a spec requesting it
|
|
51
|
+
* reds, and that red IS the request-red evidence admission waits on (curation
|
|
52
|
+
* doctrine, PRD F2).
|
|
53
|
+
*
|
|
54
|
+
* `recall`/`get` are READ-ONLY BY CONSTRUCTION, not by promise: `recall` returns
|
|
55
|
+
* pointers (path/symbol/line-range, no bodies) and `get` trades ONE pointer for
|
|
56
|
+
* ONE chunk — it refuses any range that is not a chunk boundary, so it cannot be
|
|
57
|
+
* widened into a whole-file read, and it is content-hash gated (a drifted file
|
|
58
|
+
* throws rather than serving a stale body). Admitting them does NOT weaken the
|
|
59
|
+
* `run` lock: the worker still cannot execute anything, so it still cannot run
|
|
60
|
+
* its own close (the arbiter). F19: `shell_read` starts at byte ZERO and cannot
|
|
61
|
+
* seek, so a pointer was INERT — the worker paged 1.37 MB of source through
|
|
62
|
+
* context to reach 8 lines. The ranged read is the pager.
|
|
63
|
+
*
|
|
64
|
+
* `edit` (BA-13, bare-agent 0.29.0) is the anchored exact-once replace — a
|
|
65
|
+
* WRITE-class verb judged by the SAME writeScope fence as `write` (bareguard
|
|
66
|
+
* action type 'edit'). Admitted because whole-file rewrite is measurably
|
|
67
|
+
* 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. */
|
|
69
|
+
export const TOOL_MENU: readonly string[];
|
|
70
|
+
/** locked-but-listed tools: real capabilities deliberately outside the grant
|
|
71
|
+
* menu. Requesting one is a DISTINCT `request-red` (module 4) — the ledger
|
|
72
|
+
* counts admission demand, and a generic invalid-value would bury it as a typo. */
|
|
73
|
+
export const LOCKED_TOOLS: readonly string[];
|
|
74
|
+
export const CADENCE_UNITS: readonly string[];
|
|
75
|
+
export const PROVIDERS: readonly string[];
|
|
76
|
+
/** V3 environment label: declared keys only — every field is a lineage-key
|
|
77
|
+
* candidate at N3. `provider` is part of the key by definition (top-level,
|
|
78
|
+
* not duplicated here). */
|
|
79
|
+
export const CONDITION_KEYS: readonly string[];
|
|
80
|
+
/**
|
|
81
|
+
* — `verb` rides request-reds as structured data (the ledger keys on it, never on prose)
|
|
82
|
+
*/
|
|
83
|
+
export type Red = {
|
|
84
|
+
code: string;
|
|
85
|
+
path: string;
|
|
86
|
+
detail?: string;
|
|
87
|
+
verb?: string;
|
|
88
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify one spine's events into incident occurrences (the design table in
|
|
3
|
+
* bareloop vocabulary). Deliberate exclusions — bare cap-halt (a budget story),
|
|
4
|
+
* close-verdict reds and artifact-red (worker stories, the §5b line), gate-red
|
|
5
|
+
* (governance working as intended), pr-red (operator environment, not a suite
|
|
6
|
+
* lib), smoke-red escalations (already counted via primitive-smoke), hitl-close
|
|
7
|
+
* and close-unsupported (by design) — classify to nothing.
|
|
8
|
+
* @param {any[]} events one spine's parsed events, in seq order
|
|
9
|
+
* @param {{spine?: string}} [opts] spine id stamped into samples
|
|
10
|
+
* @returns {Occurrence[]}
|
|
11
|
+
*/
|
|
12
|
+
export function classifyIncidents(events: any[], { spine }?: {
|
|
13
|
+
spine?: string;
|
|
14
|
+
}): Occurrence[];
|
|
15
|
+
/**
|
|
16
|
+
* Fold ledger rows to current state — latest cumulative count per key, latest
|
|
17
|
+
* human-appended status riding on top (default 'open'). A status row for a key
|
|
18
|
+
* with no incident row still folds (occurrences 0): a human note must never
|
|
19
|
+
* silently vanish.
|
|
20
|
+
* @param {any[]} rows parsed ledger rows, in seq order
|
|
21
|
+
* @returns {Record<string, FoldEntry>}
|
|
22
|
+
*/
|
|
23
|
+
export function foldLedger(rows: any[]): Record<string, FoldEntry>;
|
|
24
|
+
/**
|
|
25
|
+
* Compute the append-only delta rows: group occurrences by key; a row is due
|
|
26
|
+
* only when the key is new or its corpus total grew past the folded count.
|
|
27
|
+
* detail is first-seen (the fold's wins); samples are the corpus's first few.
|
|
28
|
+
* @param {Record<string, FoldEntry>} fold current ledger state
|
|
29
|
+
* @param {Occurrence[]} occurrences the FULL corpus's occurrences
|
|
30
|
+
* @returns {object[]} lib-incident rows, unstamped (no seq/ts)
|
|
31
|
+
*/
|
|
32
|
+
export function ledgerDeltas(fold: Record<string, FoldEntry>, occurrences: Occurrence[]): object[];
|
|
33
|
+
/**
|
|
34
|
+
* The collector: read the ledger, classify the FULL spine corpus, append the
|
|
35
|
+
* deltas (spine conventions: type first, ts stamped last, seq continuing
|
|
36
|
+
* monotonically across appends). Idempotent over the same corpus; the CLI
|
|
37
|
+
* wrapping this lands at N5, the panel reads the same file at N6.
|
|
38
|
+
* @param {{ledgerFile: string, spineFiles: string[]}} o spineFiles: the corpus
|
|
39
|
+
* (pass ALL of it each time — counts are totals, not increments)
|
|
40
|
+
* @returns {{appended: object[], fold: Record<string, FoldEntry>}}
|
|
41
|
+
*/
|
|
42
|
+
export function updateLedger({ ledgerFile, spineFiles }: {
|
|
43
|
+
ledgerFile: string;
|
|
44
|
+
spineFiles: string[];
|
|
45
|
+
}): {
|
|
46
|
+
appended: object[];
|
|
47
|
+
fold: Record<string, FoldEntry>;
|
|
48
|
+
};
|
|
49
|
+
/** severity order, worst-first (the fold's display order; frequency ranks within) */
|
|
50
|
+
export const LEDGER_CLASSES: readonly string[];
|
|
51
|
+
export type Sample = {
|
|
52
|
+
spine: string;
|
|
53
|
+
seq: number;
|
|
54
|
+
};
|
|
55
|
+
export type Occurrence = {
|
|
56
|
+
key: string;
|
|
57
|
+
class: string;
|
|
58
|
+
lib: string;
|
|
59
|
+
verb: string;
|
|
60
|
+
sig: string;
|
|
61
|
+
detail: string;
|
|
62
|
+
sample: Sample;
|
|
63
|
+
};
|
|
64
|
+
export type FoldEntry = {
|
|
65
|
+
key: string;
|
|
66
|
+
lib: string;
|
|
67
|
+
verb: string;
|
|
68
|
+
class: string;
|
|
69
|
+
sig: string;
|
|
70
|
+
detail: string;
|
|
71
|
+
occurrences: number;
|
|
72
|
+
samples: Sample[];
|
|
73
|
+
suggestedAsk: string;
|
|
74
|
+
status: string;
|
|
75
|
+
ref?: string;
|
|
76
|
+
};
|