@entelligentsia/forgecli 0.6.6 → 0.7.7

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 (187) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +99 -190
  3. package/dist/bin/forge.js +20 -0
  4. package/dist/bin/forge.js.map +1 -1
  5. package/dist/extensions/forgecli/approve.d.ts +24 -0
  6. package/dist/extensions/forgecli/approve.js +202 -0
  7. package/dist/extensions/forgecli/approve.js.map +1 -0
  8. package/dist/extensions/forgecli/audience-gate.d.ts +4 -0
  9. package/dist/extensions/forgecli/audience-gate.js +8 -5
  10. package/dist/extensions/forgecli/audience-gate.js.map +1 -1
  11. package/dist/extensions/forgecli/collate.d.ts +24 -0
  12. package/dist/extensions/forgecli/collate.js +199 -0
  13. package/dist/extensions/forgecli/collate.js.map +1 -0
  14. package/dist/extensions/forgecli/commit.d.ts +24 -0
  15. package/dist/extensions/forgecli/commit.js +202 -0
  16. package/dist/extensions/forgecli/commit.js.map +1 -0
  17. package/dist/extensions/forgecli/fix-bug.d.ts +75 -0
  18. package/dist/extensions/forgecli/fix-bug.js +1133 -0
  19. package/dist/extensions/forgecli/fix-bug.js.map +1 -0
  20. package/dist/extensions/forgecli/forge-commands.js +7 -0
  21. package/dist/extensions/forgecli/forge-commands.js.map +1 -1
  22. package/dist/extensions/forgecli/forge-header.js +10 -4
  23. package/dist/extensions/forgecli/forge-header.js.map +1 -1
  24. package/dist/extensions/forgecli/forge-init.js +16 -8
  25. package/dist/extensions/forgecli/forge-init.js.map +1 -1
  26. package/dist/extensions/forgecli/forge-subagent.d.ts +29 -0
  27. package/dist/extensions/forgecli/forge-subagent.js +14 -1
  28. package/dist/extensions/forgecli/forge-subagent.js.map +1 -1
  29. package/dist/extensions/forgecli/hook-dispatcher.d.ts +53 -1
  30. package/dist/extensions/forgecli/hook-dispatcher.js +47 -1
  31. package/dist/extensions/forgecli/hook-dispatcher.js.map +1 -1
  32. package/dist/extensions/forgecli/hooks/post-init-hook.d.ts +15 -0
  33. package/dist/extensions/forgecli/hooks/post-init-hook.js +127 -0
  34. package/dist/extensions/forgecli/hooks/post-init-hook.js.map +1 -0
  35. package/dist/extensions/forgecli/hooks/post-sprint-hook.d.ts +37 -0
  36. package/dist/extensions/forgecli/hooks/post-sprint-hook.js +166 -0
  37. package/dist/extensions/forgecli/hooks/post-sprint-hook.js.map +1 -0
  38. package/dist/extensions/forgecli/index.js +56 -5
  39. package/dist/extensions/forgecli/index.js.map +1 -1
  40. package/dist/extensions/forgecli/review-code.d.ts +24 -0
  41. package/dist/extensions/forgecli/review-code.js +202 -0
  42. package/dist/extensions/forgecli/review-code.js.map +1 -0
  43. package/dist/extensions/forgecli/review-plan.d.ts +24 -0
  44. package/dist/extensions/forgecli/review-plan.js +203 -0
  45. package/dist/extensions/forgecli/review-plan.js.map +1 -0
  46. package/dist/extensions/forgecli/run-sprint.d.ts +18 -0
  47. package/dist/extensions/forgecli/run-sprint.js +33 -1
  48. package/dist/extensions/forgecli/run-sprint.js.map +1 -1
  49. package/dist/extensions/forgecli/run-task.d.ts +21 -2
  50. package/dist/extensions/forgecli/run-task.js +33 -9
  51. package/dist/extensions/forgecli/run-task.js.map +1 -1
  52. package/dist/extensions/forgecli/session-registry.d.ts +10 -0
  53. package/dist/extensions/forgecli/session-registry.js +9 -0
  54. package/dist/extensions/forgecli/session-registry.js.map +1 -1
  55. package/dist/extensions/forgecli/validate.d.ts +24 -0
  56. package/dist/extensions/forgecli/validate.js +202 -0
  57. package/dist/extensions/forgecli/validate.js.map +1 -0
  58. package/dist/extensions/forgecli/wf-engine/engine.d.ts +23 -0
  59. package/dist/extensions/forgecli/wf-engine/engine.js +384 -0
  60. package/dist/extensions/forgecli/wf-engine/engine.js.map +1 -0
  61. package/dist/extensions/forgecli/wf-engine/event-parser.d.ts +6 -0
  62. package/dist/extensions/forgecli/wf-engine/event-parser.js +29 -0
  63. package/dist/extensions/forgecli/wf-engine/event-parser.js.map +1 -0
  64. package/dist/extensions/forgecli/wf-engine/id-gen.d.ts +6 -0
  65. package/dist/extensions/forgecli/wf-engine/id-gen.js +17 -0
  66. package/dist/extensions/forgecli/wf-engine/id-gen.js.map +1 -0
  67. package/dist/extensions/forgecli/wf-engine/loader.d.ts +2 -0
  68. package/dist/extensions/forgecli/wf-engine/loader.js +100 -0
  69. package/dist/extensions/forgecli/wf-engine/loader.js.map +1 -0
  70. package/dist/extensions/forgecli/wf-engine/predicate.d.ts +7 -0
  71. package/dist/extensions/forgecli/wf-engine/predicate.js +36 -0
  72. package/dist/extensions/forgecli/wf-engine/predicate.js.map +1 -0
  73. package/dist/extensions/forgecli/wf-engine/prompt-compiler.d.ts +15 -0
  74. package/dist/extensions/forgecli/wf-engine/prompt-compiler.js +23 -0
  75. package/dist/extensions/forgecli/wf-engine/prompt-compiler.js.map +1 -0
  76. package/dist/extensions/forgecli/wf-engine/register.d.ts +9 -0
  77. package/dist/extensions/forgecli/wf-engine/register.js +59 -0
  78. package/dist/extensions/forgecli/wf-engine/register.js.map +1 -0
  79. package/dist/extensions/forgecli/wf-engine/remit-check.d.ts +6 -0
  80. package/dist/extensions/forgecli/wf-engine/remit-check.js +42 -0
  81. package/dist/extensions/forgecli/wf-engine/remit-check.js.map +1 -0
  82. package/dist/extensions/forgecli/wf-engine/state-store.d.ts +13 -0
  83. package/dist/extensions/forgecli/wf-engine/state-store.js +43 -0
  84. package/dist/extensions/forgecli/wf-engine/state-store.js.map +1 -0
  85. package/dist/extensions/forgecli/wf-engine/types.d.ts +66 -0
  86. package/dist/extensions/forgecli/wf-engine/types.js +2 -0
  87. package/dist/extensions/forgecli/wf-engine/types.js.map +1 -0
  88. package/dist/extensions/forgecli/wf-engine/worker.d.ts +11 -0
  89. package/dist/extensions/forgecli/wf-engine/worker.js +50 -0
  90. package/dist/extensions/forgecli/wf-engine/worker.js.map +1 -0
  91. package/dist/forge-payload/.base-pack/workflows/_fragments/context-injection.md +10 -4
  92. package/dist/forge-payload/.base-pack/workflows/fix_bug.md +12 -0
  93. package/dist/forge-payload/.schemas/bug.schema.json +4 -2
  94. package/dist/forge-payload/.schemas/event.schema.json +22 -3
  95. package/dist/forge-payload/commands/add-pipeline.md +342 -0
  96. package/dist/forge-payload/commands/add-task.md +269 -0
  97. package/dist/forge-payload/commands/ask.md +43 -0
  98. package/dist/forge-payload/commands/calibrate.md +356 -0
  99. package/dist/forge-payload/commands/config.md +202 -0
  100. package/dist/forge-payload/commands/enhance.md +38 -0
  101. package/dist/forge-payload/commands/health.md +225 -0
  102. package/dist/forge-payload/commands/init.md +165 -0
  103. package/dist/forge-payload/commands/materialize.md +119 -0
  104. package/dist/forge-payload/commands/migrate.md +160 -0
  105. package/dist/forge-payload/commands/quiz-agent.md +38 -0
  106. package/dist/forge-payload/commands/regenerate.md +673 -0
  107. package/dist/forge-payload/commands/remove.md +174 -0
  108. package/dist/forge-payload/commands/report-bug.md +191 -0
  109. package/dist/forge-payload/commands/store-query.md +73 -0
  110. package/dist/forge-payload/commands/store-repair.md +187 -0
  111. package/dist/forge-payload/commands/update-tools.md +56 -0
  112. package/dist/forge-payload/commands/update.md +1376 -0
  113. package/dist/forge-payload/tools/preflight-gate.cjs +2 -1
  114. package/dist/forge-payload/tools/read-verdict.cjs +41 -8
  115. package/dist/forge-payload/tools/store-cli.cjs +4 -3
  116. package/node_modules/argparse/CHANGELOG.md +216 -0
  117. package/node_modules/argparse/LICENSE +254 -0
  118. package/node_modules/argparse/README.md +84 -0
  119. package/node_modules/argparse/argparse.js +3707 -0
  120. package/node_modules/argparse/lib/sub.js +67 -0
  121. package/node_modules/argparse/lib/textwrap.js +440 -0
  122. package/node_modules/argparse/package.json +31 -0
  123. package/node_modules/cliui/CHANGELOG.md +121 -0
  124. package/node_modules/color-convert/CHANGELOG.md +54 -0
  125. package/node_modules/esprima/ChangeLog +235 -0
  126. package/node_modules/js-yaml/LICENSE +21 -0
  127. package/node_modules/js-yaml/README.md +247 -0
  128. package/node_modules/js-yaml/bin/js-yaml.js +126 -0
  129. package/node_modules/js-yaml/dist/js-yaml.js +3880 -0
  130. package/node_modules/js-yaml/dist/js-yaml.min.js +2 -0
  131. package/node_modules/js-yaml/dist/js-yaml.mjs +3856 -0
  132. package/node_modules/js-yaml/index.js +47 -0
  133. package/node_modules/js-yaml/lib/common.js +59 -0
  134. package/node_modules/js-yaml/lib/dumper.js +965 -0
  135. package/node_modules/js-yaml/lib/exception.js +55 -0
  136. package/node_modules/js-yaml/lib/loader.js +1733 -0
  137. package/node_modules/js-yaml/lib/schema/core.js +11 -0
  138. package/node_modules/js-yaml/lib/schema/default.js +22 -0
  139. package/node_modules/js-yaml/lib/schema/failsafe.js +17 -0
  140. package/node_modules/js-yaml/lib/schema/json.js +19 -0
  141. package/node_modules/js-yaml/lib/schema.js +121 -0
  142. package/node_modules/js-yaml/lib/snippet.js +101 -0
  143. package/node_modules/js-yaml/lib/type/binary.js +125 -0
  144. package/node_modules/js-yaml/lib/type/bool.js +35 -0
  145. package/node_modules/js-yaml/lib/type/float.js +97 -0
  146. package/node_modules/js-yaml/lib/type/int.js +156 -0
  147. package/node_modules/js-yaml/lib/type/map.js +8 -0
  148. package/node_modules/js-yaml/lib/type/merge.js +12 -0
  149. package/node_modules/js-yaml/lib/type/null.js +35 -0
  150. package/node_modules/js-yaml/lib/type/omap.js +44 -0
  151. package/node_modules/js-yaml/lib/type/pairs.js +53 -0
  152. package/node_modules/js-yaml/lib/type/seq.js +8 -0
  153. package/node_modules/js-yaml/lib/type/set.js +29 -0
  154. package/node_modules/js-yaml/lib/type/str.js +8 -0
  155. package/node_modules/js-yaml/lib/type/timestamp.js +88 -0
  156. package/node_modules/js-yaml/lib/type.js +66 -0
  157. package/node_modules/js-yaml/package.json +66 -0
  158. package/node_modules/mz/HISTORY.md +66 -0
  159. package/node_modules/proper-lockfile/CHANGELOG.md +108 -0
  160. package/node_modules/source-map/CHANGELOG.md +301 -0
  161. package/node_modules/thenify/History.md +11 -0
  162. package/node_modules/thenify-all/History.md +11 -0
  163. package/node_modules/y18n/CHANGELOG.md +100 -0
  164. package/node_modules/yargs/CHANGELOG.md +88 -0
  165. package/node_modules/yargs-parser/CHANGELOG.md +263 -0
  166. package/package.json +6 -2
  167. package/themes/forge-matrix.json +89 -0
  168. package/themes/forge-mono.json +86 -0
  169. package/workflows/lead-qualifier/prompts/digest.md +44 -0
  170. package/workflows/lead-qualifier/prompts/draft-outreach.md +44 -0
  171. package/workflows/lead-qualifier/prompts/enrich.md +52 -0
  172. package/workflows/lead-qualifier/prompts/intake.md +48 -0
  173. package/workflows/lead-qualifier/prompts/mark-cold.md +38 -0
  174. package/workflows/lead-qualifier/prompts/score.md +45 -0
  175. package/workflows/lead-qualifier/workflow.yaml +95 -0
  176. package/workflows/research-brief/prompts/brief-synthesize.md +43 -0
  177. package/workflows/research-brief/prompts/intake.md +51 -0
  178. package/workflows/research-brief/prompts/source-critique.md +38 -0
  179. package/workflows/research-brief/prompts/source-score.md +38 -0
  180. package/workflows/research-brief/prompts/source-summarize.md +54 -0
  181. package/workflows/research-brief/workflow.yaml +66 -0
  182. package/dist/extensions/forgecli/session-monitor-widget.d.ts +0 -37
  183. package/dist/extensions/forgecli/session-monitor-widget.js +0 -320
  184. package/dist/extensions/forgecli/session-monitor-widget.js.map +0 -1
  185. package/dist/extensions/forgecli/session-monitor.d.ts +0 -2
  186. package/dist/extensions/forgecli/session-monitor.js +0 -135
  187. package/dist/extensions/forgecli/session-monitor.js.map +0 -1
