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.
package/src/clock.js ADDED
@@ -0,0 +1,233 @@
1
+ // T's wall clock — the shell's time instrument (PRD v1.27/v1.29; materials
2
+ // design record 2026-07-26, addenda 1 and 3).
3
+ //
4
+ // Time is arbiter territory with exactly `budgetUsd`'s status: operator input,
5
+ // the agent may tighten and never widen, never self-raised. The agent has no time
6
+ // field in the plan vocabulary at all (addendum 2), so nothing here is reachable
7
+ // from an agent-authored document — this module is read by the runner only.
8
+ //
9
+ // Three measured facts shape it, none of them assumed:
10
+ //
11
+ // 1. `loop.stop()` CANNOT cut an in-flight call. Measured (F61/C1): fired at
12
+ // 500ms against a 4,000ms hanging generate(), run() returned at 4,018ms.
13
+ // bare-agent reads the flag at the round boundary (loop.js:661) and between
14
+ // tool calls (:916). So a wall deadline is a BETWEEN-ROUND check, and
15
+ // enforcement is `maxWallMs + closeStages × closeTimeoutMs` — reported, never
16
+ // rounded down to the requested number (F6 in a time coat). The multiplier is
17
+ // W5: a STAGED close (PRD v1.28) runs its stages one at a time and hands EACH
18
+ // one the full `closeTimeoutMs` (`runStages` → `runClose`, ralph.js:257), so a
19
+ // 4-stage close at 900s is 60 minutes of close, not 15. The runner's own
20
+ // outside watchdog already sized its stale window this way
21
+ // (scripts/run-u.mjs: `CLOSE_TIMEOUT_MS * spec.close.length`); the clock
22
+ // quoting the 1× number was the same arithmetic disagreeing with itself.
23
+ // That figure counts ONE close, and the RUNNER is what holds both of its loops
24
+ // to one: past the deadline it refuses to open a new fix iteration and stops on
25
+ // the verdict the last close already minted (W-2), and it refuses to open a
26
+ // STEP's attempt on the same reading — a step that would begin past the deadline
27
+ // is never funded. Before that second refusal a step starting with zero time left
28
+ // burned every attempt it was allowed (measured on the real flow: three attempts,
29
+ // three judged exits, all past the deadline), because the variance meter scores a
30
+ // step's time share against what the run had left when the step BEGAN and so is
31
+ // structurally blind to a step that began with nothing.
32
+ // It is still not a universal bound and is not claimed as one. Two overshoots
33
+ // remain outside this number, both deliberate. A step ALREADY UNDER WAY when the
34
+ // deadline trips finishes its attempt and runs its own exit checks (themselves
35
+ // close stages, at the full timeout each): grading real work always completes, or
36
+ // the run is unreadable after the money is spent. And that same mid-step deadline
37
+ // still routes through the ONE replan — the meter's own terminal, unchanged — so
38
+ // it pays a drafting call (floored at MIN_CALL_TIMEOUT_MS) before the new plan's
39
+ // first step is the one refused.
40
+ // 2. BA-18's provider timeout is the only instrument that fires on the ABSENCE
41
+ // of events. Measured (F61/C2): 1,259ms against a socket that accepts and
42
+ // never answers; without it the bound is the OS TCP timeout (~2h). Deriving
43
+ // it from the remaining budget is what stops the LAST round outliving the run.
44
+ // 3. ~90% accuracy is the bar, by hamr's ruling (addendum 3). This module does
45
+ // not chase precision; it reports honestly at the precision it has.
46
+ //
47
+ // Unbounded is a REAL state, not a large number: hamr ruled `maxWallMs` has no
48
+ // default, so a run without one is time-unbounded by explicit operator choice and
49
+ // says so (F45 — a defaulted cap is a silent second ceiling).
50
+
51
+ /** BA-18's default request/idle timeout (`AnthropicProvider.timeoutMs`), the
52
+ * ceiling every derived per-call timeout starts from. Kept in sync deliberately:
53
+ * a value ABOVE the provider's own default would be inert. */
54
+ export const PROVIDER_TIMEOUT_MS = 600_000;
55
+
56
+ /**
57
+ * The floor for a derived per-call timeout. Below this a call cannot plausibly
58
+ * succeed, and a timeout that is certain to trip manufactures a `provider-red`
59
+ * out of a time budget — a transport CASUALTY, which is never evidence
60
+ * (F45/F48). The deadline check is what must stop a run out of time; the per-call
61
+ * timeout exists to bound a HANG, not to enforce the budget's last second.
62
+ */
63
+ export const MIN_CALL_TIMEOUT_MS = 30_000;
64
+
65
+ /** bare-agent's rejection code when its request/idle timeout trips (BA-18,
66
+ * `provider-http.js` → `TimeoutError`). The error carries a `code` and NO
67
+ * `category`, so every consumer that defaults an uncategorised throw would file
68
+ * it as transport. */
69
+ export const TIMEOUT_CODE = 'ETIMEDOUT';
70
+
71
+ /** bare-agent's rejection code when its TOTAL-duration deadline trips (BA-19,
72
+ * `provider-http.js` → `applyRequestDeadline`). A DIFFERENT code from the idle
73
+ * bound's on purpose — upstream splits them precisely so a consumer routing
74
+ * governance stops apart from transport casualties can tell which timer fired. Like
75
+ * `TIMEOUT_CODE` it carries no `category`, so every default-to-transport catch site
76
+ * would file it as a casualty. */
77
+ export const DEADLINE_CODE = 'EDEADLINE';
78
+
79
+ /**
80
+ * F64 — is this throw the run's OWN deadline coming back dressed as a provider
81
+ * error? `callTimeoutMs()` hands the provider a bound derived from the wall, so
82
+ * when the wall is what shrank that bound, the resulting `TimeoutError` is a
83
+ * GOVERNANCE stop, not a casualty. Filing it as `provider-red` would discard it
84
+ * (a casualty is never evidence, F45/F48) and attach the F44
85
+ * `spendComplete:false` floor to a run that simply ran out of time.
86
+ *
87
+ * The discriminator is the wall itself, not the derived number: on a bounded
88
+ * clock a trip can only precede expiry when the bound came from the provider's
89
+ * own default (`remainingMs() > PROVIDER_TIMEOUT_MS`), and that case is transport
90
+ * by construction. So "already expired" IS the wall-derived set. An unbounded run
91
+ * can never produce one, and needs no separate `bounded` term to say so —
92
+ * `expired()` is false by construction with no cap. (A mutation run proved the
93
+ * extra term inert; the unbounded case is guarded by its own test instead, which
94
+ * is where that invariant belongs.)
95
+ *
96
+ * A genuine dead socket on a run whose wall has ALSO passed reads as a wall-halt.
97
+ * That is deliberate and honest: the run is out of time either way, the remedy is
98
+ * the same (raise `maxWallMs`), and the alternative — calling a governance stop
99
+ * transport on the chance that it was — is the failure this closes.
100
+ *
101
+ * BOTH derived bounds land here, because both are derived from the same wall:
102
+ * BA-18's idle timeout (`callTimeoutMs`) and BA-19's total-duration deadline
103
+ * (`callDeadlineMs`). Adding the second CODE does not widen the rule — the
104
+ * discriminator is still `clock.expired()`, never the code. That matters for the
105
+ * deadline specifically: a provider someone else constructed with its own
106
+ * `deadlineMs` could throw this code on a run with time left, and that is transport,
107
+ * exactly as it was before.
108
+ *
109
+ * @param {any} err the throw as caught
110
+ * @param {Clock} clock the run's clock
111
+ * @returns {boolean}
112
+ */
113
+ export function isWallTimeout(err, clock) {
114
+ return !!err && (err.code === TIMEOUT_CODE || err.code === DEADLINE_CODE) && clock.expired();
115
+ }
116
+
117
+ /**
118
+ * @typedef {object} Clock
119
+ * @property {boolean} bounded whether the operator set a cap at all
120
+ * @property {number|null} requestedMs the operator's number; `null` when unbounded
121
+ * @property {number} closeStages how many close stages one verdict can pay for (W5)
122
+ * @property {() => number} elapsedMs wall time since the clock was created
123
+ * @property {() => number} remainingMs what is left, floored at 0; `Infinity` when unbounded
124
+ * @property {() => boolean} expired whether the deadline has passed (always false when unbounded)
125
+ * @property {(closeTimeoutMs: number) => number|null} enforcedMs the HONEST worst case
126
+ * @property {(providerDefaultMs?: number) => number} callTimeoutMs the derived per-call bound
127
+ * @property {() => number|null} callDeadlineMs the derived per-call TOTAL-duration ceiling; `null` when unbounded
128
+ * @property {(closeTimeoutMs: number) => object} report one spine-safe shape carrying both numbers
129
+ */
130
+
131
+ /**
132
+ * Start a wall clock for one run.
133
+ * @param {{ maxWallMs?: number|null, closeStages?: number, now?: () => number, priorElapsedMs?: number }} [opts]
134
+ * `maxWallMs`: the signed operator cap, or absent/null for an honestly unbounded
135
+ * run. `closeStages`: how many stages the job's close runs (W5) — each one gets
136
+ * the FULL `closeTimeoutMs`, so this is the multiplier on the overshoot, not a
137
+ * decoration. Defaults to 1, the single-predicate close. `now`: injected for
138
+ * tests — the default is the real clock. `priorElapsedMs`: time this attempt's
139
+ * PREDECESSOR already consumed (see below).
140
+ * @returns {Clock}
141
+ */
142
+ export function createClock({ maxWallMs = null, closeStages = 1, now = () => Date.now(), priorElapsedMs = 0 } = {}) {
143
+ // RESUME (module C) — hamr's checkpoint ruling in a time coat: a run killed
144
+ // mid-try restarts THAT try under the REMAINDER of its signed per-try numbers,
145
+ // because "a budget ceiling folds in prior spend so re-invoking cannot silently
146
+ // widen it". The fold happens HERE rather than by editing `maxWallMs`, and that
147
+ // is the whole point: the cap the operator signed stays the number reported and
148
+ // the number hashed (a rewritten cap is a new spec version needing a new
149
+ // signature), while the clock simply starts already partly spent. A fresh
150
+ // allotment on every kill would let a resumed run outlive the worst case the
151
+ // operator signed, one kill at a time.
152
+ //
153
+ // Belted like `maxWallMs` above: a non-finite, negative or garbage fold is 0. A
154
+ // NaN here would make every comparison false and silently unbound the run, which
155
+ // is the inverse of what the fold exists to do.
156
+ const prior = typeof priorElapsedMs === 'number' && Number.isFinite(priorElapsedMs) && priorElapsedMs > 0 ? priorElapsedMs : 0;
157
+ const startedAt = now() - prior;
158
+ // A cap is only a cap if it is a usable positive number. validateJob already
159
+ // reds anything else, so this is the belt (a caller that hand-builds a spec
160
+ // must not be able to turn a garbage value into a silent unbounded run OR a
161
+ // silent instant expiry).
162
+ const bounded = typeof maxWallMs === 'number' && Number.isFinite(maxWallMs) && maxWallMs > 0;
163
+ const cap = bounded ? maxWallMs : null;
164
+ // The same belt on the stage count. A close always runs at least once, so 1 is
165
+ // the floor: a 0 or a NaN sneaking in here would quote an enforced number BELOW
166
+ // the requested cap, which is the dishonesty this multiplier exists to remove.
167
+ const stages = typeof closeStages === 'number' && Number.isFinite(closeStages) && closeStages >= 1
168
+ ? Math.floor(closeStages) : 1;
169
+ /** the ONE arithmetic, so the advertised record and the enforced worst case
170
+ * cannot be computed two ways (the two-transforms class, F9). */
171
+ const enforced = (/** @type {number} */ closeTimeoutMs) => (cap === null ? null : cap + stages * closeTimeoutMs);
172
+
173
+ const elapsedMs = () => now() - startedAt;
174
+ const remainingMs = () => (cap === null ? Infinity : Math.max(0, cap - elapsedMs()));
175
+ // Inclusive: AT the deadline is out of budget. A run resumes to cap, and the
176
+ // stop IS the checkpoint (v1.12) — so stopping one round early is correct and
177
+ // stopping one round late is the overshoot addendum 1 quantifies.
178
+ const expired = () => cap !== null && elapsedMs() >= cap;
179
+
180
+ return {
181
+ bounded,
182
+ requestedMs: cap,
183
+ closeStages: stages,
184
+ elapsedMs,
185
+ remainingMs,
186
+ expired,
187
+ enforcedMs: enforced,
188
+ callTimeoutMs: (providerDefaultMs = PROVIDER_TIMEOUT_MS) => {
189
+ if (cap === null) return providerDefaultMs;
190
+ // Clamped BOTH ways. Upper: never above the provider's own default, which
191
+ // would be inert. Lower: never below MIN_CALL_TIMEOUT_MS, and in
192
+ // particular never 0 — `0` DISABLES the provider timeout entirely
193
+ // (provider-anthropic.js), which is the exact opposite of the intent and
194
+ // would restore the ~2h OS-TCP hang on the last round of a tight budget.
195
+ return Math.min(providerDefaultMs, Math.max(MIN_CALL_TIMEOUT_MS, remainingMs()));
196
+ },
197
+ // BA-19 (F72 park D) — the TOTAL-duration ceiling beside the idle one. The idle
198
+ // bound resets on every byte, so a response that trickles forever never trips it
199
+ // (F66's 274-minute call, and F67's 81.5-minute freeze happened above it); this
200
+ // one is a plain timer that no byte resets.
201
+ //
202
+ // Three properties, each a rule this repo already paid for:
203
+ // - NULL when unbounded. An operator who set no wall gets no ceiling — a
204
+ // defaulted cap is a silent second ceiling (F45, and the reason `maxWallMs`
205
+ // has no default). The caller must OMIT the knob on null, not pass a number.
206
+ // - REMAINING, read at call time. A constant would bound the last call by the
207
+ // first call's budget, which is the thing `callTimeoutMs` already refuses.
208
+ // - NOT clamped to the provider default. That upper clamp exists on the idle
209
+ // bound because a value above the provider's own default is inert; a
210
+ // total-duration ceiling above it is the entire point of BA-19.
211
+ // The lower floor is shared with `callTimeoutMs` and for the same two reasons: a
212
+ // bound certain to trip manufactures a stop out of arithmetic, and `0` DISABLES
213
+ // the bound upstream (`if (!(deadlineMs > 0)) return`) — the exact inverse of the
214
+ // intent. A trip past the floor is still honest: the wall has expired by then, so
215
+ // `isWallTimeout` reads it as the governance stop it is.
216
+ callDeadlineMs: () => (cap === null ? null : Math.max(MIN_CALL_TIMEOUT_MS, remainingMs())),
217
+ report: (closeTimeoutMs) => ({
218
+ bounded,
219
+ requestedMs: cap,
220
+ // On the record, not just in the arithmetic: without it a reader seeing
221
+ // enforced 90min against a requested 30min cannot tell a 60-minute close
222
+ // timeout from four 15-minute stages, and an unexplained gap is exactly the
223
+ // kind of number a later reader rounds back down to the requested one.
224
+ closeStages: stages,
225
+ enforcedMs: enforced(closeTimeoutMs),
226
+ elapsedMs: elapsedMs(),
227
+ // null, not Infinity: JSON.stringify turns Infinity into null anyway, and
228
+ // an append-only record must not depend on that coincidence — worse, a
229
+ // downstream `?? 0` on an Infinity would read as "no time left" (F6 class).
230
+ remainingMs: cap === null ? null : remainingMs(),
231
+ }),
232
+ };
233
+ }
package/src/exits.js CHANGED
@@ -9,8 +9,14 @@
9
9
  // F45) and never gate intent (the F43 identical-refire trap: an allowed
