@codedology/code-forge 0.3.2 → 0.5.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +88 -21
  3. package/bin/code-forge.mjs +6 -1
  4. package/docs/README.md +5 -4
  5. package/docs/reference/blocks.json +236 -1
  6. package/docs/reference/config.md +13 -1
  7. package/package.json +6 -2
  8. package/schema/code-forge.schema.json +26 -4
  9. package/skill/SKILL.md +8 -7
  10. package/skill/references/adapters/claude-code.md +5 -1
  11. package/skill/references/adapters/solo.md +1 -1
  12. package/skill/references/adapters/subprocess.md +1 -1
  13. package/skill/references/code.md +4 -3
  14. package/skill/references/continuity.md +4 -0
  15. package/skill/references/decisions.md +5 -2
  16. package/skill/references/ledger.md +10 -2
  17. package/skill/references/plan.md +12 -3
  18. package/skill/references/review.md +13 -6
  19. package/skill/references/security.md +3 -1
  20. package/skill/scripts/forge +1 -1
  21. package/skill/templates/coder-brief.md +36 -0
  22. package/skill/templates/plan.md +2 -0
  23. package/src/cli/block.mjs +51 -8
  24. package/src/cli/facts.mjs +16 -1
  25. package/src/cli/jev.mjs +114 -17
  26. package/src/cli/ledger.mjs +99 -8
  27. package/src/cli/logs.mjs +193 -58
  28. package/src/cli/plan.mjs +27 -11
  29. package/src/cli/report.mjs +15 -1
  30. package/src/cli/resolve.mjs +16 -1
  31. package/src/cli/review-file.mjs +10 -5
  32. package/src/cli/review.mjs +40 -9
  33. package/src/cli/run.mjs +109 -8
  34. package/src/config/closed-book.mjs +75 -0
  35. package/src/config/validate.mjs +128 -7
  36. package/src/decide/escalation.mjs +112 -11
  37. package/src/decide/questions.mjs +27 -4
  38. package/src/doctor/index.mjs +2 -1
  39. package/src/doctor/local.mjs +15 -0
  40. package/src/doctor/probes.mjs +48 -6
  41. package/src/engines/builders/claude.mjs +4 -1
  42. package/src/engines/builders/codex.mjs +26 -11
  43. package/src/engines/builders/grok.mjs +3 -3
  44. package/src/engines/efforts.mjs +109 -0
  45. package/src/engines/sentinel.mjs +34 -0
  46. package/src/keys/onepassword.mjs +3 -0
  47. package/src/ledger/report.mjs +66 -29
  48. package/src/ledger/spend.mjs +288 -0
  49. package/src/review/engine.mjs +57 -16
  50. package/src/review/fixloop.mjs +29 -6
  51. package/src/review/session-retry.mjs +69 -0
  52. package/src/review/triage.mjs +26 -1
  53. package/src/review/validate-review.mjs +6 -3
  54. package/src/session/author.mjs +6 -2
  55. package/src/session/facts.mjs +218 -53
  56. package/src/session/plan-check.mjs +71 -6
  57. package/src/session/plan-sections.mjs +98 -0
  58. package/src/session/spawn.mjs +373 -20
  59. package/src/state/block.mjs +13 -4
  60. package/src/state/config-snapshot.mjs +150 -0
  61. package/src/state/run.mjs +86 -0
  62. package/src/util/breadcrumbs.mjs +145 -0
  63. package/src/util/error-hint.mjs +3 -2
  64. package/src/util/error-kind.mjs +2 -2
  65. package/src/util/error-log.mjs +109 -18
  66. package/src/util/forbidden.mjs +3 -1
  67. package/src/util/known-fixes.json +1 -0
  68. package/src/util/known-fixes.mjs +153 -0
  69. package/src/util/locks.mjs +104 -0
  70. package/src/worker/engine.mjs +122 -40
  71. package/src/worker/loop.mjs +326 -78
  72. package/src/worker/queue.mjs +68 -7
@@ -16,13 +16,24 @@
16
16
  * Retry ladder (§5.6, V9): step 0 is the level's own model; step `k + 1` is `fallback[k]`. For a
17
17
  * Claude coder, `fallback[0]` (same provider) rides on `--fallback-model` inside step 0 and is not
