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,945 @@
1
+ // @cap-feature(feature:F-038) Neural Cluster Detection — single-linkage clustering over thread nodes, divergence-based decay, dormant node management, and auto-labeling
2
+ // @cap-decision Pure logic module — zero I/O, zero external dependencies. All functions accept data and return structured results.
3
+ // @cap-decision Single-linkage clustering chosen for simplicity and interpretability — MAX affinity between any members of two clusters determines merge eligibility.
4
+ // @cap-decision Divergence-based decay uses MAX of three drift metrics (file-drift, keyword-drift, cluster-drift) as the combined decay factor, applied with a configurable damping rate (default 0.3) to prevent abrupt weight drops.
5
+ // @cap-decision No time-based decay — only measured divergence reduces scores. A thread from 6 months ago with still-relevant keywords keeps full affinity.
6
+ // @cap-decision Cluster IDs are stable hashes of sorted member thread IDs — deterministic as long as membership does not change.
7
+ // @cap-constraint Zero external dependencies — uses only Node.js built-ins (crypto).
8
+
9
+ 'use strict';
10
+
11
+ const crypto = require('node:crypto');
12
+
13
+ // --- Constants ---
14
+
15
+ // @cap-todo(ac:F-038/AC-1) Configurable linkage threshold (default 0.40)
16
+ /** Default linkage threshold for cluster merging. */
17
+ const DEFAULT_LINKAGE_THRESHOLD = 0.40;
18
+
19
+ // @cap-todo(ac:F-038/AC-3) Decay rate controls how aggressively drift reduces edge weights.
20
+ /** Default decay damping rate — new weight = current * (1 - maxDrift * DECAY_RATE). */
21
+ const DEFAULT_DECAY_RATE = 0.3;
22
+
23
+ // @cap-todo(ac:F-038/AC-5) Dormant nodes reactivate when new affinity score >= 0.40
24
+ /** Threshold for dormant node reactivation. */
25
+ const DORMANT_REACTIVATION_THRESHOLD = 0.40;
26
+
27
+ // --- Types ---
28
+
29
+ /**
30
+ * @typedef {Object} Cluster
31
+ * @property {string} id - Stable cluster ID (hash of sorted member thread IDs)
32
+ * @property {string[]} members - Array of thread IDs in this cluster
33
+ * @property {string} label - Auto-generated label from top 2-3 concepts
34
+ * @property {string} createdAt - ISO timestamp
35
+ */
36
+
37
+ /**
38
+ * @typedef {Object} ClusterResult
39
+ * @property {Cluster[]} clusters - Detected clusters
40
+ * @property {Object} graph - Mutated graph with cluster membership assigned
41
+ */
42
+
43
+ /**
44
+ * @typedef {Object} DriftMetrics
45
+ * @property {number} fileDrift - File intersection drift (0.0-1.0)
46
+ * @property {number} keywordDrift - Keyword Jaccard divergence (0.0-1.0)
47
+ * @property {number} clusterDrift - Cluster affinity drift (0.0-1.0)
48
+ * @property {number} maxDrift - Maximum of the three drift metrics
49
+ */
50
+
51
+ /**
52
+ * @typedef {Object} DecayResult
53
+ * @property {Array<{source: string, target: string, oldWeight: number, newWeight: number}>} decayedEdges - Edges that had their weight reduced
54
+ * @property {string[]} dormantNodes - Node IDs newly marked dormant
55
+ * @property {string[]} reactivatedNodes - Node IDs reactivated from dormancy
56
+ */
57
+
58
+ /**
59
+ * @typedef {Object} AffinityResult
60
+ * @property {string} sourceThreadId - First thread ID
61
+ * @property {string} targetThreadId - Second thread ID
62
+ * @property {number} compositeScore - Weighted composite score (0.0-1.0)
63
+ * @property {string} band - Classification band
64
+ * @property {Object[]} signals - Individual signal results
65
+ * @property {string} computedAt - ISO timestamp
66
+ */
67
+
68
+ /**
69
+ * @typedef {Object} ClusterDetectionOptions
70
+ * @property {number} [linkageThreshold] - Minimum affinity for cluster merging
71
+ * @property {Object<string, string[]>} [taxonomy] - Concept taxonomy for labeling
72
+ * @property {number} [decayRate] - Decay damping rate
73
+ */
74
+
75
+ // --- Utility Functions ---
76
+
77
+ /**
78
+ * Clamp a number to [0.0, 1.0].
79
+ * @param {number} n
80
+ * @returns {number}
81
+ */
82
+ function _clamp01(n) {
83
+ return Math.max(0, Math.min(1, n));
84
+ }
85
+
86
+ /**
87
+ * Compute Jaccard similarity between two sets.
88
+ * @param {Set<string>} setA
89
+ * @param {Set<string>} setB
90
+ * @returns {number} Similarity score (0.0-1.0)
91
+ */
92
+ function _jaccard(setA, setB) {
93
+ if (setA.size === 0 && setB.size === 0) return 0;
94
+
95
+ let intersectionCount = 0;
96
+ for (const item of setA) {
97
+ if (setB.has(item)) intersectionCount++;
98
+ }
99
+
100
+ const unionSize = setA.size + setB.size - intersectionCount;
101
+ return unionSize > 0 ? intersectionCount / unionSize : 0;
102
+ }
103
+
104
+ /**
105
+ * Find the graph node ID for a thread by its thread ID.
106
+ * Thread nodes have metadata.threadId matching the thr-XXXX id.
107
+ * @param {Object} graph - MemoryGraph
108
+ * @param {string} threadId - Thread ID (thr-XXXX)
109
+ * @returns {string|null} Graph node ID or null
110
+ */
111
+ function _findThreadNodeId(graph, threadId) {
112
+ for (const [nodeId, node] of Object.entries(graph.nodes || {})) {
113
+ if (node.type === 'thread' && node.metadata && node.metadata.threadId === threadId) {
114
+ return nodeId;
115
+ }
116
+ }
117
+ return null;
118
+ }
119
+
120
+ /**
121
+ * Get all active affinity edges from the graph.
122
+ * @param {Object} graph - MemoryGraph
123
+ * @returns {Object[]} Active affinity edges
124
+ */
125
+ function _getAffinityEdges(graph) {
126
+ return (graph.edges || []).filter(e => e.active && e.type === 'affinity');
127
+ }
128
+
129
+ /**
130
+ * Collect file paths from feature nodes connected to a thread node.
131
+ * @param {Object} graph - MemoryGraph
132
+ * @param {string} threadNodeId - Graph node ID of the thread
133
+ * @returns {Set<string>} Set of file paths
134
+ */
135
+ function _collectFilesForThread(graph, threadNodeId) {
136
+ const files = new Set();
137
+ for (const edge of (graph.edges || [])) {
138
+ if (!edge.active) continue;
139
+ let neighborId = null;
140
+ if (edge.source === threadNodeId) neighborId = edge.target;
141
+ else if (edge.target === threadNodeId) neighborId = edge.source;
142
+ if (neighborId && graph.nodes[neighborId] && graph.nodes[neighborId].type === 'feature') {
143
+ const node = graph.nodes[neighborId];
144
+ if (node.metadata && Array.isArray(node.metadata.files)) {
145
+ for (const f of node.metadata.files) {
146
+ files.add(f);
147
+ }
148
+ }
149
+ }
150
+ }
151
+ return files;
152
+ }
153
+
154
+ // --- Clustering ---
155
+
156
+ // @cap-todo(ac:F-038/AC-1) Single-linkage clustering over thread nodes using affinity scores as distance metric
157
+ /**
158
+ * Detect clusters from pairwise affinity results using single-linkage clustering.
159
+ *
160
+ * Algorithm: Start with each thread as its own cluster. Repeatedly merge the two
161
+ * clusters with highest inter-cluster affinity (single-linkage = MAX affinity between
162
+ * any member of cluster A and any member of cluster B), as long as that affinity >= threshold.
163
+ *
164
+ * @param {AffinityResult[]} affinityResults - Pairwise affinity results
165
+ * @param {Object} [options]
166
+ * @param {number} [options.linkageThreshold] - Minimum affinity for merging (default 0.40)
167
+ * @returns {Array<{id: string, members: string[]}>} Clusters (without labels yet)
168
+ */
169
+ function detectClusters(affinityResults, options) {
170
+ const threshold = (options && options.linkageThreshold != null)
171
+ ? options.linkageThreshold
172
+ : DEFAULT_LINKAGE_THRESHOLD;
173
+
174
+ // Collect all unique thread IDs
175
+ const threadIdSet = new Set();
176
+ for (const r of affinityResults) {
177
+ threadIdSet.add(r.sourceThreadId);
178
+ threadIdSet.add(r.targetThreadId);
179
+ }
180
+
181
+ // Initialize: each thread is its own cluster
182
+ // Map from thread ID -> cluster index
183
+ const threadIds = [...threadIdSet];
184
+ const clusterMap = new Map();
185
+ let nextClusterIdx = 0;
186
+
187
+ for (const tid of threadIds) {
188
+ clusterMap.set(tid, nextClusterIdx++);
189
+ }
190
+
191
+ // Build a fast lookup of affinity scores: "tidA|tidB" -> score (canonical key order)
192
+ const pairScores = new Map();
193
+ for (const r of affinityResults) {
194
+ const key = _pairKey(r.sourceThreadId, r.targetThreadId);
195
+ // Keep the maximum score if duplicates exist
196
+ const existing = pairScores.get(key) || 0;
197
+ if (r.compositeScore > existing) {
198
+ pairScores.set(key, r.compositeScore);
199
+ }
200
+ }
201
+
202
+ // Single-linkage: repeatedly merge the two clusters with the highest inter-cluster affinity
203
+ let mergedSomething = true;
204
+ while (mergedSomething) {
205
+ mergedSomething = false;
206
+
207
+ // Find distinct cluster indices
208
+ const clusterIndices = [...new Set(clusterMap.values())];
209
+ if (clusterIndices.length <= 1) break;
210
+
211
+ // Build cluster -> members mapping
212
+ const clusterMembers = new Map();
213
+ for (const [tid, cidx] of clusterMap) {
214
+ if (!clusterMembers.has(cidx)) clusterMembers.set(cidx, []);
215
+ clusterMembers.get(cidx).push(tid);
216
+ }
217
+
218
+ // Find the pair of clusters with the highest single-linkage affinity
219
+ let bestAffinity = -1;
220
+ let bestPair = null;
221
+ const clusterIdxList = [...clusterMembers.keys()];
222
+
223
+ for (let i = 0; i < clusterIdxList.length; i++) {
224
+ for (let j = i + 1; j < clusterIdxList.length; j++) {
225
+ const membersI = clusterMembers.get(clusterIdxList[i]);
226
+ const membersJ = clusterMembers.get(clusterIdxList[j]);
227
+
228
+ // Single-linkage: MAX affinity between any member pair
229
+ let maxAffinity = 0;
230
+ for (const mA of membersI) {
231
+ for (const mB of membersJ) {
232
+ const key = _pairKey(mA, mB);
233
+ const score = pairScores.get(key) || 0;
234
+ if (score > maxAffinity) maxAffinity = score;
235
+ }
236
+ }
237
+
238
+ if (maxAffinity > bestAffinity) {
239
+ bestAffinity = maxAffinity;
240
+ bestPair = [clusterIdxList[i], clusterIdxList[j]];
241
+ }
242
+ }
243
+ }
244
+
245
+ // Merge if above threshold
246
+ if (bestPair && bestAffinity >= threshold) {
247
+ const [keepIdx, mergeIdx] = bestPair;
248
+ for (const [tid, cidx] of clusterMap) {
249
+ if (cidx === mergeIdx) {
250
+ clusterMap.set(tid, keepIdx);
251
+ }
252
+ }
253
+ mergedSomething = true;
254
+ }
255
+ }
256
+
257
+ // Build final cluster objects
258
+ const clusterGroups = new Map();
259
+ for (const [tid, cidx] of clusterMap) {
260
+ if (!clusterGroups.has(cidx)) clusterGroups.set(cidx, []);
261
+ clusterGroups.get(cidx).push(tid);
262
+ }
263
+
264
+ const clusters = [];
265
+ for (const members of clusterGroups.values()) {
266
+ members.sort();
267
+ clusters.push({
268
+ id: generateClusterId(members),
269
+ members,
270
+ });
271
+ }
272
+
273
+ // Sort clusters by size descending, then by ID for stability
274
+ clusters.sort((a, b) => b.members.length - a.members.length || a.id.localeCompare(b.id));
275
+
276
+ return clusters;
277
+ }
278
+
279
+ /**
280
+ * Create a canonical pair key from two thread IDs (alphabetically sorted).
281
+ * @param {string} tidA
282
+ * @param {string} tidB
283
+ * @returns {string}
284
+ */
285
+ function _pairKey(tidA, tidB) {
286
+ return tidA < tidB ? `${tidA}|${tidB}` : `${tidB}|${tidA}`;
287
+ }
288
+
289
+ // @cap-todo(ac:F-038/AC-7) Cluster ID derived from sorted member thread IDs hash (stable as long as members don't change)
290
+ /**
291
+ * Generate a stable cluster ID from sorted member thread IDs.
292
+ * @param {string[]} memberThreadIds - Thread IDs (will be sorted internally)
293
+ * @returns {string} Cluster ID in format "cluster-{8 hex chars}"
294
+ */
295
+ function generateClusterId(memberThreadIds) {
296
+ const sorted = [...memberThreadIds].sort();
297
+ const hash = crypto.createHash('sha256')
298
+ .update(sorted.join('|'))
299
+ .digest('hex')
300
+ .substring(0, 8);
301
+ return `cluster-${hash}`;
302
+ }
303
+
304
+ // @cap-todo(ac:F-038/AC-2) Auto-generated dynamic labels from top 2-3 weighted concepts — ephemeral, recalculated each run
305
+ /**
306
+ * Generate a cluster label from the combined text of member threads projected into concept space.
307
+ * Uses the SEED_TAXONOMY from cap-semantic-pipeline for concept projection.
308
+ * Labels are ephemeral — recalculated each time, never stored as permanent names.
309
+ *
310
+ * @param {Object[]} memberThreads - Thread objects that are members of the cluster
311
+ * @param {Object<string, string[]>} taxonomy - Concept taxonomy { concept: [keywords] }
312
+ * @returns {string} Label in format "concept1 \u00b7 concept2 \u00b7 concept3"
313
+ */
314
+ function generateClusterLabel(memberThreads, taxonomy) {
315
+ if (!memberThreads || memberThreads.length === 0) return 'unnamed';
316
+ if (!taxonomy || Object.keys(taxonomy).length === 0) return 'unnamed';
317
+
318
+ // Combine all thread text
319
+ const combinedText = memberThreads.map(t => _getThreadText(t)).join(' ');
320
+
321
+ // Project into concept space
322
+ const conceptScores = _projectToConcepts(combinedText, taxonomy);
323
+
324
+ // Take top 2-3 concepts by weight
325
+ const sorted = [...conceptScores.entries()]
326
+ .sort((a, b) => b[1] - a[1])
327
+ .slice(0, 3)
328
+ .filter(([, score]) => score > 0);
329
+
330
+ if (sorted.length === 0) return 'unnamed';
331
+
332
+ return sorted.map(([concept]) => concept).join(' \u00b7 ');
333
+ }
334
+
335
+ /**
336
+ * Extract combined text from a thread for concept projection.
337
+ * @param {Object} thread - Thread object
338
+ * @returns {string}
339
+ */
340
+ function _getThreadText(thread) {
341
+ const parts = [];
342
+ if (thread.problemStatement) parts.push(thread.problemStatement);
343
+ if (thread.solutionShape) parts.push(thread.solutionShape);
344
+ if (Array.isArray(thread.boundaryDecisions)) {
345
+ parts.push(...thread.boundaryDecisions);
346
+ }
347
+ if (Array.isArray(thread.keywords)) {
348
+ parts.push(thread.keywords.join(' '));
349
+ }
350
+ if (thread.name) parts.push(thread.name);
351
+ return parts.join(' ');
352
+ }
353
+
354
+ /**
355
+ * Project text into concept space using a taxonomy.
356
+ * Mirrors the logic of cap-semantic-pipeline.cjs projectToConcepts but inlined
357
+ * to avoid cross-module dependency.
358
+ *
359
+ * @param {string} text - Text to project
360
+ * @param {Object<string, string[]>} taxonomy - Concept taxonomy
361
+ * @returns {Map<string, number>} Concept -> weight mapping
362
+ */
363
+ function _projectToConcepts(text, taxonomy) {
364
+ /** @type {Map<string, number>} */
365
+ const vector = new Map();
366
+
367
+ if (!text || typeof text !== 'string') return vector;
368
+
369
+ const lowerText = text.toLowerCase();
370
+
371
+ for (const [concept, keywords] of Object.entries(taxonomy)) {
372
+ let matchCount = 0;
373
+ for (const kw of keywords) {
374
+ if (lowerText.indexOf(kw) !== -1) {
375
+ matchCount++;
376
+ }
377
+ }
378
+ const score = keywords.length > 0 ? matchCount / keywords.length : 0;
379
+ if (score > 0) {
380
+ vector.set(concept, score);
381
+ }
382
+ }
383
+
384
+ return vector;
385
+ }
386
+
387
+ // --- Divergence Decay ---
388
+
389
+ // @cap-todo(ac:F-038/AC-3) Divergence-based decay using 3 drift metrics: file-drift, keyword-drift, cluster-drift
390
+ // @cap-todo(ac:F-038/AC-6) No time-based decay — only measured divergence reduces scores
391
+ /**
392
+ * Compute drift metrics between two threads based on their current state vs. previous affinity.
393
+ *
394
+ * Three drift metrics:
395
+ * 1. file-drift: Shrinking file intersection relative to original shared files
396
+ * 2. keyword-drift: Jaccard divergence of current keyword sets
397
+ * 3. cluster-drift: Drop in average affinity to cluster members vs. original
398
+ *
399
+ * @param {Object} threadNodeA - Graph node for thread A
400
+ * @param {Object} threadNodeB - Graph node for thread B
401
+ * @param {Object} graph - MemoryGraph
402
+ * @param {Object} previousAffinity - Previous affinity edge metadata { compositeScore, originalSharedFiles }
403
+ * @param {Object} [currentAffinityMap] - Map of pair keys to current affinity scores
404
+ * @returns {DriftMetrics}
405
+ */
406
+ function computeDrift(threadNodeA, threadNodeB, graph, previousAffinity, currentAffinityMap) {
407
+ // --- File drift ---
408
+ const filesA = _collectFilesForThread(graph, threadNodeA.id);
409
+ const filesB = _collectFilesForThread(graph, threadNodeB.id);
410
+
411
+ let currentSharedFiles = 0;
412
+ for (const f of filesA) {
413
+ if (filesB.has(f)) currentSharedFiles++;
414
+ }
415
+
416
+ const originalSharedFiles = (previousAffinity && previousAffinity.originalSharedFiles != null)
417
+ ? previousAffinity.originalSharedFiles
418
+ : currentSharedFiles;
419
+
420
+ const fileDrift = originalSharedFiles > 0
421
+ ? _clamp01(1 - (currentSharedFiles / originalSharedFiles))
422
+ : 0;
423
+
424
+ // --- Keyword drift ---
425
+ const keywordsA = new Set(
426
+ (threadNodeA.metadata && Array.isArray(threadNodeA.metadata.keywords))
427
+ ? threadNodeA.metadata.keywords
428
+ : []
429
+ );
430
+ const keywordsB = new Set(
431
+ (threadNodeB.metadata && Array.isArray(threadNodeB.metadata.keywords))
432
+ ? threadNodeB.metadata.keywords
433
+ : []
434
+ );
435
+
436
+ const keywordSimilarity = _jaccard(keywordsA, keywordsB);
437
+ const keywordDrift = _clamp01(1 - keywordSimilarity);
438
+
439
+ // --- Cluster drift ---
440
+ // If either node has a cluster, compute average affinity to cluster members
441
+ // vs. the original average affinity stored in the edge
442
+ let clusterDrift = 0;
443
+
444
+ if (currentAffinityMap && previousAffinity && previousAffinity.compositeScore > 0) {
445
+ const tidA = threadNodeA.metadata && threadNodeA.metadata.threadId;
446
+ const tidB = threadNodeB.metadata && threadNodeB.metadata.threadId;
447
+
448
+ if (tidA && tidB) {
449
+ const key = _pairKey(tidA, tidB);
450
+ const currentScore = currentAffinityMap.get(key) || 0;
451
+ const originalScore = previousAffinity.compositeScore;
452
+
453
+ if (originalScore > 0) {
454
+ clusterDrift = _clamp01(1 - (currentScore / originalScore));
455
+ }
456
+ }
457
+ }
458
+
459
+ return {
460
+ fileDrift,
461
+ keywordDrift,
462
+ clusterDrift,
463
+ maxDrift: Math.max(fileDrift, keywordDrift, clusterDrift),
464
+ };
465
+ }
466
+
467
+ // @cap-todo(ac:F-038/AC-4) Decay reduces affinity edge weights but never deletes nodes; dormant nodes get dormant:true flag
468
+ /**
469
+ * Apply decay to affinity edges in the graph based on drift results.
470
+ * Reduces edge weights: newWeight = currentWeight * (1 - maxDrift * decayRate).
471
+ * Never deletes nodes or edges.
472
+ *
473
+ * @param {Object} graph - MemoryGraph (mutated)
474
+ * @param {Map<string, DriftMetrics>} driftResults - Map of pair key -> DriftMetrics
475
+ * @param {Object} [options]
476
+ * @param {number} [options.decayRate] - Damping rate (default 0.3)
477
+ * @returns {{ decayedEdges: Array<{source: string, target: string, oldWeight: number, newWeight: number}> }}
478
+ */
479
+ function applyDecay(graph, driftResults, options) {
480
+ const decayRate = (options && options.decayRate != null) ? options.decayRate : DEFAULT_DECAY_RATE;
481
+ const decayedEdges = [];
482
+
483
+ // Build thread node ID -> thread ID mapping for quick lookup
484
+ const nodeToThread = new Map();
485
+ for (const [nodeId, node] of Object.entries(graph.nodes || {})) {
486
+ if (node.type === 'thread' && node.metadata && node.metadata.threadId) {
487
+ nodeToThread.set(nodeId, node.metadata.threadId);
488
+ }
489
+ }
490
+
491
+ for (const edge of (graph.edges || [])) {
492
+ if (!edge.active || edge.type !== 'affinity') continue;
493
+
494
+ const tidSource = nodeToThread.get(edge.source);
495
+ const tidTarget = nodeToThread.get(edge.target);
496
+ if (!tidSource || !tidTarget) continue;
497
+
498
+ const key = _pairKey(tidSource, tidTarget);
499
+ const drift = driftResults.get(key);
500
+ if (!drift || drift.maxDrift <= 0) continue;
501
+
502
+ const oldWeight = (edge.metadata && edge.metadata.compositeScore != null)
503
+ ? edge.metadata.compositeScore
504
+ : 0;
505
+
506
+ const newWeight = oldWeight * (1 - drift.maxDrift * decayRate);
507
+
508
+ if (!edge.metadata) edge.metadata = {};
509
+ edge.metadata.compositeScore = newWeight;
510
+
511
+ // Store original shared files count for future drift calculations
512
+ if (edge.metadata.originalSharedFiles == null) {
513
+ // First decay pass — snapshot the current state as baseline
514
+ // This is set externally or defaults to 0 if not present
515
+ }
516
+
517
+ decayedEdges.push({
518
+ source: edge.source,
519
+ target: edge.target,
520
+ oldWeight,
521
+ newWeight,
522
+ });
523
+ }
524
+
525
+ graph.lastUpdated = new Date().toISOString();
526
+
527
+ return { decayedEdges };
528
+ }
529
+
530
+ // --- Dormant Node Management ---
531
+
532
+ // @cap-todo(ac:F-038/AC-4) dormant nodes get dormant:true flag
533
+ /**
534
+ * Mark a node as dormant. Sets metadata.dormant = true.
535
+ * Does NOT delete the node or its edges.
536
+ *
537
+ * @param {Object} graph - MemoryGraph (mutated)
538
+ * @param {string} nodeId - Graph node ID to mark dormant
539
+ * @returns {Object} The mutated graph
540
+ */
541
+ function markDormant(graph, nodeId) {
542
+ const node = graph.nodes[nodeId];
543
+ if (!node) return graph;
544
+
545
+ if (!node.metadata) node.metadata = {};
546
+ node.metadata.dormant = true;
547
+ node.updatedAt = new Date().toISOString();
548
+ graph.lastUpdated = new Date().toISOString();
549
+
550
+ return graph;
551
+ }
552
+
553
+ // @cap-todo(ac:F-038/AC-5) Dormant nodes reactivate when new affinity score >= 0.40
554
+ /**
555
+ * Reactivate a dormant node. Sets metadata.dormant = false.
556
+ *
557
+ * @param {Object} graph - MemoryGraph (mutated)
558
+ * @param {string} nodeId - Graph node ID to reactivate
559
+ * @returns {Object} The mutated graph
560
+ */
561
+ function reactivateNode(graph, nodeId) {
562
+ const node = graph.nodes[nodeId];
563
+ if (!node) return graph;
564
+
565
+ if (!node.metadata) node.metadata = {};
566
+ node.metadata.dormant = false;
567
+ node.updatedAt = new Date().toISOString();
568
+ graph.lastUpdated = new Date().toISOString();
569
+
570
+ return graph;
571
+ }
572
+
573
+ /**
574
+ * Check which dormant nodes should be reactivated based on new affinity results.
575
+ * A dormant node reactivates when any new affinity score touching it is >= reactivation threshold.
576
+ *
577
+ * @param {Object} graph - MemoryGraph
578
+ * @param {AffinityResult[]} newAffinityResults - New affinity results to check
579
+ * @param {Object} [options]
580
+ * @param {number} [options.reactivationThreshold] - Score threshold (default 0.40)
581
+ * @returns {string[]} List of reactivated graph node IDs
582
+ */
583
+ function checkReactivation(graph, newAffinityResults, options) {
584
+ const threshold = (options && options.reactivationThreshold != null)
585
+ ? options.reactivationThreshold
586
+ : DORMANT_REACTIVATION_THRESHOLD;
587
+
588
+ const reactivated = [];
589
+
590
+ // Find all dormant thread node IDs and their thread IDs
591
+ const dormantNodes = new Map(); // threadId -> nodeId
592
+ for (const [nodeId, node] of Object.entries(graph.nodes || {})) {
593
+ if (node.type === 'thread' && node.metadata && node.metadata.dormant === true) {
594
+ dormantNodes.set(node.metadata.threadId, nodeId);
595
+ }
596
+ }
597
+
598
+ if (dormantNodes.size === 0) return reactivated;
599
+
600
+ for (const result of newAffinityResults) {
601
+ if (result.compositeScore < threshold) continue;
602
+
603
+ // Check if either thread in this result is dormant
604
+ for (const tid of [result.sourceThreadId, result.targetThreadId]) {
605
+ const nodeId = dormantNodes.get(tid);
606
+ if (nodeId) {
607
+ reactivateNode(graph, nodeId);
608
+ reactivated.push(nodeId);
609
+ dormantNodes.delete(tid); // Don't reactivate twice
610
+ }
611
+ }
612
+ }
613
+
614
+ return reactivated;
615
+ }
616
+
617
+ /**
618
+ * Identify thread nodes whose ALL affinity edges are below the silent threshold,
619
+ * and mark them as dormant.
620
+ *
621
+ * @param {Object} graph - MemoryGraph (mutated)
622
+ * @param {Object} [options]
623
+ * @param {number} [options.dormantThreshold] - Below this, edges count as weak (default 0.40)
624
+ * @returns {string[]} List of newly dormant node IDs
625
+ */
626
+ function identifyAndMarkDormant(graph, options) {
627
+ const threshold = (options && options.dormantThreshold != null)
628
+ ? options.dormantThreshold
629
+ : DEFAULT_LINKAGE_THRESHOLD;
630
+
631
+ const newlyDormant = [];
632
+
633
+ // Build thread node ID -> thread ID mapping
634
+ const threadNodeIds = [];
635
+ for (const [nodeId, node] of Object.entries(graph.nodes || {})) {
636
+ if (node.type === 'thread' && node.active) {
637
+ threadNodeIds.push(nodeId);
638
+ }
639
+ }
640
+
641
+ for (const nodeId of threadNodeIds) {
642
+ const node = graph.nodes[nodeId];
643
+ // Skip already dormant nodes
644
+ if (node.metadata && node.metadata.dormant === true) continue;
645
+
646
+ // Find all active affinity edges touching this node
647
+ const affinityEdges = (graph.edges || []).filter(e =>
648
+ e.active && e.type === 'affinity' &&
649
+ (e.source === nodeId || e.target === nodeId)
650
+ );
651
+
652
+ // If no affinity edges, skip (don't mark dormant for nodes with no edges at all)
653
+ if (affinityEdges.length === 0) continue;
654
+
655
+ // Check if ALL edges are below threshold
656
+ const allBelowThreshold = affinityEdges.every(e =>
657
+ (e.metadata && e.metadata.compositeScore != null)
658
+ ? e.metadata.compositeScore < threshold
659
+ : true
660
+ );
661
+
662
+ if (allBelowThreshold) {
663
+ markDormant(graph, nodeId);
664
+ newlyDormant.push(nodeId);
665
+ }
666
+ }
667
+
668
+ return newlyDormant;
669
+ }
670
+
671
+ // --- Cluster Membership ---
672
+
673
+ // @cap-todo(ac:F-038/AC-7) Cluster membership stored as computed property on thread nodes
674
+ /**
675
+ * Assign cluster membership to thread nodes in the graph.
676
+ * Updates each thread node's metadata with cluster info:
677
+ * metadata.cluster = { id, label, joinedAt }
678
+ *
679
+ * @param {Object} graph - MemoryGraph (mutated)
680
+ * @param {Cluster[]} clusters - Clusters with id, members, and label
681
+ * @returns {Object} The mutated graph
682
+ */
683
+ function assignClusterMembership(graph, clusters) {
684
+ const now = new Date().toISOString();
685
+
686
+ // Build thread ID -> cluster mapping
687
+ const threadToCluster = new Map();
688
+ for (const cluster of clusters) {
689
+ for (const tid of cluster.members) {
690
+ threadToCluster.set(tid, cluster);
691
+ }
692
+ }
693
+
694
+ // Update graph nodes
695
+ for (const [nodeId, node] of Object.entries(graph.nodes || {})) {
696
+ if (node.type !== 'thread') continue;
697
+
698
+ const threadId = node.metadata && node.metadata.threadId;
699
+ if (!threadId) continue;
700
+
701
+ const cluster = threadToCluster.get(threadId);
702
+ if (cluster) {
703
+ if (!node.metadata) node.metadata = {};
704
+ // Preserve existing joinedAt if cluster hasn't changed
705
+ const existingCluster = node.metadata.cluster;
706
+ const joinedAt = (existingCluster && existingCluster.id === cluster.id)
707
+ ? existingCluster.joinedAt
708
+ : now;
709
+
710
+ node.metadata.cluster = {
711
+ id: cluster.id,
712
+ label: cluster.label,
713
+ joinedAt,
714
+ };
715
+ node.updatedAt = now;
716
+ } else {
717
+ // Thread not in any cluster — clear cluster membership
718
+ if (node.metadata && node.metadata.cluster) {
719
+ delete node.metadata.cluster;
720
+ node.updatedAt = now;
721
+ }
722
+ }
723
+ }
724
+
725
+ graph.lastUpdated = now;
726
+ return graph;
727
+ }
728
+
729
+ // --- Full Pipeline ---
730
+
731
+ // @cap-todo(ac:F-038/AC-8) Clustering completes within 500ms for 200 nodes and 1000 edges
732
+ /**
733
+ * Run full cluster detection pipeline:
734
+ * 1. Detect clusters from affinity scores (single-linkage)
735
+ * 2. Generate labels for each cluster
736
+ * 3. Assign cluster membership to graph nodes
737
+ *
738
+ * @param {AffinityResult[]} affinityResults - Pairwise affinity results
739
+ * @param {Object} graph - MemoryGraph (mutated)
740
+ * @param {Object[]} threads - Thread objects for label generation
741
+ * @param {Object} [options]
742
+ * @param {number} [options.linkageThreshold] - Minimum affinity for merging
743
+ * @param {Object<string, string[]>} [options.taxonomy] - Concept taxonomy for labeling
744
+ * @returns {ClusterResult}
745
+ */
746
+ function runClusterDetection(affinityResults, graph, threads, options) {
747
+ const taxonomy = (options && options.taxonomy) || null;
748
+ const linkageThreshold = (options && options.linkageThreshold != null)
749
+ ? options.linkageThreshold
750
+ : DEFAULT_LINKAGE_THRESHOLD;
751
+
752
+ // 1. Detect clusters
753
+ const rawClusters = detectClusters(affinityResults, { linkageThreshold });
754
+
755
+ // Build thread ID -> thread object map for label generation
756
+ const threadMap = new Map();
757
+ for (const t of threads) {
758
+ threadMap.set(t.id, t);
759
+ }
760
+
761
+ // 2. Generate labels and finalize clusters
762
+ const now = new Date().toISOString();
763
+ const clusters = rawClusters.map(rc => {
764
+ const memberThreads = rc.members
765
+ .map(tid => threadMap.get(tid))
766
+ .filter(Boolean);
767
+
768
+ const label = taxonomy
769
+ ? generateClusterLabel(memberThreads, taxonomy)
770
+ : _generateFallbackLabel(memberThreads);
771
+
772
+ return {
773
+ id: rc.id,
774
+ members: rc.members,
775
+ label,
776
+ createdAt: now,
777
+ };
778
+ });
779
+
780
+ // 3. Assign membership to graph nodes
781
+ assignClusterMembership(graph, clusters);
782
+
783
+ return { clusters, graph };
784
+ }
785
+
786
+ /**
787
+ * Generate a fallback label when no taxonomy is provided.
788
+ * Uses top keywords from member threads.
789
+ *
790
+ * @param {Object[]} memberThreads - Thread objects
791
+ * @returns {string}
792
+ */
793
+ function _generateFallbackLabel(memberThreads) {
794
+ if (!memberThreads || memberThreads.length === 0) return 'unnamed';
795
+
796
+ // Collect keyword frequency
797
+ const kwFreq = new Map();
798
+ for (const t of memberThreads) {
799
+ for (const kw of (t.keywords || [])) {
800
+ kwFreq.set(kw, (kwFreq.get(kw) || 0) + 1);
801
+ }
802
+ }
803
+
804
+ const sorted = [...kwFreq.entries()]
805
+ .sort((a, b) => b[1] - a[1])
806
+ .slice(0, 3)
807
+ .filter(([, count]) => count > 0);
808
+
809
+ if (sorted.length === 0) return 'unnamed';
810
+
811
+ return sorted.map(([kw]) => kw).join(' \u00b7 ');
812
+ }
813
+
814
+ /**
815
+ * Run full decay pass:
816
+ * 1. Compute drift for each affinity edge
817
+ * 2. Apply decay to edge weights
818
+ * 3. Identify and mark dormant nodes
819
+ * 4. Check for reactivations from new affinity results
820
+ *
821
+ * @param {Object} graph - MemoryGraph (mutated)
822
+ * @param {AffinityResult[]} currentAffinities - Current affinity results
823
+ * @param {AffinityResult[]} [previousAffinities] - Previous affinity results (for cluster-drift baseline)
824
+ * @param {Object} [options]
825
+ * @param {number} [options.decayRate] - Decay damping rate (default 0.3)
826
+ * @param {number} [options.dormantThreshold] - Threshold for dormancy (default 0.40)
827
+ * @returns {DecayResult}
828
+ */
829
+ function runDecayPass(graph, currentAffinities, previousAffinities, options) {
830
+ const decayRate = (options && options.decayRate != null) ? options.decayRate : DEFAULT_DECAY_RATE;
831
+ const dormantThreshold = (options && options.dormantThreshold != null)
832
+ ? options.dormantThreshold
833
+ : DEFAULT_LINKAGE_THRESHOLD;
834
+
835
+ // Build current affinity lookup
836
+ const currentAffinityMap = new Map();
837
+ for (const r of currentAffinities) {
838
+ const key = _pairKey(r.sourceThreadId, r.targetThreadId);
839
+ currentAffinityMap.set(key, r.compositeScore);
840
+ }
841
+
842
+ // Build previous affinity lookup for baseline
843
+ const previousAffinityMap = new Map();
844
+ if (previousAffinities) {
845
+ for (const r of previousAffinities) {
846
+ const key = _pairKey(r.sourceThreadId, r.targetThreadId);
847
+ previousAffinityMap.set(key, {
848
+ compositeScore: r.compositeScore,
849
+ originalSharedFiles: null, // Will be computed from graph state
850
+ });
851
+ }
852
+ }
853
+
854
+ // Also build from existing graph edges as baseline
855
+ const nodeToThread = new Map();
856
+ for (const [nodeId, node] of Object.entries(graph.nodes || {})) {
857
+ if (node.type === 'thread' && node.metadata && node.metadata.threadId) {
858
+ nodeToThread.set(nodeId, node.metadata.threadId);
859
+ }
860
+ }
861
+
862
+ // 1. Compute drift for each active affinity edge
863
+ const driftResults = new Map();
864
+
865
+ for (const edge of _getAffinityEdges(graph)) {
866
+ const tidSource = nodeToThread.get(edge.source);
867
+ const tidTarget = nodeToThread.get(edge.target);
868
+ if (!tidSource || !tidTarget) continue;
869
+
870
+ const key = _pairKey(tidSource, tidTarget);
871
+ const nodeA = graph.nodes[edge.source];
872
+ const nodeB = graph.nodes[edge.target];
873
+ if (!nodeA || !nodeB) continue;
874
+
875
+ // Get previous affinity baseline from the edge itself or from previous results
876
+ const prevFromMap = previousAffinityMap.get(key);
877
+ const previousAffinity = prevFromMap || {
878
+ compositeScore: (edge.metadata && edge.metadata.compositeScore) || 0,
879
+ originalSharedFiles: (edge.metadata && edge.metadata.originalSharedFiles) || null,
880
+ };
881
+
882
+ const drift = computeDrift(nodeA, nodeB, graph, previousAffinity, currentAffinityMap);
883
+ driftResults.set(key, drift);
884
+ }
885
+
886
+ // 2. Apply decay
887
+ const { decayedEdges } = applyDecay(graph, driftResults, { decayRate });
888
+
889
+ // 3. Mark dormant nodes
890
+ const dormantNodes = identifyAndMarkDormant(graph, { dormantThreshold });
891
+
892
+ // 4. Check reactivations
893
+ const reactivatedNodes = checkReactivation(graph, currentAffinities, {
894
+ reactivationThreshold: dormantThreshold,
895
+ });
896
+
897
+ return {
898
+ decayedEdges,
899
+ dormantNodes,
900
+ reactivatedNodes,
901
+ };
902
+ }
903
+
904
+ // --- Module Exports ---
905
+
906
+ // @cap-decision Exporting internal helpers prefixed with _ for testing, following project convention.
907
+ module.exports = {
908
+ // Clustering
909
+ detectClusters,
910
+ generateClusterId,
911
+ generateClusterLabel,
912
+
913
+ // Divergence decay
914
+ computeDrift,
915
+ applyDecay,
916
+
917
+ // Dormant node management
918
+ markDormant,
919
+ reactivateNode,
920
+ checkReactivation,
921
+ identifyAndMarkDormant,
922
+
923
+ // Cluster membership
924
+ assignClusterMembership,
925
+
926
+ // Full pipelines
927
+ runClusterDetection,
928
+ runDecayPass,
929
+
930
+ // Constants
931
+ DEFAULT_LINKAGE_THRESHOLD,
932
+ DEFAULT_DECAY_RATE,
933
+ DORMANT_REACTIVATION_THRESHOLD,
934
+
935
+ // Internal (for testing)
936
+ _clamp01,
937
+ _jaccard,
938
+ _findThreadNodeId,
939
+ _getAffinityEdges,
940
+ _collectFilesForThread,
941
+ _pairKey,
942
+ _getThreadText,
943
+ _projectToConcepts,
944
+ _generateFallbackLabel,
945
+ };