Work attribution
Work attribution — the agent labor meter
Section titled “Work attribution — the agent labor meter”
bob labor report: how many hours the agent was active on each project, from the transcripts already on this machine (#4420, the first slice of cap #3135). (Back to reference index.)
Every Claude Code session writes a transcript under ~/.claude/projects/<slug>/,
one JSONL line per event — a prompt, an assistant block, a tool result, a hook
summary — each with a timestamp. The meter reads those lines and nothing else.
It never reads ~/.claude/token-usage.jsonl (a session’s record there is written
at session end only, so a live or crashed session has none) and it never
estimates: an hour it reports is an hour a transcript shows events for.
The meter is report only. Nothing is filed, priced or billed by it; the
later slices of #3135 group, price and export what it measures. It is
forward-only by decision on #3135: it is the clock for the sessions from
now on. It accepts --since over any transcript that still exists, which is how
a historical number is produced once as a one-off, never as a deliverable.
The three rules
Section titled “The three rules”Gap rule. Consecutive transcript events closer than G form one active
span. A gap longer than G is idle: a human wait, a question wait, an
awaiting-continue. G is gap_minutes in the operator repo’s
config/billing.json, else the product default of 10 minutes, and --gap
overrides both for one run. The report always states which it used
(gap_source.source is billing.json, default or flag).
Tool rule. The interval from a tool_use block to its own tool_result is
never idle, whatever its length. A 25-minute test run leaves no transcript
events while it runs, but the tool was running; the pair is one interval and
the hours include it. G only ever decides a gap no tool spans.
Union rule. The spans of every session on one project are unioned before
hours are summed, so a project never accrues more than one hour per wall-clock
hour. “Every session” is every transcript that resolves to the project:
parallel terminals, bob fork worktrees (a worktree’s transcript directory
resolves to the checkout’s project through scripts/lib/project-name.js, never
to its folder), and Task subagents.
A subagent transcript (<session>/subagents/agent-*.jsonl) is folded into
its parent session before the gap rule runs: its events and tool intervals join
the parent’s. The session count does not rise and the session’s hours are the
union of both. A subagent’s whole run already sits inside the parent’s Task
tool interval, so the fold changes the count, not the hours.
Reading the clock
Section titled “Reading the clock”bob labor report # every project with a transcript in the last 7 daysbob labor report --project acme-site --since 2026-10-01bob labor report --project bigbrain --since 2026-09-01 --until 2026-10-01 --jsonbob labor report --gap 20 # a wider idle threshold, named as "flag"bob labor report --projects-dir ~/archive/claude-projects --since 2026-06-01 # an archived tree, once| Flag | Meaning |
|---|---|
--project <name|path> |
one project: its name (what project-name.js resolves a checkout to), a transcript-directory slug, or a checkout path. Reported with zero hours when no transcript falls in the window. |
--since <iso> |
start of the window; a bare date is its local midnight. Default: the local midnight six days before --until. |
--until <iso> |
end of the window. Default: now. |
--gap <minutes> |
G for this run, over billing.json and the default. |
--projects-dir <dir> |
read this directory instead of ~/.claude/projects. |
--json |
one bob-labor/1 document. |
Without --project, every project directory that has a transcript with an
event in the window is reported, one block each; a project with no events in
the window is left out of that list (name it with --project to see its zero).
Exit codes: 0 reported (zero hours is a report, not a failure); 2 usage —
a bad flag or date, a --gap that is not a positive number, or an unparsable
config/billing.json, named by path. The gap is never silently defaulted over
a billing file that cannot be read.
Text output
Section titled “Text output”bob labor report — 2026-10-06T07:00:00.000Z → 2026-10-12T22:14:03.511Z gap: 10 min, the default; transcripts: /Users/paulirving/.claude/projects
bigbrain — 8.64 h, 5 session(s), 17 span(s) 2026-10-12 8.64 h 17 span(s) 5 session(s)
acme-site — 6.43 h, 6 session(s), 9 span(s) 2026-10-12 6.43 h 9 span(s) 6 session(s)One line per project per day: the unioned hours, the number of unioned spans that touch the day, and the number of sessions that fed them. Days are local (the machine’s zone); a span that crosses midnight is split at it.
billing.json — the operator’s levers
Section titled “billing.json — the operator’s levers”config/billing.json in the operator repo is operator data, reached through
operatorPath('billing.json') like every other operator file (#2270). The meter
reads one key:
{ "gap_minutes": 10 }| State | Result |
|---|---|
| no file | the default G, gap_source.source: "default" |
file present, gap_minutes set |
that G, source: "billing.json", path names the file |
file present, no gap_minutes |
the default G, source: "default", note says the file declares none |
file is not JSON, or gap_minutes is not a positive number |
exit 2, the path and the problem on stderr (and in error with --json) |
The later slices add their own keys here (rates, the job map); the meter reads only the gap.
The bob-labor/1 document
Section titled “The bob-labor/1 document”{ "schema": "bob-labor/1", "generated_at": "2026-10-12T22:14:03.511Z", "projects_dir": "/Users/paulirving/.claude/projects", "window": { "since": "2026-10-06T07:00:00.000Z", "until": "2026-10-12T22:14:03.511Z" }, "gap_minutes": 10, "gap_source": { "source": "default", "path": null }, "project": null, "rules": { "gap": "…", "tool": "…", "union": "…" }, "counts": { "transcript_dirs": 4, "projects": 4, "sessions": 21 }, "projects": [ { "project": "bigbrain", "transcript_dirs": ["-Users-paulirving-projects-bigbrain"], "hours": 8.6424, "sessions": [ { "id": "bf67cb90-…", "file": "-Users-paulirving-projects-bigbrain/bf67cb90-….jsonl", "events": 1412, "subagents": 2, "hours": 3.1, "spans": [ { "start": "…", "end": "…", "hours": 1.2 } ] } ], "spans": [ { "start": "…", "end": "…", "hours": 1.9, "sessions": ["bf67cb90-…", "0b9c…"] } ], "days": [ { "date": "2026-10-12", "hours": 8.6424, "spans": 17, "sessions": 5 } ] } ], "error": null}| Field | Meaning |
|---|---|
window |
the clipped window; a span is cut at its edges |
gap_minutes, gap_source |
G and where it came from (billing.json / default / flag, with the path) |
project |
the --project selector as resolved, or null |
counts.transcript_dirs |
directories with a transcript written since the window opened |
projects[].transcript_dirs |
every slug that resolved to the project (a worktree’s beside the main checkout’s) |
projects[].sessions[] |
each session with events in the window: its own spans and hours before the union (these sum to more than hours when sessions overlap — that is the double count the union removes) |
projects[].spans[] |
the unioned spans, each naming the sessions that fed it |
projects[].days[] |
the unioned hours per local day, with the span and session counts |
error |
null, or the usage error with --json (exit 2) |
Hours are rounded to four decimals. A session with no events in the window is
not listed, and a project with none has sessions: [], spans: [],
days: [] and hours: 0.
What the test pins
Section titled “What the test pins”tests/test-labor.js (make test-labor) drives the real bob labor report
over tests/fixtures/transcripts/labor/ — transcripts captured from this
machine and reduced to the fields the meter reads (type, timestamp, ids, cwd,
and each content block’s type and tool ids), never written by hand (#1831):
| Fixture | Pins |
|---|---|
| two bigbrain sessions whose events overlap (2026-09-13) | the union: one span, the hours once, the per-session hours summing to more |
| a bigbrain session with an 18-minute pause between assistant turns | the gap rule: two spans at G = 10, one span at --gap 20, the pause excluded |
| a mediabob session with a 13.5-minute tool run | the tool rule: one span, the run inside the hours |
| a bigbrain session with a Task subagent transcript | the fold: the session count stays at one, the hours are the union |
| a window with no events | zero hours with --project, the project absent without it |
a scratch operator with and without config/billing.json |
the G source, and exit 2 on an unparsable file |
The test runs with TZ=UTC, a scratch HOME and a scratch BOB_OPERATOR, so
it never reads this machine’s transcripts or the real operator’s billing file.
The cost side: the token ledger
Section titled “The cost side: the token ledger”Hours are one side of the baseline; COGS is the other, and it is cited from
the token ledger (~/.claude/token-usage.jsonl, priced by
token-report.js’s rate table — token monitoring),
never recomputed here. The ledger only receives a session record when a
session ends normally, so a session killed with its tmux window, a crash or a
sleep costs something and is priced at nothing. bob labor reconcile (#4421)
is the completeness pass: every transcript on this machine with priced
responses and no session record is listed (report only, exit 1 when any
exists), and --apply appends one source: reconcile record for each,
measured the way every other writer measures, once per session id in both
directions. The fleet snapshot and bob tick run it unattended, and
bob status --scope project / bob doctor print the count of transcripts
still unrecorded. The rules, the record and the duplicate rule are in
Reconcile.
Job attribution: _meta.billing and --by job (#4423)
Section titled “Job attribution: _meta.billing and --by job (#4423)”Hours are measured per project, but invoices are per client job. The one
mapping is _meta.billing in the project’s manifest in the operator repo
(provisions/<project>.json, operator data):
{ "_meta": { "project": "acme-awards", "billing": { "job": "acme", "billable": true } } }{ "_meta": { "project": "bigbrain", "billing": { "job": "overhead", "billable": false } } }| Key | Type | Meaning |
|---|---|---|
job |
non-empty string, required | the client job name as the operator’s time system knows it — nothing in BoB knows what that system is |
billable |
boolean, required | whether the project’s hours bill to the job; false for overhead |
The manifest schema (schemas/manifest.schema.json) validates the block:
make check on the product manifests and the operator repo’s own CI
(scripts/checks/check-operator.js) refuse an unknown key under it, a missing
job or billable, an empty job or a non-boolean billable, naming the
manifest. Both keys are required on purpose: a mapping without billable would
be an hour billed by omission.
The overhead rule (#3134). BoB’s own projects — bigbrain, fleet, thefarm and
the operator repo — declare {"job": "overhead", "billable": false}: harness,
farm and devops self-work is the operator’s cost. Work in a BoB checkout that is
actually for a client (#2218, tenant zero) stays overhead by default; the
override is to run that session in the client’s own project directory, not a
flag.
bob labor report --by job
Section titled “bob labor report --by job”bob labor report --by job --since 2026-10-01 # one block per job, then unbilled (unmapped)bob labor report --by job --since 2026-10-01 --json # bob-labor/1 with by: job, jobs[] and unmappedacme — 12.40 h (billable), 2 project(s) acme-awards 9.10 h acme-site 3.30 h
overhead — 18.97 h (not billable), 2 project(s) bigbrain 18.90 h fleet 0.07 h
unbilled (unmapped) — 4.68 h, 1 project(s) with no _meta.billing (never billed, never dropped) mediabob 4.68 h no _meta.billing fix: add to ~/projects/bob-operator/provisions/mediabob.json: "_meta": { "billing": { "job": "<job>", "billable": true|false } }Every project with a transcript in the window appears exactly once: under its
job, or under unbilled (unmapped) with its hours, why it is unmapped (no
manifest, no _meta.billing, an unparsable file, a malformed block) and the
exact key to add to which manifest — never silently dropped, never silently
billed. Within a job the projects’ hours add (each project’s own hours are
already a union, the three rules); overhead is a job like
any other, sorted among them. --json carries by: "job",
jobs: [{job, billable, hours, projects: [{project, hours, billable, manifest}]}]
and unmapped: {hours, projects: [{project, hours, manifest, reason, fix}]}
beside the per-project blocks. --by project is the default; --by does not
apply beside --fleet (the roll-up reads snapshots).
bob provision status prints the project’s billing line beside its other
declared verdicts — billing: acme (billable), billing: overhead (not billable), or billing: unmapped with the reason and the key to add.
tests/test-labor-jobs.js (make test-labor-jobs) drives the real command
over the labor fixtures and a scratch operator with three manifests (a client
job, overhead, none) and the real schema checkers for the refusals.
The fleet roll-up: the snapshot, the view and --fleet (#4422)
Section titled “The fleet roll-up: the snapshot, the view and --fleet (#4422)”The hours that matter most for billing accrue on the farm while the operator
is away, and the meter and the ledger are per machine — so the baseline is only
right once every machine’s figures are rolled up. Nothing new travels for that:
the fleet snapshot every machine already publishes
(audit.js snapshot, cross-machine audit
and the snapshot timer) carries, for each present
project, two records read through the meter and the ledger — once per
snapshot, over the last 30 days (the reconcile’s window), never recomputed:
| Field | Shape | Read through |
|---|---|---|
labor |
{hours_30d, by_day: [{date, hours}], sessions, gap_minutes, unrecorded, since, until, source} |
bob labor report for the window — hours_30d is the project’s union over this machine, unrecorded is the #4421 reconcile’s post-pass count (the snapshot runs that pass first; null when it did not run) |
cogs |
{usd_30d, qualified, records, since, until, source} |
the token ledger’s session records in the window, priced by token-report.js’s rate table (the Costs page’s own pricing); qualified is false when any record carries no priced model and was priced as an upper bound |
summary.labor |
{window, gap_minutes, hours_30d, usd_30d, qualified, unrecorded, projects, meter_error, ledger_error, by_project, source} |
the machine’s totals and the by_project map ({hours_30d, usd_30d, qualified, unrecorded, reason} per project) the roll-ups below join across machines |
A read that fails — an unparsable config/billing.json (the meter refuses to
default G over it), an unreadable ledger — leaves that record null with the
reason beside it (labor_reason / cogs_reason), and the snapshot still
succeeds. BOB_FLEET_LABOR_PROBE=0 skips both reads: the records are null
with not probed (BOB_FLEET_LABOR_PROBE=0) and summary.labor is
{skipped: true, reason}, matching the other probe opt-outs
(BOB_FLEET_BACKUP_PROBE, BOB_FLEET_CI_PROBE, …).
Across machines hours add. Within one machine the union rule holds (a project never accrues more than one hour per wall-clock hour there); across machines the figures add, because two machines working the same project at the same time is two agents’ labor.
The view’s Labor table
Section titled “The view’s Labor table”audit.js view joins by_project across every machine row into one
Labor table — one row per project, one column per machine, then the total:
Labor (agent hours over the last 30 days per project and machine, from bob labor report on each machine; COGS = the token ledger at list price)PROJECT macbook-pro-16 farm-01 macbook-air TOTAL h COGS UNRECORDEDacme-site 6.21 14.80 ? 21.01* $38.12 ub 3? = not read on that machine (macbook-air: no snapshot); * = partial, a machine's cell is ? or its snapshot is stale or missing; ub = upper bound, a record carries no priced model. Hours add across machines. Details: bob labor report --fleet --project <name>.A machine with no snapshot, no labor read (an opt-out, an errored probe, a
pre-#4422 snapshot) or a failed read for that project is ?, never 0, with
its reason in the footnote; a total over a ? cell or a stale snapshot is
marked partial (*). A stale snapshot’s figures still count — they are the
newest that machine published — the total is simply labelled.
bob labor report --fleet
Section titled “bob labor report --fleet”bob labor report --fleet --project acme-site # per machine and the total, from the snapshotsbob labor report --fleet # every project with a labor record on any snapshotbob labor report --fleet --project acme-site --json # bob-labor-fleet/1The same roll-up from a shell, read from the snapshot directory alone
($BOB_FLEET_SNAPSHOTS, else <BOB_HOME>/fleet/snapshots) — nothing is
read live, so it costs no network and no transcript scan. Each machine
machines.json declares is one row, graded by its newest snapshot:
| Status | Meaning | Its figures |
|---|---|---|
fresh |
the newest snapshot is younger than 24 h (BOB_FLEET_STALE_SECONDS) |
count |
stale |
older than that | count, and the total is labelled partial |
never |
no snapshot here | ? in every cell, and the total is labelled partial |
acme-siteMACHINE STATUS AGE HOURS COGS UNRECORDEDmacbook-pro-16 fresh 47 min 6.21 $12.40 0farm-01 stale 3 d 14.80 $25.72 ub 3macbook-air never - ? ? ? no snapshotTOTAL (partial) 21.01* $38.12 ub 3 partial: farm-01 stale (3 d); macbook-air never publishedfix: audit.js snapshot --install-timer on each stale / never machine (#2067)The total is labelled partial whenever a machine is stale, never or carries no
labor read, so a roll-up never silently undercounts; the fix is the snapshot
timer on that machine. --json carries the same per machine
(machines[].status, age_seconds, hours_30d, usd_30d, qualified,
unrecorded, reason) and per project (by_machine, the totals, partial,
partial_reasons). The meter’s flags — --since, --until, --gap,
--projects-dir — do not apply beside --fleet (exit 2): each machine’s
window is its snapshot’s. A project on no snapshot is ? on every machine and
partial, exit 0.
tests/test-labor-fleet.js (make test-labor-fleet) drives the real
audit.js snapshot, audit.js view and bob labor report --fleet over
the labor fixtures (timestamps shifted into the window), a fixture ledger and
two planted fixture snapshots — one fresh, one three days old — beside a
machine with none, and pins the per-project records against
bob labor report’s own output for the same window, the cross-machine sum,
the ? cell, the stale and never markers and the partial label.
What the meter is not
Section titled “What the meter is not”- Not a token count. Tokens are the ledger’s (
token-snapshot.js, token monitoring); hours are wall-clock activity. The two are joined by the later slices, never here. - Not a per-person clock. It measures the agent’s activity in transcripts; a human reading, deciding or typing between events is the idle the gap rule excludes.
- Not a backfill. A historical window is one explicit
--sincerun; the meter does not keep a store, so there is nothing to backfill into. - Not a live cross-machine read. It reads this machine’s transcripts; the fleet roll-up (above) reads the snapshots each machine published, and says when one is stale or missing rather than undercounting.
Related
Section titled “Related”- Token monitoring — the token ledger the same transcripts feed.
- Fleet snapshot timer — what keeps every machine’s snapshot fresh, the fix for a stale or never machine in the roll-up.
- Operator — where
config/billing.jsonlives and how it is resolved. - bob CLI reference — the
bob labor reportrow. - Orchestrator — the manifest’s
_metafields,_meta.billingamong them.