@codedology/code-forge 0.10.0 → 0.12.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 (44) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +34 -3
  3. package/docs/reference/blocks.json +360 -1
  4. package/docs/reference/config.md +6 -5
  5. package/package.json +2 -2
  6. package/schema/code-forge.schema.json +3 -2
  7. package/skill/SKILL.md +3 -1
  8. package/skill/references/code.md +17 -8
  9. package/skill/references/proof.md +8 -2
  10. package/skill/references/review.md +32 -5
  11. package/skill/references/rules-core.md +1 -1
  12. package/skill/scripts/forge +1 -1
  13. package/skill/templates/coder-brief.md +17 -7
  14. package/src/cli/block.mjs +112 -7
  15. package/src/cli/gates.mjs +15 -6
  16. package/src/cli/review-file.mjs +118 -14
  17. package/src/config/validate.mjs +12 -5
  18. package/src/context/pack.mjs +7 -4
  19. package/src/gates/expand.mjs +102 -9
  20. package/src/gates/run.mjs +6 -4
  21. package/src/install/wizard/flags.mjs +1 -1
  22. package/src/proof/lock.mjs +23 -10
  23. package/src/proof/rule.mjs +9 -0
  24. package/src/proof/tiers.mjs +2 -14
  25. package/src/review/engine.mjs +59 -12
  26. package/src/review/fixloop.mjs +41 -16
  27. package/src/review/gate-check.mjs +2 -2
  28. package/src/review/lenses/A.md +5 -0
  29. package/src/review/lenses/B.md +5 -0
  30. package/src/review/lenses/full.md +5 -0
  31. package/src/review/lenses/judge.md +4 -0
  32. package/src/review/lenses/quick.md +4 -0
  33. package/src/review/lenses/recheck.md +5 -0
  34. package/src/review/packet.mjs +48 -14
  35. package/src/review/paired.mjs +81 -0
  36. package/src/review/reopen.mjs +18 -6
  37. package/src/review/reviewed-lines.mjs +94 -0
  38. package/src/state/block-end.mjs +10 -6
  39. package/src/util/exec.mjs +2 -1
  40. package/src/util/gate-token.mjs +3 -0
  41. package/src/worker/engine.mjs +43 -15
  42. package/src/worker/loop.mjs +35 -2
  43. package/src/worker/queue.mjs +60 -2
  44. package/src/worker/watch.mjs +113 -0
@@ -47,7 +47,7 @@
47
47
  * not repeated. B73: this detail, and an `unreviewed` detail for a file with a cap row, ends
48
48
  * with the stop's ways out (`exits: block reopen, block waive, review-file --answers,
49
49
  * re-decompose`, `stop-exits.mjs`);
50
- * - `unproven`: a changed owned file of tier `high` (B20, Ricardo 2026-09-25) has no covering
50
+ * - `unproven`: a changed owned file of tier `high` (B20, the owner 2026-09-25) has no covering
51
51
  * red→green proof (`checkBlockProof`, below). Light-tier files need no proof row.
52
52
  * B71 (answers): a finding is also cleared — wherever a waiver clears it, by the one rule set
53
53
  * ({@link reviewRules}: `waiverOf`, so `reviewed_by_waiver` reads "waived or answered") — by a
@@ -803,7 +803,7 @@ export function closeTier({ file, rows, key, highPaths }) {
803
803
  }
804
804
 
805
805
  /**
806
- * B20 (Ricardo 2026-09-25): every changed owned file of tier `high` needs a covering red→green
806
+ * B20 (the owner 2026-09-25): every changed owned file of tier `high` needs a covering red→green
807
807
  * proof, checked PER FILE. A file is covered by a `proof` row of this run and block (`rows` must
808
808
  * already be the block's rows) with `step: 'red-green'`, `proven: true`, `red_kind: 'assertion'`,
809
809
  * whose MAC verifies and whose `covers` list (written by `proof red-green`: the sources the
@@ -11,9 +11,14 @@ You work blind: you do not see the other reviewer's answer.
11
11
  - State: ordering, concurrency, partial writes, values that can be stale.
12
12
  - Arithmetic and units: rounding, overflow, bytes versus characters, time zones.
13
13
 
14
+ Never ask for hand mutations or for tests beyond the acceptance clauses.
15
+ The fix for a weak assertion is a stronger assertion.
16
+ Code or tests beyond what the change needs (unused options, extra layers, speculative generality) is at most a `nit` unless it hides a bug.
17
+
14
18
  ## Rules for every answer
15
19
 
16
20
  - Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
21
+ - A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
17
22
  - You have no tools and no project files. The packet is everything you get.
18
23
  - `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
