@codedology/code-forge 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +34 -3
  3. package/docs/reference/blocks.json +360 -1
  4. package/docs/reference/config.md +6 -5
  5. package/package.json +2 -2
  6. package/schema/code-forge.schema.json +3 -2
  7. package/skill/SKILL.md +3 -1
  8. package/skill/references/code.md +17 -8
  9. package/skill/references/proof.md +8 -2
  10. package/skill/references/review.md +32 -5
  11. package/skill/references/rules-core.md +1 -1
  12. package/skill/scripts/forge +1 -1
  13. package/skill/templates/coder-brief.md +17 -7
  14. package/src/cli/block.mjs +112 -7
  15. package/src/cli/gates.mjs +15 -6
  16. package/src/cli/review-file.mjs +118 -14
  17. package/src/config/validate.mjs +12 -5
  18. package/src/context/pack.mjs +7 -4
  19. package/src/gates/expand.mjs +102 -9
  20. package/src/gates/run.mjs +6 -4
  21. package/src/install/wizard/flags.mjs +1 -1
  22. package/src/proof/lock.mjs +23 -10
  23. package/src/proof/rule.mjs +9 -0
  24. package/src/proof/tiers.mjs +2 -14
  25. package/src/review/engine.mjs +59 -12
  26. package/src/review/fixloop.mjs +41 -16
  27. package/src/review/gate-check.mjs +2 -2
  28. package/src/review/lenses/A.md +5 -0
  29. package/src/review/lenses/B.md +5 -0
  30. package/src/review/lenses/full.md +5 -0
  31. package/src/review/lenses/judge.md +4 -0
  32. package/src/review/lenses/quick.md +4 -0
  33. package/src/review/lenses/recheck.md +5 -0
  34. package/src/review/packet.mjs +48 -14
  35. package/src/review/paired.mjs +81 -0
  36. package/src/review/reopen.mjs +18 -6
  37. package/src/review/reviewed-lines.mjs +94 -0
  38. package/src/state/block-end.mjs +10 -6
  39. package/src/util/exec.mjs +2 -1
  40. package/src/util/gate-token.mjs +3 -0
  41. package/src/worker/engine.mjs +43 -15
  42. package/src/worker/loop.mjs +35 -2
  43. package/src/worker/queue.mjs +60 -2
  44. package/src/worker/watch.mjs +113 -0
@@ -42,7 +42,7 @@
42
42
  * second `schema` miss — this round's first try after the earlier ticket's — is tried once on
43
43
  * `review.second_levels.<level>` when one is configured.
44
44
  * B56: every round gets ONE memoised reader of the block's OTHER changed files' diffs
45
- * (`blockPeerDiffs`: listed and read at most once per ticket) so the review can name code moved
45
+ * (the ticket's `fileContext`: listed once, diffs read lazily at most once per ticket) so the review can name code moved
46
46
  * between them in a `## moved code` packet section (round 1, rechecks and patch check alike).
47
47
  * The review NEVER approves an empty or failed session: that is `unavailable` (§4.2 stub guard).
48
48
  * A throw inside the round is `unavailable` too, with the state saved as `next: retry` (see
@@ -169,14 +169,14 @@ import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } fro
169
169
  * `tree` (B62b) is the recorded `worktree` `{path, branch, base_sha}`, null when the block has
170
170
  * none; `base` is then the tree's `base_sha`. A recorded worktree that `worktreeRecord` refuses
171
171
  * (path, branch, hex base_sha) throws `worktree-malformed`.
172
- * @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, acceptance?: unknown, kind: string | null, tree: WorktreeEntry | null, workspace: string | null}>}
172
+ * @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, testFiles: string[], acceptance?: unknown, kind: string | null, tree: WorktreeEntry | null, workspace: string | null}>}
173
173
  */
174
174
  export async function blockEntryFor(runId, block) {
175
175
  let record;
176
176
  try {
177
177
  record = await readRun(runId);
178
178
  } catch (err) {
179
- if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, acceptance: undefined, kind: null, tree: null, workspace: null };
179
+ if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, testFiles: [], acceptance: undefined, kind: null, tree: null, workspace: null };
180
180
  throw new Error('run-record-unreadable');
