@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# DevTrace
|
|
2
|
+
|
|
3
|
+
**Know what your AI code actually costs you.** DevTrace is a local-first CLI that scans your git history and tells you what share of your codebase is AI-authored — and how much of it actually survived.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
# coming soon (not yet published to npm)
|
|
7
|
+
npx @devtrace-ai/cli init
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
**Works today, from source:**
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
git clone https://github.com/davidgdb/DevTrace.git devtrace
|
|
14
|
+
cd devtrace
|
|
15
|
+
npm install
|
|
16
|
+
npm run build
|
|
17
|
+
node dist/index.js init
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`init` installs a git post-commit hook for forward capture, then retro-scans your last 90 days of commit history and shows you the reveal box below in seconds. Nothing leaves your machine.
|
|
21
|
+
|
|
22
|
+
## The reveal
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
┌─ Your AI code, last 90 days ─────────────────────────
|
|
26
|
+
│ AI-authored commits: 42 (31% of 134 commits)
|
|
27
|
+
│ AI lines still at HEAD: 68% ← lines still attributed to their original commit at HEAD
|
|
28
|
+
│ Human lines still at HEAD: 81%
|
|
29
|
+
│ By tool:
|
|
30
|
+
│ Claude 71% survival (28 commits)
|
|
31
|
+
│ Cursor 52% survival (14 commits, 3 unknown)
|
|
32
|
+
│ AI lines rewritten since: ~1,204
|
|
33
|
+
└──────────────────────────────────────────────────────────────
|
|
34
|
+
→ devtrace status see traced session count
|
|
35
|
+
→ devtrace report full breakdown (DEVTRACE_LOG.md)
|
|
36
|
+
→ devtrace churn re-run windowed survival later
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Numbers above are illustrative, not sample data from a real run.
|
|
40
|
+
|
|
41
|
+
## How detection works
|
|
42
|
+
|
|
43
|
+
DevTrace is trailer-first: it only claims high confidence when git gives it hard evidence, and says so honestly when it doesn't.
|
|
44
|
+
|
|
45
|
+
| Tier | Meaning | Confidence | Basis |
|
|
46
|
+
|---|---|---|---|
|
|
47
|
+
| 1 — Confirmed | Commit trailer / co-author line (e.g. `Claude`, `Cursor`, `Copilot-Agent-Logs-Url`) | 0.95 | Fact, not inference |
|
|
48
|
+
| 2 — Instrumented | DevTrace's own Claude Code hook observed the edits behind the commit (`devtrace claude-install`) | 0.85 | Observed generation, not vendor-attested |
|
|
49
|
+
| 3 — Heuristic | Commit velocity (<45s after parent, >20 lines) or greenfield diff shape (150+ insertions, 0 deletions) | capped at 0.5, labeled `unknown-ai` | Weak prior, never proof |
|
|
50
|
+
| 0 — Unknown | No signal either way | 0.3 | Honest "we don't know" — never fabricated as human |
|
|
51
|
+
|
|
52
|
+
**Limits, honestly stated:** tools that don't leave a commit trailer (Copilot in most default configurations, plain inline completions, etc.) are nearly invisible to git-history analysis — DevTrace can only see what git records. Tier 3 heuristics are weak statistical priors that can misfire on `--amend`, squashes, rebases, or vendored code drops; they are never presented as certainty.
|
|
53
|
+
|
|
54
|
+
## Privacy
|
|
55
|
+
|
|
56
|
+
100% local. All session data is stored as JSONL on your own disk (`.devtrace/` by default). No network calls, no telemetry, no accounts. Only commit metadata (hashes, diff stats, timestamps, detected tool/confidence) is captured — never your source code or prompt content unless you explicitly annotate a session with it.
|
|
57
|
+
|
|
58
|
+
## Commands
|
|
59
|
+
|
|
60
|
+
| Command | What it does |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `devtrace init` | Install the git hook, write default config, run the retroactive scan |
|
|
63
|
+
| `devtrace scan [--fast] [--since <n>d]` | Re-run the retroactive scan only. `--fast` skips the git-blame survival pass (numstat-only); `--since 30d` narrows the window (default 90 days) |
|
|
64
|
+
| `devtrace status` | Show trust state, hook status, and traced session count (also the default with no subcommand) |
|
|
65
|
+
| `devtrace report [--out <path>]` | Rebuild `DEVTRACE_LOG.md` and generate a self-contained HTML report (`.devtrace/report.html`) |
|
|
66
|
+
| `devtrace card [--out <path>]` | Generate a shareable SVG scorecard (`.devtrace/card.svg`, social-card ratio) |
|
|
67
|
+
| `devtrace digest [--since <n>d]` | Print + write a markdown digest of the last 7 days (`.devtrace/digest.md`) |
|
|
68
|
+
| `devtrace churn` | Retroactively recompute code survival rates via `git blame` |
|
|
69
|
+
| `devtrace export` | Export the session dataset to CSV (injection-sanitized) |
|
|
70
|
+
| `devtrace annotate [--id <id>] [--correctness N] [--completeness N] [--effort N] [--prompt ...] [--notes ...] [--model ...] [--complexity ...]` | Rate/annotate the last session, or mark it human-only |
|
|
71
|
+
| `devtrace trust` / `devtrace untrust` | Allow or block this workspace from running its configured test command on commit |
|
|
72
|
+
| `devtrace uninstall` | Remove the post-commit hook |
|
|
73
|
+
| `devtrace claude-install` / `claude-uninstall` | Add/remove the Claude Code hook integration (tier-2 capture, metadata only — never prompt text) |
|
|
74
|
+
|
|
75
|
+
## Status
|
|
76
|
+
|
|
77
|
+
[](LICENSE)
|
|
78
|
+

