# Ledger Read-only counts from local `refactor` commit subjects, one Gitea issue-list request through `scripts/gitea-api.sh`, repo seats' Pi session logs, and T3's thread messages in `~/.t3/userdata/state.sqlite`. No board changes, data-root writes, fleet reads, transcript output, or scheduler. ```sh node packages/ledger/src/cli.mjs --since 2026-09-06 --until 2026-09-12 node packages/ledger/src/cli.mjs --since 2026-09-06 --until 2026-09-12 --json node packages/ledger/src/cli.mjs --since 2026-09-06 --no-issues node packages/ledger/src/cli.mjs --since 2026-09-06 --no-t3 node packages/ledger/src/cli.mjs --since 2026-09-06 --t3-db /tmp/fixture.sqlite node --test packages/ledger/tests/ ``` Dates include both endpoints in UTC. Omitted `--until` means today in UTC. The checkout containing this package supplies the sources, not the shell's cwd. No install, build, service restart, or configuration change is needed. ## Counting rules - Git uses committer timestamps and every commit reachable from local `refactor`, including merges. It reads subjects only. A repeated `#N` in one subject counts once. A commit naming two issues counts for both rows but once in total commits. Untagged commits do not count. Issue numbers are literal references to `mosaicstack/stack`, with no attempt to remap archived repositories' numbers. This can associate historical references with unrelated same-number issues. - Table 1 includes issues with a tagged commit or a `closed_at` in range. Opened is `created_at`; hours open is `closed_at - created_at`, rounded to one decimal, or `open` if not closed. It is not age as of `--until`. Reopen history is unavailable from the issue-list response. Median hours open uses only issues closed in range and rounds after computing the median. - Follow-ups are `max(commits in range - 1, 0)` per issue, not a lifetime count and not a quality assessment. Follow-ups per issue divides their sum by all Table 1 rows, including close-only rows. - Table 2 counts user-message entries in `.pi/state//sessions/*.jsonl` where `` is a real directory in `agents/`. All matching files count; duplicated entries in copied logs are not deduplicated. No transcript content leaves the parser. Assistant messages and logs outside repo seats do not count. Symlink source directories are refused and symlink files are not followed. A line ends at `\n` only. A U+2028 or U+2029 inside a JSON string does not split a record. - Table 2 also counts T3 thread messages with role `user`. The T3 source follows. A seat's row sums its Pi and T3 counts; the JSON keeps the split in `pi` (Pi rows) and `t3.seats` (T3 rows). - The first text line alone classifies a message. Two addressing forms count: the tmux preamble `[host:session -> host:session]` that `agent-send.sh` writes, and the T3 header `[from: role (thread-id) -> to: role (thread-id)]` from `docs/guides/T3-AGENT-COMMS.md`. Either may carry ` class=` before the closing bracket. A preamble whose sender is `control-board` (tmux session or T3 role) is board; any other valid preamble is agent; otherwise human. The class may be in either case: seats send `class=DECISION`. This is a format count, not proof of who typed the message. Text blocks are joined with newlines. The entry timestamp is used, falling back to the message timestamp. - Seats with no in-range user messages are omitted. Issue seats come from `#N` mentions anywhere in in-range user text, including quoted text. - Human messages per closed issue divides Table 2's human sum by issues closed in range. A zero denominator with human messages is `unknown`; a truly empty report has zero totals. JSON keeps numeric values as numbers; text displays ratios and durations with one decimal. Titles truncate to 48 characters in text only. Missing evidence is the literal string `unknown`. ## T3 source The rules come from `docs/plans/2026-09-26_ledger-t3-source.md` (Gate F). The source is on by default. `--no-t3` skips it, and the report then says `T3: not read (--no-t3)`. `--t3-db ` reads another database file with the same checks. The JSON records the database path and whether it was the default. When it wasn't, the text report prints the path, so a fixture result can't pass for a live one. The two flags can't be combined. The reader opens `state.sqlite` read-only through `node:sqlite` and reads no other file in `~/.t3`. It runs every query in one read transaction with a 5 s busy timeout. It never writes the main database file. Like any SQLite connection it may create `-wal` and `-shm` beside it, so a directory that isn't writable refuses when SQLite needs them. - **Project.** Only threads in the one non-deleted T3 project whose `workspace_root` equals this checkout's root byte for byte. The root is the realpath of the package, so a project opened through the compatibility symlink `~/src/mosaic-stack-dev-test` does not match, and the report refuses with no project. - **Thread to seat.** A thread belongs to seat `` when `` is a real directory in `agents/` and the lower-cased title equals `` or starts with `` and a space. "Dewey in Claude" maps to `dewey`; "Sagebrush" maps to nothing. Several threads can map to one seat. Threads that map to no seat share one row, `t3:unmapped`, so their human messages still reach the totals. `t3.seats` and `t3.unmapped` in the JSON list the thread ids and titles behind each row. - **Titles are current state.** T3 titles an unnamed thread from its first prompt, and a rename moves a thread's whole history to another row. This moves counts between rows, never out of the totals. - **Header check.** A user message whose T3 header is addressed to its own thread id must name that thread's seat as the `to:` role (compared lower case). In an unmapped thread the `to:` role must not be a seat. A conflict exits 1 and names the thread, its title and both roles. A header addressed to another thread isn't checked. The check misses a renamed thread that no agent writes to. Such a thread can only add human counts to a row. - **Excluded.** Imported threads (id prefix `import:`) are partial copies of Claude Code sessions, not T3 traffic; every T3 event marked `historyImport` sits in one today. Deleted threads don't count; archived threads do. `t3.excluded` gives both thread counts. - **Blind spot.** Threads in other T3 projects are not counted, even if they worked on this repository. Live, there is a project at `/home/jwoltje` and a deleted one at `/mnt/storage/src`. - **Diagnostic.** `t3.diagnostic.humanSentThroughApi` counts in-range user messages the header rule calls human that T3 recorded as sent through its API (no `appVersion` in the event's origin). Those are seat messages whose header the rule doesn't accept, such as the older free-text Discord Bot headers, and would show the next format drift. `humanWithoutEvent` counts human messages with no `thread.message-sent` event. This is T3's internal metadata, so it feeds no table or total. If `orchestration_events` or a column it needs is missing, or an event doesn't parse, both read `unknown`. These refuse the report with exit 1, and the ones about the database name `--no-t3`: a missing, unreadable or unopenable database (including a busy lock past the timeout); a symlink at `~/.t3`, `~/.t3/userdata` or `state.sqlite` (with `--t3-db`, the file or its directory); a missing table or column the counts need; no project or more than one for this root; a message in a counted thread with a role other than `user` or `assistant`, non-text content, or a `created_at` that doesn't parse; a header conflict. A missing Pi directory means no Pi seats ran here; a missing T3 database means the path or T3 changed, so it refuses instead of counting zero. Error messages name ids and paths, never message text. ## One Gitea call and missing evidence The client requests issues updated since the start date, all states, first page, limit 50. This includes issues closed in range, even if later updated. Gitea caps responses at 50; a full page fails rather than silently reporting partial totals. Use a narrower range or `--no-issues`, not hidden pagination. A commit-linked issue not returned by the updated-since query still has a row, with unknown metadata. This is the cost of the brief's one-call boundary. Exit 0 means a report was computed. Exit 1 means bad arguments or unreadable git, session or T3 evidence. Malformed JSONL, including a partially written last line, refuses the report; rerun after the seat finishes writing. Exit 2 means issue credentials, API, payload, or completeness failure. The CLI never prints API error bodies or reads authentication files itself. `--no-issues` makes no API call, keeps commit-derived rows, and shows unknown issue metadata, closed counts, median duration, and human-per-closed ratio. It cannot invent close-only rows. For fixtures, a fake `gitea-api.sh` can be placed first on PATH. Otherwise the repository scripts directory is appended to PATH for the issue request. Tests use only temporary repositories, logs, T3 databases and fake API tools, with no real credentials or network. Every CLI run in the tests sets `HOME` to a temporary directory, so no test opens the real `~/.t3`. The helper regression stubs Node before any credential read and checks successful GET, successful POST, and failed HTTP status. ## Acceptance Gate D is Jason's: run the requested week, choose a number to move next week, and write the sentence and number into `docs/plans/CURRENT.md`. Automated tests and publication do not pass that gate. Remove the package to stop using it; there is no persistent ledger state to migrate or restore.