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,707 @@
1
+ // @cap-feature(feature:F-056) Memory Prune Command — decay, archive, raw-log purge.
2
+ // @cap-decision Default dry-run — --apply required to mutate files (AC-2 is a data-safety commitment).
3
+ // @cap-decision Archive path uses the archival month (when pruned), not the entry's own month — simplifies filename collisions and gives a rolling history.
4
+ // @cap-decision Pinned entries (metadata.pinned:true) are never decayed nor archived — F-030 pin semantics outweigh decay/TTL.
5
+ // @cap-constraint Zero external deps — node: built-ins only.
6
+
7
+ 'use strict';
8
+
9
+ // @cap-history(sessions:3, edits:9, since:2026-04-20, learned:2026-05-07) Frequently modified — 3 sessions, 9 edits
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+
13
+ const confidence = require('./cap-memory-confidence.cjs');
14
+ const {
15
+ writeMemoryDirectory,
16
+ readMemoryFile,
17
+ MEMORY_DIR,
18
+ CATEGORY_FILES,
19
+ } = require('./cap-memory-dir.cjs');
20
+ // @cap-feature(feature:F-086) cap-memory-prune consumes the shared scope filter so its
21
+ // --gitignored mode uses the same path-decision logic as the scanner and migrator.
22
+ const scopeModule = require('./cap-scope-filter.cjs');
23
+
24
+ // --- Constants ---
25
+
26
+ const DECAY_START_DAYS = 90;
27
+ const DECAY_STEP_DAYS = 30;
28
+ const DECAY_AMOUNT = 0.05;
29
+ const ARCHIVE_CONFIDENCE_THRESHOLD = 0.2;
30
+ const ARCHIVE_AGE_DAYS = 180;
31
+ const RAW_LOG_RETENTION_DAYS = 30;
32
+ const CONFIDENCE_FLOOR = 0.0;
33
+
34
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
35
+
36
+ const RAW_LOG_DIR_PARTS = ['.cap', 'memory', 'raw'];
37
+ const ARCHIVE_DIR_PARTS = ['.cap', 'memory', 'archive'];
38
+ const PRUNE_LOG_PARTS = ['.cap', 'memory', 'prune-log.jsonl'];
39
+
40
+ // Decay-eligible categories: hotspots excluded (ranking-table format, regenerated fresh each run).
41
+ const DECAY_CATEGORIES = ['decision', 'pitfall', 'pattern'];
42
+
43
+ // --- Types ---
44
+
45
+ /**
46
+ * @typedef {{category:string, content:string, file?:string, metadata:Object}} MemoryEntry
47
+ */
48
+
49
+ // --- Pure helpers ---
50
+
51
+ /**
52
+ * @param {Date|string|undefined|null} value
53
+ * @returns {number|null} milliseconds since epoch, or null if invalid
54
+ */
55
+ function toMillis(value) {
56
+ if (value instanceof Date) {
57
+ const ms = value.getTime();
58
+ return Number.isFinite(ms) ? ms : null;
59
+ }
60
+ if (typeof value === 'string' && value.length > 0) {
61
+ const ms = Date.parse(value);
62
+ if (!Number.isFinite(ms)) return null;
63
+ // Date.parse silently normalises overflow calendar dates ("2026-02-30" → March 2),
64
+ // which would make "invalid" inputs yield plausible ages. Reject anything whose
65
+ // YYYY-MM-DD prefix doesn't roundtrip through the parsed timestamp.
66
+ const isoPrefix = value.substring(0, 10);
67
+ if (/^\d{4}-\d{2}-\d{2}$/.test(isoPrefix) && new Date(ms).toISOString().substring(0, 10) !== isoPrefix) {
68
+ return null;
69
+ }
70
+ return ms;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ /**
76
+ * Whole days between two Date-or-ISO-string inputs. UTC-aligned, floored.
77
+ * Invalid inputs yield Infinity (semantically "very old").
78
+ * @param {Date|string} a
79
+ * @param {Date|string} b
80
+ * @returns {number}
81
+ */
82
+ function daysBetween(a, b) {
83
+ const ma = toMillis(a);
84
+ const mb = toMillis(b);
85
+ if (ma === null || mb === null) return Infinity;
86
+ return Math.floor(Math.abs(mb - ma) / MS_PER_DAY);
87
+ }
88
+
89
+ /**
90
+ * Compute decayed confidence for an entry.
91
+ * 0 steps when age <= DECAY_START_DAYS.
92
+ * Otherwise floor((age - start) / step) decay events of DECAY_AMOUNT each.
93
+ * Floored at CONFIDENCE_FLOOR.
94
+ * @cap-todo(ac:F-056/AC-3)
95
+ * @param {number} currentConfidence
96
+ * @param {Date|string} lastSeen
97
+ * @param {Date} now
98
+ * @returns {{newConfidence:number, steps:number}}
99
+ */
100
+ function computeDecay(currentConfidence, lastSeen, now) {
101
+ const age = daysBetween(lastSeen, now);
102
+ if (!Number.isFinite(age)) {
103
+ // "Very old" — run decay until floor.
104
+ const raw = typeof currentConfidence === 'number' ? currentConfidence : confidence.DEFAULT_CONFIDENCE;
105
+ return { newConfidence: CONFIDENCE_FLOOR, steps: Math.ceil(raw / DECAY_AMOUNT) };
106
+ }
107
+ if (age <= DECAY_START_DAYS) {
108
+ return { newConfidence: round2(currentConfidence), steps: 0 };
109
+ }
110
+ const steps = Math.floor((age - DECAY_START_DAYS) / DECAY_STEP_DAYS);
111
+ if (steps <= 0) {
112
+ return { newConfidence: round2(currentConfidence), steps: 0 };
113
+ }
114
+ const raw = Math.max(CONFIDENCE_FLOOR, currentConfidence - steps * DECAY_AMOUNT);
115
+ return { newConfidence: round2(raw), steps };
116
+ }
117
+
118
+ /**
119
+ * @cap-todo(ac:F-056/AC-4)
120
+ * @param {number} conf
121
+ * @param {Date|string} lastSeen
122
+ * @param {Date} now
123
+ * @returns {boolean}
124
+ */
125
+ function shouldArchive(conf, lastSeen, now) {
126
+ const age = daysBetween(lastSeen, now);
127
+ if (typeof conf !== 'number' || Number.isNaN(conf)) return false;
128
+ return conf < ARCHIVE_CONFIDENCE_THRESHOLD && age > ARCHIVE_AGE_DAYS;
129
+ }
130
+
131
+ /**
132
+ * Two-decimal rounding to keep markdown clean (avoids 0.30000000000000004).
133
+ * @param {number} n
134
+ * @returns {number}
135
+ */
136
+ function round2(n) {
137
+ if (typeof n !== 'number' || Number.isNaN(n)) return 0;
138
+ return Math.round(n * 100) / 100;
139
+ }
140
+
141
+ /**
142
+ * Split entries into kept / decayed / archived buckets.
143
+ * Decay is applied BEFORE the archive check so an entry that crosses the
144
+ * ARCHIVE_CONFIDENCE_THRESHOLD *due to decay* is archived in the same run.
145
+ * Pinned entries bypass both.
146
+ * @cap-todo(ac:F-056/AC-3)
147
+ * @cap-todo(ac:F-056/AC-4)
148
+ * @param {MemoryEntry[]} entries
149
+ * @param {Date} now
150
+ * @returns {{kept:MemoryEntry[], decayed:Array<{entry:MemoryEntry, oldConf:number, newConf:number, steps:number}>, archived:MemoryEntry[]}}
151
+ */
152
+ function classifyEntries(entries, now) {
153
+ const kept = [];
154
+ const decayed = [];
155
+ const archived = [];
156
+
157
+ for (const raw of entries || []) {
158
+ if (!raw || !raw.metadata) {
159
+ kept.push(raw);
160
+ continue;
161
+ }
162
+
163
+ const meta = confidence.ensureFields(raw.metadata);
164
+ const entry = { ...raw, metadata: meta };
165
+
166
+ if (meta.pinned === true) {
167
+ kept.push(entry);
168
+ continue;
169
+ }
170
+
171
+ const { newConfidence, steps } = computeDecay(meta.confidence, meta.last_seen, now);
172
+ const didDecay = steps > 0 && newConfidence !== meta.confidence;
173
+
174
+ const postDecayMeta = didDecay
175
+ ? { ...meta, confidence: newConfidence }
176
+ : meta;
177
+ const postDecayEntry = didDecay
178
+ ? { ...entry, metadata: postDecayMeta }
179
+ : entry;
180
+
181
+ if (shouldArchive(postDecayMeta.confidence, postDecayMeta.last_seen, now)) {
182
+ archived.push(postDecayEntry);
183
+ continue;
184
+ }
185
+
186
+ if (didDecay) {
187
+ decayed.push({ entry: postDecayEntry, oldConf: meta.confidence, newConf: newConfidence, steps });
188
+ }
189
+ kept.push(postDecayEntry);
190
+ }
191
+
192
+ return { kept, decayed, archived };
193
+ }
194
+
195
+ // --- Raw-log selection (AC-5) ---
196
+
197
+ const RAW_LOG_FILENAME_RE = /^tag-events-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
198
+
199
+ /**
200
+ * Select raw-event-log files older than maxAgeDays.
201
+ * Ignores files without the tag-events-YYYY-MM-DD prefix, invalid dates, and subdirectories.
202
+ * @cap-todo(ac:F-056/AC-5)
203
+ * @param {string} rawDir - Absolute directory path
204
+ * @param {Date} now
205
+ * @param {number} [maxAgeDays=RAW_LOG_RETENTION_DAYS]
206
+ * @returns {string[]} Absolute paths of stale log files
207
+ */
208
+ function selectStaleRawLogs(rawDir, now, maxAgeDays = RAW_LOG_RETENTION_DAYS) {
209
+ if (!rawDir || !fs.existsSync(rawDir)) return [];
210
+
211
+ let entries;
212
+ try {
213
+ entries = fs.readdirSync(rawDir, { withFileTypes: true });
214
+ } catch {
215
+ return [];
216
+ }
217
+
218
+ const stale = [];
219
+ for (const dirent of entries) {
220
+ if (!dirent.isFile()) continue;
221
+ const m = RAW_LOG_FILENAME_RE.exec(dirent.name);
222
+ if (!m) continue;
223
+ const iso = `${m[1]}-${m[2]}-${m[3]}T00:00:00.000Z`;
224
+ const age = daysBetween(iso, now);
225
+ if (!Number.isFinite(age)) continue;
226
+ if (age > maxAgeDays) {
227
+ stale.push(path.join(rawDir, dirent.name));
228
+ }
229
+ }
230
+ return stale;
231
+ }
232
+
233
+ // --- Reporting ---
234
+
235
+ /**
236
+ * Human-readable report block.
237
+ * @param {{dryRun:boolean, decayed:number, archived:number, purged:number, rawLogFiles:string[], migrationWarning?:boolean}} result
238
+ * @returns {string}
239
+ */
240
+ function formatReport(result) {
241
+ const mode = result.dryRun ? 'DRY-RUN (no files written)' : 'APPLIED';
242
+ const lines = [
243
+ 'Memory Prune Report',
244
+ ` Mode: ${mode}`,
245
+ ` Decayed: ${result.decayed}`,
246
+ ` Archived: ${result.archived}`,
247
+ ` Purged: ${result.purged} raw-log file(s)`,
248
+ ];
249
+ if (result.rawLogFiles && result.rawLogFiles.length > 0) {
250
+ lines.push(' Raw logs targeted:');
251
+ for (const f of result.rawLogFiles) lines.push(` - ${path.basename(f)}`);
252
+ }
253
+ if (result.migrationWarning) {
254
+ lines.push('');
255
+ lines.push('Warning: archive count dwarfs decay count — likely a first-run migration');
256
+ lines.push(' of pre-F-055 memory files (missing last_seen, treated as Infinity-age).');
257
+ lines.push(' Review archived entries before committing.');
258
+ }
259
+ if (result.dryRun) {
260
+ lines.push('');
261
+ lines.push('Rerun with --apply to commit these changes.');
262
+ }
263
+ return lines.join('\n');
264
+ }
265
+
266
+ /**
267
+ * Single-line JSONL record for prune-log.jsonl.
268
+ * @cap-todo(ac:F-056/AC-6)
269
+ * @param {{dryRun:boolean, decayed:number, archived:number, purged:number, archiveFile?:string|null, errors?:Array}} result
270
+ * @param {Date} now
271
+ * @returns {string}
272
+ */
273
+ function formatPruneLogEntry(result, now) {
274
+ const payload = {
275
+ timestamp: (now instanceof Date ? now : new Date()).toISOString(),
276
+ dryRun: !!result.dryRun,
277
+ decayed: result.decayed | 0,
278
+ archived: result.archived | 0,
279
+ purged: result.purged | 0,
280
+ // Additive (new in review follow-up): keep the payload shape extensible without
281
+ // breaking older log-consumers that read only the original keys.
282
+ archiveFile: result.archiveFile ? path.basename(result.archiveFile) : null,
283
+ errorCount: Array.isArray(result.errors) ? result.errors.length : 0,
284
+ };
285
+ return JSON.stringify(payload) + '\n';
286
+ }
287
+
288
+ /**
289
+ * Normalise an Error-or-string into a short, log-safe message string.
290
+ * @param {unknown} err
291
+ * @returns {string}
292
+ */
293
+ function errorMessage(err) {
294
+ if (!err) return '';
295
+ if (typeof err === 'string') return err;
296
+ if (err && typeof err.message === 'string') return err.message;
297
+ return String(err);
298
+ }
299
+
300
+ // --- Archive writing ---
301
+
302
+ /**
303
+ * Format a single archived entry as a markdown block for the archive file.
304
+ * @param {MemoryEntry} entry
305
+ * @param {Date} now
306
+ * @returns {string}
307
+ */
308
+ function formatArchivedEntry(entry, now) {
309
+ const meta = confidence.ensureFields(entry.metadata);
310
+ const safeContent = String(entry.content || '').replace(/[\r\n]+/g, ' ');
311
+ const files = meta.relatedFiles?.length > 0
312
+ ? meta.relatedFiles.map((f) => `\`${f}\``).join(', ')
313
+ : 'cross-cutting';
314
+ const features = meta.features?.length > 0 ? ` (${meta.features.join(', ')})` : '';
315
+ const date = meta.source ? String(meta.source).substring(0, 10) : 'unknown';
316
+ const archivedAt = (now instanceof Date ? now : new Date()).toISOString();
317
+ return [
318
+ `### ${safeContent}`,
319
+ `- **Category:** ${entry.category}`,
320
+ `- **Date:** ${date}${features}`,
321
+ `- **Files:** ${files}`,
322
+ `- **Confidence:** ${meta.confidence.toFixed(2)}`,
323
+ `- **Evidence:** ${meta.evidence_count}`,
324
+ `- **Last Seen:** ${meta.last_seen}`,
325
+ `- **Archived At:** ${archivedAt}`,
326
+ '',
327
+ ].join('\n');
328
+ }
329
+
330
+ /**
331
+ * Append-or-create archive markdown for the current archival month.
332
+ * Idempotent: multiple runs in the same month append to the same file.
333
+ * @cap-todo(ac:F-056/AC-4)
334
+ * @param {string} archiveDir
335
+ * @param {MemoryEntry[]} archivedEntries
336
+ * @param {Date} now
337
+ * @returns {string|null} filepath written, or null if nothing to archive
338
+ */
339
+ function writeArchive(archiveDir, archivedEntries, now) {
340
+ if (!archivedEntries || archivedEntries.length === 0) return null;
341
+ const d = now instanceof Date ? now : new Date();
342
+ const yyyy = d.getUTCFullYear();
343
+ const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
344
+ const filename = `${yyyy}-${mm}.md`;
345
+ const filepath = path.join(archiveDir, filename);
346
+
347
+ fs.mkdirSync(archiveDir, { recursive: true });
348
+
349
+ let body = '';
350
+ if (!fs.existsSync(filepath)) {
351
+ body += `# Memory Archive: ${yyyy}-${mm}\n\n`;
352
+ body += '> Entries archived from project memory because they fell below confidence and age thresholds.\n';
353
+ body += '> Archive is additive — entries can be appended but are not mutated in place.\n\n';
354
+ }
355
+ for (const entry of archivedEntries) {
356
+ body += formatArchivedEntry(entry, d);
357
+ }
358
+ fs.appendFileSync(filepath, body, 'utf8');
359
+ return filepath;
360
+ }
361
+
362
+ // --- Main entry point ---
363
+
364
+ /**
365
+ * Classify entries across all decay-eligible category files.
366
+ * @param {string} memDir
367
+ * @param {Date} now
368
+ * @returns {{perCategoryKept:Object, allDecayed:Array, allArchived:MemoryEntry[], errors:Array}}
369
+ */
370
+ function classifyPhase(memDir, now) {
371
+ const allDecayed = [];
372
+ const allArchived = [];
373
+ const perCategoryKept = {};
374
+ const errors = [];
375
+
376
+ for (const category of DECAY_CATEGORIES) {
377
+ try {
378
+ const fp = path.join(memDir, CATEGORY_FILES[category]);
379
+ const { entries } = readMemoryFile(fp);
380
+ const enriched = entries.map((e) => ({ category, content: e.content, metadata: e.metadata }));
381
+ const { kept, decayed, archived } = classifyEntries(enriched, now);
382
+ perCategoryKept[category] = kept;
383
+ for (const d of decayed) allDecayed.push(d);
384
+ for (const a of archived) allArchived.push(a);
385
+ } catch (err) {
386
+ errors.push({ stage: `classify:${category}`, message: errorMessage(err) });
387
+ perCategoryKept[category] = [];
388
+ }
389
+ }
390
+
391
+ return { perCategoryKept, allDecayed, allArchived, errors };
392
+ }
393
+
394
+ /**
395
+ * Apply the side-effects of a prune run: write archive, rewrite memory files,
396
+ * purge stale raw logs, append the prune-log record. Each stage captures its
397
+ * own errors into `errors`; archive/memory failures short-circuit the
398
+ * remainder to avoid partial rewrites.
399
+ *
400
+ * @param {{projectRoot:string, archiveDir:string, pruneLogPath:string, rawLogFiles:string[], allArchived:MemoryEntry[], perCategoryKept:Object, now:Date}} ctx
401
+ * @param {Object} result - mutated in place with final counts + archiveFile
402
+ * @param {Array} errors - mutated in place with per-stage failures
403
+ * @returns {void}
404
+ */
405
+ function applySideEffects(ctx, result, errors) {
406
+ const { projectRoot, archiveDir, pruneLogPath, rawLogFiles, allArchived, perCategoryKept, now } = ctx;
407
+
408
+ const entriesToWrite = [];
409
+ for (const category of DECAY_CATEGORIES) {
410
+ for (const e of perCategoryKept[category] || []) {
411
+ entriesToWrite.push({ category, content: e.content, file: e.file, metadata: e.metadata });
412
+ }
413
+ }
414
+
415
+ // Archive first so a failed archive leaves live memory intact and archived
416
+ // entries remain recoverable on the next run. If archive succeeds but memory
417
+ // rewrite fails, we re-archive idempotent duplicates — never data loss.
418
+ try {
419
+ result.archiveFile = writeArchive(archiveDir, allArchived, now);
420
+ } catch (err) {
421
+ errors.push({ stage: 'write-archive', message: errorMessage(err) });
422
+ return;
423
+ }
424
+
425
+ try {
426
+ writeMemoryDirectory(projectRoot, entriesToWrite);
427
+ } catch (err) {
428
+ errors.push({ stage: 'write-memory', message: errorMessage(err) });
429
+ return;
430
+ }
431
+
432
+ const purgedOk = [];
433
+ for (const f of rawLogFiles) {
434
+ try {
435
+ fs.unlinkSync(f);
436
+ purgedOk.push(f);
437
+ } catch (err) {
438
+ errors.push({ stage: 'unlink-raw-log', message: `${path.basename(f)}: ${errorMessage(err)}` });
439
+ }
440
+ }
441
+ result.purgedFiles = purgedOk;
442
+ result.purged = purgedOk.length;
443
+
444
+ try {
445
+ fs.mkdirSync(path.dirname(pruneLogPath), { recursive: true });
446
+ fs.appendFileSync(pruneLogPath, formatPruneLogEntry(result, now), 'utf8');
447
+ } catch (err) {
448
+ errors.push({ stage: 'append-prune-log', message: errorMessage(err) });
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Prune project memory: decay stale entries, archive very-stale low-confidence ones,
454
+ * purge old raw-event-log files.
455
+ *
456
+ * @cap-todo(ac:F-056/AC-1)
457
+ * @cap-todo(ac:F-056/AC-2)
458
+ * @cap-todo(ac:F-056/AC-6)
459
+ * @param {string} projectRoot
460
+ * @param {{apply?:boolean, now?:Date}} [options]
461
+ * @returns {{dryRun:boolean, decayed:number, archived:number, purged:number, decayedEntries:Array, archivedEntries:MemoryEntry[], purgedFiles:string[], rawLogFiles:string[], archiveFile:string|null, migrationWarning:boolean, errors:Array<{stage:string, message:string}>}}
462
+ */
463
+ function prune(projectRoot, options = {}) {
464
+ const now = options.now instanceof Date ? options.now : new Date();
465
+ const apply = options.apply === true;
466
+
467
+ const memDir = path.join(projectRoot, MEMORY_DIR);
468
+ const rawDir = path.join(projectRoot, ...RAW_LOG_DIR_PARTS);
469
+ const archiveDir = path.join(projectRoot, ...ARCHIVE_DIR_PARTS);
470
+ const pruneLogPath = path.join(projectRoot, ...PRUNE_LOG_PARTS);
471
+
472
+ const { perCategoryKept, allDecayed, allArchived, errors } = classifyPhase(memDir, now);
473
+
474
+ // selectStaleRawLogs already swallows its own I/O errors (empty/missing dir,
475
+ // readdir failure) and returns []. Wrapping it in an outer try/catch was
476
+ // redundant dead code — the inner handler is authoritative.
477
+ const rawLogFiles = selectStaleRawLogs(rawDir, now, RAW_LOG_RETENTION_DAYS);
478
+
479
+ // Migration warning: if a run archives dramatically more than it decays,
480
+ // the most likely cause is pre-F-055 memory files that lack last_seen —
481
+ // classifyEntries then treats them as Infinity-age and archives wholesale.
482
+ // Flag so the CLI can surface a "looks like a first-run migration" hint.
483
+ const migrationWarning = allArchived.length >= 5 && allArchived.length > allDecayed.length * 4;
484
+
485
+ const result = {
486
+ dryRun: !apply,
487
+ decayed: allDecayed.length,
488
+ archived: allArchived.length,
489
+ purged: rawLogFiles.length,
490
+ decayedEntries: allDecayed,
491
+ archivedEntries: allArchived,
492
+ purgedFiles: rawLogFiles.slice(),
493
+ rawLogFiles: rawLogFiles.slice(),
494
+ archiveFile: null,
495
+ migrationWarning,
496
+ errors,
497
+ };
498
+
499
+ if (!apply) return result;
500
+
501
+ applySideEffects(
502
+ { projectRoot, archiveDir, pruneLogPath, rawLogFiles, allArchived, perCategoryKept, now },
503
+ result,
504
+ errors,
505
+ );
506
+
507
+ return result;
508
+ }
509
+
510
+ // ---------------------------------------------------------------------------
511
+ // F-086/AC-3: pruneGitignored — clean already-existing memory files of entries
512
+ // whose related-files would now be excluded by the scope filter. Useful for
513
+ // projects that bootstrapped with a pre-F-085 CAP version and accumulated
514
+ // build-output decisions / bundle-artefact references in their memory files.
515
+
516
+ // @cap-todo(ac:F-086/AC-3) V6 platform-memory bullet pattern: bullet line ending with a
517
+ // backtick-wrapped path:linenum reference. We extract the path, drop the line if the
518
+ // scope filter would exclude it. Auto-block markers (cap:auto:start / end) and headings
519
+ // are preserved verbatim.
520
+ const V6_BULLET_PATH_RE = /`([^`\s][^`]*?)(?::\d+)?`\s*$/;
521
+
522
+ /**
523
+ * Scan V5 monolith files (decisions/pitfalls/patterns/hotspots) and V6 platform/feature
524
+ * files for entries whose source file is now out-of-scope per the scope filter.
525
+ *
526
+ * Default behaviour is dry-run; pass `apply: true` to rewrite the files. Returns counts
527
+ * + per-file diffs so callers can render a report before committing.
528
+ *
529
+ * @param {string} projectRoot
530
+ * @param {{apply?: boolean, scope?: import('./cap-scope-filter.cjs').ScopeFilter}} [options]
531
+ * @returns {{
532
+ * dryRun: boolean,
533
+ * v5RemovedTotal: number,
534
+ * v6RemovedTotal: number,
535
+ * v5Files: Array<{file: string, removed: string[], kept: number}>,
536
+ * v6Files: Array<{file: string, removed: string[], kept: number}>,
537
+ * errors: Array<{stage: string, message: string}>
538
+ * }}
539
+ */
540
+ function pruneGitignored(projectRoot, options) {
541
+ const opts = options || {};
542
+ const apply = opts.apply === true;
543
+ const scope = opts.scope || scopeModule.buildScopeFilter(projectRoot);
544
+
545
+ const result = {
546
+ dryRun: !apply,
547
+ v5RemovedTotal: 0,
548
+ v6RemovedTotal: 0,
549
+ v5Files: [],
550
+ v6Files: [],
551
+ errors: [],
552
+ };
553
+
554
+ // ---- V5 monolith files ----
555
+ for (const [, filename] of Object.entries(CATEGORY_FILES)) {
556
+ const fp = path.join(projectRoot, MEMORY_DIR, filename);
557
+ if (!fs.existsSync(fp)) continue;
558
+ try {
559
+ const { entries } = readMemoryFile(fp);
560
+ const kept = [];
561
+ const removed = [];
562
+ for (const entry of entries) {
563
+ const files = (entry.metadata && Array.isArray(entry.metadata.relatedFiles)) ? entry.metadata.relatedFiles : [];
564
+ // Drop entry only when ALL related files are out-of-scope (and there's at least one to judge by).
565
+ if (files.length > 0 && files.every((f) => scope.isExcluded(path.resolve(projectRoot, f), false))) {
566
+ removed.push(entry.content);
567
+ } else {
568
+ kept.push(entry);
569
+ }
570
+ }
571
+ result.v5RemovedTotal += removed.length;
572
+ result.v5Files.push({ file: path.relative(projectRoot, fp), removed, kept: kept.length });
573
+ if (apply && removed.length > 0) {
574
+ // Re-render via writeMemoryDirectory in non-merge mode (full overwrite of this file's category).
575
+ // The `category` is derived from the filename via reverse-lookup.
576
+ const category = Object.entries(CATEGORY_FILES).find(([, fn]) => fn === filename)[0];
577
+ const re = require('./cap-memory-dir.cjs');
578
+ const md = re.generateCategoryMarkdown
579
+ ? re.generateCategoryMarkdown(category, kept.map((e) => ({ category, ...e })))
580
+ : null;
581
+ if (md != null) {
582
+ const tmp = fp + '.tmp';
583
+ fs.writeFileSync(tmp, md, 'utf8');
584
+ fs.renameSync(tmp, fp);
585
+ }
586
+ }
587
+ } catch (err) {
588
+ result.errors.push({ stage: `v5:${filename}`, message: errorMessage(err) });
589
+ }
590
+ }
591
+
592
+ // ---- V6 platform/feature files ----
593
+ // Walk .cap/memory/platform/*.md and .cap/memory/features/*.md
594
+ const v6Dirs = [
595
+ path.join(projectRoot, MEMORY_DIR, 'platform'),
596
+ path.join(projectRoot, MEMORY_DIR, 'features'),
597
+ ];
598
+ for (const dir of v6Dirs) {
599
+ if (!fs.existsSync(dir)) continue;
600
+ let entries;
601
+ try {
602
+ entries = fs.readdirSync(dir);
603
+ } catch (err) {
604
+ result.errors.push({ stage: `v6:readdir:${path.relative(projectRoot, dir)}`, message: errorMessage(err) });
605
+ continue;
606
+ }
607
+ for (const name of entries) {
608
+ if (!name.endsWith('.md')) continue;
609
+ const fp = path.join(dir, name);
610
+ try {
611
+ const raw = fs.readFileSync(fp, 'utf8');
612
+ const lines = raw.split('\n');
613
+ const newLines = [];
614
+ const removed = [];
615
+ let kept = 0;
616
+ for (const line of lines) {
617
+ const isBullet = /^- /.test(line);
618
+ if (isBullet) {
619
+ const match = V6_BULLET_PATH_RE.exec(line);
620
+ if (match) {
621
+ const filePath = match[1];
622
+ if (scope.isExcluded(path.resolve(projectRoot, filePath), false)) {
623
+ removed.push(line.trim());
624
+ continue; // drop this line
625
+ }
626
+ kept++;
627
+ }
628
+ }
629
+ newLines.push(line);
630
+ }
631
+ result.v6RemovedTotal += removed.length;
632
+ result.v6Files.push({ file: path.relative(projectRoot, fp), removed, kept });
633
+ if (apply && removed.length > 0) {
634
+ const tmp = fp + '.tmp';
635
+ fs.writeFileSync(tmp, newLines.join('\n'), 'utf8');
636
+ fs.renameSync(tmp, fp);
637
+ }
638
+ } catch (err) {
639
+ result.errors.push({ stage: `v6:${path.relative(projectRoot, fp)}`, message: errorMessage(err) });
640
+ }
641
+ }
642
+ }
643
+
644
+ return result;
645
+ }
646
+
647
+ /**
648
+ * Format a prune-gitignored result as a human-readable report.
649
+ * @param {ReturnType<typeof pruneGitignored>} result
650
+ * @returns {string}
651
+ */
652
+ function formatGitignoredReport(result) {
653
+ const lines = [];
654
+ lines.push(`cap:memory prune --gitignored ${result.dryRun ? '(dry-run)' : '(applied)'}`);
655
+ lines.push(` V5 entries removed: ${result.v5RemovedTotal}`);
656
+ lines.push(` V6 lines removed: ${result.v6RemovedTotal}`);
657
+ if (result.v5Files.length > 0) {
658
+ const dirty = result.v5Files.filter((f) => f.removed.length > 0);
659
+ if (dirty.length > 0) {
660
+ lines.push('');
661
+ lines.push('V5 files affected:');
662
+ for (const f of dirty) lines.push(` ${f.file} — ${f.removed.length} removed, ${f.kept} kept`);
663
+ }
664
+ }
665
+ if (result.v6Files.length > 0) {
666
+ const dirty = result.v6Files.filter((f) => f.removed.length > 0);
667
+ if (dirty.length > 0) {
668
+ lines.push('');
669
+ lines.push('V6 files affected:');
670
+ for (const f of dirty) lines.push(` ${f.file} — ${f.removed.length} removed, ${f.kept} kept`);
671
+ }
672
+ }
673
+ if (result.errors.length > 0) {
674
+ lines.push('');
675
+ lines.push('Errors:');
676
+ for (const e of result.errors) lines.push(` ${e.stage}: ${e.message}`);
677
+ }
678
+ return lines.join('\n');
679
+ }
680
+
681
+ module.exports = {
682
+ DECAY_START_DAYS,
683
+ DECAY_STEP_DAYS,
684
+ DECAY_AMOUNT,
685
+ ARCHIVE_CONFIDENCE_THRESHOLD,
686
+ ARCHIVE_AGE_DAYS,
687
+ RAW_LOG_RETENTION_DAYS,
688
+ CONFIDENCE_FLOOR,
689
+
690
+ daysBetween,
691
+ computeDecay,
692
+ shouldArchive,
693
+ classifyEntries,
694
+ selectStaleRawLogs,
695
+ formatReport,
696
+ formatPruneLogEntry,
697
+ formatArchivedEntry,
698
+ writeArchive,
699
+ // Exported for unit testing — not part of the CLI surface.
700
+ classifyPhase,
701
+ applySideEffects,
702
+ errorMessage,
703
+ prune,
704
+ // F-086/AC-3
705
+ pruneGitignored,
706
+ formatGitignoredReport,
707
+ };