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,693 @@
1
+ // @cap-context CAP v5 F-067 Thread + Cluster Navigator — thread browser, detail view, cluster visualization, keyword overlap, drift warnings, keyboard nav.
2
+ // @cap-context Extracted from cap-ui.cjs as part of F-068 hand-off (cap-ui.cjs was 2245 LOC). Public API stays stable via re-exports from cap-ui.cjs.
3
+ // @cap-decision(F-068/split) Extracted as a standalone module so F-068 can add cap-ui-design-editor.cjs alongside without touching unrelated code.
4
+ // @cap-decision(F-067/D1) Thread list is sorted newest-first. Chronologically recent threads are the more useful default for "what did we discuss recently".
5
+ // @cap-decision(F-067/D2) Thread detail uses an inline side-panel layout (list left, detail right). Familiar pattern, keyboard-friendly, no modal z-index wrangling.
6
+ // @cap-decision(F-067/D3) Cluster visualization is a plain list (not a mini-graph). The mind-map (F-066) already covers graph topology; the cluster view adds tabular depth (members, affinity, drift) that a mini-graph would hide.
7
+ // @cap-decision(F-067/D4) Keyword overlap is a 3-column list (A∩B | A only | B only). A Venn diagram would add SVG complexity for no accuracy gain.
8
+ // @cap-decision(F-067/D5) Drift warnings render as an inline icon plus a colored left border on the cluster row — visible without being loud.
9
+ // @cap-decision(F-067/D6) Keyboard navigation extends to BOTH the thread-list and mind-map nodes (F-066 deferred a11y is tied up here). Tab/Arrow/Enter/Escape wired consistently.
10
+ // @cap-constraint Zero external dependencies — node builtins only (here: none; pure data derivation + string rendering).
11
+
12
+ 'use strict';
13
+
14
+ // @cap-feature(feature:F-067) Thread + Cluster Navigator — thread browser, detail view, cluster list, keyword overlap, drift warnings, keyboard nav.
15
+
16
+ // --- HTML escape (local copy; cap-ui.cjs keeps the canonical one for re-export stability) ---
17
+ // @cap-decision(F-068/split) Local escapeHtml avoids a circular require with cap-ui.cjs. Behaviour is byte-identical to cap-ui.escapeHtml.
18
+ function escapeHtml(v) {
19
+ if (v === null || v === undefined) return '';
20
+ return String(v)
21
+ .replace(/&/g, '&')
22
+ .replace(/</g, '&lt;')
23
+ .replace(/>/g, '&gt;')
24
+ .replace(/"/g, '&quot;')
25
+ .replace(/'/g, '&#39;');
26
+ }
27
+
28
+ // --- F-067 Thread + Cluster Navigator --------------------------------------
29
+
30
+ // @cap-decision(F-067/D7) buildThreadData is a pure derivation: input = (threads, clusters, affinity, graph);
31
+ // output = { threads, clusters, keywordIndex } with chronological order, member counts, and avg affinity
32
+ // precomputed per cluster. No DOM, no I/O — testable in isolation without a running server.
33
+ // @cap-decision(F-067/D8) Drift detection reuses cap-cluster-helpers._computeDriftStatus via the graph
34
+ // passed through. Not a re-implementation; a thin projection into a UI-friendly shape.
35
+
36
+ // Lazy require of cluster-helpers for the drift computation — avoids adding it to the top-level
37
+ // require graph (cap-cluster-helpers already sits behind cap-cluster-io's lazy require pattern).
38
+ /** @returns {typeof import('./cap-cluster-helpers.cjs')} */
39
+ function _clusterHelpers() {
40
+ return require('./cap-cluster-helpers.cjs');
41
+ }
42
+
43
+ /**
44
+ * @typedef {Object} ThreadNavData
45
+ * @property {Object[]} threads - Full Thread objects sorted newest-first
46
+ * @property {Object[]} clusters - Clusters augmented with drift, avgAffinity, memberCount, pairwise[]
47
+ * @property {Object<string,string[]>} keywordIndex - threadId -> sorted unique keywords
48
+ */
49
+
50
+ // @cap-todo(ac:F-067/AC-1) Pure derivation: chronological sort + keyword index + cluster enrichment.
51
+ // @cap-todo(ac:F-067/AC-3) Cluster enrichment adds drift status, avg affinity, pairwise edges for rendering.
52
+ // @cap-todo(ac:F-067/AC-5) Drift status is stamped onto each cluster so the renderer can highlight drifting clusters.
53
+ /**
54
+ * Pure derivation: take raw threads + clusters + affinity + graph, return a UI-friendly bundle.
55
+ * @param {Object} params
56
+ * @param {Object[]} [params.threads] - Full Thread objects
57
+ * @param {Object[]} [params.clusters] - Clusters (id, label, members, drift?)
58
+ * @param {Object[]} [params.affinity] - Pairwise AffinityResult[]
59
+ * @param {Object} [params.graph] - Memory graph (for drift computation)
60
+ * @returns {ThreadNavData}
61
+ */
62
+ function buildThreadData(params) {
63
+ const rawThreads = (params && Array.isArray(params.threads)) ? params.threads : [];
64
+ const rawClusters = (params && Array.isArray(params.clusters)) ? params.clusters : [];
65
+ const affinity = (params && Array.isArray(params.affinity)) ? params.affinity : [];
66
+ const graph = (params && params.graph) || { nodes: {}, edges: [] };
67
+
68
+ // @cap-decision(F-067/D1) Chronological sort: newest timestamp first. Lexicographic ISO-8601 sort
69
+ // works byte-identically to Date-based sort and is deterministic.
70
+ const threads = [...rawThreads].sort(function (a, b) {
71
+ const at = (a && a.timestamp) ? String(a.timestamp) : '';
72
+ const bt = (b && b.timestamp) ? String(b.timestamp) : '';
73
+ if (at === bt) return String((a && a.id) || '').localeCompare(String((b && b.id) || ''));
74
+ return bt.localeCompare(at);
75
+ });
76
+
77
+ // Build keyword index (threadId -> sorted deduped keywords)
78
+ const keywordIndex = {};
79
+ for (const t of threads) {
80
+ if (!t || !t.id) continue;
81
+ const kws = Array.isArray(t.keywords) ? t.keywords : [];
82
+ keywordIndex[t.id] = [...new Set(kws.map(k => String(k)))].sort();
83
+ }
84
+
85
+ // Affinity map: "threadA|threadB" (sorted) -> compositeScore
86
+ const affinityMap = new Map();
87
+ for (const a of affinity) {
88
+ if (!a || !a.sourceThreadId || !a.targetThreadId) continue;
89
+ const key = a.sourceThreadId < a.targetThreadId
90
+ ? `${a.sourceThreadId}|${a.targetThreadId}`
91
+ : `${a.targetThreadId}|${a.sourceThreadId}`;
92
+ // Keep max if duplicates exist (mirrors cluster-detect._buildAffinityMap).
93
+ const existing = affinityMap.get(key) || 0;
94
+ if (typeof a.compositeScore === 'number' && a.compositeScore > existing) {
95
+ affinityMap.set(key, a.compositeScore);
96
+ }
97
+ }
98
+
99
+ const helpers = _clusterHelpers();
100
+ const clusters = rawClusters.map(function (c) {
101
+ const members = Array.isArray(c.members) ? c.members : [];
102
+ // Pairwise rows within the cluster: member-A, member-B, score.
103
+ const pairwise = [];
104
+ for (let i = 0; i < members.length; i++) {
105
+ for (let j = i + 1; j < members.length; j++) {
106
+ const a = members[i];
107
+ const b = members[j];
108
+ const key = a < b ? `${a}|${b}` : `${b}|${a}`;
109
+ const score = affinityMap.get(key) || 0;
110
+ pairwise.push({ a, b, score });
111
+ }
112
+ }
113
+ pairwise.sort((x, y) => y.score - x.score);
114
+
115
+ // Avg affinity = mean of pairwise scores. Empty cluster (single member) -> 0.
116
+ let avg = 0;
117
+ if (pairwise.length > 0) {
118
+ let sum = 0;
119
+ for (const p of pairwise) sum += p.score;
120
+ avg = sum / pairwise.length;
121
+ }
122
+
123
+ // @cap-todo(ac:F-067/AC-5) Compute drift status via cap-cluster-helpers (reuses established logic).
124
+ // If the graph has no relevant edges, drift stays "stable (...)" — that's the intended empty-state string.
125
+ let drift = 'stable (no data)';
126
+ try {
127
+ drift = helpers._computeDriftStatus(members, graph);
128
+ } catch {
129
+ drift = 'stable (no data)';
130
+ }
131
+ const drifting = /drift|diverging/i.test(drift) && !/^stable/i.test(drift);
132
+
133
+ return {
134
+ id: c.id,
135
+ label: c.label || 'unnamed',
136
+ members: members,
137
+ memberCount: members.length,
138
+ pairwise,
139
+ avgAffinity: Math.round(avg * 1000) / 1000,
140
+ drift,
141
+ drifting,
142
+ };
143
+ });
144
+
145
+ return { threads, clusters, keywordIndex };
146
+ }
147
+
148
+ // @cap-todo(ac:F-067/AC-1) Render thread list chronologically: timestamp, name, featureIds, keyword badges.
149
+ // @cap-decision(F-067/D9) Each list item carries tabindex=0 + role=button + data-thread-id so the client-side
150
+ // JS can drive both mouse and keyboard interaction with the same selector.
151
+ /**
152
+ * Render the thread list HTML (left rail of the navigator).
153
+ * Pure function — no DOM access, no I/O.
154
+ * @param {Object[]} threads - Threads sorted newest-first (from buildThreadData)
155
+ * @returns {string} HTML markup for the list
156
+ */
157
+ function renderThreadList(threads) {
158
+ if (!Array.isArray(threads) || threads.length === 0) {
159
+ return '<ul class="thread-list" id="thread-nav-list"><li class="empty">No threads yet. Run /cap:brainstorm to create one.</li></ul>';
160
+ }
161
+ const items = threads.map(function (t, idx) {
162
+ const fids = (t.featureIds && t.featureIds.length > 0)
163
+ ? t.featureIds.map(function (f) { return `<span class="tn-fid">${escapeHtml(f)}</span>`; }).join(' ')
164
+ : '';
165
+ const kws = (t.keywords && t.keywords.length > 0)
166
+ ? t.keywords.slice(0, 6).map(function (k) { return `<span class="tn-kw">${escapeHtml(k)}</span>`; }).join(' ')
167
+ : '';
168
+ return ` <li class="thread-item tn-thread" role="button" tabindex="0" aria-label="Open thread ${escapeHtml(t.name || t.id)}" data-thread-id="${escapeHtml(t.id || '')}" data-thread-index="${idx}">
169
+ <div class="tn-head"><span class="ts">${escapeHtml(t.timestamp || '')}</span> <strong>${escapeHtml(t.name || t.id || '')}</strong></div>
170
+ <div class="tn-meta">${fids}${fids && kws ? ' · ' : ''}${kws}</div>
171
+ </li>`;
172
+ }).join('\n');
173
+ return `<ul class="thread-list" id="thread-nav-list">\n${items}\n </ul>`;
174
+ }
175
+
176
+ // @cap-todo(ac:F-067/AC-2) Thread detail view: problem statement, solution shape, boundary decisions, feature IDs, parent link.
177
+ /**
178
+ * Render the thread detail view for a single thread.
179
+ * Returns the full panel markup including the 5 mandated fields.
180
+ * @param {Object|null} thread - Thread to render, or null for empty state
181
+ * @returns {string} HTML markup
182
+ */
183
+ function renderThreadDetail(thread) {
184
+ if (!thread) {
185
+ return '<div class="tn-detail-empty">Select a thread on the left to see its details.</div>';
186
+ }
187
+ const problemStatement = thread.problemStatement || '';
188
+ const solutionShape = thread.solutionShape || '';
189
+ const boundaryDecisions = Array.isArray(thread.boundaryDecisions) ? thread.boundaryDecisions : [];
190
+ const featureIds = Array.isArray(thread.featureIds) ? thread.featureIds : [];
191
+ const parentId = thread.parentThreadId || null;
192
+
193
+ const boundaryItems = boundaryDecisions.length > 0
194
+ ? boundaryDecisions.map(function (b) { return `<li>${escapeHtml(b)}</li>`; }).join('')
195
+ : '<li class="empty">(none)</li>';
196
+
197
+ const featurePills = featureIds.length > 0
198
+ ? featureIds.map(function (f) { return `<span class="tn-fid">${escapeHtml(f)}</span>`; }).join(' ')
199
+ : '<span class="empty">(none)</span>';
200
+
201
+ const parentLine = parentId
202
+ ? `<a class="tn-parent-link" href="#thread-${escapeHtml(parentId)}" data-parent-id="${escapeHtml(parentId)}">${escapeHtml(parentId)}</a>`
203
+ : '<span class="empty">(root thread — no parent)</span>';
204
+
205
+ return `<article class="tn-detail" id="thread-${escapeHtml(thread.id || '')}" aria-live="polite">
206
+ <header class="tn-detail-head">
207
+ <h3>${escapeHtml(thread.name || thread.id || '')}</h3>
208
+ <div class="tn-detail-meta">
209
+ <span class="ts">${escapeHtml(thread.timestamp || '')}</span>
210
+ · ${escapeHtml(thread.id || '')}
211
+ </div>
212
+ </header>
213
+ <section class="tn-field"><h4>Problem Statement</h4><p>${escapeHtml(problemStatement)}</p></section>
214
+ <section class="tn-field"><h4>Solution Shape</h4><p>${escapeHtml(solutionShape)}</p></section>
215
+ <section class="tn-field"><h4>Boundary Decisions</h4><ul>${boundaryItems}</ul></section>
216
+ <section class="tn-field"><h4>Feature IDs</h4><div>${featurePills}</div></section>
217
+ <section class="tn-field"><h4>Parent Thread</h4><div>${parentLine}</div></section>
218
+ </article>`;
219
+ }
220
+
221
+ // @cap-todo(ac:F-067/AC-3) Cluster view: per cluster — name, members, pairwise affinity, drift status.
222
+ // @cap-todo(ac:F-067/AC-5) Drift-status clusters render with the drift-warning class (CSS icon + colored border).
223
+ /**
224
+ * Render the cluster overview list.
225
+ * @param {Object[]} clusters - Clusters enriched by buildThreadData (with drift + pairwise)
226
+ * @returns {string} HTML markup
227
+ */
228
+ function renderClusterView(clusters) {
229
+ if (!Array.isArray(clusters) || clusters.length === 0) {
230
+ return '<div class="tn-cluster-empty empty">No clusters detected yet. Run /cap:cluster after a few brainstorm sessions.</div>';
231
+ }
232
+ const items = clusters.map(function (c) {
233
+ const driftClass = c.drifting ? ' drift-warning' : '';
234
+ const driftIcon = c.drifting ? '<span class="tn-drift-icon" aria-hidden="true">⚠</span>' : '';
235
+ const members = (c.members || []).map(function (m) { return `<span class="tn-member">${escapeHtml(m)}</span>`; }).join(' ');
236
+ const top = (c.pairwise || []).slice(0, 3).map(function (p) {
237
+ return `<li><code>${escapeHtml(p.a)} ↔ ${escapeHtml(p.b)}</code> <span class="tn-score">${p.score.toFixed(3)}</span></li>`;
238
+ }).join('');
239
+ return ` <li class="tn-cluster${driftClass}" data-cluster-id="${escapeHtml(c.id || '')}">
240
+ <div class="tn-cluster-head">${driftIcon}<strong>${escapeHtml(c.label || 'unnamed')}</strong> <span class="tn-cluster-meta">${c.memberCount} members · avg aff. ${(c.avgAffinity || 0).toFixed(3)}</span></div>
241
+ <div class="tn-drift-status" aria-label="drift status">drift: ${escapeHtml(c.drift || 'unknown')}</div>
242
+ <div class="tn-cluster-members">${members}</div>
243
+ <ul class="tn-pairwise">${top || '<li class="empty">(single member — no pairs)</li>'}</ul>
244
+ </li>`;
245
+ }).join('\n');
246
+ return `<ul class="tn-cluster-list">\n${items}\n </ul>`;
247
+ }
248
+
249
+ // @cap-todo(ac:F-067/AC-4) Keyword overlap: shared + unique keywords for a selected thread pair.
250
+ /**
251
+ * Render a 3-column keyword overlap view for two threads.
252
+ * Pure function — determinism via sorted arrays.
253
+ * @param {Object|null} threadA
254
+ * @param {Object|null} threadB
255
+ * @returns {string} HTML markup
256
+ */
257
+ function renderKeywordOverlap(threadA, threadB) {
258
+ if (!threadA || !threadB) {
259
+ return '<div class="tn-overlap-empty empty">Pick two threads above to compare their keywords.</div>';
260
+ }
261
+ const kwA = new Set((threadA.keywords || []).map(String));
262
+ const kwB = new Set((threadB.keywords || []).map(String));
263
+
264
+ const shared = [...kwA].filter(k => kwB.has(k)).sort();
265
+ const onlyA = [...kwA].filter(k => !kwB.has(k)).sort();
266
+ const onlyB = [...kwB].filter(k => !kwA.has(k)).sort();
267
+
268
+ const col = function (title, kws) {
269
+ if (kws.length === 0) return `<div class="tn-col"><h4>${escapeHtml(title)}</h4><p class="empty">(none)</p></div>`;
270
+ const pills = kws.map(function (k) { return `<span class="tn-kw">${escapeHtml(k)}</span>`; }).join(' ');
271
+ return `<div class="tn-col"><h4>${escapeHtml(title)} (${kws.length})</h4><div>${pills}</div></div>`;
272
+ };
273
+
274
+ return `<div class="tn-overlap" aria-live="polite">
275
+ ${col(`${threadA.name || threadA.id} ∩ ${threadB.name || threadB.id}`, shared)}
276
+ ${col(`${threadA.name || threadA.id} only`, onlyA)}
277
+ ${col(`${threadB.name || threadB.id} only`, onlyB)}
278
+ </div>`;
279
+ }
280
+
281
+ // @cap-todo(ac:F-067/AC-1) The composed Thread-Nav section — list + detail + clusters + overlap tool.
282
+ // This function is the single entry point renderHtml() calls to append F-067 markup.
283
+ /**
284
+ * Build the complete Thread-Navigator HTML section.
285
+ * @param {{ threadData: ThreadNavData }} params
286
+ * @returns {string} HTML markup (closes with </main> since it is the last section)
287
+ */
288
+ function buildThreadNavSection(params) {
289
+ const data = (params && params.threadData) || { threads: [], clusters: [], keywordIndex: {} };
290
+ const threads = Array.isArray(data.threads) ? data.threads : [];
291
+ const clusters = Array.isArray(data.clusters) ? data.clusters : [];
292
+
293
+ const listHtml = renderThreadList(threads);
294
+ // Detail stays empty on first render — client JS fills it on click / keyboard Enter.
295
+ const detailHtml = renderThreadDetail(null);
296
+ const clusterHtml = renderClusterView(clusters);
297
+ const overlapHtml = renderKeywordOverlap(null, null);
298
+
299
+ // Thread-pair pickers for the keyword-overlap tool.
300
+ const threadOptions = threads.map(function (t) {
301
+ return `<option value="${escapeHtml(t.id || '')}">${escapeHtml(t.name || t.id || '')}</option>`;
302
+ }).join('\n');
303
+
304
+ // Embed thread + keyword data as a tiny JSON blob the client JS reads out of the DOM.
305
+ // @cap-risk(feature:F-067) JSON embedding uses the `</` escape defense to avoid breaking out
306
+ // of the <script type="application/json"> block. XSS surface is zero in practice because
307
+ // escapeHtml is applied everywhere user content meets attributes, but defense-in-depth still matters.
308
+ const payload = JSON.stringify({
309
+ threads: threads.map(function (t) {
310
+ return {
311
+ id: t.id,
312
+ name: t.name,
313
+ timestamp: t.timestamp,
314
+ problemStatement: t.problemStatement || '',
315
+ solutionShape: t.solutionShape || '',
316
+ boundaryDecisions: Array.isArray(t.boundaryDecisions) ? t.boundaryDecisions : [],
317
+ featureIds: Array.isArray(t.featureIds) ? t.featureIds : [],
318
+ keywords: Array.isArray(t.keywords) ? t.keywords : [],
319
+ parentThreadId: t.parentThreadId || null,
320
+ };
321
+ }),
322
+ }).replace(/<\//g, '<\\/');
323
+
324
+ return `
325
+ <section class="cap-section" id="threads">
326
+ <h2>Threads (${threads.length})</h2>
327
+ <script id="tn-data" type="application/json">${payload}</script>
328
+ <div class="tn-layout">
329
+ <div class="tn-left">
330
+ ${listHtml}
331
+ </div>
332
+ <div class="tn-right" id="tn-detail-panel" aria-label="Thread detail">
333
+ ${detailHtml}
334
+ </div>
335
+ </div>
336
+ </section>
337
+ <section class="cap-section" id="clusters">
338
+ <h2>Clusters (${clusters.length})</h2>
339
+ ${clusterHtml}
340
+ </section>
341
+ <section class="cap-section" id="keyword-overlap">
342
+ <h2>Keyword Overlap</h2>
343
+ <div class="tn-overlap-picker">
344
+ <label>Thread A <select id="tn-overlap-a" aria-label="Select thread A"><option value="">—</option>${threadOptions}</select></label>
345
+ <label>Thread B <select id="tn-overlap-b" aria-label="Select thread B"><option value="">—</option>${threadOptions}</select></label>
346
+ <button type="button" id="tn-overlap-compare" class="tn-btn">Compare</button>
347
+ </div>
348
+ <div id="tn-overlap-result">${overlapHtml}</div>
349
+ </section></main>`;
350
+ }
351
+
352
+ // @cap-todo(ac:F-067/AC-1) Thread-Nav CSS: warm-neutral + terracotta palette, monospace, no gradients.
353
+ // @cap-todo(ac:F-067/AC-5) Drift-warning: inline icon + colored left border (D5). No full-row red.
354
+ function buildThreadNavCss() {
355
+ return `
356
+ .tn-layout {
357
+ display: grid;
358
+ grid-template-columns: minmax(280px, 360px) 1fr;
359
+ gap: 16px;
360
+ }
361
+ @media (max-width: 780px) { .tn-layout { grid-template-columns: 1fr; } }
362
+ .tn-left ul.thread-list { max-height: 520px; overflow: auto; margin: 0; padding: 0; }
363
+ .tn-thread {
364
+ list-style: none;
365
+ cursor: pointer;
366
+ border: 1px solid var(--border);
367
+ background: var(--bg-card);
368
+ padding: 8px 10px;
369
+ margin: 0 0 6px;
370
+ border-radius: 3px;
371
+ outline: none;
372
+ }
373
+ .tn-thread:hover { border-color: var(--accent-muted); }
374
+ .tn-thread.tn-selected { border-color: var(--accent); background: #fff4ea; }
375
+ .tn-thread:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
376
+ .tn-thread .tn-head { display: flex; gap: 8px; align-items: baseline; font-size: 12.5px; }
377
+ .tn-thread .tn-head .ts { color: var(--fg-muted); font-size: 11px; white-space: nowrap; }
378
+ .tn-thread .tn-meta { margin-top: 4px; font-size: 11.5px; color: var(--fg-muted); display: flex; flex-wrap: wrap; gap: 4px; }
379
+ .tn-fid {
380
+ display: inline-block;
381
+ padding: 1px 5px;
382
+ background: #f1e4d0;
383
+ color: var(--state-prototyped);
384
+ border-radius: 2px;
385
+ font-size: 11px;
386
+ }
387
+ .tn-kw {
388
+ display: inline-block;
389
+ padding: 1px 5px;
390
+ background: var(--border);
391
+ color: var(--fg);
392
+ border-radius: 2px;
393
+ font-size: 11px;
394
+ }
395
+ .tn-right {
396
+ border: 1px solid var(--border);
397
+ background: var(--bg-card);
398
+ border-radius: 3px;
399
+ padding: 12px 14px;
400
+ min-height: 260px;
401
+ }
402
+ .tn-detail-empty, .tn-cluster-empty, .tn-overlap-empty {
403
+ color: var(--fg-muted);
404
+ font-style: italic;
405
+ padding: 12px 0;
406
+ }
407
+ .tn-detail-head h3 {
408
+ margin: 0 0 4px;
409
+ font-size: 14px;
410
+ color: var(--accent);
411
+ }
412
+ .tn-detail-meta { font-size: 11px; color: var(--fg-muted); margin-bottom: 10px; }
413
+ .tn-field {
414
+ margin: 10px 0;
415
+ padding-top: 6px;
416
+ border-top: 1px dashed var(--border);
417
+ }
418
+ .tn-field:first-of-type { border-top: none; padding-top: 0; }
419
+ .tn-field h4 {
420
+ margin: 0 0 4px;
421
+ font-size: 11px;
422
+ font-weight: 600;
423
+ letter-spacing: 0.04em;
424
+ text-transform: uppercase;
425
+ color: var(--fg-muted);
426
+ }
427
+ .tn-field p, .tn-field ul { margin: 0; font-size: 12.5px; }
428
+ .tn-field ul { padding-left: 18px; }
429
+ .tn-parent-link { color: var(--accent); text-decoration: none; border-bottom: 1px dashed var(--accent-muted); }
430
+ .tn-parent-link:hover { border-bottom-style: solid; }
431
+ .tn-cluster-list { list-style: none; padding: 0; margin: 0; }
432
+ .tn-cluster {
433
+ border: 1px solid var(--border);
434
+ border-left: 4px solid var(--border);
435
+ background: var(--bg-card);
436
+ padding: 10px 12px;
437
+ margin: 0 0 8px;
438
+ border-radius: 3px;
439
+ font-size: 12.5px;
440
+ }
441
+ .tn-cluster.drift-warning {
442
+ border-left-color: var(--accent);
443
+ background: #fff4ea;
444
+ }
445
+ .tn-drift-icon {
446
+ display: inline-block;
447
+ margin-right: 6px;
448
+ color: var(--accent);
449
+ font-weight: 700;
450
+ }
451
+ .tn-cluster-head strong { color: var(--accent); }
452
+ .tn-cluster-meta { color: var(--fg-muted); font-size: 11px; margin-left: 6px; }
453
+ .tn-drift-status { color: var(--fg-muted); font-size: 11px; margin: 4px 0; }
454
+ .tn-cluster-members { margin: 4px 0; display: flex; flex-wrap: wrap; gap: 4px; }
455
+ .tn-member {
456
+ display: inline-block;
457
+ padding: 1px 5px;
458
+ background: var(--border);
459
+ border-radius: 2px;
460
+ font-size: 11px;
461
+ }
462
+ .tn-pairwise { list-style: none; padding: 0; margin: 6px 0 0; font-size: 11.5px; }
463
+ .tn-pairwise li { padding: 2px 0; color: var(--fg-muted); }
464
+ .tn-pairwise code { color: var(--fg); }
465
+ .tn-pairwise .tn-score { color: var(--accent); font-weight: 600; margin-left: 6px; }
466
+ .tn-overlap-picker {
467
+ display: flex;
468
+ flex-wrap: wrap;
469
+ gap: 12px;
470
+ align-items: center;
471
+ padding: 8px 10px;
472
+ background: var(--bg-card);
473
+ border: 1px solid var(--border);
474
+ border-radius: 3px;
475
+ margin-bottom: 8px;
476
+ font-size: 12px;
477
+ }
478
+ .tn-overlap-picker label { display: inline-flex; gap: 6px; align-items: center; color: var(--fg-muted); }
479
+ .tn-overlap-picker select {
480
+ background: var(--bg);
481
+ border: 1px solid var(--border);
482
+ color: var(--fg);
483
+ font-family: var(--mono);
484
+ font-size: 12px;
485
+ padding: 3px 6px;
486
+ border-radius: 2px;
487
+ }
488
+ .tn-btn {
489
+ background: var(--accent);
490
+ color: var(--bg);
491
+ border: none;
492
+ padding: 4px 10px;
493
+ font-family: var(--mono);
494
+ font-size: 12px;
495
+ cursor: pointer;
496
+ border-radius: 2px;
497
+ }
498
+ .tn-btn:hover { background: #9c4530; }
499
+ .tn-overlap { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
500
+ @media (max-width: 780px) { .tn-overlap { grid-template-columns: 1fr; } }
501
+ .tn-col {
502
+ border: 1px solid var(--border);
503
+ background: var(--bg-card);
504
+ border-radius: 3px;
505
+ padding: 8px 10px;
506
+ }
507
+ .tn-col h4 {
508
+ margin: 0 0 4px;
509
+ font-size: 11px;
510
+ font-weight: 600;
511
+ letter-spacing: 0.04em;
512
+ text-transform: uppercase;
513
+ color: var(--fg-muted);
514
+ }
515
+ .tn-col div { display: flex; flex-wrap: wrap; gap: 4px; }
516
+ `.trim();
517
+ }
518
+
519
+ // @cap-todo(ac:F-067/AC-2) Thread-Nav client JS: click → detail, keyboard nav (Tab/Arrow/Enter/Space/Escape),
520
+ // parent-link follow, overlap compare button, drift warning is CSS-only (no JS needed for AC-5).
521
+ // @cap-decision(F-067/D10) Data passed via inline <script type="application/json"> so the same code path works
522
+ // for --serve (live) and --share (static snapshot). No fetch('/threads.json') — consistent with F-066.
523
+ // @cap-decision(F-067-review/hardening) Keyword-overlap uses Object.create(null) maps to avoid prototype-pollution on odd keys.
524
+ function buildThreadNavJs() {
525
+ return `
526
+ (function(){
527
+ var dataNode = document.getElementById('tn-data');
528
+ if (!dataNode) return;
529
+ var payload;
530
+ try { payload = JSON.parse(dataNode.textContent || '{}'); } catch (e) { payload = { threads: [] }; }
531
+ var threads = Array.isArray(payload.threads) ? payload.threads : [];
532
+ var threadById = Object.create(null);
533
+ for (var i = 0; i < threads.length; i++) { if (threads[i] && threads[i].id) threadById[threads[i].id] = threads[i]; }
534
+
535
+ var detailPanel = document.getElementById('tn-detail-panel');
536
+ var list = document.getElementById('thread-nav-list');
537
+
538
+ function escapeHtml(s){
539
+ return String(s == null ? '' : s)
540
+ .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
541
+ .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
542
+ }
543
+
544
+ // Mirror of server-side renderThreadDetail — kept minimal: same 5 fields, same IDs.
545
+ function renderDetail(t) {
546
+ if (!t) {
547
+ return '<div class="tn-detail-empty">Select a thread on the left to see its details.</div>';
548
+ }
549
+ var boundary = (t.boundaryDecisions && t.boundaryDecisions.length)
550
+ ? t.boundaryDecisions.map(function(b){ return '<li>' + escapeHtml(b) + '</li>'; }).join('')
551
+ : '<li class="empty">(none)</li>';
552
+ var fids = (t.featureIds && t.featureIds.length)
553
+ ? t.featureIds.map(function(f){ return '<span class="tn-fid">' + escapeHtml(f) + '</span>'; }).join(' ')
554
+ : '<span class="empty">(none)</span>';
555
+ var parent = t.parentThreadId
556
+ ? '<a class="tn-parent-link" href="#thread-' + escapeHtml(t.parentThreadId) + '" data-parent-id="' + escapeHtml(t.parentThreadId) + '">' + escapeHtml(t.parentThreadId) + '</a>'
557
+ : '<span class="empty">(root thread — no parent)</span>';
558
+ return '<article class="tn-detail" id="thread-' + escapeHtml(t.id) + '" aria-live="polite">'
559
+ + '<header class="tn-detail-head"><h3>' + escapeHtml(t.name || t.id) + '</h3>'
560
+ + '<div class="tn-detail-meta"><span class="ts">' + escapeHtml(t.timestamp || '') + '</span> · ' + escapeHtml(t.id) + '</div></header>'
561
+ + '<section class="tn-field"><h4>Problem Statement</h4><p>' + escapeHtml(t.problemStatement || '') + '</p></section>'
562
+ + '<section class="tn-field"><h4>Solution Shape</h4><p>' + escapeHtml(t.solutionShape || '') + '</p></section>'
563
+ + '<section class="tn-field"><h4>Boundary Decisions</h4><ul>' + boundary + '</ul></section>'
564
+ + '<section class="tn-field"><h4>Feature IDs</h4><div>' + fids + '</div></section>'
565
+ + '<section class="tn-field"><h4>Parent Thread</h4><div>' + parent + '</div></section>'
566
+ + '</article>';
567
+ }
568
+
569
+ function selectThread(id, opts) {
570
+ var t = threadById[id];
571
+ if (!t) return;
572
+ if (detailPanel) detailPanel.innerHTML = renderDetail(t);
573
+ var items = list ? list.querySelectorAll('.tn-thread') : [];
574
+ for (var i = 0; i < items.length; i++) {
575
+ if (items[i].getAttribute('data-thread-id') === id) {
576
+ items[i].classList.add('tn-selected');
577
+ if (opts && opts.focus) items[i].focus();
578
+ } else {
579
+ items[i].classList.remove('tn-selected');
580
+ }
581
+ }
582
+ }
583
+
584
+ // --- Click & keyboard on thread-list items ------------------------------
585
+ if (list) {
586
+ list.addEventListener('click', function(e){
587
+ var li = e.target && e.target.closest ? e.target.closest('.tn-thread') : null;
588
+ if (!li) return;
589
+ var id = li.getAttribute('data-thread-id');
590
+ if (id) selectThread(id);
591
+ });
592
+
593
+ list.addEventListener('keydown', function(e){
594
+ var li = e.target && e.target.closest ? e.target.closest('.tn-thread') : null;
595
+ var items = list.querySelectorAll('.tn-thread');
596
+ if (!items.length) return;
597
+ var currentIndex = -1;
598
+ for (var i = 0; i < items.length; i++) { if (items[i] === li) { currentIndex = i; break; } }
599
+
600
+ if (e.key === 'Enter' || e.key === ' ') {
601
+ if (li) {
602
+ var id = li.getAttribute('data-thread-id');
603
+ if (id) selectThread(id);
604
+ e.preventDefault();
605
+ }
606
+ } else if (e.key === 'ArrowDown') {
607
+ var next = Math.min(items.length - 1, currentIndex + 1);
608
+ if (next >= 0 && items[next]) items[next].focus();
609
+ e.preventDefault();
610
+ } else if (e.key === 'ArrowUp') {
611
+ var prev = Math.max(0, currentIndex - 1);
612
+ if (prev >= 0 && items[prev]) items[prev].focus();
613
+ e.preventDefault();
614
+ } else if (e.key === 'Home') {
615
+ if (items[0]) items[0].focus();
616
+ e.preventDefault();
617
+ } else if (e.key === 'End') {
618
+ if (items[items.length - 1]) items[items.length - 1].focus();
619
+ e.preventDefault();
620
+ } else if (e.key === 'Escape') {
621
+ if (detailPanel) detailPanel.innerHTML = renderDetail(null);
622
+ for (var j = 0; j < items.length; j++) items[j].classList.remove('tn-selected');
623
+ e.preventDefault();
624
+ }
625
+ });
626
+ }
627
+
628
+ // --- Parent-thread link follows to the selected thread ------------------
629
+ if (detailPanel) {
630
+ detailPanel.addEventListener('click', function(e){
631
+ var link = e.target && e.target.closest ? e.target.closest('.tn-parent-link') : null;
632
+ if (!link) return;
633
+ var pid = link.getAttribute('data-parent-id');
634
+ if (pid && threadById[pid]) {
635
+ e.preventDefault();
636
+ selectThread(pid, { focus: true });
637
+ }
638
+ });
639
+ }
640
+
641
+ // --- Keyword-overlap picker ---------------------------------------------
642
+ function renderOverlap(a, b) {
643
+ if (!a || !b) {
644
+ return '<div class="tn-overlap-empty empty">Pick two threads above to compare their keywords.</div>';
645
+ }
646
+ // @cap-decision(F-067-review/hardening) Use null-proto maps so keyword keys like "constructor" are treated as data.
647
+ var setA = Object.create(null); (a.keywords || []).forEach(function(k){ setA[k] = true; });
648
+ var setB = Object.create(null); (b.keywords || []).forEach(function(k){ setB[k] = true; });
649
+ var shared = []; var onlyA = []; var onlyB = [];
650
+ Object.keys(setA).sort().forEach(function(k){ if (setB[k]) shared.push(k); else onlyA.push(k); });
651
+ Object.keys(setB).sort().forEach(function(k){ if (!setA[k]) onlyB.push(k); });
652
+ function col(title, kws) {
653
+ if (!kws.length) return '<div class="tn-col"><h4>' + escapeHtml(title) + '</h4><p class="empty">(none)</p></div>';
654
+ var pills = kws.map(function(k){ return '<span class="tn-kw">' + escapeHtml(k) + '</span>'; }).join(' ');
655
+ return '<div class="tn-col"><h4>' + escapeHtml(title) + ' (' + kws.length + ')</h4><div>' + pills + '</div></div>';
656
+ }
657
+ var nameA = a.name || a.id;
658
+ var nameB = b.name || b.id;
659
+ return '<div class="tn-overlap" aria-live="polite">'
660
+ + col(nameA + ' ∩ ' + nameB, shared)
661
+ + col(nameA + ' only', onlyA)
662
+ + col(nameB + ' only', onlyB)
663
+ + '</div>';
664
+ }
665
+
666
+ var btn = document.getElementById('tn-overlap-compare');
667
+ var selA = document.getElementById('tn-overlap-a');
668
+ var selB = document.getElementById('tn-overlap-b');
669
+ var result = document.getElementById('tn-overlap-result');
670
+ function doCompare() {
671
+ if (!selA || !selB || !result) return;
672
+ var a = threadById[selA.value];
673
+ var b = threadById[selB.value];
674
+ result.innerHTML = renderOverlap(a, b);
675
+ }
676
+ if (btn) btn.addEventListener('click', doCompare);
677
+ // Auto-recompute when either select changes so the view stays in sync without requiring the button.
678
+ if (selA) selA.addEventListener('change', doCompare);
679
+ if (selB) selB.addEventListener('change', doCompare);
680
+ })();
681
+ `.trim();
682
+ }
683
+
684
+ module.exports = {
685
+ buildThreadData,
686
+ renderThreadList,
687
+ renderThreadDetail,
688
+ renderClusterView,
689
+ renderKeywordOverlap,
690
+ buildThreadNavSection,
691
+ buildThreadNavCss,
692
+ buildThreadNavJs,
693
+ };