181
181
  }
182
182
  if (!record || typeof record !== 'object' || !record.blocks || typeof record.blocks !== 'object') throw new Error('run-record-unreadable');
@@ -187,6 +187,7 @@ export async function blockEntryFor(runId, block) {
187
187
  const owned = Array.isArray(entry.owned_files) ? entry.owned_files.filter((f) => typeof f === 'string') : [];
188
188
  const kinds = entry.owned_kinds;
189
189
  const ownedKinds = kinds && typeof kinds === 'object' && !Array.isArray(kinds) ? kinds : {};
190
+ const testFiles = Array.isArray(entry.test_files) ? entry.test_files.filter((f) => typeof f === 'string' && !/[*?\[\]{}]/.test(f) && ownsFile(owned, f, ownedKinds)) : [];
190
191
  /** @type {WorktreeEntry | null} */
191
192
  let tree = null;
192
193
  if (entry.worktree !== undefined && entry.worktree !== null) {
@@ -196,10 +197,11 @@ export async function blockEntryFor(runId, block) {
196
197
  throw new Error('worktree-malformed');
197
198
  }
198
199
  }
199
- return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
200
+ return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, testFiles, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
200
201
  }
201
202
 
202
203
  /** @typedef {{path: string, branch: string, base_sha: string}} WorktreeEntry */
204
+ /** @typedef {{peers: string[], changedFiles: string[], ownedFiles: string[], testFiles: string[]}} FileContext */
203
205
 
204
206
  /** The refusals of {@link blockTree} a ticket answers with, as its `reason`. */
205
207
  const TREE_CODES = new Set(['worktree-malformed', 'worktree-missing', 'worktree-not-dir', 'worktree-branch-mismatch', 'worktree-workspace-missing', 'worktree-foreign', 'git-failed']);
@@ -373,7 +375,28 @@ export async function reviewTicket(ticket, ctx) {
373
375
  }
374
376
  }
375
377
  const factsExcerpt = factsFor(entry, ctx.cfg);