19
24
  - Answer with ONE JSON object that matches the answer schema: `passed`, `summary` (≤ 300 chars), `reviewed_hunks`, `findings[]`, and `needs_file[]` (empty when you need nothing).
@@ -12,9 +12,14 @@ You work blind: you do not see the other reviewer's answer.
12
12
  that can escape a root (`..`, absolute, symlinks); missing authorization; unsafe defaults.
13
13
  - Facts: a flag, path or API the change relies on that the facts excerpt contradicts.
14
14
 
15
+ Never ask for hand mutations or for tests beyond the acceptance clauses.
16
+ The fix for a weak assertion is a stronger assertion.
17
+ Code or tests beyond what the change needs (unused options, extra layers, speculative generality) is at most a `nit` unless it hides a bug.
18
+
15
19
  ## Rules for every answer
16
20
 
17
21
  - Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
22
+ - A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
18
23
  - You have no tools and no project files. The packet is everything you get.
19
24
  - `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
20
25
  - Answer with ONE JSON object that matches the answer schema: `passed`, `summary` (≤ 300 chars), `reviewed_hunks`, `findings[]`, and `needs_file[]` (empty when you need nothing).
@@ -12,9 +12,14 @@ You are a code reviewer doing a FULL review of one changed file.
12
12
 
13
13
  Report each real defect once, at the lines where it lives.
14
14
 
15
+ Never ask for hand mutations or for tests beyond the acceptance clauses.
16
+ The fix for a weak assertion is a stronger assertion.
17
+ Code or tests beyond what the change needs (unused options, extra layers, speculative generality) is at most a `nit` unless it hides a bug.
18
+
15
19
  ## Rules for every answer
16
20
 
17
21
  - Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
22
+ - A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
18
23
  - You have no tools and no project files. The packet is everything you get.
19
24
  - `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
20
25
  - Answer with ONE JSON object that matches the answer schema: `passed`, `summary` (≤ 300 chars), `reviewed_hunks`, `findings[]`, and `needs_file[]` (empty when you need nothing).
@@ -13,6 +13,10 @@ their two answers, the list of hunk headers, and the diff only when the packet i
13
13
  - Your `findings` list is final: nothing you drop is fixed, everything you keep is.
14
14
  - `passed` is true only when you keep no `critical` and no `warning`.
15
15
 
16
+ Never ask for hand mutations or for tests beyond the acceptance clauses.
17
+ The fix for a weak assertion is a stronger assertion.
18
+ Code or tests beyond what the change needs (unused options, extra layers, speculative generality) is at most a `nit` unless it hides a bug.
19
+
16
20
  ## Rules for every answer
17
21
 
18
22
  - Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
@@ -10,6 +10,10 @@ You are a code reviewer doing a QUICK pass on one small, low-risk change.
10
10
 
11
11
  Skip style, naming and structure unless it hides a bug. Keep `findings` short.
12
12
 
13
+ Never ask for hand mutations or for tests beyond the acceptance clauses.
14
+ The fix for a weak assertion is a stronger assertion.
15
+ Code or tests beyond what the change needs (unused options, extra layers, speculative generality) is at most a `nit` unless it hides a bug.
16
+
13
17
  ## Rules for every answer
14
18
 
15
19
  - Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