@@ -0,0 +1,1133 @@
1
+ // fix-bug.ts — /forge:fix-bug Orchestrator native handler (FORGE-S21-T07).
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).
7
+ //
8
+ // Iron Laws enforced here:
9
+ // IL1 — code only under forge-cli/src/extensions/forgecli/
10
+ // IL6 — no shell-string interpolation; all external calls via spawnSync argv arrays
11
+ // IL7 — every failure path emits ctx.ui.notify and returns; no silent continuation
12
+ // IL10 — ALL LLM dispatch goes through runForgeSubagent (NO sendKickoff calls here)
13
+ //
14
+ // sendKickoff is NEVER called from this file.
15
+ // Audit-grep: grep -n "sendKickoff(" fix-bug.ts must return empty.
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import { spawnSync } from "node:child_process";
19
+ import { assertAudience, CallerContextStore } from "./audience-gate.js";
20
+ import { checkMaterialization } from "./plan.js";
21
+ import { loadForgePersona, runForgeSubagent } from "./forge-subagent.js";
22
+ import { discoverForgeConfig } from "./forge-root.js";
23
+ import { loadWorkflow } from "./loaders/workflow-loader.js";
24
+ import { getSessionRegistry } from "./session-registry.js";
25
+ import { validateId, isNonInteractive, formatLocalTime, emitEvent, findPredecessorIndex, runPreflightGate, buildPhaseEvent, drainFrictionFile, judgementFromSummary, } from "./run-task.js";
26
+ // ── Bug phase descriptor table ──────────────────────────────────────────────
27
+ //
28
+ // Decoded from .forge/workflows/fix_bug.md and the task prompt's BUG_PHASES.
29
+ // triage / plan-fix / implement all read the same fix_bug.md body — the
30
+ // workflow handles all three phases through prose.
31
+ export const BUG_PHASES = [
32
+ { role: "triage", workflowFile: "fix_bug", personaNoun: "bug-fixer", isReview: false, maxIterations: 1 },
33
+ { role: "plan-fix", workflowFile: "fix_bug", personaNoun: "bug-fixer", isReview: false, maxIterations: 1 },
34
+ { role: "review-plan", workflowFile: "review_plan", personaNoun: "supervisor", isReview: true, maxIterations: 3 },
35
+ { role: "implement", workflowFile: "fix_bug", personaNoun: "bug-fixer", isReview: false, maxIterations: 1 },
36
+ { role: "review-code", workflowFile: "review_code", personaNoun: "supervisor", isReview: true, maxIterations: 3 },
37
+ { role: "approve", workflowFile: "architect_approve", personaNoun: "architect", isReview: true, maxIterations: 3 },
38
+ { role: "commit", workflowFile: "commit_task", personaNoun: "engineer", isReview: false, maxIterations: 1 },
39
+ ];
40
+ // Map phase.role → canonical summary key written by base-pack workflows.
41
+ // Phases mapped to null use update-status bug instead of set-bug-summary
42
+ // for verdict tracking (Option B).
43
+ export const BUG_SUMMARY_KEY_BY_ROLE = {
44
+ "triage": "triage",
45
+ "plan-fix": "plan",
46
+ "review-plan": "review_plan",
47
+ "implement": "implementation",
48
+ "review-code": "code_review",
49
+ "approve": "approve", // read from bug.summaries.approve (set-bug-summary)
50
+ "commit": null, // commit transitions bug.status → verified, no summaries entry
51
+ };
52
+ // Bug-event type tokens — explicit mapping per review finding #3.
53
+ // Non-review phases always emit the pass token. Review phases select
54
+ // pass or fail based on ec.judgement.verdict.
55
+ export const BUG_TYPE_TOKENS = {
56
+ "triage": { pass: "bug-triaged", fail: "bug-triaged" },
57
+ "plan-fix": { pass: "fix-planned", fail: "fix-planned" },
58
+ "review-plan": { pass: "fix-review-passed", fail: "fix-review-failed" },
59
+ "implement": { pass: "fix-implemented", fail: "fix-implemented" },
60
+ "review-code": { pass: "fix-code-review-passed", fail: "fix-code-review-failed" },
61
+ "approve": { pass: "fix-approved", fail: "fix-revision-requested" },
62
+ "commit": { pass: "bug-committed", fail: "bug-commit-failed" },
63
+ };
64
+ // ── Bug FSM transitions ────────────────────────────────────────────────────
65
+ // Mirrors store-cli BUG_TRANSITIONS. Only `verified` is terminal.
66
+ // These are used locally for preflight gate logic; the canonical source
67
+ // is store-cli.cjs.
68
+ const BUG_TERMINAL_STATES = new Set(["verified"]);
69
+ function bugStateFilePath(cwd, bugId, sessionId) {
70
+ if (!validateId(bugId)) {
71
+ throw new Error(`Invalid bugId for state file path: ${bugId}`);
72
+ }
73
+ const suffix = sessionId ?? process.env.FORGE_SESSION_ID ?? `${process.pid}`;
74
+ return path.join(cwd, ".forge", "cache", `fix-bug-state-${bugId}-${suffix}.json`);
75
+ }
76
+ export function readBugState(cwd, bugId, sessionId) {
77
+ // If a specific session ID is given, read that file directly.
78
+ if (sessionId || process.env.FORGE_SESSION_ID) {
79
+ const fp = bugStateFilePath(cwd, bugId, sessionId);
80
+ try {
81
+ if (!fs.existsSync(fp))
82
+ return null;
83
+ const raw = fs.readFileSync(fp, "utf8");
84
+ return JSON.parse(raw);
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ // No specific session — glob for the most recent matching state file.
91
+ // Single-writer assumption: normally only one session per bug.
92
+ const cacheDir = path.join(cwd, ".forge", "cache");
93
+ const prefix = `fix-bug-state-${bugId}-`;
94
+ let bestFile = null;
95
+ let bestMtime = 0;
96
+ try {
97
+ const entries = fs.readdirSync(cacheDir);
98
+ for (const entry of entries) {
99
+ if (!entry.startsWith(prefix) || !entry.endsWith(".json"))
100
+ continue;
101
+ const fp = path.join(cacheDir, entry);
102
+ try {
103
+ const st = fs.statSync(fp);
104
+ if (st.mtimeMs > bestMtime) {
105
+ bestMtime = st.mtimeMs;
106
+ bestFile = fp;
107
+ }
108
+ }
109
+ catch {
110
+ continue;
111
+ }
112
+ }
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ if (!bestFile)
118
+ return null;
119
+ try {
120
+ const raw = fs.readFileSync(bestFile, "utf8");
121
+ return JSON.parse(raw);
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ export function writeBugState(cwd, state) {
128
+ // Guard: never write state for PENDING bugIds — wait for real bugId capture.
129
+ if (state.bugId.startsWith("PENDING-"))
130
+ return;
131
+ const fp = bugStateFilePath(cwd, state.bugId);
132
+ const dir = path.dirname(fp);
133
+ fs.mkdirSync(dir, { recursive: true });
134
+ fs.writeFileSync(fp, JSON.stringify(state, null, 2), "utf8");
135
+ }
136
+ export function deleteBugState(cwd, bugId) {
137
+ // Clean up all state files for this bug (all sessions)
138
+ const cacheDir = path.join(cwd, ".forge", "cache");
139
+ const statePrefix = `fix-bug-state-${bugId}-`;
140
+ const debugPrefix = `fix-bug-debug-${bugId}`;
141
+ try {
142
+ const entries = fs.readdirSync(cacheDir);
143
+ for (const entry of entries) {
144
+ if ((entry.startsWith(statePrefix) && entry.endsWith(".json")) || entry.startsWith(debugPrefix)) {
145
+ try {
146
+ fs.unlinkSync(path.join(cacheDir, entry));
147
+ }
148
+ catch { /* non-fatal */ }
149
+ }
150
+ }
151
+ }
152
+ catch {
153
+ // non-fatal
154
+ }
155
+ }
156
+ export function isBugStateStale(state) {
157
+ const savedAt = new Date(state.savedAt).getTime();
158
+ const ageMs = Date.now() - savedAt;
159
+ const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
160
+ return ageMs > sevenDaysMs;
161
+ }
162
+ export function readBugRecord(bugId, storeCli, cwd) {
163
+ const result = spawnSync("node", [storeCli, "read", "bug", bugId], { cwd, encoding: "utf8" });
164
+ if (result.status !== 0)
165
+ return null;
166
+ try {
167
+ const raw = typeof result.stdout === "string" ? result.stdout : String(result.stdout);
168
+ return JSON.parse(raw);
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ }
174
+ // Pre-assigns a real FORGE-BUG-NNN ID by listing existing bugs and incrementing.
175
+ // Returns the next ID in sequence, e.g. "FORGE-BUG-003" if bugs 001 and 002 exist.
176
+ export function assignNextBugId(storeCli, cwd) {
177
+ const result = spawnSync("node", [storeCli, "list", "bug", "--json"], { cwd, encoding: "utf8" });
178
+ let maxNum = 0;
179
+ if (result.status === 0 && result.stdout) {
180
+ try {
181
+ const bugs = JSON.parse(result.stdout);
182
+ if (Array.isArray(bugs)) {
183
+ for (const b of bugs) {
184
+ const m = String(b.bugId ?? "").match(/FORGE-BUG-(\d+)/);
185
+ if (m) {
186
+ const n = parseInt(m[1], 10);
187
+ if (n > maxNum)
188
+ maxNum = n;
189
+ }
190
+ }
191
+ }
192
+ }
193
+ catch { /* empty store — start from 1 */ }
194
+ }
195
+ const next = maxNum + 1;
196
+ return `FORGE-BUG-${String(next).padStart(3, "0")}`;
197
+ }
198
+ // Pre-creates a minimal bug record so the subagent has a real ID to work with.
199
+ export function preCreateBug(bugId, title, storeCli, cwd) {
200
+ const data = {
201
+ bugId,
202
+ title,
203
+ severity: "minor",
204
+ status: "reported",
205
+ path: `engineering/bugs/${bugId}`,
206
+ reportedAt: new Date().toISOString(),
207
+ };
208
+ const result = spawnSync("node", [storeCli, "write", "bug", JSON.stringify(data)], { cwd, encoding: "utf8" });
209
+ return result.status === 0;
210
+ }
211
+ export function readBugVerdict(bugRecord, phaseRole, summaryKeyByRole) {
212
+ if (!bugRecord)
213
+ return "missing";
214
+ // Approve phase: read bug status OR approve summary verdict.
215
+ // After Fix 2, the approve summary key exists in bug.schema.json.
216
+ // Prefer the summary verdict if present; fall back to status.
217
+ if (phaseRole === "approve") {
218
+ // Try summary first (set via set-bug-summary)
219
+ const summaryKey = summaryKeyByRole["approve"];
220
+ if (summaryKey) {
221
+ const summaries = bugRecord.summaries ?? {};
222
+ const blob = summaries[summaryKey];
223
+ if (blob && typeof blob === "object") {
224
+ const verdict = blob?.verdict;
225
+ if (typeof verdict === "string") {
226
+ if (verdict === "approved")
227
+ return "approved";
228
+ if (verdict === "revision")
229
+ return "revision";
230
+ }
231
+ }
232
+ }
233
+ // Fallback: read bug status directly.
234
+ if (bugRecord.status === "approved")
235
+ return "approved";
236
+ if (bugRecord.status === "fixed" || bugRecord.status === "in-progress")
237
+ return "revision";
238
+ return "missing";
239
+ }
240
+ // Commit phase: read bug status directly.
241
+ // verified → commit succeeded; approved → revision (commit did not advance).
242
+ if (phaseRole === "commit") {
243
+ if (bugRecord.status === "verified")
244
+ return "approved";
245
+ if (bugRecord.status === "approved")
246
+ return "revision";
247
+ return "missing";
248
+ }
249
+ // Review phases: read from summaries via key map.
250
+ const summaryKey = summaryKeyByRole[phaseRole];
251
+ if (!summaryKey)
252
+ return "missing";
253
+ const summaries = bugRecord.summaries ?? {};
254
+ const blob = summaries[summaryKey];
255
+ if (!blob || typeof blob !== "object")
256
+ return "missing";
257
+ const verdict = blob?.verdict;
258
+ if (typeof verdict !== "string")
259
+ return "missing";
260
+ if (verdict === "approved")
261
+ return "approved";
262
+ if (verdict === "revision")
263
+ return "revision";
264
+ return "missing";
265
+ }
266
+ // ── Bug body composition ──────────────────────────────────────────────────
267
+ export function composeBugBody(subWorkflowMd, bugId, phaseRole, bugStatusBeforePhase) {
268
+ // Entity-kind override block prepended before workflow body.
269
+ // This tells the subagent that it's operating on a bug, not a task,
270
+ // and provides exact update-status commands for approve and commit phases.
271
+ const entityKindLines = [
272
+ `Bug ID: ${bugId}`,
273
+ "",
274
+ "⚠ ENTITY KIND OVERRIDE: This is a bug, not a task.",
275
+ "- All `update-status` calls must use entity kind `bug` (not `task`).",
276
+ `- Approve phase: on approval, run \`node "$FORGE_ROOT/tools/store-cli.cjs" update-status bug ${bugId} status approved\``,
277
+ `- Commit phase: on success, run \`node "$FORGE_ROOT/tools/store-cli.cjs" update-status bug ${bugId} status verified\``,
278
+ `- Do NOT reference task-specific status values (e.g., \"committed\") or task entity kind.`,
279
+ "- CRITICAL: All `set-summary` calls must use `set-bug-summary` (not `set-summary`).",
280
+ ` e.g. node "$FORGE_ROOT/tools/store-cli.cjs" set-bug-summary ${bugId} review_plan <jsonFile>`,
281
+ `- Preflight gate: use \`--bug\` flag (not \`--task\`). e.g. node "$FORGE_ROOT/tools/preflight-gate.cjs" --phase review-plan --bug ${bugId}`,
282
+ "- Skip re-running preflight-gate — the orchestrator already checked it. Proceed directly to the review.",
283
+ "Any workflow text that says \"task\" should be read as \"bug\" for this context.",
284
+ ];
285
+ // Add phase-specific transition hints.
286
+ if (phaseRole === "approve" && bugStatusBeforePhase) {
287
+ entityKindLines.push(`- Approve phase: on approval, transition bug.status from '${bugStatusBeforePhase}' to 'approved'.`);
288
+ }
289
+ if (phaseRole === "commit" && bugStatusBeforePhase) {
290
+ entityKindLines.push(`- Commit phase: on success, transition bug.status from '${bugStatusBeforePhase}' to 'verified'.`);
291
+ }
292
+ return [
293
+ `Read the workflow below and follow it. Bug ID: ${bugId}.`,
294
+ "",
295
+ "---",
296
+ "",
297
+ entityKindLines.join("\n"),
298
+ "",
299
+ "---",
300
+ "",
301
+ subWorkflowMd.trim(),
302
+ ].join("\n");
303
+ }
304
+ // ── BugId capture via tool_execution_end ──────────────────────────────────
305
+ const BUG_WRITE_TOOL_NAMES = new Set(["write", "store-cli", "bash", "forge_store"]);
306
+ /**
307
+ * Scan tool_execution_end events to extract the bugId written by a triage
308
+ * subagent. Returns the LAST matching tool call's bugId, or null if none found.
309
+ *
310
+ * In pi runtime, the forge_store tool is registered as "forge_store" (not
311
+ * "store-cli"). In Claude Code runtime, subagents may shell out via Bash.
312
+ * This function covers all three paths.
313
+ */
314
+ export function extractBugIdFromEvents(events) {
315
+ let lastBugId = null;
316
+ for (const event of events) {
317
+ if (!event.toolName)
318
+ continue;
319
+ // Check for store-cli write bug calls (Claude Code runtime)
320
+ if (event.toolName === "store-cli") {
321
+ const result = event.result;
322
+ if (typeof result === "string") {
323
+ const match = result.match(/FORGE-BUG-\d+/);
324
+ if (match)
325
+ lastBugId = match[0];
326
+ }
327
+ else if (result && typeof result === "object") {
328
+ const obj = result;
329
+ if (typeof obj.bugId === "string" && obj.bugId.startsWith("FORGE-BUG-")) {
330
+ lastBugId = obj.bugId;
331
+ }
332
+ }
333
+ }
334
+ // Check for forge_store tool calls (pi runtime)
335
+ // The pi extension registers the tool as "forge_store", not "store-cli".
336
+ if (event.toolName === "forge_store" && event.result != null) {
337
+ const output = typeof event.result === "string" ? event.result : JSON.stringify(event.result);
338
+ const match = output.match(/FORGE-BUG-\d+/);
339
+ if (match)
340
+ lastBugId = match[0];
341
+ }
342
+ // Also check for write operations to .forge/store/bugs/
343
+ if (event.toolName === "write" && typeof event.result === "string") {
344
+ const match = event.result.match(/(FORGE-BUG-\d+)/);
345
+ if (match)
346
+ lastBugId = match[0];
347
+ }
348
+ // Bash events: subagents shelling out via Bash may run "store-cli write bug".
349
+ // Only match when output includes store-cli, write, and bug together
350
+ // to avoid false positives from unrelated Bash commands that happen to
351
+ // mention a bug ID in a different context.
352
+ if (event.toolName === "bash" && event.result != null) {
353
+ const output = typeof event.result === "string" ? event.result : JSON.stringify(event.result);
354
+ if (output.includes("store-cli") && output.includes("write") && output.includes("bug")) {
355
+ const match = output.match(/FORGE-BUG-\d+/);
356
+ if (match)
357
+ lastBugId = match[0];
358
+ }
359
+ }
360
+ }
361
+ return lastBugId;
362
+ }
363
+ const STATUS_KEY = "forge:fix-bug";
364
+ const MESSAGE_KEY = "forge:fix-bug:message";
365
+ export async function runBugPipeline(opts) {
366
+ const { bugId: initialBugId, originalArg, isNewBug, cwd, ctx, forgeRoot, storeCli, preflightGate, registry, resumeFromState } = opts;
367
+ // Mutable bugId — for new bugs, pre-assign a real FORGE-BUG-NNN ID
368
+ // before triage so the subagent never needs to create or discover one.
369
+ // This replaces the fragile PENDING→capture pattern where the subagent was
370
+ // expected to create the bug record and we'd fish the ID from events.
371
+ let bugId = initialBugId;
372
+ let currentPhaseIndex = resumeFromState?.phaseIndex ?? 0;
373
+ let iterationCounts = resumeFromState?.iterationCounts ?? {};
374
+ let lastModel;
375
+ let lastProvider;
376
+ while (currentPhaseIndex < BUG_PHASES.length) {
377
+ const phase = BUG_PHASES[currentPhaseIndex];
378
+ if (!phase) {
379
+ ctx.ui.notify(`× forge:fix-bug — invalid phase index ${currentPhaseIndex}`, "error");
380
+ return { status: "failed", lastPhaseIndex: currentPhaseIndex, iterationCounts, lastError: `invalid phase index ${currentPhaseIndex}` };
381
+ }
382
+ ctx.ui.setStatus?.(STATUS_KEY, `fix-bug ${bugId}: phase ${currentPhaseIndex + 1}/${BUG_PHASES.length} (${phase.role})`);
383
+ ctx.ui.notify(`→ ${bugId}: ${phase.role} (phase ${currentPhaseIndex + 1}/${BUG_PHASES.length})`, "info");
384
+ const subWorkflowPath = path.join(cwd, ".forge", "workflows", `${phase.workflowFile}.md`);
385
+ // ── Read sub-workflow ─────────────────────────────────────────
386
+ let subWorkflowMd;
387
+ let subWorkflowAudience = "any";
388
+ try {
389
+ const loaded = loadWorkflow(subWorkflowPath);
390
+ subWorkflowMd = loaded.rawMarkdown;
391
+ subWorkflowAudience = loaded.audience;
392
+ }
393
+ catch (err) {
394
+ const e = err;
395
+ ctx.ui.notify(`× forge:fix-bug — failed to read sub-workflow for ${phase.role}: ${e.message ?? "unknown"}`, "error");
396
+ writeBugState(cwd, {
397
+ bugId,
398
+ phaseIndex: currentPhaseIndex,
399
+ iterationCounts,
400
+ halted: true,
401
+ lastError: `sub-workflow read failed: ${e.message ?? "unknown"}`,
402
+ savedAt: new Date().toISOString(),
403
+ });
404
+ return { status: "failed", lastPhaseIndex: currentPhaseIndex, iterationCounts, lastError: `sub-workflow read failed: ${e.message ?? "unknown"}` };
405
+ }
406
+ // ── 6a. Phase skip (state-aware) ───────────────────────────────
407
+ // Subagents sometimes do "Path A" — fixing the bug end-to-end during
408
+ // triage instead of just triaging. Rather than rolling back (which
409
+ // discards work), we skip non-review phases whose output is already
410
+ // reflected in the bug status. Review phases are never skipped —
411
+ // they are quality gates that must always run.
412
+ const PHASE_SKIP_STATES = {
413
+ "plan-fix": new Set(["fixed", "approved", "verified"]),
414
+ "implement": new Set(["fixed", "approved", "verified"]),
415
+ "commit": new Set(["verified"]), // commit produces verified
416
+ };
417
+ const bugNow = readBugRecord(bugId, storeCli, cwd);
418
+ const skipStates = PHASE_SKIP_STATES[phase.role];
419
+ if (skipStates && bugNow?.status && skipStates.has(bugNow.status) && !phase.isReview) {
420
+ ctx.ui.notify(`⊘ forge:fix-bug — skipping ${phase.role}: bug ${bugId} is already '${bugNow.status}' (work already done).`, "info");
421
+ // Write a synthetic "approved" summary so downstream `after` predecessor
422
+ // verdict checks find a verdict and don't block review phases.
423
+ const summaryKey = BUG_SUMMARY_KEY_BY_ROLE[phase.role];
424
+ if (summaryKey) {
425
+ const synthSummary = {
426
+ objective: `Phase ${phase.role} skipped — bug already ${bugNow.status}`,
427
+ findings: ["Subagent completed fix during triage (Path A); phase output implicitly satisfied."],
428
+ verdict: "approved",
429
+ written_at: new Date().toISOString(),
430
+ };
431
+ const synthFile = path.join(cwd, ".forge", "cache", `synthetic-summary-${bugId}-${summaryKey}.json`);
432
+ fs.writeFileSync(synthFile, JSON.stringify(synthSummary, null, 2), "utf8");
433
+ const synthResult = spawnSync("node", [storeCli, "set-bug-summary", bugId, summaryKey, synthFile], { cwd, encoding: "utf8" });
434
+ if (synthResult.status !== 0) {
435
+ ctx.ui.notify(`⚠ forge:fix-bug — synthetic summary write failed for ${phase.role}: ${String(synthResult.stderr).trim()}`, "warning");
436
+ }
437
+ try {
438
+ fs.unlinkSync(synthFile);
439
+ }
440
+ catch { /* non-fatal */ }
441
+ }
442
+ currentPhaseIndex++;
443
+ continue;
444
+ }
445
+ // ── 6b. Preflight gate ────────────────────────────────────────
446
+ // Skip preflight gate for triage phase of new bugs (PENDING- placeholder)
447
+ // because the bug record doesn't exist yet — gates referencing bug fields
448
+ // would always fail.
449
+ const pendingBugId = bugId.startsWith("PENDING-");
450
+ if (!pendingBugId && fs.existsSync(preflightGate)) {
451
+ const preflightResult = runPreflightGate(preflightGate, phase.role, bugId, cwd, "bug");
452
+ if (preflightResult === "halt") {
453
+ ctx.ui.notify(`× forge:fix-bug — preflight gate failed for phase ${phase.role} (exit 1); halting.`, "error");
454
+ writeBugState(cwd, {
455
+ bugId,
456
+ phaseIndex: currentPhaseIndex,
457
+ iterationCounts,
458
+ halted: true,
459
+ lastError: `preflight gate exit 1 for ${phase.role}`,
460
+ savedAt: new Date().toISOString(),
461
+ });
462
+ return { status: "halted", lastPhaseIndex: currentPhaseIndex, iterationCounts, lastError: `preflight gate exit 1 for ${phase.role}` };
463
+ }
464
+ if (preflightResult === "escalate") {
465
+ ctx.ui.notify(`× forge:fix-bug — preflight gate escalated for phase ${phase.role} (exit 2); manual intervention required.`, "error");
466
+ writeBugState(cwd, {
467
+ bugId,
468
+ phaseIndex: currentPhaseIndex,
469
+ iterationCounts,
470
+ halted: true,
471
+ lastError: `preflight gate exit 2 (escalate) for ${phase.role}`,
472
+ savedAt: new Date().toISOString(),
473
+ });
474
+ return { status: "escalated", lastPhaseIndex: currentPhaseIndex, iterationCounts, lastError: `preflight gate exit 2 (escalate) for ${phase.role}` };
475
+ }
476
+ }
477
+ // ── 6. Materialization-marker check ───────────────────────────
478
+ // Skip for the monolithic fix_bug.md — it is the orchestrator prose
479
+ // algorithm, not a sub-workflow that subagents run tool calls against.
480
+ // Triage/plan-fix/implement phases reference fix_bug.md for their
481
+ // prose body but the actual tool-use discipline (Store-Write Verification,
482
+ // forge_store) lives in the sub-workflows (review_plan.md, commit_task.md,
483
+ // etc.) which get checked when their own phases run.
484
+ if (phase.workflowFile !== "fix_bug") {
485
+ const markerCheck = checkMaterialization(subWorkflowPath, subWorkflowMd);
486
+ if (!markerCheck.ok) {
487
+ for (const marker of markerCheck.missing) {
488
+ ctx.ui.notify(`× workflow regression: ${marker} not found in ${subWorkflowPath}`, "error");
489
+ }
490
+ return { status: "failed", lastPhaseIndex: currentPhaseIndex, iterationCounts, lastError: `materialization markers missing: ${markerCheck.missing.join(", ")}` };
491
+ }
492
+ }
493
+ // ── 5. Audience check ─────────────────────────────────────────
494
+ // fix_bug.md is orchestrator-only but the subagent doesn't "run" it as a
495
+ // workflow — the orchestrator reads its prose and composes the body text.
496
+ // Skip the audience gate for the monolithic fix_bug.md; only check the
497
+ // true sub-workflows (review_plan, review_code, architect_approve, commit_task)
498
+ // which the subagent does run directly.
499
+ const audienceOk = phase.workflowFile === "fix_bug" || CallerContextStore.asSubagent(() => assertAudience({ workflowName: phase.workflowFile, audience: subWorkflowAudience }, ctx));
500
+ if (!audienceOk) {
501
+ writeBugState(cwd, {
502
+ bugId,
503
+ phaseIndex: currentPhaseIndex,
504
+ iterationCounts,
505
+ halted: true,
506
+ lastError: `audience check failed for ${phase.workflowFile}`,
507
+ savedAt: new Date().toISOString(),
508
+ });
509
+ return { status: "failed", lastPhaseIndex: currentPhaseIndex, iterationCounts, lastError: `audience check failed for ${phase.workflowFile}` };
510
+ }
511
+ // ── Persona load ──────────────────────────────────────────────
512
+ let persona;
513
+ try {
514
+ persona = loadForgePersona(phase.personaNoun, cwd);
515
+ }
516
+ catch (err) {
517
+ const e = err;
518
+ ctx.ui.notify(`× forge:fix-bug — persona '${phase.personaNoun}' not found for phase ${phase.role}: ${e.message ?? "unknown"}. ` +
519
+ "Run /forge:regenerate to materialize persona files.", "error");
520
+ writeBugState(cwd, {
521
+ bugId,
522
+ phaseIndex: currentPhaseIndex,
523
+ iterationCounts,
524
+ halted: true,
525
+ lastError: `persona load failed: ${e.message ?? "unknown"}`,
526
+ savedAt: new Date().toISOString(),
527
+ });
528
+ return { status: "failed", lastPhaseIndex: currentPhaseIndex, iterationCounts, lastError: `persona load failed: ${e.message ?? "unknown"}` };
529
+ }
530
+ // ── Read bug record for current status ────────────────────────
531
+ // Skip for PENDING bugIds (bug doesn't exist yet).
532
+ const bugRecordBefore = pendingBugId ? null : readBugRecord(bugId, storeCli, cwd);
533
+ const bugStatusBeforePhase = bugRecordBefore?.status;
534
+ // ── 4. Dispatch via runForgeSubagent (IL10) ───────────────────
535
+ // NEVER sendKickoff here — that would reproduce issue #30.
536
+ let bugBody = composeBugBody(subWorkflowMd, bugId, phase.role, bugStatusBeforePhase);
537
+ // For new bugs in triage, prepend the original free-form text so the
538
+ // subagent knows the user-provided bug description to triage.
539
+ // The bug record already exists (pre-created with status "reported"),
540
+ // so the subagent should update it, not create a new one.
541
+ if (phase.role === "triage" && isNewBug && originalArg) {
542
+ bugBody = `Bug description: ${originalArg}\n\n---\n\n${bugBody}`;
543
+ }
544
+ // Phase-scoped progress counters
545
+ const phaseStart = Date.now();
546
+ let turn = 0;
547
+ let toolCount = 0;
548
+ let errCount = 0;
549
+ let lastTool = "";
550
+ const argsByCallId = new Map();
551
+ // Track tool_execution_end events for bugId capture (Findings #1, #2).
552
+ const toolExecutionEvents = [];
553
+ // Stabilization debug log
554
+ // Skip for PENDING bugIds — create after real bugId is captured.
555
+ // Disable entirely with FORGE_DEBUG_LOG=0.
556
+ const debugLogDisabled = process.env.FORGE_DEBUG_LOG === "0";
557
+ let debugLogPath = null;
558
+ let writeDebug = () => { };
559
+ if (!pendingBugId && !debugLogDisabled) {
560
+ debugLogPath = path.join(cwd, ".forge", "cache", `fix-bug-debug-${bugId}.jsonl`);
561
+ writeDebug = (rec) => {
562
+ try {
563
+ fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
564
+ // Cap at 10 MB: truncate head when size exceeds the cap.
565
+ try {
566
+ const st = fs.statSync(debugLogPath);
567
+ if (st.size > 10 * 1024 * 1024) {
568
+ const all = fs.readFileSync(debugLogPath, "utf8");
569
+ const lines = all.split("\n");
570
+ // Keep last 80% of lines
571
+ const keep = Math.floor(lines.length * 0.8);
572
+ fs.writeFileSync(debugLogPath, lines.slice(-keep).join("\n"), "utf8");
573
+ }
574
+ }
575
+ catch { /* file may not exist yet */ }
576
+ fs.appendFileSync(debugLogPath, `${JSON.stringify({ ts: new Date().toISOString(), phase: phase.role, ...rec })}\n`, "utf8");
577
+ }
578
+ catch {
579
+ // non-fatal; debug log is best-effort
580
+ }
581
+ };
582
+ }
583
+ writeDebug({ kind: "phase_start", phaseIndex: currentPhaseIndex });
584
+ registry.startPhase(bugId, phase.role, currentPhaseIndex);
585
+ const argHint = (toolName, args) => {
586
+ if (!args || typeof args !== "object")
587
+ return "";
588
+ const a = args;
589
+ const fp = (a.file_path ?? a.path);
590
+ if (typeof fp === "string")
591
+ return path.basename(fp);
592
+ if (typeof a.command === "string") {
593
+ const head = a.command.split(/\s+/).slice(0, 2).join(" ");
594
+ return head.length > 40 ? `${head.slice(0, 40)}…` : head;
595
+ }
596
+ if (typeof a.pattern === "string")
597
+ return a.pattern.slice(0, 40);
598
+ if (typeof a.query === "string")
599
+ return a.query.slice(0, 40);
600
+ return "";
601
+ };
602
+ // Tail-line formatters
603
+ const formatTime = (ms) => {
604
+ const d = new Date(ms);
605
+ const pad = (n) => String(n).padStart(2, "0");
606
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
607
+ };
608
+ const tailPrefix = () => `[${phase.role} ${formatTime(Date.now())}]`;
609
+ const extractErrorSummary = (result) => {
610
+ const raw = typeof result === "string"
611
+ ? result
612
+ : typeof result === "object" && result !== null
613
+ ? JSON.stringify(result)
614
+ : String(result);
615
+ const firstLine = raw.split(/\r?\n/).find((l) => l.trim().length > 0) ?? raw;
616
+ return firstLine.length > 160 ? `${firstLine.slice(0, 160)}…` : firstLine;
617
+ };
618
+ const appendTail = (line, opts) => {
619
+ registry.appendTail(bugId, phase.role, line, opts);
620
+ };
621
+ appendTail(`${tailPrefix()} ─── phase ${phase.role} begin ───`);
622
+ const refreshStatus = () => {
623
+ if (process.env.FORGE_VERBOSE !== "1")
624
+ return;
625
+ const elapsed = Math.floor((Date.now() - phaseStart) / 1000);
626
+ const tail = lastTool ? ` · ${lastTool}` : "";
627
+ ctx.ui.setStatus?.(STATUS_KEY, `fix-bug ${bugId}: ${phase.role} · t${turn} · tools ${toolCount}${errCount ? ` · err ${errCount}` : ""} · ${elapsed}s${tail}`);
628
+ };
629
+ let result;
630
+ try {
631
+ result = await runForgeSubagent({
632
+ persona,
633
+ task: bugBody,
634
+ cwd,
635
+ exportTag: `${bugId}__${phase.role}`,
636
+ // Sprint-scoped if the bug is attached to one, else bug-scoped.
637
+ // Keeps every phase of this bug-fix pipeline in a single cache
638
+ // namespace so the system-prompt + persona prefix stays warm
639
+ // across the ~10-minute phases.
640
+ cacheSessionId: typeof bugRecordBefore?.sprintId === "string" ? `forge:${bugRecordBefore.sprintId}` : `forge:bug:${bugId}`,
641
+ onEvent: (event) => {
642
+ switch (event.type) {
643
+ case "turn_start": {
644
+ turn++;
645
+ lastTool = "";
646
+ registry.bumpTurn(bugId);
647
+ refreshStatus();
648
+ break;
649
+ }
650
+ case "turn_end": {
651
+ // Extract turn preview from assistant message
652
+ break;
653
+ }
654
+ case "tool_execution_start": {
655
+ toolCount++;
656
+ argsByCallId.set(event.toolCallId, event.args);
657
+ const hint = argHint(event.toolName, event.args);
658
+ lastTool = `${event.toolName}${hint ? ` ${hint}` : ""}`;
659
+ writeDebug({
660
+ kind: "tool_start",
661
+ toolName: event.toolName,
662
+ toolCallId: event.toolCallId,
663
+ args: event.args,
664
+ });
665
+ registry.recordToolStart(bugId, event.toolCallId, event.toolName, event.args);
666
+ appendTail(`${tailPrefix()} → ${event.toolName}${hint ? ` ${hint}` : ""}`);
667
+ refreshStatus();
668
+ break;
669
+ }
670
+ case "tool_execution_end": {
671
+ const startArgs = argsByCallId.get(event.toolCallId);
672
+ argsByCallId.delete(event.toolCallId);
673
+ // Collect tool_execution_end events for bugId capture (Findings #1, #2).
674
+ toolExecutionEvents.push({ toolName: event.toolName, result: event.result });
675
+ writeDebug({
676
+ kind: "tool_end",
677
+ toolName: event.toolName,
678
+ toolCallId: event.toolCallId,
679
+ isError: event.isError,
680
+ args: startArgs,
681
+ result: event.result,
682
+ });
683
+ registry.recordToolEnd(bugId, event.toolCallId, event.toolName, event.isError, event.result);
684
+ if (event.isError) {
685
+ errCount++;
686
+ appendTail(`${tailPrefix()} ⚠ ${event.toolName} failed: ${extractErrorSummary(event.result)}`, { warning: true });
687
+ }
688
+ else {
689
+ appendTail(`${tailPrefix()} ← ${event.toolName} ok`);
690
+ }
691
+ refreshStatus();
692
+ break;
693
+ }
694
+ case "compaction_start": {
695
+ ctx.ui.notify(`◌ ${phase.role}: context compacting (${event.reason})…`, "info");
696
+ appendTail(`${tailPrefix()} ◌ compacting (${event.reason})`);
697
+ break;
698
+ }
699
+ case "auto_retry_start": {
700
+ const err = event.errorMessage ?? "";
701
+ ctx.ui.notify(`↻ ${phase.role}: model retry ${event.attempt}/${event.maxAttempts}${err ? `\n${err}` : ""}`, "warning");
702
+ appendTail(`${tailPrefix()} ↻ retry ${event.attempt}/${event.maxAttempts}${err ? `: ${extractErrorSummary(err)}` : ""}`, { warning: true });
703
+ break;
704
+ }
705
+ }
706
+ },
707
+ });
708
+ }
709
+ catch (err) {
710
+ const e = err;
711
+ ctx.ui.notify(`× forge:fix-bug — runForgeSubagent threw for phase ${phase.role}: ${e.message ?? "unknown"}`, "error");
712
+ writeBugState(cwd, {
713
+ bugId,
714
+ phaseIndex: currentPhaseIndex,
715
+ iterationCounts,
716
+ halted: true,
717
+ lastError: `runForgeSubagent threw: ${e.message ?? "unknown"}`,
718
+ savedAt: new Date().toISOString(),
719
+ });
720
+ return { status: "failed", lastPhaseIndex: currentPhaseIndex, iterationCounts, lastError: `runForgeSubagent threw: ${e.message ?? "unknown"}` };
721
+ }
722
+ // ── Halt-on-failure ───────────────────────────────────────────
723
+ if (result.exitCode !== 0) {
724
+ ctx.ui.notify(`× forge:fix-bug — phase ${phase.role} failed (exit ${result.exitCode})` +
725
+ (result.errorMessage ? `: ${result.errorMessage}` : "") +
726
+ (result.stopReason ? ` [${result.stopReason}]` : ""), "error");
727
+ writeBugState(cwd, {
728
+ bugId,
729
+ phaseIndex: currentPhaseIndex,
730
+ iterationCounts,
731
+ halted: true,
732
+ lastError: result.errorMessage ?? result.stopReason ?? "subagent exit non-zero",
733
+ savedAt: new Date().toISOString(),
734
+ });
735
+ return { status: "failed", lastPhaseIndex: currentPhaseIndex, iterationCounts, lastError: result.errorMessage ?? result.stopReason ?? "subagent exit non-zero" };
736
+ }
737
+ // Capture model/provider from subagent result.
738
+ if (result.model)
739
+ lastModel = result.model;
740
+ if (result.provider)
741
+ lastProvider = result.provider;
742
+ // ── BugId capture after triage phase (Finding #1, #2) ──────────
743
+ // For new bugs, the triage subagent creates the bug record via store-cli.
744
+ // We capture the bugId by scanning tool_execution_end events.
745
+ if (phase.role === "triage" && isNewBug && bugId.startsWith("PENDING-")) {
746
+ const capturedBugId = extractBugIdFromEvents(toolExecutionEvents);
747
+ if (capturedBugId) {
748
+ ctx.ui.notify(`forge:fix-bug — captured bug ID: ${capturedBugId}`, "info");
749
+ bugId = capturedBugId;
750
+ }
751
+ else {
752
+ // Fallback: list bugs and find the most recent one created after pipeline start.
753
+ const listResult = spawnSync("node", [storeCli, "list", "bug", "--json"], { cwd, encoding: "utf8" });
754
+ if (listResult.status === 0 && listResult.stdout) {
755
+ try {
756
+ const bugs = JSON.parse(listResult.stdout);
757
+ if (Array.isArray(bugs)) {
758
+ // Find most recent bug whose reportedAt is after the pipeline start
759
+ const pipelineStartIso = new Date(parseInt(bugId.replace("PENDING-", ""))).toISOString();
760
+ const recent = bugs
761
+ .filter((b) => b.reportedAt && b.reportedAt >= pipelineStartIso)
762
+ .sort((a, b) => String(b.reportedAt).localeCompare(String(a.reportedAt)))[0];
763
+ if (recent && recent.bugId && typeof recent.bugId === "string" && recent.bugId.startsWith("FORGE-BUG-")) {
764
+ bugId = recent.bugId;
765
+ ctx.ui.notify(`forge:fix-bug — captured bug ID via store fallback: ${bugId}`, "info");
766
+ }
767
+ }
768
+ }
769
+ catch { /* parse failure — fall through to assertion */ }
770
+ }
771
+ }
772
+ // Defensive guard: if bugId is still PENDING after triage, pipeline cannot proceed.
773
+ if (bugId.startsWith("PENDING-")) {
774
+ ctx.ui.notify("× forge:fix-bug — failed to capture real bug ID after triage. Cannot proceed with PENDING placeholder.", "error");
775
+ return { status: "failed", lastPhaseIndex: currentPhaseIndex, iterationCounts, lastError: "bugId still PENDING after triage" };
776
+ }
777
+ // Re-initialize debug log now that real bugId is available.
778
+ if (!debugLogDisabled) {
779
+ debugLogPath = path.join(cwd, ".forge", "cache", `fix-bug-debug-${bugId}.jsonl`);
780
+ const savedWriteDebug = writeDebug;
781
+ writeDebug = (rec) => {
782
+ try {
783
+ fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
784
+ try {
785
+ const st = fs.statSync(debugLogPath);
786
+ if (st.size > 10 * 1024 * 1024) {
787
+ const all = fs.readFileSync(debugLogPath, "utf8");
788
+ const lines = all.split("\n");
789
+ const keep = Math.floor(lines.length * 0.8);
790
+ fs.writeFileSync(debugLogPath, lines.slice(-keep).join("\n"), "utf8");
791
+ }
792
+ }
793
+ catch { /* file may not exist yet */ }
794
+ fs.appendFileSync(debugLogPath, `${JSON.stringify({ ts: new Date().toISOString(), phase: phase.role, ...rec })}\n`, "utf8");
795
+ }
796
+ catch {
797
+ // non-fatal
798
+ }
799
+ };
800
+ writeDebug({ kind: "bugid_captured", bugId });
801
+ }
802
+ }
803
+ {
804
+ const elapsed = Math.floor((Date.now() - phaseStart) / 1000);
805
+ ctx.ui.notify(`✓ ${phase.role}: ${turn} turn${turn === 1 ? "" : "s"} · ${toolCount} tool call${toolCount === 1 ? "" : "s"}${errCount ? ` · ${errCount} err` : ""} · ${elapsed}s`, "info");
806
+ }
807
+ // ── Slice-2: orchestrator emits phase event ──────────────────
808
+ // sprintId for bug event emission is the literal "bugs" (routing key),
809
+ // matching the convention in .forge/workflows/fix_bug.md.
810
+ const phaseEndMs = Date.now();
811
+ const bugRecord = readBugRecord(bugId, storeCli, cwd);
812
+ const sprintId = "bugs"; // routing key for bug events — not a sprint reference
813
+ const phaseIteration = (iterationCounts[phase.role] ?? 0) + 1;
814
+ // Read summary judgement for review phases (using bug summary key map)
815
+ const judgement = phase.isReview
816
+ ? judgementFromSummary(bugRecord ?? null, phase.role, BUG_SUMMARY_KEY_BY_ROLE)
817
+ : undefined;
818
+ const emitCtx = {
819
+ entityType: "bug",
820
+ bugId,
821
+ sprintId, // routing key "bugs" — not a sprint reference
822
+ phase,
823
+ iteration: phaseIteration,
824
+ startMs: phaseStart,
825
+ endMs: phaseEndMs,
826
+ model: result.model ?? "unknown",
827
+ provider: result.provider ?? "unknown",
828
+ usage: {
829
+ input: result.usage.input,
830
+ output: result.usage.output,
831
+ cacheRead: result.usage.cacheRead,
832
+ cacheWrite: result.usage.cacheWrite,
833
+ },
834
+ judgement,
835
+ storeCli,
836
+ cwd,
837
+ };
838
+ const phaseEvent = buildPhaseEvent(emitCtx);
839
+ // Set bug event type based on BUG_TYPE_TOKENS mapping.
840
+ const typeTokenEntry = BUG_TYPE_TOKENS[phase.role];
841
+ if (typeTokenEntry) {
842
+ if (phase.isReview && judgement?.verdict === "revision") {
843
+ phaseEvent.type = typeTokenEntry.fail;
844
+ }
845
+ else {
846
+ phaseEvent.type = typeTokenEntry.pass;
847
+ }
848
+ }
849
+ const emitResult = emitEvent(storeCli, cwd, sprintId, phaseEvent);
850
+ if (!emitResult.ok) {
851
+ ctx.ui.notify(`⚠ forge:fix-bug — phase event emit failed for ${phase.role}: ${emitResult.stderr.trim()}`, "warning");
852
+ writeDebug({ kind: "emit_failed", stderr: emitResult.stderr });
853
+ }
854
+ else {
855
+ writeDebug({ kind: "emit_ok", eventId: phaseEvent.eventId });
856
+ }
857
+ // Drain friction file for this phase.
858
+ const frictionPath = path.join(cwd, ".forge", "cache", `FRICTION-${phase.role}.jsonl`);
859
+ const drain = drainFrictionFile(frictionPath, emitCtx);
860
+ if (drain.emitted + drain.failed > 0) {
861
+ writeDebug({ kind: "friction_drain", ...drain });
862
+ if (drain.failed > 0) {
863
+ ctx.ui.notify(`⚠ forge:fix-bug — friction drain for ${phase.role}: ${drain.emitted} ok, ${drain.failed} failed`, "warning");
864
+ }
865
+ }
866
+ // ── AC §C.16: Bug FSM canonical-enum assertion ────────────────
867
+ // After each phase that could transition bug status, validate the new
868
+ // status via store-cli (single source of truth). Surface a warning (not halt) if invalid.
869
+ const currentBugRecordForAssert = readBugRecord(bugId, storeCli, cwd);
870
+ if (currentBugRecordForAssert && currentBugRecordForAssert.status) {
871
+ // Defer to store-cli's isLegalTransition as authoritative guard.
872
+ // Only warn on statuses store-cli itself would reject.
873
+ const validateResult = spawnSync("node", [storeCli, "validate", "bug", JSON.stringify(currentBugRecordForAssert)], { cwd, encoding: "utf8" });
874
+ if (validateResult.status !== 0) {
875
+ const detail = typeof validateResult.stderr === "string" ? validateResult.stderr.trim() : "unknown";
876
+ ctx.ui.notify(`⚠ forge:fix-bug — bug ${bugId} validation warning: ${detail}`, "warning");
877
+ writeDebug({ kind: "fsm_assertion_warning", bugId, status: currentBugRecordForAssert.status, detail });
878
+ }
879
+ }
880
+ // ── 6b. Verdict check (review phases only) ────────────────────
881
+ if (phase.isReview) {
882
+ // Re-read bug record for latest status after subagent ran
883
+ const updatedBugRecord = readBugRecord(bugId, storeCli, cwd);
884
+ const verdict = readBugVerdict(updatedBugRecord, phase.role, BUG_SUMMARY_KEY_BY_ROLE);
885
+ if (verdict === "missing") {
886
+ ctx.ui.notify(`× forge:fix-bug — verdict missing for phase ${phase.role} after subagent completed. Escalating.`, "error");
887
+ writeBugState(cwd, {
888
+ bugId,
889
+ phaseIndex: currentPhaseIndex,
890
+ iterationCounts,
891
+ halted: true,
892
+ lastError: `verdict missing for ${phase.role}`,
893
+ savedAt: new Date().toISOString(),
894
+ });
895
+ return { status: "failed", lastPhaseIndex: currentPhaseIndex, iterationCounts, lastError: `verdict missing for ${phase.role}` };
896
+ }
897
+ if (verdict === "revision") {
898
+ iterationCounts[phase.role] = (iterationCounts[phase.role] ?? 0) + 1;
899
+ if (iterationCounts[phase.role] >= phase.maxIterations) {
900
+ ctx.ui.notify(`× forge:fix-bug — revision cap reached for phase ${phase.role} ` +
901
+ `(${iterationCounts[phase.role]}/${phase.maxIterations} iterations). Escalating.`, "error");
902
+ writeBugState(cwd, {
903
+ bugId,
904
+ phaseIndex: currentPhaseIndex,
905
+ iterationCounts,
906
+ halted: true,
907
+ lastError: `revision cap reached for ${phase.role}`,
908
+ savedAt: new Date().toISOString(),
909
+ });
910
+ return { status: "escalated", lastPhaseIndex: currentPhaseIndex, iterationCounts, lastError: `revision cap reached for ${phase.role}` };
911
+ }
912
+ // Transition bug back to in-progress before re-dispatching implement.
913
+ // This is required for review-code → implement and approve → implement loops.
914
+ const currentBugStatus = updatedBugRecord?.status;
915
+ if (currentBugStatus === "fixed" || currentBugStatus === "approved") {
916
+ const transitionResult = spawnSync("node", [storeCli, "update-status", "bug", bugId, "status", "in-progress"], { cwd, encoding: "utf8" });
917
+ if (transitionResult.status !== 0) {
918
+ ctx.ui.notify(`⚠ forge:fix-bug — failed to transition bug ${bugId} from ${currentBugStatus} to in-progress: ${transitionResult.stderr ?? "unknown"}`, "warning");
919
+ }
920
+ else {
921
+ ctx.ui.notify(`⟳ forge:fix-bug — transitioned bug ${bugId}: ${currentBugStatus} → in-progress`, "info");
922
+ }
923
+ }
924
+ const predIndex = findPredecessorIndex(BUG_PHASES, currentPhaseIndex);
925
+ ctx.ui.notify(`⟳ forge:fix-bug — ${phase.role} returned revision; looping to ${BUG_PHASES[predIndex]?.role ?? predIndex} ` +
926
+ `(attempt ${iterationCounts[phase.role]}/${phase.maxIterations})`, "info");
927
+ writeBugState(cwd, {
928
+ bugId,
929
+ phaseIndex: predIndex,
930
+ iterationCounts,
931
+ halted: false,
932
+ savedAt: new Date().toISOString(),
933
+ });
934
+ currentPhaseIndex = predIndex;
935
+ continue;
936
+ }
937
+ // verdict === "approved": fall through to advance
938
+ }
939
+ // ── Advance to next phase ─────────────────────────────────────
940
+ registry.completePhase(bugId, phase.role, "completed");
941
+ writeBugState(cwd, {
942
+ bugId,
943
+ phaseIndex: currentPhaseIndex,
944
+ iterationCounts,
945
+ halted: false,
946
+ savedAt: new Date().toISOString(),
947
+ });
948
+ currentPhaseIndex++;
949
+ }
950
+ // ── All phases complete ───────────────────────────────────────────
951
+ deleteBugState(cwd, bugId);
952
+ return { status: "completed", lastPhaseIndex: BUG_PHASES.length - 1, iterationCounts, model: lastModel, provider: lastProvider };
953
+ }
954
+ export function registerFixBug(pi, options = {}) {
955
+ pi.registerCommand("forge:fix-bug", {
956
+ description: "Run the full bug-fix pipeline (triage → plan-fix → review-plan → implement → review-code → approve → commit). " +
957
+ "Usage: /forge:fix-bug <BUG_ID_OR_SUMMARY>. " +
958
+ "Orchestrator archetype: each phase is an isolated subagent session (IL10).",
959
+ async handler(args, ctx) {
960
+ const cwd = options.cwd ?? process.cwd();
961
+ const rawArg = args.trim();
962
+ if (!rawArg) {
963
+ ctx.ui.notify("× forge:fix-bug — bug ID or summary required. Usage: /forge:fix-bug <BUG_ID_OR_SUMMARY>", "error");
964
+ return;
965
+ }
966
+ ctx.ui.setStatus?.(STATUS_KEY, `fix-bug: initializing…`);
967
+ // ── Discover forge config ────────────────────────────────────────
968
+ const forgeConfig = discoverForgeConfig(cwd);
969
+ if (!forgeConfig) {
970
+ ctx.ui.notify("× forge:fix-bug — no Forge project found at cwd. Run /forge:init first.", "error");
971
+ ctx.ui.setStatus?.(STATUS_KEY, undefined);
972
+ ctx.ui.setStatus?.(MESSAGE_KEY, undefined);
973
+ return;
974
+ }
975
+ const forgeRoot = forgeConfig.forgeRoot;
976
+ // Tool paths
977
+ const storeCli = path.join(forgeRoot, "tools", "store-cli.cjs");
978
+ const preflightGate = path.join(forgeRoot, "tools", "preflight-gate.cjs");
979
+ // ── Determine bugId ────────────────────────────────────────────
980
+ let bugId;
981
+ let isNewBug = false;
982
+ if (/^FORGE-BUG-\d+$/.test(rawArg)) {
983
+ // Existing bug ID — verify it exists
984
+ bugId = rawArg;
985
+ const bugRecord = readBugRecord(bugId, storeCli, cwd);
986
+ if (!bugRecord) {
987
+ ctx.ui.notify(`× forge:fix-bug — bug ${bugId} not found in store.`, "error");
988
+ ctx.ui.setStatus?.(STATUS_KEY, undefined);
989
+ return;
990
+ }
991
+ // Check if bug is already in a terminal state
992
+ if (BUG_TERMINAL_STATES.has(bugRecord.status ?? "")) {
993
+ ctx.ui.notify(`× forge:fix-bug — bug ${bugId} is already in terminal state '${bugRecord.status}'. No further processing.`, "error");
994
+ ctx.ui.setStatus?.(STATUS_KEY, undefined);
995
+ return;
996
+ }
997
+ }
998
+ else {
999
+ // Free-form text — defer bug creation to triage-phase subagent
1000
+ // Use a temporary bugId placeholder; will be captured from subagent events
1001
+ bugId = `PENDING-${Date.now()}`;
1002
+ isNewBug = true;
1003
+ }
1004
+ // ── Pre-flight confirm ───────────────────────────────────────────
1005
+ if (!isNonInteractive()) {
1006
+ const confirmMsg = isNewBug
1007
+ ? `Fix bug: "${rawArg.slice(0, 80)}"? A bug record will be created during triage.`
1008
+ : `Fix bug ${bugId}?`;
1009
+ const proceed = await ctx.ui.confirm(`Fix bug?`, confirmMsg);
1010
+ if (!proceed) {
1011
+ ctx.ui.notify("forge:fix-bug — cancelled.", "info");
1012
+ ctx.ui.setStatus?.(STATUS_KEY, undefined);
1013
+ return;
1014
+ }
1015
+ }
1016
+ // ── Resume detection ─────────────────────────────────────────────
1017
+ const registry = getSessionRegistry();
1018
+ const existing = isNewBug ? null : readBugState(cwd, bugId);
1019
+ let resumeFromState;
1020
+ if (existing) {
1021
+ if (isBugStateStale(existing)) {
1022
+ ctx.ui.notify(`⚠ forge:fix-bug — cached state for ${bugId} is stale (>7 days old, saved at ${formatLocalTime(existing.savedAt)}). Offering purge.`, "warning");
1023
+ if (!isNonInteractive()) {
1024
+ 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?");
1025
+ if (purge) {
1026
+ deleteBugState(cwd, bugId);
1027
+ }
1028
+ else {
1029
+ ctx.ui.notify("forge:fix-bug — stale state kept; aborting.", "info");
1030
+ ctx.ui.setStatus?.(STATUS_KEY, undefined);
1031
+ ctx.ui.setStatus?.(MESSAGE_KEY, undefined);
1032
+ return;
1033
+ }
1034
+ }
1035
+ else {
1036
+ ctx.ui.notify("forge:fix-bug — stale state; non-interactive mode auto-aborting.", "info");
1037
+ ctx.ui.setStatus?.(STATUS_KEY, undefined);
1038
+ ctx.ui.setStatus?.(MESSAGE_KEY, undefined);
1039
+ return;
1040
+ }
1041
+ }
1042
+ else {
1043
+ if (!isNonInteractive()) {
1044
+ const resume = await ctx.ui.confirm(`Resume ${bugId}?`, `Cached state found at phase ${existing.phaseIndex} (saved at ${formatLocalTime(existing.savedAt)}). Resume from here?`);
1045
+ if (resume) {
1046
+ resumeFromState = existing;
1047
+ ctx.ui.notify(`forge:fix-bug — resuming ${bugId} from phase ${BUG_PHASES[existing.phaseIndex]?.role ?? existing.phaseIndex}`, "info");
1048
+ }
1049
+ else {
1050
+ deleteBugState(cwd, bugId);
1051
+ }
1052
+ }
1053
+ else {
1054
+ ctx.ui.notify(`forge:fix-bug — cached state for ${bugId} found but non-interactive mode; aborting.`, "info");
1055
+ ctx.ui.setStatus?.(STATUS_KEY, undefined);
1056
+ ctx.ui.setStatus?.(MESSAGE_KEY, undefined);
1057
+ return;
1058
+ }
1059
+ }
1060
+ }
1061
+ // For new bugs, triage phase will create the bug record.
1062
+ // After triage, we need to capture the bugId from the subagent events.
1063
+ // This is handled inside runBugPipeline via onEvent interception.
1064
+ // For now, we pass the temporary bugId; runBugPipeline will update it.
1065
+ // ── Materialization check (top-level workflow) ──────────────────
1066
+ const workflowPath = path.join(cwd, ".forge", "workflows", "fix_bug.md");
1067
+ if (fs.existsSync(workflowPath)) {
1068
+ try {
1069
+ const loaded = loadWorkflow(workflowPath);
1070
+ // AC#12: Top-level audience check for the fix_bug.md workflow.
1071
+ // The orchestrator ITSELF runs fix_bug.md (not a subagent), so check
1072
+ // from orchestrator context. Using asSubagent would falsely reject
1073
+ // orchestrator-only workflows called by the orchestrator.
1074
+ const topAudienceOk = CallerContextStore.asOrchestrator(() => assertAudience({ workflowName: "fix_bug", audience: loaded.audience }, ctx));
1075
+ if (!topAudienceOk) {
1076
+ ctx.ui.notify("× forge:fix-bug — audience check failed for top-level fix_bug workflow.", "error");
1077
+ ctx.ui.setStatus?.(STATUS_KEY, undefined);
1078
+ ctx.ui.setStatus?.(MESSAGE_KEY, undefined);
1079
+ return;
1080
+ }
1081
+ // Note: no materialization-marker check here. fix_bug.md is the
1082
+ // orchestrator workflow (prose algorithm), not a sub-workflow that
1083
+ // subagents run directly. Per-phase sub-workflows (architect_approve,
1084
+ // review_code, etc.) each get their own materialization check inside
1085
+ // runBugPipeline at line ~481, which is the correct guard layer.
1086
+ }
1087
+ catch {
1088
+ // Workflow file exists but couldn't be read — non-fatal, continue
1089
+ }
1090
+ }
1091
+ // ── Pre-assign real bug ID for new bugs ────────────────────────
1092
+ // Previously this was done inside runBugPipeline, but the session registry
1093
+ // needs the real ID before startSession is called.
1094
+ if (isNewBug && bugId.startsWith("PENDING-")) {
1095
+ const realBugId = assignNextBugId(storeCli, cwd);
1096
+ const title = (rawArg && !rawArg.startsWith("@")) ? rawArg.slice(0, 120) : "New bug (pending triage)";
1097
+ if (preCreateBug(realBugId, title, storeCli, cwd)) {
1098
+ ctx.ui.notify(`forge:fix-bug — pre-assigned bug ID: ${realBugId}`, "info");
1099
+ bugId = realBugId;
1100
+ }
1101
+ else {
1102
+ ctx.ui.notify("× forge:fix-bug — failed to pre-create bug record. Falling back to PENDING capture.", "error");
1103
+ }
1104
+ }
1105
+ // Register session
1106
+ registry.startSession(bugId);
1107
+ // ── Delegate to pipeline ─────────────────────────────────────────
1108
+ const pipelineResult = await runBugPipeline({
1109
+ bugId,
1110
+ originalArg: isNewBug ? rawArg : undefined,
1111
+ isNewBug,
1112
+ cwd,
1113
+ ctx,
1114
+ forgeRoot,
1115
+ storeCli,
1116
+ preflightGate,
1117
+ registry,
1118
+ resumeFromState,
1119
+ });
1120
+ // ── Handle result ────────────────────────────────────────────────
1121
+ if (pipelineResult.status === "completed") {
1122
+ registry.completeSession(bugId, "completed");
1123
+ ctx.ui.notify(`〇 forge:fix-bug — ${bugId} pipeline complete (${BUG_PHASES.length} phases).`, "info");
1124
+ }
1125
+ else {
1126
+ registry.completeSession(bugId, "failed");
1127
+ }
1128
+ ctx.ui.setStatus?.(STATUS_KEY, undefined);
1129
+ ctx.ui.setStatus?.(MESSAGE_KEY, undefined);
1130
+ },
1131
+ });
1132
+ }
1133
+ //# sourceMappingURL=fix-bug.js.map