Skip to content

Commit 50792e7

Browse files
ralyodioclaude
andcommitted
feat: count the agents on an entry, and answer to on/off
An hour of agentic work is an hour times however many engines ran in it, so an entry now carries `agents` (default 1) and reports `agentHours` in --json. @profullstack/billing multiplies by it when the rate says to ($100/hour/agent/upto:4) and ignores it when the rate is flat. --agents takes a whole number. `auto` is refused with a message saying why: moshcode reads the count off its own herd and passes the result, and this package has no herd to ask. Silently treating "auto" as 1 would under-bill every entry it appeared on. `start` also answers to `on` and `stop` to `off`, so muscle memory from other timers works and moshcode's own /timer wording keeps meaning what it meant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KVUZsx4WBZK5rnJJFGgwF5
1 parent 45e3f20 commit 50792e7

5 files changed

Lines changed: 68 additions & 9 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ Everything `--json` returns is built from this shape:
4242
"billable": true,
4343
"rate": null,
4444
"agent": "claude-opus-5",
45+
"agents": 1,
46+
"agentHours": 1.5,
4547
"notes": "",
4648
"meta": {}
4749
}
@@ -59,6 +61,11 @@ ID=$(timer start acme --task "refactor auth" --json | jq -r .started.id)
5961
timer stop --id "$ID" --json
6062
```
6163

64+
`--agents N` records how many engines were working during the entry, which is
65+
what an agent-priced rate multiplies by. It takes a whole number: `auto` is
66+
rejected rather than silently treated as 1, because this package has no herd to
67+
count and under-billing quietly is worse than an error.
68+
6269
Several clocks may run at once, which is the point: parallel agents each track
6370
their own work and do not stop each other. Use `--switch` only if you mean to
6471
close everyone else's clock.

README.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ you start a clock.
3636

3737
| Command | What it does |
3838
| --- | --- |
39-
| `start <project> [task…]` | Start a clock. `--at 09:15`, `--at -20m`, `--tag`, `--note`, `--rate`, `--switch` |
39+
| `start <project> [task…]` | Start a clock. `--at 09:15`, `--at -20m`, `--tag`, `--note`, `--rate`, `--agents N`, `--switch` |
4040
| `stop [id]` | Stop the newest clock, an id, `--project <p>`, or `--all` |
4141
| `status` | Running clocks and today's total |
4242
| `log [project]` | List entries in a window |
@@ -65,6 +65,23 @@ A window compares against the entry's **start**, and `--until` is exclusive. An
6565
entry that runs past midnight therefore belongs to the day it began on — which
6666
is what keeps a total from being counted twice.
6767

68+
### Counting agents
69+
70+
An hour of agentic work is an hour times however many engines ran in it, so an
71+
entry carries an agent count:
72+
73+
```sh
74+
timer start acme refactor auth --agents 4
75+
timer add acme code review --duration 45m --agents 2
76+
```
77+
78+
`@profullstack/billing` multiplies by it when the rate says to
79+
(`$100/hour/agent/upto:4`), and ignores it when the rate is flat. It defaults to
80+
1, so you can ignore the whole idea until you need it.
81+
82+
`start` is also spelled `on` and `stop` is also spelled `off`, so muscle memory
83+
from other timers works.
84+
6885
### Billable and not
6986

7087
Every entry is billable unless you say otherwise with `--no-billable`. Reports
@@ -97,7 +114,9 @@ timer log --today --json
97114
```
98115

99116
`--meta '{"pr":42}'` hangs your own identifiers off an entry, and they survive
100-
round-trip unchanged.
117+
round-trip unchanged. `--agents N` records how many engines were working, which
118+
is what an agent-priced rate multiplies by; it takes a number, not `auto`, since
119+
this package has no herd to count.
101120

102121
There is more detail, including the entry schema, in [AGENTS.md](AGENTS.md).
103122

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@profullstack/timer",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"type": "module",
55
"description": "A time tracker for the terminal and for agents — start a clock against a project, stop it, and get billable hours back as text or JSON.",
66
"keywords": ["timer", "time-tracking", "timesheet", "billable", "hours", "cli", "agent", "freelance"],

src/cli.mjs

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
} from "./entries.mjs";
2525
import { formatDuration, hours, parseMoment, resolveWindow, shortStamp } from "./time.mjs";
2626

27-
export const VERSION = "0.1.0";
27+
export const VERSION = "0.2.0";
2828

