@codedology/code-forge 0.9.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +49 -9
  3. package/docs/reference/blocks.json +587 -1
  4. package/docs/reference/config.md +36 -0
  5. package/package.json +2 -2
  6. package/schema/code-forge.schema.json +89 -0
  7. package/skill/SKILL.md +1 -1
  8. package/skill/references/adapters/claude-code.md +1 -1
  9. package/skill/references/adapters/solo.md +2 -2
  10. package/skill/references/adapters/subprocess.md +12 -2
  11. package/skill/references/code.md +14 -7
  12. package/skill/references/ledger.md +2 -2
  13. package/skill/references/proof.md +77 -2
  14. package/skill/references/review.md +39 -7
  15. package/skill/references/security.md +4 -4
  16. package/skill/scripts/forge +1 -1
  17. package/skill/templates/coder-brief.md +6 -6
  18. package/src/autopilot/binnacle.mjs +9 -3
  19. package/src/cli/block.mjs +262 -31
  20. package/src/cli/jev.mjs +22 -3
  21. package/src/cli/keys.mjs +156 -21
  22. package/src/cli/proof.mjs +35 -7
  23. package/src/cli/review-file.mjs +17 -11
  24. package/src/cli/spawn.mjs +44 -3
  25. package/src/config/validate.mjs +320 -5
  26. package/src/doctor/index.mjs +2 -1
  27. package/src/doctor/local.mjs +27 -6
  28. package/src/doctor/probes.mjs +38 -0
  29. package/src/engines/builders/codex.mjs +44 -4
  30. package/src/engines/sandbox-dirs.mjs +257 -0
  31. package/src/keys/store.mjs +128 -21
  32. package/src/proof/junit.mjs +530 -0
  33. package/src/proof/red-green.mjs +57 -9
  34. package/src/proof/red-parse.mjs +2 -0
  35. package/src/proof/runners.mjs +472 -0
  36. package/src/proof/xcresult.mjs +285 -0
  37. package/src/review/engine.mjs +141 -42
  38. package/src/review/fixloop.mjs +75 -23
  39. package/src/review/gate-check.mjs +3 -2
  40. package/src/review/lenses/A.md +2 -0
  41. package/src/review/lenses/B.md +2 -0
  42. package/src/review/lenses/full.md +2 -0
  43. package/src/review/lenses/quick.md +1 -0
  44. package/src/review/lenses/recheck.md +2 -0
  45. package/src/review/packet.mjs +48 -14
  46. package/src/review/paired.mjs +80 -0
  47. package/src/review/reopen.mjs +4 -6
  48. package/src/review/reviewed-lines.mjs +94 -0
  49. package/src/review/route.mjs +232 -0
  50. package/src/review/schema-fallback.mjs +77 -22
  51. package/src/review/session-retry.mjs +44 -9
  52. package/src/review/test-quality.mjs +201 -0
  53. package/src/review/verb-forms.mjs +39 -0
  54. package/src/session/spawn.mjs +39 -2
  55. package/src/state/block-end.mjs +10 -6
  56. package/src/state/block.mjs +13 -3
  57. package/src/state/config-snapshot.mjs +39 -0
  58. package/src/state/run.mjs +4 -0
  59. package/src/util/forbidden.mjs +2 -2
  60. package/src/worker/engine.mjs +43 -15
  61. package/src/worker/loop.mjs +98 -15
  62. package/src/worker/ticket.mjs +9 -1
@@ -93,11 +93,12 @@ import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
93
93
  import { parseDiff } from './context.mjs';
94
94
  import { assemblePacket, assertPacketPath, diffOnlyTokens, readFileDiff } from './packet.mjs';
95
95
  import { movedText, movesFor, peerDiffReader } from './moved.mjs';
96
+ import { pairedContext } from './engine.mjs';
96
97
  import { isMarkdownFile, packSections, sectionFindings, sectionGroups, sectionOf } from './sections.mjs';
97
98
  import { cameFromJudge, resolvedBand, triageFindings } from './triage.mjs';
98
99
  import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
99
100
  import { spawnWithTimeoutRetry } from './session-retry.mjs';
100
- import { afterSchemaMiss, logSecondLevelMiss } from './schema-fallback.mjs';
101
+ import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallback.mjs';
101
102
 
102
103
  /** @typedef {import('./triage.mjs').Finding} Finding */
