@codedology/code-forge 0.11.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.
@@ -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) });
@@ -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,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`);
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}';
@@ -57,6 +57,7 @@ import { countLines, RouteError, routeTicket } from '../review/route.mjs';
57
57
  import { canonicalPath, contentRootFor, reviewTicket } from './engine.mjs';
58
58
  import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
59
59
  import { DELETED_HASH, hashBytes, readRegularFileNoFollow, repoRootOf, WorkerError } from './ticket.mjs';
60
+ import { watchTick } from './watch.mjs';
60
61
 
61
62
  /** Name of the key the worker resolves for System 1 (`keys.jev` / `system1.key` reference). */
62
63
  export const JEV_KEY_NAME = 'jev';
@@ -234,6 +235,8 @@ export function recordedVerdict(result) {
234
235
  * @property {typeof spawnSession} [spawn]
235
236
  * @property {typeof readRun} [readRun] - the run-record read behind the per-ticket config (a test seam).
236
237
  * @property {typeof readTicket} [readTicket] - the ticket read (a test seam).
238
+ * @property {typeof import('../util/exec.mjs').exec} [watchExec] - watch git exec seam.
239
+ * @property {Parameters<typeof watchTick>[0]['hooks']} [watchHooks] - deterministic watch races.
237
240
  * @property {import('../keys/store.mjs').KeyStore} [store] - default: B2's production store.
238
241
  * @property {any} [opRead]
239
242
  * @property {NodeJS.ProcessEnv} [env]
@@ -281,6 +284,9 @@ export async function createWorker(opts, deps = {}) {
281
284
 
282
285
  /** @param {Record<string, any>} row */
283
286
  const ledger = (row) => writeRow(signRow({ run: runId, ...row }, key));
287
+ // Shared across direct ticks and the scheduler, so running tickets are never superseded.
288
+ const activeTickets = new Set();
289
+ const tickWatch = () => watchTick({ repoRoot, runId, cfg, readRun: loadRun, writeRow: ledger, running: activeTickets, exec: deps.watchExec, hooks: deps.watchHooks });
284
290
 
285
291
  /**
286
292
  * The config ticket `id` is reviewed with (B35): its snapshot from the run record, re-read per
@@ -617,6 +623,13 @@ export async function createWorker(opts, deps = {}) {
617
623
  }
618
624
  }
619
625
  if (!stopping) {
626
+ try {
627
+ await tickWatch();
628
+ } catch (thrown) {
629
+ const text = `watch tick failed (${errorKind(thrown)})`;
630
+ writeSafe(warnStream, `worker: WARN ${text}\n`);
631
+ try { await logWarning({ warning: 'watch_tick_failed', message: text }); } catch { /* a log failure never stops dispatch */ }
632
+ }
620
633
  for (const id of pendingTickets(repoRoot)) {
621
634
  if (poisoned.has(id) || started.has(id)) continue;
622
635
  if (limit === null) limit = await poolSize();
@@ -624,6 +637,7 @@ export async function createWorker(opts, deps = {}) {
624
637
  const fileKey = fileKeyOf(id);
625
638
  if (fileKey !== null && busy.has(fileKey)) continue; // waits without a slot
626
639
  started.add(id);
640
+ activeTickets.add(id);
627
641
  if (fileKey !== null) busy.add(fileKey);
628
642
  const slot = runOne(id)
629
643
  .then((n) => {
@@ -632,6 +646,7 @@ export async function createWorker(opts, deps = {}) {
632
646
  .finally(() => {
633
647
  // the slot is freed on every exit path (`runOne` never rejects)
634
648
  running.delete(id);
649
+ activeTickets.delete(id);
635
650
  fileKeys.delete(id);
636
651
  if (fileKey !== null) busy.delete(fileKey);
637
652
  });
@@ -737,7 +752,7 @@ export async function createWorker(opts, deps = {}) {
737
752
  retractWorker(repoRoot, process.pid);
738
753
  }
739
754
 
740
- return { processTicket, drain, run, once, stop, release, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
755
+ return { processTicket, drain, run, once, stop, release, watchTick: tickWatch, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
741
756
  }
742
757
 
743
758
  /**
@@ -102,17 +102,56 @@ export function resolveTicket({ repoRoot, run, block, file, tree = null }) {
102
102
  * @throws {WorkerError} `bad-path` for a relative `tree` or one that cannot be resolved.
103
103
  */
104
104
  export function enqueue({ repoRoot, run, block, file, tree = null, now = new Date() }) {
105
- const { rel, root, content_hash, ticket } = resolveTicket({ repoRoot, run, block, file, tree });
105
+ const { content_hash } = resolveTicket({ repoRoot, run, block, file, tree });
106
+ return enqueueHash({ repoRoot, run, block, file, tree, content_hash, now });
107
+ }
108
+
109
+ /** Queue proven git-object content through the same identity as a manual review.
110
+ * @param {{repoRoot: string, run: string, block: string, file: string, content_hash: string, tree?: string | null, now?: Date}} opts
111
+ * @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, tree?: string}}
112
+ */
113
+ export function enqueueHash({ repoRoot, run, block, file, content_hash, tree = null, now = new Date() }) {
114
+ const rel = assertRowPath(file);
115
+ const root = tree === null ? null : realTree(tree);
116
+ const ticket = ticketId({ run, block, file: rel, content_hash, tree: root });
106
117
  const target = path.join(queueDir(repoRoot), `${ticket}.json`);
107
118
  const named = root === null ? {} : { tree: root };
108
119
  if (!existsSync(target)) {
109
120
  /** @type {Ticket} */
110
121
  const body = { ticket, run, block, file: rel, content_hash, enqueued_at: now.toISOString(), ...named };
111
- writeAtomic(target, `${JSON.stringify(body)}\n`);
122
+ // Publish a complete immutable ticket without replacing a concurrent coder's ticket.
123
+ mkdirSync(path.dirname(target), { recursive: true });
124
+ const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
125
+ try {
126
+ writeFileSync(tmp, `${JSON.stringify(body)}\n`, { mode: 0o644 });
127
+ try { linkSync(tmp, target); } catch (err) { if (err.code !== 'EEXIST') throw err; }
128
+ } finally { rmSync(tmp, { force: true }); }
112
129
  }
130
+ // Returning content is current again. Claim only a superseded marker's exact bytes;
131
+ // a real result that replaced it wins the comparison and is restored unchanged.
132
+ let seen;
133
+ let done;
134
+ try {
135
+ seen = readFileSync(path.join(queueDir(repoRoot), `${ticket}.done`));
136
+ done = JSON.parse(seen.toString('utf8'));
137
+ } catch { /* missing or unreadable markers remain untouched */ }
138
+ if (seen && done?.status === 'refused' && done.reason === 'superseded' && !done.result) dropDoneIf(repoRoot, ticket, seen);
113
139
  return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash, ...named };
114
140
  }
115
141
 
142
+ /** Finish only an unstarted, pending review. A superseded marker has no review result.
143
+ * @param {string} repoRoot @param {string} ticket
144
+ * @param {{write?: (file: string, text: string) => void, link?: (from: string, to: string) => void}} [io] - publication seams for tests.
145
+ */
146
+ export function supersedeTicket(repoRoot, ticket, io = {}) {
147
+ const marker = path.join(queueDir(repoRoot), `${assertTicketId(ticket)}.done`);
148
+ const tmp = `${marker}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
149
+ try {
150
+ (io.write ?? ((file, text) => writeFileSync(file, text, { mode: 0o644 })))(tmp, `${JSON.stringify({ ticket, status: 'refused', reason: 'superseded' })}\n`);
151
+ try { (io.link ?? linkSync)(tmp, marker); } catch (err) { if (err.code !== 'EEXIST') throw err; }
152
+ } finally { rmSync(tmp, { force: true }); }
153
+ }
154
+
116
155
  /** B71: the most finding ids one answers ticket may name. */
