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,812 @@
1
+ // @cap-context CAP V6 Memory-Format-Pivot — per-feature memory files at .cap/memory/features/F-NNN-<topic>.md.
2
+ // Replaces the V5 monolithic decisions.md (which scaled poorly: 296 KB / 1219 entries / 28% noise on real audits).
3
+ // V6 augments Claude-native auto-memory rather than duplicating it; FEATURE-MAP.md remains the single source of
4
+ // truth for lifecycle (state, ACs, title), while these per-feature memory files capture the *narrative* —
5
+ // decisions and pitfalls extracted from tags (auto-block) plus human lessons / snapshot links (manual-block).
6
+
7
+ 'use strict';
8
+
9
+ // @cap-feature(feature:F-076, primary:true) V6 Per-Feature Memory Format — schema, validator, round-trip-safe parser/serializer
10
+
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+
14
+ // -------- Constants --------
15
+
16
+ // @cap-decision(F-076/D1) Marker comments are HTML-style (`<!-- ... -->`) so they render as invisible whitespace in
17
+ // any Markdown viewer (GitHub, VS Code preview, Obsidian) — humans see clean section content, machines see anchors.
18
+ // Alternatives considered: ATX headings (`## __auto_start__`) leak into TOCs; horizontal rules (`---`) collide with
19
+ // front-matter delimiters; YAML stanzas inline are not Markdown-friendly.
20
+ const AUTO_BLOCK_START_MARKER = '<!-- cap:auto:start -->';
21
+ const AUTO_BLOCK_END_MARKER = '<!-- cap:auto:end -->';
22
+
23
+ // @cap-decision(F-076/D2) Memory features live under a fixed relative path so all consumers (F-077 migration,
24
+ // F-078 platform bucket, F-079 snapshot linkage, F-080 Claude-native bridge) share the same directory contract
25
+ // without each re-deriving it. Exported as a constant rather than computed per call.
26
+ //
27
+ // NOTE: Use `getFeaturePath(root, featureId, topic)` to construct paths — that goes through the
28
+ // feature-id + topic regex validation. Do NOT `path.join(root, MEMORY_FEATURES_DIR, …)` directly
29
+ // for path construction; the constant is exposed for read-only path matching only.
30
+ const MEMORY_FEATURES_DIR = '.cap/memory/features';
31
+
32
+ // @cap-decision(F-076/AC-4) Cross-feature linkage is by Feature-ID reference only. The schema
33
+ // deliberately does NOT define an `embedded_content` or `inlined_decisions` field. If a downstream
34
+ // feature wants to reference another feature's decision, it lists `related_features: [F-NNN]` and
35
+ // the reader resolves by ID at read time. Inhibition prevents the kind of duplication-drift that
36
+ // the V5 monolith decisions.md suffered from.
37
+ //
38
+ // @cap-decision(F-076/AC-6) The schema is strictly complementary to FEATURE-MAP.md. Lifecycle fields
39
+ // (title/state/acs/dependencies) live ONLY in FEATURE-MAP. Per-feature memory files own decisions,
40
+ // pitfalls, lessons, linked snapshots, and cross-feature references. If you find yourself wanting
41
+ // to mirror state into a memory file, that is a sign FEATURE-MAP needs the missing field — not that
42
+ // the memory format should grow.
43
+
44
+ // @cap-decision(F-076/D3) Feature ID regex enforces F-NNN with at least 3 digits (matches FEATURE-MAP.md zero-pad
45
+ // convention and is forward-compat with F-1000+ when the project crosses 1000 features). Anchored on both sides to
46
+ // reject substring matches like "FF-076x" or "F-076-suffix".
47
+ // @cap-feature(feature:F-081) Union form: legacy F-NNN OR long-form F-LONGFORM (uppercase-led).
48
+ // @cap-todo(ac:F-081/AC-5) Schema validator accepts the union ID format so long-form IDs (e.g. F-DEPLOY,
49
+ // F-HUB-AUTH) participate in V6 per-feature memory file naming. The second branch deliberately requires
50
+ // an UPPERCASE leading char so digit-leading suffixes like `076-suffix` cannot match — the F-076-suffix
51
+ // rejection invariant from the original F-076 schema tests is preserved.
52
+ // @cap-risk(reason:regex-test-coverage) FEATURE_ID_RE is exported and consumed by getFeaturePath() and
53
+ // validateFeatureMemoryFile(); widening it changes both surfaces simultaneously. Existing tests at
54
+ // tests/cap-memory-schema.test.cjs:91-96 still hold under the union; new long-form test cases are
55
+ // added to cap-feature-map-bullet.test.cjs to cover the F-081 expansion.
56
+ // @cap-todo(ac:F-081/iter1) Stage-2 #5 fix: de-duplicate the regex by importing the canonical
57
+ // pattern from cap-feature-map.cjs (which defines `FEATURE_ID_PATTERN`). cap-feature-map.cjs
58
+ // does NOT require cap-memory-schema.cjs (verified: no circular dep), so a one-way import is safe.
59
+ // Source-of-truth lives next to the parser that uses it most heavily.
60
+ // @cap-decision(F-081/iter1) Local alias `FEATURE_ID_RE` preserved so the rest of this module
61
+ // (validators, getFeaturePath) reads naturally and downstream code/tests that destructured
62
+ // the local name continue to work without code-churn.
63
+ const { FEATURE_ID_PATTERN: FEATURE_ID_RE } = require('./cap-feature-map.cjs');
64
+
65
+ // @cap-decision(F-076/D4) Topic slug enforces kebab-case alphanumerics-and-dashes only — same shape as filenames in
66
+ // `cap/bin/lib/cap-*.cjs`. Excludes leading/trailing dashes and consecutive dashes to keep filenames clean.
67
+ const TOPIC_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
68
+
69
+ // @cap-decision(F-076/D5) The `extends` field, when present, must follow `platform/<topic>` shape. This is the
70
+ // hook F-078 uses to mount platform-bucket links (e.g., `platform/atomic-writes`). Validated here rather than in
71
+ // F-078 so the contract is locked from the foundation outward.
72
+ const EXTENDS_RE = /^platform\/[a-z0-9]+(?:-[a-z0-9]+)*$/;
73
+
74
+ // ISO 8601 instant or date-time with timezone (Z or ±HH:MM). Permissive enough for `new Date().toISOString()`
75
+ // which is what the pipeline emits, strict enough to reject "yesterday".
76
+ const ISO8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
77
+
78
+ // -------- Typedefs --------
79
+
80
+ /**
81
+ * @typedef {Object} FrontMatter
82
+ * @property {string} feature - Feature ID (e.g., "F-076"), matches /^F-\d{3,}$/
83
+ * @property {string} topic - kebab-case topic slug (e.g., "v6-memory-format")
84
+ * @property {string} updated - ISO 8601 timestamp
85
+ * @property {string[]=} related_features - array of F-NNN ids
86
+ * @property {string[]=} key_files - array of repo-relative paths
87
+ * @property {string=} extends - "platform/<topic>" reference (F-078 forward-compat)
88
+ */
89
+
90
+ /**
91
+ * @typedef {Object} AutoBlock
92
+ * @property {Array<{text: string, location: string}>} decisions - decision entries (text + `file:line` location)
93
+ * @property {Array<{text: string, location: string}>} pitfalls - pitfall entries
94
+ */
95
+
96
+ /**
97
+ * @typedef {Object} ManualBlock
98
+ * @property {string} raw - the entire manual content as a literal string slice (preserved byte-identical for round-trip)
99
+ */
100
+
101
+ /**
102
+ * @typedef {Object} FeatureMemoryFile
103
+ * @property {FrontMatter} frontmatter
104
+ * @property {AutoBlock} autoBlock
105
+ * @property {ManualBlock} manualBlock
106
+ * @property {string=} title - the H1 heading text (e.g., "F-076: Define V6 Memory Format Schema"); preserved as part of manualBlock for round-trip but exposed separately for convenience
107
+ */
108
+
109
+ /**
110
+ * @typedef {Object} ValidationResult
111
+ * @property {boolean} valid
112
+ * @property {string[]} errors
113
+ * @property {string[]} warnings
114
+ */
115
+
116
+ // -------- Front-matter parsing (minimal, scoped to this schema only) --------
117
+
118
+ // @cap-risk Using a custom YAML mini-parser instead of `frontmatter.cjs` keeps this module zero-coupled to legacy
119
+ // GSD code. The trade-off: we only support the small subset this schema needs (scalars + inline arrays). If a future
120
+ // AC requires nested objects in front-matter, switch to extractFrontmatter() from frontmatter.cjs.
121
+
122
+ /**
123
+ * Find the front-matter block at the start of `content` and return [yamlBody, endIndex].
124
+ * Returns [null, 0] if none present. The endIndex is the offset *after* the closing `---` line including its
125
+ * trailing newline (or end-of-file if the file ends without one).
126
+ * @param {string} content
127
+ * @returns {[string|null, number]}
128
+ */
129
+ function locateFrontMatter(content) {
130
+ // Allow optional UTF-8 BOM, then `---` on its own line, then body, then `---` on its own line.
131
+ const startMatch = content.match(/^\uFEFF?---\r?\n/);
132
+ if (!startMatch) return [null, 0];
133
+ const bodyStart = startMatch[0].length;
134
+ // Find the closing `---` on its own line. Use a regex anchored to a newline boundary so we don't match `---`
135
+ // inside the body (e.g., a horizontal rule).
136
+ const closeRe = /\r?\n---(?:\r?\n|$)/g;
137
+ closeRe.lastIndex = bodyStart;
138
+ const closeMatch = closeRe.exec(content);
139
+ if (!closeMatch) return [null, 0];
140
+ const yaml = content.slice(bodyStart, closeMatch.index);
141
+ const endIndex = closeMatch.index + closeMatch[0].length;
142
+ return [yaml, endIndex];
143
+ }
144
+
145
+ /**
146
+ * Parse the limited YAML subset we accept in V6 memory front-matter.
147
+ * Supported forms:
148
+ * key: value
149
+ * key: [a, b, c]
150
+ * key: ["a", "b"]
151
+ * Lines starting with `#` are comments.
152
+ * @param {string} yaml
153
+ * @returns {Object<string, string|string[]>}
154
+ */
155
+ function parseSimpleYaml(yaml) {
156
+ // @cap-risk(F-076/AC-5) Front-matter is hand-editable. A typo or copy-paste from external docs
157
+ // could include `__proto__:`, `constructor:`, or `prototype:` keys, which
158
+ // would (a) re-parent the parsed object to a non-Object prototype or
159
+ // (b) overwrite its constructor field. V8 blocks global Object.prototype
160
+ // pollution for object literals, but the per-instance damage still bites
161
+ // any downstream consumer doing instanceof / .constructor checks. Two
162
+ // defenses: prototype-less object via Object.create(null), and a hard
163
+ // skip on the three reserved keys.
164
+ const out = Object.create(null);
165
+ const RESERVED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
166
+ const lines = yaml.split(/\r?\n/);
167
+ for (const raw of lines) {
168
+ const line = raw.replace(/^\s+|\s+$/g, '');
169
+ if (line === '' || line.startsWith('#')) continue;
170
+ const m = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*)$/);
171
+ if (!m) continue; // unknown shape — silently skip; validator will catch missing required keys
172
+ const key = m[1];
173
+ if (RESERVED_KEYS.has(key)) continue; // defense-in-depth, regex already excludes most via __ prefix
174
+ const rawValue = m[2];
175
+ if (rawValue === '') {
176
+ out[key] = '';
177
+ continue;
178
+ }
179
+ // Inline array
180
+ if (rawValue.startsWith('[') && rawValue.endsWith(']')) {
181
+ const inner = rawValue.slice(1, -1).trim();
182
+ if (inner === '') {
183
+ out[key] = [];
184
+ } else {
185
+ out[key] = inner
186
+ .split(',')
187
+ .map((s) => s.trim().replace(/^["']|["']$/g, ''))
188
+ .filter((s) => s.length > 0);
189
+ }
190
+ continue;
191
+ }
192
+ // Scalar (strip surrounding quotes if any)
193
+ out[key] = rawValue.replace(/^["']|["']$/g, '');
194
+ }
195
+ return out;
196
+ }
197
+
198
+ /**
199
+ * Serialize the front-matter object back to YAML body (without the `---` fences).
200
+ * Mirrors parseSimpleYaml's accepted shapes and produces stable, ordered output.
201
+ * @param {FrontMatter} fm
202
+ * @returns {string}
203
+ */
204
+ function serializeFrontMatter(fm) {
205
+ const lines = [];
206
+ // Stable key order matches the schema doc — required first, optional after.
207
+ const ordered = ['feature', 'topic', 'updated', 'related_features', 'key_files', 'extends'];
208
+ for (const key of ordered) {
209
+ if (!Object.prototype.hasOwnProperty.call(fm, key)) continue;
210
+ const val = fm[key];
211
+ if (val === undefined || val === null) continue;
212
+ if (Array.isArray(val)) {
213
+ lines.push(`${key}: [${val.join(', ')}]`);
214
+ } else {
215
+ lines.push(`${key}: ${String(val)}`);
216
+ }
217
+ }
218
+ // Preserve any unknown keys verbatim at the tail (round-trip-safe).
219
+ for (const key of Object.keys(fm)) {
220
+ if (ordered.includes(key)) continue;
221
+ const val = fm[key];
222
+ if (val === undefined || val === null) continue;
223
+ if (Array.isArray(val)) {
224
+ lines.push(`${key}: [${val.join(', ')}]`);
225
+ } else {
226
+ lines.push(`${key}: ${String(val)}`);
227
+ }
228
+ }
229
+ return lines.join('\n');
230
+ }
231
+
232
+ // -------- Auto-block parsing / serialization --------
233
+
234
+ // @cap-todo(ac:F-076/AC-2) parseAutoBlock recognizes the marker pair and extracts decisions/pitfalls between them.
235
+ /**
236
+ * Locate the auto-block in `content` and return its bounds + parsed entries.
237
+ * @param {string} content
238
+ * @returns {{ startIdx: number, endIdx: number, body: string } | null}
239
+ */
240
+ function locateAutoBlock(content) {
241
+ const startIdx = content.indexOf(AUTO_BLOCK_START_MARKER);
242
+ if (startIdx === -1) return null;
243
+ const endIdx = content.indexOf(AUTO_BLOCK_END_MARKER, startIdx + AUTO_BLOCK_START_MARKER.length);
244
+ if (endIdx === -1) return null;
245
+ // The body lives between the two markers (exclusive of the markers themselves).
246
+ const body = content.slice(startIdx + AUTO_BLOCK_START_MARKER.length, endIdx);
247
+ return { startIdx, endIdx: endIdx + AUTO_BLOCK_END_MARKER.length, body };
248
+ }
249
+
250
+ /**
251
+ * Parse the body of the auto-block (between markers) into a structured AutoBlock.
252
+ * Recognized sections:
253
+ * ## Decisions (from tags)
254
+ * - <text> — `<file>:<line>`
255
+ * ## Pitfalls (from tags)
256
+ * - <text> — `<file>:<line>`
257
+ * Sections may be omitted entirely when empty (AC-3).
258
+ * @param {string} body
259
+ * @returns {AutoBlock}
260
+ */
261
+ function parseAutoBlockBody(body) {
262
+ /** @type {AutoBlock} */
263
+ const out = { decisions: [], pitfalls: [] };
264
+ const sectionRe = /^##\s+(Decisions|Pitfalls)\s*\(from tags\)\s*$/i;
265
+ const lines = body.split(/\r?\n/);
266
+ /** @type {'decisions'|'pitfalls'|null} */
267
+ let current = null;
268
+ for (const raw of lines) {
269
+ const line = raw.replace(/\s+$/g, '');
270
+ const headerMatch = line.match(sectionRe);
271
+ if (headerMatch) {
272
+ current = headerMatch[1].toLowerCase() === 'decisions' ? 'decisions' : 'pitfalls';
273
+ continue;
274
+ }
275
+ if (!current) continue;
276
+ if (line.startsWith('- ')) {
277
+ const item = line.slice(2);
278
+ // Split on em-dash + backtick boundary: "<text> — `<file>:<line>`"
279
+ const m = item.match(/^(.*?)\s+—\s+`([^`]+)`\s*$/);
280
+ if (m) {
281
+ out[current].push({ text: m[1].trim(), location: m[2].trim() });
282
+ } else {
283
+ // Fallback: text-only entry without location
284
+ out[current].push({ text: item.trim(), location: '' });
285
+ }
286
+ }
287
+ }
288
+ return out;
289
+ }
290
+
291
+ // @cap-todo(ac:F-076/AC-3) renderAutoBlockBody omits empty optional sections — no `(none)` or `TODO` placeholders.
292
+ /**
293
+ * Render an AutoBlock to its body string (between markers, exclusive of markers).
294
+ * @param {AutoBlock} block
295
+ * @param {{ eol?: string }=} opts
296
+ * @returns {string}
297
+ */
298
+ function renderAutoBlockBody(block, opts) {
299
+ const eol = (opts && opts.eol) || '\n';
300
+ const parts = [];
301
+ const renderSection = (label, items) => {
302
+ if (!items || items.length === 0) return; // AC-3: omit empty sections
303
+ parts.push(`## ${label} (from tags)`);
304
+ for (const item of items) {
305
+ const loc = item.location ? ` — \`${item.location}\`` : '';
306
+ parts.push(`- ${item.text}${loc}`);
307
+ }
308
+ };
309
+ renderSection('Decisions', block.decisions);
310
+ if (parts.length > 0 && block.pitfalls && block.pitfalls.length > 0) {
311
+ parts.push(''); // blank line between sections
312
+ }
313
+ renderSection('Pitfalls', block.pitfalls);
314
+ if (parts.length === 0) {
315
+ // Both sections empty: produce a single blank line so the markers don't sit on the same row.
316
+ return `${eol}${eol}`;
317
+ }
318
+ // Surround with leading/trailing newlines so markers are on their own lines.
319
+ return `${eol}${parts.join(eol)}${eol}`;
320
+ }
321
+
322
+ // -------- Top-level parser --------
323
+
324
+ // @cap-todo(ac:F-076/AC-7) parseFeatureMemoryFile preserves the manual block as a literal string slice so a
325
+ // subsequent serializeFeatureMemoryFile() round-trips byte-identical when only the auto-block was mutated.
326
+ /**
327
+ * Parse a feature memory file's content into structured form.
328
+ * Pure function — does NO file IO.
329
+ * @param {string} content
330
+ * @returns {FeatureMemoryFile}
331
+ */
332
+ function parseFeatureMemoryFile(content) {
333
+ if (typeof content !== 'string') {
334
+ throw new TypeError('parseFeatureMemoryFile: content must be a string');
335
+ }
336
+ // 1. Strip optional BOM, but remember it so serialization can restore it.
337
+ let bom = '';
338
+ let body = content;
339
+ if (body.charCodeAt(0) === 0xfeff) {
340
+ bom = '\uFEFF';
341
+ body = body.slice(1);
342
+ }
343
+ // 2. Locate front-matter.
344
+ const [yamlBody, fmEndIndex] = locateFrontMatter(bom + body);
345
+ let frontmatter = /** @type {FrontMatter} */ ({});
346
+ let afterFm;
347
+ let fmLiteral;
348
+ if (yamlBody !== null) {
349
+ frontmatter = /** @type {FrontMatter} */ (parseSimpleYaml(yamlBody));
350
+ fmLiteral = (bom + body).slice(0, fmEndIndex);
351
+ afterFm = (bom + body).slice(fmEndIndex);
352
+ } else {
353
+ fmLiteral = bom; // no front-matter at all
354
+ afterFm = body;
355
+ }
356
+ // 3. Locate auto-block within the post-front-matter region.
357
+ const autoLoc = locateAutoBlock(afterFm);
358
+ /** @type {AutoBlock} */
359
+ let autoBlock = { decisions: [], pitfalls: [] };
360
+ /** @type {string} */
361
+ let preAuto = afterFm;
362
+ /** @type {string} */
363
+ let postAuto = '';
364
+ /** @type {string|null} */
365
+ let autoLiteral = null;
366
+ if (autoLoc) {
367
+ autoBlock = parseAutoBlockBody(autoLoc.body);
368
+ preAuto = afterFm.slice(0, autoLoc.startIdx);
369
+ autoLiteral = afterFm.slice(autoLoc.startIdx, autoLoc.endIdx);
370
+ postAuto = afterFm.slice(autoLoc.endIdx);
371
+ }
372
+ // 4. Manual block = everything except the auto-block (preAuto + postAuto). Captured as a literal slice for AC-7.
373
+ /** @type {ManualBlock} */
374
+ const manualBlock = { raw: preAuto + postAuto };
375
+
376
+ // 5. Extract title (H1) for convenience, scanning the pre-auto manual region.
377
+ let title;
378
+ const h1Match = preAuto.match(/^#\s+(.+?)\s*$/m);
379
+ if (h1Match) title = h1Match[1];
380
+
381
+ // Internal: stash the literal slices on the result so serialize can round-trip exactly.
382
+ const out = /** @type {FeatureMemoryFile} */ ({
383
+ frontmatter,
384
+ autoBlock,
385
+ manualBlock,
386
+ title,
387
+ });
388
+ // Hidden round-trip metadata (non-enumerable so it doesn't pollute deepEqual checks unexpectedly,
389
+ // but reachable for serialize()).
390
+ Object.defineProperty(out, '__roundTrip', {
391
+ value: { fmLiteral, preAuto, autoLiteral, postAuto, hadAutoBlock: !!autoLoc, hadFrontMatter: yamlBody !== null, bom },
392
+ enumerable: false,
393
+ writable: false,
394
+ configurable: false,
395
+ });
396
+ return out;
397
+ }
398
+
399
+ // -------- Top-level serializer --------
400
+
401
+ // @cap-todo(ac:F-076/AC-7) serializeFeatureMemoryFile is the inverse of parse: round-trip-safe when round-trip
402
+ // metadata is present; otherwise it produces a canonical rendering.
403
+ /**
404
+ * Serialize a parsed FeatureMemoryFile back to text.
405
+ *
406
+ * If `file` was produced by parseFeatureMemoryFile() and only the autoBlock has changed, the manual block
407
+ * is restored byte-identical (round-trip invariant, AC-7). Modifying frontmatter or manualBlock.raw will
408
+ * produce a fresh canonical rendering for those parts but still preserve the unchanged sections verbatim.
409
+ *
410
+ * @param {FeatureMemoryFile & { __roundTrip?: any }} file
411
+ * @returns {string}
412
+ */
413
+ function serializeFeatureMemoryFile(file) {
414
+ if (!file || typeof file !== 'object') {
415
+ throw new TypeError('serializeFeatureMemoryFile: file must be an object');
416
+ }
417
+ const rt = file.__roundTrip || null;
418
+ // Detect line-ending convention from round-trip metadata, default to \n.
419
+ const eol = rt && rt.fmLiteral && rt.fmLiteral.includes('\r\n') ? '\r\n' : (rt && rt.preAuto && rt.preAuto.includes('\r\n') ? '\r\n' : '\n');
420
+
421
+ // 1. Front-matter.
422
+ let fmText;
423
+ if (rt && rt.hadFrontMatter && _frontMatterUnchanged(file.frontmatter, rt.fmLiteral)) {
424
+ fmText = rt.fmLiteral; // preserve original byte-for-byte (including BOM if any)
425
+ } else if (Object.keys(file.frontmatter || {}).length > 0) {
426
+ const yaml = serializeFrontMatter(file.frontmatter);
427
+ const bom = rt && rt.bom ? rt.bom : '';
428
+ fmText = `${bom}---${eol}${yaml}${eol}---${eol}`;
429
+ } else {
430
+ fmText = rt && rt.bom ? rt.bom : '';
431
+ }
432
+
433
+ // 2. Manual + auto. If round-trip metadata exists, splice exactly.
434
+ if (rt) {
435
+ // @cap-decision(F-076/D7) When the parsed source had NO auto-block AND the in-memory autoBlock
436
+ // is empty, do not inject markers on serialize. AC-7 byte-identity must hold for
437
+ // empty-auto-block inputs too — F-077 migration will validate files that may not
438
+ // yet have markers, and adding spurious markers would break round-trip on a clean
439
+ // pre-V6 file or on a freshly-stubbed front-matter-only file.
440
+ const autoIsEmpty = !file.autoBlock
441
+ || ((file.autoBlock.decisions || []).length === 0
442
+ && (file.autoBlock.pitfalls || []).length === 0);
443
+ if (!rt.hadAutoBlock && autoIsEmpty) {
444
+ return fmText + (file.manualBlock ? file.manualBlock.raw : '');
445
+ }
446
+ const autoText = _autoBlockUnchangedLiteral(file, rt) ?? renderAutoBlock(file.autoBlock, eol);
447
+ const preAuto = (file.manualBlock && _manualUnchanged(file, rt))
448
+ ? rt.preAuto
449
+ : _splitManualPre(file.manualBlock.raw, rt);
450
+ const postAuto = (file.manualBlock && _manualUnchanged(file, rt))
451
+ ? rt.postAuto
452
+ : _splitManualPost(file.manualBlock.raw, rt);
453
+ if (rt.hadAutoBlock || file.autoBlock) {
454
+ return fmText + preAuto + autoText + postAuto;
455
+ }
456
+ return fmText + (file.manualBlock ? file.manualBlock.raw : '');
457
+ }
458
+ // 3. No round-trip metadata: canonical render.
459
+ const manual = file.manualBlock ? file.manualBlock.raw : '';
460
+ const autoText = renderAutoBlock(file.autoBlock || { decisions: [], pitfalls: [] }, eol);
461
+ // Insert auto block after H1 if present, else at the start of the manual region.
462
+ const h1Idx = manual.search(/^#\s+/m);
463
+ if (h1Idx !== -1) {
464
+ const afterH1 = manual.indexOf('\n', h1Idx);
465
+ const insertAt = afterH1 === -1 ? manual.length : afterH1 + 1;
466
+ return fmText + manual.slice(0, insertAt) + eol + autoText + manual.slice(insertAt);
467
+ }
468
+ return fmText + autoText + manual;
469
+ }
470
+
471
+ /**
472
+ * @param {AutoBlock} block
473
+ * @param {string} eol
474
+ * @returns {string}
475
+ */
476
+ function renderAutoBlock(block, eol) {
477
+ const body = renderAutoBlockBody(block, { eol });
478
+ return `${AUTO_BLOCK_START_MARKER}${body}${AUTO_BLOCK_END_MARKER}`;
479
+ }
480
+
481
+ /**
482
+ * Heuristic: is the parsed front-matter object structurally equal to what would be parsed from the
483
+ * literal slice? If yes, we can preserve the literal byte-for-byte.
484
+ * @param {FrontMatter} fm
485
+ * @param {string} literal
486
+ */
487
+ function _frontMatterUnchanged(fm, literal) {
488
+ if (!literal) return false;
489
+ const [yaml] = locateFrontMatter(literal);
490
+ if (yaml === null) return false;
491
+ const reparsed = parseSimpleYaml(yaml);
492
+ return _shallowEqual(fm, reparsed);
493
+ }
494
+
495
+ /** @param {AutoBlock} a @param {AutoBlock} b */
496
+ function _autoBlockEqual(a, b) {
497
+ if (!a || !b) return a === b;
498
+ if ((a.decisions || []).length !== (b.decisions || []).length) return false;
499
+ if ((a.pitfalls || []).length !== (b.pitfalls || []).length) return false;
500
+ for (let i = 0; i < a.decisions.length; i++) {
501
+ if (a.decisions[i].text !== b.decisions[i].text || a.decisions[i].location !== b.decisions[i].location) return false;
502
+ }
503
+ for (let i = 0; i < a.pitfalls.length; i++) {
504
+ if (a.pitfalls[i].text !== b.pitfalls[i].text || a.pitfalls[i].location !== b.pitfalls[i].location) return false;
505
+ }
506
+ return true;
507
+ }
508
+
509
+ /**
510
+ * If the auto-block is unchanged from the parsed original, return the literal slice; otherwise null.
511
+ */
512
+ function _autoBlockUnchangedLiteral(file, rt) {
513
+ if (!rt.hadAutoBlock || !rt.autoLiteral) return null;
514
+ const reparsedLoc = locateAutoBlock(rt.autoLiteral);
515
+ if (!reparsedLoc) return null;
516
+ const reparsed = parseAutoBlockBody(reparsedLoc.body);
517
+ return _autoBlockEqual(file.autoBlock, reparsed) ? rt.autoLiteral : null;
518
+ }
519
+
520
+ function _manualUnchanged(file, rt) {
521
+ return file.manualBlock && file.manualBlock.raw === (rt.preAuto + rt.postAuto);
522
+ }
523
+
524
+ function _splitManualPre(raw, rt) {
525
+ // Prefer the original split point if the prefix matches.
526
+ if (rt.preAuto && raw.startsWith(rt.preAuto)) return rt.preAuto;
527
+ return raw; // degraded: dump everything as pre, post will be empty
528
+ }
529
+ function _splitManualPost(raw, rt) {
530
+ if (rt.preAuto && raw.startsWith(rt.preAuto)) return raw.slice(rt.preAuto.length);
531
+ return '';
532
+ }
533
+
534
+ function _shallowEqual(a, b) {
535
+ if (a === b) return true;
536
+ if (!a || !b) return false;
537
+ const ka = Object.keys(a);
538
+ const kb = Object.keys(b);
539
+ if (ka.length !== kb.length) return false;
540
+ for (const k of ka) {
541
+ if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
542
+ const va = a[k];
543
+ const vb = b[k];
544
+ if (Array.isArray(va) && Array.isArray(vb)) {
545
+ if (va.length !== vb.length) return false;
546
+ for (let i = 0; i < va.length; i++) if (va[i] !== vb[i]) return false;
547
+ } else if (va !== vb) {
548
+ return false;
549
+ }
550
+ }
551
+ return true;
552
+ }
553
+
554
+ // -------- Validation --------
555
+
556
+ // @cap-todo(ac:F-076/AC-5) validateFeatureMemoryFile accepts either a path or a content string and returns
557
+ // a ValidationResult shape (valid + errors[] + warnings[]).
558
+ /**
559
+ * Validate a feature memory file. Accepts either a filesystem path (read via fs) or a content string directly.
560
+ *
561
+ * Heuristic: if the input contains a newline OR begins with `---` / `<!--` / `#`, treat as content; otherwise
562
+ * treat as a filesystem path. This is unambiguous in practice (paths don't contain newlines or markdown sigils).
563
+ *
564
+ * @param {string} pathOrContent
565
+ * @returns {ValidationResult}
566
+ */
567
+ function validateFeatureMemoryFile(pathOrContent) {
568
+ /** @type {ValidationResult} */
569
+ const result = { valid: true, errors: [], warnings: [] };
570
+ if (typeof pathOrContent !== 'string') {
571
+ result.valid = false;
572
+ result.errors.push('input must be a string (path or content)');
573
+ return result;
574
+ }
575
+ let content;
576
+ if (_looksLikeContent(pathOrContent)) {
577
+ content = pathOrContent;
578
+ } else {
579
+ try {
580
+ content = fs.readFileSync(pathOrContent, 'utf8');
581
+ } catch (err) {
582
+ result.valid = false;
583
+ result.errors.push(`failed to read file: ${err && err.message ? err.message : String(err)}`);
584
+ return result;
585
+ }
586
+ }
587
+ return _validateContent(content, result);
588
+ }
589
+
590
+ /**
591
+ * @param {string} s
592
+ */
593
+ function _looksLikeContent(s) {
594
+ if (s.length === 0) return true; // empty string -> treat as content (will fail validation cleanly)
595
+ if (s.includes('\n')) return true;
596
+ if (s.startsWith('\uFEFF---')) return true;
597
+ if (s.startsWith('---')) return true;
598
+ if (s.startsWith('<!--')) return true;
599
+ if (s.startsWith('#')) return true;
600
+ return false;
601
+ }
602
+
603
+ /**
604
+ * @param {string} content
605
+ * @param {ValidationResult} result
606
+ * @returns {ValidationResult}
607
+ */
608
+ function _validateContent(content, result) {
609
+ // 1. Front-matter required.
610
+ const [yamlBody] = locateFrontMatter(content);
611
+ if (yamlBody === null) {
612
+ result.valid = false;
613
+ result.errors.push('missing front-matter block (must start with `---` ... `---`)');
614
+ return result;
615
+ }
616
+ const fm = parseSimpleYaml(yamlBody);
617
+
618
+ // 2. Required fields.
619
+ if (!fm.feature || typeof fm.feature !== 'string') {
620
+ result.valid = false;
621
+ result.errors.push('front-matter: `feature` is required');
622
+ } else if (!FEATURE_ID_RE.test(fm.feature)) {
623
+ result.valid = false;
624
+ // @cap-todo(ac:F-081/AC-5) Error message reflects union ID format — F-NNN | F-LONGFORM.
625
+ result.errors.push(`front-matter: \`feature\` must match /^F-(?:\\d{3,}|[A-Z][A-Z0-9_-]*)$/ (got "${fm.feature}")`);
626
+ }
627
+ if (!fm.topic || typeof fm.topic !== 'string') {
628
+ result.valid = false;
629
+ result.errors.push('front-matter: `topic` is required');
630
+ } else if (!TOPIC_RE.test(fm.topic)) {
631
+ result.valid = false;
632
+ result.errors.push(`front-matter: \`topic\` must be kebab-case (got "${fm.topic}")`);
633
+ }
634
+ if (!fm.updated || typeof fm.updated !== 'string') {
635
+ result.valid = false;
636
+ result.errors.push('front-matter: `updated` is required');
637
+ } else if (!ISO8601_RE.test(fm.updated)) {
638
+ result.valid = false;
639
+ result.errors.push(`front-matter: \`updated\` must be ISO 8601 (got "${fm.updated}")`);
640
+ } else {
641
+ const updatedAt = new Date(fm.updated).getTime();
642
+ if (!Number.isNaN(updatedAt)) {
643
+ const ageDays = (Date.now() - updatedAt) / (1000 * 60 * 60 * 24);
644
+ if (ageDays > 30) {
645
+ result.warnings.push(`front-matter: \`updated\` is ${Math.round(ageDays)} days old (> 30 day staleness threshold)`);
646
+ }
647
+ }
648
+ }
649
+
650
+ // 3. Optional fields.
651
+ if (fm.related_features !== undefined) {
652
+ if (!Array.isArray(fm.related_features)) {
653
+ result.valid = false;
654
+ result.errors.push('front-matter: `related_features` must be an array');
655
+ } else {
656
+ for (const id of fm.related_features) {
657
+ if (!FEATURE_ID_RE.test(id)) {
658
+ result.valid = false;
659
+ result.errors.push(`front-matter: \`related_features\` contains invalid id "${id}"`);
660
+ }
661
+ }
662
+ }
663
+ }
664
+ if (fm.key_files !== undefined) {
665
+ if (!Array.isArray(fm.key_files)) {
666
+ result.valid = false;
667
+ result.errors.push('front-matter: `key_files` must be an array');
668
+ } else {
669
+ for (const f of fm.key_files) {
670
+ if (typeof f !== 'string' || f.length === 0) {
671
+ result.valid = false;
672
+ result.errors.push(`front-matter: \`key_files\` contains non-string entry`);
673
+ }
674
+ }
675
+ }
676
+ }
677
+ if (fm.extends !== undefined && fm.extends !== '') {
678
+ if (typeof fm.extends !== 'string' || !EXTENDS_RE.test(fm.extends)) {
679
+ result.valid = false;
680
+ result.errors.push(`front-matter: \`extends\` must match "platform/<topic>" (got "${fm.extends}")`);
681
+ }
682
+ }
683
+
684
+ // 4. Auto-block markers.
685
+ // @cap-risk Marker uniqueness is critical: a duplicated start marker would let a parser silently nest
686
+ // garbage in the wrong block. We count occurrences explicitly and require exactly one of each.
687
+ // @cap-decision(F-076/D6) Counting on full-line matches (not raw substring) so a memory file that
688
+ // legitimately mentions the marker text in a Lessons section ("the format uses
689
+ // <!-- cap:auto:start --> ...") doesn't fail validation. The parser already uses
690
+ // indexOf on the first occurrence; the validator must agree on what "marker"
691
+ // means. F-076 is THE feature documenting the marker format — a meta-test would
692
+ // otherwise wedge.
693
+ const startCount = _countMarkerLines(content, AUTO_BLOCK_START_MARKER);
694
+ const endCount = _countMarkerLines(content, AUTO_BLOCK_END_MARKER);
695
+ if (startCount === 0 && endCount === 0) {
696
+ result.valid = false;
697
+ result.errors.push(`auto-block markers missing (expected exactly one ${AUTO_BLOCK_START_MARKER} and one ${AUTO_BLOCK_END_MARKER})`);
698
+ } else {
699
+ if (startCount !== 1) {
700
+ result.valid = false;
701
+ result.errors.push(`auto-block: expected exactly one ${AUTO_BLOCK_START_MARKER}, found ${startCount}`);
702
+ }
703
+ if (endCount !== 1) {
704
+ result.valid = false;
705
+ result.errors.push(`auto-block: expected exactly one ${AUTO_BLOCK_END_MARKER}, found ${endCount}`);
706
+ }
707
+ if (startCount === 1 && endCount === 1) {
708
+ const startIdx = content.indexOf(AUTO_BLOCK_START_MARKER);
709
+ const endIdx = content.indexOf(AUTO_BLOCK_END_MARKER);
710
+ if (endIdx < startIdx) {
711
+ result.valid = false;
712
+ result.errors.push('auto-block: end marker appears before start marker');
713
+ }
714
+ // Ensure markers are on their own lines (no other non-whitespace content on the marker line).
715
+ _validateMarkerLine(content, startIdx, AUTO_BLOCK_START_MARKER, result);
716
+ _validateMarkerLine(content, endIdx, AUTO_BLOCK_END_MARKER, result);
717
+ }
718
+ }
719
+
720
+ return result;
721
+ }
722
+
723
+ /**
724
+ * @param {string} content
725
+ * @param {number} idx - byte index of the marker
726
+ * @param {string} marker
727
+ * @param {ValidationResult} result
728
+ */
729
+ function _validateMarkerLine(content, idx, marker, result) {
730
+ // Find the start of the line containing idx.
731
+ let lineStart = content.lastIndexOf('\n', idx - 1) + 1;
732
+ let lineEnd = content.indexOf('\n', idx);
733
+ if (lineEnd === -1) lineEnd = content.length;
734
+ const line = content.slice(lineStart, lineEnd).replace(/\r$/, '');
735
+ const trimmed = line.replace(/^\s+|\s+$/g, '');
736
+ if (trimmed !== marker) {
737
+ result.valid = false;
738
+ result.errors.push(`auto-block: marker line must contain only the marker (got "${trimmed}")`);
739
+ }
740
+ }
741
+
742
+ /**
743
+ * @param {string} haystack
744
+ * @param {string} needle
745
+ */
746
+ function _countOccurrences(haystack, needle) {
747
+ let count = 0;
748
+ let idx = 0;
749
+ while ((idx = haystack.indexOf(needle, idx)) !== -1) {
750
+ count++;
751
+ idx += needle.length;
752
+ }
753
+ return count;
754
+ }
755
+
756
+ /**
757
+ * Count lines whose trimmed content equals `marker` exactly. Mirrors `_validateMarkerLine`
758
+ * semantics so the validator and parser agree on what counts as a marker (only marker-only
759
+ * lines, not in-prose mentions).
760
+ * @param {string} content
761
+ * @param {string} marker
762
+ * @returns {number}
763
+ */
764
+ function _countMarkerLines(content, marker) {
765
+ let count = 0;
766
+ for (const line of content.split(/\r?\n/)) {
767
+ const trimmed = line.replace(/^\s+|\s+$/g, '');
768
+ if (trimmed === marker) count++;
769
+ }
770
+ return count;
771
+ }
772
+
773
+ // -------- Path helper --------
774
+
775
+ // @cap-todo(ac:F-076/AC-1) getFeaturePath returns the canonical .cap/memory/features/F-NNN-<topic>.md path.
776
+ /**
777
+ * @param {string} projectRoot
778
+ * @param {string} featureId
779
+ * @param {string} topic
780
+ * @returns {string}
781
+ */
782
+ function getFeaturePath(projectRoot, featureId, topic) {
783
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
784
+ throw new TypeError('getFeaturePath: projectRoot must be a non-empty string');
785
+ }
786
+ if (!FEATURE_ID_RE.test(featureId)) {
787
+ // @cap-todo(ac:F-081/AC-5) Error message reflects union ID format — F-NNN | F-LONGFORM.
788
+ throw new TypeError(`getFeaturePath: featureId must match /^F-(?:\\d{3,}|[A-Z][A-Z0-9_-]*)$/ (got "${featureId}")`);
789
+ }
790
+ if (!TOPIC_RE.test(topic)) {
791
+ throw new TypeError(`getFeaturePath: topic must be kebab-case (got "${topic}")`);
792
+ }
793
+ return path.join(projectRoot, MEMORY_FEATURES_DIR, `${featureId}-${topic}.md`);
794
+ }
795
+
796
+ // -------- Exports --------
797
+
798
+ module.exports = {
799
+ // public API
800
+ parseFeatureMemoryFile,
801
+ serializeFeatureMemoryFile,
802
+ validateFeatureMemoryFile,
803
+ getFeaturePath,
804
+ // constants
805
+ AUTO_BLOCK_START_MARKER,
806
+ AUTO_BLOCK_END_MARKER,
807
+ MEMORY_FEATURES_DIR,
808
+ FEATURE_ID_RE,
809
+ TOPIC_RE,
810
+ EXTENDS_RE,
811
+ ISO8601_RE,
812
+ };