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,1203 @@
1
+ // @cap-context CAP F-074 Enable Pattern Unlearn and Auto-Retract — closes the V5 self-learning loop.
2
+ // Applies F-071 patterns to running CAP behaviour, audits each apply, watches the
3
+ // post-apply fitness for 5 sessions, auto-flags patches whose Layer-1 override-rate
4
+ // worsens, and offers a clean unlearn path. F-073 (the review board, not yet built)
5
+ // will consume the retract list and trigger the unlearn from one click. This module
6
+ // ONLY exposes the list and the apply/unlearn primitives — it does NOT implement
7
+ // F-073's UI nor wire F-071 to read applied-state.json (both follow-ups, captured in
8
+ // @cap-todo tags below).
9
+ // @cap-decision(F-074/D1) Apply state location — Centralized `.cap/learning/applied-state.json` for
10
+ // L1 (parameters) and L2 (rules). L3 (prompt-template patches) makes real edits to
11
+ // agents/cap-*.md / commands/cap/*.md files; the originalText snapshot is stored
12
+ // inside the apply audit record at `.cap/learning/applied/P-NNN.json` so unlearn
13
+ // can reverse it deterministically. Locked by user direction.
14
+ // @cap-decision(F-074/D2) Apply consumers — `applied-state.json` is read by F-071 (parameter
15
+ // overrides like the heuristic threshold) and the eventual L2 rule consumer when a
16
+ // candidate matches an applied rule (suppress promotion). F-074 only WRITES the
17
+ // file; the consumer-side wiring is a follow-up captured in @cap-todo. The contract
18
+ // is documented at the top of readAppliedState() so a future PR can implement the
19
+ // read side without re-deriving the schema.
20
+ // @cap-decision(F-074/D3) Git commit safety — Stage ONLY CAP-managed files (.cap/learning/applied/...,
21
+ // applied-state.json, plus L3-edited agents/cap-*.md / commands/cap/*.md). Never
22
+ // `git add .` or `-A`. Run a normal `git commit` (NOT `--no-verify`) so user
23
+ // pre-commit hooks fire. On hook failure: write the audit record with
24
+ // applyState:'pending', leave staged files staged, return an error to the caller.
25
+ // The user can resolve manually (fix lint, re-commit) or call applyPattern again
26
+ // with `--retry` to retry the commit. CLAUDE.md forbids --no-verify and we honour it.
27
+ // @cap-decision(F-074/D4) L3 reverse-patch strategy — At apply time, store
28
+ // `{ originalText, patchedText, targetFile }` inside the audit record. At unlearn
29
+ // time, read the file's current content; if it === patchedText (no intermediate
30
+ // edits), restore originalText. If the file has drifted (current content !=
31
+ // patchedText), refuse with `{ unlearned: false, reason: 'l3-drift', commitHashToRevert }`
32
+ // so the user can `git revert` manually. Do NOT silently overwrite drifted L3 files.
33
+ // @cap-decision(F-074/D5) 5-session post-apply check (AC-5) — runs cold-path inside
34
+ // `runRetractCheck(projectRoot)`. For each applied pattern: count distinct override
35
+ // sessionIds since the apply commit (from F-070 corpus). When the post-apply session
36
+ // count crosses 5, compare current Layer-1 override-rate to fitnessSnapshot.layer1.value.
37
+ // If worse (current > snapshot for override-rate, since more overrides = pattern hurting
38
+ // more), append to retract-recommendations.jsonl. The .jsonl file is the single source
39
+ // of truth — `listRetractRecommended` reads it and de-dups by patternId (most-recent wins).
40
+ // @cap-decision(F-074/D6) Idempotency proof for AC-7 — Read `.cap/learning/unlearned/P-NNN.json` first.
41
+ // If exists → return early with `{ unlearned: false, reason: 'already-unlearned', priorRecord }`.
42
+ // No git operation, no second write. The unlearned-record-existence is the lock; we never
43
+ // rely on git history to detect a prior unlearn (would be brittle across rebases).
44
+ // @cap-decision(F-074/D7) Pending-apply retry semantics — When a commit fails (pre-commit hook
45
+ // non-zero exit), the apply audit is written with applyState:'pending' and
46
+ // commitHash:null, but the L3 file edit + applied-state mutation ARE persisted (the
47
+ // staged changes remain). On a `--retry` call, applyPattern detects the existing
48
+ // 'pending' audit, attempts only the commit step (re-stage + commit), and on success
49
+ // promotes the audit to applyState:'committed'. Without --retry, a second
50
+ // applyPattern call returns `{ applied: false, reason: 'already-applied' }` so a user
51
+ // cannot accidentally double-apply. PIN-1 below tracks this — the user should
52
+ // confirm the retry semantics before merge.
53
+ // @cap-constraint Zero external dependencies: node:fs + node:path + node:child_process (for git)
54
+ // only. We re-use cap-pattern-pipeline (listPatterns, getPattern), cap-fitness-score
55
+ // (recordApplySnapshot, getFitness), cap-learning-signals (getSignals). We never read
56
+ // overrides.jsonl / fitness JSONs / pattern JSONs directly — always through the
57
+ // module APIs.
58
+ // @cap-risk(F-074/AC-2) Every git invocation in this file carries this tag. A misfire (e.g. an
59
+ // accidental `git add .` or a commit that picks up unrelated files) would dirty the
60
+ // user's repo. The internal helper `gitStageAndCommit` is THE choke point — every
61
+ // path routes through it. Tests assert the staged file list is exactly what we asked
62
+ // for, never more.
63
+ // @cap-risk(F-074/AC-7) Idempotency guard at the top of unlearnPattern. A regression that fails to
64
+ // read .cap/learning/unlearned/<P-NNN>.json before mutating state would cause double
65
+ // commits. The adversarial test pins this with a count-of-commits assertion.
66
+
67
+ 'use strict';
68
+
69
+ // @cap-feature(feature:F-074, primary:true) Enable Pattern Unlearn and Auto-Retract — apply audit,
70
+ // git-commit-per-apply, 5-session retract check,
71
+ // L1/L2/L3 reverse-patch with drift detection, idempotency.
72
+
73
+ const fs = require('node:fs');
74
+ const path = require('node:path');
75
+ const { spawnSync } = require('node:child_process');
76
+
77
+ const patternPipeline = require('./cap-pattern-pipeline.cjs');
78
+ const fitnessScore = require('./cap-fitness-score.cjs');
79
+ const learningSignals = require('./cap-learning-signals.cjs');
80
+
81
+ // -----------------------------------------------------------------------------
82
+ // Constants — top-of-file so consumers (F-073, /cap:learn) and tests reference
83
+ // exactly one place. Mirrors cap-fitness-score.cjs / cap-pattern-pipeline.cjs.
84
+ // -----------------------------------------------------------------------------
85
+
86
+ const CAP_DIR = '.cap';
87
+ const LEARNING_DIR = 'learning';
88
+ const APPLIED_DIR = 'applied';
89
+ const UNLEARNED_DIR = 'unlearned';
90
+ const APPLIED_STATE_FILE = 'applied-state.json';
91
+ const RETRACT_RECOMMENDATIONS_FILE = 'retract-recommendations.jsonl';
92
+
93
+ // AC-5: how many distinct override-corpus sessions must elapse between apply and the retract
94
+ // check before we trust the post-apply Layer-1 comparison. Centralised so tests can flex it via
95
+ // runRetractCheck options.window without redefining the rule.
96
+ const RETRACT_SESSION_THRESHOLD = 5;
97
+
98
+ // Pattern-id format mirror — duplicated here for the regex; canonical allocator lives in
99
+ // cap-pattern-pipeline.cjs.
100
+ const PATTERN_ID_RE = /^P-\d+$/;
101
+
102
+ // applied-state.json schema version — bump when the shape changes so a stale consumer can refuse
103
+ // to read newer state instead of mis-parsing it.
104
+ const APPLIED_STATE_VERSION = 1;
105
+
106
+ // L3-target whitelist — only files under these prefixes can be L3 patch targets. CLAUDE.md scopes
107
+ // CAP-managed L3 patches to agents/cap-*.md and commands/cap/*.md; we enforce that here so a
108
+ // hostile or buggy pattern cannot rewrite arbitrary user files.
109
+ // @cap-risk(F-074/AC-2) The L3 prefix gate is THE only thing standing between an attacker-crafted
110
+ // pattern and arbitrary file rewrites. Every L3 apply path routes through
111
+ // isAllowedL3Target(); the adversarial test verifies a pattern targeting
112
+ // `package.json` is rejected.
113
+ const L3_TARGET_PREFIXES = ['agents/', 'commands/cap/'];
114
+
115
+ /**
116
+ * @typedef {Object} AppliedAuditRecord
117
+ * @property {string} id - Mirrors patternId for back-compat with the F-071/F-072 record shape.
118
+ * @property {string} patternId - 'P-NNN'.
119
+ * @property {string} appliedAt - ISO timestamp.
120
+ * @property {'committed'|'pending'} applyState - 'pending' when the git commit failed (hook non-zero).
121
+ * @property {'L1'|'L2'|'L3'} level
122
+ * @property {string|null} featureRef - Feature ID this pattern targets, e.g. 'F-070'.
123
+ * @property {string|null} commitHash - Abbrev SHA of the apply commit; null when applyState='pending'.
124
+ * @property {string[]} targetFiles - Relative paths from projectRoot (the files we staged + committed).
125
+ * @property {object|null} fitnessSnapshot - The FitnessRecord captured at apply-time (F-072 SnapshotRecord).
126
+ * @property {object} beforeAfterDiff - Level-specific shape: L1 {key, from, to}; L2 {rule}; L3 {file, originalText, patchedText}.
127
+ */
128
+
129
+ /**
130
+ * @typedef {Object} UnlearnedAuditRecord
131
+ * @property {string} id
132
+ * @property {string} patternId
133
+ * @property {string} unlearnedAt - ISO timestamp.
134
+ * @property {'manual'|'auto-retract'} reason
135
+ * @property {string|null} commitHash - SHA of the unlearn commit; null on git failure.
136
+ * @property {string|null} appliedCommitHash - SHA of the prior apply commit, for traceability.
137
+ */
138
+
139
+ /**
140
+ * @typedef {Object} AppliedState
141
+ * @property {number} version
142
+ * @property {Object<string, *>} l1 - Parameter overrides keyed by `{F-NNN}/{KEY}` strings.
143
+ * @property {Array<{patternId:string, rule:object, appliedAt:string}>} l2
144
+ * @property {Array<{patternId:string, file:string, appliedAt:string}>} l3
145
+ */
146
+
147
+ // -----------------------------------------------------------------------------
148
+ // Internal helpers — directory + IO
149
+ // -----------------------------------------------------------------------------
150
+
151
+ function ensureDir(dir) {
152
+ try {
153
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
154
+ } catch (_e) {
155
+ // Public boundary callers swallow; the next write surfaces persistent IO problems.
156
+ }
157
+ }
158
+
159
+ function learningRoot(projectRoot) {
160
+ return path.join(projectRoot, CAP_DIR, LEARNING_DIR);
161
+ }
162
+
163
+ function appliedDir(projectRoot) {
164
+ return path.join(learningRoot(projectRoot), APPLIED_DIR);
165
+ }
166
+
167
+ function unlearnedDir(projectRoot) {
168
+ return path.join(learningRoot(projectRoot), UNLEARNED_DIR);
169
+ }
170
+
171
+ function appliedStateFilePath(projectRoot) {
172
+ return path.join(learningRoot(projectRoot), APPLIED_STATE_FILE);
173
+ }
174
+
175
+ function retractRecommendationsPath(projectRoot) {
176
+ return path.join(learningRoot(projectRoot), RETRACT_RECOMMENDATIONS_FILE);
177
+ }
178
+
179
+ function appliedAuditPath(projectRoot, patternId) {
180
+ return path.join(appliedDir(projectRoot), `${patternId}.json`);
181
+ }
182
+
183
+ function unlearnedAuditPath(projectRoot, patternId) {
184
+ return path.join(unlearnedDir(projectRoot), `${patternId}.json`);
185
+ }
186
+
187
+ /**
188
+ * Validate a P-NNN id. Every public boundary routes through this gate so a hostile or
189
+ * malformed id can never become a path. Mirrors cap-fitness-score.cjs#isValidPatternId.
190
+ * @param {any} id
191
+ * @returns {boolean}
192
+ */
193
+ function isValidPatternId(id) {
194
+ return typeof id === 'string' && PATTERN_ID_RE.test(id);
195
+ }
196
+
197
+ /**
198
+ * Look up the persisted PatternRecord with the given id. Returns null when missing or
199
+ * unreadable. Never throws.
200
+ * @param {string} projectRoot
201
+ * @param {string} patternId
202
+ * @returns {object|null}
203
+ */
204
+ function findPattern(projectRoot, patternId) {
205
+ try {
206
+ const all = patternPipeline.listPatterns(projectRoot);
207
+ if (!Array.isArray(all)) return null;
208
+ for (const p of all) {
209
+ if (p && p.id === patternId) return p;
210
+ }
211
+ return null;
212
+ } catch (_e) {
213
+ return null;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Read a JSON file or return null. Never throws.
219
+ * @param {string} fp
220
+ * @returns {object|null}
221
+ */
222
+ function readJson(fp) {
223
+ try {
224
+ if (!fs.existsSync(fp)) return null;
225
+ const raw = fs.readFileSync(fp, 'utf8');
226
+ const parsed = JSON.parse(raw);
227
+ if (!parsed || typeof parsed !== 'object') return null;
228
+ return parsed;
229
+ } catch (_e) {
230
+ return null;
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Write a JSON file with trailing newline. Returns true on success.
236
+ * @param {string} fp
237
+ * @param {object} data
238
+ * @returns {boolean}
239
+ */
240
+ // @cap-decision(F-074/D8) Atomic write-temp-then-rename for ALL JSON writes in this module.
241
+ // applied-state.json, applied/<P>.json, unlearned/<P>.json — interruption between
242
+ // truncate and flush (Ctrl-C, OOM, hardware fault) would otherwise leave a zero-byte
243
+ // or partial-JSON file. The next read would return null, F-071 would silently revert
244
+ // every prior L1/L2 override. POSIX rename(2) is atomic; NTFS rename is good-enough.
245
+ // Fixed pre-ship per Stage-2 review.
246
+ function writeJson(fp, data) {
247
+ try {
248
+ ensureDir(path.dirname(fp));
249
+ const tmp = fp + '.tmp';
250
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
251
+ fs.renameSync(tmp, fp);
252
+ return true;
253
+ } catch (_e) {
254
+ // Best-effort cleanup — leave no .tmp orphan on failure.
255
+ try { fs.unlinkSync(fp + '.tmp'); } catch (_e2) { /* ignore */ }
256
+ return false;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Read the SESSION.json sessionId, if any. Used to attribute apply/unlearn audit records
262
+ * to a session. Falls back to null silently. Never throws.
263
+ * @param {string} projectRoot
264
+ * @returns {string|null}
265
+ */
266
+ function currentSessionId(projectRoot) {
267
+ try {
268
+ const fp = path.join(projectRoot, CAP_DIR, 'SESSION.json');
269
+ if (!fs.existsSync(fp)) return null;
270
+ const raw = fs.readFileSync(fp, 'utf8');
271
+ const parsed = JSON.parse(raw);
272
+ if (parsed && typeof parsed.sessionId === 'string' && parsed.sessionId.length > 0) {
273
+ return parsed.sessionId;
274
+ }
275
+ return null;
276
+ } catch (_e) {
277
+ return null;
278
+ }
279
+ }
280
+
281
+ // -----------------------------------------------------------------------------
282
+ // Git helpers — single choke point so the safety contract (D3) lives in ONE place.
283
+ // -----------------------------------------------------------------------------
284
+
285
+ /**
286
+ * Run a git command in the project root. Returns { stdout, stderr, status }.
287
+ * Never throws — callers inspect status for failure.
288
+ * @param {string} projectRoot
289
+ * @param {string[]} args
290
+ * @returns {{stdout:string, stderr:string, status:number|null}}
291
+ */
292
+ function git(projectRoot, args) {
293
+ // @cap-risk(F-074/AC-2) Every git command in this file routes through here. We use spawnSync
294
+ // (not execSync) so an untrusted argv is passed as an array — no shell
295
+ // interpolation, no opportunity for `; rm -rf` injection through a
296
+ // hostile pattern field.
297
+ const result = spawnSync('git', args, {
298
+ cwd: projectRoot,
299
+ encoding: 'utf8',
300
+ // Inherit user env so global git config (user.name/user.email) and pre-commit-hook PATH work.
301
+ env: process.env,
302
+ });
303
+ return {
304
+ stdout: typeof result.stdout === 'string' ? result.stdout : '',
305
+ stderr: typeof result.stderr === 'string' ? result.stderr : '',
306
+ status: typeof result.status === 'number' ? result.status : null,
307
+ };
308
+ }
309
+
310
+ /**
311
+ * Stage SPECIFIC files and run a normal `git commit`. CLAUDE.md forbids `--no-verify`; user
312
+ * pre-commit hooks must fire. Returns the commit hash on success or an error reason on failure.
313
+ *
314
+ * @cap-risk(F-074/AC-2) `files` is the closed set we stage. Callers MUST pass exact paths;
315
+ * passing a directory or a glob would silently expand to a wider stage.
316
+ * The internal `git add --` ensures the args after `--` are treated as
317
+ * file paths even when one starts with `-`.
318
+ *
319
+ * @cap-decision(F-074/D3) On hook failure (status !== 0 from `git commit`), we DO NOT unstage.
320
+ * The user fixes the hook issue manually (lint, format) and either
321
+ * re-runs `git commit` themselves or invokes applyPattern with `--retry`.
322
+ * Unstaging would silently lose the L3 file edit + the audit record write.
323
+ *
324
+ * @param {string} projectRoot
325
+ * @param {string[]} files - Relative paths from projectRoot. NEVER use '.' or '-A'.
326
+ * @param {string} message - Commit message; passed verbatim to `git commit -m`.
327
+ * @returns {{success:boolean, commitHash?:string, error?:string, stage?:string}}
328
+ * stage: 'add' | 'commit' — which step failed.
329
+ */
330
+ function gitStageAndCommit(projectRoot, files, message) {
331
+ // Defensive — refuse to operate on a non-list or empty list. A bug that calls with [] would
332
+ // otherwise produce an empty `git add --` (no-op) followed by `git commit` of whatever was
333
+ // already staged, leaking state across calls.
334
+ if (!Array.isArray(files) || files.length === 0) {
335
+ return { success: false, error: 'no files to stage', stage: 'add' };
336
+ }
337
+ for (const f of files) {
338
+ if (typeof f !== 'string' || f.length === 0) {
339
+ return { success: false, error: 'invalid file path in stage list', stage: 'add' };
340
+ }
341
+ // Refuse the wildcard catch-alls explicitly. CLAUDE.md forbids them.
342
+ if (f === '.' || f === '-A' || f === '-a' || f === '*') {
343
+ return { success: false, error: `wildcard stage refused: ${f}`, stage: 'add' };
344
+ }
345
+ // @cap-decision(F-074/D9) Path-traversal defense-in-depth. Git's pathspec resolution
346
+ // already refuses paths outside the repo, but the retry path consumes the
347
+ // on-disk audit's targetFiles verbatim — a forged audit could pass an
348
+ // absolute path or a `..`-climb string. Refuse at the F-074 boundary
349
+ // instead of relying on git's behaviour.
350
+ if (path.isAbsolute(f) || f.startsWith('../') || f.includes('/../') || f === '..') {
351
+ return { success: false, error: `path-traversal refused: ${f}`, stage: 'add' };
352
+ }
353
+ const normalized = path.posix.normalize(f.replace(/\\/g, '/'));
354
+ if (normalized.startsWith('../') || normalized === '..') {
355
+ return { success: false, error: `path-traversal refused (post-normalize): ${f}`, stage: 'add' };
356
+ }
357
+ }
358
+
359
+ // Stage the closed set. The `--` sentinel ensures every subsequent token is a path even if
360
+ // one starts with `-`.
361
+ const addArgs = ['add', '--', ...files];
362
+ const addResult = git(projectRoot, addArgs);
363
+ if (addResult.status !== 0) {
364
+ return {
365
+ success: false,
366
+ error: `git add failed: ${addResult.stderr.trim() || `status ${addResult.status}`}`,
367
+ stage: 'add',
368
+ };
369
+ }
370
+
371
+ // @cap-risk(F-074/AC-2) Plain `git commit` — pre-commit hooks WILL fire. CLAUDE.md forbids
372
+ // --no-verify; we honour it. On hook failure the staged files remain
373
+ // staged so the user can resolve manually.
374
+ // @cap-decision(F-074/D3) Pass the message via -m as a single argv (no shell interpolation).
375
+ // Multi-line messages are flattened by callers; we don't need a HEREDOC
376
+ // in this codepath.
377
+ const commitArgs = ['commit', '-m', message];
378
+ const commitResult = git(projectRoot, commitArgs);
379
+ if (commitResult.status !== 0) {
380
+ return {
381
+ success: false,
382
+ error: `git commit failed: ${commitResult.stderr.trim() || commitResult.stdout.trim() || `status ${commitResult.status}`}`,
383
+ stage: 'commit',
384
+ };
385
+ }
386
+
387
+ // Read the abbreviated SHA of the new HEAD. Using --short for stable abbrev length and
388
+ // `-1` so we get exactly the commit we just created.
389
+ const sha = git(projectRoot, ['rev-parse', '--short', 'HEAD']);
390
+ if (sha.status !== 0) {
391
+ // Commit succeeded but we couldn't read the hash — degrade gracefully with null hash so
392
+ // the audit record still lands; the user can recover the SHA from `git log` manually.
393
+ return { success: true, commitHash: null };
394
+ }
395
+ return { success: true, commitHash: sha.stdout.trim() };
396
+ }
397
+
398
+ // -----------------------------------------------------------------------------
399
+ // L3 target whitelist
400
+ // -----------------------------------------------------------------------------
401
+
402
+ /**
403
+ * Return true iff the relative path is allowed as an L3 patch target. CLAUDE.md scopes L3
404
+ * to agents/ and commands/cap/ — patterns targeting anything else are rejected.
405
+ *
406
+ * @cap-risk(F-074/AC-2) THE prefix gate. A regression here would let a hostile pattern rewrite
407
+ * arbitrary repo files. Adversarial test verifies a pattern targeting
408
+ * `package.json` (or `../etc/passwd`) is rejected.
409
+ *
410
+ * @param {string} relPath
411
+ * @returns {boolean}
412
+ */
413
+ function isAllowedL3Target(relPath) {
414
+ if (typeof relPath !== 'string' || relPath.length === 0) return false;
415
+ // Reject path-traversal explicitly. A path like `agents/../../../etc/passwd` would otherwise
416
+ // pass the prefix check; we use path.normalize and refuse anything that climbs out.
417
+ const normalized = path.posix.normalize(relPath.replace(/\\/g, '/'));
418
+ if (normalized.startsWith('..') || normalized.startsWith('/')) return false;
419
+ for (const prefix of L3_TARGET_PREFIXES) {
420
+ // @cap-decision(F-074/D10) Require a non-empty segment after the prefix. A bare `agents/` or
421
+ // `commands/cap/` (no filename) would otherwise pass; the file read would
422
+ // then catch EISDIR, but it's cleaner to refuse at the gate.
423
+ if (normalized.startsWith(prefix) && normalized.length > prefix.length) return true;
424
+ }
425
+ return false;
426
+ }
427
+
428
+ // -----------------------------------------------------------------------------
429
+ // applied-state.json read/write
430
+ // -----------------------------------------------------------------------------
431
+
432
+ /**
433
+ * Read the centralised applied-state file. Returns the default empty state when the file is
434
+ * missing or malformed. Never throws.
435
+ *
436
+ * @cap-decision(F-074/D2) F-071 / future L2 consumer reads this file to honour applied
437
+ * parameters / rules. F-074 only writes it; the consumer wiring is a
438
+ * follow-up captured in @cap-todo. Schema:
439
+ * {
440
+ * version: 1,
441
+ * l1: { '<featureId>/<KEY>': value, ... },
442
+ * l2: [ { patternId, rule, appliedAt }, ... ],
443
+ * l3: [ { patternId, file, appliedAt }, ... ]
444
+ * }
445
+ *
446
+ * @cap-todo(ac:F-074/AC-1) F-071 (cap-pattern-pipeline) shall read appliedState.l1[`F-071/THRESHOLD_OVERRIDE_COUNT`]
447
+ * and use it as the override-threshold override before falling back to the
448
+ * THRESHOLD_OVERRIDE_COUNT constant. This wiring is OUT OF SCOPE for F-074;
449
+ * a follow-up PR will add it without changing F-074's API.
450
+ *
451
+ * @param {string} projectRoot
452
+ * @returns {AppliedState}
453
+ */
454
+ function readAppliedState(projectRoot) {
455
+ const fp = appliedStateFilePath(projectRoot);
456
+ const parsed = readJson(fp);
457
+ if (!parsed) {
458
+ return { version: APPLIED_STATE_VERSION, l1: {}, l2: [], l3: [] };
459
+ }
460
+ // Defensive shape normalisation — a hand-edited file with a missing field shouldn't crash callers.
461
+ return {
462
+ version: typeof parsed.version === 'number' ? parsed.version : APPLIED_STATE_VERSION,
463
+ l1: (parsed.l1 && typeof parsed.l1 === 'object' && !Array.isArray(parsed.l1)) ? parsed.l1 : {},
464
+ l2: Array.isArray(parsed.l2) ? parsed.l2 : [],
465
+ l3: Array.isArray(parsed.l3) ? parsed.l3 : [],
466
+ };
467
+ }
468
+
469
+ /**
470
+ * Write the centralised applied-state file. Returns true on success. Test helper +
471
+ * called internally by applyPattern / unlearnPattern.
472
+ * @param {string} projectRoot
473
+ * @param {AppliedState} state
474
+ * @returns {boolean}
475
+ */
476
+ function writeAppliedState(projectRoot, state) {
477
+ if (!state || typeof state !== 'object') return false;
478
+ ensureDir(learningRoot(projectRoot));
479
+ return writeJson(appliedStateFilePath(projectRoot), {
480
+ version: typeof state.version === 'number' ? state.version : APPLIED_STATE_VERSION,
481
+ l1: (state.l1 && typeof state.l1 === 'object' && !Array.isArray(state.l1)) ? state.l1 : {},
482
+ l2: Array.isArray(state.l2) ? state.l2 : [],
483
+ l3: Array.isArray(state.l3) ? state.l3 : [],
484
+ });
485
+ }
486
+
487
+ // -----------------------------------------------------------------------------
488
+ // Level-specific apply/reverse helpers
489
+ // -----------------------------------------------------------------------------
490
+
491
+ /**
492
+ * Apply an L1 (parameter) pattern. Mutates the in-memory state and persists it. Returns
493
+ * the beforeAfterDiff that lands in the audit record.
494
+ * @param {AppliedState} state
495
+ * @param {object} pattern
496
+ * @returns {{key:string, from:any, to:any}}
497
+ */
498
+ function applyL1(state, pattern) {
499
+ const sug = pattern && pattern.suggestion;
500
+ // F-071's L1 suggestion shape: { kind: 'L1', target: '<featureId>/<KEY>', from: <prior>, to: <new>, rationale }.
501
+ const key = (sug && typeof sug.target === 'string') ? sug.target : `${pattern.id}/value`;
502
+ // @cap-decision(F-074/D1) Record whether the key was already set in applied-state. On unlearn we
503
+ // restore the prior value if `hadPrior` is true, otherwise we delete the
504
+ // key so the post-unlearn state shape matches pre-apply byte-for-byte.
505
+ // Recording the boolean explicitly avoids the "null is a real value vs.
506
+ // null means unset" ambiguity that bit me on the first iteration.
507
+ const hadPrior = Object.prototype.hasOwnProperty.call(state.l1, key);
508
+ const from = hadPrior ? state.l1[key] : null;
509
+ const to = sug && Object.prototype.hasOwnProperty.call(sug, 'to') ? sug.to : null;
510
+ state.l1[key] = to;
511
+ return { key, hadPrior, from, to };
512
+ }
513
+
514
+ /**
515
+ * Reverse an L1 apply. The audit's diff carries `{ key, hadPrior, from, to }`; if hadPrior we
516
+ * restore `from`, else we delete the key. Falls back to the legacy 2026-05 shape (no hadPrior
517
+ * field) by treating `from === null` as "unset" — keeps a future schema migration painless.
518
+ * @param {AppliedState} state
519
+ * @param {{key:string, hadPrior?:boolean, from:any, to:any}} diff
520
+ */
521
+ function reverseL1(state, diff) {
522
+ if (!diff || typeof diff.key !== 'string') return;
523
+ // Strict path: hadPrior is the authoritative signal.
524
+ if (diff.hadPrior === true) {
525
+ state.l1[diff.key] = diff.from;
526
+ return;
527
+ }
528
+ if (diff.hadPrior === false) {
529
+ delete state.l1[diff.key];
530
+ return;
531
+ }
532
+ // Legacy path (no hadPrior recorded): treat null/undefined `from` as "unset".
533
+ if (diff.from === null || diff.from === undefined) {
534
+ delete state.l1[diff.key];
535
+ return;
536
+ }
537
+ state.l1[diff.key] = diff.from;
538
+ }
539
+
540
+ /**
541
+ * Apply an L2 (rule) pattern. Append the rule object to state.l2 with the patternId and ts.
542
+ * @param {AppliedState} state
543
+ * @param {object} pattern
544
+ * @param {string} appliedAt
545
+ * @returns {{rule:object}}
546
+ */
547
+ function applyL2(state, pattern, appliedAt) {
548
+ const rule = (pattern && pattern.suggestion) || { kind: 'L2' };
549
+ state.l2.push({ patternId: pattern.id, rule, appliedAt });
550
+ return { rule };
551
+ }
552
+
553
+ /**
554
+ * Reverse an L2 apply. Remove every entry whose patternId matches.
555
+ * @param {AppliedState} state
556
+ * @param {string} patternId
557
+ */
558
+ function reverseL2(state, patternId) {
559
+ state.l2 = state.l2.filter((entry) => entry && entry.patternId !== patternId);
560
+ }
561
+
562
+ /**
563
+ * Apply an L3 (prompt-template patch) pattern. Reads the target file, snapshots originalText,
564
+ * writes patchedText, returns the diff for the audit record.
565
+ *
566
+ * @cap-decision(F-074/D4) We capture BOTH original and patched text in the audit; unlearn uses
567
+ * the comparison `currentContent === patchedText` to detect drift. If the
568
+ * file has been edited between apply and unlearn, we refuse to revert.
569
+ *
570
+ * @param {string} projectRoot
571
+ * @param {object} pattern
572
+ * @returns {{file:string, originalText:string, patchedText:string}|{error:string}}
573
+ */
574
+ function applyL3(projectRoot, pattern) {
575
+ const sug = pattern && pattern.suggestion;
576
+ if (!sug || typeof sug !== 'object') {
577
+ return { error: 'l3-suggestion-missing' };
578
+ }
579
+ const file = typeof sug.file === 'string' ? sug.file : (typeof sug.target === 'string' ? sug.target : null);
580
+ if (!file) return { error: 'l3-target-missing' };
581
+ if (!isAllowedL3Target(file)) return { error: 'l3-target-not-allowed' };
582
+
583
+ const abs = path.join(projectRoot, file);
584
+ let originalText;
585
+ try {
586
+ if (!fs.existsSync(abs)) return { error: 'l3-target-missing' };
587
+ originalText = fs.readFileSync(abs, 'utf8');
588
+ } catch (_e) {
589
+ return { error: 'l3-read-failed' };
590
+ }
591
+
592
+ // The patched text is supplied by the pattern. F-071's LLM-stage L3 suggestion shape carries
593
+ // either `patchedText` (full replacement) or `patch` (a future diff format we don't support
594
+ // yet). Strict path: require patchedText for now; reject otherwise so we don't half-apply.
595
+ const patchedText = typeof sug.patchedText === 'string' ? sug.patchedText : null;
596
+ if (patchedText === null) return { error: 'l3-patched-text-missing' };
597
+
598
+ try {
599
+ fs.writeFileSync(abs, patchedText, 'utf8');
600
+ } catch (_e) {
601
+ return { error: 'l3-write-failed' };
602
+ }
603
+
604
+ return { file, originalText, patchedText };
605
+ }
606
+
607
+ /**
608
+ * Reverse an L3 apply. Reads the current file content, asserts it === patchedText, then
609
+ * restores originalText. If drift is detected (current !== patchedText), refuses.
610
+ *
611
+ * @cap-decision(F-074/D4) Drift detection is byte-exact equality. A trailing-newline change or a
612
+ * CRLF↔LF flip will trigger drift; that's intentional — we'd rather refuse
613
+ * and let the user resolve via `git revert <apply-hash>` than silently
614
+ * clobber a downstream edit.
615
+ *
616
+ * @param {string} projectRoot
617
+ * @param {{file:string, originalText:string, patchedText:string}} diff
618
+ * @returns {{success:true, file:string} | {success:false, reason:'l3-drift'|'l3-target-missing'|'l3-read-failed'|'l3-write-failed'|'l3-target-not-allowed'}}
619
+ */
620
+ function reverseL3(projectRoot, diff) {
621
+ if (!diff || typeof diff.file !== 'string') {
622
+ return { success: false, reason: 'l3-target-missing' };
623
+ }
624
+ if (!isAllowedL3Target(diff.file)) {
625
+ // Defensive: a malformed audit could carry an out-of-scope file. Refuse rather than write.
626
+ return { success: false, reason: 'l3-target-not-allowed' };
627
+ }
628
+ const abs = path.join(projectRoot, diff.file);
629
+ let current;
630
+ try {
631
+ if (!fs.existsSync(abs)) return { success: false, reason: 'l3-target-missing' };
632
+ current = fs.readFileSync(abs, 'utf8');
633
+ } catch (_e) {
634
+ return { success: false, reason: 'l3-read-failed' };
635
+ }
636
+ if (current !== diff.patchedText) {
637
+ return { success: false, reason: 'l3-drift' };
638
+ }
639
+ try {
640
+ fs.writeFileSync(abs, diff.originalText, 'utf8');
641
+ } catch (_e) {
642
+ return { success: false, reason: 'l3-write-failed' };
643
+ }
644
+ return { success: true, file: diff.file };
645
+ }
646
+
647
+ // -----------------------------------------------------------------------------
648
+ // Public API — applyPattern (AC-1, AC-2)
649
+ // -----------------------------------------------------------------------------
650
+
651
+ // @cap-todo(ac:F-074/AC-1) Audit record per apply at .cap/learning/applied/P-NNN.json with
652
+ // {patternId, appliedAt, level, targetFiles, featureRef, fitnessSnapshot,
653
+ // beforeAfterDiff?, applyState}.
654
+ // @cap-todo(ac:F-074/AC-2) Each apply creates `learn: apply P-NNN (F-XXX)` git commit.
655
+ /**
656
+ * Apply a pattern: write the audit record, mutate applied-state.json (or the L3 file), capture
657
+ * a fitness snapshot, and create a git commit. Returns success or a structured failure reason.
658
+ *
659
+ * @cap-decision(F-074/D7) Already-applied detection routes through the audit-record-existence
660
+ * check (NOT git history). When `options.retry === true` AND the existing
661
+ * audit's applyState is 'pending', we retry only the commit step.
662
+ *
663
+ * @param {string} projectRoot
664
+ * @param {string} patternId
665
+ * @param {Object} [options]
666
+ * @param {Date|string} [options.now] - Override the persisted timestamps (mostly for tests).
667
+ * @param {boolean} [options.retry] - Retry a prior pending commit (do NOT re-mutate state).
668
+ * @param {'manual'|'auto'} [options.trigger] - Audit flavour (default 'manual').
669
+ * @returns {{applied:true, commitHash:string|null, audit:AppliedAuditRecord}
670
+ * | {applied:false, reason:'pattern-not-found'|'l3-target-missing'|'l3-target-not-allowed'|'l3-patched-text-missing'|'l3-suggestion-missing'|'l3-read-failed'|'l3-write-failed'|'pending-hook-fail'|'already-applied'|'invalid-pattern-id'|'invalid-project-root'|'unsupported-level', error?:string, audit?:AppliedAuditRecord}}
671
+ */
672
+ function applyPattern(projectRoot, patternId, options) {
673
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
674
+ return { applied: false, reason: 'invalid-project-root' };
675
+ }
676
+ if (!isValidPatternId(patternId)) {
677
+ return { applied: false, reason: 'invalid-pattern-id' };
678
+ }
679
+ const opts = options || {};
680
+ const retry = opts.retry === true;
681
+ const nowIso = opts.now ? new Date(opts.now).toISOString() : new Date().toISOString();
682
+
683
+ // Idempotency / retry gate — read the prior audit record (if any) BEFORE mutating state.
684
+ // @cap-risk(F-074/AC-7) Without this gate, a second applyPattern call would double-apply and
685
+ // create a duplicate commit. The audit-record-existence is the lock.
686
+ const priorAudit = readJson(appliedAuditPath(projectRoot, patternId));
687
+ if (priorAudit && !retry) {
688
+ return { applied: false, reason: 'already-applied', audit: priorAudit };
689
+ }
690
+ if (priorAudit && retry && priorAudit.applyState !== 'pending') {
691
+ // Retry on a committed audit is a no-op — nothing to retry.
692
+ return { applied: false, reason: 'already-applied', audit: priorAudit };
693
+ }
694
+
695
+ const pattern = findPattern(projectRoot, patternId);
696
+ if (!pattern) {
697
+ return { applied: false, reason: 'pattern-not-found' };
698
+ }
699
+
700
+ // Retry path — skip the state-mutation step, just re-stage and re-commit using the existing audit.
701
+ if (retry && priorAudit && priorAudit.applyState === 'pending') {
702
+ return retryApplyCommit(projectRoot, priorAudit);
703
+ }
704
+
705
+ const level = pattern.level;
706
+ if (level !== 'L1' && level !== 'L2' && level !== 'L3') {
707
+ return { applied: false, reason: 'unsupported-level' };
708
+ }
709
+
710
+ // -- State mutation --
711
+ const state = readAppliedState(projectRoot);
712
+ let beforeAfterDiff;
713
+ /** @type {string[]} */
714
+ const targetFiles = [];
715
+
716
+ if (level === 'L1') {
717
+ const diff = applyL1(state, pattern);
718
+ beforeAfterDiff = { L1: diff };
719
+ } else if (level === 'L2') {
720
+ const diff = applyL2(state, pattern, nowIso);
721
+ beforeAfterDiff = { L2: diff };
722
+ } else {
723
+ // L3
724
+ const diff = applyL3(projectRoot, pattern);
725
+ if (diff.error) {
726
+ // L3 apply failed before any state mutation — return the error reason verbatim.
727
+ return { applied: false, reason: diff.error };
728
+ }
729
+ beforeAfterDiff = { L3: diff };
730
+ state.l3.push({ patternId: pattern.id, file: diff.file, appliedAt: nowIso });
731
+ targetFiles.push(diff.file);
732
+ }
733
+
734
+ // Persist applied-state.json for L1 / L2 / L3 (L3 also needs the rule entry).
735
+ writeAppliedState(projectRoot, state);
736
+
737
+ // -- Fitness snapshot --
738
+ // F-072 takes the snapshot append-only into <P-NNN>.snapshots.jsonl and returns the SnapshotRecord.
739
+ // We embed that record in the audit so the AC-5 retract check can compare without re-reading.
740
+ let fitnessSnapshot = null;
741
+ try {
742
+ fitnessSnapshot = fitnessScore.recordApplySnapshot(projectRoot, patternId, { now: nowIso });
743
+ } catch (_e) {
744
+ fitnessSnapshot = null;
745
+ }
746
+
747
+ // -- Audit record --
748
+ const featureRef = (typeof pattern.featureRef === 'string' && /^F-\d+$/.test(pattern.featureRef))
749
+ ? pattern.featureRef
750
+ : null;
751
+
752
+ /** @type {AppliedAuditRecord} */
753
+ const audit = {
754
+ id: patternId,
755
+ patternId,
756
+ appliedAt: nowIso,
757
+ applyState: 'pending', // upgraded to 'committed' once git commit succeeds
758
+ level,
759
+ featureRef,
760
+ commitHash: null,
761
+ targetFiles: [
762
+ // Always include the audit + applied-state files; L3 adds the patched file too.
763
+ path.posix.join(CAP_DIR, LEARNING_DIR, APPLIED_DIR, `${patternId}.json`),
764
+ path.posix.join(CAP_DIR, LEARNING_DIR, APPLIED_STATE_FILE),
765
+ ...targetFiles,
766
+ ],
767
+ fitnessSnapshot,
768
+ beforeAfterDiff,
769
+ };
770
+
771
+ // Persist the audit BEFORE the commit so a hook failure leaves an applyState:'pending' record on disk.
772
+ writeJson(appliedAuditPath(projectRoot, patternId), audit);
773
+
774
+ // -- Git commit --
775
+ // @cap-risk(F-074/AC-2) Commit message format is contractual — F-073 will parse it for the review board.
776
+ // Format: `learn: apply P-NNN (F-XXX)` or `learn: apply P-NNN` when no featureRef.
777
+ const commitMsg = featureRef
778
+ ? `learn: apply ${patternId} (${featureRef})`
779
+ : `learn: apply ${patternId}`;
780
+ const commitResult = gitStageAndCommit(projectRoot, audit.targetFiles, commitMsg);
781
+
782
+ if (!commitResult.success) {
783
+ // Hook failed (or git itself failed). Audit stays at applyState:'pending'; staged files
784
+ // remain staged for the user to resolve.
785
+ return {
786
+ applied: false,
787
+ reason: 'pending-hook-fail',
788
+ error: commitResult.error,
789
+ audit,
790
+ };
791
+ }
792
+
793
+ audit.applyState = 'committed';
794
+ audit.commitHash = commitResult.commitHash;
795
+ writeJson(appliedAuditPath(projectRoot, patternId), audit);
796
+
797
+ return { applied: true, commitHash: commitResult.commitHash, audit };
798
+ }
799
+
800
+ /**
801
+ * Retry the commit for a prior pending audit. The state was already mutated on the original
802
+ * applyPattern call; we only re-stage + commit. On success, promote the audit to
803
+ * applyState:'committed' and return.
804
+ *
805
+ * @cap-decision(F-074/D7) Retry skips state mutation. The L3 file is still patched on disk;
806
+ * applied-state.json is still updated. The user fixed the hook issue
807
+ * (e.g. lint), and the commit can now go through.
808
+ *
809
+ * @param {string} projectRoot
810
+ * @param {AppliedAuditRecord} priorAudit
811
+ * @returns {{applied:true, commitHash:string|null, audit:AppliedAuditRecord}|{applied:false, reason:'pending-hook-fail', error:string, audit:AppliedAuditRecord}}
812
+ */
813
+ function retryApplyCommit(projectRoot, priorAudit) {
814
+ const featureRef = priorAudit.featureRef;
815
+ const commitMsg = featureRef
816
+ ? `learn: apply ${priorAudit.patternId} (${featureRef})`
817
+ : `learn: apply ${priorAudit.patternId}`;
818
+ const commitResult = gitStageAndCommit(projectRoot, priorAudit.targetFiles, commitMsg);
819
+ if (!commitResult.success) {
820
+ return { applied: false, reason: 'pending-hook-fail', error: commitResult.error, audit: priorAudit };
821
+ }
822
+ const updated = { ...priorAudit, applyState: 'committed', commitHash: commitResult.commitHash };
823
+ writeJson(appliedAuditPath(projectRoot, priorAudit.patternId), updated);
824
+ return { applied: true, commitHash: commitResult.commitHash, audit: updated };
825
+ }
826
+
827
+ // -----------------------------------------------------------------------------
828
+ // Public API — unlearnPattern (AC-3, AC-4, AC-7)
829
+ // -----------------------------------------------------------------------------
830
+
831
+ // @cap-todo(ac:F-074/AC-3) /cap:learn unlearn <P-ID> generates a reverse patch, applies it,
832
+ // commits as `learn: unlearn P-NNN`.
833
+ // @cap-todo(ac:F-074/AC-4) Unlearn audit at .cap/learning/unlearned/P-NNN.json with
834
+ // {reason:'manual'|'auto-retract', ts, commitHash}.
835
+ // @cap-todo(ac:F-074/AC-7) Idempotency — second call on already-unlearned pattern is a no-op.
836
+ /**
837
+ * Unlearn a pattern: reverse the apply, write the unlearn audit, create the unlearn commit.
838
+ *
839
+ * @cap-risk(F-074/AC-7) Idempotency guard at the top — read the unlearned audit BEFORE any state
840
+ * mutation. A regression that skips this would double-commit.
841
+ *
842
+ * @param {string} projectRoot
843
+ * @param {string} patternId
844
+ * @param {Object} [options]
845
+ * @param {'manual'|'auto-retract'} [options.reason] - Default 'manual'.
846
+ * @param {Date|string} [options.now]
847
+ * @returns {{unlearned:true, commitHash:string|null, audit:UnlearnedAuditRecord}
848
+ * | {unlearned:false, reason:'already-unlearned'|'l3-drift'|'apply-not-found'|'pending-hook-fail'|'invalid-pattern-id'|'invalid-project-root'|'l3-target-missing'|'l3-read-failed'|'l3-write-failed'|'l3-target-not-allowed', priorRecord?:UnlearnedAuditRecord, error?:string, commitHashToRevert?:string|null}}
849
+ */
850
+ function unlearnPattern(projectRoot, patternId, options) {
851
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
852
+ return { unlearned: false, reason: 'invalid-project-root' };
853
+ }
854
+ if (!isValidPatternId(patternId)) {
855
+ return { unlearned: false, reason: 'invalid-pattern-id' };
856
+ }
857
+ const opts = options || {};
858
+ const reason = opts.reason === 'auto-retract' ? 'auto-retract' : 'manual';
859
+ const nowIso = opts.now ? new Date(opts.now).toISOString() : new Date().toISOString();
860
+
861
+ // @cap-risk(F-074/AC-7) Idempotency gate — return early if the unlearn audit already exists.
862
+ const priorUnlearned = readJson(unlearnedAuditPath(projectRoot, patternId));
863
+ if (priorUnlearned) {
864
+ return { unlearned: false, reason: 'already-unlearned', priorRecord: priorUnlearned };
865
+ }
866
+
867
+ // The apply audit must exist to unlearn against.
868
+ const applyAudit = readJson(appliedAuditPath(projectRoot, patternId));
869
+ if (!applyAudit) {
870
+ return { unlearned: false, reason: 'apply-not-found' };
871
+ }
872
+
873
+ // Reverse the level-specific change.
874
+ const state = readAppliedState(projectRoot);
875
+ const level = applyAudit.level;
876
+ /** @type {string[]} */
877
+ const targetFiles = [
878
+ path.posix.join(CAP_DIR, LEARNING_DIR, UNLEARNED_DIR, `${patternId}.json`),
879
+ path.posix.join(CAP_DIR, LEARNING_DIR, APPLIED_STATE_FILE),
880
+ ];
881
+
882
+ if (level === 'L1') {
883
+ const diff = applyAudit.beforeAfterDiff && applyAudit.beforeAfterDiff.L1;
884
+ reverseL1(state, diff);
885
+ } else if (level === 'L2') {
886
+ reverseL2(state, patternId);
887
+ } else if (level === 'L3') {
888
+ const diff = applyAudit.beforeAfterDiff && applyAudit.beforeAfterDiff.L3;
889
+ const result = reverseL3(projectRoot, diff);
890
+ if (!result.success) {
891
+ // L3 drift / read-failure / etc. — refuse without committing.
892
+ // @cap-decision(F-074/D4) On l3-drift, surface the prior apply commit hash so the user can
893
+ // `git revert <apply-hash>` manually. This is THE escape hatch.
894
+ return {
895
+ unlearned: false,
896
+ reason: result.reason,
897
+ commitHashToRevert: applyAudit.commitHash || null,
898
+ };
899
+ }
900
+ // Remove the matching l3 entry from applied-state.
901
+ state.l3 = state.l3.filter((entry) => entry && entry.patternId !== patternId);
902
+ targetFiles.push(diff.file);
903
+ } else {
904
+ return { unlearned: false, reason: 'apply-not-found' };
905
+ }
906
+
907
+ writeAppliedState(projectRoot, state);
908
+
909
+ // Persist the unlearn audit BEFORE the commit (mirror the apply path).
910
+ /** @type {UnlearnedAuditRecord} */
911
+ const audit = {
912
+ id: patternId,
913
+ patternId,
914
+ unlearnedAt: nowIso,
915
+ reason,
916
+ commitHash: null,
917
+ appliedCommitHash: applyAudit.commitHash || null,
918
+ };
919
+ writeJson(unlearnedAuditPath(projectRoot, patternId), audit);
920
+
921
+ const commitMsg = `learn: unlearn ${patternId}`;
922
+ const commitResult = gitStageAndCommit(projectRoot, targetFiles, commitMsg);
923
+ if (!commitResult.success) {
924
+ // Audit stays in place with commitHash:null. The user can resolve manually.
925
+ return { unlearned: false, reason: 'pending-hook-fail', error: commitResult.error };
926
+ }
927
+
928
+ audit.commitHash = commitResult.commitHash;
929
+ writeJson(unlearnedAuditPath(projectRoot, patternId), audit);
930
+
931
+ return { unlearned: true, commitHash: commitResult.commitHash, audit };
932
+ }
933
+
934
+ // -----------------------------------------------------------------------------
935
+ // Public API — listAppliedPatterns / listUnlearnedPatterns
936
+ // -----------------------------------------------------------------------------
937
+
938
+ /**
939
+ * Read every persisted apply audit. Tolerant to missing dir + malformed files.
940
+ * @param {string} projectRoot
941
+ * @returns {AppliedAuditRecord[]}
942
+ */
943
+ function listAppliedPatterns(projectRoot) {
944
+ const dir = appliedDir(projectRoot);
945
+ if (!fs.existsSync(dir)) return [];
946
+ let entries;
947
+ try {
948
+ entries = fs.readdirSync(dir);
949
+ } catch (_e) {
950
+ return [];
951
+ }
952
+ const out = [];
953
+ for (const f of entries) {
954
+ if (!/^P-\d+\.json$/.test(f)) continue;
955
+ const parsed = readJson(path.join(dir, f));
956
+ if (parsed) out.push(parsed);
957
+ }
958
+ // Sort by patternId ascending for deterministic output.
959
+ out.sort((a, b) => {
960
+ const ai = (a && a.patternId) || '';
961
+ const bi = (b && b.patternId) || '';
962
+ if (ai < bi) return -1;
963
+ if (ai > bi) return 1;
964
+ return 0;
965
+ });
966
+ return out;
967
+ }
968
+
969
+ /**
970
+ * Read every persisted unlearn audit. Tolerant to missing dir + malformed files.
971
+ * @param {string} projectRoot
972
+ * @returns {UnlearnedAuditRecord[]}
973
+ */
974
+ function listUnlearnedPatterns(projectRoot) {
975
+ const dir = unlearnedDir(projectRoot);
976
+ if (!fs.existsSync(dir)) return [];
977
+ let entries;
978
+ try {
979
+ entries = fs.readdirSync(dir);
980
+ } catch (_e) {
981
+ return [];
982
+ }
983
+ const out = [];
984
+ for (const f of entries) {
985
+ if (!/^P-\d+\.json$/.test(f)) continue;
986
+ const parsed = readJson(path.join(dir, f));
987
+ if (parsed) out.push(parsed);
988
+ }
989
+ out.sort((a, b) => {
990
+ const ai = (a && a.patternId) || '';
991
+ const bi = (b && b.patternId) || '';
992
+ if (ai < bi) return -1;
993
+ if (ai > bi) return 1;
994
+ return 0;
995
+ });
996
+ return out;
997
+ }
998
+
999
+ // -----------------------------------------------------------------------------
1000
+ // Public API — listRetractRecommended (AC-5, F-073 hook point)
1001
+ // -----------------------------------------------------------------------------
1002
+
1003
+ // @cap-todo(ac:F-074/AC-5) Read the retract list (.jsonl) and return de-duped pattern ids.
1004
+ // @cap-todo(ac:F-074/AC-6) F-073 review board reads this list to label patterns "Rückzug empfohlen"
1005
+ // and offer a one-click unlearn affordance. F-074 only EXPOSES the list;
1006
+ // F-073 wires the UI. This is intentionally a follow-up.
1007
+ /**
1008
+ * Read the retract-recommendations.jsonl and return the unique pattern ids most-recently
1009
+ * recommended for retraction. De-dup by patternId — most-recent line wins. Patterns that
1010
+ * have ALREADY been unlearned are filtered out (the recommendation is moot).
1011
+ *
1012
+ * @param {string} projectRoot
1013
+ * @returns {string[]} Sorted ascending for deterministic output.
1014
+ */
1015
+ function listRetractRecommended(projectRoot) {
1016
+ const fp = retractRecommendationsPath(projectRoot);
1017
+ if (!fs.existsSync(fp)) return [];
1018
+
1019
+ let raw;
1020
+ try {
1021
+ raw = fs.readFileSync(fp, 'utf8');
1022
+ } catch (_e) {
1023
+ return [];
1024
+ }
1025
+
1026
+ // Most-recent-wins de-dup: walk the file in order, overwrite per-id.
1027
+ /** @type {Map<string, object>} */
1028
+ const byId = new Map();
1029
+ for (const line of raw.split('\n')) {
1030
+ if (!line) continue;
1031
+ try {
1032
+ const parsed = JSON.parse(line);
1033
+ if (!parsed || typeof parsed !== 'object') continue;
1034
+ if (typeof parsed.patternId !== 'string' || !PATTERN_ID_RE.test(parsed.patternId)) continue;
1035
+ byId.set(parsed.patternId, parsed);
1036
+ } catch (_e) {
1037
+ // Skip malformed lines — never throw.
1038
+ }
1039
+ }
1040
+
1041
+ // Filter out patterns that have been unlearned already.
1042
+ const unlearned = new Set(listUnlearnedPatterns(projectRoot).map((u) => u.patternId));
1043
+ const out = [];
1044
+ for (const id of byId.keys()) {
1045
+ if (!unlearned.has(id)) out.push(id);
1046
+ }
1047
+ out.sort();
1048
+ return out;
1049
+ }
1050
+
1051
+ // -----------------------------------------------------------------------------
1052
+ // Public API — runRetractCheck (AC-5)
1053
+ // -----------------------------------------------------------------------------
1054
+
1055
+ // @cap-todo(ac:F-074/AC-5) 5-session post-apply check: for each applied pattern, count distinct
1056
+ // override-corpus sessions since apply. If >=5 AND current Layer-1 override-rate
1057
+ // is worse than fitnessSnapshot.layer1.value, append to retract-recommendations.jsonl.
1058
+ /**
1059
+ * Walk every applied pattern; for each, count distinct override sessions since the apply timestamp.
1060
+ * When that count >= window AND the current Layer-1 override-count is worse than the snapshot's,
1061
+ * append a retract recommendation to .cap/learning/retract-recommendations.jsonl.
1062
+ *
1063
+ * @cap-decision(F-074/D5) "Worse" = currentLayer1.value > snapshotLayer1.value (more overrides =
1064
+ * pattern hurting more). When equal or better, no recommendation.
1065
+ *
1066
+ * @cap-decision(F-074/D5) "Sessions since apply" is computed from the F-070 override corpus —
1067
+ * distinct sessionIds whose ts > applyAuditAppliedAt. We do NOT use git
1068
+ * commit history (would couple us to git rebase semantics; brittle).
1069
+ *
1070
+ * @param {string} projectRoot
1071
+ * @param {Object} [options]
1072
+ * @param {number} [options.window] - Override RETRACT_SESSION_THRESHOLD (mostly for tests).
1073
+ * @param {Date|string} [options.now] - Timestamp for the appended JSONL line (default Date.now()).
1074
+ * @returns {{checked:string[], recommended:string[], errors:string[]}}
1075
+ */
1076
+ function runRetractCheck(projectRoot, options) {
1077
+ const opts = options || {};
1078
+ const window = typeof opts.window === 'number' && opts.window > 0 ? opts.window : RETRACT_SESSION_THRESHOLD;
1079
+ const nowIso = opts.now ? new Date(opts.now).toISOString() : new Date().toISOString();
1080
+
1081
+ /** @type {string[]} */
1082
+ const checked = [];
1083
+ /** @type {string[]} */
1084
+ const recommended = [];
1085
+ /** @type {string[]} */
1086
+ const errors = [];
1087
+
1088
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
1089
+ return { checked, recommended, errors: ['projectRoot is required'] };
1090
+ }
1091
+
1092
+ // Read the override corpus ONCE — performance bound (100 patterns × 1000 signals < 500ms).
1093
+ let overrides = [];
1094
+ try {
1095
+ overrides = learningSignals.getSignals(projectRoot, 'override') || [];
1096
+ } catch (e) {
1097
+ errors.push(`getSignals(override) failed: ${e && e.message ? e.message : 'unknown'}`);
1098
+ }
1099
+
1100
+ const applied = listAppliedPatterns(projectRoot);
1101
+ const unlearnedSet = new Set(listUnlearnedPatterns(projectRoot).map((u) => u.patternId));
1102
+
1103
+ for (const audit of applied) {
1104
+ if (!audit || !isValidPatternId(audit.patternId)) continue;
1105
+ if (unlearnedSet.has(audit.patternId)) continue; // already retracted manually
1106
+ if (audit.applyState !== 'committed') continue; // pending applies aren't yet "live"
1107
+
1108
+ checked.push(audit.patternId);
1109
+
1110
+ // Count distinct override sessions whose ts > applyAuditAppliedAt.
1111
+ const since = audit.appliedAt;
1112
+ /** @type {Set<string>} */
1113
+ const sessionsSince = new Set();
1114
+ for (const r of overrides) {
1115
+ if (!r || typeof r.sessionId !== 'string' || r.sessionId.length === 0) continue;
1116
+ if (typeof r.ts !== 'string') continue;
1117
+ if (r.ts <= since) continue;
1118
+ sessionsSince.add(r.sessionId);
1119
+ }
1120
+ const sessionsSinceApply = sessionsSince.size;
1121
+ if (sessionsSinceApply < window) continue; // not enough data yet
1122
+
1123
+ // Compare current Layer-1 to the apply-time snapshot. We re-compute current fitness via F-072
1124
+ // (single source of truth — never duplicate the formula).
1125
+ let current;
1126
+ try {
1127
+ current = fitnessScore.computeFitness(projectRoot, audit.patternId);
1128
+ } catch (e) {
1129
+ errors.push(`computeFitness threw for ${audit.patternId}: ${e && e.message ? e.message : 'unknown'}`);
1130
+ continue;
1131
+ }
1132
+ if (!current || !current.layer1) continue;
1133
+
1134
+ const snapshotL1 = audit.fitnessSnapshot && audit.fitnessSnapshot.layer1
1135
+ ? Number(audit.fitnessSnapshot.layer1.value) || 0
1136
+ : 0;
1137
+ const currentL1 = Number(current.layer1.value) || 0;
1138
+
1139
+ if (currentL1 > snapshotL1) {
1140
+ // @cap-risk(F-074/AC-2) Append-only JSONL — never overwrite. F-073 reads this file via
1141
+ // listRetractRecommended() which de-dups (most-recent-wins).
1142
+ const line = JSON.stringify({
1143
+ ts: nowIso,
1144
+ patternId: audit.patternId,
1145
+ sessionsSinceApply,
1146
+ snapshot: snapshotL1,
1147
+ current: currentL1,
1148
+ reason: 'override-rate-worse',
1149
+ }) + '\n';
1150
+ try {
1151
+ ensureDir(learningRoot(projectRoot));
1152
+ const fd = fs.openSync(retractRecommendationsPath(projectRoot), 'a');
1153
+ try {
1154
+ fs.writeSync(fd, line);
1155
+ } finally {
1156
+ fs.closeSync(fd);
1157
+ }
1158
+ recommended.push(audit.patternId);
1159
+ } catch (e) {
1160
+ errors.push(`append retract-recommendations failed for ${audit.patternId}: ${e && e.message ? e.message : 'unknown'}`);
1161
+ }
1162
+ }
1163
+ }
1164
+
1165
+ return { checked, recommended, errors };
1166
+ }
1167
+
1168
+ // -----------------------------------------------------------------------------
1169
+ // Exports — keep this list minimal. F-073 / /cap:learn should consume only these.
1170
+ // -----------------------------------------------------------------------------
1171
+
1172
+ module.exports = {
1173
+ // Constants — exported for tests + downstream consumers.
1174
+ CAP_DIR,
1175
+ LEARNING_DIR,
1176
+ APPLIED_DIR,
1177
+ UNLEARNED_DIR,
1178
+ APPLIED_STATE_FILE,
1179
+ RETRACT_RECOMMENDATIONS_FILE,
1180
+ RETRACT_SESSION_THRESHOLD,
1181
+ APPLIED_STATE_VERSION,
1182
+ L3_TARGET_PREFIXES,
1183
+ // Public API.
1184
+ applyPattern,
1185
+ unlearnPattern,
1186
+ listAppliedPatterns,
1187
+ listUnlearnedPatterns,
1188
+ listRetractRecommended,
1189
+ runRetractCheck,
1190
+ readAppliedState,
1191
+ writeAppliedState,
1192
+ // Path helpers — exported for tests.
1193
+ appliedDir,
1194
+ unlearnedDir,
1195
+ appliedStateFilePath,
1196
+ retractRecommendationsPath,
1197
+ appliedAuditPath,
1198
+ unlearnedAuditPath,
1199
+ // Helpers exposed for tests / introspection — keep this list small.
1200
+ isAllowedL3Target,
1201
+ gitStageAndCommit,
1202
+ currentSessionId,
1203
+ };