bareloop 0.13.0 → 0.14.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
@@ -5,6 +5,52 @@ All notable changes to bareloop are documented here. Format:
5
5
  [SemVer](https://semver.org/spec/v2.0.0.html). Pre-1.0: **minor** = a ladder rung or
6
6
  feature lands, **patch** = docs, fixes, scaffolding.
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.14.0] — 2026-08-25
11
+
12
+ ### Added
13
+
14
+ - **One transport-class retry** (F115, hamr's ruling): the ONE worker-Loop seam in
15
+ `src/planrun.js` now retries exactly once on a transport-only throw (TLS fault,
16
+ `ECONNRESET`/`EPIPE`/`ETIMEDOUT`, `fetch failed` over a network cause — never an HTTP
17
+ 4xx/5xx/429 response), classified by the new `src/transport.js` (`isTransportFailure`,
18
+ fixed `TRANSPORT_MAX_ATTEMPTS`, never operator-configurable). Emits a report-only
19
+ `transport-retry` spine record and forces `job-end.spendComplete: false` for the rest of the
20
+ run — the retried attempt's first throw may already have been billed — surfaced by
21
+ `scripts/run-u.mjs` as a `retries` line only when a retry occurred.
22
+ - **provider-red joins the resumable set** (F115, PRD v1.80 TODO #4): when the one transport
23
+ retry also fails and a `run-u` run ends `provider-red`, its tail now prints an honest
24
+ readout — spend so far as a floor, "died in step X of Y" (or "before a plan was accepted"),
25
+ and the exact `--resume` command — and `--resume` re-enters at the recorded step with the
26
+ accepted plan, folding prior spend in. No cost/step threshold gates the offer (the operator
27
+ decides, every time); verdict terminals (`escalated`/`step-red`/`plan-red`) still refuse.
28
+ Folded from a local `scripts/run-u.mjs` widening into the library's own canonical
29
+ `CHECKPOINT_OUTCOMES` (`src/reuse.js`) so the two lists cannot drift.
30
+ - **Run behaviour summary** (build-list item 2, agreed 2026-08-23): `runBehaviour(events, {
31
+ runId? })` and `formatBehaviour(summary)` in `src/behaviour.js`, plus
32
+ `scripts/behaviour-readout.mjs <gate-audit.jsonl> [run_id]`. A report-only text block —
33
+ "94 tool calls · 61 read, 28 grep, 5 recall" / "17 exact repeats (~18%)" — computed entirely
34
+ from a run's own gate-audit JSONL. Gates nothing, changes no verdict, writes nothing to the
35
+ spine. The exact-repeat key is the full recorded action (`type` + `path` + every `args` key),
36
+ never collapsed to `{type, path}` — sharper collapse would have merged different byte ranges
37
+ of the same file into false repeats.
38
+ - **`memory-cache` end-of-run readout** for the read shim: `runPlan` emits one spine record
39
+ (`{pointered, capped, bytesWithheld, approxTokens}`) summing what L1's pointer and L4's cap
40
+ withheld across the whole run, only when the shim is armed. `scripts/run-u.mjs` prints it as
41
+ `MEMORY-CACHE N re-reads answered from memory · M reads capped · X KB withheld (~Tk tokens
42
+ not re-sent)`; `scripts/behaviour-readout.mjs` prints the same line given the run's spine as
43
+ an optional third positional. No cost fields — report-only, not a spend record.
44
+
45
+ ### Fixed
46
+
47
+ - **`scripts/readshim-battery.mjs`'s spend reader compared a resumed leg's own money against
48
+ the wrong job-end figure** (F116): `readSpend`'s `ledgerUsd` now reads
49
+ `job-end.engagementSpentUsd` (this leg's spend) when a spine carries one, falling back to
50
+ `job-end.spentUsd` only for a cold run — `spentUsd` on a resumed job-end is a CHAIN total,
51
+ not this spine's own spend. `chainUsd`/`priorUsd` are exposed as new, honest fields and never
52
+ folded into `accountedUsd`/`ledgerUsd`/`spendUsd`.
53
+
8
54
  ## [0.13.0] — 2026-08-23
9
55
 
10
56
  ### Fixed (2026-08-23 — the release round: a pointer that lied, a close with no home, a rule that charged for its own refusal)
package/README.md CHANGED
@@ -162,7 +162,7 @@ package, and gaps get fixed upstream rather than shimmed.
162
162
  bareloop is the productization of **[adaptlearn](https://github.com/hamr0/adaptlearn)**, a
163
163
  closed experimental record on whether agent-authored workflows improve under an honest gate.
164
164
  The design laws it inherited, and the open questions it has not answered yet, are in
165
- [`docs/01-product/PRD.md`](docs/01-product/PRD.md).
165
+ [`docs/product/PRD.md`](docs/product/PRD.md).
166
166
 
167
167
  ## License
168
168
 
@@ -26,7 +26,7 @@
26
26
  > where `hitl` IS admitted, `human-confirms` IS live, and the three `hitl-*` terminals exist —
27
27
  > integrate against what is written here, and expect the class-level rename when the next rung
28
28
  > lands. Records: PRD Addendum v1.71 and
29
- > `docs/02-features/2026-08-17-softgreen-review-door-design.md` (repo-only, not shipped);
29
+ > `docs/product/2026-08-17-softgreen-review-door-design.md` (repo-only, not shipped);
30
30
  > evidence F102–F105.
31
31
 
32
32
  ## What this is
@@ -1093,13 +1093,25 @@ raises it.
1093
1093
  of PRICED rounds ONLY — never an estimate from token counts or averages (cap-not-estimate) —
1094
1094
  and it is stated even on the pre-token reds, where the honest figure is a real `0`.
1095
1095
  `spendComplete` says whether that figure is EXACT; `false` means `spentUsd` is a FLOOR ("at
1096
- least $X", true total unknowable) and must not be read as a total. Four things set it, all
1096
+ least $X", true total unknowable) and must not be read as a total. Five things set it, all
1097
1097
  the same class — a call whose cost is unknowable from here: a round that came back unpriced
1098
1098
  (F6); a run that ABSORBED a stall, because the fuse's silent reissue may pay twice for one
1099
1099
  answer and the flag is one-way whatever the outcome; a `wall-halt` that cut a call
1100
- mid-flight (`cutMidCall`); and a RESUME whose fold was itself a floor
1100
+ mid-flight (`cutMidCall`); a RESUME whose fold was itself a floor
1101
1101
  (`priorSpendComplete: false` — an unknown does not heal by being carried forward, so every
1102
- round of this attempt being priced repairs nothing about the one before it). A wall stop read BETWEEN iterations or steps has no call in flight,
1102
+ round of this attempt being priced repairs nothing about the one before it); and a
1103
+ **transport retry** (F115, hamr's ruling: "one retry for transport layer failure and
1104
+ reporting with the rest end of gate"). Every worker Loop (scout, drafter, each step, the fix
1105
+ worker — the ONE seam in `planrun.js`) gets exactly ONE extra attempt, and only for a
1106
+ transport-class throw — fetch itself throwing with no HTTP response at all (a TLS fault,
1107
+ `ECONNRESET`/`EPIPE`/`ETIMEDOUT`, `fetch failed` wrapping a network cause); an HTTP response
1108
+ (4xx/5xx/429) is never retried here and rides bare-agent's own unchanged policy. The budget is
1109
+ fixed (`TRANSPORT_MAX_ATTEMPTS` in `src/transport.js`) — never a job-spec or CLI knob, tighten-only
1110
+ doctrine. Each retry emits a report-only `transport-retry` spine record (`{phase, attempt, error,
1111
+ recovered}`, no cost field — the ledger is unaffected); the FIRST attempt threw before any usage
1112
+ figure came back, so **any** transport retry floors `spendComplete` for the rest of the run even
1113
+ when the retry recovers and the run finishes green — the same one-way flag class as `stalled`
1114
+ and `cutMidCall`. A wall stop read BETWEEN iterations or steps has no call in flight,
1103
1115
  so it stays exact. Both fields are present on all outcomes, so a consumer never branches on
1104
1116
  field presence and never has to launder a missing `spentUsd` into `$0`.
1105
1117
 
@@ -2084,6 +2096,52 @@ $0). `VOUCHED_RATE_SOURCES` is the frozen allow-list they key on. **Reporting on
2084
2096
  no class, no halt. CLI lands at N5; the
2085
2097
  panel reads the same file at N6.
2086
2098
 
2099
+ ### `runBehaviour(events, { runId? })` / `formatBehaviour(summary)` — `src/behaviour.js`
2100
+
2101
+ Build-list item 2 (agreed 2026-08-23): one report-only text block summarizing what a run
2102
+ DID with its tools, computed entirely from the run's own gate-audit JSONL. **Gates nothing,
2103
+ changes no verdict, writes NOTHING to the spine** — a pure function over records that already
2104
+ exist, same posture as `spendProvenance` above. `runBehaviour` counts and returns the summary
2105
+ object; `formatBehaviour` renders it as the printed block:
2106
+
2107
+ ```
2108
+ 94 tool calls · 61 read, 28 grep, 5 recall
2109
+ 17 exact repeats (~18%)
2110
+ ```
2111
+
2112
+ `llm` action rows are provider rounds, not tool calls, and are always excluded; only
2113
+ `decision: 'allow'` rows count toward the total (a `deny` is tallied separately as `N denied`,
2114
+ shown only when nonzero). **The exact-repeat key is the FULL recorded action — `type` + `path`
2115
+ + every key of `args`** (sorted, stably serialized), never collapsed to `{type, path}`: if the
2116
+ gate ever starts recording byte ranges or offsets, two different slices of one file must not
2117
+ read as the same repeated call. Repeat detection is therefore exactly as sharp as what the gate
2118
+ records, no sharper — a tool that reads identical bytes through two differently-shaped argument
2119
+ objects will not be caught by this. `scripts/behaviour-readout.mjs <gate-audit.jsonl> [run_id]`
2120
+ prints the block for one run or every run_id found in the file, skipping malformed lines with
2121
+ a count rather than throwing.
2122
+
2123
+ ```js
2124
+ import { runBehaviour, formatBehaviour } from 'bareloop';
2125
+ const events = fs.readFileSync(auditFile, 'utf8').trim().split('\n').map(JSON.parse);
2126
+ console.log(formatBehaviour(runBehaviour(events, { runId })));
2127
+ ```
2128
+
2129
+ #### `memory-cache` — the read shim's own end-of-run readout (`src/readshim.js`, `src/planrun.js`)
2130
+
2131
+ A single spine record, emitted once by `runPlan` right before it returns (so it lands on the
2132
+ spine before the caller's `job-end`), reporting what the READ SHIM (L1 pointer + L4 cap) saved
2133
+ this run: `{pointered, capped, bytesWithheld, approxTokens}`. `pointered` counts re-reads of
2134
+ unchanged content answered with a pointer instead of the bytes; `capped` counts reads served as
2135
+ a bounded slice; `bytesWithheld` sums exactly the bytes NOT re-sent in both cases (never
2136
+ estimated) — the diff lever (L2) isn't counted, it's a different response shape. `approxTokens`
2137
+ is `Math.round(bytesWithheld / 4)`, a bytes→tokens ESTIMATE named accordingly so nothing reads
2138
+ it as measured. **Emitted only when the shim is armed** (any `readShim` arm but the off one) —
2139
+ an unarmed run emits no record at all, never a fabricated zero. It carries no cost fields, so it
2140
+ is not a spend record and no spend-slicing instrument needs to account for it.
2141
+ `scripts/run-u.mjs` prints it as a `MEMORY-CACHE` line right after `BEHAVIOUR` when armed
2142
+ (`no record` if the run ended before the summary could fire); `scripts/behaviour-readout.mjs`
2143
+ prints the same line when handed the run's spine file as its optional third positional.
2144
+
2087
2145
  ## Architecture
2088
2146
 
2089
2147
  Three layers. An **outer shell** (dumb, permanent): per-run budget cap via bareguard,
@@ -2115,7 +2173,7 @@ is disclosed to the authoring agent; only admitted verbs are callable per job.
2115
2173
  ## Gotchas
2116
2174
 
2117
2175
  *TBD from real adopter friction; recorded here as they're found (repo-side friction goes
2118
- to `docs/FINDINGS.md`).*
2176
+ to `docs/logs/FINDINGS.md`).*
2119
2177
 
2120
2178
  ## Constraints
2121
2179
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.13.0",
3
+ "version": "0.14.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,7 +23,7 @@
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,
package/src/index.js CHANGED
@@ -228,6 +228,11 @@ 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';
231
236
  // M4b — the interview, the refusal, the composition, and D9's three gates.
232
237
  // `prepareSigning` returns the resolved spec's HASH and the seed evidence; it
233
238
  // never signs — the approvals array and the human's word are unchanged.
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.
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
  }
package/src/readshim.js CHANGED
@@ -332,13 +332,18 @@ const stalePastEofNotice = (path, startLine, endLine, lines, detail) =>
332
332
  * reference to the ledger — see its own note. Both live in one object because
333
333
  * they are one worker's shim: the read seam and the retrieval seam the cap
334
334
  * steers a capped worker towards.
335
- * @param {{cap?: number, arm?: ReadShimArm}} [opts] `cap`: delivery bound in bytes (the
336
- * shipped value is `READ_SHIM_CAP`; the option exists so the native path can pin the
337
- * two bounds to one number rather than two constants drifting apart). `arm`: which
338
- * levers are live (`readShimArm`). Defaults to ALL of them, so every caller written
339
- * before the arms existed keeps the behaviour it was written against.
335
+ * @param {{cap?: number, arm?: ReadShimArm, onCount?: ((kind: 'pointered'|'capped', bytesWithheld: number) => void)|null}} [opts]
336
+ * `cap`: delivery bound in bytes (the shipped value is `READ_SHIM_CAP`; the option
337
+ * exists so the native path can pin the two bounds to one number rather than two
338
+ * constants drifting apart). `arm`: which levers are live (`readShimArm`). Defaults to
339
+ * ALL of them, so every caller written before the arms existed keeps the behaviour it
340
+ * was written against. `onCount`: MEMORY-CACHE — fired once per pointer/capped-slice
341
+ * response with exactly the bytes withheld on THAT response, for a caller (e.g.
342
+ * `runPlan`) that sums several workers' shims into one run-level readout. Never called
343
+ * for a diff response (L2 is its own lever, uncounted here) or a first read under the
344
+ * cap (nothing was withheld).
340
345
  */
341
- export function createReadShim({ cap = READ_SHIM_CAP, arm = ARM_ALL } = {}) {
346
+ export function createReadShim({ cap = READ_SHIM_CAP, arm = ARM_ALL, onCount = null } = {}) {
342
347
  /** path → what the worker has been handed of the CURRENT content. `full` is
343
348
  * the delivered text itself, kept ONLY while the worker holds the whole file
344
349
  * (null otherwise) because that is the only state a diff is legal from.
@@ -354,6 +359,22 @@ export function createReadShim({ cap = READ_SHIM_CAP, arm = ARM_ALL } = {}) {
354
359
  * @type {Map<string, {hash: string, total: number, delivered: number, full: string|null}>} */
355
360
  const ledger = new Map();
356
361
 
362
+ // MEMORY-CACHE counters (this worker's own ledger, summed by a caller across
363
+ // workers for a run-level readout — see `onCount` above). Counted only where
364
+ // it is EXACT, never estimated: a pointer withholds exactly `total` bytes (the
365
+ // whole delivery it replaces) and a capped slice withholds exactly `total - end`
366
+ // (the tail this call did not send) — both numbers the ledger already computes
367
+ // for its own bookkeeping below, read here rather than re-derived.
368
+ let pointered = 0;
369
+ let capped = 0;
370
+ let bytesWithheld = 0;
371
+ /** @param {'pointered'|'capped'} kind @param {number} bytes */
372
+ const count = (kind, bytes) => {
373
+ if (kind === 'pointered') pointered++; else capped++;
374
+ bytesWithheld += bytes;
375
+ if (onCount) onCount(kind, bytes);
376
+ };
377
+
357
378
  /**
358
379
  * THE STALE-POINTER SERVE. When `ctx_get` throws `StalePointerError` — the file
359
380
  * changed on disk after indexing — hand back the REQUESTED LINE RANGE read
@@ -510,6 +531,9 @@ export function createReadShim({ cap = READ_SHIM_CAP, arm = ARM_ALL } = {}) {
510
531
  // slice path and is delivered (as nothing) truthfully.
511
532
  if (arm.pointer && seen && seen.hash === hash && start >= total) {
512
533
  ledger.set(path, { hash, total, delivered: total, full: r });
534
+ // Withheld = the whole delivery this pointer replaces: `total` bytes the
535
+ // worker asked to be re-sent and was not, because it already holds them.
536
+ count('pointered', total);
513
537
  return pointer(path, total);
514
538
  }
515
539
  ledger.set(path, { hash, total, delivered: end, full: end === total ? r : null });
@@ -518,12 +542,20 @@ export function createReadShim({ cap = READ_SHIM_CAP, arm = ARM_ALL } = {}) {
518
542
  // no notice, no reframing. That is the overwhelming majority of reads, and
519
543
  // it keeps the shim invisible where it buys nothing.
520
544
  if (start === 0 && end === total) return slice;
545
+ // Withheld = the tail beyond this slice (`total - end`), not the slice
546
+ // itself — the slice IS what was sent; the remainder is what was not.
547
+ if (end < total) count('capped', total - end);
521
548
  return slice + (end < total ? capNotice(start, end, total) : doneNotice(start, total));
522
549
  };
523
550
  return tool;
524
551
  };
525
552
 
526
- return { arm, cap, wrapRead, serveStale };
553
+ /** MEMORY-CACHE: this worker's own counts, exact — never re-derived, never
554
+ * estimated. `bytesWithheld` sums only the `pointered`/`capped` cases above;
555
+ * the diff lever (L2) is a different response shape and is not counted here. */
556
+ const summary = () => ({ pointered, capped, bytesWithheld });
557
+
558
+ return { arm, cap, wrapRead, serveStale, summary };
527
559
  }
528
560
 
529
561
  /**
package/src/reuse.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Layer 3 — the D7 ENVELOPE and the REUSE RUNNER (design record
2
- // docs/02-features/2026-08-01-layer-3-reuse-design.md, D3/D5/D6/D7 + R1).
2
+ // docs/product/2026-08-01-layer-3-reuse-design.md, D3/D5/D6/D7 + R1).
3
3
  //
4
4
  // hamr's sentence is the whole specification of this file, verbatim:
5
5
  //
@@ -115,8 +115,30 @@ export const REUSE_GRADED_RED = Object.freeze(['escalated']);
115
115
  * how they would come to disagree about which member is which: the first three are
116
116
  * allowances the loop's remaining tries are meant to spend against, and the human
117
117
  * ones are the stop no further try can change (see `hardStop`).
118
+ *
119
+ * `provider-red` folded in 2026-08-25 (PRD v1.80 TODO #4, F115 "Ruled: one transport
120
+ * retry"; hamr's ruling verbatim: *"joins resume anyways and you decide but you are
121
+ * given an honest readout and with 1 retry"*). It is a MACHINE halt, not a human one
122
+ * — it does NOT go into `HUMAN_CHECKPOINTS` — but it is a checkpoint by the same
123
+ * test every other member here passes: the close never got the say it was asked
124
+ * for (no verdict was rendered, so it is not a graded red — see `REUSE_GRADED_RED`
125
+ * above), and the scout+draft and every finished step it bought stand on disk
126
+ * exactly like a cap-halt or a wall-halt. The one transport retry
127
+ * (`src/planrun.js`'s `withTransportRetry`) already covers the common case; when
128
+ * that retry ALSO fails, throwing the run away because the transport hiccuped
129
+ * twice is throwing away real work for no reason a machine can judge. Deliberately
130
+ * NO cost/step threshold gates this: a threshold is a number picked from a small
131
+ * observed sample (F115 logged exactly 7 rows), which is arbiter territory
132
+ * reserved for hamr — the operator decides case by case, every time, off the
133
+ * honest readout `scripts/run-u.mjs` prints.
134
+ *
135
+ * This entry was built 2026-08-24 as a LOCAL widening in `scripts/run-u.mjs`
136
+ * (`RESUMABLE_HALTS = [...CHECKPOINT_OUTCOMES, 'provider-red']`) because this file
137
+ * sat outside that pass's scope — the fold here is the follow-up, so this repo
138
+ * does not end up with two lists that can drift, which is the exact failure this
139
+ * constant exists to end (see the paragraph above).
118
140
  */
119
- export const CHECKPOINT_OUTCOMES = Object.freeze(['cap-halt', 'wall-halt', 'step-stalled', ...HUMAN_CHECKPOINTS]);
141
+ export const CHECKPOINT_OUTCOMES = Object.freeze(['cap-halt', 'wall-halt', 'step-stalled', 'provider-red', ...HUMAN_CHECKPOINTS]);
120
142
 
121
143
  /**
122
144
  * v1's definition of "the same KIND of recipe" — and it is the LOAD GATE's own: the
package/src/root.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Layer R — the root: the within-run ratchet (LAYERS.md item 2; design record
2
- // docs/02-features/2026-07-19-layer-r-design.md). One state that survives attempts
2
+ // docs/product/2026-07-19-layer-r-design.md). One state that survives attempts
3
3
  // inside ONE run so a never-green run stops repeating itself (F21). Role is
4
4
  // CONTINUITY ONLY — F39 measured that hand-delivered state buys no conversion;
5
5
  // the semantic converter is Layer 2's job, not this module's.
package/src/run.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // smoke, the ONE budget ledger (cap-not-estimate; unpriced is never free, F6),
4
4
  // and the decision-ready stops. It composes the plan flow below it and
5
5
  // interprets nothing itself.
6
- // Design record: docs/02-features/2026-07-12-n2-headless-loop-design.md.
6
+ // Design record: docs/product/2026-07-12-n2-headless-loop-design.md.
7
7
  //
8
8
  // The legacy operator-authored `steps[]` path was DELETED 2026-07-26 (PRD
9
9
  // v1.32): the drafting call, config-v1, the per-step interpret loops and the
@@ -254,6 +254,15 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
254
254
  // outcome — keying on the outcome would floor the exact stop too, which is the same
255
255
  // dishonesty pointing the other way.
256
256
  let cutMidCall = false;
257
+ // F115 — did any worker call in this run absorb a transport-class retry
258
+ // (hamr's ruling: one retry, transport-only, planrun.js `withTransportRetry`)?
259
+ // The FIRST attempt of a retried call may already have been billed and threw
260
+ // before returning any usage figure — the same unknown a self-healed stall
261
+ // and a mid-call wall cut carry, so the same one-way floor applies even when
262
+ // the retry recovered and the run finishes green. Read off the `transport-retry`
263
+ // spine record's presence alone, never its `recovered` field: a recovered
264
+ // retry still hides the first attempt's possible spend.
265
+ let transportRetried = false;
257
266
  // The money on the terminal record, and whether the money is EXACT. `spentUsd`
258
267
  // is the accumulated sum of PRICED rounds ONLY — never an estimate derived
259
268
  // from tokens or averages (cap-not-estimate). When any round came back
@@ -263,7 +272,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
263
272
  // branch on field presence.
264
273
  // ONE spelling of the four causes, so the terminal and the in-flight money readout can
265
274
  // never disagree about whether the same run's figure is exact.
266
- const spendComplete = () => !unpriced && !stalled && !cutMidCall && !priorFloor;
275
+ const spendComplete = () => !unpriced && !stalled && !cutMidCall && !priorFloor && !transportRetried;
267
276
  const spend = () => ({
268
277
  spentUsd,
269
278
  spendComplete: spendComplete(),
@@ -364,6 +373,10 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
364
373
  // One-way, and read by `spend()` — the run ends on this record either way, but the
365
374
  // between-rounds stop must keep its exact figure rather than inheriting a floor.
366
375
  if (type === 'wall-halt' && /** @type {any} */ (data)?.cutMidCall === true) cutMidCall = true;
376
+ // F115 — one-way, same lane as `stall`/`cutMidCall`: a transport retry's
377
+ // FIRST attempt threw before any usage figure came back, so the priced sum
378
+ // is a floor for the rest of the run regardless of `recovered`.
379
+ if (type === 'transport-retry') transportRetried = true;
367
380
  return emit(type, data);
368
381
  };
