@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
@@ -5,6 +5,7 @@
5
5
  * <repo>/.code-forge/queue/<ticket>.json the ticket (written by `review-file`, idempotent)
6
6
  * <repo>/.code-forge/queue/<ticket>.done the worker's done marker `{ticket, status, result}`
7
7
  * <repo>/.code-forge/queue/worker.json the worker's announcement `{pid, start_time, run, started_at, heartbeat_at}`
8
+ * <repo>/.code-forge/workers/<pid>-<start_time>.json each worker's private registry entry
8
9
  * <repo>/.code-forge/reviews/<run>/<ticket>.json the SIGNED result (run key HMAC, §8.6)
9
10
  *
10
11
  * The forbidden list (§8.4 ★) denies a coder any write under `reviews/` and to `queue/*.done`;
@@ -28,7 +29,7 @@
28
29
  */
29
30
 
30
31
  import { createHash, randomBytes } from 'node:crypto';
31
- import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
32
+ import { chmodSync, existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
32
33
  import path from 'node:path';
33
34
  import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
34
35
  import { isAlive, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
@@ -44,15 +45,19 @@ export const HEARTBEAT_STALE_MS = 60_000;
44
45
 
45
46
  /** @param {string} repoRoot */
46
47
  export const queueDir = (repoRoot) => path.join(repoRoot, '.code-forge', 'queue');
48
+ /** @param {string} repoRoot */
49
+ const workersDir = (repoRoot) => path.join(repoRoot, '.code-forge', 'workers');
50
+ /** @param {string} repoRoot @param {{pid: number, start_time: string}} self */
51
+ const workerPath = (repoRoot, self) => path.join(workersDir(repoRoot), `${self.pid}-${self.start_time}.json`);
47
52
  /** @param {string} repoRoot @param {string} runId */
48
53
  export const reviewsDir = (repoRoot, runId) => path.join(repoRoot, '.code-forge', 'reviews', runId);
49
54
 
50
- /** @param {string} file @param {string} text */
51
- function writeAtomic(file, text) {
55
+ /** @param {string} file @param {string} text @param {number} [mode] */
56
+ function writeAtomic(file, text, mode = 0o644) {
52
57
  mkdirSync(path.dirname(file), { recursive: true });
53
58
  const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
54
59
  try {
55
- writeFileSync(tmp, text, { mode: 0o644 });
60
+ writeFileSync(tmp, text, { mode });
56
61
  renameSync(tmp, file);
57
62
  } catch (err) {
58
63
  rmSync(tmp, { force: true });
@@ -102,17 +107,56 @@ export function resolveTicket({ repoRoot, run, block, file, tree = null }) {
102
107
  * @throws {WorkerError} `bad-path` for a relative `tree` or one that cannot be resolved.
103
108
  */
104
109
  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 });
110
+ const { content_hash } = resolveTicket({ repoRoot, run, block, file, tree });
111
+ return enqueueHash({ repoRoot, run, block, file, tree, content_hash, now });
112
+ }
113
+
114
+ /** Queue proven git-object content through the same identity as a manual review.
115
+ * @param {{repoRoot: string, run: string, block: string, file: string, content_hash: string, tree?: string | null, now?: Date}} opts
116
+ * @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, tree?: string}}
117
+ */
118
+ export function enqueueHash({ repoRoot, run, block, file, content_hash, tree = null, now = new Date() }) {
119
+ const rel = assertRowPath(file);
120
+ const root = tree === null ? null : realTree(tree);
121
+ const ticket = ticketId({ run, block, file: rel, content_hash, tree: root });
106
122
  const target = path.join(queueDir(repoRoot), `${ticket}.json`);
107
123
  const named = root === null ? {} : { tree: root };
108
124
  if (!existsSync(target)) {
109
125
  /** @type {Ticket} */
110
126
  const body = { ticket, run, block, file: rel, content_hash, enqueued_at: now.toISOString(), ...named };
111
- writeAtomic(target, `${JSON.stringify(body)}\n`);
127
+ // Publish a complete immutable ticket without replacing a concurrent coder's ticket.
128
+ mkdirSync(path.dirname(target), { recursive: true });
129
+ const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
130
+ try {
131
+ writeFileSync(tmp, `${JSON.stringify(body)}\n`, { mode: 0o644 });
132
+ try { linkSync(tmp, target); } catch (err) { if (err.code !== 'EEXIST') throw err; }
133
+ } finally { rmSync(tmp, { force: true }); }
112
134
  }
135
+ // Returning content is current again. Claim only a superseded marker's exact bytes;
136
+ // a real result that replaced it wins the comparison and is restored unchanged.
137
+ let seen;
138
+ let done;
139
+ try {
140
+ seen = readFileSync(path.join(queueDir(repoRoot), `${ticket}.done`));
141
+ done = JSON.parse(seen.toString('utf8'));
142
+ } catch { /* missing or unreadable markers remain untouched */ }
143
+ if (seen && done?.status === 'refused' && done.reason === 'superseded' && !done.result) dropDoneIf(repoRoot, ticket, seen);
113
144
  return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash, ...named };
114
145
  }
115
146
 
147
+ /** Finish only an unstarted, pending review. A superseded marker has no review result.
148
+ * @param {string} repoRoot @param {string} ticket
149
+ * @param {{write?: (file: string, text: string) => void, link?: (from: string, to: string) => void}} [io] - publication seams for tests.
150
+ */
151
+ export function supersedeTicket(repoRoot, ticket, io = {}) {
152
+ const marker = path.join(queueDir(repoRoot), `${assertTicketId(ticket)}.done`);
153
+ const tmp = `${marker}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
154
+ try {
155
+ (io.write ?? ((file, text) => writeFileSync(file, text, { mode: 0o644 })))(tmp, `${JSON.stringify({ ticket, status: 'refused', reason: 'superseded' })}\n`);
156
+ try { (io.link ?? linkSync)(tmp, marker); } catch (err) { if (err.code !== 'EEXIST') throw err; }
157
+ } finally { rmSync(tmp, { force: true }); }
158
+ }
159
+
116
160
  /** B71: the most finding ids one answers ticket may name. */
117
161
  export const MAX_ANSWERS = 10;
118
162
 
@@ -369,6 +413,55 @@ export function pendingTickets(repoRoot) {
369
413
  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
414
  }
371
415
 
416
+ /**
417
+ * Valid review tickets for one block and content tree, oldest first. Answers tickets cannot
418
+ * stand in for a file's own review. Unreadable tickets are refused, never silently omitted.
419
+ * @param {{repoRoot: string, run: string, block: string, tree?: string | null}} opts
420
+ * @returns {Ticket[]}
421
+ */
422
+ export function blockTickets({ repoRoot, run, block, tree = null }) {
423
+ let names;
424
+ try {
425
+ names = readdirSync(queueDir(repoRoot));
426
+ } catch (err) {
427
+ if (err.code === 'ENOENT') return [];
428
+ throw new WorkerError('queue-unreadable', 'the review queue cannot be read');
429
+ }
430
+ const tickets = names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n)).map((n) => readTicket(repoRoot, n.slice(0, 24)));
431
+ return tickets.filter((t) => !t.kind && t.run === run && t.block === block && (t.tree ?? null) === tree)
432
+ .sort((a, b) => a.enqueued_at.localeCompare(b.enqueued_at) || a.ticket.localeCompare(b.ticket));
433
+ }
434
+
435
+ /**
436
+ * Atomically record the join's findings handoff once. Read only this marker, never a result.
437
+ * @param {{repoRoot: string, block: string, file: string, ticket: string}} opts
438
+ * @returns {boolean} true only for the join that first delivered the findings.
439
+ */
440
+ export function deliverBlockFindings({ repoRoot, block, file, ticket }) {
441
+ const marker = path.join(queueDir(repoRoot), `${assertTicketId(ticket)}.delivered`);
442
+ const expected = { block, file, ticket };
443
+ const delivered = () => {
444
+ try {
445
+ const seen = JSON.parse(readFileSync(marker, 'utf8'));
446
+ if (seen?.block === block && seen.file === file && seen.ticket === ticket) return true;
447
+ } catch (err) {
448
+ if (err.code === 'ENOENT') return false;
449
+ }
450
+ throw new WorkerError('result-unreadable', 'a findings delivery marker cannot be read');
451
+ };
452
+ if (delivered()) return false;
453
+ const tmp = `${marker}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
454
+ try {
455
+ writeFileSync(tmp, `${JSON.stringify(expected)}\n`, { flag: 'wx' });
456
+ try { linkSync(tmp, marker); } catch (err) {
457
+ if (err.code !== 'EEXIST') throw err;
458
+ if (delivered()) return false;
459
+ throw new WorkerError('result-unreadable', 'a findings delivery marker disappeared');
460
+ }
461
+ return true;
462
+ } finally { rmSync(tmp, { force: true }); }
463
+ }
464
+
372
465
  /**
373
466
  * Read and re-validate a ticket (coder-writable): its id must match its content.
374
467
  * @param {string} repoRoot @param {string} id
@@ -410,7 +503,8 @@ export function writeResult({ repoRoot, runId, ticket, result, key }) {
410
503
  const signed = signRow({ ...result, run: runId, ticket: assertTicketId(ticket) }, key);
411
504
  const file = path.join(reviewsDir(repoRoot, runId), `${ticket}.json`);
412
505
  writeAtomic(file, `${JSON.stringify(signed)}\n`);
413
- writeAtomic(path.join(queueDir(repoRoot), `${ticket}.done`), `${JSON.stringify({ ticket, status: signed.status, result: path.relative(repoRoot, file) })}\n`);
506
+ const openFindings = signed.status === 'reviewed' && signed.approved !== true && Array.isArray(signed.findings) && signed.findings.length > 0;
507
+ writeAtomic(path.join(queueDir(repoRoot), `${ticket}.done`), `${JSON.stringify({ ticket, status: signed.status, ...(openFindings ? { open_findings: true } : {}), result: path.relative(repoRoot, file) })}\n`);
414
508
  return signed;
415
509
  }
416
510
 
@@ -439,20 +533,25 @@ export async function verifyResult(repoRoot, runId, ticket) {
439
533
  }
440
534
 
441
535
  /**
442
- * Announce the serving worker in the queue directory (what `review-file` checks for liveness).
536
+ * Announce the serving worker in the private registry and legacy queue file.
443
537
  * @param {string} repoRoot @param {{pid: number, run: string}} who
444
538
  */
445
539
  export function announceWorker(repoRoot, { pid, run }) {
446
540
  const now = new Date().toISOString();
447
541
  const body = { pid, start_time: readStartTime(pid) ?? UNKNOWN_START_TIME, run, started_at: now, heartbeat_at: now };
448
- writeAtomic(path.join(queueDir(repoRoot), WORKER_FILE), `${JSON.stringify(body)}\n`);
542
+ mkdirSync(workersDir(repoRoot), { recursive: true, mode: 0o700 });
543
+ chmodSync(workersDir(repoRoot), 0o700);
544
+ writeAtomic(workerPath(repoRoot, body), `${JSON.stringify(body)}\n`, 0o600);
545
+ const legacy = readWorker(repoRoot);
546
+ if (!legacy || !workerLive(legacy)) writeAtomic(path.join(queueDir(repoRoot), WORKER_FILE), `${JSON.stringify(body)}\n`);
449
547
  return body;
450
548
  }
451
549
 
452
550
  /**
453
- * Refresh the announcement's `heartbeat_at` (B30) while it is still THIS worker's: the file is
551
+ * Refresh both announcements' `heartbeat_at` (B30) while each is still THIS worker's: each file is
454
552
  * re-read right before the write and must name the same `pid` AND `start_time` the worker
455
- * announced. Another worker's announcement is never overwritten (the caller stops beating).
553
+ * announced. Another worker's announcement is never overwritten. A worker keeps beating its
554
+ * registry entry even when another worker owns worker.json.
456
555
  * The beat is written to a temp file, the announcement re-read and compared once more, and only
457
556
  * then renamed over it. The worker calls this from a timer of its own, so it keeps beating while
458
557
  * a long review session is awaited.
@@ -462,23 +561,23 @@ export function announceWorker(repoRoot, { pid, run }) {
462
561
  */
463
562
  export function beatWorker(repoRoot, self, now = new Date()) {
464
563
  const mine = (/** @type {Record<string, any> | null} */ w) => w?.pid === self.pid && w.start_time === self.start_time;
465
- const w = readWorker(repoRoot);
466
- if (!mine(w)) return false;
467
- const file = path.join(queueDir(repoRoot), WORKER_FILE);
468
- const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
469
- try {
470
- writeFileSync(tmp, `${JSON.stringify({ ...w, heartbeat_at: now.toISOString() })}\n`, { mode: 0o644 });
471
- // re-read right before the rename: an announcement that changed meanwhile is never replaced
472
- if (!mine(readWorker(repoRoot))) {
564
+ let written = false;
565
+ const registryFile = workerPath(repoRoot, self);
566
+ for (const file of [registryFile, path.join(queueDir(repoRoot), WORKER_FILE)]) {
567
+ const w = readJSON(file);
568
+ if (!mine(w)) continue;
569
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
570
+ try {
571
+ writeFileSync(tmp, `${JSON.stringify({ ...w, heartbeat_at: now.toISOString() })}\n`, { mode: file === registryFile ? 0o600 : 0o644 });
572
+ // Re-read before rename; another owner's announcement is never replaced.
573
+ if (!mine(readJSON(file))) continue;
574
+ renameSync(tmp, file);
575
+ written = true;
576
+ } finally {
473
577
  rmSync(tmp, { force: true });
474
- return false;
475
578
  }
476
- renameSync(tmp, file);
477
- return true;
478
- } catch (err) {
479
- rmSync(tmp, { force: true });
480
- throw err;
481
579
  }
580
+ return written;
482
581
  }
483
582
 
484
583
  /** A heartbeat further than this in the future is not fresh (clock skew tolerance). */
@@ -502,9 +601,59 @@ export function readWorker(repoRoot) {
502
601
  return w && Number.isInteger(w.pid) && w.pid > 1 && typeof w.run === 'string' ? w : null;
503
602
  }
504
603
 
505
- /** Remove the announcement if it is still `pid`'s. @param {string} repoRoot @param {number} pid */
506
- export function retractWorker(repoRoot, pid) {
507
- if (readWorker(repoRoot)?.pid === pid) rmSync(path.join(queueDir(repoRoot), WORKER_FILE), { force: true });
604
+ /** Remove only this owner's announcements. @param {string} repoRoot @param {number} pid @param {string} [startTime] */
605
+ export function retractWorker(repoRoot, pid, startTime) {
606
+ const mine = (w) => w?.pid === pid && (startTime === undefined || w.start_time === startTime);
607
+ for (const entry of workerRegistry(repoRoot)) {
608
+ if (mine(entry.worker)) rmSync(entry.file, { force: true });
609
+ }
610
+ if (mine(readWorker(repoRoot))) rmSync(path.join(queueDir(repoRoot), WORKER_FILE), { force: true });
611
+ }
612
+
613
+ /** @typedef {{pid: number, start_time: string, run: string, started_at: string, heartbeat_at: string}} WorkerAnnouncement */
614
+
615
+ /** Bad entries remain occupied until a human fixes them. @param {any} w @returns {w is WorkerAnnouncement} */
616
+ function workerShape(w) {
617
+ return w && Number.isInteger(w.pid) && w.pid > 1 && typeof w.start_time === 'string' && w.start_time.length > 0
618
+ && !/[\\/]/.test(w.start_time) && typeof w.run === 'string' && w.run.length > 0
619
+ && typeof w.started_at === 'string' && Number.isFinite(Date.parse(w.started_at))
620
+ && typeof w.heartbeat_at === 'string' && Number.isFinite(Date.parse(w.heartbeat_at));
621
+ }
622
+
623
+ /** @param {{pid: number, start_time: string, heartbeat_at?: string}} w @param {number} [nowMs] */
624
+ function workerLive(w, nowMs) {
625
+ if (!isAlive(w.pid)) return false;
626
+ if (heartbeatFresh(w, nowMs)) return true;
627
+ return w.start_time === UNKNOWN_START_TIME || readStartTime(w.pid) === w.start_time;
628
+ }
629
+
630
+ /** @param {{pid: number, start_time: string, heartbeat_at?: string}} w @param {number} [nowMs] */
631
+ function workerLiveAtBoot(w, nowMs) {
632
+ if (!isAlive(w.pid)) return false;
633
+ const startTime = readStartTime(w.pid);
634
+ // Boot trusts orchestrator ps; sandbox readers let fresh heartbeats mask unreliable ps.
635
+ if (startTime !== null && w.start_time !== UNKNOWN_START_TIME) return startTime === w.start_time;
636
+ return heartbeatFresh(w, nowMs) || w.start_time === UNKNOWN_START_TIME;
637
+ }
638
+
639
+ /** Read all entries, keeping unreadable/bad entries occupied. Only proven stale owners may be pruned.
640
+ * @param {string} repoRoot @param {{prune?: boolean, nowMs?: number}} [opts]
641
+ * @returns {Array<{file: string, worker: WorkerAnnouncement | null, live: boolean}>}
642
+ */
643
+ export function workerRegistry(repoRoot, opts = {}) {
644
+ let names;
645
+ try { names = readdirSync(workersDir(repoRoot)); }
646
+ catch (err) { if (err.code === 'ENOENT') return []; throw err; }
647
+ const entries = [];
648
+ for (const name of names.sort().filter((n) => n.endsWith('.json'))) {
649
+ const file = path.join(workersDir(repoRoot), name);
650
+ const w = readJSON(file);
651
+ const worker = workerShape(w) && name === `${w.pid}-${w.start_time}.json` ? w : null;
652
+ const live = worker === null || (opts.prune ? workerLiveAtBoot(worker, opts.nowMs) : workerLive(worker, opts.nowMs));
653
+ if (!live && opts.prune) rmSync(file, { force: true });
654
+ else entries.push({ file, worker, live });
655
+ }
656
+ return entries;
508
657
  }
509
658
 
510
659
  /**
@@ -516,13 +665,14 @@ export function retractWorker(repoRoot, pid) {
516
665
  * Accepted trade-off: a worker that is SIGKILLed (no retraction) leaves a heartbeat that stays
517
666
  * fresh for up to `HEARTBEAT_STALE_MS` (60 s); if the OS reuses its pid inside that window, the
518
667
  * queue reads as served until the heartbeat goes stale. A clean stop retracts the file at once.
519
- * @param {string} repoRoot @param {{nowMs?: number}} [opts]
668
+ * Registry entries take precedence; worker.json supports workers from older versions.
669
+ * @param {string} repoRoot @param {{nowMs?: number, pid?: number, registeredOnly?: boolean}} [opts]
520
670
  */
521
671
  export function liveWorker(repoRoot, opts = {}) {
672
+ const entry = workerRegistry(repoRoot, opts).find((e) => e.live && e.worker && (opts.pid === undefined || e.worker.pid === opts.pid));
673
+ if (entry) return entry.worker;
674
+ if (opts.registeredOnly) return null;
522
675
  const w = readWorker(repoRoot);
523
- if (!w || !isAlive(w.pid)) return null;
524
- if (heartbeatFresh(w, opts.nowMs)) return w;
525
- if (w.start_time !== UNKNOWN_START_TIME && readStartTime(w.pid) !== w.start_time) return null;
526
- return w;
676
+ return w && (opts.pid === undefined || w.pid === opts.pid) && workerLive(w, opts.nowMs) ? w : null;
527
677
  }
528
678
 
@@ -0,0 +1,115 @@
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 held = earlier.some((old) => old.file === file);
103
+ if (!held) enqueueHash({ repoRoot, run: runId, block, file, content_hash, tree });
104
+ for (const old of earlier) {
105
+ if (old.file === file && old.content_hash !== content_hash && !running.has(old.ticket) && !isDone(repoRoot, old.ticket)) supersedeTicket(repoRoot, old.ticket);
106
+ }
107
+ if (held) await writeRow({ event: 'watch.held', block, file, content_hash });
108
+ }
109
+ save({ ...state, run: runId, base, tree, sha: head, skipped: [] });
110
+ } catch {
111
+ // A bad path, missing tree or failed git read is a skip, never a block failure.
112
+ await skip('unreadable_head');
113
+ }
114
+ }
115
+ }