@codedology/code-forge 0.10.0 → 0.11.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.
@@ -10,6 +10,7 @@
10
10
  * ## project rules the rules digest (≤ 60 lines)
11
11
  * ## facts the facts-sheet excerpt
12
12
  * ## context the current file (whole, or hunk windows — `context.mjs`)
13
+ * ## paired optional paired-file names and summary (B97), dropped first over budget
13
14
  * ## moved code only when code moved to/from another file of the block (`moved.mjs`, B56)
14
15
  * ## diff `file:`, the `hunks:` list the reviewer must echo, then the diff
15
16
  *
@@ -20,7 +21,7 @@
20
21
  * Budget (§4.3): `review.budgets.full_in` (`quick_in` for the quick lens), in tokens estimated as
21
22
  * bytes / 4. A diff that alone exceeds it ⇒ `split_required` (a Markdown file is then reviewed
22
23
  * section by section — `sections.mjs`, B54). Otherwise over budget ⇒ the context shrinks to
23
- * ± `min_context_lines` (`minimal`) FIRST, then the digest is trimmed from the end; the diff is
24
+ * ± `min_context_lines` (`minimal`) after dropping paired, then the digest is trimmed from the end; the diff is
24
25
  * never cut. The `## moved code` section (B56) is part of that measure: it is counted in the diff-only
25
26
  * size (`diffOnlyTokens`, `split_required`) and never trimmed.
26
27
  *
@@ -190,11 +191,11 @@ export async function readFileDiff({ repoRoot, file, base }) {
190
191
  * Assemble a reviewer packet (pure).
191
192
  * @param {{
192
193
  * diff: FileDiff, lens: string, rulesDigest?: string, factsExcerpt?: string,
193
- * cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', budget?: number, moved?: string,
194
+ * cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', budget?: number, moved?: string, paired?: string,
194
195
  * }} opts - `moved`: the file's `## moved code` section (`moved.mjs`), '' for none.
195
196
  * @returns {Packet | {status: 'split_required', tokensIn: number, budget: number}}
196
197
  */
197
- export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = '', cfg, contextMode = 'auto', budget, moved = '' }) {
198
+ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = '', cfg, contextMode = 'auto', budget, moved = '', paired = '' }) {
198
199
  const lensText = loadLens(lens);
199
200
  const limit = budget ?? budgetFor(cfg, lens === 'quick' ? 'quick_in' : 'full_in');
200
201
  const hunkHeaders = diff.hunks.map((h) => h.header);
@@ -204,7 +205,12 @@ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = ''
204
205
 
205
206
  let digest = splitLines(rulesDigest).slice(0, DIGEST_MAX_LINES);
206
207
  let ctx = buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: contextMode });
207
- let text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
208
+ let pairedSection = boundedPaired(paired, budgetFor(cfg, 'full_in'));
209
+ let text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved, pairedSection);
210
+ if (estimateTokens(text) > limit && pairedSection.length > 0) {
211
+ pairedSection = '';
212
+ text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
213
+ }
208
214
  if (estimateTokens(text) > limit) {
209
215
  ctx = buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: 'minimal' });
210
216
  text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
@@ -217,6 +223,22 @@ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = ''
217
223
  return { status: 'ok', text, hunkHeaders, contextMode: ctx.mode, contextLines: ctx.lineCount, digestLines: digest.length, tokensIn, budget: limit, overBudget: tokensIn > limit };
218
224
  }
219
225
 
226
+ /** Cap the entire section at full_in / 16 tokens, without cutting a UTF-8 character.
227
+ * @param {string} paired @param {number} fullIn @returns {string}
228
+ */
229
+ function boundedPaired(paired, fullIn) {
230
+ if (paired.trim().length === 0) return '';
231
+ const heading = '## paired\n';
232
+ let left = Math.floor(fullIn / 16) * 4 - Buffer.byteLength(heading) - 1;
233
+ let body = '';
234
+ for (const char of paired) {
235
+ left -= Buffer.byteLength(char);
236
+ if (left < 0) break;
237
+ body += char;
238
+ }
239
+ return body.length > 0 ? heading + body.trimEnd() : '';
240
+ }
241
+
220
242
  /**
221
243
  * The tokens of the diff-only packet (lens + moved code + diff, no digest, facts or context): what
222
244
  * `assemblePacket` holds against the budget before it says `split_required`. Pass the SAME `moved`
@@ -244,8 +266,9 @@ function diffSectionOf(diff) {
244
266
  /**
245
267
  * @param {string} lensText @param {string[]} digest @param {string} facts @param {string} context
246
268
  * @param {string} diffSection @param {string} [moved] - the `## moved code` section (heading included), '' for none.
269
+ * @param {string} [paired] - the bounded `## paired` section (heading included).
247
270
  */
