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,1075 @@
1
+ // @cap-context CAP F-072 Compute Two-Layer Fitness Score — drives F-074 Pattern Unlearn. Pure-compute,
2
+ // deterministic, zero external deps. Reads only via cap-pattern-pipeline.listPatterns and
3
+ // cap-learning-signals.getSignals; writes per-pattern fitness records and append-only
4
+ // apply-snapshot JSONL under .cap/learning/fitness/. Layer 1 is a short-term Override
5
+ // *count* over the most-recently-observed sessionId; Layer 2 is a long-term per-session
6
+ // weighted average that activates at n >= 5 active sessions.
7
+ // @cap-decision(F-072/D1) AC-1 metric is a COUNT, not a rate. The number of override records in the most
8
+ // recent session whose evidence.candidateId matches the pattern's evidence.candidateId.
9
+ // Fallback path (defensive): when the pattern's evidence carries no candidateId, fall back
10
+ // to featureRef matching against the override record's featureId. Locked by user direction.
11
+ // @cap-decision(F-072/D2) AC-2 norm = active-session count (per-session average). Layer 2 activates at
12
+ // n >= 5; below that, the value is still computed (AC-5 requires the data exists from day
13
+ // one) and ready=false signals "do not display yet". Locked by user direction.
14
+ // @cap-decision(F-072/D3) "Pattern was active in session" = at least one signal in that session matches
15
+ // ONE of: evidence.candidateId on an override; OR evidence.candidateId on a regret; OR a
16
+ // memoryFileHash that the pattern references (memory-ref signals). Pinned definition;
17
+ // used by both Layer-2 norm and the AC-4 expired-after-20-sessions check.
18
+ // @cap-decision(F-072/D4) "Last session" for AC-1 = the most-recent sessionId observed in the override
19
+ // JSONL — NOT a wall-clock cut-off. Determinism (AC-7) requires no time-based gates.
20
+ // "Most-recent" is computed deterministically: the override record with the maximum
21
+ // ts string (lexicographic ISO-8601 sort) wins; ties resolved by the record's id field.
22
+ // @cap-decision(F-072/D5) Apply-snapshots are APPEND-ONLY (.snapshots.jsonl). Each call to
23
+ // recordApplySnapshot appends a fresh line — multiple applies in the same session
24
+ // produce multiple lines. F-074 reads the tail to compare pre-apply vs post-apply.
25
+ // AC-3 "Rolling-30-Sessions AND Lifetime aggregates simultaneously" is satisfied by
26
+ // splitting the two responsibilities: the canonical .json record IS the lifetime
27
+ // aggregate (cumulative across all sessions); the .snapshots.jsonl IS the rolling
28
+ // sequence (one line per apply event). F-074 / F-073 read both. User-confirmed
29
+ // before ship; an alternative (compute a 30-session rolling window inside the
30
+ // canonical record) was considered and rejected because it would double the formula
31
+ // surface and require a deterministic "last 30" cut-off without a clear use case yet.
32
+ // @cap-decision(F-072/D6) AC-7 zero-deps + deterministic. Wherever Sets/Maps drive iteration we sort
33
+ // the keys before consuming them. randomBytes / Date.now / Math.random are forbidden
34
+ // inside the score formulas (the persisted ts is the ONLY allowed time source, and it
35
+ // enters via options.now → never the formula).
36
+ // @cap-constraint Zero external dependencies: node:fs + node:path only. We never read overrides.jsonl /
37
+ // memory-refs.jsonl / regrets.jsonl directly — always via cap-learning-signals.getSignals.
38
+ // We never read pattern files directly — always via cap-pattern-pipeline.listPatterns.
39
+ // Single source of truth for both queries. cap-telemetry.hashContext is available if a
40
+ // hash is ever needed, but this module currently doesn't need one.
41
+ // @cap-risk(F-072/AC-7) DETERMINISM BOUNDARY — every code path that uses Set/Map for iteration MUST
42
+ // sort keys before iterating. Every code path that touches time MUST route through
43
+ // options.now and never let the timestamp affect the formula. The adversarial test
44
+ // runs computeFitness 10x and shuffles signal-record ordering to assert byte-level
45
+ // equality of the resulting FitnessRecord.
46
+
47
+ 'use strict';
48
+
49
+ // @cap-feature(feature:F-072, primary:true) Compute Two-Layer Fitness Score — short-term override-count
50
+ // + long-term weighted memory-ref/regret average per pattern.
51
+
52
+ const fs = require('node:fs');
53
+ const path = require('node:path');
54
+
55
+ const learningSignals = require('./cap-learning-signals.cjs');
56
+ const patternPipeline = require('./cap-pattern-pipeline.cjs');
57
+
58
+ // -----------------------------------------------------------------------------
59
+ // Constants — top-of-file so consumers (F-073, F-074, /cap:learn) and tests
60
+ // reference exactly one place. Mirrors cap-pattern-pipeline.cjs layout.
61
+ // -----------------------------------------------------------------------------
62
+
63
+ const CAP_DIR = '.cap';
64
+ const LEARNING_DIR = 'learning';
65
+ const FITNESS_DIR = 'fitness';
66
+
67
+ // AC-2: Layer 2 activates at n >= 5 active sessions. Below that, ready=false but the
68
+ // value is still computed and persisted (AC-5).
69
+ const LAYER2_READY_THRESHOLD = 5;
70
+
71
+ // AC-2 weights. memory-ref signals (positive — "this pattern's territory was useful")
72
+ // count as 1; regret signals (negative — "we now wish we'd done differently") count as 2.
73
+ // Locked top-of-file so a future tuning lives in one place and the adversarial tests can
74
+ // verify exact behaviour.
75
+ const WEIGHT_MEMORY_REF = 1;
76
+ const WEIGHT_REGRET = 2;
77
+
78
+ // AC-4: a pattern that has been observed in zero sessions over the last EXPIRY_SESSIONS
79
+ // sessions worth of signals is auto-marked expired. The "last 20 sessions" window is
80
+ // computed from the union of session ids across the three signal types — NOT from a
81
+ // wall-clock window (D6 forbids time-based gates inside the formulas).
82
+ const EXPIRY_SESSIONS = 20;
83
+
84
+ // File-name shapes.
85
+ const FITNESS_JSON_SUFFIX = '.json';
86
+ const FITNESS_SNAPSHOTS_SUFFIX = '.snapshots.jsonl';
87
+
88
+ // Pattern-id format mirror — duplicated here only for the regex; the canonical
89
+ // allocator lives in cap-pattern-pipeline.cjs.
90
+ const PATTERN_ID_RE = /^P-\d+$/;
91
+
92
+ /**
93
+ * @typedef {Object} FitnessLayer1
94
+ * @property {'override-count'} kind
95
+ * @property {number} value - Number of overrides in the most-recent session whose evidence.candidateId matches the pattern.
96
+ * @property {string|null} lastSessionId - Most-recent sessionId observed in the override corpus (D4).
97
+ */
98
+
99
+ /**
100
+ * @typedef {Object} FitnessLayer2
101
+ * @property {'weighted-average'} kind
102
+ * @property {number} value - (memoryRefs * WEIGHT_MEMORY_REF + regrets * WEIGHT_REGRET) / activeSessions, or 0 when n=0.
103
+ * @property {number} n - Active-session count for this pattern (across all signal types — see D3).
104
+ * @property {boolean} ready - True iff n >= LAYER2_READY_THRESHOLD.
105
+ */
106
+
107
+ /**
108
+ * @typedef {Object} FitnessRecord
109
+ * @property {string} id - Mirrors patternId for back-compat with the F-071 PatternRecord shape.
110
+ * @property {string} patternId - 'P-NNN'.
111
+ * @property {string} ts - ISO timestamp at which this record was persisted (NOT used in any formula).
112
+ * @property {FitnessLayer1} layer1
113
+ * @property {FitnessLayer2} layer2
114
+ * @property {number} activeSessions - Same as layer2.n; surfaced top-level for convenience.
115
+ * @property {string|null} lastSeenSessionId - Most-recent sessionId in which this pattern was active.
116
+ * @property {string|null} lastSeenAt - ISO timestamp of the most-recent matching signal.
117
+ * @property {boolean} expired - AC-4 marker; set by markExpired or runFitnessPass.
118
+ * @property {{candidateId: string|null, featureRef: string|null}} evidence - Pinned identity used to match signals (D1).
119
+ */
120
+
121
+ /**
122
+ * @typedef {Object} SnapshotRecord
123
+ * @property {string} ts - ISO timestamp at apply-time (D5).
124
+ * @property {string} patternId
125
+ * @property {FitnessLayer1} layer1
126
+ * @property {FitnessLayer2} layer2
127
+ * @property {number} n
128
+ * @property {string[]} activeSessionsList - SORTED list of sessionIds in which the pattern was active. Sorted lock matches D6.
129
+ */
130
+
131
+ // -----------------------------------------------------------------------------
132
+ // Internal helpers — directory + IO
133
+ // -----------------------------------------------------------------------------
134
+
135
+ function ensureDir(dir) {
136
+ try {
137
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
138
+ } catch (_e) {
139
+ // Public boundary callers swallow errors; the next write will surface persistent IO problems.
140
+ }
141
+ }
142
+
143
+ function learningRoot(projectRoot) {
144
+ return path.join(projectRoot, CAP_DIR, LEARNING_DIR);
145
+ }
146
+
147
+ function fitnessDir(projectRoot) {
148
+ return path.join(learningRoot(projectRoot), FITNESS_DIR);
149
+ }
150
+
151
+ function fitnessFilePath(projectRoot, patternId) {
152
+ return path.join(fitnessDir(projectRoot), `${patternId}${FITNESS_JSON_SUFFIX}`);
153
+ }
154
+
155
+ function snapshotsFilePath(projectRoot, patternId) {
156
+ return path.join(fitnessDir(projectRoot), `${patternId}${FITNESS_SNAPSHOTS_SUFFIX}`);
157
+ }
158
+
159
+ /**
160
+ * Validate a P-NNN id. Rejects anything else defensively — every public function
161
+ * routes through this gate so a hostile or malformed id can never become a path.
162
+ *
163
+ * @param {any} id
164
+ * @returns {boolean}
165
+ */
166
+ function isValidPatternId(id) {
167
+ return typeof id === 'string' && PATTERN_ID_RE.test(id);
168
+ }
169
+
170
+ /**
171
+ * Look up the persisted PatternRecord with the given id. Returns null when the
172
+ * pattern is not found (or the patterns directory is missing). Never throws —
173
+ * F-074 will call this on potentially-deleted ids and must get a clean null.
174
+ *
175
+ * @param {string} projectRoot
176
+ * @param {string} patternId
177
+ * @returns {object|null}
178
+ */
179
+ function findPattern(projectRoot, patternId) {
180
+ try {
181
+ const all = patternPipeline.listPatterns(projectRoot);
182
+ if (!Array.isArray(all)) return null;
183
+ for (const p of all) {
184
+ if (p && p.id === patternId) return p;
185
+ }
186
+ return null;
187
+ } catch (_e) {
188
+ return null;
189
+ }
190
+ }
191
+
192
+ // -----------------------------------------------------------------------------
193
+ // Signal matching — turns a PatternRecord + a signal record into a boolean.
194
+ // D1, D3 live here.
195
+ // -----------------------------------------------------------------------------
196
+
197
+ /**
198
+ * Extract the candidateId the pattern is anchored to, if any. F-071's PatternRecord
199
+ * stores it under evidence.candidateId. Anything else (legacy or hand-written
200
+ * patterns) falls back to null and we use featureRef as the matcher (D1).
201
+ *
202
+ * @param {object} pattern
203
+ * @returns {string|null}
204
+ */
205
+ function patternCandidateId(pattern) {
206
+ if (!pattern || typeof pattern !== 'object') return null;
207
+ const ev = pattern.evidence;
208
+ if (ev && typeof ev === 'object' && typeof ev.candidateId === 'string' && ev.candidateId.length > 0) {
209
+ return ev.candidateId;
210
+ }
211
+ return null;
212
+ }
213
+
214
+ /**
215
+ * Extract the featureRef the pattern targets. Used as the fallback matcher when
216
+ * the pattern has no candidateId (D1). Defensive: only accept exact F-NNN shape.
217
+ *
218
+ * @param {object} pattern
219
+ * @returns {string|null}
220
+ */
221
+ function patternFeatureRef(pattern) {
222
+ if (!pattern || typeof pattern !== 'object') return null;
223
+ if (typeof pattern.featureRef === 'string' && /^F-\d+$/.test(pattern.featureRef)) {
224
+ return pattern.featureRef;
225
+ }
226
+ return null;
227
+ }
228
+
229
+ /**
230
+ * Memory-file-hash anchors the pattern carries — a future LLM-stage pattern may
231
+ * reference one or more memory files via evidence.memoryFileHashes[]. We accept
232
+ * either a single string or an array; missing → empty array. D3.
233
+ *
234
+ * @param {object} pattern
235
+ * @returns {string[]}
236
+ */
237
+ function patternMemoryFileHashes(pattern) {
238
+ if (!pattern || typeof pattern !== 'object') return [];
239
+ const ev = pattern.evidence;
240
+ if (!ev || typeof ev !== 'object') return [];
241
+ const raw = ev.memoryFileHashes != null ? ev.memoryFileHashes : ev.memoryFileHash;
242
+ if (!raw) return [];
243
+ const arr = Array.isArray(raw) ? raw : [raw];
244
+ const out = [];
245
+ for (const h of arr) {
246
+ if (typeof h === 'string' && h.length > 0) out.push(h);
247
+ }
248
+ return out;
249
+ }
250
+
251
+ /**
252
+ * Decision-id anchors for regrets (D3). F-070 regrets carry decisionId; a future
253
+ * LLM-stage pattern may reference specific decisionIds via evidence.decisionIds[].
254
+ *
255
+ * @param {object} pattern
256
+ * @returns {string[]}
257
+ */
258
+ function patternDecisionIds(pattern) {
259
+ if (!pattern || typeof pattern !== 'object') return [];
260
+ const ev = pattern.evidence;
261
+ if (!ev || typeof ev !== 'object') return [];
262
+ const raw = ev.decisionIds != null ? ev.decisionIds : ev.decisionId;
263
+ if (!raw) return [];
264
+ const arr = Array.isArray(raw) ? raw : [raw];
265
+ const out = [];
266
+ for (const d of arr) {
267
+ if (typeof d === 'string' && d.length > 0) out.push(d);
268
+ }
269
+ return out;
270
+ }
271
+
272
+ /**
273
+ * Does an OVERRIDE record match this pattern? D1 primary path: candidateId match.
274
+ * Defensive fallback: when the pattern carries no candidateId we accept featureRef
275
+ * matches. AC-7 demands this be deterministic — no time considerations.
276
+ *
277
+ * The override record's `evidence` field doesn't exist in F-070's record schema; the
278
+ * pattern's evidence.candidateId is matched against an override-derived candidate
279
+ * key. Since F-071 builds candidateId via telemetry.hashContext(token) where token
280
+ * encodes (signalType, featureId, contextKey), and the override record carries the
281
+ * same fields, we reconstruct the same key here and compare. Re-using F-071's
282
+ * exact hashing primitive is over-engineering for the test surface — instead we
283
+ * match on the structured fields directly: {signalType=override, featureId, targetFileHash}.
284
+ * This stays robust if F-071's hashing changes.
285
+ *
286
+ * @param {object} record
287
+ * @param {string|null} candidateId - Pattern's candidateId, if any.
288
+ * @param {string|null} featureRef - Pattern's featureRef, if any.
289
+ * @param {string|null} candidateFeatureId - Pattern's evidence-derived featureId (mirrors how F-071
290
+ * built the candidate). When the pattern has a candidateId we still need its featureId to match
291
+ * the override; we get it from pattern.evidence.featureId or fall back to featureRef.
292
+ * @param {string|null} candidateContextHash - Pattern's evidence contextHash (the "topContextHashes[0].hash"
293
+ * F-071 persists). When present, prefer matching override.contextHash exactly — that's the strict identity.
294
+ *
295
+ * **Silent-zero edge case (debugging hint):** when `candidateId` is set BUT both
296
+ * `candidateFeatureId` AND `featureRef` are null, this matcher returns false for every
297
+ * override — there is no anchor to compare against. The result looks like "no overrides
298
+ * match this pattern" without an obvious cause. The condition is unreachable in normal
299
+ * flows (F-071 always populates either evidence.featureId or featureRef on a promoted
300
+ * pattern), but a malformed legacy / hand-edited pattern record can land in this branch.
301
+ * If you ever see Layer 1 stuck at 0 for a pattern that obviously has signals, check
302
+ * `pattern.evidence.featureId` and `pattern.featureRef` first.
303
+ * @returns {boolean}
304
+ */
305
+ function overrideMatchesPattern(record, candidateId, featureRef, candidateFeatureId, candidateContextHash) {
306
+ if (!record || record.signalType !== 'override') return false;
307
+ // Primary path (D1): candidateId exists → the pattern was promoted by F-071. F-071 builds
308
+ // candidateId via hashContext("override|<featureId>|<contextKey>") — i.e. featureId is BAKED
309
+ // into the candidate identity. A faithful "candidateId match" therefore requires:
310
+ // (a) record.featureId === pattern's candidate-featureId, AND
311
+ // (b) when the pattern carries a contextHash anchor, record.contextHash matches it.
312
+ // Without (b), all overrides on the same feature match — which is the right behaviour when
313
+ // the pattern doesn't (yet) anchor to a specific contextHash. With (b), the match is strict.
314
+ if (candidateId) {
315
+ if (!candidateFeatureId) return false;
316
+ if (record.featureId !== candidateFeatureId) return false;
317
+ if (candidateContextHash) {
318
+ // Strict identity: same feature AND same contextHash. Without the contextHash match,
319
+ // an override on a different file under the same feature is NOT on the candidate territory.
320
+ return typeof record.contextHash === 'string' && record.contextHash === candidateContextHash;
321
+ }
322
+ return true;
323
+ }
324
+ // Defensive fallback (D1): no candidateId → match on featureRef alone.
325
+ if (featureRef && record.featureId === featureRef) return true;
326
+ return false;
327
+ }
328
+
329
+ /**
330
+ * Does a MEMORY-REF record match this pattern? D3 — memoryFileHash matches one of the
331
+ * pattern's memory-file anchors, OR (defensive fallback) featureRef matches.
332
+ *
333
+ * @param {object} record
334
+ * @param {string[]} memoryHashes
335
+ * @param {string|null} featureRef
336
+ * @returns {boolean}
337
+ */
338
+ function memoryRefMatchesPattern(record, memoryHashes, featureRef) {
339
+ if (!record || record.signalType !== 'memory-ref') return false;
340
+ if (memoryHashes.length > 0 && typeof record.memoryFileHash === 'string') {
341
+ for (const h of memoryHashes) {
342
+ if (record.memoryFileHash === h) return true;
343
+ }
344
+ }
345
+ // Fallback: feature-scoped memory reference.
346
+ if (featureRef && record.featureId === featureRef) return true;
347
+ return false;
348
+ }
349
+
350
+ /**
351
+ * Does a REGRET record match this pattern? D3 — decisionId matches one of the
352
+ * pattern's anchors, OR (defensive fallback) featureRef matches.
353
+ *
354
+ * @param {object} record
355
+ * @param {string[]} decisionIds
356
+ * @param {string|null} featureRef
357
+ * @returns {boolean}
358
+ */
359
+ function regretMatchesPattern(record, decisionIds, featureRef) {
360
+ if (!record || record.signalType !== 'regret') return false;
361
+ if (decisionIds.length > 0 && typeof record.decisionId === 'string') {
362
+ for (const d of decisionIds) {
363
+ if (record.decisionId === d) return true;
364
+ }
365
+ }
366
+ if (featureRef && record.featureId === featureRef) return true;
367
+ return false;
368
+ }
369
+
370
+ // -----------------------------------------------------------------------------
371
+ // Internal helpers — deterministic session bookkeeping
372
+ // -----------------------------------------------------------------------------
373
+
374
+ /**
375
+ * Determine the most-recent sessionId across an override corpus. D4: lexicographic
376
+ * max of the ts string with id as tiebreaker. Returns null when the corpus is empty
377
+ * or all records are session-less.
378
+ *
379
+ * AC-7: this is the only place "latest" is computed. No Date.now, no wall clock —
380
+ * we sort by the record's persisted timestamp, which is fully deterministic w.r.t.
381
+ * the input corpus.
382
+ *
383
+ * @param {Array<object>} overrideRecords
384
+ * @returns {string|null}
385
+ */
386
+ function latestSessionId(overrideRecords) {
387
+ if (!Array.isArray(overrideRecords) || overrideRecords.length === 0) return null;
388
+ let bestTs = null;
389
+ let bestId = null;
390
+ let bestSession = null;
391
+ for (const r of overrideRecords) {
392
+ if (!r || typeof r.sessionId !== 'string' || r.sessionId.length === 0) continue;
393
+ const ts = typeof r.ts === 'string' ? r.ts : '';
394
+ const id = typeof r.id === 'string' ? r.id : '';
395
+ // Deterministic ordering: ts desc, then id desc as tiebreaker.
396
+ if (
397
+ bestTs == null
398
+ || ts > bestTs
399
+ || (ts === bestTs && id > bestId)
400
+ ) {
401
+ bestTs = ts;
402
+ bestId = id;
403
+ bestSession = r.sessionId;
404
+ }
405
+ }
406
+ return bestSession;
407
+ }
408
+
409
+ /**
410
+ * Compute the set of sessionIds in which the pattern was active across all three
411
+ * signal types. D3 + D6: returned as a SORTED array so any downstream iteration is
412
+ * deterministic regardless of Set/Map insertion order.
413
+ *
414
+ * @param {object} pattern
415
+ * @param {Array<object>} overrides
416
+ * @param {Array<object>} memoryRefs
417
+ * @param {Array<object>} regrets
418
+ * @returns {string[]} Sorted list of sessionIds.
419
+ */
420
+ function computeActiveSessions(pattern, overrides, memoryRefs, regrets) {
421
+ const candidateId = patternCandidateId(pattern);
422
+ const featureRef = patternFeatureRef(pattern);
423
+ const memoryHashes = patternMemoryFileHashes(pattern);
424
+ const decisionIds = patternDecisionIds(pattern);
425
+ // Pattern-side featureId for override matching (see overrideMatchesPattern doc).
426
+ const candidateFeatureId = (pattern && pattern.evidence && typeof pattern.evidence.featureId === 'string')
427
+ ? pattern.evidence.featureId
428
+ : featureRef;
429
+ const candidateContextHash = pickPrimaryContextHash(pattern);
430
+
431
+ // Use a plain object as a string-keyed set to avoid Set iteration-order surprises.
432
+ // We sort the final array before returning (D6).
433
+ /** @type {Object<string, true>} */
434
+ const sessions = Object.create(null);
435
+
436
+ for (const r of overrides || []) {
437
+ if (!r || typeof r.sessionId !== 'string' || r.sessionId.length === 0) continue;
438
+ if (overrideMatchesPattern(r, candidateId, featureRef, candidateFeatureId, candidateContextHash)) {
439
+ sessions[r.sessionId] = true;
440
+ }
441
+ }
442
+ for (const r of memoryRefs || []) {
443
+ if (!r || typeof r.sessionId !== 'string' || r.sessionId.length === 0) continue;
444
+ if (memoryRefMatchesPattern(r, memoryHashes, featureRef)) {
445
+ sessions[r.sessionId] = true;
446
+ }
447
+ }
448
+ for (const r of regrets || []) {
449
+ if (!r || typeof r.sessionId !== 'string' || r.sessionId.length === 0) continue;
450
+ if (regretMatchesPattern(r, decisionIds, featureRef)) {
451
+ sessions[r.sessionId] = true;
452
+ }
453
+ }
454
+
455
+ // @cap-risk(F-072/AC-7) Sort lock: Object.keys()'s order isn't guaranteed across V8
456
+ // versions for non-numeric strings; an explicit .sort() seals it.
457
+ return Object.keys(sessions).sort();
458
+ }
459
+
460
+ /**
461
+ * Pull the primary contextHash anchor off a pattern's evidence, if any. F-071 persists
462
+ * topContextHashes[]; we treat the FIRST entry as the canonical anchor for matching
463
+ * overrides (D1 strictest path). When evidence carries an explicit `contextHash` field
464
+ * (a future LLM-stage pattern shape), that wins.
465
+ *
466
+ * @param {object} pattern
467
+ * @returns {string|null}
468
+ */
469
+ function pickPrimaryContextHash(pattern) {
470
+ if (!pattern || typeof pattern !== 'object') return null;
471
+ const ev = pattern.evidence;
472
+ if (!ev || typeof ev !== 'object') return null;
473
+ if (typeof ev.contextHash === 'string' && ev.contextHash.length > 0) return ev.contextHash;
474
+ if (Array.isArray(ev.topContextHashes) && ev.topContextHashes.length > 0) {
475
+ const first = ev.topContextHashes[0];
476
+ if (first && typeof first.hash === 'string' && first.hash.length > 0) return first.hash;
477
+ }
478
+ return null;
479
+ }
480
+
481
+ // -----------------------------------------------------------------------------
482
+ // Layer 1 + Layer 2 compute
483
+ // -----------------------------------------------------------------------------
484
+
485
+ /**
486
+ * Layer 1: short-term Override-COUNT (D1) over the most-recent session.
487
+ *
488
+ * @param {object} pattern
489
+ * @param {Array<object>} overrides - Already filtered to signalType=override.
490
+ * @returns {FitnessLayer1}
491
+ */
492
+ function computeLayer1(pattern, overrides) {
493
+ const lastSession = latestSessionId(overrides);
494
+ const candidateId = patternCandidateId(pattern);
495
+ const featureRef = patternFeatureRef(pattern);
496
+ const candidateFeatureId = (pattern && pattern.evidence && typeof pattern.evidence.featureId === 'string')
497
+ ? pattern.evidence.featureId
498
+ : featureRef;
499
+ const candidateContextHash = pickPrimaryContextHash(pattern);
500
+
501
+ let count = 0;
502
+ if (lastSession) {
503
+ for (const r of overrides || []) {
504
+ if (!r || r.sessionId !== lastSession) continue;
505
+ if (overrideMatchesPattern(r, candidateId, featureRef, candidateFeatureId, candidateContextHash)) {
506
+ count += 1;
507
+ }
508
+ }
509
+ }
510
+
511
+ return {
512
+ kind: 'override-count',
513
+ value: count,
514
+ lastSessionId: lastSession,
515
+ };
516
+ }
517
+
518
+ /**
519
+ * Layer 2: long-term per-session weighted average (D2). value = (memoryRefs * 1 + regrets * 2) / n,
520
+ * where n = activeSessions.length. ready = (n >= LAYER2_READY_THRESHOLD).
521
+ *
522
+ * AC-5: value is computed even when n < threshold so the data exists from day 1; the consumer
523
+ * (display layer / F-074) gates on ready.
524
+ *
525
+ * @param {object} pattern
526
+ * @param {Array<object>} memoryRefs
527
+ * @param {Array<object>} regrets
528
+ * @param {string[]} activeSessions - Sorted list from computeActiveSessions.
529
+ * @returns {FitnessLayer2}
530
+ */
531
+ function computeLayer2(pattern, memoryRefs, regrets, activeSessions) {
532
+ const featureRef = patternFeatureRef(pattern);
533
+ const memoryHashes = patternMemoryFileHashes(pattern);
534
+ const decisionIds = patternDecisionIds(pattern);
535
+
536
+ let memoryHits = 0;
537
+ for (const r of memoryRefs || []) {
538
+ if (memoryRefMatchesPattern(r, memoryHashes, featureRef)) memoryHits += 1;
539
+ }
540
+ let regretHits = 0;
541
+ for (const r of regrets || []) {
542
+ if (regretMatchesPattern(r, decisionIds, featureRef)) regretHits += 1;
543
+ }
544
+
545
+ const n = activeSessions.length;
546
+ // @cap-risk(F-072/AC-7) Divide-by-zero guard: when n=0, value=0. The adversarial test pins this.
547
+ const value = n > 0
548
+ ? (memoryHits * WEIGHT_MEMORY_REF + regretHits * WEIGHT_REGRET) / n
549
+ : 0;
550
+
551
+ return {
552
+ kind: 'weighted-average',
553
+ value,
554
+ n,
555
+ ready: n >= LAYER2_READY_THRESHOLD,
556
+ };
557
+ }
558
+
559
+ /**
560
+ * Find the most-recent matching signal across all types — used to populate
561
+ * lastSeenSessionId / lastSeenAt on the FitnessRecord. D6: deterministic sort by ts.
562
+ *
563
+ * @param {object} pattern
564
+ * @param {Array<object>} overrides
565
+ * @param {Array<object>} memoryRefs
566
+ * @param {Array<object>} regrets
567
+ * @returns {{sessionId: string|null, ts: string|null}}
568
+ */
569
+ function lastSeen(pattern, overrides, memoryRefs, regrets) {
570
+ const candidateId = patternCandidateId(pattern);
571
+ const featureRef = patternFeatureRef(pattern);
572
+ const memoryHashes = patternMemoryFileHashes(pattern);
573
+ const decisionIds = patternDecisionIds(pattern);
574
+ const candidateFeatureId = (pattern && pattern.evidence && typeof pattern.evidence.featureId === 'string')
575
+ ? pattern.evidence.featureId
576
+ : featureRef;
577
+ const candidateContextHash = pickPrimaryContextHash(pattern);
578
+
579
+ let bestTs = null;
580
+ let bestId = null;
581
+ let bestSession = null;
582
+
583
+ const consider = (r, isMatch) => {
584
+ if (!r || !isMatch) return;
585
+ const ts = typeof r.ts === 'string' ? r.ts : '';
586
+ const id = typeof r.id === 'string' ? r.id : '';
587
+ if (
588
+ bestTs == null
589
+ || ts > bestTs
590
+ || (ts === bestTs && id > bestId)
591
+ ) {
592
+ bestTs = ts;
593
+ bestId = id;
594
+ bestSession = (typeof r.sessionId === 'string' && r.sessionId.length > 0) ? r.sessionId : null;
595
+ }
596
+ };
597
+
598
+ for (const r of overrides || []) {
599
+ consider(r, overrideMatchesPattern(r, candidateId, featureRef, candidateFeatureId, candidateContextHash));
600
+ }
601
+ for (const r of memoryRefs || []) {
602
+ consider(r, memoryRefMatchesPattern(r, memoryHashes, featureRef));
603
+ }
604
+ for (const r of regrets || []) {
605
+ consider(r, regretMatchesPattern(r, decisionIds, featureRef));
606
+ }
607
+
608
+ return { sessionId: bestSession, ts: bestTs };
609
+ }
610
+
611
+ /**
612
+ * Compute every union session id across the three corpora (signal-source sessions —
613
+ * NOT pattern-active sessions). Used by the AC-4 expiry check: a pattern with no
614
+ * activity in the last EXPIRY_SESSIONS *signal-corpus* sessions is expired.
615
+ *
616
+ * Sessions are ordered by their max-ts (most-recent ts seen with that sessionId);
617
+ * D6 demands a deterministic order, so ties on max-ts fall back to lexicographic
618
+ * sessionId order. The returned array is most-recent-first.
619
+ *
620
+ * @param {Array<object>} overrides
621
+ * @param {Array<object>} memoryRefs
622
+ * @param {Array<object>} regrets
623
+ * @returns {string[]} Sessions in most-recent-first order.
624
+ */
625
+ function unionSessionsByRecency(overrides, memoryRefs, regrets) {
626
+ /** @type {Object<string, string>} */
627
+ const sessionMaxTs = Object.create(null);
628
+ const collect = (arr) => {
629
+ for (const r of arr || []) {
630
+ if (!r || typeof r.sessionId !== 'string' || r.sessionId.length === 0) continue;
631
+ const ts = typeof r.ts === 'string' ? r.ts : '';
632
+ const prev = sessionMaxTs[r.sessionId];
633
+ if (prev == null || ts > prev) sessionMaxTs[r.sessionId] = ts;
634
+ }
635
+ };
636
+ collect(overrides);
637
+ collect(memoryRefs);
638
+ collect(regrets);
639
+
640
+ // @cap-risk(F-072/AC-7) Sort by (max-ts desc, sessionId desc) for full determinism.
641
+ const sessions = Object.keys(sessionMaxTs);
642
+ sessions.sort((a, b) => {
643
+ const ta = sessionMaxTs[a];
644
+ const tb = sessionMaxTs[b];
645
+ if (ta < tb) return 1;
646
+ if (ta > tb) return -1;
647
+ if (a < b) return 1;
648
+ if (a > b) return -1;
649
+ return 0;
650
+ });
651
+ return sessions;
652
+ }
653
+
654
+ // -----------------------------------------------------------------------------
655
+ // Public API — computeFitness
656
+ // -----------------------------------------------------------------------------
657
+
658
+ // @cap-todo(ac:F-072/AC-1) Layer 1 short-term override-count over the last session.
659
+ // @cap-todo(ac:F-072/AC-2) Layer 2 long-term weighted per-session average; ready at n>=5.
660
+ // @cap-todo(ac:F-072/AC-5) Layer 2 value is computed and persisted from day 1, ready=false below threshold.
661
+ // @cap-todo(ac:F-072/AC-7) Pure compute, deterministic — no random / no Date.now in the formulas.
662
+ /**
663
+ * Compute the FitnessRecord for `patternId` from the current signal corpus. Pure compute,
664
+ * no IO except reading via cap-learning-signals.getSignals + cap-pattern-pipeline.listPatterns.
665
+ *
666
+ * @param {string} projectRoot
667
+ * @param {string} patternId - 'P-NNN'
668
+ * @param {Object} [options]
669
+ * @param {Date|string} [options.now] - Override the persisted ts (mostly for tests). NEVER affects the formulas.
670
+ * @returns {FitnessRecord|null} null when projectRoot/patternId invalid or pattern not found.
671
+ */
672
+ function computeFitness(projectRoot, patternId, options) {
673
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) return null;
674
+ if (!isValidPatternId(patternId)) return null;
675
+ const opts = options || {};
676
+
677
+ const pattern = findPattern(projectRoot, patternId);
678
+ if (!pattern) return null;
679
+
680
+ // Pull all three corpora via the F-070 query API. We never read JSONL directly.
681
+ let overrides = [];
682
+ let memoryRefs = [];
683
+ let regrets = [];
684
+ try { overrides = learningSignals.getSignals(projectRoot, 'override') || []; } catch (_e) { overrides = []; }
685
+ try { memoryRefs = learningSignals.getSignals(projectRoot, 'memory-ref') || []; } catch (_e) { memoryRefs = []; }
686
+ try { regrets = learningSignals.getSignals(projectRoot, 'regret') || []; } catch (_e) { regrets = []; }
687
+
688
+ return computeFitnessFromCorpus(pattern, overrides, memoryRefs, regrets, opts.now);
689
+ }
690
+
691
+ /**
692
+ * Internal worker — computes a FitnessRecord from a pre-loaded signal corpus. Used by
693
+ * computeFitness (one-shot) and runFitnessPass (batch optimisation: read corpus once).
694
+ *
695
+ * @param {object} pattern
696
+ * @param {Array<object>} overrides
697
+ * @param {Array<object>} memoryRefs
698
+ * @param {Array<object>} regrets
699
+ * @param {Date|string} [now]
700
+ * @returns {FitnessRecord}
701
+ */
702
+ function computeFitnessFromCorpus(pattern, overrides, memoryRefs, regrets, now) {
703
+ const activeSessions = computeActiveSessions(pattern, overrides, memoryRefs, regrets);
704
+ const layer1 = computeLayer1(pattern, overrides);
705
+ const layer2 = computeLayer2(pattern, memoryRefs, regrets, activeSessions);
706
+ const seen = lastSeen(pattern, overrides, memoryRefs, regrets);
707
+
708
+ const ts = now ? new Date(now).toISOString() : new Date().toISOString();
709
+
710
+ /** @type {FitnessRecord} */
711
+ return {
712
+ id: pattern.id,
713
+ patternId: pattern.id,
714
+ ts,
715
+ layer1,
716
+ layer2,
717
+ activeSessions: activeSessions.length,
718
+ lastSeenSessionId: seen.sessionId,
719
+ lastSeenAt: seen.ts,
720
+ expired: false,
721
+ evidence: {
722
+ candidateId: patternCandidateId(pattern),
723
+ featureRef: patternFeatureRef(pattern),
724
+ },
725
+ };
726
+ }
727
+
728
+ // -----------------------------------------------------------------------------
729
+ // Public API — recordFitness / getFitness
730
+ // -----------------------------------------------------------------------------
731
+
732
+ // @cap-todo(ac:F-072/AC-3) Persistence layer — getFitness round-trips computeFitness.
733
+ // Rolling-30 / Lifetime aggregates: the persisted record IS the
734
+ // lifetime aggregate (every signal across all sessions); rolling-30
735
+ // is reconstructible per-call by restricting the corpus, but the
736
+ // MVP persists the lifetime view and surfaces it as the canonical
737
+ // fitness record. Snapshot history (recordApplySnapshot) handles
738
+ // the rolling-history view F-074 needs.
739
+ /**
740
+ * Compute + persist a FitnessRecord to .cap/learning/fitness/<P-NNN>.json. Idempotent within
741
+ * a session — re-computes from scratch and overwrites the prior write.
742
+ *
743
+ * @param {string} projectRoot
744
+ * @param {string} patternId - 'P-NNN'
745
+ * @param {Object} [options]
746
+ * @param {Date|string} [options.now]
747
+ * @returns {boolean} true on successful write; false on invalid input or IO error.
748
+ */
749
+ function recordFitness(projectRoot, patternId, options) {
750
+ const record = computeFitness(projectRoot, patternId, options);
751
+ if (!record) return false;
752
+ ensureDir(fitnessDir(projectRoot));
753
+ try {
754
+ fs.writeFileSync(fitnessFilePath(projectRoot, patternId), JSON.stringify(record, null, 2) + '\n', 'utf8');
755
+ return true;
756
+ } catch (_e) {
757
+ return false;
758
+ }
759
+ }
760
+
761
+ /**
762
+ * Read the persisted FitnessRecord for `patternId`. Returns null when the file
763
+ * is missing or malformed. Never throws.
764
+ *
765
+ * @param {string} projectRoot
766
+ * @param {string} patternId
767
+ * @returns {FitnessRecord|null}
768
+ */
769
+ function getFitness(projectRoot, patternId) {
770
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) return null;
771
+ if (!isValidPatternId(patternId)) return null;
772
+ const fp = fitnessFilePath(projectRoot, patternId);
773
+ try {
774
+ if (!fs.existsSync(fp)) return null;
775
+ const raw = fs.readFileSync(fp, 'utf8');
776
+ const parsed = JSON.parse(raw);
777
+ if (!parsed || typeof parsed !== 'object') return null;
778
+ return parsed;
779
+ } catch (_e) {
780
+ return null;
781
+ }
782
+ }
783
+
784
+ // -----------------------------------------------------------------------------
785
+ // Public API — recordApplySnapshot (AC-6, F-073 hook point)
786
+ // -----------------------------------------------------------------------------
787
+
788
+ // @cap-todo(ac:F-072/AC-6) Apply-time snapshot — F-073 calls recordApplySnapshot when the user
789
+ // applies a pattern; F-074 reads .snapshots.jsonl tails to compare
790
+ // pre-apply vs post-apply fitness.
791
+ /**
792
+ * Compute current fitness AND append a SnapshotRecord to .cap/learning/fitness/<P-NNN>.snapshots.jsonl.
793
+ * The append-only log (D5) means multiple applies in the same session produce multiple lines.
794
+ *
795
+ * F-073 calls this when the user applies a pattern (we expose the API; F-073 wires the call).
796
+ *
797
+ * @param {string} projectRoot
798
+ * @param {string} patternId
799
+ * @param {Object} [options]
800
+ * @param {Date|string} [options.now]
801
+ * @returns {SnapshotRecord|null} null when projectRoot/patternId invalid, pattern missing, or write failed.
802
+ */
803
+ function recordApplySnapshot(projectRoot, patternId, options) {
804
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) return null;
805
+ if (!isValidPatternId(patternId)) return null;
806
+ const opts = options || {};
807
+
808
+ const pattern = findPattern(projectRoot, patternId);
809
+ if (!pattern) return null;
810
+
811
+ let overrides = [];
812
+ let memoryRefs = [];
813
+ let regrets = [];
814
+ try { overrides = learningSignals.getSignals(projectRoot, 'override') || []; } catch (_e) { overrides = []; }
815
+ try { memoryRefs = learningSignals.getSignals(projectRoot, 'memory-ref') || []; } catch (_e) { memoryRefs = []; }
816
+ try { regrets = learningSignals.getSignals(projectRoot, 'regret') || []; } catch (_e) { regrets = []; }
817
+
818
+ const activeSessions = computeActiveSessions(pattern, overrides, memoryRefs, regrets);
819
+ const layer1 = computeLayer1(pattern, overrides);
820
+ const layer2 = computeLayer2(pattern, memoryRefs, regrets, activeSessions);
821
+ const ts = opts.now ? new Date(opts.now).toISOString() : new Date().toISOString();
822
+
823
+ /** @type {SnapshotRecord} */
824
+ const snapshot = {
825
+ ts,
826
+ patternId,
827
+ layer1,
828
+ layer2,
829
+ n: layer2.n,
830
+ activeSessionsList: activeSessions, // already sorted by computeActiveSessions
831
+ };
832
+
833
+ ensureDir(fitnessDir(projectRoot));
834
+ try {
835
+ const line = JSON.stringify(snapshot) + '\n';
836
+ const fd = fs.openSync(snapshotsFilePath(projectRoot, patternId), 'a');
837
+ try {
838
+ fs.writeSync(fd, line);
839
+ } finally {
840
+ fs.closeSync(fd);
841
+ }
842
+ return snapshot;
843
+ } catch (_e) {
844
+ return null;
845
+ }
846
+ }
847
+
848
+ // -----------------------------------------------------------------------------
849
+ // Public API — listFitnessExpired / markExpired (AC-4)
850
+ // -----------------------------------------------------------------------------
851
+
852
+ // @cap-todo(ac:F-072/AC-4) Patterns with no usage over EXPIRY_SESSIONS sessions auto-marked expired.
853
+ /**
854
+ * Return the list of pattern ids that have had no activity in the last EXPIRY_SESSIONS
855
+ * signal-corpus sessions. The window is computed from the union of all three signal
856
+ * types' sessionIds, ordered by most-recent ts (D6 deterministic sort); a pattern is
857
+ * "expired" iff intersect(activeSessions, last20) === emptySet.
858
+ *
859
+ * Edge case: when the corpus has fewer than EXPIRY_SESSIONS distinct sessions, no
860
+ * pattern is considered expired (we don't have enough data yet).
861
+ *
862
+ * @param {string} projectRoot
863
+ * @param {Object} [options]
864
+ * @param {number} [options.window] - Override EXPIRY_SESSIONS (mostly for tests).
865
+ * @returns {string[]} Pattern ids — sorted ascending for deterministic output.
866
+ */
867
+ /**
868
+ * Pure-compute helper: given an in-memory corpus and pattern list, return ids of patterns
869
+ * that have not been active in any of the most-recent `window` sessions. Both
870
+ * listFitnessExpired (which loads the corpus from disk) and runFitnessPass (which already
871
+ * has it in hand) call this — single source of truth for the expiry rule.
872
+ *
873
+ * @cap-risk(F-072/AC-7) Sorted output for deterministic behaviour. The sort lock is here,
874
+ * not at every call site, so the next contributor cannot accidentally
875
+ * remove it from one path while leaving it in the other.
876
+ *
877
+ * @param {Array<object>} patterns
878
+ * @param {Array<object>} overrides
879
+ * @param {Array<object>} memoryRefs
880
+ * @param {Array<object>} regrets
881
+ * @param {number} window - Number of most-recent sessions defining the activity window.
882
+ * @returns {string[]} Pattern ids — sorted ascending.
883
+ */
884
+ function expiredIdsFromCorpus(patterns, overrides, memoryRefs, regrets, window) {
885
+ const recencyOrdered = unionSessionsByRecency(overrides, memoryRefs, regrets);
886
+ if (recencyOrdered.length < window) return [];
887
+ const last = new Set(recencyOrdered.slice(0, window));
888
+
889
+ const expired = [];
890
+ for (const p of patterns) {
891
+ if (!p || !isValidPatternId(p.id)) continue;
892
+ const active = computeActiveSessions(p, overrides, memoryRefs, regrets);
893
+ let intersects = false;
894
+ for (const sid of active) {
895
+ if (last.has(sid)) { intersects = true; break; }
896
+ }
897
+ if (!intersects) expired.push(p.id);
898
+ }
899
+ expired.sort();
900
+ return expired;
901
+ }
902
+
903
+ function listFitnessExpired(projectRoot, options) {
904
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) return [];
905
+ const opts = options || {};
906
+ const window = typeof opts.window === 'number' && opts.window > 0 ? opts.window : EXPIRY_SESSIONS;
907
+
908
+ let overrides = [];
909
+ let memoryRefs = [];
910
+ let regrets = [];
911
+ try { overrides = learningSignals.getSignals(projectRoot, 'override') || []; } catch (_e) { overrides = []; }
912
+ try { memoryRefs = learningSignals.getSignals(projectRoot, 'memory-ref') || []; } catch (_e) { memoryRefs = []; }
913
+ try { regrets = learningSignals.getSignals(projectRoot, 'regret') || []; } catch (_e) { regrets = []; }
914
+
915
+ const patterns = patternPipeline.listPatterns(projectRoot) || [];
916
+ return expiredIdsFromCorpus(patterns, overrides, memoryRefs, regrets, window);
917
+ }
918
+
919
+ /**
920
+ * Mark a persisted FitnessRecord as expired. Reads the existing record (if any) and
921
+ * sets expired=true. When the record doesn't yet exist, computes a fresh one and
922
+ * persists it with expired=true so getFitness reflects the change. Returns true on
923
+ * successful write.
924
+ *
925
+ * @param {string} projectRoot
926
+ * @param {string} patternId
927
+ * @returns {boolean}
928
+ */
929
+ function markExpired(projectRoot, patternId) {
930
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) return false;
931
+ if (!isValidPatternId(patternId)) return false;
932
+
933
+ let record = getFitness(projectRoot, patternId);
934
+ if (!record) {
935
+ record = computeFitness(projectRoot, patternId);
936
+ if (!record) return false;
937
+ }
938
+ record.expired = true;
939
+ ensureDir(fitnessDir(projectRoot));
940
+ try {
941
+ fs.writeFileSync(fitnessFilePath(projectRoot, patternId), JSON.stringify(record, null, 2) + '\n', 'utf8');
942
+ return true;
943
+ } catch (_e) {
944
+ return false;
945
+ }
946
+ }
947
+
948
+ // -----------------------------------------------------------------------------
949
+ // Public API — runFitnessPass (batch helper for /cap:learn Step 6.5)
950
+ // -----------------------------------------------------------------------------
951
+
952
+ // @cap-decision(F-072/D7) /cap:learn Step 6.5 calls runFitnessPass(projectRoot) as a courtesy refresh
953
+ // — every learn invocation re-computes fitness for all patterns. Cost is bounded by
954
+ // the performance probe (<500ms for 100 patterns × 1000 signals); the additive step
955
+ // doesn't refactor the existing 7 steps in commands/cap/learn.md.
956
+ /**
957
+ * Refresh fitness for every persisted pattern AND auto-mark expired ones. Used by
958
+ * /cap:learn Step 6.5 (additive) and any future /cap:fitness skill.
959
+ *
960
+ * @param {string} projectRoot
961
+ * @param {Object} [options]
962
+ * @param {Date|string} [options.now]
963
+ * @param {number} [options.window] - Override EXPIRY_SESSIONS.
964
+ * @returns {{recorded: string[], expired: string[], errors: string[]}}
965
+ */
966
+ function runFitnessPass(projectRoot, options) {
967
+ const opts = options || {};
968
+ const recorded = [];
969
+ const expired = [];
970
+ const errors = [];
971
+
972
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
973
+ return { recorded, expired, errors: ['projectRoot is required'] };
974
+ }
975
+
976
+ let patterns = [];
977
+ try {
978
+ patterns = patternPipeline.listPatterns(projectRoot) || [];
979
+ } catch (e) {
980
+ errors.push(`listPatterns failed: ${e && e.message ? e.message : 'unknown'}`);
981
+ return { recorded, expired, errors };
982
+ }
983
+
984
+ // @cap-risk(F-072/AC-7) Sort patterns by id ascending so iteration order is stable
985
+ // regardless of fs.readdir's filesystem-dependent ordering.
986
+ patterns = [...patterns].sort((a, b) => {
987
+ const ai = (a && a.id) || '';
988
+ const bi = (b && b.id) || '';
989
+ if (ai < bi) return -1;
990
+ if (ai > bi) return 1;
991
+ return 0;
992
+ });
993
+
994
+ // @cap-risk(F-072/AC-7) Performance: read the three signal corpora ONCE, then run the
995
+ // per-pattern compute against the in-memory arrays. Without this batch
996
+ // optimisation, each recordFitness call re-reads the corpora — O(P²)
997
+ // in pattern count vs the O(P) we want for runFitnessPass. The numerical
998
+ // result is identical (we still call the same compute helpers), but the
999
+ // perf probe (100 patterns × 1000 signals) hits the 500ms budget.
1000
+ let overrides = [];
1001
+ let memoryRefs = [];
1002
+ let regrets = [];
1003
+ try { overrides = learningSignals.getSignals(projectRoot, 'override') || []; } catch (e) {
1004
+ errors.push(`getSignals(override) failed: ${e && e.message ? e.message : 'unknown'}`);
1005
+ }
1006
+ try { memoryRefs = learningSignals.getSignals(projectRoot, 'memory-ref') || []; } catch (e) {
1007
+ errors.push(`getSignals(memory-ref) failed: ${e && e.message ? e.message : 'unknown'}`);
1008
+ }
1009
+ try { regrets = learningSignals.getSignals(projectRoot, 'regret') || []; } catch (e) {
1010
+ errors.push(`getSignals(regret) failed: ${e && e.message ? e.message : 'unknown'}`);
1011
+ }
1012
+
1013
+ ensureDir(fitnessDir(projectRoot));
1014
+
1015
+ for (const p of patterns) {
1016
+ if (!p || !isValidPatternId(p.id)) continue;
1017
+ try {
1018
+ const record = computeFitnessFromCorpus(p, overrides, memoryRefs, regrets, opts.now);
1019
+ try {
1020
+ fs.writeFileSync(fitnessFilePath(projectRoot, p.id), JSON.stringify(record, null, 2) + '\n', 'utf8');
1021
+ recorded.push(p.id);
1022
+ } catch (we) {
1023
+ errors.push(`recordFitness write failed for ${p.id}: ${we && we.message ? we.message : 'unknown'}`);
1024
+ }
1025
+ } catch (e) {
1026
+ errors.push(`recordFitness threw for ${p.id}: ${e && e.message ? e.message : 'unknown'}`);
1027
+ }
1028
+ }
1029
+
1030
+ // Expiry check reuses the in-memory corpus to avoid a second disk read.
1031
+ // expiredIdsFromCorpus is the single source of truth — listFitnessExpired calls it too.
1032
+ let expiredIds = [];
1033
+ try {
1034
+ const window = typeof opts.window === 'number' && opts.window > 0 ? opts.window : EXPIRY_SESSIONS;
1035
+ expiredIds = expiredIdsFromCorpus(patterns, overrides, memoryRefs, regrets, window);
1036
+ } catch (e) {
1037
+ errors.push(`expired check failed: ${e && e.message ? e.message : 'unknown'}`);
1038
+ }
1039
+ for (const id of expiredIds) {
1040
+ try {
1041
+ if (markExpired(projectRoot, id)) expired.push(id);
1042
+ } catch (e) {
1043
+ errors.push(`markExpired failed for ${id}: ${e && e.message ? e.message : 'unknown'}`);
1044
+ }
1045
+ }
1046
+
1047
+ return { recorded, expired, errors };
1048
+ }
1049
+
1050
+ // -----------------------------------------------------------------------------
1051
+ // Exports — keep this list minimal. F-073 / F-074 should consume only these.
1052
+ // -----------------------------------------------------------------------------
1053
+
1054
+ module.exports = {
1055
+ // Constants — exported for tests + downstream consumers.
1056
+ CAP_DIR,
1057
+ LEARNING_DIR,
1058
+ FITNESS_DIR,
1059
+ LAYER2_READY_THRESHOLD,
1060
+ WEIGHT_MEMORY_REF,
1061
+ WEIGHT_REGRET,
1062
+ EXPIRY_SESSIONS,
1063
+ // Public API.
1064
+ computeFitness,
1065
+ recordFitness,
1066
+ getFitness,
1067
+ recordApplySnapshot,
1068
+ listFitnessExpired,
1069
+ markExpired,
1070
+ runFitnessPass,
1071
+ // Path helpers — exported for tests.
1072
+ fitnessDir,
1073
+ fitnessFilePath,
1074
+ snapshotsFilePath,
1075
+ };