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/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();
|