feat(ledger): Gate F, the ledger's T3 thread source (#1506)

packages/ledger/src/t3.mjs reads ~/.t3/userdata/state.sqlite read-only,
in one transaction. It maps each thread to a seat by title and checks
self-addressed headers. Unmatched threads go in a t3:unmapped row. A
missing or locked database exits 1 and names --no-t3. Gate F is on by
default (lead decision 12). The 6a uppercase-class fix rides here.

Separate item: the Pi session reader splits lines only on \n, so a raw
U+2028 or U+2029 in a string no longer splits a record. Node 26.8.1's
readline split there, and the live ledger refused on HEAD.

Darkwing built to brief R3 (f3c05c1b); manifest ba73a163. Filbert
approved the build (e47ec6da) and the U+2028 fix as its own item; brief
review be1aa414. On an index export: the eight suites
24/90/43/17/14/15/63/18, ledger 47/47. Four nonblocking notes go to a
small follow-up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 16:39:56 -05:00
co-authored by Claude Opus 5.5
parent e58783d278
commit 136958c98b
11 changed files with 1764 additions and 31 deletions
+77 -5
View File
@@ -1,13 +1,16 @@
# Ledger
Read-only counts from local `refactor` commit subjects, one Gitea issue-list
request through `scripts/gitea-api.sh`, and repo seats' Pi session logs.
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/
```
@@ -36,12 +39,17 @@ No install, build, service restart, or configuration change is needed.
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 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=<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.
@@ -53,6 +61,69 @@ No install, build, service restart, or configuration change is needed.
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 <path>` 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 `<s>` when `<s>` is a real
directory in `agents/` and the lower-cased title equals `<s>` or starts
with `<s>` 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,
@@ -62,8 +133,8 @@ 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
or session evidence. Malformed JSONL, including a partially written last line,
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
@@ -72,8 +143,9 @@ 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, and fake API tools, with no real
credentials or network. The helper regression stubs Node before any credential
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