bareloop 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +204 -0
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +123 -11
- package/bareloop.context.md +182 -0
- package/package.json +41 -3
- package/src/extract.js +95 -0
- package/src/index.js +13 -0
- package/src/interpret.js +261 -0
- package/src/job.js +252 -0
- package/src/ralph.js +122 -0
- package/src/spine.js +29 -0
- package/src/text.js +9 -0
- package/src/validate.js +331 -0
- package/types/extract.d.ts +23 -0
- package/types/index.d.ts +6 -0
- package/types/interpret.d.ts +62 -0
- package/types/job.d.ts +57 -0
- package/types/ralph.d.ts +61 -0
- package/types/spine.d.ts +6 -0
- package/types/text.d.ts +1 -0
- package/types/validate.d.ts +69 -0
package/src/job.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
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
|
+
export const CADENCE_UNITS = Object.freeze(['hour', 'day', 'week']);
|
|
28
|
+
export const PROVIDERS = Object.freeze(['anthropic-api']); // SP-2: API-first; local deferred (PRD §5/§8)
|
|
29
|
+
/** V3 environment label: declared keys only — every field is a lineage-key
|
|
30
|
+
* candidate at N3. `provider` is part of the key by definition (top-level,
|
|
31
|
+
* not duplicated here). */
|
|
32
|
+
export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', 'taskFraming', 'scaffold']);
|
|
33
|
+
const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'conditions', 'cadence', 'budgetUsd', 'writeScope', 'steps', 'escalation'];
|
|
34
|
+
/** exact field set per close type — anything else is an unknown-field red
|
|
35
|
+
* (freeform code, script bodies, and minting claims all land there) */
|
|
36
|
+
const CLOSE_FIELDS = {
|
|
37
|
+
predicate: ['type', 'cmd', 'expect'],
|
|
38
|
+
gold: ['type', 'expected', 'compare'],
|
|
39
|
+
rubric: ['type', 'criteria'],
|
|
40
|
+
hitl: ['type', 'prompt'],
|
|
41
|
+
};
|
|
42
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
43
|
+
|
|
44
|
+
/** @typedef {{code: string, path: string, detail?: string}} Red */
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Validate an operator-owned job spec (`schema: "job-v1"`). Never throws on
|
|
48
|
+
* JSON text or plain parsed data — the ingest contract; a live object with a
|
|
49
|
+
* hostile accessor is outside it. Every failure is a named red. Returns the
|
|
50
|
+
* parsed spec on ok (single parse), null on any red.
|
|
51
|
+
* @param {object|string} input parsed spec, or raw JSON text (parse failures are a red)
|
|
52
|
+
* @param {{ shellCapUsd?: number }} [opts] the shell's hard ceiling — the job
|
|
53
|
+
* budget may tighten it, never exceed it (ceiling chain: workflow ≤ job ≤ shell)
|
|
54
|
+
* @returns {{ ok: boolean, reds: Red[], job: object|null }}
|
|
55
|
+
*/
|
|
56
|
+
export function validateJob(input, { shellCapUsd = 2 } = {}) {
|
|
57
|
+
/** @type {Red[]} */
|
|
58
|
+
const reds = [];
|
|
59
|
+
/** @type {(code: string, path: string, detail?: string) => void} */
|
|
60
|
+
const red = (code, path, detail) => { reds.push(detail ? { code, path, detail } : { code, path }); };
|
|
61
|
+
|
|
62
|
+
let j = input;
|
|
63
|
+
if (typeof j === 'string') {
|
|
64
|
+
try { j = JSON.parse(j); } catch (e) {
|
|
65
|
+
return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: String(/** @type {Error} */ (e).message) }], job: null };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (!isObj(j)) return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: 'job spec must be a JSON object' }], job: null };
|
|
69
|
+
const spec = /** @type {Record<string, any>} */ (j);
|
|
70
|
+
|
|
71
|
+
// 1. shape — unknown fields red here: hooks/loop/memory (agent domain),
|
|
72
|
+
// capRuns (shell domain), minting claims — inexpressibility is the guard.
|
|
73
|
+
for (const key of Object.keys(spec)) {
|
|
74
|
+
if (!JOB_FIELDS.includes(key)) red('unknown-field', key);
|
|
75
|
+
}
|
|
76
|
+
if (spec.schema === undefined) red('missing-required', 'schema');
|
|
77
|
+
else if (spec.schema !== 'job-v1') red('invalid-value', 'schema', `expected "job-v1", got ${JSON.stringify(spec.schema)}`);
|
|
78
|
+
if (!isNonEmptyString(spec.job) || !SLUG_RE.test(spec.job)) red('invalid-value', 'job', 'kebab-case slug');
|
|
79
|
+
if (!isNonEmptyString(spec.description)) red('missing-required', 'description');
|
|
80
|
+
|
|
81
|
+
if (spec.provider === undefined) red('missing-required', 'provider');
|
|
82
|
+
else if (!PROVIDERS.includes(spec.provider)) red('invalid-value', 'provider', `menu: ${PROVIDERS.join('|')}`);
|
|
83
|
+
|
|
84
|
+
// 2. the environment label (V3, design law #5): declared keys only
|
|
85
|
+
if (spec.conditions !== undefined) {
|
|
86
|
+
if (!isObj(spec.conditions)) red('invalid-value', 'conditions', 'flat object of declared condition keys');
|
|
87
|
+
else {
|
|
88
|
+
for (const [key, value] of Object.entries(spec.conditions)) {
|
|
89
|
+
if (!CONDITION_KEYS.includes(key)) red('unknown-field', `conditions.${key}`, `declared keys only: ${CONDITION_KEYS.join('|')} (every field is a lineage-key dimension, V3)`);
|
|
90
|
+
else if (!isNonEmptyString(value)) red('invalid-value', `conditions.${key}`, 'non-empty string');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (spec.cadence === undefined) red('missing-required', 'cadence');
|
|
96
|
+
else if (!isObj(spec.cadence)) red('invalid-value', 'cadence', 'object of {unit, every}');
|
|
97
|
+
else {
|
|
98
|
+
const cad = spec.cadence;
|
|
99
|
+
for (const key of Object.keys(cad)) {
|
|
100
|
+
if (key !== 'unit' && key !== 'every') red('unknown-field', `cadence.${key}`, 'nested objects red unknown keys too — no smuggling level exists in a signed spec');
|
|
101
|
+
}
|
|
102
|
+
if (!CADENCE_UNITS.includes(cad.unit)) red('invalid-value', 'cadence.unit', CADENCE_UNITS.join('|'));
|
|
103
|
+
if (!(Number.isInteger(cad.every) && cad.every >= 1 && cad.every <= 30)) red('bounds', 'cadence.every', '1..30');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (spec.budgetUsd === undefined) red('missing-required', 'budgetUsd');
|
|
107
|
+
else if (!(typeof spec.budgetUsd === 'number' && spec.budgetUsd > 0 && spec.budgetUsd <= shellCapUsd)) {
|
|
108
|
+
red('bounds', 'budgetUsd', `0 < budget <= shell cap ${shellCapUsd} (cap-not-estimate; no self-adjusted budgets, ever)`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 3. the outer write fence — operator law (interview decision #4), same
|
|
112
|
+
// containment rules as the workflow layer THROUGH THE SAME CODE (F9: two
|
|
113
|
+
// transforms would let a fence validate green and mean something else).
|
|
114
|
+
if (spec.writeScope === undefined) red('missing-required', 'writeScope', 'the fence is operator law — the workflow scope must fit inside it');
|
|
115
|
+
else if (!(Array.isArray(spec.writeScope) && spec.writeScope.length > 0
|
|
116
|
+
&& spec.writeScope.every((/** @type {unknown} */ s) => isNonEmptyString(s)))) {
|
|
117
|
+
red('invalid-value', 'writeScope', 'non-empty array of glob strings');
|
|
118
|
+
} else if (!spec.writeScope.every((/** @type {string} */ s) => !globToPrefix(s).includes('*'))) {
|
|
119
|
+
red('invalid-value', 'writeScope', 'wildcards only as a trailing "/**" or "/*" (enforcement is prefix-containment, adaptlearn F9)');
|
|
120
|
+
} else if (!spec.writeScope.every(scopeContained)) {
|
|
121
|
+
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)');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (spec.escalation === undefined) red('missing-required', 'escalation.mode', 'the pain channel is not optional (law #7)');
|
|
125
|
+
else {
|
|
126
|
+
const esc = isObj(spec.escalation) ? spec.escalation : {};
|
|
127
|
+
for (const key of Object.keys(esc)) {
|
|
128
|
+
if (key !== 'mode') red('unknown-field', `escalation.${key}`, 'nested objects red unknown keys too — no smuggling level exists in a signed spec');
|
|
129
|
+
}
|
|
130
|
+
if (esc.mode !== 'decision-ready') red('invalid-value', 'escalation.mode', 'must be "decision-ready"');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 4. steps + the close chain
|
|
134
|
+
if (!Array.isArray(spec.steps) || spec.steps.length === 0) {
|
|
135
|
+
red('missing-required', 'steps', 'non-empty array — a job without closes is ungated spend');
|
|
136
|
+
} else {
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
spec.steps.forEach((/** @type {any} */ s, /** @type {number} */ i) => {
|
|
139
|
+
const at = `steps.${i}`;
|
|
140
|
+
if (!isObj(s)) { red('invalid-value', at, 'step must be an object'); return; }
|
|
141
|
+
for (const key of Object.keys(s)) {
|
|
142
|
+
if (!['id', 'close', 'class'].includes(key)) red('unknown-field', `${at}.${key}`);
|
|
143
|
+
}
|
|
144
|
+
if (!isNonEmptyString(s.id) || !SLUG_RE.test(s.id)) red('invalid-value', `${at}.id`, 'kebab-case slug');
|
|
145
|
+
else if (seen.has(s.id)) red('duplicate-id', `${at}.id`, s.id);
|
|
146
|
+
else seen.add(s.id);
|
|
147
|
+
|
|
148
|
+
if (s.class === undefined) red('missing-required', `${at}.class`);
|
|
149
|
+
else if (!CLASSES.includes(s.class)) red('invalid-value', `${at}.class`, CLASSES.join('|'));
|
|
150
|
+
|
|
151
|
+
if (!isObj(s.close)) { red('missing-required', `${at}.close`, 'every step names its close — a step without one is ungated spend'); return; }
|
|
152
|
+
const close = s.close;
|
|
153
|
+
if (!CLOSE_TYPES.includes(close.type)) {
|
|
154
|
+
red('close-type', `${at}.close.type`, `menu: ${CLOSE_TYPES.join('|')} — a close is data, never code`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// per-type contracts — fixed menus only
|
|
158
|
+
if (close.type === 'predicate') {
|
|
159
|
+
if (!isNonEmptyString(close.cmd)) red('missing-required', `${at}.close.cmd`);
|
|
160
|
+
if (!Number.isInteger(close.expect)) red('invalid-value', `${at}.close.expect`, 'integer exit code');
|
|
161
|
+
} else if (close.type === 'gold') {
|
|
162
|
+
if (close.expected === undefined) red('missing-required', `${at}.close.expected`);
|
|
163
|
+
if (!GOLD_COMPARE.includes(close.compare)) red('invalid-value', `${at}.close.compare`, GOLD_COMPARE.join('|'));
|
|
164
|
+
} else if (close.type === 'rubric') {
|
|
165
|
+
if (!isNonEmptyString(close.criteria)) red('missing-required', `${at}.close.criteria`);
|
|
166
|
+
} else if (close.type === 'hitl') {
|
|
167
|
+
if (!isNonEmptyString(close.prompt)) red('missing-required', `${at}.close.prompt`);
|
|
168
|
+
}
|
|
169
|
+
for (const key of Object.keys(close)) {
|
|
170
|
+
if (!CLOSE_FIELDS[close.type].includes(key)) {
|
|
171
|
+
red('unknown-field', `${at}.close.${key}`, `not a ${close.type} field (script bodies and minting claims land here)`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// the hierarchy: one check covers both directions — a class outside the
|
|
175
|
+
// close type's menu is laundering (rubric-as-hard) or delegation
|
|
176
|
+
// (hitl-class on a script close); hitl ⇔ hitl falls out of the menus.
|
|
177
|
+
const legal = CLASS_BY_CLOSE[close.type];
|
|
178
|
+
if (CLASSES.includes(s.class) && !legal.includes(s.class)) {
|
|
179
|
+
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)`);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 5. secrets sweep — the SAME shared guard the workflow validator runs
|
|
185
|
+
// (defense-in-depth against known token shapes; env-only loading is the law)
|
|
186
|
+
sweepSecretLiterals(spec, red);
|
|
187
|
+
|
|
188
|
+
return { ok: reds.length === 0, reds, job: reds.length === 0 ? spec : null };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Canonical JSON — recursive key sort, so the hash binds to CONTENT, not to
|
|
193
|
+
* the accident of key order a drafting UX or an editor produced.
|
|
194
|
+
* @param {any} v
|
|
195
|
+
* @returns {string}
|
|
196
|
+
*/
|
|
197
|
+
function canon(v) {
|
|
198
|
+
// Follow JSON.stringify semantics — the hash must equal the hash of the
|
|
199
|
+
// disk round-trip, or an approval minted in memory fails after save/reload
|
|
200
|
+
// (human-signs-always rejecting an unedited spec). That means honoring toJSON
|
|
201
|
+
// (a Date serializes to its ISO string, not walked as {}) and dropping
|
|
202
|
+
// undefined-valued keys, both of which JSON.stringify does. One divergence:
|
|
203
|
+
// a function/symbol-VALUED key canonizes as null where stringify drops it —
|
|
204
|
+
// reachable only on non-JSON specs, where a round-trip mismatch is correct.
|
|
205
|
+
if (v !== null && typeof v === 'object' && typeof (/** @type {any} */ (v).toJSON) === 'function') {
|
|
206
|
+
return canon(/** @type {any} */ (v).toJSON());
|
|
207
|
+
}
|
|
208
|
+
if (Array.isArray(v)) return `[${v.map((x) => (x === undefined ? 'null' : canon(x))).join(',')}]`;
|
|
209
|
+
if (isObj(v)) {
|
|
210
|
+
const obj = /** @type {Record<string, any>} */ (v);
|
|
211
|
+
return `{${Object.keys(obj).sort().filter((k) => obj[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canon(obj[k])}`).join(',')}}`;
|
|
212
|
+
}
|
|
213
|
+
const s = JSON.stringify(v);
|
|
214
|
+
return s === undefined ? 'null' : s; // undefined/function/symbol leaf: not JSON-representable — 'null', never throw
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Content hash of a job spec — what an approval record binds to. Any edit to
|
|
219
|
+
* the spec changes the hash, so an edited spec is unapproved by construction
|
|
220
|
+
* (interview decision #1: human signs, always, once per spec VERSION).
|
|
221
|
+
* @param {object} job
|
|
222
|
+
* @returns {string} sha256 hex
|
|
223
|
+
*/
|
|
224
|
+
export function jobSpecHash(job) {
|
|
225
|
+
// NEVER throws: the N2 runner mints {specHash, signer, ts} by calling this
|
|
226
|
+
// directly on the raw job object, so a non-JSON value (BigInt, a cycle, a
|
|
227
|
+
// throwing getter) must yield a hash, not crash the signer. canon handles
|
|
228
|
+
// JSON-representable inputs; the guard covers cycles and hostile getters.
|
|
229
|
+
let c;
|
|
230
|
+
try { c = canon(job); } catch { c = '\u0000unhashable'; }
|
|
231
|
+
return createHash('sha256').update(c).digest('hex');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Does an approval record cover this exact spec version? Pure predicate — the
|
|
236
|
+
* N2 runner refuses to run without it; nothing here writes or prompts. The
|
|
237
|
+
* approval record lives OUTSIDE the document it signs (a spec never contains
|
|
238
|
+
* its own signature) and is shell/human territory, never agent-writable.
|
|
239
|
+
* @param {object} job
|
|
240
|
+
* @param {unknown} approvals array of { specHash, signer, ts } records
|
|
241
|
+
* @returns {boolean}
|
|
242
|
+
*/
|
|
243
|
+
export function checkApproval(job, approvals) {
|
|
244
|
+
if (!Array.isArray(approvals)) return false;
|
|
245
|
+
// NOT jobSpecHash: its un-hashable sentinel would make every un-hashable
|
|
246
|
+
// spec hash-equal — an approval minted for one would authorize any other.
|
|
247
|
+
// Here un-hashable simply means unapproved, full stop.
|
|
248
|
+
let c;
|
|
249
|
+
try { c = canon(job); } catch { return false; }
|
|
250
|
+
const h = createHash('sha256').update(c).digest('hex');
|
|
251
|
+
return approvals.some((a) => isObj(a) && /** @type {Record<string, unknown>} */ (a).specHash === h);
|
|
252
|
+
}
|
package/src/ralph.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// The outer shell — fixed, deliberately dumb, permanent (PRD §4). It holds the
|
|
2
|
+
// arbiter (the close: exit code = truth) and the iteration budget, and nothing
|
|
3
|
+
// inside negotiates with either (design law #1: the agent never authors its
|
|
4
|
+
// arbiter). Stateless across runs and stdlib-only by design: it must stay too
|
|
5
|
+
// dumb to be gamed. Do not make it smarter.
|
|
6
|
+
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Run a close command; its exit code is the verdict (hard green, PRD §4).
|
|
11
|
+
* 0 → satisfied; nonzero → needs_revision (gap fed back); spawn error → failed (terminal).
|
|
12
|
+
*
|
|
13
|
+
* `redact` scrubs the close's own output at the SOURCE — before the gap becomes
|
|
14
|
+
* a spine event or a worker prompt — so a secret a checked command echoes
|
|
15
|
+
* (a 401 dumping an `Authorization: Bearer …` header) never enters the
|
|
16
|
+
* append-only spine (the hard line). It is INJECTED, not imported: the shell
|
|
17
|
+
* stays stdlib-only and un-gameable, and the redactor is a fixed shell
|
|
18
|
+
* primitive (bareguard's, wired by the interpreter), never an emergent
|
|
19
|
+
* component — so V4 holds. The shell's canonical emission IS the redacted text;
|
|
20
|
+
* a benign gap is returned byte-identical (default: identity).
|
|
21
|
+
* @param {string[]} close argv, e.g. ['node', '--test', 'close/']
|
|
22
|
+
* @param {(s: string) => string} [redact] source scrubber; identity by default
|
|
23
|
+
*/
|
|
24
|
+
export function runClose(close, redact = (s) => s) {
|
|
25
|
+
const env = { ...process.env };
|
|
26
|
+
delete env.NODE_TEST_CONTEXT; // a `node --test` close inherits this from a test runner and silently no-ops — a fake green
|
|
27
|
+
const r = spawnSync(close[0], close.slice(1), { env, encoding: 'utf8', timeout: 120_000 });
|
|
28
|
+
if (r.error) return { verdict: 'failed', detail: String(r.error) };
|
|
29
|
+
if (r.status === 0) return { verdict: 'satisfied' };
|
|
30
|
+
// The gap must never be falsy: every consumer guards with `if (gap)` — an
|
|
31
|
+
// empty-output red would silently kill gap feedback, after-red hooks, AND
|
|
32
|
+
// stall detection, leaving the worker re-prompted byte-identically to the cap.
|
|
33
|
+
// Redact BEFORE slicing so a token straddling the 2000-char bound can't survive.
|
|
34
|
+
return { verdict: 'needs_revision', gap: redact(r.stderr || r.stdout || '').slice(0, 2000) || '(close exited nonzero with no output)', exitCode: r.status };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The loop: `while close-red and under-cap: run the middle`. Stops at first
|
|
39
|
+
* green (within-run tuning past a visible close is the fit-to-pass surface,
|
|
40
|
+
* PRD §2). The two honest terminals are green and a decision-ready escalation;
|
|
41
|
+
* cap-halt means "not under cap", its own category, never merged with "wrong"
|
|
42
|
+
* (design law #8). A broken close escalates immediately — a broken arbiter
|
|
43
|
+
* must not masquerade as a bad harness.
|
|
44
|
+
*
|
|
45
|
+
* A middle that throws is read through the failure map: a throw carrying
|
|
46
|
+
* `category: 'cap-halt'` (the USD gate tripping inside the middle) escalates
|
|
47
|
+
* as cap-halt; any other named category is relayed as-is; an unnamed throw is
|
|
48
|
+
* an interpreter-red — a broken interpreter must not masquerade as a bad
|
|
49
|
+
* harness. Ralph never interprets, only relays.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} opts
|
|
52
|
+
* @param {(iteration: number, gap?: string) => void|Promise<void>} opts.middle the emergent middle; never sees close/cap
|
|
53
|
+
* @param {string[]} opts.close argv whose exit code is truth
|
|
54
|
+
* @param {number} opts.capRuns budget: max middle runs
|
|
55
|
+
* @param {(type: string, data?: object) => object} opts.emit a spine emitter
|
|
56
|
+
* @param {(s: string) => string} [opts.redact] source scrubber for close output
|
|
57
|
+
* (secrets never enter the spine); injected so the shell stays stdlib-only
|
|
58
|
+
* @returns {Promise<'green'|'escalated'>}
|
|
59
|
+
*/
|
|
60
|
+
export async function ralph({ middle, close, capRuns, emit, redact }) {
|
|
61
|
+
emit('run-start', { capRuns, close: close.join(' ') });
|
|
62
|
+
const verdicts = [];
|
|
63
|
+
let gap;
|
|
64
|
+
for (let iteration = 1; iteration <= capRuns; iteration++) {
|
|
65
|
+
emit('iteration-start', { iteration });
|
|
66
|
+
try {
|
|
67
|
+
await middle(iteration, gap);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
// dumb passthrough: the thrower names its category (cap-halt | gate-red | …)
|
|
70
|
+
const category = e && typeof e.category === 'string' ? e.category : 'interpreter-red';
|
|
71
|
+
if (category === 'cap-halt') emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail: String(e.message || e) });
|
|
72
|
+
const DECISIONS = {
|
|
73
|
+
'cap-halt': [`Budget gate tripped mid-run (${e && e.message}). Continue with a higher cap, change approach, or stop?`,
|
|
74
|
+
['raise the cap and rerun', 'change the middle/harness', 'abandon the task']],
|
|
75
|
+
'gate-red': ['The gate denied an action mid-run — the harness asked for something outside its scope.',
|
|
76
|
+
['widen the write scope deliberately', 'change the middle/harness', 'abandon the task']],
|
|
77
|
+
};
|
|
78
|
+
// Object.hasOwn, not bare lookup: a category named after an
|
|
79
|
+
// Object.prototype member ("toString") would return the inherited
|
|
80
|
+
// function, and destructuring it would crash the shell inside its own
|
|
81
|
+
// escalation path — the one place that must never break.
|
|
82
|
+
const [decision, options] = (Object.hasOwn(DECISIONS, category) ? DECISIONS[category] : undefined)
|
|
83
|
+
?? ['The middle itself broke — no harness verdict is trustworthy until it is fixed.', ['fix the interpreter', 'abandon the task']];
|
|
84
|
+
emit('escalation', {
|
|
85
|
+
category, decisionReady: true, verdicts,
|
|
86
|
+
spend: { runs: iteration, capRuns },
|
|
87
|
+
decision, options, detail: String(e.message || e),
|
|
88
|
+
});
|
|
89
|
+
emit('run-end', { outcome: 'escalated', iterations: iteration });
|
|
90
|
+
return 'escalated';
|
|
91
|
+
}
|
|
92
|
+
emit('middle-done', { iteration });
|
|
93
|
+
const v = runClose(close, redact);
|
|
94
|
+
verdicts.push(v.verdict);
|
|
95
|
+
emit('close-verdict', { iteration, ...v });
|
|
96
|
+
if (v.verdict === 'satisfied') {
|
|
97
|
+
emit('run-end', { outcome: 'green', iterations: iteration });
|
|
98
|
+
return 'green';
|
|
99
|
+
}
|
|
100
|
+
if (v.verdict === 'failed') {
|
|
101
|
+
emit('escalation', {
|
|
102
|
+
category: 'broken-close', decisionReady: true, verdicts,
|
|
103
|
+
spend: { runs: iteration, capRuns },
|
|
104
|
+
decision: 'The close itself cannot run — no verdict is trustworthy until it is fixed.',
|
|
105
|
+
options: ['fix the close command', 'abandon the task'],
|
|
106
|
+
detail: v.detail,
|
|
107
|
+
});
|
|
108
|
+
emit('run-end', { outcome: 'escalated', iterations: iteration });
|
|
109
|
+
return 'escalated';
|
|
110
|
+
}
|
|
111
|
+
gap = v.gap; // feedback for the next middle run; the shell itself learns nothing
|
|
112
|
+
}
|
|
113
|
+
emit('cap-halt', { category: 'cap-halt', meaning: 'not under cap — not "can\'t"', capRuns });
|
|
114
|
+
emit('escalation', {
|
|
115
|
+
category: 'cap-halt', decisionReady: true, verdicts,
|
|
116
|
+
spend: { runs: capRuns, capRuns },
|
|
117
|
+
decision: `${capRuns}/${capRuns} runs spent, close still red. Continue, change approach, or stop?`,
|
|
118
|
+
options: ['raise the cap and rerun', 'change the middle/harness', 'abandon the task'],
|
|
119
|
+
});
|
|
120
|
+
emit('run-end', { outcome: 'escalated', iterations: capRuns });
|
|
121
|
+
return 'escalated';
|
|
122
|
+
}
|
package/src/spine.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The floor's event spine: append-only JSONL, one event per line (PRD §4).
|
|
2
|
+
// Single source for every UI — the panel is a pure observer of this file.
|
|
3
|
+
// Contract (carried from adaptlearn/relayfact — the pattern, not the code):
|
|
4
|
+
// `seq` is monotonic per spine, `ts` is stamped last and is always the final
|
|
5
|
+
// key, and consumers are pure listeners — nothing here reads the file back.
|
|
6
|
+
// Secrets never enter the spine (PRD §4): an append-only record that captures
|
|
7
|
+
// a key captures it forever.
|
|
8
|
+
|
|
9
|
+
import { appendFileSync } from 'node:fs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create an emitter bound to one JSONL file.
|
|
13
|
+
* @param {string} file absolute path; created on first emit
|
|
14
|
+
* @returns {(type: string, data?: object) => object} emit — returns the event as written
|
|
15
|
+
*/
|
|
16
|
+
export function makeSpine(file) {
|
|
17
|
+
let seq = 0;
|
|
18
|
+
return function emit(type, data = {}) {
|
|
19
|
+
// type/seq/ts are the envelope's, by mechanism not comment: callers spread
|
|
20
|
+
// foreign objects into events, and a payload that grows one of these keys
|
|
21
|
+
// must never relabel or mis-stamp a row in the append-only record.
|
|
22
|
+
const { type: _type, seq: _seq, ts: _ts, ...payload } = /** @type {Record<string, unknown>} */ (data);
|
|
23
|
+
/** @type {Record<string, unknown>} */
|
|
24
|
+
const ev = { type, ...payload, seq: ++seq };
|
|
25
|
+
ev.ts = new Date().toISOString();
|
|
26
|
+
appendFileSync(file, JSON.stringify(ev) + '\n');
|
|
27
|
+
return ev;
|
|
28
|
+
};
|
|
29
|
+
}
|
package/src/text.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Shared text helpers. stripFences is deliberately minimal at N0 (reference
|
|
2
|
+
// parity): it strips ONE leading and ONE trailing markdown fence. The
|
|
3
|
+
// fence-robust upgrade (prose-wrapped, mid-text fences — adaptlearn F21's
|
|
4
|
+
// instrument caveat, filed in FINDINGS F2) lands here at N2, in one place:
|
|
5
|
+
// interpret's artifact path and extract's rules path must never disagree
|
|
6
|
+
// about what the same model output parses to.
|
|
7
|
+
|
|
8
|
+
/** @param {string} t */
|
|
9
|
+
export const stripFences = (t) => t.trim().replace(/^```[a-z]*\n?/i, '').replace(/\n?```\s*$/, '');
|