@agile-vibe-coding/avc 0.1.1 → 0.3.1

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 (239) hide show
  1. package/cli/agent-loader.js +21 -0
  2. package/cli/agents/agent-selector.md +152 -0
  3. package/cli/agents/architecture-recommender.md +418 -0
  4. package/cli/agents/code-implementer.md +117 -0
  5. package/cli/agents/code-validator.md +80 -0
  6. package/cli/agents/context-reviewer-epic.md +101 -0
  7. package/cli/agents/context-reviewer-story.md +92 -0
  8. package/cli/agents/context-writer-epic.md +145 -0
  9. package/cli/agents/context-writer-story.md +111 -0
  10. package/cli/agents/database-deep-dive.md +470 -0
  11. package/cli/agents/database-recommender.md +634 -0
  12. package/cli/agents/doc-distributor.md +176 -0
  13. package/cli/agents/doc-writer-epic.md +42 -0
  14. package/cli/agents/doc-writer-story.md +43 -0
  15. package/cli/agents/documentation-updater.md +203 -0
  16. package/cli/agents/duplicate-detector.md +110 -0
  17. package/cli/agents/epic-story-decomposer.md +559 -0
  18. package/cli/agents/feature-context-generator.md +91 -0
  19. package/cli/agents/gap-checker-epic.md +52 -0
  20. package/cli/agents/impact-checker-story.md +51 -0
  21. package/cli/agents/migration-guide-generator.md +305 -0
  22. package/cli/agents/mission-scope-generator.md +143 -0
  23. package/cli/agents/mission-scope-validator.md +146 -0
  24. package/cli/agents/project-context-extractor.md +122 -0
  25. package/cli/agents/project-documentation-creator.json +226 -0
  26. package/cli/agents/project-documentation-creator.md +595 -0
  27. package/cli/agents/question-prefiller.md +269 -0
  28. package/cli/agents/refiner-epic.md +39 -0
  29. package/cli/agents/refiner-story.md +42 -0
  30. package/cli/agents/scaffolding-generator.md +99 -0
  31. package/cli/agents/seed-validator.md +71 -0
  32. package/cli/agents/story-doc-enricher.md +133 -0
  33. package/cli/agents/story-scope-reviewer.md +147 -0
  34. package/cli/agents/story-splitter.md +83 -0
  35. package/cli/agents/suggestion-business-analyst.md +88 -0
  36. package/cli/agents/suggestion-deployment-architect.md +263 -0
  37. package/cli/agents/suggestion-product-manager.md +129 -0
  38. package/cli/agents/suggestion-security-specialist.md +156 -0
  39. package/cli/agents/suggestion-technical-architect.md +269 -0
  40. package/cli/agents/suggestion-ux-researcher.md +93 -0
  41. package/cli/agents/task-subtask-decomposer.md +188 -0
  42. package/cli/agents/validator-documentation.json +183 -0
  43. package/cli/agents/validator-documentation.md +455 -0
  44. package/cli/agents/validator-selector.md +211 -0
  45. package/cli/ansi-colors.js +21 -0
  46. package/cli/api-reference-tool.js +368 -0
  47. package/cli/build-docs.js +29 -8
  48. package/cli/ceremony-history.js +369 -0
  49. package/cli/checks/catalog.json +76 -0
  50. package/cli/checks/code/quality.json +26 -0
  51. package/cli/checks/code/testing.json +14 -0
  52. package/cli/checks/code/traceability.json +26 -0
  53. package/cli/checks/cross-refs/epic.json +171 -0
  54. package/cli/checks/cross-refs/story.json +149 -0
  55. package/cli/checks/epic/api.json +114 -0
  56. package/cli/checks/epic/backend.json +126 -0
  57. package/cli/checks/epic/cloud.json +126 -0
  58. package/cli/checks/epic/data.json +102 -0
  59. package/cli/checks/epic/database.json +114 -0
  60. package/cli/checks/epic/developer.json +182 -0
  61. package/cli/checks/epic/devops.json +174 -0
  62. package/cli/checks/epic/frontend.json +162 -0
  63. package/cli/checks/epic/mobile.json +102 -0
  64. package/cli/checks/epic/qa.json +90 -0
  65. package/cli/checks/epic/security.json +184 -0
  66. package/cli/checks/epic/solution-architect.json +192 -0
  67. package/cli/checks/epic/test-architect.json +90 -0
  68. package/cli/checks/epic/ui.json +102 -0
  69. package/cli/checks/epic/ux.json +90 -0
  70. package/cli/checks/fixes/epic-fix-template.md +10 -0
  71. package/cli/checks/fixes/story-fix-template.md +10 -0
  72. package/cli/checks/story/api.json +186 -0
  73. package/cli/checks/story/backend.json +102 -0
  74. package/cli/checks/story/cloud.json +102 -0
  75. package/cli/checks/story/data.json +210 -0
  76. package/cli/checks/story/database.json +102 -0
  77. package/cli/checks/story/developer.json +168 -0
  78. package/cli/checks/story/devops.json +102 -0
  79. package/cli/checks/story/frontend.json +174 -0
  80. package/cli/checks/story/mobile.json +102 -0
  81. package/cli/checks/story/qa.json +210 -0
  82. package/cli/checks/story/security.json +198 -0
  83. package/cli/checks/story/solution-architect.json +230 -0
  84. package/cli/checks/story/test-architect.json +210 -0
  85. package/cli/checks/story/ui.json +102 -0
  86. package/cli/checks/story/ux.json +102 -0
  87. package/cli/coding-order.js +401 -0
  88. package/cli/command-logger.js +49 -12
  89. package/cli/components/static-output.js +63 -0
  90. package/cli/console-output-manager.js +94 -0
  91. package/cli/dependency-checker.js +72 -0
  92. package/cli/docs-sync.js +306 -0
  93. package/cli/epic-story-validator.js +659 -0
  94. package/cli/evaluation-prompts.js +1008 -0
  95. package/cli/execution-context.js +195 -0
  96. package/cli/generate-summary-table.js +340 -0
  97. package/cli/init-model-config.js +704 -0
  98. package/cli/init.js +1737 -278
  99. package/cli/kanban-server-manager.js +227 -0
  100. package/cli/llm-claude.js +150 -1
  101. package/cli/llm-gemini.js +109 -0
  102. package/cli/llm-local.js +493 -0
  103. package/cli/llm-mock.js +233 -0
  104. package/cli/llm-openai.js +454 -0
  105. package/cli/llm-provider.js +379 -3
  106. package/cli/llm-token-limits.js +211 -0
  107. package/cli/llm-verifier.js +662 -0
  108. package/cli/llm-xiaomi.js +143 -0
  109. package/cli/message-constants.js +49 -0
  110. package/cli/message-manager.js +334 -0
  111. package/cli/message-types.js +96 -0
  112. package/cli/messaging-api.js +291 -0
  113. package/cli/micro-check-fixer.js +335 -0
  114. package/cli/micro-check-runner.js +449 -0
  115. package/cli/micro-check-scorer.js +148 -0
  116. package/cli/micro-check-validator.js +538 -0
  117. package/cli/model-pricing.js +192 -0
  118. package/cli/model-query-engine.js +468 -0
  119. package/cli/model-recommendation-analyzer.js +495 -0
  120. package/cli/model-selector.js +270 -0
  121. package/cli/output-buffer.js +107 -0
  122. package/cli/process-manager.js +73 -2
  123. package/cli/prompt-logger.js +57 -0
  124. package/cli/repl-ink.js +4625 -1094
  125. package/cli/repl-old.js +3 -4
  126. package/cli/seed-processor.js +962 -0
  127. package/cli/sprint-planning-processor.js +4162 -0
  128. package/cli/template-processor.js +2149 -105
  129. package/cli/templates/project.md +25 -8
  130. package/cli/templates/vitepress-config.mts.template +5 -4
  131. package/cli/token-tracker.js +547 -0
  132. package/cli/tools/generate-story-validators.js +317 -0
  133. package/cli/tools/generate-validators.js +669 -0
  134. package/cli/update-checker.js +19 -17
  135. package/cli/update-notifier.js +4 -4
  136. package/cli/validation-router.js +667 -0
  137. package/cli/verification-tracker.js +563 -0
  138. package/cli/worktree-runner.js +654 -0
  139. package/kanban/README.md +386 -0
  140. package/kanban/client/README.md +205 -0
  141. package/kanban/client/components.json +20 -0
  142. package/kanban/client/dist/assets/index-D_KC5EQT.css +1 -0
  143. package/kanban/client/dist/assets/index-DjY5zqW7.js +351 -0
  144. package/kanban/client/dist/index.html +16 -0
  145. package/kanban/client/dist/vite.svg +1 -0
  146. package/kanban/client/index.html +15 -0
  147. package/kanban/client/package-lock.json +9442 -0
  148. package/kanban/client/package.json +44 -0
  149. package/kanban/client/postcss.config.js +6 -0
  150. package/kanban/client/public/vite.svg +1 -0
  151. package/kanban/client/src/App.jsx +651 -0
  152. package/kanban/client/src/components/ProjectFileEditorPopup.jsx +117 -0
  153. package/kanban/client/src/components/ceremony/AskArchPopup.jsx +420 -0
  154. package/kanban/client/src/components/ceremony/AskModelPopup.jsx +629 -0
  155. package/kanban/client/src/components/ceremony/CeremonyWorkflowModal.jsx +1133 -0
  156. package/kanban/client/src/components/ceremony/EpicStorySelectionModal.jsx +254 -0
  157. package/kanban/client/src/components/ceremony/ProviderSwitcherButton.jsx +290 -0
  158. package/kanban/client/src/components/ceremony/SponsorCallModal.jsx +686 -0
  159. package/kanban/client/src/components/ceremony/SprintPlanningModal.jsx +838 -0
  160. package/kanban/client/src/components/ceremony/steps/ArchitectureStep.jsx +150 -0
  161. package/kanban/client/src/components/ceremony/steps/CompleteStep.jsx +136 -0
  162. package/kanban/client/src/components/ceremony/steps/DatabaseStep.jsx +202 -0
  163. package/kanban/client/src/components/ceremony/steps/DeploymentStep.jsx +123 -0
  164. package/kanban/client/src/components/ceremony/steps/MissionStep.jsx +106 -0
  165. package/kanban/client/src/components/ceremony/steps/ReviewAnswersStep.jsx +329 -0
  166. package/kanban/client/src/components/ceremony/steps/RunningStep.jsx +249 -0
  167. package/kanban/client/src/components/kanban/CardDetailModal.jsx +646 -0
  168. package/kanban/client/src/components/kanban/EpicSection.jsx +146 -0
  169. package/kanban/client/src/components/kanban/FilterToolbar.jsx +222 -0
  170. package/kanban/client/src/components/kanban/GroupingSelector.jsx +63 -0
  171. package/kanban/client/src/components/kanban/KanbanBoard.jsx +211 -0
  172. package/kanban/client/src/components/kanban/KanbanCard.jsx +147 -0
  173. package/kanban/client/src/components/kanban/KanbanColumn.jsx +90 -0
  174. package/kanban/client/src/components/kanban/RefineWorkItemPopup.jsx +784 -0
  175. package/kanban/client/src/components/kanban/RunButton.jsx +162 -0
  176. package/kanban/client/src/components/kanban/SeedButton.jsx +176 -0
  177. package/kanban/client/src/components/layout/LoadingScreen.jsx +82 -0
  178. package/kanban/client/src/components/process/ProcessMonitorBar.jsx +80 -0
  179. package/kanban/client/src/components/settings/AgentEditorPopup.jsx +171 -0
  180. package/kanban/client/src/components/settings/AgentsTab.jsx +381 -0
  181. package/kanban/client/src/components/settings/ApiKeysTab.jsx +142 -0
  182. package/kanban/client/src/components/settings/CeremonyModelsTab.jsx +105 -0
  183. package/kanban/client/src/components/settings/CheckEditorPopup.jsx +507 -0
  184. package/kanban/client/src/components/settings/CostThresholdsTab.jsx +95 -0
  185. package/kanban/client/src/components/settings/ModelPricingTab.jsx +269 -0
  186. package/kanban/client/src/components/settings/OpenAIAuthSection.jsx +412 -0
  187. package/kanban/client/src/components/settings/ServersTab.jsx +121 -0
  188. package/kanban/client/src/components/settings/SettingsModal.jsx +84 -0
  189. package/kanban/client/src/components/stats/CostModal.jsx +384 -0
  190. package/kanban/client/src/components/ui/badge.jsx +27 -0
  191. package/kanban/client/src/components/ui/dialog.jsx +121 -0
  192. package/kanban/client/src/components/ui/tabs.jsx +85 -0
  193. package/kanban/client/src/hooks/__tests__/useGrouping.test.js +232 -0
  194. package/kanban/client/src/hooks/useGrouping.js +177 -0
  195. package/kanban/client/src/hooks/useWebSocket.js +120 -0
  196. package/kanban/client/src/lib/__tests__/api.test.js +196 -0
  197. package/kanban/client/src/lib/__tests__/status-grouping.test.js +94 -0
  198. package/kanban/client/src/lib/api.js +515 -0
  199. package/kanban/client/src/lib/status-grouping.js +154 -0
  200. package/kanban/client/src/lib/utils.js +11 -0
  201. package/kanban/client/src/main.jsx +10 -0
  202. package/kanban/client/src/store/__tests__/kanbanStore.test.js +164 -0
  203. package/kanban/client/src/store/ceremonyStore.js +172 -0
  204. package/kanban/client/src/store/filterStore.js +201 -0
  205. package/kanban/client/src/store/kanbanStore.js +123 -0
  206. package/kanban/client/src/store/processStore.js +65 -0
  207. package/kanban/client/src/store/sprintPlanningStore.js +33 -0
  208. package/kanban/client/src/styles/globals.css +59 -0
  209. package/kanban/client/tailwind.config.js +77 -0
  210. package/kanban/client/vite.config.js +28 -0
  211. package/kanban/client/vitest.config.js +28 -0
  212. package/kanban/dev-start.sh +47 -0
  213. package/kanban/package.json +12 -0
  214. package/kanban/server/index.js +537 -0
  215. package/kanban/server/routes/ceremony.js +454 -0
  216. package/kanban/server/routes/costs.js +163 -0
  217. package/kanban/server/routes/openai-oauth.js +366 -0
  218. package/kanban/server/routes/processes.js +50 -0
  219. package/kanban/server/routes/settings.js +736 -0
  220. package/kanban/server/routes/websocket.js +281 -0
  221. package/kanban/server/routes/work-items.js +487 -0
  222. package/kanban/server/services/CeremonyService.js +1441 -0
  223. package/kanban/server/services/FileSystemScanner.js +95 -0
  224. package/kanban/server/services/FileWatcher.js +144 -0
  225. package/kanban/server/services/HierarchyBuilder.js +196 -0
  226. package/kanban/server/services/ProcessRegistry.js +122 -0
  227. package/kanban/server/services/TaskRunnerService.js +261 -0
  228. package/kanban/server/services/WorkItemReader.js +123 -0
  229. package/kanban/server/services/WorkItemRefineService.js +510 -0
  230. package/kanban/server/start.js +49 -0
  231. package/kanban/server/utils/kanban-logger.js +132 -0
  232. package/kanban/server/utils/markdown.js +91 -0
  233. package/kanban/server/utils/status-grouping.js +107 -0
  234. package/kanban/server/workers/run-task-worker.js +121 -0
  235. package/kanban/server/workers/seed-worker.js +94 -0
  236. package/kanban/server/workers/sponsor-call-worker.js +92 -0
  237. package/kanban/server/workers/sprint-planning-worker.js +212 -0
  238. package/package.json +19 -7
  239. package/cli/agents/documentation.md +0 -302
