@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,510 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { LLMProvider } from '../../../cli/llm-provider.js';
|
|
4
|
+
import { extractDescriptionFromDoc, updateDescriptionInDoc } from '../utils/markdown.js';
|
|
5
|
+
import { EpicStoryValidator } from '../../../cli/epic-story-validator.js';
|
|
6
|
+
import { loadAgent } from '../../../cli/agent-loader.js';
|
|
7
|
+
import { KanbanLogger } from '../utils/kanban-logger.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* WorkItemRefineService
|
|
11
|
+
* Orchestrates AI-powered refinement of epics and stories:
|
|
12
|
+
* - Principal model generates improved item (description, features/acceptance, dependencies)
|
|
13
|
+
* - EpicStoryValidator runs domain validators + solvers on the result
|
|
14
|
+
* - For epics: impact-check on all child stories + gap analysis for missing stories
|
|
15
|
+
* Results are proposed changes — applyChanges() writes them to disk after user approval.
|
|
16
|
+
*/
|
|
17
|
+
export class WorkItemRefineService {
|
|
18
|
+
constructor(projectRoot) {
|
|
19
|
+
this.projectRoot = projectRoot;
|
|
20
|
+
this.avcPath = path.join(projectRoot, '.avc');
|
|
21
|
+
this.websocket = null; // set after WebSocket is initialised
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Start an async refinement job.
|
|
26
|
+
* Returns jobId immediately; broadcasts refine:progress / refine:complete / refine:error.
|
|
27
|
+
* @param {string} itemId
|
|
28
|
+
* @param {object} item - cleaned work item (from API, includes context field)
|
|
29
|
+
* @param {object} options - { refinementRequest, selectedIssues, modelId, provider, validatorModelId, validatorProvider }
|
|
30
|
+
* @returns {string} jobId
|
|
31
|
+
*/
|
|
32
|
+
async startRefine(itemId, item, options) {
|
|
33
|
+
const jobId = `refine-${Date.now()}`;
|
|
34
|
+
|
|
35
|
+
// Load .env for API keys
|
|
36
|
+
const { default: dotenv } = await import('dotenv');
|
|
37
|
+
dotenv.config({ path: path.join(this.projectRoot, '.env') });
|
|
38
|
+
|
|
39
|
+
// Fire-and-forget — WS events carry progress and results
|
|
40
|
+
this._runRefine(jobId, itemId, item, options).catch((err) => {
|
|
41
|
+
console.error(`[RefineService] _runRefine failed for ${itemId}:`, err.message);
|
|
42
|
+
this.websocket?.broadcastRefineError(itemId, jobId, err.message);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return jobId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Private: main refinement pipeline ──────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
async _runRefine(jobId, itemId, item, options) {
|
|
51
|
+
const {
|
|
52
|
+
refinementRequest = '',
|
|
53
|
+
selectedIssues = [],
|
|
54
|
+
modelId,
|
|
55
|
+
provider,
|
|
56
|
+
validatorModelId,
|
|
57
|
+
validatorProvider,
|
|
58
|
+
itemDirPath = null,
|
|
59
|
+
} = options;
|
|
60
|
+
|
|
61
|
+
const log = new KanbanLogger('refine', this.projectRoot);
|
|
62
|
+
const emit = (message) => this.websocket?.broadcastRefineProgress(itemId, jobId, message);
|
|
63
|
+
|
|
64
|
+
log.info('_runRefine() started', { jobId, itemId, type: item.type, provider, modelId });
|
|
65
|
+
|
|
66
|
+
const isEpic = item.type === 'epic';
|
|
67
|
+
|
|
68
|
+
// Load principal model agent
|
|
69
|
+
const principalAgent = loadAgent(
|
|
70
|
+
isEpic ? 'refiner-epic.md' : 'refiner-story.md',
|
|
71
|
+
this.projectRoot
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Create LLM providers
|
|
75
|
+
const principalLLM = await LLMProvider.create(provider, modelId);
|
|
76
|
+
const validatorLLM = await LLMProvider.create(validatorProvider, validatorModelId);
|
|
77
|
+
|
|
78
|
+
// Build issues context string
|
|
79
|
+
const issuesText =
|
|
80
|
+
selectedIssues.length > 0
|
|
81
|
+
? selectedIssues
|
|
82
|
+
.map(
|
|
83
|
+
(iss, i) =>
|
|
84
|
+
`${i + 1}. [${(iss.severity || 'unknown').toUpperCase()}] ${iss.description || ''}` +
|
|
85
|
+
(iss.suggestion ? `\n Suggestion: ${iss.suggestion}` : '')
|
|
86
|
+
)
|
|
87
|
+
.join('\n')
|
|
88
|
+
: 'No specific issues selected — improve overall quality based on your expertise.';
|
|
89
|
+
|
|
90
|
+
// Override description from doc.md (canonical source)
|
|
91
|
+
const itemDir = itemDirPath ?? this._findItemDir(itemId);
|
|
92
|
+
if (itemDir) {
|
|
93
|
+
const docPath = path.join(itemDir, 'doc.md');
|
|
94
|
+
if (fs.existsSync(docPath)) {
|
|
95
|
+
const docContent = fs.readFileSync(docPath, 'utf8');
|
|
96
|
+
const docDesc = extractDescriptionFromDoc(docContent);
|
|
97
|
+
if (docDesc) item = { ...item, description: docDesc };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Step 1: Principal model generates refined item ────────────────────────
|
|
102
|
+
emit('Calling principal model to generate refinement…');
|
|
103
|
+
const principalPrompt = buildPrincipalPrompt(item, issuesText, refinementRequest);
|
|
104
|
+
let refined = await principalLLM.generateJSON(principalPrompt, principalAgent);
|
|
105
|
+
log.info('Principal model responded', { refinedId: refined?.id });
|
|
106
|
+
|
|
107
|
+
if (!refined || refined.id !== item.id) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Principal model returned invalid item — expected id "${item.id}", got "${refined?.id}"`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Step 2: Validate the refined item (validator + solver loop) ───────────
|
|
114
|
+
emit('Running validators on refined item…');
|
|
115
|
+
const validator = new EpicStoryValidator(validatorLLM, null, null, false, null);
|
|
116
|
+
let validationResult = null;
|
|
117
|
+
|
|
118
|
+
if (isEpic) {
|
|
119
|
+
const context = item.context || '';
|
|
120
|
+
// validateEpic mutates refined in place (description, features, dependencies, metadata.validationResult)
|
|
121
|
+
validationResult = await validator.validateEpic(refined, context);
|
|
122
|
+
} else {
|
|
123
|
+
const parentEpic = await this._loadParentEpic(item);
|
|
124
|
+
const context = item.context || '';
|
|
125
|
+
validationResult = await validator.validateStory(refined, context, parentEpic || { name: '', domain: '', features: [] });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const score = validationResult?.averageScore ?? 0;
|
|
129
|
+
emit(
|
|
130
|
+
`Validation score: ${score}/100 — ${validationResult?.readyToPublish ? 'passed' : 'needs improvement'}`
|
|
131
|
+
);
|
|
132
|
+
log.info('Validation complete', { score, readyToPublish: validationResult?.readyToPublish });
|
|
133
|
+
|
|
134
|
+
// ── Step 3 (epic only): Child story impact + gap analysis ─────────────────
|
|
135
|
+
let storyImpacts = [];
|
|
136
|
+
|
|
137
|
+
if (isEpic) {
|
|
138
|
+
const epicDir = itemDirPath ?? this._findItemDir(itemId);
|
|
139
|
+
const existingStories = epicDir ? await this._loadChildStories(epicDir) : [];
|
|
140
|
+
|
|
141
|
+
emit(`Checking impact on ${existingStories.length} existing stories…`);
|
|
142
|
+
const updateImpacts = await this._checkChildImpacts(
|
|
143
|
+
item, refined, existingStories, principalLLM, emit, log
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
emit('Checking for capability gaps in epic scope…');
|
|
147
|
+
const newStories = await this._identifyMissingStories(
|
|
148
|
+
refined, existingStories, principalLLM, emit, log
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
storyImpacts = [
|
|
152
|
+
...updateImpacts.map((r) => ({ type: 'update', ...r })),
|
|
153
|
+
...newStories.map((s) => ({ type: 'new', storyId: null, ...s })),
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
const impactedCount = updateImpacts.filter((r) => r.impacted).length;
|
|
157
|
+
emit(
|
|
158
|
+
`Impact analysis complete: ${impactedCount} stories need updates, ${newStories.length} new stories proposed`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Broadcast complete ────────────────────────────────────────────────────
|
|
163
|
+
const result = { jobId, itemId, originalItem: item, proposedItem: refined, validationResult, storyImpacts };
|
|
164
|
+
this.websocket?.broadcastRefineComplete(itemId, jobId, result);
|
|
165
|
+
log.info('_runRefine() completed', { jobId, itemId, storyImpactCount: storyImpacts.length });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Private: child impact check ────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
async _checkChildImpacts(originalEpic, refinedEpic, stories, principalLLM, emit, log) {
|
|
171
|
+
const impactAgent = loadAgent('impact-checker-story.md', this.projectRoot);
|
|
172
|
+
const impacts = [];
|
|
173
|
+
|
|
174
|
+
for (const story of stories) {
|
|
175
|
+
const prompt = buildImpactCheckPrompt(originalEpic, refinedEpic, story);
|
|
176
|
+
try {
|
|
177
|
+
const result = await principalLLM.generateJSON(prompt, impactAgent);
|
|
178
|
+
if (result?.impacted) {
|
|
179
|
+
emit(` Story "${story.name}" — impact detected`);
|
|
180
|
+
impacts.push({
|
|
181
|
+
storyId: story.id,
|
|
182
|
+
storyName: story.name,
|
|
183
|
+
impacted: true,
|
|
184
|
+
changesNeeded: result.changesNeeded || [],
|
|
185
|
+
proposedStory: result.proposedStory || null,
|
|
186
|
+
});
|
|
187
|
+
} else {
|
|
188
|
+
impacts.push({ storyId: story.id, storyName: story.name, impacted: false });
|
|
189
|
+
}
|
|
190
|
+
log.info('Impact check', { storyId: story.id, impacted: !!result?.impacted });
|
|
191
|
+
} catch (err) {
|
|
192
|
+
log.error('Impact check failed', { storyId: story.id, error: err.message });
|
|
193
|
+
impacts.push({ storyId: story.id, storyName: story.name, impacted: false });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return impacts;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Private: gap analysis ──────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
async _identifyMissingStories(refinedEpic, existingStories, principalLLM, emit, log) {
|
|
203
|
+
try {
|
|
204
|
+
const gapAgent = loadAgent('gap-checker-epic.md', this.projectRoot);
|
|
205
|
+
const prompt = buildGapCheckPrompt(refinedEpic, existingStories);
|
|
206
|
+
const result = await principalLLM.generateJSON(prompt, gapAgent);
|
|
207
|
+
|
|
208
|
+
const gaps = result?.gaps || [];
|
|
209
|
+
for (const gap of gaps) {
|
|
210
|
+
if (gap.proposedStory) {
|
|
211
|
+
emit(` Gap: "${gap.missingFeature}" — proposing new story`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
log.info('Gap analysis complete', { gapCount: gaps.length });
|
|
215
|
+
|
|
216
|
+
return gaps.map((g) => ({
|
|
217
|
+
storyName: g.proposedStory?.name || g.missingFeature,
|
|
218
|
+
missingFeature: g.missingFeature,
|
|
219
|
+
proposedStory: g.proposedStory,
|
|
220
|
+
}));
|
|
221
|
+
} catch (err) {
|
|
222
|
+
log.error('Gap analysis failed', { error: err.message });
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Public: apply accepted changes to disk ─────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Write accepted refinement changes to the filesystem.
|
|
231
|
+
* storyChanges: [{ type: 'update'|'new', storyId?, proposedStory }] — only accepted ones
|
|
232
|
+
* dirPathMap: Map<id, dirPath> — pre-resolved paths from the data store (preferred over filesystem scan)
|
|
233
|
+
*/
|
|
234
|
+
async applyChanges(itemId, proposedItem, storyChanges = [], dirPathMap = new Map()) {
|
|
235
|
+
const isEpic = proposedItem.type === 'epic';
|
|
236
|
+
const itemDir = dirPathMap.get(itemId) ?? this._findItemDir(itemId);
|
|
237
|
+
if (!itemDir) throw new Error(`Item directory not found for "${itemId}"`);
|
|
238
|
+
|
|
239
|
+
const workJsonPath = path.join(itemDir, 'work.json');
|
|
240
|
+
const existing = JSON.parse(fs.readFileSync(workJsonPath, 'utf8'));
|
|
241
|
+
|
|
242
|
+
// If description changed, update doc.md first paragraph (doc.md is canonical)
|
|
243
|
+
if (proposedItem.description && proposedItem.description !== existing.description) {
|
|
244
|
+
const docPath = path.join(itemDir, 'doc.md');
|
|
245
|
+
if (fs.existsSync(docPath)) {
|
|
246
|
+
const currentDoc = fs.readFileSync(docPath, 'utf8');
|
|
247
|
+
fs.writeFileSync(docPath, updateDescriptionInDoc(currentDoc, proposedItem.description), 'utf8');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Build updated item (safe fields only) — description kept as display cache
|
|
252
|
+
const updated = {
|
|
253
|
+
...existing,
|
|
254
|
+
description: proposedItem.description ?? existing.description,
|
|
255
|
+
features: proposedItem.features ?? existing.features,
|
|
256
|
+
acceptance: proposedItem.acceptance ?? existing.acceptance,
|
|
257
|
+
dependencies: proposedItem.dependencies ?? existing.dependencies,
|
|
258
|
+
metadata: {
|
|
259
|
+
...existing.metadata,
|
|
260
|
+
validationResult:
|
|
261
|
+
proposedItem.metadata?.validationResult ?? existing.metadata?.validationResult,
|
|
262
|
+
refinedAt: new Date().toISOString(),
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
if (isEpic) {
|
|
267
|
+
// Apply story updates (existing stories)
|
|
268
|
+
for (const change of storyChanges.filter((c) => c.type === 'update' && c.proposedStory)) {
|
|
269
|
+
const storyDir = dirPathMap.get(change.storyId) ?? this._findItemDir(change.storyId);
|
|
270
|
+
if (!storyDir) {
|
|
271
|
+
console.warn(`[RefineService] Story dir not found for ${change.storyId} — skipping`);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const storyWorkJsonPath = path.join(storyDir, 'work.json');
|
|
275
|
+
const existingStory = JSON.parse(fs.readFileSync(storyWorkJsonPath, 'utf8'));
|
|
276
|
+
|
|
277
|
+
// Sync description to story doc.md if changed
|
|
278
|
+
if (change.proposedStory.description && change.proposedStory.description !== existingStory.description) {
|
|
279
|
+
const storyDocPath = path.join(storyDir, 'doc.md');
|
|
280
|
+
if (fs.existsSync(storyDocPath)) {
|
|
281
|
+
const currentStoryDoc = fs.readFileSync(storyDocPath, 'utf8');
|
|
282
|
+
fs.writeFileSync(storyDocPath, updateDescriptionInDoc(currentStoryDoc, change.proposedStory.description), 'utf8');
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const updatedStory = {
|
|
287
|
+
...existingStory,
|
|
288
|
+
description: change.proposedStory.description ?? existingStory.description,
|
|
289
|
+
acceptance: change.proposedStory.acceptance ?? existingStory.acceptance,
|
|
290
|
+
dependencies: change.proposedStory.dependencies ?? existingStory.dependencies,
|
|
291
|
+
metadata: { ...existingStory.metadata, refinedAt: new Date().toISOString() },
|
|
292
|
+
};
|
|
293
|
+
fs.writeFileSync(storyWorkJsonPath, JSON.stringify(updatedStory, null, 2), 'utf8');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Create new stories (gap analysis results)
|
|
297
|
+
const newStoryIds = [];
|
|
298
|
+
for (const change of storyChanges.filter((c) => c.type === 'new' && c.proposedStory)) {
|
|
299
|
+
const newId = this._nextStoryId(itemId, itemDir);
|
|
300
|
+
const newStoryDir = path.join(itemDir, newId);
|
|
301
|
+
fs.mkdirSync(newStoryDir, { recursive: true });
|
|
302
|
+
|
|
303
|
+
const newStoryWorkJson = {
|
|
304
|
+
id: newId,
|
|
305
|
+
name: change.proposedStory.name,
|
|
306
|
+
type: 'story',
|
|
307
|
+
userType: change.proposedStory.userType || 'user',
|
|
308
|
+
description: change.proposedStory.description || '',
|
|
309
|
+
acceptance: change.proposedStory.acceptance || [],
|
|
310
|
+
status: 'planned',
|
|
311
|
+
dependencies: change.proposedStory.dependencies || [],
|
|
312
|
+
children: [],
|
|
313
|
+
metadata: {
|
|
314
|
+
created: new Date().toISOString(),
|
|
315
|
+
ceremony: 'refinement',
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
fs.writeFileSync(
|
|
319
|
+
path.join(newStoryDir, 'work.json'),
|
|
320
|
+
JSON.stringify(newStoryWorkJson, null, 2),
|
|
321
|
+
'utf8'
|
|
322
|
+
);
|
|
323
|
+
fs.writeFileSync(
|
|
324
|
+
path.join(newStoryDir, 'doc.md'),
|
|
325
|
+
`# ${change.proposedStory.name}\n\n${change.proposedStory.description || ''}\n`,
|
|
326
|
+
'utf8'
|
|
327
|
+
);
|
|
328
|
+
newStoryIds.push(newId);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Add new story ids to epic's children[]
|
|
332
|
+
if (newStoryIds.length > 0) {
|
|
333
|
+
updated.children = [...(updated.children || []), ...newStoryIds];
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Write epic/story work.json last (after all children are created)
|
|
338
|
+
fs.writeFileSync(workJsonPath, JSON.stringify(updated, null, 2), 'utf8');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── Private: filesystem helpers ────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
_nextStoryId(epicId, epicDir) {
|
|
344
|
+
let maxNum = 0;
|
|
345
|
+
try {
|
|
346
|
+
const entries = fs.readdirSync(epicDir, { withFileTypes: true });
|
|
347
|
+
for (const entry of entries) {
|
|
348
|
+
if (!entry.isDirectory()) continue;
|
|
349
|
+
const parts = entry.name.split('-');
|
|
350
|
+
const num = parseInt(parts[parts.length - 1], 10);
|
|
351
|
+
if (!isNaN(num) && num > maxNum) maxNum = num;
|
|
352
|
+
}
|
|
353
|
+
} catch (_) {}
|
|
354
|
+
return `${epicId}-${String(maxNum + 1).padStart(4, '0')}`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
_findItemDir(id) {
|
|
358
|
+
const projectPath = path.join(this.avcPath, 'project');
|
|
359
|
+
return this._findDirById(projectPath, id);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
_findDirById(dir, id) {
|
|
363
|
+
try {
|
|
364
|
+
const workJsonPath = path.join(dir, 'work.json');
|
|
365
|
+
if (fs.existsSync(workJsonPath)) {
|
|
366
|
+
const data = JSON.parse(fs.readFileSync(workJsonPath, 'utf8'));
|
|
367
|
+
if (data.id === id) return dir;
|
|
368
|
+
}
|
|
369
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
370
|
+
for (const entry of entries) {
|
|
371
|
+
if (entry.isDirectory()) {
|
|
372
|
+
const found = this._findDirById(path.join(dir, entry.name), id);
|
|
373
|
+
if (found) return found;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
} catch (_) {}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async _loadParentEpic(storyItem) {
|
|
381
|
+
const parentId = storyItem.id.split('-').slice(0, -1).join('-');
|
|
382
|
+
const parentDir = this._findItemDir(parentId);
|
|
383
|
+
if (!parentDir) return null;
|
|
384
|
+
try {
|
|
385
|
+
return JSON.parse(fs.readFileSync(path.join(parentDir, 'work.json'), 'utf8'));
|
|
386
|
+
} catch (_) {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async _loadChildStories(epicDir) {
|
|
392
|
+
const stories = [];
|
|
393
|
+
try {
|
|
394
|
+
const entries = fs.readdirSync(epicDir, { withFileTypes: true });
|
|
395
|
+
for (const entry of entries) {
|
|
396
|
+
if (!entry.isDirectory()) continue;
|
|
397
|
+
const workJsonPath = path.join(epicDir, entry.name, 'work.json');
|
|
398
|
+
if (!fs.existsSync(workJsonPath)) continue;
|
|
399
|
+
try {
|
|
400
|
+
const data = JSON.parse(fs.readFileSync(workJsonPath, 'utf8'));
|
|
401
|
+
if (data.type === 'story') {
|
|
402
|
+
try {
|
|
403
|
+
const ctxPath = path.join(epicDir, entry.name, 'context.md');
|
|
404
|
+
data.context = fs.existsSync(ctxPath)
|
|
405
|
+
? fs.readFileSync(ctxPath, 'utf8')
|
|
406
|
+
: '';
|
|
407
|
+
} catch (_) {
|
|
408
|
+
data.context = '';
|
|
409
|
+
}
|
|
410
|
+
stories.push(data);
|
|
411
|
+
}
|
|
412
|
+
} catch (_) {}
|
|
413
|
+
}
|
|
414
|
+
} catch (_) {}
|
|
415
|
+
return stories;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── Prompt builders ───────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
function buildPrincipalPrompt(item, issuesText, refinementRequest) {
|
|
422
|
+
const isEpic = item.type === 'epic';
|
|
423
|
+
if (isEpic) {
|
|
424
|
+
return `# Epic to Refine
|
|
425
|
+
|
|
426
|
+
**Epic ID:** ${item.id}
|
|
427
|
+
**Epic Name:** ${item.name}
|
|
428
|
+
**Domain:** ${item.domain || 'general'}
|
|
429
|
+
**Current Description:** ${item.description || '(none)'}
|
|
430
|
+
|
|
431
|
+
**Current Features:**
|
|
432
|
+
${(item.features || []).map((f) => `- ${f}`).join('\n') || '(none)'}
|
|
433
|
+
|
|
434
|
+
**Current Dependencies:**
|
|
435
|
+
${(item.dependencies || []).length > 0 ? item.dependencies.join(', ') : 'None'}
|
|
436
|
+
|
|
437
|
+
## Validation Issues to Address:
|
|
438
|
+
|
|
439
|
+
${issuesText}
|
|
440
|
+
|
|
441
|
+
## User Refinement Request:
|
|
442
|
+
|
|
443
|
+
${refinementRequest?.trim() || 'No specific request — improve based on validation issues above.'}
|
|
444
|
+
|
|
445
|
+
Refine this Epic to address all issues and the user request. Return complete improved Epic JSON.`;
|
|
446
|
+
} else {
|
|
447
|
+
return `# Story to Refine
|
|
448
|
+
|
|
449
|
+
**Story ID:** ${item.id}
|
|
450
|
+
**Story Name:** ${item.name}
|
|
451
|
+
**User Type:** ${item.userType || 'user'}
|
|
452
|
+
**Current Description:** ${item.description || '(none)'}
|
|
453
|
+
|
|
454
|
+
**Current Acceptance Criteria:**
|
|
455
|
+
${(item.acceptance || []).map((ac, i) => `${i + 1}. ${ac}`).join('\n') || '(none)'}
|
|
456
|
+
|
|
457
|
+
**Current Dependencies:**
|
|
458
|
+
${(item.dependencies || []).length > 0 ? item.dependencies.join(', ') : 'None'}
|
|
459
|
+
|
|
460
|
+
## Validation Issues to Address:
|
|
461
|
+
|
|
462
|
+
${issuesText}
|
|
463
|
+
|
|
464
|
+
## User Refinement Request:
|
|
465
|
+
|
|
466
|
+
${refinementRequest?.trim() || 'No specific request — improve based on validation issues above.'}
|
|
467
|
+
|
|
468
|
+
Refine this Story to address all issues and the user request. Return complete improved Story JSON.`;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function buildImpactCheckPrompt(originalEpic, refinedEpic, story) {
|
|
473
|
+
return `# Epic Change Impact Analysis
|
|
474
|
+
|
|
475
|
+
## Original Epic
|
|
476
|
+
**ID:** ${originalEpic.id}
|
|
477
|
+
**Description:** ${originalEpic.description || '(none)'}
|
|
478
|
+
**Features:**
|
|
479
|
+
${(originalEpic.features || []).map((f) => `- ${f}`).join('\n') || '(none)'}
|
|
480
|
+
|
|
481
|
+
## Refined Epic (proposed changes)
|
|
482
|
+
**Description:** ${refinedEpic.description || '(none)'}
|
|
483
|
+
**Features:**
|
|
484
|
+
${(refinedEpic.features || []).map((f) => `- ${f}`).join('\n') || '(none)'}
|
|
485
|
+
|
|
486
|
+
## Story to Assess
|
|
487
|
+
**Story ID:** ${story.id}
|
|
488
|
+
**Story Name:** ${story.name}
|
|
489
|
+
**Description:** ${story.description || '(none)'}
|
|
490
|
+
**Acceptance Criteria:**
|
|
491
|
+
${(story.acceptance || []).map((ac, i) => `${i + 1}. ${ac}`).join('\n') || '(none)'}
|
|
492
|
+
|
|
493
|
+
Analyze whether the epic changes require any updates to this story. Return JSON following your instructions.`;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function buildGapCheckPrompt(refinedEpic, existingStories) {
|
|
497
|
+
return `# Epic Coverage Gap Analysis
|
|
498
|
+
|
|
499
|
+
## Refined Epic
|
|
500
|
+
**ID:** ${refinedEpic.id}
|
|
501
|
+
**Name:** ${refinedEpic.name}
|
|
502
|
+
**Description:** ${refinedEpic.description || '(none)'}
|
|
503
|
+
**Features:**
|
|
504
|
+
${(refinedEpic.features || []).map((f) => `- ${f}`).join('\n') || '(none)'}
|
|
505
|
+
|
|
506
|
+
## Existing Stories (ID + name only)
|
|
507
|
+
${existingStories.map((s, i) => `${i + 1}. ${s.id} — ${s.name}`).join('\n') || '(no stories yet)'}
|
|
508
|
+
|
|
509
|
+
Identify any epic features NOT covered by any existing story. Propose new stories for each gap.`;
|
|
510
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Kanban Server Startup Script
|
|
5
|
+
* Used by BackgroundProcessManager to start the kanban server
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { KanbanServer } from './index.js';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
|
|
11
|
+
// Get project root from command line argument
|
|
12
|
+
const projectRoot = process.argv[2] || process.cwd();
|
|
13
|
+
const port = process.argv[3] ? parseInt(process.argv[3]) : 4174;
|
|
14
|
+
|
|
15
|
+
console.log(`Starting AVC Kanban Server...`);
|
|
16
|
+
console.log(`Project root: ${projectRoot}`);
|
|
17
|
+
console.log(`Port: ${port}`);
|
|
18
|
+
|
|
19
|
+
const server = new KanbanServer(projectRoot, { port });
|
|
20
|
+
|
|
21
|
+
// Handle graceful shutdown
|
|
22
|
+
const shutdown = async () => {
|
|
23
|
+
console.log('\nShutting down kanban server...');
|
|
24
|
+
await server.stop();
|
|
25
|
+
process.exit(0);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
process.on('SIGINT', shutdown);
|
|
29
|
+
process.on('SIGTERM', shutdown);
|
|
30
|
+
|
|
31
|
+
// Start server
|
|
32
|
+
server.start().then(() => {
|
|
33
|
+
// When launched as a fork of the AVC CLI, wire incoming relay messages so that
|
|
34
|
+
// ceremony worker events (forwarded by the CLI) reach CeremonyService.
|
|
35
|
+
if (process.connected) {
|
|
36
|
+
process.on('message', (msg) => {
|
|
37
|
+
if (msg.type === 'ceremony:worker-msg') {
|
|
38
|
+
server.ceremonyService.handleWorkerMessage(msg.processId, msg.msg);
|
|
39
|
+
} else if (msg.type === 'ceremony:worker-exit') {
|
|
40
|
+
server.ceremonyService.handleWorkerExit(msg.processId, msg.code);
|
|
41
|
+
} else if (msg.type === 'ceremony:started') {
|
|
42
|
+
server.ceremonyService.handleWorkerStarted(msg.processId, msg.pid);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}).catch((error) => {
|
|
47
|
+
console.error('Failed to start kanban server:', error);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KanbanLogger
|
|
3
|
+
* Per-operation debug logger for the kanban server.
|
|
4
|
+
* Each significant operation (mission generation, ceremony run, etc.)
|
|
5
|
+
* creates a timestamped log file under .avc/logs/.
|
|
6
|
+
*
|
|
7
|
+
* Log files: kanban-<operation>-YYYY-MM-DD-HH-MM-SS.log
|
|
8
|
+
* Auto-cleanup: keeps last 10 logs per operation prefix.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
|
|
14
|
+
export class KanbanLogger {
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} operationName - e.g. 'mission', 'ceremony-run', 'analyze-db'
|
|
17
|
+
* @param {string} projectRoot - absolute path to the project root
|
|
18
|
+
*/
|
|
19
|
+
constructor(operationName, projectRoot) {
|
|
20
|
+
this.operationName = operationName;
|
|
21
|
+
this.projectRoot = projectRoot;
|
|
22
|
+
this.logsDir = path.join(projectRoot, '.avc', 'logs');
|
|
23
|
+
this.logFile = null;
|
|
24
|
+
this.startTime = Date.now();
|
|
25
|
+
|
|
26
|
+
const now = new Date();
|
|
27
|
+
const ts = now.toISOString()
|
|
28
|
+
.replace('T', '-')
|
|
29
|
+
.replace(/:/g, '-')
|
|
30
|
+
.replace(/\..+/, '');
|
|
31
|
+
|
|
32
|
+
const fileName = `kanban-${operationName}-${ts}.log`;
|
|
33
|
+
this.logFile = path.join(this.logsDir, fileName);
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
fs.mkdirSync(this.logsDir, { recursive: true });
|
|
37
|
+
const header = [
|
|
38
|
+
'='.repeat(80),
|
|
39
|
+
`Kanban Server Log: ${operationName}`,
|
|
40
|
+
`Started : ${now.toISOString()}`,
|
|
41
|
+
`Project : ${projectRoot}`,
|
|
42
|
+
`Log file: ${fileName}`,
|
|
43
|
+
'='.repeat(80),
|
|
44
|
+
'',
|
|
45
|
+
].join('\n');
|
|
46
|
+
fs.writeFileSync(this.logFile, header, 'utf8');
|
|
47
|
+
} catch {
|
|
48
|
+
this.logFile = null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Core write ──────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
_write(level, message, data) {
|
|
55
|
+
if (!this.logFile) return;
|
|
56
|
+
const ts = new Date().toISOString();
|
|
57
|
+
const elapsed = `+${Date.now() - this.startTime}ms`;
|
|
58
|
+
let line = `[${ts}] [${elapsed}] [${level}] ${message}`;
|
|
59
|
+
if (data !== undefined && data !== null) {
|
|
60
|
+
try {
|
|
61
|
+
line += '\n' + JSON.stringify(data, (_k, v) =>
|
|
62
|
+
typeof v === 'function' ? '[Function]' : v, 2);
|
|
63
|
+
} catch {
|
|
64
|
+
line += '\n[unserializable]';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
line += '\n';
|
|
68
|
+
try {
|
|
69
|
+
fs.appendFileSync(this.logFile, line, 'utf8');
|
|
70
|
+
} catch (err) {
|
|
71
|
+
process.stderr.write(`[KanbanLogger] write failed: ${err.message}\n`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
debug(message, data) { this._write('DEBUG', message, data); }
|
|
78
|
+
info(message, data) { this._write('INFO', message, data); }
|
|
79
|
+
warn(message, data) { this._write('WARN', message, data); }
|
|
80
|
+
error(message, data) { this._write('ERROR', message, data); }
|
|
81
|
+
|
|
82
|
+
/** Call at the end of an operation to write the footer and trigger cleanup. */
|
|
83
|
+
finish(success = true, summary = null) {
|
|
84
|
+
const elapsed = Date.now() - this.startTime;
|
|
85
|
+
const status = success ? 'COMPLETED' : 'FAILED';
|
|
86
|
+
const footer = [
|
|
87
|
+
'',
|
|
88
|
+
'='.repeat(80),
|
|
89
|
+
`Operation ${status}: ${this.operationName}`,
|
|
90
|
+
`Elapsed : ${elapsed}ms`,
|
|
91
|
+
summary ? `Summary : ${summary}` : null,
|
|
92
|
+
`Finished : ${new Date().toISOString()}`,
|
|
93
|
+
'='.repeat(80),
|
|
94
|
+
].filter(Boolean).join('\n');
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
if (this.logFile) fs.appendFileSync(this.logFile, footer + '\n', 'utf8');
|
|
98
|
+
} catch {}
|
|
99
|
+
|
|
100
|
+
KanbanLogger.cleanup(this.logsDir, `kanban-${this.operationName}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Returns the absolute path to the log file (useful for debugging the logger itself). */
|
|
104
|
+
getLogPath() {
|
|
105
|
+
return this.logFile;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Static helpers ──────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Remove old logs keeping only the last `keepCount` per prefix.
|
|
112
|
+
* @param {string} logsDir
|
|
113
|
+
* @param {string} prefix - e.g. 'kanban-mission'
|
|
114
|
+
* @param {number} keepCount
|
|
115
|
+
*/
|
|
116
|
+
static cleanup(logsDir, prefix, keepCount = 10) {
|
|
117
|
+
try {
|
|
118
|
+
const files = fs.readdirSync(logsDir)
|
|
119
|
+
.filter(f => f.startsWith(prefix) && f.endsWith('.log'))
|
|
120
|
+
.map(f => ({
|
|
121
|
+
name: f,
|
|
122
|
+
fullPath: path.join(logsDir, f),
|
|
123
|
+
mtime: fs.statSync(path.join(logsDir, f)).mtime,
|
|
124
|
+
}))
|
|
125
|
+
.sort((a, b) => b.mtime - a.mtime); // newest first
|
|
126
|
+
|
|
127
|
+
files.slice(keepCount).forEach(f => {
|
|
128
|
+
try { fs.unlinkSync(f.fullPath); } catch {}
|
|
129
|
+
});
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
}
|