@@ -15,9 +15,14 @@ that changed since that round, each with a window of the current file around it.
15
15
  - `passed` is true only when every open finding is resolved and you raised no new `critical`
16
16
  or `warning`.
17
17
 
18
+ Never ask for hand mutations or for tests beyond the acceptance clauses.
19
+ The fix for a weak assertion is a stronger assertion.
20
+ Code or tests beyond what the change needs (unused options, extra layers, speculative generality) is at most a `nit` unless it hides a bug.
21
+
18
22
  ## Rules for every answer
19
23
 
20
24
  - Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
25
+ - A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
21
26
  - You have no tools and no project files. The packet is everything you get.
22
27
  - `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order.
23
28
  - Answer with ONE JSON object that matches the answer schema.
@@ -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,81 @@
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 (stem.endsWith('_test')) { test = true; stem = stem.slice(0, -5); }
21
+ } else if (ext === '.swift') {
22
+ if (stem.endsWith('Tests')) { test = true; stem = stem.slice(0, -5); }
23
+ } else return null;
24
+ return { ext, stem, test };
25
+ }
26
+
27
+ /** @param {string} file @param {ReadonlyArray<string>} candidates @returns {string[]} */
28
+ export function pairsFor(file, candidates) {
29
+ const source = identity(file);
30
+ if (!source) return [];
31
+ return [...new Set(candidates)].filter((candidate) => {
32
+ const other = identity(candidate);
33
+ return candidate !== file && other?.ext === source.ext && other.stem === source.stem && other.test !== source.test;
34
+ });
35
+ }
36
+
37
+ /**
38
+ * A best-effort list, not a parser or proof of coverage. Names only, never function bodies.
39
+ * Redact before truncating so a token crossing the 120-character boundary cannot leak.
40
+ * @param {string} text @param {string} file @returns {string[]}
41
+ */
42
+ export function summarize(text, file) {
43
+ const id = identity(file);
44
+ if (!id) return [];
45
+ /** @type {Set<string>} */
46
+ const names = new Set();
47
+ for (const line of text.split(/\r?\n/)) {
48
+ /** @type {RegExpMatchArray | null} */
49
+ let match = null;
50
+ if (id.test) {
51
+ if (id.ext === '.mjs' || id.ext === '.ts' || id.ext === '.php') {
52
+ match = line.match(/^\s*(?:await\s+)?(?:test|it)(?:\.(?:only|skip|todo))?\(\s*(['"`])([^\r\n]+?)\1/);
53
+ if (match) { names.add(match[2]); match = null; }
54
+ else if (id.ext === '.php') match = line.match(/^\s*(?:(?:public|protected)\s+)?function\s+(test\w+)\s*\(/);
55
+ else continue;
56
+ } else if (id.ext === '.go') match = line.match(/^\s*func\s+(Test\w+)\s*\(/);
57
+ else if (id.ext === '.py') match = line.match(/^\s*(?:async\s+)?def\s+(test_\w+)\s*\(/);
58
+ else if (id.ext === '.swift') match = line.match(/^\s*func\s+(test\w+)\s*\(/);
59
+ } else {
60
+ if (id.ext === '.mjs' || id.ext === '.ts') {
61
+ match = line.match(/^\s*export\s+(?:default\s+)?(?:async\s+)?(?:function\*?|class|const|let|var|interface|type|enum)\s+(\w+)/);
62
+ const list = line.match(/^\s*export\s*\{([^}]+)\}/);
63
+ if (list) {
64
+ for (const item of list[1].split(',')) {
65
+ const symbol = item.trim().match(/^(?:type\s+)?(\w+)(?:\s+as\s+(\w+))?$/);
66
+ if (symbol) names.add(symbol[2] ?? symbol[1]);
67
+ }
68
+ }
69
+ } else if (id.ext === '.php') match = line.match(/^\s*(?:(?:public|protected|private|static|final|abstract)\s+)*(?:function|class|interface|trait)\s+(\w+)/);
70
+ else if (id.ext === '.go') match = line.match(/^\s*(?:func\s+(?:\([^)]*\)\s+)?|(?:type|var|const)\s+)([A-Z]\w*)/);
71
+ else if (id.ext === '.py') match = line.match(/^\s*(?:async\s+)?(?:def|class)\s+([a-zA-Z]\w*)/);
72
+ else if (id.ext === '.swift') match = line.match(/^\s*(?:public|open)\s+(?:final\s+)?(?:func|class|struct|enum|protocol|let|var)\s+(\w+)/);
73
+ }
74
+ if (match) names.add(match[1]);
75
+ if (names.size >= 40) break;
76
+ }
77
+ return [...names].slice(0, 40).map((name) => {
78
+ const safe = redact(name);
79
+ return Array.from(findSecret(safe) ? '[REDACTED]' : safe).slice(0, 120).join('');
80
+ });
81
+ }
@@ -18,7 +18,7 @@
18
18
  * - `reopen-not-stopped` — no state, or a state that is open or complete;