103
104
  /** @typedef {import('./context.mjs').Hunk} Hunk */
@@ -144,6 +145,9 @@ import { afterSchemaMiss, logSecondLevelMiss } from './schema-fallback.mjs';
144
145
  * @property {(req: {file: string, level: 'L3', open: Finding[]}) => Promise<unknown>} [patch] - the L3 patch rung (≤ 80 lines, owned files only).
145
146
  * @property {string | null} [base] - the block base (`recheck_scope: file`, and the B56 moved-code diff).
146
147
  * @property {ReadonlyArray<string>} [peers] - B56: the block's other changed files (moved-code hints).
148
+ * @property {ReadonlyArray<string>} [ownedFiles] - concrete paths owned by this block.
149
+ * @property {ReadonlyArray<string>} [changedFiles] - concrete changed paths of the same block.
150
+ * @property {ReadonlyArray<string>} [testFiles] - the block's explicit test_files.
147
151
  * @property {() => Promise<import('./moved.mjs').PeerDiff[]>} [peerDiffs] - B56: a memoised reader of their diffs (`peerDiffReader`); wins over `peers`.
148
152
  * @property {string} [rulesDigest] @property {string} [factsExcerpt]
149
153
  * @property {number} [extraRounds] - B47: rounds past `review.max_rounds_per_file` granted by autopilot (`extraRoundsFor`).
