@codedology/code-forge 0.11.0 → 0.13.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 (50) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +30 -2
  3. package/docs/reference/blocks.json +359 -1
  4. package/docs/reference/config.md +7 -5
  5. package/package.json +2 -2
  6. package/schema/code-forge.schema.json +4 -2
  7. package/skill/SKILL.md +2 -0
  8. package/skill/references/code.md +19 -5
  9. package/skill/references/proof.md +8 -2
  10. package/skill/references/review.md +16 -7
  11. package/skill/references/rules-core.md +1 -1
  12. package/skill/scripts/forge +1 -1
  13. package/skill/templates/coder-brief.md +15 -3
  14. package/src/autopilot/actions.mjs +4 -5
  15. package/src/cli/block.mjs +41 -0
  16. package/src/cli/context.mjs +3 -2
  17. package/src/cli/gates.mjs +15 -6
  18. package/src/cli/review-file.mjs +114 -3
  19. package/src/cli/review.mjs +2 -2
  20. package/src/config/validate.mjs +12 -5
  21. package/src/context/pack.mjs +20 -13
  22. package/src/doctor/index.mjs +2 -1
  23. package/src/doctor/local.mjs +19 -0
  24. package/src/doctor/worker-probe.mjs +15 -2
  25. package/src/gates/expand.mjs +102 -9
  26. package/src/gates/run.mjs +6 -4
  27. package/src/install/wizard/flags.mjs +1 -1
  28. package/src/ledger/spend.mjs +3 -3
  29. package/src/proof/lock.mjs +27 -10
  30. package/src/proof/rule.mjs +9 -0
  31. package/src/proof/tiers.mjs +2 -14
  32. package/src/review/fixloop.mjs +11 -6
  33. package/src/review/gate-check.mjs +34 -9
  34. package/src/review/lenses/A.md +4 -0
  35. package/src/review/lenses/B.md +4 -0
  36. package/src/review/lenses/full.md +4 -0
  37. package/src/review/lenses/judge.md +4 -0
  38. package/src/review/lenses/quick.md +4 -0
  39. package/src/review/lenses/recheck.md +4 -0
  40. package/src/review/paired.mjs +1 -0
  41. package/src/review/reopen.mjs +18 -6
  42. package/src/review/schema-fallback.mjs +3 -3
  43. package/src/util/exec.mjs +2 -1
  44. package/src/util/file-locks.mjs +118 -0
  45. package/src/util/forbidden.mjs +29 -2
  46. package/src/util/gate-token.mjs +3 -0
  47. package/src/worker/engine.mjs +2 -2
  48. package/src/worker/loop.mjs +98 -33
  49. package/src/worker/queue.mjs +183 -33
  50. package/src/worker/watch.mjs +115 -0
@@ -16,7 +16,8 @@
16
16
  * with `isolation: lock` through the B6 writer.
17
17
  */
18
18
 
19
- import { hasTestFilesItem, testScope } from '../gates/expand.mjs';
19
+ import { changedScope, fileBaseline, hasScopeItem, testScope } from '../gates/expand.mjs';
20
+ import { readAllRows } from '../ledger/write.mjs';
20
21
  import { ownedFileCount } from '../gates/owned-count.mjs';
21
22
  import { runGates } from '../gates/run.mjs';
22
23
  import { StateError } from '../state/paths.mjs';
@@ -139,23 +140,39 @@ export async function withGateSlot({ runId, blockId, fn, pid = process.pid }) {
139
140
  * run record once the slot is taken). The full suite runs instead when the block owns more than
140
141
  * `threshold` (`gates.full_suite_threshold_files`) files, or when no test file is known. The
141
142
  * owned files are counted by `ownedFileCount` over the tracked and new (not ignored) files at
142
- * `cwd`. Gates with no `{test_files}` item run exactly as before B60: the run record is not read
143
- * and nothing is counted.
144
- * @param {{runId: string, blockId: string, gates: Parameters<typeof runGates>[0], cwd: string, timeoutMs?: number, threshold?: number}} opts
143
+ * `cwd`. Plain gates without --changed retain the old path. B99 records bounded owned hashes
144
+ * after green scoped runs; --changed uses the last green record and B97's stem pairs.
145
+ * @param {{runId: string, blockId: string, gates: Parameters<typeof runGates>[0], cwd: string, timeoutMs?: number, threshold?: number, changed?: boolean, slug?: string}} opts
145
146
  */
