@codedology/code-forge 0.14.0 → 0.15.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 (42) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +136 -438
  3. package/docs/README.md +1 -1
  4. package/docs/reference/blocks.json +420 -1
  5. package/docs/reference/config.md +7 -0
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +22 -0
  8. package/scripts/first-use.mjs +11 -5
  9. package/skill/SKILL.md +2 -0
  10. package/skill/references/autopilot.md +2 -0
  11. package/skill/references/code.md +25 -1
  12. package/skill/references/review.md +10 -2
  13. package/skill/scripts/forge +1 -1
  14. package/src/autopilot/scopes.mjs +2 -1
  15. package/src/cli/block.mjs +235 -44
  16. package/src/cli/gates.mjs +36 -6
  17. package/src/cli/ledger.mjs +21 -2
  18. package/src/cli/report.mjs +14 -0
  19. package/src/cli/review-file.mjs +56 -16
  20. package/src/cli/run.mjs +44 -7
  21. package/src/cli/status.mjs +7 -3
  22. package/src/cli/upgrade.mjs +95 -15
  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/install/link.mjs +12 -6
  28. package/src/ledger/report.mjs +29 -7
  29. package/src/review/accept-hit.mjs +22 -4
  30. package/src/review/engine.mjs +30 -10
  31. package/src/review/finding.schema.json +2 -1
  32. package/src/review/fixloop.mjs +79 -14
  33. package/src/review/gate-check.mjs +22 -3
  34. package/src/review/lenses/recheck.md +3 -0
  35. package/src/review/packet.mjs +26 -0
  36. package/src/state/block.mjs +162 -20
  37. package/src/state/run.mjs +35 -8
  38. package/src/util/forbidden.mjs +14 -0
  39. package/src/util/reaper.mjs +3 -3
  40. package/src/worker/engine.mjs +60 -4
  41. package/src/worker/loop.mjs +19 -10
  42. package/src/worker/queue.mjs +5 -2
@@ -20,7 +20,9 @@
20
20
  * coder one. A later call for the same PR replaces the earlier row in every reader.
21
21
  */
22
22
 
23
+ import { readdir, stat } from 'node:fs/promises';
23
24
  import { buildCalibration } from '../ledger/calibration.mjs';
25
+ import { ledgerDir, ledgerPath } from '../ledger/paths.mjs';
24
26
  import { recordCiOutcome, reviewedEntriesFromRows, scanGitAndRecord } from '../ledger/outcome.mjs';
25
27
  import { OUTSIDE_REVIEW_EVENT, OUTSIDE_REVIEW_USAGE, outsideReviewRecordedLine, parseOutsideReviewArgs } from '../ledger/outside-review.mjs';
26
28
  import { roundUsd, runSpend } from '../ledger/spend.mjs';
@@ -62,6 +64,23 @@ function parseNonNegativeInt(raw) {
62
64
  return Number.parseInt(raw, 10);
63
65
  }
64
66
 
67
+ /** Readers distinguish an absent ledger from an existing empty file. @param {string} slug */
68
+ async function readExistingRows(slug) {
69
+ try {
70
+ await stat(ledgerPath(slug));
71
+ } catch (error) {
72
+ if (error.code !== 'ENOENT') throw error;
73
+ const entries = await readdir(ledgerDir(), { withFileTypes: true }).catch((e) => {
74
+ if (e.code === 'ENOENT') return [];
75
+ throw e;
76
+ });
77
+ const slugs = [...new Set(entries.filter((e) => e.isFile() && /^[a-z0-9][a-z0-9-]*(?:\.\d+)?\.jsonl$/.test(e.name))
78
+ .map((e) => e.name.replace(/(?:\.\d+)?\.jsonl$/, '')))].sort();
79
+ throw new Error(`no ledger for slug "${slug}"; known slugs: ${slugs.join(', ') || 'none'}`);
80
+ }
81
+ return readAllRows(slug);
82
+ }
83
+
65
84
  /**
66
85
  * @param {string[]} args
67
86
  * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}}} [deps]
@@ -89,7 +108,7 @@ export async function runLedger(args, deps = {}) {
89
108
  err('ledger calibration: --question requires a value\n');
90
109
  return 2;
91
110
  }
92
- const rows = await readAllRows(slug);
111
+ const rows = await readExistingRows(slug);
93
112
  const answers = rows.filter((r) => r.event === 'decision' && typeof r.confidence === 'number');
94
113
  const outcomes = rows.filter((r) => r.event === 'outcome' && r.decision_id);
95
114
  const { buckets, unbacked, unmapped } = buildCalibration(answers, outcomes, { questionId: question.value });
@@ -161,7 +180,7 @@ export async function runLedger(args, deps = {}) {
161
180
  err(`ledger tail: --n/-n must be a non-negative integer, got "${nArg}"\n`);
162
181
  return 2;
163
182
  }
164
- const rows = await readAllRows(slug);
183
+ const rows = await readExistingRows(slug);
165
184
  for (const row of n === 0 ? [] : rows.slice(-n)) {
166
185
  out(`${JSON.stringify(row)}\n`);
167
186
  }
@@ -6,7 +6,9 @@
6
6
  * printed as text lines under its header (`outside review: none`, or one line per PR).
7
7
  */
8
8
 
9
+ import { readdir, stat } from 'node:fs/promises';
9
10
  import { writeSafe } from '../util/redact.mjs';
11
+ import { ledgerDir, ledgerPath } from '../ledger/paths.mjs';
10
12
  import { buildReport, buildRunModes, buildSpend } from '../ledger/report.mjs';
