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,183 @@
1
+ 'use strict';
2
+
3
+ // @cap-feature(feature:F-030, primary:true) Pin/unpin management for @cap-pitfall annotations.
4
+ // Provides the write side of /cap:memory pin / /cap:memory unpin so users can flag a pitfall
5
+ // as "pinned" (exempt from F-027 aging) or clear that flag.
6
+ //
7
+ // @cap-decision Pure string manipulation — finds the matching @cap-pitfall line by content
8
+ // prefix and rewrites its metadata block in place. No whole-file parsing, no AST, no
9
+ // re-annotation pass. This keeps the blast radius of a pin operation to a single line edit
10
+ // and preserves all surrounding context and comments.
11
+ // @cap-decision Match policy: the first @cap-pitfall line whose description STARTS with the
12
+ // user-supplied prefix (trimmed, case-sensitive) wins. Ambiguous prefixes return a
13
+ // multi-match result so the caller can disambiguate rather than guess.
14
+
15
+ const fs = require('node:fs');
16
+
17
+ // Matches a line carrying a @cap-pitfall annotation. Captures:
18
+ // [1] leading comment prefix + whitespace (e.g. '// ', '# ')
19
+ // [2] metadata block INCLUDING the surrounding parentheses, or '' if none
20
+ // [3] inner metadata content (without parens), or '' if no parens
21
+ // [4] description (trailing text after the annotation)
22
+ const PITFALL_LINE_RE = /^(\s*(?:\/\/|#|--|;|%)\s*)@cap-pitfall(\(([^)]*)\))?\s*(.*)$/;
23
+
24
+ /**
25
+ * @typedef {Object} PinResult
26
+ * @property {boolean} changed - True when the file was rewritten
27
+ * @property {('pinned'|'unpinned'|'already-pinned'|'not-pinned'|'not-found'|'ambiguous'|'read-error')} status
28
+ * @property {string|null} file - Absolute file path that was acted on (null on read-error)
29
+ * @property {number|null} line - 1-based line number of the modified annotation (null when not-found/ambiguous)
30
+ * @property {string|null} description - The full description line of the matched pitfall (for display)
31
+ * @property {string[]} candidates - When ambiguous: list of candidate descriptions to help the user pick
32
+ */
33
+
34
+ /**
35
+ * Toggle the `pinned:true` flag on the first @cap-pitfall annotation whose description
36
+ * starts with `contentPrefix` (trimmed, case-sensitive).
37
+ *
38
+ * @param {string} filePath - Absolute path to the source file
39
+ * @param {string} contentPrefix - User-supplied prefix of the pitfall description to match
40
+ * @param {{ pin: boolean, dryRun?: boolean }} opts
41
+ * @returns {PinResult}
42
+ */
43
+ function pinAnnotation(filePath, contentPrefix, opts) {
44
+ const shouldPin = !!(opts && opts.pin);
45
+ const dryRun = !!(opts && opts.dryRun);
46
+
47
+ /** @type {PinResult} */
48
+ const result = {
49
+ changed: false,
50
+ status: 'not-found',
51
+ file: filePath,
52
+ line: null,
53
+ description: null,
54
+ candidates: [],
55
+ };
56
+
57
+ let content;
58
+ try {
59
+ content = fs.readFileSync(filePath, 'utf8');
60
+ } catch (_e) {
61
+ result.status = 'read-error';
62
+ result.file = null;
63
+ return result;
64
+ }
65
+
66
+ const lines = content.split('\n');
67
+ const prefix = String(contentPrefix || '').trim();
68
+
69
+ // Find all pitfall candidates whose description starts with prefix.
70
+ const matches = [];
71
+ for (let i = 0; i < lines.length; i++) {
72
+ const m = lines[i].match(PITFALL_LINE_RE);
73
+ if (!m) continue;
74
+ const description = (m[4] || '').trim();
75
+ if (description.startsWith(prefix)) {
76
+ matches.push({ lineIndex: i, match: m, description });
77
+ }
78
+ }
79
+
80
+ if (matches.length === 0) return result;
81
+
82
+ if (matches.length > 1) {
83
+ result.status = 'ambiguous';
84
+ result.candidates = matches.map((m) => m.description);
85
+ return result;
86
+ }
87
+
88
+ const { lineIndex, match, description } = matches[0];
89
+ const leading = match[1];
90
+ const metaInner = (match[3] || '').trim();
91
+
92
+ // Parse the existing metadata tokens. Tokens are comma-separated key:value pairs.
93
+ const tokens = metaInner.length === 0
94
+ ? []
95
+ : metaInner.split(',').map((s) => s.trim()).filter(Boolean);
96
+ const hasPinned = tokens.some((t) => /^pinned\s*:\s*true$/.test(t));
97
+
98
+ if (shouldPin && hasPinned) {
99
+ result.status = 'already-pinned';
100
+ result.line = lineIndex + 1;
101
+ result.description = description;
102
+ return result;
103
+ }
104
+ if (!shouldPin && !hasPinned) {
105
+ result.status = 'not-pinned';
106
+ result.line = lineIndex + 1;
107
+ result.description = description;
108
+ return result;
109
+ }
110
+
111
+ const newTokens = shouldPin
112
+ ? [...tokens, 'pinned:true']
113
+ : tokens.filter((t) => !/^pinned\s*:\s*true$/.test(t));
114
+
115
+ const newMeta = newTokens.length === 0 ? '' : `(${newTokens.join(', ')})`;
116
+ const trailing = description.length === 0 ? '' : ` ${description}`;
117
+ const rewritten = `${leading}@cap-pitfall${newMeta}${trailing}`;
118
+
119
+ lines[lineIndex] = rewritten;
120
+ if (!dryRun) fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
121
+
122
+ result.changed = true;
123
+ result.status = shouldPin ? 'pinned' : 'unpinned';
124
+ result.line = lineIndex + 1;
125
+ result.description = description;
126
+ return result;
127
+ }
128
+
129
+ /**
130
+ * Convenience wrapper for pinning.
131
+ * @param {string} filePath
132
+ * @param {string} contentPrefix
133
+ * @param {{dryRun?: boolean}} [opts]
134
+ * @returns {PinResult}
135
+ */
136
+ function pin(filePath, contentPrefix, opts) {
137
+ return pinAnnotation(filePath, contentPrefix, { ...(opts || {}), pin: true });
138
+ }
139
+
140
+ /**
141
+ * Convenience wrapper for unpinning.
142
+ * @param {string} filePath
143
+ * @param {string} contentPrefix
144
+ * @param {{dryRun?: boolean}} [opts]
145
+ * @returns {PinResult}
146
+ */
147
+ function unpin(filePath, contentPrefix, opts) {
148
+ return pinAnnotation(filePath, contentPrefix, { ...(opts || {}), pin: false });
149
+ }
150
+
151
+ /**
152
+ * Format a PinResult as a single-line status string for the CLI surface.
153
+ * @param {PinResult} result
154
+ * @returns {string}
155
+ */
156
+ function formatResult(result) {
157
+ switch (result.status) {
158
+ case 'pinned':
159
+ return `pinned ${result.file}:${result.line} — "${result.description}"`;
160
+ case 'unpinned':
161
+ return `unpinned ${result.file}:${result.line} — "${result.description}"`;
162
+ case 'already-pinned':
163
+ return `no change: already pinned at ${result.file}:${result.line}`;
164
+ case 'not-pinned':
165
+ return `no change: annotation was not pinned at ${result.file}:${result.line}`;
166
+ case 'not-found':
167
+ return `no @cap-pitfall annotation matching prefix found in ${result.file}`;
168
+ case 'ambiguous':
169
+ return `ambiguous prefix — multiple pitfall annotations matched:\n ${result.candidates.join('\n ')}`;
170
+ case 'read-error':
171
+ return `could not read file`;
172
+ default:
173
+ return `unknown status: ${result.status}`;
174
+ }
175
+ }
176
+
177
+ module.exports = {
178
+ pinAnnotation,
179
+ pin,
180
+ unpin,
181
+ formatResult,
182
+ PITFALL_LINE_RE,
183
+ };
@@ -0,0 +1,490 @@
1
+ // @cap-feature(feature:F-078, primary:true) Platform-Bucket for Cross-Cutting Decisions —
2
+ // .cap/memory/platform/<topic>.md and .cap/memory/platform/checklists/<subsystem>.md
3
+ //
4
+ // @cap-context This module owns explicit-only platform-bucket file IO. Per AC-2, platform
5
+ // promotion is NEVER automatic from per-feature files: a decision lands here only when it
6
+ // carries a `@cap-decision(platform:<topic>)` tag. The classifier helper for that promotion
7
+ // rule lives next to the file IO so the contract is locked in one place.
8
+ //
9
+ // @cap-context F-077's cap-memory-migrate.cjs already writes simplified platform files via
10
+ // renderPlannedWrite. F-078 layers a stricter schema (auto/manual split matching F-076)
11
+ // and a read API for the resolution path that F-079/F-080 depend on. The migrator continues
12
+ // to own the *write* path during migration; F-078 owns read + classifier + checklist.
13
+ //
14
+ // @cap-decision(F-078/AC-1) Platform topic files reuse F-076's auto-block markers
15
+ // (cap:auto:start/end) so the F-076 parser/serializer round-trips them byte-identical.
16
+ // Alternatives considered: a separate marker pair (cap:platform:start) — rejected because
17
+ // every downstream consumer would have to learn two formats. Single marker contract = single
18
+ // failure surface.
19
+
20
+ 'use strict';
21
+
22
+ const fs = require('node:fs');
23
+ const path = require('node:path');
24
+
25
+ const schema = require('./cap-memory-schema.cjs');
26
+
27
+ // -------- Constants --------
28
+
29
+ // @cap-decision(F-078/D1) Platform tree layout is fixed under .cap/memory/platform/.
30
+ // Topic files live at the tree root; subsystem checklists live one level deeper. This
31
+ // separation is structural, not just naming — `listPlatformTopics` filters out the
32
+ // checklists subdir so a checklist is never mistaken for a topic.
33
+ const MEMORY_PLATFORM_DIR = path.join('.cap', 'memory', 'platform');
34
+ const MEMORY_PLATFORM_CHECKLISTS_DIR = path.join('.cap', 'memory', 'platform', 'checklists');
35
+
36
+ // @cap-decision(F-078/D2) Slug regex matches F-076's TOPIC_RE shape (kebab-case alphanumerics)
37
+ // but is re-defined locally so a future divergence between feature topics and platform topics
38
+ // doesn't silently couple. Both currently use the SAME shape; if that changes, update one,
39
+ // not both.
40
+ const PLATFORM_TOPIC_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
41
+
42
+ // @cap-decision(F-078/D3) Subsystem slug matches the same kebab-case shape. Subsystem names
43
+ // are derived from module/folder names (e.g. "memory", "tag-scanner") and that's the same
44
+ // alphabet feature topics use.
45
+ const PLATFORM_SUBSYSTEM_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
46
+
47
+ // @cap-risk(F-078) Path traversal: `topic` and `subsystem` end up concatenated into a
48
+ // filesystem path. If they ever contain `..` or `/`, an attacker (or a buggy classifier)
49
+ // could write outside the platform tree. The slug regex EXCLUDES both characters, but we
50
+ // double-check explicitly in _validateSlug() because a lone regex without an anchor check
51
+ // has historically been a foot-gun (cf. F-074/D8 path-traversal lesson).
52
+ function _validateSlug(value, kind, regex) {
53
+ if (typeof value !== 'string' || value.length === 0) {
54
+ throw new TypeError(`${kind} must be a non-empty string (got ${typeof value})`);
55
+ }
56
+ // Defense-in-depth: reject path-traversal sigils even if the regex would already catch them.
57
+ if (value.includes('/') || value.includes('\\') || value.includes('..') || value.includes('\0')) {
58
+ throw new TypeError(`${kind} must not contain path separators or traversal sequences (got "${_safeForError(value)}")`);
59
+ }
60
+ if (!regex.test(value)) {
61
+ throw new TypeError(`${kind} must be kebab-case slug matching ${regex} (got "${_safeForError(value)}")`);
62
+ }
63
+ }
64
+
65
+ // @cap-decision(F-078/D4) ANSI/control-byte sanitization for error messages: console.warn /
66
+ // thrown error messages embed the rejected slug. If a malicious input contains ANSI escape
67
+ // codes or backspace bytes, a developer reading logs could be visually misled. Strip
68
+ // non-printable bytes when echoing the value, but keep the raw value out of any actual
69
+ // filesystem path (the validator throws before that point anyway).
70
+ function _safeForError(value) {
71
+ if (typeof value !== 'string') return String(value);
72
+ // Replace any byte outside printable ASCII (excluding DEL) with `?`.
73
+ return value.replace(/[^\x20-\x7E]/g, '?').slice(0, 64);
74
+ }
75
+
76
+ // -------- Path helpers --------
77
+
78
+ // @cap-todo(ac:F-078/AC-1) getPlatformTopicPath builds the canonical .cap/memory/platform/<topic>.md path.
79
+ /**
80
+ * @param {string} projectRoot
81
+ * @param {string} topic
82
+ * @returns {string}
83
+ */
84
+ function getPlatformTopicPath(projectRoot, topic) {
85
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
86
+ throw new TypeError('projectRoot must be a non-empty string');
87
+ }
88
+ _validateSlug(topic, 'topic', PLATFORM_TOPIC_RE);
89
+ return path.join(projectRoot, MEMORY_PLATFORM_DIR, `${topic}.md`);
90
+ }
91
+
92
+ // @cap-todo(ac:F-078/AC-4) getChecklistPath builds the canonical .cap/memory/platform/checklists/<subsystem>.md path.
93
+ /**
94
+ * @param {string} projectRoot
95
+ * @param {string} subsystem
96
+ * @returns {string}
97
+ */
98
+ function getChecklistPath(projectRoot, subsystem) {
99
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
100
+ throw new TypeError('projectRoot must be a non-empty string');
101
+ }
102
+ _validateSlug(subsystem, 'subsystem', PLATFORM_SUBSYSTEM_RE);
103
+ return path.join(projectRoot, MEMORY_PLATFORM_CHECKLISTS_DIR, `${subsystem}.md`);
104
+ }
105
+
106
+ // -------- Read API --------
107
+
108
+ // @cap-todo(ac:F-078/AC-1) loadPlatformTopic reads a topic file and parses it via the F-076 schema parser.
109
+ // Same auto/manual split as per-feature files (AC-1 contract).
110
+ /**
111
+ * Load a platform-topic file. Returns null if the file does not exist (graceful skip).
112
+ * Parses via the F-076 schema parser so the auto/manual split is consistent with
113
+ * per-feature files.
114
+ *
115
+ * @param {string} projectRoot
116
+ * @param {string} topic
117
+ * @returns {{exists:boolean, path:string, file:import('./cap-memory-schema.cjs').FeatureMemoryFile|null, raw:string|null}}
118
+ */
119
+ function loadPlatformTopic(projectRoot, topic) {
120
+ const fp = getPlatformTopicPath(projectRoot, topic);
121
+ if (!fs.existsSync(fp)) {
122
+ return { exists: false, path: fp, file: null, raw: null };
123
+ }
124
+ const raw = fs.readFileSync(fp, 'utf8');
125
+ // Reuse the F-076 parser. Platform files don't have a `feature:` field — the parser is
126
+ // resilient to that (parseSimpleYaml ignores missing required keys; only validate*()
127
+ // surfaces them as errors). Callers that want strict schema can opt-in via validate().
128
+ const file = schema.parseFeatureMemoryFile(raw);
129
+ return { exists: true, path: fp, file, raw };
130
+ }
131
+
132
+ // @cap-todo(ac:F-078/AC-4) loadChecklist reads a subsystem checklist (manual-only by convention; auto-block optional).
133
+ /**
134
+ * @param {string} projectRoot
135
+ * @param {string} subsystem
136
+ * @returns {{exists:boolean, path:string, file:import('./cap-memory-schema.cjs').FeatureMemoryFile|null, raw:string|null}}
137
+ */
138
+ function loadChecklist(projectRoot, subsystem) {
139
+ const fp = getChecklistPath(projectRoot, subsystem);
140
+ if (!fs.existsSync(fp)) {
141
+ return { exists: false, path: fp, file: null, raw: null };
142
+ }
143
+ const raw = fs.readFileSync(fp, 'utf8');
144
+ const file = schema.parseFeatureMemoryFile(raw);
145
+ return { exists: true, path: fp, file, raw };
146
+ }
147
+
148
+ // @cap-todo(ac:F-078/AC-1) listPlatformTopics enumerates topic slugs (excluding the checklists subdir).
149
+ /**
150
+ * List all platform-topic slugs present in .cap/memory/platform/. Excludes:
151
+ * - the `checklists/` subdirectory
152
+ * - any non-`.md` file
153
+ * - any file whose basename does not pass PLATFORM_TOPIC_RE (defensive — corrupt
154
+ * filenames are skipped silently rather than crashing)
155
+ *
156
+ * @param {string} projectRoot
157
+ * @returns {string[]} sorted list of topic slugs
158
+ */
159
+ function listPlatformTopics(projectRoot) {
160
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
161
+ throw new TypeError('projectRoot must be a non-empty string');
162
+ }
163
+ const dir = path.join(projectRoot, MEMORY_PLATFORM_DIR);
164
+ if (!fs.existsSync(dir)) return [];
165
+ let entries;
166
+ try {
167
+ entries = fs.readdirSync(dir, { withFileTypes: true });
168
+ } catch (_e) {
169
+ return [];
170
+ }
171
+ const topics = [];
172
+ for (const e of entries) {
173
+ if (!e || typeof e.name !== 'string') continue;
174
+ // Skip subdirectories (including `checklists/`).
175
+ if (e.isDirectory && e.isDirectory()) continue;
176
+ if (!e.name.endsWith('.md')) continue;
177
+ const slug = e.name.slice(0, -3); // strip .md
178
+ if (!PLATFORM_TOPIC_RE.test(slug)) continue;
179
+ topics.push(slug);
180
+ }
181
+ topics.sort();
182
+ return topics;
183
+ }
184
+
185
+ // @cap-todo(ac:F-078/AC-4) listChecklists enumerates subsystem slugs from the checklists subdir.
186
+ /**
187
+ * @param {string} projectRoot
188
+ * @returns {string[]} sorted list of subsystem slugs
189
+ */
190
+ function listChecklists(projectRoot) {
191
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
192
+ throw new TypeError('projectRoot must be a non-empty string');
193
+ }
194
+ const dir = path.join(projectRoot, MEMORY_PLATFORM_CHECKLISTS_DIR);
195
+ if (!fs.existsSync(dir)) return [];
196
+ let entries;
197
+ try {
198
+ entries = fs.readdirSync(dir, { withFileTypes: true });
199
+ } catch (_e) {
200
+ return [];
201
+ }
202
+ const out = [];
203
+ for (const e of entries) {
204
+ if (!e || typeof e.name !== 'string') continue;
205
+ if (e.isDirectory && e.isDirectory()) continue;
206
+ if (!e.name.endsWith('.md')) continue;
207
+ const slug = e.name.slice(0, -3);
208
+ if (!PLATFORM_SUBSYSTEM_RE.test(slug)) continue;
209
+ out.push(slug);
210
+ }
211
+ out.sort();
212
+ return out;
213
+ }
214
+
215
+ // -------- Write API --------
216
+
217
+ // @cap-decision(F-078/D5) Atomic write goes through the existing _atomicWriteFile helper from
218
+ // cap-memory-migrate.cjs (tmp + rename pattern, F-074/D8). Importing the helper rather than
219
+ // re-implementing keeps all V6 writes funneling through ONE choke point — if a future bug
220
+ // fix lands there, F-078 inherits it for free. Trade-off: we depend on cap-memory-migrate's
221
+ // public surface. cap-memory-migrate.cjs exports _atomicWriteFile explicitly for this use.
222
+ const { _atomicWriteFile } = require('./cap-memory-migrate.cjs');
223
+
224
+ // @cap-todo(ac:F-078/AC-1) writePlatformTopic atomically writes a topic file, creating parent dirs as needed.
225
+ /**
226
+ * Write a platform-topic file. Returns `{ updated: bool, reason: string }` per F-082's
227
+ * silent-state-update lesson — the caller can tell whether the file was actually changed
228
+ * vs. skipped due to byte-identical no-op.
229
+ *
230
+ * @param {string} projectRoot
231
+ * @param {string} topic
232
+ * @param {string} content - full file content (frontmatter + markers + body)
233
+ * @returns {{updated:boolean, reason:string, path:string}}
234
+ */
235
+ function writePlatformTopic(projectRoot, topic, content) {
236
+ const fp = getPlatformTopicPath(projectRoot, topic);
237
+ if (typeof content !== 'string') {
238
+ throw new TypeError('content must be a string');
239
+ }
240
+ // Idempotency: skip atomic write if existing content is byte-identical. Mirrors
241
+ // cap-memory-migrate.cjs:_writePlannedFile.
242
+ if (fs.existsSync(fp)) {
243
+ try {
244
+ const existing = fs.readFileSync(fp, 'utf8');
245
+ if (existing === content) {
246
+ return { updated: false, reason: 'byte-identical-noop', path: fp };
247
+ }
248
+ } catch (_e) {
249
+ // fallthrough to write
250
+ }
251
+ }
252
+ _atomicWriteFile(fp, content);
253
+ return { updated: true, reason: 'wrote', path: fp };
254
+ }
255
+
256
+ // @cap-todo(ac:F-078/AC-4) writeChecklist atomically writes a subsystem-checklist file.
257
+ /**
258
+ * @param {string} projectRoot
259
+ * @param {string} subsystem
260
+ * @param {string} content
261
+ * @returns {{updated:boolean, reason:string, path:string}}
262
+ */
263
+ function writeChecklist(projectRoot, subsystem, content) {
264
+ const fp = getChecklistPath(projectRoot, subsystem);
265
+ if (typeof content !== 'string') {
266
+ throw new TypeError('content must be a string');
267
+ }
268
+ if (fs.existsSync(fp)) {
269
+ try {
270
+ const existing = fs.readFileSync(fp, 'utf8');
271
+ if (existing === content) {
272
+ return { updated: false, reason: 'byte-identical-noop', path: fp };
273
+ }
274
+ } catch (_e) {
275
+ // fallthrough
276
+ }
277
+ }
278
+ _atomicWriteFile(fp, content);
279
+ return { updated: true, reason: 'wrote', path: fp };
280
+ }
281
+
282
+ // -------- Render helpers --------
283
+
284
+ // @cap-decision(F-078/D6) renderPlatformTopic builds a canonical platform-topic file using
285
+ // F-076's auto-block markers. Used by the classifier promotion path (and tests). Mirrors
286
+ // the shape produced by cap-memory-migrate.cjs:renderPlannedWrite for platform writes, but
287
+ // is exposed as a pure function so F-078 callers don't have to depend on the migrator.
288
+ /**
289
+ * @param {{topic:string, decisions?:Array<{text:string, location?:string}>, pitfalls?:Array<{text:string, location?:string}>, lessons?:string, updated?:string}} input
290
+ * @returns {string}
291
+ */
292
+ function renderPlatformTopic(input) {
293
+ if (!input || typeof input !== 'object') {
294
+ throw new TypeError('renderPlatformTopic: input must be an object');
295
+ }
296
+ _validateSlug(input.topic, 'topic', PLATFORM_TOPIC_RE);
297
+ const updated = input.updated || new Date().toISOString();
298
+ const decisions = (input.decisions || []).map((d) => ({
299
+ text: String(d.text || '').replace(/[\r\n]+/g, ' ').trim(),
300
+ location: String(d.location || '').replace(/[\r\n]+/g, ' ').trim(),
301
+ }));
302
+ const pitfalls = (input.pitfalls || []).map((p) => ({
303
+ text: String(p.text || '').replace(/[\r\n]+/g, ' ').trim(),
304
+ location: String(p.location || '').replace(/[\r\n]+/g, ' ').trim(),
305
+ }));
306
+
307
+ const fmLines = [
308
+ '---',
309
+ `topic: ${input.topic}`,
310
+ `updated: ${updated}`,
311
+ '---',
312
+ ];
313
+
314
+ const titleCase = input.topic.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
315
+
316
+ // @cap-decision(F-078/D7) Empty auto-block: when both decisions and pitfalls are empty, we
317
+ // still emit the marker pair on their own lines (with one blank line between) rather than
318
+ // omitting the auto-block entirely. Reason: the F-076 schema validator accepts empty
319
+ // marker bodies, and downstream re-runs of the migrator will write into the marker pair
320
+ // without needing to re-introduce it. F-076 fixture tests already cover this shape.
321
+ const autoLines = [schema.AUTO_BLOCK_START_MARKER];
322
+ if (decisions.length > 0) {
323
+ autoLines.push('## Decisions (from tags)');
324
+ for (const d of decisions) {
325
+ const loc = d.location ? ` — \`${d.location}\`` : '';
326
+ autoLines.push(`- ${d.text}${loc}`);
327
+ }
328
+ }
329
+ if (pitfalls.length > 0) {
330
+ if (decisions.length > 0) autoLines.push('');
331
+ autoLines.push('## Pitfalls (from tags)');
332
+ for (const p of pitfalls) {
333
+ const loc = p.location ? ` — \`${p.location}\`` : '';
334
+ autoLines.push(`- ${p.text}${loc}`);
335
+ }
336
+ }
337
+ autoLines.push(schema.AUTO_BLOCK_END_MARKER);
338
+
339
+ const lessonsText = (typeof input.lessons === 'string' && input.lessons.trim().length > 0)
340
+ ? input.lessons
341
+ : '<!-- Manual lessons go here. The auto-block above is regenerated by the memory pipeline. -->';
342
+
343
+ const out = [
344
+ fmLines.join('\n'),
345
+ '',
346
+ `# Platform: ${titleCase}`,
347
+ '',
348
+ autoLines.join('\n'),
349
+ '',
350
+ '## Lessons',
351
+ '',
352
+ lessonsText,
353
+ '',
354
+ ].join('\n');
355
+
356
+ return out;
357
+ }
358
+
359
+ // -------- Classifier (AC-2: explicit-only platform promotion) --------
360
+
361
+ // @cap-feature(feature:F-078) classifyDecisionTag — explicit-only platform promotion gate.
362
+ //
363
+ // @cap-todo(ac:F-078/AC-2) classifyDecisionTag routes a single tag to either feature-bucket,
364
+ // platform-bucket, or rejects it. Plain `@cap-decision` (no platform: key) NEVER lands in
365
+ // the platform bucket. A tag with BOTH feature: and platform: keys is REJECTED with a loud
366
+ // parse-error so the author has to pick one.
367
+ //
368
+ // @cap-decision(F-078/AC-2) Explicit-only: there is no fallback heuristic that promotes a
369
+ // per-feature decision into the platform bucket. F-077 had path-heuristik for *unrouted*
370
+ // V5 entries; that's a different problem (orphan classification). Here, the author has
371
+ // explicitly tagged the location and the answer is unambiguous — no guessing.
372
+
373
+ /**
374
+ * @typedef {Object} ClassifierResult
375
+ * @property {'feature'|'platform'|'unassigned'|'error'} destination
376
+ * @property {string|null} featureId - F-NNN if destination === 'feature'
377
+ * @property {string|null} topic - platform topic slug if destination === 'platform'
378
+ * @property {string} reason - human-readable reason
379
+ * @property {string|null} error - error message if destination === 'error'
380
+ */
381
+
382
+ /**
383
+ * Classify a single @cap-decision tag for routing. Pure function — no IO.
384
+ *
385
+ * Routing rules (priority order):
386
+ * 1. Both feature: AND platform: present → ERROR (loud parse-fail). The author must pick one.
387
+ * 2. platform:<topic> present (and slug-valid) → platform-bucket.
388
+ * 3. feature:<F-NNN> present → feature-bucket.
389
+ * 4. Neither present → unassigned (caller's choice — typically falls back to active feature
390
+ * or unassigned platform topic per F-077).
391
+ *
392
+ * @param {{type?:string, metadata?:Object<string,string>, file?:string, line?:number, description?:string}} tag
393
+ * A CapTag-shaped object as emitted by cap-tag-scanner.cjs.
394
+ * @returns {ClassifierResult}
395
+ */
396
+ function classifyDecisionTag(tag) {
397
+ // @cap-risk(F-078) Defensive: a malformed tag object (missing metadata) should not crash
398
+ // the classifier — return an `error` result instead so the caller can log + continue.
399
+ if (!tag || typeof tag !== 'object') {
400
+ return { destination: 'error', featureId: null, topic: null, reason: 'invalid-tag-shape', error: 'tag must be an object' };
401
+ }
402
+ // F-078 only governs @cap-decision tags. Other types pass through as 'unassigned' so the
403
+ // classifier is safe to call from a generic loop without pre-filtering.
404
+ if (tag.type !== 'decision') {
405
+ return { destination: 'unassigned', featureId: null, topic: null, reason: 'not-a-decision-tag', error: null };
406
+ }
407
+ const meta = tag.metadata || Object.create(null);
408
+ // Normalize values defensively. parseMetadata in tag-scanner already strips whitespace,
409
+ // but defense-in-depth is cheap.
410
+ const platformRaw = (typeof meta.platform === 'string' && meta.platform !== 'true') ? meta.platform.trim() : null;
411
+ const featureRaw = (typeof meta.feature === 'string' && meta.feature !== 'true') ? meta.feature.trim() : null;
412
+
413
+ // 1. Both present → loud error (AC-2 spec gap fix).
414
+ if (platformRaw && featureRaw) {
415
+ const loc = (tag.file ? `${tag.file}:${tag.line || '?'}` : 'unknown');
416
+ return {
417
+ destination: 'error',
418
+ featureId: featureRaw,
419
+ topic: platformRaw,
420
+ reason: 'both-feature-and-platform',
421
+ error: `@cap-decision at ${loc} has BOTH feature:${featureRaw} AND platform:${platformRaw} — pick one`,
422
+ };
423
+ }
424
+
425
+ // 2. Platform tag present.
426
+ if (platformRaw) {
427
+ if (!PLATFORM_TOPIC_RE.test(platformRaw)) {
428
+ const loc = (tag.file ? `${tag.file}:${tag.line || '?'}` : 'unknown');
429
+ return {
430
+ destination: 'error',
431
+ featureId: null,
432
+ topic: platformRaw,
433
+ reason: 'invalid-platform-slug',
434
+ error: `@cap-decision at ${loc} has invalid platform topic "${_safeForError(platformRaw)}" (must be kebab-case)`,
435
+ };
436
+ }
437
+ return {
438
+ destination: 'platform',
439
+ featureId: null,
440
+ topic: platformRaw,
441
+ reason: 'explicit-platform-tag',
442
+ error: null,
443
+ };
444
+ }
445
+
446
+ // 3. Feature tag present.
447
+ if (featureRaw) {
448
+ if (!schema.FEATURE_ID_RE.test(featureRaw)) {
449
+ const loc = (tag.file ? `${tag.file}:${tag.line || '?'}` : 'unknown');
450
+ return {
451
+ destination: 'error',
452
+ featureId: featureRaw,
453
+ topic: null,
454
+ reason: 'invalid-feature-id',
455
+ error: `@cap-decision at ${loc} has invalid feature id "${_safeForError(featureRaw)}"`,
456
+ };
457
+ }
458
+ return {
459
+ destination: 'feature',
460
+ featureId: featureRaw,
461
+ topic: null,
462
+ reason: 'explicit-feature-tag',
463
+ error: null,
464
+ };
465
+ }
466
+
467
+ // 4. Neither — caller decides (typically: fall back to activeFeature or unassigned).
468
+ return { destination: 'unassigned', featureId: null, topic: null, reason: 'no-routing-tag', error: null };
469
+ }
470
+
471
+ // -------- Exports --------
472
+
473
+ module.exports = {
474
+ // public API
475
+ loadPlatformTopic,
476
+ writePlatformTopic,
477
+ listPlatformTopics,
478
+ loadChecklist,
479
+ writeChecklist,
480
+ listChecklists,
481
+ renderPlatformTopic,
482
+ classifyDecisionTag,
483
+ getPlatformTopicPath,
484
+ getChecklistPath,
485
+ // constants
486
+ MEMORY_PLATFORM_DIR,
487
+ MEMORY_PLATFORM_CHECKLISTS_DIR,
488
+ PLATFORM_TOPIC_RE,
489
+ PLATFORM_SUBSYSTEM_RE,
490
+ };