@entelligentsia/forgecli 1.0.21 → 1.0.36

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 (218) hide show
  1. package/CHANGELOG.md +346 -0
  2. package/README.md +2 -0
  3. package/dist/CHANGELOG-forge-plugin.md +281 -0
  4. package/dist/bin/argv.d.ts +2 -2
  5. package/dist/bin/argv.js +25 -0
  6. package/dist/bin/argv.js.map +1 -1
  7. package/dist/bin/forge.js +12 -0
  8. package/dist/bin/forge.js.map +1 -1
  9. package/dist/bin/init.d.ts +23 -0
  10. package/dist/bin/init.js +123 -0
  11. package/dist/bin/init.js.map +1 -0
  12. package/dist/bin/uninstall.d.ts +20 -0
  13. package/dist/bin/uninstall.js +141 -0
  14. package/dist/bin/uninstall.js.map +1 -0
  15. package/dist/extensions/forgecli/claude-bootstrap/bootstrap.d.ts +40 -0
  16. package/dist/extensions/forgecli/claude-bootstrap/bootstrap.js +593 -0
  17. package/dist/extensions/forgecli/claude-bootstrap/bootstrap.js.map +1 -0
  18. package/dist/extensions/forgecli/claude-bootstrap/settings-merge.d.ts +46 -0
  19. package/dist/extensions/forgecli/claude-bootstrap/settings-merge.js +245 -0
  20. package/dist/extensions/forgecli/claude-bootstrap/settings-merge.js.map +1 -0
  21. package/dist/extensions/forgecli/claude-bootstrap/uninstall.d.ts +23 -0
  22. package/dist/extensions/forgecli/claude-bootstrap/uninstall.js +215 -0
  23. package/dist/extensions/forgecli/claude-bootstrap/uninstall.js.map +1 -0
  24. package/dist/extensions/forgecli/dashboard/component.js +10 -7
  25. package/dist/extensions/forgecli/dashboard/component.js.map +1 -1
  26. package/dist/extensions/forgecli/forge-tools.d.ts +1 -0
  27. package/dist/extensions/forgecli/forge-tools.js +73 -0
  28. package/dist/extensions/forgecli/forge-tools.js.map +1 -1
  29. package/dist/extensions/forgecli/lib/forge-root.d.ts +5 -0
  30. package/dist/extensions/forgecli/lib/forge-root.js +14 -1
  31. package/dist/extensions/forgecli/lib/forge-root.js.map +1 -1
  32. package/dist/extensions/forgecli/orchestrators/bug/bug-body.d.ts +1 -0
  33. package/dist/extensions/forgecli/orchestrators/bug/bug-body.js +65 -0
  34. package/dist/extensions/forgecli/orchestrators/bug/bug-body.js.map +1 -0
  35. package/dist/extensions/forgecli/orchestrators/bug/bug-id.d.ts +23 -0
  36. package/dist/extensions/forgecli/orchestrators/bug/bug-id.js +140 -0
  37. package/dist/extensions/forgecli/orchestrators/bug/bug-id.js.map +1 -0
  38. package/dist/extensions/forgecli/orchestrators/bug/bug-phase-dispatch.d.ts +54 -0
  39. package/dist/extensions/forgecli/orchestrators/bug/bug-phase-dispatch.js +349 -0
  40. package/dist/extensions/forgecli/orchestrators/bug/bug-phase-dispatch.js.map +1 -0
  41. package/dist/extensions/forgecli/orchestrators/bug/bug-phases.d.ts +8 -0
  42. package/dist/extensions/forgecli/orchestrators/bug/bug-phases.js +60 -0
  43. package/dist/extensions/forgecli/orchestrators/bug/bug-phases.js.map +1 -0
  44. package/dist/extensions/forgecli/orchestrators/bug/bug-state.d.ts +14 -0
  45. package/dist/extensions/forgecli/orchestrators/bug/bug-state.js +100 -0
  46. package/dist/extensions/forgecli/orchestrators/bug/bug-state.js.map +1 -0
  47. package/dist/extensions/forgecli/orchestrators/bug/bug-triage-routing.d.ts +72 -0
  48. package/dist/extensions/forgecli/orchestrators/bug/bug-triage-routing.js +204 -0
  49. package/dist/extensions/forgecli/orchestrators/bug/bug-triage-routing.js.map +1 -0
  50. package/dist/extensions/forgecli/orchestrators/bug/bug-verdict-loop.d.ts +38 -0
  51. package/dist/extensions/forgecli/orchestrators/bug/bug-verdict-loop.js +166 -0
  52. package/dist/extensions/forgecli/orchestrators/bug/bug-verdict-loop.js.map +1 -0
  53. package/dist/extensions/forgecli/orchestrators/bug/bug-verdict.d.ts +3 -0
  54. package/dist/extensions/forgecli/orchestrators/bug/bug-verdict.js +55 -0
  55. package/dist/extensions/forgecli/orchestrators/bug/bug-verdict.js.map +1 -0
  56. package/dist/extensions/forgecli/orchestrators/bug/run-bug-command.d.ts +7 -0
  57. package/dist/extensions/forgecli/orchestrators/bug/run-bug-command.js +293 -0
  58. package/dist/extensions/forgecli/orchestrators/bug/run-bug-command.js.map +1 -0
  59. package/dist/extensions/forgecli/orchestrators/bug/run-bug-pipeline.d.ts +2 -0
  60. package/dist/extensions/forgecli/orchestrators/bug/run-bug-pipeline.js +501 -0
  61. package/dist/extensions/forgecli/orchestrators/bug/run-bug-pipeline.js.map +1 -0
  62. package/dist/extensions/forgecli/orchestrators/bug/run-bug-types.d.ts +41 -0
  63. package/dist/extensions/forgecli/orchestrators/bug/run-bug-types.js +5 -0
  64. package/dist/extensions/forgecli/orchestrators/bug/run-bug-types.js.map +1 -0
  65. package/dist/extensions/forgecli/orchestrators/common/orchestrator-entry.d.ts +43 -0
  66. package/dist/extensions/forgecli/orchestrators/common/orchestrator-entry.js +85 -0
  67. package/dist/extensions/forgecli/orchestrators/common/orchestrator-entry.js.map +1 -0
  68. package/dist/extensions/forgecli/orchestrators/common/orchestrator-misc.d.ts +8 -0
  69. package/dist/extensions/forgecli/orchestrators/common/orchestrator-misc.js +37 -0
  70. package/dist/extensions/forgecli/orchestrators/common/orchestrator-misc.js.map +1 -0
  71. package/dist/extensions/forgecli/orchestrators/common/orchestrator-notify.d.ts +28 -0
  72. package/dist/extensions/forgecli/orchestrators/common/orchestrator-notify.js +45 -0
  73. package/dist/extensions/forgecli/orchestrators/common/orchestrator-notify.js.map +1 -0
  74. package/dist/extensions/forgecli/orchestrators/common/orchestrator-transcript-session.d.ts +26 -0
  75. package/dist/extensions/forgecli/orchestrators/common/orchestrator-transcript-session.js +75 -0
  76. package/dist/extensions/forgecli/orchestrators/common/orchestrator-transcript-session.js.map +1 -0
  77. package/dist/extensions/forgecli/orchestrators/common/summary-recovery.d.ts +24 -0
  78. package/dist/extensions/forgecli/orchestrators/common/summary-recovery.js +37 -0
  79. package/dist/extensions/forgecli/orchestrators/common/summary-recovery.js.map +1 -0
  80. package/dist/extensions/forgecli/orchestrators/fix-bug.d.ts +9 -92
  81. package/dist/extensions/forgecli/orchestrators/fix-bug.js +23 -1695
  82. package/dist/extensions/forgecli/orchestrators/fix-bug.js.map +1 -1
  83. package/dist/extensions/forgecli/orchestrators/run-sprint.d.ts +3 -12
  84. package/dist/extensions/forgecli/orchestrators/run-sprint.js +97 -270
  85. package/dist/extensions/forgecli/orchestrators/run-sprint.js.map +1 -1
  86. package/dist/extensions/forgecli/orchestrators/run-task.d.ts +10 -214
  87. package/dist/extensions/forgecli/orchestrators/run-task.js +31 -1481
  88. package/dist/extensions/forgecli/orchestrators/run-task.js.map +1 -1
  89. package/dist/extensions/forgecli/orchestrators/sprint/sprint-ceremony.d.ts +33 -0
  90. package/dist/extensions/forgecli/orchestrators/sprint/sprint-ceremony.js +135 -0
  91. package/dist/extensions/forgecli/orchestrators/sprint/sprint-ceremony.js.map +1 -0
  92. package/dist/extensions/forgecli/orchestrators/sprint/sprint-state.d.ts +18 -0
  93. package/dist/extensions/forgecli/orchestrators/sprint/sprint-state.js +55 -0
  94. package/dist/extensions/forgecli/orchestrators/sprint/sprint-state.js.map +1 -0
  95. package/dist/extensions/forgecli/orchestrators/task/run-task-command.d.ts +9 -0
  96. package/dist/extensions/forgecli/orchestrators/task/run-task-command.js +174 -0
  97. package/dist/extensions/forgecli/orchestrators/task/run-task-command.js.map +1 -0
  98. package/dist/extensions/forgecli/orchestrators/task/run-task-pipeline.d.ts +2 -0
  99. package/dist/extensions/forgecli/orchestrators/task/run-task-pipeline.js +494 -0
  100. package/dist/extensions/forgecli/orchestrators/task/run-task-pipeline.js.map +1 -0
  101. package/dist/extensions/forgecli/orchestrators/task/run-task-types.d.ts +62 -0
  102. package/dist/extensions/forgecli/orchestrators/task/run-task-types.js +5 -0
  103. package/dist/extensions/forgecli/orchestrators/task/run-task-types.js.map +1 -0
  104. package/dist/extensions/forgecli/orchestrators/task/task-body.d.ts +4 -0
  105. package/dist/extensions/forgecli/orchestrators/task/task-body.js +48 -0
  106. package/dist/extensions/forgecli/orchestrators/task/task-body.js.map +1 -0
  107. package/dist/extensions/forgecli/orchestrators/task/task-events.d.ts +63 -0
  108. package/dist/extensions/forgecli/orchestrators/task/task-events.js +185 -0
  109. package/dist/extensions/forgecli/orchestrators/task/task-events.js.map +1 -0
  110. package/dist/extensions/forgecli/orchestrators/task/task-gates.d.ts +34 -0
  111. package/dist/extensions/forgecli/orchestrators/task/task-gates.js +78 -0
  112. package/dist/extensions/forgecli/orchestrators/task/task-gates.js.map +1 -0
  113. package/dist/extensions/forgecli/orchestrators/task/task-phase-dispatch.d.ts +42 -0
  114. package/dist/extensions/forgecli/orchestrators/task/task-phase-dispatch.js +370 -0
  115. package/dist/extensions/forgecli/orchestrators/task/task-phase-dispatch.js.map +1 -0
  116. package/dist/extensions/forgecli/orchestrators/task/task-phases.d.ts +14 -0
  117. package/dist/extensions/forgecli/orchestrators/task/task-phases.js +26 -0
  118. package/dist/extensions/forgecli/orchestrators/task/task-phases.js.map +1 -0
  119. package/dist/extensions/forgecli/orchestrators/task/task-record.d.ts +9 -0
  120. package/dist/extensions/forgecli/orchestrators/task/task-record.js +58 -0
  121. package/dist/extensions/forgecli/orchestrators/task/task-record.js.map +1 -0
  122. package/dist/extensions/forgecli/orchestrators/task/task-state.d.ts +14 -0
  123. package/dist/extensions/forgecli/orchestrators/task/task-state.js +35 -0
  124. package/dist/extensions/forgecli/orchestrators/task/task-state.js.map +1 -0
  125. package/dist/extensions/forgecli/orchestrators/task/task-verdict-loop.d.ts +36 -0
  126. package/dist/extensions/forgecli/orchestrators/task/task-verdict-loop.js +152 -0
  127. package/dist/extensions/forgecli/orchestrators/task/task-verdict-loop.js.map +1 -0
  128. package/dist/extensions/forgecli/update/forge-update-command.js +10 -7
  129. package/dist/extensions/forgecli/update/forge-update-command.js.map +1 -1
  130. package/dist/forge-payload/.base-pack/commands/approve.md +2 -2
  131. package/dist/forge-payload/.base-pack/commands/check-agent.md +2 -2
  132. package/dist/forge-payload/.base-pack/commands/collate.md +2 -2
  133. package/dist/forge-payload/.base-pack/commands/commit.md +2 -2
  134. package/dist/forge-payload/.base-pack/commands/enhance.md +2 -2
  135. package/dist/forge-payload/.base-pack/commands/fix-bug.md +2 -2
  136. package/dist/forge-payload/.base-pack/commands/implement.md +2 -2
  137. package/dist/forge-payload/.base-pack/commands/init.md +278 -0
  138. package/dist/forge-payload/.base-pack/commands/new-sprint.md +2 -2
  139. package/dist/forge-payload/.base-pack/commands/plan-sprint.md +2 -2
  140. package/dist/forge-payload/.base-pack/commands/plan.md +2 -2
  141. package/dist/forge-payload/.base-pack/commands/retro.md +2 -2
  142. package/dist/forge-payload/.base-pack/commands/review-code.md +2 -2
  143. package/dist/forge-payload/.base-pack/commands/review-plan.md +2 -2
  144. package/dist/forge-payload/.base-pack/commands/run-sprint.md +2 -2
  145. package/dist/forge-payload/.base-pack/commands/run-task.md +2 -2
  146. package/dist/forge-payload/.base-pack/commands/validate.md +2 -2
  147. package/dist/forge-payload/.base-pack/workflows/_fragments/event-emission-schema.md +4 -0
  148. package/dist/forge-payload/.base-pack/workflows/_fragments/event-vocabulary.md +88 -0
  149. package/dist/forge-payload/.base-pack/workflows/collator_agent.md +5 -6
  150. package/dist/forge-payload/.base-pack/workflows/commit_task.md +41 -38
  151. package/dist/forge-payload/.base-pack/workflows/implement_plan.md +3 -3
  152. package/dist/forge-payload/.base-pack/workflows/migrate_structural.md +1 -1
  153. package/dist/forge-payload/.base-pack/workflows-js/wfl-fix-bug.js +42 -6
  154. package/dist/forge-payload/.base-pack/workflows-js/wfl-init.js +449 -0
  155. package/dist/forge-payload/.base-pack/workflows-js/wfl-run-task.js +32 -1
  156. package/dist/forge-payload/.claude-plugin/plugin.json +1 -1
  157. package/dist/forge-payload/.schemas/enum-catalog.json +2 -2
  158. package/dist/forge-payload/.schemas/event.schema.json +8 -3
  159. package/dist/forge-payload/.schemas/migrations.json +141 -0
  160. package/dist/forge-payload/commands/add-pipeline.md +1 -1
  161. package/dist/forge-payload/commands/add-task.md +3 -3
  162. package/dist/forge-payload/commands/ask.md +1 -1
  163. package/dist/forge-payload/commands/check-agent.md +1 -1
  164. package/dist/forge-payload/commands/config.md +1 -1
  165. package/dist/forge-payload/commands/health.md +1 -1
  166. package/dist/forge-payload/commands/init.md +62 -7
  167. package/dist/forge-payload/commands/rebuild.md +3 -3
  168. package/dist/forge-payload/commands/remove.md +1 -1
  169. package/dist/forge-payload/commands/repair.md +1 -1
  170. package/dist/forge-payload/commands/report-bug.md +1 -1
  171. package/dist/forge-payload/commands/status.md +1 -1
  172. package/dist/forge-payload/commands/update.md +3 -3
  173. package/dist/forge-payload/hooks/lib/common.cjs +228 -0
  174. package/dist/forge-payload/hooks/lib/plugin-detection.cjs +106 -0
  175. package/dist/forge-payload/hooks/lib/update-msg.cjs +23 -0
  176. package/dist/forge-payload/hooks/lib/update-url.cjs +46 -0
  177. package/dist/forge-payload/hooks/lib/write-registry.js +53 -0
  178. package/dist/forge-payload/init/discovery/discover-database.md +32 -0
  179. package/dist/forge-payload/init/discovery/discover-processes.md +31 -0
  180. package/dist/forge-payload/init/discovery/discover-routing.md +31 -0
  181. package/dist/forge-payload/init/discovery/discover-stack.md +33 -0
  182. package/dist/forge-payload/init/discovery/discover-testing.md +34 -0
  183. package/dist/forge-payload/init/generation/generate-commands.md +171 -0
  184. package/dist/forge-payload/init/generation/generate-kb-doc.md +60 -0
  185. package/dist/forge-payload/init/generation/generate-knowledge-base.md +56 -0
  186. package/dist/forge-payload/init/generation/generate-persona.md +73 -0
  187. package/dist/forge-payload/init/generation/generate-personas.md +54 -0
  188. package/dist/forge-payload/init/generation/generate-skill.md +66 -0
  189. package/dist/forge-payload/init/generation/generate-skills.md +36 -0
  190. package/dist/forge-payload/init/generation/generate-template.md +60 -0
  191. package/dist/forge-payload/init/generation/generate-templates.md +39 -0
  192. package/dist/forge-payload/init/generation/generate-tools.md +133 -0
  193. package/dist/forge-payload/init/generation/generate-workflows.md +78 -0
  194. package/dist/forge-payload/init/phases/phase-1-collect.md +10 -2
  195. package/dist/forge-payload/init/phases/phase-3-materialize.md +1 -1
  196. package/dist/forge-payload/init/phases/phase-4-register.md +8 -0
  197. package/dist/forge-payload/init/workflow-gen-plan.json +17 -0
  198. package/dist/forge-payload/integrity.json +16 -16
  199. package/dist/forge-payload/meta/store-schema/event.schema.md +7 -0
  200. package/dist/forge-payload/meta/workflows/_fragments/event-emission-schema.md +4 -0
  201. package/dist/forge-payload/meta/workflows/_fragments/event-vocabulary.md +88 -0
  202. package/dist/forge-payload/meta/workflows/meta-collate.md +5 -6
  203. package/dist/forge-payload/meta/workflows/meta-commit.md +46 -43
  204. package/dist/forge-payload/meta/workflows/meta-fix-bug.md +7 -2
  205. package/dist/forge-payload/meta/workflows/meta-implement.md +3 -3
  206. package/dist/forge-payload/meta/workflows/meta-migrate.md +1 -1
  207. package/dist/forge-payload/meta/workflows/meta-orchestrate.md +4 -1
  208. package/dist/forge-payload/schemas/enum-catalog.json +2 -2
  209. package/dist/forge-payload/schemas/event.schema.json +8 -3
  210. package/dist/forge-payload/schemas/structure-manifest.json +5 -12
  211. package/dist/forge-payload/tools/commit-task.cjs +218 -0
  212. package/dist/forge-payload/tools/forge-preflight.cjs +268 -0
  213. package/dist/forge-payload/tools/lib/paths.cjs +12 -11
  214. package/dist/forge-payload/tools/lib/pricing.cjs +31 -11
  215. package/dist/forge-payload/tools/query-logger.cjs +34 -0
  216. package/dist/forge-payload/tools/store-cli.cjs +6 -1
  217. package/dist/forge-payload/tools/substitute-placeholders.cjs +5 -6
  218. package/package.json +2 -2
