@codedology/code-forge 0.14.1 → 0.15.1

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 (41) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/docs/reference/blocks.json +495 -1
  4. package/docs/reference/config.md +7 -0
  5. package/package.json +1 -1
  6. package/schema/code-forge.schema.json +22 -0
  7. package/scripts/first-use.mjs +11 -5
  8. package/skill/SKILL.md +4 -0
  9. package/skill/references/autopilot.md +2 -0
  10. package/skill/references/code.md +28 -2
  11. package/skill/references/review.md +10 -2
  12. package/skill/references/security.md +2 -0
  13. package/skill/scripts/forge +1 -1
  14. package/skill/templates/coder-brief.md +3 -1
  15. package/src/autopilot/scopes.mjs +2 -1
  16. package/src/cli/block.mjs +271 -50
  17. package/src/cli/gates.mjs +36 -6
  18. package/src/cli/ledger.mjs +21 -2
  19. package/src/cli/report.mjs +14 -0
  20. package/src/cli/review-file.mjs +56 -16
  21. package/src/cli/run.mjs +44 -7
  22. package/src/cli/status.mjs +7 -3
  23. package/src/config/validate.mjs +13 -1
  24. package/src/gates/acceptance.mjs +7 -3
  25. package/src/gates/expand.mjs +25 -2
  26. package/src/gates/run.mjs +38 -8
  27. package/src/ledger/report.mjs +29 -7
  28. package/src/review/accept-hit.mjs +22 -4
  29. package/src/review/engine.mjs +30 -10
  30. package/src/review/finding.schema.json +2 -1
  31. package/src/review/fixloop.mjs +79 -14
  32. package/src/review/gate-check.mjs +33 -7
  33. package/src/review/lenses/recheck.md +3 -0
  34. package/src/review/packet.mjs +26 -0
  35. package/src/state/block.mjs +196 -22
  36. package/src/state/run.mjs +35 -8
  37. package/src/util/forbidden.mjs +230 -6
  38. package/src/util/reaper.mjs +3 -3
  39. package/src/worker/engine.mjs +74 -6
  40. package/src/worker/loop.mjs +19 -10
  41. package/src/worker/queue.mjs +38 -8
@@ -31,6 +31,7 @@
31
31
  import { createHash, randomBytes } from 'node:crypto';
32
32
  import { chmodSync, existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
33
33
  import path from 'node:path';
34
+ import { runRecordPath } from '../state/paths.mjs';
34
35
  import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
35
36
  import { isAlive, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
36
37
  import { assertRowPath, assertTicketId, contentHash, ticketId, WorkerError } from './ticket.mjs';
@@ -75,7 +76,7 @@ function readJSON(file) {
75
76
  }
76
77
 
77
78
  /**
78
- * @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: string, tree?: string, kind?: 'answers', by_file?: string, by_hash?: string, answers?: string[]}} Ticket
79
+ * @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: string, tree?: string, acceptance_revision?: string, kind?: 'answers', by_file?: string, by_hash?: string, answers?: string[]}} Ticket
79
80
  * `tree` (B62b): the block's worktree the content was hashed in. It is part of the ticket id,
80
81
  * and the worker only checks it against the tree it takes from the run record (a ticket whose
81
82
  * tree is not that one is `unavailable`, `worktree-mismatch`); it never reads content there.
@@ -93,7 +94,30 @@ export function resolveTicket({ repoRoot, run, block, file, tree = null }) {
93
94
  const rel = assertRowPath(file);
94
95
  const root = tree === null ? null : realTree(tree);
95
96
  const content_hash = contentHash(root ?? repoRoot, rel);
96
- return { rel, root, content_hash, ticket: ticketId({ run, block, file: rel, content_hash, tree: root }) };
97
+ const revision = acceptanceRevision(repoRoot, run, block);
98
+ return { rel, root, content_hash, ticket: reviewTicketId({ run, block, file: rel, content_hash, tree: root, acceptance_revision: revision }) };
99
+ }
100
+
101
+ /** The mirror lets sandboxed callers resolve the same ticket without the private record.
102
+ * @param {string} repoRoot @param {string} run @param {string} block @returns {string | undefined}
103
+ */
104
+ function acceptanceRevision(repoRoot, run, block) {
105
+ const record = readJSON(runRecordPath(run)) ?? readJSON(path.join(repoRoot, '.code-forge', 'runs', `${run}.json`));
106
+ const revision = record?.blocks?.[block]?.acceptance_revision;
107
+ return typeof revision === 'string' ? revision : undefined;
108
+ }
109
+
110
+ /** @param {{run: string, block: string, file: string, content_hash: string, tree?: string | null, acceptance_revision?: string}} parts */
111
+ function reviewTicketId(parts) {
112
+ const id = ticketId(parts);
113
+ return parts.acceptance_revision === undefined ? id : createHash('sha256').update(`${id}\0${parts.acceptance_revision}`).digest('hex').slice(0, 24);
114
+ }
115
+
116
+ /** Reset only the commit-watch cursor; review evidence and results remain intact.
117
+ * @param {string} repoRoot @param {string} block
118
+ */
119
+ export function resetAcceptanceWatch(repoRoot, block) {
120
+ rmSync(path.join(queueDir(repoRoot), 'watch', `${block}.json`), { force: true });
97
121
  }
98
122
 
99
123
  /**
@@ -118,9 +142,10 @@ export function enqueue({ repoRoot, run, block, file, tree = null, now = new Dat
118
142
  export function enqueueHash({ repoRoot, run, block, file, content_hash, tree = null, now = new Date() }) {
119
143
  const rel = assertRowPath(file);
120
144
  const root = tree === null ? null : realTree(tree);
121
- const ticket = ticketId({ run, block, file: rel, content_hash, tree: root });
145
+ const acceptance_revision = acceptanceRevision(repoRoot, run, block);
146
+ const ticket = reviewTicketId({ run, block, file: rel, content_hash, tree: root, acceptance_revision });
122
147
  const target = path.join(queueDir(repoRoot), `${ticket}.json`);
123
- const named = root === null ? {} : { tree: root };
148
+ const named = { ...(root === null ? {} : { tree: root }), ...(acceptance_revision === undefined ? {} : { acceptance_revision }) };
124
149
  if (!existsSync(target)) {
125
150
  /** @type {Ticket} */
126
151
  const body = { ticket, run, block, file: rel, content_hash, enqueued_at: now.toISOString(), ...named };
@@ -444,7 +469,8 @@ export function blockTickets({ repoRoot, run, block, tree = null }) {
444
469
  throw new WorkerError('queue-unreadable', 'the review queue cannot be read');
445
470
  }
446
471
  const tickets = names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n)).map((n) => readTicket(repoRoot, n.slice(0, 24)));