10
10
  // write that lands the same bytes is NOT a change).
11
11
  // - a failing exit's detail is a MECHANICAL gap (a named wall, a count) —
12
- // the genre that converts (F38/F46); details carry names and counts only,
13
- // never file bodies (they ride the spine, which is append-only forever).
12
+ // the genre that converts (F38/F46). One detail is NOT names-and-counts:
13
+ // `json-valid` embeds `JSON.parse`'s own message and V8 quotes a window of
14
+ // the SOURCE inside it, so it can carry file bytes the worker chose. The
15
+ // CALLER scrubs, not this module: `runPlan` redacts every detail (the ONE
16
+ // `SECRET_PATTERNS` inventory) at the emission boundary, before anything
17
+ // rides the spine, which is append-only forever. `evalExits` is a public
18
+ // export — a direct caller wiring results into its own log applies its own
19
+ // redaction.
14
20
  // - `check-passes` delegates through the `runCheck` seam the runner wires
15
21
  // to the full runClose machinery; the evaluator itself never spawns. An
16
22
  // unwired or throwing check fails CLOSED (the broken-close class: an
package/src/index.js CHANGED
@@ -1,29 +1,64 @@
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
- // N1: the operator-owned job spec (job-v1) — validateJob is validate.js's
5
- // sibling (never an extension); jobSpecHash/checkApproval are the pure half
6
- // of human-signs-always (the N2 runner enforces).
7
- // N2: runJob — the runner: approval gate → primitive smoke → sealed priced
8
- // draft → sequential per-step loops under the ONE ledger (unpriced is never
9
- // free, F6) → the hitl step is the decision-ready escalation. Module 4: the
10
- // upstream ledger — spines fold into one append-only incident file; filing
11
- // stays human (suggestedAsk is a seed, never an auto-file).
1
+ // bareloop public surface — grows one rung at a time (PRD §10). The modules
2
+ // compose bottom-up: makeSpine feeds ralph, ralph is the wheel every layer is
3
+ // made of, and runJob is the one entry.
4
+ // The operator-owned job spec (job-v1) is the arbiter's rulebook — validateJob
5
+ // and validatePlan are siblings, never extensions of each other; jobSpecHash /
6
+ // checkApproval are the pure half of human-signs-always (the runner enforces).
7
+ // runJob: approval gate → primitive smoke → the plan flow under the ONE ledger
8
+ // (unpriced is never free, F6). The upstream ledger folds spines into one
9
+ // append-only incident file; filing stays human (suggestedAsk is a seed, never
10
+ // an auto-file).
11
+ //
12
+ // The config-v1 exports (`validateConfig`, `interpret`, `extractRules`,
13
+ // `diffPaths`, `LOOP_SHAPES`, `SLOTS`, `VERBS`, `STALL_REDS`, `CLASSES`,
14
+ // `STEP_MODES`) were REMOVED 2026-07-26 with the operator-authored steps[] path
15
+ // (PRD v1.32) — a breaking change, and the reason for the version bump.
12
16
 
