shakers-cli-beta 0.8.0-beta.4 → 0.9.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/README.md +20 -11
- package/bin/ai-usage.js +5 -0
- package/bin/login.js +1 -1
- package/bin/mcp.js +25 -12
- package/package.json +4 -2
- package/src/authorship.js +7 -6
- package/src/certify-dimension-client.js +0 -54
- package/src/certify-dimension-flow.js +20 -81
- package/src/config.js +0 -8
- package/src/cv-text.js +48 -13
- package/src/git-activity.js +8 -7
- package/src/git-log-prefetch.js +53 -0
- package/src/i18n.js +10 -36
- package/src/mcp-auth-form.js +35 -21
- package/src/mcp-certify-dimension-tools.js +2 -2
- package/src/mcp-onboarding-tools.js +1 -1
- package/src/mcp-prompts.js +71 -0
- package/src/mcp-register-tools.js +16 -11
- package/src/mcp-server.js +38 -2
- package/src/register-context.js +44 -13
- package/src/scanner.js +13 -1
- package/src/session-normalizers.js +174 -0
- package/src/session-scan.js +149 -10
- package/src/work-streams.js +7 -2
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Input normalizers: map a RAW session record into the Claude-shaped internal object model
|
|
4
|
+
// that session-scan harvest + transcript-turns already consume
|
|
5
|
+
// ({ cwd, timestamp, type, message:{ role, content:[{type:'text'|'tool_use', ...}] } }).
|
|
6
|
+
// They ONLY translate input shape — downstream redaction/upload (share.js#derivePayload
|
|
7
|
+
// -> POST usage/reports) is unchanged; no field is read that the Claude path didn't expose.
|
|
8
|
+
// FAIL-CLOSED: every normalizer returns null / [] when a record does not match the expected
|
|
9
|
+
// shape — it never fabricates or guesses fields. A documented format that differs from the
|
|
10
|
+
// real one yields "no data for that tool", never wrong data.
|
|
11
|
+
|
|
12
|
+
function str(v) {
|
|
13
|
+
return typeof v === 'string' && v ? v : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function textFromParts(parts) {
|
|
17
|
+
if (typeof parts === 'string') return parts.trim() || null;
|
|
18
|
+
if (!Array.isArray(parts)) return null;
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const p of parts) {
|
|
21
|
+
if (typeof p === 'string') out.push(p);
|
|
22
|
+
else if (p && typeof p === 'object' && typeof p.text === 'string') out.push(p.text);
|
|
23
|
+
}
|
|
24
|
+
const joined = out.join('').trim();
|
|
25
|
+
return joined || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// --- Codex (~/.codex/sessions/**/rollout-*.jsonl) ---
|
|
29
|
+
// Reads timestamp, cwd (session_meta), user text, and shell commands; a record matching none
|
|
30
|
+
// of those passes through only its own real timestamp/cwd, else is dropped.
|
|
31
|
+
function codexCommand(args) {
|
|
32
|
+
let a = args;
|
|
33
|
+
if (typeof a === 'string') {
|
|
34
|
+
try { a = JSON.parse(a); } catch { return a.trim() || null; }
|
|
35
|
+
}
|
|
36
|
+
if (!a || typeof a !== 'object') return null;
|
|
37
|
+
const cmd = a.command ?? a.cmd ?? a.commandLine;
|
|
38
|
+
if (Array.isArray(cmd)) return cmd.map(String).join(' ').trim() || null;
|
|
39
|
+
return str(cmd);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeCodexRecord(raw) {
|
|
43
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
44
|
+
const payload = raw.payload && typeof raw.payload === 'object' ? raw.payload : null;
|
|
45
|
+
const item = payload || raw;
|
|
46
|
+
const out = {};
|
|
47
|
+
const ts = str(raw.timestamp) || str(item.timestamp);
|
|
48
|
+
if (ts) out.timestamp = ts;
|
|
49
|
+
const cwd = str(raw.cwd) || str(item.cwd);
|
|
50
|
+
if (cwd) out.cwd = cwd;
|
|
51
|
+
|
|
52
|
+
const role = str(item.role);
|
|
53
|
+
if (role === 'user') {
|
|
54
|
+
const text = textFromParts(item.content);
|
|
55
|
+
if (text) {
|
|
56
|
+
out.type = 'user';
|
|
57
|
+
out.message = { role: 'user', content: [{ type: 'text', text }] };
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const type = str(raw.type) || str(item.type);
|
|
63
|
+
const name = str(item.name) || (type === 'function_call' ? 'shell' : null);
|
|
64
|
+
if (name && /shell|exec|bash|terminal|command/i.test(name)) {
|
|
65
|
+
const command = codexCommand(item.arguments ?? item.input ?? item.args);
|
|
66
|
+
if (command) {
|
|
67
|
+
out.message = { content: [{ type: 'tool_use', name: 'Shell', input: { command } }] };
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return out.timestamp || out.cwd ? out : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- Gemini CLI (~/.gemini/tmp/<project-hash>/chats/logs.json | session-*.jsonl) ---
|
|
76
|
+
// logs.json = JSON array of { type:'user'|'model', message, timestamp }; session-*.jsonl =
|
|
77
|
+
// per-line { role, parts }. No cwd is recorded (the dir is a one-way SHA-256 hash), so cwd
|
|
78
|
+
// stays null — global session/time metrics still count; per-repo scoping can't.
|
|
79
|
+
function normalizeGeminiRecord(raw) {
|
|
80
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
81
|
+
const out = {};
|
|
82
|
+
const ts = str(raw.timestamp) || str(raw.time);
|
|
83
|
+
if (ts) out.timestamp = ts;
|
|
84
|
+
|
|
85
|
+
const isUser = str(raw.type) === 'user' || str(raw.role) === 'user';
|
|
86
|
+
if (isUser) {
|
|
87
|
+
const text = str(raw.message) || textFromParts(raw.parts) || textFromParts(raw.content);
|
|
88
|
+
if (text) {
|
|
89
|
+
out.type = 'user';
|
|
90
|
+
out.message = { role: 'user', content: [{ type: 'text', text }] };
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out.timestamp ? out : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- Continue (continue.dev) ~/.continue/sessions/*.json ---
|
|
98
|
+
// Each file is one session object; expand its history into per-turn objects, carrying
|
|
99
|
+
// workspaceDirectory as cwd (good repo attribution).
|
|
100
|
+
function normalizeContinueRecord(raw) {
|
|
101
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
102
|
+
const cwd = str(raw.workspaceDirectory) || str(raw.workspace) || str(raw.cwd);
|
|
103
|
+
const history = Array.isArray(raw.history) ? raw.history
|
|
104
|
+
: (Array.isArray(raw.messages) ? raw.messages : (Array.isArray(raw.conversation) ? raw.conversation : null));
|
|
105
|
+
// A single message object (not a session) — handle defensively too.
|
|
106
|
+
if (!history) {
|
|
107
|
+
const msg = raw.message && typeof raw.message === 'object' ? raw.message : raw;
|
|
108
|
+
if (str(msg.role) === 'user') {
|
|
109
|
+
const text = typeof msg.content === 'string' ? msg.content.trim() : textFromParts(msg.content);
|
|
110
|
+
if (text) return oneUser(text, cwd, str(raw.timestamp) || str(raw.createdAt));
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const entry of history) {
|
|
116
|
+
const msg = entry && entry.message && typeof entry.message === 'object' ? entry.message : entry;
|
|
117
|
+
if (!msg || typeof msg !== 'object') continue;
|
|
118
|
+
const ts = str(entry.timestamp) || str(entry.createdAt) || str(msg.timestamp);
|
|
119
|
+
// Only emit on a real user turn (with text) or a record carrying a real timestamp.
|
|
120
|
+
if (str(msg.role) === 'user') {
|
|
121
|
+
const text = typeof msg.content === 'string' ? msg.content.trim() : textFromParts(msg.content);
|
|
122
|
+
if (text) { out.push(oneUser(text, cwd, ts)); continue; }
|
|
123
|
+
}
|
|
124
|
+
if (ts) out.push(cwd ? { cwd, timestamp: ts } : { timestamp: ts });
|
|
125
|
+
}
|
|
126
|
+
return out.length ? out : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- Cline / Roo Code: VS Code globalStorage .../tasks/<id>/api_conversation_history.json ---
|
|
130
|
+
// One JSON array of Anthropic messages (role/content); no cwd and no timestamp in this file
|
|
131
|
+
// (ui_messages.json has ts) → cwd/time attribution absent. Emits only real user turns.
|
|
132
|
+
function normalizeClineRecord(raw) {
|
|
133
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
134
|
+
if (str(raw.role) !== 'user') return null;
|
|
135
|
+
const text = typeof raw.content === 'string' ? raw.content.trim() : textFromParts(raw.content);
|
|
136
|
+
return text ? oneUser(text, null, null) : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- Aider: per-repo .aider.chat.history.md (markdown) ---
|
|
140
|
+
// Repo-level source → cwd is the repo toplevel (good attribution). Markdown parse:
|
|
141
|
+
// '# aider chat started at <ts>' sets the session time; '#### <text>' lines are user
|
|
142
|
+
// instructions. A file matching neither pattern yields 0 records.
|
|
143
|
+
function parseAiderHistory(text, cwd) {
|
|
144
|
+
if (typeof text !== 'string' || !text) return [];
|
|
145
|
+
const out = [];
|
|
146
|
+
let ts = null;
|
|
147
|
+
for (const line of text.split('\n')) {
|
|
148
|
+
const started = /^#\s*aider chat started at\s+(.+?)\s*$/i.exec(line);
|
|
149
|
+
if (started) {
|
|
150
|
+
const t = Date.parse(started[1].replace(' ', 'T'));
|
|
151
|
+
ts = Number.isFinite(t) ? new Date(t).toISOString() : null;
|
|
152
|
+
if (ts) out.push(cwd ? { cwd, timestamp: ts } : { timestamp: ts });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const user = /^####\s+(.*)$/.exec(line);
|
|
156
|
+
if (user && user[1].trim()) out.push(oneUser(user[1].trim(), cwd, ts));
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function oneUser(text, cwd, ts) {
|
|
162
|
+
const o = { type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } };
|
|
163
|
+
if (cwd) o.cwd = cwd;
|
|
164
|
+
if (ts) o.timestamp = ts;
|
|
165
|
+
return o;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = {
|
|
169
|
+
normalizeCodexRecord,
|
|
170
|
+
normalizeGeminiRecord,
|
|
171
|
+
normalizeContinueRecord,
|
|
172
|
+
normalizeClineRecord,
|
|
173
|
+
parseAiderHistory,
|
|
174
|
+
};
|
package/src/session-scan.js
CHANGED
|
@@ -4,6 +4,13 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { getHomeDir } = require('./env-paths');
|
|
6
6
|
const { scrubSecrets } = require('./agent-synthesis');
|
|
7
|
+
const {
|
|
8
|
+
normalizeCodexRecord,
|
|
9
|
+
normalizeGeminiRecord,
|
|
10
|
+
normalizeContinueRecord,
|
|
11
|
+
normalizeClineRecord,
|
|
12
|
+
parseAiderHistory,
|
|
13
|
+
} = require('./session-normalizers');
|
|
7
14
|
|
|
8
15
|
// GLOBAL, machine-wide inventory of AI coding-tool SESSIONS (hybrid evaluation, Slice 1).
|
|
9
16
|
|
|
@@ -73,16 +80,39 @@ const FILE_TOOL_NAMES = new Set([
|
|
|
73
80
|
]);
|
|
74
81
|
const PLANNING_TOOL_NAMES = new Set(['ExitPlanMode', 'TodoWrite']);
|
|
75
82
|
|
|
83
|
+
// Only logs.json + session-*.jsonl under ~/.gemini/tmp are transcripts (skip checkpoints/config).
|
|
84
|
+
function isGeminiTranscriptFile(name) {
|
|
85
|
+
return name === 'logs.json' || (name.startsWith('session-') && name.endsWith('.jsonl'));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Cline / Roo Code conversation file (both forks use this filename under tasks/<id>/).
|
|
89
|
+
function isClineHistoryFile(name) {
|
|
90
|
+
return name === 'api_conversation_history.json';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// VS Code globalStorage root per OS (Cline/Roo live under <ext-id>/tasks/<id>/ here).
|
|
94
|
+
function vscodeGlobalStorageDir(home) {
|
|
95
|
+
if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage');
|
|
96
|
+
if (process.platform === 'win32') return path.join(home, 'AppData', 'Roaming', 'Code', 'User', 'globalStorage');
|
|
97
|
+
return path.join(home, '.config', 'Code', 'User', 'globalStorage');
|
|
98
|
+
}
|
|
99
|
+
|
|
76
100
|
function toolSources(env = process.env) {
|
|
77
101
|
const home = getHomeDir(env);
|
|
78
102
|
return [
|
|
79
103
|
{ tool: 'claude', dir: path.join(home, '.claude', 'projects') },
|
|
80
|
-
{ tool: 'codex', dir: path.join(home, '.codex', 'sessions') },
|
|
104
|
+
{ tool: 'codex', dir: path.join(home, '.codex', 'sessions'), normalize: normalizeCodexRecord },
|
|
105
|
+
{ tool: 'gemini', dir: path.join(home, '.gemini', 'tmp'), fileFilter: isGeminiTranscriptFile, normalize: normalizeGeminiRecord },
|
|
106
|
+
// Kept by decision: ~/.cursor/chats is OBSOLETE (real Cursor chat is SQLite in App Support); awaiting a future SQLite extractor, no tool removed.
|
|
81
107
|
{ tool: 'cursor', dir: path.join(home, '.cursor', 'chats') },
|
|
108
|
+
{ tool: 'continue', dir: path.join(home, '.continue', 'sessions'), normalize: normalizeContinueRecord },
|
|
109
|
+
{ tool: 'cline', dir: vscodeGlobalStorageDir(home), fileFilter: isClineHistoryFile, normalize: normalizeClineRecord },
|
|
110
|
+
// Repo-level: .aider.chat.history.md lives INSIDE each git repo, so cwd = the repo toplevel.
|
|
111
|
+
{ tool: 'aider', repoLevel: true, file: '.aider.chat.history.md', parseText: parseAiderHistory },
|
|
82
112
|
];
|
|
83
113
|
}
|
|
84
114
|
|
|
85
|
-
function listSessionFiles(root) {
|
|
115
|
+
function listSessionFiles(root, fileFilter = isTranscriptFile) {
|
|
86
116
|
const out = [];
|
|
87
117
|
const stack = [root];
|
|
88
118
|
while (stack.length > 0) {
|
|
@@ -97,7 +127,7 @@ function listSessionFiles(root) {
|
|
|
97
127
|
const full = path.join(dir, entry.name);
|
|
98
128
|
if (entry.isDirectory()) {
|
|
99
129
|
stack.push(full);
|
|
100
|
-
} else if (entry.isFile() &&
|
|
130
|
+
} else if (entry.isFile() && fileFilter(entry.name)) {
|
|
101
131
|
out.push(full);
|
|
102
132
|
if (out.length >= MAX_FILES_PER_TOOL) return out;
|
|
103
133
|
}
|
|
@@ -106,6 +136,77 @@ function listSessionFiles(root) {
|
|
|
106
136
|
return out;
|
|
107
137
|
}
|
|
108
138
|
|
|
139
|
+
// Whole-file JSON reader (bounded) for `.json` transcripts (e.g. Gemini logs.json = array).
|
|
140
|
+
function readJsonArrayFile(file) {
|
|
141
|
+
let fd;
|
|
142
|
+
try {
|
|
143
|
+
fd = fs.openSync(file, 'r');
|
|
144
|
+
} catch {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const { size } = fs.fstatSync(fd);
|
|
149
|
+
const readLen = Math.min(size, MAX_BYTES_PER_FILE);
|
|
150
|
+
const buf = Buffer.allocUnsafe(readLen);
|
|
151
|
+
fs.readSync(fd, buf, 0, readLen, 0);
|
|
152
|
+
const parsed = JSON.parse(buf.toString('utf8'));
|
|
153
|
+
if (Array.isArray(parsed)) return parsed;
|
|
154
|
+
return parsed && typeof parsed === 'object' ? [parsed] : [];
|
|
155
|
+
} catch {
|
|
156
|
+
return [];
|
|
157
|
+
} finally {
|
|
158
|
+
try { fs.closeSync(fd); } catch { /* already closed */ }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Parse a file to raw records by extension (.jsonl = per line, .json = whole array).
|
|
163
|
+
function parseRawRecords(file) {
|
|
164
|
+
return file.endsWith('.jsonl') ? readSessionObjects(file) : readJsonArrayFile(file);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Read a source's file and normalize its records into the Claude-shaped internal model.
|
|
168
|
+
// A normalizer may return an object, an array (expanded), or null/[] (fail-closed skip).
|
|
169
|
+
function readSourceObjects(source, file) {
|
|
170
|
+
const raw = parseRawRecords(file);
|
|
171
|
+
if (typeof source.normalize !== 'function') return raw;
|
|
172
|
+
const out = [];
|
|
173
|
+
for (const r of raw) {
|
|
174
|
+
const o = source.normalize(r);
|
|
175
|
+
if (Array.isArray(o)) out.push(...o);
|
|
176
|
+
else if (o) out.push(o);
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Bounded whole-file text read (for repo-level markdown sources, e.g. Aider).
|
|
182
|
+
function readTextFile(file) {
|
|
183
|
+
let fd;
|
|
184
|
+
try {
|
|
185
|
+
fd = fs.openSync(file, 'r');
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const { size } = fs.fstatSync(fd);
|
|
191
|
+
const readLen = Math.min(size, MAX_BYTES_PER_FILE);
|
|
192
|
+
const buf = Buffer.allocUnsafe(readLen);
|
|
193
|
+
fs.readSync(fd, buf, 0, readLen, 0);
|
|
194
|
+
return buf.toString('utf8');
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
} finally {
|
|
198
|
+
try { fs.closeSync(fd); } catch { /* already closed */ }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Repo-level source: read <toplevel>/<source.file> and parse it with cwd = the toplevel.
|
|
203
|
+
function readRepoLevelObjects(source, toplevel) {
|
|
204
|
+
const text = readTextFile(path.join(toplevel, source.file));
|
|
205
|
+
if (text === null) return [];
|
|
206
|
+
const objects = source.parseText(text, toplevel);
|
|
207
|
+
return Array.isArray(objects) ? objects : [];
|
|
208
|
+
}
|
|
209
|
+
|
|
109
210
|
function pushCommand(acc, command) {
|
|
110
211
|
if (typeof command !== 'string') return;
|
|
111
212
|
acc.bashCommandsCount += 1;
|
|
@@ -198,16 +299,31 @@ function fileInScope(cwd, selectedCwds) {
|
|
|
198
299
|
|
|
199
300
|
// Yields every parsed session object across every known AI-tool session directory on this machine, tagged with the tool it came from.
|
|
200
301
|
function* iterateSessionObjects(env = process.env) {
|
|
201
|
-
|
|
202
|
-
|
|
302
|
+
const sources = toolSources(env);
|
|
303
|
+
// Phase 1: home-dir sources; collect the repo toplevels (cwds) they reveal.
|
|
304
|
+
const seenCwds = new Set();
|
|
305
|
+
for (const source of sources) {
|
|
306
|
+
if (source.repoLevel) continue;
|
|
307
|
+
const files = listSessionFiles(source.dir, source.fileFilter || isTranscriptFile);
|
|
203
308
|
for (const file of files) {
|
|
204
|
-
const objects =
|
|
309
|
+
const objects = readSourceObjects(source, file);
|
|
205
310
|
const cwd = resolveFileCwd(objects);
|
|
311
|
+
if (cwd) seenCwds.add(cwd);
|
|
206
312
|
for (const obj of objects) {
|
|
207
313
|
yield { tool: source.tool, obj, cwd, filePath: file };
|
|
208
314
|
}
|
|
209
315
|
}
|
|
210
316
|
}
|
|
317
|
+
// Phase 2: repo-level sources (e.g. Aider) over the repos just discovered — cwd is known.
|
|
318
|
+
for (const source of sources) {
|
|
319
|
+
if (!source.repoLevel) continue;
|
|
320
|
+
for (const top of seenCwds) {
|
|
321
|
+
const objects = readRepoLevelObjects(source, top);
|
|
322
|
+
for (const obj of objects) {
|
|
323
|
+
yield { tool: source.tool, obj, cwd: top, filePath: path.join(top, source.file) };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
211
327
|
}
|
|
212
328
|
|
|
213
329
|
// Scan every known AI-tool session directory on this machine.
|
|
@@ -303,13 +419,19 @@ function makeSessionsAccumulator() {
|
|
|
303
419
|
|
|
304
420
|
function walkSessionSignals(env = process.env, selectedCwds = null, onHumanObject = null) {
|
|
305
421
|
const sessions = makeSessionsAccumulator();
|
|
306
|
-
|
|
307
|
-
|
|
422
|
+
const sources = toolSources(env);
|
|
423
|
+
const seenCwds = new Set();
|
|
424
|
+
// Phase 1: home-dir sources; remember the repo toplevels (cwds) they reveal.
|
|
425
|
+
for (const source of sources) {
|
|
426
|
+
if (source.repoLevel) continue;
|
|
427
|
+
const files = listSessionFiles(source.dir, source.fileFilter || isTranscriptFile);
|
|
308
428
|
if (files.length === 0) continue;
|
|
309
429
|
sessions.startTool(source.tool);
|
|
310
430
|
for (const f of files) {
|
|
311
|
-
const objects =
|
|
312
|
-
|
|
431
|
+
const objects = readSourceObjects(source, f);
|
|
432
|
+
const cwd = resolveFileCwd(objects);
|
|
433
|
+
if (cwd) seenCwds.add(cwd);
|
|
434
|
+
if (!fileInScope(cwd, selectedCwds)) continue;
|
|
313
435
|
sessions.addFile(f, objects);
|
|
314
436
|
if (onHumanObject) {
|
|
315
437
|
for (const o of objects) onHumanObject(o);
|
|
@@ -317,6 +439,23 @@ function walkSessionSignals(env = process.env, selectedCwds = null, onHumanObjec
|
|
|
317
439
|
}
|
|
318
440
|
sessions.endTool();
|
|
319
441
|
}
|
|
442
|
+
// Phase 2: repo-level sources (e.g. Aider) — candidate repos = the selected scope, else
|
|
443
|
+
// the repos seen in phase 1. cwd is the repo toplevel, so attribution is exact.
|
|
444
|
+
const toplevels = selectedCwds instanceof Set ? selectedCwds : seenCwds;
|
|
445
|
+
for (const source of sources) {
|
|
446
|
+
if (!source.repoLevel) continue;
|
|
447
|
+
sessions.startTool(source.tool);
|
|
448
|
+
for (const top of toplevels) {
|
|
449
|
+
if (!fileInScope(top, selectedCwds)) continue;
|
|
450
|
+
const objects = readRepoLevelObjects(source, top);
|
|
451
|
+
if (objects.length === 0) continue;
|
|
452
|
+
sessions.addFile(path.join(top, source.file), objects);
|
|
453
|
+
if (onHumanObject) {
|
|
454
|
+
for (const o of objects) onHumanObject(o);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
sessions.endTool();
|
|
458
|
+
}
|
|
320
459
|
return sessions.result();
|
|
321
460
|
}
|
|
322
461
|
|
package/src/work-streams.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { execFileSync } = require('child_process');
|
|
4
4
|
const { isGitRepo } = require('./git-activity');
|
|
5
|
+
const { takePrefetchedGitLog } = require('./git-log-prefetch');
|
|
5
6
|
|
|
6
7
|
// Deterministic WORK-STREAM structure for the project where `shakers ai-usage` runs (hybrid evaluation, Slice 2).
|
|
7
8
|
|
|
@@ -9,11 +10,15 @@ const GIT_TIMEOUT_MS = 15000;
|
|
|
9
10
|
const SESSION_GAP_SECONDS = 4 * 3600;
|
|
10
11
|
const SECONDS_PER_DAY = 86400;
|
|
11
12
|
|
|
13
|
+
const WORK_STREAMS_LOG_ARGS = ['log', '--no-merges', '--pretty=format:%at'];
|
|
14
|
+
|
|
12
15
|
function runGitTimestamps(root) {
|
|
16
|
+
const prefetched = takePrefetchedGitLog(root, WORK_STREAMS_LOG_ARGS);
|
|
17
|
+
if (prefetched !== undefined) return prefetched;
|
|
13
18
|
try {
|
|
14
19
|
return execFileSync(
|
|
15
20
|
'git',
|
|
16
|
-
['-C', root,
|
|
21
|
+
['-C', root, ...WORK_STREAMS_LOG_ARGS],
|
|
17
22
|
{
|
|
18
23
|
encoding: 'utf8',
|
|
19
24
|
timeout: GIT_TIMEOUT_MS,
|
|
@@ -71,4 +76,4 @@ function collectWorkStreams(root) {
|
|
|
71
76
|
};
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
module.exports = { collectWorkStreams, SESSION_GAP_SECONDS };
|
|
79
|
+
module.exports = { collectWorkStreams, SESSION_GAP_SECONDS, WORK_STREAMS_LOG_ARGS };
|