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,764 @@
1
+ // @cap-feature(feature:F-080, primary:true) Bridge to Claude-native Memory —
2
+ // read-only consumer of ~/.claude/projects/<slug>/memory/ MEMORY.md + sibling files.
3
+ //
4
+ // @cap-context This module owns the read-only contract between Claude Code's auto-memory
5
+ // (~/.claude/projects/<slug>/memory/) and the CAP runtime surface (/cap:start, /cap:status).
6
+ // It NEVER writes into the Claude-native directory — the bridge is strictly a one-way
7
+ // pull. The local cache (.cap/memory/.claude-native-index.json) is the only thing this
8
+ // module writes, and it's a derived artifact under the project's own .cap/ tree.
9
+ //
10
+ // @cap-context AC-4 wires the surface into /cap:start + /cap:status as a runtime-only
11
+ // echo. The bridge does NOT persist its data into per-feature memory files — see
12
+ // @cap-decision(F-080/spec-gap) below for the runtime-only rationale.
13
+ //
14
+ // @cap-decision(F-080/AC-1) Read-only contract is sacred: NEVER write to
15
+ // ~/.claude/projects/<slug>/memory/. The cache lives under .cap/memory/ and is the only
16
+ // write target. Tests assert the source dir's mtime + content stay byte-identical across
17
+ // bridge invocations (cap-memory-bridge-adversarial.test.cjs).
18
+ //
19
+ // @cap-decision(F-080/AC-3) Missing or unreadable Claude-native dir → graceful skip
20
+ // (silent: no stdout, no stderr, no throw). The user may not have set up auto-memory
21
+ // yet, or may be running in an environment without ~/.claude/projects/. The bridge must
22
+ // degrade silently rather than fail the surrounding command.
23
+ //
24
+ // @cap-decision(F-080/AC-5) Surface priority order is fixed:
25
+ // 1. Entries whose title/file mentions the activeFeature ID (e.g. F-080)
26
+ // 2. Entries that match any related_features from the feature's per-feature memory file
27
+ // 3. Last 2 globally-recent entries (by file mtime desc) as fallback context
28
+ // Hard cap: 5 bullets total. If the priority sort yields more, truncate.
29
+ //
30
+ // @cap-decision(F-080/spec-gap) AC-4 wording "surface" is interpreted as RUNTIME-ONLY
31
+ // stdout (not persistence into per-feature files). Rationale: lower blast radius — the
32
+ // bridge stays purely additive, no schema changes to per-feature files, no auto-block
33
+ // pollution. If a future feature needs persistence, a new auto-block name (e.g.
34
+ // `claude_native_recall`) under F-079's `<!-- @auto-block <name> -->` convention can be
35
+ // added without touching this module.
36
+
37
+ 'use strict';
38
+
39
+ const fs = require('node:fs');
40
+ const path = require('node:path');
41
+ const os = require('node:os');
42
+
43
+ const schema = require('./cap-memory-schema.cjs');
44
+
45
+ // -------- Constants --------
46
+
47
+ // @cap-decision(F-080/D1) Cache file lives under the project's own .cap/memory/ tree
48
+ // (NOT inside .cap/memory/features/). Naming convention `.claude-native-index.json` —
49
+ // the leading dot signals "derived/transient" and aligns with `.cap/memory/.last-run`.
50
+ const CACHE_REL_PATH = path.join('.cap', 'memory', '.claude-native-index.json');
51
+
52
+ // @cap-decision(F-080/D2) Cache schema version starts at 1. Bumping is a hard-invalidate
53
+ // signal — if a future iteration changes the entry shape, increment this and the loader
54
+ // will refuse to honor the old cache (forces a re-parse).
55
+ const CACHE_SCHEMA_VERSION = 1;
56
+
57
+ // @cap-decision(F-080/D3) Hard cap on surface bullets. Spec AC-5 says "max 5 per run".
58
+ // Enforced as a single MAX_BULLETS constant so a future tweak is a one-line change.
59
+ const MAX_BULLETS = 5;
60
+
61
+ // @cap-decision(F-080/D4) Slug-character regex for the project-slug derivation. Claude
62
+ // Code's auto-memory directory uses the absolute path with `/` → `-`. We accept the
63
+ // resulting alphabet (alnum + `-` + `.` + `_`). Defense-in-depth: reject anything else
64
+ // in _validateSlug below.
65
+ const SLUG_CHAR_RE = /^[A-Za-z0-9._-]+$/;
66
+
67
+ // @cap-decision(F-080/D5) Reserved slug tokens that would survive the regex but still
68
+ // pose a path-traversal risk (e.g. `..` matches the regex above). Hard-reject these.
69
+ const RESERVED_SLUG_TOKENS = new Set(['..', '.', '__proto__', 'constructor', 'prototype']);
70
+
71
+ // -------- Defensive helpers --------
72
+
73
+ // @cap-decision(F-080/D6) ANSI/control-byte sanitization for any user-supplied string
74
+ // that could land in stdout (entry titles, hooks). Mirrors cap-memory-platform.cjs and
75
+ // cap-snapshot-linkage.cjs `_safeForError` — kept local so a refactor in one module
76
+ // can't silently weaken the defense in another.
77
+ function _safeForOutput(value) {
78
+ if (typeof value !== 'string') return String(value);
79
+ // Replace any byte outside printable ASCII (excluding DEL) with `?`. Strip to 200 chars
80
+ // to keep surface lines bounded — entry titles longer than that get visually truncated.
81
+ return value.replace(/[^\x20-\x7E]/g, '?').slice(0, 200);
82
+ }
83
+
84
+ // @cap-risk(reason:path-traversal-via-cwd) The project-slug is derived from the absolute
85
+ // cwd path. If cwd contains `..` or symlinks, we still produce a safe slug because we
86
+ // transform `/` → `-` (no `..`-segment can sneak through), but we double-check with
87
+ // _validateSlug below. Defense-in-depth pattern from F-078/D4 + F-079 _validateSnapshotName.
88
+ function _validateSlug(slug) {
89
+ if (typeof slug !== 'string' || slug.length === 0) {
90
+ throw new TypeError(`project-slug must be a non-empty string (got ${typeof slug})`);
91
+ }
92
+ if (slug.includes('/') || slug.includes('\\') || slug.includes('\0')) {
93
+ throw new TypeError(`project-slug must not contain path separators or NUL (got "${_safeForOutput(slug)}")`);
94
+ }
95
+ if (RESERVED_SLUG_TOKENS.has(slug)) {
96
+ throw new TypeError(`project-slug is a reserved token (got "${_safeForOutput(slug)}")`);
97
+ }
98
+ if (!SLUG_CHAR_RE.test(slug)) {
99
+ throw new TypeError(`project-slug must match ${SLUG_CHAR_RE} (got "${_safeForOutput(slug)}")`);
100
+ }
101
+ }
102
+
103
+ // -------- Slug derivation --------
104
+
105
+ // @cap-todo(ac:F-080/AC-1) getProjectSlug derives the Claude-native auto-memory slug from
106
+ // an absolute project path. Claude Code's convention: replace `/` with `-`, keep dots
107
+ // and other path-safe chars verbatim.
108
+ /**
109
+ * Derive the Claude-native auto-memory project slug from an absolute project path.
110
+ * Convention (Claude Code): the absolute project path with `/` substituted by `-`, e.g.
111
+ * `/Users/foo/bar` → `-Users-foo-bar`.
112
+ *
113
+ * @param {string} projectRoot - absolute path to the project root
114
+ * @returns {string} slug (validated)
115
+ */
116
+ function getProjectSlug(projectRoot) {
117
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
118
+ throw new TypeError('projectRoot must be a non-empty string');
119
+ }
120
+ // Normalize away any trailing slash and resolve `..` segments before slug-ifying so
121
+ // cwd `/Users/foo/bar/` and `/Users/foo/bar/baz/..` both produce the same slug.
122
+ const normalized = path.resolve(projectRoot);
123
+ // Reject path-traversal sigils in the resolved path defensively (path.resolve already
124
+ // eliminates `..` but the input may contain a NUL byte that survives).
125
+ if (normalized.includes('\0')) {
126
+ throw new TypeError(`projectRoot contains NUL byte (got "${_safeForOutput(projectRoot)}")`);
127
+ }
128
+ // Replace BOTH POSIX `/` and Windows `\` with `-` for cross-platform safety. The
129
+ // Claude-native convention is observed on POSIX as `/` → `-`; on Windows the parallel
130
+ // is `\` → `-`. Both substitutions are idempotent.
131
+ const slug = normalized.replace(/[/\\]/g, '-');
132
+ _validateSlug(slug);
133
+ return slug;
134
+ }
135
+
136
+ // @cap-todo(ac:F-080/AC-1) getClaudeNativeDir builds the absolute path to the Claude-native
137
+ // auto-memory directory: ~/.claude/projects/<slug>/memory/.
138
+ // @cap-risk(reason:claude-native-layout-dependency) Bridge depends on Claude Code's
139
+ // ~/.claude/projects/<slug>/memory/ layout convention. If Claude Code changes this (e.g. to
140
+ // XDG_CONFIG_HOME or a per-user data directory), the bridge will silent-skip but produce no
141
+ // surface data. The silent-skip contract masks the failure, so a layout change would degrade
142
+ // without a visible error. Track via test fixture against a known-stable layout assumption;
143
+ // when Claude Code's filesystem conventions change, surface the new path here and bump the
144
+ // cache schema version to invalidate stale caches.
145
+ /**
146
+ * @param {string} projectRoot
147
+ * @returns {string} absolute path to ~/.claude/projects/<slug>/memory/
148
+ */
149
+ function getClaudeNativeDir(projectRoot) {
150
+ const slug = getProjectSlug(projectRoot);
151
+ return path.join(os.homedir(), '.claude', 'projects', slug, 'memory');
152
+ }
153
+
154
+ // @cap-todo(ac:F-080/AC-1) getClaudeNativeMemoryMdPath builds the path to the index file
155
+ // (MEMORY.md) under the Claude-native dir.
156
+ /**
157
+ * @param {string} projectRoot
158
+ * @returns {string}
159
+ */
160
+ function getClaudeNativeMemoryMdPath(projectRoot) {
161
+ return path.join(getClaudeNativeDir(projectRoot), 'MEMORY.md');
162
+ }
163
+
164
+ // @cap-todo(ac:F-080/AC-2) getCachePath builds the path to the local cache file under
165
+ // .cap/memory/.claude-native-index.json.
166
+ /**
167
+ * @param {string} projectRoot
168
+ * @returns {string}
169
+ */
170
+ function getCachePath(projectRoot) {
171
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
172
+ throw new TypeError('projectRoot must be a non-empty string');
173
+ }
174
+ return path.join(projectRoot, CACHE_REL_PATH);
175
+ }
176
+
177
+ // -------- MEMORY.md parser --------
178
+
179
+ /**
180
+ * @typedef {Object} ClaudeNativeEntry
181
+ * @property {string} title - human title from the bullet
182
+ * @property {string} file - sibling filename (relative to memory dir)
183
+ * @property {string} hook - one-line hook text after the em-dash
184
+ * @property {string|null} type - 'user'|'feedback'|'project'|'reference'|null (from sibling frontmatter)
185
+ * @property {string|null} fileMtime - ISO mtime of the sibling file (null if missing)
186
+ * @property {string|null} description - sibling's frontmatter description if present
187
+ */
188
+
189
+ // @cap-decision(F-080/D7) MEMORY.md grammar: each line is `- [Title](file.md) — hook text`.
190
+ // Tolerate both em-dash (—), en-dash (–) and regular hyphen (-) as separator (mirrors the
191
+ // F-082 em-dash lesson). A line that doesn't match the bullet shape is silently skipped —
192
+ // MEMORY.md may have prose interspersed (header notes, comments).
193
+ // @cap-decision(F-080/followup) F-080-FIX-B: MEMORY_MD_LINE_RE requires surrounding spaces
194
+ // for hyphen. Previously the separator class `[—–-]` matched a hyphen with no required
195
+ // surrounding whitespace, which could ambiguously split titles that contain a hyphen
196
+ // (e.g. `[Foo-Bar](file.md) Description`). Em-dash (—) and en-dash (–) are multi-byte and
197
+ // unambiguous, but the hyphen branch now requires `\s+-\s+` for consistency. The em/en-dash
198
+ // branch retains its existing `\s*[—–]\s*` tolerance to avoid breaking existing fixtures.
199
+ const MEMORY_MD_LINE_RE = /^-\s*\[([^\]]+)\]\(([^)]+)\)(?:\s*[—–]\s*|\s+-\s+)(.+?)\s*$/;
200
+
201
+ // @cap-todo(ac:F-080/AC-1) parseMemoryMd parses the index file into structured entries.
202
+ // Frontmatter on sibling files is read via parseSiblingFrontmatter.
203
+ //
204
+ // @cap-decision(F-080/iter0/D8) Sibling-file reads are best-effort: a missing or
205
+ // unreadable sibling is dropped from the parse with no error (the index entry survives
206
+ // but `type` and `description` will be null). This keeps a partially-broken auto-memory
207
+ // dir surface-able rather than blocking the bridge entirely.
208
+ /**
209
+ * @param {string} memoryDir - absolute path to ~/.claude/projects/<slug>/memory/
210
+ * @returns {ClaudeNativeEntry[]}
211
+ */
212
+ function parseMemoryMd(memoryDir) {
213
+ if (typeof memoryDir !== 'string' || memoryDir.length === 0) {
214
+ throw new TypeError('memoryDir must be a non-empty string');
215
+ }
216
+ const memoryMdPath = path.join(memoryDir, 'MEMORY.md');
217
+ if (!fs.existsSync(memoryMdPath)) return [];
218
+ let raw;
219
+ try {
220
+ raw = fs.readFileSync(memoryMdPath, 'utf8');
221
+ } catch (_e) {
222
+ // Unreadable index → treat as empty (graceful).
223
+ return [];
224
+ }
225
+ /** @type {ClaudeNativeEntry[]} */
226
+ const entries = [];
227
+ const seenFiles = new Set();
228
+ for (const rawLine of raw.split(/\r?\n/)) {
229
+ const line = rawLine.replace(/^\s+|\s+$/g, '');
230
+ if (line.length === 0) continue;
231
+ const m = line.match(MEMORY_MD_LINE_RE);
232
+ if (!m) continue;
233
+ const title = m[1].trim();
234
+ const fileRel = m[2].trim();
235
+ const hook = m[3].trim();
236
+ // Defensive: reject sibling references that try to escape the memory dir. The expected
237
+ // shape is a bare filename (no slash, no leading dot beyond `.md`).
238
+ if (fileRel.includes('/') || fileRel.includes('\\') || fileRel.includes('\0') || fileRel.includes('..')) continue;
239
+ if (seenFiles.has(fileRel)) continue; // dedup by file
240
+ seenFiles.add(fileRel);
241
+ const sibling = parseSiblingFrontmatter(memoryDir, fileRel);
242
+ // @cap-decision(F-080/followup) F-080-FIX-A: _safeForOutput at parse-time, not surface-time.
243
+ // Previously only entry titles were sanitized (at the surface step). Hook + description
244
+ // strings flowed through unsanitized into entries[]. Today only formatSurface consumes
245
+ // entries (and it sanitizes titles), so this isn't an active vulnerability — but a future
246
+ // caller pulling raw entries (e.g. a verbose mode, debug command, LLM context block) would
247
+ // render ANSI bytes verbatim. Sanitize at the storage assembly step so EVERY consumer of
248
+ // entries[] gets pre-sanitized data.
249
+ entries.push({
250
+ title: _safeForOutput(title),
251
+ file: fileRel,
252
+ hook: _safeForOutput(hook),
253
+ type: sibling.type,
254
+ fileMtime: sibling.mtime,
255
+ description: sibling.description == null ? null : _safeForOutput(sibling.description),
256
+ });
257
+ }
258
+ return entries;
259
+ }
260
+
261
+ /**
262
+ * @param {string} memoryDir
263
+ * @param {string} fileRel
264
+ * @returns {{type:string|null, description:string|null, mtime:string|null}}
265
+ */
266
+ function parseSiblingFrontmatter(memoryDir, fileRel) {
267
+ const fp = path.join(memoryDir, fileRel);
268
+ /** @type {{type:string|null, description:string|null, mtime:string|null}} */
269
+ const empty = { type: null, description: null, mtime: null };
270
+ if (!fs.existsSync(fp)) return empty;
271
+ let stat;
272
+ let content;
273
+ try {
274
+ stat = fs.statSync(fp);
275
+ content = fs.readFileSync(fp, 'utf8');
276
+ } catch (_e) {
277
+ return empty;
278
+ }
279
+ const mtime = stat.mtime.toISOString();
280
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
281
+ if (!fmMatch) return { type: null, description: null, mtime };
282
+ const fmBody = fmMatch[1];
283
+ let type = null;
284
+ let description = null;
285
+ // @cap-risk(reason:proto-pollution-via-frontmatter) Sibling frontmatter is YAML-like.
286
+ // Skip reserved tokens explicitly (defense-in-depth — we never use the parsed values
287
+ // as object keys, only assign to fixed fields, but the tradition is established).
288
+ const RESERVED = new Set(['__proto__', 'constructor', 'prototype']);
289
+ for (const line of fmBody.split(/\r?\n/)) {
290
+ const m = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/);
291
+ if (!m) continue;
292
+ const key = m[1];
293
+ if (RESERVED.has(key)) continue;
294
+ const val = (m[2] || '').replace(/^["']|["']$/g, '').trim();
295
+ if (key === 'type') type = val || null;
296
+ else if (key === 'description') description = val || null;
297
+ }
298
+ return { type, description, mtime };
299
+ }
300
+
301
+ // -------- Cache I/O --------
302
+
303
+ // @cap-todo(ac:F-080/AC-2) loadCachedIndex reads the local cache file and returns the
304
+ // parsed structure (or null on any failure — cache is best-effort).
305
+ /**
306
+ * @param {string} projectRoot
307
+ * @returns {{schemaVersion:number, sourceRoot:string, memoryMdMtime:string|null, entries:ClaudeNativeEntry[]}|null}
308
+ */
309
+ function loadCachedIndex(projectRoot) {
310
+ const cachePath = getCachePath(projectRoot);
311
+ if (!fs.existsSync(cachePath)) return null;
312
+ let raw;
313
+ try {
314
+ raw = fs.readFileSync(cachePath, 'utf8');
315
+ } catch (_e) {
316
+ return null;
317
+ }
318
+ let parsed;
319
+ try {
320
+ parsed = JSON.parse(raw);
321
+ } catch (_e) {
322
+ // @cap-decision(F-080/iter0/D9) Corrupt cache JSON → treat as missing cache (caller
323
+ // re-parses from source). The cache file is regenerated on the next refresh, so
324
+ // there's no persistent failure mode here. Loud-throw was rejected because corrupt
325
+ // cache shouldn't block surface output — re-parse is the cheap, safe path.
326
+ return null;
327
+ }
328
+ if (!parsed || typeof parsed !== 'object') return null;
329
+ if (parsed.schemaVersion !== CACHE_SCHEMA_VERSION) return null;
330
+ if (!Array.isArray(parsed.entries)) return null;
331
+ // Sanitize entries — accept only entries with the expected shape.
332
+ /** @type {ClaudeNativeEntry[]} */
333
+ const safeEntries = [];
334
+ const RESERVED = new Set(['__proto__', 'constructor', 'prototype']);
335
+ for (const e of parsed.entries) {
336
+ if (!e || typeof e !== 'object') continue;
337
+ if (typeof e.title !== 'string' || typeof e.file !== 'string') continue;
338
+ if (RESERVED.has(e.file)) continue;
339
+ if (e.file.includes('/') || e.file.includes('\\') || e.file.includes('..')) continue;
340
+ // @cap-decision(F-080/followup) F-080-FIX-A: _safeForOutput at parse-time, not surface-time.
341
+ // Cache is a derived artifact — but a malicious or corrupted cache could carry ANSI bytes
342
+ // if a previous version (or a third party) wrote them. Sanitize on load symmetrically with
343
+ // parseMemoryMd so entries[] are uniformly safe regardless of source path.
344
+ safeEntries.push({
345
+ title: _safeForOutput(e.title),
346
+ file: e.file,
347
+ hook: typeof e.hook === 'string' ? _safeForOutput(e.hook) : '',
348
+ type: typeof e.type === 'string' ? e.type : null,
349
+ fileMtime: typeof e.fileMtime === 'string' ? e.fileMtime : null,
350
+ description: typeof e.description === 'string' ? _safeForOutput(e.description) : null,
351
+ });
352
+ }
353
+ return {
354
+ schemaVersion: parsed.schemaVersion,
355
+ sourceRoot: typeof parsed.sourceRoot === 'string' ? parsed.sourceRoot : '',
356
+ memoryMdMtime: typeof parsed.memoryMdMtime === 'string' ? parsed.memoryMdMtime : null,
357
+ entries: safeEntries,
358
+ };
359
+ }
360
+
361
+ // @cap-todo(ac:F-080/AC-2) isCacheValid compares cached mtimes against current source
362
+ // mtimes. Returns true ONLY if the cache exists, the index file mtime matches, and
363
+ // no sibling file is newer than the cache.
364
+ //
365
+ // @cap-risk(reason:cache-toctou-acceptable) Between isCacheValid() and the actual
366
+ // refresh/load, a sibling file could change. Worst case: caller surfaces one-tick-stale
367
+ // data. Acceptable for a read-only display surface.
368
+ /**
369
+ * @param {string} projectRoot
370
+ * @returns {boolean}
371
+ */
372
+ function isCacheValid(projectRoot) {
373
+ const cached = loadCachedIndex(projectRoot);
374
+ if (!cached) return false;
375
+ const memoryMdPath = getClaudeNativeMemoryMdPath(projectRoot);
376
+ if (!fs.existsSync(memoryMdPath)) return false;
377
+ let stat;
378
+ try {
379
+ stat = fs.statSync(memoryMdPath);
380
+ } catch (_e) {
381
+ return false;
382
+ }
383
+ const currentMtime = stat.mtime.toISOString();
384
+ if (cached.memoryMdMtime !== currentMtime) return false;
385
+ // Check sibling files: if ANY referenced sibling has a newer mtime than recorded in the
386
+ // cache, invalidate. This catches the case where MEMORY.md is unchanged but a sibling's
387
+ // frontmatter (e.g. type, description) was edited.
388
+ const memoryDir = getClaudeNativeDir(projectRoot);
389
+ for (const entry of cached.entries) {
390
+ const fp = path.join(memoryDir, entry.file);
391
+ if (!fs.existsSync(fp)) {
392
+ // Sibling went missing → invalidate so the re-parse drops it.
393
+ return false;
394
+ }
395
+ let sStat;
396
+ try {
397
+ sStat = fs.statSync(fp);
398
+ } catch (_e) {
399
+ return false;
400
+ }
401
+ const currMtime = sStat.mtime.toISOString();
402
+ if (entry.fileMtime !== null && currMtime !== entry.fileMtime) return false;
403
+ }
404
+ return true;
405
+ }
406
+
407
+ // @cap-todo(ac:F-080/AC-2) refreshCache re-parses MEMORY.md + sibling files and writes
408
+ // a fresh `.claude-native-index.json`. Atomic write (tmp + rename) so a crash mid-write
409
+ // doesn't corrupt the cache.
410
+ //
411
+ // @cap-decision(F-080/D10) Atomic write goes through a local helper rather than importing
412
+ // `_atomicWriteFile` from cap-memory-migrate.cjs. Reasons:
413
+ // 1. Lower coupling — F-080 is a leaf module, depending on cap-memory-migrate would
414
+ // pull in a much larger surface (the migrator owns the V6 transformation pipeline).
415
+ // 2. Symmetric with the parent dir creation we need anyway (cache lives in
416
+ // `.cap/memory/` which may not exist on first use).
417
+ // 3. The pattern is small (3 lines: write tmp, rename, optionally chmod). Duplication
418
+ // is cheaper than the dep edge.
419
+ /**
420
+ * @param {string} projectRoot
421
+ * @returns {{written:boolean, entries:ClaudeNativeEntry[], reason:string}}
422
+ */
423
+ function refreshCache(projectRoot) {
424
+ const memoryDir = getClaudeNativeDir(projectRoot);
425
+ const memoryMdPath = path.join(memoryDir, 'MEMORY.md');
426
+ if (!fs.existsSync(memoryMdPath)) {
427
+ return { written: false, entries: [], reason: 'source-missing' };
428
+ }
429
+ let memoryMdStat;
430
+ try {
431
+ memoryMdStat = fs.statSync(memoryMdPath);
432
+ } catch (_e) {
433
+ return { written: false, entries: [], reason: 'source-stat-failed' };
434
+ }
435
+ const entries = parseMemoryMd(memoryDir);
436
+ const cachePayload = {
437
+ schemaVersion: CACHE_SCHEMA_VERSION,
438
+ sourceRoot: memoryDir,
439
+ memoryMdMtime: memoryMdStat.mtime.toISOString(),
440
+ entries,
441
+ };
442
+ const cachePath = getCachePath(projectRoot);
443
+ // Ensure parent dir exists.
444
+ try {
445
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
446
+ } catch (_e) {
447
+ return { written: false, entries, reason: 'parent-dir-create-failed' };
448
+ }
449
+ // Atomic write: tmp + rename (matches F-074/F-078 pattern).
450
+ const tmpPath = `${cachePath}.tmp.${process.pid}.${Date.now()}`;
451
+ try {
452
+ fs.writeFileSync(tmpPath, JSON.stringify(cachePayload, null, 2) + '\n', 'utf8');
453
+ _renameWithRetry(tmpPath, cachePath);
454
+ } catch (e) {
455
+ // Cleanup tmp file if it exists.
456
+ try { fs.unlinkSync(tmpPath); } catch (_e2) { /* ignore */ }
457
+ // @cap-decision(F-080/followup) F-080-FIX-C: rename retry on EBUSY/EPERM with backoff.
458
+ // When _renameWithRetry exhausts its retries on Windows EBUSY/EPERM, surface a single
459
+ // warning to stderr (NOT silent-skip — this is a write failure, deserves visibility).
460
+ // Other rename errors fall through to the same warning path. The hook caller still
461
+ // continues normally; the "best-effort, never block" contract is upheld via the
462
+ // non-throwing return value.
463
+ if (e && (e.code === 'EBUSY' || e.code === 'EPERM')) {
464
+ try {
465
+ process.stderr.write(`cap-memory-bridge: cache write failed after retries (${e.code}); continuing with stale cache\n`);
466
+ } catch (_e3) { /* ignore */ }
467
+ }
468
+ return { written: false, entries, reason: 'cache-write-failed' };
469
+ }
470
+ return { written: true, entries, reason: 'wrote' };
471
+ }
472
+
473
+ // @cap-decision(F-080/followup) F-080-FIX-C: rename retry on EBUSY/EPERM with backoff.
474
+ // On Windows, fs.renameSync can fail with EBUSY (target file open by another process) or
475
+ // EPERM (UAC + concurrent reader). Retry up to 3 times with backoff (50ms, 100ms, 200ms).
476
+ // If the final attempt still fails, throw — the caller logs a warning and discards the tmp.
477
+ // Other error codes (ENOENT, EACCES on the directory, etc.) throw immediately on first try
478
+ // because retry won't help and the caller has its own cleanup path.
479
+ // @cap-risk(reason:sync-backoff-blocks-event-loop) The backoff uses Atomics.wait on a fresh
480
+ // Int32Array as a sync sleep primitive. This is acceptable here because (a) refreshCache is
481
+ // already a synchronous filesystem operation, (b) the maximum total wait is 350ms, and
482
+ // (c) the call site is hook-driven, not user-interactive.
483
+ function _renameWithRetry(srcPath, destPath) {
484
+ const RETRYABLE = new Set(['EBUSY', 'EPERM']);
485
+ const backoffsMs = [50, 100, 200];
486
+ let lastErr = null;
487
+ for (let attempt = 0; attempt <= backoffsMs.length; attempt++) {
488
+ try {
489
+ fs.renameSync(srcPath, destPath);
490
+ return;
491
+ } catch (e) {
492
+ lastErr = e;
493
+ if (!e || !RETRYABLE.has(e.code)) throw e;
494
+ if (attempt === backoffsMs.length) break;
495
+ // Sync sleep: Atomics.wait on a fresh shared int32 — guaranteed to time out.
496
+ try {
497
+ const buf = new Int32Array(new SharedArrayBuffer(4));
498
+ Atomics.wait(buf, 0, 0, backoffsMs[attempt]);
499
+ } catch (_e) {
500
+ // SharedArrayBuffer / Atomics may be unavailable in some sandboxed environments —
501
+ // fall back to a busy-loop. Bounded by the same backoff window.
502
+ const deadline = Date.now() + backoffsMs[attempt];
503
+ while (Date.now() < deadline) { /* spin */ }
504
+ }
505
+ }
506
+ }
507
+ throw lastErr;
508
+ }
509
+
510
+ // -------- Bridge data assembly --------
511
+
512
+ /**
513
+ * @typedef {Object} BridgeData
514
+ * @property {boolean} available - false = silent skip; true = entries usable
515
+ * @property {ClaudeNativeEntry[]} entries
516
+ * @property {string} reason - 'ok' | 'no-claude-native-dir' | 'no-memory-md' | 'parse-empty' | 'unreadable'
517
+ */
518
+
519
+ // @cap-todo(ac:F-080/AC-3) getBridgeData is the silent-skip-aware entry point. Returns
520
+ // {available:false} for any failure path; never throws, never logs to stdout/stderr.
521
+ //
522
+ // @cap-decision(F-080/AC-3) "Silent skip" is REAL silent: zero output to stdout/stderr.
523
+ // The `reason` field carries the diagnostic for tests / debug logging. A future debug
524
+ // hook can opt-in to log the reason via env-var (e.g. `CAP_DEBUG=1`) but the default
525
+ // path emits NOTHING.
526
+ /**
527
+ * Single entry point for the bridge. Resolves cache vs source, returns assembled data.
528
+ * Silent on any failure.
529
+ *
530
+ * @param {string} projectRoot
531
+ * @returns {BridgeData}
532
+ */
533
+ function getBridgeData(projectRoot) {
534
+ /** @type {BridgeData} */
535
+ const skip = (reason) => ({ available: false, entries: [], reason });
536
+ // Wrap EVERYTHING in try/catch — silent-skip means even an unexpected throw must not
537
+ // surface. Belt-and-braces: the called helpers are already defensive, but a typo in
538
+ // future maintenance shouldn't break the surrounding command.
539
+ try {
540
+ let claudeNativeDir;
541
+ try {
542
+ claudeNativeDir = getClaudeNativeDir(projectRoot);
543
+ } catch (_e) {
544
+ return skip('slug-derivation-failed');
545
+ }
546
+ if (!fs.existsSync(claudeNativeDir)) {
547
+ return skip('no-claude-native-dir');
548
+ }
549
+ const memoryMdPath = path.join(claudeNativeDir, 'MEMORY.md');
550
+ if (!fs.existsSync(memoryMdPath)) {
551
+ return skip('no-memory-md');
552
+ }
553
+ let entries;
554
+ if (isCacheValid(projectRoot)) {
555
+ const cached = loadCachedIndex(projectRoot);
556
+ entries = cached ? cached.entries : [];
557
+ } else {
558
+ const refreshed = refreshCache(projectRoot);
559
+ entries = refreshed.entries;
560
+ }
561
+ if (!Array.isArray(entries)) entries = [];
562
+ return { available: true, entries, reason: entries.length === 0 ? 'parse-empty' : 'ok' };
563
+ } catch (_e) {
564
+ // Last-ditch swallow. Silent-skip contract overrides anything else.
565
+ return skip('unexpected-error');
566
+ }
567
+ }
568
+
569
+ // -------- Surface (priority + max-5 truncation) --------
570
+
571
+ /**
572
+ * @typedef {Object} SurfaceResult
573
+ * @property {string[]} bullets - already formatted "- <title>" strings, max 5
574
+ * @property {boolean} truncated - true if input had > 5 candidates
575
+ * @property {ClaudeNativeEntry[]} chosen - the entries that backed the bullets (debug)
576
+ */
577
+
578
+ // @cap-todo(ac:F-080/AC-4) surfaceForFeature returns the bullet list to print under the
579
+ // "Claude-native erinnert:" header. Pure function: takes projectRoot + activeFeature,
580
+ // returns formatted bullets.
581
+ //
582
+ // @cap-todo(ac:F-080/AC-5) Priority: activeFeature direct match → related_features from
583
+ // per-feature memory file → last 2 globally-recent (by fileMtime desc). Hard-cap 5.
584
+ //
585
+ // @cap-decision(F-080/AC-5/tiebreak) Within a single priority bucket, sort by fileMtime
586
+ // desc, then title asc. Deterministic ordering pinned by tests so future changes can't
587
+ // silently shuffle the surface output.
588
+ /**
589
+ * @param {string} projectRoot
590
+ * @param {string|null} activeFeatureId - F-NNN id (or null = no active feature)
591
+ * @param {{relatedFeatures?:string[]}=} options - test seam: lets tests inject related_features
592
+ * without writing a per-feature memory file. In production, related_features is read from
593
+ * the per-feature file via _readRelatedFeatures.
594
+ * @returns {SurfaceResult}
595
+ */
596
+ function surfaceForFeature(projectRoot, activeFeatureId, options) {
597
+ const opts = options || {};
598
+ const data = getBridgeData(projectRoot);
599
+ if (!data.available || data.entries.length === 0) {
600
+ return { bullets: [], truncated: false, chosen: [] };
601
+ }
602
+ // Resolve related-features: prefer test-injected, fall back to per-feature file lookup.
603
+ let relatedFeatures = Array.isArray(opts.relatedFeatures)
604
+ ? opts.relatedFeatures.filter((f) => typeof f === 'string')
605
+ : null;
606
+ if (relatedFeatures === null && activeFeatureId && schema.FEATURE_ID_RE.test(activeFeatureId)) {
607
+ relatedFeatures = _readRelatedFeatures(projectRoot, activeFeatureId);
608
+ }
609
+ if (!Array.isArray(relatedFeatures)) relatedFeatures = [];
610
+
611
+ // Tier 1: entries mentioning the active feature.
612
+ /** @type {ClaudeNativeEntry[]} */
613
+ const tier1 = [];
614
+ /** @type {ClaudeNativeEntry[]} */
615
+ const tier2 = [];
616
+ /** @type {ClaudeNativeEntry[]} */
617
+ const tier3 = [];
618
+ const seen = new Set();
619
+ const matchesFeature = (entry, fid) => {
620
+ const haystack = `${entry.title}\n${entry.file}\n${entry.hook}\n${entry.description || ''}`.toLowerCase();
621
+ return haystack.includes(fid.toLowerCase());
622
+ };
623
+ // Stable-sort comparator (mtime desc, title asc) used in EVERY tier.
624
+ const tierSort = (a, b) => {
625
+ const ma = a.fileMtime || '';
626
+ const mb = b.fileMtime || '';
627
+ if (ma !== mb) return ma < mb ? 1 : -1; // desc
628
+ if (a.title === b.title) return 0;
629
+ return a.title < b.title ? -1 : 1;
630
+ };
631
+ if (activeFeatureId) {
632
+ for (const e of data.entries) {
633
+ if (seen.has(e.file)) continue;
634
+ if (matchesFeature(e, activeFeatureId)) {
635
+ tier1.push(e);
636
+ seen.add(e.file);
637
+ }
638
+ }
639
+ }
640
+ for (const fid of relatedFeatures) {
641
+ if (!schema.FEATURE_ID_RE.test(fid)) continue;
642
+ for (const e of data.entries) {
643
+ if (seen.has(e.file)) continue;
644
+ if (matchesFeature(e, fid)) {
645
+ tier2.push(e);
646
+ seen.add(e.file);
647
+ }
648
+ }
649
+ }
650
+ // Tier 3: most-recent globals (by mtime desc, title asc tiebreak), excluding already-seen.
651
+ // @cap-decision(F-080/AC-5/D11) Tier 3 cap at 2 entries (per spec "letzte 2 globale Einträge").
652
+ // This is enforced INSIDE tier3 (not just by the outer MAX_BULLETS) so a future bump of
653
+ // MAX_BULLETS doesn't accidentally widen the global-recents window.
654
+ const TIER3_CAP = 2;
655
+ const remaining = data.entries.filter((e) => !seen.has(e.file));
656
+ remaining.sort(tierSort);
657
+ for (const e of remaining) {
658
+ if (tier3.length >= TIER3_CAP) break;
659
+ tier3.push(e);
660
+ seen.add(e.file);
661
+ }
662
+
663
+ // Sort each tier deterministically. tier1 + tier2: mtime desc / title asc. tier3 already sorted.
664
+ tier1.sort(tierSort);
665
+ tier2.sort(tierSort);
666
+
667
+ // Concatenate priorities and hard-cap.
668
+ const merged = [...tier1, ...tier2, ...tier3];
669
+ const truncated = merged.length > MAX_BULLETS;
670
+ const chosen = merged.slice(0, MAX_BULLETS);
671
+ const bullets = chosen.map((e) => `- ${_safeForOutput(e.title)}`);
672
+ return { bullets, truncated, chosen };
673
+ }
674
+
675
+ // @cap-todo(ac:F-080/AC-4) formatSurface emits the full surface block. Empty bullets →
676
+ // empty string (caller writes nothing). This is the single source of truth for the
677
+ // surface format so /cap:start and /cap:status produce identical output.
678
+ /**
679
+ * @param {SurfaceResult} surface
680
+ * @returns {string} multi-line string ready to print, or '' when no bullets
681
+ */
682
+ function formatSurface(surface) {
683
+ if (!surface || !Array.isArray(surface.bullets) || surface.bullets.length === 0) {
684
+ return '';
685
+ }
686
+ const lines = ['Claude-native erinnert:'];
687
+ for (const b of surface.bullets) {
688
+ lines.push(` ${b}`);
689
+ }
690
+ if (surface.truncated) {
691
+ lines.push(` (truncated to ${MAX_BULLETS} of ${surface.bullets.length}+ candidates)`);
692
+ }
693
+ return lines.join('\n');
694
+ }
695
+
696
+ // -------- Per-feature file → related_features lookup --------
697
+
698
+ /**
699
+ * Read related_features from the per-feature memory file's frontmatter. Best-effort:
700
+ * returns [] on any failure (no file, no frontmatter, no related_features field).
701
+ *
702
+ * @param {string} projectRoot
703
+ * @param {string} featureId
704
+ * @returns {string[]}
705
+ */
706
+ function _readRelatedFeatures(projectRoot, featureId) {
707
+ if (!schema.FEATURE_ID_RE.test(featureId)) return [];
708
+ const featuresDir = path.join(projectRoot, schema.MEMORY_FEATURES_DIR);
709
+ if (!fs.existsSync(featuresDir)) return [];
710
+ let names;
711
+ try {
712
+ names = fs.readdirSync(featuresDir);
713
+ } catch (_e) {
714
+ return [];
715
+ }
716
+ const prefix = `${featureId}-`;
717
+ let target = null;
718
+ for (const name of names) {
719
+ if (typeof name !== 'string') continue;
720
+ if (!name.endsWith('.md')) continue;
721
+ if (name.startsWith(prefix)) { target = name; break; }
722
+ }
723
+ if (!target) return [];
724
+ let raw;
725
+ try {
726
+ raw = fs.readFileSync(path.join(featuresDir, target), 'utf8');
727
+ } catch (_e) {
728
+ return [];
729
+ }
730
+ try {
731
+ const file = schema.parseFeatureMemoryFile(raw);
732
+ if (file && file.frontmatter && Array.isArray(file.frontmatter.related_features)) {
733
+ return file.frontmatter.related_features.filter((f) => typeof f === 'string' && schema.FEATURE_ID_RE.test(f));
734
+ }
735
+ } catch (_e) {
736
+ // Malformed frontmatter — fall through to empty.
737
+ }
738
+ return [];
739
+ }
740
+
741
+ // -------- Exports --------
742
+
743
+ module.exports = {
744
+ // Public API
745
+ getProjectSlug,
746
+ getClaudeNativeDir,
747
+ getClaudeNativeMemoryMdPath,
748
+ getCachePath,
749
+ parseMemoryMd,
750
+ loadCachedIndex,
751
+ isCacheValid,
752
+ refreshCache,
753
+ getBridgeData,
754
+ surfaceForFeature,
755
+ formatSurface,
756
+ // Constants
757
+ CACHE_REL_PATH,
758
+ CACHE_SCHEMA_VERSION,
759
+ MAX_BULLETS,
760
+ // Test seams
761
+ _readRelatedFeatures,
762
+ _safeForOutput,
763
+ _renameWithRetry,
764
+ };