146
- export async function runGatesGuarded({ runId, blockId, gates, cwd, timeoutMs, threshold }) {
147
+ export async function runGatesGuarded({ runId, blockId, gates, cwd, timeoutMs, threshold, changed = false, slug }) {
147
148
  return withGateSlot({
148
149
  runId,
149
150
  blockId,
150
151
  fn: async () => {
151
- // plain gates (no `{test_files}` item) run exactly as before B60: no scope is worked out
152
- if (!hasTestFilesItem(gates)) return runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) });
152
+ // Plain gates without --changed need no scope or baseline.
153
+ if (!hasScopeItem(gates) && !changed) {
154
+ const record = await readRun(runId);
155
+ const ran = await runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) });
156
+ return { ...ran, ledger_slug: slug ?? record.project };
157
+ }
153
158
  // the slot checked the block is open; it may still be gone by the time it is read again
154
- const block = (await readRun(runId)).blocks?.[blockId];
159
+ const record = await readRun(runId);
160
+ const block = record.blocks?.[blockId];
155
161
  if (!block) throw new StateError('no-block', `block ${blockId} is not in run ${runId}`);
156
162
  const ownedFiles = await ownedFileCount(cwd, block.owned_files, block.owned_kinds);
157
- const scope = testScope({ testFiles: block.test_files, owned: block.owned_files, ownedFiles, ...(threshold === undefined ? {} : { threshold }) });
158
- return runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}), scope });
163
+ const fallback = testScope({ testFiles: block.test_files, owned: block.owned_files, ownedFiles, ...(threshold === undefined ? {} : { threshold }) });
164
+ const current = await fileBaseline(cwd, block.owned_files, block.owned_kinds);
165
+ let baseline;
166
+ if (changed) {
167
+ const rows = await readAllRows(slug ?? record.project);
168
+ baseline = rows.filter((row) => row.event === 'gate.done' && row.run === runId && row.block === blockId && row.all_ok === true && row.worktree === block.worktree?.path).at(-1)?.files;
169
+ }
170
+ const scope = changed ? changedScope({ baseline, current, fallback, owned: block.owned_files, kinds: block.owned_kinds, ...(threshold === undefined ? {} : { threshold }) }) : fallback;
171
+ const ran = await runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}), scope });
172
+ // A command that changed an input did not measure the resulting bytes. Do not baseline it.
173
+ const after = ran.allOk && current ? await fileBaseline(cwd, block.owned_files, block.owned_kinds) : null;
174
+ const files = after && JSON.stringify(current?.files) === JSON.stringify(after.files) ? after.files : undefined;
175
+ return { ...ran, ledger_slug: slug ?? record.project, ...(files ? { files } : {}), ...(changed ? { changed_scope: scope } : {}) };
159
176
  },
160
177
  });
161
178
  }
@@ -0,0 +1,9 @@
1
+ /** The built-in proof rule carried by every coder context pack. */
2
+ export const PROOF_RULE = [
3
+ "## proof rule (code-forge)",
4
+ "- Red→green: each new or changed test must FAIL on the old code first (red), then PASS on the new code (green). A red caused by an import, compile or crash error proves nothing; the red must be an assertion failure. `code-forge proof red-green` does this with the block's base code.",
5
+ "- No hand mutations. Do not break the code on purpose to test the tests. Red→green is the proof.",
6
+ "- One exact test per acceptance clause, plus one negative or boundary test where the clause can fail. Assertions say which element and how many, never only \"it exists\".",
7
+ "- Proof scales with risk: a light-tier file needs red→green per new test; a high-tier file must have it at the gate. Do not add tests, mutants, layers, options or abstractions that the acceptance does not ask for.",
8
+ "- A weak assertion is fixed by a stronger assertion, not by more tests or mutants.",
9
+ ].join('\n');
@@ -5,9 +5,8 @@
5
5
  * high ⇐ risk ≥ 2, or the path matches `proof.tiers.high.paths`, or `security_sensitive`
