Skip to content

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.

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.

Terminal window
bob labor report # every project with a transcript in the last 7 days
bob labor report --project acme-site --since 2026-10-01
bob labor report --project bigbrain --since 2026-09-01 --until 2026-10-01 --json
bob 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.

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.

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.

{
"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.

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.

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.

Terminal window
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 unmapped
acme — 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.

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 UNRECORDED
acme-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.

Terminal window
bob labor report --fleet --project acme-site # per machine and the total, from the snapshots
bob labor report --fleet # every project with a labor record on any snapshot
bob labor report --fleet --project acme-site --json # bob-labor-fleet/1

The 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-site
MACHINE STATUS AGE HOURS COGS UNRECORDED
macbook-pro-16 fresh 47 min 6.21 $12.40 0
farm-01 stale 3 d 14.80 $25.72 ub 3
macbook-air never - ? ? ? no snapshot
TOTAL (partial) 21.01* $38.12 ub 3
partial: farm-01 stale (3 d); macbook-air never published
fix: 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.

  • 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 --since run; 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.
  • 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.json lives and how it is resolved.
  • bob CLI reference — the bob labor report row.
  • Orchestrator — the manifest’s _meta fields, _meta.billing among them.