bareloop 0.5.1 → 0.7.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.
@@ -0,0 +1,160 @@
1
+ // Layer 3 — D3's DISPLAY half: the workflow LISTING, and the prompt that asks for a
2
+ // pick (design record docs/plans/2026-08-01-layer-3-reuse-design.md, D3).
3
+ //
4
+ // hamr's ruling, verbatim: *"i think there should be a listing for workflows names that
5
+ // user can choose or auto-choose, or do we want llm to always decide (we still need to
6
+ // keep a way for user to inspect and view existing workflows … but llm can override and
7
+ // say none matches the ask and that agent has to create a new one)"*.
8
+ //
9
+ // **Both functions are pure text.** Nothing here reads a file, calls a model, or decides
10
+ // a run: the selection CALL, the operator's pin/shortlist/force-cold flow, and the parse
11
+ // of the answer are the runner's (modules 4/5). Choosing is not authoring — the user
12
+ // picks among workflows the agent authored and greens minted, and never writes a step, a
13
+ // check or a plan (PRD v1.28, unbroken).
14
+ //
15
+ // Two display rules are load-bearing rather than cosmetic:
16
+ //
17
+ // - **F6 — unpriced is never free.** An unknown cost or duration renders as UNKNOWN, and
18
+ // a band that skipped unknowns says how many it skipped. A `$0.00` where nothing was
19
+ // priced is a floor that reads as exact, which is the dishonesty F6 exists to stop; the
20
+ // same discipline extends to time.
21
+ // - **A listing shorter than its directory must say so.** `loadRegistry` skips-and-reports
22
+ // a malformed entry; a listing that quietly shows the survivors would leave the picker
23
+ // choosing from a set nobody told it was incomplete — the blind-instrument class.
24
+ //
25
+ // There is deliberately NO score and no ranking beyond name order (D6): n=1 is an anecdote
26
+ // in either direction, a percentage over 2–3 runs is fake precision, and if a score ever
27
+ // earns its way in its threshold is hamr's, from a measured base rate.
28
+
29
+ import { listingRow } from './bridges.js';
30
+
31
+ /** minutes, one decimal — the granularity a human picks a workflow at (the unit is
32
+ * written once, by the band) */
33
+ const minutes = (/** @type {number} */ ms) => (ms / 60_000).toFixed(1);
34
+ const usd = (/** @type {number} */ n) => `$${n.toFixed(2)}`;
35
+
36
+ /**
37
+ * One aggregate, rendered honestly (F6). `min`/`max` are null when NOTHING in the set was
38
+ * known — the answer is UNKNOWN, never a zero — and a partial set carries the count it
39
+ * could not read.
40
+ * @param {number|null} min @param {number|null} max
41
+ * @param {(n: number) => string} fmt
42
+ * @param {number} unknown how many of the greens carried no figure
43
+ * @param {number} total how many greens there are
44
+ * @param {string} word the honest name for a missing figure ('unpriced' | 'untimed')
45
+ * @param {string} [suffix] the unit, written ONCE after the range ("8.9–31.6 min", never
46
+ * "8.9 min–31.6 min")
47
+ */
48
+ function band(min, max, fmt, unknown, total, word, suffix = '') {
49
+ if (min === null || max === null) return `UNKNOWN (${total || 'no'} green${total === 1 ? '' : 's'}, none ${word === 'unpriced' ? 'priced' : 'timed'})`;
50
+ const core = `${min === max ? fmt(min) : `${fmt(min)}–${fmt(max)}`}${suffix}`;
51
+ return unknown > 0 ? `${core} (${unknown} of ${total} ${word})` : core;
52
+ }
53
+
54
+ /**
55
+ * D3's listing: what the operator inspects and what the selecting model reads — the SAME
56
+ * text, so the two can never be choosing from different sets.
57
+ *
58
+ * Per bridge, one compact block: name, the job sentence it greened, status, greens/reds
59
+ * with the last outcome, and the cost/time band of its greens.
60
+ *
61
+ * Total and never throws: a listing is a display surface, and one malformed entry must
62
+ * not take the whole listing down (`listingRow` holds the same contract). Deterministic
63
+ * by name, whatever order the entries arrived in — a set that reshuffles between calls
64
+ * would make one picker's answer unreadable against another's.
65
+ *
66
+ * @param {unknown} registry `loadRegistry`'s result (`{ok, reds, bridges}`) or a bare
67
+ * array of entries. The RESULT form is preferred: it carries the skip reds, and the
68
+ * skips are the half a bare array cannot tell you about.
69
+ * @returns {string}
70
+ */
71
+ export function renderListing(registry) {
72
+ const r = /** @type {any} */ (registry);
73
+ const bridges = (Array.isArray(r) ? r : (Array.isArray(r?.bridges) ? r.bridges : [])).filter((b) => b && typeof b === 'object');
74
+ const reds = Array.isArray(r?.reds) ? r.reds : [];
75
+ const rows = bridges.map(listingRow).sort((a, b) => String(a.name).localeCompare(String(b.name)));
76
+
77
+ const skipped = reds.length
78
+ ? [`\n\nNOTE: ${reds.length} registry entr${reds.length === 1 ? 'y' : 'ies'} could not be read and ${reds.length === 1 ? 'is' : 'are'} NOT listed — this listing is smaller than the directory:`,
79
+ ...reds.map((/** @type {any} */ x) => ` ${x.path ?? '(unknown file)'}${x.detail ? ` — ${x.detail}` : ''}`)].join('\n')
80
+ : '';
81
+
82
+ if (!rows.length) {
83
+ return `WORKFLOWS ON RECORD — none.\nThere are no workflows to reuse; a plan has to be drafted from scratch.${skipped}`;
84
+ }
85
+
86
+ const blocks = rows.map((row) => {
87
+ const g = row.greenCost;
88
+ return [
89
+ `[${row.name ?? '(unnamed)'}] ${(row.status ?? 'no-green').toUpperCase()}`,
90
+ ` goal ${row.goal ?? '(no goal recorded)'}`,
91
+ ` runs ${row.greens} green · ${row.reds} red${row.lastOutcome ? ` (last: ${row.lastOutcome})` : ''}`,
92
+ ` green cost ${band(g.minUsd, g.maxUsd, usd, g.unpricedCount, row.greens, 'unpriced')}`
93
+ + ` · time ${band(g.minWallMs, g.maxWallMs, minutes, g.untimedCount, row.greens, 'untimed', ' min')}`,
94
+ ].join('\n');
95
+ });
96
+
97
+ return `WORKFLOWS ON RECORD — ${rows.length}\nCANDIDATE = greened once · PROVEN = greened on two distinct repositories. There is deliberately no success score.\n\n${blocks.join('\n\n')}${skipped}`;
98
+ }
99
+
100
+ /**
101
+ * The SELECT-WORKFLOW prompt: the text a later module sends to ask which workflow (if
102
+ * any) the run should start from. Pure — it sends nothing and parses nothing.
103
+ *
104
+ * D3's two standing rules are IN the prompt rather than enforced after the fact:
105
+ * - *"none matches"* is a first-class answer, and it means the agent drafts new. A picker
106
+ * with no way to decline would return the closest thing on the shelf every time, which
107
+ * is how a registry becomes a lookup table.
108
+ * - a PINNED workflow may be refused **only explicitly, with a reason** — never silently
109
+ * ignored or quietly substituted. The operator's pick is the operator's; overriding it
110
+ * invisibly is the one thing a picker must not be able to do.
111
+ *
112
+ * The output contract is stated so the answer is machine-readable, but the PARSE belongs
113
+ * to the runner that sends this (modules 4/5) — and the runner's parser and this shape are
114
+ * one contract in two places, which is why the shape is spelled out here rather than left
115
+ * to the model.
116
+ *
117
+ * @param {string} listing `renderListing`'s text — verbatim, so the picker sees exactly
118
+ * what the human inspecting the registry sees
119
+ * @param {string} ask the job's goal sentence, in the operator's own words
120
+ * @param {string|null} [pinned] the operator's PIN, stated IN the call. D3 rules that a
121
+ * pin is never bypassed and never silently overridden — so the pin does not skip the
122
+ * call, it enters it, and the model's only legal ways out are confirming it or
123
+ * refusing it by name with a reason. Stated here rather than assembled by the runner:
124
+ * a second spelling of the pin sentence is a second prompt, and the two would drift.
125
+ * @returns {string}
126
+ */
127
+ export function selectionPrompt(listing, ask, pinned = null) {
128
+ const pin = pinned
129
+ ? `\nThe operator has PINNED the workflow "${pinned}". Start from it unless it is genuinely the
130
+ wrong kind of recipe for this ask. If you cannot use it, you must say so by picking a
131
+ DIFFERENT name (or null) and giving the reason — the operator's pick is then handed back
132
+ to the operator, never quietly replaced by yours.\n`
133
+ : '';
134
+ return `SELECT-WORKFLOW
135
+ A previous run's workflow can be handed to the planner as a STARTING DRAFT for a new job —
136
+ it saves the planner starting from a blank page. Your job is to decide whether any workflow
137
+ on record is close enough to be worth starting from, or whether this ask needs a new plan.
138
+
139
+ The ask:
140
+ ${ask}
141
+
142
+ ${listing}
143
+ ${pin}
144
+ Rules:
145
+ - Pick AT MOST ONE workflow, by its exact name, copied character for character from the listing.
146
+ - A workflow is worth reusing when it did the same KIND of work — the same sort of change,
147
+ judged the same sort of way. It does NOT have to name the same files: it ran on a different
148
+ repository, so its paths, scopes and targets are expected to be wrong here and the planner
149
+ will replace them.
150
+ - If none of them matches this ask, say so plainly and pick nothing.
151
+ The agent then drafts a new plan from scratch. "None matches" is a real answer, and a bad
152
+ starting draft costs more than a blank page.
153
+ - If the operator PINNED a workflow, you may refuse it — but only EXPLICITLY, and with a
154
+ reason. Never silently ignore the pin or quietly substitute another one.
155
+ - You are choosing, not authoring. You cannot edit, merge or invent a workflow here; the
156
+ planner does the tweaking afterwards, and the job's own verification is the only truth.
157
+
158
+ Output ONLY this JSON object, no fences and no commentary:
159
+ {"choice": "<exact name from the listing>" or null, "reason": "<one sentence>"}`;
160
+ }
package/src/spine.js CHANGED
@@ -11,10 +11,15 @@ import { appendFileSync } from 'node:fs';
11
11
  /**
12
12
  * Create an emitter bound to one JSONL file.
13
13
  * @param {string} file absolute path; created on first emit
14
+ * @param {{startSeq?: number}} [opts] `startSeq`: the last `seq` already IN the file, for a
15
+ * process that CONTINUES an existing spine (a resumed run appends to the dead run's own
16
+ * log — module C). `seq` is monotonic per SPINE, so a second series restarting at 1
17
+ * would give one append-only log two rows with the same number and silently break every
18
+ * reader that orders by it. Default 0: a fresh file, which is every other caller.
14
19
  * @returns {(type: string, data?: object) => object} emit — returns the event as written
15
20
  */
