bareloop 0.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.13.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,13 +17,13 @@
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"
24
24
  },
25
25
  "dependencies": {
26
- "bare-agent": "^0.36.0",
26
+ "bare-agent": "^0.39.0",
27
27
  "bareguard": "^0.13.0",
28
28
  "litectx": "^0.32.0"
29
29
  },
@@ -0,0 +1,150 @@
1
+ // The run behaviour summary (PRD build-list §2, agreed 2026-08-23): ONE
2
+ // report-only text block computed entirely from a run's gate-audit JSONL.
3
+ // It gates NOTHING, mints no verdict, writes NOTHING to the spine — a pure
4
+ // read of records that already exist. "We can see what a run cost, never
5
+ // what it wasted" is the gap this closes; item 4's five-minute-replay test
6
+ // gets this as its cheap first slice.
7
+ //
8
+ // Gate-audit rows this reads (observed shape, this repo's real archive):
9
+ // {ts, seq, run_id, phase, action: {type, path?, args?}, decision, ...}
10
+ // `action.type` is one of `llm` (a provider round, NOT a tool call — always
11
+ // excluded), `read`, `edit`, `ctx_remember`. `action.args.tool` names the
12
+ // concrete tool for read-shaped actions (`shell_read`, `shell_grep`,
13
+ // `ctx_recall`, `ctx_get`, `ctx_recent`, `ctx_impact`, `ctx_related`);
14
+ // edit/ctx_remember rows carry no `args.tool`. `decision` is `allow` for a
15
+ // gated tool call, `null` for an llm row, or `deny`.
16
+
17
+ /**
18
+ * Stable JSON — keys sorted at every level — so two calls with the same
19
+ * arguments in a different insertion order (or a different property visit
20
+ * order out of JSON.parse) hash to the same string. Used only to build the
21
+ * exact-repeat key below; it is not a general-purpose canonicalizer.
22
+ * @param {any} v
23
+ * @returns {string}
24
+ */
25
+ function stableStringify(v) {
26
+ if (v === null || typeof v !== 'object') return JSON.stringify(v);
27
+ if (Array.isArray(v)) return `[${v.map(stableStringify).join(',')}]`;
28
+ const keys = Object.keys(v).sort();
29
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(v[k])}`).join(',')}}`;
30
+ }
31
+
32
+ /**
33
+ * The exact-repeat key for one tool-call action: `type` + `path` + a stable
34
+ * serialization of the WHOLE `args` object.
35
+ *
36
+ * Load-bearing, and deliberately not `{type, path}`: this repo's own history
37
+ * is a blind-instrument catalogue of collapsed keys reading two DIFFERENT
38
+ * things as the same thing (a ledger that summed input+output with no cache
39
+ * tiers, a gate audit that collapsed reads to `{type,path}`, `git status` as
40
+ * "did it write?"). If the gate ever starts recording byte ranges or offsets
41
+ * in `args`, two different slices of the same file must NOT count as a
42
+ * repeat — dropping `args` from the key would silently manufacture that
43
+ * exact confound here. This detector is exactly as sharp as what the gate
44
+ * records, no sharper: a tool that reads the same bytes through two
45
+ * differently-shaped argument objects will not be caught, and that is an
46
+ * honest limit, not a bug.
47
+ * @param {{type?: string, path?: string, args?: any}} action
48
+ * @returns {string}
49
+ */
50
+ function repeatKey(action) {
51
+ return `${action?.type ?? ''} ${action?.path ?? ''} ${stableStringify(action?.args ?? null)}`;
52
+ }
53
+
54
+ /**
55
+ * The display label for one tool-call breakdown row. Kept SEPARATE from the
56
+ * counting key (`args.tool || action.type`, computed inline in
57
+ * {@link runBehaviour}): the summary object stores the raw tool name so a
58
+ * consumer can trace a count back to its source, and only {@link
59
+ * formatBehaviour} shortens it for the printed line — the agreed shape reads
60
+ * "read"/"grep"/"recall", not "shell_read"/"shell_grep"/"ctx_recall".
61
+ * @param {string} key
62
+ * @returns {string}
63
+ */
64
+ function displayLabel(key) {
65
+ return key.replace(/^shell_/, '').replace(/^ctx_/, '');
66
+ }
67
+
68
+ /**
69
+ * Summarize what a run DID with its tools, from its gate-audit records
70
+ * alone. Pure and read-only: no IO, no spine writes, no verdict.
71
+ *
72
+ * Counting rules:
73
+ * - `llm` rows are provider rounds, never tool calls — always excluded.
74
+ * - a tool call counts toward `totalCalls`/`byTool`/`uniquePaths`/repeats
75
+ * only when `decision === 'allow'`; a `deny` is tallied separately in
76
+ * `denied` and touches nothing else (denied ≠ done).
77
+ * - `byTool` is keyed by `action.args.tool` when present, else
78
+ * `action.type` (an edit or ctx_remember has no `args.tool`).
79
+ * - exact repeats: see {@link repeatKey}. A first occurrence of an action
80
+ * is not itself a "repeat" — only the 2nd, 3rd, ... occurrences count,
81
+ * so `repeats` can never exceed `totalCalls - byTool-group-count`.
82
+ * @param {Array<{run_id?: string, action?: {type?: string, path?: string, args?: any}, decision?: string|null}>} events
83
+ * @param {{runId?: string}} [opts]
84
+ * @returns {{totalCalls: number, byTool: Record<string, number>, denied: number,
85
+ * uniquePaths: number, repeats: number, repeatPct: number|null}}
86
+ */
87
+ export function runBehaviour(events, { runId } = {}) {
88
+ const list = Array.isArray(events) ? events : [];
89
+ const scoped = runId === undefined ? list : list.filter((e) => e?.run_id === runId);
90
+
91
+ const byTool = /** @type {Record<string, number>} */ ({});
92
+ const paths = new Set();
93
+ const keyCounts = new Map();
94
+ let totalCalls = 0;
95
+ let denied = 0;
96
+
97
+ for (const e of scoped) {
98
+ const action = e?.action ?? {};
99
+ if (action.type === 'llm') continue; // a provider round, never a tool call
100
+ if (e?.decision === 'deny') { denied += 1; continue; }
101
+ if (e?.decision !== 'allow') continue; // neither allow nor deny: not a counted call
102
+
103
+ totalCalls += 1;
104
+ const tool = action.args?.tool ?? action.type ?? 'unknown';
105
+ byTool[tool] = (byTool[tool] ?? 0) + 1;
106
+ if (action.path) paths.add(action.path);
107
+
108
+ const k = repeatKey(action);
109
+ keyCounts.set(k, (keyCounts.get(k) ?? 0) + 1);
110
+ }
111
+
112
+ let repeats = 0;
113
+ for (const count of keyCounts.values()) if (count > 1) repeats += count - 1;
114
+
115
+ return {
116
+ totalCalls,
117
+ byTool,
118
+ denied,
119
+ uniquePaths: paths.size,
120
+ repeats,
121
+ repeatPct: totalCalls > 0 ? Math.round((repeats / totalCalls) * 100) : null,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Render a {@link runBehaviour} summary as the agreed printable block
127
+ * (2–4 short lines). A zero-call run formats plainly ("0 tool calls") rather
128
+ * than a NaN-laced percentage — an empty run is a complete, reportable
129
+ * answer, not a division error.
130
+ * @param {ReturnType<typeof runBehaviour>} summary
131
+ * @returns {string}
132
+ */
133
+ export function formatBehaviour(summary) {
134
+ const { totalCalls, byTool, denied, repeats, repeatPct } = summary; // uniquePaths: data-only, not in the agreed printed shape
135
+ const lines = [];
136
+
137
+ if (totalCalls === 0) {
138
+ lines.push('0 tool calls');
139
+ } else {
140
+ const breakdown = Object.entries(byTool)
141
+ .sort((a, b) => b[1] - a[1])
142
+ .map(([tool, n]) => `${n} ${displayLabel(tool)}`)
143
+ .join(', ');
144
+ lines.push(`${totalCalls} tool call${totalCalls === 1 ? '' : 's'} · ${breakdown}`);
145
+ lines.push(`${repeats} exact repeat${repeats === 1 ? '' : 's'} (~${repeatPct}%)`);
146
+ }
147
+ if (denied > 0) lines.push(`${denied} denied`);
148
+
149
+ return lines.join('\n');
150
+ }
package/src/bridges.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Layer 3 — the REUSE registry (design record:
2
- // docs/02-features/2026-08-01-layer-3-reuse-design.md, D1/D2/D6, frozen 2026-08-01).
2
+ // docs/product/2026-08-01-layer-3-reuse-design.md, D1/D2/D6, frozen 2026-08-01).
3
3
  //
4
4
  // A BRIDGE is the plan a green actually executed, kept so the next run of the same
5
5
  // SHAPE starts from it instead of cold. This module is the box and the label on it:
package/src/calibrate.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // THE CALIBRATION GATE — softgreen module 5 (design record
2
- // docs/02-features/2026-08-17-softgreen-review-door-design.md §4.4, the
2
+ // docs/product/2026-08-17-softgreen-review-door-design.md §4.4, the
3
3
  // 2026-08-18 threshold addendum rulings 1–3, and the second same-day addendum's
4
4
  // rulings 1 and 3).
5
5
  //
package/src/cardauthor.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // THE TWO SIGNED ARTIFACTS — softgreen module 4 (design record
2
- // docs/02-features/2026-08-17-softgreen-review-door-design.md §4.3 and §4.4,
2
+ // docs/product/2026-08-17-softgreen-review-door-design.md §4.3 and §4.4,
3
3
  // plus the 2026-08-18 threshold addendum).
4
4
  //
5
5
  // Module 1 built the judged floor's core, module 2 wired the stage into a close,
@@ -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
+ }
package/src/index.js CHANGED
@@ -228,6 +228,17 @@ export {
228
228
  HUMAN_PAUSE, HITL_PAUSE, HITL_DECISION_RED, HUMAN_CHECKPOINTS,
229
229
  } from './declaredclose.js';
230
230
  export { closeStagesOf } from './plan.js';
231
+ // Build-list item 2 (2026-08-23) — the run behaviour summary: a report-only
232
+ // read of a run's gate-audit records. Gates nothing, mints no verdict,
233
+ // writes nothing to the spine; `runBehaviour` computes, `formatBehaviour`
234
+ // renders the printable block.
235
+ export { runBehaviour, formatBehaviour } from './behaviour.js';
236
+ // PRD build-list item 6 — generic run replay: point it at a run's spine (+
237
+ // its gate-audit sidecar) and get the whole story back as one object
238
+ // (`replayRun`) or one printable page (`formatReplay`). Reads only, mints no
239
+ // verdict, writes nothing to the spine — reuses `runBehaviour` rather than
240
+ // re-implementing the tool-call count.
241
+ export { replayRun, formatReplay } from './replay.js';
231
242
  // M4b — the interview, the refusal, the composition, and D9's three gates.
232
243
  // `prepareSigning` returns the resolved spec's HASH and the seed evidence; it
233
244
  // never signs — the approvals array and the human's word are unchanged.
@@ -235,3 +246,9 @@ export {
235
246
  runInterview, authorCloseForJob, assembleSpec, prepareSigning, refusalEvents, refuseLockedKind,
236
247
  GENRE, REFUSAL_LIB, REFUSAL_CATEGORY, AUTHORED_SPEC_FIELDS,
237
248
  } from './authorjob.js';
249
+ // PRD build-list item 5 (TODO #8) — the prompt-register inventory: every file
250
+ // known to carry model-facing prompt/instruction text, and the predicate a
251
+ // commit-message check (`scripts/prompt-commit-check.mjs`) uses to decide
252
+ // whether a changed file is one. The ONE place this list lives; exported so
253
+ // bareloop.context.md can point an adopter here instead of re-deriving it.
254
+ export { PROMPT_REGISTERS, isPromptFile } from './promptregisters.js';
package/src/job.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Job-spec validator — the OPERATOR-owned half of the config story (N1,
2
- // design record: docs/02-features/2026-07-12-n1-job-close-schema-design.md).
2
+ // design record: docs/product/2026-07-12-n1-job-close-schema-design.md).
3
3
  // A job spec is the arbiter's rulebook: the close chain, the budget, the
4
4
  // outer write fence, the escalation route. It is pure declarative data —
5
5
  // no freeform code is expressible anywhere in it — and it validates
package/src/judged.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // THE JUDGED FLOOR'S CORE — softgreen module 1 (design record
2
- // docs/02-features/2026-08-17-softgreen-review-door-design.md §4, plus the
2
+ // docs/product/2026-08-17-softgreen-review-door-design.md §4, plus the
3
3
  // 2026-08-18 threshold addendum). POC: poc/softgreen-judge/poc.mjs — validated
4
4
  // logic, rewritten here, never copied (graduation is a rewrite).
5
5
  //
package/src/ledger.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // The upstream ledger (module 4) — reactive lib/primitive incident monitoring.
2
- // Design record: docs/02-features/2026-07-11-upstream-ledger-design.md (adaptlearn
2
+ // Design record: docs/product/2026-07-11-upstream-ledger-design.md (adaptlearn
3
3
  // POC'd spec) + its 2026-07-13 addendum: the bareloop event mapping. One
4
4
  // append-only JSONL both the consumer (the panel, N6) and the maintainer read;
5
5
  // the A1/A2/A3 upstream-ask flow, mechanized — evidence in, human judgment out.
@@ -479,7 +479,7 @@ export function rateProvenance(record) {
479
479
  * Re-spelled rather than imported (src/run.js imports this module's siblings and a cycle
480
480
  * buys nothing here) — `tests/ledger.test.js` asserts the two spellings agree, because a
481
481
  * spend-slicing instrument that misses a writer is the F45 class this list exists to avoid. */
482
- const SPEND_RECORD_TYPES = Object.freeze(['worker-round', 'judge-round', 'worker-turn']);
482
+ export const SPEND_RECORD_TYPES = Object.freeze(['worker-round', 'judge-round', 'worker-turn']);
483
483
 
484
484
  /** @typedef {{rounds: number, usd: number, unpricedRounds: number}} ProvenanceBucket */
485
485
 
package/src/planrun.js CHANGED
@@ -42,9 +42,101 @@ import {
42
42
  REVIEW_DOOR, doorOpens, mechanicalStages,
43
43
  } from './kinds.js';
44
44
  import { workBranchName, prepareWorkBranch } from './workbranch.js';
45
+ import { TRANSPORT_MAX_ATTEMPTS, isTransportFailure } from './transport.js';
45
46
 
46
47
  const require = createRequire(import.meta.url);
47
- const { Loop, wireGate, HaltError } = require('bare-agent');
48
+ const { Loop, Retry, wireGate, HaltError } = require('bare-agent');
49
+
50
+ // F115 — hamr's ruling verbatim: "one retry for transport layer failure and
51
+ // reporting with the rest end of gate." TRANSPORT_MAX_ATTEMPTS is fixed
52
+ // (src/transport.js — never a job-spec/argv knob); `retryOn` is bare-agent's
53
+ // own extension seam (node_modules/bare-agent/src/retry.js), constrained to
54
+ // ONLY the no-HTTP-response transport class so a provider RESPONSE (4xx/5xx/
55
+ // 429) still rides bare-agent's own unchanged policy. One instance: `Retry`
56
+ // carries no per-call state, so it is safe to share across every worker.
57
+ // timeout:0 disables Retry's own per-attempt race (this run already has its
58
+ // own timing authority — the stall watchdog and the wall clock, src/stall.js
59
+ // / src/clock.js — and a second racing 60s clock here would just be a second,
60
+ // disagreeing one; bare-agent's own BA-18 idle-socket timeout is what
61
+ // actually bounds a hung call, and that is exactly one of the errors this
62
+ // retry is FOR). `backoff` is deliberately left unset: bare-agent's
63
+ // constructor does `options.backoff || 'exponential'`, so a literal `0` is
64
+ // falsy and would silently fall through to the same exponential default
65
+ // anyway — passing it would read as "no pause" while doing the opposite.
66
+ // With `maxAttempts:2` there is only ever ONE delay to compute (attempt 1),
67
+ // so the default exponential backoff's own formula (`2**0 * 1000`, capped at
68
+ // 30s) is a flat ~1s pause before the one retry — named here, not hidden
69
+ // behind a knob that lies about what it does.
70
+ const transportRetry = new Retry({ maxAttempts: TRANSPORT_MAX_ATTEMPTS, retryOn: isTransportFailure, timeout: 0 });
71
+
72
+ /**
73
+ * Wrap a Loop provider's `generate` so a single transport-class throw (fetch
74
+ * itself throwing — no HTTP response was ever produced) gets ONE retry via
75
+ * `transportRetry`, and the outcome is reported once the retry is settled.
76
+ * Every other property of `baseProvider` (`.name`, `.model`, `.ownsCycle`,
77
+ * `.policy`) passes through unchanged — bare-agent's Loop reads those
78
+ * directly (src/loop.js).
79
+ *
80
+ * F64 carve-out: a wall-DERIVED timeout (bare-agent's own `ETIMEDOUT`/
81
+ * `EDEADLINE`, thrown because `clock.callTimeoutMs()` told the provider to
82
+ * cut the call — `isWallTimeout`, src/clock.js) is doctrine's governance
83
+ * stop, never a transport casualty, and retrying it would both mis-spend a
84
+ * round the operator's clock already ended and corrupt `categorize`'s own
85
+ * classification (which reads the SAME error a moment later). So the
86
+ * per-call `retryOn` here is `isTransportFailure(err) && !isWallTimeout(err,
87
+ * clock)` — the clock's verdict always wins over the transport shape.
88
+ *
89
+ * Scope note: this wraps the ONE seam that builds every worker Loop
90
+ * (scout/drafter/step-worker/fix-worker, planrun.js `newLoop` ~line 2240) —
91
+ * and ONLY its Loop-driven (anthropic-api-shaped) provider branch. The judge
92
+ * loops (src/judged.js), the native/CLI session path (this file, `mkWorker`'s
93
+ * NATIVE branch above `ask`/`askFrom`, AND the toolless native/claude-json
94
+ * branch of `newLoop`'s own `loopProvider` — a retried native session
95
+ * re-spends a subscription turn, a different failure/cost model entirely),
96
+ * and the authoring scout (src/authorscout.js) are each a distinct call site
97
+ * outside hamr's ruling and are deliberately left unwrapped.
98
+ * @param {any} baseProvider
99
+ * @param {string} phase
100
+ * @param {(type: string, data?: object) => any} emit
101
+ * @param {(s: string) => string} scrub
102
+ * @param {import('./clock.js').Clock} clock
103
+ */
104
+ function withTransportRetry(baseProvider, phase, emit, scrub, clock) {
105
+ return new Proxy(baseProvider, {
106
+ get(target, prop, receiver) {
107
+ if (prop !== 'generate') return Reflect.get(target, prop, receiver);
108
+ return async (...args) => {
109
+ let attempts = 0;
110
+ let firstErr = null;
111
+ const fn = async () => {
112
+ attempts += 1;
113
+ try {
114
+ return await target.generate(...args);
115
+ } catch (err) {
116
+ if (attempts === 1) firstErr = err;
117
+ throw err;
118
+ }
119
+ };
120
+ // `recovered` is knowable only once the retried attempt has settled
121
+ // (succeeded or exhausted the budget) — emit exactly once, here,
122
+ // after the outcome is known, never speculatively before it.
123
+ const report = (recovered) => {
124
+ if (attempts <= 1) return; // no retry happened — nothing to report
125
+ const message = String(firstErr?.message ?? firstErr ?? '').slice(0, 200);
126
+ emit('transport-retry', { phase, attempt: 1, error: scrub(message), recovered });
127
+ };
128
+ try {
129
+ const result = await transportRetry.call(fn, { retryOn: (err) => isTransportFailure(err) && !isWallTimeout(err, clock) });
130
+ report(true);
131
+ return result;
132
+ } catch (err) {
133
+ report(false);
134
+ throw err;
135
+ }
136
+ };
137
+ },
138
+ });
139
+ }
48
140
  const { createShellTools } = require('bare-agent/tools');
49
141
 
50
142
  /** the scout's hard round bound — read-only survey, never a worker (v1.12 §1) */
@@ -714,6 +806,14 @@ ${scoutBlob || '(no scout notes)'}`;
714
806
  * 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
715
807
  */
716
808
  export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, readShim = false, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null }) {
809
+ // MEMORY-CACHE: what the read shim (src/readshim.js) saved THIS run, summed across
810
+ // every mkWorker's own shim instance (scout, drafter, each step's worker, the fix
811
+ // worker) — one accumulator closed over by all of them, because the shim's ledger
812
+ // is deliberately per-worker (F-class reset boundary) while this readout is
813
+ // per-run. `wrapRead`'s call sites feed it via `onCount`; nothing here re-derives
814
+ // a count, it only sums what the shim already measured. Read, never written, by
815
+ // anything outside the `finally` below.
816
+ const memoryCacheCounts = { pointered: 0, capped: 0, bytesWithheld: 0 };
717
817
  workdir = resolve(workdir);
718
818
  // The read shim's ARM, resolved at the door — before the workdir is read, before
719
819
  // the scout, before a single token. An unrecognised spelling throws HERE, where
@@ -744,6 +844,12 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
744
844
  throw new TypeError(`readShim: this arm caps reads, so a step granting "read" must also grant ${RETRIEVAL_PAIR.join(' and ')} — but the signed ceiling [${signedCeiling.join(', ')}] offers no ${short.join('/')}, so no legal plan exists and every draft would red as "read-blind". Re-sign the spec with ${short.join(' and ')}, or run with the shim off.`);
745
845
  }
746
846
  }
847
+ // MEMORY-CACHE's own `try`: everything from here on is a run that actually
848
+ // BEGAN (both $0 refusals above — the unknown-arm throw and the G1 throw —
849
+ // already happened and never touch this block), so `finally` below is the
850
+ // one place the accumulated counts are read out, no matter which of the
851
+ // paths below this point returns or throws.
852
+ try {
747
853
  // ONE spelling of the redaction, housed next to the inventory it reads
748
854
  // (src/validate.js) — the same helper the isolate verbs scrub the litectx store
749
855
  // with. Two hand-spelled copies of "what a secret looks like" is exactly how
@@ -1961,7 +2067,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1961
2067
  // are one shim because the cap creates the second problem — its strategy line
1962
2068
  // sends a capped worker to ctx_recall/ctx_get for a whole function, and a stale
1963
2069
  // pointer there is the cap's own dead end, not litectx's.
1964
- const shim = createReadShim({ arm: shimArm });
2070
+ const shim = createReadShim({
2071
+ arm: shimArm,
2072
+ // MEMORY-CACHE: this worker's shim reports into the RUN-level accumulator
2073
+ // declared at the top of `runPlan` — one shim per worker, one sum per run.
2074
+ onCount: (kind, bytes) => {
2075
+ if (kind === 'pointered') memoryCacheCounts.pointered++;
2076
+ else memoryCacheCounts.capped++;
2077
+ memoryCacheCounts.bytesWithheld += bytes;
2078
+ },
2079
+ });
1965
2080
  const rdTool = shell.find((/** @type {{name: string}} */ t) => t.name === TOOL_BY_VERB.read);
1966
2081
  // F48: on native, bound shell_read below the CLI's tool-result display cap and hand back a
1967
2082
  // TRUSTED truncation notice steering to ctx_get — the CLI's own truncation blinds the worker
@@ -2111,9 +2226,12 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2111
2226
  // claude-json TEXT path reports a real per-call cost that the Loop's
2112
2227
  // onLlmResult meters exactly like an API round, so the drafter's spend is
2113
2228
  // never invisible (F6/F44). The gate policy is wired but idle (no tools).
2229
+ // F115's retry wraps ONLY the API-shaped branch — a native/claude-json session
2230
+ // (below) has its own failure and cost model (a retried native session
2231
+ // re-spends a subscription turn, not a metered fetch) and is out of scope.
2114
2232
  const loopProvider = native
2115
2233
  ? /** @type {any} */ (nativeProvider)({ policy, maxTurns, hasTools: false })
2116
- : (workerProvider ?? provider);
2234
+ : withTransportRetry(workerProvider ?? provider, phase, emit, scrub, clock);
2117
2235
  // F66 — the stall watchdog. bare-agent's `timeoutMs` bounds socket INACTIVITY,
2118
2236
  // not call duration (provider-http.js `req.setTimeout` resets on every byte),
2119
2237
  // so a connection that stays alive while producing nothing is invisible to it:
@@ -3490,4 +3608,23 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3490
3608
  // the verdict was arrived at.
3491
3609
  if (fixOutcome === 'green') await emitReviewDoor('green', lastCloseVerdict);
3492
3610
  return fixOutcome === 'green' ? 'green' : 'escalated';
3611
+ } finally {
3612
+ // Emitted on EVERY exit (return or throw) — `finally` fires exactly once,
3613
+ // right before this call returns to `runJob`, which is where every other
3614
+ // caller's `job-end` lands next. Armed-only: an unarmed run leaves no trace
3615
+ // (absence reported as absence, never a fabricated zero line, PRD antigen).
3616
+ // No cost fields — this is not a spend record and spend-slicing instruments
3617
+ // are unaffected by it (F6/F12's ACCOUNTED_ROUND_TYPES list is untouched).
3618
+ if (readShimArm(readShim).on) {
3619
+ emit('memory-cache', {
3620
+ pointered: memoryCacheCounts.pointered,
3621
+ capped: memoryCacheCounts.capped,
3622
+ bytesWithheld: memoryCacheCounts.bytesWithheld,
3623
+ // bytes→tokens is an ESTIMATE (÷4, the same rough conversion used
3624
+ // elsewhere) — the field is named `approx…` so no consumer reads it
3625
+ // as measured the way `bytesWithheld` itself is.
3626
+ approxTokens: Math.round(memoryCacheCounts.bytesWithheld / 4),
3627
+ });
3628
+ }
3629
+ }
3493
3630
  }