bareloop 0.14.0 → 0.15.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 CHANGED
@@ -7,6 +7,149 @@ feature lands, **patch** = docs, fixes, scaffolding.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.0] — 2026-08-27
11
+
12
+ ### Added
13
+
14
+ - **Generic run replay** (PRD build-list #6): `replayRun`/`formatReplay` (`src/replay.js`)
15
+ reconstruct one run's whole story from its spine JSONL + gate-audit sidecar. The single-run
16
+ report is a signed layout (hamr, 2026-08-25): a header block (`goal:`/`shape:`/`signed:`/
17
+ `branch:`/`outcome:`/`spent:`, absent fields printing the literal `none recorded`, never
18
+ blank or a fabricated `0`), a `TIMELINE (steps)` or `TIMELINE (iterations — loop-shape run)`
19
+ section with its own column-header row and per-row time/cost (wall, `$` — a `null` `costUsd`
20
+ anywhere in a row's window makes that row's spend `unknown (N unpriced rounds)`, never `0` or
21
+ a partial sum stamped exact) and a `↳ tripped:`/`↳ close:` line under any row an escalation or
22
+ close fell inside, a `CLOSE` section (never omitted — `none — the run ended before any close
23
+ ran` when nothing ever closed; picks the run's REAL final close by seq, never by record type —
24
+ an early `outer-close` precheck reading red must not shadow a LATER fix-loop close-verdict that
25
+ went satisfied), `ENDING` (last record + 3 before it, plus an out-of-window escalation), the
26
+ `BEHAVIOUR` block, and a `MEMORY-CACHE` line always printed (`not armed on this run` when
27
+ absent — absence is never silent). Stop reason is `category — decision — detail` off the last
28
+ escalation, so a run's actual failure text (a TLS "bad record mac", a specific mypy error)
29
+ rides onto the report instead of just the generic per-category decision prose. A step id that
30
+ recurs after a replan is split per OCCURRENCE, never merged into one pooled row. `--all <dir>`
31
+ is name-agnostic (a spine is identified by a `job-start`/`run-start` record in its content,
32
+ never a filename convention) and prints a fixed-column, ALIGNED table (never CSV) — `id job
33
+ shape outcome spend wall steps reason`, `reason` built from `detail` only (never `decision`,
34
+ cut at 60 chars) — flagging any non-spine `.jsonl` as `<name> not-a-spine` rather than dropping
35
+ it. A RESUMED run's `job-end.spentUsd` is a CHAIN total (prior legs folded in) that can never
36
+ equal this one file's own rounds (measured: `u-msf70nei` header `$5.3389` vs its own 55 rounds
37
+ summing to `$1.2127`) — the header now prints both, each labelled, plus a `resumed:` line
38
+ (keyed on `job-start.priorSpentUsd`, never on the narrower `resume-seed` record alone — 4 real
39
+ archived runs carry a real fold with no resume-seed record at all). A non-resumed run's own
40
+ spend disagreeing with its summed rounds is a genuine finding and prints as a `MISMATCH`, never
41
+ hidden (measured: 0 of the archive's non-resumed runs trip it). `--all` marks a resumed row
42
+ with a trailing `*` and one footnote line when the listing holds any. Reuses `runBehaviour`/the
43
+ `memory-cache` readout rather than reimplementing either. Reads only, mints no verdict, writes
44
+ nothing to the spine. `job-start` (`src/run.js`) now also carries `verdictType` (the spec's
45
+ required verdict class) and `model` (when the shell-owned provider binding itself carries one)
46
+ — PRD TODO #20, blessed verbatim by hamr the same day. The header prints `class:`/`model:`
47
+ right after `shape:` (`not recorded (pre-F117 spine)` / `not recorded` on an older archive or
48
+ a provider with no `.model`), and `--all` gains `class`/`model` columns (`model` kept FULL,
49
+ never truncated).
50
+ - **Prompt-commit shape — a check, not convention** (PRD build-list #5, TODO #8, Q9 answered
51
+ by hamr 2026-08-25): a commit that changes a prompt register (a model-facing
52
+ system/strategy/instruction string a worker or judge reads) must now say, in its own
53
+ message, three labelled things — `Failure:` what caused the change, `Addresses:` what it
54
+ addresses, `Corrects:` what it corrects. `src/promptregisters.js` (`PROMPT_REGISTERS`,
55
+ `isPromptFile`, both exported from `bareloop`'s root) is the ONE inventory: 7 files carry
56
+ real prompt content (`src/authorscout.js`, `src/authorflow.js`, `src/judged.js`,
57
+ `src/cardauthor.js`, `src/readshim.js`, `src/tools.js`, `src/planrun.js`) — more than double
58
+ the 8 named consts a first grep found, once module-private consts and inline
59
+ template-literal prompt builders (`scoutPrompt()`, `cardCasesPrompt()`, `locatePrompt()`,
60
+ `strategyFor()`) are counted; the check is file-scoped precisely because a const-scoped one
61
+ would have silently missed those. Enforcement is LOCAL ONLY — wired into `npm test`
62
+ (`scripts/prompt-commit-check.mjs --range origin/main..HEAD`), never into any
63
+ `.github/workflows/*` file (ask-first, untouched); an unresolvable range (no `origin`, no
64
+ fetch) is a printed SKIP at exit 0, never a violation. Pure decision logic lives in
65
+ `scripts/promptcommitlib.mjs`, neither of which ships (`scripts/` is outside
66
+ `package.json`'s `files`). hamr's same-day addition: the `Failure:` line must also cite
67
+ the RUN that caused the change (`run <id>` / `run u-<id>`, `\brun\s+(u-)?[a-z0-9]{6,12}\b`
68
+ — alphabet/length measured against 231 real archived spine ids, every one exactly 8
69
+ lowercase-alnum chars), failing with a distinct message
70
+ (`FAILURE_NEEDS_RUN_REF`) when absent; format only, never checked against a real spine
71
+ file.
72
+ - **Run→code stamp** (F118, the parked half of the same day's prompt-commit build, closed
73
+ same day on hamr's go): the prompt-commit check closes commit→run; this closes run→code.
74
+ `src/codeversion.js`'s `codeVersion()` is a pure, $0, no-shell reader — `version` from
75
+ bareloop's own `package.json`, `sha` from `.git/HEAD` (loose ref, falling back to
76
+ `packed-refs`) ONLY when a `.git` directory exists at the package root (a dev checkout;
77
+ an npm install has none), `dirty` always `null` (uncommitted-changes state cannot be
78
+ known without a shell — reported as unknown, never faked as `false`). `job-start`
79
+ (`src/run.js`) now also carries `code: {version, sha}`. `replayRun`/`formatReplay`
80
+ (`src/replay.js`) read it: a `code:` line right after `model:` — `bareloop 0.14.0 @
81
+ 43f2812` / `@ sha unknown` / `not recorded (pre-F118 spine)`; no new `--all` column
82
+ (hamr's index is already full). The library never grows a shell seam to answer this —
83
+ `run` stays the one locked verb.
84
+ - **Replay surfaces transport retries and floor reasons** (F119, live-proven the same day
85
+ a real run — `u-mt8yk53k` — fired the retry seam twice): `replayRun`/`formatReplay`
86
+ window `transport-retry` records into the step/iteration occurrence they fell inside
87
+ (same `seq` windowing as `worker-round`), printing `↳ transport retry ×N (recovered|not
88
+ recovered|partially recovered) — <error>` under that row; a retry outside every window
89
+ prints as `transport retries outside steps: N` under the `TIMELINE` header instead. The
90
+ header's `spent:` line, whenever `spendComplete:false`, now appends `· floor because:
91
+ <reason(s)>` — derived from the spine, never guessed: `transport retry ×N`, `unpriced
92
+ round(s)`, `cut mid-call`, `stall`, `prior leg floor`, every cause that shows real
93
+ evidence, or `floor (reason not in spine)` when none can be derived. `--all` adds no
94
+ column: a run carrying any retry gets ` ⟲N` appended to its outcome word (same posture
95
+ as the resumed `*`), with one footnote when the listing holds any. Report-only — reads
96
+ existing `transport-retry`/`wall-halt`/`stall`/`job-start` records, writes nothing.
97
+
98
+ ### Fixed
99
+
100
+ - **Leaked tmp dirs from `npm test`** (same class that filled `/tmp` tmpfs on 2026-08-25 and
101
+ blocked a build): `tests/codeversion.test.js`'s real-worktree test tracked only the `wt`
102
+ subdirectory passed to `git worktree remove`, never the `mkdtemp`-created parent, so every run
103
+ left an empty `codeversion-worktree-*` dir behind; its `after()` now also prunes stale worktree
104
+ metadata when `remove` fails and `rmSync`s the tracked dir unconditionally, plus the mkdtemp
105
+ parent is now itself tracked and removed. `tests/ralph.test.js`'s cwd-threading test created a
106
+ `ralph-cwd-*` repo with no cleanup at all, and its cwd-relative-close test likewise leaked a
107
+ `close-cwd-*` and a `close-cwd-other-*` dir per run; added a per-test `after(() => rmSync(...))`
108
+ for each, matching the file's existing top-level cleanup pattern.
109
+ - **`codeversion.test.js` false-red on PR checkouts** (F118 addendum): the HEAD-sha test's own
110
+ precondition assumed a symbolic ref, but `actions/checkout` leaves a PR checkout's HEAD
111
+ DETACHED at the merge commit, so the precondition threw before the module under test ever
112
+ ran — `src/codeversion.js` itself already handled the detached case correctly. Test-only fix:
113
+ the precondition now resolves a bare 40-hex `.git/HEAD` the same way `codeVersion()` does, plus
114
+ a new deterministic fake-package-root test that exercises the detached case without depending
115
+ on the checkout's own HEAD state.
116
+ - **Review of PR #23 (2026-08-26), 7 items fixed**: (1) `replayRun`'s spend totals
117
+ (`src/replay.js`) counted only `worker-round`, silently dropping a close's own `judge-round`
118
+ spend and native `worker-turn` attribution out of every total — now filters on the canonical
119
+ `SPEND_RECORD_TYPES` (newly exported from `src/ledger.js`), including the per-step window,
120
+ which no longer requires a `.phase` match for records (like `judge-round`) that never carry
121
+ one. (2) `scripts/promptcommitlib.mjs`'s run-reference regex accepted plain prose ("run
122
+ failed" satisfied its `{6,12}` margin outright) — tightened to the measured exact shape
123
+ (`{8}`); a proposed additional per-id digit requirement was investigated and REJECTED (23 of
124
+ 130 re-sampled real archived ids carry no digit, `msdsmkid` among them). (3)
125
+ `src/codeversion.js` read `sha: null` inside any real git worktree (`.git` there is a FILE,
126
+ `gitdir: <path>`, not a directory) — now resolves the pointer file and the worktree's
127
+ `commondir` to find HEAD/refs correctly. (4) `replayRun`'s `resumed` flag missed a genuine
128
+ $0-floor resume (`priorSpentUsd: 0` is still a real fold per `src/run.js:328`'s own doctrine)
129
+ — now keys on the field's presence, not `> 0`. (5) `scripts/run-replay.mjs --all` opened
130
+ every run's gate-audit sidecar despite never using the fields it feeds — no longer opens it
131
+ in `--all` mode (`--all` output on `aurora-u-bareloop` confirmed byte-identical before/after).
132
+ (6) the steps/iterations windowing logic in `replayRun` was duplicated inline twice — extracted
133
+ into one shared `buildOccurrenceMetrics` helper (replay output on `u-msdsmkid`, `u-msf70nei`,
134
+ `u-mt8yk53k` confirmed byte-identical before/after). (7) `npm test`'s prompt-commit check
135
+ (`origin/main..HEAD`) inspects NOTHING after a direct push to main, because the push itself
136
+ advances `origin/main` to match `HEAD` — `scripts/prompt-commit-check.mjs` now falls back to
137
+ `HEAD~1..HEAD` on that exact condition and prints that it did; a multi-commit direct push
138
+ still only re-covers the last commit (needs `github.event.before`, a `.github/` edit — out of
139
+ scope here, ask-first).
140
+ - **Release-gate findings (2026-08-27)**: `scripts/prompt-commit-check.mjs`'s `--range` value
141
+ was passed to `git rev-list` positionally with no way to disambiguate it from an option — an
142
+ option-shaped value (e.g. `--output=<path>`) was parsed by git as its own flag (verified: a
143
+ `--` separator does NOT fix `rev-list`, unlike `show`/`diff-tree`, since it treats everything
144
+ after it as a path filter, not a revision range); the script now rejects any `--range` value
145
+ starting with `-` up front, before any git call runs. Also corrected the fallback's printed
146
+ limitation and code comment: on a MERGE commit, `HEAD~1` is the first parent, so
147
+ `HEAD~1..HEAD` covers the merge PLUS every commit unique to the merged branch (measured on
148
+ this repo's own `4904d76`: 10 commits, not 1) — the prior wording ("only re-covers the last
149
+ commit") was only accurate for a LINEAR multi-commit push, which is now stated explicitly.
150
+ (`src/replay.js`'s phase-less step-window filter was also reviewed and confirmed correct by a
151
+ new adversarial synthetic test — no code change there.)
152
+
10
153
  ## [0.14.0] — 2026-08-25
11
154
 
12
155
  ### Added
@@ -2142,6 +2142,318 @@ is not a spend record and no spend-slicing instrument needs to account for it.
2142
2142
  (`no record` if the run ended before the summary could fire); `scripts/behaviour-readout.mjs`
2143
2143
  prints the same line when handed the run's spine file as its optional third positional.
2144
2144
 
2145
+
2146
+ ### `replayRun(spineEvents, auditEvents?, { runId? })` / `formatReplay(summary)` — `src/replay.js`
2147
+
2148
+ PRD build-list item 6: today no failed run can be reconstructed in under five minutes — every
2149
+ byte is on disk (the spine JSONL + its gate-audit sidecar) but only hand-slicing JSONL reads it.
2150
+ `replayRun` is a report-only reader over BOTH files: **reads only, mints no verdict, writes
2151
+ NOTHING to the spine**, same posture as `runBehaviour` above, which it reuses rather than
2152
+ reimplements for the tool-call breakdown. It returns one plain object:
2153
+
2154
+ ```
2155
+ { runId, job, goal, budgetUsd, specHash, branch, verdictType, model, code,
2156
+ outcome, stopReason, spentUsd, spendComplete, wallMs, chainClock,
2157
+ resumed, resumeSeed, thisFileSpend, spendMismatch,
2158
+ timelineKind: 'steps'|'iterations', replans, close,
2159
+ steps: [{ id, occurrence, outcome, rounds, toolCalls, checks: {passed, failed},
2160
+ treeChanged, wallMs, spentUsd, unpricedRounds, tripped }],
2161
+ iterations: [{ iteration, verdict, boundary, closeStage, rounds, toolCalls,
2162
+ wallMs, spentUsd, unpricedRounds, tripped }],
2163
+ ending: { record, before /* the 3 records before it */, escalation },
2164
+ lastEscalation, behaviour, memoryCache, skipped }
2165
+ ```
2166
+
2167
+ **`verdictType`/`model`** (F117, PRD TODO #20 — hamr blessed the whole item verbatim, landed
2168
+ 2026-08-25): `verdictType` reads `job-start.verdictType` (`src/run.js:303`) — a REQUIRED spec
2169
+ field (`src/job.js:552` reds `missing-required` before job-start ever fires), so `null` here
2170
+ means only "this spine predates the field" (measured: `u-msdsmkid`, an Aug-3 archived run),
2171
+ never "unset". `model` reads `job-start.model`, present only when the shell-owned provider
2172
+ binding itself carried a `.model` string at job-start time — `src/planrun.js:76`'s own comment
2173
+ documents bare-agent's Loop reading `baseProvider.model` directly off that same object, so it is
2174
+ a real, citable, synchronously-available property, not a guess; a native/clipipe call path (or
2175
+ any provider double with no such field — measured on `tests/helpers.js`'s `scriptedProvider`)
2176
+ legitimately has neither, and this reader reports that honestly rather than defaulting one in.
2177
+ `formatReplay` prints both right after `shape:` — `class: softgreen` / `class: not recorded
2178
+ (pre-F117 spine)`, `model: claude-sonnet-5` / `model: not recorded`
2179
+ (the two "not recorded" wordings are deliberately different: a missing `verdictType` is always
2180
+ an archive-age gap, a missing `model` can happen on any spine, old or new). `--all` gains a
2181
+ `class` column right after `shape` and a `model` column right after `class` (the verdict class /
2182
+ the model string, or `-`) — `model` is kept FULL, never truncated, unlike the compact `reason`
2183
+ column: a wrong or uncertain model reading is exactly the thing a directory-wide scan needs in
2184
+ full, not cut.
2185
+
2186
+ **`code`** (F118, the run→code direction — the commit→run direction is the prompt-commit
2187
+ check's `Failure:` run-reference rule; the two are companion halves of one loop, built the
2188
+ same day). `codeVersion()` (`src/codeversion.js`) is a pure, $0, no-shell reader: `version`
2189
+ is bareloop's own `package.json` version (resolved off `import.meta.url`, never a hardcoded
2190
+ path — works whether this module is imported from `src/` or from an installed
2191
+ `node_modules/bareloop`); `sha` is read straight off `.git/HEAD` (following a symbolic ref
2192
+ to its loose ref file, falling back to `packed-refs` when the branch has been packed) **only
2193
+ when `.git` is a real directory at the package root** — a dev checkout has one, an
2194
+ npm-installed copy does not, and that absence reads as `sha: null`, honestly, never a guess.
2195
+ `dirty` is always `null`: whether the tree has uncommitted changes cannot be known without a
2196
+ shell, and this module NEVER shells out — `run` is the one locked verb (hamr's law targets
2197
+ what the agent can DO, not how it's written), and a report-only field is not a license for
2198
+ the library to grow a shell seam. `null` is reported here rather than a defaulted `false`,
2199
+ because a faked "clean" reading is a worse lie than an honest "don't know."
2200
+
2201
+ `job-start` (`src/run.js:303`) carries `code: {version, sha}` alongside F117's
2202
+ `verdictType`/`model` (`dirty` is intentionally omitted from the spine — it is always
2203
+ `null`, and a spine field that can only ever hold one value is not worth a byte).
2204
+ `replayRun` reads it as `{version, sha}` or `null` (absent field, an archive older than this
2205
+ landing); `formatReplay` prints a `code:` line right after `model:` — `code: bareloop
2206
+ 0.14.0 @ 43f2812` on a spine with a real sha, `code: bareloop 0.14.0 @ sha unknown` when
2207
+ `sha` is null (an npm-installed run), `code: not recorded (pre-F118 spine)` when the
2208
+ whole field is absent. `--all` gains no new column for this — the row is already at hamr's
2209
+ named limit.
2210
+
2211
+ **Unknown money/time is always `null`/`unknown` — never `0`, and a partial sum is never stamped
2212
+ exact** (F6's rule, carried through every level this module reports at): `spentUsd`/`wallMs` are
2213
+ `null` when the run's own `job-end` is absent or carries no figure; `spendComplete` reads `false`
2214
+ whenever `spentUsd` is `null`, regardless of what the record's own flag says. The SAME rule
2215
+ governs one occurrence/iteration's own `spentUsd`: it sums `worker-round.costUsd`
2216
+ (`src/planrun.js:2288-2297`) over records strictly inside that occurrence's own `seq` window —
2217
+ a single `null` `costUsd` anywhere in the window makes the WHOLE occurrence's `spentUsd` `null`
2218
+ (`unpricedRounds` names how many), never a partial sum. Zero rounds in the window is a real `$0`,
2219
+ not an unknown one.
2220
+
2221
+ `stopReason` is `null` on a green/already-green outcome; otherwise `category — decision —
2222
+ detail` from the last `escalation` record, every part present that record actually carries
2223
+ (`category` dropped when it duplicates the outcome already shown beside it; `detail` trimmed at
2224
+ 400 chars with an explicit `…[+N chars]` marker, never silent) — `decision` alone is the generic
2225
+ one-line prose every category shares ("the provider path failed mid-run…" fires for every
2226
+ transport casualty); **`detail` is what actually killed THIS run** (a TLS "bad record mac", a
2227
+ specific mypy error) and is the reason a stop is explained, never the decision prose alone.
2228
+ Falls back to the bare outcome string for a pre-escalation red (`plan-red`/`job-red`/
2229
+ `smoke-red`). `ending.escalation` carries that same last escalation record whenever it falls
2230
+ outside the fixed 3-before window (common on a run that keeps emitting bookkeeping records after
2231
+ its real failure) — never silently dropped. `lastEscalation` is the same raw record exposed
2232
+ unconditionally (not gated on the window check) — `summarizeForAllLine`'s `reason` column reads
2233
+ it directly rather than re-deriving from `ending`.
2234
+
2235
+ **A step id can recur** (a replan re-executes the same step): each `step-start`..`step-end` pair
2236
+ is its own **occurrence** (`occurrence: 1, 2, …`), and every field on it — `rounds`, `toolCalls`,
2237
+ `checks`, `treeChanged`, `wallMs`, `spentUsd` — is scoped to records strictly between THAT pair's
2238
+ own `seq` (the spine's monotonic per-record counter, never `ts` — every record in one spine
2239
+ shares the same seq counter, so it is an exact ordering where two same-millisecond `ts` stamps
2240
+ are not), never pooled across every occurrence sharing the id. `rounds` counts real
2241
+ `worker-round` records (`phase === 'step:<id>'`), never the declared cap; `toolCalls` is the one
2242
+ thing still windowed by `ts` (the gate-audit carries no `step` or shared `seq`). `tripped` is the
2243
+ last `escalation` whose `seq` falls inside the occurrence's window — `{category, detail}`, with
2244
+ `detail` falling back to `decision` when the escalation site carries no `detail` at all (a
2245
+ ladder-exhaustion `cap-halt`, `src/ralph.js`, is the measured example — without the fallback the
2246
+ tripped line would print a bare category and hide the real reason).
2247
+
2248
+ **A run whose spine carries no `step-start` at all** — the plan's only step was
2249
+ already-green-skipped on a resumed leg, or a genuinely loop-only shape (measured: `u-msf70nei`,
2250
+ a resumed leg whose one step was already proven and only the CLOSE-FIX loop ran) — has
2251
+ `timelineKind: 'iterations'` and `iterations[]` populated from `iteration-start`
2252
+ (`src/ralph.js:583`) instead: each row runs from one `iteration-start` to the next
2253
+ `close-verdict`/`run-end`/`escalation` (`src/ralph.js:674,577/657/676/698`), whichever comes
2254
+ first by `seq`. `steps`/`iterations` are never both populated — exactly one is non-empty. Each
2255
+ iteration's own `closeStage` (`{verdict, stages}`) is set when a `close-verdict` ended it, distinct
2256
+ from the run's FINAL `close` (below).
2257
+
2258
+ **`close`** is the run's real, final close — resolved by picking the LAST (by `seq`) record
2259
+ among every `close-verdict` that carries an ARRAY `stages` field and every `outer-close`
2260
+ (`src/planrun.js:3296`). A `close-verdict` with NO `stages` came from a PLAN STEP's own
2261
+ micro-loop, judged by the exit evaluator instead of a command (`src/planrun.js:2907`) — never
2262
+ "the close". Picking by `seq` rather than preferring `outer-close`'s TYPE matters: `outer-close`
2263
+ is the PRECHECK against the real close, fired once before the fix loop even starts, so when it
2264
+ reads red the fix loop's own staged close-verdicts run strictly AFTER it — measured on
2265
+ `u-msf70nei`, whose `outer-close` (seq 14) reads `needs_revision` but whose fix loop's own final
2266
+ close-verdict (seq 97, LATER) reads `satisfied`, which is what the run actually ended on
2267
+ (`job-end.outcome:'green'`). A run that never reached ANY close (e.g. `u-msdsmkid`, stopped by a
2268
+ step-variance meter before `judgeClose()` ever fired) resolves `close: null`, printed as `none —
2269
+ the run ended before any close ran` — never omitted.
2270
+
2271
+ `replans` prefers `plan-executed.replans` (`src/planrun.js:2688`) and falls back to counting
2272
+ `materials` records stamped `phase:'replan'` (`src/planrun.js:2520`) when that field is absent —
2273
+ an older archived spine can predate the field (measured on `u-msdsmkid`, an Aug-3 run). `branch`
2274
+ reads the last `work-branch.branch` (`src/planrun.js:1589`) or `null` when absent — also measured
2275
+ missing on the same era of archive, before the work-branch rung landed.
2276
+
2277
+ `wallMs` is THIS FILE's own `job-start`→`job-end` span, labeled `(this file)` — on a RESUMED leg
2278
+ that is only the latest engagement, not the whole signed chain. `chainClock` is the other true
2279
+ number: the SIGNED wall read off the run's own clock record — `wall-halt` when present (the
2280
+ TERMINAL reading, taken at the moment the wall stopped the run; `src/planrun.js:1269`'s
2281
+ `emitWallHalt`), else the single `wall-clock` record every run emits near its start
2282
+ (`src/planrun.js:1203`) — both spread from `clock.report()` (`src/clock.js:235-247`: `bounded,
2283
+ requestedMs, closeStages, enforcedMs, elapsedMs, remainingMs`). `elapsedMs` on either is
2284
+ chain-scoped by construction (`createClock`'s `priorElapsedMs` fold, `src/clock.js:174`), so it
2285
+ can legitimately exceed this file's own `wallMs` on a resumed run. `wall-halt`'s reading is
2286
+ end-of-run-honest, so `formatReplay` states it plainly: `clock: 60m04s of 60m00s signed (chain,
2287
+ from wall-halt)`. `wall-clock`'s reading is taken at the TOP of the engagement (chain time
2288
+ already inherited, not this run's own duration) — printing that under the same template read as
2289
+ "this run took 0 seconds" on a cold run, so it gets its own honest wording instead: `clock: 30m00s
2290
+ signed · 0.0s inherited from prior legs at start (from wall-clock; no end-of-run reading in this
2291
+ file)`.
2292
+
2293
+ **Money carries the identical split** (hamr, 2026-08-25): a RESUMED run's `job-end.spentUsd` is
2294
+ the CHAIN total (prior legs folded in — `src/run.js:227`'s `chainFoldUsd`, seeded from
2295
+ `job-start.priorSpentUsd`), which can never equal this ONE file's own rounds — measured on
2296
+ `u-msf70nei`: header `spentUsd` reads `$5.3389` while its own 55 `worker-round`s sum to exactly
2297
+ `$1.2127`. `resumed` reads `job-start.priorSpentUsd > 0` — the SAME condition `src/run.js` itself
2298
+ folds on — deliberately NOT `resume-seed`'s presence: `resume-seed` (`src/planrun.js:2622`) is a
2299
+ NARROWER record, fired only when a plan is actually reloaded mid-flight, and its absence does not
2300
+ mean "not resumed" — measured directly on 4 real archived runs (`litectx-maintainer-bareloop`'s
2301
+ `msx7xoe0`/`msx87qqs`/`msxf9129`/`msxf2mwi`) that carry a real `priorSpentUsd` fold with NO
2302
+ resume-seed record at all. `resumeSeed` (when present) supplies the DETAIL —
2303
+ `{phase, completed, skipping, divergence}` — printed as `resumed: yes — phase close, 1 of 1
2304
+ completed step(s) skipped`; a resumed leg with no resume-seed record still reads `resumed: yes
2305
+ (no resume-seed detail recorded)`, honestly, rather than falling back to `no`. `thisFileSpend`
2306
+ prefers `job-end.engagementSpentUsd` (F103, `src/run.js:284` — this engagement's own fold-out
2307
+ spend) when the record carries it, falling back to summing this file's own `worker-round.costUsd`
2308
+ when it does not (measured absent on `u-msf70nei` itself); an unpriced round anywhere in THIS
2309
+ FILE makes the figure `null` regardless of source (F6, never a partial sum stamped exact) — both
2310
+ figures print on the `spent:` line, each labeled with its source, never one silently standing in
2311
+ for the other. `spendMismatch` is the same-class check for a NON-resumed run: there is no fold to
2312
+ explain a gap, so `job-end.spentUsd` disagreeing with this file's own summed rounds by more than
2313
+ $0.0001 is a genuine finding, printed as `MISMATCH vs job-end`, never hidden — measured across
2314
+ every non-resumed run in the available archive (aurora-u, bareagent-u, litectx-maintainer): zero
2315
+ trip it. `--all`'s spend column stays the chain total (what a directory scan asks first) but
2316
+ marks a resumed row with a trailing `*` on its id, with one footnote line
2317
+ (`* resumed run — spend is the chain total, see detail`) printed once, only when the listing
2318
+ holds at least one such row.
2319
+
2320
+ `formatReplay` renders the full page: a header block (`goal:`/`shape:`/`signed:`/`branch:`/
2321
+ `resumed:`/`outcome:`/`spent:`, every absent field printing the literal `none recorded` — never
2322
+ blank, never a fabricated `0`), a `TIMELINE (steps)` or `TIMELINE (iterations — loop-shape run)` section with
2323
+ its own column-header row (`# step result rounds tools checks tree wall spend`, or the
2324
+ iteration equivalent without `checks`/`tree`) and a `↳ tripped:`/`↳ close:` line under any row an
2325
+ escalation or close fell inside, a `CLOSE` section (never omitted — `none — the run ended before
2326
+ any close ran` when nothing ever closed; the failing stage, if any, prints FIRST with `✗`, the
2327
+ rest `name OK`), `ENDING` (last record + 3 before it, plus an out-of-window escalation), the
2328
+ `BEHAVIOUR` block, and a `MEMORY-CACHE` line **always printed** — the existing wording when the
2329
+ run carried a record, `not armed on this run` otherwise. An absent record is never silent.
2330
+ `RESULT` words are `GREEN`/`RED`/`UNKNOWN` — `satisfied`/`green`/`already-green` read GREEN,
2331
+ every other non-null value reads RED.
2332
+
2333
+ `summarizeForAllLine(summary)` builds one `--all` row's raw fields (`id, job, shape, outcome,
2334
+ spend, wall, steps, reason`) — `shape` is `plan`/`loop` (from `timelineKind`, never the
2335
+ always-literal-`'plan'` `job-start.shape` field); `steps` is a bare count for a plan-shape run,
2336
+ `"N it"` for a loop-shape one; `reason` is `category — <first clause of detail, cut at 60 chars,
2337
+ plain "…", no [+N] marker>` off `lastEscalation` — **`detail` only, never `decision`** for this
2338
+ column specifically (unlike the full report's `↳ tripped:` line, which does fall back to
2339
+ `decision` — this compact row has no room for both and the coordinator's own ruling keeps
2340
+ `decision`'s generic per-category prose out of it); `-` on a green row; the distinct, literal
2341
+ `no job-end (killed mid-run)` when the spine never reached a `job-end` at all (`outcome` itself
2342
+ is `null`) — not folded into the ordinary escalation case. `formatAllLines(entries)` renders every
2343
+ row as a FIXED-COLUMN, ALIGNED table with a header row (never CSV — columns are padded, not
2344
+ delimited; the header's own label lengths count toward the column widths) plus any
2345
+ `<name> not-a-spine` lines at their original position (unpadded — a filename carries no
2346
+ outcome/spend/wall of its own). No header prints when there are zero real spines in the listing.
2347
+
2348
+ `scripts/run-replay.mjs <spine.jsonl>` resolves the sibling `-gate-audit.jsonl` automatically and
2349
+ prints the full report; `--all <dir>` is **name-agnostic** — the patient corpus does not agree on
2350
+ one filename convention (`u-<id>.jsonl`, `battery-A1-<id>.jsonl`, `l2accept-L1-<id>.jsonl`,
2351
+ `reuse-<id>.jsonl`, `types-screen-C-<id>.jsonl`, …), so a spine is identified by CONTENT: any
2352
+ `.jsonl` that is not a sidecar by name (`*-gate-audit.jsonl`, `*.lag.jsonl`) and whose records
2353
+ include a `job-start` (present on every real spine checked, nested wrappers included) or
2354
+ `run-start` (present on most, absent on a run that died before ralph's first iteration). A
2355
+ `.jsonl` matching neither is printed as `<name> not-a-spine` — never silently dropped or
2356
+ misread as an empty run. Both modes mirror `scripts/behaviour-readout.mjs`'s own malformed-line
2357
+ handling (skip-and-count, never throw).
2358
+
2359
+ **F117's PRD TODO #20 is now landed in full**: both the verdict CLASS and the worker MODEL
2360
+ name are surfaced (see `verdictType`/`model` above) — hamr blessed the item verbatim
2361
+ ("1") on 2026-08-25, so the spine-WRITER change to `job-start` (`src/run.js`) that a
2362
+ report-only reader would not otherwise make on its own authority went in with his explicit
2363
+ word. `judgedCount` on a close-verdict was never a reliable stand-in for the class either way
2364
+ (it appears on plain green closes too, not only softgreen ones) — this reads the real
2365
+ declared field instead.
2366
+
2367
+ **Transport retries and floor reasons (F119, live-proven 2026-08-25)**: every
2368
+ `transport-retry` record (`src/planrun.js:126`) whose `seq` falls inside a step's or
2369
+ iteration's own window prints a `↳ transport retry ×N (recovered|not recovered|partially
2370
+ recovered) — <first 80 chars of the error>` line under that row — the same open-interval
2371
+ `seq` windowing `windowSpend` already uses for rounds. A retry whose `seq` falls outside
2372
+ every occurrence window (e.g. during the scout/plan phase) is counted instead under the
2373
+ `TIMELINE` header as `transport retries outside steps: N`, printed only when that count is
2374
+ non-zero. The header's `spent:` line, whenever `spendComplete` is `false`, appends
2375
+ `· floor because: <reason(s)>` — derived from the spine, never guessed: `transport retry
2376
+ ×N` (any `transport-retry` record), `unpriced round(s)` (a `worker-round`/`judge-round`
2377
+ with non-finite `costUsd`), `cut mid-call` (a `wall-halt` record with `cutMidCall:true`),
2378
+ `stall` (a `stall` record), `prior leg floor` (`job-start.priorSpendComplete === false`) —
2379
+ every cause that actually shows evidence prints, never just one; a floor with no derivable
2380
+ evidence on this spine (an older archive, or a resumed leg's floor inherited from a file not
2381
+ in hand) prints `· floor (reason not in spine)` instead. `--all` adds no new column: a run
2382
+ that carried any transport retry gets ` ⟲N` appended directly after its outcome word (same
2383
+ posture as the resumed `*` on `id`), with one footnote
2384
+ (`⟲N transport retry(ies), recovered inline — see the full replay for detail`) printed once
2385
+ when the listing holds at least one such row. Live-proven on `u-mt8yk53k` (F119): two
2386
+ recovered TLS `bad record mac` retries, both inside step `fix-mypy-strict`'s window —
2387
+ `floor because: transport retry ×2` and `green ⟲2` in `--all`, on a run that finished green.
2388
+
2389
+ ```js
2390
+ import { replayRun, formatReplay } from 'bareloop';
2391
+ const spine = fs.readFileSync(spineFile, 'utf8').trim().split('\n').map(JSON.parse);
2392
+ const audit = fs.readFileSync(auditFile, 'utf8').trim().split('\n').map(JSON.parse);
2393
+ console.log(formatReplay(replayRun(spine, audit, { runId: 'mszcthk1' })));
2394
+ ```
2395
+
2396
+ ### `PROMPT_REGISTERS` / `isPromptFile(path)` — `src/promptregisters.js`, and the prompt-commit rule
2397
+
2398
+ PRD build-list item 5 (TODO #8), Q9 answered (hamr, 2026-08-25): "a check". A commit that
2399
+ changes a **prompt register** — one of the model-facing system/strategy/instruction
2400
+ strings a worker or judge actually reads (`SCOUT_SYSTEM`, `AUTHOR_SYSTEM`,
2401
+ `PERSONA_TOOLS`, `READ_SHIM_STRATEGY`, and their neighbors — the full, verified list is
2402
+ `PROMPT_REGISTERS` in `src/promptregisters.js`) — must say, in its own message, three
2403
+ things: what failure caused the change, what it addresses, and what it corrects.
2404
+ Convention-only was rejected: doctrine already on record is "a frozen rule without a
2405
+ wired detector is prose." `isPromptFile(path)` is the pure predicate the check runs
2406
+ against; both are exported from `bareloop`'s root so the inventory has exactly one home.
2407
+
2408
+ hamr's addition (2026-08-25, same day): the `Failure:` line must ALSO cite the **run**
2409
+ that caused the change — `run <id>` or `run u-<id>` (case-insensitive at the `run`
2410
+ keyword, multiple refs fine), matched by `\brun\s+(u-)?[a-z0-9]{8}\b` (tightened from an
2411
+ initial `{6,12}` margin after a PR #23 review found the margin loose enough to accept
2412
+ plain prose — "run failed" is 6 lowercase letters, satisfying `{6,12}` outright — to the
2413
+ measured exact shape: every real archived id is exactly 8 characters. A per-id digit
2414
+ requirement was proposed in that same review and rejected: 23 of 130 re-sampled real ids
2415
+ carry no digit at all, `msdsmkid` among them — a real archived run cited by name
2416
+ elsewhere in this repo — so requiring a digit would reject genuine citations). The commit
2417
+ points at the run; `replayRun`/`formatReplay` (above) close the loop the other way,
2418
+ turning that run id back into the whole story. A `Failure:` line with no run reference
2419
+ fails with a distinct message: `Failure: must cite the run that caused the change (e.g.
2420
+ "Failure: run mszcthk1 — ...")`. FORMAT ONLY — the check never verifies the cited run's
2421
+ spine file actually exists (a run's patient lives outside this repo entirely, so there is
2422
+ nothing here to check it against).
2423
+
2424
+ Enforcement is **local only** — wired into `npm test` (see `package.json`'s `test`
2425
+ script), never into `.github/workflows/*` (CI already runs `npm test`, so the rule is
2426
+ enforced there without an ask-first CI edit). The check itself lives outside the
2427
+ published package, at `scripts/prompt-commit-check.mjs` (pure decision logic in
2428
+ `scripts/promptcommitlib.mjs`, `scripts/` is not in `package.json`'s `files`, so none of
2429
+ this ships):
2430
+
2431
+ ```
2432
+ node scripts/prompt-commit-check.mjs --range origin/main..HEAD # validates every commit
2433
+ # in the range that
2434
+ # touches a prompt file
2435
+ node scripts/prompt-commit-check.mjs --message-file .git/COMMIT_EDITMSG
2436
+ # commit-msg hook mode:
2437
+ # validates one message
2438
+ # against staged files
2439
+ ```
2440
+
2441
+ A compliant message:
2442
+
2443
+ ```
2444
+ fix: tighten PERSONA_TOOLS
2445
+
2446
+ Failure: run mszcthk1 — worker read the arbiter spine after being told it was denied
2447
+ Addresses: PERSONA_TOOLS did not name the spine file explicitly
2448
+ Corrects: spells the denied paths from ARBITER_BOOK_STORES
2449
+ ```
2450
+
2451
+ A commit that touches no prompt-register file is passed without inspecting its message.
2452
+ An unresolvable `--range` (no such ref — a fresh clone with no `origin`, a detached
2453
+ checkout that never fetched) is a **skip**, printed and exited 0: a missing baseline is
2454
+ not a rule violation, never a red.
2455
+
2456
+
2145
2457
  ## Architecture
2146
2458
 
2147
2459
  Three layers. An **outer shell** (dumb, permanent): per-run budget cap via bareguard,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.14.0",
3
+ "version": "0.15.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",
@@ -17,7 +17,7 @@
17
17
  "node": ">=20"
18
18
  },
19
19
  "scripts": {
20
- "test": "node --test",
20
+ "test": "node --test && node scripts/prompt-commit-check.mjs --range origin/main..HEAD",
21
21
  "typecheck": "tsc --noEmit",
22
22
  "build:types": "tsc",
23
23
  "prepublishOnly": "npm run build:types"
@@ -0,0 +1,155 @@
1
+ // F118 (parked half) — the run→code direction. The commit→run direction
2
+ // (prompt-commit-check's `Failure:` line citing a run id) landed same-day;
3
+ // this is the other half: a run's own spine never recorded which version of
4
+ // bareloop's code it ran under, so a replay could not tell which prompts
5
+ // were in force. Report-only, $0, no shell — same posture as the F117
6
+ // verdictType/model stamp this rides beside (src/run.js:303).
7
+ //
8
+ // NEVER shells out to git: `run` is the one locked verb (hamr's law), and a
9
+ // library growing a shell seam just to read a report field is exactly the
10
+ // kind of capability creep that doctrine exists to block. Everything below
11
+ // is a plain file read of `.git`'s own on-disk layout.
12
+
13
+ import { readFileSync, existsSync, statSync } from 'node:fs';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { dirname, join } from 'node:path';
16
+
17
+ // this module lives at <pkgRoot>/src/codeversion.js
18
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
19
+
20
+ /**
21
+ * `.git` at a package root can be a FILE, not a directory — `git worktree
22
+ * add` makes every worktree's own `.git` a one-line pointer file
23
+ * (`gitdir: <path>`, absolute or relative to the worktree root) at the
24
+ * per-worktree git dir living under the main checkout's `.git/worktrees/
25
+ * <name>/` (measured directly: `git worktree add <tmp> HEAD --detach`
26
+ * against this repo). Resolves that file to the real per-worktree git dir;
27
+ * returns the plain `.git` directory path unchanged when it already is one,
28
+ * and `null` when `.git` is absent (an npm install) or the pointer file
29
+ * can't be read/parsed.
30
+ * @param {string} pkgRoot
31
+ * @returns {string|null}
32
+ */
33
+ function resolveGitDir(pkgRoot) {
34
+ const dotGit = join(pkgRoot, '.git');
35
+ if (!existsSync(dotGit)) return null;
36
+ const st = statSync(dotGit);
37
+ if (st.isDirectory()) return dotGit;
38
+ if (!st.isFile()) return null;
39
+ const contents = readFileSync(dotGit, 'utf8').trim();
40
+ const m = /^gitdir:\s*(.+)$/.exec(contents);
41
+ if (!m) return null;
42
+ const target = m[1].trim();
43
+ return target.startsWith('/') ? target : join(pkgRoot, target);
44
+ }
45
+
46
+ /**
47
+ * A worktree's own git dir (from {@link resolveGitDir}) holds HEAD but never
48
+ * refs — branches/`packed-refs` are shared, and live in the MAIN checkout's
49
+ * `.git`, whose path is this worktree git dir's own `commondir` file (one
50
+ * line, a path RELATIVE TO THIS GIT DIR — measured directly: this repo's own
51
+ * worktree fixture wrote `../..`, which from `.git/worktrees/<name>/`
52
+ * resolves back to the repo's real `.git`). A normal (non-worktree) git dir
53
+ * carries no `commondir` file at all and IS already the common dir.
54
+ * @param {string} gitDir
55
+ * @returns {string}
56
+ */
57
+ function resolveCommonDir(gitDir) {
58
+ const commondirPath = join(gitDir, 'commondir');
59
+ if (!existsSync(commondirPath)) return gitDir;
60
+ try {
61
+ const rel = readFileSync(commondirPath, 'utf8').trim();
62
+ if (!rel) return gitDir;
63
+ return rel.startsWith('/') ? rel : join(gitDir, rel);
64
+ } catch {
65
+ return gitDir;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Resolve a ref name (e.g. "refs/heads/main") to a sha by reading the loose
71
+ * ref file, falling back to `packed-refs` when the branch has been packed
72
+ * (measured: this repo's own `.git/packed-refs` carries `main` but not every
73
+ * branch — a loose ref can legitimately be absent while the branch is real).
74
+ * @param {string} gitDir
75
+ * @param {string} refName
76
+ * @returns {string|null}
77
+ */
78
+ function resolveRef(gitDir, refName) {
79
+ const loose = join(gitDir, refName);
80
+ if (existsSync(loose)) {
81
+ try {
82
+ const sha = readFileSync(loose, 'utf8').trim();
83
+ return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+ const packed = join(gitDir, 'packed-refs');
89
+ if (!existsSync(packed)) return null;
90
+ try {
91
+ const lines = readFileSync(packed, 'utf8').split('\n');
92
+ for (const line of lines) {
93
+ if (line.startsWith('#')) continue;
94
+ const [sha, ref] = line.trim().split(' ');
95
+ if (ref === refName && sha && /^[0-9a-f]{40}$/.test(sha)) return sha;
96
+ }
97
+ } catch {
98
+ return null;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * Report-only stamp of the bareloop code this process is running as. Never
105
+ * throws — an unreadable package.json or absent `.git` reads as `null`,
106
+ * honestly, rather than crashing a caller over a report field.
107
+ * @returns {{ version: string|null, sha: string|null, dirty: null }}
108
+ */
109
+ export function codeVersion() {
110
+ let version = null;
111
+ try {
112
+ const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
113
+ version = typeof pkg.version === 'string' ? pkg.version : null;
114
+ } catch {
115
+ version = null;
116
+ }
117
+
118
+ let sha = null;
119
+ try {
120
+ // only a dev checkout carries a `.git` at all (directory OR the
121
+ // `gitdir:` pointer file a worktree checkout leaves instead) — an
122
+ // npm-installed copy has neither, and this reads that absence as
123
+ // "unknown", never fakes one
124
+ const gitDir = resolveGitDir(PKG_ROOT);
125
+ if (gitDir !== null) {
126
+ const headPath = join(gitDir, 'HEAD');
127
+ const head = readFileSync(headPath, 'utf8').trim();
128
+ const m = /^ref:\s*(\S+)$/.exec(head);
129
+ if (m) {
130
+ // branches/packed-refs are shared across worktrees and live in the
131
+ // COMMON git dir, never the per-worktree one HEAD itself came from
132
+ sha = resolveRef(resolveCommonDir(gitDir), m[1]);
133
+ } else if (/^[0-9a-f]{40}$/.test(head)) {
134
+ // detached HEAD: a bare 40-hex sha directly in the file
135
+ sha = head;
136
+ }
137
+ }
138
+ } catch {
139
+ sha = null;
140
+ }
141
+
142
+ // dirty: uncommitted-changes state cannot be known without a shell
143
+ // (`git status`/`git diff`) — reported as unknown, never faked as false.
144
+ return { version, sha, dirty: null };
145
+ }
146
+
147
+ /**
148
+ * First 8 characters of a sha, or null. Mirrors the short-sha convention
149
+ * `replay.js` already uses for `specHash` (`s.specHash.slice(0, 8)`).
150
+ * @param {string|null|undefined} sha
151
+ * @returns {string|null}
152
+ */
153
+ export function shortSha(sha) {
154
+ return typeof sha === 'string' && sha.length > 0 ? sha.slice(0, 8) : null;
155
+ }