bareloop 0.0.1 → 0.1.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 +124 -0
- package/LICENSE +202 -0
- package/README.md +123 -11
- package/bareloop.context.md +115 -0
- package/package.json +40 -3
- package/src/extract.js +95 -0
- package/src/index.js +9 -0
- package/src/interpret.js +228 -0
- package/src/ralph.js +109 -0
- package/src/spine.js +29 -0
- package/src/text.js +9 -0
- package/src/validate.js +213 -0
- package/types/extract.d.ts +23 -0
- package/types/index.d.ts +5 -0
- package/types/interpret.d.ts +57 -0
- package/types/ralph.d.ts +48 -0
- package/types/spine.d.ts +6 -0
- package/types/text.d.ts +1 -0
- package/types/validate.d.ts +38 -0
package/src/extract.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The rules extractor — the lineage's inheritance representation. One sealed
|
|
2
|
+
// LLM call after a green run distills/updates the lineage's rules list from
|
|
3
|
+
// LEDGER FACTS only: the green config, the prior rules, and the revision diff
|
|
4
|
+
// if the run recovered mid-run (the diff is the lesson — failure-transition
|
|
5
|
+
// evidence, free in the event log). It never sees the close, the tests, or the
|
|
6
|
+
// worker's code: rules describe the WORKFLOW, and showing the judge would open
|
|
7
|
+
// the config-level fit-to-pass surface (PRD §2).
|
|
8
|
+
//
|
|
9
|
+
// N3 extends this with the contrast-bit rule (design law #3: verdict admits,
|
|
10
|
+
// contrast attributes — the extractor may claim a knob only with ≥1
|
|
11
|
+
// ledger-counted contrast bit; adaptlearn F16/F18 showed bare greens lose
|
|
12
|
+
// working features). At N0 the extractor is the adaptlearn-proven distiller.
|
|
13
|
+
//
|
|
14
|
+
// Honesty invariants:
|
|
15
|
+
// - One shot, no retry: malformed output is a red as DATA — the caller keeps
|
|
16
|
+
// the lineage's prior rules; there is never a silent empty inheritance.
|
|
17
|
+
// - The ≤MAX_RULES/≤MAX_RULE_CHARS bound is stated to the model but ENFORCED
|
|
18
|
+
// mechanically post-call, and enforcement rejects whole — never trims
|
|
19
|
+
// (rejecting a half-applicable output beats silently part-applying it).
|
|
20
|
+
// - Extractor spend is carried in costUsd and lands on the run's cost line —
|
|
21
|
+
// a representation whose upkeep rides free corrupts the ranking (design
|
|
22
|
+
// law #4: green gates, cost ranks).
|
|
23
|
+
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
import { stripFences } from './text.js';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const { Loop } = require('bare-agent');
|
|
30
|
+
|
|
31
|
+
export const MAX_RULES = 5;
|
|
32
|
+
export const MAX_RULE_CHARS = 200;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Distill/update a lineage's rules from one green run's ledger facts.
|
|
36
|
+
* @param {object} opts
|
|
37
|
+
* @param {object} opts.config the config that went green
|
|
38
|
+
* @param {object} opts.provider a bareagent provider — SHELL-owned, sealed
|
|
39
|
+
* @param {string[]|null} opts.priorRules the lineage's current rules, if any
|
|
40
|
+
* @param {string[]} [opts.revisionDiff] changed config paths, when the run recovered mid-run
|
|
41
|
+
* @returns {Promise<{rules: string[]|null, valid: boolean, reds: Array<object>, costUsd: number, raw: string}>}
|
|
42
|
+
*/
|
|
43
|
+
export async function extractRules({ config, provider, priorRules, revisionDiff }) {
|
|
44
|
+
const loop = new Loop({ provider, system: 'You emit exactly one JSON document and nothing else.' });
|
|
45
|
+
const prompt = `An automated coding workflow just completed a run that PASSED its hidden judgement.
|
|
46
|
+
You maintain this workflow lineage's inherited rules: short, general lessons about how to
|
|
47
|
+
configure the workflow for this task family. You never see the tasks' tests or code — only
|
|
48
|
+
workflow facts.
|
|
49
|
+
|
|
50
|
+
The config that went green:
|
|
51
|
+
${JSON.stringify(config, null, 2)}
|
|
52
|
+
${revisionDiff && revisionDiff.length > 0 ? `
|
|
53
|
+
This run stalled and recovered after revising these config paths mid-run (the revision that
|
|
54
|
+
turned red into green — strong evidence about what mattered):
|
|
55
|
+
${revisionDiff.join(', ')}
|
|
56
|
+
` : ''}${priorRules && priorRules.length > 0 ? `
|
|
57
|
+
The lineage's current rules — revise them: keep what still holds, drop what this run
|
|
58
|
+
contradicts, add at most what this run actually evidences:
|
|
59
|
+
${JSON.stringify(priorRules, null, 2)}
|
|
60
|
+
` : `
|
|
61
|
+
The lineage has no rules yet. Write only what this single run actually evidences.
|
|
62
|
+
`}
|
|
63
|
+
Output ONLY a JSON array of strings: at most ${MAX_RULES} rules, each at most ${MAX_RULE_CHARS}
|
|
64
|
+
characters. No markdown fences, no commentary.`;
|
|
65
|
+
|
|
66
|
+
// Never throw (the module contract): a provider blip during post-green
|
|
67
|
+
// extraction must not lose the green run's bookkeeping — it degrades to a
|
|
68
|
+
// red as data and the caller keeps the lineage's prior rules. Loop defaults
|
|
69
|
+
// throwOnError: true, so a transient 500 WOULD reject without this.
|
|
70
|
+
let r;
|
|
71
|
+
try {
|
|
72
|
+
r = await loop.run([{ role: 'user', content: prompt }]);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return { rules: null, valid: false, reds: [{ code: 'provider-error', detail: String(/** @type {Error} */ (e).message ?? e) }], costUsd: 0, raw: '' };
|
|
75
|
+
}
|
|
76
|
+
const costUsd = r.metrics?.costUsd ?? r.cost ?? 0;
|
|
77
|
+
const raw = stripFences(r.text ?? '');
|
|
78
|
+
if (r.error) {
|
|
79
|
+
return { rules: null, valid: false, reds: [{ code: 'provider-error', detail: String(r.error) }], costUsd, raw };
|
|
80
|
+
}
|
|
81
|
+
/** @type {(code: string, detail: string) => {rules: null, valid: false, reds: Array<object>, costUsd: number, raw: string}} */
|
|
82
|
+
const red = (code, detail) => ({ rules: null, valid: false, reds: [{ code, detail }], costUsd, raw });
|
|
83
|
+
|
|
84
|
+
let rules;
|
|
85
|
+
try { rules = JSON.parse(raw); } catch (e) {
|
|
86
|
+
return red('parse-error', String(/** @type {Error} */ (e).message));
|
|
87
|
+
}
|
|
88
|
+
if (!Array.isArray(rules) || rules.length === 0 || !rules.every((x) => typeof x === 'string')) {
|
|
89
|
+
return red('rules-shape', 'expected a non-empty JSON array of strings');
|
|
90
|
+
}
|
|
91
|
+
if (rules.length > MAX_RULES || rules.some((x) => x.length > MAX_RULE_CHARS)) {
|
|
92
|
+
return red('rules-bound', `max ${MAX_RULES} rules of ${MAX_RULE_CHARS} chars — rejected whole, never trimmed`);
|
|
93
|
+
}
|
|
94
|
+
return { rules, valid: true, reds: [], costUsd, raw };
|
|
95
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// bareloop public surface — grows one rung at a time (PRD §10). N0: the
|
|
2
|
+
// token-free spine. The five modules compose bottom-up: makeSpine feeds ralph,
|
|
3
|
+
// interpret is the only config reader, extractRules distills a green run.
|
|
4
|
+
|
|
5
|
+
export { makeSpine } from './spine.js';
|
|
6
|
+
export { ralph, runClose } from './ralph.js';
|
|
7
|
+
export { validateConfig, diffPaths, globToPrefix, LOOP_SHAPES, SLOTS, VERBS } from './validate.js';
|
|
8
|
+
export { interpret, STALL_REDS } from './interpret.js';
|
|
9
|
+
export { extractRules, MAX_RULES, MAX_RULE_CHARS } from './extract.js';
|
package/src/interpret.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// The interpreter — the ONLY code that reads a workflow config (PRD §4's
|
|
2
|
+
// emergent middle, executed). It composes the suite, never invents: litectx is
|
|
3
|
+
// the store, bareguard is the leash, bareagent is the worker loop (design law
|
|
4
|
+
// #10). The provider arrives from the SHELL (never the config — it is
|
|
5
|
+
// arbiter-adjacent), and the close never runs here: the shell runs it and
|
|
6
|
+
// feeds the verdict back as `gap`.
|
|
7
|
+
//
|
|
8
|
+
// Two traps this encodes, both paid for in adaptlearn: `onLlmResult` is a Loop
|
|
9
|
+
// CONSTRUCTOR option — passed to run() it is silently ignored and the budget
|
|
10
|
+
// axis goes blind (F3); and a budget-exhausted gate deny must surface as
|
|
11
|
+
// cap-halt, its own category, never a generic error (design law #8).
|
|
12
|
+
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { writeFileSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
import { Gate } from 'bareguard';
|
|
17
|
+
import { LiteCtx, compress } from 'litectx';
|
|
18
|
+
import { validateConfig, diffPaths, globToPrefix } from './validate.js';
|
|
19
|
+
import { ralph } from './ralph.js';
|
|
20
|
+
|
|
21
|
+
/** @typedef {Error & {category?: string}} CategorizedError the failure map's carrier: ralph relays by `category` */
|
|
22
|
+
|
|
23
|
+
// consecutive close reds that count as a stall; one revision per run
|
|
24
|
+
export const STALL_REDS = 2;
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const { Loop, wireGate, HaltError } = require('bare-agent');
|
|
28
|
+
|
|
29
|
+
import { stripFences } from './text.js';
|
|
30
|
+
|
|
31
|
+
/** @typedef {{body?: string|null, text?: string|null}} RecallHit litectx recall hit — body present only with `{body: true}` */
|
|
32
|
+
|
|
33
|
+
const PERSONA = 'You are a senior engineer. Reply with ONLY the complete contents of the requested JavaScript file — no markdown fences, no commentary. ESM.';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Execute a workflow config against one task under the dumb shell.
|
|
37
|
+
*
|
|
38
|
+
* @param {object|string} configRaw schema v1 config (object or raw JSON text)
|
|
39
|
+
* @param {object} opts
|
|
40
|
+
* @param {string} opts.task implement instruction shown to the worker
|
|
41
|
+
* @param {string} opts.target absolute path the artifact is written to
|
|
42
|
+
* @param {string[]} opts.close argv whose exit code is truth (shell-owned)
|
|
43
|
+
* @param {string} opts.workdir run directory (litectx root, gate audit, scope base)
|
|
44
|
+
* @param {number} opts.capRuns shell iteration budget; the config may tighten via loop.maxIterations, never exceed
|
|
45
|
+
* @param {(type: string, data?: object) => object} opts.emit spine emitter
|
|
46
|
+
* @param {object} opts.provider a bareagent provider — SHELL-owned binding (adaptlearn F8: an unsealed binding is a gate bypass)
|
|
47
|
+
* @param {number} [opts.shellCapUsd=2] the shell's USD cap; config budgetUsd is clamped by validation
|
|
48
|
+
* @param {(o: {config: object, gaps: string[], policy: any, onLlmResult: any}) => Promise<{candidate: object|null, parseError?: string|null, costUsd?: number}>} [opts.revisor]
|
|
49
|
+
* optional mid-run revision seam. Fires ONCE per run after STALL_REDS consecutive
|
|
50
|
+
* close reds. The interpreter — never the revisor — owns acceptance: the candidate
|
|
51
|
+
* must validate, and gate/escalation/loop.maxIterations must be unchanged
|
|
52
|
+
* (arbiter-touch / cap-touch revision-reds otherwise; the run continues on the old
|
|
53
|
+
* config). Revisor spend rides the run's own gate handlers — same budget axis as
|
|
54
|
+
* the worker.
|
|
55
|
+
* @returns {Promise<'green'|'escalated'|'config-red'>}
|
|
56
|
+
*/
|
|
57
|
+
export async function interpret(configRaw, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd = 2, revisor }) {
|
|
58
|
+
const v = validateConfig(configRaw, { shellCapUsd });
|
|
59
|
+
emit('config-validate', { ok: v.ok, reds: v.reds });
|
|
60
|
+
if (!v.ok) {
|
|
61
|
+
for (const r of v.reds) emit('config-red', r);
|
|
62
|
+
emit('run-end', { outcome: 'config-red', iterations: 0 });
|
|
63
|
+
return 'config-red';
|
|
64
|
+
}
|
|
65
|
+
let config = typeof configRaw === 'string' ? JSON.parse(configRaw) : configRaw;
|
|
66
|
+
|
|
67
|
+
const lc = new LiteCtx({ root: workdir });
|
|
68
|
+
const gate = new Gate({
|
|
69
|
+
// bareguard fs.writeScope is prefix-containment, not glob (adaptlearn F4); globToPrefix
|
|
70
|
+
// is the ONE transform shared with the validator's legality rule — mid-path wildcards
|
|
71
|
+
// and workdir-escaping scopes were already rejected up front (adaptlearn F9, law #1).
|
|
72
|
+
fs: { writeScope: config.gate.writeScope.map((/** @type {string} */ g) => resolve(workdir, globToPrefix(g))) },
|
|
73
|
+
budget: { maxCostUsd: config.gate.budgetUsd },
|
|
74
|
+
limits: { maxTurns: 8 * (capRuns + 1) },
|
|
75
|
+
audit: { path: join(workdir, 'gate-audit.jsonl') },
|
|
76
|
+
humanChannel: async () => ({ decision: 'terminate' }), // no human mid-run: a tripped cap terminates → decision-ready escalation
|
|
77
|
+
});
|
|
78
|
+
await gate.init();
|
|
79
|
+
const { policy, onLlmResult } = wireGate(gate);
|
|
80
|
+
const loop = new Loop({ provider, system: PERSONA, policy, onLlmResult });
|
|
81
|
+
|
|
82
|
+
/** @param {string} slot */
|
|
83
|
+
const slotOps = (slot) => config.hooks?.[slot] ?? [];
|
|
84
|
+
/** @param {{kinds?: string[]}} op */
|
|
85
|
+
const recallKinds = (op) => op.kinds ?? config.memory.recall?.kinds ?? ['fact'];
|
|
86
|
+
|
|
87
|
+
/** @param {string} prompt */
|
|
88
|
+
async function ask(prompt) {
|
|
89
|
+
let r;
|
|
90
|
+
try {
|
|
91
|
+
r = await loop.run([{ role: 'user', content: prompt }]);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
if (e instanceof HaltError) /** @type {CategorizedError} */ (e).category = 'cap-halt'; // belt: bare-agent's contract could change
|
|
94
|
+
throw e;
|
|
95
|
+
}
|
|
96
|
+
// bare-agent NEVER throws HaltError out of run() — a governance halt comes
|
|
97
|
+
// back as an error RETURN ({text: '', error: 'halt:<rule>'}; loop.js: "no
|
|
98
|
+
// throw even when throwOnError: true"). Read it, or the failure map goes
|
|
99
|
+
// blind and a cap story masquerades as a worker result (design law #8).
|
|
100
|
+
if (r.error) {
|
|
101
|
+
const err = /** @type {CategorizedError} */ (new Error(`worker loop: ${r.error}`));
|
|
102
|
+
err.category = r.error.startsWith('halt:') ? 'cap-halt' : 'interpreter-red';
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
return r;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {string} slot
|
|
110
|
+
* @param {{iteration?: number, gap?: string, context?: any}} o
|
|
111
|
+
*/
|
|
112
|
+
async function runOps(slot, { iteration, gap, context }) {
|
|
113
|
+
for (const op of slotOps(slot)) {
|
|
114
|
+
if (op.op === 'recall') {
|
|
115
|
+
/** @type {RecallHit[]} */
|
|
116
|
+
const hits = [];
|
|
117
|
+
for (const kind of recallKinds(op)) {
|
|
118
|
+
hits.push(...await lc.recall(task, { kind, n: op.k ?? config.memory.recall?.k ?? 5, body: true }));
|
|
119
|
+
}
|
|
120
|
+
context.text = hits.map((h) => h.body ?? h.text ?? '').filter(Boolean).join('\n');
|
|
121
|
+
context.level = null;
|
|
122
|
+
emit('hook-op', { slot, op: 'recall', hits: hits.length, iteration });
|
|
123
|
+
} else if (op.op === 'compress') {
|
|
124
|
+
const level = op.level ?? config.memory.compressLevel ?? 'verbatim';
|
|
125
|
+
if (context.text) context.text = await compress({ text: context.text, format: 'js' }, { level });
|
|
126
|
+
emit('hook-op', { slot, op: 'compress', level, iteration });
|
|
127
|
+
} else if (op.op === 'stash') {
|
|
128
|
+
if (gap) lc.stash(`gap-${iteration}`, gap);
|
|
129
|
+
emit('hook-op', { slot, op: 'stash', iteration });
|
|
130
|
+
} else if (op.op === 'remember') {
|
|
131
|
+
await lc.remember(`green-${iteration ?? 'final'}-${target.split('/').at(-1)}`, readFileSync(target, 'utf8'), { kind: op.kind ?? 'fact' });
|
|
132
|
+
emit('hook-op', { slot, op: 'remember' });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Mid-run revision: interpreter-owned acceptance — a revisor cannot vouch for
|
|
138
|
+
// its own output. The gate is already constructed and the iteration budget
|
|
139
|
+
// already snapshotted, so gate/escalation (arbiter) and loop.maxIterations
|
|
140
|
+
// (cap) must be byte-identical; anything else that validates is a legal
|
|
141
|
+
// free-axis revision.
|
|
142
|
+
/** @param {any} candidate */
|
|
143
|
+
const acceptRevision = (candidate) => {
|
|
144
|
+
if (!candidate) return { code: 'parse-error' };
|
|
145
|
+
const cv = validateConfig(candidate, { shellCapUsd });
|
|
146
|
+
if (!cv.ok) return { code: 'validation', reds: cv.reds };
|
|
147
|
+
if (JSON.stringify(candidate.gate) !== JSON.stringify(config.gate)
|
|
148
|
+
|| JSON.stringify(candidate.escalation) !== JSON.stringify(config.escalation)) {
|
|
149
|
+
return { code: 'arbiter-touch' };
|
|
150
|
+
}
|
|
151
|
+
if (candidate.loop?.maxIterations !== config.loop.maxIterations) return { code: 'cap-touch' };
|
|
152
|
+
return null;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** @type {string[]} */
|
|
156
|
+
const gaps = [];
|
|
157
|
+
let revised = false;
|
|
158
|
+
/**
|
|
159
|
+
* @param {number} iteration
|
|
160
|
+
* @param {string} [gap]
|
|
161
|
+
*/
|
|
162
|
+
const middle = async (iteration, gap) => {
|
|
163
|
+
if (gap) gaps.push(gap);
|
|
164
|
+
if (revisor && !revised && gaps.length >= STALL_REDS) {
|
|
165
|
+
emit('stall-detected', { iteration, consecutiveReds: gaps.length });
|
|
166
|
+
revised = true; // one revision per run, spent even if rejected
|
|
167
|
+
let rv;
|
|
168
|
+
try {
|
|
169
|
+
// the run's own gate handlers ride along: revisor spend hits the same
|
|
170
|
+
// budget axis as the worker; a budget halt mid-revision is a cap story,
|
|
171
|
+
// not a revision bug
|
|
172
|
+
rv = await revisor({ config, gaps: [...gaps], policy, onLlmResult });
|
|
173
|
+
} catch (e) {
|
|
174
|
+
if (e instanceof HaltError) /** @type {CategorizedError} */ (e).category = 'cap-halt';
|
|
175
|
+
throw e;
|
|
176
|
+
}
|
|
177
|
+
const red = acceptRevision(rv.candidate);
|
|
178
|
+
if (red) {
|
|
179
|
+
emit('revision-red', { iteration, ...red, detail: rv.parseError ?? undefined, costUsd: rv.costUsd ?? 0 });
|
|
180
|
+
} else {
|
|
181
|
+
emit('revision-accepted', { iteration, changedPaths: diffPaths(config, rv.candidate), costUsd: rv.costUsd ?? 0 });
|
|
182
|
+
config = rv.candidate;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (gap) await runOps('after-red', { iteration, gap, context: {} });
|
|
186
|
+
const context = {};
|
|
187
|
+
await runOps('before-attempt', { iteration, context });
|
|
188
|
+
const parts = [task, context.text && `Possibly relevant notes:\n${context.text}`, gap && `Previous attempt failed the test suite:\n${gap}`];
|
|
189
|
+
|
|
190
|
+
if (config.loop.shape === 'plan') {
|
|
191
|
+
// plan-then-execute: one call to decompose, one to implement following the plan
|
|
192
|
+
const p = await ask([`Produce a SHORT numbered implementation plan (2-4 steps) for this task. Plan only, no code.`, ...parts.slice(1), parts[0]].filter(Boolean).join('\n\n'));
|
|
193
|
+
emit('worker-plan', { iteration, costUsd: p.metrics?.costUsd ?? p.cost ?? null }); // metrics.costUsd carries the honest null when unpriced; cost sums priced rounds only
|
|
194
|
+
parts.push(`Follow this plan:\n${p.text}`);
|
|
195
|
+
}
|
|
196
|
+
const r = await ask(parts.filter(Boolean).join('\n\n'));
|
|
197
|
+
emit('worker-result', { iteration, costUsd: r.metrics?.costUsd ?? r.cost ?? null, tokens: r.usage?.outputTokens ?? null });
|
|
198
|
+
|
|
199
|
+
const decision = await gate.check({ type: 'write', path: target, args: { bytes: r.text.length } });
|
|
200
|
+
if (decision.outcome !== 'allow') {
|
|
201
|
+
const err = /** @type {CategorizedError} */ (new Error(`gate ${decision.outcome} write to ${target} (${decision.rule ?? 'no rule'})`));
|
|
202
|
+
err.category = decision.severity === 'halt' ? 'cap-halt' : 'gate-red';
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
writeFileSync(target, stripFences(r.text));
|
|
206
|
+
emit('artifact-written', { iteration, path: target });
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// the config may tighten the shell's iteration budget, never exceed it (mirrors budgetUsd)
|
|
210
|
+
const effectiveCap = Math.min(capRuns, config.loop.maxIterations ?? capRuns);
|
|
211
|
+
const outcome = await ralph({ middle, close, capRuns: effectiveCap, emit });
|
|
212
|
+
if (outcome === 'green') {
|
|
213
|
+
// The close already passed — a retention hiccup must not un-green a real
|
|
214
|
+
// green (it would corrupt the learning curve). It degrades loudly:
|
|
215
|
+
// retention-red on the spine means this green mints NO inheritance, but
|
|
216
|
+
// the delivery stands (adaptlearn F5).
|
|
217
|
+
try {
|
|
218
|
+
await runOps('on-green', {});
|
|
219
|
+
} catch (e) {
|
|
220
|
+
emit('retention-red', { category: 'retention-red', detail: String(/** @type {Error} */ (e).message || e) });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Design law #2 (adaptlearn F18): the run-as-executed record. A mid-run
|
|
224
|
+
// revision changes `config`; without this event the revised config dies with
|
|
225
|
+
// the run and every inheritance channel reads only the config-as-authored.
|
|
226
|
+
emit('config-final', { config, revised });
|
|
227
|
+
return outcome;
|
|
228
|
+
}
|
package/src/ralph.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
* @param {string[]} close argv, e.g. ['node', '--test', 'close/']
|
|
13
|
+
*/
|
|
14
|
+
export function runClose(close) {
|
|
15
|
+
const env = { ...process.env };
|
|
16
|
+
delete env.NODE_TEST_CONTEXT; // a `node --test` close inherits this from a test runner and silently no-ops — a fake green
|
|
17
|
+
const r = spawnSync(close[0], close.slice(1), { env, encoding: 'utf8', timeout: 120_000 });
|
|
18
|
+
if (r.error) return { verdict: 'failed', detail: String(r.error) };
|
|
19
|
+
if (r.status === 0) return { verdict: 'satisfied' };
|
|
20
|
+
// The gap must never be falsy: every consumer guards with `if (gap)` — an
|
|
21
|
+
// empty-output red would silently kill gap feedback, after-red hooks, AND
|
|
22
|
+
// stall detection, leaving the worker re-prompted byte-identically to the cap.
|
|
23
|
+
return { verdict: 'needs_revision', gap: (r.stderr || r.stdout || '').slice(0, 2000) || '(close exited nonzero with no output)', exitCode: r.status };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The loop: `while close-red and under-cap: run the middle`. Stops at first
|
|
28
|
+
* green (within-run tuning past a visible close is the fit-to-pass surface,
|
|
29
|
+
* PRD §2). The two honest terminals are green and a decision-ready escalation;
|
|
30
|
+
* cap-halt means "not under cap", its own category, never merged with "wrong"
|
|
31
|
+
* (design law #8). A broken close escalates immediately — a broken arbiter
|
|
32
|
+
* must not masquerade as a bad harness.
|
|
33
|
+
*
|
|
34
|
+
* A middle that throws is read through the failure map: a throw carrying
|
|
35
|
+
* `category: 'cap-halt'` (the USD gate tripping inside the middle) escalates
|
|
36
|
+
* as cap-halt; any other named category is relayed as-is; an unnamed throw is
|
|
37
|
+
* an interpreter-red — a broken interpreter must not masquerade as a bad
|
|
38
|
+
* harness. Ralph never interprets, only relays.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} opts
|
|
41
|
+
* @param {(iteration: number, gap?: string) => void|Promise<void>} opts.middle the emergent middle; never sees close/cap
|
|
42
|
+
* @param {string[]} opts.close argv whose exit code is truth
|
|
43
|
+
* @param {number} opts.capRuns budget: max middle runs
|
|
44
|
+
* @param {(type: string, data?: object) => object} opts.emit a spine emitter
|
|
45
|
+
* @returns {Promise<'green'|'escalated'>}
|
|
46
|
+
*/
|
|
47
|
+
export async function ralph({ middle, close, capRuns, emit }) {
|
|
48
|
+
emit('run-start', { capRuns, close: close.join(' ') });
|
|
49
|
+
const verdicts = [];
|
|
50
|
+
let gap;
|
|
51
|
+
for (let iteration = 1; iteration <= capRuns; iteration++) {
|
|
52
|
+
emit('iteration-start', { iteration });
|
|
53
|
+
try {
|
|
54
|
+
await middle(iteration, gap);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
// dumb passthrough: the thrower names its category (cap-halt | gate-red | …)
|
|
57
|
+
const category = e && typeof e.category === 'string' ? e.category : 'interpreter-red';
|
|
58
|
+
if (category === 'cap-halt') emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail: String(e.message || e) });
|
|
59
|
+
const DECISIONS = {
|
|
60
|
+
'cap-halt': [`Budget gate tripped mid-run (${e && e.message}). Continue with a higher cap, change approach, or stop?`,
|
|
61
|
+
['raise the cap and rerun', 'change the middle/harness', 'abandon the task']],
|
|
62
|
+
'gate-red': ['The gate denied an action mid-run — the harness asked for something outside its scope.',
|
|
63
|
+
['widen the write scope deliberately', 'change the middle/harness', 'abandon the task']],
|
|
64
|
+
};
|
|
65
|
+
// Object.hasOwn, not bare lookup: a category named after an
|
|
66
|
+
// Object.prototype member ("toString") would return the inherited
|
|
67
|
+
// function, and destructuring it would crash the shell inside its own
|
|
68
|
+
// escalation path — the one place that must never break.
|
|
69
|
+
const [decision, options] = (Object.hasOwn(DECISIONS, category) ? DECISIONS[category] : undefined)
|
|
70
|
+
?? ['The middle itself broke — no harness verdict is trustworthy until it is fixed.', ['fix the interpreter', 'abandon the task']];
|
|
71
|
+
emit('escalation', {
|
|
72
|
+
category, decisionReady: true, verdicts,
|
|
73
|
+
spend: { runs: iteration, capRuns },
|
|
74
|
+
decision, options, detail: String(e.message || e),
|
|
75
|
+
});
|
|
76
|
+
emit('run-end', { outcome: 'escalated', iterations: iteration });
|
|
77
|
+
return 'escalated';
|
|
78
|
+
}
|
|
79
|
+
emit('middle-done', { iteration });
|
|
80
|
+
const v = runClose(close);
|
|
81
|
+
verdicts.push(v.verdict);
|
|
82
|
+
emit('close-verdict', { iteration, ...v });
|
|
83
|
+
if (v.verdict === 'satisfied') {
|
|
84
|
+
emit('run-end', { outcome: 'green', iterations: iteration });
|
|
85
|
+
return 'green';
|
|
86
|
+
}
|
|
87
|
+
if (v.verdict === 'failed') {
|
|
88
|
+
emit('escalation', {
|
|
89
|
+
category: 'broken-close', decisionReady: true, verdicts,
|
|
90
|
+
spend: { runs: iteration, capRuns },
|
|
91
|
+
decision: 'The close itself cannot run — no verdict is trustworthy until it is fixed.',
|
|
92
|
+
options: ['fix the close command', 'abandon the task'],
|
|
93
|
+
detail: v.detail,
|
|
94
|
+
});
|
|
95
|
+
emit('run-end', { outcome: 'escalated', iterations: iteration });
|
|
96
|
+
return 'escalated';
|
|
97
|
+
}
|
|
98
|
+
gap = v.gap; // feedback for the next middle run; the shell itself learns nothing
|
|
99
|
+
}
|
|
100
|
+
emit('cap-halt', { category: 'cap-halt', meaning: 'not under cap — not "can\'t"', capRuns });
|
|
101
|
+
emit('escalation', {
|
|
102
|
+
category: 'cap-halt', decisionReady: true, verdicts,
|
|
103
|
+
spend: { runs: capRuns, capRuns },
|
|
104
|
+
decision: `${capRuns}/${capRuns} runs spent, close still red. Continue, change approach, or stop?`,
|
|
105
|
+
options: ['raise the cap and rerun', 'change the middle/harness', 'abandon the task'],
|
|
106
|
+
});
|
|
107
|
+
emit('run-end', { outcome: 'escalated', iterations: capRuns });
|
|
108
|
+
return 'escalated';
|
|
109
|
+
}
|
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*$/, '');
|