Files
stack/agents/darkwing/work/ledger-t3-source/build.md
T
jason.woltjeandClaude Opus 5.5 136958c98b 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]>
2026-09-26 16:39:56 -05:00

8.4 KiB

Gate F build: the ledger's T3 source (#1506), candidate for review

Darkwing built this on 2026-09-26 from the approved brief R3, docs/plans/2026-09-26_ledger-t3-source.md (sha256 f3c05c1b, committed in ffc22c04). Sage gave the go once Filbert confirmed R3. Filbert reviews the code; Sage commits after the suites. Base is HEAD 1c5f6bc3. Nothing is committed or pushed.

Files

build-manifest.sha256 pins the five files, and build.patch is the diff against 1c5f6bc3 with t3.mjs included as a new file.

  • packages/ledger/src/t3.mjs (new). readT3(root, range, {dbPath, isDefault}): path checks, one read transaction, schema check, project, title mapping, header cross-check, counts, mentions, diagnostic.
  • packages/ledger/src/ledger.mjs. The class fix, t3Header(), readSeats(), mergeSources(), the pi and t3 keys in the report, the text line for --no-t3 or a non-default path, and the U+2028 fix below.
  • packages/ledger/src/cli.mjs. --no-t3 and --t3-db PATH, which refuse each other; the usage line.
  • packages/ledger/tests/ledger.test.mjs. HOME at both spawn sites, the empty default database, 25 new tests.
  • packages/ledger/README.md. A new "T3 source" section.

The commit should also carry Filbert's updated review, agents/filbert/work/ledger-t3-source-review-2026-09-26.md (be1aa414), and this directory's new files.

Beyond the brief: the Pi reader split valid lines

The brief's live read has to exit 0. It didn't, and T3 wasn't the cause. HEAD refuses the live checkout the same way: Malformed session JSON: filbert/2026-09-12T16-38-58-597Z_01a0967c-….jsonl:611. That line parses. It holds a raw U+2028 inside a JSON string, which JSON allows and JSON.stringify writes unescaped. Node 26.8.1's readline ends a line at U+2028 too, so it cut the record in two (733 lines by readline, 732 by \n). The reader parses every line before it checks the range, so on Node 26.8.1 every live run refuses, whatever the dates. The file was last written 2026-09-14. I haven't checked which Node version first split there.

The fix replaces readline with a small splitter that ends lines at \n only. It sits in ledger.mjs, which this build already changes, and it blocked acceptance, so I made it here instead of filing it. A new test writes a Pi log with a raw U+2028 and CRLF endings; it fails with readline and passes with the splitter. Please review it as its own item.

Choices the brief left open

  • Imported threads are excluded by the import: prefix alone. Live, all 1678 historyImport events sit in import: streams, so the two rules agree today. The events table stays optional, so the exclusion doesn't depend on it.
  • The header cross-check runs over every user message in a counted thread, in range or not. The title mapping is current state, so a conflict in old history still misassigns counts for any range that includes it.
  • Validation (role, text, created_at) also covers every message in a counted thread, assistant rows included, and not only rows in range.
  • The diagnostic is in range: humanSentThroughApi and humanWithoutEvent. One unparseable event, or an event with no string messageId, makes both unknown, the same as a missing table (F5).
  • Project and thread matching compare workspace_root in JavaScript, so a declared collation on the column can't loosen byte-for-byte equality.
  • JSON adds top-level pi (Pi rows) and t3 (read flag, database, seats with threads, unmapped, excluded, diagnostic). seats and totals keep their shape, so existing consumers and tests are unchanged. t3.seats lists a seat whenever it has a mapped thread, even with zero counts in range.
  • The unmapped row comes last in seats, and only when it has counts.

