agent-hours 0.3.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 +155 -0
- package/dist/cli.js +658 -0
- package/dist/core.js +417 -0
- package/dist/install.js +84 -0
- package/dist/sources/codex.js +145 -0
- package/dist/worklog.js +209 -0
- package/package.json +43 -0
package/dist/core.js
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-hours core — ported 1:1 from the battle-tested Python reference
|
|
3
|
+
* (reference/claude_hours.py + reference/prototype-split.py).
|
|
4
|
+
*
|
|
5
|
+
* Parity is enforced by test/parity.test.mjs: same fixtures through both
|
|
6
|
+
* implementations must yield identical numbers.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
export const PROJECTS_BASE = path.join(os.homedir(), ".claude", "projects");
|
|
12
|
+
/** /Users/x/code/foo -> -Users-x-code-foo (Claude Code's project dir naming). */
|
|
13
|
+
export function projectToHash(p) {
|
|
14
|
+
return path.resolve(p).split(path.sep).join("-");
|
|
15
|
+
}
|
|
16
|
+
export function findProjectDir(projectArg) {
|
|
17
|
+
let hashName;
|
|
18
|
+
if (projectArg) {
|
|
19
|
+
hashName =
|
|
20
|
+
projectArg.includes(path.sep) || projectArg.startsWith("/")
|
|
21
|
+
? projectToHash(projectArg)
|
|
22
|
+
: projectArg;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
hashName = projectToHash(process.cwd());
|
|
26
|
+
}
|
|
27
|
+
return path.join(PROJECTS_BASE, hashName);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Event classification, verified against real Claude Code logs (2026-06):
|
|
31
|
+
*
|
|
32
|
+
* - `promptSource` (newer logs): "typed" = human, "system" = scheduled
|
|
33
|
+
* tasks/hooks (phantom prompts!), "queued" = injection moment of a queued
|
|
34
|
+
* message — the human typing already happened at the queue-operation
|
|
35
|
+
* enqueue event, so the injection itself is NOT human time.
|
|
36
|
+
* - `queue-operation` enqueue with human content = the moment of real typing
|
|
37
|
+
* WHILE Claude was running (strong presence proof). Enqueues carrying
|
|
38
|
+
* `<task-notification>` payloads are system traffic.
|
|
39
|
+
* - `isSidechain` = subagent transcript — machine, never a human prompt.
|
|
40
|
+
* - `isCompactSummary` = synthetic continuation prompt, not human.
|
|
41
|
+
* - `attachment` edited_text_file = user edited a file in an external editor
|
|
42
|
+
* (presence proof, not a prompt).
|
|
43
|
+
* - Legacy logs without `promptSource` fall back to the shape heuristic:
|
|
44
|
+
* type=user, not isMeta, content is a string or has text but no tool_result.
|
|
45
|
+
*/
|
|
46
|
+
export function classifyRecord(record) {
|
|
47
|
+
const work = { kind: "work", presence: false };
|
|
48
|
+
const r = record;
|
|
49
|
+
if (!r)
|
|
50
|
+
return work;
|
|
51
|
+
if (r["isSidechain"] === true)
|
|
52
|
+
return work;
|
|
53
|
+
const t = r["type"];
|
|
54
|
+
if (t === "queue-operation") {
|
|
55
|
+
const content = r["content"];
|
|
56
|
+
if (r["operation"] === "enqueue" &&
|
|
57
|
+
typeof content === "string" &&
|
|
58
|
+
!content.trimStart().startsWith("<task-notification")) {
|
|
59
|
+
return { kind: "prompt", presence: true };
|
|
60
|
+
}
|
|
61
|
+
return work;
|
|
62
|
+
}
|
|
63
|
+
if (t === "attachment") {
|
|
64
|
+
const a = r["attachment"];
|
|
65
|
+
if (a?.["type"] === "edited_text_file")
|
|
66
|
+
return { kind: "work", presence: true };
|
|
67
|
+
return work;
|
|
68
|
+
}
|
|
69
|
+
if (t === "user" && !r["isMeta"] && !r["isCompactSummary"]) {
|
|
70
|
+
const src = r["promptSource"];
|
|
71
|
+
if (src === "system" || src === "queued")
|
|
72
|
+
return work;
|
|
73
|
+
if (src === "typed")
|
|
74
|
+
return { kind: "prompt", presence: true };
|
|
75
|
+
const msg = r["message"];
|
|
76
|
+
const c = msg?.["content"];
|
|
77
|
+
if (typeof c === "string")
|
|
78
|
+
return { kind: "prompt", presence: true };
|
|
79
|
+
if (Array.isArray(c)) {
|
|
80
|
+
const items = c.filter((i) => i !== null && typeof i === "object");
|
|
81
|
+
const hasText = items.some((i) => i["type"] === "text");
|
|
82
|
+
const hasToolResult = items.some((i) => i["type"] === "tool_result");
|
|
83
|
+
if (hasText && !hasToolResult)
|
|
84
|
+
return { kind: "prompt", presence: true };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return work;
|
|
88
|
+
}
|
|
89
|
+
/** Convenience wrapper: just the kind. */
|
|
90
|
+
export function classifyKind(record) {
|
|
91
|
+
return classifyRecord(record).kind;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Reads all events of one session JSONL, filtered to [sinceMs, untilMs].
|
|
95
|
+
* With forceWork (subagent transcripts) every event counts as machine work —
|
|
96
|
+
* subagent "user" messages are task prompts from the orchestrator, not humans.
|
|
97
|
+
*/
|
|
98
|
+
export function loadSessionEvents(jsonlPath, sinceMs, untilMs, forceWork = false) {
|
|
99
|
+
let raw;
|
|
100
|
+
try {
|
|
101
|
+
raw = fs.readFileSync(jsonlPath, "utf8");
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
const events = [];
|
|
107
|
+
for (const line of raw.split("\n")) {
|
|
108
|
+
if (!line.trim())
|
|
109
|
+
continue;
|
|
110
|
+
let record;
|
|
111
|
+
try {
|
|
112
|
+
record = JSON.parse(line);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const tsStr = record?.["timestamp"];
|
|
118
|
+
if (typeof tsStr !== "string")
|
|
119
|
+
continue;
|
|
120
|
+
const ts = Date.parse(tsStr);
|
|
121
|
+
if (Number.isNaN(ts))
|
|
122
|
+
continue;
|
|
123
|
+
if (ts < sinceMs || ts > untilMs)
|
|
124
|
+
continue;
|
|
125
|
+
if (forceWork) {
|
|
126
|
+
events.push({ ts, kind: "work", presence: false });
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
const c = classifyRecord(record);
|
|
130
|
+
events.push({ ts, kind: c.kind, presence: c.presence });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
events.sort((a, b) => a.ts - b.ts);
|
|
134
|
+
return events;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Loads all sessions of a project directory: top-level *.jsonl PLUS subagent
|
|
138
|
+
* transcripts in <sessionUuid>/subagents/agent-*.jsonl (newer Claude Code
|
|
139
|
+
* versions store parallel-agent work there — missing them undercounts total
|
|
140
|
+
* activity whenever only subagents were running).
|
|
141
|
+
*/
|
|
142
|
+
export function loadProject(projectDir, sinceMs, untilMs) {
|
|
143
|
+
let entries;
|
|
144
|
+
try {
|
|
145
|
+
entries = fs.readdirSync(projectDir, { withFileTypes: true });
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
const sessions = [];
|
|
151
|
+
for (const f of entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => e.name).sort()) {
|
|
152
|
+
const events = loadSessionEvents(path.join(projectDir, f), sinceMs, untilMs);
|
|
153
|
+
if (events.length > 0)
|
|
154
|
+
sessions.push({ name: f, events });
|
|
155
|
+
}
|
|
156
|
+
for (const dir of entries.filter((e) => e.isDirectory()).map((e) => e.name).sort()) {
|
|
157
|
+
const subDir = path.join(projectDir, dir, "subagents");
|
|
158
|
+
let subFiles;
|
|
159
|
+
try {
|
|
160
|
+
subFiles = fs.readdirSync(subDir).filter((f) => f.endsWith(".jsonl")).sort();
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
for (const f of subFiles) {
|
|
166
|
+
const events = loadSessionEvents(path.join(subDir, f), sinceMs, untilMs, true);
|
|
167
|
+
if (events.length > 0)
|
|
168
|
+
sessions.push({ name: `${dir}/subagents/${f}`, events });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return sessions;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Industry-standard active time (WakaTime/RescueTime style): sum of
|
|
175
|
+
* inter-event gaps, each gap capped at capMinutes ("cap bonus" — a pause
|
|
176
|
+
* longer than the cap still contributes capMinutes).
|
|
177
|
+
*/
|
|
178
|
+
export function activeMinutes(timesMs, capMinutes) {
|
|
179
|
+
if (timesMs.length < 2)
|
|
180
|
+
return 0;
|
|
181
|
+
let total = 0;
|
|
182
|
+
for (let i = 1; i < timesMs.length; i++) {
|
|
183
|
+
const gap = (timesMs[i] - timesMs[i - 1]) / 60000;
|
|
184
|
+
total += Math.min(gap, capMinutes);
|
|
185
|
+
}
|
|
186
|
+
return total;
|
|
187
|
+
}
|
|
188
|
+
/** Strict variant: gaps > cap count 0 (no cap bonus). Honest lower bound. */
|
|
189
|
+
export function activeMinutesStrict(timesMs, capMinutes) {
|
|
190
|
+
if (timesMs.length < 2)
|
|
191
|
+
return 0;
|
|
192
|
+
let total = 0;
|
|
193
|
+
for (let i = 1; i < timesMs.length; i++) {
|
|
194
|
+
const gap = (timesMs[i] - timesMs[i - 1]) / 60000;
|
|
195
|
+
if (gap <= capMinutes)
|
|
196
|
+
total += gap;
|
|
197
|
+
}
|
|
198
|
+
return total;
|
|
199
|
+
}
|
|
200
|
+
/** All gaps >= minMinutes, sorted by duration descending. */
|
|
201
|
+
export function findPauses(timesMs, minMinutes) {
|
|
202
|
+
const pauses = [];
|
|
203
|
+
for (let i = 1; i < timesMs.length; i++) {
|
|
204
|
+
const minutes = (timesMs[i] - timesMs[i - 1]) / 60000;
|
|
205
|
+
if (minutes >= minMinutes) {
|
|
206
|
+
pauses.push({ startMs: timesMs[i - 1], endMs: timesMs[i], minutes });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
pauses.sort((a, b) => b.minutes - a.minutes);
|
|
210
|
+
return pauses;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Merge all sessions into ONE sorted timeline. Critical for parallel
|
|
214
|
+
* sessions/agents: per-session sums would double-count overlapping wall-clock
|
|
215
|
+
* time; the merged timeline counts every real minute exactly once.
|
|
216
|
+
*/
|
|
217
|
+
export function mergeEvents(sessions) {
|
|
218
|
+
const merged = [];
|
|
219
|
+
for (const s of sessions)
|
|
220
|
+
merged.push(...s.events);
|
|
221
|
+
merged.sort((a, b) => a.ts - b.ts);
|
|
222
|
+
return merged;
|
|
223
|
+
}
|
|
224
|
+
/** True if at least two sessions' time windows overlap. */
|
|
225
|
+
export function detectOverlaps(sessions) {
|
|
226
|
+
const ranges = sessions
|
|
227
|
+
.filter((s) => s.events.length >= 2)
|
|
228
|
+
.map((s) => [s.events[0].ts, s.events[s.events.length - 1].ts])
|
|
229
|
+
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
230
|
+
for (let i = 1; i < ranges.length; i++) {
|
|
231
|
+
if (ranges[i][0] < ranges[i - 1][1])
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The core USP. Total = capped inter-event time over the merged timeline of
|
|
238
|
+
* ALL events. Human-active = capped inter-PROMPT time (its own cap — "waiting
|
|
239
|
+
* for Claude and testing" counts as active). AI-solo = total − human.
|
|
240
|
+
*/
|
|
241
|
+
export function computeSplit(merged, capMinutes, promptCapMinutes) {
|
|
242
|
+
const allTimes = merged.map((e) => e.ts);
|
|
243
|
+
const promptTimes = merged.filter((e) => e.kind === "prompt").map((e) => e.ts);
|
|
244
|
+
const totalMinutes = activeMinutes(allTimes, capMinutes);
|
|
245
|
+
const humanMinutes = activeMinutes(promptTimes, promptCapMinutes);
|
|
246
|
+
return {
|
|
247
|
+
totalMinutes,
|
|
248
|
+
humanMinutes: Math.min(humanMinutes, totalMinutes),
|
|
249
|
+
aiSoloMinutes: Math.max(0, totalMinutes - humanMinutes),
|
|
250
|
+
promptCount: promptTimes.length,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** Local-date key (YYYY-MM-DD) for a UTC timestamp at the given offset. */
|
|
254
|
+
export function dayKey(tsMs, tzOffsetHours) {
|
|
255
|
+
return new Date(tsMs + tzOffsetHours * 3600_000).toISOString().slice(0, 10);
|
|
256
|
+
}
|
|
257
|
+
/** Local-hour key (YYYY-MM-DD HH:00) for a UTC timestamp at the given offset. */
|
|
258
|
+
export function hourKey(tsMs, tzOffsetHours) {
|
|
259
|
+
return (new Date(tsMs + tzOffsetHours * 3600_000).toISOString().slice(0, 13).replace("T", " ") +
|
|
260
|
+
":00");
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Three-state model (replaces the binary inter-prompt heuristic, which counts
|
|
264
|
+
* Claude-working time inside prompt windows as fully human):
|
|
265
|
+
*
|
|
266
|
+
* Per prompt-to-prompt window, capped at promptCapMinutes (same cap semantics
|
|
267
|
+
* as the old heuristic, so the old number stays a true upper bound):
|
|
268
|
+
*
|
|
269
|
+
* 1. hands-on — the reaction tail: time between the last event of any kind
|
|
270
|
+
* and the next prompt. The human provably read/thought/typed then.
|
|
271
|
+
* 2. supervised — the agent-working part of the capped window, weighted by
|
|
272
|
+
* watch evidence: reaction < 30 s ⇒ they were watching ⇒ 100 %, < 5 min ⇒
|
|
273
|
+
* 50 %, else 0 %. Hard presence proof inside the window (message typed
|
|
274
|
+
* mid-turn, external file edit) forces 100 %.
|
|
275
|
+
* 3. AI autonomous — total minus the two above.
|
|
276
|
+
*
|
|
277
|
+
* Invariant: handsOn + supervised <= upperBound (old heuristic) <= total.
|
|
278
|
+
* A fast reaction proves presence at the END of a window, not throughout —
|
|
279
|
+
* capping supervision at the prompt-cap window encodes exactly that.
|
|
280
|
+
*/
|
|
281
|
+
export function computeRefinedSplit(merged, opts) {
|
|
282
|
+
const WATCH_FULL = opts.watchFullMinutes ?? 0.5;
|
|
283
|
+
const WATCH_HALF = opts.watchHalfMinutes ?? 5;
|
|
284
|
+
const n = merged.length;
|
|
285
|
+
const promptIdx = [];
|
|
286
|
+
for (let i = 0; i < n; i++)
|
|
287
|
+
if (merged[i].kind === "prompt")
|
|
288
|
+
promptIdx.push(i);
|
|
289
|
+
// Pass 1: totals per hour over the full event timeline.
|
|
290
|
+
let total = 0;
|
|
291
|
+
const byHour = new Map();
|
|
292
|
+
const hourOf = (tsMs) => hourKey(tsMs, opts.tzOffsetHours);
|
|
293
|
+
const bucket = (key) => {
|
|
294
|
+
let b = byHour.get(key);
|
|
295
|
+
if (!b) {
|
|
296
|
+
b = { total: 0, handsOn: 0, supervised: 0, ai: 0 };
|
|
297
|
+
byHour.set(key, b);
|
|
298
|
+
}
|
|
299
|
+
return b;
|
|
300
|
+
};
|
|
301
|
+
for (let i = 1; i < n; i++) {
|
|
302
|
+
const capped = Math.min((merged[i].ts - merged[i - 1].ts) / 60000, opts.capMinutes);
|
|
303
|
+
total += capped;
|
|
304
|
+
bucket(hourOf(merged[i - 1].ts)).total += capped;
|
|
305
|
+
}
|
|
306
|
+
// Pass 2: human states per prompt-to-prompt segment.
|
|
307
|
+
let handsOn = 0;
|
|
308
|
+
let supervised = 0;
|
|
309
|
+
for (let k = 1; k < promptIdx.length; k++) {
|
|
310
|
+
const p1 = promptIdx[k - 1];
|
|
311
|
+
const p2 = promptIdx[k];
|
|
312
|
+
const windowMin = (merged[p2].ts - merged[p1].ts) / 60000;
|
|
313
|
+
const cappedWindow = Math.min(windowMin, opts.promptCapMinutes);
|
|
314
|
+
const reactionMin = (merged[p2].ts - merged[p2 - 1].ts) / 60000;
|
|
315
|
+
const tail = Math.min(reactionMin, cappedWindow);
|
|
316
|
+
const agentPart = cappedWindow - tail;
|
|
317
|
+
let proof = false;
|
|
318
|
+
for (let j = p2 - 1; j > p1; j--) {
|
|
319
|
+
if (merged[j].presence) {
|
|
320
|
+
proof = true;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
let w = 0;
|
|
325
|
+
if (proof || reactionMin <= WATCH_FULL)
|
|
326
|
+
w = 1;
|
|
327
|
+
else if (reactionMin <= WATCH_HALF)
|
|
328
|
+
w = 0.5;
|
|
329
|
+
const sup = agentPart * w;
|
|
330
|
+
handsOn += tail;
|
|
331
|
+
supervised += sup;
|
|
332
|
+
// Hour attribution: tail to the hour of the tail gap's start; supervised
|
|
333
|
+
// distributed over the segment's work gaps proportionally to capped time.
|
|
334
|
+
bucket(hourOf(merged[p2 - 1].ts)).handsOn += tail;
|
|
335
|
+
if (sup > 0) {
|
|
336
|
+
let denom = 0;
|
|
337
|
+
for (let i = p1 + 1; i < p2; i++) {
|
|
338
|
+
denom += Math.min((merged[i].ts - merged[i - 1].ts) / 60000, opts.capMinutes);
|
|
339
|
+
}
|
|
340
|
+
if (denom > 0) {
|
|
341
|
+
for (let i = p1 + 1; i < p2; i++) {
|
|
342
|
+
const g = Math.min((merged[i].ts - merged[i - 1].ts) / 60000, opts.capMinutes);
|
|
343
|
+
bucket(hourOf(merged[i - 1].ts)).supervised += (sup * g) / denom;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
bucket(hourOf(merged[p1].ts)).supervised += sup;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
for (const b of byHour.values()) {
|
|
352
|
+
b.ai = Math.max(0, b.total - b.handsOn - b.supervised);
|
|
353
|
+
}
|
|
354
|
+
const promptTimes = promptIdx.map((i) => merged[i].ts);
|
|
355
|
+
const upperBound = Math.min(activeMinutes(promptTimes, opts.promptCapMinutes), total);
|
|
356
|
+
const attention = handsOn + supervised;
|
|
357
|
+
return {
|
|
358
|
+
totalMinutes: total,
|
|
359
|
+
handsOnMinutes: handsOn,
|
|
360
|
+
supervisedMinutes: supervised,
|
|
361
|
+
attentionMinutes: attention,
|
|
362
|
+
upperBoundMinutes: upperBound,
|
|
363
|
+
aiAutonomousMinutes: total - attention,
|
|
364
|
+
promptCount: promptIdx.length,
|
|
365
|
+
byHour,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Per-day capped minutes. Each gap is attributed to the day of its EARLIER
|
|
370
|
+
* event (same convention as the Python reference's --by-day and --csv).
|
|
371
|
+
*/
|
|
372
|
+
export function bucketMinutesByDay(timesMs, capMinutes, tzOffsetHours) {
|
|
373
|
+
const days = new Map();
|
|
374
|
+
for (let i = 1; i < timesMs.length; i++) {
|
|
375
|
+
const gap = Math.min((timesMs[i] - timesMs[i - 1]) / 60000, capMinutes);
|
|
376
|
+
const day = dayKey(timesMs[i - 1], tzOffsetHours);
|
|
377
|
+
days.set(day, (days.get(day) ?? 0) + gap);
|
|
378
|
+
}
|
|
379
|
+
return days;
|
|
380
|
+
}
|
|
381
|
+
export function countByDay(timesMs, tzOffsetHours) {
|
|
382
|
+
const days = new Map();
|
|
383
|
+
for (const t of timesMs) {
|
|
384
|
+
const day = dayKey(t, tzOffsetHours);
|
|
385
|
+
days.set(day, (days.get(day) ?? 0) + 1);
|
|
386
|
+
}
|
|
387
|
+
return days;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Parses YYYY-MM-DD, "YYYY-MM-DD HH:MM[:SS]" or an ISO timestamp — all
|
|
391
|
+
* interpreted as UTC (same contract as the Python reference).
|
|
392
|
+
*/
|
|
393
|
+
export function parseDate(s) {
|
|
394
|
+
if (s.includes("T")) {
|
|
395
|
+
const ts = Date.parse(s);
|
|
396
|
+
if (Number.isNaN(ts))
|
|
397
|
+
throw new Error(`Cannot parse date '${s}'`);
|
|
398
|
+
return ts;
|
|
399
|
+
}
|
|
400
|
+
let iso;
|
|
401
|
+
if (s.includes(" ")) {
|
|
402
|
+
const m = s.match(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})(:\d{2})?$/);
|
|
403
|
+
if (!m)
|
|
404
|
+
throw new Error(`Cannot parse date '${s}' (expected YYYY-MM-DD or YYYY-MM-DD HH:MM)`);
|
|
405
|
+
iso = `${m[1]}T${m[2]}${m[3] ?? ":00"}Z`;
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) {
|
|
409
|
+
throw new Error(`Cannot parse date '${s}' (expected YYYY-MM-DD)`);
|
|
410
|
+
}
|
|
411
|
+
iso = `${s}T00:00:00Z`;
|
|
412
|
+
}
|
|
413
|
+
const ts = Date.parse(iso);
|
|
414
|
+
if (Number.isNaN(ts))
|
|
415
|
+
throw new Error(`Cannot parse date '${s}'`);
|
|
416
|
+
return ts;
|
|
417
|
+
}
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent-hours install` — headroom-style one-shot integration.
|
|
3
|
+
*
|
|
4
|
+
* Drops a SKILL.md into the agents' skill directories so users can simply
|
|
5
|
+
* ask their agent "what did I work on this week?" and it knows to run this
|
|
6
|
+
* CLI and interpret the numbers. Idempotent; overwrites our own skill file
|
|
7
|
+
* only.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
export const SKILL_MD = `---
|
|
13
|
+
name: agent-hours
|
|
14
|
+
description: Billable hours and worklog from coding-agent session logs (Claude Code + Codex, merged). Use when the user asks "how many hours", "what did I work on", "time tracking", "billing", "invoice summary", "Stunden", "woran habe ich gearbeitet", "Arbeitszeit", "Abrechnung", or wants a per-hour/per-day work summary for a period.
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# agent-hours
|
|
18
|
+
|
|
19
|
+
Answer time-tracking and what-was-done questions by running the agent-hours
|
|
20
|
+
CLI (reads local session logs retroactively — zero setup) and interpreting
|
|
21
|
+
its output. Run it from the project directory the user asks about.
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
\`\`\`bash
|
|
26
|
+
npx agent-hours --json # full three-state split, machine-readable
|
|
27
|
+
npx agent-hours --worklog --csv --by-day # per-day: hours per state + description
|
|
28
|
+
npx agent-hours --worklog --csv # per-hour variant
|
|
29
|
+
npx agent-hours --json --since 2026-06-01 --until 2026-06-30
|
|
30
|
+
npx agent-hours --all-projects --split # everything on this machine
|
|
31
|
+
\`\`\`
|
|
32
|
+
|
|
33
|
+
## How to answer
|
|
34
|
+
|
|
35
|
+
1. **"How many hours did I work?"** — run \`--json\`. Present the BAND, never a
|
|
36
|
+
single number as truth: hands-on hours (provable lower bound), attention
|
|
37
|
+
hours (model estimate incl. supervised agent time), upper bound. Mention
|
|
38
|
+
total agent runtime separately.
|
|
39
|
+
2. **"What did I work on?"** — run \`--worklog --csv --by-day\` (or per-hour for
|
|
40
|
+
one day) and summarize the description column in your own words, grouped
|
|
41
|
+
by theme. This is free — do NOT pass --summarize unless the user asks.
|
|
42
|
+
3. **Invoice/billing export** — write the CSV to ~/Downloads and remind the
|
|
43
|
+
user: browser/call time is not in the logs, add 15–25 % on top.
|
|
44
|
+
|
|
45
|
+
## Notes
|
|
46
|
+
|
|
47
|
+
- Sources are merged into ONE timeline (Claude Code + Codex; parallel agents
|
|
48
|
+
and agent-launched agents never double-count).
|
|
49
|
+
- Claude Code prunes logs after cleanupPeriodDays (default 30) — if a range
|
|
50
|
+
looks empty, say so and recommend raising it in ~/.claude/settings.json.
|
|
51
|
+
- Idle-cap methodology: gaps between events capped at 10 min (configurable
|
|
52
|
+
via --cap/--prompt-cap).
|
|
53
|
+
`;
|
|
54
|
+
function installSkillFile(skillDir) {
|
|
55
|
+
const target = path.join(skillDir, "agent-hours", "SKILL.md");
|
|
56
|
+
const existed = fs.existsSync(target);
|
|
57
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
58
|
+
fs.writeFileSync(target, SKILL_MD);
|
|
59
|
+
return { target: skillDir, path: target, status: existed ? "updated" : "installed" };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Installs the skill for the requested agents. `homeDir` is overridable for
|
|
63
|
+
* tests. Codex is skipped (not failed) when ~/.codex doesn't exist.
|
|
64
|
+
*/
|
|
65
|
+
export function runInstall(agent, homeDir = os.homedir()) {
|
|
66
|
+
const results = [];
|
|
67
|
+
if (agent === "claude" || agent === "all") {
|
|
68
|
+
results.push(installSkillFile(path.join(homeDir, ".claude", "skills")));
|
|
69
|
+
}
|
|
70
|
+
if (agent === "codex" || agent === "all") {
|
|
71
|
+
if (fs.existsSync(path.join(homeDir, ".codex"))) {
|
|
72
|
+
results.push(installSkillFile(path.join(homeDir, ".codex", "skills")));
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
results.push({
|
|
76
|
+
target: path.join(homeDir, ".codex"),
|
|
77
|
+
path: "",
|
|
78
|
+
status: "skipped",
|
|
79
|
+
reason: "~/.codex not found (Codex CLI not installed)",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex CLI source adapter.
|
|
3
|
+
*
|
|
4
|
+
* Codex stores sessions in ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl.
|
|
5
|
+
* Every line has a top-level ISO `timestamp`; the first line is a
|
|
6
|
+
* `session_meta` record carrying `payload.cwd` (project matching) and
|
|
7
|
+
* `payload.source` ("exec" = non-interactive run launched by a script or
|
|
8
|
+
* another agent — its "user" messages are machine-authored, so the whole
|
|
9
|
+
* session counts as AI runtime; "tui"/IDE sessions have real human prompts).
|
|
10
|
+
*
|
|
11
|
+
* Verified against real logs 2026-06-12 (Codex CLI 0.135/0.139).
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
export const CODEX_SESSIONS_BASE = path.join(os.homedir(), ".codex", "sessions");
|
|
17
|
+
function walkJsonl(dir, out = []) {
|
|
18
|
+
let entries;
|
|
19
|
+
try {
|
|
20
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
for (const e of entries) {
|
|
26
|
+
const p = path.join(dir, e.name);
|
|
27
|
+
if (e.isDirectory())
|
|
28
|
+
walkJsonl(p, out);
|
|
29
|
+
else if (e.isFile() && e.name.endsWith(".jsonl"))
|
|
30
|
+
out.push(p);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Cheap header check: only the first line is needed to match the project.
|
|
36
|
+
* The session_meta line can be huge (it embeds the agent's base instructions),
|
|
37
|
+
* so read in growing chunks until the first newline appears.
|
|
38
|
+
*/
|
|
39
|
+
function readMeta(file) {
|
|
40
|
+
let fd;
|
|
41
|
+
try {
|
|
42
|
+
fd = fs.openSync(file, "r");
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const CHUNK = 65536;
|
|
49
|
+
const MAX = 4 * 1024 * 1024;
|
|
50
|
+
let data = Buffer.alloc(0);
|
|
51
|
+
let pos = 0;
|
|
52
|
+
let nl = -1;
|
|
53
|
+
while (nl < 0 && pos < MAX) {
|
|
54
|
+
const buf = Buffer.alloc(CHUNK);
|
|
55
|
+
const n = fs.readSync(fd, buf, 0, CHUNK, pos);
|
|
56
|
+
if (n <= 0)
|
|
57
|
+
break;
|
|
58
|
+
data = Buffer.concat([data, buf.subarray(0, n)]);
|
|
59
|
+
pos += n;
|
|
60
|
+
nl = data.indexOf(0x0a);
|
|
61
|
+
}
|
|
62
|
+
const firstLine = (nl >= 0 ? data.subarray(0, nl) : data).toString("utf8");
|
|
63
|
+
const rec = JSON.parse(firstLine);
|
|
64
|
+
if (rec["type"] !== "session_meta")
|
|
65
|
+
return null;
|
|
66
|
+
const payload = (rec["payload"] ?? {});
|
|
67
|
+
return {
|
|
68
|
+
cwd: typeof payload["cwd"] === "string" ? payload["cwd"] : null,
|
|
69
|
+
interactive: payload["source"] !== "exec",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
fs.closeSync(fd);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function parseEvents(file, sinceMs, untilMs, interactive) {
|
|
80
|
+
let raw;
|
|
81
|
+
try {
|
|
82
|
+
raw = fs.readFileSync(file, "utf8");
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
const events = [];
|
|
88
|
+
for (const line of raw.split("\n")) {
|
|
89
|
+
if (!line.trim())
|
|
90
|
+
continue;
|
|
91
|
+
let r;
|
|
92
|
+
try {
|
|
93
|
+
r = JSON.parse(line);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const tsStr = r["timestamp"];
|
|
99
|
+
if (typeof tsStr !== "string")
|
|
100
|
+
continue;
|
|
101
|
+
const ts = Date.parse(tsStr);
|
|
102
|
+
if (Number.isNaN(ts) || ts < sinceMs || ts > untilMs)
|
|
103
|
+
continue;
|
|
104
|
+
let kind = "work";
|
|
105
|
+
let presence = false;
|
|
106
|
+
if (interactive && r["type"] === "response_item") {
|
|
107
|
+
const p = (r["payload"] ?? {});
|
|
108
|
+
if (p["type"] === "message" && p["role"] === "user") {
|
|
109
|
+
const content = p["content"];
|
|
110
|
+
const text = Array.isArray(content)
|
|
111
|
+
? content.find((c) => c && typeof c === "object" && c["type"] === "input_text")?.["text"]
|
|
112
|
+
: undefined;
|
|
113
|
+
// Codex injects environment/permission context as user-role messages
|
|
114
|
+
// wrapped in <tags> — those are not human.
|
|
115
|
+
if (typeof text === "string" && !text.trimStart().startsWith("<")) {
|
|
116
|
+
kind = "prompt";
|
|
117
|
+
presence = true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
events.push({ ts, kind, presence });
|
|
122
|
+
}
|
|
123
|
+
events.sort((a, b) => a.ts - b.ts);
|
|
124
|
+
return events;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Loads all Codex sessions whose cwd is the project path (or inside it).
|
|
128
|
+
* Session names are prefixed "codex:" for --by-session readability.
|
|
129
|
+
*/
|
|
130
|
+
export function loadCodexSessions(projectPath, sinceMs, untilMs, baseDir = CODEX_SESSIONS_BASE) {
|
|
131
|
+
const project = path.resolve(projectPath);
|
|
132
|
+
const sessions = [];
|
|
133
|
+
for (const file of walkJsonl(baseDir).sort()) {
|
|
134
|
+
const meta = readMeta(file);
|
|
135
|
+
if (!meta || !meta.cwd)
|
|
136
|
+
continue;
|
|
137
|
+
if (meta.cwd !== project && !meta.cwd.startsWith(project + path.sep))
|
|
138
|
+
continue;
|
|
139
|
+
const events = parseEvents(file, sinceMs, untilMs, meta.interactive);
|
|
140
|
+
if (events.length > 0) {
|
|
141
|
+
sessions.push({ name: `codex:${path.relative(baseDir, file)}`, events });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return sessions;
|
|
145
|
+
}
|