@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.
- package/cli/agent-loader.js +21 -0
- package/cli/agents/agent-selector.md +152 -0
- package/cli/agents/architecture-recommender.md +418 -0
- package/cli/agents/code-implementer.md +117 -0
- package/cli/agents/code-validator.md +80 -0
- package/cli/agents/context-reviewer-epic.md +101 -0
- package/cli/agents/context-reviewer-story.md +92 -0
- package/cli/agents/context-writer-epic.md +145 -0
- package/cli/agents/context-writer-story.md +111 -0
- package/cli/agents/database-deep-dive.md +470 -0
- package/cli/agents/database-recommender.md +634 -0
- package/cli/agents/doc-distributor.md +176 -0
- package/cli/agents/doc-writer-epic.md +42 -0
- package/cli/agents/doc-writer-story.md +43 -0
- package/cli/agents/documentation-updater.md +203 -0
- package/cli/agents/duplicate-detector.md +110 -0
- package/cli/agents/epic-story-decomposer.md +559 -0
- package/cli/agents/feature-context-generator.md +91 -0
- package/cli/agents/gap-checker-epic.md +52 -0
- package/cli/agents/impact-checker-story.md +51 -0
- package/cli/agents/migration-guide-generator.md +305 -0
- package/cli/agents/mission-scope-generator.md +143 -0
- package/cli/agents/mission-scope-validator.md +146 -0
- package/cli/agents/project-context-extractor.md +122 -0
- package/cli/agents/project-documentation-creator.json +226 -0
- package/cli/agents/project-documentation-creator.md +595 -0
- package/cli/agents/question-prefiller.md +269 -0
- package/cli/agents/refiner-epic.md +39 -0
- package/cli/agents/refiner-story.md +42 -0
- package/cli/agents/scaffolding-generator.md +99 -0
- package/cli/agents/seed-validator.md +71 -0
- package/cli/agents/story-doc-enricher.md +133 -0
- package/cli/agents/story-scope-reviewer.md +147 -0
- package/cli/agents/story-splitter.md +83 -0
- package/cli/agents/suggestion-business-analyst.md +88 -0
- package/cli/agents/suggestion-deployment-architect.md +263 -0
- package/cli/agents/suggestion-product-manager.md +129 -0
- package/cli/agents/suggestion-security-specialist.md +156 -0
- package/cli/agents/suggestion-technical-architect.md +269 -0
- package/cli/agents/suggestion-ux-researcher.md +93 -0
- package/cli/agents/task-subtask-decomposer.md +188 -0
- package/cli/agents/validator-documentation.json +183 -0
- package/cli/agents/validator-documentation.md +455 -0
- package/cli/agents/validator-selector.md +211 -0
- package/cli/ansi-colors.js +21 -0
- package/cli/api-reference-tool.js +368 -0
- package/cli/build-docs.js +29 -8
- package/cli/ceremony-history.js +369 -0
- package/cli/checks/catalog.json +76 -0
- package/cli/checks/code/quality.json +26 -0
- package/cli/checks/code/testing.json +14 -0
- package/cli/checks/code/traceability.json +26 -0
- package/cli/checks/cross-refs/epic.json +171 -0
- package/cli/checks/cross-refs/story.json +149 -0
- package/cli/checks/epic/api.json +114 -0
- package/cli/checks/epic/backend.json +126 -0
- package/cli/checks/epic/cloud.json +126 -0
- package/cli/checks/epic/data.json +102 -0
- package/cli/checks/epic/database.json +114 -0
- package/cli/checks/epic/developer.json +182 -0
- package/cli/checks/epic/devops.json +174 -0
- package/cli/checks/epic/frontend.json +162 -0
- package/cli/checks/epic/mobile.json +102 -0
- package/cli/checks/epic/qa.json +90 -0
- package/cli/checks/epic/security.json +184 -0
- package/cli/checks/epic/solution-architect.json +192 -0
- package/cli/checks/epic/test-architect.json +90 -0
- package/cli/checks/epic/ui.json +102 -0
- package/cli/checks/epic/ux.json +90 -0
- package/cli/checks/fixes/epic-fix-template.md +10 -0
- package/cli/checks/fixes/story-fix-template.md +10 -0
- package/cli/checks/story/api.json +186 -0
- package/cli/checks/story/backend.json +102 -0
- package/cli/checks/story/cloud.json +102 -0
- package/cli/checks/story/data.json +210 -0
- package/cli/checks/story/database.json +102 -0
- package/cli/checks/story/developer.json +168 -0
- package/cli/checks/story/devops.json +102 -0
- package/cli/checks/story/frontend.json +174 -0
- package/cli/checks/story/mobile.json +102 -0
- package/cli/checks/story/qa.json +210 -0
- package/cli/checks/story/security.json +198 -0
- package/cli/checks/story/solution-architect.json +230 -0
- package/cli/checks/story/test-architect.json +210 -0
- package/cli/checks/story/ui.json +102 -0
- package/cli/checks/story/ux.json +102 -0
- package/cli/coding-order.js +401 -0
- package/cli/command-logger.js +49 -12
- package/cli/components/static-output.js +63 -0
- package/cli/console-output-manager.js +94 -0
- package/cli/dependency-checker.js +72 -0
- package/cli/docs-sync.js +306 -0
- package/cli/epic-story-validator.js +659 -0
- package/cli/evaluation-prompts.js +1008 -0
- package/cli/execution-context.js +195 -0
- package/cli/generate-summary-table.js +340 -0
- package/cli/init-model-config.js +704 -0
- package/cli/init.js +1737 -278
- package/cli/kanban-server-manager.js +227 -0
- package/cli/llm-claude.js +150 -1
- package/cli/llm-gemini.js +109 -0
- package/cli/llm-local.js +493 -0
- package/cli/llm-mock.js +233 -0
- package/cli/llm-openai.js +454 -0
- package/cli/llm-provider.js +379 -3
- package/cli/llm-token-limits.js +211 -0
- package/cli/llm-verifier.js +662 -0
- package/cli/llm-xiaomi.js +143 -0
- package/cli/message-constants.js +49 -0
- package/cli/message-manager.js +334 -0
- package/cli/message-types.js +96 -0
- package/cli/messaging-api.js +291 -0
- package/cli/micro-check-fixer.js +335 -0
- package/cli/micro-check-runner.js +449 -0
- package/cli/micro-check-scorer.js +148 -0
- package/cli/micro-check-validator.js +538 -0
- package/cli/model-pricing.js +192 -0
- package/cli/model-query-engine.js +468 -0
- package/cli/model-recommendation-analyzer.js +495 -0
- package/cli/model-selector.js +270 -0
- package/cli/output-buffer.js +107 -0
- package/cli/process-manager.js +73 -2
- package/cli/prompt-logger.js +57 -0
- package/cli/repl-ink.js +4625 -1094
- package/cli/repl-old.js +3 -4
- package/cli/seed-processor.js +962 -0
- package/cli/sprint-planning-processor.js +4162 -0
- package/cli/template-processor.js +2149 -105
- package/cli/templates/project.md +25 -8
- package/cli/templates/vitepress-config.mts.template +5 -4
- package/cli/token-tracker.js +547 -0
- package/cli/tools/generate-story-validators.js +317 -0
- package/cli/tools/generate-validators.js +669 -0
- package/cli/update-checker.js +19 -17
- package/cli/update-notifier.js +4 -4
- package/cli/validation-router.js +667 -0
- package/cli/verification-tracker.js +563 -0
- package/cli/worktree-runner.js +654 -0
- package/kanban/README.md +386 -0
- package/kanban/client/README.md +205 -0
- package/kanban/client/components.json +20 -0
- package/kanban/client/dist/assets/index-D_KC5EQT.css +1 -0
- package/kanban/client/dist/assets/index-DjY5zqW7.js +351 -0
- package/kanban/client/dist/index.html +16 -0
- package/kanban/client/dist/vite.svg +1 -0
- package/kanban/client/index.html +15 -0
- package/kanban/client/package-lock.json +9442 -0
- package/kanban/client/package.json +44 -0
- package/kanban/client/postcss.config.js +6 -0
- package/kanban/client/public/vite.svg +1 -0
- package/kanban/client/src/App.jsx +651 -0
- package/kanban/client/src/components/ProjectFileEditorPopup.jsx +117 -0
- package/kanban/client/src/components/ceremony/AskArchPopup.jsx +420 -0
- package/kanban/client/src/components/ceremony/AskModelPopup.jsx +629 -0
- package/kanban/client/src/components/ceremony/CeremonyWorkflowModal.jsx +1133 -0
- package/kanban/client/src/components/ceremony/EpicStorySelectionModal.jsx +254 -0
- package/kanban/client/src/components/ceremony/ProviderSwitcherButton.jsx +290 -0
- package/kanban/client/src/components/ceremony/SponsorCallModal.jsx +686 -0
- package/kanban/client/src/components/ceremony/SprintPlanningModal.jsx +838 -0
- package/kanban/client/src/components/ceremony/steps/ArchitectureStep.jsx +150 -0
- package/kanban/client/src/components/ceremony/steps/CompleteStep.jsx +136 -0
- package/kanban/client/src/components/ceremony/steps/DatabaseStep.jsx +202 -0
- package/kanban/client/src/components/ceremony/steps/DeploymentStep.jsx +123 -0
- package/kanban/client/src/components/ceremony/steps/MissionStep.jsx +106 -0
- package/kanban/client/src/components/ceremony/steps/ReviewAnswersStep.jsx +329 -0
- package/kanban/client/src/components/ceremony/steps/RunningStep.jsx +249 -0
- package/kanban/client/src/components/kanban/CardDetailModal.jsx +646 -0
- package/kanban/client/src/components/kanban/EpicSection.jsx +146 -0
- package/kanban/client/src/components/kanban/FilterToolbar.jsx +222 -0
- package/kanban/client/src/components/kanban/GroupingSelector.jsx +63 -0
- package/kanban/client/src/components/kanban/KanbanBoard.jsx +211 -0
- package/kanban/client/src/components/kanban/KanbanCard.jsx +147 -0
- package/kanban/client/src/components/kanban/KanbanColumn.jsx +90 -0
- package/kanban/client/src/components/kanban/RefineWorkItemPopup.jsx +784 -0
- package/kanban/client/src/components/kanban/RunButton.jsx +162 -0
- package/kanban/client/src/components/kanban/SeedButton.jsx +176 -0
- package/kanban/client/src/components/layout/LoadingScreen.jsx +82 -0
- package/kanban/client/src/components/process/ProcessMonitorBar.jsx +80 -0
- package/kanban/client/src/components/settings/AgentEditorPopup.jsx +171 -0
- package/kanban/client/src/components/settings/AgentsTab.jsx +381 -0
- package/kanban/client/src/components/settings/ApiKeysTab.jsx +142 -0
- package/kanban/client/src/components/settings/CeremonyModelsTab.jsx +105 -0
- package/kanban/client/src/components/settings/CheckEditorPopup.jsx +507 -0
- package/kanban/client/src/components/settings/CostThresholdsTab.jsx +95 -0
- package/kanban/client/src/components/settings/ModelPricingTab.jsx +269 -0
- package/kanban/client/src/components/settings/OpenAIAuthSection.jsx +412 -0
- package/kanban/client/src/components/settings/ServersTab.jsx +121 -0
- package/kanban/client/src/components/settings/SettingsModal.jsx +84 -0
- package/kanban/client/src/components/stats/CostModal.jsx +384 -0
- package/kanban/client/src/components/ui/badge.jsx +27 -0
- package/kanban/client/src/components/ui/dialog.jsx +121 -0
- package/kanban/client/src/components/ui/tabs.jsx +85 -0
- package/kanban/client/src/hooks/__tests__/useGrouping.test.js +232 -0
- package/kanban/client/src/hooks/useGrouping.js +177 -0
- package/kanban/client/src/hooks/useWebSocket.js +120 -0
- package/kanban/client/src/lib/__tests__/api.test.js +196 -0
- package/kanban/client/src/lib/__tests__/status-grouping.test.js +94 -0
- package/kanban/client/src/lib/api.js +515 -0
- package/kanban/client/src/lib/status-grouping.js +154 -0
- package/kanban/client/src/lib/utils.js +11 -0
- package/kanban/client/src/main.jsx +10 -0
- package/kanban/client/src/store/__tests__/kanbanStore.test.js +164 -0
- package/kanban/client/src/store/ceremonyStore.js +172 -0
- package/kanban/client/src/store/filterStore.js +201 -0
- package/kanban/client/src/store/kanbanStore.js +123 -0
- package/kanban/client/src/store/processStore.js +65 -0
- package/kanban/client/src/store/sprintPlanningStore.js +33 -0
- package/kanban/client/src/styles/globals.css +59 -0
- package/kanban/client/tailwind.config.js +77 -0
- package/kanban/client/vite.config.js +28 -0
- package/kanban/client/vitest.config.js +28 -0
- package/kanban/dev-start.sh +47 -0
- package/kanban/package.json +12 -0
- package/kanban/server/index.js +537 -0
- package/kanban/server/routes/ceremony.js +454 -0
- package/kanban/server/routes/costs.js +163 -0
- package/kanban/server/routes/openai-oauth.js +366 -0
- package/kanban/server/routes/processes.js +50 -0
- package/kanban/server/routes/settings.js +736 -0
- package/kanban/server/routes/websocket.js +281 -0
- package/kanban/server/routes/work-items.js +487 -0
- package/kanban/server/services/CeremonyService.js +1441 -0
- package/kanban/server/services/FileSystemScanner.js +95 -0
- package/kanban/server/services/FileWatcher.js +144 -0
- package/kanban/server/services/HierarchyBuilder.js +196 -0
- package/kanban/server/services/ProcessRegistry.js +122 -0
- package/kanban/server/services/TaskRunnerService.js +261 -0
- package/kanban/server/services/WorkItemReader.js +123 -0
- package/kanban/server/services/WorkItemRefineService.js +510 -0
- package/kanban/server/start.js +49 -0
- package/kanban/server/utils/kanban-logger.js +132 -0
- package/kanban/server/utils/markdown.js +91 -0
- package/kanban/server/utils/status-grouping.js +107 -0
- package/kanban/server/workers/run-task-worker.js +121 -0
- package/kanban/server/workers/seed-worker.js +94 -0
- package/kanban/server/workers/sponsor-call-worker.js +92 -0
- package/kanban/server/workers/sprint-planning-worker.js +212 -0
- package/package.json +19 -7
- 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
|
+
}
|