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/job.js
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// Job-spec validator — the OPERATOR-owned half of the config story (N1,
|
|
2
|
+
// design record: docs/plans/2026-07-12-n1-job-close-schema-design.md).
|
|
3
|
+
// A job spec is the arbiter's rulebook: the close chain, the budget, the
|
|
4
|
+
// outer write fence, the escalation route. It is pure declarative data —
|
|
5
|
+
// no freeform code is expressible anywhere in it — and it validates
|
|
6
|
+
// reds-before-tokens like its sibling validate.js (workflow, agent-authored).
|
|
7
|
+
// The arbiter split is guarded from both sides by INEXPRESSIBILITY: the
|
|
8
|
+
// workflow config cannot say close/provider (validate.js, shipped at N0);
|
|
9
|
+
// the job spec cannot say hooks/loop/memory, any minting claim, or the
|
|
10
|
+
// shell-owned retry cap — all unknown-field reds here.
|
|
11
|
+
//
|
|
12
|
+
// Close-authoring hierarchy (PRD §7) as a class menu keyed by close type:
|
|
13
|
+
// verdict-class laundering (a rubric claiming hard) is a named red, never a
|
|
14
|
+
// judgment call. Minting policy is product doctrine, not job-authorable.
|
|
15
|
+
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals } from './validate.js';
|
|
18
|
+
|
|
19
|
+
// The menus below ARE the close-authoring hierarchy (PRD §7) and ship frozen:
|
|
20
|
+
// they are read at call time, so a mutable export would let adopter code
|
|
21
|
+
// enable verdict-class laundering process-wide with one push().
|
|
22
|
+
export const CLOSE_TYPES = Object.freeze(['predicate', 'gold', 'rubric', 'hitl']);
|
|
23
|
+
export const CLASSES = Object.freeze(['hard', 'soft', 'hitl']);
|
|
24
|
+
/** the hierarchy: which verdict classes a close type may claim (PRD §7) */
|
|
25
|
+
export const CLASS_BY_CLOSE = Object.freeze({ predicate: Object.freeze(['hard']), gold: Object.freeze(['hard']), rubric: Object.freeze(['soft']), hitl: Object.freeze(['hitl']) });
|
|
26
|
+
export const GOLD_COMPARE = Object.freeze(['exact', 'json-equal']);
|
|
27
|
+
/** step middle modes (2b): text = single-target artifact; tools = Gate-governed file tools */
|
|
28
|
+
export const STEP_MODES = Object.freeze(['text', 'tools']);
|
|
29
|
+
/** the tool grant menu (2b interview #1): read/grep/write, plus the two litectx
|
|
30
|
+
* RETRIEVAL verbs (F19) — `run` stays locked-but-listed; a spec requesting it
|
|
31
|
+
* reds, and that red IS the request-red evidence admission waits on (curation
|
|
32
|
+
* doctrine, PRD F2).
|
|
33
|
+
*
|
|
34
|
+
* `recall`/`get` are READ-ONLY BY CONSTRUCTION, not by promise: `recall` returns
|
|
35
|
+
* pointers (path/symbol/line-range, no bodies) and `get` trades ONE pointer for
|
|
36
|
+
* ONE chunk — it refuses any range that is not a chunk boundary, so it cannot be
|
|
37
|
+
* widened into a whole-file read, and it is content-hash gated (a drifted file
|
|
38
|
+
* throws rather than serving a stale body). Admitting them does NOT weaken the
|
|
39
|
+
* `run` lock: the worker still cannot execute anything, so it still cannot run
|
|
40
|
+
* its own close (the arbiter). F19: `shell_read` starts at byte ZERO and cannot
|
|
41
|
+
* seek, so a pointer was INERT — the worker paged 1.37 MB of source through
|
|
42
|
+
* context to reach 8 lines. The ranged read is the pager.
|
|
43
|
+
*
|
|
44
|
+
* `edit` (BA-13, bare-agent 0.29.0) is the anchored exact-once replace — a
|
|
45
|
+
* WRITE-class verb judged by the SAME writeScope fence as `write` (bareguard
|
|
46
|
+
* action type 'edit'). Admitted because whole-file rewrite is measurably
|
|
47
|
+
* unreliable at size (F31: 4 of 5 big-file whole-writes broke the tree) and
|
|
48
|
+
* taxes output tokens ∝ file size. It admits no execution either. */
|
|
49
|
+
export const TOOL_MENU = Object.freeze(['read', 'grep', 'write', 'edit', 'recall', 'get']);
|
|
50
|
+
/** locked-but-listed tools: real capabilities deliberately outside the grant
|
|
51
|
+
* menu. Requesting one is a DISTINCT `request-red` (module 4) — the ledger
|
|
52
|
+
* counts admission demand, and a generic invalid-value would bury it as a typo. */
|
|
53
|
+
export const LOCKED_TOOLS = Object.freeze(['run']);
|
|
54
|
+
export const CADENCE_UNITS = Object.freeze(['hour', 'day', 'week']);
|
|
55
|
+
export const PROVIDERS = Object.freeze(['anthropic-api']); // SP-2: API-first; local deferred (PRD §5/§8)
|
|
56
|
+
/** V3 environment label: declared keys only — every field is a lineage-key
|
|
57
|
+
* candidate at N3. `provider` is part of the key by definition (top-level,
|
|
58
|
+
* not duplicated here). */
|
|
59
|
+
export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', 'taskFraming', 'scaffold']);
|
|
60
|
+
const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'conditions', 'cadence', 'budgetUsd', 'writeScope', 'steps', 'escalation'];
|
|
61
|
+
/** exact field set per close type — anything else is an unknown-field red
|
|
62
|
+
* (freeform code, script bodies, and minting claims all land there) */
|
|
63
|
+
const CLOSE_FIELDS = {
|
|
64
|
+
predicate: ['type', 'cmd', 'expect', 'judged', 'gapKeep'],
|
|
65
|
+
gold: ['type', 'expected', 'compare'],
|
|
66
|
+
rubric: ['type', 'criteria'],
|
|
67
|
+
hitl: ['type', 'prompt'],
|
|
68
|
+
};
|
|
69
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
70
|
+
|
|
71
|
+
/** @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) */
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Validate an operator-owned job spec (`schema: "job-v1"`). Never throws on
|
|
75
|
+
* JSON text or plain parsed data — the ingest contract; a live object with a
|
|
76
|
+
* hostile accessor is outside it. Every failure is a named red. Returns the
|
|
77
|
+
* parsed spec on ok (single parse), null on any red.
|
|
78
|
+
* @param {object|string} input parsed spec, or raw JSON text (parse failures are a red)
|
|
79
|
+
* @param {{ shellCapUsd?: number }} [opts] the shell's hard ceiling — the job
|
|
80
|
+
* budget may tighten it, never exceed it (ceiling chain: workflow ≤ job ≤ shell)
|
|
81
|
+
* @returns {{ ok: boolean, reds: Red[], job: object|null }}
|
|
82
|
+
*/
|
|
83
|
+
export function validateJob(input, { shellCapUsd = 2 } = {}) {
|
|
84
|
+
/** @type {Red[]} */
|
|
85
|
+
const reds = [];
|
|
86
|
+
/** @type {(code: string, path: string, detail?: string) => void} */
|
|
87
|
+
const red = (code, path, detail) => { reds.push(detail ? { code, path, detail } : { code, path }); };
|
|
88
|
+
|
|
89
|
+
let j = input;
|
|
90
|
+
if (typeof j === 'string') {
|
|
91
|
+
try { j = JSON.parse(j); } catch (e) {
|
|
92
|
+
return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: String(/** @type {Error} */ (e).message) }], job: null };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!isObj(j)) return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: 'job spec must be a JSON object' }], job: null };
|
|
96
|
+
const spec = /** @type {Record<string, any>} */ (j);
|
|
97
|
+
|
|
98
|
+
// 1. shape — unknown fields red here: hooks/loop/memory (agent domain),
|
|
99
|
+
// capRuns (shell domain), minting claims — inexpressibility is the guard.
|
|
100
|
+
for (const key of Object.keys(spec)) {
|
|
101
|
+
if (!JOB_FIELDS.includes(key)) red('unknown-field', key);
|
|
102
|
+
}
|
|
103
|
+
if (spec.schema === undefined) red('missing-required', 'schema');
|
|
104
|
+
else if (spec.schema !== 'job-v1') red('invalid-value', 'schema', `expected "job-v1", got ${JSON.stringify(spec.schema)}`);
|
|
105
|
+
if (!isNonEmptyString(spec.job) || !SLUG_RE.test(spec.job)) red('invalid-value', 'job', 'kebab-case slug');
|
|
106
|
+
if (!isNonEmptyString(spec.description)) red('missing-required', 'description');
|
|
107
|
+
|
|
108
|
+
if (spec.provider === undefined) red('missing-required', 'provider');
|
|
109
|
+
else if (!PROVIDERS.includes(spec.provider)) red('invalid-value', 'provider', `menu: ${PROVIDERS.join('|')}`);
|
|
110
|
+
|
|
111
|
+
// 2. the environment label (V3, design law #5): declared keys only
|
|
112
|
+
if (spec.conditions !== undefined) {
|
|
113
|
+
if (!isObj(spec.conditions)) red('invalid-value', 'conditions', 'flat object of declared condition keys');
|
|
114
|
+
else {
|
|
115
|
+
for (const [key, value] of Object.entries(spec.conditions)) {
|
|
116
|
+
if (!CONDITION_KEYS.includes(key)) red('unknown-field', `conditions.${key}`, `declared keys only: ${CONDITION_KEYS.join('|')} (every field is a lineage-key dimension, V3)`);
|
|
117
|
+
else if (!isNonEmptyString(value)) red('invalid-value', `conditions.${key}`, 'non-empty string');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (spec.cadence === undefined) red('missing-required', 'cadence');
|
|
123
|
+
else if (!isObj(spec.cadence)) red('invalid-value', 'cadence', 'object of {unit, every}');
|
|
124
|
+
else {
|
|
125
|
+
const cad = spec.cadence;
|
|
126
|
+
for (const key of Object.keys(cad)) {
|
|
127
|
+
if (key !== 'unit' && key !== 'every') red('unknown-field', `cadence.${key}`, 'nested objects red unknown keys too — no smuggling level exists in a signed spec');
|
|
128
|
+
}
|
|
129
|
+
if (!CADENCE_UNITS.includes(cad.unit)) red('invalid-value', 'cadence.unit', CADENCE_UNITS.join('|'));
|
|
130
|
+
if (!(Number.isInteger(cad.every) && cad.every >= 1 && cad.every <= 30)) red('bounds', 'cadence.every', '1..30');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (spec.budgetUsd === undefined) red('missing-required', 'budgetUsd');
|
|
134
|
+
else if (!(typeof spec.budgetUsd === 'number' && spec.budgetUsd > 0 && spec.budgetUsd <= shellCapUsd)) {
|
|
135
|
+
red('bounds', 'budgetUsd', `0 < budget <= shell cap ${shellCapUsd} (cap-not-estimate; no self-adjusted budgets, ever)`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 3. the outer write fence — operator law (interview decision #4), same
|
|
139
|
+
// containment rules as the workflow layer THROUGH THE SAME CODE (F9: two
|
|
140
|
+
// transforms would let a fence validate green and mean something else).
|
|
141
|
+
if (spec.writeScope === undefined) red('missing-required', 'writeScope', 'the fence is operator law — the workflow scope must fit inside it');
|
|
142
|
+
else if (!(Array.isArray(spec.writeScope) && spec.writeScope.length > 0
|
|
143
|
+
&& spec.writeScope.every((/** @type {unknown} */ s) => isNonEmptyString(s)))) {
|
|
144
|
+
red('invalid-value', 'writeScope', 'non-empty array of glob strings');
|
|
145
|
+
} else if (!spec.writeScope.every((/** @type {string} */ s) => !globToPrefix(s).includes('*'))) {
|
|
146
|
+
red('invalid-value', 'writeScope', 'wildcards only as a trailing "/**" or "/*" (enforcement is prefix-containment, adaptlearn F9)');
|
|
147
|
+
} else if (!spec.writeScope.every(scopeContained)) {
|
|
148
|
+
red('invalid-value', 'writeScope', 'must be a proper subdirectory of the run directory — no absolute paths, no ".." segments, not the run dir itself (design law #1)');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (spec.escalation === undefined) red('missing-required', 'escalation.mode', 'the pain channel is not optional (law #7)');
|
|
152
|
+
else {
|
|
153
|
+
const esc = isObj(spec.escalation) ? spec.escalation : {};
|
|
154
|
+
for (const key of Object.keys(esc)) {
|
|
155
|
+
if (key !== 'mode') red('unknown-field', `escalation.${key}`, 'nested objects red unknown keys too — no smuggling level exists in a signed spec');
|
|
156
|
+
}
|
|
157
|
+
if (esc.mode !== 'decision-ready') red('invalid-value', 'escalation.mode', 'must be "decision-ready"');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 4. steps + the close chain
|
|
161
|
+
if (!Array.isArray(spec.steps) || spec.steps.length === 0) {
|
|
162
|
+
red('missing-required', 'steps', 'non-empty array — a job without closes is ungated spend');
|
|
163
|
+
} else {
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
spec.steps.forEach((/** @type {any} */ s, /** @type {number} */ i) => {
|
|
166
|
+
const at = `steps.${i}`;
|
|
167
|
+
if (!isObj(s)) { red('invalid-value', at, 'step must be an object'); return; }
|
|
168
|
+
for (const key of Object.keys(s)) {
|
|
169
|
+
if (!['id', 'close', 'class', 'mode', 'tools'].includes(key)) red('unknown-field', `${at}.${key}`);
|
|
170
|
+
}
|
|
171
|
+
// mode/tools — the spec-side tool grant (2b decision #2: the human job
|
|
172
|
+
// spec owns mode + menu; the drafted config cannot express either)
|
|
173
|
+
const isHitl = isObj(s.close) && s.close.type === 'hitl';
|
|
174
|
+
if ((s.mode !== undefined || s.tools !== undefined) && isHitl) {
|
|
175
|
+
red('invalid-value', `${at}.mode`, 'hitl steps run no loop — mode/tools do not apply');
|
|
176
|
+
} else {
|
|
177
|
+
if (s.mode !== undefined && !STEP_MODES.includes(s.mode)) red('invalid-value', `${at}.mode`, STEP_MODES.join('|'));
|
|
178
|
+
if (s.tools !== undefined) {
|
|
179
|
+
if (s.mode !== 'tools') red('invalid-value', `${at}.tools`, 'a tool grant requires mode "tools" — a grant without the mode is incoherent');
|
|
180
|
+
else if (!(Array.isArray(s.tools) && s.tools.length > 0
|
|
181
|
+
&& s.tools.every((/** @type {unknown} */ t) => typeof t === 'string')
|
|
182
|
+
&& new Set(s.tools).size === s.tools.length)) {
|
|
183
|
+
red('invalid-value', `${at}.tools`, `non-empty unique subset of ${TOOL_MENU.join('|')}`);
|
|
184
|
+
} else {
|
|
185
|
+
// locked-but-listed asks red DISTINCTLY: request-red is the admission
|
|
186
|
+
// evidence the ledger tallies (two-red routing); a typo stays invalid-value
|
|
187
|
+
for (const t of s.tools.filter((/** @type {string} */ t) => LOCKED_TOOLS.includes(t))) {
|
|
188
|
+
// verb as a structured field: the ledger keys admission demand on it;
|
|
189
|
+
// parsing it back out of the prose detail would break on a rewording
|
|
190
|
+
reds.push({ code: 'request-red', path: `${at}.tools`, verb: t, detail: `"${t}" is locked-but-listed — this red IS the admission evidence, never a grant; granted menu: ${TOOL_MENU.join('|')}` });
|
|
191
|
+
}
|
|
192
|
+
const unknown = s.tools.filter((/** @type {string} */ t) => !TOOL_MENU.includes(t) && !LOCKED_TOOLS.includes(t));
|
|
193
|
+
if (unknown.length) red('invalid-value', `${at}.tools`, `unknown tool(s) ${unknown.join(', ')} — menu: ${TOOL_MENU.join('|')}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (!isNonEmptyString(s.id) || !SLUG_RE.test(s.id)) red('invalid-value', `${at}.id`, 'kebab-case slug');
|
|
198
|
+
else if (seen.has(s.id)) red('duplicate-id', `${at}.id`, s.id);
|
|
199
|
+
else seen.add(s.id);
|
|
200
|
+
|
|
201
|
+
if (s.class === undefined) red('missing-required', `${at}.class`);
|
|
202
|
+
else if (!CLASSES.includes(s.class)) red('invalid-value', `${at}.class`, CLASSES.join('|'));
|
|
203
|
+
|
|
204
|
+
if (!isObj(s.close)) { red('missing-required', `${at}.close`, 'every step names its close — a step without one is ungated spend'); return; }
|
|
205
|
+
const close = s.close;
|
|
206
|
+
if (!CLOSE_TYPES.includes(close.type)) {
|
|
207
|
+
red('close-type', `${at}.close.type`, `menu: ${CLOSE_TYPES.join('|')} — a close is data, never code`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// per-type contracts — fixed menus only
|
|
211
|
+
if (close.type === 'predicate') {
|
|
212
|
+
if (!isNonEmptyString(close.cmd)) red('missing-required', `${at}.close.cmd`);
|
|
213
|
+
// the runner executes cmd as whitespace-split argv with NO shell: quote
|
|
214
|
+
// characters imply shell semantics the split cannot honor — honest
|
|
215
|
+
// refusal beats silent misparse (N2 design default)
|
|
216
|
+
else if (/["']/.test(close.cmd)) red('invalid-value', `${at}.close.cmd`, 'quote characters are inexpressible: cmd runs as whitespace-split argv, no shell');
|
|
217
|
+
else if (close.cmd !== close.cmd.trim()) red('invalid-value', `${at}.close.cmd`, 'leading/trailing whitespace — argv splits on whitespace and an empty argv[0] cannot spawn; honest refusal beats a silent misparse');
|
|
218
|
+
if (!Number.isInteger(close.expect)) red('invalid-value', `${at}.close.expect`, 'integer exit code');
|
|
219
|
+
// The judgment-rendered signal (PRD v1.11, optional). Exit code alone
|
|
220
|
+
// cannot separate "the suite ran and failed" from "the suite crashed at
|
|
221
|
+
// load" — they are byte-identical at the seam, and against `node --test`
|
|
222
|
+
// so are the raw counts (a crashed file is reported as ONE failing test).
|
|
223
|
+
// So the signal is a FLOOR, not a zero-check: a close declares how many
|
|
224
|
+
// things it must judge before its exit code means anything at all.
|
|
225
|
+
if (close.judged !== undefined) {
|
|
226
|
+
const j = close.judged;
|
|
227
|
+
if (!isObj(j)) red('invalid-value', `${at}.close.judged`, 'object {pattern, min} — proof the close actually judged something');
|
|
228
|
+
else {
|
|
229
|
+
for (const key of Object.keys(j)) {
|
|
230
|
+
if (!['pattern', 'min'].includes(key)) red('unknown-field', `${at}.close.judged.${key}`);
|
|
231
|
+
}
|
|
232
|
+
if (!isNonEmptyString(j.pattern)) red('missing-required', `${at}.close.judged.pattern`, 'regex over the close output with ONE capture group yielding the count');
|
|
233
|
+
else {
|
|
234
|
+
let groups = 0;
|
|
235
|
+
try {
|
|
236
|
+
// `p|` always matches the empty string, so exec() returns an array
|
|
237
|
+
// whose length is 1 + the number of capture groups — the only way
|
|
238
|
+
// to count groups without executing the pattern against real output.
|
|
239
|
+
groups = (new RegExp(`${j.pattern}|`).exec('')?.length ?? 1) - 1;
|
|
240
|
+
} catch {
|
|
241
|
+
red('invalid-value', `${at}.close.judged.pattern`, 'must compile as a RegExp');
|
|
242
|
+
groups = -1;
|
|
243
|
+
}
|
|
244
|
+
// A pattern with no capture group extracts nothing, so EVERY close
|
|
245
|
+
// would read as crashed — a dead arbiter that reds forever. Red the
|
|
246
|
+
// spec, not every run.
|
|
247
|
+
if (groups === 0) red('invalid-value', `${at}.close.judged.pattern`, 'no capture group — the count is read from group 1, so this pattern would crash every close');
|
|
248
|
+
// More than one is the same defect wearing a subtler hat: runClose
|
|
249
|
+
// reads group 1 ONLY, so an alternation whose other branch carries
|
|
250
|
+
// the count leaves group 1 undefined → NaN → judgedCount null →
|
|
251
|
+
// an exit-0 GREEN stamped 'crashed' (a fake crash, the mirror of
|
|
252
|
+
// the fake green this floor exists to catch). Alternation stays
|
|
253
|
+
// fully expressible with non-capturing branches: `(?:a|b) (\d+)`.
|
|
254
|
+
else if (groups > 1) red('invalid-value', `${at}.close.judged.pattern`, `${groups} capture groups — the count is read from group 1 only; use exactly one capture group and non-capturing (?:…) branches`);
|
|
255
|
+
}
|
|
256
|
+
if (!Number.isInteger(j.min) || j.min < 1) {
|
|
257
|
+
red('invalid-value', `${at}.close.judged.min`, 'integer >= 1 — a floor of 0 is satisfied by judging nothing, which is the check it is meant to make');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// The kept-failures pattern (F28, optional). ralph's gap bound head/tail-
|
|
262
|
+
// elides a large close stream, and a big TAP suite prints its `not ok`
|
|
263
|
+
// lines in the MIDDLE — so the failing-test NAMES (the causal navigation
|
|
264
|
+
// input the worker runs on) were deleted in transit and the worker was
|
|
265
|
+
// told "5 fail" and never WHICH. gapKeep is a regex SOURCE whose matching
|
|
266
|
+
// lines are PRESERVED in the gap in addition to head+tail. Same discipline
|
|
267
|
+
// as judged.pattern: a non-empty string that must compile as a RegExp, or
|
|
268
|
+
// it is a spec red before any tokens burn (reds-before-tokens) — an invalid
|
|
269
|
+
// keep pattern must never surface as a runtime crash inside the arbiter.
|
|
270
|
+
if (close.gapKeep !== undefined) {
|
|
271
|
+
if (!isNonEmptyString(close.gapKeep)) red('invalid-value', `${at}.close.gapKeep`, 'regex source string — lines matching it survive the gap bound (F28)');
|
|
272
|
+
else {
|
|
273
|
+
try { new RegExp(close.gapKeep, 'm'); }
|
|
274
|
+
catch { red('invalid-value', `${at}.close.gapKeep`, 'must compile as a RegExp'); }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} else if (close.type === 'gold') {
|
|
278
|
+
if (close.expected === undefined) red('missing-required', `${at}.close.expected`);
|
|
279
|
+
if (!GOLD_COMPARE.includes(close.compare)) red('invalid-value', `${at}.close.compare`, GOLD_COMPARE.join('|'));
|
|
280
|
+
} else if (close.type === 'rubric') {
|
|
281
|
+
if (!isNonEmptyString(close.criteria)) red('missing-required', `${at}.close.criteria`);
|
|
282
|
+
} else if (close.type === 'hitl') {
|
|
283
|
+
if (!isNonEmptyString(close.prompt)) red('missing-required', `${at}.close.prompt`);
|
|
284
|
+
}
|
|
285
|
+
for (const key of Object.keys(close)) {
|
|
286
|
+
if (!CLOSE_FIELDS[close.type].includes(key)) {
|
|
287
|
+
red('unknown-field', `${at}.close.${key}`, `not a ${close.type} field (script bodies and minting claims land here)`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// the hierarchy: one check covers both directions — a class outside the
|
|
291
|
+
// close type's menu is laundering (rubric-as-hard) or delegation
|
|
292
|
+
// (hitl-class on a script close); hitl ⇔ hitl falls out of the menus.
|
|
293
|
+
const legal = CLASS_BY_CLOSE[close.type];
|
|
294
|
+
if (CLASSES.includes(s.class) && !legal.includes(s.class)) {
|
|
295
|
+
red('close-hierarchy', `${at}.class`, `${close.type} close admits class ${legal.join('|')} only (a rubric can never be hard; a human IS the hitl close — PRD §7)`);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 5. secrets sweep — the SAME shared guard the workflow validator runs
|
|
301
|
+
// (defense-in-depth against known token shapes; env-only loading is the law)
|
|
302
|
+
sweepSecretLiterals(spec, red);
|
|
303
|
+
|
|
304
|
+
return { ok: reds.length === 0, reds, job: reds.length === 0 ? spec : null };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Canonical JSON — recursive key sort, so the hash binds to CONTENT, not to
|
|
309
|
+
* the accident of key order a drafting UX or an editor produced.
|
|
310
|
+
* @param {any} v
|
|
311
|
+
* @returns {string}
|
|
312
|
+
*/
|
|
313
|
+
function canon(v) {
|
|
314
|
+
// Follow JSON.stringify semantics — the hash must equal the hash of the
|
|
315
|
+
// disk round-trip, or an approval minted in memory fails after save/reload
|
|
316
|
+
// (human-signs-always rejecting an unedited spec). That means honoring toJSON
|
|
317
|
+
// (a Date serializes to its ISO string, not walked as {}) and dropping
|
|
318
|
+
// undefined-valued keys, both of which JSON.stringify does. One divergence:
|
|
319
|
+
// a function/symbol-VALUED key canonizes as null where stringify drops it —
|
|
320
|
+
// reachable only on non-JSON specs, where a round-trip mismatch is correct.
|
|
321
|
+
if (v !== null && typeof v === 'object' && typeof (/** @type {any} */ (v).toJSON) === 'function') {
|
|
322
|
+
return canon(/** @type {any} */ (v).toJSON());
|
|
323
|
+
}
|
|
324
|
+
if (Array.isArray(v)) return `[${v.map((x) => (x === undefined ? 'null' : canon(x))).join(',')}]`;
|
|
325
|
+
if (isObj(v)) {
|
|
326
|
+
const obj = /** @type {Record<string, any>} */ (v);
|
|
327
|
+
return `{${Object.keys(obj).sort().filter((k) => obj[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canon(obj[k])}`).join(',')}}`;
|
|
328
|
+
}
|
|
329
|
+
const s = JSON.stringify(v);
|
|
330
|
+
return s === undefined ? 'null' : s; // undefined/function/symbol leaf: not JSON-representable — 'null', never throw
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Content hash of a job spec — what an approval record binds to. Any edit to
|
|
335
|
+
* the spec changes the hash, so an edited spec is unapproved by construction
|
|
336
|
+
* (interview decision #1: human signs, always, once per spec VERSION).
|
|
337
|
+
* @param {object} job
|
|
338
|
+
* @returns {string} sha256 hex
|
|
339
|
+
*/
|
|
340
|
+
export function jobSpecHash(job) {
|
|
341
|
+
// NEVER throws: the N2 runner mints {specHash, signer, ts} by calling this
|
|
342
|
+
// directly on the raw job object, so a non-JSON value (BigInt, a cycle, a
|
|
343
|
+
// throwing getter) must yield a hash, not crash the signer. canon handles
|
|
344
|
+
// JSON-representable inputs; the guard covers cycles and hostile getters.
|
|
345
|
+
let c;
|
|
346
|
+
try { c = canon(job); } catch { c = '\u0000unhashable'; }
|
|
347
|
+
return createHash('sha256').update(c).digest('hex');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Does an approval record cover this exact spec version? Pure predicate — the
|
|
352
|
+
* N2 runner refuses to run without it; nothing here writes or prompts. The
|
|
353
|
+
* approval record lives OUTSIDE the document it signs (a spec never contains
|
|
354
|
+
* its own signature) and is shell/human territory, never agent-writable.
|
|
355
|
+
* @param {object} job
|
|
356
|
+
* @param {unknown} approvals array of { specHash, signer, ts } records
|
|
357
|
+
* @returns {boolean}
|
|
358
|
+
*/
|
|
359
|
+
export function checkApproval(job, approvals) {
|
|
360
|
+
if (!Array.isArray(approvals)) return false;
|
|
361
|
+
// NOT jobSpecHash: its un-hashable sentinel would make every un-hashable
|
|
362
|
+
// spec hash-equal — an approval minted for one would authorize any other.
|
|
363
|
+
// Here un-hashable simply means unapproved, full stop.
|
|
364
|
+
let c;
|
|
365
|
+
try { c = canon(job); } catch { return false; }
|
|
366
|
+
const h = createHash('sha256').update(c).digest('hex');
|
|
367
|
+
return approvals.some((a) => isObj(a) && /** @type {Record<string, unknown>} */ (a).specHash === h);
|
|
368
|
+
}
|
package/src/ledger.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// The upstream ledger (module 4) — reactive lib/primitive incident monitoring.
|
|
2
|
+
// Design record: docs/plans/2026-07-11-upstream-ledger-design.md (adaptlearn
|
|
3
|
+
// POC'd spec) + its 2026-07-13 addendum: the bareloop event mapping. One
|
|
4
|
+
// append-only JSONL both the consumer (the panel, N6) and the maintainer read;
|
|
5
|
+
// the A1/A2/A3 upstream-ask flow, mechanized — evidence in, human judgment out.
|
|
6
|
+
//
|
|
7
|
+
// Doctrine this encodes:
|
|
8
|
+
// - Spines stay ground truth; the ledger is DERIVED and reconstructible
|
|
9
|
+
// (delete it, re-run the collector over the corpus: same fold). Current
|
|
10
|
+
// state is a fold, never a mutation — occurrence rows are cumulative deltas.
|
|
11
|
+
// - Idempotence is the collector contract: pass the FULL spine corpus each
|
|
12
|
+
// time; counts are totals computed from what you pass, and a row appends
|
|
13
|
+
// only when a key is new or its count grew.
|
|
14
|
+
// - suggestedAsk is a template seed for UPSTREAM-ASKS, never auto-filed —
|
|
15
|
+
// filing stays human (the arbiter line, maintainer form). Status rows
|
|
16
|
+
// (open → filed → fixed → consumed) are human-appended; this module only
|
|
17
|
+
// folds them.
|
|
18
|
+
// - capability-gap (cap-halt + request-red in one spine) ships but is dormant
|
|
19
|
+
// in N2: a request-red is a job-validation red today, so the job never runs.
|
|
20
|
+
// It goes live when in-loop admission lands (2b deferral).
|
|
21
|
+
|
|
22
|
+
import { readFileSync, appendFileSync, existsSync } from 'node:fs';
|
|
23
|
+
import { basename } from 'node:path';
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import { VERBS } from './validate.js';
|
|
26
|
+
|
|
27
|
+
/** severity order, worst-first (the fold's display order; frequency ranks within) */
|
|
28
|
+
export const LEDGER_CLASSES = Object.freeze([
|
|
29
|
+
'silent-degradation', // A3: a degraded primitive throws nothing — only the known-answer smoke reds it
|
|
30
|
+
'runtime-red', // a store/verb threw at runtime
|
|
31
|
+
'provider-red', // the provider path failed (bare-agent territory)
|
|
32
|
+
'pricing-red', // F6: a result carried no priced cost — the cap went blind
|
|
33
|
+
'capability-gap', // a locked verb was requested AND the run cap-halted
|
|
34
|
+
'broken-close', // consumer-attributed: the arbiter itself cannot run
|
|
35
|
+
'request-red', // admission demand for a locked-but-listed verb
|
|
36
|
+
'retention-red', // litectx retention failed on-green (green stands, mints nothing)
|
|
37
|
+
'config-red', // drafting friction — bareloop's own schema/prompt indicted
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/** @typedef {{spine: string, seq: number}} Sample */
|
|
41
|
+
/** @typedef {{key: string, class: string, lib: string, verb: string, sig: string, detail: string, sample: Sample}} Occurrence */
|
|
42
|
+
/** @typedef {{key: string, lib: string, verb: string, class: string, sig: string, detail: string, occurrences: number, samples: Sample[], suggestedAsk: string, status: string, ref?: string}} FoldEntry */
|
|
43
|
+
|
|
44
|
+
const DETAIL_MAX = 300;
|
|
45
|
+
/** paths and numbers are run-local noise: normalize them so the same bug
|
|
46
|
+
* across runs dedupes and distinct bugs in one verb don't merge */
|
|
47
|
+
const normalizeDetail = (/** @type {unknown} */ s) => String(s ?? '')
|
|
48
|
+
.replace(/(?:~|\.{1,2})?\/[^\s"'`):,]+/g, '<path>')
|
|
49
|
+
.replace(/\d+(?:\.\d+)?/g, '#')
|
|
50
|
+
.slice(0, DETAIL_MAX);
|
|
51
|
+
const sigOf = (/** @type {string} */ normalized) => createHash('sha256').update(normalized).digest('hex').slice(0, 8);
|
|
52
|
+
|
|
53
|
+
const VERB_RE = new RegExp(`\\b(${VERBS.join('|')})\\b`);
|
|
54
|
+
const QUOTED_VERB_RE = /"([a-z0-9-]+)"/;
|
|
55
|
+
|
|
56
|
+
/** The deliberate escalation exclusions as a RUNTIME set, not prose: anything
|
|
57
|
+
* outside {classified} ∪ {this} is an unmapped class and gets counted (see the
|
|
58
|
+
* escalation branch). Kept beside the doc comment on classifyIncidents, which
|
|
59
|
+
* explains WHY each one classifies to nothing. */
|
|
60
|
+
const EXCLUDED_ESCALATIONS = new Set([
|
|
61
|
+
'cap-halt', // a budget story, not a lib bug
|
|
62
|
+
'gate-red', // governance working as intended
|
|
63
|
+
'smoke-red', // already counted via primitive-smoke
|
|
64
|
+
'hitl-close', // by design: a human is the close
|
|
65
|
+
'close-unsupported', // honest refusal, by design
|
|
66
|
+
'close-timeout', // close-verdict reds: the arbiter's own named terminals
|
|
67
|
+
'close-killed', // (F17) — operator/environment stories, never a suite lib
|
|
68
|
+
'close-crashed',
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
/** suggestedAsk templates — seeds for UPSTREAM-ASKS entries, never auto-filed */
|
|
72
|
+
const ASKS = /** @type {Record<string, (o: {lib: string, verb: string, detail: string}) => string>} */ ({
|
|
73
|
+
'silent-degradation': (o) => `${o.lib}: \`${o.verb}\` failed its known-answer smoke — ${o.detail}`,
|
|
74
|
+
'runtime-red': (o) => `${o.lib}: \`${o.verb}\` threw at runtime — ${o.detail}`,
|
|
75
|
+
'provider-red': (o) => `bare-agent: the provider path failed — ${o.detail}`,
|
|
76
|
+
'pricing-red': (o) => `bare-agent: a provider result carried no priced cost (unpriced is never free, F6) — ${o.detail}`,
|
|
77
|
+
'capability-gap': (o) => `bare-agent: locked \`${o.verb}\` was requested and the run cap-halted — admission candidate`,
|
|
78
|
+
'broken-close': (o) => `job owner: the close itself cannot run — ${o.detail}`,
|
|
79
|
+
'request-red': (o) => `admission: locked \`${o.verb}\` requested by a job spec — demand evidence, never a grant`,
|
|
80
|
+
'retention-red': (o) => `litectx: on-green retention failed — ${o.detail}`,
|
|
81
|
+
'config-red': (o) => `bareloop: drafting friction at ${o.verb} — ${o.detail}`,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Classify one spine's events into incident occurrences (the design table in
|
|
86
|
+
* bareloop vocabulary). Deliberate exclusions — bare cap-halt (a budget story),
|
|
87
|
+
* close-verdict reds and artifact-red (worker stories, the §5b line), gate-red
|
|
88
|
+
* (governance working as intended), pr-red (operator environment, not a suite
|
|
89
|
+
* lib), smoke-red escalations (already counted via primitive-smoke), hitl-close
|
|
90
|
+
* and close-unsupported (by design) — classify to nothing.
|
|
91
|
+
* @param {any[]} events one spine's parsed events, in seq order
|
|
92
|
+
* @param {{spine?: string}} [opts] spine id stamped into samples
|
|
93
|
+
* @returns {Occurrence[]}
|
|
94
|
+
*/
|
|
95
|
+
export function classifyIncidents(events, { spine = 'spine' } = {}) {
|
|
96
|
+
/** @type {Occurrence[]} */
|
|
97
|
+
const occs = [];
|
|
98
|
+
let capHalted = false;
|
|
99
|
+
/** @type {(ev: any, cls: string, lib: string, verb: string, rawDetail: unknown) => void} */
|
|
100
|
+
const add = (ev, cls, lib, verb, rawDetail) => {
|
|
101
|
+
const detail = normalizeDetail(rawDetail);
|
|
102
|
+
const sig = sigOf(detail);
|
|
103
|
+
occs.push({ key: `${lib}:${verb}:${cls}:${sig}`, class: cls, lib, verb, sig, detail, sample: { spine, seq: ev.seq } });
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
for (const ev of events) {
|
|
107
|
+
if (ev.type === 'primitive-smoke' && ev.ok === false) {
|
|
108
|
+
add(ev, 'silent-degradation', ev.primitive ?? 'unknown', 'smoke', ev.detail);
|
|
109
|
+
} else if (ev.type === 'retention-red') {
|
|
110
|
+
add(ev, 'retention-red', 'litectx', 'remember', ev.detail);
|
|
111
|
+
} else if (ev.type === 'cap-halt') {
|
|
112
|
+
capHalted = true; // not an incident alone — see capability-gap below
|
|
113
|
+
} else if (ev.type === 'config-red') {
|
|
114
|
+
// in bareloop the config is model-drafted against OUR schema description:
|
|
115
|
+
// a repeated signature indicts the drafting prompt/schema, i.e. bareloop
|
|
116
|
+
add(ev, 'config-red', 'bareloop', String(ev.path ?? 'config').split('.')[0] || 'config', `${ev.code} at ${ev.path}: ${ev.detail ?? ''}`);
|
|
117
|
+
} else if (ev.type === 'job-red' && ev.code === 'request-red') {
|
|
118
|
+
// the structured field wins; the prose-quoted verb stays as the fallback
|
|
119
|
+
// for spines written before the field existed
|
|
120
|
+
add(ev, 'request-red', 'bare-agent', ev.verb ?? (String(ev.detail ?? '').match(QUOTED_VERB_RE) ?? [])[1] ?? 'unknown', ev.detail);
|
|
121
|
+
} else if (ev.type === 'escalation') {
|
|
122
|
+
if (ev.category === 'broken-close') {
|
|
123
|
+
add(ev, 'broken-close', 'consumer', 'close', ev.detail);
|
|
124
|
+
} else if (ev.category === 'provider-red') {
|
|
125
|
+
// the runner's transport-throw seam (design table: retry-exhausted seam)
|
|
126
|
+
add(ev, 'provider-red', 'bare-agent', 'provider', ev.detail ?? ev.decision);
|
|
127
|
+
} else if (ev.category === 'pricing-red') {
|
|
128
|
+
add(ev, 'pricing-red', 'bare-agent', 'pricing', ev.decision ?? ev.detail);
|
|
129
|
+
} else if (ev.category === 'interpreter-red') {
|
|
130
|
+
// the design split: a store verb in the detail → runtime-red (verb→lib
|
|
131
|
+
// map); a worker-loop/provider failure → provider-red; neither is
|
|
132
|
+
// still counted (runtime-red, lib unknown) — never silently dropped
|
|
133
|
+
const detail = String(ev.detail ?? '');
|
|
134
|
+
// The STRUCTURED field wins, prose stays the fallback for spines written
|
|
135
|
+
// before it existed (the request-red contract above). Prose alone
|
|
136
|
+
// misfiled deterministically: interpret.js prefixes every worker-loop
|
|
137
|
+
// error with "worker loop:", and the verb sniff ran first, so a
|
|
138
|
+
// bare-agent transport failure whose text merely said "recall" was
|
|
139
|
+
// billed to litectx — wrong upstream asked, real regression hidden.
|
|
140
|
+
if (ev.lib === 'bare-agent') add(ev, 'provider-red', 'bare-agent', 'provider', detail);
|
|
141
|
+
else if (ev.lib === 'litectx') add(ev, 'runtime-red', 'litectx', (detail.match(VERB_RE) ?? [])[1] ?? 'store', detail);
|
|
142
|
+
else {
|
|
143
|
+
const verb = (detail.match(VERB_RE) ?? [])[1];
|
|
144
|
+
if (verb || /\blitectx\b/.test(detail)) add(ev, 'runtime-red', 'litectx', verb ?? 'store', detail);
|
|
145
|
+
else if (/worker loop|provider/i.test(detail)) add(ev, 'provider-red', 'bare-agent', 'provider', detail);
|
|
146
|
+
else add(ev, 'runtime-red', 'unknown', 'middle', detail);
|
|
147
|
+
}
|
|
148
|
+
} else if (!EXCLUDED_ESCALATIONS.has(String(ev.category))) {
|
|
149
|
+
// The exclusions above are DELIBERATE and now executable (see the set):
|
|
150
|
+
// a category that is neither classified nor named there is a NEW or
|
|
151
|
+
// renamed failure class, and the four-way chain used to drop it into
|
|
152
|
+
// exactly the same silence as a deliberate skip — a whole class could
|
|
153
|
+
// leave the ledger with no red, no count, nothing to notice. Counted
|
|
154
|
+
// against bareloop: a stale emit→classify mapping is OUR bug.
|
|
155
|
+
add(ev, 'runtime-red', 'bareloop', 'escalation', `unclassified escalation category "${ev.category}": ${ev.detail ?? ev.decision ?? ''}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (capHalted) {
|
|
161
|
+
// capability-gap: the run hit its cap in a spine that also asked for a
|
|
162
|
+
// locked verb — the strongest admission evidence there is (design table)
|
|
163
|
+
for (const verb of new Set(occs.filter((o) => o.class === 'request-red').map((o) => o.verb))) {
|
|
164
|
+
const halt = events.find((e) => e.type === 'cap-halt');
|
|
165
|
+
add(halt, 'capability-gap', 'bare-agent', verb, `locked "${verb}" requested and the run cap-halted`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return occs;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Fold ledger rows to current state — latest cumulative count per key, latest
|
|
173
|
+
* human-appended status riding on top (default 'open'). A status row for a key
|
|
174
|
+
* with no incident row still folds (occurrences 0): a human note must never
|
|
175
|
+
* silently vanish.
|
|
176
|
+
* @param {any[]} rows parsed ledger rows, in seq order
|
|
177
|
+
* @returns {Record<string, FoldEntry>}
|
|
178
|
+
*/
|
|
179
|
+
export function foldLedger(rows) {
|
|
180
|
+
/** @type {Record<string, FoldEntry>} */
|
|
181
|
+
const fold = {};
|
|
182
|
+
for (const row of rows) {
|
|
183
|
+
if (row.type === 'lib-incident') {
|
|
184
|
+
const prev = fold[row.key];
|
|
185
|
+
const { seq: _seq, ts: _ts, type: _type, ...fields } = row;
|
|
186
|
+
// incident fields refresh; a human-appended status must ride on top
|
|
187
|
+
fold[row.key] = { ...fields, status: prev?.status ?? 'open', ...(prev?.ref !== undefined ? { ref: prev.ref } : {}) };
|
|
188
|
+
} else if (row.type === 'lib-incident-status') {
|
|
189
|
+
/** @type {FoldEntry} */
|
|
190
|
+
const prev = fold[row.key] ?? { key: row.key, lib: 'unknown', verb: 'unknown', class: 'unknown', sig: '', detail: '', occurrences: 0, samples: [], suggestedAsk: '', status: 'open' };
|
|
191
|
+
fold[row.key] = { ...prev, status: row.status, ref: row.ref };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return fold;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const SAMPLES_MAX = 5;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Compute the append-only delta rows: group occurrences by key; a row is due
|
|
201
|
+
* only when the key is new or its corpus total grew past the folded count.
|
|
202
|
+
* detail is first-seen (the fold's wins); samples are the corpus's first few.
|
|
203
|
+
* @param {Record<string, FoldEntry>} fold current ledger state
|
|
204
|
+
* @param {Occurrence[]} occurrences the FULL corpus's occurrences
|
|
205
|
+
* @returns {object[]} lib-incident rows, unstamped (no seq/ts)
|
|
206
|
+
*/
|
|
207
|
+
export function ledgerDeltas(fold, occurrences) {
|
|
208
|
+
/** @type {Map<string, Occurrence[]>} */
|
|
209
|
+
const byKey = new Map();
|
|
210
|
+
for (const o of occurrences) {
|
|
211
|
+
const group = byKey.get(o.key) ?? [];
|
|
212
|
+
group.push(o);
|
|
213
|
+
byKey.set(o.key, group);
|
|
214
|
+
}
|
|
215
|
+
const rows = [];
|
|
216
|
+
for (const [key, group] of byKey) {
|
|
217
|
+
const prev = fold[key];
|
|
218
|
+
if (prev && group.length <= prev.occurrences) continue;
|
|
219
|
+
const first = group[0];
|
|
220
|
+
const detail = prev?.detail || first.detail;
|
|
221
|
+
rows.push({
|
|
222
|
+
type: 'lib-incident', key, lib: first.lib, verb: first.verb, class: first.class, sig: first.sig,
|
|
223
|
+
detail, occurrences: group.length, samples: group.slice(0, SAMPLES_MAX).map((o) => o.sample),
|
|
224
|
+
suggestedAsk: ASKS[first.class]({ lib: first.lib, verb: first.verb, detail }),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return rows;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** @param {string} file @returns {any[]} */
|
|
231
|
+
function readJsonl(file) {
|
|
232
|
+
const rows = [];
|
|
233
|
+
const lines = readFileSync(file, 'utf8').split('\n');
|
|
234
|
+
for (let i = 0; i < lines.length; i++) {
|
|
235
|
+
if (!lines[i].trim()) continue;
|
|
236
|
+
try { rows.push(JSON.parse(lines[i])); } catch {
|
|
237
|
+
// corruption in a derived file is never papered over — rebuild from spines
|
|
238
|
+
throw new Error(`${file} line ${i + 1}: malformed JSONL`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return rows;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The collector: read the ledger, classify the FULL spine corpus, append the
|
|
246
|
+
* deltas (spine conventions: type first, ts stamped last, seq continuing
|
|
247
|
+
* monotonically across appends). Idempotent over the same corpus; the CLI
|
|
248
|
+
* wrapping this lands at N5, the panel reads the same file at N6.
|
|
249
|
+
* @param {{ledgerFile: string, spineFiles: string[]}} o spineFiles: the corpus
|
|
250
|
+
* (pass ALL of it each time — counts are totals, not increments)
|
|
251
|
+
* @returns {{appended: object[], fold: Record<string, FoldEntry>}}
|
|
252
|
+
*/
|
|
253
|
+
export function updateLedger({ ledgerFile, spineFiles }) {
|
|
254
|
+
const rows = existsSync(ledgerFile) ? readJsonl(ledgerFile) : [];
|
|
255
|
+
const occurrences = spineFiles.flatMap((f) => classifyIncidents(readJsonl(f), { spine: basename(f).replace(/\.jsonl$/, '') }));
|
|
256
|
+
const deltas = ledgerDeltas(foldLedger(rows), occurrences);
|
|
257
|
+
let seq = rows.reduce((m, r) => Math.max(m, r.seq ?? 0), 0);
|
|
258
|
+
const appended = deltas.map((row) => {
|
|
259
|
+
/** @type {Record<string, unknown>} */
|
|
260
|
+
const stamped = { ...row, seq: ++seq };
|
|
261
|
+
stamped.ts = new Date().toISOString();
|
|
262
|
+
appendFileSync(ledgerFile, JSON.stringify(stamped) + '\n');
|
|
263
|
+
return stamped;
|
|
264
|
+
});
|
|
265
|
+
return { appended, fold: foldLedger(rows.concat(appended)) };
|
|
266
|
+
}
|