@entelligentsia/forgecli 1.0.2 → 1.0.3

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 (88) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/CHANGELOG-forge-plugin.md +24 -0
  3. package/dist/extensions/forgecli/audience-gate.js +1 -1
  4. package/dist/extensions/forgecli/audience-gate.js.map +1 -1
  5. package/dist/extensions/forgecli/fix-bug.d.ts +1 -2
  6. package/dist/extensions/forgecli/fix-bug.js +678 -609
  7. package/dist/extensions/forgecli/fix-bug.js.map +1 -1
  8. package/dist/extensions/forgecli/forge-artifact-tool.js +15 -3
  9. package/dist/extensions/forgecli/forge-artifact-tool.js.map +1 -1
  10. package/dist/extensions/forgecli/forge-subagent.d.ts +17 -0
  11. package/dist/extensions/forgecli/forge-subagent.js +31 -12
  12. package/dist/extensions/forgecli/forge-subagent.js.map +1 -1
  13. package/dist/extensions/forgecli/forge-tools.d.ts +6 -0
  14. package/dist/extensions/forgecli/forge-tools.js +69 -6
  15. package/dist/extensions/forgecli/forge-tools.js.map +1 -1
  16. package/dist/extensions/forgecli/run-task.js +461 -391
  17. package/dist/extensions/forgecli/run-task.js.map +1 -1
  18. package/dist/extensions/forgecli/session-registry.d.ts +12 -0
  19. package/dist/extensions/forgecli/session-registry.js +23 -0
  20. package/dist/extensions/forgecli/session-registry.js.map +1 -1
  21. package/dist/extensions/forgecli/subagent/caller-context.d.ts +35 -11
  22. package/dist/extensions/forgecli/subagent/caller-context.js +49 -21
  23. package/dist/extensions/forgecli/subagent/caller-context.js.map +1 -1
  24. package/dist/extensions/forgecli/subagent/orchestrator-transcript.d.ts +66 -0
  25. package/dist/extensions/forgecli/subagent/orchestrator-transcript.js +66 -0
  26. package/dist/extensions/forgecli/subagent/orchestrator-transcript.js.map +1 -0
  27. package/dist/extensions/forgecli/subagent/phase-guard.d.ts +34 -0
  28. package/dist/extensions/forgecli/subagent/phase-guard.js +139 -0
  29. package/dist/extensions/forgecli/subagent/phase-guard.js.map +1 -0
  30. package/dist/extensions/forgecli/subagent/phase-summary-map.d.ts +1 -0
  31. package/dist/extensions/forgecli/subagent/phase-summary-map.js +22 -0
  32. package/dist/extensions/forgecli/subagent/phase-summary-map.js.map +1 -0
  33. package/dist/extensions/forgecli/thread-switcher.js +2 -2
  34. package/dist/extensions/forgecli/thread-switcher.js.map +1 -1
  35. package/dist/extensions/forgecli/viewport-events.d.ts +4 -0
  36. package/dist/extensions/forgecli/viewport-events.js +18 -1
  37. package/dist/extensions/forgecli/viewport-events.js.map +1 -1
  38. package/dist/extensions/forgecli/viewport-renderer.d.ts +12 -2
  39. package/dist/extensions/forgecli/viewport-renderer.js +8 -6
  40. package/dist/extensions/forgecli/viewport-renderer.js.map +1 -1
  41. package/dist/forge-payload/.base-pack/workflows/fix_bug.md +10 -28
  42. package/dist/forge-payload/.base-pack/workflows/triage.md +190 -0
  43. package/dist/forge-payload/.claude-plugin/plugin.json +1 -1
  44. package/dist/forge-payload/.schemas/enum-catalog.json +1 -1
  45. package/dist/forge-payload/.schemas/migrations.json +9 -0
  46. package/dist/forge-payload/integrity.json +3 -3
  47. package/dist/forge-payload/meta/fragments/tool-discipline.md +21 -2
  48. package/dist/forge-payload/meta/workflows/meta-bug-triage.md +210 -0
  49. package/dist/forge-payload/meta/workflows/meta-fix-bug.md +10 -28
  50. package/dist/forge-payload/schemas/enum-catalog.json +1 -1
  51. package/dist/forge-payload/schemas/structure-manifest.json +20 -1
  52. package/dist/forge-payload/tools/artifact.cjs +34 -5
  53. package/node_modules/@entelligentsia/forge-compress/dist/compressor.d.ts +6 -0
  54. package/node_modules/@entelligentsia/forge-compress/dist/compressor.js +137 -0
  55. package/node_modules/@entelligentsia/forge-compress/dist/entropy.d.ts +3 -0
  56. package/node_modules/@entelligentsia/forge-compress/dist/entropy.js +99 -0
  57. package/node_modules/@entelligentsia/forge-compress/dist/forge/entity.d.ts +8 -0
  58. package/node_modules/@entelligentsia/forge-compress/dist/forge/entity.js +149 -0
  59. package/node_modules/@entelligentsia/forge-compress/dist/forge/index.d.ts +7 -0
  60. package/node_modules/@entelligentsia/forge-compress/dist/forge/index.js +4 -0
  61. package/node_modules/@entelligentsia/forge-compress/dist/forge/markdown.d.ts +5 -0
  62. package/node_modules/@entelligentsia/forge-compress/dist/forge/markdown.js +92 -0
  63. package/node_modules/@entelligentsia/forge-compress/dist/forge/query.d.ts +7 -0
  64. package/node_modules/@entelligentsia/forge-compress/dist/forge/query.js +60 -0
  65. package/node_modules/@entelligentsia/forge-compress/dist/forge/validate.d.ts +1 -0
  66. package/node_modules/@entelligentsia/forge-compress/dist/forge/validate.js +82 -0
  67. package/node_modules/@entelligentsia/forge-compress/dist/index.d.ts +6 -0
  68. package/node_modules/@entelligentsia/forge-compress/dist/index.js +5 -0
  69. package/node_modules/@entelligentsia/forge-compress/dist/progressive.d.ts +1 -0
  70. package/node_modules/@entelligentsia/forge-compress/dist/progressive.js +108 -0
  71. package/node_modules/@entelligentsia/forge-compress/dist/strip.d.ts +4 -0
  72. package/node_modules/@entelligentsia/forge-compress/dist/strip.js +55 -0
  73. package/node_modules/@entelligentsia/forge-compress/dist/tokens.d.ts +2 -0
  74. package/node_modules/@entelligentsia/forge-compress/dist/tokens.js +17 -0
  75. package/node_modules/@entelligentsia/forge-compress/package.json +45 -0
  76. package/node_modules/@entelligentsia/forge-compress/src/__tests__/compress.test.ts +409 -0
  77. package/node_modules/@entelligentsia/forge-compress/src/compressor.ts +147 -0
  78. package/node_modules/@entelligentsia/forge-compress/src/entropy.ts +105 -0
  79. package/node_modules/@entelligentsia/forge-compress/src/forge/entity.ts +184 -0
  80. package/node_modules/@entelligentsia/forge-compress/src/forge/index.ts +10 -0
  81. package/node_modules/@entelligentsia/forge-compress/src/forge/markdown.ts +122 -0
  82. package/node_modules/@entelligentsia/forge-compress/src/forge/query.ts +105 -0
  83. package/node_modules/@entelligentsia/forge-compress/src/forge/validate.ts +86 -0
  84. package/node_modules/@entelligentsia/forge-compress/src/index.ts +22 -0
  85. package/node_modules/@entelligentsia/forge-compress/src/progressive.ts +123 -0
  86. package/node_modules/@entelligentsia/forge-compress/src/strip.ts +58 -0
  87. package/node_modules/@entelligentsia/forge-compress/src/tokens.ts +19 -0
  88. package/package.json +5 -10
@@ -56,6 +56,7 @@ import { resolveModelForPhase } from "./model-resolver.js";
56
56
  import { loadWorkflow } from "./parsers/workflow-loader.js";
57
57
  import { buildPhaseEvent, buildSummariesBlock, drainFrictionFile, emitEvent, findPredecessorIndex, formatLocalTime, isNonInteractive, judgementFromSummary, runPreflightGate, validateId, } from "./run-task.js";
58
58
  import { getSessionRegistry } from "./session-registry.js";
59
+ import { OrchestratorTranscriptWriter } from "./subagent/orchestrator-transcript.js";
59
60
  import { resolveToCanonicalId, resolveToolDir } from "./store-resolver.js";
60
61
  import { attachViewportObserver } from "./viewport-events.js";
61
62
  import { fmtPhaseSummary } from "./viewport-renderer.js";
@@ -67,26 +68,27 @@ import { fmtPhaseSummary } from "./viewport-renderer.js";
67
68
  // FORGE-S25-T16: readPersonaDirBug / readPipelineNamesBug extracted to
68
69
  // lib/catalog-helpers.ts and imported above with aliases (H-4, N-H-G).
69
70
  export const BUG_PHASES = [
70
- { role: "triage", workflowFile: "fix_bug", personaNoun: "bug-fixer", isReview: false, maxIterations: 1 },
71
- { role: "plan-fix", workflowFile: "fix_bug", personaNoun: "bug-fixer", isReview: false, maxIterations: 1 },
71
+ // FORGE-BUG-040: each phase points at its own phase-scoped subagent workflow.
72
+ // Previously triage/plan-fix/implement all pointed at fix_bug.md (the
73
+ // orchestrator-only body), which caused the triage subagent to execute
74
+ // the full lifecycle in a single invocation. plan-fix and implement reuse
75
+ // plan_task.md / implement_plan.md (bug-mode) per meta-fix-bug.md
76
+ // § Pipeline Phases — the bug-mode entity-kind detection is built into
77
+ // those workflows already.
78
+ { role: "triage", workflowFile: "triage", personaNoun: "bug-fixer", isReview: false, maxIterations: 1 },
79
+ { role: "plan-fix", workflowFile: "plan_task", personaNoun: "engineer", isReview: false, maxIterations: 1 },
72
80
  { role: "review-plan", workflowFile: "review_plan", personaNoun: "supervisor", isReview: true, maxIterations: 3 },
73
- { role: "implement", workflowFile: "fix_bug", personaNoun: "bug-fixer", isReview: false, maxIterations: 1 },
81
+ { role: "implement", workflowFile: "implement_plan", personaNoun: "engineer", isReview: false, maxIterations: 1 },
74
82
  { role: "review-code", workflowFile: "review_code", personaNoun: "supervisor", isReview: true, maxIterations: 3 },
75
83
  { role: "approve", workflowFile: "architect_approve", personaNoun: "architect", isReview: true, maxIterations: 3 },
76
84
  { role: "commit", workflowFile: "commit_task", personaNoun: "engineer", isReview: false, maxIterations: 1 },
77
85
  ];