6
6
  * light ⇐ everything else (the review-depth band `1 ≤ risk < 2` does NOT raise the proof tier)
7
7
  *
8
- * The light tier's proof is red→green (B10b). The high tier's extra proof — tool-made mutants on
9
- * changed lines — is held on Q16 (block B10c), so `proofRequirements('high')` reports it as
10
- * `held` and the gate enforces red→green only until Ricardo answers. Pure: no I/O.
8
+ * The light tier needs red→green per new or changed test. The high tier must have red→green
9
+ * at the gate. Mutation testing was cut by the owner (Q16). Pure: no I/O.
11
10
  */
12
11
 
13
12
  import { ownsFile } from '../state/registry.mjs';
@@ -50,14 +49,3 @@ export function blockTier(fileTiers) {
50
49
  if (!Array.isArray(fileTiers)) throw new TypeError('blockTier: fileTiers must be an array');
51
50
  return fileTiers.some((t) => t?.tier === 'high') ? 'high' : 'light';
52
51
  }
53
-
54
- /**
55
- * What the gate requires for a tier. `mutants: 'held'` = the high tier's mutation proof is
56
- * specified but not enforced until Q16 is answered (B10c).
57
- * @param {Tier} tier
58
- * @returns {{red_green: true, mutants: 'none' | 'held'}}
59
- */
60
- export function proofRequirements(tier) {
61
- if (!TIERS.includes(tier)) throw new TypeError(`proofRequirements: tier must be one of ${TIERS.join(', ')}`);
62
- return { red_green: true, mutants: tier === 'high' ? 'held' : 'none' };
63
- }
@@ -126,6 +126,7 @@ import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallba
126
126
  * @property {string | null} reviewed_content - the content the last counted round reviewed.
127
127
  * @property {boolean} l3_rung_used - the block's L3 rung (rule 5 or 6) was used — once per block.
128
128
  * @property {'open' | 'complete' | 'stopped'} status
129
+ * @property {string} [stop_content_hash] - bytes reviewed by a stop (absent in legacy states).
129
130
  * @property {NextStep | null} next
130
131
  * @property {{context_mode: string, context_lines: number} | null} [last_packet] - the last recheck round's context: one packet's, or over a Markdown recheck's section packets the summed lines and the one mode they share, else `mixed` (B54).
131
132
  * @property {'full' | 'recheck' | 'patch_check' | null} [pending_kind] - the round to re-run after a `retry`.
