@codexstar/bug-hunter 3.0.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 (51) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/LICENSE +21 -0
  3. package/README.md +665 -0
  4. package/SKILL.md +624 -0
  5. package/bin/bug-hunter +222 -0
  6. package/evals/evals.json +362 -0
  7. package/modes/_dispatch.md +121 -0
  8. package/modes/extended.md +94 -0
  9. package/modes/fix-loop.md +115 -0
  10. package/modes/fix-pipeline.md +384 -0
  11. package/modes/large-codebase.md +212 -0
  12. package/modes/local-sequential.md +143 -0
  13. package/modes/loop.md +125 -0
  14. package/modes/parallel.md +113 -0
  15. package/modes/scaled.md +76 -0
  16. package/modes/single-file.md +38 -0
  17. package/modes/small.md +86 -0
  18. package/package.json +56 -0
  19. package/prompts/doc-lookup.md +44 -0
  20. package/prompts/examples/hunter-examples.md +131 -0
  21. package/prompts/examples/skeptic-examples.md +87 -0
  22. package/prompts/fixer.md +103 -0
  23. package/prompts/hunter.md +146 -0
  24. package/prompts/recon.md +159 -0
  25. package/prompts/referee.md +122 -0
  26. package/prompts/skeptic.md +143 -0
  27. package/prompts/threat-model.md +122 -0
  28. package/scripts/bug-hunter-state.cjs +537 -0
  29. package/scripts/code-index.cjs +541 -0
  30. package/scripts/context7-api.cjs +133 -0
  31. package/scripts/delta-mode.cjs +219 -0
  32. package/scripts/dep-scan.cjs +343 -0
  33. package/scripts/doc-lookup.cjs +316 -0
  34. package/scripts/fix-lock.cjs +167 -0
  35. package/scripts/init-test-fixture.sh +19 -0
  36. package/scripts/payload-guard.cjs +197 -0
  37. package/scripts/run-bug-hunter.cjs +892 -0
  38. package/scripts/tests/bug-hunter-state.test.cjs +87 -0
  39. package/scripts/tests/code-index.test.cjs +57 -0
  40. package/scripts/tests/delta-mode.test.cjs +47 -0
  41. package/scripts/tests/fix-lock.test.cjs +36 -0
  42. package/scripts/tests/fixtures/flaky-worker.cjs +63 -0
  43. package/scripts/tests/fixtures/low-confidence-worker.cjs +73 -0
  44. package/scripts/tests/fixtures/success-worker.cjs +42 -0
  45. package/scripts/tests/payload-guard.test.cjs +41 -0
  46. package/scripts/tests/run-bug-hunter.test.cjs +403 -0
  47. package/scripts/tests/test-utils.cjs +59 -0
  48. package/scripts/tests/worktree-harvest.test.cjs +297 -0
  49. package/scripts/triage.cjs +528 -0
  50. package/scripts/worktree-harvest.cjs +516 -0
  51. package/templates/subagent-wrapper.md +109 -0