376
- const peerDiffs = blockPeerDiffs(contentRoot, { base: entry.base, owned: entry.owned, ownedKinds: entry.ownedKinds, file: ticket.file });
378
+ // Resolve concrete paths in this block's tree; neither globs nor another block's tests
379
+ // authorize an untracked attachment. Read lazily so a stopped ticket needs no file listing.
380
+ /** @type {Promise<FileContext> | undefined} */
381
+ let contextOnce;
382
+ /** @type {() => Promise<import('../review/moved.mjs').PeerDiff[]>} */
383
+ let readDiffs = () => Promise.resolve([]);
384
+ const fileContext = () => contextOnce ??= (async () => {
385
+ try {
386
+ // Read the listing directly: blockPeers' [] fallback would hide a git failure and
387
+ // still authorize explicit tests. Context and peer diffs must fail soft together.
388
+ const peers = entry.base === null || entry.owned.length === 0 ? [] : (await computeFileSet({ cwd: contentRoot, base: entry.base })).all.filter((f) => f !== ticket.file && ownsFile(entry.owned, f, entry.ownedKinds));
389
+ readDiffs = peerDiffReader({ repoRoot: contentRoot, base: entry.base, file: ticket.file, peers });
390
+ const changedFiles = [...peers, ticket.file].filter((f) => ownsFile(entry.owned, f, entry.ownedKinds));
391
+ return { peers, changedFiles, ownedFiles: [...new Set([...changedFiles, ...entry.testFiles])], testFiles: entry.testFiles };
392
+ } catch {
393
+ return { peers: [], changedFiles: [], ownedFiles: [], testFiles: [] };
394
+ }
395
+ })();
396
+ const peerDiffs = async () => {
397
+ await fileContext();
398
+ return readDiffs();
399
+ };
377
400
  try {
378
401
  // B63a: the `context` rules and lessons, read from the MAIN tree (`ctx.repoRoot`, never the
379
402
  // block's worktree) with the config in force for this ticket; '' without a `context` key, so
@@ -390,11 +413,11 @@ export async function reviewTicket(ticket, ctx) {
390
413
  const digest = rulesDigest === '' ? {} : { rulesDigest };
391
414
  if (!ctx.key) {
392
415
  return await reviewFile(
393
- { repoRoot: contentRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt, ...digest, peerDiffs },
416
+ { repoRoot: contentRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt, ...digest, peerDiffs, ...(await fileContext()) },
394
417
  { spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
395
418
  );
396
419
  }
397
- return await fixLoopRound(ticket, tctx, { base: entry.base, level: entry.level, owned: entry.owned, ownedKinds: entry.ownedKinds, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt, rulesDigest, peerDiffs });
420
+ return await fixLoopRound(ticket, tctx, { base: entry.base, level: entry.level, owned: entry.owned, ownedKinds: entry.ownedKinds, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt, rulesDigest, peerDiffs, fileContext });
398
421
  } finally {
399
422
  rmSync(workDir, { recursive: true, force: true });
400
423
  }
@@ -506,10 +529,10 @@ export function mayTakeRung(state, kind, cfg, rungUsed) {
506
529
  * first full round, or any round once the rung is gone) runs outside the block lock, in parallel
507
530
  * with the block's other files.
508
531
  * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
509
- * @param {{base: string | null, level: string, owned: string[], ownedKinds?: Record<string, string>, blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string, rulesDigest?: string, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
532
+ * @param {{base: string | null, level: string, owned: string[], ownedKinds?: Record<string, string>, blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string, rulesDigest?: string, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>, fileContext: () => Promise<FileContext>}} opts
510
533
  * @returns {Promise<ReviewOutcome>}
511
534
  */
512
- async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, rulesDigest = '', peerDiffs }) {
535
+ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, rulesDigest = '', peerDiffs, fileContext }) {
513
536
  if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
514
537
  const readRows = ctx.readRows;
515
538
  /** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
@@ -572,7 +595,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
572
595
  state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
573
596
  }
574
597
  const fixed = state;
575
- const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest, extraRounds, peerDiffs });
598
+ const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest, extraRounds, peerDiffs, fileContext });
576
599
  // a round that could take the rung keeps the block lock until its state is saved
577
600
  if (mayTakeRung(fixed, kind, ctx.cfg, rungUsed)) return { outcome: await round() };
578
601
  return { round };
@@ -583,10 +606,10 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
583
606
  /**
584
607
  * Run the prepared round and save the file's state: the ticket's outcome.
585
608
  * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
586
- * @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string, rulesDigest?: string, extraRounds?: number, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
609
+ * @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string, rulesDigest?: string, extraRounds?: number, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>, fileContext: () => Promise<FileContext>}} opts
587
610
  * @returns {Promise<ReviewOutcome>}
588
611
  */
589
- async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest = '', extraRounds = 0, peerDiffs }) {
612
+ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest = '', extraRounds = 0, peerDiffs, fileContext }) {
590
613
  /** @type {Record<string, any> | null} */
591
614
  let engineOutcome = null;
592
615
  let approvalWritten = false;
@@ -626,6 +649,7 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
626
649
  const schemaHistory = ctx.readRows
627
650
  ? ledgerSchemaHistory({ readRows: ctx.readRows, verify: (row) => verifyRow(row, where.key).ok, runId: ctx.runId, file: ticket.file, contentHash: ticket.content_hash })
628
651
  : undefined;
652
+ const files = await fileContext();
629
653
  await runRound(
630
654
  state,
631
655
  {
@@ -637,16 +661,20 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
637
661
  factsExcerpt,
638
662
  ...(rulesDigest !== '' ? { rulesDigest } : {}),
639
663
  extraRounds,
664
+ ...files,
640
665
  ...(peerDiffs ? { peerDiffs } : {}),
641
666
  ...(jev ? { jev } : {}),
642
667
  ...(schemaHistory ? { schemaHistory } : {}),
643
668
  review: async () => {
644
- engineOutcome = await reviewFile({ repoRoot: ctx.contentRoot ?? ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt, ...(rulesDigest !== '' ? { rulesDigest } : {}), ...(peerDiffs ? { peerDiffs } : {}) }, { spawn: ctx.spawn, writeRow, ...(schemaHistory ? { schemaHistory } : {}) });
669
+ engineOutcome = await reviewFile({ repoRoot: ctx.contentRoot ?? ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt, ...files, ...(rulesDigest !== '' ? { rulesDigest } : {}), ...(peerDiffs ? { peerDiffs } : {}) }, { spawn: ctx.spawn, writeRow, ...(schemaHistory ? { schemaHistory } : {}) });
645
670
  return engineOutcome;
646
671
  },
647
672
  spawn: async (opts) => {
648
673
  const res = await ctx.spawn(opts);
649
- rechecks.push({ lens: opts?.rowExtra?.lens ?? null, role: opts?.role ?? null, level: opts?.level ?? null, provider: res?.provider ?? null, model: res?.model ?? null });
674
+ rechecks.push({ lens: opts?.rowExtra?.lens ?? null, role: opts?.role ?? null, level: opts?.level ?? null, provider: res?.provider ?? null, model: res?.model ?? null,
675
+ status: res?.status ?? null, tokens_in: res?.usage?.tokens_in ?? res?.row?.tokens_in ?? null, tokens_out: res?.usage?.tokens_out ?? res?.row?.tokens_out ?? null,
676
+ ...(typeof res?.status === 'string' && res.status !== 'ok' ? { reason: res.reason ?? res.status } : {}),
677
+ });
650
678
  return res;
651
679
  },
652
680
  },
@@ -689,7 +717,7 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
689
717
  return { ...head, status: 'reviewed', approved: true, findings: [] };
690
718
  }
691
719
  if (status === 'stopped') return { ...head, status: 'stopped', stopped: next?.reason ?? 'stopped', ...stopSize(state), approved: false, findings: state.open };
692
- return { ...head, status: 'reviewed', approved: false, reason: 'findings-open', findings: state.open };
720
+ return { ...head, status: 'reviewed', approved: false, reason: next?.reason === 'no-fix-hunks' ? 'no-fix-hunks' : 'findings-open', findings: state.open };
693
721
  }
