depa-codument 0.4.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/LICENSE +21 -0
- package/README.md +262 -0
- package/package.json +63 -0
- package/src/cli/commands/archive.ts +519 -0
- package/src/cli/commands/decisions.ts +123 -0
- package/src/cli/commands/engineering.ts +105 -0
- package/src/cli/commands/init.ts +54 -0
- package/src/cli/commands/list.ts +73 -0
- package/src/cli/commands/modeling.ts +105 -0
- package/src/cli/commands/show.ts +238 -0
- package/src/cli/commands/status.ts +140 -0
- package/src/cli/commands/upgrade-track.ts +385 -0
- package/src/cli/commands/upgrade-workspace.ts +138 -0
- package/src/cli/commands/validate.ts +330 -0
- package/src/cli/engineering/config.ts +68 -0
- package/src/cli/engineering/lint.ts +58 -0
- package/src/cli/engineering/merge.ts +172 -0
- package/src/cli/engineering/registry.ts +230 -0
- package/src/cli/engineering/schema.ts +126 -0
- package/src/cli/engineering/validate.ts +286 -0
- package/src/cli/index.ts +136 -0
- package/src/cli/modeling/config.ts +68 -0
- package/src/cli/modeling/lint.ts +58 -0
- package/src/cli/modeling/merge.ts +172 -0
- package/src/cli/modeling/registry.ts +229 -0
- package/src/cli/modeling/schema.ts +160 -0
- package/src/cli/modeling/validate.ts +282 -0
- package/src/cli/utils/index.ts +941 -0
- package/src/cli/utils/install.ts +291 -0
- package/src/cli/utils/spec-xml.ts +673 -0
- package/src/cli/utils/track-time.ts +75 -0
- package/src/cli/utils/vfs.ts +102 -0
- package/src/templates/codument/README.md +59 -0
- package/src/templates/codument/attractors/product.md +17 -0
- package/src/templates/codument/attractors/project.md +10 -0
- package/src/templates/codument/backlog/README.md +33 -0
- package/src/templates/codument/config/attractor-profiles.xml +31 -0
- package/src/templates/codument/config/engineering.xml +22 -0
- package/src/templates/codument/config/modeling.xml +22 -0
- package/src/templates/codument/config/operation-hooks.xml +55 -0
- package/src/templates/codument/memory/README.md +13 -0
- package/src/templates/codument/missions/README.md +125 -0
- package/src/templates/codument/sop/README.md +14 -0
- package/src/templates/codument/std/AGENTS.md +82 -0
- package/src/templates/codument/std/attractors/depa-attractor.md +572 -0
- package/src/templates/codument/std/attractors/knowledge-tiers.md +128 -0
- package/src/templates/codument/std/attractors/model-driven-docs.md +293 -0
- package/src/templates/codument/std/attractors/project-memory.md +48 -0
- package/src/templates/codument/std/docs-impl-fractal/index.md +110 -0
- package/src/templates/codument/std/docs-modeling-fractal/index.md +156 -0
- package/src/templates/codument/std/kernel-pointer.md +19 -0
- package/src/templates/codument/std/operations/README.md +30 -0
- package/src/templates/codument/std/operations/_operation-spec.md +41 -0
- package/src/templates/codument/std/operations/archive-mission.md +66 -0
- package/src/templates/codument/std/operations/archive-track.md +238 -0
- package/src/templates/codument/std/operations/artifact-sync.md +172 -0
- package/src/templates/codument/std/operations/discuss-phase.md +214 -0
- package/src/templates/codument/std/operations/discuss.md +87 -0
- package/src/templates/codument/std/operations/docs-bootstrap.md +148 -0
- package/src/templates/codument/std/operations/gap-loop.md +301 -0
- package/src/templates/codument/std/operations/impl-mission.md +167 -0
- package/src/templates/codument/std/operations/impl-quick.md +79 -0
- package/src/templates/codument/std/operations/impl-track.md +537 -0
- package/src/templates/codument/std/operations/migrate.md +337 -0
- package/src/templates/codument/std/operations/plan-mission.md +230 -0
- package/src/templates/codument/std/operations/plan-track-wave.md +231 -0
- package/src/templates/codument/std/operations/plan-track.md +579 -0
- package/src/templates/codument/std/operations/revise-track.md +136 -0
- package/src/templates/codument/std/operations/validate.md +339 -0
- package/src/templates/codument/std/operations/verify.md +184 -0
- package/src/templates/codument/std/root-agents.md +39 -0
- package/src/templates/codument/std/sop/questioning.md +98 -0
- package/src/templates/codument/std/sop/tdd.md +26 -0
- package/src/templates/codument/std/sop/validation.md +25 -0
- package/src/templates/codument/std/sop/wave-exec.md +42 -0
- package/src/templates/codument/std/sop/workflow.md +35 -0
- package/src/templates/codument/std/spec/behavior-delta.md +36 -0
- package/src/templates/codument/std/spec/behavior-registry.md +42 -0
- package/src/templates/codument/std/spec/engineering-delta.md +68 -0
- package/src/templates/codument/std/spec/engineering-node-schema.md +86 -0
- package/src/templates/codument/std/spec/engineering-registry.md +82 -0
- package/src/templates/codument/std/spec/flow-notation.md +93 -0
- package/src/templates/codument/std/spec/folder-manifest.md +99 -0
- package/src/templates/codument/std/spec/mission-xml-spec.md +249 -0
- package/src/templates/codument/std/spec/modeling-delta.md +85 -0
- package/src/templates/codument/std/spec/modeling-node-schema.md +183 -0
- package/src/templates/codument/std/spec/modeling-registry.md +49 -0
- package/src/templates/codument/std/spec/track-xml-spec.md +272 -0
- package/src/templates/codument/std/spec/xnl-format.md +301 -0
- package/src/templates/codument/workflows/README.md +15 -0
- package/src/templates/manifest.ts +177 -0
- package/src/templates/skills/README.md +38 -0
- package/src/templates/skills/codument-archive/SKILL.md +17 -0
- package/src/templates/skills/codument-archive-mission/SKILL.md +17 -0
- package/src/templates/skills/codument-archive-track/SKILL.md +17 -0
- package/src/templates/skills/codument-artifact-sync/SKILL.md +17 -0
- package/src/templates/skills/codument-code-quality-score/SKILL.md +67 -0
- package/src/templates/skills/codument-decision-tree/SKILL.md +40 -0
- package/src/templates/skills/codument-discuss/SKILL.md +17 -0
- package/src/templates/skills/codument-discuss-phase/SKILL.md +17 -0
- package/src/templates/skills/codument-docs-bootstrap/SKILL.md +17 -0
- package/src/templates/skills/codument-gap-loop/SKILL.md +17 -0
- package/src/templates/skills/codument-impl-mission/SKILL.md +17 -0
- package/src/templates/skills/codument-impl-quick/SKILL.md +17 -0
- package/src/templates/skills/codument-impl-track/SKILL.md +17 -0
- package/src/templates/skills/codument-implement/SKILL.md +14 -0
- package/src/templates/skills/codument-migrate/SKILL.md +17 -0
- package/src/templates/skills/codument-modeling-engineering-e2e/SKILL.md +74 -0
- package/src/templates/skills/codument-plan-mission/SKILL.md +17 -0
- package/src/templates/skills/codument-plan-track/SKILL.md +17 -0
- package/src/templates/skills/codument-plan-track-wave/SKILL.md +17 -0
- package/src/templates/skills/codument-revise-track/SKILL.md +17 -0
- package/src/templates/skills/codument-track/SKILL.md +14 -0
- package/src/templates/skills/codument-validate/SKILL.md +17 -0
- package/src/templates/skills/codument-verify/SKILL.md +17 -0
- package/src/types/text-assets.d.ts +9 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,941 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { getSpecXmlStats, loadSpecXml, parseSpecXmlContent, type SpecXmlNode } from './spec-xml';
|
|
4
|
+
|
|
5
|
+
// Workspace directory (can be changed via --workspace-dir)
|
|
6
|
+
let workspaceDir = process.cwd();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Set the workspace directory
|
|
10
|
+
*/
|
|
11
|
+
export function setWorkspaceDir(dir: string): void {
|
|
12
|
+
workspaceDir = path.resolve(dir);
|
|
13
|
+
process.chdir(workspaceDir);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get the workspace directory
|
|
18
|
+
*/
|
|
19
|
+
export function getWorkspaceDir(): string {
|
|
20
|
+
return workspaceDir;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const CODUMENT_DIR = 'codument';
|
|
24
|
+
export const TRACKS_DIR = path.join(CODUMENT_DIR, 'tracks');
|
|
25
|
+
export const SPECS_DIR = path.join(CODUMENT_DIR, 'specs');
|
|
26
|
+
export const BEHAVIORS_DIR = path.join(CODUMENT_DIR, 'behaviors');
|
|
27
|
+
export const ARCHIVE_DIR = path.join(CODUMENT_DIR, 'archive');
|
|
28
|
+
export const ATTRACTORS_DIR = path.join(CODUMENT_DIR, 'attractors');
|
|
29
|
+
export const CONFIG_DIR = path.join(CODUMENT_DIR, 'config');
|
|
30
|
+
export const DECISIONS_DIR = path.join(CODUMENT_DIR, 'decisions');
|
|
31
|
+
export const MEMORY_DIR = path.join(CODUMENT_DIR, 'memory');
|
|
32
|
+
export const LEGACY_DIR = path.join(CODUMENT_DIR, 'legacy');
|
|
33
|
+
|
|
34
|
+
export interface TrackMetadata {
|
|
35
|
+
track_id: string;
|
|
36
|
+
type: 'feature' | 'bug' | 'chore' | 'refactor';
|
|
37
|
+
status: 'new' | 'in_progress' | 'completed' | 'cancelled';
|
|
38
|
+
commit_mode?: 'auto' | 'manual';
|
|
39
|
+
track_name?: string;
|
|
40
|
+
goal?: string;
|
|
41
|
+
created_at: string;
|
|
42
|
+
updated_at: string;
|
|
43
|
+
description: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface TaskSummary {
|
|
47
|
+
total_phases: number;
|
|
48
|
+
total_tasks: number;
|
|
49
|
+
total_subtasks: number;
|
|
50
|
+
total_estimated_days: number;
|
|
51
|
+
completed: number;
|
|
52
|
+
in_progress: number;
|
|
53
|
+
todo: number;
|
|
54
|
+
blocked: number;
|
|
55
|
+
commit_mode?: 'auto' | 'manual';
|
|
56
|
+
execution_mode?: 'wave' | 'sequential';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Track {
|
|
60
|
+
id: string;
|
|
61
|
+
metadata: TrackMetadata;
|
|
62
|
+
taskSummary?: TaskSummary;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Spec {
|
|
66
|
+
id: string;
|
|
67
|
+
path: string;
|
|
68
|
+
requirements: number;
|
|
69
|
+
scenarios: number;
|
|
70
|
+
format?: 'markdown' | 'xml';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getPlanTrackStatus(planPath: string): TrackMetadata['status'] | undefined {
|
|
74
|
+
try {
|
|
75
|
+
const content = fs.readFileSync(planPath, 'utf-8');
|
|
76
|
+
const statusMatch = content.match(/<metadata>[\s\S]*?<status>(new|in_progress|completed|cancelled)<\/status>/);
|
|
77
|
+
return statusMatch ? statusMatch[1] as TrackMetadata['status'] : undefined;
|
|
78
|
+
} catch (e) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getTagText(content: string, tag: string): string | undefined {
|
|
84
|
+
const match = content.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`));
|
|
85
|
+
return match ? match[1].trim() : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getMetadataBlock(content: string): string | undefined {
|
|
89
|
+
return content.match(/<metadata>([\s\S]*?)<\/metadata>/)?.[1];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function xmlEscape(value: string): string {
|
|
93
|
+
return value
|
|
94
|
+
.replace(/&/g, '&')
|
|
95
|
+
.replace(/</g, '<')
|
|
96
|
+
.replace(/>/g, '>')
|
|
97
|
+
.replace(/"/g, '"')
|
|
98
|
+
.replace(/'/g, ''');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function insertMetadataField(content: string, tag: string, value: string): string {
|
|
102
|
+
const metadataEndIdx = content.indexOf('</metadata>');
|
|
103
|
+
if (metadataEndIdx === -1) {
|
|
104
|
+
return content;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return content.slice(0, metadataEndIdx) + ` <${tag}>${xmlEscape(value)}</${tag}>\n` + content.slice(metadataEndIdx);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function mergeLegacyMetadataIntoPlan(planPath: string, metadataPath: string): void {
|
|
111
|
+
if (!fs.existsSync(metadataPath)) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const legacy = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')) as Partial<TrackMetadata>;
|
|
117
|
+
let content = fs.readFileSync(planPath, 'utf-8');
|
|
118
|
+
|
|
119
|
+
for (const field of ['track_id', 'type', 'created_at', 'updated_at', 'description'] as const) {
|
|
120
|
+
const legacyValue = legacy[field];
|
|
121
|
+
const metadataBlock = getMetadataBlock(content) || '';
|
|
122
|
+
if (legacyValue && !getTagText(metadataBlock, field)) {
|
|
123
|
+
content = insertMetadataField(content, field, String(legacyValue));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
fs.writeFileSync(planPath, content, 'utf-8');
|
|
128
|
+
} catch (e) {
|
|
129
|
+
// Invalid legacy metadata should not prevent reading plan.xml.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function parsePlanMetadata(planPath: string, trackIdFallback?: string): TrackMetadata | null {
|
|
134
|
+
try {
|
|
135
|
+
const content = fs.readFileSync(planPath, 'utf-8');
|
|
136
|
+
const metadataBlock = content.match(/<metadata>([\s\S]*?)<\/metadata>/)?.[1];
|
|
137
|
+
if (!metadataBlock) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const status = getTagText(metadataBlock, 'status');
|
|
142
|
+
const type = getTagText(metadataBlock, 'type');
|
|
143
|
+
const commitMode = getTagText(metadataBlock, 'commit_mode');
|
|
144
|
+
const trackId = getTagText(metadataBlock, 'track_id') || trackIdFallback;
|
|
145
|
+
const createdAt = getTagText(metadataBlock, 'created_at');
|
|
146
|
+
const updatedAt = getTagText(metadataBlock, 'updated_at') || createdAt;
|
|
147
|
+
const description = getTagText(metadataBlock, 'description') || getTagText(metadataBlock, 'goal');
|
|
148
|
+
|
|
149
|
+
if (!trackId || !type || !status || !createdAt || !updatedAt || !description) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
track_id: trackId,
|
|
155
|
+
track_name: getTagText(metadataBlock, 'track_name'),
|
|
156
|
+
goal: getTagText(metadataBlock, 'goal'),
|
|
157
|
+
type: type as TrackMetadata['type'],
|
|
158
|
+
status: status as TrackMetadata['status'],
|
|
159
|
+
commit_mode: commitMode === 'auto' || commitMode === 'manual' ? commitMode : undefined,
|
|
160
|
+
created_at: createdAt,
|
|
161
|
+
updated_at: updatedAt,
|
|
162
|
+
description,
|
|
163
|
+
};
|
|
164
|
+
} catch (e) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Check if codument directory exists
|
|
171
|
+
*/
|
|
172
|
+
export function codumentExists(): boolean {
|
|
173
|
+
return fs.existsSync(CODUMENT_DIR);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Get track directory IDs that contain a track.xml.
|
|
178
|
+
*/
|
|
179
|
+
export function getTrackIds(): string[] {
|
|
180
|
+
if (!fs.existsSync(TRACKS_DIR)) {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return fs.readdirSync(TRACKS_DIR, { withFileTypes: true })
|
|
185
|
+
.filter(dirent => dirent.isDirectory())
|
|
186
|
+
.map(dirent => dirent.name)
|
|
187
|
+
.filter(trackId => fs.existsSync(path.join(TRACKS_DIR, trackId, 'track.xml')));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---- track.xml readers (new XML standard) ----------------------------------
|
|
191
|
+
|
|
192
|
+
const SPARROW_DONE = new Set(['DONE']);
|
|
193
|
+
const SPARROW_ACTIVE = new Set(['ACTIVE', 'DELEGATED', 'FORWARDED']);
|
|
194
|
+
const SPARROW_BLOCKED = new Set(['REFUSED', 'ABANDONED']);
|
|
195
|
+
|
|
196
|
+
function trackXmlPath(trackId: string): string {
|
|
197
|
+
return path.join(TRACKS_DIR, trackId, 'track.xml');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parseTrackRoot(file: string): SpecXmlNode | null {
|
|
201
|
+
try {
|
|
202
|
+
const root = parseSpecXmlContent(fs.readFileSync(file, 'utf-8'));
|
|
203
|
+
return root.tag === 'Track' ? root : null;
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function childOf(node: SpecXmlNode | undefined, tag: string): SpecXmlNode | undefined {
|
|
210
|
+
return node?.children.find((c) => c.tag === tag);
|
|
211
|
+
}
|
|
212
|
+
function childTextOf(node: SpecXmlNode | undefined, tag: string): string | undefined {
|
|
213
|
+
return childOf(node, tag)?.text?.trim() || undefined;
|
|
214
|
+
}
|
|
215
|
+
function findAll(node: SpecXmlNode, pred: (n: SpecXmlNode) => boolean, acc: SpecXmlNode[] = []): SpecXmlNode[] {
|
|
216
|
+
for (const c of node.children) {
|
|
217
|
+
if (pred(c)) acc.push(c);
|
|
218
|
+
findAll(c, pred, acc);
|
|
219
|
+
}
|
|
220
|
+
return acc;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Read track.xml <Metadata> into TrackMetadata (track.xml has no `type`; defaults to feature). */
|
|
224
|
+
export function parseTrackMetadata(file: string, trackIdFallback?: string): TrackMetadata | null {
|
|
225
|
+
const root = parseTrackRoot(file);
|
|
226
|
+
if (!root) return null;
|
|
227
|
+
|
|
228
|
+
const id = root.attrs['id'] || trackIdFallback;
|
|
229
|
+
if (!id) return null;
|
|
230
|
+
const meta = childOf(root, 'Metadata');
|
|
231
|
+
const status = (childTextOf(meta, 'Status') as TrackMetadata['status']) || 'new';
|
|
232
|
+
const commit = childTextOf(meta, 'CommitMode');
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
track_id: id,
|
|
236
|
+
track_name: childTextOf(meta, 'Goal'),
|
|
237
|
+
goal: childTextOf(meta, 'Goal'),
|
|
238
|
+
type: 'feature',
|
|
239
|
+
status,
|
|
240
|
+
commit_mode: commit === 'auto' || commit === 'manual' ? commit : undefined,
|
|
241
|
+
created_at: childTextOf(meta, 'CreatedAt') || '',
|
|
242
|
+
updated_at: childTextOf(meta, 'UpdatedAt') || childTextOf(meta, 'CreatedAt') || '',
|
|
243
|
+
description: childTextOf(meta, 'Description') || childTextOf(meta, 'Goal') || '',
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Count phases (first-level TaskGroups) and leaf Tasks by sparrow status. */
|
|
248
|
+
export function parseTrackSummary(file: string): TaskSummary | undefined {
|
|
249
|
+
const root = parseTrackRoot(file);
|
|
250
|
+
if (!root) return undefined;
|
|
251
|
+
const space = childOf(root, 'TaskSpace');
|
|
252
|
+
if (!space) return undefined;
|
|
253
|
+
|
|
254
|
+
const firstLevel = childOf(space, 'SubNodes') ?? space;
|
|
255
|
+
const total_phases = firstLevel.children.filter((c) => c.tag === 'TaskGroup').length;
|
|
256
|
+
const leaves = findAll(space, (n) => n.tag === 'Task');
|
|
257
|
+
|
|
258
|
+
let completed = 0, in_progress = 0, todo = 0, blocked = 0;
|
|
259
|
+
for (const t of leaves) {
|
|
260
|
+
const s = t.attrs['status'] || 'NOT_STARTED';
|
|
261
|
+
if (SPARROW_DONE.has(s)) completed++;
|
|
262
|
+
else if (SPARROW_ACTIVE.has(s)) in_progress++;
|
|
263
|
+
else if (SPARROW_BLOCKED.has(s)) blocked++;
|
|
264
|
+
else todo++;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
total_phases,
|
|
269
|
+
total_tasks: leaves.length,
|
|
270
|
+
total_subtasks: 0,
|
|
271
|
+
total_estimated_days: 0,
|
|
272
|
+
completed,
|
|
273
|
+
in_progress,
|
|
274
|
+
todo,
|
|
275
|
+
blocked,
|
|
276
|
+
commit_mode: parseTrackMetadata(file)?.commit_mode,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface TrackTaskInfo {
|
|
281
|
+
phaseName: string;
|
|
282
|
+
name: string;
|
|
283
|
+
status: string;
|
|
284
|
+
}
|
|
285
|
+
/** Flat list of leaf tasks with their phase + sparrow status (for status display). */
|
|
286
|
+
export function walkTrackTasks(file: string): TrackTaskInfo[] {
|
|
287
|
+
const root = parseTrackRoot(file);
|
|
288
|
+
if (!root) return [];
|
|
289
|
+
const space = childOf(root, 'TaskSpace');
|
|
290
|
+
if (!space) return [];
|
|
291
|
+
const firstLevel = childOf(space, 'SubNodes') ?? space;
|
|
292
|
+
const out: TrackTaskInfo[] = [];
|
|
293
|
+
for (const phase of firstLevel.children.filter((c) => c.tag === 'TaskGroup')) {
|
|
294
|
+
const phaseName = phase.attrs['name'] || phase.attrs['id'] || '';
|
|
295
|
+
for (const t of findAll(phase, (n) => n.tag === 'Task')) {
|
|
296
|
+
out.push({ phaseName, name: t.attrs['name'] || t.attrs['id'] || '', status: t.attrs['status'] || 'NOT_STARTED' });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Get all tracks from tracks directory
|
|
304
|
+
*/
|
|
305
|
+
export function getTracks(): Track[] {
|
|
306
|
+
const tracks: Track[] = [];
|
|
307
|
+
for (const trackId of getTrackIds()) {
|
|
308
|
+
const file = trackXmlPath(trackId);
|
|
309
|
+
const metadata = parseTrackMetadata(file, trackId);
|
|
310
|
+
if (metadata) {
|
|
311
|
+
tracks.push({ id: trackId, metadata, taskSummary: parseTrackSummary(file) });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return tracks;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Get all specs from specs directory
|
|
319
|
+
*/
|
|
320
|
+
export function getSpecs(): Spec[] {
|
|
321
|
+
if (!fs.existsSync(SPECS_DIR)) {
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const specs: Spec[] = [];
|
|
326
|
+
|
|
327
|
+
for (const entry of fs.readdirSync(SPECS_DIR, { withFileTypes: true })) {
|
|
328
|
+
const specId = entry.isFile() && entry.name.endsWith('.xml')
|
|
329
|
+
? entry.name.slice(0, -'.xml'.length)
|
|
330
|
+
: entry.name;
|
|
331
|
+
const entryPath = path.join(SPECS_DIR, entry.name);
|
|
332
|
+
const markdownSpecPath = path.join(entryPath, 'spec.md');
|
|
333
|
+
const folderXmlPath = path.join(entryPath, 'index.xml');
|
|
334
|
+
|
|
335
|
+
if (entry.isDirectory() && fs.existsSync(markdownSpecPath)) {
|
|
336
|
+
const content = fs.readFileSync(markdownSpecPath, 'utf-8');
|
|
337
|
+
const requirements = (content.match(/^### Requirement:/gm) || []).length;
|
|
338
|
+
const scenarios = (content.match(/^#### Scenario:/gm) || []).length;
|
|
339
|
+
|
|
340
|
+
specs.push({
|
|
341
|
+
id: specId,
|
|
342
|
+
path: markdownSpecPath,
|
|
343
|
+
requirements,
|
|
344
|
+
scenarios,
|
|
345
|
+
format: 'markdown',
|
|
346
|
+
});
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if ((entry.isFile() && entry.name.endsWith('.xml')) || (entry.isDirectory() && fs.existsSync(folderXmlPath))) {
|
|
351
|
+
try {
|
|
352
|
+
const xmlPath = entry.isFile() ? entryPath : entryPath;
|
|
353
|
+
const stats = getSpecXmlStats(loadSpecXml(xmlPath));
|
|
354
|
+
specs.push({
|
|
355
|
+
id: specId,
|
|
356
|
+
path: entry.isFile() ? entryPath : folderXmlPath,
|
|
357
|
+
requirements: stats.requirements,
|
|
358
|
+
scenarios: stats.scenarios,
|
|
359
|
+
format: 'xml',
|
|
360
|
+
});
|
|
361
|
+
} catch {
|
|
362
|
+
specs.push({
|
|
363
|
+
id: specId,
|
|
364
|
+
path: entry.isFile() ? entryPath : folderXmlPath,
|
|
365
|
+
requirements: 0,
|
|
366
|
+
scenarios: 0,
|
|
367
|
+
format: 'xml',
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return specs;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Parse plan.xml summary section (supports new XML format)
|
|
378
|
+
*/
|
|
379
|
+
export function parsePlanSummary(planPath: string): TaskSummary | undefined {
|
|
380
|
+
try {
|
|
381
|
+
const content = fs.readFileSync(planPath, 'utf-8');
|
|
382
|
+
|
|
383
|
+
// Helper to get tag value from summary section
|
|
384
|
+
const getTagValue = (tag: string): number => {
|
|
385
|
+
const tagMatch = content.match(new RegExp(`<${tag}>(\\d+)</${tag}>`));
|
|
386
|
+
return tagMatch ? parseInt(tagMatch[1], 10) : 0;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// Helper to get commit_mode from metadata
|
|
390
|
+
const getCommitMode = (): 'auto' | 'manual' | undefined => {
|
|
391
|
+
const modeMatch = content.match(/<commit_mode>(auto|manual)<\/commit_mode>/);
|
|
392
|
+
return modeMatch ? modeMatch[1] as 'auto' | 'manual' : undefined;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
// Helper to get execution_mode
|
|
396
|
+
const getExecutionMode = (): 'wave' | 'sequential' => {
|
|
397
|
+
const modeMatch = content.match(/<execution_mode>(wave|sequential)<\/execution_mode>/);
|
|
398
|
+
return modeMatch ? modeMatch[1] as 'wave' | 'sequential' : 'sequential';
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// If summary section exists, use it
|
|
402
|
+
if (content.includes('<summary>')) {
|
|
403
|
+
return {
|
|
404
|
+
total_phases: getTagValue('total_phases'),
|
|
405
|
+
total_tasks: getTagValue('total_tasks'),
|
|
406
|
+
total_subtasks: getTagValue('total_subtasks'),
|
|
407
|
+
total_estimated_days: getTagValue('total_estimated_days'),
|
|
408
|
+
completed: getTagValue('completed'),
|
|
409
|
+
in_progress: getTagValue('in_progress'),
|
|
410
|
+
todo: getTagValue('todo'),
|
|
411
|
+
blocked: getTagValue('blocked'),
|
|
412
|
+
commit_mode: getCommitMode(),
|
|
413
|
+
execution_mode: getExecutionMode(),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Otherwise, compute from task attributes (new format: status in attributes)
|
|
418
|
+
const taskStatusMatches = content.matchAll(/<task[^>]+status="([^"]+)"[^>]*>/g);
|
|
419
|
+
const subtaskStatusMatches = content.matchAll(/<subtask\s+[^>]*status="([^"]+)"[^>]*(?:\/>|>)/g);
|
|
420
|
+
const phaseMatches = content.matchAll(/<phase[^>]+id="[^"]+"[^>]*>/g);
|
|
421
|
+
|
|
422
|
+
let completed = 0;
|
|
423
|
+
let in_progress = 0;
|
|
424
|
+
let todo = 0;
|
|
425
|
+
let blocked = 0;
|
|
426
|
+
let total_tasks = 0;
|
|
427
|
+
let total_subtasks = 0;
|
|
428
|
+
|
|
429
|
+
// Count tasks
|
|
430
|
+
for (const taskStatusMatch of taskStatusMatches) {
|
|
431
|
+
total_tasks++;
|
|
432
|
+
const status = taskStatusMatch[1];
|
|
433
|
+
if (status === 'DONE') completed++;
|
|
434
|
+
else if (status === 'IN_PROGRESS') in_progress++;
|
|
435
|
+
else if (status === 'TODO') todo++;
|
|
436
|
+
else if (status === 'BLOCKED') blocked++;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Count subtasks
|
|
440
|
+
total_subtasks = [...subtaskStatusMatches].length;
|
|
441
|
+
|
|
442
|
+
// Count phases
|
|
443
|
+
const total_phases = [...phaseMatches].length;
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
total_phases,
|
|
447
|
+
total_tasks,
|
|
448
|
+
total_subtasks,
|
|
449
|
+
total_estimated_days: 0, // Cannot compute without parsing estimated_days attributes
|
|
450
|
+
completed,
|
|
451
|
+
in_progress,
|
|
452
|
+
todo,
|
|
453
|
+
blocked,
|
|
454
|
+
commit_mode: getCommitMode(),
|
|
455
|
+
execution_mode: getExecutionMode(),
|
|
456
|
+
};
|
|
457
|
+
} catch (e) {
|
|
458
|
+
return undefined;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Get track by ID (reads track.xml).
|
|
464
|
+
*/
|
|
465
|
+
export function getTrack(trackId: string): Track | null {
|
|
466
|
+
const file = trackXmlPath(trackId);
|
|
467
|
+
if (!fs.existsSync(file)) {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
const metadata = parseTrackMetadata(file, trackId);
|
|
471
|
+
if (!metadata) {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
id: trackId,
|
|
476
|
+
metadata,
|
|
477
|
+
taskSummary: parseTrackSummary(file),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Parse command line arguments
|
|
483
|
+
*/
|
|
484
|
+
export function parseOptions(args: string[]): { positional: string[]; options: Record<string, string | boolean> } {
|
|
485
|
+
const positional: string[] = [];
|
|
486
|
+
const options: Record<string, string | boolean> = {};
|
|
487
|
+
|
|
488
|
+
for (let i = 0; i < args.length; i++) {
|
|
489
|
+
const arg = args[i];
|
|
490
|
+
|
|
491
|
+
if (arg.startsWith('--')) {
|
|
492
|
+
const equalsIndex = arg.indexOf('=');
|
|
493
|
+
if (equalsIndex !== -1) {
|
|
494
|
+
const key = arg.slice(2, equalsIndex);
|
|
495
|
+
options[key] = arg.slice(equalsIndex + 1);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const key = arg.slice(2);
|
|
500
|
+
const nextArg = args[i + 1];
|
|
501
|
+
|
|
502
|
+
if (nextArg && !nextArg.startsWith('-')) {
|
|
503
|
+
options[key] = nextArg;
|
|
504
|
+
i++;
|
|
505
|
+
} else {
|
|
506
|
+
options[key] = true;
|
|
507
|
+
}
|
|
508
|
+
} else if (arg.startsWith('-')) {
|
|
509
|
+
const key = arg.slice(1);
|
|
510
|
+
options[key] = true;
|
|
511
|
+
} else {
|
|
512
|
+
positional.push(arg);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return { positional, options };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Format status badge
|
|
521
|
+
*/
|
|
522
|
+
export function formatStatus(status: string): string {
|
|
523
|
+
const statusMap: Record<string, string> = {
|
|
524
|
+
'new': '[ ]',
|
|
525
|
+
'in_progress': '[~]',
|
|
526
|
+
'completed': '[x]',
|
|
527
|
+
'cancelled': '[-]',
|
|
528
|
+
'TODO': '[ ]',
|
|
529
|
+
'IN_PROGRESS': '[~]',
|
|
530
|
+
'DONE': '[x]',
|
|
531
|
+
'BLOCKED': '[!]',
|
|
532
|
+
};
|
|
533
|
+
return statusMap[status] || `[${status}]`;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Task detail interface
|
|
538
|
+
*/
|
|
539
|
+
export interface TaskDetail {
|
|
540
|
+
id: string;
|
|
541
|
+
name: string;
|
|
542
|
+
status: 'TODO' | 'IN_PROGRESS' | 'DONE' | 'BLOCKED' | 'CANCELLED';
|
|
543
|
+
priority: 'P0' | 'P1' | 'P2';
|
|
544
|
+
description: string;
|
|
545
|
+
estimated_days?: number;
|
|
546
|
+
commit?: string;
|
|
547
|
+
wave?: string;
|
|
548
|
+
acceptance_criteria?: { id: string; text: string; checked: boolean }[];
|
|
549
|
+
subtasks?: SubtaskDetail[];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export interface SubtaskDetail {
|
|
553
|
+
id: string;
|
|
554
|
+
name: string;
|
|
555
|
+
status: 'TODO' | 'IN_PROGRESS' | 'DONE' | 'BLOCKED' | 'CANCELLED';
|
|
556
|
+
estimated_hours?: number;
|
|
557
|
+
detail_ref?: string;
|
|
558
|
+
children?: SubtaskDetail[];
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export interface WaveInfo {
|
|
562
|
+
id: string;
|
|
563
|
+
depends_on: string[];
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export interface PhaseDetail {
|
|
567
|
+
id: string;
|
|
568
|
+
name: string;
|
|
569
|
+
goal: string;
|
|
570
|
+
milestone?: string;
|
|
571
|
+
estimated_days?: number;
|
|
572
|
+
tasks: TaskDetail[];
|
|
573
|
+
gate_criteria?: string[];
|
|
574
|
+
waves?: WaveInfo[];
|
|
575
|
+
context_files?: string[];
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
interface ParsedSubtaskNode {
|
|
579
|
+
attrs: string;
|
|
580
|
+
innerContent?: string;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function findFirstBalancedTag(
|
|
584
|
+
content: string,
|
|
585
|
+
tagName: string,
|
|
586
|
+
): { start: number; end: number; inner: string } | undefined {
|
|
587
|
+
const openTag = `<${tagName}>`;
|
|
588
|
+
const closeTag = `</${tagName}>`;
|
|
589
|
+
|
|
590
|
+
const start = content.indexOf(openTag);
|
|
591
|
+
if (start === -1) {
|
|
592
|
+
return undefined;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
let depth = 1;
|
|
596
|
+
let cursor = start + openTag.length;
|
|
597
|
+
|
|
598
|
+
while (depth > 0) {
|
|
599
|
+
const nextOpen = content.indexOf(openTag, cursor);
|
|
600
|
+
const nextClose = content.indexOf(closeTag, cursor);
|
|
601
|
+
|
|
602
|
+
if (nextClose === -1) {
|
|
603
|
+
return undefined;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (nextOpen !== -1 && nextOpen < nextClose) {
|
|
607
|
+
depth++;
|
|
608
|
+
cursor = nextOpen + openTag.length;
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
depth--;
|
|
613
|
+
if (depth === 0) {
|
|
614
|
+
const closeEnd = nextClose + closeTag.length;
|
|
615
|
+
return {
|
|
616
|
+
start,
|
|
617
|
+
end: closeEnd,
|
|
618
|
+
inner: content.slice(start + openTag.length, nextClose),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
cursor = nextClose + closeTag.length;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return undefined;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function parseSubtaskNodes(subtasksContent: string): ParsedSubtaskNode[] {
|
|
629
|
+
const nodes: ParsedSubtaskNode[] = [];
|
|
630
|
+
let cursor = 0;
|
|
631
|
+
|
|
632
|
+
const findNextSubtaskOpen = (from: number): number => {
|
|
633
|
+
let idx = subtasksContent.indexOf('<subtask', from);
|
|
634
|
+
while (idx !== -1) {
|
|
635
|
+
const nextChar = subtasksContent[idx + '<subtask'.length];
|
|
636
|
+
if (nextChar === ' ' || nextChar === '\t' || nextChar === '\n' || nextChar === '\r') {
|
|
637
|
+
return idx;
|
|
638
|
+
}
|
|
639
|
+
idx = subtasksContent.indexOf('<subtask', idx + 1);
|
|
640
|
+
}
|
|
641
|
+
return -1;
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
while (true) {
|
|
645
|
+
const openIdx = findNextSubtaskOpen(cursor);
|
|
646
|
+
if (openIdx === -1) {
|
|
647
|
+
break;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const tagEndIdx = subtasksContent.indexOf('>', openIdx);
|
|
651
|
+
if (tagEndIdx === -1) {
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const openingTag = subtasksContent.slice(openIdx, tagEndIdx + 1);
|
|
656
|
+
const attrsMatch = openingTag.match(/<subtask\s+([^>]*?)(?:\/)?\s*>/);
|
|
657
|
+
const attrs = attrsMatch ? attrsMatch[1] : '';
|
|
658
|
+
const selfClosing = /\/\s*>$/.test(openingTag);
|
|
659
|
+
|
|
660
|
+
if (selfClosing) {
|
|
661
|
+
nodes.push({ attrs });
|
|
662
|
+
cursor = tagEndIdx + 1;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
let depth = 1;
|
|
667
|
+
let searchFrom = tagEndIdx + 1;
|
|
668
|
+
let closeIdx = -1;
|
|
669
|
+
|
|
670
|
+
while (depth > 0) {
|
|
671
|
+
const nextOpen = findNextSubtaskOpen(searchFrom);
|
|
672
|
+
const nextClose = subtasksContent.indexOf('</subtask>', searchFrom);
|
|
673
|
+
|
|
674
|
+
if (nextClose === -1) {
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (nextOpen !== -1 && nextOpen < nextClose) {
|
|
679
|
+
const nextOpenEnd = subtasksContent.indexOf('>', nextOpen);
|
|
680
|
+
if (nextOpenEnd === -1) {
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const nextOpenTag = subtasksContent.slice(nextOpen, nextOpenEnd + 1);
|
|
685
|
+
const nextSelfClosing = /\/\s*>$/.test(nextOpenTag);
|
|
686
|
+
if (!nextSelfClosing) {
|
|
687
|
+
depth++;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
searchFrom = nextOpenEnd + 1;
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
depth--;
|
|
695
|
+
closeIdx = nextClose;
|
|
696
|
+
searchFrom = nextClose + '</subtask>'.length;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (closeIdx === -1 || depth !== 0) {
|
|
700
|
+
cursor = tagEndIdx + 1;
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
nodes.push({
|
|
705
|
+
attrs,
|
|
706
|
+
innerContent: subtasksContent.slice(tagEndIdx + 1, closeIdx),
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
cursor = closeIdx + '</subtask>'.length;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return nodes;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Recursively parse subtasks (supports both self-closing and open/close tags)
|
|
717
|
+
*/
|
|
718
|
+
function parseSubtasks(subtasksContent: string): SubtaskDetail[] {
|
|
719
|
+
const results: SubtaskDetail[] = [];
|
|
720
|
+
|
|
721
|
+
const subtaskNodes = parseSubtaskNodes(subtasksContent);
|
|
722
|
+
|
|
723
|
+
for (const node of subtaskNodes) {
|
|
724
|
+
const attrs = node.attrs;
|
|
725
|
+
const innerContent = node.innerContent; // undefined for self-closing
|
|
726
|
+
|
|
727
|
+
const id = attrs.match(/id="([^"]+)"/)?.[1] ?? '';
|
|
728
|
+
const name = attrs.match(/name="([^"]+)"/)?.[1] ?? '';
|
|
729
|
+
const status = attrs.match(/status="([^"]+)"/)?.[1] ?? 'TODO';
|
|
730
|
+
const estHours = attrs.match(/estimated_hours="(\d+)"/)?.[1];
|
|
731
|
+
|
|
732
|
+
const subtask: SubtaskDetail = {
|
|
733
|
+
id,
|
|
734
|
+
name,
|
|
735
|
+
status: status as SubtaskDetail['status'],
|
|
736
|
+
estimated_hours: estHours ? parseInt(estHours, 10) : undefined,
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
if (innerContent) {
|
|
740
|
+
// Extract nested subtasks (balanced)
|
|
741
|
+
const nestedSubtasksTag = findFirstBalancedTag(innerContent, 'subtasks');
|
|
742
|
+
|
|
743
|
+
// Extract detail_ref from current level (exclude nested subtasks content)
|
|
744
|
+
const detailRefSearchContent = nestedSubtasksTag
|
|
745
|
+
? innerContent.slice(0, nestedSubtasksTag.start) + innerContent.slice(nestedSubtasksTag.end)
|
|
746
|
+
: innerContent;
|
|
747
|
+
const detailRefMatch = detailRefSearchContent.match(/<detail_ref>([^<]+)<\/detail_ref>/);
|
|
748
|
+
if (detailRefMatch) {
|
|
749
|
+
subtask.detail_ref = detailRefMatch[1].trim();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Recursively parse nested subtasks
|
|
753
|
+
if (nestedSubtasksTag) {
|
|
754
|
+
const children = parseSubtasks(nestedSubtasksTag.inner);
|
|
755
|
+
if (children.length > 0) {
|
|
756
|
+
subtask.children = children;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
results.push(subtask);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return results;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Parse task details from plan.xml
|
|
769
|
+
*/
|
|
770
|
+
export function parseTaskDetails(planPath: string): PhaseDetail[] {
|
|
771
|
+
try {
|
|
772
|
+
const content = fs.readFileSync(planPath, 'utf-8');
|
|
773
|
+
const phases: PhaseDetail[] = [];
|
|
774
|
+
|
|
775
|
+
// Match all phase elements
|
|
776
|
+
const phaseRegex = /<phase\s+id="([^"]+)"\s+name="([^"]+)"(?:\s+milestone="([^"]+)")?[^>]*>([\s\S]*?)<\/phase>/g;
|
|
777
|
+
let phaseMatch;
|
|
778
|
+
|
|
779
|
+
while ((phaseMatch = phaseRegex.exec(content)) !== null) {
|
|
780
|
+
const [, phaseId, phaseName, milestone, phaseContent] = phaseMatch;
|
|
781
|
+
|
|
782
|
+
// Extract phase goal
|
|
783
|
+
const goalMatch = phaseContent.match(/<goal>([\s\S]*?)<\/goal>/);
|
|
784
|
+
const goal = goalMatch ? goalMatch[1].trim() : '';
|
|
785
|
+
|
|
786
|
+
// Extract estimated_days
|
|
787
|
+
const estDaysMatch = phaseContent.match(/<estimated_days>(\d+)<\/estimated_days>/);
|
|
788
|
+
const estimated_days = estDaysMatch ? parseInt(estDaysMatch[1], 10) : undefined;
|
|
789
|
+
|
|
790
|
+
// Extract gate criteria
|
|
791
|
+
const gateMatch = phaseContent.match(/<gate_criteria>([\s\S]*?)<\/gate_criteria>/);
|
|
792
|
+
let gate_criteria: string[] | undefined;
|
|
793
|
+
if (gateMatch) {
|
|
794
|
+
const criterionMatches = gateMatch[1].matchAll(/<criterion>([^<]+)<\/criterion>/g);
|
|
795
|
+
gate_criteria = [...criterionMatches].map(m => m[1].trim());
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Extract waves DAG
|
|
799
|
+
const wavesMatch = phaseContent.match(/<waves>([\s\S]*?)<\/waves>/);
|
|
800
|
+
let waves: WaveInfo[] | undefined;
|
|
801
|
+
if (wavesMatch) {
|
|
802
|
+
const waveMatches = wavesMatch[1].matchAll(/<wave\s+id="([^"]+)"(?:\s+depends_on="([^"]*)")?[^/]*\/>/g);
|
|
803
|
+
waves = [...waveMatches].map(m => ({
|
|
804
|
+
id: m[1],
|
|
805
|
+
depends_on: m[2] ? m[2].split(',').map(d => d.trim()).filter(Boolean) : [],
|
|
806
|
+
}));
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Extract context_files
|
|
810
|
+
const ctxMatch = phaseContent.match(/<context_files>([\s\S]*?)<\/context_files>/);
|
|
811
|
+
let context_files: string[] | undefined;
|
|
812
|
+
if (ctxMatch) {
|
|
813
|
+
const fileMatches = ctxMatch[1].matchAll(/<file>([^<]+)<\/file>/g);
|
|
814
|
+
context_files = [...fileMatches].map(m => m[1].trim());
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Parse tasks
|
|
818
|
+
const tasks: TaskDetail[] = [];
|
|
819
|
+
const taskRegex = /<task\s+([^>]+)>([\s\S]*?)<\/task>/g;
|
|
820
|
+
let taskMatch;
|
|
821
|
+
|
|
822
|
+
while ((taskMatch = taskRegex.exec(phaseContent)) !== null) {
|
|
823
|
+
const [, taskAttrs, taskContent] = taskMatch;
|
|
824
|
+
|
|
825
|
+
// Extract task attributes
|
|
826
|
+
const taskId = taskAttrs.match(/id="([^"]+)"/)?.[1] ?? '';
|
|
827
|
+
const taskName = taskAttrs.match(/name="([^"]+)"/)?.[1] ?? '';
|
|
828
|
+
const taskStatus = taskAttrs.match(/status="([^"]+)"/)?.[1] ?? 'TODO';
|
|
829
|
+
const taskPriority = taskAttrs.match(/priority="([^"]+)"/)?.[1] ?? 'P2';
|
|
830
|
+
const taskWave = taskAttrs.match(/wave="([^"]+)"/)?.[1];
|
|
831
|
+
const commitAttr = taskAttrs.match(/commit="([^"]+)"/)?.[1];
|
|
832
|
+
const estDaysAttr = taskAttrs.match(/estimated_days="([^"]+)"/)?.[1];
|
|
833
|
+
|
|
834
|
+
// Extract description
|
|
835
|
+
// Preferred: <description>...</description>
|
|
836
|
+
// Backward-compatible: raw text content before first child element
|
|
837
|
+
const descriptionTagMatch = taskContent.match(/<description>([\s\S]*?)<\/description>/);
|
|
838
|
+
let description = '';
|
|
839
|
+
if (descriptionTagMatch) {
|
|
840
|
+
description = descriptionTagMatch[1].trim();
|
|
841
|
+
} else {
|
|
842
|
+
const descMatch = taskContent.match(/^\s*([^<]+)/);
|
|
843
|
+
description = descMatch ? descMatch[1].trim() : '';
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Extract acceptance criteria
|
|
847
|
+
const acMatch = taskContent.match(/<acceptance_criteria>([\s\S]*?)<\/acceptance_criteria>/);
|
|
848
|
+
let acceptance_criteria: { id: string; text: string; checked: boolean }[] | undefined;
|
|
849
|
+
if (acMatch) {
|
|
850
|
+
const criterionMatches = acMatch[1].matchAll(/<criterion\s+id="([^"]+)"\s+checked="([^"]+)">([^<]+)<\/criterion>/g);
|
|
851
|
+
acceptance_criteria = [...criterionMatches].map(m => ({
|
|
852
|
+
id: m[1],
|
|
853
|
+
text: m[3].trim(),
|
|
854
|
+
checked: m[2] === 'true',
|
|
855
|
+
}));
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Extract subtasks (use balanced matching for nested subtasks)
|
|
859
|
+
const subtasksStartIdx = taskContent.indexOf('<subtasks>');
|
|
860
|
+
let subtasks: SubtaskDetail[] | undefined;
|
|
861
|
+
if (subtasksStartIdx !== -1) {
|
|
862
|
+
const subtasksEndIdx = taskContent.lastIndexOf('</subtasks>');
|
|
863
|
+
if (subtasksEndIdx > subtasksStartIdx) {
|
|
864
|
+
const subtasksInner = taskContent.slice(subtasksStartIdx + '<subtasks>'.length, subtasksEndIdx);
|
|
865
|
+
subtasks = parseSubtasks(subtasksInner);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
tasks.push({
|
|
870
|
+
id: taskId,
|
|
871
|
+
name: taskName,
|
|
872
|
+
status: taskStatus as TaskDetail['status'],
|
|
873
|
+
priority: taskPriority as TaskDetail['priority'],
|
|
874
|
+
description,
|
|
875
|
+
estimated_days: estDaysAttr ? parseInt(estDaysAttr, 10) : undefined,
|
|
876
|
+
commit: commitAttr,
|
|
877
|
+
wave: taskWave,
|
|
878
|
+
acceptance_criteria,
|
|
879
|
+
subtasks,
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
phases.push({
|
|
884
|
+
id: phaseId,
|
|
885
|
+
name: phaseName,
|
|
886
|
+
goal,
|
|
887
|
+
milestone,
|
|
888
|
+
estimated_days,
|
|
889
|
+
tasks,
|
|
890
|
+
gate_criteria,
|
|
891
|
+
waves,
|
|
892
|
+
context_files,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return phases;
|
|
897
|
+
} catch (e) {
|
|
898
|
+
return [];
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Find in-progress task (for interruption recovery)
|
|
904
|
+
*/
|
|
905
|
+
export function findInProgressTask(planPath: string): TaskDetail | null {
|
|
906
|
+
const phases = parseTaskDetails(planPath);
|
|
907
|
+
for (const phase of phases) {
|
|
908
|
+
for (const task of phase.tasks) {
|
|
909
|
+
if (task.status === 'IN_PROGRESS') {
|
|
910
|
+
return task;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Get track commit mode from plan.xml
|
|
919
|
+
*/
|
|
920
|
+
export function getCommitMode(planPath: string): 'auto' | 'manual' | null {
|
|
921
|
+
try {
|
|
922
|
+
const content = fs.readFileSync(planPath, 'utf-8');
|
|
923
|
+
const modeMatch = content.match(/<commit_mode>(auto|manual)<\/commit_mode>/);
|
|
924
|
+
return modeMatch ? modeMatch[1] as 'auto' | 'manual' : null;
|
|
925
|
+
} catch (e) {
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Get execution mode from plan.xml (defaults to 'sequential')
|
|
932
|
+
*/
|
|
933
|
+
export function getExecutionMode(planPath: string): 'wave' | 'sequential' {
|
|
934
|
+
try {
|
|
935
|
+
const content = fs.readFileSync(planPath, 'utf-8');
|
|
936
|
+
const modeMatch = content.match(/<execution_mode>(wave|sequential)<\/execution_mode>/);
|
|
937
|
+
return modeMatch ? modeMatch[1] as 'wave' | 'sequential' : 'sequential';
|
|
938
|
+
} catch (e) {
|
|
939
|
+
return 'sequential';
|
|
940
|
+
}
|
|
941
|
+
}
|