code-as-plan 2.0.3 → 2.0.6
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/cap/bin/lib/cap-feature-map.cjs +88 -19
- package/cap/bin/lib/cap-migrate.cjs +22 -1
- package/cap/bin/lib/cap-session.cjs +56 -0
- package/cap/bin/lib/cap-stack-docs.cjs +64 -18
- package/cap/bin/lib/cap-tag-scanner.cjs +142 -0
- package/commands/cap/init.md +45 -0
- package/commands/cap/scan.md +92 -2
- package/commands/cap/start.md +55 -0
- package/commands/cap/switch-app.md +166 -0
- package/package.json +1 -1
|
@@ -75,15 +75,17 @@ function generateTemplate() {
|
|
|
75
75
|
`;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
// @gsd-api readFeatureMap(projectRoot) -- Reads and parses FEATURE-MAP.md from project root.
|
|
78
|
+
// @gsd-api readFeatureMap(projectRoot, appPath) -- Reads and parses FEATURE-MAP.md from project root or app subdirectory.
|
|
79
79
|
// Returns: FeatureMap object with features and lastScan timestamp.
|
|
80
80
|
// @gsd-todo(ref:AC-10) Feature Map is the single source of truth for feature identity, state, ACs, and relationships
|
|
81
81
|
/**
|
|
82
82
|
* @param {string} projectRoot - Absolute path to project root
|
|
83
|
+
* @param {string|null} [appPath=null] - Relative app path (e.g., "apps/flow"). If null, reads from projectRoot.
|
|
83
84
|
* @returns {FeatureMap}
|
|
84
85
|
*/
|
|
85
|
-
function readFeatureMap(projectRoot) {
|
|
86
|
-
const
|
|
86
|
+
function readFeatureMap(projectRoot, appPath) {
|
|
87
|
+
const baseDir = appPath ? path.join(projectRoot, appPath) : projectRoot;
|
|
88
|
+
const filePath = path.join(baseDir, FEATURE_MAP_FILE);
|
|
87
89
|
if (!fs.existsSync(filePath)) {
|
|
88
90
|
return { features: [], lastScan: null };
|
|
89
91
|
}
|
|
@@ -195,14 +197,16 @@ function parseFeatureMapContent(content) {
|
|
|
195
197
|
return { features, lastScan };
|
|
196
198
|
}
|
|
197
199
|
|
|
198
|
-
// @gsd-api writeFeatureMap(projectRoot, featureMap) -- Serializes FeatureMap to FEATURE-MAP.md.
|
|
199
|
-
// Side effect: overwrites FEATURE-MAP.md at project root.
|
|
200
|
+
// @gsd-api writeFeatureMap(projectRoot, featureMap, appPath) -- Serializes FeatureMap to FEATURE-MAP.md.
|
|
201
|
+
// Side effect: overwrites FEATURE-MAP.md at project root or app subdirectory.
|
|
200
202
|
/**
|
|
201
203
|
* @param {string} projectRoot - Absolute path to project root
|
|
202
204
|
* @param {FeatureMap} featureMap - Structured feature map data
|
|
205
|
+
* @param {string|null} [appPath=null] - Relative app path (e.g., "apps/flow"). If null, writes to projectRoot.
|
|
203
206
|
*/
|
|
204
|
-
function writeFeatureMap(projectRoot, featureMap) {
|
|
205
|
-
const
|
|
207
|
+
function writeFeatureMap(projectRoot, featureMap, appPath) {
|
|
208
|
+
const baseDir = appPath ? path.join(projectRoot, appPath) : projectRoot;
|
|
209
|
+
const filePath = path.join(baseDir, FEATURE_MAP_FILE);
|
|
206
210
|
const content = serializeFeatureMap(featureMap);
|
|
207
211
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
208
212
|
}
|
|
@@ -271,14 +275,15 @@ function serializeFeatureMap(featureMap) {
|
|
|
271
275
|
return lines.join('\n');
|
|
272
276
|
}
|
|
273
277
|
|
|
274
|
-
// @gsd-api addFeature(projectRoot, feature) -- Add a new feature entry to FEATURE-MAP.md.
|
|
278
|
+
// @gsd-api addFeature(projectRoot, feature, appPath) -- Add a new feature entry to FEATURE-MAP.md.
|
|
275
279
|
/**
|
|
276
280
|
* @param {string} projectRoot - Absolute path to project root
|
|
277
281
|
* @param {{ title: string, acs?: AcceptanceCriterion[], dependencies?: string[], metadata?: Object }} feature - Feature data (ID auto-generated)
|
|
282
|
+
* @param {string|null} [appPath=null] - Relative app path for monorepo scoping
|
|
278
283
|
* @returns {Feature} - The added feature with generated ID
|
|
279
284
|
*/
|
|
280
|
-
function addFeature(projectRoot, feature) {
|
|
281
|
-
const featureMap = readFeatureMap(projectRoot);
|
|
285
|
+
function addFeature(projectRoot, feature, appPath) {
|
|
286
|
+
const featureMap = readFeatureMap(projectRoot, appPath);
|
|
282
287
|
const id = getNextFeatureId(featureMap.features);
|
|
283
288
|
const newFeature = {
|
|
284
289
|
id,
|
|
@@ -290,22 +295,23 @@ function addFeature(projectRoot, feature) {
|
|
|
290
295
|
metadata: feature.metadata || {},
|
|
291
296
|
};
|
|
292
297
|
featureMap.features.push(newFeature);
|
|
293
|
-
writeFeatureMap(projectRoot, featureMap);
|
|
298
|
+
writeFeatureMap(projectRoot, featureMap, appPath);
|
|
294
299
|
return newFeature;
|
|
295
300
|
}
|
|
296
301
|
|
|
297
|
-
// @gsd-api updateFeatureState(projectRoot, featureId, newState) -- Transition feature state.
|
|
302
|
+
// @gsd-api updateFeatureState(projectRoot, featureId, newState, appPath) -- Transition feature state.
|
|
298
303
|
// @gsd-todo(ref:AC-9) Enforce valid state transitions: planned->prototyped->tested->shipped
|
|
299
304
|
/**
|
|
300
305
|
* @param {string} projectRoot - Absolute path to project root
|
|
301
306
|
* @param {string} featureId - Feature ID (e.g., "F-001")
|
|
302
307
|
* @param {string} newState - Target state
|
|
308
|
+
* @param {string|null} [appPath=null] - Relative app path for monorepo scoping
|
|
303
309
|
* @returns {boolean} - True if transition was valid and applied
|
|
304
310
|
*/
|
|
305
|
-
function updateFeatureState(projectRoot, featureId, newState) {
|
|
311
|
+
function updateFeatureState(projectRoot, featureId, newState, appPath) {
|
|
306
312
|
if (!VALID_STATES.includes(newState)) return false;
|
|
307
313
|
|
|
308
|
-
const featureMap = readFeatureMap(projectRoot);
|
|
314
|
+
const featureMap = readFeatureMap(projectRoot, appPath);
|
|
309
315
|
const feature = featureMap.features.find(f => f.id === featureId);
|
|
310
316
|
if (!feature) return false;
|
|
311
317
|
|
|
@@ -313,19 +319,20 @@ function updateFeatureState(projectRoot, featureId, newState) {
|
|
|
313
319
|
if (!allowed || !allowed.includes(newState)) return false;
|
|
314
320
|
|
|
315
321
|
feature.state = newState;
|
|
316
|
-
writeFeatureMap(projectRoot, featureMap);
|
|
322
|
+
writeFeatureMap(projectRoot, featureMap, appPath);
|
|
317
323
|
return true;
|
|
318
324
|
}
|
|
319
325
|
|
|
320
|
-
// @gsd-api enrichFromTags(projectRoot, scanResults) -- Update file references from tag scan.
|
|
326
|
+
// @gsd-api enrichFromTags(projectRoot, scanResults, appPath) -- Update file references from tag scan.
|
|
321
327
|
// @gsd-todo(ref:AC-12) Feature Map auto-enriched from @cap-feature tags found in source code
|
|
322
328
|
/**
|
|
323
329
|
* @param {string} projectRoot - Absolute path to project root
|
|
324
330
|
* @param {import('./cap-tag-scanner.cjs').CapTag[]} scanResults - Tags from cap-tag-scanner
|
|
331
|
+
* @param {string|null} [appPath=null] - Relative app path for monorepo scoping
|
|
325
332
|
* @returns {FeatureMap}
|
|
326
333
|
*/
|
|
327
|
-
function enrichFromTags(projectRoot, scanResults) {
|
|
328
|
-
const featureMap = readFeatureMap(projectRoot);
|
|
334
|
+
function enrichFromTags(projectRoot, scanResults, appPath) {
|
|
335
|
+
const featureMap = readFeatureMap(projectRoot, appPath);
|
|
329
336
|
|
|
330
337
|
for (const tag of scanResults) {
|
|
331
338
|
if (tag.type !== 'feature') continue;
|
|
@@ -341,7 +348,7 @@ function enrichFromTags(projectRoot, scanResults) {
|
|
|
341
348
|
}
|
|
342
349
|
}
|
|
343
350
|
|
|
344
|
-
writeFeatureMap(projectRoot, featureMap);
|
|
351
|
+
writeFeatureMap(projectRoot, featureMap, appPath);
|
|
345
352
|
return featureMap;
|
|
346
353
|
}
|
|
347
354
|
|
|
@@ -486,6 +493,66 @@ function getStatus(featureMap) {
|
|
|
486
493
|
return { totalFeatures, completedFeatures, totalACs, implementedACs, testedACs, reviewedACs };
|
|
487
494
|
}
|
|
488
495
|
|
|
496
|
+
// @gsd-api initAppFeatureMap(projectRoot, appPath) -- Create FEATURE-MAP.md for a specific app in a monorepo.
|
|
497
|
+
// Idempotent: does not overwrite existing FEATURE-MAP.md.
|
|
498
|
+
/**
|
|
499
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
500
|
+
* @param {string} appPath - Relative app path (e.g., "apps/flow")
|
|
501
|
+
* @returns {boolean} - True if created, false if already existed
|
|
502
|
+
*/
|
|
503
|
+
function initAppFeatureMap(projectRoot, appPath) {
|
|
504
|
+
const baseDir = path.join(projectRoot, appPath);
|
|
505
|
+
const filePath = path.join(baseDir, FEATURE_MAP_FILE);
|
|
506
|
+
if (fs.existsSync(filePath)) return false;
|
|
507
|
+
// Ensure directory exists
|
|
508
|
+
if (!fs.existsSync(baseDir)) {
|
|
509
|
+
fs.mkdirSync(baseDir, { recursive: true });
|
|
510
|
+
}
|
|
511
|
+
fs.writeFileSync(filePath, generateTemplate(), 'utf8');
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// @gsd-api listAppFeatureMaps(projectRoot) -- Find all FEATURE-MAP.md files in a monorepo.
|
|
516
|
+
// Returns array of relative paths to directories containing FEATURE-MAP.md.
|
|
517
|
+
/**
|
|
518
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
519
|
+
* @returns {string[]} - Relative directory paths that contain FEATURE-MAP.md (e.g., [".", "apps/flow", "packages/ui"])
|
|
520
|
+
*/
|
|
521
|
+
function listAppFeatureMaps(projectRoot) {
|
|
522
|
+
const results = [];
|
|
523
|
+
|
|
524
|
+
// Check root
|
|
525
|
+
if (fs.existsSync(path.join(projectRoot, FEATURE_MAP_FILE))) {
|
|
526
|
+
results.push('.');
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Walk subdirectories (max depth 3, skip excluded dirs)
|
|
530
|
+
const excludeDirs = new Set(['node_modules', '.git', '.cap', 'dist', 'build', 'coverage', '.planning']);
|
|
531
|
+
|
|
532
|
+
function walk(dir, depth) {
|
|
533
|
+
if (depth > 3) return;
|
|
534
|
+
let entries;
|
|
535
|
+
try {
|
|
536
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
537
|
+
} catch (_e) {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
for (const entry of entries) {
|
|
541
|
+
if (!entry.isDirectory()) continue;
|
|
542
|
+
if (excludeDirs.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
543
|
+
const fullPath = path.join(dir, entry.name);
|
|
544
|
+
const fmPath = path.join(fullPath, FEATURE_MAP_FILE);
|
|
545
|
+
if (fs.existsSync(fmPath)) {
|
|
546
|
+
results.push(path.relative(projectRoot, fullPath));
|
|
547
|
+
}
|
|
548
|
+
walk(fullPath, depth + 1);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
walk(projectRoot, 0);
|
|
553
|
+
return results;
|
|
554
|
+
}
|
|
555
|
+
|
|
489
556
|
module.exports = {
|
|
490
557
|
FEATURE_MAP_FILE,
|
|
491
558
|
VALID_STATES,
|
|
@@ -503,4 +570,6 @@ module.exports = {
|
|
|
503
570
|
enrichFromScan,
|
|
504
571
|
addFeatures,
|
|
505
572
|
getStatus,
|
|
573
|
+
initAppFeatureMap,
|
|
574
|
+
listAppFeatureMaps,
|
|
506
575
|
};
|
|
@@ -9,7 +9,7 @@ const path = require('node:path');
|
|
|
9
9
|
|
|
10
10
|
// --- Constants ---
|
|
11
11
|
|
|
12
|
-
const GSD_TAG_RE = /(@gsd-(feature|
|
|
12
|
+
const GSD_TAG_RE = /(@gsd-(feature|todos?|risk|decision|context|status|depends|ref|pattern|api|constraint|placeholder|concern))(\([^)]*\))?\s*(.*)/;
|
|
13
13
|
|
|
14
14
|
const SUPPORTED_EXTENSIONS = ['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.py', '.rb', '.go', '.rs', '.sh', '.md'];
|
|
15
15
|
const EXCLUDE_DIRS = ['node_modules', '.git', '.cap', 'dist', 'build', 'coverage'];
|
|
@@ -120,6 +120,27 @@ function migrateLineTag(line) {
|
|
|
120
120
|
action: 'plain-comment',
|
|
121
121
|
};
|
|
122
122
|
|
|
123
|
+
case 'todos':
|
|
124
|
+
// @gsd-todos (plural typo) → @cap-todo
|
|
125
|
+
return {
|
|
126
|
+
replaced: line.replace(fullTag, '@cap-todo'),
|
|
127
|
+
action: 'converted',
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
case 'placeholder':
|
|
131
|
+
// @gsd-placeholder → @cap-todo (placeholder is a todo variant)
|
|
132
|
+
return {
|
|
133
|
+
replaced: line.replace(fullTag, '@cap-todo'),
|
|
134
|
+
action: 'converted',
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
case 'concern':
|
|
138
|
+
// @gsd-concern → @cap-todo risk: (concerns are risks)
|
|
139
|
+
return {
|
|
140
|
+
replaced: line.replace(fullTag + metadata + (description ? ' ' : ''), '@cap-todo' + metadata + ' risk: ').replace(/ +/g, ' '),
|
|
141
|
+
action: 'converted',
|
|
142
|
+
};
|
|
143
|
+
|
|
123
144
|
default:
|
|
124
145
|
return null;
|
|
125
146
|
}
|
|
@@ -16,6 +16,7 @@ const path = require('node:path');
|
|
|
16
16
|
* @property {string} version - Session schema version (e.g., "2.0.0")
|
|
17
17
|
* @property {string|null} lastCommand - Last /cap: command executed
|
|
18
18
|
* @property {string|null} lastCommandTimestamp - ISO timestamp of last command
|
|
19
|
+
* @property {string|null} activeApp - Currently focused app path (e.g., "apps/flow") or null for single-repo/root
|
|
19
20
|
* @property {string|null} activeFeature - Currently focused feature ID
|
|
20
21
|
* @property {string|null} step - Current workflow step
|
|
21
22
|
* @property {string|null} startedAt - ISO timestamp of when session started
|
|
@@ -42,6 +43,7 @@ function getDefaultSession() {
|
|
|
42
43
|
version: '2.0.0',
|
|
43
44
|
lastCommand: null,
|
|
44
45
|
lastCommandTimestamp: null,
|
|
46
|
+
activeApp: null,
|
|
45
47
|
activeFeature: null,
|
|
46
48
|
step: null,
|
|
47
49
|
startedAt: null,
|
|
@@ -175,6 +177,56 @@ function initCapDirectory(projectRoot) {
|
|
|
175
177
|
}
|
|
176
178
|
}
|
|
177
179
|
|
|
180
|
+
// @gsd-api setActiveApp(projectRoot, appPath) -- Set the active app in SESSION.json for monorepo scoping.
|
|
181
|
+
/**
|
|
182
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
183
|
+
* @param {string|null} appPath - Relative app path (e.g., "apps/flow") or null to clear
|
|
184
|
+
* @returns {CapSession}
|
|
185
|
+
*/
|
|
186
|
+
function setActiveApp(projectRoot, appPath) {
|
|
187
|
+
return updateSession(projectRoot, { activeApp: appPath || null });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// @gsd-api getActiveApp(projectRoot) -- Get current active app path from SESSION.json.
|
|
191
|
+
/**
|
|
192
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
193
|
+
* @returns {string|null} - Active app path or null
|
|
194
|
+
*/
|
|
195
|
+
function getActiveApp(projectRoot) {
|
|
196
|
+
const session = loadSession(projectRoot);
|
|
197
|
+
return session.activeApp || null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// @gsd-api getAppRoot(projectRoot) -- Returns the effective root for app-scoped operations.
|
|
201
|
+
// If activeApp is set, returns projectRoot + activeApp. Otherwise returns projectRoot.
|
|
202
|
+
// This is the KEY function for all scoping decisions.
|
|
203
|
+
/**
|
|
204
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
205
|
+
* @returns {string} - Absolute path to the active app root (or project root if no app)
|
|
206
|
+
*/
|
|
207
|
+
function getAppRoot(projectRoot) {
|
|
208
|
+
const activeApp = getActiveApp(projectRoot);
|
|
209
|
+
if (activeApp) {
|
|
210
|
+
return path.join(projectRoot, activeApp);
|
|
211
|
+
}
|
|
212
|
+
return projectRoot;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// @gsd-api listApps(projectRoot) -- List available apps/packages in a monorepo using detectWorkspaces.
|
|
216
|
+
/**
|
|
217
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
218
|
+
* @returns {{ isMonorepo: boolean, apps: string[] }}
|
|
219
|
+
*/
|
|
220
|
+
function listApps(projectRoot) {
|
|
221
|
+
// Lazy require to avoid circular dependency
|
|
222
|
+
const { detectWorkspaces } = require('./cap-tag-scanner.cjs');
|
|
223
|
+
const workspaces = detectWorkspaces(projectRoot);
|
|
224
|
+
return {
|
|
225
|
+
isMonorepo: workspaces.isMonorepo,
|
|
226
|
+
apps: workspaces.packages,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
178
230
|
module.exports = {
|
|
179
231
|
CAP_DIR,
|
|
180
232
|
SESSION_FILE,
|
|
@@ -188,4 +240,8 @@ module.exports = {
|
|
|
188
240
|
endSession,
|
|
189
241
|
isInitialized,
|
|
190
242
|
initCapDirectory,
|
|
243
|
+
setActiveApp,
|
|
244
|
+
getActiveApp,
|
|
245
|
+
getAppRoot,
|
|
246
|
+
listApps,
|
|
191
247
|
};
|
|
@@ -551,27 +551,73 @@ function detectWorkspacePackages(projectRoot) {
|
|
|
551
551
|
}
|
|
552
552
|
}
|
|
553
553
|
|
|
554
|
-
//
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
if (fs.existsSync(fullDir) && fs.statSync(fullDir).isDirectory()) {
|
|
565
|
-
const entries = fs.readdirSync(fullDir, { withFileTypes: true });
|
|
566
|
-
for (const entry of entries) {
|
|
567
|
-
if (entry.isDirectory()) {
|
|
568
|
-
result.packages.push(path.join(baseDir, entry.name));
|
|
569
|
-
}
|
|
554
|
+
// Helper to expand workspace patterns
|
|
555
|
+
const expandPatterns = (patterns) => {
|
|
556
|
+
for (const pattern of patterns) {
|
|
557
|
+
const baseDir = pattern.replace(/\/\*.*$/, '');
|
|
558
|
+
const fullDir = path.join(projectRoot, baseDir);
|
|
559
|
+
if (fs.existsSync(fullDir) && fs.statSync(fullDir).isDirectory()) {
|
|
560
|
+
const entries = fs.readdirSync(fullDir, { withFileTypes: true });
|
|
561
|
+
for (const entry of entries) {
|
|
562
|
+
if (entry.isDirectory()) {
|
|
563
|
+
result.packages.push(path.join(baseDir, entry.name));
|
|
570
564
|
}
|
|
571
565
|
}
|
|
572
566
|
}
|
|
573
|
-
}
|
|
574
|
-
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
// Check pnpm-workspace.yaml
|
|
571
|
+
if (!result.isMonorepo) {
|
|
572
|
+
const pnpmPath = path.join(projectRoot, 'pnpm-workspace.yaml');
|
|
573
|
+
if (fs.existsSync(pnpmPath)) {
|
|
574
|
+
try {
|
|
575
|
+
const content = fs.readFileSync(pnpmPath, 'utf8');
|
|
576
|
+
const packagesMatch = content.match(/packages:\s*\n((?:\s+-\s*.+\n?)*)/);
|
|
577
|
+
if (packagesMatch) {
|
|
578
|
+
result.isMonorepo = true;
|
|
579
|
+
const patterns = packagesMatch[1]
|
|
580
|
+
.split('\n')
|
|
581
|
+
.map(line => line.replace(/^\s*-\s*['"]?/, '').replace(/['"]?\s*$/, ''))
|
|
582
|
+
.filter(Boolean);
|
|
583
|
+
expandPatterns(patterns);
|
|
584
|
+
}
|
|
585
|
+
} catch (_e) {}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Check nx.json
|
|
590
|
+
if (!result.isMonorepo) {
|
|
591
|
+
const nxPath = path.join(projectRoot, 'nx.json');
|
|
592
|
+
if (fs.existsSync(nxPath)) {
|
|
593
|
+
try {
|
|
594
|
+
const nx = JSON.parse(fs.readFileSync(nxPath, 'utf8'));
|
|
595
|
+
result.isMonorepo = true;
|
|
596
|
+
const layout = nx.workspaceLayout || {};
|
|
597
|
+
const patterns = [];
|
|
598
|
+
if (layout.appsDir) patterns.push(layout.appsDir + '/*');
|
|
599
|
+
if (layout.libsDir) patterns.push(layout.libsDir + '/*');
|
|
600
|
+
if (patterns.length === 0) {
|
|
601
|
+
for (const dir of ['apps', 'packages', 'libs']) {
|
|
602
|
+
if (fs.existsSync(path.join(projectRoot, dir))) {
|
|
603
|
+
patterns.push(dir + '/*');
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
expandPatterns(patterns);
|
|
608
|
+
} catch (_e) {}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Check lerna.json
|
|
613
|
+
if (!result.isMonorepo) {
|
|
614
|
+
const lernaPath = path.join(projectRoot, 'lerna.json');
|
|
615
|
+
if (fs.existsSync(lernaPath)) {
|
|
616
|
+
try {
|
|
617
|
+
const lerna = JSON.parse(fs.readFileSync(lernaPath, 'utf8'));
|
|
618
|
+
result.isMonorepo = true;
|
|
619
|
+
expandPatterns(lerna.packages || ['packages/*']);
|
|
620
|
+
} catch (_e) {}
|
|
575
621
|
}
|
|
576
622
|
}
|
|
577
623
|
|
|
@@ -284,6 +284,60 @@ function detectWorkspaces(projectRoot) {
|
|
|
284
284
|
}
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
// Check pnpm-workspace.yaml
|
|
288
|
+
if (!result.isMonorepo) {
|
|
289
|
+
const pnpmPath = path.join(projectRoot, 'pnpm-workspace.yaml');
|
|
290
|
+
if (fs.existsSync(pnpmPath)) {
|
|
291
|
+
try {
|
|
292
|
+
const content = fs.readFileSync(pnpmPath, 'utf8');
|
|
293
|
+
// Simple YAML parsing for packages array — handles:
|
|
294
|
+
// packages:
|
|
295
|
+
// - "apps/*"
|
|
296
|
+
// - "packages/*"
|
|
297
|
+
const packagesMatch = content.match(/packages:\s*\n((?:\s+-\s*.+\n?)*)/);
|
|
298
|
+
if (packagesMatch) {
|
|
299
|
+
result.isMonorepo = true;
|
|
300
|
+
const patterns = packagesMatch[1]
|
|
301
|
+
.split('\n')
|
|
302
|
+
.map(line => line.replace(/^\s*-\s*['"]?/, '').replace(/['"]?\s*$/, ''))
|
|
303
|
+
.filter(Boolean);
|
|
304
|
+
result.packages = resolveWorkspaceGlobs(projectRoot, patterns);
|
|
305
|
+
}
|
|
306
|
+
} catch (_e) {
|
|
307
|
+
// Malformed pnpm-workspace.yaml
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Check nx.json (NX workspace — look for project patterns or apps/packages dirs)
|
|
313
|
+
if (!result.isMonorepo) {
|
|
314
|
+
const nxPath = path.join(projectRoot, 'nx.json');
|
|
315
|
+
if (fs.existsSync(nxPath)) {
|
|
316
|
+
try {
|
|
317
|
+
const nx = JSON.parse(fs.readFileSync(nxPath, 'utf8'));
|
|
318
|
+
result.isMonorepo = true;
|
|
319
|
+
// NX may define workspaceLayout or rely on convention (apps/, packages/, libs/)
|
|
320
|
+
const layout = nx.workspaceLayout || {};
|
|
321
|
+
const patterns = [];
|
|
322
|
+
if (layout.appsDir) patterns.push(layout.appsDir + '/*');
|
|
323
|
+
if (layout.libsDir) patterns.push(layout.libsDir + '/*');
|
|
324
|
+
// Fallback: check common NX directories
|
|
325
|
+
if (patterns.length === 0) {
|
|
326
|
+
for (const dir of ['apps', 'packages', 'libs']) {
|
|
327
|
+
if (fs.existsSync(path.join(projectRoot, dir))) {
|
|
328
|
+
patterns.push(dir + '/*');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (patterns.length > 0) {
|
|
333
|
+
result.packages = resolveWorkspaceGlobs(projectRoot, patterns);
|
|
334
|
+
}
|
|
335
|
+
} catch (_e) {
|
|
336
|
+
// Malformed nx.json
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
287
341
|
// Check lerna.json
|
|
288
342
|
if (!result.isMonorepo) {
|
|
289
343
|
const lernaPath = path.join(projectRoot, 'lerna.json');
|
|
@@ -439,6 +493,92 @@ function groupByPackage(tags, packages) {
|
|
|
439
493
|
return groups;
|
|
440
494
|
}
|
|
441
495
|
|
|
496
|
+
// @gsd-api scanApp(projectRoot, appPath, options) -- Scans a single app directory plus referenced shared packages.
|
|
497
|
+
// When activeApp is set, scans only the active app and shared packages it imports.
|
|
498
|
+
/**
|
|
499
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
500
|
+
* @param {string} appPath - Relative app path (e.g., "apps/flow")
|
|
501
|
+
* @param {Object} [options]
|
|
502
|
+
* @param {string[]} [options.extensions] - File extensions to include
|
|
503
|
+
* @param {string[]} [options.exclude] - Directory names to exclude
|
|
504
|
+
* @returns {{ tags: CapTag[], scannedDirs: string[] }}
|
|
505
|
+
*/
|
|
506
|
+
function scanApp(projectRoot, appPath, options = {}) {
|
|
507
|
+
const appDir = path.join(projectRoot, appPath);
|
|
508
|
+
const scannedDirs = [appPath];
|
|
509
|
+
|
|
510
|
+
// Scan the app directory itself
|
|
511
|
+
const appTags = scanDirectory(appDir, {
|
|
512
|
+
...options,
|
|
513
|
+
projectRoot,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const allTags = [...appTags];
|
|
517
|
+
const seen = new Set(appTags.map(t => `${t.file}:${t.line}`));
|
|
518
|
+
|
|
519
|
+
// Detect shared packages referenced by this app via package.json dependencies
|
|
520
|
+
const sharedPkgs = detectSharedPackages(projectRoot, appPath);
|
|
521
|
+
for (const pkg of sharedPkgs) {
|
|
522
|
+
const pkgDir = path.join(projectRoot, pkg);
|
|
523
|
+
if (!fs.existsSync(pkgDir)) continue;
|
|
524
|
+
scannedDirs.push(pkg);
|
|
525
|
+
const pkgTags = scanDirectory(pkgDir, {
|
|
526
|
+
...options,
|
|
527
|
+
projectRoot,
|
|
528
|
+
});
|
|
529
|
+
for (const tag of pkgTags) {
|
|
530
|
+
const key = `${tag.file}:${tag.line}`;
|
|
531
|
+
if (!seen.has(key)) {
|
|
532
|
+
seen.add(key);
|
|
533
|
+
allTags.push(tag);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return { tags: allTags, scannedDirs };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// @gsd-api detectSharedPackages(projectRoot, appPath) -- Detects workspace packages referenced by an app's package.json.
|
|
542
|
+
/**
|
|
543
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
544
|
+
* @param {string} appPath - Relative app path
|
|
545
|
+
* @returns {string[]} - Array of relative paths to shared packages
|
|
546
|
+
*/
|
|
547
|
+
function detectSharedPackages(projectRoot, appPath) {
|
|
548
|
+
const packages = [];
|
|
549
|
+
const appPkgPath = path.join(projectRoot, appPath, 'package.json');
|
|
550
|
+
if (!fs.existsSync(appPkgPath)) return packages;
|
|
551
|
+
|
|
552
|
+
let appPkg;
|
|
553
|
+
try {
|
|
554
|
+
appPkg = JSON.parse(fs.readFileSync(appPkgPath, 'utf8'));
|
|
555
|
+
} catch (_e) {
|
|
556
|
+
return packages;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Collect all dependency names
|
|
560
|
+
const allDeps = Object.keys(appPkg.dependencies || {}).concat(Object.keys(appPkg.devDependencies || {}));
|
|
561
|
+
|
|
562
|
+
// Resolve workspace packages -- check if any dep matches a workspace package name
|
|
563
|
+
const workspaces = detectWorkspaces(projectRoot);
|
|
564
|
+
if (!workspaces.isMonorepo) return packages;
|
|
565
|
+
|
|
566
|
+
for (const wsPkg of workspaces.packages) {
|
|
567
|
+
const wsPkgJsonPath = path.join(projectRoot, wsPkg, 'package.json');
|
|
568
|
+
if (!fs.existsSync(wsPkgJsonPath)) continue;
|
|
569
|
+
try {
|
|
570
|
+
const wsPkgJson = JSON.parse(fs.readFileSync(wsPkgJsonPath, 'utf8'));
|
|
571
|
+
if (wsPkgJson.name && allDeps.includes(wsPkgJson.name)) {
|
|
572
|
+
packages.push(wsPkg);
|
|
573
|
+
}
|
|
574
|
+
} catch (_e) {
|
|
575
|
+
// Skip malformed
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return packages;
|
|
580
|
+
}
|
|
581
|
+
|
|
442
582
|
// @cap-todo Detect legacy @gsd-* tags and recommend /cap:migrate
|
|
443
583
|
const LEGACY_TAG_RE = /^[ \t]*(?:\/\/|\/\*|\*|#|--|"""|''')[ \t]*@gsd-(feature|todo|risk|decision|context|status|depends|ref|pattern|api|constraint)/;
|
|
444
584
|
|
|
@@ -527,4 +667,6 @@ module.exports = {
|
|
|
527
667
|
scanMonorepo,
|
|
528
668
|
groupByPackage,
|
|
529
669
|
detectLegacyTags,
|
|
670
|
+
scanApp,
|
|
671
|
+
detectSharedPackages,
|
|
530
672
|
};
|
package/commands/cap/init.md
CHANGED
|
@@ -103,6 +103,51 @@ Note the `context7Available` field -- this gets set in Step 6 to indicate whethe
|
|
|
103
103
|
|
|
104
104
|
If FEATURE-MAP.md does NOT exist, write the empty template.
|
|
105
105
|
|
|
106
|
+
## Step 5b: Detect monorepo and create per-app FEATURE-MAP.md files
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
node -e "
|
|
110
|
+
const session = require('./cap/bin/lib/cap-session.cjs');
|
|
111
|
+
const fm = require('./cap/bin/lib/cap-feature-map.cjs');
|
|
112
|
+
const fs = require('node:fs');
|
|
113
|
+
const path = require('node:path');
|
|
114
|
+
const projectRoot = process.cwd();
|
|
115
|
+
const mono = session.listApps(projectRoot);
|
|
116
|
+
|
|
117
|
+
if (!mono.isMonorepo) {
|
|
118
|
+
console.log(JSON.stringify({ isMonorepo: false, created: 0, apps: [] }));
|
|
119
|
+
} else {
|
|
120
|
+
let created = 0;
|
|
121
|
+
const createdApps = [];
|
|
122
|
+
for (const app of mono.apps) {
|
|
123
|
+
// Only create FEATURE-MAP.md for apps that have source files
|
|
124
|
+
const appDir = path.join(projectRoot, app);
|
|
125
|
+
if (!fs.existsSync(appDir)) continue;
|
|
126
|
+
const entries = fs.readdirSync(appDir, { withFileTypes: true });
|
|
127
|
+
const hasSrc = entries.some(e =>
|
|
128
|
+
(e.isDirectory() && (e.name === 'src' || e.name === 'lib' || e.name === 'app')) ||
|
|
129
|
+
(e.isFile() && /\.(js|ts|py|go|rs|java|rb)$/.test(e.name))
|
|
130
|
+
);
|
|
131
|
+
if (hasSrc) {
|
|
132
|
+
const wasCreated = fm.initAppFeatureMap(projectRoot, app);
|
|
133
|
+
if (wasCreated) {
|
|
134
|
+
created++;
|
|
135
|
+
createdApps.push(app);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
console.log(JSON.stringify({ isMonorepo: true, created, apps: createdApps, totalApps: mono.apps.length }));
|
|
140
|
+
}
|
|
141
|
+
"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Store as `monorepo_init`.
|
|
145
|
+
|
|
146
|
+
**If monorepo detected:**
|
|
147
|
+
Log: "Monorepo detected with {totalApps} workspace packages."
|
|
148
|
+
If `created > 0`: Log: "Created FEATURE-MAP.md for {created} apps: {apps list}"
|
|
149
|
+
If `created === 0`: Log: "All apps already have FEATURE-MAP.md (or no source files detected)."
|
|
150
|
+
|
|
106
151
|
## Step 6: Mandatory Context7 dependency fetch
|
|
107
152
|
|
|
108
153
|
<!-- @gsd-decision Multi-language dependency detection runs in priority order: package.json first, then requirements.txt, then Cargo.toml, then go.mod. First match sets project type. -->
|
package/commands/cap/scan.md
CHANGED
|
@@ -47,6 +47,18 @@ Check `$ARGUMENTS` for:
|
|
|
47
47
|
- `--features NAME` -- if present, store as `feature_filter`
|
|
48
48
|
- `--json` -- if present, set `json_output = true`
|
|
49
49
|
|
|
50
|
+
## Step 0b: Check active app scoping
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
node -e "
|
|
54
|
+
const session = require('./cap/bin/lib/cap-session.cjs');
|
|
55
|
+
const s = session.loadSession(process.cwd());
|
|
56
|
+
console.log(JSON.stringify({ activeApp: s.activeApp }));
|
|
57
|
+
"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Store as `app_scope`. If `app_scope.activeApp` is set, this scan will be scoped to the active app directory and its shared packages. The results will be written to the app's FEATURE-MAP.md (not root).
|
|
61
|
+
|
|
50
62
|
## Step 1: Detect monorepo configuration
|
|
51
63
|
|
|
52
64
|
<!-- @gsd-decision Monorepo detection reads package.json workspaces and lerna.json. Supports npm, yarn, pnpm workspace patterns. Glob expansion uses Bash for simplicity. -->
|
|
@@ -72,6 +84,43 @@ if (fs.existsSync(pkgPath)) {
|
|
|
72
84
|
} catch (_e) {}
|
|
73
85
|
}
|
|
74
86
|
|
|
87
|
+
// Check pnpm-workspace.yaml
|
|
88
|
+
const pnpmPath = path.join(process.cwd(), 'pnpm-workspace.yaml');
|
|
89
|
+
if (!result.isMonorepo && fs.existsSync(pnpmPath)) {
|
|
90
|
+
try {
|
|
91
|
+
const content = fs.readFileSync(pnpmPath, 'utf8');
|
|
92
|
+
const packagesMatch = content.match(/packages:\\s*\\n((?:\\s+-\\s*.+\\n?)*)/);
|
|
93
|
+
if (packagesMatch) {
|
|
94
|
+
result.isMonorepo = true;
|
|
95
|
+
result.workspaces = packagesMatch[1]
|
|
96
|
+
.split('\\n')
|
|
97
|
+
.map(line => line.replace(/^\\s*-\\s*['\"]?/, '').replace(/['\"]?\\s*$/, ''))
|
|
98
|
+
.filter(Boolean);
|
|
99
|
+
}
|
|
100
|
+
} catch (_e) {}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check nx.json
|
|
104
|
+
const nxPath = path.join(process.cwd(), 'nx.json');
|
|
105
|
+
if (!result.isMonorepo && fs.existsSync(nxPath)) {
|
|
106
|
+
try {
|
|
107
|
+
const nx = JSON.parse(fs.readFileSync(nxPath, 'utf8'));
|
|
108
|
+
result.isMonorepo = true;
|
|
109
|
+
const layout = nx.workspaceLayout || {};
|
|
110
|
+
const patterns = [];
|
|
111
|
+
if (layout.appsDir) patterns.push(layout.appsDir + '/*');
|
|
112
|
+
if (layout.libsDir) patterns.push(layout.libsDir + '/*');
|
|
113
|
+
if (patterns.length === 0) {
|
|
114
|
+
for (const dir of ['apps', 'packages', 'libs']) {
|
|
115
|
+
if (fs.existsSync(path.join(process.cwd(), dir))) {
|
|
116
|
+
patterns.push(dir + '/*');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
result.workspaces = patterns;
|
|
121
|
+
} catch (_e) {}
|
|
122
|
+
}
|
|
123
|
+
|
|
75
124
|
// Check lerna.json
|
|
76
125
|
if (!result.isMonorepo && fs.existsSync(lernaPath)) {
|
|
77
126
|
try {
|
|
@@ -108,9 +157,27 @@ Store as `monorepo_info`. Log project type:
|
|
|
108
157
|
- Monorepo: "Detected monorepo with {N} workspace packages: {list}"
|
|
109
158
|
- Single repo: "Single repository project detected."
|
|
110
159
|
|
|
111
|
-
## Step 2: Run tag scanner (with monorepo awareness)
|
|
160
|
+
## Step 2: Run tag scanner (with monorepo and app-scoping awareness)
|
|
161
|
+
|
|
162
|
+
**If `app_scope.activeApp` is set (app-scoped scan):**
|
|
112
163
|
|
|
113
|
-
|
|
164
|
+
Scan only the active app directory and its referenced shared packages:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
node -e "
|
|
168
|
+
const scanner = require('./cap/bin/lib/cap-tag-scanner.cjs');
|
|
169
|
+
const projectRoot = process.cwd();
|
|
170
|
+
const appPath = process.argv[1];
|
|
171
|
+
const result = scanner.scanApp(projectRoot, appPath);
|
|
172
|
+
console.log(JSON.stringify({ tags: result.tags, scannedDirs: result.scannedDirs }, null, 2));
|
|
173
|
+
" '<ACTIVE_APP_PATH>'
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Log: "App-scoped scan: {activeApp} (+ {N} shared packages)"
|
|
177
|
+
|
|
178
|
+
**Else if monorepo detected (full monorepo scan):**
|
|
179
|
+
|
|
180
|
+
Scan each workspace package independently AND the root:
|
|
114
181
|
|
|
115
182
|
```bash
|
|
116
183
|
node -e "
|
|
@@ -152,6 +219,10 @@ if (monorepoInfo.isMonorepo) {
|
|
|
152
219
|
" '<MONOREPO_INFO_JSON>'
|
|
153
220
|
```
|
|
154
221
|
|
|
222
|
+
**Else (single repo):**
|
|
223
|
+
|
|
224
|
+
Standard scan as before.
|
|
225
|
+
|
|
155
226
|
Store as `all_tags`.
|
|
156
227
|
|
|
157
228
|
## Step 3: Group tags by feature and by package
|
|
@@ -187,6 +258,25 @@ Same as base scan -- run orphan detection against FEATURE-MAP.md.
|
|
|
187
258
|
|
|
188
259
|
<!-- @gsd-decision Cross-package file refs are stored as full relative paths from project root. This means packages/core/src/auth.ts, not just src/auth.ts. Feature Map readers can identify the package from the path prefix. -->
|
|
189
260
|
|
|
261
|
+
**If app-scoped (activeApp set):** Enrich the app's FEATURE-MAP.md, not root.
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
node -e "
|
|
265
|
+
const fm = require('./cap/bin/lib/cap-feature-map.cjs');
|
|
266
|
+
const activeApp = process.argv[1] === 'null' ? null : process.argv[1];
|
|
267
|
+
const tags = JSON.parse(process.argv[2]);
|
|
268
|
+
const updated = fm.enrichFromTags(process.cwd(), tags, activeApp);
|
|
269
|
+
console.log(JSON.stringify({
|
|
270
|
+
features_enriched: updated.features.filter(f => f.files.length > 0).length,
|
|
271
|
+
total_file_refs: updated.features.reduce((sum, f) => sum + f.files.length, 0),
|
|
272
|
+
cross_package_refs: updated.features.reduce((sum, f) =>
|
|
273
|
+
sum + f.files.filter(fp => fp.startsWith('packages/')).length, 0)
|
|
274
|
+
}));
|
|
275
|
+
" '<ACTIVE_APP_OR_NULL>' '<ALL_TAGS_JSON>'
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
**If not app-scoped (full scan):**
|
|
279
|
+
|
|
190
280
|
```bash
|
|
191
281
|
node -e "
|
|
192
282
|
const scanner = require('./cap/bin/lib/cap-tag-scanner.cjs');
|
package/commands/cap/start.md
CHANGED
|
@@ -73,6 +73,61 @@ console.log('FEATURE-MAP.md created');
|
|
|
73
73
|
|
|
74
74
|
Log: "Created .cap/ directory and FEATURE-MAP.md"
|
|
75
75
|
|
|
76
|
+
## Step 1b: Detect monorepo and handle app scoping
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
node -e "
|
|
80
|
+
const session = require('./cap/bin/lib/cap-session.cjs');
|
|
81
|
+
const s = session.loadSession(process.cwd());
|
|
82
|
+
const mono = session.listApps(process.cwd());
|
|
83
|
+
console.log(JSON.stringify({ isMonorepo: mono.isMonorepo, apps: mono.apps, activeApp: s.activeApp }));
|
|
84
|
+
"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Store as `mono_info`.
|
|
88
|
+
|
|
89
|
+
**If monorepo and no activeApp in session:**
|
|
90
|
+
|
|
91
|
+
Log: "Monorepo detected with {N} apps. Select an app to scope your session."
|
|
92
|
+
|
|
93
|
+
List apps:
|
|
94
|
+
```
|
|
95
|
+
Available apps:
|
|
96
|
+
{For each app:}
|
|
97
|
+
{index}. {app}
|
|
98
|
+
{End for}
|
|
99
|
+
0. (root) -- Work at monorepo root level
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Use AskUserQuestion:
|
|
103
|
+
> "Select an app to focus on (enter number or path, e.g., 'apps/flow'), or '0' for root-level work:"
|
|
104
|
+
|
|
105
|
+
Process response and set active app:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
node -e "
|
|
109
|
+
const session = require('./cap/bin/lib/cap-session.cjs');
|
|
110
|
+
const selected = process.argv[1] === 'null' ? null : process.argv[1];
|
|
111
|
+
session.setActiveApp(process.cwd(), selected);
|
|
112
|
+
console.log('Active app set to: ' + (selected || '(root)'));
|
|
113
|
+
" '<SELECTED_APP_PATH_OR_NULL>'
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**If monorepo and activeApp is set:**
|
|
117
|
+
|
|
118
|
+
Log: "Monorepo session restored. Active app: {activeApp}"
|
|
119
|
+
Continue with existing activeApp. User can switch later with /cap:switch-app.
|
|
120
|
+
|
|
121
|
+
**If not a monorepo:**
|
|
122
|
+
|
|
123
|
+
Continue with single-repo behavior (no app scoping).
|
|
124
|
+
|
|
125
|
+
**For all subsequent steps:** When reading/writing FEATURE-MAP.md, use the activeApp path.
|
|
126
|
+
The effective FEATURE-MAP.md location is:
|
|
127
|
+
- Monorepo with activeApp: `{projectRoot}/{activeApp}/FEATURE-MAP.md`
|
|
128
|
+
- Monorepo without activeApp (root): `{projectRoot}/FEATURE-MAP.md`
|
|
129
|
+
- Single repo: `{projectRoot}/FEATURE-MAP.md`
|
|
130
|
+
|
|
76
131
|
## Step 2: Auto-detect project context
|
|
77
132
|
|
|
78
133
|
<!-- @gsd-todo(ref:AC-35) /cap:start shall auto-scope to the project by deriving project information from actual code (package.json, directory structure) rather than asking questions. -->
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cap:switch-app
|
|
3
|
+
description: "Switch active app in a monorepo -- lists available workspace packages, shows tag counts, updates SESSION.json."
|
|
4
|
+
allowed-tools:
|
|
5
|
+
- Bash
|
|
6
|
+
- Read
|
|
7
|
+
- AskUserQuestion
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
<!-- @cap-feature(feature:monorepo-scoping) Per-app monorepo scoping -- switch active app context for all CAP commands. -->
|
|
11
|
+
|
|
12
|
+
<objective>
|
|
13
|
+
Switch the active app in a monorepo project. Lists available workspace packages with their tag counts, lets the user select one, updates SESSION.json, and shows the selected app's FEATURE-MAP.md status.
|
|
14
|
+
|
|
15
|
+
If the project is not a monorepo, inform the user and exit.
|
|
16
|
+
</objective>
|
|
17
|
+
|
|
18
|
+
<context>
|
|
19
|
+
$ARGUMENTS
|
|
20
|
+
|
|
21
|
+
@.cap/SESSION.json
|
|
22
|
+
</context>
|
|
23
|
+
|
|
24
|
+
<process>
|
|
25
|
+
|
|
26
|
+
## Step 1: Detect monorepo
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
node -e "
|
|
30
|
+
const session = require('./cap/bin/lib/cap-session.cjs');
|
|
31
|
+
const result = session.listApps(process.cwd());
|
|
32
|
+
console.log(JSON.stringify(result));
|
|
33
|
+
"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Store as `mono_info`.
|
|
37
|
+
|
|
38
|
+
**If not a monorepo:**
|
|
39
|
+
Log: "This project is not a monorepo. /cap:switch-app is only available for monorepo projects."
|
|
40
|
+
Exit.
|
|
41
|
+
|
|
42
|
+
## Step 2: Get current session state
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
node -e "
|
|
46
|
+
const session = require('./cap/bin/lib/cap-session.cjs');
|
|
47
|
+
const s = session.loadSession(process.cwd());
|
|
48
|
+
console.log(JSON.stringify({ activeApp: s.activeApp }));
|
|
49
|
+
"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Store as `current_session`.
|
|
53
|
+
|
|
54
|
+
## Step 3: Scan each app for tag counts
|
|
55
|
+
|
|
56
|
+
For each app in `mono_info.apps`:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
node -e "
|
|
60
|
+
const scanner = require('./cap/bin/lib/cap-tag-scanner.cjs');
|
|
61
|
+
const fm = require('./cap/bin/lib/cap-feature-map.cjs');
|
|
62
|
+
const fs = require('node:fs');
|
|
63
|
+
const path = require('node:path');
|
|
64
|
+
const projectRoot = process.cwd();
|
|
65
|
+
const apps = JSON.parse(process.argv[1]);
|
|
66
|
+
const results = [];
|
|
67
|
+
|
|
68
|
+
for (const app of apps) {
|
|
69
|
+
const appDir = path.join(projectRoot, app);
|
|
70
|
+
if (!fs.existsSync(appDir)) continue;
|
|
71
|
+
const tags = scanner.scanDirectory(appDir, { projectRoot });
|
|
72
|
+
const featureMap = fm.readFeatureMap(projectRoot, app);
|
|
73
|
+
const status = fm.getStatus(featureMap);
|
|
74
|
+
results.push({
|
|
75
|
+
path: app,
|
|
76
|
+
tagCount: tags.length,
|
|
77
|
+
featureCount: status.totalFeatures,
|
|
78
|
+
hasFeatureMap: fs.existsSync(path.join(appDir, 'FEATURE-MAP.md'))
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(JSON.stringify(results, null, 2));
|
|
83
|
+
" '<APPS_JSON>'
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Store as `app_stats`.
|
|
87
|
+
|
|
88
|
+
## Step 4: Present app list and ask user to select
|
|
89
|
+
|
|
90
|
+
Display:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
=== Monorepo App Selector ===
|
|
94
|
+
|
|
95
|
+
{If current_session.activeApp:}
|
|
96
|
+
Currently active: {current_session.activeApp}
|
|
97
|
+
{End if}
|
|
98
|
+
|
|
99
|
+
Available apps:
|
|
100
|
+
{For each app in app_stats:}
|
|
101
|
+
{index}. {app.path} -- {app.tagCount} tags, {app.featureCount} features {app.hasFeatureMap ? "" : "(no FEATURE-MAP.md)"}
|
|
102
|
+
{End for}
|
|
103
|
+
|
|
104
|
+
0. (root) -- Work at monorepo root level
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Use AskUserQuestion:
|
|
108
|
+
> "Select an app by number or path (e.g., 'apps/flow'), or '0' for root-level work:"
|
|
109
|
+
|
|
110
|
+
## Step 5: Update SESSION.json
|
|
111
|
+
|
|
112
|
+
Process user response:
|
|
113
|
+
- If `0` or `root` or `none`: set activeApp to null
|
|
114
|
+
- If a number: map to the corresponding app path
|
|
115
|
+
- If a path string: validate it exists in the app list
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
node -e "
|
|
119
|
+
const session = require('./cap/bin/lib/cap-session.cjs');
|
|
120
|
+
const selected = process.argv[1] === 'null' ? null : process.argv[1];
|
|
121
|
+
session.setActiveApp(process.cwd(), selected);
|
|
122
|
+
session.updateSession(process.cwd(), {
|
|
123
|
+
lastCommand: '/cap:switch-app',
|
|
124
|
+
lastCommandTimestamp: new Date().toISOString()
|
|
125
|
+
});
|
|
126
|
+
console.log('Active app set to: ' + (selected || '(root)'));
|
|
127
|
+
" '<SELECTED_APP_PATH_OR_NULL>'
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Step 6: Show selected app status
|
|
131
|
+
|
|
132
|
+
If an app was selected (not root):
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
node -e "
|
|
136
|
+
const fm = require('./cap/bin/lib/cap-feature-map.cjs');
|
|
137
|
+
const appPath = process.argv[1];
|
|
138
|
+
const featureMap = fm.readFeatureMap(process.cwd(), appPath);
|
|
139
|
+
const status = fm.getStatus(featureMap);
|
|
140
|
+
console.log(JSON.stringify({
|
|
141
|
+
features: featureMap.features.map(f => ({ id: f.id, title: f.title, state: f.state })),
|
|
142
|
+
...status
|
|
143
|
+
}));
|
|
144
|
+
" '<SELECTED_APP_PATH>'
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Display:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
Switched to: {selected_app}
|
|
151
|
+
|
|
152
|
+
Feature Map status:
|
|
153
|
+
Features: {totalFeatures} ({completedFeatures} shipped)
|
|
154
|
+
ACs: {totalACs} total, {implementedACs} implemented
|
|
155
|
+
|
|
156
|
+
{If features exist:}
|
|
157
|
+
Features in {selected_app}:
|
|
158
|
+
{For each feature:}
|
|
159
|
+
{feature.id}: {feature.title} [{feature.state}]
|
|
160
|
+
{End for}
|
|
161
|
+
{Else:}
|
|
162
|
+
No features yet. Run /cap:brainstorm or /cap:init to create FEATURE-MAP.md for this app.
|
|
163
|
+
{End if}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
</process>
|