|
|
79
|
+

|
|
80
|
+
|
|
81
|
+
Zero runtime dependencies. Not yet published to npm.
|
|
82
|
+
|
|
83
|
+
30-day windowed survival is measured honestly: commits younger than 30 days report "window still open", never a fabricated number.
|
|
84
|
+
|
|
85
|
+
## Roadmap (planned, not yet built)
|
|
86
|
+
|
|
87
|
+
- PNG export for the scorecard card (WASM rasterizer, opt-in — the core stays zero-dependency)
|
|
88
|
+
- Team dashboard across multiple repos/contributors
|
|
89
|
+
|
|
90
|
+
## Contributing
|
|
91
|
+
|
|
92
|
+
Issues and PRs welcome. Build with `npm run build`, test with `npm test` (`node --test`). No dependencies to keep the CLI auditable — please keep it that way.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT — see [LICENSE](LICENSE).
|
package/dist/analyzer.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MetricsAnalyzer = void 0;
|
|
4
|
+
class MetricsAnalyzer {
|
|
5
|
+
static analyze(sessions) {
|
|
6
|
+
const byToolStats = {};
|
|
7
|
+
const byChangeTypeStats = {};
|
|
8
|
+
const stats = {
|
|
9
|
+
totalCommits: sessions.length,
|
|
10
|
+
aiCommits: 0,
|
|
11
|
+
humanCommits: 0,
|
|
12
|
+
aiContributionRate: 0,
|
|
13
|
+
avgInsertions: 0,
|
|
14
|
+
avgDeletions: 0,
|
|
15
|
+
reworkRate: 0,
|
|
16
|
+
byTool: byToolStats,
|
|
17
|
+
byChangeType: byChangeTypeStats,
|
|
18
|
+
timeline: {}
|
|
19
|
+
};
|
|
20
|
+
// Initialize tools
|
|
21
|
+
const tools = ['claude-code', 'gemini-cli', 'cursor', 'copilot', 'codex', 'aider', 'ollama', 'other', 'unknown-ai', 'none'];
|
|
22
|
+
const toolAnnotatedCounts = {
|
|
23
|
+
'claude-code': 0,
|
|
24
|
+
'gemini-cli': 0,
|
|
25
|
+
'cursor': 0,
|
|
26
|
+
'copilot': 0,
|
|
27
|
+
'codex': 0,
|
|
28
|
+
'aider': 0,
|
|
29
|
+
'ollama': 0,
|
|
30
|
+
'other': 0,
|
|
31
|
+
'unknown-ai': 0,
|
|
32
|
+
'none': 0
|
|
33
|
+
};
|
|
34
|
+
for (const tool of tools) {
|
|
35
|
+
stats.byTool[tool] = {
|
|
36
|
+
count: 0,
|
|
37
|
+
insertions: 0,
|
|
38
|
+
deletions: 0,
|
|
39
|
+
testPasses: 0,
|
|
40
|
+
testFailures: 0,
|
|
41
|
+
avgCorrectness: 0,
|
|
42
|
+
avgCompleteness: 0,
|
|
43
|
+
avgEffort: 0
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// Initialize types
|
|
47
|
+
const types = ['feature', 'bugfix', 'refactor', 'test', 'docs', 'config', 'style', 'unknown'];
|
|
48
|
+
for (const type of types) {
|
|
49
|
+
stats.byChangeType[type] = {
|
|
50
|
+
count: 0,
|
|
51
|
+
aiCount: 0,
|
|
52
|
+
humanCount: 0,
|
|
53
|
+
avgInsertions: 0
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
let grandInsertions = 0;
|
|
57
|
+
let grandDeletions = 0;
|
|
58
|
+
let totalReworkInsertions = 0;
|
|
59
|
+
let totalReworkDeletions = 0;
|
|
60
|
+
for (const s of sessions) {
|
|
61
|
+
const isAI = s.aiTool !== 'none';
|
|
62
|
+
if (isAI)
|
|
63
|
+
stats.aiCommits++;
|
|
64
|
+
else
|
|
65
|
+
stats.humanCommits++;
|
|
66
|
+
grandInsertions += s.diff.totalInsertions;
|
|
67
|
+
grandDeletions += s.diff.totalDeletions;
|
|
68
|
+
// Group by tool (safely fallback to 'other' if tool is unrecognized)
|
|
69
|
+
let toolKey = s.aiTool;
|
|
70
|
+
if (!stats.byTool[toolKey]) {
|
|
71
|
+
toolKey = 'other';
|
|
72
|
+
}
|
|
73
|
+
const toolStats = stats.byTool[toolKey];
|
|
74
|
+
toolStats.count++;
|
|
75
|
+
toolStats.insertions += s.diff.totalInsertions;
|
|
76
|
+
toolStats.deletions += s.diff.totalDeletions;
|
|
77
|
+
if (s.testResult) {
|
|
78
|
+
if (s.testResult.exitCode === 0)
|
|
79
|
+
toolStats.testPasses++;
|
|
80
|
+
else
|
|
81
|
+
toolStats.testFailures++;
|
|
82
|
+
}
|
|
83
|
+
if (s.annotation) {
|
|
84
|
+
let hasAnnotation = false;
|
|
85
|
+
if (s.annotation.correctness !== undefined && s.annotation.correctness !== null) {
|
|
86
|
+
toolStats.avgCorrectness += s.annotation.correctness;
|
|
87
|
+
hasAnnotation = true;
|
|
88
|
+
}
|
|
89
|
+
if (s.annotation.completeness !== undefined && s.annotation.completeness !== null) {
|
|
90
|
+
toolStats.avgCompleteness += s.annotation.completeness;
|
|
91
|
+
hasAnnotation = true;
|
|
92
|
+
}
|
|
93
|
+
if (s.annotation.effortToIntegrate !== undefined && s.annotation.effortToIntegrate !== null) {
|
|
94
|
+
toolStats.avgEffort += s.annotation.effortToIntegrate;
|
|
95
|
+
hasAnnotation = true;
|
|
96
|
+
}
|
|
97
|
+
if (hasAnnotation) {
|
|
98
|
+
toolAnnotatedCounts[toolKey]++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Group by change type (safely fallback to 'unknown' if unrecognized)
|
|
102
|
+
let typeKey = s.changeType;
|
|
103
|
+
if (!stats.byChangeType[typeKey]) {
|
|
104
|
+
typeKey = 'unknown';
|
|
105
|
+
}
|
|
106
|
+
const typeStats = stats.byChangeType[typeKey];
|
|
107
|
+
typeStats.count++;
|
|
108
|
+
typeStats.avgInsertions += s.diff.totalInsertions;
|
|
109
|
+
if (isAI)
|
|
110
|
+
typeStats.aiCount++;
|
|
111
|
+
else
|
|
112
|
+
typeStats.humanCount++;
|
|
113
|
+
// Timeline aggregation (YYYY-MM-DD)
|
|
114
|
+
const dateStr = s.timestamp.substring(0, 10);
|
|
115
|
+
if (!stats.timeline[dateStr]) {
|
|
116
|
+
stats.timeline[dateStr] = { total: 0, ai: 0, human: 0 };
|
|
117
|
+
}
|
|
118
|
+
stats.timeline[dateStr].total++;
|
|
119
|
+
if (isAI)
|
|
120
|
+
stats.timeline[dateStr].ai++;
|
|
121
|
+
else
|
|
122
|
+
stats.timeline[dateStr].human++;
|
|
123
|
+
// Rework tracking aggregation
|
|
124
|
+
if (s.churn) {
|
|
125
|
+
totalReworkDeletions += s.churn.linesReverted;
|
|
126
|
+
totalReworkInsertions += s.diff.totalInsertions;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Compute averages
|
|
130
|
+
if (stats.totalCommits > 0) {
|
|
131
|
+
stats.aiContributionRate = parseFloat(((stats.aiCommits / stats.totalCommits) * 100).toFixed(1));
|
|
132
|
+
stats.avgInsertions = parseFloat((grandInsertions / stats.totalCommits).toFixed(1));
|
|
133
|
+
stats.avgDeletions = parseFloat((grandDeletions / stats.totalCommits).toFixed(1));
|
|
134
|
+
}
|
|
135
|
+
if (totalReworkInsertions > 0) {
|
|
136
|
+
stats.reworkRate = parseFloat(((totalReworkDeletions / totalReworkInsertions) * 100).toFixed(1));
|
|
137
|
+
}
|
|
138
|
+
// Normalize tool averages
|
|
139
|
+
for (const tool of tools) {
|
|
140
|
+
const toolStats = stats.byTool[tool];
|
|
141
|
+
const annotatedCount = toolAnnotatedCounts[tool];
|
|
142
|
+
if (annotatedCount > 0) {
|
|
143
|
+
toolStats.avgCorrectness = parseFloat((toolStats.avgCorrectness / annotatedCount).toFixed(1));
|
|
144
|
+
toolStats.avgCompleteness = parseFloat((toolStats.avgCompleteness / annotatedCount).toFixed(1));
|
|
145
|
+
toolStats.avgEffort = parseFloat((toolStats.avgEffort / annotatedCount).toFixed(1));
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
toolStats.avgCorrectness = 0;
|
|
149
|
+
toolStats.avgCompleteness = 0;
|
|
150
|
+
toolStats.avgEffort = 0;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Normalize change type averages
|
|
154
|
+
for (const type of types) {
|
|
155
|
+
const typeStats = stats.byChangeType[type];
|
|
156
|
+
if (typeStats.count > 0) {
|
|
157
|
+
typeStats.avgInsertions = parseFloat((typeStats.avgInsertions / typeStats.count).toFixed(1));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return stats;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
exports.MetricsAnalyzer = MetricsAnalyzer;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.ClaudeCaptureSource = void 0;
|
|
37
|
+
exports.slugifyCwd = slugifyCwd;
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
/**
|
|
41
|
+
* Slugifies an absolute path into the directory-name form Claude Code uses
|
|
42
|
+
* under ~/.claude/projects/. See module doc comment for the verified rule.
|
|
43
|
+
*/
|
|
44
|
+
function slugifyCwd(cwd) {
|
|
45
|
+
return cwd.replace(/[^A-Za-z0-9]/g, '-');
|
|
46
|
+
}
|
|
47
|
+
class ClaudeCaptureSource {
|
|
48
|
+
/** Base directory containing per-project transcript folders. Injectable for tests. */
|
|
49
|
+
baseDir;
|
|
50
|
+
constructor(baseDir) {
|
|
51
|
+
if (baseDir) {
|
|
52
|
+
this.baseDir = baseDir;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
56
|
+
this.baseDir = homeDir ? path.join(homeDir, '.claude', 'projects') : '';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Resolves the transcript directory for a given repo working directory.
|
|
61
|
+
* Returns null if it can't be resolved (no home dir) or doesn't exist.
|
|
62
|
+
*/
|
|
63
|
+
resolveProjectDir(cwd) {
|
|
64
|
+
if (!this.baseDir)
|
|
65
|
+
return null;
|
|
66
|
+
const dir = path.join(this.baseDir, slugifyCwd(cwd));
|
|
67
|
+
if (!fs.existsSync(dir))
|
|
68
|
+
return null;
|
|
69
|
+
return dir;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Finds the most recently modified transcript (*.jsonl) for the given cwd,
|
|
73
|
+
* optionally bounded to files modified within `maxAgeMs` of now (recency
|
|
74
|
+
* window, e.g. to correlate a capture with a just-made commit).
|
|
75
|
+
*/
|
|
76
|
+
findLatestTranscript(cwd, maxAgeMs) {
|
|
77
|
+
const projectDir = this.resolveProjectDir(cwd);
|
|
78
|
+
if (!projectDir)
|
|
79
|
+
return null;
|
|
80
|
+
let entries;
|
|
81
|
+
try {
|
|
82
|
+
entries = fs.readdirSync(projectDir);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const candidates = [];
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (!entry.endsWith('.jsonl'))
|
|
90
|
+
continue;
|
|
91
|
+
const filePath = path.join(projectDir, entry);
|
|
92
|
+
try {
|
|
93
|
+
const stat = fs.statSync(filePath);
|
|
94
|
+
if (!stat.isFile())
|
|
95
|
+
continue;
|
|
96
|
+
candidates.push({ filePath, mtimeMs: stat.mtimeMs });
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Skip files that vanish/error between readdir and stat.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (candidates.length === 0)
|
|
103
|
+
return null;
|
|
104
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
105
|
+
const newest = candidates[0];
|
|
106
|
+
if (maxAgeMs !== undefined && Date.now() - newest.mtimeMs > maxAgeMs) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return newest;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Extracts the plain text of a user/assistant `message.content` field,
|
|
113
|
+
* which may be a raw string or an array of content blocks (text,
|
|
114
|
+
* tool_result, thinking, etc). Unknown block shapes are ignored.
|
|
115
|
+
*/
|
|
116
|
+
static extractText(content) {
|
|
117
|
+
if (typeof content === 'string') {
|
|
118
|
+
return content;
|
|
119
|
+
}
|
|
120
|
+
if (Array.isArray(content)) {
|
|
121
|
+
const parts = [];
|
|
122
|
+
for (const block of content) {
|
|
123
|
+
if (block && typeof block === 'object') {
|
|
124
|
+
const b = block;
|
|
125
|
+
if (b.type === 'text' && typeof b.text === 'string') {
|
|
126
|
+
parts.push(b.text);
|
|
127
|
+
}
|
|
128
|
+
// tool_result / tool_use / thinking blocks intentionally skipped:
|
|
129
|
+
// they aren't the human-authored prompt text we want to surface.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return parts.join('\n');
|
|
133
|
+
}
|
|
134
|
+
return '';
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Parses a transcript JSONL file and extracts the latest user prompt text
|
|
138
|
+
* plus the most recently observed assistant model name. Tolerates
|
|
139
|
+
* malformed lines by skipping them.
|
|
140
|
+
*/
|
|
141
|
+
static parseTranscript(filePath) {
|
|
142
|
+
let raw;
|
|
143
|
+
try {
|
|
144
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const lines = raw.split('\n');
|
|
150
|
+
let latestUserPrompt = '';
|
|
151
|
+
let latestUserTimestamp = -Infinity;
|
|
152
|
+
let latestModel = null;
|
|
153
|
+
let latestModelTimestamp = -Infinity;
|
|
154
|
+
let sessionId = '';
|
|
155
|
+
for (const line of lines) {
|
|
156
|
+
const trimmed = line.trim();
|
|
157
|
+
if (!trimmed)
|
|
158
|
+
continue;
|
|
159
|
+
let parsed;
|
|
160
|
+
try {
|
|
161
|
+
parsed = JSON.parse(trimmed);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Malformed line: skip and keep going (format is unstable by design).
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (!parsed || typeof parsed !== 'object')
|
|
168
|
+
continue;
|
|
169
|
+
if (typeof parsed.sessionId === 'string' && parsed.sessionId) {
|
|
170
|
+
sessionId = parsed.sessionId;
|
|
171
|
+
}
|
|
172
|
+
const ts = typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : NaN;
|
|
173
|
+
const tsOrOrder = Number.isNaN(ts) ? 0 : ts;
|
|
174
|
+
if (parsed.type === 'user') {
|
|
175
|
+
const content = parsed.message?.content;
|
|
176
|
+
// Only treat string or block-array content with actual text as a
|
|
177
|
+
// real user-authored prompt; tool_result-only turns extract to ''
|
|
178
|
+
// and are skipped so we don't surface tool output as "the prompt".
|
|
179
|
+
const text = ClaudeCaptureSource.extractText(content);
|
|
180
|
+
if (text && tsOrOrder >= latestUserTimestamp) {
|
|
181
|
+
latestUserTimestamp = tsOrOrder;
|
|
182
|
+
latestUserPrompt = text;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else if (parsed.type === 'assistant') {
|
|
186
|
+
const model = parsed.message?.model;
|
|
187
|
+
if (typeof model === 'string' && model && tsOrOrder >= latestModelTimestamp) {
|
|
188
|
+
latestModelTimestamp = tsOrOrder;
|
|
189
|
+
latestModel = model;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!latestUserPrompt)
|
|
194
|
+
return null;
|
|
195
|
+
return { promptText: latestUserPrompt, model: latestModel, sessionId: sessionId || path.basename(filePath, '.jsonl') };
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Public API: returns the latest user prompt + model + session id for
|
|
199
|
+
* THIS repo (cwd-correlated via the slugified project dir), or null if
|
|
200
|
+
* unavailable. `maxAgeMs`, if given, only considers transcripts modified
|
|
201
|
+
* within that window of now (e.g. to correlate with a just-made commit).
|
|
202
|
+
*/
|
|
203
|
+
getLatestPrompt(cwd, maxAgeMs) {
|
|
204
|
+
const latest = this.findLatestTranscript(cwd, maxAgeMs);
|
|
205
|
+
if (!latest)
|
|
206
|
+
return null;
|
|
207
|
+
const parsed = ClaudeCaptureSource.parseTranscript(latest.filePath);
|
|
208
|
+
if (!parsed)
|
|
209
|
+
return null;
|
|
210
|
+
return {
|
|
211
|
+
promptText: parsed.promptText,
|
|
212
|
+
model: parsed.model,
|
|
213
|
+
sessionId: parsed.sessionId,
|
|
214
|
+
transcriptPath: latest.filePath,
|
|
215
|
+
mtimeMs: latest.mtimeMs
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
exports.ClaudeCaptureSource = ClaudeCaptureSource;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TrailerParser = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Parses git commit trailers and known message conventions for AI-provenance signals.
|
|
6
|
+
* Pure functions — no git calls, no I/O. See docs/plans/03b Gap 3 trailer table.
|
|
7
|
+
*/
|
|
8
|
+
class TrailerParser {
|
|
9
|
+
/**
|
|
10
|
+
* Runs every known trailer/pattern check against a commit message (and optionally
|
|
11
|
+
* the commit author name, needed for Aider's ` (aider)` author-suffix convention).
|
|
12
|
+
* Returns all matches found (a commit can carry more than one trailer).
|
|
13
|
+
*/
|
|
14
|
+
static parse(commitMessage, authorName = '') {
|
|
15
|
+
const matches = [];
|
|
16
|
+
const message = commitMessage || '';
|
|
17
|
+
this.matchClaudeCode(message, matches);
|
|
18
|
+
this.matchCopilot(message, matches);
|
|
19
|
+
this.matchCursor(message, matches);
|
|
20
|
+
this.matchCodex(message, matches);
|
|
21
|
+
this.matchAider(message, authorName, matches);
|
|
22
|
+
this.matchAssistedBy(message, matches);
|
|
23
|
+
return matches;
|
|
24
|
+
}
|
|
25
|
+
/** Convenience: true if any trailer/pattern matched. */
|
|
26
|
+
static hasMatch(commitMessage, authorName = '') {
|
|
27
|
+
return this.parse(commitMessage, authorName).length > 0;
|
|
28
|
+
}
|
|
29
|
+
static matchClaudeCode(message, matches) {
|
|
30
|
+
// Co-Authored-By: Claude <noreply@anthropic.com> (trailer key is case-insensitive)
|
|
31
|
+
if (/^co-authored-by:\s*claude\s*<noreply@anthropic\.com>/im.test(message)) {
|
|
32
|
+
matches.push({ tool: 'claude-code', pattern: 'trailer:claude-code' });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
// "Generated with Claude Code" attribution line
|
|
36
|
+
if (/generated with\s*\[?claude code\]?/i.test(message)) {
|
|
37
|
+
matches.push({ tool: 'claude-code', pattern: 'trailer:claude-code-generated-with' });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
static matchCopilot(message, matches) {
|
|
41
|
+
// Co-authored-by: Copilot <...@users.noreply.github.com> or <copilot@github.com>
|
|
42
|
+
const coAuthored = /^co-authored-by:\s*copilot\s*<[^>]*(?:users\.noreply\.github\.com|copilot@github\.com)>/im;
|
|
43
|
+
if (coAuthored.test(message)) {
|
|
44
|
+
matches.push({ tool: 'copilot', pattern: 'trailer:copilot' });
|
|
45
|
+
}
|
|
46
|
+
// Agent-Logs-Url trailer indicates Copilot coding agent
|
|
47
|
+
if (/^agent-logs-url:/im.test(message)) {
|
|
48
|
+
matches.push({ tool: 'copilot', pattern: 'trailer:copilot-agent-logs-url' });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
static matchCursor(message, matches) {
|
|
52
|
+
// Co-authored-by: Cursor <cursoragent@cursor.com>
|
|
53
|
+
if (/^co-authored-by:\s*cursor\s*<cursoragent@cursor\.com>/im.test(message)) {
|
|
54
|
+
matches.push({ tool: 'cursor', pattern: 'trailer:cursor' });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
static matchCodex(message, matches) {
|
|
58
|
+
// Co-authored-by: Codex <noreply@openai.com> — medium confidence, unverified vendor string
|
|
59
|
+
if (/^co-authored-by:\s*codex\s*<noreply@openai\.com>/im.test(message)) {
|
|
60
|
+
matches.push({ tool: 'codex', pattern: 'unverified-pattern', unverified: true });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
static matchAider(message, authorName, matches) {
|
|
64
|
+
// Author-name suffix: "Some Name (aider)"
|
|
65
|
+
if (/\(aider\)\s*$/i.test(authorName.trim())) {
|
|
66
|
+
matches.push({ tool: 'aider', pattern: 'trailer:aider-author-suffix' });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// Co-authored-by: aider (aider commits its own author line, but flag also supports a trailer)
|
|
70
|
+
if (/^co-authored-by:\s*aider\b/im.test(message)) {
|
|
71
|
+
matches.push({ tool: 'aider', pattern: 'trailer:aider-co-authored-by' });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Message prefix convention: "aider: <message>"
|
|
75
|
+
if (/^aider:\s*/i.test(message.trim())) {
|
|
76
|
+
matches.push({ tool: 'aider', pattern: 'trailer:aider-message-prefix' });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
static matchAssistedBy(message, matches) {
|
|
80
|
+
// Assisted-by: <agent>:<model> (RFC trailer, Linux kernel/Fedora/Apache/LLVM/OpenTelemetry)
|
|
81
|
+
const re = /^assisted-by:\s*([\w.\-]+):([\w.\-]+)/im;
|
|
82
|
+
const m = re.exec(message);
|
|
83
|
+
if (m) {
|
|
84
|
+
const agentToken = m[1].toLowerCase();
|
|
85
|
+
const model = m[2];
|
|
86
|
+
matches.push({
|
|
87
|
+
tool: this.agentTokenToTool(agentToken),
|
|
88
|
+
model,
|
|
89
|
+
pattern: 'trailer:assisted-by'
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Maps the free-form agent token in an Assisted-by trailer to a known AITool. */
|
|
94
|
+
static agentTokenToTool(agentToken) {
|
|
95
|
+
if (/claude/.test(agentToken))
|
|
96
|
+
return 'claude-code';
|
|
97
|
+
if (/copilot/.test(agentToken))
|
|
98
|
+
return 'copilot';
|
|
99
|
+
if (/cursor/.test(agentToken))
|
|
100
|
+
return 'cursor';
|
|
101
|
+
if (/codex/.test(agentToken))
|
|
102
|
+
return 'codex';
|
|
103
|
+
if (/aider/.test(agentToken))
|
|
104
|
+
return 'aider';
|
|
105
|
+
if (/gemini/.test(agentToken))
|
|
106
|
+
return 'gemini-cli';
|
|
107
|
+
if (/ollama/.test(agentToken))
|
|
108
|
+
return 'ollama';
|
|
109
|
+
return 'other';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
exports.TrailerParser = TrailerParser;
|