694
722
 
695
723
  /**
@@ -35,6 +35,8 @@
35
35
  */
36
36
 
37
37
  import { spawn as nodeSpawn } from 'node:child_process';
38
+ import { existsSync, statSync } from 'node:fs';
39
+ import os from 'node:os';
38
40
  import path from 'node:path';
39
41
  import { fileURLToPath } from 'node:url';
40
42
  import { expiryChecks } from '../autopilot/limits.mjs';
@@ -43,6 +45,7 @@ import { cacheHoursFrom, createDefaultKeyStore, resolveKey } from '../keys/store
43
45
  import { appendRow, readAllRows } from '../ledger/write.mjs';
44
46
  import { spawnSession } from '../session/spawn.mjs';
45
47
  import { snapshotFor } from '../state/config-snapshot.mjs';
48
+ import { runRecordPath } from '../state/paths.mjs';
46
49
  import { readRun, reattachWorker } from '../state/run.mjs';
47
50
  import { loadKey, signRow } from '../state/signer.mjs';
48
51
  import { logWarning } from '../util/error-log.mjs';
@@ -54,6 +57,7 @@ import { countLines, RouteError, routeTicket } from '../review/route.mjs';
54
57
  import { canonicalPath, contentRootFor, reviewTicket } from './engine.mjs';
55
58
  import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
56
59
  import { DELETED_HASH, hashBytes, readRegularFileNoFollow, repoRootOf, WorkerError } from './ticket.mjs';
60
+ import { watchTick } from './watch.mjs';
57
61
 
58
62
  /** Name of the key the worker resolves for System 1 (`keys.jev` / `system1.key` reference). */
59
63
  export const JEV_KEY_NAME = 'jev';
