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,987 @@
1
+ // @cap-feature(feature:F-029) Cross-File Memory Directory — write aggregated memory to .cap/memory/ markdown files
2
+ // @cap-decision .cap/memory/ is git-tracked (not gitignored) — project memory persists across clones and team members.
3
+ // @cap-decision Stable anchor IDs derived from content hash — cross-reference links survive regeneration.
4
+ // @cap-constraint Zero external dependencies — uses only Node.js built-ins.
5
+
6
+ 'use strict';
7
+
8
+ // @cap-history(sessions:6, edits:23, since:2026-04-20, learned:2026-05-08) Frequently modified — 6 sessions, 23 edits
9
+ // @cap-history(sessions:2, edits:3, since:2026-04-20, learned:2026-04-21) Frequently modified — 2 sessions, 3 edits
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+ const crypto = require('node:crypto');
13
+ const confidence = require('./cap-memory-confidence.cjs');
14
+
15
+ // --- Constants ---
16
+
17
+ const MEMORY_DIR = path.join('.cap', 'memory');
18
+
19
+ const CATEGORY_FILES = {
20
+ decision: 'decisions.md',
21
+ hotspot: 'hotspots.md',
22
+ pitfall: 'pitfalls.md',
23
+ pattern: 'patterns.md',
24
+ };
25
+
26
+ // --- Anchor Generation (AC-6) ---
27
+
28
+ // @cap-todo(ref:F-029:AC-6) Generate stable anchor IDs so cross-reference links remain valid across regenerations
29
+
30
+ /**
31
+ * Generate a stable anchor ID from entry content.
32
+ * Uses first 8 chars of SHA-256 hash of normalized content.
33
+ * @param {string} content
34
+ * @returns {string} Anchor ID (e.g., "a3f2b1c0")
35
+ */
36
+ function generateAnchorId(content) {
37
+ const normalized = content.toLowerCase().trim().replace(/\s+/g, ' ');
38
+ return crypto.createHash('sha256').update(normalized).digest('hex').substring(0, 8);
39
+ }
40
+
41
+ // --- Markdown Generation ---
42
+
43
+ // @cap-todo(ref:F-029:AC-1) Write to .cap/memory/ as four markdown files
44
+ // @cap-todo(ref:F-029:AC-3) Each entry includes source session date, related files, summary
45
+
46
+ /**
47
+ * Generate markdown content for a memory category file.
48
+ * @param {string} category
49
+ * @param {import('./cap-memory-engine.cjs').MemoryEntry[]} entries
50
+ * @param {{ minConfidence?: number }} [opts] - F-090: confidence threshold (default 0.6)
51
+ * @returns {string}
52
+ */
53
+ function generateCategoryMarkdown(category, entries, opts = {}) {
54
+ const title = category.charAt(0).toUpperCase() + category.slice(1) + 's';
55
+ const out = [];
56
+ out.push(`# Project Memory: ${title}`);
57
+ out.push('');
58
+ out.push(`> Auto-generated from code tags and session data. Pinned entries are preserved; others may be updated on regeneration.`);
59
+ out.push(`> Last updated: ${new Date().toISOString().substring(0, 10)}`);
60
+ out.push('');
61
+
62
+ if (entries.length === 0) {
63
+ out.push(`_No ${category}s recorded yet._`);
64
+ return out.join('\n');
65
+ }
66
+
67
+ if (category === 'hotspot') {
68
+ return generateHotspotsMarkdown(out, entries);
69
+ }
70
+
71
+ // @cap-feature(feature:F-090, primary:true) Confidence-filter: drop low-signal entries from
72
+ // the .md output so agents reading the file at session-start don't ingest 568 KB of
73
+ // Confidence:0.50/Evidence:1 heuristic-extracted comment text. graph.json stays full
74
+ // (Cluster/Affinity components need every node); the filter only reduces the human/agent-
75
+ // readable .md surface.
76
+ // @cap-decision(F-090/separation-of-concerns) generateCategoryMarkdown defaults to 0 (no filter)
77
+ // so it stays a pure rendering function — render-correctness tests don't have to think about
78
+ // the filter. writeMemoryDirectory (the pipeline entry point) defaults to 0.6 to apply the
79
+ // policy. Callers who want filtered rendering pass minConfidence explicitly.
80
+ const minConfidence =
81
+ typeof opts.minConfidence === 'number' ? opts.minConfidence : 0;
82
+ const filtered = _filterEntriesForOutput(entries, { minConfidence });
83
+ const droppedCount = entries.length - filtered.length;
84
+
85
+ if (filtered.length === 0) {
86
+ out.push(`_No high-confidence ${category}s recorded yet (filtered out ${droppedCount} low-confidence ${category}s)._`);
87
+ return out.join('\n');
88
+ }
89
+
90
+ // Default: list format for decisions, pitfalls, patterns
91
+ for (const entry of filtered) {
92
+ const anchor = generateAnchorId(entry.content);
93
+ // Newlines or CRs in entry content would fracture into phantom entries on the next readMemoryFile pass and could smuggle a fake anchor heading. Collapse on the write path so the Markdown grammar stays one-entry-per-heading.
94
+ const safeContent = String(entry.content).replace(/[\r\n]+/g, ' ');
95
+ const pinTag = entry.metadata.pinned ? ' **[pinned]**' : '';
96
+ const date = entry.metadata.source ? entry.metadata.source.substring(0, 10) : 'unknown';
97
+ const files = entry.metadata.relatedFiles?.length > 0
98
+ ? entry.metadata.relatedFiles.map(f => `\`${f}\``).join(', ')
99
+ : 'cross-cutting';
100
+ const features = entry.metadata.features?.length > 0
101
+ ? ` (${entry.metadata.features.join(', ')})`
102
+ : '';
103
+
104
+ // @cap-todo(ac:F-055/AC-1) Confidence + evidence_count rendered as entry-block bullets.
105
+ // @cap-todo(ac:F-055/AC-3) ensureFields supplies defaults for entries that predate F-055.
106
+ const fields = confidence.ensureFields(entry.metadata);
107
+ // @cap-todo(ac:F-055/AC-6) Entries with confidence<0.3 render as a blockquote prefixed with "*(low confidence)*".
108
+ const dim = confidence.isLowConfidence(entry.metadata);
109
+ const prefix = dim ? '> ' : '';
110
+ const dimMarker = dim ? '*(low confidence)* ' : '';
111
+
112
+ out.push(`${prefix}### <a id="${anchor}"></a>${dimMarker}${safeContent}${pinTag}`);
113
+ out.push(dim ? '>' : '');
114
+ out.push(`${prefix}- **Date:** ${date}${features}`);
115
+ out.push(`${prefix}- **Files:** ${files}`);
116
+ out.push(`${prefix}- **Confidence:** ${fields.confidence.toFixed(2)}`);
117
+ out.push(`${prefix}- **Evidence:** ${fields.evidence_count}`);
118
+ // @cap-todo(ac:F-056/AC-3) Last Seen bullet written so the decay clock roundtrips through disk.
119
+ out.push(`${prefix}- **Last Seen:** ${fields.last_seen}`);
120
+ if (entry.metadata.confirmations) {
121
+ out.push(`${prefix}- **Confirmed:** ${entry.metadata.confirmations} times`);
122
+ }
123
+ out.push('');
124
+ }
125
+
126
+ out.push(`---`);
127
+ // @cap-feature(feature:F-090) Footer-Counter shows kept + filtered counts so the user can
128
+ // tell at a glance how aggressive the confidence filter was on this run.
129
+ if (droppedCount > 0) {
130
+ out.push(`*${filtered.length} ${category}s kept (filtered out ${droppedCount} low-confidence ${category}s; threshold=${minConfidence})*`);
131
+ } else {
132
+ out.push(`*${filtered.length} ${category}s total*`);
133
+ }
134
+ return out.join('\n');
135
+ }
136
+
137
+ // @cap-feature(feature:F-090) Pure filter for V5 monolithic Memory output.
138
+ // @cap-decision(F-090/AC-1) Filter rule: keep entry IFF (pinned OR confidence >= threshold).
139
+ // evidence_count is implicit in confidence (each re-observation +0.1, default 0.5), so a
140
+ // single check on confidence captures both "trustworthy" (high confidence) and "user-curated"
141
+ // (pinned) signals. evidence-only entries without re-observation are noise by definition.
142
+ // @cap-decision(F-090/AC-5) Defense-in-depth: pinned wins regardless of confidence value.
143
+ // A pinned entry with confidence:0.0 (e.g. user-suppressed via contradiction) is still
144
+ // user-curated content and must round-trip to disk.
145
+ /**
146
+ * Filter memory entries for .md output. graph.json is built independently and not affected.
147
+ * @param {import('./cap-memory-engine.cjs').MemoryEntry[]} entries
148
+ * @param {{ minConfidence: number }} options
149
+ * @returns {import('./cap-memory-engine.cjs').MemoryEntry[]}
150
+ */
151
+ function _filterEntriesForOutput(entries, options) {
152
+ const threshold = options.minConfidence;
153
+ const out = [];
154
+ for (const entry of entries) {
155
+ if (!entry || !entry.metadata) continue;
156
+ if (entry.metadata.pinned === true) { out.push(entry); continue; }
157
+ const fields = confidence.ensureFields(entry.metadata);
158
+ if (typeof fields.confidence === 'number' && fields.confidence >= threshold) {
159
+ out.push(entry);
160
+ }
161
+ }
162
+ return out;
163
+ }
164
+
165
+ // @cap-todo(ref:F-029:AC-4) hotspots.md ranks files by cross-session edit frequency
166
+
167
+ /**
168
+ * Generate hotspots markdown with ranking table.
169
+ * @param {string[]} out - Output lines (header already added)
170
+ * @param {import('./cap-memory-engine.cjs').MemoryEntry[]} entries
171
+ * @returns {string}
172
+ */
173
+ function generateHotspotsMarkdown(out, entries) {
174
+ // Sort by sessions desc, then edits desc
175
+ const sorted = [...entries].sort((a, b) => {
176
+ const sDiff = (b.metadata.sessions || 0) - (a.metadata.sessions || 0);
177
+ if (sDiff !== 0) return sDiff;
178
+ return (b.metadata.edits || 0) - (a.metadata.edits || 0);
179
+ });
180
+
181
+ out.push('| Rank | File | Sessions | Edits | Since |');
182
+ out.push('|------|------|----------|-------|-------|');
183
+
184
+ // Newlines or stray pipes in entry.file / metadata.since would fracture the
185
+ // markdown table into invalid rows. Parallel to the list-writer in
186
+ // generateCategoryMarkdown that collapses \r\n in entry.content.
187
+ const cellSanitize = (v) => String(v ?? '?').replace(/[\r\n]+/g, ' ').replace(/\|/g, '\\|');
188
+
189
+ sorted.forEach((entry, i) => {
190
+ const anchor = generateAnchorId(entry.content + entry.file);
191
+ const file = cellSanitize(entry.file);
192
+ const sessions = cellSanitize(entry.metadata.sessions || '?');
193
+ const edits = cellSanitize(entry.metadata.edits || '?');
194
+ const since = cellSanitize(entry.metadata.since || '?');
195
+ out.push(`| <a id="${anchor}"></a>${i + 1} | \`${file}\` | ${sessions} | ${edits} | ${since} |`);
196
+ });
197
+
198
+ out.push('');
199
+ out.push(`---`);
200
+ out.push(`*${entries.length} hotspots total*`);
201
+ return out.join('\n');
202
+ }
203
+
204
+ // --- File I/O ---
205
+
206
+ // @cap-todo(ref:F-029:AC-2) Auto-generated — manual edits outside pinned entries overwritten
207
+ // @cap-todo(ref:F-029:AC-7) .cap/memory/ is git-committable (not gitignored)
208
+
209
+ /**
210
+ * Parse existing memory entries from a markdown file to support merging.
211
+ * Extracts anchor IDs to detect already-known entries.
212
+ * @param {string} content - Markdown file content
213
+ * @returns {Set<string>} Set of anchor IDs already present
214
+ */
215
+ function parseExistingAnchors(content) {
216
+ const anchors = new Set();
217
+ const re = /<a id="([a-f0-9]+)"><\/a>/g;
218
+ let match;
219
+ while ((match = re.exec(content)) !== null) {
220
+ anchors.add(match[1]);
221
+ }
222
+ return anchors;
223
+ }
224
+
225
+ /**
226
+ * Write all memory category files to .cap/memory/.
227
+ * Supports merge mode: new entries are added to existing files, duplicates skipped by anchor ID.
228
+ * @param {string} projectRoot - Project root directory
229
+ * @param {import('./cap-memory-engine.cjs').MemoryEntry[]} entries - All memory entries
230
+ * @param {Object} [options]
231
+ * @param {boolean} [options.dryRun] - If true, return content without writing
232
+ * @param {boolean} [options.merge] - If true, merge with existing entries instead of overwriting
233
+ * @returns {{files: Object<string, string>, written: number}}
234
+ */
235
+ function writeMemoryDirectory(projectRoot, entries, options = {}) {
236
+ // @cap-feature(feature:F-093, primary:true) Layout dispatch: V5 monolithic (default) or V6 per-feature.
237
+ // V6 mode is opt-in via .cap/config.json: { memory: { layout: 'v6' } }. Without the flag
238
+ // behaviour is byte-identical to pre-F-093 (legacy callers and tests stay green).
239
+ if (_isV6LayoutEnabled(projectRoot, options)) {
240
+ return _writeMemoryV6(projectRoot, entries, options);
241
+ }
242
+ const memDir = path.join(projectRoot, MEMORY_DIR);
243
+ const files = {};
244
+ let written = 0;
245
+
246
+ // Group entries by category
247
+ const grouped = { decision: [], hotspot: [], pitfall: [], pattern: [] };
248
+ for (const entry of entries) {
249
+ const cat = entry.category;
250
+ if (grouped[cat]) grouped[cat].push(entry);
251
+ }
252
+
253
+ // In merge mode, read existing files and skip entries with matching anchor IDs
254
+ const existingFiles = options.merge ? readMemoryDirectory(projectRoot) : {};
255
+
256
+ for (const [category, categoryEntries] of Object.entries(grouped)) {
257
+ const filename = CATEGORY_FILES[category];
258
+
259
+ // If merging: filter out entries whose anchor already exists
260
+ let entriesToWrite = categoryEntries;
261
+ if (options.merge && existingFiles[filename]) {
262
+ const existingAnchors = parseExistingAnchors(existingFiles[filename]);
263
+ entriesToWrite = categoryEntries.filter(entry => {
264
+ const anchor = category === 'hotspot'
265
+ ? generateAnchorId(entry.content + entry.file)
266
+ : generateAnchorId(entry.content);
267
+ return !existingAnchors.has(anchor);
268
+ });
269
+
270
+ // For hotspots: always regenerate fully (session counts change)
271
+ if (category === 'hotspot') {
272
+ entriesToWrite = categoryEntries;
273
+ }
274
+ }
275
+
276
+ // @cap-feature(feature:F-090) Forward minConfidence option to the generator. graph.json
277
+ // is built separately from the same entries[] input — no filter applied there.
278
+ // @cap-decision(F-090) Default = 0 (no filter) preserves backwards-compat for direct
279
+ // callers (tests, CLI tools). The HOOK (hooks/cap-memory.js) applies the policy by
280
+ // passing minConfidence:0.6 explicitly — that's where the agent-facing token-cost-of-read
281
+ // problem manifests, so that's where the policy lives.
282
+ const content = generateCategoryMarkdown(
283
+ category,
284
+ category === 'hotspot' ? entriesToWrite : categoryEntries,
285
+ { minConfidence: options.minConfidence }
286
+ );
287
+ files[filename] = content;
288
+
289
+ if (!options.dryRun) {
290
+ if (!fs.existsSync(memDir)) fs.mkdirSync(memDir, { recursive: true });
291
+ fs.writeFileSync(path.join(memDir, filename), content, 'utf8');
292
+ written++;
293
+ }
294
+ }
295
+
296
+ return { files, written };
297
+ }
298
+
299
+ /**
300
+ * Read existing memory directory entries (for merging with pinned entries).
301
+ * @param {string} projectRoot
302
+ * @returns {Object<string, string>} filename -> content
303
+ */
304
+ function readMemoryDirectory(projectRoot) {
305
+ const memDir = path.join(projectRoot, MEMORY_DIR);
306
+ const result = {};
307
+
308
+ if (!fs.existsSync(memDir)) return result;
309
+
310
+ for (const [, filename] of Object.entries(CATEGORY_FILES)) {
311
+ const fp = path.join(memDir, filename);
312
+ if (fs.existsSync(fp)) {
313
+ result[filename] = fs.readFileSync(fp, 'utf8');
314
+ }
315
+ }
316
+
317
+ return result;
318
+ }
319
+
320
+ // @cap-todo(ref:F-029:AC-5) Code annotations include cross-reference link to memory file section
321
+
322
+ /**
323
+ * Generate a cross-reference string for an annotation pointing to the memory directory.
324
+ * @param {import('./cap-memory-engine.cjs').MemoryEntry} entry
325
+ * @returns {string} e.g., "see .cap/memory/decisions.md#a3f2b1c0"
326
+ */
327
+ function getCrossReference(entry) {
328
+ const filename = CATEGORY_FILES[entry.category];
329
+ if (!filename) return '';
330
+ const anchor = entry.category === 'hotspot'
331
+ ? generateAnchorId(entry.content + entry.file)
332
+ : generateAnchorId(entry.content);
333
+ return `see .cap/memory/${filename}#${anchor}`;
334
+ }
335
+
336
+ // --- Per-file Parser (F-055) ---
337
+
338
+ // @cap-feature(feature:F-055) readMemoryFile parses a single category markdown back into structured entries, applying lazy AC-3 migration for pre-F-055 files.
339
+ // @cap-decision Lightweight line-oriented parser rather than a full markdown AST — the write-side format is fixed and deterministic, so a state machine over bullet prefixes is both sufficient and robust to ad-hoc editing (dim-prefixes, pinned tags).
340
+
341
+ /**
342
+ * Parse a single .cap/memory/{category}.md file back into structured entries.
343
+ * Applies ensureFields() on every parsed entry so pre-F-055 files migrate silently (AC-3).
344
+ *
345
+ * Hotspots use a different format (ranking table) and are intentionally not parsed here —
346
+ * the pipeline regenerates them fully each run from session data.
347
+ *
348
+ * @param {string} filePath - Absolute path to a decisions.md / pitfalls.md / patterns.md file
349
+ * @returns {{entries: Array<{content:string, metadata:Object, anchor:string|null}>}}
350
+ */
351
+ function readMemoryFile(filePath) {
352
+ if (!fs.existsSync(filePath)) return { entries: [] };
353
+ const raw = fs.readFileSync(filePath, 'utf8');
354
+ const lines = raw.split('\n');
355
+
356
+ const entries = [];
357
+ let current = null;
358
+
359
+ const stripQuote = (line) => line.replace(/^>\s?/, '');
360
+
361
+ const flush = () => {
362
+ if (!current) return;
363
+ // @cap-todo(ac:F-055/AC-3) Missing confidence/evidence_count fields get defaulted silently on read.
364
+ current.metadata = confidence.ensureFields(current.metadata);
365
+ entries.push(current);
366
+ current = null;
367
+ };
368
+
369
+ for (let rawLine of lines) {
370
+ const quoted = rawLine.startsWith('>');
371
+ const line = quoted ? stripQuote(rawLine) : rawLine;
372
+
373
+ // Heading opens a new entry: "### <a id="HASH"></a>[*(low confidence)* ]Content[ **[pinned]**]"
374
+ const headingMatch = line.match(/^###\s+<a id="([a-f0-9]+)"><\/a>\s*(.*)$/);
375
+ if (headingMatch) {
376
+ flush();
377
+ let title = headingMatch[2].trim();
378
+ // Strip dim marker + pinned suffix from the displayed content.
379
+ const dim = title.startsWith('*(low confidence)*');
380
+ if (dim) title = title.slice('*(low confidence)*'.length).trim();
381
+ const pinned = / \*\*\[pinned\]\*\*\s*$/.test(title);
382
+ title = title.replace(/ \*\*\[pinned\]\*\*\s*$/, '').trim();
383
+
384
+ current = {
385
+ content: title,
386
+ anchor: headingMatch[1],
387
+ metadata: {
388
+ pinned,
389
+ relatedFiles: [],
390
+ features: [],
391
+ },
392
+ };
393
+ continue;
394
+ }
395
+
396
+ if (!current) continue;
397
+
398
+ // Terminator: a footer rule or the totals line ends the last entry.
399
+ if (/^---\s*$/.test(line) || /^\*\d+\s+\w+s total\*/.test(line)) {
400
+ flush();
401
+ continue;
402
+ }
403
+
404
+ // Bullets:
405
+ const dateMatch = line.match(/^-\s+\*\*Date:\*\*\s+(.+?)(?:\s+\((.+?)\))?\s*$/);
406
+ if (dateMatch) {
407
+ const dateStr = dateMatch[1].trim();
408
+ current.metadata.source = dateStr === 'unknown' ? null : dateStr;
409
+ if (dateMatch[2]) {
410
+ current.metadata.features = dateMatch[2].split(',').map((f) => f.trim()).filter(Boolean);
411
+ }
412
+ continue;
413
+ }
414
+
415
+ const filesMatch = line.match(/^-\s+\*\*Files:\*\*\s+(.+?)\s*$/);
416
+ if (filesMatch) {
417
+ const body = filesMatch[1].trim();
418
+ if (body === 'cross-cutting') {
419
+ current.metadata.relatedFiles = [];
420
+ } else {
421
+ current.metadata.relatedFiles = [...body.matchAll(/`([^`]+)`/g)].map((m) => m[1]);
422
+ }
423
+ continue;
424
+ }
425
+
426
+ const confMatch = line.match(/^-\s+\*\*Confidence:\*\*\s+([0-9.]+)\s*$/);
427
+ if (confMatch) {
428
+ current.metadata.confidence = Number(confMatch[1]);
429
+ continue;
430
+ }
431
+
432
+ const eviMatch = line.match(/^-\s+\*\*Evidence:\*\*\s+(\d+)\s*$/);
433
+ if (eviMatch) {
434
+ current.metadata.evidence_count = Number(eviMatch[1]);
435
+ continue;
436
+ }
437
+
438
+ // @cap-todo(ac:F-056/AC-3) Last Seen parsed back; missing values get ensureFields-migrated.
439
+ const lastSeenMatch = line.match(/^-\s+\*\*Last Seen:\*\*\s+(.+?)\s*$/);
440
+ if (lastSeenMatch) {
441
+ current.metadata.last_seen = lastSeenMatch[1].trim();
442
+ continue;
443
+ }
444
+
445
+ const confirmedMatch = line.match(/^-\s+\*\*Confirmed:\*\*\s+(\d+)\s+times\s*$/);
446
+ if (confirmedMatch) {
447
+ current.metadata.confirmations = Number(confirmedMatch[1]);
448
+ continue;
449
+ }
450
+ }
451
+
452
+ flush();
453
+ return { entries };
454
+ }
455
+
456
+ // =====================================================================
457
+ // F-093: V6 Per-Feature Memory Pipeline Layout
458
+ // =====================================================================
459
+ //
460
+ // @cap-feature(feature:F-093, primary:true) V6 layout opt-in via .cap/config.json
461
+ // { memory: { layout: 'v6' } }. When enabled, writeMemoryDirectory groups entries
462
+ // by feature using F-077's classifier (sourceFileToFeatureId code-tag reverse-index
463
+ // + FEATURE-MAP key_files), writes per-feature files under .cap/memory/features/
464
+ // and platform/, and produces top-level decisions.md/pitfalls.md as Index files.
465
+ //
466
+ // The classifier is shared with F-077 (one-shot migration), so the routing decisions
467
+ // are consistent: a Hub session running incremental V6 yields the same per-feature
468
+ // distribution that F-077 produced from the V5 monolith snapshot.
469
+ //
470
+ // Manual edits in per-feature files are preserved across regeneration via F-076's
471
+ // auto-block markers (<!-- cap:auto:start --> / <!-- cap:auto:end -->).
472
+
473
+ /**
474
+ * @param {string} projectRoot
475
+ * @param {Object} [options]
476
+ * @returns {boolean}
477
+ */
478
+ function _isV6LayoutEnabled(projectRoot, options) {
479
+ if (options && options.layout === 'v6') return true;
480
+ if (options && options.layout === 'v5') return false;
481
+ if (!projectRoot) return false;
482
+ try {
483
+ const cfgPath = path.join(projectRoot, '.cap', 'config.json');
484
+ const raw = fs.readFileSync(cfgPath, 'utf8');
485
+ const parsed = JSON.parse(raw);
486
+ return !!(parsed && parsed.memory && parsed.memory.layout === 'v6');
487
+ } catch (_e) {
488
+ return false;
489
+ }
490
+ }
491
+
492
+ // =====================================================================
493
+ // F-096: Cross-App Memory Aggregation Index
494
+ // =====================================================================
495
+ //
496
+ // @cap-feature(feature:F-096, primary:true) Monorepo-aware V6 aggregation.
497
+ // Detects sub-apps with V6 layout active under apps/* and routes app-
498
+ // tagged entries away from the root (which would otherwise duplicate
499
+ // features that the sub-app pipeline already owns). Cross-cutting
500
+ // entries (no app source) and ambiguous entries (multiple apps) stay
501
+ // at root. Root index lists everything with cross-app paths.
502
+ //
503
+ // @cap-decision(F-096) Read-only on sub-apps: root pipeline NEVER writes
504
+ // to apps/<app>/.cap/memory/. Sub-app pipeline owns its own features/.
505
+ // Root just uses sub-app filenames for the index. Avoids race conditions
506
+ // between root + sub-app pipeline runs.
507
+ //
508
+ // @cap-decision(F-096) Auto-detected, no flag: monorepo layout is detected
509
+ // by presence of apps/<name>/.cap/memory/decisions.md with the (V6 Index)
510
+ // marker. No user-facing flag — symmetrical to F-093 (config-only) and
511
+ // keeps the surface area small.
512
+
513
+ /**
514
+ * Detect monorepo layout: returns array of sub-app names that have V6 layout active.
515
+ * Empty array means single-app project (or no V6 sub-apps yet) — fall back to F-093 default.
516
+ * @param {string} projectRoot
517
+ * @returns {string[]} sub-app names (e.g. ['hub', 'booking'])
518
+ */
519
+ function _isMonorepoLayout(projectRoot) {
520
+ if (!projectRoot) return [];
521
+ const appsDir = path.join(projectRoot, 'apps');
522
+ if (!fs.existsSync(appsDir)) return [];
523
+ let entries;
524
+ try {
525
+ entries = fs.readdirSync(appsDir, { withFileTypes: true });
526
+ } catch (_e) {
527
+ return [];
528
+ }
529
+ const v6Apps = [];
530
+ for (const entry of entries) {
531
+ if (!entry.isDirectory()) continue;
532
+ const subDecisions = path.join(appsDir, entry.name, '.cap', 'memory', 'decisions.md');
533
+ if (!fs.existsSync(subDecisions)) continue;
534
+ let raw;
535
+ try { raw = fs.readFileSync(subDecisions, 'utf8'); } catch (_e) { continue; }
536
+ if (raw.includes('(V6 Index)')) v6Apps.push(entry.name);
537
+ }
538
+ return v6Apps;
539
+ }
540
+
541
+ /**
542
+ * Resolve which sub-app a source-file belongs to. Returns null if file is
543
+ * not under apps/<v6Apps[i]>/. Path is normalized to forward-slashes; leading
544
+ * slashes are stripped before matching.
545
+ * @param {string|undefined} filePath
546
+ * @param {string[]} v6Apps - sub-apps with V6 active
547
+ * @returns {string|null}
548
+ */
549
+ function _resolveAppForFile(filePath, v6Apps) {
550
+ if (!filePath || !v6Apps || v6Apps.length === 0) return null;
551
+ const normalized = String(filePath).replace(/\\/g, '/').replace(/^\/+/, '');
552
+ const match = normalized.match(/^apps\/([^/]+)\//);
553
+ if (!match) return null;
554
+ return v6Apps.includes(match[1]) ? match[1] : null;
555
+ }
556
+
557
+ /**
558
+ * Look up an existing V6 feature-file in a sub-app by featureId prefix.
559
+ * Returns the filename (e.g. "F-HUB-USER-MESSAGES-in-app-direktnachrichten.md")
560
+ * or null if no matching file exists. Used by the root index to render
561
+ * cross-app links without inventing slugs.
562
+ * @param {string} projectRoot
563
+ * @param {string} app
564
+ * @param {string} featureId
565
+ * @returns {string|null}
566
+ */
567
+ function _findSubAppFeatureFile(projectRoot, app, featureId) {
568
+ const featuresDir = path.join(projectRoot, 'apps', app, '.cap', 'memory', 'features');
569
+ if (!fs.existsSync(featuresDir)) return null;
570
+ let files;
571
+ try { files = fs.readdirSync(featuresDir); } catch (_e) { return null; }
572
+ // CAP convention: filename = `<FEATURE-ID>-<slug>.md` where featureId is UPPERCASE
573
+ // and slug starts with a lowercase letter or digit. So `F-HUB-CHAT` should match
574
+ // `F-HUB-CHAT-some-slug.md` but NOT `F-HUB-CHAT-VOICE-NOTES-other.md` — the trailing
575
+ // `V` (uppercase) signals that VOICE-NOTES is a continuation of the featureId, not slug.
576
+ const prefix = `${featureId}-`;
577
+ const match = files.find((f) => {
578
+ if (!f.startsWith(prefix) || !f.endsWith('.md')) return false;
579
+ const after = f.slice(prefix.length);
580
+ if (after.length === 0) return false;
581
+ // First char of slug must be lowercase letter or digit (or hyphen for edge cases).
582
+ const first = after.charAt(0);
583
+ return first === first.toLowerCase() && /[a-z0-9]/.test(first);
584
+ });
585
+ return match || null;
586
+ }
587
+
588
+ /**
589
+ * Slugify a string for use in filenames. Mirrors F-077's _slugify behavior
590
+ * (lowercase, alpha-num + hyphens, trim).
591
+ * @param {string} s
592
+ * @returns {string}
593
+ */
594
+ function _slugifyForV6(s) {
595
+ return String(s || '')
596
+ .toLowerCase()
597
+ .replace(/[^a-z0-9]+/g, '-')
598
+ .replace(/^-+|-+$/g, '')
599
+ .slice(0, 80);
600
+ }
601
+
602
+ /**
603
+ * Resolve a feature title from FEATURE-MAP given the F-NNN id. Falls back to the id itself.
604
+ * @param {string} featureId
605
+ * @param {Array<{id: string, title: string}>=} features
606
+ * @returns {string}
607
+ */
608
+ function _featureTitleFor(featureId, features) {
609
+ if (!features) return featureId;
610
+ const f = features.find((x) => x.id === featureId);
611
+ return f && f.title ? f.title : featureId;
612
+ }
613
+
614
+ /**
615
+ * Group entries into V6 destinations using F-077's classifier.
616
+ * Returns a Map keyed by destination identifier ("feature:F-XXX" or "platform:topic")
617
+ * with values { destination, featureId?, topic?, decisions, pitfalls }.
618
+ * @param {Array} entries
619
+ * @param {Object} context - F-077 ClassifierContext
620
+ * @returns {Map<string, {destination: string, featureId?: string, topic?: string, decisions: Array, pitfalls: Array}>}
621
+ */
622
+ function _groupEntriesByDestination(entries, context, classifyEntry) {
623
+ const groups = new Map();
624
+ for (const entry of entries) {
625
+ if (entry.category === 'hotspot' || entry.category === 'pattern') continue; // V6 only handles decision/pitfall per F-076 schema
626
+ const v5Entry = {
627
+ kind: entry.category,
628
+ title: entry.content,
629
+ content: entry.content,
630
+ relatedFiles: (entry.metadata && entry.metadata.relatedFiles) || [],
631
+ taggedFeatureId: entry.metadata && entry.metadata.features && entry.metadata.features.length > 0 ? entry.metadata.features[0] : null,
632
+ anchorId: '',
633
+ dateLabel: '',
634
+ };
635
+ const decision = classifyEntry(v5Entry, context);
636
+ let key, group;
637
+ if (decision.destination === 'feature' && decision.confidence >= 0.7) {
638
+ key = `feature:${decision.featureId}`;
639
+ if (!groups.has(key)) groups.set(key, { destination: 'feature', featureId: decision.featureId, topic: decision.topic, decisions: [], pitfalls: [] });
640
+ group = groups.get(key);
641
+ } else {
642
+ const topic = decision.topic || 'unassigned';
643
+ key = `platform:${topic}`;
644
+ if (!groups.has(key)) groups.set(key, { destination: 'platform', topic, decisions: [], pitfalls: [] });
645
+ group = groups.get(key);
646
+ }
647
+ if (entry.category === 'decision') group.decisions.push(entry);
648
+ else group.pitfalls.push(entry);
649
+ }
650
+ return groups;
651
+ }
652
+
653
+ /**
654
+ * Build the auto-block items array (decisions or pitfalls) for F-076 schema.
655
+ * @param {Array} entries
656
+ * @returns {Array<{text: string, location?: string}>}
657
+ */
658
+ function _toAutoBlockItems(entries) {
659
+ return entries.map((e) => {
660
+ const item = { text: e.content };
661
+ const files = (e.metadata && e.metadata.relatedFiles) || [];
662
+ if (files.length > 0) item.location = files[0];
663
+ return item;
664
+ });
665
+ }
666
+
667
+ /**
668
+ * Write the top-level Index file (decisions.md or pitfalls.md) summarizing
669
+ * per-feature counts. Replaces the V5 monolith with a sparse pointer table.
670
+ * @param {string} category 'decision' | 'pitfall'
671
+ * @param {Map} groups
672
+ * @param {Object} context
673
+ */
674
+ function _renderV6Index(category, groups, context, indexOptions = {}) {
675
+ const filename = CATEGORY_FILES[category];
676
+ const titleCat = category.charAt(0).toUpperCase() + category.slice(1) + 's';
677
+ const isAggregating = Array.isArray(indexOptions.aggregatedAppFeatures);
678
+ const lines = [
679
+ `# Project Memory: ${titleCat} (V6 Index)`,
680
+ '',
681
+ isAggregating
682
+ ? `> **V6 layout active (monorepo aggregation).** Per-feature ${category}s live in \`.cap/memory/features/\` (cross-cutting) and \`apps/<app>/.cap/memory/features/\` (app-owned, see "Cross-App" rows). This file is an auto-generated index — see the linked feature file for the actual entries.`
683
+ : `> **V6 layout active.** Per-feature ${category}s live in \`.cap/memory/features/\` and \`.cap/memory/platform/\`. This file is an auto-generated index — see the linked feature file for the actual entries.`,
684
+ `> Last updated: ${new Date().toISOString().substring(0, 10)}`,
685
+ '',
686
+ '| Destination | Count | File |',
687
+ '|---|---|---|',
688
+ ];
689
+ // Sort: features alphabetically, then platform topics
690
+ const featureGroups = [...groups.values()].filter((g) => g.destination === 'feature').sort((a, b) => String(a.featureId).localeCompare(String(b.featureId)));
691
+ const platformGroups = [...groups.values()].filter((g) => g.destination === 'platform').sort((a, b) => String(a.topic).localeCompare(String(b.topic)));
692
+ for (const g of featureGroups) {
693
+ const items = category === 'decision' ? g.decisions : g.pitfalls;
694
+ if (items.length === 0) continue;
695
+ const slug = _slugifyForV6(_featureTitleFor(g.featureId, context.features));
696
+ const file = `features/${g.featureId}-${slug}.md`;
697
+ lines.push(`| ${g.featureId} | ${items.length} | [${file}](${file}) |`);
698
+ }
699
+ for (const g of platformGroups) {
700
+ const items = category === 'decision' ? g.decisions : g.pitfalls;
701
+ if (items.length === 0) continue;
702
+ const file = `platform/${g.topic}.md`;
703
+ lines.push(`| platform/${g.topic} | ${items.length} | [${file}](${file}) |`);
704
+ }
705
+ // F-096: Cross-app aggregated features (sub-app owns the file, root just indexes)
706
+ if (isAggregating && indexOptions.aggregatedAppFeatures.length > 0) {
707
+ const agg = [...indexOptions.aggregatedAppFeatures].sort((a, b) => {
708
+ if (a.app !== b.app) return a.app.localeCompare(b.app);
709
+ return a.featureId.localeCompare(b.featureId);
710
+ });
711
+ lines.push('');
712
+ lines.push(`## Cross-App (sub-app owned)`);
713
+ lines.push('');
714
+ lines.push('| Feature | App | Count | File |');
715
+ lines.push('|---|---|---|---|');
716
+ for (const f of agg) {
717
+ const count = category === 'decision' ? f.decisionsCount : f.pitfallsCount;
718
+ if (count === 0) continue;
719
+ // Path resolves from .cap/memory/decisions.md → ../../apps/<app>/.cap/memory/features/<file>
720
+ // If sub-app pipeline hasn't created the file yet (fileName === null), point at the directory.
721
+ const file = f.fileName
722
+ ? `../../apps/${f.app}/.cap/memory/features/${f.fileName}`
723
+ : `../../apps/${f.app}/.cap/memory/features/`;
724
+ const display = f.fileName || `${f.featureId} (pending sub-app pipeline)`;
725
+ lines.push(`| ${f.featureId} | ${f.app} | ${count} | [${display}](${file}) |`);
726
+ }
727
+ }
728
+ return lines.join('\n') + '\n';
729
+ }
730
+
731
+ /**
732
+ * Write a per-feature file using F-076 schema (auto-block + manual-block preservation).
733
+ * @param {string} filePath
734
+ * @param {string} title
735
+ * @param {Array} decisions
736
+ * @param {Array} pitfalls
737
+ */
738
+ function _writeV6FeatureFile(filePath, title, decisions, pitfalls) {
739
+ const schema = require('./cap-memory-schema.cjs');
740
+ let parsed;
741
+ try {
742
+ const existing = fs.readFileSync(filePath, 'utf8');
743
+ parsed = schema.parseFeatureMemoryFile(existing);
744
+ } catch (_e) {
745
+ parsed = {
746
+ frontmatter: {},
747
+ autoBlock: { decisions: [], pitfalls: [] },
748
+ manualBlock: { raw: `# ${title}\n\n` },
749
+ };
750
+ }
751
+ parsed.autoBlock = {
752
+ decisions: _toAutoBlockItems(decisions),
753
+ pitfalls: _toAutoBlockItems(pitfalls),
754
+ };
755
+ const out = schema.serializeFeatureMemoryFile(parsed);
756
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
757
+ fs.writeFileSync(filePath, out, 'utf8');
758
+ }
759
+
760
+ /**
761
+ * Archive existing V5 monolith files to .archive/ before first V6 write,
762
+ * mirroring F-077's backup convention. Idempotent on same date.
763
+ * @param {string} projectRoot
764
+ */
765
+ function _archiveV5IfPresent(projectRoot) {
766
+ const memDir = path.join(projectRoot, MEMORY_DIR);
767
+ const archiveDir = path.join(memDir, '.archive');
768
+ const date = new Date().toISOString().substring(0, 10);
769
+ for (const filename of Object.values(CATEGORY_FILES)) {
770
+ const src = path.join(memDir, filename);
771
+ if (!fs.existsSync(src)) continue;
772
+ // Only archive if file looks like a V5 monolith — V6 index files have a special marker.
773
+ let raw;
774
+ try { raw = fs.readFileSync(src, 'utf8'); } catch (_e) { continue; }
775
+ if (raw.includes('(V6 Index)')) continue; // already a V6 index, skip
776
+ const base = filename.replace(/\.md$/, '');
777
+ const dest = path.join(archiveDir, `${base}-pre-v6-${date}.md`);
778
+ if (fs.existsSync(dest)) continue; // idempotent on same date
779
+ fs.mkdirSync(archiveDir, { recursive: true });
780
+ fs.copyFileSync(src, dest);
781
+ }
782
+ }
783
+
784
+ /**
785
+ * V6 layout writer — entry point dispatched from writeMemoryDirectory when
786
+ * { memory: { layout: 'v6' } } is set in .cap/config.json.
787
+ * @param {string} projectRoot
788
+ * @param {Array} entries
789
+ * @param {Object} options
790
+ * @returns {{files: Object<string, string>, written: number}}
791
+ */
792
+ function _writeMemoryV6(projectRoot, entries, options = {}) {
793
+ // Lazy-load the F-077 classifier to avoid coupling the V5 path.
794
+ const migrate = require('./cap-memory-migrate.cjs');
795
+ const context = migrate.buildClassifierContext(projectRoot);
796
+
797
+ const groups = _groupEntriesByDestination(entries, context, migrate.classifyEntry);
798
+
799
+ // F-096: detect monorepo aggregation mode. Returns sub-apps with V6 active.
800
+ // options.aggregate === false explicitly opts out (test-friendly + escape hatch).
801
+ const v6Apps = options.aggregate === false ? [] : _isMonorepoLayout(projectRoot);
802
+ const isAggregating = v6Apps.length > 0;
803
+
804
+ // F-096: split groups into "owned by sub-app" (skip writing locally, just index)
805
+ // vs "stays at root" (write locally as before).
806
+ // A feature-group is owned by a sub-app when ALL its source-files resolve to that
807
+ // single app. Multi-app or no-app entries stay at root (cross-cutting / ambiguous).
808
+ const aggregatedAppFeatures = []; // [{ app, featureId, count, decisionsCount, pitfallsCount, fileName }]
809
+ const localGroups = new Map();
810
+ for (const [key, g] of groups) {
811
+ if (isAggregating && g.destination === 'feature') {
812
+ const items = [...g.decisions, ...g.pitfalls];
813
+ const apps = new Set(
814
+ items
815
+ .map((e) => {
816
+ const f = (e.metadata && e.metadata.relatedFiles && e.metadata.relatedFiles[0]) || e.file;
817
+ return _resolveAppForFile(f, v6Apps);
818
+ })
819
+ .filter(Boolean),
820
+ );
821
+ if (apps.size === 1) {
822
+ // Single sub-app owns this feature → don't write at root, just track for index.
823
+ const app = [...apps][0];
824
+ const fileName = _findSubAppFeatureFile(projectRoot, app, g.featureId);
825
+ aggregatedAppFeatures.push({
826
+ app,
827
+ featureId: g.featureId,
828
+ decisionsCount: g.decisions.length,
829
+ pitfallsCount: g.pitfalls.length,
830
+ fileName, // may be null if sub-app pipeline hasn't created it yet
831
+ });
832
+ continue;
833
+ }
834
+ // Multi-app or no-app → keep at root.
835
+ }
836
+ localGroups.set(key, g);
837
+ }
838
+
839
+ const memDir = path.join(projectRoot, MEMORY_DIR);
840
+ if (!options.dryRun) {
841
+ fs.mkdirSync(memDir, { recursive: true });
842
+ _archiveV5IfPresent(projectRoot);
843
+ }
844
+
845
+ const files = {};
846
+ let written = 0;
847
+
848
+ // Per-feature + per-platform writes (only localGroups in aggregation mode)
849
+ for (const g of localGroups.values()) {
850
+ let filePath, title;
851
+ if (g.destination === 'feature') {
852
+ const slug = _slugifyForV6(_featureTitleFor(g.featureId, context.features));
853
+ filePath = path.join(memDir, 'features', `${g.featureId}-${slug}.md`);
854
+ title = `${g.featureId}: ${_featureTitleFor(g.featureId, context.features)}`;
855
+ } else {
856
+ filePath = path.join(memDir, 'platform', `${g.topic}.md`);
857
+ title = `Platform: ${g.topic}`;
858
+ }
859
+ if (!options.dryRun) {
860
+ _writeV6FeatureFile(filePath, title, g.decisions, g.pitfalls);
861
+ written++;
862
+ }
863
+ // Snapshot for return value
864
+ const relKey = path.relative(memDir, filePath);
865
+ files[relKey] = `${title}\n decisions:${g.decisions.length}\n pitfalls:${g.pitfalls.length}`;
866
+ }
867
+
868
+ // Top-level Index files (include aggregated app features in F-096 mode)
869
+ for (const cat of ['decision', 'pitfall']) {
870
+ const indexContent = _renderV6Index(cat, localGroups, context, {
871
+ aggregatedAppFeatures: isAggregating ? aggregatedAppFeatures : null,
872
+ });
873
+ const filename = CATEGORY_FILES[cat];
874
+ files[filename] = indexContent;
875
+ if (!options.dryRun) {
876
+ fs.writeFileSync(path.join(memDir, filename), indexContent, 'utf8');
877
+ written++;
878
+ }
879
+ }
880
+
881
+ // Skip patterns/hotspots in V6 — those remain V5-monolith for now (out-of-scope per F-076 schema).
882
+ // Generate empty stubs only if they don't exist, to preserve legacy callers.
883
+
884
+ return { files, written };
885
+ }
886
+
887
+ // @cap-feature(feature:F-095, primary:true) Memory Layout-Switch Activation CLI — leichtgewichtige Aktivierung von V6
888
+ // ohne session-reprocess. Liest existing V5 entries via readMemoryFile, persistiert config.json, ruft writeMemoryDirectory
889
+ // einmal mit V6-dispatch. Workflow-Lücke aus F-093: Stop-Hook returnt früh ohne neue Sessions, V6-config greift nicht
890
+ // beim ersten Toggle. /cap:memory init wäre heavy (alle Sessions reprocess); switchLayout ist <1s auf Hub-Größe.
891
+ // @cap-decision(F-095) write-then-rollback statt config-first: writeMemoryDirectory zuerst (try/catch), config.json
892
+ // wird erst NACH success geschrieben. Bei error bleiben V5-Files + alte config unverändert. Atomicity via Schreib-Order,
893
+ // nicht via Locks.
894
+ /**
895
+ * Switch the memory layout for a project (V5 → V6).
896
+ * Reads existing V5 entries, persists config.json with the new layout flag,
897
+ * and triggers writeMemoryDirectory once so the V6 dispatch produces the
898
+ * per-feature/platform files and Index.
899
+ *
900
+ * Idempotent for V6→V6: detects the `(V6 Index)` marker and short-circuits.
901
+ *
902
+ * @param {string} projectRoot
903
+ * @param {string} target - currently only 'v6' supported
904
+ * @returns {{ status: 'switched'|'noop', target: string, sourceEntries: number, written: number, configPath: string, archives: string[] }}
905
+ */
906
+ function switchLayout(projectRoot, target) {
907
+ if (target !== 'v6') {
908
+ throw new Error(`switchLayout: unsupported target "${target}" (only "v6" supported in F-095)`);
909
+ }
910
+
911
+ const memDir = path.join(projectRoot, MEMORY_DIR);
912
+ const configPath = path.join(projectRoot, '.cap', 'config.json');
913
+
914
+ // AC-3: idempotency check — if top-level decisions.md already has the V6 marker, no-op.
915
+ const decisionsFile = path.join(memDir, CATEGORY_FILES.decision);
916
+ if (fs.existsSync(decisionsFile)) {
917
+ const raw = fs.readFileSync(decisionsFile, 'utf8');
918
+ if (raw.includes('(V6 Index)')) {
919
+ return { status: 'noop', target, sourceEntries: 0, written: 0, configPath, archives: [] };
920
+ }
921
+ }
922
+
923
+ // Read existing V5 entries (decisions + pitfalls). patterns/hotspots stay V5-monolith per F-093 schema.
924
+ const pitfallsFile = path.join(memDir, CATEGORY_FILES.pitfall);
925
+ const decEntries = fs.existsSync(decisionsFile)
926
+ ? readMemoryFile(decisionsFile).entries.map(e => ({ ...e, category: 'decision' }))
927
+ : [];
928
+ const pitEntries = fs.existsSync(pitfallsFile)
929
+ ? readMemoryFile(pitfallsFile).entries.map(e => ({ ...e, category: 'pitfall' }))
930
+ : [];
931
+ const allEntries = [...decEntries, ...pitEntries];
932
+
933
+ // AC-2: writeMemoryDirectory zuerst (force layout via options); config.json schreiben wir erst nach success.
934
+ // Force layout via options.layout — bypasses config.json read so a missing/invalid config doesn't block the switch.
935
+ const result = writeMemoryDirectory(projectRoot, allEntries, { layout: target });
936
+
937
+ // Persist config.json (merge with existing if present).
938
+ const capDir = path.join(projectRoot, '.cap');
939
+ if (!fs.existsSync(capDir)) fs.mkdirSync(capDir, { recursive: true });
940
+ let config = {};
941
+ if (fs.existsSync(configPath)) {
942
+ try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')) || {}; } catch (_e) { config = {}; }
943
+ }
944
+ config.memory = { ...(config.memory || {}), layout: target };
945
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
946
+
947
+ // Collect archive list for reporting (AC-4).
948
+ const archiveDir = path.join(memDir, '.archive');
949
+ const archives = fs.existsSync(archiveDir)
950
+ ? fs.readdirSync(archiveDir).filter(f => f.includes('pre-v6'))
951
+ : [];
952
+
953
+ return {
954
+ status: 'switched',
955
+ target,
956
+ sourceEntries: allEntries.length,
957
+ written: result.written,
958
+ configPath,
959
+ archives,
960
+ };
961
+ }
962
+
963
+ module.exports = {
964
+ generateAnchorId,
965
+ generateCategoryMarkdown,
966
+ parseExistingAnchors,
967
+ writeMemoryDirectory,
968
+ readMemoryDirectory,
969
+ readMemoryFile,
970
+ getCrossReference,
971
+ // F-090: confidence filter exposed for tests + downstream tools that want the same gating.
972
+ _filterEntriesForOutput,
973
+ // F-093: V6 layout helpers exposed for testing.
974
+ _isV6LayoutEnabled,
975
+ _writeMemoryV6,
976
+ _groupEntriesByDestination,
977
+ _renderV6Index,
978
+ _archiveV5IfPresent,
979
+ // F-095: Layout-Switch Activation CLI.
980
+ switchLayout,
981
+ // F-096: Cross-app aggregation helpers exposed for testing.
982
+ _isMonorepoLayout,
983
+ _resolveAppForFile,
984
+ _findSubAppFeatureFile,
985
+ MEMORY_DIR,
986
+ CATEGORY_FILES,
987
+ };