cap-pro 1.0.0

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.
Files changed (275) hide show
  1. package/.claude-plugin/README.md +26 -0
  2. package/.claude-plugin/marketplace.json +24 -0
  3. package/.claude-plugin/plugin.json +24 -0
  4. package/LICENSE +21 -0
  5. package/README.ja-JP.md +834 -0
  6. package/README.ko-KR.md +823 -0
  7. package/README.md +806 -0
  8. package/README.pt-BR.md +452 -0
  9. package/README.zh-CN.md +800 -0
  10. package/agents/cap-architect.md +269 -0
  11. package/agents/cap-brainstormer.md +207 -0
  12. package/agents/cap-curator.md +276 -0
  13. package/agents/cap-debugger.md +365 -0
  14. package/agents/cap-designer.md +246 -0
  15. package/agents/cap-historian.md +464 -0
  16. package/agents/cap-migrator.md +291 -0
  17. package/agents/cap-prototyper.md +197 -0
  18. package/agents/cap-validator.md +308 -0
  19. package/bin/install.js +5433 -0
  20. package/cap/bin/cap-tools.cjs +853 -0
  21. package/cap/bin/lib/arc-scanner.cjs +344 -0
  22. package/cap/bin/lib/cap-affinity-engine.cjs +862 -0
  23. package/cap/bin/lib/cap-anchor.cjs +228 -0
  24. package/cap/bin/lib/cap-annotation-writer.cjs +340 -0
  25. package/cap/bin/lib/cap-checkpoint.cjs +434 -0
  26. package/cap/bin/lib/cap-cluster-detect.cjs +945 -0
  27. package/cap/bin/lib/cap-cluster-display.cjs +52 -0
  28. package/cap/bin/lib/cap-cluster-format.cjs +245 -0
  29. package/cap/bin/lib/cap-cluster-helpers.cjs +295 -0
  30. package/cap/bin/lib/cap-cluster-io.cjs +212 -0
  31. package/cap/bin/lib/cap-completeness.cjs +540 -0
  32. package/cap/bin/lib/cap-deps.cjs +583 -0
  33. package/cap/bin/lib/cap-design-families.cjs +332 -0
  34. package/cap/bin/lib/cap-design.cjs +966 -0
  35. package/cap/bin/lib/cap-divergence-detector.cjs +400 -0
  36. package/cap/bin/lib/cap-doctor.cjs +752 -0
  37. package/cap/bin/lib/cap-feature-map-internals.cjs +19 -0
  38. package/cap/bin/lib/cap-feature-map-migrate.cjs +335 -0
  39. package/cap/bin/lib/cap-feature-map-monorepo.cjs +885 -0
  40. package/cap/bin/lib/cap-feature-map-shard.cjs +315 -0
  41. package/cap/bin/lib/cap-feature-map.cjs +1943 -0
  42. package/cap/bin/lib/cap-fitness-score.cjs +1075 -0
  43. package/cap/bin/lib/cap-impact-analysis.cjs +652 -0
  44. package/cap/bin/lib/cap-learn-review.cjs +1072 -0
  45. package/cap/bin/lib/cap-learning-signals.cjs +627 -0
  46. package/cap/bin/lib/cap-loader.cjs +227 -0
  47. package/cap/bin/lib/cap-logger.cjs +57 -0
  48. package/cap/bin/lib/cap-memory-bridge.cjs +764 -0
  49. package/cap/bin/lib/cap-memory-confidence.cjs +452 -0
  50. package/cap/bin/lib/cap-memory-dir.cjs +987 -0
  51. package/cap/bin/lib/cap-memory-engine.cjs +698 -0
  52. package/cap/bin/lib/cap-memory-extends.cjs +398 -0
  53. package/cap/bin/lib/cap-memory-graph.cjs +790 -0
  54. package/cap/bin/lib/cap-memory-migrate.cjs +2015 -0
  55. package/cap/bin/lib/cap-memory-pin.cjs +183 -0
  56. package/cap/bin/lib/cap-memory-platform.cjs +490 -0
  57. package/cap/bin/lib/cap-memory-prune.cjs +707 -0
  58. package/cap/bin/lib/cap-memory-schema.cjs +812 -0
  59. package/cap/bin/lib/cap-migrate-tags.cjs +309 -0
  60. package/cap/bin/lib/cap-migrate.cjs +540 -0
  61. package/cap/bin/lib/cap-pattern-apply.cjs +1203 -0
  62. package/cap/bin/lib/cap-pattern-pipeline.cjs +1034 -0
  63. package/cap/bin/lib/cap-plugin-manifest.cjs +80 -0
  64. package/cap/bin/lib/cap-realtime-affinity.cjs +399 -0
  65. package/cap/bin/lib/cap-reconcile.cjs +570 -0
  66. package/cap/bin/lib/cap-research-gate.cjs +218 -0
  67. package/cap/bin/lib/cap-scope-filter.cjs +402 -0
  68. package/cap/bin/lib/cap-semantic-pipeline.cjs +1038 -0
  69. package/cap/bin/lib/cap-session-extract.cjs +987 -0
  70. package/cap/bin/lib/cap-session.cjs +445 -0
  71. package/cap/bin/lib/cap-snapshot-linkage.cjs +963 -0
  72. package/cap/bin/lib/cap-stack-docs.cjs +646 -0
  73. package/cap/bin/lib/cap-tag-observer.cjs +371 -0
  74. package/cap/bin/lib/cap-tag-scanner.cjs +1766 -0
  75. package/cap/bin/lib/cap-telemetry.cjs +466 -0
  76. package/cap/bin/lib/cap-test-audit.cjs +1438 -0
  77. package/cap/bin/lib/cap-thread-migrator.cjs +307 -0
  78. package/cap/bin/lib/cap-thread-synthesis.cjs +545 -0
  79. package/cap/bin/lib/cap-thread-tracker.cjs +519 -0
  80. package/cap/bin/lib/cap-trace.cjs +399 -0
  81. package/cap/bin/lib/cap-trust-mode.cjs +336 -0
  82. package/cap/bin/lib/cap-ui-design-editor.cjs +642 -0
  83. package/cap/bin/lib/cap-ui-mind-map.cjs +712 -0
  84. package/cap/bin/lib/cap-ui-thread-nav.cjs +693 -0
  85. package/cap/bin/lib/cap-ui.cjs +1245 -0
  86. package/cap/bin/lib/cap-upgrade.cjs +1028 -0
  87. package/cap/bin/lib/cli/arg-helpers.cjs +49 -0
  88. package/cap/bin/lib/cli/frontmatter-router.cjs +31 -0
  89. package/cap/bin/lib/cli/init-router.cjs +68 -0
  90. package/cap/bin/lib/cli/phase-router.cjs +102 -0
  91. package/cap/bin/lib/cli/state-router.cjs +61 -0
  92. package/cap/bin/lib/cli/template-router.cjs +37 -0
  93. package/cap/bin/lib/cli/uat-router.cjs +29 -0
  94. package/cap/bin/lib/cli/validation-router.cjs +26 -0
  95. package/cap/bin/lib/cli/verification-router.cjs +31 -0
  96. package/cap/bin/lib/cli/workstream-router.cjs +39 -0
  97. package/cap/bin/lib/commands.cjs +961 -0
  98. package/cap/bin/lib/config.cjs +467 -0
  99. package/cap/bin/lib/convention-reader.cjs +258 -0
  100. package/cap/bin/lib/core.cjs +1241 -0
  101. package/cap/bin/lib/feature-aggregator.cjs +423 -0
  102. package/cap/bin/lib/frontmatter.cjs +337 -0
  103. package/cap/bin/lib/init.cjs +1443 -0
  104. package/cap/bin/lib/manifest-generator.cjs +383 -0
  105. package/cap/bin/lib/milestone.cjs +253 -0
  106. package/cap/bin/lib/model-profiles.cjs +69 -0
  107. package/cap/bin/lib/monorepo-context.cjs +226 -0
  108. package/cap/bin/lib/monorepo-migrator.cjs +509 -0
  109. package/cap/bin/lib/phase.cjs +889 -0
  110. package/cap/bin/lib/profile-output.cjs +989 -0
  111. package/cap/bin/lib/profile-pipeline.cjs +540 -0
  112. package/cap/bin/lib/roadmap.cjs +330 -0
  113. package/cap/bin/lib/security.cjs +394 -0
  114. package/cap/bin/lib/session-manager.cjs +292 -0
  115. package/cap/bin/lib/skeleton-generator.cjs +179 -0
  116. package/cap/bin/lib/state.cjs +1032 -0
  117. package/cap/bin/lib/template.cjs +231 -0
  118. package/cap/bin/lib/test-detector.cjs +62 -0
  119. package/cap/bin/lib/uat.cjs +283 -0
  120. package/cap/bin/lib/verify.cjs +889 -0
  121. package/cap/bin/lib/workspace-detector.cjs +371 -0
  122. package/cap/bin/lib/workstream.cjs +492 -0
  123. package/cap/commands/gsd/workstreams.md +63 -0
  124. package/cap/references/arc-standard.md +315 -0
  125. package/cap/references/cap-agent-architecture.md +101 -0
  126. package/cap/references/cap-gitignore-template +9 -0
  127. package/cap/references/cap-zero-deps.md +158 -0
  128. package/cap/references/checkpoints.md +778 -0
  129. package/cap/references/continuation-format.md +249 -0
  130. package/cap/references/contract-test-templates.md +312 -0
  131. package/cap/references/feature-map-template.md +25 -0
  132. package/cap/references/git-integration.md +295 -0
  133. package/cap/references/git-planning-commit.md +38 -0
  134. package/cap/references/model-profiles.md +174 -0
  135. package/cap/references/phase-numbering.md +126 -0
  136. package/cap/references/planning-config.md +202 -0
  137. package/cap/references/property-test-templates.md +316 -0
  138. package/cap/references/security-test-templates.md +347 -0
  139. package/cap/references/session-template.json +8 -0
  140. package/cap/references/tdd.md +263 -0
  141. package/cap/references/user-profiling.md +681 -0
  142. package/cap/references/verification-patterns.md +612 -0
  143. package/cap/templates/UAT.md +265 -0
  144. package/cap/templates/claude-md.md +175 -0
  145. package/cap/templates/codebase/architecture.md +255 -0
  146. package/cap/templates/codebase/concerns.md +310 -0
  147. package/cap/templates/codebase/conventions.md +307 -0
  148. package/cap/templates/codebase/integrations.md +280 -0
  149. package/cap/templates/codebase/stack.md +186 -0
  150. package/cap/templates/codebase/structure.md +285 -0
  151. package/cap/templates/codebase/testing.md +480 -0
  152. package/cap/templates/config.json +44 -0
  153. package/cap/templates/context.md +352 -0
  154. package/cap/templates/continue-here.md +78 -0
  155. package/cap/templates/copilot-instructions.md +7 -0
  156. package/cap/templates/debug-subagent-prompt.md +91 -0
  157. package/cap/templates/discussion-log.md +63 -0
  158. package/cap/templates/milestone-archive.md +123 -0
  159. package/cap/templates/milestone.md +115 -0
  160. package/cap/templates/phase-prompt.md +610 -0
  161. package/cap/templates/planner-subagent-prompt.md +117 -0
  162. package/cap/templates/project.md +186 -0
  163. package/cap/templates/requirements.md +231 -0
  164. package/cap/templates/research-project/ARCHITECTURE.md +204 -0
  165. package/cap/templates/research-project/FEATURES.md +147 -0
  166. package/cap/templates/research-project/PITFALLS.md +200 -0
  167. package/cap/templates/research-project/STACK.md +120 -0
  168. package/cap/templates/research-project/SUMMARY.md +170 -0
  169. package/cap/templates/research.md +552 -0
  170. package/cap/templates/roadmap.md +202 -0
  171. package/cap/templates/state.md +176 -0
  172. package/cap/templates/summary.md +364 -0
  173. package/cap/templates/user-preferences.md +498 -0
  174. package/cap/templates/verification-report.md +322 -0
  175. package/cap/workflows/add-phase.md +112 -0
  176. package/cap/workflows/add-tests.md +351 -0
  177. package/cap/workflows/add-todo.md +158 -0
  178. package/cap/workflows/audit-milestone.md +340 -0
  179. package/cap/workflows/audit-uat.md +109 -0
  180. package/cap/workflows/autonomous.md +891 -0
  181. package/cap/workflows/check-todos.md +177 -0
  182. package/cap/workflows/cleanup.md +152 -0
  183. package/cap/workflows/complete-milestone.md +767 -0
  184. package/cap/workflows/diagnose-issues.md +231 -0
  185. package/cap/workflows/discovery-phase.md +289 -0
  186. package/cap/workflows/discuss-phase-assumptions.md +653 -0
  187. package/cap/workflows/discuss-phase.md +1049 -0
  188. package/cap/workflows/do.md +104 -0
  189. package/cap/workflows/execute-phase.md +846 -0
  190. package/cap/workflows/execute-plan.md +514 -0
  191. package/cap/workflows/fast.md +105 -0
  192. package/cap/workflows/forensics.md +265 -0
  193. package/cap/workflows/health.md +181 -0
  194. package/cap/workflows/help.md +660 -0
  195. package/cap/workflows/insert-phase.md +130 -0
  196. package/cap/workflows/list-phase-assumptions.md +178 -0
  197. package/cap/workflows/list-workspaces.md +56 -0
  198. package/cap/workflows/manager.md +362 -0
  199. package/cap/workflows/map-codebase.md +377 -0
  200. package/cap/workflows/milestone-summary.md +223 -0
  201. package/cap/workflows/new-milestone.md +486 -0
  202. package/cap/workflows/new-project.md +1250 -0
  203. package/cap/workflows/new-workspace.md +237 -0
  204. package/cap/workflows/next.md +97 -0
  205. package/cap/workflows/node-repair.md +92 -0
  206. package/cap/workflows/note.md +156 -0
  207. package/cap/workflows/pause-work.md +176 -0
  208. package/cap/workflows/plan-milestone-gaps.md +273 -0
  209. package/cap/workflows/plan-phase.md +857 -0
  210. package/cap/workflows/plant-seed.md +169 -0
  211. package/cap/workflows/pr-branch.md +129 -0
  212. package/cap/workflows/profile-user.md +449 -0
  213. package/cap/workflows/progress.md +507 -0
  214. package/cap/workflows/quick.md +757 -0
  215. package/cap/workflows/remove-phase.md +155 -0
  216. package/cap/workflows/remove-workspace.md +90 -0
  217. package/cap/workflows/research-phase.md +82 -0
  218. package/cap/workflows/resume-project.md +326 -0
  219. package/cap/workflows/review.md +228 -0
  220. package/cap/workflows/session-report.md +146 -0
  221. package/cap/workflows/settings.md +283 -0
  222. package/cap/workflows/ship.md +228 -0
  223. package/cap/workflows/stats.md +60 -0
  224. package/cap/workflows/transition.md +671 -0
  225. package/cap/workflows/ui-phase.md +298 -0
  226. package/cap/workflows/ui-review.md +161 -0
  227. package/cap/workflows/update.md +323 -0
  228. package/cap/workflows/validate-phase.md +170 -0
  229. package/cap/workflows/verify-phase.md +254 -0
  230. package/cap/workflows/verify-work.md +637 -0
  231. package/commands/cap/annotate.md +165 -0
  232. package/commands/cap/brainstorm.md +393 -0
  233. package/commands/cap/checkpoint.md +106 -0
  234. package/commands/cap/completeness.md +94 -0
  235. package/commands/cap/continue.md +72 -0
  236. package/commands/cap/debug.md +588 -0
  237. package/commands/cap/deps.md +169 -0
  238. package/commands/cap/design.md +479 -0
  239. package/commands/cap/init.md +354 -0
  240. package/commands/cap/iterate.md +249 -0
  241. package/commands/cap/learn.md +459 -0
  242. package/commands/cap/memory.md +275 -0
  243. package/commands/cap/migrate-feature-map.md +91 -0
  244. package/commands/cap/migrate-memory.md +108 -0
  245. package/commands/cap/migrate-tags.md +91 -0
  246. package/commands/cap/migrate.md +131 -0
  247. package/commands/cap/prototype.md +510 -0
  248. package/commands/cap/reconcile.md +121 -0
  249. package/commands/cap/review.md +360 -0
  250. package/commands/cap/save.md +72 -0
  251. package/commands/cap/scan.md +404 -0
  252. package/commands/cap/start.md +356 -0
  253. package/commands/cap/status.md +118 -0
  254. package/commands/cap/test-audit.md +262 -0
  255. package/commands/cap/test.md +394 -0
  256. package/commands/cap/trace.md +133 -0
  257. package/commands/cap/ui.md +167 -0
  258. package/hooks/dist/cap-check-update.js +115 -0
  259. package/hooks/dist/cap-context-monitor.js +185 -0
  260. package/hooks/dist/cap-learn-review-hook.js +114 -0
  261. package/hooks/dist/cap-learning-hook.js +192 -0
  262. package/hooks/dist/cap-memory.js +299 -0
  263. package/hooks/dist/cap-prompt-guard.js +97 -0
  264. package/hooks/dist/cap-statusline.js +157 -0
  265. package/hooks/dist/cap-tag-observer.js +115 -0
  266. package/hooks/dist/cap-version-check.js +112 -0
  267. package/hooks/dist/cap-workflow-guard.js +175 -0
  268. package/hooks/hooks.json +55 -0
  269. package/package.json +58 -0
  270. package/scripts/base64-scan.sh +262 -0
  271. package/scripts/build-hooks.js +93 -0
  272. package/scripts/cap-removal-checklist.md +202 -0
  273. package/scripts/prompt-injection-scan.sh +199 -0
  274. package/scripts/run-tests.cjs +181 -0
  275. package/scripts/secret-scan.sh +227 -0