@@ -220,6 +224,7 @@ export function recordedVerdict(result) {
220
224
  * @property {string} slug - ledger project slug (the run record's `project`).
221
225
  * @property {Buffer} key - the run's HMAC key.
222
226
  * @property {number} [pollMs]
227
+ * @property {string} [guardRunRecordPath] - booted workers stop when this authoritative record disappears.
223
228
  * @property {number} [heartbeatMs] - how often `heartbeat_at` is refreshed: an integer ≥ `MIN_HEARTBEAT_MS`,
224
229
  * else `HEARTBEAT_MS`.
225
230
  */
@@ -230,6 +235,8 @@ export function recordedVerdict(result) {
230
235
  * @property {typeof spawnSession} [spawn]
231
236
  * @property {typeof readRun} [readRun] - the run-record read behind the per-ticket config (a test seam).
232
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.
233
240
  * @property {import('../keys/store.mjs').KeyStore} [store] - default: B2's production store.
234
241
  * @property {any} [opRead]
235
242
  * @property {NodeJS.ProcessEnv} [env]
@@ -277,6 +284,9 @@ export async function createWorker(opts, deps = {}) {
277
284
 
278
285
  /** @param {Record<string, any>} row */
279
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 });
280
290
 
281
291
  /**
282
292
  * The config ticket `id` is reviewed with (B35): its snapshot from the run record, re-read per
@@ -598,7 +608,28 @@ export async function createWorker(opts, deps = {}) {
598
608
  /** @type {Set<string>} */
599
609
  const started = new Set();
600
610
  for (;;) {
611
+ // One cheap stat per scheduler tick, including while tickets are in flight. Removing a
612
+ // test HOME (or the run record) stops new tickets; an open real run remains untouched.
613
+ if (!stopping && opts.guardRunRecordPath) {
614
+ try {
615
+ statSync(opts.guardRunRecordPath);
616
+ } catch (err) {
617
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR') {
618
+ stop();
619
+ // The error log lives under HOME: never write it when HOME itself is gone, or the
620
+ // log write would create the removed HOME again.
621
+ if (existsSync(os.homedir())) await logWarning({ warning: 'worker_orphan_stop', message: `run record missing: ${opts.guardRunRecordPath}` }).catch(() => null);
622
+ }
623
+ }
624
+ }
601
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
+ }
602
633
  for (const id of pendingTickets(repoRoot)) {
603
634
  if (poisoned.has(id) || started.has(id)) continue;
604
635
  if (limit === null) limit = await poolSize();
@@ -606,6 +637,7 @@ export async function createWorker(opts, deps = {}) {
606
637
  const fileKey = fileKeyOf(id);
607
638
  if (fileKey !== null && busy.has(fileKey)) continue; // waits without a slot
608
639
  started.add(id);
640
+ activeTickets.add(id);
609
641
  if (fileKey !== null) busy.add(fileKey);
610
642
  const slot = runOne(id)
611
643
  .then((n) => {
@@ -614,6 +646,7 @@ export async function createWorker(opts, deps = {}) {
614
646
  .finally(() => {
615
647
  // the slot is freed on every exit path (`runOne` never rejects)
616
648
  running.delete(id);
649
+ activeTickets.delete(id);
617
650
  fileKeys.delete(id);
618
651
  if (fileKey !== null) busy.delete(fileKey);
619
652
  });
@@ -719,7 +752,7 @@ export async function createWorker(opts, deps = {}) {
719
752
  retractWorker(repoRoot, process.pid);
720
753
  }
721
754
 
722
- 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 };
723
756
  }
724
757
 
725
758
  /**
@@ -750,7 +783,7 @@ export async function bootWorker(opts, deps = {}) {
750
783
  const runRootDir = runRoot(opts.runId, { root: tmpRoot });
751
784
  setRunRoot(runRootDir);
752
785
  const key = await loadKey(opts.runId);
753
- const worker = await createWorker({ runId: opts.runId, repoRoot, cfg, runRootDir, slug: record.project, key, pollMs: opts.pollMs }, deps);
786
+ const worker = await createWorker({ runId: opts.runId, repoRoot, cfg, runRootDir, slug: record.project, key, pollMs: opts.pollMs, guardRunRecordPath: runRecordPath(opts.runId) }, deps);
754
787
  return { ...worker, repoRoot, runRootDir };
755
788
  }
756
789
 
@@ -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
+ }