13
17
  export { makeSpine } from './spine.js';
14
18
  // CLOSE_FAULTS/scanSecrets: bareloop.context.md documents both as public API —
15
19
  // the exports make the adopter contract true (release review 2026-07-19; the
16
20
  // exports map admits only ".", so a deep import cannot reach them).
17
- export { ralph, runClose, CLOSE_FAULTS } from './ralph.js';
18
- export { validateConfig, diffPaths, globToPrefix, scanSecrets, LOOP_SHAPES, SLOTS, VERBS } from './validate.js';
19
- export { validateJob, jobSpecHash, checkApproval, CLOSE_TYPES, CLASSES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS, STEP_MODES, TOOL_MENU, LOCKED_TOOLS, VERDICT_TYPES, LOCKED_VERDICTS } from './job.js';
21
+ // runStages/checkMenu/STORE_VERBS: same reason, one rung later (review 2026-07-30)
22
+ // — the CHANGELOG announces runStages async beside runClose and STORE_VERBS beside
23
+ // the exported WRITE_VERBS, and bareloop.context.md points adopters at checkMenu by
24
+ // name. Documented-but-unexported is a false contract; the additions are additive.
25
+ export { ralph, runClose, runStages, CLOSE_FAULTS } from './ralph.js';
26
+ export { globToPrefix, scanSecrets } from './validate.js';
27
+ export { validateJob, jobSpecHash, checkApproval, checkMenu, CLOSE_TYPES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS, TOOL_MENU, LOCKED_TOOLS, STORE_VERBS, VERDICT_TYPES, LOCKED_VERDICTS } from './job.js';
20
28
  // Layer 2: the plan-v1 validator — the agent-authored plan doc's gate; the