@@ -0,0 +1,1943 @@
1
+ // @cap-context CAP v2.0 Feature Map reader/writer -- FEATURE-MAP.md is the single source of truth for all features, ACs, status, and dependencies.
2
+ // @cap-decision Markdown format for Feature Map (not JSON/YAML) -- human-readable, diffable in git, editable in any text editor. Machine-readable via regex parsing of structured table rows.
3
+ // @cap-decision Read and write are separate operations -- no in-memory mutation API. Read returns structured data, write takes structured data and serializes to markdown.
4
+ // @cap-constraint Zero external dependencies -- uses only Node.js built-ins (fs, path).
5
+ // @cap-pattern Feature Map is the bridge between all CAP workflows. Brainstorm writes entries, scan updates status, status reads for dashboard.
6
+
7
+ 'use strict';
8
+
9
+ // @cap-feature(feature:F-002) Feature Map Management — read/write/enrich FEATURE-MAP.md as single source of truth
10
+ // @cap-feature(feature:F-081) Multi-Format Feature Map Parser — Union ID regex (F-NNN | F-LONGFORM), bullet-style ACs, config-driven format selection
11
+ // @cap-feature(feature:F-082) Aggregate Feature Maps Across Monorepo Sub-Apps — readFeatureMap transparently merges sub-app maps via Rescoped Table or opt-in directory walk
12
+
13
+ // @cap-history(sessions:5, edits:23, since:2026-04-20, learned:2026-05-08) Frequently modified — 5 sessions, 23 edits
14
+ const fs = require('node:fs');
15
+ const path = require('node:path');
16
+
17
+ const FEATURE_MAP_FILE = 'FEATURE-MAP.md';
18
+
19
+ // @cap-feature(feature:F-081) Union Feature-ID pattern: legacy F-NNN (3+ digits) OR long-form F-UPPERCASE
20
+ // @cap-feature(feature:F-089) Pattern widened to include mixed-case deskriptiv IDs (F-Hub-Spotlight-Carousel)
21
+ // for monorepo apps. Three-branch union; F-076-suffix invariant preserved (digit-leading suffixed
22
+ // forms match neither branch).
23
+ // @cap-decision(F-081/AC-1) The pattern is intentionally anchored on both ends; the letter branch
24
+ // requires letter-first char so digit-leading slugs like `F-076-suffix` continue to be REJECTED —
25
+ // preserves the F-076 schema invariant proven by cap-memory-schema tests.
26
+ // @cap-risk(reason:regex-asymmetry) The narrow header regex `featureHeaderRE` historically used `\d{3}`;
27
+ // widening it to the union must NOT also widen `getNextFeatureId`'s sequence detection (which only
28
+ // considers numeric IDs for next-id allocation). Long-form IDs are user-named and never auto-generated.
29
+ // @cap-decision(F-089/regex-sync) The canonical source is cap-feature-map-shard.cjs:FEATURE_ID_PATTERN.
30
+ // Keep this constant in sync with that file or callers will see asymmetric ID acceptance.
31
+ const FEATURE_ID_PATTERN = /^F-(?:\d{3,}|[A-Z](?:[A-Z0-9_]*[A-Z0-9])?(?:[-_][A-Z0-9_]*[A-Z0-9])*|[A-Z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+)$/;
32
+
33
+ // @cap-todo(ref:AC-9) Feature state lifecycle: planned -> prototyped -> tested -> shipped
34
+ const VALID_STATES = ['planned', 'prototyped', 'tested', 'shipped'];
35
+ const STATE_TRANSITIONS = {
36
+ planned: ['prototyped'],
37
+ prototyped: ['tested'],
38
+ tested: ['shipped'],
39
+ shipped: [],
40
+ };
41
+
42
+ /**
43
+ * @typedef {Object} AcceptanceCriterion
44
+ * @property {string} id - AC identifier (e.g., "AC-1")
45
+ * @property {string} description - Imperative description text
46
+ * @property {'pending'|'implemented'|'tested'|'reviewed'} status - Current status
47
+ */
48
+
49
+ /**
50
+ * @typedef {Object} Feature
51
+ * @property {string} id - Feature ID (e.g., "F-001")
52
+ * @property {string} title - Feature title (verb+object format)
53
+ * @property {'planned'|'prototyped'|'tested'|'shipped'} state - Feature lifecycle state
54
+ * @property {AcceptanceCriterion[]} acs - Acceptance criteria
55
+ * @property {string[]} files - File references linked to this feature
56
+ * @property {string[]} dependencies - Feature IDs this depends on
57
+ * @property {string[]} usesDesign - F-063: DT-NNN / DC-NNN IDs that this feature references (default [])
58
+ * @property {Object<string,string>} metadata - Additional key-value metadata
59
+ */
60
+
61
+ /**
62
+ * @typedef {Object} FeatureMap
63
+ * @property {Feature[]} features - All features
64
+ * @property {string} lastScan - ISO timestamp of last scan
65
+ */
66
+
67
+ // @cap-todo(ref:AC-7) Feature Map is a single Markdown file at the project root named FEATURE-MAP.md
68
+
69
+ // @cap-todo(ref:AC-1) Generate empty FEATURE-MAP.md template with section headers (Features, Legend) and no feature entries
70
+ /**
71
+ * Generate the empty FEATURE-MAP.md template for /cap:init.
72
+ * @returns {string}
73
+ */
74
+ function generateTemplate() {
75
+ return `# Feature Map
76
+
77
+ > Single source of truth for feature identity, state, acceptance criteria, and relationships.
78
+ > Auto-enriched by \`@cap-feature\` tags and dependency analysis.
79
+
80
+ ## Features
81
+
82
+ <!-- No features yet. Run /cap:brainstorm or add features with addFeature(). -->
83
+
84
+ ## Legend
85
+
86
+ | State | Meaning |
87
+ |-------|---------|
88
+ | planned | Feature identified, not yet implemented |
89
+ | prototyped | Initial implementation exists |
90
+ | tested | Tests written and passing |
91
+ | shipped | Deployed / merged to main |
92
+
93
+ ---
94
+ *Last updated: ${new Date().toISOString()}*
95
+ `;
96
+ }
97
+
98
+ // @cap-api readFeatureMap(projectRoot, appPath) -- Reads and parses FEATURE-MAP.md from project root or app subdirectory.
99
+ // Returns: FeatureMap object with features and lastScan timestamp.
100
+ // @cap-todo(ref:AC-10) Feature Map is the single source of truth for feature identity, state, ACs, and relationships
101
+ /**
102
+ * @param {string} projectRoot - Absolute path to project root
103
+ * @param {string|null} [appPath=null] - Relative app path (e.g., "apps/flow"). If null, reads from projectRoot.
104
+ * @param {{ safe?: boolean }} [options] - F-081/iter1: when `safe:true`, duplicate-id detection
105
+ * returns `{features, lastScan, parseError}` instead of throwing. Default false (legacy throw
106
+ * preserved — pinned by adversarial regression test "duplicate-on-disk causes readFeatureMap
107
+ * to throw with positioned error").
108
+ * @returns {FeatureMap}
109
+ */
110
+ function readFeatureMap(projectRoot, appPath, options) {
111
+ const baseDir = appPath ? path.join(projectRoot, appPath) : projectRoot;
112
+ const filePath = path.join(baseDir, FEATURE_MAP_FILE);
113
+
114
+ // @cap-feature(feature:F-089, primary:true) Sharded-mode dispatch — load index + per-feature files
115
+ // when features/ directory exists. Falls back to monolithic for AC-7 backwards-compat.
116
+ if (_shard().isShardedMap(projectRoot, appPath)) {
117
+ return _readShardedMap(projectRoot, appPath, options);
118
+ }
119
+
120
+ if (!fs.existsSync(filePath)) {
121
+ return { features: [], lastScan: null };
122
+ }
123
+
124
+ const content = fs.readFileSync(filePath, 'utf8');
125
+ // @cap-todo(ac:F-081/AC-7) Forward projectRoot for config-driven format style.
126
+ // @cap-decision(F-081/iter1+iter2) Safe-mode opt-in: default strict (throw on duplicate) preserves
127
+ // pinned adversarial test; `{safe: true}` returns parseError for tooling. Write-back paths bail on
128
+ // parseError; read-only consumers warn-and-continue. F-076/F-077 lesson on user-controlled IDs in
129
+ // warn messages: parseError.message is wrapped in String(...).trim() at every call site.
130
+ const safe = Boolean(options && options.safe === true);
131
+ const rootResult = parseFeatureMapContent(content, { projectRoot, safe });
132
+
133
+ // @cap-todo(ac:F-082/AC-1) Aggregation only triggers on ROOT-level reads (appPath null/undef).
134
+ // Sub-app reads (caller passed appPath explicitly) get the single map verbatim — the caller
135
+ // is targeting one sub-app deliberately and aggregation would be surprising.
136
+ // @cap-decision(F-082/single-level-aggregation) Single-level only: root → sub-apps → features.
137
+ // A sub-app FEATURE-MAP.md with its own Rescoped Table is NOT recursively expanded — that
138
+ // would create cycles, bloat parser surface, and confuse the round-trip writer (which sub-app
139
+ // does a write-back belong to?). If a project legitimately needs nested workspaces, the user
140
+ // reads each sub-app explicitly via appPath.
141
+ if (appPath) return rootResult;
142
+
143
+ // @cap-todo(ac:F-082/AC-1) Detect "Rescoped Feature Maps" header in the root content; if found,
144
+ // parse the table to discover sub-app paths and aggregate transparently.
145
+ // @cap-todo(ac:F-083/AC-6) Lazy-require monorepo module — see _monorepo() definition.
146
+ const _mr = _monorepo();
147
+ const rescopedEntries = _mr.parseRescopedTable(content);
148
+
149
+ // @cap-todo(ac:F-082/AC-3) Opt-in directory-walk fallback: when no Rescoped Table is present
150
+ // AND `.cap/config.json:featureMaps.discover === "auto"`, glob `apps/*/FEATURE-MAP.md`
151
+ // and `packages/*/FEATURE-MAP.md`. Default `"table-only"` preserves legacy single-map behavior.
152
+ /** @type {Array<{appPath: string}>} */
153
+ let aggregationTargets = rescopedEntries;
154
+ if (aggregationTargets.length === 0) {
155
+ const cfg = readCapConfig(projectRoot);
156
+ const discoverMode =
157
+ cfg && cfg.featureMaps && typeof cfg.featureMaps.discover === 'string'
158
+ ? cfg.featureMaps.discover
159
+ : 'table-only';
160
+ if (discoverMode === 'auto') {
161
+ aggregationTargets = _mr.discoverSubAppFeatureMaps(projectRoot);
162
+ }
163
+ }
164
+
165
+ if (aggregationTargets.length === 0) return rootResult;
166
+
167
+ return _mr.aggregateSubAppFeatureMaps(projectRoot, rootResult, aggregationTargets, { safe });
168
+ }
169
+
170
+ // @cap-feature(feature:F-083) parseRescopedTable / discoverSubAppFeatureMaps /
171
+ // aggregateSubAppFeatureMaps extracted to cap-feature-map-monorepo.cjs; re-exported below.
172
+
173
+ // @cap-todo(ref:AC-8) Each feature entry contains: feature ID, title, state, ACs, and file references
174
+ // @cap-todo(ref:AC-14) Feature Map scales to 80-120 features in a single file
175
+ // @cap-feature(feature:F-041) Fix Feature Map Parser Roundtrip Symmetry — parser is the read half of a
176
+ // symmetric pair with serializeFeatureMap. Parser must accept every format the serializer can write,
177
+ // without dropping ACs or transforming status case beyond what the serializer can re-emit.
178
+
179
+ /**
180
+ * @typedef {Object} CapConfig
181
+ * @property {('table'|'bullet'|'auto')=} featureMapStyle - explicit AC format selection (default "auto")
182
+ */
183
+
184
+ /**
185
+ * @typedef {Object} ParseOptions
186
+ * @property {string=} projectRoot - Absolute path to project root for config loading
187
+ * @property {('table'|'bullet'|'auto')=} featureMapStyle - explicit override (takes precedence over config)
188
+ * @property {boolean=} safe - F-081/iter1: when true, return `{features, lastScan, parseError}`
189
+ * on duplicate-feature-id detection instead of throwing. Default false (legacy throw behavior
190
+ * preserved for direct parseFeatureMapContent callers and existing tests). readFeatureMap
191
+ * passes safe:true by default so the 24 bare CLI/library call sites no longer crash on a
192
+ * hand-edited duplicate.
193
+ */
194
+
195
+ /**
196
+ * @typedef {Object} ParseError
197
+ * @property {string} code - Stable error code (currently only 'CAP_DUPLICATE_FEATURE_ID')
198
+ * @property {string} message - Human-readable error message
199
+ * @property {string} duplicateId - Normalized feature ID that collided
200
+ * @property {number} firstLine - Line number of the first occurrence (1-based)
201
+ * @property {number} duplicateLine - Line number of the duplicate occurrence (1-based)
202
+ */
203
+
204
+ // @cap-feature(feature:F-081) readCapConfig — graceful loader for .cap/config.json
205
+ // @cap-todo(ac:F-081/AC-7) Config-loader infrastructure available in cap-feature-map.cjs for F-082 reuse.
206
+ // @cap-decision(F-081/AC-7) Returns {} on every error path (missing file, malformed JSON, read errors).
207
+ // Rationale: parser must remain robust — config is an enhancement, never a hard dependency. Throwing
208
+ // here would make a malformed config file silently break every Feature-Map read across all CAP commands.
209
+ /**
210
+ * Read .cap/config.json from a project root with graceful defaults on every error path.
211
+ * @param {string} projectRoot - Absolute path to project root
212
+ * @returns {CapConfig} - Parsed config, or empty object on missing/malformed/read-error
213
+ */
214
+ function readCapConfig(projectRoot) {
215
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) return {};
216
+ const configPath = path.join(projectRoot, '.cap', 'config.json');
217
+ if (!fs.existsSync(configPath)) return {};
218
+ try {
219
+ const raw = fs.readFileSync(configPath, 'utf8');
220
+ const parsed = JSON.parse(raw);
221
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
222
+ return parsed;
223
+ } catch (_e) {
224
+ return {};
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Parse FEATURE-MAP.md content into structured data.
230
+ * @param {string} content - Raw markdown content
231
+ * @param {ParseOptions=} options - Optional parser options (projectRoot for config, featureMapStyle override)
232
+ * @returns {FeatureMap}
233
+ */
234
+ function parseFeatureMapContent(content, options) {
235
+ const features = [];
236
+ const lines = content.split('\n');
237
+
238
+ // @cap-todo(ac:F-081/AC-3) Resolve format style: explicit option > config > "auto" default.
239
+ let formatStyle = 'auto';
240
+ if (options && typeof options.featureMapStyle === 'string') {
241
+ formatStyle = options.featureMapStyle;
242
+ } else if (options && options.projectRoot) {
243
+ const cfg = readCapConfig(options.projectRoot);
244
+ if (cfg && typeof cfg.featureMapStyle === 'string') {
245
+ formatStyle = cfg.featureMapStyle;
246
+ }
247
+ }
248
+ if (formatStyle !== 'table' && formatStyle !== 'bullet' && formatStyle !== 'auto') {
249
+ formatStyle = 'auto';
250
+ }
251
+
252
+ // Match feature headers: ### F-001: Title text [state]
253
+ // Also accepts: ### F-001: Title text (no [state] — state comes from separate line)
254
+ // Also accepts em-dash / en-dash / hyphen separator with surrounding spaces:
255
+ // ### F-001 — Title ### F-001 – Title ### F-001 - Title
256
+ // @cap-todo(ac:F-081/AC-1) Union Feature-ID regex accepts F-NNN AND F-LONGFORM (uppercase-led).
257
+ // @cap-decision(F-082/iter2) Header separator tolerance: GoetzeInvest real-world dry-run uses ` — ` (em-dash)
258
+ // throughout root + sub-app maps. Accepting `:` plus dash forms (with required surrounding whitespace
259
+ // to disambiguate from hyphen-in-ID) makes CAP tolerant of the legacy CAP-init-template em-dash style
260
+ // without forcing migration. Tested in cap-feature-map-emdash.test.cjs.
261
+ // @cap-feature(feature:F-089) Header regex keeps in sync with FEATURE_ID_PATTERN above.
262
+ const featureHeaderRE = /^###\s+(F-(?:\d{3,}|[A-Z](?:[A-Z0-9_]*[A-Z0-9])?(?:[-_][A-Z0-9_]*[A-Z0-9])*|[A-Z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+))(?::\s+|\s+[—–-]\s+)(.+?)\s*$/;
263
+ // Match AC rows: | AC-N | status | description |
264
+ // End-anchor (\s*$) forces the non-greedy description group to expand up to the
265
+ // trailing pipe of the row, not the first internal pipe. Without the anchor an AC
266
+ // description containing a literal "|" character (e.g. "parse foo | bar from stdin")
267
+ // was silently truncated at the first pipe — which e.g. dropped F-057/AC-2 during
268
+ // the 2026-04-21 ECC feature batch and required a manual restore workaround.
269
+ const acRowRE = /^\|\s*(AC-\d+)\s*\|\s*(\w+)\s*\|\s*(.+?)\s*\|\s*$/;
270
+ // @cap-todo(ac:F-041/AC-4) Strict header detector: only match the literal table header
271
+ // "| AC | Status | Description |" so AC-N rows whose description contains the word "Status"
272
+ // (e.g. F-041/AC-6) are not misclassified as table headers, which previously truncated the
273
+ // table and silently dropped subsequent AC rows.
274
+ const acTableHeaderRE = /^\|\s*AC\s*\|\s*Status\s*\|\s*Description\s*\|/i;
275
+ // Match AC checkboxes: - [x] description or - [ ] description
276
+ const acCheckboxRE = /^[\s]*-\s+\[(x| )\]\s+(.+)/;
277
+ // @cap-todo(ac:F-081/AC-2) Bullet-style AC with EXPLICIT AC-N prefix.
278
+ // @cap-decision(F-081/AC-2) Prefix-bearing format `- [ ] AC-N: description` is the canonical bullet
279
+ // shape; this differs from the legacy `- **AC:**`-section anonymous checkboxes which auto-number.
280
+ // Explicit AC-IDs let bullet-style maps survive AC reordering / partial AC additions without
281
+ // silently re-numbering downstream entries — a pitfall observed in early CAP brainstorms.
282
+ // @cap-risk(reason:asterisk-bullet-marker) Markdown allows `*` and `-` as bullet markers; we accept
283
+ // both for parser robustness (some editors auto-rewrite). The serializer always emits `-` to keep
284
+ // roundtrip output stable.
285
+ // @cap-todo(ac:F-081/AC-2 iter:1) Description capture widened from `(.+?)` to `(.*?)` so an
286
+ // empty-description bullet (`- [ ] AC-1:` with EOL) is recognized as a legitimate AC instead
287
+ // of falling through to the legacy anonymous-checkbox branch (which would silently swallow
288
+ // the AC-N: prefix as the description and block all subsequent bullets via inAcCheckboxes=true).
289
+ // @cap-decision(F-081/iter1) Stage-2 #1 fix: empty-desc bullet is a legitimate parse outcome —
290
+ // downstream code should treat `description: ''` as missing-text, never as missing-AC.
291
+ const bulletAcRE = /^[\s]*[-*]\s+\[([ x])\]\s+(AC-\d+):\s*(.*?)\s*$/i;
292
+ // @cap-decision(F-081/iter1) Shape-only detector is SEPARATE from the value-extraction regex.
293
+ // The shape detector matches the prefix `- [ ] AC-N:` regardless of description content (empty
294
+ // or non-empty) so `isExplicitBulletShape` (below) gates the legacy branch correctly even when
295
+ // the value-extraction regex would have matched anyway. Keeping the two regexes separate also
296
+ // means future loosening of the value regex (e.g. multi-line continuation) cannot accidentally
297
+ // re-introduce the silent-swallow bug fixed here.
298
+ const bulletAcShapeRE = /^[\s]*[-*]\s+\[[ xX]\]\s+AC-\d+:/;
299
+ // Match file refs: - `path/to/file`
300
+ const fileRefRE = /^-\s+`(.+?)`/;
301
+ // Match dependencies: **Depends on:** F-001, F-002 or - **Dependencies:** F-001
302
+ const depsRE = /^-?\s*\*\*Depend(?:s on|encies):\*\*\s*(.+)/;
303
+ // @cap-todo(ac:F-063/AC-3) Match design usage: **Uses design:** DT-001, DC-001
304
+ // @cap-decision(F-063/D3) Line format mirrors **Depends on:** — same shape, same delimiter, same position.
305
+ const usesDesignRE = /^-?\s*\*\*Uses design:\*\*\s*(.+)/i;
306
+ // Match status line: - **Status:** shipped or **Status:** shipped
307
+ const statusLineRE = /^-?\s*\*\*Status:\*\*\s*(\w+)/;
308
+ // File refs detected inline via regex test (not a stored RE)
309
+ // Match AC section header: - **AC:**
310
+ const acSectionRE = /^-?\s*\*\*AC:\*\*/;
311
+ // Match lastScan in footer
312
+ const lastScanRE = /^\*Last updated:\s*(.+?)\*$/;
313
+
314
+ let currentFeature = null;
315
+ let inAcTable = false;
316
+ let inAcCheckboxes = false;
317
+ let inFileRefs = false;
318
+ let acCounter = 0;
319
+ let lastScan = null;
320
+ // @cap-todo(ac:F-081/AC-4) Track per-feature header line for positioned duplicate-error messages.
321
+ /** @type {Array<{id: string, line: number}>} */
322
+ const featureLineOrigins = [];
323
+ // @cap-todo(ac:F-081/AC-2) Track table-row presence per feature; "auto" only enables bullet-AC
324
+ // detection when zero table rows have been seen — matches the AC-2 contract and keeps the
325
+ // table fast-path for AC-6 unchanged.
326
+ let sawTableRow = false;
327
+
328
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
329
+ const line = lines[lineIdx];
330
+ const headerMatch = line.match(featureHeaderRE);
331
+ if (headerMatch) {
332
+ if (currentFeature) features.push(currentFeature);
333
+ // Extract [state] from end of title if present, otherwise state is null (set from status line)
334
+ let title = headerMatch[2];
335
+ let state = null;
336
+ const stateInTitle = title.match(/^(.+?)\s+\[(\w+)\]\s*$/);
337
+ if (stateInTitle) {
338
+ title = stateInTitle[1];
339
+ state = stateInTitle[2];
340
+ }
341
+ currentFeature = {
342
+ id: headerMatch[1],
343
+ title,
344
+ state: state || 'planned',
345
+ acs: [],
346
+ files: [],
347
+ dependencies: [],
348
+ usesDesign: [], // @cap-todo(ac:F-063/AC-3) F-063: default-empty DT/DC IDs list.
349
+ metadata: {},
350
+ };
351
+ featureLineOrigins.push({ id: headerMatch[1], line: lineIdx + 1 });
352
+ inAcTable = false;
353
+ inAcCheckboxes = false;
354
+ inFileRefs = false;
355
+ acCounter = 0;
356
+ sawTableRow = false;
357
+ continue;
358
+ }
359
+
360
+ if (!currentFeature) {
361
+ const scanMatch = line.match(lastScanRE);
362
+ if (scanMatch) lastScan = scanMatch[1].trim();
363
+ continue;
364
+ }
365
+
366
+ // Status line: - **Status:** shipped
367
+ // @cap-todo(ac:F-041/AC-3) Preserve case of status as written so a roundtrip
368
+ // (parse -> serialize -> parse) does not transform the value. Canonical
369
+ // lifecycle values are lowercase; this only matters for non-canonical inputs.
370
+ const statusMatch = line.match(statusLineRE);
371
+ if (statusMatch) {
372
+ currentFeature.state = statusMatch[1];
373
+ continue;
374
+ }
375
+
376
+ // Detect AC table start using the strict header detector (see acTableHeaderRE above).
377
+ // @cap-todo(ac:F-041/AC-4) Use strict header regex instead of substring "Status" check
378
+ // so AC-N data rows whose description contains the word "Status" do not falsely trigger
379
+ // a "new table" reset that drops subsequent AC entries.
380
+ if (acTableHeaderRE.test(line)) {
381
+ inAcTable = true;
382
+ inAcCheckboxes = false;
383
+ inFileRefs = false;
384
+ continue;
385
+ }
386
+ // Skip table separator
387
+ if (line.match(/^\|[\s-]+\|/)) continue;
388
+
389
+ const acMatch = line.match(acRowRE);
390
+ if (acMatch && inAcTable) {
391
+ // @cap-todo(ac:F-041/AC-3) Preserve case of AC status so roundtrip is lossless.
392
+ currentFeature.acs.push({
393
+ id: acMatch[1],
394
+ description: acMatch[3].trim(),
395
+ status: acMatch[2],
396
+ });
397
+ sawTableRow = true; // @cap-todo(ac:F-081/AC-2) Block bullet detection once any table row exists.
398
+ // @cap-todo(ac:F-081/iter1) Mark this feature's AC origin format as 'table' so the
399
+ // serializer can preserve it on round-trip. Once any table row is seen, the feature
400
+ // sticks to 'table' even if a stray bullet appears later (matches sawTableRow gate).
401
+ currentFeature._inputFormat = 'table';
402
+ continue;
403
+ }
404
+
405
+ // AC section header: - **AC:**
406
+ if (line.match(acSectionRE)) {
407
+ inAcCheckboxes = true;
408
+ inAcTable = false;
409
+ inFileRefs = false;
410
+ continue;
411
+ }
412
+
413
+ // @cap-todo(ac:F-081/AC-2) Bullet-style AC detection — must precede the legacy anonymous-checkbox
414
+ // branch because the legacy branch's regex is broader and would swallow `AC-N:` prefixes verbatim
415
+ // into the description, breaking AC-ID round-trips.
416
+ // @cap-decision(F-081/AC-3) Format-style gate:
417
+ // - "table" : never run bullet branch (caller declared table-only)
418
+ // - "bullet" : always run bullet branch when no `- **AC:**` section is active
419
+ // - "auto" : only run bullet branch when no table rows have been seen for this feature yet
420
+ const bulletAcMatch = line.match(bulletAcRE);
421
+ if (
422
+ bulletAcMatch &&
423
+ formatStyle !== 'table' &&
424
+ !inAcCheckboxes &&
425
+ !inFileRefs &&
426
+ (formatStyle === 'bullet' || (formatStyle === 'auto' && !sawTableRow))
427
+ ) {
428
+ const checked = bulletAcMatch[1].toLowerCase() === 'x';
429
+ currentFeature.acs.push({
430
+ id: bulletAcMatch[2],
431
+ description: bulletAcMatch[3].trim(),
432
+ status: checked ? 'tested' : 'pending',
433
+ });
434
+ inAcTable = false;
435
+ inFileRefs = false;
436
+ // @cap-todo(ac:F-081/iter1) Mark this feature's AC origin format as 'bullet' so the
437
+ // serializer preserves bullet format on the next write — fixes Stage-2 #2 (round-trip
438
+ // asymmetry) where every writeFeatureMap call after readFeatureMap silently rewrote
439
+ // bullet input to table form.
440
+ // @cap-decision(F-081/iter1) `_inputFormat` is in-memory metadata (underscore prefix
441
+ // marks it as runtime-only, never persisted as a separate front-matter field).
442
+ // Source-of-truth on subsequent reads is the AC line shape itself; this field is a
443
+ // hint for the serializer between read and write within the same process. Mirrors
444
+ // the F-082 `metadata.subApp` runtime-hint pattern.
445
+ // @cap-risk(reason:proto-pollution) `_inputFormat` is set from parser branch detection,
446
+ // never from raw user input. A malicious FEATURE-MAP cannot inject this field through
447
+ // parsed content (no attacker-controlled key path reaches here).
448
+ currentFeature._inputFormat = 'bullet';
449
+ continue;
450
+ }
451
+
452
+ // AC checkboxes: - [x] description or - [ ] description
453
+ // @cap-decision(F-081/AC-2) Lines that match the explicit `AC-N:` bullet shape are NEVER routed
454
+ // through the legacy anonymous-checkbox branch — even if the bullet branch above declined them
455
+ // (e.g. format="table" or table rows already seen). Anonymous auto-numbering of `AC-N:`-prefixed
456
+ // text would silently rewrite the AC ID to a counter and dump the prefix into the description,
457
+ // which is exactly the silent-corruption mode AC-2/AC-4 are written to prevent.
458
+ // @cap-todo(ac:F-081/AC-2 iter:1) Use shape-only detector here (independent of the value
459
+ // regex's description capture) so empty-description bullets `- [ ] AC-1:` are also gated
460
+ // away from the legacy branch. Without this, the legacy branch would set inAcCheckboxes=true
461
+ // and block all subsequent bullets in the same feature.
462
+ const isExplicitBulletShape = bulletAcShapeRE.test(line);
463
+ const checkboxMatch = isExplicitBulletShape ? null : line.match(acCheckboxRE);
464
+ if (checkboxMatch && (inAcCheckboxes || !inFileRefs)) {
465
+ acCounter++;
466
+ const checked = checkboxMatch[1] === 'x';
467
+ currentFeature.acs.push({
468
+ id: `AC-${acCounter}`,
469
+ description: checkboxMatch[2].trim(),
470
+ status: checked ? 'tested' : 'pending',
471
+ });
472
+ inAcCheckboxes = true;
473
+ inAcTable = false;
474
+ inFileRefs = false;
475
+ continue;
476
+ }
477
+
478
+ // File references — inline on **Files:** line or as separate section
479
+ // Matches: **Files:** or - **Files:** `path`, `path2`
480
+ if (/^-?\s*\*\*Files:\*\*/.test(line)) {
481
+ // Extract any backtick-quoted paths on this same line
482
+ const pathMatches = line.matchAll(/`([^`]+)`/g);
483
+ for (const m of pathMatches) {
484
+ currentFeature.files.push(m[1]);
485
+ }
486
+ inFileRefs = true;
487
+ inAcTable = false;
488
+ inAcCheckboxes = false;
489
+ continue;
490
+ }
491
+
492
+ if (inFileRefs) {
493
+ const refMatch = line.match(fileRefRE);
494
+ if (refMatch) {
495
+ currentFeature.files.push(refMatch[1]);
496
+ continue;
497
+ } else if (line.trim() === '') {
498
+ inFileRefs = false;
499
+ }
500
+ }
501
+
502
+ // Dependencies
503
+ const depsMatch = line.match(depsRE);
504
+ if (depsMatch) {
505
+ currentFeature.dependencies = depsMatch[1].split(',').map(d => d.trim()).filter(Boolean);
506
+ continue;
507
+ }
508
+
509
+ // @cap-todo(ac:F-063/AC-3) Parse **Uses design:** line — DT/DC IDs comma-separated.
510
+ // Tolerant parser: accepts "DT-001", "DT-001 primary-color" (takes the ID prefix only).
511
+ const usesMatch = line.match(usesDesignRE);
512
+ if (usesMatch) {
513
+ currentFeature.usesDesign = usesMatch[1]
514
+ .split(',')
515
+ .map(s => s.trim())
516
+ .filter(Boolean)
517
+ .map(s => {
518
+ // Accept "DT-001" or "DT-001 primary-color" — keep only the ID token.
519
+ const m = s.match(/^(DT-\d{3,}|DC-\d{3,})\b/);
520
+ return m ? m[1] : s;
521
+ })
522
+ .filter(s => /^(DT-\d{3,}|DC-\d{3,})$/.test(s));
523
+ continue;
524
+ }
525
+
526
+ const scanMatch = line.match(lastScanRE);
527
+ if (scanMatch) lastScan = scanMatch[1].trim();
528
+ }
529
+
530
+ if (currentFeature) features.push(currentFeature);
531
+
532
+ // @cap-todo(ac:F-081/AC-4) Duplicate-after-normalization detection — HARD error, no silent dedup.
533
+ // @cap-decision(F-081/AC-4) Throws synchronously rather than returning a soft result. Rationale:
534
+ // silent dedup is exactly the failure mode the AC was written to prevent — a user who rename-collides
535
+ // two features (e.g. typoed `F-DEPLOY` vs `F-deploy`) would have one half their map disappear with
536
+ // no signal. Throwing forces visibility. The error message includes both line numbers so the user
537
+ // can navigate directly to the conflict in their editor.
538
+ // @cap-decision(F-081/iter1) Stage-2 #3 fix: opt-in safe mode. When `options.safe === true`, attach
539
+ // the structured error to `result.parseError` and return the partial map (features parsed up to
540
+ // the first duplicate). Default behavior (no `safe` flag, or explicit `safe:false`) preserves
541
+ // the throw — required by 18 existing duplicate-detection regression tests in cap-feature-map-bullet
542
+ // and cap-feature-map-adversarial, and by tooling that wants hard-fail semantics.
543
+ // @cap-risk(reason:partial-map-on-error) In safe mode the caller receives the features parsed up
544
+ // to (but not including) the duplicate header. This matches the "fail-fast at first collision"
545
+ // semantics of the throw path and gives downstream tooling a useful (if incomplete) view. CLI
546
+ // surfaces should always check `result.parseError` and surface a warning when present.
547
+ const safe = Boolean(options && options.safe === true);
548
+ const seenIds = new Map();
549
+ let parseError;
550
+ for (const origin of featureLineOrigins) {
551
+ const normalized = String(origin.id).toUpperCase().trim();
552
+ if (seenIds.has(normalized)) {
553
+ const firstLine = seenIds.get(normalized);
554
+ const message = `Duplicate feature ID after normalization: ${origin.id} (line ${origin.line}) collides with ${origin.id} (line ${firstLine})`;
555
+ if (safe) {
556
+ parseError = {
557
+ code: 'CAP_DUPLICATE_FEATURE_ID',
558
+ message,
559
+ duplicateId: normalized,
560
+ firstLine,
561
+ duplicateLine: origin.line,
562
+ };
563
+ break;
564
+ }
565
+ const err = new Error(message);
566
+ err.code = 'CAP_DUPLICATE_FEATURE_ID';
567
+ err.duplicateId = normalized;
568
+ err.firstLine = firstLine;
569
+ err.duplicateLine = origin.line;
570
+ throw err;
571
+ }
572
+ seenIds.set(normalized, origin.line);
573
+ }
574
+
575
+ // @cap-todo(ac:F-081/iter1) parseError is only present when set — keeps the result shape minimal
576
+ // for the happy path (zero new property on the 99.9% case).
577
+ if (parseError) {
578
+ return { features, lastScan, parseError };
579
+ }
580
+ return { features, lastScan };
581
+ }
582
+
583
+ // @cap-feature(feature:F-088, primary:true) Surgical-patch helpers — flip status bits directly
584
+ // in the on-disk FEATURE-MAP content via regex substitution, without going through the lossy
585
+ // parse → serialize round-trip. Used by setAcStatus and updateFeatureState (AC-5).
586
+ //
587
+ // @cap-decision(F-088/AC-5) The surgical patcher operates on the canonical bracket-header form
588
+ // (`### F-NNN: Title [state]`) and table-form ACs (`| AC-N | status | desc |`). Bullet-form
589
+ // ACs and legacy `- **Status:** state` headers are NOT yet supported by the patcher; callers
590
+ // fall back to the parse → write path for those, which is acceptable because (a) the bracket
591
+ // form + table form is what 99% of real maps use, (b) bullet/legacy maps tend to be smaller
592
+ // and less likely to have prose lost on round-trip.
593
+
594
+ /**
595
+ * Surgically update the [state] bracket in a feature header line.
596
+ * Returns the new content + a hit flag. If the regex did not match (header in legacy form
597
+ * or feature missing), returns hit=false and the original content unchanged so the caller
598
+ * can fall back to a parse → write flow.
599
+ *
600
+ * @param {string} content
601
+ * @param {string} featureId e.g. "F-001"
602
+ * @param {string} newState
603
+ * @returns {{ content: string, hit: boolean }}
604
+ */
605
+ function _surgicalUpdateFeatureState(content, featureId, newState) {
606
+ // Match: `### F-NNN: Title text [state]` OR `### F-NNN — Title text [state]`
607
+ // Capture the prefix (up to the [) and the closing ] so we can replace just the state token.
608
+ const escapedId = featureId.replace(/[-]/g, '\\-');
609
+ const re = new RegExp(
610
+ '^(###\\s+' + escapedId + '(?::\\s+|\\s+[—–-]\\s+)[^\\n]*?\\[)([^\\]]+)(\\][ \\t]*$)',
611
+ 'm'
612
+ );
613
+ if (!re.test(content)) return { content, hit: false };
614
+ return {
615
+ content: content.replace(re, (_m, before, _state, after) => before + newState + after),
616
+ hit: true,
617
+ };
618
+ }
619
+
620
+ /**
621
+ * Surgically update the status cell of an AC table row inside a specific feature block.
622
+ * Scopes the substitution to the lines between the feature's header and the next feature
623
+ * header (or end-of-file) so two features that share an AC-id (e.g. both have AC-1) don't
624
+ * collide.
625
+ *
626
+ * @param {string} content
627
+ * @param {string} featureId
628
+ * @param {string} acId e.g. "AC-1"
629
+ * @param {string} newStatus
630
+ * @returns {{ content: string, hit: boolean }}
631
+ */
632
+ function _surgicalSetAcStatus(content, featureId, acId, newStatus) {
633
+ const escapedId = featureId.replace(/[-]/g, '\\-');
634
+ const headerRe = new RegExp(
635
+ '^###\\s+' + escapedId + '(?::\\s+|\\s+[—–-]\\s+)',
636
+ 'm'
637
+ );
638
+ const headerMatch = headerRe.exec(content);
639
+ if (!headerMatch) return { content, hit: false };
640
+
641
+ // Find the start of the NEXT feature header (or end of content).
642
+ const blockStart = headerMatch.index;
643
+ const afterHeader = content.slice(blockStart + headerMatch[0].length);
644
+ // @cap-feature(feature:F-089) Next-header regex keeps in sync with FEATURE_ID_PATTERN above.
645
+ const nextHeaderMatch = /^###\s+F-(?:\d{3,}|[A-Z](?:[A-Z0-9_]*[A-Z0-9])?(?:[-_][A-Z0-9_]*[A-Z0-9])*|[A-Z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+)(?::\s+|\s+[—–-]\s+)/m.exec(afterHeader);
646
+ const blockEnd = nextHeaderMatch
647
+ ? blockStart + headerMatch[0].length + nextHeaderMatch.index
648
+ : content.length;
649
+
650
+ const block = content.slice(blockStart, blockEnd);
651
+ const escapedAc = acId.replace(/[-]/g, '\\-');
652
+ // Match: `| AC-N | oldStatus | description |`
653
+ // Capture the AC cell + leading pipe, then the status token, then the trailing pipe + desc.
654
+ const acRowRe = new RegExp(
655
+ '^(\\|\\s*' + escapedAc + '\\s*\\|\\s*)(\\w+)(\\s*\\|)',
656
+ 'm'
657
+ );
658
+ if (!acRowRe.test(block)) return { content, hit: false };
659
+
660
+ const newBlock = block.replace(acRowRe, (_m, prefix, _status, suffix) => prefix + newStatus + suffix);
661
+ return {
662
+ content: content.slice(0, blockStart) + newBlock + content.slice(blockEnd),
663
+ hit: true,
664
+ };
665
+ }
666
+
667
+ /**
668
+ * Surgically update the on-disk FEATURE-MAP file by applying multiple state/AC-status patches
669
+ * in a single read-modify-write cycle. Atomic via .tmp + rename. Bypasses the F-088 shrink
670
+ * guard because we never re-serialize through the lossy path.
671
+ *
672
+ * @param {string} projectRoot
673
+ * @param {string|null|undefined} appPath
674
+ * @param {Array<
675
+ * {kind:'state', featureId:string, newState:string} |
676
+ * {kind:'ac', featureId:string, acId:string, newStatus:string}
677
+ * >} patches
678
+ * @returns {{ ok: boolean, hits: number, misses: Array<object> }}
679
+ */
680
+ function applySurgicalPatches(projectRoot, appPath, patches) {
681
+ // @cap-feature(feature:F-089) Sharded-mode dispatch — route to per-feature surgical patcher.
682
+ if (_shard().isShardedMap(projectRoot, appPath)) {
683
+ return _applyShardedSurgicalPatches(projectRoot, appPath, patches);
684
+ }
685
+ const baseDir = appPath ? path.join(projectRoot, appPath) : projectRoot;
686
+ const filePath = path.join(baseDir, FEATURE_MAP_FILE);
687
+ let content;
688
+ try {
689
+ content = fs.readFileSync(filePath, 'utf8');
690
+ } catch (e) {
691
+ return { ok: false, hits: 0, misses: patches.slice(), error: e.message };
692
+ }
693
+ let hits = 0;
694
+ const misses = [];
695
+ for (const p of patches) {
696
+ let result;
697
+ if (p.kind === 'state') {
698
+ result = _surgicalUpdateFeatureState(content, p.featureId, p.newState);
699
+ } else if (p.kind === 'ac') {
700
+ result = _surgicalSetAcStatus(content, p.featureId, p.acId, p.newStatus);
701
+ } else {
702
+ misses.push(p);
703
+ continue;
704
+ }
705
+ if (result.hit) {
706
+ content = result.content;
707
+ hits++;
708
+ } else {
709
+ misses.push(p);
710
+ }
711
+ }
712
+ if (hits === 0) {
713
+ return { ok: false, hits: 0, misses };
714
+ }
715
+ // Atomic write: tmp + rename
716
+ const tmp = filePath + '.tmp';
717
+ fs.writeFileSync(tmp, content, 'utf8');
718
+ fs.renameSync(tmp, filePath);
719
+ return { ok: true, hits, misses };
720
+ }
721
+
722
+ // @cap-api writeFeatureMap(projectRoot, featureMap, appPath, options) -- Serializes FeatureMap to FEATURE-MAP.md.
723
+ // Side effect: overwrites FEATURE-MAP.md at project root or app subdirectory.
724
+ /**
725
+ * @param {string} projectRoot - Absolute path to project root
726
+ * @param {FeatureMap} featureMap - Structured feature map data
727
+ * @param {string|null} [appPath=null] - Relative app path (e.g., "apps/flow"). If null, writes to projectRoot.
728
+ * @param {{ legacyStatusLine?: boolean, allowShrink?: boolean }} [options] - Serialization options forwarded to serializeFeatureMap.
729
+ */
730
+ function writeFeatureMap(projectRoot, featureMap, appPath, options) {
731
+ // @cap-feature(feature:F-089) Sharded-mode dispatch — route to per-feature writer.
732
+ if (_shard().isShardedMap(projectRoot, appPath)) {
733
+ return _writeShardedMap(projectRoot, featureMap, appPath, options);
734
+ }
735
+ const baseDir = appPath ? path.join(projectRoot, appPath) : projectRoot;
736
+ const filePath = path.join(baseDir, FEATURE_MAP_FILE);
737
+
738
+ // @cap-todo(ac:F-082/AC-8) Round-trip idempotency: preserve the on-disk Rescoped Table on root writes.
739
+ // @cap-decision(F-082/AC-8 strategy-a) Filter feature list to ROOT-only (no metadata.subApp) before
740
+ // serializing; re-inject the Rescoped Table verbatim after serialize. Sub-app mutations require
741
+ // explicit appPath. Without this, aggregated read → write would flatten sub-apps into root.
742
+ let preservedRescopedBlock = null;
743
+ /** @type {Feature[]} */
744
+ let featuresForRoot = featureMap && Array.isArray(featureMap.features) ? featureMap.features : [];
745
+ // @cap-todo(ac:F-082/iter1 warn:7) Warning #7 fix: when the on-disk file vanishes between
746
+ // existsSync and readFileSync (TOCTOU race), abort the write rather than silently flattening.
747
+ // Returning false signals the caller. Pre-iter1 silently fell through and clobbered the
748
+ // Rescoped Table on disk if the file briefly disappeared.
749
+ // @cap-decision(F-082/iter1 warn:7) Hard abort over best-effort write — the alternative is to
750
+ // write a flattened map that destroys the Rescoped Table mid-race. Aborting preserves data
751
+ // integrity at the cost of a single retry.
752
+ let toctouAbort = false;
753
+ if (!appPath && fs.existsSync(filePath)) {
754
+ try {
755
+ const existing = fs.readFileSync(filePath, 'utf8');
756
+ // @cap-todo(ac:F-083/AC-6) Lazy-require — see _monorepo() definition.
757
+ preservedRescopedBlock = _monorepo().extractRescopedBlock(existing);
758
+ } catch (e) {
759
+ // File existed at existsSync but disappeared / unreadable on read → abort.
760
+ console.warn('cap: writeFeatureMap aborted — Rescoped Table preservation failed (TOCTOU): ' + String(e && e.message ? e.message : e).trim());
761
+ toctouAbort = true;
762
+ }
763
+ if (toctouAbort) return false;
764
+ if (preservedRescopedBlock) {
765
+ // Filter out aggregated sub-app features from the root write — they belong to their
766
+ // own FEATURE-MAP.md files and were merged in only at read-time.
767
+ // @cap-todo(ac:F-082/iter1 fix:1) Safety-net: if any sub-app features survived to here,
768
+ // warn loudly. With Fix #1 (monorepo-aware enrichFromTags) this branch should ideally
769
+ // never trigger — but it's a defense-in-depth signal for code paths that bypass the
770
+ // monorepo-aware enrichment helpers.
771
+ const droppedSubApps = new Set();
772
+ let droppedCount = 0;
773
+ for (const f of featuresForRoot) {
774
+ if (f && f.metadata && f.metadata.subApp) {
775
+ droppedSubApps.add(f.metadata.subApp);
776
+ droppedCount++;
777
+ }
778
+ }
779
+ if (droppedCount > 0) {
780
+ console.warn(
781
+ 'cap: writeFeatureMap dropped ' + droppedCount + ' sub-app feature(s) (subApps: ' +
782
+ [...droppedSubApps].sort().join(', ') + '). ' +
783
+ 'Use writeFeatureMap(root, ..., appPath) or call mutation functions per sub-app to persist sub-app changes.'
784
+ );
785
+ }
786
+ featuresForRoot = featuresForRoot.filter(f => !(f && f.metadata && f.metadata.subApp));
787
+ }
788
+ } else if (appPath) {
789
+ // @cap-decision(F-082/iter1 warn:6) Symmetric filter for sub-app writes: drop foreign-subApp and
790
+ // root-direct features. Only warn when filter changed input AND features remain (distinguishes
791
+ // misuse from the legitimate single-map case where features have NO metadata.subApp). Legacy
792
+ // contract preserved: readFeatureMap(root, appPath) returning a no-metadata single-map still works.
793
+ const ownSubApp = path.basename(appPath);
794
+ const featuresInScope = [];
795
+ let droppedForeign = 0;
796
+ for (const f of featuresForRoot) {
797
+ const subApp = f && f.metadata && f.metadata.subApp;
798
+ if (!subApp) { droppedForeign++; continue; }
799
+ if (subApp !== ownSubApp) { droppedForeign++; continue; }
800
+ featuresInScope.push(f);
801
+ }
802
+ if (droppedForeign > 0 && featuresInScope.length > 0) {
803
+ console.warn(
804
+ 'cap: writeFeatureMap (appPath=' + appPath + ') dropped ' + droppedForeign +
805
+ ' feature(s) that did not belong to this sub-app.'
806
+ );
807
+ featuresForRoot = featuresInScope;
808
+ }
809
+ }
810
+
811
+ const filteredMap = { ...featureMap, features: featuresForRoot };
812
+ let content = serializeFeatureMap(filteredMap, options);
813
+
814
+ if (preservedRescopedBlock) {
815
+ // @cap-todo(ac:F-083/AC-6) Lazy-require — see _monorepo() definition.
816
+ content = _monorepo().injectRescopedBlock(content, preservedRescopedBlock);
817
+ }
818
+
819
+ // @cap-todo(ac:F-088/AC-7) Pre-write safety net: refuse to write a FEATURE-MAP that lost
820
+ // more than 50% of its lines vs. the on-disk version. The lossy round-trip in this module
821
+ // silently drops free-text descriptions, **Group:** markers, --- separators, and other
822
+ // unstructured content (real-world: GoetzeInvest hub shrunk 3303 → 1902 lines on a single
823
+ // reconcile run). Until the lossless round-trip lands (AC-1..4), this guard turns silent
824
+ // data loss into a loud, actionable error. The 50% threshold + 50-line floor lets small
825
+ // maps shrink legitimately (e.g. 10-feature project removing half via cleanup) while
826
+ // catching the 3303 → 20 stub-wipe failure mode the memory pitfall warns about.
827
+ if (!options || options.allowShrink !== true) {
828
+ let preExisting = null;
829
+ try {
830
+ preExisting = fs.readFileSync(filePath, 'utf8');
831
+ } catch (_e) {
832
+ preExisting = null; // first write; nothing to compare against
833
+ }
834
+ if (preExisting && preExisting.length > 0) {
835
+ const oldLines = preExisting.split('\n').length;
836
+ const newLines = content.split('\n').length;
837
+ const SAFETY_MIN_LINES = 50; // sanity floor — small maps may legitimately halve
838
+ const SAFETY_RATIO = 0.5;
839
+ if (oldLines >= SAFETY_MIN_LINES && newLines < oldLines * SAFETY_RATIO) {
840
+ const err = new Error(
841
+ 'cap: writeFeatureMap aborted — output is ' + newLines + ' lines, on-disk is ' +
842
+ oldLines + ' (lost ' + (oldLines - newLines) + ' lines, ' +
843
+ Math.round(((oldLines - newLines) / oldLines) * 100) + '% shrink). This is almost ' +
844
+ 'always a lossy round-trip bug (F-088). Pass options.allowShrink:true to override after ' +
845
+ 'verifying the diff.'
846
+ );
847
+ err.code = 'CAP_FEATURE_MAP_SHRINK_GUARD';
848
+ err.oldLines = oldLines;
849
+ err.newLines = newLines;
850
+ throw err;
851
+ }
852
+ }
853
+ }
854
+
855
+ fs.writeFileSync(filePath, content, 'utf8');
856
+ return true;
857
+ }
858
+
859
+ // @cap-feature(feature:F-089) Lazy accessor for cap-feature-map-shard.cjs. Mirrors the F-083
860
+ // _monorepo() pattern. Used inside function bodies (never at top-level) so the shard module
861
+ // stays out of the require cycle and the import order remains stable.
862
+ let _shardCache = null;
863
+ function _shard() {
864
+ if (!_shardCache) _shardCache = require('./cap-feature-map-shard.cjs');
865
+ return _shardCache;
866
+ }
867
+
868
+ /**
869
+ * Read a sharded Feature Map: parse FEATURE-MAP.md (index) for the entry list, then load each
870
+ * features/<id>.md and parse it as a single-feature mini-map. Returns the same FeatureMap shape
871
+ * as monolithic readFeatureMap so all downstream code is shape-compatible.
872
+ *
873
+ * @param {string} projectRoot
874
+ * @param {string|null|undefined} appPath
875
+ * @param {{ safe?: boolean }=} options
876
+ * @returns {FeatureMap}
877
+ */
878
+ function _readShardedMap(projectRoot, appPath, options) {
879
+ const sh = _shard();
880
+ const baseDir = appPath ? path.join(projectRoot, appPath) : projectRoot;
881
+ const indexPath = path.join(baseDir, FEATURE_MAP_FILE);
882
+ const safe = Boolean(options && options.safe === true);
883
+
884
+ let indexContent = '';
885
+ let lastScan = null;
886
+ if (fs.existsSync(indexPath)) {
887
+ indexContent = fs.readFileSync(indexPath, 'utf8');
888
+ const m = indexContent.match(/^\*Last updated:\s*(.+?)\*$/m);
889
+ if (m) lastScan = m[1].trim();
890
+ }
891
+ const indexEntries = sh.parseIndex(indexContent);
892
+
893
+ /** @type {Feature[]} */
894
+ const features = [];
895
+ for (const entry of indexEntries) {
896
+ const filePath = sh.featureFilePath(projectRoot, entry.id, appPath);
897
+ if (!fs.existsSync(filePath)) {
898
+ // Index lists a feature file that doesn't exist — surface a structured warning but continue
899
+ // so partial/recovering states remain readable.
900
+ const msg = 'cap: feature file missing for index entry ' + entry.id + ' (expected ' + filePath + ')';
901
+ if (safe) {
902
+ console.warn(msg);
903
+ continue;
904
+ }
905
+ console.warn(msg);
906
+ continue;
907
+ }
908
+ let blockContent;
909
+ try {
910
+ blockContent = fs.readFileSync(filePath, 'utf8');
911
+ } catch (e) {
912
+ console.warn('cap: failed to read feature file ' + filePath + ': ' + _safeForError(e && e.message));
913
+ continue;
914
+ }
915
+ const parsed = parseFeatureMapContent(blockContent, { projectRoot, safe });
916
+ if (parsed.parseError) {
917
+ // Per-feature parse error — skip this feature, surface warning. Sharded model isolates
918
+ // damage to a single feature instead of poisoning the whole map.
919
+ console.warn('cap: parseError in ' + filePath + ': ' + _safeForError(parsed.parseError.message));
920
+ continue;
921
+ }
922
+ if (parsed.features.length === 0) {
923
+ console.warn('cap: feature file produced no parsed feature: ' + filePath);
924
+ continue;
925
+ }
926
+ // Trust the per-feature file content; the index entry is just a summary cache.
927
+ const feature = parsed.features[0];
928
+ // If the index says a different state from the per-feature file, the per-feature file wins
929
+ // (it's the authoritative source). The index is best-effort summary.
930
+ features.push(feature);
931
+ }
932
+
933
+ return { features, lastScan };
934
+ }
935
+
936
+ /**
937
+ * Write a sharded Feature Map: per-feature file for each feature + atomic index update.
938
+ * Used by the writeFeatureMap dispatcher when the project is in sharded mode.
939
+ *
940
+ * @param {string} projectRoot
941
+ * @param {FeatureMap} featureMap
942
+ * @param {string|null|undefined} appPath
943
+ * @param {{ legacyStatusLine?: boolean, allowShrink?: boolean }=} options
944
+ * @returns {boolean}
945
+ */
946
+ function _writeShardedMap(projectRoot, featureMap, appPath, options) {
947
+ const sh = _shard();
948
+ const baseDir = appPath ? path.join(projectRoot, appPath) : projectRoot;
949
+ const indexPath = path.join(baseDir, FEATURE_MAP_FILE);
950
+ const featuresDir = sh.featuresDirPath(projectRoot, appPath);
951
+
952
+ fs.mkdirSync(featuresDir, { recursive: true });
953
+
954
+ const features = (featureMap && Array.isArray(featureMap.features)) ? featureMap.features : [];
955
+ /** @type {import('./cap-feature-map-shard.cjs').IndexEntry[]} */
956
+ const indexEntries = [];
957
+
958
+ for (const feature of features) {
959
+ if (!feature || !sh.validateFeatureId(feature.id)) continue;
960
+ const target = sh.featureFilePath(projectRoot, feature.id, appPath);
961
+ // Per-feature serialization: emit only THIS feature's block (header + Depends + ACs + Files).
962
+ // We construct a single-feature mini-FeatureMap and reuse the existing serializer, then strip
963
+ // the global header/Legend/footer so only the feature block remains.
964
+ const singleMap = {
965
+ features: [feature],
966
+ lastScan: null,
967
+ };
968
+ const fullSerialized = serializeFeatureMap(singleMap, options);
969
+ // Extract just the feature block: from `### F-` to the next `## ` (Legend) or to start of footer.
970
+ const blockMatch = /(^### F-[\s\S]+?)(\n## Legend|\n---\n\*Last updated:)/m.exec(fullSerialized);
971
+ const blockContent = blockMatch ? blockMatch[1].replace(/\s+$/, '') + '\n' : fullSerialized;
972
+ // Atomic write
973
+ const tmp = target + '.tmp';
974
+ fs.writeFileSync(tmp, blockContent, 'utf8');
975
+ fs.renameSync(tmp, target);
976
+ indexEntries.push({ id: feature.id, state: feature.state, title: feature.title });
977
+ }
978
+
979
+ // Re-build index. We do NOT preserve any prose between feature entries — the sharded mode
980
+ // moves prose into the per-feature files, so the index is always clean header + entries + Legend.
981
+ const indexContent = sh.serializeIndex(indexEntries);
982
+ const tmpIdx = indexPath + '.tmp';
983
+ fs.writeFileSync(tmpIdx, indexContent, 'utf8');
984
+ fs.renameSync(tmpIdx, indexPath);
985
+ return true;
986
+ }
987
+
988
+ /**
989
+ * Apply F-088-style surgical patches in sharded mode. Routes each patch by featureId to the
990
+ * relevant per-feature file. State changes also surgical-patch the index entry.
991
+ *
992
+ * @param {string} projectRoot
993
+ * @param {string|null|undefined} appPath
994
+ * @param {Array<
995
+ * {kind:'state', featureId:string, newState:string} |
996
+ * {kind:'ac', featureId:string, acId:string, newStatus:string}
997
+ * >} patches
998
+ * @returns {{ ok: boolean, hits: number, misses: Array<object> }}
999
+ */
1000
+ function _applyShardedSurgicalPatches(projectRoot, appPath, patches) {
1001
+ const sh = _shard();
1002
+ const baseDir = appPath ? path.join(projectRoot, appPath) : projectRoot;
1003
+ const indexPath = path.join(baseDir, FEATURE_MAP_FILE);
1004
+
1005
+ // Group patches by featureId so we apply all changes for one file in a single read-modify-write.
1006
+ /** @type {Map<string, Array<object>>} */
1007
+ const byFeature = new Map();
1008
+ for (const p of patches) {
1009
+ if (!p || !p.featureId || !sh.validateFeatureId(p.featureId)) continue;
1010
+ if (!byFeature.has(p.featureId)) byFeature.set(p.featureId, []);
1011
+ byFeature.get(p.featureId).push(p);
1012
+ }
1013
+
1014
+ let hits = 0;
1015
+ const misses = [];
1016
+
1017
+ // Per-feature file patches.
1018
+ for (const [featureId, featurePatches] of byFeature.entries()) {
1019
+ const filePath = sh.featureFilePath(projectRoot, featureId, appPath);
1020
+ let content;
1021
+ try {
1022
+ content = fs.readFileSync(filePath, 'utf8');
1023
+ } catch (_e) {
1024
+ // Feature file doesn't exist — record as miss for every patch.
1025
+ for (const p of featurePatches) misses.push(p);
1026
+ continue;
1027
+ }
1028
+ let modified = false;
1029
+ for (const p of featurePatches) {
1030
+ let result;
1031
+ if (p.kind === 'state') {
1032
+ result = _surgicalUpdateFeatureState(content, p.featureId, p.newState);
1033
+ } else if (p.kind === 'ac') {
1034
+ result = _surgicalSetAcStatus(content, p.featureId, p.acId, p.newStatus);
1035
+ } else {
1036
+ misses.push(p);
1037
+ continue;
1038
+ }
1039
+ if (result.hit) {
1040
+ content = result.content;
1041
+ modified = true;
1042
+ hits++;
1043
+ } else {
1044
+ misses.push(p);
1045
+ }
1046
+ }
1047
+ if (modified) {
1048
+ const tmp = filePath + '.tmp';
1049
+ fs.writeFileSync(tmp, content, 'utf8');
1050
+ fs.renameSync(tmp, filePath);
1051
+ }
1052
+ }
1053
+
1054
+ // Index state-column updates: for every successful 'state' patch, surgical-patch the index too.
1055
+ if (fs.existsSync(indexPath)) {
1056
+ let indexContent = fs.readFileSync(indexPath, 'utf8');
1057
+ let indexChanged = false;
1058
+ for (const [featureId, featurePatches] of byFeature.entries()) {
1059
+ const stateP = featurePatches.find(p => p.kind === 'state');
1060
+ if (!stateP) continue;
1061
+ const r = sh._updateIndexEntry(indexContent, featureId, { state: stateP.newState });
1062
+ if (r.hit) {
1063
+ indexContent = r.content;
1064
+ indexChanged = true;
1065
+ }
1066
+ }
1067
+ if (indexChanged) {
1068
+ const tmp = indexPath + '.tmp';
1069
+ fs.writeFileSync(tmp, indexContent, 'utf8');
1070
+ fs.renameSync(tmp, indexPath);
1071
+ }
1072
+ }
1073
+
1074
+ if (hits === 0) {
1075
+ return { ok: false, hits: 0, misses };
1076
+ }
1077
+ return { ok: true, hits, misses };
1078
+ }
1079
+
1080
+ // @cap-feature(feature:F-082) _safeForError — sanitize a user-controlled value before
1081
+ // interpolating it into console.warn/error. Strips non-printable bytes (incl. ANSI CSI, BEL,
1082
+ // BS, NUL) and caps length at 256. F-076/F-077/F-081 doctrine.
1083
+ // @cap-decision(F-083/balance) Stays in core: used by writeFeatureMap (TOCTOU warn) AND by the
1084
+ // monorepo enrichment helpers via lazy-require — hosting it in core keeps the cycle accessor
1085
+ // list shorter and avoids an inverse import edge.
1086
+ /**
1087
+ * @param {*} value - any user-controlled value to be interpolated into a warn message
1088
+ * @param {number} [maxLen=256] - max output length before truncation
1089
+ * @returns {string}
1090
+ */
1091
+ function _safeForError(value, maxLen = 256) {
1092
+ let s;
1093
+ try {
1094
+ s = String(value);
1095
+ } catch (_e) {
1096
+ s = '<unprintable>';
1097
+ }
1098
+ // Strip any non-printable byte (incl. ESC, BEL, BS, NUL). Keep printable ASCII + multibyte UTF-8
1099
+ // (codepoints >= 0x20). This neutralizes ANSI CSI sequences regardless of how they're wrapped.
1100
+ // eslint-disable-next-line no-control-regex
1101
+ s = s.replace(/[\x00-\x1f\x7f]/g, '');
1102
+ s = s.trim();
1103
+ if (s.length > maxLen) s = s.slice(0, maxLen) + '…';
1104
+ return s;
1105
+ }
1106
+
1107
+ // @cap-feature(feature:F-083) Lazy accessor for cap-feature-map-monorepo.cjs. Used INSIDE
1108
+ // function bodies (never at top-level) to break the cycle between core and monorepo.
1109
+ // The monorepo module's mirror is `_core()` in cap-feature-map-monorepo.cjs.
1110
+ // @cap-decision(F-083/cycle) Lazy-require both directions; AC-6 static-analysis test pins
1111
+ // the no-cycle contract.
1112
+ let _monorepoCache = null;
1113
+ function _monorepo() {
1114
+ if (!_monorepoCache) _monorepoCache = require('./cap-feature-map-monorepo.cjs');
1115
+ return _monorepoCache;
1116
+ }
1117
+
1118
+ // @cap-feature(feature:F-041) Serializer is the write half of the symmetric pair.
1119
+ // It must preserve every status value the parser accepted (AC-1) and offer a legacy
1120
+ // **Status:** line emission mode (AC-6) so the legacy non-table input format is not
1121
+ // forcibly upgraded to bracketed-header format on the first roundtrip.
1122
+ // @cap-feature(feature:F-081) Bullet/table-aware serializer — preserves the AC format
1123
+ // the parser saw on the way in (Stage-2 #2 fix).
1124
+ /**
1125
+ * Serialize FeatureMap to markdown string.
1126
+ * @param {FeatureMap} featureMap
1127
+ * @param {{ legacyStatusLine?: boolean, featureMapStyle?: ('table'|'bullet') }} [options]
1128
+ * - legacyStatusLine: when true, emit `### F-NNN: Title` followed by `- **Status:** state`
1129
+ * instead of `### F-NNN: Title [state]`. Default false (canonical bracket-header form).
1130
+ * - featureMapStyle: F-081/iter1 — global override for AC format. Resolution order:
1131
+ * per-feature `_inputFormat` (set by parser) > options.featureMapStyle > 'table' default.
1132
+ * @returns {string}
1133
+ */
1134
+ function serializeFeatureMap(featureMap, options = {}) {
1135
+ // @cap-todo(ac:F-041/AC-6) Optional legacy emission keeps non-table input shape stable.
1136
+ const legacyStatusLine = Boolean(options && options.legacyStatusLine);
1137
+ // @cap-todo(ac:F-081/iter1) Resolve global format style from options. Default 'table' preserves
1138
+ // pre-iter1 behavior — features without _inputFormat (e.g. created via addFeature on a fresh
1139
+ // project) keep emitting tables unless the project explicitly opts into bullets via the option.
1140
+ const globalStyle =
1141
+ options && (options.featureMapStyle === 'bullet' || options.featureMapStyle === 'table')
1142
+ ? options.featureMapStyle
1143
+ : null;
1144
+ const lines = [
1145
+ '# Feature Map',
1146
+ '',
1147
+ '> Single source of truth for feature identity, state, acceptance criteria, and relationships.',
1148
+ '> Auto-enriched by `@cap-feature` tags and dependency analysis.',
1149
+ '',
1150
+ '## Features',
1151
+ '',
1152
+ ];
1153
+
1154
+ for (const feature of featureMap.features) {
1155
+ // @cap-todo(ac:F-041/AC-1) feature.state is emitted verbatim — no case mutation,
1156
+ // so any value the parser accepted survives the roundtrip unchanged.
1157
+ if (legacyStatusLine) {
1158
+ lines.push(`### ${feature.id}: ${feature.title}`);
1159
+ lines.push('');
1160
+ lines.push(`- **Status:** ${feature.state}`);
1161
+ } else {
1162
+ lines.push(`### ${feature.id}: ${feature.title} [${feature.state}]`);
1163
+ }
1164
+ lines.push('');
1165
+
1166
+ if (feature.dependencies.length > 0) {
1167
+ lines.push(`**Depends on:** ${feature.dependencies.join(', ')}`);
1168
+ lines.push('');
1169
+ }
1170
+
1171
+ // @cap-todo(ac:F-063/AC-3) Serialize **Uses design:** only when non-empty — additive, backward-compatible.
1172
+ // Unset / empty arrays emit nothing so existing F-062-era FEATURE-MAP.md files roundtrip byte-identical.
1173
+ if (Array.isArray(feature.usesDesign) && feature.usesDesign.length > 0) {
1174
+ lines.push(`**Uses design:** ${feature.usesDesign.join(', ')}`);
1175
+ lines.push('');
1176
+ }
1177
+
1178
+ if (feature.acs.length > 0) {
1179
+ // @cap-todo(ac:F-081/iter1) Per-feature format resolution: feature._inputFormat (from parser)
1180
+ // > options.featureMapStyle (caller override) > 'table' (legacy default).
1181
+ // @cap-decision(F-081/iter1) Per-feature wins over global option: if a single mixed-format
1182
+ // FEATURE-MAP.md has some bullet features and some table features (e.g. mid-migration),
1183
+ // round-tripping must preserve each one independently.
1184
+ const featureStyle =
1185
+ feature && feature._inputFormat === 'bullet'
1186
+ ? 'bullet'
1187
+ : feature && feature._inputFormat === 'table'
1188
+ ? 'table'
1189
+ : globalStyle || 'table';
1190
+
1191
+ if (featureStyle === 'bullet') {
1192
+ // @cap-todo(ac:F-081/iter1) Bullet emission: `- [x] AC-N: description` for tested,
1193
+ // `- [ ] AC-N: description` otherwise. Mirrors the canonical bullet shape the parser
1194
+ // accepts at line ~217 (bulletAcRE).
1195
+ // @cap-risk(reason:status-bullet-mapping) Bullet form has only two checkbox states
1196
+ // ([ ] / [x]) but the AC schema has 4 statuses (pending/prototyped/tested/implemented).
1197
+ // We map: tested -> [x]; everything else -> [ ]. This is lossy: a 'prototyped' or
1198
+ // 'implemented' AC round-trips as 'pending' through bullet-only storage. The intermediate
1199
+ // states are runtime/transitional in canonical CAP usage, so the loss is acceptable for
1200
+ // now. If this becomes user-visible, switch the bullet emitter to honor a `[?]` token
1201
+ // for 'prototyped' or fall back to table form on mixed-status features.
1202
+ // @cap-todo(ref:future-feature) Stage-2 #8 follow-up: enrichFromScan writes 'implemented'
1203
+ // status which has no faithful bullet representation. Defer to a follow-up feature that
1204
+ // defines a richer bullet token set or a hybrid emission policy.
1205
+ for (const ac of feature.acs) {
1206
+ const checked = ac.status === 'tested' ? 'x' : ' ';
1207
+ // Empty descriptions emit no trailing space — matches the parser's empty-desc shape.
1208
+ const desc = ac.description ? ` ${ac.description}` : '';
1209
+ lines.push(`- [${checked}] ${ac.id}:${desc}`);
1210
+ }
1211
+ lines.push('');
1212
+ } else {
1213
+ lines.push('| AC | Status | Description |');
1214
+ lines.push('|----|--------|-------------|');
1215
+ for (const ac of feature.acs) {
1216
+ // @cap-todo(ac:F-041/AC-1) ac.status emitted verbatim for lossless roundtrip.
1217
+ lines.push(`| ${ac.id} | ${ac.status} | ${ac.description} |`);
1218
+ }
1219
+ lines.push('');
1220
+ }
1221
+ }
1222
+
1223
+ if (feature.files.length > 0) {
1224
+ lines.push('**Files:**');
1225
+ for (const file of feature.files) {
1226
+ lines.push(`- \`${file}\``);
1227
+ }
1228
+ lines.push('');
1229
+ }
1230
+ }
1231
+
1232
+ if (featureMap.features.length === 0) {
1233
+ lines.push('<!-- No features yet. Run /cap:brainstorm or add features with addFeature(). -->');
1234
+ lines.push('');
1235
+ }
1236
+
1237
+ lines.push('## Legend');
1238
+ lines.push('');
1239
+ lines.push('| State | Meaning |');
1240
+ lines.push('|-------|---------|');
1241
+ lines.push('| planned | Feature identified, not yet implemented |');
1242
+ lines.push('| prototyped | Initial implementation exists |');
1243
+ lines.push('| tested | Tests written and passing |');
1244
+ lines.push('| shipped | Deployed / merged to main |');
1245
+ lines.push('');
1246
+ lines.push('---');
1247
+ lines.push(`*Last updated: ${new Date().toISOString()}*`);
1248
+ lines.push('');
1249
+
1250
+ return lines.join('\n');
1251
+ }
1252
+
1253
+ // @cap-api addFeature(projectRoot, feature, appPath) -- Add a new feature entry to FEATURE-MAP.md.
1254
+ // @cap-decision(F-082/asymmetry) addFeature does NOT auto-redirect to a sub-app via
1255
+ // _maybeRedirectToSubApp, unlike updateFeatureState/setAcStatus/setFeatureUsesDesign.
1256
+ // Reasoning: a NEW feature has no metadata.subApp yet, so there is nothing to redirect
1257
+ // FROM. Sub-app placement is determined at write-time by the caller passing `appPath`
1258
+ // explicitly. This asymmetry is INTENTIONAL — do not "fix" it without first considering
1259
+ // where new features should land in a monorepo (currently always the scope named by
1260
+ // `appPath`, defaulting to root; opt-in to a sub-app via explicit `appPath`).
1261
+ /**
1262
+ * @param {string} projectRoot - Absolute path to project root
1263
+ * @param {{ title: string, acs?: AcceptanceCriterion[], dependencies?: string[], metadata?: Object }} feature - Feature data (ID auto-generated)
1264
+ * @param {string|null} [appPath=null] - Relative app path for monorepo scoping
1265
+ * @returns {Feature} - The added feature with generated ID
1266
+ */
1267
+ function addFeature(projectRoot, feature, appPath) {
1268
+ // @cap-todo(ac:F-081/AC-4 iter:2) Migrated to {safe: true} opt-in to preserve CLI on duplicate-ID FEATURE-MAP.
1269
+ // @cap-decision(F-081/iter2) Bail on parseError — do not persist partial enrichment.
1270
+ const featureMap = readFeatureMap(projectRoot, appPath, { safe: true });
1271
+ if (featureMap.parseError) {
1272
+ console.warn('cap: addFeature aborted — duplicate feature ID detected: ' + String(featureMap.parseError.message).trim());
1273
+ return null;
1274
+ }
1275
+ const id = getNextFeatureId(featureMap.features);
1276
+ // @cap-todo(ac:F-081/iter1) Inherit dominant AC format from existing features so a bullet-style
1277
+ // FEATURE-MAP.md does not get a stray table-style entry on addFeature. If existing features
1278
+ // are mostly bullets, new feature defaults to bullets. Pure-table or empty maps keep table.
1279
+ // @cap-decision(F-081/iter1) Use simple majority on existing features. Ties break toward
1280
+ // 'table' (the legacy default). Empty maps return 'table' (no signal to flip the default).
1281
+ let inheritedFormat = 'table';
1282
+ let bulletCount = 0;
1283
+ let tableCount = 0;
1284
+ for (const f of featureMap.features) {
1285
+ if (f._inputFormat === 'bullet') bulletCount++;
1286
+ else if (f._inputFormat === 'table') tableCount++;
1287
+ }
1288
+ if (bulletCount > tableCount) inheritedFormat = 'bullet';
1289
+ const newFeature = {
1290
+ id,
1291
+ title: feature.title,
1292
+ state: 'planned',
1293
+ acs: feature.acs || [],
1294
+ files: [],
1295
+ dependencies: feature.dependencies || [],
1296
+ usesDesign: feature.usesDesign || [], // F-063: default-empty DT/DC IDs list.
1297
+ metadata: feature.metadata || {},
1298
+ _inputFormat: inheritedFormat,
1299
+ };
1300
+ featureMap.features.push(newFeature);
1301
+ writeFeatureMap(projectRoot, featureMap, appPath);
1302
+ return newFeature;
1303
+ }
1304
+
1305
+ // @cap-feature(feature:F-042) Propagate Feature State Transitions to Acceptance Criteria —
1306
+ // extends updateFeatureState with AC propagation and a shipped-gate so feature/AC status cannot drift.
1307
+ // @cap-decision(feature:F-042) Canonical AC status set for setAcStatus / propagation is
1308
+ // pending | prototyped | tested. Legacy 'implemented' / 'reviewed' values that the parser may have
1309
+ // read from older Feature Maps are tolerated on read but never written by this module.
1310
+ const AC_VALID_STATUSES = ['pending', 'prototyped', 'tested'];
1311
+
1312
+ // @cap-api updateFeatureState(projectRoot, featureId, newState, appPath) -- Transition feature state.
1313
+ // @cap-todo(ref:AC-9) Enforce valid state transitions: planned->prototyped->tested->shipped
1314
+ // @cap-todo(ac:F-042/AC-1) Propagate transitions to ACs: tested promotes pending/prototyped ACs to tested.
1315
+ // @cap-todo(ac:F-042/AC-2) Propagation rule: prototyped does not change AC status; tested promotes
1316
+ // pending/prototyped ACs to tested; shipped requires all ACs already tested and rejects otherwise.
1317
+ // @cap-decision(feature:F-042) The shipped-gate REJECTS the transition by returning false (no throw).
1318
+ // Rationale: the existing updateFeatureState contract already returns false for any invalid transition
1319
+ // (unknown feature, illegal state hop, unknown state name). Throwing on the new gate would break every
1320
+ // caller that today relies on a boolean signal. The drift report (detectDrift) is the structured
1321
+ // diagnostic surface; updateFeatureState stays a simple predicate.
1322
+ /**
1323
+ * @param {string} projectRoot - Absolute path to project root
1324
+ * @param {string} featureId - Feature ID (e.g., "F-001")
1325
+ * @param {string} newState - Target state
1326
+ * @param {string|null} [appPath=null] - Relative app path for monorepo scoping
1327
+ * @returns {boolean} - True if transition was valid and applied
1328
+ */
1329
+ function updateFeatureState(projectRoot, featureId, newState, appPath) {
1330
+ if (!VALID_STATES.includes(newState)) return false;
1331
+
1332
+ // @cap-todo(ac:F-081/AC-4 iter:2) Migrated to {safe: true} opt-in to preserve CLI on duplicate-ID FEATURE-MAP.
1333
+ // @cap-decision(F-081/iter2) Bail on parseError — do not persist partial enrichment.
1334
+ const featureMap = readFeatureMap(projectRoot, appPath, { safe: true });
1335
+ if (featureMap.parseError) {
1336
+ console.warn('cap: updateFeatureState aborted — duplicate feature ID detected: ' + String(featureMap.parseError.message).trim());
1337
+ return false;
1338
+ }
1339
+ const feature = featureMap.features.find(f => f.id === featureId);
1340
+ if (!feature) return false;
1341
+
1342
+ // @cap-todo(ac:F-082/iter1 fix:2) Auto-redirect: if the looked-up feature lives in a sub-app
1343
+ // (metadata.subApp set) and the caller did not supply appPath, recurse with the sub-app
1344
+ // appPath so the mutation lands in the correct file. This eliminates the silent no-op
1345
+ // reported by Stage-2 #2 — root-scope writes against a sub-app feature USED to filter
1346
+ // the feature out and write nothing; now they explicitly route to the sub-app file.
1347
+ // @cap-decision(F-082/iter1 fix:2) Auto-redirect over loud rejection: more helpful UX, mirrors
1348
+ // the F-081 round-trip-asymmetry fix (silent loss → loud success). Recursion guard via the
1349
+ // `appPath` argument — when the recursion runs, appPath is set, so this branch can never
1350
+ // re-trigger.
1351
+ // @cap-risk(F-082/iter1) When `_subAppPrefixes` cannot resolve the slug (shouldn't happen
1352
+ // for an aggregated map, but might for a hand-built map fed through unsupported paths),
1353
+ // we fall back to a loud structured rejection. The console.warn names the sub-app slug so
1354
+ // the user knows which appPath to pass.
1355
+ // @cap-todo(ac:F-083/AC-6) Lazy-require monorepo helpers — see _monorepo() definition.
1356
+ const _mr = _monorepo();
1357
+ const redirectResult = _mr._maybeRedirectToSubApp(
1358
+ projectRoot, featureMap, feature, appPath, 'updateFeatureState',
1359
+ (resolvedAppPath) => updateFeatureState(projectRoot, featureId, newState, resolvedAppPath)
1360
+ );
1361
+ if (redirectResult !== _mr._NO_REDIRECT) return redirectResult;
1362
+
1363
+ const allowed = STATE_TRANSITIONS[feature.state];
1364
+ if (!allowed || !allowed.includes(newState)) return false;
1365
+
1366
+ // @cap-todo(ac:F-042/AC-2) shipped-gate: reject if any AC is not yet 'tested'.
1367
+ // Empty AC list is treated as "no obligations" and is allowed through — matches the
1368
+ // pre-F-042 behaviour where features without ACs could still be shipped.
1369
+ if (newState === 'shipped') {
1370
+ const blocking = feature.acs.filter(a => a.status !== 'tested');
1371
+ if (blocking.length > 0) return false;
1372
+
1373
+ // @cap-todo(ac:F-048/AC-3) Completeness-score gate — only enforces when config is opted in.
1374
+ // @cap-decision Silent failure (return false) preserves updateFeatureState's boolean contract.
1375
+ // Callers wanting the reason string can use transitionWithReason() instead.
1376
+ try {
1377
+ const { checkShipGate } = require('./cap-completeness.cjs');
1378
+ const gate = checkShipGate(featureId, newState, projectRoot);
1379
+ if (!gate.allowed) return false;
1380
+ } catch (_e) {
1381
+ // Completeness module unavailable — allow through for backwards compat.
1382
+ }
1383
+ }
1384
+
1385
+ feature.state = newState;
1386
+
1387
+ // @cap-todo(ac:F-042/AC-1) Promote ACs on transition to tested.
1388
+ if (newState === 'tested') {
1389
+ for (const ac of feature.acs) {
1390
+ if (ac.status === 'pending' || ac.status === 'prototyped') {
1391
+ ac.status = 'tested';
1392
+ }
1393
+ }
1394
+ }
1395
+ // 'planned' and 'prototyped' transitions intentionally leave ACs untouched.
1396
+
1397
+ // @cap-todo(ac:F-088/AC-5) Try the surgical-patch path first to avoid the lossy round-trip.
1398
+ // Build a patch list: (1) the feature state, (2) any AC promotions performed above. If the
1399
+ // patcher hits all of them, we're done. Otherwise (legacy header / bullet ACs / unusual
1400
+ // format) fall back to the original parse → write flow.
1401
+ const patches = [{ kind: 'state', featureId, newState }];
1402
+ if (newState === 'tested') {
1403
+ for (const ac of feature.acs) {
1404
+ if (ac.status === 'tested') {
1405
+ patches.push({ kind: 'ac', featureId, acId: ac.id, newStatus: 'tested' });
1406
+ }
1407
+ }
1408
+ }
1409
+ const surgical = applySurgicalPatches(projectRoot, appPath, patches);
1410
+ if (surgical.ok && surgical.misses.length === 0) {
1411
+ return true;
1412
+ }
1413
+
1414
+ // Fallback: legacy parse → serialize path. Still gated by the F-088 shrink-guard so a lossy
1415
+ // write is loud rather than silent.
1416
+ writeFeatureMap(projectRoot, featureMap, appPath);
1417
+ return true;
1418
+ }
1419
+
1420
+ // @cap-feature(feature:F-048) Transition a feature state and return a structured reason on rejection.
1421
+ // @cap-decision Additive API. updateFeatureState's boolean contract is preserved so existing callers
1422
+ // do not break; transitionWithReason exposes the completeness-score gate's reason text for UIs that
1423
+ // want to explain why a shipped transition was blocked.
1424
+ /**
1425
+ * Same as updateFeatureState but returns a structured result including a rejection reason.
1426
+ * Used by /cap:completeness-report and CLI surfaces that want to surface the gate reason.
1427
+ * @param {string} projectRoot
1428
+ * @param {string} featureId
1429
+ * @param {string} newState
1430
+ * @param {string|null} [appPath=null]
1431
+ * @returns {{ ok: boolean, reason: string|null, score: number|null }}
1432
+ */
1433
+ function transitionWithReason(projectRoot, featureId, newState, appPath) {
1434
+ // Pre-check the completeness gate so we can provide a reason. updateFeatureState
1435
+ // re-checks it internally for consistency (defense in depth against stale config loads).
1436
+ if (newState === 'shipped') {
1437
+ try {
1438
+ const { checkShipGate } = require('./cap-completeness.cjs');
1439
+ const gate = checkShipGate(featureId, newState, projectRoot);
1440
+ if (!gate.allowed) {
1441
+ return { ok: false, reason: gate.reason, score: gate.score };
1442
+ }
1443
+ } catch (_e) { /* completeness module unavailable — proceed */ }
1444
+ }
1445
+ const ok = updateFeatureState(projectRoot, featureId, newState, appPath);
1446
+ return { ok, reason: ok ? null : 'State transition rejected by feature-map validation (wrong source state, missing AC tested status, or invalid target).', score: null };
1447
+ }
1448
+
1449
+ // @cap-feature(feature:F-042) setAcStatus — explicit per-AC mutation (AC-3).
1450
+ // @cap-todo(ac:F-042/AC-3) New function setAcStatus(projectRoot, featureId, acId, newStatus, appPath)
1451
+ // for finer-grained per-AC state changes. Does NOT propagate upward to feature state.
1452
+ /**
1453
+ * Explicitly set the status of a single AC. Does not modify feature state.
1454
+ * @param {string} projectRoot - Absolute path to project root
1455
+ * @param {string} featureId - Feature ID (e.g., "F-001")
1456
+ * @param {string} acId - AC ID (e.g., "AC-1")
1457
+ * @param {string} newStatus - One of AC_VALID_STATUSES (pending | prototyped | tested)
1458
+ * @param {string|null} [appPath=null] - Relative app path for monorepo scoping
1459
+ * @returns {boolean} - True if the AC was found and updated, false otherwise
1460
+ */
1461
+ function setAcStatus(projectRoot, featureId, acId, newStatus, appPath) {
1462
+ if (!AC_VALID_STATUSES.includes(newStatus)) return false;
1463
+
1464
+ // @cap-todo(ac:F-081/AC-4 iter:2) Migrated to {safe: true} opt-in to preserve CLI on duplicate-ID FEATURE-MAP.
1465
+ // @cap-decision(F-081/iter2) Bail on parseError — do not persist partial enrichment.
1466
+ const featureMap = readFeatureMap(projectRoot, appPath, { safe: true });
1467
+ if (featureMap.parseError) {
1468
+ console.warn('cap: setAcStatus aborted — duplicate feature ID detected: ' + String(featureMap.parseError.message).trim());
1469
+ return false;
1470
+ }
1471
+ const feature = featureMap.features.find(f => f.id === featureId);
1472
+ if (!feature) return false;
1473
+
1474
+ // @cap-todo(ac:F-082/iter1 fix:2) Auto-redirect to sub-app when feature lives there. See
1475
+ // updateFeatureState for the full lesson.
1476
+ // @cap-todo(ac:F-083/AC-6) Lazy-require monorepo helpers — see _monorepo() definition.
1477
+ const _mrSetAc = _monorepo();
1478
+ const redirectResult = _mrSetAc._maybeRedirectToSubApp(
1479
+ projectRoot, featureMap, feature, appPath, 'setAcStatus',
1480
+ (resolvedAppPath) => setAcStatus(projectRoot, featureId, acId, newStatus, resolvedAppPath)
1481
+ );
1482
+ if (redirectResult !== _mrSetAc._NO_REDIRECT) return redirectResult;
1483
+
1484
+ const ac = feature.acs.find(a => a.id === acId);
1485
+ if (!ac) return false;
1486
+
1487
+ ac.status = newStatus;
1488
+
1489
+ // @cap-todo(ac:F-088/AC-5) Surgical-patch fast path — avoid the lossy round-trip when the
1490
+ // FEATURE-MAP uses the canonical bracket-header + table-AC form. Falls back to the parse →
1491
+ // write path for legacy / bullet-form features.
1492
+ const surgical = applySurgicalPatches(projectRoot, appPath, [
1493
+ { kind: 'ac', featureId, acId, newStatus },
1494
+ ]);
1495
+ if (surgical.ok && surgical.misses.length === 0) {
1496
+ return true;
1497
+ }
1498
+
1499
+ writeFeatureMap(projectRoot, featureMap, appPath);
1500
+ return true;
1501
+ }
1502
+
1503
+ /**
1504
+ * @typedef {Object} DriftEntry
1505
+ * @property {string} id - Feature ID
1506
+ * @property {string} title - Feature title
1507
+ * @property {string} state - Feature state (always 'tested' or 'shipped' in a drift entry)
1508
+ * @property {{id: string, description: string}[]} pendingAcs - ACs still in 'pending' status
1509
+ * @property {number} totalAcs - Total AC count for this feature
1510
+ */
1511
+
1512
+ /**
1513
+ * @typedef {Object} DriftReport
1514
+ * @property {boolean} hasDrift - True if any features show drift
1515
+ * @property {number} driftCount - Number of features with drift
1516
+ * @property {DriftEntry[]} features - Per-feature drift details
1517
+ */
1518
+
1519
+ // @cap-feature(feature:F-042) detectDrift — pure diagnostic over the parsed Feature Map (AC-4).
1520
+ // @cap-todo(ac:F-042/AC-4) Status drift detection: flag features where state is shipped/tested but
1521
+ // one or more ACs are still pending. Returns a structured DriftReport. No console output, no writes.
1522
+ /**
1523
+ * Detect features whose feature state is 'shipped' or 'tested' but where ACs remain 'pending'.
1524
+ * @param {string} projectRoot - Absolute path to project root
1525
+ * @param {string|null} [appPath=null] - Relative app path for monorepo scoping
1526
+ * @returns {DriftReport}
1527
+ */
1528
+ function detectDrift(projectRoot, appPath) {
1529
+ // @cap-todo(ac:F-081/AC-4 iter:2) Migrated to {safe: true} opt-in to preserve CLI on duplicate-ID FEATURE-MAP.
1530
+ // @cap-decision(F-081/iter2) Warn on parseError; continue with partial map for read-only display.
1531
+ const featureMap = readFeatureMap(projectRoot, appPath, { safe: true });
1532
+ if (featureMap.parseError) {
1533
+ console.warn('cap: detectDrift — duplicate feature ID detected, drift report uses partial map: ' + String(featureMap.parseError.message).trim());
1534
+ }
1535
+ const driftFeatures = [];
1536
+
1537
+ for (const f of featureMap.features) {
1538
+ if (f.state !== 'shipped' && f.state !== 'tested') continue;
1539
+ const pendingAcs = f.acs.filter(a => a.status === 'pending');
1540
+ if (pendingAcs.length === 0) continue;
1541
+
1542
+ driftFeatures.push({
1543
+ id: f.id,
1544
+ title: f.title,
1545
+ state: f.state,
1546
+ pendingAcs: pendingAcs.map(a => ({ id: a.id, description: a.description })),
1547
+ totalAcs: f.acs.length,
1548
+ });
1549
+ }
1550
+
1551
+ return {
1552
+ hasDrift: driftFeatures.length > 0,
1553
+ driftCount: driftFeatures.length,
1554
+ features: driftFeatures,
1555
+ };
1556
+ }
1557
+
1558
+ // @cap-feature(feature:F-042) formatDriftReport — markdown-friendly renderer used by the
1559
+ // /cap:status --drift CLI (AC-6). Pure function: input report, output string. No I/O.
1560
+ /**
1561
+ * Render a DriftReport as a markdown table for CLI display.
1562
+ * @param {DriftReport} report
1563
+ * @returns {string}
1564
+ */
1565
+ function formatDriftReport(report) {
1566
+ // @cap-todo(ac:F-042/AC-6) Defensive: nullish report is treated as the no-drift case so
1567
+ // downstream CLI shells never explode when the upstream pipeline hands back a missing
1568
+ // value (e.g. F-043 reconciliation tooling that may short-circuit before producing a report).
1569
+ if (!report || !report.hasDrift) {
1570
+ return 'Status Drift: none — Feature Map is consistent.';
1571
+ }
1572
+
1573
+ const lines = [];
1574
+ lines.push(`Status Drift Detected: ${report.driftCount} features`);
1575
+ lines.push('');
1576
+ lines.push('| Feature | State | Pending ACs |');
1577
+ lines.push('|---------|----------|-------------|');
1578
+ for (const f of report.features) {
1579
+ // pad state column to roughly the width of "shipped" + 1
1580
+ const statePadded = f.state.padEnd(8, ' ');
1581
+ const ratio = `${f.pendingAcs.length}/${f.totalAcs}`;
1582
+ lines.push(`| ${f.id} | ${statePadded} | ${ratio.padEnd(11, ' ')} |`);
1583
+ }
1584
+ return lines.join('\n');
1585
+ }
1586
+
1587
+ // @cap-api enrichFromTags(projectRoot, scanResults, appPath) -- Update file references from tag scan.
1588
+ // @cap-todo(ref:AC-12) Feature Map auto-enriched from @cap-feature tags found in source code
1589
+ /**
1590
+ * @param {string} projectRoot - Absolute path to project root
1591
+ * @param {import('./cap-tag-scanner.cjs').CapTag[]} scanResults - Tags from cap-tag-scanner
1592
+ * @param {string|null} [appPath=null] - Relative app path for monorepo scoping
1593
+ * @returns {FeatureMap}
1594
+ */
1595
+ function enrichFromTags(projectRoot, scanResults, appPath) {
1596
+ // @cap-todo(ac:F-081/AC-4 iter:2) Migrated to {safe: true} opt-in to preserve CLI on duplicate-ID FEATURE-MAP.
1597
+ const featureMap = readFeatureMap(projectRoot, appPath, { safe: true });
1598
+
1599
+ // @cap-todo(ac:F-082/iter1 fix:1) Monorepo-aware enrichment. Stage-2 #1 found that on a
1600
+ // monorepo project (Rescoped Table present), the legacy bare `enrichFromTags(root, tags)`
1601
+ // call read the AGGREGATED map (sub-app features included), mutated their `files[]` in
1602
+ // memory, then wrote to root — where the writer-filter at writeFeatureMap (L894+) silently
1603
+ // stripped them out. Net effect: every sub-app `@cap-feature(...)` tag was dropped on every
1604
+ // `/cap:scan`. Production-bite class.
1605
+ // @cap-decision(F-082/iter1 fix:1) Internal-split strategy. Detect the aggregated map via
1606
+ // the runtime-only `_subAppPrefixes`. If present and caller did not specify appPath,
1607
+ // group features by `metadata.subApp` and write each group back via the appropriate appPath.
1608
+ // API surface unchanged — callers in commands/cap/{scan,prototype,iterate,annotate}.md
1609
+ // continue to call `enrichFromTags(process.cwd(), tags)` and now Just Work for monorepos.
1610
+ // @cap-risk(F-082/iter1 fix:1) The same scanResults are applied to every per-scope write —
1611
+ // each enrichment loop re-filters tags against the features it owns (find returns null for
1612
+ // a foreign feature, so the file ref is not persisted to a wrong sub-app file).
1613
+ // @cap-decision(F-082/followup) Cross-sub-app blast radius fix: parseError gate is now
1614
+ // evaluated AFTER the aggregation-detection branch. An aggregated parseError (a duplicate
1615
+ // in ONE sub-app) must NOT block enrichment for healthy sibling sub-apps — the aggregator
1616
+ // `_enrichFromTagsAcrossSubApps` already skips bad scopes individually at L1671-1675. The
1617
+ // gate below applies ONLY to legacy single-scope reads (no _subAppPrefixes).
1618
+ if (!appPath && featureMap._subAppPrefixes && featureMap._subAppPrefixes.size > 0) {
1619
+ // @cap-todo(ac:F-083/AC-6) Lazy-require — see _monorepo() definition.
1620
+ return _monorepo()._enrichFromTagsAcrossSubApps(projectRoot, scanResults, featureMap);
1621
+ }
1622
+
1623
+ // @cap-decision(F-081/iter2) Bail on parseError — do not persist partial enrichment.
1624
+ // Single-scope only; aggregated reads handle parseError per-scope (see above).
1625
+ if (featureMap.parseError) {
1626
+ console.warn('cap: skipping enrichFromTags — duplicate feature ID detected: ' + _safeForError(featureMap.parseError.message));
1627
+ return featureMap;
1628
+ }
1629
+
1630
+ for (const tag of scanResults) {
1631
+ if (tag.type !== 'feature') continue;
1632
+ const featureId = tag.metadata.feature;
1633
+ if (!featureId) continue;
1634
+
1635
+ const feature = featureMap.features.find(f => f.id === featureId);
1636
+ if (!feature) continue;
1637
+
1638
+ // Add file reference if not already present
1639
+ if (!feature.files.includes(tag.file)) {
1640
+ feature.files.push(tag.file);
1641
+ }
1642
+ }
1643
+
1644
+ writeFeatureMap(projectRoot, featureMap, appPath);
1645
+ return featureMap;
1646
+ }
1647
+
1648
+ // @cap-feature(feature:F-063) enrichFromDesignTags — populate Feature.usesDesign from design-token/design-component tags.
1649
+ // @cap-api enrichFromDesignTags(projectRoot, scanResults, appPath) -- Add DT/DC IDs to each feature's usesDesign
1650
+ // based on where design-token / design-component tags co-locate with a feature's files.
1651
+ // @cap-todo(ac:F-063/AC-3) Design-usage enrichment: when a file tagged @cap-feature(feature:F-NNN) also
1652
+ // carries @cap-design-token(id:DT-NNN) or @cap-design-component(id:DC-NNN), the ID is appended to F-NNN.usesDesign.
1653
+ // @cap-decision Co-location (same file) is the heuristic. Cross-file usage would require import resolution,
1654
+ // which /cap:design --scope handles explicitly (user-curated). This keeps the scanner pure and the UX predictable.
1655
+ /**
1656
+ * @param {string} projectRoot - Absolute path to project root
1657
+ * @param {import('./cap-tag-scanner.cjs').CapTag[]} scanResults - Tags from cap-tag-scanner (must include design-token/design-component entries)
1658
+ * @param {string|null} [appPath=null] - Relative app path for monorepo scoping
1659
+ * @returns {FeatureMap}
1660
+ */
1661
+ function enrichFromDesignTags(projectRoot, scanResults, appPath) {
1662
+ // @cap-todo(ac:F-081/AC-4 iter:2) Migrated to {safe: true} opt-in to preserve CLI on duplicate-ID FEATURE-MAP.
1663
+ const featureMap = readFeatureMap(projectRoot, appPath, { safe: true });
1664
+
1665
+ // @cap-todo(ac:F-082/iter1 fix:1) Monorepo-aware design enrichment — same lesson as enrichFromTags.
1666
+ // @cap-decision(F-082/followup) Cross-sub-app blast radius fix: parseError gate moved BELOW
1667
+ // the aggregation branch so a duplicate in one sub-app does not block healthy siblings.
1668
+ // See enrichFromTags for the full reasoning.
1669
+ if (!appPath && featureMap._subAppPrefixes && featureMap._subAppPrefixes.size > 0) {
1670
+ // @cap-todo(ac:F-083/AC-6) Lazy-require — see _monorepo() definition.
1671
+ return _monorepo()._enrichFromDesignTagsAcrossSubApps(projectRoot, scanResults, featureMap);
1672
+ }
1673
+
1674
+ // @cap-decision(F-081/iter2) Bail on parseError — do not persist partial enrichment.
1675
+ // Single-scope only; aggregated reads handle parseError per-scope.
1676
+ if (featureMap.parseError) {
1677
+ console.warn('cap: skipping enrichFromDesignTags — duplicate feature ID detected: ' + _safeForError(featureMap.parseError.message));
1678
+ return featureMap;
1679
+ }
1680
+
1681
+ // Build file -> featureId map (first @cap-feature wins, matches F-049 convention).
1682
+ const fileToFeature = new Map();
1683
+ for (const tag of scanResults) {
1684
+ if (tag.type !== 'feature') continue;
1685
+ const fid = tag.metadata && tag.metadata.feature;
1686
+ if (!fid) continue;
1687
+ if (!fileToFeature.has(tag.file)) fileToFeature.set(tag.file, fid);
1688
+ }
1689
+
1690
+ // For each design tag, find its file's feature and append the design ID.
1691
+ for (const tag of scanResults) {
1692
+ if (tag.type !== 'design-token' && tag.type !== 'design-component') continue;
1693
+ const designId = tag.metadata && tag.metadata.id;
1694
+ if (!designId) continue;
1695
+ const featureId = fileToFeature.get(tag.file);
1696
+ if (!featureId) continue;
1697
+
1698
+ const feature = featureMap.features.find(f => f.id === featureId);
1699
+ if (!feature) continue;
1700
+ if (!Array.isArray(feature.usesDesign)) feature.usesDesign = [];
1701
+ if (!feature.usesDesign.includes(designId)) feature.usesDesign.push(designId);
1702
+ }
1703
+
1704
+ // Stable sort for deterministic output.
1705
+ for (const f of featureMap.features) {
1706
+ if (Array.isArray(f.usesDesign)) f.usesDesign.sort();
1707
+ }
1708
+
1709
+ writeFeatureMap(projectRoot, featureMap, appPath);
1710
+ return featureMap;
1711
+ }
1712
+
1713
+ // @cap-api setFeatureUsesDesign(projectRoot, featureId, designIds, appPath) -- Replace a feature's usesDesign list.
1714
+ // @cap-todo(ac:F-063/AC-4) Called by /cap:design --scope after the user confirms which DT/DC IDs the feature uses.
1715
+ /**
1716
+ * @param {string} projectRoot
1717
+ * @param {string} featureId - e.g. "F-023"
1718
+ * @param {string[]} designIds - list of DT-NNN / DC-NNN IDs (replaces existing value)
1719
+ * @param {string|null} [appPath=null]
1720
+ * @returns {boolean} - true if the feature existed and was updated
1721
+ */
1722
+ function setFeatureUsesDesign(projectRoot, featureId, designIds, appPath) {
1723
+ // @cap-todo(ac:F-081/AC-4 iter:2) Migrated to {safe: true} opt-in to preserve CLI on duplicate-ID FEATURE-MAP.
1724
+ // @cap-decision(F-081/iter2) Bail on parseError — do not persist partial enrichment.
1725
+ const featureMap = readFeatureMap(projectRoot, appPath, { safe: true });
1726
+ if (featureMap.parseError) {
1727
+ console.warn('cap: setFeatureUsesDesign aborted — duplicate feature ID detected: ' + String(featureMap.parseError.message).trim());
1728
+ return false;
1729
+ }
1730
+ const feature = featureMap.features.find(f => f.id === featureId);
1731
+ if (!feature) return false;
1732
+
1733
+ // @cap-todo(ac:F-082/iter1 fix:2) Auto-redirect to sub-app when feature lives there. See
1734
+ // updateFeatureState for the full lesson.
1735
+ // @cap-todo(ac:F-083/AC-6) Lazy-require monorepo helpers — see _monorepo() definition.
1736
+ const _mrSetUd = _monorepo();
1737
+ const redirectResult = _mrSetUd._maybeRedirectToSubApp(
1738
+ projectRoot, featureMap, feature, appPath, 'setFeatureUsesDesign',
1739
+ (resolvedAppPath) => setFeatureUsesDesign(projectRoot, featureId, designIds, resolvedAppPath)
1740
+ );
1741
+ if (redirectResult !== _mrSetUd._NO_REDIRECT) return redirectResult;
1742
+
1743
+ const cleaned = (Array.isArray(designIds) ? designIds : [])
1744
+ .map(s => String(s).trim())
1745
+ .filter(s => /^(DT-\d{3,}|DC-\d{3,})$/.test(s));
1746
+ // Stable, deterministic order.
1747
+ feature.usesDesign = [...new Set(cleaned)].sort();
1748
+ writeFeatureMap(projectRoot, featureMap, appPath);
1749
+ return true;
1750
+ }
1751
+
1752
+ // @cap-api enrichFromDeps(projectRoot) -- Read package.json, detect imports, add dependency info to features.
1753
+ // @cap-todo(ref:AC-13) Feature Map auto-enriched from dependency graph analysis, env vars, package.json
1754
+ /**
1755
+ * @param {string} projectRoot - Absolute path to project root
1756
+ * @returns {{ dependencies: string[], devDependencies: string[], envVars: string[] }}
1757
+ */
1758
+ function enrichFromDeps(projectRoot) {
1759
+ const result = { dependencies: [], devDependencies: [], envVars: [] };
1760
+
1761
+ const pkgPath = path.join(projectRoot, 'package.json');
1762
+ if (fs.existsSync(pkgPath)) {
1763
+ try {
1764
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
1765
+ if (pkg.dependencies) result.dependencies = Object.keys(pkg.dependencies);
1766
+ if (pkg.devDependencies) result.devDependencies = Object.keys(pkg.devDependencies);
1767
+ } catch (_e) {
1768
+ // Malformed package.json
1769
+ }
1770
+ }
1771
+
1772
+ // Scan for .env file to detect environment variables
1773
+ const envPath = path.join(projectRoot, '.env');
1774
+ if (fs.existsSync(envPath)) {
1775
+ try {
1776
+ const envContent = fs.readFileSync(envPath, 'utf8');
1777
+ const envRE = /^([A-Z_][A-Z0-9_]*)=/gm;
1778
+ let match;
1779
+ while ((match = envRE.exec(envContent)) !== null) {
1780
+ result.envVars.push(match[1]);
1781
+ }
1782
+ } catch (_e) {
1783
+ // Ignore
1784
+ }
1785
+ }
1786
+
1787
+ return result;
1788
+ }
1789
+
1790
+ // @cap-api getNextFeatureId(features) -- Generate next F-NNN ID.
1791
+ /**
1792
+ * @param {Feature[]} features - Existing features
1793
+ * @returns {string} - Next feature ID (e.g., "F-001")
1794
+ */
1795
+ function getNextFeatureId(features) {
1796
+ if (!features || features.length === 0) return 'F-001';
1797
+
1798
+ let maxNum = 0;
1799
+ for (const f of features) {
1800
+ const match = f.id.match(/^F-(\d+)$/);
1801
+ if (match) {
1802
+ const num = parseInt(match[1], 10);
1803
+ if (num > maxNum) maxNum = num;
1804
+ }
1805
+ }
1806
+
1807
+ return `F-${String(maxNum + 1).padStart(3, '0')}`;
1808
+ }
1809
+
1810
+ // @cap-api enrichFromScan(featureMap, tags) -- Updates Feature Map status from tag scan results.
1811
+ // Returns: updated FeatureMap with AC statuses reflecting code annotations.
1812
+ /**
1813
+ * @param {FeatureMap} featureMap - Current feature map data
1814
+ * @param {import('./cap-tag-scanner.cjs').CapTag[]} tags - Tags from cap-tag-scanner
1815
+ * @returns {FeatureMap}
1816
+ */
1817
+ function enrichFromScan(featureMap, tags) {
1818
+ for (const tag of tags) {
1819
+ if (tag.type !== 'feature') continue;
1820
+ const featureId = tag.metadata.feature;
1821
+ if (!featureId) continue;
1822
+
1823
+ const feature = featureMap.features.find(f => f.id === featureId);
1824
+ if (!feature) continue;
1825
+
1826
+ // Add file reference
1827
+ if (!feature.files.includes(tag.file)) {
1828
+ feature.files.push(tag.file);
1829
+ }
1830
+
1831
+ // If AC reference in metadata, mark it as implemented
1832
+ const acRef = tag.metadata.ac;
1833
+ if (acRef) {
1834
+ const ac = feature.acs.find(a => a.id === acRef);
1835
+ if (ac && ac.status === 'pending') {
1836
+ ac.status = 'implemented';
1837
+ }
1838
+ }
1839
+ }
1840
+
1841
+ return featureMap;
1842
+ }
1843
+
1844
+ // @cap-api addFeatures(featureMap, newFeatures) -- Adds new features to an existing Feature Map (from brainstorm).
1845
+ // @cap-todo(ref:AC-11) Feature Map supports auto-derivation from brainstorm output
1846
+ /**
1847
+ * @param {FeatureMap} featureMap - Current feature map data
1848
+ * @param {Feature[]} newFeatures - Features to add
1849
+ * @returns {FeatureMap}
1850
+ */
1851
+ function addFeatures(featureMap, newFeatures) {
1852
+ const existingIds = new Set(featureMap.features.map(f => f.id));
1853
+ const existingTitles = new Set(featureMap.features.map(f => f.title.toLowerCase()));
1854
+
1855
+ for (const nf of newFeatures) {
1856
+ // Skip duplicates by ID or title
1857
+ if (existingIds.has(nf.id)) continue;
1858
+ if (existingTitles.has(nf.title.toLowerCase())) continue;
1859
+
1860
+ featureMap.features.push(nf);
1861
+ existingIds.add(nf.id);
1862
+ existingTitles.add(nf.title.toLowerCase());
1863
+ }
1864
+
1865
+ return featureMap;
1866
+ }
1867
+
1868
+ // @cap-api getStatus(featureMap) -- Computes aggregate project status from Feature Map.
1869
+ /**
1870
+ * @param {FeatureMap} featureMap
1871
+ * @returns {{ totalFeatures: number, completedFeatures: number, totalACs: number, implementedACs: number, testedACs: number, reviewedACs: number }}
1872
+ */
1873
+ function getStatus(featureMap) {
1874
+ let totalFeatures = featureMap.features.length;
1875
+ let completedFeatures = featureMap.features.filter(f => f.state === 'shipped').length;
1876
+ let totalACs = 0;
1877
+ let implementedACs = 0;
1878
+ let testedACs = 0;
1879
+ let reviewedACs = 0;
1880
+
1881
+ for (const f of featureMap.features) {
1882
+ totalACs += f.acs.length;
1883
+ for (const ac of f.acs) {
1884
+ if (ac.status === 'implemented') implementedACs++;
1885
+ if (ac.status === 'tested') testedACs++;
1886
+ if (ac.status === 'reviewed') reviewedACs++;
1887
+ }
1888
+ }
1889
+
1890
+ return { totalFeatures, completedFeatures, totalACs, implementedACs, testedACs, reviewedACs };
1891
+ }
1892
+
1893
+ // @cap-feature(feature:F-083) Module exports assigned in TWO STAGES — Stage 1 attaches the
1894
+ // locally-defined exports; Stage 2 (the trailing block) attaches identity-preserving
1895
+ // references to the monorepo module's exports. AC-2 pins the identity-preservation contract.
1896
+ // @cap-decision(F-083/backward-compat) Re-exports preserve zero call-site change contract.
1897
+ module.exports = {
1898
+ FEATURE_MAP_FILE,
1899
+ FEATURE_ID_PATTERN, // F-081
1900
+ VALID_STATES,
1901
+ STATE_TRANSITIONS,
1902
+ AC_VALID_STATUSES,
1903
+ generateTemplate,
1904
+ readFeatureMap,
1905
+ readCapConfig, // F-081
1906
+ writeFeatureMap,
1907
+ parseFeatureMapContent,
1908
+ serializeFeatureMap,
1909
+ addFeature,
1910
+ updateFeatureState,
1911
+ transitionWithReason,
1912
+ setAcStatus,
1913
+ detectDrift,
1914
+ formatDriftReport,
1915
+ enrichFromTags,
1916
+ enrichFromDesignTags, // F-063
1917
+ setFeatureUsesDesign, // F-063
1918
+ enrichFromDeps,
1919
+ getNextFeatureId,
1920
+ enrichFromScan,
1921
+ addFeatures,
1922
+ getStatus,
1923
+ // @cap-todo(ac:F-083/AC-1) Internal helper exposed for the monorepo module's lazy-require.
1924
+ // Not part of the documented public surface, but the monorepo module destructures it.
1925
+ _safeForError,
1926
+ // F-088 surgical-patch helpers (exposed for tests + downstream callers like cap-reconcile).
1927
+ applySurgicalPatches,
1928
+ _surgicalUpdateFeatureState,
1929
+ _surgicalSetAcStatus,
1930
+ };
1931
+
1932
+ // @cap-todo(ac:F-083/AC-2) Stage-2 re-export attachment — identity-preserving wiring of the
1933
+ // monorepo module's exports onto this module's surface. Runs AFTER Stage 1 above.
1934
+ {
1935
+ const _mr = _monorepo();
1936
+ for (const k of [
1937
+ 'parseRescopedTable', 'discoverSubAppFeatureMaps', 'aggregateSubAppFeatureMaps',
1938
+ 'extractRescopedBlock', 'injectRescopedBlock',
1939
+ '_enrichFromTagsAcrossSubApps', '_enrichFromDesignTagsAcrossSubApps',
1940
+ '_maybeRedirectToSubApp', '_NO_REDIRECT',
1941
+ 'initAppFeatureMap', 'listAppFeatureMaps', 'rescopeFeatures',
1942
+ ]) module.exports[k] = _mr[k];
1943
+ }