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.
- package/.claude-plugin/README.md +26 -0
- package/.claude-plugin/marketplace.json +24 -0
- package/.claude-plugin/plugin.json +24 -0
- package/LICENSE +21 -0
- package/README.ja-JP.md +834 -0
- package/README.ko-KR.md +823 -0
- package/README.md +806 -0
- package/README.pt-BR.md +452 -0
- package/README.zh-CN.md +800 -0
- package/agents/cap-architect.md +269 -0
- package/agents/cap-brainstormer.md +207 -0
- package/agents/cap-curator.md +276 -0
- package/agents/cap-debugger.md +365 -0
- package/agents/cap-designer.md +246 -0
- package/agents/cap-historian.md +464 -0
- package/agents/cap-migrator.md +291 -0
- package/agents/cap-prototyper.md +197 -0
- package/agents/cap-validator.md +308 -0
- package/bin/install.js +5433 -0
- package/cap/bin/cap-tools.cjs +853 -0
- package/cap/bin/lib/arc-scanner.cjs +344 -0
- package/cap/bin/lib/cap-affinity-engine.cjs +862 -0
- package/cap/bin/lib/cap-anchor.cjs +228 -0
- package/cap/bin/lib/cap-annotation-writer.cjs +340 -0
- package/cap/bin/lib/cap-checkpoint.cjs +434 -0
- package/cap/bin/lib/cap-cluster-detect.cjs +945 -0
- package/cap/bin/lib/cap-cluster-display.cjs +52 -0
- package/cap/bin/lib/cap-cluster-format.cjs +245 -0
- package/cap/bin/lib/cap-cluster-helpers.cjs +295 -0
- package/cap/bin/lib/cap-cluster-io.cjs +212 -0
- package/cap/bin/lib/cap-completeness.cjs +540 -0
- package/cap/bin/lib/cap-deps.cjs +583 -0
- package/cap/bin/lib/cap-design-families.cjs +332 -0
- package/cap/bin/lib/cap-design.cjs +966 -0
- package/cap/bin/lib/cap-divergence-detector.cjs +400 -0
- package/cap/bin/lib/cap-doctor.cjs +752 -0
- package/cap/bin/lib/cap-feature-map-internals.cjs +19 -0
- package/cap/bin/lib/cap-feature-map-migrate.cjs +335 -0
- package/cap/bin/lib/cap-feature-map-monorepo.cjs +885 -0
- package/cap/bin/lib/cap-feature-map-shard.cjs +315 -0
- package/cap/bin/lib/cap-feature-map.cjs +1943 -0
- package/cap/bin/lib/cap-fitness-score.cjs +1075 -0
- package/cap/bin/lib/cap-impact-analysis.cjs +652 -0
- package/cap/bin/lib/cap-learn-review.cjs +1072 -0
- package/cap/bin/lib/cap-learning-signals.cjs +627 -0
- package/cap/bin/lib/cap-loader.cjs +227 -0
- package/cap/bin/lib/cap-logger.cjs +57 -0
- package/cap/bin/lib/cap-memory-bridge.cjs +764 -0
- package/cap/bin/lib/cap-memory-confidence.cjs +452 -0
- package/cap/bin/lib/cap-memory-dir.cjs +987 -0
- package/cap/bin/lib/cap-memory-engine.cjs +698 -0
- package/cap/bin/lib/cap-memory-extends.cjs +398 -0
- package/cap/bin/lib/cap-memory-graph.cjs +790 -0
- package/cap/bin/lib/cap-memory-migrate.cjs +2015 -0
- package/cap/bin/lib/cap-memory-pin.cjs +183 -0
- package/cap/bin/lib/cap-memory-platform.cjs +490 -0
- package/cap/bin/lib/cap-memory-prune.cjs +707 -0
- package/cap/bin/lib/cap-memory-schema.cjs +812 -0
- package/cap/bin/lib/cap-migrate-tags.cjs +309 -0
- package/cap/bin/lib/cap-migrate.cjs +540 -0
- package/cap/bin/lib/cap-pattern-apply.cjs +1203 -0
- package/cap/bin/lib/cap-pattern-pipeline.cjs +1034 -0
- package/cap/bin/lib/cap-plugin-manifest.cjs +80 -0
- package/cap/bin/lib/cap-realtime-affinity.cjs +399 -0
- package/cap/bin/lib/cap-reconcile.cjs +570 -0
- package/cap/bin/lib/cap-research-gate.cjs +218 -0
- package/cap/bin/lib/cap-scope-filter.cjs +402 -0
- package/cap/bin/lib/cap-semantic-pipeline.cjs +1038 -0
- package/cap/bin/lib/cap-session-extract.cjs +987 -0
- package/cap/bin/lib/cap-session.cjs +445 -0
- package/cap/bin/lib/cap-snapshot-linkage.cjs +963 -0
- package/cap/bin/lib/cap-stack-docs.cjs +646 -0
- package/cap/bin/lib/cap-tag-observer.cjs +371 -0
- package/cap/bin/lib/cap-tag-scanner.cjs +1766 -0
- package/cap/bin/lib/cap-telemetry.cjs +466 -0
- package/cap/bin/lib/cap-test-audit.cjs +1438 -0
- package/cap/bin/lib/cap-thread-migrator.cjs +307 -0
- package/cap/bin/lib/cap-thread-synthesis.cjs +545 -0
- package/cap/bin/lib/cap-thread-tracker.cjs +519 -0
- package/cap/bin/lib/cap-trace.cjs +399 -0
- package/cap/bin/lib/cap-trust-mode.cjs +336 -0
- package/cap/bin/lib/cap-ui-design-editor.cjs +642 -0
- package/cap/bin/lib/cap-ui-mind-map.cjs +712 -0
- package/cap/bin/lib/cap-ui-thread-nav.cjs +693 -0
- package/cap/bin/lib/cap-ui.cjs +1245 -0
- package/cap/bin/lib/cap-upgrade.cjs +1028 -0
- package/cap/bin/lib/cli/arg-helpers.cjs +49 -0
- package/cap/bin/lib/cli/frontmatter-router.cjs +31 -0
- package/cap/bin/lib/cli/init-router.cjs +68 -0
- package/cap/bin/lib/cli/phase-router.cjs +102 -0
- package/cap/bin/lib/cli/state-router.cjs +61 -0
- package/cap/bin/lib/cli/template-router.cjs +37 -0
- package/cap/bin/lib/cli/uat-router.cjs +29 -0
- package/cap/bin/lib/cli/validation-router.cjs +26 -0
- package/cap/bin/lib/cli/verification-router.cjs +31 -0
- package/cap/bin/lib/cli/workstream-router.cjs +39 -0
- package/cap/bin/lib/commands.cjs +961 -0
- package/cap/bin/lib/config.cjs +467 -0
- package/cap/bin/lib/convention-reader.cjs +258 -0
- package/cap/bin/lib/core.cjs +1241 -0
- package/cap/bin/lib/feature-aggregator.cjs +423 -0
- package/cap/bin/lib/frontmatter.cjs +337 -0
- package/cap/bin/lib/init.cjs +1443 -0
- package/cap/bin/lib/manifest-generator.cjs +383 -0
- package/cap/bin/lib/milestone.cjs +253 -0
- package/cap/bin/lib/model-profiles.cjs +69 -0
- package/cap/bin/lib/monorepo-context.cjs +226 -0
- package/cap/bin/lib/monorepo-migrator.cjs +509 -0
- package/cap/bin/lib/phase.cjs +889 -0
- package/cap/bin/lib/profile-output.cjs +989 -0
- package/cap/bin/lib/profile-pipeline.cjs +540 -0
- package/cap/bin/lib/roadmap.cjs +330 -0
- package/cap/bin/lib/security.cjs +394 -0
- package/cap/bin/lib/session-manager.cjs +292 -0
- package/cap/bin/lib/skeleton-generator.cjs +179 -0
- package/cap/bin/lib/state.cjs +1032 -0
- package/cap/bin/lib/template.cjs +231 -0
- package/cap/bin/lib/test-detector.cjs +62 -0
- package/cap/bin/lib/uat.cjs +283 -0
- package/cap/bin/lib/verify.cjs +889 -0
- package/cap/bin/lib/workspace-detector.cjs +371 -0
- package/cap/bin/lib/workstream.cjs +492 -0
- package/cap/commands/gsd/workstreams.md +63 -0
- package/cap/references/arc-standard.md +315 -0
- package/cap/references/cap-agent-architecture.md +101 -0
- package/cap/references/cap-gitignore-template +9 -0
- package/cap/references/cap-zero-deps.md +158 -0
- package/cap/references/checkpoints.md +778 -0
- package/cap/references/continuation-format.md +249 -0
- package/cap/references/contract-test-templates.md +312 -0
- package/cap/references/feature-map-template.md +25 -0
- package/cap/references/git-integration.md +295 -0
- package/cap/references/git-planning-commit.md +38 -0
- package/cap/references/model-profiles.md +174 -0
- package/cap/references/phase-numbering.md +126 -0
- package/cap/references/planning-config.md +202 -0
- package/cap/references/property-test-templates.md +316 -0
- package/cap/references/security-test-templates.md +347 -0
- package/cap/references/session-template.json +8 -0
- package/cap/references/tdd.md +263 -0
- package/cap/references/user-profiling.md +681 -0
- package/cap/references/verification-patterns.md +612 -0
- package/cap/templates/UAT.md +265 -0
- package/cap/templates/claude-md.md +175 -0
- package/cap/templates/codebase/architecture.md +255 -0
- package/cap/templates/codebase/concerns.md +310 -0
- package/cap/templates/codebase/conventions.md +307 -0
- package/cap/templates/codebase/integrations.md +280 -0
- package/cap/templates/codebase/stack.md +186 -0
- package/cap/templates/codebase/structure.md +285 -0
- package/cap/templates/codebase/testing.md +480 -0
- package/cap/templates/config.json +44 -0
- package/cap/templates/context.md +352 -0
- package/cap/templates/continue-here.md +78 -0
- package/cap/templates/copilot-instructions.md +7 -0
- package/cap/templates/debug-subagent-prompt.md +91 -0
- package/cap/templates/discussion-log.md +63 -0
- package/cap/templates/milestone-archive.md +123 -0
- package/cap/templates/milestone.md +115 -0
- package/cap/templates/phase-prompt.md +610 -0
- package/cap/templates/planner-subagent-prompt.md +117 -0
- package/cap/templates/project.md +186 -0
- package/cap/templates/requirements.md +231 -0
- package/cap/templates/research-project/ARCHITECTURE.md +204 -0
- package/cap/templates/research-project/FEATURES.md +147 -0
- package/cap/templates/research-project/PITFALLS.md +200 -0
- package/cap/templates/research-project/STACK.md +120 -0
- package/cap/templates/research-project/SUMMARY.md +170 -0
- package/cap/templates/research.md +552 -0
- package/cap/templates/roadmap.md +202 -0
- package/cap/templates/state.md +176 -0
- package/cap/templates/summary.md +364 -0
- package/cap/templates/user-preferences.md +498 -0
- package/cap/templates/verification-report.md +322 -0
- package/cap/workflows/add-phase.md +112 -0
- package/cap/workflows/add-tests.md +351 -0
- package/cap/workflows/add-todo.md +158 -0
- package/cap/workflows/audit-milestone.md +340 -0
- package/cap/workflows/audit-uat.md +109 -0
- package/cap/workflows/autonomous.md +891 -0
- package/cap/workflows/check-todos.md +177 -0
- package/cap/workflows/cleanup.md +152 -0
- package/cap/workflows/complete-milestone.md +767 -0
- package/cap/workflows/diagnose-issues.md +231 -0
- package/cap/workflows/discovery-phase.md +289 -0
- package/cap/workflows/discuss-phase-assumptions.md +653 -0
- package/cap/workflows/discuss-phase.md +1049 -0
- package/cap/workflows/do.md +104 -0
- package/cap/workflows/execute-phase.md +846 -0
- package/cap/workflows/execute-plan.md +514 -0
- package/cap/workflows/fast.md +105 -0
- package/cap/workflows/forensics.md +265 -0
- package/cap/workflows/health.md +181 -0
- package/cap/workflows/help.md +660 -0
- package/cap/workflows/insert-phase.md +130 -0
- package/cap/workflows/list-phase-assumptions.md +178 -0
- package/cap/workflows/list-workspaces.md +56 -0
- package/cap/workflows/manager.md +362 -0
- package/cap/workflows/map-codebase.md +377 -0
- package/cap/workflows/milestone-summary.md +223 -0
- package/cap/workflows/new-milestone.md +486 -0
- package/cap/workflows/new-project.md +1250 -0
- package/cap/workflows/new-workspace.md +237 -0
- package/cap/workflows/next.md +97 -0
- package/cap/workflows/node-repair.md +92 -0
- package/cap/workflows/note.md +156 -0
- package/cap/workflows/pause-work.md +176 -0
- package/cap/workflows/plan-milestone-gaps.md +273 -0
- package/cap/workflows/plan-phase.md +857 -0
- package/cap/workflows/plant-seed.md +169 -0
- package/cap/workflows/pr-branch.md +129 -0
- package/cap/workflows/profile-user.md +449 -0
- package/cap/workflows/progress.md +507 -0
- package/cap/workflows/quick.md +757 -0
- package/cap/workflows/remove-phase.md +155 -0
- package/cap/workflows/remove-workspace.md +90 -0
- package/cap/workflows/research-phase.md +82 -0
- package/cap/workflows/resume-project.md +326 -0
- package/cap/workflows/review.md +228 -0
- package/cap/workflows/session-report.md +146 -0
- package/cap/workflows/settings.md +283 -0
- package/cap/workflows/ship.md +228 -0
- package/cap/workflows/stats.md +60 -0
- package/cap/workflows/transition.md +671 -0
- package/cap/workflows/ui-phase.md +298 -0
- package/cap/workflows/ui-review.md +161 -0
- package/cap/workflows/update.md +323 -0
- package/cap/workflows/validate-phase.md +170 -0
- package/cap/workflows/verify-phase.md +254 -0
- package/cap/workflows/verify-work.md +637 -0
- package/commands/cap/annotate.md +165 -0
- package/commands/cap/brainstorm.md +393 -0
- package/commands/cap/checkpoint.md +106 -0
- package/commands/cap/completeness.md +94 -0
- package/commands/cap/continue.md +72 -0
- package/commands/cap/debug.md +588 -0
- package/commands/cap/deps.md +169 -0
- package/commands/cap/design.md +479 -0
- package/commands/cap/init.md +354 -0
- package/commands/cap/iterate.md +249 -0
- package/commands/cap/learn.md +459 -0
- package/commands/cap/memory.md +275 -0
- package/commands/cap/migrate-feature-map.md +91 -0
- package/commands/cap/migrate-memory.md +108 -0
- package/commands/cap/migrate-tags.md +91 -0
- package/commands/cap/migrate.md +131 -0
- package/commands/cap/prototype.md +510 -0
- package/commands/cap/reconcile.md +121 -0
- package/commands/cap/review.md +360 -0
- package/commands/cap/save.md +72 -0
- package/commands/cap/scan.md +404 -0
- package/commands/cap/start.md +356 -0
- package/commands/cap/status.md +118 -0
- package/commands/cap/test-audit.md +262 -0
- package/commands/cap/test.md +394 -0
- package/commands/cap/trace.md +133 -0
- package/commands/cap/ui.md +167 -0
- package/hooks/dist/cap-check-update.js +115 -0
- package/hooks/dist/cap-context-monitor.js +185 -0
- package/hooks/dist/cap-learn-review-hook.js +114 -0
- package/hooks/dist/cap-learning-hook.js +192 -0
- package/hooks/dist/cap-memory.js +299 -0
- package/hooks/dist/cap-prompt-guard.js +97 -0
- package/hooks/dist/cap-statusline.js +157 -0
- package/hooks/dist/cap-tag-observer.js +115 -0
- package/hooks/dist/cap-version-check.js +112 -0
- package/hooks/dist/cap-workflow-guard.js +175 -0
- package/hooks/hooks.json +55 -0
- package/package.json +58 -0
- package/scripts/base64-scan.sh +262 -0
- package/scripts/build-hooks.js +93 -0
- package/scripts/cap-removal-checklist.md +202 -0
- package/scripts/prompt-injection-scan.sh +199 -0
- package/scripts/run-tests.cjs +181 -0
- 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
|
+
};
|