@devtrace-ai/cli 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/LICENSE +21 -0
- package/README.md +96 -0
- package/dist/analyzer.js +163 -0
- package/dist/capture/claude.js +219 -0
- package/dist/capture/trailers.js +112 -0
- package/dist/card.js +142 -0
- package/dist/chat.js +266 -0
- package/dist/claude-correlate.js +150 -0
- package/dist/claude-hook-install.js +183 -0
- package/dist/claude-hooks.js +236 -0
- package/dist/cli.js +113 -0
- package/dist/config.js +174 -0
- package/dist/core/scan.js +265 -0
- package/dist/core/survival.js +400 -0
- package/dist/detector.js +92 -0
- package/dist/digest.js +183 -0
- package/dist/git.js +212 -0
- package/dist/hooks.js +166 -0
- package/dist/html_report.js +325 -0
- package/dist/ignore.js +126 -0
- package/dist/index.js +798 -0
- package/dist/metrics.js +133 -0
- package/dist/reporter.js +127 -0
- package/dist/storage.js +344 -0
- package/dist/test_runner.js +143 -0
- package/dist/types.js +2 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
const crypto = __importStar(require("crypto"));
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const detector_1 = require("./detector");
|
|
41
|
+
const git_1 = require("./git");
|
|
42
|
+
const hooks_1 = require("./hooks");
|
|
43
|
+
const ignore_1 = require("./ignore");
|
|
44
|
+
const metrics_1 = require("./metrics");
|
|
45
|
+
const reporter_1 = require("./reporter");
|
|
46
|
+
const storage_1 = require("./storage");
|
|
47
|
+
const test_runner_1 = require("./test_runner");
|
|
48
|
+
const cli_1 = require("./cli");
|
|
49
|
+
const config_1 = require("./config");
|
|
50
|
+
const chat_1 = require("./chat");
|
|
51
|
+
const analyzer_1 = require("./analyzer");
|
|
52
|
+
const scan_1 = require("./core/scan");
|
|
53
|
+
const survival_1 = require("./core/survival");
|
|
54
|
+
const html_report_1 = require("./html_report");
|
|
55
|
+
const card_1 = require("./card");
|
|
56
|
+
const digest_1 = require("./digest");
|
|
57
|
+
const claude_hooks_1 = require("./claude-hooks");
|
|
58
|
+
const claude_hook_install_1 = require("./claude-hook-install");
|
|
59
|
+
const claude_correlate_1 = require("./claude-correlate");
|
|
60
|
+
/** Human-readable label for an AITool key, used in the reveal box's by-tool breakdown. */
|
|
61
|
+
function toolLabel(tool) {
|
|
62
|
+
const labels = {
|
|
63
|
+
'claude-code': 'Claude',
|
|
64
|
+
'gemini-cli': 'Gemini',
|
|
65
|
+
'cursor': 'Cursor',
|
|
66
|
+
'copilot': 'Copilot',
|
|
67
|
+
'codex': 'Codex',
|
|
68
|
+
'aider': 'Aider',
|
|
69
|
+
'ollama': 'Ollama',
|
|
70
|
+
'other': 'Other AI',
|
|
71
|
+
'unknown-ai': 'Unknown AI (heuristic)',
|
|
72
|
+
'none': 'Human'
|
|
73
|
+
};
|
|
74
|
+
return labels[tool] || tool;
|
|
75
|
+
}
|
|
76
|
+
/** Best-effort read of the Claude settings.json for before/after summary counts. */
|
|
77
|
+
function readClaudeSettingsForSummary() {
|
|
78
|
+
try {
|
|
79
|
+
const raw = fs.readFileSync((0, claude_hook_install_1.resolveClaudeSettingsPath)(), 'utf8');
|
|
80
|
+
const parsed = JSON.parse(raw);
|
|
81
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Runs the retroactive scan (Pass 1 classification + optional Pass 2 survival), dedupes
|
|
89
|
+
* against sessions already in the store, batch-writes new sessions, and prints the reveal box.
|
|
90
|
+
* See docs/plans/04-architecture-audit-build-plan.md ยง2.3 and 02-product-gtm-plan.md ยง3 Step 2.
|
|
91
|
+
*/
|
|
92
|
+
async function runRetroScan(cwd, opts) {
|
|
93
|
+
const config = config_1.ConfigManager.loadConfig(cwd);
|
|
94
|
+
const ignoreFilter = new ignore_1.IgnoreFilter(cwd, config.storageDir);
|
|
95
|
+
console.log(`\n๐ Scanning commit history (since ${opts.since})...`);
|
|
96
|
+
let lastReported = -1;
|
|
97
|
+
const scanResult = await (0, scan_1.scanCommitHistory)({
|
|
98
|
+
cwd,
|
|
99
|
+
since: opts.since,
|
|
100
|
+
ignoreFilter,
|
|
101
|
+
onProgress: (n) => {
|
|
102
|
+
// Throttle: redraw every 50 commits so huge repos don't spam the terminal.
|
|
103
|
+
if (n > 0 && n !== lastReported && n % 50 === 0) {
|
|
104
|
+
process.stdout.write(`\r commits parsed: ${n}`);
|
|
105
|
+
lastReported = n;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
if (lastReported !== -1)
|
|
110
|
+
process.stdout.write('\n');
|
|
111
|
+
console.log(`โ
Parsed ${scanResult.commits.length} commit(s) in window (${scanResult.totalCommitsSeen} seen total).`);
|
|
112
|
+
// Dedupe against sessions already in the store (by commitHash) so re-running init/scan
|
|
113
|
+
// never double-imports.
|
|
114
|
+
const existingSessions = await storage_1.StorageManager.loadSessions(cwd);
|
|
115
|
+
const existingHashes = new Set(existingSessions.map(s => s.commitHash));
|
|
116
|
+
const newCommits = scanResult.commits.filter(c => !existingHashes.has(c.commitHash));
|
|
117
|
+
if (newCommits.length === 0 && scanResult.commits.length > 0) {
|
|
118
|
+
console.log('โน๏ธ All commits in this window are already recorded โ nothing new to import.');
|
|
119
|
+
}
|
|
120
|
+
// Pass 2: survival, unless --fast. Computed over ALL window commits (not just new ones)
|
|
121
|
+
// so a re-run still shows the full reveal; only the storage write below is deduped.
|
|
122
|
+
let survivingLines = new Map();
|
|
123
|
+
let unknownSurvivalCommits = new Set();
|
|
124
|
+
let survival30dByCommit = new Map();
|
|
125
|
+
if (!opts.fast && scanResult.commits.length > 0) {
|
|
126
|
+
const touchedFiles = new Set();
|
|
127
|
+
for (const c of scanResult.commits) {
|
|
128
|
+
for (const f of c.diff.files) {
|
|
129
|
+
if (!f.isBinary)
|
|
130
|
+
touchedFiles.add(f.path);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (touchedFiles.size > 0) {
|
|
134
|
+
console.log(`\n๐ฌ Computing survival (git blame HEAD, ${touchedFiles.size} file(s))...`);
|
|
135
|
+
let lastFilesReported = -1;
|
|
136
|
+
const survivalResult = await (0, survival_1.computeSurvival)(Array.from(touchedFiles), {
|
|
137
|
+
cwd,
|
|
138
|
+
ignoreFilter,
|
|
139
|
+
onProgress: (blamed, total) => {
|
|
140
|
+
// Throttle: redraw at most every 25 files (always drawing the final count)
|
|
141
|
+
// so large repos don't spam the terminal with per-file updates.
|
|
142
|
+
if (blamed !== lastFilesReported && (blamed % 25 === 0 || blamed === total)) {
|
|
143
|
+
process.stdout.write(`\r files blamed: ${blamed}/${total}`);
|
|
144
|
+
lastFilesReported = blamed;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
if (lastFilesReported !== -1)
|
|
149
|
+
process.stdout.write('\n');
|
|
150
|
+
survivingLines = survivalResult.survivingLines;
|
|
151
|
+
const skippedSet = new Set(survivalResult.skippedFiles);
|
|
152
|
+
for (const c of scanResult.commits) {
|
|
153
|
+
if (c.diff.files.some(f => !f.isBinary && skippedSet.has(f.path))) {
|
|
154
|
+
unknownSurvivalCommits.add(c.commitHash);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
console.log(`โ
Blamed ${survivalResult.filesBlamed} file(s), skipped ${survivalResult.filesSkipped} (size guards or not tracked at HEAD โ survival unknown).`);
|
|
158
|
+
// Windowed 30-day survival: blame at the commit boundary closest to C+30d, not HEAD.
|
|
159
|
+
// See docs/plans/04-architecture-audit-build-plan.md ยงPhase 1 risk #2.
|
|
160
|
+
const windowedInputs = scanResult.commits.map(c => ({
|
|
161
|
+
commitHash: c.commitHash,
|
|
162
|
+
authorDate: c.authorDate,
|
|
163
|
+
insertions: c.diff.totalInsertions,
|
|
164
|
+
files: c.diff.files.filter(f => !f.isBinary && !ignoreFilter.shouldIgnore(f.path)).map(f => f.path)
|
|
165
|
+
}));
|
|
166
|
+
console.log(`๐ฌ Computing windowed 30-day survival...`);
|
|
167
|
+
let lastPairsReported = -1;
|
|
168
|
+
const windowedResult = await (0, survival_1.computeWindowedSurvival)(windowedInputs, {
|
|
169
|
+
cwd,
|
|
170
|
+
onProgress: (blamed, total) => {
|
|
171
|
+
if (blamed !== lastPairsReported && (blamed % 25 === 0 || blamed === total) && total > 0) {
|
|
172
|
+
process.stdout.write(`\r (file, boundary) pairs blamed: ${blamed}/${total}`);
|
|
173
|
+
lastPairsReported = blamed;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
if (lastPairsReported !== -1)
|
|
178
|
+
process.stdout.write('\n');
|
|
179
|
+
survival30dByCommit = windowedResult.survival30d;
|
|
180
|
+
const measured = Array.from(survival30dByCommit.values()).filter(v => v.status === 'measured').length;
|
|
181
|
+
console.log(`โ
Windowed 30-day survival measured for ${measured} commit(s) (older, unmeasurable-eligible commits marked unknown; younger commits are window-open).`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Build TraceSessions for the new commits and batch-write.
|
|
185
|
+
const survivalComputed = !opts.fast;
|
|
186
|
+
const newSessions = newCommits.map((c) => sessionFromScannedCommit(c, cwd, survivingLines, unknownSurvivalCommits, survivalComputed, survival30dByCommit));
|
|
187
|
+
// Backfill churn on already-imported window sessions (e.g. imported by a --fast run that
|
|
188
|
+
// could not measure survival) now that fresh blame data exists.
|
|
189
|
+
let backfilled = 0;
|
|
190
|
+
if (survivalComputed) {
|
|
191
|
+
const byHash = new Map(scanResult.commits.map(c => [c.commitHash, c]));
|
|
192
|
+
for (const s of existingSessions) {
|
|
193
|
+
const c = byHash.get(s.commitHash);
|
|
194
|
+
if (!c || unknownSurvivalCommits.has(c.commitHash))
|
|
195
|
+
continue;
|
|
196
|
+
const rate = (0, survival_1.survivalRateFor)(survivingLines.get(c.commitHash) || 0, c.diff.totalInsertions);
|
|
197
|
+
if (rate === null)
|
|
198
|
+
continue;
|
|
199
|
+
s.churn = {
|
|
200
|
+
linesReverted: Math.max(0, c.diff.totalInsertions - (survivingLines.get(c.commitHash) || 0)),
|
|
201
|
+
survivalRate: rate,
|
|
202
|
+
survival30d: survival30dByCommit.get(c.commitHash)
|
|
203
|
+
};
|
|
204
|
+
backfilled++;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (newSessions.length > 0 || backfilled > 0) {
|
|
208
|
+
const merged = existingSessions.concat(newSessions);
|
|
209
|
+
storage_1.StorageManager.saveAllSessions(merged, cwd);
|
|
210
|
+
const stats = analyzer_1.MetricsAnalyzer.analyze(merged);
|
|
211
|
+
reporter_1.ReportGenerator.generateReport(stats, merged, cwd);
|
|
212
|
+
}
|
|
213
|
+
printRevealBox(scanResult.commits, survivingLines, unknownSurvivalCommits, survival30dByCommit, opts);
|
|
214
|
+
}
|
|
215
|
+
/** Converts one classified ScannedCommit into a persistable TraceSession (source: retro-scan). */
|
|
216
|
+
function sessionFromScannedCommit(c, cwd, survivingLines, unknownSurvivalCommits, survivalComputed, survival30dByCommit) {
|
|
217
|
+
// Never fabricate a survival number: --fast runs (no blame pass) and guard-skipped
|
|
218
|
+
// commits store no churn at all, which downstream reads as "unknown".
|
|
219
|
+
const rate = !survivalComputed || unknownSurvivalCommits.has(c.commitHash)
|
|
220
|
+
? null
|
|
221
|
+
: (0, survival_1.survivalRateFor)(survivingLines.get(c.commitHash) || 0, c.diff.totalInsertions);
|
|
222
|
+
return {
|
|
223
|
+
id: crypto.randomUUID(),
|
|
224
|
+
version: 1,
|
|
225
|
+
timestamp: c.authorDate,
|
|
226
|
+
commitHash: c.commitHash,
|
|
227
|
+
parentCommitHash: c.parentHash,
|
|
228
|
+
captureMode: 'retro-scan',
|
|
229
|
+
aiTool: c.aiTool,
|
|
230
|
+
aiToolConfidence: c.aiToolConfidence,
|
|
231
|
+
aiToolTier: c.aiToolTier,
|
|
232
|
+
branchName: git_1.GitTracker.getCurrentBranch(cwd),
|
|
233
|
+
treeHashBefore: '',
|
|
234
|
+
treeHashAfter: '',
|
|
235
|
+
diff: c.diff,
|
|
236
|
+
changeType: c.changeType,
|
|
237
|
+
changeTypeConfidence: c.changeTypeConfidence,
|
|
238
|
+
testResult: null,
|
|
239
|
+
churn: rate !== null ? {
|
|
240
|
+
linesReverted: Math.max(0, c.diff.totalInsertions - (survivingLines.get(c.commitHash) || 0)),
|
|
241
|
+
survivalRate: rate,
|
|
242
|
+
// Windowed 30-day survival (T1.1): 'measured' | 'window-open' | 'unknown' โ honest
|
|
243
|
+
// tri-state, never a fabricated boolean. Absent only if survival wasn't computed at all.
|
|
244
|
+
survival30d: survival30dByCommit.get(c.commitHash)
|
|
245
|
+
} : undefined
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/** Prints the "reveal" box โ the aha-moment number the user did not know and cannot un-know. */
|
|
249
|
+
function printRevealBox(commits, survivingLines, unknownSurvivalCommits, survival30dByCommit, opts) {
|
|
250
|
+
const total = commits.length;
|
|
251
|
+
const aiCommits = commits.filter(c => c.aiTool !== 'none');
|
|
252
|
+
const aiPct = total > 0 ? ((aiCommits.length / total) * 100).toFixed(0) : '0';
|
|
253
|
+
console.log(`\nโโ Your AI code, ${describeWindow(opts.since)} โโโโโโโโโโโโโโโโโโโโโโโโโ`);
|
|
254
|
+
console.log(`โ AI-authored commits: ${aiCommits.length} (${aiPct}% of ${total} commits)`);
|
|
255
|
+
if (opts.fast) {
|
|
256
|
+
console.log(`โ Survival rates: skipped (--fast: numstat-only, no blame)`);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
const aiSurvival = weightedSurvival(aiCommits, survivingLines, unknownSurvivalCommits);
|
|
260
|
+
const humanCommits = commits.filter(c => c.aiTool === 'none');
|
|
261
|
+
const humanSurvival = weightedSurvival(humanCommits, survivingLines, unknownSurvivalCommits);
|
|
262
|
+
console.log(`โ AI lines still at HEAD: ${fmtPct(aiSurvival)} โ lines still attributed to their original commit at HEAD`);
|
|
263
|
+
console.log(`โ Human lines still at HEAD: ${fmtPct(humanSurvival)}`);
|
|
264
|
+
// Windowed 30-day survival line โ only shown when at least one AI commit has a measured
|
|
265
|
+
// verdict, so repos with an all-young or all-unknown window don't print a hollow line.
|
|
266
|
+
const measuredAiWindowed = aiCommits
|
|
267
|
+
.map(c => survival30dByCommit.get(c.commitHash))
|
|
268
|
+
.filter((v) => v !== undefined && v.status === 'measured');
|
|
269
|
+
if (measuredAiWindowed.length > 0) {
|
|
270
|
+
const avg30d = measuredAiWindowed.reduce((sum, v) => sum + v.rate, 0) / measuredAiWindowed.length;
|
|
271
|
+
console.log(`โ AI lines surviving 30 days: ${fmtPct(avg30d)} (n=${measuredAiWindowed.length} commit${measuredAiWindowed.length === 1 ? '' : 's'} measured)`);
|
|
272
|
+
}
|
|
273
|
+
const byTool = new Map();
|
|
274
|
+
for (const c of aiCommits) {
|
|
275
|
+
if (!byTool.has(c.aiTool))
|
|
276
|
+
byTool.set(c.aiTool, []);
|
|
277
|
+
byTool.get(c.aiTool).push(c);
|
|
278
|
+
}
|
|
279
|
+
if (byTool.size > 0) {
|
|
280
|
+
console.log(`โ By tool:`);
|
|
281
|
+
for (const [tool, toolCommits] of byTool.entries()) {
|
|
282
|
+
const rate = weightedSurvival(toolCommits, survivingLines, unknownSurvivalCommits);
|
|
283
|
+
const unknownCount = toolCommits.filter(c => unknownSurvivalCommits.has(c.commitHash)).length;
|
|
284
|
+
const unknownNote = unknownCount > 0 ? `, ${unknownCount} unknown` : '';
|
|
285
|
+
console.log(`โ ${toolLabel(tool).padEnd(12)} ${fmtPct(rate)} survival (${toolCommits.length} commit${toolCommits.length === 1 ? '' : 's'}${unknownNote})`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
let rewrittenLines = 0;
|
|
289
|
+
for (const c of aiCommits) {
|
|
290
|
+
if (unknownSurvivalCommits.has(c.commitHash))
|
|
291
|
+
continue;
|
|
292
|
+
const surviving = survivingLines.get(c.commitHash) || 0;
|
|
293
|
+
rewrittenLines += Math.max(0, c.diff.totalInsertions - surviving);
|
|
294
|
+
}
|
|
295
|
+
console.log(`โ AI lines rewritten since: ~${rewrittenLines.toLocaleString()}`);
|
|
296
|
+
}
|
|
297
|
+
console.log(`โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ`);
|
|
298
|
+
console.log(` โ devtrace status see traced session count`);
|
|
299
|
+
console.log(` โ devtrace report full breakdown (DEVTRACE_LOG.md)`);
|
|
300
|
+
console.log(` โ devtrace churn re-run windowed survival later`);
|
|
301
|
+
console.log(`\n๐ Setup complete! DevTrace-AI will now automatically record metadata on every commit in the background.`);
|
|
302
|
+
}
|
|
303
|
+
function describeWindow(since) {
|
|
304
|
+
const daysMatch = /^(\d+) days ago$/.exec(since);
|
|
305
|
+
return daysMatch ? `last ${daysMatch[1]} days` : `since ${since}`;
|
|
306
|
+
}
|
|
307
|
+
function fmtPct(rate) {
|
|
308
|
+
if (rate === null)
|
|
309
|
+
return 'unknown';
|
|
310
|
+
return `${(rate * 100).toFixed(0)}%`;
|
|
311
|
+
}
|
|
312
|
+
/** Insertion-weighted average survival rate across a set of commits, excluding unknowns. */
|
|
313
|
+
function weightedSurvival(commits, survivingLines, unknownSurvivalCommits) {
|
|
314
|
+
let totalInsertions = 0;
|
|
315
|
+
let totalSurviving = 0;
|
|
316
|
+
let anyKnown = false;
|
|
317
|
+
for (const c of commits) {
|
|
318
|
+
if (unknownSurvivalCommits.has(c.commitHash))
|
|
319
|
+
continue;
|
|
320
|
+
if (c.diff.totalInsertions <= 0)
|
|
321
|
+
continue;
|
|
322
|
+
anyKnown = true;
|
|
323
|
+
totalInsertions += c.diff.totalInsertions;
|
|
324
|
+
totalSurviving += Math.min(c.diff.totalInsertions, survivingLines.get(c.commitHash) || 0);
|
|
325
|
+
}
|
|
326
|
+
if (!anyKnown || totalInsertions === 0)
|
|
327
|
+
return null;
|
|
328
|
+
return totalSurviving / totalInsertions;
|
|
329
|
+
}
|
|
330
|
+
async function run() {
|
|
331
|
+
const args = process.argv.slice(2);
|
|
332
|
+
let command = args[0] || 'status';
|
|
333
|
+
// Handle 'hook' subcommand alias as documented in the README
|
|
334
|
+
if (command === 'hook') {
|
|
335
|
+
const sub = args[1];
|
|
336
|
+
if (sub === 'install') {
|
|
337
|
+
command = 'init';
|
|
338
|
+
}
|
|
339
|
+
else if (sub === 'uninstall') {
|
|
340
|
+
command = 'uninstall';
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// Resolve target workspace (repository root) to support execution from subdirectories
|
|
344
|
+
const rawCwd = process.cwd();
|
|
345
|
+
const cwd = git_1.GitTracker.getRepositoryRoot(rawCwd);
|
|
346
|
+
switch (command) {
|
|
347
|
+
case 'init': {
|
|
348
|
+
console.log('\n๐ Initializing DevTrace-AI Empirical Logging Framework...');
|
|
349
|
+
// Save configuration
|
|
350
|
+
config_1.ConfigManager.saveConfig(config_1.DEFAULT_CONFIG, cwd);
|
|
351
|
+
console.log(`โ
Configuration saved to: devtrace.config.json`);
|
|
352
|
+
// Create storage directory
|
|
353
|
+
const config = config_1.ConfigManager.loadConfig(cwd);
|
|
354
|
+
const storageDir = path.join(cwd, config.storageDir);
|
|
355
|
+
if (!fs.existsSync(storageDir)) {
|
|
356
|
+
fs.mkdirSync(storageDir, { recursive: true });
|
|
357
|
+
}
|
|
358
|
+
// Install Hook
|
|
359
|
+
try {
|
|
360
|
+
const hookPath = hooks_1.HookManager.installHook(cwd);
|
|
361
|
+
console.log(`โ
Git post-commit hook successfully installed at: ${hookPath}`);
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
console.error(`โ Failed to install git hook: ${err.message}`);
|
|
365
|
+
process.exit(1);
|
|
366
|
+
}
|
|
367
|
+
// Retroactive scan: the aha-moment. Parse --fast and --since <n>d.
|
|
368
|
+
const fast = args.includes('--fast');
|
|
369
|
+
const sinceFlagIdx = args.indexOf('--since');
|
|
370
|
+
let sinceArg = '90 days ago';
|
|
371
|
+
if (sinceFlagIdx !== -1 && args[sinceFlagIdx + 1]) {
|
|
372
|
+
const raw = args[sinceFlagIdx + 1];
|
|
373
|
+
const daysMatch = /^(\d+)d$/.exec(raw);
|
|
374
|
+
sinceArg = daysMatch ? `${daysMatch[1]} days ago` : raw;
|
|
375
|
+
}
|
|
376
|
+
await runRetroScan(cwd, { fast, since: sinceArg });
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
case 'scan': {
|
|
380
|
+
const fast = args.includes('--fast');
|
|
381
|
+
const sinceFlagIdx = args.indexOf('--since');
|
|
382
|
+
let sinceArg = '90 days ago';
|
|
383
|
+
if (sinceFlagIdx !== -1 && args[sinceFlagIdx + 1]) {
|
|
384
|
+
const raw = args[sinceFlagIdx + 1];
|
|
385
|
+
const daysMatch = /^(\d+)d$/.exec(raw);
|
|
386
|
+
sinceArg = daysMatch ? `${daysMatch[1]} days ago` : raw;
|
|
387
|
+
}
|
|
388
|
+
await runRetroScan(cwd, { fast, since: sinceArg });
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
case 'post-commit': {
|
|
392
|
+
// Automatic execution triggered by git hook
|
|
393
|
+
const currentCommit = git_1.GitTracker.resolveRef('HEAD', cwd);
|
|
394
|
+
if (!currentCommit) {
|
|
395
|
+
process.exit(0);
|
|
396
|
+
}
|
|
397
|
+
// Skip merge commits
|
|
398
|
+
if (git_1.GitTracker.isMergeCommit(currentCommit, cwd)) {
|
|
399
|
+
process.exit(0);
|
|
400
|
+
}
|
|
401
|
+
const config = config_1.ConfigManager.loadConfig(cwd);
|
|
402
|
+
// Resolve parent commit (use empty tree if initial commit)
|
|
403
|
+
const parentCommit = git_1.GitTracker.resolveRef('HEAD~1', cwd) || git_1.GitTracker.getEmptyTreeHash(cwd);
|
|
404
|
+
// Get raw diff
|
|
405
|
+
const rawDiff = git_1.GitTracker.getDiff(parentCommit, currentCommit, cwd);
|
|
406
|
+
// Filter out ignored files
|
|
407
|
+
const ignoreFilter = new ignore_1.IgnoreFilter(cwd, config.storageDir);
|
|
408
|
+
const filteredFiles = rawDiff.files.filter(f => !ignoreFilter.shouldIgnore(f.path));
|
|
409
|
+
// If no code files changed, skip logging to avoid database spam
|
|
410
|
+
if (filteredFiles.length === 0) {
|
|
411
|
+
process.exit(0);
|
|
412
|
+
}
|
|
413
|
+
const totalInsertions = filteredFiles.reduce((sum, f) => sum + f.insertions, 0);
|
|
414
|
+
const totalDeletions = filteredFiles.reduce((sum, f) => sum + f.deletions, 0);
|
|
415
|
+
const filteredDiff = {
|
|
416
|
+
filesChanged: filteredFiles.length,
|
|
417
|
+
totalInsertions,
|
|
418
|
+
totalDeletions,
|
|
419
|
+
netLines: totalInsertions - totalDeletions,
|
|
420
|
+
files: filteredFiles
|
|
421
|
+
};
|
|
422
|
+
// Run tests safely matching trust restrictions
|
|
423
|
+
let testResult = null;
|
|
424
|
+
const detectedCmd = test_runner_1.TestRunner.detectTestCommand(cwd);
|
|
425
|
+
const configuredCmd = config.testCommand;
|
|
426
|
+
const isTrusted = config_1.GlobalConfigManager.isWorkspaceTrusted(cwd);
|
|
427
|
+
if (configuredCmd || detectedCmd) {
|
|
428
|
+
if (isTrusted) {
|
|
429
|
+
testResult = test_runner_1.TestRunner.runTests(cwd);
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
const cmdName = configuredCmd || detectedCmd;
|
|
433
|
+
console.warn(`[DevTrace] Skipped test command "${cmdName}" because this workspace is untrusted.`);
|
|
434
|
+
console.warn(`[DevTrace] Run 'devtrace trust' to trust this workspace and allow test execution.`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// Resolve commit metadata for classification
|
|
438
|
+
const commitMeta = git_1.GitTracker.getCommitMetadata(currentCommit, cwd);
|
|
439
|
+
// Detect AI presence and change classification
|
|
440
|
+
let aiDetection = detector_1.AIDetector.detect(currentCommit, filteredDiff, cwd);
|
|
441
|
+
const classification = metrics_1.MetricsClassifier.classify(filteredDiff, commitMeta.message);
|
|
442
|
+
// Tier-2 correlation (T1.4): if the first-party Claude Code hook ledger recorded
|
|
443
|
+
// edits (since the last commit's watermark) touching files in this commit, and no
|
|
444
|
+
// tier-1 trailer already matched, upgrade to "instrumented" โ trailers stay authoritative.
|
|
445
|
+
if (aiDetection.tier !== 1) {
|
|
446
|
+
const correlation = (0, claude_correlate_1.correlateCommitWithLedger)(cwd, filteredFiles.map(f => f.path));
|
|
447
|
+
if (correlation.matched) {
|
|
448
|
+
aiDetection = {
|
|
449
|
+
tool: 'claude-code',
|
|
450
|
+
confidence: claude_correlate_1.TIER2_CONFIDENCE,
|
|
451
|
+
tier: 2,
|
|
452
|
+
reasons: [...aiDetection.reasons, `hook:claude-code-ledger (${correlation.matchedFiles.length} file(s) matched)`]
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
// Attempt auto-parsing of chat/prompt histories
|
|
457
|
+
let autoPromptText = '';
|
|
458
|
+
if (aiDetection.tool === 'claude-code') {
|
|
459
|
+
const claudeChat = chat_1.ChatLogParser.parseLatestClaudeCode();
|
|
460
|
+
if (claudeChat)
|
|
461
|
+
autoPromptText = claudeChat.promptText;
|
|
462
|
+
}
|
|
463
|
+
else if (aiDetection.tool === 'cursor') {
|
|
464
|
+
const cursorChat = chat_1.ChatLogParser.parseLatestCursorChat();
|
|
465
|
+
if (cursorChat)
|
|
466
|
+
autoPromptText = cursorChat.promptText;
|
|
467
|
+
}
|
|
468
|
+
const session = {
|
|
469
|
+
id: crypto.randomUUID(),
|
|
470
|
+
version: 1,
|
|
471
|
+
timestamp: new Date().toISOString(),
|
|
472
|
+
commitHash: currentCommit,
|
|
473
|
+
parentCommitHash: parentCommit,
|
|
474
|
+
captureMode: 'auto-hook',
|
|
475
|
+
aiTool: aiDetection.tool,
|
|
476
|
+
aiToolConfidence: aiDetection.confidence,
|
|
477
|
+
aiToolTier: aiDetection.tier,
|
|
478
|
+
branchName: git_1.GitTracker.getCurrentBranch(cwd),
|
|
479
|
+
treeHashBefore: git_1.GitTracker.getTreeHash(parentCommit, cwd),
|
|
480
|
+
treeHashAfter: git_1.GitTracker.getTreeHash(currentCommit, cwd),
|
|
481
|
+
diff: filteredDiff,
|
|
482
|
+
changeType: classification.changeType,
|
|
483
|
+
changeTypeConfidence: classification.confidence,
|
|
484
|
+
testResult,
|
|
485
|
+
annotation: autoPromptText ? { promptText: autoPromptText } : undefined
|
|
486
|
+
};
|
|
487
|
+
storage_1.StorageManager.appendSession(session, cwd);
|
|
488
|
+
// Regenerate reports
|
|
489
|
+
const sessions = await storage_1.StorageManager.loadSessions(cwd);
|
|
490
|
+
const stats = analyzer_1.MetricsAnalyzer.analyze(sessions);
|
|
491
|
+
reporter_1.ReportGenerator.generateReport(stats, sessions, cwd);
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
case 'annotate': {
|
|
495
|
+
const sessions = await storage_1.StorageManager.loadSessions(cwd);
|
|
496
|
+
if (sessions.length === 0) {
|
|
497
|
+
console.log('โ No traced sessions found. Make some commits first!');
|
|
498
|
+
process.exit(1);
|
|
499
|
+
}
|
|
500
|
+
// Check if CLI flags were provided
|
|
501
|
+
const flagMap = {};
|
|
502
|
+
for (let i = 0; i < args.length; i++) {
|
|
503
|
+
if (args[i].startsWith('--')) {
|
|
504
|
+
const key = args[i].substring(2);
|
|
505
|
+
const val = args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : 'true';
|
|
506
|
+
flagMap[key] = val;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const targetId = flagMap.id;
|
|
510
|
+
const lastSession = targetId ? (sessions.find(s => s.id === targetId) || sessions[sessions.length - 1]) : sessions[sessions.length - 1];
|
|
511
|
+
if (Object.keys(flagMap).length > 0 && !flagMap.id) {
|
|
512
|
+
// We have flags (excluding just --id)
|
|
513
|
+
const correctness = flagMap.correctness ? parseInt(flagMap.correctness, 10) : undefined;
|
|
514
|
+
const completeness = flagMap.completeness ? parseInt(flagMap.completeness, 10) : undefined;
|
|
515
|
+
const effort = flagMap.effort ? parseInt(flagMap.effort, 10) : undefined;
|
|
516
|
+
const promptText = flagMap.prompt;
|
|
517
|
+
const notes = flagMap.notes;
|
|
518
|
+
const complexity = flagMap.complexity;
|
|
519
|
+
const model = flagMap.model;
|
|
520
|
+
lastSession.annotation = {
|
|
521
|
+
...lastSession.annotation,
|
|
522
|
+
correctness: correctness !== undefined && !isNaN(correctness) ? correctness : lastSession.annotation?.correctness,
|
|
523
|
+
completeness: completeness !== undefined && !isNaN(completeness) ? completeness : lastSession.annotation?.completeness,
|
|
524
|
+
effortToIntegrate: effort !== undefined && !isNaN(effort) ? effort : lastSession.annotation?.effortToIntegrate,
|
|
525
|
+
promptText: promptText || lastSession.annotation?.promptText,
|
|
526
|
+
notes: notes || lastSession.annotation?.notes,
|
|
527
|
+
taskComplexity: complexity || lastSession.annotation?.taskComplexity,
|
|
528
|
+
llmModelName: model || lastSession.annotation?.llmModelName
|
|
529
|
+
};
|
|
530
|
+
storage_1.StorageManager.updateSessionSync(lastSession.id, (session) => {
|
|
531
|
+
session.annotation = lastSession.annotation;
|
|
532
|
+
}, cwd);
|
|
533
|
+
// Regenerate reports
|
|
534
|
+
const freshSessions = await storage_1.StorageManager.loadSessions(cwd);
|
|
535
|
+
const stats = analyzer_1.MetricsAnalyzer.analyze(freshSessions);
|
|
536
|
+
reporter_1.ReportGenerator.generateReport(stats, freshSessions, cwd);
|
|
537
|
+
console.log('\nโ
Annotations saved successfully via flags!');
|
|
538
|
+
process.exit(0);
|
|
539
|
+
}
|
|
540
|
+
console.log(`\n================ Annotating Session ================`);
|
|
541
|
+
console.log(`Commit: ${lastSession.commitHash.substring(0, 8)} [${lastSession.branchName}]`);
|
|
542
|
+
console.log(`Files: ${lastSession.diff.filesChanged} files changed (+${lastSession.diff.totalInsertions}/-${lastSession.diff.totalDeletions})`);
|
|
543
|
+
const tierLabel = lastSession.aiToolTier !== undefined ? ` [tier ${lastSession.aiToolTier}]` : '';
|
|
544
|
+
console.log(`AI Tool: ${lastSession.aiTool} (Confidence: ${(lastSession.aiToolConfidence * 100).toFixed(0)}%)${tierLabel}`);
|
|
545
|
+
console.log(`Type: ${lastSession.changeType}`);
|
|
546
|
+
const choice = await cli_1.CliHelper.askChoice('What would you like to annotate?', [
|
|
547
|
+
'Add qualitative Likert ratings & Prompt details',
|
|
548
|
+
'Upload manual Chat logs/prompts from file',
|
|
549
|
+
'Mark this session as human-only (correct AI detection error)',
|
|
550
|
+
'Exit'
|
|
551
|
+
]);
|
|
552
|
+
if (choice === 1) {
|
|
553
|
+
const correctness = await cli_1.CliHelper.askLikert('Rate correctness of AI generated code', '1=Totally incorrect, 5=Flawless execution');
|
|
554
|
+
const completeness = await cli_1.CliHelper.askLikert('Rate completeness of changes', '1=Placeholder code/missing functions, 5=Fully complete implementation');
|
|
555
|
+
const effort = await cli_1.CliHelper.askLikert('Rate implementation/integration effort', '1=Required a full rewrite, 5=Integrated with zero modifications');
|
|
556
|
+
const complexity = await cli_1.CliHelper.askComplexity();
|
|
557
|
+
const model = await cli_1.CliHelper.askQuestion('\nEnter the LLM model name (e.g. claude-3-5-sonnet, gpt-4o):\n> ');
|
|
558
|
+
const promptText = await cli_1.CliHelper.askQuestion('\nEnter the prompt payload passed to the AI:\n> ');
|
|
559
|
+
const notes = await cli_1.CliHelper.askQuestion('\nAdd qualitative notes/observations (e.g. logic hallucinations):\n> ');
|
|
560
|
+
lastSession.annotation = {
|
|
561
|
+
promptText: promptText || lastSession.annotation?.promptText,
|
|
562
|
+
correctness,
|
|
563
|
+
completeness,
|
|
564
|
+
effortToIntegrate: effort,
|
|
565
|
+
notes: notes || undefined,
|
|
566
|
+
taskComplexity: complexity,
|
|
567
|
+
llmModelName: model || undefined
|
|
568
|
+
};
|
|
569
|
+
storage_1.StorageManager.updateSessionSync(lastSession.id, (session) => {
|
|
570
|
+
session.annotation = lastSession.annotation;
|
|
571
|
+
}, cwd);
|
|
572
|
+
}
|
|
573
|
+
else if (choice === 2) {
|
|
574
|
+
const filePath = await cli_1.CliHelper.askQuestion('\nEnter the path to the chat log file (.json or text):\n> ');
|
|
575
|
+
const parsed = chat_1.ChatLogParser.parseUserFile(path.resolve(filePath));
|
|
576
|
+
if (parsed) {
|
|
577
|
+
lastSession.annotation = {
|
|
578
|
+
...lastSession.annotation,
|
|
579
|
+
promptText: parsed.promptText,
|
|
580
|
+
chatLogFile: filePath
|
|
581
|
+
};
|
|
582
|
+
storage_1.StorageManager.updateSessionSync(lastSession.id, (session) => {
|
|
583
|
+
session.annotation = lastSession.annotation;
|
|
584
|
+
}, cwd);
|
|
585
|
+
console.log('โ
Chat log details parsed and linked successfully.');
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
console.log('โ Failed to parse or locate log file.');
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
else if (choice === 3) {
|
|
592
|
+
storage_1.StorageManager.updateSessionSync(lastSession.id, (session) => {
|
|
593
|
+
session.aiTool = 'none';
|
|
594
|
+
session.aiToolConfidence = 1.0; // manually confirmed by the developer, not a heuristic guess
|
|
595
|
+
session.aiToolTier = 1;
|
|
596
|
+
}, cwd);
|
|
597
|
+
console.log('โ
Updated session to human-only.');
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
cli_1.CliHelper.closeInterface();
|
|
601
|
+
process.exit(0);
|
|
602
|
+
}
|
|
603
|
+
// Regenerate reports
|
|
604
|
+
const freshSessions = await storage_1.StorageManager.loadSessions(cwd);
|
|
605
|
+
const stats = analyzer_1.MetricsAnalyzer.analyze(freshSessions);
|
|
606
|
+
reporter_1.ReportGenerator.generateReport(stats, freshSessions, cwd);
|
|
607
|
+
console.log('\nโ
Annotations saved! Telemetry log updated.');
|
|
608
|
+
cli_1.CliHelper.closeInterface();
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
case 'report': {
|
|
612
|
+
const sessions = await storage_1.StorageManager.loadSessions(cwd);
|
|
613
|
+
const stats = analyzer_1.MetricsAnalyzer.analyze(sessions);
|
|
614
|
+
// Keep the existing markdown telemetry log in sync (unchanged behavior)...
|
|
615
|
+
const reportFile = reporter_1.ReportGenerator.generateReport(stats, sessions, cwd);
|
|
616
|
+
console.log(`โ
Telemetry report compiled: ${reportFile}`);
|
|
617
|
+
// ...and additionally produce the self-contained HTML report (Phase 1 ยง2.5).
|
|
618
|
+
const outFlagIdx = args.indexOf('--out');
|
|
619
|
+
const outPath = outFlagIdx !== -1 && args[outFlagIdx + 1] ? args[outFlagIdx + 1] : undefined;
|
|
620
|
+
const htmlFile = (0, html_report_1.writeHtmlReport)(stats, sessions, cwd, outPath);
|
|
621
|
+
console.log(`โ
HTML report generated: ${htmlFile}`);
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
case 'card': {
|
|
625
|
+
const sessions = await storage_1.StorageManager.loadSessions(cwd);
|
|
626
|
+
const pngFlagIdx = args.indexOf('--png');
|
|
627
|
+
if (pngFlagIdx !== -1) {
|
|
628
|
+
console.log('PNG export coming soon โ render the SVG at https://svgtopng.com or use any SVG converter.');
|
|
629
|
+
}
|
|
630
|
+
const outFlagIdx = args.indexOf('--out');
|
|
631
|
+
const outPath = outFlagIdx !== -1 && args[outFlagIdx + 1] ? args[outFlagIdx + 1] : undefined;
|
|
632
|
+
const cardFile = (0, card_1.writeCardSvg)(sessions, cwd, outPath);
|
|
633
|
+
console.log(`โ
Scorecard SVG generated: ${cardFile}`);
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
case 'digest': {
|
|
637
|
+
const sessions = await storage_1.StorageManager.loadSessions(cwd);
|
|
638
|
+
const sinceFlagIdx = args.indexOf('--since');
|
|
639
|
+
const sinceArg = sinceFlagIdx !== -1 && args[sinceFlagIdx + 1] ? args[sinceFlagIdx + 1] : undefined;
|
|
640
|
+
const days = (0, digest_1.parseSinceDays)(sinceArg);
|
|
641
|
+
const digest = (0, digest_1.buildDigest)(sessions, days);
|
|
642
|
+
console.log(`\n${digest.text}`);
|
|
643
|
+
const digestFile = (0, digest_1.writeDigestMarkdown)(digest.markdown, cwd);
|
|
644
|
+
console.log(`\nโ
Digest written: ${digestFile}`);
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
case 'export': {
|
|
648
|
+
const csvPath = await storage_1.StorageManager.exportToCsv(cwd);
|
|
649
|
+
console.log(`โ
Successfully exported telemetry dataset to CSV: ${csvPath}`);
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
case 'churn': {
|
|
653
|
+
console.log('\n๐ Analyzing longitudinal code survival rate (git blame HEAD + windowed 30-day)...');
|
|
654
|
+
const sessions = await storage_1.StorageManager.loadSessions(cwd);
|
|
655
|
+
const config = config_1.ConfigManager.loadConfig(cwd);
|
|
656
|
+
const ignoreFilter = new ignore_1.IgnoreFilter(cwd, config.storageDir);
|
|
657
|
+
let updatedCount = 0;
|
|
658
|
+
const eligible = sessions.filter(s => s.diff.totalInsertions > 0 && s.aiTool !== 'none');
|
|
659
|
+
const touchedFiles = new Set();
|
|
660
|
+
for (const s of eligible) {
|
|
661
|
+
for (const f of s.diff.files) {
|
|
662
|
+
if (!f.isBinary)
|
|
663
|
+
touchedFiles.add(f.path);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (touchedFiles.size > 0) {
|
|
667
|
+
// Reuse the same reverse-attribution blame engine as the retro scan (Pass 2) instead of
|
|
668
|
+
// a bespoke, less correct blame parser โ one blame per file at HEAD, amortized.
|
|
669
|
+
const survivalResult = await (0, survival_1.computeSurvival)(Array.from(touchedFiles), { cwd, ignoreFilter });
|
|
670
|
+
const skippedSet = new Set(survivalResult.skippedFiles);
|
|
671
|
+
const windowedInputs = eligible.map(s => ({
|
|
672
|
+
commitHash: s.commitHash,
|
|
673
|
+
authorDate: s.timestamp,
|
|
674
|
+
insertions: s.diff.totalInsertions,
|
|
675
|
+
files: s.diff.files.filter(f => !f.isBinary && !ignoreFilter.shouldIgnore(f.path)).map(f => f.path)
|
|
676
|
+
}));
|
|
677
|
+
const windowedResult = await (0, survival_1.computeWindowedSurvival)(windowedInputs, { cwd });
|
|
678
|
+
for (const s of eligible) {
|
|
679
|
+
const unknown = s.diff.files.some(f => !f.isBinary && skippedSet.has(f.path));
|
|
680
|
+
const rate = unknown
|
|
681
|
+
? null
|
|
682
|
+
: (0, survival_1.survivalRateFor)(survivalResult.survivingLines.get(s.commitHash) || 0, s.diff.totalInsertions);
|
|
683
|
+
if (rate === null)
|
|
684
|
+
continue;
|
|
685
|
+
s.churn = {
|
|
686
|
+
linesReverted: Math.max(0, s.diff.totalInsertions - (survivalResult.survivingLines.get(s.commitHash) || 0)),
|
|
687
|
+
survivalRate: rate,
|
|
688
|
+
survival30d: windowedResult.survival30d.get(s.commitHash)
|
|
689
|
+
};
|
|
690
|
+
updatedCount++;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
storage_1.StorageManager.saveAllSessions(sessions, cwd);
|
|
694
|
+
// Regenerate reports
|
|
695
|
+
const stats = analyzer_1.MetricsAnalyzer.analyze(sessions);
|
|
696
|
+
reporter_1.ReportGenerator.generateReport(stats, sessions, cwd);
|
|
697
|
+
console.log(`โ
Code churn statistics calculated for ${updatedCount} sessions.`);
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
case 'trust': {
|
|
701
|
+
config_1.GlobalConfigManager.trustWorkspace(cwd);
|
|
702
|
+
console.log(`โ
Trusted this workspace. Custom test commands are now allowed to run.`);
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
case 'untrust': {
|
|
706
|
+
config_1.GlobalConfigManager.untrustWorkspace(cwd);
|
|
707
|
+
console.log(`โ
Untrusted this workspace. Custom test commands are now blocked from executing.`);
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
case 'uninstall': {
|
|
711
|
+
const success = hooks_1.HookManager.uninstallHook(cwd);
|
|
712
|
+
if (success) {
|
|
713
|
+
console.log('โ
Post-commit git hook removed successfully.');
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
console.log('โน๏ธ No DevTrace-AI hook detected in this repository.');
|
|
717
|
+
}
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
case 'claude-hook': {
|
|
721
|
+
// Invoked BY Claude Code itself (PostToolUse / SessionStart hook command).
|
|
722
|
+
// Must never throw, never block, and always exit 0 โ see src/claude-hooks.ts.
|
|
723
|
+
(0, claude_hooks_1.runClaudeHookCli)();
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
case 'claude-install': {
|
|
727
|
+
const beforeCount = (0, claude_hook_install_1.countDevtraceEntries)(readClaudeSettingsForSummary());
|
|
728
|
+
const result = (0, claude_hook_install_1.installClaudeHooks)();
|
|
729
|
+
const afterCount = (0, claude_hook_install_1.countDevtraceEntries)(result.after);
|
|
730
|
+
console.log(`\n๐ Installing Claude Code hook integration (PostToolUse + SessionStart)...`);
|
|
731
|
+
console.log(` Settings file: ${result.settingsPath}`);
|
|
732
|
+
console.log(` DevTrace hook entries: ${beforeCount} -> ${afterCount}`);
|
|
733
|
+
console.log(`โ
Claude Code will now report Edit/Write/MultiEdit + session-start events to any initialized DevTrace repo (.devtrace/claude-ledger.jsonl).`);
|
|
734
|
+
console.log(` Metadata only โ prompt text and file contents are never recorded.`);
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
case 'claude-uninstall': {
|
|
738
|
+
const beforeCount = (0, claude_hook_install_1.countDevtraceEntries)(readClaudeSettingsForSummary());
|
|
739
|
+
const result = (0, claude_hook_install_1.uninstallClaudeHooks)();
|
|
740
|
+
const afterCount = (0, claude_hook_install_1.countDevtraceEntries)(result.after);
|
|
741
|
+
console.log(`\n๐ Removing Claude Code hook integration...`);
|
|
742
|
+
console.log(` Settings file: ${result.settingsPath}`);
|
|
743
|
+
console.log(` DevTrace hook entries: ${beforeCount} -> ${afterCount}`);
|
|
744
|
+
console.log(`โ
Claude Code hook integration removed. Other hooks/settings were left untouched.`);
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
case 'status':
|
|
748
|
+
default: {
|
|
749
|
+
const config = config_1.ConfigManager.loadConfig(cwd);
|
|
750
|
+
const jsonlPath = storage_1.StorageManager.getJsonlPath(cwd);
|
|
751
|
+
const sessions = await storage_1.StorageManager.loadSessions(cwd);
|
|
752
|
+
console.log('\n==== DevTrace-AI Telemetry Status ====');
|
|
753
|
+
console.log(`Project Directory: ${cwd}`);
|
|
754
|
+
console.log(`Storage Directory: ${config.storageDir}`);
|
|
755
|
+
console.log(`Workspace Trust: ${config_1.GlobalConfigManager.isWorkspaceTrusted(cwd) ? 'โ
Trusted' : 'โ Untrusted'}`);
|
|
756
|
+
console.log(`Git Hook Mode: ${config.runTestsAsync ? 'Asynchronous (Background)' : 'Synchronous'}`);
|
|
757
|
+
console.log(`Traced Sessions: ${sessions.length}`);
|
|
758
|
+
let hookActive = false;
|
|
759
|
+
try {
|
|
760
|
+
const hooksDir = hooks_1.HookManager.getHooksDirectory(cwd);
|
|
761
|
+
const hookPath = path.join(hooksDir, 'post-commit');
|
|
762
|
+
hookActive = fs.existsSync(hookPath) && fs.readFileSync(hookPath, 'utf8').includes('DEVTRACE-AI');
|
|
763
|
+
}
|
|
764
|
+
catch { }
|
|
765
|
+
console.log(`Git Hook Installed: ${hookActive ? 'โ
Active' : 'โ Inactive'}`);
|
|
766
|
+
if (sessions.length > 0) {
|
|
767
|
+
console.log(`\nLast Session Commit: ${sessions[sessions.length - 1].commitHash.substring(0, 8)}`);
|
|
768
|
+
}
|
|
769
|
+
const measured30d = sessions
|
|
770
|
+
.map(s => s.churn?.survival30d)
|
|
771
|
+
.filter((v) => v !== undefined && v.status === 'measured');
|
|
772
|
+
if (measured30d.length > 0) {
|
|
773
|
+
const avg = measured30d.reduce((sum, v) => sum + v.rate, 0) / measured30d.length;
|
|
774
|
+
console.log(`AI lines surviving 30 days: ${(avg * 100).toFixed(0)}% (n=${measured30d.length} commit${measured30d.length === 1 ? '' : 's'} measured)`);
|
|
775
|
+
}
|
|
776
|
+
console.log('\nAvailable Subcommands:');
|
|
777
|
+
console.log(' init Install hook, default config, and run the retroactive scan');
|
|
778
|
+
console.log(' scan Re-run the retroactive scan only (--fast, --since <n>d)');
|
|
779
|
+
console.log(' annotate Rate and annotate the last session');
|
|
780
|
+
console.log(' report Rebuild DEVTRACE_LOG.md + self-contained HTML report (--out <path>)');
|
|
781
|
+
console.log(' card Generate a shareable SVG scorecard (--out <path>)');
|
|
782
|
+
console.log(' digest Print + write a digest of recent activity (--since <n>d, default 7d)');
|
|
783
|
+
console.log(' churn Retroactively analyze code survival rates');
|
|
784
|
+
console.log(' export Compile session data to CSV');
|
|
785
|
+
console.log(' trust Trust this workspace to run custom testCommand');
|
|
786
|
+
console.log(' untrust Untrust this workspace');
|
|
787
|
+
console.log(' uninstall Remove post-commit hook');
|
|
788
|
+
console.log('\nIntegration (first-party Claude Code capture, tier-2 instrumented):');
|
|
789
|
+
console.log(' claude-install Install Claude Code hooks (PostToolUse + SessionStart)');
|
|
790
|
+
console.log(' claude-uninstall Remove Claude Code hooks');
|
|
791
|
+
break;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
run().catch(err => {
|
|
796
|
+
console.error(`โ Unexpected execution error: ${err.message}`);
|
|
797
|
+
process.exit(1);
|
|
798
|
+
});
|