78
- // Map phase.role canonical summary key written by base-pack workflows.
79
- // Phases mapped to null use update-status bug instead of set-bug-summary
80
- // for verdict tracking (Option B).
81
- export const BUG_SUMMARY_KEY_BY_ROLE = {
82
- triage: "triage",
83
- "plan-fix": "plan",
84
- "review-plan": "review_plan",
85
- implement: "implementation",
86
- "review-code": "code_review",
87
- approve: "approve", // read from bug.summaries.approve (set-bug-summary)
88
- commit: null, // commit transitions bug.status → fixed (terminal), no summaries entry
89
- };
86
+ // FORGE-BUG-040: BUG_SUMMARY_KEY_BY_ROLE lives in
87
+ // subagent/phase-summary-map.ts so the new phase-guard.ts can import
88
+ // it without dragging fix-bug.ts into a forge-tools import cycle.
89
+ // Re-exported here for backwards-compatibility with existing call sites.
90
+ export { BUG_SUMMARY_KEY_BY_ROLE } from "./subagent/phase-summary-map.js";
91
+ import { BUG_SUMMARY_KEY_BY_ROLE } from "./subagent/phase-summary-map.js";
90
92
  // Bug-event type tokens — explicit mapping per review finding #3.
91
93
  // Non-review phases always emit the pass token. Review phases select
92
94
  // pass or fail based on ec.judgement.verdict.
@@ -339,9 +341,11 @@ export function composeBugBody(subWorkflowMd, bugId, phaseRole, bugStatusBeforeP
339
341
  if (phaseRole === "commit" && bugStatusBeforePhase) {
340
342
  entityKindLines.push(`- Commit phase: after the git commit lands, transition bug.status from '${bugStatusBeforePhase}' to 'fixed'.`);
341
343
  }
342
- if (phaseRole === "triage") {
343
- entityKindLines.push('- Triage phase: in addition to writing TRIAGE.md and TRIAGE-SUMMARY.json, the summary MUST include a `route` field set to `"A"` or `"B"`.', " Path A (short-circuit): severity == minor AND single-file fix ≤ ~20 lines AND no schema/API/migration AND regression test obvious from repro.", " Path B (default): everything else. When in doubt, choose B.", " The orchestrator reads bug.summaries.triage.route to select the downstream phase list.");
344
- }
344
+ // FORGE-BUG-040: the triage-phase hint block previously prepended here
345
+ // compensated for the orchestrator-only fix_bug.md being delivered to
346
+ // the triage subagent. With the new phase-scoped triage.md sub-workflow,
347
+ // the route-field contract and Path A/B criteria are documented natively
348
+ // in the workflow body — no compose-time injection required.
345
349
  const parts = [
346
350
  `Read the workflow below and follow it. Bug ID: ${bugId}.`,
347
351
  "",
@@ -474,476 +478,301 @@ export async function runBugPipeline(opts) {
474
478
  };
475
479
  }
476
480
  }
