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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user