18
18
  * spawned again. A step that reports unavailability (CLI missing, login expired, HTTP 402, or
19
- * rate-limited twice in a row) hands over to the next step; any other failure is final.
19
+ * rate-limited three times in a row) hands over to the next step; any other failure is final.
20
+ *
21
+ * Provider slots and backoff (B40): every attempt holds one slot of the module-level semaphore of
22
+ * its step's provider (`review.provider_concurrency[provider]`, defaults anthropic 4, openai 2,
23
+ * xai 2) for as long as the attempt runs, and frees it on every exit path. B41: the slot is taken
24
+ * FIRST, then the budget gate (check + reservation under the run's lock) runs; a refused session
25
+ * frees its slot at once, and a session waiting for a slot holds no reservation. A rate-limited attempt frees its slot, waits
26
+ * `RATE_LIMIT_BACKOFF_MS` (2 s, then 6 s, each ±30 % jitter) and tries the same step again; after
27
+ * the second wait a third rate limit moves the ladder to the next step.
20
28
  *
21
29
  * Every spawn prints `level=<Lx> provider=<p> model=<id> effort=<e> fallback_step=<n>` on stderr,
22
30
  * and every attempt writes one ledger row (`event: session`) with `tokens_source` `reported` when
23
31
  * the CLI's JSON carried usage, else `estimated` (bytes / 4).
24
32
  *
25
- * A coder argv that `isForbidden` matches is refused before anything spawns (§8.4).
33
+ * A coder argv that `isForbidden` matches is refused before anything spawns (§8.4). A
34
+ * reviewer/judge/S2/author level on openai is refused too (`closed-book`, B32: Codex always has a
35
+ * shell); an openai fallback step of such a session is skipped with one stderr line. The opt-in
36
+ * `review.allow_open_book_codex: true` lifts both and builds Codex's read-only argv.
26
37
  *
27
38
  * `background: true` (coders, §5.2): the child is started detached with stdout/stderr going to
28
39
  * `<run-root>/sessions/<id>/session.log`, its pid written to `<run-root>/sessions/<id>/pid.json`
@@ -36,6 +47,7 @@ import { createHash, randomBytes } from 'node:crypto';
36
47
  import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
37
48
  import path from 'node:path';
38
49
  import { Ajv2020 } from 'ajv/dist/2020.js';
50
+ import { CODEX_CLOSED_BOOK_REFUSAL, closedBookRefused, isNoToolRole, openBookCodexAllowed } from '../config/closed-book.mjs';
39
51
  import { resolveLevel } from '../config/known-ids.mjs';
40
52
  import { compileSchema } from '../config/schema-compile.mjs';
41
53
  import { buildArgv } from '../engines/builders/index.mjs';
@@ -43,9 +55,11 @@ import { removeCodexHome } from '../engines/codex-home.mjs';
43
55
  import { cliNameForProvider } from '../engines/provider-cli.mjs';
44
56
  import { parseSentinel } from '../engines/sentinel.mjs';
45
57
  import { parseUsage } from '../engines/usage-parse.mjs';
46
- import { appendRow } from '../ledger/write.mjs';
58
+ import { budgetUsdOf, checkBudget, estimateSessionUsd, priceSession, reservedUsd, reserveBudget, SPEND_UNREADABLE_MESSAGE, withBudgetLock } from '../ledger/spend.mjs';
59
+ import { appendRow, readAllRows } from '../ledger/write.mjs';
47
60
  import { exec } from '../util/exec.mjs';
48
61
  import { isForbidden, mergeForbidden } from '../util/forbidden.mjs';
62
+ import { semaphore } from '../util/locks.mjs';
49
63
  import { readStartTime, registerPid, UNKNOWN_START_TIME } from '../util/reaper.mjs';
50
64
  import { redact, writeSafe } from '../util/redact.mjs';
51
65
  import { currentRunRoot, pidsDir, runRoot, tmpBase, untrustedReason } from '../util/tmp.mjs';
@@ -59,7 +73,58 @@ export const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
59
73
  /** Unavailability reasons that move the ladder to the next step (§5.6). */
60
74
  export const UNAVAILABLE_REASONS = Object.freeze(['cli-missing', 'login-expired', 'http-402', 'rate-limited']);
61
75
 
62
- /** An error the spawner raises before anything runs (`code`: `usage`, `forbidden`). */
76
+ /** B40: concurrent sessions per provider when `review.provider_concurrency` does not say. */
77
+ export const DEFAULT_PROVIDER_CONCURRENCY = Object.freeze({ anthropic: 4, openai: 2, xai: 2 });
78
+
79
+ /** B40: the waits before each retry of a rate-limited step (ms, before jitter); its length is the retry count. */
80
+ export const RATE_LIMIT_BACKOFF_MS = Object.freeze([2000, 6000]);
81
+
82
+ /** B40: each backoff wait is its base ±30 %. */
83
+ export const RATE_LIMIT_JITTER = 0.3;
84
+
85
+ /**
86
+ * B40: the session limit of `provider` — `review.provider_concurrency[provider]` when it is an
87
+ * integer ≥ 1, else the default (1 for a provider with no default).
88
+ * @param {Record<string, any>} cfg @param {string} provider @returns {number}
89
+ */
90
+ export function providerLimit(cfg, provider) {
91
+ const set = /** @type {any} */ (cfg)?.review?.provider_concurrency?.[provider];
92
+ if (Number.isInteger(set) && set >= 1) return set;
93
+ return Object.hasOwn(DEFAULT_PROVIDER_CONCURRENCY, provider) ? DEFAULT_PROVIDER_CONCURRENCY[/** @type {"anthropic"|"openai"|"xai"} */ (provider)] : 1;
94
+ }
95
+
96
+ /** @type {Map<string, import('../util/locks.mjs').Semaphore>} one semaphore per (provider, limit), created on first use. */
97
+ const providerSemaphores = new Map();
98
+
99
+ /**
100
+ * B40: the in-process semaphore holding `provider`'s session slots at `limit`.
101
+ * @param {string} provider @param {number} limit
102
+ * @returns {import('../util/locks.mjs').Semaphore}
103
+ */
104
+ export function providerSemaphore(provider, limit) {
105
+ const key = `${provider}:${limit}`;
106
+ let sem = providerSemaphores.get(key);
107
+ if (sem === undefined) {
108
+ sem = semaphore(limit);
109
+ providerSemaphores.set(key, sem);
110
+ }
111
+ return sem;
112
+ }
113
+
114
+ /**
115
+ * B40: the wait before retry number `n` (0-based) of a rate-limited step: the base ±30 %, from
116
+ * `random` in [0, 1) (0.5 gives the base exactly).
117
+ * @param {number} n @param {() => number} random @returns {number}
118
+ */
119
+ export function backoffMs(n, random) {
120
+ const base = RATE_LIMIT_BACKOFF_MS[n];
121
+ return Math.round(base * (1 + RATE_LIMIT_JITTER * (2 * random() - 1)));
122
+ }
123
+
124
+ /** @param {number} ms @returns {Promise<void>} */
125
+ const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
126
+
127
+ /** An error the spawner raises before anything runs (`code`: `usage`, `forbidden`, `closed-book`). */
63
128
  export class SessionError extends Error {
64
129
  /** @param {string} code @param {string} message */
65
130
  constructor(code, message) {
@@ -84,6 +149,125 @@ export function runRootFor(runId, root) {
84
149
  return runRoot(runId, { root });
85
150
  }
86
151
 
152
+ /** How much of a timed-out session's stderr is kept (B30). */
153
+ export const DIAGNOSTIC_TAIL_BYTES = 4096;
154
+
155
+ /** A timed-out session's stderr over this size keeps no text at all (bounds the redact cost). */
156
+ export const STDERR_MAX_BYTES = 1024 * 1024;
157
+
158
+ /** How many stdout event types a timed-out session keeps. */
159
+ export const STDOUT_EVENTS_MAX = 20;
160
+
161
+ /** The longest event type kept. */
162
+ const EVENT_TYPE_MAX = 40;
163
+
164
+ /**
165
+ * Packet lines shorter than this are too common to tell apart from ordinary output, so they are
166
+ * NOT filtered. Accepted risk (B30): a packet line under 16 characters (a brace, `return x;`) can
167
+ * appear in a stderr tail; such a line carries no meaningful packet content on its own.
168
+ */
169
+ const PACKET_LINE_MIN = 16;
170
+
171
+ /** The tail note when the packet cannot be read: no stderr text is kept at all (fail closed). */
172
+ export const TAIL_WITHHELD_PACKET = 'withheld: packet unreadable';
173
+ /** The tail note when stderr is over `STDERR_MAX_BYTES`. */
174
+ export const TAIL_WITHHELD_LARGE = 'stderr too large, withheld';
175
+ /** The tail note when the last 4 KB of stderr is one partial line. */
176
+ export const TAIL_WITHHELD_LONG_LINE = 'one long line, withheld';
177
+
178
+ /** @param {Buffer} buf @param {number} at @returns {number} the first character start at or after `at`. */
179
+ function charStart(buf, at) {
180
+ let i = at;
181
+ while (i < buf.length && (buf[i] & 0xc0) === 0x80) i += 1;
182
+ return i;
183
+ }
184
+
185
+ /** @param {unknown} text @returns {string} */
186
+ function asText(text) {
187
+ return Buffer.isBuffer(text) ? text.toString('utf8') : typeof text === 'string' ? text : '';
188
+ }
189
+
190
+ const PEM_BEGIN = /-----BEGIN [A-Z0-9 ]*-----/;
191
+ const PEM_END = /-----END [A-Z0-9 ]*-----/;
192
+
193
+ /**
194
+ * The stderr tail of a timed-out session (B30), never packet content:
195
+ * 1. no packet text (unreadable) ⇒ no text: `{tail: null, note: 'withheld: packet unreadable'}`;
196
+ * stderr over `STDERR_MAX_BYTES` ⇒ `{tail: null, note: 'stderr too large, withheld'}`;
197
+ * 2. on the FULL raw text: every PEM block (`-----BEGIN` … `-----END` lines, an unterminated one
198
+ * to the end) and every line repeating a packet line (≥ 16 chars) is dropped;
199
+ * 3. the FULL remaining text goes through B0 `redact`;
200
+ * 4. over `maxBytes`, the last `maxBytes` bytes are kept, starting on a character, with the first
201
+ * (partial) line dropped; when that piece has no line break the tail is withheld.
202
+ * @param {string | Buffer | null | undefined} text @param {string | null} packetText @param {number} [maxBytes]
203
+ * @returns {{tail: string | null, note?: string}}
204
+ */
205
+ export function stderrTail(text, packetText, maxBytes = DIAGNOSTIC_TAIL_BYTES) {
206
+ if (typeof packetText !== 'string') return { tail: null, note: TAIL_WITHHELD_PACKET };
207
+ const raw = asText(text);
208
+ if (raw.length === 0) return { tail: '' };
209
+ if (Buffer.byteLength(raw) > STDERR_MAX_BYTES) return { tail: null, note: TAIL_WITHHELD_LARGE };
210
+ const packetLines = [
211
+ ...new Set(
212
+ packetText
213
+ .split('\n')
214
+ .map((l) => l.trim())
215
+ .filter((l) => l.length >= PACKET_LINE_MIN),
216
+ ),
217
+ ];
218
+ let inPem = false;
219
+ /** @type {string[]} */
220
+ const kept = [];
221
+ for (const line of raw.split('\n')) {
222
+ const t = line.trim();
223
+ if (inPem) {
224
+ if (PEM_END.test(t)) inPem = false;
225
+ continue;
226
+ }
227
+ if (PEM_BEGIN.test(t)) {
228
+ inPem = !PEM_END.test(t);
229
+ continue;
230
+ }
231
+ if (t.length >= PACKET_LINE_MIN && packetLines.some((p) => t.includes(p))) continue;
232
+ kept.push(line);
233
+ }
234
+ const safe = /** @type {string} */ (redact(kept.join('\n')));
235
+ const buf = Buffer.from(safe, 'utf8');
236
+ if (buf.length <= maxBytes) return { tail: safe };
237
+ const from = charStart(buf, buf.length - maxBytes);
238
+ const piece = buf.subarray(from).toString('utf8');
239
+ if (buf[from - 1] === 0x0a) return { tail: piece }; // the cut fell exactly on a line start
240
+ const nl = piece.indexOf('\n');
241
+ if (nl === -1) return { tail: '', note: TAIL_WITHHELD_LONG_LINE };
242
+ return { tail: piece.slice(nl + 1) };
243
+ }
244
+
245
+ /**
246
+ * What a timed-out session's stdout says without any of its text (B30): the last
247
+ * `STDOUT_EVENTS_MAX` event types (JSON lines with a string `type`, each cut to 40 characters and
248
+ * kept only when it is all `[a-z0-9_.-]`) and the total byte length. Non-JSON stdout gives only
249
+ * `stdout_bytes`. Event payloads, which can echo the packet, are never kept.
250
+ * @param {string | Buffer | null | undefined} text
251
+ * @returns {{stdout_bytes: number, stdout_events?: string[]}}
252
+ */
253
+ export function stdoutEvents(text) {
254
+ const raw = asText(text);
255
+ const bytes = Buffer.byteLength(raw);
256
+ /** @type {string[]} */
257
+ const types = [];
258
+ const lines = raw.split('\n');
259
+ // scan from the end: stop once enough events are kept, or after a bounded number of lines
260
+ for (let i = lines.length - 1, scanned = 0; i >= 0 && types.length < STDOUT_EVENTS_MAX && scanned < 5000; i -= 1, scanned += 1) {
261
+ const line = lines[i].trim();
262
+ if (!line.startsWith('{')) continue;
263
+ const event = tryJSON(line);
264
+ if (!event || typeof event !== 'object' || typeof event.type !== 'string') continue;
265
+ const type = event.type.slice(0, EVENT_TYPE_MAX);
266
+ if (/^[a-z0-9_.-]+$/.test(type)) types.unshift(type);
267
+ }
268
+ return types.length > 0 ? { stdout_bytes: bytes, stdout_events: types } : { stdout_bytes: bytes };
269
+ }
270
+
87
271
  /** @param {number} bytes */
88
272
  export const estimateTokens = (bytes) => Math.ceil(bytes / 4);
89
273
 
@@ -317,8 +501,12 @@ function childEnv(/** @type {NodeJS.ProcessEnv} */ env) {
317
501
  * @property {Partial<Record<"claude"|"codex"|"grok", string>>} [bins] - replaces argv[0] (tests: the fakes).
318
502
  * @property {typeof exec} [exec]
319
503
  * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow]
504
+ * @property {() => Promise<Array<Record<string, any>>>} [readRows] - the ledger rows the
505
+ * `budget.usd` check reads (default: `readAllRows(opts.slug)`).
320
506
  * @property {{write: (s: string) => unknown}} [stderr]
321
507
  * @property {NodeJS.ProcessEnv} [env]
508
+ * @property {(ms: number) => Promise<unknown>} [sleep] - B40: the rate-limit backoff wait (tests: a recorder).
509
+ * @property {() => number} [random] - B40: the backoff jitter source, in [0, 1) (default `Math.random`).
322
510
  */
323
511
 
324
512
  /**
@@ -432,18 +620,67 @@ export async function spawnSession(opts, deps = {}) {
432
620
  const runRootDir = opts.runRoot ?? currentRunRoot();
433
621
  if (role === 'facts' && opts.cwd !== undefined) assertFactsCwd(opts.cwd, runRootDir);
434
622
  const writeRow = deps.writeRow ?? (opts.slug ? (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: /** @type {string} */ (opts.slug) }) : null);