11
13
  import { exportReportSections } from '../ledger/export.mjs';
12
14
  import { outsideReviewLines } from '../ledger/outside-review.mjs';
@@ -46,6 +48,18 @@ export async function runReport(args, deps = {}) {
46
48
  // try/catch below turns that, and any other I/O failure, into a clean CLI message instead of a
47
49
  // raw stack trace that skips the `writeSafe` redaction path.
48
50
  try {
51
+ try {
52
+ await stat(ledgerPath(slug));
53
+ } catch (error) {
54
+ if (error.code !== 'ENOENT') throw error;
55
+ const entries = await readdir(ledgerDir(), { withFileTypes: true }).catch((e) => {
56
+ if (e.code === 'ENOENT') return [];
57
+ throw e;
58
+ });
59
+ const slugs = [...new Set(entries.filter((e) => e.isFile() && /^[a-z0-9][a-z0-9-]*(?:\.\d+)?\.jsonl$/.test(e.name))
60
+ .map((e) => e.name.replace(/(?:\.\d+)?\.jsonl$/, '')))].sort();
61
+ throw new Error(`no ledger for slug "${slug}"; known slugs: ${slugs.join(', ') || 'none'}`);
62
+ }
49
63
  const rows = await readAllRows(slug);
50
64
  const { sections } = buildReport(rows);
51
65
 
@@ -73,12 +73,14 @@ import { answersTicketId, assertAnswers, blockTickets, deliverBlockFindings, dro
73
73
  import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
74
74
  import { blockFileSet, namedOnHash } from '../review/gate-check.mjs';
75
75
  import { STOP_EXITS } from '../review/stop-exits.mjs';
76
+ import { budgetFor } from '../review/packet.mjs';
76
77
  import { ownsFile } from '../state/registry.mjs';
77
78
  import { readAllRows } from '../ledger/write.mjs';
78
79
  import { parseFlags } from '../state/cli-args.mjs';
79
80
  import { StateError } from '../state/paths.mjs';
80
81
  import { assertRunId } from '../state/paths.mjs';
81
82
  import { readRun } from '../state/run.mjs';
83
+ import { configInForce } from '../state/config-snapshot.mjs';
82
84
  import { blockTree } from '../proof/worktree.mjs';
83
85
  import { writeSafe } from '../util/redact.mjs';
84
86
 
@@ -132,6 +134,18 @@ function resultOfMarker(repoRoot, text) {
132
134
  /** B75: the refused reasons that are not a verdict on the content (`--fresh` re-runs them). */
133
135
  const FRESH_REFUSED = new Set(['other-run', 'context-unreadable']);
134
136
 
137
+ /**
138
+ * A split is reusable only under the budget it measured; absent metadata fails closed.
139
+ * @param {Record<string, any> | null} result @param {Record<string, any> | null} cfg
140
+ * @returns {{old: number, current: number} | null}
141
+ */
142
+ function splitBudgetChange(result, cfg) {
143
+ if (!cfg || !result || (result.status !== 'split_required' && !(result.status === 'stopped' && result.stopped === 'split_required'))) return null;
144
+ if (!['quick', 'full', 'A', 'B', 'recheck'].includes(result.lens) || !Number.isFinite(result.budget) || result.budget <= 0) return null;
145
+ const current = budgetFor(cfg, result.lens === 'quick' ? 'quick_in' : 'full_in');
146
+ return result.budget !== current ? { old: result.budget, current } : null;
147
+ }
148
+
135
149
  /**
136
150
  * B75: may `--fresh` drop this cached result? An allow-list, fail closed: `rerun` only for a
137
151
  * result that never counted as a review — `stale`, `unavailable`, round-1 `split_required`, or
@@ -141,9 +155,10 @@ const FRESH_REFUSED = new Set(['other-run', 'context-unreadable']);
141
155
  * ran, `review-state-lost`, an answers result, an unknown status or reason, an unreadable marker or
142
156
  * result).
143
157
  * @param {string} text - the done marker's text @param {Record<string, any> | null} result
158
+ * @param {Record<string, any> | null} [cfg] - the reloaded config, when readable.
144
159
  * @returns {'rerun' | 'tampered' | 'counted'}
145
160
  */
146
- export function freshVerdict(text, result) {
161
+ export function freshVerdict(text, result, cfg = null) {
147
162
  let marker;
148
163
  try {
149
164
  marker = JSON.parse(text);
@@ -151,6 +166,7 @@ export function freshVerdict(text, result) {
151
166
  return 'counted';
152
167
  }
153
168
  if (result === null || marker === null || typeof marker !== 'object' || marker.status !== result.status) return 'counted';
169
+ if (splitBudgetChange(result, cfg)) return 'rerun';
154
170
  if (result.status === 'stale' || result.status === 'unavailable' || result.status === 'split_required') return 'rerun';
155
171
  if (result.status !== 'refused' || typeof result.reason !== 'string') return 'counted';
156
172
  if (result.reason === 'review-state-tampered') return 'tampered';
@@ -168,10 +184,10 @@ const RETRY_STATUSES = new Set(['stale', 'unavailable', 'split_required']);
168
184
 
169
185
  /**
170
186
  * Classify in place and use the same byte-checked claim for fresh and automatic retries.
171
- * @param {{repoRoot: string, ticket: string, file: string, dropDone?: typeof dropDoneIf, once?: boolean, statuses?: Set<string>}} opts
172
- * @returns {{verdict: 'rerun' | 'tampered' | 'counted', outcome: 'dropped' | 'gone' | 'kept'}}
187
+ * @param {{repoRoot: string, ticket: string, file: string, dropDone?: typeof dropDoneIf, once?: boolean, statuses?: Set<string>, cfg?: Record<string, any> | null}} opts
188
+ * @returns {{verdict: 'rerun' | 'tampered' | 'counted', outcome: 'dropped' | 'gone' | 'kept', budgetChange?: {old: number, current: number} | null}}
173
189
  */
174
- function dropFreshDone({ repoRoot, ticket, file, dropDone = dropDoneIf, once = false, statuses }) {
190
+ function dropFreshDone({ repoRoot, ticket, file, dropDone = dropDoneIf, once = false, statuses, cfg = null }) {
175
191
  const markerPath = path.join(queueDir(repoRoot), `${ticket}.done`);
176
192
  /** @type {'rerun' | 'tampered' | 'counted'} */
177
193
  let verdict = 'counted';
@@ -189,7 +205,7 @@ function dropFreshDone({ repoRoot, ticket, file, dropDone = dropDoneIf, once = f
189
205
  // classified IN PLACE: a refusal never moves, renames or touches the marker
190
206
  const text = bytes.toString('utf8');
191
207
  const result = resultOfMarker(repoRoot, text);
192
- verdict = freshVerdict(text, result);
208
+ verdict = freshVerdict(text, result, cfg);
193
209
  if (statuses && !statuses.has(result?.status)) verdict = 'counted';
194
210
  if (verdict !== 'rerun') break;
195
211
  /** @type {unknown} */
@@ -208,7 +224,7 @@ function dropFreshDone({ repoRoot, ticket, file, dropDone = dropDoneIf, once = f
208
224
  if (outcome === 'kept') break;
209
225
  // an allow-list of outcomes: anything else is refused and nothing is queued
210
226
  if (outcome !== 'dropped' && outcome !== 'gone') throw new WorkerError('fresh-claim-unknown', `the claim of the cached result of ${file} gave an unknown outcome; nothing was queued`);
211
- return { verdict, outcome };
227
+ return { verdict, outcome, budgetChange: outcome === 'dropped' ? splitBudgetChange(result, cfg) : null };
212
228
  }
213
229
  return { verdict, outcome: 'kept' };
214
230
  }
@@ -223,14 +239,15 @@ function dropFreshDone({ repoRoot, ticket, file, dropDone = dropDoneIf, once = f
223
239
  * `fresh-needs-reopen` (names `block reopen` and `block waive`) or `fresh-tampered` (names only
224
240
  * the owner's `block close --no-require-reviews`; reopen refuses a tampered state).
225
241
  * B110: a plain cached round-1 `split_required` uses the same claim, with `retry: true`.
226
- * @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, retry?: boolean}} opts
242
+ * @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, retry?: boolean, cfg?: Record<string, any> | null, stderr: {write: (s: string) => unknown}}} opts
227
243
  * @returns {number} the exit code.
228
244
  */
229
- function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf, retry = false }) {
245
+ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf, retry = false, cfg = null, stderr }) {
230
246
  const { ticket } = resolveTicket({ repoRoot, run, block, file, tree: where.tree ?? null });
231
- const { verdict, outcome } = dropFreshDone({ repoRoot, ticket, file, dropDone });
247
+ const { verdict, outcome, budgetChange } = dropFreshDone({ repoRoot, ticket, file, dropDone, cfg });
232
248
  if (outcome === 'dropped' || outcome === 'gone') {
233
249
  const queued = enqueue({ repoRoot, run, block, file, ...where });
250
+ if (budgetChange) writeSafe(stderr, `split_required at budget ${budgetChange.old}; budget is now ${budgetChange.current}: reviewing again\n`);
234
251
  say(outcome === 'dropped' ? { ...queued, ...(retry ? { retry: true } : { fresh: true }) } : queued);
235
252
  return 0;
236
253
  }
@@ -417,6 +434,17 @@ function blockTicketMarker(repoRoot, ticket) {
417
434
  throw new WorkerError('result-unreadable', 'a ticket completion marker cannot be read');
418
435
  }
419
436
 
437
+ /** Progress only: legacy reviewed markers encode approval by absence of open_findings.
438
+ * @param {Record<string, any> | null} marker
439
+ * @returns {'approved' | 'findings' | 'stopped' | 'split_required' | 'unavailable' | null}
440
+ */
441
+ function blockTicketOutcome(marker) {
442
+ if (!marker) return null;
443
+ if (marker.status === 'reviewed') return marker.open_findings === true || marker.approved === false ? 'findings' : 'approved';
444
+ if (marker.status === 'stopped' || marker.status === 'split_required') return marker.status;
445
+ return 'unavailable';
446
+ }
447
+
420
448
  /**
421
449
  * A block wait uses unsigned results only to classify retries; it never reads the signing key.
422
450
  * Re-scan current content on every poll. Deliver unseen findings of the newest earlier ticket
@@ -443,15 +471,15 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
443
471
  if (!present && previous.length > 0) {
444
472
  const newest = previous[previous.length - 1];
445
473
  const marker = blockTicketMarker(repoRoot, newest.ticket);
446
- if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done' };
474
+ if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done', outcome: blockTicketOutcome(marker) };
447
475
  if (marker?.status === 'reviewed' && marker.open_findings === true && live) {
448
476
  if (deliveredHere.has(newest.ticket) || deliverBlockFindings({ repoRoot, block, file, ticket: newest.ticket })) {
449
477
  deliveredHere.add(newest.ticket);
450
- return { file, ticket: newest.ticket, status: 'findings' };
478
+ return { file, ticket: newest.ticket, status: 'findings', outcome: blockTicketOutcome(marker) };
451
479
  }
452
480
  }
453
481
  }
454
- if (!present && previous.length > 0 && live) {
482
+ if (!present && live) {
455
483
  enqueue(opts);
456
484
  present = { ...previous[previous.length - 1], ticket };
457
485
  }
@@ -460,12 +488,17 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
460
488
  dropFreshDone({ repoRoot, ticket, file, dropDone, once: true, statuses: RETRY_STATUSES });
461
489
  marker = blockTicketMarker(repoRoot, ticket);
462
490
  }
463
- return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : marker ? 'done' : 'pending' };
491
+ return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : marker ? 'done' : 'pending', outcome: blockTicketOutcome(marker) };
464
492
  });
465
493
  if (downPolls >= 2) {
466
494
  say({ block, files, done: false, status: 'worker_down' });
467
495
  return 3;
468
496
  }
497
+ const split = files.find((f) => f.outcome === 'split_required');
498
+ if (live && split) {
499
+ say({ block, files, done: false, reason: 'split_required', next: `The owner raises review.budgets for ${split.file}, runs run reload, then review-file ${split.file} --fresh.` });
500
+ return 0;
501
+ }
469
502
  if (live && files.every((f) => f.status === 'done' || f.status === 'findings')) {
470
503
  say({ block, files, done: true });
471
504
  return 0;
@@ -541,10 +574,17 @@ export async function runReviewFile(args, deps = {}) {
541
574
  const tree = recorded === null ? await repoRootOf(cwd) : (recorded.top ?? repoRoot);
542
575
  assertInsideRoot(tree, file); // the path, re-checked in the tree its content is read from
543
576
  const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree };
544
- if (flags.fresh === true) return freshTicket({ repoRoot, run: worker.run, block, file, where, say, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
577
+ let cfg = null;
578
+ try {
579
+ cfg = configInForce(await readRun(worker.run));
580
+ } catch {
581
+ // No readable snapshot: retain the existing cache and --fresh rules.
582
+ }
583
+ if (flags.fresh === true) return freshTicket({ repoRoot, run: worker.run, block, file, where, say, cfg, stderr, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
545
584
  const queued = enqueue({ repoRoot, run: worker.run, block, file, ...where });
546
- if (queued.status === 'done' && ['unavailable', 'split_required'].includes(doneResult(repoRoot, queued.ticket)?.status)) {
547
- return freshTicket({ repoRoot, run: worker.run, block, file, where, say, retry: true, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
585
+ const cached = queued.status === 'done' ? doneResult(repoRoot, queued.ticket) : null;
586
+ if (cached && (['unavailable', 'split_required'].includes(cached.status) || splitBudgetChange(cached, cfg))) {
587
+ return freshTicket({ repoRoot, run: worker.run, block, file, where, say, retry: true, cfg, stderr, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
548
588
  }
549
589
  say(queued);
550
590
  return 0;
package/src/cli/run.mjs CHANGED
@@ -31,14 +31,18 @@
31
31
  import path from 'node:path';
32
32
  import { reloadFailureText, reloadWorkspace } from '../autopilot/limits.mjs';
33
33
  import { DEFAULT_CONFIG_FILENAME, loadProjectConfig, projectRootFor, slugFor } from '../config/load.mjs';
34
+ import { reviewWorkers } from '../config/validate.mjs';
34
35
  import { budgetUsdOf, runSpend } from '../ledger/spend.mjs';
35
36
  import { appendRow, readAllRows } from '../ledger/write.mjs';
36
37
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
37
38
  import { StateError } from '../state/paths.mjs';
39
+ import { configInForce } from '../state/config-snapshot.mjs';
38
40
  import { endRun, processStartTime, readRun, reattachWorker, RUN_MODES, startRun, stopPinnedWorker } from '../state/run.mjs';
39
41
  import { writeSafe } from '../util/redact.mjs';
42
+ import { withLock } from '../util/file-locks.mjs';
40
43
  import { sweepRoots, tmpBase } from '../util/tmp.mjs';
41
44
  import { launchWorker, repinWorker } from '../worker/loop.mjs';
45
+ import { liveWorker, workerRegistry } from '../worker/queue.mjs';
42
46
  import { repoRootOf } from '../worker/ticket.mjs';
43
47
 
44
48
  /**
@@ -69,15 +73,18 @@ const ledgerWriter = (slug) => (/** @type {Record<string, any>} */ row) => appen
69
73
  * A worker that `run start` launched but could not pin must not outlive the command as an orphan:
70
74
  * stop it through the pinned-worker stopper (pid > 1, start time re-checked right before the
71
75
  * signal, so a recycled pid is never signalled) and name the pid on stderr. `startedAt` is the
72
- * start time read right after the launch; null means the process was already gone.
73
- * @param {{pid: number, startedAt: string | null}} launched
76
+ * start time read right after the launch; null means the process was already gone. When that
77
+ * probe threw (`undefined`), use the OS probe to obtain and re-check the identity for cleanup.
78
+ * @param {{pid: number, startedAt: string | null | undefined}} launched
74
79
  * @param {{stopWorker: StopWorker, probe?: import('../state/run.mjs').StartTimeProbe, err: (s: string) => void}} deps
75
80
  */
76
81
  async function stopLaunched({ pid, startedAt }, { stopWorker, probe, err }) {
77
82
  /** @type {{stopped: boolean, reason?: string}} */
78
83
  let outcome;
79
84
  try {
80
- outcome = startedAt === null ? { stopped: false, reason: 'not running' } : await stopWorker({ pid, started_at: startedAt }, { ...(probe ? { probe } : {}) });
85
+ const cleanupProbe = startedAt === undefined ? processStartTime : probe;
86
+ if (startedAt === undefined) startedAt = await processStartTime(pid);
87
+ outcome = startedAt === null ? { stopped: false, reason: 'not running' } : await stopWorker({ pid, started_at: startedAt }, { ...(cleanupProbe ? { probe: cleanupProbe } : {}) });
81
88
  } catch (thrown) {
82
89
  outcome = { stopped: false, reason: thrown?.message ?? String(thrown) };
83
90
  }
@@ -107,13 +114,42 @@ export async function runRun(args, deps = {}) {
107
114
  return 2;
108
115
  }
109
116
  try {
110
- const { flags } = parseFlags(rest, { values: ['run', 'cwd', 'engine', 'worker-pid', 'mode'], booleans: ['reattach'] });
117
+ const { flags } = parseFlags(rest, { values: ['run', 'cwd', 'engine', 'worker-pid', 'mode'], booleans: ['reattach', 'add-worker'] });
111
118
  const runId = typeof flags.run === 'string' ? flags.run : undefined;
112
119
  const workerPid = intFlag(flags['worker-pid'], 'worker-pid');
113
120
  const mode = typeof flags.mode === 'string' ? flags.mode : undefined;
114
- if (mode !== undefined && (sub !== 'start' || flags.reattach)) throw new StateError('usage', '--mode is set once, by a new run start (the mode is fixed for the run)');
121
+ if (mode !== undefined && (sub !== 'start' || flags.reattach || flags['add-worker'])) throw new StateError('usage', '--mode is set once, by a new run start (the mode is fixed for the run)');
115
122
  if (mode !== undefined && !RUN_MODES.includes(mode)) throw new StateError('usage', `--mode must be one of ${RUN_MODES.join(', ')}`);
116
123
 
124
+ if (flags['add-worker']) {
125
+ if (sub !== 'start' || !runId || flags.reattach || workerPid !== undefined) throw new StateError('usage', 'run start --add-worker needs --run and cannot use --reattach or --worker-pid');
126
+ if (!startWorker) throw new StateError('usage', 'run start --add-worker needs a worker launcher');
127
+ return await withLock(`worker-add:${runId}`, async () => {
128
+ const record = await readRun(runId);
129
+ if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
130
+ const repoRoot = await repoRootOf(record.workspace);
131
+ const loaded = record.config ? { ok: true, config: configInForce(record) } : await loadProjectConfig(record.workspace);
132
+ if (!loaded.ok) throw new StateError('config', 'the project config cannot be loaded');
133
+ const limit = reviewWorkers(loaded.config);
134
+ const entries = workerRegistry(repoRoot, { prune: true });
135
+ const legacy = liveWorker(repoRoot);
136
+ const count = entries.filter((e) => e.live).length + (legacy && !entries.some((e) => e.worker?.pid === legacy.pid && e.worker.start_time === legacy.start_time) ? 1 : 0);
137
+ if (count >= limit) throw new StateError('worker-running', `review.workers limit ${limit} reached`);
138
+ const launched = await startWorker({ runId, workspace: record.workspace });
139
+ /** @type {string | null | undefined} */
140
+ let startedAt;
141
+ try {
142
+ startedAt = await (probe ?? processStartTime)(launched.pid);
143
+ await repinWorker({ runId, repoRoot: launched.repoRoot, expectPid: launched.pid, add: true, writeRow: ledgerWriter(record.project), ...(probe ? { probe } : {}) });
144
+ } catch (thrown) {
145
+ await stopLaunched({ pid: launched.pid, startedAt }, { stopWorker: stopWorker ?? stopPinnedWorker, probe, err });
146
+ throw thrown;
147
+ }
148
+ out(`run ${runId}: added worker pid ${launched.pid} (review.workers limit ${limit})\n`);
149
+ return 0;
150
+ });
151
+ }
152
+
117
153
  if (sub === 'start' && flags.reattach) {
118
154
  if (!runId) throw new StateError('usage', 'run start --reattach needs --run');
119
155
  const { project, workspace } = await readRun(runId);
@@ -159,7 +195,8 @@ export async function runRun(args, deps = {}) {
159
195
  const record = await readRun(runId);
160
196
  const { run_id, status, engine, blocks, orphans } = record;
161
197
  const spend = await spendOf(record);
162
- out(`${JSON.stringify({ run_id, status, engine, worker_pinned: Boolean(record.worker), blocks, orphans, ...spend }, null, 2)}\n`);
198
+ const awaiting = Object.keys(blocks).filter((id) => blocks[id].status === 'awaiting_human_check').sort();
199
+ out(`${JSON.stringify({ run_id, status, engine, worker_pinned: Boolean(record.worker), blocks, orphans, ...spend, ...(awaiting.length > 0 ? { awaiting_human_check: awaiting } : {}) }, null, 2)}\n`);
163
200
  // on stderr, so stdout stays one JSON document
164
201
  const n = spend.unknown_usd_sessions;
165
202
  if (n > 0) err(`${n} session${n === 1 ? ' has' : 's have'} no known price; the budget does not count ${n === 1 ? 'it' : 'them'}\n`);
@@ -221,7 +258,7 @@ async function reloadVerb(runId, { out, err }) {
221
258
  }
222
259
 
223
260
  const USAGE =
224
- 'usage: code-forge run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>] [--mode plan|brief] | start --reattach --run <id> [--worker-pid <pid>] | status --run <id> | reload --run <id> | end --run <id>\n';
261
+ 'usage: code-forge run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>] [--mode plan|brief] | start --add-worker --run <id> | start --reattach --run <id> [--worker-pid <pid>] | status --run <id> | reload --run <id> | end --run <id>\n';
225
262
 
226
263
  /** @param {string[]} args @returns {Promise<number>} */
227
264
  export default async function run(args) {
@@ -65,7 +65,7 @@ const USAGE = 'usage: code-forge status [--run <id>] [--json]\n';
65
65
  const STATUS_MAX_LINES = 10;
66
66
 
67
67
  /**
68
- * @typedef {import('../state/brief-status.mjs').RunStatus} RunStatus
68
+ * @typedef {import('../state/brief-status.mjs').RunStatus & {awaiting_human_check?: string[]}} RunStatus
69
69
  */
70
70
 
71
71
  /** @param {string} p @returns {Promise<string>} */
@@ -108,7 +108,9 @@ async function statusOf(record) {
108
108
  if (list) list.push(r);
109
109
  else byBlock.set(r.block, [r]);
110
110
  }
111
- return briefStatus({ record, rows, budget, findingsOf: (block) => openFindingsOf({ block, runId, mine: byBlock.get(block) ?? [], rows: all, key }) });
111
+ const awaiting = Object.keys(record.blocks ?? {}).filter((id) => record.blocks[id].status === 'awaiting_human_check').sort();
112
+ return { ...briefStatus({ record, rows, budget, findingsOf: (block) => openFindingsOf({ block, runId, mine: byBlock.get(block) ?? [], rows: all, key }) }),
113
+ ...(awaiting.length > 0 ? { awaiting_human_check: awaiting } : {}) };
112
114
  }
113
115
 
114
116
  /**
@@ -152,12 +154,14 @@ export function runLines(s) {
152
154
  const spent = s.spent_usd === null ? '$?' : `${usd(s.spent_usd)}${(s.unknown_usd_sessions ?? 0) > 0 ? '+?' : ''}`;
153
155
  const lines = [`run ${s.run_id} · ${s.status ?? '-'} · ${s.mode} · spent ${spent}${s.budget_usd !== null ? ` of ${usd(s.budget_usd)}` : ''}`];
154
156
  const tally = s.closed + s.stopped > 0 ? `closed ${s.closed} · stopped ${s.stopped}` : null;
155
- const room = STATUS_MAX_LINES - 1 - (tally === null ? 0 : 1);
157
+ const awaiting = s.awaiting_human_check?.length ? `awaiting_human_check: ${JSON.stringify(s.awaiting_human_check)}` : null;
158
+ const room = STATUS_MAX_LINES - 1 - (tally === null ? 0 : 1) - (awaiting === null ? 0 : 1);
156
159
  const shown = s.blocks.length <= room ? s.blocks : s.blocks.slice(0, room - 1);
157
160
  for (const b of shown) {
158
161
  lines.push(`${b.id} ${b.state} ${b.level ?? '-'} · ${b.model ?? '-'} · gate ${b.gate.result}${b.gate.name ? ` (${b.gate.name})` : ''} · findings ${b.findings}`);
159
162
  }
160
163
  if (shown.length < s.blocks.length) lines.push(`… +${s.blocks.length - shown.length} more blocks`);
164
+ if (awaiting !== null) lines.push(awaiting);
161
165
  if (tally !== null) lines.push(tally);
162
166
  return lines;
163
167
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `code-forge upgrade [--source <path>]` — re-points every recorded install at a skill source
2
+ * `code-forge upgrade [--source <path>] [--plain]` — re-points every recorded install at a skill source
3
3
  * (plan §10.3 acceptance: "`upgrade` re-points all 3 recorded links"; research/installer-patterns.md
4
4
  * point 8, "a self `upgrade` command"). A symlink record is re-created to point at the new
5
5
  * source; a copy record is re-copied from it. With no `--source`, defaults to this running
@@ -8,33 +8,93 @@
8
8
  * (tests, or a power user working from a git checkout of the skill).
9
9
  */
10
10
 
11
- import { stat } from 'node:fs/promises';
11
+ import { readFileSync } from 'node:fs';
12
+ import { readFile, stat } from 'node:fs/promises';
12
13
  import os from 'node:os';
13
14
  import path from 'node:path';
14
15
  import { fileURLToPath } from 'node:url';
16
+ import { stripVTControlCharacters } from 'node:util';
15
17
  import { writeSafe } from '../util/redact.mjs';
16
- import { installsPath, upgradeAll } from '../install/link.mjs';
18
+ import { installsPath, readInstalls, upgradeAll } from '../install/link.mjs';
17
19
 
18
- const USAGE = 'usage: code-forge upgrade [--source <path>]\n';
20
+ const USAGE = 'usage: code-forge upgrade [--source <path>] [--plain]\n';
21
+ const LOGO = ' ___ __ __ ___ ___ __ __ __ ___\n| | | | \\ |__ __ |__ | | |__/ | _ |__\n|___ |__| |__/ |___ | |__| | \\ |__] |___\n';
22
+ const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
23
+ const CHANGELOG_PATH = fileURLToPath(new URL('../../CHANGELOG.md', import.meta.url));
19
24
 
20
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
26
  /** This package's own skill directory: `<package root>/skill`, i.e. two levels above `src/cli/`. */
22
27
  export const DEFAULT_SKILL_SOURCE = path.join(__dirname, '..', '..', 'skill');
23
28
 
24
29
  /**
25
- * Strict parse: `[--source <path>]` only.
30
+ * Strict parse: optional source and plain flags, each at most once, in either order.
26
31
  * @param {string[]} args
27
- * @returns {{source?: string} | null}
32
+ * @returns {{source?: string, plain?: boolean} | null}
28
33
  */
29
34
  export function parseUpgradeArgs(args) {
30
- if (args.length === 0) {
31
- return {};
35
+ /** @type {{source?: string, plain?: boolean}} */
36
+ const parsed = {};
37
+ for (let i = 0; i < args.length; i++) {
38
+ if (args[i] === '--plain' && !parsed.plain) {
39
+ parsed.plain = true;
40
+ } else if (args[i] === '--source' && parsed.source === undefined) {
41
+ const value = args[++i];
42
+ if (!value || value.startsWith('--')) return null;
43
+ parsed.source = value;
44
+ } else {
45
+ return null;
46
+ }
32
47
  }
33
- const [flag, value, ...extra] = args;
34
- if (flag !== '--source' || typeof value !== 'string' || value.length === 0 || extra.length > 0) {
35
- return null;
48
+ return parsed;
49
+ }
50
+
51
+ /** Semver ordering, including numeric prerelease identifiers (rc.2 precedes rc.10).
52
+ * @param {string} a @param {string} b @returns {number}
53
+ */
54
+ function compareVersions(a, b) {
55
+ const [coreA, preA] = a.split('+')[0].split(/-(.*)/);
56
+ const [coreB, preB] = b.split('+')[0].split(/-(.*)/);
57
+ const coreOrder = coreA.localeCompare(coreB, 'en', { numeric: true });
58
+ if (coreOrder) return coreOrder;
59
+ if (preA === undefined) return preB === undefined ? 0 : 1;
60
+ if (preB === undefined) return -1;
61
+ const idsA = preA.split('.');
62
+ const idsB = preB.split('.');
63
+ for (let i = 0; i < Math.max(idsA.length, idsB.length); i++) {
64
+ if (idsA[i] === undefined) return -1;
65
+ if (idsB[i] === undefined) return 1;
66
+ if (idsA[i] === idsB[i]) continue;
67
+ const numericA = /^\d+$/.test(idsA[i]);
68
+ const numericB = /^\d+$/.test(idsB[i]);
69
+ if (numericA !== numericB) return numericA ? -1 : 1;
70
+ return numericA ? Number(idsA[i]) - Number(idsB[i]) : (idsA[i] < idsB[i] ? -1 : 1);
36
71
  }
37
- return { source: value };
72
+ return 0;
73
+ }
74
+
75
+ /** Read only bullets in the exact release section; unavailable notes are a silent omission.
76
+ * @param {string} file @param {string} version @returns {Promise<string[]>}
77
+ */
78
+ async function releaseBullets(file, version) {
79
+ let text;
80
+ try {
81
+ text = await readFile(file, 'utf8');
82
+ } catch {
83
+ return [];
84
+ }
85
+ const bullets = [];
86
+ let inRelease = false;
87
+ for (const line of text.split(/\r?\n/)) {
88
+ if (/^##\s/.test(line)) {
89
+ if (inRelease) break;
90
+ inRelease = /^## \[([^\]]+)\](?:\s|$)/.exec(line)?.[1] === version;
91
+ } else if (inRelease && line.startsWith('- ')) {
92
+ const chars = Array.from(stripVTControlCharacters(line.slice(2)));
93
+ bullets.push(chars.length > 100 ? `${chars.slice(0, 97).join('')}...` : chars.join(''));
94
+ if (bullets.length === 5) break;
95
+ }
96
+ }
97
+ return bullets;
38
98
  }
39
99
 
40
100
  /**
@@ -44,6 +104,8 @@ export function parseUpgradeArgs(args) {
44
104
  * @param {{write: (s: string) => unknown}} [deps.stderr]
45
105
  * @param {string} [deps.home]
46
106
  * @param {string} [deps.defaultSource]
107
+ * @param {string} [deps.packageVersion] - running package version, injectable for tests.
108
+ * @param {string} [deps.changelogPath] - packaged release notes, injectable for tests.
47
109
  * @param {string} [deps.cwd] - a relative `--source` is resolved against this, never against the
48
110
  * link's own directory (which `symlink()` would otherwise silently use).
49
111
  * @returns {Promise<number>}
@@ -55,11 +117,13 @@ export async function runUpgrade(
55
117
  stderr = process.stderr,
56
118
  home = os.homedir(),
57
119
  defaultSource = DEFAULT_SKILL_SOURCE,
120
+ packageVersion = PACKAGE_VERSION,
121
+ changelogPath = CHANGELOG_PATH,
58
122
  cwd = process.cwd(),
59
123
  } = {},
60
124
  ) {
61
- const out = (/** @type {string} */ s) => writeSafe(stdout, s);
62
- const err = (/** @type {string} */ s) => writeSafe(stderr, s);
125
+ const out = (/** @type {string} */ s) => writeSafe(stdout, stripVTControlCharacters(s));
126
+ const err = (/** @type {string} */ s) => writeSafe(stderr, stripVTControlCharacters(s));
63
127
 
64
128
  const parsed = parseUpgradeArgs(args);
65
129
  if (!parsed) {
@@ -84,13 +148,29 @@ export async function runUpgrade(
84
148
  return 1;
85
149
  }
86
150
 
87
- const updated = await upgradeAll({ installsFile: installsPath(home), source: resolvedSource });
151
+ const installsFile = installsPath(home);
152
+ const previous = await readInstalls(installsFile);
153
+ const updated = await upgradeAll({ installsFile, source: resolvedSource, version: packageVersion });
154
+ if (!parsed.plain) {
155
+ const versions = previous.map((r) => r.version).filter((v) => v !== undefined).sort(compareVersions);
156
+ const oldest = versions[0];
157
+ const change = oldest === undefined ? 'first versioned upgrade'
158
+ : oldest === packageVersion ? `already on ${packageVersion}` : `${oldest} -> ${packageVersion}`;
159
+ out(`${LOGO}code-forge ${packageVersion}\n${change}\n`);
160
+ }
88
161
  for (const record of updated) {
89
162
  out(`upgraded ${record.harness} (${record.scope}) -> ${record.source}\n`);
90
163
  }
91
164
  if (updated.length === 0) {
92
165
  out('nothing to upgrade\n');
93
166
  }
167
+ if (!parsed.plain) {
168
+ const notesPath = path.resolve(changelogPath);
169
+ const bullets = await releaseBullets(notesPath, packageVersion);
170
+ if (bullets.length > 0) {
171
+ out(`What's new in ${packageVersion}:\n${bullets.join('\n')}\nFull notes: ${notesPath}\n`);
172
+ }
173
+ }
94
174
  return 0;
95
175
  }
96
176
 
@@ -513,7 +513,12 @@ function gateMessages(key, value) {
513
513
  }
514
514
 
515
515
  function checkGatesNotArgvArray(cfg) {
516
- return GATE_KEYS.flatMap((key) => gateMessages(key, cfg?.gates?.[key]).map((message) => ({ rule: 'gates-not-argv-array', severity: 'error', message })));
516
+ const messages = GATE_KEYS.flatMap((key) => gateMessages(key, cfg?.gates?.[key]));
517
+ if (Object.hasOwn(cfg?.gates ?? {}, 'repeat_new_tests')) {
518
+ const n = cfg.gates.repeat_new_tests;
519
+ if (!Number.isInteger(n) || n < 2 || n > 10) messages.push('gates.repeat_new_tests must be an integer 2..10');
520
+ }
521
+ return messages.map((message) => ({ rule: 'gates-not-argv-array', severity: 'error', message }));
517
522
  }
518
523
 
519
524
  // ── 10. secret-looking-value (ERROR) ────────────────────────────────────────
@@ -1169,3 +1174,10 @@ export { schema as configSchema };
1169
1174
  export function reviewFileLoop(cfg) {
1170
1175
  return cfg?.review?.file_loop ?? 'wait';
1171
1176
  }
1177
+
1178
+ /** Maximum workers for a validated config (the schema restricts it to 1..4).
1179
+ * @param {Record<string, any>} cfg @returns {number}
1180
+ */
1181
+ export function reviewWorkers(cfg) {
1182
+ return cfg?.review?.workers ?? 1;
1183
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Acceptance-clause coverage (plan §4.6 row 4, O17): "every clause names ≥ 1 test id; each named
2
+ * Acceptance-clause coverage (plan §4.6 row 4, O17): "every code clause names ≥ 1 test id; each named
3
3
  * test exists in the tree and passed in this gate run (parsed from the runner output)." This is
4
4
  * DETERMINISTIC — S1's `scope` question (§3.2) judges only whether hunks OUTSIDE the named tests
5
5
  * are unrelated; whether a named test id actually passed is a fact, never a model's opinion.
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  /**
15
- * @typedef {{clause: string, tests: string[]}} AcceptanceClause
15
+ * @typedef {{clause: string, kind?: string, tests?: string[]}} AcceptanceClause
16
16
  * @typedef {{ok: true} | {ok: false, reason: string, clause: string, test: string}} AcceptanceResult
17
17
  */
18
18
 
@@ -57,7 +57,7 @@ export function parseTapPassed(output) {
57
57
  }
58
58
 
59
59
  /**
60
- * @param {ReadonlyArray<AcceptanceClause>} clauses - each must name ≥ 1 test id.
60
+ * @param {ReadonlyArray<AcceptanceClause>} clauses - code clauses name ≥ 1 test id; human checks name none.
61
61
  * @param {Set<string> | ReadonlyArray<string>} passed - test ids that passed in THIS gate run.
62
62
  * @param {Set<string> | ReadonlyArray<string>} [failed] - test ids that FAILED in this gate run.
63
63
  * `parseTapPassed` flattens every file's/describe-block's TAP subtests into one global name set,
@@ -77,6 +77,10 @@ export function checkAcceptance(clauses, passed, failed = new Set()) {
77
77
  for (const entry of clauses) {
78
78
  const clause = entry?.clause;
79
79
  const tests = entry?.tests;
80
+ if (entry?.kind === 'human_check' && typeof clause === 'string' && clause.length > 0) {
81
+ if (Object.hasOwn(entry, 'tests')) return { ok: false, reason: 'human_check clause must not name tests', clause, test: '' };
82
+ continue;
83
+ }
80
84
  if (typeof clause !== 'string' || clause.length === 0 || !Array.isArray(tests) || tests.length === 0) {
81
85
  return { ok: false, reason: 'clause names no test id', clause: String(clause ?? ''), test: '' };
82
86
  }