16
- export function makeSpine(file) {
17
- let seq = 0;
21
+ export function makeSpine(file, { startSeq = 0 } = {}) {
22
+ let seq = typeof startSeq === 'number' && Number.isFinite(startSeq) && startSeq > 0 ? Math.floor(startSeq) : 0;
18
23
  return function emit(type, data = {}) {
19
24
  // type/seq/ts are the envelope's, by mechanism not comment: callers spread
20
25
  // foreign objects into events, and a payload that grows one of these keys
package/src/stall.js ADDED
@@ -0,0 +1,252 @@
1
+ // The stall watchdog (F66) — the only instrument that fires on the ABSENCE of ROUNDS.
2
+ //
3
+ // MEASURED reason this exists. bare-agent's `timeoutMs` is an IDLE-SOCKET timeout:
4
+ // `applyRequestTimeout` is one `req.setTimeout(...)` in provider-http.js, and Node
5
+ // resets that timer on every byte. Its own docstring is explicit — "the timer
6
+ // resets on activity, so a slow-but-streaming response is not killed". On U run
7
+ // ms3197n8 the connection stayed alive for 274 minutes with ZERO rounds emitted,
8
+ // then died `read ECONNRESET`. bare-agent's timer never fired; the run spent
9
+ // 287.3min against a 45min wall and $3.23 for nothing. The bound was correctly
10
+ // derived and correctly forwarded (loop.js:732 passes options per provider call) —
11
+ // it just watches bytes, and bytes kept flowing while the run made no progress.
12
+ //
13
+ // So the heartbeat here is A COMPLETED ROUND, not a byte. That swap IS the fix,
14
+ // and it is the one signal bare-agent cannot see from inside the HTTP request.
15
+ //
16
+ // SELF-HEAL, not a stop (hamr's ruling): "the goal is always self heal and killing
17
+ // and coming back is not an option — what would we tell the user, it timed out, do
18
+ // something?!" A stall abandons the call and REISSUES it; the operator never hears
19
+ // about it. Only after `maxStalls` does the watch give up, and even then it does
20
+ // not end the run — it throws `step-stalled`, which the step loop reads as a
21
+ // replan trigger. The run adapts; it does not surrender.
22
+ //
23
+ // What still bounds this, so self-heal cannot run away: the wallet is unchanged (a
24
+ // reissue spends from the same signed budget), the wall clock still ends the run,
25
+ // and the step's round bound is untouched. The one honest cost is that an
26
+ // abandoned call MAY already have been billed, so a reissue can pay twice — that
27
+ // is capped by the wallet, and unknown spend is reported as unknown, never zero
28
+ // (F6/F44). It is also exactly why `Retry` was left unwired; self-heal is the
29
+ // ruling that overrides it.
30
+ //
31
+ // L4 — "the wall clock still ends the run" was the half-true half of that. The
32
+ // clock is consulted where a ROUND completes (planrun's `metered`) and after a step
33
+ // returns; during a stall no round completes and no step returns, so nothing read it
34
+ // between the wall passing and the next reissue. A worker stalling at its wall could
35
+ // therefore be reissued for up to maxStalls × stallMs of calls the run had already
36
+ // declined to authorize. So the watch takes the wall as a callback and consults it
37
+ // BEFORE reissuing: past the deadline it does not self-heal, it gives up. That is
38
+ // not a retreat from hamr's ruling — self-heal is what a run does with time left,
39
+ // and there is none.
40
+ //
41
+ // Timers are injected so the whole mechanism is deterministically testable — a
42
+ // watchdog validated with real sleeps is a flake generator, and one that is never
43
+ // watched failing is not validated at all.
44
+
45
+ /** hamr's number: no completed round for five minutes is a stall. */
46
+ export const STALL_MS = 300_000;
47
+ /** hamr's number: three stalls, then stop reissuing and replan. The count is
48
+ * CUMULATIVE over the watch's whole life and `watch()` never resets it — planrun
49
+ * builds one watch per WORKER, so the ceiling is spent across the STEP, not per
50
+ * call: two stalls on one call and one on the next give up just the same. That
51
+ * is deliberate (a step that keeps hanging is a step to replan, not a call to
52
+ * retry forever), and it is why nothing here says "on one call". */
53
+ export const MAX_STALLS = 3;
54
+
55
+ /**
56
+ * A stall gave up. Carries the category the step loop's replan trigger reads and
57
+ * the `lib` attribution stamped AT THE THROW SITE (never sniffed from prose).
58
+ */
59
+ export class StallError extends Error {
60
+ /** @param {string} message @param {number} stalls */
61
+ constructor(message, stalls) {
62
+ super(message);
63
+ this.name = 'StallError';
64
+ /** @type {string} */
65
+ this.category = 'step-stalled';
66
+ /** @type {string} */
67
+ this.lib = 'bare-agent';
68
+ /** @type {number} */
69
+ this.stalls = stalls;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * @typedef {Object} StallWatch
75
+ * @property {<T>(call: (gen: number) => Promise<T>) => Promise<T>} watch run `call`,
76
+ * reissuing it whenever it goes `stallMs` without a beat; rejects `StallError` past
77
+ * `maxStalls`. Each issue is handed its GENERATION — the token every callback that
78
+ * call installs must carry back, so a corpse's callbacks cannot touch the live call.
79
+ * @property {(gen: number) => void} beat a round completed on generation `gen` — reset
80
+ * the window. From any other generation it is inert.
81
+ * @property {(gen: number) => boolean} isCurrent is `gen` still the call this watch is
82
+ * waiting on? The caller reads it to withhold everything else a dead call would do.
83
+ * @property {() => number} stalls how many stalls this watch has absorbed
84
+ */
85
+
86
+ /**
87
+ * Start a stall watchdog.
88
+ * @param {{ stallMs?: number, maxStalls?: number, onStall?: (n: number) => void,
89
+ * expired?: () => boolean,
90
+ * setTimer?: (fn: () => void, ms: number) => any,
91
+ * clearTimer?: (id: any) => void }} [opts]
92
+ * `onStall` is announced for EVERY stall including the last — a trim or a
93
+ * recovery that happens silently is indistinguishable from one that never
94
+ * happened (F28's rule, applied to time).
95
+ * `expired` is the run's WALL, read at the one moment this module decides to
96
+ * spend again (L4). Omitted, the watch behaves exactly as it did before the wall
97
+ * existed — a caller with no clock reissues on the same rules.
98
+ * @returns {StallWatch}
99
+ */
100
+ export function createStallWatch({
101
+ stallMs = STALL_MS,
102
+ maxStalls = MAX_STALLS,
103
+ onStall = () => {},
104
+ expired = () => false,
105
+ setTimer = setTimeout,
106
+ clearTimer = clearTimeout,
107
+ } = {}) {
108
+ let stalls = 0;
109
+ /** @type {any} */
110
+ let timerId = null;
111
+ /** the generation of the call currently being watched: NOTHING from an ABANDONED
112
+ * call may touch the one that replaced it — not its trip, not its late settlement,
113
+ * and not its beats. The corpse keeps streaming (ms3197n8's socket lived 274min
114
+ * after we would have moved on), so every callback it installed keeps firing; the
115
+ * generation is the token that tells those callbacks apart from the live call's.
116
+ * It is handed to each issued call and carried back through `beat`/`isCurrent`. */
117
+ let generation = 0;
118
+ let settled = true;
119
+ /** @type {((v: any) => void) | null} */
120
+ let resolve = null;
121
+ /** @type {((e: any) => void) | null} */
122
+ let reject = null;
123
+ /** @type {((gen: number) => Promise<any>) | null} */
124
+ let theCall = null;
125
+
126
+ const disarm = () => {
127
+ if (timerId !== null) { clearTimer(timerId); timerId = null; }
128
+ };
129
+
130
+ /** @param {number} gen */
131
+ function arm(gen) {
132
+ disarm();
133
+ timerId = setTimer(() => {
134
+ timerId = null;
135
+ if (settled || gen !== generation) return; // a trip from a superseded call
136
+ trip();
137
+ }, stallMs);
138
+ }
139
+
140
+ function trip() {
141
+ stalls += 1;
142
+ onStall(stalls);
143
+ generation += 1; // everything from the abandoned call is now inert
144
+ // L4 — the WALL, read before the decision to spend again, and read FIRST. A
145
+ // reissue past the deadline is money and time the run has already declined to
146
+ // authorize, and it is the only spend on this path nothing else can catch: no
147
+ // round completes during a stall, so `metered`'s clock check never fires, and no
148
+ // step returns, so the step loop's never does either.
149
+ //
150
+ // It outranks the `maxStalls` ceiling deliberately, and the category is the
151
+ // reason. `step-stalled` is a REPLAN TRIGGER (planrun's third trigger): it says
152
+ // "the model stopped answering, re-allocate what is left" and buys a fresh
153
+ // drafting round. On an expired wall there is nothing left to re-allocate, so
154
+ // that route would spend a draft on a run that is over and hand the operator
155
+ // "retry the run / check provider status" for a stop whose only remedy is
156
+ // `maxWallMs`. `wall-halt` is the truthful reading — the wall bound the run, not
157
+ // the model — and it is the SAME ruling clock.js already made for the F64
158
+ // ambiguity: a call that dies on a run whose wall has also passed reads as a
159
+ // wall-halt, because the run is out of time either way and the remedy is
160
+ // identical. No new category, no new routing: every consumer (ralph's
161
+ // DECISIONS, the step loop's terminal, `relay`, the ledger's exclusions) already
162
+ // handles `wall-halt`, and none of them replans on it.
163
+ if (expired()) {
164
+ settled = true;
165
+ disarm();
166
+ // Not a StallError: that class IS the `step-stalled` category, and a stall
167
+ // count is what happened on the way here, not what stopped it. No `lib`
168
+ // either — the wall is bareloop's own governance instrument, and stamping an
169
+ // upstream package would stand a fake bug of ours where real evidence goes.
170
+ const err = /** @type {any} */ (new Error(
171
+ `the run's wall-clock cap passed while this call was stalled (${stalls} stall${stalls === 1 ? '' : 's'}) — not reissuing`,
172
+ ));
173
+ err.category = 'wall-halt';
174
+ err.stalls = stalls;
175
+ reject?.(err);
176
+ return;
177
+ }
178
+ if (stalls >= maxStalls) {
179
+ settled = true;
180
+ disarm();
181
+ reject?.(new StallError(
182
+ `no round completed for ${Math.round(stallMs / 1000)}s, ${stalls} times in this step — not reissuing again`,
183
+ stalls,
184
+ ));
185
+ return;
186
+ }
187
+ issue(); // self-heal: abandon the hung call, start a fresh one
188
+ }
189
+
190
+ function issue() {
191
+ const gen = generation;
192
+ arm(gen);
193
+ let p;
194
+ try {
195
+ // the call is TOLD its generation: the callbacks it wires (metering, the round
196
+ // bound, the wall cut) outlive the call itself, and this is the only token that
197
+ // lets them ask "am I still the live one?".
198
+ p = /** @type {(g: number) => Promise<any>} */ (theCall)(gen);
199
+ } catch (e) { // a synchronous throw is a real failure, not a stall
200
+ settled = true;
201
+ disarm();
202
+ reject?.(e);
203
+ return;
204
+ }
205
+ p.then(
206
+ (v) => {
207
+ if (settled || gen !== generation) return; // an abandoned call finally answered
208
+ settled = true;
209
+ disarm();
210
+ resolve?.(v);
211
+ },
212
+ (e) => {
213
+ // The abandoned socket dies LATER — ms3197n8's did, at +274min. Nobody is
214
+ // listening any more, and an unhandled rejection here would take the whole
215
+ // run down. Swallowing it is the point, not an oversight.
216
+ if (settled || gen !== generation) return;
217
+ settled = true;
218
+ disarm();
219
+ reject?.(e);
220
+ },
221
+ );
222
+ }
223
+
224
+ /** @type {<T>(call: (gen: number) => Promise<T>) => Promise<T>} */
225
+ function watch(call) {
226
+ theCall = call;
227
+ settled = false;
228
+ generation += 1;
229
+ return new Promise((res, rej) => {
230
+ resolve = res;
231
+ reject = rej;
232
+ issue();
233
+ });
234
+ }
235
+
236
+ return {
237
+ stalls: () => stalls,
238
+ // A beat is SCOPED TO ITS CALL. Rounds do arrive from a call we abandoned, and
239
+ // re-arming on one is not a harmless late timer: it re-arms the watch that is
240
+ // timing the REPLACEMENT, so the corpse feeds the watchdog and the live call can
241
+ // hang for the rest of the run without ever tripping. A beat after the watch has
242
+ // settled is inert for the same reason.
243
+ /** @param {number} gen the generation `watch` handed the call that produced this round */
244
+ beat(gen) {
245
+ if (settled || gen !== generation) return;
246
+ arm(generation);
247
+ },
248
+ /** @param {number} gen @returns {boolean} */
249
+ isCurrent: (gen) => !settled && gen === generation,
250
+ watch,
251
+ };
252
+ }