@@ -0,0 +1,892 @@
1
+ #!/usr/bin/env node
2
+
3
+ const childProcess = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ const BACKEND_PRIORITY = ['spawn_agent', 'subagent', 'teams', 'local-sequential'];
8
+ const DEFAULT_TIMEOUT_MS = 120000;
9
+ const DEFAULT_MAX_RETRIES = 1;
10
+ const DEFAULT_BACKOFF_MS = 1000;
11
+ const DEFAULT_CHUNK_SIZE = 30;
12
+ const DEFAULT_CONFIDENCE_THRESHOLD = 75;
13
+ const DEFAULT_CANARY_SIZE = 3;
14
+ const DEFAULT_DELTA_HOPS = 2;
15
+ const DEFAULT_EXPANSION_CAP = 40;
16
+
17
+ function usage() {
18
+ console.error('Usage:');
19
+ console.error(' run-bug-hunter.cjs preflight [--skill-dir <path>] [--available-backends <csv>] [--backend <name>]');
20
+ console.error(' run-bug-hunter.cjs run --files-json <path> [--mode <name>] [--skill-dir <path>] [--state <path>] [--chunk-size <n>] [--worker-cmd <template>] [--timeout-ms <n>] [--max-retries <n>] [--backoff-ms <n>] [--available-backends <csv>] [--backend <name>] [--fail-fast <true|false>] [--use-index <true|false>] [--index-path <path>] [--delta-mode <true|false>] [--changed-files-json <path>] [--delta-hops <n>] [--expand-on-low-confidence <true|false>] [--confidence-threshold <n>] [--canary-size <n>] [--expansion-cap <n>]');
21
+ console.error(' run-bug-hunter.cjs plan --files-json <path> [--mode <name>] [--skill-dir <path>] [--chunk-size <n>] [--plan-path <path>]');
22
+ }
23
+
24
+ function nowIso() {
25
+ return new Date().toISOString();
26
+ }
27
+
28
+ function ensureDir(dirPath) {
29
+ fs.mkdirSync(dirPath, { recursive: true });
30
+ }
31
+
32
+ function parseArgs(argv) {
33
+ const [command, ...rest] = argv;
34
+ const options = {};
35
+ let index = 0;
36
+ while (index < rest.length) {
37
+ const token = rest[index];
38
+ if (!token.startsWith('--')) {
39
+ index += 1;
40
+ continue;
41
+ }
42
+ const key = token.slice(2);
43
+ const value = rest[index + 1];
44
+ if (!value || value.startsWith('--')) {
45
+ options[key] = 'true';
46
+ index += 1;
47
+ continue;
48
+ }
49
+ options[key] = value;
50
+ index += 2;
51
+ }
52
+ return { command, options };
53
+ }
54
+
55
+ function toPositiveInt(value, fallback) {
56
+ const parsed = Number.parseInt(String(value || ''), 10);
57
+ if (!Number.isInteger(parsed) || parsed <= 0) {
58
+ return fallback;
59
+ }
60
+ return parsed;
61
+ }
62
+
63
+ function toBoolean(value, fallback) {
64
+ if (value === undefined) {
65
+ return fallback;
66
+ }
67
+ const normalized = String(value).toLowerCase();
68
+ if (normalized === 'true') {
69
+ return true;
70
+ }
71
+ if (normalized === 'false') {
72
+ return false;
73
+ }
74
+ return fallback;
75
+ }
76
+
77
+ function resolveSkillDir(options) {
78
+ if (options['skill-dir']) {
79
+ return path.resolve(options['skill-dir']);
80
+ }
81
+ return path.resolve(__dirname, '..');
82
+ }
83
+
84
+ function getAvailableBackends(options) {
85
+ if (options['available-backends']) {
86
+ return String(options['available-backends'])
87
+ .split(',')
88
+ .map((item) => item.trim())
89
+ .filter(Boolean);
90
+ }
91
+ if (process.env.BUG_HUNTER_BACKENDS) {
92
+ return String(process.env.BUG_HUNTER_BACKENDS)
93
+ .split(',')
94
+ .map((item) => item.trim())
95
+ .filter(Boolean);
96
+ }
97
+ return ['local-sequential'];
98
+ }
99
+
100
+ function selectBackend(options) {
101
+ const forcedBackend = options.backend || process.env.BUG_HUNTER_BACKEND;
102
+ if (forcedBackend) {
103
+ if (!BACKEND_PRIORITY.includes(forcedBackend)) {
104
+ throw new Error(`Unsupported backend: ${forcedBackend}`);
105
+ }
106
+ return { selected: forcedBackend, available: getAvailableBackends(options), forced: true };
107
+ }
108
+ const available = getAvailableBackends(options);
109
+ const selected = BACKEND_PRIORITY.find((backend) => available.includes(backend)) || 'local-sequential';
110
+ return { selected, available, forced: false };
111
+ }
112
+
113
+ function requiredScripts(skillDir) {
114
+ return [
115
+ path.join(skillDir, 'scripts', 'bug-hunter-state.cjs'),
116
+ path.join(skillDir, 'scripts', 'payload-guard.cjs'),
117
+ path.join(skillDir, 'scripts', 'fix-lock.cjs'),
118
+ path.join(skillDir, 'scripts', 'doc-lookup.cjs'),
119
+ path.join(skillDir, 'scripts', 'context7-api.cjs'),
120
+ path.join(skillDir, 'scripts', 'delta-mode.cjs')
121
+ ];
122
+ }
123
+
124
+ function preflight(options) {
125
+ const skillDir = resolveSkillDir(options);
126
+ const missing = requiredScripts(skillDir).filter((filePath) => !fs.existsSync(filePath));
127
+ const backend = selectBackend(options);
128
+ return {
129
+ ok: missing.length === 0,
130
+ skillDir,
131
+ backend,
132
+ missing
133
+ };
134
+ }
135
+
136
+ function runJsonScript(scriptPath, args) {
137
+ const result = childProcess.spawnSync('node', [scriptPath, ...args], {
138
+ encoding: 'utf8'
139
+ });
140
+ if (result.status !== 0) {
141
+ const stderr = (result.stderr || '').trim();
142
+ const stdout = (result.stdout || '').trim();
143
+ throw new Error(stderr || stdout || `Script failed: ${scriptPath}`);
144
+ }
145
+ const output = (result.stdout || '').trim();
146
+ if (!output) {
147
+ return {};
148
+ }
149
+ return JSON.parse(output);
150
+ }
151
+
152
+ function appendJournal(logPath, event) {
153
+ ensureDir(path.dirname(logPath));
154
+ const line = JSON.stringify({ at: nowIso(), ...event });
155
+ fs.appendFileSync(logPath, `${line}\n`, 'utf8');
156
+ }
157
+
158
+ function fillTemplate(template, variables) {
159
+ return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => {
160
+ if (!(key in variables)) {
161
+ return match;
162
+ }
163
+ return String(variables[key]);
164
+ });
165
+ }
166
+
167
+ function sleep(ms) {
168
+ return new Promise((resolve) => setTimeout(resolve, ms));
169
+ }
170
+
171
+ function runCommandOnce({ command, timeoutMs }) {
172
+ return new Promise((resolve) => {
173
+ const child = childProcess.spawn('/bin/zsh', ['-lc', command], {
174
+ stdio: ['ignore', 'pipe', 'pipe']
175
+ });
176
+ let stdout = '';
177
+ let stderr = '';
178
+ let timeoutHit = false;
179
+
180
+ const timer = setTimeout(() => {
181
+ timeoutHit = true;
182
+ child.kill('SIGTERM');
183
+ setTimeout(() => {
184
+ if (!child.killed) {
185
+ child.kill('SIGKILL');
186
+ }
187
+ }, 2000);
188
+ }, timeoutMs);
189
+
190
+ child.stdout.on('data', (chunk) => {
191
+ stdout += chunk.toString();
192
+ });
193
+ child.stderr.on('data', (chunk) => {
194
+ stderr += chunk.toString();
195
+ });
196
+ child.on('close', (code) => {
197
+ clearTimeout(timer);
198
+ resolve({
199
+ ok: code === 0 && !timeoutHit,
200
+ code: code || 0,
201
+ timeoutHit,
202
+ stdout: stdout.trim(),
203
+ stderr: stderr.trim()
204
+ });
205
+ });
206
+ });
207
+ }
208
+
209
+ async function runWithRetry({
210
+ command,
211
+ timeoutMs,
212
+ maxRetries,
213
+ backoffMs,
214
+ journalPath,
215
+ phase,
216
+ chunkId
217
+ }) {
218
+ const attempts = maxRetries + 1;
219
+ let lastResult = null;
220
+
221
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
222
+ appendJournal(journalPath, {
223
+ event: 'attempt-start',
224
+ phase,
225
+ chunkId,
226
+ attempt,
227
+ attempts,
228
+ timeoutMs
229
+ });
230
+ const result = await runCommandOnce({ command, timeoutMs });
231
+ lastResult = result;
232
+ appendJournal(journalPath, {
233
+ event: 'attempt-end',
234
+ phase,
235
+ chunkId,
236
+ attempt,
237
+ ok: result.ok,
238
+ code: result.code,
239
+ timeoutHit: result.timeoutHit,
240
+ stderr: result.stderr.slice(0, 500)
241
+ });
242
+ if (result.ok) {
243
+ return { ok: true, result, attemptsUsed: attempt };
244
+ }
245
+ if (attempt < attempts) {
246
+ const delayMs = backoffMs * 2 ** (attempt - 1);
247
+ appendJournal(journalPath, {
248
+ event: 'retry-backoff',
249
+ phase,
250
+ chunkId,
251
+ attempt,
252
+ delayMs
253
+ });
254
+ await sleep(delayMs);
255
+ }
256
+ }
257
+
258
+ return {
259
+ ok: false,
260
+ result: lastResult,
261
+ attemptsUsed: attempts
262
+ };
263
+ }
264
+
265
+ function readJson(filePath) {
266
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
267
+ }
268
+
269
+ function writeJson(filePath, value) {
270
+ ensureDir(path.dirname(filePath));
271
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
272
+ }
273
+
274
+ function toArray(value) {
275
+ return Array.isArray(value) ? value : [];
276
+ }
277
+
278
+ function severityRank(severity) {
279
+ const normalized = String(severity || '').toLowerCase();
280
+ if (normalized === 'critical') {
281
+ return 3;
282
+ }
283
+ if (normalized === 'high') {
284
+ return 2;
285
+ }
286
+ if (normalized === 'medium') {
287
+ return 1;
288
+ }
289
+ if (normalized === 'low') {
290
+ return 0;
291
+ }
292
+ return -1;
293
+ }
294
+
295
+ function buildHeuristicFactCard({ chunkId, scanFiles, findings, index }) {
296
+ const files = toArray(scanFiles).map((item) => path.resolve(String(item)));
297
+ const findingsList = toArray(findings);
298
+ const apiContracts = [];
299
+ const authAssumptions = [];
300
+ const invariants = [];
301
+
302
+ for (const filePath of files) {
303
+ const meta = index && index.files ? index.files[filePath] : null;
304
+ if (!meta) {
305
+ continue;
306
+ }
307
+ const relative = meta.relativePath || filePath;
308
+ const boundaries = toArray(meta.trustBoundaries);
309
+ if (boundaries.includes('external-input')) {
310
+ apiContracts.push(`${relative}: external-input boundary`);
311
+ }
312
+ if (boundaries.includes('auth')) {
313
+ authAssumptions.push(`${relative}: auth boundary must preserve identity and authorization checks`);
314
+ }
315
+ if (boundaries.includes('data-store')) {
316
+ invariants.push(`${relative}: data-store writes must keep state transitions atomic`);
317
+ }
318
+ }
319
+
320
+ for (const finding of findingsList) {
321
+ const claim = String((finding && finding.claim) || '').trim();
322
+ if (!claim) {
323
+ continue;
324
+ }
325
+ invariants.push(`Finding invariant: ${claim}`);
326
+ }
327
+
328
+ return {
329
+ chunkId,
330
+ createdAt: nowIso(),
331
+ apiContracts: [...new Set(apiContracts)].slice(0, 10),
332
+ authAssumptions: [...new Set(authAssumptions)].slice(0, 10),
333
+ invariants: [...new Set(invariants)].slice(0, 12)
334
+ };
335
+ }
336
+
337
+ function buildConsistencyReport({ bugLedger, confidenceThreshold }) {
338
+ const conflicts = [];
339
+ const byBugId = new Map();
340
+ const byLocation = new Map();
341
+
342
+ for (const entry of bugLedger) {
343
+ const bugId = String(entry.bugId || '').trim();
344
+ const locationKey = `${entry.file || ''}|${entry.lines || ''}`;
345
+ if (bugId) {
346
+ if (!byBugId.has(bugId)) {
347
+ byBugId.set(bugId, []);
348
+ }
349
+ byBugId.get(bugId).push(entry);
350
+ }
351
+ if (!byLocation.has(locationKey)) {
352
+ byLocation.set(locationKey, []);
353
+ }
354
+ byLocation.get(locationKey).push(entry);
355
+ }
356
+
357
+ for (const [bugId, entries] of byBugId.entries()) {
358
+ const uniqueKeys = new Set(entries.map((entry) => entry.key));
359
+ if (uniqueKeys.size > 1) {
360
+ conflicts.push({
361
+ type: 'bug-id-reused',
362
+ bugId,
363
+ count: uniqueKeys.size,
364
+ files: [...new Set(entries.map((entry) => entry.file))].sort()
365
+ });
366
+ }
367
+ }
368
+
369
+ for (const [location, entries] of byLocation.entries()) {
370
+ const claims = [...new Set(entries.map((entry) => String(entry.claim || '').trim()).filter(Boolean))];
371
+ if (claims.length > 1) {
372
+ conflicts.push({
373
+ type: 'location-claim-conflict',
374
+ location,
375
+ claims: claims.slice(0, 5)
376
+ });
377
+ }
378
+ }
379
+
380
+ const lowConfidence = bugLedger.filter((entry) => {
381
+ const confidence = entry.confidence;
382
+ return confidence === null || confidence === undefined || Number(confidence) < confidenceThreshold;
383
+ }).length;
384
+
385
+ return {
386
+ checkedAt: nowIso(),
387
+ confidenceThreshold,
388
+ totalFindings: bugLedger.length,
389
+ lowConfidenceFindings: lowConfidence,
390
+ conflicts
391
+ };
392
+ }
393
+
394
+ function buildFixPlan({ bugLedger, confidenceThreshold, canarySize }) {
395
+ const withConfidence = bugLedger.map((entry) => {
396
+ const confidenceRaw = entry.confidence;
397
+ const confidence = Number.isFinite(Number(confidenceRaw)) ? Number(confidenceRaw) : null;
398
+ return {
399
+ ...entry,
400
+ confidence
401
+ };
402
+ });
403
+ const eligible = withConfidence
404
+ .filter((entry) => entry.confidence !== null && entry.confidence >= confidenceThreshold)
405
+ .sort((left, right) => {
406
+ const severityDiff = severityRank(right.severity) - severityRank(left.severity);
407
+ if (severityDiff !== 0) {
408
+ return severityDiff;
409
+ }
410
+ const confidenceDiff = (right.confidence || 0) - (left.confidence || 0);
411
+ if (confidenceDiff !== 0) {
412
+ return confidenceDiff;
413
+ }
414
+ return String(left.key).localeCompare(String(right.key));
415
+ });
416
+ const manualReview = withConfidence
417
+ .filter((entry) => entry.confidence === null || entry.confidence < confidenceThreshold);
418
+ const canary = eligible.slice(0, canarySize);
419
+ const rollout = eligible.slice(canarySize);
420
+
421
+ return {
422
+ generatedAt: nowIso(),
423
+ confidenceThreshold,
424
+ canarySize,
425
+ totals: {
426
+ findings: withConfidence.length,
427
+ eligible: eligible.length,
428
+ canary: canary.length,
429
+ rollout: rollout.length,
430
+ manualReview: manualReview.length
431
+ },
432
+ canary,
433
+ rollout,
434
+ manualReview
435
+ };
436
+ }
437
+
438
+ function loadIndex(indexPath) {
439
+ if (!indexPath || !fs.existsSync(indexPath)) {
440
+ return null;
441
+ }
442
+ return readJson(indexPath);
443
+ }
444
+
445
+ function normalizeFiles(files) {
446
+ return [...new Set(toArray(files).map((filePath) => path.resolve(String(filePath))))].sort();
447
+ }
448
+
449
+ async function processPendingChunks({
450
+ statePath,
451
+ stateScript,
452
+ chunksDir,
453
+ journalPath,
454
+ workerCmdTemplate,
455
+ timeoutMs,
456
+ maxRetries,
457
+ backoffMs,
458
+ failFast,
459
+ backend,
460
+ mode,
461
+ skillDir,
462
+ index
463
+ }) {
464
+ while (true) {
465
+ const next = runJsonScript(stateScript, ['next-chunk', statePath]);
466
+ if (next.done) {
467
+ break;
468
+ }
469
+ const chunk = next.chunk;
470
+ const chunkFilesJsonPath = path.join(chunksDir, `${chunk.id}-files.json`);
471
+ const scanFilesJsonPath = path.join(chunksDir, `${chunk.id}-scan-files.json`);
472
+ const findingsJsonPath = path.join(chunksDir, `${chunk.id}-findings.json`);
473
+ const factsJsonPath = path.join(chunksDir, `${chunk.id}-facts.json`);
474
+ writeJson(chunkFilesJsonPath, chunk.files);
475
+
476
+ const hashFilterResult = runJsonScript(stateScript, ['hash-filter', statePath, chunkFilesJsonPath]);
477
+ const scanFiles = hashFilterResult.scan || [];
478
+ if (scanFiles.length === 0) {
479
+ appendJournal(journalPath, {
480
+ event: 'chunk-skip',
481
+ chunkId: chunk.id,
482
+ reason: 'hash-cache-no-changes'
483
+ });
484
+ runJsonScript(stateScript, ['mark-chunk', statePath, chunk.id, 'done']);
485
+ continue;
486
+ }
487
+
488
+ writeJson(scanFilesJsonPath, scanFiles);
489
+ if (fs.existsSync(findingsJsonPath)) {
490
+ fs.unlinkSync(findingsJsonPath);
491
+ }
492
+ if (fs.existsSync(factsJsonPath)) {
493
+ fs.unlinkSync(factsJsonPath);
494
+ }
495
+ runJsonScript(stateScript, ['mark-chunk', statePath, chunk.id, 'in_progress']);
496
+
497
+ const command = fillTemplate(workerCmdTemplate, {
498
+ chunkId: chunk.id,
499
+ chunkFilesJson: chunkFilesJsonPath,
500
+ scanFilesJson: scanFilesJsonPath,
501
+ findingsJson: findingsJsonPath,
502
+ factsJson: factsJsonPath,
503
+ backend,
504
+ mode,
505
+ statePath,
506
+ skillDir
507
+ });
508
+
509
+ const runResult = await runWithRetry({
510
+ command,
511
+ timeoutMs,
512
+ maxRetries,
513
+ backoffMs,
514
+ journalPath,
515
+ phase: 'chunk-worker',
516
+ chunkId: chunk.id
517
+ });
518
+
519
+ if (!runResult.ok) {
520
+ const errorMessage = (runResult.result && runResult.result.stderr) || 'worker failed';
521
+ runJsonScript(stateScript, ['mark-chunk', statePath, chunk.id, 'failed', errorMessage.slice(0, 240)]);
522
+ appendJournal(journalPath, {
523
+ event: 'chunk-failed',
524
+ chunkId: chunk.id,
525
+ errorMessage: errorMessage.slice(0, 500)
526
+ });
527
+ if (failFast) {
528
+ throw new Error(`Chunk ${chunk.id} failed and fail-fast is enabled`);
529
+ }
530
+ continue;
531
+ }
532
+
533
+ let findings = [];
534
+ if (fs.existsSync(findingsJsonPath)) {
535
+ runJsonScript(stateScript, ['record-findings', statePath, findingsJsonPath, 'orchestrator']);
536
+ findings = readJson(findingsJsonPath);
537
+ }
538
+
539
+ if (fs.existsSync(factsJsonPath)) {
540
+ runJsonScript(stateScript, ['record-fact-card', statePath, chunk.id, factsJsonPath]);
541
+ } else {
542
+ const factCard = buildHeuristicFactCard({
543
+ chunkId: chunk.id,
544
+ scanFiles,
545
+ findings,
546
+ index
547
+ });
548
+ writeJson(factsJsonPath, factCard);
549
+ runJsonScript(stateScript, ['record-fact-card', statePath, chunk.id, factsJsonPath]);
550
+ }
551
+
552
+ runJsonScript(stateScript, ['hash-update', statePath, scanFilesJsonPath, 'scanned']);
553
+ runJsonScript(stateScript, ['mark-chunk', statePath, chunk.id, 'done']);
554
+ appendJournal(journalPath, {
555
+ event: 'chunk-done',
556
+ chunkId: chunk.id,
557
+ attemptsUsed: runResult.attemptsUsed
558
+ });
559
+ }
560
+ }
561
+
562
+ function prepareIndexAndScope({
563
+ options,
564
+ skillDir,
565
+ statePath,
566
+ filesJsonPath,
567
+ journalPath
568
+ }) {
569
+ const useIndex = toBoolean(options['use-index'], false);
570
+ const deltaMode = toBoolean(options['delta-mode'], false);
571
+ const deltaHops = toPositiveInt(options['delta-hops'], DEFAULT_DELTA_HOPS);
572
+ const codeIndexScript = path.join(skillDir, 'scripts', 'code-index.cjs');
573
+ const deltaModeScript = path.join(skillDir, 'scripts', 'delta-mode.cjs');
574
+ const scopeDir = path.dirname(statePath);
575
+ const indexPath = path.resolve(options['index-path'] || path.join(scopeDir, 'index.json'));
576
+
577
+ let activeFilesJsonPath = filesJsonPath;
578
+ let deltaResult = null;
579
+
580
+ if (useIndex || deltaMode) {
581
+ if (!fs.existsSync(codeIndexScript)) {
582
+ if (deltaMode) {
583
+ throw new Error('code-index.cjs is required when --delta-mode=true');
584
+ }
585
+ appendJournal(journalPath, {
586
+ event: 'index-skip',
587
+ reason: 'missing-code-index',
588
+ codeIndexScript
589
+ });
590
+ return {
591
+ indexPath: null,
592
+ deltaMode: false,
593
+ deltaHops,
594
+ deltaResult: null,
595
+ activeFilesJsonPath
596
+ };
597
+ }
598
+ runJsonScript(codeIndexScript, ['build', indexPath, filesJsonPath, process.cwd()]);
599
+ appendJournal(journalPath, {
600
+ event: 'index-built',
601
+ indexPath
602
+ });
603
+ }
604
+
605
+ if (deltaMode) {
606
+ if (!options['changed-files-json']) {
607
+ throw new Error('--changed-files-json is required when --delta-mode=true');
608
+ }
609
+ const changedFilesJsonPath = path.resolve(options['changed-files-json']);
610
+ deltaResult = runJsonScript(deltaModeScript, [
611
+ 'select',
612
+ indexPath,
613
+ changedFilesJsonPath,
614
+ String(deltaHops)
615
+ ]);
616
+ const deltaSelectedPath = path.resolve(scopeDir, 'delta-selected-files.json');
617
+ writeJson(deltaSelectedPath, deltaResult.selected || []);
618
+ activeFilesJsonPath = deltaSelectedPath;
619
+ appendJournal(journalPath, {
620
+ event: 'delta-selected',
621
+ selected: (deltaResult.selected || []).length,
622
+ expansionCandidates: (deltaResult.expansionCandidates || []).length
623
+ });
624
+ }
625
+
626
+ return {
627
+ indexPath: (useIndex || deltaMode) ? indexPath : null,
628
+ deltaMode,
629
+ deltaHops,
630
+ deltaResult,
631
+ activeFilesJsonPath
632
+ };
633
+ }
634
+
635
+ async function runPipeline(options) {
636
+ if (!options['files-json']) {
637
+ throw new Error('--files-json is required for run command');
638
+ }
639
+ const skillDir = resolveSkillDir(options);
640
+ const preflightResult = preflight(options);
641
+ if (!preflightResult.ok) {
642
+ throw new Error(`Missing helper scripts: ${preflightResult.missing.join(', ')}`);
643
+ }
644
+
645
+ const backend = preflightResult.backend.selected;
646
+ const mode = options.mode || 'extended';
647
+ const filesJsonPath = path.resolve(options['files-json']);
648
+ const statePath = path.resolve(options.state || '.bug-hunter/state.json');
649
+ const chunkSize = toPositiveInt(options['chunk-size'], DEFAULT_CHUNK_SIZE);
650
+ const timeoutMs = toPositiveInt(options['timeout-ms'], DEFAULT_TIMEOUT_MS);
651
+ const maxRetries = toPositiveInt(options['max-retries'], DEFAULT_MAX_RETRIES);
652
+ const backoffMs = toPositiveInt(options['backoff-ms'], DEFAULT_BACKOFF_MS);
653
+ const failFast = toBoolean(options['fail-fast'], false);
654
+ const workerCmdTemplate = options['worker-cmd'] || 'node -e "process.exit(0)"';
655
+ const confidenceThreshold = toPositiveInt(options['confidence-threshold'], DEFAULT_CONFIDENCE_THRESHOLD);
656
+ const canarySize = toPositiveInt(options['canary-size'], DEFAULT_CANARY_SIZE);
657
+ const expansionCap = toPositiveInt(options['expansion-cap'], DEFAULT_EXPANSION_CAP);
658
+ const expandOnLowConfidence = toBoolean(options['expand-on-low-confidence'], true);
659
+ const journalPath = path.resolve(options['journal-path'] || '.bug-hunter/run.log');
660
+ const stateScript = path.join(skillDir, 'scripts', 'bug-hunter-state.cjs');
661
+ const deltaModeScript = path.join(skillDir, 'scripts', 'delta-mode.cjs');
662
+ const chunksDir = path.resolve(path.dirname(statePath), 'chunks');
663
+ const consistencyReportPath = path.resolve(options['consistency-report'] || path.join(path.dirname(statePath), 'consistency.json'));
664
+ const fixPlanPath = path.resolve(options['fix-plan-path'] || path.join(path.dirname(statePath), 'fix-plan.json'));
665
+ const factsPath = path.resolve(options['facts-path'] || path.join(path.dirname(statePath), 'bug-hunter-facts.json'));
666
+ ensureDir(chunksDir);
667
+
668
+ appendJournal(journalPath, {
669
+ event: 'run-start',
670
+ mode,
671
+ backend,
672
+ statePath,
673
+ filesJsonPath,
674
+ timeoutMs,
675
+ maxRetries,
676
+ backoffMs
677
+ });
678
+
679
+ const scope = prepareIndexAndScope({
680
+ options,
681
+ skillDir,
682
+ statePath,
683
+ filesJsonPath,
684
+ journalPath
685
+ });
686
+
687
+ if (!fs.existsSync(statePath)) {
688
+ runJsonScript(stateScript, ['init', statePath, mode, scope.activeFilesJsonPath, String(chunkSize)]);
689
+ }
690
+
691
+ let index = loadIndex(scope.indexPath);
692
+ await processPendingChunks({
693
+ statePath,
694
+ stateScript,
695
+ chunksDir,
696
+ journalPath,
697
+ workerCmdTemplate,
698
+ timeoutMs,
699
+ maxRetries,
700
+ backoffMs,
701
+ failFast,
702
+ backend,
703
+ mode,
704
+ skillDir,
705
+ index
706
+ });
707
+
708
+ if (scope.deltaMode && expandOnLowConfidence) {
709
+ const state = readJson(statePath);
710
+ const lowConfidenceFiles = normalizeFiles(state.bugLedger
711
+ .filter((entry) => {
712
+ return entry.confidence === null || entry.confidence === undefined || Number(entry.confidence) < confidenceThreshold;
713
+ })
714
+ .map((entry) => entry.file));
715
+ if (lowConfidenceFiles.length > 0 && scope.indexPath) {
716
+ const lowConfidenceFilesJsonPath = path.resolve(path.dirname(statePath), 'low-confidence-files.json');
717
+ const selectedFilesJsonPath = scope.activeFilesJsonPath;
718
+ writeJson(lowConfidenceFilesJsonPath, lowConfidenceFiles);
719
+ const expansion = runJsonScript(deltaModeScript, [
720
+ 'expand',
721
+ scope.indexPath,
722
+ lowConfidenceFilesJsonPath,
723
+ selectedFilesJsonPath,
724
+ String(scope.deltaHops || DEFAULT_DELTA_HOPS)
725
+ ]);
726
+ const expandedFiles = [
727
+ ...toArray(expansion.expanded),
728
+ ...toArray(expansion.overlayOnly)
729
+ ];
730
+ const cappedExpandedFiles = normalizeFiles(expandedFiles).slice(0, expansionCap);
731
+ if (cappedExpandedFiles.length > 0) {
732
+ const expansionFilesJsonPath = path.resolve(path.dirname(statePath), 'delta-expansion-files.json');
733
+ writeJson(expansionFilesJsonPath, cappedExpandedFiles);
734
+ const appendResult = runJsonScript(stateScript, ['append-files', statePath, expansionFilesJsonPath]);
735
+ appendJournal(journalPath, {
736
+ event: 'delta-expansion',
737
+ lowConfidenceFiles: lowConfidenceFiles.length,
738
+ expansionCandidates: expandedFiles.length,
739
+ expansionAppended: appendResult.appended || 0
740
+ });
741
+ if ((appendResult.appended || 0) > 0) {
742
+ const mergedSelected = normalizeFiles([
743
+ ...readJson(selectedFilesJsonPath),
744
+ ...cappedExpandedFiles
745
+ ]);
746
+ writeJson(selectedFilesJsonPath, mergedSelected);
747
+ await processPendingChunks({
748
+ statePath,
749
+ stateScript,
750
+ chunksDir,
751
+ journalPath,
752
+ workerCmdTemplate,
753
+ timeoutMs,
754
+ maxRetries,
755
+ backoffMs,
756
+ failFast,
757
+ backend,
758
+ mode,
759
+ skillDir,
760
+ index
761
+ });
762
+ }
763
+ }
764
+ }
765
+ }
766
+
767
+ const finalState = readJson(statePath);
768
+ const status = runJsonScript(stateScript, ['status', statePath]);
769
+ const consistency = buildConsistencyReport({
770
+ bugLedger: toArray(finalState.bugLedger),
771
+ confidenceThreshold
772
+ });
773
+ writeJson(consistencyReportPath, consistency);
774
+ runJsonScript(stateScript, ['set-consistency', statePath, consistencyReportPath]);
775
+
776
+ const fixPlan = buildFixPlan({
777
+ bugLedger: toArray(finalState.bugLedger),
778
+ confidenceThreshold,
779
+ canarySize
780
+ });
781
+ writeJson(fixPlanPath, fixPlan);
782
+ runJsonScript(stateScript, ['set-fix-plan', statePath, fixPlanPath]);
783
+
784
+ writeJson(factsPath, finalState.factCards || {});
785
+
786
+ appendJournal(journalPath, {
787
+ event: 'run-end',
788
+ status: status.summary,
789
+ consistencyConflicts: consistency.conflicts.length,
790
+ canary: fixPlan.totals.canary
791
+ });
792
+
793
+ return {
794
+ ok: true,
795
+ backend,
796
+ journalPath,
797
+ statePath,
798
+ indexPath: scope.indexPath,
799
+ deltaMode: scope.deltaMode,
800
+ deltaSummary: scope.deltaResult ? {
801
+ selectedCount: (scope.deltaResult.selected || []).length,
802
+ expansionCandidatesCount: (scope.deltaResult.expansionCandidates || []).length
803
+ } : null,
804
+ consistencyReportPath,
805
+ fixPlanPath,
806
+ factsPath,
807
+ status: status.summary,
808
+ consistency: {
809
+ conflicts: consistency.conflicts.length,
810
+ lowConfidenceFindings: consistency.lowConfidenceFindings
811
+ },
812
+ fixPlan: fixPlan.totals
813
+ };
814
+ }
815
+
816
+ async function main() {
817
+ const { command, options } = parseArgs(process.argv.slice(2));
818
+ if (!command) {
819
+ usage();
820
+ process.exit(1);
821
+ }
822
+
823
+ if (command === 'preflight') {
824
+ const result = preflight(options);
825
+ console.log(JSON.stringify(result, null, 2));
826
+ if (!result.ok) {
827
+ process.exit(1);
828
+ }
829
+ return;
830
+ }
831
+
832
+ if (command === 'run') {
833
+ const result = await runPipeline(options);
834
+ console.log(JSON.stringify(result, null, 2));
835
+ return;
836
+ }
837
+
838
+ if (command === 'plan') {
839
+ if (!options['files-json']) {
840
+ throw new Error('--files-json is required for plan command');
841
+ }
842
+ const skillDir = resolveSkillDir(options);
843
+ const filesJsonPath = path.resolve(options['files-json']);
844
+ const mode = options.mode || 'extended';
845
+ const chunkSize = toPositiveInt(options['chunk-size'], DEFAULT_CHUNK_SIZE);
846
+ const planPath = path.resolve(options['plan-path'] || '.bug-hunter/plan.json');
847
+
848
+ const files = readJson(filesJsonPath);
849
+ const totalFiles = files.length;
850
+
851
+ const chunks = [];
852
+ for (let i = 0; i < totalFiles; i += chunkSize) {
853
+ const chunkFiles = files.slice(i, i + chunkSize);
854
+ chunks.push({
855
+ id: `chunk-${chunks.length + 1}`,
856
+ files: chunkFiles,
857
+ fileCount: chunkFiles.length,
858
+ status: 'pending'
859
+ });
860
+ }
861
+
862
+ const planOutput = {
863
+ generatedAt: nowIso(),
864
+ mode,
865
+ skillDir,
866
+ totalFiles,
867
+ chunkSize,
868
+ chunkCount: chunks.length,
869
+ phases: ['recon', 'hunter', 'skeptic', 'referee'],
870
+ chunks,
871
+ instructions: [
872
+ 'This plan was generated for LLM agent consumption.',
873
+ 'The agent should process chunks in order, using the state scripts to track progress.',
874
+ 'For local-sequential mode: read modes/local-sequential.md for execution instructions.',
875
+ 'For subagent mode: read modes/extended.md or modes/scaled.md for dispatch patterns.'
876
+ ]
877
+ };
878
+
879
+ writeJson(planPath, planOutput);
880
+ console.log(JSON.stringify(planOutput, null, 2));
881
+ return;
882
+ }
883
+
884
+ usage();
885
+ process.exit(1);
886
+ }
887
+
888
+ main().catch((error) => {
889
+ const message = error instanceof Error ? error.message : String(error);
890
+ console.error(message);
891
+ process.exit(1);
892
+ });