@codedology/code-forge 0.15.3 → 0.16.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 (56) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +1 -1
  3. package/docs/reference/blocks.json +9 -1
  4. package/docs/reference/config.md +11 -1
  5. package/native/README.md +10 -0
  6. package/native/bin/darwin-arm64/code-forge-native +0 -0
  7. package/native/bin/darwin-x64/code-forge-native +0 -0
  8. package/native/bin/linux-arm64/code-forge-native +0 -0
  9. package/native/bin/linux-x64/code-forge-native +0 -0
  10. package/package.json +7 -2
  11. package/schema/code-forge.schema.json +26 -1
  12. package/schema/facts.schema.json +1 -1
  13. package/skill/SKILL.md +1 -1
  14. package/skill/references/adapters/solo.md +1 -1
  15. package/skill/references/autopilot.md +6 -1
  16. package/skill/references/code.md +37 -0
  17. package/skill/references/decisions.md +1 -1
  18. package/skill/references/plan.md +18 -5
  19. package/skill/references/review.md +47 -9
  20. package/skill/scripts/forge +1 -1
  21. package/skill/templates/facts-sheet.md +9 -3
  22. package/src/cli/block.mjs +284 -11
  23. package/src/cli/doctor.mjs +32 -1
  24. package/src/cli/help.mjs +1 -0
  25. package/src/cli/native-status.mjs +13 -0
  26. package/src/cli/review-file.mjs +126 -14
  27. package/src/cli/spawn.mjs +26 -22
  28. package/src/cli/status.mjs +80 -14
  29. package/src/decide/escalation.mjs +5 -5
  30. package/src/doctor/index.mjs +11 -0
  31. package/src/doctor/worker-probe.mjs +1 -1
  32. package/src/gates/dependents.mjs +200 -0
  33. package/src/gates/expand.mjs +83 -26
  34. package/src/gates/run.mjs +76 -14
  35. package/src/ledger/tail.mjs +89 -0
  36. package/src/review/agent-waiver.mjs +95 -8
  37. package/src/review/definitions.mjs +170 -0
  38. package/src/review/fixloop.mjs +4 -4
  39. package/src/review/gate-check.mjs +33 -13
  40. package/src/review/model-stats.mjs +125 -0
  41. package/src/review/packet.mjs +111 -22
  42. package/src/review/reopen.mjs +38 -13
  43. package/src/review/route.mjs +58 -4
  44. package/src/session/facts.mjs +252 -70
  45. package/src/session/plan-check.mjs +4 -1
  46. package/src/session/spawn.mjs +70 -19
  47. package/src/state/block-end.mjs +3 -1
  48. package/src/state/block.mjs +56 -21
  49. package/src/state/run.mjs +12 -5
  50. package/src/util/native.mjs +133 -0
  51. package/src/worker/engine.mjs +11 -26
  52. package/src/worker/loop.mjs +150 -20
  53. package/src/worker/queue.mjs +3 -2
  54. package/src/worker/review-state.mjs +15 -3
  55. package/src/worker/watch.mjs +97 -83
  56. package/src/worker/worker-warning.mjs +35 -0