19
19
  * - `reopen-same-content` — a stopped file whose current content is the content its stop
20
20
  * reviewed (no free re-roll: the human waives the findings instead), or a stop whose content
21
- * is unknown;
21
+ * is unknown and the current hash already has a verified review round;
22
22
  * - `reopen-already-recorded` — a verified human row already names the file's latest anchor seq
23
23
  * for other content (run `review-file`, or the content changed since that reopen);
24
24
  * - `reopen-write-failed` — the signed row was not written.
@@ -32,7 +32,8 @@
32
32
  * which the worker sets on every stop (`src/worker/engine.mjs`) and which is signed through the
33
33
  * anchor. A state from before B72 has none: then the latest verified `review.cap` row of THIS
34
34
  * stop counts — written after the file's latest verified `review.reopened` row, with the state's
35
- * round and stop reason — else the content is unknown and the reopen is refused.
35
+ * round and stop reason — else the content is unknown. B114: unknown content can restart only
36
+ * when no verified review.round of this run/block/file names the current hash (from_hash: null).
36
37
  *
37
38
  * The worker (`fixLoopRound`) asks `reopenRowFor`: a verified human row of this run for the block
38
39
  * and file whose `at_seq` is the file's latest anchor seq starts a new state (round 1, a FULL
@@ -102,6 +103,15 @@ export function stopContentHash(state, rows, { runId, block, file, key }) {
102
103
  return null;
103
104
  }
104
105
 