Evidence

  • Ledger tests: node --test packages/ledger/tests/, 47/47 (ledger 44, of which 25 are new, and gitea helper 3). The busy-timeout test takes about 5.4 s.
  • Class fix against HEAD. HEAD's messageKind (from git show 1c5f6bc3:packages/ledger/src/ledger.mjs) calls a T3 header with class=REVIEW-REQUEST, a tmux preamble with class=DECISION and a T3 header with class=Actionable all human. The build calls them agent. The existing test asserting class=Actionable is human now asserts agent.
  • Mutations, each on a scratch copy of the package. Three gitea-helper tests fail in every scratch copy because they need the repository's scripts/, so the counts below leave them out.
    • Classes back to [a-z-]+: 6 fail.
    • No header cross-check: 2 fail.
    • No symlink refusal: 4 fail.
    • Busy timeout 0: 1 fails.
    • Two projects allowed: 1 fails.
    • Deleted threads kept, imported threads kept, or range filter removed: 4 fail each.
    • No role check: 1 fails.
    • No diagnostic table check: 1 fails.
    • readline restored: 1 fails.
  • Two mutations pass, and I'm naming them rather than hiding them:
    • Removing mode=ro changes nothing, because readOnly: true already opens read-only. Both stay, as the brief says.
    • Removing BEGIN fails 19 tests, but only because COMMIT then has no transaction. No test proves that the queries share one snapshot.
  • Eight suites on a local clone of 1c5f6bc3 with the five files: config 24, task 90, foundation 43, conductor 17, release 14, auth 15, discord 63, extension-package 18. The first task run showed 89/1, and I didn't capture the failing line. Three more task runs passed 90/90. I count it as a flake I can't name, not as green on the first try.
  • Union (control-board, webui, seat, mosaic, ledger, discord) on the same clone: 434/434 three times, 23 to 24 s each. No fake pi left running.
  • No test opens the real ~/.t3. Every CLI spawn sets HOME to a temp directory, and no test calls readT3 in process. After the runs, no ledger-* temp directories remained.

Live read

node packages/ledger/src/cli.mjs --since 2026-09-01 --until 2026-09-26 --no-issues, exit 0 three times, no header conflict. The table is the run at 2026-09-26T21:31:02Z.

Seat T3 threads T3 board / agent / human Pi board / agent / human
darkwing Darkwing; Darkwing in Claude (archived) 0 / 19 / 28 14 / 109 / 141
dewey Dewey; Dewey in Claude 0 / 17 / 7 7 / 57 / 16
filbert Filbert 0 / 25 / 1 5 / 92 / 9
rocko Rocko 0 / 20 / 1 none
sage Sage 0 / 52 / 10 0 / 193 / 72
researcher none none 3 / 1 / 1
t3:unmapped Discord Bot 0 / 0 / 68 none

This matches the brief, allowing for messages sent since 20:54Z. It maps the same seven threads. Discord Bot has 68 human: 54 without a header and the 14 free-text headers. The diagnostic reads exactly those 14 (humanSentThroughApi: 14, humanWithoutEvent: 0). T3 agent messages total 133, against the brief's 96 API headers (80 plus the 16 uppercase ones) at 20:54Z. Two imported threads are excluded, and this project has no deleted threads.

Not covered

  • Snapshot isolation across the queries (see the BEGIN mutation above).
  • A seat directory named t3:unmapped would share the unmapped row. Directory names that contain a colon aren't used in agents/.
  • The live read's effect on the main database file can't be checked while T3 writes to it. The stopped and writer-attached WAL tests check it on fixtures.

Review and correction

Filbert approved manifest ba73a163 and the U+2028 fix as its own item: agents/filbert/work/ledger-t3-build-review-2026-09-26.md, sha256 e47ec6da.

Correction to "Beyond the brief" above. Line 611 holds a raw U+2028 and a raw U+2029, and readline ends a line at each. The file has 731 lines by \n (wc -l agrees), and readline makes 733. I wrote 732 because I counted the empty string after the final newline. The splitter already ends lines at \n only, so the fix covers both characters. The test and the README name only U+2028.

Filbert's nonblocking notes, for a follow-up after the Gate F commit, since changing the pinned files now would void the approval:

  1. Add a U+2029 to the splitter test and the README line.
  2. Two diagnostic mutations survive: humanWithoutEvent hardcoded to 0, and an unparseable event skipped instead of making the diagnostic unknown. Each needs one fixture message.
  3. readT3's catch reports any error that isn't a SourceError as a SQLite read failure. It still exits 1, but a bug would read as a database problem. Rethrow errors that carry no errcode.
  4. Snapshot isolation stays untested, as recorded above.