@@ -0,0 +1,89 @@
1
+ import { open, readdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { ledgerPath } from './paths.mjs';
4
+
5
+ /**
6
+ * A worker's project-wide ledger view. Concurrent callers share one serialized
7
+ * cursor; incomplete UTF-8/JSON bytes stay buffered until a newline arrives.
8
+ * @param {string} slug
9
+ * @returns {{read: () => Promise<Record<string, any>[]>}}
10
+ */
11
+ export function createTailReader(slug) {
12
+ const file = ledgerPath(slug);
13
+ const dir = path.dirname(file);
14
+ const rotatedName = new RegExp(`^${slug}\\.(\\d+)\\.jsonl$`);
15
+ /** @type {Record<string, any>[]} */
16
+ let rows = [];
17
+ let files = '';
18
+ let inode = '';
19
+ let offset = 0;
20
+ /** @type {Buffer} */
21
+ let pending = Buffer.alloc(0);
22
+ let chain = Promise.resolve();
23
+
24
+ /** @param {Buffer} bytes */
25
+ function parse(bytes) {
26
+ const end = bytes.lastIndexOf(0x0a) + 1;
27
+ return {
28
+ rows: bytes.subarray(0, end).toString('utf8').split('\n').filter((line) => line.length > 0).map((line) => JSON.parse(line)),
29
+ pending: bytes.subarray(end),
30
+ };
31
+ }
32
+
33
+ /** @param {import('node:fs/promises').FileHandle} handle @param {number} start @param {number} size */
34
+ async function readBytes(handle, start, size) {
35
+ const bytes = Buffer.alloc(Math.max(0, size - start));
36
+ let n = 0;
37
+ while (n < bytes.length) {
38
+ const { bytesRead } = await handle.read(bytes, n, bytes.length - n, start + n);
39
+ if (bytesRead === 0) break;
40
+ n += bytesRead;
41
+ }
42
+ return bytes.subarray(0, n);
43
+ }
44
+
45
+ async function update() {
46
+ let names;
47
+ try { names = await readdir(dir); } catch (err) {
48
+ if (err.code !== 'ENOENT') throw err;
49
+ names = [];
50
+ }
51
+ const rotated = names.filter((name) => rotatedName.test(name))
52
+ .sort((a, b) => Number(rotatedName.exec(a)[1]) - Number(rotatedName.exec(b)[1]));
53
+ let handle;
54
+ try { handle = await open(file, 'r'); } catch (err) {
55
+ if (err.code !== 'ENOENT') throw err;
56
+ }
57
+ try {
58
+ const info = await handle?.stat();
59
+ const identity = info ? `${info.dev}:${info.ino}` : '';
60
+ const listing = JSON.stringify(rotated);
61
+ const reset = listing !== files || identity !== inode || (info?.size ?? 0) < offset;
62
+ let next = reset ? [] : rows;
63
+ if (reset) {
64
+ for (const name of rotated) {
65
+ const history = await open(path.join(dir, name), 'r');
66
+ try {
67
+ next = next.concat(parse(await readBytes(history, 0, (await history.stat()).size)).rows);
68
+ } finally { await history.close(); }
69
+ }
70
+ }
71
+ const start = reset ? 0 : offset;
72
+ const bytes = handle ? await readBytes(handle, start, info.size) : Buffer.alloc(0);
73
+ const parsed = parse(Buffer.concat([reset ? Buffer.alloc(0) : pending, bytes]));
74
+ // Commit only after every complete line parsed, so corruption cannot poison the cursor.
75
+ rows = next.concat(parsed.rows);
76
+ pending = parsed.pending;
77
+ offset = start + bytes.length;
78
+ files = listing;
79
+ inode = identity;
80
+ return rows.slice();
81
+ } finally { await handle?.close(); }
82
+ }
83
+
84
+ return { read() {
85
+ const result = chain.then(update);
86
+ chain = result.then(() => {}, () => {});
87
+ return result;
88
+ } };
89
+ }
@@ -5,6 +5,7 @@ import { createHash } from 'node:crypto';
5
5
  import { DELEGABLE_SEVERITIES, ownsAt, recordedSeverity, waiveFinding } from './gate-check.mjs';
6
6
  import { checkFileForm, checkFindingId } from './verb-forms.mjs';
7
7
  import { StateError } from '../state/paths.mjs';
8
+ import { readRun } from '../state/run.mjs';
8
9
  import { loadKey, verifyRow } from '../state/signer.mjs';
9
10
  import { configInForce } from '../state/config-snapshot.mjs';
10
11
  import { readAllRows } from '../ledger/write.mjs';
@@ -15,6 +16,10 @@ import { verifyResult } from '../worker/queue.mjs';
15
16
  import { resolveLevel } from '../config/known-ids.mjs';
16
17
  import { runRootFor, spawnSession } from '../session/spawn.mjs';
17
18
  import { spawnWithBrokenFallback } from './session-retry.mjs';
19
+ import { splitLines } from './context.mjs';
20
+ import { grantState } from '../autopilot/grant.mjs';
21
+ import { lastGate } from '../state/brief-status.mjs';
22
+ import { fileBaseline, testScope } from '../gates/expand.mjs';
18
23
 
19
24
  const SCHEMA = {
20
25
  type: 'object', additionalProperties: false, required: ['verdict', 'reason'],
@@ -35,6 +40,15 @@ function refuse(code, detail) {
35
40
  throw new StateError(code, `${code}: ${detail}; nothing was recorded`);
36
41
  }
37
42
 
43
+ /** The checkExpiry grant rule, without lazy expiry writes on a refused waiver.
44
+ * @param {Record<string, any>} record
45
+ */
46
+ function activeGrant(record) {
47
+ const grant = record.autopilot;
48
+ return record.status === 'active' && grant?.status === 'active'
49
+ && Number.isFinite(Date.parse(grant.until)) && grantState(grant, new Date()) === 'active';
50
+ }
51
+
38
52
  /** A citation must name a real regular file inside this tree, without following symlinks.
39
53
  * @param {string} root @param {string} file @returns {boolean}
40
54
  */
@@ -47,16 +61,48 @@ function existsInTree(root, file) {
47
61
  }
48
62
  }
49
63
 
64
+ /** @param {string} reason */
65
+ const evidenceTokens = (reason) => reason.split(/[\s"'`()[\]{},;<>]+/u).filter(Boolean);
66
+
67
+ /** Numbered windows for the first ten readable repo citations, never following a link out.
68
+ * Each window includes ten lines either side, clipped to the file and eighty lines total.
69
+ * @param {string} reason @param {string} root
70
+ */
71
+ export function citedEvidence(reason, root) {
72
+ const evidence = [];
73
+ for (const token of evidenceTokens(reason)) {
74
+ const match = /^(.+):([1-9][0-9]*)(?:-([1-9][0-9]*))?[.!?]?$/.exec(token);
75
+ if (!match) continue;
76
+ const first = Number(match[2]);
77
+ const last = Number(match[3] ?? match[2]);
78
+ if (!Number.isSafeInteger(first) || !Number.isSafeInteger(last) || last < first) continue;
79
+ try {
80
+ const file = assertRowPath(match[1]);
81
+ const bytes = readRegularFileNoFollow(root, file);
82
+ if (bytes === null) continue;
83
+ const lines = splitLines(bytes.toString('utf8'));
84
+ if (first > lines.length) continue;
85
+ const start = Math.max(1, first - 10);
86
+ const end = Math.min(lines.length, last + 10, start + 79);
87
+ const content = lines.slice(start - 1, end).map((line, i) => `${start + i}| ${line}`).join('\n');
88
+ evidence.push({ path: file, start, end, content });
89
+ if (evidence.length === 10) break;
90
+ } catch {
91
+ // Invalid, missing, unreadable and escaping citations never become packet content.
92
+ }
93
+ }
94
+ return evidence;
95
+ }
96
+
50
97
  /** @param {string} reason @param {string} root @param {Record<string, any>} entry */
51
98
  export function hasEvidence(reason, root, entry) {
99
+ if (citedEvidence(reason, root).length > 0) return true;
52
100
  for (const match of reason.matchAll(/\bclause\s+([0-9]+)\b/gi)) {
53
101
  const n = Number(match[1]);
54
102
  if (n >= 1 && n <= (Array.isArray(entry.acceptance) ? entry.acceptance.length : 0)) return true;
55
103
  }
56
- const tokens = reason.split(/[\s"'`()[\]{},;<>]+/u).filter(Boolean);
104
+ const tokens = evidenceTokens(reason);
57
105
  for (const token of tokens) {
58
- const line = /^(.+):([1-9][0-9]*)[.!?]?$/.exec(token);
59
- if (line && existsInTree(root, line[1])) return true;
60
106
  const file = token.replace(/[.!?]+$/, '');
61
107
  const testFile = /(^|\/)(?:test|tests|__tests__)\//.test(file) || /(?:^|[./_-])(?:test|spec)\.[^/]+$/.test(file) || entry.test_files?.includes(file);
62
108
  if (testFile && existsInTree(root, file)) return true;
@@ -64,6 +110,37 @@ export function hasEvidence(reason, root, entry) {
64
110
  return false;
65
111
  }
66
112
 
113
+ /** Require a cited acceptance test measured by the latest signed gate at these exact bytes.
114
+ * Gate rows summarize test success, not individual assertions; only full or known related
115
+ * scopes prove that the cited file ran. A changed scope does not record its file list.
116
+ * @param {string} reason @param {string} root @param {Record<string, any>} entry
117
+ * @param {Record<string, any>[]} rows @param {string} id @param {Buffer} key
118
+ */
119
+ async function passingAcceptanceTest(reason, root, entry, rows, id, key) {
120
+ const named = new Set((entry.acceptance ?? []).flatMap((clause) => clause.tests ?? []));
121
+ const cited = evidenceTokens(reason).map((token) => token.replace(/:[1-9][0-9]*(?:-[1-9][0-9]*)?[.!?]?$/, '').replace(/[.!?]+$/, ''))
122
+ .filter((file) => named.has(file) && existsInTree(root, file));
123
+ if (cited.length === 0) refuse('waive-yes-critical-no-test', 'cite an existing test file named in this block\'s acceptance tests');
124
+ const mine = rows.filter((row) => row.block === id);
125
+ const latest = mine.filter((row) => row.event === 'gate.done' || row.event === 'gate.red').at(-1);
126
+ if (!latest || !verifyRow(latest, key).ok || lastGate(mine).result !== 'pass'
127
+ || latest.worktree !== entry.worktree?.path
128
+ || !Array.isArray(latest.results)
129
+ || !latest.results.some((result) => result.gate === 'test' && result.ok === true && result.skipped === false)) {
130
+ refuse('waive-yes-critical-no-test', 'the cited acceptance test needs a passing test result in this block\'s latest signed gate record');
131
+ }
132
+ const current = await fileBaseline(root, entry.owned_files ?? [], entry.owned_kinds);
133
+ if (!current || !latest.files || Object.keys(current.files).length !== Object.keys(latest.files).length
134
+ || Object.entries(current.files).some(([file, hash]) => latest.files[file] !== hash)) {
135
+ refuse('waive-yes-critical-no-test', 'rerun the acceptance test: the latest gate record does not cover the current block content');
136
+ }
137
+ const scope = testScope({ testFiles: entry.test_files, owned: entry.owned_files ?? [] });
138
+ const test = cited.find((file) => latest.files[file] && (latest.scope === 'full'
139
+ || (latest.scope === 'related' && scope.scope === 'related' && scope.files.includes(file))));
140
+ if (!test) refuse('waive-yes-critical-no-test', 'the latest passing gate record must cover the cited acceptance test file and its current content');
141
+ return test;
142
+ }
143
+
67
144
  /** The full finding is normally in the signed worker result; older rows may carry it directly.
68
145
  * @param {Record<string, any>[]} rows @param {{root: string, runId: string, id: string, file: string, finding: string, key: Buffer}} where
69
146
  */
@@ -130,17 +207,26 @@ export async function waiveAsAgent({ runId, id, finding, file, reason, record, w
130
207
  const rows = (await readAllRows(record.project)).filter((r) => r.run === runId);
131
208
  const key = await loadKey(runId);
132
209
  const recorded = recordedSeverity(rows, { block: id, file, finding, key });
133
- if (!DELEGABLE_SEVERITIES.includes(recorded.severity)) refuse('waive-yes-critical', `finding ${finding} is ${recorded.severity ?? recorded.why}; only warning or nit findings may use --yes`);
210
+ const critical = recorded.severity === 'critical';
211
+ if (!DELEGABLE_SEVERITIES.includes(recorded.severity) && !(critical && activeGrant(record))) {
212
+ refuse('waive-yes-critical', `finding ${finding} is ${recorded.severity ?? recorded.why}; a critical finding needs an active autopilot grant; otherwise only warning or nit findings may use --yes`);
213
+ }
134
214
  const tree = await blockTree(record.workspace, entry);
135
215
  const contentRoot = tree?.top ?? root;
136
- if (!hasEvidence(reason, contentRoot, entry)) refuse('waive-yes-no-evidence', 'cite an existing repo path with :line, an existing test file, or a block acceptance clause number');
216
+ const test = critical ? await passingAcceptanceTest(reason, contentRoot, entry, rows, id, key) : null;
217
+ if (critical ? !citedEvidence(reason, contentRoot).some((item) => item.path === file) : !hasEvidence(reason, contentRoot, entry)) {
218
+ refuse('waive-yes-no-evidence', critical ? `cite the finding's path ${file} with :line or :start-end`
219
+ : 'cite an existing repo path with :line or :start-end, an existing test file, or a block acceptance clause number');
220
+ }
137
221
  const bytes = readRegularFileNoFollow(contentRoot, file);
138
222
  if (bytes === null) refuse('waive-yes-disagreed', 'The current file cannot be read');
139
223
  const text = await findingText(rows, { root, runId, id, file, finding, key });
140
- const answer = await judgeAgentReview({ finding: { id: finding, severity: recorded.severity, ...text }, reason, file: { path: file, content: bytes.toString('utf8') } }, { record, runId, id, judge });
224
+ const evidence = citedEvidence(reason, contentRoot);
225
+ const answer = await judgeAgentReview({ finding: { id: finding, severity: recorded.severity, ...text }, reason, file: { path: file, content: bytes.toString('utf8') }, ...(evidence.length > 0 ? { evidence } : {}) }, { record, runId, id, judge });
141
226
  if (answer.verdict === 'broken') refuse('waive-yes-broken', answer.reason);
142
227
  if (answer.verdict !== 'agree') refuse('waive-yes-disagreed', answer.reason);
143
- return waiveFinding({ runId, id, finding, file, reason, writeRow, by: 'agent', extra: { severity: recorded.severity, verdict: 'agree', judge_reason: answer.reason } });
228
+ if (critical && !activeGrant(await readRun(runId))) refuse('waive-yes-critical', 'the critical finding still needs an active autopilot grant after the judge returns');
229
+ return waiveFinding({ runId, id, finding, file, reason, writeRow, by: 'agent', extra: { severity: recorded.severity, verdict: 'agree', judge_reason: answer.reason, ...(critical ? { autopilot: true, test } : {}) } });
144
230
  }
145
231
 
146
232
  /** Verified ledger-order cancellation, scoped to one run, block, file and finding.
@@ -159,7 +245,8 @@ export function isUnwaived(rows, waiver, key, verify = (r) => verifyRow(r, key).
159
245
  */
160
246
  export function countedAgentWaivers(rows, block, key, verify = (r) => verifyRow(r, key).ok) {
161
247
  return rows.filter((r) => r.block === block && r.event === 'review.waived' && r.by === 'agent'
162
- && r.verdict === 'agree' && DELEGABLE_SEVERITIES.includes(r.severity) && verify(r)
248
+ && r.verdict === 'agree' && (DELEGABLE_SEVERITIES.includes(r.severity)
249
+ || (r.severity === 'critical' && r.autopilot === true && typeof r.test === 'string' && r.test.length > 0)) && verify(r)
163
250
  && !isUnwaived(rows, r, key, verify)
164
251
  && recordedSeverity(rows.filter((other) => other.run === r.run), { block, file: r.file, finding: r.finding, key, verify }).severity === r.severity);
165
252
  }
@@ -0,0 +1,170 @@
1
+ /** Best-effort JavaScript/TypeScript definition context, with caller-supplied I/O. */
2
+ import nodePath from 'node:path';
3
+ import { isSecretLike } from './packet.mjs';
4
+
5
+ const KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'typeof', 'new']);
6
+ const EXTENSIONS = ['.mjs', '.js', '.ts', '.cjs', '.tsx'];
7
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
8
+
9
+ /** @typedef {{path: string, name: string, start: number, end: number, lines: string[]}} Definition */
10
+
11
+ /** Comments and literals stay opaque; positions still refer to the original source.
12
+ * @param {string} text
13
+ */
14
+ function tokensOf(text) {
15
+ const tokens = [];
16
+ let line = 1;
17
+ let offset = 0;
18
+ for (const match of text.matchAll(/\/\/[^\r\n]*|\/\*[\s\S]*?\*\/|`(?:\\[\s\S]|[^`\\])*`|'(?:\\[\s\S]|[^'\\])*'|"(?:\\[\s\S]|[^"\\])*"|\/(?:\\.|\[(?:\\.|[^\]\\])*\]|[^/\n\\])+\/[dgimsuvy]*|[A-Za-z_$][\w$]*|=>|[^\s]/g)) {
19
+ line += text.slice(offset, match.index).split('\n').length - 1;
20
+ offset = match.index;
21
+ if (!/^\/[/\*]/.test(match[0])) tokens.push({ value: match[0], line });
22
+ }
23
+ return tokens;
24
+ }
25
+
26
+ /** @param {string} rel */
27
+ function safePath(rel) {
28
+ return !nodePath.posix.isAbsolute(rel) && !/[\\\x00-\x1f\x7f]/.test(rel) &&
29
+ !rel.split('/').some((part) => ['', '.', '..', '.git', '.code-forge'].includes(part)) && !isSecretLike(rel);
30
+ }
31
+
32
+ /** @param {ReturnType<typeof tokensOf>} tokens @param {number} start */
33
+ function closing(tokens, start) {
34
+ const open = tokens[start]?.value;
35
+ const close = { '(': ')', '{': '}', '[': ']' }[open];
36
+ let depth = 0;
37
+ for (let i = start; i < tokens.length; i++) {
38
+ if (tokens[i].value === open) depth++;
39
+ if (tokens[i].value === close && --depth === 0) return i;
40
+ }
41
+ return -1;
42
+ }
43
+
44
+ /** @param {string} text @param {string} file @param {string} name @param {boolean} exported
45
+ * @returns {Definition | null}
46
+ */
47
+ function declaration(text, file, name, exported) {
48
+ const tokens = tokensOf(text);
49
+ const lines = text.split(/\r?\n/);
50
+ for (let i = 0; i < tokens.length; i++) {
51
+ if (tokens[i].value !== name) continue;
52
+ const prev = tokens[i - 1]?.value;
53
+ const kind = ['function', 'const', 'class'].includes(prev) ? prev : null;
54
+ let start = i - 1;
55
+ while (start > 0 && ['async', 'default', 'export', 'declare'].includes(tokens[start - 1].value)) start--;
56
+ if (exported && !tokens.slice(start, i).some((token) => token.value === 'export')) continue;
57
+ if (kind === null && (exported || tokens[i + 1]?.value !== '(' || !['{', '}', ';', 'async', 'static', 'get', 'set'].includes(prev))) continue;
58
+ if (kind === null && tokens[closing(tokens, i + 1) + 1]?.value !== '{') continue;
59
+ if (kind === null) start = ['async', 'static', 'get', 'set'].includes(prev) ? i - 1 : i;
60
+ if (kind === 'const' && tokens[i + 1]?.value !== '=') continue;
61
+ let body = i + 1;
62
+ let end = -1;
63
+ for (; body < tokens.length; body++) {
64
+ const value = tokens[body].value;
65
+ if (value === '(' || value === '[') {
66
+ const last = closing(tokens, body);
67
+ if (last < 0) break;
68
+ body = last;
69
+ } else if (value === '{') {
70
+ end = closing(tokens, body);
71
+ break;
72
+ } else if (value === ';' || (kind === 'const' && tokens[body].line > tokens[i].line && tokens[body - 1]?.value !== '=')) {
73
+ end = value === ';' ? body : body - 1;
74
+ break;
75
+ }
76
+ }
77
+ if (end < 0) continue; // Malformed or incomplete declaration: keep looking.
78
+ const firstLine = tokens[start].line;
79
+ const lastLine = tokens[end].line;
80
+ const lastShown = Math.min(lastLine, firstLine + 39);
81
+ return { path: file, name, start: firstLine, end: lastShown,
82
+ lines: [...lines.slice(firstLine - 1, lastShown), ...(lastLine > lastShown ? ['…'] : [])] };
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /** Parse local bindings, preserving aliases and namespace owners.
88
+ * @param {string} header
89
+ */
90
+ function importsOf(header) {
91
+ const tokens = tokensOf(header);
92
+ const imports = new Map();
93
+ for (let i = 0; i < tokens.length; i++) {
94
+ if (tokens[i].value !== 'import' || ['(', '.'].includes(tokens[i + 1]?.value)) continue;
95
+ let end = i + 1;
96
+ while (end < tokens.length && !['from', ';', 'import'].includes(tokens[end].value)) end++;
97
+ if (tokens[end]?.value !== 'from') continue;
98
+ const source = tokens[end + 1]?.value;
99
+ if (!/^['"]\.\.?\//.test(source ?? '')) continue;
100
+ const specifier = source.slice(1, -1);
101
+ for (let j = i + 1; j < end; j++) {
102
+ const name = tokens[j].value;
103
+ if (!IDENTIFIER.test(name) || ['as', 'type'].includes(name)) continue;
104
+ if (tokens[j - 1]?.value === 'as') continue;
105
+ const alias = tokens[j + 1]?.value === 'as' ? tokens[j + 2]?.value : name;
106
+ imports.set(alias, { name, specifier, namespace: tokens[j - 1]?.value === '*' });
107
+ }
108
+ // `* as owner` has no ordinary imported name.
109
+ if (tokens[i + 1]?.value === '*' && tokens[i + 2]?.value === 'as') {
110
+ imports.set(tokens[i + 3]?.value, { name: '*', specifier, namespace: true });
111
+ }
112
+ i = end + 1;
113
+ }
114
+ return imports;
115
+ }
116
+
117
+ /**
118
+ * Calls on added unified-diff lines only. Missing or malformed source never aborts a packet.
119
+ * `readFile` takes an absolute path, as in relatedFiles; windows are current-file line ranges.
120
+ * @param {{path: string, diffLines: string[], header: string, io: {
121
+ * repoRoot: string, readFile: (file: string) => string | null | Promise<string | null>,
122
+ * windows?: Array<{start: number, end: number}>
123
+ * }}} opts
124
+ * @returns {Promise<Definition[]>}
125
+ */
126
+ export async function findDefinitions({ path, diffLines, header, io }) {
127
+ const found = [];
128
+ if (!safePath(path) || !EXTENSIONS.includes(nodePath.posix.extname(path).toLowerCase())) return found;
129
+ try {
130
+ const cache = new Map();
131
+ const read = async (rel) => {
132
+ if (!safePath(rel)) return null;
133
+ if (!cache.has(rel)) {
134
+ try { cache.set(rel, await io.readFile(nodePath.resolve(io.repoRoot, rel))); }
135
+ catch { cache.set(rel, null); }
136
+ }
137
+ return cache.get(rel);
138
+ };
139
+ const text = await read(path);
140
+ if (typeof text !== 'string') return found;
141
+ const added = tokensOf(diffLines.filter((line) => line.startsWith('+') && !line.startsWith('+++')).map((line) => line.slice(1)).join('\n'));
142
+ const defined = new Set(added.filter((token, i) => ['function', 'const', 'class', 'let', 'var'].includes(added[i - 1]?.value) ||
143
+ (added[i + 1]?.value === '(' && added[closing(added, i + 1) + 1]?.value === '{')).map((token) => token.value));
144
+ const imports = importsOf(header);
145
+ const seen = new Set();
146
+ for (let i = 0; i < added.length; i++) {
147
+ const name = added[i].value;
148
+ if (!IDENTIFIER.test(name) || added[i + 1]?.value !== '(' || KEYWORDS.has(name) || defined.has(name)) continue;
149
+ const receiver = added[i - 1]?.value === '.' ? added[i - 2]?.value : null;
150
+ const binding = imports.get(receiver ?? name);
151
+ let definition = declaration(text, path, name, false);
152
+ if (definition && io.windows?.some((w) => w.start <= definition.start && definition.end <= w.end)) definition = null;
153
+ if (!definition && binding && (!receiver || binding.namespace)) {
154
+ const target = nodePath.posix.normalize(nodePath.posix.join(nodePath.posix.dirname(path), binding.specifier));
155
+ if (!safePath(target)) continue;
156
+ const candidates = nodePath.posix.extname(target) ? [target] : [target, ...EXTENSIONS.map((ext) => target + ext), ...EXTENSIONS.map((ext) => `${target}/index${ext}`)];
157
+ for (const candidate of candidates) {
158
+ const imported = await read(candidate);
159
+ if (typeof imported !== 'string') continue;
160
+ definition = declaration(imported, candidate, binding.namespace ? name : binding.name, true);
161
+ break;
162
+ }
163
+ }
164
+ if (!definition) continue;
165
+ const key = `${definition.path}:${definition.start}`;
166
+ if (!seen.has(key)) { seen.add(key); found.push(definition); }
167
+ }
168
+ } catch { /* Partial context is preferable to aborting review on unparseable source. */ }
169
+ return found;
170
+ }
@@ -19,7 +19,7 @@
19
19
  * ≥ `escalation.warning_threshold` (2) warnings or any `critical` — climb at once (rule 2c,
20
20
  * B34: `trigger: review_warnings`); `warn_rounds_at_level` counts them and resets on a climb.
21
21
  * 5. Cap: `review.max_rounds_per_file` (4) reached with an open finding ⇒ the L3 patch rung
22
- * (rule 6; once per block — shared with rule 5's ceiling), whose `patch_check` round is
22
+ * (rule 6; once per file — shared with rule 5's ceiling), whose `patch_check` round is
23
23
  * OUTSIDE the cap. The rung is also rule 5: rule 2/2b at the running ceiling (L2 — L3 is never
24
24
  * a coding level, R1) takes it, so for an L2 block the rung IS the +1 at round 2. After the
25
25
  * patch the block continues at L2 (§3.6, Fable ruling A): `l3_rung_used = true`,
@@ -127,7 +127,7 @@ import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallba
127
127
  * @property {string[]} [finding_history] - claim/evidence keys from earlier rounds of this file.
128
128
  * @property {string[]} [finding_ids] - ids seen in earlier rounds, including closed findings.
129
129
  * @property {string | null} reviewed_content - the content the last counted round reviewed.
130
- * @property {boolean} l3_rung_used - the block's L3 rung (rule 5 or 6) was used — once per block.
130
+ * @property {boolean} l3_rung_used - the file's L3 rung (rule 5 or 6) was used — once per file.
131
131
  * @property {'open' | 'complete' | 'stopped'} status
132
132
  * @property {string} [stop_content_hash] - bytes reviewed by a stop (absent in legacy states).
133
133
  * @property {string} [stop_lens] - the lens whose packet stopped before a round.
@@ -146,7 +146,7 @@ import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallba
146
146
  * @property {import('./triage.mjs').JevAsk} [jev]
147
147
  * @property {import('./triage.mjs').RuleFn} [rule]
148
148
  * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow]
149
- * @property {(req: {file: string, round: number, level: string, open: Finding[], l3_patch: boolean}) => Promise<unknown>} [fix] - a fresh coder session at `level`; `l3_patch` = the block's L3 patch is in the tree (§3.6).
149
+ * @property {(req: {file: string, round: number, level: string, open: Finding[], l3_patch: boolean}) => Promise<unknown>} [fix] - a fresh coder session at `level`; `l3_patch` = the file's L3 patch is in the tree (§3.6).
150
150
  * @property {(req: {file: string, level: 'L3', open: Finding[]}) => Promise<unknown>} [patch] - the L3 patch rung (≤ 80 lines, owned files only).
151
151
  * @property {string | null} [base] - the block base (`recheck_scope: file`, and the B56 moved-code diff).
152
152
  * @property {ReadonlyArray<string>} [peers] - B56: the block's other changed files (moved-code hints).
@@ -582,7 +582,7 @@ async function recheckSession(packet, deps, open) {
582
582
  function decideNext(state, openBefore, s, kind, closedIds) {
583
583
  if (state.open.length === 0) return { action: 'complete' };
584
584
  if (kind === 'patch_check') {
585
- // the block's one rung is spent: a patch that closed no old finding stops FIRST — even when
585
+ // the file's one rung is spent: a patch that closed no old finding stops FIRST — even when
586
586
  // the rung was taken at the cap — then the cap; else (B73: ≥ 1 old id closed is progress,
587
587
  // whatever new findings its fix hunks brought) the block continues at L2: a fix, no stall
588
588
  if (closedIds === 0) return { action: 'stop', reason: 'l3_patch_exhausted', trigger: 'review_stall' };
@@ -17,7 +17,7 @@
17
17
  * the latest refusal row for the hash is signed, carries `content_hash` (a hash-less one does
18
18
  * not qualify, though it still refuses) and names at least one finding, and every finding it
19
19
  * names and every open id of the file's unsuperseded `review.cap` rows is cleared by a waiver the gate
20
- * accepts (the same rule as below: a critical only by `by: human`). Such a file is never
20
+ * accepts (a critical needs a human or an agreed agent waiver issued in autopilot). Such a file is never
21
21
  * approved; the close writes one signed `gate.review_waived_all` row for it ({@link reviewGateCheck});
22
22
  * - `unsigned`: the file's approvals for that hash exist but none carries a valid MAC;
23
23
  * - `not_approved`: (B52) the file's signed approval for its current hash is STALE — a later row of
@@ -26,8 +26,8 @@
26
26
  * reviewed the content, {@link neverReviewed}; a `review.round` with `open_after` not 0; a
27
27
  * `review.cap`; a refusal row with no `content_hash` counts for every hash of its file). One
28
28
  * refusal per open finding the latest such row names, each cleared only by a valid waiver for
29
- * the severity that row RECORDS (anything but warning/nit — a critical — only by a signed
30
- * `by: human` waiver); a row that names no finding is one `not_approved <file>` that only a
29
+ * the severity that row RECORDS (a critical needs a signed human waiver or an agreed agent
30
+ * waiver issued in autopilot); a row that names no finding is one `not_approved <file>` that only a
31
31
  * fresh approval clears. An old ledger's `review.result` row has no `approved` field and is
32
32
  * trusted neither way: the ticket's signed result file decides ({@link resultFilesFor}); a
33
33
  * file that is missing or fails its MAC refuses `not_approved <file>`. Refusal rows count
@@ -43,7 +43,7 @@
43
43
  * - `review_cap`: a file stopped at `review_cap` has an open finding with no signed
44
44
  * `review.waived {by: human}` row — unless a signed approval of the file's current hash comes
45
45
  * AFTER the cap row (ledger order) and no later refusal row made it stale (the human fixed it
46
- * by hand and it was reviewed), or a verified human or agreed agent reopen after the cap is followed by a
46
+ * by hand and it was reviewed), or a verified human, agreed agent or automatic reopen after the cap is followed by a
47
47
  * signed review round of the current bytes; a finding already refused as `not_approved` for that file is
48
48
  * not repeated. B73: this detail, and an `unreviewed` detail for a file with a cap row, ends
49
49
  * with the stop's ways out (`exits: block reopen, block waive, review-file --answers,
@@ -85,6 +85,7 @@ import { lstatSync } from 'node:fs';
85
85
  import path from 'node:path';
86
86
  import { acceptedTranscriptHits } from './accept-hit.mjs';
87
87
  import { countedAgentWaivers } from './agent-waiver.mjs';
88
+ import { isAutomaticReopen } from './reopen.mjs';
88
89
  import { exec } from '../util/exec.mjs';
89
90
  import { readRun, writeSigned } from '../state/run.mjs';
90
91
  import { StateError } from '../state/paths.mjs';
@@ -287,7 +288,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
287
288
  // Acceptance is a review input. Every older review verdict belongs to the previous input.
288
289
  const mine = rows.filter((r, i) => r?.block === block && (runId === undefined || r.run === undefined || r.run === runId)
289
290
  && !(i < amendedAt && typeof r.event === 'string' && r.event.startsWith('review.')));
290
- /** A verified human or agreed agent reopen followed by a real review clears older cap ids.
291
+ /** A verified human, agreed agent or automatic reopen followed by a real review clears older cap ids.
291
292
  * @param {Record<string, any>} cap @returns {boolean}
292
293
  */
293
294
  const supersededCap = (cap) => {
@@ -295,7 +296,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
295
296
  if (hash === undefined) return false;
296
297
  const at = mine.indexOf(cap);
297
298
  return mine.some((r, i) => i > at && r.event === 'review.reopened' && r.run === runId && r.file === cap.file
298
- && (r.by === 'human' || (r.by === 'agent' && r.verdict?.verdict === 'agree')) && ok(r)
299
+ && (r.by === 'human' || (r.by === 'agent' && r.verdict?.verdict === 'agree') || isAutomaticReopen(r)) && ok(r)
299
300
  && mine.some((round, j) => j > i && round.event === 'review.round' && round.run === runId && round.file === cap.file && round.content_hash === hash && ok(round)));
300
301
  };
301
302
  /**
@@ -368,7 +369,8 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
368
369
  * row RECORDS for it — REQUIRED: only an explicit null (none recorded there; the signed review
369
370
  * rows decide, {@link autopilotWaiverProblem}) or warning/nit may be delegated; anything else
370
371
  * (`undefined`, a critical, an unknown value) is the human's alone ({@link delegableSeverity}),
371
- * except that an answer also clears a critical. `invalid`: why the autopilot waivers naming it do
372
+ * except that an answer or a counted agent waiver issued in autopilot also clears a critical.
373
+ * `invalid`: why the autopilot waivers naming it do
372
374
  * not count (null when none names it).
373
375
  * `exceptTicket`: see {@link answered}. `answerSeverity`: the severity the answer rule reads
374
376
  * (default `severity`); the late-finding, cap and proof loops pass the one RECORDED for the id,
@@ -379,9 +381,11 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
379
381
  const waiverOf = (file, finding, severity, exceptTicket = null, answerSeverity = severity) => {
380
382
  if (waivers.some((r) => matches(r, file, finding))) return { by: 'human', invalid: null };
381
383
  if (answered(file, finding, answerSeverity, exceptTicket)) return { by: 'answer', invalid: null };
382
- if (finding !== PROOF_FINDING && delegableSeverity(severity)) {
384
+ if (finding !== PROOF_FINDING && (delegableSeverity(severity) || severity === 'critical')) {
383
385
  const recorded = recordedSeverity(mine, { block, file, finding, key, verify: ok });
384
- if (DELEGABLE_SEVERITIES.includes(recorded.severity) && agentWaivers.some((r) => matches(r, file, finding) && r.severity === recorded.severity)) return { by: 'agent', invalid: null };
386
+ const agentMayWaive = (delegableSeverity(severity) && DELEGABLE_SEVERITIES.includes(recorded.severity))
387
+ || (severity === 'critical' && recorded.severity === 'critical');
388
+ if (agentMayWaive && agentWaivers.some((r) => matches(r, file, finding) && r.severity === recorded.severity)) return { by: 'agent', invalid: null };
385
389
  }
386
390
  const named = autopilotWaivers.filter((r) => matches(r, file, finding));
387
391
  // the unverified pre-check (`verify` given) reads no autopilot waiver: it cannot check a grant
@@ -904,15 +908,31 @@ export function repoRelativePath(raw) {
904
908
  /**
905
909
  * The block's file set (§4.1): changed tracked files since `base` (deletions included, hashed
906
910
  * `DELETED_HASH`) ∪ untracked files, filtered to `owned` (`matchOwned`, default the registry's `ownsFile` with `ownedKinds`: an exact entry owns its path and the paths below it unless recorded as a regular file, B57), each with its content hash.
907
- * Every path goes through `repoRelativePath`.
908
- * @param {{repoRoot: string, base: string, owned: string[], ownedKinds?: Record<string, string>, matchOwned?: (file: string) => boolean, changed?: string[]}} opts
911
+ * Every path goes through `repoRelativePath`; files deleted now get a git base-existence check
912
+ * and, if absent at base, a history check. Omit them only when git confirms commits since base
913
+ * touched them in this workspace. Failed or empty history checks keep the deletion for review.
914
+ * Other files need no extra git call; deleted files need at most two.
915
+ * @param {{repoRoot: string, base: string, owned: string[], ownedKinds?: Record<string, string>, matchOwned?: (file: string) => boolean, changed?: string[], gitRun?: (argv: string[], opts: Parameters<typeof exec>[1]) => Promise<{result: 'ok' | 'failed', stdout?: string, timedOut?: boolean}>}} opts
909
916
  * `changed`: the tree's changed files when the caller already listed them ({@link changedFiles}).
917
+ * `gitRun`: the base-existence and history runner (defaults to {@link exec}).
910
918
  * @returns {Promise<Array<{file: string, content_hash: string}>>}
911
919
  */
912
- export async function blockFileSet({ repoRoot, base, owned, ownedKinds, matchOwned, changed }) {
920
+ export async function blockFileSet({ repoRoot, base, owned, ownedKinds, matchOwned, changed, gitRun = exec }) {
913
921
  const all = changed ?? (await changedFiles({ repoRoot, base }));
914
922
  const isOwned = matchOwned ?? ((/** @type {string} */ f) => ownsFile(owned, f, ownedKinds));
915
- return all.filter((f) => isOwned(f)).map((file) => ({ file, content_hash: currentHash(repoRoot, file) }));
923
+ const files = [];
924
+ for (const file of all.filter((f) => isOwned(f))) {
925
+ const content_hash = currentHash(repoRoot, file);
926
+ if (content_hash === DELETED_HASH) {
927
+ const atBase = await gitRun(['git', 'ls-tree', '--name-only', '-z', base, '--', file], { cwd: repoRoot, env: gitChildEnv(), timeoutMs: 30000 });
928
+ if (atBase.result === 'ok' && atBase.stdout === '') {
929
+ const history = await gitRun(['git', 'log', '--format=%H', `${base}..HEAD`, '--', file], { cwd: repoRoot, env: gitChildEnv(), timeoutMs: 30000 });
930
+ if (history.result === 'ok' && typeof history.stdout === 'string' && history.stdout.trim() !== '') continue;
931
+ }
932
+ }
933
+ files.push({ file, content_hash });
934
+ }
935
+ return files;
916
936
  }
917
937
 
918
938
  /** Safety check for npm locks excluded from model review; other formats are not parsed.