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,1028 @@
1
+ // @cap-feature(feature:F-084, primary:true) Project Onboarding & Migration Orchestrator —
2
+ // state-machine + planner + atomic marker writer for `/cap:upgrade`.
3
+ //
4
+ // @cap-context This module owns the planner / state-manager half of /cap:upgrade.
5
+ // The markdown command spec at commands/cap/upgrade.md is the orchestrator that
6
+ // invokes each /cap:* sub-command in turn; this module decides WHICH stages need
7
+ // to run, in what ORDER, and persists the marker `.cap/version` plus the per-stage
8
+ // audit log `.cap/upgrade.log`. The module never spawns child processes itself —
9
+ // it returns a STAGE-PLAN that the markdown spec executes.
10
+ //
11
+ // @cap-context F-084 is "candidate 8" in the V6 Stage-2 streak. All 12 Stage-2
12
+ // classes are applied UPFRONT (proto-pollution defense, ANSI defense, path-traversal
13
+ // rejection, silent-skip-is-real-silent, atomic writes, round-trip stability, etc.)
14
+ //
15
+ // @cap-decision(F-084/AC-2) The 7 stage names are fixed and ordered. Order
16
+ // is a contract (doctor → init → annotate → migrate-tags → memory-bootstrap →
17
+ // migrate-snapshots → refresh-docs). Skip-conditions decide whether each stage
18
+ // runs but never reorder them. A future stage can be appended to the end.
19
+ //
20
+ // @cap-decision(F-084/AC-4) Per-stage isolation: a failed stage is logged and
21
+ // the orchestrator keeps going. Tests cover this via per-stage error injection.
22
+ //
23
+ // @cap-decision(F-084/AC-5) Marker file `.cap/version` is JSON, not a flat
24
+ // semver string. Rationale: we need to persist completedStages + lastRun
25
+ // alongside version. JSON beats inventing a custom multi-line format.
26
+ //
27
+ // @cap-decision(F-084/spec-gap) cap-upgrade.cjs returns a STAGE-PLAN and a
28
+ // recordStageResult() side-effect API. The markdown command spec
29
+ // (/cap:upgrade) is responsible for actually invoking /cap:doctor, /cap:init,
30
+ // /cap:annotate, etc. for each planned stage. This avoids the foot-gun of
31
+ // JS-from-markdown subprocess invocation while still keeping the planner
32
+ // fully testable in node:test (no spawn needed for unit tests).
33
+
34
+ 'use strict';
35
+
36
+ const fs = require('node:fs');
37
+ const path = require('node:path');
38
+ const { _atomicWriteFile } = require('./cap-memory-migrate.cjs');
39
+
40
+ // -------- Constants --------
41
+
42
+ // @cap-decision(F-084/D1) Marker file path is fixed: `.cap/version`. Lives at the
43
+ // same depth as `.cap/SESSION.json` and `.cap/upgrade.log` so the whole upgrade
44
+ // surface is one directory.
45
+ const MARKER_REL_PATH = path.join('.cap', 'version');
46
+
47
+ // @cap-decision(F-084/D2) Audit log path: `.cap/upgrade.log`. JSONL (one JSON
48
+ // object per line). JSONL plays nicely with `tail -f` and `jq -s '.'` for
49
+ // post-mortem and is append-only by construction (no rewrite on each entry).
50
+ const LOG_REL_PATH = path.join('.cap', 'upgrade.log');
51
+
52
+ // @cap-decision(F-084/D3) Hook-throttle marker path: `.cap/.session-advisories.json`.
53
+ // Leading-dot signals "transient/derived" (matches `.cap/memory/.last-run`,
54
+ // `.cap/memory/.claude-native-index.json`). Tracks per-session (process.pid +
55
+ // session-id) advisory emissions so SessionStart-hook only emits once per session.
56
+ const ADVISORY_REL_PATH = path.join('.cap', '.session-advisories.json');
57
+
58
+ // @cap-decision(F-084/D4) Schema version for the marker file. Bump on shape change
59
+ // (e.g. if we add a new field that older readers can't parse). Same pattern as
60
+ // CACHE_SCHEMA_VERSION in cap-memory-bridge.cjs.
61
+ const MARKER_SCHEMA_VERSION = 1;
62
+
63
+ // @cap-decision(F-084/AC-2) Fixed stage list — the contract for the orchestrator.
64
+ // Order is doctor first (read-only health check, gate for everything else), then
65
+ // init-or-skip (foundational), then the 5 modification stages.
66
+ // @cap-decision(F-084/AC-3) The `optional` flag drives `--non-interactive` safe
67
+ // defaults: optional stages get auto-skipped in CI mode unless `--include-stages`
68
+ // re-enables them.
69
+ const STAGES = Object.freeze([
70
+ Object.freeze({ name: 'doctor', command: '/cap:doctor', optional: false, readOnly: true }),
71
+ Object.freeze({ name: 'init-or-skip', command: '/cap:init', optional: false, readOnly: false }),
72
+ Object.freeze({ name: 'annotate', command: '/cap:annotate', optional: true, readOnly: false }),
73
+ Object.freeze({ name: 'migrate-tags', command: '/cap:migrate-tags', optional: false, readOnly: false }),
74
+ Object.freeze({ name: 'memory-bootstrap', command: '/cap:memory bootstrap', optional: false, readOnly: false }),
75
+ Object.freeze({ name: 'migrate-snapshots', command: '/cap:memory migrate-snapshots', optional: false, readOnly: false }),
76
+ Object.freeze({ name: 'refresh-docs', command: '/cap:refresh-docs', optional: true, readOnly: false }),
77
+ ]);
78
+
79
+ // @cap-decision(F-084/D5) Hard-coded stage-name allowlist for input validation
80
+ // in --skip-stages parsing. Defense-in-depth against path-traversal attempts
81
+ // (`--skip-stages=../etc/passwd`).
82
+ const STAGE_NAMES = Object.freeze(STAGES.map((s) => s.name));
83
+
84
+ // @cap-decision(F-084/D6) Stage-name regex: matches the literal STAGE_NAMES only.
85
+ // Cheaper than a full validator chain; if a stage name fails this regex we know
86
+ // it's not a known stage AND it's not a path-traversal sequence either.
87
+ const STAGE_NAME_RE = /^[a-z]+(?:-[a-z]+)*$/;
88
+
89
+ // -------- Defensive helpers --------
90
+
91
+ // @cap-decision(F-084/D7) ANSI/control-byte sanitization. Mirrors
92
+ // cap-memory-platform.cjs:_safeForError, cap-memory-bridge.cjs:_safeForOutput,
93
+ // cap-snapshot-linkage.cjs:_safeForError. Kept LOCAL so a refactor in one module
94
+ // can't silently weaken the defense in another. Stage-2 #2 lesson.
95
+ function _safeForError(value) {
96
+ let s;
97
+ try {
98
+ s = String(value);
99
+ } catch (_e) {
100
+ return '<unprintable>';
101
+ }
102
+ // Strip non-printable bytes (ANSI CSI, BEL, BS, NUL). Cap at 200 chars to keep
103
+ // log lines bounded — advisory messages are 120 chars max so 200 is generous.
104
+ // eslint-disable-next-line no-control-regex
105
+ s = s.replace(/[\x00-\x1f\x7f]/g, '?');
106
+ if (s.length > 200) s = s.slice(0, 200) + '...';
107
+ return s;
108
+ }
109
+
110
+ // @cap-decision(F-084/D8) Null-prototype reconstruction for any object parsed from
111
+ // JSON that crosses a trust boundary (.cap/version, .cap/.session-advisories.json,
112
+ // .cap/config.json:upgrade). Stage-2 #1 lesson: prevents __proto__ pollution from
113
+ // a malicious or corrupted marker file.
114
+ function _safeJsonParse(raw) {
115
+ let parsed;
116
+ try {
117
+ parsed = JSON.parse(raw);
118
+ } catch (_e) {
119
+ return { ok: false, value: null, reason: 'parse-error' };
120
+ }
121
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
122
+ return { ok: false, value: null, reason: 'shape-mismatch' };
123
+ }
124
+ // Reconstruct with null prototype + only own-enumerable keys. Drops
125
+ // `__proto__` and `constructor` setters silently; they survive a `JSON.parse`
126
+ // as own keys but reconstruction breaks the prototype chain.
127
+ const safe = Object.create(null);
128
+ for (const key of Object.keys(parsed)) {
129
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
130
+ safe[key] = parsed[key];
131
+ }
132
+ return { ok: true, value: safe, reason: 'parsed' };
133
+ }
134
+
135
+ // @cap-todo(ac:F-084/AC-1) _validateProjectRoot guards every public entry-point
136
+ // against NUL-byte / non-string injection. Defense-in-depth even though Node's
137
+ // fs.* would throw; an explicit error is friendlier than the libuv message.
138
+ function _validateProjectRoot(projectRoot) {
139
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
140
+ throw new TypeError('projectRoot must be a non-empty string');
141
+ }
142
+ if (projectRoot.includes('\0')) {
143
+ throw new TypeError(`projectRoot contains NUL byte (got "${_safeForError(projectRoot)}")`);
144
+ }
145
+ }
146
+
147
+ // -------- Version comparison --------
148
+
149
+ // @cap-decision(F-084/D9) Semver compare without external deps. Accepts
150
+ // `MAJOR.MINOR.PATCH` with optional pre-release suffix (we ignore the suffix
151
+ // for ordering — the ship-on-main contract means we never publish alpha
152
+ // markers). Returns -1 / 0 / +1.
153
+ // @cap-risk(reason:semver-edge-cases) Pre-release ordering is intentionally
154
+ // a no-op here: a comparison with a pre-release suffix degrades to plain
155
+ // numeric compare on the MAJOR.MINOR.PATCH triple. Acceptable: CAP releases
156
+ // are stable-only on main.
157
+ function _parseSemver(v) {
158
+ if (typeof v !== 'string') return null;
159
+ // Strip leading `v` and any pre-release/build suffix.
160
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(v.trim());
161
+ if (!m) return null;
162
+ return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
163
+ }
164
+
165
+ // @cap-todo(ac:F-084/AC-1) compareVersions returns -1/0/+1 / null on parse-failure.
166
+ function compareVersions(a, b) {
167
+ const pa = _parseSemver(a);
168
+ const pb = _parseSemver(b);
169
+ if (!pa || !pb) return null;
170
+ for (let i = 0; i < 3; i++) {
171
+ if (pa[i] < pb[i]) return -1;
172
+ if (pa[i] > pb[i]) return 1;
173
+ }
174
+ return 0;
175
+ }
176
+
177
+ // -------- Installed version --------
178
+
179
+ // @cap-todo(ac:F-084/AC-1) getInstalledVersion reads the package.json:version
180
+ // from the CAP repo (or installed npx tree). Falls back to '0.0.0' on missing
181
+ // package.json so the planner stays operational on partial installs.
182
+ /**
183
+ * @param {string} packageJsonDir - dir containing package.json (defaults to CAP install dir)
184
+ * @returns {string} semver string (or '0.0.0' on missing/unreadable)
185
+ */
186
+ function getInstalledVersion(packageJsonDir) {
187
+ // Default to the CAP install dir (two levels up from this file: .../cap/bin/lib/ -> .../).
188
+ const dir = packageJsonDir || path.resolve(__dirname, '..', '..', '..');
189
+ const pkgPath = path.join(dir, 'package.json');
190
+ if (!fs.existsSync(pkgPath)) return '0.0.0';
191
+ let raw;
192
+ try {
193
+ raw = fs.readFileSync(pkgPath, 'utf8');
194
+ } catch (_e) {
195
+ return '0.0.0';
196
+ }
197
+ const parsed = _safeJsonParse(raw);
198
+ if (!parsed.ok) return '0.0.0';
199
+ const v = parsed.value.version;
200
+ if (typeof v !== 'string') return '0.0.0';
201
+ // Validate the shape — return '0.0.0' if it's not a clean semver.
202
+ return _parseSemver(v) ? v : '0.0.0';
203
+ }
204
+
205
+ // -------- Marker IO --------
206
+
207
+ // @cap-todo(ac:F-084/AC-5) getMarkerVersion reads `.cap/version` and returns
208
+ // a normalized payload, or null if the file is missing. Stage-2 #1 + #10
209
+ // lessons: corrupted JSON / wrong-shape JSON / missing file all return null
210
+ // gracefully (never throw). The caller treats null as "first run".
211
+ /**
212
+ * @typedef {Object} MarkerPayload
213
+ * @property {number} schemaVersion
214
+ * @property {string} version - last-run CAP version (semver)
215
+ * @property {string[]} completedStages - stage names completed successfully
216
+ * @property {string|null} lastRun - ISO timestamp of last successful upgrade
217
+ */
218
+
219
+ /**
220
+ * @param {string} projectRoot
221
+ * @returns {MarkerPayload|null} null if marker missing, corrupted, or unreadable.
222
+ */
223
+ function getMarkerVersion(projectRoot) {
224
+ _validateProjectRoot(projectRoot);
225
+ const fp = path.join(projectRoot, MARKER_REL_PATH);
226
+ if (!fs.existsSync(fp)) return null;
227
+ let raw;
228
+ try {
229
+ raw = fs.readFileSync(fp, 'utf8');
230
+ } catch (_e) {
231
+ return null;
232
+ }
233
+ const parsed = _safeJsonParse(raw);
234
+ if (!parsed.ok) return null;
235
+ const obj = parsed.value;
236
+ // Validate fields. Anything malformed → null (treat as first-run).
237
+ if (typeof obj.version !== 'string' || !_parseSemver(obj.version)) return null;
238
+ if (!Array.isArray(obj.completedStages)) return null;
239
+ // Sanitize completedStages: drop any non-string or unknown stage names.
240
+ const completedStages = obj.completedStages
241
+ .filter((s) => typeof s === 'string' && STAGE_NAMES.includes(s));
242
+ const lastRun = (typeof obj.lastRun === 'string') ? obj.lastRun : null;
243
+ const schemaVersion = (typeof obj.schemaVersion === 'number')
244
+ ? obj.schemaVersion : MARKER_SCHEMA_VERSION;
245
+ return { schemaVersion, version: obj.version, completedStages, lastRun };
246
+ }
247
+
248
+ // @cap-todo(ac:F-084/AC-5) writeMarker persists `.cap/version` atomically
249
+ // (tmpfile+rename via _atomicWriteFile from cap-memory-migrate). Stage-2 #6
250
+ // lesson: idempotent, byte-identical re-write returns true without churn
251
+ // (skip if content already matches).
252
+ // @cap-decision(F-084/D10) JSON serialization uses 2-space indent + trailing
253
+ // newline so the file is human-friendly to diff (`git log -p .cap/version`).
254
+ /**
255
+ * @param {string} projectRoot
256
+ * @param {MarkerPayload} payload
257
+ * @returns {boolean} true on write success
258
+ */
259
+ function writeMarker(projectRoot, payload) {
260
+ _validateProjectRoot(projectRoot);
261
+ if (!payload || typeof payload !== 'object') {
262
+ throw new TypeError('writeMarker: payload must be an object');
263
+ }
264
+ if (typeof payload.version !== 'string' || !_parseSemver(payload.version)) {
265
+ throw new TypeError(`writeMarker: payload.version must be a semver (got "${_safeForError(payload.version)}")`);
266
+ }
267
+ if (!Array.isArray(payload.completedStages)) {
268
+ throw new TypeError('writeMarker: payload.completedStages must be an array');
269
+ }
270
+ // Filter completedStages to known stages only. Unknown stage names are silently
271
+ // dropped — defense-in-depth against caller mistakes.
272
+ const completedStages = payload.completedStages.filter(
273
+ (s) => typeof s === 'string' && STAGE_NAMES.includes(s)
274
+ );
275
+ const safe = {
276
+ schemaVersion: MARKER_SCHEMA_VERSION,
277
+ version: payload.version,
278
+ completedStages,
279
+ lastRun: typeof payload.lastRun === 'string' ? payload.lastRun : new Date().toISOString(),
280
+ };
281
+ const fp = path.join(projectRoot, MARKER_REL_PATH);
282
+ const content = JSON.stringify(safe, null, 2) + '\n';
283
+ // Idempotency: if the file already has byte-identical content, skip the write.
284
+ // _atomicWriteFile would succeed but mtime would update — keep mtime stable on no-op.
285
+ if (fs.existsSync(fp)) {
286
+ try {
287
+ const existing = fs.readFileSync(fp, 'utf8');
288
+ if (existing === content) return true;
289
+ } catch (_e) { /* fall through to write */ }
290
+ }
291
+ _atomicWriteFile(fp, content);
292
+ return true;
293
+ }
294
+
295
+ // -------- Audit log --------
296
+
297
+ // @cap-todo(ac:F-084/AC-4) appendLog adds one JSONL entry per stage attempt.
298
+ // Append-only, atomic per-line (writeFileSync with `flag: 'a'` is atomic for
299
+ // small writes < PIPE_BUF; we keep entries < 4 KB to stay within that bound).
300
+ // @cap-decision(F-084/D11) NOT atomic-via-rename: the log is append-only, and
301
+ // using tmp+rename for an append would either (a) require reading + rewriting
302
+ // the entire file each time (bad for large logs) or (b) lose history. The
303
+ // append-with-flag pattern is the standard Unix log-append idiom.
304
+ /**
305
+ * @param {string} projectRoot
306
+ * @param {Object} entry - {stage, status, reason?, durationMs?, timestamp}
307
+ * @returns {boolean}
308
+ */
309
+ function appendLog(projectRoot, entry) {
310
+ _validateProjectRoot(projectRoot);
311
+ if (!entry || typeof entry !== 'object') {
312
+ throw new TypeError('appendLog: entry must be an object');
313
+ }
314
+ if (typeof entry.stage !== 'string' || !STAGE_NAMES.includes(entry.stage)) {
315
+ throw new TypeError(`appendLog: entry.stage must be a known stage (got "${_safeForError(entry.stage)}")`);
316
+ }
317
+ if (typeof entry.status !== 'string' || !['success', 'failure', 'skipped'].includes(entry.status)) {
318
+ throw new TypeError(`appendLog: entry.status must be success|failure|skipped (got "${_safeForError(entry.status)}")`);
319
+ }
320
+ const safe = {
321
+ stage: entry.stage,
322
+ status: entry.status,
323
+ timestamp: typeof entry.timestamp === 'string' ? entry.timestamp : new Date().toISOString(),
324
+ };
325
+ if (entry.reason != null) safe.reason = _safeForError(entry.reason);
326
+ if (typeof entry.durationMs === 'number' && Number.isFinite(entry.durationMs)) {
327
+ safe.durationMs = Math.max(0, Math.floor(entry.durationMs));
328
+ }
329
+ const fp = path.join(projectRoot, LOG_REL_PATH);
330
+ // Ensure parent dir exists.
331
+ try {
332
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
333
+ } catch (_e) {
334
+ return false;
335
+ }
336
+ const line = JSON.stringify(safe) + '\n';
337
+ try {
338
+ fs.writeFileSync(fp, line, { encoding: 'utf8', flag: 'a' });
339
+ } catch (_e) {
340
+ return false;
341
+ }
342
+ return true;
343
+ }
344
+
345
+ // @cap-todo(ac:F-084/AC-7) readLog returns the parsed entries from
346
+ // `.cap/upgrade.log`. Used by tests + by the markdown command for post-run
347
+ // summaries. Malformed lines are dropped (Stage-2 #10 lesson).
348
+ function readLog(projectRoot) {
349
+ _validateProjectRoot(projectRoot);
350
+ const fp = path.join(projectRoot, LOG_REL_PATH);
351
+ if (!fs.existsSync(fp)) return [];
352
+ let raw;
353
+ try {
354
+ raw = fs.readFileSync(fp, 'utf8');
355
+ } catch (_e) {
356
+ return [];
357
+ }
358
+ const out = [];
359
+ for (const line of raw.split('\n')) {
360
+ const trimmed = line.trim();
361
+ if (!trimmed) continue;
362
+ const parsed = _safeJsonParse(trimmed);
363
+ if (!parsed.ok) continue;
364
+ out.push(parsed.value);
365
+ }
366
+ return out;
367
+ }
368
+
369
+ // -------- Plan stages --------
370
+
371
+ // @cap-todo(ac:F-084/AC-2) skip-condition predicates per stage. Each predicate
372
+ // receives (projectRoot, opts) and returns {skip: boolean, reason: string}.
373
+ // @cap-decision(F-084/D12) Predicates are PURE FILESYSTEM CHECKS — they never
374
+ // invoke the actual stage. This keeps planMigrations cheap (sub-millisecond)
375
+ // and free of side effects so tests can fixture per-stage state without spawning.
376
+ const SKIP_PREDICATES = Object.freeze({
377
+ doctor: (_projectRoot, _opts) => ({ skip: false, reason: 'doctor always runs (read-only health check)' }),
378
+
379
+ 'init-or-skip': (projectRoot, _opts) => {
380
+ const capDir = path.join(projectRoot, '.cap');
381
+ const featureMap = path.join(projectRoot, 'FEATURE-MAP.md');
382
+ if (fs.existsSync(capDir) && fs.existsSync(featureMap)) {
383
+ return { skip: true, reason: '.cap/ + FEATURE-MAP.md already present' };
384
+ }
385
+ return { skip: false, reason: 'fresh project — needs init' };
386
+ },
387
+
388
+ annotate: (projectRoot, opts) => {
389
+ // @cap-decision(F-084/AC-3) annotate is OPTIONAL. In non-interactive mode
390
+ // skip by default. The user opts in via --include-stages=annotate.
391
+ if (opts && opts.nonInteractive && !opts.includeStages.has('annotate')) {
392
+ return { skip: true, reason: 'non-interactive mode skips optional annotate' };
393
+ }
394
+ if (opts && opts.skipStages.has('annotate')) {
395
+ return { skip: true, reason: 'user requested --skip-stages=annotate' };
396
+ }
397
+ return { skip: false, reason: 'annotate not skipped' };
398
+ },
399
+
400
+ 'migrate-tags': (_projectRoot, opts) => {
401
+ if (opts && opts.skipStages.has('migrate-tags')) {
402
+ return { skip: true, reason: 'user requested --skip-stages=migrate-tags' };
403
+ }
404
+ return { skip: false, reason: 'migrate-tags planned' };
405
+ },
406
+
407
+ 'memory-bootstrap': (projectRoot, opts) => {
408
+ if (opts && opts.skipStages.has('memory-bootstrap')) {
409
+ return { skip: true, reason: 'user requested --skip-stages=memory-bootstrap' };
410
+ }
411
+ const featuresDir = path.join(projectRoot, '.cap', 'memory', 'features');
412
+ if (fs.existsSync(featuresDir)) {
413
+ try {
414
+ const entries = fs.readdirSync(featuresDir).filter((e) => e.endsWith('.md'));
415
+ if (entries.length > 0) {
416
+ return { skip: true, reason: `.cap/memory/features/ already populated (${entries.length} files)` };
417
+ }
418
+ } catch (_e) { /* fall through */ }
419
+ }
420
+ return { skip: false, reason: 'memory-bootstrap planned' };
421
+ },
422
+
423
+ 'migrate-snapshots': (projectRoot, opts) => {
424
+ if (opts && opts.skipStages.has('migrate-snapshots')) {
425
+ return { skip: true, reason: 'user requested --skip-stages=migrate-snapshots' };
426
+ }
427
+ const snapshotsDir = path.join(projectRoot, '.cap', 'snapshots');
428
+ if (!fs.existsSync(snapshotsDir)) {
429
+ return { skip: true, reason: '.cap/snapshots/ absent — nothing to migrate' };
430
+ }
431
+ try {
432
+ const entries = fs.readdirSync(snapshotsDir).filter((e) => e.endsWith('.md'));
433
+ if (entries.length === 0) {
434
+ return { skip: true, reason: '.cap/snapshots/ empty' };
435
+ }
436
+ } catch (_e) { /* fall through */ }
437
+ return { skip: false, reason: 'migrate-snapshots planned' };
438
+ },
439
+
440
+ 'refresh-docs': (projectRoot, opts) => {
441
+ // @cap-decision(F-084/AC-3) refresh-docs is OPTIONAL — slow + needs network.
442
+ // Non-interactive skips by default; --include-stages=refresh-docs opts in.
443
+ if (opts && opts.nonInteractive && !opts.includeStages.has('refresh-docs')) {
444
+ return { skip: true, reason: 'non-interactive mode skips optional refresh-docs' };
445
+ }
446
+ if (opts && opts.skipStages.has('refresh-docs')) {
447
+ return { skip: true, reason: 'user requested --skip-stages=refresh-docs' };
448
+ }
449
+ const stackDir = path.join(projectRoot, '.cap', 'stack-docs');
450
+ if (fs.existsSync(stackDir)) {
451
+ try {
452
+ const entries = fs.readdirSync(stackDir).filter((e) => e.endsWith('.md'));
453
+ if (entries.length > 0) {
454
+ // Check mtime — anything fresher than 30 days passes.
455
+ const now = Date.now();
456
+ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
457
+ let stale = false;
458
+ for (const e of entries) {
459
+ try {
460
+ const stat = fs.statSync(path.join(stackDir, e));
461
+ if (now - stat.mtime.getTime() > THIRTY_DAYS_MS) {
462
+ stale = true;
463
+ break;
464
+ }
465
+ } catch (_e2) { /* treat as stale */ stale = true; break; }
466
+ }
467
+ if (!stale) {
468
+ return { skip: true, reason: `.cap/stack-docs/ all <30 days old (${entries.length} files)` };
469
+ }
470
+ }
471
+ } catch (_e) { /* fall through */ }
472
+ }
473
+ return { skip: false, reason: 'refresh-docs planned (stale or missing)' };
474
+ },
475
+ });
476
+
477
+ // @cap-decision(F-084/iter1) Stage-2 #2 fix: per-stage delta-probes implemented (Option A).
478
+ // AC-3 demands "per-stage delta-summary (was wird hinzugefügt/geändert)". Each
479
+ // probe is a quick READ-ONLY filesystem inspection that estimates what the
480
+ // stage would create/modify. Probes MUST be fast (<2s combined) and MUST NOT
481
+ // spawn subprocesses or invoke the actual stage logic. On any error a probe
482
+ // returns null (caller falls back to skip-reason only).
483
+ // @cap-decision(F-084/iter1) Probes are PURE READS — no atomic writes, no marker
484
+ // updates, no log appends. Stage-2 #12 lesson (read-only contract) extended to
485
+ // the dry-run UX layer.
486
+ const DELTA_PROBES = Object.freeze({
487
+ doctor: (_projectRoot) => null, // doctor is read-only health-check; no delta to preview.
488
+
489
+ 'init-or-skip': (projectRoot) => {
490
+ // Probe what /cap:init would create.
491
+ const items = [];
492
+ if (!fs.existsSync(path.join(projectRoot, '.cap'))) items.push('.cap/');
493
+ if (!fs.existsSync(path.join(projectRoot, 'FEATURE-MAP.md'))) items.push('FEATURE-MAP.md (skeleton)');
494
+ if (!fs.existsSync(path.join(projectRoot, '.cap', 'config.json'))) items.push('.cap/config.json');
495
+ if (items.length === 0) return null;
496
+ return `Will create: ${items.join(', ')}`;
497
+ },
498
+
499
+ annotate: (projectRoot) => {
500
+ // Estimate scan size: count .js / .cjs / .ts files under common source dirs.
501
+ // @cap-decision(F-084/iter1) annotate probe is an UPPER bound — counts files
502
+ // that COULD be scanned, not files that WILL be tagged. Cheap walk, capped
503
+ // at top-level + first-level depth to stay <100ms.
504
+ const candidateDirs = ['src', 'lib', 'cap/bin/lib', 'hooks', 'sdk/src', 'scripts'];
505
+ let count = 0;
506
+ const exts = new Set(['.js', '.cjs', '.mjs', '.ts', '.tsx']);
507
+ for (const rel of candidateDirs) {
508
+ const dir = path.join(projectRoot, rel);
509
+ if (!fs.existsSync(dir)) continue;
510
+ try {
511
+ // Shallow walk: top-level + one nested level. Bounded by directory entry count.
512
+ const stack = [{ dir, depth: 0 }];
513
+ while (stack.length > 0) {
514
+ const { dir: d, depth } = stack.pop();
515
+ if (depth > 2) continue;
516
+ let entries;
517
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); }
518
+ catch (_e) { continue; }
519
+ for (const e of entries) {
520
+ if (e.name.startsWith('.')) continue;
521
+ if (e.isDirectory()) {
522
+ if (e.name === 'node_modules' || e.name === 'dist') continue;
523
+ stack.push({ dir: path.join(d, e.name), depth: depth + 1 });
524
+ } else if (exts.has(path.extname(e.name))) {
525
+ count++;
526
+ }
527
+ }
528
+ }
529
+ } catch (_e) { /* swallow per-dir errors */ }
530
+ }
531
+ if (count === 0) return null;
532
+ return `Will scan ~${count} source files for tag candidates`;
533
+ },
534
+
535
+ 'migrate-tags': (projectRoot) => {
536
+ // Lazy-load the tag scanner; if unavailable, return null.
537
+ let scanner;
538
+ try {
539
+ scanner = require('./cap-tag-scanner.cjs');
540
+ } catch (_e) {
541
+ return null;
542
+ }
543
+ if (typeof scanner.scanDirectory !== 'function') return null;
544
+ let scanResult;
545
+ try {
546
+ scanResult = scanner.scanDirectory(projectRoot);
547
+ } catch (_e) {
548
+ return null;
549
+ }
550
+ // Count fragmented vs anchored tags. The exact shape varies; defensively try
551
+ // common keys. If the scanner doesn't expose fragmentation counts, we report
552
+ // total tag count instead.
553
+ const tags = Array.isArray(scanResult && scanResult.tags) ? scanResult.tags : [];
554
+ if (tags.length === 0) return null;
555
+ let fragmented = 0;
556
+ for (const t of tags) {
557
+ if (t && (t.fragmented === true || t.isFragmented === true)) fragmented++;
558
+ }
559
+ if (fragmented > 0) {
560
+ return `Will migrate ~${fragmented} fragmented tags to anchor blocks`;
561
+ }
562
+ return `Will inspect ${tags.length} tags for fragmentation`;
563
+ },
564
+
565
+ 'memory-bootstrap': (projectRoot) => {
566
+ // Count features in FEATURE-MAP.md that lack a per-feature memory file.
567
+ const featureMap = path.join(projectRoot, 'FEATURE-MAP.md');
568
+ if (!fs.existsSync(featureMap)) return null;
569
+ let raw;
570
+ try { raw = fs.readFileSync(featureMap, 'utf8'); } catch (_e) { return null; }
571
+ // Match feature IDs in headers (### F-NNN: ... or ### F-NNN — ...).
572
+ const featureIds = new Set();
573
+ const re = /^###\s+(F-\d{3,})\b/gm;
574
+ let m;
575
+ while ((m = re.exec(raw)) !== null) featureIds.add(m[1]);
576
+ if (featureIds.size === 0) return null;
577
+ const featuresDir = path.join(projectRoot, '.cap', 'memory', 'features');
578
+ let missing = 0;
579
+ for (const fid of featureIds) {
580
+ if (!fs.existsSync(path.join(featuresDir, `${fid}.md`))) missing++;
581
+ }
582
+ if (missing === 0) return null;
583
+ return `Will create ${missing} per-feature memory file${missing === 1 ? '' : 's'}`;
584
+ },
585
+
586
+ 'migrate-snapshots': (projectRoot) => {
587
+ const snapshotsDir = path.join(projectRoot, '.cap', 'snapshots');
588
+ if (!fs.existsSync(snapshotsDir)) return null;
589
+ let entries;
590
+ try {
591
+ entries = fs.readdirSync(snapshotsDir).filter((e) => e.endsWith('.md'));
592
+ } catch (_e) { return null; }
593
+ if (entries.length === 0) return null;
594
+ // Count snapshots with no `feature:` front-matter (orphaned / unlinked).
595
+ let unlinked = 0;
596
+ for (const e of entries) {
597
+ try {
598
+ const raw = fs.readFileSync(path.join(snapshotsDir, e), 'utf8');
599
+ // Check first 1 KB only — front-matter is at the top.
600
+ const head = raw.slice(0, 1024);
601
+ if (!/^---[\s\S]*?\bfeature\s*:\s*F-\d/m.test(head)) unlinked++;
602
+ } catch (_e) { /* count as unlinked */ unlinked++; }
603
+ }
604
+ if (unlinked === 0) {
605
+ return `Will inspect ${entries.length} snapshot${entries.length === 1 ? '' : 's'} (all already linked)`;
606
+ }
607
+ return `Will link ${unlinked} of ${entries.length} snapshot${entries.length === 1 ? '' : 's'} to features`;
608
+ },
609
+
610
+ 'refresh-docs': (projectRoot) => {
611
+ // Read package.json deps (top-level only).
612
+ const pkgPath = path.join(projectRoot, 'package.json');
613
+ if (!fs.existsSync(pkgPath)) return null;
614
+ let raw;
615
+ try { raw = fs.readFileSync(pkgPath, 'utf8'); } catch (_e) { return null; }
616
+ const parsed = _safeJsonParse(raw);
617
+ if (!parsed.ok) return null;
618
+ const deps = Object.assign(
619
+ Object.create(null),
620
+ parsed.value.dependencies && typeof parsed.value.dependencies === 'object' ? parsed.value.dependencies : {},
621
+ parsed.value.devDependencies && typeof parsed.value.devDependencies === 'object' ? parsed.value.devDependencies : {}
622
+ );
623
+ const names = Object.keys(deps).filter((n) => typeof n === 'string' && /^[a-z0-9@/_.-]+$/i.test(n));
624
+ if (names.length === 0) return null;
625
+ // Show first few names, capped — Stage-2 #8 surface-limit lesson.
626
+ const head = names.slice(0, 3).map((n) => _safeForError(n).slice(0, 32));
627
+ const more = names.length > 3 ? `, +${names.length - 3} more` : '';
628
+ return `Will fetch docs for libraries: ${head.join(', ')}${more}`;
629
+ },
630
+ });
631
+
632
+ // @cap-decision(F-084/iter1) Probe runner: hard timeout + error isolation. Any
633
+ // probe that throws or returns non-string is treated as null (no delta line).
634
+ function _runProbe(stageName, projectRoot) {
635
+ const probe = DELTA_PROBES[stageName];
636
+ if (typeof probe !== 'function') return null;
637
+ try {
638
+ const out = probe(projectRoot);
639
+ if (typeof out !== 'string') return null;
640
+ return _safeForError(out);
641
+ } catch (_e) {
642
+ return null;
643
+ }
644
+ }
645
+
646
+ // @cap-todo(ac:F-084/AC-3) _normalizeOptions parses runOptions into the shape
647
+ // the predicates need. Stage-2 #11 lesson: realistic-input testing — accept
648
+ // strings, arrays, undefined. Stage-name strings are validated against the
649
+ // allowlist (Stage-2 path-traversal defense).
650
+ function _normalizeOptions(opts) {
651
+ const o = (opts && typeof opts === 'object') ? opts : {};
652
+ const out = {
653
+ nonInteractive: Boolean(o.nonInteractive),
654
+ forceRerun: Boolean(o.forceRerun),
655
+ dryRunOnly: Boolean(o.dryRunOnly),
656
+ skipStages: new Set(),
657
+ includeStages: new Set(),
658
+ };
659
+ // Parse skipStages — accept array or comma-separated string.
660
+ const skipRaw = o.skipStages;
661
+ let skipList = [];
662
+ if (Array.isArray(skipRaw)) {
663
+ skipList = skipRaw;
664
+ } else if (typeof skipRaw === 'string') {
665
+ skipList = skipRaw.split(',').map((s) => s.trim()).filter(Boolean);
666
+ }
667
+ for (const name of skipList) {
668
+ // Stage-2 path-traversal: reject anything that doesn't match the allowlist.
669
+ // We DON'T throw — silently drop unknown names + log to stderr in CAP_DEBUG.
670
+ if (typeof name !== 'string') continue;
671
+ if (!STAGE_NAME_RE.test(name)) {
672
+ if (process.env.CAP_DEBUG) {
673
+ try { process.stderr.write(`[cap:debug] cap-upgrade: dropped malformed --skip-stages entry "${_safeForError(name)}"\n`); } catch (_e) { /* ignore */ }
674
+ }
675
+ continue;
676
+ }
677
+ if (!STAGE_NAMES.includes(name)) {
678
+ if (process.env.CAP_DEBUG) {
679
+ try { process.stderr.write(`[cap:debug] cap-upgrade: unknown stage "${_safeForError(name)}" in --skip-stages\n`); } catch (_e) { /* ignore */ }
680
+ }
681
+ continue;
682
+ }
683
+ out.skipStages.add(name);
684
+ }
685
+ // Parse includeStages — same as skipStages.
686
+ const includeRaw = o.includeStages;
687
+ let includeList = [];
688
+ if (Array.isArray(includeRaw)) {
689
+ includeList = includeRaw;
690
+ } else if (typeof includeRaw === 'string') {
691
+ includeList = includeRaw.split(',').map((s) => s.trim()).filter(Boolean);
692
+ }
693
+ for (const name of includeList) {
694
+ if (typeof name !== 'string') continue;
695
+ if (!STAGE_NAME_RE.test(name)) continue;
696
+ if (!STAGE_NAMES.includes(name)) continue;
697
+ out.includeStages.add(name);
698
+ }
699
+ return out;
700
+ }
701
+
702
+ // @cap-todo(ac:F-084/AC-1) planMigrations — the core planner. Reads marker +
703
+ // installed version, then walks STAGES in fixed order, asking each predicate
704
+ // whether to skip. Returns an ordered StagePlan[] with reasons.
705
+ // @cap-decision(F-084/AC-2) Stage execution order is deterministic (matches
706
+ // STAGES array). Even with --skip-stages or --include-stages permutations,
707
+ // the surviving stages keep the same relative order. Stage-2 #9 lesson.
708
+ /**
709
+ * @typedef {Object} StagePlan
710
+ * @property {string} name
711
+ * @property {string} command - /cap:* command to invoke
712
+ * @property {boolean} skip - true if this stage will be skipped
713
+ * @property {string} reason - human-readable explanation
714
+ * @property {boolean} optional - true if optional in non-interactive mode
715
+ * @property {boolean} alreadyDone - true if marker says this stage was completed at the current version
716
+ * @property {string|null} delta - per-stage delta-summary (was wird hinzugefügt/geändert) — null when no probe applies
717
+ */
718
+
719
+ /**
720
+ * @param {string} projectRoot
721
+ * @param {{installedVersion?:string, markerData?:MarkerPayload|null, runOptions?:Object}} [args]
722
+ * @returns {{installedVersion:string, markerVersion:string|null, plan:StagePlan[], firstRun:boolean, alreadyCurrent:boolean}}
723
+ */
724
+ function planMigrations(projectRoot, args) {
725
+ _validateProjectRoot(projectRoot);
726
+ const a = args || {};
727
+ const installedVersion = typeof a.installedVersion === 'string' ? a.installedVersion : getInstalledVersion();
728
+ const markerData = (a.markerData !== undefined) ? a.markerData : getMarkerVersion(projectRoot);
729
+ const runOptions = _normalizeOptions(a.runOptions);
730
+ const firstRun = markerData === null;
731
+ const markerVersion = markerData ? markerData.version : null;
732
+ const completedAtCurrent = (markerData && markerVersion === installedVersion)
733
+ ? new Set(markerData.completedStages)
734
+ : new Set();
735
+ // alreadyCurrent: marker version matches installed AND every non-optional stage was completed.
736
+ let alreadyCurrent = false;
737
+ if (markerData && markerVersion === installedVersion && !runOptions.forceRerun) {
738
+ const requiredCompleted = STAGES
739
+ .filter((s) => !s.optional)
740
+ .every((s) => completedAtCurrent.has(s.name));
741
+ alreadyCurrent = requiredCompleted;
742
+ }
743
+ const plan = [];
744
+ for (const stage of STAGES) {
745
+ const predicate = SKIP_PREDICATES[stage.name];
746
+ let result;
747
+ try {
748
+ result = predicate(projectRoot, runOptions);
749
+ } catch (e) {
750
+ // Defensive: a predicate throwing should NOT crash the planner.
751
+ result = { skip: true, reason: `predicate-error: ${_safeForError(e && e.message)}` };
752
+ }
753
+ let skip = Boolean(result.skip);
754
+ let reason = String(result.reason || '');
755
+ // alreadyDone signal: marker says this exact stage was completed at current version.
756
+ const alreadyDone = completedAtCurrent.has(stage.name) && !runOptions.forceRerun;
757
+ if (alreadyDone && !skip) {
758
+ // Marker overrides predicate — the stage was already run at this version.
759
+ skip = true;
760
+ reason = `marker shows stage completed at ${installedVersion}`;
761
+ }
762
+ // @cap-decision(F-084/iter1) Stage-2 #2 fix: per-stage delta-probes implemented (Option A).
763
+ // Probe ONLY for stages that will actually run (skip ones get null). Probes
764
+ // are read-only and degrade gracefully on any error. AC-3: "per-stage
765
+ // delta-summary (was wird hinzugefügt/geändert)".
766
+ let delta = null;
767
+ if (!skip) {
768
+ delta = _runProbe(stage.name, projectRoot);
769
+ }
770
+ plan.push({
771
+ name: stage.name,
772
+ command: stage.command,
773
+ skip,
774
+ reason,
775
+ optional: stage.optional,
776
+ alreadyDone,
777
+ delta,
778
+ });
779
+ }
780
+ return { installedVersion, markerVersion, plan, firstRun, alreadyCurrent };
781
+ }
782
+
783
+ // -------- Stage execution --------
784
+
785
+ // @cap-todo(ac:F-084/AC-4) recordStageResult records the OUTCOME of a single
786
+ // stage attempt. The actual command invocation happens in the markdown
787
+ // orchestrator — this function is the side-effect choke-point that
788
+ // updates the marker + appends the audit log. Stage-2 #4 lesson: per-stage
789
+ // isolation, a failed stage does NOT block subsequent stages.
790
+ // @cap-decision(F-084/iter1) Stage-2 #5 fix: stale comment cleanup. Function
791
+ // was previously named executeStage in earlier drafts; comment now matches.
792
+ /**
793
+ * @param {string} projectRoot
794
+ * @param {string} stageName
795
+ * @param {{status:'success'|'failure'|'skipped', reason?:string, durationMs?:number, installedVersion?:string}} outcome
796
+ * @returns {{logged:boolean, markerUpdated:boolean}}
797
+ */
798
+ function recordStageResult(projectRoot, stageName, outcome) {
799
+ _validateProjectRoot(projectRoot);
800
+ if (!STAGE_NAMES.includes(stageName)) {
801
+ throw new TypeError(`recordStageResult: unknown stage "${_safeForError(stageName)}"`);
802
+ }
803
+ if (!outcome || typeof outcome !== 'object') {
804
+ throw new TypeError('recordStageResult: outcome must be an object');
805
+ }
806
+ const status = outcome.status;
807
+ if (!['success', 'failure', 'skipped'].includes(status)) {
808
+ throw new TypeError(`recordStageResult: outcome.status must be success|failure|skipped`);
809
+ }
810
+ const timestamp = new Date().toISOString();
811
+ const logged = appendLog(projectRoot, {
812
+ stage: stageName,
813
+ status,
814
+ reason: outcome.reason,
815
+ durationMs: outcome.durationMs,
816
+ timestamp,
817
+ });
818
+ // Marker is only updated on success — failures + skips don't flip the bit.
819
+ let markerUpdated = false;
820
+ if (status === 'success') {
821
+ const installedVersion = typeof outcome.installedVersion === 'string'
822
+ ? outcome.installedVersion
823
+ : getInstalledVersion();
824
+ const existing = getMarkerVersion(projectRoot);
825
+ let completedStages;
826
+ if (existing && existing.version === installedVersion) {
827
+ const set = new Set(existing.completedStages);
828
+ set.add(stageName);
829
+ completedStages = STAGE_NAMES.filter((n) => set.has(n)); // deterministic order
830
+ } else {
831
+ // Version bumped (or first marker write) — start a fresh completed list.
832
+ completedStages = [stageName];
833
+ }
834
+ // @cap-decision(F-084/iter1) Stage-2 #4 fix: recordStageResult resilient to
835
+ // writeMarker failures. If disk fills up between stages, writeMarker
836
+ // throws (EROFS, ENOSPC, EPERM, etc). Previously the throw propagated to
837
+ // the orchestrator and crashed the whole upgrade — but the per-stage work
838
+ // already SUCCEEDED and was already logged. The stage was completed; the
839
+ // marker just couldn't be advanced. Wrap in try/catch so the upgrade
840
+ // continues; user can re-run /cap:upgrade and it will detect the partial
841
+ // marker state via predicates and resume.
842
+ try {
843
+ markerUpdated = writeMarker(projectRoot, {
844
+ version: installedVersion,
845
+ completedStages,
846
+ lastRun: timestamp,
847
+ });
848
+ } catch (e) {
849
+ markerUpdated = false;
850
+ // Best-effort: append a marker-failure entry to the log so the audit trail
851
+ // captures it. If THAT also throws (truly broken disk), swallow silently —
852
+ // we are already past the point of useful recovery.
853
+ try {
854
+ const fp = path.join(projectRoot, LOG_REL_PATH);
855
+ const safeMsg = _safeForError(e && e.message);
856
+ const failureEntry = JSON.stringify({
857
+ stage: stageName,
858
+ status: 'marker-write-failure',
859
+ reason: `marker write failed after stage success: ${safeMsg}`,
860
+ timestamp: new Date().toISOString(),
861
+ }) + '\n';
862
+ fs.writeFileSync(fp, failureEntry, { encoding: 'utf8', flag: 'a' });
863
+ } catch (_e2) { /* nothing more we can do */ }
864
+ // Single stderr warning under CAP_DEBUG — silent in normal runs (Stage-2 #4).
865
+ if (process.env.CAP_DEBUG) {
866
+ try {
867
+ process.stderr.write(`[cap:debug] cap-upgrade: marker write failed after stage "${_safeForError(stageName)}" — ${_safeForError(e && e.message)}\n`);
868
+ } catch (_e3) { /* ignore */ }
869
+ }
870
+ }
871
+ }
872
+ return { logged, markerUpdated };
873
+ }
874
+
875
+ // -------- Hook advisory throttling --------
876
+
877
+ // @cap-todo(ac:F-084/AC-6) shouldEmitAdvisory throttles SessionStart-hook
878
+ // emissions to once per session. Reads `.cap/.session-advisories.json`,
879
+ // looks for an entry keyed by the session-id, and either records a fresh
880
+ // emit OR signals "already emitted this session".
881
+ // @cap-decision(F-084/AC-6) Session ID is taken from $CLAUDE_SESSION_ID
882
+ // (Claude Code injects this into hooks). Fallback to process.ppid + start
883
+ // timestamp so we still throttle within a single shell pipeline run.
884
+ /**
885
+ * @param {string} projectRoot
886
+ * @param {{sessionId?:string, now?:number, configNotify?:boolean|null}} [opts]
887
+ * @returns {{shouldEmit:boolean, reason:string}}
888
+ */
889
+ function shouldEmitAdvisory(projectRoot, opts) {
890
+ _validateProjectRoot(projectRoot);
891
+ const o = opts || {};
892
+ // Suppression via .cap/config.json:upgrade.notify=false → silent.
893
+ if (o.configNotify === false) {
894
+ return { shouldEmit: false, reason: 'suppressed via config.upgrade.notify=false' };
895
+ }
896
+ const sessionId = (typeof o.sessionId === 'string' && o.sessionId.length > 0)
897
+ ? o.sessionId
898
+ : `pid-${process.ppid || process.pid}`;
899
+ // Session-ID validation: alphanumeric + dash + underscore + dot. Stage-2 path-
900
+ // traversal: a malicious sessionId with `..` would still be safe (we only use
901
+ // it as a JSON key, never a path segment) but we strip control bytes anyway.
902
+ const safeSessionId = _safeForError(sessionId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 128);
903
+ const fp = path.join(projectRoot, ADVISORY_REL_PATH);
904
+ let map = Object.create(null);
905
+ if (fs.existsSync(fp)) {
906
+ try {
907
+ const raw = fs.readFileSync(fp, 'utf8');
908
+ const parsed = _safeJsonParse(raw);
909
+ if (parsed.ok) {
910
+ // Only keep entries from the last 24h. Stage-2 #10 lesson: malformed
911
+ // payloads degrade silently to "fresh advisory map".
912
+ const now = typeof o.now === 'number' ? o.now : Date.now();
913
+ const TTL = 24 * 60 * 60 * 1000;
914
+ for (const key of Object.keys(parsed.value)) {
915
+ const ts = parsed.value[key];
916
+ if (typeof ts === 'string') {
917
+ const tsNum = Date.parse(ts);
918
+ if (Number.isFinite(tsNum) && (now - tsNum) < TTL) {
919
+ map[key] = ts;
920
+ }
921
+ }
922
+ }
923
+ }
924
+ } catch (_e) { /* treat as empty map */ }
925
+ }
926
+ if (Object.prototype.hasOwnProperty.call(map, safeSessionId)) {
927
+ return { shouldEmit: false, reason: 'already emitted this session' };
928
+ }
929
+ // Mark this session as emitted. Atomic write so a crash mid-write doesn't
930
+ // leave a partial file. Best-effort: if the write fails we still emit (the
931
+ // advisory is non-blocking and a missed throttle is preferable to silence).
932
+ const now = typeof o.now === 'number' ? o.now : Date.now();
933
+ map[safeSessionId] = new Date(now).toISOString();
934
+ try {
935
+ const content = JSON.stringify(map, null, 2) + '\n';
936
+ _atomicWriteFile(fp, content);
937
+ } catch (_e) {
938
+ // Non-blocking — emit anyway. The throttle is a best-effort niceness.
939
+ }
940
+ return { shouldEmit: true, reason: 'first emit this session' };
941
+ }
942
+
943
+ // @cap-todo(ac:F-084/AC-6) buildAdvisoryMessage formats the version-mismatch
944
+ // notice. Capped at 120 chars (Stage-2 #8 lesson: surface-limit). Both version
945
+ // strings are sanitized before interpolation (Stage-2 #2 lesson).
946
+ function buildAdvisoryMessage(installedVersion, markerVersion) {
947
+ const inst = _safeForError(installedVersion).slice(0, 16);
948
+ const mark = markerVersion === null ? 'unset' : _safeForError(markerVersion).slice(0, 16);
949
+ // Format: "[CAP] Run /cap:upgrade to migrate from X to Y." → kept short.
950
+ let msg;
951
+ if (markerVersion === null) {
952
+ msg = `[CAP] First run detected. Run /cap:upgrade to onboard CAP ${inst}.`;
953
+ } else {
954
+ msg = `[CAP] CAP ${inst} installed (last run: ${mark}). Run /cap:upgrade to migrate.`;
955
+ }
956
+ if (msg.length > 120) msg = msg.slice(0, 117) + '...';
957
+ return msg;
958
+ }
959
+
960
+ // @cap-todo(ac:F-084/AC-6) needsAdvisory checks if a version-mismatch warrants
961
+ // an advisory. True when installed != marker, or when marker is missing.
962
+ function needsAdvisory(installedVersion, markerVersion) {
963
+ if (typeof installedVersion !== 'string' || !_parseSemver(installedVersion)) return false;
964
+ if (markerVersion === null) return true; // first run
965
+ if (markerVersion === installedVersion) return false;
966
+ return true;
967
+ }
968
+
969
+ // -------- Top-level orchestrator entry-point --------
970
+
971
+ // @cap-todo(ac:F-084/AC-1) summarizePlan formats a StagePlan[] for stdout.
972
+ // The markdown command spec consumes this for the dry-run preview UX.
973
+ // @cap-decision(F-084/iter1) Stage-2 #2 fix: AC-3 delta-summary now appears as a
974
+ // second indented line under each [RUN] stage (when a probe returned a non-null
975
+ // string). Skipped stages keep the single-line skip-reason format.
976
+ function summarizePlan(planResult) {
977
+ if (!planResult || !Array.isArray(planResult.plan)) return '';
978
+ const lines = [];
979
+ lines.push(`CAP installed: ${_safeForError(planResult.installedVersion)}`);
980
+ lines.push(`Last run: ${planResult.markerVersion ? _safeForError(planResult.markerVersion) : 'never (first run)'}`);
981
+ lines.push(`First run: ${planResult.firstRun ? 'yes' : 'no'}`);
982
+ lines.push(`Already current: ${planResult.alreadyCurrent ? 'yes' : 'no'}`);
983
+ lines.push('');
984
+ lines.push('Stages:');
985
+ for (const s of planResult.plan) {
986
+ const status = s.skip ? ' [SKIP]' : ' [RUN] ';
987
+ lines.push(`${status} ${s.name.padEnd(20)} ${_safeForError(s.reason)}`);
988
+ // Per-stage delta-summary (AC-3). Only emitted for [RUN] stages that produced a probe.
989
+ if (!s.skip && typeof s.delta === 'string' && s.delta.length > 0) {
990
+ lines.push(` delta: ${_safeForError(s.delta)}`);
991
+ }
992
+ }
993
+ return lines.join('\n');
994
+ }
995
+
996
+ module.exports = {
997
+ // Constants
998
+ STAGES,
999
+ STAGE_NAMES,
1000
+ MARKER_REL_PATH,
1001
+ LOG_REL_PATH,
1002
+ ADVISORY_REL_PATH,
1003
+ MARKER_SCHEMA_VERSION,
1004
+ // Version
1005
+ getInstalledVersion,
1006
+ compareVersions,
1007
+ // Marker
1008
+ getMarkerVersion,
1009
+ writeMarker,
1010
+ // Log
1011
+ appendLog,
1012
+ readLog,
1013
+ // Plan + execute
1014
+ planMigrations,
1015
+ recordStageResult,
1016
+ summarizePlan,
1017
+ // Hook advisory
1018
+ shouldEmitAdvisory,
1019
+ buildAdvisoryMessage,
1020
+ needsAdvisory,
1021
+ // Internal exports for tests (Stage-2 #6 round-trip + #1 proto-pollution)
1022
+ _safeForError,
1023
+ _safeJsonParse,
1024
+ _parseSemver,
1025
+ // @cap-decision(F-084/iter1) Probe internals exposed for AC-3 delta-summary tests.
1026
+ _runProbe,
1027
+ DELTA_PROBES,
1028
+ };