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,371 @@
1
+ 'use strict';
2
+
3
+ // @cap-feature(feature:F-054) Hook-Based Tag Event Observation — pure logic module (extractTags, diffTags, snapshot I/O, event append).
4
+ // @cap-decision Snapshot-basiert statt PreToolUse — ein einheitlicher Code-Pfad für alle vier Tools (Edit/Write/MultiEdit/NotebookEdit), robust gegen Tool-Input-Schema-Änderungen, weil wir immer den aktuellen Datei-Inhalt neu lesen und gegen einen persistenten Snapshot diffen.
5
+ // @cap-constraint Zero external dependencies — nur node:-prefixed Built-ins (fs, path, crypto).
6
+
7
+ // @cap-history(sessions:2, edits:5, since:2026-04-20, learned:2026-04-21) Frequently modified — 2 sessions, 5 edits
8
+ const fs = require('node:fs');
9
+ const path = require('node:path');
10
+ const crypto = require('node:crypto');
11
+
12
+ // @cap-decision Tag-Regex bewusst eng an cap-tag-scanner.cjs gehalten (dort: `/^[ \t]*(?:\/\/|\/\*|\*|#|--|"""|''')[ \t]*@cap-(feature|todo|risk|decision)(?:\(([^)]*)\))?[ \t]*(.*)/`). Für F-054 beschränken wir uns explizit auf `feature|todo` (Scope des Memory-Events) und matchen Comment-Tokens zeilengenau, damit @cap-Strings innerhalb von String-Literalen oder Prosa nicht gezählt werden.
13
+ const TAG_LINE_RE = /^[ \t]*(?:\/\/|\/\*|\*|#|--|"""|''')[ \t]*@cap-(?:feature|todo)(?:[ \t]*\([^)]*\))?[ \t]*.*$/;
14
+
15
+ // Matches the tag token (type + optional metadata in parens). Global flag enables matchAll so multiple tokens on the same comment line are captured; `[ \t]*` before the paren tolerates `@cap-todo ( ac:F-1/AC-1 )`-style whitespace which is then stripped by the caller's normalisation step.
16
+ const TAG_TOKEN_RE = /@cap-(?:feature|todo)(?:[ \t]*\([^)]*\))?/g;
17
+
18
+ /**
19
+ * Extract @cap-feature and @cap-todo tags from file content.
20
+ *
21
+ * Returns normalized tag identities as strings (e.g. `@cap-todo(ac:F-054/AC-1)`).
22
+ * Metadata inside parens is part of the identity, so
23
+ * `@cap-todo(ac:F-054/AC-1)` and `@cap-todo(ac:F-054/AC-2)` are distinct tags.
24
+ * Duplicates within the same file are deduplicated (Set-based).
25
+ *
26
+ * @cap-todo(ac:F-054/AC-2) extractTags ist die linke Hand des Diff — jede
27
+ * Datei wird zu einer dedup'd Menge von @cap-feature/@cap-todo Tags normalisiert,
28
+ * damit added/removed gegen den Snapshot ein reines Set-Delta bleibt.
29
+ *
30
+ * @param {string} content - File content to scan
31
+ * @returns {string[]} Sorted unique tag identities
32
+ */
33
+ function extractTags(content) {
34
+ if (typeof content !== 'string' || content.length === 0) return [];
35
+ const seen = new Set();
36
+ const lines = content.split('\n');
37
+ for (const line of lines) {
38
+ if (!TAG_LINE_RE.test(line)) continue;
39
+ for (const match of line.matchAll(TAG_TOKEN_RE)) {
40
+ // Normalise internal whitespace: `@cap-todo ( ac:F-1/AC-1 )` -> `@cap-todo(ac:F-1/AC-1)`.
41
+ const tag = match[0].replace(/\s+/g, '');
42
+ seen.add(tag);
43
+ }
44
+ }
45
+ return Array.from(seen).sort();
46
+ }
47
+
48
+ /**
49
+ * Compute added/removed tag sets between two normalized tag arrays.
50
+ *
51
+ * @cap-todo(ac:F-054/AC-2) diffTags erzeugt das Delta, das später als JSONL-Event
52
+ * persistiert wird. Dedupe geschieht per Set, Inputs müssen nicht sortiert sein.
53
+ *
54
+ * @param {string[]} before - Previous tag set
55
+ * @param {string[]} after - Current tag set
56
+ * @returns {{added: string[], removed: string[]}}
57
+ */
58
+ function diffTags(before, after) {
59
+ const beforeSet = new Set(Array.isArray(before) ? before : []);
60
+ const afterSet = new Set(Array.isArray(after) ? after : []);
61
+ const added = [];
62
+ const removed = [];
63
+ for (const t of afterSet) if (!beforeSet.has(t)) added.push(t);
64
+ for (const t of beforeSet) if (!afterSet.has(t)) removed.push(t);
65
+ added.sort();
66
+ removed.sort();
67
+ return { added, removed };
68
+ }
69
+
70
+ /**
71
+ * Compute the absolute path of a snapshot file for a given observed source file.
72
+ * Uses SHA-1 of the absolute source path, stored under `.snapshots/`.
73
+ *
74
+ * @param {string} rawDir - Absolute path to `.cap/memory/raw`
75
+ * @param {string} filePath - Path to the observed file (relative or absolute)
76
+ * @returns {string} Absolute path to the snapshot JSON file
77
+ */
78
+ function snapshotPath(rawDir, filePath) {
79
+ const abs = path.resolve(filePath);
80
+ const hash = crypto.createHash('sha1').update(abs).digest('hex');
81
+ return path.join(rawDir, '.snapshots', `${hash}.json`);
82
+ }
83
+
84
+ /**
85
+ * Ensure a directory exists (recursive mkdir, idempotent).
86
+ * @param {string} dir
87
+ */
88
+ function ensureDir(dir) {
89
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
90
+ }
91
+
92
+ /**
93
+ * Load a previously persisted tag snapshot.
94
+ *
95
+ * A missing snapshot is an expected "first observation" state — silent null.
96
+ * A corrupt snapshot (non-JSON, wrong shape) is an anomaly worth a breadcrumb
97
+ * so we can later correlate spurious `removed`-events with snapshot damage.
98
+ *
99
+ * @param {string} rawDir
100
+ * @param {string} filePath
101
+ * @returns {{file:string, tags:string[], mtime:(number|null), updatedAt:string}|null}
102
+ */
103
+ function loadSnapshot(rawDir, filePath) {
104
+ const snap = snapshotPath(rawDir, filePath);
105
+ let raw;
106
+ try {
107
+ raw = fs.readFileSync(snap, 'utf8');
108
+ } catch {
109
+ // ENOENT / EACCES / first-ever observation — no breadcrumb, caller treats as fresh.
110
+ return null;
111
+ }
112
+ try {
113
+ const parsed = JSON.parse(raw);
114
+ if (!parsed || !Array.isArray(parsed.tags)) {
115
+ logError(rawDir, new Error(`corrupt snapshot (missing tags array): ${snap}`));
116
+ return null;
117
+ }
118
+ return parsed;
119
+ } catch (err) {
120
+ logError(rawDir, new Error(`corrupt snapshot (${err.message}): ${snap}`));
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Write a tag snapshot atomically (tmp + rename).
127
+ *
128
+ * @cap-decision Atomic tmp+rename statt direktem Write, damit ein abgebrochener
129
+ * Hook (z.B. durch SIGTERM des Parent-Claude-Prozesses) nie einen halb-
130
+ * geschriebenen Snapshot hinterlässt, der beim nächsten Lauf als `tags:[]`
131
+ * interpretiert würde und einen falschen `removed`-Event produziert.
132
+ *
133
+ * @param {string} rawDir
134
+ * @param {string} filePath
135
+ * @param {{file:string, tags:string[], mtime:(number|null), updatedAt:string}} data
136
+ */
137
+ function writeSnapshot(rawDir, filePath, data) {
138
+ const snap = snapshotPath(rawDir, filePath);
139
+ ensureDir(path.dirname(snap));
140
+ // 8 random bytes disambiguate two hook invocations that land in the same
141
+ // millisecond from the same pid (e.g. parallel Edit calls under CI) — Date.now()
142
+ // alone is insufficient because rename(2) on an existing tmp is a data-race.
143
+ const tmp = `${snap}.${process.pid}.${Date.now()}.${crypto.randomBytes(8).toString('hex')}.tmp`;
144
+ fs.writeFileSync(tmp, JSON.stringify(data), 'utf8');
145
+ fs.renameSync(tmp, snap);
146
+ }
147
+
148
+ /**
149
+ * Format a date into the daily log filename suffix (UTC-stable `YYYY-MM-DD`).
150
+ *
151
+ * @cap-decision UTC statt Lokalzeit, damit Log-Rotation zwischen Maschinen /
152
+ * CI-Runnern mit unterschiedlichen TZ deterministisch bleibt und
153
+ * /cap:memory prune (F-056) den Tagesstempel einfach vergleichen kann.
154
+ *
155
+ * @param {Date} date
156
+ * @returns {string}
157
+ */
158
+ function dayStamp(date) {
159
+ const y = date.getUTCFullYear();
160
+ const m = String(date.getUTCMonth() + 1).padStart(2, '0');
161
+ const d = String(date.getUTCDate()).padStart(2, '0');
162
+ return `${y}-${m}-${d}`;
163
+ }
164
+
165
+ /**
166
+ * Append a JSONL event line to the daily tag-events log.
167
+ *
168
+ * @cap-todo(ac:F-054/AC-3) appendEvent schreibt eine Zeile
169
+ * {timestamp, tool, file, added[], removed[]} nach
170
+ * `.cap/memory/raw/tag-events-{YYYY-MM-DD}.jsonl`.
171
+ * @cap-todo(ac:F-054/AC-7) Tages-Rotation: Der Dateiname trägt das Datum, der
172
+ * Cleanup von >30 Tage alten Files ist F-056's Job.
173
+ *
174
+ * @cap-risk `fs.appendFileSync` ist auf POSIX atomar für ≤ PIPE_BUF-sized writes
175
+ * (≥4 KiB, typischer JSONL-Event ist weit darunter). Auf Windows gibt es
176
+ * keine formale Garantie; die Payloads bleiben bewusst klein, damit der Hook
177
+ * bei parallelen Tool-Calls nicht interleavt.
178
+ *
179
+ * @param {string} rawDir
180
+ * @param {{timestamp:string, tool:string, file:string, added:string[], removed:string[]}} event
181
+ */
182
+ function appendEvent(rawDir, event) {
183
+ ensureDir(rawDir);
184
+ const when = event && event.timestamp ? new Date(event.timestamp) : new Date();
185
+ const safeDay = Number.isNaN(when.getTime()) ? dayStamp(new Date()) : dayStamp(when);
186
+ const file = path.join(rawDir, `tag-events-${safeDay}.jsonl`);
187
+ fs.appendFileSync(file, JSON.stringify(event) + '\n', 'utf8');
188
+ }
189
+
190
+ /**
191
+ * Append an error record to `.cap/memory/raw/errors.log`.
192
+ *
193
+ * @cap-todo(ac:F-054/AC-6) Hook-Fehler landen hier und blockieren den Edit-Tool
194
+ * nie — der Aufrufer (Hook-Entry) ruft logError im catch und exit'ed mit 0.
195
+ *
196
+ * @param {string} rawDir
197
+ * @param {Error|{message:string, stack?:string}} err
198
+ */
199
+ function logError(rawDir, err) {
200
+ try {
201
+ ensureDir(rawDir);
202
+ const entry = {
203
+ timestamp: new Date().toISOString(),
204
+ message: (err && err.message) || String(err),
205
+ stack: (err && err.stack) || null,
206
+ };
207
+ fs.appendFileSync(path.join(rawDir, 'errors.log'), JSON.stringify(entry) + '\n', 'utf8');
208
+ } catch {
209
+ // Best-effort — if even logging fails, we must stay silent (AC-6).
210
+ }
211
+ }
212
+
213
+ // @cap-decision Hard cap for readFile — any file > MAX_OBSERVE_BYTES is skipped
214
+ // with a breadcrumb. Rationale: the hook runs synchronously on every Edit/Write
215
+ // and must stay <100 ms (AC-5). A pathological 200 MB generated fixture would
216
+ // not only blow the budget but also push the entire file into memory for a
217
+ // regex scan. 5 MiB comfortably holds any hand-authored source while bounding
218
+ // worst-case latency.
219
+ const MAX_OBSERVE_BYTES = 5 * 1024 * 1024;
220
+
221
+ /**
222
+ * Read the observed file's bytes within the size cap. Returns null (and logs)
223
+ * on I/O failure or size-overflow; never throws.
224
+ *
225
+ * @param {string} filePath
226
+ * @param {string} rawDir
227
+ * @param {(p:string)=>string} readFile
228
+ * @returns {{content:string, mtime:(number|null)}|null}
229
+ */
230
+ function readObservedFile(filePath, rawDir, readFile) {
231
+ let size = null;
232
+ let mtime = null;
233
+ try {
234
+ const st = fs.statSync(filePath);
235
+ size = st.size;
236
+ mtime = st.mtimeMs;
237
+ } catch {
238
+ // statSync failure is non-fatal — readFile may still succeed (e.g. injected
239
+ // reader in tests). Proceed without the size-guard short-circuit.
240
+ }
241
+ if (size !== null && size > MAX_OBSERVE_BYTES) {
242
+ logError(rawDir, new Error(`skipping oversized file (${size} bytes > ${MAX_OBSERVE_BYTES}): ${filePath}`));
243
+ return null;
244
+ }
245
+ try {
246
+ return { content: readFile(filePath), mtime };
247
+ } catch (err) {
248
+ logError(rawDir, err);
249
+ return null;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Compute the tag-diff between the current file content and the last snapshot.
255
+ * Pure function modulo the snapshot-read side-effect inside loadSnapshot.
256
+ *
257
+ * @param {string} content
258
+ * @param {string} rawDir
259
+ * @param {string} filePath
260
+ * @returns {{currentTags:string[], snapshot:object|null, added:string[], removed:string[]}}
261
+ */
262
+ function computeDelta(content, rawDir, filePath) {
263
+ const currentTags = extractTags(content);
264
+ const snapshot = loadSnapshot(rawDir, filePath);
265
+ const previousTags = snapshot ? snapshot.tags : [];
266
+ const { added, removed } = diffTags(previousTags, currentTags);
267
+ return { currentTags, snapshot, added, removed };
268
+ }
269
+
270
+ /**
271
+ * Persist the JSONL event and update the snapshot. Event-write failure is fatal
272
+ * for this observation (returns eventWritten=false); snapshot-write failure is
273
+ * non-fatal (next run re-diffs against the stale snapshot).
274
+ */
275
+ function persistObservation(rawDir, filePath, tool, now, currentTags, mtime, added, removed) {
276
+ try {
277
+ appendEvent(rawDir, {
278
+ timestamp: now.toISOString(),
279
+ tool,
280
+ file: path.resolve(filePath),
281
+ added,
282
+ removed,
283
+ });
284
+ } catch (err) {
285
+ logError(rawDir, err);
286
+ return { eventWritten: false };
287
+ }
288
+ try {
289
+ writeSnapshot(rawDir, filePath, {
290
+ file: path.resolve(filePath),
291
+ tags: currentTags,
292
+ mtime,
293
+ updatedAt: now.toISOString(),
294
+ });
295
+ } catch (err) {
296
+ logError(rawDir, err);
297
+ }
298
+ return { eventWritten: true };
299
+ }
300
+
301
+ /**
302
+ * Main entry: observe a file after a tool invocation, compute tag diff against
303
+ * the last snapshot, persist an event on change.
304
+ *
305
+ * @cap-todo(ac:F-054/AC-2) observe liest die aktuelle Datei, lädt den Snapshot,
306
+ * ruft diffTags auf und persistiert sowohl Event als auch aktualisierten
307
+ * Snapshot.
308
+ * @cap-todo(ac:F-054/AC-4) Kein Diff (added.length === 0 && removed.length === 0)
309
+ * → kein Write. Keine leere JSONL-Zeile, kein Noise.
310
+ * @cap-todo(ac:F-054/AC-5) observe ist synchron und vermeidet zweite Reads/
311
+ * Regex-Passes: ein einziger split+Regex-Scan, dann Set-Diff. Performance-Test
312
+ * in cap-tag-observer.test.cjs erzwingt <100 ms für 10 000-Zeilen-Files.
313
+ *
314
+ * @param {Object} opts
315
+ * @param {string} opts.filePath - Absolute or cwd-relative path to the file that was edited.
316
+ * @param {string} opts.tool - Tool name (Edit/Write/MultiEdit/NotebookEdit).
317
+ * @param {string} [opts.rawDir] - Override raw memory directory (defaults to `<cwd>/.cap/memory/raw`).
318
+ * @param {Date} [opts.now] - Injected clock for testing.
319
+ * @param {(p:string)=>string} [opts.readFile] - Injected reader for testing.
320
+ * @returns {{eventWritten:boolean, added:string[], removed:string[]}}
321
+ */
322
+ function observe(opts) {
323
+ const filePath = opts && opts.filePath;
324
+ const tool = (opts && opts.tool) || 'unknown';
325
+ const now = (opts && opts.now) || new Date();
326
+ const readFile = (opts && opts.readFile) || ((p) => fs.readFileSync(p, 'utf8'));
327
+ const rawDir = (opts && opts.rawDir) || path.join(process.cwd(), '.cap', 'memory', 'raw');
328
+
329
+ if (!filePath) return { eventWritten: false, added: [], removed: [] };
330
+
331
+ const read = readObservedFile(filePath, rawDir, readFile);
332
+ if (!read) return { eventWritten: false, added: [], removed: [] };
333
+
334
+ const { currentTags, snapshot, added, removed } = computeDelta(read.content, rawDir, filePath);
335
+
336
+ if (added.length === 0 && removed.length === 0) {
337
+ // AC-4: nothing to report. Still seed a snapshot on first-ever observation
338
+ // of a tagless file so we don't re-diff the same empty set forever.
339
+ if (!snapshot) {
340
+ try {
341
+ writeSnapshot(rawDir, filePath, {
342
+ file: path.resolve(filePath),
343
+ tags: currentTags,
344
+ mtime: read.mtime,
345
+ updatedAt: now.toISOString(),
346
+ });
347
+ } catch (err) {
348
+ logError(rawDir, err);
349
+ }
350
+ }
351
+ return { eventWritten: false, added, removed };
352
+ }
353
+
354
+ const { eventWritten } = persistObservation(
355
+ rawDir, filePath, tool, now, currentTags, read.mtime, added, removed,
356
+ );
357
+ return { eventWritten, added, removed };
358
+ }
359
+
360
+ module.exports = {
361
+ extractTags,
362
+ diffTags,
363
+ snapshotPath,
364
+ loadSnapshot,
365
+ writeSnapshot,
366
+ appendEvent,
367
+ logError,
368
+ observe,
369
+ // exposed for tests
370
+ _dayStamp: dayStamp,
371
+ };