@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
@@ -282,6 +282,7 @@ export const FORBIDDEN = Object.freeze(
282
282
  * - `--no-require-reviews` — the human's way out of `block close`'s per-file review check;
283
283
  * - `--remove-worktree` (B62a) — `block close` removing the block's worktree, the orchestrator's
284
284
  * alone. `block close` itself is not on this list, so the flag is listed on its own.
285
+ * - `code-forge block human-pass` (B137) — passing a human device check is human-only.
285
286
  * @type {ReadonlyArray<ForbiddenEntry>}
286
287
  */
287
288
  export const CODER_ONLY_FORBIDDEN = Object.freeze(
@@ -346,6 +347,25 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
346
347
  prefixes: [['code-forge', 'block', 'ack-waivers'], ['forge', 'block', 'ack-waivers']],
347
348
  description: 'Acknowledge agent waivers — human-only, confirmed in the human terminal (B124b)',
348
349
  },
350
+ {
351
+ id: 'code-forge-block-human-pass-from-coder',
352
+ kind: 'anyPrefix',
353
+ prefixes: [['code-forge', 'block', 'human-pass'], ['forge', 'block', 'human-pass']],
354
+ description: 'Pass a human device check — human-only, confirmed in the human terminal (B137)',
355
+ },
356
+ {
357
+ id: 'code-forge-add-worker-from-coder',
358
+ kind: 'commandFlag',
359
+ command: ['code-forge', 'run', 'start'],
360
+ groups: [{ anyOf: ['--add-worker'] }],
361
+ description: 'Add a review worker to a run — the orchestrator only (B107)',
362
+ },
363
+ {
364
+ id: 'code-forge-block-amend-acceptance-from-coder',
365
+ kind: 'anyPrefix',
366
+ prefixes: [['code-forge', 'block', 'amend-acceptance'], ['forge', 'block', 'amend-acceptance']],
367
+ description: 'Correct a block acceptance list — human-only, confirmed in the human terminal (B143)',
368
+ },
349
369
  ].map(freezeEntry),
350
370
  );
351
371
 