@@ -1,1705 +1,33 @@
1
1
  // fix-bug.ts — /forge:fix-bug Orchestrator native handler (FORGE-S21-T07).
2
2
  //
3
- // Promotes /forge:fix-bug from stub to a full TS-driven Orchestrator-archetype
4
- // native handler. Reads `.forge/workflows/fix_bug.md`, chains the bug-specific
5
- // phase sequence (triage → plan-fix review-plan implement review-code
6
- // approve commit) by spawning a fresh runForgeSubagent per phase (IL10).
3
+ // BARREL (FORGE-S31 file-size refactor). The implementation was decomposed into
4
+ // themed modules under ./bug/ to satisfy the per-file architectural line cap.
5
+ // This file re-exports the full public surface so existing importers
6
+ // (index.ts, bin/config.ts, the test suite) keep resolving unchanged.
7
7
  //
8
- // Iron Laws enforced here:
8
+ // Iron Laws (enforced in the extracted modules):
9
9
  // IL1 — code only under forge-cli/src/extensions/forgecli/
10
10
  // IL6 — no shell-string interpolation; all external calls via spawnSync argv arrays
11
11
  // IL7 — every failure path emits ctx.ui.notify and returns; no silent continuation
12
12
  // IL10 — ALL LLM dispatch goes through runForgeSubagent (NO sendKickoff calls here)
13
13
  //
14
- // sendKickoff is NEVER called from this file.
15
- // Audit-grep: grep -n "sendKickoff(" fix-bug.ts must return empty.
16
- //
17
- // N-H-C bugId dual-assignment lifecycle:
18
- // Phase 1 (handler entry, ~line 1372): bugId = `PENDING-${Date.now()}`, isNewBug = true.
19
- // A temporary placeholder; the timestamp is later used to find the real bug record.
20
- // Phase 2 (pre-init, ~line 1495–1500): preCreateBug() writes a minimal bug record with a
21
- // real FORGE-BUG-NNN ID so the triage subagent has a stable ID to reference.
22
- // If preCreateBug fails, the PENDING- placeholder is kept for fallback capture.
23
- // Phase 3 (post-triage, ~line 962–989): capture real ID from BugCreated events emitted by
24
- // the triage subagent; fall back to listing the most-recent bug after pipelineStart if
25
- // event capture fails. The PENDING- prefix is used throughout as a guard for
26
- // state-write paths (see ~line 176 and CallerContextStore guards).
27
- // Reference: PENDING- prefix semantics defined in CallerContextStore guards.
28
- //
29
- // N-H-H — Preflight gate design (closed by FORGE-S25-T17):
30
- // Entry-level: runOrchestratorPreflight is called at runBugPipeline entry (~line 523).
31
- // Validates persona/model config before any LLM dispatch (mirrors run-task.ts design).
32
- // Per-phase: runPreflightGate (store-cli gate) is called per phase (~line 667).
33
- // Evaluates declarative gate conditions from the workflow's gate block.
34
- // This two-level design ensures both structural validity (model/persona config) and
35
- // store-state validity (predecessor verdicts, status guards) are checked.
36
- // Reference: orchestrator-preflight.ts (N-H-H, FORGE-S25-T17).
37
- //
38
- // N-H-E tag: see inline comment at the materialization skip (~line 707 / checkMaterialization).
39
- import { spawnSync } from "node:child_process";
40
- import * as fs from "node:fs";
41
- import * as path from "node:path";
42
- import { fileURLToPath } from "node:url";
43
- import { assertAudience, CallerContextStore } from "../audience-gate.js";
44
- // ModelRegistry/AuthStorage no longer instantiated here — use ctx.modelRegistry
45
- // so extension-registered providers (registered against the live session) are
46
- // visible to validateModelConfig. Creating a fresh registry here would miss
47
- // them and produce spurious MODEL_UNAVAILABLE warnings (FORGE-BUG-001).
48
- import { loadLayeredConfig } from "../config/config-layer.js";
49
- import { resolveModelForPhase } from "../config/model-resolver.js";
50
- import { loadForgePersona, runForgeSubagent } from "../forge-subagent.js";
51
- import { getSubagentTools } from "../forge-tools.js";
52
- import { loadGovernorProjectConfig } from "../governor-config.js";
53
- import { readPersonaDir as readPersonaDirBug, readPipelineNames as readPipelineNamesBug, } from "../lib/catalog-helpers.js";
54
- import { discoverForgeConfigCached } from "../lib/forge-config.js";
55
- import { checkMaterialization } from "../lib/manifest-checker.js";
56
- import { getOrchestratorTree } from "../orchestrator-tree.js";
57
- import { loadWorkflow } from "../parsers/workflow-loader.js";
58
- import { getSessionRegistry } from "../session-registry.js";
59
- import { resolveToCanonicalId, resolveToolDir } from "../store/store-resolver.js";
60
- import { OrchestratorTranscriptWriter } from "../subagent/orchestrator-transcript.js";
61
- import { archiveRun, sweepProjectTranscripts } from "../transcript-archive.js";
62
- import { attachViewportObserver } from "../viewport/events.js";
63
- import { fmtPhaseSummary } from "../viewport/renderer.js";
64
- import { resolveAdvisorModel, runHaltAdvisor } from "./halt-advisor.js";
65
- import { runOrchestratorPreflight } from "./orchestrator-preflight.js";
66
- import { buildPhaseEvent, buildSummariesBlock, drainFrictionFile, emitEvent, emitIncompletePhaseEvent, findPredecessorIndex, formatLocalTime, isNonInteractive, judgementFromSummary, runPreflightGateWithData, transcriptOutcomeFor, validateId, } from "./run-task.js";
67
- // ── Bug phase descriptor table ──────────────────────────────────────────────
68
- //
69
- // Decoded from .forge/workflows/fix_bug.md and the task prompt's BUG_PHASES.
70
- // triage / plan-fix / implement all read the same fix_bug.md body — the
71
- // workflow handles all three phases through prose.
72
- // FORGE-S25-T16: readPersonaDirBug / readPipelineNamesBug extracted to
73
- // lib/catalog-helpers.ts and imported above with aliases (H-4, N-H-G).
74
- export const BUG_PHASES = [
75
- // FORGE-BUG-040: each phase points at its own phase-scoped subagent workflow.
76
- // Previously triage/plan-fix/implement all pointed at fix_bug.md (the
77
- // orchestrator-only body), which caused the triage subagent to execute
78
- // the full lifecycle in a single invocation. plan-fix and implement reuse
79
- // plan_task.md / implement_plan.md (bug-mode) per meta-fix-bug.md
80
- // § Pipeline Phases — the bug-mode entity-kind detection is built into
81
- // those workflows already.
82
- { role: "triage", workflowFile: "triage", personaNoun: "bug-fixer", isReview: false, maxIterations: 1 },
83
- { role: "plan-fix", workflowFile: "plan_task", personaNoun: "engineer", isReview: false, maxIterations: 1 },
84
- { role: "review-plan", workflowFile: "review_plan", personaNoun: "supervisor", isReview: true, maxIterations: 3 },
85
- { role: "implement", workflowFile: "implement_plan", personaNoun: "engineer", isReview: false, maxIterations: 1 },
86
- { role: "review-code", workflowFile: "review_code", personaNoun: "supervisor", isReview: true, maxIterations: 3 },
87
- { role: "approve", workflowFile: "architect_approve", personaNoun: "architect", isReview: true, maxIterations: 3 },
88
- { role: "commit", workflowFile: "commit_task", personaNoun: "engineer", isReview: false, maxIterations: 1 },
89
- ];
90
- // FORGE-BUG-040: BUG_SUMMARY_KEY_BY_ROLE lives in
91
- // subagent/phase-summary-map.ts so the new phase-guard.ts can import
92
- // it without dragging fix-bug.ts into a forge-tools import cycle.
93
- // Re-exported here for backwards-compatibility with existing call sites.
14
+ // sendKickoff is NEVER called from this file or its extracted modules.
15
+ // ── Bug phase descriptor table + FSM transitions + event tokens ────────────
16
+ export { BUG_PHASES, BUG_TYPE_TOKENS, postTriageTransitions } from "./bug/bug-phases.js";
17
+ // ── Post-triage routing (status transitions + Path A/B branch) ─────────────
18
+ export { routeAfterTriage, } from "./bug/bug-triage-routing.js";
19
+ // BUG_SUMMARY_KEY_BY_ROLE lives in subagent/phase-summary-map.ts; re-exported
20
+ // here for backwards-compatibility with existing call sites and tests.
94
21
  export { BUG_SUMMARY_KEY_BY_ROLE } from "../subagent/phase-summary-map.js";