106
+ /**
107
+ * A legacy stop with unknown content can restart only on bytes with no verified round here.
108
+ * @param {ReadonlyArray<Record<string, any>>} rows
109
+ * @param {{runId: string, block: string, file: string, key: Buffer, contentHash: string}} at
110
+ */
111
+ function hasReviewedHash(rows, { runId, block, file, key, contentHash }) {
112
+ return rows.some((r) => r?.event === 'review.round' && r.run === runId && r.block === block && r.file === file && r.content_hash === contentHash && verified(r, key));
113
+ }
114
+
105
115
  /**
106
116
  * The block checks every reopen runs first, before any config, key or ledger read: the run must
107
117
  * know the block (`unknown_block`, the code `block waive` uses) and the block must be open.
@@ -138,7 +148,8 @@ function latestRowAt(rows, { runId, block, file, key, seq }) {
138
148
  * verifies with the run key, of this run, block and file, `by: human`, and whose `at_seq` is the
139
149
  * file's latest anchor seq counts (the latest such row). A `lost` state needs `from: lost`; a
140
150
  * stopped state needs `from: stopped` AND current content that is neither the row's `from_hash`
141
- * nor the content the stop reviewed (`stopContentHash`; unknown ⇒ no restart).
151
+ * nor the content the stop reviewed (`stopContentHash`). Unknown content requires a null
152
+ * from_hash, the row's to_hash equal to current content, and no verified round at that hash.
142
153
  * @param {ReadonlyArray<Record<string, any>>} rows
143
154
  * @param {{runId: string, block: string, file: string, key: Buffer, status: string, state: Record<string, any> | null, seq: number, contentHash: string}} at
144
155
  * @returns {Record<string, any> | null}
@@ -149,7 +160,8 @@ export function reopenRowFor(rows, { runId, block, file, key, status, state, seq
149
160
  if (status === 'lost') return row.from === 'lost' ? row : null;
150
161
  if ((status === 'ok' || status === 'restored') && state?.status === 'stopped' && row.from === 'stopped') {
151
162
  const stopHash = stopContentHash(state, rows, { runId, block, file, key });
152
- if (stopHash === null || contentHash === stopHash || contentHash === row.from_hash) return null;
163
+ if (contentHash === stopHash || contentHash === row.from_hash) return null;
164
+ if (stopHash === null && (row.from_hash !== null || row.to_hash !== contentHash || hasReviewedHash(rows, { runId, block, file, key, contentHash }))) return null;
153
165
  return row;
154
166
  }
155
167
  return null;
@@ -200,8 +212,8 @@ export async function reopenFile({ runId, id, file, reason, record, rows, key, w
200
212
  from = 'stopped';
201
213
  stopReason = typeof loaded.state.next?.reason === 'string' ? loaded.state.next.reason : 'stopped';
202
214
  fromHash = stopContentHash(loaded.state, runRows, { runId, block: id, file, key });
203
- if (fromHash === null) {
204
- throw new StateError('reopen-same-content', `reopen-same-content: the content the stop of ${file} reviewed is unknown, so a change cannot be shown; waive its findings instead (block waive); nothing was recorded`);
215
+ if (fromHash === null && hasReviewedHash(runRows, { runId, block: id, file, key, contentHash: current })) {
216
+ throw new StateError('reopen-same-content', `reopen-same-content: the content the stop of ${file} reviewed is unknown and its current content has already been reviewed; change the file and run block reopen, or the owner can run block close --no-require-reviews; nothing was recorded`);
205
217
  }
206
218
  if (fromHash === current) {
207
219
  throw new StateError('reopen-same-content', `reopen-same-content: ${file} still has the content its stop (${stopReason}) reviewed; change the file first, or waive its findings (block waive); nothing was recorded`);
@@ -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`);
package/src/util/exec.mjs CHANGED
@@ -139,6 +139,7 @@ function untrackChild(pid) {
139
139
  * @param {object} [opts]
140
140
  * @param {string} [opts.cwd]
141
141
  * @param {NodeJS.ProcessEnv} [opts.env]
142
+ * @param {BufferEncoding} [opts.stdoutEncoding] - stdout encoding (default UTF-8); base64 preserves binary git blobs.
142
143
  * @param {number} [opts.timeoutMs] - Kill the child (and its process group) if it runs longer
143
144
  * than this. Must be a finite number > 0 when given.
144
145
  * @param {number[]} [opts.okExitCodes] - Exit codes treated as `result: "ok"` (default `[0]`).
@@ -212,7 +213,7 @@ export function exec(argv, opts = {}) {
212
213
  child.kill(signal);
213
214
  };
214
215
 
215
- const stdoutText = () => Buffer.concat(stdoutChunks).toString('utf8');
216
+ const stdoutText = () => Buffer.concat(stdoutChunks).toString(opts.stdoutEncoding ?? 'utf8');
216
217
  const stderrText = () => Buffer.concat(stderrChunks).toString('utf8');
217
218
 
218
219
  /** @param {ExecResult} result */
@@ -6,3 +6,6 @@
6
6
 
7
7
  /** A gate argv item that is EXACTLY this string expands to the block's test files. */
8
8
  export const TEST_FILES_TOKEN = '{test_files}';
9
+
10
+ /** A whole argv item for lint/format paths, never a command. */
11
+ export const CHANGED_FILES_TOKEN = '{changed_files}';