bareloop 0.5.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -23,9 +23,9 @@
23
23
  "prepublishOnly": "npm run build:types"
24
24
  },
25
25
  "dependencies": {
26
- "bare-agent": "^0.33.1",
26
+ "bare-agent": "^0.35.0",
27
27
  "bareguard": "^0.12.0",
28
- "litectx": "^0.29.1"
28
+ "litectx": "^0.31.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^22.0.0",
package/src/clock.js ADDED
@@ -0,0 +1,181 @@
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
+ /**
72
+ * F64 — is this throw the run's OWN deadline coming back dressed as a provider
73
+ * error? `callTimeoutMs()` hands the provider a bound derived from the wall, so
74
+ * when the wall is what shrank that bound, the resulting `TimeoutError` is a
75
+ * GOVERNANCE stop, not a casualty. Filing it as `provider-red` would discard it
76
+ * (a casualty is never evidence, F45/F48) and attach the F44
77
+ * `spendComplete:false` floor to a run that simply ran out of time.
78
+ *
79
+ * The discriminator is the wall itself, not the derived number: on a bounded
80
+ * clock a trip can only precede expiry when the bound came from the provider's
81
+ * own default (`remainingMs() > PROVIDER_TIMEOUT_MS`), and that case is transport
82
+ * by construction. So "already expired" IS the wall-derived set. An unbounded run
83
+ * can never produce one, and needs no separate `bounded` term to say so —
84
+ * `expired()` is false by construction with no cap. (A mutation run proved the
85
+ * extra term inert; the unbounded case is guarded by its own test instead, which
86
+ * is where that invariant belongs.)
87
+ *
88
+ * A genuine dead socket on a run whose wall has ALSO passed reads as a wall-halt.
89
+ * That is deliberate and honest: the run is out of time either way, the remedy is
90
+ * the same (raise `maxWallMs`), and the alternative — calling a governance stop
91
+ * transport on the chance that it was — is the failure this closes.
92
+ *
93
+ * @param {any} err the throw as caught
94
+ * @param {Clock} clock the run's clock
95
+ * @returns {boolean}
96
+ */
97
+ export function isWallTimeout(err, clock) {
98
+ return !!err && err.code === TIMEOUT_CODE && clock.expired();
99
+ }
100
+
101
+ /**
102
+ * @typedef {object} Clock
103
+ * @property {boolean} bounded whether the operator set a cap at all
104
+ * @property {number|null} requestedMs the operator's number; `null` when unbounded
105
+ * @property {number} closeStages how many close stages one verdict can pay for (W5)
106
+ * @property {() => number} elapsedMs wall time since the clock was created
107
+ * @property {() => number} remainingMs what is left, floored at 0; `Infinity` when unbounded
108
+ * @property {() => boolean} expired whether the deadline has passed (always false when unbounded)
109
+ * @property {(closeTimeoutMs: number) => number|null} enforcedMs the HONEST worst case
110
+ * @property {(providerDefaultMs?: number) => number} callTimeoutMs the derived per-call bound
111
+ * @property {(closeTimeoutMs: number) => object} report one spine-safe shape carrying both numbers
112
+ */
113
+
114
+ /**
115
+ * Start a wall clock for one run.
116
+ * @param {{ maxWallMs?: number|null, closeStages?: number, now?: () => number }} [opts]
117
+ * `maxWallMs`: the signed operator cap, or absent/null for an honestly unbounded
118
+ * run. `closeStages`: how many stages the job's close runs (W5) — each one gets
119
+ * the FULL `closeTimeoutMs`, so this is the multiplier on the overshoot, not a
120
+ * decoration. Defaults to 1, the single-predicate close. `now`: injected for
121
+ * tests — the default is the real clock.
122
+ * @returns {Clock}
123
+ */
124
+ export function createClock({ maxWallMs = null, closeStages = 1, now = () => Date.now() } = {}) {
125
+ const startedAt = now();
126
+ // A cap is only a cap if it is a usable positive number. validateJob already
127
+ // reds anything else, so this is the belt (a caller that hand-builds a spec
128
+ // must not be able to turn a garbage value into a silent unbounded run OR a
129
+ // silent instant expiry).
130
+ const bounded = typeof maxWallMs === 'number' && Number.isFinite(maxWallMs) && maxWallMs > 0;
131
+ const cap = bounded ? maxWallMs : null;
132
+ // The same belt on the stage count. A close always runs at least once, so 1 is
133
+ // the floor: a 0 or a NaN sneaking in here would quote an enforced number BELOW
134
+ // the requested cap, which is the dishonesty this multiplier exists to remove.
135
+ const stages = typeof closeStages === 'number' && Number.isFinite(closeStages) && closeStages >= 1
136
+ ? Math.floor(closeStages) : 1;
137
+ /** the ONE arithmetic, so the advertised record and the enforced worst case
138
+ * cannot be computed two ways (the two-transforms class, F9). */
139
+ const enforced = (/** @type {number} */ closeTimeoutMs) => (cap === null ? null : cap + stages * closeTimeoutMs);
140
+
141
+ const elapsedMs = () => now() - startedAt;
142
+ const remainingMs = () => (cap === null ? Infinity : Math.max(0, cap - elapsedMs()));
143
+ // Inclusive: AT the deadline is out of budget. A run resumes to cap, and the
144
+ // stop IS the checkpoint (v1.12) — so stopping one round early is correct and
145
+ // stopping one round late is the overshoot addendum 1 quantifies.
146
+ const expired = () => cap !== null && elapsedMs() >= cap;
147
+
148
+ return {
149
+ bounded,
150
+ requestedMs: cap,
151
+ closeStages: stages,
152
+ elapsedMs,
153
+ remainingMs,
154
+ expired,
155
+ enforcedMs: enforced,
156
+ callTimeoutMs: (providerDefaultMs = PROVIDER_TIMEOUT_MS) => {
157
+ if (cap === null) return providerDefaultMs;
158
+ // Clamped BOTH ways. Upper: never above the provider's own default, which
159
+ // would be inert. Lower: never below MIN_CALL_TIMEOUT_MS, and in
160
+ // particular never 0 — `0` DISABLES the provider timeout entirely
161
+ // (provider-anthropic.js), which is the exact opposite of the intent and
162
+ // would restore the ~2h OS-TCP hang on the last round of a tight budget.
163
+ return Math.min(providerDefaultMs, Math.max(MIN_CALL_TIMEOUT_MS, remainingMs()));
164
+ },
165
+ report: (closeTimeoutMs) => ({
166
+ bounded,
167
+ requestedMs: cap,
168
+ // On the record, not just in the arithmetic: without it a reader seeing
169
+ // enforced 90min against a requested 30min cannot tell a 60-minute close
170
+ // timeout from four 15-minute stages, and an unexplained gap is exactly the
171
+ // kind of number a later reader rounds back down to the requested one.
172
+ closeStages: stages,
173
+ enforcedMs: enforced(closeTimeoutMs),
174
+ elapsedMs: elapsedMs(),
175
+ // null, not Infinity: JSON.stringify turns Infinity into null anyway, and
176
+ // an append-only record must not depend on that coincidence — worse, a
177
+ // downstream `?? 0` on an Infinity would read as "no time left" (F6 class).
178
+ remainingMs: cap === null ? null : remainingMs(),
179
+ }),
180
+ };
181
+ }
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,35 @@
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';
29
35
  export { classifyIncidents, foldLedger, ledgerDeltas, updateLedger, LEDGER_CLASSES } from './ledger.js';