95
- import { BUG_SUMMARY_KEY_BY_ROLE } from "../subagent/phase-summary-map.js";
96
- // Bug-event type tokens explicit mapping per review finding #3.
97
- // Non-review phases always emit the pass token. Review phases select
98
- // pass or fail based on ec.judgement.verdict.
99
- export const BUG_TYPE_TOKENS = {
100
- triage: { pass: "bug-triaged", fail: "bug-triaged" },
101
- "plan-fix": { pass: "fix-planned", fail: "fix-planned" },
102
- "review-plan": { pass: "fix-review-passed", fail: "fix-review-failed" },
103
- implement: { pass: "fix-implemented", fail: "fix-implemented" },
104
- "review-code": { pass: "fix-code-review-passed", fail: "fix-code-review-failed" },
105
- approve: { pass: "fix-approved", fail: "fix-revision-requested" },
106
- commit: { pass: "bug-committed", fail: "bug-commit-failed" },
107
- };
108
- // ── Bug FSM transitions ────────────────────────────────────────────────────
109
- // Mirrors store-cli BUG_TRANSITIONS. Terminal: `fixed`.
110
- // `approved` and `verified` enum values were dropped in forge v0.44.0
111
- // (FORGE-BUG-002 trap). The canonical source is store-cli.cjs.
112
- const BUG_TERMINAL_STATES = new Set(["fixed"]);
113
- function bugStateFilePath(cwd, bugId, sessionId) {
114
- if (!validateId(bugId)) {
115
- throw new Error(`Invalid bugId for state file path: ${bugId}`);
116
- }
117
- const suffix = sessionId ?? process.env.FORGE_SESSION_ID ?? `${process.pid}`;
118
- return path.join(cwd, ".forge", "cache", `fix-bug-state-${bugId}-${suffix}.json`);
119
- }
120
- export function readBugState(cwd, bugId, sessionId) {
121
- // If a specific session ID is given, read that file directly.
122
- if (sessionId || process.env.FORGE_SESSION_ID) {
123
- const fp = bugStateFilePath(cwd, bugId, sessionId);
124
- try {
125
- if (!fs.existsSync(fp))
126
- return null;
127
- const raw = fs.readFileSync(fp, "utf8");
128
- return JSON.parse(raw);
129
- }
130
- catch {
131
- return null;
132
- }
133
- }
134
- // No specific session — glob for the most recent matching state file.
135
- // Single-writer assumption: normally only one session per bug.
136
- const cacheDir = path.join(cwd, ".forge", "cache");
137
- const prefix = `fix-bug-state-${bugId}-`;
138
- let bestFile = null;
139
- let bestMtime = 0;
140
- try {
141
- const entries = fs.readdirSync(cacheDir);
142
- for (const entry of entries) {
143
- if (!entry.startsWith(prefix) || !entry.endsWith(".json"))
144
- continue;
145
- const fp = path.join(cacheDir, entry);
146
- try {
147
- const st = fs.statSync(fp);
148
- if (st.mtimeMs > bestMtime) {
149
- bestMtime = st.mtimeMs;
150
- bestFile = fp;
151
- }
152
- }
153
- catch { }
154
- }
155
- }
156
- catch {
157
- return null;
158
- }
159
- if (!bestFile)
160
- return null;
161
- try {
162
- const raw = fs.readFileSync(bestFile, "utf8");
163
- return JSON.parse(raw);
164
- }
165
- catch {
166
- return null;
167
- }
168
- }
169
- export function writeBugState(cwd, state) {
170
- // Guard: never write state for PENDING bugIds — wait for real bugId capture.
171
- if (state.bugId.startsWith("PENDING-"))
172
- return;
173
- const fp = bugStateFilePath(cwd, state.bugId);
174
- const dir = path.dirname(fp);
175
- fs.mkdirSync(dir, { recursive: true });
176
- fs.writeFileSync(fp, JSON.stringify(state, null, 2), "utf8");
177
- }
178
- export function deleteBugState(cwd, bugId) {
179
- // Clean up all state files for this bug (all sessions)
180
- const cacheDir = path.join(cwd, ".forge", "cache");
181
- const statePrefix = `fix-bug-state-${bugId}-`;
182
- const debugPrefix = `fix-bug-debug-${bugId}`;
183
- try {
184
- const entries = fs.readdirSync(cacheDir);
185
- for (const entry of entries) {
186
- if ((entry.startsWith(statePrefix) && entry.endsWith(".json")) || entry.startsWith(debugPrefix)) {
187
- try {
188
- fs.unlinkSync(path.join(cacheDir, entry));
189
- }
190
- catch {
191
- /* non-fatal */
192
- }
193
- }
194
- }
195
- }
196
- catch {
197
- // non-fatal
198
- }
199
- }
200
- export function isBugStateStale(state) {
201
- const savedAt = new Date(state.savedAt).getTime();
202
- const ageMs = Date.now() - savedAt;
203
- const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
204
- return ageMs > sevenDaysMs;
205
- }
206
- export function readBugRecord(bugId, storeCli, cwd) {
207
- const result = spawnSync("node", [storeCli, "read", "bug", bugId], { cwd, encoding: "utf8" });
208
- if (result.status !== 0)
209
- return null;
210
- try {
211
- const raw = typeof result.stdout === "string" ? result.stdout : String(result.stdout);
212
- return JSON.parse(raw);
213
- }
214
- catch {
215
- return null;
216
- }
217
- }
218
- // Pure helper: next <PREFIX>-BUG-NNN ID given the existing bug IDs.
219
- // Only same-prefix bugs participate in the increment — exported for tests.
220
- // The prefix is config-owned (project.prefix) and identifier-validated by
221
- // loadGovernorProjectConfig, so it is safe to splice into a RegExp.
222
- export function computeNextBugId(bugIds, prefix) {
223
- const idPattern = new RegExp(`^${prefix}-BUG-(\\d+)$`);
224
- let maxNum = 0;
225
- for (const id of bugIds) {
226
- const m = idPattern.exec(id);
227
- if (m) {
228
- const n = parseInt(m[1], 10);
229
- if (n > maxNum)
230
- maxNum = n;
231
- }
232
- }
233
- return `${prefix}-BUG-${String(maxNum + 1).padStart(3, "0")}`;
234
- }
235
- // Pre-assigns a real <PREFIX>-BUG-NNN ID by listing existing bugs and
236
- // incrementing. Prefix comes from .forge/config.json project.prefix — the
237
- // hardcoded FORGE prefix minted phantom FORGE-BUG-* records in any project
238
- // with a different prefix (CART testbench incident, FORGE-BUG-043 class).
239
- export function assignNextBugId(storeCli, cwd, prefix = "FORGE") {
240
- const result = spawnSync("node", [storeCli, "list", "bug", "--json"], { cwd, encoding: "utf8" });
241
- let bugIds = [];
242
- if (result.status === 0 && result.stdout) {
243
- try {
244
- const bugs = JSON.parse(result.stdout);
245
- if (Array.isArray(bugs)) {
246
- bugIds = bugs.map((b) => String(b.bugId ?? ""));
247
- }
248
- }
249
- catch {
250
- /* empty store — start from 1 */
251
- }
252
- }
253
- return computeNextBugId(bugIds, prefix);
254
- }
255
- // Extracts the first canonical <PREFIX>-BUG-NNN referenced in a bug-report
256
- // text (e.g. the "**Bug ID**: CART-BUG-001" header line BUG_REPORT.md files
257
- // carry). Used by the @file intake path so /forge:fix-bug @BUG_REPORT.md
258
- // operates on the referenced store record instead of minting a duplicate.
259
- // Prefix is config-owned and identifier-validated (safe in a RegExp).
260
- export function extractBugIdFromReportText(text, prefix) {
261
- const m = text.match(new RegExp(`\\b${prefix}-BUG-\\d+\\b`));
262
- return m ? m[0] : null;
263
- }
264
- // Pre-creates a minimal bug record so the subagent has a real ID to work with.
265
- export function preCreateBug(bugId, title, storeCli, cwd) {
266
- const data = {
267
- bugId,
268
- title,
269
- severity: "minor",
270
- status: "reported",
271
- path: `engineering/bugs/${bugId}`,
272
- reportedAt: new Date().toISOString(),
273
- };
274
- const result = spawnSync("node", [storeCli, "write", "bug", JSON.stringify(data)], { cwd, encoding: "utf8" });
275
- return result.status === 0;
276
- }
277
- export function readBugVerdict(bugRecord, phaseRole, summaryKeyByRole) {
278
- if (!bugRecord)
279
- return "missing";
280
- // Approve phase: read approve summary verdict (set via set-bug-summary).
281
- // The forge v0.44.0 contract makes summaries.approve.verdict the canonical
282
- // approve signal for bugs — `bug.status` does NOT carry an "approved"
283
- // value (that enum was dropped). See read-verdict.cjs §
284
- // BUG_PHASE_VERDICT_SOURCE for the matching plugin-side wiring.
285
- if (phaseRole === "approve") {
286
- const summaryKey = summaryKeyByRole["approve"];
287
- if (summaryKey) {
288
- const summaries = bugRecord.summaries ?? {};
289
- const blob = summaries[summaryKey];
290
- if (blob && typeof blob === "object") {
291
- const verdict = blob?.verdict;
292
- if (typeof verdict === "string") {
293
- if (verdict === "approved")
294
- return "approved";
295
- if (verdict === "revision")
296
- return "revision";
297
- }
298
- }
299
- }
300
- return "missing";
301
- }
302
- // Commit phase: read bug status directly. Terminal target is `fixed`.
303
- if (phaseRole === "commit") {
304
- if (bugRecord.status === "fixed")
305
- return "approved";
306
- // in-progress means commit did not advance status — treat as revision-needed.
307
- if (bugRecord.status === "in-progress")
308
- return "revision";
309
- return "missing";
310
- }
311
- // Review phases: read from summaries via key map.
312
- const summaryKey = summaryKeyByRole[phaseRole];
313
- if (!summaryKey)
314
- return "missing";
315
- const summaries = bugRecord.summaries ?? {};
316
- const blob = summaries[summaryKey];
317
- if (!blob || typeof blob !== "object")
318
- return "missing";
319
- const verdict = blob?.verdict;
320
- if (typeof verdict !== "string")
321
- return "missing";
322
- if (verdict === "approved")
323
- return "approved";
324
- if (verdict === "revision")
325
- return "revision";
326
- return "missing";
327
- }
328
- // ── Bug body composition ──────────────────────────────────────────────────
329
- export function composeBugBody(subWorkflowMd, bugId, phaseRole, bugStatusBeforePhase, summariesBlock) {
330
- // Entity-kind override block prepended before workflow body.
331
- // Conforms to forge v0.44.x meta-fix-bug contract:
332
- // - bug.status enum is {reported, triaged, in-progress, fixed}; `fixed` is terminal.
333
- // - `approved` and `verified` are NOT valid bug status values (dropped in v0.44.0).
334
- // - Approve phase: NO status write. Architect writes summaries.approve.verdict
335
- // via set-bug-summary; verdict signal IS the summary (read by
336
- // read-verdict.cjs § BUG_PHASE_VERDICT_SOURCE).
337
- // - Commit phase: status → fixed (the only status transition post-triage).
338
- //
339
- // Earlier revisions of this prompt told the architect to write
340
- // `update-status bug ... approved` and the engineer to write `... verified`.
341
- // Those instructions produced the FORGE-BUG-002 trap (LLM-translation of
342
- // task-shaped approve workflow → illegal transition through a terminal state).
343
- // The new contract removes the trap at its source.
344
- const entityKindLines = [
345
- `Bug ID: ${bugId}`,
346
- "",
347
- "⚠ ENTITY KIND OVERRIDE: This is a bug, not a task.",
348
- "- All `update-status` calls must use entity kind `bug` (not `task`).",
349
- "- Approve phase: NO status write. Write the approval verdict via set-bug-summary:",
350
- ` node "$FORGE_ROOT/tools/store-cli.cjs" set-bug-summary ${bugId} approve <APPROVE-SUMMARY.json>`,
351
- ` The summary's "verdict" field MUST be "approved" or "revision". The downstream commit gate reads this, not bug.status.`,
352
- `- Commit phase: on successful git commit, run \`node "$FORGE_ROOT/tools/store-cli.cjs" update-status bug ${bugId} status fixed\` (terminal).`,
353
- `- Do NOT write "approved" or "verified" to bug.status — those values were removed from the schema in forge v0.44.0.`,
354
- `- Do NOT reference task-specific status values (e.g., "committed") or task entity kind.`,
355
- "- CRITICAL: All `set-summary` calls must use `set-bug-summary` (not `set-summary`).",
356
- ` e.g. node "$FORGE_ROOT/tools/store-cli.cjs" set-bug-summary ${bugId} review_plan <jsonFile>`,
357
- `- Preflight gate: use \`--bug\` flag (not \`--task\`). e.g. node "$FORGE_ROOT/tools/preflight-gate.cjs" --phase review-plan --bug ${bugId}`,
358
- "- Skip re-running preflight-gate — the orchestrator already checked it. Proceed directly to the review.",
359
- 'Any workflow text that says "task" should be read as "bug" for this context.',
360
- ];
361
- // Phase-specific reinforcement when the orchestrator can name the current status.
362
- if (phaseRole === "approve" && bugStatusBeforePhase) {
363
- entityKindLines.push(`- Approve phase (reinforce): bug.status is currently '${bugStatusBeforePhase}' and MUST NOT change in this phase. Record verdict in summaries.approve only.`);
364
- }
365
- if (phaseRole === "commit" && bugStatusBeforePhase) {
366
- entityKindLines.push(`- Commit phase: after the git commit lands, transition bug.status from '${bugStatusBeforePhase}' to 'fixed'.`);
367
- }
368
- // FORGE-BUG-040: the triage-phase hint block previously prepended here
369
- // compensated for the orchestrator-only fix_bug.md being delivered to
370
- // the triage subagent. With the new phase-scoped triage.md sub-workflow,
371
- // the route-field contract and Path A/B criteria are documented natively
372
- // in the workflow body — no compose-time injection required.
373
- const parts = [
374
- `Read the workflow below and follow it. Bug ID: ${bugId}.`,
375
- "",
376
- "---",
377
- "",
378
- entityKindLines.join("\n"),
379
- "",
380
- "---",
381
- "",
382
- ];
383
- if (summariesBlock) {
384
- parts.push(summariesBlock, "", "---", "");
385
- }
386
- parts.push(subWorkflowMd.trim());
387
- return parts.join("\n");
388
- }
389
- // ── BugId capture via tool_execution_end ──────────────────────────────────
390
- const BUG_WRITE_TOOL_NAMES = new Set(["write", "store-cli", "bash", "forge_store"]);
391
- /**
392
- * Scan tool_execution_end events to extract the bugId written by a triage
393
- * subagent. Returns the LAST matching tool call's bugId, or null if none found.
394
- *
395
- * In pi runtime, the forge_store tool is registered as "forge_store" (not
396
- * "store-cli"). In Claude Code runtime, subagents may shell out via Bash.
397
- * This function covers all three paths.
398
- */
399
- export function extractBugIdFromEvents(events, prefix = "FORGE") {
400
- // Prefix is config-owned (project.prefix) and identifier-validated by
401
- // loadGovernorProjectConfig — the previous hardcoded FORGE-BUG- pattern
402
- // missed every capture in differently-prefixed projects (CART incident).
403
- const idPattern = new RegExp(`${prefix}-BUG-\\d+`);
404
- const idPrefix = `${prefix}-BUG-`;
405
- let lastBugId = null;
406
- for (const event of events) {
407
- if (!event.toolName)
408
- continue;
409
- // Check for store-cli write bug calls (Claude Code runtime)
410
- if (event.toolName === "store-cli") {
411
- const result = event.result;
412
- if (typeof result === "string") {
413
- const match = result.match(idPattern);
414
- if (match)
415
- lastBugId = match[0];
416
- }
417
- else if (result && typeof result === "object") {
418
- const obj = result;
419
- if (typeof obj.bugId === "string" && obj.bugId.startsWith(idPrefix)) {
420
- lastBugId = obj.bugId;
421
- }
422
- }
423
- }
424
- // Check for forge_store tool calls (pi runtime)
425
- // The pi extension registers the tool as "forge_store", not "store-cli".
426
- if (event.toolName === "forge_store" && event.result != null) {
427
- const output = typeof event.result === "string" ? event.result : JSON.stringify(event.result);
428
- const match = output.match(idPattern);
429
- if (match)
430
- lastBugId = match[0];
431
- }
432
- // Also check for write operations to .forge/store/bugs/
433
- if (event.toolName === "write" && typeof event.result === "string") {
434
- const match = event.result.match(idPattern);
435
- if (match)
436
- lastBugId = match[0];
437
- }
438
- // Bash events: subagents shelling out via Bash may run "store-cli write bug".
439
- // Only match when output includes store-cli, write, and bug together
440
- // to avoid false positives from unrelated Bash commands that happen to
441
- // mention a bug ID in a different context.
442
- if (event.toolName === "bash" && event.result != null) {
443
- const output = typeof event.result === "string" ? event.result : JSON.stringify(event.result);
444
- if (output.includes("store-cli") && output.includes("write") && output.includes("bug")) {
445
- const match = output.match(idPattern);
446
- if (match)
447
- lastBugId = match[0];
448
- }
449
- }
450
- }
451
- return lastBugId;
452
- }
453
- const STATUS_KEY = "forge:fix-bug";
454
- const MESSAGE_KEY = "forge:fix-bug:message";
455
- export async function runBugPipeline(opts) {
456
- // Thin wrapper: capture the orchestrator transcript writer the inner
457
- // pipeline creates, surface its path on the result (so callers can
458
- // archive the run), and GUARANTEE a pipeline-end on every outcome —
459
- // cancel/halt/failure paths that return without recording one left runs
460
- // archived as "incomplete" instead of their true outcome.
461
- let orchTranscript;
462
- const result = await runBugPipelineInner(opts, (writer) => {
463
- orchTranscript = writer;
464
- });
465
- if (orchTranscript) {
466
- orchTranscript.close(transcriptOutcomeFor(result.status), result.lastError);
467
- result.orchestratorTranscriptPath = orchTranscript.filePath;
468
- }
469
- return result;
470
- }
471
- async function runBugPipelineInner(opts, onOrchestratorTranscript) {
472
- const { bugId: initialBugId, originalArg, isNewBug, cwd, ctx, forgeRoot, storeCli, preflightGate, registry, resumeFromState, } = opts;
473
- const tree = getOrchestratorTree();
474
- // Mutable bugId — for new bugs, pre-assign a real FORGE-BUG-NNN ID
475
- // before triage so the subagent never needs to create or discover one.
476
- // This replaces the fragile PENDING→capture pattern where the subagent was
477
- // expected to create the bug record and we'd fish the ID from events.
478
- let bugId = initialBugId;
479
- let currentPhaseIndex = resumeFromState?.phaseIndex ?? 0;
480
- const iterationCounts = resumeFromState?.iterationCounts ?? {};
481
- // Per-role dispatch counter for OrchestratorTree node identity. Distinct
482
- // from iterationCounts (which only tracks review-verdict revisions): every
483
- // dispatch of a role — including a plan-fix re-run after a review
484
- // loopback — gets its own `<bugId>:<role>:<attempt>` node, so the
485
- // dashboard renders one leaf per run in dispatch order instead of merging
486
- // attempts into one node (CART-BUG-003 dashboard regression). Seeded from
487
- // resume state so resumed runs continue numbering past prior attempts.
488
- const dispatchCounts = { ...(resumeFromState?.iterationCounts ?? {}) };
489
- let lastModel;
490
- let lastProvider;
491
- // ── Per-persona model routing (Plan 16) ─────────────────────────────────
492
- // Load layered routing config once at bug-pipeline entry. Empty / absent
493
- // config produces inherit for every phase — no behaviour change. Pipeline
494
- // name "fix-bug" lets users configure per-phase overrides distinctly from
495
- // task pipelines under pipelines["fix-bug"] in their routing config.
496
- // N-B-E: surface schema errors to caller (Decision 9 — orchestrators fail-fast).
497
- // See doc/decisions/layered-config-error-policy.md.
498
- const { merged: modelRoutingConfig, errors: layeredConfigErrors } = loadLayeredConfig(cwd);
499
- if (layeredConfigErrors.length > 0) {
500
- for (const e of layeredConfigErrors) {
501
- ctx.ui.notify(`× forge:fix-bug — forge-cli config schema error: ${e}`, "error");
502
- }
503
- return {
504
- status: "failed",
505
- lastPhaseIndex: currentPhaseIndex,
506
- iterationCounts,
507
- lastError: `forge-cli config schema errors: ${layeredConfigErrors.join("; ")}`,
508
- };
509
- }
510
- // Pre-flight validation — same shape as run-task / run-sprint.
511
- // FORGE-S25-T17: delegated to orchestrator-preflight.ts (H-13).
512
- {
513
- const personasDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "forge-payload", ".base-pack", "personas");
514
- const personaCatalogue = readPersonaDirBug(personasDir);
515
- const forgeCfgPath = path.join(cwd, ".forge", "config.json");
516
- const pipelineCatalogue = readPipelineNamesBug(forgeCfgPath);
517
- const availableModels = ctx.modelRegistry?.getAvailable?.() ?? [];
518
- const preflightResult = runOrchestratorPreflight({
519
- mode: "task",
520
- ctx,
521
- notifyPrefix: "forge:fix-bug",
522
- personaCatalogue,
523
- pipelineCatalogue,
524
- modelRoutingConfig,
525
- availableModels: availableModels.map((m) => ({ provider: m.provider, id: m.id })),
526
- });
527
- if (!preflightResult.proceed) {
528
- return {
529
- ...preflightResult.result,
530
- lastPhaseIndex: currentPhaseIndex,
531
- iterationCounts,
532
- };
533
- }
534
- }
535
- // ── Orchestrator transcript ──────────────────────────────────────────
536
- // One JSONL file per pipeline run, ISO-prefixed in its filename so
537
- // review-loop iterations (plan → review → plan → review) preserve
538
- // their own logs instead of overwriting each other. Captures every
539
- // ctx.ui.notify line plus structured phase-boundary events.
540
- const orchTranscript = new OrchestratorTranscriptWriter({
541
- cwd,
542
- entityKind: "bug",
543
- entityId: bugId,
544
- });
545
- onOrchestratorTranscript(orchTranscript);
546
- const __origNotify = ctx.ui.notify.bind(ctx.ui);
547
- ctx.ui.notify = ((msg, level) => {
548
- __origNotify(msg, level);
549
- orchTranscript.record({
550
- kind: "notify",
551
- ts: new Date().toISOString(),
552
- level: (level ?? "info"),
553
- message: typeof msg === "string" ? msg : String(msg),
554
- });
555
- });
556
- const pipelineStartMs = Date.now();
557
- try {
558
- while (currentPhaseIndex < BUG_PHASES.length) {
559
- // ── Between-phase cancellation gate ────────────────────────────
560
- if (opts.signal?.aborted) {
561
- ctx.ui.notify(`⊘ forge:fix-bug — ${bugId} cancelled by user.`, "info");
562
- registry.completePhase(bugId, BUG_PHASES[currentPhaseIndex]?.role ?? "unknown", "cancelled");
563
- registry.confirmCancelled(bugId);
564
- // ADR-S21-01: preserve state file so cancelled runs are resumable
565
- writeBugState(cwd, {
566
- bugId,
567
- phaseIndex: currentPhaseIndex,
568
- iterationCounts,
569
- halted: false,
570
- status: "cancelled",
571
- lastError: undefined,
572
- savedAt: new Date().toISOString(),
573
- });
574
- return { status: "cancelled", lastPhaseIndex: currentPhaseIndex, iterationCounts };
575
- }
576
- const phase = BUG_PHASES[currentPhaseIndex];
577
- if (!phase) {
578
- ctx.ui.notify(`× forge:fix-bug — invalid phase index ${currentPhaseIndex}`, "error");
579
- return {
580
- status: "failed",
581
- lastPhaseIndex: currentPhaseIndex,
582
- iterationCounts,
583
- lastError: `invalid phase index ${currentPhaseIndex}`,
584
- };
585
- }
586
- ctx.ui.setStatus?.(STATUS_KEY, `fix-bug ${bugId}: phase ${currentPhaseIndex + 1}/${BUG_PHASES.length} (${phase.role})`);
587
- ctx.ui.notify(`→ ${bugId}: ${phase.role} (phase ${currentPhaseIndex + 1}/${BUG_PHASES.length})`, "info");
588
- orchTranscript.record({
589
- kind: "phase-start",
590
- ts: new Date().toISOString(),
591
- phase: phase.role,
592
- phaseIndex: currentPhaseIndex,
593
- phaseCount: BUG_PHASES.length,
594
- attempt: (iterationCounts[phase.role] ?? 0) + 1,
595
- workflowFile: phase.workflowFile,
596
- persona: phase.personaNoun,
597
- });
598
- const subWorkflowPath = path.join(cwd, ".forge", "workflows", `${phase.workflowFile}.md`);
599
- // ── Read sub-workflow ─────────────────────────────────────────
600
- let subWorkflowMd;
601
- let subWorkflowAudience = "any";
602
- try {
603
- const loaded = loadWorkflow(subWorkflowPath);
604
- subWorkflowMd = loaded.rawMarkdown;
605
- subWorkflowAudience = loaded.audience;
606
- }
607
- catch (err) {
608
- const e = err;
609
- ctx.ui.notify(`× forge:fix-bug — failed to read sub-workflow for ${phase.role}: ${e.message ?? "unknown"}`, "error");
610
- writeBugState(cwd, {
611
- bugId,
612
- phaseIndex: currentPhaseIndex,
613
- iterationCounts,
614
- halted: true,
615
- lastError: `sub-workflow read failed: ${e.message ?? "unknown"}`,
616
- savedAt: new Date().toISOString(),
617
- });
618
- return {
619
- status: "failed",
620
- lastPhaseIndex: currentPhaseIndex,
621
- iterationCounts,
622
- lastError: `sub-workflow read failed: ${e.message ?? "unknown"}`,
623
- };
624
- }
625
- // ── 6a. Phase skip (state-aware, defense-in-depth) ─────────────
626
- // Belt-and-suspenders alongside the explicit summaries.triage.route
627
- // branch (handled in section 6c below). Some subagents in some
628
- // runtimes still go end-to-end during triage instead of just triaging
629
- // — rather than roll back the work they did, skip non-review phases
630
- // whose output is already reflected in the bug status. Review phases
631
- // are never skipped — they are quality gates that must always run.
632
- //
633
- // Post-v0.44.0: terminal status is `fixed` only. `approved` and
634
- // `verified` are no longer valid bug status values; references
635
- // removed.
636
- const PHASE_SKIP_STATES = {
637
- "plan-fix": new Set(["fixed"]),
638
- implement: new Set(["fixed"]),
639
- commit: new Set(["fixed"]), // commit writes the terminal status; skip if already there
640
- };
641
- const bugNow = readBugRecord(bugId, storeCli, cwd);
642
- const skipStates = PHASE_SKIP_STATES[phase.role];
643
- if (skipStates && bugNow?.status && skipStates.has(bugNow.status) && !phase.isReview) {
644
- ctx.ui.notify(`⊘ forge:fix-bug — skipping ${phase.role}: bug ${bugId} is already '${bugNow.status}' (work already done).`, "info");
645
- // Write a synthetic "approved" summary so downstream `after` predecessor
646
- // verdict checks find a verdict and don't block review phases.
647
- const summaryKey = BUG_SUMMARY_KEY_BY_ROLE[phase.role];
648
- if (summaryKey) {
649
- const synthSummary = {
650
- objective: `Phase ${phase.role} skipped — bug already ${bugNow.status}`,
651
- findings: ["Subagent completed fix during triage (Path A); phase output implicitly satisfied."],
652
- // Non-review phases should have verdict "n/a" — the phase
653
- // didn't produce a gate verdict. This matches the `after
654
- // <phase> = n/a` preflight gate contract. Review phases
655
- // use "approved" since they are gate phases.
656
- verdict: phase.isReview ? "approved" : "n/a",
657
- written_at: new Date().toISOString(),
658
- };
659
- const synthFile = path.join(cwd, ".forge", "cache", `synthetic-summary-${bugId}-${summaryKey}.json`);
660
- fs.writeFileSync(synthFile, JSON.stringify(synthSummary, null, 2), "utf8");
661
- const synthResult = spawnSync("node", [storeCli, "set-bug-summary", bugId, summaryKey, synthFile], {
662
- cwd,
663
- encoding: "utf8",
664
- });
665
- if (synthResult.status !== 0) {
666
- ctx.ui.notify(`⚠ forge:fix-bug — synthetic summary write failed for ${phase.role}: ${String(synthResult.stderr).trim()}`, "warning");
667
- }
668
- try {
669
- fs.unlinkSync(synthFile);
670
- }
671
- catch {
672
- /* non-fatal */
673
- }
674
- }
675
- currentPhaseIndex++;
676
- continue;
677
- }
678
- // ── 6b. Preflight gate ────────────────────────────────────────
679
- // Skip preflight gate for triage phase of new bugs (PENDING- placeholder)
680
- // because the bug record doesn't exist yet — gates referencing bug fields
681
- // would always fail.
682
- //
683
- // Also skip for review phases when the bug is already in a terminal
684
- // state ("fixed"). Path A bugs get fixed during triage, then the
685
- // preflight gate's `forbid bug.status == fixed` and `after implement
686
- // = n/a` checks block review-code/review-plan even though we
687
- // deliberately want to run those reviews. The review subagent handles
688
- // the already-fixed scenario internally.
689
- const pendingBugId = bugId.startsWith("PENDING-");
690
- const bugAlreadyFixed = bugNow?.status === "fixed" && phase.isReview;
691
- if (!pendingBugId && !bugAlreadyFixed && fs.existsSync(preflightGate)) {
692
- const preflightOutcome = runPreflightGateWithData(preflightGate, phase.role, bugId, cwd, "bug");
693
- if (preflightOutcome.result === "halt") {
694
- // Render structured failure reason if available.
695
- if (preflightOutcome.gateFailure) {
696
- ctx.ui.notify(`× forge:fix-bug — preflight gate failed for phase ${phase.role} ` +
697
- `[${preflightOutcome.gateFailure.reasonCode}]: ${preflightOutcome.gateFailure.detail}`, "error");
698
- }
699
- else {
700
- ctx.ui.notify(`× forge:fix-bug — preflight gate failed for phase ${phase.role} (exit 1); halting.`, "error");
701
- }
702
- writeBugState(cwd, {
703
- bugId,
704
- phaseIndex: currentPhaseIndex,
705
- iterationCounts,
706
- halted: true,
707
- lastError: `preflight gate exit 1 for ${phase.role}`,
708
- savedAt: new Date().toISOString(),
709
- });
710
- // Spawn halt-recovery advisor (Tier 1, best-effort — non-fatal).
711
- if (preflightOutcome.gateFailure) {
712
- const advisorModel = resolveAdvisorModel(modelRoutingConfig, ctx.model);
713
- void runHaltAdvisor({
714
- gateFailure: preflightOutcome.gateFailure,
715
- advisorModel,
716
- taskId: bugId,
717
- cwd,
718
- ctx: { ui: ctx.ui },
719
- forgeRoot,
720
- });
721
- }
722
- return {
723
- status: "halted",
724
- lastPhaseIndex: currentPhaseIndex,
725
- iterationCounts,
726
- lastError: `preflight gate exit 1 for ${phase.role}`,
727
- };
728
- }
729
- if (preflightOutcome.result === "escalate") {
730
- ctx.ui.notify(`× forge:fix-bug — preflight gate escalated for phase ${phase.role} (exit 2); manual intervention required.`, "error");
731
- writeBugState(cwd, {
732
- bugId,
733
- phaseIndex: currentPhaseIndex,
734
- iterationCounts,
735
- halted: true,
736
- lastError: `preflight gate exit 2 (escalate) for ${phase.role}`,
737
- savedAt: new Date().toISOString(),
738
- });
739
- return {
740
- status: "escalated",
741
- lastPhaseIndex: currentPhaseIndex,
742
- iterationCounts,
743
- lastError: `preflight gate exit 2 (escalate) for ${phase.role}`,
744
- };
745
- }
746
- }
747
- // ── 6. Materialization-marker check ───────────────────────────
748
- // FORGE-BUG-040: every BUG phase is now a true `audience: subagent`
749
- // sub-workflow — triage / plan-fix / implement no longer alias to
750
- // fix_bug.md. The marker check is therefore unconditional; a missing
751
- // marker is a hard failure on the first dispatch.
752
- {
753
- const markerCheck = checkMaterialization(subWorkflowPath, subWorkflowMd);
754
- if (!markerCheck.ok) {
755
- for (const marker of markerCheck.missing) {
756
- ctx.ui.notify(`× workflow regression: ${marker} not found in ${subWorkflowPath}`, "error");
757
- }
758
- return {
759
- status: "failed",
760
- lastPhaseIndex: currentPhaseIndex,
761
- iterationCounts,
762
- lastError: `materialization markers missing: ${markerCheck.missing.join(", ")}`,
763
- };
764
- }
765
- }
766
- // ── 5. Audience check ─────────────────────────────────────────
767
- // FORGE-BUG-040: every BUG phase is a true `audience: subagent`
768
- // workflow now; the previous `fix_bug.md` audience-bypass is gone.
769
- const audienceOk = CallerContextStore.asSubagent(phase.role, () => assertAudience({ workflowName: phase.workflowFile, audience: subWorkflowAudience }, ctx));
770
- if (!audienceOk) {
771
- writeBugState(cwd, {
772
- bugId,
773
- phaseIndex: currentPhaseIndex,
774
- iterationCounts,
775
- halted: true,
776
- lastError: `audience check failed for ${phase.workflowFile}`,
777
- savedAt: new Date().toISOString(),
778
- });
779
- return {
780
- status: "failed",
781
- lastPhaseIndex: currentPhaseIndex,
782
- iterationCounts,
783
- lastError: `audience check failed for ${phase.workflowFile}`,
784
- };
785
- }
786
- // ── Persona load ──────────────────────────────────────────────
787
- let persona;
788
- try {
789
- persona = loadForgePersona(phase.personaNoun, cwd);
790
- }
791
- catch (err) {
792
- const e = err;
793
- ctx.ui.notify(`× forge:fix-bug — persona '${phase.personaNoun}' not found for phase ${phase.role}: ${e.message ?? "unknown"}. ` +
794
- "Run /forge:regenerate to materialize persona files.", "error");
795
- writeBugState(cwd, {
796
- bugId,
797
- phaseIndex: currentPhaseIndex,
798
- iterationCounts,
799
- halted: true,
800
- lastError: `persona load failed: ${e.message ?? "unknown"}`,
801
- savedAt: new Date().toISOString(),
802
- });
803
- return {
804
- status: "failed",
805
- lastPhaseIndex: currentPhaseIndex,
806
- iterationCounts,
807
- lastError: `persona load failed: ${e.message ?? "unknown"}`,
808
- };
809
- }
810
- // ── Read bug record for current status ────────────────────────
811
- // Skip for PENDING bugIds (bug doesn't exist yet).
812
- const bugRecordBefore = pendingBugId ? null : readBugRecord(bugId, storeCli, cwd);
813
- const bugStatusBeforePhase = bugRecordBefore?.status;
814
- // ── 4. Dispatch via runForgeSubagent (IL10) ───────────────────
815
- // NEVER sendKickoff here — that would reproduce issue #30.
816
- // Carry forward prior phase summaries (forge-cli#19).
817
- const bugSummariesBlock = currentPhaseIndex > 0 ? buildSummariesBlock(bugRecordBefore?.summaries) || undefined : undefined;
818
- let bugBody = composeBugBody(subWorkflowMd, bugId, phase.role, bugStatusBeforePhase, bugSummariesBlock);
819
- // For new bugs in triage, prepend the original free-form text so the
820
- // subagent knows the user-provided bug description to triage.
821
- // The bug record already exists (pre-created with status "reported"),
822
- // so the subagent should update it, not create a new one.
823
- if (phase.role === "triage" && isNewBug && originalArg) {
824
- bugBody = `Bug description: ${originalArg}\n\n---\n\n${bugBody}`;
825
- }
826
- // Phase-scoped progress counters
827
- const phaseStart = Date.now();
828
- // Track tool_execution_end events for bugId capture (Findings #1, #2).
829
- const toolExecutionEvents = [];
830
- // Stabilization debug log
831
- // Skip for PENDING bugIds — create after real bugId is captured.
832
- // Disable entirely with FORGE_DEBUG_LOG=0.
833
- const debugLogDisabled = process.env.FORGE_DEBUG_LOG === "0";
834
- let debugLogPath = null;
835
- let writeDebug = () => { };
836
- if (!pendingBugId && !debugLogDisabled) {
837
- debugLogPath = path.join(cwd, ".forge", "cache", `fix-bug-debug-${bugId}.jsonl`);
838
- writeDebug = (rec) => {
839
- try {
840
- fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
841
- // Cap at 10 MB: truncate head when size exceeds the cap.
842
- try {
843
- const st = fs.statSync(debugLogPath);
844
- if (st.size > 10 * 1024 * 1024) {
845
- const all = fs.readFileSync(debugLogPath, "utf8");
846
- const lines = all.split("\n");
847
- // Keep last 80% of lines
848
- const keep = Math.floor(lines.length * 0.8);
849
- fs.writeFileSync(debugLogPath, lines.slice(-keep).join("\n"), "utf8");
850
- }
851
- }
852
- catch {
853
- /* file may not exist yet */
854
- }
855
- fs.appendFileSync(debugLogPath, `${JSON.stringify({ ts: new Date().toISOString(), phase: phase.role, ...rec })}\n`, "utf8");
856
- }
857
- catch {
858
- // non-fatal; debug log is best-effort
859
- }
860
- };
861
- }
862
- writeDebug({ kind: "phase_start", phaseIndex: currentPhaseIndex });
863
- registry.startPhase(bugId, phase.role, currentPhaseIndex);
864
- // Bridge: register phase in OrchestratorTree. Node identity is
865
- // per-dispatch (see dispatchCounts above) — never reuse an ID for
866
- // a re-dispatched role.
867
- const iteration = (dispatchCounts[phase.role] = (dispatchCounts[phase.role] ?? 0) + 1);
868
- const phaseNodeId = `${bugId}:${phase.role}:${iteration}`;
869
- tree.startNode(phaseNodeId, {
870
- parentId: bugId,
871
- label: `${phase.role}:${iteration}`,
872
- kind: "leaf",
873
- // Full body — display clamping/expansion is the view's decision
874
- // (the tree applies only a storage cap).
875
- promptPreview: bugBody,
876
- });
877
- const refreshStatus = () => {
878
- if (process.env.FORGE_VERBOSE !== "1")
879
- return;
880
- const elapsed = Math.floor((Date.now() - phaseStart) / 1000);
881
- const tail = observer.state.lastTool ? ` · ${observer.state.lastTool}` : "";
882
- 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}`);
883
- };
884
- const observer = attachViewportObserver({
885
- registry,
886
- sessionId: bugId,
887
- phaseRole: phase.role,
888
- nodeId: phaseNodeId,
889
- beginHeader: `─── phase ${phase.role} begin ───`,
890
- writeDebug,
891
- notify: (msg, level) => ctx.ui.notify(msg, level),
892
- setStatusVerbose: process.env.FORGE_VERBOSE === "1" ? (k, v) => ctx.ui.setStatus?.(k, v) : undefined,
893
- verboseKeys: { messageKey: `${STATUS_KEY}:message` },
894
- afterEach: refreshStatus,
895
- });
896
- // Wrap the observer's onEvent to also capture tool_execution_end events
897
- // for bugId capture downstream (findings #1, #2), plus the first turn_end
898
- // per phase (IL10 visibility — stream-observed model id).
899
- let modelObservedLogged = false;
900
- const onSubagentEvent = (event) => {
901
- if (event?.type === "tool_execution_end") {
902
- toolExecutionEvents.push({ toolName: event.toolName, result: event.result });
903
- }
904
- if (!modelObservedLogged && event?.type === "turn_end" && event.message?.model) {
905
- modelObservedLogged = true;
906
- writeDebug({
907
- kind: "model_observed",
908
- provider: event.message.provider ?? null,
909
- model: event.message.model,
910
- });
911
- }
912
- observer.onEvent(event);
913
- };
914
- // Per-phase model resolution. When config is absent or cascade bottoms
915
- // out, resolves to inherit (model: undefined) — setModel is skipped and
916
- // pi's current model is used. IL10 still holds: result.model below is
917
- // the stream-observed runtime model, not whatever we requested here.
918
- const modelResolution = resolveModelForPhase("fix-bug", phase.role, phase.personaNoun, modelRoutingConfig);
919
- writeDebug({
920
- kind: "requested_model",
921
- requested: modelResolution.model ?? null,
922
- source: modelResolution.source,
923
- persona: phase.personaNoun,
924
- });
925
- let result;
926
- try {
927
- // FORGE-BUG-040: wrap the runForgeSubagent dispatch in the phase
928
- // caller context so downstream tool calls (forge_preflight,
929
- // forge_store update-status / set-bug-summary / set-summary / emit)
930
- // can verify the caller's phase matches the phase named in the
931
- // tool's arguments. This is the single setter of phase context
932
- // for the bug pipeline; the audience-test wrap above is a
933
- // short-lived test, not the canonical dispatch context.
934
- result = await CallerContextStore.asSubagent(phase.role, () => runForgeSubagent({
935
- persona,
936
- task: bugBody,
937
- cwd,
938
- exportTag: `${bugId}__${phase.role}`,
939
- tailLog: observer.state.tailLog,
940
- // Sprint-scoped if the bug is attached to one, else bug-scoped.
941
- // Keeps every phase of this bug-fix pipeline in a single cache
942
- // namespace so the system-prompt + persona prefix stays warm
943
- // across the ~10-minute phases.
944
- cacheSessionId: typeof bugRecordBefore?.sprintId === "string"
945
- ? `forge:${bugRecordBefore.sprintId}`
946
- : `forge:bug:${bugId}`,
947
- onEvent: onSubagentEvent,
948
- requestedModel: modelResolution.model,
949
- modelRegistry: ctx.modelRegistry,
950
- signal: opts.signal,
951
- customTools: opts.forgeToolDefs ? getSubagentTools(opts.forgeToolDefs, persona.name) : undefined,
952
- }));
953
- }
954
- catch (err) {
955
- const e = err;
956
- ctx.ui.notify(`× forge:fix-bug — runForgeSubagent threw for phase ${phase.role}: ${e.message ?? "unknown"}`, "error");
957
- tree.completeNode(phaseNodeId, "failed");
958
- writeBugState(cwd, {
959
- bugId,
960
- phaseIndex: currentPhaseIndex,
961
- iterationCounts,
962
- halted: true,
963
- lastError: `runForgeSubagent threw: ${e.message ?? "unknown"}`,
964
- savedAt: new Date().toISOString(),
965
- });
966
- return {
967
- status: "failed",
968
- lastPhaseIndex: currentPhaseIndex,
969
- iterationCounts,
970
- lastError: `runForgeSubagent threw: ${e.message ?? "unknown"}`,
971
- };
972
- }
973
- // Close this dispatch's tree node with final usage/model — MUST be
974
- // called on every exit path below (failure, halt, escalation,
975
- // loopback, advance). A node left `running` keeps a live spinner
976
- // in the dashboard forever AND absorbs the next same-role
977
- // dispatch's telemetry via the observer's legacy role-prefix scan.
978
- const finishPhaseNode = (status) => {
979
- tree.setNodeUsage(phaseNodeId, {
980
- input: result.usage.input,
981
- output: result.usage.output,
982
- cacheRead: result.usage.cacheRead,
983
- });
984
- if (result.model)
985
- tree.setNodeModel(phaseNodeId, result.model, result.provider ?? "");
986
- tree.completeNode(phaseNodeId, status);
987
- };
988
- // ── Post-subagent abort detection ─────────────────────────────────
989
- if (result.stopReason === "aborted" || opts.signal?.aborted) {
990
- ctx.ui.notify(`⊘ forge:fix-bug — ${bugId} phase ${phase.role} cancelled.`, "info");
991
- registry.completePhase(bugId, phase.role, "cancelled");
992
- tree.completeNode(phaseNodeId, "cancelled");
993
- registry.confirmCancelled(bugId);
994
- // Bug B parity with run-task: account billed tokens of the aborted attempt.
995
- // sprintId "bugs" = routing key for bug events (matches success path).
996
- // The optional `type` token is omitted — verdict carries the outcome.
997
- emitIncompletePhaseEvent({
998
- emitCtx: {
999
- entityType: "bug",
1000
- bugId,
1001
- sprintId: "bugs",
1002
- phase,
1003
- iteration: (iterationCounts[phase.role] ?? 0) + 1,
1004
- startMs: phaseStart,
1005
- endMs: Date.now(),
1006
- model: result.model ?? "unknown",
1007
- provider: result.provider ?? "unknown",
1008
- usage: {
1009
- input: result.usage.input,
1010
- output: result.usage.output,
1011
- cacheRead: result.usage.cacheRead,
1012
- cacheWrite: result.usage.cacheWrite,
1013
- },
1014
- judgement: undefined,
1015
- storeCli,
1016
- cwd,
1017
- },
1018
- outcome: "aborted",
1019
- notes: result.errorMessage ?? result.stopReason ?? undefined,
1020
- onDebug: writeDebug,
1021
- });
1022
- // ADR-S21-01: preserve state file so cancelled runs are resumable
1023
- writeBugState(cwd, {
1024
- bugId,
1025
- phaseIndex: currentPhaseIndex,
1026
- iterationCounts,
1027
- halted: false,
1028
- status: "cancelled",
1029
- lastError: undefined,
1030
- savedAt: new Date().toISOString(),
1031
- });
1032
- return { status: "cancelled", lastPhaseIndex: currentPhaseIndex, iterationCounts };
1033
- }
1034
- // ── Halt-on-failure ───────────────────────────────────────────
1035
- if (result.exitCode !== 0) {
1036
- ctx.ui.notify(`× forge:fix-bug — phase ${phase.role} failed (exit ${result.exitCode})` +
1037
- (result.errorMessage ? `: ${result.errorMessage}` : "") +
1038
- (result.stopReason ? ` [${result.stopReason}]` : ""), "error");
1039
- finishPhaseNode("failed");
1040
- // Bug B parity with run-task: account billed tokens of the failed attempt.
1041
- emitIncompletePhaseEvent({
1042
- emitCtx: {
1043
- entityType: "bug",
1044
- bugId,
1045
- sprintId: "bugs",
1046
- phase,
1047
- iteration: (iterationCounts[phase.role] ?? 0) + 1,
1048
- startMs: phaseStart,
1049
- endMs: Date.now(),
1050
- model: result.model ?? "unknown",
1051
- provider: result.provider ?? "unknown",
1052
- usage: {
1053
- input: result.usage.input,
1054
- output: result.usage.output,
1055
- cacheRead: result.usage.cacheRead,
1056
- cacheWrite: result.usage.cacheWrite,
1057
- },
1058
- judgement: undefined,
1059
- storeCli,
1060
- cwd,
1061
- },
1062
- outcome: "failed",
1063
- notes: result.errorMessage ?? result.stopReason ?? undefined,
1064
- onDebug: writeDebug,
1065
- });
1066
- writeBugState(cwd, {
1067
- bugId,
1068
- phaseIndex: currentPhaseIndex,
1069
- iterationCounts,
1070
- halted: true,
1071
- lastError: result.errorMessage ?? result.stopReason ?? "subagent exit non-zero",
1072
- savedAt: new Date().toISOString(),
1073
- });
1074
- return {
1075
- status: "failed",
1076
- lastPhaseIndex: currentPhaseIndex,
1077
- iterationCounts,
1078
- lastError: result.errorMessage ?? result.stopReason ?? "subagent exit non-zero",
1079
- };
1080
- }
1081
- // Capture model/provider from subagent result.
1082
- if (result.model)
1083
- lastModel = result.model;
1084
- if (result.provider)
1085
- lastProvider = result.provider;
1086
- // ── BugId capture after triage phase (Finding #1, #2) ──────────
1087
- // For new bugs, the triage subagent creates the bug record via store-cli.
1088
- // We capture the bugId by scanning tool_execution_end events.
1089
- if (phase.role === "triage" && isNewBug && bugId.startsWith("PENDING-")) {
1090
- const capturedBugId = extractBugIdFromEvents(toolExecutionEvents, loadGovernorProjectConfig(cwd).prefix);
1091
- if (capturedBugId) {
1092
- ctx.ui.notify(`forge:fix-bug — captured bug ID: ${capturedBugId}`, "info");
1093
- bugId = capturedBugId;
1094
- }
1095
- else {
1096
- // Fallback: list bugs and find the most recent one created after pipeline start.
1097
- const listResult = spawnSync("node", [storeCli, "list", "bug", "--json"], { cwd, encoding: "utf8" });
1098
- if (listResult.status === 0 && listResult.stdout) {
1099
- try {
1100
- const bugs = JSON.parse(listResult.stdout);
1101
- if (Array.isArray(bugs)) {
1102
- // Find most recent bug whose reportedAt is after the pipeline start
1103
- const pipelineStartIso = new Date(parseInt(bugId.replace("PENDING-", ""))).toISOString();
1104
- const recent = bugs
1105
- .filter((b) => b.reportedAt && b.reportedAt >= pipelineStartIso)
1106
- .sort((a, b) => String(b.reportedAt).localeCompare(String(a.reportedAt)))[0];
1107
- if (recent &&
1108
- recent.bugId &&
1109
- typeof recent.bugId === "string" &&
1110
- recent.bugId.startsWith("FORGE-BUG-")) {
1111
- bugId = recent.bugId;
1112
- ctx.ui.notify(`forge:fix-bug — captured bug ID via store fallback: ${bugId}`, "info");
1113
- }
1114
- }
1115
- }
1116
- catch {
1117
- /* parse failure — fall through to assertion */
1118
- }
1119
- }
1120
- }
1121
- // Defensive guard: if bugId is still PENDING after triage, pipeline cannot proceed.
1122
- if (bugId.startsWith("PENDING-")) {
1123
- ctx.ui.notify("× forge:fix-bug — failed to capture real bug ID after triage. Cannot proceed with PENDING placeholder.", "error");
1124
- return {
1125
- status: "failed",
1126
- lastPhaseIndex: currentPhaseIndex,
1127
- iterationCounts,
1128
- lastError: "bugId still PENDING after triage",
1129
- };
1130
- }
1131
- // Re-initialize debug log now that real bugId is available.
1132
- if (!debugLogDisabled) {
1133
- debugLogPath = path.join(cwd, ".forge", "cache", `fix-bug-debug-${bugId}.jsonl`);
1134
- const savedWriteDebug = writeDebug;
1135
- writeDebug = (rec) => {
1136
- try {
1137
- fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
1138
- try {
1139
- const st = fs.statSync(debugLogPath);
1140
- if (st.size > 10 * 1024 * 1024) {
1141
- const all = fs.readFileSync(debugLogPath, "utf8");
1142
- const lines = all.split("\n");
1143
- const keep = Math.floor(lines.length * 0.8);
1144
- fs.writeFileSync(debugLogPath, lines.slice(-keep).join("\n"), "utf8");
1145
- }
1146
- }
1147
- catch {
1148
- /* file may not exist yet */
1149
- }
1150
- fs.appendFileSync(debugLogPath, `${JSON.stringify({ ts: new Date().toISOString(), phase: phase.role, ...rec })}\n`, "utf8");
1151
- }
1152
- catch {
1153
- // non-fatal
1154
- }
1155
- };
1156
- writeDebug({ kind: "bugid_captured", bugId });
1157
- }
1158
- }
1159
- {
1160
- const elapsed = Math.floor((Date.now() - phaseStart) / 1000);
1161
- const { turn, toolCount, errCount, cumUsage, cumCompression } = observer.state;
1162
- ctx.ui.notify(`✓ ${phase.role}: ${turn} turn${turn === 1 ? "" : "s"} · ${toolCount} tool call${toolCount === 1 ? "" : "s"}${errCount ? ` · ${errCount} err` : ""} · ${elapsed}s`, "info");
1163
- orchTranscript.record({
1164
- kind: "phase-end",
1165
- ts: new Date().toISOString(),
1166
- phase: phase.role,
1167
- phaseIndex: currentPhaseIndex,
1168
- attempt: (iterationCounts[phase.role] ?? 0) + 1,
1169
- verdict: "n/a",
1170
- elapsedMs: Date.now() - phaseStart,
1171
- turns: turn,
1172
- toolCount,
1173
- errCount,
1174
- subagentTranscriptPath: result.subagentTranscriptPath,
1175
- });
1176
- registry.appendTail(bugId, phase.role, fmtPhaseSummary({
1177
- role: phase.role,
1178
- turns: turn,
1179
- tools: toolCount,
1180
- errors: errCount,
1181
- wallSeconds: elapsed,
1182
- usage: cumUsage,
1183
- model: result.model,
1184
- provider: result.provider,
1185
- compression: cumCompression.tokensSaved > 0 ? cumCompression : undefined,
1186
- }));
1187
- }
1188
- // ── Slice-2: orchestrator emits phase event ──────────────────
1189
- // sprintId for bug event emission is the literal "bugs" (routing key),
1190
- // matching the convention in .forge/workflows/fix_bug.md.
1191
- const phaseEndMs = Date.now();
1192
- const bugRecord = readBugRecord(bugId, storeCli, cwd);
1193
- const sprintId = "bugs"; // routing key for bug events — not a sprint reference
1194
- const phaseIteration = (iterationCounts[phase.role] ?? 0) + 1;
1195
- // Read summary judgement for review phases (using bug summary key map)
1196
- const judgement = phase.isReview
1197
- ? judgementFromSummary(bugRecord ?? null, phase.role, BUG_SUMMARY_KEY_BY_ROLE)
1198
- : undefined;
1199
- const emitCtx = {
1200
- entityType: "bug",
1201
- bugId,
1202
- sprintId, // routing key "bugs" — not a sprint reference
1203
- phase,
1204
- iteration: phaseIteration,
1205
- startMs: phaseStart,
1206
- endMs: phaseEndMs,
1207
- model: result.model ?? "unknown",
1208
- provider: result.provider ?? "unknown",
1209
- usage: {
1210
- input: result.usage.input,
1211
- output: result.usage.output,
1212
- cacheRead: result.usage.cacheRead,
1213
- cacheWrite: result.usage.cacheWrite,
1214
- },
1215
- judgement,
1216
- storeCli,
1217
- cwd,
1218
- };
1219
- const phaseEvent = buildPhaseEvent(emitCtx);
1220
- // Set bug event type based on BUG_TYPE_TOKENS mapping.
1221
- const typeTokenEntry = BUG_TYPE_TOKENS[phase.role];
1222
- if (typeTokenEntry) {
1223
- if (phase.isReview && judgement?.verdict === "revision") {
1224
- phaseEvent.type = typeTokenEntry.fail;
1225
- }
1226
- else {
1227
- phaseEvent.type = typeTokenEntry.pass;
1228
- }
1229
- }
1230
- const emitResult = emitEvent(storeCli, cwd, sprintId, phaseEvent);
1231
- if (!emitResult.ok) {
1232
- ctx.ui.notify(`⚠ forge:fix-bug — phase event emit failed for ${phase.role}: ${emitResult.stderr.trim()}`, "warning");
1233
- writeDebug({ kind: "emit_failed", stderr: emitResult.stderr });
1234
- }
1235
- else {
1236
- writeDebug({ kind: "emit_ok", eventId: phaseEvent.eventId });
1237
- }
1238
- // Drain friction file for this phase.
1239
- const frictionPath = path.join(cwd, ".forge", "cache", `FRICTION-${phase.role}.jsonl`);
1240
- const drain = drainFrictionFile(frictionPath, emitCtx);
1241
- if (drain.emitted + drain.failed > 0) {
1242
- writeDebug({ kind: "friction_drain", ...drain });
1243
- if (drain.failed > 0) {
1244
- ctx.ui.notify(`⚠ forge:fix-bug — friction drain for ${phase.role}: ${drain.emitted} ok, ${drain.failed} failed`, "warning");
1245
- }
1246
- }
1247
- // ── AC §C.16: Bug FSM canonical-enum assertion ────────────────
1248
- // After each phase that could transition bug status, validate the new
1249
- // status via store-cli (single source of truth). Surface a warning (not halt) if invalid.
1250
- const currentBugRecordForAssert = readBugRecord(bugId, storeCli, cwd);
1251
- if (currentBugRecordForAssert && currentBugRecordForAssert.status) {
1252
- // Defer to store-cli's isLegalTransition as authoritative guard.
1253
- // Only warn on statuses store-cli itself would reject.
1254
- const validateResult = spawnSync("node", [storeCli, "validate", "bug", JSON.stringify(currentBugRecordForAssert)], { cwd, encoding: "utf8" });
1255
- if (validateResult.status !== 0) {
1256
- const detail = typeof validateResult.stderr === "string" ? validateResult.stderr.trim() : "unknown";
1257
- ctx.ui.notify(`⚠ forge:fix-bug — bug ${bugId} validation warning: ${detail}`, "warning");
1258
- writeDebug({ kind: "fsm_assertion_warning", bugId, status: currentBugRecordForAssert.status, detail });
1259
- }
1260
- }
1261
- // ── 6b. Verdict check (review phases only) ────────────────────
1262
- if (phase.isReview) {
1263
- // Re-read bug record for latest status after subagent ran
1264
- const updatedBugRecord = readBugRecord(bugId, storeCli, cwd);
1265
- const verdict = readBugVerdict(updatedBugRecord, phase.role, BUG_SUMMARY_KEY_BY_ROLE);
1266
- if (verdict === "missing") {
1267
- ctx.ui.notify(`× forge:fix-bug — verdict missing for phase ${phase.role} after subagent completed. Halting for advisory.`, "error");
1268
- finishPhaseNode("failed");
1269
- writeBugState(cwd, {
1270
- bugId,
1271
- phaseIndex: currentPhaseIndex,
1272
- iterationCounts,
1273
- halted: true,
1274
- lastError: `verdict missing for ${phase.role}`,
1275
- savedAt: new Date().toISOString(),
1276
- });
1277
- // A missing verdict IS a postflight-outputs failure: the canonical
1278
- // phase summary the subagent must write (e.g. summaries.code_review,
1279
- // linked via set-bug-summary) was never recorded, so there is no
1280
- // verdict to route on. Route it through the halt-recovery advisor
1281
- // (FORGE-S26-T18) — the same hand-off the preflight/postflight gate
1282
- // failures use — instead of a bare escalation. Best-effort, non-fatal.
1283
- const advisorModel = resolveAdvisorModel(modelRoutingConfig, ctx.model);
1284
- void runHaltAdvisor({
1285
- gateFailure: {
1286
- phase: phase.role,
1287
- reasonCode: "verdict-missing",
1288
- detail: `Phase '${phase.role}' completed but no verdict was found in the store. ` +
1289
- "The canonical phase summary was not written, so the orchestrator has no verdict to route on.",
1290
- remediation: "Re-run the phase and ensure the subagent's forge_store set-bug-summary call " +
1291
- 'uses args:["<bugId>", "<phaseKey>"] with the literal phase key as args[1] ' +
1292
- "(e.g. code_review), and that the call exits zero before the subagent returns.",
1293
- },
1294
- advisorModel,
1295
- taskId: bugId,
1296
- cwd,
1297
- ctx: { ui: ctx.ui },
1298
- forgeRoot,
1299
- });
1300
- return {
1301
- status: "halted",
1302
- lastPhaseIndex: currentPhaseIndex,
1303
- iterationCounts,
1304
- lastError: `verdict missing for ${phase.role}`,
1305
- };
1306
- }
1307
- if (verdict === "revision") {
1308
- iterationCounts[phase.role] = (iterationCounts[phase.role] ?? 0) + 1;
1309
- if (iterationCounts[phase.role] >= phase.maxIterations) {
1310
- ctx.ui.notify(`× forge:fix-bug — revision cap reached for phase ${phase.role} ` +
1311
- `(${iterationCounts[phase.role]}/${phase.maxIterations} iterations). Escalating.`, "error");
1312
- finishPhaseNode("escalated");
1313
- writeBugState(cwd, {
1314
- bugId,
1315
- phaseIndex: currentPhaseIndex,
1316
- iterationCounts,
1317
- halted: true,
1318
- lastError: `revision cap reached for ${phase.role}`,
1319
- savedAt: new Date().toISOString(),
1320
- });
1321
- return {
1322
- status: "escalated",
1323
- lastPhaseIndex: currentPhaseIndex,
1324
- iterationCounts,
1325
- lastError: `revision cap reached for ${phase.role}`,
1326
- };
1327
- }
1328
- // Transition bug back to in-progress before re-dispatching implement.
1329
- // This is required for review-code → implement and approve → implement loops.
1330
- const currentBugStatus = updatedBugRecord?.status;
1331
- if (currentBugStatus === "fixed" || currentBugStatus === "approved") {
1332
- const transitionResult = spawnSync("node", [storeCli, "update-status", "bug", bugId, "status", "in-progress"], { cwd, encoding: "utf8" });
1333
- if (transitionResult.status !== 0) {
1334
- ctx.ui.notify(`⚠ forge:fix-bug — failed to transition bug ${bugId} from ${currentBugStatus} to in-progress: ${transitionResult.stderr ?? "unknown"}`, "warning");
1335
- }
1336
- else {
1337
- ctx.ui.notify(`⟳ forge:fix-bug — transitioned bug ${bugId}: ${currentBugStatus} → in-progress`, "info");
1338
- }
1339
- }
1340
- // The review subagent itself finished cleanly — `revision`
1341
- // is its verdict, not a failure — so its node closes as
1342
- // completed before the predecessor re-dispatches under a
1343
- // fresh node ID.
1344
- finishPhaseNode("completed");
1345
- const predIndex = findPredecessorIndex(BUG_PHASES, currentPhaseIndex);
1346
- ctx.ui.notify(`⟳ forge:fix-bug — ${phase.role} returned revision; looping to ${BUG_PHASES[predIndex]?.role ?? predIndex} ` +
1347
- `(attempt ${iterationCounts[phase.role]}/${phase.maxIterations})`, "info");
1348
- orchTranscript.record({
1349
- kind: "phase-loopback",
1350
- ts: new Date().toISOString(),
1351
- fromPhase: phase.role,
1352
- toPhase: BUG_PHASES[predIndex]?.role ?? String(predIndex),
1353
- fromPhaseIndex: currentPhaseIndex,
1354
- toPhaseIndex: predIndex,
1355
- reason: `${phase.role} returned revision (attempt ${iterationCounts[phase.role]}/${phase.maxIterations})`,
1356
- });
1357
- writeBugState(cwd, {
1358
- bugId,
1359
- phaseIndex: predIndex,
1360
- iterationCounts,
1361
- halted: false,
1362
- savedAt: new Date().toISOString(),
1363
- });
1364
- currentPhaseIndex = predIndex;
1365
- continue;
1366
- }
1367
- // verdict === "approved": fall through to advance
1368
- }
1369
- // ── Advance to next phase ─────────────────────────────────────
1370
- registry.completePhase(bugId, phase.role, "completed");
1371
- finishPhaseNode("completed");
1372
- writeBugState(cwd, {
1373
- bugId,
1374
- phaseIndex: currentPhaseIndex,
1375
- iterationCounts,
1376
- halted: false,
1377
- savedAt: new Date().toISOString(),
1378
- });
1379
- // ── 6c. Path A / Path B branch (post-triage) ──────────────────
1380
- // Per meta-fix-bug.md § Triage Judgement (forge v0.44.0+), the
1381
- // triage subagent records the route decision in
1382
- // bug.summaries.triage.route. The orchestrator reads it after
1383
- // triage returns and selects the downstream phase list:
1384
- // Path A (short-circuit): skip plan-fix + review-plan
1385
- // Path B (default, full loop): run all phases
1386
- //
1387
- // If route is missing or malformed, default to Path B (the safe
1388
- // choice — running extra phases never produces an unsafe outcome).
1389
- // The PHASE_SKIP_STATES heuristic at section 6a remains as
1390
- // defense-in-depth for cases where the field is missing but the
1391
- // bug status proves the work happened.
1392
- if (phase.role === "triage") {
1393
- const bugAfterTriage = readBugRecord(bugId, storeCli, cwd);
1394
- const triageSummary = bugAfterTriage?.summaries?.triage;
1395
- const route = triageSummary?.route;
1396
- if (route === "A") {
1397
- const skipUntilIndex = BUG_PHASES.findIndex((p) => p.role === "implement");
1398
- if (skipUntilIndex > currentPhaseIndex + 1) {
1399
- ctx.ui.notify(`⊘ forge:fix-bug — Path A selected by triage; skipping plan-fix and review-plan.`, "info");
1400
- currentPhaseIndex = skipUntilIndex;
1401
- continue;
1402
- }
1403
- }
1404
- // route === "B", missing, or any other value → fall through to standard advance
1405
- }
1406
- currentPhaseIndex++;
1407
- }
1408
- // ── All phases complete ───────────────────────────────────────────
1409
- deleteBugState(cwd, bugId);
1410
- orchTranscript.record({
1411
- kind: "pipeline-end",
1412
- ts: new Date().toISOString(),
1413
- outcome: "complete",
1414
- elapsedMs: Date.now() - pipelineStartMs,
1415
- });
1416
- return {
1417
- status: "completed",
1418
- lastPhaseIndex: BUG_PHASES.length - 1,
1419
- iterationCounts,
1420
- model: lastModel,
1421
- provider: lastProvider,
1422
- };
1423
- }
1424
- finally {
1425
- ctx.ui.notify = __origNotify;
1426
- }
1427
- }
1428
- export function registerFixBug(pi, options = {}) {
1429
- pi.registerCommand("forge:fix-bug", {
1430
- description: "Run the full bug-fix pipeline (triage → plan-fix → review-plan → implement → review-code → approve → commit). " +
1431
- "Usage: /forge:fix-bug <BUG_ID_OR_SUMMARY>. " +
1432
- "Orchestrator archetype: each phase is an isolated subagent session (IL10).",
1433
- async handler(args, ctx) {
1434
- const cwd = options.cwd ?? process.cwd();
1435
- const rawArg = args.trim();
1436
- if (!rawArg) {
1437
- ctx.ui.notify("× forge:fix-bug — bug ID or summary required. Usage: /forge:fix-bug <BUG_ID_OR_SUMMARY>", "error");
1438
- return;
1439
- }
1440
- ctx.ui.setStatus?.(STATUS_KEY, `fix-bug: initializing…`);
1441
- // ── Discover forge config ────────────────────────────────────────
1442
- const forgeConfig = discoverForgeConfigCached(cwd);
1443
- if (!forgeConfig) {
1444
- ctx.ui.notify("× forge:fix-bug — no Forge project found at cwd. Run /forge:init first.", "error");
1445
- ctx.ui.setStatus?.(STATUS_KEY, undefined);
1446
- ctx.ui.setStatus?.(MESSAGE_KEY, undefined);
1447
- return;
1448
- }
1449
- const forgeRoot = forgeConfig.forgeRoot;
1450
- // Best-effort transcript-archive sweep: adopt any project-local runs
1451
- // not yet in the central index (crash recovery + pre-existing
1452
- // history). Runs BEFORE this pipeline creates its own transcript
1453
- // writer, so the in-flight run is never swept half-written.
1454
- sweepProjectTranscripts(cwd);
1455
- // Tool paths
1456
- const storeCli = path.join(forgeRoot, "tools", "store-cli.cjs");
1457
- const preflightGate = path.join(forgeRoot, "tools", "preflight-gate.cjs");
1458
- // ── Determine bugId ────────────────────────────────────────────
1459
- let bugId;
1460
- let isNewBug = false;
1461
- // Check if arg looks like it could be a bug ID (prefixed or unprefixed).
1462
- // Covers: FORGE-BUG-042, BUG-042, B042.
1463
- const looksLikeBugId = /^(?:[A-Z0-9]+-)?(?:BUG-?\d+|B\d+)$/i.test(rawArg) || /^BUG-\d+$/i.test(rawArg);
1464
- if (/^[A-Z][A-Z0-9]*-BUG-\d+$/.test(rawArg)) {
1465
- // Canonical prefixed bug ID (any project prefix, e.g. FORGE-BUG-042,
1466
- // CART-BUG-001) — verify it exists. Previously hardcoded to FORGE-,
1467
- // which pushed other-prefix canonical IDs through the resolver.
1468
- bugId = rawArg;
1469
- const bugRecord = readBugRecord(bugId, storeCli, cwd);
1470
- if (!bugRecord) {
1471
- ctx.ui.notify(`× forge:fix-bug — bug ${bugId} not found in store.`, "error");
1472
- ctx.ui.setStatus?.(STATUS_KEY, undefined);
1473
- return;
1474
- }
1475
- // Check if bug is already in a terminal state
1476
- if (BUG_TERMINAL_STATES.has(bugRecord.status ?? "")) {
1477
- ctx.ui.notify(`× forge:fix-bug — bug ${bugId} is already in terminal state '${bugRecord.status}'. No further processing.`, "error");
1478
- ctx.ui.setStatus?.(STATUS_KEY, undefined);
1479
- return;
1480
- }
1481
- }
1482
- else if (looksLikeBugId) {
1483
- // Unprefixed bug ID — resolve through the store cascade.
1484
- // Issue #20: unprefixed entity IDs silently poisoned substitutions.
1485
- const toolDir = resolveToolDir(forgeRoot);
1486
- const resolvedBugId = await resolveToCanonicalId(rawArg, toolDir, cwd, "bug", {
1487
- ctx,
1488
- commandLabel: "forge:fix-bug",
1489
- });
1490
- if (!resolvedBugId) {
1491
- // Error already emitted by resolver
1492
- ctx.ui.setStatus?.(STATUS_KEY, undefined);
1493
- ctx.ui.setStatus?.(MESSAGE_KEY, undefined);
1494
- return;
1495
- }
1496
- bugId = resolvedBugId;
1497
- // Re-verify the resolved bug exists
1498
- const bugRecord = readBugRecord(bugId, storeCli, cwd);
1499
- if (!bugRecord) {
1500
- ctx.ui.notify(`× forge:fix-bug — bug ${bugId} not found in store.`, "error");
1501
- ctx.ui.setStatus?.(STATUS_KEY, undefined);
1502
- return;
1503
- }
1504
- if (BUG_TERMINAL_STATES.has(bugRecord.status ?? "")) {
1505
- ctx.ui.notify(`× forge:fix-bug — bug ${bugId} is already in terminal state '${bugRecord.status}'. No further processing.`, "error");
1506
- ctx.ui.setStatus?.(STATUS_KEY, undefined);
1507
- return;
1508
- }
1509
- }
1510
- else {
1511
- // @file or free-form text. If an @file report references a canonical
1512
- // <PREFIX>-BUG-NNN that already exists in the store, fix THAT record —
1513
- // minting a new bug here duplicated CART-BUG-001 as a phantom in the
1514
- // CART incident (the report header carried the real ID all along).
1515
- let reportBugId = null;
1516
- if (rawArg.startsWith("@")) {
1517
- const rel = rawArg.slice(1).trim();
1518
- const reportPath = path.isAbsolute(rel) ? rel : path.join(cwd, rel);
1519
- try {
1520
- // 256 KB cap — bug reports are small; never slurp arbitrary files.
1521
- if (fs.existsSync(reportPath) && fs.statSync(reportPath).size <= 262144) {
1522
- const projectPrefix = loadGovernorProjectConfig(cwd).prefix;
1523
- reportBugId = extractBugIdFromReportText(fs.readFileSync(reportPath, "utf8"), projectPrefix);
1524
- }
1525
- }
1526
- catch {
1527
- /* unreadable report — fall through to new-bug intake */
1528
- }
1529
- }
1530
- const reportRecord = reportBugId ? readBugRecord(reportBugId, storeCli, cwd) : null;
1531
- if (reportBugId && reportRecord) {
1532
- if (BUG_TERMINAL_STATES.has(reportRecord.status ?? "")) {
1533
- ctx.ui.notify(`× forge:fix-bug — ${rawArg} references ${reportBugId}, which is already in terminal state ` +
1534
- `'${reportRecord.status}'. No further processing.`, "error");
1535
- ctx.ui.setStatus?.(STATUS_KEY, undefined);
1536
- return;
1537
- }
1538
- bugId = reportBugId;
1539
- ctx.ui.notify(`forge:fix-bug — ${rawArg} references existing bug ${bugId}; fixing it (no new record minted).`, "info");
1540
- }
1541
- else {
1542
- // Free-form text (or report with no resolvable ID) — defer bug
1543
- // creation to the triage-phase subagent via a PENDING placeholder.
1544
- bugId = `PENDING-${Date.now()}`;
1545
- isNewBug = true;
1546
- }
1547
- }
1548
- // ── Pre-flight confirm ───────────────────────────────────────────
1549
- if (!isNonInteractive()) {
1550
- const confirmMsg = isNewBug
1551
- ? `Fix bug: "${rawArg.slice(0, 80)}"? A bug record will be created during triage.`
1552
- : `Fix bug ${bugId}?`;
1553
- const proceed = await ctx.ui.confirm(`Fix bug?`, confirmMsg);
1554
- if (!proceed) {
1555
- ctx.ui.notify("forge:fix-bug — cancelled.", "info");
1556
- ctx.ui.setStatus?.(STATUS_KEY, undefined);
1557
- return;
1558
- }
1559
- }
1560
- // ── Resume detection ─────────────────────────────────────────────
1561
- const registry = getSessionRegistry();
1562
- const existing = isNewBug ? null : readBugState(cwd, bugId);
1563
- let resumeFromState;
1564
- if (existing) {
1565
- if (isBugStateStale(existing)) {
1566
- ctx.ui.notify(`⚠ forge:fix-bug — cached state for ${bugId} is stale (>7 days old, saved at ${formatLocalTime(existing.savedAt)}). Offering purge.`, "warning");
1567
- if (!isNonInteractive()) {
1568
- const purge = await ctx.ui.confirm(`Purge stale state for ${bugId}?`, "The cached state is older than 7 days. Purge and restart from the beginning?");
1569
- if (purge) {
1570
- deleteBugState(cwd, bugId);
1571
- }
1572
- else {
1573
- ctx.ui.notify("forge:fix-bug — stale state kept; aborting.", "info");
1574
- ctx.ui.setStatus?.(STATUS_KEY, undefined);
1575
- ctx.ui.setStatus?.(MESSAGE_KEY, undefined);
1576
- return;
1577
- }
1578
- }
1579
- else {
1580
- ctx.ui.notify("forge:fix-bug — stale state; non-interactive mode auto-aborting.", "info");
1581
- ctx.ui.setStatus?.(STATUS_KEY, undefined);
1582
- ctx.ui.setStatus?.(MESSAGE_KEY, undefined);
1583
- return;
1584
- }
1585
- }
1586
- else {
1587
- // ADR-S21-01: offer resume for ALL non-stale states — halted=true
1588
- // (explicit failure), halted=false (cancelled/interrupted), and
1589
- // any state with existing.status set.
1590
- const stateStatus = existing.status ?? (existing.halted ? "halted" : "interrupted");
1591
- const statusLabel = stateStatus === "cancelled" ? "cancelled" : stateStatus === "halted" ? "halted" : "interrupted";
1592
- const phaseRole = BUG_PHASES[existing.phaseIndex]?.role ?? existing.phaseIndex;
1593
- if (!isNonInteractive()) {
1594
- const resume = await ctx.ui.confirm(`Resume ${bugId}?`, `Cached state — phase ${existing.phaseIndex} (${phaseRole}), ${statusLabel}, ` +
1595
- `saved at ${formatLocalTime(existing.savedAt)}. Resume from here?`);
1596
- if (resume) {
1597
- resumeFromState = existing;
1598
- ctx.ui.notify(`forge:fix-bug — resuming ${bugId} from phase ${phaseRole} (${statusLabel})`, "info");
1599
- }
1600
- else {
1601
- deleteBugState(cwd, bugId);
1602
- }
1603
- }
1604
- else {
1605
- // Non-interactive: auto-resume from state (no confirmation).
1606
- // Cancelled/interrupted states are valid resume points.
1607
- resumeFromState = existing;
1608
- ctx.ui.notify(`forge:fix-bug — resuming ${bugId} from phase ${phaseRole} (${statusLabel})`, "info");
1609
- }
1610
- }
1611
- }
1612
- // For new bugs, triage phase will create the bug record.
1613
- // After triage, we need to capture the bugId from the subagent events.
1614
- // This is handled inside runBugPipeline via onEvent interception.
1615
- // For now, we pass the temporary bugId; runBugPipeline will update it.
1616
- // ── Materialization check (top-level workflow) ──────────────────
1617
- const workflowPath = path.join(cwd, ".forge", "workflows", "fix_bug.md");
1618
- if (fs.existsSync(workflowPath)) {
1619
- try {
1620
- const loaded = loadWorkflow(workflowPath);
1621
- // AC#12: Top-level audience check for the fix_bug.md workflow.
1622
- // The orchestrator ITSELF runs fix_bug.md (not a subagent), so check
1623
- // from orchestrator context. Using asSubagent would falsely reject
1624
- // orchestrator-only workflows called by the orchestrator.
1625
- const topAudienceOk = CallerContextStore.asOrchestrator(() => assertAudience({ workflowName: "fix_bug", audience: loaded.audience }, ctx));
1626
- if (!topAudienceOk) {
1627
- ctx.ui.notify("× forge:fix-bug — audience check failed for top-level fix_bug workflow.", "error");
1628
- ctx.ui.setStatus?.(STATUS_KEY, undefined);
1629
- ctx.ui.setStatus?.(MESSAGE_KEY, undefined);
1630
- return;
1631
- }
1632
- // Note: no materialization-marker check here. fix_bug.md is the
1633
- // orchestrator workflow (prose algorithm), not a sub-workflow that
1634
- // subagents run directly. Per-phase sub-workflows (architect_approve,
1635
- // review_code, etc.) each get their own materialization check inside
1636
- // runBugPipeline at line ~481, which is the correct guard layer.
1637
- }
1638
- catch {
1639
- // Workflow file exists but couldn't be read — non-fatal, continue
1640
- }
1641
- }
1642
- // ── Pre-assign real bug ID for new bugs ────────────────────────
1643
- // Previously this was done inside runBugPipeline, but the session registry
1644
- // needs the real ID before startSession is called.
1645
- if (isNewBug && bugId.startsWith("PENDING-")) {
1646
- const realBugId = assignNextBugId(storeCli, cwd, loadGovernorProjectConfig(cwd).prefix);
1647
- const title = rawArg && !rawArg.startsWith("@") ? rawArg.slice(0, 120) : "New bug (pending triage)";
1648
- if (preCreateBug(realBugId, title, storeCli, cwd)) {
1649
- ctx.ui.notify(`forge:fix-bug — pre-assigned bug ID: ${realBugId}`, "info");
1650
- bugId = realBugId;
1651
- }
1652
- else {
1653
- ctx.ui.notify("× forge:fix-bug — failed to pre-create bug record. Falling back to PENDING capture.", "error");
1654
- }
1655
- }
1656
- // Register session
1657
- registry.startSession(bugId);
1658
- // Bridge: register bug in OrchestratorTree.
1659
- const tree = getOrchestratorTree();
1660
- tree.startNode(bugId, { label: `fix-bug ${bugId}`, kind: "orchestrator" });
1661
- // ── Delegate to pipeline ─────────────────────────────────────────
1662
- // ── Delegate to pipeline ─────────────────────────────────────────
1663
- const signal = registry.getAbortSignal(bugId);
1664
- const pipelineResult = await runBugPipeline({
1665
- bugId,
1666
- originalArg: isNewBug ? rawArg : undefined,
1667
- isNewBug,
1668
- cwd,
1669
- ctx,
1670
- forgeRoot,
1671
- storeCli,
1672
- preflightGate,
1673
- registry,
1674
- resumeFromState,
1675
- signal,
1676
- forgeToolDefs: options.forgeToolDefs,
1677
- });
1678
- // ── Handle result ────────────────────────────────────────────────
1679
- if (pipelineResult.status === "completed") {
1680
- registry.completeSession(bugId, "completed");
1681
- tree.completeNode(bugId, "completed");
1682
- ctx.ui.notify(`〇 forge:fix-bug — ${bugId} pipeline complete (${BUG_PHASES.length} phases).`, "info");
1683
- }
1684
- else if (pipelineResult.status === "cancelled") {
1685
- registry.completeSession(bugId, "cancelled");
1686
- tree.completeNode(bugId, "cancelled");
1687
- }
1688
- else {
1689
- registry.completeSession(bugId, "failed");
1690
- tree.completeNode(bugId, "failed");
1691
- }
1692
- // Mirror this run into the central transcript archive (best-effort —
1693
- // archiveRun never throws).
1694
- if (pipelineResult.orchestratorTranscriptPath) {
1695
- archiveRun({
1696
- cwd,
1697
- orchestratorJsonlPath: pipelineResult.orchestratorTranscriptPath,
1698
- });
1699
- }
1700
- ctx.ui.setStatus?.(STATUS_KEY, undefined);
1701
- ctx.ui.setStatus?.(MESSAGE_KEY, undefined);
1702
- },
1703
- });
1704
- }
22
+ // ── State persistence ─────────────────────────────────────────────────────
23
+ export { deleteBugState, isBugStateStale, readBugState, writeBugState, } from "./bug/bug-state.js";
24
+ // ── Bug record + ID minting / capture helpers ──────────────────────────────
25
+ export { assignNextBugId, computeNextBugId, extractBugIdFromEvents, extractBugIdFromReportText, preCreateBug, readBugRecord, } from "./bug/bug-id.js";
26
+ // ── Verdict read ───────────────────────────────────────────────────────────
27
+ export { readBugVerdict } from "./bug/bug-verdict.js";
28
+ // ── Bug body composition ───────────────────────────────────────────────────
29
+ export { composeBugBody } from "./bug/bug-body.js";
30
+ // ── Pipeline + command handler ─────────────────────────────────────────────
31
+ export { runBugPipeline } from "./bug/run-bug-pipeline.js";
32
+ export { registerFixBug } from "./bug/run-bug-command.js";
1705
33
  //# sourceMappingURL=fix-bug.js.map