@codedology/code-forge 0.12.0 → 0.14.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 (40) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +28 -2
  3. package/docs/reference/blocks.json +354 -1
  4. package/docs/reference/config.md +4 -1
  5. package/package.json +1 -1
  6. package/schema/code-forge.schema.json +10 -1
  7. package/skill/references/adapters/claude-code.md +1 -1
  8. package/skill/references/adapters/solo.md +1 -1
  9. package/skill/references/code.md +15 -8
  10. package/skill/references/ledger.md +4 -2
  11. package/skill/references/proof.md +1 -1
  12. package/skill/references/review.md +21 -7
  13. package/skill/references/security.md +5 -3
  14. package/skill/scripts/forge +1 -1
  15. package/src/autopilot/actions.mjs +4 -5
  16. package/src/cli/block.mjs +130 -31
  17. package/src/cli/context.mjs +3 -2
  18. package/src/cli/report.mjs +2 -2
  19. package/src/cli/review-file.mjs +67 -42
  20. package/src/cli/review.mjs +2 -2
  21. package/src/context/pack.mjs +13 -9
  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/ledger/export.mjs +1 -1
  26. package/src/ledger/report.mjs +9 -3
  27. package/src/ledger/spend.mjs +3 -3
  28. package/src/proof/lock.mjs +5 -1
  29. package/src/review/accept-hit.mjs +78 -0
  30. package/src/review/agent-waiver.mjs +201 -0
  31. package/src/review/gate-check.mjs +62 -28
  32. package/src/review/reopen.mjs +35 -14
  33. package/src/review/schema-fallback.mjs +3 -3
  34. package/src/state/config-snapshot.mjs +1 -0
  35. package/src/util/file-locks.mjs +118 -0
  36. package/src/util/forbidden.mjs +51 -2
  37. package/src/worker/engine.mjs +2 -2
  38. package/src/worker/loop.mjs +83 -33
  39. package/src/worker/queue.mjs +141 -33
  40. package/src/worker/watch.mjs +4 -2