117
156
  export const MAX_ANSWERS = 10;
118
157
 
@@ -369,6 +408,25 @@ export function pendingTickets(repoRoot) {
369
408
  return pending.map((id) => ({ id, at: at(id) })).sort((a, b) => (a.at === b.at ? (a.id < b.id ? -1 : 1) : a.at < b.at ? -1 : 1)).map((e) => e.id);
370
409
  }
371
410
 
411
+ /**
412
+ * Valid review tickets for one block and content tree, oldest first. Answers tickets cannot
413
+ * stand in for a file's own review. Unreadable tickets are refused, never silently omitted.
414
+ * @param {{repoRoot: string, run: string, block: string, tree?: string | null}} opts
415
+ * @returns {Ticket[]}
416
+ */
417
+ export function blockTickets({ repoRoot, run, block, tree = null }) {
418
+ let names;
419
+ try {
420
+ names = readdirSync(queueDir(repoRoot));
421
+ } catch (err) {
422
+ if (err.code === 'ENOENT') return [];
423
+ throw new WorkerError('queue-unreadable', 'the review queue cannot be read');
424
+ }
425
+ const tickets = names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n)).map((n) => readTicket(repoRoot, n.slice(0, 24)));
426
+ return tickets.filter((t) => !t.kind && t.run === run && t.block === block && (t.tree ?? null) === tree)
427
+ .sort((a, b) => a.enqueued_at.localeCompare(b.enqueued_at) || a.ticket.localeCompare(b.ticket));
428
+ }
429
+
372
430
  /**
373
431
  * Read and re-validate a ticket (coder-writable): its id must match its content.
374
432
  * @param {string} repoRoot @param {string} id
@@ -0,0 +1,113 @@
1
+ /** Opt-in commit watch. Read immutable git objects, never the coder's working bytes. */
2
+ import { randomBytes } from 'node:crypto';
3
+ import { mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { reviewFileLoop } from '../config/validate.mjs';
6
+ import { snapshotFor } from '../state/config-snapshot.mjs';
7
+ import { readRun } from '../state/run.mjs';
8
+ import { ownsAt } from '../review/gate-check.mjs';
9
+ import { exec } from '../util/exec.mjs';
10
+ import { blockTickets, enqueueHash, isDone, queueDir, supersedeTicket } from './queue.mjs';
11
+ import { assertRowPath, DELETED_HASH, gitChildEnv, hashBytes } from './ticket.mjs';
12
+
13
+ const SHA = /^[0-9a-f]{40,64}$/;
14
+ const BLOCK = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
15
+
16
+ /**
17
+ * One scheduler tick, also exposed directly for deterministic race tests.
18
+ * The boot config gates ALL watch work; absent/wait/overlap never reads the run or spawns git.
19
+ * @param {{repoRoot: string, runId: string, cfg: Record<string, any>, readRun?: typeof readRun,
20
+ * writeRow: (row: Record<string, any>) => Promise<unknown>, exec?: typeof exec,
21
+ * running?: ReadonlySet<string>, hooks?: {afterHead?: (head: string) => Promise<unknown>, beforeQueue?: () => Promise<unknown>}}} opts
22
+ */
23
+ export async function watchTick({ repoRoot, runId, cfg, readRun: loadRun = readRun, writeRow, exec: execute = exec, running = new Set(), hooks = {} }) {
24
+ if (reviewFileLoop(cfg) !== 'watch') return;
25
+ const record = await loadRun(runId);
26
+ if (record.status !== 'active' || reviewFileLoop(snapshotFor(record, '')?.config ?? cfg) !== 'watch') return;
27
+ const env = gitChildEnv();
28
+ for (const [block, entry] of Object.entries(record.blocks ?? {})) {
29
+ if (entry.status !== 'open' || !BLOCK.test(block)) continue;
30
+ const stateFile = path.join(queueDir(repoRoot), 'watch', `${block}.json`);
31
+ let tree = null;
32
+ let root = repoRoot;
33
+ let state = {};
34
+ try { state = JSON.parse(readFileSync(stateFile, 'utf8')) ?? {}; } catch { /* restart at base */ }
35
+ const base = entry.base_sha;
36
+ /** @param {Record<string, any>} next */
37
+ const save = (next) => {
38
+ mkdirSync(path.dirname(stateFile), { recursive: true });
39
+ const tmp = `${stateFile}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
40
+ try {
41
+ writeFileSync(tmp, `${JSON.stringify(next)}\n`, { mode: 0o600 });
42
+ renameSync(tmp, stateFile);
43
+ state = next;
44
+ } finally { rmSync(tmp, { force: true }); }
45
+ };
46
+ /** @param {string} reason */
47
+ const skip = async (reason) => {
48
+ const skipped = Array.isArray(state.skipped) ? state.skipped : [];
49
+ if (!skipped.includes(reason)) {
50
+ await writeRow({ event: 'watch.skipped', block, reason });
51
+ save({ ...state, skipped: [...skipped, reason] });
52
+ }
53
+ };
54
+ try {
55
+ if (entry.worktree) {
56
+ root = realpathSync(entry.worktree.path);
57
+ tree = root;
58
+ }
59
+ if (state.run !== runId || state.base !== base || state.tree !== tree) state = { run: runId, base, tree, skipped: [] };
60
+ /** @param {string[]} argv */
61
+ const git = (argv) => execute(['git', ...argv], { cwd: root, env, timeoutMs: 10000 });
62
+ const headResult = await git(['rev-parse', '--verify', 'HEAD^{commit}']);
63
+ const head = headResult.stdout.trim();
64
+ if (headResult.result !== 'ok' || !SHA.test(head)) { await skip('unreadable_head'); continue; }
65
+ const earlier = blockTickets({ repoRoot, run: runId, block, tree });
66
+ await hooks.afterHead?.(head);
67
+ if (typeof base !== 'string' || !SHA.test(base) || (await git(['merge-base', '--is-ancestor', base, head])).result !== 'ok') {
68
+ await skip('not_descendant'); continue;
69
+ }
70
+ const last = typeof state.sha === 'string' && SHA.test(state.sha) ? state.sha : base;
71
+ if (last !== base && (await git(['merge-base', '--is-ancestor', last, head])).result !== 'ok') {
72
+ await skip('rewound');
73
+ const { sha: _sha, ...restart } = state;
74
+ save(restart);
75
+ continue;
76
+ }
77
+ // A healthy tick ends the skip streak: no new commit here, or a queued commit below. An
78
+ // unreadable commit keeps the streak, so it writes one watch.skipped row, not one per tick.
79
+ if (last === head) { if (state.skipped?.length) save({ ...state, skipped: [] }); continue; }
80
+ const diff = await git(['diff', '--name-only', '-z', '--no-renames', last, head, '--']);
81
+ if (diff.result !== 'ok') { await skip('unreadable_head'); continue; }
82
+ const owns = ownsAt(tree ? '' : path.relative(repoRoot, record.workspace).split(path.sep).join('/'));
83
+ const files = diff.stdout.split('\0').filter(Boolean);
84
+ const owned = files.filter((file) => owns(entry.owned_files, file, entry.owned_kinds));
85
+ if (owned.length !== files.length) await skip('unowned_path');
86
+ // Stage every hash before publishing anything: an unreadable object queues nothing.
87
+ const hashes = [];
88
+ let unreadable = false;
89
+ for (const raw of owned) {
90
+ const file = assertRowPath(raw);
91
+ const listed = await git(['ls-tree', '-z', head, '--', file]);
92
+ if (listed.result !== 'ok') { unreadable = true; break; }
93
+ if (!listed.stdout) { hashes.push({ file, content_hash: DELETED_HASH }); continue; }
94
+ if (!/^100(?:644|755) blob /.test(listed.stdout)) { unreadable = true; break; }
95
+ const shown = await execute(['git', 'show', `${head}:${file}`], { cwd: root, env, timeoutMs: 10000, stdoutEncoding: 'base64' });
96
+ if (shown.result !== 'ok') { unreadable = true; break; }
97
+ hashes.push({ file, content_hash: hashBytes(Buffer.from(shown.stdout, 'base64')) });
98
+ }
99
+ if (unreadable) { await skip('unreadable_head'); continue; }
100
+ await hooks.beforeQueue?.();
101
+ for (const { file, content_hash } of hashes) {
102
+ const current = enqueueHash({ repoRoot, run: runId, block, file, content_hash, tree });
103
+ for (const old of earlier) {
104
+ if (old.file === file && old.ticket !== current.ticket && !running.has(old.ticket) && !isDone(repoRoot, old.ticket)) supersedeTicket(repoRoot, old.ticket);
105
+ }
106
+ }
107
+ save({ ...state, run: runId, base, tree, sha: head, skipped: [] });
108
+ } catch {
109
+ // A bad path, missing tree or failed git read is a skip, never a block failure.
110
+ await skip('unreadable_head');
111
+ }
112
+ }
113
+ }