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,862 @@
1
+ // @cap-feature(feature:F-036) Multi-Signal Affinity Engine — computes affinity scores between thread nodes using 8 weighted signals
2
+ // @cap-decision Pure logic module — all functions take data as input and return structured results. The ONLY exception is loadConfig which reads .cap/config.json.
3
+ // @cap-decision Signals split into realtime (structural lookups, fast) and post-session (deeper analysis) groups for phased execution.
4
+ // @cap-decision Jaccard similarity used as the foundational metric across multiple signals — simple, interpretable, well-bounded (0-1), and requires no external dependencies.
5
+ // @cap-decision Band classification uses configurable thresholds so teams can tune sensitivity without code changes.
6
+ // @cap-constraint Zero external dependencies — uses only Node.js built-ins (fs, path).
7
+
8
+ 'use strict';
9
+
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+
13
+ // --- Constants ---
14
+
15
+ // @cap-todo(ac:F-036/AC-2) Support 8 named signals: feature-id-overlap, shared-files, temporal-proximity, causal-chains (realtime); concept-overlap, problem-space-similarity, shared-decisions-deep, transitive-connections (post-session)
16
+
17
+ /** Ordered list of all signal names. */
18
+ const SIGNAL_NAMES = [
19
+ 'feature-id-overlap',
20
+ 'shared-files',
21
+ 'temporal-proximity',
22
+ 'causal-chains',
23
+ 'concept-overlap',
24
+ 'problem-space-similarity',
25
+ 'shared-decisions-deep',
26
+ 'transitive-connections',
27
+ ];
28
+
29
+ /** Signals computed during realtime (fast, structural lookups). */
30
+ const REALTIME_SIGNALS = [
31
+ 'feature-id-overlap',
32
+ 'shared-files',
33
+ 'temporal-proximity',
34
+ 'causal-chains',
35
+ ];
36
+
37
+ /** Signals computed during post-session analysis (deeper). */
38
+ const POST_SESSION_SIGNALS = [
39
+ 'concept-overlap',
40
+ 'problem-space-similarity',
41
+ 'shared-decisions-deep',
42
+ 'transitive-connections',
43
+ ];
44
+
45
+ // @cap-todo(ac:F-036/AC-4) Signal weights configurable via .cap/config.json under key affinityWeights, defaults sum to 1.0
46
+
47
+ /** Default signal weights (sum to 1.0). */
48
+ const DEFAULT_WEIGHTS = {
49
+ 'feature-id-overlap': 0.20,
50
+ 'shared-files': 0.15,
51
+ 'temporal-proximity': 0.05,
52
+ 'causal-chains': 0.10,
53
+ 'concept-overlap': 0.20,
54
+ 'problem-space-similarity': 0.10,
55
+ 'shared-decisions-deep': 0.10,
56
+ 'transitive-connections': 0.10,
57
+ };
58
+
59
+ // @cap-todo(ac:F-036/AC-5) Classify scores into 4 bands: urgent (>=0.90), notify (0.75-0.89), silent (0.40-0.74), discard (<0.40) — thresholds configurable
60
+
61
+ /** Default band thresholds. */
62
+ const DEFAULT_BANDS = {
63
+ urgent: 0.90,
64
+ notify: 0.75,
65
+ silent: 0.40,
66
+ // Below silent threshold = discard
67
+ };
68
+
69
+ /** Config file path relative to project root. */
70
+ const CONFIG_FILE = path.join('.cap', 'config.json');
71
+
72
+ // --- Types ---
73
+
74
+ /**
75
+ * @typedef {'feature-id-overlap'|'shared-files'|'temporal-proximity'|'causal-chains'|'concept-overlap'|'problem-space-similarity'|'shared-decisions-deep'|'transitive-connections'} SignalName
76
+ */
77
+
78
+ /**
79
+ * @typedef {Object} SignalResult
80
+ * @property {SignalName} signal - Signal name
81
+ * @property {number} score - Signal score (0.0-1.0)
82
+ * @property {string} reason - Human-readable explanation
83
+ */
84
+
85
+ /**
86
+ * @typedef {'urgent'|'notify'|'silent'|'discard'} AffinityBand
87
+ */
88
+
89
+ /**
90
+ * @typedef {Object} AffinityResult
91
+ * @property {string} sourceThreadId - First thread ID
92
+ * @property {string} targetThreadId - Second thread ID
93
+ * @property {number} compositeScore - Weighted composite score (0.0-1.0)
94
+ * @property {AffinityBand} band - Classification band
95
+ * @property {SignalResult[]} signals - Individual signal results
96
+ * @property {string} computedAt - ISO timestamp
97
+ */
98
+
99
+ /**
100
+ * @typedef {Object} AffinityConfig
101
+ * @property {Object<SignalName, number>} weights - Signal weights (should sum to 1.0)
102
+ * @property {Object} bands - Band thresholds { urgent, notify, silent }
103
+ */
104
+
105
+ /**
106
+ * @typedef {Object} AffinityContext
107
+ * @property {Object} graph - MemoryGraph instance
108
+ * @property {Object[]} allThreads - Array of Thread objects
109
+ * @property {Object[]} threadIndex - Array of ThreadIndexEntry objects
110
+ */
111
+
112
+ /**
113
+ * @typedef {Object} Thread
114
+ * @property {string} id - Thread ID
115
+ * @property {string} name - Human-readable name
116
+ * @property {string} timestamp - ISO timestamp
117
+ * @property {string|null} parentThreadId - Parent thread ID
118
+ * @property {string|null} divergencePoint - Divergence description
119
+ * @property {string} problemStatement - Problem being explored
120
+ * @property {string} solutionShape - Solution direction
121
+ * @property {string[]} boundaryDecisions - Key decisions
122
+ * @property {string[]} featureIds - Associated feature IDs
123
+ * @property {string[]} keywords - Problem-space keywords
124
+ */
125
+
126
+ // --- Stop Words (shared with cap-thread-tracker.cjs pattern) ---
127
+
128
+ /** @type {Set<string>} */
129
+ const STOP_WORDS = new Set([
130
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
131
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
132
+ 'should', 'may', 'might', 'shall', 'can', 'need', 'must', 'ought',
133
+ 'and', 'but', 'or', 'nor', 'not', 'so', 'yet', 'both', 'either',
134
+ 'neither', 'each', 'every', 'all', 'any', 'few', 'more', 'most',
135
+ 'other', 'some', 'such', 'no', 'only', 'own', 'same', 'than',
136
+ 'too', 'very', 'just', 'because', 'as', 'until', 'while', 'of',
137
+ 'at', 'by', 'for', 'with', 'about', 'against', 'between', 'through',
138
+ 'during', 'before', 'after', 'above', 'below', 'to', 'from', 'up',
139
+ 'down', 'in', 'out', 'on', 'off', 'over', 'under', 'again',
140
+ 'further', 'then', 'once', 'here', 'there', 'when', 'where', 'why',
141
+ 'how', 'what', 'which', 'who', 'whom', 'this', 'that', 'these',
142
+ 'those', 'i', 'me', 'my', 'myself', 'we', 'our', 'ours', 'you',
143
+ 'your', 'yours', 'he', 'him', 'his', 'she', 'her', 'hers', 'it',
144
+ 'its', 'they', 'them', 'their', 'theirs', 'also', 'into', 'if',
145
+ ]);
146
+
147
+ // --- Utility Functions ---
148
+
149
+ /**
150
+ * Compute Jaccard similarity between two sets.
151
+ * @param {Set<string>} setA
152
+ * @param {Set<string>} setB
153
+ * @returns {{ score: number, intersection: string[], union: string[] }}
154
+ */
155
+ function jaccard(setA, setB) {
156
+ if (setA.size === 0 && setB.size === 0) {
157
+ return { score: 0, intersection: [], union: [] };
158
+ }
159
+
160
+ const intersection = [];
161
+ for (const item of setA) {
162
+ if (setB.has(item)) {
163
+ intersection.push(item);
164
+ }
165
+ }
166
+
167
+ const unionSet = new Set([...setA, ...setB]);
168
+
169
+ return {
170
+ score: intersection.length / unionSet.size,
171
+ intersection: intersection.sort(),
172
+ union: [...unionSet].sort(),
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Extract keywords from text, filtering stop words and short words.
178
+ * @param {string} text
179
+ * @returns {string[]} Deduplicated sorted keywords
180
+ */
181
+ function extractKeywords(text) {
182
+ if (!text || typeof text !== 'string') return [];
183
+
184
+ return [...new Set(
185
+ text
186
+ .toLowerCase()
187
+ .replace(/[^a-z0-9\s-]/g, ' ')
188
+ .split(/\s+/)
189
+ .filter(w => w.length >= 3 && !STOP_WORDS.has(w))
190
+ )].sort();
191
+ }
192
+
193
+ /**
194
+ * Clamp a number to [0.0, 1.0].
195
+ * @param {number} n
196
+ * @returns {number}
197
+ */
198
+ function clamp01(n) {
199
+ return Math.max(0, Math.min(1, n));
200
+ }
201
+
202
+ /**
203
+ * Truncate an array for display, appending "..." if truncated.
204
+ * @param {string[]} items
205
+ * @param {number} maxItems
206
+ * @returns {string} Comma-separated display string
207
+ */
208
+ function truncateList(items, maxItems = 5) {
209
+ if (items.length <= maxItems) return items.join(', ');
210
+ return items.slice(0, maxItems).join(', ') + ', ...';
211
+ }
212
+
213
+ /**
214
+ * Find the graph node ID for a thread by its thread ID.
215
+ * Thread nodes have metadata.threadId matching the thr-XXXX id.
216
+ * @param {Object} graph - MemoryGraph
217
+ * @param {string} threadId - Thread ID (thr-XXXX)
218
+ * @returns {string|null} Graph node ID or null
219
+ */
220
+ function findThreadNodeId(graph, threadId) {
221
+ for (const [nodeId, node] of Object.entries(graph.nodes || {})) {
222
+ if (node.type === 'thread' && node.metadata && node.metadata.threadId === threadId) {
223
+ return nodeId;
224
+ }
225
+ }
226
+ return null;
227
+ }
228
+
229
+ /**
230
+ * Get feature IDs connected to a thread node in the graph.
231
+ * Looks for edges from the thread node to feature nodes.
232
+ * @param {Object} graph - MemoryGraph
233
+ * @param {string} threadNodeId - Graph node ID of the thread
234
+ * @returns {string[]} Array of feature node IDs
235
+ */
236
+ function getConnectedFeatureNodeIds(graph, threadNodeId) {
237
+ const featureNodeIds = [];
238
+ for (const edge of (graph.edges || [])) {
239
+ if (!edge.active) continue;
240
+ let neighborId = null;
241
+ if (edge.source === threadNodeId) neighborId = edge.target;
242
+ else if (edge.target === threadNodeId) neighborId = edge.source;
243
+ if (neighborId && graph.nodes[neighborId] && graph.nodes[neighborId].type === 'feature') {
244
+ featureNodeIds.push(neighborId);
245
+ }
246
+ }
247
+ return featureNodeIds;
248
+ }
249
+
250
+ /**
251
+ * Collect file paths from feature nodes connected to a thread.
252
+ * @param {Object} graph - MemoryGraph
253
+ * @param {string} threadNodeId - Graph node ID of the thread
254
+ * @returns {Set<string>} Set of file paths
255
+ */
256
+ function collectFilesForThread(graph, threadNodeId) {
257
+ const files = new Set();
258
+ const featureNodeIds = getConnectedFeatureNodeIds(graph, threadNodeId);
259
+ for (const fNodeId of featureNodeIds) {
260
+ const node = graph.nodes[fNodeId];
261
+ if (node && node.metadata && Array.isArray(node.metadata.files)) {
262
+ for (const f of node.metadata.files) {
263
+ files.add(f);
264
+ }
265
+ }
266
+ }
267
+ return files;
268
+ }
269
+
270
+ /**
271
+ * Get graph neighbor node IDs for a given node (active edges only).
272
+ * @param {Object} graph - MemoryGraph
273
+ * @param {string} nodeId - Node ID
274
+ * @returns {Set<string>} Set of neighbor node IDs
275
+ */
276
+ function getGraphNeighbors(graph, nodeId) {
277
+ const neighbors = new Set();
278
+ for (const edge of (graph.edges || [])) {
279
+ if (!edge.active) continue;
280
+ if (edge.source === nodeId) neighbors.add(edge.target);
281
+ else if (edge.target === nodeId) neighbors.add(edge.source);
282
+ }
283
+ return neighbors;
284
+ }
285
+
286
+ // --- Signal Functions ---
287
+ // @cap-todo(ac:F-036/AC-3) Each signal returns independent score (0.0-1.0) and human-readable reason string
288
+
289
+ /**
290
+ * Signal 1: Feature ID overlap between two threads.
291
+ * Uses Jaccard similarity on featureIds arrays.
292
+ * @param {Thread} threadA
293
+ * @param {Thread} threadB
294
+ * @param {AffinityContext} _context - Unused for this signal
295
+ * @returns {SignalResult}
296
+ */
297
+ function signalFeatureIdOverlap(threadA, threadB, _context) {
298
+ const setA = new Set(threadA.featureIds || []);
299
+ const setB = new Set(threadB.featureIds || []);
300
+ const { score, intersection, union } = jaccard(setA, setB);
301
+
302
+ return {
303
+ signal: 'feature-id-overlap',
304
+ score: clamp01(score),
305
+ reason: intersection.length > 0
306
+ ? `Shares ${intersection.length} of ${union.length} features: ${truncateList(intersection)}`
307
+ : 'No shared feature IDs',
308
+ };
309
+ }
310
+
311
+ /**
312
+ * Signal 2: Shared files between threads.
313
+ * Collects file paths from feature nodes connected to each thread in the graph,
314
+ * then computes Jaccard similarity.
315
+ * @param {Thread} threadA
316
+ * @param {Thread} threadB
317
+ * @param {AffinityContext} context
318
+ * @returns {SignalResult}
319
+ */
320
+ function signalSharedFiles(threadA, threadB, context) {
321
+ const graph = context.graph || { nodes: {}, edges: [] };
322
+
323
+ const nodeIdA = findThreadNodeId(graph, threadA.id);
324
+ const nodeIdB = findThreadNodeId(graph, threadB.id);
325
+
326
+ if (!nodeIdA || !nodeIdB) {
327
+ return {
328
+ signal: 'shared-files',
329
+ score: 0,
330
+ reason: 'Thread(s) not found in graph',
331
+ };
332
+ }
333
+
334
+ const filesA = collectFilesForThread(graph, nodeIdA);
335
+ const filesB = collectFilesForThread(graph, nodeIdB);
336
+ const { score, intersection } = jaccard(filesA, filesB);
337
+
338
+ return {
339
+ signal: 'shared-files',
340
+ score: clamp01(score),
341
+ reason: intersection.length > 0
342
+ ? `${intersection.length} shared files: ${truncateList(intersection)}`
343
+ : 'No shared files',
344
+ };
345
+ }
346
+
347
+ /**
348
+ * Signal 3: Temporal proximity between threads.
349
+ * Inverse decay: 1 / (1 + daysBetween / 7).
350
+ * Same day ~1.0, 1 week apart ~0.5, 1 month ~0.19.
351
+ * @param {Thread} threadA
352
+ * @param {Thread} threadB
353
+ * @param {AffinityContext} _context
354
+ * @returns {SignalResult}
355
+ */
356
+ function signalTemporalProximity(threadA, threadB, _context) {
357
+ const tsA = new Date(threadA.timestamp || 0).getTime();
358
+ const tsB = new Date(threadB.timestamp || 0).getTime();
359
+
360
+ if (isNaN(tsA) || isNaN(tsB)) {
361
+ return {
362
+ signal: 'temporal-proximity',
363
+ score: 0,
364
+ reason: 'Invalid timestamp(s)',
365
+ };
366
+ }
367
+
368
+ const daysBetween = Math.abs(tsA - tsB) / (1000 * 60 * 60 * 24);
369
+ const score = 1 / (1 + daysBetween / 7);
370
+
371
+ const daysLabel = daysBetween < 1
372
+ ? 'same day'
373
+ : `${Math.round(daysBetween)} day${Math.round(daysBetween) === 1 ? '' : 's'} apart`;
374
+
375
+ return {
376
+ signal: 'temporal-proximity',
377
+ score: clamp01(score),
378
+ reason: daysLabel,
379
+ };
380
+ }
381
+
382
+ /**
383
+ * Signal 4: Causal chains between threads.
384
+ * Checks if Thread B's problem keywords appear in Thread A's solution/decisions,
385
+ * and vice versa. Uses bidirectional keyword overlap.
386
+ * @param {Thread} threadA
387
+ * @param {Thread} threadB
388
+ * @param {AffinityContext} _context
389
+ * @returns {SignalResult}
390
+ */
391
+ function signalCausalChains(threadA, threadB, _context) {
392
+ // Extract keywords from A's solution space
393
+ const solutionKeywordsA = new Set([
394
+ ...extractKeywords(threadA.solutionShape || ''),
395
+ ...((threadA.boundaryDecisions || []).flatMap(d => extractKeywords(d))),
396
+ ]);
397
+
398
+ // Extract keywords from B's problem space
399
+ const problemKeywordsB = new Set(extractKeywords(threadB.problemStatement || ''));
400
+
401
+ // Forward: A's solution -> B's problem
402
+ const forwardOverlap = [];
403
+ for (const kw of problemKeywordsB) {
404
+ if (solutionKeywordsA.has(kw)) forwardOverlap.push(kw);
405
+ }
406
+
407
+ // Extract keywords from B's solution space
408
+ const solutionKeywordsB = new Set([
409
+ ...extractKeywords(threadB.solutionShape || ''),
410
+ ...((threadB.boundaryDecisions || []).flatMap(d => extractKeywords(d))),
411
+ ]);
412
+
413
+ // Extract keywords from A's problem space
414
+ const problemKeywordsA = new Set(extractKeywords(threadA.problemStatement || ''));
415
+
416
+ // Reverse: B's solution -> A's problem
417
+ const reverseOverlap = [];
418
+ for (const kw of problemKeywordsA) {
419
+ if (solutionKeywordsB.has(kw)) reverseOverlap.push(kw);
420
+ }
421
+
422
+ // Combine unique overlapping keywords
423
+ const allOverlap = [...new Set([...forwardOverlap, ...reverseOverlap])].sort();
424
+
425
+ // Score: proportion of problem keywords matched, using the best direction
426
+ const forwardDenom = problemKeywordsB.size || 1;
427
+ const reverseDenom = problemKeywordsA.size || 1;
428
+ const forwardScore = forwardOverlap.length / forwardDenom;
429
+ const reverseScore = reverseOverlap.length / reverseDenom;
430
+ const score = Math.max(forwardScore, reverseScore);
431
+
432
+ let reason;
433
+ if (allOverlap.length > 0) {
434
+ const direction = forwardScore >= reverseScore ? 'A -> B' : 'B -> A';
435
+ reason = `Causal chain detected (${direction}): ${allOverlap.length} shared concepts: ${truncateList(allOverlap)}`;
436
+ } else {
437
+ reason = 'No causal chain detected';
438
+ }
439
+
440
+ return {
441
+ signal: 'causal-chains',
442
+ score: clamp01(score),
443
+ reason,
444
+ };
445
+ }
446
+
447
+ /**
448
+ * Signal 5: Concept overlap between threads.
449
+ * For the prototype, uses Jaccard on keyword sets.
450
+ * @cap-risk F-037 will enhance this with TF-IDF/taxonomy — current implementation is a keyword proxy only.
451
+ * @param {Thread} threadA
452
+ * @param {Thread} threadB
453
+ * @param {AffinityContext} _context
454
+ * @returns {SignalResult}
455
+ */
456
+ function signalConceptOverlap(threadA, threadB, _context) {
457
+ const setA = new Set(threadA.keywords || []);
458
+ const setB = new Set(threadB.keywords || []);
459
+ const { score, intersection } = jaccard(setA, setB);
460
+
461
+ return {
462
+ signal: 'concept-overlap',
463
+ score: clamp01(score),
464
+ reason: intersection.length > 0
465
+ ? `${intersection.length} shared concepts from keyword analysis: ${truncateList(intersection)}`
466
+ : 'No shared concepts',
467
+ };
468
+ }
469
+
470
+ /**
471
+ * Signal 6: Problem-space similarity.
472
+ * Extracts keywords from problemStatement specifically and computes Jaccard.
473
+ * @param {Thread} threadA
474
+ * @param {Thread} threadB
475
+ * @param {AffinityContext} _context
476
+ * @returns {SignalResult}
477
+ */
478
+ function signalProblemSpaceSimilarity(threadA, threadB, _context) {
479
+ const kwA = extractKeywords(threadA.problemStatement || '');
480
+ const kwB = extractKeywords(threadB.problemStatement || '');
481
+ const setA = new Set(kwA);
482
+ const setB = new Set(kwB);
483
+ const { score, intersection } = jaccard(setA, setB);
484
+
485
+ return {
486
+ signal: 'problem-space-similarity',
487
+ score: clamp01(score),
488
+ reason: intersection.length > 0
489
+ ? `Problem statements share ${intersection.length} keywords: ${truncateList(intersection)}`
490
+ : 'No shared problem-space keywords',
491
+ };
492
+ }
493
+
494
+ /**
495
+ * Signal 7: Shared decisions (deep analysis).
496
+ * Extracts keywords from all boundaryDecisions of each thread and computes Jaccard.
497
+ * @param {Thread} threadA
498
+ * @param {Thread} threadB
499
+ * @param {AffinityContext} _context
500
+ * @returns {SignalResult}
501
+ */
502
+ function signalSharedDecisionsDeep(threadA, threadB, _context) {
503
+ const decisionsA = threadA.boundaryDecisions || [];
504
+ const decisionsB = threadB.boundaryDecisions || [];
505
+
506
+ const kwA = new Set(decisionsA.flatMap(d => extractKeywords(d)));
507
+ const kwB = new Set(decisionsB.flatMap(d => extractKeywords(d)));
508
+ const { score, intersection } = jaccard(kwA, kwB);
509
+
510
+ return {
511
+ signal: 'shared-decisions-deep',
512
+ score: clamp01(score),
513
+ reason: intersection.length > 0
514
+ ? `${intersection.length} shared decision keywords across ${decisionsA.length + decisionsB.length} decisions: ${truncateList(intersection)}`
515
+ : 'No shared decision keywords',
516
+ };
517
+ }
518
+
519
+ /**
520
+ * Signal 8: Transitive connections via shared graph neighbors.
521
+ * Counts thread nodes connected to both A and B in the graph.
522
+ * Score: |shared| / max(|neighbors_A|, |neighbors_B|, 1)
523
+ * @param {Thread} threadA
524
+ * @param {Thread} threadB
525
+ * @param {AffinityContext} context
526
+ * @returns {SignalResult}
527
+ */
528
+ function signalTransitiveConnections(threadA, threadB, context) {
529
+ const graph = context.graph || { nodes: {}, edges: [] };
530
+
531
+ const nodeIdA = findThreadNodeId(graph, threadA.id);
532
+ const nodeIdB = findThreadNodeId(graph, threadB.id);
533
+
534
+ if (!nodeIdA || !nodeIdB) {
535
+ return {
536
+ signal: 'transitive-connections',
537
+ score: 0,
538
+ reason: 'Thread(s) not found in graph',
539
+ };
540
+ }
541
+
542
+ const neighborsA = getGraphNeighbors(graph, nodeIdA);
543
+ const neighborsB = getGraphNeighbors(graph, nodeIdB);
544
+
545
+ // Remove direct connection between A and B from neighbor sets
546
+ neighborsA.delete(nodeIdB);
547
+ neighborsB.delete(nodeIdA);
548
+
549
+ const shared = [];
550
+ for (const n of neighborsA) {
551
+ if (neighborsB.has(n)) {
552
+ shared.push(n);
553
+ }
554
+ }
555
+
556
+ const denom = Math.max(neighborsA.size, neighborsB.size, 1);
557
+ const score = shared.length / denom;
558
+
559
+ // Resolve labels for shared neighbors
560
+ const labels = shared
561
+ .map(nid => (graph.nodes[nid] && graph.nodes[nid].label) || nid)
562
+ .sort();
563
+
564
+ return {
565
+ signal: 'transitive-connections',
566
+ score: clamp01(score),
567
+ reason: shared.length > 0
568
+ ? `${shared.length} shared graph neighbors: ${truncateList(labels)}`
569
+ : 'No shared graph neighbors',
570
+ };
571
+ }
572
+
573
+ // --- Signal Registry ---
574
+
575
+ /** @type {Object<SignalName, function(Thread, Thread, AffinityContext): SignalResult>} */
576
+ const SIGNAL_FUNCTIONS = {
577
+ 'feature-id-overlap': signalFeatureIdOverlap,
578
+ 'shared-files': signalSharedFiles,
579
+ 'temporal-proximity': signalTemporalProximity,
580
+ 'causal-chains': signalCausalChains,
581
+ 'concept-overlap': signalConceptOverlap,
582
+ 'problem-space-similarity': signalProblemSpaceSimilarity,
583
+ 'shared-decisions-deep': signalSharedDecisionsDeep,
584
+ 'transitive-connections': signalTransitiveConnections,
585
+ };
586
+
587
+ // --- Configuration ---
588
+
589
+ /**
590
+ * Load affinity configuration from .cap/config.json.
591
+ * This is the ONLY function in the module that performs I/O.
592
+ * @param {string} cwd - Project root directory
593
+ * @returns {AffinityConfig} Merged configuration (user overrides + defaults)
594
+ */
595
+ function loadConfig(cwd) {
596
+ const configPath = path.join(cwd, CONFIG_FILE);
597
+ let userConfig = {};
598
+
599
+ try {
600
+ const raw = fs.readFileSync(configPath, 'utf-8');
601
+ const parsed = JSON.parse(raw);
602
+ userConfig = parsed || {};
603
+ } catch (_err) {
604
+ // No config file or invalid JSON — use defaults
605
+ }
606
+
607
+ return mergeWithDefaults(userConfig);
608
+ }
609
+
610
+ /**
611
+ * Merge user-supplied configuration with defaults.
612
+ * Validates that weights sum to 1.0 (within tolerance) and normalizes if needed.
613
+ * @param {Object} userConfig - Raw user config (may have affinityWeights, affinityBands)
614
+ * @returns {AffinityConfig}
615
+ */
616
+ function mergeWithDefaults(userConfig) {
617
+ // Merge weights
618
+ let weights = { ...DEFAULT_WEIGHTS };
619
+ if (userConfig.affinityWeights && typeof userConfig.affinityWeights === 'object') {
620
+ for (const signal of SIGNAL_NAMES) {
621
+ if (typeof userConfig.affinityWeights[signal] === 'number') {
622
+ weights[signal] = userConfig.affinityWeights[signal];
623
+ }
624
+ }
625
+ }
626
+
627
+ // Normalize weights to sum to 1.0
628
+ const weightSum = Object.values(weights).reduce((a, b) => a + b, 0);
629
+ if (Math.abs(weightSum - 1.0) > 0.001) {
630
+ // @cap-risk Weights that do not sum to 1.0 are silently normalized — could mask user config errors
631
+ for (const signal of SIGNAL_NAMES) {
632
+ weights[signal] = weights[signal] / weightSum;
633
+ }
634
+ }
635
+
636
+ // Merge band thresholds
637
+ let bands = { ...DEFAULT_BANDS };
638
+ if (userConfig.affinityBands && typeof userConfig.affinityBands === 'object') {
639
+ if (typeof userConfig.affinityBands.urgent === 'number') bands.urgent = userConfig.affinityBands.urgent;
640
+ if (typeof userConfig.affinityBands.notify === 'number') bands.notify = userConfig.affinityBands.notify;
641
+ if (typeof userConfig.affinityBands.silent === 'number') bands.silent = userConfig.affinityBands.silent;
642
+ }
643
+
644
+ return { weights, bands };
645
+ }
646
+
647
+ // --- Core Functions ---
648
+
649
+ // @cap-todo(ac:F-036/AC-1) Compute composite affinity score (0.0-1.0) between any two thread nodes by combining 8 weighted signal scores
650
+
651
+ /**
652
+ * Compute the full affinity between two threads using all 8 signals.
653
+ * @param {Thread} threadA - First thread
654
+ * @param {Thread} threadB - Second thread
655
+ * @param {AffinityContext} context - Graph, threads, and index data
656
+ * @param {AffinityConfig} [config] - Optional config (uses defaults if omitted)
657
+ * @returns {AffinityResult}
658
+ */
659
+ function computeAffinity(threadA, threadB, context, config) {
660
+ const cfg = config || { weights: { ...DEFAULT_WEIGHTS }, bands: { ...DEFAULT_BANDS } };
661
+ return _computeWithSignals(threadA, threadB, context, cfg, SIGNAL_NAMES);
662
+ }
663
+
664
+ /**
665
+ * Compute affinity using only the 4 realtime signals.
666
+ * Weights are renormalized to sum to 1.0 across the selected signals.
667
+ * @param {Thread} threadA
668
+ * @param {Thread} threadB
669
+ * @param {AffinityContext} context
670
+ * @param {AffinityConfig} [config]
671
+ * @returns {AffinityResult}
672
+ */
673
+ function computeRealtimeAffinity(threadA, threadB, context, config) {
674
+ const cfg = config || { weights: { ...DEFAULT_WEIGHTS }, bands: { ...DEFAULT_BANDS } };
675
+ return _computeWithSignals(threadA, threadB, context, cfg, REALTIME_SIGNALS);
676
+ }
677
+
678
+ /**
679
+ * Compute affinity using only the 4 post-session signals.
680
+ * Weights are renormalized to sum to 1.0 across the selected signals.
681
+ * @param {Thread} threadA
682
+ * @param {Thread} threadB
683
+ * @param {AffinityContext} context
684
+ * @param {AffinityConfig} [config]
685
+ * @returns {AffinityResult}
686
+ */
687
+ function computePostSessionAffinity(threadA, threadB, context, config) {
688
+ const cfg = config || { weights: { ...DEFAULT_WEIGHTS }, bands: { ...DEFAULT_BANDS } };
689
+ return _computeWithSignals(threadA, threadB, context, cfg, POST_SESSION_SIGNALS);
690
+ }
691
+
692
+ /**
693
+ * Internal: compute affinity using a specific subset of signals.
694
+ * @param {Thread} threadA
695
+ * @param {Thread} threadB
696
+ * @param {AffinityContext} context
697
+ * @param {AffinityConfig} config
698
+ * @param {SignalName[]} signalNames - Which signals to use
699
+ * @returns {AffinityResult}
700
+ */
701
+ function _computeWithSignals(threadA, threadB, context, config, signalNames) {
702
+ const signals = [];
703
+ let compositeScore = 0;
704
+
705
+ // Compute renormalized weights for the selected signal subset
706
+ const subsetWeightSum = signalNames.reduce((sum, name) => sum + (config.weights[name] || 0), 0);
707
+ const normalizer = subsetWeightSum > 0 ? subsetWeightSum : 1;
708
+
709
+ for (const name of signalNames) {
710
+ const fn = SIGNAL_FUNCTIONS[name];
711
+ if (!fn) continue;
712
+
713
+ const result = fn(threadA, threadB, context);
714
+ signals.push(result);
715
+
716
+ const weight = (config.weights[name] || 0) / normalizer;
717
+ compositeScore += result.score * weight;
718
+ }
719
+
720
+ compositeScore = clamp01(compositeScore);
721
+ const band = classifyBand(compositeScore, config.bands);
722
+
723
+ return {
724
+ sourceThreadId: threadA.id,
725
+ targetThreadId: threadB.id,
726
+ compositeScore,
727
+ band,
728
+ signals,
729
+ computedAt: new Date().toISOString(),
730
+ };
731
+ }
732
+
733
+ // @cap-todo(ac:F-036/AC-8) <200ms for single thread pair with 100 thread nodes
734
+
735
+ /**
736
+ * Compute affinity for all unique thread pairs.
737
+ * Returns results sorted by composite score descending.
738
+ * @param {Thread[]} threads - Array of threads to compare
739
+ * @param {AffinityContext} context
740
+ * @param {AffinityConfig} [config]
741
+ * @returns {AffinityResult[]}
742
+ */
743
+ function computeAffinityBatch(threads, context, config) {
744
+ const cfg = config || { weights: { ...DEFAULT_WEIGHTS }, bands: { ...DEFAULT_BANDS } };
745
+ const results = [];
746
+
747
+ for (let i = 0; i < threads.length; i++) {
748
+ for (let j = i + 1; j < threads.length; j++) {
749
+ results.push(computeAffinity(threads[i], threads[j], context, cfg));
750
+ }
751
+ }
752
+
753
+ // Sort descending by composite score
754
+ results.sort((a, b) => b.compositeScore - a.compositeScore);
755
+
756
+ return results;
757
+ }
758
+
759
+ // @cap-todo(ac:F-036/AC-5) classifyBand with configurable thresholds
760
+
761
+ /**
762
+ * Classify a composite score into an affinity band.
763
+ * @param {number} score - Composite score (0.0-1.0)
764
+ * @param {Object} [bandConfig] - Band thresholds { urgent, notify, silent }
765
+ * @returns {AffinityBand}
766
+ */
767
+ function classifyBand(score, bandConfig) {
768
+ const bands = bandConfig || DEFAULT_BANDS;
769
+
770
+ if (score >= bands.urgent) return 'urgent';
771
+ if (score >= bands.notify) return 'notify';
772
+ if (score >= bands.silent) return 'silent';
773
+ return 'discard';
774
+ }
775
+
776
+ // @cap-todo(ac:F-036/AC-6) Discard band scores not persisted; others stored as weighted edges with type "affinity"
777
+
778
+ /**
779
+ * Filter affinity results to only those that should be persisted (non-discard).
780
+ * @param {AffinityResult[]} results
781
+ * @returns {AffinityResult[]}
782
+ */
783
+ function filterPersistable(results) {
784
+ return results.filter(r => r.band !== 'discard');
785
+ }
786
+
787
+ /**
788
+ * Convert an affinity result into a graph edge suitable for addEdge().
789
+ * Only call this for persistable (non-discard) results.
790
+ * @param {AffinityResult} result
791
+ * @param {Object} graph - MemoryGraph to resolve thread node IDs
792
+ * @returns {Object|null} Graph edge object or null if thread nodes not found
793
+ */
794
+ function toGraphEdge(result, graph) {
795
+ const sourceNodeId = findThreadNodeId(graph, result.sourceThreadId);
796
+ const targetNodeId = findThreadNodeId(graph, result.targetThreadId);
797
+
798
+ if (!sourceNodeId || !targetNodeId) return null;
799
+
800
+ return {
801
+ source: sourceNodeId,
802
+ target: targetNodeId,
803
+ type: 'affinity',
804
+ createdAt: result.computedAt,
805
+ active: true,
806
+ metadata: {
807
+ compositeScore: result.compositeScore,
808
+ band: result.band,
809
+ signals: result.signals.map(s => ({
810
+ signal: s.signal,
811
+ score: s.score,
812
+ reason: s.reason,
813
+ })),
814
+ },
815
+ };
816
+ }
817
+
818
+ // --- Module Exports ---
819
+
820
+ // @cap-todo(ac:F-036/AC-7) Pure logic module — no direct I/O (except loadConfig)
821
+ // @cap-decision Exporting internal helpers prefixed with _ for testing, following project convention.
822
+
823
+ module.exports = {
824
+ // Core affinity computation
825
+ computeAffinity,
826
+ computeRealtimeAffinity,
827
+ computePostSessionAffinity,
828
+ computeAffinityBatch,
829
+
830
+ // Classification and filtering
831
+ classifyBand,
832
+ filterPersistable,
833
+ toGraphEdge,
834
+
835
+ // Configuration
836
+ loadConfig,
837
+ mergeWithDefaults,
838
+
839
+ // Constants
840
+ SIGNAL_NAMES,
841
+ REALTIME_SIGNALS,
842
+ POST_SESSION_SIGNALS,
843
+ DEFAULT_WEIGHTS,
844
+ DEFAULT_BANDS,
845
+
846
+ // Internal (for testing)
847
+ _signalFeatureIdOverlap: signalFeatureIdOverlap,
848
+ _signalSharedFiles: signalSharedFiles,
849
+ _signalTemporalProximity: signalTemporalProximity,
850
+ _signalCausalChains: signalCausalChains,
851
+ _signalConceptOverlap: signalConceptOverlap,
852
+ _signalProblemSpaceSimilarity: signalProblemSpaceSimilarity,
853
+ _signalSharedDecisionsDeep: signalSharedDecisionsDeep,
854
+ _signalTransitiveConnections: signalTransitiveConnections,
855
+ _jaccard: jaccard,
856
+ _extractKeywords: extractKeywords,
857
+ _clamp01: clamp01,
858
+ _findThreadNodeId: findThreadNodeId,
859
+ _collectFilesForThread: collectFilesForThread,
860
+ _getGraphNeighbors: getGraphNeighbors,
861
+ _computeWithSignals,
862
+ };