@@ -362,11 +366,11 @@ export function recheckTokens(diff, open, moved = '') {
362
366
  /**
363
367
  * The recheck packet: the `recheck` lens on the fix hunks (hunk form, never the whole file), then
364
368
  * the open findings by id. Over budget ⇒ `minimal` (see the module doc).
365
- * @param {{diff: import('./packet.mjs').FileDiff, open: Finding[], cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', rulesDigest?: string, factsExcerpt?: string, moved?: string}} opts -
369
+ * @param {{diff: import('./packet.mjs').FileDiff, open: Finding[], cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', rulesDigest?: string, factsExcerpt?: string, moved?: string, paired?: string}} opts -
366
370
  * `moved`: the file's `## moved code` section (B56), '' for none.
367
371
  */
368
- export function buildRecheckPacket({ diff, open, cfg, contextMode = 'recheck', rulesDigest, factsExcerpt, moved = '' }) {
369
- const packet = assemblePacket({ diff, lens: 'recheck', cfg, contextMode, rulesDigest, factsExcerpt, moved });
372
+ export function buildRecheckPacket({ diff, open, cfg, contextMode = 'recheck', rulesDigest, factsExcerpt, moved = '', paired = '' }) {
373
+ const packet = assemblePacket({ diff, lens: 'recheck', cfg, contextMode, rulesDigest, factsExcerpt, moved, paired });
370
374
  if (packet.status !== 'ok') return packet;
371
375
  const text = `${packet.text}${openFindingsText(open)}`;
372
376
  return { ...packet, text, tokensIn: packet.tokensIn + Math.ceil(Buffer.byteLength(text.slice(packet.text.length)) / 4) };
@@ -431,25 +435,49 @@ export function insideHunks(f, hunks) {
431
435
  }
432
436
 
433
437
  /**
434
- * One recheck session through the stub guard — and, on a `schema` miss, the B55 second-level try
435
- * on the same packet (`schema-fallback.mjs`; rechecks and the patch_check alike).
438
+ * One recheck session through the stub guard — and, on a `schema` miss or (B91) a timed-out
439
+ * attempt that is the packet's second broken result, the B55 second-level try on the same packet
440
+ * (`schema-fallback.mjs`; rechecks and the patch_check alike).
436
441
  * @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string}} packet
437
442
  * @param {LoopDeps} deps
443
+ * @param {ReadonlyArray<Finding>} open - only the findings listed in this packet.
438
444
  */
439
- async function recheckSession(packet, deps) {
445
+ async function recheckSession(packet, deps, open) {
440
446
  mkdirSync(deps.workDir, { recursive: true, mode: 0o700 });
441
447
  const promptPath = path.join(deps.workDir, `recheck-${randomBytes(6).toString('hex')}.md`);
442
448
  writeFileSync(promptPath, packet.text, { mode: 0o600 });
443
449
  const min = minTokensOut(deps.cfg);
450
+ /** @param {Record<string, any> | null} result @returns {import('./validate-review.mjs').Verdict} */
451
+ const validate = (result) => {
452
+ const verdict = validateReview(result, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
453
+ if (!verdict.ok) return verdict;
454
+ const ids = new Set((verdict.review?.resolved ?? []).map((/** @type {{id: string}} */ entry) => entry.id));
455
+ if (open.some((finding) => !ids.has(finding.id))) return { ok: false, reason: 'schema', detail: 'resolved does not cover the packet open findings' };
456
+ return verdict;
457
+ };
444
458
  const session = { lens: 'recheck', role: 'reviewer', level: 'L2' };
445
- /** @param {Record<string, any> | undefined} cfg */
459
+ // no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
460
+ // (each helper handles its own failed write)
461
+ const write = deps.writeRow ? (/** @type {Record<string, any>} */ row) => /** @type {NonNullable<LoopDeps['writeRow']>} */ (deps.writeRow)(row) : undefined;
462
+ const hash = packetHash(packet.text);
463
+ /** @param {Record<string, any> | null} missed @param {'schema' | 'timeout'} trigger */
464
+ const tryNext = (missed, trigger) =>
465
+ afterSchemaMiss({ res: missed, session, packetText: packet.text, cfg: deps.cfg ?? {}, allowed: true /* rechecks are never consensus; the budget gate is inside the spawn */, history: deps.schemaHistory, writeRow: write, spawnAt: attempt, trigger });
466
+ /**
467
+ * @param {Record<string, any> | undefined} cfg - set only for the second-level try (B91: its
468
+ * timeouts never count and never try again).
469
+ * @returns {Promise<{res: Record<string, any> | null, attempts: number, to?: Record<string, any>, broken?: Record<string, any>}>}
470
+ */
446
471
  const attempt = async (cfg) =>
447
472
  deps.spawn
448
- ? // a `timeout` is spawned once more on the same packet and level (B30)
473
+ ? // a `timeout` is spawned once more on the same packet and level (B30); B91: the packet's
474
+ // second broken result tries the second level instead
449
475
  spawnWithTimeoutRetry(
450
476
  deps.spawn,
451
477
  { level: 'L2', role: 'reviewer', promptPath, schema: FINDING_SCHEMA, rowExtra: { lens: 'recheck', context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn }, ...(cfg ? { cfg } : {}) },
452
478
  (row) => note(deps, row),
479
+ undefined,
480
+ cfg ? { packetHash: hash, secondLevel: true } : { packetHash: hash, onBroken: (r) => tryNext(r, 'timeout') },
453
481
  )
454
482
  : { res: null, attempts: 0 };
455
483
  /** @type {Record<string, any> | null} */
@@ -462,23 +490,40 @@ async function recheckSession(packet, deps) {
462
490
  let tokensOut = null;
463
491
  // the packet file is removed on every path, and only after the second-level try (which reads it)
464
492
  try {
493
+ /** @type {{res: Record<string, any> | null, attempts: number, to?: Record<string, any>, broken?: Record<string, any>} | null} */
494
+ let first = null;
465
495
  try {
466
- res = (await attempt(undefined)).res;
496
+ first = await attempt(undefined);
497
+ res = first.res;
467
498
  } catch {
468
499
  res = null; // the helper never throws; if it ever did, it is still an `exit` failure
469
500
  }
470
- tokensIn = res?.usage?.tokens_in ?? null;
471
- tokensOut = res?.usage?.tokens_out ?? null;
472
- verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
473
- if (!verdict.ok && verdict.reason === 'schema') {
501
+ verdict = validate(res);
502
+ if (first?.to) {
503
+ // B91: a timed-out attempt was the packet's second broken result; the second level ran. The
504
+ // verdict is already set: whatever throws after it leaves that verdict, never an exception
505
+ // the second level's miss is logged first, in its own try, so no sum error can skip it
474
506
  try {
475
- // no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
476
- // (each helper handles its own failed write)
477
- const write = deps.writeRow ? (/** @type {Record<string, any>} */ row) => /** @type {NonNullable<LoopDeps['writeRow']>} */ (deps.writeRow)(row) : undefined;
478
- const second = await afterSchemaMiss({ res, session, packetText: packet.text, cfg: deps.cfg ?? {}, allowed: true /* rechecks are never consensus; the budget gate is inside the spawn */, history: deps.schemaHistory, writeRow: write, spawnAt: attempt });
507
+ if (!verdict.ok && verdict.reason === 'schema') await logSecondLevelMiss({ res, session, packetText: packet.text, writeRow: write });
508
+ } catch {
509
+ // defensive only: the helper never throws; the verdict above stands (never approval)
510
+ }
511
+ try {
512
+ tokensIn = sum(first.broken?.usage?.tokens_in ?? null, res?.usage?.tokens_in);
513
+ tokensOut = sum(first.broken?.usage?.tokens_out ?? null, res?.usage?.tokens_out);
514
+ } catch {
515
+ // defensive only, as on the schema path below
516
+ }
517
+ } else {
518
+ tokensIn = res?.usage?.tokens_in ?? null;
519
+ tokensOut = res?.usage?.tokens_out ?? null;
520
+ }
521
+ if (!first?.to && !verdict.ok && verdict.reason === 'schema') {
522
+ try {
523
+ const second = await tryNext(res, 'schema');
479
524
  if (second) {
480
525
  // compute everything first, then assign together, so a throw never leaves a mixed state
481
- const secondVerdict = validateReview(second.res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
526
+ const secondVerdict = validate(second.res);
482
527
  const sumIn = sum(tokensIn, second.res?.usage?.tokens_in);
483
528
  const sumOut = sum(tokensOut, second.res?.usage?.tokens_out);
484
529
  res = second.res;
@@ -632,6 +677,11 @@ export async function runRound(state, deps, opts = {}) {
632
677
  s.scope === 'file'
633
678
  ? await readFileDiff({ repoRoot: deps.repoRoot, file: current.rel, base: deps.base ?? null })
634
679
  : await fixHunkDiff({ file: current.rel, previous: state.reviewed_content ?? '', current: current.content, workDir: deps.workDir });
680
+ if (kind === 'recheck' && (current.content === state.reviewed_content || diff.hunks.length === 0)) {
681
+ state.next = { action: 'fix', level: state.level, trigger: null, reason: 'no-fix-hunks' };
682
+ state.pending_kind = null;
683
+ return state;
684
+ }
635
685
  if (diff.hunks.length > 0) {
636
686
  /** @type {Finding[]} */
637
687
  const pending = [];
@@ -644,7 +694,8 @@ export async function runRound(state, deps, opts = {}) {
644
694
  if (pending.length > 0) {
645
695
  const contextMode = s.scope === 'file' ? 'auto' : 'recheck';
646
696
  const moves = await recheckMoves(deps, current.rel, s.scope === 'file' ? diff : null);
647
- const whole = buildRecheckPacket({ diff, open: pending, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved: movedText(moves, diff.content, null) });
697
+ const paired = pairedContext({ ...deps, file: current.rel });
698
+ const whole = buildRecheckPacket({ diff, open: pending, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved: movedText(moves, diff.content, null), paired });
648
699
  /** @type {Array<{index: number | null, heading: string | null, diff: import('./packet.mjs').FileDiff, open: Finding[], keys: number[] | null}>} */
649
700
  let parts = [{ index: null, heading: null, diff, open: pending, keys: null }];
650
701
  if (whole.status !== 'ok') {
@@ -665,7 +716,7 @@ export async function runRound(state, deps, opts = {}) {
665
716
  continue;
666
717
  }
667
718
  const moved = movedText(moves, diff.content, part.keys);
668
- const packet = buildRecheckPacket({ diff: part.diff, open: part.open, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved });
719
+ const packet = buildRecheckPacket({ diff: part.diff, open: part.open, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved, paired });
669
720
  // a packet that fails to build stops with ITS status (and size, when it has one)
670
721
  if (packet.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, { ...packet, section: part.heading });
671
722
  const size = recheckTokens(part.diff, part.open, moved);
@@ -681,7 +732,7 @@ export async function runRound(state, deps, opts = {}) {
681
732
  for (const [i, part] of parts.entries()) {
682
733
  const packet = /** @type {any} */ (built[i]);
683
734
  packetInfo = mergePacketInfo(packetInfo, packet);
684
- const session = await recheckSession(packet, deps);
735
+ const session = await recheckSession(packet, deps, part.open);
685
736
  tokensIn = sum(tokensIn, session.tokens_in);
686
737
  tokensOut = sum(tokensOut, session.tokens_out);
687
738
  if (!session.verdict.ok) {
@@ -711,7 +762,7 @@ export async function runRound(state, deps, opts = {}) {
711
762
  draft.open = [...stillOpen, ...triaged.fix_now, ...blocking];
712
763
  }
713
764
  }
714
- // no fix hunks (the content did not change): nothing closes — the round stalls
765
+ // A patch_check with no fix hunks closed nothing: B73 still stops it.
715
766
  }
716
767
 
717
768
  if (kind !== 'patch_check') {
@@ -796,6 +847,7 @@ export async function converge(state, deps) {
796
847
  }
797
848
  await deps.fix?.({ file: state.file, round: state.round + 1, level: state.level, open: [...state.open], l3_patch: state.l3_rung_used });
798
849
  await runRound(state, deps);
850
+ if (state.next?.reason === 'no-fix-hunks') return state;
799
851
  }
800
852
  return state;
801
853
  }
@@ -93,6 +93,7 @@ import { tierFor } from '../proof/tiers.mjs';
93
93
  import { DELETED_HASH, contentHash, gitChildEnv } from '../worker/ticket.mjs';
94
94
  import { verifyResult } from '../worker/queue.mjs';
95
95
  import { stopExitsLine } from './stop-exits.mjs';
96
+ import { checkFileForm, checkFindingId } from './verb-forms.mjs';
96
97
 
97
98
  /**
98
99
  * @typedef {{file: string, content_hash: string, status: 'reviewed_by_waiver', findings: string[], by: 'human' | 'autopilot' | null, answered?: string[]}} WaivedAll
@@ -1055,10 +1056,10 @@ export function reviewGateCheck(load, { onWaivedAll } = {}) {
1055
1056
  * @returns {Promise<Record<string, any>>} the signed row.
1056
1057
  */
1057
1058
  export async function waiveFinding({ runId, id, finding, reason, file, writeRow }) {
1058
- if (typeof finding !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(finding)) throw new StateError('usage', 'a finding id is required (letters, digits, . _ : -)');
1059
+ checkFindingId(finding);
1059
1060
  if (typeof reason !== 'string' || reason.trim().length === 0) throw new StateError('usage', 'block waive needs --reason');
1060
1061
  if (typeof file !== 'string' || file.length === 0) throw new StateError('usage', 'block waive needs --file <path>');
1061
- if (path.isAbsolute(file) || file.split('/').includes('..') || file.startsWith('./')) throw new StateError('usage', '--file must be repo-root-relative');
1062
+ checkFileForm(file, { normalized: false });
1062
1063
  const record = await readRun(runId);
1063
1064
  if (!record.blocks?.[id]) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
1064
1065
  const row = { event: 'review.waived', block: id, file, finding, reason: reason.trim(), by: 'human' };
@@ -14,9 +14,11 @@ You work blind: you do not see the other reviewer's answer.
14
14
  ## Rules for every answer
15
15
 
16
16
  - Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
17
+ - A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
17
18
  - You have no tools and no project files. The packet is everything you get.
18
19
  - `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
20
  - 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).
20
21
  - Every finding names `file`, `line_start`, `line_end` (line numbers of the CURRENT file, as numbered in the context), `severity` (`critical` = wrong behaviour, data loss, security; `warning` = a real defect that is not critical; `nit` = style or preference), a short `category`, the `claim`, the `evidence` you read in the packet, and the `fix`. Each of `claim`, `evidence`, `fix` ≤ 300 chars.
21
22
  - `passed` is true only when you found no `critical` and no `warning`.
22
23
  - Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
24
+ - A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.
@@ -15,9 +15,11 @@ You work blind: you do not see the other reviewer's answer.
15
15
  ## Rules for every answer
16
16
 
17
17
  - Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
18
+ - A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
18
19
  - You have no tools and no project files. The packet is everything you get.
19
20
  - `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
21
  - 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).
21
22
  - Every finding names `file`, `line_start`, `line_end` (line numbers of the CURRENT file, as numbered in the context), `severity` (`critical` = wrong behaviour, data loss, security; `warning` = a real defect that is not critical; `nit` = style or preference), a short `category`, the `claim`, the `evidence` you read in the packet, and the `fix`. Each of `claim`, `evidence`, `fix` ≤ 300 chars.
22
23
  - `passed` is true only when you found no `critical` and no `warning`.
23
24
  - Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
25
+ - A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.
@@ -15,9 +15,11 @@ Report each real defect once, at the lines where it lives.
15
15
  ## Rules for every answer
16
16
 
17
17
  - Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
18
+ - A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
18
19
  - You have no tools and no project files. The packet is everything you get.
19
20
  - `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
21
  - 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).
21
22
  - Every finding names `file`, `line_start`, `line_end` (line numbers of the CURRENT file, as numbered in the context), `severity` (`critical` = wrong behaviour, data loss, security; `warning` = a real defect that is not critical; `nit` = style or preference), a short `category`, the `claim`, the `evidence` you read in the packet, and the `fix`. Each of `claim`, `evidence`, `fix` ≤ 300 chars.
22
23
  - `passed` is true only when you found no `critical` and no `warning`.
23
24
  - Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
25
+ - A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.
@@ -19,3 +19,4 @@ Skip style, naming and structure unless it hides a bug. Keep `findings` short.
19
19
  - Every finding names `file`, `line_start`, `line_end` (line numbers of the CURRENT file, as numbered in the context), `severity` (`critical` = wrong behaviour, data loss, security; `warning` = a real defect that is not critical; `nit` = style or preference), a short `category`, the `claim`, the `evidence` you read in the packet, and the `fix`. Each of `claim`, `evidence`, `fix` ≤ 300 chars.
20
20
  - `passed` is true only when you found no `critical` and no `warning`.
21
21
  - Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
22
+ - A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.
@@ -18,6 +18,8 @@ that changed since that round, each with a window of the current file around it.
18
18
  ## Rules for every answer
19
19
 
20
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.
21
22
  - You have no tools and no project files. The packet is everything you get.
22
23
  - `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order.
23
24
  - Answer with ONE JSON object that matches the answer schema.
25
+ - A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.
@@ -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
+ }
@@ -8,8 +8,8 @@
8
8
  * Human-only like `block waive`: the verb is on `CODER_ONLY_FORBIDDEN` (`src/util/forbidden.mjs`)
9
9
  * and its row is signed with the run key, `by: human` (`block waive` has no other in-verb guard,
10
10
  * so neither has this). The verb (`reopenFile`) refuses, writing no row, with a stable code:
11
- * - `usage` (exit 2) — no `--file`, no `--reason`, or a path that is not a normalised
12
- * repo-root-relative path;
11
+ * - `usage` (exit 2) — no `--file`, no `--reason`, or a path with a control character or that is
12
+ * not a normalised repo-root-relative path (`checkFileForm`, shared with `block waive`);
13
13
  * - `unknown_block` — the run does not know the block (the code `block waive` uses);
14
14
  * - `reopen-block-not-open` — the block is not open (stopped or closed);
15
15
  * - `reopen-not-owned` — the block does not own the file;
@@ -42,9 +42,9 @@
42
42
  *
43
43
  * This file imports nothing from `src/worker/`: the fix-loop state is read through `io`.
44
44
  */
45
- import path from 'node:path';
46
45
  import { StateError } from '../state/paths.mjs';
47
46
  import { verifyRow } from '../state/signer.mjs';
47
+ import { checkFileForm } from './verb-forms.mjs';
48
48
 
49
49
  export const REOPENED_EVENT = 'review.reopened';
50
50
 
@@ -177,9 +177,7 @@ export function reopenRowFor(rows, { runId, block, file, key, status, state, seq
177
177
  export async function reopenFile({ runId, id, file, reason, record, rows, key, writeRow, writeSigned, io }) {
178
178
  if (typeof file !== 'string' || file.length === 0) throw new StateError('usage', 'block reopen needs --file <path>');
179
179
  if (typeof reason !== 'string' || reason.trim().length === 0) throw new StateError('usage', 'block reopen needs --reason');
180
- if (path.isAbsolute(file) || file.split('/').includes('..') || file.startsWith('./') || path.posix.normalize(file) !== file) {
181
- throw new StateError('usage', '--file must be repo-root-relative');
182
- }
180
+ checkFileForm(file, { normalized: true });
183
181
  const entry = openBlockEntry(record, runId, id);
184
182
  const owned = Array.isArray(entry.owned_files) ? entry.owned_files : [];
185
183
  if (!io.owns(owned, file, entry.owned_kinds)) throw new StateError('reopen-not-owned', `reopen-not-owned: block ${id} does not own ${file}; nothing was recorded`);