wogiflow 1.0.11 → 1.0.13
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/.workflow/specs/architecture.md.template +24 -0
- package/.workflow/specs/stack.md.template +33 -0
- package/.workflow/specs/testing.md.template +36 -0
- package/README.md +90 -1
- package/lib/unified-wizard.js +569 -30
- package/package.json +1 -1
- package/scripts/MEMORY-ARCHITECTURE.md +150 -0
- package/scripts/flow +20 -19
- package/scripts/flow-auto-context.js +97 -3
- package/scripts/flow-conflict-resolver.js +735 -0
- package/scripts/flow-context-gatherer.js +520 -0
- package/scripts/flow-context-monitor.js +148 -19
- package/scripts/flow-damage-control.js +5 -1
- package/scripts/flow-export-profile +168 -1
- package/scripts/flow-import-profile +257 -6
- package/scripts/flow-instruction-richness.js +182 -18
- package/scripts/flow-knowledge-router.js +2 -0
- package/scripts/flow-knowledge-sync.js +2 -0
- package/scripts/{flow-transcript-chunking.js → flow-long-input-chunking.js} +4 -2
- package/scripts/{flow-transcript-parsing.js → flow-long-input-parsing.js} +35 -0
- package/scripts/{flow-transcript-stories.js → flow-long-input-stories.js} +86 -38
- package/scripts/{flow-transcript-digest.js → flow-long-input.js} +231 -15
- package/scripts/flow-memory-db.js +386 -1
- package/scripts/flow-memory-sync.js +2 -0
- package/scripts/flow-model-adapter.js +53 -29
- package/scripts/flow-model-router.js +246 -1
- package/scripts/flow-morning.js +94 -0
- package/scripts/flow-onboard +223 -10
- package/scripts/flow-orchestrate-validation.js +539 -0
- package/scripts/flow-orchestrate.js +16 -507
- package/scripts/flow-pattern-extractor.js +1265 -0
- package/scripts/flow-prompt-composer.js +222 -2
- package/scripts/flow-quality-guard.js +594 -0
- package/scripts/flow-section-index.js +713 -0
- package/scripts/flow-section-resolver.js +484 -0
- package/scripts/flow-session-end.js +188 -2
- package/scripts/flow-skill-create.js +19 -3
- package/scripts/flow-skill-matcher.js +122 -7
- package/scripts/flow-statusline-setup.js +218 -0
- package/scripts/flow-step-review.js +19 -0
- package/scripts/flow-tech-debt.js +734 -0
- package/scripts/flow-utils.js +2 -0
- package/scripts/hooks/core/long-input-gate.js +293 -0
- package/scripts/flow-parallel-detector.js +0 -399
- package/scripts/flow-parallel-dispatch.js +0 -987
- /package/scripts/{flow-transcript-language.js → flow-long-input-language.js} +0 -0
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow - Section Index Generator
|
|
5
|
+
*
|
|
6
|
+
* Creates a section-level index from decisions.md and app-map.md for
|
|
7
|
+
* targeted context loading. Enables "pin" lookups and section references.
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Parses decisions.md into indexed sections with semantic pins
|
|
11
|
+
* - Parses app-map.md tables into indexed rows
|
|
12
|
+
* - Auto-regenerates on file change (via watcher)
|
|
13
|
+
* - Supports content hashing for change detection
|
|
14
|
+
*
|
|
15
|
+
* Part of Smart Context System (Phase 1)
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* node scripts/flow-section-index.js # Generate index
|
|
19
|
+
* node scripts/flow-section-index.js --watch # Watch for changes
|
|
20
|
+
* node scripts/flow-section-index.js --json # Output JSON result
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const crypto = require('crypto');
|
|
26
|
+
const {
|
|
27
|
+
PATHS,
|
|
28
|
+
PROJECT_ROOT,
|
|
29
|
+
readFile,
|
|
30
|
+
writeFile,
|
|
31
|
+
fileExists,
|
|
32
|
+
dirExists,
|
|
33
|
+
success,
|
|
34
|
+
warn,
|
|
35
|
+
info,
|
|
36
|
+
error,
|
|
37
|
+
parseFlags,
|
|
38
|
+
outputJson,
|
|
39
|
+
safeJsonParse
|
|
40
|
+
} = require('./flow-utils');
|
|
41
|
+
|
|
42
|
+
// Re-use existing section parser from flow-rules-sync
|
|
43
|
+
const { parseMarkdownSections, slugify } = require('./flow-rules-sync');
|
|
44
|
+
|
|
45
|
+
// ============================================================
|
|
46
|
+
// Configuration
|
|
47
|
+
// ============================================================
|
|
48
|
+
|
|
49
|
+
const INDEX_PATH = path.join(PATHS.state, 'section-index.json');
|
|
50
|
+
const DEBOUNCE_MS = 500;
|
|
51
|
+
|
|
52
|
+
// Keywords that generate semantic pins for different rule types
|
|
53
|
+
const PIN_KEYWORDS = {
|
|
54
|
+
// Error handling
|
|
55
|
+
'try-catch': ['try', 'catch', 'error', 'exception', 'throw', 'safe'],
|
|
56
|
+
'error-handling': ['error', 'handle', 'exception', 'fail', 'catch'],
|
|
57
|
+
|
|
58
|
+
// File operations
|
|
59
|
+
'fs-read': ['fs', 'read', 'file', 'readFile', 'readFileSync'],
|
|
60
|
+
'fs-write': ['fs', 'write', 'file', 'writeFile', 'writeFileSync'],
|
|
61
|
+
'file-safety': ['file', 'path', 'fs', 'exists', 'check'],
|
|
62
|
+
|
|
63
|
+
// JSON operations
|
|
64
|
+
'json-parse': ['json', 'parse', 'JSON.parse', 'stringify'],
|
|
65
|
+
'json-safety': ['json', 'safe', 'parse', 'validate'],
|
|
66
|
+
|
|
67
|
+
// Security
|
|
68
|
+
'prototype-pollution': ['prototype', '__proto__', 'constructor', 'injection'],
|
|
69
|
+
'path-traversal': ['path', 'traversal', '..', 'join', 'resolve'],
|
|
70
|
+
'input-validation': ['validate', 'sanitize', 'input', 'user'],
|
|
71
|
+
|
|
72
|
+
// Components
|
|
73
|
+
'component-creation': ['component', 'create', 'new', 'add'],
|
|
74
|
+
'component-naming': ['component', 'name', 'naming', 'convention'],
|
|
75
|
+
'component-reuse': ['component', 'reuse', 'existing', 'variant'],
|
|
76
|
+
|
|
77
|
+
// Naming conventions
|
|
78
|
+
'naming-convention': ['naming', 'convention', 'case', 'kebab', 'camel'],
|
|
79
|
+
'file-naming': ['file', 'name', 'naming', 'kebab-case'],
|
|
80
|
+
|
|
81
|
+
// Architecture
|
|
82
|
+
'model-architecture': ['model', 'architecture', 'system', 'design'],
|
|
83
|
+
'api-pattern': ['api', 'endpoint', 'route', 'controller'],
|
|
84
|
+
|
|
85
|
+
// UI/UX
|
|
86
|
+
'variant-naming': ['variant', 'size', 'intent', 'state', 'primary', 'secondary']
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// ============================================================
|
|
90
|
+
// Pin Generation
|
|
91
|
+
// ============================================================
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Generate semantic pins for a section based on title and content
|
|
95
|
+
* @param {string} title - Section title
|
|
96
|
+
* @param {string} content - Section content
|
|
97
|
+
* @returns {string[]} - Array of pins
|
|
98
|
+
*/
|
|
99
|
+
function generatePins(title, content) {
|
|
100
|
+
const pins = new Set();
|
|
101
|
+
const combined = `${title} ${content}`.toLowerCase();
|
|
102
|
+
|
|
103
|
+
// Add pins based on keyword matches
|
|
104
|
+
for (const [pin, keywords] of Object.entries(PIN_KEYWORDS)) {
|
|
105
|
+
const matchCount = keywords.filter(kw => combined.includes(kw.toLowerCase())).length;
|
|
106
|
+
// Require at least 2 keyword matches or strong single match
|
|
107
|
+
if (matchCount >= 2 || (matchCount === 1 && combined.includes(pin.replace(/-/g, ' ')))) {
|
|
108
|
+
pins.add(pin);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Extract significant words from title as pins
|
|
113
|
+
const titleWords = title
|
|
114
|
+
.toLowerCase()
|
|
115
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
116
|
+
.split(/\s+/)
|
|
117
|
+
.filter(w => w.length > 2 && !['the', 'and', 'for', 'with', 'from'].includes(w));
|
|
118
|
+
|
|
119
|
+
titleWords.forEach(w => pins.add(w));
|
|
120
|
+
|
|
121
|
+
// Generate compound pins from title
|
|
122
|
+
const titleSlug = slugify(title);
|
|
123
|
+
pins.add(titleSlug);
|
|
124
|
+
|
|
125
|
+
return Array.from(pins);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Generate content hash for change detection
|
|
130
|
+
* @param {string} content - Content to hash
|
|
131
|
+
* @returns {string} - MD5 hash (first 8 chars)
|
|
132
|
+
*/
|
|
133
|
+
function hashContent(content) {
|
|
134
|
+
return crypto.createHash('md5').update(content).digest('hex').substring(0, 8);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ============================================================
|
|
138
|
+
// Decisions.md Parser
|
|
139
|
+
// ============================================================
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Parse decisions.md into indexed sections with hierarchical structure
|
|
143
|
+
* @param {string} content - File content
|
|
144
|
+
* @returns {Object[]} - Array of indexed sections
|
|
145
|
+
*/
|
|
146
|
+
function parseDecisionsSections(content) {
|
|
147
|
+
const sections = [];
|
|
148
|
+
const lines = content.split('\n');
|
|
149
|
+
|
|
150
|
+
let currentCategory = null;
|
|
151
|
+
let currentSection = null;
|
|
152
|
+
let currentContent = [];
|
|
153
|
+
let lineStart = 0;
|
|
154
|
+
|
|
155
|
+
for (let i = 0; i < lines.length; i++) {
|
|
156
|
+
const line = lines[i];
|
|
157
|
+
|
|
158
|
+
// Match ## headers (categories)
|
|
159
|
+
const categoryMatch = line.match(/^##\s+(.+)$/);
|
|
160
|
+
if (categoryMatch) {
|
|
161
|
+
// Save previous section
|
|
162
|
+
if (currentSection && currentContent.length > 0) {
|
|
163
|
+
const trimmedContent = currentContent.join('\n').trim();
|
|
164
|
+
if (trimmedContent && !trimmedContent.startsWith('<!--')) {
|
|
165
|
+
sections.push(createDecisionSection(
|
|
166
|
+
currentCategory,
|
|
167
|
+
currentSection,
|
|
168
|
+
trimmedContent,
|
|
169
|
+
lineStart,
|
|
170
|
+
i - 1
|
|
171
|
+
));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
currentCategory = categoryMatch[1].trim();
|
|
176
|
+
currentSection = null;
|
|
177
|
+
currentContent = [];
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Match ### headers (sections within category)
|
|
182
|
+
const sectionMatch = line.match(/^###\s+(.+)$/);
|
|
183
|
+
if (sectionMatch) {
|
|
184
|
+
// Save previous section
|
|
185
|
+
if (currentSection && currentContent.length > 0) {
|
|
186
|
+
const trimmedContent = currentContent.join('\n').trim();
|
|
187
|
+
if (trimmedContent && !trimmedContent.startsWith('<!--')) {
|
|
188
|
+
sections.push(createDecisionSection(
|
|
189
|
+
currentCategory,
|
|
190
|
+
currentSection,
|
|
191
|
+
trimmedContent,
|
|
192
|
+
lineStart,
|
|
193
|
+
i - 1
|
|
194
|
+
));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
currentSection = sectionMatch[1].trim();
|
|
199
|
+
currentContent = [];
|
|
200
|
+
lineStart = i + 1;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Accumulate content
|
|
205
|
+
if (currentSection && line.trim() !== '---') {
|
|
206
|
+
currentContent.push(line);
|
|
207
|
+
} else if (currentCategory && !currentSection && line.trim() && line.trim() !== '---') {
|
|
208
|
+
// Content directly under category (no subsection)
|
|
209
|
+
currentSection = currentCategory;
|
|
210
|
+
currentContent.push(line);
|
|
211
|
+
lineStart = i;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Save last section
|
|
216
|
+
if (currentSection && currentContent.length > 0) {
|
|
217
|
+
const trimmedContent = currentContent.join('\n').trim();
|
|
218
|
+
if (trimmedContent && !trimmedContent.startsWith('<!--')) {
|
|
219
|
+
sections.push(createDecisionSection(
|
|
220
|
+
currentCategory,
|
|
221
|
+
currentSection,
|
|
222
|
+
trimmedContent,
|
|
223
|
+
lineStart,
|
|
224
|
+
lines.length - 1
|
|
225
|
+
));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return sections;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Create a decision section object
|
|
234
|
+
*/
|
|
235
|
+
function createDecisionSection(category, title, content, lineStart, lineEnd) {
|
|
236
|
+
const categorySlug = category ? slugify(category) : 'general';
|
|
237
|
+
const titleSlug = slugify(title);
|
|
238
|
+
const id = `${categorySlug}:${titleSlug}`;
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
id,
|
|
242
|
+
title,
|
|
243
|
+
category: category || 'General',
|
|
244
|
+
pins: generatePins(title, content),
|
|
245
|
+
lineStart: lineStart + 1, // 1-indexed
|
|
246
|
+
lineEnd: lineEnd + 1,
|
|
247
|
+
content,
|
|
248
|
+
contentHash: hashContent(content)
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ============================================================
|
|
253
|
+
// App-Map.md Parser
|
|
254
|
+
// ============================================================
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Parse app-map.md tables into indexed rows
|
|
258
|
+
* @param {string} content - File content
|
|
259
|
+
* @returns {Object[]} - Array of indexed rows
|
|
260
|
+
*/
|
|
261
|
+
function parseAppMapRows(content) {
|
|
262
|
+
const rows = [];
|
|
263
|
+
const lines = content.split('\n');
|
|
264
|
+
|
|
265
|
+
let currentCategory = null;
|
|
266
|
+
let tableHeaders = null;
|
|
267
|
+
let inTable = false;
|
|
268
|
+
|
|
269
|
+
for (let i = 0; i < lines.length; i++) {
|
|
270
|
+
const line = lines[i];
|
|
271
|
+
|
|
272
|
+
// Match ## headers (categories: Screens, Modals, Components)
|
|
273
|
+
const categoryMatch = line.match(/^##\s+(.+)$/);
|
|
274
|
+
if (categoryMatch) {
|
|
275
|
+
currentCategory = categoryMatch[1].trim();
|
|
276
|
+
tableHeaders = null;
|
|
277
|
+
inTable = false;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Match table header row
|
|
282
|
+
if (line.startsWith('|') && line.includes('|') && !tableHeaders) {
|
|
283
|
+
tableHeaders = parseTableRow(line);
|
|
284
|
+
inTable = true;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Skip separator row
|
|
289
|
+
if (line.match(/^\|[-\s|]+\|$/)) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Parse table data row
|
|
294
|
+
if (inTable && line.startsWith('|') && tableHeaders) {
|
|
295
|
+
const cells = parseTableRow(line);
|
|
296
|
+
if (cells.length > 0 && !cells[0].startsWith('_')) { // Skip example rows
|
|
297
|
+
const row = createAppMapRow(currentCategory, tableHeaders, cells, i + 1);
|
|
298
|
+
if (row) {
|
|
299
|
+
rows.push(row);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// End of table
|
|
305
|
+
if (inTable && !line.startsWith('|') && line.trim() !== '') {
|
|
306
|
+
inTable = false;
|
|
307
|
+
tableHeaders = null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return rows;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Parse a table row into cells
|
|
316
|
+
*/
|
|
317
|
+
function parseTableRow(line) {
|
|
318
|
+
return line
|
|
319
|
+
.split('|')
|
|
320
|
+
.map(cell => cell.trim())
|
|
321
|
+
.filter(cell => cell.length > 0);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Create an app-map row object
|
|
326
|
+
*/
|
|
327
|
+
function createAppMapRow(category, headers, cells, lineNumber) {
|
|
328
|
+
if (!category || cells.length < 2) return null;
|
|
329
|
+
|
|
330
|
+
const categorySlug = slugify(category);
|
|
331
|
+
const name = cells[0].replace(/[`*_]/g, ''); // Remove markdown formatting
|
|
332
|
+
const nameSlug = slugify(name);
|
|
333
|
+
const id = `${categorySlug}:${nameSlug}`;
|
|
334
|
+
|
|
335
|
+
// Build data object from headers
|
|
336
|
+
const data = {};
|
|
337
|
+
headers.forEach((header, idx) => {
|
|
338
|
+
if (cells[idx]) {
|
|
339
|
+
data[header.toLowerCase()] = cells[idx].replace(/[`*_]/g, '');
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Generate pins
|
|
344
|
+
const pins = new Set([nameSlug, name.toLowerCase()]);
|
|
345
|
+
|
|
346
|
+
// Add category-based pins
|
|
347
|
+
if (category.toLowerCase().includes('screen')) {
|
|
348
|
+
pins.add('screen');
|
|
349
|
+
pins.add('page');
|
|
350
|
+
pins.add('route');
|
|
351
|
+
} else if (category.toLowerCase().includes('modal')) {
|
|
352
|
+
pins.add('modal');
|
|
353
|
+
pins.add('dialog');
|
|
354
|
+
pins.add('popup');
|
|
355
|
+
} else if (category.toLowerCase().includes('component')) {
|
|
356
|
+
pins.add('component');
|
|
357
|
+
pins.add('ui');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Add variant pins if present
|
|
361
|
+
if (data.variants) {
|
|
362
|
+
data.variants.split(',').map(v => v.trim()).forEach(v => pins.add(v.toLowerCase()));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
id,
|
|
367
|
+
name,
|
|
368
|
+
category,
|
|
369
|
+
pins: Array.from(pins),
|
|
370
|
+
line: lineNumber,
|
|
371
|
+
path: data.path || null,
|
|
372
|
+
status: data.status || null,
|
|
373
|
+
variants: data.variants ? data.variants.split(',').map(v => v.trim()) : [],
|
|
374
|
+
data
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ============================================================
|
|
379
|
+
// Index Generation
|
|
380
|
+
// ============================================================
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Generate the full section index
|
|
384
|
+
* @returns {Object} - Section index object
|
|
385
|
+
*/
|
|
386
|
+
function generateIndex() {
|
|
387
|
+
const index = {
|
|
388
|
+
version: '1.0',
|
|
389
|
+
generatedAt: new Date().toISOString(),
|
|
390
|
+
sources: {}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// Parse decisions.md
|
|
394
|
+
if (fileExists(PATHS.decisions)) {
|
|
395
|
+
try {
|
|
396
|
+
const decisionsContent = readFile(PATHS.decisions);
|
|
397
|
+
const sections = parseDecisionsSections(decisionsContent);
|
|
398
|
+
index.sources['decisions.md'] = {
|
|
399
|
+
path: PATHS.decisions,
|
|
400
|
+
lastModified: fs.statSync(PATHS.decisions).mtime.toISOString(),
|
|
401
|
+
contentHash: hashContent(decisionsContent),
|
|
402
|
+
sections
|
|
403
|
+
};
|
|
404
|
+
} catch (err) {
|
|
405
|
+
warn(`Error parsing decisions.md: ${err.message}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Parse app-map.md
|
|
410
|
+
if (fileExists(PATHS.appMap)) {
|
|
411
|
+
try {
|
|
412
|
+
const appMapContent = readFile(PATHS.appMap);
|
|
413
|
+
const rows = parseAppMapRows(appMapContent);
|
|
414
|
+
index.sources['app-map.md'] = {
|
|
415
|
+
path: PATHS.appMap,
|
|
416
|
+
lastModified: fs.statSync(PATHS.appMap).mtime.toISOString(),
|
|
417
|
+
contentHash: hashContent(appMapContent),
|
|
418
|
+
rows
|
|
419
|
+
};
|
|
420
|
+
} catch (err) {
|
|
421
|
+
warn(`Error parsing app-map.md: ${err.message}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Calculate stats
|
|
426
|
+
const decisionsSections = index.sources['decisions.md']?.sections?.length || 0;
|
|
427
|
+
const appMapRows = index.sources['app-map.md']?.rows?.length || 0;
|
|
428
|
+
|
|
429
|
+
index.stats = {
|
|
430
|
+
totalSections: decisionsSections,
|
|
431
|
+
totalRows: appMapRows,
|
|
432
|
+
totalPins: countUniquePins(index)
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
return index;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Count unique pins across all sources
|
|
440
|
+
*/
|
|
441
|
+
function countUniquePins(index) {
|
|
442
|
+
const pins = new Set();
|
|
443
|
+
|
|
444
|
+
for (const source of Object.values(index.sources)) {
|
|
445
|
+
const items = source.sections || source.rows || [];
|
|
446
|
+
for (const item of items) {
|
|
447
|
+
item.pins?.forEach(p => pins.add(p));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return pins.size;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Write index to file
|
|
456
|
+
*/
|
|
457
|
+
function writeIndex(index) {
|
|
458
|
+
if (!dirExists(PATHS.state)) {
|
|
459
|
+
fs.mkdirSync(PATHS.state, { recursive: true });
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
writeFile(INDEX_PATH, JSON.stringify(index, null, 2));
|
|
463
|
+
return INDEX_PATH;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Read existing index
|
|
468
|
+
* Uses safeJsonParse for prototype pollution protection
|
|
469
|
+
*/
|
|
470
|
+
function readIndex() {
|
|
471
|
+
if (!fileExists(INDEX_PATH)) {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Use safeJsonParse for security (prototype pollution protection)
|
|
476
|
+
return safeJsonParse(INDEX_PATH, null);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Check if index needs regeneration
|
|
481
|
+
*/
|
|
482
|
+
function needsRegeneration() {
|
|
483
|
+
const existingIndex = readIndex();
|
|
484
|
+
if (!existingIndex) return true;
|
|
485
|
+
|
|
486
|
+
// Check decisions.md
|
|
487
|
+
if (fileExists(PATHS.decisions)) {
|
|
488
|
+
const currentHash = hashContent(readFile(PATHS.decisions));
|
|
489
|
+
const indexedHash = existingIndex.sources['decisions.md']?.contentHash;
|
|
490
|
+
if (currentHash !== indexedHash) return true;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Check app-map.md
|
|
494
|
+
if (fileExists(PATHS.appMap)) {
|
|
495
|
+
const currentHash = hashContent(readFile(PATHS.appMap));
|
|
496
|
+
const indexedHash = existingIndex.sources['app-map.md']?.contentHash;
|
|
497
|
+
if (currentHash !== indexedHash) return true;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ============================================================
|
|
504
|
+
// File Watcher
|
|
505
|
+
// ============================================================
|
|
506
|
+
|
|
507
|
+
let debounceTimer = null;
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Start watching source files for changes
|
|
511
|
+
*/
|
|
512
|
+
function startWatcher() {
|
|
513
|
+
const filesToWatch = [PATHS.decisions, PATHS.appMap].filter(f => fileExists(f));
|
|
514
|
+
|
|
515
|
+
if (filesToWatch.length === 0) {
|
|
516
|
+
warn('No source files found to watch');
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
info(`Watching ${filesToWatch.length} files for changes...`);
|
|
521
|
+
|
|
522
|
+
for (const filePath of filesToWatch) {
|
|
523
|
+
fs.watch(filePath, (eventType) => {
|
|
524
|
+
if (eventType === 'change') {
|
|
525
|
+
// Debounce rapid changes
|
|
526
|
+
if (debounceTimer) {
|
|
527
|
+
clearTimeout(debounceTimer);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
debounceTimer = setTimeout(() => {
|
|
531
|
+
info(`[${new Date().toISOString()}] Change detected, regenerating index...`);
|
|
532
|
+
const index = generateIndex();
|
|
533
|
+
writeIndex(index);
|
|
534
|
+
success(`Section index regenerated (${index.stats.totalSections} sections, ${index.stats.totalRows} rows)`);
|
|
535
|
+
}, DEBOUNCE_MS);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
info('Press Ctrl+C to stop watching');
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ============================================================
|
|
544
|
+
// Public API
|
|
545
|
+
// ============================================================
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Generate and write section index
|
|
549
|
+
* @param {Object} options - { force: boolean }
|
|
550
|
+
* @returns {Object} - { success, indexPath, stats }
|
|
551
|
+
*/
|
|
552
|
+
function generateSectionIndex(options = {}) {
|
|
553
|
+
const { force = false } = options;
|
|
554
|
+
|
|
555
|
+
// Check if regeneration is needed
|
|
556
|
+
if (!force && !needsRegeneration()) {
|
|
557
|
+
const existingIndex = readIndex();
|
|
558
|
+
return {
|
|
559
|
+
success: true,
|
|
560
|
+
skipped: true,
|
|
561
|
+
indexPath: INDEX_PATH,
|
|
562
|
+
stats: existingIndex.stats
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const index = generateIndex();
|
|
567
|
+
const indexPath = writeIndex(index);
|
|
568
|
+
|
|
569
|
+
return {
|
|
570
|
+
success: true,
|
|
571
|
+
skipped: false,
|
|
572
|
+
indexPath,
|
|
573
|
+
stats: index.stats
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Get all sections matching pins
|
|
579
|
+
* @param {string[]} pins - Pins to match
|
|
580
|
+
* @returns {Object[]} - Matching sections
|
|
581
|
+
*/
|
|
582
|
+
function getSectionsByPins(pins) {
|
|
583
|
+
const index = readIndex();
|
|
584
|
+
if (!index) return [];
|
|
585
|
+
|
|
586
|
+
const results = [];
|
|
587
|
+
const pinsLower = pins.map(p => p.toLowerCase());
|
|
588
|
+
|
|
589
|
+
for (const source of Object.values(index.sources)) {
|
|
590
|
+
const items = source.sections || source.rows || [];
|
|
591
|
+
for (const item of items) {
|
|
592
|
+
const matchCount = item.pins?.filter(p => pinsLower.includes(p.toLowerCase())).length || 0;
|
|
593
|
+
if (matchCount > 0) {
|
|
594
|
+
results.push({
|
|
595
|
+
...item,
|
|
596
|
+
source: source.path,
|
|
597
|
+
matchCount,
|
|
598
|
+
matchScore: matchCount / pinsLower.length
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Sort by match score
|
|
605
|
+
return results.sort((a, b) => b.matchScore - a.matchScore);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Get section by ID
|
|
610
|
+
* @param {string} sectionId - Section ID (e.g., "security:file-read-safety")
|
|
611
|
+
* @returns {Object|null} - Section object or null
|
|
612
|
+
*/
|
|
613
|
+
function getSectionById(sectionId) {
|
|
614
|
+
const index = readIndex();
|
|
615
|
+
if (!index) return null;
|
|
616
|
+
|
|
617
|
+
for (const source of Object.values(index.sources)) {
|
|
618
|
+
const items = source.sections || source.rows || [];
|
|
619
|
+
const found = items.find(item => item.id === sectionId);
|
|
620
|
+
if (found) {
|
|
621
|
+
return { ...found, source: source.path };
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ============================================================
|
|
629
|
+
// Main
|
|
630
|
+
// ============================================================
|
|
631
|
+
|
|
632
|
+
function main() {
|
|
633
|
+
const { flags } = parseFlags(process.argv.slice(2));
|
|
634
|
+
|
|
635
|
+
if (flags.help) {
|
|
636
|
+
console.log(`
|
|
637
|
+
Usage: node scripts/flow-section-index.js [options]
|
|
638
|
+
|
|
639
|
+
Generate section-level index from decisions.md and app-map.md.
|
|
640
|
+
|
|
641
|
+
Options:
|
|
642
|
+
--watch Watch files for changes and auto-regenerate
|
|
643
|
+
--force Force regeneration even if no changes detected
|
|
644
|
+
--json Output result as JSON
|
|
645
|
+
--help Show this help message
|
|
646
|
+
|
|
647
|
+
Examples:
|
|
648
|
+
node scripts/flow-section-index.js # Generate index
|
|
649
|
+
node scripts/flow-section-index.js --watch # Watch for changes
|
|
650
|
+
node scripts/flow-section-index.js --force # Force regeneration
|
|
651
|
+
`);
|
|
652
|
+
process.exit(0);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Watch mode
|
|
656
|
+
if (flags.watch) {
|
|
657
|
+
// Generate initial index
|
|
658
|
+
const result = generateSectionIndex({ force: true });
|
|
659
|
+
if (result.success) {
|
|
660
|
+
success(`Initial index generated: ${result.stats.totalSections} sections, ${result.stats.totalRows} rows`);
|
|
661
|
+
}
|
|
662
|
+
startWatcher();
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Generate index
|
|
667
|
+
const result = generateSectionIndex({ force: flags.force });
|
|
668
|
+
|
|
669
|
+
if (flags.json) {
|
|
670
|
+
outputJson(result);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (result.skipped) {
|
|
675
|
+
info('Index is up to date (no changes detected)');
|
|
676
|
+
info(` Sections: ${result.stats.totalSections}`);
|
|
677
|
+
info(` Rows: ${result.stats.totalRows}`);
|
|
678
|
+
info(` Unique pins: ${result.stats.totalPins}`);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (result.success) {
|
|
683
|
+
success('Section index generated');
|
|
684
|
+
info(` Path: ${result.indexPath}`);
|
|
685
|
+
info(` Sections: ${result.stats.totalSections}`);
|
|
686
|
+
info(` Rows: ${result.stats.totalRows}`);
|
|
687
|
+
info(` Unique pins: ${result.stats.totalPins}`);
|
|
688
|
+
} else {
|
|
689
|
+
error('Failed to generate section index');
|
|
690
|
+
process.exit(1);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ============================================================
|
|
695
|
+
// Exports
|
|
696
|
+
// ============================================================
|
|
697
|
+
|
|
698
|
+
module.exports = {
|
|
699
|
+
generateSectionIndex,
|
|
700
|
+
getSectionsByPins,
|
|
701
|
+
getSectionById,
|
|
702
|
+
readIndex,
|
|
703
|
+
needsRegeneration,
|
|
704
|
+
generatePins,
|
|
705
|
+
parseDecisionsSections,
|
|
706
|
+
parseAppMapRows,
|
|
707
|
+
INDEX_PATH
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
// Run if called directly
|
|
711
|
+
if (require.main === module) {
|
|
712
|
+
main();
|
|
713
|
+
}
|