@codedology/code-forge 0.15.2 → 0.15.3

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 (61) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +5 -1
  3. package/docs/README.md +2 -0
  4. package/docs/reference/blocks.json +154 -1
  5. package/docs/reference/config.md +27 -14
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +57 -2
  8. package/schema/outcome.schema.json +45 -0
  9. package/schema/pipeline.schema.json +30 -0
  10. package/scripts/first-use.mjs +8 -0
  11. package/scripts/gen-config-doc.mjs +1 -1
  12. package/skill/references/code.md +15 -0
  13. package/skill/references/ledger.md +13 -1
  14. package/skill/scripts/forge +1 -1
  15. package/src/cli/block.mjs +146 -30
  16. package/src/cli/context.mjs +17 -7
  17. package/src/cli/gates.mjs +76 -10
  18. package/src/cli/help.mjs +4 -0
  19. package/src/cli/outcome.mjs +87 -0
  20. package/src/cli/pipeline.mjs +61 -0
  21. package/src/cli/proof.mjs +10 -7
  22. package/src/cli/report.mjs +53 -6
  23. package/src/cli/review-file.mjs +168 -38
  24. package/src/cli/run.mjs +3 -2
  25. package/src/cli/sweep.mjs +28 -0
  26. package/src/cli/validate.mjs +6 -0
  27. package/src/config/validate.mjs +83 -1
  28. package/src/context/pack.mjs +53 -3
  29. package/src/doctor/local.mjs +22 -4
  30. package/src/engines/builders/codex.mjs +27 -7
  31. package/src/engines/builders/grok.mjs +11 -5
  32. package/src/engines/codex-home.mjs +18 -15
  33. package/src/engines/sentinel.mjs +11 -0
  34. package/src/gates/detect.mjs +6 -3
  35. package/src/gates/run.mjs +295 -27
  36. package/src/ledger/export.mjs +1 -1
  37. package/src/ledger/report.mjs +143 -5
  38. package/src/outcome/outcome.mjs +108 -0
  39. package/src/pipeline/pipeline.mjs +112 -0
  40. package/src/proof/red-green.mjs +41 -13
  41. package/src/proof/runners.mjs +28 -4
  42. package/src/proof/worktree.mjs +25 -15
  43. package/src/review/agent-waiver.mjs +18 -4
  44. package/src/review/engine.mjs +25 -5
  45. package/src/review/gate-check.mjs +39 -1
  46. package/src/review/lenses/full.md +1 -0
  47. package/src/review/lenses/quick.md +1 -0
  48. package/src/review/packet.mjs +34 -6
  49. package/src/review/related.mjs +112 -0
  50. package/src/review/reopen.mjs +4 -3
  51. package/src/review/session-retry.mjs +59 -0
  52. package/src/session/spawn.mjs +54 -3
  53. package/src/state/block.mjs +101 -34
  54. package/src/state/config-snapshot.mjs +13 -3
  55. package/src/state/run.mjs +3 -2
  56. package/src/util/exec.mjs +48 -31
  57. package/src/util/reaper.mjs +238 -8
  58. package/src/worker/engine.mjs +7 -3
  59. package/src/worker/loop.mjs +67 -14
  60. package/src/worker/queue.mjs +106 -23
  61. package/src/worker/watch.mjs +2 -2
@@ -31,7 +31,9 @@
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
+ import { reviewExcludeRule } from '../config/validate.mjs';
35
+ import { configInForce } from '../state/config-snapshot.mjs';
36
+ import { runRecordPath, StateError } from '../state/paths.mjs';
35
37
  import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