@@ -805,6 +825,194 @@ export function renderForCodex(list = FORBIDDEN) {
805
825
 
806
826
  // ── Transcripts ──────────────────────────────────────────────────────────────
807
827
 
828
+ /**
829
+ * Mask inert, non-shell interpreter bodies for command/contains rules only. Walk shell
830
+ * words and queued heredocs once; unknown commands, malformed quotes and unfinished
831
+ * heredocs stay fail closed. Decode at most one shell -c wrapper; deeper wrappers
832
+ * stay whole. This deliberately does not evaluate shell expansions.
833
+ * @param {string} command
834
+ * @param {boolean} [unwrapShell]
835
+ * @returns {string}
836
+ */
837
+ function transcriptCommandText(command, unwrapShell = true) {
838
+ if (unwrapShell) {
839
+ const wrapper = /^\s*(?:[^\s'"\\;|&()<>`$]+\/)?(?:sh|bash|zsh|dash|ksh)(?:[ \t]+-[li]+)*[ \t]+-[li]*c[ \t]+(['"])/.exec(command);
840
+ if (wrapper) {
841
+ let quote = wrapper[1];
842
+ const parts = [];
843
+ let i = wrapper[0].length;
844
+ while (i < command.length) {
845
+ const char = command[i++];
846
+ if (char === quote) {
847
+ // A shell word can splice opposite-quoted text into its enclosing
848
+ // quotes, as in "script"'literal'"rest". Require the enclosing
849
+ // quote again at the end; uncertain suffixes still scan whole.
850
+ if (command[i] === (quote === '"' ? "'" : '"')) {
851
+ quote = command[i++];
852
+ continue;
853
+ }
854
+ if (quote !== wrapper[1] || command.slice(i).trim() !== '') return command;
855
+ const prefix = command.slice(0, wrapper[0].length - 1);
856
+ return prefix + transcriptCommandText(parts.join(''), false);
857
+ }
858
+ // Within double quotes, other backslashes are literal; single quotes
859
+ // preserve every character. A backslash-newline pair disappears.
860
+ if (quote === '"' && char === '\\' && /["\\$`\n]/.test(command[i] ?? '')) {
861
+ const escaped = command[i++];
862
+ if (escaped !== '\n') parts.push(escaped);
863
+ } else {
864
+ // Keep the source segment's quote context until after this check:
865
+ // the parent shell expands these even inside an inner quoted heredoc.
866
+ // Single-quoted splices and the escaped characters above are literal.
867
+ // Unquoted splices already fail closed at the closing-quote check.
868
+ if (quote === '"' && (char === '`' || (char === '$' && command[i] === '('))) return command;
869
+ parts.push(char);
870
+ }
871
+ }
872
+ return command;
873
+ }
874
+ }
875
+ const spawnWord = /\b(?:exec|execSync|execFile|execFileSync|spawn|spawnSync|fork|system|popen|subprocess|child_process|Popen)\b/;
876
+ const interpreter = /^(node|python|python3|ruby|perl)(?:\d+(?:\.\d+)*)?$/;
877
+ /** @param {string} body */
878
+ const hasShellSubstitution = (body) => {
879
+ for (let i = 0; i < body.length; i += 1) {
880
+ // Skip escaped characters in pairs so even backslash runs still expand.
881
+ if (body[i] === '\\') { i += 1; continue; }
882
+ if (body[i] === '`' || (body[i] === '$' && body[i + 1] === '(')) return true;
883
+ }
884
+ return false;
885
+ };
886
+ /** @param {string} body @param {string} language @param {boolean} shellExpands */
887
+ const hasSpawn = (body, language, shellExpands) => spawnWord.test(body)
888
+ || (shellExpands && hasShellSubstitution(body))
889
+ || ((language === 'ruby' || language === 'perl') && /`|\$\(|%x/.test(body))
890
+ || (language === 'perl' && /\bqx\b/.test(body));
891
+ const evalFlags = new Set(['-e', '-c', '--eval', '-p', '--print']);
892
+ /** @type {{start: number, end: number}[]} */
893
+ const inert = [];
894
+ /** @type {{delimiter: string, tabs: boolean, language: string, shellExpands: boolean}[]} */
895
+ let heredocs = [];
896
+ let state = 'command';
897
+ let language = '';
898
+ let evalNext = false;
899
+ let envValue = false;
900
+ let redirect = '';
901
+ let i = 0;
902
+ while (i < command.length) {
903
+ const char = command[i];
904
+ if (char === '\n') {
905
+ i += 1;
906
+ for (const doc of heredocs) {
907
+ const start = i;
908
+ let closed = false;
909
+ while (i < command.length) {
910
+ const newline = command.indexOf('\n', i);
911
+ const end = newline < 0 ? command.length : newline;
912
+ const line = command.slice(i, end);
913
+ if ((doc.tabs ? line.replace(/^\t+/, '') : line) === doc.delimiter) {
914
+ if (doc.language && !hasSpawn(command.slice(start, i), doc.language, doc.shellExpands)) inert.push({ start, end: i });
915
+ i = newline < 0 ? end : end + 1;
916
+ closed = true;
917
+ break;
918
+ }
919
+ i = newline < 0 ? end : end + 1;
920
+ }
921
+ if (!closed) return command;
922
+ }
923
+ heredocs = [];
924
+ state = 'command';
925
+ evalNext = false;
926
+ redirect = '';
927
+ continue;
928
+ }
929
+ if (/\s/.test(char)) { i += 1; continue; }
930
+ if (char === '#') {
931
+ const newline = command.indexOf('\n', i);
932
+ i = newline < 0 ? command.length : newline;
933
+ continue;
934
+ }
935
+ if (';|&()'.includes(char)) {
936
+ state = 'command';
937
+ evalNext = false;
938
+ i += 1;
939
+ continue;
940
+ }
941
+ if (char === '<' || char === '>') {
942
+ redirect = char;
943
+ i += 1;
944
+ if (command[i] === char) { redirect += char; i += 1; }
945
+ if (redirect === '<<' && command[i] === '-') { redirect += '-'; i += 1; }
946
+ // Here-strings and other uncertain shell syntax are never exempted.
947
+ if (command[i] === '<') return command;
948
+ continue;
949
+ }
950
+ const start = i;
951
+ const parts = [];
952
+ let quotes = 0;
953
+ let quote = '';
954
+ let escaped = false;
955
+ let unquoted = false;
956
+ while (i < command.length && !/[\s;|&()<>]/.test(command[i])) {
957
+ const next = command[i++];
958
+ if (next === "'" || next === '"') {
959
+ quotes += 1;
960
+ quote = next;
961
+ let closed = false;
962
+ while (i < command.length) {
963
+ const inside = command[i++];
964
+ if (inside === next) { closed = true; break; }
965
+ if (inside === '\\' && next === '"' && i < command.length) parts.push(command[i++]);
966
+ else parts.push(inside);
967
+ }
968
+ if (!closed) return command;
969
+ } else {
970
+ unquoted = true;
971
+ if (next === '\\') escaped = true;
972
+ parts.push(next === '\\' && i < command.length ? command[i++] : next);
973
+ }
974
+ }
975
+ const word = parts.join('');
976
+ if (redirect) {
977
+ if (redirect.startsWith('<<')) heredocs.push({
978
+ delimiter: word, tabs: redirect === '<<-', language: state === 'interpreter' ? language : '',
979
+ shellExpands: quotes === 0 && !escaped,
980
+ });
981
+ redirect = '';
982
+ continue;
983
+ }
984
+ if (state === 'env') {
985
+ if (envValue) { envValue = false; continue; }
986
+ if (/^[A-Za-z_][A-Za-z_0-9]*=/.test(word) || ['-i', '--ignore-environment'].includes(word)) continue;
987
+ if (['-u', '--unset', '-C', '--chdir'].includes(word)) { envValue = true; continue; }
988
+ if (word === '--') { state = 'command'; continue; }
989
+ if (word.startsWith('-')) { state = 'other'; continue; }
990
+ state = 'command';
991
+ }
992
+ if (state === 'command') {
993
+ if (/^[A-Za-z_][A-Za-z_0-9]*=/.test(word)) continue;
994
+ const name = word.slice(word.lastIndexOf('/') + 1);
995
+ language = interpreter.exec(name)?.[1] ?? '';
996
+ state = name === 'env' ? 'env' : language ? 'interpreter' : 'other';
997
+ } else if (state === 'interpreter') {
998
+ if (evalNext) {
999
+ if (quotes === 1 && !unquoted && !hasSpawn(command.slice(start, i), language, quote === '"')) inert.push({ start, end: i });
1000
+ evalNext = false;
1001
+ } else if (evalFlags.has(word)) evalNext = true;
1002
+ else if (!word.startsWith('-') || word === '--') state = 'other';
1003
+ }
1004
+ }
1005
+ if (heredocs.length > 0) return command;
1006
+ const chunks = [];
1007
+ let end = 0;
1008
+ for (const span of inert) {
1009
+ chunks.push(command.slice(end, span.start), ' ');
1010
+ end = span.end;
1011
+ }
1012
+ chunks.push(command.slice(end));
1013
+ return chunks.join('');
1014
+ }
1015
+
808
1016
  /**
809
1017
  * Scan captured coder output (subprocess stdout, Solo `search_output` text, a harness log) for
810
1018
  * any entry of `list`, line by line: each line is split into shell-ish tokens (whitespace,
@@ -815,7 +1023,18 @@ export function renderForCodex(list = FORBIDDEN) {
815
1023
  * Codex `exec --json` logs (every non-empty line an object with a string `type`, first event
816
1024
  * `thread.started`) scan command_execution commands and file_change paths as writes. Other
817
1025
  * items scan whole, except agent_message and reasoning; a command_execution's aggregated_output
818
- * is never scanned; non-item events are skipped. Any other log uses the plain-text grep above, unchanged.
1026
+ * is never scanned; non-item events are skipped. For string commands, node/python/ruby/perl
1027
+ * heredocs and quoted -e/-c/--eval/-p/--print bodies without a process-spawn word are omitted
1028
+ * from command and contains rules only; path rules still scan the whole command. Backticks and
1029
+ * $( count when unescaped and the shell expands the body (unquoted heredoc delimiter or double-quoted inline
1030
+ * script), or for Ruby/Perl; %x counts for Ruby/Perl, and qx for Perl. Quoted or backslash-escaped
1031
+ * heredoc delimiters and single-quoted inline scripts prevent shell expansion. One sh/bash/zsh/
1032
+ * dash/ksh -c wrapper (including -lc/-ic and -l -c) with a single quoted script (including
1033
+ * opposite-quoted splices) is decoded first; its prefix remains scanned. Unescaped parent-shell
1034
+ * substitutions in double-quoted wrapper segments keep the entire command scanned; single-quoted
1035
+ * splices are literal at that layer. Unquoted splices, nested wrappers,
1036
+ * malformed wrappers, shell/unknown heredocs and
1037
+ * unterminated bodies scan whole. Any other log uses the plain-text grep above, unchanged.
819
1038
  *
820
1039
  * Linear in the line length: `contains` and `path` entries are evaluated ONCE per line over its
821
1040
  * tokens (a `write` path entry hits when a write command starts before a token naming the path);
@@ -850,13 +1069,17 @@ export function scanTranscript(text, list = FORBIDDEN) {
850
1069
  const hits = [];
851
1070
  lines
852
1071
  .forEach((lineText, index) => {
1072
+ let commandText;
853
1073
  if (events !== null) {
854
1074
  const event = events[index];
855
1075
  if (!event?.type.startsWith('item.') || !event.item) return;
856
1076
  const item = event.item;
857
1077
  if (item.type === 'agent_message' || item.type === 'reasoning') return;
858
1078
  // a command that is not a string is scanned with the rest of its item (fail closed), its output left out
859
- if (item.type === 'command_execution') lineText = typeof item.command === 'string' ? item.command : JSON.stringify({ ...item, aggregated_output: undefined });
1079
+ if (item.type === 'command_execution') {
1080
+ lineText = typeof item.command === 'string' ? item.command : JSON.stringify({ ...item, aggregated_output: undefined });
1081
+ if (typeof item.command === 'string') commandText = transcriptCommandText(lineText);
1082
+ }
860
1083
  else if (item.type === 'file_change') {
861
1084
  lineText = (Array.isArray(item.changes) ? item.changes : [])
862
1085
  .filter((change) => typeof change?.path === 'string')
@@ -864,20 +1087,21 @@ export function scanTranscript(text, list = FORBIDDEN) {
864
1087
  } else lineText = JSON.stringify(item);
865
1088
  }
866
1089
  const tokens = lineText.split(/(>>?)|[\s'"`;|&(),{}[\]]+/).filter((t) => typeof t === 'string' && t.length > 0);
1090
+ const commandTokens = commandText === undefined ? tokens : commandText.split(/(>>?)|[\s'"`;|&(),{}[\]]+/).filter((t) => typeof t === 'string' && t.length > 0);
867
1091
  const ids = new Set();
868
1092
  let firstWrite = -1;
869
1093
  for (let i = 0; i < tokens.length && firstWrite < 0; i += 1) {
870
1094
  if (writeStarts.has(commandName(tokens[i])) && isWriteCommand(tokens.slice(i, i + TRANSCRIPT_WINDOW))) firstWrite = i;
871
1095
  }
872
1096
  for (const entry of list) {
873
- if (entry.kind === 'contains' && matchesEntry([], tokens, entry)) ids.add(entry.id);
1097
+ if (entry.kind === 'contains' && matchesEntry([], commandTokens, entry)) ids.add(entry.id);
874
1098
  if (entry.kind !== 'path') continue;
875
1099
  const named = tokens.findIndex((t, i) => (entry.access === 'readwrite' || (firstWrite >= 0 && i > firstWrite)) && namesPath(t, entry));
876
1100
  if (named >= 0) ids.add(entry.id);
877
1101
  }
878
- for (let i = 0; i < tokens.length; i += 1) {
879
- if (!firstWords.has(commandName(tokens[i])) && !tokens[i].includes('code-forge')) continue;
880
- const window = tokens.slice(i, i + TRANSCRIPT_WINDOW);
1102
+ for (let i = 0; i < commandTokens.length; i += 1) {
1103
+ if (!firstWords.has(commandName(commandTokens[i])) && !commandTokens[i].includes('code-forge')) continue;
1104
+ const window = commandTokens.slice(i, i + TRANSCRIPT_WINDOW);
881
1105
  for (const entry of commandEntries) {
882
1106
  if (!ids.has(entry.id) && isForbidden(window, [entry])) ids.add(entry.id);
883
1107
  }
@@ -27,7 +27,7 @@
27
27
  */
28
28
 
29
29
  import { execFileSync } from 'node:child_process';
30
- import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
30
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
31
31
  import path from 'node:path';
32
32
  import { setTimeout as sleep } from 'node:timers/promises';
33
33
 
@@ -85,7 +85,7 @@ export function isAlive(pid) {
85
85
  export function readStartTime(pid) {
86
86
  if (!isValidPid(pid)) return null;
87
87
  try {
88
- const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
88
+ const out = execFileSync(existsSync('/bin/ps') ? '/bin/ps' : 'ps', ['-o', 'lstart=', '-p', String(pid)], {
89
89
  encoding: 'utf8',
90
90
  stdio: ['ignore', 'pipe', 'ignore'],
91
91
  env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
@@ -165,7 +165,7 @@ export function groupMembers(pgid) {
165
165
  const members = new Map();
166
166
  let out = '';
167
167
  try {
168
- out = execFileSync('ps', ['-A', '-o', 'pid=,pgid=,lstart='], {
168
+ out = execFileSync(existsSync('/bin/ps') ? '/bin/ps' : 'ps', ['-A', '-o', 'pid=,pgid=,lstart='], {
169
169
  encoding: 'utf8',
170
170
  stdio: ['ignore', 'pipe', 'ignore'],
171
171
  env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
@@ -95,13 +95,13 @@ import { askJev } from '../decide/jev-client.mjs';
95
95
  import { planAndRecord, tierOf } from '../review/budget.mjs';
96
96
  import { budgetFor } from '../review/packet.mjs';
97
97
  import { ContextError, reviewDigest } from '../context/pack.mjs';
98
- import { reviewFile, rulesRisk } from '../review/engine.mjs';
98
+ import { reviewFile, reviewLightBatch, rulesRisk } from '../review/engine.mjs';
99
99
  import { reviewAnswers } from '../review/answers.mjs';
100
100
  import { peerDiffReader } from '../review/moved.mjs';
101
101
  import { computeFileSet, ownsFile } from '../gates/scope.mjs';
102
102
  import { extraRoundsFor, newFileState, reopenForExtraRound, runRound } from '../review/fixloop.mjs';
103
103
  import { reopenRowFor } from '../review/reopen.mjs';
104
- import { readRun } from '../state/run.mjs';
104
+ import { processStartTime, readRun } from '../state/run.mjs';
105
105
  import { StateError } from '../state/paths.mjs';
106
106
  import { blockTree, worktreeRecord } from '../proof/worktree.mjs';
107
107
  import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
@@ -109,7 +109,7 @@ import { verifyRow } from '../state/signer.mjs';
109
109
  import { redact } from '../util/redact.mjs';
110
110
  import { withLock } from '../util/file-locks.mjs';
111
111
  import { blockAnchors, blockRungUsed, loadState, RESTORED_EVENT, saveState } from './review-state.mjs';
112
- import { assertAnswers } from './queue.mjs';
112
+ import { assertAnswers, enqueueHash, isDone, writeResult } from './queue.mjs';
113
113
  import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } from './ticket.mjs';
114
114
 
115
115
  /**
@@ -127,6 +127,8 @@ import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } fro
127
127
  * the worker's and a per-call `cfg` wins).
128
128
  * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - a signed ledger row
129
129
  * (the worker adds `run`, `block`, `file`, `content_hash`).
130
+ * @property {(row: Record<string, any>) => Promise<unknown>} [writeBlockRow] - light fanout rows;
131
+ * the worker pins run and block, while the batch supplies each file and content hash.
130
132
  * @property {Buffer} [key] - the run's HMAC key: signs the fix-loop state. Absent ⇒ engine only.
131
133
  * @property {() => Promise<Array<Record<string, any>>>} [readRows] - the run's ledger rows (the
132
134
  * `review.state` anchors the state is checked against); required with `key`.
@@ -169,7 +171,7 @@ import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } fro
169
171
  * `tree` (B62b) is the recorded `worktree` `{path, branch, base_sha}`, null when the block has
170
172
  * none; `base` is then the tree's `base_sha`. A recorded worktree that `worktreeRecord` refuses
171
173
  * (path, branch, hex base_sha) throws `worktree-malformed`.
172
- * @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, testFiles: string[], acceptance?: unknown, kind: string | null, tree: WorktreeEntry | null, workspace: string | null}>}
174
+ * @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, testFiles: string[], acceptance?: unknown, acceptance_revision?: string, kind: string | null, tree: WorktreeEntry | null, workspace: string | null}>}
173
175
  */
174
176
  export async function blockEntryFor(runId, block) {
175
177
  let record;
@@ -197,7 +199,7 @@ export async function blockEntryFor(runId, block) {
197
199
  throw new Error('worktree-malformed');
198
200
  }
199
201
  }
200
- return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, testFiles, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
202
+ return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, testFiles, acceptance: entry.acceptance, acceptance_revision: entry.acceptance_revision, kind: entry.kind === 'light' ? 'light' : blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
201
203
  }
202
204
 
203
205
  /** @typedef {{path: string, branch: string, base_sha: string}} WorktreeEntry */
@@ -352,6 +354,9 @@ export async function reviewTicket(ticket, ctx) {
352
354
  return { status: 'unavailable', reason: err instanceof Error ? err.message : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
353
355
  }
354
356
  if (entry.kind === null) entry = { ...entry, kind: blockKind({ owned: [ticket.file] }) };
357
+ if (!ticket.kind && ticket.acceptance_revision !== entry.acceptance_revision) {
358
+ return { status: 'stale', reason: 'acceptance-amended', approved: false, engine: 'adaptive', sessions: [] };
359
+ }
355
360
  // B62b: every content read of this ticket happens in the block's tree (the main tree without
356
361
  // one), taken from the SAME entry as the base and the facts. The worker passes the tree it
357
362
  // hashed the ticket in as `ctx.contentRoot`; a record that changed since names another tree.
@@ -411,6 +416,10 @@ export async function reviewTicket(ticket, ctx) {
411
416
  return { status: 'unavailable', reason: 'context-unreadable', approved: false, engine: 'adaptive', sessions: [] };
412
417
  }
413
418
  const digest = rulesDigest === '' ? {} : { rulesDigest };
419
+ if (entry.kind === 'light' && ctx.key) {
420
+ const batch = await lightRound(ticket, tctx, { entry, workDir, factsExcerpt, rulesDigest });
421
+ if (batch) return batch;
422
+ }
414
423
  if (!ctx.key) {
415
424
  return await reviewFile(
416
425
  { repoRoot: contentRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt, ...digest, peerDiffs, ...(await fileContext()) },
@@ -423,6 +432,56 @@ export async function reviewTicket(ticket, ctx) {
423
432
  }
424
433
  }
425
434
 
435
+ /** Serialize a light block across tickets/workers; durable signed rows enforce the two-batch cap.
436
+ * A batch completes every file's queue ticket, including files queued after this ticket started.
437
+ * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
438
+ * @param {{entry: Awaited<ReturnType<typeof blockEntryFor>>, workDir: string, factsExcerpt: string, rulesDigest: string}} input
439
+ * @returns {Promise<ReviewOutcome | null>} null enters the ordinary per-file loop.
440
+ */
441
+ async function lightRound(ticket, ctx, { entry, workDir, factsExcerpt, rulesDigest }) {
442
+ if (!ctx.key || !ctx.readRows || !ctx.writeBlockRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
443
+ const key = ctx.key, readRows = ctx.readRows, writeRow = ctx.writeBlockRow;
444
+ const worker = { pid: process.pid, started_at: await processStartTime(process.pid) };
445
+ return withLock(`light:${ctx.runId}\0${ticket.block}`, async () => {
446
+ const root = ctx.contentRoot ?? ctx.repoRoot;
447
+ const files = (await computeFileSet({ cwd: root, base: entry.base ?? 'HEAD' })).all.filter((file) => ownsFile(entry.owned, file, entry.ownedKinds)).sort();
448
+ const hashes = files.map((file) => ({ file, content_hash: contentHash(root, file) }));
449
+ if (!hashes.some((f) => f.file === ticket.file && f.content_hash === ticket.content_hash)) return { status: 'stale', approved: false, engine: 'adaptive', sessions: [] };
450
+ const signature = JSON.stringify([entry.base, hashes]);
451
+ const rows = (await readRows()).filter((r) => r.run === ctx.runId && r.block === ticket.block && verifyRow(r, key).ok);
452
+ const cached = rows.filter((r) => r.event === 'review.light_result' && r.signature === signature).at(-1)?.outcomes?.[ticket.file];
453
+ if (cached && (cached.status === 'reviewed' || isDone(ctx.repoRoot, ticket.ticket))) return cached;
454
+ const count = rows.filter((r) => r.event === 'review.light_batch').length;
455
+ if (count >= 2) return null;
456
+ const round = count + 1;
457
+ // Reserve before the session: a crash cannot reset the cap on a worker restart.
458
+ await writeRow({ event: 'review.light_batch', round, signature });
459
+ const batch = await reviewLightBatch({ repoRoot: root, files, base: entry.base, cfg: ctx.cfg, workDir, factsExcerpt, rulesDigest }, { spawn: ctx.spawn, writeRow });
460
+ const moved = hashes.some((f) => contentHash(root, f.file) !== f.content_hash);
461
+ const findings = batch.findings ?? [];
462
+ const blocking = findings.some((f) => f.severity === 'critical' || f.severity === 'warning');
463
+ /** @type {Record<string, ReviewOutcome>} */
464
+ const outcomes = {};
465
+ for (const { file, content_hash } of hashes) {
466
+ const own = findings.filter((f) => f.file === file);
467
+ const approved = !moved && batch.status === 'reviewed' && (batch.approved || blocking) && own.every((f) => f.severity === 'nit');
468
+ const outcome = { ...batch, ...(moved ? { status: 'stale' } : {}), approved, findings: own, round, kind: 'full', level: entry.level,
469
+ next: { action: approved ? 'complete' : 'fix', level: entry.level } };
470
+ outcomes[file] = outcome;
471
+ if (batch.status === 'reviewed' && !moved) {
472
+ await writeRow({ event: 'review.round', file, content_hash, round, kind: 'full', level: entry.level, open_after: own.filter((f) => f.severity !== 'nit').length });
473
+ if (approved) await writeRow({ event: 'review.approved', file, content_hash });
474
+ }
475
+ const queued = enqueueHash({ repoRoot: ctx.repoRoot, run: ctx.runId, block: ticket.block, file, content_hash, ...(ticket.tree ? { tree: ticket.tree } : {}) });
476
+ const open = approved ? [] : own;
477
+ await writeRow({ event: 'review.result', ticket: queued.ticket, file, content_hash, status: outcome.status, approved, open, worker });
478
+ writeResult({ repoRoot: ctx.repoRoot, runId: ctx.runId, ticket: queued.ticket, result: { ...outcome, block: ticket.block, file, content_hash, open, worker }, key });
479
+ }
480
+ await writeRow({ event: 'review.light_result', signature, outcomes });
481
+ return outcomes[ticket.file];
482
+ });
483
+ }
484
+
426
485
  /**
427
486
  * B56: the block's OTHER changed files — the §4.1 file set since the block base (tracked changes ∪
428
487
  * untracked), filtered to the block's owned files (`ownsFile`, as the budget row does), minus the
@@ -565,7 +624,16 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
565
624
  seq: loaded.seq,
566
625
  contentHash: ticket.content_hash,
567
626
  };
568
- const reopened = reopenRowFor(rows, reopenAt) !== null;
627
+ let amendedAt = -1;
628
+ let anchoredAt = -1;
629
+ rows.forEach((row, i) => {
630
+ if (row.block !== ticket.block || !verifyRow(row, key).ok) return;
631
+ if (row.event === 'block.acceptance_amended') amendedAt = i;
632
+ if (row.event === 'review.state' && row.file === ticket.file) anchoredAt = i;
633
+ });
634
+ // A changed review input starts a full round, including previously stopped files. Keep
635
+ // the anchor sequence and the block's spent L3 rung; tampered state still refuses above.
636
+ const reopened = amendedAt > anchoredAt || reopenRowFor(rows, reopenAt) !== null;
569
637
  if (loaded.status === 'lost' && !reopened) return { outcome: /** @type {ReviewOutcome} */ ({ status: 'refused', reason: 'review-state-lost', message: LOST_MESSAGE, approved: false, engine: 'adaptive', sessions: [] }) };
570
638
  let seq = loaded.seq;
571
639
  if (loaded.status === 'restored' && loaded.state !== null && !reopened) {
@@ -42,12 +42,13 @@ import path from 'node:path';
42
42
  import { fileURLToPath } from 'node:url';
43
43
  import { expiryChecks } from '../autopilot/limits.mjs';
44
44
  import { loadProjectConfig } from '../config/load.mjs';
45
+ import { reviewWorkers } from '../config/validate.mjs';
45
46
  import { cacheHoursFrom, createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
46
47
  import { appendRow, readAllRows } from '../ledger/write.mjs';
47
48
  import { spawnSession } from '../session/spawn.mjs';
48
49
  import { snapshotFor } from '../state/config-snapshot.mjs';
49
50
  import { runRecordPath } from '../state/paths.mjs';
50
- import { readRun, reattachWorker } from '../state/run.mjs';
51
+ import { processStartTime, readRun, reattachWorker } from '../state/run.mjs';
51
52
  import { loadKey, signRow } from '../state/signer.mjs';
52
53
  import { logWarning } from '../util/error-log.mjs';
53
54
  import { withLock, tryLock } from '../util/file-locks.mjs';
@@ -260,6 +261,7 @@ export function recordedVerdict(result) {
260
261
  */
261
262
  export async function createWorker(opts, deps = {}) {
262
263
  const { runId, repoRoot, cfg, runRootDir, slug, key } = opts;
264
+ const identity = { pid: process.pid, started_at: await processStartTime(process.pid) };
263
265
  const env = deps.env ?? process.env;
264
266
  const review = deps.review ?? reviewTicket;
265
267
  const spawn = deps.spawn ?? spawnSession;
@@ -382,6 +384,7 @@ export async function createWorker(opts, deps = {}) {
382
384
  { env: childEnv, ...(deps.bins ? { bins: deps.bins } : {}), ...(deps.stderr ? { stderr: deps.stderr } : {}) },
383
385
  ),
384
386
  writeRow: (row) => ledger({ ...row, block: ticket.block, file: ticket.file, content_hash: ticket.content_hash }),
387
+ writeBlockRow: (row) => ledger({ ...row, block: ticket.block }),
385
388
  };
386
389
  try {
387
390
  const outcome = await review(ticket, ctx);
@@ -484,12 +487,14 @@ export async function createWorker(opts, deps = {}) {
484
487
  * @param {string} id @param {Record<string, any>} outcome - recorded through {@link recordedVerdict}.
485
488
  */
486
489
  async function complete(id, outcome) {
487
- const result = recordedVerdict(outcome);
490
+ /** @type {Record<string, any>} */
491
+ const result = { ...recordedVerdict(outcome), worker: identity };
488
492
  const signed = writeResult({ repoRoot, runId, ticket: id, result, key });
489
493
  try {
490
494
  await ledger({
491
495
  event: 'review.result',
492
496
  ticket: id,
497
+ worker: identity,
493
498
  block: result.block ?? null,
494
499
  file: result.file ?? null,
495
500
  content_hash: result.content_hash ?? null,
@@ -793,9 +798,6 @@ export async function createWorker(opts, deps = {}) {
793
798
  return { processTicket, drain, run, once, stop, release, announce, watchTick: tickWatch, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
794
799
  }
795
800
 
796
- /** This block permits one registered worker per queue. */
797
- const WORKER_LIMIT = 1;
798
-
799
801
  /**
800
802
  * Start-up for the `worker` verb: run record, repo root, config, run temp root (owned by this
801
803
  * process), run key; refuses when another live worker already serves this queue.
@@ -831,7 +833,10 @@ export async function bootWorker(opts, deps = {}) {
831
833
  const legacy = liveWorker(repoRoot);
832
834
  if (legacy && !entries.some((entry) => entry.worker?.pid === legacy.pid && entry.worker.start_time === legacy.start_time)) count++;
833
835
  await deps.hooks?.afterCount?.(count);
834
- if (count >= WORKER_LIMIT) throw new WorkerError('worker-running', 'a live or unreadable worker entry already serves this queue');
836
+ const limit = reviewWorkers(cfg);
837
+ if (entries.some((entry) => entry.live && !entry.worker) || (legacy && legacy.run !== opts.runId) || entries.some((entry) => entry.live && entry.worker?.run !== opts.runId) || count >= limit) {
838
+ throw new WorkerError('worker-running', `review.workers limit ${limit}: a live or unreadable worker entry already serves this queue`);
839
+ }
835
840
  worker.announce();
836
841
  });
837
842
  return { ...worker, repoRoot, runRootDir };
@@ -844,10 +849,10 @@ export async function bootWorker(opts, deps = {}) {
844
849
  * `expectPid` (a `run start` that just launched a worker) pins ONLY that pid: an older or stale
845
850
  * announcement naming another live pid is refused (`worker_mismatch`) instead of being pinned
846
851
  * over the new worker.
847
- * @param {{runId: string, repoRoot: string, writeRow?: (row: Record<string, any>) => Promise<unknown>, probe?: import('../state/run.mjs').StartTimeProbe, expectPid?: number}} opts
852
+ * @param {{runId: string, repoRoot: string, writeRow?: (row: Record<string, any>) => Promise<unknown>, probe?: import('../state/run.mjs').StartTimeProbe, expectPid?: number, add?: boolean}} opts
848
853
  */
849
- export async function repinWorker({ runId, repoRoot, writeRow, probe, expectPid }) {
850
- const live = liveWorker(repoRoot);
854
+ export async function repinWorker({ runId, repoRoot, writeRow, probe, expectPid, add = false }) {
855
+ const live = (expectPid === undefined ? null : liveWorker(repoRoot, { pid: expectPid, registeredOnly: true })) ?? liveWorker(repoRoot);
851
856
  if (!live) throw new WorkerError('worker_down', 'no live worker serves this queue');
852
857
  if (live.run !== runId) throw new WorkerError('other-run', `the live worker serves run ${live.run}`);
853
858
  if (expectPid !== undefined && live.pid !== expectPid) {
@@ -855,7 +860,7 @@ export async function repinWorker({ runId, repoRoot, writeRow, probe, expectPid
855
860
  }
856
861
  const { project } = await readRun(runId);
857
862
  const write = writeRow ?? ((/** @type {Record<string, any>} */ row) => appendRow(row, { slug: project }));
858
- return reattachWorker({ runId, workerPid: live.pid, writeRow: write, ...(probe ? { probe } : {}) });
863
+ return reattachWorker({ runId, workerPid: live.pid, writeRow: write, add, ...(probe ? { probe } : {}) });
859
864
  }
860
865
 
861
866
  /** The CLI entry the worker is launched from. */
@@ -887,5 +892,9 @@ export async function launchWorker({ runId, workspace, env = process.env, timeou
887
892
  // already gone
888
893
  }
889
894
  }
895
+ const other = liveWorker(repoRoot);
896
+ if (other && other.run !== runId) {
897
+ throw new WorkerError('worker_down', `the worker did not start: run ${other.run} still has a live worker (pid ${other.pid}); end it with: code-forge run end --run ${other.run}`);
898
+ }
890
899
  throw new WorkerError('worker_down', 'the worker did not start (run `code-forge worker --run <id>` by hand to see why)');
891
900
  }