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,398 @@
1
+ // @cap-feature(feature:F-078, primary:true) Extends-Chain Resolver — resolves
2
+ // `extends: platform/<topic>` chains across per-feature memory files in a single lookup pass,
3
+ // with explicit cycle detection.
4
+ //
5
+ // @cap-context Per-Feature memory files (F-076) carry an optional `extends: platform/<topic>`
6
+ // frontmatter field (defined in cap-memory-schema.cjs:EXTENDS_RE). F-078/AC-3 says the reader
7
+ // MUST resolve those chains in a SINGLE pass — no recursive expansion that could blow up on
8
+ // pathological input, no per-feature partial-merge that could shadow upstream errors.
9
+ //
10
+ // @cap-context F-078/AC-5 says cycles MUST be rejected with the FULL chain in the error message
11
+ // (e.g. `F-070 → platform/A → platform/B → platform/A`), not a generic "cycle detected".
12
+ // That's testable in the error-string assertion, and it's the difference between a 30-second
13
+ // fix and an hour of debugging.
14
+ //
15
+ // @cap-decision(F-078/AC-3) Single-pass resolution: walk the extends chain iteratively with a
16
+ // visited-set, accumulating layers in order. Depth-bound at MAX_CHAIN_DEPTH (8) as a hard
17
+ // safety net — even if the cycle detector somehow missed a cycle, the depth cap fails loud
18
+ // instead of looping. This is defense-in-depth, not the primary detector.
19
+
20
+ 'use strict';
21
+
22
+ const path = require('node:path');
23
+
24
+ const schema = require('./cap-memory-schema.cjs');
25
+ const platformLib = require('./cap-memory-platform.cjs');
26
+
27
+ // -------- Constants --------
28
+
29
+ // @cap-decision(F-078/D8) Hard cap on chain depth — any project that legitimately needs >8
30
+ // levels of platform extends has bigger problems than this resolver. The cap exists so a
31
+ // hostile input (a cycle that the visited-set somehow missed) can't loop forever.
32
+ const MAX_CHAIN_DEPTH = 8;
33
+
34
+ // @cap-decision(F-078/iter1) Stage-2 #1 fix: ANSI defense extended to extends-resolver.
35
+ // User-controlled bytes (extendsRef from frontmatter, ref strings shown in cycle-paths and
36
+ // dangling-warnings) flow into error/warning messages that are typically piped to a terminal.
37
+ // Without sanitization, an attacker-authored memory file containing ANSI escape bytes could
38
+ // recolor or truncate operator-visible output. We mirror the helper from cap-memory-platform.cjs
39
+ // rather than importing it: keeping the defense local to each module avoids a fragile coupling
40
+ // where someone refactors platform's helper out from under us. Both modules share the SAME
41
+ // behavior — strip non-printable bytes outside `0x20-0x7E`, slice to 64 chars.
42
+ function _safeForError(value) {
43
+ if (typeof value !== 'string') return String(value);
44
+ return value.replace(/[^\x20-\x7E]/g, '?').slice(0, 64);
45
+ }
46
+
47
+ // @cap-decision(F-078/iter1) Stage-2 #2 fix helper: deep-clone YAML-derived frontmatter
48
+ // while preserving null-prototype on the returned object. JSON-roundtrip handles the
49
+ // nested arrays/objects (frontmatter is plain-data only — no Date, RegExp, Map, etc.),
50
+ // then we re-create with `Object.create(null)` to keep the proto-pollution defense.
51
+ function _deepCloneFrontmatter(src) {
52
+ if (!src || typeof src !== 'object') return Object.create(null);
53
+ let cloned;
54
+ try {
55
+ cloned = JSON.parse(JSON.stringify(src));
56
+ } catch (_e) {
57
+ cloned = {};
58
+ }
59
+ const out = Object.create(null);
60
+ for (const k of Object.keys(cloned)) {
61
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
62
+ out[k] = cloned[k];
63
+ }
64
+ return out;
65
+ }
66
+
67
+ // -------- Typedefs --------
68
+
69
+ /**
70
+ * @typedef {Object} ExtendsLayer
71
+ * @property {'feature'|'platform'} kind
72
+ * @property {string} ref - "F-NNN" or "platform/<topic>"
73
+ * @property {string} path - filesystem path the layer was loaded from
74
+ * @property {boolean} exists - true if the file was found and loaded
75
+ * @property {import('./cap-memory-schema.cjs').FeatureMemoryFile|null} file - parsed file, or null if missing
76
+ */
77
+
78
+ /**
79
+ * @typedef {Object} ResolveResult
80
+ * @property {boolean} ok - true if resolution succeeded; false if a cycle was detected
81
+ * @property {ExtendsLayer[]} layers - ordered chain of resolved layers; first = root, last = deepest extends
82
+ * @property {string[]} chain - human-readable chain of refs (e.g. ["F-070", "platform/A", "platform/B"])
83
+ * @property {string[]} warnings - non-fatal warnings (e.g. dangling extends)
84
+ * @property {string|null} error - cycle path or other fatal error, null on success
85
+ * @property {string|null} cyclePath - "F-070 → platform/A → platform/A" formatted chain, null if no cycle
86
+ */
87
+
88
+ // -------- Reference helpers --------
89
+
90
+ /**
91
+ * Parse an extends-ref string into kind + ref components.
92
+ * Currently only `platform/<topic>` is supported (mirrors EXTENDS_RE in the schema).
93
+ * @param {string} ref
94
+ * @returns {{kind:'platform', topic:string}|null}
95
+ */
96
+ function parseExtendsRef(ref) {
97
+ if (typeof ref !== 'string') return null;
98
+ const m = ref.match(/^platform\/([a-z0-9]+(?:-[a-z0-9]+)*)$/);
99
+ if (!m) return null;
100
+ return { kind: 'platform', topic: m[1] };
101
+ }
102
+
103
+ /**
104
+ * Build a stable visited-set key for a layer ref (used in cycle detection).
105
+ * @param {string} ref
106
+ * @returns {string}
107
+ */
108
+ function _refKey(ref) {
109
+ return ref;
110
+ }
111
+
112
+ // -------- Loaders --------
113
+
114
+ /**
115
+ * Load the layer at `extendsRef` (currently always platform). Returns the layer record
116
+ * regardless of whether the underlying file exists — caller decides whether to treat
117
+ * a dangling extends as fatal (we don't; we soft-warn per F-078 spec gap).
118
+ *
119
+ * @param {string} projectRoot
120
+ * @param {string} extendsRef - e.g. "platform/observability"
121
+ * @returns {ExtendsLayer}
122
+ */
123
+ function loadLayer(projectRoot, extendsRef) {
124
+ const parsed = parseExtendsRef(extendsRef);
125
+ if (!parsed) {
126
+ // Caller has already validated the ref shape via the schema's EXTENDS_RE, so this is a
127
+ // defensive fallback: return a layer with exists=false so the resolver can warn cleanly.
128
+ return {
129
+ kind: 'platform',
130
+ ref: extendsRef,
131
+ path: '',
132
+ exists: false,
133
+ file: null,
134
+ };
135
+ }
136
+ const loaded = platformLib.loadPlatformTopic(projectRoot, parsed.topic);
137
+ return {
138
+ kind: 'platform',
139
+ ref: extendsRef,
140
+ path: loaded.path,
141
+ exists: loaded.exists,
142
+ file: loaded.file,
143
+ };
144
+ }
145
+
146
+ // -------- Core resolver --------
147
+
148
+ // @cap-todo(ac:F-078/AC-3) resolveExtends walks the extends-chain in a SINGLE pass and returns
149
+ // the ordered layer list. Per-feature file is layer[0]; each platform extends is appended.
150
+ // @cap-todo(ac:F-078/AC-5) resolveExtends detects cycles via a visited-set keyed on the
151
+ // normalized ref string. Cycle path is rendered with `→` separators so the error message
152
+ // contains the FULL chain, not just "cycle detected".
153
+ // @cap-risk(reason:cycle-mishandling-corrupts-resolved-view) If the visited-set check fires
154
+ // AFTER pushing the layer (not before), the cycle path would be off-by-one and could leak
155
+ // the duplicate entry into the merged view. Order matters: check FIRST, then push.
156
+
157
+ /**
158
+ * Resolve a per-feature memory file's extends chain into an ordered layer list.
159
+ * Pure-ish: reads files via cap-memory-platform's loaders, but does not write.
160
+ *
161
+ * @param {string} projectRoot
162
+ * @param {string} perFeaturePath - absolute path to a .cap/memory/features/F-NNN-<topic>.md file
163
+ * @returns {ResolveResult}
164
+ */
165
+ function resolveExtends(projectRoot, perFeaturePath) {
166
+ /** @type {ResolveResult} */
167
+ const result = {
168
+ ok: true,
169
+ layers: [],
170
+ chain: [],
171
+ warnings: [],
172
+ error: null,
173
+ cyclePath: null,
174
+ };
175
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
176
+ result.ok = false;
177
+ result.error = 'projectRoot must be a non-empty string';
178
+ return result;
179
+ }
180
+ if (typeof perFeaturePath !== 'string' || perFeaturePath.length === 0) {
181
+ result.ok = false;
182
+ result.error = 'perFeaturePath must be a non-empty string';
183
+ return result;
184
+ }
185
+
186
+ // 1. Load the root (per-feature) file. We don't go through getFeaturePath here because the
187
+ // caller might pass an arbitrary absolute path; the schema parser handles a missing file
188
+ // via parseFeatureMemoryFile only if we read it ourselves.
189
+ const fs = require('node:fs');
190
+ let rootRaw;
191
+ try {
192
+ rootRaw = fs.readFileSync(perFeaturePath, 'utf8');
193
+ } catch (e) {
194
+ result.ok = false;
195
+ result.error = `failed to read root file ${perFeaturePath}: ${e && e.message ? e.message : String(e)}`;
196
+ return result;
197
+ }
198
+ let rootFile;
199
+ try {
200
+ rootFile = schema.parseFeatureMemoryFile(rootRaw);
201
+ } catch (e) {
202
+ result.ok = false;
203
+ result.error = `failed to parse root file ${perFeaturePath}: ${e && e.message ? e.message : String(e)}`;
204
+ return result;
205
+ }
206
+ // Derive a chain-display ref for the root layer. Prefer `feature` from frontmatter, else
207
+ // the basename without extension. This is purely cosmetic — the cycle detector keys on
208
+ // the platform refs, which are unique on their own.
209
+ const rootRef = (rootFile.frontmatter && typeof rootFile.frontmatter.feature === 'string'
210
+ && rootFile.frontmatter.feature.length > 0)
211
+ ? rootFile.frontmatter.feature
212
+ : path.basename(perFeaturePath, '.md');
213
+ result.layers.push({
214
+ kind: 'feature',
215
+ ref: rootRef,
216
+ path: perFeaturePath,
217
+ exists: true,
218
+ file: rootFile,
219
+ });
220
+ result.chain.push(rootRef);
221
+
222
+ // 2. Single-pass walk of the extends chain.
223
+ // @cap-decision(F-078/AC-5) visited-set keys on the platform-ref string. The root feature
224
+ // ref is NOT added to the visited-set because a per-feature file referencing itself is
225
+ // structurally impossible (extends only points at platform/), and re-using the root ref
226
+ // would produce a confusing duplicate entry in the displayed cycle path.
227
+ const visited = new Set();
228
+ let current = rootFile;
229
+ let depth = 0;
230
+ // @cap-decision(F-078/iter1) Stage-2 #3 fix: drop double 'platform/' prefix in
231
+ // malformed-extends message. Track the *previous* layer's ref explicitly so the
232
+ // mid-chain malformed-extends error names the actual parent file (already a full
233
+ // ref like `platform/a`) instead of synthesizing `'platform/' + lastVisited`, which
234
+ // double-prefixed because visited entries are already full refs. The variable starts
235
+ // empty and is updated AFTER each successful push so it always points at the layer
236
+ // whose `extends:` field we're currently validating.
237
+ let lastRef = '';
238
+ while (current && current.frontmatter && current.frontmatter.extends) {
239
+ const extendsRef = String(current.frontmatter.extends).trim();
240
+ if (extendsRef === '') break;
241
+
242
+ // @cap-risk Validate the ref shape via the same regex the schema uses, so a malformed
243
+ // extends value (e.g. `extends: ../../etc/passwd`) is rejected here too. parseExtendsRef
244
+ // returns null on shape failure; we then surface a hard error rather than a soft warn,
245
+ // because a malformed extends is an authoring bug, not a missing-file condition.
246
+ const parsed = parseExtendsRef(extendsRef);
247
+ if (!parsed) {
248
+ result.ok = false;
249
+ // @cap-decision(F-078/iter1) Stage-2 #1 fix: ANSI defense extended to extends-resolver.
250
+ // Both extendsRef (user-controlled frontmatter) and lastRef (also a user-derived
251
+ // upstream ref) are sanitized before interpolation. perFeaturePath is operator-supplied,
252
+ // not user-controlled, but we sanitize it anyway as defense-in-depth — log-injection
253
+ // class issues compound when even one slot is unsanitized.
254
+ const inLocation = current === rootFile
255
+ ? _safeForError(perFeaturePath)
256
+ : _safeForError(lastRef || '?');
257
+ result.error = `invalid extends ref "${_safeForError(extendsRef)}" in ${inLocation} (must match platform/<topic>)`;
258
+ return result;
259
+ }
260
+
261
+ // @cap-decision(F-078/AC-5) Cycle check FIRST, then push. Reverse order would let the
262
+ // duplicate slip into result.layers.
263
+ if (visited.has(_refKey(extendsRef))) {
264
+ // Cycle: build a display chain that includes the duplicate ref at the end so the
265
+ // user sees the loop close visually.
266
+ // @cap-decision(F-078/iter1) Stage-2 #1 fix: defense-in-depth — sanitize each ref in
267
+ // the cycle path before joining. parseExtendsRef anchors the topic shape, so refs
268
+ // SHOULD already be ANSI-clean, but the chain[0] is the ROOT ref which is derived
269
+ // from `frontmatter.feature` or basename — both user-controlled paths.
270
+ const cyclePath = [...result.chain, extendsRef].map(_safeForError).join(' → ');
271
+ result.ok = false;
272
+ result.cyclePath = cyclePath;
273
+ result.error = `cycle detected in extends chain: ${cyclePath}`;
274
+ return result;
275
+ }
276
+
277
+ if (depth >= MAX_CHAIN_DEPTH) {
278
+ // Safety net — cycle detector should always catch this first, but if not, fail loud.
279
+ result.ok = false;
280
+ result.cyclePath = [...result.chain, extendsRef].map(_safeForError).join(' → ');
281
+ result.error = `extends chain exceeds max depth ${MAX_CHAIN_DEPTH}: ${result.cyclePath}`;
282
+ return result;
283
+ }
284
+
285
+ visited.add(_refKey(extendsRef));
286
+
287
+ // Load the next layer.
288
+ const layer = loadLayer(projectRoot, extendsRef);
289
+ result.layers.push(layer);
290
+ result.chain.push(extendsRef);
291
+
292
+ if (!layer.exists || !layer.file) {
293
+ // @cap-decision(F-078/spec-gap) Dangling extends is SOFT-warn, not fatal. Reasoning:
294
+ // the spec says "validate that referenced topic exists OR deferred-warning if not
295
+ // (don't hard-block on dangling extends)". A platform topic might be created in a
296
+ // sibling PR, and a hard-block here would force ordering between PRs. The resolved
297
+ // view excludes the dangling layer (we don't push the missing file's content into
298
+ // any merged-view), but the chain still records that we attempted the link.
299
+ // @cap-decision(F-078/iter1) Stage-2 #1 fix: ANSI-sanitize the ref + path in the
300
+ // dangling warning text. layer.path is derived from a sanitized topic (via the
301
+ // platform path helper), so already clean — but defense-in-depth is cheap.
302
+ result.warnings.push(`dangling extends: ${_safeForError(extendsRef)} (file not found at ${_safeForError(layer.path)})`);
303
+ break;
304
+ }
305
+
306
+ // Continue walking from the layer we just loaded. Update lastRef AFTER push so a
307
+ // subsequent malformed-extends error names this layer (the parent of the bad ref).
308
+ lastRef = extendsRef;
309
+ current = layer.file;
310
+ depth += 1;
311
+ }
312
+
313
+ return result;
314
+ }
315
+
316
+ // -------- Merged view --------
317
+
318
+ // @cap-decision(F-078/spec-gap) Merge semantics: when collapsing the layer chain into a
319
+ // single view, AUTO-block decisions/pitfalls CONCAT (preserve all sources, deduped on
320
+ // `text + location` to avoid noise on re-runs). FRONTMATTER fields use OVERRIDE-from-root
321
+ // (the per-feature file wins on conflict) — the per-feature file is the authoritative
322
+ // authoring point. The MANUAL-block raw text is NOT merged: it lives only on the root file
323
+ // (extending platform manual lessons would be confusing on re-runs and is out of scope for
324
+ // AC-3 which only asks for the chain to RESOLVE, not for a fully-merged authoring view).
325
+
326
+ /**
327
+ * Collapse a resolved extends chain into a single merged view (concat auto-block, override
328
+ * frontmatter from root, manual-block from root only).
329
+ *
330
+ * @param {ResolveResult} resolved
331
+ * @returns {{frontmatter:Object, autoBlock:{decisions:Array<{text:string,location:string}>, pitfalls:Array<{text:string,location:string}>}, manualBlock:{raw:string}, layerCount:number}}
332
+ */
333
+ function mergeResolvedView(resolved) {
334
+ if (!resolved || !resolved.ok) {
335
+ throw new Error('mergeResolvedView: cannot merge an unresolved chain');
336
+ }
337
+ const layers = resolved.layers || [];
338
+ if (layers.length === 0) {
339
+ return {
340
+ frontmatter: Object.create(null),
341
+ autoBlock: { decisions: [], pitfalls: [] },
342
+ manualBlock: { raw: '' },
343
+ layerCount: 0,
344
+ };
345
+ }
346
+ const root = layers[0];
347
+ // @cap-decision(F-078/iter1) Stage-2 #2 fix: deep-clone frontmatter on merge (F-082 lesson).
348
+ // Object.assign was a shallow copy — array values (`related_features`, `key_files`) shared
349
+ // their references with the parsed source file. A caller doing
350
+ // `merged.frontmatter.related_features.push(...)` would silently mutate the upstream parsed
351
+ // file. Frontmatter is YAML-derived plain data (strings, numbers, arrays of strings),
352
+ // never functions or class instances, so JSON-roundtrip is safe and avoids the
353
+ // structuredClone+`Object.create(null)` proto-edge-case (structuredClone preserves the
354
+ // null-prototype, which we want, but the JSON path is the simpler proven contract here).
355
+ // We then re-prototype the result with `Object.create(null)` to keep the same proto-poison
356
+ // defense the original code provided.
357
+ const frontmatter = _deepCloneFrontmatter(root.file ? root.file.frontmatter : {});
358
+ const seen = new Set();
359
+ const decisions = [];
360
+ const pitfalls = [];
361
+
362
+ // Walk DEEPEST to ROOT so the root layer's entries appear last (most-recent-wins display
363
+ // order). On dedup, the LATER write wins because we check `seen` before pushing — but
364
+ // since dedup is keyed on text+location, "winner" is irrelevant anyway.
365
+ for (let i = layers.length - 1; i >= 0; i--) {
366
+ const layer = layers[i];
367
+ if (!layer.file || !layer.file.autoBlock) continue;
368
+ for (const d of layer.file.autoBlock.decisions || []) {
369
+ const k = `D|${d.text}|${d.location}`;
370
+ if (seen.has(k)) continue;
371
+ seen.add(k);
372
+ decisions.push({ text: d.text, location: d.location, sourceRef: layer.ref });
373
+ }
374
+ for (const p of layer.file.autoBlock.pitfalls || []) {
375
+ const k = `P|${p.text}|${p.location}`;
376
+ if (seen.has(k)) continue;
377
+ seen.add(k);
378
+ pitfalls.push({ text: p.text, location: p.location, sourceRef: layer.ref });
379
+ }
380
+ }
381
+
382
+ return {
383
+ frontmatter,
384
+ autoBlock: { decisions, pitfalls },
385
+ manualBlock: { raw: root.file ? (root.file.manualBlock ? root.file.manualBlock.raw : '') : '' },
386
+ layerCount: layers.length,
387
+ };
388
+ }
389
+
390
+ // -------- Exports --------
391
+
392
+ module.exports = {
393
+ resolveExtends,
394
+ mergeResolvedView,
395
+ parseExtendsRef,
396
+ loadLayer,
397
+ MAX_CHAIN_DEPTH,
398
+ };