435
- const steps = ladderFor(resolveLevel(cfg, level), role);
623
+ const ladder = ladderFor(resolveLevel(cfg, level), role);
624
+ // B32: Codex has no no-tools mode, so it runs no reviewer/judge/S2/author session unless the
625
+ // config opts in (`review.allow_open_book_codex: true`). Without it, step 0 on openai refuses the
626
+ // session and an openai fallback step is dropped from the ladder.
627
+ const openBook = openBookCodexAllowed(cfg); // read ONCE: the refusal and every build use it
628
+ if (closedBookRefused(ladder[0].provider, role, openBook)) throw new SessionError('closed-book', CODEX_CLOSED_BOOK_REFUSAL);
629
+ const steps = ladder.filter((step) => !closedBookRefused(step.provider, role, openBook));
630
+ if (steps.length < ladder.length) writeSafe(stderr, `fallback skipped: ${ladder.length - steps.length} openai step(s) — ${CODEX_CLOSED_BOOK_REFUSAL}\n`);
436
631
  if (opts.background) steps.length = 1;
437
632
 
438
633
  const attempts = [];
439
- let rateLimitedOnce = false;
634
+ const sleep = deps.sleep ?? realSleep;
635
+ const random = deps.random ?? Math.random;
636
+ let rateLimitWaits = 0; // backoff waits taken on the current step
440
637
  for (let i = 0; i < steps.length; i += 1) {
441
638
  const step = steps[i];
639
+ // B40/B41: the provider slot first, then the budget gate — a session queued for a slot never
640
+ // holds a reservation; a refusal frees the slot at once (the `finally` below)
641
+ const releaseSlot = await providerSemaphore(step.provider, providerLimit(cfg, step.provider)).acquire();
642
+ /** @type {number | null} */
643
+ let waitMs = null;
644
+ try {
645
+ const gate = await budgetGate(opts, deps, writeRow, stderr, step);
646
+ if (gate.refused) return { ...gate.refused, provider: step.provider, model: step.model, effort: step.effort ?? null, fallback_step: step.fallback_step, attempts };
647
+ // B41: the reservation is released once the row is written (inside runAttempt) and, as a net,
648
+ // in the `finally` below on every other exit path (a throw, a background start)
649
+ const releaseReservation = gate.release;
650
+ try {
651
+ const outcome = await runAttempt(step, i + 1 === steps.length, releaseReservation);
652
+ if (outcome.result) return outcome.result;
653
+ waitMs = outcome.waitMs;
654
+ } finally {
655
+ releaseReservation();
656
+ }
657
+ } finally {
658
+ releaseSlot();
659
+ }
660
+ if (waitMs !== null) {
661
+ // B40: the slot is free while we wait; the same step is tried again (through the budget gate)
662
+ writeSafe(stderr, `rate-limited: provider=${step.provider} model=${step.model} retry in ${(waitMs / 1000).toFixed(1)} s\n`);
663
+ await sleep(waitMs);
664
+ i -= 1;
665
+ continue;
666
+ }
667
+ rateLimitWaits = 0;
668
+ }
669
+ throw new Error('spawnSession: unreachable — the ladder always has a step');
670
+
671
+ /**
672
+ * One attempt of `step` (the caller holds its provider slot).
673
+ * @param {ReturnType<typeof ladderFor>[number]} step @param {boolean} isLast - no step after this one.
674
+ * @param {() => void} releaseReservation - B41: called as soon as the session row is written.
675
+ * @returns {Promise<{result: SessionResult | null, waitMs: number | null}>} a `result` ends the
676
+ * session; otherwise `waitMs` = back off and retry the step, null = go to the next step.
677
+ */
678
+ async function runAttempt(step, isLast, releaseReservation) {
442
679
  const sessionDir = path.join(runRootDir, 'sessions', `${role}-${Date.now()}-${randomBytes(4).toString('hex')}`);
443
680
  mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
444
681
  let keep = false;
445
682
  try {
446
- const built = buildStep(opts, step, sessionDir);
683
+ const built = buildStep(opts, step, sessionDir, openBook);
447
684
  const cli = built.cli;
448
685
  built.argv[0] = deps.bins?.[cli] ?? built.argv[0];
449
686
  if (role === 'coder') {
@@ -458,8 +695,13 @@ export async function spawnSession(opts, deps = {}) {
458
695
  // B4.1: the builder's env (the Codex coder's CODEX_HOME) wins over the caller's; a
459
696
  // background session's home stays until the run root is swept.
460
697
  const bg = startBackground(built, sessionDir, runRootDir, { ...childEnv(deps.env ?? process.env), ...(built.env ?? {}) });
461
- if (writeRow) await writeRow({ event: 'session.background', ...base, pid: bg.pid, status: 'started' });
462
- return { status: 'started', ...bg, provider: step.provider, model: step.model, effort: step.effort ?? null, fallback_step: step.fallback_step, attempts };
698
+ // a detached child's tokens are never read back: its price is unknown (use `ledger add coder`)
699
+ try {
700
+ if (writeRow) await writeRow({ event: 'session.background', ...base, pid: bg.pid, status: 'started', usd: null, usd_unknown: true });
701
+ } finally {
702
+ releaseReservation();
703
+ }
704
+ return { waitMs: null, result: { status: 'started', ...bg, provider: step.provider, model: step.model, effort: step.effort ?? null, fallback_step: step.fallback_step, attempts } };
463
705
  }
464
706
 
465
707
  const attempt = await runForeground(built, opts, deps);
@@ -471,24 +713,117 @@ export async function spawnSession(opts, deps = {}) {
471
713
  tokens_in: attempt.usage.tokens_in,
472
714
  tokens_out: attempt.usage.tokens_out,
473
715
  tokens_source: attempt.usage.tokens_source,
716
+ ...sessionUsd(step.provider, level, attempt.usage),
474
717
  duration_ms: attempt.duration_ms,
475
718
  ...(level === 'L3' && step.fallback_step > 0 ? { l3_fallback: true } : {}),
476
719
  };
477
- if (writeRow) await writeRow(row);
720
+ try {
721
+ if (writeRow) await writeRow(row);
722
+ } finally {
723
+ releaseReservation(); // B41: the row now carries the spend (or the write failed)
724
+ }
478
725
  attempts.push({ fallback_step: step.fallback_step, provider: step.provider, model: step.model, status: attempt.status, reason: attempt.reason ?? null });
479
- if (attempt.status === 'unavailable' && attempt.reason === 'rate-limited' && !rateLimitedOnce) {
480
- rateLimitedOnce = true; // rate-limited once: the same step gets one more try (§5.6 "twice in a row")
481
- i -= 1;
482
- continue;
726
+ if (attempt.status === 'unavailable' && attempt.reason === 'rate-limited' && rateLimitWaits < RATE_LIMIT_BACKOFF_MS.length) {
727
+ // B40: back off (2 s, then 6 s, ±30 %) and try the same step again — never at once
728
+ const waitMs = backoffMs(rateLimitWaits, random);
729
+ rateLimitWaits += 1;
730
+ return { waitMs, result: null };
483
731
  }
484
- rateLimitedOnce = false;
485
- if (attempt.status === 'unavailable' && i + 1 < steps.length) continue;
486
- return { ...attempt, provider: step.provider, model: step.model, effort: step.effort ?? null, fallback_step: step.fallback_step, row, attempts };
732
+ if (attempt.status === 'unavailable' && !isLast) return { waitMs: null, result: null };
733
+ return { waitMs: null, result: { ...attempt, provider: step.provider, model: step.model, effort: step.effort ?? null, fallback_step: step.fallback_step, row, attempts } };
487
734
  } finally {
488
735
  if (!keep) rmSync(sessionDir, { recursive: true, force: true });
489
736
  }
490
737
  }
491
- throw new Error('spawnSession: unreachable — the ladder always has a step');
738
+ }
739
+
740
+ /**
741
+ * B33: a session row's dollars from its tokens and the static price table — `{usd, cost_source:
742
+ * 'estimated'}`, or `{usd: null, usd_unknown: true}` when the provider/level has no price.
743
+ * @param {string} provider @param {string} level @param {{tokens_in: number, tokens_out: number}} usage
744
+ */
745
+ function sessionUsd(provider, level, usage) {
746
+ const priced = priceSession({ provider, level, tokens_in: usage.tokens_in, tokens_out: usage.tokens_out });
747
+ return priced.usd_unknown ? { usd: null, usd_unknown: true } : { usd: priced.usd, cost_source: 'estimated' };
748
+ }
749
+
750
+ /**
751
+ * B33, R6 — the single budget choke point: before every spawn attempt of every role, a run with
752
+ * `budget.usd` set reads its spend from the ledger (`deps.readRows`, else the `slug` ledger).
753
+ * ≥ 80 % prints and records one warning per run; ≥ 100 % refuses with a plain message and a
754
+ * `budget.refused` row, and nothing is spawned. No `budget.usd` or no `run` ⇒ no check. Fail
755
+ * closed: with a budget and a run but no way to read the spend (no `readRows`, no `slug`, or the
756
+ * read throws) the session is refused too. It sits at the top of the ladder loop, so every attempt
757
+ * — each fallback step, the rate-limit retry of a step, and every `spawnSession` call a caller
758
+ * makes again (B30's timeout retry) — passes it.
759
+ *
760
+ * B41: with a budget, the ledger read, the check and the reservation of this attempt's estimated
761
+ * cost (`estimateSessionUsd`: the packet's bytes / 4 in, a fixed tokens_out per role and level)
762
+ * run under the run's budget lock, so concurrent sessions of one run see each other's holds. No
763
+ * budget (or no run) ⇒ no lock, no reservation.
764
+ * @param {SessionOpts} opts @param {SessionDeps} deps
765
+ * @param {((row: Record<string, any>) => Promise<unknown>) | null} writeRow
766
+ * @param {{write: (s: string) => unknown}} stderr
767
+ * @param {{provider: string}} step - the ladder step about to run (its provider prices the estimate).
768
+ * @returns {Promise<{refused: SessionResult, release?: undefined} | {refused: null, release: () => void}>}
769
+ * the refusal result, or the (idempotent) release of this attempt's reservation.
770
+ */
771
+ async function budgetGate(opts, deps, writeRow, stderr, step) {
772
+ const budget = budgetUsdOf(opts.cfg);
773
+ if (budget === null || typeof opts.run !== 'string' || opts.run.length === 0) return { refused: null, release: () => {} };
774
+ const run = opts.run;
775
+ const estimate = estimateSessionUsd({ provider: step.provider, level: opts.level, role: opts.role, tokensIn: packetTokens(opts) });
776
+ return withBudgetLock(run, async () => {
777
+ const refused = await checkGate(opts, deps, writeRow, stderr, budget, estimate);
778
+ if (refused) return { refused };
779
+ return { refused: null, release: reserveBudget(run, estimate) };
780
+ });
781
+ }
782
+
783
+ /**
784
+ * The packet's (or brief's) tokens plus the system prompt's, by the ledger's bytes / 4 rule (an
785
+ * unreadable packet counts 0 bytes; tokens_out is still reserved).
786
+ * @param {SessionOpts} opts @returns {number}
787
+ */
788
+ function packetTokens(opts) {
789
+ return estimateTokens(fileSize(opts.promptPath) + (typeof opts.systemPromptText === 'string' ? Buffer.byteLength(opts.systemPromptText) : 0));
790
+ }
791
+
792
+ /**
793
+ * The B33 check proper (called under the budget lock): read the run's spend, fail closed, refuse
794
+ * when spent + reserved + `estimate` passes the budget.
795
+ * @param {SessionOpts} opts - `opts.run` is a non-empty string (checked by `budgetGate`).
796
+ * @param {SessionDeps} deps
797
+ * @param {((row: Record<string, any>) => Promise<unknown>) | null} writeRow
798
+ * @param {{write: (s: string) => unknown}} stderr @param {number} budget @param {number} estimate
799
+ * @returns {Promise<SessionResult | null>} the refusal result, or null to go on.
800
+ */
801
+ async function checkGate(opts, deps, writeRow, stderr, budget, estimate) {
802
+ /** @type {Array<Record<string, any>> | null} */
803
+ let rows = null;
804
+ try {
805
+ const slug = opts.slug;
806
+ const read = deps.readRows ?? (slug ? () => readAllRows(slug) : null);
807
+ const got = read ? await read() : null;
808
+ rows = Array.isArray(got) ? got : null;
809
+ } catch {
810
+ rows = null;
811
+ }
812
+ if (rows === null) {
813
+ // fail closed: a budget we cannot check is never treated as "not reached"
814
+ writeSafe(stderr, `code-forge: ${SPEND_UNREADABLE_MESSAGE}\n`);
815
+ if (writeRow) {
816
+ try {
817
+ await writeRow({ event: 'budget.refused', run: opts.run, block: opts.block ?? null, role: opts.role, budget_usd: budget, spent_usd: null, reason: 'spend-unreadable' });
818
+ } catch {
819
+ // the refusal stands without its row
820
+ }
821
+ }
822
+ return { status: 'unavailable', reason: 'budget', message: SPEND_UNREADABLE_MESSAGE, answer: null, spent_usd: null, budget_usd: budget };
823
+ }
824
+ const verdict = await checkBudget({ budget, run: /** @type {string} */ (opts.run), rows, block: opts.block ?? null, role: opts.role, writeRow, stderr, reserved: reservedUsd(/** @type {string} */ (opts.run)), estimate });
825
+ if (!verdict.refuse) return null;
826
+ return { status: 'unavailable', reason: 'budget', message: verdict.message, answer: null, spent_usd: verdict.spent, budget_usd: budget };
492
827
  }
493
828
 
494
829
  /**
@@ -498,8 +833,9 @@ export async function spawnSession(opts, deps = {}) {
498
833
  * @param {SessionOpts} opts
499
834
  * @param {{provider: string, model: string, effort?: string, flagFallback?: {provider: string, model: string}}} step
500
835
  * @param {string} sessionDir
836
+ * @param {boolean} openBook - B32: `review.allow_open_book_codex`, read once by `spawnSession`.
501
837
  */
502
- function buildStep(opts, step, sessionDir) {
838
+ function buildStep(opts, step, sessionDir, openBook) {
503
839
  const closedBook = opts.role !== 'coder';
504
840
  // B9b.1: the facts delegate may run in the caller's read-only project snapshot; nobody else may
505
841
  const snapshot = opts.role === 'facts' && opts.cwd !== undefined ? realpathSync(path.resolve(opts.cwd)) : null; // validated by assertFactsCwd
@@ -514,6 +850,8 @@ function buildStep(opts, step, sessionDir) {
514
850
  if (cli === 'claude' && opts.maxBudgetUsd !== undefined) params.maxBudgetUsd = opts.maxBudgetUsd;
515
851
  if (closedBook && opts.systemPromptText !== undefined && cli !== 'codex') params.systemPromptText = opts.systemPromptText;
516
852
  if (cli === 'codex') params.outPath = path.join(sessionDir, 'out.json');
853
+ // B32 opt-in: only a no-tools role needs it; coder and facts build exactly as without it
854
+ if (cli === 'codex' && openBook && isNoToolRole(opts.role)) params.allowOpenBook = true;
517
855
  /** @type {Record<string, any> | null} */
518
856
  let compiled = null;
519
857
  const strict = variantFor(step.provider) === 'strict';
@@ -570,7 +908,22 @@ async function runForeground(built, opts, deps) {
570
908
 
571
909
  const unavailable = classifyUnavailable(res, read.parsed);
572
910
  if (unavailable) return { status: 'unavailable', reason: unavailable, answer: null, ...common };
573
- if (res.timedOut) return { status: 'timeout', reason: `killed after ${opts.timeoutMs ?? DEFAULT_TIMEOUT_MS} ms`, answer: null, ...common };
911
+ if (res.timedOut) {
912
+ // B30: what the CLI printed last, so a hang's cause can be seen — stderr redacted, capped and
913
+ // packet-free; stdout only as event types and a byte count, never its text
914
+ /** @type {string | null} */
915
+ let packetText = input !== undefined ? Buffer.from(input).toString('utf8') : null;
916
+ if (packetText === null) {
917
+ try {
918
+ packetText = readFileSync(opts.promptPath, 'utf8');
919
+ } catch {
920
+ packetText = null; // fail closed: no tails without the packet to filter against
921
+ }
922
+ }
923
+ const err = stderrTail(res.stderr, packetText);
924
+ const tails = { stderr_tail: err.tail, ...(err.note ? { tail_note: err.note } : {}), ...stdoutEvents(res.stdout) };
925
+ return { status: 'timeout', reason: `killed after ${opts.timeoutMs ?? DEFAULT_TIMEOUT_MS} ms`, answer: null, ...common, ...tails };
926
+ }
574
927
  if (res.result !== 'ok' || read.parsed?.is_error === true) return { status: 'failed', reason: `exit ${res.code}`, answer: null, ...common };
575
928
  if (built.compiled) {
576
929
  // validate against the schema the provider was actually given (the cached variant, stable across attempts)
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { createHash } from 'node:crypto';
15
15
  import os from 'node:os';
16
+ import { BLOCK_KINDS, blockKind, dispatchLevel } from '../decide/escalation.mjs';
16
17
  import { exec } from '../util/exec.mjs';
17
18
  import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
18
19
  import { StateError } from './paths.mjs';
@@ -125,11 +126,15 @@ function activeBlocks(record) {
125
126
 
126
127
  /**
127
128
  * `block open` — refuses a second open of the same id and any owned set that overlaps another
128
- * ACTIVE block's; records base, owned files, level, attempt, acceptance, line forecast.
129
+ * ACTIVE block's; records base, owned files, level, attempt, acceptance, line forecast and the
130
+ * block kind (B34: the declared `kind`, else `blockKind` of the owned files). A docs/contract
131
+ * block asked for below `levels.coder_floor_docs` (default L1) is opened AT the floor: the entry
132
+ * and the dispatch row carry the floor level, and the row adds `requested_level` and
133
+ * `trigger: 'docs_floor'`.
129
134
  * @param {{
130
135
  * runId: string, id: string, level: string, owned: string[], acceptance: unknown,
131
136
  * attempt?: number, base?: string, lines?: number, brief?: {path: string, content: Buffer | string},
132
- * writeRow: WriteRow, now?: Date,
137
+ * kind?: string, cfg?: Record<string, any>, writeRow: WriteRow, now?: Date,
133
138
  * }} opts
134
139
  * @returns {Promise<{block: Record<string, any>, pointer: string | null}>}
135
140
  */
@@ -139,8 +144,11 @@ export async function openBlock(opts) {
139
144
  if (typeof level !== 'string' || !LEVEL.test(level)) throw new StateError('bad-level', 'level must be L0..L3');
140
145
  if (!Number.isInteger(attempt) || attempt < 1) throw new StateError('bad-attempt', 'attempt must be an integer >= 1');
141
146
  if (lines !== null && !(Number.isInteger(lines) && lines > 0)) throw new StateError('bad-lines', 'lines forecast must be a positive integer');
147
+ if (opts.kind !== undefined && !BLOCK_KINDS.includes(opts.kind)) throw new StateError('bad-kind', `kind must be one of ${BLOCK_KINDS.join(', ')}`);
142
148
  const owned = assertOwned(opts.owned);
143
149
  const acceptance = assertAcceptance(opts.acceptance);
150
+ const kind = blockKind({ owned, declared: opts.kind });
151
+ const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
144
152
  const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
145
153
 
146
154
  const block = await mutateRun(runId, null, writeRow, async (record) => {
@@ -150,11 +158,12 @@ export async function openBlock(opts) {
150
158
  if (pair) throw new StateError('overlap', `block ${id} owns ${pair[0]}, which overlaps ${pair[1]} owned by open block ${otherId}`);
151
159
  }
152
160
  const baseSha = await resolveCommit(opts.base ?? 'HEAD', record.workspace);
153
- const entry = { block: id, base_sha: baseSha, owned_files: [...owned], level, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open' };
161
+ const entry = { block: id, base_sha: baseSha, owned_files: [...owned], level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open' };
154
162
  record.blocks[id] = entry;
163
+ const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
155
164
  return {
156
165
  result: entry,
157
- rows: [{ event: 'dispatch', block: id, level, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines }],
166
+ rows: [{ event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines }],
158
167
  };
159
168
  });
160
169
  return { block, pointer };