477
- while (currentPhaseIndex < BUG_PHASES.length) {
478
- // ── Between-phase cancellation gate ────────────────────────────
479
- if (opts.signal?.aborted) {
480
- ctx.ui.notify(`⊘ forge:fix-bug ${bugId} cancelled by user.`, "info");
481
- registry.completePhase(bugId, BUG_PHASES[currentPhaseIndex]?.role ?? "unknown", "cancelled");
482
- registry.confirmCancelled(bugId);
483
- // ADR-S21-01: preserve state file so cancelled runs are resumable
484
- writeBugState(cwd, {
485
- bugId,
486
- phaseIndex: currentPhaseIndex,
487
- iterationCounts,
488
- halted: false,
489
- status: "cancelled",
490
- lastError: undefined,
491
- savedAt: new Date().toISOString(),
492
- });
493
- return { status: "cancelled", lastPhaseIndex: currentPhaseIndex, iterationCounts };
494
- }
495
- const phase = BUG_PHASES[currentPhaseIndex];
496
- if (!phase) {
497
- ctx.ui.notify(`× forge:fix-bug invalid phase index ${currentPhaseIndex}`, "error");
498
- return {
499
- status: "failed",
500
- lastPhaseIndex: currentPhaseIndex,
501
- iterationCounts,
502
- lastError: `invalid phase index ${currentPhaseIndex}`,
503
- };
504
- }
505
- ctx.ui.setStatus?.(STATUS_KEY, `fix-bug ${bugId}: phase ${currentPhaseIndex + 1}/${BUG_PHASES.length} (${phase.role})`);
506
- ctx.ui.notify(`→ ${bugId}: ${phase.role} (phase ${currentPhaseIndex + 1}/${BUG_PHASES.length})`, "info");
507
- const subWorkflowPath = path.join(cwd, ".forge", "workflows", `${phase.workflowFile}.md`);
508
- // ── Read sub-workflow ─────────────────────────────────────────
509
- let subWorkflowMd;
510
- let subWorkflowAudience = "any";
511
- try {
512
- const loaded = loadWorkflow(subWorkflowPath);
513
- subWorkflowMd = loaded.rawMarkdown;
514
- subWorkflowAudience = loaded.audience;
515
- }
516
- catch (err) {
517
- const e = err;
518
- ctx.ui.notify(`× forge:fix-bug — failed to read sub-workflow for ${phase.role}: ${e.message ?? "unknown"}`, "error");
519
- writeBugState(cwd, {
520
- bugId,
521
- phaseIndex: currentPhaseIndex,
522
- iterationCounts,
523
- halted: true,
524
- lastError: `sub-workflow read failed: ${e.message ?? "unknown"}`,
525
- savedAt: new Date().toISOString(),
526
- });
527
- return {
528
- status: "failed",
529
- lastPhaseIndex: currentPhaseIndex,
530
- iterationCounts,
531
- lastError: `sub-workflow read failed: ${e.message ?? "unknown"}`,
532
- };
533
- }
534
- // ── 6a. Phase skip (state-aware, defense-in-depth) ─────────────
535
- // Belt-and-suspenders alongside the explicit summaries.triage.route
536
- // branch (handled in section 6c below). Some subagents in some
537
- // runtimes still go end-to-end during triage instead of just triaging
538
- // — rather than roll back the work they did, skip non-review phases
539
- // whose output is already reflected in the bug status. Review phases
540
- // are never skipped — they are quality gates that must always run.
541
- //
542
- // Post-v0.44.0: terminal status is `fixed` only. `approved` and
543
- // `verified` are no longer valid bug status values; references
544
- // removed.
545
- const PHASE_SKIP_STATES = {
546
- "plan-fix": new Set(["fixed"]),
547
- implement: new Set(["fixed"]),
548
- commit: new Set(["fixed"]), // commit writes the terminal status; skip if already there
549
- };
550
- const bugNow = readBugRecord(bugId, storeCli, cwd);
551
- const skipStates = PHASE_SKIP_STATES[phase.role];
552
- if (skipStates && bugNow?.status && skipStates.has(bugNow.status) && !phase.isReview) {
553
- ctx.ui.notify(`⊘ forge:fix-bug — skipping ${phase.role}: bug ${bugId} is already '${bugNow.status}' (work already done).`, "info");
554
- // Write a synthetic "approved" summary so downstream `after` predecessor
555
- // verdict checks find a verdict and don't block review phases.
556
- const summaryKey = BUG_SUMMARY_KEY_BY_ROLE[phase.role];
557
- if (summaryKey) {
558
- const synthSummary = {
559
- objective: `Phase ${phase.role} skipped — bug already ${bugNow.status}`,
560
- findings: ["Subagent completed fix during triage (Path A); phase output implicitly satisfied."],
561
- // Non-review phases should have verdict "n/a" — the phase
562
- // didn't produce a gate verdict. This matches the `after
563
- // <phase> = n/a` preflight gate contract. Review phases
564
- // use "approved" since they are gate phases.
565
- verdict: phase.isReview ? "approved" : "n/a",
566
- written_at: new Date().toISOString(),
567
- };
568
- const synthFile = path.join(cwd, ".forge", "cache", `synthetic-summary-${bugId}-${summaryKey}.json`);
569
- fs.writeFileSync(synthFile, JSON.stringify(synthSummary, null, 2), "utf8");
570
- const synthResult = spawnSync("node", [storeCli, "set-bug-summary", bugId, summaryKey, synthFile], {
571
- cwd,
572
- encoding: "utf8",
573
- });
574
- if (synthResult.status !== 0) {
575
- ctx.ui.notify(`⚠ forge:fix-bug — synthetic summary write failed for ${phase.role}: ${String(synthResult.stderr).trim()}`, "warning");
576
- }
577
- try {
578
- fs.unlinkSync(synthFile);
579
- }
580
- catch {
581
- /* non-fatal */
582
- }
583
- }
584
- currentPhaseIndex++;
585
- continue;
586
- }
587
- // ── 6b. Preflight gate ────────────────────────────────────────
588
- // Skip preflight gate for triage phase of new bugs (PENDING- placeholder)
589
- // because the bug record doesn't exist yet — gates referencing bug fields
590
- // would always fail.
591
- //
592
- // Also skip for review phases when the bug is already in a terminal
593
- // state ("fixed"). Path A bugs get fixed during triage, then the
594
- // preflight gate's `forbid bug.status == fixed` and `after implement
595
- // = n/a` checks block review-code/review-plan even though we
596
- // deliberately want to run those reviews. The review subagent handles
597
- // the already-fixed scenario internally.
598
- const pendingBugId = bugId.startsWith("PENDING-");
599
- const bugAlreadyFixed = bugNow?.status === "fixed" && phase.isReview;
600
- if (!pendingBugId && !bugAlreadyFixed && fs.existsSync(preflightGate)) {
601
- const preflightResult = runPreflightGate(preflightGate, phase.role, bugId, cwd, "bug");
602
- if (preflightResult === "halt") {
603
- ctx.ui.notify(`× forge:fix-bug — preflight gate failed for phase ${phase.role} (exit 1); halting.`, "error");
481
+ // ── Orchestrator transcript ──────────────────────────────────────────
482
+ // One JSONL file per pipeline run, ISO-prefixed in its filename so
483
+ // review-loop iterations (plan → review → plan → review) preserve
484
+ // their own logs instead of overwriting each other. Captures every
485
+ // ctx.ui.notify line plus structured phase-boundary events.
486
+ const orchTranscript = new OrchestratorTranscriptWriter({
487
+ cwd,
488
+ entityKind: "bug",
489
+ entityId: bugId,
490
+ });
491
+ const __origNotify = ctx.ui.notify.bind(ctx.ui);
492
+ ctx.ui.notify = ((msg, level) => {
493
+ __origNotify(msg, level);
494
+ orchTranscript.record({
495
+ kind: "notify",
496
+ ts: new Date().toISOString(),
497
+ level: (level ?? "info"),
498
+ message: typeof msg === "string" ? msg : String(msg),
499
+ });
500
+ });
501
+ const pipelineStartMs = Date.now();
502
+ try {
503
+ while (currentPhaseIndex < BUG_PHASES.length) {
504
+ // ── Between-phase cancellation gate ────────────────────────────
505
+ if (opts.signal?.aborted) {
506
+ ctx.ui.notify(`⊘ forge:fix-bug ${bugId} cancelled by user.`, "info");
507
+ registry.completePhase(bugId, BUG_PHASES[currentPhaseIndex]?.role ?? "unknown", "cancelled");
508
+ registry.confirmCancelled(bugId);
509
+ // ADR-S21-01: preserve state file so cancelled runs are resumable
604
510
  writeBugState(cwd, {
605
511
  bugId,
606
512
  phaseIndex: currentPhaseIndex,
607
513
  iterationCounts,
608
- halted: true,
609
- lastError: `preflight gate exit 1 for ${phase.role}`,
514
+ halted: false,
515
+ status: "cancelled",
516
+ lastError: undefined,
610
517
  savedAt: new Date().toISOString(),
611
518
  });
519
+ return { status: "cancelled", lastPhaseIndex: currentPhaseIndex, iterationCounts };
520
+ }
521
+ const phase = BUG_PHASES[currentPhaseIndex];
522
+ if (!phase) {
523
+ ctx.ui.notify(`× forge:fix-bug — invalid phase index ${currentPhaseIndex}`, "error");
612
524
  return {
613
- status: "halted",
525
+ status: "failed",
614
526
  lastPhaseIndex: currentPhaseIndex,
615
527
  iterationCounts,
616
- lastError: `preflight gate exit 1 for ${phase.role}`,
528
+ lastError: `invalid phase index ${currentPhaseIndex}`,
617
529
  };
618
530
  }
619
- if (preflightResult === "escalate") {
620
- ctx.ui.notify( forge:fix-bug — preflight gate escalated for phase ${phase.role} (exit 2); manual intervention required.`, "error");
531
+ ctx.ui.setStatus?.(STATUS_KEY, `fix-bug ${bugId}: phase ${currentPhaseIndex + 1}/${BUG_PHASES.length} (${phase.role})`);
532
+ ctx.ui.notify(`→ ${bugId}: ${phase.role} (phase ${currentPhaseIndex + 1}/${BUG_PHASES.length})`, "info");
533
+ orchTranscript.record({
534
+ kind: "phase-start",
535
+ ts: new Date().toISOString(),
536
+ phase: phase.role,
537
+ phaseIndex: currentPhaseIndex,
538
+ phaseCount: BUG_PHASES.length,
539
+ attempt: (iterationCounts[phase.role] ?? 0) + 1,
540
+ workflowFile: phase.workflowFile,
541
+ persona: phase.personaNoun,
542
+ });
543
+ const subWorkflowPath = path.join(cwd, ".forge", "workflows", `${phase.workflowFile}.md`);
544
+ // ── Read sub-workflow ─────────────────────────────────────────
545
+ let subWorkflowMd;
546
+ let subWorkflowAudience = "any";
547
+ try {
548
+ const loaded = loadWorkflow(subWorkflowPath);
549
+ subWorkflowMd = loaded.rawMarkdown;
550
+ subWorkflowAudience = loaded.audience;
551
+ }
552
+ catch (err) {
553
+ const e = err;
554
+ ctx.ui.notify(`× forge:fix-bug — failed to read sub-workflow for ${phase.role}: ${e.message ?? "unknown"}`, "error");
621
555
  writeBugState(cwd, {
622
556
  bugId,
623
557
  phaseIndex: currentPhaseIndex,
624
558
  iterationCounts,
625
559
  halted: true,
626
- lastError: `preflight gate exit 2 (escalate) for ${phase.role}`,
560
+ lastError: `sub-workflow read failed: ${e.message ?? "unknown"}`,
627
561
  savedAt: new Date().toISOString(),
628
562
  });
629
- return {
630
- status: "escalated",
631
- lastPhaseIndex: currentPhaseIndex,
632
- iterationCounts,
633
- lastError: `preflight gate exit 2 (escalate) for ${phase.role}`,
634
- };
635
- }
636
- }
637
- // ── 6. Materialization-marker check ───────────────────────────
638
- // N-H-E: Skip for the monolithic fix_bug.md — it is the orchestrator prose
639
- // algorithm, not a sub-workflow that subagents run tool calls against.
640
- // Triage/plan-fix/implement phases reference fix_bug.md for their
641
- // prose body but the actual tool-use discipline (Store-Write Verification,
642
- // forge_store) lives in the sub-workflows (review_plan.md, commit_task.md,
643
- // etc.) which get checked when their own phases run.
644
- if (phase.workflowFile !== "fix_bug") {
645
- const markerCheck = checkMaterialization(subWorkflowPath, subWorkflowMd);
646
- if (!markerCheck.ok) {
647
- for (const marker of markerCheck.missing) {
648
- ctx.ui.notify(`× workflow regression: ${marker} not found in ${subWorkflowPath}`, "error");
649
- }
650
563
  return {
651
564
  status: "failed",
652
565
  lastPhaseIndex: currentPhaseIndex,
653
566
  iterationCounts,
654
- lastError: `materialization markers missing: ${markerCheck.missing.join(", ")}`,
567
+ lastError: `sub-workflow read failed: ${e.message ?? "unknown"}`,
655
568
  };
656
569
  }
657
- }
658
- // ── 5. Audience check ─────────────────────────────────────────
659
- // fix_bug.md is orchestrator-only but the subagent doesn't "run" it as a
660
- // workflow the orchestrator reads its prose and composes the body text.
661
- // Skip the audience gate for the monolithic fix_bug.md; only check the
662
- // true sub-workflows (review_plan, review_code, architect_approve, commit_task)
663
- // which the subagent does run directly.
664
- const audienceOk = phase.workflowFile === "fix_bug" ||
665
- CallerContextStore.asSubagent(() => assertAudience({ workflowName: phase.workflowFile, audience: subWorkflowAudience }, ctx));
666
- if (!audienceOk) {
667
- writeBugState(cwd, {
668
- bugId,
669
- phaseIndex: currentPhaseIndex,
670
- iterationCounts,
671
- halted: true,
672
- lastError: `audience check failed for ${phase.workflowFile}`,
673
- savedAt: new Date().toISOString(),
674
- });
675
- return {
676
- status: "failed",
677
- lastPhaseIndex: currentPhaseIndex,
678
- iterationCounts,
679
- lastError: `audience check failed for ${phase.workflowFile}`,
570
+ // ── 6a. Phase skip (state-aware, defense-in-depth) ─────────────
571
+ // Belt-and-suspenders alongside the explicit summaries.triage.route
572
+ // branch (handled in section 6c below). Some subagents in some
573
+ // runtimes still go end-to-end during triage instead of just triaging
574
+ // rather than roll back the work they did, skip non-review phases
575
+ // whose output is already reflected in the bug status. Review phases
576
+ // are never skipped they are quality gates that must always run.
577
+ //
578
+ // Post-v0.44.0: terminal status is `fixed` only. `approved` and
579
+ // `verified` are no longer valid bug status values; references
580
+ // removed.
581
+ const PHASE_SKIP_STATES = {
582
+ "plan-fix": new Set(["fixed"]),
583
+ implement: new Set(["fixed"]),
584
+ commit: new Set(["fixed"]), // commit writes the terminal status; skip if already there
680
585
  };
681
- }
682
- // ── Persona load ──────────────────────────────────────────────
683
- let persona;
684
- try {
685
- persona = loadForgePersona(phase.personaNoun, cwd);
686
- }
687
- catch (err) {
688
- const e = err;
689
- ctx.ui.notify(`× forge:fix-bug persona '${phase.personaNoun}' not found for phase ${phase.role}: ${e.message ?? "unknown"}. ` +
690
- "Run /forge:regenerate to materialize persona files.", "error");
691
- writeBugState(cwd, {
692
- bugId,
693
- phaseIndex: currentPhaseIndex,
694
- iterationCounts,
695
- halted: true,
696
- lastError: `persona load failed: ${e.message ?? "unknown"}`,
697
- savedAt: new Date().toISOString(),
698
- });
699
- return {
700
- status: "failed",
701
- lastPhaseIndex: currentPhaseIndex,
702
- iterationCounts,
703
- lastError: `persona load failed: ${e.message ?? "unknown"}`,
704
- };
705
- }
706
- // ── Read bug record for current status ────────────────────────
707
- // Skip for PENDING bugIds (bug doesn't exist yet).
708
- const bugRecordBefore = pendingBugId ? null : readBugRecord(bugId, storeCli, cwd);
709
- const bugStatusBeforePhase = bugRecordBefore?.status;
710
- // ── 4. Dispatch via runForgeSubagent (IL10) ───────────────────
711
- // NEVER sendKickoff here — that would reproduce issue #30.
712
- // Carry forward prior phase summaries (forge-cli#19).
713
- const bugSummariesBlock = currentPhaseIndex > 0
714
- ? buildSummariesBlock(bugRecordBefore?.summaries) || undefined
715
- : undefined;
716
- let bugBody = composeBugBody(subWorkflowMd, bugId, phase.role, bugStatusBeforePhase, bugSummariesBlock);
717
- // For new bugs in triage, prepend the original free-form text so the
718
- // subagent knows the user-provided bug description to triage.
719
- // The bug record already exists (pre-created with status "reported"),
720
- // so the subagent should update it, not create a new one.
721
- if (phase.role === "triage" && isNewBug && originalArg) {
722
- bugBody = `Bug description: ${originalArg}\n\n---\n\n${bugBody}`;
723
- }
724
- // Phase-scoped progress counters
725
- const phaseStart = Date.now();
726
- // Track tool_execution_end events for bugId capture (Findings #1, #2).
727
- const toolExecutionEvents = [];
728
- // Stabilization debug log
729
- // Skip for PENDING bugIds — create after real bugId is captured.
730
- // Disable entirely with FORGE_DEBUG_LOG=0.
731
- const debugLogDisabled = process.env.FORGE_DEBUG_LOG === "0";
732
- let debugLogPath = null;
733
- let writeDebug = () => { };
734
- if (!pendingBugId && !debugLogDisabled) {
735
- debugLogPath = path.join(cwd, ".forge", "cache", `fix-bug-debug-${bugId}.jsonl`);
736
- writeDebug = (rec) => {
737
- try {
738
- fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
739
- // Cap at 10 MB: truncate head when size exceeds the cap.
586
+ const bugNow = readBugRecord(bugId, storeCli, cwd);
587
+ const skipStates = PHASE_SKIP_STATES[phase.role];
588
+ if (skipStates && bugNow?.status && skipStates.has(bugNow.status) && !phase.isReview) {
589
+ ctx.ui.notify(`⊘ forge:fix-bug — skipping ${phase.role}: bug ${bugId} is already '${bugNow.status}' (work already done).`, "info");
590
+ // Write a synthetic "approved" summary so downstream `after` predecessor
591
+ // verdict checks find a verdict and don't block review phases.
592
+ const summaryKey = BUG_SUMMARY_KEY_BY_ROLE[phase.role];
593
+ if (summaryKey) {
594
+ const synthSummary = {
595
+ objective: `Phase ${phase.role} skipped — bug already ${bugNow.status}`,
596
+ findings: ["Subagent completed fix during triage (Path A); phase output implicitly satisfied."],
597
+ // Non-review phases should have verdict "n/a" — the phase
598
+ // didn't produce a gate verdict. This matches the `after
599
+ // <phase> = n/a` preflight gate contract. Review phases
600
+ // use "approved" since they are gate phases.
601
+ verdict: phase.isReview ? "approved" : "n/a",
602
+ written_at: new Date().toISOString(),
603
+ };
604
+ const synthFile = path.join(cwd, ".forge", "cache", `synthetic-summary-${bugId}-${summaryKey}.json`);
605
+ fs.writeFileSync(synthFile, JSON.stringify(synthSummary, null, 2), "utf8");
606
+ const synthResult = spawnSync("node", [storeCli, "set-bug-summary", bugId, summaryKey, synthFile], {
607
+ cwd,
608
+ encoding: "utf8",
609
+ });
610
+ if (synthResult.status !== 0) {
611
+ ctx.ui.notify(`⚠ forge:fix-bug synthetic summary write failed for ${phase.role}: ${String(synthResult.stderr).trim()}`, "warning");
612
+ }
740
613
  try {
741
- const st = fs.statSync(debugLogPath);
742
- if (st.size > 10 * 1024 * 1024) {
743
- const all = fs.readFileSync(debugLogPath, "utf8");
744
- const lines = all.split("\n");
745
- // Keep last 80% of lines
746
- const keep = Math.floor(lines.length * 0.8);
747
- fs.writeFileSync(debugLogPath, lines.slice(-keep).join("\n"), "utf8");
748
- }
614
+ fs.unlinkSync(synthFile);
749
615
  }
750
616
  catch {
751
- /* file may not exist yet */
617
+ /* non-fatal */
752
618
  }
753
- fs.appendFileSync(debugLogPath, `${JSON.stringify({ ts: new Date().toISOString(), phase: phase.role, ...rec })}\n`, "utf8");
754
619
  }
755
- catch {
756
- // non-fatal; debug log is best-effort
757
- }
758
- };
759
- }
760
- writeDebug({ kind: "phase_start", phaseIndex: currentPhaseIndex });
761
- registry.startPhase(bugId, phase.role, currentPhaseIndex);
762
- const refreshStatus = () => {
763
- if (process.env.FORGE_VERBOSE !== "1")
764
- return;
765
- const elapsed = Math.floor((Date.now() - phaseStart) / 1000);
766
- const tail = observer.state.lastTool ? ` · ${observer.state.lastTool}` : "";
767
- ctx.ui.setStatus?.(STATUS_KEY, `fix-bug ${bugId}: ${phase.role} · t${observer.state.turn} · tools ${observer.state.toolCount}${observer.state.errCount ? ` · err ${observer.state.errCount}` : ""} · ${elapsed}s${tail}`);
768
- };
769
- const observer = attachViewportObserver({
770
- registry,
771
- sessionId: bugId,
772
- phaseRole: phase.role,
773
- beginHeader: `─── phase ${phase.role} begin ───`,
774
- writeDebug,
775
- notify: (msg, level) => ctx.ui.notify(msg, level),
776
- setStatusVerbose: process.env.FORGE_VERBOSE === "1" ? (k, v) => ctx.ui.setStatus?.(k, v) : undefined,
777
- verboseKeys: { messageKey: `${STATUS_KEY}:message` },
778
- afterEach: refreshStatus,
779
- });
780
- // Wrap the observer's onEvent to also capture tool_execution_end events
781
- // for bugId capture downstream (findings #1, #2), plus the first turn_end
782
- // per phase (IL10 visibility — stream-observed model id).
783
- let modelObservedLogged = false;
784
- const onSubagentEvent = (event) => {
785
- if (event?.type === "tool_execution_end") {
786
- toolExecutionEvents.push({ toolName: event.toolName, result: event.result });
787
- }
788
- if (!modelObservedLogged && event?.type === "turn_end" && event.message?.model) {
789
- modelObservedLogged = true;
790
- writeDebug({
791
- kind: "model_observed",
792
- provider: event.message.provider ?? null,
793
- model: event.message.model,
794
- });
620
+ currentPhaseIndex++;
621
+ continue;
795
622
  }
796
- observer.onEvent(event);
797
- };
798
- // Per-phase model resolution. When config is absent or cascade bottoms
799
- // out, resolves to inherit (model: undefined) — setModel is skipped and
800
- // pi's current model is used. IL10 still holds: result.model below is
801
- // the stream-observed runtime model, not whatever we requested here.
802
- const modelResolution = resolveModelForPhase("fix-bug", phase.role, phase.personaNoun, modelRoutingConfig);
803
- writeDebug({
804
- kind: "requested_model",
805
- requested: modelResolution.model ?? null,
806
- source: modelResolution.source,
807
- persona: phase.personaNoun,
808
- });
809
- let result;
810
- try {
811
- result = await runForgeSubagent({
812
- persona,
813
- task: bugBody,
814
- cwd,
815
- exportTag: `${bugId}__${phase.role}`,
816
- // Sprint-scoped if the bug is attached to one, else bug-scoped.
817
- // Keeps every phase of this bug-fix pipeline in a single cache
818
- // namespace so the system-prompt + persona prefix stays warm
819
- // across the ~10-minute phases.
820
- cacheSessionId: typeof bugRecordBefore?.sprintId === "string"
821
- ? `forge:${bugRecordBefore.sprintId}`
822
- : `forge:bug:${bugId}`,
823
- onEvent: onSubagentEvent,
824
- requestedModel: modelResolution.model,
825
- modelRegistry: ctx.modelRegistry,
826
- signal: opts.signal,
827
- customTools: opts.forgeToolDefs ? getSubagentTools(opts.forgeToolDefs, persona.name) : undefined,
828
- });
829
- }
830
- catch (err) {
831
- const e = err;
832
- ctx.ui.notify(`× forge:fix-bug — runForgeSubagent threw for phase ${phase.role}: ${e.message ?? "unknown"}`, "error");
833
- writeBugState(cwd, {
834
- bugId,
835
- phaseIndex: currentPhaseIndex,
836
- iterationCounts,
837
- halted: true,
838
- lastError: `runForgeSubagent threw: ${e.message ?? "unknown"}`,
839
- savedAt: new Date().toISOString(),
840
- });
841
- return {
842
- status: "failed",
843
- lastPhaseIndex: currentPhaseIndex,
844
- iterationCounts,
845
- lastError: `runForgeSubagent threw: ${e.message ?? "unknown"}`,
846
- };
847
- }
848
- // ── Post-subagent abort detection ─────────────────────────────────
849
- if (result.stopReason === "aborted" || opts.signal?.aborted) {
850
- ctx.ui.notify(`⊘ forge:fix-bug — ${bugId} phase ${phase.role} cancelled.`, "info");
851
- registry.completePhase(bugId, phase.role, "cancelled");
852
- registry.confirmCancelled(bugId);
853
- // ADR-S21-01: preserve state file so cancelled runs are resumable
854
- writeBugState(cwd, {
855
- bugId,
856
- phaseIndex: currentPhaseIndex,
857
- iterationCounts,
858
- halted: false,
859
- status: "cancelled",
860
- lastError: undefined,
861
- savedAt: new Date().toISOString(),
862
- });
863
- return { status: "cancelled", lastPhaseIndex: currentPhaseIndex, iterationCounts };
864
- }
865
- // ── Halt-on-failure ───────────────────────────────────────────
866
- if (result.exitCode !== 0) {
867
- ctx.ui.notify(`× forge:fix-bug — phase ${phase.role} failed (exit ${result.exitCode})` +
868
- (result.errorMessage ? `: ${result.errorMessage}` : "") +
869
- (result.stopReason ? ` [${result.stopReason}]` : ""), "error");
870
- writeBugState(cwd, {
871
- bugId,
872
- phaseIndex: currentPhaseIndex,
873
- iterationCounts,
874
- halted: true,
875
- lastError: result.errorMessage ?? result.stopReason ?? "subagent exit non-zero",
876
- savedAt: new Date().toISOString(),
877
- });
878
- return {
879
- status: "failed",
880
- lastPhaseIndex: currentPhaseIndex,
881
- iterationCounts,
882
- lastError: result.errorMessage ?? result.stopReason ?? "subagent exit non-zero",
883
- };
884
- }
885
- // Capture model/provider from subagent result.
886
- if (result.model)
887
- lastModel = result.model;
888
- if (result.provider)
889
- lastProvider = result.provider;
890
- // ── BugId capture after triage phase (Finding #1, #2) ──────────
891
- // For new bugs, the triage subagent creates the bug record via store-cli.
892
- // We capture the bugId by scanning tool_execution_end events.
893
- if (phase.role === "triage" && isNewBug && bugId.startsWith("PENDING-")) {
894
- const capturedBugId = extractBugIdFromEvents(toolExecutionEvents);
895
- if (capturedBugId) {
896
- ctx.ui.notify(`forge:fix-bug — captured bug ID: ${capturedBugId}`, "info");
897
- bugId = capturedBugId;
623
+ // ── 6b. Preflight gate ────────────────────────────────────────
624
+ // Skip preflight gate for triage phase of new bugs (PENDING- placeholder)
625
+ // because the bug record doesn't exist yet gates referencing bug fields
626
+ // would always fail.
627
+ //
628
+ // Also skip for review phases when the bug is already in a terminal
629
+ // state ("fixed"). Path A bugs get fixed during triage, then the
630
+ // preflight gate's `forbid bug.status == fixed` and `after implement
631
+ // = n/a` checks block review-code/review-plan even though we
632
+ // deliberately want to run those reviews. The review subagent handles
633
+ // the already-fixed scenario internally.
634
+ const pendingBugId = bugId.startsWith("PENDING-");
635
+ const bugAlreadyFixed = bugNow?.status === "fixed" && phase.isReview;
636
+ if (!pendingBugId && !bugAlreadyFixed && fs.existsSync(preflightGate)) {
637
+ const preflightResult = runPreflightGate(preflightGate, phase.role, bugId, cwd, "bug");
638
+ if (preflightResult === "halt") {
639
+ ctx.ui.notify(`× forge:fix-bug — preflight gate failed for phase ${phase.role} (exit 1); halting.`, "error");
640
+ writeBugState(cwd, {
641
+ bugId,
642
+ phaseIndex: currentPhaseIndex,
643
+ iterationCounts,
644
+ halted: true,
645
+ lastError: `preflight gate exit 1 for ${phase.role}`,
646
+ savedAt: new Date().toISOString(),
647
+ });
648
+ return {
649
+ status: "halted",
650
+ lastPhaseIndex: currentPhaseIndex,
651
+ iterationCounts,
652
+ lastError: `preflight gate exit 1 for ${phase.role}`,
653
+ };
654
+ }
655
+ if (preflightResult === "escalate") {
656
+ ctx.ui.notify(`× forge:fix-bug — preflight gate escalated for phase ${phase.role} (exit 2); manual intervention required.`, "error");
657
+ writeBugState(cwd, {
658
+ bugId,
659
+ phaseIndex: currentPhaseIndex,
660
+ iterationCounts,
661
+ halted: true,
662
+ lastError: `preflight gate exit 2 (escalate) for ${phase.role}`,
663
+ savedAt: new Date().toISOString(),
664
+ });
665
+ return {
666
+ status: "escalated",
667
+ lastPhaseIndex: currentPhaseIndex,
668
+ iterationCounts,
669
+ lastError: `preflight gate exit 2 (escalate) for ${phase.role}`,
670
+ };
671
+ }
898
672
  }
899
- else {
900
- // Fallback: list bugs and find the most recent one created after pipeline start.
901
- const listResult = spawnSync("node", [storeCli, "list", "bug", "--json"], { cwd, encoding: "utf8" });
902
- if (listResult.status === 0 && listResult.stdout) {
903
- try {
904
- const bugs = JSON.parse(listResult.stdout);
905
- if (Array.isArray(bugs)) {
906
- // Find most recent bug whose reportedAt is after the pipeline start
907
- const pipelineStartIso = new Date(parseInt(bugId.replace("PENDING-", ""))).toISOString();
908
- const recent = bugs
909
- .filter((b) => b.reportedAt && b.reportedAt >= pipelineStartIso)
910
- .sort((a, b) => String(b.reportedAt).localeCompare(String(a.reportedAt)))[0];
911
- if (recent &&
912
- recent.bugId &&
913
- typeof recent.bugId === "string" &&
914
- recent.bugId.startsWith("FORGE-BUG-")) {
915
- bugId = recent.bugId;
916
- ctx.ui.notify(`forge:fix-bug — captured bug ID via store fallback: ${bugId}`, "info");
917
- }
918
- }
919
- }
920
- catch {
921
- /* parse failure — fall through to assertion */
673
+ // ── 6. Materialization-marker check ───────────────────────────
674
+ // FORGE-BUG-040: every BUG phase is now a true `audience: subagent`
675
+ // sub-workflow triage / plan-fix / implement no longer alias to
676
+ // fix_bug.md. The marker check is therefore unconditional; a missing
677
+ // marker is a hard failure on the first dispatch.
678
+ {
679
+ const markerCheck = checkMaterialization(subWorkflowPath, subWorkflowMd);
680
+ if (!markerCheck.ok) {
681
+ for (const marker of markerCheck.missing) {
682
+ ctx.ui.notify(`× workflow regression: ${marker} not found in ${subWorkflowPath}`, "error");
922
683
  }
684
+ return {
685
+ status: "failed",
686
+ lastPhaseIndex: currentPhaseIndex,
687
+ iterationCounts,
688
+ lastError: `materialization markers missing: ${markerCheck.missing.join(", ")}`,
689
+ };
923
690
  }
924
691
  }
925
- // Defensive guard: if bugId is still PENDING after triage, pipeline cannot proceed.
926
- if (bugId.startsWith("PENDING-")) {
927
- ctx.ui.notify("× forge:fix-bug failed to capture real bug ID after triage. Cannot proceed with PENDING placeholder.", "error");
692
+ // ── 5. Audience check ─────────────────────────────────────────
693
+ // FORGE-BUG-040: every BUG phase is a true `audience: subagent`
694
+ // workflow now; the previous `fix_bug.md` audience-bypass is gone.
695
+ const audienceOk = CallerContextStore.asSubagent(phase.role, () => assertAudience({ workflowName: phase.workflowFile, audience: subWorkflowAudience }, ctx));
696
+ if (!audienceOk) {
697
+ writeBugState(cwd, {
698
+ bugId,
699
+ phaseIndex: currentPhaseIndex,
700
+ iterationCounts,
701
+ halted: true,
702
+ lastError: `audience check failed for ${phase.workflowFile}`,
703
+ savedAt: new Date().toISOString(),
704
+ });
705
+ return {
706
+ status: "failed",
707
+ lastPhaseIndex: currentPhaseIndex,
708
+ iterationCounts,
709
+ lastError: `audience check failed for ${phase.workflowFile}`,
710
+ };
711
+ }
712
+ // ── Persona load ──────────────────────────────────────────────
713
+ let persona;
714
+ try {
715
+ persona = loadForgePersona(phase.personaNoun, cwd);
716
+ }
717
+ catch (err) {
718
+ const e = err;
719
+ ctx.ui.notify(`× forge:fix-bug — persona '${phase.personaNoun}' not found for phase ${phase.role}: ${e.message ?? "unknown"}. ` +
720
+ "Run /forge:regenerate to materialize persona files.", "error");
721
+ writeBugState(cwd, {
722
+ bugId,
723
+ phaseIndex: currentPhaseIndex,
724
+ iterationCounts,
725
+ halted: true,
726
+ lastError: `persona load failed: ${e.message ?? "unknown"}`,
727
+ savedAt: new Date().toISOString(),
728
+ });
928
729
  return {
929
730
  status: "failed",
930
731
  lastPhaseIndex: currentPhaseIndex,
931
732
  iterationCounts,
932
- lastError: "bugId still PENDING after triage",
733
+ lastError: `persona load failed: ${e.message ?? "unknown"}`,
933
734
  };
934
735
  }
935
- // Re-initialize debug log now that real bugId is available.
936
- if (!debugLogDisabled) {
736
+ // ── Read bug record for current status ────────────────────────
737
+ // Skip for PENDING bugIds (bug doesn't exist yet).
738
+ const bugRecordBefore = pendingBugId ? null : readBugRecord(bugId, storeCli, cwd);
739
+ const bugStatusBeforePhase = bugRecordBefore?.status;
740
+ // ── 4. Dispatch via runForgeSubagent (IL10) ───────────────────
741
+ // NEVER sendKickoff here — that would reproduce issue #30.
742
+ // Carry forward prior phase summaries (forge-cli#19).
743
+ const bugSummariesBlock = currentPhaseIndex > 0
744
+ ? buildSummariesBlock(bugRecordBefore?.summaries) || undefined
745
+ : undefined;
746
+ let bugBody = composeBugBody(subWorkflowMd, bugId, phase.role, bugStatusBeforePhase, bugSummariesBlock);
747
+ // For new bugs in triage, prepend the original free-form text so the
748
+ // subagent knows the user-provided bug description to triage.
749
+ // The bug record already exists (pre-created with status "reported"),
750
+ // so the subagent should update it, not create a new one.
751
+ if (phase.role === "triage" && isNewBug && originalArg) {
752
+ bugBody = `Bug description: ${originalArg}\n\n---\n\n${bugBody}`;
753
+ }
754
+ // Phase-scoped progress counters
755
+ const phaseStart = Date.now();
756
+ // Track tool_execution_end events for bugId capture (Findings #1, #2).
757
+ const toolExecutionEvents = [];
758
+ // Stabilization debug log
759
+ // Skip for PENDING bugIds — create after real bugId is captured.
760
+ // Disable entirely with FORGE_DEBUG_LOG=0.
761
+ const debugLogDisabled = process.env.FORGE_DEBUG_LOG === "0";
762
+ let debugLogPath = null;
763
+ let writeDebug = () => { };
764
+ if (!pendingBugId && !debugLogDisabled) {
937
765
  debugLogPath = path.join(cwd, ".forge", "cache", `fix-bug-debug-${bugId}.jsonl`);
938
- const savedWriteDebug = writeDebug;
939
766
  writeDebug = (rec) => {
940
767
  try {
941
768
  fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
769
+ // Cap at 10 MB: truncate head when size exceeds the cap.
942
770
  try {
943
771
  const st = fs.statSync(debugLogPath);
944
772
  if (st.size > 10 * 1024 * 1024) {
945
773
  const all = fs.readFileSync(debugLogPath, "utf8");
946
774
  const lines = all.split("\n");
775
+ // Keep last 80% of lines
947
776
  const keep = Math.floor(lines.length * 0.8);
948
777
  fs.writeFileSync(debugLogPath, lines.slice(-keep).join("\n"), "utf8");
949
778
  }
@@ -954,216 +783,456 @@ export async function runBugPipeline(opts) {
954
783
  fs.appendFileSync(debugLogPath, `${JSON.stringify({ ts: new Date().toISOString(), phase: phase.role, ...rec })}\n`, "utf8");
955
784
  }
956
785
  catch {
957
- // non-fatal
786
+ // non-fatal; debug log is best-effort
958
787
  }
959
788
  };
960
- writeDebug({ kind: "bugid_captured", bugId });
961
789
  }
962
- }
963
- {
964
- const elapsed = Math.floor((Date.now() - phaseStart) / 1000);
965
- const { turn, toolCount, errCount, cumUsage } = observer.state;
966
- ctx.ui.notify(`✓ ${phase.role}: ${turn} turn${turn === 1 ? "" : "s"} · ${toolCount} tool call${toolCount === 1 ? "" : "s"}${errCount ? ` · ${errCount} err` : ""} · ${elapsed}s`, "info");
967
- registry.appendTail(bugId, phase.role, fmtPhaseSummary({
968
- role: phase.role,
969
- turns: turn,
970
- tools: toolCount,
971
- errors: errCount,
972
- wallSeconds: elapsed,
973
- usage: cumUsage,
974
- model: result.model,
975
- provider: result.provider,
976
- }));
977
- }
978
- // ── Slice-2: orchestrator emits phase event ──────────────────
979
- // sprintId for bug event emission is the literal "bugs" (routing key),
980
- // matching the convention in .forge/workflows/fix_bug.md.
981
- const phaseEndMs = Date.now();
982
- const bugRecord = readBugRecord(bugId, storeCli, cwd);
983
- const sprintId = "bugs"; // routing key for bug events not a sprint reference
984
- const phaseIteration = (iterationCounts[phase.role] ?? 0) + 1;
985
- // Read summary judgement for review phases (using bug summary key map)
986
- const judgement = phase.isReview
987
- ? judgementFromSummary(bugRecord ?? null, phase.role, BUG_SUMMARY_KEY_BY_ROLE)
988
- : undefined;
989
- const emitCtx = {
990
- entityType: "bug",
991
- bugId,
992
- sprintId, // routing key "bugs" — not a sprint reference
993
- phase,
994
- iteration: phaseIteration,
995
- startMs: phaseStart,
996
- endMs: phaseEndMs,
997
- model: result.model ?? "unknown",
998
- provider: result.provider ?? "unknown",
999
- usage: {
1000
- input: result.usage.input,
1001
- output: result.usage.output,
1002
- cacheRead: result.usage.cacheRead,
1003
- cacheWrite: result.usage.cacheWrite,
1004
- },
1005
- judgement,
1006
- storeCli,
1007
- cwd,
1008
- };
1009
- const phaseEvent = buildPhaseEvent(emitCtx);
1010
- // Set bug event type based on BUG_TYPE_TOKENS mapping.
1011
- const typeTokenEntry = BUG_TYPE_TOKENS[phase.role];
1012
- if (typeTokenEntry) {
1013
- if (phase.isReview && judgement?.verdict === "revision") {
1014
- phaseEvent.type = typeTokenEntry.fail;
1015
- }
1016
- else {
1017
- phaseEvent.type = typeTokenEntry.pass;
790
+ writeDebug({ kind: "phase_start", phaseIndex: currentPhaseIndex });
791
+ registry.startPhase(bugId, phase.role, currentPhaseIndex);
792
+ const refreshStatus = () => {
793
+ if (process.env.FORGE_VERBOSE !== "1")
794
+ return;
795
+ const elapsed = Math.floor((Date.now() - phaseStart) / 1000);
796
+ const tail = observer.state.lastTool ? ` · ${observer.state.lastTool}` : "";
797
+ ctx.ui.setStatus?.(STATUS_KEY, `fix-bug ${bugId}: ${phase.role} · t${observer.state.turn} · tools ${observer.state.toolCount}${observer.state.errCount ? ` · err ${observer.state.errCount}` : ""} · ${elapsed}s${tail}`);
798
+ };
799
+ const observer = attachViewportObserver({
800
+ registry,
801
+ sessionId: bugId,
802
+ phaseRole: phase.role,
803
+ beginHeader: `─── phase ${phase.role} begin ───`,
804
+ writeDebug,
805
+ notify: (msg, level) => ctx.ui.notify(msg, level),
806
+ setStatusVerbose: process.env.FORGE_VERBOSE === "1" ? (k, v) => ctx.ui.setStatus?.(k, v) : undefined,
807
+ verboseKeys: { messageKey: `${STATUS_KEY}:message` },
808
+ afterEach: refreshStatus,
809
+ });
810
+ // Wrap the observer's onEvent to also capture tool_execution_end events
811
+ // for bugId capture downstream (findings #1, #2), plus the first turn_end
812
+ // per phase (IL10 visibility stream-observed model id).
813
+ let modelObservedLogged = false;
814
+ const onSubagentEvent = (event) => {
815
+ if (event?.type === "tool_execution_end") {
816
+ toolExecutionEvents.push({ toolName: event.toolName, result: event.result });
817
+ }
818
+ if (!modelObservedLogged && event?.type === "turn_end" && event.message?.model) {
819
+ modelObservedLogged = true;
820
+ writeDebug({
821
+ kind: "model_observed",
822
+ provider: event.message.provider ?? null,
823
+ model: event.message.model,
824
+ });
825
+ }
826
+ observer.onEvent(event);
827
+ };
828
+ // Per-phase model resolution. When config is absent or cascade bottoms
829
+ // out, resolves to inherit (model: undefined) — setModel is skipped and
830
+ // pi's current model is used. IL10 still holds: result.model below is
831
+ // the stream-observed runtime model, not whatever we requested here.
832
+ const modelResolution = resolveModelForPhase("fix-bug", phase.role, phase.personaNoun, modelRoutingConfig);
833
+ writeDebug({
834
+ kind: "requested_model",
835
+ requested: modelResolution.model ?? null,
836
+ source: modelResolution.source,
837
+ persona: phase.personaNoun,
838
+ });
839
+ let result;
840
+ try {
841
+ // FORGE-BUG-040: wrap the runForgeSubagent dispatch in the phase
842
+ // caller context so downstream tool calls (forge_preflight,
843
+ // forge_store update-status / set-bug-summary / set-summary / emit)
844
+ // can verify the caller's phase matches the phase named in the
845
+ // tool's arguments. This is the single setter of phase context
846
+ // for the bug pipeline; the audience-test wrap above is a
847
+ // short-lived test, not the canonical dispatch context.
848
+ result = await CallerContextStore.asSubagent(phase.role, () => runForgeSubagent({
849
+ persona,
850
+ task: bugBody,
851
+ cwd,
852
+ exportTag: `${bugId}__${phase.role}`,
853
+ // Sprint-scoped if the bug is attached to one, else bug-scoped.
854
+ // Keeps every phase of this bug-fix pipeline in a single cache
855
+ // namespace so the system-prompt + persona prefix stays warm
856
+ // across the ~10-minute phases.
857
+ cacheSessionId: typeof bugRecordBefore?.sprintId === "string"
858
+ ? `forge:${bugRecordBefore.sprintId}`
859
+ : `forge:bug:${bugId}`,
860
+ onEvent: onSubagentEvent,
861
+ requestedModel: modelResolution.model,
862
+ modelRegistry: ctx.modelRegistry,
863
+ signal: opts.signal,
864
+ customTools: opts.forgeToolDefs ? getSubagentTools(opts.forgeToolDefs, persona.name) : undefined,
865
+ }));
1018
866
  }
1019
- }
1020
- const emitResult = emitEvent(storeCli, cwd, sprintId, phaseEvent);
1021
- if (!emitResult.ok) {
1022
- ctx.ui.notify(`⚠ forge:fix-bug — phase event emit failed for ${phase.role}: ${emitResult.stderr.trim()}`, "warning");
1023
- writeDebug({ kind: "emit_failed", stderr: emitResult.stderr });
1024
- }
1025
- else {
1026
- writeDebug({ kind: "emit_ok", eventId: phaseEvent.eventId });
1027
- }
1028
- // Drain friction file for this phase.
1029
- const frictionPath = path.join(cwd, ".forge", "cache", `FRICTION-${phase.role}.jsonl`);
1030
- const drain = drainFrictionFile(frictionPath, emitCtx);
1031
- if (drain.emitted + drain.failed > 0) {
1032
- writeDebug({ kind: "friction_drain", ...drain });
1033
- if (drain.failed > 0) {
1034
- ctx.ui.notify(`⚠ forge:fix-bug friction drain for ${phase.role}: ${drain.emitted} ok, ${drain.failed} failed`, "warning");
867
+ catch (err) {
868
+ const e = err;
869
+ ctx.ui.notify(`× forge:fix-bug — runForgeSubagent threw for phase ${phase.role}: ${e.message ?? "unknown"}`, "error");
870
+ writeBugState(cwd, {
871
+ bugId,
872
+ phaseIndex: currentPhaseIndex,
873
+ iterationCounts,
874
+ halted: true,
875
+ lastError: `runForgeSubagent threw: ${e.message ?? "unknown"}`,
876
+ savedAt: new Date().toISOString(),
877
+ });
878
+ return {
879
+ status: "failed",
880
+ lastPhaseIndex: currentPhaseIndex,
881
+ iterationCounts,
882
+ lastError: `runForgeSubagent threw: ${e.message ?? "unknown"}`,
883
+ };
1035
884
  }
1036
- }
1037
- // ── AC §C.16: Bug FSM canonical-enum assertion ────────────────
1038
- // After each phase that could transition bug status, validate the new
1039
- // status via store-cli (single source of truth). Surface a warning (not halt) if invalid.
1040
- const currentBugRecordForAssert = readBugRecord(bugId, storeCli, cwd);
1041
- if (currentBugRecordForAssert && currentBugRecordForAssert.status) {
1042
- // Defer to store-cli's isLegalTransition as authoritative guard.
1043
- // Only warn on statuses store-cli itself would reject.
1044
- const validateResult = spawnSync("node", [storeCli, "validate", "bug", JSON.stringify(currentBugRecordForAssert)], { cwd, encoding: "utf8" });
1045
- if (validateResult.status !== 0) {
1046
- const detail = typeof validateResult.stderr === "string" ? validateResult.stderr.trim() : "unknown";
1047
- ctx.ui.notify(`⚠ forge:fix-bug — bug ${bugId} validation warning: ${detail}`, "warning");
1048
- writeDebug({ kind: "fsm_assertion_warning", bugId, status: currentBugRecordForAssert.status, detail });
885
+ // ── Post-subagent abort detection ─────────────────────────────────
886
+ if (result.stopReason === "aborted" || opts.signal?.aborted) {
887
+ ctx.ui.notify(`⊘ forge:fix-bug ${bugId} phase ${phase.role} cancelled.`, "info");
888
+ registry.completePhase(bugId, phase.role, "cancelled");
889
+ registry.confirmCancelled(bugId);
890
+ // ADR-S21-01: preserve state file so cancelled runs are resumable
891
+ writeBugState(cwd, {
892
+ bugId,
893
+ phaseIndex: currentPhaseIndex,
894
+ iterationCounts,
895
+ halted: false,
896
+ status: "cancelled",
897
+ lastError: undefined,
898
+ savedAt: new Date().toISOString(),
899
+ });
900
+ return { status: "cancelled", lastPhaseIndex: currentPhaseIndex, iterationCounts };
1049
901
  }
1050
- }
1051
- // ── 6b. Verdict check (review phases only) ────────────────────
1052
- if (phase.isReview) {
1053
- // Re-read bug record for latest status after subagent ran
1054
- const updatedBugRecord = readBugRecord(bugId, storeCli, cwd);
1055
- const verdict = readBugVerdict(updatedBugRecord, phase.role, BUG_SUMMARY_KEY_BY_ROLE);
1056
- if (verdict === "missing") {
1057
- ctx.ui.notify(`× forge:fix-bug — verdict missing for phase ${phase.role} after subagent completed. Escalating.`, "error");
902
+ // ── Halt-on-failure ───────────────────────────────────────────
903
+ if (result.exitCode !== 0) {
904
+ ctx.ui.notify(`× forge:fix-bug — phase ${phase.role} failed (exit ${result.exitCode})` +
905
+ (result.errorMessage ? `: ${result.errorMessage}` : "") +
906
+ (result.stopReason ? ` [${result.stopReason}]` : ""), "error");
1058
907
  writeBugState(cwd, {
1059
908
  bugId,
1060
909
  phaseIndex: currentPhaseIndex,
1061
910
  iterationCounts,
1062
911
  halted: true,
1063
- lastError: `verdict missing for ${phase.role}`,
912
+ lastError: result.errorMessage ?? result.stopReason ?? "subagent exit non-zero",
1064
913
  savedAt: new Date().toISOString(),
1065
914
  });
1066
915
  return {
1067
916
  status: "failed",
1068
917
  lastPhaseIndex: currentPhaseIndex,
1069
918
  iterationCounts,
1070
- lastError: `verdict missing for ${phase.role}`,
919
+ lastError: result.errorMessage ?? result.stopReason ?? "subagent exit non-zero",
1071
920
  };
1072
921
  }
1073
- if (verdict === "revision") {
1074
- iterationCounts[phase.role] = (iterationCounts[phase.role] ?? 0) + 1;
1075
- if (iterationCounts[phase.role] >= phase.maxIterations) {
1076
- ctx.ui.notify(`× forge:fix-bug — revision cap reached for phase ${phase.role} ` +
1077
- `(${iterationCounts[phase.role]}/${phase.maxIterations} iterations). Escalating.`, "error");
922
+ // Capture model/provider from subagent result.
923
+ if (result.model)
924
+ lastModel = result.model;
925
+ if (result.provider)
926
+ lastProvider = result.provider;
927
+ // ── BugId capture after triage phase (Finding #1, #2) ──────────
928
+ // For new bugs, the triage subagent creates the bug record via store-cli.
929
+ // We capture the bugId by scanning tool_execution_end events.
930
+ if (phase.role === "triage" && isNewBug && bugId.startsWith("PENDING-")) {
931
+ const capturedBugId = extractBugIdFromEvents(toolExecutionEvents);
932
+ if (capturedBugId) {
933
+ ctx.ui.notify(`forge:fix-bug — captured bug ID: ${capturedBugId}`, "info");
934
+ bugId = capturedBugId;
935
+ }
936
+ else {
937
+ // Fallback: list bugs and find the most recent one created after pipeline start.
938
+ const listResult = spawnSync("node", [storeCli, "list", "bug", "--json"], { cwd, encoding: "utf8" });
939
+ if (listResult.status === 0 && listResult.stdout) {
940
+ try {
941
+ const bugs = JSON.parse(listResult.stdout);
942
+ if (Array.isArray(bugs)) {
943
+ // Find most recent bug whose reportedAt is after the pipeline start
944
+ const pipelineStartIso = new Date(parseInt(bugId.replace("PENDING-", ""))).toISOString();
945
+ const recent = bugs
946
+ .filter((b) => b.reportedAt && b.reportedAt >= pipelineStartIso)
947
+ .sort((a, b) => String(b.reportedAt).localeCompare(String(a.reportedAt)))[0];
948
+ if (recent &&
949
+ recent.bugId &&
950
+ typeof recent.bugId === "string" &&
951
+ recent.bugId.startsWith("FORGE-BUG-")) {
952
+ bugId = recent.bugId;
953
+ ctx.ui.notify(`forge:fix-bug — captured bug ID via store fallback: ${bugId}`, "info");
954
+ }
955
+ }
956
+ }
957
+ catch {
958
+ /* parse failure — fall through to assertion */
959
+ }
960
+ }
961
+ }
962
+ // Defensive guard: if bugId is still PENDING after triage, pipeline cannot proceed.
963
+ if (bugId.startsWith("PENDING-")) {
964
+ ctx.ui.notify("× forge:fix-bug — failed to capture real bug ID after triage. Cannot proceed with PENDING placeholder.", "error");
965
+ return {
966
+ status: "failed",
967
+ lastPhaseIndex: currentPhaseIndex,
968
+ iterationCounts,
969
+ lastError: "bugId still PENDING after triage",
970
+ };
971
+ }
972
+ // Re-initialize debug log now that real bugId is available.
973
+ if (!debugLogDisabled) {
974
+ debugLogPath = path.join(cwd, ".forge", "cache", `fix-bug-debug-${bugId}.jsonl`);
975
+ const savedWriteDebug = writeDebug;
976
+ writeDebug = (rec) => {
977
+ try {
978
+ fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
979
+ try {
980
+ const st = fs.statSync(debugLogPath);
981
+ if (st.size > 10 * 1024 * 1024) {
982
+ const all = fs.readFileSync(debugLogPath, "utf8");
983
+ const lines = all.split("\n");
984
+ const keep = Math.floor(lines.length * 0.8);
985
+ fs.writeFileSync(debugLogPath, lines.slice(-keep).join("\n"), "utf8");
986
+ }
987
+ }
988
+ catch {
989
+ /* file may not exist yet */
990
+ }
991
+ fs.appendFileSync(debugLogPath, `${JSON.stringify({ ts: new Date().toISOString(), phase: phase.role, ...rec })}\n`, "utf8");
992
+ }
993
+ catch {
994
+ // non-fatal
995
+ }
996
+ };
997
+ writeDebug({ kind: "bugid_captured", bugId });
998
+ }
999
+ }
1000
+ {
1001
+ const elapsed = Math.floor((Date.now() - phaseStart) / 1000);
1002
+ const { turn, toolCount, errCount, cumUsage, cumCompression } = observer.state;
1003
+ ctx.ui.notify(`✓ ${phase.role}: ${turn} turn${turn === 1 ? "" : "s"} · ${toolCount} tool call${toolCount === 1 ? "" : "s"}${errCount ? ` · ${errCount} err` : ""} · ${elapsed}s`, "info");
1004
+ orchTranscript.record({
1005
+ kind: "phase-end",
1006
+ ts: new Date().toISOString(),
1007
+ phase: phase.role,
1008
+ phaseIndex: currentPhaseIndex,
1009
+ attempt: (iterationCounts[phase.role] ?? 0) + 1,
1010
+ verdict: "n/a",
1011
+ elapsedMs: Date.now() - phaseStart,
1012
+ turns: turn,
1013
+ toolCount,
1014
+ errCount,
1015
+ });
1016
+ registry.appendTail(bugId, phase.role, fmtPhaseSummary({
1017
+ role: phase.role,
1018
+ turns: turn,
1019
+ tools: toolCount,
1020
+ errors: errCount,
1021
+ wallSeconds: elapsed,
1022
+ usage: cumUsage,
1023
+ model: result.model,
1024
+ provider: result.provider,
1025
+ compression: cumCompression.tokensSaved > 0 ? cumCompression : undefined,
1026
+ }));
1027
+ }
1028
+ // ── Slice-2: orchestrator emits phase event ──────────────────
1029
+ // sprintId for bug event emission is the literal "bugs" (routing key),
1030
+ // matching the convention in .forge/workflows/fix_bug.md.
1031
+ const phaseEndMs = Date.now();
1032
+ const bugRecord = readBugRecord(bugId, storeCli, cwd);
1033
+ const sprintId = "bugs"; // routing key for bug events — not a sprint reference
1034
+ const phaseIteration = (iterationCounts[phase.role] ?? 0) + 1;
1035
+ // Read summary judgement for review phases (using bug summary key map)
1036
+ const judgement = phase.isReview
1037
+ ? judgementFromSummary(bugRecord ?? null, phase.role, BUG_SUMMARY_KEY_BY_ROLE)
1038
+ : undefined;
1039
+ const emitCtx = {
1040
+ entityType: "bug",
1041
+ bugId,
1042
+ sprintId, // routing key "bugs" — not a sprint reference
1043
+ phase,
1044
+ iteration: phaseIteration,
1045
+ startMs: phaseStart,
1046
+ endMs: phaseEndMs,
1047
+ model: result.model ?? "unknown",
1048
+ provider: result.provider ?? "unknown",
1049
+ usage: {
1050
+ input: result.usage.input,
1051
+ output: result.usage.output,
1052
+ cacheRead: result.usage.cacheRead,
1053
+ cacheWrite: result.usage.cacheWrite,
1054
+ },
1055
+ judgement,
1056
+ storeCli,
1057
+ cwd,
1058
+ };
1059
+ const phaseEvent = buildPhaseEvent(emitCtx);
1060
+ // Set bug event type based on BUG_TYPE_TOKENS mapping.
1061
+ const typeTokenEntry = BUG_TYPE_TOKENS[phase.role];
1062
+ if (typeTokenEntry) {
1063
+ if (phase.isReview && judgement?.verdict === "revision") {
1064
+ phaseEvent.type = typeTokenEntry.fail;
1065
+ }
1066
+ else {
1067
+ phaseEvent.type = typeTokenEntry.pass;
1068
+ }
1069
+ }
1070
+ const emitResult = emitEvent(storeCli, cwd, sprintId, phaseEvent);
1071
+ if (!emitResult.ok) {
1072
+ ctx.ui.notify(`⚠ forge:fix-bug — phase event emit failed for ${phase.role}: ${emitResult.stderr.trim()}`, "warning");
1073
+ writeDebug({ kind: "emit_failed", stderr: emitResult.stderr });
1074
+ }
1075
+ else {
1076
+ writeDebug({ kind: "emit_ok", eventId: phaseEvent.eventId });
1077
+ }
1078
+ // Drain friction file for this phase.
1079
+ const frictionPath = path.join(cwd, ".forge", "cache", `FRICTION-${phase.role}.jsonl`);
1080
+ const drain = drainFrictionFile(frictionPath, emitCtx);
1081
+ if (drain.emitted + drain.failed > 0) {
1082
+ writeDebug({ kind: "friction_drain", ...drain });
1083
+ if (drain.failed > 0) {
1084
+ ctx.ui.notify(`⚠ forge:fix-bug — friction drain for ${phase.role}: ${drain.emitted} ok, ${drain.failed} failed`, "warning");
1085
+ }
1086
+ }
1087
+ // ── AC §C.16: Bug FSM canonical-enum assertion ────────────────
1088
+ // After each phase that could transition bug status, validate the new
1089
+ // status via store-cli (single source of truth). Surface a warning (not halt) if invalid.
1090
+ const currentBugRecordForAssert = readBugRecord(bugId, storeCli, cwd);
1091
+ if (currentBugRecordForAssert && currentBugRecordForAssert.status) {
1092
+ // Defer to store-cli's isLegalTransition as authoritative guard.
1093
+ // Only warn on statuses store-cli itself would reject.
1094
+ const validateResult = spawnSync("node", [storeCli, "validate", "bug", JSON.stringify(currentBugRecordForAssert)], { cwd, encoding: "utf8" });
1095
+ if (validateResult.status !== 0) {
1096
+ const detail = typeof validateResult.stderr === "string" ? validateResult.stderr.trim() : "unknown";
1097
+ ctx.ui.notify(`⚠ forge:fix-bug — bug ${bugId} validation warning: ${detail}`, "warning");
1098
+ writeDebug({ kind: "fsm_assertion_warning", bugId, status: currentBugRecordForAssert.status, detail });
1099
+ }
1100
+ }
1101
+ // ── 6b. Verdict check (review phases only) ────────────────────
1102
+ if (phase.isReview) {
1103
+ // Re-read bug record for latest status after subagent ran
1104
+ const updatedBugRecord = readBugRecord(bugId, storeCli, cwd);
1105
+ const verdict = readBugVerdict(updatedBugRecord, phase.role, BUG_SUMMARY_KEY_BY_ROLE);
1106
+ if (verdict === "missing") {
1107
+ ctx.ui.notify(`× forge:fix-bug — verdict missing for phase ${phase.role} after subagent completed. Escalating.`, "error");
1078
1108
  writeBugState(cwd, {
1079
1109
  bugId,
1080
1110
  phaseIndex: currentPhaseIndex,
1081
1111
  iterationCounts,
1082
1112
  halted: true,
1083
- lastError: `revision cap reached for ${phase.role}`,
1113
+ lastError: `verdict missing for ${phase.role}`,
1084
1114
  savedAt: new Date().toISOString(),
1085
1115
  });
1086
1116
  return {
1087
- status: "escalated",
1117
+ status: "failed",
1088
1118
  lastPhaseIndex: currentPhaseIndex,
1089
1119
  iterationCounts,
1090
- lastError: `revision cap reached for ${phase.role}`,
1120
+ lastError: `verdict missing for ${phase.role}`,
1091
1121
  };
1092
1122
  }
1093
- // Transition bug back to in-progress before re-dispatching implement.
1094
- // This is required for review-code → implement and approve → implement loops.
1095
- const currentBugStatus = updatedBugRecord?.status;
1096
- if (currentBugStatus === "fixed" || currentBugStatus === "approved") {
1097
- const transitionResult = spawnSync("node", [storeCli, "update-status", "bug", bugId, "status", "in-progress"], { cwd, encoding: "utf8" });
1098
- if (transitionResult.status !== 0) {
1099
- ctx.ui.notify(`⚠ forge:fix-bug — failed to transition bug ${bugId} from ${currentBugStatus} to in-progress: ${transitionResult.stderr ?? "unknown"}`, "warning");
1123
+ if (verdict === "revision") {
1124
+ iterationCounts[phase.role] = (iterationCounts[phase.role] ?? 0) + 1;
1125
+ if (iterationCounts[phase.role] >= phase.maxIterations) {
1126
+ ctx.ui.notify( forge:fix-bug revision cap reached for phase ${phase.role} ` +
1127
+ `(${iterationCounts[phase.role]}/${phase.maxIterations} iterations). Escalating.`, "error");
1128
+ writeBugState(cwd, {
1129
+ bugId,
1130
+ phaseIndex: currentPhaseIndex,
1131
+ iterationCounts,
1132
+ halted: true,
1133
+ lastError: `revision cap reached for ${phase.role}`,
1134
+ savedAt: new Date().toISOString(),
1135
+ });
1136
+ return {
1137
+ status: "escalated",
1138
+ lastPhaseIndex: currentPhaseIndex,
1139
+ iterationCounts,
1140
+ lastError: `revision cap reached for ${phase.role}`,
1141
+ };
1100
1142
  }
1101
- else {
1102
- ctx.ui.notify(`⟳ forge:fix-bug transitioned bug ${bugId}: ${currentBugStatus} in-progress`, "info");
1143
+ // Transition bug back to in-progress before re-dispatching implement.
1144
+ // This is required for review-code implement and approveimplement loops.
1145
+ const currentBugStatus = updatedBugRecord?.status;
1146
+ if (currentBugStatus === "fixed" || currentBugStatus === "approved") {
1147
+ const transitionResult = spawnSync("node", [storeCli, "update-status", "bug", bugId, "status", "in-progress"], { cwd, encoding: "utf8" });
1148
+ if (transitionResult.status !== 0) {
1149
+ ctx.ui.notify(`⚠ forge:fix-bug — failed to transition bug ${bugId} from ${currentBugStatus} to in-progress: ${transitionResult.stderr ?? "unknown"}`, "warning");
1150
+ }
1151
+ else {
1152
+ ctx.ui.notify(`⟳ forge:fix-bug — transitioned bug ${bugId}: ${currentBugStatus} → in-progress`, "info");
1153
+ }
1103
1154
  }
1155
+ const predIndex = findPredecessorIndex(BUG_PHASES, currentPhaseIndex);
1156
+ ctx.ui.notify(`⟳ forge:fix-bug — ${phase.role} returned revision; looping to ${BUG_PHASES[predIndex]?.role ?? predIndex} ` +
1157
+ `(attempt ${iterationCounts[phase.role]}/${phase.maxIterations})`, "info");
1158
+ orchTranscript.record({
1159
+ kind: "phase-loopback",
1160
+ ts: new Date().toISOString(),
1161
+ fromPhase: phase.role,
1162
+ toPhase: BUG_PHASES[predIndex]?.role ?? String(predIndex),
1163
+ fromPhaseIndex: currentPhaseIndex,
1164
+ toPhaseIndex: predIndex,
1165
+ reason: `${phase.role} returned revision (attempt ${iterationCounts[phase.role]}/${phase.maxIterations})`,
1166
+ });
1167
+ writeBugState(cwd, {
1168
+ bugId,
1169
+ phaseIndex: predIndex,
1170
+ iterationCounts,
1171
+ halted: false,
1172
+ savedAt: new Date().toISOString(),
1173
+ });
1174
+ currentPhaseIndex = predIndex;
1175
+ continue;
1104
1176
  }
1105
- const predIndex = findPredecessorIndex(BUG_PHASES, currentPhaseIndex);
1106
- ctx.ui.notify(`⟳ forge:fix-bug — ${phase.role} returned revision; looping to ${BUG_PHASES[predIndex]?.role ?? predIndex} ` +
1107
- `(attempt ${iterationCounts[phase.role]}/${phase.maxIterations})`, "info");
1108
- writeBugState(cwd, {
1109
- bugId,
1110
- phaseIndex: predIndex,
1111
- iterationCounts,
1112
- halted: false,
1113
- savedAt: new Date().toISOString(),
1114
- });
1115
- currentPhaseIndex = predIndex;
1116
- continue;
1177
+ // verdict === "approved": fall through to advance
1117
1178
  }
1118
- // verdict === "approved": fall through to advance
1119
- }
1120
- // ── Advance to next phase ─────────────────────────────────────
1121
- registry.completePhase(bugId, phase.role, "completed");
1122
- writeBugState(cwd, {
1123
- bugId,
1124
- phaseIndex: currentPhaseIndex,
1125
- iterationCounts,
1126
- halted: false,
1127
- savedAt: new Date().toISOString(),
1128
- });
1129
- // ── 6c. Path A / Path B branch (post-triage) ──────────────────
1130
- // Per meta-fix-bug.md § Triage Judgement (forge v0.44.0+), the
1131
- // triage subagent records the route decision in
1132
- // bug.summaries.triage.route. The orchestrator reads it after
1133
- // triage returns and selects the downstream phase list:
1134
- // Path A (short-circuit): skip plan-fix + review-plan
1135
- // Path B (default, full loop): run all phases
1136
- //
1137
- // If route is missing or malformed, default to Path B (the safe
1138
- // choice running extra phases never produces an unsafe outcome).
1139
- // The PHASE_SKIP_STATES heuristic at section 6a remains as
1140
- // defense-in-depth for cases where the field is missing but the
1141
- // bug status proves the work happened.
1142
- if (phase.role === "triage") {
1143
- const bugAfterTriage = readBugRecord(bugId, storeCli, cwd);
1144
- const triageSummary = bugAfterTriage?.summaries?.triage;
1145
- const route = triageSummary?.route;
1146
- if (route === "A") {
1147
- const skipUntilIndex = BUG_PHASES.findIndex((p) => p.role === "implement");
1148
- if (skipUntilIndex > currentPhaseIndex + 1) {
1149
- ctx.ui.notify(`⊘ forge:fix-bug — Path A selected by triage; skipping plan-fix and review-plan.`, "info");
1150
- currentPhaseIndex = skipUntilIndex;
1151
- continue;
1179
+ // ── Advance to next phase ─────────────────────────────────────
1180
+ registry.completePhase(bugId, phase.role, "completed");
1181
+ writeBugState(cwd, {
1182
+ bugId,
1183
+ phaseIndex: currentPhaseIndex,
1184
+ iterationCounts,
1185
+ halted: false,
1186
+ savedAt: new Date().toISOString(),
1187
+ });
1188
+ // ── 6c. Path A / Path B branch (post-triage) ──────────────────
1189
+ // Per meta-fix-bug.md § Triage Judgement (forge v0.44.0+), the
1190
+ // triage subagent records the route decision in
1191
+ // bug.summaries.triage.route. The orchestrator reads it after
1192
+ // triage returns and selects the downstream phase list:
1193
+ // Path A (short-circuit): skip plan-fix + review-plan
1194
+ // Path B (default, full loop): run all phases
1195
+ //
1196
+ // If route is missing or malformed, default to Path B (the safe
1197
+ // choice — running extra phases never produces an unsafe outcome).
1198
+ // The PHASE_SKIP_STATES heuristic at section 6a remains as
1199
+ // defense-in-depth for cases where the field is missing but the
1200
+ // bug status proves the work happened.
1201
+ if (phase.role === "triage") {
1202
+ const bugAfterTriage = readBugRecord(bugId, storeCli, cwd);
1203
+ const triageSummary = bugAfterTriage?.summaries?.triage;
1204
+ const route = triageSummary?.route;
1205
+ if (route === "A") {
1206
+ const skipUntilIndex = BUG_PHASES.findIndex((p) => p.role === "implement");
1207
+ if (skipUntilIndex > currentPhaseIndex + 1) {
1208
+ ctx.ui.notify(`⊘ forge:fix-bug Path A selected by triage; skipping plan-fix and review-plan.`, "info");
1209
+ currentPhaseIndex = skipUntilIndex;
1210
+ continue;
1211
+ }
1152
1212
  }
1213
+ // route === "B", missing, or any other value → fall through to standard advance
1153
1214
  }
1154
- // route === "B", missing, or any other value → fall through to standard advance
1215
+ currentPhaseIndex++;
1155
1216
  }
1156
- currentPhaseIndex++;
1217
+ // ── All phases complete ───────────────────────────────────────────
1218
+ deleteBugState(cwd, bugId);
1219
+ orchTranscript.record({
1220
+ kind: "pipeline-end",
1221
+ ts: new Date().toISOString(),
1222
+ outcome: "complete",
1223
+ elapsedMs: Date.now() - pipelineStartMs,
1224
+ });
1225
+ return {
1226
+ status: "completed",
1227
+ lastPhaseIndex: BUG_PHASES.length - 1,
1228
+ iterationCounts,
1229
+ model: lastModel,
1230
+ provider: lastProvider,
1231
+ };
1232
+ }
1233
+ finally {
1234
+ ctx.ui.notify = __origNotify;
1157
1235
  }
1158
- // ── All phases complete ───────────────────────────────────────────
1159
- deleteBugState(cwd, bugId);
1160
- return {
1161
- status: "completed",
1162
- lastPhaseIndex: BUG_PHASES.length - 1,
1163
- iterationCounts,
1164
- model: lastModel,
1165
- provider: lastProvider,
1166
- };
1167
1236
  }
1168
1237
  export function registerFixBug(pi, options = {}) {
1169
1238
  pi.registerCommand("forge:fix-bug", {