447
- return tickets.filter((t) => !t.kind && t.run === run && t.block === block && (t.tree ?? null) === tree)
472
+ const revision = acceptanceRevision(repoRoot, run, block);
473
+ return tickets.filter((t) => !t.kind && t.run === run && t.block === block && (t.tree ?? null) === tree && t.acceptance_revision === revision)
448
474
  .sort((a, b) => a.enqueued_at.localeCompare(b.enqueued_at) || a.ticket.localeCompare(b.ticket));
449
475
  }
450
476
 
@@ -505,7 +531,8 @@ export function readTicket(repoRoot, id) {
505
531
  if (typeof t.by_hash !== 'string' || ids.join(',') !== t.answers.join(',') || answersTicketId(t) !== id) throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
506
532
  return /** @type {Ticket} */ (t);
507
533
  }
508
- if (ticketId(t) !== id) throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
534
+ if (t.acceptance_revision !== undefined && typeof t.acceptance_revision !== 'string') throw new WorkerError('bad-ticket', `ticket ${id} has an invalid acceptance revision`);
535
+ if (reviewTicketId(t) !== id) throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
509
536
  return /** @type {Ticket} */ (t);
510
537
  }
511
538
 
@@ -516,11 +543,14 @@ export function readTicket(repoRoot, id) {
516
543
  * @returns {Record<string, any>} the signed result
517
544
  */
518
545
  export function writeResult({ repoRoot, runId, ticket, result, key }) {
519
- const signed = signRow({ ...result, run: runId, ticket: assertTicketId(ticket) }, key);
546
+ const split = result.status === 'split_required' || (result.status === 'stopped' && result.stopped === 'split_required');
547
+ // The engine's depth identifies the round-1 packet; rechecks always use the recheck lens.
548
+ const measured = split ? { budget: result.budget ?? null, lens: result.lens ?? (result.status === 'stopped' ? 'recheck' : result.depth === 'quick' ? 'quick' : result.depth === 'dual' ? 'A' : 'full') } : {};
549
+ const signed = signRow({ ...result, ...measured, run: runId, ticket: assertTicketId(ticket) }, key);
520
550
  const file = path.join(reviewsDir(repoRoot, runId), `${ticket}.json`);
521
551
  writeAtomic(file, `${JSON.stringify(signed)}\n`);
522
552
  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`);
553
+ writeAtomic(path.join(queueDir(repoRoot), `${ticket}.done`), `${JSON.stringify({ ticket, status: signed.status, ...measured, ...(openFindings ? { open_findings: true } : {}), result: path.relative(repoRoot, file) })}\n`);
524
554
  return signed;
525
555
  }
526
556