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,963 @@
1
+ // @cap-feature(feature:F-079, primary:true) Snapshot-Linkage to Features and Platform —
2
+ // wires .cap/snapshots/* into the F-076 memory layer.
3
+ //
4
+ // @cap-context This module owns the contract between snapshot creation (cap:save) and the
5
+ // per-feature / platform memory files. AC-1..AC-3 cover the WRITE-time linkage (frontmatter
6
+ // + soft-warn), AC-4 covers the pipeline-time idempotent re-linking, AC-5 covers the F-077
7
+ // migration heuristic for legacy orphans, and AC-6 covers the unassigned fallback bucket.
8
+ //
9
+ // @cap-context Auto-block contract: snapshot references live in their OWN auto-managed
10
+ // marker pair (`<!-- @auto-block linked_snapshots -->` ... `<!-- /@auto-block -->`),
11
+ // distinct from F-076's `<!-- cap:auto:start -->` block. F-076's parser/serializer is
12
+ // authoritative for decisions+pitfalls; touching it would change a shipped contract for
13
+ // every consumer. Snapshots get their own block so the two markers stay decoupled and
14
+ // either can evolve without breaking the other. Spec wording "Auto-Block des Per-Feature-
15
+ // Files unter Sektion linked_snapshots" is honored: `linked_snapshots` IS its own
16
+ // auto-managed block, just a sibling of F-076's auto-block rather than nested inside it.
17
+ //
18
+ // @cap-decision(F-079/AC-4) Auto-block isolation — `linked_snapshots` uses dedicated
19
+ // marker pair `<!-- @auto-block linked_snapshots -->` ... `<!-- /@auto-block -->` separate
20
+ // from F-076's `cap:auto:start/end`. Trade-off: two marker pairs in the same file vs.
21
+ // modifying the shipped F-076 schema parser. Two pairs keep blast radius zero — F-076
22
+ // tests stay green and any future block type (e.g. F-080 claude-native bridge) can reuse
23
+ // the same `@auto-block <name>` pattern without needing a parser change.
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+
30
+ const session = require('./cap-session.cjs');
31
+ const schema = require('./cap-memory-schema.cjs');
32
+ const platformLib = require('./cap-memory-platform.cjs');
33
+ const { _atomicWriteFile } = require('./cap-memory-migrate.cjs');
34
+
35
+ // -------- Constants --------
36
+
37
+ // @cap-decision(F-079/D1) Snapshot directory is fixed at .cap/snapshots/. Mirrors F-077's
38
+ // SNAPSHOTS_DIR (defined inline there) — single contract across modules.
39
+ const SNAPSHOTS_DIR = path.join('.cap', 'snapshots');
40
+
41
+ // @cap-decision(F-079/D2) Linked-snapshots section uses its own marker pair inside the
42
+ // per-feature OR platform file. Format `<!-- @auto-block linked_snapshots -->` ...
43
+ // `<!-- /@auto-block -->`. The `@auto-block <name>` shape is intentionally generic so
44
+ // F-080 / future features can mount more named auto-managed blocks without inventing
45
+ // new marker conventions.
46
+ const LINKED_SNAPSHOTS_BLOCK_NAME = 'linked_snapshots';
47
+ const LINKED_SNAPSHOTS_START = `<!-- @auto-block ${LINKED_SNAPSHOTS_BLOCK_NAME} -->`;
48
+ const LINKED_SNAPSHOTS_END = '<!-- /@auto-block -->';
49
+
50
+ // @cap-decision(F-079/D3) snapshot-name slug regex: lowercase kebab-case alphanumerics,
51
+ // optionally allows internal `.` segments only via `_` (i.e. NO dots). Mirrors F-076's
52
+ // TOPIC_RE shape but tightened to forbid path-traversal byte forms. Snapshots traditionally
53
+ // embed dates (`2026-05-06-foo`) which the kebab regex already accepts.
54
+ const SNAPSHOT_NAME_RE = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
55
+
56
+ // @cap-decision(F-079/D4) Date-window for the migration heuristic (AC-5). 24h matches
57
+ // F-077's classifySnapshot SNAPSHOT_DATE_WINDOW_HOURS — same heuristic, same window. If a
58
+ // future tightening is needed, change it once here.
59
+ const SNAPSHOT_DATE_WINDOW_HOURS = 24;
60
+
61
+ // @cap-decision(F-079/D5) Unassigned snapshots topic name matches F-077's
62
+ // UNASSIGNED_SNAPSHOTS_TOPIC for cross-module consistency. Spec AC-6 names this file
63
+ // explicitly as `.cap/memory/platform/snapshots-unassigned.md`.
64
+ const UNASSIGNED_SNAPSHOTS_TOPIC = 'snapshots-unassigned';
65
+
66
+ // -------- Defensive helpers --------
67
+
68
+ // @cap-decision(F-079/iter1) Stage-2 #2: ANSI/control-byte sanitization for any user-supplied
69
+ // string that flows into stderr/throw messages. Mirrors cap-memory-platform.cjs:_safeForError
70
+ // — kept local so a refactor in one module can't silently weaken the defense in another.
71
+ function _safeForError(value) {
72
+ if (typeof value !== 'string') return String(value);
73
+ return value.replace(/[^\x20-\x7E]/g, '?').slice(0, 64);
74
+ }
75
+
76
+ // @cap-risk(reason:path-traversal-via-snapshot-name) Snapshot file paths are concatenated
77
+ // from a user-supplied snapshot name. Reject path separators, NUL bytes, and traversal
78
+ // sequences explicitly even though the slug regex would already catch them. Defense-in-depth
79
+ // matching F-078's _validateSlug pattern.
80
+ function _validateSnapshotName(name) {
81
+ if (typeof name !== 'string' || name.length === 0) {
82
+ throw new TypeError(`snapshot name must be a non-empty string (got ${typeof name})`);
83
+ }
84
+ if (name.includes('/') || name.includes('\\') || name.includes('..') || name.includes('\0')) {
85
+ throw new TypeError(`snapshot name must not contain path separators or traversal sequences (got "${_safeForError(name)}")`);
86
+ }
87
+ if (!SNAPSHOT_NAME_RE.test(name)) {
88
+ throw new TypeError(`snapshot name must be kebab-case (got "${_safeForError(name)}")`);
89
+ }
90
+ }
91
+
92
+ // @cap-risk(reason:proto-pollution-via-frontmatter) Topic / featureId values land inside
93
+ // frontmatter strings. parseSimpleYaml already strips __proto__/constructor/prototype keys,
94
+ // but defense-in-depth: reject the strings themselves if they match those reserved tokens
95
+ // when used as a topic/feature.
96
+ function _validateTopic(topic) {
97
+ if (typeof topic !== 'string' || topic.length === 0) {
98
+ throw new TypeError(`topic must be a non-empty string (got ${typeof topic})`);
99
+ }
100
+ if (topic === '__proto__' || topic === 'constructor' || topic === 'prototype') {
101
+ throw new TypeError(`topic name reserved (got "${_safeForError(topic)}")`);
102
+ }
103
+ if (topic.includes('/') || topic.includes('\\') || topic.includes('..') || topic.includes('\0')) {
104
+ throw new TypeError(`topic must not contain path separators or traversal sequences (got "${_safeForError(topic)}")`);
105
+ }
106
+ if (!platformLib.PLATFORM_TOPIC_RE.test(topic)) {
107
+ throw new TypeError(`topic must be kebab-case slug (got "${_safeForError(topic)}")`);
108
+ }
109
+ }
110
+
111
+ // @cap-decision(F-079/iter1) Stage-2 #3 fix: F-076-style "marker line must contain ONLY
112
+ // the marker after trim". Returns ALL byte offsets of qualifying marker lines so the
113
+ // caller can both pair start/end AND detect duplicate-block accidents.
114
+ /**
115
+ * @param {string} content
116
+ * @param {string} marker
117
+ * @returns {{offset:number, lineNo:number}[]}
118
+ */
119
+ function _findMarkerLinePositions(content, marker) {
120
+ /** @type {{offset:number, lineNo:number}[]} */
121
+ const out = [];
122
+ // Iterate manually so we can track byte offsets without regex zero-length match traps.
123
+ let cursor = 0;
124
+ let lineNo = 0;
125
+ while (cursor <= content.length) {
126
+ let nl = content.indexOf('\n', cursor);
127
+ if (nl === -1) nl = content.length;
128
+ const lineStart = cursor;
129
+ let line = content.slice(lineStart, nl);
130
+ // Tolerate CRLF — strip a trailing \r from the line content.
131
+ if (line.length > 0 && line.charCodeAt(line.length - 1) === 13) line = line.slice(0, -1);
132
+ lineNo++;
133
+ const trimmed = line.replace(/^\s+|\s+$/g, '');
134
+ if (trimmed === marker) {
135
+ const markerCol = line.indexOf(marker);
136
+ out.push({ offset: lineStart + (markerCol >= 0 ? markerCol : 0), lineNo });
137
+ }
138
+ if (nl === content.length) break;
139
+ cursor = nl + 1;
140
+ }
141
+ return out;
142
+ }
143
+
144
+ // -------- Snapshot frontmatter helpers --------
145
+
146
+ /**
147
+ * @typedef {Object} SnapshotFrontmatter
148
+ * @property {string=} session
149
+ * @property {string=} date
150
+ * @property {string=} branch
151
+ * @property {string=} source
152
+ * @property {string=} feature - F-NNN id (mutually exclusive with `platform`)
153
+ * @property {string=} platform - kebab-case topic (mutually exclusive with `feature`)
154
+ */
155
+
156
+ /**
157
+ * @typedef {Object} SnapshotRecord
158
+ * @property {string} name - basename without .md
159
+ * @property {string} relPath - .cap/snapshots/<name>.md (forward-slash form)
160
+ * @property {string} absPath
161
+ * @property {SnapshotFrontmatter} frontmatter
162
+ * @property {string} title - first H1 if any, else name
163
+ * @property {string} raw - full file content
164
+ */
165
+
166
+ // @cap-todo(ac:F-079/AC-1) parseSnapshotFile reads a single .cap/snapshots/<name>.md and
167
+ // returns its frontmatter (incl. feature/platform routing) + title.
168
+ /**
169
+ * @param {string} projectRoot
170
+ * @param {string} snapshotName
171
+ * @returns {SnapshotRecord|null}
172
+ */
173
+ function parseSnapshotFile(projectRoot, snapshotName) {
174
+ _validateSnapshotName(snapshotName);
175
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
176
+ throw new TypeError('projectRoot must be a non-empty string');
177
+ }
178
+ const absPath = path.join(projectRoot, SNAPSHOTS_DIR, `${snapshotName}.md`);
179
+ if (!fs.existsSync(absPath)) return null;
180
+ const raw = fs.readFileSync(absPath, 'utf8');
181
+ return _parseSnapshotContent(snapshotName, absPath, raw);
182
+ }
183
+
184
+ /**
185
+ * @param {string} snapshotName
186
+ * @param {string} absPath
187
+ * @param {string} raw
188
+ * @returns {SnapshotRecord}
189
+ */
190
+ function _parseSnapshotContent(snapshotName, absPath, raw) {
191
+ /** @type {SnapshotFrontmatter} */
192
+ const fm = Object.create(null);
193
+ // @cap-decision(F-079/D6) Reuse a minimal YAML extractor here rather than depend on
194
+ // cap-memory-schema's parseFeatureMemoryFile — snapshot frontmatter is plain key:value
195
+ // (no inline arrays today) and doesn't carry F-076's auto-block markers. Keeping this
196
+ // local avoids a bidirectional dep on F-076 just to read 5 scalars.
197
+ const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
198
+ if (fmMatch) {
199
+ const body = fmMatch[1];
200
+ const RESERVED = new Set(['__proto__', 'constructor', 'prototype']);
201
+ for (const line of body.split(/\r?\n/)) {
202
+ const m = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/);
203
+ if (!m) continue;
204
+ const key = m[1];
205
+ if (RESERVED.has(key)) continue;
206
+ const val = (m[2] || '').replace(/^["']|["']$/g, '').trim();
207
+ if (key === 'session') fm.session = val;
208
+ else if (key === 'date') fm.date = val;
209
+ else if (key === 'branch') fm.branch = val;
210
+ else if (key === 'source') fm.source = val;
211
+ else if (key === 'feature') fm.feature = val;
212
+ else if (key === 'platform') fm.platform = val;
213
+ }
214
+ }
215
+ // Title: first H1.
216
+ let title = snapshotName;
217
+ const h1 = raw.match(/^#\s+(.+?)\s*$/m);
218
+ if (h1) title = h1[1].trim();
219
+
220
+ return {
221
+ name: snapshotName,
222
+ relPath: `${SNAPSHOTS_DIR.replace(/\\/g, '/')}/${snapshotName}.md`,
223
+ absPath,
224
+ frontmatter: fm,
225
+ title,
226
+ raw,
227
+ };
228
+ }
229
+
230
+ // @cap-todo(ac:F-079/AC-1) listSnapshots enumerates snapshot basenames (without .md).
231
+ /**
232
+ * @param {string} projectRoot
233
+ * @returns {string[]} sorted list of snapshot basenames (no extension)
234
+ */
235
+ function listSnapshots(projectRoot) {
236
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
237
+ throw new TypeError('projectRoot must be a non-empty string');
238
+ }
239
+ const dir = path.join(projectRoot, SNAPSHOTS_DIR);
240
+ if (!fs.existsSync(dir)) return [];
241
+ let entries;
242
+ try {
243
+ entries = fs.readdirSync(dir, { withFileTypes: true });
244
+ } catch (_e) {
245
+ return [];
246
+ }
247
+ /** @type {string[]} */
248
+ const out = [];
249
+ for (const e of entries) {
250
+ if (!e || typeof e.name !== 'string') continue;
251
+ if (e.isDirectory && e.isDirectory()) continue;
252
+ if (!e.name.endsWith('.md')) continue;
253
+ const slug = e.name.slice(0, -3);
254
+ // Defensive: skip files whose name fails the slug regex — they could be hand-edited
255
+ // experiments and we don't want them to crash listSnapshots, just to be ignored.
256
+ if (!SNAPSHOT_NAME_RE.test(slug)) continue;
257
+ out.push(slug);
258
+ }
259
+ out.sort();
260
+ return out;
261
+ }
262
+
263
+ // -------- AC-1/AC-2/AC-3: Save-time options resolution --------
264
+
265
+ /**
266
+ * @typedef {Object} SaveOptions
267
+ * @property {boolean=} unassigned - --unassigned flag
268
+ * @property {string=} platform - --platform=<topic> flag value (if present)
269
+ * @property {string=} _explicitFeatureOverride - test-only seam (not a CLI flag); lets unit
270
+ * tests drive the explicit-feature branch without writing SESSION.json. Public CLI surface
271
+ * per AC-2 stays at exactly two flags: --unassigned and --platform=<topic>.
272
+ * @property {string=} activeFeature - injected active feature ID (test seam — defaults to SESSION.json)
273
+ */
274
+
275
+ /**
276
+ * @typedef {Object} ResolvedLinkage
277
+ * @property {'feature'|'platform'|'unassigned'} kind
278
+ * @property {string|null} featureId
279
+ * @property {string|null} topic
280
+ * @property {string|null} warning - non-null = soft-warn message (AC-3)
281
+ * @property {Partial<SnapshotFrontmatter>} frontmatterPatch
282
+ */
283
+
284
+ // @cap-feature(feature:F-079) resolveLinkageOptions — AC-1+AC-2+AC-3 single dispatcher.
285
+ //
286
+ // @cap-todo(ac:F-079/AC-1) When neither --unassigned nor --platform= is given, default to
287
+ // reading activeFeature from SESSION.json. If present, link the snapshot to that feature.
288
+ // @cap-todo(ac:F-079/AC-2) --unassigned and --platform=<topic> are mutually exclusive. Both
289
+ // together → loud parse-error (caller surfaces via process.exitCode + stderr).
290
+ // @cap-todo(ac:F-079/AC-3) Soft-warn (no fail) emits when the explicit --unassigned flag is
291
+ // set OR when no activeFeature is in SESSION.json. The snapshot is always created.
292
+ // @cap-decision(F-079/AC-2) Mutually-exclusive flags throw early via TypeError so the caller
293
+ // surfaces the error before any filesystem write happens. cap:save then exits non-zero with
294
+ // stderr — the snapshot is NOT created on parse-error. This is a HARD-fail (parse error),
295
+ // distinct from AC-3's SOFT-warn (linkage missing).
296
+ // @cap-decision(F-079/AC-3) Soft-warn rationale: snapshot creation is best-effort linkage.
297
+ // The user's primary intent is to capture context; failing the save because we can't link
298
+ // would lose data. Linkage failures emit on stderr and are non-fatal.
299
+
300
+ /**
301
+ * Resolve the linkage options for a cap:save invocation.
302
+ *
303
+ * @param {string} projectRoot
304
+ * @param {SaveOptions=} options
305
+ * @returns {ResolvedLinkage}
306
+ */
307
+ function resolveLinkageOptions(projectRoot, options) {
308
+ const opts = options || {};
309
+ const unassigned = opts.unassigned === true;
310
+ const platformRaw = (typeof opts.platform === 'string' && opts.platform.length > 0)
311
+ ? opts.platform
312
+ : null;
313
+ // @cap-decision(F-079/iter1) Stage-2 #4 fix: test-only seam renamed from `feature` to
314
+ // `_explicitFeatureOverride` so the public API surface signals "this is NOT a CLI flag".
315
+ // commands/cap/save.md exposes only --unassigned and --platform= per AC-2; the seam
316
+ // exists purely to keep unit tests deterministic without writing SESSION.json on disk.
317
+ const explicitFeature = (typeof opts._explicitFeatureOverride === 'string' && opts._explicitFeatureOverride.length > 0)
318
+ ? opts._explicitFeatureOverride
319
+ : null;
320
+
321
+ // AC-2: mutually-exclusive flag combinations.
322
+ if (unassigned && platformRaw) {
323
+ // Loud parse error — caller decides exit semantics.
324
+ throw new TypeError('cap:save: --unassigned and --platform=<topic> are mutually exclusive — pick one');
325
+ }
326
+ if (unassigned && explicitFeature) {
327
+ throw new TypeError('cap:save: --unassigned and explicit feature override are mutually exclusive — pick one');
328
+ }
329
+ if (platformRaw && explicitFeature) {
330
+ throw new TypeError('cap:save: --platform=<topic> and explicit feature override are mutually exclusive — pick one');
331
+ }
332
+
333
+ // AC-2 platform branch: validate topic shape and route.
334
+ if (platformRaw !== null) {
335
+ _validateTopic(platformRaw);
336
+ return {
337
+ kind: 'platform',
338
+ featureId: null,
339
+ topic: platformRaw,
340
+ warning: null,
341
+ frontmatterPatch: { platform: platformRaw },
342
+ };
343
+ }
344
+
345
+ // AC-2 / AC-3 unassigned branch: explicit user intent → soft-warn + no link.
346
+ if (unassigned) {
347
+ return {
348
+ kind: 'unassigned',
349
+ featureId: null,
350
+ topic: null,
351
+ warning: 'cap:save: --unassigned set; snapshot will not be linked to any feature or platform topic',
352
+ frontmatterPatch: {},
353
+ };
354
+ }
355
+
356
+ // AC-2 explicit-feature branch (test seam — opts._explicitFeatureOverride; NOT a CLI flag).
357
+ if (explicitFeature !== null) {
358
+ if (!schema.FEATURE_ID_RE.test(explicitFeature)) {
359
+ throw new TypeError(`cap:save: explicit feature override must match feature id regex (got "${_safeForError(explicitFeature)}")`);
360
+ }
361
+ return {
362
+ kind: 'feature',
363
+ featureId: explicitFeature,
364
+ topic: null,
365
+ warning: null,
366
+ frontmatterPatch: { feature: explicitFeature },
367
+ };
368
+ }
369
+
370
+ // AC-1 default: read activeFeature from SESSION.json.
371
+ // @cap-decision(F-079/iter1) Test seam: opts.activeFeature wins over SESSION.json so unit
372
+ // tests can drive every branch without writing a SESSION.json file each time. In production
373
+ // this field is never set by the cap:save command — only by tests.
374
+ let activeFeature = (typeof opts.activeFeature === 'string' && opts.activeFeature.length > 0)
375
+ ? opts.activeFeature
376
+ : null;
377
+ if (activeFeature === null) {
378
+ try {
379
+ const sess = session.loadSession(projectRoot);
380
+ if (sess && typeof sess.activeFeature === 'string' && sess.activeFeature.length > 0) {
381
+ activeFeature = sess.activeFeature;
382
+ }
383
+ } catch (_e) {
384
+ // loadSession is supposed to be defensive; ignore any unexpected error.
385
+ }
386
+ }
387
+ if (activeFeature !== null) {
388
+ if (!schema.FEATURE_ID_RE.test(activeFeature)) {
389
+ // SESSION.json had a malformed activeFeature — soft-warn rather than throw because the
390
+ // user didn't supply this directly; treat as "no link available".
391
+ return {
392
+ kind: 'unassigned',
393
+ featureId: null,
394
+ topic: null,
395
+ warning: `cap:save: activeFeature in SESSION.json ("${_safeForError(activeFeature)}") does not match feature-id regex; saving without linkage`,
396
+ frontmatterPatch: {},
397
+ };
398
+ }
399
+ return {
400
+ kind: 'feature',
401
+ featureId: activeFeature,
402
+ topic: null,
403
+ warning: null,
404
+ frontmatterPatch: { feature: activeFeature },
405
+ };
406
+ }
407
+
408
+ // AC-3: no activeFeature → soft-warn + unassigned.
409
+ // @cap-decision(F-079/followup) F-079-FIX-A: warning rephrase to only mention real CLI flags.
410
+ // Previously the warning advertised `--feature/--platform/--unassigned`, but `--feature`
411
+ // is NOT a real CLI flag — it was renamed to the `_explicitFeatureOverride` test-seam in
412
+ // F-079/iter1 to keep the public CLI surface aligned with AC-2 (exactly two flags). The
413
+ // warning text now only mentions the actual user-facing flags.
414
+ return {
415
+ kind: 'unassigned',
416
+ featureId: null,
417
+ topic: null,
418
+ warning: 'cap:save: no activeFeature set in SESSION.json and no --platform/--unassigned flag; snapshot will be saved without linkage',
419
+ frontmatterPatch: {},
420
+ };
421
+ }
422
+
423
+ // @cap-feature(feature:F-079) injectLinkageFrontmatter — pure helper that takes a raw snapshot
424
+ // markdown body (with or without existing frontmatter) and returns a new body with the
425
+ // linkage fields merged into frontmatter. Used by cap:save (test seam: keeps the file-IO
426
+ // path separate from string transformation).
427
+ //
428
+ // @cap-decision(F-079/D7) Always emit `feature:` OR `platform:` (or neither for unassigned)
429
+ // in the frontmatter of the snapshot file. Never emit both — the resolver guarantees that.
430
+ // When a snapshot is saved as `--unassigned`, no linkage line is added at all (rather than
431
+ // emitting `feature: null`), so the F-077 migration heuristic later sees a true orphan.
432
+
433
+ /**
434
+ * @param {string} body - existing snapshot markdown content
435
+ * @param {Partial<SnapshotFrontmatter>} patch
436
+ * @returns {string} new body with patch applied
437
+ */
438
+ function injectLinkageFrontmatter(body, patch) {
439
+ if (typeof body !== 'string') {
440
+ throw new TypeError('body must be a string');
441
+ }
442
+ if (!patch || typeof patch !== 'object') {
443
+ return body;
444
+ }
445
+ // Strip any existing feature: / platform: lines from frontmatter (re-write).
446
+ const fmMatch = body.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
447
+ if (!fmMatch) {
448
+ // No frontmatter at all — synthesize a minimal block.
449
+ const lines = ['---'];
450
+ if (patch.feature) lines.push(`feature: ${patch.feature}`);
451
+ if (patch.platform) lines.push(`platform: ${patch.platform}`);
452
+ if (lines.length === 1) {
453
+ // No additions — return body verbatim.
454
+ return body;
455
+ }
456
+ lines.push('---');
457
+ return `${lines.join('\n')}\n\n${body}`;
458
+ }
459
+ const fmBody = fmMatch[1];
460
+ const filtered = fmBody.split(/\r?\n/).filter((line) => {
461
+ return !/^(feature|platform)\s*:/.test(line.trim());
462
+ });
463
+ if (patch.feature) filtered.push(`feature: ${patch.feature}`);
464
+ if (patch.platform) filtered.push(`platform: ${patch.platform}`);
465
+ // Reconstruct the closing fence with the exact same trailing newline shape the original
466
+ // had (match either `\n---\n` or `\n---\n` at end of fm). fmMatch[0] already includes any
467
+ // trailing newline after the closing `---`, so we just splice from there.
468
+ const newFm = `---\n${filtered.join('\n')}\n---\n`;
469
+ const rest = body.slice(fmMatch[0].length);
470
+ return newFm + rest;
471
+ }
472
+
473
+ // -------- AC-4: Linked-snapshot block parsing/rendering --------
474
+
475
+ /**
476
+ * @typedef {Object} LinkedSnapshotEntry
477
+ * @property {string} name
478
+ * @property {string|null} date - ISO date (or short YYYY-MM-DD) extracted from frontmatter
479
+ * @property {string|null} branch - branch from snapshot frontmatter (display only)
480
+ */
481
+
482
+ // @cap-todo(ac:F-079/AC-4) parseLinkedSnapshotsBlock locates the dedicated marker pair in a
483
+ // target file and returns the parsed entries (idempotent re-write contract).
484
+ //
485
+ // @cap-decision(F-079/iter1) Stage-2 #3 fix: parser hardened against in-prose mentions and
486
+ // duplicate blocks — mirrors F-076's `_countMarkerLines` semantics (marker must be the
487
+ // ENTIRE trimmed line) so a Lessons section that documents the marker text doesn't get
488
+ // picked up as a marker. Two `<!-- @auto-block linked_snapshots -->` markers in the same
489
+ // file → loud throw with both line-positions (F-082 lesson: silent drop is the worst
490
+ // failure mode; loud-failure is the contract).
491
+ /**
492
+ * @param {string} content
493
+ * @returns {{startIdx:number, endIdx:number, entries:LinkedSnapshotEntry[]}|null}
494
+ */
495
+ function parseLinkedSnapshotsBlock(content) {
496
+ if (typeof content !== 'string') return null;
497
+ // Find marker lines where the marker IS the entire trimmed line content (mirrors
498
+ // cap-memory-schema.cjs:_countMarkerLines). This ignores in-prose mentions (e.g. inside
499
+ // a code-fence in the manual region) that would otherwise collide with bare indexOf.
500
+ const startLines = _findMarkerLinePositions(content, LINKED_SNAPSHOTS_START);
501
+ const endLines = _findMarkerLinePositions(content, LINKED_SNAPSHOTS_END);
502
+ if (startLines.length === 0 || endLines.length === 0) return null;
503
+ if (startLines.length > 1) {
504
+ throw new Error(
505
+ `parseLinkedSnapshotsBlock: expected exactly one ${LINKED_SNAPSHOTS_START}, found ${startLines.length} ` +
506
+ `(at byte offsets ${startLines.map((p) => p.offset).join(', ')})`
507
+ );
508
+ }
509
+ const startIdx = startLines[0].offset;
510
+ // Pair with the first end-marker AFTER the start-marker.
511
+ const pairedEnd = endLines.find((p) => p.offset > startIdx + LINKED_SNAPSHOTS_START.length);
512
+ if (!pairedEnd) return null;
513
+ const endIdx = pairedEnd.offset;
514
+ const body = content.slice(startIdx + LINKED_SNAPSHOTS_START.length, endIdx);
515
+ /** @type {LinkedSnapshotEntry[]} */
516
+ const entries = [];
517
+ const lineRe = /^-\s+([a-z0-9][a-z0-9_-]*)\s*(?:\(([^)]+)\))?\s*$/i;
518
+ for (const raw of body.split(/\r?\n/)) {
519
+ const line = raw.replace(/^\s+|\s+$/g, '');
520
+ if (!line.startsWith('- ')) continue;
521
+ const m = line.match(lineRe);
522
+ if (!m) continue;
523
+ const name = m[1];
524
+ let date = null;
525
+ let branch = null;
526
+ if (m[2]) {
527
+ // metadata is "<date>, branch: <branch>" or "<date>" or "branch: <branch>"
528
+ const parts = m[2].split(',').map((s) => s.trim());
529
+ for (const p of parts) {
530
+ const bm = p.match(/^branch:\s*(.+)$/i);
531
+ if (bm) branch = bm[1].trim();
532
+ else if (/^\d{4}-\d{2}-\d{2}/.test(p)) date = p;
533
+ }
534
+ }
535
+ entries.push({ name, date, branch });
536
+ }
537
+ return { startIdx, endIdx: endIdx + LINKED_SNAPSHOTS_END.length, entries };
538
+ }
539
+
540
+ // @cap-todo(ac:F-079/AC-4) renderLinkedSnapshotsBlock emits a stable, sorted, deduped block.
541
+ // Same input → byte-identical output (idempotent contract).
542
+ //
543
+ // @cap-decision(F-079/AC-4) Sort by (date asc, name asc) so a re-run with the same set of
544
+ // snapshots produces identical output. Dedup by snapshot name. Empty list → still emit
545
+ // the marker pair on their own lines (with one blank between) so a future snapshot has
546
+ // a stable insertion point and the round-trip is byte-stable on no-snapshots. (Stage-2
547
+ // #3: empty-block-injection guard.)
548
+ /**
549
+ * @param {LinkedSnapshotEntry[]} entries
550
+ * @returns {string}
551
+ */
552
+ function renderLinkedSnapshotsBlock(entries) {
553
+ const list = Array.isArray(entries) ? entries.slice() : [];
554
+ // Dedup by name — last write wins on metadata.
555
+ const byName = new Map();
556
+ for (const e of list) {
557
+ if (!e || typeof e.name !== 'string') continue;
558
+ byName.set(e.name, {
559
+ name: e.name,
560
+ date: (e.date && /^\d{4}-\d{2}-\d{2}/.test(e.date)) ? e.date.slice(0, 10) : null,
561
+ branch: typeof e.branch === 'string' && e.branch.length > 0 ? e.branch : null,
562
+ });
563
+ }
564
+ const sorted = [...byName.values()].sort((a, b) => {
565
+ const da = a.date || '';
566
+ const db = b.date || '';
567
+ if (da !== db) return da < db ? -1 : 1;
568
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
569
+ });
570
+ const lines = [LINKED_SNAPSHOTS_START];
571
+ if (sorted.length === 0) {
572
+ // Empty body — keep the marker pair compact (one blank line between markers) so the
573
+ // parser still finds them and the round-trip is byte-stable.
574
+ lines.push('');
575
+ } else {
576
+ for (const e of sorted) {
577
+ const meta = [];
578
+ if (e.date) meta.push(e.date);
579
+ if (e.branch) meta.push(`branch: ${e.branch}`);
580
+ const suffix = meta.length > 0 ? ` (${meta.join(', ')})` : '';
581
+ lines.push(`- ${e.name}${suffix}`);
582
+ }
583
+ }
584
+ lines.push(LINKED_SNAPSHOTS_END);
585
+ return lines.join('\n');
586
+ }
587
+
588
+ // @cap-feature(feature:F-079) upsertLinkedSnapshotsBlock — pure string-level merge.
589
+ // Returns new file content with the linked_snapshots block updated. Idempotent.
590
+ /**
591
+ * @param {string} content - existing target file content
592
+ * @param {LinkedSnapshotEntry[]} entries - the FULL desired set (not a delta)
593
+ * @returns {string}
594
+ */
595
+ function upsertLinkedSnapshotsBlock(content, entries) {
596
+ if (typeof content !== 'string') {
597
+ throw new TypeError('content must be a string');
598
+ }
599
+ const block = renderLinkedSnapshotsBlock(entries);
600
+ const existing = parseLinkedSnapshotsBlock(content);
601
+ if (existing) {
602
+ return content.slice(0, existing.startIdx) + block + content.slice(existing.endIdx);
603
+ }
604
+ // No block yet — append after the F-076 auto-block end-marker if present, else at EOF.
605
+ const autoEnd = content.indexOf(schema.AUTO_BLOCK_END_MARKER);
606
+ if (autoEnd !== -1) {
607
+ const after = autoEnd + schema.AUTO_BLOCK_END_MARKER.length;
608
+ // Insert with a leading blank line so it doesn't fuse onto the auto-block's end marker.
609
+ const sep = content.charAt(after) === '\n' ? '\n' : '\n\n';
610
+ return content.slice(0, after) + sep + block + (content.charAt(after) === '\n' ? '\n' : '') + content.slice(after);
611
+ }
612
+ // No auto-block either — append to EOF with a separating blank.
613
+ const trailer = content.endsWith('\n') ? '' : '\n';
614
+ return `${content}${trailer}\n${block}\n`;
615
+ }
616
+
617
+ // -------- AC-4: Per-feature / platform linker (file IO) --------
618
+
619
+ /**
620
+ * @typedef {Object} LinkResult
621
+ * @property {boolean} updated
622
+ * @property {string} reason - 'wrote' | 'byte-identical-noop' | 'target-missing-stub-created'
623
+ * @property {string} path - absolute path of the target file
624
+ */
625
+
626
+ // @cap-todo(ac:F-079/AC-4) linkSnapshotToFeature appends the snapshot to the per-feature
627
+ // memory file's linked_snapshots block. Idempotent: re-running with the same input is a
628
+ // no-op.
629
+ //
630
+ // @cap-decision(F-079/iter1) Stage-2 #5: return {updated, reason, path} instead of bare void
631
+ // so callers (pipeline, tests) can tell apart wrote / no-op / stub-created without
632
+ // re-reading the file. Mirrors writePlatformTopic's contract.
633
+ //
634
+ // @cap-decision(F-079/AC-4) Auto-create stub per-feature file when missing. The pipeline
635
+ // may run before any other memory has been written for a feature; we want the snapshot
636
+ // linkage to land regardless. Stub uses F-076 frontmatter (feature/topic/updated) +
637
+ // empty F-076 auto-block + linked_snapshots block. Schema-valid but minimal.
638
+ /**
639
+ * @param {string} projectRoot
640
+ * @param {string} featureId - F-NNN
641
+ * @param {string} topic - kebab-case topic for the per-feature file
642
+ * @param {LinkedSnapshotEntry[]} entries - FULL desired entry set
643
+ * @returns {LinkResult}
644
+ */
645
+ function linkSnapshotsToFeature(projectRoot, featureId, topic, entries) {
646
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
647
+ throw new TypeError('projectRoot must be a non-empty string');
648
+ }
649
+ if (!schema.FEATURE_ID_RE.test(featureId)) {
650
+ throw new TypeError(`featureId must match feature-id regex (got "${_safeForError(featureId)}")`);
651
+ }
652
+ if (!schema.TOPIC_RE.test(topic)) {
653
+ throw new TypeError(`topic must be kebab-case (got "${_safeForError(topic)}")`);
654
+ }
655
+ const featurePath = schema.getFeaturePath(projectRoot, featureId, topic);
656
+ let existing;
657
+ let stubCreated = false;
658
+ if (fs.existsSync(featurePath)) {
659
+ existing = fs.readFileSync(featurePath, 'utf8');
660
+ } else {
661
+ // Try to find any existing per-feature file for this featureId — topic may differ.
662
+ const featuresDir = path.join(projectRoot, schema.MEMORY_FEATURES_DIR);
663
+ const found = _findFeatureFileForId(featuresDir, featureId);
664
+ if (found) {
665
+ existing = fs.readFileSync(found, 'utf8');
666
+ // Honor the on-disk topic so we don't fork a sibling file.
667
+ const newPath = found;
668
+ const next = upsertLinkedSnapshotsBlock(existing, entries);
669
+ if (next === existing) {
670
+ return { updated: false, reason: 'byte-identical-noop', path: newPath };
671
+ }
672
+ _atomicWriteFile(newPath, next);
673
+ return { updated: true, reason: 'wrote', path: newPath };
674
+ }
675
+ // No file at all — synthesize a stub using the requested topic.
676
+ existing = _renderFeatureStub(featureId, topic);
677
+ stubCreated = true;
678
+ }
679
+ const next = upsertLinkedSnapshotsBlock(existing, entries);
680
+ if (!stubCreated && next === existing) {
681
+ return { updated: false, reason: 'byte-identical-noop', path: featurePath };
682
+ }
683
+ _atomicWriteFile(featurePath, next);
684
+ return {
685
+ updated: true,
686
+ reason: stubCreated ? 'target-missing-stub-created' : 'wrote',
687
+ path: featurePath,
688
+ };
689
+ }
690
+
691
+ // @cap-todo(ac:F-079/AC-4) linkSnapshotsToPlatform appends snapshots to a platform-topic
692
+ // memory file's linked_snapshots block. Same idempotent contract as the feature linker.
693
+ /**
694
+ * @param {string} projectRoot
695
+ * @param {string} topic - platform topic slug
696
+ * @param {LinkedSnapshotEntry[]} entries
697
+ * @returns {LinkResult}
698
+ */
699
+ function linkSnapshotsToPlatform(projectRoot, topic, entries) {
700
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
701
+ throw new TypeError('projectRoot must be a non-empty string');
702
+ }
703
+ _validateTopic(topic);
704
+ const platformPath = platformLib.getPlatformTopicPath(projectRoot, topic);
705
+ let existing;
706
+ let stubCreated = false;
707
+ if (fs.existsSync(platformPath)) {
708
+ existing = fs.readFileSync(platformPath, 'utf8');
709
+ } else {
710
+ existing = platformLib.renderPlatformTopic({ topic, updated: new Date().toISOString() });
711
+ stubCreated = true;
712
+ }
713
+ const next = upsertLinkedSnapshotsBlock(existing, entries);
714
+ if (!stubCreated && next === existing) {
715
+ return { updated: false, reason: 'byte-identical-noop', path: platformPath };
716
+ }
717
+ _atomicWriteFile(platformPath, next);
718
+ return {
719
+ updated: true,
720
+ reason: stubCreated ? 'target-missing-stub-created' : 'wrote',
721
+ path: platformPath,
722
+ };
723
+ }
724
+
725
+ /**
726
+ * Search .cap/memory/features/ for any file whose basename starts with `<featureId>-`.
727
+ * Returns the absolute path or null. Defensive against empty/missing dir.
728
+ * @param {string} featuresDir
729
+ * @param {string} featureId
730
+ * @returns {string|null}
731
+ */
732
+ function _findFeatureFileForId(featuresDir, featureId) {
733
+ if (!fs.existsSync(featuresDir)) return null;
734
+ let entries;
735
+ try {
736
+ entries = fs.readdirSync(featuresDir);
737
+ } catch (_e) {
738
+ return null;
739
+ }
740
+ const prefix = `${featureId}-`;
741
+ for (const name of entries) {
742
+ if (typeof name !== 'string') continue;
743
+ if (!name.endsWith('.md')) continue;
744
+ if (name.startsWith(prefix)) return path.join(featuresDir, name);
745
+ }
746
+ return null;
747
+ }
748
+
749
+ /**
750
+ * Synthesize a minimal F-076-shaped per-feature memory file body. Schema-valid: feature +
751
+ * topic + updated + empty auto-block + empty manual region.
752
+ * @param {string} featureId
753
+ * @param {string} topic
754
+ * @returns {string}
755
+ */
756
+ function _renderFeatureStub(featureId, topic) {
757
+ const updated = new Date().toISOString();
758
+ const titleCase = topic.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
759
+ return [
760
+ '---',
761
+ `feature: ${featureId}`,
762
+ `topic: ${topic}`,
763
+ `updated: ${updated}`,
764
+ '---',
765
+ '',
766
+ `# ${featureId}: ${titleCase}`,
767
+ '',
768
+ schema.AUTO_BLOCK_START_MARKER,
769
+ schema.AUTO_BLOCK_END_MARKER,
770
+ '',
771
+ '## Lessons',
772
+ '',
773
+ '<!-- Manual lessons go here. The auto-block above is regenerated by the memory pipeline. -->',
774
+ '',
775
+ ].join('\n');
776
+ }
777
+
778
+ // -------- AC-5: Migration heuristic (date + state-transitions) --------
779
+ //
780
+ // @cap-decision(F-079/iter1) Stage-2 #2 fix: AC-5 duplicate consolidated via Option A.
781
+ // The previous F-079 prototype shipped a pure helper `assignSnapshotByDate(date, transitions)`
782
+ // that duplicated the date-proximity branch of F-077's `classifySnapshot` in
783
+ // cap-memory-migrate.cjs:716-808. F-077's classifier is the SINGLE SOURCE OF TRUTH for
784
+ // "snapshot date heuristic" — it covers strictly more cases (frontmatter feature wins,
785
+ // date-proximity-single, date-proximity-multi, title F-NNN fallback, no-signal → unassigned)
786
+ // AND is the only one wired into the actual migration plan (`migrateMemory()`). Shipping a
787
+ // second copy created a maintenance hazard: a future tweak to one would silently diverge
788
+ // from the other.
789
+ //
790
+ // Option A chosen (delete F-079's helper) over Option B (refactor F-077 to delegate) because:
791
+ // 1. F-077's classifier has materially MORE behavior (frontmatter + multi-candidate +
792
+ // title-F-NNN), so it can't simply call assignSnapshotByDate as a primitive.
793
+ // 2. F-077's `classifySnapshot` is already pinned by 4 tests in cap-memory-migrate.test.cjs
794
+ // (frontmatter wins / date-proximity / title fallback / no-signal). Migrating F-079's
795
+ // boundary-determinism case (two transitions at the same timestamp → secondary sort by
796
+ // featureId) into the F-077 test file as a new `it()` keeps that pin alive.
797
+ // 3. F-077 already runs the migration end-to-end. AC-5 spec wording "Migration aus F-077
798
+ // MUSS Datum + State-Transitions ... nutzen" is already honored by F-077 — AC-5 is now
799
+ // a direct callout to the existing F-077 mechanism, not a separate F-079 deliverable.
800
+ //
801
+ // AC-5 deliverable for F-079: NONE in this module. F-077's classifier IS the implementation.
802
+ // The historical 12-snapshot adversarial fixture moves to tests/cap-memory-migrate.test.cjs
803
+ // (Option-A test migration, see Stage-2 #2).
804
+
805
+ // -------- AC-4 + AC-6: processSnapshots pipeline step --------
806
+
807
+ /**
808
+ * @typedef {Object} ProcessSnapshotsOptions
809
+ * @property {string=} now - ISO timestamp to use for stub `updated` fields (test seam)
810
+ * @property {Map<string,string>=} featureTopics - F-NNN -> existing topic slug; lets the caller
811
+ * override the default `_slugify(featureId)` (used to align stubs with F-077 outputs)
812
+ */
813
+
814
+ /**
815
+ * @typedef {Object} ProcessSnapshotsResult
816
+ * @property {string[]} processed - snapshot names processed
817
+ * @property {string[]} writes - target file paths actually written
818
+ * @property {string[]} noops - target file paths that were byte-identical no-ops
819
+ * @property {{name:string, reason:string}[]} skipped - snapshots that could not be linked
820
+ * @property {{name:string, kind:'feature'|'platform'|'unassigned', target:string}[]} routes
821
+ */
822
+
823
+ // @cap-feature(feature:F-079) processSnapshots — pipeline step that walks .cap/snapshots/*
824
+ // and ensures every snapshot is referenced from its target's linked_snapshots block.
825
+ // Idempotent: byte-identical re-write on second run.
826
+ //
827
+ // @cap-todo(ac:F-079/AC-4) processSnapshots groups snapshots by target (feature|platform|unassigned)
828
+ // and writes ONE upsert per target with the FULL set (sorted, deduped) — not per-snapshot
829
+ // appends. This is what makes the operation idempotent: the input set determines the output
830
+ // set deterministically.
831
+ //
832
+ // @cap-todo(ac:F-079/AC-6) Snapshots without `feature:` or `platform:` frontmatter (i.e. the
833
+ // classic orphan case) land in `.cap/memory/platform/snapshots-unassigned.md`. No snapshot
834
+ // is ever silently dropped.
835
+
836
+ /**
837
+ * @param {string} projectRoot
838
+ * @param {ProcessSnapshotsOptions=} options
839
+ * @returns {ProcessSnapshotsResult}
840
+ */
841
+ function processSnapshots(projectRoot, options) {
842
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
843
+ throw new TypeError('projectRoot must be a non-empty string');
844
+ }
845
+ const opts = options || {};
846
+ const featureTopics = opts.featureTopics instanceof Map ? opts.featureTopics : new Map();
847
+
848
+ /** @type {ProcessSnapshotsResult} */
849
+ const result = {
850
+ processed: [],
851
+ writes: [],
852
+ noops: [],
853
+ skipped: [],
854
+ routes: [],
855
+ };
856
+
857
+ const names = listSnapshots(projectRoot);
858
+ if (names.length === 0) {
859
+ // AC: pipeline.processSnapshots on empty .cap/snapshots/ → no-op, no warn, no crash.
860
+ return result;
861
+ }
862
+
863
+ /** @type {Map<string, LinkedSnapshotEntry[]>} keyed by `feature:F-NNN:<topic>` or `platform:<topic>` */
864
+ const byTarget = new Map();
865
+ /** @type {Map<string, {kind:'feature'|'platform', featureId?:string, topic:string}>} */
866
+ const targetMeta = new Map();
867
+
868
+ for (const name of names) {
869
+ let snap;
870
+ try {
871
+ snap = parseSnapshotFile(projectRoot, name);
872
+ } catch (_e) {
873
+ // Defensive: a malformed snapshot filename shouldn't kill the pipeline. Skip and move on.
874
+ result.skipped.push({ name, reason: 'parse-error' });
875
+ continue;
876
+ }
877
+ if (!snap) {
878
+ result.skipped.push({ name, reason: 'file-disappeared-during-walk' });
879
+ continue;
880
+ }
881
+ result.processed.push(name);
882
+
883
+ const fm = snap.frontmatter;
884
+ const dateStr = (fm && typeof fm.date === 'string') ? fm.date : null;
885
+ const branchStr = (fm && typeof fm.branch === 'string') ? fm.branch : null;
886
+ const entry = { name, date: dateStr, branch: branchStr };
887
+
888
+ if (fm && typeof fm.feature === 'string' && schema.FEATURE_ID_RE.test(fm.feature)) {
889
+ const fid = fm.feature;
890
+ const topic = featureTopics.get(fid) || _slugifyFromFeatureId(fid);
891
+ const key = `feature:${fid}:${topic}`;
892
+ if (!byTarget.has(key)) byTarget.set(key, []);
893
+ byTarget.get(key).push(entry);
894
+ targetMeta.set(key, { kind: 'feature', featureId: fid, topic });
895
+ result.routes.push({ name, kind: 'feature', target: `${fid}-${topic}.md` });
896
+ continue;
897
+ }
898
+ if (fm && typeof fm.platform === 'string' && platformLib.PLATFORM_TOPIC_RE.test(fm.platform)) {
899
+ const topic = fm.platform;
900
+ const key = `platform:${topic}`;
901
+ if (!byTarget.has(key)) byTarget.set(key, []);
902
+ byTarget.get(key).push(entry);
903
+ targetMeta.set(key, { kind: 'platform', topic });
904
+ result.routes.push({ name, kind: 'platform', target: `${topic}.md` });
905
+ continue;
906
+ }
907
+ // AC-6: orphan → unassigned platform topic.
908
+ const key = `platform:${UNASSIGNED_SNAPSHOTS_TOPIC}`;
909
+ if (!byTarget.has(key)) byTarget.set(key, []);
910
+ byTarget.get(key).push(entry);
911
+ targetMeta.set(key, { kind: 'platform', topic: UNASSIGNED_SNAPSHOTS_TOPIC });
912
+ result.routes.push({ name, kind: 'unassigned', target: `${UNASSIGNED_SNAPSHOTS_TOPIC}.md` });
913
+ }
914
+
915
+ for (const [key, entries] of byTarget.entries()) {
916
+ const meta = targetMeta.get(key);
917
+ if (!meta) continue;
918
+ if (meta.kind === 'feature' && meta.featureId && meta.topic) {
919
+ const linkResult = linkSnapshotsToFeature(projectRoot, meta.featureId, meta.topic, entries);
920
+ if (linkResult.updated) result.writes.push(linkResult.path);
921
+ else result.noops.push(linkResult.path);
922
+ } else if (meta.kind === 'platform' && meta.topic) {
923
+ const linkResult = linkSnapshotsToPlatform(projectRoot, meta.topic, entries);
924
+ if (linkResult.updated) result.writes.push(linkResult.path);
925
+ else result.noops.push(linkResult.path);
926
+ }
927
+ }
928
+
929
+ return result;
930
+ }
931
+
932
+ /**
933
+ * Derive a default kebab-slug topic from a feature id alone (e.g. "F-079" → "f-079").
934
+ * Used when the caller can't supply a richer topic from FEATURE-MAP.
935
+ * @param {string} featureId
936
+ */
937
+ function _slugifyFromFeatureId(featureId) {
938
+ return featureId.toLowerCase();
939
+ }
940
+
941
+ // -------- Exports --------
942
+
943
+ module.exports = {
944
+ // Public API
945
+ resolveLinkageOptions,
946
+ injectLinkageFrontmatter,
947
+ parseSnapshotFile,
948
+ listSnapshots,
949
+ parseLinkedSnapshotsBlock,
950
+ renderLinkedSnapshotsBlock,
951
+ upsertLinkedSnapshotsBlock,
952
+ linkSnapshotsToFeature,
953
+ linkSnapshotsToPlatform,
954
+ processSnapshots,
955
+ // Constants
956
+ SNAPSHOTS_DIR,
957
+ LINKED_SNAPSHOTS_BLOCK_NAME,
958
+ LINKED_SNAPSHOTS_START,
959
+ LINKED_SNAPSHOTS_END,
960
+ SNAPSHOT_NAME_RE,
961
+ SNAPSHOT_DATE_WINDOW_HOURS,
962
+ UNASSIGNED_SNAPSHOTS_TOPIC,
963
+ };