@codexstar/bug-hunter 3.0.0 → 3.0.5

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 (77) hide show
  1. package/CHANGELOG.md +149 -83
  2. package/README.md +150 -15
  3. package/SKILL.md +94 -27
  4. package/agents/openai.yaml +4 -0
  5. package/bin/bug-hunter +9 -3
  6. package/docs/images/2026-03-12-fix-plan-rollout.png +0 -0
  7. package/docs/images/2026-03-12-hero-bug-hunter-overview.png +0 -0
  8. package/docs/images/2026-03-12-machine-readable-artifacts.png +0 -0
  9. package/docs/images/2026-03-12-pr-review-flow.png +0 -0
  10. package/docs/images/2026-03-12-security-pack.png +0 -0
  11. package/docs/images/adversarial-debate.png +0 -0
  12. package/docs/images/doc-verify-fix-plan.png +0 -0
  13. package/docs/images/hero.png +0 -0
  14. package/docs/images/pipeline-overview.png +0 -0
  15. package/docs/images/security-finding-card.png +0 -0
  16. package/docs/plans/2026-03-11-structured-output-migration-plan.md +288 -0
  17. package/docs/plans/2026-03-12-audit-bug-fixes-surgical-plan.md +193 -0
  18. package/docs/plans/2026-03-12-enterprise-security-pack-e2e-plan.md +59 -0
  19. package/docs/plans/2026-03-12-local-security-skills-integration-plan.md +39 -0
  20. package/docs/plans/2026-03-12-pr-review-strategic-fix-flow.md +78 -0
  21. package/evals/evals.json +366 -102
  22. package/modes/extended.md +2 -2
  23. package/modes/fix-loop.md +30 -30
  24. package/modes/fix-pipeline.md +32 -6
  25. package/modes/large-codebase.md +14 -15
  26. package/modes/local-sequential.md +44 -20
  27. package/modes/loop.md +56 -56
  28. package/modes/parallel.md +3 -3
  29. package/modes/scaled.md +2 -2
  30. package/modes/single-file.md +3 -3
  31. package/modes/small.md +11 -11
  32. package/package.json +10 -1
  33. package/prompts/fixer.md +37 -23
  34. package/prompts/hunter.md +39 -20
  35. package/prompts/referee.md +34 -20
  36. package/prompts/skeptic.md +25 -22
  37. package/schemas/coverage.schema.json +67 -0
  38. package/schemas/examples/findings.invalid.json +13 -0
  39. package/schemas/examples/findings.valid.json +17 -0
  40. package/schemas/findings.schema.json +76 -0
  41. package/schemas/fix-plan.schema.json +94 -0
  42. package/schemas/fix-report.schema.json +105 -0
  43. package/schemas/fix-strategy.schema.json +99 -0
  44. package/schemas/recon.schema.json +31 -0
  45. package/schemas/referee.schema.json +46 -0
  46. package/schemas/shared.schema.json +51 -0
  47. package/schemas/skeptic.schema.json +21 -0
  48. package/scripts/bug-hunter-state.cjs +35 -12
  49. package/scripts/code-index.cjs +11 -4
  50. package/scripts/fix-lock.cjs +95 -25
  51. package/scripts/payload-guard.cjs +24 -10
  52. package/scripts/pr-scope.cjs +181 -0
  53. package/scripts/render-report.cjs +346 -0
  54. package/scripts/run-bug-hunter.cjs +667 -32
  55. package/scripts/schema-runtime.cjs +273 -0
  56. package/scripts/schema-validate.cjs +40 -0
  57. package/scripts/tests/bug-hunter-state.test.cjs +68 -3
  58. package/scripts/tests/code-index.test.cjs +15 -0
  59. package/scripts/tests/fix-lock.test.cjs +60 -2
  60. package/scripts/tests/fixtures/flaky-worker.cjs +6 -1
  61. package/scripts/tests/fixtures/low-confidence-worker.cjs +8 -2
  62. package/scripts/tests/fixtures/success-worker.cjs +6 -1
  63. package/scripts/tests/payload-guard.test.cjs +154 -2
  64. package/scripts/tests/pr-scope.test.cjs +212 -0
  65. package/scripts/tests/render-report.test.cjs +180 -0
  66. package/scripts/tests/run-bug-hunter.test.cjs +686 -2
  67. package/scripts/tests/security-skills-integration.test.cjs +29 -0
  68. package/scripts/tests/skills-packaging.test.cjs +30 -0
  69. package/scripts/tests/worktree-harvest.test.cjs +66 -0
  70. package/scripts/worktree-harvest.cjs +62 -9
  71. package/skills/README.md +19 -0
  72. package/skills/commit-security-scan/SKILL.md +63 -0
  73. package/skills/security-review/SKILL.md +57 -0
  74. package/skills/threat-model-generation/SKILL.md +47 -0
  75. package/skills/vulnerability-validation/SKILL.md +59 -0
  76. package/templates/subagent-wrapper.md +12 -3
  77. package/modes/_dispatch.md +0 -121
@@ -8,6 +8,7 @@ const {
8
8
  readJson,
9
9
  resolveSkillScript,
10
10
  runJson,
11
+ runRaw,
11
12
  writeJson
12
13
  } = require('./test-utils.cjs');
13
14
 