369
382
  const pricingRed = () => {
package/src/selection.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Layer 3 — D3's DISPLAY half: the workflow LISTING, and the prompt that asks for a
2
- // pick (design record docs/02-features/2026-08-01-layer-3-reuse-design.md, D3).
2
+ // pick (design record docs/product/2026-08-01-layer-3-reuse-design.md, D3).
3
3
  //
4
4
  // hamr's ruling, verbatim: *"i think there should be a listing for workflows names that
5
5
  // user can choose or auto-choose, or do we want llm to always decide (we still need to
@@ -0,0 +1,71 @@
1
+ // hamr's ruling, verbatim: "one retry for transport layer failure and
2
+ // reporting with the rest end of gate." (F115, docs/logs/FINDINGS.md.)
3
+ //
4
+ // A transport failure is fetch THROWING — the request never produced an HTTP
5
+ // response at all (a TLS fault mid-record, a reset socket, a DNS/network
6
+ // failure). That is a DIFFERENT unknown from an HTTP response the provider
7
+ // DID send (4xx/5xx/429): a response means the provider answered, so
8
+ // bare-agent's own retry policy for those stays exactly what it is — this
9
+ // module never widens that. Retrying only the no-response case is the whole
10
+ // point: a call that dies mid-read may already have been billed (retry can
11
+ // pay twice), so the retry budget stays as tight as hamr authorized it —
12
+ // exactly one extra attempt, never configurable upward from a job spec or
13
+ // argv (tighten-only doctrine).
14
+ //
15
+ // Scope: this module wires ONE seam — the worker Loop built in
16
+ // src/planrun.js's `newLoop` (~line 2239), which is the ONE factory behind
17
+ // every scout/drafter/step/fix-worker call. It deliberately does NOT reach
18
+ // the judge loops (src/judged.js), the native/CLI loop (src/planrun.js
19
+ // ~2089), or the authoring scout (src/authorscout.js) — none of those are in
20
+ // scope of hamr's ruling and each has its own transport story.
21
+
22
+ /** The fixed retry budget: ONE extra attempt after the first transport
23
+ * throw. Never read from a job spec or CLI flag — raising it is arbiter
24
+ * territory (only a code change hamr signs, never a run-time knob). */
25
+ export const TRANSPORT_RETRIES = 1;
26
+
27
+ /** bare-agent's `Retry.call` counts ATTEMPTS, not retries. */
28
+ export const TRANSPORT_MAX_ATTEMPTS = TRANSPORT_RETRIES + 1;
29
+
30
+ /**
31
+ * Is `err` a transport-class failure — the request never produced an HTTP
32
+ * response? True for: a TLS fault mid-record ("bad record mac" and kin), a
33
+ * reset/broken/timed-out socket (ECONNRESET, EPIPE, ETIMEDOUT, ECONNREFUSED,
34
+ * ENETUNREACH — by code on the error itself or on `err.cause`), and
35
+ * `fetch failed` wrapping a network `cause`. False for anything carrying an
36
+ * HTTP status (`err.status`/`err.statusCode`, 4xx/5xx/429) or an explicit
37
+ * `err.retryable === false` — those are provider RESPONSES, not transport
38
+ * casualties, and bare-agent's own policy governs them unchanged.
39
+ * @param {any} err
40
+ * @returns {boolean}
41
+ */
42
+ export function isTransportFailure(err) {
43
+ if (err == null) return false;
44
+ // An explicit HTTP response is never a transport casualty, no matter what
45
+ // its message says — the provider answered.
46
+ const status = err.status ?? err.statusCode;
47
+ if (typeof status === 'number') return false;
48
+ if (err.retryable === false) return false;
49
+
50
+ const codeOf = (e) => (e && typeof e.code === 'string' ? e.code : null);
51
+ const TRANSPORT_CODES = new Set(['ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'ECONNREFUSED', 'ENETUNREACH', 'EAI_AGAIN']);
52
+ const code = codeOf(err) ?? codeOf(err.cause);
53
+ if (code && TRANSPORT_CODES.has(code)) return true;
54
+
55
+ const message = String(err.message ?? err ?? '');
56
+ if (/bad record mac/i.test(message)) return true;
57
+ if (TRANSPORT_CODES.has(message.trim())) return true;
58
+ for (const c of TRANSPORT_CODES) if (message.includes(c)) return true;
59
+ if (/fetch failed/i.test(message)) {
60
+ // "fetch failed" alone is Node's generic wrapper — it is only a
61
+ // transport casualty when its cause names a network fault, never on
62
+ // message text alone (a fetch can also fail on a non-network throw
63
+ // upstream, and that must not silently qualify).
64
+ const causeCode = codeOf(err.cause);
65
+ if (causeCode) return true;
66
+ const causeMsg = String(err.cause?.message ?? '');
67
+ if (/bad record mac/i.test(causeMsg) || [...TRANSPORT_CODES].some((c) => causeMsg.includes(c))) return true;
68
+ return false;
69
+ }
70
+ return false;
71
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Summarize what a run DID with its tools, from its gate-audit records
3
+ * alone. Pure and read-only: no IO, no spine writes, no verdict.
4
+ *
5
+ * Counting rules:
6
+ * - `llm` rows are provider rounds, never tool calls — always excluded.
7
+ * - a tool call counts toward `totalCalls`/`byTool`/`uniquePaths`/repeats
8
+ * only when `decision === 'allow'`; a `deny` is tallied separately in
9
+ * `denied` and touches nothing else (denied ≠ done).
10
+ * - `byTool` is keyed by `action.args.tool` when present, else
11
+ * `action.type` (an edit or ctx_remember has no `args.tool`).
12
+ * - exact repeats: see {@link repeatKey}. A first occurrence of an action
13
+ * is not itself a "repeat" — only the 2nd, 3rd, ... occurrences count,
14
+ * so `repeats` can never exceed `totalCalls - byTool-group-count`.
15
+ * @param {Array<{run_id?: string, action?: {type?: string, path?: string, args?: any}, decision?: string|null}>} events
16
+ * @param {{runId?: string}} [opts]
17
+ * @returns {{totalCalls: number, byTool: Record<string, number>, denied: number,
18
+ * uniquePaths: number, repeats: number, repeatPct: number|null}}
19
+ */
20
+ export function runBehaviour(events: Array<{
21
+ run_id?: string;
22
+ action?: {
23
+ type?: string;
24
+ path?: string;
25
+ args?: any;
26
+ };
27
+ decision?: string | null;
28
+ }>, { runId }?: {
29
+ runId?: string;
30
+ }): {
31
+ totalCalls: number;
32
+ byTool: Record<string, number>;
33
+ denied: number;
34
+ uniquePaths: number;
35
+ repeats: number;
36
+ repeatPct: number | null;
37
+ };
38
+ /**
39
+ * Render a {@link runBehaviour} summary as the agreed printable block
40
+ * (2–4 short lines). A zero-call run formats plainly ("0 tool calls") rather
41
+ * than a NaN-laced percentage — an empty run is a complete, reportable
42
+ * answer, not a division error.
43
+ * @param {ReturnType<typeof runBehaviour>} summary
44
+ * @returns {string}
45
+ */
46
+ export function formatBehaviour(summary: ReturnType<typeof runBehaviour>): string;
package/types/index.d.ts CHANGED
@@ -22,4 +22,5 @@ export { runAuthorScout, buildSeedListing, seedFileList, classifySurvey, AUTHOR_
22
22
  export { scrubRaw, RAW_PERSIST_MAX, RAW_TRIM_MARKER } from "./text.js";
23
23
  export { authorClose, authorPrompt, declarationSchema, makeLoopGenerate, MAX_REVISIONS, QUESTION_SETS, GREEN_QUESTIONS, SOFTGREEN_QUESTIONS, CLASS_STATEMENTS, questionsFor, requiredAnswersFor } from "./authorflow.js";
24
24
  export { validateCloseDecl, runDeclaredStages, declaredStages, isDeclaredClose, guardNames, closeGrade, DECLARED_GENRES, DECLARED_GAP_PREFIX, DECLARED_GAP_KEEP, DECLARED_CLOSE_CLASSES, CLOSE_DECL_FIELDS, HUMAN_PAUSE, HITL_PAUSE, HITL_DECISION_RED, HUMAN_CHECKPOINTS } from "./declaredclose.js";
25
+ export { runBehaviour, formatBehaviour } from "./behaviour.js";
25
26
  export { runInterview, authorCloseForJob, assembleSpec, prepareSigning, refusalEvents, refuseLockedKind, GENRE, REFUSAL_LIB, REFUSAL_CATEGORY, AUTHORED_SPEC_FIELDS } from "./authorjob.js";
@@ -36,15 +36,21 @@ export function readShimStrategy(arm: ReadShimArm): string;
36
36
  * reference to the ledger — see its own note. Both live in one object because
37
37
  * they are one worker's shim: the read seam and the retrieval seam the cap
38
38
  * steers a capped worker towards.
39
- * @param {{cap?: number, arm?: ReadShimArm}} [opts] `cap`: delivery bound in bytes (the
40
- * shipped value is `READ_SHIM_CAP`; the option exists so the native path can pin the
41
- * two bounds to one number rather than two constants drifting apart). `arm`: which
42
- * levers are live (`readShimArm`). Defaults to ALL of them, so every caller written
43
- * before the arms existed keeps the behaviour it was written against.
39
+ * @param {{cap?: number, arm?: ReadShimArm, onCount?: ((kind: 'pointered'|'capped', bytesWithheld: number) => void)|null}} [opts]
40
+ * `cap`: delivery bound in bytes (the shipped value is `READ_SHIM_CAP`; the option
41
+ * exists so the native path can pin the two bounds to one number rather than two
42
+ * constants drifting apart). `arm`: which levers are live (`readShimArm`). Defaults to
43
+ * ALL of them, so every caller written before the arms existed keeps the behaviour it
44
+ * was written against. `onCount`: MEMORY-CACHE — fired once per pointer/capped-slice
45
+ * response with exactly the bytes withheld on THAT response, for a caller (e.g.
46
+ * `runPlan`) that sums several workers' shims into one run-level readout. Never called
47
+ * for a diff response (L2 is its own lever, uncounted here) or a first read under the
48
+ * cap (nothing was withheld).
44
49
  */
45
- export function createReadShim({ cap, arm }?: {
50
+ export function createReadShim({ cap, arm, onCount }?: {
46
51
  cap?: number;
47
52
  arm?: ReadShimArm;
53
+ onCount?: ((kind: "pointered" | "capped", bytesWithheld: number) => void) | null;
48
54
  }): {
49
55
  arm: ReadShimArm;
50
56
  cap: number;
@@ -56,6 +62,11 @@ export function createReadShim({ cap, arm }?: {
56
62
  execute: (args: any) => Promise<any>;
57
63
  };
58
64
  serveStale: ({ file, path, startLine, endLine, detail }: StaleReq) => StaleServe | null;
65
+ summary: () => {
66
+ pointered: number;
67
+ capped: number;
68
+ bytesWithheld: number;
69
+ };
59
70
  };
60
71
  /**
61
72
  * The one-tool spelling, kept for every caller written before the shim became an
package/types/reuse.d.ts CHANGED
@@ -589,6 +589,28 @@ export const REUSE_GRADED_RED: readonly string[];
589
589
  * how they would come to disagree about which member is which: the first three are
590
590
  * allowances the loop's remaining tries are meant to spend against, and the human
591
591
  * ones are the stop no further try can change (see `hardStop`).
592
+ *
593
+ * `provider-red` folded in 2026-08-25 (PRD v1.80 TODO #4, F115 "Ruled: one transport
594
+ * retry"; hamr's ruling verbatim: *"joins resume anyways and you decide but you are
595
+ * given an honest readout and with 1 retry"*). It is a MACHINE halt, not a human one
596
+ * — it does NOT go into `HUMAN_CHECKPOINTS` — but it is a checkpoint by the same
597
+ * test every other member here passes: the close never got the say it was asked
598
+ * for (no verdict was rendered, so it is not a graded red — see `REUSE_GRADED_RED`
599
+ * above), and the scout+draft and every finished step it bought stand on disk
600
+ * exactly like a cap-halt or a wall-halt. The one transport retry
601
+ * (`src/planrun.js`'s `withTransportRetry`) already covers the common case; when
602
+ * that retry ALSO fails, throwing the run away because the transport hiccuped
603
+ * twice is throwing away real work for no reason a machine can judge. Deliberately
604
+ * NO cost/step threshold gates this: a threshold is a number picked from a small
605
+ * observed sample (F115 logged exactly 7 rows), which is arbiter territory
606
+ * reserved for hamr — the operator decides case by case, every time, off the
607
+ * honest readout `scripts/run-u.mjs` prints.
608
+ *
609
+ * This entry was built 2026-08-24 as a LOCAL widening in `scripts/run-u.mjs`
610
+ * (`RESUMABLE_HALTS = [...CHECKPOINT_OUTCOMES, 'provider-red']`) because this file
611
+ * sat outside that pass's scope — the fold here is the follow-up, so this repo
612
+ * does not end up with two lists that can drift, which is the exact failure this
613
+ * constant exists to end (see the paragraph above).
592
614
  */
593
615
  export const CHECKPOINT_OUTCOMES: readonly string[];
594
616
  /**
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Is `err` a transport-class failure — the request never produced an HTTP
3
+ * response? True for: a TLS fault mid-record ("bad record mac" and kin), a
4
+ * reset/broken/timed-out socket (ECONNRESET, EPIPE, ETIMEDOUT, ECONNREFUSED,
5
+ * ENETUNREACH — by code on the error itself or on `err.cause`), and
6
+ * `fetch failed` wrapping a network `cause`. False for anything carrying an
7
+ * HTTP status (`err.status`/`err.statusCode`, 4xx/5xx/429) or an explicit
8
+ * `err.retryable === false` — those are provider RESPONSES, not transport
9
+ * casualties, and bare-agent's own policy governs them unchanged.
10
+ * @param {any} err
11
+ * @returns {boolean}
12
+ */
13
+ export function isTransportFailure(err: any): boolean;
14
+ /** The fixed retry budget: ONE extra attempt after the first transport
15
+ * throw. Never read from a job spec or CLI flag — raising it is arbiter
16
+ * territory (only a code change hamr signs, never a run-time knob). */
17
+ export const TRANSPORT_RETRIES: 1;
18
+ /** bare-agent's `Retry.call` counts ATTEMPTS, not retries. */
19
+ export const TRANSPORT_MAX_ATTEMPTS: number;