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.
@@ -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 filePath = path.join(projectRoot, FEATURE_MAP_FILE);
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 filePath = path.join(projectRoot, FEATURE_MAP_FILE);
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|todo|risk|decision|context|status|depends|ref|pattern|api|constraint))(\([^)]*\))?\s*(.*)/;
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
- // Check lerna.json
555
- const lernaPath = path.join(projectRoot, 'lerna.json');
556
- if (!result.isMonorepo && fs.existsSync(lernaPath)) {
557
- try {
558
- const lerna = JSON.parse(fs.readFileSync(lernaPath, 'utf8'));
559
- result.isMonorepo = true;
560
- const patterns = lerna.packages || ['packages/*'];
561
- for (const pattern of patterns) {
562
- const baseDir = pattern.replace(/\/\*.*$/, '');
563
- const fullDir = path.join(projectRoot, baseDir);
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
- } catch (_e) {
574
- // Ignore
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
  };
@@ -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. -->
@@ -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
- If monorepo detected, scan each workspace package independently AND the root:
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');
@@ -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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-as-plan",
3
- "version": "2.0.3",
3
+ "version": "2.0.6",
4
4
  "description": "CAP — Code as Plan. Build first, plan from code. Farley-aligned engineering framework for Claude Code.",
5
5
  "bin": {
6
6
  "cap": "bin/install.js"