@magnusekdahl/parallix 1.2.1 → 1.3.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 (81) hide show
  1. package/index.js +190 -185
  2. package/lib/agents/agents.js +34 -4
  3. package/lib/agents/agents.ts +40 -5
  4. package/lib/commands/active.js +502 -579
  5. package/lib/commands/active.ts +665 -0
  6. package/lib/commands/checkpoint.js +98 -74
  7. package/lib/commands/checkpoint.ts +79 -0
  8. package/lib/commands/config.js +61 -31
  9. package/lib/commands/config.ts +47 -0
  10. package/lib/commands/coverage-gate.js +271 -246
  11. package/lib/commands/coverage-gate.ts +362 -0
  12. package/lib/commands/diff.js +140 -119
  13. package/lib/commands/diff.ts +125 -0
  14. package/lib/commands/draft.js +770 -923
  15. package/lib/commands/draft.ts +1021 -0
  16. package/lib/commands/handoff.js +588 -608
  17. package/lib/commands/handoff.ts +694 -0
  18. package/lib/commands/integrate.js +1331 -1468
  19. package/lib/commands/integrate.ts +1688 -0
  20. package/lib/commands/mission-start.js +279 -237
  21. package/lib/commands/mission-start.ts +263 -0
  22. package/lib/commands/rebase.js +606 -582
  23. package/lib/commands/rebase.ts +635 -0
  24. package/lib/commands/repair-handoff.js +181 -179
  25. package/lib/commands/repair-handoff.ts +228 -0
  26. package/lib/commands/resolve-conflict.js +121 -102
  27. package/lib/commands/resolve-conflict.ts +112 -0
  28. package/lib/commands/review.js +3 -4
  29. package/lib/commands/review.ts +14 -0
  30. package/lib/commands/setup-review.js +4 -5
  31. package/lib/commands/setup-review.ts +14 -0
  32. package/lib/commands/setup.js +3 -3
  33. package/lib/commands/setup.ts +2 -0
  34. package/lib/commands/stats-backfill.js +394 -359
  35. package/lib/commands/stats-backfill.ts +418 -0
  36. package/lib/commands/stats.js +1424 -1665
  37. package/lib/commands/stats.ts +2186 -0
  38. package/lib/commands/status.js +219 -200
  39. package/lib/commands/status.ts +219 -0
  40. package/lib/commands/verify.js +3 -1
  41. package/lib/commands/verify.ts +2 -0
  42. package/lib/core/nels.js +112 -127
  43. package/lib/core/nels.ts +189 -0
  44. package/lib/core/subagent-limit.js +15 -16
  45. package/lib/core/subagent-limit.ts +25 -0
  46. package/lib/index.js +142 -59
  47. package/lib/index.ts +134 -0
  48. package/lib/review/rebase.js +142 -145
  49. package/lib/review/rebase.ts +188 -0
  50. package/lib/review/review-adapter.js +99 -87
  51. package/lib/review/review-adapter.ts +198 -0
  52. package/lib/review/review-artifacts.js +486 -629
  53. package/lib/review/review-artifacts.ts +622 -0
  54. package/lib/review/review-commands.js +1220 -1373
  55. package/lib/review/review-commands.ts +1579 -0
  56. package/lib/review/review-events.js +544 -886
  57. package/lib/review/review-events.ts +954 -0
  58. package/lib/review/review-loop.js +945 -1020
  59. package/lib/review/review-loop.ts +1174 -0
  60. package/lib/review/review-polling.js +86 -115
  61. package/lib/review/review-polling.ts +194 -0
  62. package/lib/review/review-prompts.js +154 -189
  63. package/lib/review/review-prompts.ts +182 -0
  64. package/lib/review/review-state.js +246 -225
  65. package/lib/review/review-state.ts +353 -0
  66. package/lib/review/review.js +63 -84
  67. package/lib/review/review.ts +138 -0
  68. package/lib/tools/backlog.js +694 -680
  69. package/lib/tools/backlog.ts +835 -0
  70. package/lib/tools/forgejo.js +1270 -1405
  71. package/lib/tools/forgejo.ts +1793 -0
  72. package/lib/tools/gatekeeper.js +106 -96
  73. package/lib/tools/gatekeeper.ts +124 -0
  74. package/lib/tools/redgreen.js +200 -181
  75. package/lib/tools/redgreen.ts +221 -0
  76. package/lib/tools/sessions.js +58 -52
  77. package/lib/tools/sessions.ts +81 -0
  78. package/lib/tools/setup-review.js +902 -975
  79. package/lib/tools/setup-review.ts +1152 -0
  80. package/package.json +4 -4
  81. package/px.js +226 -208
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * coverage-gate.js - run parallix tests with Node's built-in coverage support
5
+ * and enforce a configurable line-coverage threshold (default 90%).
6
+ *
7
+ * Usage:
8
+ * node parallix/lib/commands/coverage-gate.js [--threshold <pct>] [--dry-run]
9
+ *
10
+ * Exit 0 when tests pass and coverage >= threshold.
11
+ * Exit 1 otherwise.
12
+ *
13
+ * Denominator: parallix/index.js plus all nested .js files under parallix/lib
14
+ * Excludes: parallix/test/*, parallix/prompts/*, parallix/config/*.json,
15
+ * coverage output dirs, node_modules, generated/temp files.
16
+ *
17
+ * /tmp write inventory (SC-1): every /tmp or os.tmpdir() write location used by
18
+ * coverage-gate.js and the workflow test infrastructure it runs:
19
+ *
20
+ * 1. PER_RUN_SCRATCH array (line 58) — tracks per-process scratch dirs created
21
+ * by this process. Each entry is a path returned by fs.mkdtempSync().
22
+ * 2. Node --experimental-test-coverage scratch (line 146) — Node creates
23
+ * coverage-*.json files under NODE_V8_COVERAGE. coverage-gate.js sets
24
+ * NODE_V8_COVERAGE to a per-process unique dir via createPerRunScratchDirs()
25
+ * (line 176), so Node writes to a unique path, never /tmp/node-coverage-*.
26
+ * 3. parallix/coverage/ directory (line 153) — created via fs.mkdirSync for
27
+ * lcov output; lives under REPO_ROOT, not /tmp.
28
+ * 4. TEMP_DIR_PREFIXES (lines 26-44) — prefix-based cleanup allowlist used by
29
+ * cleanupNewTempDirs() (lines 94-110). Covers dirs created by test files
30
+ * under parallix/test/*. These prefixes are intentionally NOT modified per SC-8.
31
+ * 5. os.tmpdir() default parameter (lines 85, 94) — listTempEntries() and
32
+ * cleanupNewTempDirs() accept os.tmpdir() as default root. runTests() passes
33
+ * a per-run TMPDIR to the child test process and cleans only that root.
34
+ * 6. Per-process mkdtempSync dirs (line 113) — createPerRunScratchDirs() creates
35
+ * a unique dir via fs.mkdtempSync(path.join(os.tmpdir(), 'node-coverage-'))
36
+ * and records it in PER_RUN_SCRATCH for targeted cleanup.
37
+ * 7. Test files (37 files, ~168 mkdtempSync calls) — all use
38
+ * fs.mkdtempSync(path.join(os.tmpdir(), 'prefix-')) pattern; already
39
+ * per-process unique. No fixed-path /tmp writes in test files. Generic
40
+ * runtime-* dirs are intentionally excluded because runtime-matrix launcher
41
+ * tests keep executable probes there while parallel coverage processes run.
42
+ * 8. /tmp/test-main — not produced by any in-scope code; orphaned 1.1 GB dir
43
+ * on disk from manual integration test (pre-existing, outside parallix/ scope).
44
+ * 9. Prompt templates (review.md, review-verbose.md, act-on-review.md, etc.) —
45
+ * /tmp/{{slug}}-*.md patterns are templates, not actual writes.
46
+ *
47
+ * Cleanup strategy: per-process inventory (PER_RUN_SCRATCH) + exit/SIGINT/SIGTERM
48
+ * handlers (lines 131-141) that remove only tracked dirs. No blanket rm -rf.
49
+ */
50
+
51
+ import { spawnSync } from 'node:child_process';
52
+ import * as fs from 'node:fs';
53
+ import * as os from 'node:os';
54
+ import * as path from 'node:path';
55
+ import * as fmt from '../core/fmt.js';
56
+
57
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
58
+ const SELF_TEST_FILE = 'coverage-gate.test.js';
59
+ const TEMP_DIR_PREFIXES = [
60
+ 'agents-',
61
+ 'sessions-',
62
+ 'review-',
63
+ 'forgejo-',
64
+ 'workflow-',
65
+ 'gatekeeper-',
66
+ 'graphify-',
67
+ 'claude-',
68
+ 'codex-',
69
+ 'gemini-',
70
+ 'opencode-',
71
+ 'startagent-',
72
+ 'test-',
73
+ 'mission-task-',
74
+ 'visualBoard-task-',
75
+ 'backlog-'
76
+ ];
77
+ const COVERAGE_INCLUDES = [
78
+ 'index.js',
79
+ 'lib/**/*.js'
80
+ ];
81
+ const COVERAGE_EXCLUDES = [
82
+ 'test/**',
83
+ 'prompts/**',
84
+ 'config/*.json',
85
+ '.workflow/**',
86
+ 'node_modules/**'
87
+ ];
88
+ // The full suite regularly exceeds 10 minutes in this repository, especially
89
+ // under cold caches. Keep the gate generous so it can finish without a manual
90
+ // override while still failing on real hangs.
91
+ const DEFAULT_TEST_TIMEOUT_MS = 3_600_000;
92
+ const PER_RUN_SCRATCH: string[] = [];
93
+ let threshold = 90;
94
+ let dryRun = false;
95
+ let lcov = false;
96
+ let cleanupDone = false;
97
+
98
+ for (let i = 2; i < process.argv.length; i++) {
99
+ if (process.argv[i] === '--threshold' && process.argv[i + 1]) {
100
+ threshold = parseFloat(process.argv[i + 1]);
101
+ i++;
102
+ } else if (process.argv[i] === '--dry-run') {
103
+ dryRun = true;
104
+ } else if (process.argv[i] === '--lcov') {
105
+ lcov = true;
106
+ }
107
+ }
108
+
109
+ function discoverTestFiles() {
110
+ const testDir = path.join(REPO_ROOT, 'test');
111
+ if (!fs.existsSync(testDir)) {return [];}
112
+ return fs.readdirSync(testDir)
113
+ .filter(file => file.endsWith('.test.js'))
114
+ .filter(file => file !== SELF_TEST_FILE)
115
+ .map(file => path.join(testDir, file))
116
+ .sort();
117
+ }
118
+
119
+ function listTempEntries(tmpRoot: string = os.tmpdir()) {
120
+ if (!fs.existsSync(tmpRoot)) {return new Set<string>();}
121
+ return new Set(fs.readdirSync(tmpRoot));
122
+ }
123
+
124
+ function shouldCleanTempDir(name: string) {
125
+ return TEMP_DIR_PREFIXES.some(prefix => name.startsWith(prefix));
126
+ }
127
+
128
+ function cleanupNewTempDirs(beforeEntries: Set<string>, tmpRoot: string = os.tmpdir()) {
129
+ if (!fs.existsSync(tmpRoot)) {return;}
130
+ for (const name of fs.readdirSync(tmpRoot)) {
131
+ if (beforeEntries.has(name) || !shouldCleanTempDir(name)) {continue;}
132
+ const fullPath = path.join(tmpRoot, name);
133
+ let stat = null;
134
+ try {
135
+ stat = fs.statSync(fullPath);
136
+ } catch (_) {
137
+ continue;
138
+ }
139
+ if (!stat.isDirectory()) {continue;}
140
+ try {
141
+ fs.rmSync(fullPath, { recursive: true, force: true });
142
+ } catch (_) {}
143
+ }
144
+ }
145
+
146
+ function createPerRunScratchDirs() {
147
+ const coverageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'node-coverage-'));
148
+ PER_RUN_SCRATCH.push(coverageDir);
149
+ return coverageDir;
150
+ }
151
+
152
+ function createPerRunTmpRoot() {
153
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'coverage-gate-tmp-'));
154
+ PER_RUN_SCRATCH.push(tmpRoot);
155
+ return tmpRoot;
156
+ }
157
+
158
+ function createMockGraphifyBin() {
159
+ const graphifyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graphify-'));
160
+ PER_RUN_SCRATCH.push(graphifyDir);
161
+ const graphifyBin = path.join(graphifyDir, 'graphify');
162
+ fs.writeFileSync(graphifyBin, `#!/usr/bin/env bash
163
+ set -euo pipefail
164
+ if [[ "\${1:-}" == "--help" ]]; then
165
+ echo "mock graphify"
166
+ exit 0
167
+ fi
168
+ if [[ "\${1:-}" == "update" ]]; then
169
+ mkdir -p graphify-out
170
+ echo '{"nodes":[],"edges":[]}' > graphify-out/graph.json
171
+ echo '# Mock Graph Report' > graphify-out/GRAPH_REPORT.md
172
+ echo "[mock graphify] update $*"
173
+ exit 0
174
+ fi
175
+ echo "Unhandled graphify command: $*" >&2
176
+ exit 1
177
+ `, { mode: 0o755 });
178
+ return graphifyBin;
179
+ }
180
+
181
+ function cleanupPerRunScratch() {
182
+ if (cleanupDone) {return;}
183
+ cleanupDone = true;
184
+ for (const dir of PER_RUN_SCRATCH) {
185
+ try {
186
+ if (fs.existsSync(dir)) {
187
+ fs.rmSync(dir, { recursive: true, force: true });
188
+ }
189
+ } catch (_) {}
190
+ }
191
+ PER_RUN_SCRATCH.length = 0;
192
+ }
193
+
194
+ function registerExitHandlers() {
195
+ process.on('exit', cleanupPerRunScratch);
196
+ process.on('SIGINT', () => {
197
+ cleanupPerRunScratch();
198
+ process.exit(130);
199
+ });
200
+ process.on('SIGTERM', () => {
201
+ cleanupPerRunScratch();
202
+ process.exit(143);
203
+ });
204
+ }
205
+
206
+ function resetPerRunScratchState() {
207
+ PER_RUN_SCRATCH.length = 0;
208
+ cleanupDone = false;
209
+ }
210
+
211
+ function buildCoverageArgs(testFiles: string[], coverageThreshold = threshold, useLcov = lcov) {
212
+ const args = [
213
+ '--test',
214
+ '--experimental-test-coverage',
215
+ `--test-coverage-lines=${coverageThreshold}`,
216
+ ...COVERAGE_INCLUDES.flatMap(pattern => ['--test-coverage-include', pattern]),
217
+ ...COVERAGE_EXCLUDES.flatMap(pattern => ['--test-coverage-exclude', pattern]),
218
+ ];
219
+
220
+ if (useLcov) {
221
+ const coverageDir = path.join(REPO_ROOT, 'coverage');
222
+ if (!fs.existsSync(coverageDir)) {
223
+ fs.mkdirSync(coverageDir, { recursive: true });
224
+ }
225
+ args.push('--test-reporter=lcov');
226
+ args.push(`--test-reporter-destination=${path.join(coverageDir, 'lcov.info')}`);
227
+ args.push('--test-reporter=spec');
228
+ args.push('--test-reporter-destination=stdout');
229
+ }
230
+
231
+ args.push(...testFiles);
232
+ return args;
233
+ }
234
+
235
+ function resolveTestTimeoutMs(env = process.env) {
236
+ const raw = env.WORKFLOW_COVERAGE_GATE_TIMEOUT_MS;
237
+ if (raw === undefined || raw === '') {return DEFAULT_TEST_TIMEOUT_MS;}
238
+ const parsed = Number(raw);
239
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TEST_TIMEOUT_MS;
240
+ }
241
+
242
+ function runTests(testFiles: string[], coverageThreshold = threshold, _spawnSync = spawnSync) {
243
+ const tmpRoot = createPerRunTmpRoot();
244
+ const tmpEntriesBefore = listTempEntries(tmpRoot);
245
+ const nodeCoverageDir = createPerRunScratchDirs();
246
+ const childEnv = { ...process.env };
247
+ delete childEnv.NODE_TEST_CONTEXT;
248
+ delete childEnv.NODE_OPTIONS;
249
+ childEnv.TMPDIR = tmpRoot;
250
+ childEnv.NODE_V8_COVERAGE = nodeCoverageDir;
251
+ childEnv.GRAPHIFY_BIN = createMockGraphifyBin();
252
+
253
+ const result = _spawnSync(process.execPath, buildCoverageArgs(testFiles, coverageThreshold), {
254
+ encoding: 'utf8',
255
+ cwd: REPO_ROOT,
256
+ env: childEnv,
257
+ stdio: ['pipe', 'inherit', 'inherit'],
258
+ timeout: resolveTestTimeoutMs(),
259
+ });
260
+
261
+ cleanupNewTempDirs(tmpEntriesBefore, tmpRoot);
262
+ cleanupPerRunScratch();
263
+
264
+ if (result.error) {
265
+ fmt.log.error(`[coverage-gate] failed to spawn test runner: ${result.error.message}`);
266
+ return 1;
267
+ }
268
+
269
+ if (result.signal) {
270
+ fmt.log.error(`[coverage-gate] test runner killed by signal ${result.signal}`);
271
+ return 1;
272
+ }
273
+
274
+ return result.status === null ? 1 : result.status;
275
+ }
276
+
277
+ function main() {
278
+ const testFiles = discoverTestFiles();
279
+ if (testFiles.length === 0) {
280
+ fmt.log.fail('no parallix test files found under test/');
281
+ process.exit(1);
282
+ }
283
+
284
+ fmt.log.info(`Found ${testFiles.length} test file(s)`);
285
+
286
+ if (dryRun) {
287
+ fmt.log.info(`DRY-RUN mode — threshold=${threshold}%`);
288
+ fmt.log.info('Denominator: index.js + lib/**/*.js');
289
+ fmt.log.info(`Include globs: ${COVERAGE_INCLUDES.join(', ')}`);
290
+ fmt.log.info(`Exclude globs: ${COVERAGE_EXCLUDES.join(', ')}`);
291
+ fmt.log.info(`Would run: ${fmt.command(`${process.execPath} ${buildCoverageArgs(testFiles, threshold).join(' ')}`)}`);
292
+ process.exit(0);
293
+ }
294
+
295
+ registerExitHandlers();
296
+ process.exit(runTests(testFiles, threshold));
297
+ }
298
+
299
+ if (typeof require !== 'undefined' && require.main === module) {
300
+ main();
301
+ }
302
+
303
+ interface CoverageGateOptions {
304
+ exitFn?: (code: number) => void;
305
+ }
306
+
307
+ function run(args: string[], options: CoverageGateOptions = {}) {
308
+ const exitFn = options.exitFn || process.exit;
309
+ let threshold_ = 90;
310
+ let dryRun_ = false;
311
+ for (let i = 0; i < args.length; i++) {
312
+ if (args[i] === '--threshold' && args[i + 1]) {
313
+ threshold_ = parseFloat(args[i + 1]);
314
+ i++;
315
+ } else if (args[i] === '--dry-run') {
316
+ dryRun_ = true;
317
+ }
318
+ }
319
+ const savedThreshold = threshold;
320
+ const savedDryRun = dryRun;
321
+ threshold = threshold_;
322
+ dryRun = dryRun_;
323
+ try {
324
+ if (dryRun_) {
325
+ const testFiles = discoverTestFiles();
326
+ if (testFiles.length === 0) {
327
+ if (typeof exitFn === 'function') {exitFn(1);}
328
+ } else {
329
+ fmt.log.info(`Found ${testFiles.length} test file(s)`);
330
+ fmt.log.info(`DRY-RUN mode — threshold=${threshold}%`);
331
+ fmt.log.info('Denominator: index.js + lib/**/*.js');
332
+ fmt.log.info(`Include globs: ${COVERAGE_INCLUDES.join(', ')}`);
333
+ fmt.log.info(`Exclude globs: ${COVERAGE_EXCLUDES.join(', ')}`);
334
+ fmt.log.info(`Would run: ${fmt.command(`${process.execPath} ${buildCoverageArgs(testFiles, threshold).join(' ')}`)}`);
335
+ if (typeof exitFn === 'function') {exitFn(0);}
336
+ else {process.exit(0);}
337
+ }
338
+ } else {
339
+ registerExitHandlers();
340
+ exitFn(runTests(discoverTestFiles(), threshold));
341
+ }
342
+ } finally {
343
+ threshold = savedThreshold;
344
+ dryRun = savedDryRun;
345
+ }
346
+ }
347
+
348
+ (run as any).buildCoverageArgs = buildCoverageArgs;
349
+ (run as any).cleanupNewTempDirs = cleanupNewTempDirs;
350
+ (run as any).cleanupPerRunScratch = cleanupPerRunScratch;
351
+ (run as any).createPerRunScratchDirs = createPerRunScratchDirs;
352
+ (run as any).COVERAGE_EXCLUDES = COVERAGE_EXCLUDES;
353
+ (run as any).COVERAGE_INCLUDES = COVERAGE_INCLUDES;
354
+ (run as any).DEFAULT_TEST_TIMEOUT_MS = DEFAULT_TEST_TIMEOUT_MS;
355
+ (run as any).discoverTestFiles = discoverTestFiles;
356
+ (run as any).listTempEntries = listTempEntries;
357
+ (run as any).registerExitHandlers = registerExitHandlers;
358
+ (run as any).resetPerRunScratchState = resetPerRunScratchState;
359
+ (run as any).resolveTestTimeoutMs = resolveTestTimeoutMs;
360
+ (run as any).runTests = runTests;
361
+ (run as any).shouldCleanTempDir = shouldCleanTempDir;
362
+ export = run;
@@ -1,125 +1,146 @@
1
- const childProcess = require('child_process');
2
- const path = require('path');
3
- const { git } = require('../core/git');
4
- const { getPrimaryBranch, missionBranchName, inferSlug, resolveWorktree } = require('../core/mission-utils');
5
- const fmt = require('../core/fmt');
6
-
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ const node_child_process_1 = __importDefault(require("node:child_process"));
39
+ const node_path_1 = __importDefault(require("node:path"));
40
+ const git_js_1 = require("../core/git.js");
41
+ const mission_utils_js_1 = require("../core/mission-utils.js");
42
+ const fmt = __importStar(require("../core/fmt.js"));
7
43
  /** @param {string[]} args @param {{gitFn?: Function, spawnSyncFn?: Function, getPrimaryBranchFn?: Function, missionBranchNameFn?: Function, inferSlugFn?: Function, resolveWorktreeFn?: Function, exitFn?: Function, failFn?: Function}} opts */