21
29
  // two-doc split's third validator never happens (plan-v1 gates the PLAN, the
22
30
  // job spec stays the arbiter's only home).
23
- export { validatePlan, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, WRITE_VERBS } from './plan.js';
31
+ export { validatePlan, stageClose, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, WRITE_VERBS } from './plan.js';
24
32
  export { snapshotScope, evalExits } from './exits.js';
25
33
  export { runPlan } from './planrun.js';
26
- export { interpret, STALL_REDS } from './interpret.js';
27
- export { extractRules, MAX_RULES, MAX_RULE_CHARS } from './extract.js';
28
34
  export { runJob } from './run.js';
35
+ // Layer 3 — the REUSE registry (bridge-v1) and D3's display half. The registry is a
36
+ // directory of plain files at an OPERATOR-supplied path (never a default location), the
37
+ // load gate is the code half of D2's split, and `renderListing`/`selectionPrompt` are
38
+ // pure text: they send nothing, parse nothing and decide nothing. The selection CALL and
39
+ // the pin/shortlist/force-cold flow are the adopter's, which is why the pieces they need
40
+ // are exported rather than kept internal to the runner.
41
+ export {
42
+ BRIDGE_SCHEMA, validateBridge, deriveStatus, listingRow, loadGate,
43
+ mintBridge, appendGreen, appendRed,
44
+ loadBridge, loadRegistry, saveBridge, makeRegistry, registryExists,
45
+ } from './bridges.js';
46
+ export { renderListing, selectionPrompt } from './selection.js';
47
+ // Layer 3 modules 4+5 — the D7 envelope and the reuse runner. `runReuse` composes
48
+ // `runJob` under an operator-signed envelope (three explicit numbers, tighten-only) and
49
+ // is the only thing in the library that WRITES the registry — minting is a graded
50
+ // green's privilege (R1), never a caller's. `validateEnvelope`/`resolveTrySpec` are
51
+ // exported because an operator runner has to show the resolved per-try spec's HASH at
52
+ // its approval gate: a tightened envelope is a new spec version, and the human signs it.
53
+ // `resolveReuse`/`reuseSpecHash` are the pair that gate must actually PRINT: the try
54
+ // count multiplies the worst case, so a signature over the per-try spec alone leaves it
55
+ // unsigned — a runner showing `jobSpecHash(resolveTrySpec(...))` prints the same hash for
56
+ // 0 tries and for 9.
57
+ // Module C (resume after a kill) exports its two operator-side halves for the same
58
+ // reason: `readResume` is what turns a dead run's spine back into the state a resume
59
+ // continues from (which tries completed, which one restarts, on what remainder), and
60
+ // `resumeTreeGate` is the ruling that a resumed patient is continued dirty and never
61
+ // reset. Both are read by a runner BEFORE the approval gate, so neither can live behind
62
+ // `runReuse`'s own entry.
63
+ export { validateEnvelope, resolveTrySpec, resolveReuse, reuseSpecHash, selectBridge, runReuse, REUSE_GRADED_RED, readResume, resumeTreeGate } from './reuse.js';
29
64
  export { classifyIncidents, foldLedger, ledgerDeltas, updateLedger, LEDGER_CLASSES } from './ledger.js';