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,987 @@
|
|
|
1
|
+
// @cap-feature(feature:F-025) Session Extract CLI — extract and analyze Claude Code session data
|
|
2
|
+
// @cap-decision Output is structured Markdown — LLM-consumable and human-readable without formatting deps.
|
|
3
|
+
// @cap-decision Session index is 1-based, most recent first — matches natural "last session = 1" mental model.
|
|
4
|
+
// @cap-constraint Zero external dependencies — uses only Node.js built-ins (fs, path, os).
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
// @cap-history(sessions:2, edits:15, since:2026-04-03, learned:2026-04-04) Frequently modified — 2 sessions, 15 edits
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
|
|
13
|
+
// --- Constants ---
|
|
14
|
+
|
|
15
|
+
const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
16
|
+
|
|
17
|
+
// --- Project Discovery ---
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Find the Claude Code project directory for a given working directory.
|
|
21
|
+
* Claude encodes the absolute path with - replacing /.
|
|
22
|
+
* @param {string} cwd - Current working directory
|
|
23
|
+
* @returns {string|null} Path to the project session directory
|
|
24
|
+
*/
|
|
25
|
+
function getProjectDir(cwd) {
|
|
26
|
+
if (!fs.existsSync(PROJECTS_DIR)) return null;
|
|
27
|
+
const encoded = cwd.replace(/\//g, '-');
|
|
28
|
+
const dir = path.join(PROJECTS_DIR, encoded);
|
|
29
|
+
if (fs.existsSync(dir)) return dir;
|
|
30
|
+
// Fallback: scan for matching suffix
|
|
31
|
+
for (const d of fs.readdirSync(PROJECTS_DIR)) {
|
|
32
|
+
if (d.endsWith(path.basename(cwd))) return path.join(PROJECTS_DIR, d);
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Find all Claude Code project directories for a path and its sub-projects.
|
|
39
|
+
* For monorepos: if cwd is /GoetzeInvest, finds sessions for GoetzeInvest,
|
|
40
|
+
* GoetzeInvest/GoetzeBooking, GoetzeInvest/EasySign, etc.
|
|
41
|
+
* @param {string} cwd - Current working directory (typically monorepo root)
|
|
42
|
+
* @returns {Array<{dir: string, name: string}>} Project directories with display names
|
|
43
|
+
*/
|
|
44
|
+
function getProjectDirsWithChildren(cwd) {
|
|
45
|
+
if (!fs.existsSync(PROJECTS_DIR)) return [];
|
|
46
|
+
const encoded = cwd.replace(/\//g, '-');
|
|
47
|
+
const results = [];
|
|
48
|
+
|
|
49
|
+
for (const d of fs.readdirSync(PROJECTS_DIR)) {
|
|
50
|
+
if (d.startsWith(encoded)) {
|
|
51
|
+
const fullPath = path.join(PROJECTS_DIR, d);
|
|
52
|
+
// Extract the sub-path relative to cwd for display
|
|
53
|
+
const suffix = d.slice(encoded.length);
|
|
54
|
+
const name = suffix ? suffix.replace(/^-/, '').replace(/-/g, '/') : '(root)';
|
|
55
|
+
results.push({ dir: fullPath, name });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return results;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get all session files across a project and its sub-projects (monorepo-aware).
|
|
64
|
+
* @param {string} cwd - Working directory
|
|
65
|
+
* @returns {{files: Array<{file: string, path: string, date: string|null, size: number, project: string}>, projects: string[]}}
|
|
66
|
+
*/
|
|
67
|
+
function getAllSessionFiles(cwd) {
|
|
68
|
+
const projectDirs = getProjectDirsWithChildren(cwd);
|
|
69
|
+
const allFiles = [];
|
|
70
|
+
const projects = [];
|
|
71
|
+
|
|
72
|
+
for (const { dir, name } of projectDirs) {
|
|
73
|
+
const files = getSessionFiles(dir);
|
|
74
|
+
if (files.length > 0) {
|
|
75
|
+
projects.push(`${name} (${files.length} sessions)`);
|
|
76
|
+
for (const f of files) {
|
|
77
|
+
allFiles.push({ ...f, project: name });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Sort all files by date, most recent first
|
|
83
|
+
allFiles.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
|
|
84
|
+
|
|
85
|
+
return { files: allFiles, projects };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- JSONL Parsing ---
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @typedef {Object} SessionMeta
|
|
92
|
+
* @property {string} id - Session UUID
|
|
93
|
+
* @property {string|null} timestamp - ISO timestamp
|
|
94
|
+
* @property {string|null} version - Claude Code version
|
|
95
|
+
* @property {string|null} branch - Git branch
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @typedef {Object} ParsedSession
|
|
100
|
+
* @property {SessionMeta} meta
|
|
101
|
+
* @property {Array<Object>} messages
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parse a JSONL session file into structured data.
|
|
106
|
+
* @param {string} filePath - Path to .jsonl file
|
|
107
|
+
* @returns {ParsedSession}
|
|
108
|
+
*/
|
|
109
|
+
function parseSession(filePath) {
|
|
110
|
+
const lines = fs.readFileSync(filePath, 'utf8').trim().split('\n');
|
|
111
|
+
const messages = [];
|
|
112
|
+
let sessionMeta = null;
|
|
113
|
+
|
|
114
|
+
for (const line of lines) {
|
|
115
|
+
try {
|
|
116
|
+
const obj = JSON.parse(line);
|
|
117
|
+
if (obj.sessionId && !sessionMeta) {
|
|
118
|
+
sessionMeta = { id: obj.sessionId, timestamp: null, version: obj.version || null, branch: obj.gitBranch || null };
|
|
119
|
+
}
|
|
120
|
+
if (sessionMeta && !sessionMeta.timestamp && obj.timestamp) {
|
|
121
|
+
sessionMeta.timestamp = obj.timestamp;
|
|
122
|
+
}
|
|
123
|
+
if (sessionMeta && !sessionMeta.branch && obj.gitBranch) {
|
|
124
|
+
sessionMeta.branch = obj.gitBranch;
|
|
125
|
+
}
|
|
126
|
+
if (obj.type === 'user' || obj.type === 'assistant') {
|
|
127
|
+
messages.push(obj);
|
|
128
|
+
}
|
|
129
|
+
} catch { /* skip malformed lines */ }
|
|
130
|
+
}
|
|
131
|
+
return { meta: sessionMeta || { id: 'unknown', timestamp: null, version: null, branch: null }, messages };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get sorted session file entries for a project directory.
|
|
136
|
+
* @param {string} projectDir
|
|
137
|
+
* @returns {Array<{file: string, path: string, date: string|null, size: number}>}
|
|
138
|
+
*/
|
|
139
|
+
function getSessionFiles(projectDir) {
|
|
140
|
+
return fs.readdirSync(projectDir)
|
|
141
|
+
.filter(f => f.endsWith('.jsonl') && !f.includes('/'))
|
|
142
|
+
.map(f => {
|
|
143
|
+
const fp = path.join(projectDir, f);
|
|
144
|
+
const stat = fs.statSync(fp);
|
|
145
|
+
// Scan first few lines for a timestamp — session headers (permission-mode,
|
|
146
|
+
// file-history-snapshot) don't carry one, but the first user message does.
|
|
147
|
+
let ts = null;
|
|
148
|
+
const fd = fs.openSync(fp, 'r');
|
|
149
|
+
try {
|
|
150
|
+
const buf = Buffer.alloc(4096);
|
|
151
|
+
const bytesRead = fs.readSync(fd, buf, 0, 4096, 0);
|
|
152
|
+
const lines = buf.toString('utf8', 0, bytesRead).split('\n');
|
|
153
|
+
for (const line of lines) {
|
|
154
|
+
if (!line) continue;
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(line);
|
|
157
|
+
if (parsed.timestamp) { ts = parsed.timestamp; break; }
|
|
158
|
+
} catch { /* partial line or parse error — skip */ }
|
|
159
|
+
}
|
|
160
|
+
} finally {
|
|
161
|
+
fs.closeSync(fd);
|
|
162
|
+
}
|
|
163
|
+
// Fallback: use file mtime if no timestamp found in content
|
|
164
|
+
if (!ts) ts = stat.mtime.toISOString();
|
|
165
|
+
return { file: f, path: fp, date: ts, size: stat.size };
|
|
166
|
+
})
|
|
167
|
+
.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- Shared Patterns ---
|
|
171
|
+
|
|
172
|
+
/** Decision-related regex patterns for extracting key decisions from assistant messages. */
|
|
173
|
+
const DECISION_PATTERNS = [
|
|
174
|
+
/(?:decided|decision|chose|choice|approach|strategy|trade-?off|rationale|conclusion)/i,
|
|
175
|
+
/(?:the problem|the issue|root cause|the fix|solution|workaround)/i,
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
// --- Content Extraction Helpers ---
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Extract text content from a message object.
|
|
182
|
+
* @param {Object} msg
|
|
183
|
+
* @returns {string}
|
|
184
|
+
*/
|
|
185
|
+
function extractTextContent(msg) {
|
|
186
|
+
const content = msg.message?.content;
|
|
187
|
+
if (!content) return '';
|
|
188
|
+
if (typeof content === 'string') return content;
|
|
189
|
+
if (Array.isArray(content)) {
|
|
190
|
+
return content
|
|
191
|
+
.filter(c => c.type === 'text')
|
|
192
|
+
.map(c => c.text || '')
|
|
193
|
+
.join('\n');
|
|
194
|
+
}
|
|
195
|
+
return '';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Extract tool use records from a message.
|
|
200
|
+
* @param {Object} msg
|
|
201
|
+
* @returns {Array<{tool: string, input: Object}>}
|
|
202
|
+
*/
|
|
203
|
+
function extractToolUses(msg) {
|
|
204
|
+
const content = msg.message?.content;
|
|
205
|
+
if (!Array.isArray(content)) return [];
|
|
206
|
+
return content
|
|
207
|
+
.filter(c => c.type === 'tool_use')
|
|
208
|
+
.map(c => ({ tool: c.name, input: c.input }));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- Formatting Helpers ---
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Format byte size for display.
|
|
215
|
+
* @param {number} bytes
|
|
216
|
+
* @returns {string}
|
|
217
|
+
*/
|
|
218
|
+
function formatSize(bytes) {
|
|
219
|
+
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + 'MB';
|
|
220
|
+
if (bytes >= 1024) return (bytes / 1024).toFixed(0) + 'KB';
|
|
221
|
+
return bytes + 'B';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Format ISO timestamp for display.
|
|
226
|
+
* @param {string|null} ts
|
|
227
|
+
* @returns {string}
|
|
228
|
+
*/
|
|
229
|
+
function formatDate(ts) {
|
|
230
|
+
if (!ts) return 'unknown';
|
|
231
|
+
const d = new Date(ts);
|
|
232
|
+
return d.toISOString().replace('T', ' ').substring(0, 16);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Strip system-reminder and other XML tags from text.
|
|
237
|
+
* @param {string} text
|
|
238
|
+
* @returns {string}
|
|
239
|
+
*/
|
|
240
|
+
function stripSystemTags(text) {
|
|
241
|
+
return text
|
|
242
|
+
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
|
|
243
|
+
.replace(/<[^>]+>/g, '')
|
|
244
|
+
.trim();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- Extract Modes ---
|
|
248
|
+
|
|
249
|
+
// @cap-todo(ref:F-025:AC-1) cap extract list — display all sessions with date, size, turns, preview
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* List all sessions with metadata.
|
|
253
|
+
* @param {string} projectDir
|
|
254
|
+
* @returns {string} Formatted output
|
|
255
|
+
*/
|
|
256
|
+
function listSessions(projectDir) {
|
|
257
|
+
const files = getSessionFiles(projectDir);
|
|
258
|
+
const rows = files.map(f => {
|
|
259
|
+
const lines = fs.readFileSync(f.path, 'utf8').trim().split('\n');
|
|
260
|
+
let firstMsg = '';
|
|
261
|
+
let userCount = 0;
|
|
262
|
+
let assistantCount = 0;
|
|
263
|
+
|
|
264
|
+
for (const line of lines.slice(0, 10)) {
|
|
265
|
+
try {
|
|
266
|
+
const obj = JSON.parse(line);
|
|
267
|
+
if (!firstMsg && obj.type === 'user') {
|
|
268
|
+
const text = extractTextContent(obj);
|
|
269
|
+
firstMsg = stripSystemTags(text).substring(0, 80) || '(command)';
|
|
270
|
+
}
|
|
271
|
+
} catch { /* ignore */ }
|
|
272
|
+
}
|
|
273
|
+
for (const line of lines) {
|
|
274
|
+
if (line.includes('"type":"user"')) userCount++;
|
|
275
|
+
else if (line.includes('"type":"assistant"')) assistantCount++;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
date: f.date,
|
|
280
|
+
size: f.size,
|
|
281
|
+
turns: userCount,
|
|
282
|
+
responses: assistantCount,
|
|
283
|
+
preview: firstMsg,
|
|
284
|
+
};
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const out = [];
|
|
288
|
+
out.push(`# Sessions (${path.basename(projectDir)})\n`);
|
|
289
|
+
out.push('| # | Date | Size | Turns | Preview |');
|
|
290
|
+
out.push('|---|------|------|-------|---------|');
|
|
291
|
+
rows.forEach((r, i) => {
|
|
292
|
+
out.push(`| ${i + 1} | ${formatDate(r.date)} | ${formatSize(r.size)} | ${r.turns} | ${r.preview} |`);
|
|
293
|
+
});
|
|
294
|
+
out.push(`\n*${rows.length} sessions total*`);
|
|
295
|
+
return out.join('\n');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// @cap-todo(ref:F-025:AC-2) cap extract stats — token counts, tool distribution, duration, turns
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Extract statistics from a single session.
|
|
302
|
+
* @param {string} filePath
|
|
303
|
+
* @returns {string} Formatted Markdown
|
|
304
|
+
*/
|
|
305
|
+
function extractStats(filePath) {
|
|
306
|
+
const { meta, messages } = parseSession(filePath);
|
|
307
|
+
const toolCounts = {};
|
|
308
|
+
let userTurns = 0;
|
|
309
|
+
let assistantTurns = 0;
|
|
310
|
+
let totalInputTokens = 0;
|
|
311
|
+
let totalOutputTokens = 0;
|
|
312
|
+
let firstTimestamp = null;
|
|
313
|
+
let lastTimestamp = null;
|
|
314
|
+
|
|
315
|
+
// Re-read raw lines for token data (not just user/assistant messages)
|
|
316
|
+
const rawLines = fs.readFileSync(filePath, 'utf8').trim().split('\n');
|
|
317
|
+
for (const line of rawLines) {
|
|
318
|
+
try {
|
|
319
|
+
const obj = JSON.parse(line);
|
|
320
|
+
if (obj.timestamp) {
|
|
321
|
+
if (!firstTimestamp) firstTimestamp = obj.timestamp;
|
|
322
|
+
lastTimestamp = obj.timestamp;
|
|
323
|
+
}
|
|
324
|
+
if (obj.message?.usage) {
|
|
325
|
+
totalInputTokens += obj.message.usage.input_tokens || 0;
|
|
326
|
+
totalOutputTokens += obj.message.usage.output_tokens || 0;
|
|
327
|
+
}
|
|
328
|
+
} catch { /* ignore */ }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
for (const msg of messages) {
|
|
332
|
+
if (msg.type === 'user') userTurns++;
|
|
333
|
+
if (msg.type === 'assistant') {
|
|
334
|
+
assistantTurns++;
|
|
335
|
+
for (const tool of extractToolUses(msg)) {
|
|
336
|
+
toolCounts[tool.tool] = (toolCounts[tool.tool] || 0) + 1;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let duration = 'unknown';
|
|
342
|
+
if (firstTimestamp && lastTimestamp) {
|
|
343
|
+
const ms = new Date(lastTimestamp) - new Date(firstTimestamp);
|
|
344
|
+
const mins = Math.round(ms / 60000);
|
|
345
|
+
duration = mins < 60 ? `${mins}m` : `${Math.floor(mins / 60)}h ${mins % 60}m`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const out = [];
|
|
349
|
+
out.push(`# Session Stats`);
|
|
350
|
+
out.push(`> Date: ${formatDate(meta.timestamp)} | Branch: ${meta.branch || 'unknown'}\n`);
|
|
351
|
+
out.push(`| Metric | Value |`);
|
|
352
|
+
out.push(`|--------|-------|`);
|
|
353
|
+
out.push(`| Duration | ${duration} |`);
|
|
354
|
+
out.push(`| User turns | ${userTurns} |`);
|
|
355
|
+
out.push(`| Assistant turns | ${assistantTurns} |`);
|
|
356
|
+
out.push(`| Input tokens | ${totalInputTokens.toLocaleString()} |`);
|
|
357
|
+
out.push(`| Output tokens | ${totalOutputTokens.toLocaleString()} |`);
|
|
358
|
+
out.push(`| Total tokens | ${(totalInputTokens + totalOutputTokens).toLocaleString()} |`);
|
|
359
|
+
out.push('');
|
|
360
|
+
|
|
361
|
+
const sortedTools = Object.entries(toolCounts).sort((a, b) => b[1] - a[1]);
|
|
362
|
+
if (sortedTools.length > 0) {
|
|
363
|
+
out.push('## Tool Usage\n');
|
|
364
|
+
out.push('| Tool | Count |');
|
|
365
|
+
out.push('|------|-------|');
|
|
366
|
+
for (const [tool, count] of sortedTools) {
|
|
367
|
+
out.push(`| ${tool} | ${count} |`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return out.join('\n');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// @cap-todo(ref:F-025:AC-3) cap extract conversation — user/assistant dialogue as Markdown
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Extract conversation from a session.
|
|
378
|
+
* @param {string} filePath
|
|
379
|
+
* @returns {string} Formatted Markdown
|
|
380
|
+
*/
|
|
381
|
+
function extractConversation(filePath) {
|
|
382
|
+
const { meta, messages } = parseSession(filePath);
|
|
383
|
+
const out = [];
|
|
384
|
+
out.push(`# Session Conversation`);
|
|
385
|
+
out.push(`> Date: ${formatDate(meta.timestamp)} | Branch: ${meta.branch || 'unknown'}\n`);
|
|
386
|
+
|
|
387
|
+
let turnNum = 0;
|
|
388
|
+
for (const msg of messages) {
|
|
389
|
+
if (msg.isSidechain) continue;
|
|
390
|
+
const text = extractTextContent(msg);
|
|
391
|
+
if (!text.trim()) continue;
|
|
392
|
+
const clean = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
|
|
393
|
+
if (!clean) continue;
|
|
394
|
+
|
|
395
|
+
if (msg.type === 'user') {
|
|
396
|
+
turnNum++;
|
|
397
|
+
const userText = stripSystemTags(clean);
|
|
398
|
+
if (!userText) continue;
|
|
399
|
+
out.push(`## Turn ${turnNum}`);
|
|
400
|
+
out.push(`**User:** ${userText}\n`);
|
|
401
|
+
} else if (msg.type === 'assistant') {
|
|
402
|
+
out.push(clean);
|
|
403
|
+
out.push('');
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return out.join('\n');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// @cap-todo(ref:F-025:AC-4) cap extract code — all file writes and edits grouped by file
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Extract code changes from a session.
|
|
413
|
+
* @param {string} filePath
|
|
414
|
+
* @returns {string} Formatted Markdown
|
|
415
|
+
*/
|
|
416
|
+
function extractCode(filePath) {
|
|
417
|
+
const { meta, messages } = parseSession(filePath);
|
|
418
|
+
const out = [];
|
|
419
|
+
out.push(`# Session Code Changes`);
|
|
420
|
+
out.push(`> Date: ${formatDate(meta.timestamp)} | Branch: ${meta.branch || 'unknown'}\n`);
|
|
421
|
+
|
|
422
|
+
const fileChanges = {};
|
|
423
|
+
|
|
424
|
+
for (const msg of messages) {
|
|
425
|
+
if (msg.type !== 'assistant') continue;
|
|
426
|
+
for (const tool of extractToolUses(msg)) {
|
|
427
|
+
if (tool.tool !== 'Write' && tool.tool !== 'Edit' && tool.tool !== 'MultiEdit') continue;
|
|
428
|
+
const changedFilePath = tool.input?.file_path || tool.input?.filePath || 'unknown';
|
|
429
|
+
if (!fileChanges[changedFilePath]) fileChanges[changedFilePath] = [];
|
|
430
|
+
fileChanges[changedFilePath].push({
|
|
431
|
+
op: tool.tool === 'Write' ? 'create/overwrite' : 'edit',
|
|
432
|
+
tool: tool.tool,
|
|
433
|
+
input: tool.input,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const paths = Object.keys(fileChanges);
|
|
439
|
+
if (paths.length === 0) {
|
|
440
|
+
out.push('_No code changes in this session._');
|
|
441
|
+
return out.join('\n');
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
out.push(`**${paths.length} files changed:**\n`);
|
|
445
|
+
for (const fp of paths.sort()) {
|
|
446
|
+
const changes = fileChanges[fp];
|
|
447
|
+
out.push(`### \`${fp}\` (${changes.length} ${changes.length === 1 ? 'change' : 'changes'})`);
|
|
448
|
+
for (const c of changes) {
|
|
449
|
+
if (c.op === 'create/overwrite') {
|
|
450
|
+
const preview = (c.input?.content || '').substring(0, 300);
|
|
451
|
+
out.push(`**${c.op}**`);
|
|
452
|
+
out.push('```');
|
|
453
|
+
out.push(preview + (c.input?.content?.length > 300 ? '\n// ... truncated' : ''));
|
|
454
|
+
out.push('```');
|
|
455
|
+
} else {
|
|
456
|
+
out.push(`**${c.op}**`);
|
|
457
|
+
out.push('```diff');
|
|
458
|
+
out.push('- ' + (c.input?.old_string || '').substring(0, 200));
|
|
459
|
+
out.push('+ ' + (c.input?.new_string || '').substring(0, 200));
|
|
460
|
+
out.push('```');
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
out.push('');
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
out.push(`---\n*${paths.length} files, ${Object.values(fileChanges).reduce((s, c) => s + c.length, 0)} changes total*`);
|
|
467
|
+
return out.join('\n');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// @cap-todo(ref:F-025:AC-5) cap extract summary — structured Markdown for LLM consumption
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Extract a structured summary of a session (raw data for LLM consumption).
|
|
474
|
+
* @param {string} filePath
|
|
475
|
+
* @returns {string} Formatted Markdown
|
|
476
|
+
*/
|
|
477
|
+
function extractSummary(filePath) {
|
|
478
|
+
const { meta, messages } = parseSession(filePath);
|
|
479
|
+
const out = [];
|
|
480
|
+
out.push(`# Session Summary`);
|
|
481
|
+
out.push(`> Date: ${formatDate(meta.timestamp)} | Branch: ${meta.branch || 'unknown'}\n`);
|
|
482
|
+
|
|
483
|
+
const decisions = [];
|
|
484
|
+
|
|
485
|
+
// Files changed
|
|
486
|
+
const filesChanged = new Set();
|
|
487
|
+
|
|
488
|
+
// Features referenced
|
|
489
|
+
const featuresReferenced = new Set();
|
|
490
|
+
const featurePattern = /F-\d{3}/g;
|
|
491
|
+
|
|
492
|
+
// Key outcomes: last assistant message
|
|
493
|
+
let lastAssistantText = '';
|
|
494
|
+
|
|
495
|
+
let userTurns = 0;
|
|
496
|
+
let assistantTurns = 0;
|
|
497
|
+
|
|
498
|
+
for (const msg of messages) {
|
|
499
|
+
if (msg.isSidechain) continue;
|
|
500
|
+
|
|
501
|
+
if (msg.type === 'user') userTurns++;
|
|
502
|
+
if (msg.type === 'assistant') {
|
|
503
|
+
assistantTurns++;
|
|
504
|
+
const text = extractTextContent(msg);
|
|
505
|
+
if (text.trim()) lastAssistantText = text;
|
|
506
|
+
|
|
507
|
+
// Collect decisions
|
|
508
|
+
const clean = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
|
|
509
|
+
const sentences = clean.split(/(?<=[.!?\n])\s+/);
|
|
510
|
+
for (const sentence of sentences) {
|
|
511
|
+
if (sentence.length >= 20 && sentence.length <= 500) {
|
|
512
|
+
if (DECISION_PATTERNS.some(p => p.test(sentence))) {
|
|
513
|
+
decisions.push(sentence.trim());
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Collect file changes
|
|
519
|
+
for (const tool of extractToolUses(msg)) {
|
|
520
|
+
if (tool.tool === 'Write' || tool.tool === 'Edit' || tool.tool === 'MultiEdit') {
|
|
521
|
+
filesChanged.add(tool.input?.file_path || tool.input?.filePath || 'unknown');
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Collect feature references
|
|
526
|
+
const matches = clean.match(featurePattern);
|
|
527
|
+
if (matches) matches.forEach(m => featuresReferenced.add(m));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Overview
|
|
532
|
+
out.push(`## Overview`);
|
|
533
|
+
out.push(`- **Turns:** ${userTurns} user / ${assistantTurns} assistant`);
|
|
534
|
+
out.push(`- **Files changed:** ${filesChanged.size}`);
|
|
535
|
+
out.push(`- **Features referenced:** ${featuresReferenced.size > 0 ? [...featuresReferenced].join(', ') : 'none'}`);
|
|
536
|
+
out.push('');
|
|
537
|
+
|
|
538
|
+
// Files
|
|
539
|
+
if (filesChanged.size > 0) {
|
|
540
|
+
out.push(`## Files Changed`);
|
|
541
|
+
for (const f of [...filesChanged].sort()) {
|
|
542
|
+
out.push(`- \`${f}\``);
|
|
543
|
+
}
|
|
544
|
+
out.push('');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Decisions
|
|
548
|
+
if (decisions.length > 0) {
|
|
549
|
+
out.push(`## Decisions`);
|
|
550
|
+
const unique = [...new Set(decisions)].slice(0, 20);
|
|
551
|
+
for (const d of unique) {
|
|
552
|
+
out.push(`- ${d}`);
|
|
553
|
+
}
|
|
554
|
+
out.push('');
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return out.join('\n');
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// --- Cross-Session Aggregation (F-026) ---
|
|
561
|
+
|
|
562
|
+
// @cap-feature(feature:F-026) Cross-Session Aggregation — decisions, hotspots, timeline, cost across all sessions
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Filter session files by --since date.
|
|
566
|
+
* @param {Array<{file: string, path: string, date: string|null, size: number}>} files
|
|
567
|
+
* @param {string|null} sinceDate - YYYY-MM-DD or null
|
|
568
|
+
* @returns {Array}
|
|
569
|
+
*/
|
|
570
|
+
function filterBySince(files, sinceDate) {
|
|
571
|
+
if (!sinceDate) return files;
|
|
572
|
+
// String comparison works because ISO 8601 timestamps sort lexicographically
|
|
573
|
+
return files.filter(f => f.date && f.date >= sinceDate);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Parse --since flag from args array.
|
|
578
|
+
* @param {string[]} args
|
|
579
|
+
* @returns {{sinceDate: string|null, cleanArgs: string[]}}
|
|
580
|
+
*/
|
|
581
|
+
function parseSinceFlag(args) {
|
|
582
|
+
const idx = args.indexOf('--since');
|
|
583
|
+
if (idx === -1 || idx + 1 >= args.length) return { sinceDate: null, cleanArgs: args };
|
|
584
|
+
const sinceDate = args[idx + 1];
|
|
585
|
+
const cleanArgs = [...args.slice(0, idx), ...args.slice(idx + 2)];
|
|
586
|
+
return { sinceDate, cleanArgs };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// @cap-todo(ref:F-026:AC-1) cap extract decisions --all — decisions across all sessions
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Aggregate decisions across all sessions.
|
|
593
|
+
* @param {string} projectDir
|
|
594
|
+
* @param {string|null} sinceDate
|
|
595
|
+
* @returns {string}
|
|
596
|
+
*/
|
|
597
|
+
function extractDecisionsAll(projectDir, sinceDate) {
|
|
598
|
+
const files = filterBySince(getSessionFiles(projectDir), sinceDate);
|
|
599
|
+
const out = [];
|
|
600
|
+
out.push('# Decisions Across Sessions');
|
|
601
|
+
if (sinceDate) out.push(`> Filtered: since ${sinceDate}`);
|
|
602
|
+
out.push(`> Sessions scanned: ${files.length}\n`);
|
|
603
|
+
|
|
604
|
+
let totalDecisions = 0;
|
|
605
|
+
|
|
606
|
+
for (const f of files) {
|
|
607
|
+
const { meta, messages } = parseSession(f.path);
|
|
608
|
+
const sessionDecisions = [];
|
|
609
|
+
|
|
610
|
+
for (const msg of messages) {
|
|
611
|
+
if (msg.type !== 'assistant' || msg.isSidechain) continue;
|
|
612
|
+
const text = extractTextContent(msg).replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
|
|
613
|
+
const sentences = text.split(/(?<=[.!?\n])\s+/);
|
|
614
|
+
for (const sentence of sentences) {
|
|
615
|
+
if (sentence.length >= 20 && sentence.length <= 500) {
|
|
616
|
+
if (DECISION_PATTERNS.some(p => p.test(sentence))) {
|
|
617
|
+
sessionDecisions.push(sentence.trim());
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (sessionDecisions.length > 0) {
|
|
624
|
+
out.push(`## ${formatDate(meta.timestamp)} (${meta.branch || 'unknown'})`);
|
|
625
|
+
const unique = [...new Set(sessionDecisions)];
|
|
626
|
+
for (const d of unique) {
|
|
627
|
+
out.push(`- ${d}`);
|
|
628
|
+
}
|
|
629
|
+
out.push('');
|
|
630
|
+
totalDecisions += unique.length;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (totalDecisions === 0) {
|
|
635
|
+
out.push('_No decisions found._');
|
|
636
|
+
} else {
|
|
637
|
+
out.push(`---\n*${totalDecisions} decisions across ${files.length} sessions*`);
|
|
638
|
+
}
|
|
639
|
+
return out.join('\n');
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// @cap-todo(ref:F-026:AC-2) cap extract hotspots — files ranked by edit frequency
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Rank files by edit frequency across all sessions.
|
|
646
|
+
* @param {string} projectDir
|
|
647
|
+
* @param {string|null} sinceDate
|
|
648
|
+
* @returns {string}
|
|
649
|
+
*/
|
|
650
|
+
function extractHotspots(projectDir, sinceDate) {
|
|
651
|
+
const files = filterBySince(getSessionFiles(projectDir), sinceDate);
|
|
652
|
+
const out = [];
|
|
653
|
+
out.push('# File Hotspots');
|
|
654
|
+
if (sinceDate) out.push(`> Filtered: since ${sinceDate}`);
|
|
655
|
+
out.push(`> Sessions scanned: ${files.length}\n`);
|
|
656
|
+
|
|
657
|
+
const fileCounts = {}; // path -> { edits: N, sessions: Set }
|
|
658
|
+
|
|
659
|
+
for (const f of files) {
|
|
660
|
+
const { messages } = parseSession(f.path);
|
|
661
|
+
const sessionFiles = new Set();
|
|
662
|
+
|
|
663
|
+
for (const msg of messages) {
|
|
664
|
+
if (msg.type !== 'assistant') continue;
|
|
665
|
+
for (const tool of extractToolUses(msg)) {
|
|
666
|
+
if (tool.tool !== 'Write' && tool.tool !== 'Edit' && tool.tool !== 'MultiEdit') continue;
|
|
667
|
+
const fp = tool.input?.file_path || tool.input?.filePath || 'unknown';
|
|
668
|
+
if (!fileCounts[fp]) fileCounts[fp] = { edits: 0, sessions: new Set() };
|
|
669
|
+
fileCounts[fp].edits++;
|
|
670
|
+
sessionFiles.add(fp);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
for (const fp of sessionFiles) {
|
|
675
|
+
fileCounts[fp].sessions.add(f.file);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const sorted = Object.entries(fileCounts)
|
|
680
|
+
.map(([fp, data]) => ({ path: fp, edits: data.edits, sessions: data.sessions.size }))
|
|
681
|
+
.sort((a, b) => b.edits - a.edits);
|
|
682
|
+
|
|
683
|
+
if (sorted.length === 0) {
|
|
684
|
+
out.push('_No file edits found._');
|
|
685
|
+
return out.join('\n');
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
out.push('| Rank | File | Edits | Sessions |');
|
|
689
|
+
out.push('|------|------|-------|----------|');
|
|
690
|
+
sorted.slice(0, 30).forEach((entry, i) => {
|
|
691
|
+
out.push(`| ${i + 1} | \`${entry.path}\` | ${entry.edits} | ${entry.sessions} |`);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
out.push(`\n*${sorted.length} files changed across ${files.length} sessions*`);
|
|
695
|
+
return out.join('\n');
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// @cap-todo(ref:F-026:AC-3) cap extract timeline — chronological view across sessions
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Chronological timeline of work across sessions.
|
|
702
|
+
* @param {string} projectDir
|
|
703
|
+
* @param {string|null} sinceDate
|
|
704
|
+
* @returns {string}
|
|
705
|
+
*/
|
|
706
|
+
function extractTimeline(projectDir, sinceDate) {
|
|
707
|
+
const files = filterBySince(getSessionFiles(projectDir), sinceDate);
|
|
708
|
+
const out = [];
|
|
709
|
+
out.push('# Session Timeline');
|
|
710
|
+
if (sinceDate) out.push(`> Filtered: since ${sinceDate}`);
|
|
711
|
+
out.push(`> Sessions: ${files.length}\n`);
|
|
712
|
+
|
|
713
|
+
// Reverse to chronological order (oldest first)
|
|
714
|
+
const chronological = [...files].reverse();
|
|
715
|
+
|
|
716
|
+
for (const f of chronological) {
|
|
717
|
+
const { meta, messages } = parseSession(f.path);
|
|
718
|
+
|
|
719
|
+
// Collect changed files
|
|
720
|
+
const changedFiles = new Set();
|
|
721
|
+
const features = new Set();
|
|
722
|
+
const featurePattern = /F-\d{3}/g;
|
|
723
|
+
let firstUserMsg = '';
|
|
724
|
+
|
|
725
|
+
for (const msg of messages) {
|
|
726
|
+
if (msg.type === 'user' && !firstUserMsg) {
|
|
727
|
+
firstUserMsg = stripSystemTags(extractTextContent(msg)).substring(0, 100);
|
|
728
|
+
}
|
|
729
|
+
if (msg.type === 'assistant') {
|
|
730
|
+
for (const tool of extractToolUses(msg)) {
|
|
731
|
+
if (tool.tool === 'Write' || tool.tool === 'Edit' || tool.tool === 'MultiEdit') {
|
|
732
|
+
changedFiles.add(tool.input?.file_path || tool.input?.filePath || 'unknown');
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const text = extractTextContent(msg);
|
|
736
|
+
const matches = text.match(featurePattern);
|
|
737
|
+
if (matches) matches.forEach(m => features.add(m));
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const userTurns = messages.filter(m => m.type === 'user').length;
|
|
742
|
+
|
|
743
|
+
out.push(`### ${formatDate(meta.timestamp)} — ${meta.branch || 'unknown'}`);
|
|
744
|
+
out.push(`- **Topic:** ${firstUserMsg || '(unknown)'}`);
|
|
745
|
+
out.push(`- **Turns:** ${userTurns}`);
|
|
746
|
+
if (features.size > 0) out.push(`- **Features:** ${[...features].join(', ')}`);
|
|
747
|
+
out.push(`- **Files changed:** ${changedFiles.size > 0 ? [...changedFiles].slice(0, 5).map(f => `\`${f}\``).join(', ') + (changedFiles.size > 5 ? ` +${changedFiles.size - 5} more` : '') : 'none'}`);
|
|
748
|
+
out.push('');
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return out.join('\n');
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// @cap-todo(ref:F-026:AC-4) cap extract cost — token usage aggregated across sessions
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Aggregate token usage and estimate cost across sessions.
|
|
758
|
+
* @param {string} projectDir
|
|
759
|
+
* @param {string|null} sinceDate
|
|
760
|
+
* @returns {string}
|
|
761
|
+
*/
|
|
762
|
+
function extractCost(projectDir, sinceDate) {
|
|
763
|
+
const files = filterBySince(getSessionFiles(projectDir), sinceDate);
|
|
764
|
+
const out = [];
|
|
765
|
+
out.push('# Token Cost Report');
|
|
766
|
+
if (sinceDate) out.push(`> Filtered: since ${sinceDate}`);
|
|
767
|
+
out.push(`> Sessions: ${files.length}\n`);
|
|
768
|
+
|
|
769
|
+
// Default rates (USD per 1M tokens) — Opus pricing as of 2025-05
|
|
770
|
+
const inputRate = 15;
|
|
771
|
+
const outputRate = 75;
|
|
772
|
+
|
|
773
|
+
let grandInputTokens = 0;
|
|
774
|
+
let grandOutputTokens = 0;
|
|
775
|
+
const rows = [];
|
|
776
|
+
|
|
777
|
+
for (const f of files) {
|
|
778
|
+
const rawLines = fs.readFileSync(f.path, 'utf8').trim().split('\n');
|
|
779
|
+
let inputTokens = 0;
|
|
780
|
+
let outputTokens = 0;
|
|
781
|
+
|
|
782
|
+
for (const line of rawLines) {
|
|
783
|
+
try {
|
|
784
|
+
const obj = JSON.parse(line);
|
|
785
|
+
if (obj.message?.usage) {
|
|
786
|
+
inputTokens += obj.message.usage.input_tokens || 0;
|
|
787
|
+
outputTokens += obj.message.usage.output_tokens || 0;
|
|
788
|
+
}
|
|
789
|
+
} catch { /* ignore */ }
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
grandInputTokens += inputTokens;
|
|
793
|
+
grandOutputTokens += outputTokens;
|
|
794
|
+
|
|
795
|
+
const total = inputTokens + outputTokens;
|
|
796
|
+
const cost = (inputTokens / 1e6) * inputRate + (outputTokens / 1e6) * outputRate;
|
|
797
|
+
|
|
798
|
+
rows.push({
|
|
799
|
+
date: f.date,
|
|
800
|
+
input: inputTokens,
|
|
801
|
+
output: outputTokens,
|
|
802
|
+
total,
|
|
803
|
+
cost,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const grandTotal = grandInputTokens + grandOutputTokens;
|
|
808
|
+
const grandCost = (grandInputTokens / 1e6) * inputRate + (grandOutputTokens / 1e6) * outputRate;
|
|
809
|
+
|
|
810
|
+
out.push('## Per Session\n');
|
|
811
|
+
out.push('| # | Date | Input | Output | Total | Est. Cost |');
|
|
812
|
+
out.push('|---|------|-------|--------|-------|-----------|');
|
|
813
|
+
rows.forEach((r, i) => {
|
|
814
|
+
out.push(`| ${i + 1} | ${formatDate(r.date)} | ${r.input.toLocaleString()} | ${r.output.toLocaleString()} | ${r.total.toLocaleString()} | $${r.cost.toFixed(2)} |`);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
out.push('');
|
|
818
|
+
out.push('## Totals\n');
|
|
819
|
+
out.push(`| Metric | Value |`);
|
|
820
|
+
out.push(`|--------|-------|`);
|
|
821
|
+
out.push(`| Input tokens | ${grandInputTokens.toLocaleString()} |`);
|
|
822
|
+
out.push(`| Output tokens | ${grandOutputTokens.toLocaleString()} |`);
|
|
823
|
+
out.push(`| Total tokens | ${grandTotal.toLocaleString()} |`);
|
|
824
|
+
out.push(`| Estimated cost | $${grandCost.toFixed(2)} |`);
|
|
825
|
+
out.push(`\n*Rates: $${inputRate}/1M input, $${outputRate}/1M output (Opus)*`);
|
|
826
|
+
|
|
827
|
+
return out.join('\n');
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// @cap-todo(ref:F-025:AC-8) Support session references by numeric index and date-based lookup
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Resolve a session reference to a file path.
|
|
834
|
+
* Supports numeric index (1 = most recent) or date string (YYYY-MM-DD).
|
|
835
|
+
* @param {string} projectDir
|
|
836
|
+
* @param {string} ref - Session reference (number or date)
|
|
837
|
+
* @returns {string} File path to session
|
|
838
|
+
*/
|
|
839
|
+
function resolveSessionRef(projectDir, ref) {
|
|
840
|
+
const files = getSessionFiles(projectDir);
|
|
841
|
+
if (files.length === 0) {
|
|
842
|
+
throw new Error('No sessions found.');
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Numeric index
|
|
846
|
+
const num = parseInt(ref, 10);
|
|
847
|
+
if (!isNaN(num) && String(num) === ref) {
|
|
848
|
+
if (num < 1 || num > files.length) {
|
|
849
|
+
throw new Error(`Session #${num} not found. ${files.length} sessions available.`);
|
|
850
|
+
}
|
|
851
|
+
return files[num - 1].path;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Date-based lookup (YYYY-MM-DD) — find first session matching that date
|
|
855
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(ref)) {
|
|
856
|
+
const match = files.find(f => f.date && f.date.startsWith(ref));
|
|
857
|
+
if (!match) {
|
|
858
|
+
throw new Error(`No session found for date ${ref}.`);
|
|
859
|
+
}
|
|
860
|
+
return match.path;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
throw new Error(`Invalid session reference: "${ref}". Use a number (1 = most recent) or date (YYYY-MM-DD).`);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// @cap-todo(ref:F-025:AC-7) Register extract as subcommand with help text and error handling
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Run the extract CLI with given arguments.
|
|
870
|
+
* @param {string[]} args - CLI arguments after "extract"
|
|
871
|
+
* @param {string} [cwd] - Working directory override
|
|
872
|
+
* @returns {string} Output text
|
|
873
|
+
*/
|
|
874
|
+
function run(args, cwd) {
|
|
875
|
+
const workDir = cwd || process.cwd();
|
|
876
|
+
const { sinceDate, cleanArgs } = parseSinceFlag(args);
|
|
877
|
+
const command = cleanArgs[0] || 'help';
|
|
878
|
+
|
|
879
|
+
if (command === 'help' || command === '--help' || command === '-h') {
|
|
880
|
+
return getHelp();
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const projectDir = getProjectDir(workDir);
|
|
884
|
+
if (!projectDir) {
|
|
885
|
+
throw new Error(`No Claude Code sessions found for: ${workDir}`);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (command === 'list' || command === 'ls') {
|
|
889
|
+
return listSessions(projectDir);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
if (command === 'stats') {
|
|
893
|
+
const ref = cleanArgs[1];
|
|
894
|
+
if (!ref) throw new Error('Usage: cap extract stats <session#>');
|
|
895
|
+
const filePath = resolveSessionRef(projectDir, ref);
|
|
896
|
+
return extractStats(filePath);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Cross-session commands (F-026)
|
|
900
|
+
if (command === 'decisions') {
|
|
901
|
+
return extractDecisionsAll(projectDir, sinceDate);
|
|
902
|
+
}
|
|
903
|
+
if (command === 'hotspots') {
|
|
904
|
+
return extractHotspots(projectDir, sinceDate);
|
|
905
|
+
}
|
|
906
|
+
if (command === 'timeline') {
|
|
907
|
+
return extractTimeline(projectDir, sinceDate);
|
|
908
|
+
}
|
|
909
|
+
if (command === 'cost') {
|
|
910
|
+
return extractCost(projectDir, sinceDate);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Session extraction modes: cap extract <ref> <mode>
|
|
914
|
+
const ref = cleanArgs[0];
|
|
915
|
+
const mode = cleanArgs[1] || 'conversation';
|
|
916
|
+
const filePath = resolveSessionRef(projectDir, ref);
|
|
917
|
+
|
|
918
|
+
switch (mode) {
|
|
919
|
+
case 'conversation': return extractConversation(filePath);
|
|
920
|
+
case 'code': return extractCode(filePath);
|
|
921
|
+
case 'summary': return extractSummary(filePath);
|
|
922
|
+
case 'stats': return extractStats(filePath);
|
|
923
|
+
default:
|
|
924
|
+
throw new Error(`Unknown mode: "${mode}". Available: conversation, code, summary, stats`);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Get help text.
|
|
930
|
+
* @returns {string}
|
|
931
|
+
*/
|
|
932
|
+
function getHelp() {
|
|
933
|
+
return `cap extract — Extract and analyze Claude Code sessions
|
|
934
|
+
|
|
935
|
+
Usage:
|
|
936
|
+
Single session:
|
|
937
|
+
cap extract list List all sessions
|
|
938
|
+
cap extract stats <ref> Session statistics (tokens, tools, duration)
|
|
939
|
+
cap extract <ref> conversation User/assistant dialogue (default)
|
|
940
|
+
cap extract <ref> code All file writes and edits
|
|
941
|
+
cap extract <ref> summary Structured summary for LLM consumption
|
|
942
|
+
|
|
943
|
+
Cross-session:
|
|
944
|
+
cap extract decisions [--since DATE] Decisions across all sessions
|
|
945
|
+
cap extract hotspots [--since DATE] Files ranked by edit frequency
|
|
946
|
+
cap extract timeline [--since DATE] Chronological work overview
|
|
947
|
+
cap extract cost [--since DATE] Token usage and cost estimates
|
|
948
|
+
|
|
949
|
+
Session references:
|
|
950
|
+
1, 2, 3... By index (1 = most recent)
|
|
951
|
+
2026-04-03 By date (YYYY-MM-DD)
|
|
952
|
+
|
|
953
|
+
Examples:
|
|
954
|
+
cap extract list
|
|
955
|
+
cap extract stats 1
|
|
956
|
+
cap extract 1 conversation
|
|
957
|
+
cap extract 2 code > changes.md
|
|
958
|
+
cap extract decisions --since 2026-03-01
|
|
959
|
+
cap extract hotspots
|
|
960
|
+
cap extract cost`;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
module.exports = {
|
|
964
|
+
run,
|
|
965
|
+
listSessions,
|
|
966
|
+
extractStats,
|
|
967
|
+
extractConversation,
|
|
968
|
+
extractCode,
|
|
969
|
+
extractSummary,
|
|
970
|
+
extractDecisionsAll,
|
|
971
|
+
extractHotspots,
|
|
972
|
+
extractTimeline,
|
|
973
|
+
extractCost,
|
|
974
|
+
resolveSessionRef,
|
|
975
|
+
getProjectDir,
|
|
976
|
+
getProjectDirsWithChildren,
|
|
977
|
+
getAllSessionFiles,
|
|
978
|
+
parseSession,
|
|
979
|
+
getSessionFiles,
|
|
980
|
+
filterBySince,
|
|
981
|
+
parseSinceFlag,
|
|
982
|
+
extractTextContent,
|
|
983
|
+
extractToolUses,
|
|
984
|
+
formatSize,
|
|
985
|
+
formatDate,
|
|
986
|
+
getHelp,
|
|
987
|
+
};
|