36
38
  import { isAlive, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
37
39
  import { assertRowPath, assertTicketId, contentHash, ticketId, WorkerError } from './ticket.mjs';
@@ -76,7 +78,7 @@ function readJSON(file) {
76
78
  }
77
79
 
78
80
  /**
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
81
+ * @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: string, queued_at?: string | null, started_at?: string | null, finished_at?: string | null, duration_ms?: number | null, tree?: string, acceptance_revision?: string, reopened_at?: string, kind?: 'answers', by_file?: string, by_hash?: string, answers?: string[]}} Ticket
80
82
  * `tree` (B62b): the block's worktree the content was hashed in. It is part of the ticket id,
81
83
  * and the worker only checks it against the tree it takes from the run record (a ticket whose
82
84
  * tree is not that one is `unavailable`, `worktree-mismatch`); it never reads content there.
@@ -86,16 +88,25 @@ function readJSON(file) {
86
88
  * The review ticket for `file`'s CURRENT content: the one rule `enqueue` and `clearDone` (B72)
87
89
  * share — the repo-relative path, the realpath'd tree (or null), the content hash taken under the
88
90
  * tree when given (else under `repoRoot`) and the ticket id over all of them.
89
- * @param {{repoRoot: string, run: string, block: string, file: string, tree?: string | null}} opts
91
+ * @param {{repoRoot: string, run: string, block: string, file: string, tree?: string | null, after?: string}} opts
90
92
  * @returns {{rel: string, root: string | null, content_hash: string, ticket: string}}
91
93
  * @throws {WorkerError} `bad-path` for a bad file path or a tree that cannot be resolved.
92
94
  */
93
- export function resolveTicket({ repoRoot, run, block, file, tree = null }) {
95
+ export function resolveTicket({ repoRoot, run, block, file, tree = null, after }) {
94
96
  const rel = assertRowPath(file);
95
97
  const root = tree === null ? null : realTree(tree);
96
98
  const content_hash = contentHash(root ?? repoRoot, rel);
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
+ return resolveHashTicket({ repoRoot, run, block, file: rel, tree: root, after, content_hash });
100
+ }
101
+
102
+ /** Resolve already measured content through the same acceptance/reopen identity.
103
+ * @param {{repoRoot: string, run: string, block: string, file: string, content_hash: string, tree?: string | null, after?: string}} opts
104
+ */
105
+ export function resolveHashTicket({ repoRoot, run, block, file, content_hash, tree = null, after }) {
106
+ const rel = assertRowPath(file);
107
+ const root = tree === null ? null : realTree(tree);
108
+ const { ticket } = reviewIdentity({ repoRoot, run, block, file: rel, content_hash, tree: root, after });
109
+ return { rel, root, content_hash, ticket };
99
110
  }
100
111
 
101
112
  /** The mirror lets sandboxed callers resolve the same ticket without the private record.
@@ -107,10 +118,39 @@ function acceptanceRevision(repoRoot, run, block) {
107
118
  return typeof revision === 'string' ? revision : undefined;
108
119
  }
109
120
 
110
- /** @param {{run: string, block: string, file: string, content_hash: string, tree?: string | null, acceptance_revision?: string}} parts */
121
+ /** @param {{run: string, block: string, file: string, content_hash: string, tree?: string | null, acceptance_revision?: string, reopened_at?: string}} parts */
111
122
  function reviewTicketId(parts) {
112
123
  const id = ticketId(parts);
113
- return parts.acceptance_revision === undefined ? id : createHash('sha256').update(`${id}\0${parts.acceptance_revision}`).digest('hex').slice(0, 24);
124
+ const revisionId = parts.acceptance_revision === undefined ? id : createHash('sha256').update(`${id}\0${parts.acceptance_revision}`).digest('hex').slice(0, 24);
125
+ return parts.reopened_at === undefined ? revisionId : createHash('sha256').update(`${revisionId}\0reopen\0${parts.reopened_at}`).digest('hex').slice(0, 24);
126
+ }
127
+
128
+ /** Reopens get their own identity; ordinary enqueues keep using that attempt afterwards.
129
+ * @param {{repoRoot: string, run: string, block: string, file: string, content_hash: string, tree: string | null, after?: string}} opts
130
+ */
131
+ function reviewIdentity({ repoRoot, run, block, file, content_hash, tree, after }) {
132
+ const acceptance_revision = acceptanceRevision(repoRoot, run, block);
133
+ let names;
134
+ try { names = readdirSync(queueDir(repoRoot)); } catch (err) {
135
+ if (err.code !== 'ENOENT') throw new WorkerError('queue-unreadable', 'the review queue cannot be read');
136
+ names = [];
137
+ }
138
+ const matching = [];
139
+ for (const name of names) {
140
+ if (!/^[0-9a-f]{24}\.json$/.test(name)) continue;
141
+ const t = readJSON(path.join(queueDir(repoRoot), name));
142
+ // Scope before validation: a stored tree from another ticket must never select this tree.
143
+ if (t && !t.kind && t.run === run && t.block === block && t.file === file && t.content_hash === content_hash && (t.tree ?? null) === tree && t.acceptance_revision === acceptance_revision) {
144
+ matching.push(readTicket(repoRoot, name.slice(0, 24)));
145
+ }
146
+ }
147
+ matching.sort((a, b) => a.enqueued_at.localeCompare(b.enqueued_at) || a.ticket.localeCompare(b.ticket));
148
+ const latest = matching.filter((t) => t.reopened_at !== undefined).at(-1);
149
+ const afterTime = Date.parse(after ?? '');
150
+ const fresh = matching.filter((t) => Date.parse(t.enqueued_at) > afterTime).at(-1);
151
+ const reopened_at = fresh ? fresh.reopened_at : Number.isFinite(afterTime) ? after : latest?.reopened_at;
152
+ const ticket = reviewTicketId({ run, block, file, content_hash, tree, acceptance_revision, reopened_at });
153
+ return { ticket, acceptance_revision, reopened_at, matching };
114
154
  }
115
155
 
116
156
  /** Reset only the commit-watch cursor; review evidence and results remain intact.
@@ -126,30 +166,44 @@ export function resetAcceptanceWatch(repoRoot, block) {
126
166
  * content is hashed under `tree` when given (B62b: the block's worktree, realpath'd here), else
127
167
  * under `repoRoot`. The tree is part of the ticket id, so a main-tree ticket and a tree ticket for
128
168
  * the same bytes differ, and the answer names the tree given here, never a stored value.
129
- * @param {{repoRoot: string, run: string, block: string, file: string, tree?: string | null, now?: Date}} opts
130
- * @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, tree?: string}}
169
+ * `after` creates a new identity for a reopen. Repeated calls for that row reuse the ticket,
170
+ * including its eventual completion; ordinary enqueues also reuse the latest reopened attempt.
171
+ * A manual review queued after the row already counts as fresh and keeps its identity.
172
+ * @param {{repoRoot: string, run: string, block: string, file: string, tree?: string | null, now?: Date, after?: string}} opts
173
+ * @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, tree?: string} | {status: 'excluded', file: string, rule: string, ticket?: never, content_hash?: never, tree?: never}}
131
174
  * @throws {WorkerError} `bad-path` for a relative `tree` or one that cannot be resolved.
132
175
  */
133
- export function enqueue({ repoRoot, run, block, file, tree = null, now = new Date() }) {
176
+ export function enqueue({ repoRoot, run, block, file, tree = null, now = new Date(), after }) {
177
+ const rule = reviewExclusionFor(repoRoot, run, assertRowPath(file));
178
+ if (rule !== null) return { status: 'excluded', file, rule };
134
179
  const { content_hash } = resolveTicket({ repoRoot, run, block, file, tree });
135
- return enqueueHash({ repoRoot, run, block, file, tree, content_hash, now });
180
+ return enqueueHash({ repoRoot, run, block, file, tree, content_hash, now, after });
136
181
  }
137
182
 
138
183
  /** Queue proven git-object content through the same identity as a manual review.
139
- * @param {{repoRoot: string, run: string, block: string, file: string, content_hash: string, tree?: string | null, now?: Date}} opts
140
- * @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, tree?: string}}
184
+ * @param {{repoRoot: string, run: string, block: string, file: string, content_hash: string, tree?: string | null, now?: Date, after?: string}} opts
185
+ * @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, tree?: string} | {status: 'excluded', file: string, rule: string, ticket?: never, content_hash?: never, tree?: never}}
141
186
  */
142
- export function enqueueHash({ repoRoot, run, block, file, content_hash, tree = null, now = new Date() }) {
187
+ export function enqueueHash({ repoRoot, run, block, file, content_hash, tree = null, now = new Date(), after }) {
143
188
  const rel = assertRowPath(file);
189
+ const rule = reviewExclusionFor(repoRoot, run, rel);
190
+ if (rule !== null) return { status: 'excluded', file: rel, rule };
144
191
  const root = tree === null ? null : realTree(tree);
145
- const acceptance_revision = acceptanceRevision(repoRoot, run, block);
146
- const ticket = reviewTicketId({ run, block, file: rel, content_hash, tree: root, acceptance_revision });
192
+ const { ticket, acceptance_revision, reopened_at, matching } = reviewIdentity({ repoRoot, run, block, file: rel, content_hash, tree: root, after });
147
193
  const target = path.join(queueDir(repoRoot), `${ticket}.json`);
148
- const named = { ...(root === null ? {} : { tree: root }), ...(acceptance_revision === undefined ? {} : { acceptance_revision }) };
194
+ const named = { ...(root === null ? {} : { tree: root }), ...(acceptance_revision === undefined ? {} : { acceptance_revision }), ...(reopened_at === undefined ? {} : { reopened_at }) };
195
+ const reopenedAt = Date.parse(reopened_at ?? '');
196
+ if (Number.isFinite(reopenedAt)) {
197
+ // clearDone may have made the old ticket pending. Retire it before publishing its successor.
198
+ for (const previous of matching) {
199
+ if (previous.ticket !== ticket && Date.parse(previous.enqueued_at) <= reopenedAt) supersedeTicket(repoRoot, previous.ticket);
200
+ }
201
+ }
149
202
  if (!existsSync(target)) {
203
+ const queued_at = new Date(Number.isFinite(reopenedAt) ? Math.max(now.getTime(), reopenedAt + 1) : now.getTime()).toISOString();
150
204
  /** @type {Ticket} */
151
- const body = { ticket, run, block, file: rel, content_hash, enqueued_at: now.toISOString(), ...named };
152
- // Publish a complete immutable ticket without replacing a concurrent coder's ticket.
205
+ const body = { ticket, run, block, file: rel, content_hash, enqueued_at: queued_at, queued_at, started_at: null, finished_at: null, duration_ms: null, ...named };
206
+ // Publish a complete ticket without replacing a concurrent coder's ticket.
153
207
  mkdirSync(path.dirname(target), { recursive: true });
154
208
  const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
155
209
  try {
@@ -169,6 +223,23 @@ export function enqueueHash({ repoRoot, run, block, file, content_hash, tree = n
169
223
  return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash, ...named };
170
224
  }
171
225
 
226
+ /** Use the current run snapshot, including reloads, just as the watch and close do.
227
+ * @param {string} repoRoot @param {string} run @param {string} file @param {Record<string, any> | null} [fallback]
228
+ */
229
+ export function reviewExclusionFor(repoRoot, run, file, fallback = null) {
230
+ const record = readJSON(runRecordPath(run)) ?? readJSON(path.join(repoRoot, '.code-forge', 'runs', `${run}.json`));
231
+ let cfg;
232
+ try {
233
+ cfg = record ? configInForce(record) : null;
234
+ } catch (err) {
235
+ if (!(err instanceof StateError) || err.code !== 'config-snapshot') throw err;
236
+ // Keep the ticket queued so the worker records unavailable/config-snapshot. Never use
237
+ // exclusions from an untrusted snapshot or the fallback to skip that result.
238
+ return null;
239
+ }
240
+ return reviewExcludeRule(cfg ?? fallback, file);
241
+ }
242
+
172
243
  /** Finish only an unstarted, pending review. A superseded marker has no review result.
173
244
  * @param {string} repoRoot @param {string} ticket
174
245
  * @param {{write?: (file: string, text: string) => void, link?: (from: string, to: string) => void}} [io] - publication seams for tests.
@@ -222,13 +293,17 @@ export function assertAnswers(answers) {
222
293
  * repo-root-relative). Both files are hashed now (under `tree` when given); idempotent like
223
294
  * {@link enqueue}. The caller has already refused the same file and the id list.
224
295
  * @param {{repoRoot: string, run: string, block: string, file: string, byFile: string, answers: string[], tree?: string | null, now?: Date}} opts
225
- * @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, by_file: string, by_hash: string, answers: string[], tree?: string}}
296
+ * @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, by_file: string, by_hash: string, answers: string[], tree?: string} | {status: 'excluded', file: string, rule: string, ticket?: never, answers?: never}}
226
297
  * @throws {WorkerError} `bad-path`, `usage`
227
298
  */
228
299
  export function enqueueAnswers({ repoRoot, run, block, file, byFile, answers, tree = null, now = new Date() }) {
229
300
  const rel = assertRowPath(file);
230
301
  const by_file = assertRowPath(byFile);
231
302
  const ids = assertAnswers(answers);
303
+ for (const candidate of [rel, by_file]) {
304
+ const rule = reviewExclusionFor(repoRoot, run, candidate);
305
+ if (rule !== null) return { status: 'excluded', file: candidate, rule };
306
+ }
232
307
  const root = tree === null ? null : realTree(tree);
233
308
  const content_hash = contentHash(root ?? repoRoot, rel);
234
309
  const by_hash = contentHash(root ?? repoRoot, by_file);
@@ -237,7 +312,7 @@ export function enqueueAnswers({ repoRoot, run, block, file, byFile, answers, tr
237
312
  const named = root === null ? {} : { tree: root };
238
313
  if (!existsSync(target)) {
239
314
  /** @type {AnswersTicket} */
240
- const body = { ticket, kind: 'answers', run, block, file: rel, content_hash, by_file, by_hash, answers: ids, enqueued_at: now.toISOString(), ...named };
315
+ const body = { ticket, kind: 'answers', run, block, file: rel, content_hash, by_file, by_hash, answers: ids, enqueued_at: now.toISOString(), queued_at: now.toISOString(), started_at: null, finished_at: null, duration_ms: null, ...named };
241
316
  writeAtomic(target, `${JSON.stringify(body)}\n`);
242
317
  }
243
318
  return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash, by_file, by_hash, answers: ids, ...named };
@@ -532,6 +607,7 @@ export function readTicket(repoRoot, id) {
532
607
  return /** @type {Ticket} */ (t);
533
608
  }
534
609
  if (t.acceptance_revision !== undefined && typeof t.acceptance_revision !== 'string') throw new WorkerError('bad-ticket', `ticket ${id} has an invalid acceptance revision`);
610
+ if (t.reopened_at !== undefined && (typeof t.reopened_at !== 'string' || !Number.isFinite(Date.parse(t.reopened_at)))) throw new WorkerError('bad-ticket', `ticket ${id} has an invalid reopen timestamp`);
535
611
  if (reviewTicketId(t) !== id) throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
536
612
  return /** @type {Ticket} */ (t);
537
613
  }
@@ -550,10 +626,17 @@ export function writeResult({ repoRoot, runId, ticket, result, key }) {
550
626
  const file = path.join(reviewsDir(repoRoot, runId), `${ticket}.json`);
551
627
  writeAtomic(file, `${JSON.stringify(signed)}\n`);
552
628
  const openFindings = signed.status === 'reviewed' && signed.approved !== true && Array.isArray(signed.findings) && signed.findings.length > 0;
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`);
629
+ const timings = Object.fromEntries(['queued_at', 'started_at', 'finished_at', 'duration_ms'].filter((field) => Object.hasOwn(signed, field)).map((field) => [field, signed[field]]));
630
+ writeAtomic(path.join(queueDir(repoRoot), `${ticket}.done`), `${JSON.stringify({ ticket, status: signed.status, ...measured, ...timings, ...(openFindings ? { open_findings: true } : {}), result: path.relative(repoRoot, file) })}\n`);
554
631
  return signed;
555
632
  }
556
633
 
634
+ /** Worker-owned lifecycle metadata; identity and enqueue ordering stay unchanged.
635
+ * @param {string} repoRoot @param {Ticket} ticket @param {Record<string, any>} timings */
636
+ export function writeTicketTimings(repoRoot, ticket, timings) {
637
+ writeAtomic(path.join(queueDir(repoRoot), `${assertTicketId(ticket.ticket)}.json`), `${JSON.stringify({ ...ticket, ...timings })}\n`);
638
+ }
639
+
557
640
  /**
558
641
  * Read a result without verifying it (the coder's `--wait` cannot read the run key).
559
642
  * @param {string} repoRoot @param {string} runId @param {string} ticket
@@ -2,7 +2,7 @@
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import path from 'node:path';
5
- import { reviewFileLoop } from '../config/validate.mjs';
5
+ import { reviewExcludeRule, reviewFileLoop } from '../config/validate.mjs';
6
6
  import { snapshotFor } from '../state/config-snapshot.mjs';
7
7
  import { readRun } from '../state/run.mjs';
8
8
  import { ownsAt } from '../review/gate-check.mjs';
@@ -86,7 +86,7 @@ export async function watchTick({ repoRoot, runId, cfg, readRun: loadRun = readR
86
86
  // Stage every hash before publishing anything: an unreadable object queues nothing.
87
87
  const hashes = [];
88
88
  let unreadable = false;
89
- for (const raw of owned) {
89
+ for (const raw of owned.filter((file) => reviewExcludeRule(snapshotFor(record, '')?.config ?? cfg, file) === null)) {
90
90
  const file = assertRowPath(raw);
91
91
  const listed = await git(['ls-tree', '-z', head, '--', file]);
92
92
  if (listed.result !== 'ok') { unreadable = true; break; }