@iservu-inc/adf-cli 0.17.1 → 0.18.0
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/.project/chats/current/SESSION-STATUS.md +29 -27
- package/.project/docs/ROADMAP.md +74 -64
- package/CHANGELOG.md +78 -0
- package/CLAUDE.md +1 -1
- package/README.md +63 -27
- package/bin/adf.js +54 -0
- package/lib/analysis/dynamic-pipeline.js +26 -0
- package/lib/analysis/knowledge-graph.js +66 -0
- package/lib/commands/deploy.js +35 -0
- package/lib/commands/harness.js +345 -0
- package/lib/commands/init.js +135 -10
- package/lib/frameworks/interviewer.js +130 -0
- package/lib/frameworks/progress-tracker.js +30 -1
- package/lib/frameworks/session-manager.js +76 -0
- package/lib/harness/context-window-manager.js +255 -0
- package/lib/harness/event-logger.js +115 -0
- package/lib/harness/feature-manifest.js +175 -0
- package/lib/harness/headless-adapter.js +184 -0
- package/lib/harness/milestone-tracker.js +183 -0
- package/lib/harness/protocol.js +503 -0
- package/lib/harness/provider-bridge.js +226 -0
- package/lib/harness/run-manager.js +267 -0
- package/lib/templates/scripts/analyze-docs.js +12 -1
- package/lib/utils/context-extractor.js +48 -0
- package/lib/utils/framework-detector.js +10 -1
- package/lib/utils/project-detector.js +5 -1
- package/lib/utils/tool-detector.js +167 -0
- package/lib/utils/tool-feature-registry.js +82 -13
- package/lib/utils/tool-recommender.js +325 -0
- package/package.json +1 -1
- package/tests/context-extractor.test.js +45 -0
- package/tests/framework-detector.test.js +28 -0
- package/tests/harness-backward-compat.test.js +251 -0
- package/tests/harness-context-window.test.js +310 -0
- package/tests/harness-event-logger.test.js +148 -0
- package/tests/harness-feature-manifest.test.js +124 -0
- package/tests/harness-headless-adapter.test.js +196 -0
- package/tests/harness-integration.test.js +207 -0
- package/tests/harness-milestone-tracker.test.js +158 -0
- package/tests/harness-protocol.test.js +341 -0
- package/tests/harness-provider-bridge.test.js +180 -0
- package/tests/harness-provider-switch.test.js +204 -0
- package/tests/harness-run-manager.test.js +131 -0
- package/tests/tool-detector.test.js +152 -0
- package/tests/tool-recommender.test.js +218 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { FeatureManifest } = require('./protocol');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Feature Manifest Manager
|
|
7
|
+
*
|
|
8
|
+
* Generates and manages the feature list from session outputs.
|
|
9
|
+
* Enforces passes-only mutation pattern (Anthropic feature list pattern).
|
|
10
|
+
*/
|
|
11
|
+
class FeatureManifestManager {
|
|
12
|
+
constructor(runDir) {
|
|
13
|
+
this.runDir = runDir;
|
|
14
|
+
this.filePath = path.join(runDir, 'feature-manifest.json');
|
|
15
|
+
this.manifest = null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Generate feature manifest from session outputs
|
|
20
|
+
*/
|
|
21
|
+
async generateFromSession(runId, sessionOutputs = {}) {
|
|
22
|
+
const features = [];
|
|
23
|
+
|
|
24
|
+
// Extract features from PRP output
|
|
25
|
+
if (sessionOutputs.prp) {
|
|
26
|
+
const prpFeatures = this.extractFeaturesFromMarkdown(sessionOutputs.prp, 'prp');
|
|
27
|
+
features.push(...prpFeatures);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Extract features from specification output
|
|
31
|
+
if (sessionOutputs.specification) {
|
|
32
|
+
const specFeatures = this.extractFeaturesFromMarkdown(sessionOutputs.specification, 'specification');
|
|
33
|
+
features.push(...specFeatures);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Extract features from stories output
|
|
37
|
+
if (sessionOutputs.stories) {
|
|
38
|
+
const storyFeatures = this.extractFeaturesFromMarkdown(sessionOutputs.stories, 'stories');
|
|
39
|
+
features.push(...storyFeatures);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Extract features from PRD output
|
|
43
|
+
if (sessionOutputs.prd) {
|
|
44
|
+
const prdFeatures = this.extractFeaturesFromMarkdown(sessionOutputs.prd, 'prd');
|
|
45
|
+
features.push(...prdFeatures);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Extract features from tasks output
|
|
49
|
+
if (sessionOutputs.tasks) {
|
|
50
|
+
const taskFeatures = this.extractFeaturesFromMarkdown(sessionOutputs.tasks, 'tasks');
|
|
51
|
+
features.push(...taskFeatures);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.manifest = new FeatureManifest({ runId, features });
|
|
55
|
+
await this.save();
|
|
56
|
+
return this.manifest;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Generate feature manifest from explicit feature list
|
|
61
|
+
*/
|
|
62
|
+
async generateFromList(runId, featureList) {
|
|
63
|
+
const features = featureList.map(f => ({
|
|
64
|
+
title: f.title,
|
|
65
|
+
description: f.description || '',
|
|
66
|
+
sourceQuestions: f.sourceQuestions || [],
|
|
67
|
+
acceptanceCriteria: f.acceptanceCriteria || []
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
this.manifest = new FeatureManifest({ runId, features });
|
|
71
|
+
await this.save();
|
|
72
|
+
return this.manifest;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Mark a feature as passing (ONLY allowed mutation)
|
|
77
|
+
*/
|
|
78
|
+
async markPasses(featureId) {
|
|
79
|
+
if (!this.manifest) {
|
|
80
|
+
await this.load();
|
|
81
|
+
}
|
|
82
|
+
if (!this.manifest) {
|
|
83
|
+
throw new Error('No feature manifest loaded');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const feature = this.manifest.markPasses(featureId);
|
|
87
|
+
await this.save();
|
|
88
|
+
return feature;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get progress summary
|
|
93
|
+
*/
|
|
94
|
+
getProgress() {
|
|
95
|
+
if (!this.manifest) return { total: 0, passed: 0, remaining: 0, percentage: 0 };
|
|
96
|
+
return this.manifest.getProgress();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Save manifest to disk
|
|
101
|
+
*/
|
|
102
|
+
async save() {
|
|
103
|
+
if (!this.manifest) return;
|
|
104
|
+
await fs.writeJson(this.filePath, this.manifest.toJSON(), { spaces: 2 });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Load manifest from disk
|
|
109
|
+
*/
|
|
110
|
+
async load() {
|
|
111
|
+
if (!await fs.pathExists(this.filePath)) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const data = await fs.readJson(this.filePath);
|
|
116
|
+
this.manifest = FeatureManifest.fromJSON(data);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get the manifest
|
|
122
|
+
*/
|
|
123
|
+
getManifest() {
|
|
124
|
+
return this.manifest;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Extract features from markdown content (simple heuristic)
|
|
129
|
+
*/
|
|
130
|
+
extractFeaturesFromMarkdown(content, source) {
|
|
131
|
+
const features = [];
|
|
132
|
+
const lines = content.split('\n');
|
|
133
|
+
|
|
134
|
+
for (let i = 0; i < lines.length; i++) {
|
|
135
|
+
const line = lines[i].trim();
|
|
136
|
+
|
|
137
|
+
// Match h2/h3 headers that look like features
|
|
138
|
+
const headerMatch = line.match(/^#{2,3}\s+(?:\d+\.\s+)?(.+)/);
|
|
139
|
+
if (headerMatch) {
|
|
140
|
+
const title = headerMatch[1].trim();
|
|
141
|
+
|
|
142
|
+
// Skip generic headers
|
|
143
|
+
const skipHeaders = [
|
|
144
|
+
'overview', 'summary', 'introduction', 'table of contents',
|
|
145
|
+
'references', 'appendix', 'glossary', 'changelog',
|
|
146
|
+
'executive summary', 'background', 'scope', 'assumptions'
|
|
147
|
+
];
|
|
148
|
+
if (skipHeaders.some(h => title.toLowerCase().includes(h))) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Collect description from following lines
|
|
153
|
+
let description = '';
|
|
154
|
+
for (let j = i + 1; j < lines.length && j < i + 4; j++) {
|
|
155
|
+
const nextLine = lines[j].trim();
|
|
156
|
+
if (nextLine && !nextLine.startsWith('#')) {
|
|
157
|
+
description += (description ? ' ' : '') + nextLine;
|
|
158
|
+
} else if (nextLine.startsWith('#')) {
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
features.push({
|
|
164
|
+
title,
|
|
165
|
+
description: description.slice(0, 200),
|
|
166
|
+
sourceQuestions: [source]
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return features;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = FeatureManifestManager;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless Adapter
|
|
5
|
+
*
|
|
6
|
+
* Replaces interactive inquirer prompts with JSON-driven input
|
|
7
|
+
* for CI/automation pipelines. Provides deterministic answer flow
|
|
8
|
+
* without user interaction.
|
|
9
|
+
*/
|
|
10
|
+
class HeadlessAdapter {
|
|
11
|
+
constructor(inputSource) {
|
|
12
|
+
this.answers = new Map();
|
|
13
|
+
this.defaultAnswer = null;
|
|
14
|
+
this.answerIndex = 0;
|
|
15
|
+
this.orderedAnswers = [];
|
|
16
|
+
this.log = [];
|
|
17
|
+
|
|
18
|
+
if (inputSource) {
|
|
19
|
+
this.loadSource(inputSource);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Load answers from various source types
|
|
25
|
+
*/
|
|
26
|
+
loadSource(source) {
|
|
27
|
+
if (typeof source === 'string') {
|
|
28
|
+
// File path - will be loaded async
|
|
29
|
+
this.pendingFile = source;
|
|
30
|
+
} else if (Array.isArray(source)) {
|
|
31
|
+
// Ordered array of answers
|
|
32
|
+
this.orderedAnswers = source;
|
|
33
|
+
} else if (typeof source === 'object') {
|
|
34
|
+
// Map of questionId -> answer
|
|
35
|
+
for (const [key, value] of Object.entries(source)) {
|
|
36
|
+
this.answers.set(key, value);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Load answers from a JSON file
|
|
43
|
+
*/
|
|
44
|
+
async loadFromFile(filePath) {
|
|
45
|
+
if (!await fs.pathExists(filePath)) {
|
|
46
|
+
throw new Error(`Headless input file not found: ${filePath}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const data = await fs.readJson(filePath);
|
|
50
|
+
|
|
51
|
+
if (data.answers) {
|
|
52
|
+
// Structured format: { answers: { questionId: answer }, defaults: {...} }
|
|
53
|
+
for (const [key, value] of Object.entries(data.answers)) {
|
|
54
|
+
this.answers.set(key, value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (data.orderedAnswers) {
|
|
59
|
+
this.orderedAnswers = data.orderedAnswers;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (data.defaultAnswer) {
|
|
63
|
+
this.defaultAnswer = data.defaultAnswer;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Initialize (load pending file if any)
|
|
71
|
+
*/
|
|
72
|
+
async initialize() {
|
|
73
|
+
if (this.pendingFile) {
|
|
74
|
+
await this.loadFromFile(this.pendingFile);
|
|
75
|
+
this.pendingFile = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get an answer for a question (replaces inquirer.prompt)
|
|
81
|
+
*/
|
|
82
|
+
getAnswer(questionId, questionText) {
|
|
83
|
+
let answer = null;
|
|
84
|
+
|
|
85
|
+
// Try mapped answer first
|
|
86
|
+
if (this.answers.has(questionId)) {
|
|
87
|
+
answer = this.answers.get(questionId);
|
|
88
|
+
}
|
|
89
|
+
// Try ordered answers
|
|
90
|
+
else if (this.answerIndex < this.orderedAnswers.length) {
|
|
91
|
+
answer = this.orderedAnswers[this.answerIndex];
|
|
92
|
+
this.answerIndex++;
|
|
93
|
+
}
|
|
94
|
+
// Use default
|
|
95
|
+
else if (this.defaultAnswer) {
|
|
96
|
+
answer = typeof this.defaultAnswer === 'function'
|
|
97
|
+
? this.defaultAnswer(questionId, questionText)
|
|
98
|
+
: this.defaultAnswer;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.log.push({
|
|
102
|
+
questionId,
|
|
103
|
+
questionText,
|
|
104
|
+
answer,
|
|
105
|
+
timestamp: new Date().toISOString()
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return answer;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get a choice selection (replaces inquirer list/select)
|
|
113
|
+
*/
|
|
114
|
+
getChoice(questionId, choices, defaultChoice) {
|
|
115
|
+
// Try mapped answer
|
|
116
|
+
if (this.answers.has(questionId)) {
|
|
117
|
+
const mapped = this.answers.get(questionId);
|
|
118
|
+
// Find matching choice
|
|
119
|
+
const match = choices.find(c =>
|
|
120
|
+
(typeof c === 'string' && c === mapped) ||
|
|
121
|
+
(typeof c === 'object' && (c.value === mapped || c.name === mapped))
|
|
122
|
+
);
|
|
123
|
+
if (match) {
|
|
124
|
+
return typeof match === 'object' ? match.value : match;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Return default or first choice
|
|
129
|
+
if (defaultChoice !== undefined) return defaultChoice;
|
|
130
|
+
const first = choices[0];
|
|
131
|
+
return typeof first === 'object' ? first.value : first;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get confirmation (replaces inquirer confirm)
|
|
136
|
+
*/
|
|
137
|
+
getConfirmation(questionId, defaultValue = true) {
|
|
138
|
+
if (this.answers.has(questionId)) {
|
|
139
|
+
const val = this.answers.get(questionId);
|
|
140
|
+
return typeof val === 'boolean' ? val : val === 'yes' || val === 'true';
|
|
141
|
+
}
|
|
142
|
+
return defaultValue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Check if there are more answers available
|
|
147
|
+
*/
|
|
148
|
+
hasMoreAnswers() {
|
|
149
|
+
return this.answerIndex < this.orderedAnswers.length ||
|
|
150
|
+
this.defaultAnswer !== null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get the interaction log
|
|
155
|
+
*/
|
|
156
|
+
getLog() {
|
|
157
|
+
return this.log;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get stats
|
|
162
|
+
*/
|
|
163
|
+
getStats() {
|
|
164
|
+
return {
|
|
165
|
+
totalQuestions: this.log.length,
|
|
166
|
+
answered: this.log.filter(l => l.answer !== null).length,
|
|
167
|
+
unanswered: this.log.filter(l => l.answer === null).length,
|
|
168
|
+
mappedAnswers: this.answers.size,
|
|
169
|
+
orderedAnswersRemaining: Math.max(0, this.orderedAnswers.length - this.answerIndex)
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Export log to JSON for debugging
|
|
175
|
+
*/
|
|
176
|
+
async exportLog(filePath) {
|
|
177
|
+
await fs.writeJson(filePath, {
|
|
178
|
+
stats: this.getStats(),
|
|
179
|
+
log: this.log
|
|
180
|
+
}, { spaces: 2 });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = HeadlessAdapter;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { Milestone } = require('./protocol');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Milestone Tracker
|
|
7
|
+
*
|
|
8
|
+
* Maps ADF question blocks to milestones, tracks progression,
|
|
9
|
+
* and provides checkpoint-based progress reporting.
|
|
10
|
+
*/
|
|
11
|
+
class MilestoneTracker {
|
|
12
|
+
constructor(runDir) {
|
|
13
|
+
this.runDir = runDir;
|
|
14
|
+
this.milestonesDir = path.join(runDir, 'milestones');
|
|
15
|
+
this.milestones = [];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Generate milestones from question blocks
|
|
20
|
+
*/
|
|
21
|
+
async generateFromBlocks(blocks) {
|
|
22
|
+
this.milestones = blocks.map((block, index) => {
|
|
23
|
+
return new Milestone({
|
|
24
|
+
index,
|
|
25
|
+
title: block.title || `Block ${index + 1}`,
|
|
26
|
+
description: block.description || '',
|
|
27
|
+
blockNumber: block.number != null ? block.number : index + 1,
|
|
28
|
+
questionsInScope: (block.questions || []).map(q => q.id || q),
|
|
29
|
+
acceptanceCriteria: block.acceptanceCriteria || [
|
|
30
|
+
`All questions in "${block.title || `Block ${index + 1}`}" answered or explicitly skipped`
|
|
31
|
+
]
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
await this.saveAll();
|
|
36
|
+
return this.milestones;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Mark a question as completed within its milestone
|
|
41
|
+
*/
|
|
42
|
+
async completeQuestion(questionId) {
|
|
43
|
+
for (const milestone of this.milestones) {
|
|
44
|
+
if (milestone.questionsInScope.includes(questionId)) {
|
|
45
|
+
if (!milestone.questionsCompleted.includes(questionId)) {
|
|
46
|
+
milestone.questionsCompleted.push(questionId);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Update percentage
|
|
50
|
+
const total = milestone.questionsInScope.length;
|
|
51
|
+
const done = milestone.questionsCompleted.length;
|
|
52
|
+
milestone.percentage = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
53
|
+
|
|
54
|
+
// Auto-transition status
|
|
55
|
+
if (milestone.status === 'pending') {
|
|
56
|
+
milestone.status = 'in_progress';
|
|
57
|
+
}
|
|
58
|
+
if (done >= total) {
|
|
59
|
+
milestone.status = 'completed';
|
|
60
|
+
milestone.completedAt = new Date().toISOString();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await this.saveMilestone(milestone);
|
|
64
|
+
return milestone;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Skip a milestone entirely
|
|
72
|
+
*/
|
|
73
|
+
async skipMilestone(index) {
|
|
74
|
+
if (index < 0 || index >= this.milestones.length) {
|
|
75
|
+
throw new Error(`Invalid milestone index: ${index}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const milestone = this.milestones[index];
|
|
79
|
+
milestone.status = 'skipped';
|
|
80
|
+
await this.saveMilestone(milestone);
|
|
81
|
+
return milestone;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get current milestone (first non-completed, non-skipped)
|
|
86
|
+
*/
|
|
87
|
+
getCurrentMilestone() {
|
|
88
|
+
return this.milestones.find(m =>
|
|
89
|
+
m.status === 'pending' || m.status === 'in_progress'
|
|
90
|
+
) || null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get current milestone index
|
|
95
|
+
*/
|
|
96
|
+
getCurrentIndex() {
|
|
97
|
+
const current = this.getCurrentMilestone();
|
|
98
|
+
return current ? current.index : this.milestones.length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get overall progress
|
|
103
|
+
*/
|
|
104
|
+
getProgress() {
|
|
105
|
+
const total = this.milestones.length;
|
|
106
|
+
const completed = this.milestones.filter(m => m.status === 'completed').length;
|
|
107
|
+
const skipped = this.milestones.filter(m => m.status === 'skipped').length;
|
|
108
|
+
const inProgress = this.milestones.filter(m => m.status === 'in_progress').length;
|
|
109
|
+
const pending = this.milestones.filter(m => m.status === 'pending').length;
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
total,
|
|
113
|
+
completed,
|
|
114
|
+
skipped,
|
|
115
|
+
inProgress,
|
|
116
|
+
pending,
|
|
117
|
+
percentage: total > 0 ? Math.round(((completed + skipped) / total) * 100) : 0
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get milestone for handoff package
|
|
123
|
+
*/
|
|
124
|
+
getHandoffInfo() {
|
|
125
|
+
const current = this.getCurrentMilestone();
|
|
126
|
+
if (!current) {
|
|
127
|
+
return { index: this.milestones.length, title: 'All complete', percentage: 100 };
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
index: current.index,
|
|
131
|
+
title: current.title,
|
|
132
|
+
percentage: current.percentage
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Save a single milestone
|
|
138
|
+
*/
|
|
139
|
+
async saveMilestone(milestone) {
|
|
140
|
+
await fs.ensureDir(this.milestonesDir);
|
|
141
|
+
const filePath = path.join(this.milestonesDir, `ms_${milestone.index + 1}.json`);
|
|
142
|
+
await fs.writeJson(filePath, milestone.toJSON(), { spaces: 2 });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Save all milestones
|
|
147
|
+
*/
|
|
148
|
+
async saveAll() {
|
|
149
|
+
await fs.ensureDir(this.milestonesDir);
|
|
150
|
+
for (const milestone of this.milestones) {
|
|
151
|
+
await this.saveMilestone(milestone);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Load milestones from disk
|
|
157
|
+
*/
|
|
158
|
+
async load() {
|
|
159
|
+
if (!await fs.pathExists(this.milestonesDir)) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const files = await fs.readdir(this.milestonesDir);
|
|
164
|
+
const milestoneFiles = files.filter(f => f.startsWith('ms_') && f.endsWith('.json'));
|
|
165
|
+
|
|
166
|
+
this.milestones = [];
|
|
167
|
+
for (const file of milestoneFiles.sort()) {
|
|
168
|
+
const data = await fs.readJson(path.join(this.milestonesDir, file));
|
|
169
|
+
this.milestones.push(Milestone.fromJSON(data));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return this.milestones.length > 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get all milestones
|
|
177
|
+
*/
|
|
178
|
+
getAll() {
|
|
179
|
+
return this.milestones;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = MilestoneTracker;
|