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,466 @@
1
+ // @cap-context CAP F-061 Token Telemetry — observability foundation for LLM usage.
2
+ // Persists per-call metrics and per-session aggregates without ever touching raw prompts/completions.
3
+ // Consumed by F-070 (signal collectors) and F-071 (pattern pipeline LLM budget enforcement).
4
+ // @cap-decision(F-061/D1) JSONL format (not JSON array) — append-only, deterministic, no rewrite on add.
5
+ // One call per line. Reading is O(n) streaming, writing is O(1) append.
6
+ // @cap-decision(F-061/D2) Per-session aggregate lives under .cap/telemetry/sessions/<session-id>.json — stable path
7
+ // keyed by sessionId so F-070 / F-071 can look up by session or walk the directory for ranges.
8
+ // @cap-decision(F-061/D3) Enablement is read per call from .cap/config.json on disk (no in-process cache) — keeps
9
+ // no-op semantics honest when config flips at runtime (e.g. a test or a manual toggle).
10
+ // @cap-constraint Zero external dependencies: node:fs, node:path, node:crypto (hashing) only.
11
+ // @cap-risk(F-061/AC-5) PRIVACY BOUNDARY — this module must never accept, log, or persist raw prompt or completion
12
+ // text. Any future contributor adding a `prompt` or `completion` field violates AC-5.
13
+ // `commandContext` is structured metadata only (command name, feature ID). Free-text must be hashed.
14
+
15
+ 'use strict';
16
+
17
+ // @cap-feature(feature:F-061, primary:true) Token Telemetry — LLM-call metrics without raw prompt persistence.
18
+
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+ const crypto = require('node:crypto');
22
+
23
+ const CAP_DIR = '.cap';
24
+ const CONFIG_FILE = 'config.json';
25
+ const TELEMETRY_DIR = 'telemetry';
26
+ const CALLS_FILE = 'llm-calls.jsonl';
27
+ const SESSIONS_DIR = 'sessions';
28
+ const LEARNING_DIR = 'learning';
29
+ const LEARNING_CONFIG_FILE = 'config.json';
30
+ const DEFAULT_LLM_BUDGET_PER_SESSION = 3;
31
+
32
+ /**
33
+ * @typedef {Object} CommandContext
34
+ * @property {string} [command] - CAP command name, e.g. "/cap:prototype".
35
+ * @property {string} [feature] - Feature ID, e.g. "F-061".
36
+ * @property {string} [agent] - Agent name, e.g. "cap-prototyper".
37
+ * @property {string} [note] - Short structured note (no free-text prompts).
38
+ */
39
+
40
+ /**
41
+ * @typedef {Object} LlmCallRecord
42
+ * @property {string} id - ULID-ish unique id derived from timestamp + random.
43
+ * @property {string} ts - ISO timestamp of the call.
44
+ * @property {string} model - Model identifier (e.g. "claude-opus-4-7").
45
+ * @property {number} promptTokens
46
+ * @property {number} completionTokens
47
+ * @property {number} totalTokens
48
+ * @property {number} durationMs
49
+ * @property {string|null} sessionId
50
+ * @property {string|null} featureId
51
+ * @property {CommandContext} commandContext - Structured context only; never raw prompt text.
52
+ * @property {string} [contextHash] - Optional sha256[:16] hash of derived context (not prompts).
53
+ */
54
+
55
+ /**
56
+ * @typedef {Object} SessionAggregate
57
+ * @property {string} sessionId
58
+ * @property {string|null} featureId - Last known active feature for this session (may change).
59
+ * @property {number} callCount
60
+ * @property {number} totalPromptTokens
61
+ * @property {number} totalCompletionTokens
62
+ * @property {number} totalTokens
63
+ * @property {string} firstSeenAt - ISO timestamp of first call seen in this session.
64
+ * @property {string} lastSeenAt - ISO timestamp of last call seen in this session.
65
+ * @property {number} budget - Effective LLM budget per session (calls).
66
+ * @property {number} budgetRemaining - budget - callCount (floored at 0).
67
+ * @property {Object<string,number>} byModel - callCount per model.
68
+ */
69
+
70
+ // -----------------------------------------------------------------------------
71
+ // Config
72
+ // -----------------------------------------------------------------------------
73
+
74
+ // @cap-todo(ac:F-061/AC-6) readConfig returns an empty object when .cap/config.json is missing or malformed,
75
+ // so every caller falls through to the "default behaviour" branch without exceptions.
76
+ /**
77
+ * Read .cap/config.json. Returns `{}` when missing or malformed (no throw, ever).
78
+ * @param {string} projectRoot
79
+ * @returns {object}
80
+ */
81
+ function readConfig(projectRoot) {
82
+ const configPath = path.join(projectRoot, CAP_DIR, CONFIG_FILE);
83
+ try {
84
+ if (!fs.existsSync(configPath)) return {};
85
+ const raw = fs.readFileSync(configPath, 'utf8');
86
+ const parsed = JSON.parse(raw);
87
+ // @cap-decision(F-061/D5) Normalise non-object roots (strings, arrays, null, numbers)
88
+ // to {} — downstream code always expects a plain object.
89
+ // Without this guard, `JSON.parse('"hi"')` leaks a string,
90
+ // and later `cfg.telemetry` throws or silently becomes undefined
91
+ // on primitive autoboxing.
92
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
93
+ return parsed;
94
+ } catch (_e) {
95
+ return {};
96
+ }
97
+ }
98
+
99
+ // @cap-todo(ac:F-061/AC-6) isEnabled returns false iff config explicitly sets telemetry.enabled = false.
100
+ // Missing config OR missing telemetry key means "enabled" — matches the user's
101
+ // explicit opt-in when the project ships without a config file.
102
+ /**
103
+ * Check whether telemetry writes are enabled for this project.
104
+ * Default when config/key missing: true (opt-out, not opt-in).
105
+ * @param {string} projectRoot
106
+ * @returns {boolean}
107
+ */
108
+ function isEnabled(projectRoot) {
109
+ const cfg = readConfig(projectRoot);
110
+ if (cfg && cfg.telemetry && cfg.telemetry.enabled === false) return false;
111
+ return true;
112
+ }
113
+
114
+ /**
115
+ * Read the effective LLM budget per session from .cap/learning/config.json.
116
+ * Missing or malformed config → DEFAULT_LLM_BUDGET_PER_SESSION (3).
117
+ * @param {string} projectRoot
118
+ * @returns {{ budget: number, source: 'config' | 'default' }}
119
+ */
120
+ function readBudget(projectRoot) {
121
+ const learningConfigPath = path.join(projectRoot, CAP_DIR, LEARNING_DIR, LEARNING_CONFIG_FILE);
122
+ try {
123
+ if (!fs.existsSync(learningConfigPath)) {
124
+ return { budget: DEFAULT_LLM_BUDGET_PER_SESSION, source: 'default' };
125
+ }
126
+ const raw = fs.readFileSync(learningConfigPath, 'utf8');
127
+ const parsed = JSON.parse(raw);
128
+ if (parsed && typeof parsed.llmBudgetPerSession === 'number' && parsed.llmBudgetPerSession >= 0) {
129
+ return { budget: parsed.llmBudgetPerSession, source: 'config' };
130
+ }
131
+ return { budget: DEFAULT_LLM_BUDGET_PER_SESSION, source: 'default' };
132
+ } catch (_e) {
133
+ return { budget: DEFAULT_LLM_BUDGET_PER_SESSION, source: 'default' };
134
+ }
135
+ }
136
+
137
+ // -----------------------------------------------------------------------------
138
+ // Hashing helper — privacy-preserving fingerprint for optional dedup keys
139
+ // -----------------------------------------------------------------------------
140
+
141
+ // @cap-risk(F-061/AC-5) This helper is the ONLY way free-text should ever enter the telemetry pipeline,
142
+ // and even then only its first-16-char sha256 hex digest, never the text itself.
143
+ /**
144
+ * Compute a short sha256 hex digest of an arbitrary string. Used e.g. to fingerprint a prompt template
145
+ * id without storing the template's rendered contents. NEVER store the input `text` anywhere.
146
+ * @param {string} text
147
+ * @returns {string} 16-char hex
148
+ */
149
+ function hashContext(text) {
150
+ const input = typeof text === 'string' ? text : String(text == null ? '' : text);
151
+ return crypto.createHash('sha256').update(input).digest('hex').slice(0, 16);
152
+ }
153
+
154
+ // -----------------------------------------------------------------------------
155
+ // Directory + atomic-write primitives
156
+ // -----------------------------------------------------------------------------
157
+
158
+ function ensureDir(dir) {
159
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
160
+ }
161
+
162
+ // @cap-decision(F-061/D4) JSONL append uses O_APPEND with an atomic single-line write. On Linux/macOS
163
+ // writes <= PIPE_BUF (4 KiB) to an O_APPEND fd are atomic w.r.t. other writers,
164
+ // which is enough for our short metric records. No temp+rename is needed for
165
+ // append-only lines; temp+rename IS used for the JSON aggregate file below.
166
+ /**
167
+ * Append one JSON record as a single line to the given file. Record + newline must fit in one write.
168
+ * @param {string} filePath
169
+ * @param {object} record
170
+ */
171
+ function writeJsonlLine(filePath, record) {
172
+ ensureDir(path.dirname(filePath));
173
+ const line = JSON.stringify(record) + '\n';
174
+ const fd = fs.openSync(filePath, 'a');
175
+ try {
176
+ fs.writeSync(fd, line);
177
+ } finally {
178
+ fs.closeSync(fd);
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Atomically write a JSON file via temp + rename. Prevents partial-file readers.
184
+ * @param {string} filePath
185
+ * @param {object} data
186
+ */
187
+ function writeJsonAtomic(filePath, data) {
188
+ ensureDir(path.dirname(filePath));
189
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
190
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
191
+ fs.renameSync(tmp, filePath);
192
+ }
193
+
194
+ /**
195
+ * Generate a short unique ID for a single call record. Not cryptographically secure,
196
+ * but unique enough to distinguish concurrent writes inside one process.
197
+ */
198
+ function generateCallId() {
199
+ const ts = Date.now().toString(36);
200
+ const rnd = crypto.randomBytes(4).toString('hex');
201
+ return `${ts}-${rnd}`;
202
+ }
203
+
204
+ /**
205
+ * Sanitize a structured CommandContext — drop keys we do NOT want in telemetry, keep only
206
+ * the whitelisted structured fields. This is the privacy gate for AC-5.
207
+ * @param {any} raw
208
+ * @returns {CommandContext}
209
+ */
210
+ function sanitizeCommandContext(raw) {
211
+ // @cap-risk(F-061/AC-5) Any new key added here MUST be structured metadata — never free-text.
212
+ const allowed = ['command', 'feature', 'agent', 'note'];
213
+ const out = {};
214
+ if (raw && typeof raw === 'object') {
215
+ for (const k of allowed) {
216
+ if (typeof raw[k] === 'string' && raw[k].length > 0) {
217
+ // Cap the value length so an accidental paste of a prompt never lands whole.
218
+ out[k] = raw[k].slice(0, 200);
219
+ }
220
+ }
221
+ }
222
+ return out;
223
+ }
224
+
225
+ // -----------------------------------------------------------------------------
226
+ // Public API
227
+ // -----------------------------------------------------------------------------
228
+
229
+ // @cap-todo(ac:F-061/AC-1) Per-call JSONL record persisted to .cap/telemetry/llm-calls.jsonl.
230
+ // @cap-todo(ac:F-061/AC-7) Zero deps — only node:fs, node:path, node:crypto.
231
+ /**
232
+ * Record a single LLM call. No-op when telemetry is disabled.
233
+ *
234
+ * The function never accepts raw prompt or completion text — only token counts and structured context.
235
+ * See @cap-risk(F-061/AC-5): anyone adding `prompt` or `completion` to this signature breaks privacy.
236
+ *
237
+ * @param {string} projectRoot - Absolute path to project root.
238
+ * @param {Object} input
239
+ * @param {string} input.model
240
+ * @param {number} input.promptTokens
241
+ * @param {number} input.completionTokens
242
+ * @param {number} [input.totalTokens] - Derived from prompt+completion when omitted.
243
+ * @param {number} input.durationMs
244
+ * @param {string|null} [input.sessionId]
245
+ * @param {string|null} [input.featureId]
246
+ * @param {CommandContext} [input.commandContext]
247
+ * @param {string} [input.contextHash] - Optional pre-computed hash. Never derive from raw prompt text here.
248
+ * @param {string} [input.ts] - Override timestamp (mostly for tests); defaults to new Date().toISOString().
249
+ * @returns {LlmCallRecord|null} The persisted record, or null when telemetry is disabled.
250
+ */
251
+ function recordLlmCall(projectRoot, input) {
252
+ // @cap-todo(ac:F-061/AC-6) Disabled telemetry is a silent no-op — no directories created, no exceptions.
253
+ if (!isEnabled(projectRoot)) return null;
254
+
255
+ const safeInput = input || {};
256
+ // @cap-decision(F-061/D6) Every numeric field must be a FINITE non-negative number.
257
+ // `Number(Infinity)` is finite-checked; `NaN` / Infinity / -Infinity / negatives
258
+ // all collapse to 0. Without this, totalTokens can become Infinity and
259
+ // `JSON.stringify` serialises it as `null`, breaking downstream integer math.
260
+ const toFiniteNonNeg = (v) => {
261
+ const n = Number(v);
262
+ if (!Number.isFinite(n) || n < 0) return 0;
263
+ return n;
264
+ };
265
+ const promptTokens = toFiniteNonNeg(safeInput.promptTokens);
266
+ const completionTokens = toFiniteNonNeg(safeInput.completionTokens);
267
+ const totalTokens = Number.isFinite(Number(safeInput.totalTokens)) && Number(safeInput.totalTokens) >= 0
268
+ ? Number(safeInput.totalTokens)
269
+ : promptTokens + completionTokens;
270
+
271
+ // @cap-risk(F-061/AC-5) Length-cap the model string so an attacker cannot use it
272
+ // as a prompt-smuggle channel. 200 chars matches the commandContext cap.
273
+ const ID_MAX = 200;
274
+ const rawModel = typeof safeInput.model === 'string' ? safeInput.model : 'unknown';
275
+ const model = rawModel.slice(0, ID_MAX);
276
+ // @cap-risk(F-061/AC-5) sessionId and featureId become part of the trust boundary in F-070
277
+ // (external user events). Apply the same type-check + length-cap as `model`
278
+ // so a non-string or huge payload cannot reach disk via these fields.
279
+ const capId = (v) => (typeof v === 'string' && v.length > 0 ? v.slice(0, ID_MAX) : null);
280
+ const sessionId = capId(safeInput.sessionId);
281
+ const featureId = capId(safeInput.featureId);
282
+
283
+ /** @type {LlmCallRecord} */
284
+ const record = {
285
+ id: generateCallId(),
286
+ ts: safeInput.ts || new Date().toISOString(),
287
+ model,
288
+ promptTokens,
289
+ completionTokens,
290
+ totalTokens,
291
+ durationMs: toFiniteNonNeg(safeInput.durationMs),
292
+ sessionId,
293
+ featureId,
294
+ commandContext: sanitizeCommandContext(safeInput.commandContext),
295
+ };
296
+ if (typeof safeInput.contextHash === 'string' && safeInput.contextHash.length > 0) {
297
+ record.contextHash = safeInput.contextHash.slice(0, 64);
298
+ }
299
+
300
+ const callsPath = path.join(projectRoot, CAP_DIR, TELEMETRY_DIR, CALLS_FILE);
301
+ writeJsonlLine(callsPath, record);
302
+ return record;
303
+ }
304
+
305
+ /**
306
+ * Read all per-call records. Tolerant to missing file and malformed lines (they're skipped).
307
+ * @param {string} projectRoot
308
+ * @returns {LlmCallRecord[]}
309
+ */
310
+ function readAllCalls(projectRoot) {
311
+ const callsPath = path.join(projectRoot, CAP_DIR, TELEMETRY_DIR, CALLS_FILE);
312
+ if (!fs.existsSync(callsPath)) return [];
313
+ const raw = fs.readFileSync(callsPath, 'utf8');
314
+ const records = [];
315
+ for (const line of raw.split('\n')) {
316
+ if (!line) continue;
317
+ try {
318
+ records.push(JSON.parse(line));
319
+ } catch (_e) {
320
+ // Skip malformed lines — telemetry must never crash a command.
321
+ }
322
+ }
323
+ return records;
324
+ }
325
+
326
+ // @cap-todo(ac:F-061/AC-4) Query API consumed by F-070 (signal collectors) and F-071 (pattern pipeline).
327
+ /**
328
+ * Query LLM usage. At least one of { sessionId, featureId, range } must be provided.
329
+ * Returns a flat list of matching call records.
330
+ *
331
+ * @param {string} projectRoot
332
+ * @param {Object} filter
333
+ * @param {string} [filter.sessionId]
334
+ * @param {string} [filter.featureId]
335
+ * @param {{from?: string|Date, to?: string|Date}} [filter.range]
336
+ * @returns {LlmCallRecord[]}
337
+ */
338
+ function getLlmUsage(projectRoot, filter) {
339
+ const f = filter || {};
340
+ const all = readAllCalls(projectRoot);
341
+ const fromTs = f.range && f.range.from ? new Date(f.range.from).getTime() : null;
342
+ const toTs = f.range && f.range.to ? new Date(f.range.to).getTime() : null;
343
+
344
+ return all.filter((r) => {
345
+ if (f.sessionId && r.sessionId !== f.sessionId) return false;
346
+ if (f.featureId && r.featureId !== f.featureId) return false;
347
+ if (fromTs !== null || toTs !== null) {
348
+ const recordTs = new Date(r.ts).getTime();
349
+ if (Number.isNaN(recordTs)) return false;
350
+ if (fromTs !== null && recordTs < fromTs) return false;
351
+ if (toTs !== null && recordTs > toTs) return false;
352
+ }
353
+ return true;
354
+ });
355
+ }
356
+
357
+ // @cap-todo(ac:F-061/AC-2) Per-session aggregate: { callCount, totalTokens, budget, budgetRemaining },
358
+ // findable by sessionId and carrying the active featureId for cross-linking.
359
+ /**
360
+ * Compute and persist the aggregate for a given session. No-op when telemetry is disabled.
361
+ * @param {string} projectRoot
362
+ * @param {string} sessionId
363
+ * @returns {SessionAggregate|null}
364
+ */
365
+ function recordSessionAggregate(projectRoot, sessionId) {
366
+ if (!isEnabled(projectRoot)) return null;
367
+ if (!sessionId) return null;
368
+
369
+ const aggregate = computeSessionAggregate(projectRoot, sessionId);
370
+ const aggregatePath = path.join(
371
+ projectRoot, CAP_DIR, TELEMETRY_DIR, SESSIONS_DIR, `${sessionId}.json`
372
+ );
373
+ writeJsonAtomic(aggregatePath, aggregate);
374
+ return aggregate;
375
+ }
376
+
377
+ /**
378
+ * Compute (but do NOT persist) the aggregate view of a session. Pure function over persisted calls.
379
+ * @param {string} projectRoot
380
+ * @param {string} sessionId
381
+ * @returns {SessionAggregate}
382
+ */
383
+ function computeSessionAggregate(projectRoot, sessionId) {
384
+ const calls = getLlmUsage(projectRoot, { sessionId });
385
+ const { budget } = readBudget(projectRoot);
386
+
387
+ let totalPromptTokens = 0;
388
+ let totalCompletionTokens = 0;
389
+ let totalTokens = 0;
390
+ let firstSeenAt = null;
391
+ let lastSeenAt = null;
392
+ let featureId = null;
393
+ const byModel = {};
394
+
395
+ for (const c of calls) {
396
+ totalPromptTokens += Number(c.promptTokens) || 0;
397
+ totalCompletionTokens += Number(c.completionTokens) || 0;
398
+ totalTokens += Number(c.totalTokens) || 0;
399
+ if (!firstSeenAt || c.ts < firstSeenAt) firstSeenAt = c.ts;
400
+ if (!lastSeenAt || c.ts > lastSeenAt) lastSeenAt = c.ts;
401
+ if (c.featureId) featureId = c.featureId;
402
+ if (c.model) byModel[c.model] = (byModel[c.model] || 0) + 1;
403
+ }
404
+
405
+ const callCount = calls.length;
406
+ const budgetRemaining = Math.max(0, budget - callCount);
407
+
408
+ return {
409
+ sessionId,
410
+ featureId,
411
+ callCount,
412
+ totalPromptTokens,
413
+ totalCompletionTokens,
414
+ totalTokens,
415
+ firstSeenAt,
416
+ lastSeenAt,
417
+ budget,
418
+ budgetRemaining,
419
+ byModel,
420
+ };
421
+ }
422
+
423
+ // @cap-todo(ac:F-061/AC-3) Human-readable summary consumed by /cap:status. Budget source: .cap/learning/config.json.
424
+ /**
425
+ * Format a one-liner status summary for a session. Safe to call even when telemetry is disabled
426
+ * (returns a neutral message). Budget source is surfaced so the user can tell default vs configured.
427
+ *
428
+ * @param {string} projectRoot
429
+ * @param {string|null} sessionId
430
+ * @returns {string}
431
+ */
432
+ function formatSessionStatusLine(projectRoot, sessionId) {
433
+ if (!isEnabled(projectRoot)) {
434
+ return 'Telemetry: disabled (.cap/config.json telemetry.enabled=false)';
435
+ }
436
+ const { budget, source } = readBudget(projectRoot);
437
+ if (!sessionId) {
438
+ return `Telemetry: enabled · Budget: ${budget} (${source}) · no active session`;
439
+ }
440
+ const agg = computeSessionAggregate(projectRoot, sessionId);
441
+ const sourceLabel = source === 'default' ? 'default' : 'configured';
442
+ return `Token usage: ${agg.totalTokens} tokens across ${agg.callCount} calls · ` +
443
+ `Budget: ${agg.budget} (${sourceLabel}) · Used: ${agg.callCount} · Remaining: ${agg.budgetRemaining}`;
444
+ }
445
+
446
+ module.exports = {
447
+ // constants
448
+ CAP_DIR,
449
+ TELEMETRY_DIR,
450
+ CALLS_FILE,
451
+ SESSIONS_DIR,
452
+ DEFAULT_LLM_BUDGET_PER_SESSION,
453
+ // config
454
+ readConfig,
455
+ isEnabled,
456
+ readBudget,
457
+ // privacy helper
458
+ hashContext,
459
+ // public API
460
+ recordLlmCall,
461
+ readAllCalls,
462
+ getLlmUsage,
463
+ recordSessionAggregate,
464
+ computeSessionAggregate,
465
+ formatSessionStatusLine,
466
+ };