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,627 @@
1
+ // @cap-context CAP F-070 Collect Learning Signals — observability foundation for the V5 Self-Learning pivot.
2
+ // Three collectors emit different signal types into .cap/learning/signals/<type>.jsonl, plus a
3
+ // getSignals(type, range) query API that F-071 (pattern extraction) and F-072 (fitness score)
4
+ // will consume. Mirrors F-061's privacy boundary: no raw text, hash-only context.
5
+ // @cap-decision(F-070/D1) JSONL append-only format (same as F-061 telemetry). One record per line, O(1)
6
+ // append, O(n) streaming read. Reading is reserved for the cold path (getSignals), never the
7
+ // hot path (recordX). Steals the writeJsonlLine pattern from cap-telemetry.cjs#writeJsonlLine.
8
+ // @cap-decision(F-070/D2) Hot-path collectors (recordOverride / recordMemoryRef) are SYNCHRONOUS and never
9
+ // read **signal** JSONLs. AC-5 caps hook overhead at <50ms; the only way to keep that bound
10
+ // under a growing signal volume is to never read overrides.jsonl / memory-refs.jsonl during a
11
+ // hook. Regret detection is the deliberate exception (AC-3) and runs from /cap:scan.
12
+ // The state ledger (written-files.jsonl) is a separate concern from signals: hooks DO read
13
+ // it, but the file stays per-session-small (<100 entries typical) so the read is bounded.
14
+ // @cap-decision(F-070/D11) `subType` (not `kind`) discriminates override flavours. F-061 uses `kind` for a
15
+ // single discriminator; F-070 needs TWO nested discriminators (`signalType` for the broad
16
+ // type — override / memory-ref / regret — and `subType` for override-internal flavours
17
+ // — editAfterWrite / rejectApproval). `kind=override` would be redundant to signalType.
18
+ // F-071/F-072 readers will see both discriminators and route accordingly.
19
+ // @cap-decision(F-070/D3) Record schema is fixed: { id, ts, sessionId, featureId, signalType, subType?,
20
+ // contextHash, ...typeSpecific }. AC-4 forbids raw text on disk — every free-text field must
21
+ // be hashed via cap-telemetry.cjs#hashContext (re-used, not duplicated). New keys added in
22
+ // the future must be structured metadata only.
23
+ // @cap-decision(F-070/D4) Trigger split: hooks fire recordOverride / recordMemoryRef from PostToolUse,
24
+ // recordRegret runs from the tag-scanner (cold path) via recordRegretsFromScan. A regret hook
25
+ // on every Stop would scan all source files and blow AC-5's 50ms budget on any non-trivial
26
+ // codebase. Retrospective tagging is a scan-time concern, not a per-tool-call concern.
27
+ // @cap-constraint Zero external dependencies: node:fs, node:path, node:crypto only — and we re-use
28
+ // cap-telemetry.cjs#hashContext for the SHA256 path so the privacy gate has a single source.
29
+ // @cap-risk(F-070/AC-4) PRIVACY BOUNDARY — this module must never accept, log, or persist raw user-typed
30
+ // prompts, edit diffs, or file contents. Free-text inputs (e.g. file paths, decision text)
31
+ // must pass through hashContext before they reach disk. Any future contributor adding a
32
+ // `diff`, `prompt`, `body`, or `text` field violates AC-4.
33
+ // @cap-risk(F-070/AC-5) HOT-PATH OVERHEAD — recordOverride and recordMemoryRef MUST NOT read JSONL files,
34
+ // spawn processes, or do any work that scales with prior signal volume. The performance
35
+ // budget is <50ms per hook invocation; tests bracket this with performance.now().
36
+
37
+ 'use strict';
38
+
39
+ // @cap-feature(feature:F-070, primary:true) Collect Learning Signals — three collectors + getSignals query API.
40
+
41
+ const fs = require('node:fs');
42
+ const path = require('node:path');
43
+ const crypto = require('node:crypto');
44
+
45
+ // Re-use the hashContext primitive from F-061. Single source of truth for the SHA256 privacy gate.
46
+ // @cap-risk(F-070/AC-4) Direct require avoids duplicating the sha256[:16] code path. If F-061's helper
47
+ // changes shape (e.g. digest length), this module follows automatically — there is
48
+ // only one privacy primitive, and it lives in cap-telemetry.cjs.
49
+ const telemetry = require('./cap-telemetry.cjs');
50
+
51
+ // -----------------------------------------------------------------------------
52
+ // Constants
53
+ // -----------------------------------------------------------------------------
54
+
55
+ const CAP_DIR = '.cap';
56
+ const LEARNING_DIR = 'learning';
57
+ const SIGNALS_DIR = 'signals';
58
+ const STATE_DIR = 'state';
59
+
60
+ // File names per signal type. Kept as constants so tests and consumers (F-071) reference one place.
61
+ const OVERRIDES_FILE = 'overrides.jsonl';
62
+ const MEMORY_REFS_FILE = 'memory-refs.jsonl';
63
+ const REGRETS_FILE = 'regrets.jsonl';
64
+
65
+ // Per-session ledger: which files the agent wrote in this session. Read by the editAfterWrite
66
+ // hook to determine whether an Edit follows a Write of the same file. Hooks fire as fresh
67
+ // subprocesses, so an in-memory Set cannot persist across events — the ledger is the bridge.
68
+ const WRITTEN_FILES_LEDGER = 'written-files.jsonl';
69
+
70
+ // Cap on the persisted file path so a hostile or accidental long path doesn't bloat the ledger.
71
+ // Real paths in this codebase are <500 chars; 1024 is a generous cap.
72
+ const PATH_MAX = 1024;
73
+
74
+ // Length cap for any string field that lands on disk. Matches cap-telemetry.cjs#ID_MAX so a hostile
75
+ // caller can't use sessionId / featureId as a smuggle channel even if the privacy gate above slips.
76
+ const ID_MAX = 200;
77
+
78
+ // Allowed signal types for the public getSignals API. Order matches FEATURE-MAP.md AC-6 phrasing.
79
+ // @cap-decision(F-070/D5) Public type names are 'override' | 'memory-ref' | 'regret' (singular, hyphenated)
80
+ // to match the AC-6 contract. Internally the file names use plural ('overrides.jsonl' etc.)
81
+ // to mirror cap-telemetry.cjs's file-naming convention; the mapping is centralised in
82
+ // typeToFile() so consumers never see the difference.
83
+ const VALID_TYPES = new Set(['override', 'memory-ref', 'regret']);
84
+
85
+ // Allowed override subTypes. AC-1 distinguishes Edit-after-Write from explicit Reject-Approval events.
86
+ const VALID_OVERRIDE_SUBTYPES = new Set(['editAfterWrite', 'rejectApproval']);
87
+
88
+ /**
89
+ * @typedef {Object} OverrideRecord
90
+ * @property {string} id - Unique record id (timestamp + random).
91
+ * @property {string} ts - ISO timestamp.
92
+ * @property {string|null} sessionId
93
+ * @property {string|null} featureId
94
+ * @property {'override'} signalType
95
+ * @property {'editAfterWrite'|'rejectApproval'} subType
96
+ * @property {string} contextHash - 16-char sha256 hex of the structured context (path or decision id).
97
+ * @property {string} [targetFileHash] - 16-char sha256 of the targeted file path (path-string-only, never the contents).
98
+ */
99
+
100
+ /**
101
+ * @typedef {Object} MemoryRefRecord
102
+ * @property {string} id
103
+ * @property {string} ts
104
+ * @property {string|null} sessionId
105
+ * @property {string|null} featureId
106
+ * @property {'memory-ref'} signalType
107
+ * @property {string} contextHash - 16-char sha256 of the memory-file path (path-string-only — AC-2 forbids reading the file).
108
+ * @property {string} [memoryFileHash] - 16-char sha256 of the memory-file path (alias of contextHash for query convenience).
109
+ */
110
+
111
+ /**
112
+ * @typedef {Object} RegretRecord
113
+ * @property {string} id
114
+ * @property {string} ts
115
+ * @property {string|null} sessionId
116
+ * @property {string|null} featureId
117
+ * @property {'regret'} signalType
118
+ * @property {string} decisionId - Stable identifier for the @cap-decision tag (file:line is the default; consumers may pass the decision-id from metadata).
119
+ * @property {string} contextHash - 16-char sha256 of decisionId. Used for dedup keys in F-071.
120
+ */
121
+
122
+ // -----------------------------------------------------------------------------
123
+ // Internal helpers (lazy-create dir, atomic-ish append, id generation)
124
+ // -----------------------------------------------------------------------------
125
+
126
+ // @cap-todo(ac:F-070/AC-7) Lazy-create on first append: ensure .cap/learning/signals/ exists before writing
127
+ // the first JSONL line. Idempotent; mkdir { recursive: true } is safe to call repeatedly.
128
+ function ensureDir(dir) {
129
+ try {
130
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
131
+ } catch (_e) {
132
+ // Swallow — AC-7 demands no exception escapes a collector. The append below will surface
133
+ // any persistent IO problem via its own try/catch and silently no-op.
134
+ }
135
+ }
136
+
137
+ // @cap-decision(F-070/D6) JSONL append uses O_APPEND like cap-telemetry.cjs. Atomic for short single-line
138
+ // writes on Linux/macOS (PIPE_BUF >= 4 KiB). The record + newline always fits in one write.
139
+ // Stealing the proven pattern from F-061 keeps both modules' on-disk format consistent so
140
+ // F-071 / F-072 can share a single JSONL reader if they want to.
141
+ /**
142
+ * Append one JSON record as a single line to the given file. Lazy-creates the parent directory.
143
+ * Never throws — AC-7 requires that a collector failure is silent.
144
+ * @param {string} filePath
145
+ * @param {object} record
146
+ */
147
+ function appendJsonlLine(filePath, record) {
148
+ try {
149
+ ensureDir(path.dirname(filePath));
150
+ const line = JSON.stringify(record) + '\n';
151
+ const fd = fs.openSync(filePath, 'a');
152
+ try {
153
+ fs.writeSync(fd, line);
154
+ } finally {
155
+ fs.closeSync(fd);
156
+ }
157
+ } catch (_e) {
158
+ // @cap-risk(F-070/AC-7) Swallow IO errors so a transient EACCES / ENOSPC doesn't crash a hook.
159
+ // The signal is lost, but the user's command continues. F-074 (Pattern Unlearn)
160
+ // will surface signal-loss diagnostics later — not our concern here.
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Generate a short unique record id. Same shape as cap-telemetry.cjs#generateCallId so consumers
166
+ * can use one regex if they ever cross-reference IDs.
167
+ */
168
+ function generateSignalId() {
169
+ const ts = Date.now().toString(36);
170
+ const rnd = crypto.randomBytes(4).toString('hex');
171
+ return `${ts}-${rnd}`;
172
+ }
173
+
174
+ /**
175
+ * Length-cap a string id (sessionId, featureId, decisionId) and reject non-strings.
176
+ * Mirrors cap-telemetry.cjs's capId helper.
177
+ * @param {any} v
178
+ * @returns {string|null}
179
+ */
180
+ function capId(v) {
181
+ if (typeof v !== 'string' || v.length === 0) return null;
182
+ return v.slice(0, ID_MAX);
183
+ }
184
+
185
+ /**
186
+ * Map a public signal type to its on-disk file name. Centralised so AC-1/AC-2/AC-3 file paths
187
+ * are defined in exactly one place.
188
+ * @param {string} type - 'override' | 'memory-ref' | 'regret'
189
+ * @returns {string|null}
190
+ */
191
+ function typeToFile(type) {
192
+ if (type === 'override') return OVERRIDES_FILE;
193
+ if (type === 'memory-ref') return MEMORY_REFS_FILE;
194
+ if (type === 'regret') return REGRETS_FILE;
195
+ return null;
196
+ }
197
+
198
+ /**
199
+ * Resolve the absolute path for a given signal type's JSONL file.
200
+ * @param {string} projectRoot
201
+ * @param {string} type
202
+ * @returns {string|null}
203
+ */
204
+ function signalsFilePath(projectRoot, type) {
205
+ const file = typeToFile(type);
206
+ if (!file) return null;
207
+ return path.join(projectRoot, CAP_DIR, LEARNING_DIR, SIGNALS_DIR, file);
208
+ }
209
+
210
+ /**
211
+ * Resolve the absolute path for the per-session written-files ledger.
212
+ * @param {string} projectRoot
213
+ * @returns {string}
214
+ */
215
+ function writtenFilesLedgerPath(projectRoot) {
216
+ return path.join(projectRoot, CAP_DIR, LEARNING_DIR, STATE_DIR, WRITTEN_FILES_LEDGER);
217
+ }
218
+
219
+ // -----------------------------------------------------------------------------
220
+ // State ledger — bridges Write→Edit events across subprocess hook invocations.
221
+ // -----------------------------------------------------------------------------
222
+
223
+ // @cap-todo(ac:F-070/AC-1) Persistent ledger replaces the broken in-memory Set in
224
+ // hooks/cap-learning-hook.js. Subprocess hooks cannot share a Set;
225
+ // they share the file system. Append on Write, check on Edit.
226
+ /**
227
+ * Record that the agent wrote `targetFile` in `sessionId`. Called from PostToolUse on
228
+ * Write / MultiEdit / NotebookEdit. Lazy-creates `.cap/learning/state/written-files.jsonl`.
229
+ * Never throws — AC-7 contract.
230
+ * @param {string} projectRoot
231
+ * @param {string} sessionId
232
+ * @param {string} targetFile
233
+ * @returns {{sessionId:string, targetFile:string, ts:string}|null}
234
+ */
235
+ function recordWriteIntoLedger(projectRoot, sessionId, targetFile) {
236
+ try {
237
+ if (!projectRoot || typeof projectRoot !== 'string') return null;
238
+ const sid = capId(sessionId);
239
+ if (!sid) return null;
240
+ if (typeof targetFile !== 'string' || targetFile.length === 0) return null;
241
+ const record = {
242
+ sessionId: sid,
243
+ targetFile: targetFile.slice(0, PATH_MAX),
244
+ ts: new Date().toISOString(),
245
+ };
246
+ appendJsonlLine(writtenFilesLedgerPath(projectRoot), record);
247
+ return record;
248
+ } catch (_e) {
249
+ return null;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Check whether `targetFile` was previously recorded as written by `sessionId`.
255
+ * Reads the ledger filtered by sessionId. Returns false on any error or missing file.
256
+ *
257
+ * Hot-path read, but bounded: ledger entries are scoped per session and the file is
258
+ * cold-started fresh each project. AC-5's <50ms budget is preserved because (a) we only
259
+ * read this single small file, never the signal JSONLs, and (b) typical sessions have
260
+ * <100 writes. Adversarial test bracket-confirms the bound.
261
+ *
262
+ * @param {string} projectRoot
263
+ * @param {string} sessionId
264
+ * @param {string} targetFile
265
+ * @returns {boolean}
266
+ */
267
+ function wasWrittenInSession(projectRoot, sessionId, targetFile) {
268
+ try {
269
+ if (!projectRoot || typeof projectRoot !== 'string') return false;
270
+ const sid = capId(sessionId);
271
+ if (!sid) return false;
272
+ if (typeof targetFile !== 'string' || targetFile.length === 0) return false;
273
+ const fp = writtenFilesLedgerPath(projectRoot);
274
+ if (!fs.existsSync(fp)) return false;
275
+ const raw = fs.readFileSync(fp, 'utf8');
276
+ const target = targetFile.slice(0, PATH_MAX);
277
+ for (const line of raw.split('\n')) {
278
+ if (!line) continue;
279
+ try {
280
+ const r = JSON.parse(line);
281
+ if (r && r.sessionId === sid && r.targetFile === target) return true;
282
+ } catch (_e) {
283
+ // Malformed line — skip, keep scanning. AC-7 forbids throwing.
284
+ }
285
+ }
286
+ return false;
287
+ } catch (_e) {
288
+ return false;
289
+ }
290
+ }
291
+
292
+ // -----------------------------------------------------------------------------
293
+ // Public API — collectors
294
+ // -----------------------------------------------------------------------------
295
+
296
+ // @cap-todo(ac:F-070/AC-1) Override collector: persists Edit-after-Write and Reject-Approval events to
297
+ // .cap/learning/signals/overrides.jsonl. subType discriminates the two sources.
298
+ // @cap-todo(ac:F-070/AC-4) Record schema enforced here: { id, ts, sessionId, featureId, signalType,
299
+ // subType, contextHash, targetFileHash? } — never raw paths or text.
300
+ /**
301
+ * Record an override event. Two flavours: 'editAfterWrite' (the agent wrote a file, the user edited it
302
+ * during the same session) and 'rejectApproval' (the user explicitly rejected an approval prompt).
303
+ *
304
+ * Never throws — AC-7 contract.
305
+ *
306
+ * @param {Object} input
307
+ * @param {string} input.projectRoot
308
+ * @param {'editAfterWrite'|'rejectApproval'} input.subType
309
+ * @param {string|null} [input.sessionId]
310
+ * @param {string|null} [input.featureId]
311
+ * @param {string} [input.contextHash] - Optional pre-computed hash. If omitted and `targetFile` is given,
312
+ * the hash is derived from the target file path (path-string-only — never reads the file).
313
+ * @param {string} [input.targetFile] - Optional structured context (e.g. the edited file path). Hashed
314
+ * before persistence — the raw string never reaches disk.
315
+ * @param {string} [input.ts] - Override timestamp (mostly for tests).
316
+ * @returns {OverrideRecord|null} The persisted record, or null when the input is invalid (no throw).
317
+ */
318
+ function recordOverride(input) {
319
+ try {
320
+ const safe = input || {};
321
+ if (!safe.projectRoot || typeof safe.projectRoot !== 'string') return null;
322
+ if (!VALID_OVERRIDE_SUBTYPES.has(safe.subType)) return null;
323
+
324
+ // @cap-risk(F-070/AC-4) Derive contextHash from the structured target file path, never from file
325
+ // contents. If the caller passes a pre-computed hash we accept it (consumer
326
+ // knows their dedup key), but we still cap its length defensively.
327
+ const fallbackContext = safe.targetFile
328
+ ? telemetry.hashContext(safe.targetFile)
329
+ : telemetry.hashContext(`${safe.subType}:${safe.sessionId || ''}`);
330
+ const contextHash = (typeof safe.contextHash === 'string' && safe.contextHash.length > 0)
331
+ ? safe.contextHash.slice(0, 64)
332
+ : fallbackContext;
333
+
334
+ /** @type {OverrideRecord} */
335
+ const record = {
336
+ id: generateSignalId(),
337
+ ts: safe.ts || new Date().toISOString(),
338
+ sessionId: capId(safe.sessionId),
339
+ featureId: capId(safe.featureId),
340
+ signalType: 'override',
341
+ subType: safe.subType,
342
+ contextHash,
343
+ };
344
+ if (safe.targetFile) {
345
+ // Hash-only — path string is privacy-sensitive (could include a username under /Users/<name>/...).
346
+ record.targetFileHash = telemetry.hashContext(safe.targetFile);
347
+ }
348
+
349
+ appendJsonlLine(signalsFilePath(safe.projectRoot, 'override'), record);
350
+ return record;
351
+ } catch (_e) {
352
+ return null;
353
+ }
354
+ }
355
+
356
+ // @cap-todo(ac:F-070/AC-2) Memory-Reference collector: increments a per-session count whenever any file
357
+ // under .cap/memory/*.md is read. Writes one record per read to memory-refs.jsonl.
358
+ // The "count" is reconstructed by query (getSignals) — we don't aggregate at write.
359
+ /**
360
+ * Record a memory-reference event. Called when the agent (via PostToolUse hook on Read) touches any file
361
+ * under `.cap/memory/`. The file path is hashed; the file contents are NEVER read here.
362
+ *
363
+ * Never throws — AC-7 contract.
364
+ *
365
+ * @param {Object} input
366
+ * @param {string} input.projectRoot
367
+ * @param {string|null} [input.sessionId]
368
+ * @param {string|null} [input.featureId]
369
+ * @param {string} input.memoryFile - Path of the touched memory file (relative or absolute). Hashed before
370
+ * persistence — the raw path never lands on disk.
371
+ * @param {string} [input.ts]
372
+ * @returns {MemoryRefRecord|null}
373
+ */
374
+ function recordMemoryRef(input) {
375
+ try {
376
+ const safe = input || {};
377
+ if (!safe.projectRoot || typeof safe.projectRoot !== 'string') return null;
378
+ if (typeof safe.memoryFile !== 'string' || safe.memoryFile.length === 0) return null;
379
+
380
+ // @cap-risk(F-070/AC-4) memoryFile is hashed, never persisted as a raw path. The privacy boundary is
381
+ // symmetric with recordOverride — same hash function, same 16-char digest.
382
+ const memoryFileHash = telemetry.hashContext(safe.memoryFile);
383
+
384
+ /** @type {MemoryRefRecord} */
385
+ const record = {
386
+ id: generateSignalId(),
387
+ ts: safe.ts || new Date().toISOString(),
388
+ sessionId: capId(safe.sessionId),
389
+ featureId: capId(safe.featureId),
390
+ signalType: 'memory-ref',
391
+ contextHash: memoryFileHash,
392
+ memoryFileHash,
393
+ };
394
+
395
+ appendJsonlLine(signalsFilePath(safe.projectRoot, 'memory-ref'), record);
396
+ return record;
397
+ } catch (_e) {
398
+ return null;
399
+ }
400
+ }
401
+
402
+ // @cap-todo(ac:F-070/AC-3) Decision-Regret collector: emits one record per @cap-decision tag carrying
403
+ // regret:true. Triggered from /cap:scan (the cold path) — see recordRegretsFromScan
404
+ // below for the integration point.
405
+ /**
406
+ * Record a single regret. Lower-level than recordRegretsFromScan — useful when a caller already has the
407
+ * decision id in hand (e.g. the F-073 review board's manual "mark regret" action).
408
+ *
409
+ * Never throws — AC-7 contract.
410
+ *
411
+ * @param {Object} input
412
+ * @param {string} input.projectRoot
413
+ * @param {string|null} [input.sessionId]
414
+ * @param {string|null} [input.featureId]
415
+ * @param {string} input.decisionId - Stable identifier for the @cap-decision (file:line by default).
416
+ * @param {string} [input.contextHash]
417
+ * @param {string} [input.ts]
418
+ * @returns {RegretRecord|null}
419
+ */
420
+ function recordRegret(input) {
421
+ try {
422
+ const safe = input || {};
423
+ if (!safe.projectRoot || typeof safe.projectRoot !== 'string') return null;
424
+ if (typeof safe.decisionId !== 'string' || safe.decisionId.length === 0) return null;
425
+
426
+ const decisionId = safe.decisionId.slice(0, ID_MAX);
427
+ const contextHash = (typeof safe.contextHash === 'string' && safe.contextHash.length > 0)
428
+ ? safe.contextHash.slice(0, 64)
429
+ : telemetry.hashContext(decisionId);
430
+
431
+ /** @type {RegretRecord} */
432
+ const record = {
433
+ id: generateSignalId(),
434
+ ts: safe.ts || new Date().toISOString(),
435
+ sessionId: capId(safe.sessionId),
436
+ featureId: capId(safe.featureId),
437
+ signalType: 'regret',
438
+ decisionId,
439
+ contextHash,
440
+ };
441
+
442
+ appendJsonlLine(signalsFilePath(safe.projectRoot, 'regret'), record);
443
+ return record;
444
+ } catch (_e) {
445
+ return null;
446
+ }
447
+ }
448
+
449
+ // -----------------------------------------------------------------------------
450
+ // Public API — query
451
+ // -----------------------------------------------------------------------------
452
+
453
+ /**
454
+ * Read all records from a signal-type JSONL. Tolerant to missing file and malformed lines.
455
+ * Internal helper for getSignals.
456
+ * @param {string} projectRoot
457
+ * @param {string} type
458
+ * @returns {Array<object>}
459
+ */
460
+ function readAllSignals(projectRoot, type) {
461
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) return [];
462
+ const filePath = signalsFilePath(projectRoot, type);
463
+ if (!filePath || !fs.existsSync(filePath)) return [];
464
+ let raw;
465
+ try {
466
+ raw = fs.readFileSync(filePath, 'utf8');
467
+ } catch (_e) {
468
+ return [];
469
+ }
470
+ const records = [];
471
+ for (const line of raw.split('\n')) {
472
+ if (!line) continue;
473
+ try {
474
+ records.push(JSON.parse(line));
475
+ } catch (_e) {
476
+ // Skip malformed lines — query must never crash a command (mirrors F-061 behaviour).
477
+ }
478
+ }
479
+ return records;
480
+ }
481
+
482
+ // @cap-todo(ac:F-070/AC-6) Query API consumed by F-071 (pattern extraction) and F-072 (fitness score).
483
+ // Contract intentionally minimal: type + range. No byFeature, no recentSignals —
484
+ // add them later only when F-071/F-072 actually need them.
485
+ /**
486
+ * Query persisted signals by type and range.
487
+ *
488
+ * @param {string} projectRoot - Absolute path to project root.
489
+ * @param {'override'|'memory-ref'|'regret'} type - Signal type.
490
+ * @param {{from?: string|Date, to?: string|Date, sessionId?: string}} [range] - Time range OR sessionId.
491
+ * Pass `{from, to}` for a time slice (ISO strings or Date objects, inclusive).
492
+ * Pass `{sessionId}` to filter by session. Both keys may be combined.
493
+ * When `range` is omitted, ALL records of the given type are returned.
494
+ * @returns {Array<object>} Matching records, or [] if the type is invalid or no file exists.
495
+ */
496
+ function getSignals(projectRoot, type, range) {
497
+ if (!VALID_TYPES.has(type)) return [];
498
+ const all = readAllSignals(projectRoot, type);
499
+ if (!range) return all;
500
+
501
+ const fromTs = range.from ? new Date(range.from).getTime() : null;
502
+ const toTs = range.to ? new Date(range.to).getTime() : null;
503
+ const sessionId = typeof range.sessionId === 'string' && range.sessionId.length > 0
504
+ ? range.sessionId
505
+ : null;
506
+
507
+ return all.filter((r) => {
508
+ if (sessionId && r.sessionId !== sessionId) return false;
509
+ if (fromTs !== null || toTs !== null) {
510
+ const recordTs = new Date(r.ts).getTime();
511
+ if (Number.isNaN(recordTs)) return false;
512
+ if (fromTs !== null && recordTs < fromTs) return false;
513
+ if (toTs !== null && recordTs > toTs) return false;
514
+ }
515
+ return true;
516
+ });
517
+ }
518
+
519
+ // -----------------------------------------------------------------------------
520
+ // Tag-scanner integration — regret detection (cold path)
521
+ // -----------------------------------------------------------------------------
522
+
523
+ // @cap-todo(ac:F-070/AC-3) Walk @cap-decision tags carrying regret:true and emit a RegretRecord per tag.
524
+ // Called from /cap:scan after enrichFromTags. Reads existing regrets to dedup —
525
+ // this is fine because the scan path is cold (AC-5 governs hooks, not scan).
526
+ // @cap-decision(F-070/D7) Dedup key is decisionId. The tag scanner's CapTag carries (file, line, metadata)
527
+ // so we synthesise a stable id when the tag has no explicit `id:` metadata: `<file>:<line>`.
528
+ // If multiple regret tags share a decisionId across runs (e.g. the same line), only the first
529
+ // is recorded — F-074's audit trail will track lifecycle from there.
530
+ /**
531
+ * Scan a tag list for `@cap-decision` tags carrying `regret:true` and emit a RegretRecord for each one
532
+ * not already persisted. Idempotent across repeated /cap:scan invocations.
533
+ *
534
+ * Never throws — wraps individual tag failures so a single malformed tag doesn't break the batch.
535
+ *
536
+ * @param {string} projectRoot
537
+ * @param {Array<{type: string, file: string, line: number, metadata: object, description: string}>} tags
538
+ * Tags from cap-tag-scanner.cjs#scanDirectory.
539
+ * @param {Object} [options]
540
+ * @param {string|null} [options.sessionId] - Optional session id to attach to emitted records.
541
+ * @param {string|null} [options.featureId] - Optional active feature id (default falls back to tag.metadata.feature).
542
+ * @returns {{recorded: number, skipped: number}} Counts for /cap:scan reporting.
543
+ */
544
+ function recordRegretsFromScan(projectRoot, tags, options) {
545
+ const opts = options || {};
546
+ let recorded = 0;
547
+ let skipped = 0;
548
+
549
+ if (!Array.isArray(tags) || tags.length === 0) {
550
+ return { recorded, skipped };
551
+ }
552
+
553
+ // Read existing regrets once to build the dedup set. Cold-path read — fine per D2.
554
+ const existing = readAllSignals(projectRoot, 'regret');
555
+ const seenDecisionIds = new Set(existing.map((r) => r.decisionId).filter(Boolean));
556
+
557
+ for (const tag of tags) {
558
+ try {
559
+ if (!tag || tag.type !== 'decision') continue;
560
+ // Match the regret marker. Tag metadata is parsed by cap-tag-scanner.cjs#parseMetadata which
561
+ // stores `regret:true` as the string 'true' (boolean-flag convention). We accept both string
562
+ // and boolean defensively — future scanner refactors mustn't silently break this integration.
563
+ const md = tag.metadata || {};
564
+ const isRegret = md.regret === 'true' || md.regret === true;
565
+ if (!isRegret) continue;
566
+
567
+ // Derive a stable decisionId. Prefer explicit metadata.id, else metadata.decision (e.g. "F-070/D1"),
568
+ // else fall back to file:line as a per-tag-position anchor.
569
+ const explicitId = md.id || md.decision;
570
+ const decisionId = (typeof explicitId === 'string' && explicitId.length > 0)
571
+ ? explicitId
572
+ : `${tag.file}:${tag.line}`;
573
+
574
+ if (seenDecisionIds.has(decisionId)) {
575
+ skipped += 1;
576
+ continue;
577
+ }
578
+
579
+ const featureId = opts.featureId != null ? opts.featureId : (md.feature || null);
580
+
581
+ const result = recordRegret({
582
+ projectRoot,
583
+ sessionId: opts.sessionId || null,
584
+ featureId,
585
+ decisionId,
586
+ });
587
+ if (result) {
588
+ recorded += 1;
589
+ seenDecisionIds.add(decisionId);
590
+ }
591
+ } catch (_e) {
592
+ // Per-tag failure must not break the batch.
593
+ }
594
+ }
595
+
596
+ return { recorded, skipped };
597
+ }
598
+
599
+ // -----------------------------------------------------------------------------
600
+ // Exports
601
+ // -----------------------------------------------------------------------------
602
+
603
+ module.exports = {
604
+ // constants — exported for tests and consumers (F-071/F-072)
605
+ CAP_DIR,
606
+ LEARNING_DIR,
607
+ SIGNALS_DIR,
608
+ STATE_DIR,
609
+ OVERRIDES_FILE,
610
+ MEMORY_REFS_FILE,
611
+ REGRETS_FILE,
612
+ WRITTEN_FILES_LEDGER,
613
+ VALID_TYPES,
614
+ VALID_OVERRIDE_SUBTYPES,
615
+ // public API — collectors
616
+ recordOverride,
617
+ recordMemoryRef,
618
+ recordRegret,
619
+ // public API — query
620
+ getSignals,
621
+ // tag-scanner integration
622
+ recordRegretsFromScan,
623
+ // hook integration — persistent state ledger for editAfterWrite detection
624
+ recordWriteIntoLedger,
625
+ wasWrittenInSession,
626
+ writtenFilesLedgerPath,
627
+ };