@lyupro/codex-bridge 0.6.8 → 0.6.10

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 (128) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +6 -4
  3. package/bin/codex-bridge.mjs +35 -177
  4. package/cli/command-help.mjs +49 -0
  5. package/cli/command-registry.mjs +284 -0
  6. package/cli/copy.mjs +42 -5
  7. package/cli/dispatcher-contract-record.mjs +3 -2
  8. package/cli/doctor-host-side.mjs +110 -0
  9. package/cli/doctor.mjs +17 -1
  10. package/cli/home-inspection.mjs +122 -0
  11. package/cli/home-plan-execute.mjs +140 -0
  12. package/cli/home-removal-plan.mjs +61 -0
  13. package/cli/hook-recognizer.mjs +78 -0
  14. package/cli/host-contract.mjs +11 -5
  15. package/cli/host-inspection.mjs +156 -0
  16. package/cli/host-removal.mjs +91 -0
  17. package/cli/image-evidence.mjs +44 -0
  18. package/cli/install-owners.mjs +244 -0
  19. package/cli/install-record.mjs +71 -9
  20. package/cli/install.mjs +88 -75
  21. package/cli/inventory-removal.mjs +84 -0
  22. package/cli/inventory-transition.mjs +66 -0
  23. package/cli/lifecycle-lock-clear.mjs +104 -0
  24. package/cli/lifecycle-lock-inspect.mjs +92 -0
  25. package/cli/lifecycle-lock.mjs +222 -0
  26. package/cli/lifecycle-transaction.mjs +57 -0
  27. package/cli/link-segments.mjs +45 -0
  28. package/cli/model-set.mjs +4 -3
  29. package/cli/model-speed.mjs +9 -2
  30. package/cli/model.mjs +5 -3
  31. package/cli/owner-record.mjs +40 -0
  32. package/cli/owner-sync-apply.mjs +108 -0
  33. package/cli/owner-sync.mjs +115 -0
  34. package/cli/projects.mjs +5 -2
  35. package/cli/prune-args.mjs +9 -4
  36. package/cli/prune.mjs +23 -72
  37. package/cli/purge-live-runs.mjs +98 -0
  38. package/cli/purge-preflight.mjs +103 -0
  39. package/cli/read.mjs +22 -1
  40. package/cli/record-removal.mjs +43 -0
  41. package/cli/removal-outcomes.mjs +115 -0
  42. package/cli/removal-plan.mjs +187 -0
  43. package/cli/remove-layout.mjs +40 -1
  44. package/cli/rules-owners.mjs +4 -2
  45. package/cli/settings-merge.mjs +3 -29
  46. package/cli/terminal-question.mjs +41 -0
  47. package/cli/uninstall-purge.mjs +139 -0
  48. package/cli/uninstall-rules.mjs +72 -0
  49. package/cli/uninstall.mjs +208 -127
  50. package/cli/unlock-lifecycle.mjs +102 -0
  51. package/cli/unlock.mjs +7 -4
  52. package/cli/update.mjs +102 -27
  53. package/package.json +4 -3
  54. package/scripts/home-write-audit.mjs +286 -0
  55. package/scripts/home-write-inventory.mjs +59 -0
  56. package/scripts/home-write-lexer.mjs +182 -0
  57. package/scripts/isolated-roots.mjs +6 -1
  58. package/scripts/open-work/check.mjs +180 -0
  59. package/scripts/open-work/live-thresholds.mjs +155 -0
  60. package/scripts/open-work/register.mjs +56 -0
  61. package/scripts/root-guard/root-entries.mjs +15 -0
  62. package/scripts/run-tests.mjs +26 -1
  63. package/src/agents/codex-advisor.md +23 -10
  64. package/src/agents/codex-build.md +24 -14
  65. package/src/agents/codex-review.md +17 -10
  66. package/src/agents/codex-scout.md +22 -12
  67. package/src/home/conventions.md +9 -0
  68. package/src/home/hooks/guard-tries.mjs +6 -4
  69. package/src/home/hooks/order-gate.mjs +27 -19
  70. package/src/home/hooks/reply-guard.mjs +2 -7
  71. package/src/home/hooks/reply-parser.mjs +1 -1
  72. package/src/home/hooks/worktree-witness.mjs +6 -6
  73. package/src/home/lib/agents.mjs +1 -1
  74. package/src/home/lib/atomic-json.mjs +24 -6
  75. package/src/home/lib/command-path.mjs +32 -0
  76. package/src/home/lib/config-edit.mjs +25 -10
  77. package/src/home/lib/dispatcher-command.mjs +4 -2
  78. package/src/home/lib/dispatcher-state.mjs +25 -10
  79. package/src/home/lib/file-lock.mjs +41 -10
  80. package/src/home/lib/handback-witness.mjs +7 -4
  81. package/src/home/lib/home-registry.mjs +211 -0
  82. package/src/home/lib/home-write.mjs +189 -0
  83. package/src/home/lib/hook-diagnostics.mjs +20 -0
  84. package/src/home/lib/host-observations.mjs +7 -4
  85. package/src/home/lib/kernel-lock.mjs +171 -0
  86. package/src/home/lib/meta/advice-status.mjs +2 -2
  87. package/src/home/lib/meta/advice-verdict.mjs +30 -1
  88. package/src/home/lib/meta/chain.mjs +19 -8
  89. package/src/home/lib/meta/context-only.mjs +43 -0
  90. package/src/home/lib/meta/events.mjs +16 -3
  91. package/src/home/lib/meta/launch-rows.mjs +23 -0
  92. package/src/home/lib/meta/paths.mjs +24 -24
  93. package/src/home/lib/meta/reply.mjs +37 -3
  94. package/src/home/lib/meta/run-snapshots.mjs +29 -0
  95. package/src/home/lib/meta/run-state.mjs +3 -0
  96. package/src/home/lib/meta/snapshot-format.mjs +96 -0
  97. package/src/home/lib/meta/verdict.mjs +30 -24
  98. package/src/home/lib/required-inputs.mjs +48 -8
  99. package/src/home/lib/run-codex.mjs +6 -3
  100. package/src/home/lib/run-config.mjs +10 -1
  101. package/src/home/lib/runner/advise-carry.mjs +2 -1
  102. package/src/home/lib/runner/args.mjs +16 -5
  103. package/src/home/lib/runner/attach.mjs +21 -14
  104. package/src/home/lib/runner/build-evidence.mjs +29 -0
  105. package/src/home/lib/runner/codex-availability.mjs +50 -0
  106. package/src/home/lib/runner/codex-cmd.mjs +2 -19
  107. package/src/home/lib/runner/continuation.mjs +60 -8
  108. package/src/home/lib/runner/exit-codes.mjs +13 -0
  109. package/src/home/lib/runner/flag-baseline.mjs +105 -0
  110. package/src/home/lib/runner/flag-scan.mjs +165 -0
  111. package/src/home/lib/runner/git-paths.mjs +74 -0
  112. package/src/home/lib/runner/git-state.mjs +37 -66
  113. package/src/home/lib/runner/launcher.mjs +233 -273
  114. package/src/home/lib/runner/order-claim.mjs +84 -0
  115. package/src/home/lib/runner/order-owner.mjs +18 -0
  116. package/src/home/lib/runner/pass-gate.mjs +109 -0
  117. package/src/home/lib/runner/preflight.mjs +22 -18
  118. package/src/home/lib/runner/prompts.mjs +42 -4
  119. package/src/home/lib/runner/question-kind.mjs +41 -0
  120. package/src/home/lib/runner/run-env.mjs +2 -1
  121. package/src/home/lib/runner/sandbox-probe.mjs +1 -1
  122. package/src/home/lib/runner/schemas.mjs +4 -1
  123. package/src/home/lib/runner/scope-check.mjs +55 -28
  124. package/src/home/lib/runner/task-input.mjs +11 -2
  125. package/src/home/lib/runner/worker-admission.mjs +43 -0
  126. package/src/home/lib/runner/worker.mjs +19 -18
  127. package/src/home/lib/task-header.mjs +127 -0
  128. package/src/home/lib/write-meta.mjs +17 -5
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Judges added lines against start bytes because Plan_60 D3a must not blame earlier dirty work or TODO test data.
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { readFlagBaseline, removeFlagBaselineCopies, FLAG_BASELINE_DIR } from './flag-baseline.mjs';
9
+ import { runSnapshotChanges } from '../meta/run-snapshots.mjs';
10
+
11
+ const STUB = /test\.(skip|only)|it\.(skip|only)|describe\.(skip|only)|NotImplemented/;
12
+ const MARKER_PREFIX = /(?:^[ \t]*|\/\/[ \t]*|\/\*+[ \t]*|#[ \t]*|<!--[ \t]*|--[ \t]*|^[ \t]*\*[ \t]*)$/;
13
+ const LIST_PREFIX = /^[ \t]*(?:[-+*]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?$/;
14
+
15
+ export function isFlagLine(text) {
16
+ if (STUB.test(text)) return true;
17
+ for (const match of text.matchAll(/(?:TODO|FIXME)(?![A-Za-z0-9_])/g)) {
18
+ const prefix = text.slice(0, match.index);
19
+ if (MARKER_PREFIX.test(prefix) || LIST_PREFIX.test(prefix)) return true;
20
+ }
21
+ return false;
22
+ }
23
+
24
+ function addedLines(diff) {
25
+ const added = [];
26
+ let inside = false;
27
+ let number = 0;
28
+ for (const line of diff.split('\n')) {
29
+ if (line.startsWith('diff ')) {
30
+ inside = false;
31
+ continue;
32
+ }
33
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
34
+ if (hunk) {
35
+ inside = true;
36
+ number = Number(hunk[1]);
37
+ } else if (inside && line.startsWith('+')) {
38
+ added.push({ number, text: line.slice(1) });
39
+ number += 1;
40
+ } else if (inside && line.startsWith(' ')) {
41
+ number += 1;
42
+ }
43
+ }
44
+ return added;
45
+ }
46
+
47
+ function compareFile(baseFile, currentFile) {
48
+ const result = spawnSync('git', [
49
+ '-c', 'core.quotepath=false', 'diff', '--no-index', '--no-color', '--no-ext-diff', '--no-textconv',
50
+ '--ignore-cr-at-eol', '--diff-algorithm=myers', '-U0', '--', baseFile, currentFile,
51
+ ], { windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 });
52
+ if (result.error || ![0, 1].includes(result.status)) return null;
53
+ return result.status === 0 ? [] : addedLines(result.stdout.toString('utf8'));
54
+ }
55
+
56
+ // `git show <head>:<path>` exits non-zero both for a path the start commit lacks (a new file: every line is added)
57
+ // and for a start commit nobody can read; only a readable commit lets the first case accuse anyone.
58
+ function headReadable(repoRoot, head) {
59
+ const result = spawnSync('git', ['-C', repoRoot, 'cat-file', '-e', `${head}^{commit}`], {
60
+ windowsHide: true, timeout: 30_000,
61
+ });
62
+ return !result.error && result.status === 0;
63
+ }
64
+
65
+ function scanPath({ name, entry, baseline, headKnown, runDir, repoRoot, gaps }) {
66
+ const currentFile = path.join(repoRoot, name);
67
+ let current;
68
+ try {
69
+ const stat = fs.lstatSync(currentFile);
70
+ if (!stat.isFile()) return [];
71
+ if (stat.size > baseline.limits.perFileBytes) {
72
+ gaps.push(`${name}: end content over the limit`);
73
+ return [];
74
+ }
75
+ current = fs.readFileSync(currentFile);
76
+ } catch (error) {
77
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') gaps.push(`${name}: end content unreadable`);
78
+ return [];
79
+ }
80
+ if (current.length > baseline.limits.perFileBytes) {
81
+ gaps.push(`${name}: end content over the limit`);
82
+ return [];
83
+ }
84
+ if (current.subarray(0, 8000).includes(0)) return [];
85
+
86
+ let base = Buffer.alloc(0);
87
+ let baseFile;
88
+ let temporary = false;
89
+ if (entry) {
90
+ if (entry.state === 'copied') {
91
+ baseFile = path.join(runDir, FLAG_BASELINE_DIR, entry.copy);
92
+ try {
93
+ base = fs.readFileSync(baseFile);
94
+ } catch {
95
+ gaps.push(`${name}: start content unreadable`);
96
+ return [];
97
+ }
98
+ } else if (!['deleted', 'absent'].includes(entry.state)) {
99
+ gaps.push(`${name}: start content ${entry.state}`);
100
+ return [];
101
+ }
102
+ } else if (!headKnown) {
103
+ gaps.push(`${name}: start commit unreadable`);
104
+ return [];
105
+ } else {
106
+ const result = spawnSync('git', ['-C', repoRoot, 'show', `${baseline.head}:${name}`], {
107
+ windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024,
108
+ });
109
+ if (result.error || result.status === null) {
110
+ gaps.push(`${name}: start content unreadable`);
111
+ return [];
112
+ }
113
+ if (result.status === 0) base = result.stdout;
114
+ if (base.length) {
115
+ const copyDir = path.join(runDir, FLAG_BASELINE_DIR);
116
+ fs.mkdirSync(copyDir, { recursive: true });
117
+ baseFile = path.join(copyDir, `head-${randomUUID()}.bin`);
118
+ fs.writeFileSync(baseFile, base, { flag: 'wx' });
119
+ temporary = true;
120
+ }
121
+ }
122
+
123
+ if (!base.length) {
124
+ return current.toString('utf8').split('\n').map((text, index) => ({ number: index + 1, text }));
125
+ }
126
+ let added;
127
+ try {
128
+ added = compareFile(baseFile, currentFile);
129
+ } finally {
130
+ if (temporary) fs.unlinkSync(baseFile);
131
+ }
132
+ if (added === null) {
133
+ gaps.push(`${name}: diff failed`);
134
+ return [];
135
+ }
136
+ return added;
137
+ }
138
+
139
+ export function scanRunFlags({ runDir, repoRoot }) {
140
+ const gaps = [];
141
+ const hits = [];
142
+ try {
143
+ const baseline = readFlagBaseline(runDir);
144
+ if (!baseline) {
145
+ gaps.push('no flag baseline');
146
+ } else if (!baseline.complete && baseline.files.length === 0) {
147
+ gaps.push(`flag baseline incomplete: ${baseline.reason}`);
148
+ } else {
149
+ const entries = new Map(baseline.files.map((entry) => [entry.path, entry]));
150
+ const comparison = runSnapshotChanges(runDir);
151
+ if (!comparison.ok) gaps.push('snapshots not comparable');
152
+ const names = [...new Set([...entries.keys(), ...(comparison.ok ? comparison.changed : [])])].sort();
153
+ const headKnown = headReadable(repoRoot, baseline.head);
154
+ for (const name of names) {
155
+ const added = scanPath({ name, entry: entries.get(name), baseline, headKnown, runDir, repoRoot, gaps });
156
+ for (const line of added) {
157
+ if (hits.length < 20 && isFlagLine(line.text)) hits.push(`${name}:${line.number}: ${line.text.trim()}\n`);
158
+ }
159
+ }
160
+ }
161
+ } finally {
162
+ removeFlagBaselineCopies(runDir);
163
+ }
164
+ return { text: hits.join(''), coverage: { complete: gaps.length === 0, gaps } };
165
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * One responsibility: every git call that lists repository paths runs here with `-z` and returns exact names.
3
+ * On 2026-09-30, quoted octal escapes for areas/tickets-и-notifier.md made scope preflight
4
+ * refuse both its literal path and an ASCII glob, blocking a vault with Cyrillic file names.
5
+ */
6
+ import { spawnSync } from 'node:child_process';
7
+
8
+ function pathRecords(repoRoot, args) {
9
+ const result = spawnSync(
10
+ 'git',
11
+ ['-C', repoRoot, ...args],
12
+ { windowsHide: true, maxBuffer: 64 * 1024 * 1024 },
13
+ );
14
+ if (result.error || result.status !== 0) return null;
15
+
16
+ let output;
17
+ try {
18
+ // A leading BOM can be part of the first name, so decoding must preserve it too.
19
+ output = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(result.stdout);
20
+ } catch (cause) {
21
+ const error = new Error(`Repository ${repoRoot} contains a file name that is not UTF-8`, { cause });
22
+ error.code = 'ERR_GIT_PATH_NOT_UTF8';
23
+ throw error;
24
+ }
25
+ const names = output.split('\0');
26
+ if (names.at(-1) === '') names.pop();
27
+ return names;
28
+ }
29
+
30
+ export function listRepositoryPaths(repoRoot) {
31
+ return pathRecords(repoRoot, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']);
32
+ }
33
+
34
+ export function listUntrackedPaths(repoRoot) {
35
+ return pathRecords(repoRoot, ['ls-files', '-z', '-o', '--exclude-standard']);
36
+ }
37
+
38
+ export function numstatRows(repoRoot) {
39
+ const records = pathRecords(repoRoot, ['diff', 'HEAD', '--numstat', '--no-renames', '-z']);
40
+ if (records === null) return null;
41
+ return records.map((record) => {
42
+ const firstTab = record.indexOf('\t');
43
+ const secondTab = record.indexOf('\t', firstTab + 1);
44
+ return {
45
+ added: record.slice(0, firstTab),
46
+ deleted: record.slice(firstTab + 1, secondTab),
47
+ path: record.slice(secondTab + 1),
48
+ };
49
+ });
50
+ }
51
+
52
+ // The owner spells each command whole: a caller that composes path-listing arguments itself is what
53
+ // tests/git-path-listing-guard.test.mjs exists to catch.
54
+ export function diffNames(repoRoot, range) {
55
+ return pathRecords(repoRoot, ['diff', '--name-only', '-z', range]);
56
+ }
57
+
58
+ export function commitNames(repoRoot, sha) {
59
+ return pathRecords(repoRoot, ['show', '--name-only', '--format=', '-z', sha]);
60
+ }
61
+
62
+ export function porcelainPaths(repoRoot) {
63
+ const records = pathRecords(repoRoot, ['status', '--porcelain', '-z']);
64
+ if (records === null) return null;
65
+ const names = new Set();
66
+ for (let index = 0; index < records.length; index += 1) {
67
+ const record = records[index];
68
+ names.add(record.slice(3));
69
+ if (record[0] === 'R' || record[1] === 'R' || record[0] === 'C' || record[1] === 'C') {
70
+ names.add(records[++index]);
71
+ }
72
+ }
73
+ return [...names];
74
+ }
@@ -10,7 +10,10 @@
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import { spawnSync } from 'node:child_process';
13
+ import { createHash } from 'node:crypto';
13
14
  import { runsRoot } from './runs-root.mjs';
15
+ import { commitNames, diffNames, listUntrackedPaths, numstatRows, porcelainPaths } from './git-paths.mjs';
16
+ import { encodeSnapshot } from '../meta/snapshot-format.mjs';
14
17
 
15
18
  export const MAX_LOG = 256 * 1024 * 1024;
16
19
 
@@ -32,38 +35,48 @@ export function runsPrefixInside(repo) {
32
35
 
33
36
  /**
34
37
  * State of the worktree in terms of actual content, not porcelain letters: line counts
35
- * per tracked file plus sizes of untracked ones. A porcelain code stays ` M` when Codex
38
+ * per tracked file plus content hashes of untracked ones. A porcelain code stays ` M` when Codex
36
39
  * edits an already-modified file, so comparing codes would report "0 files changed" for
37
40
  * a run that did real work.
38
41
  */
39
42
  export function worktreeSnapshot(repo) {
40
43
  const skip = runsPrefixInside(repo);
41
- // The path is the third tab-separated field in both git outputs used here.
42
- const mine = (line) => Boolean(skip) && (line.split('\t')[2] || '').startsWith(skip);
43
44
  // `--no-renames` because every reader of this snapshot compares paths against a scope: with
44
45
  // rename detection on, numstat prints one row spelled `old => new` (or `dir/{a => b}/file`),
45
46
  // which is not a path and matches no pattern — an in-scope rename would be judged a stray, and
46
47
  // the witness named that token at the orchestrator on 2026-09-20. Without it a rename is a
47
48
  // deletion plus an addition: two rows, both real paths, both judged on their own merits.
48
- const tracked = (git(repo, ['diff', 'HEAD', '--numstat', '--no-renames']).stdout || '')
49
- .split(/\r?\n/)
50
- .filter((line) => line.trim() && !mine(line))
51
- .join('\n')
52
- .trim();
53
- const untracked = (git(repo, ['ls-files', '-o', '--exclude-standard']).stdout || '')
54
- .split(/\r?\n/)
55
- .filter(Boolean)
49
+ // Plan_73 D4 preserves the existing git-failure meaning: no rows from that listing.
50
+ const tracked = (numstatRows(repo) ?? [])
51
+ .filter((row) => !(skip && row.path.startsWith(skip)))
52
+ .map((row) => ({ path: row.path, state: `${row.added}\t${row.deleted}` }));
53
+ const untracked = (listUntrackedPaths(repo) ?? [])
56
54
  .filter((file) => !(skip && `${file}/`.startsWith(skip)))
57
- .map((file) => {
58
- let bytes = 0;
59
- try {
60
- bytes = fs.statSync(path.join(repo, file)).size;
61
- } catch {
62
- // Vanished between listing and stat: size 0 still marks it as present.
63
- }
64
- return `U\t${bytes}\t${file}`;
65
- });
66
- return [tracked, untracked.join('\n')].filter(Boolean).join('\n');
55
+ .map((file) => ({ path: file, state: untrackedState(path.join(repo, file)) }));
56
+ return encodeSnapshot([...tracked, ...untracked]);
57
+ }
58
+
59
+ /**
60
+ * Size plus sha256 of an untracked file, read in chunks so a large one does not have to fit in
61
+ * memory. A file that vanished after listing is `missing`; one that cannot be read (on Windows a
62
+ * file held by another process) is `unreadable` rather than an exception that stops the run.
63
+ */
64
+ function untrackedState(file) {
65
+ let fd;
66
+ try {
67
+ fd = fs.openSync(file, 'r');
68
+ const hash = createHash('sha256');
69
+ const chunk = Buffer.alloc(1024 * 1024);
70
+ let bytes = 0;
71
+ for (let read; (read = fs.readSync(fd, chunk, 0, chunk.length, null)) > 0; bytes += read) {
72
+ hash.update(chunk.subarray(0, read));
73
+ }
74
+ return `U\t${bytes}:${hash.digest('hex')}`;
75
+ } catch (error) {
76
+ return error.code === 'ENOENT' ? 'U\tmissing' : 'U\tunreadable';
77
+ } finally {
78
+ if (fd !== undefined) fs.closeSync(fd);
79
+ }
67
80
  }
68
81
 
69
82
  /**
@@ -81,41 +94,6 @@ export const headSha = (repo) => (git(repo, ['rev-parse', 'HEAD']).stdout || '')
81
94
  export const branchName = (repo) =>
82
95
  (git(repo, ['symbolic-ref', '--short', '-q', 'HEAD']).stdout || '').trim();
83
96
 
84
- const FAKE_DONE_RE =
85
- /TODO|FIXME|test\.(skip|only)|it\.(skip|only)|describe\.(skip|only)|NotImplemented/;
86
-
87
- /**
88
- * Traces of fake completion. `git diff` carries nothing for untracked files, so a brand
89
- * new file full of TODOs would otherwise pass as "Flags: none" — the one case the check
90
- * exists for.
91
- */
92
- export function findFakeDone(repo) {
93
- const skip = runsPrefixInside(repo);
94
- const hits = (git(repo, ['diff', '-U0']).stdout || '')
95
- .split(/\r?\n/)
96
- .filter((l) => l.startsWith('+') && FAKE_DONE_RE.test(l));
97
- for (const file of (git(repo, ['ls-files', '-o', '--exclude-standard']).stdout || '')
98
- .split(/\r?\n/)
99
- .filter(Boolean)
100
- // Same reason as in worktreeSnapshot: inside ~/.claude the run folder is part of the
101
- // worktree, and task.md spells out the very words this scans for ("do not leave TODOs,
102
- // test.skip"). A run flagged itself for quoting its own instructions.
103
- .filter((file) => !(skip && `${file}/`.startsWith(skip)))) {
104
- const full = path.join(repo, file);
105
- try {
106
- if (fs.statSync(full).size > 1024 * 1024) continue;
107
- fs.readFileSync(full, 'utf8')
108
- .split(/\r?\n/)
109
- .forEach((l, i) => {
110
- if (FAKE_DONE_RE.test(l)) hits.push(`${file}:${i + 1}: ${l.trim()}`);
111
- });
112
- } catch {
113
- // Binary or unreadable: nothing to flag.
114
- }
115
- }
116
- return hits.length ? `${hits.slice(0, 20).join('\n')}\n` : '';
117
- }
118
-
119
97
  /** What exactly is under review, resolved from git rather than from wording. */
120
98
  export function reviewScope(repo, changeset) {
121
99
  if (changeset.startsWith('base:')) {
@@ -123,9 +101,7 @@ export function reviewScope(repo, changeset) {
123
101
  return {
124
102
  label: `branch changes against base ${base}`,
125
103
  diffCommand: `git diff ${base}...HEAD`,
126
- files: (git(repo, ['diff', '--name-only', `${base}...HEAD`]).stdout || '')
127
- .split(/\r?\n/)
128
- .filter(Boolean),
104
+ files: diffNames(repo, `${base}...HEAD`) ?? [],
129
105
  };
130
106
  }
131
107
  if (changeset.startsWith('commit:')) {
@@ -133,17 +109,12 @@ export function reviewScope(repo, changeset) {
133
109
  return {
134
110
  label: `commit ${sha}`,
135
111
  diffCommand: `git show ${sha}`,
136
- files: (git(repo, ['show', '--name-only', '--format=', sha]).stdout || '')
137
- .split(/\r?\n/)
138
- .filter(Boolean),
112
+ files: commitNames(repo, sha) ?? [],
139
113
  };
140
114
  }
141
115
  return {
142
116
  label: 'uncommitted changes (staged, unstaged, untracked)',
143
117
  diffCommand: 'git status --porcelain && git diff HEAD',
144
- files: (git(repo, ['status', '--porcelain']).stdout || '')
145
- .split(/\r?\n/)
146
- .filter(Boolean)
147
- .map((l) => l.slice(3).trim()),
118
+ files: porcelainPaths(repo) ?? [],
148
119
  };
149
120
  }