@codedology/code-forge 0.3.1 → 0.4.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 (67) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +79 -21
  3. package/bin/code-forge.mjs +6 -1
  4. package/docs/README.md +5 -4
  5. package/docs/reference/blocks.json +153 -1
  6. package/docs/reference/config.md +8 -1
  7. package/package.json +4 -2
  8. package/schema/code-forge.schema.json +15 -4
  9. package/skill/SKILL.md +7 -7
  10. package/skill/references/adapters/claude-code.md +1 -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 +8 -2
  17. package/skill/references/plan.md +3 -3
  18. package/skill/references/review.md +8 -5
  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/jev.mjs +114 -17
  25. package/src/cli/ledger.mjs +99 -8
  26. package/src/cli/logs.mjs +193 -58
  27. package/src/cli/plan.mjs +27 -11
  28. package/src/cli/report.mjs +15 -1
  29. package/src/cli/resolve.mjs +16 -1
  30. package/src/cli/review-file.mjs +10 -5
  31. package/src/cli/run.mjs +109 -8
  32. package/src/config/closed-book.mjs +75 -0
  33. package/src/config/validate.mjs +128 -7
  34. package/src/decide/escalation.mjs +112 -11
  35. package/src/decide/jev-client.mjs +1 -1
  36. package/src/decide/questions.mjs +39 -3
  37. package/src/doctor/probes.mjs +48 -6
  38. package/src/engines/builders/claude.mjs +4 -1
  39. package/src/engines/builders/codex.mjs +26 -11
  40. package/src/engines/builders/grok.mjs +3 -3
  41. package/src/engines/efforts.mjs +109 -0
  42. package/src/engines/sentinel.mjs +34 -0
  43. package/src/keys/onepassword.mjs +3 -0
  44. package/src/ledger/report.mjs +66 -29
  45. package/src/ledger/spend.mjs +171 -0
  46. package/src/review/engine.mjs +65 -23
  47. package/src/review/fixloop.mjs +29 -6
  48. package/src/review/session-retry.mjs +69 -0
  49. package/src/review/triage.mjs +26 -1
  50. package/src/review/validate-review.mjs +21 -6
  51. package/src/session/author.mjs +6 -2
  52. package/src/session/plan-check.mjs +71 -6
  53. package/src/session/plan-sections.mjs +98 -0
  54. package/src/session/spawn.mjs +217 -8
  55. package/src/state/block.mjs +13 -4
  56. package/src/state/config-snapshot.mjs +150 -0
  57. package/src/state/run.mjs +86 -0
  58. package/src/util/breadcrumbs.mjs +145 -0
  59. package/src/util/error-hint.mjs +3 -2
  60. package/src/util/error-kind.mjs +2 -2
  61. package/src/util/error-log.mjs +109 -18
  62. package/src/util/forbidden.mjs +3 -1
  63. package/src/util/known-fixes.json +1 -0
  64. package/src/util/known-fixes.mjs +153 -0
  65. package/src/worker/engine.mjs +13 -8
  66. package/src/worker/loop.mjs +135 -39
  67. package/src/worker/queue.mjs +68 -7
@@ -22,7 +22,10 @@
22
22
  * and every attempt writes one ledger row (`event: session`) with `tokens_source` `reported` when
23
23
  * the CLI's JSON carried usage, else `estimated` (bytes / 4).
24
24
  *
25
- * A coder argv that `isForbidden` matches is refused before anything spawns (§8.4).
25
+ * A coder argv that `isForbidden` matches is refused before anything spawns (§8.4). A
26
+ * reviewer/judge/S2/author level on openai is refused too (`closed-book`, B32: Codex always has a
27
+ * shell); an openai fallback step of such a session is skipped with one stderr line. The opt-in
28
+ * `review.allow_open_book_codex: true` lifts both and builds Codex's read-only argv.
26
29
  *
27
30
  * `background: true` (coders, §5.2): the child is started detached with stdout/stderr going to
28
31
  * `<run-root>/sessions/<id>/session.log`, its pid written to `<run-root>/sessions/<id>/pid.json`
@@ -36,6 +39,7 @@ import { createHash, randomBytes } from 'node:crypto';
36
39
  import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
37
40
  import path from 'node:path';
38
41
  import { Ajv2020 } from 'ajv/dist/2020.js';
42
+ import { CODEX_CLOSED_BOOK_REFUSAL, closedBookRefused, isNoToolRole, openBookCodexAllowed } from '../config/closed-book.mjs';
39
43
  import { resolveLevel } from '../config/known-ids.mjs';
40
44
  import { compileSchema } from '../config/schema-compile.mjs';
41
45
  import { buildArgv } from '../engines/builders/index.mjs';
@@ -43,7 +47,8 @@ import { removeCodexHome } from '../engines/codex-home.mjs';
43
47
  import { cliNameForProvider } from '../engines/provider-cli.mjs';
44
48
  import { parseSentinel } from '../engines/sentinel.mjs';