248
- function render(lensText, digest, facts, context, diffSection, moved = '') {
271
+ function render(lensText, digest, facts, context, diffSection, moved = '', paired = '') {
249
272
  return [
250
273
  '# code-forge review packet',
251
274
  '## lens',
@@ -256,6 +279,7 @@ function render(lensText, digest, facts, context, diffSection, moved = '') {
256
279
  facts.trim().length > 0 ? facts.trimEnd() : '(none)',
257
280
  '## context',
258
281
  context.length > 0 ? context : '(none)',
282
+ ...(paired.length > 0 ? [paired] : []),
259
283
  ...(moved.length > 0 ? [moved] : []),
260
284
  '## diff',
261
285
  diffSection,
@@ -319,9 +343,10 @@ function readRepoText(repoRoot, rel) {
319
343
 
320
344
  /**
321
345
  * Why `rel` may not be attached, or null.
322
- * @param {string} repoRoot @param {string} rel @returns {Promise<string | null>}
346
+ * @param {string} repoRoot @param {string} rel @param {ReadonlyArray<string>} ownedFiles
347
+ * @param {ReadonlyArray<string>} changedFiles @returns {Promise<string | null>}
323
348
  */
324
- async function attachRefusal(repoRoot, rel) {
349
+ async function attachRefusal(repoRoot, rel, ownedFiles, changedFiles) {
325
350
  if (isSecretLike(rel)) return 'refused: secret-like path';
326
351
  let st;
327
352
  try {
@@ -330,20 +355,29 @@ async function attachRefusal(repoRoot, rel) {
330
355
  return 'missing';
331
356
  }
332
357
  if (!st.isFile()) return 'not a file';
333
- const res = await exec(['git', 'ls-files', '-z', '--error-unmatch', '--', rel], { cwd: repoRoot, env: reviewGitEnv(), timeoutMs: GIT_TIMEOUT_MS });
334
- if (res.result !== 'ok' || res.stdout.length === 0) return 'refused: not tracked by git';
358
+ const res = await exec(['git', 'ls-files', '-z', '--', rel], { cwd: repoRoot, env: reviewGitEnv(), timeoutMs: GIT_TIMEOUT_MS });
359
+ if (res.result !== 'ok') return 'refused: git failed';
360
+ if (res.stdout === `${rel}\0`) return null;
361
+ if (res.stdout.length > 0) return 'refused: git failed';
362
+ if (!ownedFiles.includes(rel)) return 'refused: untracked, not owned by this block';
363
+ if (!changedFiles.includes(rel)) return 'refused: untracked, not changed in this block';
364
+ const ignored = await exec(['git', 'check-ignore', '-q', '--', rel], { cwd: repoRoot, env: plainGitEnv(), timeoutMs: GIT_TIMEOUT_MS, okExitCodes: [0, 1] });
365
+ if (ignored.result !== 'ok' || (ignored.code !== 0 && ignored.code !== 1)) return 'refused: git failed';
366
+ if (ignored.code === 0) return 'refused: gitignored';
335
367
  return null;
336
368
  }
337
369
 
338
370
  /**
339
371
  * The `needs_file` follow-up (§4.2): the requested files, read-only, appended AFTER the packet
340
- * (the stable prefix stays stable). Only regular files git TRACKS are attached — an untracked or
341
- * ignored file (`.env`, a key) never reaches a provider — and secret-like paths are refused even
372
+ * (the stable prefix stays stable). Regular tracked files, or untracked files present in BOTH
373
+ * concrete path lists of this block, are attached. Ignored untracked files never reach a provider,
374
+ * and secret-like paths are refused even
342
375
  * when tracked; every refusal is named in the section. The attachment is trimmed to `budgetTokens`.
343
- * @param {{repoRoot: string, paths: ReadonlyArray<unknown>, budgetTokens: number}} opts
376
+ * @param {{repoRoot: string, paths: ReadonlyArray<unknown>, budgetTokens: number, ownedFiles?: ReadonlyArray<string>, changedFiles?: ReadonlyArray<string>}} opts
377
+ * `ownedFiles` and `changedFiles`: resolved paths from the SAME block in this repoRoot; no globs.
344
378
  * @returns {Promise<string>} the section to append.
345
379
  */
346
- export async function attachFiles({ repoRoot, paths, budgetTokens }) {
380
+ export async function attachFiles({ repoRoot, paths, budgetTokens, ownedFiles = [], changedFiles = [] }) {
347
381
  const out = ['## attached files (read-only, requested in needs_file)'];
348
382
  let left = budgetTokens * 4;
349
383
  for (const raw of paths.slice(0, 10)) {
@@ -356,7 +390,7 @@ export async function attachFiles({ repoRoot, paths, budgetTokens }) {
356
390
  out.push(notFile ? `### ${String(raw)} (not a file)` : '### (refused: not a repo-relative path)');
357
391
  continue;
358
392
  }
359
- const refusal = await attachRefusal(repoRoot, rel);
393
+ const refusal = await attachRefusal(repoRoot, rel, ownedFiles, changedFiles);
360
394
  if (refusal) {
361
395
  out.push(`### ${rel} (${refusal})`);
362
396
  continue;
@@ -0,0 +1,80 @@
1
+ /** Stem pairs and bounded names for closed-book review packets (B97). No file reads. */
2
+ import path from 'node:path';
3
+ import { findSecret } from '../util/gh.mjs';
4
+ import { redact } from '../util/redact.mjs';
5
+
6
+ /** @param {string} file */
7
+ function identity(file) {
8
+ if (typeof file !== 'string' || file.startsWith('/') || /[\\\x00-\x1f]/.test(file) || file.split('/').some((s) => s === '.' || s === '..' || s === '')) return null;
9
+ const ext = path.posix.extname(file);
10
+ let stem = path.posix.basename(file, ext);
11
+ let test = /(^|\/)(?:test|tests)\//.test(file);
12
+ if (ext === '.mjs' || ext === '.ts') {
13
+ if (/\.(test|spec)$/.test(stem)) { test = true; stem = stem.replace(/\.(test|spec)$/, ''); }
14
+ } else if (ext === '.php') {
15
+ if (stem.endsWith('Test')) { test = true; stem = stem.slice(0, -4); }
16
+ } else if (ext === '.go') {
17
+ if (stem.endsWith('_test')) { test = true; stem = stem.slice(0, -5); }
18
+ } else if (ext === '.py') {
19
+ if (stem.startsWith('test_')) { test = true; stem = stem.slice(5); }
20
+ } else if (ext === '.swift') {
21
+ if (stem.endsWith('Tests')) { test = true; stem = stem.slice(0, -5); }
22
+ } else return null;
23
+ return { ext, stem, test };
24
+ }
25
+
26
+ /** @param {string} file @param {ReadonlyArray<string>} candidates @returns {string[]} */
27
+ export function pairsFor(file, candidates) {
28
+ const source = identity(file);
29
+ if (!source) return [];
30
+ return [...new Set(candidates)].filter((candidate) => {
31
+ const other = identity(candidate);
32
+ return candidate !== file && other?.ext === source.ext && other.stem === source.stem && other.test !== source.test;
33
+ });
34
+ }
35
+
36
+ /**
37
+ * A best-effort list, not a parser or proof of coverage. Names only, never function bodies.
38
+ * Redact before truncating so a token crossing the 120-character boundary cannot leak.
39
+ * @param {string} text @param {string} file @returns {string[]}
40
+ */
41
+ export function summarize(text, file) {
42
+ const id = identity(file);
43
+ if (!id) return [];
44
+ /** @type {Set<string>} */
45
+ const names = new Set();
46
+ for (const line of text.split(/\r?\n/)) {
47
+ /** @type {RegExpMatchArray | null} */
48
+ let match = null;
49
+ if (id.test) {
50
+ if (id.ext === '.mjs' || id.ext === '.ts' || id.ext === '.php') {
51
+ match = line.match(/^\s*(?:await\s+)?(?:test|it)(?:\.(?:only|skip|todo))?\(\s*(['"`])([^\r\n]+?)\1/);
52
+ if (match) { names.add(match[2]); match = null; }
53
+ else if (id.ext === '.php') match = line.match(/^\s*(?:(?:public|protected)\s+)?function\s+(test\w+)\s*\(/);
54
+ else continue;
55
+ } else if (id.ext === '.go') match = line.match(/^\s*func\s+(Test\w+)\s*\(/);
56
+ else if (id.ext === '.py') match = line.match(/^\s*(?:async\s+)?def\s+(test_\w+)\s*\(/);
57
+ else if (id.ext === '.swift') match = line.match(/^\s*func\s+(test\w+)\s*\(/);
58
+ } else {
59
+ if (id.ext === '.mjs' || id.ext === '.ts') {
60
+ match = line.match(/^\s*export\s+(?:default\s+)?(?:async\s+)?(?:function\*?|class|const|let|var|interface|type|enum)\s+(\w+)/);
61
+ const list = line.match(/^\s*export\s*\{([^}]+)\}/);
62
+ if (list) {
63
+ for (const item of list[1].split(',')) {
64
+ const symbol = item.trim().match(/^(?:type\s+)?(\w+)(?:\s+as\s+(\w+))?$/);
65
+ if (symbol) names.add(symbol[2] ?? symbol[1]);
66
+ }
67
+ }
68
+ } else if (id.ext === '.php') match = line.match(/^\s*(?:(?:public|protected|private|static|final|abstract)\s+)*(?:function|class|interface|trait)\s+(\w+)/);
69
+ else if (id.ext === '.go') match = line.match(/^\s*(?:func\s+(?:\([^)]*\)\s+)?|(?:type|var|const)\s+)([A-Z]\w*)/);
70
+ else if (id.ext === '.py') match = line.match(/^\s*(?:async\s+)?(?:def|class)\s+([a-zA-Z]\w*)/);
71
+ else if (id.ext === '.swift') match = line.match(/^\s*(?:public|open)\s+(?:final\s+)?(?:func|class|struct|enum|protocol|let|var)\s+(\w+)/);
72
+ }
73
+ if (match) names.add(match[1]);
74
+ if (names.size >= 40) break;
75
+ }
76
+ return [...names].slice(0, 40).map((name) => {
77
+ const safe = redact(name);
78
+ return Array.from(findSecret(safe) ? '[REDACTED]' : safe).slice(0, 120).join('');
79
+ });
80
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * B96 (E1): the close report is built by the tool. After the review gate passed (`reviewGateCheck`
3
+ * over `checkBlockReviews`), every file the gate checked is accepted at its current hash, either
4
+ * by a signed `review.approved` row or — the gate's `waivedAll` list (B70/B71) — because every
5
+ * finding its review left open was waived or answered. This module turns that verdict into one
6
+ * line per file: `{file, ticket, via}`, `via` one of `approved`, `waiver`, `answered`, and
7
+ * `ticket` the queue's ticket for that file and hash (the caller passes `ticketOf`, the queue's
8
+ * own rule: `src/review` never imports the worker). It never re-decides the acceptance: the gate
9
+ * is the only judge, and a file the gate did not accept is never passed here.
10
+ *
11
+ * It also reads the coder's hand lines (`reviewed <path> ticket <id>`), to name a hand line that
12
+ * names another ticket (D1: a warning, the tool lines are the record).
13
+ *
14
+ * Its warnings (`onNoTicket`, `onBadTicket`) are callbacks: the close holds what they report and
15
+ * prints it only when the block closes (src/cli/block.mjs).
16
+ */
17
+
18
+ /** @typedef {'approved' | 'waiver' | 'answered'} Via */
19
+ /** @typedef {{file: string, ticket: string, via: Via}} ReviewedLine */
20
+
21
+ /**
22
+ * The `via` of a file the gate accepted, read from the gate's own record (`WaivedAll`, built by
23
+ * `waivedAllOf` in gate-check.mjs): no record at the same hash ⇒ `approved` (a signed approval);
24
+ * a record whose `by` is null ⇒ `answered` (the gate sets `by` to null exactly when every open
25
+ * finding was answered); any other `by` (`human` or `autopilot`) ⇒ `waiver` — also for a file
26
+ * with some findings answered and at least one waived (the waiver is what a human or a grant
27
+ * decided, so the line names it).
28
+ * @param {import('./gate-check.mjs').WaivedAll | undefined} record @returns {Via}
29
+ */
30
+ export function viaOf(record) {
31
+ if (record === undefined) return 'approved';
32
+ return record.by === null ? 'answered' : 'waiver';
33
+ }
34
+
35
+ /** A ticket id as the queue gives it: 24 lowercase hex characters (`assertTicketId`). */
36
+ const TICKET_ID = /^[0-9a-f]{24}$/;
37
+
38
+ /**
39
+ * The tool's reviewed lines, sorted by file. Pure but for `onNoTicket` and `onBadTicket`.
40
+ * @param {{
41
+ * files: ReadonlyArray<{file: string, content_hash: string}>,
42
+ * waivedAll: ReadonlyArray<import('./gate-check.mjs').WaivedAll>,
43
+ * ticketOf: (file: {file: string, content_hash: string}) => unknown,
44
+ * onNoTicket?: (file: string) => void,
45
+ * onBadTicket?: (file: string) => void,
46
+ * }} opts `files`: the files the review gate checked and ACCEPTED (it passed); `waivedAll`: the
47
+ * gate's `reviewed_by_waiver` files ({@link viaOf}); every other file passed through a signed
48
+ * approval. `ticketOf`: the queue's ticket for that file at that hash, or null (or undefined)
49
+ * when it cannot be given — then the file has no line (fail closed) and `onNoTicket(file)` is
50
+ * called once. Any other value that is not 24 lowercase hex characters (a string of another
51
+ * shape, a number…) is a bad ticket: the file has no line either and `onBadTicket(file)` is
52
+ * called once instead.
53
+ * @returns {ReviewedLine[]}
54
+ */
55
+ export function reviewedLines({ files, waivedAll, ticketOf, onNoTicket = () => {}, onBadTicket = () => {} }) {
56
+ /** @type {ReviewedLine[]} */
57
+ const lines = [];
58
+ for (const f of files) {
59
+ const via = viaOf(waivedAll.find((w) => w.file === f.file && w.content_hash === f.content_hash));
60
+ const ticket = ticketOf(f);
61
+ if (ticket === null || ticket === undefined) onNoTicket(f.file);
62
+ else if (typeof ticket === 'string' && TICKET_ID.test(ticket)) lines.push({ file: f.file, ticket, via });
63
+ else onBadTicket(f.file);
64
+ }
65
+ return lines.sort((x, y) => (x.file < y.file ? -1 : x.file > y.file ? 1 : 0));
66
+ }
67
+
68
+ /**
69
+ * A hand line: exactly `reviewed <path> ticket <id>` once trimmed, with backticks dropped and an
70
+ * optional leading list marker (`- `, `* ` or a number and a dot, `1. `). The id is 24 hex
71
+ * characters of either case: a ticket id is lowercase, so an id in capitals is read here and then
72
+ * never equals the tool's ticket ({@link reportMismatches} compares exactly).
73
+ */
74
+ const HAND_LINE = /^(?:(?:[-*]|\d{1,9}\.)\s+)?reviewed\s+(\S+)\s+ticket\s+([0-9a-fA-F]{24})$/;
75
+
76
+ /**
77
+ * D1: the files whose hand line in `report` names a ticket id other than the tool's (compared
78
+ * exactly, so an id in capitals is another ticket). Only a `reviewed <path> ticket <id>` line
79
+ * counts ({@link HAND_LINE}); a hash of another length, or a line of any other shape that names
80
+ * the file, never does.
81
+ * @param {string} report @param {ReadonlyArray<ReviewedLine>} lines
82
+ * @returns {string[]} the files with a mismatched hand line, in the order of `lines`.
83
+ */
84
+ export function reportMismatches(report, lines) {
85
+ /** @type {Set<string>} */
86
+ const other = new Set();
87
+ for (const raw of report.split('\n')) {
88
+ const m = HAND_LINE.exec(raw.replaceAll('`', '').trim());
89
+ if (m === null) continue;
90
+ const line = lines.find((l) => l.file === m[1]);
91
+ if (line !== undefined && line.ticket !== m[2]) other.add(line.file);
92
+ }
93
+ return lines.filter((l) => other.has(l.file)).map((l) => l.file);
94
+ }
@@ -54,11 +54,13 @@ function safeId(id) {
54
54
  * @param {{
55
55
  * block: string, runId: string, status: 'closed' | 'stopped', event: string, reason?: string | null,
56
56
  * commit: string | null, spend: {usd: number, unknown: number} | null, findings: number | null, at: Date,
57
- * }} facts
57
+ * reviewed?: ReadonlyArray<{file: string, ticket: string, via: string}>,
58
+ * }} facts `reviewed` (B96): the close's reviewed lines, sorted by file; the key is added last, and only when given.
58
59
  * @returns {{block: string, run_id: string, status: string, event: string, reason: string | null, commit: string | null,
59
- * spent_usd: number | null, unknown_usd_sessions: number | null, findings: number | null, at: string}}
60
+ * spent_usd: number | null, unknown_usd_sessions: number | null, findings: number | null, at: string,
61
+ * reviewed?: Array<{file: string, ticket: string, via: string}>}}
60
62
  */
61
- export function doneFileContent({ block, runId, status, event, reason = null, commit, spend, findings, at }) {
63
+ export function doneFileContent({ block, runId, status, event, reason = null, commit, spend, findings, at, reviewed }) {
62
64
  return {
63
65
  block,
64
66
  run_id: runId,
@@ -71,6 +73,7 @@ export function doneFileContent({ block, runId, status, event, reason = null, co
71
73
  unknown_usd_sessions: spend && Number.isInteger(spend.unknown) ? spend.unknown : null,
72
74
  findings: Number.isInteger(findings) ? findings : null,
73
75
  at: at.toISOString(),
76
+ ...(Array.isArray(reviewed) ? { reviewed: reviewed.map((r) => ({ file: r.file, ticket: r.ticket, via: r.via })).sort((x, y) => (x.file < y.file ? -1 : x.file > y.file ? 1 : 0)) } : {}),
74
77
  };
75
78
  }
76
79
 
@@ -97,12 +100,13 @@ async function headCommit(cwd) {
97
100
  * @param {{
98
101
  * record: Record<string, any>, id: string, status: 'closed' | 'stopped', event: string, reason?: string | null,
99
102
  * openFindings: (rows: Record<string, any>[]) => Promise<number>, err: (s: string) => unknown, now?: Date,
103
+ * reviewed?: ReadonlyArray<{file: string, ticket: string, via: string}>,
100
104
  * }} opts - `record` is the run record read before the end (its `workspace` and the block's
101
105
  * `worktree` do not change at an end); `openFindings` counts the block's open findings exactly
102
- * as `code-forge status` does.
106
+ * as `code-forge status` does. `reviewed` (B96): the close's reviewed lines, kept as the last key.
103
107
  * @returns {Promise<string | null>} the done file's absolute path, or null when it was not written.
104
108
  */
105
- export async function writeDoneFile({ record, id, status, event, reason = null, openFindings, err, now = new Date() }) {
109
+ export async function writeDoneFile({ record, id, status, event, reason = null, openFindings, err, now = new Date(), reviewed }) {
106
110
  const runId = String(record?.run_id);
107
111
  try {
108
112
  const file = doneFilePath(runId, id);
@@ -127,7 +131,7 @@ export async function writeDoneFile({ record, id, status, event, reason = null,
127
131
  }
128
132
  const tree = record.blocks?.[id]?.worktree?.path;
129
133
  const commit = await headCommit(typeof tree === 'string' && tree.length > 0 ? tree : record.workspace);
130
- const content = doneFileContent({ block: id, runId, status, event, reason, commit, spend, findings, at: now });
134
+ const content = doneFileContent({ block: id, runId, status, event, reason, commit, spend, findings, at: now, ...(reviewed === undefined ? {} : { reviewed }) });
131
135
  const dir = path.dirname(file);
132
136
  await mkdir(dir, { recursive: true, mode: 0o700 });
133
137
  const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
@@ -42,7 +42,7 @@
42
42
  * second `schema` miss — this round's first try after the earlier ticket's — is tried once on
43
43
  * `review.second_levels.<level>` when one is configured.
44
44
  * B56: every round gets ONE memoised reader of the block's OTHER changed files' diffs
45
- * (`blockPeerDiffs`: listed and read at most once per ticket) so the review can name code moved
45
+ * (the ticket's `fileContext`: listed once, diffs read lazily at most once per ticket) so the review can name code moved
46
46
  * between them in a `## moved code` packet section (round 1, rechecks and patch check alike).
47
47
  * The review NEVER approves an empty or failed session: that is `unavailable` (§4.2 stub guard).
48
48
  * A throw inside the round is `unavailable` too, with the state saved as `next: retry` (see
@@ -169,14 +169,14 @@ import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } fro
169
169
  * `tree` (B62b) is the recorded `worktree` `{path, branch, base_sha}`, null when the block has
170
170
  * none; `base` is then the tree's `base_sha`. A recorded worktree that `worktreeRecord` refuses
171
171
  * (path, branch, hex base_sha) throws `worktree-malformed`.
172
- * @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, acceptance?: unknown, kind: string | null, tree: WorktreeEntry | null, workspace: string | null}>}
172
+ * @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, testFiles: string[], acceptance?: unknown, kind: string | null, tree: WorktreeEntry | null, workspace: string | null}>}
173
173
  */
174
174
  export async function blockEntryFor(runId, block) {
175
175
  let record;
176
176
  try {
177
177
  record = await readRun(runId);
178
178
  } catch (err) {
179
- if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, acceptance: undefined, kind: null, tree: null, workspace: null };
179
+ if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, testFiles: [], acceptance: undefined, kind: null, tree: null, workspace: null };
180
180
  throw new Error('run-record-unreadable');
181
181
  }
182
182
  if (!record || typeof record !== 'object' || !record.blocks || typeof record.blocks !== 'object') throw new Error('run-record-unreadable');
@@ -187,6 +187,7 @@ export async function blockEntryFor(runId, block) {
187
187
  const owned = Array.isArray(entry.owned_files) ? entry.owned_files.filter((f) => typeof f === 'string') : [];
188
188
  const kinds = entry.owned_kinds;
189
189
  const ownedKinds = kinds && typeof kinds === 'object' && !Array.isArray(kinds) ? kinds : {};
190
+ const testFiles = Array.isArray(entry.test_files) ? entry.test_files.filter((f) => typeof f === 'string' && !/[*?\[\]{}]/.test(f) && ownsFile(owned, f, ownedKinds)) : [];
190
191
  /** @type {WorktreeEntry | null} */
191
192
  let tree = null;
192
193
  if (entry.worktree !== undefined && entry.worktree !== null) {
@@ -196,10 +197,11 @@ export async function blockEntryFor(runId, block) {
196
197
  throw new Error('worktree-malformed');
197
198
  }
198
199
  }
199
- return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
200
+ return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, testFiles, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
200
201
  }
201
202
 
202
203
  /** @typedef {{path: string, branch: string, base_sha: string}} WorktreeEntry */
204
+ /** @typedef {{peers: string[], changedFiles: string[], ownedFiles: string[], testFiles: string[]}} FileContext */
203
205
 
204
206
  /** The refusals of {@link blockTree} a ticket answers with, as its `reason`. */
205
207
  const TREE_CODES = new Set(['worktree-malformed', 'worktree-missing', 'worktree-not-dir', 'worktree-branch-mismatch', 'worktree-workspace-missing', 'worktree-foreign', 'git-failed']);
@@ -373,7 +375,28 @@ export async function reviewTicket(ticket, ctx) {
373
375
  }
374
376
  }
375
377
  const factsExcerpt = factsFor(entry, ctx.cfg);
376
- const peerDiffs = blockPeerDiffs(contentRoot, { base: entry.base, owned: entry.owned, ownedKinds: entry.ownedKinds, file: ticket.file });
378
+ // Resolve concrete paths in this block's tree; neither globs nor another block's tests
379
+ // authorize an untracked attachment. Read lazily so a stopped ticket needs no file listing.
380
+ /** @type {Promise<FileContext> | undefined} */
381
+ let contextOnce;
382
+ /** @type {() => Promise<import('../review/moved.mjs').PeerDiff[]>} */
383
+ let readDiffs = () => Promise.resolve([]);
384
+ const fileContext = () => contextOnce ??= (async () => {
385
+ try {
386
+ // Read the listing directly: blockPeers' [] fallback would hide a git failure and
387
+ // still authorize explicit tests. Context and peer diffs must fail soft together.
388
+ const peers = entry.base === null || entry.owned.length === 0 ? [] : (await computeFileSet({ cwd: contentRoot, base: entry.base })).all.filter((f) => f !== ticket.file && ownsFile(entry.owned, f, entry.ownedKinds));
389
+ readDiffs = peerDiffReader({ repoRoot: contentRoot, base: entry.base, file: ticket.file, peers });
390
+ const changedFiles = [...peers, ticket.file].filter((f) => ownsFile(entry.owned, f, entry.ownedKinds));
391
+ return { peers, changedFiles, ownedFiles: [...new Set([...changedFiles, ...entry.testFiles])], testFiles: entry.testFiles };
392
+ } catch {
393
+ return { peers: [], changedFiles: [], ownedFiles: [], testFiles: [] };
394
+ }
395
+ })();
396
+ const peerDiffs = async () => {
397
+ await fileContext();
398
+ return readDiffs();
399
+ };
377
400
  try {
378
401
  // B63a: the `context` rules and lessons, read from the MAIN tree (`ctx.repoRoot`, never the
379
402
  // block's worktree) with the config in force for this ticket; '' without a `context` key, so
@@ -390,11 +413,11 @@ export async function reviewTicket(ticket, ctx) {
390
413
  const digest = rulesDigest === '' ? {} : { rulesDigest };
391
414
  if (!ctx.key) {
392
415
  return await reviewFile(
393
- { repoRoot: contentRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt, ...digest, peerDiffs },
416
+ { repoRoot: contentRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt, ...digest, peerDiffs, ...(await fileContext()) },
394
417
  { spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
395
418
  );
396
419
  }
397
- return await fixLoopRound(ticket, tctx, { base: entry.base, level: entry.level, owned: entry.owned, ownedKinds: entry.ownedKinds, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt, rulesDigest, peerDiffs });
420
+ return await fixLoopRound(ticket, tctx, { base: entry.base, level: entry.level, owned: entry.owned, ownedKinds: entry.ownedKinds, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt, rulesDigest, peerDiffs, fileContext });
398
421
  } finally {
399
422
  rmSync(workDir, { recursive: true, force: true });
400
423
  }
@@ -506,10 +529,10 @@ export function mayTakeRung(state, kind, cfg, rungUsed) {
506
529
  * first full round, or any round once the rung is gone) runs outside the block lock, in parallel
507
530
  * with the block's other files.
508
531
  * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
509
- * @param {{base: string | null, level: string, owned: string[], ownedKinds?: Record<string, string>, blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string, rulesDigest?: string, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
532
+ * @param {{base: string | null, level: string, owned: string[], ownedKinds?: Record<string, string>, blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string, rulesDigest?: string, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>, fileContext: () => Promise<FileContext>}} opts
510
533
  * @returns {Promise<ReviewOutcome>}
511
534
  */
512
- async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, rulesDigest = '', peerDiffs }) {
535
+ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, rulesDigest = '', peerDiffs, fileContext }) {
513
536
  if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
514
537
  const readRows = ctx.readRows;
515
538
  /** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
@@ -572,7 +595,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
572
595
  state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
573
596
  }
574
597
  const fixed = state;
575
- const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest, extraRounds, peerDiffs });
598
+ const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest, extraRounds, peerDiffs, fileContext });
576
599
  // a round that could take the rung keeps the block lock until its state is saved
577
600
  if (mayTakeRung(fixed, kind, ctx.cfg, rungUsed)) return { outcome: await round() };
578
601
  return { round };
@@ -583,10 +606,10 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
583
606
  /**
584
607
  * Run the prepared round and save the file's state: the ticket's outcome.
585
608
  * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
586
- * @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string, rulesDigest?: string, extraRounds?: number, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
609
+ * @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string, rulesDigest?: string, extraRounds?: number, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>, fileContext: () => Promise<FileContext>}} opts
587
610
  * @returns {Promise<ReviewOutcome>}
588
611
  */
589
- async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest = '', extraRounds = 0, peerDiffs }) {
612
+ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest = '', extraRounds = 0, peerDiffs, fileContext }) {
590
613
  /** @type {Record<string, any> | null} */
591
614
  let engineOutcome = null;
592
615
  let approvalWritten = false;
@@ -626,6 +649,7 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
626
649
  const schemaHistory = ctx.readRows
627
650
  ? ledgerSchemaHistory({ readRows: ctx.readRows, verify: (row) => verifyRow(row, where.key).ok, runId: ctx.runId, file: ticket.file, contentHash: ticket.content_hash })
628
651
  : undefined;
652
+ const files = await fileContext();
629
653
  await runRound(
630
654
  state,
631
655
  {
@@ -637,16 +661,20 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
637
661
  factsExcerpt,
638
662
  ...(rulesDigest !== '' ? { rulesDigest } : {}),
639
663
  extraRounds,
664
+ ...files,
640
665
  ...(peerDiffs ? { peerDiffs } : {}),
641
666
  ...(jev ? { jev } : {}),
642
667
  ...(schemaHistory ? { schemaHistory } : {}),
643
668
  review: async () => {
644
- engineOutcome = await reviewFile({ repoRoot: ctx.contentRoot ?? ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt, ...(rulesDigest !== '' ? { rulesDigest } : {}), ...(peerDiffs ? { peerDiffs } : {}) }, { spawn: ctx.spawn, writeRow, ...(schemaHistory ? { schemaHistory } : {}) });
669
+ engineOutcome = await reviewFile({ repoRoot: ctx.contentRoot ?? ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt, ...files, ...(rulesDigest !== '' ? { rulesDigest } : {}), ...(peerDiffs ? { peerDiffs } : {}) }, { spawn: ctx.spawn, writeRow, ...(schemaHistory ? { schemaHistory } : {}) });
645
670
  return engineOutcome;
646
671
  },
647
672
  spawn: async (opts) => {
648
673
  const res = await ctx.spawn(opts);
649
- rechecks.push({ lens: opts?.rowExtra?.lens ?? null, role: opts?.role ?? null, level: opts?.level ?? null, provider: res?.provider ?? null, model: res?.model ?? null });
674
+ rechecks.push({ lens: opts?.rowExtra?.lens ?? null, role: opts?.role ?? null, level: opts?.level ?? null, provider: res?.provider ?? null, model: res?.model ?? null,
675
+ status: res?.status ?? null, tokens_in: res?.usage?.tokens_in ?? res?.row?.tokens_in ?? null, tokens_out: res?.usage?.tokens_out ?? res?.row?.tokens_out ?? null,
676
+ ...(typeof res?.status === 'string' && res.status !== 'ok' ? { reason: res.reason ?? res.status } : {}),
677
+ });
650
678
  return res;
651
679
  },
652
680
  },
@@ -689,7 +717,7 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
689
717
  return { ...head, status: 'reviewed', approved: true, findings: [] };
690
718
  }
691
719
  if (status === 'stopped') return { ...head, status: 'stopped', stopped: next?.reason ?? 'stopped', ...stopSize(state), approved: false, findings: state.open };
692
- return { ...head, status: 'reviewed', approved: false, reason: 'findings-open', findings: state.open };
720
+ return { ...head, status: 'reviewed', approved: false, reason: next?.reason === 'no-fix-hunks' ? 'no-fix-hunks' : 'findings-open', findings: state.open };
693
721
  }
694
722
 
695
723
  /**
@@ -35,6 +35,8 @@
35
35
  */
36
36
 
37
37
  import { spawn as nodeSpawn } from 'node:child_process';
38
+ import { existsSync, statSync } from 'node:fs';
39
+ import os from 'node:os';
38
40
  import path from 'node:path';
39
41
  import { fileURLToPath } from 'node:url';
40
42
  import { expiryChecks } from '../autopilot/limits.mjs';
@@ -43,6 +45,7 @@ import { cacheHoursFrom, createDefaultKeyStore, resolveKey } from '../keys/store
43
45
  import { appendRow, readAllRows } from '../ledger/write.mjs';
44
46
  import { spawnSession } from '../session/spawn.mjs';
45
47
  import { snapshotFor } from '../state/config-snapshot.mjs';
48
+ import { runRecordPath } from '../state/paths.mjs';
46
49
  import { readRun, reattachWorker } from '../state/run.mjs';
47
50
  import { loadKey, signRow } from '../state/signer.mjs';
48
51
  import { logWarning } from '../util/error-log.mjs';
@@ -220,6 +223,7 @@ export function recordedVerdict(result) {
220
223
  * @property {string} slug - ledger project slug (the run record's `project`).
221
224
  * @property {Buffer} key - the run's HMAC key.
222
225
  * @property {number} [pollMs]
226
+ * @property {string} [guardRunRecordPath] - booted workers stop when this authoritative record disappears.
223
227
  * @property {number} [heartbeatMs] - how often `heartbeat_at` is refreshed: an integer ≥ `MIN_HEARTBEAT_MS`,
224
228
  * else `HEARTBEAT_MS`.
225
229
  */
@@ -598,6 +602,20 @@ export async function createWorker(opts, deps = {}) {
598
602
  /** @type {Set<string>} */
599
603
  const started = new Set();
600
604
  for (;;) {
605
+ // One cheap stat per scheduler tick, including while tickets are in flight. Removing a
606
+ // test HOME (or the run record) stops new tickets; an open real run remains untouched.
607
+ if (!stopping && opts.guardRunRecordPath) {
608
+ try {
609
+ statSync(opts.guardRunRecordPath);
610
+ } catch (err) {
611
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR') {
612
+ stop();
613
+ // The error log lives under HOME: never write it when HOME itself is gone, or the
614
+ // log write would create the removed HOME again.
615
+ if (existsSync(os.homedir())) await logWarning({ warning: 'worker_orphan_stop', message: `run record missing: ${opts.guardRunRecordPath}` }).catch(() => null);
616
+ }
617
+ }
618
+ }
601
619
  if (!stopping) {
602
620
  for (const id of pendingTickets(repoRoot)) {
603
621
  if (poisoned.has(id) || started.has(id)) continue;
@@ -750,7 +768,7 @@ export async function bootWorker(opts, deps = {}) {
750
768
  const runRootDir = runRoot(opts.runId, { root: tmpRoot });
751
769
  setRunRoot(runRootDir);
752
770
  const key = await loadKey(opts.runId);
753
- const worker = await createWorker({ runId: opts.runId, repoRoot, cfg, runRootDir, slug: record.project, key, pollMs: opts.pollMs }, deps);
771
+ const worker = await createWorker({ runId: opts.runId, repoRoot, cfg, runRootDir, slug: record.project, key, pollMs: opts.pollMs, guardRunRecordPath: runRecordPath(opts.runId) }, deps);
754
772
  return { ...worker, repoRoot, runRootDir };
755
773
  }
756
774