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,1245 @@
1
+ // @cap-context CAP v5 CAP-UI Core — local HTTP server + static snapshot export for Feature-Map, Memory, Threads, DESIGN.md.
2
+ // @cap-context CAP v5 F-066 composes a Tag Mind-Map Visualization (SVG + vanilla JS, zero external deps) — extracted to cap-ui-mind-map.cjs.
3
+ // @cap-context CAP v5 F-067 composes a Thread + Cluster Navigator (thread browser, detail view, cluster visualization, keyword overlap, drift warnings, keyboard nav) — extracted to cap-ui-thread-nav.cjs.
4
+ // @cap-context CAP v5 F-068 adds a Visual Design Editor (DESIGN.md-only edits) — implemented in cap-ui-design-editor.cjs; this file wires it into the HTTP server and renderer when --editable is set.
5
+ // @cap-decision Zero external deps by design. Only node: builtins (http, fs, path, url, os, crypto). No Express, no WebSockets, no React.
6
+ // @cap-decision Read-only UI for Feature-Map + Memory + Threads is the DEFAULT. DESIGN.md edit capability requires an explicit `editable: true` flag (F-068/AC-1 + AC-6).
7
+ // @cap-decision Server-Sent Events (SSE) over WebSockets — browser-native EventSource handles reconnect, firewalls, and proxies more gracefully.
8
+ // @cap-decision HTML rendered via template literals (not DOM builder, not JSX) — zero build step, same code path for --serve and --share.
9
+ // @cap-decision CSS + JS embedded inline in every response — required for --share to produce a standalone shareable HTML file.
10
+ // @cap-decision(F-068/refactor) The request handler now uses a per-route method dispatch table.
11
+ // GET-only routes keep their 405-on-non-GET behaviour (F-065/AC-5 invariant). Edit routes accept PUT/DELETE
12
+ // ONLY when the server was started with `editable: true`. FEATURE-MAP / MEMORY paths ALWAYS 405 on writes (AC-6).
13
+ // @cap-decision(F-068/split) F-066 / F-067 helpers live in their own modules and are re-exported here so existing tests
14
+ // (cap-ui.test.cjs, cap-ui-adversarial.test.cjs, cap-ui-mind-map.test.cjs, cap-ui-thread-nav.test.cjs) keep working unchanged.
15
+ // @cap-constraint All file I/O goes through this module (and the shared lib readers); no direct fs access from command layer for UI state.
16
+ // @cap-pattern Renderer is a pure function over data; server and snapshot both call the same renderHtml() so they stay byte-compatible.
17
+
18
+ 'use strict';
19
+
20
+ // @cap-feature(feature:F-065) CAP-UI Core — local server, renderer, file watcher, snapshot exporter.
21
+ // @cap-feature(feature:F-066) Tag Mind-Map Visualization — graph data derivation, deterministic force layout, SVG renderer, inline interaction JS. (impl: cap-ui-mind-map.cjs)
22
+ // @cap-feature(feature:F-067) Thread + Cluster Navigator — thread browser, detail view, cluster list, keyword overlap, drift warnings, keyboard nav. (impl: cap-ui-thread-nav.cjs)
23
+ // @cap-feature(feature:F-068) Visual Design Editor — DESIGN.md-only edit surface, atomic writes, path-traversal guard. (impl: cap-ui-design-editor.cjs)
24
+
25
+ const http = require('node:http');
26
+ const fs = require('node:fs');
27
+ const path = require('node:path');
28
+ const os = require('node:os');
29
+
30
+ const featureMapLib = require('./cap-feature-map.cjs');
31
+ const sessionLib = require('./cap-session.cjs');
32
+ const threadLib = require('./cap-thread-tracker.cjs');
33
+ // @cap-todo(ac:F-066/AC-1) Design IDs come from cap-design.cjs so Mind-Map can classify DT-NNN / DC-NNN nodes when DESIGN.md exists.
34
+ const designLib = require('./cap-design.cjs');
35
+ // @cap-todo(ac:F-067/AC-3) Cluster + affinity data comes from cap-cluster-io.cjs so the Thread-Nav can visualize neural clusters + drift.
36
+ // @cap-risk Loading the full cluster pipeline on every HTTP request is the same O(threads²) cost paid by /cap:status; for CAP-scale (<500 threads) acceptable.
37
+ // If this becomes a hot-path bottleneck, cache the result keyed on graph.json mtime.
38
+ const clusterIo = require('./cap-cluster-io.cjs');
39
+
40
+ // F-066 mind-map lives in its own module (extracted for F-068 hand-off).
41
+ const mindMapLib = require('./cap-ui-mind-map.cjs');
42
+ // F-067 thread-nav lives in its own module (extracted for F-068 hand-off).
43
+ const threadNavLib = require('./cap-ui-thread-nav.cjs');
44
+ // F-068 design editor (DESIGN.md-only write surface).
45
+ const designEditorLib = require('./cap-ui-design-editor.cjs');
46
+
47
+ // --- Constants -------------------------------------------------------------
48
+
49
+ /** Default port. AC-1 requires configurable port; this is the default. */
50
+ const DEFAULT_PORT = 4747;
51
+
52
+ /** Max auto-increment attempts when the default port is busy (D5). */
53
+ const MAX_PORT_ATTEMPTS = 10;
54
+
55
+ /** Debounce window (ms) for file-watcher change coalescing. */
56
+ const WATCH_DEBOUNCE_MS = 100;
57
+
58
+ /** Heartbeat interval (ms) for SSE connections. */
59
+ const SSE_HEARTBEAT_MS = 30000;
60
+
61
+ /** Paths watched by the file-watcher (relative to project root). AC-3. */
62
+ const WATCH_TARGETS = [
63
+ 'FEATURE-MAP.md',
64
+ 'DESIGN.md',
65
+ path.join('.cap', 'SESSION.json'),
66
+ path.join('.cap', 'memory'), // recursive
67
+ ];
68
+
69
+ /** Snapshot output path (AC-4). */
70
+ const SNAPSHOT_PATH = path.join('.cap', 'ui', 'snapshot.html');
71
+
72
+ // --- Types -----------------------------------------------------------------
73
+
74
+ /**
75
+ * @typedef {Object} ProjectSnapshot
76
+ * @property {string} projectName
77
+ * @property {string} generatedAt - ISO timestamp
78
+ * @property {Object} session - CapSession from cap-session.cjs
79
+ * @property {Object} featureMap - FeatureMap from cap-feature-map.cjs
80
+ * @property {Object[]} threads - Thread index entries from cap-thread-tracker.cjs (lightweight list for the top-level view)
81
+ * @property {Object[]} fullThreads - Full Thread objects (problemStatement, solutionShape, boundaryDecisions, featureIds, keywords, parent)
82
+ * @property {Object[]} clusters - Detected clusters from cap-cluster-io (id, label, members, drift)
83
+ * @property {Object[]} affinityResults - Pairwise affinity results (sourceThreadId, targetThreadId, compositeScore)
84
+ * @property {Object} clusterGraph - Memory graph (nodes/edges) used for drift computation
85
+ * @property {Object} memory - { decisions, pitfalls, patterns, hotspots } as markdown strings
86
+ * @property {string|null} designMd - DESIGN.md contents if present, else null
87
+ */
88
+
89
+ // --- Logging ---------------------------------------------------------------
90
+
91
+ // @cap-todo(ac:F-065/AC-6) Server logs all events (start, SSE connect, file change, heartbeat) with ISO timestamps to stdout for debugging.
92
+ /**
93
+ * Emit a structured log line to stdout. Single line, ISO timestamp, level + message + optional meta.
94
+ * @param {'info'|'warn'|'error'} level
95
+ * @param {string} msg
96
+ * @param {Object} [meta]
97
+ */
98
+ function logEvent(level, msg, meta) {
99
+ const ts = new Date().toISOString();
100
+ const metaStr = meta && Object.keys(meta).length > 0 ? ' ' + JSON.stringify(meta) : '';
101
+ // Use process.stdout.write so tests can capture without newline-buffering surprises.
102
+ process.stdout.write(`[${ts}] [cap:ui] [${level}] ${msg}${metaStr}\n`);
103
+ }
104
+
105
+ // --- Data collection -------------------------------------------------------
106
+
107
+ /**
108
+ * Collect all project state the UI needs to render.
109
+ * Pure data aggregation — no rendering, no side effects.
110
+ * @param {string} projectRoot - Absolute path to project root
111
+ * @returns {ProjectSnapshot}
112
+ */
113
+ function collectProjectSnapshot(projectRoot) {
114
+ // @cap-risk Reading a large FEATURE-MAP.md or many thread files on every request is O(n); fine for CAP-scale projects (<200 features, <500 threads) but should be monitored.
115
+ const featureMap = safeCall(() => featureMapLib.readFeatureMap(projectRoot), { features: [], lastScan: null });
116
+ const session = safeCall(() => sessionLib.loadSession(projectRoot), {});
117
+ const threadIndex = safeCall(() => threadLib.listThreads(projectRoot), []);
118
+
119
+ // @cap-todo(ac:F-067/AC-1) Load full thread objects (problemStatement, solutionShape, boundaryDecisions, keywords, parent) for the navigator detail view.
120
+ // @cap-risk(feature:F-067) If the per-thread file is missing (index referenced an id but the thread file was never written,
121
+ // or was deleted), we degrade gracefully by promoting the index entry to a thread stub. Detail fields stay empty, but the
122
+ // thread still appears in the list. This keeps F-065 tests (which seed only the index) green.
123
+ const fullThreads = safeCall(() => {
124
+ const out = [];
125
+ for (const entry of threadIndex) {
126
+ const t = threadLib.loadThread(projectRoot, entry.id);
127
+ if (t) {
128
+ out.push(t);
129
+ } else if (entry && entry.id) {
130
+ out.push({
131
+ id: entry.id,
132
+ name: entry.name || entry.id,
133
+ timestamp: entry.timestamp || '',
134
+ featureIds: Array.isArray(entry.featureIds) ? entry.featureIds : [],
135
+ keywords: Array.isArray(entry.keywords) ? entry.keywords : [],
136
+ parentThreadId: entry.parentThreadId || null,
137
+ problemStatement: '',
138
+ solutionShape: '',
139
+ boundaryDecisions: [],
140
+ });
141
+ }
142
+ }
143
+ return out;
144
+ }, []);
145
+
146
+ // @cap-todo(ac:F-067/AC-3) Load cluster + affinity data via cap-cluster-io so the navigator can visualize neural clusters.
147
+ // @cap-risk(feature:F-067) Fresh project with no .cap/memory/graph.json: cluster-io already handles this gracefully
148
+ // (returns empty clusters array); safeCall is a belt-and-suspenders fallback.
149
+ const clusterBundle = safeCall(
150
+ () => clusterIo._loadClusterData(projectRoot),
151
+ { clusters: [], graph: { nodes: {}, edges: [] }, affinityResults: [], threads: [] }
152
+ );
153
+
154
+ const memory = {
155
+ decisions: readIfExists(path.join(projectRoot, '.cap', 'memory', 'decisions.md')),
156
+ pitfalls: readIfExists(path.join(projectRoot, '.cap', 'memory', 'pitfalls.md')),
157
+ patterns: readIfExists(path.join(projectRoot, '.cap', 'memory', 'patterns.md')),
158
+ hotspots: readIfExists(path.join(projectRoot, '.cap', 'memory', 'hotspots.md')),
159
+ };
160
+
161
+ const designMd = readIfExists(path.join(projectRoot, 'DESIGN.md'));
162
+
163
+ // @cap-todo(ac:F-066/AC-2) Parse DT-NNN / DC-NNN IDs out of DESIGN.md so the mind-map can show design-token / design-component nodes.
164
+ // Empty graph is handled gracefully — if no DESIGN.md or no IDs, the design arrays stay empty.
165
+ let designIds = { tokens: [], components: [], byToken: {}, byComponent: {} };
166
+ if (designMd) {
167
+ try { designIds = designLib.parseDesignIds(designMd) || designIds; } catch { /* ignore */ }
168
+ }
169
+
170
+ const projectName = detectProjectName(projectRoot);
171
+
172
+ return {
173
+ projectName,
174
+ generatedAt: new Date().toISOString(),
175
+ session,
176
+ featureMap,
177
+ threads: threadIndex,
178
+ fullThreads,
179
+ clusters: clusterBundle.clusters || [],
180
+ affinityResults: clusterBundle.affinityResults || [],
181
+ clusterGraph: clusterBundle.graph || { nodes: {}, edges: [] },
182
+ memory,
183
+ designMd,
184
+ designIds,
185
+ };
186
+ }
187
+
188
+ function readIfExists(filePath) {
189
+ try {
190
+ if (!fs.existsSync(filePath)) return null;
191
+ return fs.readFileSync(filePath, 'utf8');
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ function safeCall(fn, fallback) {
198
+ try { return fn(); } catch { return fallback; }
199
+ }
200
+
201
+ function detectProjectName(projectRoot) {
202
+ try {
203
+ const pkgPath = path.join(projectRoot, 'package.json');
204
+ if (fs.existsSync(pkgPath)) {
205
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
206
+ if (pkg.name) return pkg.name;
207
+ }
208
+ } catch { /* ignore */ }
209
+ return path.basename(projectRoot);
210
+ }
211
+
212
+ // --- HTML rendering --------------------------------------------------------
213
+
214
+ // @cap-decision(D1) Use template literals (not DOM builder / JSX) for HTML generation — simpler, zero build step, trivially auditable.
215
+ // @cap-decision(D2) CSS embedded via inline <style>; JS via inline <script>. Same output for --serve and --share (snapshot needs standalone).
216
+
217
+ // @cap-todo(ac:F-065/AC-2) renderHtml produces a full HTML page: header (project name, active feature, timestamp), Features section, Design section, Memory section, Threads section, footer.
218
+ /**
219
+ * Render a full HTML document for the given project snapshot.
220
+ * Pure function — no I/O, no dates beyond what the snapshot already carries.
221
+ * @param {Object} params
222
+ * @param {ProjectSnapshot} params.snapshot
223
+ * @param {Object} [params.options]
224
+ * @param {boolean} [params.options.live] - If true, include SSE client JS (for --serve). If false, static snapshot (for --share).
225
+ * @param {boolean} [params.options.editable] - If true, include the F-068 DESIGN.md editor UI + JS.
226
+ * @returns {string} Full HTML document
227
+ */
228
+ function renderHtml({ snapshot, options = {} }) {
229
+ const live = options.live === true;
230
+ const editable = options.editable === true;
231
+ const s = snapshot;
232
+
233
+ const css = buildCss({ editable });
234
+ const js = buildClientJs({ live, editable });
235
+
236
+ // @cap-todo(ac:F-066/AC-5) Mind-Map section is composed into renderHtml so it appears in BOTH the live /-response AND the .cap/ui/snapshot.html output.
237
+ const graphData = mindMapLib.buildGraphData({
238
+ featureMap: s.featureMap,
239
+ designTokens: (s.designIds && s.designIds.tokens) || [],
240
+ designComponents: (s.designIds && s.designIds.components) || [],
241
+ });
242
+
243
+ // @cap-todo(ac:F-067/AC-1) Thread-Nav section is composed into renderHtml so it appears in --serve AND --share output.
244
+ const threadData = threadNavLib.buildThreadData({
245
+ threads: s.fullThreads || [],
246
+ clusters: s.clusters || [],
247
+ affinity: s.affinityResults || [],
248
+ graph: s.clusterGraph || { nodes: {}, edges: [] },
249
+ });
250
+
251
+ // @cap-todo(ac:F-068/AC-1) Design editor section rendered ONLY when editable=true. Empty string otherwise.
252
+ const editorSection = designEditorLib.buildEditorSection({
253
+ designMd: s.designMd,
254
+ designData: s.designIds,
255
+ editable,
256
+ });
257
+
258
+ const body = [
259
+ renderHeader(s, editable),
260
+ renderNav(editable),
261
+ renderFeaturesSection(s.featureMap),
262
+ renderDesignSection(s.designMd),
263
+ editorSection,
264
+ mindMapLib.buildMindMapSection({ graphData }),
265
+ renderMemorySection(s.memory),
266
+ threadNavLib.buildThreadNavSection({ threadData }),
267
+ renderFooter(live, editable),
268
+ ].filter(Boolean).join('\n');
269
+
270
+ return [
271
+ '<!doctype html>',
272
+ '<html lang="en">',
273
+ '<head>',
274
+ '<meta charset="utf-8">',
275
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
276
+ `<title>${escapeHtml(s.projectName)} — cap:ui</title>`,
277
+ `<style>${css}</style>`,
278
+ '</head>',
279
+ '<body>',
280
+ body,
281
+ `<script>${js}</script>`,
282
+ '</body>',
283
+ '</html>',
284
+ ].join('\n');
285
+ }
286
+
287
+ // @cap-decision(Terminal-Core) Font stack is system monospace only — ui-monospace, SF Mono, Menlo, Consolas, monospace. NO Inter/Roboto/Arial (F-062 Anti-Slop).
288
+ // @cap-decision(Terminal-Core) Palette: warm neutrals + terracotta accent. NO purple-blue gradients, NO 3-column feature-card template.
289
+ // @cap-decision(F-066/D2) buildCss composes buildCoreCss + mindMapLib.buildMindMapCss + threadNavLib.buildThreadNavCss (+ F-068 editor CSS when editable).
290
+ // @cap-todo(ac:F-068/AC-1) Editor CSS opts in only when `editable` is true — read-only snapshot HTML stays lean for F-065/AC-4 size checks.
291
+ function buildCss(opts) {
292
+ const editable = !!(opts && opts.editable);
293
+ const parts = [buildCoreCss(), mindMapLib.buildMindMapCss(), threadNavLib.buildThreadNavCss()];
294
+ if (editable) parts.push(designEditorLib.buildEditorCss());
295
+ return parts.join('\n');
296
+ }
297
+
298
+ function buildCoreCss() {
299
+ return `
300
+ :root {
301
+ --fg: #2a2420;
302
+ --fg-muted: #6b5e54;
303
+ --bg: #faf7f2;
304
+ --bg-card: #fffbf5;
305
+ --border: #d9cfc2;
306
+ --accent: #b4553a;
307
+ --accent-muted: #d49a83;
308
+ --state-planned: #8a7a66;
309
+ --state-prototyped: #b47a3a;
310
+ --state-tested: #3a7a55;
311
+ --state-shipped: #2a5a7a;
312
+ --mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
313
+ }
314
+ * { box-sizing: border-box; }
315
+ html, body { margin: 0; padding: 0; }
316
+ body {
317
+ font-family: var(--mono);
318
+ font-size: 14px;
319
+ line-height: 1.55;
320
+ color: var(--fg);
321
+ background: var(--bg);
322
+ padding: 0;
323
+ }
324
+ header.cap-header {
325
+ padding: 18px 24px 12px;
326
+ border-bottom: 1px solid var(--border);
327
+ background: var(--bg-card);
328
+ }
329
+ header.cap-header h1 {
330
+ margin: 0 0 4px;
331
+ font-size: 16px;
332
+ font-weight: 600;
333
+ color: var(--accent);
334
+ }
335
+ header.cap-header .meta {
336
+ color: var(--fg-muted);
337
+ font-size: 12px;
338
+ }
339
+ header.cap-header .editable-badge {
340
+ display: inline-block;
341
+ margin-left: 8px;
342
+ padding: 1px 6px;
343
+ background: var(--accent);
344
+ color: var(--bg);
345
+ font-size: 10px;
346
+ border-radius: 2px;
347
+ text-transform: uppercase;
348
+ letter-spacing: 0.06em;
349
+ }
350
+ nav.cap-nav {
351
+ padding: 8px 24px;
352
+ border-bottom: 1px solid var(--border);
353
+ background: var(--bg);
354
+ }
355
+ nav.cap-nav a {
356
+ color: var(--fg-muted);
357
+ text-decoration: none;
358
+ margin-right: 16px;
359
+ font-size: 12px;
360
+ }
361
+ nav.cap-nav a:hover { color: var(--accent); }
362
+ main { padding: 16px 24px 48px; max-width: 1100px; }
363
+ section.cap-section {
364
+ margin: 24px 0;
365
+ padding-top: 8px;
366
+ border-top: 1px dashed var(--border);
367
+ }
368
+ section.cap-section:first-child { border-top: none; }
369
+ section.cap-section > h2 {
370
+ margin: 8px 0 12px;
371
+ font-size: 13px;
372
+ font-weight: 600;
373
+ letter-spacing: 0.04em;
374
+ text-transform: uppercase;
375
+ color: var(--accent);
376
+ }
377
+ .feature-list { list-style: none; padding: 0; margin: 0; }
378
+ .feature-item {
379
+ border: 1px solid var(--border);
380
+ background: var(--bg-card);
381
+ padding: 10px 12px;
382
+ margin: 6px 0;
383
+ border-radius: 3px;
384
+ }
385
+ .feature-item .id {
386
+ font-weight: 600;
387
+ color: var(--accent);
388
+ }
389
+ .feature-item .title { color: var(--fg); }
390
+ .feature-item .state {
391
+ font-size: 11px;
392
+ padding: 1px 6px;
393
+ border-radius: 2px;
394
+ background: var(--border);
395
+ color: var(--fg);
396
+ margin-left: 6px;
397
+ }
398
+ .feature-item .state.planned { background: var(--border); color: var(--state-planned); }
399
+ .feature-item .state.prototyped{ background: #f1e4d0; color: var(--state-prototyped); }
400
+ .feature-item .state.tested { background: #d8ead9; color: var(--state-tested); }
401
+ .feature-item .state.shipped { background: #d0dde9; color: var(--state-shipped); }
402
+ .ac-table {
403
+ width: 100%;
404
+ border-collapse: collapse;
405
+ margin-top: 8px;
406
+ font-size: 12.5px;
407
+ }
408
+ .ac-table th, .ac-table td {
409
+ text-align: left;
410
+ padding: 4px 8px;
411
+ border-bottom: 1px solid var(--border);
412
+ vertical-align: top;
413
+ }
414
+ .ac-table th { color: var(--fg-muted); font-weight: 500; }
415
+ .memory-block {
416
+ background: var(--bg-card);
417
+ border: 1px solid var(--border);
418
+ padding: 10px 12px;
419
+ margin: 6px 0;
420
+ white-space: pre-wrap;
421
+ font-size: 12.5px;
422
+ border-radius: 3px;
423
+ max-height: 360px;
424
+ overflow: auto;
425
+ }
426
+ .thread-list { list-style: none; padding: 0; margin: 0; }
427
+ .thread-item {
428
+ border: 1px solid var(--border);
429
+ background: var(--bg-card);
430
+ padding: 8px 12px;
431
+ margin: 4px 0;
432
+ font-size: 12.5px;
433
+ border-radius: 3px;
434
+ }
435
+ .thread-item .ts { color: var(--fg-muted); margin-right: 8px; }
436
+ .empty { color: var(--fg-muted); font-style: italic; }
437
+ footer.cap-footer {
438
+ padding: 12px 24px;
439
+ border-top: 1px solid var(--border);
440
+ color: var(--fg-muted);
441
+ font-size: 11px;
442
+ }
443
+ .live-dot {
444
+ display: inline-block;
445
+ width: 6px;
446
+ height: 6px;
447
+ border-radius: 50%;
448
+ background: var(--accent-muted);
449
+ margin-right: 6px;
450
+ vertical-align: middle;
451
+ }
452
+ .live-dot.on { background: #3a7a55; }
453
+ .filter-input {
454
+ background: var(--bg-card);
455
+ border: 1px solid var(--border);
456
+ color: var(--fg);
457
+ font-family: var(--mono);
458
+ font-size: 12px;
459
+ padding: 4px 8px;
460
+ border-radius: 2px;
461
+ width: 260px;
462
+ }
463
+ `.trim();
464
+ }
465
+
466
+ // @cap-decision(D3) Client JS inline — vanilla, no modules, no bundler. For snapshots (--share) the script is a no-op reconnect guard.
467
+ // @cap-decision(D4) EventSource handles reconnect natively; client code only needs to connect + refresh on 'reload'/'change' events.
468
+ // @cap-decision(F-066/D2) buildClientJs composes buildCoreJs + mindMapLib.buildMindMapJs + threadNavLib.buildThreadNavJs (+ F-068 editor JS when editable).
469
+ function buildClientJs(opts) {
470
+ const live = !!(opts && opts.live);
471
+ const editable = !!(opts && opts.editable);
472
+ const parts = [buildCoreJs({ live }), mindMapLib.buildMindMapJs(), threadNavLib.buildThreadNavJs()];
473
+ if (editable) parts.push(designEditorLib.buildEditorJs());
474
+ return parts.join('\n');
475
+ }
476
+
477
+ function buildCoreJs({ live }) {
478
+ if (!live) {
479
+ // Static snapshot: no live features, but keep a tiny client filter for feature search.
480
+ return `
481
+ (function(){
482
+ var input=document.getElementById('feature-filter');
483
+ if(!input)return;
484
+ input.addEventListener('input',function(e){
485
+ var q=e.target.value.trim().toLowerCase();
486
+ document.querySelectorAll('.feature-item').forEach(function(el){
487
+ el.style.display = !q || el.textContent.toLowerCase().indexOf(q)>=0 ? '' : 'none';
488
+ });
489
+ });
490
+ })();
491
+ `.trim();
492
+ }
493
+ return `
494
+ (function(){
495
+ var dot=document.getElementById('live-dot');
496
+ function setDot(on){ if(dot) dot.className='live-dot'+(on?' on':''); }
497
+ var input=document.getElementById('feature-filter');
498
+ if(input){
499
+ input.addEventListener('input',function(e){
500
+ var q=e.target.value.trim().toLowerCase();
501
+ document.querySelectorAll('.feature-item').forEach(function(el){
502
+ el.style.display = !q || el.textContent.toLowerCase().indexOf(q)>=0 ? '' : 'none';
503
+ });
504
+ });
505
+ }
506
+ try {
507
+ var es=new EventSource('/events');
508
+ es.addEventListener('open',function(){ setDot(true); });
509
+ es.addEventListener('error',function(){ setDot(false); });
510
+ es.addEventListener('change',function(){ location.reload(); });
511
+ es.addEventListener('reload',function(){ location.reload(); });
512
+ es.addEventListener('heartbeat',function(){ setDot(true); });
513
+ } catch(e){ setDot(false); }
514
+ })();
515
+ `.trim();
516
+ }
517
+
518
+ function renderHeader(s, editable) {
519
+ const activeFeature = s.session && s.session.activeFeature ? s.session.activeFeature : '(none)';
520
+ const lastScan = (s.featureMap && s.featureMap.lastScan) || s.generatedAt;
521
+ const badge = editable ? ' <span class="editable-badge">editable</span>' : '';
522
+ return `
523
+ <header class="cap-header">
524
+ <h1>${escapeHtml(s.projectName)} <span class="meta">— cap:ui</span>${badge}</h1>
525
+ <div class="meta">active feature: ${escapeHtml(activeFeature)} · generated: ${escapeHtml(s.generatedAt)} · last scan: ${escapeHtml(lastScan)}</div>
526
+ </header>`.trim();
527
+ }
528
+
529
+ function renderNav(editable) {
530
+ const editorLink = editable ? '\n <a href="#design-editor">Design Editor</a>' : '';
531
+ return `
532
+ <nav class="cap-nav">
533
+ <span id="live-dot" class="live-dot"></span>
534
+ <a href="#features">Features</a>
535
+ <a href="#design">Design</a>${editorLink}
536
+ <a href="#mind-map">Mind-Map</a>
537
+ <a href="#memory">Memory</a>
538
+ <a href="#threads">Threads</a>
539
+ <a href="#clusters">Clusters</a>
540
+ </nav>`.trim();
541
+ }
542
+
543
+ function renderFeaturesSection(featureMap) {
544
+ const features = (featureMap && featureMap.features) || [];
545
+ if (features.length === 0) {
546
+ return `
547
+ <main><section class="cap-section" id="features">
548
+ <h2>Features</h2>
549
+ <p class="empty">No features found. Run /cap:brainstorm to create one.</p>
550
+ </section>`;
551
+ }
552
+ const items = features.map(renderFeatureItem).join('\n');
553
+ return `
554
+ <main><section class="cap-section" id="features">
555
+ <h2>Features (${features.length})</h2>
556
+ <input id="feature-filter" class="filter-input" type="search" placeholder="filter features…" aria-label="Filter features">
557
+ <ul class="feature-list">
558
+ ${items}
559
+ </ul>
560
+ </section>`;
561
+ }
562
+
563
+ function renderFeatureItem(f) {
564
+ const state = (f.state || 'planned').toLowerCase();
565
+ // @cap-risk XSS defence: state is injected into a class attribute. Even though escapeHtml would neutralise
566
+ // angle brackets, a hostile value like `" onclick=x` could still break out of the attribute. Restrict to
567
+ // a safe CSS token charset [a-z0-9_-] so the class-based state styling keeps working for legitimate
568
+ // values (planned|prototyped|tested|shipped) while hostile values degrade to an empty token.
569
+ const stateToken = state.replace(/[^a-z0-9_-]/g, '');
570
+ const deps = (f.dependencies || []).length > 0
571
+ ? `<div class="meta">depends on: ${escapeHtml((f.dependencies || []).join(', '))}</div>`
572
+ : '';
573
+ const usesDesign = (f.usesDesign || []).length > 0
574
+ ? `<div class="meta">uses design: ${escapeHtml((f.usesDesign || []).join(', '))}</div>`
575
+ : '';
576
+ const acs = (f.acs || []).map(function (ac) {
577
+ return `<tr><td>${escapeHtml(ac.id)}</td><td>${escapeHtml(ac.status || 'pending')}</td><td>${escapeHtml(ac.description || '')}</td></tr>`;
578
+ }).join('');
579
+ const acTable = acs
580
+ ? `<table class="ac-table"><thead><tr><th>AC</th><th>Status</th><th>Description</th></tr></thead><tbody>${acs}</tbody></table>`
581
+ : '';
582
+ return ` <li class="feature-item">
583
+ <span class="id">${escapeHtml(f.id)}</span>
584
+ <span class="title">${escapeHtml(f.title || '')}</span>
585
+ <span class="state ${stateToken}">${escapeHtml(state)}</span>
586
+ ${deps}
587
+ ${usesDesign}
588
+ ${acTable}
589
+ </li>`;
590
+ }
591
+
592
+ function renderDesignSection(designMd) {
593
+ if (!designMd) {
594
+ return `
595
+ <section class="cap-section" id="design">
596
+ <h2>Design</h2>
597
+ <p class="empty">No DESIGN.md found. Run /cap:design --new.</p>
598
+ </section>`;
599
+ }
600
+ return `
601
+ <section class="cap-section" id="design">
602
+ <h2>Design (DESIGN.md)</h2>
603
+ <div class="memory-block">${escapeHtml(designMd)}</div>
604
+ </section>`;
605
+ }
606
+
607
+ function renderMemorySection(memory) {
608
+ const blocks = ['decisions', 'pitfalls', 'patterns', 'hotspots'].map(function (key) {
609
+ const content = memory[key];
610
+ if (!content) {
611
+ return `<h3>${key}</h3><p class="empty">— none —</p>`;
612
+ }
613
+ return `<h3>${key}</h3><div class="memory-block">${escapeHtml(content)}</div>`;
614
+ }).join('\n');
615
+ return `
616
+ <section class="cap-section" id="memory">
617
+ <h2>Memory</h2>
618
+ ${blocks}
619
+ </section>`;
620
+ }
621
+
622
+ // @cap-decision(F-067) The old flat renderThreadsSection is superseded by buildThreadNavSection (F-067).
623
+ // The new section owns the closing </main> tag (previously emitted here) so the HTML document stays well-formed.
624
+
625
+ function renderFooter(live, editable) {
626
+ const mode = live ? 'live (--serve)' : 'static snapshot (--share)';
627
+ const edit = editable ? ' · edit mode: DESIGN.md only (FEATURE-MAP + Memory stay read-only)' : '';
628
+ return `
629
+ <footer class="cap-footer">
630
+ cap:ui v0.1 — ${editable ? 'EDIT MODE' : 'read-only view'} · mode: ${escapeHtml(mode)}${edit} · press Ctrl+C to stop.
631
+ </footer>`.trim();
632
+ }
633
+
634
+ // @cap-risk HTML escaping is critical — Feature-Map and memory content are developer-authored but may contain markdown symbols or user-controlled strings in multi-user repos. Centralize escaping here.
635
+ function escapeHtml(v) {
636
+ if (v === null || v === undefined) return '';
637
+ return String(v)
638
+ .replace(/&/g, '&amp;')
639
+ .replace(/</g, '&lt;')
640
+ .replace(/>/g, '&gt;')
641
+ .replace(/"/g, '&quot;')
642
+ .replace(/'/g, '&#39;');
643
+ }
644
+
645
+ // --- SSE helper ------------------------------------------------------------
646
+
647
+ // @cap-todo(ac:F-065/AC-3) SSE helper writes text/event-stream headers and provides send() / heartbeat / close handlers.
648
+ /**
649
+ * Upgrade an http response to an SSE stream. Returns a controller with send() and close().
650
+ * @param {http.ServerResponse} res
651
+ * @returns {{ send: (event: string, data: any) => boolean, close: () => void }}
652
+ */
653
+ function sseResponse(res) {
654
+ res.writeHead(200, {
655
+ 'Content-Type': 'text/event-stream',
656
+ 'Cache-Control': 'no-cache, no-transform',
657
+ 'Connection': 'keep-alive',
658
+ 'X-Accel-Buffering': 'no',
659
+ });
660
+ // Initial comment to open the stream immediately on proxied connections.
661
+ res.write(': cap-ui sse open\n\n');
662
+
663
+ let closed = false;
664
+
665
+ function send(event, data) {
666
+ if (closed) return false;
667
+ try {
668
+ const payload = typeof data === 'string' ? data : JSON.stringify(data);
669
+ res.write(`event: ${event}\ndata: ${payload}\n\n`);
670
+ return true;
671
+ } catch {
672
+ return false;
673
+ }
674
+ }
675
+ function close() {
676
+ if (closed) return;
677
+ closed = true;
678
+ try { res.end(); } catch { /* ignore */ }
679
+ }
680
+ res.on('close', close);
681
+ res.on('error', close);
682
+ return { send, close };
683
+ }
684
+
685
+ // --- File watcher ----------------------------------------------------------
686
+
687
+ // @cap-todo(ac:F-065/AC-3) startFileWatcher wraps fs.watch for FEATURE-MAP.md, DESIGN.md, .cap/SESSION.json, .cap/memory/ (recursive).
688
+ // @cap-risk fs.watch behaves differently per platform (macOS FSEvents fires once, Linux inotify fires multiple times, Windows is separate). Debounce + best-effort is the right trade-off for a local dev UI.
689
+ /**
690
+ * Start a file-watcher across FEATURE-MAP.md, DESIGN.md, .cap/SESSION.json, .cap/memory/.
691
+ * Coalesces bursts into a single onChange call via debounce.
692
+ * @param {Object} params
693
+ * @param {string} params.projectRoot
694
+ * @param {(event: {file: string, type: string}) => void} params.onChange
695
+ * @param {number} [params.debounceMs]
696
+ * @returns {{ stop: () => void }}
697
+ */
698
+ function startFileWatcher({ projectRoot, onChange, debounceMs = WATCH_DEBOUNCE_MS }) {
699
+ const watchers = [];
700
+ const pending = new Map(); // path -> Timeout
701
+ let stopped = false;
702
+
703
+ function fire(file, type) {
704
+ if (stopped) return;
705
+ const existing = pending.get(file);
706
+ if (existing) clearTimeout(existing);
707
+ const to = setTimeout(function () {
708
+ pending.delete(file);
709
+ try {
710
+ logEvent('info', 'file-change', { file, type });
711
+ onChange({ file, type });
712
+ } catch (err) {
713
+ logEvent('error', 'onChange threw', { error: err && err.message });
714
+ }
715
+ }, debounceMs);
716
+ pending.set(file, to);
717
+ }
718
+
719
+ for (const target of WATCH_TARGETS) {
720
+ const abs = path.join(projectRoot, target);
721
+ try {
722
+ // @cap-risk Non-existent paths: we attach a best-effort watcher and silently skip if missing.
723
+ if (!fs.existsSync(abs)) continue;
724
+ const stat = fs.statSync(abs);
725
+ const isDir = stat.isDirectory();
726
+ const w = fs.watch(abs, { recursive: isDir }, function (eventType, filename) {
727
+ const rel = filename ? path.join(target, String(filename)) : target;
728
+ fire(rel, eventType || 'change');
729
+ });
730
+ watchers.push(w);
731
+ } catch (err) {
732
+ logEvent('warn', 'file-watcher attach failed', { target, error: err && err.message });
733
+ }
734
+ }
735
+
736
+ function stop() {
737
+ if (stopped) return;
738
+ stopped = true;
739
+ for (const w of watchers) {
740
+ try { w.close(); } catch { /* ignore */ }
741
+ }
742
+ for (const to of pending.values()) clearTimeout(to);
743
+ pending.clear();
744
+ }
745
+
746
+ return { stop };
747
+ }
748
+
749
+ // --- HTTP server -----------------------------------------------------------
750
+
751
+ // @cap-todo(ac:F-065/AC-1) startServer binds to requested port, auto-increments on EADDRINUSE, returns {url, stop}.
752
+ // @cap-todo(ac:F-065/AC-5) Default server is read-only: only GET routes are registered. POST/PUT/DELETE return 405 Method Not Allowed.
753
+ // @cap-todo(ac:F-068/AC-1) When `editable: true`, the server also accepts PUT/DELETE on `/api/design/*` paths.
754
+ // @cap-todo(ac:F-068/AC-6) `/api/feature-map/*` and `/api/memory/*` paths ALWAYS 405 on writes — edit mode never unlocks them.
755
+ // @cap-decision(D5) Port conflict handling: auto-increment up to MAX_PORT_ATTEMPTS, then fail loudly. Mirrors how dev servers like Vite behave.
756
+ /**
757
+ * Start the CAP-UI HTTP server.
758
+ * @param {Object} params
759
+ * @param {string} params.projectRoot - Absolute path to project root
760
+ * @param {number} [params.port] - Desired port (default DEFAULT_PORT). Use 0 for an OS-assigned port.
761
+ * @param {boolean} [params.watch] - If true, start file watcher and broadcast changes via SSE (default true)
762
+ * @param {boolean} [params.editable] - If true, enable DESIGN.md edit endpoints (F-068). Default false.
763
+ * @returns {Promise<{ url: string, port: number, stop: () => Promise<void> }>}
764
+ */
765
+ function startServer({ projectRoot, port, watch = true, editable = false }) {
766
+ const desired = (typeof port === 'number') ? port : DEFAULT_PORT;
767
+ const clients = new Set();
768
+
769
+ // Broadcast to all SSE clients.
770
+ function broadcast(event, data) {
771
+ for (const c of clients) {
772
+ const ok = c.send(event, data);
773
+ if (!ok) clients.delete(c);
774
+ }
775
+ }
776
+
777
+ // @cap-todo(ac:F-068/refactor) Route table — per-route method set + handler. Replaces the monolithic
778
+ // `if (req.method !== 'GET') return send405()` approach so edit mode can slot in PUT/DELETE handlers
779
+ // without weakening the invariant for read-only paths.
780
+ const routes = buildRoutes({ projectRoot, editable, clients, broadcast });
781
+
782
+ const server = http.createServer(function (req, res) {
783
+ const method = req.method || 'GET';
784
+ const url = req.url || '/';
785
+ const match = matchRoute(routes, url);
786
+
787
+ // No route pattern matches → 404.
788
+ if (!match) {
789
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
790
+ res.end('Not Found');
791
+ return;
792
+ }
793
+
794
+ // Found the route; check method.
795
+ // @cap-decision(F-068/refactor) HEAD is accepted wherever GET is accepted. Other methods → 405 with correct Allow header.
796
+ const allowedMethods = match.route.methods;
797
+ const methodOk = allowedMethods.includes(method) || (method === 'HEAD' && allowedMethods.includes('GET'));
798
+ if (!methodOk) {
799
+ res.writeHead(405, {
800
+ 'Content-Type': 'text/plain',
801
+ 'Allow': allowedMethods.join(', ') || 'GET, HEAD',
802
+ });
803
+ res.end(`Method Not Allowed — ${allowedMethods.join(', ') || 'GET'} only on ${match.pattern}`);
804
+ return;
805
+ }
806
+
807
+ try {
808
+ match.route.handler(req, res, match.params);
809
+ } catch (err) {
810
+ logEvent('error', 'route-handler-threw', { url, method, error: err && err.message });
811
+ if (!res.headersSent) {
812
+ res.writeHead(500, { 'Content-Type': 'application/json' });
813
+ res.end(JSON.stringify({ error: 'internal error' }));
814
+ } else {
815
+ try { res.end(); } catch { /* ignore */ }
816
+ }
817
+ }
818
+ });
819
+
820
+ let watcherHandle = null;
821
+
822
+ return new Promise(function (resolve, reject) {
823
+ let attempt = 0;
824
+ let tryingPort = desired;
825
+
826
+ function tryListen() {
827
+ server.once('error', onError);
828
+ server.listen(tryingPort, '127.0.0.1', onListening);
829
+ }
830
+ function onError(err) {
831
+ if (err && err.code === 'EADDRINUSE' && desired !== 0 && attempt < MAX_PORT_ATTEMPTS) {
832
+ attempt += 1;
833
+ logEvent('warn', 'port-in-use', { port: tryingPort, trying: tryingPort + 1 });
834
+ tryingPort += 1;
835
+ server.removeListener('listening', onListening);
836
+ tryListen();
837
+ return;
838
+ }
839
+ reject(err);
840
+ }
841
+ function onListening() {
842
+ server.removeListener('error', onError);
843
+ const addr = server.address();
844
+ const actualPort = (addr && typeof addr === 'object') ? addr.port : tryingPort;
845
+ const url = `http://127.0.0.1:${actualPort}`;
846
+ logEvent('info', 'server-start', { port: actualPort, url, editable });
847
+
848
+ if (watch) {
849
+ watcherHandle = startFileWatcher({
850
+ projectRoot,
851
+ onChange: function (evt) { broadcast('change', evt); },
852
+ });
853
+ }
854
+
855
+ function stop() {
856
+ return new Promise(function (res2) {
857
+ try { if (watcherHandle) watcherHandle.stop(); } catch { /* ignore */ }
858
+ for (const c of clients) { try { c.close(); } catch { /* ignore */ } }
859
+ clients.clear();
860
+ try {
861
+ server.close(function () { res2(); });
862
+ } catch { res2(); }
863
+ });
864
+ }
865
+
866
+ resolve({ url, port: actualPort, stop });
867
+ }
868
+
869
+ tryListen();
870
+ });
871
+ }
872
+
873
+ // --- Route dispatch --------------------------------------------------------
874
+
875
+ // @cap-todo(ac:F-068/refactor) Per-route method dispatch — GET-only routes stay GET-only, edit endpoints
876
+ // only register when `editable` is true. FEATURE-MAP + Memory writes are explicitly 405 guarded (AC-6).
877
+ /**
878
+ * @typedef {Object} Route
879
+ * @property {string} pattern - URL pattern (supports `:id` segments)
880
+ * @property {string[]} methods - Allowed HTTP methods (e.g. ['GET'] or ['PUT'])
881
+ * @property {(req: http.IncomingMessage, res: http.ServerResponse, params: Object) => void} handler
882
+ */
883
+
884
+ /**
885
+ * Build the route table.
886
+ * @param {{projectRoot:string, editable:boolean, clients:Set, broadcast:Function}} ctx
887
+ * @returns {Route[]}
888
+ */
889
+ function buildRoutes(ctx) {
890
+ const { projectRoot, editable, clients, broadcast } = ctx;
891
+ const routes = [];
892
+
893
+ // --- Read-only GET routes (always available) ----------------------------
894
+ routes.push({
895
+ pattern: '/events',
896
+ methods: ['GET'],
897
+ handler: function (req, res) {
898
+ const client = sseResponse(res);
899
+ clients.add(client);
900
+ logEvent('info', 'sse-connect', { clients: clients.size });
901
+ client.send('heartbeat', { at: new Date().toISOString() });
902
+ const hb = setInterval(function () {
903
+ if (!client.send('heartbeat', { at: new Date().toISOString() })) {
904
+ clearInterval(hb);
905
+ clients.delete(client);
906
+ }
907
+ }, SSE_HEARTBEAT_MS);
908
+ res.on('close', function () {
909
+ clearInterval(hb);
910
+ clients.delete(client);
911
+ logEvent('info', 'sse-disconnect', { clients: clients.size });
912
+ });
913
+ },
914
+ });
915
+
916
+ routes.push({
917
+ pattern: '/healthz',
918
+ methods: ['GET'],
919
+ handler: function (req, res) {
920
+ res.writeHead(200, { 'Content-Type': 'application/json' });
921
+ res.end(JSON.stringify({ ok: true, at: new Date().toISOString(), editable }));
922
+ },
923
+ });
924
+
925
+ // @cap-decision(F-068) Index pages are registered twice with different patterns for exact matching
926
+ // (matchRoute treats pattern literals strictly — `/` does not collide with `/index.html`).
927
+ const indexHandler = function (req, res) {
928
+ const snapshot = collectProjectSnapshot(projectRoot);
929
+ const html = renderHtml({ snapshot, options: { live: true, editable } });
930
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
931
+ if ((req.method || 'GET') === 'HEAD') { res.end(); return; }
932
+ res.end(html);
933
+ };
934
+ routes.push({ pattern: '/', methods: ['GET'], handler: indexHandler });
935
+ routes.push({ pattern: '/index.html', methods: ['GET'], handler: indexHandler });
936
+
937
+ // --- AC-6 guard: FEATURE-MAP + MEMORY are ALWAYS read-only (even in edit mode). ----
938
+ // They respond 405 with an explicit Allow: GET header for any non-GET method.
939
+ // @cap-todo(ac:F-068/AC-6) FEATURE-MAP + Memory paths explicitly 405 on any mutating method.
940
+ routes.push({ pattern: '/api/feature-map', methods: ['GET'], handler: guardReadOnlyApi('feature-map') });
941
+ routes.push({ pattern: '/api/feature-map/:id', methods: ['GET'], handler: guardReadOnlyApi('feature-map') });
942
+ routes.push({ pattern: '/api/memory', methods: ['GET'], handler: guardReadOnlyApi('memory') });
943
+ routes.push({ pattern: '/api/memory/:id', methods: ['GET'], handler: guardReadOnlyApi('memory') });
944
+
945
+ // --- DESIGN.md read route (always available) ----------------------------
946
+ routes.push({
947
+ pattern: '/api/design/read',
948
+ methods: ['GET'],
949
+ handler: function (req, res) {
950
+ try {
951
+ const content = designLib.readDesignMd(projectRoot);
952
+ res.writeHead(200, { 'Content-Type': 'application/json' });
953
+ res.end(JSON.stringify({ content: content || null }));
954
+ } catch (err) {
955
+ res.writeHead(500, { 'Content-Type': 'application/json' });
956
+ res.end(JSON.stringify({ error: String(err && err.message || err) }));
957
+ }
958
+ },
959
+ });
960
+
961
+ // --- F-068 edit routes — ONLY when editable=true ------------------------
962
+ if (editable) {
963
+ // @cap-todo(ac:F-068/AC-2) PUT /api/design/color/:id — update a color token.
964
+ routes.push({
965
+ pattern: '/api/design/color/:id',
966
+ methods: ['PUT'],
967
+ handler: withJsonBody(function (req, res, params, body) {
968
+ const id = params.id;
969
+ if (typeof body.value !== 'string') {
970
+ return sendJson(res, 400, { error: 'body.value (string) required' });
971
+ }
972
+ applyAndWrite(projectRoot, (content) =>
973
+ designEditorLib.applyColorEdit(content, { id, value: body.value }),
974
+ broadcast, res, { op: 'color', id, value: body.value });
975
+ }),
976
+ });
977
+
978
+ // @cap-todo(ac:F-068/AC-3) PUT /api/design/spacing/:id — update a scale array (spacing or typography).
979
+ routes.push({
980
+ pattern: '/api/design/spacing/:id',
981
+ methods: ['PUT'],
982
+ handler: withJsonBody(function (req, res, params, body) {
983
+ const id = params.id;
984
+ if (!Array.isArray(body.value) && typeof body.value !== 'string') {
985
+ return sendJson(res, 400, { error: 'body.value (array or CSV string) required' });
986
+ }
987
+ applyAndWrite(projectRoot, (content) =>
988
+ designEditorLib.applySpacingEdit(content, { id, value: body.value }),
989
+ broadcast, res, { op: 'spacing', id });
990
+ }),
991
+ });
992
+
993
+ // @cap-todo(ac:F-068/AC-4) PUT /api/design/component/:id — add/remove a variant via JSON body.
994
+ routes.push({
995
+ pattern: '/api/design/component/:id',
996
+ methods: ['PUT'],
997
+ handler: withJsonBody(function (req, res, params, body) {
998
+ const id = params.id;
999
+ const action = body.action;
1000
+ const variant = body.variant;
1001
+ if (action !== 'add' && action !== 'remove') {
1002
+ return sendJson(res, 400, { error: "body.action must be 'add' or 'remove'" });
1003
+ }
1004
+ if (typeof variant !== 'string' || variant.length === 0) {
1005
+ return sendJson(res, 400, { error: 'body.variant (string) required' });
1006
+ }
1007
+ applyAndWrite(projectRoot, (content) =>
1008
+ designEditorLib.applyComponentEdit(content, { id, action, variant }),
1009
+ broadcast, res, { op: 'component', id, action, variant });
1010
+ }),
1011
+ });
1012
+
1013
+ // @cap-todo(ac:F-068/AC-4) DELETE /api/design/component/:id/variant/:name — alias for {action:'remove'}.
1014
+ routes.push({
1015
+ pattern: '/api/design/component/:id/variant/:name',
1016
+ methods: ['DELETE'],
1017
+ handler: function (req, res, params) {
1018
+ applyAndWrite(projectRoot, (content) =>
1019
+ designEditorLib.applyComponentEdit(content, { id: params.id, action: 'remove', variant: params.name }),
1020
+ broadcast, res, { op: 'component-variant-delete', id: params.id, variant: params.name });
1021
+ },
1022
+ });
1023
+ }
1024
+
1025
+ return routes;
1026
+ }
1027
+
1028
+ // @cap-decision(F-068) Route matching: strict literal segments + `:name` placeholder.
1029
+ // Any segment containing `..` or a slash (after decoding) fails to match — extra defense beyond the
1030
+ // library-layer path-traversal check in cap-ui-design-editor.cjs.
1031
+ function matchRoute(routes, rawUrl) {
1032
+ let pathname = rawUrl;
1033
+ const qIdx = pathname.indexOf('?');
1034
+ if (qIdx !== -1) pathname = pathname.slice(0, qIdx);
1035
+ const hIdx = pathname.indexOf('#');
1036
+ if (hIdx !== -1) pathname = pathname.slice(0, hIdx);
1037
+
1038
+ // Normalise: refuse `..` anywhere in the path to prevent traversal via patterns like /api/design/../../etc.
1039
+ // @cap-todo(ac:F-068/AC-5) First line of defense against path-traversal in URLs — refuse any segment equal to '..'.
1040
+ const rawSegments = pathname.split('/');
1041
+ for (const seg of rawSegments) {
1042
+ if (seg === '..' || seg === '.') return null;
1043
+ // Backslashes should never appear in URL paths; reject them defensively.
1044
+ if (seg.indexOf('\\') !== -1) return null;
1045
+ }
1046
+
1047
+ for (const route of routes) {
1048
+ const params = matchPattern(route.pattern, pathname);
1049
+ if (params) return { route, pattern: route.pattern, params };
1050
+ }
1051
+ return null;
1052
+ }
1053
+
1054
+ function matchPattern(pattern, pathname) {
1055
+ const patParts = pattern.split('/');
1056
+ const pathParts = pathname.split('/');
1057
+ if (patParts.length !== pathParts.length) return null;
1058
+ const params = {};
1059
+ for (let i = 0; i < patParts.length; i++) {
1060
+ const p = patParts[i];
1061
+ const v = pathParts[i];
1062
+ if (p.startsWith(':')) {
1063
+ // @cap-risk URL-decoded params are passed to handlers. Handlers MUST validate format (DT-NNN etc.) before use.
1064
+ let decoded;
1065
+ try { decoded = decodeURIComponent(v); } catch { return null; }
1066
+ if (decoded.indexOf('/') !== -1 || decoded.indexOf('..') !== -1 || decoded.length === 0) return null;
1067
+ params[p.slice(1)] = decoded;
1068
+ } else if (p !== v) {
1069
+ return null;
1070
+ }
1071
+ }
1072
+ return params;
1073
+ }
1074
+
1075
+ // @cap-todo(ac:F-068/AC-6) guardReadOnlyApi: FEATURE-MAP / Memory endpoints respond with a minimal read-only stub on GET,
1076
+ // and the route registration guarantees 405 on any other method (including in --editable mode).
1077
+ function guardReadOnlyApi(kind) {
1078
+ return function (req, res) {
1079
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1080
+ res.end(JSON.stringify({ readOnly: true, kind, note: 'Collaboration is Git-based for FEATURE-MAP and Memory (F-068/AC-6).' }));
1081
+ };
1082
+ }
1083
+
1084
+ // Body parsing helper — reads a small JSON body (≤64KB) and calls the handler.
1085
+ function withJsonBody(handler) {
1086
+ return function (req, res, params) {
1087
+ const chunks = [];
1088
+ let total = 0;
1089
+ const LIMIT = 64 * 1024;
1090
+ req.on('data', function (chunk) {
1091
+ total += chunk.length;
1092
+ if (total > LIMIT) {
1093
+ req.removeAllListeners('data');
1094
+ sendJson(res, 413, { error: 'request body too large' });
1095
+ try { req.destroy(); } catch { /* ignore */ }
1096
+ return;
1097
+ }
1098
+ chunks.push(chunk);
1099
+ });
1100
+ req.on('end', function () {
1101
+ let body = {};
1102
+ if (chunks.length > 0) {
1103
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
1104
+ catch (err) { return sendJson(res, 400, { error: 'invalid JSON: ' + err.message }); }
1105
+ }
1106
+ handler(req, res, params, body);
1107
+ });
1108
+ req.on('error', function (err) { sendJson(res, 400, { error: 'request error: ' + err.message }); });
1109
+ };
1110
+ }
1111
+
1112
+ function sendJson(res, status, obj) {
1113
+ if (!res.headersSent) {
1114
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1115
+ }
1116
+ res.end(JSON.stringify(obj));
1117
+ }
1118
+
1119
+ // @cap-todo(ac:F-068/AC-5) applyAndWrite: read DESIGN.md, run pure transform, atomically write result, broadcast change.
1120
+ function applyAndWrite(projectRoot, transform, broadcast, res, meta) {
1121
+ let content;
1122
+ try {
1123
+ content = designLib.readDesignMd(projectRoot);
1124
+ } catch (err) {
1125
+ return sendJson(res, 500, { error: 'failed to read DESIGN.md: ' + (err && err.message) });
1126
+ }
1127
+ if (content === null) {
1128
+ return sendJson(res, 404, { error: 'DESIGN.md not found — run /cap:design --new first' });
1129
+ }
1130
+ let next;
1131
+ try {
1132
+ next = transform(content);
1133
+ } catch (err) {
1134
+ return sendJson(res, 400, { error: (err && err.message) || 'edit failed' });
1135
+ }
1136
+ if (typeof next !== 'string') {
1137
+ return sendJson(res, 500, { error: 'transform did not return a string' });
1138
+ }
1139
+ if (next === content) {
1140
+ // No-op edit — do not write, do not broadcast, but report success for idempotency.
1141
+ logEvent('info', 'design-edit-noop', meta);
1142
+ return sendJson(res, 200, { ok: true, noop: true });
1143
+ }
1144
+ try {
1145
+ designEditorLib.atomicWriteDesign(projectRoot, next);
1146
+ } catch (err) {
1147
+ logEvent('error', 'atomic-write-failed', { err: err && err.message, meta });
1148
+ return sendJson(res, 500, { error: (err && err.message) || 'write failed' });
1149
+ }
1150
+ logEvent('info', 'design-edit', meta);
1151
+ try { broadcast('change', { file: 'DESIGN.md', type: 'edit' }); } catch { /* ignore */ }
1152
+ sendJson(res, 200, { ok: true });
1153
+ }
1154
+
1155
+ // --- Snapshot (standalone HTML) -------------------------------------------
1156
+
1157
+ // @cap-todo(ac:F-065/AC-4) createSnapshot writes a standalone HTML snapshot to .cap/ui/snapshot.html with inline CSS/JS and no external fetch.
1158
+ // @cap-todo(ac:F-068/hand-off) createSnapshot outputPath is now path-traversal-guarded via designEditorLib.checkContainment.
1159
+ // @cap-decision(D2) Snapshot and live server share the same renderHtml() — the only difference is options.live=false (disables SSE client).
1160
+ /**
1161
+ * Generate a standalone HTML snapshot at .cap/ui/snapshot.html (or custom outputPath).
1162
+ * Contains inline CSS + JS; no external fetch required.
1163
+ * @param {Object} params
1164
+ * @param {string} params.projectRoot
1165
+ * @param {string} [params.outputPath] - Relative path from projectRoot; default `.cap/ui/snapshot.html`.
1166
+ * @returns {{ snapshotPath: string, bytes: number }}
1167
+ */
1168
+ function createSnapshot({ projectRoot, outputPath }) {
1169
+ const rel = outputPath || SNAPSHOT_PATH;
1170
+ // @cap-todo(ac:F-068/hand-off) F-065 review deferred this containment check to F-068 — enforce now.
1171
+ const abs = designEditorLib.checkContainment(projectRoot, rel);
1172
+ const dir = path.dirname(abs);
1173
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1174
+
1175
+ const snapshot = collectProjectSnapshot(projectRoot);
1176
+ // @cap-decision(F-068) Snapshot stays read-only — even in --editable sessions, --share does not embed the edit UI.
1177
+ // A static shareable HTML has no server to PUT against, so the editor would be non-functional and misleading.
1178
+ const html = renderHtml({ snapshot, options: { live: false, editable: false } });
1179
+
1180
+ fs.writeFileSync(abs, html, 'utf8');
1181
+ logEvent('info', 'snapshot-written', { path: rel, bytes: html.length });
1182
+ return { snapshotPath: rel, bytes: html.length };
1183
+ }
1184
+
1185
+ // --- Exports ---------------------------------------------------------------
1186
+
1187
+ module.exports = {
1188
+ // Constants
1189
+ DEFAULT_PORT,
1190
+ MAX_PORT_ATTEMPTS,
1191
+ WATCH_DEBOUNCE_MS,
1192
+ WATCH_TARGETS,
1193
+ SNAPSHOT_PATH,
1194
+
1195
+ // Core
1196
+ startServer,
1197
+ renderHtml,
1198
+ createSnapshot,
1199
+ startFileWatcher,
1200
+
1201
+ // Helpers (exported for testing)
1202
+ sseResponse,
1203
+ collectProjectSnapshot,
1204
+ logEvent,
1205
+ escapeHtml,
1206
+
1207
+ // Composition entry points
1208
+ buildCoreCss,
1209
+ buildCoreJs,
1210
+ buildCss,
1211
+ buildClientJs,
1212
+
1213
+ // F-066 Mind-Map (re-exported from cap-ui-mind-map.cjs for back-compat).
1214
+ buildGraphData: mindMapLib.buildGraphData,
1215
+ runForceLayout: mindMapLib.runForceLayout,
1216
+ renderMindMapSvg: mindMapLib.renderMindMapSvg,
1217
+ buildMindMapSection: mindMapLib.buildMindMapSection,
1218
+ buildMindMapCss: mindMapLib.buildMindMapCss,
1219
+ buildMindMapJs: mindMapLib.buildMindMapJs,
1220
+
1221
+ // F-067 Thread + Cluster Navigator (re-exported from cap-ui-thread-nav.cjs for back-compat).
1222
+ buildThreadData: threadNavLib.buildThreadData,
1223
+ renderThreadList: threadNavLib.renderThreadList,
1224
+ renderThreadDetail: threadNavLib.renderThreadDetail,
1225
+ renderClusterView: threadNavLib.renderClusterView,
1226
+ renderKeywordOverlap: threadNavLib.renderKeywordOverlap,
1227
+ buildThreadNavSection: threadNavLib.buildThreadNavSection,
1228
+ buildThreadNavCss: threadNavLib.buildThreadNavCss,
1229
+ buildThreadNavJs: threadNavLib.buildThreadNavJs,
1230
+
1231
+ // F-068 Design Editor (re-exported for convenience + testing).
1232
+ buildEditorSection: designEditorLib.buildEditorSection,
1233
+ buildEditorCss: designEditorLib.buildEditorCss,
1234
+ buildEditorJs: designEditorLib.buildEditorJs,
1235
+ applyColorEdit: designEditorLib.applyColorEdit,
1236
+ applySpacingEdit: designEditorLib.applySpacingEdit,
1237
+ applyComponentEdit: designEditorLib.applyComponentEdit,
1238
+ checkContainment: designEditorLib.checkContainment,
1239
+ atomicWriteDesign: designEditorLib.atomicWriteDesign,
1240
+
1241
+ // Internal — exposed for tests.
1242
+ _matchRoute: matchRoute,
1243
+ _matchPattern: matchPattern,
1244
+ _buildRoutes: buildRoutes,
1245
+ };