deepflow 0.1.97 → 0.1.99
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/bin/plan-consolidator.js +330 -0
- package/bin/plan-consolidator.test.js +882 -0
- package/bin/wave-runner.js +74 -8
- package/bin/wave-runner.test.js +529 -2
- package/hooks/df-subagent-registry.js +8 -0
- package/hooks/df-subagent-registry.test.js +110 -3
- package/package.json +1 -1
- package/src/commands/df/execute.md +38 -7
- package/src/commands/df/plan.md +112 -114
- package/src/commands/df/verify.md +1 -1
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* deepflow plan-consolidator
|
|
4
|
+
* Reads mini-plan files from a directory, renumbers T-ids globally, detects
|
|
5
|
+
* cross-spec file conflicts, and outputs a consolidated tasks section to stdout.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* node bin/plan-consolidator.js --plans-dir .deepflow/plans/
|
|
9
|
+
*
|
|
10
|
+
* Output: consolidated tasks markdown (tasks section only) to stdout
|
|
11
|
+
* Input mini-plan files are NEVER modified.
|
|
12
|
+
*
|
|
13
|
+
* Exit codes: 0=success, 1=error
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// CLI arg parsing
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv) {
|
|
26
|
+
const args = { plansDir: null };
|
|
27
|
+
let i = 2;
|
|
28
|
+
while (i < argv.length) {
|
|
29
|
+
const arg = argv[i];
|
|
30
|
+
if (arg === '--plans-dir' && argv[i + 1]) {
|
|
31
|
+
args.plansDir = argv[++i];
|
|
32
|
+
}
|
|
33
|
+
i++;
|
|
34
|
+
}
|
|
35
|
+
return args;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Mini-plan parser
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse tasks from a single mini-plan file.
|
|
44
|
+
* Returns array of { localId, num, description, tags, blockedBy, files, rawLines }
|
|
45
|
+
*
|
|
46
|
+
* Recognises task header lines like:
|
|
47
|
+
* - [ ] **T1**: description
|
|
48
|
+
* - [ ] **T1** [tag]: description
|
|
49
|
+
*
|
|
50
|
+
* And annotation lines immediately following (indented):
|
|
51
|
+
* - Files: file1, file2
|
|
52
|
+
* - Blocked by: T1, T2
|
|
53
|
+
*/
|
|
54
|
+
function parseMiniPlan(text) {
|
|
55
|
+
const lines = text.split('\n');
|
|
56
|
+
const tasks = [];
|
|
57
|
+
let current = null;
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < lines.length; i++) {
|
|
60
|
+
const line = lines[i];
|
|
61
|
+
|
|
62
|
+
// Match pending task header: - [ ] **T{N}**...
|
|
63
|
+
const taskMatch = line.match(/^\s*-\s+\[\s+\]\s+\*\*T(\d+)\*\*(\s+\[[^\]]*\])?[:\s]*(.*)/);
|
|
64
|
+
if (taskMatch) {
|
|
65
|
+
current = {
|
|
66
|
+
localId: `T${taskMatch[1]}`,
|
|
67
|
+
num: parseInt(taskMatch[1], 10),
|
|
68
|
+
description: taskMatch[3].trim(),
|
|
69
|
+
tags: taskMatch[2] ? taskMatch[2].trim() : '',
|
|
70
|
+
blockedBy: [], // local T-ids
|
|
71
|
+
files: [],
|
|
72
|
+
rawLine: line, // original header line (for reference)
|
|
73
|
+
};
|
|
74
|
+
tasks.push(current);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Completed task — reset current so annotations don't bleed
|
|
79
|
+
const doneMatch = line.match(/^\s*-\s+\[x\]\s+/i);
|
|
80
|
+
if (doneMatch) {
|
|
81
|
+
current = null;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (current) {
|
|
86
|
+
// Match "Blocked by:" annotation
|
|
87
|
+
const blockedMatch = line.match(/^\s+-\s+Blocked\s+by:\s+(.+)/i);
|
|
88
|
+
if (blockedMatch) {
|
|
89
|
+
const deps = blockedMatch[1]
|
|
90
|
+
.split(/[,\s]+/)
|
|
91
|
+
.map(s => s.trim())
|
|
92
|
+
.filter(s => /^T\d+$/.test(s));
|
|
93
|
+
current.blockedBy.push(...deps);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Match "Files:" annotation
|
|
98
|
+
const filesMatch = line.match(/^\s+-\s+Files?:\s+(.+)/i);
|
|
99
|
+
if (filesMatch) {
|
|
100
|
+
const fileList = filesMatch[1]
|
|
101
|
+
.split(/,\s*/)
|
|
102
|
+
.map(s => s.trim())
|
|
103
|
+
.filter(Boolean);
|
|
104
|
+
current.files.push(...fileList);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return tasks;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Cross-spec file-conflict detection
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Given a list of spec entries { specName, tasks }, build a map of
|
|
119
|
+
* filename → [specName, ...] for files touched by more than one spec.
|
|
120
|
+
*
|
|
121
|
+
* Returns Map<filename, string[]>
|
|
122
|
+
*/
|
|
123
|
+
function detectFileConflicts(specEntries) {
|
|
124
|
+
// Map: filename → set of spec names that touch it
|
|
125
|
+
const fileToSpecs = new Map();
|
|
126
|
+
|
|
127
|
+
for (const { specName, tasks } of specEntries) {
|
|
128
|
+
for (const task of tasks) {
|
|
129
|
+
for (const file of task.files) {
|
|
130
|
+
if (!fileToSpecs.has(file)) fileToSpecs.set(file, new Set());
|
|
131
|
+
fileToSpecs.get(file).add(specName);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Keep only files touched by 2+ specs
|
|
137
|
+
const conflicts = new Map();
|
|
138
|
+
for (const [file, specs] of fileToSpecs) {
|
|
139
|
+
if (specs.size > 1) {
|
|
140
|
+
conflicts.set(file, [...specs]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return conflicts;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Consolidation
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Consolidate all spec entries into a globally renumbered task list.
|
|
153
|
+
*
|
|
154
|
+
* Rules:
|
|
155
|
+
* - T-ids are renumbered globally in spec-file order, then task-num order.
|
|
156
|
+
* - "Blocked by" references within a spec are remapped to global ids
|
|
157
|
+
* (chain-only: no cross-spec blocking is added).
|
|
158
|
+
* - Cross-spec file conflicts get [file-conflict: {filename}] annotations
|
|
159
|
+
* on the tasks that touch conflicted files.
|
|
160
|
+
*
|
|
161
|
+
* Returns array of consolidated task objects:
|
|
162
|
+
* { globalId, specName, description, tags, blockedBy (global), files, conflictAnnotations }
|
|
163
|
+
*/
|
|
164
|
+
function consolidate(specEntries, fileConflicts) {
|
|
165
|
+
let globalCounter = 0;
|
|
166
|
+
const consolidated = [];
|
|
167
|
+
|
|
168
|
+
for (const { specName, tasks } of specEntries) {
|
|
169
|
+
// Build local→global id map for this spec
|
|
170
|
+
const localToGlobal = new Map();
|
|
171
|
+
for (const task of tasks) {
|
|
172
|
+
globalCounter++;
|
|
173
|
+
localToGlobal.set(task.localId, `T${globalCounter}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const task of tasks) {
|
|
177
|
+
const globalId = localToGlobal.get(task.localId);
|
|
178
|
+
|
|
179
|
+
// Remap blocked-by to global ids (chain-only: only remap refs that exist
|
|
180
|
+
// in this spec's local map; cross-spec refs are silently dropped)
|
|
181
|
+
const globalBlockedBy = task.blockedBy
|
|
182
|
+
.filter(dep => localToGlobal.has(dep))
|
|
183
|
+
.map(dep => localToGlobal.get(dep));
|
|
184
|
+
|
|
185
|
+
// Detect which of this task's files are in conflict
|
|
186
|
+
const conflictAnnotations = task.files
|
|
187
|
+
.filter(f => fileConflicts.has(f))
|
|
188
|
+
.map(f => `[file-conflict: ${f}]`);
|
|
189
|
+
|
|
190
|
+
consolidated.push({
|
|
191
|
+
globalId,
|
|
192
|
+
specName,
|
|
193
|
+
description: task.description,
|
|
194
|
+
tags: task.tags,
|
|
195
|
+
blockedBy: globalBlockedBy,
|
|
196
|
+
files: task.files,
|
|
197
|
+
conflictAnnotations,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return consolidated;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Output formatter
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Render consolidated tasks as PLAN.md-compatible markdown.
|
|
211
|
+
* Groups tasks under ### {specName} headings.
|
|
212
|
+
* Compatible with wave-runner's parsePlan regex (see wave-runner.js parsePlan).
|
|
213
|
+
*/
|
|
214
|
+
function formatConsolidated(consolidated) {
|
|
215
|
+
if (consolidated.length === 0) {
|
|
216
|
+
return '## Tasks\n\n(no tasks found)\n';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const lines = ['## Tasks\n'];
|
|
220
|
+
let lastSpec = null;
|
|
221
|
+
|
|
222
|
+
for (const task of consolidated) {
|
|
223
|
+
if (task.specName !== lastSpec) {
|
|
224
|
+
lines.push(`### ${task.specName}\n`);
|
|
225
|
+
lastSpec = task.specName;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Task header line
|
|
229
|
+
const tagPart = task.tags ? ` ${task.tags}` : '';
|
|
230
|
+
// Append conflict annotations to description if any
|
|
231
|
+
const conflictPart = task.conflictAnnotations.length > 0
|
|
232
|
+
? ' ' + task.conflictAnnotations.join(' ')
|
|
233
|
+
: '';
|
|
234
|
+
const descPart = (task.description + conflictPart).trim();
|
|
235
|
+
const headerDesc = descPart ? `: ${descPart}` : '';
|
|
236
|
+
lines.push(`- [ ] **${task.globalId}**${tagPart}${headerDesc}`);
|
|
237
|
+
|
|
238
|
+
// Files annotation
|
|
239
|
+
if (task.files.length > 0) {
|
|
240
|
+
lines.push(` - Files: ${task.files.join(', ')}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Blocked by annotation
|
|
244
|
+
if (task.blockedBy.length > 0) {
|
|
245
|
+
lines.push(` - Blocked by: ${task.blockedBy.join(', ')}`);
|
|
246
|
+
} else {
|
|
247
|
+
lines.push(' - Blocked by: none');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
lines.push('');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return lines.join('\n');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Main
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
function main() {
|
|
261
|
+
const args = parseArgs(process.argv);
|
|
262
|
+
|
|
263
|
+
if (!args.plansDir) {
|
|
264
|
+
process.stderr.write('plan-consolidator: --plans-dir <path> is required\n');
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const plansDir = path.resolve(process.cwd(), args.plansDir);
|
|
269
|
+
|
|
270
|
+
if (!fs.existsSync(plansDir)) {
|
|
271
|
+
process.stderr.write(`plan-consolidator: plans directory not found: ${plansDir}\n`);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Collect mini-plan files: doing-{name}.md, sorted alphabetically for determinism
|
|
276
|
+
let entries;
|
|
277
|
+
try {
|
|
278
|
+
entries = fs.readdirSync(plansDir)
|
|
279
|
+
.filter(f => f.startsWith('doing-') && f.endsWith('.md'))
|
|
280
|
+
.sort();
|
|
281
|
+
} catch (err) {
|
|
282
|
+
process.stderr.write(`plan-consolidator: failed to read plans dir: ${err.message}\n`);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (entries.length === 0) {
|
|
287
|
+
process.stdout.write('## Tasks\n\n(no mini-plan files found in ' + plansDir + ')\n');
|
|
288
|
+
process.exit(0);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Parse each mini-plan (read-only — files are never modified)
|
|
292
|
+
const specEntries = [];
|
|
293
|
+
for (const filename of entries) {
|
|
294
|
+
const filePath = path.join(plansDir, filename);
|
|
295
|
+
let text;
|
|
296
|
+
try {
|
|
297
|
+
text = fs.readFileSync(filePath, 'utf8');
|
|
298
|
+
} catch (err) {
|
|
299
|
+
process.stderr.write(`plan-consolidator: failed to read ${filePath}: ${err.message}\n`);
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Derive spec name from filename: doing-{name}.md → {name}
|
|
304
|
+
const specName = filename.replace(/^doing-/, '').replace(/\.md$/, '');
|
|
305
|
+
const tasks = parseMiniPlan(text);
|
|
306
|
+
specEntries.push({ specName, tasks, filePath });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Detect cross-spec file conflicts (read phase complete — no more file I/O on inputs)
|
|
310
|
+
const fileConflicts = detectFileConflicts(specEntries);
|
|
311
|
+
|
|
312
|
+
if (fileConflicts.size > 0) {
|
|
313
|
+
process.stderr.write(
|
|
314
|
+
`plan-consolidator: ${fileConflicts.size} file conflict(s) detected:\n`
|
|
315
|
+
);
|
|
316
|
+
for (const [file, specs] of fileConflicts) {
|
|
317
|
+
process.stderr.write(` ${file}: ${specs.join(', ')}\n`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Consolidate: renumber T-ids globally, remap blocking, annotate conflicts
|
|
322
|
+
const consolidated = consolidate(specEntries, fileConflicts);
|
|
323
|
+
|
|
324
|
+
// Render and emit to stdout
|
|
325
|
+
const output = formatConsolidated(consolidated);
|
|
326
|
+
process.stdout.write(output);
|
|
327
|
+
process.exit(0);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
main();
|