@@ -620,11 +621,11 @@ function retry(state, kind, reason) {
620
621
  * not written, the open set stays as it was, and the gate-relevant `review.cap` row goes through
621
622
  * the must-write path — a failed write is a `retry`, never a silent stop.
622
623
  * @param {FileState} state @param {LoopDeps} deps
623
- * @param {'full' | 'recheck' | 'patch_check'} kind @param {string} file
624
+ * @param {'full' | 'recheck' | 'patch_check'} kind @param {string} file @param {string} contentHash
624
625
  * @param {{status: string, tokensIn?: number, budget?: number, section?: string | null}} packet - the non-ok packet (a Markdown section names its heading).
625
626
  * @returns {Promise<FileState>}
626
627
  */
627
- async function stopBeforeRound(state, deps, kind, file, packet) {
628
+ async function stopBeforeRound(state, deps, kind, file, contentHash, packet) {
628
629
  const reason = packet.status;
629
630
  const size =
630
631
  typeof packet.tokensIn === 'number' && typeof packet.budget === 'number'
@@ -633,6 +634,7 @@ async function stopBeforeRound(state, deps, kind, file, packet) {
633
634
  const row = { event: 'review.cap', file, round: state.round, reason, open: state.open.map((f) => f.id), ...size };
634
635
  if (!(await mustNote(deps, row))) return retry(state, kind, 'ledger_write_failed');
635
636
  state.status = 'stopped';
637
+ state.stop_content_hash = contentHash;
636
638
  state.next = { action: 'stop', reason, ...size };
637
639
  state.pending_kind = null;
638
640
  return state;
@@ -703,7 +705,7 @@ export async function runRound(state, deps, opts = {}) {
703
705
  // a Markdown file whose packet is `split_required`, rechecked section by section (B54);
704
706
  // any other status stops as it always did
705
707
  const packed = whole.status === 'split_required' && isMarkdownFile(current.rel) ? packRecheck(diff, pending, whole.budget, moves) : null;
706
- if (!packed || packed.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, packed ?? whole);
708
+ if (!packed || packed.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, current.hash, packed ?? whole);
707
709
  parts = packed.sections.map((sec) => ({ index: sec.index, heading: sec.headings[0], diff: sec.diff, open: sec.open, keys: sec.keys }));
708
710
  }
709
711
  // EVERY packet is built and held to the budget BEFORE any session runs: a section over it
@@ -718,9 +720,9 @@ export async function runRound(state, deps, opts = {}) {
718
720
  const moved = movedText(moves, diff.content, part.keys);
719
721
  const packet = buildRecheckPacket({ diff: part.diff, open: part.open, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved, paired });
720
722
  // a packet that fails to build stops with ITS status (and size, when it has one)
721
- if (packet.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, { ...packet, section: part.heading });
723
+ if (packet.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, current.hash, { ...packet, section: part.heading });
722
724
  const size = recheckTokens(part.diff, part.open, moved);
723
- if (size > whole.budget) return stopBeforeRound(state, deps, kind, current.rel, { status: 'split_required', tokensIn: size, budget: whole.budget, section: part.heading });
725
+ if (size > whole.budget) return stopBeforeRound(state, deps, kind, current.rel, current.hash, { status: 'split_required', tokensIn: size, budget: whole.budget, section: part.heading });
724
726
  built.push(packet);
725
727
  }
726
728
  /** @type {Set<Finding>} the pending findings a session that LISTED them resolved */
@@ -804,7 +806,10 @@ export async function runRound(state, deps, opts = {}) {
804
806
  const after = new Set(draft.open.map((f) => f.id));
805
807
  const closedIds = new Set(state.open.map((f) => f.id).filter((id) => !after.has(id))).size;
806
808
  draft.next = decideNext(draft, openBefore, s, kind, closedIds);
807
- if (draft.next.action === 'stop') draft.status = 'stopped';
809
+ if (draft.next.action === 'stop') {
810
+ draft.status = 'stopped';
811
+ draft.stop_content_hash = current.hash;
812
+ }
808
813
  }
809
814
  const rows = [...lateRows, roundRow];
810
815
  if (draft.status === 'stopped') rows.push({ event: 'review.cap', file: current.rel, round: draft.round, reason: draft.next.reason, open: draft.open.map((f) => f.id) });
@@ -16,7 +16,7 @@
16
16
  * session really reviewed these bytes; a repeated stop or an `unavailable` result never counts),
17
17
  * the latest refusal row for the hash is signed, carries `content_hash` (a hash-less one does
18
18
  * not qualify, though it still refuses) and names at least one finding, and every finding it
19
- * names and every open id of the file's `review.cap` rows is cleared by a waiver the gate
19
+ * names and every open id of the file's unsuperseded `review.cap` rows is cleared by a waiver the gate
20
20
  * accepts (the same rule as below: a critical only by `by: human`). Such a file is never
21
21
  * approved; the close writes one signed `gate.review_waived_all` row for it ({@link reviewGateCheck});
22
22
  * - `unsigned`: the file's approvals for that hash exist but none carries a valid MAC;
@@ -43,11 +43,12 @@
43
43
  * - `review_cap`: a file stopped at `review_cap` has an open finding with no signed
44
44
  * `review.waived {by: human}` row — unless a signed approval of the file's current hash comes
45
45
  * AFTER the cap row (ledger order) and no later refusal row made it stale (the human fixed it
46
- * by hand and it was reviewed); a finding already refused as `not_approved` for that file is
46
+ * by hand and it was reviewed), or a verified human reopen after the cap is followed by a
47
+ * signed review round of the current bytes; a finding already refused as `not_approved` for that file is
47
48
  * not repeated. B73: this detail, and an `unreviewed` detail for a file with a cap row, ends
48
49
  * with the stop's ways out (`exits: block reopen, block waive, review-file --answers,
49
50
  * re-decompose`, `stop-exits.mjs`);
50
- * - `unproven`: a changed owned file of tier `high` (B20, Ricardo 2026-09-25) has no covering
51
+ * - `unproven`: a changed owned file of tier `high` (B20, the owner 2026-09-25) has no covering
51
52
  * red→green proof (`checkBlockProof`, below). Light-tier files need no proof row.
52
53
  * B71 (answers): a finding is also cleared — wherever a waiver clears it, by the one rule set
53
54
  * ({@link reviewRules}: `waiverOf`, so `reviewed_by_waiver` reads "waived or answered") — by a
@@ -137,7 +138,7 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
137
138
  /** @type {Map<string, string>} */
138
139
  const current = new Map();
139
140
  for (const f of [...proofFiles, ...files]) current.set(f.file, f.content_hash);
140
- const { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, recordedRefusalSeverity } = reviewRules({ block, runId, rows, key, resultFiles, current });
141
+ const { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, recordedRefusalSeverity, supersededCap } = reviewRules({ block, runId, rows, key, resultFiles, current });
141
142
  /** @type {Refusal[]} */
142
143
  const refusals = [];
143
144
  const approvedFor = (/** @type {string} */ file, /** @type {string} */ hash) => mine.filter((r) => r.event === 'review.approved' && r.file === file && r.content_hash === hash);
@@ -216,6 +217,7 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
216
217
  }
217
218
 
218
219
  for (const cap of mine.filter((r) => r.event === 'review.cap')) {
220
+ if (supersededCap(cap)) continue;
219
221
  const hash = hashOf.get(cap.file);
220
222
  // honoured unless a signed approval of the current content comes AFTER the cap and is not stale
221
223
  if (hash !== undefined) {
@@ -260,6 +262,16 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
260
262
  // namedOnHash}), which has no key and can only refuse; it then reads no autopilot waiver
261
263
  const ok = verify ?? ((/** @type {Record<string, any>} */ r) => verified(r, key));
262
264
  const mine = rows.filter((r) => r?.block === block && (runId === undefined || r.run === undefined || r.run === runId));
265
+ /** A human reopen followed by a real review of the current bytes clears older cap ids.
266
+ * @param {Record<string, any>} cap @returns {boolean}
267
+ */
268
+ const supersededCap = (cap) => {
269
+ const hash = current.get(cap.file);
270
+ if (hash === undefined) return false;
271
+ const at = mine.indexOf(cap);
272
+ return mine.some((r, i) => i > at && r.event === 'review.reopened' && r.run === runId && r.file === cap.file && r.by === 'human' && ok(r)
273
+ && mine.some((round, j) => j > i && round.event === 'review.round' && round.run === runId && round.file === cap.file && round.content_hash === hash && ok(round)));
274
+ };
263
275
  /**
264
276
  * B52: the index of the last signed approval and the latest refusal AFTER it for (file, hash);
265
277
  * `denial` is null when that approval stands (or there is none — `unreviewed` / `unsigned` say
@@ -380,7 +392,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
380
392
  };
381
393
 
382
394
  /**
383
- * B79: the ids every signed `review.cap` row for (file, hash) names. A cap row records no
395
+ * B79: the ids every unsuperseded signed `review.cap` row for (file, hash) names. A cap row records no
384
396
  * severity, so the rules read each of these ids at {@link recordedRefusalSeverity} — also when
385
397
  * that cap row is the latest refusal (its own null never stands for them).
386
398
  * @param {string} file @param {string} hash @returns {Set<string>}
@@ -390,6 +402,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
390
402
  const ids = new Set();
391
403
  for (const cap of mine) {
392
404
  if (cap.event !== 'review.cap' || cap.file !== file || cap.content_hash !== hash || !Array.isArray(cap.open) || !ok(cap)) continue;
405
+ if (supersededCap(cap)) continue;
393
406
  for (const id of cap.open) if (typeof id === 'string' && id.length > 0) ids.add(id);
394
407
  }
395
408
  return ids;
@@ -400,7 +413,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
400
413
  * or null when no such review qualifies. All of: a signed `review.round` row for (file, hash) — a
401
414
  * session reviewed these bytes; the latest refusal row for (file, hash) is signed, carries
402
415
  * `content_hash: hash` (a hash-less one never qualifies) and names at least one finding. The ids
403
- * are every finding it names and every open id of the file's `review.cap` rows. Severities: an id
416
+ * are every finding it names and every open id of the file's unsuperseded `review.cap` rows. Severities: an id
404
417
  * a signed `review.cap` row at (file, hash) names ({@link capIdsAt}) — also when that cap row is
405
418
  * the latest refusal — and any other open cap id use {@link recordedRefusalSeverity}, whose
406
419
  * `unknown` (no signed refusal at `hash` records one) is the human's alone and never answered.
@@ -417,6 +430,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
417
430
  const named = new Map(latest.open.map((f) => [f.id, f.severity]));
418
431
  for (const id of capIdsAt(file, hash)) named.set(id, recordedRefusalSeverity(file, hash, id));
419
432
  for (const cap of mine.filter((r) => r.event === 'review.cap' && r.file === file)) {
433
+ if (supersededCap(cap)) continue;
420
434
  if (!Array.isArray(cap.open) || !cap.open.every((/** @type {unknown} */ id) => typeof id === 'string' && id.length > 0)) return null;
421
435
  for (const id of cap.open) if (!named.has(id)) named.set(id, recordedRefusalSeverity(file, hash, id));
422
436
  }
@@ -477,7 +491,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
477
491
  return new Map([...named].filter(([id, severity]) => id !== PROOF_FINDING && answerableSeverity(severity) && waiverOf(file, id, severity, ticket).by === null));
478
492
  };
479
493
 
480
- return { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, openAt, answerRows, recordedRefusalSeverity };
494
+ return { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, openAt, answerRows, recordedRefusalSeverity, supersededCap };
481
495
  }
482
496
 
483
497
  /**
@@ -803,7 +817,7 @@ export function closeTier({ file, rows, key, highPaths }) {
803
817
  }
804
818
 
805
819
  /**
806
- * B20 (Ricardo 2026-09-25): every changed owned file of tier `high` needs a covering red→green
820
+ * B20 (the owner 2026-09-25): every changed owned file of tier `high` needs a covering red→green
807
821
  * proof, checked PER FILE. A file is covered by a `proof` row of this run and block (`rows` must
808
822
  * already be the block's rows) with `step: 'red-green'`, `proven: true`, `red_kind: 'assertion'`,
809
823
  * whose MAC verifies and whose `covers` list (written by `proof red-green`: the sources the
@@ -1045,7 +1059,18 @@ export function reviewGateCheck(load, { onWaivedAll } = {}) {
1045
1059
  onWaivedAll?.(result.waivedAll);
1046
1060
  return { ok: true };
1047
1061
  }
1048
- return { ok: false, reason: result.refusals.map((r) => `${r.code}${r.file ? ` ${r.file}` : ''}${r.finding ? ` ${r.finding}` : ''}`).join('; ') };
1062
+ const reasons = [];
1063
+ const transcriptIds = new Set();
1064
+ for (const r of result.refusals) {
1065
+ const hit = r.code === 'rule_break' && /^transcript line \d+: (.+)$/.exec(r.detail);
1066
+ if (hit) {
1067
+ if (transcriptIds.has(hit[1])) continue;
1068
+ transcriptIds.add(hit[1]);
1069
+ if (transcriptIds.size <= 5) reasons.push(`rule_break ${r.detail}`);
1070
+ } else reasons.push(`${r.code}${r.file ? ` ${r.file}` : ''}${r.finding ? ` ${r.finding}` : ''}`);
1071
+ }
1072
+ if (transcriptIds.size > 5) reasons.push(`+${transcriptIds.size - 5} more rule_break`);
1073
+ return { ok: false, reason: reasons.join('; ') };
1049
1074
  };
1050
1075
  }
1051
1076
 
@@ -11,6 +11,10 @@ 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.
@@ -12,6 +12,10 @@ 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.
@@ -12,6 +12,10 @@ 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.
@@ -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,6 +15,10 @@ 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.
@@ -17,6 +17,7 @@ function identity(file) {
17
17
  if (stem.endsWith('_test')) { test = true; stem = stem.slice(0, -5); }
18
18
  } else if (ext === '.py') {
19
19
  if (stem.startsWith('test_')) { test = true; stem = stem.slice(5); }
20
+ else if (stem.endsWith('_test')) { test = true; stem = stem.slice(0, -5); }
20
21
  } else if (ext === '.swift') {
21
22
  if (stem.endsWith('Tests')) { test = true; stem = stem.slice(0, -5); }
22
23
  } else return null;
@@ -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`);
@@ -59,7 +59,7 @@
59
59
  * Concurrency: the worker never runs two tickets for one (block, file) at once (its file lock,
60
60
  * `worker/loop.mjs`), and round 1, its retry, every recheck and the patch_check of a file are all
61
61
  * tickets for that file, so one packet's history is read and written by one ticket at a time. The
62
- * read-history → write-mark section here also runs under an in-process lock per (packet hash,
62
+ * read-history → write-mark section here also runs under a cross-process lock per (packet hash,
63
63
  * level, role), so even the same file in two blocks cannot take a packet's one try twice.
64
64
  *
65
65
  * No ledger writer (`writeRow` absent) ⇒ nothing is logged and nothing is tried. The helpers never
@@ -71,7 +71,7 @@ import { createHash } from 'node:crypto';
71
71
  import { closedBookRefused, openBookCodexAllowed } from '../config/closed-book.mjs';
72
72
  import { resolveLevel } from '../config/known-ids.mjs';
73
73
  import { logWarning } from '../util/error-log.mjs';
74
- import { keyedLock } from '../util/locks.mjs';
74
+ import { withLock } from '../util/file-locks.mjs';
75
75
  import { schemaErrorsOf } from './validate-review.mjs';
76
76
 
77
77
  /** Broken results (B91: schema misses and timeouts) of the level's own ladder on one packet before the second level is tried. */
@@ -291,7 +291,7 @@ export async function afterSchemaMiss({ res, session, packetText, cfg, allowed,
291
291
  /** @type {{cfg: Record<string, any>, to: {level: string, provider: string, model: string}} | null} */
292
292
  let go = null;
293
293
  try {
294
- go = await keyedLock(`schema-fallback\0${hash}\0${session.level}\0${session.role}`, async () => {
294
+ go = await withLock(`schema-fallback\0${hash}\0${session.level}\0${session.role}`, async () => {
295
295
  // the history is read BEFORE a schema miss is logged: failures = signed earlier misses + this
296
296
  // one. A timeout's note is already in it (B91): it is the miss in hand, never added twice.
297
297
  let seen = null;
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 */
@@ -0,0 +1,118 @@
1
+ /** Cross-process locks, with FIFO serialization inside this process. */
2
+ import { createHash } from 'node:crypto';
3
+ import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { processStartTime } from '../state/run.mjs';
7
+ import { keyedLock } from './locks.mjs';
8
+
9
+ /** @typedef {{probe?: typeof processStartTime, alive?: (pid: number) => boolean, afterRead?: () => Promise<unknown>, pollMs?: number}} LockOpts */
10
+ /** @typedef {{ok: true, release: () => void} | {ok: false, result: 'held'}} LockResult */
11
+ /** @type {{ok: false, result: 'held'}} */
12
+ const HELD = { ok: false, result: 'held' };
13
+ const locksDir = () => path.join(os.homedir(), '.code-forge', 'locks');
14
+ /** @param {string} key */
15
+ const lockPath = (key) => path.join(locksDir(), `${createHash('sha256').update(key).digest('hex')}.lock`);
16
+ /** @type {Promise<string | null> | undefined} */
17
+ let selfStart;
18
+
19
+ /** @param {number} pid */
20
+ function alive(pid) {
21
+ try { process.kill(pid, 0); return true; }
22
+ catch (err) { return err.code !== 'ESRCH'; }
23
+ }
24
+
25
+ /** @param {Buffer} bytes @returns {{key: string, pid: number, start: string} | null} */
26
+ function ownerOf(bytes) {
27
+ try {
28
+ const owner = JSON.parse(bytes.toString('utf8'));
29
+ return owner && typeof owner.key === 'string' && Number.isInteger(owner.pid) && owner.pid > 1 && typeof owner.start === 'string' ? owner : null;
30
+ } catch { return null; }
31
+ }
32
+
33
+ /** Exclusive creation; release synchronously removes only this owner's bytes.
34
+ * @param {string} file @param {Buffer} bytes @returns {LockResult}
35
+ */
36
+ function create(file, bytes) {
37
+ let fd;
38
+ try { fd = openSync(file, 'wx', 0o600); }
39
+ catch (err) { if (err.code === 'EEXIST') return HELD; throw err; }
40
+ try { writeFileSync(fd, bytes); }
41
+ finally { closeSync(fd); }
42
+ let released = false;
43
+ return { ok: true, release() {
44
+ if (released) return;
45
+ released = true;
46
+ try { if (readFileSync(file).equals(bytes)) unlinkSync(file); }
47
+ catch { /* missing, unreadable or replaced: leave it alone */ }
48
+ } };
49
+ }
50
+
51
+ /** Take a key or fail closed. A proven dead owner permits one guarded break attempt.
52
+ * @param {string} key @param {LockOpts} [opts] @returns {Promise<LockResult>}
53
+ */
54
+ export async function tryLock(key, opts = {}) {
55
+ const probe = opts.probe ?? processStartTime;
56
+ const start = await (selfStart ??= processStartTime(process.pid));
57
+ if (typeof start !== 'string') throw new Error('cannot determine lock owner process start time');
58
+ const file = lockPath(key);
59
+ const bytes = Buffer.from(JSON.stringify({ key, pid: process.pid, start }));
60
+ mkdirSync(locksDir(), { recursive: true, mode: 0o700 });
61
+ const initial = create(file, bytes);
62
+ if (initial.ok) return initial;
63
+ let seen;
64
+ try { seen = readFileSync(file); }
65
+ catch { return HELD; }
66
+ await opts.afterRead?.();
67
+ const owner = ownerOf(seen);
68
+ if (owner === null || owner.key !== key) return HELD;
69
+ let dead = !(opts.alive ?? alive)(owner.pid);
70
+ if (!dead) {
71
+ const actual = await probe(owner.pid);
72
+ dead = actual !== null && actual !== owner.start;
73
+ }
74
+ if (!dead) return HELD;
75
+
76
+ // Only unchanged dead-owner bytes may be removed under the per-key break mutex.
77
+ const mutex = `${file}.break`;
78
+ let guard;
79
+ try { guard = openSync(mutex, 'wx', 0o600); }
80
+ catch { return HELD; }
81
+ try {
82
+ writeFileSync(guard, bytes);
83
+ try { if (!readFileSync(file).equals(seen)) return HELD; }
84
+ catch { return HELD; }
85
+ unlinkSync(file);
86
+ return create(file, bytes);
87
+ } finally {
88
+ closeSync(guard);
89
+ unlinkSync(mutex);
90
+ }
91
+ }
92
+
93
+ /** @template T @param {string} key @param {() => T | Promise<T>} fn @param {LockOpts} [opts] @returns {Promise<T>} */
94
+ export function withLock(key, fn, opts = {}) {
95
+ return keyedLock(key, async () => {
96
+ let lock = await tryLock(key, opts);
97
+ while (!lock.ok) {
98
+ await new Promise((resolve) => setTimeout(resolve, opts.pollMs ?? 25));
99
+ lock = await tryLock(key, opts);
100
+ }
101
+ try { return await fn(); }
102
+ finally { lock.release(); }
103
+ });
104
+ }
105
+
106
+ /** @returns {string[]} paths of unreadable/malformed locks or dead-owner break mutexes. */
107
+ export function listBadLocks() {
108
+ let names;
109
+ try { names = readdirSync(locksDir()); }
110
+ catch (err) { if (err.code === 'ENOENT') return []; throw err; }
111
+ return names.filter((name) => name.endsWith('.lock') || name.endsWith('.lock.break')).sort().map((name) => path.join(locksDir(), name)).filter((file) => {
112
+ try {
113
+ const owner = ownerOf(readFileSync(file));
114
+ return owner === null || (file.endsWith('.lock.break') && !alive(owner.pid));
115
+ }
116
+ catch { return true; }
117
+ });
118
+ }