@@ -0,0 +1,1441 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import { fork } from 'child_process';
4
+ import { fileURLToPath } from 'url';
5
+ import { KanbanLogger } from '../utils/kanban-logger.js';
6
+ import { TokenTracker } from '../../../cli/token-tracker.js';
7
+ import { loadAgent } from '../../../cli/agent-loader.js';
8
+ import { PromptLogger } from '../../../cli/prompt-logger.js';
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+
11
+ const PROVIDER_KEY_MAP = {
12
+ claude: 'ANTHROPIC_API_KEY',
13
+ gemini: 'GEMINI_API_KEY',
14
+ openai: 'OPENAI_API_KEY',
15
+ xiaomi: 'XIAOMI_API_KEY',
16
+ };
17
+
18
+ /** Returns true when the provider is OpenAI and OAuth mode is active (flat-rate — no per-token billing). */
19
+ function isOAuthProvider(provider) {
20
+ return provider === 'openai' && process.env.OPENAI_AUTH_MODE === 'oauth';
21
+ }
22
+
23
+ // Returns true if a provider has any valid auth credential (API key or token/oauth)
24
+ function hasProviderAuth(provider, projectRoot) {
25
+ if (provider === 'local') return true; // No API key needed for local models
26
+ if (provider === 'claude') {
27
+ return !!process.env.ANTHROPIC_API_KEY;
28
+ }
29
+ if (provider === 'openai') {
30
+ const oauthFile = path.join(projectRoot, '.avc', 'openai-oauth.json');
31
+ return !!(process.env.OPENAI_API_KEY ||
32
+ (process.env.OPENAI_AUTH_MODE === 'oauth' && fs.existsSync(oauthFile)));
33
+ }
34
+ return !!process.env[PROVIDER_KEY_MAP[provider]];
35
+ }
36
+
37
+ /**
38
+ * CeremonyService
39
+ * Orchestrates the sponsor-call ceremony from the web UI.
40
+ * Wraps TemplateProcessor and ProjectInitiator methods,
41
+ * manages in-memory ceremony state, and broadcasts WebSocket events.
42
+ */
43
+ export class CeremonyService {
44
+ constructor(projectRoot) {
45
+ this.projectRoot = projectRoot;
46
+ this.state = { status: 'idle', progress: [], result: null, error: null, costLimitInfo: null, quotaLimitInfo: null, decomposedHierarchy: null };
47
+ this.websocket = null;
48
+ this._paused = false;
49
+ this._cancelled = false;
50
+ this._runningType = null; // 'sprint-planning' | 'sponsor-call'
51
+ this._activeProcessId = null; // processId of the currently running ceremony worker
52
+ this._preRunSnapshot = []; // dirs that existed before sprint-planning run
53
+ this._activeChild = null; // forked ChildProcess (if fork-based run)
54
+ }
55
+
56
+ pause() {
57
+ this._paused = true;
58
+ if (this._activeChild) {
59
+ // Fork-based: send IPC; worker will reply with { type: 'paused' } which triggers broadcast
60
+ try { this._activeChild.send({ type: 'pause' }); } catch (_) {}
61
+ } else {
62
+ // In-process: broadcast immediately
63
+ if (this._runningType === 'sprint-planning') this.websocket?.broadcastSprintPlanningPaused();
64
+ else this.websocket?.broadcastCeremonyPaused();
65
+ }
66
+ }
67
+
68
+ resume() {
69
+ this._paused = false;
70
+ if (this._activeChild) {
71
+ try { this._activeChild.send({ type: 'resume' }); } catch (_) {}
72
+ } else {
73
+ if (this._runningType === 'sprint-planning') this.websocket?.broadcastSprintPlanningResumed();
74
+ else this.websocket?.broadcastCeremonyResumed();
75
+ }
76
+ }
77
+
78
+ cancel({ keepItems = false } = {}) {
79
+ this._cancelled = true;
80
+ this._keepItemsOnCancel = keepItems;
81
+ // Mark state as cancelling so handleWorkerExit can distinguish cancel-in-progress
82
+ // from a genuine unexpected crash (the worker may exit before sending 'cancelled' IPC).
83
+ this.state.status = 'cancelling';
84
+ if (this._activeChild) {
85
+ try { this._activeChild.send({ type: 'cancel' }); } catch (_) {}
86
+ // Safety net: if the worker doesn't exit within 10s (stuck in a long LLM call),
87
+ // send SIGTERM to force it down. handleWorkerExit will clean up.
88
+ const child = this._activeChild;
89
+ this._cancelKillTimer = setTimeout(() => {
90
+ this._cancelKillTimer = null;
91
+ if (child === this._activeChild && this.state.status === 'cancelling') {
92
+ console.log('[ceremony] cancel timeout — sending SIGTERM to worker');
93
+ try { child.kill('SIGTERM'); } catch (_) {}
94
+ // Last resort: SIGKILL after 3 more seconds
95
+ this._cancelKillTimer = setTimeout(() => {
96
+ this._cancelKillTimer = null;
97
+ if (child === this._activeChild) {
98
+ console.log('[ceremony] cancel SIGTERM timeout — sending SIGKILL');
99
+ try { child.kill('SIGKILL'); } catch (_) {}
100
+ }
101
+ }, 3000);
102
+ }
103
+ }, 10000);
104
+ }
105
+ const isSprintPlanning = this._runningType === 'sprint-planning';
106
+ const msg = 'Waiting for current LLM call to finish…';
107
+ this.state.progress.push({ type: 'detail', detail: msg });
108
+ if (isSprintPlanning) this.websocket?.broadcastSprintPlanningDetail(msg);
109
+ else this.websocket?.broadcastCeremonyDetail(msg);
110
+ }
111
+
112
+ forceReset() {
113
+ this._cancelled = true;
114
+ this._paused = false;
115
+ if (this._cancelKillTimer) { clearTimeout(this._cancelKillTimer); this._cancelKillTimer = null; }
116
+ if (this._activeChild) {
117
+ try { this._activeChild.send({ type: 'cancel' }); } catch (_) {}
118
+ const child = this._activeChild;
119
+ setTimeout(() => { try { child.kill('SIGTERM'); } catch (_) {} }, 3000);
120
+ this._activeChild = null;
121
+ }
122
+ const wasRunningType = this._runningType;
123
+ this._runningType = null;
124
+ this._activeProcessId = null;
125
+ this.state = { status: 'idle', progress: [], result: null, error: null, costLimitInfo: null, quotaLimitInfo: null, decomposedHierarchy: null };
126
+ // Broadcast to whichever ceremony was running (or both if unknown)
127
+ if (wasRunningType === 'sprint-planning' || !wasRunningType) {
128
+ this.websocket?.broadcastSprintPlanningCancelled();
129
+ }
130
+ if (wasRunningType === 'sponsor-call' || !wasRunningType) {
131
+ this.websocket?.broadcastCeremonyCancelled();
132
+ }
133
+ }
134
+
135
+ _cleanupCancelledSprintPlanning() {
136
+ const projectDir = path.join(this.projectRoot, '.avc', 'project');
137
+ if (!fs.existsSync(projectDir)) {
138
+ console.log('[ceremony] cleanup: project dir does not exist, nothing to delete');
139
+ return;
140
+ }
141
+ const current = fs.readdirSync(projectDir);
142
+ const toDelete = current.filter(d => !this._preRunSnapshot.includes(d));
143
+ console.log(`[ceremony] cleanup: snapshot=${this._preRunSnapshot?.length ?? 'null'} entries, current=${current.length}, toDelete=${toDelete.length}`);
144
+ for (const d of toDelete) {
145
+ try {
146
+ fs.rmSync(path.join(projectDir, d), { recursive: true, force: true });
147
+ console.log(`[ceremony] cleanup: deleted ${d}`);
148
+ } catch (err) {
149
+ console.error(`[ceremony] cleanup: failed to delete ${d}: ${err.message}`);
150
+ }
151
+ }
152
+ }
153
+
154
+ setWebSocket(ws) {
155
+ this.websocket = ws;
156
+ }
157
+
158
+ setReloadCallback(fn) {
159
+ this._reloadCallback = fn;
160
+ }
161
+
162
+ getStatus() {
163
+ return {
164
+ status: this.state.status,
165
+ runningType: this._runningType,
166
+ processId: this._activeProcessId,
167
+ progress: this.state.progress,
168
+ result: this.state.result,
169
+ error: this.state.error,
170
+ costLimitInfo: this.state.costLimitInfo || null,
171
+ quotaLimitInfo: this.state.quotaLimitInfo || null,
172
+ decomposedHierarchy: this.state.decomposedHierarchy || null,
173
+ };
174
+ }
175
+
176
+ async getAvailableModels() {
177
+ const { default: dotenv } = await import('dotenv');
178
+ dotenv.config({ path: path.join(this.projectRoot, '.env'), override: true });
179
+
180
+ const avcJsonPath = path.join(this.projectRoot, '.avc', 'avc.json');
181
+ const avcConfig = JSON.parse(fs.readFileSync(avcJsonPath, 'utf8'));
182
+ const models = avcConfig?.settings?.models || {};
183
+
184
+ return Object.entries(models).map(([modelId, info]) => ({
185
+ modelId,
186
+ displayName: info.displayName,
187
+ provider: info.provider,
188
+ hasApiKey: hasProviderAuth(info.provider, this.projectRoot),
189
+ }));
190
+ }
191
+
192
+ async generateMissionScope(description, modelId, provider, validatorModelId, validatorProvider) {
193
+ const log = new KanbanLogger('mission', this.projectRoot);
194
+ log.info('generateMissionScope() called', {
195
+ description: description.slice(0, 200),
196
+ generatorModel: { provider, modelId },
197
+ validatorModel: { provider: validatorProvider, modelId: validatorModelId },
198
+ });
199
+
200
+ try {
201
+ const { default: dotenv } = await import('dotenv');
202
+ dotenv.config({ path: path.join(this.projectRoot, '.env'), override: true });
203
+ log.debug('dotenv loaded', { envFile: path.join(this.projectRoot, '.env') });
204
+
205
+ // Read validation settings exclusively from avc.json
206
+ const avcJsonPath = path.join(this.projectRoot, '.avc', 'avc.json');
207
+ log.debug('Reading avc.json', { path: avcJsonPath });
208
+ const avcConfig = JSON.parse(fs.readFileSync(avcJsonPath, 'utf8'));
209
+ const vs = avcConfig?.settings?.missionGenerator?.validation;
210
+ if (!vs) {
211
+ const err = new Error(
212
+ 'Missing settings.missionGenerator.validation in avc.json. ' +
213
+ 'Add: { "settings": { "missionGenerator": { "validation": { "maxIterations": 3, "acceptanceThreshold": 75 } } } }'
214
+ );
215
+ log.error('Config missing missionGenerator.validation', { avcJsonPath });
216
+ throw err;
217
+ }
218
+ const maxIterations = vs.maxIterations;
219
+ const acceptanceThreshold = vs.acceptanceThreshold;
220
+ log.info('Validation config loaded', { maxIterations, acceptanceThreshold });
221
+
222
+ // Create LLM providers
223
+ log.debug('Creating LLM providers');
224
+ const { LLMProvider } = await import('../../../cli/llm-provider.js');
225
+ const generatorLLM = await LLMProvider.create(provider, modelId);
226
+ const validatorLLM = await LLMProvider.create(validatorProvider, validatorModelId);
227
+ log.info('LLM providers created', {
228
+ generator: `${provider}/${modelId}`,
229
+ validator: `${validatorProvider}/${validatorModelId}`,
230
+ });
231
+
232
+ // Attach prompt logger
233
+ const _missionPromptLogger = new PromptLogger(this.projectRoot, 'sponsor-call');
234
+ generatorLLM.setPromptLogger(_missionPromptLogger, 'mission-generate');
235
+ validatorLLM.setPromptLogger(_missionPromptLogger, 'mission-validate');
236
+
237
+ // Load agent files
238
+ log.debug('Loading agent files');
239
+ const generatorAgent = loadAgent('mission-scope-generator.md', this.projectRoot);
240
+ const validatorAgent = loadAgent('mission-scope-validator.md', this.projectRoot);
241
+ log.debug('Agent files loaded', {
242
+ generatorBytes: generatorAgent.length,
243
+ validatorBytes: validatorAgent.length,
244
+ });
245
+
246
+ const emit = (step, message) => {
247
+ log.debug(`[WS emit] ${step}: ${message}`);
248
+ this.websocket?.broadcastMissionProgress(step, message);
249
+ };
250
+
251
+ // ── Step 1: Initial generation ─────────────────────────────────────────
252
+ emit('generating', 'Generating initial mission & scope…');
253
+ const generatorPrompt =
254
+ `The user wants to build:\n\n${description}\n\nGenerate a focused mission statement and initial scope.`;
255
+ log.info('STEP 1: Initial generation — calling generator LLM', {
256
+ model: `${provider}/${modelId}`,
257
+ promptLength: generatorPrompt.length,
258
+ });
259
+
260
+ let result = await generatorLLM.generateJSON(generatorPrompt, generatorAgent);
261
+ log.info('STEP 1: Generator LLM responded', {
262
+ missionStatement: result.missionStatement,
263
+ initialScope: result.initialScope,
264
+ hasTokenUsage: typeof generatorLLM.getTokenUsage === 'function',
265
+ tokenUsage: typeof generatorLLM.getTokenUsage === 'function' ? generatorLLM.getTokenUsage() : null,
266
+ });
267
+
268
+ if (!result.missionStatement || !result.initialScope) {
269
+ log.error('STEP 1: Incomplete output from generator', { result });
270
+ throw new Error('Model returned incomplete output — missing missionStatement or initialScope');
271
+ }
272
+
273
+ // ── Iterative validate → refine loop ───────────────────────────────────
274
+ let validationResult = null;
275
+ let validationsRun = 0;
276
+
277
+ log.info('Starting validate→refine loop', { maxIterations, acceptanceThreshold });
278
+
279
+ while (validationsRun < maxIterations) {
280
+ // Step 2: Validate
281
+ emit('validating', `Validating result (pass ${validationsRun + 1} of ${maxIterations})…`);
282
+ const validatorPrompt =
283
+ `User description: ${description}\n\n` +
284
+ `Mission Statement: ${result.missionStatement}\n\n` +
285
+ `Initial Scope:\n${result.initialScope}\n\n` +
286
+ `Validate this mission and scope.`;
287
+
288
+ log.info(`STEP 2 [iter ${validationsRun + 1}]: Calling validator LLM`, {
289
+ model: `${validatorProvider}/${validatorModelId}`,
290
+ promptLength: validatorPrompt.length,
291
+ currentMission: result.missionStatement,
292
+ currentScopeLines: result.initialScope.split('\n').length,
293
+ });
294
+
295
+ validationResult = await validatorLLM.generateJSON(validatorPrompt, validatorAgent);
296
+ validationsRun++;
297
+
298
+ log.info(`STEP 2 [iter ${validationsRun}]: Validator LLM responded`, {
299
+ overallScore: validationResult.overallScore,
300
+ validationStatus: validationResult.validationStatus,
301
+ readyToUse: validationResult.readyToUse,
302
+ issueCount: validationResult.issues?.length ?? 0,
303
+ issues: validationResult.issues,
304
+ strengths: validationResult.strengths,
305
+ improvementPriorities: validationResult.improvementPriorities,
306
+ tokenUsage: typeof validatorLLM.getTokenUsage === 'function' ? validatorLLM.getTokenUsage() : null,
307
+ });
308
+
309
+ const score = Number(validationResult.overallScore) || 0;
310
+ log.debug(`Score check: ${score} >= ${acceptanceThreshold}?`, {
311
+ score,
312
+ acceptanceThreshold,
313
+ passes: score >= acceptanceThreshold,
314
+ });
315
+
316
+ if (score >= acceptanceThreshold) {
317
+ emit('done', `Quality check passed — score ${score}/100`);
318
+ log.info(`Loop EXIT: score ${score} >= threshold ${acceptanceThreshold} — accepting result`, {
319
+ validationsRun,
320
+ });
321
+ break;
322
+ }
323
+
324
+ if (validationsRun >= maxIterations) {
325
+ emit('done', `Max iterations reached — score ${score}/100`);
326
+ log.warn(`Loop EXIT: max iterations (${maxIterations}) reached — accepting current result`, {
327
+ finalScore: score,
328
+ validationsRun,
329
+ });
330
+ break;
331
+ }
332
+
333
+ // Step 3: Refine
334
+ emit('refining', `Refining based on ${validationResult.issues?.length ?? 0} issue(s)…`);
335
+ const issues = validationResult.issues || [];
336
+ const issueSummary = issues
337
+ .map(i => `- [${i.severity.toUpperCase()}] ${i.field}: ${i.description} → ${i.suggestion}`)
338
+ .join('\n');
339
+
340
+ const refinePrompt =
341
+ `Original description: ${description}\n\n` +
342
+ `Previous mission statement: ${result.missionStatement}\n` +
343
+ `Previous scope:\n${result.initialScope}\n\n` +
344
+ `Validation score: ${score}/100\n` +
345
+ `Issues to fix:\n${issueSummary}\n\n` +
346
+ `Refine the mission and scope to address these issues.`;
347
+
348
+ log.info(`STEP 3 [iter ${validationsRun}]: Calling generator LLM for refinement`, {
349
+ model: `${provider}/${modelId}`,
350
+ score,
351
+ issueCount: issues.length,
352
+ issueSummary,
353
+ promptLength: refinePrompt.length,
354
+ });
355
+
356
+ result = await generatorLLM.generateJSON(refinePrompt, generatorAgent);
357
+
358
+ log.info(`STEP 3 [iter ${validationsRun}]: Generator LLM refinement responded`, {
359
+ missionStatement: result.missionStatement,
360
+ initialScope: result.initialScope,
361
+ tokenUsage: typeof generatorLLM.getTokenUsage === 'function' ? generatorLLM.getTokenUsage() : null,
362
+ });
363
+
364
+ if (!result.missionStatement || !result.initialScope) {
365
+ log.error(`STEP 3 [iter ${validationsRun}]: Refinement returned incomplete output`, { result });
366
+ throw new Error('Refinement returned incomplete output');
367
+ }
368
+ }
369
+
370
+ const finalScore = validationResult ? Number(validationResult.overallScore) || 0 : null;
371
+ // Only surface issues when the final score did NOT pass the threshold.
372
+ // A passing validation may still return "resolution notes" in issues[] — those are
373
+ // misleading when shown to the user as if they were real problems.
374
+ const finalIssues = (finalScore !== null && finalScore >= acceptanceThreshold)
375
+ ? []
376
+ : (validationResult?.issues || []);
377
+ const returnValue = {
378
+ missionStatement: result.missionStatement,
379
+ initialScope: result.initialScope,
380
+ validationScore: finalScore,
381
+ iterations: validationsRun,
382
+ issues: finalIssues,
383
+ };
384
+
385
+ log.info('generateMissionScope() completed successfully', {
386
+ validationScore: finalScore,
387
+ iterations: validationsRun,
388
+ issueCount: returnValue.issues.length,
389
+ missionStatement: result.missionStatement,
390
+ });
391
+ log.finish(true, `score=${finalScore} iterations=${validationsRun}`);
392
+
393
+ // Track token usage
394
+ try {
395
+ const tracker = new TokenTracker(path.join(this.projectRoot, '.avc'));
396
+ const genUsage = generatorLLM.getTokenUsage();
397
+ if (genUsage.totalCalls > 0) {
398
+ tracker.addExecution('mission-scope', {
399
+ input: genUsage.inputTokens,
400
+ output: genUsage.outputTokens,
401
+ provider,
402
+ model: modelId,
403
+ skipCost: isOAuthProvider(provider),
404
+ });
405
+ }
406
+ const valUsage = validatorLLM.getTokenUsage();
407
+ if (valUsage.totalCalls > 0) {
408
+ tracker.addExecution('mission-scope', {
409
+ input: valUsage.inputTokens,
410
+ output: valUsage.outputTokens,
411
+ provider: validatorProvider,
412
+ model: validatorModelId,
413
+ skipCost: isOAuthProvider(validatorProvider),
414
+ });
415
+ }
416
+ } catch (trackErr) {
417
+ log.warn('Failed to track token usage', { error: trackErr.message });
418
+ }
419
+
420
+ return returnValue;
421
+
422
+ } catch (err) {
423
+ log.error('generateMissionScope() threw an error', {
424
+ message: err.message,
425
+ stack: err.stack,
426
+ });
427
+ log.finish(false, err.message);
428
+ throw err;
429
+ }
430
+ }
431
+
432
+ async refineMissionScope(missionStatement, initialScope, refinementRequest, modelId, provider, validatorModelId, validatorProvider) {
433
+ const log = new KanbanLogger('mission-refine', this.projectRoot);
434
+ log.info('refineMissionScope() called', {
435
+ missionStatement: missionStatement.slice(0, 200),
436
+ refinementRequest: refinementRequest.slice(0, 200),
437
+ generatorModel: { provider, modelId },
438
+ validatorModel: { provider: validatorProvider, modelId: validatorModelId },
439
+ });
440
+
441
+ try {
442
+ const { default: dotenv } = await import('dotenv');
443
+ dotenv.config({ path: path.join(this.projectRoot, '.env'), override: true });
444
+
445
+ const avcJsonPath = path.join(this.projectRoot, '.avc', 'avc.json');
446
+ const avcConfig = JSON.parse(fs.readFileSync(avcJsonPath, 'utf8'));
447
+ const vs = avcConfig?.settings?.missionGenerator?.validation;
448
+ if (!vs) {
449
+ throw new Error(
450
+ 'Missing settings.missionGenerator.validation in avc.json. ' +
451
+ 'Add: { "settings": { "missionGenerator": { "validation": { "maxIterations": 3, "acceptanceThreshold": 75 } } } }'
452
+ );
453
+ }
454
+ const maxIterations = vs.maxIterations;
455
+ const acceptanceThreshold = vs.acceptanceThreshold;
456
+ log.info('Validation config loaded', { maxIterations, acceptanceThreshold });
457
+
458
+ const { LLMProvider } = await import('../../../cli/llm-provider.js');
459
+ const generatorLLM = await LLMProvider.create(provider, modelId);
460
+ const validatorLLM = await LLMProvider.create(validatorProvider, validatorModelId);
461
+
462
+ // Attach prompt logger
463
+ const _refinePromptLogger = new PromptLogger(this.projectRoot, 'sponsor-call');
464
+ generatorLLM.setPromptLogger(_refinePromptLogger, 'mission-refine-generate');
465
+ validatorLLM.setPromptLogger(_refinePromptLogger, 'mission-refine-validate');
466
+
467
+ const generatorAgent = loadAgent('mission-scope-generator.md', this.projectRoot);
468
+ const validatorAgent = loadAgent('mission-scope-validator.md', this.projectRoot);
469
+
470
+ const emit = (step, message) => {
471
+ log.debug(`[WS emit] ${step}: ${message}`);
472
+ this.websocket?.broadcastMissionProgress(step, message);
473
+ };
474
+
475
+ // ── Step 1: Initial refinement ─────────────────────────────────────────
476
+ emit('generating', 'Refining mission & scope…');
477
+ const generatorPrompt =
478
+ `Current mission statement:\n${missionStatement}\n\n` +
479
+ `Current initial scope:\n${initialScope}\n\n` +
480
+ `The user has requested the following refinement:\n${refinementRequest}\n\n` +
481
+ `Refine the mission statement and initial scope accordingly.`;
482
+ log.info('STEP 1: Initial refinement — calling generator LLM', {
483
+ model: `${provider}/${modelId}`,
484
+ promptLength: generatorPrompt.length,
485
+ });
486
+
487
+ let result = await generatorLLM.generateJSON(generatorPrompt, generatorAgent);
488
+ log.info('STEP 1: Generator LLM responded', {
489
+ missionStatement: result.missionStatement,
490
+ initialScope: result.initialScope,
491
+ });
492
+
493
+ if (!result.missionStatement || !result.initialScope) {
494
+ log.error('STEP 1: Incomplete output from generator', { result });
495
+ throw new Error('Model returned incomplete output — missing missionStatement or initialScope');
496
+ }
497
+
498
+ // ── Iterative validate → refine loop ───────────────────────────────────
499
+ let validationResult = null;
500
+ let validationsRun = 0;
501
+
502
+ log.info('Starting validate→refine loop', { maxIterations, acceptanceThreshold });
503
+
504
+ while (validationsRun < maxIterations) {
505
+ emit('validating', `Validating result (pass ${validationsRun + 1} of ${maxIterations})…`);
506
+ const validatorPrompt =
507
+ `Refinement request: ${refinementRequest}\n\n` +
508
+ `Mission Statement: ${result.missionStatement}\n\n` +
509
+ `Initial Scope:\n${result.initialScope}\n\n` +
510
+ `Validate this mission and scope.`;
511
+
512
+ log.info(`STEP 2 [iter ${validationsRun + 1}]: Calling validator LLM`, {
513
+ model: `${validatorProvider}/${validatorModelId}`,
514
+ promptLength: validatorPrompt.length,
515
+ });
516
+
517
+ validationResult = await validatorLLM.generateJSON(validatorPrompt, validatorAgent);
518
+ validationsRun++;
519
+
520
+ log.info(`STEP 2 [iter ${validationsRun}]: Validator LLM responded`, {
521
+ overallScore: validationResult.overallScore,
522
+ issueCount: validationResult.issues?.length ?? 0,
523
+ });
524
+
525
+ const score = Number(validationResult.overallScore) || 0;
526
+
527
+ if (score >= acceptanceThreshold) {
528
+ emit('done', `Quality check passed — score ${score}/100`);
529
+ log.info(`Loop EXIT: score ${score} >= threshold ${acceptanceThreshold}`, { validationsRun });
530
+ break;
531
+ }
532
+
533
+ if (validationsRun >= maxIterations) {
534
+ emit('done', `Max iterations reached — score ${score}/100`);
535
+ log.warn(`Loop EXIT: max iterations (${maxIterations}) reached`, { finalScore: score, validationsRun });
536
+ break;
537
+ }
538
+
539
+ emit('refining', `Refining based on ${validationResult.issues?.length ?? 0} issue(s)…`);
540
+ const issues = validationResult.issues || [];
541
+ const issueSummary = issues
542
+ .map(i => `- [${i.severity.toUpperCase()}] ${i.field}: ${i.description} → ${i.suggestion}`)
543
+ .join('\n');
544
+
545
+ const refinePrompt =
546
+ `Refinement request: ${refinementRequest}\n\n` +
547
+ `Previous mission statement: ${result.missionStatement}\n` +
548
+ `Previous scope:\n${result.initialScope}\n\n` +
549
+ `Validation score: ${score}/100\n` +
550
+ `Issues to fix:\n${issueSummary}\n\n` +
551
+ `Refine the mission and scope to address these issues while honouring the refinement request.`;
552
+
553
+ log.info(`STEP 3 [iter ${validationsRun}]: Calling generator LLM for refinement`, {
554
+ model: `${provider}/${modelId}`,
555
+ score,
556
+ issueCount: issues.length,
557
+ promptLength: refinePrompt.length,
558
+ });
559
+
560
+ result = await generatorLLM.generateJSON(refinePrompt, generatorAgent);
561
+
562
+ log.info(`STEP 3 [iter ${validationsRun}]: Generator LLM refinement responded`, {
563
+ missionStatement: result.missionStatement,
564
+ });
565
+
566
+ if (!result.missionStatement || !result.initialScope) {
567
+ log.error(`STEP 3 [iter ${validationsRun}]: Refinement returned incomplete output`, { result });
568
+ throw new Error('Refinement returned incomplete output');
569
+ }
570
+ }
571
+
572
+ const finalScore = validationResult ? Number(validationResult.overallScore) || 0 : null;
573
+ // Only surface issues when the final score did NOT pass the threshold.
574
+ const finalIssues = (finalScore !== null && finalScore >= acceptanceThreshold)
575
+ ? []
576
+ : (validationResult?.issues || []);
577
+ const returnValue = {
578
+ missionStatement: result.missionStatement,
579
+ initialScope: result.initialScope,
580
+ validationScore: finalScore,
581
+ iterations: validationsRun,
582
+ issues: finalIssues,
583
+ };
584
+
585
+ log.info('refineMissionScope() completed successfully', {
586
+ validationScore: finalScore,
587
+ iterations: validationsRun,
588
+ issueCount: returnValue.issues.length,
589
+ });
590
+ log.finish(true, `score=${finalScore} iterations=${validationsRun}`);
591
+
592
+ // Track token usage
593
+ try {
594
+ const tracker = new TokenTracker(path.join(this.projectRoot, '.avc'));
595
+ const genUsage = generatorLLM.getTokenUsage();
596
+ if (genUsage.totalCalls > 0) {
597
+ tracker.addExecution('mission-refine', {
598
+ input: genUsage.inputTokens,
599
+ output: genUsage.outputTokens,
600
+ provider,
601
+ model: modelId,
602
+ skipCost: isOAuthProvider(provider),
603
+ });
604
+ }
605
+ const valUsage = validatorLLM.getTokenUsage();
606
+ if (valUsage.totalCalls > 0) {
607
+ tracker.addExecution('mission-refine', {
608
+ input: valUsage.inputTokens,
609
+ output: valUsage.outputTokens,
610
+ provider: validatorProvider,
611
+ model: validatorModelId,
612
+ skipCost: isOAuthProvider(validatorProvider),
613
+ });
614
+ }
615
+ } catch (trackErr) {
616
+ log.warn('Failed to track token usage', { error: trackErr.message });
617
+ }
618
+
619
+ return returnValue;
620
+
621
+ } catch (err) {
622
+ log.error('refineMissionScope() threw an error', { message: err.message, stack: err.stack });
623
+ log.finish(false, err.message);
624
+ throw err;
625
+ }
626
+ }
627
+
628
+ async generateCustomArchitecture(description, modelId, provider) {
629
+ const log = new KanbanLogger('arch-custom', this.projectRoot);
630
+ const { default: dotenv } = await import('dotenv');
631
+ dotenv.config({ path: path.join(this.projectRoot, '.env'), override: true });
632
+
633
+ const { LLMProvider } = await import('../../../cli/llm-provider.js');
634
+ const llm = await LLMProvider.create(provider, modelId);
635
+ llm.setPromptLogger(new PromptLogger(this.projectRoot, 'sponsor-call'), 'arch-generate');
636
+ const agentInstruction = loadAgent('architecture-recommender.md', this.projectRoot);
637
+
638
+ const prompt =
639
+ `The user wants a SINGLE custom architecture for their project.\n\n` +
640
+ `User description: ${description}\n\n` +
641
+ `Return a JSON object with EXACTLY ONE architecture in the "architectures" array ` +
642
+ `matching the user's description. Include all required fields: ` +
643
+ `name, description, requiresCloudProvider, bestFor, costTier. ` +
644
+ `Optionally include migrationPath if applicable.`;
645
+
646
+ const result = await llm.generateJSON(prompt, agentInstruction);
647
+ const arch = (result.architectures || [])[0];
648
+ if (!arch?.name || !arch?.description) {
649
+ throw new Error('Model returned incomplete architecture — missing name or description');
650
+ }
651
+ log.info('generateCustomArchitecture result', { archName: arch.name });
652
+ return arch;
653
+ }
654
+
655
+ async refineCustomArchitecture(currentArch, refinementRequest, modelId, provider) {
656
+ const log = new KanbanLogger('arch-custom', this.projectRoot);
657
+ const { default: dotenv } = await import('dotenv');
658
+ dotenv.config({ path: path.join(this.projectRoot, '.env'), override: true });
659
+
660
+ const { LLMProvider } = await import('../../../cli/llm-provider.js');
661
+ const llm = await LLMProvider.create(provider, modelId);
662
+ llm.setPromptLogger(new PromptLogger(this.projectRoot, 'sponsor-call'), 'arch-refine');
663
+ const agentInstruction = loadAgent('architecture-recommender.md', this.projectRoot);
664
+
665
+ const prompt =
666
+ `Refine the following architecture based on the user's request.\n\n` +
667
+ `Current architecture: ${JSON.stringify(currentArch, null, 2)}\n\n` +
668
+ `Refinement request: ${refinementRequest}\n\n` +
669
+ `Return a JSON object with EXACTLY ONE updated architecture in the "architectures" array.`;
670
+
671
+ const result = await llm.generateJSON(prompt, agentInstruction);
672
+ const arch = (result.architectures || [])[0];
673
+ if (!arch?.name || !arch?.description) {
674
+ throw new Error('Model returned incomplete architecture');
675
+ }
676
+ log.info('refineCustomArchitecture result', { archName: arch.name });
677
+ return arch;
678
+ }
679
+
680
+ async analyzeDatabase(mission, scope, strategy) {
681
+ const log = new KanbanLogger('analyze-db', this.projectRoot);
682
+ log.info('analyzeDatabase() called', {
683
+ missionLength: mission?.length,
684
+ scopeLines: scope?.split('\n').length,
685
+ strategy,
686
+ });
687
+ try {
688
+ const { TemplateProcessor } = await import('../../../cli/template-processor.js');
689
+ const p = new TemplateProcessor('sponsor-call', null, true);
690
+ log.debug('Calling getDatabaseRecommendation');
691
+ const result = await p.getDatabaseRecommendation(mission, scope, strategy);
692
+ log.info('analyzeDatabase() completed', { resultKeys: Object.keys(result || {}) });
693
+
694
+ // Track token usage from TemplateProcessor's internal providers
695
+ try {
696
+ const tracker = new TokenTracker(path.join(this.projectRoot, '.avc'));
697
+ if (p._stageProviders) {
698
+ for (const providerInstance of Object.values(p._stageProviders)) {
699
+ if (typeof providerInstance.getTokenUsage === 'function') {
700
+ const usage = providerInstance.getTokenUsage();
701
+ if (usage.totalCalls > 0) {
702
+ tracker.addExecution('analyze-database', {
703
+ input: usage.inputTokens,
704
+ output: usage.outputTokens,
705
+ provider: usage.provider,
706
+ model: usage.model,
707
+ skipCost: isOAuthProvider(usage.provider),
708
+ });
709
+ }
710
+ }
711
+ }
712
+ }
713
+ } catch (trackErr) {
714
+ log.warn('Failed to track token usage', { error: trackErr.message });
715
+ }
716
+
717
+ log.finish(true);
718
+ return result;
719
+ } catch (err) {
720
+ log.error('analyzeDatabase() failed', { message: err.message, stack: err.stack });
721
+ log.finish(false, err.message);
722
+ throw err;
723
+ }
724
+ }
725
+
726
+ async analyzeArchitecture(mission, scope, dbContext, strategy) {
727
+ const log = new KanbanLogger('analyze-arch', this.projectRoot);
728
+ log.info('analyzeArchitecture() called', {
729
+ missionLength: mission?.length,
730
+ scopeLines: scope?.split('\n').length,
731
+ dbContext: dbContext ? 'provided' : 'null',
732
+ strategy,
733
+ });
734
+ try {
735
+ const { TemplateProcessor } = await import('../../../cli/template-processor.js');
736
+ const p = new TemplateProcessor('sponsor-call', null, true);
737
+ log.debug('Calling getArchitectureRecommendations');
738
+ const result = await p.getArchitectureRecommendations(mission, scope, dbContext, strategy);
739
+ log.info('analyzeArchitecture() completed', { resultKeys: Object.keys(result || {}) });
740
+
741
+ // Track token usage from TemplateProcessor's internal providers
742
+ try {
743
+ const tracker = new TokenTracker(path.join(this.projectRoot, '.avc'));
744
+ if (p._stageProviders) {
745
+ for (const providerInstance of Object.values(p._stageProviders)) {
746
+ if (typeof providerInstance.getTokenUsage === 'function') {
747
+ const usage = providerInstance.getTokenUsage();
748
+ if (usage.totalCalls > 0) {
749
+ tracker.addExecution('analyze-architecture', {
750
+ input: usage.inputTokens,
751
+ output: usage.outputTokens,
752
+ provider: usage.provider,
753
+ model: usage.model,
754
+ skipCost: isOAuthProvider(usage.provider),
755
+ });
756
+ }
757
+ }
758
+ }
759
+ }
760
+ } catch (trackErr) {
761
+ log.warn('Failed to track token usage', { error: trackErr.message });
762
+ }
763
+
764
+ log.finish(true);
765
+ return result;
766
+ } catch (err) {
767
+ log.error('analyzeArchitecture() failed', { message: err.message, stack: err.stack });
768
+ log.finish(false, err.message);
769
+ throw err;
770
+ }
771
+ }
772
+
773
+ async prefillAnswers(mission, scope, arch, dbContext, strategy) {
774
+ const log = new KanbanLogger('prefill', this.projectRoot);
775
+ log.info('prefillAnswers() called', {
776
+ missionLength: mission?.length,
777
+ scopeLines: scope?.split('\n').length,
778
+ arch: arch ? 'provided' : 'null',
779
+ dbContext: dbContext ? 'provided' : 'null',
780
+ strategy,
781
+ });
782
+ try {
783
+ const { TemplateProcessor } = await import('../../../cli/template-processor.js');
784
+ const p = new TemplateProcessor('sponsor-call', null, true);
785
+ log.debug('Calling prefillQuestions');
786
+ // cloudProvider is null — we let the architecture name carry that context
787
+ const result = await p.prefillQuestions(mission, scope, arch, null, dbContext, strategy);
788
+ log.info('prefillAnswers() completed', { resultKeys: Object.keys(result || {}) });
789
+
790
+ // Track token usage from TemplateProcessor's internal providers
791
+ try {
792
+ const tracker = new TokenTracker(path.join(this.projectRoot, '.avc'));
793
+ if (p._stageProviders) {
794
+ for (const providerInstance of Object.values(p._stageProviders)) {
795
+ if (typeof providerInstance.getTokenUsage === 'function') {
796
+ const usage = providerInstance.getTokenUsage();
797
+ if (usage.totalCalls > 0) {
798
+ tracker.addExecution('prefill-answers', {
799
+ input: usage.inputTokens,
800
+ output: usage.outputTokens,
801
+ provider: usage.provider,
802
+ model: usage.model,
803
+ skipCost: isOAuthProvider(usage.provider),
804
+ });
805
+ }
806
+ }
807
+ }
808
+ }
809
+ } catch (trackErr) {
810
+ log.warn('Failed to track token usage', { error: trackErr.message });
811
+ }
812
+
813
+ log.finish(true);
814
+ return result;
815
+ } catch (err) {
816
+ log.error('prefillAnswers() failed', { message: err.message, stack: err.stack });
817
+ log.finish(false, err.message);
818
+ throw err;
819
+ }
820
+ }
821
+
822
+ /**
823
+ * Refine a single requirement field using an LLM.
824
+ * @param {string} fieldKey - e.g. 'TARGET_USERS'
825
+ * @param {string} fieldLabel - e.g. 'Target Users'
826
+ * @param {string} currentValue - current field content
827
+ * @param {string} refinementRequest - user's instruction for improvement
828
+ * @param {object} context - { mission, scope } for grounding
829
+ * @param {string} modelId
830
+ * @param {string} provider
831
+ * @returns {Promise<{ value: string }>}
832
+ */
833
+ async refineField(fieldKey, fieldLabel, currentValue, refinementRequest, context, modelId, provider) {
834
+ const log = new KanbanLogger('refine-field', this.projectRoot);
835
+ log.info('refineField() called', { fieldKey, modelId, provider });
836
+
837
+ const { default: dotenv } = await import('dotenv');
838
+ dotenv.config({ path: path.join(this.projectRoot, '.env'), override: true });
839
+
840
+ const { LLMProvider } = await import('../../../cli/llm-provider.js');
841
+ const llm = await LLMProvider.create(provider, modelId);
842
+ llm.setPromptLogger(new PromptLogger(this.projectRoot, 'sponsor-call'), 'refine-field');
843
+
844
+ const prompt =
845
+ `You are helping refine a single field of a software project's requirements.\n\n` +
846
+ `Project Mission: ${context.mission}\n\n` +
847
+ `Project Scope: ${context.scope}\n\n` +
848
+ `Field: "${fieldLabel}" (key: ${fieldKey})\n` +
849
+ `Current value:\n${currentValue}\n\n` +
850
+ `User's refinement request: ${refinementRequest}\n\n` +
851
+ `Return a JSON object with a single key "value" containing the improved text for this field. ` +
852
+ `Keep the same general format and level of detail. Do not include any other keys.`;
853
+
854
+ try {
855
+ const result = await llm.generateJSON(prompt);
856
+ if (!result?.value) {
857
+ throw new Error('Model returned empty result');
858
+ }
859
+
860
+ // Track token usage
861
+ try {
862
+ const tracker = new TokenTracker(path.join(this.projectRoot, '.avc'));
863
+ const usage = llm.getTokenUsage();
864
+ if (usage.totalCalls > 0) {
865
+ tracker.addExecution('refine-field', {
866
+ input: usage.inputTokens,
867
+ output: usage.outputTokens,
868
+ provider: usage.provider,
869
+ model: usage.model,
870
+ skipCost: isOAuthProvider(usage.provider),
871
+ });
872
+ }
873
+ } catch (trackErr) {
874
+ log.warn('Failed to track token usage', { error: trackErr.message });
875
+ }
876
+
877
+ log.info('refineField() completed', { fieldKey, valueLength: result.value.length });
878
+ log.finish(true);
879
+ return { value: result.value };
880
+ } catch (err) {
881
+ log.error('refineField() failed', { fieldKey, message: err.message });
882
+ log.finish(false, err.message);
883
+ throw err;
884
+ }
885
+ }
886
+
887
+ // ── Fork-based ceremony execution ───────────────────────────────────────────
888
+
889
+ /**
890
+ * Shared dispatcher for worker IPC messages.
891
+ * Used both by the direct-fork path (child.on('message')) and the
892
+ * IPC relay path (handleWorkerMessage called from start.js).
893
+ * @param {object} msg - Worker IPC message
894
+ * @param {object} record - ProcessRegistry record
895
+ * @param {ProcessRegistry} registry
896
+ */
897
+ async _dispatchWorkerMessage(msg, record, registry) {
898
+ const entry = { ts: Date.now(), level: 'info', text: msg.message || msg.substep || msg.detail || '' };
899
+ const isSP = record.type === 'sprint-planning';
900
+ switch (msg.type) {
901
+ case 'progress':
902
+ registry.appendLog(record.id, entry);
903
+ this.state.progress.push({ type: 'progress', message: msg.message });
904
+ if (isSP) this.websocket?.broadcastSprintPlanningProgress(msg.message);
905
+ else this.websocket?.broadcastCeremonyProgress(msg.message);
906
+ break;
907
+ case 'substep':
908
+ entry.level = 'detail'; entry.text = msg.substep;
909
+ registry.appendLog(record.id, entry);
910
+ this.state.progress.push({ type: 'substep', substep: msg.substep, meta: msg.meta });
911
+ if (isSP) this.websocket?.broadcastSprintPlanningSubstep(msg.substep, msg.meta);
912
+ else this.websocket?.broadcastCeremonySubstep(msg.substep, msg.meta);
913
+ break;
914
+ case 'detail':
915
+ entry.level = 'detail'; entry.text = msg.detail;
916
+ registry.appendLog(record.id, entry);
917
+ this.state.progress.push({ type: 'detail', detail: msg.detail });
918
+ if (isSP) this.websocket?.broadcastSprintPlanningDetail(msg.detail);
919
+ else this.websocket?.broadcastCeremonyDetail(msg.detail);
920
+ break;
921
+ case 'paused':
922
+ registry.setStatus(record.id, 'paused');
923
+ if (isSP) this.websocket?.broadcastSprintPlanningPaused();
924
+ else this.websocket?.broadcastCeremonyPaused();
925
+ break;
926
+ case 'resumed':
927
+ registry.setStatus(record.id, 'running');
928
+ if (isSP) this.websocket?.broadcastSprintPlanningResumed();
929
+ else this.websocket?.broadcastCeremonyResumed();
930
+ break;
931
+ case 'complete':
932
+ this.state.status = 'complete';
933
+ this.state.result = msg.result;
934
+ this._activeChild = null;
935
+ this._activeProcessId = null;
936
+ registry.setStatus(record.id, 'complete', { result: msg.result });
937
+ await this._reloadCallback?.();
938
+ if (isSP) this.websocket?.broadcastSprintPlanningComplete(msg.result);
939
+ else this.websocket?.broadcastCeremonyComplete(msg.result);
940
+ this.websocket?.broadcastRefresh();
941
+ break;
942
+ case 'cancelled': {
943
+ if (this._cancelKillTimer) { clearTimeout(this._cancelKillTimer); this._cancelKillTimer = null; }
944
+ const itemsKept = isSP && this._keepItemsOnCancel;
945
+ if (isSP && !this._keepItemsOnCancel) this._cleanupCancelledSprintPlanning();
946
+ // Reload + refresh regardless of keep/delete so the board reflects disk state
947
+ if (isSP) {
948
+ await this._reloadCallback?.();
949
+ this.websocket?.broadcastRefresh();
950
+ }
951
+ this.state.status = 'idle';
952
+ this._activeChild = null;
953
+ this._activeProcessId = null;
954
+ this._keepItemsOnCancel = false;
955
+ this._runningType = null;
956
+ registry.setStatus(record.id, 'cancelled');
957
+ if (isSP) this.websocket?.broadcastSprintPlanningCancelled(itemsKept);
958
+ else this.websocket?.broadcastCeremonyCancelled();
959
+ break;
960
+ }
961
+ case 'error':
962
+ this.state.status = 'error';
963
+ this.state.error = msg.error;
964
+ this._activeChild = null;
965
+ this._activeProcessId = null;
966
+ registry.setStatus(record.id, 'error', { error: msg.error });
967
+ if (isSP) this.websocket?.broadcastSprintPlanningError(msg.error);
968
+ else this.websocket?.broadcastCeremonyError(msg.error);
969
+ break;
970
+ case 'cost-limit': {
971
+ const pauseMsg = `Cost limit reached: $${msg.cost.toFixed(4)} spent (limit: $${(msg.threshold ?? 0).toFixed(2)}). Ceremony paused — waiting for user decision.`;
972
+ this.state.progress.push({ type: 'progress', message: pauseMsg });
973
+ this.state.status = 'cost-limit-pending';
974
+ this.state.costLimitInfo = { cost: msg.cost, threshold: msg.threshold };
975
+ // _activeChild stays alive — worker is waiting for cost-limit-continue or cancel
976
+ registry.setStatus(record.id, 'paused');
977
+ this.websocket?.broadcastCostLimit(msg.cost, msg.threshold, this._runningType);
978
+ break;
979
+ }
980
+ case 'quota-limit':
981
+ this.state.status = 'quota-limit-pending';
982
+ this.state.quotaLimitInfo = {
983
+ validatorName: msg.validatorName,
984
+ errMsg: msg.errMsg,
985
+ provider: msg.provider,
986
+ model: msg.model,
987
+ };
988
+ registry.setStatus(record.id, 'paused');
989
+ this.websocket?.broadcastQuotaLimit(msg.provider, msg.model, msg.errMsg, msg.validatorName, this._runningType);
990
+ break;
991
+ case 'decomposition-complete':
992
+ this.state.status = 'awaiting-selection';
993
+ this.state.decomposedHierarchy = msg.hierarchy;
994
+ // _activeChild stays alive — worker is polling for selection-confirmed or cancel
995
+ registry.setStatus(record.id, 'paused');
996
+ this.websocket?.broadcastSprintPlanningDecompositionComplete(msg.hierarchy);
997
+ break;
998
+ case 'item-written':
999
+ // A single epic or story work.json was written — debounce board refresh
1000
+ // so rapid writes don't cause excessive reloads.
1001
+ if (this._itemWrittenTimer) clearTimeout(this._itemWrittenTimer);
1002
+ this._itemWrittenTimer = setTimeout(async () => {
1003
+ this._itemWrittenTimer = null;
1004
+ await this._reloadCallback?.();
1005
+ this.websocket?.broadcastRefresh();
1006
+ }, 300);
1007
+ break;
1008
+ case 'hierarchy-written':
1009
+ // Stage 6 finished writing all work.json files — final refresh to catch everything.
1010
+ if (this._itemWrittenTimer) { clearTimeout(this._itemWrittenTimer); this._itemWrittenTimer = null; }
1011
+ await this._reloadCallback?.();
1012
+ this.websocket?.broadcastRefresh();
1013
+ break;
1014
+ }
1015
+ }
1016
+
1017
+ /**
1018
+ * Resume sprint planning with the user's epic/story selection.
1019
+ * Sends selection-confirmed to the waiting worker and restores running state.
1020
+ */
1021
+ confirmSprintPlanningSelection(selectedEpicIds, selectedStoryIds) {
1022
+ if (this._activeChild) {
1023
+ try {
1024
+ this._activeChild.send({ type: 'selection-confirmed', selectedEpicIds, selectedStoryIds });
1025
+ } catch (_) {}
1026
+ }
1027
+ this.state.status = 'running';
1028
+ this.state.decomposedHierarchy = null;
1029
+ }
1030
+
1031
+ /**
1032
+ * Resume ceremony past the cost limit (user chose "Continue Anyway").
1033
+ * Sends cost-limit-continue to the waiting worker and restores running state.
1034
+ */
1035
+ continuePastCostLimit() {
1036
+ if (this._activeChild) {
1037
+ try { this._activeChild.send({ type: 'cost-limit-continue' }); } catch (_) {}
1038
+ }
1039
+ this.state.status = 'running';
1040
+ this.state.costLimitInfo = null;
1041
+ }
1042
+
1043
+ /**
1044
+ * Resume ceremony after quota-limit pause.
1045
+ * newProvider/newModel: if provided, worker switches validation+solver stage config.
1046
+ * If null, worker retries with the same model (e.g., user added credits).
1047
+ */
1048
+ continueAfterQuota(newProvider = null, newModel = null) {
1049
+ if (this._activeChild) {
1050
+ try {
1051
+ this._activeChild.send({ type: 'quota-continue', newProvider, newModel });
1052
+ } catch (_) {}
1053
+ }
1054
+ this.state.status = 'running';
1055
+ this.state.quotaLimitInfo = null;
1056
+ }
1057
+
1058
+ /**
1059
+ * Read the cost threshold for a ceremony type from avc.json.
1060
+ * Returns null if not configured (unlimited).
1061
+ */
1062
+ _getCostThreshold(ceremonyType) {
1063
+ try {
1064
+ const config = JSON.parse(fs.readFileSync(path.join(this.projectRoot, '.avc', 'avc.json'), 'utf8'));
1065
+ const threshold = config.settings?.costThresholds?.[ceremonyType] ?? null;
1066
+ if (threshold == null) return null;
1067
+ // OAuth providers are flat-rate (no per-token billing) — cost limits don't apply
1068
+ const ceremony = config.settings?.ceremonies?.find(c => c.name === ceremonyType);
1069
+ const provider = ceremony?.provider ?? config.settings?.provider ?? 'claude';
1070
+ if (isOAuthProvider(provider)) return null;
1071
+ return threshold;
1072
+ } catch { return null; }
1073
+ }
1074
+
1075
+ // ── Public relay entry-points (called from start.js when running as CLI fork) ─
1076
+
1077
+ /** Relay a worker IPC message received via CLI → Kanban IPC channel. */
1078
+ handleWorkerMessage(processId, msg) {
1079
+ const record = this._registry?.getByProcessId(processId);
1080
+ if (record) this._dispatchWorkerMessage(msg, record, this._registry);
1081
+ }
1082
+
1083
+ /** Relay worker exit notification received via CLI → Kanban IPC channel. */
1084
+ async handleWorkerExit(processId, code) {
1085
+ const record = this._registry?.getByProcessId(processId);
1086
+ if (!record) return;
1087
+ if (this._cancelKillTimer) { clearTimeout(this._cancelKillTimer); this._cancelKillTimer = null; }
1088
+ this._activeChild = null;
1089
+ this._activeProcessId = null;
1090
+ const isSP = record.type === 'sprint-planning';
1091
+
1092
+ // Cancel was in progress but the worker exited before sending its 'cancelled' IPC
1093
+ // message (e.g. IPC channel broke, disconnect handler fired with exit code 1).
1094
+ // Treat this as a successful cancellation — run cleanup and broadcast.
1095
+ if (this.state.status === 'cancelling') {
1096
+ const itemsKept = isSP && this._keepItemsOnCancel;
1097
+ if (isSP && !this._keepItemsOnCancel) this._cleanupCancelledSprintPlanning();
1098
+ if (isSP) {
1099
+ await this._reloadCallback?.();
1100
+ this.websocket?.broadcastRefresh();
1101
+ }
1102
+ this.state.status = 'idle';
1103
+ this._keepItemsOnCancel = false;
1104
+ this._runningType = null;
1105
+ this._registry.setStatus(record.id, 'cancelled');
1106
+ if (isSP) this.websocket?.broadcastSprintPlanningCancelled(itemsKept);
1107
+ else this.websocket?.broadcastCeremonyCancelled();
1108
+ return;
1109
+ }
1110
+
1111
+ const wasActive = this._runningType === record.type &&
1112
+ (this.state.status === 'running' || this.state.status === 'cost-limit-pending' || this.state.status === 'quota-limit-pending');
1113
+ if (wasActive) {
1114
+ const error = `Worker exited unexpectedly (code ${code})`;
1115
+ this._registry.setStatus(record.id, 'error', { error });
1116
+ this.state.status = 'error';
1117
+ this.state.error = error;
1118
+ this.state.costLimitInfo = null;
1119
+ this.state.quotaLimitInfo = null;
1120
+ if (isSP) this.websocket?.broadcastSprintPlanningError(error);
1121
+ else this.websocket?.broadcastCeremonyError(error);
1122
+ }
1123
+ this._runningType = null;
1124
+ }
1125
+
1126
+ /** Called when CLI confirms it has forked the worker (informational). */
1127
+ handleWorkerStarted(processId, pid) {
1128
+ // Worker forked by CLI — no additional action required in Kanban
1129
+ }
1130
+
1131
+ /**
1132
+ * Run sprint planning in a forked child process.
1133
+ * When running as a fork of the CLI (process.connected), uses IPC relay mode:
1134
+ * the CLI forks the worker and relays messages via its IPC channel.
1135
+ * @param {ProcessRegistry} registry
1136
+ * @returns {string} processId
1137
+ */
1138
+ async runSprintPlanningInProcess(registry, resumeFrom = null) {
1139
+ if (this.state.status === 'running') {
1140
+ throw new Error('Ceremony already running');
1141
+ }
1142
+
1143
+ const projectDir = path.join(this.projectRoot, '.avc', 'project');
1144
+ this._preRunSnapshot = fs.existsSync(projectDir) ? fs.readdirSync(projectDir) : [];
1145
+ this._paused = false;
1146
+ this._cancelled = false;
1147
+ this._runningType = 'sprint-planning';
1148
+ this.state = { status: 'running', progress: [], result: null, error: null };
1149
+
1150
+ const record = registry.create('sprint-planning', 'Sprint Planning');
1151
+ this._registry = registry;
1152
+ this._activeProcessId = record.id;
1153
+
1154
+ const costThreshold = this._getCostThreshold('sprint-planning');
1155
+
1156
+ if (process.connected) {
1157
+ // IPC relay mode — proxy stands in for the worker child so that pause/resume/cancel
1158
+ // continue to work unchanged; actual forking is delegated to the CLI process.
1159
+ const proxy = {
1160
+ send: (m) => { try { process.send({ type: 'ceremony:control', action: m.type, processId: record.id, payload: m }); } catch (_) {} },
1161
+ kill: (s) => { try { process.send({ type: 'ceremony:kill', signal: s, processId: record.id }); } catch (_) {} },
1162
+ };
1163
+ this._activeChild = proxy;
1164
+ registry.attach(record.id, proxy);
1165
+ process.send({ type: 'ceremony:fork', ceremonyType: 'sprint-planning', processId: record.id, costThreshold, resumeFrom });
1166
+ return record.id;
1167
+ }
1168
+
1169
+ // Standalone fallback — direct fork (used for tests / manual server launch)
1170
+ const workerPath = path.join(__dirname, '../workers/sprint-planning-worker.js');
1171
+ const child = fork(workerPath, [], {
1172
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
1173
+ cwd: this.projectRoot,
1174
+ env: { ...process.env },
1175
+ });
1176
+ child.stdout?.on('data', d => process.stdout.write(d));
1177
+ child.stderr?.on('data', d => process.stderr.write(d));
1178
+
1179
+ registry.attach(record.id, child);
1180
+ this._activeChild = child;
1181
+ child.send({ type: 'init', costThreshold, resumeFrom });
1182
+
1183
+ child.on('message', (msg) => this._dispatchWorkerMessage(msg, record, registry));
1184
+
1185
+ child.on('exit', async (code) => {
1186
+ this._activeChild = null;
1187
+ this._activeProcessId = null;
1188
+ // Cancel was in progress but worker exited before sending 'cancelled' IPC
1189
+ if (this.state.status === 'cancelling') {
1190
+ const itemsKept = this._keepItemsOnCancel;
1191
+ if (!this._keepItemsOnCancel) this._cleanupCancelledSprintPlanning();
1192
+ await this._reloadCallback?.();
1193
+ this.websocket?.broadcastRefresh();
1194
+ this.state.status = 'idle';
1195
+ this._keepItemsOnCancel = false;
1196
+ this._runningType = null;
1197
+ registry.setStatus(record.id, 'cancelled');
1198
+ this.websocket?.broadcastSprintPlanningCancelled(itemsKept);
1199
+ return;
1200
+ }
1201
+ if (this._runningType === 'sprint-planning' && this.state.status === 'running') {
1202
+ registry.setStatus(record.id, 'error', { error: `Worker exited unexpectedly (code ${code})` });
1203
+ this.state.status = 'error';
1204
+ this.state.error = `Worker exited unexpectedly (code ${code})`;
1205
+ this.websocket?.broadcastSprintPlanningError(this.state.error);
1206
+ }
1207
+ this._runningType = null;
1208
+ });
1209
+
1210
+ return record.id;
1211
+ }
1212
+
1213
+ /**
1214
+ * Run sponsor call in a forked child process.
1215
+ * When running as a fork of the CLI (process.connected), uses IPC relay mode.
1216
+ * @param {ProcessRegistry} registry
1217
+ * @param {object} requirements - All 7 template variables
1218
+ * @returns {string} processId
1219
+ */
1220
+ async runSponsorCallInProcess(registry, requirements) {
1221
+ if (this.state.status === 'running') {
1222
+ throw new Error('Ceremony already running');
1223
+ }
1224
+
1225
+ this._paused = false;
1226
+ this._cancelled = false;
1227
+ this._runningType = 'sponsor-call';
1228
+ this.state = { status: 'running', progress: [], result: null, error: null };
1229
+
1230
+ const record = registry.create('sponsor-call', 'Sponsor Call');
1231
+ this._registry = registry;
1232
+ this._activeProcessId = record.id;
1233
+
1234
+ const costThreshold = this._getCostThreshold('sponsor-call');
1235
+
1236
+ if (process.connected) {
1237
+ // IPC relay mode
1238
+ const proxy = {
1239
+ send: (m) => { try { process.send({ type: 'ceremony:control', action: m.type, processId: record.id, payload: m }); } catch (_) {} },
1240
+ kill: (s) => { try { process.send({ type: 'ceremony:kill', signal: s, processId: record.id }); } catch (_) {} },
1241
+ };
1242
+ this._activeChild = proxy;
1243
+ registry.attach(record.id, proxy);
1244
+ process.send({ type: 'ceremony:fork', ceremonyType: 'sponsor-call', processId: record.id, requirements, costThreshold });
1245
+ return record.id;
1246
+ }
1247
+
1248
+ // Standalone fallback — direct fork
1249
+ const workerPath = path.join(__dirname, '../workers/sponsor-call-worker.js');
1250
+ const child = fork(workerPath, [], {
1251
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
1252
+ cwd: this.projectRoot,
1253
+ env: { ...process.env },
1254
+ });
1255
+ child.stdout?.on('data', d => process.stdout.write(d));
1256
+ child.stderr?.on('data', d => process.stderr.write(d));
1257
+
1258
+ registry.attach(record.id, child);
1259
+ this._activeChild = child;
1260
+ child.send({ type: 'init', requirements, costThreshold });
1261
+
1262
+ child.on('message', (msg) => this._dispatchWorkerMessage(msg, record, registry));
1263
+
1264
+ child.on('exit', (code) => {
1265
+ this._activeChild = null;
1266
+ this._activeProcessId = null;
1267
+ // Cancel was in progress but worker exited before sending 'cancelled' IPC
1268
+ if (this.state.status === 'cancelling') {
1269
+ this.state.status = 'idle';
1270
+ this._keepItemsOnCancel = false;
1271
+ this._runningType = null;
1272
+ registry.setStatus(record.id, 'cancelled');
1273
+ this.websocket?.broadcastCeremonyCancelled();
1274
+ return;
1275
+ }
1276
+ if (this._runningType === 'sponsor-call' && this.state.status === 'running') {
1277
+ registry.setStatus(record.id, 'error', { error: `Worker exited unexpectedly (code ${code})` });
1278
+ this.state.status = 'error';
1279
+ this.state.error = `Worker exited unexpectedly (code ${code})`;
1280
+ this.websocket?.broadcastCeremonyError(this.state.error);
1281
+ }
1282
+ this._runningType = null;
1283
+ });
1284
+
1285
+ return record.id;
1286
+ }
1287
+
1288
+ // ── Legacy in-process ceremony execution (kept for backward compat) ──────────
1289
+
1290
+ async run(requirements) {
1291
+ if (this.state.status === 'running') {
1292
+ throw new Error('Ceremony already running');
1293
+ }
1294
+
1295
+ this._paused = false;
1296
+ this._cancelled = false;
1297
+ this._runningType = 'sponsor-call';
1298
+ this.state = { status: 'running', progress: [], result: null, error: null };
1299
+
1300
+ // Fire-and-forget: caller gets {started:true} immediately
1301
+ this._runAsync(requirements);
1302
+ }
1303
+
1304
+ async runSprintPlanning() {
1305
+ if (this.state.status === 'running') {
1306
+ throw new Error('Ceremony already running');
1307
+ }
1308
+ const projectDir = path.join(this.projectRoot, '.avc', 'project');
1309
+ this._preRunSnapshot = fs.existsSync(projectDir) ? fs.readdirSync(projectDir) : [];
1310
+ this._paused = false;
1311
+ this._cancelled = false;
1312
+ this._runningType = 'sprint-planning';
1313
+ this.state = { status: 'running', progress: [], result: null, error: null };
1314
+ this._runSprintPlanningAsync(); // fire-and-forget
1315
+ }
1316
+
1317
+ async _runSprintPlanningAsync() {
1318
+ const log = new KanbanLogger('sprint-planning-run', this.projectRoot);
1319
+ log.info('_runSprintPlanningAsync() started');
1320
+ try {
1321
+ const { ProjectInitiator } = await import('../../../cli/init.js');
1322
+ const initiator = new ProjectInitiator();
1323
+ const progressCallback = async (msg, substep, meta) => {
1324
+ if (this._cancelled) throw new Error('CEREMONY_CANCELLED');
1325
+ while (this._paused) {
1326
+ await new Promise(r => setTimeout(r, 200));
1327
+ if (this._cancelled) throw new Error('CEREMONY_CANCELLED');
1328
+ }
1329
+ if (msg) {
1330
+ log.info(`[progress] ${msg}`);
1331
+ this.state.progress.push({ type: 'progress', message: msg });
1332
+ this.websocket?.broadcastSprintPlanningProgress(msg);
1333
+ }
1334
+ if (substep) {
1335
+ log.debug(`[substep] ${substep}`);
1336
+ this.state.progress.push({ type: 'substep', substep, meta: meta || {} });
1337
+ this.websocket?.broadcastSprintPlanningSubstep(substep, meta || {});
1338
+ }
1339
+ if (meta?.detail) {
1340
+ log.debug(`[detail] ${meta.detail}`);
1341
+ this.state.progress.push({ type: 'detail', detail: meta.detail });
1342
+ this.websocket?.broadcastSprintPlanningDetail(meta.detail);
1343
+ }
1344
+ };
1345
+ const result = await initiator.sprintPlanningWithCallback(progressCallback);
1346
+ this.state.status = 'complete';
1347
+ this.state.result = result;
1348
+ log.info('_runSprintPlanningAsync() completed', result);
1349
+ log.finish(true);
1350
+ this.websocket?.broadcastSprintPlanningComplete(result);
1351
+ this.websocket?.broadcastRefresh();
1352
+ } catch (err) {
1353
+ if (err.message === 'CEREMONY_CANCELLED') {
1354
+ const itemsKept = this._keepItemsOnCancel;
1355
+ if (!this._keepItemsOnCancel) this._cleanupCancelledSprintPlanning();
1356
+ // Reload + refresh regardless of keep/delete so the board reflects disk state
1357
+ await this._reloadCallback?.();
1358
+ this.websocket?.broadcastRefresh();
1359
+ this._keepItemsOnCancel = false;
1360
+ this._runningType = null;
1361
+ this.state.status = 'idle';
1362
+ log.info('_runSprintPlanningAsync() cancelled by user', { itemsKept });
1363
+ log.finish(true, 'cancelled');
1364
+ this.websocket?.broadcastSprintPlanningCancelled(itemsKept);
1365
+ } else {
1366
+ this.state.status = 'error';
1367
+ this.state.error = err.message;
1368
+ log.error('_runSprintPlanningAsync() failed', { message: err.message, stack: err.stack });
1369
+ log.finish(false, err.message);
1370
+ this.websocket?.broadcastSprintPlanningError(err.message);
1371
+ }
1372
+ }
1373
+ }
1374
+
1375
+ async _runAsync(requirements) {
1376
+ const log = new KanbanLogger('ceremony-run', this.projectRoot);
1377
+ log.info('_runAsync() started', {
1378
+ requirementKeys: Object.keys(requirements || {}),
1379
+ missionLength: requirements?.MISSION_STATEMENT?.length,
1380
+ });
1381
+
1382
+ try {
1383
+ const { ProjectInitiator } = await import('../../../cli/init.js');
1384
+ const initiator = new ProjectInitiator();
1385
+ log.debug('ProjectInitiator created');
1386
+
1387
+ const progressCallback = async (msg, substep, meta) => {
1388
+ if (this._cancelled) throw new Error('CEREMONY_CANCELLED');
1389
+ while (this._paused) {
1390
+ await new Promise(r => setTimeout(r, 200));
1391
+ if (this._cancelled) throw new Error('CEREMONY_CANCELLED');
1392
+ }
1393
+ if (msg) {
1394
+ log.info(`[progress] ${msg}`);
1395
+ this.state.progress.push({ type: 'progress', message: msg });
1396
+ this.websocket?.broadcastCeremonyProgress(msg);
1397
+ }
1398
+ if (substep) {
1399
+ log.debug(`[substep] ${substep}`, meta);
1400
+ this.state.progress.push({ type: 'substep', substep, meta: meta || {} });
1401
+ this.websocket?.broadcastCeremonySubstep(substep, meta || {});
1402
+ }
1403
+ if (meta?.detail) {
1404
+ log.debug(`[detail] ${meta.detail}`);
1405
+ this.state.progress.push({ type: 'detail', detail: meta.detail });
1406
+ this.websocket?.broadcastCeremonyDetail(meta.detail);
1407
+ }
1408
+ };
1409
+
1410
+ const result = await initiator.sponsorCallWithAnswers(requirements, progressCallback);
1411
+
1412
+ // sponsorCallWithAnswers returns { error: true, message } on validation failure instead of throwing
1413
+ if (result?.error === true) {
1414
+ throw new Error(result.message || 'Ceremony failed');
1415
+ }
1416
+
1417
+ this.state.status = 'complete';
1418
+ this.state.result = result;
1419
+ log.info('_runAsync() completed successfully', {
1420
+ resultKeys: Object.keys(result || {}),
1421
+ });
1422
+ log.finish(true);
1423
+
1424
+ this.websocket?.broadcastCeremonyComplete(result);
1425
+ this.websocket?.broadcastRefresh();
1426
+ } catch (err) {
1427
+ if (err.message === 'CEREMONY_CANCELLED') {
1428
+ this.state.status = 'idle';
1429
+ log.info('_runAsync() cancelled by user');
1430
+ log.finish(true, 'cancelled');
1431
+ this.websocket?.broadcastCeremonyCancelled();
1432
+ } else {
1433
+ this.state.status = 'error';
1434
+ this.state.error = err.message;
1435
+ log.error('_runAsync() failed', { message: err.message, stack: err.stack });
1436
+ log.finish(false, err.message);
1437
+ this.websocket?.broadcastCeremonyError(err.message);
1438
+ }
1439
+ }
1440
+ }
1441
+ }