2929
/** A bad command line — worth a different exit code than a failed operation. */
3030
export class UsageError extends Error {
@@ -73,6 +73,8 @@ function serialize(entry, now = new Date()) {
7373
billable: entry.billable,
7474
rate: entry.rate,
7575
agent: entry.agent,
76+
agents: entry.agents ?? 1,
77+
agentHours: hours(secs * (entry.agents ?? 1)),
7678
notes: entry.notes,
7779
meta: entry.meta,
7880
};
@@ -84,6 +86,7 @@ const ENTRY_COLUMNS = [
8486
{ header: "PROJECT", get: (e) => e.project },
8587
{ header: "TASK", get: (e) => e.task || "-" },
8688
{ header: "TIME", get: (e) => formatDuration(e.seconds), align: "right" },
89+
{ header: "AGENTS", get: (e) => (e.agents > 1 ? e.agents : "") , align: "right" },
8790
{ header: "TAGS", get: (e) => (e.tags.length ? e.tags.join(",") : "-") },
8891
{ header: "", get: (e) => (e.running ? "running" : (e.billable ? "" : "unbillable")) },
8992
];
@@ -107,6 +110,26 @@ function sumOf(rows) {
107110
return { entries: rows.length, seconds: secs, hours: hours(secs), billableSeconds: billable, billableHours: hours(billable) };
108111
}
109112

113+
/**
114+
* How many agents were working, from --agents.
115+
*
116+
* A number only. moshcode's `--agents auto` reads the count off its own herd
117+
* and passes the result here: this package has no herd to ask, and silently
118+
* treating "auto" as 1 would under-bill every entry it appeared on.
119+
*/
120+
function agentCount(flags) {
121+
if (flags.agents == null) return 1;
122+
const n = Number(flags.agents);
123+
if (!Number.isFinite(n) || n < 1 || !Number.isInteger(n)) {
124+
throw new UsageError(
125+
`--agents: "${flags.agents}" is not a whole number of agents`
126+
+ (String(flags.agents).toLowerCase() === "auto"
127+
? " (this package has no herd to count; pass the number)" : ""),
128+
);
129+
}
130+
return n;
131+
}
132+
110133
function requireProject(positional, flags) {
111134
const project = flags.project || positional[0];
112135
if (!project) throw new UsageError("which project? e.g. timer start acme");
@@ -118,11 +141,11 @@ function requireProject(positional, flags) {
118141
const COMMANDS = [
119142
{
120143
name: "start",
121-
aliases: ["begin", "in"],
144+
aliases: ["begin", "in", "on"],
122145
args: "<project> [task words…]",
123146
summary: "start the clock on a project",
124147
booleans: ["billable", "switch"],
125-
values: ["task", "note", "agent", "rate", "at", "project", "meta"],
148+
values: ["task", "note", "agent", "agents", "rate", "at", "project", "meta"],
126149
multi: ["tag"],
127150
detail: [
128151
"Everything after the project name is taken as the task, so you can type",
@@ -154,6 +177,7 @@ const COMMANDS = [
154177
start: at,
155178
notes: flags.note || "",
156179
agent: flags.agent || process.env.TIMER_AGENT || null,
180+
agents: agentCount(flags),
157181
rate: flags.rate,
158182
billable: flags.billable !== false,
159183
meta,
@@ -175,7 +199,7 @@ const COMMANDS = [
175199
},
176200
{
177201
name: "stop",
178-
aliases: ["out"],
202+
aliases: ["out", "off"],
179203
args: "[id]",
180204
summary: "stop a running clock",
181205
booleans: ["all"],
@@ -299,7 +323,7 @@ const COMMANDS = [
299323
args: "<project> [task words…]",
300324
summary: "record time you did not clock",
301325
booleans: ["billable"],
302-
values: ["from", "to", "duration", "task", "note", "agent", "rate", "project", "meta"],
326+
values: ["from", "to", "duration", "task", "note", "agent", "agents", "rate", "project", "meta"],
303327
multi: ["tag"],
304328
detail: [
305329
"Give any two of --from, --to and --duration; with only --duration the entry",
@@ -338,6 +362,7 @@ const COMMANDS = [
338362
end: bounds.end,
339363
notes: flags.note || "",
340364
agent: flags.agent || process.env.TIMER_AGENT || null,
365+
agents: agentCount(flags),
341366
rate: flags.rate,
342367
billable: flags.billable !== false,
343368
meta,
@@ -358,7 +383,7 @@ const COMMANDS = [
358383
args: "<id>",
359384
summary: "change an entry",
360385
booleans: ["billable"],
361-
values: ["project", "task", "note", "agent", "rate", "from", "to", "duration", "meta"],
386+
values: ["project", "task", "note", "agent", "agents", "rate", "from", "to", "duration", "meta"],
362387
multi: ["tag"],
363388
detail: ["Only the fields you name change. --tag replaces the whole tag list."],
364389
run({ positional, flags, file }) {
@@ -372,6 +397,7 @@ const COMMANDS = [
372397
if (flags.task != null) e.task = flags.task;
373398
if (flags.note != null) e.notes = flags.note;
374399
if (flags.agent != null) e.agent = flags.agent || null;
400+
if (flags.agents != null) e.agents = agentCount(flags);
375401
if (flags.rate != null) e.rate = Number(flags.rate);
376402
if ("billable" in flags) e.billable = Boolean(flags.billable);
377403
if (flags.tag) e.tags = [...new Set(flags.tag)];
@@ -459,6 +485,7 @@ const COMMANDS = [
459485
start: at,
460486
notes: "",
461487
agent: source.agent,
488+
agents: source.agents ?? 1,
462489
rate: source.rate,
463490
billable: source.billable,
464491
meta: source.meta,

src/entries.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export function makeEntry({
2424
end = null,
2525
notes = "",
2626
agent = null,
27+
agents = 1,
2728
rate = null,
2829
billable = true,
2930
meta = {},
@@ -38,6 +39,11 @@ export function makeEntry({
3839
end,
3940
notes: String(notes || ""),
4041
agent: agent ? String(agent) : null,
42+
// How many engines were working during this entry. An hour of agentic work
43+
// is an hour times however many agents ran in it, and a rate priced per
44+
// agent needs that number per entry - averaging it across a day bills a
45+
// two-agent afternoon at the four-agent rate.
46+
agents: Math.max(1, Math.round(Number(agents) || 1)),
4147
rate: rate == null ? null : Number(rate),
4248
billable: Boolean(billable),
4349
meta: meta && typeof meta === "object" ? meta : {},

0 commit comments

Comments
 (0)