@@ -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 });
@@ -295,11 +300,13 @@ export function clearDone({ repoRoot, run, block, file, tree = null }) {
295
300
  * A crash between the drop and the caller's enqueue leaves a re-runnable ticket with no marker:
296
301
  * harmless — its ticket file is pending, and the next `review-file` queues it anyway. Synchronous.
297
302
  * @param {string} repoRoot @param {string} ticket @param {Buffer} seen
298
- * @param {{link?: (from: string, to: string) => void, read?: (file: string) => Buffer, rename?: (from: string, to: string) => void, remove?: (file: string) => void, write?: (file: string, text: string) => void}} [io] - for
303
+ * `once` records the join's one retry before releasing the claim. An existing retry marker
304
+ * keeps the result, including when another join has already finished the rerun.
305
+ * @param {{once?: boolean, link?: (from: string, to: string) => void, read?: (file: string) => Buffer, rename?: (from: string, to: string) => void, remove?: (file: string) => void, write?: (file: string, text: string) => void}} [io] - for
299
306
  * tests (default `linkSync`, `readFileSync`, `renameSync`, `rmSync` with force, `writeFileSync`
300
307
  * with flag `wx` and mode 0o600). A claim that cannot be read is restored the same way and
301
308
  * refused `fresh-restore-failed`.
302
- * @returns {'dropped' | 'gone' | 'changed'}
309
+ * @returns {'dropped' | 'gone' | 'changed' | 'kept'}
303
310
  * @throws {WorkerError} `fresh-claim-failed` (the claim could not be taken, or not removed after
304
311
  * the drop; nothing was queued) or `fresh-restore-failed` (the message says which state was left:
305
312
  * put back, a newer marker, a refused marker, the claim kept, or nothing). A message names an
@@ -307,6 +314,8 @@ export function clearDone({ repoRoot, run, block, file, tree = null }) {
307
314
  */
308
315
  export function dropDoneIf(repoRoot, ticket, seen, io = {}) {
309
316
  const marker = path.join(queueDir(repoRoot), `${assertTicketId(ticket)}.done`);
317
+ const retried = path.join(queueDir(repoRoot), `${ticket}.retried`);
318
+ if (io.once && existsSync(retried)) return 'kept';
310
319
  const claim = `${marker}.${process.pid}.${randomBytes(4).toString('hex')}.fresh`;
311
320
  const remove = io.remove ?? ((/** @type {string} */ file) => rmSync(file, { force: true }));
312
321
  try {
@@ -324,6 +333,16 @@ export function dropDoneIf(repoRoot, ticket, seen, io = {}) {
324
333
  } catch (err) {
325
334
  readFailed = codeOf(err);
326
335
  }
336
+ let retryFailed = null;
337
+ if (same && io.once) {
338
+ try {
339
+ writeFileSync(retried, `${ticket}\n`, { flag: 'wx', mode: 0o600 });
340
+ } catch (err) {
341
+ // Restore the claim before keeping or refusing it. Only one join can spend the retry.
342
+ retryFailed = codeOf(err);
343
+ same = false;
344
+ }
345
+ }
327
346
  if (same) {
328
347
  try {
329
348
  remove(claim);
@@ -350,6 +369,8 @@ export function dropDoneIf(repoRoot, ticket, seen, io = {}) {
350
369
  // the marker is back (or a newer one won), so the ticket is done; `clearDone` removes the claim
351
370
  }
352
371
  if (readFailed !== null) throw new WorkerError('fresh-restore-failed', `${unread}; ${linkFailed === 'EEXIST' ? 'a newer marker is in place' : 'it was put back'}; nothing was queued`);
372
+ if (retryFailed === 'EEXIST') return 'kept';
373
+ if (retryFailed !== null) throw new WorkerError('fresh-claim-failed', `the retry marker of ticket ${ticket} could not be written (${retryFailed}); the done marker was restored; nothing was queued`);
353
374
  return 'changed';
354
375
  }
355
376
 
@@ -427,6 +448,36 @@ export function blockTickets({ repoRoot, run, block, tree = null }) {
427
448
  .sort((a, b) => a.enqueued_at.localeCompare(b.enqueued_at) || a.ticket.localeCompare(b.ticket));
428
449
  }
429
450
 
451
+ /**
452
+ * Atomically record the join's findings handoff once. Read only this marker, never a result.
453
+ * @param {{repoRoot: string, block: string, file: string, ticket: string}} opts
454
+ * @returns {boolean} true only for the join that first delivered the findings.
455
+ */
456
+ export function deliverBlockFindings({ repoRoot, block, file, ticket }) {
457
+ const marker = path.join(queueDir(repoRoot), `${assertTicketId(ticket)}.delivered`);
458
+ const expected = { block, file, ticket };
459
+ const delivered = () => {
460
+ try {
461
+ const seen = JSON.parse(readFileSync(marker, 'utf8'));
462
+ if (seen?.block === block && seen.file === file && seen.ticket === ticket) return true;
463
+ } catch (err) {
464
+ if (err.code === 'ENOENT') return false;
465
+ }
466
+ throw new WorkerError('result-unreadable', 'a findings delivery marker cannot be read');
467
+ };
468
+ if (delivered()) return false;
469
+ const tmp = `${marker}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
470
+ try {
471
+ writeFileSync(tmp, `${JSON.stringify(expected)}\n`, { flag: 'wx' });
472
+ try { linkSync(tmp, marker); } catch (err) {
473
+ if (err.code !== 'EEXIST') throw err;
474
+ if (delivered()) return false;
475
+ throw new WorkerError('result-unreadable', 'a findings delivery marker disappeared');
476
+ }
477
+ return true;
478
+ } finally { rmSync(tmp, { force: true }); }
479
+ }
480
+
430
481
  /**
431
482
  * Read and re-validate a ticket (coder-writable): its id must match its content.
432
483
  * @param {string} repoRoot @param {string} id
@@ -468,7 +519,8 @@ export function writeResult({ repoRoot, runId, ticket, result, key }) {
468
519
  const signed = signRow({ ...result, run: runId, ticket: assertTicketId(ticket) }, key);
469
520
  const file = path.join(reviewsDir(repoRoot, runId), `${ticket}.json`);
470
521
  writeAtomic(file, `${JSON.stringify(signed)}\n`);
471
- writeAtomic(path.join(queueDir(repoRoot), `${ticket}.done`), `${JSON.stringify({ ticket, status: signed.status, result: path.relative(repoRoot, file) })}\n`);
522
+ const openFindings = signed.status === 'reviewed' && signed.approved !== true && Array.isArray(signed.findings) && signed.findings.length > 0;
523
+ writeAtomic(path.join(queueDir(repoRoot), `${ticket}.done`), `${JSON.stringify({ ticket, status: signed.status, ...(openFindings ? { open_findings: true } : {}), result: path.relative(repoRoot, file) })}\n`);
472
524
  return signed;
473
525
  }
474
526
 
@@ -497,20 +549,25 @@ export async function verifyResult(repoRoot, runId, ticket) {
497
549
  }
498
550
 
499
551
  /**
500
- * Announce the serving worker in the queue directory (what `review-file` checks for liveness).
552
+ * Announce the serving worker in the private registry and legacy queue file.
501
553
  * @param {string} repoRoot @param {{pid: number, run: string}} who
502
554
  */
503
555
  export function announceWorker(repoRoot, { pid, run }) {
504
556
  const now = new Date().toISOString();
505
557
  const body = { pid, start_time: readStartTime(pid) ?? UNKNOWN_START_TIME, run, started_at: now, heartbeat_at: now };
506
- writeAtomic(path.join(queueDir(repoRoot), WORKER_FILE), `${JSON.stringify(body)}\n`);
558
+ mkdirSync(workersDir(repoRoot), { recursive: true, mode: 0o700 });
559
+ chmodSync(workersDir(repoRoot), 0o700);
560
+ writeAtomic(workerPath(repoRoot, body), `${JSON.stringify(body)}\n`, 0o600);
561
+ const legacy = readWorker(repoRoot);
562
+ if (!legacy || !workerLive(legacy)) writeAtomic(path.join(queueDir(repoRoot), WORKER_FILE), `${JSON.stringify(body)}\n`);
507
563
  return body;
508
564
  }
509
565
 
510
566
  /**
511
- * Refresh the announcement's `heartbeat_at` (B30) while it is still THIS worker's: the file is
567
+ * Refresh both announcements' `heartbeat_at` (B30) while each is still THIS worker's: each file is
512
568
  * re-read right before the write and must name the same `pid` AND `start_time` the worker
513
- * announced. Another worker's announcement is never overwritten (the caller stops beating).
569
+ * announced. Another worker's announcement is never overwritten. A worker keeps beating its
570
+ * registry entry even when another worker owns worker.json.
514
571
  * The beat is written to a temp file, the announcement re-read and compared once more, and only
515
572
  * then renamed over it. The worker calls this from a timer of its own, so it keeps beating while
516
573
  * a long review session is awaited.
@@ -520,23 +577,23 @@ export function announceWorker(repoRoot, { pid, run }) {
520
577
  */
521
578
  export function beatWorker(repoRoot, self, now = new Date()) {
522
579
  const mine = (/** @type {Record<string, any> | null} */ w) => w?.pid === self.pid && w.start_time === self.start_time;
523
- const w = readWorker(repoRoot);
524
- if (!mine(w)) return false;
525
- const file = path.join(queueDir(repoRoot), WORKER_FILE);
526
- const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
527
- try {
528
- writeFileSync(tmp, `${JSON.stringify({ ...w, heartbeat_at: now.toISOString() })}\n`, { mode: 0o644 });
529
- // re-read right before the rename: an announcement that changed meanwhile is never replaced
530
- if (!mine(readWorker(repoRoot))) {
580
+ let written = false;
581
+ const registryFile = workerPath(repoRoot, self);
582
+ for (const file of [registryFile, path.join(queueDir(repoRoot), WORKER_FILE)]) {
583
+ const w = readJSON(file);
584
+ if (!mine(w)) continue;
585
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
586
+ try {
587
+ writeFileSync(tmp, `${JSON.stringify({ ...w, heartbeat_at: now.toISOString() })}\n`, { mode: file === registryFile ? 0o600 : 0o644 });
588
+ // Re-read before rename; another owner's announcement is never replaced.
589
+ if (!mine(readJSON(file))) continue;
590
+ renameSync(tmp, file);
591
+ written = true;
592
+ } finally {
531
593
  rmSync(tmp, { force: true });
532
- return false;
533
594
  }
534
- renameSync(tmp, file);
535
- return true;
536
- } catch (err) {
537
- rmSync(tmp, { force: true });
538
- throw err;
539
595
  }
596
+ return written;
540
597
  }
541
598
 
542
599
  /** A heartbeat further than this in the future is not fresh (clock skew tolerance). */
@@ -560,9 +617,59 @@ export function readWorker(repoRoot) {
560
617
  return w && Number.isInteger(w.pid) && w.pid > 1 && typeof w.run === 'string' ? w : null;
561
618
  }
562
619
 
563
- /** Remove the announcement if it is still `pid`'s. @param {string} repoRoot @param {number} pid */
564
- export function retractWorker(repoRoot, pid) {
565
- if (readWorker(repoRoot)?.pid === pid) rmSync(path.join(queueDir(repoRoot), WORKER_FILE), { force: true });
620
+ /** Remove only this owner's announcements. @param {string} repoRoot @param {number} pid @param {string} [startTime] */
621
+ export function retractWorker(repoRoot, pid, startTime) {
622
+ const mine = (w) => w?.pid === pid && (startTime === undefined || w.start_time === startTime);
623
+ for (const entry of workerRegistry(repoRoot)) {
624
+ if (mine(entry.worker)) rmSync(entry.file, { force: true });
625
+ }
626
+ if (mine(readWorker(repoRoot))) rmSync(path.join(queueDir(repoRoot), WORKER_FILE), { force: true });
627
+ }
628
+
629
+ /** @typedef {{pid: number, start_time: string, run: string, started_at: string, heartbeat_at: string}} WorkerAnnouncement */
630
+
631
+ /** Bad entries remain occupied until a human fixes them. @param {any} w @returns {w is WorkerAnnouncement} */
632
+ function workerShape(w) {
633
+ return w && Number.isInteger(w.pid) && w.pid > 1 && typeof w.start_time === 'string' && w.start_time.length > 0
634
+ && !/[\\/]/.test(w.start_time) && typeof w.run === 'string' && w.run.length > 0
635
+ && typeof w.started_at === 'string' && Number.isFinite(Date.parse(w.started_at))
636
+ && typeof w.heartbeat_at === 'string' && Number.isFinite(Date.parse(w.heartbeat_at));
637
+ }
638
+
639
+ /** @param {{pid: number, start_time: string, heartbeat_at?: string}} w @param {number} [nowMs] */
640
+ function workerLive(w, nowMs) {
641
+ if (!isAlive(w.pid)) return false;
642
+ if (heartbeatFresh(w, nowMs)) return true;
643
+ return w.start_time === UNKNOWN_START_TIME || readStartTime(w.pid) === w.start_time;
644
+ }
645
+
646
+ /** @param {{pid: number, start_time: string, heartbeat_at?: string}} w @param {number} [nowMs] */
647
+ function workerLiveAtBoot(w, nowMs) {
648
+ if (!isAlive(w.pid)) return false;
649
+ const startTime = readStartTime(w.pid);
650
+ // Boot trusts orchestrator ps; sandbox readers let fresh heartbeats mask unreliable ps.
651
+ if (startTime !== null && w.start_time !== UNKNOWN_START_TIME) return startTime === w.start_time;
652
+ return heartbeatFresh(w, nowMs) || w.start_time === UNKNOWN_START_TIME;
653
+ }
654
+
655
+ /** Read all entries, keeping unreadable/bad entries occupied. Only proven stale owners may be pruned.
656
+ * @param {string} repoRoot @param {{prune?: boolean, nowMs?: number}} [opts]
657
+ * @returns {Array<{file: string, worker: WorkerAnnouncement | null, live: boolean}>}
658
+ */
659
+ export function workerRegistry(repoRoot, opts = {}) {
660
+ let names;
661
+ try { names = readdirSync(workersDir(repoRoot)); }
662
+ catch (err) { if (err.code === 'ENOENT') return []; throw err; }
663
+ const entries = [];
664
+ for (const name of names.sort().filter((n) => n.endsWith('.json'))) {
665
+ const file = path.join(workersDir(repoRoot), name);
666
+ const w = readJSON(file);
667
+ const worker = workerShape(w) && name === `${w.pid}-${w.start_time}.json` ? w : null;
668
+ const live = worker === null || (opts.prune ? workerLiveAtBoot(worker, opts.nowMs) : workerLive(worker, opts.nowMs));
669
+ if (!live && opts.prune) rmSync(file, { force: true });
670
+ else entries.push({ file, worker, live });
671
+ }
672
+ return entries;
566
673
  }
567
674
 
568
675
  /**
@@ -574,13 +681,14 @@ export function retractWorker(repoRoot, pid) {
574
681
  * Accepted trade-off: a worker that is SIGKILLed (no retraction) leaves a heartbeat that stays
575
682
  * fresh for up to `HEARTBEAT_STALE_MS` (60 s); if the OS reuses its pid inside that window, the
576
683
  * queue reads as served until the heartbeat goes stale. A clean stop retracts the file at once.
577
- * @param {string} repoRoot @param {{nowMs?: number}} [opts]
684
+ * Registry entries take precedence; worker.json supports workers from older versions.
685
+ * @param {string} repoRoot @param {{nowMs?: number, pid?: number, registeredOnly?: boolean}} [opts]
578
686
  */
579
687
  export function liveWorker(repoRoot, opts = {}) {
688
+ const entry = workerRegistry(repoRoot, opts).find((e) => e.live && e.worker && (opts.pid === undefined || e.worker.pid === opts.pid));
689
+ if (entry) return entry.worker;
690
+ if (opts.registeredOnly) return null;
580
691
  const w = readWorker(repoRoot);
581
- if (!w || !isAlive(w.pid)) return null;
582
- if (heartbeatFresh(w, opts.nowMs)) return w;
583
- if (w.start_time !== UNKNOWN_START_TIME && readStartTime(w.pid) !== w.start_time) return null;
584
- return w;
692
+ return w && (opts.pid === undefined || w.pid === opts.pid) && workerLive(w, opts.nowMs) ? w : null;
585
693
  }
586
694
 
@@ -99,10 +99,12 @@ export async function watchTick({ repoRoot, runId, cfg, readRun: loadRun = readR
99
99
  if (unreadable) { await skip('unreadable_head'); continue; }
100
100
  await hooks.beforeQueue?.();
101
101
  for (const { file, content_hash } of hashes) {
102
- const current = enqueueHash({ repoRoot, run: runId, block, file, content_hash, tree });
102
+ const held = earlier.some((old) => old.file === file);
103
+ if (!held) enqueueHash({ repoRoot, run: runId, block, file, content_hash, tree });
103
104
  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
+ if (old.file === file && old.content_hash !== content_hash && !running.has(old.ticket) && !isDone(repoRoot, old.ticket)) supersedeTicket(repoRoot, old.ticket);
105
106
  }
107
+ if (held) await writeRow({ event: 'watch.held', block, file, content_hash });
106
108
  }
107
109
  save({ ...state, run: runId, base, tree, sha: head, skipped: [] });
108
110
  } catch {