@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
@@ -3,6 +3,7 @@
3
3
  const childProcess = require('child_process');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const { validateArtifactFile, validateArtifactValue } = require('./schema-runtime.cjs');
6
7
 
7
8
  const BACKEND_PRIORITY = ['spawn_agent', 'subagent', 'teams', 'local-sequential'];
8
9
  const DEFAULT_TIMEOUT_MS = 120000;
@@ -17,7 +18,8 @@ const DEFAULT_EXPANSION_CAP = 40;
17
18
  function usage() {
18
19
  console.error('Usage:');
19
20
  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 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>] [--strategy-path <path>] [--strategy-markdown-path <path>]');
22
+ console.error(' run-bug-hunter.cjs phase --artifact <name> --output-path <path> --worker-cmd <template> [--phase-name <name>] [--skill-dir <path>] [--journal-path <path>] [--render-cmd <template>] [--render-output-path <path>] [--timeout-ms <n>] [--render-timeout-ms <n>] [--max-retries <n>] [--backoff-ms <n>]');
21
23
  console.error(' run-bug-hunter.cjs plan --files-json <path> [--mode <name>] [--skill-dir <path>] [--chunk-size <n>] [--plan-path <path>]');
22
24
  }
23
25
 
@@ -114,10 +116,23 @@ function requiredScripts(skillDir) {
114
116
  return [
115
117
  path.join(skillDir, 'scripts', 'bug-hunter-state.cjs'),
116
118
  path.join(skillDir, 'scripts', 'payload-guard.cjs'),
119
+ path.join(skillDir, 'scripts', 'schema-validate.cjs'),
120
+ path.join(skillDir, 'scripts', 'schema-runtime.cjs'),
121
+ path.join(skillDir, 'scripts', 'render-report.cjs'),
117
122
  path.join(skillDir, 'scripts', 'fix-lock.cjs'),
118
123
  path.join(skillDir, 'scripts', 'doc-lookup.cjs'),
119
124
  path.join(skillDir, 'scripts', 'context7-api.cjs'),
120
- path.join(skillDir, 'scripts', 'delta-mode.cjs')
125
+ path.join(skillDir, 'scripts', 'delta-mode.cjs'),
126
+ path.join(skillDir, 'scripts', 'pr-scope.cjs'),
127
+ path.join(skillDir, 'schemas', 'findings.schema.json'),
128
+ path.join(skillDir, 'schemas', 'skeptic.schema.json'),
129
+ path.join(skillDir, 'schemas', 'referee.schema.json'),
130
+ path.join(skillDir, 'schemas', 'coverage.schema.json'),
131
+ path.join(skillDir, 'schemas', 'fix-report.schema.json'),
132
+ path.join(skillDir, 'schemas', 'fix-plan.schema.json'),
133
+ path.join(skillDir, 'schemas', 'fix-strategy.schema.json'),
134
+ path.join(skillDir, 'schemas', 'recon.schema.json'),
135
+ path.join(skillDir, 'schemas', 'shared.schema.json')
121
136
  ];
122
137
  }
123
138
 
@@ -149,18 +164,38 @@ function runJsonScript(scriptPath, args) {
149
164
  return JSON.parse(output);
150
165
  }
151
166
 
167
+ function runTextScript(scriptPath, args) {
168
+ const result = childProcess.spawnSync('node', [scriptPath, ...args], {
169
+ encoding: 'utf8'
170
+ });
171
+ if (result.status !== 0) {
172
+ const stderr = (result.stderr || '').trim();
173
+ const stdout = (result.stdout || '').trim();
174
+ throw new Error(stderr || stdout || `Script failed: ${scriptPath}`);
175
+ }
176
+ return result.stdout || '';
177
+ }
178
+
152
179
  function appendJournal(logPath, event) {
153
180
  ensureDir(path.dirname(logPath));
154
181
  const line = JSON.stringify({ at: nowIso(), ...event });
155
182
  fs.appendFileSync(logPath, `${line}\n`, 'utf8');
156
183
  }
157
184
 
