deepflow 0.1.97 → 0.1.98

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.
@@ -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();