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/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';
@@ -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*$/, '');