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/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
- return scope.replace(/\/\*\*?$/, '');
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
- if (prefix === '' || prefix === '.') return false;
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
- function isObj(v) {
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 cap — a config may tighten it, never exceed it
80
- * @returns {{ ok: boolean, reds: Array<{code: string, path: string, detail?: string}> }}
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((s) => typeof s === 'string' && s.length > 0))) {
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
- return { ok: reds.length === 0, reds };
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
  /**
@@ -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 { ralph, runClose } from "./ralph.js";
3
- export { validateConfig, diffPaths, globToPrefix, LOOP_SHAPES, SLOTS, VERBS } from "./validate.js";
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";
@@ -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 is clamped by validation
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: string;
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
+ };