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,1072 @@
1
+ // @cap-context CAP F-073 Review Patterns via Learn Command — final piece of the V5 self-learning
2
+ // loop. Consumes F-071 patterns, F-072 fitness/confidence, F-074 applied/unlearned/
3
+ // retract-recommended state, computes a per-session "pending review" set, renders a
4
+ // human-friendly board.md, and exposes skip/reject/archive helpers + a Stop-hook gate.
5
+ // PURE-COMPUTE + small persistence: writes board.md, skipped/rejected JSONs, archive
6
+ // files, and the board-pending.flag. Never spawns the LLM, never writes git commits.
7
+ // The /cap:learn review skill orchestrates apply/unlearn by delegating to F-074.
8
+ // @cap-decision(F-073/D1) Stop-Hook integration — a separate hook file, hooks/cap-learn-review-hook.js,
9
+ // fires AFTER cap-memory's Stop hook (memory pipeline → learn pipeline → review board).
10
+ // The hook only computes shouldShowBoard() and writes a tiny .cap/learning/board-pending.flag
11
+ // on positive gate; it NEVER spawns the skill (Claude Code hook subprocesses can't drive an
12
+ // interactive flow). Same fail-silent posture as cap-learning-hook.js: on any error the hook
13
+ // exits 0 so a session can never be blocked. Lib-resolution mirrors cap-learning-hook.js
14
+ // (env override → colocated → ~/.claude). Skip via CAP_SKIP_LEARN_REVIEW_HOOK=1.
15
+ // @cap-decision(F-073/D2) Review UX via Briefing-pattern — mirrors F-071's LLM Skill-Briefing. The skill
16
+ // renders board.md with all eligible patterns + per-pattern options + retract labels, then
17
+ // INSTRUCTS the outer agent to read board.md, decide approve/reject/skip/unlearn per
18
+ // pattern, and call cap-pattern-apply.applyPattern / unlearnPattern (or our skipPattern /
19
+ // rejectPattern helpers). The skill exit code follows the AC-7 contract: ANY apply that
20
+ // returns applied:false → non-zero exit. There is NO interactive CLI subprocess.
21
+ // @cap-decision(F-073/D3) Eligibility = persisted in .cap/learning/patterns/ AND not in applied/ AND not
22
+ // in unlearned/ AND not in archive/ AND not in this-session's skipped-<sid>.json AND not
23
+ // in this-session's rejected-<sid>.json. Skipping is per-SESSION only (not a persistent
24
+ // mute) — a new session re-shows the patterns. Rejection is also per-session (the user
25
+ // may want to reconsider next session); persistence beyond a session would require a
26
+ // separate "permanent rejected" store, which is out of scope for F-073.
27
+ // @cap-decision(F-073/D4) Threshold gate (AC-2) — board appears only when:
28
+ // (a) ≥ 1 high-confidence eligible pattern: layer2.ready=true AND layer2.value >= 0.75
29
+ // AND layer2.n >= 5 (the F-072 confidence threshold), OR
30
+ // (b) ≥ 3 eligible candidates of any kind (any level / source / fitness).
31
+ // Below the gate, the skill exits 0 silently with a "no review needed" log line. The
32
+ // hook uses the same gate so the .flag file is only written when the user would actually
33
+ // see something. "high-confidence" uses the fitness layer2 reading because Layer-2 is
34
+ // the "long-term per-session weighted average" that signals the pattern's territory has
35
+ // been trustably useful — a fresh n=2 candidate with layer2.ready=false is NOT high-
36
+ // confidence regardless of its layer2.value snapshot. The gate is computed from the
37
+ // ELIGIBLE set (D3), not the raw persisted set, so applied/unlearned patterns can never
38
+ // contribute to "≥3" double-counting once they've left review scope.
39
+ // @cap-decision(F-073/D5) Stale-archive (AC-5) — patterns un-reviewed for > 7 sessions auto-archive to
40
+ // .cap/learning/archive/<P-NNN>.json AND are removed from .cap/learning/patterns/. The
41
+ // "session count" comes from the F-070 signal corpus — count distinct sessionIds with
42
+ // ts >= pattern.createdAt across the union of override / memory-ref / regret signals.
43
+ // If the corpus has fewer than 7 distinct sessions total → NO archive (insufficient
44
+ // data — F-072's expiry rule uses the same insufficient-history short-circuit). We do
45
+ // NOT archive applied/unlearned patterns (they've already left review) and we do NOT
46
+ // archive patterns that were skipped/rejected this session (the skip/reject is the
47
+ // user's "still aware of it" signal). Archive is idempotent: re-running on an already-
48
+ // archived id is a no-op; missing-source-pattern → recorded as error, not a throw.
49
+ // F-072's unionSessionsByRecency is NOT exported, so we replicate the simple count
50
+ // here (count distinct sessionIds across the three corpora).
51
+ // @cap-decision(F-073/D6) Atomic write contract (mirrors F-074/D8) — every JSON / md write that's not
52
+ // a one-shot append-only flag goes through the writeAtomic helper: write to .tmp,
53
+ // fs.renameSync into place. POSIX rename(2) is atomic; an interrupted write leaves a
54
+ // .tmp orphan we clean up on the next attempt rather than a half-written board.md that
55
+ // the outer agent might process. The flag file is small and write-truncate is fine —
56
+ // a half-written flag is harmless because the .json content isn't parsed by the skill
57
+ // (presence is the signal).
58
+ // @cap-constraint Zero external dependencies: node:fs + node:path only. Always go through F-071/F-072/
59
+ // F-074 module APIs — never read pattern/fitness/applied JSONs directly. cap-session
60
+ // is read via a tiny inline helper (mirrors F-074's currentSessionId pattern); we
61
+ // don't take a hard dep on cap-session to keep the resolver-graph identical to F-074.
62
+ // @cap-risk(F-073/AC-7) The approve→applyPattern call site is a CRITICAL SURFACE. The skill must
63
+ // propagate applied:false to a non-zero exit code; a regression that swallows the
64
+ // result would silently apply nothing while reporting success. The skill orchestration
65
+ // lives in commands/cap/learn.md (Subcommand: review). This module exposes the inputs
66
+ // the skill needs to make that decision — it does NOT call applyPattern itself
67
+ // (separation of concerns: F-073 = compute + render + skip/reject/archive; F-074 =
68
+ // apply/unlearn). The board.md hand-off documents the contract so the outer agent
69
+ // reports back faithfully.
70
+ // @cap-risk(F-073/AC-2) The threshold gate is the only thing standing between a noisy first session
71
+ // (lots of low-confidence candidates) and a flood of useless review prompts. A
72
+ // regression that loosens the gate would burn the user's attention. Adversarial test
73
+ // pins the boundary cases (exactly 3 / exactly 2 / exactly 1 high-confidence).
74
+ // @cap-risk(F-073/AC-4) The skipped-<sid>.json file shape is per-session ONLY. A regression that
75
+ // wrote it as a global skip-mute would silently hide patterns indefinitely. Tests
76
+ // pin: same-session re-read excludes ids; new-session re-read shows them again.
77
+
78
+ 'use strict';
79
+
80
+ // @cap-feature(feature:F-073, primary:true) Review Patterns via Learn Command — board renderer +
81
+ // eligibility computation + skip/reject/archive helpers
82
+ // + Stop-hook gate.
83
+
84
+ const fs = require('node:fs');
85
+ const path = require('node:path');
86
+
87
+ const patternPipeline = require('./cap-pattern-pipeline.cjs');
88
+ const fitnessScore = require('./cap-fitness-score.cjs');
89
+ const patternApply = require('./cap-pattern-apply.cjs');
90
+ const learningSignals = require('./cap-learning-signals.cjs');
91
+
92
+ // -----------------------------------------------------------------------------
93
+ // Constants — kept top-of-file so consumers (the /cap:learn review skill, the
94
+ // Stop-hook, tests) reference exactly one place. Mirrors layout of
95
+ // cap-pattern-apply.cjs and cap-fitness-score.cjs.
96
+ // -----------------------------------------------------------------------------
97
+
98
+ const CAP_DIR = '.cap';
99
+ const LEARNING_DIR = 'learning';
100
+ const PATTERNS_DIR = 'patterns';
101
+ const ARCHIVE_DIR = 'archive';
102
+ const BOARD_FILE = 'board.md';
103
+ const BOARD_PENDING_FLAG = 'board-pending.flag';
104
+
105
+ // AC-2 threshold knobs (D4). Centralised so a future tuning lives in ONE place;
106
+ // the adversarial test verifies exact behaviour.
107
+ const HIGH_CONFIDENCE_LAYER2_VALUE = 0.75;
108
+ const HIGH_CONFIDENCE_LAYER2_N = 5;
109
+ const ANY_KIND_THRESHOLD = 3;
110
+
111
+ // AC-5 stale-archive knob.
112
+ const STALE_SESSION_THRESHOLD = 7;
113
+
114
+ // Pattern-id format mirror.
115
+ const PATTERN_ID_RE = /^P-\d+$/;
116
+
117
+ // SessionId sanitisation guards (AC-7 / privacy).
118
+ // Mirrors cap-fitness-score's sessionId guards — the sessionId can flow into:
119
+ // 1. the skipped/rejected JSON's `sessionId` field (round-trips back to the
120
+ // review board in the same session), and
121
+ // 2. the board-pending.flag's `sessionId` field.
122
+ // A hostile sessionId could otherwise smuggle markdown / JSON / control bytes
123
+ // into either file. We refuse anything outside the SESSION_ID_RE alphabet and
124
+ // truncate at SESSION_ID_MAX before persisting.
125
+ const SESSION_ID_MAX = 200;
126
+ const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,200}$/;
127
+
128
+ /**
129
+ * @typedef {Object} EligibleEntry
130
+ * @property {string} patternId - 'P-NNN'.
131
+ * @property {object|null} fitness - The full F-072 FitnessRecord, or null when missing.
132
+ * @property {number} confidence - 0..1 derived from fitness (D4) for board display.
133
+ * @property {string} triggerReason - Short description of WHY this pattern qualifies (e.g. 'override-cluster F-100', 'regret').
134
+ * @property {boolean} retractRecommended - True iff F-074 listRetractRecommended() includes this id.
135
+ * @property {string[]} options - The action options surfaced on the board ('Approve','Reject','Skip','Unlearn'?).
136
+ * @property {object} pattern - The full PatternRecord (for the renderer; not persisted in the JSON shape).
137
+ */
138
+
139
+ /**
140
+ * @typedef {Object} ReviewBoard
141
+ * @property {EligibleEntry[]} eligible
142
+ * @property {{met: boolean, reason: string}} threshold
143
+ * @property {string[]} archived - Pattern ids moved to archive/ THIS run.
144
+ * @property {string[]} skippedThisSession - Pattern ids in the session's skipped file.
145
+ * @property {string[]} rejectedThisSession - Pattern ids in the session's rejected file.
146
+ * @property {string|null} sessionId - Session id used for skip/reject scoping.
147
+ * @property {string} ts - ISO timestamp of board build.
148
+ */
149
+
150
+ // -----------------------------------------------------------------------------
151
+ // Internal helpers — directory + IO
152
+ // -----------------------------------------------------------------------------
153
+
154
+ function ensureDir(dir) {
155
+ try {
156
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
157
+ } catch (_e) {
158
+ // Boundary callers swallow; the next write surfaces persistent IO problems.
159
+ }
160
+ }
161
+
162
+ function learningRoot(projectRoot) {
163
+ return path.join(projectRoot, CAP_DIR, LEARNING_DIR);
164
+ }
165
+
166
+ function patternsDir(projectRoot) {
167
+ return path.join(learningRoot(projectRoot), PATTERNS_DIR);
168
+ }
169
+
170
+ function archiveDir(projectRoot) {
171
+ return path.join(learningRoot(projectRoot), ARCHIVE_DIR);
172
+ }
173
+
174
+ function boardFilePath(projectRoot) {
175
+ return path.join(learningRoot(projectRoot), BOARD_FILE);
176
+ }
177
+
178
+ function boardPendingFlagPath(projectRoot) {
179
+ return path.join(learningRoot(projectRoot), BOARD_PENDING_FLAG);
180
+ }
181
+
182
+ function skippedFilePath(projectRoot, sessionId) {
183
+ return path.join(learningRoot(projectRoot), `skipped-${sessionId}.json`);
184
+ }
185
+
186
+ function rejectedFilePath(projectRoot, sessionId) {
187
+ return path.join(learningRoot(projectRoot), `rejected-${sessionId}.json`);
188
+ }
189
+
190
+ function archiveFilePath(projectRoot, patternId) {
191
+ return path.join(archiveDir(projectRoot), `${patternId}.json`);
192
+ }
193
+
194
+ function patternFilePath(projectRoot, patternId) {
195
+ return path.join(patternsDir(projectRoot), `${patternId}.json`);
196
+ }
197
+
198
+ /**
199
+ * Validate a P-NNN id. Every public boundary routes through this gate.
200
+ * @param {any} id
201
+ * @returns {boolean}
202
+ */
203
+ function isValidPatternId(id) {
204
+ return typeof id === 'string' && PATTERN_ID_RE.test(id);
205
+ }
206
+
207
+ /**
208
+ * Sanitise a sessionId. Returns null when the id is missing/invalid; otherwise
209
+ * returns the (truncated) id verified against SESSION_ID_RE.
210
+ * @cap-risk(F-073/AC-3) The sessionId flows into the .flag file's JSON body and
211
+ * file paths. A hostile sessionId could otherwise inject
212
+ * newlines / JSON-control / path-traversal segments.
213
+ * @param {any} v
214
+ * @returns {string|null}
215
+ */
216
+ function sanitiseSessionId(v) {
217
+ if (typeof v !== 'string' || v.length === 0) return null;
218
+ const trimmed = v.length > SESSION_ID_MAX ? v.slice(0, SESSION_ID_MAX) : v;
219
+ if (!SESSION_ID_RE.test(trimmed)) return null;
220
+ return trimmed;
221
+ }
222
+
223
+ /**
224
+ * Read the SESSION.json sessionId, if any. Mirrors the F-074 helper exactly.
225
+ * @param {string} projectRoot
226
+ * @returns {string|null}
227
+ */
228
+ function currentSessionId(projectRoot) {
229
+ try {
230
+ const fp = path.join(projectRoot, CAP_DIR, 'SESSION.json');
231
+ if (!fs.existsSync(fp)) return null;
232
+ const raw = fs.readFileSync(fp, 'utf8');
233
+ const parsed = JSON.parse(raw);
234
+ if (parsed && typeof parsed.sessionId === 'string' && parsed.sessionId.length > 0) {
235
+ return sanitiseSessionId(parsed.sessionId);
236
+ }
237
+ return null;
238
+ } catch (_e) {
239
+ return null;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Read a JSON file; return null on missing / malformed. Never throws.
245
+ * @param {string} fp
246
+ * @returns {any|null}
247
+ */
248
+ function readJson(fp) {
249
+ try {
250
+ if (!fs.existsSync(fp)) return null;
251
+ const raw = fs.readFileSync(fp, 'utf8');
252
+ const parsed = JSON.parse(raw);
253
+ return parsed;
254
+ } catch (_e) {
255
+ return null;
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Atomic write helper. Mirrors F-074/D8 pattern: write to .tmp, rename into place.
261
+ * @param {string} fp
262
+ * @param {string|Buffer} content
263
+ * @returns {boolean}
264
+ */
265
+ function writeAtomic(fp, content) {
266
+ try {
267
+ ensureDir(path.dirname(fp));
268
+ const tmp = fp + '.tmp';
269
+ fs.writeFileSync(tmp, content);
270
+ fs.renameSync(tmp, fp);
271
+ return true;
272
+ } catch (_e) {
273
+ try { fs.unlinkSync(fp + '.tmp'); } catch (_e2) { /* ignore */ }
274
+ return false;
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Atomic write of a JSON file (with trailing newline).
280
+ * @param {string} fp
281
+ * @param {object} data
282
+ * @returns {boolean}
283
+ */
284
+ function writeAtomicJson(fp, data) {
285
+ return writeAtomic(fp, JSON.stringify(data, null, 2) + '\n');
286
+ }
287
+
288
+ // -----------------------------------------------------------------------------
289
+ // Internal helpers — skip / reject persistence
290
+ // -----------------------------------------------------------------------------
291
+
292
+ /**
293
+ * Load the skipped-<sid>.json content for the given sessionId. Returns the
294
+ * patternIds array (de-duplicated).
295
+ * @param {string} projectRoot
296
+ * @param {string} sessionId
297
+ * @returns {string[]}
298
+ */
299
+ function loadSkippedThisSession(projectRoot, sessionId) {
300
+ const sid = sanitiseSessionId(sessionId);
301
+ if (!sid) return [];
302
+ const parsed = readJson(skippedFilePath(projectRoot, sid));
303
+ if (!parsed || !Array.isArray(parsed.patternIds)) return [];
304
+ const seen = new Set();
305
+ const out = [];
306
+ for (const id of parsed.patternIds) {
307
+ if (isValidPatternId(id) && !seen.has(id)) { seen.add(id); out.push(id); }
308
+ }
309
+ return out.sort();
310
+ }
311
+
312
+ /**
313
+ * Load the rejected-<sid>.json content for the given sessionId.
314
+ * @param {string} projectRoot
315
+ * @param {string} sessionId
316
+ * @returns {string[]}
317
+ */
318
+ function loadRejectedThisSession(projectRoot, sessionId) {
319
+ const sid = sanitiseSessionId(sessionId);
320
+ if (!sid) return [];
321
+ const parsed = readJson(rejectedFilePath(projectRoot, sid));
322
+ if (!parsed || !Array.isArray(parsed.patternIds)) return [];
323
+ const seen = new Set();
324
+ const out = [];
325
+ for (const id of parsed.patternIds) {
326
+ if (isValidPatternId(id) && !seen.has(id)) { seen.add(id); out.push(id); }
327
+ }
328
+ return out.sort();
329
+ }
330
+
331
+ // -----------------------------------------------------------------------------
332
+ // Internal helpers — eligibility + presentation
333
+ // -----------------------------------------------------------------------------
334
+
335
+ /**
336
+ * Compute the set of pattern ids eligible for review THIS session.
337
+ * D3: persisted ∧ ¬applied ∧ ¬unlearned ∧ ¬archived ∧ ¬skipped ∧ ¬rejected.
338
+ *
339
+ * @param {string} projectRoot
340
+ * @param {string|null} sessionId - When null, only persistent state filters apply.
341
+ * @returns {{ ids: string[], skipped: string[], rejected: string[] }}
342
+ */
343
+ function eligiblePatternIds(projectRoot, sessionId) {
344
+ let patterns = [];
345
+ try { patterns = patternPipeline.listPatterns(projectRoot) || []; } catch (_e) { patterns = []; }
346
+
347
+ const applied = new Set((patternApply.listAppliedPatterns(projectRoot) || [])
348
+ .map((a) => a && a.patternId).filter(isValidPatternId));
349
+ const unlearned = new Set((patternApply.listUnlearnedPatterns(projectRoot) || [])
350
+ .map((u) => u && u.patternId).filter(isValidPatternId));
351
+
352
+ // Archived is a directory listing, not a module API.
353
+ const archived = new Set();
354
+ try {
355
+ const dir = archiveDir(projectRoot);
356
+ if (fs.existsSync(dir)) {
357
+ for (const f of fs.readdirSync(dir)) {
358
+ if (!/^P-\d+\.json$/.test(f)) continue;
359
+ archived.add(f.slice(0, -'.json'.length));
360
+ }
361
+ }
362
+ } catch (_e) { /* ignore */ }
363
+
364
+ const skipped = sessionId ? loadSkippedThisSession(projectRoot, sessionId) : [];
365
+ const rejected = sessionId ? loadRejectedThisSession(projectRoot, sessionId) : [];
366
+ const skippedSet = new Set(skipped);
367
+ const rejectedSet = new Set(rejected);
368
+
369
+ const ids = [];
370
+ for (const p of patterns) {
371
+ if (!p || !isValidPatternId(p.id)) continue;
372
+ if (applied.has(p.id)) continue;
373
+ if (unlearned.has(p.id)) continue;
374
+ if (archived.has(p.id)) continue;
375
+ if (skippedSet.has(p.id)) continue;
376
+ if (rejectedSet.has(p.id)) continue;
377
+ ids.push(p.id);
378
+ }
379
+ ids.sort();
380
+ return { ids, skipped, rejected };
381
+ }
382
+
383
+ /**
384
+ * Derive a short, human-readable trigger reason for the board entry. Mirrors
385
+ * F-071's evidence shape: signalType + featureRef.
386
+ * @param {object} pattern - PatternRecord.
387
+ * @returns {string}
388
+ */
389
+ function triggerReasonFor(pattern) {
390
+ if (!pattern || typeof pattern !== 'object') return 'unknown';
391
+ const ev = pattern.evidence || {};
392
+ const sigType = (typeof ev.signalType === 'string' && ev.signalType.length > 0) ? ev.signalType : 'unknown-signal';
393
+ const fid = (typeof pattern.featureRef === 'string' && /^F-\d+$/.test(pattern.featureRef))
394
+ ? pattern.featureRef
395
+ : null;
396
+ const count = Number.isFinite(Number(ev.count)) ? Number(ev.count) : null;
397
+ const parts = [];
398
+ parts.push(sigType);
399
+ if (fid) parts.push(fid);
400
+ if (count != null) parts.push(`n=${count}`);
401
+ return parts.join(' · ');
402
+ }
403
+
404
+ /**
405
+ * Derive a confidence float from the F-072 fitness record. We use the pattern's
406
+ * own `confidence` field when present (LLM stage attaches one between 0..1) and
407
+ * fall back to the layer2 reading when it isn't. Returns 0 when neither is
408
+ * available — the board renderer surfaces this as "0.00".
409
+ *
410
+ * @param {object} pattern - PatternRecord.
411
+ * @param {object|null} fitness - FitnessRecord or null.
412
+ * @returns {number}
413
+ */
414
+ function confidenceFromFitness(pattern, fitness) {
415
+ if (pattern && typeof pattern.confidence === 'number'
416
+ && Number.isFinite(pattern.confidence) && pattern.confidence >= 0 && pattern.confidence <= 1) {
417
+ return pattern.confidence;
418
+ }
419
+ if (fitness && fitness.layer2 && typeof fitness.layer2.value === 'number' && Number.isFinite(fitness.layer2.value)) {
420
+ // Layer-2 value range is open (memoryRefs + 2*regrets / n); clamp to 0..1
421
+ // for a confidence reading. A "1.0" Layer-2 average means every active
422
+ // session produced at least one strong positive — solid confidence.
423
+ const v = fitness.layer2.value;
424
+ if (v < 0) return 0;
425
+ if (v > 1) return 1;
426
+ return v;
427
+ }
428
+ return 0;
429
+ }
430
+
431
+ // -----------------------------------------------------------------------------
432
+ // Public API — buildReviewBoard / shouldShowBoard
433
+ // -----------------------------------------------------------------------------
434
+
435
+ // @cap-todo(ac:F-073/AC-1) Pending = persisted ∧ ¬applied ∧ ¬unlearned ∧ ¬archived
436
+ // ∧ ¬skipped-this-session ∧ ¬rejected-this-session.
437
+ // @cap-todo(ac:F-073/AC-6) Each eligible pattern surfaces options Approve / Reject / Skip,
438
+ // plus Unlearn (with 'Rückzug empfohlen' label) when the id is in
439
+ // listRetractRecommended().
440
+ /**
441
+ * Build the in-memory review board. Pure-compute except for the archive sweep
442
+ * (the only mutation): we do NOT write board.md here — the orchestrator calls
443
+ * renderBoardMarkdown + writeBoardFile separately so dry-run tests stay clean.
444
+ *
445
+ * @param {string} projectRoot
446
+ * @param {Object} [options]
447
+ * @param {string} [options.sessionId] - Override the SESSION.json sessionId.
448
+ * @param {Date|string} [options.now] - Override timestamp (mostly for tests).
449
+ * @returns {ReviewBoard}
450
+ */
451
+ function buildReviewBoard(projectRoot, options) {
452
+ const opts = options || {};
453
+ const ts = opts.now ? new Date(opts.now).toISOString() : new Date().toISOString();
454
+ const sid = opts.sessionId !== undefined
455
+ ? sanitiseSessionId(opts.sessionId)
456
+ : currentSessionId(projectRoot);
457
+
458
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
459
+ return {
460
+ eligible: [],
461
+ threshold: { met: false, reason: 'invalid-project-root' },
462
+ archived: [],
463
+ skippedThisSession: [],
464
+ rejectedThisSession: [],
465
+ sessionId: sid,
466
+ ts,
467
+ };
468
+ }
469
+
470
+ const { ids, skipped, rejected } = eligiblePatternIds(projectRoot, sid);
471
+
472
+ // Build per-pattern entries with fitness + confidence + retract status.
473
+ let retractList = [];
474
+ try { retractList = patternApply.listRetractRecommended(projectRoot) || []; } catch (_e) { retractList = []; }
475
+ const retractSet = new Set(retractList);
476
+
477
+ // Read patterns once (listPatterns is the single source of truth).
478
+ let allPatterns = [];
479
+ try { allPatterns = patternPipeline.listPatterns(projectRoot) || []; } catch (_e) { allPatterns = []; }
480
+ /** @type {Map<string, object>} */
481
+ const byId = new Map();
482
+ for (const p of allPatterns) {
483
+ if (p && isValidPatternId(p.id)) byId.set(p.id, p);
484
+ }
485
+
486
+ /** @type {EligibleEntry[]} */
487
+ const eligible = [];
488
+ for (const id of ids) {
489
+ const pattern = byId.get(id);
490
+ if (!pattern) continue; // race: pattern deleted between listPatterns and now
491
+ let fitness = null;
492
+ try { fitness = fitnessScore.getFitness(projectRoot, id); } catch (_e) { fitness = null; }
493
+ const confidence = confidenceFromFitness(pattern, fitness);
494
+ const retractRecommended = retractSet.has(id);
495
+ const opts2 = ['Approve', 'Reject', 'Skip'];
496
+ if (retractRecommended) opts2.push('Unlearn');
497
+ eligible.push({
498
+ patternId: id,
499
+ fitness,
500
+ confidence,
501
+ triggerReason: triggerReasonFor(pattern),
502
+ retractRecommended,
503
+ options: opts2,
504
+ pattern, // for the renderer; the JSON shape exposed externally still includes it
505
+ });
506
+ }
507
+
508
+ const threshold = computeThreshold(eligible);
509
+
510
+ return {
511
+ eligible,
512
+ threshold,
513
+ archived: [], // populated by archiveStalePatterns separately
514
+ skippedThisSession: skipped,
515
+ rejectedThisSession: rejected,
516
+ sessionId: sid,
517
+ ts,
518
+ };
519
+ }
520
+
521
+ /**
522
+ * Compute the AC-2 threshold from an eligible-entries list. Pure-compute helper.
523
+ * Public-ish via shouldShowBoard, which re-uses this function on a freshly-built
524
+ * board.
525
+ *
526
+ * @cap-todo(ac:F-073/AC-2) Board appears only when ≥1 high-confidence (layer2.ready
527
+ * AND value≥0.75 AND n≥5) OR ≥3 candidates of any kind.
528
+ *
529
+ * @param {EligibleEntry[]} eligible
530
+ * @returns {{met: boolean, reason: string}}
531
+ */
532
+ function computeThreshold(eligible) {
533
+ if (!Array.isArray(eligible) || eligible.length === 0) {
534
+ return { met: false, reason: 'no-eligible-patterns' };
535
+ }
536
+ let highConfidenceCount = 0;
537
+ for (const e of eligible) {
538
+ if (!e || !e.fitness || !e.fitness.layer2) continue;
539
+ const l2 = e.fitness.layer2;
540
+ if (l2.ready === true
541
+ && Number(l2.value) >= HIGH_CONFIDENCE_LAYER2_VALUE
542
+ && Number(l2.n) >= HIGH_CONFIDENCE_LAYER2_N) {
543
+ highConfidenceCount += 1;
544
+ }
545
+ }
546
+ if (highConfidenceCount >= 1) {
547
+ return {
548
+ met: true,
549
+ reason: `high-confidence-pattern (${highConfidenceCount} eligible with layer2.value>=${HIGH_CONFIDENCE_LAYER2_VALUE} n>=${HIGH_CONFIDENCE_LAYER2_N})`,
550
+ };
551
+ }
552
+ if (eligible.length >= ANY_KIND_THRESHOLD) {
553
+ return {
554
+ met: true,
555
+ reason: `any-kind-threshold (${eligible.length} eligible patterns >= ${ANY_KIND_THRESHOLD})`,
556
+ };
557
+ }
558
+ return {
559
+ met: false,
560
+ reason: `below-threshold (${eligible.length} eligible, ${highConfidenceCount} high-confidence)`,
561
+ };
562
+ }
563
+
564
+ /**
565
+ * The AC-2 gate, also used by the Stop-hook. Compute-only, no side-effects.
566
+ * Mirrors buildReviewBoard's eligibility pipeline but skips the per-entry
567
+ * fitness lookup unless we need it (we DO need it for the high-confidence arm).
568
+ *
569
+ * @cap-todo(ac:F-073/AC-2) shouldShowBoard returns the boolean gate.
570
+ *
571
+ * @param {string} projectRoot
572
+ * @param {Object} [options]
573
+ * @param {string} [options.sessionId]
574
+ * @returns {boolean}
575
+ */
576
+ function shouldShowBoard(projectRoot, options) {
577
+ const board = buildReviewBoard(projectRoot, options);
578
+ return board.threshold.met === true;
579
+ }
580
+
581
+ // -----------------------------------------------------------------------------
582
+ // Public API — renderBoardMarkdown / writeBoardFile
583
+ // -----------------------------------------------------------------------------
584
+
585
+ /**
586
+ * Render the board.md content from a ReviewBoard object. PURE-compute string
587
+ * builder. Renderer escapes markdown control characters in dynamic fields so
588
+ * a hostile pattern record can't smuggle markdown injection.
589
+ *
590
+ * @cap-risk(F-073/AC-3) Renderer escapes the dynamic fields (triggerReason,
591
+ * featureRef, sessionId) by collapsing newlines and
592
+ * backticks into literal placeholders. F-071 already
593
+ * constrains pattern fields, but this is defence in
594
+ * depth — a future contributor adding a free-text
595
+ * field shouldn't have to remember to escape on render.
596
+ *
597
+ * @param {ReviewBoard} board
598
+ * @returns {string}
599
+ */
600
+ function renderBoardMarkdown(board) {
601
+ if (!board || typeof board !== 'object') return '';
602
+ const lines = [];
603
+ const ts = typeof board.ts === 'string' ? board.ts : new Date().toISOString();
604
+ lines.push(`# Pattern Review Board — ${escapeMd(ts)}`);
605
+ lines.push('');
606
+ if (board.sessionId) {
607
+ lines.push(`Session: \`${escapeMd(board.sessionId)}\``);
608
+ }
609
+ lines.push(`Threshold: ${board.threshold.met ? 'MET' : 'BELOW'} (${escapeMd(board.threshold.reason || '')})`);
610
+ lines.push('');
611
+
612
+ if (!Array.isArray(board.eligible) || board.eligible.length === 0) {
613
+ lines.push('_(no eligible patterns)_');
614
+ lines.push('');
615
+ return lines.join('\n');
616
+ }
617
+
618
+ lines.push(`Eligible patterns: ${board.eligible.length}`);
619
+ lines.push('');
620
+ lines.push('---');
621
+ lines.push('');
622
+
623
+ for (const e of board.eligible) {
624
+ const p = e.pattern || {};
625
+ const ev = p.evidence || {};
626
+ const fitness = e.fitness;
627
+
628
+ lines.push(`## ${escapeMd(e.patternId)} — ${escapeMd(e.triggerReason || 'unknown')}`);
629
+ lines.push('');
630
+ lines.push(`- **Level**: ${escapeMd(p.level || 'unknown')}`);
631
+ lines.push(`- **Feature**: ${escapeMd(p.featureRef || '(unassigned)')}`);
632
+ if (fitness && fitness.layer1 && fitness.layer2) {
633
+ const l1v = Number(fitness.layer1.value);
634
+ const l2v = Number(fitness.layer2.value);
635
+ const l2n = Number(fitness.layer2.n);
636
+ const ready = fitness.layer2.ready === true;
637
+ lines.push(`- **Fitness**: layer1=${Number.isFinite(l1v) ? l1v : 0}, layer2=${formatFloat(l2v)} (n=${Number.isFinite(l2n) ? l2n : 0}, ready=${ready})`);
638
+ } else {
639
+ lines.push('- **Fitness**: _(no fitness record)_');
640
+ }
641
+ lines.push(`- **Confidence**: ${formatFloat(e.confidence)}`);
642
+ const source = (p.source === 'llm' || p.source === 'heuristic') ? p.source : 'unknown';
643
+ const degraded = p.degraded === true ? 'yes' : 'no';
644
+ lines.push(`- **Source**: ${escapeMd(source)} | Degraded: ${degraded}`);
645
+ if (e.retractRecommended) {
646
+ lines.push('- **⚠️ Rückzug empfohlen** (current vs snapshot delta worsened, see retract-recommendations.jsonl)');
647
+ }
648
+ if (ev && typeof ev.candidateId === 'string' && /^[0-9a-f]+$/.test(ev.candidateId)) {
649
+ lines.push(`- **Evidence candidateId**: \`${escapeMd(ev.candidateId)}\``);
650
+ }
651
+ lines.push('');
652
+ lines.push(`**Options**: ${e.options.join(' / ')}`);
653
+ lines.push('');
654
+ lines.push('---');
655
+ lines.push('');
656
+ }
657
+
658
+ // Hand-off contract for the outer agent (D2). Documents the AC-7 exit-code
659
+ // semantics so the agent reports back faithfully.
660
+ lines.push('## Hand-off');
661
+ lines.push('');
662
+ lines.push('For each pattern above, choose ONE of approve / reject / skip / unlearn:');
663
+ lines.push('- **approve** → call `cap-pattern-apply.applyPattern(projectRoot, patternId)`. Record the commit hash.');
664
+ lines.push('- **unlearn** → call `cap-pattern-apply.unlearnPattern(projectRoot, patternId, { reason: \'manual\' })`.');
665
+ lines.push('- **skip** → call `cap-learn-review.skipPattern(projectRoot, patternId)`. Per-session only.');
666
+ lines.push('- **reject** → call `cap-learn-review.rejectPattern(projectRoot, patternId)`. Per-session only.');
667
+ lines.push('');
668
+ lines.push('**Exit code contract (F-073/AC-7)**: the skill exits 0 ONLY when EVERY approve produced `applied:true`.');
669
+ lines.push('Any apply returning `applied:false` → non-zero exit + a description of the failure. Do not swallow.');
670
+ lines.push('');
671
+ lines.push('Privacy: this board contains structured metadata only — counts, hashes, ids. No raw paths or user text.');
672
+ lines.push('');
673
+
674
+ return lines.join('\n');
675
+ }
676
+
677
+ /**
678
+ * Format a float to 2 decimal places. Defensive: NaN/non-finite collapses to '0.00'.
679
+ * @param {number} v
680
+ * @returns {string}
681
+ */
682
+ function formatFloat(v) {
683
+ const n = Number(v);
684
+ if (!Number.isFinite(n)) return '0.00';
685
+ return n.toFixed(2);
686
+ }
687
+
688
+ /**
689
+ * Escape markdown control characters in a single-line dynamic field. We:
690
+ * - Collapse all whitespace runs (incl. newlines) to a single space.
691
+ * - Replace backticks with single quotes (prevents code-fence escapes).
692
+ * - Drop the markdown structural triplet '---' if it appears bare (a section
693
+ * break inside an inline header would scramble the renderer's output).
694
+ * @param {any} v
695
+ * @returns {string}
696
+ */
697
+ function escapeMd(v) {
698
+ if (v === null || v === undefined) return '';
699
+ let s = String(v);
700
+ s = s.replace(/`/g, "'");
701
+ s = s.replace(/[\r\n\t\f\v]+/g, ' ');
702
+ s = s.replace(/\s{2,}/g, ' ');
703
+ // Defensive: collapse a literal '---' run (markdown thematic break) so it
704
+ // can't terminate a list item early. Three or more consecutive '-' dashes
705
+ // in the middle of an inline field get a thin space between them.
706
+ s = s.replace(/-{3,}/g, '—');
707
+ return s;
708
+ }
709
+
710
+ /**
711
+ * Atomic write of board.md.
712
+ * @param {string} projectRoot
713
+ * @param {string} boardMd
714
+ * @returns {boolean}
715
+ */
716
+ function writeBoardFile(projectRoot, boardMd) {
717
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) return false;
718
+ if (typeof boardMd !== 'string') return false;
719
+ ensureDir(learningRoot(projectRoot));
720
+ return writeAtomic(boardFilePath(projectRoot), boardMd);
721
+ }
722
+
723
+ // -----------------------------------------------------------------------------
724
+ // Public API — skipPattern / rejectPattern (AC-4)
725
+ // -----------------------------------------------------------------------------
726
+
727
+ // @cap-todo(ac:F-073/AC-4) Skip persists to .cap/learning/skipped-<sessionId>.json.
728
+ // Per-session ONLY. New session shows the patterns again.
729
+ /**
730
+ * Append a patternId to the session's skipped file. Idempotent: re-adding an
731
+ * already-present id does NOT duplicate the entry.
732
+ *
733
+ * @param {string} projectRoot
734
+ * @param {string} patternId
735
+ * @param {string} [sessionId] - Override the SESSION.json sessionId.
736
+ * @returns {boolean}
737
+ */
738
+ function skipPattern(projectRoot, patternId, sessionId) {
739
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) return false;
740
+ if (!isValidPatternId(patternId)) return false;
741
+ const sid = sessionId !== undefined ? sanitiseSessionId(sessionId) : currentSessionId(projectRoot);
742
+ if (!sid) return false;
743
+
744
+ const fp = skippedFilePath(projectRoot, sid);
745
+ // Read existing first so the file shape is consistent (ids de-duped, sorted).
746
+ const prior = readJson(fp);
747
+ /** @type {Set<string>} */
748
+ const ids = new Set();
749
+ if (prior && Array.isArray(prior.patternIds)) {
750
+ for (const id of prior.patternIds) {
751
+ if (isValidPatternId(id)) ids.add(id);
752
+ }
753
+ }
754
+ // @cap-decision(F-073/D7) True idempotency — when the patternId is already recorded,
755
+ // skip the write entirely so the on-disk ts does not change. Otherwise
756
+ // a second skipPattern bumps `ts` by 1 ms and the file is no longer
757
+ // byte-stable, polluting `git diff` and breaking the "no side-effect"
758
+ // contract reviewers expect from idempotent helpers.
759
+ if (ids.has(patternId)) return true;
760
+
761
+ ids.add(patternId);
762
+ const sorted = [...ids].sort();
763
+ return writeAtomicJson(fp, {
764
+ sessionId: sid,
765
+ ts: new Date().toISOString(),
766
+ patternIds: sorted,
767
+ });
768
+ }
769
+
770
+ /**
771
+ * Append a patternId to the session's rejected file. Idempotent.
772
+ * @param {string} projectRoot
773
+ * @param {string} patternId
774
+ * @param {string} [sessionId]
775
+ * @returns {boolean}
776
+ */
777
+ function rejectPattern(projectRoot, patternId, sessionId) {
778
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) return false;
779
+ if (!isValidPatternId(patternId)) return false;
780
+ const sid = sessionId !== undefined ? sanitiseSessionId(sessionId) : currentSessionId(projectRoot);
781
+ if (!sid) return false;
782
+
783
+ const fp = rejectedFilePath(projectRoot, sid);
784
+ const prior = readJson(fp);
785
+ /** @type {Set<string>} */
786
+ const ids = new Set();
787
+ if (prior && Array.isArray(prior.patternIds)) {
788
+ for (const id of prior.patternIds) {
789
+ if (isValidPatternId(id)) ids.add(id);
790
+ }
791
+ }
792
+ // @cap-decision(F-073/D7) Idempotency mirror of skipPattern — no write when the id is already recorded.
793
+ if (ids.has(patternId)) return true;
794
+
795
+ ids.add(patternId);
796
+ const sorted = [...ids].sort();
797
+ return writeAtomicJson(fp, {
798
+ sessionId: sid,
799
+ ts: new Date().toISOString(),
800
+ patternIds: sorted,
801
+ });
802
+ }
803
+
804
+ // -----------------------------------------------------------------------------
805
+ // Public API — archiveStalePatterns (AC-5)
806
+ // -----------------------------------------------------------------------------
807
+
808
+ // @cap-todo(ac:F-073/AC-5) Patterns un-reviewed > 7 sessions auto-move to
809
+ // .cap/learning/archive/<P-NNN>.json AND are removed
810
+ // from .cap/learning/patterns/. Insufficient-history
811
+ // short-circuit when the corpus has fewer than 7
812
+ // distinct sessions total.
813
+ /**
814
+ * Compute the count of distinct sessionIds in the F-070 corpus across the three
815
+ * signal types. F-072 has unionSessionsByRecency but doesn't export it; we
816
+ * replicate the simple distinct-count here. We intentionally do NOT include
817
+ * SESSION.json's sessionId (that's a single in-progress session, not a corpus
818
+ * record).
819
+ *
820
+ * @cap-decision(F-073/D5) Replicating the union-of-distinct-sessionIds count
821
+ * inline because F-072 doesn't export the helper. The cost is
822
+ * ~5 lines of code; the benefit is no API surface change to
823
+ * F-072 just to wire F-073.
824
+ *
825
+ * @param {string} projectRoot
826
+ * @returns {{ corpusSessionCount: number, sessionsByPattern: Map<string, Set<string>> }}
827
+ */
828
+ function corpusSessionStats(projectRoot) {
829
+ let overrides = [];
830
+ let memoryRefs = [];
831
+ let regrets = [];
832
+ try { overrides = learningSignals.getSignals(projectRoot, 'override') || []; } catch (_e) { overrides = []; }
833
+ try { memoryRefs = learningSignals.getSignals(projectRoot, 'memory-ref') || []; } catch (_e) { memoryRefs = []; }
834
+ try { regrets = learningSignals.getSignals(projectRoot, 'regret') || []; } catch (_e) { regrets = []; }
835
+
836
+ /** @type {Set<string>} */
837
+ const all = new Set();
838
+ for (const arr of [overrides, memoryRefs, regrets]) {
839
+ for (const r of arr) {
840
+ if (r && typeof r.sessionId === 'string' && r.sessionId.length > 0) {
841
+ all.add(r.sessionId);
842
+ }
843
+ }
844
+ }
845
+
846
+ // Per-record { sessionId, ts } collection — used by callers to count
847
+ // per-pattern session reach since pattern.createdAt.
848
+ /** @type {Map<string, Set<string>>} */
849
+ const sessionsByPattern = new Map(); // populated lazily by archiveStalePatterns
850
+ return { corpusSessionCount: all.size, sessionsByPattern, allRecords: { overrides, memoryRefs, regrets } };
851
+ }
852
+
853
+ /**
854
+ * Archive any pattern whose distinct-session count since createdAt exceeds
855
+ * STALE_SESSION_THRESHOLD. Idempotent: an already-archived pattern is skipped.
856
+ * Insufficient-history short-circuit: when corpus has fewer than the threshold
857
+ * sessions total, NO archive (we don't have enough data).
858
+ *
859
+ * Excludes:
860
+ * - applied / unlearned patterns (already left review).
861
+ * - patterns skipped or rejected this session (the user is engaged with them).
862
+ *
863
+ * @param {string} projectRoot
864
+ * @param {Object} [options]
865
+ * @param {string} [options.sessionId] - Override SESSION.json sessionId.
866
+ * @param {Date|string} [options.now] - Override timestamp on the archived record.
867
+ * @param {number} [options.window] - Override STALE_SESSION_THRESHOLD (mostly for tests).
868
+ * @returns {{ archived: string[], errors: string[] }}
869
+ */
870
+ function archiveStalePatterns(projectRoot, options) {
871
+ const opts = options || {};
872
+ const archived = [];
873
+ const errors = [];
874
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
875
+ return { archived, errors: ['invalid-project-root'] };
876
+ }
877
+ const window = (typeof opts.window === 'number' && opts.window > 0) ? opts.window : STALE_SESSION_THRESHOLD;
878
+ const sid = opts.sessionId !== undefined ? sanitiseSessionId(opts.sessionId) : currentSessionId(projectRoot);
879
+ const nowIso = opts.now ? new Date(opts.now).toISOString() : new Date().toISOString();
880
+
881
+ const stats = corpusSessionStats(projectRoot);
882
+ if (stats.corpusSessionCount < window) {
883
+ // @cap-decision(F-073/D5) Insufficient-history short-circuit. Mirrors F-072's
884
+ // expiry-window guard.
885
+ return { archived, errors };
886
+ }
887
+
888
+ // Read patterns + state via module APIs.
889
+ let patterns = [];
890
+ try { patterns = patternPipeline.listPatterns(projectRoot) || []; } catch (e) {
891
+ errors.push(`listPatterns failed: ${e && e.message ? e.message : 'unknown'}`);
892
+ return { archived, errors };
893
+ }
894
+ const applied = new Set((patternApply.listAppliedPatterns(projectRoot) || [])
895
+ .map((a) => a && a.patternId).filter(isValidPatternId));
896
+ const unlearned = new Set((patternApply.listUnlearnedPatterns(projectRoot) || [])
897
+ .map((u) => u && u.patternId).filter(isValidPatternId));
898
+ const skipped = sid ? new Set(loadSkippedThisSession(projectRoot, sid)) : new Set();
899
+ const rejected = sid ? new Set(loadRejectedThisSession(projectRoot, sid)) : new Set();
900
+
901
+ for (const pattern of patterns) {
902
+ if (!pattern || !isValidPatternId(pattern.id)) continue;
903
+ if (applied.has(pattern.id)) continue;
904
+ if (unlearned.has(pattern.id)) continue;
905
+ if (skipped.has(pattern.id)) continue;
906
+ if (rejected.has(pattern.id)) continue;
907
+
908
+ const since = typeof pattern.createdAt === 'string' ? pattern.createdAt : null;
909
+ if (!since) continue; // can't compute session-reach without a createdAt
910
+
911
+ // Already archived? (idempotency)
912
+ const archivePath = archiveFilePath(projectRoot, pattern.id);
913
+ if (fs.existsSync(archivePath)) {
914
+ // The source pattern file might still exist if a prior archive only wrote
915
+ // the archive copy and crashed before delete; clean that up here.
916
+ const sourcePath = patternFilePath(projectRoot, pattern.id);
917
+ if (fs.existsSync(sourcePath)) {
918
+ try { fs.unlinkSync(sourcePath); } catch (_e) { /* ignore */ }
919
+ }
920
+ continue;
921
+ }
922
+
923
+ // Distinct sessions since createdAt across union of three corpora.
924
+ const sessionsSet = new Set();
925
+ for (const arrName of ['overrides', 'memoryRefs', 'regrets']) {
926
+ for (const r of stats.allRecords[arrName] || []) {
927
+ if (!r || typeof r.sessionId !== 'string' || r.sessionId.length === 0) continue;
928
+ if (typeof r.ts !== 'string') continue;
929
+ if (r.ts < since) continue;
930
+ sessionsSet.add(r.sessionId);
931
+ }
932
+ }
933
+ if (sessionsSet.size <= window) continue; // not stale yet — needs MORE than threshold
934
+
935
+ // Move: write archive record (with archivedAt + reason) atomically, then
936
+ // delete the source pattern file.
937
+ const record = {
938
+ ...pattern,
939
+ archivedAt: nowIso,
940
+ reason: 'stale-7-sessions',
941
+ };
942
+ if (!writeAtomicJson(archivePath, record)) {
943
+ errors.push(`archive write failed for ${pattern.id}`);
944
+ continue;
945
+ }
946
+ const sourcePath = patternFilePath(projectRoot, pattern.id);
947
+ try {
948
+ if (fs.existsSync(sourcePath)) fs.unlinkSync(sourcePath);
949
+ } catch (e) {
950
+ errors.push(`archive source delete failed for ${pattern.id}: ${e && e.message ? e.message : 'unknown'}`);
951
+ // Don't include in archived list — partially-applied move.
952
+ continue;
953
+ }
954
+ archived.push(pattern.id);
955
+ }
956
+
957
+ archived.sort();
958
+ return { archived, errors };
959
+ }
960
+
961
+ // -----------------------------------------------------------------------------
962
+ // Public API — board-pending.flag round-trip (AC-3)
963
+ // -----------------------------------------------------------------------------
964
+
965
+ // @cap-todo(ac:F-073/AC-3) Stop-hook computes shouldShowBoard() and writes the
966
+ // .flag file when true. /cap:status / /cap:learn review
967
+ // surface the flag. Skill clears the flag after the
968
+ // board has been processed.
969
+ /**
970
+ * Write the board-pending flag. The flag content is a tiny JSON snippet
971
+ * (timestamp + sessionId + eligibleCount) for diagnostic purposes; the SKILL
972
+ * checks for FILE EXISTENCE, not content, so a half-written flag is harmless.
973
+ *
974
+ * @cap-risk(F-073/AC-3) sessionId is sanitised before persistence so a hostile
975
+ * SESSION.json can't smuggle bytes via the flag content.
976
+ * Adversarial test pins this with a SECRET_NEEDLE
977
+ * sessionId.
978
+ *
979
+ * @param {string} projectRoot
980
+ * @param {Object} [options]
981
+ * @param {string} [options.sessionId]
982
+ * @param {number} [options.eligibleCount]
983
+ * @param {Date|string} [options.now]
984
+ * @returns {boolean}
985
+ */
986
+ function writeBoardPendingFlag(projectRoot, options) {
987
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) return false;
988
+ const opts = options || {};
989
+ const sid = opts.sessionId !== undefined ? sanitiseSessionId(opts.sessionId) : currentSessionId(projectRoot);
990
+ const ts = opts.now ? new Date(opts.now).toISOString() : new Date().toISOString();
991
+ const eligibleCount = Number.isFinite(Number(opts.eligibleCount)) ? Math.max(0, Math.floor(Number(opts.eligibleCount))) : 0;
992
+ ensureDir(learningRoot(projectRoot));
993
+ const payload = { ts, sessionId: sid || null, eligibleCount };
994
+ return writeAtomicJson(boardPendingFlagPath(projectRoot), payload);
995
+ }
996
+
997
+ /**
998
+ * Remove the board-pending flag. Idempotent: missing file is success.
999
+ * @param {string} projectRoot
1000
+ * @returns {boolean}
1001
+ */
1002
+ function clearBoardPendingFlag(projectRoot) {
1003
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) return false;
1004
+ const fp = boardPendingFlagPath(projectRoot);
1005
+ try {
1006
+ if (fs.existsSync(fp)) fs.unlinkSync(fp);
1007
+ return true;
1008
+ } catch (_e) {
1009
+ return false;
1010
+ }
1011
+ }
1012
+
1013
+ /**
1014
+ * Return true iff the board-pending flag exists. Used by /cap:status and the
1015
+ * skill startup banner.
1016
+ * @param {string} projectRoot
1017
+ * @returns {boolean}
1018
+ */
1019
+ function hasBoardPendingFlag(projectRoot) {
1020
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) return false;
1021
+ try {
1022
+ return fs.existsSync(boardPendingFlagPath(projectRoot));
1023
+ } catch (_e) {
1024
+ return false;
1025
+ }
1026
+ }
1027
+
1028
+ // -----------------------------------------------------------------------------
1029
+ // Exports — keep this list minimal. /cap:learn review + the Stop hook should
1030
+ // consume only these.
1031
+ // -----------------------------------------------------------------------------
1032
+
1033
+ module.exports = {
1034
+ // Constants — exported for tests + downstream consumers.
1035
+ CAP_DIR,
1036
+ LEARNING_DIR,
1037
+ PATTERNS_DIR,
1038
+ ARCHIVE_DIR,
1039
+ BOARD_FILE,
1040
+ BOARD_PENDING_FLAG,
1041
+ HIGH_CONFIDENCE_LAYER2_VALUE,
1042
+ HIGH_CONFIDENCE_LAYER2_N,
1043
+ ANY_KIND_THRESHOLD,
1044
+ STALE_SESSION_THRESHOLD,
1045
+ // Public API.
1046
+ buildReviewBoard,
1047
+ renderBoardMarkdown,
1048
+ writeBoardFile,
1049
+ skipPattern,
1050
+ rejectPattern,
1051
+ archiveStalePatterns,
1052
+ shouldShowBoard,
1053
+ writeBoardPendingFlag,
1054
+ clearBoardPendingFlag,
1055
+ hasBoardPendingFlag,
1056
+ // Path helpers — exported for tests.
1057
+ archiveDir,
1058
+ archiveFilePath,
1059
+ boardFilePath,
1060
+ boardPendingFlagPath,
1061
+ skippedFilePath,
1062
+ rejectedFilePath,
1063
+ // Helpers exposed for tests / introspection.
1064
+ loadSkippedThisSession,
1065
+ loadRejectedThisSession,
1066
+ eligiblePatternIds,
1067
+ triggerReasonFor,
1068
+ confidenceFromFitness,
1069
+ computeThreshold,
1070
+ currentSessionId,
1071
+ sanitiseSessionId,
1072
+ };