8
- async function diff(args, {
9
- gitFn = git,
10
- spawnSyncFn = childProcess.spawnSync,
11
- getPrimaryBranchFn = getPrimaryBranch,
12
- missionBranchNameFn = missionBranchName,
13
- inferSlugFn = inferSlug,
14
- resolveWorktreeFn = resolveWorktree,
15
- exitFn = process.exit,
16
- failFn = fmt.log.fail,
17
- } = {}) {
18
- /** @param {string} a */
19
- const explicitSlug = args.filter(a => !a.startsWith('--'))[0];
20
- const slug = inferSlugFn(explicitSlug);
21
-
22
- if (!slug) {
23
- failFn('Usage: node parallix diff [<slug>]');
24
- exitFn(1);
25
- return;
26
- }
27
-
28
- const worktree = resolveWorktreeFn(slug);
29
- if (!worktree) {
30
- failFn(`Mission worktree not found for ${fmt.slug(slug)}. Run this from the mission worktree or create it first.`);
31
- exitFn(1);
32
- return;
33
- }
34
-
35
- let primary;
36
- try {
37
- primary = getPrimaryBranchFn(worktree);
38
- } catch (err) {
39
- failFn(/** @type{Error} */(err).message);
40
- exitFn(1);
41
- return;
42
- }
43
-
44
- const branch = missionBranchNameFn(slug);
45
- const target = `${primary}..HEAD`;
46
-
47
- // Detect configured diff tool/pager
48
- /** @param {string} key */
49
- const getGitConfig = (key) => {
50
- const result = gitFn(['-C', worktree, 'config', '--get', key]);
51
- return result.status === 0 ? result.stdout.trim() : null;
52
- };
53
-
54
- /** @param {string} cmd */
55
- const isSpecializedTool = (cmd) => {
56
- if (!cmd) {return false;}
57
- const parts = cmd.split(/\s+/);
58
- // Handle cases like "LESS=FRX less" or "/usr/bin/delta"
59
- /** @param {string} p */
60
- const exePart = parts.find(p => !p.includes('='));
61
- if (!exePart) {return false;}
62
- const exe = path.basename(exePart).toLowerCase();
63
-
64
- // Explicitly reject known default pagers
65
- if (['less', 'cat', 'more'].includes(exe)) {return false;}
66
-
67
- // Accept known specialized tools documented in review-tooling.md
68
- if (['delta', 'difft', 'diff-so-fancy'].includes(exe)) {return true;}
69
-
70
- // Fallback: if it's not a known default, and we have a value, treat it as specialized
71
- // for now to avoid breaking custom setups like "vimdiff", but we've rejected plain "less".
72
- return true;
73
- };
74
-
75
- /** @param {childProcess.SpawnSyncReturns<string>} result @param {string} toolName */
76
- const handleLaunchResult = (result, toolName) => {
77
- if (result.error) {
78
- failFn(`Failed to launch ${toolName}: ${result.error.message}`);
79
- exitFn(1);
80
- return;
44
+ async function diff(args, { gitFn = git_js_1.git, spawnSyncFn = node_child_process_1.default.spawnSync, getPrimaryBranchFn = mission_utils_js_1.getPrimaryBranch, missionBranchNameFn = mission_utils_js_1.missionBranchName, inferSlugFn = mission_utils_js_1.inferSlug, resolveWorktreeFn = mission_utils_js_1.resolveWorktree, exitFn = process.exit, failFn = fmt.log.fail, } = {}) {
45
+ /** @param {string} a */
46
+ const explicitSlug = args.filter((a) => !a.startsWith('--'))[0];
47
+ const slug = inferSlugFn(explicitSlug);
48
+ if (!slug) {
49
+ failFn('Usage: node parallix diff [<slug>]');
50
+ exitFn(1);
51
+ return;
52
+ }
53
+ const worktree = resolveWorktreeFn(slug);
54
+ if (!worktree) {
55
+ failFn(`Mission worktree not found for ${fmt.slug(slug)}. Run this from the mission worktree or create it first.`);
56
+ exitFn(1);
57
+ return;
81
58
  }
82
- if (result.signal) {
83
- failFn(`${toolName} terminated by signal: ${result.signal}`);
84
- exitFn(1);
85
- return;
59
+ let primary;
60
+ try {
61
+ primary = getPrimaryBranchFn(worktree);
86
62
  }
87
- if (result.status === null) {
88
- failFn(`${toolName} exited with unknown status.`);
89
- exitFn(1);
90
- return;
63
+ catch (err) {
64
+ failFn(err.message);
65
+ exitFn(1);
66
+ return;
91
67
  }
92
- exitFn(result.status);
93
- };
94
-
95
- const diffTool = getGitConfig('diff.tool');
96
- if (diffTool) {
97
- fmt.log.info(`Launching ${fmt.command('git difftool')} for ${fmt.branch(branch)} (tool: ${fmt.bold(diffTool)})...`);
98
- const result = spawnSyncFn('git', ['difftool', '-d', '--no-prompt', target], { cwd: worktree, stdio: 'inherit' });
99
- handleLaunchResult(result, 'git difftool');
100
- return;
101
- }
102
-
103
- const pagerDiff = getGitConfig('pager.diff');
104
- if (isSpecializedTool(pagerDiff)) {
105
- fmt.log.info(`Launching ${fmt.command('git diff')} for ${fmt.branch(branch)} (pager: ${fmt.bold(pagerDiff)})...`);
106
- const result = spawnSyncFn('git', ['diff', target], { cwd: worktree, stdio: 'inherit' });
107
- handleLaunchResult(result, 'git diff');
108
- return;
109
- }
110
-
111
- const corePager = getGitConfig('core.pager');
112
- if (isSpecializedTool(corePager)) {
113
- fmt.log.info(`Launching ${fmt.command('git diff')} for ${fmt.branch(branch)} (core.pager: ${fmt.bold(corePager)})...`);
114
- const result = spawnSyncFn('git', ['diff', target], { cwd: worktree, stdio: 'inherit' });
115
- handleLaunchResult(result, 'git diff');
116
- return;
117
- }
118
-
119
- failFn('No specialized local diff tool (e.g., delta, difftastic) is configured in Git.');
120
- fmt.log.info('The workflow diff command requires an explicitly configured diff.tool or a non-default core.pager.');
121
- fmt.log.info(`Refer to ${fmt.path('docs/developer-setup/review-tooling.md')} for setup guidance.`);
122
- exitFn(1);
68
+ const branch = missionBranchNameFn(slug);
69
+ const target = `${primary}..HEAD`;
70
+ // Detect configured diff tool/pager
71
+ /** @param {string} key */
72
+ const getGitConfig = (key) => {
73
+ const result = gitFn(['-C', worktree, 'config', '--get', key]);
74
+ return result.status === 0 ? result.stdout.trim() : null;
75
+ };
76
+ /** @param {string} cmd */
77
+ const isSpecializedTool = (cmd) => {
78
+ if (!cmd) {
79
+ return false;
80
+ }
81
+ const parts = cmd.split(/\s+/);
82
+ // Handle cases like "LESS=FRX less" or "/usr/bin/delta"
83
+ /** @param {string} p */
84
+ const exePart = parts.find((p) => !p.includes('='));
85
+ if (!exePart) {
86
+ return false;
87
+ }
88
+ const exe = node_path_1.default.basename(exePart).toLowerCase();
89
+ // Explicitly reject known default pagers
90
+ if (['less', 'cat', 'more'].includes(exe)) {
91
+ return false;
92
+ }
93
+ // Accept known specialized tools documented in review-tooling.md
94
+ if (['delta', 'difft', 'diff-so-fancy'].includes(exe)) {
95
+ return true;
96
+ }
97
+ // Fallback: if it's not a known default, and we have a value, treat it as specialized
98
+ // for now to avoid breaking custom setups like "vimdiff", but we've rejected plain "less".
99
+ return true;
100
+ };
101
+ /** @param {childProcess.SpawnSyncReturns<string>} result @param {string} toolName */
102
+ const handleLaunchResult = (result, toolName) => {
103
+ if (result.error) {
104
+ failFn(`Failed to launch ${toolName}: ${result.error.message}`);
105
+ exitFn(1);
106
+ return;
107
+ }
108
+ if (result.signal) {
109
+ failFn(`${toolName} terminated by signal: ${result.signal}`);
110
+ exitFn(1);
111
+ return;
112
+ }
113
+ if (result.status === null) {
114
+ failFn(`${toolName} exited with unknown status.`);
115
+ exitFn(1);
116
+ return;
117
+ }
118
+ exitFn(result.status);
119
+ };
120
+ const diffTool = getGitConfig('diff.tool');
121
+ if (diffTool) {
122
+ fmt.log.info(`Launching ${fmt.command('git difftool')} for ${fmt.branch(branch)} (tool: ${fmt.bold(diffTool)})...`);
123
+ const result = spawnSyncFn('git', ['difftool', '-d', '--no-prompt', target], { cwd: worktree, stdio: 'inherit' });
124
+ handleLaunchResult(result, 'git difftool');
125
+ return;
126
+ }
127
+ const pagerDiff = getGitConfig('pager.diff');
128
+ if (isSpecializedTool(pagerDiff || '')) {
129
+ fmt.log.info(`Launching ${fmt.command('git diff')} for ${fmt.branch(branch)} (pager: ${fmt.bold(pagerDiff || "")})...`);
130
+ const result = spawnSyncFn('git', ['diff', target], { cwd: worktree, stdio: 'inherit' });
131
+ handleLaunchResult(result, 'git diff');
132
+ return;
133
+ }
134
+ const corePager = getGitConfig('core.pager');
135
+ if (isSpecializedTool(corePager || '')) {
136
+ fmt.log.info(`Launching ${fmt.command('git diff')} for ${fmt.branch(branch)} (core.pager: ${fmt.bold(corePager || "")})...`);
137
+ const result = spawnSyncFn('git', ['diff', target], { cwd: worktree, stdio: 'inherit' });
138
+ handleLaunchResult(result, 'git diff');
139
+ return;
140
+ }
141
+ failFn('No specialized local diff tool (e.g., delta, difftastic) is configured in Git.');
142
+ fmt.log.info('The workflow diff command requires an explicitly configured diff.tool or a non-default core.pager.');
143
+ fmt.log.info(`Refer to ${fmt.path('docs/developer-setup/review-tooling.md')} for setup guidance.`);
144
+ exitFn(1);
123
145
  }
124
-
125
146
  module.exports = diff;