45
49
  import { parseUsage } from '../engines/usage-parse.mjs';
46
- import { appendRow } from '../ledger/write.mjs';
50
+ import { budgetUsdOf, checkBudget, priceSession, SPEND_UNREADABLE_MESSAGE } from '../ledger/spend.mjs';
51
+ import { appendRow, readAllRows } from '../ledger/write.mjs';
47
52
  import { exec } from '../util/exec.mjs';
48
53
  import { isForbidden, mergeForbidden } from '../util/forbidden.mjs';
49
54
  import { readStartTime, registerPid, UNKNOWN_START_TIME } from '../util/reaper.mjs';
@@ -59,7 +64,7 @@ export const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
59
64
  /** Unavailability reasons that move the ladder to the next step (§5.6). */
60
65
  export const UNAVAILABLE_REASONS = Object.freeze(['cli-missing', 'login-expired', 'http-402', 'rate-limited']);
61
66
 
62
- /** An error the spawner raises before anything runs (`code`: `usage`, `forbidden`). */
67
+ /** An error the spawner raises before anything runs (`code`: `usage`, `forbidden`, `closed-book`). */
63
68
  export class SessionError extends Error {
64
69
  /** @param {string} code @param {string} message */
65
70
  constructor(code, message) {
@@ -84,6 +89,125 @@ export function runRootFor(runId, root) {
84
89
  return runRoot(runId, { root });
85
90
  }
86
91
 
92
+ /** How much of a timed-out session's stderr is kept (B30). */
93
+ export const DIAGNOSTIC_TAIL_BYTES = 4096;
94
+
95
+ /** A timed-out session's stderr over this size keeps no text at all (bounds the redact cost). */
96
+ export const STDERR_MAX_BYTES = 1024 * 1024;
97
+
98
+ /** How many stdout event types a timed-out session keeps. */
99
+ export const STDOUT_EVENTS_MAX = 20;
100
+
101
+ /** The longest event type kept. */
102
+ const EVENT_TYPE_MAX = 40;
103
+
104
+ /**
105
+ * Packet lines shorter than this are too common to tell apart from ordinary output, so they are
106
+ * NOT filtered. Accepted risk (B30): a packet line under 16 characters (a brace, `return x;`) can
107
+ * appear in a stderr tail; such a line carries no meaningful packet content on its own.
108
+ */
109
+ const PACKET_LINE_MIN = 16;
110
+
111
+ /** The tail note when the packet cannot be read: no stderr text is kept at all (fail closed). */
112
+ export const TAIL_WITHHELD_PACKET = 'withheld: packet unreadable';
113
+ /** The tail note when stderr is over `STDERR_MAX_BYTES`. */
114
+ export const TAIL_WITHHELD_LARGE = 'stderr too large, withheld';
115
+ /** The tail note when the last 4 KB of stderr is one partial line. */
116
+ export const TAIL_WITHHELD_LONG_LINE = 'one long line, withheld';
117
+
118
+ /** @param {Buffer} buf @param {number} at @returns {number} the first character start at or after `at`. */
119
+ function charStart(buf, at) {
120
+ let i = at;
121
+ while (i < buf.length && (buf[i] & 0xc0) === 0x80) i += 1;
122
+ return i;
123
+ }
124
+
125
+ /** @param {unknown} text @returns {string} */
126
+ function asText(text) {
127
+ return Buffer.isBuffer(text) ? text.toString('utf8') : typeof text === 'string' ? text : '';
128
+ }
129
+
130
+ const PEM_BEGIN = /-----BEGIN [A-Z0-9 ]*-----/;
131
+ const PEM_END = /-----END [A-Z0-9 ]*-----/;
132
+
133
+ /**
134
+ * The stderr tail of a timed-out session (B30), never packet content:
135
+ * 1. no packet text (unreadable) ⇒ no text: `{tail: null, note: 'withheld: packet unreadable'}`;
136
+ * stderr over `STDERR_MAX_BYTES` ⇒ `{tail: null, note: 'stderr too large, withheld'}`;
137
+ * 2. on the FULL raw text: every PEM block (`-----BEGIN` … `-----END` lines, an unterminated one
138
+ * to the end) and every line repeating a packet line (≥ 16 chars) is dropped;
139
+ * 3. the FULL remaining text goes through B0 `redact`;
140
+ * 4. over `maxBytes`, the last `maxBytes` bytes are kept, starting on a character, with the first
141
+ * (partial) line dropped; when that piece has no line break the tail is withheld.
142
+ * @param {string | Buffer | null | undefined} text @param {string | null} packetText @param {number} [maxBytes]
143
+ * @returns {{tail: string | null, note?: string}}
144
+ */
145
+ export function stderrTail(text, packetText, maxBytes = DIAGNOSTIC_TAIL_BYTES) {
146
+ if (typeof packetText !== 'string') return { tail: null, note: TAIL_WITHHELD_PACKET };
147
+ const raw = asText(text);
148
+ if (raw.length === 0) return { tail: '' };
149
+ if (Buffer.byteLength(raw) > STDERR_MAX_BYTES) return { tail: null, note: TAIL_WITHHELD_LARGE };
150
+ const packetLines = [
151
+ ...new Set(
152
+ packetText
153
+ .split('\n')
154
+ .map((l) => l.trim())
155
+ .filter((l) => l.length >= PACKET_LINE_MIN),
156
+ ),
157
+ ];
158
+ let inPem = false;
159
+ /** @type {string[]} */
160
+ const kept = [];
161
+ for (const line of raw.split('\n')) {
162
+ const t = line.trim();
163
+ if (inPem) {
164
+ if (PEM_END.test(t)) inPem = false;
165
+ continue;
166
+ }
167
+ if (PEM_BEGIN.test(t)) {
168
+ inPem = !PEM_END.test(t);
169
+ continue;
170
+ }
171
+ if (t.length >= PACKET_LINE_MIN && packetLines.some((p) => t.includes(p))) continue;
172
+ kept.push(line);
173
+ }
174
+ const safe = /** @type {string} */ (redact(kept.join('\n')));
175
+ const buf = Buffer.from(safe, 'utf8');
176
+ if (buf.length <= maxBytes) return { tail: safe };
177
+ const from = charStart(buf, buf.length - maxBytes);
178
+ const piece = buf.subarray(from).toString('utf8');
179
+ if (buf[from - 1] === 0x0a) return { tail: piece }; // the cut fell exactly on a line start
180
+ const nl = piece.indexOf('\n');
181
+ if (nl === -1) return { tail: '', note: TAIL_WITHHELD_LONG_LINE };
182
+ return { tail: piece.slice(nl + 1) };
183
+ }
184
+
185
+ /**
186
+ * What a timed-out session's stdout says without any of its text (B30): the last
187
+ * `STDOUT_EVENTS_MAX` event types (JSON lines with a string `type`, each cut to 40 characters and
188
+ * kept only when it is all `[a-z0-9_.-]`) and the total byte length. Non-JSON stdout gives only
189
+ * `stdout_bytes`. Event payloads, which can echo the packet, are never kept.
190
+ * @param {string | Buffer | null | undefined} text
191
+ * @returns {{stdout_bytes: number, stdout_events?: string[]}}
192
+ */
193
+ export function stdoutEvents(text) {
194
+ const raw = asText(text);
195
+ const bytes = Buffer.byteLength(raw);
196
+ /** @type {string[]} */
197
+ const types = [];
198
+ const lines = raw.split('\n');
199
+ // scan from the end: stop once enough events are kept, or after a bounded number of lines
200
+ for (let i = lines.length - 1, scanned = 0; i >= 0 && types.length < STDOUT_EVENTS_MAX && scanned < 5000; i -= 1, scanned += 1) {
201
+ const line = lines[i].trim();
202
+ if (!line.startsWith('{')) continue;
203
+ const event = tryJSON(line);
204
+ if (!event || typeof event !== 'object' || typeof event.type !== 'string') continue;
205
+ const type = event.type.slice(0, EVENT_TYPE_MAX);
206
+ if (/^[a-z0-9_.-]+$/.test(type)) types.unshift(type);
207
+ }
208
+ return types.length > 0 ? { stdout_bytes: bytes, stdout_events: types } : { stdout_bytes: bytes };
209
+ }
210
+
87
211
  /** @param {number} bytes */
88
212
  export const estimateTokens = (bytes) => Math.ceil(bytes / 4);
89
213
 
@@ -317,6 +441,8 @@ function childEnv(/** @type {NodeJS.ProcessEnv} */ env) {
317
441
  * @property {Partial<Record<"claude"|"codex"|"grok", string>>} [bins] - replaces argv[0] (tests: the fakes).
318
442
  * @property {typeof exec} [exec]
319
443
  * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow]
444
+ * @property {() => Promise<Array<Record<string, any>>>} [readRows] - the ledger rows the
445
+ * `budget.usd` check reads (default: `readAllRows(opts.slug)`).
320
446
  * @property {{write: (s: string) => unknown}} [stderr]
321
447
  * @property {NodeJS.ProcessEnv} [env]
322
448
  */
@@ -432,18 +558,27 @@ export async function spawnSession(opts, deps = {}) {
432
558
  const runRootDir = opts.runRoot ?? currentRunRoot();
433
559
  if (role === 'facts' && opts.cwd !== undefined) assertFactsCwd(opts.cwd, runRootDir);
434
560
  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);
561
+ const ladder = ladderFor(resolveLevel(cfg, level), role);
562
+ // B32: Codex has no no-tools mode, so it runs no reviewer/judge/S2/author session unless the
563
+ // config opts in (`review.allow_open_book_codex: true`). Without it, step 0 on openai refuses the
564
+ // session and an openai fallback step is dropped from the ladder.
565
+ const openBook = openBookCodexAllowed(cfg); // read ONCE: the refusal and every build use it
566
+ if (closedBookRefused(ladder[0].provider, role, openBook)) throw new SessionError('closed-book', CODEX_CLOSED_BOOK_REFUSAL);
567
+ const steps = ladder.filter((step) => !closedBookRefused(step.provider, role, openBook));
568
+ if (steps.length < ladder.length) writeSafe(stderr, `fallback skipped: ${ladder.length - steps.length} openai step(s) — ${CODEX_CLOSED_BOOK_REFUSAL}\n`);
436
569
  if (opts.background) steps.length = 1;
437
570
 
438
571
  const attempts = [];
439
572
  let rateLimitedOnce = false;
440
573
  for (let i = 0; i < steps.length; i += 1) {
441
574
  const step = steps[i];
575
+ const refused = await budgetGate(opts, deps, writeRow, stderr);
576
+ if (refused) return { ...refused, provider: step.provider, model: step.model, effort: step.effort ?? null, fallback_step: step.fallback_step, attempts };
442
577
  const sessionDir = path.join(runRootDir, 'sessions', `${role}-${Date.now()}-${randomBytes(4).toString('hex')}`);
443
578
  mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
444
579
  let keep = false;
445
580
  try {
446
- const built = buildStep(opts, step, sessionDir);
581
+ const built = buildStep(opts, step, sessionDir, openBook);
447
582
  const cli = built.cli;
448
583
  built.argv[0] = deps.bins?.[cli] ?? built.argv[0];
449
584
  if (role === 'coder') {
@@ -458,7 +593,8 @@ export async function spawnSession(opts, deps = {}) {
458
593
  // B4.1: the builder's env (the Codex coder's CODEX_HOME) wins over the caller's; a
459
594
  // background session's home stays until the run root is swept.
460
595
  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' });
596
+ // a detached child's tokens are never read back: its price is unknown (use `ledger add coder`)
597
+ if (writeRow) await writeRow({ event: 'session.background', ...base, pid: bg.pid, status: 'started', usd: null, usd_unknown: true });
462
598
  return { status: 'started', ...bg, provider: step.provider, model: step.model, effort: step.effort ?? null, fallback_step: step.fallback_step, attempts };
463
599
  }
464
600
 
@@ -471,6 +607,7 @@ export async function spawnSession(opts, deps = {}) {
471
607
  tokens_in: attempt.usage.tokens_in,
472
608
  tokens_out: attempt.usage.tokens_out,
473
609
  tokens_source: attempt.usage.tokens_source,
610
+ ...sessionUsd(step.provider, level, attempt.usage),
474
611
  duration_ms: attempt.duration_ms,
475
612
  ...(level === 'L3' && step.fallback_step > 0 ? { l3_fallback: true } : {}),
476
613
  };
@@ -491,6 +628,60 @@ export async function spawnSession(opts, deps = {}) {
491
628
  throw new Error('spawnSession: unreachable — the ladder always has a step');
492
629
  }
493
630
 
631
+ /**
632
+ * B33: a session row's dollars from its tokens and the static price table — `{usd, cost_source:
633
+ * 'estimated'}`, or `{usd: null, usd_unknown: true}` when the provider/level has no price.
634
+ * @param {string} provider @param {string} level @param {{tokens_in: number, tokens_out: number}} usage
635
+ */
636
+ function sessionUsd(provider, level, usage) {
637
+ const priced = priceSession({ provider, level, tokens_in: usage.tokens_in, tokens_out: usage.tokens_out });
638
+ return priced.usd_unknown ? { usd: null, usd_unknown: true } : { usd: priced.usd, cost_source: 'estimated' };
639
+ }
640
+
641
+ /**
642
+ * B33, R6 — the single budget choke point: before every spawn attempt of every role, a run with
643
+ * `budget.usd` set reads its spend from the ledger (`deps.readRows`, else the `slug` ledger).
644
+ * ≥ 80 % prints and records one warning per run; ≥ 100 % refuses with a plain message and a
645
+ * `budget.refused` row, and nothing is spawned. No `budget.usd` or no `run` ⇒ no check. Fail
646
+ * closed: with a budget and a run but no way to read the spend (no `readRows`, no `slug`, or the
647
+ * read throws) the session is refused too. It sits at the top of the ladder loop, so every attempt
648
+ * — each fallback step, the rate-limit retry of a step, and every `spawnSession` call a caller
649
+ * makes again (B30's timeout retry) — passes it.
650
+ * @param {SessionOpts} opts @param {SessionDeps} deps
651
+ * @param {((row: Record<string, any>) => Promise<unknown>) | null} writeRow
652
+ * @param {{write: (s: string) => unknown}} stderr
653
+ * @returns {Promise<SessionResult | null>} the refusal result, or null to go on.
654
+ */
655
+ async function budgetGate(opts, deps, writeRow, stderr) {
656
+ const budget = budgetUsdOf(opts.cfg);
657
+ if (budget === null || typeof opts.run !== 'string' || opts.run.length === 0) return null;
658
+ /** @type {Array<Record<string, any>> | null} */
659
+ let rows = null;
660
+ try {
661
+ const slug = opts.slug;
662
+ const read = deps.readRows ?? (slug ? () => readAllRows(slug) : null);
663
+ const got = read ? await read() : null;
664
+ rows = Array.isArray(got) ? got : null;
665
+ } catch {
666
+ rows = null;
667
+ }
668
+ if (rows === null) {
669
+ // fail closed: a budget we cannot check is never treated as "not reached"
670
+ writeSafe(stderr, `code-forge: ${SPEND_UNREADABLE_MESSAGE}\n`);
671
+ if (writeRow) {
672
+ try {
673
+ await writeRow({ event: 'budget.refused', run: opts.run, block: opts.block ?? null, role: opts.role, budget_usd: budget, spent_usd: null, reason: 'spend-unreadable' });
674
+ } catch {
675
+ // the refusal stands without its row
676
+ }
677
+ }
678
+ return { status: 'unavailable', reason: 'budget', message: SPEND_UNREADABLE_MESSAGE, answer: null, spent_usd: null, budget_usd: budget };
679
+ }
680
+ const verdict = await checkBudget({ budget, run: opts.run, rows, block: opts.block ?? null, role: opts.role, writeRow, stderr });
681
+ if (!verdict.refuse) return null;
682
+ return { status: 'unavailable', reason: 'budget', message: verdict.message, answer: null, spent_usd: verdict.spent, budget_usd: budget };
683
+ }
684
+
494
685
  /**
495
686
  * Build one step's argv; closed-book roles get a fresh empty cwd inside the session dir (the facts
496
687
  * role: the caller's snapshot when `opts.cwd` names one), and Codex
@@ -498,8 +689,9 @@ export async function spawnSession(opts, deps = {}) {
498
689
  * @param {SessionOpts} opts
499
690
  * @param {{provider: string, model: string, effort?: string, flagFallback?: {provider: string, model: string}}} step
500
691
  * @param {string} sessionDir
692
+ * @param {boolean} openBook - B32: `review.allow_open_book_codex`, read once by `spawnSession`.
501
693
  */
502
- function buildStep(opts, step, sessionDir) {
694
+ function buildStep(opts, step, sessionDir, openBook) {
503
695
  const closedBook = opts.role !== 'coder';
504
696
  // B9b.1: the facts delegate may run in the caller's read-only project snapshot; nobody else may
505
697
  const snapshot = opts.role === 'facts' && opts.cwd !== undefined ? realpathSync(path.resolve(opts.cwd)) : null; // validated by assertFactsCwd
@@ -514,6 +706,8 @@ function buildStep(opts, step, sessionDir) {
514
706
  if (cli === 'claude' && opts.maxBudgetUsd !== undefined) params.maxBudgetUsd = opts.maxBudgetUsd;
515
707
  if (closedBook && opts.systemPromptText !== undefined && cli !== 'codex') params.systemPromptText = opts.systemPromptText;
516
708
  if (cli === 'codex') params.outPath = path.join(sessionDir, 'out.json');
709
+ // B32 opt-in: only a no-tools role needs it; coder and facts build exactly as without it
710
+ if (cli === 'codex' && openBook && isNoToolRole(opts.role)) params.allowOpenBook = true;
517
711
  /** @type {Record<string, any> | null} */
518
712
  let compiled = null;
519
713
  const strict = variantFor(step.provider) === 'strict';
@@ -570,7 +764,22 @@ async function runForeground(built, opts, deps) {
570
764
 
571
765
  const unavailable = classifyUnavailable(res, read.parsed);
572
766
  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 };
767
+ if (res.timedOut) {
768
+ // B30: what the CLI printed last, so a hang's cause can be seen — stderr redacted, capped and
769
+ // packet-free; stdout only as event types and a byte count, never its text
770
+ /** @type {string | null} */
771
+ let packetText = input !== undefined ? Buffer.from(input).toString('utf8') : null;
772
+ if (packetText === null) {
773
+ try {
774
+ packetText = readFileSync(opts.promptPath, 'utf8');
775
+ } catch {
776
+ packetText = null; // fail closed: no tails without the packet to filter against
777
+ }
778
+ }
779
+ const err = stderrTail(res.stderr, packetText);
780
+ const tails = { stderr_tail: err.tail, ...(err.note ? { tail_note: err.note } : {}), ...stdoutEvents(res.stdout) };
781
+ return { status: 'timeout', reason: `killed after ${opts.timeoutMs ?? DEFAULT_TIMEOUT_MS} ms`, answer: null, ...common, ...tails };
782
+ }
574
783
  if (res.result !== 'ok' || read.parsed?.is_error === true) return { status: 'failed', reason: `exit ${res.code}`, answer: null, ...common };
575
784
  if (built.compiled) {
576
785
  // 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 };
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The run's config snapshot (issue #2, block B35): `run start` stores the loaded
3
+ * `.code-forge.yml` in the run record, `run reload` replaces it mid-run, and the worker reviews
4
+ * every ticket with the snapshot it was enqueued under.
5
+ *
6
+ * Shape, in the authoritative run record (`~/.code-forge/runs/<run>.json`):
7
+ *
8
+ * config: {
9
+ * hash: sha256 of the canonical JSON of the snapshot in force,
10
+ * snapshots: {<hash>: <config>} every snapshot a queued ticket may still need,
11
+ * pins: {<ticket id>: <hash>} tickets that were pending at a reload keep the old snapshot,
12
+ * reloaded_at?: ISO time of the last reload,
13
+ * }
14
+ *
15
+ * A ticket is pinned by `run reload` itself (the orchestrator, under the run lock), never by the
16
+ * coder-writable ticket file, so a coder cannot pick an older (weaker) config for a new ticket.
17
+ * Messages here name key PATHS only, never values.
18
+ */
19
+
20
+ import { createHash } from 'node:crypto';
21
+
22
+ /**
23
+ * Keys that cannot change mid-run, each with why. A change to one is refused by `run reload`
24
+ * (end the run and start a new one).
25
+ */
26
+ export const IMMUTABLE_KEYS = Object.freeze({
27
+ version: 'the config format is fixed for the run',
28
+ 'project.slug': 'the ledger file of the run is chosen at run start',
29
+ engine: 'the run engine is fixed at run start',
30
+ 'tmp.root': "the worker's run temp root is created at run start",
31
+ keys: 'the worker resolves keys once, at start',
32
+ 'system1.key': 'the worker resolves the Jev key once, at start',
33
+ });
34
+
35
+ /** @param {unknown} v @returns {boolean} what `JSON.stringify` drops from an object. */
36
+ const dropped = (v) => v === undefined || typeof v === 'function' || typeof v === 'symbol';
37
+
38
+ /**
39
+ * JSON with object keys sorted at every level (arrays keep their order), so the hash does not
40
+ * depend on key order in the YAML. JSON semantics otherwise: a value with `toJSON` (a `Date` from
41
+ * a YAML timestamp ⇒ its ISO string) is converted first, and what JSON drops is dropped (`null` in
42
+ * a list) — so the hash of a config equals the hash of the same config after the run record's
43
+ * JSON save/read round trip.
44
+ * @param {unknown} value
45
+ * @returns {string}
46
+ */
47
+ export function canonicalJSON(value) {
48
+ const v = value !== null && typeof value === 'object' && typeof (/** @type {any} */ (value).toJSON) === 'function' ? /** @type {any} */ (value).toJSON() : value;
49
+ if (Array.isArray(v)) return `[${v.map((item) => canonicalJSON(dropped(item) ? null : item)).join(',')}]`;
50
+ if (v !== null && typeof v === 'object') {
51
+ const obj = /** @type {Record<string, unknown>} */ (v);
52
+ const keys = Object.keys(obj)
53
+ .filter((k) => !dropped(obj[k]))
54
+ .sort();
55
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJSON(obj[k])}`).join(',')}}`;
56
+ }
57
+ return dropped(v) ? 'null' : JSON.stringify(v);
58
+ }
59
+
60
+ /** @param {unknown} cfg @returns {string} sha256 hex of the canonical JSON. */
61
+ export function configHash(cfg) {
62
+ return createHash('sha256').update(canonicalJSON(cfg)).digest('hex');
63
+ }
64
+
65
+ /** @param {unknown} v @returns {v is Record<string, unknown>} */
66
+ const isMapping = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
67
+
68
+ /**
69
+ * The dotted key paths whose value differs between `before` and `after`, sorted. Mappings are
70
+ * walked key by key; a key present on one side only, a list, or a scalar is one path. A path
71
+ * segment that is not a plain identifier is written in brackets (`keys["a.b"]`).
72
+ * @param {unknown} before @param {unknown} after
73
+ * @returns {string[]}
74
+ */
75
+ export function changedKeyPaths(before, after) {
76
+ /** @type {string[]} */
77
+ const out = [];
78
+ /** @param {unknown} a @param {unknown} b @param {string} at */
79
+ const walk = (a, b, at) => {
80
+ if (isMapping(a) && isMapping(b)) {
81
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
82
+ for (const k of keys) walk(own(a, k), own(b, k), join(at, k));
83
+ return;
84
+ }
85
+ if (canonicalJSON(a ?? null) !== canonicalJSON(b ?? null) || (a === undefined) !== (b === undefined)) out.push(at);
86
+ };
87
+ walk(before, after, '');
88
+ return out.sort();
89
+ }
90
+
91
+ /** @param {Record<string, unknown>} obj @param {string} key @returns {unknown} an OWN property only. */
92
+ const own = (obj, key) => (Object.hasOwn(obj, key) ? obj[key] : undefined);
93
+
94
+ /** @param {string} at @param {string} key */
95
+ function join(at, key) {
96
+ const seg = /^[A-Za-z_][A-Za-z0-9_-]*$/.test(key) ? key : `[${JSON.stringify(key)}]`;
97
+ if (at === '') return seg;
98
+ return seg.startsWith('[') ? `${at}${seg}` : `${at}.${seg}`;
99
+ }
100
+
101
+ /**
102
+ * @param {unknown} cfg @param {string} dotted
103
+ * @returns {unknown} the value at `dotted`, or undefined.
104
+ */
105
+ function valueAt(cfg, dotted) {
106
+ /** @type {unknown} */
107
+ let cur = cfg;
108
+ for (const seg of dotted.split('.')) {
109
+ if (!isMapping(cur)) return undefined;
110
+ cur = own(cur, seg);
111
+ }
112
+ return cur;
113
+ }
114
+
115
+ /**
116
+ * The immutable keys whose value differs between the two configs, in {@link IMMUTABLE_KEYS} order.
117
+ * @param {unknown} before @param {unknown} after
118
+ * @returns {string[]}
119
+ */
120
+ export function immutableChanges(before, after) {
121
+ return Object.keys(IMMUTABLE_KEYS).filter((k) => canonicalJSON(valueAt(before, k) ?? null) !== canonicalJSON(valueAt(after, k) ?? null));
122
+ }
123
+
124
+ /**
125
+ * The `config` block for a new run record.
126
+ * @param {Record<string, any>} cfg
127
+ */
128
+ export function initialConfigState(cfg) {
129
+ const hash = configHash(cfg);
130
+ return { hash, snapshots: { [hash]: cfg }, pins: {} };
131
+ }
132
+
133
+ /**
134
+ * The snapshot a ticket is reviewed with: its pin when `run reload` pinned it, else the current
135
+ * one. Null when the record predates snapshots (the caller keeps the config it booted with).
136
+ * A snapshot that is missing or does not match its hash throws `config-snapshot`.
137
+ * @param {Record<string, any>} record @param {string} ticket
138
+ * @returns {{hash: string, config: Record<string, any>} | null}
139
+ */
140
+ export function snapshotFor(record, ticket) {
141
+ const state = record?.config;
142
+ if (!isMapping(state) || typeof state.hash !== 'string') return null;
143
+ const pins = isMapping(state.pins) ? state.pins : {};
144
+ const pin = own(pins, ticket);
145
+ const hash = typeof pin === 'string' ? pin : state.hash;
146
+ const snaps = isMapping(state.snapshots) ? state.snapshots : {};
147
+ const config = own(snaps, hash);
148
+ if (!isMapping(config) || configHash(config) !== hash) throw new Error('config-snapshot');
149
+ return { hash, config };
150
+ }
package/src/state/run.mjs CHANGED
@@ -16,6 +16,7 @@ import { mkdir, readFile, rename, rmdir, writeFile } from 'node:fs/promises';
16
16
  import path from 'node:path';
17
17
  import { exec } from '../util/exec.mjs';
18
18
  import { StateError, assertRunId, assertWorkspaceOutsideRuns, mirrorPath, newRunId, runRecordPath, runsDir } from './paths.mjs';
19
+ import { changedKeyPaths, configHash, IMMUTABLE_KEYS, immutableChanges, initialConfigState } from './config-snapshot.mjs';
19
20
  import { generateKey, loadKey, signRow } from './signer.mjs';
20
21
 
21
22
  /** @typedef {(row: Record<string, any>) => Promise<unknown>} WriteRow */
@@ -165,6 +166,9 @@ export async function startRun(opts) {
165
166
  status: 'active',
166
167
  blocks: {},
167
168
  orphans: [],
169
+ // B35: the config snapshot the worker reviews with (`run reload` replaces it). Only when the
170
+ // caller passed the loaded config: a record without it keeps the worker on its boot config.
171
+ ...(opts.config !== undefined ? { config: initialConfigState(config) } : {}),
168
172
  };
169
173
  return withRunLock(runId, async () => {
170
174
  const exists = await readRun(runId).then(
@@ -179,6 +183,88 @@ export async function startRun(opts) {
179
183
  });
180
184
  }
181
185
 
186
+ /**
187
+ * `run reload` (B35): replace the run's config snapshot with `config` (already loaded and
188
+ * validated by the caller), entirely under the run lock. Open blocks, their attempts and the
189
+ * worker pin are untouched.
190
+ *
191
+ * `readPending()` (the queue's tickets without a done marker) is called UNDER the lock, right
192
+ * before the record is saved, so the window in which a ticket could be enqueued unpinned is as
193
+ * short as it can be (enqueueing itself does not take the run lock). Each pending ticket keeps a
194
+ * pin whose snapshot is still stored; any other pending ticket — new, or pinned to a snapshot that
195
+ * has gone missing (never left dangling) — is pinned to the snapshot in force until now. Pins of
196
+ * tickets no longer pending are dropped, and so are snapshots no pin or the current hash names.
197
+ *
198
+ * Fixed keys ({@link IMMUTABLE_KEYS}): every one that changed is refused at once
199
+ * (`immutable-key`, nothing written). A record from before snapshots (no `config`) is checked
200
+ * against what it does store: `engine` and the ledger slug (`effectiveSlug`, computed by the
201
+ * caller the same way `run start` did); it then gets its first snapshot, `changed_keys: null`,
202
+ * `old_hash: null`.
203
+ *
204
+ * No change (same hash) ⇒ nothing written, no row. Otherwise the record is saved FIRST (it is the
205
+ * source of truth), then one signed `run.reload` row: the changed key PATHS (never values) and
206
+ * the old/new snapshot hashes. A row that cannot be written leaves the reload in place and is
207
+ * reported as `rowError`.
208
+ * @param {{
209
+ * runId: string, config: Record<string, any>, readPending?: () => string[], effectiveSlug?: string,
210
+ * writeRow: WriteRow, now?: Date,
211
+ * }} opts
212
+ * @returns {Promise<{changed: string[] | null, oldHash: string | null, newHash: string, pinned: number, rowError: string | null}>}
213
+ * `changed` is `[]` when nothing changed.
214
+ */
215
+ export async function reloadRun({ runId, config, readPending = () => [], effectiveSlug, writeRow, now = new Date() }) {
216
+ return withRunLock(runId, async () => {
217
+ const current = await readRun(runId);
218
+ if (current.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
219
+ const state = current.config && typeof current.config === 'object' && typeof current.config.hash === 'string' ? current.config : null;
220
+ const stored = state && state.snapshots && typeof state.snapshots === 'object' ? state.snapshots : {};
221
+ const newHash = configHash(config);
222
+ const oldHash = state ? state.hash : null;
223
+ if (oldHash === newHash) return { changed: [], oldHash, newHash, pinned: 0, rowError: null };
224
+ const before = state && Object.hasOwn(stored, state.hash) ? stored[state.hash] : undefined;
225
+ if (state && (before === null || typeof before !== 'object')) throw new StateError('config-snapshot', `run ${runId}: the current config snapshot is missing from the run record`);
226
+ /** @type {string[]} */
227
+ let frozen;
228
+ if (state) {
229
+ frozen = immutableChanges(before, config);
230
+ } else {
231
+ // no snapshot: compare what the record stores (`run start` wrote engine and the slug)
232
+ frozen = [];
233
+ if (effectiveSlug !== undefined && effectiveSlug !== current.project) frozen.push('project.slug');
234
+ if (typeof current.engine === 'string' && (config.engine ?? 'auto') !== current.engine) frozen.push('engine');
235
+ }
236
+ if (frozen.length > 0) {
237
+ const why = frozen.map((key) => `${key} cannot change mid-run (${IMMUTABLE_KEYS[/** @type {keyof typeof IMMUTABLE_KEYS} */ (key)]})`);
238
+ throw new StateError('immutable-key', `${why.join('; ')} — put ${frozen.length === 1 ? 'it' : 'them'} back, or end the run and start a new one`);
239
+ }
240
+ const changed = state ? changedKeyPaths(before, config) : null;
241
+ /** @type {Record<string, string>} */
242
+ const pins = {};
243
+ const oldPins = state?.pins && typeof state.pins === 'object' ? state.pins : {};
244
+ for (const ticket of new Set(readPending())) {
245
+ const hash = Object.hasOwn(oldPins, ticket) ? oldPins[ticket] : undefined;
246
+ if (typeof hash === 'string' && Object.hasOwn(stored, hash)) pins[ticket] = hash;
247
+ else if (oldHash !== null) pins[ticket] = oldHash;
248
+ }
249
+ /** @type {Record<string, any>} */
250
+ const snapshots = { [newHash]: config };
251
+ for (const hash of new Set(Object.values(pins))) snapshots[hash] = stored[hash];
252
+ current.config = { hash: newHash, snapshots, pins, reloaded_at: now.toISOString() };
253
+ current.provider = config.provider ?? null;
254
+ current.levels = config.levels ?? null;
255
+ current.fallback_mode = config.system1?.fallback ?? 'rules';
256
+ await saveRun(current);
257
+ const pinned = Object.keys(pins).length;
258
+ let rowError = null;
259
+ try {
260
+ await writeSigned(runId, writeRow, { event: 'run.reload', changed_keys: changed, old_hash: oldHash, new_hash: newHash, pinned_tickets: pinned });
261
+ } catch (err) {
262
+ rowError = err?.code ?? err?.name ?? 'error';
263
+ }
264
+ return { changed, oldHash, newHash, pinned, rowError };
265
+ });
266
+ }
267
+
182
268
  /**
183
269
  * `run start --reattach`: re-pin a restarted worker (the orchestrator only — §4.8).
184
270
  * @param {{runId: string, workerPid: number, writeRow: WriteRow, probe?: StartTimeProbe}} opts