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/dist/cli.js ADDED
@@ -0,0 +1,658 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * agent-hours — billable hours from local coding-agent session logs
4
+ * (Claude Code today; Codex adapter planned), including the three-state
5
+ * split: hands-on / supervised / AI-autonomous.
6
+ *
7
+ * Data source: ~/.claude/projects/<project-hash>/*.jsonl (local only,
8
+ * retroactive, zero setup — nothing leaves your machine).
9
+ */
10
+ import { parseArgs } from "node:util";
11
+ import { spawnSync } from "node:child_process";
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { PROJECTS_BASE, activeMinutes, activeMinutesStrict, bucketMinutesByDay, computeRefinedSplit, countByDay, dayKey, detectOverlaps, findPauses, findProjectDir, loadProject, mergeEvents, parseDate, } from "./core.js";
15
+ import { WORKLOG_LLM_TEMPLATE, collectWorklog, describeLog, mergeLogs, shortPath, } from "./worklog.js";
16
+ import { CODEX_SESSIONS_BASE, loadCodexSessions } from "./sources/codex.js";
17
+ import { runInstall } from "./install.js";
18
+ const HELP = `agent-hours — billable hours from coding-agent session logs
19
+
20
+ Usage:
21
+ npx agent-hours install integrate with your agents (one shot):
22
+ drops a skill so you can just ask
23
+ "what did I work on this week?"
24
+ npx agent-hours current project, cap overview
25
+ npx agent-hours --split hands-on / supervised / AI-autonomous
26
+ npx agent-hours --by-day --split per-day table with split
27
+ npx agent-hours --worklog hourly worklog (invoice attachment)
28
+ npx agent-hours --worklog --csv hourly CSV incl. what-was-done column
29
+ npx agent-hours --worklog --csv --by-day daily CSV incl. descriptions
30
+ npx agent-hours --csv > hours.csv CSV export (invoice tools)
31
+ npx agent-hours --json machine-readable output
32
+ npx agent-hours --all-projects overview across all projects
33
+
34
+ Options:
35
+ --project <path|hash> project to analyze (default: cwd)
36
+ --source <s> auto | claude | codex (default: auto — merges all
37
+ agents into ONE timeline, no double counting)
38
+ --summarize refine worklog descriptions via \`claude -p\`
39
+ (opt-in, the ONLY feature that costs API money)
40
+ --since <date> start, inclusive (YYYY-MM-DD [HH:MM], UTC)
41
+ --until <date> end, inclusive (YYYY-MM-DD [HH:MM], UTC)
42
+ --cap <min> idle cap for total time (default: 10)
43
+ --prompt-cap <min> idle cap for hands-on reaction tails (default: 10)
44
+ --split show the three-state human/AI split
45
+ --by-day per-day breakdown
46
+ --by-session per-session breakdown (parallel-session debugging)
47
+ --worklog hourly what-was-done log (markdown)
48
+ --worklog-json hourly worklog as JSON + LLM prompt template
49
+ --csv CSV output (semicolon-separated)
50
+ --json JSON output (always includes the split)
51
+ --tz-offset <h> UTC offset for day bucketing (default: 2)
52
+ --pauses list longest pauses (> cap)
53
+ --top-pauses <n> how many pauses to list (default: 10)
54
+ --all-projects scan every project under ~/.claude/projects
55
+ -h, --help this help
56
+ -v, --version version
57
+
58
+ Methodology — three states, honest band instead of fake precision:
59
+ hands-on gaps ending in a prompt: you read/thought/typed (lower bound)
60
+ supervised agent working while evidence says you watched (reaction < 30s
61
+ => 100%, < 5min => 50%; mid-turn typing / external file edits
62
+ force 100%)
63
+ AI-autonomous the rest of agent runtime
64
+ Browser/call time is NOT in the logs — rule of thumb: add 15-25 %.`;
65
+ function fail(msg) {
66
+ process.stderr.write(msg + "\n");
67
+ process.exit(1);
68
+ }
69
+ function fmtH(minutes) {
70
+ return (minutes / 60).toFixed(2);
71
+ }
72
+ function hhmm(minutes) {
73
+ const m = Math.floor(minutes);
74
+ return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, "0")}`;
75
+ }
76
+ function main() {
77
+ let args;
78
+ let positionals = [];
79
+ try {
80
+ const parsed = parseArgs({
81
+ allowPositionals: true,
82
+ options: {
83
+ project: { type: "string" },
84
+ source: { type: "string", default: "auto" },
85
+ summarize: { type: "boolean", default: false },
86
+ since: { type: "string" },
87
+ until: { type: "string" },
88
+ cap: { type: "string", default: "10" },
89
+ "prompt-cap": { type: "string", default: "10" },
90
+ split: { type: "boolean", default: false },
91
+ "by-day": { type: "boolean", default: false },
92
+ "by-session": { type: "boolean", default: false },
93
+ worklog: { type: "boolean", default: false },
94
+ "worklog-json": { type: "boolean", default: false },
95
+ csv: { type: "boolean", default: false },
96
+ json: { type: "boolean", default: false },
97
+ "tz-offset": { type: "string", default: "2" },
98
+ pauses: { type: "boolean", default: false },
99
+ "top-pauses": { type: "string", default: "10" },
100
+ "all-projects": { type: "boolean", default: false },
101
+ help: { type: "boolean", short: "h", default: false },
102
+ version: { type: "boolean", short: "v", default: false },
103
+ },
104
+ });
105
+ args = parsed.values;
106
+ positionals = parsed.positionals;
107
+ }
108
+ catch (e) {
109
+ fail(`${e.message}\n\nRun agent-hours --help for usage.`);
110
+ }
111
+ if (positionals[0] === "install") {
112
+ const target = (positionals[1] ?? "all");
113
+ if (!["claude", "codex", "all"].includes(target)) {
114
+ fail("Usage: agent-hours install [claude|codex|all]");
115
+ }
116
+ for (const r of runInstall(target)) {
117
+ if (r.status === "skipped")
118
+ console.log(`- skipped: ${r.reason}`);
119
+ else
120
+ console.log(`- ${r.status}: ${r.path}`);
121
+ }
122
+ console.log();
123
+ console.log("Done. Now just ask your agent things like:");
124
+ console.log(` "How many hours did I work on this project last week?"`);
125
+ console.log(` "What did I work on yesterday? Export a CSV for my invoice."`);
126
+ console.log();
127
+ console.log("Tip (fewer permission prompts): allow \"Bash(npx agent-hours *)\"");
128
+ console.log("in ~/.claude/settings.json under permissions.allow.");
129
+ return;
130
+ }
131
+ else if (positionals.length > 0) {
132
+ fail(`Unknown command '${positionals[0]}'. Run agent-hours --help for usage.`);
133
+ }
134
+ if (args.help) {
135
+ console.log(HELP);
136
+ return;
137
+ }
138
+ if (args.version) {
139
+ const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
140
+ console.log(pkg.version);
141
+ return;
142
+ }
143
+ const cap = Number(args.cap);
144
+ const promptCap = Number(args["prompt-cap"]);
145
+ const tzOffset = Number(args["tz-offset"]);
146
+ const topPauses = Number(args["top-pauses"]);
147
+ if (!Number.isFinite(cap) || cap <= 0)
148
+ fail("--cap must be a positive number of minutes.");
149
+ if (!Number.isFinite(promptCap) || promptCap <= 0)
150
+ fail("--prompt-cap must be a positive number of minutes.");
151
+ if (!Number.isFinite(tzOffset))
152
+ fail("--tz-offset must be a number of hours.");
153
+ let sinceMs = 0;
154
+ let untilMs = Date.parse("2100-01-01T00:00:00Z");
155
+ try {
156
+ if (args.since)
157
+ sinceMs = parseDate(args.since);
158
+ if (args.until) {
159
+ untilMs =
160
+ args.until.includes(" ") || args.until.includes("T")
161
+ ? parseDate(args.until)
162
+ : parseDate(args.until + "T23:59:59Z");
163
+ }
164
+ }
165
+ catch (e) {
166
+ fail(e.message);
167
+ }
168
+ if (args["all-projects"]) {
169
+ runAllProjects(sinceMs, untilMs, cap, promptCap, tzOffset, args.split, args.json);
170
+ return;
171
+ }
172
+ const source = String(args.source).toLowerCase();
173
+ if (!["auto", "claude", "codex"].includes(source)) {
174
+ fail("--source must be auto, claude or codex.");
175
+ }
176
+ const wantClaude = source === "auto" || source === "claude";
177
+ const wantCodex = source === "auto" || source === "codex";
178
+ const projectDir = findProjectDir(args.project);
179
+ // Codex matching needs a real path; a bare Claude hash can't be mapped back.
180
+ const projectPath = args.project === undefined
181
+ ? process.cwd()
182
+ : args.project.includes(path.sep) || args.project.startsWith("/")
183
+ ? path.resolve(args.project)
184
+ : null;
185
+ const sessions = [];
186
+ let claudeCount = 0;
187
+ if (wantClaude && fs.existsSync(projectDir)) {
188
+ sessions.push(...loadProject(projectDir, sinceMs, untilMs));
189
+ claudeCount = sessions.length;
190
+ }
191
+ if (wantCodex && projectPath && fs.existsSync(CODEX_SESSIONS_BASE)) {
192
+ sessions.push(...loadCodexSessions(projectPath, sinceMs, untilMs));
193
+ }
194
+ const codexCount = sessions.length - claudeCount;
195
+ if (sessions.length === 0) {
196
+ fail(`ERROR: no sessions found.\n` +
197
+ ` Claude logs checked: ${projectDir}\n` +
198
+ (wantCodex ? ` Codex logs checked: ${CODEX_SESSIONS_BASE} (cwd match)\n` : "") +
199
+ `Tip: pass --project <path> or run from the project root.`);
200
+ }
201
+ const merged = mergeEvents(sessions);
202
+ const overlapping = detectOverlaps(sessions);
203
+ const refined = computeRefinedSplit(merged, {
204
+ capMinutes: cap,
205
+ promptCapMinutes: promptCap,
206
+ tzOffsetHours: tzOffset,
207
+ });
208
+ const allTimes = merged.map((e) => e.ts);
209
+ if (args["worklog"] || args["worklog-json"]) {
210
+ if (args.csv && !args["worklog-json"]) {
211
+ printWorklogCsv(projectDir, sinceMs, untilMs, tzOffset, refined, args["by-day"], args.summarize);
212
+ }
213
+ else {
214
+ printWorklog(projectDir, sinceMs, untilMs, tzOffset, refined, args["worklog-json"], args.summarize);
215
+ }
216
+ return;
217
+ }
218
+ if (args.json) {
219
+ printJson(projectDir, args, sessions, merged, refined, cap, promptCap, tzOffset, overlapping);
220
+ return;
221
+ }
222
+ if (args.csv) {
223
+ printCsv(refined, args.split);
224
+ return;
225
+ }
226
+ // Human-readable output
227
+ console.log(`Project logs: ${projectDir}`);
228
+ console.log(`Range: ${args.since ?? "beginning"} – ${args.until ?? "now"}`);
229
+ console.log(`Sessions in range: ${sessions.length}` +
230
+ (codexCount > 0 ? ` (claude ${claudeCount}, codex ${codexCount})` : ""));
231
+ console.log(`Total events: ${merged.length.toLocaleString("en-US")}`);
232
+ console.log();
233
+ console.log("Activity at different idle caps:");
234
+ console.log(` ${"Cap".padEnd(8)} ${"with cap bonus".padEnd(16)} ${"strict (pause>cap=0)".padEnd(22)}`);
235
+ for (const c of [1, 2, 3, 5, 10, 15]) {
236
+ const h = fmtH(activeMinutes(allTimes, c));
237
+ const hs = fmtH(activeMinutesStrict(allTimes, c));
238
+ let marker = c === Math.round(cap) ? " ← standard" : "";
239
+ if (c === 2)
240
+ marker += " ← pure working time";
241
+ console.log(` ${String(c).padStart(2)}min ${h.padStart(6)}h ${hs.padStart(6)}h${marker}`);
242
+ }
243
+ console.log();
244
+ console.log(" with cap bonus: industry standard (pause > cap counts cap minutes).");
245
+ console.log(" strict : pause > cap counts 0 — honest lower bound for");
246
+ console.log(" background-heavy sessions (orchestrator waits).");
247
+ console.log();
248
+ if (args.split) {
249
+ console.log(`Human/AI split — three-state model (cap ${cap}min / prompt cap ${promptCap}min):`);
250
+ console.log(` Hands-on (provably human): ${fmtH(refined.handsOnMinutes).padStart(7)} h (${refined.promptCount} prompts)`);
251
+ console.log(` Supervised (weighted est.): ${fmtH(refined.supervisedMinutes).padStart(7)} h`);
252
+ console.log(` = Human attention (model): ${fmtH(refined.attentionMinutes).padStart(7)} h`);
253
+ console.log(` Upper bound (inter-prompt): ${fmtH(refined.upperBoundMinutes).padStart(7)} h`);
254
+ console.log(` AI autonomous (model): ${fmtH(refined.aiAutonomousMinutes).padStart(7)} h`);
255
+ console.log(` Total (merged timeline): ${fmtH(refined.totalMinutes).padStart(7)} h`);
256
+ console.log();
257
+ console.log(" Band: hands-on is the defensible lower bound, inter-prompt the upper.");
258
+ console.log(" The model weighs agent-working windows by watch evidence (reaction");
259
+ console.log(" < 30s / mid-turn typing / external edits). Billing decision is yours.");
260
+ console.log();
261
+ console.log(" Range across caps (cap and prompt cap varied together):");
262
+ for (const c of [5, 10, 15]) {
263
+ const s = computeRefinedSplit(merged, {
264
+ capMinutes: c,
265
+ promptCapMinutes: c,
266
+ tzOffsetHours: tzOffset,
267
+ });
268
+ console.log(` cap ${String(c).padStart(2)}min: total ${fmtH(s.totalMinutes).padStart(6)}h | attention ${fmtH(s.attentionMinutes).padStart(6)}h | AI ${fmtH(s.aiAutonomousMinutes).padStart(6)}h`);
269
+ }
270
+ console.log();
271
+ }
272
+ const realPauses = findPauses(allTimes, cap);
273
+ if (realPauses.length > 0 && (args.pauses || !args.split)) {
274
+ const n = Math.min(topPauses, realPauses.length);
275
+ const idleTotal = realPauses.reduce((acc, p) => acc + p.minutes, 0);
276
+ const capBonus = realPauses.length * cap;
277
+ console.log(`Pauses > ${cap}min cap (${realPauses.length} total, ${fmtH(idleTotal)}h idle):`);
278
+ console.log(` Cap-bonus effect: ${capBonus.toFixed(0)}min (${fmtH(capBonus)}h) still counted as work by the standard method.`);
279
+ console.log();
280
+ console.log(` Top ${n} longest pauses (shown at tz offset ${tzOffset >= 0 ? "+" : ""}${tzOffset}h):`);
281
+ for (let i = 0; i < n; i++) {
282
+ const p = realPauses[i];
283
+ const s = new Date(p.startMs + tzOffset * 3600_000).toISOString().slice(11, 19);
284
+ const e = new Date(p.endMs + tzOffset * 3600_000).toISOString().slice(11, 19);
285
+ const dur = p.minutes >= 60
286
+ ? `${Math.floor(p.minutes / 60)}h${String(Math.floor(p.minutes % 60)).padStart(2, "0")}m`
287
+ : `${p.minutes.toFixed(1)} min`;
288
+ const day = dayKey(p.startMs, tzOffset);
289
+ console.log(` ${String(i + 1).padStart(2)}. ${day} ${s} – ${e} → ${dur}`);
290
+ }
291
+ console.log();
292
+ }
293
+ if (overlapping) {
294
+ console.log("⚠ NOTE: multiple sessions/subagents ran IN PARALLEL.");
295
+ console.log(" Hours above use the merged timeline (every real minute counted");
296
+ console.log(" once — no double counting). Details per session: --by-session");
297
+ console.log();
298
+ }
299
+ if (args["by-session"]) {
300
+ console.log(`Per session (cap ${cap}min):`);
301
+ for (const s of sessions) {
302
+ const times = s.events.map((e) => e.ts);
303
+ const a = fmtH(activeMinutes(times, cap));
304
+ const f = new Date(times[0] + tzOffset * 3600_000).toISOString().slice(0, 16).replace("T", " ");
305
+ const l = new Date(times[times.length - 1] + tzOffset * 3600_000).toISOString().slice(0, 16).replace("T", " ");
306
+ console.log(` ${s.name}`);
307
+ console.log(` ${f} – ${l} | ${String(times.length).padStart(4)} events | ${a.padStart(6)}h active`);
308
+ }
309
+ const sumH = sessions.reduce((acc, s) => acc + activeMinutes(s.events.map((e) => e.ts), cap), 0);
310
+ console.log();
311
+ console.log(` Sum of individual sessions (double-counts overlap): ${fmtH(sumH)}h`);
312
+ console.log(` Merged timeline (real active time): ${fmtH(refined.totalMinutes)}h`);
313
+ console.log();
314
+ }
315
+ if (args["by-day"]) {
316
+ if (args.split) {
317
+ const days = aggregateDays(refined);
318
+ console.log(`Per day (cap ${cap}min / prompt cap ${promptCap}min):`);
319
+ console.log(` Date | Total | Hands-on | Superv. | AI-auto`);
320
+ for (const day of [...days.keys()].sort()) {
321
+ const d = days.get(day);
322
+ console.log(` ${day} | ${fmtH(d.total).padStart(5)}h | ${fmtH(d.handsOn).padStart(7)}h | ${fmtH(d.supervised).padStart(6)}h | ${fmtH(d.ai).padStart(6)}h`);
323
+ }
324
+ }
325
+ else {
326
+ const dayTotal = bucketMinutesByDay(allTimes, cap, tzOffset);
327
+ const dayMsgs = countByDay(allTimes, tzOffset);
328
+ console.log(`Per day (cap ${cap}min):`);
329
+ for (const day of [...dayTotal.keys()].sort()) {
330
+ console.log(` ${day} | ${fmtH(dayTotal.get(day) ?? 0).padStart(5)}h | ${String(dayMsgs.get(day) ?? 0).padStart(4)} events`);
331
+ }
332
+ }
333
+ console.log();
334
+ console.log(`Sum: ${(refined.totalMinutes / 60).toFixed(1)} h`);
335
+ }
336
+ }
337
+ function aggregateDays(refined) {
338
+ const days = new Map();
339
+ for (const [hour, st] of refined.byHour) {
340
+ const day = hour.slice(0, 10);
341
+ const d = days.get(day) ?? { total: 0, handsOn: 0, supervised: 0, ai: 0 };
342
+ d.total += st.total;
343
+ d.handsOn += st.handsOn;
344
+ d.supervised += st.supervised;
345
+ d.ai += st.ai;
346
+ days.set(day, d);
347
+ }
348
+ return days;
349
+ }
350
+ function printCsv(refined, withSplit) {
351
+ const days = aggregateDays(refined);
352
+ const header = ["Datum", "Dauer (h)", "Dauer (Stunden:Minuten)"];
353
+ if (withSplit)
354
+ header.push("Hands-on (h)", "Supervised (h)", "Mensch gesamt (h)", "AI-solo (h)");
355
+ console.log(header.join(";"));
356
+ for (const day of [...days.keys()].sort()) {
357
+ const d = days.get(day);
358
+ const row = [day, fmtH(d.total), hhmm(d.total)];
359
+ if (withSplit) {
360
+ row.push(fmtH(d.handsOn), fmtH(d.supervised), fmtH(d.handsOn + d.supervised), fmtH(d.ai));
361
+ }
362
+ console.log(row.join(";"));
363
+ }
364
+ }
365
+ function csvField(s) {
366
+ return /[;"\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
367
+ }
368
+ /**
369
+ * Optional AI refinement — the ONLY feature that spends API money, therefore
370
+ * strictly opt-in via --summarize. One `claude -p` call for ALL buckets.
371
+ */
372
+ function aiSummarize(buckets) {
373
+ const instruction = "You receive JSON evidence buckets extracted from coding-agent session logs. " +
374
+ "For EACH bucket output exactly one line in the format <key>\\t<summary> " +
375
+ "(summary: max 14 words, dominant language of the evidence, invoice-attachment tone). " +
376
+ "Finish with one line OVERALL\\t<2-3 sentence summary of the whole period>. Output nothing else.";
377
+ const res = spawnSync("claude", ["-p", instruction], {
378
+ input: JSON.stringify(buckets),
379
+ encoding: "utf8",
380
+ maxBuffer: 16 * 1024 * 1024,
381
+ });
382
+ if (res.error || res.status !== 0) {
383
+ fail("--summarize shells out to `claude -p` and needs the claude CLI on PATH.\n" +
384
+ `Error: ${res.error?.message ?? (res.stderr || "exit code " + res.status)}`);
385
+ }
386
+ const map = new Map();
387
+ for (const line of res.stdout.split("\n")) {
388
+ const idx = line.indexOf("\t");
389
+ if (idx > 0)
390
+ map.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
391
+ }
392
+ return map;
393
+ }
394
+ /**
395
+ * Worklog as CSV with rule-based descriptions — hourly by default, daily with
396
+ * --by-day. Always ends with a GESAMT row describing the whole range.
397
+ */
398
+ function printWorklogCsv(projectDir, sinceMs, untilMs, tzOffset, refined, byDay, summarize = false) {
399
+ const log = collectWorklog(projectDir, sinceMs, untilMs, tzOffset);
400
+ const keys = [...new Set([...refined.byHour.keys(), ...log.keys()])].sort();
401
+ // Optional AI pass: collect evidence per row key, one claude -p call.
402
+ let ai = new Map();
403
+ if (summarize) {
404
+ const buckets = [];
405
+ const rowKeys = byDay ? [...new Set(keys.map((k) => k.slice(0, 10)))].sort() : keys;
406
+ for (const rk of rowKeys) {
407
+ const logs = byDay
408
+ ? keys.filter((k) => k.startsWith(rk)).map((k) => log.get(k)).filter((l) => !!l)
409
+ : [log.get(rk)].filter(Boolean);
410
+ const m = mergeLogs(logs);
411
+ buckets.push({
412
+ key: rk,
413
+ commits: m.commits.slice(0, 8),
414
+ prompts: m.prompts.slice(0, 6),
415
+ files: [...m.filesEdited].map(shortPath).slice(0, 10),
416
+ awaySummaries: m.awaySummaries.slice(-2),
417
+ });
418
+ }
419
+ ai = aiSummarize(buckets);
420
+ }
421
+ const describe = (key, l, maxLen) => ai.get(key) ?? describeLog(l, maxLen);
422
+ if (byDay) {
423
+ console.log(["Datum", "Aktiv (h)", "Hands-on (h)", "Supervised (h)", "AI (h)", "Beschreibung"].join(";"));
424
+ const days = [...new Set(keys.map((k) => k.slice(0, 10)))].sort();
425
+ for (const day of days) {
426
+ const dayHours = keys.filter((k) => k.startsWith(day));
427
+ const st = dayHours.reduce((acc, h) => {
428
+ const s = refined.byHour.get(h);
429
+ if (s) {
430
+ acc.total += s.total;
431
+ acc.handsOn += s.handsOn;
432
+ acc.supervised += s.supervised;
433
+ acc.ai += s.ai;
434
+ }
435
+ return acc;
436
+ }, { total: 0, handsOn: 0, supervised: 0, ai: 0 });
437
+ const merged = mergeLogs(dayHours.map((h) => log.get(h)).filter((l) => !!l));
438
+ console.log([day, fmtH(st.total), fmtH(st.handsOn), fmtH(st.supervised), fmtH(st.ai), csvField(describe(day, merged))].join(";"));
439
+ }
440
+ }
441
+ else {
442
+ console.log(["Datum", "Stunde", "Aktiv (min)", "Hands-on (min)", "Supervised (min)", "AI (min)", "Beschreibung"].join(";"));
443
+ for (const k of keys) {
444
+ const st = refined.byHour.get(k) ?? { total: 0, handsOn: 0, supervised: 0, ai: 0 };
445
+ const l = log.get(k);
446
+ console.log([
447
+ k.slice(0, 10),
448
+ k.slice(11),
449
+ st.total.toFixed(0),
450
+ st.handsOn.toFixed(0),
451
+ st.supervised.toFixed(0),
452
+ st.ai.toFixed(0),
453
+ csvField(l ? describe(k, l) : ""),
454
+ ].join(";"));
455
+ }
456
+ }
457
+ // Project-level summary row (chronological merge)
458
+ const all = mergeLogs(keys.map((k) => log.get(k)).filter((l) => !!l));
459
+ const label = byDay ? "GESAMT" : "GESAMT;";
460
+ console.log([
461
+ label,
462
+ byDay ? fmtH(refined.totalMinutes) : refined.totalMinutes.toFixed(0),
463
+ byDay ? fmtH(refined.handsOnMinutes) : refined.handsOnMinutes.toFixed(0),
464
+ byDay ? fmtH(refined.supervisedMinutes) : refined.supervisedMinutes.toFixed(0),
465
+ byDay ? fmtH(refined.aiAutonomousMinutes) : refined.aiAutonomousMinutes.toFixed(0),
466
+ csvField(ai.get("OVERALL") ?? describeLog(all, 400)),
467
+ ].join(";"));
468
+ }
469
+ function printWorklog(projectDir, sinceMs, untilMs, tzOffset, refined, asJson, summarize = false) {
470
+ const log = collectWorklog(projectDir, sinceMs, untilMs, tzOffset);
471
+ const hours = [...new Set([...refined.byHour.keys(), ...log.keys()])].sort();
472
+ let ai = new Map();
473
+ if (summarize && !asJson) {
474
+ ai = aiSummarize(hours
475
+ .filter((h) => log.has(h))
476
+ .map((h) => {
477
+ const l = log.get(h);
478
+ return {
479
+ key: h,
480
+ commits: l.commits.slice(0, 8),
481
+ prompts: l.prompts.slice(0, 6),
482
+ files: [...l.filesEdited].map(shortPath).slice(0, 10),
483
+ awaySummaries: l.awaySummaries.slice(-2),
484
+ };
485
+ }));
486
+ }
487
+ if (asJson) {
488
+ const out = hours.map((h) => {
489
+ const st = refined.byHour.get(h);
490
+ const l = log.get(h);
491
+ return {
492
+ hour: h,
493
+ minutes: st
494
+ ? {
495
+ total: +st.total.toFixed(1),
496
+ handsOn: +st.handsOn.toFixed(1),
497
+ supervised: +st.supervised.toFixed(1),
498
+ aiAutonomous: +st.ai.toFixed(1),
499
+ }
500
+ : null,
501
+ prompts: l?.prompts ?? [],
502
+ filesEdited: l ? [...l.filesEdited] : [],
503
+ commands: l?.commands ?? [],
504
+ commits: l?.commits ?? [],
505
+ awaySummaries: l?.awaySummaries ?? [],
506
+ };
507
+ });
508
+ console.log(JSON.stringify({ llmTemplate: WORKLOG_LLM_TEMPLATE, hours: out }, null, 2));
509
+ return;
510
+ }
511
+ console.log(`# Worklog ${projectDir.split("/").pop()}`);
512
+ let currentDay = "";
513
+ for (const h of hours) {
514
+ const day = h.slice(0, 10);
515
+ if (day !== currentDay) {
516
+ currentDay = day;
517
+ console.log(`\n## ${day}`);
518
+ }
519
+ const st = refined.byHour.get(h);
520
+ const l = log.get(h);
521
+ const mins = st
522
+ ? `${st.total.toFixed(0)} min active (${st.handsOn.toFixed(0)} hands-on / ${st.supervised.toFixed(0)} supervised / ${st.ai.toFixed(0)} AI)`
523
+ : "evidence only";
524
+ console.log(`\n### ${h.slice(11)} · ${mins}`);
525
+ const summary = ai.get(h);
526
+ if (summary)
527
+ console.log(`- **Summary:** ${summary}`);
528
+ if (l) {
529
+ if (l.prompts.length) {
530
+ console.log(`- Prompts (${l.prompts.length}): ${l.prompts.slice(0, 3).map((p) => `"${p}"`).join(" | ")}${l.prompts.length > 3 ? " …" : ""}`);
531
+ }
532
+ if (l.filesEdited.size) {
533
+ const files = [...l.filesEdited].map(shortPath);
534
+ console.log(`- Files edited (${files.length}): ${files.slice(0, 6).join(", ")}${files.length > 6 ? " …" : ""}`);
535
+ }
536
+ if (l.commits.length)
537
+ console.log(`- Commits: ${l.commits.map((c) => `"${c}"`).join(", ")}`);
538
+ if (l.commands.length)
539
+ console.log(`- Commands (${l.commands.length}): ${l.commands.slice(0, 3).join(" | ")}${l.commands.length > 3 ? " …" : ""}`);
540
+ for (const a of l.awaySummaries)
541
+ console.log(`- Away summary: ${a}`);
542
+ }
543
+ }
544
+ console.log();
545
+ console.log(`---`);
546
+ const overall = ai.get("OVERALL");
547
+ if (overall)
548
+ console.log(`**Overall:** ${overall}\n`);
549
+ console.log(`Totals: ${fmtH(refined.totalMinutes)}h | hands-on ${fmtH(refined.handsOnMinutes)}h | supervised ${fmtH(refined.supervisedMinutes)}h | AI ${fmtH(refined.aiAutonomousMinutes)}h`);
550
+ if (!summarize) {
551
+ console.log(`Tip: --summarize refines descriptions via claude -p (paid), or pipe --worklog-json into your AI chat for free.`);
552
+ }
553
+ }
554
+ function printJson(projectDir, args, sessions, merged, refined, cap, promptCap, tzOffset, overlapping) {
555
+ const allTimes = merged.map((e) => e.ts);
556
+ const days = aggregateDays(refined);
557
+ const caps = {};
558
+ for (const c of [5, 10, 15]) {
559
+ const s = computeRefinedSplit(merged, {
560
+ capMinutes: c,
561
+ promptCapMinutes: c,
562
+ tzOffsetHours: tzOffset,
563
+ });
564
+ caps[String(c)] = {
565
+ totalHours: +(s.totalMinutes / 60).toFixed(2),
566
+ attentionHours: +(s.attentionMinutes / 60).toFixed(2),
567
+ aiAutonomousHours: +(s.aiAutonomousMinutes / 60).toFixed(2),
568
+ };
569
+ }
570
+ console.log(JSON.stringify({
571
+ projectDir,
572
+ since: args.since ?? null,
573
+ until: args.until ?? null,
574
+ capMinutes: cap,
575
+ promptCapMinutes: promptCap,
576
+ tzOffsetHours: tzOffset,
577
+ sessions: sessions.length,
578
+ events: merged.length,
579
+ prompts: refined.promptCount,
580
+ parallelSessions: overlapping,
581
+ totalHours: +(refined.totalMinutes / 60).toFixed(2),
582
+ strictTotalHours: +(activeMinutesStrict(allTimes, cap) / 60).toFixed(2),
583
+ handsOnHours: +(refined.handsOnMinutes / 60).toFixed(2),
584
+ supervisedHours: +(refined.supervisedMinutes / 60).toFixed(2),
585
+ attentionHours: +(refined.attentionMinutes / 60).toFixed(2),
586
+ upperBoundHours: +(refined.upperBoundMinutes / 60).toFixed(2),
587
+ aiAutonomousHours: +(refined.aiAutonomousMinutes / 60).toFixed(2),
588
+ capRange: caps,
589
+ byDay: [...days.keys()].sort().map((day) => {
590
+ const d = days.get(day);
591
+ return {
592
+ date: day,
593
+ totalHours: +(d.total / 60).toFixed(2),
594
+ handsOnHours: +(d.handsOn / 60).toFixed(2),
595
+ supervisedHours: +(d.supervised / 60).toFixed(2),
596
+ attentionHours: +((d.handsOn + d.supervised) / 60).toFixed(2),
597
+ aiAutonomousHours: +(d.ai / 60).toFixed(2),
598
+ };
599
+ }),
600
+ }, null, 2));
601
+ }
602
+ function runAllProjects(sinceMs, untilMs, cap, promptCap, tzOffset, withSplit, asJson) {
603
+ let dirs;
604
+ try {
605
+ dirs = fs
606
+ .readdirSync(PROJECTS_BASE, { withFileTypes: true })
607
+ .filter((d) => d.isDirectory())
608
+ .map((d) => d.name)
609
+ .sort();
610
+ }
611
+ catch {
612
+ fail(`ERROR: cannot read ${PROJECTS_BASE}`);
613
+ }
614
+ const rows = [];
615
+ for (const dir of dirs) {
616
+ const sessions = loadProject(path.join(PROJECTS_BASE, dir), sinceMs, untilMs);
617
+ if (sessions.length === 0)
618
+ continue;
619
+ const merged = mergeEvents(sessions);
620
+ const refined = computeRefinedSplit(merged, {
621
+ capMinutes: cap,
622
+ promptCapMinutes: promptCap,
623
+ tzOffsetHours: tzOffset,
624
+ });
625
+ rows.push({
626
+ project: dir,
627
+ totalHours: +(refined.totalMinutes / 60).toFixed(2),
628
+ attentionHours: +(refined.attentionMinutes / 60).toFixed(2),
629
+ aiAutonomousHours: +(refined.aiAutonomousMinutes / 60).toFixed(2),
630
+ events: merged.length,
631
+ lastActivity: dayKey(merged[merged.length - 1].ts, tzOffset),
632
+ });
633
+ }
634
+ rows.sort((a, b) => b.totalHours - a.totalHours);
635
+ if (asJson) {
636
+ console.log(JSON.stringify({ capMinutes: cap, promptCapMinutes: promptCap, projects: rows }, null, 2));
637
+ return;
638
+ }
639
+ console.log(`All projects (cap ${cap}min)${withSplit ? " — with split" : ""}:`);
640
+ console.log();
641
+ const head = withSplit
642
+ ? ` ${"Hours".padStart(7)} ${"Attn".padStart(7)} ${"AI-auto".padStart(8)} ${"Last".padEnd(11)} Project`
643
+ : ` ${"Hours".padStart(7)} ${"Events".padStart(7)} ${"Last".padEnd(11)} Project`;
644
+ console.log(head);
645
+ let sum = 0;
646
+ for (const r of rows) {
647
+ sum += r.totalHours;
648
+ if (withSplit) {
649
+ console.log(` ${r.totalHours.toFixed(2).padStart(6)}h ${r.attentionHours.toFixed(2).padStart(6)}h ${r.aiAutonomousHours.toFixed(2).padStart(7)}h ${r.lastActivity.padEnd(11)} ${r.project}`);
650
+ }
651
+ else {
652
+ console.log(` ${r.totalHours.toFixed(2).padStart(6)}h ${String(r.events).padStart(7)} ${r.lastActivity.padEnd(11)} ${r.project}`);
653
+ }
654
+ }
655
+ console.log();
656
+ console.log(` ${rows.length} projects, ${sum.toFixed(1)} h total in range.`);
657
+ }
658
+ main();