185
+ function shellQuote(value) {
186
+ const stringValue = String(value);
187
+ if (stringValue.length === 0) {
188
+ return "''";
189
+ }
190
+ return `'${stringValue.replace(/'/g, `'\\''`)}'`;
191
+ }
192
+
158
193
  function fillTemplate(template, variables) {
159
194
  return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => {
160
195
  if (!(key in variables)) {
161
- return match;
196
+ throw new Error(`Unknown template placeholder: ${key}`);
162
197
  }
163
- return String(variables[key]);
198
+ return shellQuote(variables[key]);
164
199
  });
165
200
  }
166
201
 
@@ -213,7 +248,9 @@ async function runWithRetry({
213
248
  backoffMs,
214
249
  journalPath,
215
250
  phase,
216
- chunkId
251
+ chunkId,
252
+ beforeAttempt,
253
+ postAttempt
217
254
  }) {
218
255
  const attempts = maxRetries + 1;
219
256
  let lastResult = null;
@@ -227,20 +264,45 @@ async function runWithRetry({
227
264
  attempts,
228
265
  timeoutMs
229
266
  });
267
+ if (typeof beforeAttempt === 'function') {
268
+ await beforeAttempt({ attempt });
269
+ }
230
270
  const result = await runCommandOnce({ command, timeoutMs });
231
- lastResult = result;
271
+ let finalResult = result;
272
+
273
+ if (finalResult.ok && typeof postAttempt === 'function') {
274
+ const postAttemptResult = await postAttempt({ attempt });
275
+ if (!postAttemptResult.ok) {
276
+ const validationMessage = String(postAttemptResult.errorMessage || 'post-attempt validation failed');
277
+ appendJournal(journalPath, {
278
+ event: 'attempt-post-check-failed',
279
+ phase,
280
+ chunkId,
281
+ attempt,
282
+ errorMessage: validationMessage.slice(0, 500)
283
+ });
284
+ finalResult = {
285
+ ...finalResult,
286
+ ok: false,
287
+ stderr: validationMessage
288
+ };
289
+ }
290
+ }
291
+
232
292
  appendJournal(journalPath, {
233
293
  event: 'attempt-end',
234
294
  phase,
235
295
  chunkId,
236
296
  attempt,
237
- ok: result.ok,
238
- code: result.code,
239
- timeoutHit: result.timeoutHit,
240
- stderr: result.stderr.slice(0, 500)
297
+ ok: finalResult.ok,
298
+ code: finalResult.code,
299
+ timeoutHit: finalResult.timeoutHit,
300
+ stderr: finalResult.stderr.slice(0, 500)
241
301
  });
242
- if (result.ok) {
243
- return { ok: true, result, attemptsUsed: attempt };
302
+
303
+ lastResult = finalResult;
304
+ if (finalResult.ok) {
305
+ return { ok: true, result: finalResult, attemptsUsed: attempt };
244
306
  }
245
307
  if (attempt < attempts) {
246
308
  const delayMs = backoffMs * 2 ** (attempt - 1);
@@ -378,8 +440,8 @@ function buildConsistencyReport({ bugLedger, confidenceThreshold }) {
378
440
  }
379
441
 
380
442
  const lowConfidence = bugLedger.filter((entry) => {
381
- const confidence = entry.confidence;
382
- return confidence === null || confidence === undefined || Number(confidence) < confidenceThreshold;
443
+ const confidenceScore = entry.confidenceScore;
444
+ return confidenceScore === null || confidenceScore === undefined || Number(confidenceScore) < confidenceThreshold;
383
445
  }).length;
384
446
 
385
447
  return {
@@ -391,30 +453,69 @@ function buildConsistencyReport({ bugLedger, confidenceThreshold }) {
391
453
  };
392
454
  }
393
455
 
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;
456
+ function buildConflictSets(consistency) {
457
+ const conflicts = toArray(consistency && consistency.conflicts);
458
+ const bugIds = new Set();
459
+ const locations = new Set();
460
+
461
+ for (const conflict of conflicts) {
462
+ if (conflict && conflict.type === 'bug-id-reused' && conflict.bugId) {
463
+ bugIds.add(String(conflict.bugId));
464
+ }
465
+ if (conflict && conflict.type === 'location-claim-conflict' && conflict.location) {
466
+ locations.add(String(conflict.location));
467
+ }
468
+ }
469
+
470
+ return { bugIds, locations };
471
+ }
472
+
473
+ function applyConflictClassification(entry, classification, conflictSets) {
474
+ const bugId = String(entry.bugId || '').trim();
475
+ const location = `${entry.file || ''}|${entry.lines || ''}`;
476
+ const hasConflict = conflictSets.bugIds.has(bugId) || conflictSets.locations.has(location);
477
+ if (!hasConflict) {
478
+ return classification;
479
+ }
480
+ return {
481
+ strategy: 'manual-review',
482
+ executionStage: 'manual-review',
483
+ autofixEligible: false,
484
+ reason: 'Consistency conflict requires manual review before any fix is attempted.'
485
+ };
486
+ }
487
+
488
+ function buildFixPlan({ bugLedger, confidenceThreshold, canarySize, consistency }) {
489
+ const conflictSets = buildConflictSets(consistency);
490
+ const classifiedEntries = bugLedger.map((entry) => {
491
+ const confidenceRaw = entry.confidenceScore;
492
+ const confidenceScore = Number.isFinite(Number(confidenceRaw)) ? Number(confidenceRaw) : null;
493
+ const classification = applyConflictClassification(
494
+ entry,
495
+ classifyStrategy({ ...entry, confidenceScore }, confidenceThreshold),
496
+ conflictSets
497
+ );
398
498
  return {
399
499
  ...entry,
400
- confidence
500
+ confidenceScore,
501
+ ...classification
401
502
  };
402
503
  });
403
- const eligible = withConfidence
404
- .filter((entry) => entry.confidence !== null && entry.confidence >= confidenceThreshold)
504
+ const eligible = classifiedEntries
505
+ .filter((entry) => entry.autofixEligible === true)
405
506
  .sort((left, right) => {
406
507
  const severityDiff = severityRank(right.severity) - severityRank(left.severity);
407
508
  if (severityDiff !== 0) {
408
509
  return severityDiff;
409
510
  }
410
- const confidenceDiff = (right.confidence || 0) - (left.confidence || 0);
511
+ const confidenceDiff = (right.confidenceScore || 0) - (left.confidenceScore || 0);
411
512
  if (confidenceDiff !== 0) {
412
513
  return confidenceDiff;
413
514
  }
414
515
  return String(left.key).localeCompare(String(right.key));
415
516
  });
416
- const manualReview = withConfidence
417
- .filter((entry) => entry.confidence === null || entry.confidence < confidenceThreshold);
517
+ const manualReview = classifiedEntries
518
+ .filter((entry) => entry.autofixEligible !== true);
418
519
  const canary = eligible.slice(0, canarySize);
419
520
  const rollout = eligible.slice(canarySize);
420
521
 
@@ -423,7 +524,7 @@ function buildFixPlan({ bugLedger, confidenceThreshold, canarySize }) {
423
524
  confidenceThreshold,
424
525
  canarySize,
425
526
  totals: {
426
- findings: withConfidence.length,
527
+ findings: classifiedEntries.length,
427
528
  eligible: eligible.length,
428
529
  canary: canary.length,
429
530
  rollout: rollout.length,
@@ -435,6 +536,431 @@ function buildFixPlan({ bugLedger, confidenceThreshold, canarySize }) {
435
536
  };
436
537
  }
437
538
 
539
+ function classifyStrategy(entry, confidenceThreshold) {
540
+ const confidenceScore = Number.isFinite(Number(entry.confidenceScore)) ? Number(entry.confidenceScore) : null;
541
+ const claim = String(entry.claim || '').toLowerCase();
542
+ const crossReferences = toArray(entry.crossReferences);
543
+ const architecturalSignals = ['architecture', 'migration', 'schema', 'contract', 'signature', 'protocol'];
544
+ const refactorSignals = ['refactor', 'transaction', 'concurrency', 'race', 'lock ordering'];
545
+
546
+ if (confidenceScore === null || confidenceScore < confidenceThreshold) {
547
+ return {
548
+ strategy: 'manual-review',
549
+ executionStage: 'manual-review',
550
+ autofixEligible: false,
551
+ reason: 'Confidence is below the autofix threshold.'
552
+ };
553
+ }
554
+
555
+ if (architecturalSignals.some((signal) => claim.includes(signal)) || crossReferences.length >= 3) {
556
+ return {
557
+ strategy: 'architectural-remediation',
558
+ executionStage: 'report-only',
559
+ autofixEligible: false,
560
+ reason: 'Claim spans broader contracts or architecture boundaries.'
561
+ };
562
+ }
563
+
564
+ if (refactorSignals.some((signal) => claim.includes(signal)) || severityRank(entry.severity) >= 2 && crossReferences.length >= 2) {
565
+ return {
566
+ strategy: 'larger-refactor',
567
+ executionStage: 'manual-review',
568
+ autofixEligible: false,
569
+ reason: 'Fix likely needs coordinated multi-file changes beyond a surgical patch.'
570
+ };
571
+ }
572
+
573
+ return {
574
+ strategy: 'safe-autofix',
575
+ executionStage: severityRank(entry.severity) >= 2 ? 'canary' : 'rollout',
576
+ autofixEligible: true,
577
+ reason: 'Finding is localized enough for a guarded surgical fix.'
578
+ };
579
+ }
580
+
581
+ function recommendedActionForStrategy(strategy) {
582
+ if (strategy === 'architectural-remediation') {
583
+ return 'Do not auto-edit. Capture a remediation design and schedule a broader change.';
584
+ }
585
+ if (strategy === 'larger-refactor') {
586
+ return 'Pause before patching. Review interfaces, callers, and rollback scope with a human.';
587
+ }
588
+ if (strategy === 'manual-review') {
589
+ return 'Keep this in the report and require human approval before any edits.';
590
+ }
591
+ return 'Proceed through the guarded fix pipeline with canary verification and rollback safety.';
592
+ }
593
+
594
+ function buildFixStrategy({ bugLedger, confidenceThreshold, consistency }) {
595
+ const conflictSets = buildConflictSets(consistency);
596
+ const normalized = bugLedger.map((entry) => {
597
+ const confidenceScore = Number.isFinite(Number(entry.confidenceScore)) ? Number(entry.confidenceScore) : null;
598
+ const classification = applyConflictClassification(
599
+ entry,
600
+ classifyStrategy({ ...entry, confidenceScore }, confidenceThreshold),
601
+ conflictSets
602
+ );
603
+ const filePath = String(entry.file || '').trim() || 'unknown-file';
604
+ const clusterDir = path.dirname(filePath);
605
+ const clusterSeed = `${classification.strategy}|${classification.executionStage}|${clusterDir}`;
606
+ return {
607
+ ...entry,
608
+ confidenceScore,
609
+ file: filePath,
610
+ clusterDir,
611
+ clusterSeed,
612
+ ...classification
613
+ };
614
+ });
615
+
616
+ const byCluster = new Map();
617
+ for (const entry of normalized) {
618
+ if (!byCluster.has(entry.clusterSeed)) {
619
+ byCluster.set(entry.clusterSeed, []);
620
+ }
621
+ byCluster.get(entry.clusterSeed).push(entry);
622
+ }
623
+
624
+ const clusters = [...byCluster.entries()].map(([clusterSeed, entries], index) => {
625
+ const strategy = entries[0].strategy;
626
+ const executionStage = entries[0].executionStage;
627
+ const files = [...new Set(entries.map((entry) => entry.file))].sort();
628
+ const bugIds = [...new Set(entries.map((entry) => String(entry.bugId || entry.key || '').trim()).filter(Boolean))];
629
+ const maxSeverity = entries
630
+ .map((entry) => entry.severity)
631
+ .sort((left, right) => severityRank(right) - severityRank(left))[0] || 'LOW';
632
+ const reasons = [...new Set(entries.map((entry) => entry.reason).filter(Boolean))];
633
+ const firstDir = entries[0].clusterDir || path.dirname(files[0] || 'unknown-file');
634
+ return {
635
+ clusterId: `cluster-${index + 1}`,
636
+ strategy,
637
+ executionStage,
638
+ autofixEligible: entries.every((entry) => entry.autofixEligible),
639
+ bugIds,
640
+ files,
641
+ maxSeverity,
642
+ summary: `${bugIds.length} bug(s) in ${firstDir || '.'} classified as ${strategy}.`,
643
+ recommendedAction: recommendedActionForStrategy(strategy),
644
+ reasons
645
+ };
646
+ }).sort((left, right) => {
647
+ const stageRank = {
648
+ canary: 0,
649
+ rollout: 1,
650
+ 'manual-review': 2,
651
+ 'report-only': 3
652
+ };
653
+ const stageDiff = stageRank[left.executionStage] - stageRank[right.executionStage];
654
+ if (stageDiff !== 0) {
655
+ return stageDiff;
656
+ }
657
+ return severityRank(right.maxSeverity) - severityRank(left.maxSeverity);
658
+ });
659
+
660
+ const summary = {
661
+ confirmed: normalized.length,
662
+ safeAutofix: normalized.filter((entry) => entry.strategy === 'safe-autofix').length,
663
+ manualReview: normalized.filter((entry) => entry.strategy === 'manual-review').length,
664
+ largerRefactor: normalized.filter((entry) => entry.strategy === 'larger-refactor').length,
665
+ architecturalRemediation: normalized.filter((entry) => entry.strategy === 'architectural-remediation').length,
666
+ canaryCandidates: normalized.filter((entry) => entry.executionStage === 'canary').length,
667
+ rolloutCandidates: normalized.filter((entry) => entry.executionStage === 'rollout').length
668
+ };
669
+
670
+ return {
671
+ version: '3.1.0',
672
+ generatedAt: nowIso(),
673
+ confidenceThreshold,
674
+ summary,
675
+ clusters
676
+ };
677
+ }
678
+
679
+ function toCoverageStatus(chunkStatus) {
680
+ if (chunkStatus === 'done') {
681
+ return 'done';
682
+ }
683
+ if (chunkStatus === 'in_progress') {
684
+ return 'in_progress';
685
+ }
686
+ if (chunkStatus === 'failed') {
687
+ return 'failed';
688
+ }
689
+ return 'pending';
690
+ }
691
+
692
+ function buildCoverageArtifact({ state, fixPlan }) {
693
+ const fileEntries = toArray(state.chunks).flatMap((chunk) => {
694
+ return toArray(chunk.files).map((filePath) => {
695
+ return {
696
+ path: String(filePath),
697
+ status: toCoverageStatus(chunk.status)
698
+ };
699
+ });
700
+ });
701
+
702
+ const bugs = toArray(state.bugLedger).map((entry) => {
703
+ return {
704
+ bugId: String(entry.bugId || '').trim() || String(entry.key || '').trim(),
705
+ severity: String(entry.severity || 'Low'),
706
+ file: String(entry.file || '').trim(),
707
+ claim: String(entry.claim || '').trim()
708
+ };
709
+ });
710
+
711
+ const fixStatusByBugId = new Map();
712
+ for (const entry of toArray(fixPlan && fixPlan.canary)) {
713
+ fixStatusByBugId.set(String(entry.bugId || '').trim(), 'CANARY');
714
+ }
715
+ for (const entry of toArray(fixPlan && fixPlan.rollout)) {
716
+ fixStatusByBugId.set(String(entry.bugId || '').trim(), 'ROLLOUT');
717
+ }
718
+ for (const entry of toArray(fixPlan && fixPlan.manualReview)) {
719
+ fixStatusByBugId.set(String(entry.bugId || '').trim(), 'MANUAL_REVIEW');
720
+ }
721
+
722
+ const fixes = [...fixStatusByBugId.entries()]
723
+ .filter(([bugId]) => Boolean(bugId))
724
+ .map(([bugId, status]) => {
725
+ return {
726
+ bugId,
727
+ status
728
+ };
729
+ });
730
+
731
+ const hasOpenChunks = toArray(state.chunks).some((chunk) => chunk.status !== 'done');
732
+
733
+ return {
734
+ schemaVersion: 1,
735
+ iteration: 1,
736
+ status: hasOpenChunks ? 'IN_PROGRESS' : 'COMPLETE',
737
+ files: fileEntries,
738
+ bugs,
739
+ fixes
740
+ };
741
+ }
742
+
743
+ function renderCoverageMarkdown(coverage) {
744
+ const lines = [
745
+ '# Bug Hunter Coverage',
746
+ '',
747
+ `- Status: ${coverage.status}`,
748
+ `- Iteration: ${coverage.iteration}`,
749
+ `- Files: ${coverage.files.length}`,
750
+ `- Bugs: ${coverage.bugs.length}`,
751
+ `- Fix entries: ${coverage.fixes.length}`,
752
+ '',
753
+ '## Files'
754
+ ];
755
+
756
+ if (coverage.files.length === 0) {
757
+ lines.push('- None');
758
+ } else {
759
+ for (const entry of coverage.files) {
760
+ lines.push(`- ${entry.status} | ${entry.path}`);
761
+ }
762
+ }
763
+
764
+ lines.push('', '## Bugs');
765
+ if (coverage.bugs.length === 0) {
766
+ lines.push('- None');
767
+ } else {
768
+ for (const bug of coverage.bugs) {
769
+ lines.push(`- ${bug.bugId} | ${bug.severity} | ${bug.file} | ${bug.claim}`);
770
+ }
771
+ }
772
+
773
+ lines.push('', '## Fixes');
774
+ if (coverage.fixes.length === 0) {
775
+ lines.push('- None');
776
+ } else {
777
+ for (const fix of coverage.fixes) {
778
+ lines.push(`- ${fix.bugId} | ${fix.status}`);
779
+ }
780
+ }
781
+
782
+ return `${lines.join('\n')}\n`;
783
+ }
784
+
785
+ function validateFindingsArtifact(findingsJsonPath) {
786
+ if (!fs.existsSync(findingsJsonPath)) {
787
+ return {
788
+ ok: false,
789
+ errors: [`Missing findings artifact: ${findingsJsonPath}`]
790
+ };
791
+ }
792
+ return validateArtifactFile({
793
+ artifactName: 'findings',
794
+ filePath: findingsJsonPath
795
+ });
796
+ }
797
+
798
+ function validateNamedArtifact({ artifactName, filePath }) {
799
+ if (!fs.existsSync(filePath)) {
800
+ return {
801
+ ok: false,
802
+ errors: [`Missing ${artifactName} artifact: ${filePath}`]
803
+ };
804
+ }
805
+ return validateArtifactFile({
806
+ artifactName,
807
+ filePath
808
+ });
809
+ }
810
+
811
+ function removeFileIfExists(filePath) {
812
+ if (!filePath) {
813
+ return;
814
+ }
815
+ if (fs.existsSync(filePath)) {
816
+ fs.unlinkSync(filePath);
817
+ }
818
+ }
819
+
820
+ async function runPhase(options) {
821
+ const artifact = String(options.artifact || '').trim();
822
+ if (!artifact) {
823
+ throw new Error('--artifact is required for phase command');
824
+ }
825
+ if (!options['output-path']) {
826
+ throw new Error('--output-path is required for phase command');
827
+ }
828
+ if (!options['worker-cmd']) {
829
+ throw new Error('--worker-cmd is required for phase command');
830
+ }
831
+
832
+ const skillDir = resolveSkillDir(options);
833
+ const preflightResult = preflight(options);
834
+ if (!preflightResult.ok) {
835
+ throw new Error(`Missing helper scripts: ${preflightResult.missing.join(', ')}`);
836
+ }
837
+
838
+ const phaseName = options['phase-name'] || artifact;
839
+ const outputPath = path.resolve(options['output-path']);
840
+ const renderOutputPath = options['render-output-path']
841
+ ? path.resolve(options['render-output-path'])
842
+ : null;
843
+ const workerCmdTemplate = options['worker-cmd'];
844
+ const renderCmdTemplate = options['render-cmd'] || null;
845
+ const timeoutMs = toPositiveInt(options['timeout-ms'], DEFAULT_TIMEOUT_MS);
846
+ const renderTimeoutMs = toPositiveInt(options['render-timeout-ms'], timeoutMs);
847
+ const maxRetries = toPositiveInt(options['max-retries'], DEFAULT_MAX_RETRIES);
848
+ const backoffMs = toPositiveInt(options['backoff-ms'], DEFAULT_BACKOFF_MS);
849
+ const journalPath = path.resolve(
850
+ options['journal-path'] || path.join(path.dirname(outputPath), `${phaseName}.log`)
851
+ );
852
+ const templateVariables = {
853
+ artifact,
854
+ outputPath,
855
+ outputFilePath: outputPath,
856
+ renderOutputPath: renderOutputPath || '',
857
+ journalPath,
858
+ phaseName,
859
+ skillDir
860
+ };
861
+
862
+ ensureDir(path.dirname(outputPath));
863
+ if (renderOutputPath) {
864
+ ensureDir(path.dirname(renderOutputPath));
865
+ }
866
+ removeFileIfExists(outputPath);
867
+ removeFileIfExists(renderOutputPath);
868
+
869
+ appendJournal(journalPath, {
870
+ event: 'phase-start',
871
+ artifact,
872
+ phase: phaseName,
873
+ outputPath,
874
+ renderOutputPath
875
+ });
876
+
877
+ const workerCommand = fillTemplate(workerCmdTemplate, templateVariables);
878
+ const runResult = await runWithRetry({
879
+ command: workerCommand,
880
+ timeoutMs,
881
+ maxRetries,
882
+ backoffMs,
883
+ journalPath,
884
+ phase: phaseName,
885
+ chunkId: artifact,
886
+ beforeAttempt: async () => {
887
+ removeFileIfExists(outputPath);
888
+ removeFileIfExists(renderOutputPath);
889
+ },
890
+ postAttempt: async () => {
891
+ const validation = validateNamedArtifact({
892
+ artifactName: artifact,
893
+ filePath: outputPath
894
+ });
895
+ if (validation.ok) {
896
+ return { ok: true };
897
+ }
898
+ return {
899
+ ok: false,
900
+ errorMessage: validation.errors.join('; ')
901
+ };
902
+ }
903
+ });
904
+
905
+ if (!runResult.ok) {
906
+ const errorMessage = (runResult.result && runResult.result.stderr) || `${phaseName} failed`;
907
+ appendJournal(journalPath, {
908
+ event: 'phase-failed',
909
+ artifact,
910
+ phase: phaseName,
911
+ errorMessage: errorMessage.slice(0, 500)
912
+ });
913
+ throw new Error(errorMessage);
914
+ }
915
+
916
+ if (renderCmdTemplate) {
917
+ const renderCommand = fillTemplate(renderCmdTemplate, templateVariables);
918
+ appendJournal(journalPath, {
919
+ event: 'phase-render-start',
920
+ artifact,
921
+ phase: phaseName,
922
+ renderOutputPath
923
+ });
924
+ const renderResult = await runCommandOnce({
925
+ command: renderCommand,
926
+ timeoutMs: renderTimeoutMs
927
+ });
928
+ if (!renderResult.ok) {
929
+ const renderError = renderResult.stderr || renderResult.stdout || `${phaseName} render failed`;
930
+ appendJournal(journalPath, {
931
+ event: 'phase-render-failed',
932
+ artifact,
933
+ phase: phaseName,
934
+ errorMessage: renderError.slice(0, 500)
935
+ });
936
+ throw new Error(renderError);
937
+ }
938
+ appendJournal(journalPath, {
939
+ event: 'phase-render-end',
940
+ artifact,
941
+ phase: phaseName,
942
+ renderOutputPath
943
+ });
944
+ }
945
+
946
+ appendJournal(journalPath, {
947
+ event: 'phase-end',
948
+ artifact,
949
+ phase: phaseName,
950
+ attemptsUsed: runResult.attemptsUsed
951
+ });
952
+
953
+ return {
954
+ ok: true,
955
+ artifact,
956
+ phase: phaseName,
957
+ outputPath,
958
+ renderOutputPath,
959
+ journalPath,
960
+ attemptsUsed: runResult.attemptsUsed
961
+ };
962
+ }
963
+
438
964
  function loadIndex(indexPath) {
439
965
  if (!indexPath || !fs.existsSync(indexPath)) {
440
966
  return null;
@@ -513,7 +1039,21 @@ async function processPendingChunks({
513
1039
  backoffMs,
514
1040
  journalPath,
515
1041
  phase: 'chunk-worker',
516
- chunkId: chunk.id
1042
+ chunkId: chunk.id,
1043
+ beforeAttempt: async () => {
1044
+ removeFileIfExists(findingsJsonPath);
1045
+ removeFileIfExists(factsJsonPath);
1046
+ },
1047
+ postAttempt: async () => {
1048
+ const findingsValidation = validateFindingsArtifact(findingsJsonPath);
1049
+ if (findingsValidation.ok) {
1050
+ return { ok: true };
1051
+ }
1052
+ return {
1053
+ ok: false,
1054
+ errorMessage: findingsValidation.errors.join('; ')
1055
+ };
1056
+ }
517
1057
  });
518
1058
 
519
1059
  if (!runResult.ok) {
@@ -531,10 +1071,8 @@ async function processPendingChunks({
531
1071
  }
532
1072
 
533
1073
  let findings = [];
534
- if (fs.existsSync(findingsJsonPath)) {
535
- runJsonScript(stateScript, ['record-findings', statePath, findingsJsonPath, 'orchestrator']);
536
- findings = readJson(findingsJsonPath);
537
- }
1074
+ runJsonScript(stateScript, ['record-findings', statePath, findingsJsonPath, 'orchestrator']);
1075
+ findings = readJson(findingsJsonPath);
538
1076
 
539
1077
  if (fs.existsSync(factsJsonPath)) {
540
1078
  runJsonScript(stateScript, ['record-fact-card', statePath, chunk.id, factsJsonPath]);
@@ -662,6 +1200,10 @@ async function runPipeline(options) {
662
1200
  const chunksDir = path.resolve(path.dirname(statePath), 'chunks');
663
1201
  const consistencyReportPath = path.resolve(options['consistency-report'] || path.join(path.dirname(statePath), 'consistency.json'));
664
1202
  const fixPlanPath = path.resolve(options['fix-plan-path'] || path.join(path.dirname(statePath), 'fix-plan.json'));
1203
+ const strategyPath = path.resolve(options['strategy-path'] || path.join(path.dirname(statePath), 'fix-strategy.json'));
1204
+ const strategyMarkdownPath = path.resolve(options['strategy-markdown-path'] || path.join(path.dirname(statePath), 'fix-strategy.md'));
1205
+ const coveragePath = path.resolve(options['coverage-path'] || path.join(path.dirname(statePath), 'coverage.json'));
1206
+ const coverageMarkdownPath = path.resolve(options['coverage-markdown-path'] || path.join(path.dirname(statePath), 'coverage.md'));
665
1207
  const factsPath = path.resolve(options['facts-path'] || path.join(path.dirname(statePath), 'bug-hunter-facts.json'));
666
1208
  ensureDir(chunksDir);
667
1209
 
@@ -709,7 +1251,7 @@ async function runPipeline(options) {
709
1251
  const state = readJson(statePath);
710
1252
  const lowConfidenceFiles = normalizeFiles(state.bugLedger
711
1253
  .filter((entry) => {
712
- return entry.confidence === null || entry.confidence === undefined || Number(entry.confidence) < confidenceThreshold;
1254
+ return entry.confidenceScore === null || entry.confidenceScore === undefined || Number(entry.confidenceScore) < confidenceThreshold;
713
1255
  })
714
1256
  .map((entry) => entry.file));
715
1257
  if (lowConfidenceFiles.length > 0 && scope.indexPath) {
@@ -773,14 +1315,96 @@ async function runPipeline(options) {
773
1315
  writeJson(consistencyReportPath, consistency);
774
1316
  runJsonScript(stateScript, ['set-consistency', statePath, consistencyReportPath]);
775
1317
 
1318
+ const hasOpenOrFailedChunks = (status.summary.chunkStatus.pending || 0) > 0
1319
+ || (status.summary.chunkStatus.inProgress || 0) > 0
1320
+ || (status.summary.chunkStatus.failed || 0) > 0;
1321
+
1322
+ if (hasOpenOrFailedChunks) {
1323
+ appendJournal(journalPath, {
1324
+ event: 'fix-planning-skipped',
1325
+ reason: 'incomplete-or-failed-chunks',
1326
+ chunkStatus: status.summary.chunkStatus
1327
+ });
1328
+
1329
+ return {
1330
+ ok: true,
1331
+ backend,
1332
+ journalPath,
1333
+ statePath,
1334
+ indexPath: scope.indexPath,
1335
+ deltaMode: scope.deltaMode,
1336
+ deltaSummary: scope.deltaResult ? {
1337
+ selectedCount: (scope.deltaResult.selected || []).length,
1338
+ expansionCandidatesCount: (scope.deltaResult.expansionCandidates || []).length
1339
+ } : null,
1340
+ consistencyReportPath,
1341
+ strategyPath: null,
1342
+ strategyMarkdownPath: null,
1343
+ fixPlanPath: null,
1344
+ coveragePath: null,
1345
+ coverageMarkdownPath: null,
1346
+ factsPath,
1347
+ status: status.summary,
1348
+ consistency: {
1349
+ conflicts: consistency.conflicts.length,
1350
+ lowConfidenceFindings: consistency.lowConfidenceFindings
1351
+ },
1352
+ fixStrategy: null,
1353
+ fixPlan: null
1354
+ };
1355
+ }
1356
+
1357
+ const fixStrategy = buildFixStrategy({
1358
+ bugLedger: toArray(finalState.bugLedger),
1359
+ confidenceThreshold,
1360
+ consistency
1361
+ });
1362
+ const fixStrategyValidation = validateArtifactValue({
1363
+ artifactName: 'fix-strategy',
1364
+ value: fixStrategy
1365
+ });
1366
+ if (!fixStrategyValidation.ok) {
1367
+ throw new Error(`Generated invalid fix strategy artifact: ${fixStrategyValidation.errors.join('; ')}`);
1368
+ }
1369
+ writeJson(strategyPath, fixStrategy);
1370
+ ensureDir(path.dirname(strategyMarkdownPath));
1371
+ fs.writeFileSync(
1372
+ strategyMarkdownPath,
1373
+ runTextScript(path.join(skillDir, 'scripts', 'render-report.cjs'), ['fix-strategy', strategyPath]),
1374
+ 'utf8'
1375
+ );
1376
+
776
1377
  const fixPlan = buildFixPlan({
777
1378
  bugLedger: toArray(finalState.bugLedger),
778
1379
  confidenceThreshold,
779
- canarySize
1380
+ canarySize,
1381
+ consistency
780
1382
  });
1383
+ const fixPlanValidation = validateArtifactValue({
1384
+ artifactName: 'fix-plan',
1385
+ value: fixPlan
1386
+ });
1387
+ if (!fixPlanValidation.ok) {
1388
+ throw new Error(`Generated invalid fix plan artifact: ${fixPlanValidation.errors.join('; ')}`);
1389
+ }
781
1390
  writeJson(fixPlanPath, fixPlan);
782
1391
  runJsonScript(stateScript, ['set-fix-plan', statePath, fixPlanPath]);
783
1392
 
1393
+ const coverage = buildCoverageArtifact({
1394
+ state: finalState,
1395
+ fixPlan
1396
+ });
1397
+ const coverageValidation = validateArtifactValue({
1398
+ artifactName: 'coverage',
1399
+ value: coverage
1400
+ });
1401
+ if (!coverageValidation.ok) {
1402
+ throw new Error(`Generated invalid coverage artifact: ${coverageValidation.errors.join('; ')}`);
1403
+ }
1404
+ writeJson(coveragePath, coverage);
1405
+ ensureDir(path.dirname(coverageMarkdownPath));
1406
+ fs.writeFileSync(coverageMarkdownPath, renderCoverageMarkdown(coverage), 'utf8');
1407
+
784
1408
  writeJson(factsPath, finalState.factCards || {});
785
1409
 
786
1410
  appendJournal(journalPath, {
@@ -802,13 +1426,18 @@ async function runPipeline(options) {
802
1426
  expansionCandidatesCount: (scope.deltaResult.expansionCandidates || []).length
803
1427
  } : null,
804
1428
  consistencyReportPath,
1429
+ strategyPath,
1430
+ strategyMarkdownPath,
805
1431
  fixPlanPath,
1432
+ coveragePath,
1433
+ coverageMarkdownPath,
806
1434
  factsPath,
807
1435
  status: status.summary,
808
1436
  consistency: {
809
1437
  conflicts: consistency.conflicts.length,
810
1438
  lowConfidenceFindings: consistency.lowConfidenceFindings
811
1439
  },
1440
+ fixStrategy: fixStrategy.summary,
812
1441
  fixPlan: fixPlan.totals
813
1442
  };
814
1443
  }
@@ -835,6 +1464,12 @@ async function main() {
835
1464
  return;
836
1465
  }
837
1466
 
1467
+ if (command === 'phase') {
1468
+ const result = await runPhase(options);
1469
+ console.log(JSON.stringify(result, null, 2));
1470
+ return;
1471
+ }
1472
+
838
1473
  if (command === 'plan') {
839
1474
  if (!options['files-json']) {
840
1475
  throw new Error('--files-json is required for plan command');