@@ -31,19 +32,41 @@ test('run-bug-hunter preflight tolerates missing optional code-index helper', ()
31
32
  const runner = resolveSkillScript('run-bug-hunter.cjs');
32
33
  const optionalSkillDir = path.join(sandbox, 'skill');
33
34
  const scriptsDir = path.join(optionalSkillDir, 'scripts');
35
+ const schemasDir = path.join(optionalSkillDir, 'schemas');
34
36
  fs.mkdirSync(scriptsDir, { recursive: true });
37
+ fs.mkdirSync(schemasDir, { recursive: true });
35
38
 
36
39
  for (const fileName of [
37
40
  'run-bug-hunter.cjs',
38
41
  'bug-hunter-state.cjs',
39
42
  'payload-guard.cjs',
43
+ 'schema-validate.cjs',
44
+ 'schema-runtime.cjs',
45
+ 'render-report.cjs',
40
46
  'fix-lock.cjs',
41
47
  'doc-lookup.cjs',
42
48
  'context7-api.cjs',
43
- 'delta-mode.cjs'
49
+ 'delta-mode.cjs',
50
+ 'pr-scope.cjs'
44
51
  ]) {
45
52
  fs.copyFileSync(resolveSkillScript(fileName), path.join(scriptsDir, fileName));
46
53
  }
54
+ for (const fileName of [
55
+ 'findings.schema.json',
56
+ 'skeptic.schema.json',
57
+ 'referee.schema.json',
58
+ 'coverage.schema.json',
59
+ 'fix-report.schema.json',
60
+ 'fix-plan.schema.json',
61
+ 'fix-strategy.schema.json',
62
+ 'recon.schema.json',
63
+ 'shared.schema.json'
64
+ ]) {
65
+ fs.copyFileSync(
66
+ resolveSkillScript('..', 'schemas', fileName),
67
+ path.join(schemasDir, fileName)
68
+ );
69
+ }
47
70
 
48
71
  const result = runJson('node', [
49
72
  path.join(scriptsDir, 'run-bug-hunter.cjs'),
@@ -151,7 +174,11 @@ test('run-bug-hunter integrates index+delta, fact cards, consistency pass, and f
151
174
  const seenFilesPath = path.join(sandbox, 'seen-files.json');
152
175
  const consistencyReportPath = path.join(sandbox, '.claude', 'bug-hunter-consistency.json');
153
176
  const fixPlanPath = path.join(sandbox, '.claude', 'bug-hunter-fix-plan.json');
177
+ const strategyPath = path.join(sandbox, '.claude', 'bug-hunter-fix-strategy.json');
178
+ const strategyMarkdownPath = path.join(sandbox, '.claude', 'bug-hunter-fix-strategy.md');
154
179
  const factsPath = path.join(sandbox, '.claude', 'bug-hunter-facts.json');
180
+ const coveragePath = path.join(sandbox, '.claude', 'coverage.json');
181
+ const coverageMarkdownPath = path.join(sandbox, '.claude', 'coverage.md');
155
182
 
156
183
  const changedFile = path.join(sandbox, 'src', 'feature', 'changed.ts');
157
184
  const depFile = path.join(sandbox, 'src', 'feature', 'dep.ts');
@@ -212,6 +239,10 @@ test('run-bug-hunter integrates index+delta, fact cards, consistency pass, and f
212
239
  consistencyReportPath,
213
240
  '--fix-plan-path',
214
241
  fixPlanPath,
242
+ '--strategy-path',
243
+ strategyPath,
244
+ '--strategy-markdown-path',
245
+ strategyMarkdownPath,
215
246
  '--facts-path',
216
247
  factsPath,
217
248
  '--use-index',
@@ -235,7 +266,11 @@ test('run-bug-hunter integrates index+delta, fact cards, consistency pass, and f
235
266
  assert.equal(result.deltaSummary.selectedCount >= 2, true);
236
267
  assert.equal(fs.existsSync(consistencyReportPath), true);
237
268
  assert.equal(fs.existsSync(fixPlanPath), true);
269
+ assert.equal(fs.existsSync(strategyPath), true);
270
+ assert.equal(fs.existsSync(strategyMarkdownPath), true);
238
271
  assert.equal(fs.existsSync(factsPath), true);
272
+ assert.equal(fs.existsSync(coveragePath), true);
273
+ assert.equal(fs.existsSync(coverageMarkdownPath), true);
239
274
 
240
275
  const seenFiles = readJson(seenFilesPath);
241
276
  assert.equal(seenFiles.includes(overlayFile), true);
@@ -243,12 +278,25 @@ test('run-bug-hunter integrates index+delta, fact cards, consistency pass, and f
243
278
  const state = readJson(statePath);
244
279
  assert.equal(Object.keys(state.factCards || {}).length >= 3, true);
245
280
  assert.equal(state.metrics.lowConfidenceFindings >= 1, true);
281
+ assert.equal(state.bugLedger.every((entry) => typeof entry.confidenceScore === 'number'), true);
246
282
 
247
283
  const consistency = readJson(consistencyReportPath);
248
284
  assert.equal(consistency.lowConfidenceFindings >= 1, true);
249
285
 
250
286
  const fixPlan = readJson(fixPlanPath);
251
287
  assert.equal(fixPlan.totals.manualReview >= 1, true);
288
+
289
+ const fixStrategy = readJson(strategyPath);
290
+ assert.equal(fixStrategy.summary.manualReview >= 1, true);
291
+ assert.equal(Array.isArray(fixStrategy.clusters), true);
292
+
293
+ const strategyMarkdown = fs.readFileSync(strategyMarkdownPath, 'utf8');
294
+ assert.match(strategyMarkdown, /# Fix Strategy/);
295
+
296
+ const coverage = readJson(coveragePath);
297
+ assert.equal(coverage.status, 'COMPLETE');
298
+ assert.equal(Array.isArray(coverage.files), true);
299
+ assert.equal(Array.isArray(coverage.bugs), true);
252
300
  });
253
301
 
254
302
  test('run-bug-hunter builds canary fix subset from high-confidence findings', () => {
@@ -314,6 +362,126 @@ test('run-bug-hunter builds canary fix subset from high-confidence findings', ()
314
362
  assert.equal(fixPlan.totals.canary, 1);
315
363
  });
316
364
 
365
+ test('run-bug-hunter excludes non-autofix strategy findings from the executable fix plan', () => {
366
+ const sandbox = makeSandbox('run-bug-hunter-strategy-gate-');
367
+ const runner = resolveSkillScript('run-bug-hunter.cjs');
368
+ const skillDir = path.resolve(__dirname, '..', '..');
369
+ const filesJsonPath = path.join(sandbox, 'files.json');
370
+ const statePath = path.join(sandbox, '.claude', 'bug-hunter-state.json');
371
+ const fixPlanPath = path.join(sandbox, '.claude', 'bug-hunter-fix-plan.json');
372
+ const workerPath = path.join(sandbox, 'worker.cjs');
373
+
374
+ const fileA = path.join(sandbox, 'src', 'architecture.ts');
375
+ fs.mkdirSync(path.dirname(fileA), { recursive: true });
376
+ fs.writeFileSync(fileA, 'export const architecture = true;\n', 'utf8');
377
+ writeJson(filesJsonPath, [fileA]);
378
+
379
+ fs.writeFileSync(workerPath, [
380
+ '#!/usr/bin/env node',
381
+ "const fs = require('fs');",
382
+ "const findingsPath = process.argv[process.argv.indexOf('--findings-json') + 1];",
383
+ "const scanPath = process.argv[process.argv.indexOf('--scan-files-json') + 1];",
384
+ "const scanFiles = JSON.parse(fs.readFileSync(scanPath, 'utf8'));",
385
+ "fs.writeFileSync(findingsPath, JSON.stringify([{ bugId: 'BUG-ARCH', severity: 'Critical', category: 'logic', file: scanFiles[0], lines: '1', claim: 'architecture contract violation in orchestration flow', evidence: scanFiles[0] + ':1 architecture evidence', runtimeTrigger: 'Run the orchestrator on this file', crossReferences: ['Single file'], confidenceScore: 98, confidenceLabel: 'high', stride: 'N/A', cwe: 'N/A' }], null, 2));"
386
+ ].join('\n'), 'utf8');
387
+
388
+ runJson('node', [
389
+ runner,
390
+ 'run',
391
+ '--skill-dir',
392
+ skillDir,
393
+ '--files-json',
394
+ filesJsonPath,
395
+ '--state',
396
+ statePath,
397
+ '--mode',
398
+ 'extended',
399
+ '--chunk-size',
400
+ '1',
401
+ '--worker-cmd',
402
+ `node ${workerPath} --chunk-id {chunkId} --scan-files-json {scanFilesJson} --findings-json {findingsJson}`,
403
+ '--timeout-ms',
404
+ '5000',
405
+ '--confidence-threshold',
406
+ '75',
407
+ '--fix-plan-path',
408
+ fixPlanPath,
409
+ '--canary-size',
410
+ '1'
411
+ ], {
412
+ cwd: sandbox
413
+ });
414
+
415
+ const fixPlan = readJson(fixPlanPath);
416
+ assert.equal(fixPlan.totals.eligible, 0);
417
+ assert.equal(fixPlan.totals.canary, 0);
418
+ assert.equal(fixPlan.totals.rollout, 0);
419
+ });
420
+
421
+ test('run-bug-hunter downgrades conflicting findings to manual review before fix-plan execution', () => {
422
+ const sandbox = makeSandbox('run-bug-hunter-conflicts-');
423
+ const runner = resolveSkillScript('run-bug-hunter.cjs');
424
+ const skillDir = path.resolve(__dirname, '..', '..');
425
+ const filesJsonPath = path.join(sandbox, 'files.json');
426
+ const statePath = path.join(sandbox, '.claude', 'bug-hunter-state.json');
427
+ const fixPlanPath = path.join(sandbox, '.claude', 'bug-hunter-fix-plan.json');
428
+ const consistencyPath = path.join(sandbox, '.claude', 'consistency.json');
429
+ const workerPath = path.join(sandbox, 'worker.cjs');
430
+
431
+ const fileA = path.join(sandbox, 'src', 'conflict.ts');
432
+ fs.mkdirSync(path.dirname(fileA), { recursive: true });
433
+ fs.writeFileSync(fileA, 'export const conflict = true;\n', 'utf8');
434
+ writeJson(filesJsonPath, [fileA]);
435
+
436
+ fs.writeFileSync(workerPath, [
437
+ '#!/usr/bin/env node',
438
+ "const fs = require('fs');",
439
+ "const findingsPath = process.argv[process.argv.indexOf('--findings-json') + 1];",
440
+ "const scanPath = process.argv[process.argv.indexOf('--scan-files-json') + 1];",
441
+ "const scanFiles = JSON.parse(fs.readFileSync(scanPath, 'utf8'));",
442
+ "fs.writeFileSync(findingsPath, JSON.stringify([",
443
+ " { bugId: 'BUG-1', severity: 'Critical', category: 'logic', file: scanFiles[0], lines: '1', claim: 'first conflicting claim', evidence: scanFiles[0] + ':1 first', runtimeTrigger: 'Trigger first', crossReferences: ['Single file'], confidenceScore: 97, confidenceLabel: 'high', stride: 'N/A', cwe: 'N/A' },",
444
+ " { bugId: 'BUG-2', severity: 'Critical', category: 'logic', file: scanFiles[0], lines: '1', claim: 'second conflicting claim', evidence: scanFiles[0] + ':1 second', runtimeTrigger: 'Trigger second', crossReferences: ['Single file'], confidenceScore: 96, confidenceLabel: 'high', stride: 'N/A', cwe: 'N/A' }",
445
+ "], null, 2));"
446
+ ].join('\n'), 'utf8');
447
+
448
+ runJson('node', [
449
+ runner,
450
+ 'run',
451
+ '--skill-dir',
452
+ skillDir,
453
+ '--files-json',
454
+ filesJsonPath,
455
+ '--state',
456
+ statePath,
457
+ '--mode',
458
+ 'extended',
459
+ '--chunk-size',
460
+ '1',
461
+ '--worker-cmd',
462
+ `node ${workerPath} --chunk-id {chunkId} --scan-files-json {scanFilesJson} --findings-json {findingsJson}`,
463
+ '--timeout-ms',
464
+ '5000',
465
+ '--confidence-threshold',
466
+ '75',
467
+ '--fix-plan-path',
468
+ fixPlanPath,
469
+ '--consistency-report',
470
+ consistencyPath,
471
+ '--canary-size',
472
+ '1'
473
+ ], {
474
+ cwd: sandbox
475
+ });
476
+
477
+ const consistency = readJson(consistencyPath);
478
+ assert.equal(consistency.conflicts.length >= 1, true);
479
+
480
+ const fixPlan = readJson(fixPlanPath);
481
+ assert.equal(fixPlan.totals.eligible, 0);
482
+ assert.equal(fixPlan.totals.manualReview, 2);
483
+ });
484
+
317
485
  test('run-bug-hunter respects configured delta hops during low-confidence expansion', () => {
318
486
  const sandbox = makeSandbox('run-bug-hunter-delta-hops-');
319
487
  const runner = resolveSkillScript('run-bug-hunter.cjs');
@@ -347,7 +515,7 @@ test('run-bug-hunter respects configured delta hops during low-confidence expans
347
515
  "if (fs.existsSync(seenPath)) seen = JSON.parse(fs.readFileSync(seenPath, 'utf8'));",
348
516
  'seen.push(scan);',
349
517
  "fs.writeFileSync(seenPath, JSON.stringify(seen));",
350
- "const findings = scan[0] === changedPath ? [{ file: scan[0], lines: '1', claim: 'low confidence', severity: 'Low', confidence: 60 }] : [];",
518
+ "const findings = scan[0] === changedPath ? [{ bugId: 'BUG-inline', severity: 'Low', category: 'logic', file: scan[0], lines: '1', claim: 'low confidence', evidence: scan[0] + ':1 inline evidence', runtimeTrigger: 'Load the changed file', crossReferences: ['Single file'], confidenceScore: 60 }] : [];",
351
519
  "fs.writeFileSync(findingsPath, JSON.stringify(findings));"
352
520
  ].join('\n'), 'utf8');
353
521
 
@@ -401,3 +569,519 @@ test('run-bug-hunter respects configured delta hops during low-confidence expans
401
569
  const seenFiles = readJson(seenFilesPath).flat();
402
570
  assert.equal(seenFiles.includes(twoHopFile), false);
403
571
  });
572
+
573
+ test('run-bug-hunter retries malformed findings and records schema errors in the journal', () => {
574
+ const sandbox = makeSandbox('run-bug-hunter-invalid-findings-');
575
+ const runner = resolveSkillScript('run-bug-hunter.cjs');
576
+ const skillDir = path.resolve(__dirname, '..', '..');
577
+ const filesJsonPath = path.join(sandbox, 'files.json');
578
+ const statePath = path.join(sandbox, '.claude', 'bug-hunter-state.json');
579
+ const journalPath = path.join(sandbox, '.claude', 'bug-hunter-run.log');
580
+ const attemptsFile = path.join(sandbox, 'attempts.json');
581
+
582
+ const sourceFile = path.join(sandbox, 'src', 'a.ts');
583
+ fs.mkdirSync(path.dirname(sourceFile), { recursive: true });
584
+ fs.writeFileSync(sourceFile, 'export const a = 1;\n', 'utf8');
585
+ writeJson(filesJsonPath, [sourceFile]);
586
+
587
+ const workerPath = path.join(sandbox, 'invalid-then-valid-worker.cjs');
588
+ fs.writeFileSync(workerPath, [
589
+ '#!/usr/bin/env node',
590
+ "const fs = require('fs');",
591
+ "const path = require('path');",
592
+ "const args = process.argv;",
593
+ "const chunkId = args[args.indexOf('--chunk-id') + 1];",
594
+ "const findingsPath = args[args.indexOf('--findings-json') + 1];",
595
+ "const attemptsPath = args[args.indexOf('--attempts-file') + 1];",
596
+ 'let attempts = {};',
597
+ "if (fs.existsSync(attemptsPath)) attempts = JSON.parse(fs.readFileSync(attemptsPath, 'utf8'));",
598
+ "attempts[chunkId] = (attempts[chunkId] || 0) + 1;",
599
+ "fs.mkdirSync(path.dirname(attemptsPath), { recursive: true });",
600
+ "fs.writeFileSync(attemptsPath, JSON.stringify(attempts, null, 2));",
601
+ "const payload = attempts[chunkId] === 1",
602
+ " ? [{ bugId: 'BUG-1', severity: 'Low', category: 'logic', file: 'src/a.ts', lines: '1', evidence: 'src/a.ts:1 evidence', runtimeTrigger: 'Call a()', crossReferences: ['Single file'], confidenceScore: 60 }]",
603
+ " : [{ bugId: 'BUG-1', severity: 'Low', category: 'logic', file: 'src/a.ts', lines: '1', claim: 'valid after retry', evidence: 'src/a.ts:1 evidence', runtimeTrigger: 'Call a()', crossReferences: ['Single file'], confidenceScore: 60 }];",
604
+ "fs.writeFileSync(findingsPath, JSON.stringify(payload, null, 2));"
605
+ ].join('\n'), 'utf8');
606
+
607
+ const workerTemplate = [
608
+ 'node',
609
+ workerPath,
610
+ '--chunk-id',
611
+ '{chunkId}',
612
+ '--findings-json',
613
+ '{findingsJson}',
614
+ '--attempts-file',
615
+ attemptsFile
616
+ ].join(' ');
617
+
618
+ const result = runJson('node', [
619
+ runner,
620
+ 'run',
621
+ '--skill-dir',
622
+ skillDir,
623
+ '--files-json',
624
+ filesJsonPath,
625
+ '--state',
626
+ statePath,
627
+ '--chunk-size',
628
+ '1',
629
+ '--worker-cmd',
630
+ workerTemplate,
631
+ '--timeout-ms',
632
+ '5000',
633
+ '--max-retries',
634
+ '1',
635
+ '--backoff-ms',
636
+ '10',
637
+ '--journal-path',
638
+ journalPath
639
+ ], {
640
+ cwd: sandbox
641
+ });
642
+
643
+ assert.equal(result.ok, true);
644
+ const attempts = readJson(attemptsFile);
645
+ assert.equal(attempts['chunk-1'], 2);
646
+ const journal = fs.readFileSync(journalPath, 'utf8');
647
+ assert.match(journal, /attempt-post-check-failed/);
648
+ assert.match(journal, /\$\[0\]\.claim is required/);
649
+ });
650
+
651
+ test('run-bug-hunter clears stale findings artifacts before retrying a chunk', () => {
652
+ const sandbox = makeSandbox('run-bug-hunter-stale-artifact-');
653
+ const runner = resolveSkillScript('run-bug-hunter.cjs');
654
+ const skillDir = path.resolve(__dirname, '..', '..');
655
+ const filesJsonPath = path.join(sandbox, 'files.json');
656
+ const statePath = path.join(sandbox, '.claude', 'bug-hunter-state.json');
657
+ const journalPath = path.join(sandbox, '.claude', 'bug-hunter-run.log');
658
+ const attemptsFile = path.join(sandbox, 'attempts.json');
659
+ const sourceFile = path.join(sandbox, 'src', 'a.ts');
660
+
661
+ fs.mkdirSync(path.dirname(sourceFile), { recursive: true });
662
+ fs.writeFileSync(sourceFile, 'export const a = 1;\n', 'utf8');
663
+ writeJson(filesJsonPath, [sourceFile]);
664
+
665
+ const workerPath = path.join(sandbox, 'stale-artifact-worker.cjs');
666
+ fs.writeFileSync(workerPath, [
667
+ '#!/usr/bin/env node',
668
+ "const fs = require('fs');",
669
+ "const path = require('path');",
670
+ "const args = process.argv;",
671
+ "const chunkId = args[args.indexOf('--chunk-id') + 1];",
672
+ "const findingsPath = args[args.indexOf('--findings-json') + 1];",
673
+ "const attemptsPath = args[args.indexOf('--attempts-file') + 1];",
674
+ 'let attempts = {};',
675
+ "if (fs.existsSync(attemptsPath)) attempts = JSON.parse(fs.readFileSync(attemptsPath, 'utf8'));",
676
+ "attempts[chunkId] = (attempts[chunkId] || 0) + 1;",
677
+ "fs.mkdirSync(path.dirname(attemptsPath), { recursive: true });",
678
+ "fs.writeFileSync(attemptsPath, JSON.stringify(attempts, null, 2));",
679
+ 'if (attempts[chunkId] === 1) {',
680
+ " fs.writeFileSync(findingsPath, JSON.stringify([{ bugId: 'BUG-stale', severity: 'Low', category: 'logic', file: 'src/a.ts', lines: '1', claim: 'stale artifact', evidence: 'src/a.ts:1 evidence', runtimeTrigger: 'Call a()', crossReferences: ['Single file'], confidenceScore: 60 }], null, 2));",
681
+ ' process.exit(1);',
682
+ '}',
683
+ 'process.exit(0);'
684
+ ].join('\n'), 'utf8');
685
+
686
+ const workerTemplate = [
687
+ 'node',
688
+ workerPath,
689
+ '--chunk-id',
690
+ '{chunkId}',
691
+ '--findings-json',
692
+ '{findingsJson}',
693
+ '--attempts-file',
694
+ attemptsFile
695
+ ].join(' ');
696
+
697
+ const result = runJson('node', [
698
+ runner,
699
+ 'run',
700
+ '--skill-dir',
701
+ skillDir,
702
+ '--files-json',
703
+ filesJsonPath,
704
+ '--state',
705
+ statePath,
706
+ '--chunk-size',
707
+ '1',
708
+ '--worker-cmd',
709
+ workerTemplate,
710
+ '--timeout-ms',
711
+ '5000',
712
+ '--max-retries',
713
+ '1',
714
+ '--backoff-ms',
715
+ '10',
716
+ '--journal-path',
717
+ journalPath
718
+ ], {
719
+ cwd: sandbox
720
+ });
721
+
722
+ assert.equal(result.ok, true);
723
+ const attempts = readJson(attemptsFile);
724
+ assert.equal(attempts['chunk-1'], 2);
725
+ const state = readJson(statePath);
726
+ assert.equal(state.chunks[0].status, 'failed');
727
+ });
728
+
729
+ test('run-bug-hunter handles worker paths containing spaces', () => {
730
+ const sandbox = makeSandbox('run-bug-hunter-space-path-');
731
+ const runner = resolveSkillScript('run-bug-hunter.cjs');
732
+ const skillDir = path.resolve(__dirname, '..', '..');
733
+ const filesJsonPath = path.join(sandbox, 'files.json');
734
+ const statePath = path.join(sandbox, '.claude', 'bug-hunter-state.json');
735
+ const workerPath = path.join(sandbox, 'worker script.cjs');
736
+ const sourceFile = path.join(sandbox, 'src', 'dir with space', 'a.ts');
737
+
738
+ fs.mkdirSync(path.dirname(sourceFile), { recursive: true });
739
+ fs.writeFileSync(sourceFile, 'export const a = 1;\n', 'utf8');
740
+ writeJson(filesJsonPath, [sourceFile]);
741
+
742
+ fs.writeFileSync(workerPath, [
743
+ '#!/usr/bin/env node',
744
+ "const fs = require('fs');",
745
+ "const args = process.argv;",
746
+ "const findingsPath = args[args.indexOf('--findings-json') + 1];",
747
+ "const scanFilesJson = args[args.indexOf('--scan-files-json') + 1];",
748
+ "const scanFiles = JSON.parse(fs.readFileSync(scanFilesJson, 'utf8'));",
749
+ "fs.writeFileSync(findingsPath, JSON.stringify([{ bugId: 'BUG-space', severity: 'Low', category: 'logic', file: scanFiles[0], lines: '1', claim: 'space path works', evidence: scanFiles[0] + ':1 evidence', runtimeTrigger: 'Call a()', crossReferences: ['Single file'], confidenceScore: 60 }], null, 2));"
750
+ ].join('\n'), 'utf8');
751
+
752
+ const workerTemplate = [
753
+ 'node',
754
+ workerPath,
755
+ '--chunk-id',
756
+ '{chunkId}',
757
+ '--scan-files-json',
758
+ '{scanFilesJson}',
759
+ '--findings-json',
760
+ '{findingsJson}'
761
+ ].join(' ');
762
+
763
+ const result = runJson('node', [
764
+ runner,
765
+ 'run',
766
+ '--skill-dir',
767
+ skillDir,
768
+ '--files-json',
769
+ filesJsonPath,
770
+ '--state',
771
+ statePath,
772
+ '--chunk-size',
773
+ '1',
774
+ '--worker-cmd',
775
+ workerTemplate,
776
+ '--timeout-ms',
777
+ '5000'
778
+ ], {
779
+ cwd: sandbox
780
+ });
781
+
782
+ assert.equal(result.ok, true);
783
+ });
784
+
785
+ test('run-bug-hunter skips fix strategy and fix plan emission when chunks fail', () => {
786
+ const sandbox = makeSandbox('run-bug-hunter-failed-chunks-');
787
+ const runner = resolveSkillScript('run-bug-hunter.cjs');
788
+ const skillDir = path.resolve(__dirname, '..', '..');
789
+ const filesJsonPath = path.join(sandbox, 'files.json');
790
+ const statePath = path.join(sandbox, '.claude', 'bug-hunter-state.json');
791
+ const fixPlanPath = path.join(sandbox, '.claude', 'bug-hunter-fix-plan.json');
792
+ const strategyPath = path.join(sandbox, '.claude', 'bug-hunter-fix-strategy.json');
793
+ const workerPath = path.join(sandbox, 'always-fail-worker.cjs');
794
+ const sourceFile = path.join(sandbox, 'src', 'a.ts');
795
+
796
+ fs.mkdirSync(path.dirname(sourceFile), { recursive: true });
797
+ fs.writeFileSync(sourceFile, 'export const a = 1;\n', 'utf8');
798
+ writeJson(filesJsonPath, [sourceFile]);
799
+
800
+ fs.writeFileSync(workerPath, '#!/usr/bin/env node\nprocess.exit(1);\n', 'utf8');
801
+
802
+ const result = runJson('node', [
803
+ runner,
804
+ 'run',
805
+ '--skill-dir',
806
+ skillDir,
807
+ '--files-json',
808
+ filesJsonPath,
809
+ '--state',
810
+ statePath,
811
+ '--chunk-size',
812
+ '1',
813
+ '--worker-cmd',
814
+ `node ${workerPath}`,
815
+ '--timeout-ms',
816
+ '5000',
817
+ '--max-retries',
818
+ '1',
819
+ '--fix-plan-path',
820
+ fixPlanPath,
821
+ '--strategy-path',
822
+ strategyPath
823
+ ], {
824
+ cwd: sandbox
825
+ });
826
+
827
+ assert.equal(result.ok, true);
828
+ const state = readJson(statePath);
829
+ assert.equal(state.chunks[0].status, 'failed');
830
+ assert.equal(fs.existsSync(fixPlanPath), false);
831
+ assert.equal(fs.existsSync(strategyPath), false);
832
+ });
833
+
834
+ test('run-bug-hunter fails fast on unknown placeholders in worker templates', () => {
835
+ const sandbox = makeSandbox('run-bug-hunter-bad-template-');
836
+ const runner = resolveSkillScript('run-bug-hunter.cjs');
837
+ const skillDir = path.resolve(__dirname, '..', '..');
838
+ const outputPath = path.join(sandbox, '.bug-hunter', 'skeptic.json');
839
+
840
+ const result = runRaw('node', [
841
+ runner,
842
+ 'phase',
843
+ '--skill-dir',
844
+ skillDir,
845
+ '--phase-name',
846
+ 'skeptic-phase',
847
+ '--artifact',
848
+ 'skeptic',
849
+ '--output-path',
850
+ outputPath,
851
+ '--worker-cmd',
852
+ 'node fake-worker --output-path {outputPath} --missing {unknownPlaceholder}',
853
+ '--timeout-ms',
854
+ '5000'
855
+ ], {
856
+ cwd: sandbox,
857
+ encoding: 'utf8'
858
+ });
859
+
860
+ assert.notEqual(result.status, 0);
861
+ assert.match(`${result.stdout || ''}${result.stderr || ''}`, /Unknown template placeholder|unknownPlaceholder/);
862
+ });
863
+
864
+ test('run-bug-hunter phase retries invalid skeptic output and renders a markdown companion', () => {
865
+ const sandbox = makeSandbox('run-bug-hunter-phase-skeptic-');
866
+ const runner = resolveSkillScript('run-bug-hunter.cjs');
867
+ const skillDir = path.resolve(__dirname, '..', '..');
868
+ const outputPath = path.join(sandbox, '.bug-hunter', 'skeptic.json');
869
+ const renderOutputPath = path.join(sandbox, '.bug-hunter', 'skeptic.md');
870
+ const journalPath = path.join(sandbox, '.bug-hunter', 'phase.log');
871
+ const attemptsFile = path.join(sandbox, 'attempts.json');
872
+ const workerPath = path.join(sandbox, 'skeptic-worker.cjs');
873
+
874
+ fs.writeFileSync(workerPath, [
875
+ '#!/usr/bin/env node',
876
+ "const fs = require('fs');",
877
+ "const path = require('path');",
878
+ "const args = process.argv;",
879
+ "const outputPath = args[args.indexOf('--output-path') + 1];",
880
+ "const attemptsPath = args[args.indexOf('--attempts-file') + 1];",
881
+ 'let attempts = {};',
882
+ "if (fs.existsSync(attemptsPath)) attempts = JSON.parse(fs.readFileSync(attemptsPath, 'utf8'));",
883
+ "attempts.skeptic = (attempts.skeptic || 0) + 1;",
884
+ "fs.mkdirSync(path.dirname(attemptsPath), { recursive: true });",
885
+ "fs.writeFileSync(attemptsPath, JSON.stringify(attempts, null, 2));",
886
+ "const payload = attempts.skeptic === 1",
887
+ " ? [{ bugId: 'BUG-1', response: 'ACCEPT' }]",
888
+ " : [{ bugId: 'BUG-1', response: 'ACCEPT', analysisSummary: 'Validated on retry.' }];",
889
+ "fs.mkdirSync(path.dirname(outputPath), { recursive: true });",
890
+ "fs.writeFileSync(outputPath, JSON.stringify(payload, null, 2));"
891
+ ].join('\n'), 'utf8');
892
+
893
+ const workerTemplate = [
894
+ 'node',
895
+ workerPath,
896
+ '--output-path',
897
+ '{outputPath}',
898
+ '--attempts-file',
899
+ attemptsFile
900
+ ].join(' ');
901
+
902
+ const renderTemplate = [
903
+ 'node',
904
+ path.join(skillDir, 'scripts', 'render-report.cjs'),
905
+ 'skeptic',
906
+ '{outputPath}',
907
+ '>',
908
+ '{renderOutputPath}'
909
+ ].join(' ');
910
+
911
+ const result = runJson('node', [
912
+ runner,
913
+ 'phase',
914
+ '--skill-dir',
915
+ skillDir,
916
+ '--phase-name',
917
+ 'skeptic-phase',
918
+ '--artifact',
919
+ 'skeptic',
920
+ '--output-path',
921
+ outputPath,
922
+ '--render-output-path',
923
+ renderOutputPath,
924
+ '--worker-cmd',
925
+ workerTemplate,
926
+ '--render-cmd',
927
+ renderTemplate,
928
+ '--timeout-ms',
929
+ '5000',
930
+ '--max-retries',
931
+ '1',
932
+ '--backoff-ms',
933
+ '10',
934
+ '--journal-path',
935
+ journalPath
936
+ ], {
937
+ cwd: sandbox
938
+ });
939
+
940
+ assert.equal(result.ok, true);
941
+ assert.equal(result.artifact, 'skeptic');
942
+ assert.equal(fs.existsSync(outputPath), true);
943
+ assert.equal(fs.existsSync(renderOutputPath), true);
944
+
945
+ const attempts = readJson(attemptsFile);
946
+ assert.equal(attempts.skeptic, 2);
947
+
948
+ const journal = fs.readFileSync(journalPath, 'utf8');
949
+ assert.match(journal, /attempt-post-check-failed/);
950
+ assert.match(journal, /\$\[0\]\.analysisSummary is required/);
951
+
952
+ const rendered = fs.readFileSync(renderOutputPath, 'utf8');
953
+ assert.match(rendered, /# Skeptic Review/);
954
+ assert.match(rendered, /Validated on retry/);
955
+ });
956
+
957
+ test('run-bug-hunter phase validates referee and fix-report artifacts', () => {
958
+ const sandbox = makeSandbox('run-bug-hunter-phase-multi-');
959
+ const runner = resolveSkillScript('run-bug-hunter.cjs');
960
+ const skillDir = path.resolve(__dirname, '..', '..');
961
+
962
+ const phases = [
963
+ {
964
+ artifact: 'referee',
965
+ invalidBody: "[{\"bugId\":\"BUG-1\",\"verdict\":\"REAL_BUG\"}]",
966
+ validBody: JSON.stringify([
967
+ {
968
+ bugId: 'BUG-1',
969
+ verdict: 'REAL_BUG',
970
+ trueSeverity: 'Critical',
971
+ confidenceScore: 99,
972
+ confidenceLabel: 'high',
973
+ verificationMode: 'INDEPENDENTLY_VERIFIED',
974
+ analysisSummary: 'Confirmed on retry.'
975
+ }
976
+ ], null, 2),
977
+ expectedError: '\\$\\[0\\]\\.trueSeverity is required'
978
+ },
979
+ {
980
+ artifact: 'fix-report',
981
+ invalidBody: JSON.stringify({
982
+ version: '3.0.4',
983
+ fix_branch: 'bug-hunter-fix-branch'
984
+ }, null, 2),
985
+ validBody: JSON.stringify({
986
+ version: '3.0.4',
987
+ fix_branch: 'bug-hunter-fix-branch',
988
+ base_commit: 'abc123',
989
+ dry_run: false,
990
+ circuit_breaker_tripped: false,
991
+ phase2_timeout_hit: false,
992
+ fixes: [],
993
+ verification: {
994
+ baseline_pass: 1,
995
+ baseline_fail: 0,
996
+ flaky_tests: 0,
997
+ final_pass: 1,
998
+ final_fail: 0,
999
+ new_failures: 0,
1000
+ resolved_failures: 0,
1001
+ typecheck_pass: true,
1002
+ build_pass: true,
1003
+ fixer_bugs_found: 0
1004
+ },
1005
+ summary: {
1006
+ total_confirmed: 0,
1007
+ eligible: 0,
1008
+ manual_review: 0,
1009
+ fixed: 0,
1010
+ fix_reverted: 0,
1011
+ fix_failed: 0,
1012
+ skipped: 0,
1013
+ fixer_bug: 0,
1014
+ partial: 0
1015
+ }
1016
+ }, null, 2),
1017
+ expectedError: '\\$\\.base_commit is required'
1018
+ }
1019
+ ];
1020
+
1021
+ phases.forEach((phase) => {
1022
+ const outputPath = path.join(sandbox, '.bug-hunter', `${phase.artifact}.json`);
1023
+ const journalPath = path.join(sandbox, '.bug-hunter', `${phase.artifact}.log`);
1024
+ const attemptsFile = path.join(sandbox, `${phase.artifact}-attempts.json`);
1025
+ const workerPath = path.join(sandbox, `${phase.artifact}-worker.cjs`);
1026
+
1027
+ fs.writeFileSync(workerPath, [
1028
+ '#!/usr/bin/env node',
1029
+ "const fs = require('fs');",
1030
+ "const path = require('path');",
1031
+ "const args = process.argv;",
1032
+ "const outputPath = args[args.indexOf('--output-path') + 1];",
1033
+ "const attemptsPath = args[args.indexOf('--attempts-file') + 1];",
1034
+ 'let attempts = 0;',
1035
+ "if (fs.existsSync(attemptsPath)) attempts = Number(fs.readFileSync(attemptsPath, 'utf8'));",
1036
+ 'attempts += 1;',
1037
+ "fs.mkdirSync(path.dirname(attemptsPath), { recursive: true });",
1038
+ "fs.writeFileSync(attemptsPath, String(attempts));",
1039
+ `const invalidBody = ${JSON.stringify(phase.invalidBody)};`,
1040
+ `const validBody = ${JSON.stringify(phase.validBody)};`,
1041
+ "fs.mkdirSync(path.dirname(outputPath), { recursive: true });",
1042
+ "fs.writeFileSync(outputPath, attempts === 1 ? invalidBody : validBody);"
1043
+ ].join('\n'), 'utf8');
1044
+
1045
+ const workerTemplate = [
1046
+ 'node',
1047
+ workerPath,
1048
+ '--output-path',
1049
+ '{outputPath}',
1050
+ '--attempts-file',
1051
+ attemptsFile
1052
+ ].join(' ');
1053
+
1054
+ const result = runJson('node', [
1055
+ runner,
1056
+ 'phase',
1057
+ '--skill-dir',
1058
+ skillDir,
1059
+ '--phase-name',
1060
+ `${phase.artifact}-phase`,
1061
+ '--artifact',
1062
+ phase.artifact,
1063
+ '--output-path',
1064
+ outputPath,
1065
+ '--worker-cmd',
1066
+ workerTemplate,
1067
+ '--timeout-ms',
1068
+ '5000',
1069
+ '--max-retries',
1070
+ '1',
1071
+ '--backoff-ms',
1072
+ '10',
1073
+ '--journal-path',
1074
+ journalPath
1075
+ ], {
1076
+ cwd: sandbox
1077
+ });
1078
+
1079
+ assert.equal(result.ok, true);
1080
+ assert.equal(result.artifact, phase.artifact);
1081
+ assert.equal(fs.existsSync(outputPath), true);
1082
+
1083
+ const journal = fs.readFileSync(journalPath, 'utf8');
1084
+ assert.match(journal, /attempt-post-check-failed/);
1085
+ assert.match(journal, new RegExp(phase.expectedError));
1086
+ });
1087
+ });