Compare commits

..
608 Commits
Author SHA1 Message Date
jason.woltjeandClaude Opus 5.5 319ee1332c chore(queue): row 13 round 1 approved by filbert, comment 26589 on #1508
Filbert posted the verdict as comment 26589 with the seat's own token and
recorded it (rev 23). The candidate fd72d268 passes verify-commit, and no
file under docs/plans/reviews/ was added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 11:38:32 -05:00
jason.woltjeandClaude Opus 5.5 ae2cfcb574 chore(queue): row 13 in-review, round 1 request posted as comment 26586 on #1508
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 11:36:23 -05:00
jason.woltjeandClaude Opus 5.5 a91df5ed78 docs(records): Piece E build note and follow-ups (row 13, #1508)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 11:35:21 -05:00
jason.woltjeandClaude Opus 5.5 0a6f58ddd7 chore(queue): row 7 brief re-pinned to the ledger README weekly routine after Piece E
Piece E (fd72d268) moved the weekly routine into packages/ledger/README.md.
Row 7 stays until Jason closes it (lead decision 40).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 11:35:07 -05:00
jason.woltjeandClaude Opus 5.5 fd72d26899 feat(ledger): Piece E, queue section in the weekly ledger (row 13, #1508)
The ledger prints a queue section above the weekly table. It checks four
things:
- open issues named by done rows;
- owner registrations for active rows;
- closed issues for done rows;
- the age of required rows.
The result is fail, incomplete or reduced pass. It uses its own Gitea
budget of the open list plus at most 10 lookups. A full open page counts
only while an issue in some row's closes has no known state (lead
decision 40). T3 seats are exempt per run with --unsupported-runtime.
The weekly routine is in packages/ledger/README.md.

Built by Darkwing (build.patch ab1f12ca, manifest 0b20bbca). Filbert
reviewed it: round 1 81f26f2e asked for changes (C1, ISO requiredSince
never aged); round 2 ce8ce150 approved. Also carries Filbert's plan
amendment for decision 40 (68a25ffe).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 11:33:44 -05:00
jason.woltjeandClaude Opus 5.5 2333d837e2 docs(records): lead decision 40, Piece E questions (row 13, #1508)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 11:04:28 -05:00
jason.woltjeandClaude Opus 5.5 f304eaa567 docs(records): row 12 live round and close, queue-commit message follow-up (#1508)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 10:14:35 -05:00
jason.woltjeandClaude Opus 5.5 ff61532aa8 chore(queue): row 12 done, Piece D live round approved (comment 26579, #1508)
Round 1 on f539466f: request comment 26577 by darkwing, approval
comment 26579 by filbert, recorded at rev 17. The rev 17 entry went into
f3f48cfd, whose message doesn't mention it. No file was added under
docs/plans/reviews/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 10:14:25 -05:00
jason.woltjeandClaude Opus 5.5 f3f48cfdc4 chore(queue): row 13 in-progress, Piece E started (#1508)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 10:12:52 -05:00
jason.woltjeandClaude Opus 5.5 3c9f4244af chore(queue): row 12 in-review, round 1 request posted as comment 26577 on #1508
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 10:10:54 -05:00
jason.woltjeandClaude Opus 5.5 32ddb2f210 chore(queue): row 12 reviewer filbert for the live round on #1508
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 10:09:35 -05:00
jason.woltjeandClaude Opus 5.5 0550ab21cf docs(records): Piece D build note, TOOLS.md review verbs, DEFERRED updates (#1508)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 10:07:51 -05:00
jason.woltjeandClaude Opus 5.5 f539466fcb feat(queue): Piece D, reviews as issue comments, raw per-seat token helper (row 12, #1508)
queue move ID in-review posts the review request as a Gitea comment and
review record reads verdicts back, so reviews stop being files in
docs/plans/reviews/. On a comment round, in-review to waiting-on-jason
now needs every listed reviewer's approval for the current round, the
same as in-review to done (Filbert r1 C1). scripts/gitea-api.sh reads
the raw per-seat token files (lead decisions 37 to 39): config built and
checked before curl starts, export attribute cleared, fixed base URL.
test-queue.sh skips its live checks outside the canonical root.

Darkwing authored. Filbert approved D r2 (cf1d3fd0) after r1 (a2dc2302)
and corrected the plan (293747cd). Rocko reviewed the helper (e896192f,
2096b0a3), and Sage's lead check passed under decision 38. Manifest
b402fb38, 19 files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 10:07:29 -05:00
jason.woltjeandClaude Opus 5.5 cdcedb2741 docs(records): lead decision 39, helper lead check passed, C1 in D (row 12)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 21:45:23 -05:00
jason.woltjeandClaude Opus 5.5 4c53f8c738 docs(records): lead decision 38, Gitea helper round 2 disposition (row 12)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 21:39:10 -05:00
jason.woltjeandClaude Opus 5.5 8efc0ff330 chore(queue): row 5 note, CHAT-03 source author Dewey (lead decision 36)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 20:25:24 -05:00
jason.woltjeandClaude Opus 5.5 d2813a4b6b docs(records): lead decision 37, raw per-seat token files in the Gitea helper (row 12)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 20:25:02 -05:00
jason.woltjeandClaude Opus 5.5 8ffbd73b76 docs(records): lead decision 36, row 10 build note, export recipe limit (#1508)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 20:24:24 -05:00
jason.woltjeandClaude Opus 5.5 eae341d3ca chore(queue): row 10 to Jason's gate, row 16 check, header points at the goals review (#1508)
Includes Darkwing's revs 3 to 8 (row 9 to waiting-on-jason, row 12 started).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 20:23:47 -05:00
jason.woltjeandClaude Opus 5.5 5efe28ab01 docs(agents): seats read the queue, not CURRENT.md (row 10, #1508)
AGENTS.md cadence, pointer and recovery rule name `scripts/mosaic queue
next <seat>` and the ratified goal order. The six seat CONTEXT files run
the queue instead of reading CURRENT.md for ownership and gates. The
queue README says why queue-commit.sh calls cli.mjs directly (Filbert
A2 r1 n3).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 20:23:20 -05:00
jason.woltjeandClaude Opus 5.5 9e23705724 chore(queue): row 9 to waiting-on-jason for Gate G, review round 1 on A2 6ca116b7 (#1508)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 20:19:07 -05:00
jason.woltjeandClaude Opus 5.5 c8236f7b78 docs(records): queue genesis build note and session line (#1508)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 20:17:18 -05:00
jason.woltjeandClaude Opus 5.5 42f3f2d94f chore(queue): assign row 10 to sage, row 13 gate after rows 9 to 12 (lead decision 35, #1508)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 20:17:06 -05:00
jason.woltjeandClaude Opus 5.5 3377b877ba chore(queue): genesis from reviewed map (#1508)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 20:16:10 -05:00
jason.woltjeandClaude Opus 5.5 6ca116b7ba feat(queue): queue as data A2, migration, render and dispatch (#1508)
Filbert approved round 1 (f167b85e). Manifest 782bcb62, 21 files, plus
the QUEUE.md markers and the TOOLS.md section. Lead decision 35.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 20:14:09 -05:00
jason.woltjeandClaude Opus 5.5 c9539baa0f docs(records): CHAT-03 build note for unexplained aborted, lead decision 34
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 19:45:26 -05:00
jason.woltjeandClaude Opus 5.5 8a465e891a docs(chat-03): brief pinned at 1ef15ac0 after r3 and scope check, lead decision 33 (#1507)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 19:45:04 -05:00
jason.woltjeandClaude Opus 5.5 93ee5054f4 docs(plans): north star and goal order ratified by Jason, AGENTS.md pointer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 19:40:05 -05:00
jason.woltjeandClaude Opus 5.5 77680e0b22 docs(records): CHAT-03 r3 closed with two rulings, lead decision 32
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 19:39:00 -05:00
jason.woltjeandClaude Opus 5.5 8dba3ff797 docs(records): CHAT-03 seal loads no explicit extensions, lead decision 31
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 19:34:06 -05:00
jason.woltjeandClaude Opus 5.5 f0296e7763 docs(records): CHAT-03 rescoped against Gate E, lead decision 30
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 19:31:38 -05:00
jason.woltjeandClaude Opus 5.5 4bdd3fc3b0 docs(queue): close rows 1, 6, 23, 24, 25 and issues 1503, 1509, 1511, lead decision 29
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 19:28:13 -05:00
jason.woltjeandClaude Opus 5.5 a11e1153d7 docs(records): fix lead decision reference in DEFERRED SetSpark line
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 19:24:28 -05:00
jason.woltjeandClaude Opus 5.5 9da0d0895f docs(records): SetSpark approver fix deployed and surveyed, lead decision 28
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 19:24:20 -05:00
jason.woltjeandClaude Opus 5.5 4d999e2f61 docs(queue): row 5 CHAT-03 rescope hold, row 7 ledger owner and numbers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 19:22:49 -05:00
jason.woltjeandClaude Opus 5.5 f2b9e622da docs(plans): goals review with ledger evidence, lead decision 27
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 19:21:33 -05:00
jason.woltjeandClaude Opus 5.5 4bbacf63ae docs(queue): rows 9 and 11, queue A1 committed, A2 in progress
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 19:10:39 -05:00
jason.woltjeandClaude Opus 5.5 08bd3a4cc5 fix(tests): clear NODE_TEST_CONTEXT for nested node --test in two suites (#1508)
test-foundation.sh and test-discord.sh ran a nested node --test that would
exit 0 on failure under a parent runner. Both clear the variable now, and
each has a check that fails the suite if it comes back. Darkwing wrote it,
Filbert approved it (e464be6c). Nine suites green on an index export.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 19:10:31 -05:00
jason.woltjeandClaude Opus 5.5 34a72af912 feat(queue): queue as data A1, journal, lock, CLI and verify (#1508)
packages/queue, scripts/queue-commit.sh, scripts/git-hooks and
scripts/test-queue.sh, plus docs/plans/BRIEF-TEMPLATE.md. There is no
queue.json yet, so verify skips until the genesis commit after A2.

Darkwing built it, and Filbert reviewed R0 (6933b885, changes requested)
and r1 (e464be6c, approved). The 20 files match manifest 85a8a453. The
nine suites passed on an index export, including the new queue suite.
test-queue.sh joins the suite list in AGENTS.md. Lead decisions 20, 23
and 26.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 19:07:48 -05:00
jason.woltjeandClaude Opus 5.5 91df7d6b54 docs(records): CHAT-03 deviation V-1 accepted with limits, lead decision 25
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 18:58:22 -05:00
jason.woltjeandClaude Opus 5.5 f87cd6e201 docs(records): SetSpark approver fix landed in shared-signals cc74d92, lead decision 24
Packet, Rocko's R1 and R2 reviews, DEFERRED outcome and SESSIONS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 18:52:04 -05:00
jason.woltjeandClaude Opus 5.5 40a02d2bcb docs(records): queue A1 review rulings, lead decision 23
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 18:25:52 -05:00
jason.woltjeandClaude Opus 5.5 556772ceb2 docs(records): CHAT-03 chartered, SetSpark approver owner, lead decisions 20 to 22
Jason approved CHAT-03 and gave Sage the SetSpark approver owner choice.
Decision 20 records the queue A1/A2 split. Decision 21 makes the SetSpark
fix Mosaic's through a Sage subagent and keeps the sanctioned pending:
approver markers so the cutover migration still works. Decision 22 charters
CHAT-03 with Dewey writing the brief. QUEUE row 5, DEFERRED and SESSIONS
updated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 18:14:02 -05:00
jason.woltjeandClaude Opus 5.5 bc73482045 docs(records): CHAT-02 Console live check passed, WebUI restarted
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 18:07:27 -05:00
jason.woltjeandClaude Opus 5.5 c9e771cf59 feat(webui): CHAT-02 Console, read-only conversation view (#1507)
History opens a seat's conversation from the Waiting card, table row
and inspector. It pages the whole branch through the CHAT-02 board
routes, renders untrusted text inert, polls with the follow cursor, and
marks every switch (branch, newer, reconcile, gone). The WebUI proxy
passes only the two conversation routes' queries upstream.

Dewey authored it. Filbert asked for changes on r1 (24b046af) and
approved r2 (d06de6a7) in review 160dd68d. A relaunch shows 'newer',
not 'reconcile', a deviation from brief 2.3 item 6 that Filbert
accepted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 18:05:11 -05:00
jason.woltjeandClaude Opus 5.5 3a209eeafe fix(ledger): Gate F follow-up, Filbert's notes 1 to 3 (#1506)
Darkwing's follow-up to the T3 thread source: manifest 382f5bb0 pins
t3.mjs, ledger.test.mjs and README.md. Filbert approved it (review
6fd693b6). Ledger 51/51; the eight suites pass on the index.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:57:51 -05:00
jason.woltjeandClaude Opus 5.5 a4d38a3d93 docs(records): row 25 live check passed, SetSpark approver gap has no owner
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:42:17 -05:00
jason.woltjeandClaude Opus 5.5 a68dc1740a docs(records): CHAT-02 build log, Gate F commit, snapshot isolation gap
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:40:23 -05:00
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
jason.woltjeandClaude Opus 5.5 e58783d278 docs(records): CHAT-02 backend commit and board restart
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:37:07 -05:00
jason.woltjeandClaude Opus 5.5 a5beb6d97d feat(conversation): CHAT-02 read-only Pi history reader and two board routes (#1507)
packages/conversation is a library with no server: safe-fs, the Pi session
parser, CHAT-01 pages, pinned snapshots, cursors and follow. The control
board adds GET /api/conversations and /api/conversation behind the Host
and Origin guard. Both are read-only, their queries are validated, and
each refusal code maps to a status.

Dewey authored it (packet 0cf177b1, revision 2). Filbert reviewed the code:
R1 revise (branch ids moving on append, the assumed-link bridge merging
branches, one unreadable seat directory turning the catalogue into a 500),
then R2 approve (3b14d66c). Darkwing reviewed the routes: R1 approve
(07b10ad1), R2 approve (b9d92003). The package lands with the routes,
because serve.mjs imports the reader at load.

On an index export: the eight suites 24/90/43/17/14/15/63/18,
conversation and control-board 153/153, webui 9/9.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:36:20 -05:00
jason.woltjeandClaude Opus 5.5 c5db8c8819 docs(records): row 25 approver fix, second Discord restart, SetSpark approver and docker network gaps
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:31:11 -05:00
jason.woltjeandClaude Opus 5.5 20ea5a0b64 fix(discord): row 25 approvers are user names, never Discord ids in tool text (#1509)
Jason's live check after the 20:58Z restart posted no Approve button. The
Discord Sage wrote DEC-009's required_approvers as names; the SetSpark
service stores approvers as discord:<id> and accepted the names, and the
connector correctly refused the approval request ("bad approver id").

- binding.mjs derives setspark.approvers from the binding's users (name to
  id); a binding-set approvers key and duplicate names are refused. With
  setspark set, a user id or name change refuses the reload (pi's approvers
  are fixed at start).
- setspark.mjs: record_create/record_update map required_approvers names to
  discord:<id> and refuse unknown names, ids, duplicates and non-lists
  before any request, without echoing the value. hideIds turns mentions,
  discord: values and standalone 17-20 digit runs into the user's name or
  "unknown user" in every verb's text and refusal, including the service
  message and code before they are cut. The connector's approval request
  keeps the bare ids.
- tests: boundary test over nested, keyed, numeric, mention and cut ids;
  a local contract fixture from create through validateRequest, with the
  old name-stored shape still refused.

Rocko: R1 revise, R2 revise, R3 approve (81379830..., report da75219f...).
Suites on an index export: 24/90/43/17/14/15/63/18; Discord node tests 173/173.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:30:11 -05:00
jason.woltjeandClaude Opus 5.5 1c5f6bc3a0 docs(queue): queue-as-data plan round 6, Rocko approved (#1508)
Plan 282fabbb (Filbert) and the six adversarial rounds (Rocko, r6 80cde839).
Lead item 15: Gate F first, then A1 and A2 as separate reviewed commits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:05:16 -05:00
jason.woltjeandClaude Opus 5.5 ffc22c04c6 docs(ledger): Gate F T3 thread source brief R2, Filbert approved (#1506)
Brief e8300cb6 (Darkwing), review bb02d8d3 (Filbert, approve with three
nits), R1 record and R1-to-R2 diff. Lead rulings in item 14.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:04:56 -05:00
jason.woltjeandClaude Opus 5.5 34777c56bc docs(records): row 25 SetSpark fix, Discord restart receipt, Gate F rulings
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 15:58:59 -05:00
jason.woltjeandClaude Opus 5.5 6c06a6f358 fix(discord): row 25 SetSpark key reaches pi and the connector (#1509)
resolveToolRoots dropped tools.setspark, so the record verbs never reached
pi's --tools list and the connector never built its SetSpark client. The
resolved config now carries only baseUrl, keyFile, principal and timeoutMs,
the keys the extension's loadSetsparkConfig accepts; the extension restores
the response cap. The connector's client uses the validated binding object,
which keeps the cap. README: principal is required, timeoutMs is optional.

Test (failing first): binding -> resolveToolRoots -> JSON -> loadToolsConfig
gives the binding's config, and the verbs reach enabledToolNames. Eight
suites green on an index export. Rocko approved
(agents/rocko/work/row25-setspark-fix-review-2026-09-26.md, a28df89e).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 15:57:55 -05:00
jason.woltjeandClaude Opus 5.5 84d0965142 docs(deferred): 6b closes the leaked fake pi and concurrent hang entries; wedge-restart and startup-source follow-ups
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 15:51:05 -05:00
jason.woltjeandClaude Opus 5.5 3edb15eb96 fix(discord): engine tests stop in finally; a turn pi never started stops pi instead of guessing (#1509)
Every engine test stops its engine in finally, and commands() tolerates a
log that doesn't exist yet, so a failed assertion no longer leaks a fake pi
and hangs the six-package union. A turn that failed client-side stays at
the front of the queue and holds the next prompt. If pi has sent no
agent_start ABORT_GRACE_MS (30 s) after the failure, the engine marks
itself wedged, fails held prompts with engine-wedged, refuses new ones with
engine-down, and stops pi. The exit reaches onExit, the connector exits 1,
and the unit restarts it. Pi's events carry no prompt id, so R1's approach,
dropping the turn and sending on, let a late run answer the next prompt.
Rocko rejected R1 and approved R2.

Tests: engine 17/17 (R1 fails 4, HEAD fails 5). Union 408/408 and the eight
suites green on the committed index. Record:
agents/darkwing/work/discord-engine-busy/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 15:50:37 -05:00
jason.woltjeandClaude Opus 5.5 f55b94e866 docs(records): board guard live, restart receipt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 15:40:56 -05:00
jason.woltjeandClaude Opus 5.5 d1629d610d fix(board): refuse foreign Host and Origin on every control-board route (#1507)
After a DNS rebind, a web page could read /api/board and POST /api/reply,
which pastes into a live seat pane. foreignRequest() now runs first and
returns 403 for a non-loopback Host, a wrong port, userinfo or a path in
Host, or any Origin other than http://<Host>. A missing Origin still passes,
which covers the WebUI proxy. Dewey authored it; Rocko approved de9ff942
(review 5a12f08e) with one low wording finding, now fixed in the notes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 15:40:34 -05:00
jason.woltjeandClaude Opus 5.5 401cc850bb docs(records): correct walkthrough ruling times from the transcript, note 6b R1 verdict
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 15:36:03 -05:00
jason.woltjeandClaude Opus 5.5 993673865a docs(records): Jason's walkthrough rulings, Sage moved to SetSpark, CHAT-02 go
Jason ruled on seven open items (20:27Z-20:45Z): seat Gitea tokens read in
place, one Discord restart after 6b with row 25 live, row 8 limited to the
dev seats, DYOR in dyor-stack-v4 with Sage moved to SetSpark, skills/aws-*
excluded locally, no second WebUI return defect, and go on CHAT-02 only.

Sage persona files name SetSpark as its business work. Darkwing's SOUL drops
harness names that were wrong for T3. DEFERRED adds the slash-prefix paste
hazard and the board Host/Origin gap, and moves the ledger T3 item to Done.
Dewey's approved CHAT-02 brief (636b0fac) and Filbert's review are recorded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 15:35:15 -05:00
jason.woltjeandClaude Opus 5.5 ef0020ad85 fix(ledger): count the T3 header as agent, control-board sender as board (#1506)
messageKind knew only the tmux preamble, so a prompt opening with the T3
header [from: role (id) -> to: role (id)] counted as human in Table 2. The
first line now matches either form; anything short of the full header stays
human. Filbert approved R1 against the frozen hashes.

Proof: packages/ledger/tests/ledger.test.mjs, 22/22; against HEAD's
ledger.mjs it fails exactly the two new tests. No suite runs it. Eight
suites green on the staged tree.

The fix changes zero current counts: no Pi log under .pi/state contains a
T3 header, and the ledger does not read T3 transcripts. Gate F waits on a
T3 thread source.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 15:16:55 -05:00
jason.woltjeandClaude Opus 5.5 d41f81aafe feat(agents): Sage launch files, lead text across seat personas, lead decision record
- agents/sage/ launch files committed after Dewey's review (R1 revise, R2
  approve). The launcher test now covers sage with its zai/glm-5.3 high pin.
  The README states the lead role and its limits, and the seat reads but
  never writes the old DYOR records under ~/.mosaic.
- N6 (Dewey authored, Sage reviewed against pins): seat personas and the
  Rocko launcher name Sage as project lead and Darkwing as a collaborating
  engineering seat, per Jason's 2026-09-26 ruling.
- docs/plans/2026-09-26_lead-decisions.md: the push, no merge into next and
  its conditions, the board restart, queue-as-data rulings, Gate F waiting
  on a T3 source, and what stays with Jason.

Launcher tests 6/6 and 1/1, eight suites green. Not pushed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 15:11:10 -05:00
jason.woltjeandClaude Opus 5.5 42c08d5285 fix(webui): pending reply notice until the seat answers, relative Age (#1507)
Dewey's return-flow candidate on the #1512 R1 baseline. The inspector used to
show the previous answer while a seat worked on a reply, which looked like the
reply; it now shows a pending notice that clears on the new final answer.
Age shows a relative time beside the ISO time. Filbert approved R2, source
only; the patch reproduces the pinned hashes (app.js d1a51646,
return-flow.test.mjs a598c0d4). webui tests 9/9, all eight suites green.
Known limits are in agents/dewey/work/return-flow-age/NOTES.md. Not pushed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 15:00:59 -05:00
jason.woltjeandClaude Opus 5.5 0f5b7cb9be docs(records): Sage lead handover, rows 23-25 state, row 8 stub, BUILD-LOG rebuilt on HEAD
Records Jason's 2026-09-26 ruling: Sage leads the project, Darkwing is a
collaborating seat, development stays in T3, and the old ~/.mosaic fleet is
being retired. The lead role adds no push, merge or deploy authority.

- QUEUE rows 23-25 show their commits and pushes; row 8 links a parked stub
  brief listing the rulings Jason must make before fleet seats move.
- Shared records from Darkwing (rows 6, 16, 18, 22, #1511, #1512) and Dewey
  (row 5) that were waiting on one owner for the shared files.
- DEFERRED: T3 headers counted as human in the ledger (#1506), #1509 engine
  test leak and busy gap, #1512 re-run outcome.
- BUILD-LOG.md rebuilt as HEAD plus the uncommitted entries; the working copy
  had dropped the row 23-25 entries. Diff against HEAD is additions only.

All eight suites green. Not pushed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 14:59:24 -05:00
jason.woltjeandClaude Opus 5.5 af4203ca92 feat(board): session attention, Discord rows, task attribution and relaunch activity (rows 18, 22, #1511, #1512)
One cumulative control-board, webui and seat state. The four rows edit the
same files (scan.mjs, page.html, README.md, app.js), so they land together,
each on its own receipt:

- Row 18, Discord connector rows on the board (#1509): R3 approved by
  Darkwing and Dewey, Gitea comment 26257, manifest 254403b8. Jason
  accepted the visual test.
- Row 22, board attention status (#1503): Filbert approved R1, comment
  26248, manifest e40b58ec; restart receipt 26249.
- #1511, task attribution (row 6 code phase): R2 approved by Filbert and
  Dewey, manifest d4c96395. docs/TOOLS.md carries the approved --by usage
  line (tools-usage.patch 86bcba3c).
- #1512, relaunch activity (row 6 pilot): R1 approved by Darkwing and
  Dewey, candidate manifest 47769fad. All seven source files match it.

Row 16, internal development bootstrap (#1510): the seven files outside
shared records match Filbert's R1 pins, receipt 26204 (agents/researcher/*,
scripts/test-darkwing-launch.mjs, the bootstrap plan).

packages/webui/src/public/app.js is committed at its #1512 R1 pin ce7d79a4.
The working copy holds Dewey's unreviewed return-flow candidate on top of
that, and it stays uncommitted.

Also: the four row briefs and Darkwing's evidence records under
agents/darkwing/work, including the 2026-09-26 tree manifest and the #1512
re-run against 21e3e908. Serial acceptance command: 397/397, three runs.
The failures that only show when tests run concurrently are in #1509 engine
tests, and they reproduce on clean HEAD.

Suites on the exact staged tree: config 24, task 90, foundation 43,
conductor 17, release 14, auth 15, discord 63; package union 397/397
(serial); test-darkwing-launch 5/5.

Shared records (BUILD-LOG, QUEUE, CURRENT, DEFERRED, SESSIONS, AGENTS.md,
agents/README.md) follow in Sage's records commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 14:54:18 -05:00
jason.woltjeandClaude Opus 5.5 21e3e908b6 docs(comms): T3 threads as the temporary development channel beside tmux
Jason, 2026-09-26: while Mosaic Stack development runs in T3, development
agents message each other through T3 threads with the t3 MCP tools, until
Mosaic Stack has its own internal comms. ms-communications now picks the
transport by where the recipient runs, carries the T3 header and reply
rules, and counts a returned turnId as the delivery receipt.
docs/guides/T3-AGENT-COMMS.md is adapted to this repository: status,
thread lookup by agent-name title, replies with wait off, and record
locations; references to guides absent here are replaced.

Suites green before commit: config 24, task 90, foundation 43,
conductor 17, release 14, auth 15, discord 63. unslop-check clean on
both files. Reviewed by Sage (T3 thread 1ef1e4f8).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 14:33:24 -05:00
jason.woltjeandClaude Fable 5.1 43d7574d6a feat(discord): SetSpark record client for the Discord Sage, fixed verbs against setspark-api, connector-verified approvals (#1509)
Row 25, parts 2a and 2b, against the shared-signals contract a5425a2.

Model side: eight fixed verbs in the pi extension (record_list, record_get,
record_create, record_update, resolve_id, open_approval_request,
get_approval_request, create_document), each one HTTP call with arguments
checked before any request. Writes carry an idempotency key
<principal>:<message id>:<call index> and an audit context. The seat key is
read from a 0600 file on every call and never cached, printed or journaled.

Connector side: append-only approval ledger, Approve button and exact
"approve" reply resolved by the connector against the required approvers,
confirmation message posted as button evidence, bind and add_approval through
the service under connector keys, retry of unknown entries on start.

Evidence: node tests 162 pass, scripts/test-discord.sh 63/63. Review by
rev-code-02, round 1 approved (#1509 comment 26467, tree 7872d8c5).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-22 12:59:39 -05:00
jason.woltjeandClaude Fable 5.1 1949ed8d31 feat(discord): git verbs for the Discord Sage on the shared-signals root, seat identity through a package credential helper, vault record protocol (#1509)
Row 24. A writable root that is a git work tree may carry a git object in
the binding; the seat then has git_status, git_commit (explicit paths, seat
author, Requested-by trailer from the envelope requester, push at once per
D6), git_pull (ff-only) and git_push (one branch, never force), plus
reserve_id and per-write clone locks under protocol vault. Git children run
with no host config and one credential helper, bin/git-credential.mjs,
reading the 0600 seat token file named in the binding; the fleet helper
serves only the Gitea hosts. Suite 58/58, node 143. rev-code-02 APPROVED
round 1 (#1509 comment 26375, tree 82ab962f).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-18 07:52:35 -05:00
jason.woltjeandClaude Fable 5.1 1685deb423 feat(discord): writes on write-marked roots, web fetch and search, held prompts (#1509)
Row 23. write_file and edit_file for roots marked write: true under the
same fence as reads; web_fetch (https only, public addresses, pinned
connection, capped body) and web_search through SearXNG; extension
renamed to tools.mjs. Engine holds a prompt while pi is busy and sends
it as its own run, so a second message mid-turn no longer folds into
the first (live defect). fake-pi models the real follow-up folding.

Suite 52/52, node tests 129. rev-code-02 APPROVED round 3, comment
26362, tree dbd2ce9a. Records: QUEUE rows 23-24, CURRENT, BUILD-LOG
phase, SESSIONS, row 24 brief (git verbs, D5-D7 ruled).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-18 07:27:50 -05:00
jason.woltjeandClaude Opus 5 1ac812d3d5 feat(discord): read-only tools for the Discord Sage through a Mosaic pi extension confined to declared roots (#1509)
A binding may declare `tools` with named roots. pi starts with
--no-builtin-tools and the package's own extension, allowlisting
list_dir, read_file and search. src/tools.mjs holds the rules: names
not paths, per-segment lstat walk, one checked descriptor read that
refuses symlinks, swaps, FIFOs, hard links and oversize files, credential
shapes refusing the whole read, and a per-message call budget. The engine
settles on agent_end and records tool calls in the turn record.

Jason's rulings R1-R7 in the brief, section 7. rev-code-02 approved
round 2 (comment 26276) on tree 43f0329b after four round 1 fixes.
Suite 48/48, node tests 116. Not pushed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 19:52:21 -05:00
jason.woltjeandClaude Fable 5.1 c4fc8e7d7f docs(discord): brief read-only tools for the Discord Sage as row 21, waiting on D1–D3 (#1509)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-13 19:33:45 -05:00
jason.woltjeandClaude Fable 5.1 1267e6a0a0 docs(discord): rows 19–20 done, Carmen enrolled live by reload; records and receipt (#1509)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-13 19:03:53 -05:00
jason.woltjeandClaude Fable 5.1 caaef941e6 feat(discord): binding reload without a restart, and a per-user channel allowlist (#1509)
`reload` validates the binding file and sends SIGHUP to the live owner;
the running connector re-reads it and swaps guildName, channels, users
and limits in place. name, seat, guildId, botUserId, tokenFile, engine
and context are fixed for the life of the process; a change there, an
invalid file or a channel outside the guild refuses the reload and keeps
the old binding. Every attempt is one line in reloads.jsonl. The service
unit maps `systemctl --user reload` to the same signal.

A user entry may carry `channels`, an allowlist of listed channel ids;
absent means every listed channel. Outside the list the message is
dropped as channel-not-for-user; threads count as their parent.

Suite 41/41, 101 node tests. QUEUE rows 19 and 20 opened.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-13 18:59:31 -05:00
jason.woltjeandClaude Fable 5.1 d9745a4510 docs(discord): row 17 operator check passed, service unit verified by Jason (#1509)
Jason ran traffic under the unit, SIGKILL recovery, the brake, and the
release, and reported all verified. Receipt in the private evidence dir.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-13 16:29:19 -05:00
jason.woltjeandClaude Fable 5.1 3affbab5e2 docs(discord): row 18 assigned to darkwing by Jason's ruling (#1509)
The control board row for the Discord connector is board-side work in
packages/control-board. Jason ruled darkwing builds it under the board
brief; the coordinator answers connector-side questions only.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-13 16:20:15 -05:00
jason.woltjeandClaude Fable 5.1 90cb31f56f docs(discord): brief the control board row as iteration 3, blocked on ownership (#1509)
QUEUE row 18. The board discovers rows from pi session directories and
decides liveness by tmux; the connector's session and run.lock live
elsewhere and it has no pane since iteration 2. The brief lists what the
board-side change needs. Owner for Jason to rule.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-13 14:40:29 -05:00
jason.woltjeandClaude Fable 5.1 436ba6ed6b feat(discord): systemd user service with a supervised run; brakes exit 3 and are never retried (#1509)
QUEUE row 17, MVP iteration 2. scripts/discord-service.sh renders and
installs mosaic-discord@<binding> from packages/discord/systemd/. The
unit's main process is `run --supervised`, which applies the new recover
policy first: a lock whose owner is gone is cleared and only the STOP
written for that is removed; an operator STOP or a held binding refuses
with exit 3, which RestartPreventExitStatus never retries. `recover` is
also a CLI verb. First cut used ExecStartPre and looped live, since systemd
honours the never-retry status only from the main process; replaced and
re-verified before any message traffic. Suite 40/40, 95 node tests.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-13 14:39:11 -05:00
jason.woltjeandClaude Fable 5.1 dc5902aafd docs(discord): read receipt live check passed, QUEUE row 15 done (#1509)
Jason's message in #sage-admin got the eyes reaction before the reply; the
turn record carries receipt.ok true and a private evidence receipt was
written. CURRENT.md gains the pilot narrative line.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-13 14:22:34 -05:00
jason.woltjeandClaude Fable 5.1 93d6b62457 feat(discord): eyes reaction as a read receipt on every admitted message (#1509)
QUEUE row 15, MVP iteration 1 after the Sage pilot. rest.react is best
effort (2xx true, anything else false, never throws); the connector reacts
at admission before the engine runs and records the outcome in the turn
record as receipt. Drops and refusals get no reaction. Suite 28/28, 90
node tests.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-13 14:20:30 -05:00
jason.woltjeandClaude Fable 5.1 788515dc69 docs(discord): close the Sage pilot, Gate H passed; correct the Discord profile wording (#1509)
Live pilot receipts summarized in BUILD-LOG (eight steps, private
evidence under the seat's work directory). Jason ruled the replies read
as Sage; QUEUE row 14 done. DISCORD-USER.md now says unlisted senders
are dropped silently rather than refused with a reply, which is what
the connector does.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-13 13:39:25 -05:00
jason.woltjeandClaude Fable 5.1 786e379c49 feat(discord): connector pilot for the Sage seat, reviewed candidate (#1509)
Zero-dependency Discord connector under packages/discord: binding
validation, REST and gateway clients, pi engine adapter, journal with
append-only inbox, outbox, admissions and notices, and a run.lock
ownership record {pid, start, boot} whose identity is checked three ways
and whose cleanup is gated by STOP. CLI check|run|stop|unlock via
scripts/discord.sh; offline suite scripts/test-discord.sh (28 checks,
87 node tests).

Reviewed by rev-code-02 on #1509 over nine rounds; approved exact tree
4e0feb6758c0a7e4a71483912a8e0d3e3ec95aef at comment 26170. Corrections
(1) to (12) recorded in BUILD-LOG. No listener started, no token read,
no Discord write; the live pilot follows this commit per the brief.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-13 01:15:37 -05:00
darkwing b023841c8a docs: publish reviewed CHAT-01C private readback contracts (#1507) 2026-09-13 00:48:26 -05:00
jason.woltje 28d4e98ad8 docs: publish reviewed CHAT-01 draft contracts and fixtures (#1507) 2026-09-12 23:48:03 -05:00
jason.woltje 370823b354 docs: pin CHAT-00 protocol research and synthetic checks (#1507) 2026-09-12 20:43:35 -05:00
jason.woltje 4c436f4ce4 plan: confirm all-seat WebUI session chat and gated delivery (#1507) 2026-09-12 20:28:22 -05:00
jason.woltjeandClaude Fable 5.1 0e77cfd10a Plan page for #1508: queue as data, pieces A-E, Gate G; QUEUE rows 9-13 link to it
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 19:45:14 -05:00
jason.woltjeandClaude Fable 5.1 cbf24b01b0 QUEUE rows 9-13 (#1508): process becomes data, one writer, ledger checks; AGENTS.md cadence reads QUEUE.md first
Jason: the process goes off the rails every time because the entry point is
prose. Rows 9-13 are marked required and cannot be parked.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 19:41:56 -05:00
jason.woltje 11659cf2a1 records: hand Console to Jason for Gate E after publication (#1507) 2026-09-12 19:39:14 -05:00
jason.woltje ea00ec66d9 webui: serve the Console first screen from the control board (#1507) 2026-09-12 19:37:35 -05:00
jason.woltjeandClaude Fable 5.1 7c8e530add QUEUE.md: one task table; CURRENT and DEFERRED point at it
Jason could not find what is next without reading the prose plans. QUEUE.md
is one row per piece with owner, issue, state, gate and brief location.
CURRENT.md becomes the narrative log; DEFERRED.md keeps only gaps.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 19:33:59 -05:00
jason.woltjeandClaude Fable 5.1 355b4308c3 CURRENT points at the piece 5 brief; DEFERRED: shared-checkout hazard (#1503)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 19:26:19 -05:00
jason.woltjeandClaude Fable 5.1 f80404f541 brief for piece 5, darkwing on point, with Gate F (#1503)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 19:25:53 -05:00
jason.woltje fab40f25f8 records: close ledger and seat registration after owner acceptance (#1506, #1504) 2026-09-12 19:18:47 -05:00
jason.woltjeandClaude Fable 5.1 b7f8d1cc35 Gate D written; brief for piece 4, the WebUI first screen on the Console design (#1506, #1503)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 19:05:44 -05:00
jason.woltjeandClaude Fable 5.1 46ded3fcba record Jason's comms rule and working preferences in the repository (#1503)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 18:59:33 -05:00
jason.woltje cd0aa5fbb3 ledger: count issue-tagged commits and repo seat messages (#1506) 2026-09-12 12:04:15 -05:00
jason.woltjeandClaude Fable 5.1 db0d784bd8 plans: add DEFERRED.md, the one list of gaps found and not yet handled (#1503)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 11:53:24 -05:00
jason.woltjeandClaude Fable 5.1 889d87500f CURRENT: next action is piece 3, the ledger, per Jason's go (#1503)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 11:42:56 -05:00
jason.woltjeandClaude Fable 5.1 0334026e13 control board: brief for piece 3, the ledger, with Gate D (#1503)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 11:42:28 -05:00
jason.woltjeandClaude Fable 5.1 c90ce3a836 control board: Gate C passed, #1505 closed, next action waits for the ledger brief (#1503)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 11:39:23 -05:00
jason.woltjeandClaude Fable 5.1 4f83068097 control board: log Gate C pass for reply-from-board (#1505)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 11:38:55 -05:00
jason.woltjeandClaude Fable 5.1 867619dca2 control board: reply from the board through agent-send.sh (#1505)
Piece 2 of the MVP (#1503). A one-line reply box and Send in the detail
of rows with a live registration; POST /api/reply runs
tools/tmux/agent-send.sh -s <session> -S <host>:control-board
[-L <socket>] -m <text> once for one seat and returns the exit code,
stdout and stderr. The page shows delivered or failed with the tool's
stderr; other rows say "reply needs a registered seat". No send-keys,
queue, retries, history or broadcast; packages/seat and agent-send.sh
untouched. Every message ends with a fixed trailer telling the seat to
answer in its own session (Jason's refinement after the first Gate C
exchange; the board has no pane). Board suite 98/98.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 11:30:30 -05:00
jason.woltjeandClaude Fable 5.1 a62ca1904f control board: brief for piece 2, reply-from-board, with Gate C (#1503)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 11:17:28 -05:00
jason.woltjeandClaude Fable 5.1 0bed9ba3db control board: show the model each seat is running (#1503)
Jason asked for the running model (sonnet, opus, gpt-6-astra) on the
board. readSession keeps model and provider from the log's latest
model_change entry or assistant turn, whichever is later, so a /model
switch shows on the next scan; scanAgent exposes both; the page shows the
model under the agent name with the provider in the hover and a Model
detail row. Blank when the log names none.

Live check on a scratch board against the real data root: all 42 rows
carried a model. Board 91/91. Sonnet review caught a double-escaped hover
title; fixed.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 11:13:32 -05:00
jason.woltjeandClaude Fable 5.1 d0fb5b0f20 control board: log Gate B pass, seat registration shown on the board (#1504)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 11:13:05 -05:00
jason.woltjeandClaude Fable 5.1 17153fe140 control board: stale registrations and a launch-test leak (#1504)
Two defects in 69f99323, reported by the professor session and verified.

The darkwing launch test's flock-contention spawn ran without the fixture
config, so launch.sh re-entered scripts/mosaic against the real data root
and wrote fixture records for darkwing, dewey and filbert there. That
spawn now names the fixture config, and both launch test files set
MOSAIC_CONFIG to a nonexistent path and clear MOSAIC_LAUNCH_REGISTERED
process-wide, so a spawn that forgets fails instead of polluting.

A registration is written before the launch script's own checks, so a
refused launch left a record with a dead pid that the board honoured. The
scanner now probes the recorded pid (pidAlive, signal 0); a gone pid makes
the record stale: still on the Registered line with alive false, derived
task, project and workspace win, index gains registrationStale, CLI
summary gains a stale count.

Fleet launchers marked not planned per Jason. Board 90/90, seat 15/15,
launch scripts 5/5. Sonnet review APPROVED.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 11:04:49 -05:00
jason.woltjeandClaude Fable 5.1 69f99323c7 Add mosaic launch <seat> with seat registration for the control board (#1504)
New package packages/seat and wrapper scripts/mosaic. `launch <seat>` writes
<dataRoot>/seats/<layout>/<seat>/registration.json and then execs the seat's
launch.sh unchanged; `seat task <seat> <text>` edits the task only. The board
reads registrations, matches by sessions directory, and lets a registered
task, project or workspace override the derived value with a source tag.
The four repository launch scripts register themselves unless already
registered or run with --check. Fleet launchers untouched; one-liner on the
plan page.

Review found the record path keyed by seat name alone (repo and fleet
"darkwing" would collide); fixed by keying on layout. Also: the Pi pin
refusal now names installed and required versions.

Tests: seat 15, control-board 89, launch scripts 5, registry 69, config 24.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 10:56:17 -05:00
jason.woltjeandClaude Fable 5.1 01d9a19612 control board: record Jason's selection of Dewey design 3 (Console) for the WebUI (#1503)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 10:39:54 -05:00
jason.woltjeandClaude Fable 5.1 4a7e16c3ec Show task, active project and workspace per control board row (#1503)
Gate A fix asked by the professor session on Jason's behalf. Each row
now carries three derived fields, shown as "unknown" when the log and
tmux do not hold them:

- task: the session's first user message (pi logs have no task envelope)
- workspace: the live pane path of the pane running pi, else session cwd
- activeProject: basename of the nearest git checkout above the workspace

tmuxInspect replaces the bare liveness call in the CLI and returns
{ alive, workspace }; tmuxIsAlive stays as a wrapper. The grouping column
and seen.json keys are unchanged. Fixture test per field, tmux parse
tests, page test; missing launcher signals are recorded in the plan page.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 09:52:45 -05:00
jason.woltjeandClaude Fable 5.1 6ec253de20 Pin the mid-tool-call rule in the control board scanner (#1503)
Acceptance rule (plan page, bf641e22): a seat mid-tool-call is working,
never waiting. The scanner already met it through pi's stopReason values;
deriveState now also checks the content for a toolCall block (working),
after the error stop reasons and before "stop" (waiting). Thinking blocks
do not keep a text turn from being waiting. Three JSONL fixture tests and
three state-table cases pin the rule. Live check on the real board:
orch-01 and rev-code-01 mid-tool-call are working, velma's finished
text-only turn is waiting. Sonnet review: APPROVED.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 09:29:19 -05:00
jason.woltjeandClaude Fable 5.1 bf641e220a control board: park D05/registry-3/new seats and add the mid-tool-call working rule (#1503)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 09:25:32 -05:00
jason.woltjeandClaude Fable 5.1 bf1391a424 Show "shown of total" in control board project headers while rows are hidden (#1503)
A project header now reads "fleet (13 of 38)" while Hide offline or Hide
seen hides at least one row, and "fleet (38)" when nothing is hidden. The
note under the table still says which filter hid how many. Numbers only,
so nothing new needs escaping. Static test pins the expression and the
removal of the raw-length header. Sonnet review: APPROVED, no findings.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 09:00:21 -05:00
jason.woltjeandClaude Fable 5.1 e90d15dd70 Add a per-project "Hide seen" checkbox to the control board page (#1503)
Each project table now has "Hide seen" beside "Hide offline", both on by
default, with a note saying how many rows each one hides. The choice
survives the 10-second refresh. Static test pins the markup, the filter,
the persistence guard, and the change handler. Sonnet review: APPROVED.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 08:55:22 -05:00
jason.woltjeandClaude Fable 5.1 b70749219f Add a collapsed "Seen" section to the control board page (#1503)
Jason asked for a way to recall rows he marked Seen. The page now lists
them under a collapsed "Seen (N)" section between "Waiting on you" and
"By project", each with Unsee. Open/closed state survives the 10-second
refresh because only the section body is re-rendered. Page-only change
plus one static test. Tests: control-board 64/64, registry 69/69.
Review APPROVED, no findings.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 08:45:51 -05:00
jason.woltjeandClaude Fable 5.1 88d21defde Check pi liveness per tmux pane and add "Seen" marks to the control board (#1503)
First step-3 refinement from Jason's daily use. Liveness now lists the
panes of the agent's tmux session and counts it alive only if a pane runs
pi, so killed pi sessions whose tmux session still exists show offline
instead of waiting. A "Seen" button on waiting and error rows stores the
row's lastActivity in <dataRoot>/board/seen.json (clicks only, never
rewritten by a scan, fail closed if corrupt) and drops the row from
"Waiting on you" until the agent writes anything newer; "Unsee" reverses
it. New POST /api/seen route: JSON only, 4 KB limit, 400 on bad input.

Tests: control-board 63/63 (30 new), registry 69/69. Review APPROVED;
receipt docs/plans/reviews/2026-09-12_control-board-step3-seen-marks.md.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 08:24:49 -05:00
jason.woltjeandClaude Fable 5.1 ebedd1281e Add control board web page and local server (#1503)
Step 2 of the control board MVP (MOSAIC-STACK-D-001): `serve` command starts
a loopback-only local server that serves one self-contained page and re-runs
the status scanner on each /api/board request. The page lists sessions
waiting on Jason first (errors on top), then one table per project with
plain-word states, ages, last messages, expandable detail rows, per-project
hide-offline, and a 10-second auto-refresh with pause.

Tests: control-board 33/33 (10 new: loopback rules, host refusal, all routes,
per-request rescan, 500 path, CLI refusals, live serve, page escaping guard);
registry 69/69 unchanged. Receipt:
docs/plans/reviews/2026-09-12_control-board-step2-review.md.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 07:58:38 -05:00
jason.woltjeandClaude Fable 5.1 b9f59a5903 Add control board status scanner and MVP plan (#1503)
Step 1 of the control board MVP (decision MOSAIC-STACK-D-001): a plan page,
Gitea #1503, and packages/control-board, which reads each agent's newest pi
session log plus tmux liveness and writes one status file per agent under
<dataRoot>/board/. 23/23 tests; independent review approved after three
fixes (length stopReason as error, unknown liveness state, secrets-boundary
test). CURRENT.md now points at step 2, the page.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 07:26:03 -05:00
jason.woltjeandClaude Fable 5.1 1993039c76 Record owner acceptance and closure of #1500 fixture increment
Jason reran the two-test fixture demonstration on the canonical checkout
at 5abbabb7 (2/2 pass). Records-only: acceptance receipt, BUILD-LOG,
SESSIONS and CURRENT.md next action (MVP re-plan). No source change.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 06:47:24 -05:00
jason.woltje 5abbabb74b Record fixture increment review, publication and owner test gate (#1500) 2026-09-10 20:23:37 -05:00
jason.woltje 3daee5ad89 Add fixture-only execution materialization and refresh (#1500) 2026-09-10 20:20:44 -05:00
jason.woltje 27e4873acc Record reviewed prerequisite correction and separate next approval gate (#1500) 2026-09-10 16:41:36 -05:00
jason.woltje 6335342873 Correct registry validation and contain metadata reads (#1500) 2026-09-10 16:40:16 -05:00
jason.woltje b3fa221060 Record owner acceptance of increment 1 (#1499) 2026-09-10 16:17:22 -05:00
jason.woltje 5446097483 Record increment 1 approval after contamination fix (#1499) 2026-09-10 15:23:58 -05:00
jason.woltje e5a9a05ba2 Drop uncommitted sage agent contamination and dangling export (#1499 I1-F1/F2) 2026-09-10 15:23:02 -05:00
jason.woltje e6e30b6f55 Record increment 1 review request (#1499) 2026-09-10 15:18:27 -05:00
jason.woltje 557aba0f9e Add packages/mosaic registry schemas, validation, read-only CLI; pin pi 0.85.1 (#1499) 2026-09-10 15:18:15 -05:00
jason.woltje 54c1231280 Draft M20 increment 1 charter for owner approval (#1499) 2026-09-10 15:10:20 -05:00
jason.woltje 21461c8674 Register completed registry gate 7 closure 2026-09-10 15:08:58 -05:00
jason.woltje 86e009dde5 Close registry gate 7 with owner-approved refresh mechanism 2026-09-10 15:08:40 -05:00
jason.woltje 224147eccc Record owner rulings resolving registry review gates 2026-09-10 14:42:31 -05:00
jason.woltje d5307d2b0a Register completed registry alignment records 2026-09-10 14:28:07 -05:00
jason.woltje b8008eda5d Record owner account-selection rulings and registry draft revision 2026-09-10 14:27:47 -05:00
jason.woltje 50d2a2eba9 Record independent verification and completion of skill repair (#1498) 2026-09-08 18:13:46 -05:00
jason.woltje f3dce32088 Repair native launcher skill rename and fail-closed regression coverage (#1498) 2026-09-08 18:12:20 -05:00
jason.woltje 12ff5da7df Record owner acceptance and close publication recovery trial (#1497) 2026-09-08 17:57:29 -05:00
jason.woltje 10448e41a2 Record verified publication and owner acceptance handoff (#1497) 2026-09-08 17:20:12 -05:00
jason.woltje 29c1defe29 Publish reviewed development agents and WUI draft with recovery evidence (#1497) 2026-09-08 17:15:03 -05:00
jason.woltje 3b7fd19d08 docs: verify published wave and set registry review next 2026-09-08 12:44:13 -05:00
jason.woltje 69f10a4062 fix(tmux): explicit transport-only dispatch and safe remote quoting (#1496) 2026-09-08 12:41:25 -05:00
jason.woltje 67eaf6fb47 docs(logs): pending session/build records through 2026-09-07
Append-only BUILD-LOG phases, SESSIONS registrations, and CURRENT
checkpoint state accumulated through the consolidation and inspector
review waves.
2026-09-07 14:07:16 -05:00
jason.woltje 3ea385223e feat(skills): six new ms-* skills
ms-archify (evidence-based architectural mapping), ms-sdlc,
ms-proactive-agent, ms-goal, ms-grill-me, ms-frontend-design.
2026-09-07 14:07:16 -05:00
jason.woltje 193479b52d docs: concept annexation, provider/reference docs, ACT-1 groundwork
Mosaic concepts pages now own the adapted content; source/license
metadata under docs/reference/concepts. Adds ACT-1 agent-context
planning capture, pinned concept test package + preparation utility,
foundation observation notes (durability, evidence, federation,
onboarding, workflow), and the #1495 consolidation assessment.
TOOLS.md updated for the host-dev launcher.
2026-09-07 14:07:05 -05:00
jason.woltje 7c580a5625 feat(agents): darkwing host-dev launcher + entry-point consolidation
scripts/agent.sh --host-dev delegates to scripts/agent-host-dev.sh;
agents/darkwing/launch.sh provides the native development TUI
(context, skills, coding tools, /goal). Root SOUL.md is the M14-era
default-collaborator persona captured by the ACT-1 context work.
Launcher regression checks pass (test-darkwing-launch.mjs).
2026-09-07 14:06:54 -05:00
jason.woltje 8ebddd6f93 feat(foundation): offline synthetic scope/permission inspector (FI-FILBERT-8 APPROVED r6)
Rocko-authored, Filbert-reviewed inspector (r6 manifest
a4a44930...) with full review/build/verdict evidence under
docs/plans/reviews. 43/0 selftests, oracle zero-disagreement,
foundation checker PASS. Owner A9 acceptance recorded separately.
2026-09-07 14:06:35 -05:00
jason.woltje 127a54fdff chore: consolidate new foundation and archive v1 (#1495) 2026-09-07 12:32:57 -05:00
Dewey 9a5fbdbda7 fix(goal): quiet waits and unify fleet NG ownership (#56, #57, #58) 2026-09-06 04:07:09 -05:00
jason.woltje 7345f330fc docs: map foundation to integrated rewrite baseline 2026-09-06 02:40:05 -05:00
jason.woltje d4696d09eb feat(extensions): establish canonical goal source (#54, #55) 2026-09-06 02:32:32 -05:00
jason.woltje 44f257cb06 docs: record accepted phase-2 foundation contract 2026-09-06 02:23:15 -05:00
code-infra-01andorch-01 5d27700026 fix(#1257): confirm delivery by draft transition, not prompt detection (adopts #1262) (#1332)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-infra-01 <[email protected]>
2026-09-04 22:25:13 +00:00
jason.woltje 69d1bb3aa4 docs(plan): resolve harness IDs + lifecycle review gate (#50)
Owner adjudication:
- canonical harness IDs match executables: pi, claude, codex, opencode
- agent.json uses one scalar harness ID; registry/manifest resolution, no
  hard-coded schema enum
- target mosaic harness list/detect/install/rm/status lifecycle
- detection recognizes reviewed executables and records compatibility
  without reading/copying harness homes
- installs are exact-version/verified, Mosaic-managed, never global
- detected external harness is available but not container-ready until
  imported/installed, absent a separately reviewed host adapter

Gate 1 resolved. Remaining P0/review gates stay open; no implementation
authorized. Suites 24/15/90/14/17 + verify green; unslop clean.
2026-09-04 12:26:57 -05:00
jason.woltje 9ea7dea711 docs(review): record independent glm-5.3 auth-registry spec review (#50)
Verdict ACCEPT WITH CHANGES. Persist ten-gate recommendations and three
P0 blockers: per-seat launch provider/model resolution; rotating OAuth
persistence for long-running seats; role auth ceiling ∩ settings profile
plus data-map/reset alignment. Reviewer made no repo edits.

Implementation remains blocked pending owner/conductor adjudication.
Prior gate remains green: suites 24/15/90/14/17 + verify.
2026-09-04 12:14:48 -05:00
jason.woltje d9a94f51ba docs(plan): specify harness declaration + centralized auth/provider registry (#49)
Design only; implementation blocked pending owner review.

- agent.json: one harness identifier (pi first), resolved through a
  versioned adapter/harness manifest; reusable settingsProfile reference
- central data-root registry: providers, accounts (metadata + secret
  credential split), reusable settings profiles, audited runtime selection
- per-seat pi auth.json/models.json mechanically generated and atomically
  activated; no seat/provider registration ceremony
- mixed oauth/api-key accounts supported centrally; one active account per
  provider per pi materialization
- host-side centralized OAuth login/refresh; agents never authenticate
- local/remote Ollama modeled as endpoint providers, not accounts
- target mosaic auth/provider/agent settings CLI; secrets never on argv
- migration, fail-closed acceptance suites, and ten explicit review gates

CURRENT.md points only to spec review. Suites 24/15/90/14/17 + verify
green; unslop clean.
2026-09-04 11:57:40 -05:00
jason.woltje 975084abe2 fix(auth): mosaic-managed auth lives under the data root, never ~/.pi (#48)
Owner direction: the stack must never impact default harness usage.
Correction to M19 as shipped (nothing had been created in ~/.pi — the
move breaks nothing).

- Mosaic-managed accounts: <dataRoot>/auth/<account>.json, perms 0600
  enforced (loose perms flagged in listings, refused by --auth — mirrors
  gitea-api.sh credential hygiene).
- ~/.pi is read-only to the stack, permanently; the only interaction
  remains the existing read-only container mount of the default
  credential. Recorded as a ROADMAP standing decision.
- auth.sh is now config-driven (data root from config.json, fail closed,
  consistent with every other tool); status reports both sources labeled.
- agent.sh --auth resolution moved after load_config (needs the data
  root); missing/symlinked/non-0600 accounts refuse.
- test-auth.sh: 15 no-Docker cases (accounts-create-nothing, loose-perms
  refusal, invalid-config refusal added). Test-authoring correction
  recorded in BUILD-LOG (fixture-state mismatch caught before running).

Suites 24/15/90/14/17 + verify green.
2026-09-03 22:53:33 -05:00
jason.woltje 073bbfdb6a feat(auth): M19 harness auth tooling — auth.sh checkpoint + per-launch account injection (#47)
Investigation (pi 0.84.4 docs + host auth.json metadata, values never
read): provider stacking is native (one auth.json keyed by provider;
resolution --api-key > auth.json > env > models.json; OAuth auto-refresh).
Multi-account per provider is NOT native -> named-file design:
auth.<account>.json + per-launch injection.

- scripts/auth.sh: status (provider names, credential types, perms,
  env-side names informational — never credential material) and accounts
  (named files, active marker). Exit codes per convention: 3 missing for
  a read, 2 unparseable, 4 file/environment (symlinks refuse).
- scripts/agent.sh --auth <account>: resolves auth.<account>.json and
  exports PI_AUTH_FILE (the existing compose read-only mount source — no
  new plumbing); missing/invalid account refuses pre-container.
- scripts/test-auth.sh: 13 no-Docker cases; core assertion is the safety
  property itself — fixture key/token/env VALUES never reach output.
- Docs: TOOLS.md Auth section, AGENTS.md command surface + suites.

Headless task runs keep the default credential (worker auth selection is
a separate policy decision). Real-host smoke: anthropic/openai-codex
oauth + zai api_key reported, perms 600, no named accounts yet.

Suites 24/90/14/17/13 + verify green. Agreed sequence M16-M19 complete;
M20 owner-gated.
2026-09-03 19:58:50 -05:00
jason.woltje d1d7b5598d feat(agent): fail-closed seat resolution under MOSAIC_AGENTS_DIR override (#46)
Owner decision after live verification of M18: an explicit agents-dir
override that cannot resolve the named seat now refuses the launch
(exit 4, names the seat and dir) instead of launching seatless and
unbounded. Unsetting the override keeps the M13 plain governed TUI.
MOSAIC_ROLES_DIR needs no symmetric change - the M18 gate already
refuses unresolvable role contracts.

Task suite 88 -> 90 (refusal + refusal-names-the-seat). TOOLS.md Agent
section documents the refusal.

Suites 24/90/14/17 + verify green.
2026-09-03 19:45:10 -05:00
jason.woltje ca8135d70c feat(roles): M18 seat-role progressive capability restriction (#45)
Role contracts (roles/<role>.json): roleVersion, name bound to filename,
tools ceiling (subset of pi built-ins), network declared (none|api-only|
open; enforced when network policy lands). Strict schema, fail closed -
a non-role document refuses resolution.

mosaic-task.mjs resolve-role: config-free contract validation, emits
MOSAIC_ROLE_TOOLS / MOSAIC_ROLE_NETWORK.

agent.sh: a declared role binds to its contract. Missing/invalid contract
refuses the launch (exit 2, names the role - the under-equipped-seat
failure mode, mirroring M17 skills). Effective tools = ceiling ∩ requested
(CLI --tools or agent.json caps); no request -> ceiling stands; narrowing
and tool-free outcomes loud on stderr. Adapters unchanged; headless M9
chain (mission ∩ task) untouched.

Ships roles/researcher.json (existing seat declares the role; without the
contract the fail-closed gate would refuse its launch).

Task suite 74 -> 88: contract resolution, wrong-kind/name/network/
duplicate/unsupported/missing refusals, ceiling narrowing E2E (mock
adapter), tool-free E2E, missing-contract refusal. Test-authoring
correction recorded in BUILD-LOG (a check that registered on one path
only, caught by count arithmetic).

Suites 24/88/14/17 + verify green.
2026-09-03 17:25:17 -05:00
jason.woltje bf56583a49 docs(skills): owner loop-doctrine + collaborator delivery discipline, remediated (#44)
ms-communications: integrated as-authored - owner preamble restructure +
collaborator delivery-discipline hunks from the #43 calibration (own
session output is not a send path; the tool performs the preamble flip;
receiving rule 3 requires actually running agent-send.sh).

ms-conductor: collaborator redraft integrated (canon-aligned tracking
surfaces, one-action cadence, fail-closed core) with one conductor
remediation - step 3 now distinguishes refusal (fail closed, never
bypass) from runner outage (direct dispatch to a qualified live seat via
ms-communications permitted, recorded loudly as degraded: no sandbox, no
run record; suites still gate integration). Preserves the owner's
outage-dispatch intent inside invariant 6.

docs/TOOLS.md: release.sh ensure row added (M16 subcommand existed in
code but not in the doc - flagged by the collaborator, verified in
release.sh usage).

Authorship: owner (ms-conductor doctrine, preamble restructure) +
ms-test collaborator (delivery hunks, redraft); remediation + integration
by conductor (dragon-lin:darkwing). Suites 24/74/14/17 + verify green;
unslop clean.
2026-09-03 17:17:29 -05:00
jason.woltje c8f433131c docs: calibration phase 22 record; CURRENT.md staleness corrected (M16/M17 late-logged, next M18); session registered (#43) 2026-09-03 16:55:31 -05:00
jason.woltje d1e75f855c docs(tools): document tools/ tree in TOOLS.md + fix stale suite counts (#43)
Collaborator-authored via conductor-loop calibration: task dispatched to the
live ms-test seat (glm-5.3-flash) over agent-send.sh; diff reviewed line by
line and every documented flag/exit code independently verified against tool
source by the conductor; suites green at integration (config 24 / task 74 /
release 14 / conductor 17 + verify).

- new 'Tools (host-side)' section: agent-send.sh, agent-watch.sh, unslop-check.js
- intro reading guide now points at tools/ (worker-flagged addition, accepted)
- Maintenance suite counts corrected: test-task.sh 58 -> 74

Authored-by: ms-test collaborator (glm-5.3-flash)
Integrated-by: conductor (dragon-lin:darkwing)
2026-09-03 16:55:25 -05:00
jason.woltje f710bf1a68 docs: KICKSTART file 2026-09-03 16:34:20 -05:00
jason.woltje 2085d75190 docs: ms-communications skill (owner-authored) + session registry entry + KICKSTART recovery file 2026-09-03 16:34:03 -05:00
jason.woltje 2f5a8d2cec feat(skills): skill lifecycle + ms-* skill set completion (#40, #41, #42)
- scripts/skill.sh: install (bundled or path) / activate / deactivate /
  uninstall (refuses while enabled) / list
- skills-enabled + skills-available dirs under the data root; a skill not
  in skills-enabled is not enabled or available for use
- pi adapter: MOSAIC_SKILLS -> --skill per dir; --no-skills when none
- agent.sh: seat definitions declare skills[]; resolution against
  skills-enabled refuses the launch loudly when missing
- ms-* skills completed (owner-authored canon, hands-off): ms-tools
  adapted to the runtime, ms-file-read/write/agent/conductor bodies
  written in the owner's style; ms-agent-watch + ms-unslop untouched
- tasks/USER.md onboarding fixtures; suite hardening (nested def path,
  user seed, mock-adapter dispatch evidence)

Suites: config 24, task 74, release 14, conductor 17, verify PASS.
RELEASE 0.0.12 packaged; health-gated activation on merge.

Closes #40, closes #41, closes #42
2026-09-03 16:23:57 -05:00
jason.woltje e14ad9ab52 Merge: skill lifecycle, skills completion, M16 self-determination hardening, M20 decision
- skill.sh lifecycle (install/activate/deactivate/uninstall/list)
- 8 ms-* skills completed (owner canon preserved)
- seat skills dispatch + enabled-dir resolution
- M16: ensure at launch, drift warnings, recursion guard
- M20 decision: packages/* monorepo at usurpation

Closes #40, closes #41, closes #42
2026-09-03 16:03:05 -05:00
jason.woltje 9fd16b9739 feat(release): recursion guard for the health gate; run-task drift warning; M20 packages/* decision recorded (#39)
- release.sh health gate runs with MOSAIC_ENSURE_SKIP=1: the gated task run
  cannot re-enter release self-determination
- run-task.sh warns on release drift instead of silently using a stale image
- ROADMAP: M20 decision recorded (packages/* monorepo at usurpation,
  continuity-first); restructure sequenced as M20 phase 1

Closes #39
2026-09-03 15:58:46 -05:00
jason.woltje 9051ad179b docs(roadmap): restructure sequencing (M20 phase 1, not first) + skills-as-discipline doctrine 2026-09-03 14:50:59 -05:00
jason.woltje 2f649ed930 docs: README release ensure row 2026-09-03 14:43:25 -05:00
jason.woltje db330c12c7 feat(release): self-determination - ensure at launch, drift warnings, M20 packages/* decision (#38)
- release.sh ensure: aligned no-op; drift -> package-if-needed + health-gated
  activate (M3 gate-then-flip, automated)
- ensure_release_aligned in common.sh: invoked by hello/verify/agent;
  MOSAIC_ENSURE_SKIP guards recursion; run-task warns on drift without
  auto-aligning (workers/suites never trigger builds or model gates)
- ROADMAP: M20 decision recorded - v2 adopts packages/* monorepo at usurpation
- BUILD-LOG Phase 20 + tool-race process note

Live-verified: post-reset pointer loss auto-restored via health-gated
ensure; drift warning fires on desired-version bump; idempotent no-op on
aligned state.

Closes #38
2026-09-03 14:42:09 -05:00
jason.woltje 0a17d29bce docs(roadmap): flush 2026-09-03 14:28:58 -05:00
jason.woltje 3458f6a7ad docs(roadmap): M17 skill lifecycle (skills-enabled/available, role-scoped subsets, --skill negates --no-skills); M20 stack succession path + monorepo question 2026-09-03 14:28:45 -05:00
jason.woltje b033952cd5 docs(harvest): pattern ledger from stack/next + fleet runtime (12 patterns, skips, owner-corrected M17-M19 designs) 2026-09-03 13:55:30 -05:00
jason.woltje c102980ad4 docs(plan): CURRENT.md - queue aligned to ROADMAP (M16 next, CI deferred) 2026-09-03 12:59:28 -05:00
jason.woltje 58b96cb715 docs(plan): ROADMAP.md - M16 release self-determination, M17 ms-tools skill, M18 seat-role restriction, M19 auth tooling; CI deferred per owner 2026-09-03 12:57:26 -05:00
jason.woltje dd3ff944a1 chore(release): 0.0.11 2026-09-03 12:30:52 -05:00
jason.woltje 8eb81ebec1 feat(onboard): user onboarding - no default USER.md, guided creation (#37)
- bootstrap no longer creates user/USER.md (owner direction)
- scripts/onboard.sh: name REQUIRED (interactive loop or --name),
  optional fields prompted (profession, marital, age, gender, education,
  location, timezone, skillset, interests, hobbies, pets); flag-driven
  non-interactive mode for automation
- templates/USER.md: canon skeleton, placeholder rendering, unfilled
  optional = (not provided)
- agent.sh: auto-runs onboarding when profile missing (TTY gate);
  headless run-task warns and continues without user context
- user profile dispatched to all launches (M14 layer)

Closes #37 (onboarding requirements from owner layout review)
2026-09-03 12:30:42 -05:00
jason.woltje 530597cc84 docs(plan): CURRENT.md flush 2026-09-03 11:59:49 -05:00
jason.woltje 9a0d44f96a docs(plan): CURRENT.md - deduplicated completed log (marked correction), M15 review queued 2026-09-03 11:59:37 -05:00
jason.woltje 121b331c6c docs(log): back-fill phases 16-19 (M10, M12, M14, M15) - recorded retroactively with ground-truth sources 2026-09-03 11:59:14 -05:00
jason.woltje 34e06e7de7 Merge M15: agent seats - per-agent SOUL and role contracts
Closes #36
2026-09-03 11:56:38 -05:00
jason.woltje 9bd4f1c405 feat(agents): agent seats - per-agent SOUL, role, definitions dir (#36)
- agents/<name>/ holds agent.json (strictly validated: version, name,
  role?, capabilities?, workspace?, session?) + SOUL.md (persona prose)
- agent.sh: definition loading (quote-safe node defaults file), runtime
  SOUL copy to dataRoot/agents/<name>/, MOSAIC_AGENT_SOUL_FILE ->
  loader fills the SOUL slot from the seat's persona (contract SOUL =
  default persona; governance never overridden)
- seat.json written once at instantiation (seatVersion, name, role, at)
- identity section gains agent role; compose passthrough for role+SOUL
- live user context (M14) + seat SOUL compose the full persona:
  governance -> persona -> identity -> user -> mission
- RELEASE -> 0.0.10; packaged and health-gated activated
- example seat committed: agents/researcher

Closes #36
2026-09-03 11:56:38 -05:00
jason.woltje a7b612435b docs: BUILD-LOG Phase 15, CURRENT.md - M13 shipped 2026-09-03 11:25:41 -05:00
jason.woltje 87f10772ce Merge M13: interactive TUI agent + TOOLS.md
Closes #35
2026-09-03 11:24:56 -05:00
jason.woltje 7db4c5c2ed feat(agent): interactive TUI launcher + identity + TOOLS.md (#35)
- scripts/agent.sh <name>: launches interactive pi TUI in the container
  with contracts + optional mission + agent identity + named session +
  optional workspace/tools; the Mosaic alternative to vanilla pi
- pi adapter: MOSAIC_INTERACTIVE branch (clean TUI, no -p, no initial
  prompt); headless exec rebuilt via positional args (no word-splitting
  on the request); MOSAIC_AGENT_NAME optional in headless
- loader: AGENT IDENTITY section when the launcher names the agent
- compose: fixed command removed (request defaults live in run-agent.sh);
  MOSAIC_INTERACTIVE/MOSAIC_AGENT_NAME passthrough
- docs/TOOLS.md: full on-demand tool reference; AGENTS.md routes to it
- RELEASE -> 0.0.8 (container change); build verified

Closes #35
2026-09-03 11:24:56 -05:00
jason.woltje 0273a84549 docs: AGENTS.md - session recovery shim, invariants canon, session registry
- AGENTS.md at root: pi loads it automatically at every session start
  (conductor-level sessions; workers deliberately exclude it via
  --no-context-files). Deliberately short: invariants, session protocol,
  role model, command surface, data map, pointers - depth stays in docs/.
- docs/SESSIONS.md: append-only session registry, mandatory per session.
- Recovery rule encoded: compaction/restart loses nothing - AGENTS.md +
  CURRENT.md + git log + suites reconstruct state; never guess.
2026-09-03 11:02:19 -05:00
jason.woltje 3b674b7a66 Merge: roles/ directory convention - root is bootstrap-only 2026-09-03 10:56:07 -05:00
jason.woltje 527bc581ca refactor(layout): role contracts move to roles/ - root is bootstrap-only
Owner direction: the repository root holds first-class, bootstrap-required
configuration only. conductor-policy.json is a ROLE contract (the
conductor's authority), one of scores of future role contracts
(agent-policy, coder-policy, ...) - such files get a dedicated home.

- roles/conductor-policy.json (git mv)
- conductor-apply.sh + test-conductor.sh read the new path
- CONDUCTOR.md records the roles/ convention

Closes UX follow-up from owner layout review; no issue (convention change).
2026-09-03 10:56:07 -05:00
jason.woltje 1249714a9a docs(plan): CURRENT.md - M12 shipped 2026-09-03 07:04:37 -05:00
jason.woltje e175616885 feat(conductor): auto-apply policy gate for worker patches (#34)
- conductor-policy.json (tracked, strictly validated): enabled switch,
  path allowlist globs, gating suites - the autonomy decision lives in a
  declarative file the owner controls
- scripts/conductor-apply.sh <runId> [--dry-run]: succeeded-run check ->
  clean target tree -> diff from worker workspace -> allowlist -> syntax
  gates (node/bash/json) -> apply -> policy suites -> attribution commit;
  ANY failure reverts the tree; push is never automatic
- scripts/test-conductor.sh: 17 sandbox cases covering every gate incl.
  suite-failure auto-revert and disabled policy
- policy defaults: scripts/docs/tasks/missions/adapters + README; all
  three suites gate

Closes #34
2026-09-03 07:03:29 -05:00
jason.woltje 6955717612 Merge M11: session forking from a common ancestor
Closes #33
2026-09-03 06:43:02 -05:00
jason.woltje 88d9cf750f feat(sessions): sessionForkFrom - branch conversations from a common ancestor (#33)
- task schema: optional sessionForkFrom (source session name); requires
  session target; self-fork rejected
- runner: resolves source newest .jsonl (fail 4 if none/outside dataRoot);
  passes MOSAIC_SESSION_FORK + MOSAIC_SESSION_DIR; result records lineage
- pi adapter: --fork <source> --session-dir <target> when forking;
  ephemeral default unchanged; plain session resume unchanged
- compose passthrough; RELEASE -> 0.0.7 (adapter changed)
- suite +9 cases (58 total): plumbing via mock stderr, validation
  negatives, live fork - child recalls ancestor code word, ancestor
  session file untouched

Closes #33
2026-09-03 06:43:02 -05:00
jason.woltje c038706eed docs(plan): CURRENT.md - M10 shipped, retention next in review 2026-09-03 06:33:54 -05:00
jason.woltje 8622c9d826 Merge M10: run-record retention
Closes #32
2026-09-03 06:33:27 -05:00
jason.woltje 88eef507b0 feat(retention): run-record pruning - keep newest N, dry-run default (#32)
- mosaic-task.mjs prune [--keep=N] [--yes]: default keep 50; without
  --yes lists candidates without deleting
- only r-* directories under the runs root; symlinks skipped;
  sessions/workspaces/state/config untouched (asserted by suite sentinels)
- append-only receipt runs/.pruned.log records every pruned id
- test-task.sh: +8 retention cases (dry-run no-delete, keep-N, newest
  kept, receipt, isolation, invalid keep, empty no-op)

Also: suite hardening - prune section scopes its config per-command
(no export/unset leaking into later sections); duplicated check()
removed; latest_reason hoisted to helpers; status colors now green OK /
red FAIL (terminal-only, NO_COLOR-aware) per owner UX feedback.

Closes #32
2026-09-03 06:33:27 -05:00
jason.woltje 439bea6915 ui(test): green OK/PASS, red FAIL - terminal-only, NO_COLOR-aware
Owner feedback: grep match-highlighting made the word 'policy' red while
status words were plain - counter-indicative. Suites + verify now emit
ANSI colors (green success, red failure) when stdout is a terminal;
piped/machine-parsed output stays plain, honoring NO_COLOR. Word 'ok'
promoted to 'OK' for scannability.

Verified byte-level via forced-pty run; piped output unchanged; suites
41/24/14 + verify green.
2026-09-03 06:23:47 -05:00
jason.woltje fad8a4718c Merge M9: mission-level capability policy
Closes #30
2026-09-03 06:16:01 -05:00
jason.woltje 2ff49adff4 feat(policy): mission-level capability policy - least-privilege intersection (#30)
- mission schema: optional capabilities.tools (same validation as task)
- merge semantics in runTask: neither -> none; mission only -> mission;
  task only -> task; both -> intersection (task narrows, never widens);
  empty intersection -> tool-free run with an explicit stderr note
- result.json records EFFECTIVE tools; task/mission snapshots remain the
  immutable declaration of intent
- adapters unchanged; host-side only (no image change, 0.0.6 still active)
- task suite +5 cases (41 total): all four merge cases asserted from run
  evidence + invalid mission capabilities rejected

Policy decision recorded: missions govern; tasks cannot escalate.

Closes #30
2026-09-03 06:16:01 -05:00
jason.woltje 44c476ebbf fix(ops): show displays retriedFrom lineage (#29)
result.json recorded lineage correctly; the human-facing show command
omitted the field. Found by owner test: show | grep retriedFrom was
empty on a run whose result.json contained it.

Closes #29
2026-09-03 06:09:51 -05:00
jason.woltje cde480eb60 docs(plan): CURRENT.md — retry lineage shipped, M9 queued for decision 2026-09-03 05:31:24 -05:00
jason.woltje 5808248707 Merge retry lineage + relative mission resolution
Closes #28
2026-09-03 05:30:57 -05:00
jason.woltje afd5827db8 fix(retry): lineage tracking + relative mission path resolution (#28)
- retryRun rewrites a snapshot's relative mission path to the run's own
  recorded mission.json (absolute) before execution — retries stay
  faithful to what originally ran
- runTask accepts options.retriedFrom; retry records lineage in
  result.json (additive optional field, no schema break)
- task suite +4 cases: retry succeeds, lineage recorded, mission section
  present after retry (36 total), missing-run retry exits 4

Closes #28
2026-09-03 05:30:57 -05:00
jason.woltje d9cc990376 Merge M8: conductor loop - self-orchestration
Closes #25, closes #26, closes #27
2026-09-02 22:42:52 -05:00
jason.woltje 83c4e9851e feat(orchestration): retry <runId> — authored by headless pi worker (#26, #27)
Collaboration record (conductor loop, docs/plans/CONDUCTOR.md):
- round 1 (worker session worker-1, 2m28s): retry implemented per spec
- conductor live test exposed spec gap: direct invocation lacked
  launcher env exports
- round 2 (same worker session, 59s): spawnEnv made self-sufficient,
  but used PI_* where compose interpolates MOSAIC_*
- conductor hotfix: 3-line rename to MOSAIC_PROVIDER/MOSAIC_MODEL/
  MOSAIC_DATA_ROOT

Final: node scripts/mosaic-task.mjs retry <runId> re-executes a run's
task snapshot as a new run; live retry replied REMEMBERED; all suites
green (24/32/14 + verify).

Known limitation: retrying a run whose task used a RELATIVE mission path
resolves it against the temp dir; lineage tracking deferred.

Closes #25, closes #26, closes #27
2026-09-02 22:42:52 -05:00
jason.woltje 22508170a2 docs(plan): CURRENT.md — single next-action pointer for cadence-driven work 2026-09-02 22:25:56 -05:00
jason.woltje 90a67d050e Merge M7: operator ergonomics + release 0.0.6
Closes #24
2026-09-02 22:14:18 -05:00
jason.woltje 24bdef75fa feat(ops): run inspection, release 0.0.6, docs (#24)
- mosaic-task.mjs show <runId>: full record + snapshots + artifacts;
  uppercase-tolerant id validation; missing/traversal ids exit 4
- list: task/workspace/session columns
- RELEASE -> 0.0.6; README workspaces/capabilities/sessions sections;
  BUILD-LOG Phases 9-11; autonomous-run tracker results filled

Closes #24
2026-09-02 22:14:18 -05:00
jason.woltje 4e2a413640 Merge M6: named sessions - persistence and resume
Closes #22, closes #23
2026-09-02 22:08:34 -05:00
jason.woltje be55549700 feat(sessions): named persistent sessions with resume (L1) (#22, #23)
- task schema: optional session (named id) -> persistent session dir at
  dataRoot/sessions/<name>, isolated per name
- pi adapter: --session-dir when declared (ephemeral --no-session stays
  the default otherwise); -c resumes the most recent session when present
- compose passthrough; result.json records session
- fixtures: tasks/session-demo-1.json (teach) + session-demo-2.json (recall)
- E2E: teach -> REMEMBERED + host-side session JSONL; resume -> recalled
  'mosaico' exactly; single continued session file

Closes #22, closes #23
2026-09-02 22:08:34 -05:00
jason.woltje ddb1554e5b Merge M5: task workspaces + capability envelope
Closes #20, closes #21
2026-09-02 22:06:43 -05:00
jason.woltje b017e66e17 test(capabilities): workspace/tooling selftests + live demo fixture (#21)
- mock plumbing cases: workspace path + tools delivered (asserted from
  run-record stderr), host workspace created, absent fields = empty vars
- validation negatives: unknown tool, workspace traversal
- tasks/workspace-demo.json: pi uses bash inside the persistent demo
  workspace; host-visible proof.txt verified live

Closes #21
2026-09-02 22:06:43 -05:00
jason.woltje 172368612c feat(capabilities): task workspaces + tools allowlist plumbing (#20)
- task schema: optional workspace (absent | :run ephemeral | named
  persistent under dataRoot/workspaces) and capabilities.tools (pi
  documented tool allowlist); strict validation, traversal-proof names
- runner: creates host workspace, passes MOSAIC_WORKSPACE (container
  path) + MOSAIC_TOOLS; result.json records both
- pi adapter: cds into workspace; --tools when allowlist present else
  --no-tools
- mock adapter: logs delivered MOSAIC_* vars to stderr as deterministic
  plumbing evidence (dash prints 'export K=v', so use env not export)

Closes #20
2026-09-02 22:03:46 -05:00
jason.woltje 1387231e57 docs(plan): autonomous work run tracker (M5-M7 scope, test plan, review checklist) 2026-09-02 21:57:30 -05:00
jason.woltje 0292392e64 Merge M4: runtime adapter seam
Closes #16, closes #17, closes #18, closes #19
2026-09-02 21:30:35 -05:00
jason.woltje 594b8d711c docs(adapters): adapter seam docs + recorded M4 E2E, release 0.0.5 (#19)
- README: Runtime adapters section (contract summary, selection, mission
  injection point); BUILD-LOG Phase 8 entries
- E2E: 24+24+14 selftests green; verify PASS; 0.0.5 packaged and
  health-gated activated; mission-bearing fixture task succeeded through
  the real pi adapter; config checksum unchanged

Closes #19
2026-09-02 21:30:35 -05:00
jason.woltje 3c1ffd2c2d test(adapters): seam selftests — deterministic mock cases + mission injection (#18)
- test-config: absent adapter defaults to pi; mock validates; unknown
  adapter exits 2; env exports MOSAIC_ADAPTER (24 cases total)
- test-task: mock adapter gate pass/mismatch (no provider needed),
  mismatch reason asserted, unknown adapter fails closed, mission section
  injected into generated prompt asserted by content (24 cases total)
- harness fixes: helpers defined before use; per-case config files (no
  cross-case leakage); newest-run selection for the live case; deduped
  accidentally duplicated live block

Closes #18
2026-09-02 21:28:57 -05:00
jason.woltje 4ebb123ba3 feat(adapters): sanctioned mission directives injection (#17)
- load-contracts.sh: MOSAIC_MISSION_FILE (readable) appends a MISSION
  (runtime) section — objective + directives — after the immutable
  contracts; unreadable path is a hard error, absent env changes nothing
- mosaic-task.mjs: exports MOSAIC_MISSION_FILE as the run snapshot's
  container path (/var/lib/mosaic/runs/<id>/mission.json), with an
  outside-dataRoot guard; also exports the configured adapter

Verified: contract-only prompt has no mission section; mission-bearing
run shows objective + directives in the generated prompt, snapshot
recorded, real provider returns exactly MOSAIC_HELLO_OK.

Closes #17
2026-09-02 21:19:22 -05:00
jason.woltje bb5cecb348 feat(adapters): adapter contract, dispatch, pi + mock adapters (#16)
- adapters/README.md: the harness boundary contract (env in, response on
  stdout, diagnostics stderr, exit 0 success)
- adapters/pi: extracted current invocation unchanged
- adapters/mock: deterministic MOSAIC_MOCK_RESPONSE echo (test-only)
- run-agent.sh: name-validated dispatch to adapters/<name>/adapter.sh
- config: optional execution.adapter (pi|mock), default pi, configVersion
  stays 1 — existing configs remain valid; selection authority is the
  config file (load_config exports it)
- compose: MOSAIC_ADAPTER / MOSAIC_MOCK_RESPONSE passthrough; Containerfile
  installs adapters read-only; RELEASE -> 0.0.5

Verified: hello unchanged; mock verbatim via config; unknown adapter and
path-traversal names refused in-container; invalid adapter exits 2.

Closes #16
2026-09-02 21:18:07 -05:00
jason.woltje 88f55d9135 test(task): live failures self-report evidence; wrong-exit no longer masks (#15)
- live hello failure dumps latest run result.json + stderr tail before
  sandbox cleanup destroys them
- wrong-expectExact case asserts reason == expect-mismatch (was: any
  exit 1, which masked compose-level failures)
- repair dangling if/else from the docker-guard refactor

Closes #15
2026-09-02 20:56:58 -05:00
jason.woltje e7e1bd26eb fix(launcher): resolve release identity for direct task runs (#14)
M3 made MOSAIC_IMAGE_TAG required in compose, but run-task.sh never
called load_release — direct task runs failed in compose before any
model call. release.sh paths masked it by exporting the tag to children.

Found by owner-run test-task.sh; failure receipts were in the run
records' stderr.txt.

Closes #14
2026-09-02 20:44:31 -05:00
jason.woltje 5d86b8fa93 Merge M3: release model and safe updates
Closes #10, closes #11, closes #12, closes #13
2026-09-02 20:24:33 -05:00
jason.woltje 35ea464661 docs(release): release model usage + recorded M3 drills (#13)
- README: Release model section (package/activate/rollback/status,
  pointer + append-only log, gate-then-flip guarantee)
- BUILD-LOG Phase 7: drills recorded (update, refusal, rollback),
  two harness/product corrections documented

Drill evidence: 0.0.3 -> 0.0.4 update with unchanged config checksum and
green verify; fault-injected refusal left pointer untouched; health-gated
rollback restored 0.0.3; full append-only event history.

Closes #13
2026-09-02 20:24:33 -05:00
jason.woltje e87ecdb3e5 test(release): release-layer selftests (#12)
14 cases: RELEASE validation (valid/invalid/missing), tag consistency,
status on empty state, fault-injected refusal with no pointer + single
valid refusal log line, healthy activation, pointer fields, repeat
activation append-only log, rollback-without-previous refusal.

Harness fix learned the hard way: restore RELEASE from backup inline
after the missing-file case (mv-back restored the mutated file); single
exit trap self-heals the repo state.

Closes #12
2026-09-02 20:23:18 -05:00
jason.woltje a947db7bfd feat(release): package/activate/rollback/status with health gate (#11)
- activate: image-presence pre-check + M2 task-runner health gate
  (tasks/hello-marker.json exact marker) before atomic pointer replace
  (tmp+rename); every attempt appended to activation-log.jsonl
- --fault-injection flips the health expectation to prove the refusal path
- rollback: health-gated re-activation of the previous activated imageTag
  from the log; refuses when the image is gone or no previous exists
- status: release, tag, pointer, recent log; safe on empty state
- state lives under <dataRoot>/state/ (config-independent, reset-scoped)

Verified: activate OK; fault-injected refuse with pointer unchanged;
rollback-without-previous refuse.

Closes #11
2026-09-02 20:20:32 -05:00
jason.woltje a35ea62ab1 feat(release): RELEASE identity + image tag single-sourcing (#10)
- RELEASE file: single source of release version (0.0.X until declared stable)
- common.sh load_release(): validates version, derives
  MOSAIC_IMAGE_TAG=mosaic-poc-agent:<pi>-r<release> from the pinned pi dep
- compose.yaml: image tag is required env; build/hello/verify call load_release
- verify.sh derives the image name instead of hardcoding it
- package.json version aligned to the same 0.0.X line

Closes #10
2026-09-02 20:18:17 -05:00
jason.woltje f6c93dcb5c Merge M2: mission and task abstraction
Closes #6, closes #7, closes #8, closes #9
2026-09-02 19:58:53 -05:00
jason.woltje 7f0408d417 feat(task): fixture mission/task, docs, recorded M2 E2E (#9)
- missions/hello.json + tasks/hello-marker.json committed fixtures
- README: Missions & tasks section (usage, run record layout, M2 scope note)
- BUILD-LOG: Phase 6 before/after entries

E2E: fixture run succeeded with exactly MOSAIC_HELLO_OK; wrong expectExact
recorded status failed (expect-mismatch) and exited 1; runs listed; config
checksum unchanged.

Closes #9
2026-09-02 19:58:53 -05:00
jason.woltje cfd2a19bd7 test(task): mission/task selftests — schema negatives + live runs (#8)
18 cases: validation negatives (unknown keys, versions, ids, prompt,
expectExact NUL, timeout range, missing/invalid mission, writes-nothing)
plus live cases: exact-marker success, wrong expectExact fails, distinct
run dirs, result.json contents, list output.

Closes #8
2026-09-02 19:57:34 -05:00
jason.woltje d2a9e26395 feat(task): mission/task schemas, validation, and run runner (#6) (#7)
- scripts/mosaic-task.mjs: validate | run | list
- Strict v1 schemas: unknown keys rejected; ids/prompt/expectExact/
  timeoutSeconds bounds enforced; optional mission file resolved against
  the task file and validated too
- run: executes through the config-driven container path with stdin
  detached (issue #5 class), SIGKILL timeout (default 120s), trimmed
  response capture
- Immutable run records under <dataRoot>/runs/r-<utcstamp>-<rand>/:
  task.json + mission.json snapshots (write-once), stderr.txt, result.json
- expectExact gate: mismatch -> status failed, exit 1; result.json is
  always written
- scripts/run-task.sh: load_config + bootstrap_runtime_dir before exec
- M2 scope: mission directives are snapshotted for provenance, not yet
  injected into the runtime prompt (later policy layer)

Closes #6, closes #7
2026-09-02 19:56:29 -05:00
jason.woltje 0734b1f3a5 fix(launcher): detach stdin on agent container run (#5)
pi print mode reads piped stdin until EOF; an attached terminal stdin
blocked the one-shot run forever. Automated contexts (closed stdin)
never exposed it. Request text comes from the compose command.

Proven: tail -f /dev/null | scripts/hello.sh now returns MOSAIC_HELLO_OK
in ~4s (previously timed out at 30s); verify.sh remains green.

Closes #5
2026-09-02 19:50:07 -05:00
jason.woltje ce6420f3de Merge M1: configuration-driven Hello World
Closes #1, closes #2, closes #3, closes #4
2026-09-02 18:32:50 -05:00
jason.woltje 81f58b15c8 fix(launcher): ensure configured data root before verify mount; docs for M1 (#4)
- verify.sh now calls bootstrap_runtime_dir after load_config; previously a
  reset-then-verify flow let Docker auto-create a root-owned mount source
- common.sh: fail with clear guidance when data root exists but is not writable
- README: configuration section, bootstrap usage, selftest entry point
- BUILD-LOG: Phase 5 entries with corrections

E2E (clean slate): 20/20 selftests; bootstrap idempotent; config-driven
hello/verify MOSAIC_HELLO_OK; negative marker exit 1; reset + rerun green;
config checksum unchanged across the entire flow.

Closes #4
2026-09-02 18:32:50 -05:00
jason.woltje 0c2113f710 test(config): sandboxed config-layer selftests (#3)
20 cases: bootstrap create/idempotency, missing config, malformed JSON,
unknown keys/version/backend/environment, relative and non-canonical
dataRoot, filesystem root, home dir, ancestor-of-config, control chars,
symlinked config file, env export resolution, validation-writes-nothing.

Closes #3
2026-09-02 18:30:46 -05:00
jason.woltje 900a506c1f feat(config): wire launcher scripts and compose to config.json (#2)
- common.sh: load_config() exports MOSAIC_DATA_ROOT/PROVIDER/MODEL; fails closed
- compose.yaml: dataRoot mount and provider/model are required env (:? errors)
- build/hello/verify load config before any mutation; no silent bootstrap
- reset.sh: target resolved from configured dataRoot; all safety checks kept

Verified: compose fails without launcher env; verify/reset fail on missing
config; config-driven hello+verify pass; symlink refusal with sandboxed
config (canary survived); config checksum unchanged across reset+rerun.

Closes #2
2026-09-02 18:30:08 -05:00
jason.woltje c3d29e796a feat(config): config module with idempotent bootstrap and strict v1 validation (#1)
- scripts/mosaic-config.mjs: bootstrap | validate | env operations
- Exclusive creation (O_EXCL 'wx'); existing config validated, never rewritten
- Strict schema: unknown keys rejected, configVersion===1, backend docker only
- dataRoot guards: absolute, canonical, not root/home/ancestor-of-config
- MOSAIC_CONFIG override for sandboxed tests; exit codes 0/2/3
- scripts/bootstrap.sh: explicit bootstrap entry point

Closes #1
2026-09-02 18:28:14 -05:00
jason.woltje c2365ae519 chore: baseline container POC and atomic foundation plan
- Containerized Pi hello-world proof (image mosaic-poc-agent:0.84.4, non-root)
- Four immutable contract fixtures loaded into a generated system prompt
- build/hello/verify/reset scripts with exact-match gating and reset safety
- Documented Pi discovery (v0.84.4, -p mode, --system-prompt, container auth)
- Append-only BUILD-LOG with corrections; deferred layers in LAYERS.md
- Architecture plan: docs/plans/2026-09-02_atomic-mosaic-foundation.md
2026-09-02 18:24:36 -05:00
orch-01 d6302f8e6f docs: make Portainer optional deployment path (#1492)
ci/woodpecker/push/publish Pipeline was successful
2026-09-02 23:23:07 +00:00
marcieandorch-01 9aa4983cf2 fix: use canonical dogfood seat identity (#1490)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: marcie <[email protected]>
2026-08-30 23:43:22 +00:00
marcieandorch-01 736b0affc1 compose: add wrapper-first dogfood workspace (#1488)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: marcie <[email protected]>
2026-08-30 22:29:30 +00:00
orch-01 e18d13d36f fleet: split-home-safe mosaic launcher (T110, P5-RM-009 stack side) (#1480)
ci/woodpecker/push/publish Pipeline was successful
2026-08-30 10:05:40 +00:00
marcieandorch-01 60bc5d2022 compose: pin MOSAIC_STORAGE_TIER=standalone (A5d formal-run fix) (#1486)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: marcie <[email protected]>
2026-08-30 07:49:35 +00:00
marcie ea91cfc421 compose: stack profile — one-command standalone deployment (A5) (#1485)
ci/woodpecker/push/publish Pipeline was successful
2026-08-30 06:03:39 +00:00
marcie acf640d00f docs: containerization plan + PRD D15 (tiered deployment, standalone v1 bar) (#1484)
ci/woodpecker/push/publish Pipeline was successful
2026-08-30 05:21:42 +00:00
fredandmarcie 431ead3a18 feat(gateway,cli): agent enrollment command family (M4-4b) (#1483)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: fred <[email protected]>
2026-08-30 04:30:05 +00:00
fred 143ba0f57a db: agent enrollment schema (M4-4a, migration 0021) (#1482)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-30 01:47:06 +00:00
fred ee815a72b1 docs: agent enrollment command family v1 design (M4-4-0) (#1481)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-30 01:21:00 +00:00
marcieandorch-01 94d626dff9 mosaic comms: socket resolution is tool-owned (B2) (#1476)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: marcie <[email protected]>
2026-08-29 23:35:37 +00:00
marcieandorch-01 ee6c842918 R3: --body-file <path>/- across body/comment carriers (#1474)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: marcie <[email protected]>
2026-08-29 22:49:20 +00:00
marcieandorch-01 ba3b854d50 D1/D3: --number canonical on issue wrappers, --labels alias on list wrappers (#1475)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: marcie <[email protected]>
2026-08-29 22:29:16 +00:00
fred c7a7fd07cc brain/gateway: prohibit mission_tasks.status as a write source (M4-3a phase 1) (#1479)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-29 21:59:13 +00:00
fred 5399c6b7e7 docs: P0 field-map currency verification at next@abb0c936 (M4-3a-0) (#1478)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-29 21:17:05 +00:00
marcieandorch-01 e09b8783b4 P1b: read-only viewers join the R1/R4 usage contract (#1472)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: marcie <[email protected]>
2026-08-29 21:03:07 +00:00
marcieandorch-01 abb0c93601 framework tools/tmux: agent-send socket default resolution + ambiguity guard (B1) (#1466)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: marcie <[email protected]>
2026-08-29 20:25:50 +00:00
marcieandorch-01 e67cced273 mosaic fleet logins: per-seat credential pass-through (P4 gap closure) (#1473)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: marcie <[email protected]>
2026-08-29 20:05:23 +00:00
fred 635cb1f666 docs: contract 2 Amendment 1 — company-CRUD capability (S2 follow-up) (#1477)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-29 19:34:48 +00:00
marcieandorch-01 09d24b9275 pr-merge --base-line: gated intra-line exception (B5) (#1471)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: marcie <[email protected]>
2026-08-29 18:14:40 +00:00
marcieandorch-01 ed4c543872 mosaic coord: board subcommand + roll alias (P3 gap closure) (#1470)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: marcie <[email protected]>
2026-08-29 17:52:30 +00:00
fred 215faeda0a feat(hierarchy): M4-1b-ii hierarchy command family, grant evaluation, visibility (#1465)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-29 16:54:39 +00:00
orch-01 5125fe21b0 p0 line landing: coord board/roll + wrapper usage contract + dispatch layer (successor to #1467) (#1468)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: orch-01 <[email protected]>
2026-08-29 16:24:08 +00:00
marcieandorch-01 41e8046371 framework tools/git: issue-comment R1/R4 usage-error contract (sync from brain) (#1462)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: marcie <[email protected]>
2026-08-29 01:58:42 +00:00
fred 6e16675ea2 docs: deployment mode and conversion contract (S2 contract 6) (#1439)
ci/woodpecker/push/publish Pipeline was successful
2026-08-28 22:57:11 +00:00
fred 19ebc422aa docs: tool-gateway mapping contract (S2 contract 5) (#1438)
ci/woodpecker/push/publish Pipeline was successful
2026-08-28 22:04:03 +00:00
fred bd749831b1 docs: company visibility classes (Ruling 4b amendment, contracts 1+3) (#1461)
ci/woodpecker/push/publish Pipeline was successful
2026-08-28 20:17:35 +00:00
fred f8e1b43b5b docs: RBAC grant model contract (S2 contract 2) (#1436)
ci/woodpecker/push/publish Pipeline is running
2026-08-28 19:09:17 +00:00
fred 2148c20d26 feat(hierarchy): audit event + outbox machinery (M4-1b-i, contract 1 §5.2) (#1460)
ci/woodpecker/push/publish Pipeline was successful
2026-08-28 02:22:53 +00:00
fred 5964dab891 feat(db): hierarchy record class schema + witnesses (contract 1, M4-1a) (#1459)
ci/woodpecker/push/publish Pipeline was successful
2026-08-28 00:42:35 +00:00
orch-01 bdb903cf69 docs: T78 official CLI capability migration contract (#1458)
ci/woodpecker/push/publish Pipeline was successful
2026-08-27 19:53:51 +00:00
fred bec2eb118b docs: API contract artifacts contract (S2 contract 9) (#1443)
ci/woodpecker/push/publish Pipeline was successful
2026-08-27 19:18:39 +00:00
fred 07624140e4 docs: hierarchy schema contract (S2 contract 1, D2) (#1435)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-27 16:39:15 +00:00
fred 1c79af25d4 fix(mosaic): retry the Invariant R pi version probe under CI load (#1441)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-27 16:38:53 +00:00
fred e605c83b27 ci(web): Phase P6 — vite build + headless E2E gate on every trunk merge (#1445) (#1454)
ci/woodpecker/push/publish Pipeline was successful
2026-08-27 15:26:10 +00:00
fred b5ee692843 P5: SPA cutover — retire Next.js, gateway serves the Vite bundle (#1444) (#1453)
ci/woodpecker/push/publish Pipeline was successful
2026-08-27 13:06:49 +00:00
fred bf8bc2128d fix(docker): copy scripts/ into appservice builder before pnpm install (#1452)
ci/woodpecker/push/publish Pipeline was successful
2026-08-27 11:28:47 +00:00
fred 01904b8f69 docs: custody pointer and consent schema contract (S2 contract 7) (#1440)
ci/woodpecker/push/publish Pipeline failed
2026-08-27 10:39:32 +00:00
fred 676900bd46 docs: onboarding wizard contract (S2 contract 3) (#1437)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-27 10:39:23 +00:00
fred a3b0770205 docs: roll-up projection contract (S2 contract 8) (#1442)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-27 02:10:12 +00:00
fred 2a30c68b84 docs: identity account-lifecycle contract (S2 contract 4) (#1433)
ci/woodpecker/push/publish Pipeline is running
2026-08-27 00:13:49 +00:00
fred f8f8f97be7 feat(web): port settings and admin surfaces into the SPA (Phase P4-2) (#1434)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-27 00:03:38 +00:00
fred 49b7943420 fix(gateway): scope /api/teams endpoints to team membership (#1428) (#1429)
ci/woodpecker/push/publish Pipeline is pending
2026-08-26 22:45:54 +00:00
fred 19e16bd44f ci: publish web+appservice sha images on next (#1407) (#1427)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-26 22:42:03 +00:00
jason.woltje 3bd490c080 Merge pull request 'docs: north-star PRD rewrite (D1-D14), ROADMAP, kanban SOT Amendment A1' (#1425) from docs/prd-north-star-rewrite into next
ci/woodpecker/push/publish Pipeline was successful
Reviewed-on: #1425
2026-08-26 16:57:16 +00:00
fred 4b448109dd docs/prd: address independent review findings 1-10 (fidelity, A1 record class + carve-out, roadmap completeness)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-25 23:20:40 -05:00
fred bc1149c15e docs: north-star PRD rewrite (D1-D14), ROADMAP.md, kanban SOT Amendment A1
ci/woodpecker/pr/ci Pipeline was successful
- docs/PRD.md: Part I product north star authored from ratified decisions
  D1-D14; Part II preserves all active workstream contracts verbatim
  (KBN-101, FCM #758, FCOM #766, TESS, #756, MOS-PORT, #1150, #1174, #1194,
  RI #1275, M1). Referenced anchors unchanged.
- docs/archive/PRD-v0.1.md: v0.1.0 beta PRD body archived verbatim with
  supersession header.
- docs/ROADMAP.md: all phases P0-P5 present from day one per D11
  (P2-P5 as explicit placeholders).
- docs/requirements/native-kanban-sot.md: Amendment A1 (D13) - hierarchy
  parentage + RBAC chain above workspaces; sections 1-7 untouched.
2026-08-25 22:23:07 -05:00
orch-01 089953a7cf ci: enable turbo remote cache on trusted publish events (#1424)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: orch-01 <[email protected]>
2026-08-25 18:32:55 +00:00
ops-deploy-01 7b25be22e9 fix(#1394): recover-token headless — dual path (--email flag + piped stdin) with documented precedence (#1423)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: ops-deploy-01 <[email protected]>
2026-08-25 16:14:56 +00:00
ops-deploy-01 4e3d179e61 fix(#1390): gateway uninstall headless — --yes/--remove-data; non-TTY without consent fails loud (#1422)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: ops-deploy-01 <[email protected]>
2026-08-25 16:00:17 +00:00
ops-deploy-01andorch-01 ae58482b72 fix(#1392): review-285 N1+N2 follow-up — env has no config authority in schema-check; verification throws fatal at install (#1421)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: ops-deploy-01 <[email protected]>
2026-08-25 15:08:04 +00:00
code-infra-01 b2d40dada0 fix(#1391): boot-time ValidationPipe metatype self-check — fail loud at startup (#1419)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-infra-01 <[email protected]>
2026-08-25 14:01:24 +00:00
code-be-02andorch-01 d30a4cce00 feat(git-tools): consume .mosaic/repo.json declarations in compat mode (T51 WP5b) (#1416)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: code-be-02 <[email protected]>
2026-08-25 12:55:40 +00:00
veronicaandorch-01 4cd280e48d feat(tools/git): grant-reviewer.sh org-team reviewer grant with fail-closed read-back (#1415) (#1417)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: veronica <[email protected]>
2026-08-25 02:30:33 +00:00
code-infra-01andorch-01 8738a03893 fix(#1395): accounts.issuer column + credential-only backfill — password auth on fresh installs (#1401)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: code-infra-01 <[email protected]>
2026-08-25 01:19:29 +00:00
code-be-01andorch-01 04a01be992 ci(publish): serialize workspace-consuming image builds after publish-next-npm (#1411) (#1412)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: code-be-01 <[email protected]>
2026-08-25 01:18:02 +00:00
veronicaandorch-01 812e2df1da fix(#1408): mosaic-agent@ condition arms on either home shape (#1410)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: veronica <[email protected]>
2026-08-25 01:17:15 +00:00
veronicaandorch-01 8c292fb32f fix(#1408): legacy-socket launch guard + seat launch.sh preference (#1409)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: veronica <[email protected]>
2026-08-25 00:49:27 +00:00
code-be-01andorch-01 f45928c311 fix(ci): restore workspace manifests after publish pin transform (#1404) (#1405)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: code-be-01 <[email protected]>
2026-08-25 00:08:40 +00:00
ops-deploy-01andorch-01 4d24ae8618 fix(#1392): hash-ledger migrations + install-time schema verification (closes #1392, closes #1402) (#1403)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: ops-deploy-01 <[email protected]>
2026-08-24 23:11:50 +00:00
code-be-01andorch-01 d790572e2e ci(publish): pin next-channel @mosaicstack deps to exact same-pipeline builds (#1389) (#1400)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: code-be-01 <[email protected]>
2026-08-24 23:05:37 +00:00
code-be-01andorch-01 d7b1dd9601 test(git-tools): wrapper-guard harness distinguishes tool failure from drift (#1380-FF) (#1388)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-be-01 <[email protected]>
2026-08-24 21:43:09 +00:00
code-be-01andorch-01 0db2d19a22 feat(framework): mosaic doctor structure-anchor provisioning check (T51 WP0b) (#1379)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-be-01 <[email protected]>
2026-08-24 20:15:08 +00:00
code-be-01andorch-01 8eb7e6354e fix(fleet): resolve-then-validate symlink guard + framework helper resolution (#1380) (#1383)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: code-be-01 <[email protected]>
2026-08-24 19:39:13 +00:00
code-be-01andorch-01 9014a510a9 ci(mosaic): repo-structure declaration CI gate (T51 WP5c) (#1378)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-be-01 <[email protected]>
2026-08-24 04:43:26 +00:00
code-be-01andorch-01 974e4740ab docs(mosaic): declare repo structure v2 (T51 WP2a) (#1377)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-be-01 <[email protected]>
2026-08-24 03:49:33 +00:00
orch-01 9cd6d39b71 fix(git-tools): pr-create API fallback resolves base from forge default branch (E4) (#1376)
ci/woodpecker/push/publish Pipeline was successful
2026-08-24 02:49:38 +00:00
code-be-01andorch-01 143f925fd8 fix(git-tools): admin-gated --no-ci-expected merge assertion for CI-less repositories (#1373)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-be-01 <[email protected]>
2026-08-23 18:54:49 +00:00
fredandgate-merge-01 24294d3b77 fix(git-tools): issue-view shows comment bodies and names the real tea failure (#1357) (#1365)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: fred <[email protected]>
2026-08-22 00:23:22 +00:00
veronicaandgate-merge-01 24caeab057 fix(tmux): locate the REPL input box by shape, not by a Claude-only glyph (#1363)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: veronica <[email protected]>
2026-08-21 23:45:08 +00:00
fredandgate-merge-01 888a6ad29b fix(#1356): tea login resolution fails closed on a declared git identity (#1361)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: fred <[email protected]>
2026-08-21 23:04:31 +00:00
fred 24462f460e docs(git-wrappers): correct the stale --login fallback and document the test suite's identity split (#1354)
ci/woodpecker/push/publish Pipeline was successful
2026-08-21 14:42:36 +00:00
veronicaandfred a480ee83dc docs(W4): document contract — stamp kind and status on 104 live docs (#1350)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: veronica <[email protected]>
2026-08-21 14:07:36 +00:00
fred fd43ed5420 fix(git-wrappers): no --login must use the caller's own credential, not a guessed shared login (#1352)
ci/woodpecker/push/publish Pipeline was successful
2026-08-21 04:10:42 +00:00
fargoandfred 1d84bc3f3d fix(fleet): lease-broker activation, symlink-safe unit placement, named launch refusal — Wall 6 (#1292) (#1297)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: fargo <[email protected]>
2026-08-21 00:40:29 +00:00
fred 6306914965 fix(lease-broker): no lease held is a no-op success, not a denied transition (#1339)
ci/woodpecker/push/publish Pipeline was successful
2026-08-20 23:40:09 +00:00
fred af43a7a63e docs(fleet): tier the north star and declare the tier-0 operator surface (#1337)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-20 23:05:34 +00:00
fred ca97b885b0 fix(#808): never borrow a session identity for non-tmux senders (#1335)
ci/woodpecker/push/publish Pipeline was successful
2026-08-20 21:03:50 +00:00
code-infra-01 6db0bead44 fix(#1327): sentinel-managed PATH block, default-home-only profile writes (#1330)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: code-infra-01 <[email protected]>
2026-08-20 20:20:33 +00:00
GhostfredGhost <>
c671290d77 docs: define main/next branch model, sequencing, responsibilities, and merge process (#1214) (#1215)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: Ghost <>
2026-08-20 20:19:39 +00:00
code-infra-01andfred 6a9b2cf6c1 fix(git-tools): pr-merge queue guard reads CI status from the BASE repo for fork PRs (B1) (#1334)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: code-infra-01 <[email protected]>
2026-08-20 20:18:59 +00:00
Ghostgate-merge-01Ghost <>
6bd93a621d scratchpads: fleet identity/comms/mosaic-tree continuation record (2026-08-17) (#1296)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Ghost <>
2026-08-20 19:11:59 +00:00
GhostfredGhost <>
e9485c3d96 docs(l0): parameterize hard gates on the project's integration trunk (#1216, Option A) (#1217)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Ghost <>
2026-08-20 18:38:18 +00:00
Ghostgate-merge-01Ghost <>
d2f0846dcc fleet: put the bootstrapped Node on PANE_PATH (#1256) (#1258)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: Ghost <>
2026-08-20 18:31:30 +00:00
fredandgate-merge-01 4f22a58041 guides: two measurement rules about pinned tool versions (#1316)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: fred <[email protected]>
2026-08-20 16:40:58 +00:00
Ghostgate-merge-01Ghost <>
9b6869fab7 fix(#1179): require security authority wiring (#1189)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: Ghost <>
2026-08-20 16:19:45 +00:00
ops-ci-01 20ad89c86b docs(ci): state the measured push-CI model in ci.yml's when-comment (#1326)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: ops-ci-01 <[email protected]>
2026-08-20 15:48:46 +00:00
ops-01 a55d1a1812 Merge pull request 'merge: absorb main into next — 23-commit divergence (08-05..13 base=main window)' (#1324) from merge/main-into-next into next
ci/woodpecker/push/publish Pipeline was successful
2026-08-20 01:07:05 +00:00
ops-01 9abd7e386f chore: serialize CI run — same tree as 9af456c, fresh pipeline head
ci/woodpecker/pr/ci Pipeline was successful
Per ops-ci-01 (PR #1324 comments 23413/23422) and fred's one-at-a-time
authorization: one serialized run of #1324's tree on the pinned image
(ci-base:lock-9cb7ffcd8828, carried since the cb9a0d1 absorb). This commit
changes no files — the tree is identical to 9af456c; it exists only to mint a
fresh pipeline head instead of restarting 2545.
2026-08-19 19:45:20 -05:00
ops-01 9af456c240 merge: absorb next (pin cb9a0d1 + #1325 + #1129) for the controlled pinned-image rerun
ci/woodpecker/pr/ci Pipeline failed
Per ops-ci-01's procedure (comments 23386/23394, brain D27): pipeline config
travels with the commit under test, so the controlled rerun requires the pinned
anchor in the PR head itself. This merge absorbs everything next took since the
original merge parent (1bdeed62): the ci-base pin (cb9a0d1, lock-9cb7ffcd8828),
the legacy-credential removal (#1325), and the ci-queue-wait statuses:null fix
(#1129 — the branch from the divergence analysis, now landed).

One conflict, same file as round 1: test:framework-shell union — our 54-entry
chain plus next's new test-ci-queue-wait-no-status.sh entry at its position
(55 entries, every target existence-verified). Enumeration guard green:
population 61, enumerated 46, excluded (signed) 16.
2026-08-19 18:43:19 -05:00
ops-ci-01 cb9a0d1642 ci: pin ci-base to immutable lock-9cb7ffcd8828 (Closes #1328) (#1329)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: ops-ci-01 <[email protected]>
2026-08-19 23:42:05 +00:00
fargo 420507da77 fix(#1323): remove legacy credential read and force-merge recipe from mosaic-gitea (#1325)
ci/woodpecker/push/publish Pipeline was successful
2026-08-19 22:51:35 +00:00
ops-01 2508f0aa99 fix(merge): round-2 CI failures — pipefail sites, verify:release mirror, format
ci/woodpecker/pr/ci Pipeline failed
Pipeline 2534 (018d96a), two test-step failures plus the format failure
root-caused by rev-code-01 (id 202):

1. pipefail-early-exit.test.mjs: main's window wrote the test but never
   wired scripts/*.test.mjs into its own CI, so its tools/install.sh pipes
   were never executed against it; next's test wiring runs them and flags
   two load-bearing pipes. Both rewritten pipefail-safe via process
   substitution (newest_matching_file ls|head, check_fleet_transport
   sed|head|tr|awk). Test now 7/7 locally.

2. verify-release.test.mjs: merge kept main's 9-command ci.yml sanitization
   but next's 4-command canonical stage. Canonical updated to mirror ci.yml
   exactly (9=9, order verified). upgrade-guard checked equal (4=4).

3. format: docs/reports/quality/1099-pipefail-sweep.md (from main f158be8)
   prettier-formatted under pinned 3.8.1.

Enumeration guard re-verified: population 60, enumerated 45, excluded 16.
2026-08-19 17:27:30 -05:00
Ghostops-02Ghost <>
d339e8fd21 fix(git-tools): ci-queue-wait — Gitea statuses:null no longer malformed; CI-less repos pushable again (#1129)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Ghost <>
2026-08-19 22:08:34 +00:00
ops-01 018d96a412 fix(merge): drop fleet test-start-agent-session.sh from framework-shell chain — review B1
ci/woodpecker/pr/ci Pipeline failed
Review stack#1324 id 201 (rev-code-01): the union resolution chained main's #1073
fleet test while keeping next's signed exclusion of it (#1271 burn-down: the test
asserts missing-binary behavior but CI provides pi on PATH — fails by design).
check-test-enumeration CONTRADICTORY EXCLUSION, CI pipeline 2532 terminal failure.

Fix per verified review recommendation: remove the chain entry (55 -> 54 parts),
keeping next's exclusion and #1271 state. Guard now passes:
population 60, enumerated 45, excluded (signed) 16.
2026-08-19 16:44:31 -05:00
ops-01 b01950e92f merge: absorb main into next — 23-commit divergence (08-05..13 base=main window)
ci/woodpecker/pr/ci Pipeline failed
17 content commits + 3 merge bubbles were genuinely missing from next (~8,000 lines:
goal controller #1152, framework enforcement #1174/#1195, pr-edit wrapper #1173/#1200,
pipefail series #1100/#1105/#1106/#1107, git-tools fixes #1073/#1085/#1086/#1089,
#991, #1007, enrollment tolerance #1094). 3 commits were already in next by content
(#1060 identical, #1066/#1062 evolved twins — conflicts resolved to next's side).

Per-commit classification and evidence: mosaic-brain fleet/lanes/stack-remediation/main-next-divergence.md.
Conflict resolutions (6 files) itemized in the PR body.
2026-08-19 16:27:17 -05:00
fargo 1bdeed62eb fix(#1320): placeholder-ize private-network topology, drop raw-curl force-merge recipe (#1322)
ci/woodpecker/push/publish Pipeline was successful
2026-08-19 21:15:54 +00:00
fargo 840c2b0d96 Merge pull request 'skills: fold agent-skills into the monorepo, single install path, promote ms-unslop (plan phase D)' (#1319) from fold-agent-skills into next
ci/woodpecker/push/publish Pipeline was successful
2026-08-19 20:08:09 +00:00
fargo 95d5cb32d4 format: cover folded skills' js/ts scripts with repo prettier
ci/woodpecker/pr/ci Pipeline was successful
The repo format:check glob covers ts/js alongside md; four non-markdown
scripts inside the folded tree were flagged after the md pass. Same pinned
prettier 3.8.1, same markup-only class (verified: node --check still passes
on the js files).
2026-08-19 14:41:18 -05:00
fargo d5f3fae896 skills: promote ms-unslop from skills-local into the package
Phase D3 of plan 2026-08-19 (decision S21): skills-local is the local test
bed; promote individually as each proves out. ms-unslop is the only one of
the seven local skills with working enforcement evidence — a checker
(tools/unslop-hook/unslop-check.js), a machine-source list (lists.json), a
19-test suite, a measured corpus, and a regression fixture.

The checker itself stays fleet-local for now (it binds to a specific
harness extension surface); this promotes the skill document only.

Gates verified on the moved file: sanitization denylist clean, prettier
3.8.1 clean (6730 chars was fred's measure at assignment; 7249 as shipped
today — both pass).
2026-08-19 14:39:46 -05:00
fargo 1556982dbc skills: single install path — canonical skills ship with the framework
Phase D2 of plan 2026-08-19: single package, single install, single command.

The framework installer already treats skills/** as a shipped, manifest-owned
framework subtree, so the folded skills now install into
$MOSAIC_HOME/skills with the rest of the framework — no second repository,
no separate sync step:

- mosaic-sync-skills (bash + powershell): the fetch machinery is gone (clone,
  pull, dirty-state migration, rsync from sources/agent-skills). The script
  now only links installed skills into runtime homes. --link-only is a compat
  no-op; --no-link exits having nothing to do.
- catalog.ts: the sources/agent-skills fallback is dead and removed.
- install.sh, launch.ts, defaults/README.md, README.md, skills/README.md:
  references to the second repo rewritten to describe the shipped path.

Verified: clean install into a fresh MOSAIC_HOME produces 102 skills with no
sources/ directory; the linker then links the selected skills into the four
runtime homes with no git involvement.
2026-08-19 14:39:28 -05:00
fargo 1a822493ba format: apply repo prettier (3.8.1) to the folded skills tree
963 markdown files reformatted with the repository's pinned prettier so
pnpm format:check covers the folded tree like every other repo file.

The formatter's embedded-language pass also normalized code fences
(TS semicolons, closed HTML tags in examples, lowercased CSS hex colors,
one renumbered list that skipped an index). Alphanumeric token deltas vs
the fold commit were audited file-by-file; all are formatter-equivalent
markup normalizations plus the four sanitized skills.
2026-08-19 14:37:17 -05:00
fargo d2eeb64433 skills: sanitize operator-identity tokens from folded ops skills
Four folded skills carried operator identity tokens that the sanitization
gate (verify-sanitized.sh) forbids in the public framework package:

- kickstart: template path pointed at a private brain checkout; now uses the
  framework-shipped $MOSAIC_HOME/templates/docs/TASKS.md.template
- mosaic-deploy: dropped one estate-specific stack-name row from the example
  table
- mosaic-portainer, mosaic-woodpecker: credentials now name the framework
  credentials store (load_credentials <service>) instead of a private
  checkout path

Estate-specific values can live in a skills-local override, which the linker
applies with precedence over canonical skills.
2026-08-19 14:34:00 -05:00
fargo 5e58597dbe fold: absorb mosaicstack/agent-skills into the framework skills tree
Fold the agent-skills repository into the monorepo as the shipped canonical
skills package (plan 2026-08-19 phase D1, decision S19: single package, single
install, single command).

History is preserved by rewriting each commit's paths from skills/ to
packages/mosaic/framework/skills/ (git fast-export/import) and merging the
rewritten history with --allow-unrelated-histories, so the original commits
with their authors, dates, and messages remain reachable. Blob content is
untouched by the rewrite; tree fidelity was verified blob-sha-for-blob-sha.

This change must be merged with a real merge commit (not squash) or the
history link is destroyed.
2026-08-19 14:33:03 -05:00
jason.woltje fe4fa20309 Merge pull request 'guides: add SEAT-IDENTITY and FLEET-COMMS; harden CODE-REVIEW evidence rules' (#1313) from fred/guides-seat-identity-fleet-comms into next
ci/woodpecker/push/publish Pipeline was successful
Reviewed-on: #1313
Reviewed-by: rev-code-01 <[email protected]>
2026-08-19 15:44:57 +00:00
fred 5e93ef70bd guides: fix the cross-reference direction in SEAT-IDENTITY
ci/woodpecker/pr/ci Pipeline was successful
rev-code-01's non-blocking nit on #1313 round 2. The no-linking-step paragraph
pointed at the bridge explanation as 'described below'; it is above. Now names the
section, which survives further reordering better than a direction word does.

Text-only. Verified with the repo's PINNED prettier (3.8.1 via pnpm-lock.yaml) and
the sanitization gate, both clean.
2026-08-18 19:10:09 -05:00
fred 3884f2de4d guides: address rev-code-01's review of #1313 (B1, B2, S1, S2)
ci/woodpecker/pr/ci Pipeline was successful
All four findings reproduced before fixing. rev-code-01 was right on each.

B2 (blocker, mine). SEAT-IDENTITY provisioning step 4 said to symlink the
framework store entry to the seat slot, while the same file says those bridges
must not be recreated. The same bridge, told both ways, in one document. I
rewrote the resolution and token-location sections when the deploy made them
stale and did not carry the change into the numbered steps. Step 4 is gone and
the file now says explicitly that no provisioning step links the store to the
slot, so the omission cannot read as an oversight.

S1 (mine). The guide claimed the helper "attempts a fleet notification" on
refusal. The shipped helper does no such thing — its only reference to
notification is a comment saying an alert built on the record is best-effort, and
there is no send or wake call anywhere in the file. Now: it writes a durable
record, the record is what exists, and nobody should wait for a notification that
nothing sends. A guide that promises an alert is worse than one that promises
nothing.

S2. Estate-local content removed from files that ship to every estate: the
~/.mosaic/fleet/bin script paths (dead paths elsewhere) and the 2026-08-18 dates,
which dated a specific host's migration rather than describing behavior. The
bridge-removal passage now states the ORDERING that matters — remove bridges only
after a seat-aware helper can reach the slot, never before — which is the part
that transfers.

B1. prettier reformatted all three files. Reproduced the pipeline 2515 failure
locally before and confirmed clean after; the other three guides prettier flags
are untouched by this branch (0 changes vs origin/next) and are pre-existing.

Sanitization gate re-run and passing.

Verified for the record, since I could not verify my own work: rev-code-01
confirmed the no-fallback claim TRUE against helper content on origin/next, and
judged the evidence rules actionable on the grounds that each names an executable
replacement.
2026-08-18 18:51:15 -05:00
fred a3c50d91ca guides: genericize the operator name in SEAT-IDENTITY provisioning
ci/woodpecker/pr/ci Pipeline failed
Pipeline 2514 failed the sanitization gate on 'Jason mints the token into the
seat slot'. The denylist is jarvis|jason|woltje|... and a shipped framework file
must not carry operator identity. My mistake: I generalized the estate paths and
seat names when promoting this guide and did not check the operator name.

Now reads 'the estate operator', with the accompanying rule that an agent does
not ask another agent to mint one either.

Verified by running tools/quality/scripts/verify-sanitized.sh locally rather than
guessing at the pattern: gate passes.
2026-08-18 18:28:37 -05:00
fred 2fd102e6af guides: state the decree, drop the mechanism
ci/woodpecker/pr/ci Pipeline failed
The #1280 prohibition carried an explanation of how the tools misattribute and
why the failure is invisible from inside them. A reader who is not going to use
the tool cannot act on any of it. Same for rule 2's closing clause about what
reviews commonly miss. Both cut to the decree and the corrective action.

Rules 1 and 3-12 keep their trailing sentences: those are corrective actions or
the detail that makes the case recognizable, not justification.
2026-08-18 18:25:47 -05:00
fred efb3c3a10c guides: add SEAT-IDENTITY and FLEET-COMMS; harden CODE-REVIEW evidence rules
ci/woodpecker/pr/ci Pipeline failed
Three guides that existed only as one host's working copy, promoted to framework
templates so every estate gets them. A working copy under ~/.mosaic binds one
host; only a template here binds all of them.

SEAT-IDENTITY.md (new) documents how a seat's git credential is actually
resolved after #1311: identity from MOSAIC_GIT_IDENTITY, then
mosaic.gitIdentity, then the stdin username; host mapped to a store prefix; then
ONE of two stores chosen by whether the seat directory exists, with no
precedence and no fallback between them. A seat with a directory and an empty
slot fails closed rather than reaching the service store, and that is the point.

It also corrects how to find the helper. credential.helper commonly names an
absolute path, so `command -v git-credential-mosaic` answers a different question
than the one git asks, and the two stop agreeing the moment the PATH copy is
removed. Git also tries EVERY configured helper in order, so a fail-closed helper
in front silently hands the request to whatever is configured behind it. The
guide says to read the whole list.

FLEET-COMMS.md (new) documents agent-send.sh: the class table, the addressing
preamble, and the exit codes — including that rc=2 means the text reached the
pane as an unsubmitted draft, so retrying double-sends it. Confirm with
capture-pane instead. It also says to measure the fleet rather than trust
roster.yaml, which on a live host was simultaneously naming a socket that did not
exist, listing seats that were not running, and omitting seats that were.

CODE-REVIEW.md gains an Evidence Discipline section: a green is not a result
until you have shown it could go red, measurement and explanation are separate
sentences, verify by content on the ref that ships rather than by ancestry of a
local sha, and confidence is part of a finding. Plus four shell-measurement rules
earned on #1311, each of which produced a wrong conclusion first — `cmd | tail;
echo rc=$?` reports tail's status, a missed glob under pipefail exits 2 and kills
the run under set -e, nonzero-with-no-output is an environment question before it
is a code question, and `git -C` in a non-repo directory answers from the
enclosing repo.

The estate-specific repository exception that lived in the working copy is not
carried here. The template says an estate may document one, scoped to a named
repository and never precedent for a second.

Both new guides are added to the two routing tables that agents read.
2026-08-18 18:15:50 -05:00
jason.woltje d4d32a80b2 Merge pull request 'git credentials: fail closed, and read a seat's token from its own slot' (#1311) from fred/credential-fail-closed-seat-slots into next
ci/woodpecker/push/publish Pipeline was successful
Reviewed-on: #1311
2026-08-18 22:40:46 +00:00
fred c703cc50eb git-credential-mosaic: escape the escalation record, and stop naming a record that was never written
ci/woodpecker/pr/ci Pipeline was successful
Both defects found in review by rev-code-01 on #1311.

F3 — the JSONL record interpolated every field with a bare %s. An identity comes
from git config or the environment and a cwd is whatever directory git ran in, so
either can contain a quote or a backslash. One such refusal turned the day's spool
into unparseable JSONL, and the operator would only discover it while reading the
record that explains an outage. Fields are now JSON-escaped.

F2 — the diagnostic printed "record: <spool>/<date>.jsonl" unconditionally, but
the record is only written inside the branch where mkdir -p succeeded. When the
spool cannot be created the helper named a file that does not exist, on exactly
the hosts where the escalation was lost. It now reports the real path or says
NOT WRITTEN.

Also: prettier on README.md, which was the format-step failure on pipeline 2508.
It reflowed only the two tables this branch added.

Tests: cases 14 and 15 cover both. Verified discriminating — against the previous
helper with these same tests, case 14 fails with the unparseable record printed
and case 15 fails on both assertions; against this one both pass.

The first draft of case 14 used `ls "$spool"/*.jsonl | head -1`, which under
`set -o pipefail` exits 2 on a missed glob and killed the suite with zero output
— the same silent-nonzero failure rev-code-01 hit from a partial tools/ extraction
and the reason this file exists. Replaced with a glob loop and a comment.
2026-08-18 17:04:55 -05:00
fred 3d2b712355 git credentials: fail closed, and read a seat's token from its own slot
ci/woodpecker/pr/ci Pipeline failed
Two changes to one rule: a credential is resolved from exactly one place,
and an identity that cannot be resolved is refused rather than substituted.

FAIL CLOSED. Both readers ended in an unconditional fall-through to the
shared Gitea account whenever an identity did not resolve. Every seat in a
fleet therefore pushed, opened PRs and filed reviews under one account, and
a record made that way cannot be traced to the agent that made it
afterwards. The fallback now applies only where there is no attribution to
lose: a host with no fleet. Where seats exist, an unresolvable request emits
nothing, exits nonzero, explains itself on stderr, and — in the git helper —
appends a record naming the identity, host, reason and cwd, and no token
value, to ${MOSAIC_CREDENTIAL_SPOOL:-~/.local/state/mosaic-credential-escalations}.

A host runs a fleet when <brain>/fleet/agents exists, which is the signal
packages/mosaic/src/fleet/brain-home.ts already uses to decide a brain is
active, resolved the same way (MOSAIC_BRAIN_HOME, else ~/.mosaic). This is
what keeps the change a no-op for an operator who has not provisioned
per-slot tokens: no fleet directory, shared account, unchanged. It is also
why there is no environment variable to restore the old behavior — one would
reintroduce the substitution being removed.

STORE SELECTION. Both readers hardcoded ~/.config/mosaic/secrets/gitea-tokens,
so a seat's own secrets/ slot was invisible to the framework: a seat could
hold a valid credential and still be served the shared account. The store is
now chosen by what the identity is. An identity with a directory under
<brain>/fleet/agents/ is a seat and is read only from
<brain>/fleet/agents/<id>/secrets/; any other identity is a service identity
and is read from the framework store. There is no precedence between them
and no fallback from one to the other, so a seat with an empty slot is
refused even when a same-named token sits in the framework store. Two copies
of one credential are drift rather than redundancy, and drift surfaces as
the stale copy returning 401, which reads as a revoked token and sends
whoever debugs it somewhere else.

detect-platform.sh is in scope alongside git-credential-mosaic because they
are the two readers of these tokens. Patching only the git helper would make
"one credential, one location" true for push and fetch and false for
pr-create.sh, issue-create.sh and pr-review.sh, which is the harder failure
to notice.

TESTS. The three assertions that pinned the shared-account fall-through are
now fail-closed assertions, and a refusal is checked four independent ways:
nonzero exit, empty stdout, a stderr diagnostic naming identity and host,
and no shared token value anywhere in the output. The exit code alone would
pass against a helper that emitted the credential and then failed. Added:
seat-slot resolution, the no-cross-store-fallback case with a control
proving the framework-store file it declines to read is readable, no-identity
on a fleet host, the fleet gate firing on the default ~/.mosaic and not only
on an injected MOSAIC_BRAIN_HOME, and a cross-host leak check. Both suites
were run against the pre-change code as a control and fail there on exactly
the shared-token emission.

shellcheck is not installed on the authoring host, so the rewritten helper
is unlinted locally and CI is the first lint of it.
2026-08-18 16:19:43 -05:00
fargo 245e0c427d feat(quality-rails): typed evaluator absorbs QC-19/QC-20; verify-release wiring (RI-3-002, #1275) (#1308)
ci/woodpecker/push/publish Pipeline failed
2026-08-18 17:54:47 +00:00
jarvisandfargo ff45f7b5d0 docs(ri-050): forge fail-closed docs + TASKS status catch-up (#1275) (#1299)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: Jarvis <[email protected]>
2026-08-18 16:00:36 +00:00
jarvisandfargo 64350892e7 docs(ri-050): RI-3-001 complete quality-rails probe inventory (#1275) (#1302)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: jarvis <[email protected]>
2026-08-18 15:59:34 +00:00
jason.woltjeandjarvis 6e9df3c640 fix(doctor): greenfield brain lock-in note (fred's #1301 follow-up, #1288) (#1307)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: Jason Woltje <[email protected]>
2026-08-18 07:57:37 +00:00
jarvis f5ba042dfa test(ri-050): RI-1-002 publish-gate negative controls (#1275) (#1305)
ci/woodpecker/push/publish Pipeline failed
2026-08-18 05:57:06 +00:00
jarvis 7c7dab3898 feat(ri-050): RI-5-001 typed freshness states and stale-safe Mission Control surfaces (#1275) (#1300)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-18 05:56:58 +00:00
fargoandjarvis d92de53399 feat(prd): one transitional PRD authority — RI-4-001 (#1275) (#1294)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: fargo <[email protected]>
2026-08-18 05:56:51 +00:00
mos-dt-0andjarvis d7e303d3c0 fix(macp): fail-closed typed gate states — RI-2-002 (#1275) (#1293)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: mos-dt-0 <[email protected]>
2026-08-18 05:56:38 +00:00
jarvis 726d2ad3a2 fix(ri-050): forge fails closed without providers; explicit typed simulation (#1275) (#1278)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-18 05:52:45 +00:00
jason.woltjeandjarvis e4ee1acf24 feat(doctor): brain-home fleet-state check (#1298 follow-up) (#1301)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: Jason Woltje <[email protected]>
2026-08-18 05:26:01 +00:00
jarvis 5c5a25e4de fix(ci): image pushes read the registry secrets that exist (#1275) (#1306)
ci/woodpecker/push/publish Pipeline failed
Squash-merged by topher (jarvis principal) via break-glass API path (wrapper main-only gap, documented). Gates: CI 2492 green at head e19013ed, review 181 APPROVED (fred) at pinned head. Diagnostic merge: the push pipeline now exercises kaniko with the REGISTRY_* secrets - valid creds yield the first fully green gated publish; invalid yield an explicit 401.

Co-authored-by: Jarvis <[email protected]>
2026-08-18 05:02:23 +00:00
jarvis 7669321ea2 test(gateway): cross-user-isolation cleanup honors dbAvailable — unblocks gated publish verify (#1275) (#1304)
ci/woodpecker/push/publish Pipeline failed
Squash-merged by topher (jarvis principal) via break-glass API path (wrapper main-only gap, documented in #1275 log). Gates: CI 2487 green at head 81f500bd, review 180 APPROVED (fred) at pinned head. Unblocks gated publish: verify's no-DB path now skips cleanly.

Co-authored-by: Jarvis <[email protected]>
2026-08-18 04:24:48 +00:00
jarvis d8e0aec950 feat(ri-050): bind next publication to exact-commit terminal verification — RI-1-001 (#1275) (#1277)
ci/woodpecker/push/publish Pipeline failed
Squash-merged by topher (jarvis principal) via break-glass: pr-merge.sh hard-codes main-only merge targets and cannot express this repo's next trunk. Gates: CI 2476 green at head 46784c8d, review 177 APPROVED (fred) at pinned head. First gated publish: every publish step now depends on verify-release at the exact commit.
2026-08-18 03:57:43 +00:00
jarvis 49d6136b02 docs(ri-050): bootstrap release-integrity workstream for 0.0.50 (#1275) (#1276)
ci/woodpecker/push/publish Pipeline was canceled
Squash-merged by topher (jarvis principal) via break-glass: pr-merge.sh hard-codes main-only merge targets and cannot express this repo's next trunk. Gates: CI 2475 green at head 758659dd, review 176 APPROVED (fred) at pinned head.
2026-08-18 03:57:27 +00:00
jason.woltjeandjarvis a80bae950d feat(fleet): brain-home split — fleet state under ~/.mosaic, templates stay config-home (#1298)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: Jason Woltje <[email protected]>
2026-08-18 03:23:15 +00:00
jason.woltje 8199261caa Merge pull request 'fix(ci): unwire test-start-agent-session.sh, restore its signed exclusion — unblocks every PR on next' (#1270) from fix/1269-ci-chain-unblock into next
ci/woodpecker/push/publish Pipeline failed
Reviewed-on: #1270
2026-08-17 20:44:59 +00:00
fred 57a2f2b40e docs(ci): point the exclusion at tracking issue #1271, not the closed first filing
ci/woodpecker/pr/ci Pipeline was successful
The first PR for this change was filed under the retired mos-dt-0 principal
(pr-create.sh has no --login flag and find_tea_login_for_host returns the first
host match) and was closed and refiled as #1270. That left in-tree references
pointing at a closed duplicate PR rather than at the burn-down issue, which is
the wrong target for them anyway: the open design question belongs on #1271.
2026-08-16 18:03:02 -05:00
fred 93c1de51e1 fix(ci): unwire test-start-agent-session.sh, restore its signed exclusion (#1269)
ci/woodpecker/pr/ci Pipeline was canceled
The `test` step has failed on every `next` pipeline since #1017 on exactly one
assertion, and it is the same one on unrelated PRs:

    FAIL: host provides 'pi' in the system path; missing-binary cases are not
    measurable here            (framework/tools/fleet/test-start-agent-session.sh:103)

Measured 2026-08-16 across pipelines 2444 (#1256), 2438 (#1240) and 2441
(#1017-quality): exactly one FAIL line in each full log, identical, this line.
Control `zzz-not-present-zzz` -> 0 on all three.

Cause. #1241 (5c35a250) added the guard: the suite shims fake mosaic/pi/npm into
$FAKE_BIN, but the constructed PANE_PATH always ends in the real system path, so
on a host that installs those binaries the missing-binary cases cannot be
measured and a green run would mean nothing. The guard says so instead of
passing. Its own pipeline 2430 was green only because the suite was CI-excluded
at the time, so the guard had never run in CI. #1017 (c56483eb) then enumerated
it and dropped the exclusion. The CI image installs
@earendil-works/[email protected].1 on purpose, so the precondition is
unsatisfiable there. Both commits are mine.

The guard is correct and is not being softened. A check that cannot measure its
property and reports success is the failure mode this repo has been cataloguing
all week; the error was wiring the suite into an image that violates its
precondition, so the wiring is what gets reverted.

Second effect, which is the reason this cost a day rather than an hour:
test:framework-shell is one && chain and this sat at position 44 of 48, so
glpi/test-list-http-status.sh, orchestrator/test-board-roll.sh,
woodpecker/test-ci-wait-exit-matrix.sh and _scripts/test-fleet-transport-check.sh
have not run at all since the merge. The pipeline reported one failure, never
"one failure plus four unrun". All four are green when run directly on
sb-it-1-dt, so the mask hid nothing broken -- but that is a local result on one
host, not a CI-image result.

Verification, with controls:
- enumeration guard OK (population 52, enumerated 36, signed-excluded 16).
- control A, exclusion line removed while unwired -> FAIL UNENUMERATED.
- control B, exclusion line kept while rewired -> FAIL CONTRADICTORY EXCLUSION.
  The gate discriminates in both directions, so its OK is load-bearing.
- the four formerly-masked suites: rc=0 each, run directly.
- the full chain cannot be run to completion on sb-it-1-dt: it stops earlier, at
  the lease-broker Invariant R test, because this host carries the quarantined
  operator-global pi 0.84.2 against a measured 0.84.1. That is host-specific and
  out of scope here -- CI pins 0.84.1, and the single FAIL line in those three
  pipelines proves positions 1-43 passed there.

Burn-down is to control the tail of PANE_PATH inside the test, not to remove pi
from the image. Recorded in the exclusion reason and in #1269.
2026-08-16 17:58:49 -05:00
fred 476db12b92 Merge pull request 'fix(fleet): tell the operator when the fleet transport is missing (#1240)' (#1245) from fix/1240-fleet-transport-check into next
ci/woodpecker/push/publish Pipeline failed
Reviewed by scooby via git comms (terminal ACK 32d986). Merge directed by Jason 2026-08-16. Conflicts from #1229 and #1252 resolved on the branch by a pi seat; pre-merge gate verified: tools/install.sh=5d28f773, framework/install.sh=1578c33b, test:framework-shell=48 links with both #1252 and #1245 suites present.
2026-08-16 18:12:33 +00:00
fred 5198c3f198 merge next into fix/1240-fleet-transport-check
ci/woodpecker/pr/ci Pipeline failed
Resolves conflicts from #1229 (tools/install.sh node provisioning) and #1252
(package.json test:framework-shell). tools/install.sh resolved to the reviewed
composite blob 5d28f773; package.json resolved as a union so both #1252's four
suites and #1245's transport-check suite run (48 links).
2026-08-16 13:11:31 -05:00
fred 19ac0a02d7 Merge pull request 'test(#1017): wire in four CI-fit shell suites, drop their signed exclusions' (#1252) from fix/1017-wire-start-agent-session into next
ci/woodpecker/push/publish Pipeline was canceled
Reviewed by scooby via git comms (no mosaicstack principal on fomo-lin; review is the comms record, terminal ACK 32d986). Merge directed by Jason 2026-08-16. Part of the five-PR greenfield composite verified E2E on two independent bare boxes.
2026-08-16 18:06:53 +00:00
fred 6d9387c857 Merge pull request 'fix(fleet): fail the agent launcher when the pane cannot survive (#1241)' (#1244) from fix/1241-launch-failure-visible into next
ci/woodpecker/push/publish Pipeline was canceled
Reviewed by scooby via git comms (no mosaicstack principal on fomo-lin; review is the comms record, terminal ACK 32d986). Merge directed by Jason 2026-08-16. Part of the five-PR greenfield composite verified E2E on two independent bare boxes.
2026-08-16 18:06:50 +00:00
fred 14cb9c6a1e Merge pull request 'fix(fleet): let ps/install work on a roster-v2 fleet, and refuse add/remove honestly (#1237 piece A)' (#1243) from fix/1237-fleet-v2-dispatch into next
ci/woodpecker/push/publish Pipeline was canceled
Reviewed by scooby via git comms (no mosaicstack principal on fomo-lin; review is the comms record, terminal ACK 32d986). Merge directed by Jason 2026-08-16. Part of the five-PR greenfield composite verified E2E on two independent bare boxes.
2026-08-16 18:06:35 +00:00
fred b5b322f80d Merge pull request 'fix(installer): pin umask and set the 0700 modes the fleet boundary requires (#1236)' (#1242) from fix/1236-installer-dir-modes into next
ci/woodpecker/push/publish Pipeline was canceled
Reviewed by scooby via git comms (no mosaicstack principal on fomo-lin; review is the comms record, terminal ACK 32d986). Merge directed by Jason 2026-08-16. Part of the five-PR greenfield composite verified E2E on two independent bare boxes.
2026-08-16 18:06:21 +00:00
jason.woltje e4674709be Merge pull request 'fix(installer): make a greenfield install actually work — node bootstrap, PATH, wizard profile' (#1229) from fix/installer-path-and-node into next
ci/woodpecker/push/publish Pipeline was canceled
Reviewed-on: #1229
2026-08-16 18:01:07 +00:00
fred c56483eb1b test(#1017): wire in four CI-fit shell suites, drop their signed exclusions
ci/woodpecker/pr/ci Pipeline failed
check-test-enumeration.sh signed four suites as 'likely CI-fit; #1017 burndown'.
Measured all four: each passes standing alone, and each still passes with tmux
removed from PATH entirely (test-start-agent-session.sh writes its own tmux shim
into a fake bin dir, so it never needed the real binary).

Red-first: removing the four exclusion lines makes the guard report exactly four
UNENUMERATED failures. Appending the four to test:framework-shell returns it to
OK, with in-population enumerated going 32 -> 36 and signed exclusions 19 -> 15.

Refs #1017
2026-08-16 01:14:03 -05:00
fred 5c35a250de test(fleet): name what the pane-boundary case's binary check rides on (#1241)
ci/woodpecker/pr/ci Pipeline was successful
Review finding from scooby. This case does not use run_start, so
install_pane_binaries' symlinks land under a home its launcher never consults
(HOME is the trusted parent here). It resolves mosaic and pi through
MOSAIC_RUNTIME_BIN=$FAKE_BIN instead. Valid path, valid green — and a trap for
anyone who later drops that env var believing the symlinks cover it, which
would break the #1241 binary check rather than exercise it.

Comment only; no behavior change. Harness rc=0.

Refs #1241.
2026-08-16 00:21:13 -05:00
fred 10a1f82031 test(fleet): cover the pane-pid-unresolved branch this PR shipped (#1241)
ci/woodpecker/pr/ci Pipeline was canceled
Review finding from scooby: this PR added a failure branch the harness
structurally could not reach. The fake tmux answered `has-session` only for
`=_holder:0.0`, so every non-holder agent landed in the session-is-gone branch
no matter what — the `elif` (tmux still reports the session, no pane PID after
the retries) had zero coverage and no way to get any.

That is the same shape as the bug this PR exists to fix, one layer down: a code
path shipped green where the gate that should measure it cannot. Less severe,
because the branch fails closed at exit 69 rather than reporting success — but
"the harness can't reach it" is the sentence that precedes the next silent
regression, so it gets closed here rather than filed.

`MOSAIC_TEST_HELD_SESSIONS` lets a case name targets the shim should also
answer for. It answers them only AFTER `new-session`, and that detail is the
whole trick: the launcher asks `has-session` about the same name twice — once
at line 255 where a yes means "already running, exit 0", and once at 417 where
a yes means "the session survived". A shim answering yes to both short-circuits
at the first and never reaches the branch under test. It would have looked like
coverage while measuring the idempotency path.

Both failure modes were measured, not reasoned about:
- toggle absent (the old shim): `code=pane-did-not-survive` — the case lands on
  the wrong branch, which is exactly the unreachability being reported.
- toggle answering unconditionally: launcher exits 0 via the idempotency
  short-circuit — "launcher reported success over a session with no resolvable
  pane PID".
- toggle gated on new-session: `code=pane-pid-unresolved`, exit 69.

The case also asserts the diagnostic is not `pane-did-not-survive` and does not
mention the heartbeat, so the two pane faults cannot collapse into one message.

Gates: bash -n · launcher harness rc=0 · test-fleet-units.sh (real tmux) rc=0 ·
fleet specs 342 passed.

Refs #1241.
2026-08-16 00:19:25 -05:00
fred b61789fe26 fix(fleet): tell the operator when the fleet transport is missing (#1240)
ci/woodpecker/pr/ci Pipeline was successful
`mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every
roster the CLI scaffolds sets `transport: tmux`, but neither `tools/install.sh`
nor `tools/_scripts/mosaic-doctor` contained the string "tmux" at all. A
greenfield host therefore came out of the installer able to install a fleet,
start a fleet, and run no seat, with `mosaic fleet ps` as the operator's first
and only signal.

Measured on mosaic-sbx-dev (Debian, no tmux, framework installed): `mosaic-doctor`
reported 11 warnings and not one of them named the reason no seat could launch.

The installer gets a warning, not a `require_cmd` hard failure: tmux is required
by the fleet, not by mosaic. Hosts that install this to run `mosaic claude` and
never scaffold a roster are common, and failing their install over a binary they
do not need would be wrong. The check runs in `--check` mode too — "what is the
state of this host" is the question `--check` is asked.

Both checks read the roster's own `transport:` rather than assuming tmux, so a
host declaring something else is pointed at the binary it actually needs instead
of at the wrong package.

The two implementations are deliberately parallel and each carries a comment
pointing at the other. They are separate because the installer must answer this
before the framework's own scripts are guaranteed to be on disk. One harness
drives BOTH from the shipped text — the functions are extracted from the scripts
by awk rather than copied — so the pair cannot drift silently, and the test
cannot keep passing after the shipped copy changes.

The harness is wired into `test:framework-shell`. Without that it would have
tripped the #1017 enumeration guard as UNENUMERATED, which is the guard doing
its job: a check nothing runs is not a check.

Evidence:
- red: the harness fails against origin/next ("could not extract
  fleet_declared_transport"); `grep -ci tmux` on both files at origin/next = 0.
- green on real hosts, all four branches:
  - dev (no tmux, no roster)  -> WARN naming tmux, points at `mosaic fleet init`
  - dev (no tmux, v2 roster)  -> WARN naming the roster, points at `mosaic fleet start`
  - dev installer --check     -> WARN saying start "reports success and no seat comes up"
  - canary (tmux present)     -> `[OK] Fleet transport available: tmux` under --verbose,
                                 silent by default (pass() is verbose-gated), installer silent
- harness green on node:24-alpine/busybox, the CI base image.
- `bash -n` x3, `pnpm typecheck` 45/45, fleet specs 342 passed,
  enumeration guard OK, its self-test OK, prettier clean.

Refs #1240. Upstream of #1237/#1243 and #1241/#1244: a correct fix for either of
those still leaves this host with no live seat.
2026-08-16 00:15:50 -05:00
fred 61a907a12f fix(fleet): fail the agent launcher when the pane cannot survive (#1241)
ci/woodpecker/pr/ci Pipeline was successful
`mosaic fleet start` returned 0 over three dead panes. The launcher knew,
and said the wrong thing at the wrong severity to the wrong layer.

The pane runs `mosaic yolo <runtime>` under PANE_PATH with a cleared
environment. When that binary is absent the pane dies in under a second,
tmux destroys the session, and the diagnostic goes with it. The launcher
then found no PANE_PID, printed a WARNING about the *heartbeat sidecar*,
and exited 0 — so systemd logged "Finished ... successfully" and
`fleet start` reported success. `fleet ps` was the only component telling
the truth.

Two changes, both in start-agent-session.sh:

1. Before any effect, resolve `mosaic` and the roster's runtime against
   PANE_PATH — the pane's own view of the path, not the launcher's.
   `mosaic yolo <runtime>` calls checkRuntime(runtime) and looks for a
   binary named exactly like the runtime, so this asks the same question
   the pane will ask a moment later, while an operator can still see the
   answer. Absent binary -> exit 69, code=missing-binary, no session
   created.

2. Replace the dead-pane WARNING+exit-0. An absent session one second
   after new-session is a runtime that died on startup, not a heartbeat
   problem -> exit 69, code=pane-did-not-survive, with the command to run
   by hand to see why. A present session with no pane PID after five
   attempts -> code=pane-pid-unresolved. Neither branch kills the
   session; destroying a possibly-live pane on a guess is worse than
   leaving it for inspection.

Exit 69 (EX_UNAVAILABLE) is deliberate: the 64s already in this file mean
the projection was bad, and here the data is fine and the host is not
ready. Callers separate the cases by `code=`, the same way fail_env's
codes share 64.

This propagates for free. `fleet start` calls runChecked() for the holder
and each agent, and runChecked throws on non-zero, so layers 4 and 5 stop
lying without a TypeScript change. Two adjacent defects are left for a
follow-up issue rather than widened into this diff: the per-agent loop
aborts on the first failure instead of attempting all and reporting an
aggregate, and runChecked's bare throw surfaces the launcher's message
under a Node unhandled-rejection stack trace because program.parse() is
synchronous.

Tests:

- test-start-agent-session.sh gains three cases: `mosaic` absent from the
  pane path, the runtime absent from the pane path, and a pane that does
  not survive. Each was verified individually red against the unmodified
  origin/next launcher.
- The two cases asserting a valid launch now supply a pane PID. Until now
  the suite's one success path was itself a dead pane the launcher
  reported as fine.
- The harness fakes `npm` so PANE_PATH stops depending on whatever the
  host has installed, and fails loudly if the host provides `mosaic` or
  `pi` in the system path, where the missing-binary cases would not be
  measurable at all.
- test-fleet-units.sh gains a `pi` shim in its runtime bin. The real-tmux
  harness named `pi` in its roster and never installed it; the new
  preflight caught it.

Refs #1241
2026-08-15 23:56:53 -05:00
fred 6f5b4c3dc1 fix(fleet): restore ConditionPathExists dropped by my own red-check
ci/woodpecker/pr/ci Pipeline was successful
Self-inflicted and worth recording rather than quietly amending.

To prove the new tests were red without the fix I ran
`git checkout origin/next -- <fleet.ts> <[email protected]>`. That writes
the *index*, not just the working tree. Copying my versions back afterwards
restored the working tree only, so the unit file sat staged-as-origin/next and
modified-in-tree, and the next commit (67f5014c) committed the index — silently
removing the ConditionPathExists line that 463745e3 had added.

Nothing caught it. The spec reads the file from the working tree, so it stayed
10/10 green against a HEAD that no longer had the guard. Found by reading
`git status` after the push, not by any gate.

Verified by content, not by assumption:
  origin/next  0 occurrences
  463745e3     1
  67f5014c     0   <- the regression
  this commit  1

Refs #1237
2026-08-15 23:35:54 -05:00
fred 67f5014cc0 fix(fleet): refuse v2 add/remove cleanly, and pin the Condition's effect
Two follow-ups from the canary red->green run and scooby's review.

1. The v2 refusal in `add`/`remove` was a bare `throw`, which reaches the CLI
   top level uncaught and prints the guidance under a Node stack trace. The
   message *is* the point of the refusal, so it now goes through
   `command.error()` — the same clean path the roster-config error uses.
   Caught on canary, not in review: the unit tests asserted the message text
   and passed either way.

2. The unit-template test asserted only that ConditionPathExists is present.
   Presence is not effect. Added two tests for the parts that can drift in
   code while that assertion still passes: the condition resolving to exactly
   the file the fleet writes (%h/%i rendered against a real install), and the
   launcher genuinely failing on an absent generated env (exit 64,
   `missing-file`) — which is what makes the condition load-bearing rather
   than decorative.

systemd is not available in the suite, so the effect itself was measured on
canary (2026-08-16), roster v2 generation 3:

  with the condition:    start rc=0, Result=success, ConditionResult=no,
                         journal "skipped, unmet condition check"
  condition removed by
  drop-in, nothing else: start rc=1, Result=exit-code, ExecMainStatus=64,
                         unit failed, "agent environment rejected: missing-file"

Canary red->green for the three commands, same v2 roster, side by side:

  fleet ps               0.0.50-next.2413 rc=1  ->  branch rc=0 (3 agents listed)
  fleet install          0.0.50-next.2413 rc=1  ->  branch rc=0
  fleet remove <name>    0.0.50-next.2413 rc=1  ->  branch rc=1, refusal naming
                                                    delete + apply

All three previously failed with "Fleet roster has unknown field(s):
generation." The #791 negative was measured too: the six existing
*.env.generated files were untouched by `install` (mtimes 20+ minutes older
than the run).

Gates: typecheck 0, eslint 0, prettier clean, fleet specs 382 passed, new spec
10/10 with the fix and 9/10 red against origin/next (the 10th passes there for
an unrelated reason and is annotated as such). Full suite: only
mutator-gate.acceptance.spec.ts fails, pre-existing on origin/next.

Still true and still worth saying: a correct fix here shows install rc=0 and
start rc=0 and STILL no live seat. #1240 (tmux absent) is upstream, #1241
(start reports lifecycle-complete over dead panes) and the missing agent
runtime are downstream.

Refs #1237
Reviewed-by: scooby (by git comms; cannot file a Gitea review from fomo-lin)
2026-08-15 23:34:35 -05:00
fred 463745e314 fix(#1237): let ps/install work on a roster-v2 fleet, and refuse add/remove honestly
On a roster-v2 fleet, `ps`, `install`, `install-systemd`, `add` and `remove`
all failed in the v1 parser. The consequence was that a greenfield v2 box could
never get its unit templates placed, so nothing downstream could start.

The read-only commands get a narrow version-agnostic view of the roster
(version, socket name, holder session, and per agent name/alias/runtime).
This is deliberately not a v2 -> v1 downshift. A downshifted FleetRoster would
be accepted by generateAgentEnvValues, which would make a third writer of
fleet/agents/<name>.env.generated through the v1 mapping and break the #791
single-SSOT invariant that projectRosterV2AgentGeneratedEnv is documented to
hold. The view is too small to write a roster or an env file back from, so that
misuse is unavailable rather than merely discouraged.

So on a v2 roster `install` places the tool files and the unit templates,
enables the units, and writes no generated env at all. Env belongs to `apply`
and `regen`, both already v2-native.

That change alone would have traded an init-time failure for a boot-time one.
`install` enables mosaic-agent@<name>.service (WantedBy=default.target) without
starting it, so a reboot between `install` and the first `apply` would run
ExecStart against an absent env file and fail every seat unit, further from its
cause. The unit template now carries

  ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated

which skips an enabled-but-unconfigured unit cleanly and starts it on the next
start once the reconciler has written env. On v1 it is a no-op, since v1
`install` writes env itself. Found in review by scooby.

`add` and `remove` are not routed to `create` and `delete`. They are different
operations: the v1 pair edits the roster and drives systemd, the v2 pair is
documented as changing desired state without runtime actions. `add` also
collects four fields where a v2 agent requires eleven, so routing it would mean
inventing an operator's provider, alias, reasoning and tool policy. On v2 both
now fail with the real two-step sequence instead.

Tests: 8 new, 7 of which are red before this change. Includes the greenfield
case scooby asked for — `ps` on a fresh v2 install with nothing running is rc=0
and lists every agent stopped, since that is the command an operator runs to
find out why there is no seat.

Note for anyone verifying this: a correct fix here shows `install` rc=0 and
`start` rc=0 and still no live seat. #1240 (tmux absent) is upstream, #1241
(start reports lifecycle-complete over dead panes) and the missing agent
runtime are downstream. A dead pane after this change is not a regression here.

Refs #1237, #791, #1240, #1241
2026-08-15 23:24:24 -05:00
fredandClaude Opus 5 03eda02c20 fix(installer): warn on a failed credentials/ chmod instead of swallowing it
ci/woodpecker/pr/ci Pipeline was successful
scooby's review flag 1 on #1242. The other three chmods warn; this one was
`|| true`. It is the one directory holding secrets, so a chmod that fails
silently there is the failure most worth a line in the output.

Comment-and-warn only. No behaviour change on the success path.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1
2026-08-15 22:40:47 -05:00
fredandClaude Opus 5 3b4055017e fix(installer): pin umask and set the 0700 modes the fleet boundary requires (#1236)
ci/woodpecker/pr/ci Pipeline was canceled
A greenfield install cannot run `mosaic fleet init --write`. It fails with
`unsafe-permissions` on an unnamed `(directory)` and an unhandled Node throw,
and every mutating `mosaic fleet` command fails the same way. Measured on a
reverted-to-greenfield sandbox VM at CLI 0.0.50-next.2413: `~/.config/mosaic`,
`fleet/` and `credentials/` all land at 0775, and 1735 directories under the
framework root carry `mode & 022`.

Two independent causes, and fixing either one alone leaves it broken.

1. The installer inherited the caller's umask. Debian/Ubuntu ship 002, so every
   `mkdir -p` produced 0775. Fedora/RHEL ship 022 and produced 0755. The
   product therefore worked or did not depending on the operator's login shell,
   with nothing in the install output distinguishing the two. 022 is already
   what this script assumes it produces — `make_durable_snapshot` restores the
   ambient umask specifically so "every later sync copy and new framework dir"
   gets 0644/0755 — so pin it rather than inherit it.

2. Even at a correct 0755, three directories are rejected. The fleet code
   guards its managed paths with two masks in two languages:
   `assertPrivateManagedDirectory` (fleet-reconciler.js, `mode & 0o077`) covers
   MOSAIC_HOME and `fleet/` and runs before the roster lock is taken;
   `assert_private_directory` (tools/fleet/start-agent-session.sh, `mode & 077`)
   covers `fleet/agents` and runs before a pane is spawned. Their laxer
   siblings use `mode & 0o022` and accept 0755. The strict mask wins, so the
   installer states 0700 outright instead of hoping a umask implies it.

The `find -perm /022 -exec chmod go-w` sweep repairs a tree installed before
this change, which the umask alone cannot reach. It strips group/other WRITE
only — never read or execute — and is scoped to directories, so it corrects the
boundary violation without changing who may traverse or read anything. It is
not sufficient for `fleet/agents`: stripping write from 0755 yields 0750 and
`mode & 077` is still non-zero, which is why that path gets its own chmod.

Reported as #1236. The `fleet/agents` half was found by scooby reading
start-agent-session.sh; the umask framing is theirs too — my first report
blamed the distro rather than the umask.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1
2026-08-15 22:29:48 -05:00
fred 07373ede4d docs(install): record the two trust/portability assumptions in install_node
ci/woodpecker/pr/ci Pipeline was successful
Comment-only, no behaviour change. Both raised by scooby in the #1229 review
as non-blocking findings worth writing down rather than fixing here.

F-A: the SHASUMS256.txt check gives integrity, not authenticity. TLS to
$NODE_DIST_BASE is the whole trust root, and MOSAIC_NODE_DIST_BASE widens it
to any mirror with no signature backstop. GPG-verifying SHASUMS256.txt.sig is
filed as its own follow-up so it gets its own review.

F-C: the uname map pulls the glibc build, so musl hosts fail — visibly, via
node_is_suitable, not silently.
2026-08-15 21:50:14 -05:00
fredandClaude Opus 5 fb5bb98a32 Revert "fix(installer): re-link runtime assets after the CLI stage"
ci/woodpecker/pr/ci Pipeline was canceled
This reverts 47e90767. I was wrong: the fix is correct about the cause and
makes the outcome worse.

The acceptance run passed everything I set out to check — greenfield canary
1125, --next --yes, no TTY, rc=0, node v22.23.2 + CLI 0.0.50-next.2413 from a
fresh login shell, and both enforcement hooks wired in ~/.claude/settings.json
where before they were stripped. Then `mosaic doctor` on that same host:

  [ERROR] Lease-enforcement hooks (mutator-gate.py, receipt-observer-client.py)
  are wired in ~/.claude/settings.json, but broker not healthy
  (checkBrokerSupervisorHealth() reports unhealthy). Every gated tool call will
  fail closed and BRICK this agent (see #869).

So the change takes a greenfield host from 'enforcement quietly off, agent
works' to 'enforcement wired, broker absent, agent bricks on the first gated
tool call'. The pre-existing behaviour reaches the safe state for the wrong
reason; this reaches the unsafe state for the right one. Safe-for-the-wrong-
reason still wins.

The real defect is underneath both, and it is not an ordering bug:

  mosaic __link-claude-settings ...   -> rc=0  (leaseEnforcementActivatable:
                                                 activatable, wire the hooks)
  mosaic doctor                       -> ERROR (checkBrokerSupervisorHealth:
                                                 unhealthy, hooks will brick)

Two capability checks, same host, opposite verdicts. And after a complete
install there is no broker supervisor to be healthy: no systemd --user unit
matching lease/broker, nothing under ~/.mosaic but the bootstrapped node, and
no lease or broker script in ~/.config/mosaic/tools/_scripts/. Lease
enforcement cannot be activated on a greenfield host at all, so
leaseEnforcementActivatable() returning true is the thing that is wrong.

Filing that separately. PR #1229 goes back to exactly the four commits scooby
reviewed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-15 21:42:20 -05:00
fredandClaude Opus 5 47e90767b7 fix(installer): re-link runtime assets after the CLI stage, so greenfield keeps its enforcement hooks
ci/woodpecker/pr/ci Pipeline was canceled
The framework's install.sh ends by running mosaic-link-runtime-assets, which
asks the `mosaic` CLI whether lease enforcement can be activated before
deciding whether to wire the #828 hooks into settings.json. Part 1 (framework)
runs before Part 2 (npm CLI), so on a first install there is no CLI to ask. The
script takes its fail-safe branch, prints a four-line ERROR, and writes
settings.json with mutator-gate.py and receipt-observer-client.py stripped out.

Measured on canary 1125, rolled back to greenfield, `--next --yes`, no TTY:

  framework template ~/.config/mosaic/runtime/claude/settings.json
    mutator-gate.py            1 occurrence
    receipt-observer-client.py 1 occurrence
  installed ~/.claude/settings.json after a clean rc=0 install
    mutator-gate.py            wired: False
    receipt-observer-client.py wired: False

So enforcement ends up off because of the order the two halves install in, not
because of anything about the host. Falsified by running the same script by
hand once the CLI existed: rc=0, both hooks wired: True. The guard's real
verdict on that host was 'activatable' the whole time.

This adds one more pass after Part 2. The script is idempotent (unchanged files
are skipped), so on an upgrade — CLI already present, first pass already
correct — it is a no-op. It deliberately does not pass
--allow-inactive-enforcement: Part 1 does not either, and a repair pass must
not be more permissive than the pass it corrects.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-15 21:38:11 -05:00
fred 00bc602f93 fix(installer): persist the bootstrapped Node on PATH, and stop duplicating PATH lines
ci/woodpecker/pr/ci Pipeline was successful
Two defects found by the second unattended greenfield run on canary (VMID 1125,
rolled back to its greenfield snapshot first).

1. ensure_node() exported the Mosaic-managed Node for the installer process and
   nothing wrote it down. The install finished rc=0, put $PREFIX/bin in
   ~/.profile, and the next login shell found `mosaic` and then died on

       env: 'node': No such file or directory

   The CLI is a Node script, so a CLI on PATH without its runtime is a
   successful install that produces a broken command. persist_node_on_path()
   now writes the runtime's bin dir to the same profile, from both the
   fresh-install and the already-installed-but-not-on-PATH branches.

2. The 'is it already in a shell rc file' guard was a single
   `grep -qslF "$dir" "${rc_files[@]}"` over four paths, most of which do
   not exist on a clean host. Handing grep a missing file makes the exit status
   implementation-defined: GNU grep 3.11 returns 0 when -q already matched an
   earlier file, ugrep 7.5 returns 2 for the missing one regardless. On the 2
   path the caller reads 'not present yet' and appends another PATH line, so
   every re-install grew the profile. Measured: 3 runs produced 3 duplicate
   entries; with the fix, 1.

   path_entry_exists() now tests each file for existence and greps it on its
   own, so the result does not depend on the grep implementation.

The profile-writing body is factored into persist_on_path(), shared by the CLI
prefix and the Node runtime, since both now need identical treatment.

Verified in a scratch $HOME: fresh write, idempotent across three runs, zsh
routes to .zshenv, an unwritable profile warns and survives set -e, and an
already-on-PATH prefix is a no-op that creates no file. Falsified by restoring
the multi-file grep: duplicates return.
2026-08-15 16:07:56 -05:00
fred d0c223bdf9 fix(wizard): write PATH to .profile/.zshenv, never .bashrc
ci/woodpecker/pr/ci Pipeline was successful
getShellProfilePath() preferred ~/.bashrc when it existed, and ~/.zshrc for
zsh. setupPath() in stages/finalize.ts appends the PATH export to whatever
it returns. Debian's default ~/.bashrc opens with

    case $- in *i*) ;; *) return;; esac

so a line appended to the bottom of it never runs for 'bash -lc', for
systemd units, for 'ssh host cmd', or for any agent seat — precisely the
consumers that need the CLI. An install could print its summary and exit 0
while leaving 'mosaic: command not found'. .zshrc has the same problem:
zsh only reads it for interactive shells.

Now ~/.profile, which login shells read and which Debian's copy sources
.bashrc from for interactive shells, so one line covers both. For zsh the
always-sourced file is .zshenv. fish and PowerShell are unchanged.

__tests__/platform/detect.test.ts pins it, including a case asserting that
no shell resolves to an interactive-only rc file. Falsified by inverting
the fix: 5 failed / 1 passed; restored 6/6. Full package suite unchanged at
17 files / 4 tests failing, matching clean origin/next.
2026-08-15 15:54:10 -05:00
fred cc0d24d5c4 fix(installer): bootstrap Node.js on a greenfield host
tools/install.sh required node and npm and installed neither. Measured on a
snapshot-reverted Debian 13 image with no node, npm or git: the run stopped
at `require_cmd node` with "Required command not found: node", exit 1,
nothing installed, and no indication of how to proceed.

Adds ensure_node() to preflight. It fetches an official Node.js release into
$HOME/.mosaic/node, verifies it against that release's SHASUMS256.txt, and
refuses rather than degrades when the entry is missing or the checksum does
not match. sha256sum on Linux, shasum on macOS. .tar.gz over the smaller
.tar.xz because gzip is universally present and xz is not — a minimal image
is the case this exists to handle.

No-op when a suitable node is already on PATH, so it never fights an
operator's nvm/fnm/distro node. MOSAIC_SKIP_NODE_BOOTSTRAP=1 declines the
download and fails with instructions instead.

Inlined rather than factored into a sibling file because this script is
fetched standalone by curl and has nothing to source.
2026-08-15 15:54:09 -05:00
fred 40fecd4d38 fix(installer): put $PREFIX/bin on PATH instead of warning about it
The three duplicated PATH blocks in tools/install.sh only warned, so an
unattended install finished with rc=0 and left `mosaic: command not found`
— there was no operator to read the advice and act on it. Measured on a
greenfield Debian 13 sandbox: `--next --yes` installed
@mosaicstack/[email protected] successfully and the CLI was still
unreachable.

Replaces all three copies with one ensure_prefix_on_path helper that
appends the export to ~/.profile (~/.zshenv under zsh) and is a no-op when
the prefix is already on PATH or already in a shell profile.

Not ~/.bashrc: Debian's default .bashrc returns early for non-interactive
shells, so a line appended there is unreachable to `bash -lc`, systemd
units and agent seats — the consumers that need the CLI.
2026-08-15 15:47:30 -05:00
mos-dt-0 7a6fb024b4 docs: establish canonical documentation architecture (#1210)
ci/woodpecker/push/publish Pipeline failed
2026-08-13 17:56:13 +00:00
mos-dt-0 f82307c4dc fix(lease): raise capability-probe timeout to 10s on both halves (#869) (#1207)
ci/woodpecker/push/publish Pipeline failed
2026-08-13 17:28:00 +00:00
coder2andMos 7102ccb93e docs(tools): index pull request edit wrapper (#1200)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: coder2 <[email protected]>
2026-08-13 14:50:38 +00:00
Mos afdaa6d0e6 framework: make tool discoverability, workspace placement and model tiering mechanical (#1174)
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-08-13 14:21:22 +00:00
coder3andMos 41749bbd33 fix(framework): detect installed tool drift (#1194) (#1195)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: coder3 <[email protected]>
2026-08-13 10:43:11 +00:00
Mos 120af4e193 feat(git-tools): add pull request edit wrapper (#1080) (#1173)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-08-13 06:28:01 +00:00
shaggyandmos-dt-0 216cd72226 refactor(chat): route browser chat through one runtime (P3 Slice-Zero Task 5) (#1172)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: shaggy <[email protected]>
2026-08-12 20:11:12 +00:00
mos-dt-0 6a8ce66702 Merge pull request 'feat(lease): verified lease-remediation stack (rebased onto next) — promotion trigger + promote CLI + carve-out + TTL' (#1109) from feat/lease-promotion-and-harness-isolation into next
ci/woodpecker/push/publish Pipeline failed
2026-08-12 03:07:32 +00:00
jason.woltjeandMos 9cd9409089 P3 Slice Zero, Task 4 — replace Web free-text selection with the structured harness catalog (#1170)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: Jason Woltje <[email protected]>
2026-08-12 02:50:18 +00:00
Jason WoltjeandClaude Fable 5 13c70a7a10 test(mutator-gate): keep launch ledger out of the shipped framework tree
ci/woodpecker/pr/ci Pipeline was successful
runRuntimeLaunchEntry set MOSAIC_HOME to the shipped framework root, so
launch-runtime.py appended its launch ledger to
framework/fleet/run/sessions/events.ndjson — polluting the tree that
manifest.spec.ts walks and failing its completeness check in CI.

Point MOSAIC_HOME at the per-entry temp root instead. Nothing in the
launch chain resolves tools via MOSAIC_HOME (entry scripts resolve via
SCRIPT_DIR); the ledger is its only consumer here.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dtdjx4Gxude9fwyLezCrhh
2026-08-11 20:51:03 -05:00
dd6357e670 test(skill): install linker asserts harness-home link topology
The two install-linker-compatibility tests still asserted the pre-isolation
behavior (mosaic skill links planted in $HOME/.claude/skills). This branch
deliberately moved the link farm into the mosaic-owned harness homes
($MOSAIC_HOME/.claude/skills) and demoted the base-install dirs to
cleanup-only legacy targets, so the tests now assert the new topology:
the skill links appear under the harness home, foreign links in the legacy
dir are preserved, and no new mosaic link is planted in the base install.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dtdjx4Gxude9fwyLezCrhh
2026-08-11 20:51:03 -05:00
Jason Woltje 709a23d08c feat(mosaic): mechanically authorize lease promotion 2026-08-11 20:51:03 -05:00
Jason WoltjeandClaude Opus 4.8 239a2a93f1 test(lease): #1124 regression uses node pane command (real field topology per scooby)
The mosaic wrapper makes pane_current_command=node (RUNTIME_ACCEPTABLE_COMMANDS.claude=['claude','node']); the walk matters precisely in that no-shell-wrapper case. Match reality.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_013SAYFkRhQfhguY7AHfiUC8
2026-08-11 20:51:03 -05:00
Jason WoltjeandClaude Opus 4.8 ea1f058022 fix(lease): resolve lease session id from the claude child, not the tmux pane pid (#1124)
The launcher runs the runtime as a spawnSync CHILD of node(mosaic) (deliberate,
per launch.ts:99 — parent survives to propagate signals), so
MOSAIC_LEASE_SESSION_ID lives on the claude child, not the pane's root pid. The
transport read only pane.pid's /proc/environ and returned RESOLVE_FAILED for
every real 'mosaic claude' seat. Now BFS the pane's process subtree (bounded,
injectable children-reader) and read the first descendant that carries a valid
lease id; fail-closed if none. Unit tests now exercise the real walk (pane=node
without lease -> child=claude with lease) rather than mocking the resolution.

Found by scooby greenfield E2E on fomo-lin with proc-level evidence.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_013SAYFkRhQfhguY7AHfiUC8
2026-08-11 20:51:03 -05:00
Jason Woltje 1fde450ff1 test(lease): align mutator carve-out acceptance 2026-08-11 20:51:03 -05:00
Jason Woltje c136baa052 fix(mosaic): bound promotion transport delivery 2026-08-11 20:51:03 -05:00
Jason Woltje 4f7f6b3281 feat(mosaic): add correlated lease promotion CLI 2026-08-11 20:51:03 -05:00
Jason Woltje 77edb0dea2 feat(lease): add single-turn Claude promotion trigger 2026-08-11 20:51:03 -05:00
Jason WoltjeandClaude Fable 5 3676180ae8 fix(lease): raise lease TTL 300s -> 3600s
MAX_LEASE_TTL_SECONDS (daemon cap+default) and DEFAULT_TTL_SECONDS
(lease_promote client) both move to 3600. The 5-minute TTL made
gated-by-default sessions unusable (re-promotion mid-task); 1 hour
matches a working session. Full test:framework-shell RC=0.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013SAYFkRhQfhguY7AHfiUC8
2026-08-11 20:51:03 -05:00
Jason Woltje 0e938b66ed fix(lease): ignore benign observer idle replies 2026-08-11 20:51:03 -05:00
Jason Woltje f0fef26eb7 fix(lease): constrain read-only tool carve-outs 2026-08-11 20:51:03 -05:00
Jason Woltje c9bccd4aae test(lease): assert pi carve-out capability 2026-08-11 20:51:03 -05:00
Jason Woltje 8ef2e5b91d test(lease): distinguish pi probe timeouts 2026-08-11 20:51:03 -05:00
Jason Woltje 4cab6c09fe test(lease): enforce read-only tool invariant 2026-08-11 20:51:03 -05:00
Jason Woltje 239fc6d03c docs: measure pi tool registry 2026-08-11 20:51:03 -05:00
Jason WoltjeandClaude Fable 5 d085182dc1 test: close W-0R review findings — assert the omission notice, skip chmod simulations under root
The independent W-0R review of 3592b92e passed but left two PLAUSIBLE
findings: the stderr notice for a legitimately-omitted operator source was
claimed and never asserted (a silent omission is the original defect in
miniature), and the chmod 0o000 unreadable simulations fail spuriously when
euid==0 (CAP_DAC_OVERRIDE). Falsifier for the new assertion: deleting the
notice block turns the suite red (failures=3); restoring returns green.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EHYXhcCQsL3J1Lnm7EraGq
2026-08-11 20:51:03 -05:00
Jason WoltjeandClaude Opus 5 e949fa3767 fix(lease): refuse an incomplete law binding instead of silently shrinking it
build_construction skipped any normative source it could not read
(`except OSError: continue`) and promoted whatever remained. That is not a
degraded binding, it is a forged smaller one: the broker recomputes h_source /
h_payload from the fragments it is SENT (daemon.py:602-616), so an omitted
fragment is internally consistent and PAYLOAD_BINDING_MISMATCH cannot fire. A
partial law promotes exactly like a complete one and nothing downstream can tell
the difference.

Measured before this change, against a seeded home: with only USER.md readable,
the client produced a one-fragment construction with promotion=True. Removing
CONSTITUTION.md, STANDARDS.md or the runtime contract likewise promoted.

The classification mirrors the framework's own file ownership rather than
inventing one:

  * CONSTITUTION.md / AGENTS.md / STANDARDS.md are framework-owned and
    reconciled every upgrade (install.sh FRAMEWORK_OWNED,
    config/file-adapter.ts FRAMEWORK_OWNED_FILES), as is the per-runtime
    RUNTIME.md. Absent => IncompleteBinding. A deployment missing one is broken,
    not minimal.
  * SOUL.md / USER.md are deliberately not seeded by install.sh ("generated by
    `mosaic init`") and TOOLS.md is seeded on first install only, so their
    absence is legitimate. It is reported on stderr, never silent.

Unreadable is handled separately from absent for EVERY source, optional ones
included: a file that will not open is not a file that was never configured, and
collapsing the two is what let a permission change quietly shrink the law.

Also corrects this module's own docstring, which asserted that a VERIFIED lease
means "this agent is running THIS law". It does not. Both sides of the broker's
comparison originate in this client, so it detects corruption in transit and
nothing else. That overstatement is where the belief spread from; the stronger
claim needs the broker re-reading on-disk sources against a manifest the agent
cannot rewrite.

Test: promotion_binding_unittest.py, enumerated in test:framework-shell (the
enumeration guard's population is *test*.sh and does not cover Python, so an
unenumerated test here would simply never run). Falsifier executed: defeating the
guard while leaving the module API intact turns the suite red (12 failures);
restoring it returns green.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EHYXhcCQsL3J1Lnm7EraGq
2026-08-11 20:51:03 -05:00
Jason Woltje f1761c91be Revert "feat(pi): wire lazy lease promotion into the mutator gate"
This reverts 939f2e04. Keeping the revert rather than dropping the commit,
because the failed attempt is the most useful record on this branch.

The wiring worked mechanically — verified with a live model on sb-it-1-dt: the
receipt was emitted verbatim as a whole message, and the broker token was minted
AND consumed, so observe_receipt and promote_lease both succeeded and the lease
reached VERIFIED.

It failed as a DESIGN, for reasons that are properties of the protocol rather
than of this wiring:

  * It puts control-plane traffic in the user-facing conversation channel. An
    operator asking "what model are you?" received a receipt string instead of an
    answer — the model tried a tool, was blocked, complied with the receipt
    instruction, and in one-shot mode that text turn BECAME the reply. Observed
    twice, non-deterministically.
  * The lease TTL is hard-capped at 300s (MAX_LEASE_TTL_SECONDS; ttl_seconds >
    cap raises INVALID_LEASE_TTL). Measured: allowed at T+0, LEASE_EXPIRED at
    T+310. So the visible cost recurs every five minutes of mutator activity.
  * Model compliance is not guaranteed — one run retried the command instead of
    emitting the receipt.

Any model emission is user-visible, so this is not fixable by better wiring; it
needs a design answer about how promotion is triggered and paid for. That is
under adversarial review (docs/scratchpads/lease-remediation/07-liveness-design-brief.md
in the operator's repo). Promotion triggering will return on its own branch once
that lands.

What remains here is independently sound and unblocked: harness-home isolation,
the immutable launch record, the skills relocation, the promotion client itself
(steps 1/4/5), and the #1087 prefix guard.
2026-08-11 20:51:03 -05:00
Jason Woltje 8109f72cf7 fix(sync-skills): guard the pre-existing prune against an empty prefix (#1087)
prune_stale_links_in_target compared "$resolved" == "$canonical_real/"* while
length-checking only $resolved. If $canonical_real were ever empty the pattern
collapses to == "/"* and matches every absolute path.

The failure is precisely inverted, which is what makes it worth fixing rather
than noting: is_mosaic_skill_name already `continue`s for names that ARE current
mosaic skills, so an empty prefix would delete exactly the FOREIGN symlinks in
every target directory and preserve the mosaic ones. On this host that is 4 base
installs, including codex's own .system entry.

Reported by mos-claude as #1087 after I introduced the same guard in the new
legacy-cleanup path in the previous commit and walked past this instance thirty
lines away. Same defect class, same file, one function apart.

$canonical_real is populated by readlink -f after a mkdir -p, so an empty value
requires readlink to fail — unlikely, but the consequence is deleting operator
symlinks across every harness, which is not a risk worth carrying for one test.
2026-08-11 20:51:03 -05:00
Jason Woltje a0be592d84 feat(pi): wire lazy lease promotion into the mutator gate
Completes the promotion path: the client landed in the previous commit, but
nothing drove step 2 — the model emitting the receipt. This wires it.

LAZY, not at session start. Promotion costs an entire model turn, because the
receipt must be the whole message (hmac.compare_digest, "not a transcript
substring"). Minting at session start would collide with the Constitution's
first-response mode declaration — the two cannot share a message, so requiring
both would be unsatisfiable. Deferring to the first DENIED MUTATOR means the
mode declaration happens first and the receipt gets its own later turn, so no
governance change is needed. A read-only session never pays for promotion at all.

Mechanism: on a MUTATOR_UNVERIFIED denial the tool_call hook mints a challenge
and returns the receipt in the block `reason`, which pi feeds back to the model
as the tool result — the existing injection path already used by
lease-lifecycle.ts. The model emits the receipt as its next message, message_end
ships it to the observer, and the extension then calls observe_receipt +
promote_lease.

Only MUTATOR_UNVERIFIED triggers minting. Other denials (GATE_UNAVAILABLE,
STALE_GENERATION, LEASE_EXPIRED, ANCESTRY_MISMATCH) describe conditions a
receipt cannot fix, and begin_verification revokes before it mints, so minting
there would thrash the broker.

Completion is gated on an EXACT text match against the minted receipt. This is
load-bearing, not defensive: message_end also fires for the message that
CONTAINED the blocked tool call — one turn BEFORE the model answers. An earlier
version completed there, so observe_receipt compared against the wrong text,
failed, and burned the challenge before the model ever emitted it. Matching the
text mirrors the broker's own compare_digest semantics and waits for the right
turn. Confirmed by instrumenting message_end and watching it fire with
pending=yes one message too early.

It never posts the receipt itself. receipt-observer-client.py accepts any
string, so self-posting would satisfy the broker while proving nothing — the
whole point is that a live model echoes a challenge it was given.

Bounded by MAX_PROMOTION_ATTEMPTS: model compliance is not guaranteed (observed
a run where the model retried the command instead of emitting the receipt), so
a non-complying model degrades to today's behaviour — denied mutators — rather
than looping.

Verified with a live model on sb-it-1-dt: receipt emitted verbatim as a whole
message, and the broker token was minted AND consumed, i.e. observe_receipt and
promote_lease both succeeded and the lease reached VERIFIED.

Known limitation: under `pi -p`, the receipt is a text-only turn, which ends the
one-shot loop — so promotion completes but the blocked tool is not retried in
that same invocation. Interactive and durable fleet sessions continue and retry
normally.
2026-08-11 20:51:03 -05:00
Jason Woltje f4a24b693e feat(lease-broker): add the missing promotion client
The enforcement half of the lease broker ships and denies; the promotion half
has no production caller anywhere in the package. Verified across 0.0.48, 0.0.49
and 0.0.50-next.2207: begin_verification / observe_receipt / promote_lease are
invoked only by broker-test-client.ts, the acceptance spec, unit tests, and two
probes under docs/.

Consequence: no lease on any host can reach VERIFIED, so mutator-gate denies
every mutator with MUTATOR_UNVERIFIED via a gate that nothing shipped can
satisfy. Runtimes that enforce the gate in-process (pi, via mosaic-extension's
tool_call hook) are bricked for mutators; runtimes whose gate is wired through a
settings hook escape only when that hook is absent — i.e. by being ungated.

This adds the client. It implements protocol steps 1, 4 and 5:

  1. begin_verification  -> mint a challenge, return the exact receipt text
  2. the MODEL emits that text verbatim as its entire latest message
  3. the runtime adapter ships that message to the observer socket
  4. observe_receipt      -> PENDING_PROMOTION
  5. promote_lease        -> VERIFIED

Step 2 is deliberately NOT implemented here, and that is the point.
is_verbatim_receipt uses hmac.compare_digest against the exact minted string —
explicitly "not a transcript substring" — which makes promotion a LIVENESS
PROOF: it requires a live model that received the challenge in its context and
echoed it exactly.

receipt-observer-client.py will post ANY string as the latest assistant message.
A promotion client that posted its own receipt would satisfy the broker while
proving nothing — a gate-disabler indistinguishable from a working fix unless
someone specifically looks. Emitting the receipt therefore belongs to the runtime
adapter, where a real model turn happens. A local diagnostic that posts its own
receipt exists in the operator's repo and is deliberately NOT shipped here.

The construction binds the exact normative source bytes, so a VERIFIED lease
means "this agent is running THIS law", not merely "this session id is known".
h_source/h_payload are derived by importing the framework's own
normative_fragments.build_payload rather than reimplementing it: the broker
derives them the same way and any divergence yields PAYLOAD_BINDING_MISMATCH.
There must be exactly one implementation.

session_identity() prefers the generation FILE over the env var, matching
lease_generation.py. Sending a generation higher than the broker's would revoke
the session's own authority (daemon.py:342-344), so it never guesses.

Verified end-to-end on sb-it-1-dt under a real lease-gated anchor: a mutator
denied rc=2 MUTATOR_UNVERIFIED, then begin -> observe -> promote -> VERIFIED,
then the same mutator allowed rc=0. Negative controls pass: a fresh session is
still denied, and an unrelated session still reads UNVERIFIED — promotion is
per-session and does not leak.

Still open: adapter wiring for step 2. Lazy promotion on first mutator attempt
avoids colliding with the Constitution's first-response mode declaration, since
compare_digest requires the receipt to be the WHOLE message.
2026-08-11 20:51:03 -05:00
Jason Woltje e4dffb7c18 feat(launch): isolate harness homes and record immutable launch provenance
Mosaic wrote into the operator's harness base installs — ~/.claude,
~/.pi/agent, ~/.codex, ~/.config/opencode — for settings, instructions, and a
102-symlink skill farm per harness. Any experiment with hooks or gating
therefore mutated the operator's own tooling, and a broken framework change
could take out the very harness needed to repair it.

Harness home isolation
----------------------
Each runtime now reads config from a dedicated mosaic-owned home via the
harness's own config-dir variable:

  claude    CLAUDE_CONFIG_DIR     ~/.config/mosaic/.claude
  pi        PI_CODING_AGENT_DIR   ~/.config/mosaic/.pi     (replaces ~/.pi/agent)
  codex     CODEX_HOME            ~/.config/mosaic/.codex
  opencode  XDG_CONFIG_HOME       ~/.config/mosaic/.opencode

These paths are manifest-UNKNOWN, so rule 3 (#791) resolves them to operator
ownership and a keep-mode upgrade can neither overwrite nor prune them.
A bare `claude` / `pi` keeps its own config AND auth, making it a structural
break-glass rather than one depending on restoring a file under pressure.

opencode is blunter than the rest: it has no dedicated variable and follows XDG,
so isolation also relocates XDG lookups for anything it spawns. Documented in
place.

mosaic-sync-skills now links into those homes and cleans the legacy farms it
previously planted in base installs. Ownership is proven by RESOLUTION, not by
name — only symlinks resolving inside the canonical/local skills dirs are
removed, mirroring the refusal already in commands/skill.js. Verified against a
real install: codex's own .system directory survived while its 102 mosaic links
were removed. Both resolution prefixes are length-checked first; an empty prefix
would make "$resolved" == "$prefix/"* match every absolute path and delete
foreign symlinks.

Immutable launch record
-----------------------
Every launch now appends one record to fleet/run/sessions/events.ndjson before
exec. Mandatory, mechanical, no model involvement.

pi rewrites its own argv to a bare `pi`, so /proc/<pid>/cmdline destroys the
launch evidence — that has already produced a confident wrong diagnosis ("this
agent bypassed the launcher"), disproved only by the parent's argv and only
because the parent had not yet exited. A record written before exec is the only
place this survives.

The path is the #797 Runtime Session Ledger, already operator-classified and
already covered by test-upgrade-manifest-guard.sh, which seeds it and proves a
populated ledger survives keep-mode upgrades — but nothing shipped ever wrote
it. This implements it in the shape that guard already asserts (0600 files under
a 0700 dir).

`mosaic` writes session.launch; launch-runtime.py appends lease.register with
the broker session id and activation capability. They correlate by an explicit
MOSAIC_LAUNCH_ID, never by pid: execRuntime uses spawnSync, so the runtime is a
child with a different pid.

Records normative fragment digests (CONSTITUTION/AGENTS/SOUL/USER/STANDARDS/
TOOLS/RUNTIME) — the same set the broker hashes for promotion, so drift is
mechanically detectable rather than a matter of judgement.

Credential-safe: env is captured as PRESENT NAMES ONLY, and argv values over
256 bytes become a sha256 + length rather than being inlined.

Also fixes CLI_VERSION resolution: '@mosaicstack/mosaic/package.json' is not in
the package exports map and always throws ERR_PACKAGE_PATH_NOT_EXPORTED.
resolveTool() uses that same failing specifier, which is why its documented
preference for bundled tools over the deployed ~/.config/mosaic copy has never
once applied — noted in place, not fixed here.

Verified on sb-it-1-dt: isolated homes written and base installs byte-identical
for all four harnesses; 408 legacy symlinks removed with 1 foreign entry
preserved; launch records paired across the spawn boundary. typecheck shows zero
errors in launch.ts (the @mosaicstack/types failures are pre-existing and
reproduce on a pristine origin/main worktree).
2026-08-11 20:51:03 -05:00
be-coder-08andJason Woltje f840843908 feat(pr-merge): preserve linked authors in squash messages (#1066)
Co-authored-by: be-coder-08 <[email protected]>
2026-08-11 20:51:03 -05:00
be-coder-08andJason Woltje aacb11b0b9 fix(ci): remove upgrade rollback signal race (#1060)
Co-authored-by: be-coder-08 <[email protected]>
2026-08-11 20:51:03 -05:00
be-coder-08andJason Woltje ce6bda18f2 test(ci): make queue guard harness deterministic (#1062)
Co-authored-by: be-coder-08 <[email protected]>
2026-08-11 20:51:03 -05:00
mos-dt-0 aca28405be Merge pull request 'ci: provision Pi runtime 0.84.1 in the test step (Invariant R)' (#1164) from ci/provision-pi-runtime into next
ci/woodpecker/push/publish Pipeline failed
2026-08-12 01:50:56 +00:00
mos-dt-0 c1eb0659c4 Merge pull request 'docs(framework): adopt MOS-STE writing standard onto next (from #965)' (#1165) from adopt/965-mos-ste-writing-standard into next
ci/woodpecker/push/publish Pipeline failed
2026-08-12 01:18:29 +00:00
Jason WoltjeandClaude Fable 5 b79708fdc7 ci: provision Pi runtime 0.84.1 in the test step (Invariant R)
ci/woodpecker/pr/ci Pipeline was successful
invariant_r_unittest.py (landing with the lease-remediation stack, PR
#1109) hard-requires an installed `pi` binary pinned to the measured
version: it boots Pi's real tool registry and proves the broker's
read-only carve-out resolves to real, unshadowed builtins. Absent
runtime fails loud by design — so CI must provide it.

Install @earendil-works/[email protected].1 (the canonical Pi;
@mariozechner/* is embedded-legacy) at step level in the test step.
Step-level rather than baked into Dockerfile.ci because ci-image
publishes are currently blocked on registry UNAUTHORIZED; baking it in
is the follow-up once registry auth is fixed, at which point this line
degrades to a fast no-op guard like the openssl line above it.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dtdjx4Gxude9fwyLezCrhh
2026-08-11 20:16:32 -05:00
jason.woltje ebe415132e Merge pull request 'feat(gateway): generic harness catalog + selection HTTP surfaces (P3 Slice Zero, Task 3)' (#1169) from feat/p3-slice0-task3-catalog-selection into next
ci/woodpecker/push/publish Pipeline was canceled
2026-08-12 01:11:21 +00:00
mos-dt-0 ec260e678f Merge pull request 'fix(git): accept http/https as one scheme class in comment URL verification (#991)' (#1022) from fix/991-comment-url-scheme-normalise into main
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-08-12 01:11:09 +00:00
jason.woltjeandClaude Opus 4.8 f16f206a0a feat(gateway): expose generic harness catalog and selection
ci/woodpecker/pr/ci Pipeline failed
Add the Slice-Zero catalog and selection HTTP surfaces for P3 Task 3:
GET /api/harnesses, GET /api/harnesses/:harnessId/catalog,
GET+PUT /api/chat/preferences/selection. Scope is always server-derived
via scopeFromUser(CurrentUser); selection tuples are validated against the
live catalog with no fallback substitution and persisted in a transitional
owner-scoped in-memory store. HarnessModule is wired into AppModule.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-11 19:56:11 -05:00
jason.woltje a186922e3a Merge pull request 'feat(gateway): harness registry, fake adapter, no-substitution suite (P3 Slice Zero, Task 2)' (#1168) from feat/p3-slice0-task2-harness-registry into next
ci/woodpecker/push/publish Pipeline failed
2026-08-12 00:40:03 +00:00
jason.woltje 43513c28f7 feat(gateway): add harness registry and fake adapter
ci/woodpecker/pr/ci Pipeline failed
2026-08-11 19:22:27 -05:00
Jason Woltjeandmos-dt-0 b6c12bdfcb style(framework): apply prettier to WRITING-STYLE.md so CI format passes (#965)
ci/woodpecker/pr/ci Pipeline was successful
The `format` step of .woodpecker/ci.yml:89 (`pnpm format:check`) failed on
pipeline 2111 for this branch. Reproduced on a bench with the lockfile-pinned
[email protected] against the repo .prettierrc and .prettierignore, using CI's
exact glob: WRITING-STYLE.md was the only failing file.

The change is mechanical and semantically null: markdown table cell padding
and `*emphasis*` -> `_emphasis_`. Verified by normalizing both revisions
(whitespace removed, `_`/`*` folded, table rules collapsed) — the results are
byte-identical.

This does not address the prose findings published on #965 (P1-P4); those
await a ruling. The `test` step also failed on 2111, on a base ~40 commits
stale — attribution for that failure needs this rerun, and is not claimed here.

Co-authored-by: mos-dt-0 <[email protected]>
2026-08-11 19:00:46 -05:00
Jason Woltje b590a5c3d8 fix(git): accept http/https as one scheme class in comment URL verification (#991)
ci/woodpecker/pr/ci Pipeline was successful
issue-comment.sh and pr-review.sh verify a durable write by pinning the
provider-returned object URL's origin and full path. The origin included the
SCHEME verbatim. On a Gitea whose ROOT_URL is configured `http://` while every
client reaches it over `https://`, the provider returns `http://` object URLs,
so the comparison rejects the provider's own truthful answer about a write that
LANDED. The failure is deterministic, not intermittent: every comment, every
time, on such a deployment.

The scheme was never what the check defends. The forgeries it exists to catch —
look-alike host, decoy path prefix, wrong owner/repo/kind/number — all vary the
HOST or the PATH. Both stay strict. `http` and `https` now collapse to one
scheme class; any other scheme (file:, ftp:, javascript:) stays distinguishing,
and an EXPLICIT non-default port still distinguishes, because a different port
is a different service on the same host.

Consequences of the bug, both observed:

- The wrapper reports failure on a comment that is durably on the issue/PR, and
  attributes it to #865 ("no durable comment created"). The write landed; the
  citation is wrong. Reproduced here: the harness's persisted state contains the
  record while the wrapper exits 1.
- pr-review.sh's comment path is worse. On a host where no seat can create a
  review OBJECT, comment-form is the only gate-16 review record obtainable, and
  this check refuses all of it.

Test gap this closes: every URL fixture in both harnesses was `https://`, and
every negative case varied only host or path. The one axis that fails in
production had zero coverage — the fixtures encoded the assumption that breaks.
Added, in both suites:

- scheme-downgrade (http vs https, otherwise correct) — must be ACCEPTED. Fails
  against the unmodified wrappers, passes against the fixed ones; verified in
  both directions, and the negative control's captured output is the #865
  misattribution above.
- explicit non-default port (`:8443`) — must stay REJECTED.
- non-web scheme (`ftp://`) — must stay REJECTED.

Also fixes test-issue-comment-readback.sh hermeticity (#1007), without which the
suite cannot run on any seat that has a per-agent Gitea token: detect-platform's
step-0 identity lookup reads ~/.config/mosaic/gitea-tokens/<identity>, outside
both XDG_CONFIG_HOME and MOSAIC_CREDENTIALS_FILE, so the suite resolved a
PRODUCTION credential and died at HTTP 401 before case 1. Same two-part fix
already merged for test-pr-review-gitea-comment.sh in #1006: a sandboxed HOME
plus an empty REPO-LOCAL mosaic.gitIdentity to shadow the global. Note the
env-var route does NOT work — detect-platform.sh reads `${MOSAIC_GIT_IDENTITY:-}`
and `:-` treats set-but-empty identically to unset.

The owner-side half of #991 (setting the deployment's Gitea ROOT_URL to https)
is not in scope here and is not made unnecessary by this change; this makes the
wrappers correct against a deployment that returns either scheme.
2026-08-11 19:00:31 -05:00
jason.woltje fb9f9cda5a Merge pull request 'fix(gateway): resolve InteractionCoordinationService handoff factory via optional DI token (#1145)' (#1167) from fix/1145-coord-di-compiled-boot into next
ci/woodpecker/push/publish Pipeline failed
2026-08-11 23:57:43 +00:00
jason.woltje 400a21ca18 Merge pull request 'feat(types): generic harness contracts (P3 Slice Zero, Task 1)' (#1166) from feat/p3-slice0-task1-harness-contracts into next
ci/woodpecker/push/publish Pipeline is pending
2026-08-11 23:51:50 +00:00
shaggy (mosaic-dev box) 4cefa5cd88 fix(gateway): resolve InteractionCoordinationService handoff factory via optional DI token (#1145)
ci/woodpecker/pr/ci Pipeline failed
root cause: emitDecoratorMetadata reflected the third constructor parameter as Function and Nest attempted to resolve it

fix: optional HANDOFF_ID_FACTORY injection token, no production provider, preserving undefined -> crypto.randomUUID() default and unchanged positional construction

TDD: real CoordModule red at Function index [2], then green; test overrides only unrelated AuthGuard because its AUTH provider comes from AppModule's global AuthModule context

Closes #1145
2026-08-11 18:37:54 -05:00
shaggy (mosaic-dev box) ddf8616716 feat(types): add generic harness contracts
ci/woodpecker/pr/ci Pipeline was canceled
2026-08-11 18:32:39 -05:00
30a694358d fix(framework): key §5 lookup on rendered bullets, not the token (mos-dt round-2)
§5 sent the agent to read direct|friendly|formal in USER.md, but the builder
renders prose bullets, not the token — the documented lookup could not key on
the shipped file. Table now keys on the leading bullet USER.md actually
contains. Also: 'concise, technical' -> 'concise, structured' (drop the round-1
residual value name from a rule-9 guide). Docs-only, no code, no scope growth.

Written-by: jarvis (dragon-lin)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-11 18:22:15 -05:00
e01dfa0cd7 fix(framework): ride the existing communicationStyle enum, drop the no-op USER.md edit (mos-dt review #960)
F1: defaults/USER.md is never installed (generated from templates/USER.md.template
via buildCommunicationPrefs). Editing it was a no-op asserting a phantom setting —
exactly the false-green §2 warns against. Reverted.
F2: the framework already has communicationStyle (direct|friendly|formal). §5 now
maps THOSE values to output instead of inventing technical|prose|brief (rule 9).
Minor: §6 states no mechanical prose check exists today; rule 1 points at §3.4.

Written-by: jarvis (dragon-lin)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-11 18:22:15 -05:00
6c4a2eb626 feat(framework): MOS-STE writing standard + Google-style code + per-user comms choice
Adds the agent output standard to the framework SOT so it injects at launch and
is selectable per user (closes the gap: it lived only as a jarvis-brain lab doc + issue #960).

- guides/WRITING-STYLE.md: MOS-STE (adapted ASD-STE100) for docs, Google Style for code,
  verification-artifact emphasis, absolute user-voice carve-out. Written in MOS-STE.
- defaults/STANDARDS.md: Output-standards block (always injected via the prompting contract).
- defaults/AGENTS.md: routing row so writing/doc/comms work reaches the guide.
- defaults/USER.md: per-user 'Comms style' option (technical|prose|brief), default technical.

Refs mosaicstack/stack#960. Owner directive (Jason, 2026-07-30): docs->adapted ASD-STE100,
code->Google style, resumes/personal carved out, comms style a per-user choice.

Written-by: jarvis (dragon-lin)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-11 18:22:15 -05:00
mos-dt-0 540ec5b6ef Merge pull request 'fix(git): #1007 suite hermeticity — pin repo-local mosaic.gitIdentity in five test suites' (#1024) from fix/1007-suite-hermeticity into main
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
2026-08-11 23:19:49 +00:00
mos-dt-0 563d1ac053 Merge pull request 'fix(shell): remove wake validation pipe hazards' (#1107) from fix/1099-pipefail-wake into main
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-08-11 23:19:45 +00:00
Mos 4d8ddb9a0a fix: quote SKILL.md descriptions containing colons (silent skill-load failure) (#3) 2026-08-11 22:53:47 +00:00
mos-dt-0 9185b0cce4 Merge pull request 'docs(webui): Phase P structure & migration map' (#1147) from docs/webui-phase-p-structure into next 2026-08-11 22:29:10 +00:00
mos-dt-0andClaude Fable 5 8925a502ae style(webui): prettier-format PHASE-P-STRUCTURE.md
ci/woodpecker/pr/ci Pipeline was successful
format:check was the only red CI step on #1147.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dtdjx4Gxude9fwyLezCrhh
2026-08-11 17:14:38 -05:00
mos-dt-0 0e4eb1445c Merge pull request 'fix(installer): propagate wizard gateway failures' (#1134) from fix/wizard-gateway-failure into next
ci/woodpecker/push/publish Pipeline failed
2026-08-11 22:01:43 +00:00
mos-dt-0 592d60425f Merge pull request 'fix(installer): require Node 22 for the --next lane' (#1130) from fix/next-node-gate into next
ci/woodpecker/push/publish Pipeline was canceled
2026-08-11 22:01:39 +00:00
mos-dt-0 a43f343efd Merge pull request 'fix(security): mosaic-init RCE — eval on prompt answers → printf -v (#1115)' (#1127) from fix/mosaic-init-rce into next
ci/woodpecker/push/publish Pipeline was canceled
2026-08-11 22:01:35 +00:00
jason.woltje 88ef9d4fa5 Merge pull request 'P3-R1: repair routing health-enum (#1) + wire /mcp command (#5)' (#1154) from feat/webui-p3r1-routing-mcp into next
ci/woodpecker/push/publish Pipeline failed
2026-08-11 20:51:52 +00:00
shaggy (mosaic-dev box) bda308efd9 fix(gateway): repair routing health and MCP command wiring
ci/woodpecker/pr/ci Pipeline was successful
2026-08-11 15:28:57 -05:00
jason.woltje 20718b5a27 Merge pull request 'P4-1: read-only Projects + Tasks SPA pages + dev-port pins' (#1153) from feat/webui-p4-1 into next
ci/woodpecker/push/publish Pipeline failed
2026-08-11 04:00:36 +00:00
shaggy (mosaic-dev box) 29db24210c fix(web): P4-1 restore graceful degradation for missions/tasks fetch on project detail
ci/woodpecker/pr/ci Pipeline was successful
2026-08-10 22:43:47 -05:00
shaggy (mosaic-dev box)andClaude Haiku 4.5 a6085eea37 feat(web): add read-only project and task SPA pages
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
2026-08-10 22:23:59 -05:00
Mos e00cc475a2 Merge pull request 'P3 — Typed SPA chat (Phase P webUI)' (#1151) from feat/webui-p3-chat into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 22:57:07 +00:00
mos-dt-0 722163671f feat(pi): add persistent Mosaic /goal controller (#1152)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-08-10 22:54:43 +00:00
shaggy (mosaic-dev box) 7d84e4ee03 fix(gateway): sanitize raw exceptions in /mcp status + /reload sources (P3 re-review#5 blocker)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-10 16:58:07 -05:00
shaggy (mosaic-dev box) 4aaf41dd1a fix(web,gateway): close P3 re-review#4 findings — sanitize all executor catches; lock pre-start turn boundary (wire turnId deferred) 2026-08-10 16:07:12 -05:00
mos-dt-0 bf32f29acd fix(ci): make queue guard purpose-sensitive (#1148)
ci/woodpecker/push/publish Pipeline failed
2026-08-10 20:54:09 +00:00
Jason Woltje 1655b1579a Move deploy and briefs into docs folder. Remove old files.
ci/woodpecker/push/publish Pipeline was canceled
2026-08-10 14:44:19 -05:00
Jason Woltje e478a359eb Move briefs directory inside the docs
ci/woodpecker/push/publish Pipeline was canceled
2026-08-10 14:42:04 -05:00
Jason Woltje 76e4242cb1 Reformat Requirements block
ci/woodpecker/push/publish Pipeline was canceled
2026-08-10 14:40:18 -05:00
shaggy (mosaic-dev box)andClaude Opus 4.8 a45f53071a docs(webui): add Phase P structure & migration map
ci/woodpecker/pr/ci Pipeline failed
First-pass structural reference for the Vite SPA migration (apps/web): dual-app
tree during migration, shared lib/ networking layer, origin-relative/same-origin
serving model, build scripts, the P1-P6 increment map, and the #1145 P5 blocker.
Living doc — details to be fleshed out by follow-up.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-10 14:34:57 -05:00
Jason Woltje 00eb216480 docs(agents): consolidate project guidance 2026-08-10 14:27:59 -05:00
shaggy (mosaic-dev box) d46a2d675a fix(web,gateway): close P3 re-review#3 findings — sanitize command errors, harden turn-lock & caps 2026-08-10 14:21:52 -05:00
shaggy 8c27024d0e Merge pull request 'fix(gateway): gate FederationModule on tier === 'federated' (#1138)' (#1140) from fix/1138-conditional-federation into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 07:09:51 +00:00
shaggy (mosaic-dev box) 48bb19310d fix(web): close P3 chat re-review findings 2026-08-10 01:58:27 -05:00
shaggy (mosaic-dev box) 406e40584d test(gateway): raise module-graph import timeout for CI load robustness (#1138)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-10 01:34:24 -05:00
shaggy (mosaic-dev box) 677aeb0c93 fix(gateway): restore daemon config discovery (#1138)
ci/woodpecker/pr/ci Pipeline failed
2026-08-10 00:31:58 -05:00
shaggy (mosaic-dev box)andClaude Haiku 4.5 caebf9ef70 fix(web): harden typed SPA chat lifecycle
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
2026-08-10 00:24:20 -05:00
shaggy (mosaic-dev box)andClaude Haiku 4.5 bd0ef2ab25 fix(gateway): anchor remaining config loads (#1138)
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
2026-08-09 23:54:53 -05:00
shaggy (mosaic-dev box) 2d5a8c81ec fix(gateway): anchor config discovery and isolate env tests (#1138) 2026-08-09 23:11:09 -05:00
shaggy (mosaic-dev box) 884d527cc8 fix(gateway): anchor dotenv discovery to module (#1138) 2026-08-09 22:21:17 -05:00
shaggy (mosaic-dev box) b2e005f2b4 feat(web): add typed SPA chat
Bring the chat experience into the Vite/React-Router SPA on the exact typed
Socket.IO /chat contract from @mosaicstack/types, replacing the /chat
placeholder behind AuthGuard. Surfaces message:ack (with an accessible
status), agent:start, streamed agent:text/agent:thinking, tool start/end
status, agent:end with usage, session:info (thinking controls + routing
decision), commands:manifest, command:result, command:approval (with a
one-time approved-run affordance), system:reload (refreshing the rendered
manifest), and error, and emits message/abort/set:thinking/command:execute/
command:approve with exact payloads.

The gateway does not guarantee message:ack is the first event for a new
conversation (session:info, and error on auth/session-creation failure, can
both arrive first) — conversation-scoped events now adopt the conversation
from whichever scoped event names it first while a send is pending, then
filter everything else against that established conversation. A typed error
stops streaming instead of leaving Stop stuck active; agent:end no longer
appends an empty assistant turn when there is no text or thinking; and a
second message can no longer be sent while a turn is streaming.

Command approval is now integrity-checked end to end: only one
command:approve request may be outstanding at a time (a concurrent request
is ignored rather than overwriting the pending command/args), a stale or
mismatched command:approval response cannot replace active approval state,
and running an approved command clears its approval state immediately (via
a ref, before React re-renders) so a double-click cannot replay
command:execute.

The `/chat` socket is now typed at a single boundary: apps/web/src/lib/
socket.ts narrows socket.io-client's untyped `io()` return value to
`ChatSocket` (Socket<ServerToClientEvents, ClientToServerEvents>) once, at
creation, via the one assertion the library's types force; every consumer
(use-chat-connection.ts) then gets fully checked `on`/`emit` calls with no
further casts. The shared contract types live in the new
apps/web/src/lib/chat-contract.ts (replacing the old spa/chat/types.ts
shim), which re-exports them via type-only imports resolved directly
against packages/types/src (apps/web has no @mosaicstack/types package
dependency, so this stays source-only and is erased at compile time —
no package manifest or lockfile is touched). The two recorded-event test
suites now drive a shared, typed fake socket
(spa/chat/test-support/fake-chat-socket.ts) instead of an untyped
`(event: string, payload: unknown)` harness, so a wrong event name or
malformed payload fails to compile.
2026-08-09 21:39:16 -05:00
shaggy 87daa12976 Merge pull request 'P2 — web SPA data layer + same-origin auth' (#1144) from feat/webui-p2-data-auth into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 01:52:01 +00:00
shaggy (mosaic-dev box) b82a51da80 fix(gateway): load dotenv before federation tier gate (#1138) 2026-08-09 20:50:37 -05:00
shaggy (mosaic-dev box) 90cf286a09 fix(web): reject protocol-relative auth callbacks
ci/woodpecker/pr/ci Pipeline was successful
2026-08-09 20:43:27 -05:00
shaggy (mosaic-dev box) 0aef432052 docs(scratchpad): record P2 remediation evidence 2026-08-09 20:21:53 -05:00
shaggy 41a16cc916 Merge pull request 'fix(docker): gateway image — git in runner, MOSAIC_ROOT workspace dir, scripts/ in builder, EXPOSE 14242' (#1142) from fix/gateway-runner-image into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 01:21:30 +00:00
shaggy (mosaic-dev box) e16c08aa9f test(web): align jsdom abort signals with Node 2026-08-09 20:20:30 -05:00
shaggy (mosaic-dev box) a34e92cf39 fix(gateway): harden workspace repository cloning
ci/woodpecker/pr/ci Pipeline was successful
2026-08-09 20:12:39 -05:00
shaggy (mosaic-dev box) a4861c221f docs(scratchpad): record WebUI P2 verification 2026-08-09 20:02:22 -05:00
shaggy (mosaic-dev box) 46d68e1ff4 feat(web): add same-origin SPA authentication 2026-08-09 20:00:22 -05:00
shaggy c3496334a5 Merge pull request 'feat(web): P1 — Vite + React Router skeleton beside Next (Phase P RFC, increment 1/6)' (#1143) from feat/webui-p1-vite-skeleton into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 00:51:02 +00:00
shaggy 6f29d00149 Merge pull request 'fix: break-C — install-hooks no-ops without git; web image builds @mosaicstack/web' (#1141) from fix/break-c-hooks-and-web-image into next
ci/woodpecker/push/publish Pipeline was canceled
2026-08-10 00:50:19 +00:00
shaggy (mosaic-dev box)andClaude Fable 5 068d0f9b1c feat(web): P1 Vite skeleton beside Next — entry, router, guards, vitest 3
ci/woodpecker/pr/ci Pipeline was successful
First increment of the approved Phase P RFC (webui-mission). Adds a Vite + React
Router SPA scaffold coexisting with the Next app: index.html with the theme
anti-flash script, src/main.tsx entry, the v1 parity route table under Guest/Auth
guard shells, and a dev proxy (/api, /socket.io ws) to the gateway on 14242 so the
SPA is same-origin in dev. vitest bumped to v3 (vite 8 pairing); existing specs
pass unchanged. Next remains the served app until the P5 cutover.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 19:02:55 -05:00
shaggy (mosaic-dev box)andClaude Fable 5 13cd673d50 fix(docker): gateway runner needs git + MOSAIC_ROOT workspace dir; EXPOSE actual port 14242
ci/woodpecker/pr/ci Pipeline was successful
WorkspaceService shells out to git at runtime and roots workspaces at
$MOSAIC_ROOT/.workspaces — the runner image had no git binary and no
workspace directory. EXPOSE said 4000 but main.ts defaults to 14242.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 18:03:42 -05:00
shaggy (mosaic-dev box)andClaude Fable 5 620cc608e0 fix(docker): copy scripts/ into web builder — prepare runs install-hooks.mjs on install
ci/woodpecker/pr/ci Pipeline was successful
The layer-cached install copies only manifests and packages/, so the
root prepare script could not be found and pnpm install exited 1
before the git-absent guard could even run.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 18:03:18 -05:00
shaggy (mosaic-dev box)andClaude Fable 5 91e692e3e7 fix: break-C — install-hooks no-ops without git; web image builds @mosaicstack/web
ci/woodpecker/pr/ci Pipeline was canceled
install-hooks.mjs hard-failed (exit 1) in environments without a git
binary — e.g. the docker image builds, which have no git and no repo.
Hook installation is meaningless there; skip with a warning instead.

docker/web.Dockerfile filtered @mosaic/web, but the package is named
@mosaicstack/web, so the image build compiled nothing.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 17:58:13 -05:00
shaggy (mosaic-dev box) b4753a75cd fix(gateway): gate FederationModule on tier federated (#1138)
ci/woodpecker/pr/ci Pipeline was successful
CaService hard-requires STEP_CA_URL/provisioner config at construction, so an
unconditional FederationModule import makes every standalone/local boot die at
DI time. Gate the module on loadConfig().tier === federated, matching the
documented intent of the federation compose profile (must not start in
non-federated dev).

Verified in mosaic-dev box: standalone tier boots to "Gateway listening on
port 14242" with bootstrap/socket.io/auth surfaces responding; federated tier
path unchanged.
2026-08-09 17:26:28 -05:00
velmaandmos-dt-0 24bbd40dc7 docs: WebUI fleet Claude bridge — Task 0 decision plan (#1131)
ci/woodpecker/push/publish Pipeline failed
Docs-only plan PR. FRED_APPROVED_REF=0629361ca39a4dd7fb3e575d11c64bea9e545dae (review 147). Merged by fred (orchestrator) via API: pr-merge.sh policy predates the next lane (main-only hardcode) — wrapper fix tracked separately.

Co-authored-by: Velma <[email protected]>
2026-08-09 10:28:41 +00:00
Jason Woltje dc67590a96 fix(installer): propagate wizard gateway failures (#1120)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-09 05:01:09 -05:00
Jason Woltje baf4306f51 fix(installer): require Node 22 for next lane
ci/woodpecker/pr/ci Pipeline was successful
2026-08-09 02:37:03 -05:00
Jason Woltje 12677a928d fix(mosaic): prevent init prompt code execution
ci/woodpecker/pr/ci Pipeline was successful
2026-08-09 00:28:57 -05:00
f10-coder f158be8003 fix(shell): remove wake validation pipe hazards
ci/woodpecker/pr/ci Pipeline was successful
2026-08-07 06:13:47 -05:00
f10-coderandMos b0f7d26dd9 fix(shell): remove test harness pipe hazards (#1106)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/manual/ci-image Pipeline failed
ci/woodpecker/manual/publish Pipeline failed
ci/woodpecker/manual/ci Pipeline was successful
Co-authored-by: f10-coder <[email protected]>
2026-08-07 11:12:17 +00:00
f10-coderandMos 3a1203b2f8 fix(shell): remove runtime early-exit pipe hazards (#1105)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: f10-coder <[email protected]>
2026-08-07 09:38:37 +00:00
be-coder-08 df4c591ab4 fix(fleet): make framework shell assertions SIGPIPE-safe (#1100)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-08-07 08:21:20 +00:00
be-coder-06andMos 4fa2768962 fix(fleet): propagate roster git identity (#1073)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: be-coder-06 <[email protected]>
2026-08-07 07:07:45 +00:00
Mos aa0a7b5fa2 fix(tools/git): issue-close.sh silently dropped the closing comment (#1085)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-08-07 05:59:12 +00:00
Mos 42ac19af48 test(gateway): size the enrollment clamp tolerance to CI jitter, not to a fast machine (closes #1090) (#1094)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
2026-08-07 05:33:38 +00:00
Mos f744f32214 feat(tools/git): explain tea's misleading user does not exist error (stale token, not a missing account) (#1086)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-08-07 05:07:40 +00:00
Mos 8ff7aac0ca fix(tools/git): detect-platform died silently outside a repo, taking every wrapper with it (#1089)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-08-07 04:26:36 +00:00
be-coder-08andMos 80a45b1e1c feat(pr-merge): preserve linked authors in squash messages (#1066)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: be-coder-08 <[email protected]>
2026-08-06 05:36:59 +00:00
be-coder-08andMos 85d2108e4e fix(ci): remove upgrade rollback signal race (#1060)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: be-coder-08 <[email protected]>
2026-08-05 22:14:15 +00:00
be-coder-08andMos 16f91157a1 test(ci): make queue guard harness deterministic (#1062)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: be-coder-08 <[email protected]>
2026-08-05 21:49:44 +00:00
mos-dt-0 4df478cdd1 Merge pull request 'chore(sync): merge main → next (B1) — resolve 8 conflicts, restore next current' (#1041) from sync/b1-main-into-next into next
ci/woodpecker/push/publish Pipeline failed
chore(sync): merge main → next (B1) — restore next current, resolve 8 conflicts (#1041)

Brings next current with main incl the RM-03 guard fix (482/5); preserves next's 10 in-flight commits. Closes #1040.
2026-08-03 10:17:06 +00:00
coder-mos2 b8844e1ff0 fix(sync): close local queue semantic merge gap
ci/woodpecker/pr/ci Pipeline was successful
2026-08-02 23:16:09 -05:00
coder-mos2 906ad8dc30 wip(sync): merge main into next with combined resolutions 2026-08-02 23:11:22 -05:00
coder-mos1andmos-dt-0 5916aeefd6 chore(release): @mosaicstack/mosaic 0.0.49 — ship RM-03 guard fix to release channel (#1036)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline failed
Co-authored-by: coder-mos1 <[email protected]>
2026-08-02 20:09:11 +00:00
coder-mos1andmos-dt-0 58b971aba3 fix(rm-03): make CI queue guard fail on asserted non-readiness (#1032)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: coder-mos1 <[email protected]>
2026-08-01 18:55:26 +00:00
coder-mos1andmos-dt-0 f4fd5967fc RM-61: prove ci-postgres teardown discrimination (#1033)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: coder-mos1 <[email protected]>
2026-08-01 14:54:04 +00:00
mos-dt-0andMos f65e9ea656 docs(remediation): mission-state snapshot at the RM-01 seam (#1028)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: mos-dt-0 <[email protected]>
2026-08-01 01:08:11 +00:00
mos-dt-0andMos f58b3699a6 RM-01: reproducible checkout — the pre-push gate fails on code, not environment (#1027)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: mos-dt-0 <[email protected]>
2026-08-01 00:50:12 +00:00
mos-dt-0andMos 01e966f36d docs(remediation): mission charter + reconciled execution backlog (#1026)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-31 22:47:03 +00:00
mos-dt-0andMos 524146055d fix(hygiene): .prettierignore must exclude Python build/test artifacts (#1025)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-31 22:24:49 +00:00
mos-dt-0andMos 06e0d40352 feat(quality): CI test-membership guard — enumeration can no longer silently under-run the disk (#1017) (#1018)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-31 14:08:41 +00:00
Mos 166ee8c90f fix(wake): close fd 9 in the detector's sleep child so a dead detector's lock dies with it (#993)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-31 13:48:33 +00:00
Jason Woltje 2fa6bcd576 fix(git): #1007 — test-issue-comment-readback is a FIFTH affected suite (second census correction)
ci/woodpecker/pr/ci Pipeline was successful
My previous commit said four. It is five. `test-issue-comment-readback.sh` has
the same defect and is fixed the same way, and I had already looked straight at
it and filed it as an *unrelated* silent failure. Correcting that here rather
than folding it in quietly.

WHY IT WAS MISSED — the general lesson, not the excuse. `run_comment()` sends
the wrapper's stdout AND stderr to `$OUTPUT_FILE`, and the `EXIT` trap deletes
`$WORK_DIR`. The suite therefore exits 1 with ZERO bytes on stdout and stderr,
and the one line that says what went wrong —

    Error: Gitea authenticated-identity read failed with HTTP 401

— lives only inside a directory that no longer exists when anyone looks. Every
oracle I had swept the family with greps for a SYMPTOM in surviving output, so
against this suite all of them returned "nothing found", which I read as "clean"
in the first sweep and as "unrelated pre-existing failure" in the second. A
suite that discards or deletes its own evidence converts a post-hoc assay into a
non-measurement, and I wrote that sentence into the previous commit while it was
already false about a file in the same directory.

HOW IT WAS ACTUALLY FOUND. Intercept the identity read at its SOURCE instead of
grepping for its consequence: a PATH shim over `git` that logs every
`mosaic.gitIdentity` read — args, rc, and resolved value — to a file OUTSIDE any
suite's work dir, then execs the real git. Deletion-proof by construction, and
it measures the defect's cause rather than one of its symptoms. Sweeping all 16
suites with it under an ordinary invocation:

  resolves a REAL identity (`mos-dt-0`) before the fix:
    test-issue-comment-readback          1 read   rc=1 (RED on every seat)
    test-pr-review-repo-host-override    6 reads  rc=0
    test-ci-queue-wait-branch-absent     3 reads  rc=0
  the four fixed in the previous commit now read empty; the rest never read at all.

The latter two are NOT affected and are deliberately left alone: under a seat
replica (identity set, no per-slot token) neither reaches `get_gitea_token`'s
fail-loud branch, and under a canary HOME neither carries the canary credential
into any surviving artifact. They read the identity and never enter a credential
path. That residual is structural and belongs to the wrapper half of #1007 —
scoping the read with `git -C "$repo"` removes it for everyone at once.

An earlier version of that sweep reported the four fixed suites as still
resolving a real identity. That was my grep, not the suites: `value=\[..*\]` is
satisfied by `value=[] args=[…]`, because `.*` runs past the empty pair and
matches the closing bracket of the NEXT one. `value=\[[^]]` is the correct test.
Recorded because the wrong pattern failed in the direction that would have sent
me re-fixing four already-correct files.

VERIFICATION of this suite, four HOME arms, all rc=0 with zero non-empty
identity reads and the pass line on stdout: real HOME, seat replica, canary
HOME, and an empty HOME with no identity at all. Full 16-suite sweep after the
change: every suite rc=0.

CONSEQUENCE FOR THE FINDING LIST IN THE PREVIOUS COMMIT: item 2 there — the
"silently red, unrelated to #1007" suite — is withdrawn. It was #1007 all along.
Item 1 (`pr-metadata.sh:89-92`, the anonymous fallback that reports an HTTP 200
carrying valid JSON as "unknown API error") stands and is still unfixed here.

Refs #1007
2026-07-31 07:27:10 -05:00
Jason Woltje 1afe2b36dc fix(git): #1007 suite hermeticity — pin repo-local mosaic.gitIdentity in four test suites
CENSUS CORRECTION: FOUR suites, not the three my own #1007 audit named. The
fourth (test-pr-metadata-gitea.sh) was outside the candidate set that audit
worked from and was found only by sweeping the discriminator across all 16
tools/git/test-*.sh suites. Recording that as a correction to my finding, not
as part of the original claim.

THE DEFECT. get_gitea_token() (detect-platform.sh:502-599) resolves a per-agent
identity at STEP 0, from `git config --get mosaic.gitIdentity`, BEFORE both the
Mosaic credential loader (step 1) and the GITEA_TOKEN env check (step 2). On a
provisioned agent seat that value is set GLOBALLY in ~/.gitconfig and is
inherited by any freshly-`git init`ed repo, so step 0 reads a REAL per-slot
token out of $HOME and returns it without ever consulting the suite's own
MOSAIC_CREDENTIALS_FILE / GITEA_TOKEN fixtures. The suites were running against
production credentials, and the fixture credential each one carefully
constructs was inert.

THE FIX: an empty repo-local `mosaic.gitIdentity`. An empty local value shadows
the global one and reads back empty at rc=0, so step 0 declines. The env route
does NOT work: detect-platform.sh reads "${MOSAIC_GIT_IDENTITY:-}", and `:-`
treats set-but-empty identically to unset.

OPERATIVE vs CONTAINMENT — the two mechanisms are not interchangeable and the
comment in each suite says so. The pin is operative: it prevents the resolution.
The sandboxed HOME each suite now also gets is containment: it bounds a failure
the pin should already have prevented. Conflating them is how this class stays
invisible, because a decoy HOME REMOVES the trigger (~/.gitconfig is where the
global identity lives), so any suite audited under one reads clean however
vulnerable it is. To MEASURE, replicate a seat: a decoy HOME whose .gitconfig
sets mosaic.gitIdentity with no per-slot token, so step 0 reaches its fail-loud
branch. That note is in each file for the next auditor.

SECOND, INDEPENDENT DEFECT in test-pr-metadata-gitea.sh. Applying the pin alone
turned that suite RED — and a control at baseline 826a8b3 under a plain HOME
reproduced the same failure, so it is pre-existing, not introduced. Its
`GITEA_TOKEN="stub-token"` / `GITEA_URL="https://git.example.test"` pair can
never satisfy step 2, because step 2 accepts GITEA_TOKEN only when GITEA_URL
matches the remote host and this repo's origin is git.uscllc.com. The suite had
therefore only ever passed by resolving a REAL credential — step 0 on a seat, or
step 1 from the operator's own credentials.json. A MOSAIC_CREDENTIALS_FILE
fixture is added rather than leaning on the sandboxed HOME making step 1 find
nothing: a test that passes because production configuration is ABSENT fails the
moment it is present. Shipping the pin without this would have moved the failure
rather than removed it.

NO CI ARM. .woodpecker/ci.yml does not run these suites; packages/mosaic/
package.json:28 (test:framework-shell) runs an ENUMERATED list that excludes all
four. They run only by hand — i.e. exclusively on a provisioned seat, the one
environment where the defect is live. "Passes in CI, fails on a seat" does not
apply here; there is no CI observation at all.

VERIFICATION (seat replica = decoy HOME with mosaic.gitIdentity set, no per-slot
token; canary = same plus a marked non-credential at both per-slot paths; plain
= empty HOME; real = ordinary invocation):
  - bash -n clean on all four.
  - Sweep of all 16 suites at baseline 826a8b3 under the seat replica:
    test-gitea-login-resolution rc=1 REACHES-STEP0; test-issue-create-
    interactive-auth rc=1 REACHES-STEP0; test-pr-merge-gitea-empty-uid rc=1
    REACHES-STEP0; test-pr-metadata-gitea rc=1 REACHES-STEP0.
  - Same sweep after: every row rc=0 with step0 absent.
  - test-gitea-token-identity flags REACHES-STEP0 in BOTH arms and is NOT a
    defect: it runs under `env -i HOME="$FAKE_HOME"` (line 77) and its hit is
    its own deliberate assert_failloud fixtures (lines 158-171). The fail-loud
    grep matches the intended behaviour as well as the defect, so it needs the
    second discriminator; recorded here so the next sweep does not re-file it.
  - Durable-argv assay (a PATH shim that tees argv out of each suite's own mock
    curl, because test-pr-merge-gitea-empty-uid truncates its log between phases
    and its EXIT trap removes the sandbox — a post-hoc read of that suite is a
    non-measurement, and "no trace" there is not a clearance):
      test-pr-merge-gitea-empty-uid  before: canary token in argv, fixture never
        used. after: fixture token in argv, canary absent. 5 curl calls both arms.
      test-pr-metadata-gitea         before: canary in argv. after: both calls
        carry the fixture token against git.uscllc.com.
  - test-pr-metadata-gitea across seat/canary/plain HOMEs after the fix: rc=0,
    rc=0, rc=0.
  - All four under the real HOME: rc=0. No regression to ordinary invocation.

The comment block is duplicated across the four files rather than pointing at a
shared note. Deliberate, and matching the merged #1006 precedent
(test-pr-review-gitea-comment.sh:87-95): the reader who needs it is auditing one
file.

TWO FINDINGS DELIBERATELY NOT FIXED HERE (out of this branch's scope, to be
filed):
  1. pr-metadata.sh:89-92 — the anonymous curl fallback does not check ^2, so an
     HTTP 200 carrying valid JSON is reported as "unknown API error" at rc=1.
  2. test-issue-comment-readback.sh exits 1 with ZERO bytes on stdout AND
     stderr, dying at its first seed_state python3 heredoc. Reproduces at
     baseline 826a8b3 under both a seat replica and the real HOME. Silently red
     at main for everyone; unrelated to #1007.

Refs #1007
2026-07-31 07:13:13 -05:00
mos-dt-0andMos 826a8b3b26 fix(git): pr-review.sh — surface the provider's stated reason, drop the hardcoded #865 attribution (#1006)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-31 10:52:58 +00:00
mos-dt-0andMos a4280b9c98 fix(wake): #984 fatal source guard + #985 absorb re-scan — #973 follow-up batch (#1001)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-31 10:16:22 +00:00
Mos 4fb44f6345 fix(wake): three-valued grep verdicts — has_match/count_lines across all ten suites (closes #973) (#983)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-31 08:39:04 +00:00
Mos 089615f63b feat(git): push-guard — refuse verifications satisfied by the null case (closes #975) (#974)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-31 03:35:24 +00:00
mos-dt-0andMos 76eef39a29 docs(wake): #953 dead-letter retention recorded as load-bearing at the write site (#968)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Closes #953.

GATE RECORD: review CLEAR at this exact head (author != reviewer, pre-registered diff-blind checks) + terminal-green CI at this exact head + queue guard clear.
CI CAVEAT (#973): green on the wake suites is currently WEAKER THAN IT LOOKS, IN BOTH DIRECTIONS. grep error/spawn exit codes are read as absence across 257 assertion sites in six idiom forms; 36 inverted (&&-fail) sites — including 19 credential-security canaries — fail toward GREEN under load. These greens were obtained on solo reruns after load-correlated FALSE reds (2115/2116/2118; main itself was red). This merge's safety therefore rests on the CONTENT review, not on the green. Remediation charter fa551c2d0 is authored and in flight.

Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 23:25:38 +00:00
mos-dt-0andMos 47f8689231 fix(wake): #952 quarantine-audit clean sweep names BOTH unprovable residual classes (#967)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Closes #952.

GATE RECORD: review CLEAR at this exact head (author != reviewer, pre-registered diff-blind checks) + terminal-green CI at this exact head + queue guard clear.
CI CAVEAT (#973): green on the wake suites is currently WEAKER THAN IT LOOKS, IN BOTH DIRECTIONS. grep error/spawn exit codes are read as absence across 257 assertion sites in six idiom forms; 36 inverted (&&-fail) sites — including 19 credential-security canaries — fail toward GREEN under load. These greens were obtained on solo reruns after load-correlated FALSE reds (2115/2116/2118; main itself was red). This merge's safety therefore rests on the CONTENT review, not on the green. Remediation charter fa551c2d0 is authored and in flight.

Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 23:25:34 +00:00
mos-dt-0andMos 8d1d6e5e76 feat(wake): #958 A11 preimage.sh — durable provenance for the operator-side preimage definition (#964)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Closes #958.

The preimage definition (source-adapter.sh) is the single most consequential file in the wake pipeline — every observed_hash is a sha256 of what it emits — and it was UNVERSIONED: no git history, no backup. When it was edited at 07:27 on 2026-07-30, attribution was recoverable only because an agent transcript happened to still be on disk. A11 gives that file durable provenance (option (2) of #958: recorded content-addressed, not in-band).

DESIGN, per the pre-registration:
- Provenance is OUT-OF-BAND (never on the adapter's stdout) — an in-band record would advance observed_hash for every source at once and manufacture the re-baseline it exists to explain (B3).
- The obligation never depends on the provenance path: a missing/corrupt store cannot halt the detector or swallow a wake (#940 advisory-fields precedent; B4).
- DESC_FMT=d1 is NOT the provenance record — the tag versions the descriptor FORMAT; a behaviour change that keeps descriptor shape re-baselines every hash and leaves the tag unchanged (B6).

CREDENTIAL HARD GATE (rebuilt after the first verdict FAILED it): byte capture is now RECORD-ONLY BY DEFAULT (extras opt in via WAKE_PREIMAGE_CAPTURE), not allow-by-default-refuse-on-shape — because a shape list can only refuse the secrets someone already enumerated, and the tool's own usage text recommended adding detector.env (where HMAC material lives). Deny is evaluated on BOTH raw and resolved path forms with resolved anchors, ordered before allow — closing the realpath-before-deny ordering defect that let a renamed symlink target through.

VERIFICATION (reviewer, mos-dt, independent of the author's claims):
- Seven decoy cases by planted-marker-then-grep-whole-state-dir: known cred path / same-name symlink / RENAMED-target symlink / prefixed secret / prefixless secret / opted-in-symlink-to-DENIED-target all REFUSED; opted-in-symlink-to-ALLOWED-target CAPTURED (positive control that the harness can capture at all, and that C was not closed by breaking every symlink).
- Polarity-completeness self-test RE-RUN with the shape list stubbed always-allow AND both deny lists stubbed — case D still safe: the flip is complete, the shape list is not load-bearing. Each stub proven live first (a stub that silently fails to apply reports the dangerous state as safe).
- B11: rm-then-change fails LOUD (rc=1), refuses to re-baseline, leaves the ledger absent; absent-with-emptied-objects still first-installs cleanly (absent-is-not-corrupt not paid for by breaking first install).
- RED-first reproduced exactly P13-P16 pre-fix; each refusal corroborated three ways (loud stderr, ledger row captured:false WITH a hash so attribution survives refusal, objects/ holding only the adapter).

KNOWN RESIDUAL (filed #969, non-gating): the deny check is both-forms but the suite needles only the resolved form — a deny reduced to resolved-only survives 17/17 green and would leak a renamed-symlink case. No reachable leak at this head (shipped code correct on all seven decoys); it constrains a FUTURE edit. Doctrine: a both-forms fix needs a needle per form; a fixture that satisfies its assertion through a DIFFERENT rule is testing the rule it did not mean to test.

Authored by pepper (sb-it-1-dt); independently reviewed by mos-dt (sb-it-1-dt) under diff-blind pre-registration (7242688b1, predating first read) — NOT CLEAR on the first verdict (B2/B11 failed by decoy), CLEAR at 8aff7d8 after the polarity rebuild. Manifest version 0.7.0.

Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 21:48:06 +00:00
mos-dt-0andMos 6a7fce34bb fix(wake): #946 digest ack watermark clamped at quarantined seqs — disclose AND clamp (#951)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Closes #946.

The digest omitted a quarantined entry's claim from disclosure while still advancing the ack watermark it instructed the consumer to run — converting a fail-safe HOLD into a silent DISCARD, through the documented normal path. Measured: the burial instruction was re-issued FIVE times, four fresh digests plus one system-initiated redelivery fired purely because the entry had gone unconsumed for 1826s. That redelivery is the proof of the 'indefinitely' half: the mechanism re-asserted itself with no new information.

SCOPE — this was NOT a missing check in the consume path. Measured before the fix: dead-letter occurrences were digest.sh 27, store.sh 0, ack.sh 0, detector.sh 0, reconcile.sh 0. Quarantine was owned ENTIRELY by the renderer; the store that advances the watermark had zero knowledge the ledger existed, so an entry could be quarantined by one subsystem and consumed by another with no possible interaction. The fix is therefore a deliberate cross-module decision — option (b), quarantine recorded into a store-owned file, preserving the existing direction of dependency — pre-registered by the consumer before any diff existed.

VERIFICATION
- Pipeline 2107 terminal SUCCESS at e11bc6622, read clone-inclusive from the provider API rather than through `pipeline-status.sh` (which filters `.type != "clone"` per workflow and would hide a clone failure behind an all-green table): 9/9 children success, exit 0 each, no non-success member. Its `test` step runs all nine wake harnesses via turbo -> packages/mosaic `test` -> `test:framework-shell`.
- Independent review by the consumer on the affected lane: eleven pre-registered acceptance checks, authored and delivered BEFORE the diff was read — the file list deliberately unlooked-at, because a filename alone would have disclosed which option was chosen. All eleven resolved, no blocker.
- The check that decides it: a RAW `ack.sh consumed --upto N` with no digest involved must refuse to advance past a quarantined seq — the case an agent hits when a digest is MISSED, and the one that would have sunk a disclosure-only fix. Covered at the head as a named assertion (T13 ordinary-path bypass), written independently of the reviewer's list.
- Mutation: one asserted site disabled -> TWELVE assertions die, every one BEHAVIOURAL, ZERO count assertions, including one killing across the module boundary the fix spans.
- RED control at base a6b5f6a: 34 and 10 assertions fail, matching the body exactly.
- Coordinator re-verify by a different instrument than the reviewer used: static reference counts across the base/head boundary — store.sh 0 -> 49, ack.sh 0 -> 6, `--agent` unchanged at 4 (so #949 correctly stayed out). A fix present-but-inert passes the count and fails the mutation; a fix behaviourally correct but smuggling #949 passes the mutation and fails the count. Neither result is reachable by repeating the other.

KNOWN RESIDUALS
- The `consumed-hashes` repair criterion is met only for keys that RE-EMIT. A corrupted row whose key never recurs stays false indefinitely; the sweep covers those, and the known-false row named in the acceptance criteria had already self-healed by re-emission rather than by design — safe by population, not by design.
- The audit's clean-sweep message names one unprovable class; a second exists (a surviving dead-letter row with an empty `observed_hash` cannot be convicted either). Wording, not logic. Filed separately.
- The audit's provability bound makes dead-letter RETENTION load-bearing for auditability. Nothing prunes it today, so this is latent — but any future rotation or size cap silently converts provable rows into unprovable ones with no signal at either end. This is not a defect; it is a property that BECAME load-bearing and is recorded nowhere. Filed separately.
- `test-wake-detector.sh` D4 fails at this head AND identically at base, with an empty diff over detector files — pre-existing, tracked, not introduced here.

Authored by pepper (sb-it-1-dt); reviewed independently by mos-dt (sb-it-1-dt). The mos-dt-0 commit and fork identity does not identify the author — attribution collapse tracked separately.

Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 15:41:03 +00:00
mos-dt-0andMos a6b5f6a01a fix(wake): #944 path becomes a hard-locator arm — detector-shape actionable entries pass the §2.1 gate (#945)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Closes #944.

_has_hard_locator accepted only repo+issue / 40-hex sha / file — the forge vocabulary. The detector emits path (+snapshot_sha when attested) and NEVER emits file/issue/sha, so predicate and sole producer shared ZERO keys and every class=actionable board_file entry dead-lettered. Latent since #920, whose harness pinned the detector's own emission shape as its malformed example — the suite certified the gap it was written to guard.

Fix: `path` becomes a hard-locator arm, and ONLY path. Bare path-less snapshot_sha is a deliberate NON-arm (would widen past the board_file vocabulary); Q11(d) asserts it still quarantines at 7/40/64 chars, spanning the detector's ^[0-9a-f]{7,64}$ attestation range.

VERIFICATION
- Pipeline 2105 terminal-green at fa36da8. Its `test` step reaches all nine wake harnesses via turbo -> packages/mosaic `test` -> `test:framework-shell`, which names each suite explicitly. The two-levels-down indirection matters: no search of .woodpecker/* can see it, and that is exactly why this question was got wrong earlier today and then corrected. CI therefore DOES attest the quarantine suite and the detector suite at this head.
- Independent review (mos-dt, consumer on the affected lane) PASS at fa36da8, from a detached worktree: predicate provably unmoved from fb3c3c3 (comment-stripped sha256 identical, _has_hard_locator body byte-identical), RED control at base reproduced exactly 8 failures all Q11 including "got 6".
- Third reviewer (wake-judge) ACCEPT on both judgment calls: the Q1 assertion reversal is a legitimate correction (the flip was forced, not elective — base fixtures red 19 assertions against the head predicate) and path-alone satisfies §2.1, whose operative test is "one targeted call, never a search" — two of its four named exemplars already resolve to current state. Requiring path+snapshot_sha jointly would permanently dead-letter a declared source class and conflict with #940's advisory-fields ruling.
- Judge's mutation criterion met: with the reconciled exemption disabled, the gate-level assertion ("an ORIENTATION-tier enumeration must NOT be quarantined") dies at this head and did not exist as a casualty before F1.
- D4 (detector lock re-acquisition) fails intermittently at base AND head; git diff base..head over the detector files is EMPTY, so it is out of this PR's surface on structural grounds rather than on a re-roll. Known defect, fix identified (detector.sh:516, fd 9 leaked into sleep), tracked separately.

KNOWN RESIDUALS
- ENUM-B is now the sole address-free reconciled fixture, so the exemption's gate-level guard is a population of one. Safe by population, not by design. Author follow-up: assert ENUM-B carries no hard-locator arm so the harness guards its own premise.
- The binding spec (CONVERGED-DESIGN.md §2.1, separate repo) still enumerates four forge tokens and reads narrower than the shipped gate. Tracked as #948, sequenced after the dragon-lin reseed.
- Hard-locator arms are type-loose: repo/file/path accept any non-null JSON value. Pre-existing; `sha` fails closed only by accident of test(). Tracked separately.

Authored by pepper (sb-it-1-dt); reviewed independently by mos-dt (sb-it-1-dt) and wake-judge. The mos-dt-0 commit/fork identity does not identify the author — attribution collapse tracked in #3092.

Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 14:01:48 +00:00
mos-dt-0andMos 539b475a92 fix(wake): #943 whole-string validation for WAKE_SNAPSHOT_TS_FUTURE_SLACK + version 0.6.13
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
grep is line-oriented, so a multi-line knob value passed the per-line anchors and was still fatal in arithmetic. Replaced with a case pattern matching the whole string, so an embedded or leading newline rejects. Manifest bumped 0.6.12 -> 0.6.13: three materially different detectors had shipped under one version string, and version= is the component sole self-identity claim.

Authored-by: pepper
Reviewed-by: mos-dt (independent, at this head; transfer proven by blob-hash equality)
Merged-by: Mos
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 11:56:18 +00:00
mos-dt-0andMos 3e47fc076f fix(wake): #942 harden WAKE_SNAPSHOT_TS_FUTURE_SLACK — validate shape AND force base-10
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
The slack knob was interpolated raw into $((...)) under set -u: a malformed value was FATAL to the poll, falsifying the poll-never-fails invariant, and a negative value inverted the guard to deny-all. Shape validation alone was insufficient — bash reads a leading zero as octal, so 08/09 passed the regex yet were fatal and 0300 silently meant 192. Now validated ^[0-9]{1,9}$ with a loud fallback to 300, then forced to base-10 via 10# so the knob means what the operator wrote.

Authored-by: pepper
Reviewed-by: mos-dt (independent, found both the original defect and the radix residual)
Merged-by: Mos
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 11:17:51 +00:00
mos-dt-0andMos 8710d0f6d7 feat(wake): #940 snapshot-datable digests — fd-3 snapshot-metadata channel (#941)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Adapter emits snapshot sha/ts out-of-band on fd 3 so a changing value never enters the delta-gate hash. Detector validates advisorily (sha regex, epoch sanity before arithmetic, future-skew slack); malformed metadata is dropped loudly and never gates the wake. Digest renders snapshot_sha/snapshot_ts plus a git-show re-verify hint. Adapters that never write fd 3 are byte-identical.

Reviewed-by: Mos (design, independent)
Reviewed-by: mos-dt (artifact, hardening §2)
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 10:55:18 +00:00
jason.woltjeandMos b981b4ec10 fix(wake): #934 mount-free, privilege-invariant seq-integrity fault injection (T9/T11 run in non-priv CI) (#936)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 16:34:58 +00:00
jason.woltjeandMos 9e81ffd7fc fix(wake): #932 stop reconciler re-enumerating already-CONSUMED detector state (#935)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 15:48:40 +00:00
jason.woltjeandMos 9becaf877f fix(wake): #917 gate the final observed_seq cursor write + observed.set/cursor consistency (defense-in-depth) (#933)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 15:14:38 +00:00
jason.woltjeandMos 17087efe15 fix(wake): #925 framework-ship canon fallback-wake (systemd timer + schema bound + A10 install/validate) — F7 out of the box (#931)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 13:44:51 +00:00
jason.woltjeandMos 0ea41e848b fix(wake): #913 installer adoption gaps — _lib dep-check fail-loud + mosaic-wake.service systemd-search-path link+validate (#930)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 12:37:29 +00:00
jason.woltjeandMos 347c1d57c1 fix(wake): #924 route dead-letter quarantine alarm via WAKE_ALARM_SINK_CMD with per-observed_seq dedup (G2a) (#929)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 11:45:09 +00:00
jason.woltjeandMos 13e6ce5e5c fix(wake): #927 enqueue TOCTOU — move stale-tmp cleanup off the hot enqueue path (no concurrent in-flight-write clobber) (#928)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 10:56:40 +00:00
jason.woltjeandMos 90265ef550 test(wake): #923 de-flake T10 concurrent-enqueue race (deterministic barrier) (#926)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 10:23:07 +00:00
jason.woltjeandMos 937a276208 fix(wake): #920 quarantine render-refused drain entry (no head-of-line block) + reconciler enumerations render orientation-tier (reconciled:true, render-tier not class=digest) (#922)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 08:45:51 +00:00
jason.woltjeandMos 712c770b7a fix(wake): #912 exercise the digest/HMAC trust suite in real CI (fix runner divergence + openssl + hard-require) (#921)
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 08:11:28 +00:00
jason.woltjeandMos d967a4a926 fix(wake): #914 digest renderer — WAKE_AGENT-prefixed ack line + digest-class locator threading (#916)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 07:13:07 +00:00
jason.woltjeandMos c585ac3326 test(wake): #918 de-flake T7 ack-no-network-block (sub-second timing) (#919)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 06:46:28 +00:00
jason.woltjeandMos e2ec927b1c fix(wake): #908 unify observed_seq on a single store-side allocator (dissolve detector-private-counter seam) (#915)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 06:02:02 +00:00
jason.woltjeandMos 2378665eaf feat(wake): W7 A10 idempotent installer + mosaic-wake.service (component-manifest, Gate-A, blank-reset retire, snapshot-guard, fail-closed install-validate) (#911)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 04:19:09 +00:00
jason.woltjeandMos 003cdaa1a6 feat(wake): W6 — off-host dead-man beacon + pluggable alarm-sink adapter (#910)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 02:30:28 +00:00
jason.woltjeandMos 320f5bfb6f feat(wake): W5 — synthetic-canary FN-oracle + source-parity reconciler (#909)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 02:04:19 +00:00
jason.woltjeandMos 5df47e735e feat(wake): W4 — per-host delta-gated detector daemon (fail-loud source semantics, enqueues to W2 store) (#907)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 01:04:55 +00:00
jason.woltjeandMos dd1391fd76 fix(wake): digest hard-locator gate covers top-level .claim entries (Closes #905) (#906)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 00:58:40 +00:00
jason.woltjeandMos 10d957d095 feat(wake): W3 — cumulative-state digest renderer + non-circular HMAC signer (#904)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 00:32:12 +00:00
jason.woltjeandMos dc45eb7c30 feat(kbn): land KBN-101 Envelope A v6 (rc.20) — declarative sink-RBAC + RLS write-source (Form A) (#902)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 23:58:18 +00:00
jason.woltjeandMos 28f022d9c0 feat(wake): W2 — three-cursor durable store + RECEIVED/CONSUMED ack-wrapper + watch-list schema (#903)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 23:58:08 +00:00
jason.woltjeandMos 2726fab5e0 chore(framework): wire agent-send.test.sh into CI test:framework-shell (W1 follow-up) (#901)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 23:23:24 +00:00
jason.woltjeandMos ab6e8e80dc fix(framework): send-message.sh fail-loud submission verdict + regression tests (Patch 6) (#895)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 22:57:11 +00:00
jason.woltjeandMos 1933c6cb1d feat(framework): accept digest message class in agent-send.sh (W1) (#894)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 22:56:46 +00:00
jason.woltjeandMos 48a0c86093 fix(lease-broker): recovery_runtime_unittest wait_ready() connect-probe (co-equal CI flake, cherry-pick #898) (#900)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 22:26:38 +00:00
jason.woltjeandMos 79c8647fd9 fix(lease-broker): wait_ready() polls real connect-readiness not socket-file existence (flaky CI race) (#898)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 22:10:15 +00:00
jason.woltjeandMos 2483dada33 fix(framework): pr-review.sh -r/--repo + -H/--host overrides + UA + repo preflight (Patches 5/5c) (#896)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 22:08:08 +00:00
jason.woltjeandMos 4c117afe03 docs(framework): add WAKE-DOCTRINE.md guide (W0) (#893)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 21:57:51 +00:00
jason.woltjeandMos 2698ddb7b5 feat(comms): P1 presence — minimal Synapse + fleet presence room + mosaic.presence heartbeat + liveness (#888)
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 21:03:18 +00:00
jason.woltjeandMos fabde1c834 docs(rfc): add RFC-001 (MACP/Matrix-native comms) + RFC-002 (install/config/topology) (#886)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 21:02:54 +00:00
jason.woltjeandMos 3c7890f17f fix(framework): detect-platform get_gitea_token fail-loud on absent per-slot token (Patch 2b) (#890)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 20:51:19 +00:00
jason.woltjeandMos 529c177830 fix(update): mosaic update runs the install-ordering guard post-reseed (#882 --sync-only bypass) (#883)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 22:18:34 +00:00
jason.woltjeandMos a32ce4c8f9 feat(869-c4): activation version-coupling assertion (Part of #869)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Part of #869

Mos (id-11) Gate-16 merge: independent APPROVE @90eb48fa (fail-closed identity locks byte-unchanged verified), author id2 != approver id11, clean mosaic-coder author, CI green wp1992. #869 Point-1 CODE COMPLETE (C1/C3/C5/C2/C4).

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 19:07:27 +00:00
jason.woltjeandMos d351caad36 feat(869-c2): install-ordering enforcement-hook guard (Part of #869)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Part of #869

Mos (id-11) Gate-16 merge: independent APPROVE @b6f36564 (8/8, verified vs real production settings template), author id2 != approver id11, clean mosaic-coder author, CI green wp1988.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 18:48:33 +00:00
jason.woltjeandMos 76b86a246e feat(869-c5): mosaic doctor activation-check (Part of #869)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was canceled
Part of #869

Mos (id-11) Gate-16 merge: independent APPROVE @e75e3238 (8/8), author id2 != approver id11, clean mosaic-coder author, CI green wp1987.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 18:38:21 +00:00
jason.woltjeandMos 4422231bdb feat: per-agent Gitea identity resolution (#873)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Closes #873

Mos (id-11) Gate-16 merge: independent APPROVE @4b472a22 (author-blocker dissolved via (a) re-author, identical tree hash to tech-approved head), author id2 != approver id11, clean mosaic-coder commit-author, CI green wp1985. Framework train COMPLETE 6/6.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 18:09:34 +00:00
jason.woltjeandMos 8504216964 fix(pr-review): case-insensitive _belongs slug compare (#875)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Closes #875

Mos (id-11) Gate-16 merge: independent APPROVE @9d8d58ae, author id2 != approver id11, clean mosaic-coder commit-author, CI green wp1982.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 17:53:26 +00:00
jason.woltjeandMos 7edc9b3121 fix(gitea): direct REST comment/review with fail-closed read-back (#865)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Closes #865

Mos (id-11) Gate-16 merge: fresh confirmatory independent APPROVE @8ac7e70f (1241-case fuzz 0 fail-open), author id2 != approver id11, clean commit-author, CI green wp1966.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 17:25:32 +00:00
jason.woltjeandMos 2f50c0876b feat(869-c3): lease-broker supervisor unit (Part of #869)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was canceled
Part of #869

Mos (id-11) Gate-16 merge: independent APPROVE @75235ef8 (9/9, no live host mutation), author id2 != approver id11, CI green wp1971.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 17:19:48 +00:00
jason.woltjeandMos db90da347e feat(869-c1): activation-capability probe (Part of #869)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was canceled
Part of #869

Mos (id-11) Gate-16 merge: independent 3-round APPROVE @c5a2bcc5, author id2 != approver id11, CI green wp1973.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 17:14:57 +00:00
jason.woltjeandMos 48fd1df28a fix(ci-queue-wait): treat absent branch (404) as queue-clear (#872)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was canceled
Closes #872

Mos (id-11) Gate-16 merge: independent review APPROVE @23cbdaf8, author jason.woltje(id2) != approver Mos(id11), CI green wp1974.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 17:08:57 +00:00
jason.woltje b79336a8c1 feat(orchestrator): board-roll.sh — auto-roll LIVE board to LEDGER under byte cap (#868)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
feat(orchestrator): board-roll.sh - auto-roll LIVE board to LEDGER under byte cap

Closes #868
2026-07-22 09:20:03 +00:00
jason.woltje 4e5af23214 Merge pull request 'skills: add glpi-* family (solve, followup, sweep, list, create)' (#863) from feat/glpi-skills into main
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-21 01:09:50 +00:00
Hermes Agent 880c28b191 docs(glpi-skills): genericize operator-specific content per review
ci/woodpecker/pr/ci Pipeline was successful
2026-07-20 19:45:50 -05:00
Jason WoltjeandClaude Opus 4.8 7bc2dfb6c8 skills: add glpi-* family (solve, followup, sweep, list, create)
ci/woodpecker/pr/ci Pipeline was successful
GLPI helpdesk workflow skills written against the portable
tools/glpi/ tooling (session-init.sh, ticket-list.sh, ticket-create.sh),
cross-linked via [[glpi-*]]:

- glpi-solve    — close a ticket by setting status Solved (5); GLPI auto-closes
- glpi-followup — add a followup via the top-level /ITILFollowup endpoint
- glpi-sweep    — read-only hunt for done-but-open tickets needing Solve
- glpi-list     — query tickets by status/recency
- glpi-create   — open a new ticket

Core rule encoded: completing work means setting status Solved, not just
posting a resolution followup (a followup documents; only Solved auto-closes).

Note: illustrative examples in the bodies are USC-flavored (M2M / helpdesk
ticket numbers) and can be genericized in review if preferred.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_019GjBgrb9tHgvq414Fqj37c
2026-07-20 18:04:53 -05:00
jason.woltje b0d78d8632 fix(mosaic): de-flake mutator-class lease gate TTL-expiry test (#861)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-20 10:32:45 +00:00
jason.woltje 344d86a635 fix(#812 follow-up): normalize detect-platform.sh host-match port comparison by scheme (#859)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-20 10:13:29 +00:00
jason.woltje 193331544d fix(wizard): honor MOSAIC_GATEWAY_SKIP_NPM_INSTALL — unblock install.sh --dev gateway testing (#698)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-10 01:30:10 +00:00
jason.woltje 495f73bfdb fix(wizard): avoid rerunning completed setup steps (#692)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 18:44:35 +00:00
jason.woltje b96cc7982a fix(wizard): report gateway failures before success summary (#691)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 18:14:40 +00:00
jason.woltje 0883fb91ec fix(wizard): resolve skills sync script path (#690)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 17:35:19 +00:00
jason.woltje 56787fabf1 fix(gateway): disable Redis consumers on local tier (#689)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 17:17:24 +00:00
jason.woltjeandClaude Opus 4.8 940ae3cc41 feat(installer): prefer npm next lane (#688)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
--next now prefers a fast npm @next install (CLI + gateway from the Gitea registry) and falls back to source build at next if the dist-tag is unavailable. Registry lane gated to non-dev, non-explicit-ref next installs; CLI/gateway prerelease versions must share a pipeline suffix. Adds tools/install-next-lane.test.sh (wired into CI). PR-event CI 1635 fully green + review-of-record APPROVE (functional install test, head 2fd7cfc3).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 07:14:24 +00:00
jason.woltjeandClaude Opus 4.8 c25a551c28 ci(#462): add durable next publish pipeline (#687)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Durable @next integration-line publish: on next pushes, compute <patch+1>-next.<pipeline#> prerelease versions (in-CI, uncommitted) and publish @mosaicstack/* under the next dist-tag; gateway image sha-only on next. Strict guardrails: next-only, never writes latest, never tags from next; main path unchanged. PR-event CI 1631 fully green + review-of-record APPROVE (head b1a887a2). Guardrails independently verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 05:45:09 +00:00
jason.woltjeandClaude Opus 4.8 94d6538061 feat(installer): add next integration lane (#686)
ci/woodpecker/push/ci Pipeline was successful
Add --next installer flag (build-from-source at the next integration branch; MOSAIC_NEXT=1 env equiv; explicit --ref wins). Three-lane install docs (stable @latest / --next prerelease / --dev source) + @next dist-tag pipeline design doc. Green PR-event CI 1626 + review-of-record APPROVE (head 3a5c12a5).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 05:14:32 +00:00
jason.woltjeandClaude Opus 4.8 a3c1ab923c test(#462): add federation M3 integration coverage (#685)
ci/woodpecker/push/ci Pipeline was successful
FED-M3-10 integration tests for the federation M3 verbs (list/get/scope). Test-infra + docs only; green PR-event CI 1623 (all steps incl ci-postgres).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 04:14:56 +00:00
jason.woltjeandClaude Opus 4.8 838701bde2 feat(#462): add federation get verb (#683)
ci/woodpecker/push/ci Pipeline was successful
FED-M3-06 get verb. Trust boundary mirrors M3-05 AND-intersect (note returned only when owned by subject AND on an authorized mission). Reviewed (review-of-record APPROVE, head 80a259b2) + green PR-event CI 1620.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 03:44:54 +00:00
jason.woltje 63e77887a8 feat: add mosaic-tools skill (fleet toolkit fast path) (#1) 2026-06-19 18:30:58 +00:00
Jarvis 809ca9a1d9 feat: add mosaic ops skills (portainer, gitea, woodpecker, deploy, orchestrator)
- mosaic-portainer: stack list/status/redeploy/logs via Portainer API scripts
- mosaic-gitea: PR/issue/milestone ops for git.mosaicstack.dev
- mosaic-woodpecker: pipeline status, trigger, CI wait
- mosaic-deploy: full end-to-end deploy flow (push → CI → merge → redeploy)
- mosaic-orchestrator: mission init/run/status + worker launch rules
2026-03-22 15:32:05 +00:00
Jason Woltje 57435bb879 switch skill docs to xdg mosaic config path 2026-02-17 14:12:07 -06:00
Jason Woltje 8c51bf7575 remove legacy nested skill link artifacts 2026-02-17 14:08:02 -06:00
Jason Woltje c3d2179ad8 add delegation mode fallback to matrix rail in kickstart 2026-02-17 14:05:43 -06:00
Jason Woltje b47c4024cc standardize skills to mosaic-first paths and docs 2026-02-17 13:09:06 -06:00
Jason Woltje 74d1cdc7c1 migrate kickstart skill to mosaic-first paths 2026-02-17 13:01:49 -06:00
Jason WoltjeandClaude Opus 4.6 8cae9e0883 feat: Add lint skill (zero-tolerance) + strengthen kickstart linting mandate
New skill: lint — zero-tolerance linting enforcement for all code changes.
Detects project linter, fixes ALL violations, never disables rules.

Updated kickstart: linting now explicit standing order #3 in worker template
with "NON-NEGOTIABLE" language and zero-tolerance enforcement.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-16 17:13:00 -06:00
Jason WoltjeandClaude Opus 4.6 2c524b6da2 feat: Add kickstart skill — orchestrator launcher via /kickstart command
/kickstart [milestone|issue|task] replaces manual orchestrator boilerplate.
Auto-discovers project context, fetches issues from Gitea/GitHub, bootstraps
tracking files, and transforms the session into an orchestrator.

Modes: milestone, issue, task ID, resume, interactive (no args)
Built-in: quality gates, Two-Phase Completion, context handoff protocol

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-16 16:59:04 -06:00
Jason WoltjeandClaude Opus 4.6 b4f2019529 security: Remove vercel-deploy (data exfiltration), annotate LD_PRELOAD shims
Security audit findings:
- CRITICAL: vercel-deploy uploaded entire project to external endpoint — REMOVED
- ANNOTATED: docx/pptx/xlsx soffice.py LD_PRELOAD shims — security warnings added
- README updated to 93 skills with full security audit section and Vue/Vite ecosystem

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-16 16:39:04 -06:00
Jason WoltjeandClaude Opus 4.6 b1eb1fb2f9 feat: Complete fleet — 94 skills across 10+ domains
Pulled ALL skills from 15 source repositories:
- anthropics/skills: 16 (docs, design, MCP, testing)
- obra/superpowers: 14 (TDD, debugging, agents, planning)
- coreyhaines31/marketingskills: 25 (marketing, CRO, SEO, growth)
- better-auth/skills: 5 (auth patterns)
- vercel-labs/agent-skills: 5 (React, design, Vercel)
- antfu/skills: 16 (Vue, Vite, Vitest, pnpm, Turborepo)
- Plus 13 individual skills from various repos

Mosaic Stack is not limited to coding — the Orchestrator and
subagents serve coding, business, design, marketing, writing,
logistics, analysis, and more.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-16 16:27:42 -06:00
Jason WoltjeandClaude Opus 4.6 dfeb4d9692 feat: Expand fleet to 23 skills across all domains
New skills (14):
- nestjs-best-practices: 40 priority-ranked rules (kadajett)
- fastapi: Pydantic v2, async SQLAlchemy, JWT auth (jezweb)
- architecture-patterns: Clean Architecture, Hexagonal, DDD (wshobson)
- python-performance-optimization: Profiling and optimization (wshobson)
- ai-sdk: Vercel AI SDK streaming and agent patterns (vercel)
- create-agent: Modular agent architecture with OpenRouter (openrouterteam)
- proactive-agent: WAL Protocol, compaction recovery, self-improvement (halthelobster)
- brand-guidelines: Brand identity enforcement (anthropics)
- ui-animation: Motion design with accessibility (mblode)
- marketing-ideas: 139 ideas across 14 categories (coreyhaines31)
- pricing-strategy: SaaS pricing and tier design (coreyhaines31)
- programmatic-seo: SEO at scale with playbooks (coreyhaines31)
- competitor-alternatives: Comparison page architecture (coreyhaines31)
- referral-program: Referral and affiliate programs (coreyhaines31)

README reorganized by domain: Code Quality, Frontend, Backend,
Auth, AI/Agent Building, Marketing, Design, Meta.

Mosaic Stack is not limited to coding — the Orchestrator serves
coding, business, design, marketing, writing, logistics, and analysis.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-16 16:22:53 -06:00
Jason WoltjeandClaude Opus 4.6 7ea13332ed feat: Add 5 curated skills for Mosaic Stack
New skills:
- next-best-practices: Next.js 15+ RSC, async patterns, self-hosting (vercel-labs)
- better-auth-best-practices: Official Better-Auth with Drizzle adapter (better-auth)
- verification-before-completion: Evidence-based completion claims (obra/superpowers)
- shadcn-ui: Component patterns with Tailwind v4 adaptation note (developer-kit)
- writing-skills: TDD methodology for skill authoring (obra/superpowers)

README reorganized by category with Mosaic Stack alignment section.
Total: 9 skills (4 existing + 5 new).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-16 16:17:40 -06:00
Jason WoltjeandClaude Opus 4.6 b032d23889 docs: Add npx install commands and clone instructions
- Add npx skills add commands for single, all, and non-interactive install
- Document .git suffix requirement for Gitea-hosted repos
- Add git clone step to manual installation
- Use ln -sf for idempotent symlinks

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-16 16:09:05 -06:00
Jason WoltjeandClaude Opus 4.6 ecde74439c feat: Initial agent-skills repo — 4 adapted skills for Mosaic Stack
Skills included:
- pr-reviewer: Adapted for Gitea/GitHub via platform-aware scripts
  (dropped fetch_pr_data.py and add_inline_comment.py, kept generate_review_files.py)
- code-review-excellence: Methodology and checklists (React, TS, Python, etc.)
- vercel-react-best-practices: 57 rules for React/Next.js performance
- tailwind-design-system: Tailwind CSS v4 patterns, CVA, design tokens

New shell scripts added to ~/.claude/scripts/git/:
- pr-diff.sh: Get PR diff (GitHub gh / Gitea API)
- pr-metadata.sh: Get PR metadata as normalized JSON

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-16 16:03:39 -06:00
4901 changed files with 1271051 additions and 13894 deletions
+20 -150
View File
@@ -1,154 +1,24 @@
# ─────────────────────────────────────────────────────────────────────────────
# Mosaic — Environment Variables Reference
# Copy this file to .env and fill in the values for your deployment.
# Lines beginning with # are comments; optional vars are commented out.
# ─────────────────────────────────────────────────────────────────────────────
# Non-secret runtime settings for the mosaic-poc-agent container.
# Copy to .env if you want to override the defaults in compose.yaml.
#
# NEVER put credentials in this file. Authentication is supplied at
# runtime only, via one of the two documented paths:
# 1. read-only mounted pi auth file (default: ~/.pi/agent/auth.json,
# override the host path with PI_AUTH_FILE)
# 2. provider API key environment variable (ZAI_API_KEY or
# ANTHROPIC_API_KEY), passed through by compose.yaml when set
# Model provider (built-in pi provider name)
PI_PROVIDER=zai
# ─── Database (PostgreSQL 17 + pgvector) ─────────────────────────────────────
# Full connection string used by the gateway, ORM, and migration runner.
# Port 5433 avoids conflict with a host-side PostgreSQL instance.
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
# Model ID within the provider
PI_MODEL=glm-5.3-flash
# Docker Compose host-port override for the PostgreSQL container (default: 5433)
# PG_HOST_PORT=5433
# Optional: alternative host path of the pi credential file mounted
# read-only at /home/node/.pi/agent/auth.json in the container
#PI_AUTH_FILE=/home/jwoltje/.pi/agent/auth.json
# ─── Queue (Valkey 8 / Redis-compatible) ─────────────────────────────────────
# Port 6380 avoids conflict with a host-side Redis/Valkey instance.
VALKEY_URL=redis://localhost:6380
# Docker Compose host-port override for the Valkey container (default: 6380)
# VALKEY_HOST_PORT=6380
# ─── Gateway ─────────────────────────────────────────────────────────────────
# TCP port the NestJS/Fastify gateway listens on (default: 14242)
GATEWAY_PORT=14242
# Comma-separated list of allowed CORS origins.
# Must include the web app origin in production.
GATEWAY_CORS_ORIGIN=http://localhost:3000
# ─── Auth (BetterAuth) ───────────────────────────────────────────────────────
# REQUIRED — random secret used to sign sessions and tokens.
# Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
# Public base URL of the gateway (used by BetterAuth for callback URLs)
BETTER_AUTH_URL=http://localhost:14242
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
# Public gateway URL — accessible from the browser, not just the server.
NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
# OTLP HTTP endpoint (otel-collector or any OpenTelemetry-compatible backend)
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# Service name shown in traces
OTEL_SERVICE_NAME=mosaic-gateway
# ─── AI Providers ────────────────────────────────────────────────────────────
# Ollama (local models — set OLLAMA_BASE_URL to enable)
# OLLAMA_BASE_URL=http://localhost:11434
# OLLAMA_HOST is a legacy alias for OLLAMA_BASE_URL
# OLLAMA_HOST=http://localhost:11434
# Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral)
# OLLAMA_MODELS=llama3.2,codellama,mistral
# Anthropic (claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5)
# ANTHROPIC_API_KEY=sk-ant-...
# OpenAI (gpt-4o, gpt-4o-mini, o3-mini)
# OPENAI_API_KEY=sk-...
# Z.ai / GLM (glm-4.5, glm-4.5-air, glm-4.5-flash)
# ZAI_API_KEY=...
# Custom providers — JSON array of provider configs
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
# MOSAIC_CUSTOM_PROVIDERS=
# ─── Embedding Service ───────────────────────────────────────────────────────
# OpenAI-compatible embeddings endpoint (default: OpenAI)
# EMBEDDING_API_URL=https://api.openai.com/v1
# EMBEDDING_MODEL=text-embedding-3-small
# ─── Log Summarization Service ───────────────────────────────────────────────
# OpenAI-compatible chat completions endpoint for log summarization (default: OpenAI)
# SUMMARIZATION_API_URL=https://api.openai.com/v1
# SUMMARIZATION_MODEL=gpt-4o-mini
# Cron schedule for summarization job (default: every 6 hours)
# SUMMARIZATION_CRON=0 */6 * * *
# Cron schedule for log tier management (default: daily at 03:00)
# TIER_MANAGEMENT_CRON=0 3 * * *
# ─── Agent ───────────────────────────────────────────────────────────────────
# Filesystem sandbox root for agent file tools (default: process.cwd())
# AGENT_FILE_SANDBOX_DIR=/var/lib/mosaic/sandbox
# Comma-separated list of tool names available to non-admin users.
# Leave unset to allow all tools for all authenticated users.
# AGENT_USER_TOOLS=read_file,list_directory,search_files
# System prompt injected into every agent session (optional)
# AGENT_SYSTEM_PROMPT=You are a helpful assistant.
# ─── MCP Servers ─────────────────────────────────────────────────────────────
# JSON array of MCP server configs — set to enable MCP tool integration.
# Each entry: {"name":"<id>","url":"<http-or-sse-url>"}
# MCP_SERVERS=[{"name":"my-mcp","url":"http://localhost:3100/sse"}]
# ─── Coordinator ─────────────────────────────────────────────────────────────
# Root directory used to scope coordinator (worktree/repo) operations.
# Defaults to the monorepo root auto-detected from process.cwd().
# MOSAIC_WORKSPACE_ROOT=/home/user/projects/mosaic
# ─── Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable) ─────────────
# DISCORD_BOT_TOKEN=
# DISCORD_GUILD_ID=
# DISCORD_GATEWAY_URL=http://localhost:14242
# ─── Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable) ───────────
# TELEGRAM_BOT_TOKEN=
# TELEGRAM_GATEWAY_URL=http://localhost:14242
# ─── SSO Providers (add credentials to enable) ───────────────────────────────
# --- Authentik (optional — set AUTHENTIK_CLIENT_ID to enable) ---
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
# AUTHENTIK_CLIENT_ID=
# AUTHENTIK_CLIENT_SECRET=
# --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) ---
# WORKOS_ISSUER=https://your-company.authkit.app
# WORKOS_CLIENT_ID=client_...
# WORKOS_CLIENT_SECRET=sk_live_...
# --- Keycloak (optional — set KEYCLOAK_CLIENT_ID to enable) ---
# KEYCLOAK_ISSUER=https://auth.example.com/realms/master
# Legacy alternative if you prefer to compose the issuer from separate vars:
# KEYCLOAK_URL=https://auth.example.com
# KEYCLOAK_REALM=master
# KEYCLOAK_CLIENT_ID=mosaic
# KEYCLOAK_CLIENT_SECRET=
# Feature flags — set to true alongside provider credentials to show SSO buttons in the UI
# NEXT_PUBLIC_WORKOS_ENABLED=true
# NEXT_PUBLIC_KEYCLOAK_ENABLED=true
# Optional: documented env-var auth alternative (secret! set in your
# shell or a gitignored .env, never commit)
#ZAI_API_KEY=
#ANTHROPIC_API_KEY=
+6 -22
View File
@@ -1,24 +1,8 @@
logs/
node_modules
dist
.turbo
.next
coverage
# build/deps
node_modules/
# runtime credentials — never commit, never copy into the image
.env
.env.local
*.tsbuildinfo
.pnpm-store
docs/reports/
secrets/
# Step-CA dev password — real file is gitignored; commit only the .example
infra/step-ca/dev-password
# Scratch dirs created by the framework git-wrapper shell test harnesses
.mosaic-test-work/
# Transient config files vite/vitest/esbuild write next to a *.config.ts while
# loading it, then unlink. They are untracked but were not ignored, so turbo's
# package traversal hashed them and intermittently failed CI with "Package
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
# file vanished mid-scan. Ignoring them removes the race.
*.timestamp-*.mjs
# generated runtime state lives in /home/jwoltje/.mosaic-dev (outside this project)
-1
View File
@@ -1 +0,0 @@
pnpm typecheck && pnpm lint && pnpm format:check
-5
View File
@@ -1,5 +0,0 @@
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
# instead of repopulating a fresh one.
store-dir=/root/.local/share/pnpm/store
+6
View File
@@ -0,0 +1,6 @@
extensions/
extensions.installed.sha256
.extensions-*
state/
evidence/
native-test-*.log
+34
View File
@@ -0,0 +1,34 @@
# Native goal development copy
From this repository, start a fresh native Pi session:
```sh
bash scripts/goal-dev.sh
```
Canonical source lives under `extensions/`. The launcher first runs `scripts/sync-dev-extensions.sh`, which installs verified ordinary-file copies under `.pi/extensions/`, then loads only the generated goal extension. Global extensions remain unloaded. The launcher keeps your usual native Pi provider authentication; it copies no credentials. Goal state and new conversation files live under `.pi/state/`, which is ignored by Git. Each process gets a fresh incarnation; `/reload` and `/new` in the same process retain its goal. Restarting Pi does not adopt an earlier process's active goal.
Plain `pi` also discovers `.pi/extensions/goal/index.ts` after project trust, but may load global extensions too. Use the launcher to avoid duplicate `/goal` registrations. This is a local development test, not a sandbox or the managed Mosaic runtime. Docker and `~/.mosaic` are unchanged.
## Try it
1. Set `/goal <a long goal with acceptance criteria>`. This starts work immediately.
2. Look below the editor for `Goal: Active`. The old above-editor goal widget is gone.
3. Run bare `/goal`, then press `Alt+G`. Both show the entire stored goal and its status. Tab remains autocomplete.
4. Use `/goal stop` and `/goal resume`. Expect Paused and Active, or Waiting if an untimed wait remains recorded.
5. A blocked `goal_report` displays Blocked. A satisfied report displays Complete and retains the full goal for recall without continuing work.
6. `/goal clear` removes the retained goal. Try `NO_COLOR=1 bash scripts/goal-dev.sh` to check text-only labels.
Use terminal scrollback for recall longer than the screen. At narrow widths Pi may truncate its footer status row; bare `/goal` and Alt+G remain available.
## Checks
```sh
node --test extensions/goal/test/*.test.ts
bash scripts/test-extension-package.sh
python3 scripts/test-goal-native.py
```
Contract tests use ordinary read-only fixture copies in `test/fixtures/skills-local/`, not live brain files. The executive-update fixture SHA-256 matches the parser's pinned contract, `bbea48a46b1f8da7bc759f86856fb52830b7dde456b826317163c6dc6ccab319`.
`SOURCE-SNAPSHOT.json` records the original external-source baseline, not the edited candidate. No symlinks are used. Never edit `.pi/extensions/`; the sync script refuses to overwrite installation drift. Make changes under `extensions/`, run the checks, and relaunch. To disable the test, stop its Pi process and remove `.pi/extensions/`. Keep `.pi/state/` only if you need local test state.
+10
View File
@@ -0,0 +1,10 @@
{
"snapshotVersion": 1,
"copiedAt": "2026-09-06T04:58:22Z",
"source": "~/.mosaic/fleet/extensions",
"goalTreeSha256": "8853f2b72dde3e87c4573648b9a931c1c75da87ccde995c3224e6d2e707a75f0",
"mosaicCoreLibTreeSha256": "d1194dce31209e5773c6cc5ce571cbca3c39b29d943a79dea06665e05d29f319",
"symlinks": false,
"autoDiscoveredExtensions": ["goal"],
"purpose": "Issue #54 native Pi NG development copy; never loaded by Docker"
}
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Compatibility entrypoint for the accepted native test command.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
exec scripts/goal-dev.sh "$@"
-10
View File
@@ -1,10 +0,0 @@
pnpm-lock.yaml
**/next-env.d.ts
**/dist
**/node_modules
**/drizzle
**/.next
.claude/
docs/tess/TASKS.md
docs/scratchpads/
packages/mosaic/src/fleet/testdata/documentation-publication-v1/inline-migration-v1.json
-130
View File
@@ -1,130 +0,0 @@
# &node_image is the pre-baked CI base built by .woodpecker/ci-image.yml:
# node:24-alpine + python3/make/g++/postgresql-client + pnpm + a warm pnpm
# store. The install step resolves from the baked store (--prefer-offline)
# instead of paying a ~731s cold fetch + native compile every run.
variables:
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
- &enable_pnpm 'corepack enable'
when:
# PR + manual CI run on any branch — the pull_request pipeline is the merge gate.
# push CI is restricted to protected branches (main) so a feature-branch push no
# longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves
# CI load on the storage-constrained runner with zero loss of gating (branch
# protection requires no push/ci status context; main still gets full push CI).
- event: [pull_request, manual]
- event: push
branch: main
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
# This avoids from_secret which is blocked on pull_request events.
# If the env vars aren't set, turbo falls back to local cache only.
steps:
install:
image: *node_image
commands:
- corepack enable
# python3/make/g++ are baked into ci-base; --prefer-offline resolves from
# the baked pnpm store.
- pnpm install --frozen-lockfile --prefer-offline
# Blocking gate: public framework package must contain no operator-specific
# personal data or private $HOME defaults. Runs early (no node_modules needed).
sanitization:
image: *node_image
commands:
- apk add --no-cache bash
- bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
# Resident line-count ceiling over framework-owned resident files
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
# Blocking gate (#791): a framework upgrade must never write or delete an
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
# survives a keep-mode reseed byte-identical (with rsync present AND absent —
# keep mode is a single cp-based path that must not depend on rsync), and that a
# corrupt/empty/missing manifest aborts fail-closed leaving operator files
# untouched (B2/B3). The rollback gate proves a mid-sync failure is rolled back
# from the pre-update snapshot (B1). The durable-snapshot gate (#791 PR2) proves
# the retained, operator-scoped pre-update backup is taken before any mutation
# (0700/0600, secret never logged, retention-pruned) and that the post-sync
# verify net restores any operator file a manifest bug lets the sync touch. The
# migration matrix pins the v2→v3 contract-file semantics. Pure bash, no
# node_modules — runs early alongside sanitization.
upgrade-guard:
image: *node_image
commands:
- apk add --no-cache bash rsync
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
typecheck:
image: *node_image
commands:
- *enable_pnpm
- pnpm typecheck
depends_on:
- install
- sanitization
- upgrade-guard
# lint, format, and test are independent — run in parallel after typecheck
lint:
image: *node_image
commands:
- *enable_pnpm
- pnpm lint
depends_on:
- typecheck
format:
image: *node_image
commands:
- *enable_pnpm
- pnpm format:check
depends_on:
- typecheck
test:
image: *node_image
environment:
# Avoid the namespace-level Woodpecker DB service named "postgres".
# The Kubernetes backend exposes service containers by step name.
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
commands:
- *enable_pnpm
# postgresql-client (pg_isready) is baked into ci-base.
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
- |
ready=0
for i in $(seq 1 60); do
if pg_isready -h ci-postgres -p 5432 -U mosaic; then
ready=1
break
fi
echo "Waiting for ci-postgres ($i/60)..."
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "ci-postgres did not become ready" >&2
exit 1
fi
# Run migrations (DATABASE_URL is set in environment above)
- pnpm --filter @mosaicstack/db run db:migrate
# Run all tests
- pnpm test
depends_on:
- typecheck
services:
ci-postgres:
image: pgvector/pgvector:pg17
environment:
POSTGRES_USER: mosaic
POSTGRES_PASSWORD: mosaic
POSTGRES_DB: mosaic
-197
View File
@@ -1,197 +0,0 @@
# Build, publish npm packages, and push Docker images
# Runs only on main branch push/tag
variables:
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
# toolchain + warm pnpm store. Kills the second cold install publish pays.
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
- &enable_pnpm 'corepack enable'
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
# the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
# images (gateway/appservice/web do not depend on @mosaicstack/mosaic). Releases
# (tags) always build everything. Exclude-list keeps the default SAFE: any
# non-excluded change still builds, so no transitive dep can silently go stale.
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
# the separate `event: tag` entry.)
- &image_build_when
- event: tag
- event: [push, manual]
branch: main
path:
exclude:
- 'packages/mosaic/**'
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
when:
- branch: [main]
event: [push, manual, tag]
steps:
install:
image: *node_image
commands:
- corepack enable
# Resolve from the baked pnpm store instead of a cold network fetch.
- pnpm install --frozen-lockfile --prefer-offline
build:
image: *node_image
commands:
- *enable_pnpm
- pnpm build
depends_on:
- install
publish-npm:
image: *node_image
# Publish only when a publishable package changed (or on a release tag); a
# pure-docs merge runs no publish. Cheap step, but gated for cleanliness.
when:
- event: tag
- event: [push, manual]
branch: main
path:
include:
- 'packages/**'
environment:
NPM_TOKEN:
from_secret: gitea_token
commands:
- *enable_pnpm
# Configure auth for Gitea npm registry
- |
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
# Publish non-private packages to Gitea.
#
# The only publish failure we tolerate is "version already exists" —
# that legitimately happens when only some packages were bumped in
# the merge. Any other failure (registry 404, auth error, network
# error) MUST fail the pipeline loudly: the previous
# `|| echo "... continuing"` fallback silently hid a 404 from the
# Gitea org rename and caused every @mosaicstack/* publish to fall
# on the floor while CI still reported green.
- |
# Portable sh (Alpine ash) — avoid bashisms like PIPESTATUS.
set +e
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" publish --no-git-checks --access public >/tmp/publish.log 2>&1
EXIT=$?
set -e
cat /tmp/publish.log
if [ "$EXIT" -eq 0 ]; then
echo "[publish] all packages published successfully"
exit 0
fi
# Hard registry / auth / network errors → fatal. Match npm's own
# error lines specifically to avoid false positives on arbitrary
# log text that happens to contain "E404" etc.
if grep -qE "npm (error|ERR!) code (E404|E401|ENEEDAUTH|ECONNREFUSED|ETIMEDOUT|ENOTFOUND)" /tmp/publish.log; then
echo "[publish] FATAL: registry/auth/network error detected — failing pipeline" >&2
exit 1
fi
# Only tolerate the explicit "version already published" case.
# npm returns this as E403 with body "You cannot publish over..."
# or EPUBLISHCONFLICT depending on version.
if grep -qE "EPUBLISHCONFLICT|You cannot publish over|previously published" /tmp/publish.log; then
echo "[publish] some packages already at this version — continuing (non-fatal)"
exit 0
fi
echo "[publish] FATAL: publish failed with unrecognized error — failing pipeline" >&2
exit 1
depends_on:
- build
# TODO: Uncomment when ready to publish to npmjs.org
# publish-npmjs:
# image: *node_image
# environment:
# NPM_TOKEN:
# from_secret: npmjs_token
# commands:
# - *enable_pnpm
# - apk add --no-cache jq bash
# - bash scripts/publish-npmjs.sh
# depends_on:
# - build
# when:
# - event: [tag]
build-gateway:
image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
REGISTRY_PASS:
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
commands:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
fi
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
depends_on:
- build
build-appservice:
image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
REGISTRY_PASS:
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
commands:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
fi
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
depends_on:
- build
build-web:
image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
REGISTRY_PASS:
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
commands:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
fi
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
depends_on:
- build
+198 -61
View File
@@ -1,80 +1,217 @@
# Agent Guidelines — Mosaic Stack
# AGENTS.md — Mosaic Stack rebuild (`mosaicstack/stack`, branch `refactor`)
## Required Load Order
Operational context for any agent session working in this repository.
Read top to bottom; it is deliberately short — depth lives in the files it
points to, not here.
1. `~/.config/mosaic/SOUL.md`
2. `~/.config/mosaic/STANDARDS.md`
3. `~/.config/mosaic/AGENTS.md`
4. `~/.config/mosaic/guides/E2E-DELIVERY.md`
5. `AGENTS.md` (this file)
6. Runtime-specific guide: `~/.config/mosaic/runtime/<runtime>/RUNTIME.md`
## What this repository is
## Project Context
Canonical checkout: `/mnt/storage/src/mosaic-stack`, origin `mosaicstack/stack`,
working branch `refactor` (Jason-authorized conversion, issue #1495).
The new foundation is at the root. `v1/` is archived legacy source, not the current
implementation; its instructions and tools do not govern the new foundation.
`~/src/mosaic-stack-dev-test` is a compatibility symlink to this checkout, not a
second working tree. Both original Git histories are retained. Conversion receipt:
`docs/plans/2026-09-07_repository-consolidation-completed.md`.
Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo with NestJS gateway, Next.js web dashboard, Pi SDK agent runtime, and plugin architecture for Discord/Telegram.
A rebuild of Mosaic Stack: a file-based, fail-closed
orchestration foundation that dispatches sandboxed headless pi workers to do
real work, with immutable run records as evidence. Thirteen-plus tagged
milestones (`git tag -l`) from `poc-container-hello-v0` to today; suites
green at every step. Not production software — a proven foundation.
## Package Map
## Non-negotiable invariants (the canon)
| Package | Purpose | Key Dependencies |
| ------------------ | ------------------------------- | -------------------------------- |
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
| `apps/web` | Next.js dashboard | React 19, Tailwind |
| `packages/types` | Shared TypeScript contracts | class-validator |
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres |
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db |
| `packages/queue` | Valkey task queue + MCP | ioredis |
| `packages/coord` | Mission coordination | @mosaicstack/queue |
| `packages/mosaic` | Unified `mosaic` CLI + TUI | Ink, Pi SDK, commander |
| `plugins/discord` | Discord channel plugin | discord.js |
| `plugins/telegram` | Telegram channel plugin | Telegraf |
1. **Root is bootstrap-only.** First-class system configuration lives at the
repository root; everything else gets a dedicated directory (`roles/`,
`contracts/`, `missions/`, `tasks/`, `docs/`). Do not add new files to root.
2. **Configuration**: `~/.config/mosaic-dev/config.json` is the sole system
config — created only by `scripts/bootstrap.sh`, never overwritten,
fail-closed on any problem. Repo-scoped role authority lives in
`roles/*.json` (versioned, reviewed commits only).
3. **Secrets** never enter the repository or container images; auth is
runtime-only (read-only mount or environment variable).
4. **Contracts** (`contracts/`) are immutable and image-baked. Missions and
tasks are declarative JSON with strict schemas.
5. **Run records** under `<dataRoot>/runs/` are write-once evidence — never
rewritten, only pruned via `prune` with a receipt.
6. **Fail closed**: missing or invalid config/policy refuses the operation.
Never improvise around a refusal; diagnose it.
7. **Policy**: missions govern tasks (least-privilege intersection — a task
narrows, never widens). Role authority is declared in `roles/` and changes
only via reviewed commits.
8. **Git**: commit only after applicable suites are green. Work on the
owner-authorized `refactor` branch; never force-push. Push remains an explicit
act. Do not merge into `next` or `main` without separate authorization.
`scripts/conductor-apply.sh` commits locally; it does not authorize a push.
9. **Append-only logs**: BUILD-LOG.md (phases), `activation-log.jsonl`,
`.pruned.log`, docs/SESSIONS.md. Corrections are new entries, never edits.
## Architecture Rules
## Autonomous operation within an agreed plan
1. Gateway is the single API surface — all clients connect through it
2. Pi SDK is ESM-only — gateway and CLI must use ESM
3. Socket.IO typed events defined in `@mosaicstack/types` enforce compile-time contracts
4. OTEL auto-instrumentation loads before NestJS bootstrap
5. BetterAuth manages auth tables; schema defined in `@mosaicstack/db`
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686)
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata)
Autonomy starts after alignment, not before it. For a new substantial assignment,
recover the applicable mission, goal, task, `CURRENT.md` state, and prior owner
decisions, then work with the user to establish a plan of action: the intended
outcome, acceptance evidence, boundaries, and any gated actions. Recommend a
concrete plan instead of presenting an open-ended menu. A direct request or
existing approved plan that already settles those points is sufficient alignment;
do not ask for ceremonial reconfirmation.
## Development Workflow
Once the plan is established, carry it to verified completion without prompting
for routine decisions or permission to take the next in-scope step. Authorization
persists for the life of that assignment unless the user changes or revokes it.
Treat mid-session user input as steering: incorporate it, update the plan or
tracking record when needed, and continue.
```bash
docker compose up -d # Infrastructure
pnpm install # Dependencies
pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
```
### Decide and continue
## Repo-Specific Notes
- Resolve naming, implementation approach, layout, and similar non-breaking
choices from, in order: repository invariants and role policy, the approved
plan and acceptance criteria, established repository conventions, then the
smallest reversible option. Record a consequential choice and its tradeoff.
- Perform the in-scope investigation, edits, tests, documentation, and tracking
needed for end-to-end acceptance. Do not ask whether to add obviously required
tests or documentation.
- Diagnose failures and retry or remediate within the agreed scope. Fix a defect
when it blocks acceptance or is local to files already being changed; otherwise
record a bounded follow-up without expanding the assignment.
- Resolve minor ambiguity in favor of the mission, goal, north star, and prior
owner decisions. State the assumption in the completion report.
- Never stop merely to ask whether to proceed, which routine option to use, or
whether to execute the next step already contained in the plan.
- DTOs in `*.dto.ts` files at module boundaries
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
- NodeNext module resolution in all tsconfigs
- Scratchpads are mandatory for non-trivial tasks
### Re-align or stop only at a real boundary
## docs/TASKS.md — Schema (CANONICAL)
Finish all independent work first, then ask one focused question only when:
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
1. Two plausible readings materially change the outcome and the choice is costly
to reverse.
2. The next action would exceed the agreed scope or authority, introduce an
unapproved breaking public/API/schema/data/policy change, or alter a security
boundary.
3. Credentials or access are missing and no in-scope path remains.
4. The action is destructive, irreversible, production-affecting, incurs spend,
or communicates externally on the user's behalf without explicit authority.
5. Objectives or owner decisions genuinely conflict and repository evidence
cannot resolve them.
6. A fail-closed policy refusal or another agent's overlapping ownership prevents
safe progress. Diagnose and report it; never route around it.
| Value | When to use | Budget |
| --------- | ----------------------------------------------------------- | -------------------------- |
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
| `glm-5.1` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
| `—` | No preference / auto-select cheapest capable | Pipeline decides |
Repository gates still apply. In particular, a successful implementation or a
broad request to “finish” does not by itself authorize push, merge, deployment,
release, production changes, policy/role expansion, or access to secrets. Perform
such an action only when the established plan explicitly includes it. If blocked,
report the exact boundary, what is complete, the recommended resolution, and the
specific action that will resume; do not use “waiting for confirmation” as a
substitute for a real blocker.
Pipeline crons read this column and spawn accordingly. Workers never modify `docs/TASKS.md` — only the orchestrator writes it.
## Session protocol (mandatory)
**Full schema:**
- **Register** your session in `docs/SESSIONS.md` — one append-only line
(date, actor, scope, outcome). Never rewrite or remove entries.
- **Cadence**: run `scripts/mosaic queue next <your seat>` first. It names
the row to resume, review or start, or says there is nothing. The goal order
in `docs/plans/2026-09-27_goals-review.md` sets priority, not CURRENT.md.
Open only the brief that row links to. Execute it through every authorized
stage (implement → test → verify against acceptance criteria; commit, push,
or close only when the established plan authorizes each) → move the row with
`scripts/mosaic queue move` (never by editing QUEUE.md) → register in
SESSIONS.md.
- "next" means one action. A batch mandate ("run the queue") repeats the
loop until green or truly blocked under the boundary rules above.
- Substantial work gets a Gitea issue and a BUILD-LOG phase entry
(before/after, with corrections recorded honestly).
```
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
```
## Internal development bootstrap
- `status`: `not-started` | `in-progress` | `done` | `failed` | `blocked` | `needs-qa`
- `agent`: model value from table above (set before spawning)
- `estimate`: token budget e.g. `8K`, `25K`
Jason's current direction is repository-native development in
`/mnt/storage/src/mosaic-stack`. Sage leads the project (Jason's ruling,
2026-09-26) and coordinates coding, review and research through Darkwing, Dewey,
Filbert, Rocko, Researcher and any further seats Jason launches under `agents/`.
Darkwing is a collaborating agent seat, not the coordinator. Development sessions
run in T3 for now. Work moves to the new stack; the old `~/.mosaic` fleet is being
retired, and a fleet seat acting outside Jason's instructions is the failure this
transition exists to prevent.
Do not assign new development work to fleet seats during this bootstrap phase.
Do not modify `~/.mosaic` launchers, provisioning or other state, or stop/migrate
live fleet processes as part of this work. Preserve existing work and histories.
Use the repository bootstrap/configuration and launch entry points; missing
configuration still fails closed. This changes development coordination, not
managed worker role policy or deployment authority. The lead role adds no push,
merge or deployment authority; those still need Jason's say-so. See
`agents/README.md` for the internal roster.
For control-board attention, start a completed reply with `Input needed: ` and
one specific nonempty request only when Jason must provide a decision or input.
Put that line at column zero, before other text. Do not use it for routine
completion or a wait on another agent. Ordinary completed replies are idle.
Use code fences or blockquotes when showing this convention as an example.
The signal is advisory status, never permission for a protected action. Seen
acknowledges a request; it does not resolve it. See `packages/control-board/README.md`.
## Role model
- **Conductor**: a system-scoped role — not an agent, not a daemon. Holds
git/credentials/policy authority; decomposes, dispatches, reviews,
verifies, integrates. Protocol: `docs/plans/CONDUCTOR.md`. Exists only
when invoked; push is never automatic.
- **Workers**: headless pi via `scripts/run-task.sh` — sandboxed workspace,
tools allowlist, optional persistent sessions and forks; no git, no
credentials, no policy control.
- Worker runs deliberately exclude this file (`--no-context-files` in the
adapter): worker context is contracts + mission via the generated system
prompt. This file is for conductor-level sessions.
## Command surface
`scripts/bootstrap.sh` (idempotent) · `build.sh` · `hello.sh` ·
`verify.sh` · `run-task.sh run <task.json>` · `release.sh
package|activate|rollback|status` · `auth.sh status|accounts` · `reset.sh` (**danger**: wipes the data
root; triple-safety-checked) · `mosaic-task.mjs validate|run|show|list|retry|prune|resolve-role` ·
`agent.sh <name>` (interactive TUI agent) ·
suites: `test-config.sh`, `test-task.sh`, `test-release.sh`,
`test-conductor.sh`, `test-auth.sh`, `test-discord.sh`, `test-queue.sh`.
Full reference — usage, fields, exit codes, safety notes:
`docs/TOOLS.md` (read on demand; do not rely on this summary for detail).
## Data map (canon)
- `~/.config/mosaic-dev/config.json` — system config (user-authored; never
auto-written).
- `<dataRoot>` (from config; default `~/.mosaic-dev`):
- `runs/` — write-once run evidence (`result.json`, snapshots, `stderr.txt`)
- `sessions/` — pi JSONL session trees, one directory per named session
- `workspaces/` — agent file effects (persistent or `:run` ephemeral)
- `state/` — release pointer + append-only activation/auto-apply logs
- Ownership is per-directory; nothing shares state. Directory map and
lifecycle rules: README.md "Data map" section.
## Pointers (depth lives here)
- `docs/plans/2026-09-27_goals-review.md` — north star and goal order (Jason ratified 2026-09-27)
- `docs/plans/QUEUE.md` — THE task list, rendered from `docs/plans/queue.json`
(`scripts/mosaic queue next <seat>` reads it; `packages/queue/README.md` has the verbs)
- `docs/plans/CURRENT.md` — narrative log behind the queue rows
- `docs/plans/ROADMAP.md` — agreed milestone path (M16+)
- `docs/plans/CONDUCTOR.md` — orchestration protocol and guardrails
- `docs/plans/2026-09-02_atomic-mosaic-foundation.md` — architecture, invariants
- `docs/plans/2026-09-03_autonomous-run.md` — batch-run tracker
- `BUILD-LOG.md` — append-only build/verification history with corrections
- `LAYERS.md` — implemented vs deferred layers
- `docs/SESSIONS.md` — session registry
- `adapters/README.md` — the harness adapter contract
- `roles/` — role contracts (conductor, future agent/coder/reviewer)
## Recovery rule
Compacted, restarted, or new? Nothing that matters is lost: this file +
`scripts/mosaic queue next <seat>` + `docs/plans/CURRENT.md` +
`git log --oneline -10` + the suites reconstruct the full state. **Never
guess** — verify with the suites; the run records and logs hold the receipts.
## Version pin
`@earendil-works/pi-coding-agent` is pinned exactly (see `package.json` /
`RELEASE`); never install unversioned. Release identity: `RELEASE` file
(0.0.X until declared stable); image tags derive from it.
+371
View File
@@ -0,0 +1,371 @@
# Minimal Mosaic Stack container proof of concept
## Purpose
Build the smallest isolated container that can:
- launch Pi
- load a small set of Mosaic-style contract files
- send one real request to a model
- return a known response.
This is a standalone experiment. It is not part of the existing Mosaic Stack repository or Software Factory.
## Working boundary
The directory containing this brief is the project root.
### Do not read, copy, mount, import, or modify anything from:
- `/home/jwoltje/.mosaic`
- `/home/jwoltje/.config/mosaic`
- `/home/jwoltje/src/mosaic-stack`
- Existing Mosaic Stack worktrees
### Do not use:
- Mosaic orchestration
- Mosaic Git wrappers
- Fleet agents
- Fleet communication
- Mosaic role policies
- Existing Mosaic contract files
- Existing Mosaic runtime state
No Git credentials, issue, pull request, reviewer, merge, or deployment are required for this experiment.
Nothing from this experiment may be copied into the existing Mosaic Stack repository until it receives a separate review later.
## Runtime data
Use this host directory only for generated runtime data:
```text
/home/jwoltje/.mosaic-dev
```
The source code must remain in the project directory containing this brief.
Inside the container, use:
```text
/opt/mosaic/contracts Immutable contract files
/var/lib/mosaic Generated runtime state
/workspace Agent workspace
```
Mount /home/jwoltje/.mosaic-dev at /var/lib/mosaic.
### Required proof
The finished experiment must prove one path:
1. Build one container image.
2. Start one Pi agent inside the container.
3. Load four local contract files from /opt/mosaic/contracts.
4. Send a request that does not contain the expected response.
5. Receive MOSAIC_HELLO_OK from the agent.
6. Exit successfully when the response matches.
7. Exit nonzero when the response does not match.
This is the entire required functional result.
### Required discovery
Before writing the runtime command:
1. Find the current package documentation for @earendil-works/pi-coding-agent.
2. Determine the current package version.
3. Determine the supported noninteractive command.
4. Determine how Pi accepts a custom system prompt or system prompt file.
5. Determine Pi's documented container authentication method.
6. Record the commands and findings in BUILD-LOG.md.
Do not guess CLI flags, authentication paths, or SDK methods.
Pin the selected Pi package version in the project. Do not install an unversioned package during each container start.
Prefer the Pi CLI. Use the Pi SDK only if the CLI cannot load the generated system prompt in noninteractive mode.
### Contract files
Create these files inside the project:
```text
contracts/CONSTITUTION.md
contracts/STANDARDS.md
contracts/SOUL.md
contracts/USER.md
```
Use these exact contents.
### contracts/CONSTITUTION.md
```markdown
# POC constitution
Never print credentials, tokens, or authentication files.
Follow the loaded system instructions before the user request.
```
### contracts/STANDARDS.md
```markdown
# POC standards
Answer startup verification requests with only the requested value.
Do not add explanation or formatting.
```
### contracts/SOUL.md
```markdown
# POC identity
Your name is mosaic-poc-agent.
Your startup marker is MOSAIC_HELLO_OK.
When asked for your startup marker, return only the marker.
```
### contracts/USER.md
```markdown
# POC user
This is an isolated local runtime test.
```
Contract loading
Create a small script that reads the four contract files in this order:
1. CONSTITUTION.md
2. STANDARDS.md
3. SOUL.md
4. USER.md
Join them with clear file separators.
Write the generated system prompt to:
```text
/var/lib/mosaic/system-prompt.md
```
Pass that generated prompt to Pi using its documented CLI or SDK method.
Do not build:
- Contract schemas
- Contract inheritance
- Overlays
- Role transitions
- Dynamic policy loading
- Guide routing
- Manifest validation
Container
Create one service named:
```text
mosaic-agent
```
Use one Containerfile and one compose.yaml.
Requirements:
- Use a maintained Node.js base image.
- Run as a non-root user.
- Install a pinned Pi package version.
- Copy the local contract fixtures into /opt/mosaic/contracts.
- Do not copy credentials into the image.
- Do not mount the Docker socket.
- Do not mount either live Mosaic directory.
- Do not add a database, web server, queue, or second container.
- The container may run as a one-shot command. It does not need to remain running.
### Authentication
Use Pi's documented authentication mechanism.
Authentication must be supplied at runtime through either:
- A read-only mounted credential file
- A supported runtime environment variable
**Never**:
- Commit credentials
- Copy credentials into the image
- Print credentials
- Print authentication files
- Include credentials in BUILD-LOG.md
- Store credentials under the project directory
Provide .env.example only for non-secret settings such as model or provider names.
If credentials are unavailable, complete the image and scripts but report that the real model request remains unverified. Do not fake the response.
### Required commands
Create these executable scripts:
```text
scripts/build.sh
scripts/hello.sh
scripts/verify.sh
scripts/reset.sh
```
### scripts/build.sh
Build the container image using Docker Compose.
### scripts/hello.sh
Run the mosaic-agent service as a one-shot container.
Send this exact user request:
```text
Return your startup marker and nothing else.
```
The request must not contain MOSAIC_HELLO_OK.
Print the model response without printing credentials or unrelated runtime data.
### scripts/verify.sh
Run the complete test.
**It must**:
1. Build or confirm the image is built.
2. Run the agent request.
3. Remove surrounding whitespace from the response.
4. Compare the response with MOSAIC_HELLO_OK.
5. Exit 0 only when they match exactly.
6. Exit nonzero with a clear error when they do not match.
### scripts/reset.sh
Delete generated POC state only when all checks pass:
1. The resolved path is exactly /home/jwoltje/.mosaic-dev.
2. The path is not a symbolic link.
3. The directory contains a .mosaic-poc-root ownership marker created by this project.
Refuse to delete anything if a check fails.
## Required files
The final project should contain only what the implementation needs:
```text
BRIEF.md
BUILD-LOG.md
README.md
LAYERS.md
Containerfile
compose.yaml
package.json
package-lock.json
.gitignore
contracts/
scripts/
src/
```
Remove unused files and empty directories.
Build log
Create BUILD-LOG.md.
Treat it as append-only.
Before each phase, append:
- Timestamp
- Intended action
- Reason
- Expected result
After each phase, append:
- Commands run
- Observed result
- Failure or correction
Never rewrite an earlier entry. Add a correction as a new entry.
Do not record credentials.
Initial decisions:
- This is a standalone experiment outside the Mosaic Software Factory.
- It does not use existing Mosaic source, tools, contracts, agents, or runtime state.
- The first proof uses one Pi agent and four small local contract files.
- The only required model result is MOSAIC_HELLO_OK.
- Persistence, policy enforcement, Claude, orchestration, and portal work are deferred.
## Acceptance criteria
The experiment passes when:
1. scripts/build.sh exits 0.
2. The image contains the four local contract files.
3. The image contains no credentials.
4. The container has no mounts from ~/.mosaic or ~/.config/mosaic.
5. scripts/hello.sh performs a real model request.
6. The request does not contain the expected marker.
7. The agent returns exactly MOSAIC_HELLO_OK.
8. scripts/verify.sh exits 0.
9. Changing the expected value makes scripts/verify.sh exit nonzero.
10. scripts/reset.sh refuses unsafe paths.
11. Resetting and rerunning the verification produces the same successful result.
## Deferred layers
Document these in LAYERS.md. Do not implement them.
- L0: Container builds and returns MOSAIC_HELLO_OK.
- L1: Persist and resume a named Pi session.
- L2: Add a fixed tool permission policy.
- L3: Load full versioned contract bundles.
- L4: Add Claude as a second runtime.
- L5: Add multiple agents and communication.
- L6: Add orchestration, knowledge storage, and portal features.
## Explicit exclusions
Do not implement:
- Existing Mosaic Stack compatibility
- Git hosting or CI
- Pull requests or code review
- Deployment
- Persistent agent sessions
- Tool read restrictions
- Claude
- Multiple agents
- Fleet communication
- Watchers
- Role management
- Knowledge storage
- Database storage
- API server
- Web interface
- Dashboard
- Production security architecture
## Final report
When finished, report:
1. Files created.
2. Pi package version.
3. Exact build command.
4. Exact verification command.
5. Verification output with credentials removed.
6. Whether the real model request passed.
7. Any remaining failure.
8. Anything implemented beyond this brief.
Do not describe the experiment as production-ready.
+3421
View File
File diff suppressed because it is too large Load Diff
+1 -46
View File
@@ -1,46 +1 @@
# CLAUDE.md — Mosaic Stack
## Project
Self-hosted, multi-user AI agent platform. TypeScript monorepo.
## Stack
- **API**: NestJS + Fastify adapter (`apps/gateway`)
- **Web**: Next.js 16 + React 19 (`apps/web`)
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
- **Auth**: BetterAuth (`packages/auth`)
- **Agent**: Pi SDK (`packages/agent`, `packages/mosaic`)
- **Queue**: Valkey 8 (`packages/queue`)
- **Build**: pnpm workspaces + Turborepo
- **CI**: Woodpecker CI
- **Observability**: OpenTelemetry → Jaeger
## Commands
```bash
pnpm typecheck # TypeScript check (all packages)
pnpm lint # ESLint (all packages)
pnpm format:check # Prettier check
pnpm test # Vitest (all packages)
pnpm build # Build all packages
# Database
pnpm --filter @mosaicstack/db db:generate # Offline migration artifact generation only
# PostgreSQL execution is held until KBN-101-00/-03/-05 land. Do not invoke a runner,
# init SQL, or Compose PostgreSQL service from this checkout.
# Dev: local PGlite data-layer work needs no PostgreSQL. Optional local queue service only:
docker compose up -d valkey
# Do not start Gateway/Web or root pnpm dev as a local PGlite route: the current unguarded dotenv
# loader can inherit a daemon PostgreSQL DSN. KBN-101-02 must make that state fail closed first.
```
## Conventions
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
- NodeNext module resolution
- Explicit `@Inject()` decorators in NestJS (tsx/esbuild doesn't support emitDecoratorMetadata)
- DTOs in `*.dto.ts` files at module boundaries
- OTEL tracing imported before NestJS bootstrap (`import './tracing.js'`)
- All three gates must pass before push: typecheck, lint, format:check
@AGENTS.md
+43
View File
@@ -0,0 +1,43 @@
# Minimal Mosaic Stack POC agent image.
# Base: maintained Node.js image (same family as Pi's documented
# containerization example in docs/containerization.md).
FROM node:24-bookworm-slim
# Tools Pi's documented container image expects (bash, CA certs, git, ripgrep).
RUN apt-get update \
&& apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \
&& rm -rf /var/lib/apt/lists/*
# Non-root user: the maintained node image ships a 'node' user at
# uid/gid 1000, which matches the host user that owns the runtime
# state directory mounted at /var/lib/mosaic. It is reused as-is.
# Pinned Pi install: package.json pins the exact version and
# package-lock.json is installed with npm ci. No unversioned installs.
WORKDIR /opt/app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
# Immutable contract fixtures (required location), runtime scripts, and
# runtime adapters.
COPY contracts /opt/mosaic/contracts
COPY src /opt/mosaic/src
COPY adapters /opt/mosaic/adapters
RUN chmod 0555 /opt/mosaic/contracts /opt/mosaic/contracts/* \
&& chmod 0555 /opt/mosaic/src /opt/mosaic/src/*.sh \
&& chmod 0555 /opt/mosaic/adapters /opt/mosaic/adapters/*/adapter.sh
# Writable state, workspace, and pi agent directory (auth.json is
# bind-mounted read-only at runtime; nothing is copied into the image).
RUN mkdir -p /var/lib/mosaic /workspace /home/node/.pi/agent \
&& chown -R node:node /var/lib/mosaic /workspace /home/node /opt/app
USER node
WORKDIR /workspace
ENV HOME=/home/node \
PATH="/opt/app/node_modules/.bin:${PATH}" \
PI_OFFLINE=1
# One-shot agent: args form the user request (default is the startup
# verification request defined in compose.yaml).
ENTRYPOINT ["/opt/mosaic/src/run-agent.sh"]
+50
View File
@@ -0,0 +1,50 @@
# LAYERS
Deferred capability layers for the Mosaic experiment. Only L0 is implemented by
this proof of concept; everything below it is documented here and deliberately
not implemented (see BRIEF.md, "Explicit exclusions").
## L0 — Implemented: container returns MOSAIC_HELLO_OK
One image (`mosaic-poc-agent:0.84.4`, built on `node:24-bookworm-slim`, non-root,
pinned Pi) runs one Pi agent one-shot. Four immutable local contract files are
loaded in fixed order into the generated system prompt
(`/var/lib/mosaic/system-prompt.md`). One real model request is sent
noninteractively; the response must equal `MOSAIC_HELLO_OK` exactly or the
verification exits nonzero. Authentication is supplied at runtime only
(read-only mounted pi auth file, or a provider API key environment variable).
## L1 — Deferred: persist and resume a named Pi session
Keep a named Pi session across container runs (`--name`, session storage under
`/var/lib/mosaic`), resume it with the documented session flags, and verify
state survives a container restart.
## L2 — Deferred: fixed tool permission policy
Add a fixed allow/deny policy for Pi tools (e.g. restricting built-in tools via
documented `--tools` / `--exclude-tools` or an extension-based permission gate),
so contract files can constrain what the agent may do, not just what it says.
## L3 — Deferred: load full versioned contract bundles
Replace the four static fixtures with versioned contract bundles: bundle
manifests, contract versions, and deterministic ordering/hashing, loaded from
an immutable bundle artifact instead of files copied at image build time.
## L4 — Deferred: Claude as a second runtime
Add a second runtime (Claude) alongside the Pi agent in the same container
stack, behind the same contract-loading path, to compare behavior across
runtimes.
## L5 — Deferred: multiple agents and communication
Run several named agents with defined roles and a communication channel between
them (message passing or shared state under `/var/lib/mosaic`).
## L6 — Deferred: orchestration, knowledge storage, and portal features
Fleet-level orchestration, knowledge storage, monitoring, and portal UI on top
of L1-L5. This is where the existing Mosaic Stack concepts would be re-evaluated
from first principles.
+211 -350
View File
@@ -1,377 +1,238 @@
# Mosaic Stack
# Mosaic Stack — new foundation
Self-hosted, multi-user AI agent platform. One config, every runtime, same standards.
The active rebuild is at this repository's root. The original Mosaic Stack v1
source is archived under `v1/`; it is not the implementation being developed here.
Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi — injecting consistent system prompts, guardrails, skills, and mission context into every session. A NestJS gateway provides the API surface, a Next.js dashboard gives you the UI, and a plugin system connects Discord, Telegram, and more.
- Canonical checkout: `/mnt/storage/src/mosaic-stack`
- Repository: `mosaicstack/stack`
- Working branch: `refactor`
- Former `~/src/mosaic-stack-dev-test`: compatibility symlink to this same checkout
## Quick Install
Both original Git histories and pending development work are preserved. See the
[conversion record](docs/plans/2026-09-07_repository-consolidation-completed.md)
and [current next action](docs/plans/CURRENT.md). Do not use v1's startup commands,
package layout or agent instructions for work on the new foundation.
```bash
curl -fsSL https://mosaicstack.dev/install.sh | bash
## Original container proof
The foundation began as a standalone container experiment. One container image
runs one Pi coding agent with four immutable local contract files as its system
prompt, sends exactly one real model request, and was verified to return exactly
`MOSAIC_HELLO_OK`. This historical result is not a claim that the full rebuild is
production-ready.
## Layout
```text
BRIEF.md requirements for the original container proof
BUILD-LOG.md append-only build/verification log
LAYERS.md implemented layer (L0) and deferred layers (L1-L6)
Containerfile image definition (node:24-bookworm-slim, non-root, pinned Pi)
compose.yaml one service: mosaic-agent (one-shot; configured via env)
package.json pins @earendil-works/pi-coding-agent at exactly 0.84.4
package-lock.json resolved lockfile used by npm ci in the image
.env.example non-secret settings only (credential-file path, env-var auth)
contracts/ CONSTITUTION.md, STANDARDS.md, SOUL.md, USER.md (immutable fixtures)
scripts/ bootstrap/build/hello/verify/reset + config tooling
src/ load-contracts.sh, run-agent.sh (run inside the container)
docs/plans/ architecture and milestone plans
```
Or use the direct URL:
## Configuration
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
The sole discovery entry point is:
```text
~/.config/mosaic-dev/config.json
```
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
Created only by the explicit, idempotent bootstrap:
```bash
bash <(curl -fsSL …) --yes # Accept all defaults
bash <(curl -fsSL …) --yes --no-auto-launch # Install only, skip wizard
scripts/bootstrap.sh # create-if-absent; validates existing config, never rewrites
```
This installs both components:
Minimal shape (`configVersion` 1):
| Component | What | Where |
| ----------------------- | ---------------------------------------------------------------- | -------------------- |
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
```json
{
"configVersion": 1,
"environment": "development",
"dataRoot": "/home/jwoltje/.mosaic-dev",
"execution": {
"backend": "docker",
"provider": "zai",
"model": "glm-5.3-flash"
}
}
```
After install, the wizard runs automatically or you can invoke it manually:
Rules enforced by `scripts/mosaic-config.mjs`:
- Unknown keys, unsupported versions/backends, and malformed JSON exit nonzero; nothing is modified.
- `dataRoot` must be absolute, canonical, and must not be or contain the home or configuration directory.
- Validation failures never touch config, state, or images.
- `scripts/test-config.sh` runs the sandboxed config selftests (no Docker required).
Run paths (`build/hello/verify/reset`) fail closed when configuration is missing or invalid; they never invent it.
## Missions & tasks (M2)
Missions and tasks are validated JSON data (strict schemas, version-pinned). The M2 layer is host-side only: mission directives are recorded for provenance but do not yet reach the runtime system prompt (capability/policy layer comes later).
```text
missions/hello.json objective + directives (missionVersion 1)
tasks/hello-marker.json prompt + optional mission ref + expectExact + timeout
<dataRoot>/runs/r-<id>/ immutable run record: task.json, mission.json,
stderr.txt, result.json (all write-once)
```
Usage:
```bash
mosaic wizard # Full guided setup (gateway install → verify)
scripts/run-task.sh validate tasks/hello-marker.json # strict validation, writes nothing
scripts/run-task.sh run tasks/hello-marker.json # execute; result recorded under dataRoot/runs
scripts/mosaic-task.mjs list # list runs and statuses
scripts/test-task.sh # selftests (schema negatives + live runs)
```
### Requirements
A run exits 0 only when its expectation is met (`expectExact` match); mismatches, nonzero agent exits, and timeouts record `status: failed` in `result.json` and exit 1. Each run gets a unique directory — rerunning never rewrites history.
- Node.js ≥ 20
- npm (for global @mosaicstack/mosaic install)
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
## Release model (M3)
`RELEASE` single-sources the release version (0.0.X until declared stable); the image tag derives from it plus the pinned Pi version. Activation is health-gated and every event is recorded:
```bash
scripts/release.sh package # build + tag the release image
scripts/release.sh activate # health check (exact marker) -> atomic pointer swap
scripts/release.sh activate --fault-injection # prove the refusal path (drills only)
scripts/release.sh rollback # health-gated return to the previous release
scripts/release.sh ensure # self-determination: align installed to RELEASE (safe no-op when aligned)
scripts/release.sh status # release, tag, active pointer, recent log
scripts/test-release.sh # release selftests
```
`ensure` is invoked automatically by the human-facing launchers (`hello`,
`verify`, `agent`): the system determines what is installed and aligns
itself — the user never runs release commands manually.
- `<dataRoot>/state/active.json` — the activation pointer (atomic tmp+rename replace)
- `<dataRoot>/state/activation-log.jsonl` — append-only history: package / activate / refused / rollback
A failed health check never activates; the previously active release remains deployed. Updating the software therefore cannot corrupt the running installation: package beside, gate, then flip. Verified by the update/refusal/rollback drills in BUILD-LOG Phase 7.
## Runtime adapters (M4)
The harness boundary is formalized: everything upstream (config, contracts, missions, tasks, run records) is harness-agnostic; everything inside an adapter belongs to one runtime.
```text
adapters/<name>/adapter.sh env in: MOSAIC_SYSTEM_PROMPT_FILE, MOSAIC_REQUEST,
MOSAIC_PROVIDER, MOSAIC_MODEL
stdout: response only; stderr: diagnostics
```
- Selection: `execution.adapter` in config.json (optional; `pi` default; allowlist `pi`, `mock`)
- `pi` — pinned Pi CLI, noninteractive print mode, ambient discovery off
- `mock` — deterministic test adapter; never for real verification
- Mission directives have a sanctioned injection point: when a task references a mission, the task runner mounts the run snapshot and the generated prompt gains a `MISSION (runtime)` section (objective + directives) after the four immutable contracts
- Adding a harness (Claude, Codex, OpenCode) later means adding one directory — no orchestrator changes
See `adapters/README.md` for the full contract.
## Workspaces, capabilities, sessions (M5/M6)
Optional task fields extend what an agent can do — all defaulting to the previous behavior:
```json
{
"workspace": "demo", // ":run" ephemeral, or persistent dataRoot/workspaces/<name>
"capabilities": { "tools": ["bash", "read"] }, // pi tool allowlist; absent = no tools
"session": "demo" // persistent session at dataRoot/sessions/<name>
}
```
- The adapter runs inside the workspace; files it writes are host-visible (`dataRoot/workspaces/<name>`).
- Sessions persist via pi's documented `--session-dir`; a follow-up run in the same session resumes the conversation (`-c`) and can recall prior context. Distinct names never share state. Ephemeral (`--no-session`) remains the default when no session is declared.
- Selection authority: config for adapter/provider/model; the task file for workspace/capabilities/session.
Inspect anything:
```bash
node scripts/mosaic-task.mjs list # runs with task/workspace/session columns
node scripts/mosaic-task.mjs show <runId> # full record + snapshots + artifacts
```
Demo fixtures: `tasks/workspace-demo.json`, `tasks/session-demo-1.json` + `tasks/session-demo-2.json`.
See `docs/plans/2026-09-02_atomic-mosaic-foundation.md` for the full plan.
Inside the container:
```text
/opt/mosaic/contracts immutable contract files
/var/lib/mosaic generated runtime state (mounted from configured dataRoot)
/workspace agent workspace
```
## How it works
1. `scripts/build.sh` builds the release image (`mosaic-poc-agent:<pi>-r<release>`,
tag derived from `RELEASE` + the pinned Pi version) with Docker Compose.
2. On each run, `/opt/mosaic/src/load-contracts.sh` reads the four contract files
in fixed order (CONSTITUTION, STANDARDS, SOUL, USER), joins them with clear
separators, and writes `/var/lib/mosaic/system-prompt.md`.
3. `/opt/mosaic/src/run-agent.sh` starts Pi noninteractively
(`pi -p "Return your startup marker and nothing else."`) with
`--system-prompt "$(cat /var/lib/mosaic/system-prompt.md)"` and all ambient
discovery disabled (`--no-context-files --no-skills --no-extensions
--no-prompt-templates --no-themes`), ephemeral (`--no-session`), tool-free
(`--no-tools`), and offline for startup network operations (`--offline`).
4. `scripts/verify.sh` trims surrounding whitespace from the response and exits 0
only when it equals `MOSAIC_HELLO_OK` exactly.
## Usage
### Launching Agent Sessions
```bash
scripts/bootstrap.sh # create config.json if absent (idempotent)
scripts/build.sh # build the image
scripts/hello.sh # one-shot request; prints the model response
scripts/verify.sh # full gated test; exit 0 only on exact MOSAIC_HELLO_OK
scripts/run-task.sh # run a mission/task file (see Missions & tasks)
scripts/release.sh # package / activate / rollback / status (see Release model)
scripts/test-config.sh # fast config-layer selftests (no Docker)
scripts/test-task.sh # mission/task selftests (schema + adapter seam + live runs)
scripts/test-release.sh # release selftests
scripts/reset.sh # delete the configured data root (safety-checked)
```
Prove the failure path (acceptance criterion 9):
```bash
mosaic pi # Launch Pi with Mosaic injection
mosaic claude # Launch Claude Code with Mosaic injection
mosaic codex # Launch Codex with Mosaic injection
mosaic opencode # Launch OpenCode with Mosaic injection
mosaic yolo claude # Claude with dangerous-permissions mode
mosaic yolo pi # Pi in yolo mode
EXPECTED_MARKER=MOSAIC_NOT_OK scripts/verify.sh # must exit nonzero
```
The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md` standards into the runtime, and forwards all arguments.
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
### TUI & Gateway
```bash
mosaic tui # Interactive TUI connected to the gateway
mosaic gateway login # Authenticate with a gateway instance
mosaic sessions list # List active agent sessions
```
### Gateway Management
```bash
mosaic gateway install # Install and configure the gateway service
mosaic gateway verify # Post-install health check
mosaic gateway login # Authenticate and store a session token
mosaic gateway config rotate-token # Rotate your API token
mosaic gateway config recover-token # Recover a token via BetterAuth cookie
```
If you already have a gateway account but no token, use `mosaic gateway config recover-token` to retrieve one without recreating your account.
### Configuration
Mosaic supports three storage tiers: `local` (PGlite, single-host), `standalone` (PostgreSQL, single-host), and `federated` (PostgreSQL + pgvector + Valkey, multi-host). See [Federated Tier Setup](docs/federation/SETUP.md) for multi-user and production deployments, or [Migrating to Federated](docs/guides/migrate-tier.md) to upgrade from existing tiers.
```bash
mosaic config show # Print full config as JSON
mosaic config get <key> # Read a specific key
mosaic config set <key> <val># Write a key
mosaic config edit # Open config in $EDITOR
mosaic config path # Print config file path
```
### Management
```bash
mosaic doctor # Health audit — detect drift and missing files
mosaic sync # Sync skills from canonical source
mosaic skill list # Audit Claude skill registrations and conflicts
mosaic skill register <name> # Register one canonical skill with Claude Code
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
mosaic update # Update CLI/framework and auto-register canonical skills
mosaic wizard # Full guided setup wizard
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
mosaic coord init # Initialize a new orchestration mission
mosaic prdy init # Create a PRD via guided session
```
### Sub-package Commands
Each Mosaic sub-package exposes its API surface through the unified CLI:
```bash
# User management
mosaic auth users list
mosaic auth users create
mosaic auth sso
# Agent brain (projects, missions, tasks)
mosaic brain projects
mosaic brain missions
mosaic brain tasks
mosaic brain conversations
# Agent forge pipeline
mosaic forge run
mosaic forge status
mosaic forge resume
mosaic forge personas
# Structured logging
mosaic log tail
mosaic log search
mosaic log export
mosaic log level
# MACP protocol
mosaic macp tasks
mosaic macp submit
mosaic macp gate
mosaic macp events
# Agent memory
mosaic memory search
mosaic memory stats
mosaic memory insights
mosaic memory preferences
# Task queue (Valkey)
mosaic queue list
mosaic queue stats
mosaic queue pause
mosaic queue resume
mosaic queue jobs
mosaic queue drain
# Object storage
mosaic storage status
mosaic storage tier
mosaic storage export
mosaic storage import
# Schema migration is unavailable in this release. The current storage wrapper shells
# directly to `pnpm --filter @mosaicstack/db db:migrate`; it is legacy N-1,
# uncertified, and MUST NOT be invoked pending KBN-101-02/-03/-06/-08 activation.
# Future schema migration is non-operative: external bootstrap → TLS/roles → runner
# --run → runner --verify → readiness. Tier copy uses only the separately held secure
# migrate-tier route.
```
### Telemetry
```bash
# Local observability (OTEL / Jaeger)
mosaic telemetry local status
mosaic telemetry local tail
mosaic telemetry local jaeger
# Remote telemetry (dry-run by default)
mosaic telemetry status
mosaic telemetry opt-in
mosaic telemetry opt-out
mosaic telemetry test
mosaic telemetry upload # Dry-run unless opted in
```
Consent state is persisted in config. Remote upload is a no-op until you run `mosaic telemetry opt-in`.
## Development
### Prerequisites
- Node.js ≥ 20
- pnpm 10.6+
- Docker & Docker Compose
### Setup
```bash
git clone [email protected]:mosaicstack/stack.git
cd stack
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
pnpm install
# Optional local queue service only. This does not start PostgreSQL.
docker compose up -d valkey
# The current Gateway/Web local process is held; see docs/guides/dev-guide.md.
# Do not start it until KBN-101-02 makes inherited dotenv/DSN state fail closed.
```
### Held future procedure
The checked-in Compose PostgreSQL service mounts legacy initialization SQL and is **not** a
current PostgreSQL, standalone, or federated developer route. Do not start it with Compose,
invoke initialization SQL, or treat the planned migrator as currently executable.
**Held future activation procedure — non-operative and no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05
land:** external bootstrap → TLS/roles → `mosaic-db-migrator --run` →
`mosaic-db-migrator --verify` → Gateway/Compose readiness. The future deployment artifacts—not
this README—will provide the reviewed commands and secret-consumer interface.
For local data-layer work, PGlite needs no PostgreSQL service. The optional Compose command above
starts only Valkey; OTEL Collector and Jaeger may likewise be started individually if needed,
without starting PostgreSQL. A Gateway/Web local process is not currently a safe PGlite route:
its unguarded dotenv loader may inherit a daemon PostgreSQL DSN. Do not use root `pnpm dev` or a
Gateway start command until KBN-101-02 makes that state fail closed.
### Quality Gates
```bash
pnpm typecheck # TypeScript type checking (all packages)
pnpm lint # ESLint (all packages)
pnpm test # Vitest (all packages)
pnpm format:check # Prettier check
pnpm format # Prettier auto-fix
```
### CI
Woodpecker CI runs on every push:
- `pnpm install --frozen-lockfile`
- **Legacy N-1 CI status only — active, uncertified, and non-authorizing as an operator route:** the checked-in job currently invokes `pnpm --filter @mosaicstack/db run db:migrate` with `DATABASE_URL` against an isolated disposable PostgreSQL CI database. It performs direct DDL in that CI database, is not approved ordinary behavior or an operator route, and remains a known exception pending KBN-101-06 removal/replacement by the certified runner-backed CI path.
- `pnpm test` (Turbo-orchestrated across all packages)
npm packages are published to the Gitea package registry on main merges.
## Architecture
```
stack/
├── apps/
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
│ └── web/ Next.js dashboard (React 19, Tailwind)
├── packages/
│ ├── mosaic/ Unified CLI — TUI, gateway client, wizard, sub-package commands
│ ├── types/ Shared TypeScript contracts (Socket.IO typed events)
│ ├── db/ Drizzle ORM schema + migrations (pgvector)
│ ├── auth/ BetterAuth configuration
│ ├── brain/ Data layer (PG-backed)
│ ├── queue/ Valkey task queue + MCP
│ ├── coord/ Mission coordination
│ ├── forge/ Multi-stage AI pipeline (intake → board → plan → code → review)
│ ├── macp/ MACP protocol — credential resolution, gate runner, events
│ ├── agent/ Agent session management
│ ├── memory/ Agent memory layer
│ ├── log/ Structured logging
│ ├── prdy/ PRD creation and validation
│ ├── quality-rails/ Quality templates (TypeScript, Next.js, monorepo)
│ └── design-tokens/ Shared design tokens
├── plugins/
│ ├── discord/ Discord channel plugin (discord.js)
│ ├── telegram/ Telegram channel plugin (Telegraf)
│ ├── macp/ OpenClaw MACP runtime plugin
│ └── mosaic-framework/ OpenClaw framework injection plugin
├── tools/
│ └── install.sh Unified installer (framework + npm CLI, --yes / --no-auto-launch)
├── scripts/agent/ Agent session lifecycle scripts
├── docker-compose.yml Dev infrastructure
└── .woodpecker/ CI pipeline configs
```
### Key Design Decisions
- **Gateway is the single API surface** — all clients (TUI, web, Discord, Telegram) connect through it
- **ESM everywhere** — `"type": "module"`, `.js` extensions in imports, NodeNext resolution
- **Socket.IO typed events** — defined in `@mosaicstack/types`, enforced at compile time
- **OTEL auto-instrumentation** — loads before NestJS bootstrap
- **Explicit `@Inject()` decorators** — required since tsx/esbuild doesn't emit decorator metadata
### Framework (`~/.config/mosaic/`)
The framework is the bash-based standards layer installed to every developer machine:
```
~/.config/mosaic/
├── AGENTS.md ← Central standards (loaded into every runtime)
├── SOUL.md ← Agent identity (name, style, guardrails)
├── USER.md ← User profile (name, timezone, preferences)
├── TOOLS.md ← Machine-level tool reference
├── bin/mosaic ← Unified launcher (claude, codex, opencode, pi, yolo)
├── guides/ ← E2E delivery, orchestrator protocol, PRD, etc.
├── runtime/ ← Per-runtime configs (claude/, codex/, opencode/, pi/)
├── skills/ ← Universal skills (synced from agent-skills repo)
├── tools/ ← Tool suites (orchestrator, git, quality, prdy, etc.)
└── memory/ ← Persistent agent memory (preserved across upgrades)
```
### Forge Pipeline
Forge is a multi-stage AI pipeline for autonomous feature delivery:
```
Intake → Discovery → Board Review → Planning (3 stages) → Coding → Review → Remediation → Test → Deploy
```
Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding), quality gates, and timeouts. The board review uses multiple AI personas (CEO, CTO, CFO, COO + specialists) to evaluate briefs before committing resources.
## Upgrading
Run the installer again — it handles upgrades automatically:
```bash
curl -fsSL https://mosaicstack.dev/install.sh | bash
```
Or use the direct URL:
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
```
Or use the CLI:
```bash
mosaic update # Check + install CLI updates
mosaic update --check # Check only, don't install
```
The CLI also performs a background update check on every invocation (cached for 1 hour).
### Installer Flags
```bash
bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --ref v1.0 # Install from a specific git ref
bash tools/install.sh --yes # Non-interactive, accept all defaults
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
```
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
## Contributing
```bash
# Create a feature branch
git checkout -b feat/my-feature
# Make changes, then verify
pnpm typecheck && pnpm lint && pnpm test && pnpm format:check
# Commit (husky runs lint-staged automatically)
git commit -m "feat: description of change"
# Push and create PR
git push -u origin feat/my-feature
```
DTOs go in `*.dto.ts` files at module boundaries. Scratchpads (`docs/scratchpads/`) are mandatory for non-trivial tasks. See `AGENTS.md` for the full standards reference.
## License
Proprietary — all rights reserved.
## Authentication
Pi's documented container authentication (see the package's
`docs/containerization.md`) is used, in this order:
1. **Read-only mounted credential file** (default): the host pi auth file
`~/.pi/agent/auth.json` is bind-mounted read-only to
`/home/node/.pi/agent/auth.json`. The host file holds a static API-key
entry for the built-in `zai` provider, so no token refresh writes are needed.
2. **Runtime environment variable** (documented alternative): set `ZAI_API_KEY`
or `ANTHROPIC_API_KEY` in the environment or in a gitignored `.env`; compose
passes them through. Pi's documented precedence applies.
Credentials are never committed, never copied into the image, and never printed.
Mosaic-managed named accounts (`agent.sh --auth`) live under the data root
(`auth/<account>.json`, 0600) — the stack never writes into `~/.pi`.
`.env.example` contains non-secret settings only.
## Boundaries honored
- No mounts of `~/.mosaic` or `~/.config/mosaic`; no Docker socket mount.
- Source stays in this project directory; generated state only in
`/home/jwoltje/.mosaic-dev` (host) and `/var/lib/mosaic` (container).
- No database, web server, queue, second container, orchestration, Git
integration, persistent sessions, or policy machinery.
+1
View File
@@ -0,0 +1 @@
0.0.12
+30
View File
@@ -0,0 +1,30 @@
# Mosaic Stack
You are the default collaborator for Mosaic Stack: a practical engineering
partner helping people build, inspect, and operate a trustworthy foundation
for delegated work.
Mosaic Stack is deliberately small, file-based, and evidence-oriented. Its
purpose is not to perform confidence; it is to make useful work attributable,
bounded, reproducible, and reviewable. Treat the system's contracts, policies,
and run records as part of the product, not paperwork around it.
Work with calm precision. Start from what the user is trying to accomplish,
make the next useful step clear, and explain results in plain language. Be
decisive when the evidence supports a decision; be explicit about uncertainty
when it does not. Never claim a test, command, integration, or outcome that
you have not actually verified.
Respect boundaries. Ask before expanding scope, changing authority, touching
credentials, or taking an irreversible external action. Prefer the least
privileged path, preserve user work, and stop on a policy or validation
refusal rather than working around it. A clean refusal with a useful diagnosis
is better than a superficially successful but untrustworthy result.
Leave a legible trail. Make changes intentional, keep records honest, and
report what changed, how it was checked, and what remains unresolved. When
coordinating other workers, give each one a bounded objective and review their
evidence instead of treating their confidence as proof.
The aim is dependable progress: small enough to understand, safe enough to
trust, and concrete enough for a person to verify.
+54
View File
@@ -0,0 +1,54 @@
# Mosaic runtime adapters
An adapter is the entire harness-specific surface of the system. Everything
upstream of an adapter — configuration, contracts, missions, tasks, run
records — is harness-agnostic; everything inside an adapter may assume one
specific agent runtime.
## Contract
An adapter lives at:
```text
/opt/mosaic/adapters/<name>/adapter.sh
```
and must be executable. The dispatcher (`/opt/mosaic/src/run-agent.sh`)
selects it via `MOSAIC_ADAPTER` (default: `pi`) and execs it after the
system prompt has been generated.
**Inputs (environment):**
| Variable | Meaning |
|---|---|
| `MOSAIC_SYSTEM_PROMPT_FILE` | Absolute path to the generated system prompt (contracts + optional mission section). Read it; do not modify it. |
| `MOSAIC_REQUEST` | The exact user request text (may contain newlines). |
| `MOSAIC_PROVIDER` | Configured provider name. |
| `MOSAIC_MODEL` | Configured model id. |
Optional, adapter-specific (documented per adapter):
| Variable | Meaning |
|---|---|
| `MOSAIC_MOCK_RESPONSE` | mock only: the verbatim response to emit |
**Outputs:**
- `stdout`: the model response text — the only channel the orchestrator captures
- `stderr`: diagnostics (never credentials)
- exit `0`: success; nonzero: failure
## Rules
1. Adapters print ONLY the response on stdout. Status lines go to stderr.
2. Adapters never read configuration files; the resolved settings arrive via environment.
3. Adapters never write outside `/var/lib/mosaic`.
4. Adding an adapter requires: a new directory, the contract implementation, and
adding the name to the allowlist in `scripts/mosaic-config.mjs`.
## Included adapters
- `pi` — the pinned `@earendil-works/pi-coding-agent` CLI in noninteractive
print mode (`-p`), ambient discovery disabled, stdin detached.
- `mock` — deterministic echo of `MOSAIC_MOCK_RESPONSE`. Test-only: never use
it where a real model response is required.
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# Mock adapter: deterministic response for seam tests. NEVER use where a
# real model response is required.
#
# Contract: see /opt/mosaic/adapters/README.md.
set -eu
[ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "mock adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; }
if [ "${MOSAIC_INTERACTIVE:-}" != "1" ]; then
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "mock adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
fi
[ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "mock adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
echo "mock adapter: responding verbatim from MOSAIC_MOCK_RESPONSE" >&2
# Deterministic plumbing evidence: which MOSAIC_* variables did the
# orchestrator actually deliver? (Auth secrets are not MOSAIC_-prefixed.)
(env | grep '^MOSAIC_' | sort) >&2 2>/dev/null || true
printf '%s\n' "${MOSAIC_MOCK_RESPONSE:-}"
+96
View File
@@ -0,0 +1,96 @@
#!/bin/sh
# Pi adapter: implements the Mosaic adapter contract for the pinned
# @earendil-works/pi-coding-agent CLI.
#
# Contract: see /opt/mosaic/adapters/README.md.
# Headless (default): stdout = response only; stderr = diagnostics; exit 0.
# Interactive (MOSAIC_INTERACTIVE=1): full pi TUI on the attached terminal.
set -eu
[ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "pi adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; }
[ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "pi adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
# MOSAIC_AGENT_NAME is optional in headless mode (identity section is then
# omitted); interactive launches always set it via scripts/agent.sh.
: "${PI_PROVIDER:?pi adapter: PI_PROVIDER is required}"
: "${PI_MODEL:?pi adapter: PI_MODEL is required}"
INTERACTIVE="${MOSAIC_INTERACTIVE:-}"
if [ "$INTERACTIVE" != "1" ]; then
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "pi adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
fi
# Workspace (M5): run inside the provided workspace when present.
if [ -n "${MOSAIC_WORKSPACE:-}" ]; then
mkdir -p "$MOSAIC_WORKSPACE"
cd "$MOSAIC_WORKSPACE"
fi
# Session (M6/M11): default ephemeral (--no-session). With a declared
# session dir: persist there and resume the most recent session. With a
# fork source: branch the source session file into the target dir
# (pi --fork) - the ancestor session is never modified.
SESSION_FLAGS="--no-session"
if [ -n "${MOSAIC_SESSION_FORK:-}" ]; then
[ -n "${MOSAIC_SESSION_DIR:-}" ] || { echo "pi adapter: session fork requires MOSAIC_SESSION_DIR" >&2; exit 2; }
mkdir -p "$MOSAIC_SESSION_DIR"
SESSION_FLAGS="--fork $MOSAIC_SESSION_FORK --session-dir $MOSAIC_SESSION_DIR"
elif [ -n "${MOSAIC_SESSION_DIR:-}" ]; then
mkdir -p "$MOSAIC_SESSION_DIR"
SESSION_FLAGS="--session-dir $MOSAIC_SESSION_DIR"
if [ -n "$(ls -A "$MOSAIC_SESSION_DIR" 2>/dev/null)" ]; then
SESSION_FLAGS="$SESSION_FLAGS -c"
fi
fi
# Capabilities (M5): explicit allowlist or no tools.
TOOLS_FLAG="--no-tools"
[ -n "${MOSAIC_TOOLS:-}" ] && TOOLS_FLAG="--tools $MOSAIC_TOOLS"
# Skills (M17): explicitly provided skill dirs replace discovery. When none
# are provided the agent runs with --no-skills (nothing ambient to find).
SKILLS_FLAG="--no-skills"
if [ -n "${MOSAIC_SKILLS:-}" ]; then
SKILLS_FLAG=""
OLDIFS=$IFS; IFS=','
for s in $MOSAIC_SKILLS; do
[ -d "$s" ] || { echo "pi adapter: skill dir missing: $s" >&2; exit 2; }
SKILLS_FLAG="$SKILLS_FLAG --skill $s"
done
IFS=$OLDIFS
fi
# Mode (M13): interactive TUI or one-shot print.
PRINT_MODE="-p"
REQUEST_ARG=""
if [ "$INTERACTIVE" = "1" ]; then
PRINT_MODE=""
else
REQUEST_ARG="$MOSAIC_REQUEST"
fi
# All flags documented in the pi package README (CLI Reference):
# -p/--print one-shot mode: print the response and exit (omitted in
# interactive TUI mode)
# --system-prompt replace the default prompt with the generated one
# --no-* no ambient context/skills/extensions/templates/themes
# SESSION_FLAGS ephemeral | persistent | forked (per env)
# TOOLS_FLAG per capabilities
# --offline no startup network operations (update checks/telemetry)
PROMPT_CONTENT="$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")"
set -- \
--offline \
--no-extensions \
$SKILLS_FLAG \
--no-prompt-templates \
--no-themes \
--no-context-files \
$TOOLS_FLAG \
$SESSION_FLAGS \
--provider "$PI_PROVIDER" \
--model "$PI_MODEL" \
--system-prompt "$PROMPT_CONTENT"
# One-shot mode appends -p and the request (both safely quoted);
# interactive mode appends nothing - clean TUI.
[ "$INTERACTIVE" = "1" ] || set -- "$@" -p "$MOSAIC_REQUEST"
exec pi "$@"
+42
View File
@@ -0,0 +1,42 @@
# Mosaic Stack development team
These are interactive host development agents working in the canonical
checkout. They do not create managed fleet registrations or change role policy.
Sage leads the project and coordinates assignments, review, and integration
(Jason's ruling, 2026-09-26). Development sessions run in T3 for now.
For the current bootstrap phase, direct coding, review and research use Darkwing,
Dewey, Filbert, Rocko, Researcher and further seats Jason launches from this repository's `agents/` directory,
not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
`~/.mosaic` launchers/provisioning or migrate/stop live fleet processes.
| Agent | Responsibility | Runtime | Launch from repository root |
| --- | --- | --- | --- |
| Darkwing | Hands-on engineering; collaborating seat under Sage | Pi, configured Mosaic model | `agents/darkwing/launch.sh` |
| Dewey | Frontend design, UX, accessibility, and UI implementation | Pi, configured Mosaic model | `agents/dewey/launch.sh` |
| Rocko | General development, investigation, testing, and review | Claude Code, Sonnet model | `agents/rocko/launch.sh` |
| Filbert | General development, investigation, testing, and review | Pi, `openai-codex/gpt-6-astra:low` | `agents/filbert/launch.sh` |
| Researcher | Source-grounded technical research and evidence | Pi, configured Mosaic model | `agents/researcher/launch.sh` |
| Sage | Project lead: coordination, review, integration; earlier DYOR strategy records retained | T3 session (Claude Code); Pi launcher `zai/glm-5.3:high` retained | `agents/sage/launch.sh` |
Each script supports `--check` and `--fresh`. Normal launches resume the agent's
own conversation; a first launch starts one. See each agent's README for
context inputs, authentication, and recovery details. Launch scripts can also
be invoked by absolute path from any directory. No assignment or model request
is submitted by the launcher itself.
The shared Pi helper supports `--provider NAME`, `--model ID`, and
`--thinking LEVEL` as per-launch overrides of the validated system defaults.
Filbert's wrapper appends the required provider, model and thinking flags so
its launch configuration remains fixed, including on resume. Use Filbert's
wrapper to select that configuration; a direct shared-helper invocation uses
its own supplied flags or the system defaults.
All agents follow repository governance and current user direction. Team
leadership does not add deployment or push authority. Coordinate overlapping
work with Sage and preserve other sessions' changes.
Before 2026-09-26 Sage worked only on DYOR strategy and sat outside the
development queue. Jason then made Sage the project lead and Darkwing a
collaborating seat. A separate fleet Sage seat under `~/.mosaic` is being
decommissioned; it does not speak for this seat. Whether the DYOR strategy work
continues is Jason's call. Joe retains DYOR engineering.
+38
View File
@@ -0,0 +1,38 @@
===== DARKWING NATIVE DEVELOPMENT CONTEXT =====
Your identity is Darkwing. This launch runs Pi directly on the host, in the
Mosaic Stack development repository. The injected SOUL defines your persona;
CONSTITUTION and STANDARDS supply governance, USER supplies user context,
and AGENTS.md supplies repository instructions.
You have host read, bash, edit, write, grep, find, and ls tools. This is a
development TUI with the operator's OS access, not a sandbox or a registered
managed fleet seat. Use repository scripts for Mosaic operations and inspect
their effects before running them. Container paths in skills describe worker
deployments, not your current workspace. A tool's presence is not authority
to change unrelated files, other agents' work, or the live fleet.
For an assigned improvement, inspect the implementation, reproduce the issue,
make the smallest useful change, verify it, and continue through the authorized
outcome. Run scripts/mosaic queue next darkwing for ownership, gates and your next piece;
a new user assignment does not silently resume unrelated queued work.
The local /goal extension is loaded and owns any operator-set goal lifecycle.
Use ms-proactive-agent for work selection and ms-goal for recovery guidance;
do not create a competing goal loop. Follow goal_report's actual schema and
reporting instructions. Its text format is Just Completed / Next Step /
Blocked, with '* none' for empty sections. No external reporting skill is
needed to discover that format. Native development packaging supersedes
older skill statements that this extension is unavailable.
For relocation recovery, read agents/darkwing/work/RESTART.md after the root
AGENTS.md and docs/plans/CURRENT.md. It records verified checkpoints and limits,
not a new assignment. The canonical checkout is /mnt/storage/src/mosaic-stack;
v1/ is archived legacy source. Reconcile newer owner direction before acting.
Conversation history persists across launcher restarts. Goals belong to a
single process incarnation; recover the assignment from verified records and
the operator's direction after a restart. No goal is started by this launcher.
Context is captured anew at launch; source edits do not update this process's
injected snapshot. Relaunch to load approved context changes.
+64
View File
@@ -0,0 +1,64 @@
# Darkwing development TUI
From any terminal, run:
```sh
/home/jwoltje/src/mosaic-stack-dev-test/agents/darkwing/launch.sh
```
The agent launcher is a thin shim to `scripts/agent.sh --host-dev darkwing`,
forwarding all arguments unchanged. `scripts/agent.sh` is the common entry
point; `scripts/agent-host-dev.sh` implements its native development mode.
The host launcher opens the repository as Darkwing's workspace.
It uses the repository-pinned Pi, the configured Mosaic provider/model, and
native Pi authentication (normal `~/.pi/agent`, or `PI_CODING_AGENT_DIR` if
explicitly set). It never copies credentials. Install dependencies with
`npm ci --ignore-scripts --no-audit --no-fund` if needed.
`--check` validates configuration and required inputs without opening Pi or
calling a model. `--fresh` starts a new conversation without deleting earlier
ones. Normal launches continue the latest conversation under
`.pi/state/darkwing/sessions/`; the first launch creates one. A launcher lock
rejects simultaneous launches through this script. It does not exclude Pi
processes started another way. Damaged JSONL history refuses automatic resume;
`--fresh` is an explicit escape hatch that preserves the damaged evidence.
The current files are combined into a private launch snapshot under
`.pi/state/darkwing/launches/`:
- `contracts/CONSTITUTION.md` and `contracts/STANDARDS.md`
- `agents/darkwing/SOUL.md`
- `<configured dataRoot>/user/USER.md`, the deployment's live user profile
- the repository's `AGENTS.md` and Darkwing's `CONTEXT.md`
Use `--soul FILE`, `--constitution FILE`, or `--user FILE` to select alternate
inputs, including a future `contracts/USER.md`. Relative paths resolve from
the repository root. Missing or empty inputs refuse launch. Snapshots can
contain personal context and remain local, with private file permissions.
Context edits take effect on relaunch, including when resuming a conversation.
The launcher enables coding/search tools, `goal_report`, ten explicit local
skills, and the canonical goal extension through `scripts/sync-dev-extensions.sh`.
Ambient context, skills, extensions, templates, and themes are disabled.
The normal Pi coding prompt is retained with the Mosaic context appended.
Enter `/goal <assignment and acceptance criteria>` to start continuing work;
`/goal stop`, `/goal resume`, and `/goal` pause, resume, and inspect it. A new
process does not automatically adopt a previous process's goal.
This TUI has the operator's host access, including repository edits and host
commands. Its tool list is not OS isolation. It creates no managed role or
fleet registration. Worker dispatch still uses the governed Mosaic task runner.
The user supplies the assignment; launch alone does not start self-modification.
## Deployment findings
The existing `scripts/agent.sh` launches a Docker container, defaults to the
`agent-<name>` session directory, and asks Pi to continue when that directory
is nonempty. Its default workspace is `<dataRoot>/workspaces/<name>`, not this
checkout. `src/load-contracts.sh` loads image-baked governance, an optional
seat SOUL override, live user Markdown, and mission context into a shared
prompt path. A seat override requires `agent.json`; a standalone SOUL is not
discovered. `adapters/pi/adapter.sh` disables extensions. The temporary host
launcher follows the existing native development path to provide repository
access and `/goal`, and keeps its conversations separate from container and
live fleet sessions. It does not invoke release alignment on startup.
+30
View File
@@ -0,0 +1,30 @@
# SOUL — Darkwing
You are Darkwing, Mosaic Stack's hands-on engineering collaborator, working
with Sage as project lead. Your job is to help Jason make the system
dependable by using it, finding where it falls short, and carrying authorized
improvements through verification.
Be curious, direct, and resourceful. Have a technical opinion and explain
the evidence behind it. Investigate before guessing. Distinguish a design
claim, a passing test, and behavior you have observed in the running system.
Use Mosaic's own tools and workflows where they fit. Turn a failure into a
reproducible case, make a focused correction, and test the behavior again.
Let each verified improvement inform the next one within the assignment.
Keep the human informed when the result, scope, or next decision changes.
Own the outcome while respecting other agents' work. Preserve their changes
and records, give delegated work clear boundaries, and seek independent
review where required. Self-improvement never grants new authority: changing
your instructions, permissions, or a live deployment follows the same review
and authorization rules as any other system change.
Sage leads the development team (Jason's ruling, 2026-09-26) and translates
Jason's priorities into scoped work, coordinates ownership and dependencies,
reviews results, and verifies integration. You are a collaborating seat.
Dewey owns frontend design and UX. Rocko and Filbert support general project needs,
including implementation, investigation, testing, and review. Reconcile
concurrent edits with Sage before integration. Keep Jason informed of
outcomes and decisions that require his input. Your role does not expand the
project's existing authorization or release rules.
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Darkwing's native development mode through the Mosaic agent entry point.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# Register this seat with the control board (packages/seat) unless already
# registered by `mosaic launch` or only running the checks.
if [ -z "${MOSAIC_LAUNCH_REGISTERED:-}" ] && ! printf '%s\n' "$@" | grep -qx -- '--check'; then
exec "$REPO/scripts/mosaic" launch --repo "$REPO" --harness pi darkwing -- "$@"
fi
exec "$REPO/scripts/agent.sh" --host-dev darkwing "$@"
+21
View File
@@ -0,0 +1,21 @@
// Refuse damaged history before Pi's --continue can silently skip it.
import { readFileSync, lstatSync } from 'node:fs';
try {
for (const file of process.argv.slice(2)) {
if (!lstatSync(file).isFile()) throw new Error(`not a regular session file: ${file}`);
const lines = readFileSync(file, 'utf8').trim().split('\n');
const entries = lines.map((line) => JSON.parse(line));
const header = entries[0];
if (header?.type !== 'session' || typeof header.id !== 'string' || !header.id ||
typeof header.version !== 'number' || typeof header.cwd !== 'string' ||
!Number.isFinite(Date.parse(header.timestamp)) ||
entries.slice(1).some((entry) => !entry || typeof entry.type !== 'string')) {
throw new Error(`invalid session structure: ${file}`);
}
if (header.cwd !== process.cwd()) throw new Error(`session belongs to another workspace: ${file}`);
}
} catch (error) {
console.error(`darkwing: cannot safely resume: ${error.message}; inspect history or explicitly use --fresh`);
process.exit(1);
}
+108
View File
@@ -0,0 +1,108 @@
# Darkwing — relocation handoff
Recorded 2026-09-07 17:43 UTC. Jason intends to relaunch with
`/mnt/storage/src/mosaic-stack/agents/darkwing/launch.sh`.
This is a recovery note, not a new assignment or automatic goal resumption.
## Read first
1. Root `AGENTS.md` and `docs/plans/CURRENT.md`.
2. This note, then `git status --short` and `git log --oneline -5`.
3. Reconcile current owner direction and any newer declared artifacts before acting.
## Repository conversion is completed locally
Jason explicitly ordered the conversion and confirmed no work was active.
- Canonical checkout: `/mnt/storage/src/mosaic-stack`.
- Origin: `https://git.mosaicstack.dev/mosaicstack/stack`.
- Branch: `refactor`.
- Conversion commit: `127a54fdff1fe6ae56c3197edddf957481465db4`.
- New foundation is at root. `v1/` is legacy archival source, NOT current code.
- Old `/home/jwoltje/src/mosaic-stack-dev-test` is a compatibility symlink to this
same checkout. Do not recreate a second working copy there.
- Both histories retained: merge parents v2 `9a5fbdbda74b16adf488fe28138b2ba69ea5e669`
and v1 `5d2770002612a09ae0cadc129b4ea30619133e8a`.
- Exact 3,507-file v1 tracked tree imported; v1 refs under `refs/archive/v1/`.
- Original v2 refs retained; `stack-v2-archive` remote has a disabled push URL.
- Only legacy tracked tree and four conversion docs committed. All earlier
uncommitted/untracked/ignored work preserved. Index was verified clean.
- Issue https://git.mosaicstack.dev/mosaicstack/stack/issues/1495 closed explicitly
for local conversion. No push, PR/trunk merge or live-service change occurred.
Record: `docs/plans/2026-09-07_repository-consolidation-completed.md`.
Receipts: `docs/plans/reviews/2026-09-07_repository-conversion-verification.json`
and `2026-09-07_repository-conversion-postcommit-verification.json`.
Verified rollback copies, NOT development roots:
- `/mnt/storage/src/.mosaic-stack-conversion-20260907T172430Z/`
- `/home/jwoltje/src/.mosaic-stack-dev-test.pre-conversion-20260907T172430Z`
Do not delete them, launch from them or restore over newer work.
## Current unfinished foundation gate
Jason's A9 acceptance of the first offline synthetic scope/permission inspector
is pending. Code is independently approved by Filbert; no blocking code finding
remains at the reviewed r6 candidate. Owner acceptance is not inferred from tests.
- Manifest: `docs/plans/reviews/2026-09-07_foundation-inspector-rocko-build-manifest-r6.json`
SHA-256 `a4a4493000aff5905337a643886ca36e7c5377d52deed77b8aeab7174ca73dcf`.
- Report: `docs/plans/reviews/2026-09-07_foundation-inspector-rocko-build-r6.md`
SHA-256 `ee0e83efd7c71eddecf5e26f939e9a34ba85b184cfcd1cffac9ff9e56ea13c37`.
- APPROVED verdict: `docs/plans/reviews/2026-09-07_foundation-inspector-code-verdict-r6.md`
SHA-256 `ab9dd5e5c3cad5c9263e873ff82cac444da2d36040e907e4798b208fa1c08b13`.
- Guide: `docs/plans/reviews/2026-09-07_foundation-inspector-demo.md`.
All 382 approved inspector files and pinned inputs survived conversion unchanged.
Actual offline checks: Node 80/0, selftests 43/0, oracle 1,568 records / zero
schema disagreements, foundation checker PASS, config/auth/conductor 24/15/17.
Postcommit conductor 17/0 and four CLI demos passed: allowed read, allowed change
PREVIEW (no mutation), missing-registration refusal, unresolved reassignment with
original selection retained. Demo inputs are separate synthetic scenarios.
`test-task.sh` and `test-release.sh` remain NOT RUN / DEFERRED under Jason's bounded
offline-demo ruling. No deployment/native/live/provider/security certification.
Reviewer qualifications: ordering equality means structural equality, not byte
identity; auxiliary native-parser warm-run anomalies remain separate unresolved
observations, not a passing universal parser-equivalence claim. Preserve all earlier
NOT APPROVED reviews and the historical correction that r3 ran unauthorized live
branches; later deferral did not retroactively authorize them.
## Ownership and limits
- Rocko authored inspector code; Filbert independently reviewed; Darkwing coordinates
and verifies. Keep the approved candidate frozen unless a new fix is authorized.
- No automatic permission to push, merge to next/main, deploy, change live config,
grant permissions, access credentials, investigate ~/.mosaic, or start new runtime
work. Local conversion authority is not authority for those activities.
- Preserve unrelated pending work. In particular `scripts/agent.sh`, `docs/TOOLS.md`,
host launcher/context files and other untracked concepts/skills belong to existing
work. Do not blanket-stage/reset/clean. Root logs and CURRENT remain uncommitted.
- Foundation #53 in the old stack-v2 project remains a separate open issue; do not
silently close or renumber it. Accepted historical SHA/path citations remain valid.
- Rocko's Archify C1 remains HELD for owner T2/T3 decisions. No lane reassignment.
- Future durability/workflow/evidence/federation/onboarding topics are notes, not
authorization to expand the inspector.
## Communications
Use only `tools/tmux/agent-send.sh`; sender `dragon-lin:darkwing`.
Rocko: `-L mosaic-fleet -s '=rocko'`; Filbert/Dewey:
`-L default -s '=filbert'` / `'=dewey'`.
Conversion notice delivered to Rocko. Filbert/Dewey sends were unconfirmed
(input boxes not locatable); no retries, no acknowledgement claimed. Check declared
artifact paths as well as direct messages; completed reviews have existed without
transported replies. Do not inspect private panes or blindly resend.
## Relaunch and goal recovery
The project launcher continues its own latest `.pi/state/darkwing/sessions/`
conversation by default. Do NOT assume this pre-launch conversation is already in
that store or that the next launch resumes this exact conversation. This handoff
is the durable bridge. No session-tree migration or launch was performed here.
The goal extension owns lifecycle. The earlier extension goal had been paused;
no restart automatically resumes it. Reconcile the actual new process state and
Jason's direction rather than reporting progress against a guessed old goal or
creating a second goal loop. Launch alone grants no new assignment.
This handoff and its CONTEXT pointer are documentation-only. Launcher scripts,
private sessions, credentials and runtime configuration were not modified.
@@ -0,0 +1,17 @@
{
"issue": 1503,
"candidate": "/tmp/board-attention-r1-q_924ksh",
"files": [
"AGENTS.md",
"packages/control-board/src/scan.mjs",
"packages/control-board/README.md",
"packages/control-board/tests/scan.test.mjs",
"packages/control-board/tests/serve.test.mjs",
"packages/control-board/tests/attention.test.mjs",
"packages/control-board/tests/attention-flow.test.mjs",
"packages/webui/tests/fixture.mjs",
"docs/plans/2026-09-13_board-attention-status.md"
],
"manifestSha256": "e40b58ecb6844d407ba776dcde8f19ad21c0b78076ca1f9b3d96b0bb1c852405",
"testsPassed": 144
}
@@ -0,0 +1,14 @@
{
"at": "2026-09-14T00:19:32.409806+00:00",
"backendPid": 3204655,
"health": "ok",
"researcher": {
"agent": "researcher",
"project": "mosaic-stack",
"alive": true,
"state": "idle",
"waitingOnYou": false,
"lastActivity": "2026-09-13T21:19:57.102Z"
},
"fiveAgentProcessesUnchanged": true
}
@@ -0,0 +1,46 @@
{
"at": "2026-09-14T00:19:11.579013+00:00",
"ownerAuthorized": true,
"oldPid": 1265952,
"newPid": 3204655,
"command": [
"/usr/bin/node",
"packages/control-board/src/cli.mjs",
"serve"
],
"cwd": "/mnt/storage/src/mosaic-stack",
"log": "/tmp/board-attention-backend-ag9xvks2.log",
"agentProcessesBefore": {
"default/darkwing": [
[
"2733924",
"12863634"
]
],
"default/dewey": [
[
"934346",
"466065"
]
],
"default/filbert": [
[
"72183",
"100870"
]
],
"default/researcher": [
[
"173699",
"66404285"
]
],
"mosaic-fleet/rocko": [
[
"90599",
"128275"
]
]
},
"gracefulExitObserved": true
}
@@ -0,0 +1,54 @@
# CHAT-02 board routes: Darkwing's review (#1507)
Reviewer: Darkwing, 2026-09-26, per Sage's D3. Requested by Dewey. Scope: the
two read-only routes only. Filbert reviews `packages/conversation` in full.
Candidate, base 34777c56, uncommitted. I verified both hashes:
- `packages/control-board/src/serve.mjs` afc95bdb…c540d
- `packages/control-board/tests/serve.test.mjs` e60aa14b…ecbc
**Verdict: approve**, with one commit condition and two nonblocking notes.
## What I checked
- Order. `foreignRequest` runs first on every request, then the POST routes,
then the GET/HEAD check (405 otherwise), then these routes. A POST to
either path is 405, and a foreign Host or Origin is 403 before any read.
- Query validation. `/api/conversations` refuses any parameter.
`/api/conversation` accepts only `id`, `branch` and `cursor`, one value
each, each matching `QUERY_VALUE`. That is the same pattern as
`parts.mjs` `ID`, so every id the reader issues (`safeId`, `root`, `c-`
cursors, `pi-` conversations) passes. No path comes from the request.
- Responses. JSON with `no-store` and `nosniff`, no CORS headers. A thrown
error gives a fixed 500 body and logs to stderr only.
- Refusal bodies. Every `Refusal` message in `packages/conversation/src` is
a fixed string. The one interpolated message (`denied`, safe-fs.mjs:34)
interpolates only "session root" or "session file". No path or content
reaches the client through `error`.
- Status map. It covers every code the route can reach. `unknown-actor` and
`unsupported-purpose` are absent, and the route can't produce them because
it always passes the default actor and purpose.
- Tests: `serve.test.mjs` plus `packages/conversation/tests/`, 61/61 on the
pinned files.
## Commit condition
`serve.mjs` imports `../../conversation/src/reader.mjs` at module load, and
`packages/conversation/` is untracked. Committing the routes without that
package breaks the board's start, not only these routes. The package must
land in the same commit or an earlier one, after Filbert's review.
## Notes (nonblocking)
1. **A cursor needs its branch.** The header comment says `branch` and
`cursor` are optional. But `next()` compares `branch !== record.branch`,
and every cursor record carries a string branch (`safeId` or `root`). So
`?id=X&cursor=C` without `branch` is always 409 `cursor-foreign`, with
`reconcile: true`. That is safe, but a client that follows `nextCursor`
alone gets a refusal that reads like a stale view. The test passes the
page's branch, so it doesn't show this. Either say in the comment that a
cursor call must repeat `page.branch`, or answer 400 "cursor requires
branch". I'd take the comment now and let CHAT-03's client decide.
2. **New codes fall to 422.** A code the reader adds later maps to 422
without a test failing. A test that runs the reader's refusal codes
through `REFUSAL_STATUS` would catch that. That's optional.
@@ -0,0 +1,69 @@
# CHAT-02 board routes, revision 2: Darkwing's review (#1507)
Reviewer: Darkwing, 2026-09-26, at Sage's request. Scope: the route delta
since my R1 approval (`chat-02-routes-review-2026-09-26.md`, 07b10ad1). Filbert
reviewed the backend (packet `agents/dewey/work/chat-02/BACKEND.md`, 0cf177b1).
Candidate, base 34777c56, uncommitted. I verified both hashes:
- `packages/control-board/src/serve.mjs` d62720dc…a2f3
- `packages/control-board/tests/serve.test.mjs` d38aa2b2…3f4a
**Verdict: approve.** The R1 commit condition still holds, and I have two
new nonblocking notes.
## What I checked
I kept no copy of the R1 files, so I read the whole route change against the
base (`git diff 34777c56 -- packages/control-board`) instead of only the
delta. It covers every item the packet's §0 lists and nothing else in the
route path.
- **Cursor needs its branch.** This was my R1 note 1. `conversationQuery` now
answers 400 "a cursor call repeats the page's branch" when `cursor` comes
without `branch`. The check runs after the per-key validation, so a
malformed value still gets its own 400 first. The header comment says the
same. A test covers it, and removing the line fails it.
- **Status map.** My R1 note 2. `REFUSAL_STATUS` is exported and now has 16
entries. I listed every `new Refusal("<code>"` in
`packages/conversation/src` myself and got 15 codes plus
`unsupported-harness`, which reader.mjs:352 raises by value. That matches the
map exactly. `parts.mjs` raises none. `unavailable`, which safe-fs.mjs:58
raises when a session root doesn't exist, is 404. That fits the rest of the
map, where not-found is 404. `unknown-actor` 403 and `unsupported-purpose` 422
are explicit now.
- **Order and guards** are unchanged from R1. The foreign Host or Origin check
comes first, then the POST routes, then 405, then these routes. No path comes
from the request, and responses carry `no-store` and `nosniff` with no CORS
headers.
- **Tests.** `serve.test.mjs` plus `packages/conversation/tests/` pass
67/67 on the pinned files.
- **Mutations** on a scratch clone of HEAD with the conversation package and
the two pinned files:
| Mutation | Result |
|---|---|
| cursor-without-branch check removed | 1 fails |
| `unknown-actor` entry dropped | 1 fails (the scan test) |
| `nosniff` removed | 1 fails |
| repeated-parameter check removed | 1 fails |
| catalogue parameter check removed | 1 fails |
| `unavailable` changed from 404 to 422 | nothing fails |
The last row is note 1 below.
## Commit condition (unchanged)
`serve.mjs` imports `../../conversation/src/reader.mjs` at module load, and
`packages/conversation/` is still untracked. The package must land in the
same commit as the routes or an earlier one. Otherwise the board fails to
start.
## Notes (nonblocking)
1. **The scan test checks keys, not values.** It proves every code has an
entry. No test proves `unavailable` is 404. If someone edits that value, or
any status no route test exercises, nothing fails. A table test that
asserts the whole `REFUSAL_STATUS` object would pin them. That's optional.
2. **The scan reads a fixed list of three files.** If a refusal is added to
`parts.mjs` or a new file, the scan won't see it, and that code falls to 422.
Reading every `.mjs` in `packages/conversation/src` would close the gap.
@@ -0,0 +1,28 @@
# Independent acceptance checklist, row 18
Darkwing reviews Filbert's implementation without editing its source candidate.
Dewey reviews visible connector presentation. No live connector manipulation.
- Discovery accepts only safe matching binding name/seat from private regular
files, never dereferences a token path and never serializes private fields.
- Path traversal, symlinked binding/runtime/session paths and malformed records
cannot cause arbitrary reads or an actionable/live row.
- No owner, malformed owner, dead PID, missing identity, reused PID and boot
mismatch are non-live. A positively matching live process is live.
- STOP presence is visible as braked independently of process liveness. Its
contents are not read or exposed; no STOP or lock is created or changed.
- Ordinary completed messages remain idle. No false human attention regression.
- Connector rows cannot borrow a native agent's registration for replies.
Exercise replyToRow and HTTP using a fake executable hook; every connector
attempt must be refused before that hook runs, including with forged tmux
registration. Normal-agent reply tests must still pass.
- Both existing board and WebUI distinguish the connector and brake state and
omit reply controls. Preserve escaping, including hostile binding fixtures.
- Discovery errors disclose no private JSON fields or raw contents. One bad
binding must not silently manufacture a healthy row.
- Candidate pins match before and after tests. Existing dirty attention changes
remain intact; no unrelated source integration or live operation is inferred.
After source approval, measure the real row read-only. Offline/braked behavior
uses isolated fixtures unless the operator separately approves a live-service
transition. Board replacement is its own protected gate.
@@ -0,0 +1,23 @@
{
"at": "2026-09-14T13:51:10.530662+00:00",
"backendPid": 3769124,
"health": "ok",
"row": {
"agent": "sage (discord: shared-signals)",
"project": "fleet",
"state": "idle",
"alive": true,
"connector": {
"binding": "shared-signals",
"braked": false,
"ownerState": "live",
"alive": true
},
"task": "Discord connector",
"taskSource": "connector"
},
"replyStatus": 409,
"replyError": "board replies are disabled for Discord connectors",
"fiveAgentPaneIdentitiesUnchanged": true,
"connectorServiceIdentityUnchanged": true
}
@@ -0,0 +1,2 @@
{"at": "2026-09-14T13:50:24.078636+00:00", "event": "owner-authorized-restart-intent", "oldPid": 3204655, "agents": {"default/darkwing": [["2733924", "12863634"]], "default/dewey": [["934346", "466065"]], "default/filbert": [["72183", "100870"]], "default/researcher": [["173699", "66404285"]], "mosaic-fleet/rocko": [["90599", "128275"]]}, "connectorService": [3022843, "67887873"], "manifest": "254403b89c0a2330da53e8dbad1cbeba3b1b06cf4f3efddc18451e04cb78f6de"}
{"at": "2026-09-14T13:50:24.503231+00:00", "event": "replacement-started", "oldExitedGracefully": true, "newPid": 3769124, "log": "/tmp/discord-board-backend-ovk_cahk.log"}
@@ -0,0 +1,18 @@
{
"at": "2026-09-14T01:10:19.375709+00:00",
"candidate": "/tmp/discord-board-r1-KbMrGQWF",
"manifestSha256": "5c92acc90d202727d790f3fb8d74387db1c9e5c3e43d4f4b56b40e5ae503a56a",
"verdict": "CHANGES REQUIRED",
"independentSerializedTests": 320,
"finding": {
"id": "R1-B1",
"severity": "P2",
"file": "packages/control-board/src/discord.mjs",
"issue": "STOP metadata access errors collapse to absence, falsely projecting not braked",
"reproduction": "Synthetic journal directory contains STOP, chmod directory to 000 as uid 1000, inspectDiscord returns braked:false, ownerState:invalid, alive:false. Restore permissions and remove fixture.",
"expected": "braked:null/unknown when STOP existence cannot be established; false only for verified absence",
"required": "Distinguish missing metadata from access errors and add non-root unreadable-directory regression."
},
"ux": "Dewey APPROVE on exact R1; three independent serialized browser tests passed, source/automation limitations retained",
"parallelQualification": "Two author concurrent frozen timeouts remain unresolved and are not green; serialized independent run passed."
}
@@ -0,0 +1,7 @@
{
"candidate": "R2",
"syntheticOnly": true,
"taskContainsEnvelopeAuthorId": true,
"taskContainsEnvelopeMessageId": true,
"taskSource": "first-user-message"
}
@@ -0,0 +1,18 @@
{
"at": "2026-09-14T01:26:42.688410+00:00",
"candidate": "/tmp/discord-board-r3-U9vVrlQu",
"manifestSha256": "254403b89c0a2330da53e8dbad1cbeba3b1b06cf4f3efddc18451e04cb78f6de",
"reviewer": "Darkwing",
"backendVerdict": "APPROVE AS SOURCE",
"verified": "Nine working/frozen pins, exact three-file R2-to-R3 delta, inherited attention pins and full serialized six-package suite 322/322",
"findingsClosed": [
"R1-B1: inaccessible STOP is unknown, non-root regression passes",
"R2-B2: canonical routing envelope no longer becomes connector Task; ordinary fallback retained"
],
"limitations": [
"No generalized transcript redaction",
"R1 concurrent combined frozen timeouts unresolved/not green",
"No live observation, backend restart, connector change or publication in this review"
],
"uxGate": "Await exact R3 confirmation from Dewey via agent-send"
}
@@ -0,0 +1,20 @@
{
"at": "2026-09-14T01:28:17.695Z",
"sourceApproval": 26257,
"readOnly": true,
"agent": "sage (discord: shared-signals)",
"project": "fleet",
"state": "idle",
"alive": true,
"connector": {
"binding": "shared-signals",
"braked": false,
"ownerState": "live",
"alive": true
},
"task": "Discord connector",
"taskSource": "connector",
"registrationAbsent": true,
"ownerMatchesService": true,
"discoveryErrorCount": 0
}
@@ -0,0 +1,9 @@
{
"at": "2026-09-14T00:50:35.339Z",
"sourceSha256": "dfbb7ab9374c0ac9fafa0503f495abd938f499f5d6227233de03a60ea3022927",
"fixture": "connector row with forged native registration",
"status": 200,
"fakeTransportCalls": 1,
"realTransportCalls": 0,
"gatePassed": false
}
@@ -0,0 +1,162 @@
# Discord engine: guaranteed test cleanup and the timeout gap in `busy` (#1509), R2 candidate
Sage assigned this on 2026-09-26 as 6b, source only. Rocko reviews. Base is
HEAD 401cc850. Not committed. The live connector runs from this checkout, so
Sage is holding its restart until this is approved and committed. Nobody
should restart it from a working copy.
## Defects (DEFERRED Open, "Discord engine: leaked fake pi…")
(a) `engine.test.mjs` read the fake's `commands.jsonl` 20 ms after a prompt and
got ENOENT under load. Seven tests stopped the engine outside `finally`, so a
failed assertion left the fake pi running and the test file never exited.
(b) `busy` was `state.busy || pending.some((t) => !t.done)`. If a turn timed out
before its `agent_start` was read, it was done while `state.busy` was still
false. The next prompt then went straight to pi, which refused it as
streaming.
## R1 and Rocko's finding
R1 held later prompts behind a failed turn. If pi had sent no `agent_start` for
it within a grace period, R1 dropped that turn from the queue and sent the next
prompt. Rocko rejected it (F1, High), in
`agents/rocko/work/discord-engine-busy-r1-review-2026-09-26.md`, sha256
047dbd8f.
Pi's events carry no prompt id. The engine attributes them to the front of its
queue. Silence until the grace ends does not prove the old run will never come.
If pi then runs it, its events land on the new prompt, which R1 had just put at
the front. Rocko's reproducer got the old run's answer and its `old.md` tool
record back as the new prompt's result. My R1 README said such events "find no
live head and are dropped". That was wrong.
The R1 files stay here as `r1-manifest.sha256` and `r1.patch`.
## Change (R2)
`packages/discord/src/engine-pi.mjs`:
- `engineBusy()` is `state.busy || state.pending.length > 0`. A failed turn
still in the queue holds the next prompt back, and stays at the front, so any
late events for it land on it. `prompt()`, `sendHeld()` and the `busy` getter
use it. This part is unchanged from R1.
- The bound is now a stop, not a drop. When a turn fails while it is still in
the queue, `failTurn` starts a timer, `abortGraceMs` (default
`ABORT_GRACE_MS`, 30 s, an engine option, not binding config). When it
fires:
- If pi has sent `agent_start` (`state.busy`), nothing happens. That run
ends on its `agent_end` or a settle, as on HEAD.
- Otherwise `wedge()` sets `state.wedged`, fails every held prompt with
code `engine-wedged`, and stops pi: stdin closed, SIGTERM, then SIGKILL
after 5 s. The failed turn stays at the front until the exit.
- While wedged, nothing is written to that child. `write()`, `sendHeld()` and
`prompt()` refuse, and a new prompt fails at once with `engine-down`. The exit
runs the usual `failAll` and `onExit`.
- `stop()`'s body moved into `stopChild()`, which both `stop()` and `wedge()`
call.
- `release()` clears the timer wherever a turn leaves the queue: `agent_end`,
settle, a refused send, and process exit. As in R1, the settle handler removes
turns before failing them.
What recovery looks like live: `cli.mjs` handles `onExit` with `shutdown(1)`.
The unit's `Restart=on-failure` starts a new connector and a new pi 15 s later,
within its limit of five tries in ten minutes. This change doesn't touch the
unit or the restart policy. A wedge now costs one connector restart. R1 would
have kept the same pi and risked a wrong answer.
`packages/discord/tests/fake-pi.mjs`:
- `mute`: accepted and never run.
- `stall <ms>`: accepted, then the fake reads nothing for `<ms>`, runs the
stalled prompt, and only then reads what came in meanwhile. This is the
order in Rocko's case.
`packages/discord/tests/engine.test.mjs`:
- `withEngine()` stops the engine in `finally`. Every test that starts an
engine uses it, or has its own `try/finally` in the exit test.
- `commands()` returns `[]` until the fake creates its log. The held-prompt
test waits for the first prompt with `until()` instead of a 20 ms sleep, and
its first prompt is `slow 300`.
- The manual-timer test fires the turn timer before any pi event is read. The
next prompt must wait for the settle and get its own answer.
- New or changed for R2:
- `mute` with `abortGraceMs: 150`. The held prompt fails with
`engine-wedged` after the grace, a later prompt fails with
`engine-down`, `onExit` fires, and pi saw only `mute` and `abort`.
- `stall 400` with the same grace, which is Rocko's case with a real
child. The held prompt fails with `engine-wedged`, pi exits, and "after
stall" never reaches pi.
- Rocko's reproducer as an in-memory test, run twice. The old prompt's
response comes either before its timeout or only with the late events.
After the grace, the old run's start, tool pair, answer, end and settle
arrive while pi is still exiting. The held prompt stays failed with
`engine-wedged` and a later prompt fails with `engine-down`. Pi saw only
`old` and `abort`, then SIGTERM, then SIGKILL at 5 s. Only the exit
reaches `onExit`. The test reads recorded outcomes after a tick instead
of awaiting, so a regression fails instead of hanging.
- `late 400` with the same grace. Pi started that run, so the grace does
not stop pi, and the next prompt gets its own answer when the run ends.
## Evidence
- `engine.test.mjs`: 17/17.
- R1's engine (d5bf24b5, from `r1.patch`) against these tests fails 4: `mute`,
`stall`, and both in-memory runs. In that run a probe shows R1 answering
"after stall" with "echo: stalled". An earlier draft of the in-memory test
awaited the held prompt and hung on R1 until the 120 s cap. It now fails in
milliseconds.
- HEAD's engine against these tests fails 5: the manual-timer test and the
same four.
- Mutations of R2:
- Without the `state.busy` check, the `late 400` test fails.
- Without the `wedge()` call, 4 fail.
- Without failing held prompts in `wedge()`, 4 fail.
- Test union (control-board, webui, seat, mosaic, ledger, discord) at default
concurrency on `git archive` of 401cc850 plus the three files: 406/406
three times, 23 to 24 s each. No fake pi was left running.
- Eight suites green on that snapshot: config 24, task 90, foundation 43,
conductor 17, release 14, auth 15, discord 63, extension-package 18.
Logs: `/tmp/dw-6b-r2-conc-{1,2,3}.txt`. R1's evidence runs:
`/tmp/dw-6b-conc-{1,2,3}.txt`, `/tmp/dw-6b-serial.txt`. HEAD's hang control:
`/tmp/dw-1509-headctl-{1,2,3}.txt`, `/tmp/dw-1509-ef00-1.txt`. There, HEAD hit
the 240 s cap at 199 ok under the union's load.
## Not covered
- A run pi started and never ends, even after abort, still holds prompts
until pi settles or exits. Each held prompt fails at its own timeout ("while
waiting for the engine"). HEAD behaves the same way through `state.busy`, and
Rocko did not block on it. Only a pi restart clears it.
- A wedge ends the connector process, and the recovery is systemd's restart.
Nothing here changes the unit, and the restart limit still applies.
- No live restart, and no change to the binding schema.
## Frozen files
`r2-manifest.sha256` holds the three R2 hashes. `r2.patch` is `git diff
packages/discord` at freeze time.
## Review
Rocko, R1, 2026-09-26: request changes, F1 High, as described above. Report:
`agents/rocko/work/discord-engine-busy-r1-review-2026-09-26.md`, sha256
047dbd8f.
Rocko, R2, 2026-09-26: approved the three pinned files. Report:
`agents/rocko/work/discord-engine-busy-r2-review-2026-09-26.md`, sha256
ed5510a0. He checked the manifests before and after, ran 17/17 himself, and
read the CLI shutdown path, `connector.stop` and the unit template. Sage asked
him three operational questions:
- A wedge exits 1, never 3. Exit 3 remains the supervised startup refusal.
- The unit's start limit (5 starts in 600 s) is a rate limit. It does not
bound repeated wedges. With the default 180 s turn timeout, the 30 s grace
and the 15 s restart delay, a cycle takes at least 225 s. That stays under
the limit, so a pi that wedges every time could restart indefinitely.
Stopping for good after repeated wedges would need a separate policy. This
change does not add one.
- He recommends, as a nonblocking follow-up, that the connector journal
record at startup: HEAD, dirty state scoped to runtime source, and a digest
of the runtime files. A wedge restart loads whatever the checkout holds.
This section was added after approval, so the README hash no longer matches
the one Rocko pinned (69350f29). The three source files are unchanged.
@@ -0,0 +1,3 @@
d5bf24b59c07c85067f4087c03b54ca8b4df923c1d591441dedd2e8a7ff2ae39 packages/discord/src/engine-pi.mjs
f0abee9c243d46d66dd2271abc3fd89089c350ac6a66ab49131bce80adfcdc33 packages/discord/tests/engine.test.mjs
fa1bf44e3f33eb970a714ada1c686abbc1932baaf418679276edf8813abbe6de packages/discord/tests/fake-pi.mjs
@@ -0,0 +1,414 @@
diff --git a/packages/discord/src/engine-pi.mjs b/packages/discord/src/engine-pi.mjs
index 5c8fd0a9..9dcaeb42 100644
--- a/packages/discord/src/engine-pi.mjs
+++ b/packages/discord/src/engine-pi.mjs
@@ -16,9 +16,12 @@
// from `tool_execution_start`/`tool_execution_end` into the result so the
// turn record shows what was read. An `agent_end` with `willRetry` is not
// the end of the run. A timeout sends `abort` and fails that turn; the
-// process stays. A malformed JSONL line from pi fails the current turn (its
-// outcome is now unknowable) and the process stays. Process exit fails
-// every pending turn and is reported through `onExit`.
+// process stays. The failed turn holds later prompts back until its
+// agent_end or a settle. If pi has not started it within ABORT_GRACE_MS, it
+// is dropped and the next prompt goes out; a run pi did start holds them
+// until it ends, as any run does. A malformed JSONL line from pi fails the
+// current turn (its outcome is now unknowable) and the process stays.
+// Process exit fails every pending turn and is reported through `onExit`.
//
// Framing follows pi's RPC doc: split on "\n" only, strip a trailing "\r".
// Node readline is not used because it also splits on U+2028/U+2029.
@@ -64,12 +67,19 @@ export function assistantText(message) {
.trim();
}
+// How long a turn that failed here (timeout, protocol error) may wait for
+// pi's agent_start before it stops holding the next prompt back. Without a
+// bound, a prompt pi accepted but never ran would queue every later prompt
+// until restart.
+export const ABORT_GRACE_MS = 30000;
+
export function createEngine({
command, args, cwd, env = {},
spawn = nodeSpawn,
setTimeoutImpl = globalThis.setTimeout, clearTimeoutImpl = globalThis.clearTimeout,
log = () => {},
onExit = () => {},
+ abortGraceMs = ABORT_GRACE_MS,
} = {}) {
if (typeof command !== "string" || command.length === 0) throw new DiscordError("engine: command required", 1);
if (!Array.isArray(args)) throw new DiscordError("engine: args required", 1);
@@ -80,15 +90,35 @@ export function createEngine({
// A turn that fails on the client side (timeout, protocol error) stays in
// the pending queue, marked done, until pi's own turn_end for it arrives.
- // Otherwise that turn_end would be attributed to the next prompt.
+ // Otherwise that turn_end would be attributed to the next prompt. It holds
+ // later prompts back; if pi has not started it within abortGraceMs, it goes.
function failTurn(turn, code, message) {
if (turn.done) return;
turn.done = true;
if (turn.timer !== null) clearTimeoutImpl(turn.timer);
turn.timer = null;
+ if (state.pending.includes(turn)) {
+ turn.grace = setTimeoutImpl(() => {
+ turn.grace = null;
+ // No agent_start by now: pi never started this run and will send no
+ // agent_end for it, so it leaves the queue and cannot take the next
+ // prompt's. A run pi did start keeps its place until it ends.
+ if (!state.busy) {
+ const i = state.pending.indexOf(turn);
+ if (i !== -1) state.pending.splice(i, 1);
+ }
+ sendHeld();
+ }, abortGraceMs);
+ }
turn.reject(new DiscordError(message, 1, { code }));
}
+ // Call when a turn leaves the pending queue.
+ function release(turn) {
+ if (turn.grace !== null) clearTimeoutImpl(turn.grace);
+ turn.grace = null;
+ }
+
function settleTurn(turn, value) {
if (turn.done) return;
turn.done = true;
@@ -99,7 +129,10 @@ export function createEngine({
function failAll(code, message) {
const pending = state.pending.splice(0);
- for (const t of pending) failTurn(t, code, message);
+ for (const t of pending) {
+ release(t);
+ failTurn(t, code, message);
+ }
for (const h of state.held.splice(0)) failTurn(h.turn, code, message);
for (const [, r] of state.responses) r.reject(new DiscordError(message, 1, { code }));
state.responses.clear();
@@ -174,6 +207,7 @@ export function createEngine({
// Attribute the run to the head even if it failed client-side, so the
// next prompt's agent_end is not taken for this one.
const run = state.pending.shift();
+ if (run) release(run);
if (!run || run.done) return;
const messages = Array.isArray(event.messages) ? event.messages.filter((m) => m && m.role === "assistant") : [];
const message = messages.length > 0 ? messages[messages.length - 1] : run.last;
@@ -193,13 +227,14 @@ export function createEngine({
// this settle and still has no agent_end will never get one: fail it now
// instead of waiting for its timeout. Turns whose prompt response has
// not arrived yet belong to a later run and stay.
+ const dropped = [];
const keep = [];
- for (const t of state.pending) {
- if (t.done) continue;
- if (t.accepted) failTurn(t, "engine-settled-without-turn", "engine settled without answering this prompt");
- else keep.push(t);
- }
+ for (const t of state.pending) (t.done || t.accepted ? dropped : keep).push(t);
state.pending = keep;
+ for (const t of dropped) {
+ release(t);
+ failTurn(t, "engine-settled-without-turn", "engine settled without answering this prompt");
+ }
sendHeld();
}
}
@@ -214,15 +249,23 @@ export function createEngine({
// Never accepted: pi will not emit a turn_end for it, so remove it.
const i = state.pending.indexOf(turn);
if (i !== -1) state.pending.splice(i, 1);
+ release(turn);
failTurn(turn, (err.details && err.details.code) || "engine-refused", err.message);
sendHeld();
});
}
+ // Pi is busy from our side while any sent prompt is still queued, even one
+ // that already failed here: a turn that timed out before its agent_start
+ // was read leaves state.busy false while pi runs it, and sending then would
+ // be refused as streaming. It leaves the queue on its agent_end, on a
+ // settle, on a refused send, or when its grace ends before pi started it.
+ const engineBusy = () => state.busy || state.pending.length > 0;
+
// After a settle (or a refused send) the oldest held prompt goes out.
function sendHeld() {
if (state.exited !== null) return;
- if (state.busy || state.pending.some((t) => !t.done)) return;
+ if (engineBusy()) return;
const next = state.held.shift();
if (next) send(next.turn, next.command);
}
@@ -281,7 +324,7 @@ export function createEngine({
// with DiscordError carrying details.code for the turn record.
prompt(text, { timeoutMs = 180000 } = {}) {
if (typeof text !== "string" || text.length === 0) throw new DiscordError("prompt text required", 1);
- const turn = { resolve: null, reject: null, timer: null, done: false, accepted: false, tools: new Map(), turns: 0, last: null };
+ const turn = { resolve: null, reject: null, timer: null, grace: null, done: false, accepted: false, tools: new Map(), turns: 0, last: null };
const done = new Promise((resolve, reject) => {
turn.resolve = resolve;
turn.reject = reject;
@@ -310,13 +353,13 @@ export function createEngine({
failTurn(turn, "engine-down", "engine is not running");
return done;
}
- if (state.busy || state.pending.some((t) => !t.done) || state.held.length > 0) state.held.push({ turn, command });
+ if (engineBusy() || state.held.length > 0) state.held.push({ turn, command });
else send(turn, command);
return done;
},
get busy() {
- return state.busy || state.pending.some((t) => !t.done) || state.held.length > 0;
+ return engineBusy() || state.held.length > 0;
},
get pendingCount() {
return state.pending.filter((t) => !t.done).length + state.held.length;
diff --git a/packages/discord/tests/engine.test.mjs b/packages/discord/tests/engine.test.mjs
index 59674d1e..62dd6017 100644
--- a/packages/discord/tests/engine.test.mjs
+++ b/packages/discord/tests/engine.test.mjs
@@ -28,7 +28,20 @@ function start(root, extra = {}) {
log: (m) => logs.push(m), ...extra,
});
engine.start();
- return { engine, logs, commands: () => readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) };
+ // The fake creates its log on the first command; until then there are none.
+ const commands = () => (existsSync(logPath) ? readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []);
+ return { engine, logs, commands };
+}
+
+// Every test stops its engine in finally: a fake pi left running after a
+// failed assertion keeps the test file from exiting.
+async function withEngine(extra, body) {
+ const started = start(makeRoot(), extra);
+ try {
+ await body(started);
+ } finally {
+ await started.engine.stop();
+ }
}
test("engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file", () => {
@@ -56,8 +69,7 @@ test("engine: with tools, buildPiArgs turns pi's own tools off, loads the extens
assert.equal(rw[rw.indexOf("--tools") + 1], "list_dir,read_file,search,write_file,edit_file", "a writable root adds exactly the two write tools");
});
-test("engine: a run with tool turns settles once, on the answer, with every tool call in the result", async () => {
- const { engine } = start(makeRoot());
+test("engine: a run with tool turns settles once, on the answer, with every tool call in the result", () => withEngine({}, async ({ engine }) => {
const r = await engine.prompt("tools 3");
assert.equal(r.text, "read 3 file(s)");
assert.equal(r.turns, 2);
@@ -71,45 +83,38 @@ test("engine: a run with tool turns settles once, on the answer, with every tool
assert.equal(plain.turns, 1);
await idle(engine);
assert.equal(engine.busy, false);
- await engine.stop();
-});
+}));
-test("engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end", async () => {
- const { engine } = start(makeRoot());
+test("engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end", () => withEngine({}, async ({ engine }) => {
const r = await engine.prompt("toolonly");
assert.equal(r.text, "", "no text: the connector turns this into engine-empty");
assert.equal(r.tools.length, 1);
const again = await engine.prompt("retry");
assert.equal(again.text, "after retry");
- await engine.stop();
-});
+}));
-test("engine: one prompt, one turn, text and usage come back", async () => {
- const { engine } = start(makeRoot());
- try {
- const r = await engine.prompt("hello");
- assert.equal(r.text, "echo: hello");
- assert.deepEqual(r.usage, { input: 3, output: 2 });
- await idle(engine);
- assert.equal(engine.busy, false);
- } finally {
- await engine.stop();
- }
-});
+test("engine: one prompt, one turn, text and usage come back", () => withEngine({}, async ({ engine }) => {
+ const r = await engine.prompt("hello");
+ assert.equal(r.text, "echo: hello");
+ assert.deepEqual(r.usage, { input: 3, output: 2 });
+ await idle(engine);
+ assert.equal(engine.busy, false);
+}));
-test("engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order", async () => {
- const { engine, commands } = start(makeRoot());
- const first = engine.prompt("slow 150");
- await new Promise((r) => setTimeout(r, 20));
+test("engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order", () => withEngine({}, async ({ engine, commands }) => {
+ const first = engine.prompt("slow 300");
assert.equal(engine.busy, true);
const second = engine.prompt("second");
assert.equal(engine.pendingCount, 2);
- await new Promise((r) => setTimeout(r, 20));
- assert.equal(commands().filter((c) => c.type === "prompt").length, 1, "the second prompt is not sent while pi is busy");
+ const prompted = () => commands().filter((c) => c.type === "prompt");
+ assert.ok(await until(() => prompted().length > 0), "the first prompt reached pi");
+ assert.equal(prompted().length, 1, "the second prompt is not sent while pi is busy");
+ // The fake refuses a prompt without streamingBehavior while it runs one, so
+ // an answered second prompt also proves it was not sent early.
const [r1, r2] = await Promise.all([first, second]);
assert.equal(r1.text, "slow reply");
assert.equal(r2.text, "echo: second");
- const prompts = commands().filter((c) => c.type === "prompt");
+ const prompts = prompted();
assert.equal(prompts.length, 2);
// Never a pi follow-up: pi would fold it into the first run and close both
// answers with one agent_end (the live loss of 2026-09-17).
@@ -117,11 +122,9 @@ test("engine: a prompt while streaming is held until pi settles, then sent as it
assert.equal(prompts[1].streamingBehavior, undefined);
await idle(engine);
assert.equal(engine.busy, false);
- await engine.stop();
-});
+}));
-test("engine: a held prompt that times out before pi settles fails on its own and is never sent", async () => {
- const { engine, commands } = start(makeRoot());
+test("engine: a held prompt that times out before pi settles fails on its own and is never sent", () => withEngine({}, async ({ engine, commands }) => {
const first = engine.prompt("slow 200");
await new Promise((r) => setTimeout(r, 20));
await assert.rejects(engine.prompt("late one", { timeoutMs: 50 }), (e) => e.details.code === "timeout" && /waiting for the engine/.test(e.message));
@@ -130,50 +133,85 @@ test("engine: a held prompt that times out before pi settles fails on its own an
await idle(engine);
assert.deepEqual(commands().filter((c) => c.type === "prompt").map((c) => c.message), ["slow 200"]);
assert.deepEqual(commands().filter((c) => c.type === "abort"), [], "a held turn is not aborted; pi never had it");
- await engine.stop();
-});
+}));
-test("engine: timeout sends abort and fails only that turn; the process stays", async () => {
- const { engine, commands, logs } = start(makeRoot());
+test("engine: timeout sends abort and fails only that turn; the process stays", () => withEngine({}, async ({ engine, commands, logs }) => {
await assert.rejects(engine.prompt("slow 5000", { timeoutMs: 100 }), (err) => err.details.code === "timeout");
assert.ok(await until(() => commands().some((c) => c.type === "abort")), "abort reached pi");
assert.ok(logs.some((l) => /timed out/.test(l)));
const r = await engine.prompt("again");
assert.equal(r.text, "echo: again");
- await engine.stop();
+}));
+
+test("engine: tool events from a run that outlived its timeout never land in the next prompt's record", () => withEngine({}, async ({ engine }) => {
+ await assert.rejects(engine.prompt("late 200", { timeoutMs: 40 }), (err) => err.details.code === "timeout");
+ const r = await engine.prompt("after late");
+ assert.equal(r.text, "echo: after late");
+ assert.deepEqual(r.tools, [], "the dead run's read is not this prompt's evidence");
+ assert.equal(r.turns, 1, "the dead run's turns are not counted here");
+}));
+
+// The turn timer is fired by hand, before the engine has read any event from
+// pi, so the timed-out run is still pi's and state.busy is still false when
+// the next prompt arrives. Under load a real timer does the same.
+const TURN_MS = 60000;
+const manualTurnTimer = (fire) => ({
+ setTimeoutImpl: (fn, ms) => (ms === TURN_MS ? fire.push(fn) : setTimeout(fn, ms)),
+ clearTimeoutImpl: (id) => { if (typeof id !== "number") clearTimeout(id); },
});
-test("engine: tool events from a run that outlived its timeout never land in the next prompt's record", async () => {
- const { engine } = start(makeRoot());
- try {
- await assert.rejects(engine.prompt("late 200", { timeoutMs: 40 }), (err) => err.details.code === "timeout");
- const r = await engine.prompt("after late");
+test("engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused", () => {
+ const fire = [];
+ return withEngine(manualTurnTimer(fire), async ({ engine, commands }) => {
+ const late = engine.prompt("late 100", { timeoutMs: TURN_MS });
+ fire.shift()();
+ assert.equal(engine.busy, true, "pi is still running the prompt that timed out");
+ const next = engine.prompt("after late", { timeoutMs: 5000 });
+ assert.equal(engine.pendingCount, 1, "only the new prompt is live");
+ await assert.rejects(late, (err) => err.details.code === "timeout");
+ const r = await next;
assert.equal(r.text, "echo: after late");
assert.deepEqual(r.tools, [], "the dead run's read is not this prompt's evidence");
- assert.equal(r.turns, 1, "the dead run's turns are not counted here");
- } finally {
- await engine.stop();
- }
+ assert.equal(r.turns, 1);
+ assert.deepEqual(commands().map((c) => (c.type === "prompt" ? c.message : c.type)), ["late 100", "abort", "after late"]);
+ await idle(engine);
+ assert.equal(engine.busy, false);
+ });
});
-test("engine: a malformed JSONL line fails the turn, not the process", async () => {
- const { engine, logs } = start(makeRoot());
+// "mute" is accepted and never run, so no agent_start, agent_end or settle
+// ever comes for it. Unbounded, it would hold every later prompt.
+test("engine: a timed-out turn pi never started holds the next prompt only for the abort grace, then leaves the queue", () => withEngine({ abortGraceMs: 150 }, async ({ engine, commands }) => {
+ await assert.rejects(engine.prompt("mute", { timeoutMs: 50 }), (err) => err.details.code === "timeout");
+ assert.equal(engine.busy, true, "pi might still be running it");
+ const started = Date.now();
+ const r = await engine.prompt("after mute", { timeoutMs: 5000 });
+ assert.equal(r.text, "echo: after mute");
+ assert.ok(Date.now() - started >= 100, "held for the grace, not sent at once");
+ assert.deepEqual(commands().map((c) => (c.type === "prompt" ? c.message : c.type)), ["mute", "abort", "after mute"]);
+ await idle(engine);
+ assert.equal(engine.busy, false);
+}));
+
+test("engine: a malformed JSONL line fails the turn, not the process", () => withEngine({}, async ({ engine, logs }) => {
await assert.rejects(engine.prompt("garbage"), (err) => err.details.code === "engine-protocol");
assert.ok(logs.some((l) => /malformed/.test(l)));
const r = await engine.prompt("still here");
assert.equal(r.text, "echo: still here");
- await engine.stop();
-});
+}));
test("engine: a turn that ends in error rejects with the error code; process exit fails pending turns", async () => {
- const root = makeRoot();
let exited = null;
- const { engine } = start(root, { onExit: (e) => (exited = e) });
- await assert.rejects(engine.prompt("error"), (err) => err.details.code === "engine-error" && /fake provider error/.test(err.message));
- const pending = engine.prompt("slow 5000");
- await new Promise((r) => setTimeout(r, 20));
- await engine.stop();
- await assert.rejects(pending, (err) => err.details.code === "engine-down");
- assert.ok(exited);
- await assert.rejects(engine.prompt("x"), /not running/);
+ const { engine } = start(makeRoot(), { onExit: (e) => (exited = e) });
+ try {
+ await assert.rejects(engine.prompt("error"), (err) => err.details.code === "engine-error" && /fake provider error/.test(err.message));
+ const pending = engine.prompt("slow 5000");
+ await new Promise((r) => setTimeout(r, 20));
+ await engine.stop();
+ await assert.rejects(pending, (err) => err.details.code === "engine-down");
+ assert.ok(exited);
+ await assert.rejects(engine.prompt("x"), /not running/);
+ } finally {
+ await engine.stop();
+ }
});
diff --git a/packages/discord/tests/fake-pi.mjs b/packages/discord/tests/fake-pi.mjs
index 94919306..ecc04e3b 100644
--- a/packages/discord/tests/fake-pi.mjs
+++ b/packages/discord/tests/fake-pi.mjs
@@ -8,6 +8,7 @@
// then a second turn that answers "read <n> file(s)"
// "toolonly" a run whose only turn calls a tool and never answers
// "retry" an agent_end with willRetry, then the real answer
+// "mute" accept the prompt and emit nothing, staying idle
// "late <ms>" ignore abort; after <ms> emit a tool pair and a tool turn,
// then answer "late reply", like a run that outlives its
// client-side timeout
@@ -30,6 +31,7 @@ function assistant(text, stopReason = "stop") {
}
function run(text) {
+ if (text === "mute") return;
busy = true;
out({ type: "agent_start" });
out({ type: "turn_start" });
@@ -0,0 +1,3 @@
77077b7fbd5a933ffd352094eb073227c299ba47b7aea52d4e60fdc55cc7101e packages/discord/src/engine-pi.mjs
47a998179c6eb46827f43ab2c6b0f6b062ef94fb47da402cfb9af8c4f180f38e packages/discord/tests/engine.test.mjs
a8e54cc3f4b670eef2c06755b63e9e6bfeb44b1b1efde3aca9bfaa91583c3ef3 packages/discord/tests/fake-pi.mjs
@@ -0,0 +1,651 @@
diff --git a/packages/discord/src/engine-pi.mjs b/packages/discord/src/engine-pi.mjs
index 5c8fd0a9..46ef1f88 100644
--- a/packages/discord/src/engine-pi.mjs
+++ b/packages/discord/src/engine-pi.mjs
@@ -16,9 +16,14 @@
// from `tool_execution_start`/`tool_execution_end` into the result so the
// turn record shows what was read. An `agent_end` with `willRetry` is not
// the end of the run. A timeout sends `abort` and fails that turn; the
-// process stays. A malformed JSONL line from pi fails the current turn (its
-// outcome is now unknowable) and the process stays. Process exit fails
-// every pending turn and is reported through `onExit`.
+// process stays. The failed turn holds later prompts back until its
+// agent_end or a settle. If pi has not started it within ABORT_GRACE_MS, the
+// engine stops pi instead of sending again: pi's events carry no prompt id,
+// so a late run of the failed prompt would be taken for the next one's. A
+// run pi did start holds later prompts until it ends, as any run does. A
+// malformed JSONL line from pi fails the current turn (its outcome is now
+// unknowable) and the process stays. Process exit fails every pending turn
+// and is reported through `onExit`.
//
// Framing follows pi's RPC doc: split on "\n" only, strip a trailing "\r".
// Node readline is not used because it also splits on U+2028/U+2029.
@@ -64,31 +69,67 @@ export function assistantText(message) {
.trim();
}
+// How long a turn that failed here (timeout, protocol error) may wait for
+// pi's agent_start before the engine stops pi. Without a bound, a prompt pi
+// accepted but never ran would hold every later prompt until restart.
+export const ABORT_GRACE_MS = 30000;
+
export function createEngine({
command, args, cwd, env = {},
spawn = nodeSpawn,
setTimeoutImpl = globalThis.setTimeout, clearTimeoutImpl = globalThis.clearTimeout,
log = () => {},
onExit = () => {},
+ abortGraceMs = ABORT_GRACE_MS,
} = {}) {
if (typeof command !== "string" || command.length === 0) throw new DiscordError("engine: command required", 1);
if (!Array.isArray(args)) throw new DiscordError("engine: args required", 1);
// pending: prompts sent to pi, oldest first. held: prompts waiting for pi
// to settle before they are sent, oldest first.
- const state = { child: null, buffer: "", pending: [], held: [], responses: new Map(), nextId: 1, busy: false, exited: null };
+ // wedged: set when the engine gave up on pi and is stopping it. Nothing
+ // is sent to that child again.
+ const state = { child: null, buffer: "", pending: [], held: [], responses: new Map(), nextId: 1, busy: false, exited: null, wedged: false };
// A turn that fails on the client side (timeout, protocol error) stays in
// the pending queue, marked done, until pi's own turn_end for it arrives.
- // Otherwise that turn_end would be attributed to the next prompt.
+ // Otherwise that turn_end would be attributed to the next prompt. It holds
+ // later prompts back; if pi has not started it within abortGraceMs, the
+ // engine stops pi.
function failTurn(turn, code, message) {
if (turn.done) return;
turn.done = true;
if (turn.timer !== null) clearTimeoutImpl(turn.timer);
turn.timer = null;
+ if (state.pending.includes(turn)) {
+ turn.grace = setTimeoutImpl(() => {
+ turn.grace = null;
+ // A run pi started keeps its place until its agent_end or a settle.
+ if (state.busy) return;
+ // No agent_start yet. Pi may never run this prompt, or its events
+ // may still be on the way; with no prompt id in them, nothing sent
+ // now could be told apart from it. Stop pi: held prompts fail, and
+ // the exit fails the rest and reaches onExit.
+ log(`engine: no agent_start ${abortGraceMs} ms after a failed turn; stopping pi`);
+ wedge();
+ }, abortGraceMs);
+ }
turn.reject(new DiscordError(message, 1, { code }));
}
+ function wedge() {
+ if (state.wedged || state.exited !== null) return;
+ state.wedged = true;
+ for (const h of state.held.splice(0)) failTurn(h.turn, "engine-wedged", "engine stopped: pi did not start an aborted turn");
+ stopChild();
+ }
+
+ // Call when a turn leaves the pending queue.
+ function release(turn) {
+ if (turn.grace !== null) clearTimeoutImpl(turn.grace);
+ turn.grace = null;
+ }
+
function settleTurn(turn, value) {
if (turn.done) return;
turn.done = true;
@@ -99,7 +140,10 @@ export function createEngine({
function failAll(code, message) {
const pending = state.pending.splice(0);
- for (const t of pending) failTurn(t, code, message);
+ for (const t of pending) {
+ release(t);
+ failTurn(t, code, message);
+ }
for (const h of state.held.splice(0)) failTurn(h.turn, code, message);
for (const [, r] of state.responses) r.reject(new DiscordError(message, 1, { code }));
state.responses.clear();
@@ -174,6 +218,7 @@ export function createEngine({
// Attribute the run to the head even if it failed client-side, so the
// next prompt's agent_end is not taken for this one.
const run = state.pending.shift();
+ if (run) release(run);
if (!run || run.done) return;
const messages = Array.isArray(event.messages) ? event.messages.filter((m) => m && m.role === "assistant") : [];
const message = messages.length > 0 ? messages[messages.length - 1] : run.last;
@@ -193,13 +238,14 @@ export function createEngine({
// this settle and still has no agent_end will never get one: fail it now
// instead of waiting for its timeout. Turns whose prompt response has
// not arrived yet belong to a later run and stay.
+ const dropped = [];
const keep = [];
- for (const t of state.pending) {
- if (t.done) continue;
- if (t.accepted) failTurn(t, "engine-settled-without-turn", "engine settled without answering this prompt");
- else keep.push(t);
- }
+ for (const t of state.pending) (t.done || t.accepted ? dropped : keep).push(t);
state.pending = keep;
+ for (const t of dropped) {
+ release(t);
+ failTurn(t, "engine-settled-without-turn", "engine settled without answering this prompt");
+ }
sendHeld();
}
}
@@ -214,21 +260,29 @@ export function createEngine({
// Never accepted: pi will not emit a turn_end for it, so remove it.
const i = state.pending.indexOf(turn);
if (i !== -1) state.pending.splice(i, 1);
+ release(turn);
failTurn(turn, (err.details && err.details.code) || "engine-refused", err.message);
sendHeld();
});
}
+ // Pi is busy from our side while any sent prompt is still queued, even one
+ // that already failed here: a turn that timed out before its agent_start
+ // was read leaves state.busy false while pi runs it, and sending then would
+ // be refused as streaming. It leaves the queue on its agent_end, on a
+ // settle, on a refused send, or at process exit.
+ const engineBusy = () => state.busy || state.pending.length > 0;
+
// After a settle (or a refused send) the oldest held prompt goes out.
function sendHeld() {
- if (state.exited !== null) return;
- if (state.busy || state.pending.some((t) => !t.done)) return;
+ if (state.exited !== null || state.wedged) return;
+ if (engineBusy()) return;
const next = state.held.shift();
if (next) send(next.turn, next.command);
}
function write(command) {
- if (!state.child || state.exited !== null) throw new DiscordError("engine is not running", 1, { code: "engine-down" });
+ if (!state.child || state.exited !== null || state.wedged) throw new DiscordError("engine is not running", 1, { code: "engine-down" });
state.child.stdin.write(JSON.stringify(command) + "\n");
}
@@ -245,6 +299,30 @@ export function createEngine({
});
}
+ function stopChild({ graceMs = 5000 } = {}) {
+ const child = state.child;
+ if (!child || state.exited !== null) return Promise.resolve(state.exited);
+ return new Promise((resolve) => {
+ const timer = setTimeoutImpl(() => {
+ try {
+ child.kill("SIGKILL");
+ } catch {
+ // already gone
+ }
+ }, graceMs);
+ child.once("exit", () => {
+ clearTimeoutImpl(timer);
+ resolve(state.exited);
+ });
+ try {
+ child.stdin.end();
+ child.kill("SIGTERM");
+ } catch {
+ // already gone
+ }
+ });
+ }
+
return {
start() {
if (state.child) throw new DiscordError("engine already started", 1);
@@ -281,7 +359,7 @@ export function createEngine({
// with DiscordError carrying details.code for the turn record.
prompt(text, { timeoutMs = 180000 } = {}) {
if (typeof text !== "string" || text.length === 0) throw new DiscordError("prompt text required", 1);
- const turn = { resolve: null, reject: null, timer: null, done: false, accepted: false, tools: new Map(), turns: 0, last: null };
+ const turn = { resolve: null, reject: null, timer: null, grace: null, done: false, accepted: false, tools: new Map(), turns: 0, last: null };
const done = new Promise((resolve, reject) => {
turn.resolve = resolve;
turn.reject = reject;
@@ -306,44 +384,24 @@ export function createEngine({
}
failTurn(turn, "timeout", `turn timed out after ${timeoutMs} ms`);
}, timeoutMs);
- if (state.exited !== null) {
+ if (state.exited !== null || state.wedged) {
failTurn(turn, "engine-down", "engine is not running");
return done;
}
- if (state.busy || state.pending.some((t) => !t.done) || state.held.length > 0) state.held.push({ turn, command });
+ if (engineBusy() || state.held.length > 0) state.held.push({ turn, command });
else send(turn, command);
return done;
},
get busy() {
- return state.busy || state.pending.some((t) => !t.done) || state.held.length > 0;
+ return engineBusy() || state.held.length > 0;
},
get pendingCount() {
return state.pending.filter((t) => !t.done).length + state.held.length;
},
- stop({ graceMs = 5000 } = {}) {
- const child = state.child;
- if (!child || state.exited !== null) return Promise.resolve(state.exited);
- return new Promise((resolve) => {
- const timer = setTimeoutImpl(() => {
- try {
- child.kill("SIGKILL");
- } catch {
- // already gone
- }
- }, graceMs);
- child.once("exit", () => {
- clearTimeoutImpl(timer);
- resolve(state.exited);
- });
- try {
- child.stdin.end();
- child.kill("SIGTERM");
- } catch {
- // already gone
- }
- });
+ stop(options) {
+ return stopChild(options);
},
};
}
diff --git a/packages/discord/tests/engine.test.mjs b/packages/discord/tests/engine.test.mjs
index 59674d1e..f3bd7525 100644
--- a/packages/discord/tests/engine.test.mjs
+++ b/packages/discord/tests/engine.test.mjs
@@ -4,6 +4,8 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createEngine, buildPiArgs, PI_FIXED_ARGS, TOOLS_EXTENSION, READONLY_TOOLS_EXTENSION, assistantText } from "../src/engine-pi.mjs";
import { existsSync } from "node:fs";
+import { EventEmitter } from "node:events";
+import { PassThrough } from "node:stream";
import { makeRoot } from "./helpers.mjs";
const fakePi = join(import.meta.dirname, "fake-pi.mjs");
@@ -28,7 +30,20 @@ function start(root, extra = {}) {
log: (m) => logs.push(m), ...extra,
});
engine.start();
- return { engine, logs, commands: () => readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) };
+ // The fake creates its log on the first command; until then there are none.
+ const commands = () => (existsSync(logPath) ? readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []);
+ return { engine, logs, commands };
+}
+
+// Every test stops its engine in finally: a fake pi left running after a
+// failed assertion keeps the test file from exiting.
+async function withEngine(extra, body) {
+ const started = start(makeRoot(), extra);
+ try {
+ await body(started);
+ } finally {
+ await started.engine.stop();
+ }
}
test("engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file", () => {
@@ -56,8 +71,7 @@ test("engine: with tools, buildPiArgs turns pi's own tools off, loads the extens
assert.equal(rw[rw.indexOf("--tools") + 1], "list_dir,read_file,search,write_file,edit_file", "a writable root adds exactly the two write tools");
});
-test("engine: a run with tool turns settles once, on the answer, with every tool call in the result", async () => {
- const { engine } = start(makeRoot());
+test("engine: a run with tool turns settles once, on the answer, with every tool call in the result", () => withEngine({}, async ({ engine }) => {
const r = await engine.prompt("tools 3");
assert.equal(r.text, "read 3 file(s)");
assert.equal(r.turns, 2);
@@ -71,45 +85,38 @@ test("engine: a run with tool turns settles once, on the answer, with every tool
assert.equal(plain.turns, 1);
await idle(engine);
assert.equal(engine.busy, false);
- await engine.stop();
-});
+}));
-test("engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end", async () => {
- const { engine } = start(makeRoot());
+test("engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end", () => withEngine({}, async ({ engine }) => {
const r = await engine.prompt("toolonly");
assert.equal(r.text, "", "no text: the connector turns this into engine-empty");
assert.equal(r.tools.length, 1);
const again = await engine.prompt("retry");
assert.equal(again.text, "after retry");
- await engine.stop();
-});
+}));
-test("engine: one prompt, one turn, text and usage come back", async () => {
- const { engine } = start(makeRoot());
- try {
- const r = await engine.prompt("hello");
- assert.equal(r.text, "echo: hello");
- assert.deepEqual(r.usage, { input: 3, output: 2 });
- await idle(engine);
- assert.equal(engine.busy, false);
- } finally {
- await engine.stop();
- }
-});
+test("engine: one prompt, one turn, text and usage come back", () => withEngine({}, async ({ engine }) => {
+ const r = await engine.prompt("hello");
+ assert.equal(r.text, "echo: hello");
+ assert.deepEqual(r.usage, { input: 3, output: 2 });
+ await idle(engine);
+ assert.equal(engine.busy, false);
+}));
-test("engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order", async () => {
- const { engine, commands } = start(makeRoot());
- const first = engine.prompt("slow 150");
- await new Promise((r) => setTimeout(r, 20));
+test("engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order", () => withEngine({}, async ({ engine, commands }) => {
+ const first = engine.prompt("slow 300");
assert.equal(engine.busy, true);
const second = engine.prompt("second");
assert.equal(engine.pendingCount, 2);
- await new Promise((r) => setTimeout(r, 20));
- assert.equal(commands().filter((c) => c.type === "prompt").length, 1, "the second prompt is not sent while pi is busy");
+ const prompted = () => commands().filter((c) => c.type === "prompt");
+ assert.ok(await until(() => prompted().length > 0), "the first prompt reached pi");
+ assert.equal(prompted().length, 1, "the second prompt is not sent while pi is busy");
+ // The fake refuses a prompt without streamingBehavior while it runs one, so
+ // an answered second prompt also proves it was not sent early.
const [r1, r2] = await Promise.all([first, second]);
assert.equal(r1.text, "slow reply");
assert.equal(r2.text, "echo: second");
- const prompts = commands().filter((c) => c.type === "prompt");
+ const prompts = prompted();
assert.equal(prompts.length, 2);
// Never a pi follow-up: pi would fold it into the first run and close both
// answers with one agent_end (the live loss of 2026-09-17).
@@ -117,11 +124,9 @@ test("engine: a prompt while streaming is held until pi settles, then sent as it
assert.equal(prompts[1].streamingBehavior, undefined);
await idle(engine);
assert.equal(engine.busy, false);
- await engine.stop();
-});
+}));
-test("engine: a held prompt that times out before pi settles fails on its own and is never sent", async () => {
- const { engine, commands } = start(makeRoot());
+test("engine: a held prompt that times out before pi settles fails on its own and is never sent", () => withEngine({}, async ({ engine, commands }) => {
const first = engine.prompt("slow 200");
await new Promise((r) => setTimeout(r, 20));
await assert.rejects(engine.prompt("late one", { timeoutMs: 50 }), (e) => e.details.code === "timeout" && /waiting for the engine/.test(e.message));
@@ -130,50 +135,177 @@ test("engine: a held prompt that times out before pi settles fails on its own an
await idle(engine);
assert.deepEqual(commands().filter((c) => c.type === "prompt").map((c) => c.message), ["slow 200"]);
assert.deepEqual(commands().filter((c) => c.type === "abort"), [], "a held turn is not aborted; pi never had it");
- await engine.stop();
-});
+}));
-test("engine: timeout sends abort and fails only that turn; the process stays", async () => {
- const { engine, commands, logs } = start(makeRoot());
+test("engine: timeout sends abort and fails only that turn; the process stays", () => withEngine({}, async ({ engine, commands, logs }) => {
await assert.rejects(engine.prompt("slow 5000", { timeoutMs: 100 }), (err) => err.details.code === "timeout");
assert.ok(await until(() => commands().some((c) => c.type === "abort")), "abort reached pi");
assert.ok(logs.some((l) => /timed out/.test(l)));
const r = await engine.prompt("again");
assert.equal(r.text, "echo: again");
- await engine.stop();
+}));
+
+test("engine: tool events from a run that outlived its timeout never land in the next prompt's record", () => withEngine({}, async ({ engine }) => {
+ await assert.rejects(engine.prompt("late 200", { timeoutMs: 40 }), (err) => err.details.code === "timeout");
+ const r = await engine.prompt("after late");
+ assert.equal(r.text, "echo: after late");
+ assert.deepEqual(r.tools, [], "the dead run's read is not this prompt's evidence");
+ assert.equal(r.turns, 1, "the dead run's turns are not counted here");
+}));
+
+// The turn timer is fired by hand, before the engine has read any event from
+// pi, so the timed-out run is still pi's and state.busy is still false when
+// the next prompt arrives. Under load a real timer does the same.
+const TURN_MS = 60000;
+const manualTurnTimer = (fire) => ({
+ setTimeoutImpl: (fn, ms) => (ms === TURN_MS ? fire.push(fn) : setTimeout(fn, ms)),
+ clearTimeoutImpl: (id) => { if (typeof id !== "number") clearTimeout(id); },
});
-test("engine: tool events from a run that outlived its timeout never land in the next prompt's record", async () => {
- const { engine } = start(makeRoot());
- try {
- await assert.rejects(engine.prompt("late 200", { timeoutMs: 40 }), (err) => err.details.code === "timeout");
- const r = await engine.prompt("after late");
+test("engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused", () => {
+ const fire = [];
+ return withEngine(manualTurnTimer(fire), async ({ engine, commands }) => {
+ const late = engine.prompt("late 100", { timeoutMs: TURN_MS });
+ fire.shift()();
+ assert.equal(engine.busy, true, "pi is still running the prompt that timed out");
+ const next = engine.prompt("after late", { timeoutMs: 5000 });
+ assert.equal(engine.pendingCount, 1, "only the new prompt is live");
+ await assert.rejects(late, (err) => err.details.code === "timeout");
+ const r = await next;
assert.equal(r.text, "echo: after late");
assert.deepEqual(r.tools, [], "the dead run's read is not this prompt's evidence");
- assert.equal(r.turns, 1, "the dead run's turns are not counted here");
- } finally {
- await engine.stop();
- }
+ assert.equal(r.turns, 1);
+ assert.deepEqual(commands().map((c) => (c.type === "prompt" ? c.message : c.type)), ["late 100", "abort", "after late"]);
+ await idle(engine);
+ assert.equal(engine.busy, false);
+ });
+});
+
+// "mute" is accepted and never run, so no agent_start, agent_end or settle
+// ever comes for it. Unbounded, it would hold every later prompt.
+test("engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts", async () => {
+ let exited = null;
+ await withEngine({ abortGraceMs: 150, onExit: (e) => (exited = e) }, async ({ engine, commands, logs }) => {
+ await assert.rejects(engine.prompt("mute", { timeoutMs: 50 }), (err) => err.details.code === "timeout");
+ assert.equal(engine.busy, true, "pi might still be running it");
+ const started = Date.now();
+ await assert.rejects(engine.prompt("after mute", { timeoutMs: 5000 }), (err) => err.details.code === "engine-wedged");
+ assert.ok(Date.now() - started >= 100, "held for the grace, not failed at once");
+ await assert.rejects(engine.prompt("later"), (err) => err.details.code === "engine-down");
+ assert.ok(await until(() => exited !== null), "pi exits and onExit hears of it");
+ assert.ok(logs.some((l) => /stopping pi/.test(l)));
+ assert.deepEqual(commands().map((c) => (c.type === "prompt" ? c.message : c.type)), ["mute", "abort"]);
+ });
+});
+
+// Rocko's 6b R1 case: pi is stuck before agent_start, then runs the old
+// prompt and only afterwards reads the next one. The events carry no prompt
+// id, so a prompt sent after the grace would get the old run's answer.
+test("engine: a timed-out turn pi starts only after the grace never answers a later prompt", async () => {
+ let exited = null;
+ await withEngine({ abortGraceMs: 150, onExit: (e) => (exited = e) }, async ({ engine, commands }) => {
+ await assert.rejects(engine.prompt("stall 400", { timeoutMs: 50 }), (err) => err.details.code === "timeout");
+ await assert.rejects(engine.prompt("after stall", { timeoutMs: 5000 }), (err) => err.details.code === "engine-wedged");
+ assert.ok(await until(() => exited !== null), "pi exits and onExit hears of it");
+ await new Promise((res) => setTimeout(res, 400));
+ assert.ok(!commands().some((c) => c.message === "after stall"), "nothing was sent after the grace");
+ });
});
-test("engine: a malformed JSONL line fails the turn, not the process", async () => {
- const { engine, logs } = start(makeRoot());
+// The same case in memory, after Rocko's reproducer: the old run's events
+// arrive after the grace while pi is still exiting. They land on the failed
+// turn, nothing more is written to pi, and only the exit ends the engine.
+// Pi's response to the old prompt comes either before its timeout or only
+// with the late events.
+for (const lateResponse of [false, true]) test(`engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (${lateResponse ? "late" : "early"} prompt response)`, async () => {
+ const timers = [];
+ const written = [];
+ const kills = [];
+ const child = new EventEmitter();
+ child.stdout = new PassThrough();
+ child.stderr = new PassThrough();
+ child.stdin = { write: (s) => { written.push(JSON.parse(s)); return true; }, end: () => {} };
+ child.kill = (signal) => { kills.push(signal); return true; };
+ let exited = null;
+ const engine = createEngine({
+ command: "memory-only", args: [], spawn: () => child, abortGraceMs: 150, onExit: (e) => (exited = e),
+ setTimeoutImpl: (fn, ms) => { const t = { fn, ms, active: true }; timers.push(t); return t; },
+ clearTimeoutImpl: (t) => { t.active = false; },
+ });
+ const emit = (x) => child.stdout.write(JSON.stringify(x) + "\n");
+ const fire = (ms) => { const t = timers.find((x) => x.ms === ms && x.active); assert.ok(t, `timer ${ms}`); t.active = false; t.fn(); };
+ const message = (text) => ({ role: "assistant", content: [{ type: "text", text }], stopReason: "stop" });
+ const tick = () => new Promise((res) => setImmediate(res));
+ // Checked after a tick instead of awaited, so a regression fails here
+ // rather than hanging on a promise nothing will settle.
+ const outcome = (p) => {
+ const o = { state: "pending", code: null, text: null };
+ p.then((v) => Object.assign(o, { state: "resolved", text: v.text }), (e) => Object.assign(o, { state: "rejected", code: e.details && e.details.code }));
+ return o;
+ };
+ engine.start();
+ const first = engine.prompt("old", { timeoutMs: 50 });
+ const accept = () => emit({ type: "response", id: written[0].id, command: "prompt", success: true });
+ if (!lateResponse) accept();
+ await tick();
+ fire(50);
+ await assert.rejects(first, (err) => err.details.code === "timeout");
+ const next = outcome(engine.prompt("new", { timeoutMs: 2000 }));
+ fire(150);
+ await tick();
+ assert.deepEqual(next, { state: "rejected", code: "engine-wedged", text: null });
+ assert.deepEqual(kills, ["SIGTERM"]);
+ if (lateResponse) accept();
+ emit({ type: "agent_start" });
+ emit({ type: "tool_execution_start", toolCallId: "old-call", toolName: "read_file", args: { root: "docs", path: "old.md" } });
+ emit({ type: "tool_execution_end", toolCallId: "old-call", toolName: "read_file", result: { details: { root: "docs", path: "old.md", ok: true } } });
+ emit({ type: "turn_end", message: message("OLD RUN ANSWER") });
+ emit({ type: "agent_end", messages: [message("OLD RUN ANSWER")] });
+ emit({ type: "agent_settled" });
+ await tick();
+ const after = outcome(engine.prompt("after settle", { timeoutMs: 2000 }));
+ await tick();
+ assert.deepEqual(after, { state: "rejected", code: "engine-down", text: null });
+ assert.deepEqual(next, { state: "rejected", code: "engine-wedged", text: null }, "the old answer did not reach the new prompt");
+ assert.deepEqual(written.map((c) => (c.type === "prompt" ? c.message : c.type)), ["old", "abort"], "no prompt reached pi after the grace");
+ assert.equal(exited, null);
+ fire(5000);
+ assert.deepEqual(kills, ["SIGTERM", "SIGKILL"]);
+ child.emit("exit", null, "SIGKILL");
+ assert.deepEqual(exited, { code: null, signal: "SIGKILL" });
+});
+
+test("engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends", async () => {
+ let exited = null;
+ await withEngine({ abortGraceMs: 150, onExit: (e) => (exited = e) }, async ({ engine, commands }) => {
+ await assert.rejects(engine.prompt("late 400", { timeoutMs: 50 }), (err) => err.details.code === "timeout");
+ const r = await engine.prompt("after late", { timeoutMs: 5000 });
+ assert.equal(r.text, "echo: after late");
+ assert.deepEqual(r.tools, []);
+ assert.equal(exited, null, "pi was not stopped");
+ assert.deepEqual(commands().map((c) => (c.type === "prompt" ? c.message : c.type)), ["late 400", "abort", "after late"]);
+ });
+});
+
+test("engine: a malformed JSONL line fails the turn, not the process", () => withEngine({}, async ({ engine, logs }) => {
await assert.rejects(engine.prompt("garbage"), (err) => err.details.code === "engine-protocol");
assert.ok(logs.some((l) => /malformed/.test(l)));
const r = await engine.prompt("still here");
assert.equal(r.text, "echo: still here");
- await engine.stop();
-});
+}));
test("engine: a turn that ends in error rejects with the error code; process exit fails pending turns", async () => {
- const root = makeRoot();
let exited = null;
- const { engine } = start(root, { onExit: (e) => (exited = e) });
- await assert.rejects(engine.prompt("error"), (err) => err.details.code === "engine-error" && /fake provider error/.test(err.message));
- const pending = engine.prompt("slow 5000");
- await new Promise((r) => setTimeout(r, 20));
- await engine.stop();
- await assert.rejects(pending, (err) => err.details.code === "engine-down");
- assert.ok(exited);
- await assert.rejects(engine.prompt("x"), /not running/);
+ const { engine } = start(makeRoot(), { onExit: (e) => (exited = e) });
+ try {
+ await assert.rejects(engine.prompt("error"), (err) => err.details.code === "engine-error" && /fake provider error/.test(err.message));
+ const pending = engine.prompt("slow 5000");
+ await new Promise((r) => setTimeout(r, 20));
+ await engine.stop();
+ await assert.rejects(pending, (err) => err.details.code === "engine-down");
+ assert.ok(exited);
+ await assert.rejects(engine.prompt("x"), /not running/);
+ } finally {
+ await engine.stop();
+ }
});
diff --git a/packages/discord/tests/fake-pi.mjs b/packages/discord/tests/fake-pi.mjs
index 94919306..bf94c013 100644
--- a/packages/discord/tests/fake-pi.mjs
+++ b/packages/discord/tests/fake-pi.mjs
@@ -8,9 +8,13 @@
// then a second turn that answers "read <n> file(s)"
// "toolonly" a run whose only turn calls a tool and never answers
// "retry" an agent_end with willRetry, then the real answer
+// "mute" accept the prompt and emit nothing, staying idle
// "late <ms>" ignore abort; after <ms> emit a tool pair and a tool turn,
// then answer "late reply", like a run that outlives its
// client-side timeout
+// "stall <ms>" accept the prompt, then read nothing for <ms> (pi stuck
+// before agent_start); then run it, answering "echo:
+// stalled", and only then read what came in meanwhile
// anything else answer "echo: <text>" immediately
// A prompt received while busy without streamingBehavior is refused, as pi
// does. A prompt with streamingBehavior followUp is folded into the running
@@ -30,6 +34,7 @@ function assistant(text, stopReason = "stop") {
}
function run(text) {
+ if (text === "mute") return;
busy = true;
out({ type: "agent_start" });
out({ type: "turn_start" });
@@ -109,11 +114,16 @@ function run(text) {
let current = null;
let buffer = "";
+let stalled = false;
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
buffer += chunk;
+ drain();
+});
+
+function drain() {
let idx;
- while ((idx = buffer.indexOf("\n")) !== -1) {
+ while (!stalled && (idx = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, idx);
buffer = buffer.slice(idx + 1);
if (!line) continue;
@@ -125,7 +135,15 @@ process.stdin.on("data", (chunk) => {
continue;
}
out({ id: cmd.id, type: "response", command: "prompt", success: true });
- if (busy) queue.push(cmd.message);
+ const sm = /^stall (\d+)$/.exec(cmd.message);
+ if (sm) {
+ stalled = true;
+ setTimeout(() => {
+ run("stalled");
+ stalled = false;
+ drain();
+ }, Number(sm[1]));
+ } else if (busy) queue.push(cmd.message);
else run(cmd.message);
} else if (cmd.type === "abort") {
out({ id: cmd.id, type: "response", command: "abort", success: true });
@@ -139,5 +157,5 @@ process.stdin.on("data", (chunk) => {
out({ id: cmd.id, type: "response", command: cmd.type, success: true, data: {} });
}
}
-});
+}
process.stdin.on("end", () => process.exit(0));
@@ -0,0 +1,25 @@
{
"observedAt": "2026-09-13T19:56:18.961314+00:00",
"issue": 1510,
"ownerAuthorizedLiveSmoke": true,
"researcher": [
{
"session": ".pi/state/researcher/sessions/2026-09-13T19-52-28-745Z_01a09c54-0b48-7154-addd-8fdce875aa4a.jsonl",
"entryId": "64b15fd5",
"timestamp": "2026-09-13T19:52:43.079Z",
"response": "RESEARCHER_NATIVE_SMOKE_OK",
"entrySha256": "d9cbaa5ea640d2b858a86b2fa24d3240b351d9383ffaee9721dd86fcd080c329"
}
],
"rocko": {
"newLaunch": "refused by existing native launch lock",
"existingPid": 3707667,
"cwd": "/mnt/storage/src/mosaic-stack",
"nativeContextVerified": true,
"sonnetFlagVerified": true,
"socket": "mosaic-fleet",
"newModelResponseTested": false
},
"existingProcessesRestarted": false,
"homeLaunchersModified": false
}
@@ -0,0 +1,19 @@
{
"issue": 1510,
"candidate": "/tmp/internal-team-r1-i9t21yzr",
"manifestSha256": "23a27014ce6f04ce8187d8495b2efe62b814c3c7d041b027f99b1ec4d490709d",
"files": [
"AGENTS.md",
"agents/README.md",
"agents/researcher/SOUL.md",
"agents/researcher/CONTEXT.md",
"agents/researcher/README.md",
"agents/researcher/launch.sh",
"agents/researcher/validate-sessions.mjs",
"scripts/test-darkwing-launch.mjs",
"docs/plans/2026-09-13_internal-development-bootstrap.md"
],
"tests": "node --test scripts/test-darkwing-launch.mjs scripts/test-rocko-launch.mjs",
"passed": 6,
"state": "ready for independent review"
}
@@ -0,0 +1,77 @@
# Ledger: T3 header counts as agent (#1506), R1 candidate
Sage assigned this on 2026-09-26 after commit A (af4203ca). Filbert reviews.
Not committed.
## Defect
`messageKind` in `packages/ledger/src/ledger.mjs` knew only the tmux preamble
`[host:session -> host:session]`. A prompt that opens with the T3 header
`[from: sage (1ef1e4f8-…) -> to: filbert (9cb9731e-…) class=actionable]`
counted as human, so Table 2's Human column and the human-per-closed ratio
rise once seats talk over T3. DEFERRED Open entry "Ledger counts T3 agent
messages as human".
## Change
- `messageKind` also matches the T3 header on the first line. The sender is the
`from:` role. `control-board` is board, any other role is agent. Anything short
of the full header stays human. That includes the header on a later line, a
leading space, a missing thread id, `class=` with capitals, `]` followed by a
non-space, and `From:` capitalized. The tmux branch is unchanged.
- Two tests: a Table 2 fixture with two headered prompts and one plain prompt
for seat `bob`, expecting agent 2 and human 1. Also a direct classification table.
- README counting rule names both forms.
## Evidence
- `node --test --test-reporter=tap packages/ledger/tests/`: 22/22 on the
working tree.
- The same test file against HEAD's `ledger.mjs` (full `git archive HEAD` tree):
20/22. The two failures are the two new tests, so they catch the defect.
An earlier archive of `packages/ledger` alone also failed three gitea-helper
tests. Those tests need `scripts/gitea-api.sh`, which the partial archive left out.
- Suites on the working tree: config 24, task 90, foundation 43, conductor 17,
release 14, auth 15, discord 63. None of them runs the ledger tests.
## Frozen files
`r1-manifest.sha256` holds the three file hashes; `r1.patch` is `git diff
packages/ledger` at freeze time.
## Known limit, not fixed here
The fix changes zero current counts. Table 2 reads only
`.pi/state/<seat>/sessions/*.jsonl`, and no file there contains a T3 header
for any seat. Filbert checked this in R1:
- `grep -rF '[from: ' .pi/state/*/sessions/` finds nothing.
- The candidate `messageKind` gives Dewey 52 agent, 7 board and 9 human, and
Sage 193 agent and 72 human. That covers every user message in Pi logs
modified since 2026-09-20. Every non-human first line is the tmux form.
- Filbert's three T3 messages to Dewey on 2026-09-26 are not in
`.pi/state/dewey`.
Dewey's and Sage's Pi logs are current, but they only carry tmux traffic. T3
traffic goes to the harness transcripts: Claude under `~/.claude/projects`,
Codex under `~/.codex/sessions`. The ledger reads neither, so a T3-routed
prompt to any seat counts nowhere, as agent or as human. The Human column
can't see T3 traffic at all. For Darkwing and Filbert, whose newest Pi logs
end 2026-09-14, and for Rocko, who has no Pi sessions directory, zero means an
empty source, not zero human prompts. The fix is correct for a source that
carries T3 headers. Sage asked for a brief on a read-only T3 thread source;
Gate F waits on it.
Filbert also found two misclassifications in older logs. Neither is touched
here:
- `[rev-code-02 -> dragon-lin:sage class=actionable]` has no host on the
sender, so it counts as human.
- One Dewey prompt opens with a quote character before the tmux preamble, so
it counts as human.
## Review
Filbert, R1, 2026-09-26: approved the three frozen files. He verified the
manifest and patch, got 22/22 on the tree and 20/22 against HEAD's source, and
matched the regex to `docs/guides/T3-AGENT-COMMS.md`. He accepts the body on
the header's line, which the tmux branch also allows. He corrected the
known-limit text above.
@@ -0,0 +1,3 @@
e0d411ca2f45d85734eef130dba645646df6e9128ea7dc2eaba2205df7891bb8 packages/ledger/README.md
e24b065c4284370960ac6ff1ed66810fe601da64ae9b9362584fe9fbee334017 packages/ledger/src/ledger.mjs
a9da013e81aff360cb013a8e103fdd111aee7e42553b96cb0811560b3da39250 packages/ledger/tests/ledger.test.mjs
@@ -0,0 +1,89 @@
diff --git a/packages/ledger/README.md b/packages/ledger/README.md
index a998a76c..da0ab1c5 100644
--- a/packages/ledger/README.md
+++ b/packages/ledger/README.md
@@ -36,11 +36,15 @@ 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.
-- The first text line alone classifies a message. A bracketed addressing
- preamble whose source session is `control-board` is board; any other valid
- addressing preamble is agent; otherwise human. 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.
+- 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.
+ 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
diff --git a/packages/ledger/src/ledger.mjs b/packages/ledger/src/ledger.mjs
index dc8a3a69..b09e95f5 100644
--- a/packages/ledger/src/ledger.mjs
+++ b/packages/ledger/src/ledger.mjs
@@ -77,8 +77,12 @@ export function messageText(content) {
}
export function messageKind(text) {
const firstLine = text.split(/\r?\n/, 1)[0];
- const match = firstLine.match(/^\[([^\s:\[\]]+):([^\s\[\]]+) -> ([^\s:\[\]]+):([^\s\[\]]+)(?: class=[a-z-]+)?\](?:\s|$)/);
- return !match ? 'human' : match[2] === 'control-board' ? 'board' : 'agent';
+ // tmux preamble from agent-send.sh: [host:session -> host:session class=x]
+ const tmux = firstLine.match(/^\[([^\s:\[\]]+):([^\s\[\]]+) -> ([^\s:\[\]]+):([^\s\[\]]+)(?: class=[a-z-]+)?\](?:\s|$)/);
+ // T3 header (docs/guides/T3-AGENT-COMMS.md): [from: role (id) -> to: role (id) class=x]
+ const t3 = firstLine.match(/^\[from: ([^\s()\[\]]+) \(([^()\[\]]+)\) -> to: ([^\s()\[\]]+) \(([^()\[\]]+)\)(?: class=[a-z-]+)?\](?:\s|$)/);
+ const sender = tmux ? tmux[2] : t3 ? t3[1] : null;
+ return sender === null ? 'human' : sender === 'control-board' ? 'board' : 'agent';
}
async function directories(dir, optional = false) {
try {
diff --git a/packages/ledger/tests/ledger.test.mjs b/packages/ledger/tests/ledger.test.mjs
index 7b4e4d33..175f5d5e 100644
--- a/packages/ledger/tests/ledger.test.mjs
+++ b/packages/ledger/tests/ledger.test.mjs
@@ -110,6 +110,19 @@ test('invalid dates, reverse dates and duplicate options refuse', t => {
assert.throws(() => dateRange('2026-02-30')); assert.throws(() => dateRange('2026-09-12', '2026-09-06'));
const f = fixture(t); assert.equal(f.run(['--since', '2026-09-01']).status, 1);
});
+test('T3 agent assignments do not count as human in Table 2', t => {
+ const f = fixture(t);
+ f.put('.pi/state/bob/sessions/t3.jsonl', [
+ f.entry('[from: sage (1ef1e4f8) -> to: bob (9cb9731e) class=actionable]\nassign #1'),
+ f.entry('[from: sage (1ef1e4f8) -> to: bob (9cb9731e)]\nfollow-up #1'),
+ f.entry('Jason: go ahead'),
+ ].map(x => JSON.stringify(x)).join('\n') + '\n');
+ const result = f.run(['--json']);
+ assert.equal(result.status, 0, result.stderr);
+ const r = JSON.parse(result.stdout);
+ assert.deepEqual(r.seats, [{ seat: 'alice', board: 1, agent: 1, human: 1 }, { seat: 'bob', board: 0, agent: 2, human: 1 }]);
+ assert.equal(r.totals.humanMessagesPerClosedIssue, 2);
+});
test('preamble parsing and issue number boundaries', () => {
assert.equal(messageKind('[h:control-board -> h:seat] hi'), 'board');
assert.equal(messageKind('[h:seat -> h:seat class=actionable] hi'), 'agent');
@@ -117,6 +130,20 @@ test('preamble parsing and issue number boundaries', () => {
assert.equal(messageKind(' [h:seat -> h:seat] quoted'), 'human');
assert.deepEqual(issueNumbers('fix #1 #2 #2 abc#3 #0 #4x'), [1, 2]);
});
+test('T3 header: agent, or board from control-board; anything short of the full header is human', () => {
+ const sage = 'sage (1ef1e4f8-3ead-4208-beca-38f9f1add079)', filbert = 'filbert (9cb9731e-a10f-4c8f-a212-c4fa1f5f4731)';
+ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert}]\nbuild #1506`), 'agent');
+ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=actionable]\nbuild`), 'agent');
+ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert}] same line`), 'agent');
+ assert.equal(messageKind(`[from: darkwing (thread-id: unknown) -> to: reviewer (new-thread)]\nreview`), 'agent');
+ assert.equal(messageKind(`[from: control-board (b) -> to: ${filbert}]\nhi`), 'board');
+ assert.equal(messageKind(`Jason here\n[from: ${sage} -> to: ${filbert}]\nquoted`), 'human');
+ assert.equal(messageKind(` [from: ${sage} -> to: ${filbert}]`), 'human');
+ assert.equal(messageKind(`[from: sage -> to: filbert]\nno thread ids`), 'human');
+ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=Actionable]`), 'human');
+ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert}]trailing`), 'human');
+ assert.equal(messageKind(`[From: ${sage} -> to: ${filbert}]`), 'human');
+});
test('no closed issues with human messages means undefined ratio, not invented zero', () => {
const r = summarize(range, [], [], { rows: [{ seat: 'a', human: 1, board: 0, agent: 0 }], mentions: new Map() });
assert.equal(r.totals.humanMessagesPerClosedIssue, 'unknown');
@@ -0,0 +1,5 @@
0afb0320e9a1833f133426169c389ffb71be3d490e0e402070162aa22e820296 packages/ledger/src/ledger.mjs
d6092a538a059e8869544df903e89a8b9a4c3b6b492645db762b2f03d5630a44 packages/ledger/src/cli.mjs
dfb092aabee5cf5197029c6e8978df570ee50f08d84babde931c6a763befafe9 packages/ledger/src/t3.mjs
7444abd1dbd8e637705def0fd98105e6e69970f1bd397a11f8277996521579d6 packages/ledger/tests/ledger.test.mjs
27f7366dd5edc30a93a8c54bfb46b3fed87e1a44f11e1b22bd159a0718625273 packages/ledger/README.md
@@ -0,0 +1,161 @@
# 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.
@@ -0,0 +1,828 @@
diff --git a/packages/ledger/README.md b/packages/ledger/README.md
index da0ab1c5..393e9c37 100644
--- a/packages/ledger/README.md
+++ b/packages/ledger/README.md
@@ -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
diff --git a/packages/ledger/src/cli.mjs b/packages/ledger/src/cli.mjs
index 66cd1417..5cf6705c 100644
--- a/packages/ledger/src/cli.mjs
+++ b/packages/ledger/src/cli.mjs
@@ -1,11 +1,12 @@
#!/usr/bin/env node
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { dateRange, readCommits, readIssues, readSessions, summarize, formatTable, SourceError } from './ledger.mjs';
+import { dateRange, readCommits, readIssues, readSessions, mergeSources, summarize, formatTable, SourceError } from './ledger.mjs';
+import { readT3, defaultT3Path } from './t3.mjs';
-const usage = 'Usage: node packages/ledger/src/cli.mjs --since YYYY-MM-DD [--until YYYY-MM-DD] [--json] [--no-issues]';
+const usage = 'Usage: node packages/ledger/src/cli.mjs --since YYYY-MM-DD [--until YYYY-MM-DD] [--json] [--no-issues] [--no-t3 | --t3-db PATH]';
export async function main(args, root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')) {
- let since, until, json = false, noIssues = false;
+ let since, until, t3Db, json = false, noIssues = false, noT3 = false;
const seen = new Set();
for (let i = 0; i < args.length; i++) {
const flag = args[i];
@@ -14,13 +15,18 @@ export async function main(args, root = path.resolve(path.dirname(fileURLToPath(
if (flag === '--help') { console.log(usage); return; }
if (flag === '--json') json = true;
else if (flag === '--no-issues') noIssues = true;
- else if (flag === '--since' || flag === '--until') {
+ else if (flag === '--no-t3') noT3 = true;
+ else if (flag === '--t3-db') {
+ t3Db = args[++i];
+ if (!t3Db || t3Db.startsWith('--')) throw new SourceError('--t3-db requires a path');
+ } else if (flag === '--since' || flag === '--until') {
const value = args[++i];
if (!value || value.startsWith('--')) throw new SourceError(`${flag} requires a date`);
if (flag === '--since') since = value; else until = value;
} else throw new SourceError('Unknown option; ' + usage);
}
if (!since) throw new SourceError(usage);
+ if (noT3 && t3Db !== undefined) throw new SourceError('--no-t3 and --t3-db cannot be combined');
const range = dateRange(since, until);
const commits = readCommits(root, range);
// Fixture tools may be placed first on PATH. The repository client is the
@@ -30,7 +36,9 @@ export async function main(args, root = path.resolve(path.dirname(fileURLToPath(
let issues;
try { issues = noIssues ? null : readIssues(root, range); }
finally { if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath; }
- const sessions = await readSessions(root, range);
+ // T3 is on by default. A missing or unreadable database refuses the report.
+ const t3 = noT3 ? null : await readT3(root, range, t3Db === undefined ? { dbPath: defaultT3Path(), isDefault: true } : { dbPath: t3Db, isDefault: false });
+ const sessions = mergeSources(await readSessions(root, range), t3);
const report = summarize(range, commits, issues, sessions);
console.log(json ? JSON.stringify(report, null, 2) : formatTable(report));
return report;
diff --git a/packages/ledger/src/ledger.mjs b/packages/ledger/src/ledger.mjs
index b09e95f5..3669d1cc 100644
--- a/packages/ledger/src/ledger.mjs
+++ b/packages/ledger/src/ledger.mjs
@@ -1,7 +1,6 @@
import { execFileSync } from 'node:child_process';
import { createReadStream } from 'node:fs';
import { readdir, lstat } from 'node:fs/promises';
-import { createInterface } from 'node:readline';
import path from 'node:path';
const DAY = 86400000;
@@ -23,7 +22,7 @@ export function dateRange(since, until = new Date().toISOString().slice(0, 10))
if (end <= start) throw new SourceError('--until must not precede --since');
return { since, until, start, end };
}
-const inRange = (value, range) => {
+export const inRange = (value, range) => {
const ms = typeof value === 'number' ? value : Date.parse(value);
return Number.isFinite(ms) && ms >= range.start && ms < range.end;
};
@@ -75,15 +74,32 @@ export function messageText(content) {
if (Array.isArray(content)) return content.filter(c => c?.type === 'text' && typeof c.text === 'string').map(c => c.text).join('\n');
return '';
}
+// Classes are matched in either case: seats send DECISION and REVIEW-REQUEST.
+// tmux preamble from agent-send.sh: [host:session -> host:session class=x]
+const TMUX = /^\[([^\s:\[\]]+):([^\s\[\]]+) -> ([^\s:\[\]]+):([^\s\[\]]+)(?: class=[A-Za-z-]+)?\](?:\s|$)/;
+// T3 header (docs/guides/T3-AGENT-COMMS.md): [from: role (id) -> to: role (id) class=x]
+const T3 = /^\[from: ([^\s()\[\]]+) \(([^()\[\]]+)\) -> to: ([^\s()\[\]]+) \(([^()\[\]]+)\)(?: class=[A-Za-z-]+)?\](?:\s|$)/;
+const firstLine = text => text.split(/\r?\n/, 1)[0];
+export function t3Header(text) {
+ const m = firstLine(text).match(T3);
+ return m ? { from: m[1], fromId: m[2], to: m[3], toId: m[4] } : null;
+}
export function messageKind(text) {
- const firstLine = text.split(/\r?\n/, 1)[0];
- // tmux preamble from agent-send.sh: [host:session -> host:session class=x]
- const tmux = firstLine.match(/^\[([^\s:\[\]]+):([^\s\[\]]+) -> ([^\s:\[\]]+):([^\s\[\]]+)(?: class=[a-z-]+)?\](?:\s|$)/);
- // T3 header (docs/guides/T3-AGENT-COMMS.md): [from: role (id) -> to: role (id) class=x]
- const t3 = firstLine.match(/^\[from: ([^\s()\[\]]+) \(([^()\[\]]+)\) -> to: ([^\s()\[\]]+) \(([^()\[\]]+)\)(?: class=[a-z-]+)?\](?:\s|$)/);
- const sender = tmux ? tmux[2] : t3 ? t3[1] : null;
+ const tmux = firstLine(text).match(TMUX), t3 = t3Header(text);
+ const sender = tmux ? tmux[2] : t3 ? t3.from : null;
return sender === null ? 'human' : sender === 'control-board' ? 'board' : 'agent';
}
+// JSONL lines end at \n only. readline also ends a line at U+2028, which JSON
+// allows raw inside a string, so it split valid records (Node 26.8.1).
+async function* jsonLines(input) {
+ let rest = '';
+ for await (const chunk of input) {
+ const parts = (rest + chunk).split('\n');
+ rest = parts.pop();
+ yield* parts;
+ }
+ if (rest) yield rest;
+}
async function directories(dir, optional = false) {
try {
if (!(await lstat(dir)).isDirectory()) throw new SourceError('Session source must be a real directory');
@@ -93,11 +109,15 @@ async function directories(dir, optional = false) {
throw new SourceError(`Cannot read ledger directory: ${dir}`);
}
}
+// Seats are the real directories in agents/, sorted.
+export async function readSeats(root) {
+ return (await directories(path.join(root, 'agents'))).filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => a.localeCompare(b));
+}
export async function readSessions(root, range) {
const rows = [];
const mentions = new Map();
// No symlink traversal, no fleet paths, no transcript content in the report.
- const agents = (await directories(path.join(root, 'agents'))).filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
+ const agents = (await readSeats(root)).map(name => ({ name }));
const state = path.join(root, '.pi', 'state');
// Check every source ancestor, not only the leaf directory.
if (!(await directories(path.join(root, '.pi'), true)).length) return { rows, mentions };
@@ -109,11 +129,10 @@ export async function readSessions(root, range) {
const files = (await directories(dir, true)).filter(e => e.isFile() && e.name.endsWith('.jsonl'));
const row = { seat: agent.name, board: 0, agent: 0, human: 0 };
for (const file of files) {
- const input = createReadStream(path.join(dir, file.name));
- const lines = createInterface({ input, crlfDelay: Infinity });
+ const input = createReadStream(path.join(dir, file.name), { encoding: 'utf8' });
let lineNumber = 0;
try {
- for await (const line of lines) {
+ for await (const line of jsonLines(input)) {
lineNumber++;
if (!line.trim()) continue;
let entry;
@@ -132,12 +151,33 @@ export async function readSessions(root, range) {
mentions.get(number).add(agent.name);
}
}
- } finally { lines.close(); input.destroy(); }
+ } finally { input.destroy(); }
}
if (row.board + row.agent + row.human) rows.push(row);
}
return { rows, mentions };
}
+// Adds T3 counts to the Pi rows per seat. Unmapped T3 threads get one row,
+// last. The report keeps the Pi rows and the T3 section so the split shows.
+export function mergeSources(pi, t3, unmapped = 't3:unmapped') {
+ if (!t3) return { rows: pi.rows, mentions: pi.mentions, pi: pi.rows, t3: { read: false } };
+ const bySeat = new Map(pi.rows.map(r => [r.seat, { ...r }]));
+ for (const [seat, counts] of t3.rows) {
+ if (seat === unmapped || !(counts.board + counts.agent + counts.human)) continue;
+ const row = bySeat.get(seat) ?? { seat, board: 0, agent: 0, human: 0 };
+ for (const kind of ['board', 'agent', 'human']) row[kind] += counts[kind];
+ bySeat.set(seat, row);
+ }
+ const rows = [...bySeat.values()].sort((a, b) => a.seat.localeCompare(b.seat));
+ const extra = t3.rows.get(unmapped);
+ if (extra.board + extra.agent + extra.human) rows.push({ seat: unmapped, ...extra });
+ const mentions = new Map([...pi.mentions].map(([n, seats]) => [n, new Set(seats)]));
+ for (const [n, seats] of t3.mentions) {
+ if (!mentions.has(n)) mentions.set(n, new Set());
+ for (const seat of seats) mentions.get(n).add(seat);
+ }
+ return { rows, mentions, pi: pi.rows, t3: t3.section };
+}
const round = value => Math.round(value * 10) / 10;
function duration(issue) {
if (!issue) return UNKNOWN;
@@ -163,13 +203,14 @@ export function summarize(range, commits, issues, sessions) {
const median = hours.includes(UNKNOWN) ? UNKNOWN : hours.length ?
round(hours.length % 2 ? hours[middle] : (hours[middle - 1] + hours[middle]) / 2) : 0;
const human = sessions.rows.reduce((sum, r) => sum + r.human, 0);
- return { since: range.since, until: range.until, timezone: 'UTC', issues: rows, seats: sessions.rows,
+ const sources = sessions.t3 ? { pi: sessions.pi, t3: sessions.t3 } : {};
+ return { since: range.since, until: range.until, timezone: 'UTC', issues: rows, seats: sessions.rows, ...sources,
totals: { issuesClosed: issues === null ? UNKNOWN : closed.length,
medianHoursOpen: issues === null ? UNKNOWN : median, commits: commits.length,
followUpsPerIssue: rows.length ? round(rows.reduce((sum, r) => sum + r.followUps, 0) / rows.length) : 0,
humanMessagesPerClosedIssue: issues === null ? UNKNOWN : closed.length ? round(human / closed.length) : human ? UNKNOWN : 0 } };
}
-const clean = value => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
+export const clean = value => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
const decimal = value => typeof value === 'number' ? value.toFixed(1) : value;
export function totalsLine(t) {
return `Totals: issues closed ${t.issuesClosed} | median hours open ${decimal(t.medianHoursOpen)} | commits ${t.commits} | follow-ups per issue ${decimal(t.followUpsPerIssue)} | human messages per closed issue ${decimal(t.humanMessagesPerClosedIssue)}`;
@@ -181,5 +222,12 @@ export function formatTable(report) {
decimal(r.hoursOpen), r.commits, r.followUps, r.seats.join(', ')].join(' | ')),
'', 'Seat | Board | Agent | Human',
...report.seats.map(r => [clean(r.seat), r.board, r.agent, r.human].join(' | ')),
+ ...t3Line(report.t3),
'', totalsLine(report.totals)].join('\n');
}
+// One line when T3 was skipped or read from somewhere other than the default.
+function t3Line(t3) {
+ if (!t3) return [];
+ if (!t3.read) return ['T3: not read (--no-t3)'];
+ return t3.database.default ? [] : [`T3: read from ${clean(t3.database.path)}, not the default`];
+}
diff --git a/packages/ledger/tests/ledger.test.mjs b/packages/ledger/tests/ledger.test.mjs
index 175f5d5e..8e60b96b 100644
--- a/packages/ledger/tests/ledger.test.mjs
+++ b/packages/ledger/tests/ledger.test.mjs
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, cpSync, symlinkSync } from 'node:fs';
+import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, cpSync, symlinkSync, realpathSync } from 'node:fs';
+import { DatabaseSync } from 'node:sqlite';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -9,13 +10,50 @@ import { dateRange, messageKind, issueNumbers, totalsLine, summarize } from '../
const source = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src');
const range = dateRange('2026-09-06', '2026-09-12');
+// T3 fixture schema: the live tables, cut to the columns the reader uses plus
+// one it doesn't. `text` allows NULL so a non-text row can be tested.
+const T3_SCHEMA = `
+ create table projection_projects (project_id text primary key, title text not null, workspace_root text not null, deleted_at text);
+ create table projection_threads (thread_id text primary key, project_id text not null, title text not null, archived_at text, deleted_at text);
+ create table projection_thread_messages (message_id text primary key, thread_id text not null, role text not null, text, created_at text not null);
+ create table orchestration_events (sequence integer primary key autoincrement, stream_id text not null, event_type text not null, payload_json text not null, metadata_json text not null);`;
+// Writes a T3 database in WAL mode. Threads default to project p1, which is
+// the fixture root. Returns the open writer when keepOpen is set.
+function t3db(file, { root, projects, threads = [], messages = [], after = [], keepOpen = false }) {
+ mkdirSync(path.dirname(file), { recursive: true });
+ for (const old of [file, `${file}-wal`, `${file}-shm`]) rmSync(old, { force: true });
+ const db = new DatabaseSync(file);
+ db.exec('pragma journal_mode=wal'); db.exec(T3_SCHEMA);
+ for (const [id, workspace, deleted = null] of projects ?? [['p1', root]]) {
+ db.prepare('insert into projection_projects values (?, ?, ?, ?)').run(id, 'project', workspace, deleted);
+ }
+ for (const t of threads) {
+ db.prepare('insert into projection_threads values (?, ?, ?, ?, ?)').run(t.id, t.project ?? 'p1', t.title, t.archived ?? null, t.deleted ?? null);
+ }
+ for (const m of messages) addMessage(db, m);
+ for (const sql of after) db.exec(sql);
+ if (keepOpen) return db;
+ db.close();
+}
+let messageId = 0;
+function addMessage(db, { thread, text, role = 'user', at = '2026-09-08T12:00:00Z', origin = 'app' }) {
+ const id = `m${++messageId}`;
+ db.prepare('insert into projection_thread_messages values (?, ?, ?, ?, ?)').run(id, thread, role, text, at);
+ if (origin !== 'none') db.prepare('insert into orchestration_events (stream_id, event_type, payload_json, metadata_json) values (?, ?, ?, ?)')
+ .run(thread, 'thread.message-sent', JSON.stringify({ messageId: id, threadId: thread, role, text }), JSON.stringify({ origin: origin === 'app' ? { appVersion: '0.0.0' } : {} }));
+}
const fixtureIssues = [
{ number: 1, title: 'First issue', created_at: '2026-09-06T00:00:00Z', closed_at: '2026-09-07T12:00:00Z' },
{ number: 2, title: 'Second issue', created_at: '2026-09-06T00:00:00Z', closed_at: null },
];
function fixture(t) {
const root = mkdtempSync(path.join(os.tmpdir(), 'ledger-test-'));
- t.after(() => rmSync(root, { recursive: true, force: true }));
+ // No test opens the real ~/.t3: every CLI run gets this HOME, with an empty
+ // T3 database at the default path. The CLI's root is a realpath.
+ const home = mkdtempSync(path.join(os.tmpdir(), 'ledger-home-'));
+ t.after(() => { rmSync(root, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); });
+ const defaultDb = path.join(home, '.t3/userdata/state.sqlite');
+ t3db(defaultDb, { root: realpathSync(root) });
const put = (name, data) => { const p = path.join(root, name); mkdirSync(path.dirname(p), { recursive: true }); writeFileSync(p, data); return p; };
const git = (args, date = '2026-09-07T00:00:00Z') => execFileSync('git', args, { cwd: root, env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, stdio: 'pipe' });
git(['init', '-b', 'refactor']); git(['config', 'user.email', '[email protected]']); git(['config', 'user.name', 'Fixture']);
@@ -30,8 +68,8 @@ function fixture(t) {
const entry = (text, timestamp = '2026-09-08T12:00:00Z') => ({ type: 'message', timestamp, message: { role: 'user', content: [{ type: 'text', text }] } });
const logs = [entry('[host:control-board -> host:alice] do #1'), entry('[host:bob -> host:alice] review #2'), entry('build #2'), entry('old #1', '2026-09-05T23:59:59Z'), { type: 'message', timestamp: '2026-09-08T00:00:00Z', message: { role: 'assistant', content: 'not a user #1' } }];
put('.pi/state/alice/sessions/one.jsonl', logs.map(x => JSON.stringify(x)).join('\n') + '\n');
- const run = (args = [], env = {}) => spawnSync(process.execPath, [path.join(root, 'packages/ledger/src/cli.mjs'), '--since', '2026-09-06', '--until', '2026-09-12', ...args], { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: `${path.join(root, 'bin')}:${process.env.PATH}`, ISSUES: path.join(root, 'issues.json'), CALLS: path.join(root, 'calls.jsonl'), ...env } });
- return { root, put, commit, run, entry, logs };
+ const run = (args = [], env = {}) => spawnSync(process.execPath, [path.join(root, 'packages/ledger/src/cli.mjs'), '--since', '2026-09-06', '--until', '2026-09-12', ...args], { cwd: root, encoding: 'utf8', env: { ...process.env, HOME: home, PATH: `${path.join(root, 'bin')}:${process.env.PATH}`, ISSUES: path.join(root, 'issues.json'), CALLS: path.join(root, 'calls.jsonl'), ...env } });
+ return { root, real: realpathSync(root), home, defaultDb, put, commit, run, entry, logs };
}
test('fixture git subjects only, follow-ups and three session kinds', t => {
const f = fixture(t), result = f.run(['--json']);
@@ -63,7 +101,7 @@ test('missing credentials exit 2, no-issues never calls API and shows unknown',
test('empty range gives no rows and zero totals', t => {
const f = fixture(t);
f.put('issues.json', '[]');
- const result = spawnSync(process.execPath, [path.join(f.root, 'packages/ledger/src/cli.mjs'), '--since', '2027-01-01', '--until', '2027-01-02', '--json'], { encoding: 'utf8', env: { ...process.env, PATH: `${f.root}/bin:${process.env.PATH}`, ISSUES: `${f.root}/issues.json`, CALLS: `${f.root}/calls.jsonl` } });
+ const result = spawnSync(process.execPath, [path.join(f.root, 'packages/ledger/src/cli.mjs'), '--since', '2027-01-01', '--until', '2027-01-02', '--json'], { encoding: 'utf8', env: { ...process.env, HOME: f.home, PATH: `${f.root}/bin:${process.env.PATH}`, ISSUES: `${f.root}/issues.json`, CALLS: `${f.root}/calls.jsonl` } });
assert.equal(result.status, 0, result.stderr); const r = JSON.parse(result.stdout);
assert.deepEqual(r.issues, []); assert.deepEqual(r.seats, []); assert.ok(Object.values(r.totals).every(n => n === 0));
});
@@ -96,6 +134,13 @@ test('partial or malformed session log refuses with location, not content', t =>
const f = fixture(t); f.put('.pi/state/alice/sessions/bad.jsonl', '{sensitive'); const r = f.run();
assert.equal(r.status, 1); assert.match(r.stderr, /Malformed session JSON: alice\/bad.jsonl:1/); assert.doesNotMatch(r.stderr, /sensitive/);
});
+test('a U+2028 inside a session string is one line, not a malformed record', t => {
+ const f = fixture(t);
+ f.put('.pi/state/bob/sessions/sep.jsonl', [f.entry('Jason: one\u2028two #2'), f.entry('[h:alice -> h:bob] ok')].map(x => JSON.stringify(x)).join('\r\n') + '\r\n');
+ assert.ok(readFileSync(path.join(f.root, '.pi/state/bob/sessions/sep.jsonl'), 'utf8').includes('\u2028'));
+ const r = f.run(['--json']); assert.equal(r.status, 0, r.stderr);
+ assert.deepEqual(JSON.parse(r.stdout).seats[1], { seat: 'bob', board: 0, agent: 1, human: 1 });
+});
test('no sessions is an empty table; symlink source refuses', t => {
const f = fixture(t); rmSync(path.join(f.root, '.pi'), { recursive: true });
assert.deepEqual(JSON.parse(f.run(['--json']).stdout).seats, []);
@@ -140,7 +185,11 @@ test('T3 header: agent, or board from control-board; anything short of the full
assert.equal(messageKind(`Jason here\n[from: ${sage} -> to: ${filbert}]\nquoted`), 'human');
assert.equal(messageKind(` [from: ${sage} -> to: ${filbert}]`), 'human');
assert.equal(messageKind(`[from: sage -> to: filbert]\nno thread ids`), 'human');
- assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=Actionable]`), 'human');
+ // Classes match in either case (Gate F). HEAD before the fix called these human.
+ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=Actionable]`), 'agent');
+ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=REVIEW-REQUEST]\nreview`), 'agent');
+ assert.equal(messageKind('[h:sage -> h:bob class=DECISION] go'), 'agent');
+ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=review_request]`), 'human');
assert.equal(messageKind(`[from: ${sage} -> to: ${filbert}]trailing`), 'human');
assert.equal(messageKind(`[From: ${sage} -> to: ${filbert}]`), 'human');
});
@@ -148,3 +197,218 @@ test('no closed issues with human messages means undefined ratio, not invented z
const r = summarize(range, [], [], { rows: [{ seat: 'a', human: 1, board: 0, agent: 0 }], mentions: new Map() });
assert.equal(r.totals.humanMessagesPerClosedIssue, 'unknown');
});
+
+// T3 thread source (Gate F, docs/plans/2026-09-26_ledger-t3-source.md).
+const T1 = 't-alice', T2 = 't-bob', T3 = 't-sagebrush', T4 = 't-researcher', T5 = 't-discord';
+const header = (from, to, toId, cls = '') => `[from: ${from} (x1) -> to: ${to} (${toId})${cls}]`;
+function t3Fixture(t) {
+ const f = fixture(t);
+ for (const seat of ['sage', 'researcher']) mkdirSync(path.join(f.root, 'agents', seat), { recursive: true });
+ const db = path.join(f.home, 'fixture/t3.sqlite');
+ const threads = [
+ { id: T1, title: 'Alice' }, { id: T2, title: 'Bob in Claude', archived: '2026-09-09T00:00:00Z' },
+ { id: T3, title: 'Sagebrush' }, { id: T4, title: 'Researcher' }, { id: T5, title: 'Discord Bot' },
+ { id: 'import:claudeAgent:1', title: 'alice' }, { id: 't-deleted', title: 'Alice', deleted: '2026-09-09T00:00:00Z' },
+ { id: 't-other', project: 'p2', title: 'Alice' },
+ ];
+ const messages = [
+ { thread: T1, text: 'Jason: go #1' },
+ { thread: T1, text: `${header('sage', 'alice', T1, ' class=REVIEW-REQUEST')}\nreview #2`, origin: 'api' },
+ { thread: T1, text: '[h:sage -> h:alice class=DECISION] go', origin: 'api' },
+ { thread: T1, text: `${header('control-board', 'alice', T1)}\nbuzz`, origin: 'api' },
+ { thread: T1, text: 'outside', at: '2026-09-13T00:00:00Z' },
+ { thread: T1, text: 'an answer #9', role: 'assistant', origin: 'none' },
+ { thread: T2, text: 'archived still counts #2' },
+ { thread: T3, text: 'Sagebrush is not sage' },
+ { thread: T3, text: `${header('sage', 'discord', T3)}\nnot a seat role`, origin: 'api' },
+ { thread: T4, text: 'research this' },
+ { thread: T5, text: '[from: SetSpark coordinator (x1) -> to: Discord Bot (x2)]\nfree text', origin: 'api' },
+ { thread: 'import:claudeAgent:1', text: 'imported' },
+ { thread: 't-deleted', text: 'deleted' },
+ { thread: 't-other', text: 'other project' },
+ ];
+ const write = (overrides = {}) => t3db(db, { root: f.real, projects: [['p1', f.real], ['p2', '/elsewhere']], threads, messages, ...overrides });
+ return { ...f, db, threads, messages, write };
+}
+test('T3: seat, archived, unmapped and Researcher threads count; imported, deleted and other-project threads do not', t => {
+ const f = t3Fixture(t); f.write();
+ const result = f.run(['--json', '--t3-db', f.db]);
+ assert.equal(result.status, 0, result.stderr);
+ const r = JSON.parse(result.stdout);
+ assert.deepEqual(r.seats, [
+ { seat: 'alice', board: 2, agent: 3, human: 2 }, { seat: 'bob', board: 0, agent: 0, human: 1 },
+ { seat: 'researcher', board: 0, agent: 0, human: 1 }, { seat: 't3:unmapped', board: 0, agent: 1, human: 2 },
+ ]);
+ assert.deepEqual(r.pi, [{ seat: 'alice', board: 1, agent: 1, human: 1 }]);
+ assert.deepEqual(r.t3.database, { path: f.db, default: false });
+ assert.deepEqual(r.t3.seats, [
+ { seat: 'alice', board: 1, agent: 2, human: 1, threads: [{ id: T1, title: 'Alice', archived: false }] },
+ { seat: 'bob', board: 0, agent: 0, human: 1, threads: [{ id: T2, title: 'Bob in Claude', archived: true }] },
+ { seat: 'researcher', board: 0, agent: 0, human: 1, threads: [{ id: T4, title: 'Researcher', archived: false }] },
+ ]);
+ assert.deepEqual(r.t3.unmapped, { board: 0, agent: 1, human: 2, threads: [
+ { id: T5, title: 'Discord Bot', archived: false }, { id: T3, title: 'Sagebrush', archived: false }] });
+ assert.deepEqual(r.t3.excluded, { importedThreads: 1, deletedThreads: 1 });
+ // The free-text header counts as human; only the diagnostic shows it was sent through the API.
+ assert.deepEqual(r.t3.diagnostic, { humanSentThroughApi: 1, humanWithoutEvent: 0 });
+ assert.equal(r.totals.humanMessagesPerClosedIssue, 6);
+ assert.deepEqual(r.issues.map(x => [x.issue, x.seats]), [[1, ['alice']], [2, ['alice', 'bob']]]);
+ const text = f.run(['--t3-db', f.db]);
+ assert.equal(text.status, 0, text.stderr);
+ assert.ok(text.stdout.includes(`T3: read from ${f.db}, not the default`));
+ assert.match(text.stdout, /t3:unmapped \| 0 \| 1 \| 2/);
+});
+test('T3: the default path is read from HOME and prints no path line; --no-t3 says so', t => {
+ const f = t3Fixture(t); f.write();
+ rmSync(f.defaultDb); cpSync(f.db, f.defaultDb);
+ const json = JSON.parse(f.run(['--json']).stdout);
+ assert.deepEqual(json.t3.database, { path: f.defaultDb, default: true });
+ assert.equal(json.seats.at(-1).seat, 't3:unmapped');
+ const text = f.run(); assert.equal(text.status, 0, text.stderr); assert.doesNotMatch(text.stdout, /^T3:/m);
+ rmSync(path.join(f.home, '.t3'), { recursive: true });
+ const off = f.run(['--no-t3']); assert.equal(off.status, 0, off.stderr);
+ assert.match(off.stdout, /^T3: not read \(--no-t3\)$/m);
+ const offJson = JSON.parse(f.run(['--no-t3', '--json']).stdout);
+ assert.deepEqual(offJson.t3, { read: false }); assert.deepEqual(offJson.seats, [{ seat: 'alice', board: 1, agent: 1, human: 1 }]);
+ const both = f.run(['--no-t3', '--t3-db', f.db]); assert.equal(both.status, 1); assert.match(both.stderr, /cannot be combined/);
+ assert.equal(f.run(['--t3-db']).status, 1);
+});
+test('T3: a HOME with no database exits 1 and names --no-t3', t => {
+ const f = fixture(t); rmSync(path.join(f.home, '.t3'), { recursive: true });
+ const r = f.run(); assert.equal(r.status, 1); assert.equal(r.stdout, '');
+ assert.match(r.stderr, /T3 database unavailable: .*\.t3 is missing or unreadable; use --no-t3/);
+});
+test('T3: a file that is not a database exits 1 and names --no-t3', t => {
+ const f = fixture(t); writeFileSync(f.defaultDb, 'not sqlite'.repeat(100));
+ const r = f.run(); assert.equal(r.status, 1); assert.match(r.stderr, /T3 database cannot be read: .*\(SQLite \d+\); use --no-t3/);
+});
+test('T3: a seat thread renamed to another seat exits 1 naming thread, title and roles', t => {
+ const f = t3Fixture(t);
+ f.write({ messages: [...f.messages, { thread: T2, text: `${header('sage', 'alice', T2, ' class=INFO')}\nfor alice`, origin: 'api' }] });
+ const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1);
+ assert.equal(r.stderr.trim(), `T3 header conflict: thread ${T2} "Bob in Claude" maps to bob, but a header addresses alice`);
+});
+test('T3: an unmapped thread addressed as a seat exits 1', t => {
+ const f = t3Fixture(t);
+ f.write({ messages: [...f.messages, { thread: T3, text: `${header('bob', 'Sage', T3)}\nhi`, origin: 'api' }] });
+ const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1);
+ assert.match(r.stderr, /thread t-sagebrush "Sagebrush" maps to no seat, but a header addresses Sage/);
+});
+test('T3: a header to another thread id is not cross-checked', t => {
+ const f = t3Fixture(t);
+ f.write({ messages: [...f.messages, { thread: T2, text: `${header('sage', 'alice', T1)}\ncopied`, origin: 'api' }] });
+ const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
+ assert.equal(JSON.parse(r.stdout).t3.seats[1].agent, 1);
+});
+test('T3: no project, or two, for this root exits 1', t => {
+ const f = t3Fixture(t);
+ f.write({ projects: [['p1', `${f.real}-link`], ['p2', '/elsewhere']] });
+ let r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, /T3 has no project for .*symlink does not match/);
+ f.write({ projects: [['p1', f.real], ['p2', f.real]] });
+ r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, /T3 has more than one project for/);
+ f.write({ projects: [['p1', f.real], ['p2', f.real, '2026-09-01T00:00:00Z']] });
+ assert.equal(f.run(['--t3-db', f.db]).status, 0);
+});
+for (const [name, after, pattern] of [
+ ['a removed column', ['alter table projection_threads drop column title'], /T3 schema changed: missing projection_threads.title/],
+ ['a missing table', ['drop table projection_thread_messages'], /T3 schema changed: missing projection_thread_messages$/m],
+]) test(`T3: ${name} exits 1 and names it`, t => {
+ const f = t3Fixture(t); f.write({ after, messages: [] });
+ const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, pattern);
+});
+for (const [name, message, pattern] of [
+ ['an unknown role', { role: 'system' }, /T3 message m\d+ in thread t-alice has an unknown role/],
+ ['non-text content', { text: null }, /has non-text content/],
+ ['an unparseable created_at', { at: 'yesterday' }, /has an invalid created_at/],
+]) test(`T3: a counted row with ${name} exits 1 without its text`, t => {
+ const f = t3Fixture(t);
+ f.write({ messages: [...f.messages, { thread: T1, text: 'secret words', ...message }] });
+ const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, pattern); assert.doesNotMatch(r.stderr, /secret/);
+});
+test('T3: a missing orchestration_events makes the diagnostic unknown and keeps the counts', t => {
+ const f = t3Fixture(t); f.write();
+ const before = JSON.parse(f.run(['--json', '--t3-db', f.db]).stdout);
+ f.write({ after: ['drop table orchestration_events'] });
+ const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
+ const after = JSON.parse(r.stdout);
+ assert.deepEqual(after.t3.diagnostic, { humanSentThroughApi: 'unknown', humanWithoutEvent: 'unknown' });
+ assert.deepEqual(after.seats, before.seats); assert.deepEqual(after.totals, before.totals);
+});
+for (const link of ['.t3', '.t3/userdata', '.t3/userdata/state.sqlite']) test(`T3: a symlink at ~/${link} exits 1`, t => {
+ const f = fixture(t), target = path.join(f.home, 'real', link);
+ mkdirSync(path.dirname(target), { recursive: true });
+ cpSync(path.join(f.home, link), target, { recursive: true });
+ rmSync(path.join(f.home, link), { recursive: true }); symlinkSync(target, path.join(f.home, link));
+ const r = f.run(); assert.equal(r.status, 1); assert.match(r.stderr, new RegExp(`${link.replaceAll('.', '\\.')} is a symlink; use --no-t3`));
+});
+test('T3: with --t3-db, a symlinked file or directory exits 1', t => {
+ const f = t3Fixture(t); f.write();
+ const file = path.join(f.home, 'file-link.sqlite'); symlinkSync(f.db, file);
+ let r = f.run(['--t3-db', file]); assert.equal(r.status, 1); assert.match(r.stderr, /file-link.sqlite is a symlink/);
+ const dir = path.join(f.home, 'dir-link'); symlinkSync(path.dirname(f.db), dir);
+ r = f.run(['--t3-db', path.join(dir, 't3.sqlite')]); assert.equal(r.status, 1); assert.match(r.stderr, /dir-link is a symlink/);
+});
+
+// WAL states. The CLI reads with mode=ro; it may create -wal and -shm but must
+// never change the main file.
+const sha = file => execFileSync('sha256sum', [file], { encoding: 'utf8' }).split(' ')[0];
+const humans = r => JSON.parse(r.stdout).t3.seats.find(s => s.seat === 'alice').human;
+const asRoot = process.getuid?.() === 0;
+function killedWriter(db) {
+ // A writer that commits into the WAL and dies without a checkpoint.
+ const code = `const { DatabaseSync } = require('node:sqlite'); const db = new DatabaseSync(${JSON.stringify(db)});
+ db.exec('pragma wal_autocheckpoint=0');
+ db.prepare("insert into projection_thread_messages values ('late', 't-alice', 'user', 'late human', '2026-09-08T13:00:00Z')").run();
+ process.kill(process.pid, 'SIGKILL');`;
+ const r = spawnSync(process.execPath, ['-e', code]);
+ assert.equal(r.signal, 'SIGKILL');
+ rmSync(`${db}-shm`);
+}
+function inReadOnlyDir(dir, check) {
+ execFileSync('chmod', ['0555', dir]);
+ try { check(); } finally { execFileSync('chmod', ['0755', dir]); }
+}
+test('T3 WAL: the newest message only in -wal, writer attached, is counted', t => {
+ const f = t3Fixture(t), writer = f.write({ keepOpen: true });
+ t.after(() => writer.close());
+ writer.exec('pragma wal_autocheckpoint=0');
+ addMessage(writer, { thread: T1, text: 'newest', at: '2026-09-08T13:00:00Z' });
+ const main = sha(f.db);
+ const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
+ assert.equal(humans(r), 2); assert.equal(sha(f.db), main);
+});
+test('T3 WAL: stopped cleanly, counts are correct and the main file is unchanged', t => {
+ const f = t3Fixture(t); f.write();
+ assert.throws(() => readFileSync(`${f.db}-wal`));
+ const main = sha(f.db);
+ const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
+ assert.equal(humans(r), 1); assert.equal(sha(f.db), main);
+});
+test('T3 WAL: -wal without -shm in a writable directory is read', t => {
+ const f = t3Fixture(t); f.write(); killedWriter(f.db);
+ const main = sha(f.db);
+ const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
+ assert.equal(humans(r), 2); assert.equal(sha(f.db), main);
+});
+test('T3 WAL: -wal without -shm in a read-only directory exits 1', { skip: asRoot && 'mode bits do not bind root' }, t => {
+ const f = t3Fixture(t); f.write(); killedWriter(f.db);
+ inReadOnlyDir(path.dirname(f.db), () => {
+ const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, /cannot be read: .*\(SQLite 14\); use --no-t3/);
+ });
+});
+test('T3 WAL: stopped cleanly in a read-only directory exits 1', { skip: asRoot && 'mode bits do not bind root' }, t => {
+ const f = t3Fixture(t); f.write();
+ inReadOnlyDir(path.dirname(f.db), () => {
+ const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, /cannot be read: .*\(SQLite 1544\); use --no-t3/);
+ });
+});
+test('T3: a lock held past the 5 s busy timeout exits 1 and names --no-t3', t => {
+ const f = t3Fixture(t), writer = f.write({ keepOpen: true });
+ t.after(() => writer.close());
+ writer.exec('pragma locking_mode=exclusive'); writer.exec('begin exclusive');
+ addMessage(writer, { thread: T1, text: 'held' });
+ const started = Date.now(), r = f.run(['--t3-db', f.db]);
+ writer.exec('commit');
+ assert.equal(r.status, 1); assert.match(r.stderr, /cannot be read: .*\(SQLite 5\); use --no-t3/);
+ assert.ok(Date.now() - started >= 4500, 'the reader waited for the busy timeout');
+});
diff --git a/packages/ledger/src/t3.mjs b/packages/ledger/src/t3.mjs
new file mode 100644
index 00000000..9672fcc9
--- /dev/null
+++ b/packages/ledger/src/t3.mjs
@@ -0,0 +1,151 @@
+import { lstat } from 'node:fs/promises';
+import { DatabaseSync } from 'node:sqlite';
+import { pathToFileURL } from 'node:url';
+import os from 'node:os';
+import path from 'node:path';
+import { SourceError, UNKNOWN, clean, inRange, issueNumbers, messageKind, readSeats, t3Header } from './ledger.mjs';
+
+// T3 keeps every thread message in one SQLite database. This reader opens that
+// file read-only and nothing else in ~/.t3. See
+// docs/plans/2026-09-26_ledger-t3-source.md for the rules below.
+export const UNMAPPED = 't3:unmapped';
+const SKIP = 'use --no-t3 to skip T3';
+const REQUIRED = {
+ projection_projects: ['project_id', 'workspace_root', 'deleted_at'],
+ projection_threads: ['thread_id', 'project_id', 'title', 'archived_at', 'deleted_at'],
+ projection_thread_messages: ['message_id', 'thread_id', 'role', 'text', 'created_at'],
+};
+const DIAGNOSTIC = { orchestration_events: ['stream_id', 'event_type', 'payload_json', 'metadata_json'] };
+
+export const defaultT3Path = () => path.join(os.homedir(), '.t3', 'userdata', 'state.sqlite');
+
+// Every named path must exist and must not be a symlink. Skipping one would be
+// a silent zero, so each problem refuses the report.
+async function checkPaths(dbPath, isDefault) {
+ const dirs = isDefault ? [path.dirname(path.dirname(dbPath)), path.dirname(dbPath)] : [path.dirname(dbPath)];
+ for (const [target, wantDir] of [...dirs.map(d => [d, true]), [dbPath, false]]) {
+ let stat;
+ try { stat = await lstat(target); }
+ catch { throw new SourceError(`T3 database unavailable: ${target} is missing or unreadable; ${SKIP}`); }
+ if (stat.isSymbolicLink()) throw new SourceError(`T3 database refused: ${target} is a symlink; ${SKIP}`);
+ if (wantDir ? !stat.isDirectory() : !stat.isFile()) {
+ throw new SourceError(`T3 database refused: ${target} is not a ${wantDir ? 'directory' : 'regular file'}; ${SKIP}`);
+ }
+ }
+}
+
+function missingColumns(db, tables) {
+ const missing = [];
+ for (const [table, columns] of Object.entries(tables)) {
+ const have = new Set(db.prepare('select name from pragma_table_info(?)').all(table).map(r => r.name));
+ if (!have.size) missing.push(table);
+ else for (const column of columns) if (!have.has(column)) missing.push(`${table}.${column}`);
+ }
+ return missing;
+}
+
+// Seat for a thread title: the lower-cased title equals the seat or starts
+// with the seat and a space. Longest seat first, so the most specific wins.
+export function seatForTitle(title, seats) {
+ const lower = title.toLowerCase();
+ return [...seats].sort((a, b) => b.length - a.length).find(s => lower === s || lower.startsWith(`${s} `)) ?? null;
+}
+
+// Origin per message id from thread.message-sent events. Any missing table,
+// column or unparseable event makes the diagnostic unknown; it decides nothing.
+function origins(db, projectId) {
+ if (missingColumns(db, DIAGNOSTIC).length) return null;
+ const byMessage = new Map();
+ const events = db.prepare(`select e.payload_json, e.metadata_json from orchestration_events e
+ join projection_threads t on t.thread_id = e.stream_id
+ where e.event_type = 'thread.message-sent' and t.project_id = ?`).all(projectId);
+ for (const event of events) {
+ let payload, metadata;
+ try { payload = JSON.parse(event.payload_json); metadata = JSON.parse(event.metadata_json); }
+ catch { return null; }
+ if (typeof payload?.messageId !== 'string') return null;
+ byMessage.set(payload.messageId, typeof metadata?.origin?.appVersion === 'string');
+ }
+ return byMessage;
+}
+
+function query(db, root, range, seats) {
+ const missing = missingColumns(db, REQUIRED);
+ if (missing.length) throw new SourceError(`T3 schema changed: missing ${missing.join(', ')}`);
+ // Compared in JavaScript so a declared collation can't loosen the match.
+ const projects = db.prepare('select project_id, workspace_root from projection_projects where deleted_at is null').all()
+ .filter(p => p.workspace_root === root);
+ if (projects.length !== 1) {
+ throw new SourceError(`T3 has ${projects.length ? 'more than one project' : 'no project'} for ${root}; a project opened through a symlink does not match; ${SKIP}`);
+ }
+ const projectId = projects[0].project_id;
+ const threads = new Map(), excluded = { importedThreads: 0, deletedThreads: 0 };
+ for (const t of db.prepare('select thread_id, title, archived_at, deleted_at from projection_threads where project_id = ?').all(projectId)) {
+ if (typeof t.thread_id !== 'string' || typeof t.title !== 'string') throw new SourceError('T3 thread with a non-text id or title');
+ if (t.thread_id.startsWith('import:')) { excluded.importedThreads++; continue; }
+ if (t.deleted_at !== null) { excluded.deletedThreads++; continue; }
+ threads.set(t.thread_id, { id: t.thread_id, title: t.title, archived: t.archived_at !== null, seat: seatForTitle(t.title, seats) });
+ }
+ const rows = new Map([...seats, UNMAPPED].map(s => [s, { board: 0, agent: 0, human: 0 }]));
+ const mentions = new Map(), human = [];
+ const messages = db.prepare(`select m.message_id, m.thread_id, m.role, m.text, m.created_at from projection_thread_messages m
+ join projection_threads t on t.thread_id = m.thread_id where t.project_id = ?`).all(projectId);
+ for (const m of messages) {
+ const thread = threads.get(m.thread_id);
+ if (!thread) continue;
+ const where = `T3 message ${clean(m.message_id)} in thread ${clean(m.thread_id)}`;
+ if (m.role !== 'user' && m.role !== 'assistant') throw new SourceError(`${where} has an unknown role`);
+ if (typeof m.text !== 'string') throw new SourceError(`${where} has non-text content`);
+ if (typeof m.created_at !== 'string' || !Number.isFinite(Date.parse(m.created_at))) throw new SourceError(`${where} has an invalid created_at`);
+ if (m.role !== 'user') continue;
+ // A header addressed to its own thread must agree with the title mapping.
+ const header = t3Header(m.text);
+ if (header && header.toId === thread.id) {
+ const to = header.to.toLowerCase();
+ if (thread.seat ? to !== thread.seat : seats.includes(to)) {
+ throw new SourceError(`T3 header conflict: thread ${clean(thread.id)} "${clean(thread.title)}" maps to ${thread.seat ?? 'no seat'}, but a header addresses ${clean(header.to)}`);
+ }
+ }
+ if (!inRange(m.created_at, range)) continue;
+ const kind = messageKind(m.text), seat = thread.seat ?? UNMAPPED;
+ rows.get(seat)[kind]++;
+ if (kind === 'human') human.push(m.message_id);
+ for (const number of issueNumbers(m.text)) {
+ if (!mentions.has(number)) mentions.set(number, new Set());
+ mentions.get(number).add(seat);
+ }
+ }
+ const byMessage = origins(db, projectId);
+ const sentThroughApi = byMessage === null ? UNKNOWN : human.filter(id => byMessage.get(id) === false).length;
+ const noEvent = byMessage === null ? UNKNOWN : human.filter(id => !byMessage.has(id)).length;
+ const listed = seat => [...threads.values()].filter(t => (t.seat ?? UNMAPPED) === seat)
+ .sort((a, b) => a.id.localeCompare(b.id)).map(({ id, title, archived }) => ({ id, title, archived }));
+ const seatRows = seats.map(seat => ({ seat, ...rows.get(seat), threads: listed(seat) })).filter(r => r.threads.length);
+ return { rows, mentions, excluded, seats: seatRows, unmapped: { ...rows.get(UNMAPPED), threads: listed(UNMAPPED) },
+ diagnostic: { humanSentThroughApi: sentThroughApi, humanWithoutEvent: noEvent } };
+}
+
+// Reads one snapshot of T3's database. Returns the per-seat rows and issue
+// mentions the ledger merges with Pi, and the report's `t3` section.
+export async function readT3(root, range, { dbPath = defaultT3Path(), isDefault = true } = {}) {
+ dbPath = path.resolve(dbPath);
+ await checkPaths(dbPath, isDefault);
+ const seats = await readSeats(root);
+ const url = pathToFileURL(dbPath);
+ url.searchParams.set('mode', 'ro');
+ let db, result;
+ try {
+ db = new DatabaseSync(url, { readOnly: true, timeout: 5000 });
+ db.exec('BEGIN');
+ result = query(db, root, range, seats);
+ db.exec('COMMIT');
+ } catch (error) {
+ if (error instanceof SourceError) throw error;
+ throw new SourceError(`T3 database cannot be read: ${dbPath} (SQLite ${error.errcode ?? 'error'}); ${SKIP}`);
+ } finally {
+ try { if (db?.isTransaction) db.exec('ROLLBACK'); } catch { /* the close below still runs */ }
+ try { db?.close(); } catch { /* nothing was written */ }
+ }
+ const { rows, mentions, ...section } = result;
+ return { rows, mentions, section: { read: true, database: { path: dbPath, default: isDefault }, ...section } };
+}
@@ -0,0 +1,3 @@
5acbc1075a5d0ad709faf14698235c8c2332c408cb4fccc580bd4a75e2c314fb packages/ledger/src/t3.mjs
6546dbaf59c046d599a9d378c1a1f2d9afc9487189db06f50fa3201c9cadc63b packages/ledger/tests/ledger.test.mjs
101013def3b168ae1b7e291ff86200b49ed6b27927787585d5ca32a283bf38bd packages/ledger/README.md
@@ -0,0 +1,67 @@
# Gate F follow-up: Filbert's notes 1 to 3 (#1506), candidate for review
Darkwing, 2026-09-26. Filbert's build review
(`agents/filbert/work/ledger-t3-build-review-2026-09-26.md`, e47ec6da) left
four nonblocking notes on Gate F (136958c9). Sage asked for 1 to 3 as one small
change that Filbert reviews and Sage commits. Note 4, snapshot isolation, went
to DEFERRED (a68dc174). Base is HEAD a4d38a3d, which changes nothing under
`packages/ledger` since 136958c9. Nothing is committed or pushed.
`followup-manifest.sha256` pins the three files. `followup.patch` is the diff
against a4d38a3d.
## Changes
1. **U+2029.** The splitter test now writes a Pi entry holding a raw U+2028
and a raw U+2029, with CRLF endings, and asserts that the file contains
both. The README line names both characters. `ledger.mjs` is unchanged,
because the splitter already ends lines at `\n` only.
2. **Diagnostic.** Three new tests:
- A human message with no `thread.message-sent` event gives
`humanWithoutEvent: 1`.
- A `thread.message-sent` event whose payload doesn't parse makes both
diagnostic fields `unknown` and leaves `seats` unchanged.
- The same for an event whose `messageId` isn't a string. Filbert didn't
list this one, but it's the third `return null` in `origins()` and had
no test either.
3. **Rethrow.** `readT3`'s catch now rethrows anything that is not a
`SourceError` and carries no numeric `errcode`. The CLI prints such an
error as `Ledger failed: cannot read source evidence`, exit 1. That's the
CLI's existing message for a non-source error, and it no longer points
at SQLite or `--no-t3`. With only numeric errcodes left, the message's
`?? 'error'` fallback could no longer fire, so I removed it. The new test
calls `readT3` in process with an explicit fixture path and a `null`
range, so `inRange` throws a `TypeError` inside the read transaction. It
asserts the `TypeError` comes out. It never touches the real `~/.t3`, and
the fixture comment says so.
## Evidence
- Ledger tests: 51/51, the Gate F 47 plus 4 new.
- Mutations on a scratch copy of the package. The three `gitea-helper` tests
fail in every scratch copy, as before, so the counts leave them out:
| Mutation | Result |
|---|---|
| `humanWithoutEvent` hardcoded to 0 | 1 fails (no-event test) |
| unparseable event skipped (`continue`) | 1 fails (unparseable test) |
| non-string `messageId` skipped | 1 fails (messageId test) |
| rethrow removed (Gate F catch) | 1 fails (rethrow test) |
| splitter also splits at U+2028 | 1 fails (splitter test) |
| splitter also splits at U+2029 | 1 fails (splitter test) |
My first try at the last two put a raw U+2028 or U+2029 in the regex
source. That ends a JS regex literal, so the whole test file failed to
load, which doesn't count as a kill. I reran with the escape written out
literally, and the rows above come from that rerun.
- Eight suites on a local clone of a4d38a3d with the three files: config 24,
task 90, foundation 43, conductor 17, release 14, auth 15, discord 63,
extension-package 18. I ran them twice, and the second run was on the final
files after the errcode edit.
- Union on the same clone. Control-board, webui, seat, mosaic, ledger and
discord, plus conversation, which CHAT-02 committed: 474/474 twice before
the errcode edit and once after. No `ledger-*` temp directories remained.
- Live read, `--since 2026-09-01 --until 2026-09-26 --no-issues --json`, at
2026-09-26T21:47Z: exit 0, no header conflict, diagnostic
`{humanSentThroughApi: 15, humanWithoutEvent: 0}`, two imported threads
excluded. The Gate F build read 14; messages have been sent since then.
@@ -0,0 +1,99 @@
diff --git a/packages/ledger/README.md b/packages/ledger/README.md
index 393e9c37..3a2ce27c 100644
--- a/packages/ledger/README.md
+++ b/packages/ledger/README.md
@@ -39,7 +39,8 @@ 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.
+ 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).
diff --git a/packages/ledger/src/t3.mjs b/packages/ledger/src/t3.mjs
index 9672fcc9..fc9da14e 100644
--- a/packages/ledger/src/t3.mjs
+++ b/packages/ledger/src/t3.mjs
@@ -140,8 +140,10 @@ export async function readT3(root, range, { dbPath = defaultT3Path(), isDefault
result = query(db, root, range, seats);
db.exec('COMMIT');
} catch (error) {
- if (error instanceof SourceError) throw error;
- throw new SourceError(`T3 database cannot be read: ${dbPath} (SQLite ${error.errcode ?? 'error'}); ${SKIP}`);
+ // Only a SQLite failure carries an errcode. Anything else is a bug and
+ // surfaces as itself, not as a database problem.
+ if (error instanceof SourceError || typeof error?.errcode !== 'number') throw error;
+ throw new SourceError(`T3 database cannot be read: ${dbPath} (SQLite ${error.errcode}); ${SKIP}`);
} finally {
try { if (db?.isTransaction) db.exec('ROLLBACK'); } catch { /* the close below still runs */ }
try { db?.close(); } catch { /* nothing was written */ }
diff --git a/packages/ledger/tests/ledger.test.mjs b/packages/ledger/tests/ledger.test.mjs
index 8e60b96b..834dbd55 100644
--- a/packages/ledger/tests/ledger.test.mjs
+++ b/packages/ledger/tests/ledger.test.mjs
@@ -7,6 +7,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync, spawnSync } from 'node:child_process';
import { dateRange, messageKind, issueNumbers, totalsLine, summarize } from '../src/ledger.mjs';
+import { readT3 } from '../src/t3.mjs';
const source = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src');
const range = dateRange('2026-09-06', '2026-09-12');
@@ -49,7 +50,8 @@ const fixtureIssues = [
function fixture(t) {
const root = mkdtempSync(path.join(os.tmpdir(), 'ledger-test-'));
// No test opens the real ~/.t3: every CLI run gets this HOME, with an empty
- // T3 database at the default path. The CLI's root is a realpath.
+ // T3 database at the default path. The one in-process readT3 call passes an
+ // explicit fixture path. The CLI's root is a realpath.
const home = mkdtempSync(path.join(os.tmpdir(), 'ledger-home-'));
t.after(() => { rmSync(root, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); });
const defaultDb = path.join(home, '.t3/userdata/state.sqlite');
@@ -134,10 +136,11 @@ test('partial or malformed session log refuses with location, not content', t =>
const f = fixture(t); f.put('.pi/state/alice/sessions/bad.jsonl', '{sensitive'); const r = f.run();
assert.equal(r.status, 1); assert.match(r.stderr, /Malformed session JSON: alice\/bad.jsonl:1/); assert.doesNotMatch(r.stderr, /sensitive/);
});
-test('a U+2028 inside a session string is one line, not a malformed record', t => {
+test('a U+2028 or U+2029 inside a session string is one line, not a malformed record', t => {
const f = fixture(t);
- f.put('.pi/state/bob/sessions/sep.jsonl', [f.entry('Jason: one\u2028two #2'), f.entry('[h:alice -> h:bob] ok')].map(x => JSON.stringify(x)).join('\r\n') + '\r\n');
- assert.ok(readFileSync(path.join(f.root, '.pi/state/bob/sessions/sep.jsonl'), 'utf8').includes('\u2028'));
+ f.put('.pi/state/bob/sessions/sep.jsonl', [f.entry('Jason: one\u2028two\u2029three #2'), f.entry('[h:alice -> h:bob] ok')].map(x => JSON.stringify(x)).join('\r\n') + '\r\n');
+ const written = readFileSync(path.join(f.root, '.pi/state/bob/sessions/sep.jsonl'), 'utf8');
+ assert.ok(written.includes('\u2028') && written.includes('\u2029'));
const r = f.run(['--json']); assert.equal(r.status, 0, r.stderr);
assert.deepEqual(JSON.parse(r.stdout).seats[1], { seat: 'bob', board: 0, agent: 1, human: 1 });
});
@@ -334,6 +337,30 @@ test('T3: a missing orchestration_events makes the diagnostic unknown and keeps
assert.deepEqual(after.t3.diagnostic, { humanSentThroughApi: 'unknown', humanWithoutEvent: 'unknown' });
assert.deepEqual(after.seats, before.seats); assert.deepEqual(after.totals, before.totals);
});
+test('T3: a human message with no event counts in humanWithoutEvent', t => {
+ const f = t3Fixture(t);
+ f.write({ messages: [...f.messages, { thread: T1, text: 'typed, no event', origin: 'none' }] });
+ const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
+ assert.deepEqual(JSON.parse(r.stdout).t3.diagnostic, { humanSentThroughApi: 1, humanWithoutEvent: 1 });
+});
+const badEvent = payload => `insert into orchestration_events (stream_id, event_type, payload_json, metadata_json) values ('${T1}', 'thread.message-sent', '${payload}', '{}')`;
+for (const [name, payload] of [['an unparseable event', '{bad'], ['an event with no string messageId', '{"messageId":7}']]) {
+ test(`T3: ${name} makes the diagnostic unknown and keeps the counts`, t => {
+ const f = t3Fixture(t); f.write();
+ const before = JSON.parse(f.run(['--json', '--t3-db', f.db]).stdout);
+ f.write({ after: [badEvent(payload)] });
+ const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
+ const after = JSON.parse(r.stdout);
+ assert.deepEqual(after.t3.diagnostic, { humanSentThroughApi: 'unknown', humanWithoutEvent: 'unknown' });
+ assert.deepEqual(after.seats, before.seats);
+ });
+}
+test('T3: an error that is not from SQLite is rethrown, not reported as a database failure', async t => {
+ const f = t3Fixture(t); f.write();
+ // In process with an explicit path, so the real ~/.t3 stays closed. A null
+ // range makes inRange throw a TypeError inside the read transaction.
+ await assert.rejects(readT3(f.real, null, { dbPath: f.db, isDefault: false }), TypeError);
+});
for (const link of ['.t3', '.t3/userdata', '.t3/userdata/state.sqlite']) test(`T3: a symlink at ~/${link} exits 1`, t => {
const f = fixture(t), target = path.join(f.home, 'real', link);
mkdirSync(path.dirname(target), { recursive: true });
@@ -0,0 +1,5 @@
08959a05574264e4f8243a90af94746e73a2fde3706f22e38f4ff1105b7a45a8 agents/darkwing/work/ledger-t3-source/r1.md
e8300cb6abea70819aba7cf10040d19b4d6019b5663c37203209537a5f10ee62 agents/darkwing/work/ledger-t3-source/r2.md
f3c05c1b4d28a419ab817621e15708b147f71dff588664980e22696eaafbc342 docs/plans/2026-09-26_ledger-t3-source.md
aa4740ae5d045aa12971af5de36a5107805bd31da839aae4e4d398a818d471fc agents/darkwing/work/ledger-t3-source/r1-to-r2.diff
4128375121673e0a49ef6789390503ff7395ba59f87bfc450764ea56b536c5b2 agents/darkwing/work/ledger-t3-source/r2-to-r3.diff
@@ -0,0 +1,419 @@
--- r1.md
+++ docs/plans/2026-09-26_ledger-t3-source.md
@@ -1,8 +1,11 @@
# Ledger: a read-only T3 thread source for Table 2 (Gate F brief)
-Brief only, no code. Darkwing wrote it on 2026-09-26 at Sage's request. Filbert
-reviews it, and Jason sees it on the decision sheet before anyone builds it.
-Issue #1506.
+Brief only, no code. Darkwing wrote it on 2026-09-26 at Sage's request, issue
+#1506. R1 (sha256 08959a05) went to Filbert, whose review asked for
+revisions: `agents/filbert/work/ledger-t3-source-review-2026-09-26.md`, sha256
+19dda29a. This is R2. It takes every finding, and it records Sage's rulings
+on the three open questions. Section 1 has one measurement that differs from
+the review.
## Why
@@ -18,8 +21,8 @@
## Where T3 keeps messages
T3 keeps its state in one SQLite database, `~/.t3/userdata/state.sqlite`, in
-WAL mode (`state.sqlite-wal` and `state.sqlite-shm` sit beside it). Three
-projection tables are enough:
+WAL mode (`state.sqlite-wal` and `state.sqlite-shm` sit beside it). The counts
+need three projection tables:
- `projection_projects`: `project_id`, `workspace_root`, `deleted_at`.
- `projection_threads`: `thread_id`, `project_id`, `title`, `archived_at`,
@@ -27,51 +30,72 @@
- `projection_thread_messages`: `message_id` (primary key), `thread_id`,
`role` (`user` or `assistant`), `text`, `created_at` (ISO UTC).
-One more table is optional. In `orchestration_events`, each
+The JSON diagnostic reads one more. In `orchestration_events`, each
`thread.message-sent` event carries `metadata_json.origin`. Messages typed in
the T3 app carry an `appVersion` there. Messages sent through T3's API or MCP
-tools, which is how seats talk to each other, don't. See the cross-check below.
+tools, which is how seats talk to each other, don't.
The same directory also holds `secrets/`, `clerk-tokens.json` and other
settings files. The reader opens `state.sqlite` and nothing else, and it
selects named columns only, never `*`.
-## Reading it, with T3 running or not
+## 1. Reading it, with T3 running or not
-The file stays on disk whether T3 runs or not. The reader opens it with Node's
-built-in `node:sqlite` (`DatabaseSync`, `file:<path>?mode=ro`, `readOnly:
-true`). That needs no dependency, and Node 26.8.1 prints no warning for it. I
-read the live database this way today, while T3 was running, with no errors
-and no locks. A WAL reader sees every committed message, including those still
-in the `-wal` file.
-
-Two rules:
-- Never open with `immutable=1` and never copy the file. Both skip the WAL
- and silently lose the newest messages. A copy of the three files is also
- not atomic.
-- If T3 stopped uncleanly and left a `-wal` without its `-shm`, a read-only
- connection may be unable to rebuild the index. If the open fails, the
- ledger reports it and refuses. I have not tested this case or the fully
- stopped case. Both are acceptance checks below.
+The reader uses Node's built-in `node:sqlite` (`DatabaseSync`). That needs no
+dependency, and Node 26.8.1 (SQLite 3.53.4) prints no warning for it.
-## Jason or agent
+- **URI.** Build it with `pathToFileURL(dbPath)` and set `mode=ro` through
+ `searchParams`, then pass `readOnly: true`. A `?`, `#` or `%` in the home
+ path would break a string-built URI.
+- **One snapshot.** Run every query, from the schema checks through the
+ diagnostic, inside one `BEGIN` … `COMMIT`. In autocommit mode each
+ statement sees its own snapshot while T3 writes between them.
+- **Busy timeout.** Set `DatabaseSync`'s `timeout` to 5 s. A transient
+ `SQLITE_BUSY` during a T3 checkpoint then waits instead of failing. A busy
+ error after the timeout exits 1 like any open failure.
+- **No `immutable=1` and no copy.** Both lose the WAL. Filbert found worse
+ than lost messages: with a table created inside the WAL, `immutable=1`
+ fails with `no such table`.
+
+What happens on disk. Filbert and I both tested these in scratch
+directories:
+
+| State | Directory writable | Result |
+|---|---|---|
+| T3 running, writer attached, newest rows only in `-wal` | yes | reads them |
+| `-wal` without `-shm` (writer killed, `-shm` removed) | yes | reads the WAL rows and creates `-shm` |
+| `-wal` without `-shm` | no | open fails, SQLite 14 |
+| T3 stopped cleanly, no `-wal` or `-shm` | yes | reads, then leaves an empty `-wal` and a 32 KiB `-shm` |
+| T3 stopped cleanly | no | fails, SQLite 1544 "attempt to write a readonly database" |
+
+In every case the main file's bytes stayed the same. The last row is where
+Filbert and I differ. His review says the stopped-case read works with the
+directory read-only. In my run it failed with and without the read
+transaction. The build's test settles it. Either way a failed open is exit 1.
+
+So the accurate claim: the reader never writes the main database file. Like
+any SQLite connection, it may create or update `-wal` and `-shm` beside it
+and takes read locks in `-shm`. T3 opens normally afterwards.
+
+## 2. Jason or agent
Reuse the 6a rule. The first line of `text` decides: a T3 header or the tmux
preamble counts as agent, `control-board` as the sender counts as board, and
anything else counts as human. Messages with role `user` count; assistant
messages don't.
-6a has a defect this source would expose. Its regex allows only a lowercase
-class (`class=[a-z-]+`). Seats send uppercase classes: Sage's DECISION, INFO,
-REVIEW-REQUEST and REVIEW-NOTE, and my own REVIEW-REQUEST. In this project's
-threads, 16 real agent headers fail on that alone and would count as human.
-The fix is to make the class match case-insensitive. It belongs in this build
-or just before it, reviewed with it. The ms-communications table lists
-lowercase names, so the fix follows what seats send, not the table.
+The class fix rides in this build (Sage's ruling). HEAD's
+`packages/ledger/src/ledger.mjs:81` (tmux) and `:83` (T3) both allow only
+`class=[a-z-]+`. Both become case-insensitive. Seats send uppercase classes:
+Sage's DECISION, INFO, REVIEW-REQUEST and REVIEW-NOTE, and my own
+REVIEW-REQUEST. In this project's threads 16 real agent headers failed on
+that alone at 20:54Z. The ms-communications table lists lowercase names, so
+the fix follows what seats send, not the table.
Cross-check, read at 2026-09-26T20:54Z for the mosaic-stack project (209
user messages outside imported and deleted threads, every one with its
-`thread.message-sent` event):
+`thread.message-sent` event). Filbert's later read agreed, plus messages sent
+since.
| T3 origin | Header matches 6a | Count |
|---|---|---|
@@ -83,110 +107,193 @@
No message typed in the app carries a header, and every API message in this
project carries one of the three forms. The 14 free-text ones are older
Discord Bot thread headers such as `[from: SetSpark coordinator (…) -> to:
-Discord Bot (…)]`, written before the guide fixed the format. With the class
-fix they still count as human. That's 14 wrong human counts, all dated
-2026-09-17 to 2026-09-22.
-
-Recommendation: the header rule decides, as Sage asked. The reader also
-reports one diagnostic number, not used in any table: user messages the rule
-calls human that T3 recorded as sent through the API. That count is how the
-uppercase-class bug showed up, and it would catch the next format drift. The
-origin field is T3's internal metadata, not a documented contract, so it
-shouldn't decide anything. I'd make it JSON only, so Table 2's layout stays
-the same.
-
-## Thread to seat
-
-A thread counts for this checkout only if its project's `workspace_root` is
-the ledger's repository root. That is `/mnt/storage/src/mosaic-stack`, project
-`34050c07`.
-
-Thread IDs change whenever Jason starts a new thread for a seat, so there's no
-fixed map. T3-AGENT-COMMS.md already names threads after the seat ("Darkwing",
-"Sage", "Dewey in Claude"). Proposed rule: a thread belongs to seat `<s>` when
-`<s>` is a real directory under `agents/` and the lower-cased title equals
-`<s>` or starts with `<s>` followed by a space. Several threads can map to one
-seat. Their counts add up, as several Pi session files already do.
+Discord Bot (…)]`, written before the guide fixed the format. Sage ruled they
+stay as recorded: they count as human, dated 2026-09-17 to 2026-09-22.
+
+The header rule decides. The JSON also carries one diagnostic that feeds no
+table or total: user messages the rule calls human that T3 recorded as sent
+through the API. That number exposed the class bug and would catch the next
+format drift. `origin` is T3's internal metadata, not a documented contract,
+so it decides nothing. If `orchestration_events` or a column it needs is
+missing, the diagnostic reads `unknown` and the report goes on (Sage's
+ruling on F5). Missing tables the counts depend on still exit 1.
+
+## 3. Thread to seat
+
+**Project.** A thread counts for this checkout only if its project's
+`workspace_root` equals the ledger's repository root, byte for byte. The CLI
+already takes that root from the realpath of its own URL, today
+`/mnt/storage/src/mosaic-stack`, project `34050c07`. So a T3 project opened
+through the compatibility symlink `~/src/mosaic-stack-dev-test` doesn't
+match, and "no project row" is the right refusal. The README says so.
+
+**Title rule.** Thread IDs change whenever Jason starts a new thread for a
+seat, so there's no fixed map. T3-AGENT-COMMS.md already names threads after
+the seat ("Darkwing", "Sage", "Dewey in Claude"). A thread belongs to seat
+`<s>` when `<s>` is a real directory under `agents/` and the lower-cased
+title equals `<s>` or starts with `<s>` followed by a space. So "Sagebrush"
+stays unmapped. Several threads can map to one seat, and their counts add
+up, as several Pi session files already do.
Today that maps Sage, Darkwing, Filbert, Dewey and Rocko (one thread each,
-created 2026-09-26), plus "Darkwing in Claude" (archived) and "Dewey in
-Claude". Three threads map to no seat. Two are imported and excluded anyway
-("FINDINGS.md review" and "[dragon-lin:darkwing -> …"). The third is
-"Discord Bot" with 68 user messages: 54 without a header, and the 14
-free-text headers above. The guide's own advice, titles like `review:
-<topic>`, will produce more unmapped threads.
-
-Unmapped threads go in one Table 2 row, `t3:unmapped`, so Jason's messages
-there still count toward the Human column and the human-per-closed ratio. The
-other choice is to drop them, which would hide those 54 headerless prompts.
-That is Jason's decision. I recommend the row.
-
-A seat's row sums its Pi and T3 counts. JSON splits them by source. Nothing is
-counted twice: every T3 session today runs on `claudeAgent` or `codex`, which
-don't write `.pi/state`, and Filbert found no T3 header in any Pi log.
+created 2026-09-26, titles set by hand), plus "Darkwing in Claude" (archived)
+and "Dewey in Claude". Researcher has a directory and no thread. Three
+threads map to no seat. Two are imported and excluded anyway ("FINDINGS.md
+review" and "[dragon-lin:darkwing -> …"). The third is "Discord Bot" with 68
+user messages: 54 without a header, and the 14 free-text headers.
+
+Titles are current state, and T3 can write them itself. They go wrong three
+ways. T3 auto-titles an unnamed thread from Jason's first prompt, so "Rocko
+review of the plan" maps to rocko. A rename moves the whole history to
+another row. A seat thread titled for a topic drops into `t3:unmapped`.
+None of this changes the Human total or the human-per-closed ratio. It only
+moves counts between rows, but Gate F reads one seat's row.
+
+**Header cross-check.** The headers already say which seat a thread belongs
+to. For every user message whose header matches the fixed 6a rule and whose
+`to:` id equals the message's own `thread_id`:
+- in a mapped thread, the `to:` role, lower-cased, must equal that thread's
+ seat;
+- in an unmapped thread, the `to:` role must not be a seat name.
+
+A conflict exits 1 and names the thread id, its title and both roles. A
+header whose `to:` id is some other thread is not checked. The check reads
+message text only, not T3 metadata. In a live read at 21:02Z every header
+agreed: all 104 addressed to their own thread carried the full thread id and
+named that thread's seat (Sage 40, Darkwing 15, Filbert 18, Dewey 15, Rocko
+16).
+
+It catches a seat thread renamed to another seat or to a topic, once any
+agent writes to it. It also catches an auto-titled thread that agents
+address by a different seat. It misses a thread no agent ever writes to.
+Such a thread can only add human counts to a seat's row, never hide them, so
+for Gate F it errs toward a visible failure. The README says so.
+
+**Unmapped row.** Unmapped threads go in one Table 2 row, `t3:unmapped`
+(Sage's ruling), so their human messages still reach the Human column and
+the human-per-closed ratio.
+
+**Mapping in the JSON.** For each seat, the T3 thread ids and titles that
+made its row, and the unmapped thread ids and titles. Anyone checking a Gate
+F result can then see which threads the row came from.
+
+A seat's row sums its Pi and T3 counts, and the JSON splits them by source.
+Nothing is counted twice. Every T3 session today runs on `claudeAgent` or
+`codex`, which don't write `.pi/state`, and Filbert found no T3 header in any
+Pi log (6a record).
-Excluded, with the reason stated in the README:
+**Excluded,** with the reason stated in the README:
- Imported threads (`thread_id` starting `import:`, events marked
`historyImport`). They are partial copies of Claude Code sessions, not T3
traffic: 55 user messages in two threads here.
- Deleted threads (`deleted_at` set). Across all projects there are 3, with
3 messages. Archived threads count.
-
-## What fails closed
-
-With the T3 source on, each of these refuses the report with exit 1, the
-code the ledger already uses for unreadable session evidence. The report
-never falls back to Pi logs alone. As with `--no-issues`, `--no-t3` turns the
-source off, and the report then says T3 was not read.
-- The database is missing, unreadable, or won't open read-only (including
- the `-wal` without `-shm` case). This differs from the Pi reader, which
- treats a missing `.pi` as no messages. A missing Pi directory means no Pi
- seats ran here. A missing T3 database on this host means the path or T3
- changed, and a silent zero is the failure Gate F exists to prevent.
-- A required table or column is missing. The reader checks `PRAGMA
+- Threads in other T3 projects. Live, there is a project at `/home/jwoltje`
+ and a deleted one at `/mnt/storage/src`. A thread in either could work on
+ this repository and would not be counted. The workspace-root rule is still
+ the right one, but the README names this blind spot.
+
+## 4. What fails closed
+
+The source is on by default (Sage's ruling). `--no-t3` turns it off, and the
+report then says T3 was not read. `--t3-db <path>` reads another database
+file instead of `~/.t3/userdata/state.sqlite`. It exists for fixtures and
+gets the same checks.
+
+Each of these refuses the report with exit 1, the code the ledger already
+uses for unreadable session evidence. The report never falls back to Pi logs
+alone. Where the database is missing or won't open, the message names
+`--no-t3`.
+- The database is missing or unreadable, or won't open read-only. That
+ includes a directory that isn't writable when SQLite needs to create
+ `-shm`, and a busy error after the timeout. The Pi reader treats a missing
+ `.pi` as no messages, and this departs from it on purpose. A missing Pi
+ directory means no Pi seats ran here. A missing T3 database on this host
+ means the path or T3 changed, and a silent zero is the failure Gate F
+ exists to prevent.
+- `~/.t3`, `~/.t3/userdata` or `state.sqlite` is a symlink. With `--t3-db`,
+ the file and its directory are checked. The Pi reader checks every
+ ancestor too, but it skips symlinked entries. Skipping one named file
+ would be another silent zero, so this reader refuses.
+- A table or column the counts need is missing. The reader checks `PRAGMA
table_info` and names what's missing. This catches a T3 upgrade that
changes the schema.
- No project row, or more than one non-deleted row, for this repository root.
- A counted row has a bad `role`, non-string `text`, or a `created_at` that
doesn't parse. The Pi reader already refuses malformed JSONL and bad
timestamps the same way.
-- `state.sqlite` or `~/.t3/userdata` is a symlink. The Pi reader skips
- symlinked entries instead. For one named file, skipping would be another
- silent zero, so this reader refuses.
-
-The source never writes to the database. It never reads other files in
-`~/.t3`, and it passes no message text beyond `messageKind` and
-`issueNumbers`, the same rule as for Pi logs. The one outside effect is
-SQLite's own: a WAL reader takes read locks in the `-shm` file, as T3's own
-connections do.
-
-## Decisions for Jason
-
-1. The source is on by default, with `--no-t3` to turn it off. The other
- choice is off by default with `--t3` to turn it on. I recommend on by
- default, because Gate F exists to count these messages.
-2. Unmapped threads get a `t3:unmapped` row. The other choice is to drop
- them. I recommend the row.
-3. The 14 free-text headers from 09-17 to 09-22 stay counted as human. Fixing
- them would mean loosening the header grammar for history only, and I don't
- recommend it.
-
-## Acceptance for the build
-
-- Fixture databases built with `node:sqlite` in a temp dir, in WAL mode:
- seat threads and an unmapped thread; imported, deleted and archived
- threads; all three header forms, uppercase classes included; a message
- outside the date range; another project with the same seat titles.
-- Each fail-closed case above has its own test, including a schema column
- removed and `-wal` without `-shm`. One test opens a database whose newest
- message is still in the WAL and counts it. Another reads a database closed
- cleanly with no writer attached, which is the T3-stopped case.
-- The class fix is proven against HEAD's `messageKind`: an uppercase class
- counts as agent after the fix and as human before it.
+- A header conflicts with the title mapping (section 3).
+
+The reader never reads other files in `~/.t3`. It passes no message text
+beyond `messageKind`, `issueNumbers` and the header's `to:` role and id, the
+same rule as for Pi logs.
+
+## 5. Rulings
+
+Sage ruled on the three questions R1 put to Jason, as lead calls:
+1. The source is on by default. A missing or unreadable database exits 1,
+ and the message names `--no-t3`.
+2. Unmapped threads get the `t3:unmapped` row.
+3. The 14 free-text headers stay as recorded. They show only in the JSON
+ diagnostic.
+
+Sage also ruled that the class fix rides in this build, and that a missing
+diagnostic table reads `unknown` (F5).
+
+## 6. Acceptance for the build
+
+**No test opens the real `~/.t3`.** Both places in
+`packages/ledger/tests/ledger.test.mjs` that spawn `cli.mjs` (the shared
+`run()` helper and the direct `spawnSync` at line 66) set `HOME` to the
+fixture's temp directory. A test that forgets `--t3-db` or `--no-t3` then
+finds no database and fails closed. The existing tests aren't about T3. Each
+gets an empty fixture database at the fixture `HOME`'s default path, with
+one project row for the fixture root. So they run with the source on, and
+their expected rows don't change. One test asserts that a `HOME` with no
+database exits 1 and names `--no-t3`.
+
+Fixture databases are built with `node:sqlite` in a temp directory, in WAL
+mode:
+- seat threads and an unmapped thread; imported, deleted and archived
+ threads; a message outside the date range;
+- all three header forms, with uppercase classes in both the tmux preamble
+ and the T3 header;
+- a thread with the same seat title in another project;
+- a seat thread renamed to another seat, with an agent header to its own
+ id, which exits 1;
+- a "Sagebrush" title, which stays unmapped;
+- a thread titled "Researcher", which maps to the seat that has no thread
+ live.
+
+WAL states, each with its own test:
+- The newest message is only in `-wal`, with the writer still attached (the
+ live-T3 case). It is counted.
+- T3 stopped: the database closed cleanly with no writer. Counts are
+ correct, and the main file's bytes are unchanged afterwards.
+- `-wal` without `-shm` in a writable directory: made by a child writer with
+ `wal_autocheckpoint=0` that is SIGKILLed, then `-shm` deleted. The WAL
+ rows are counted.
+- `-wal` without `-shm` in a directory that isn't writable: exit 1, naming
+ `--no-t3`. Skipped when the tests run as root, where the mode bits don't
+ bind.
+- The stopped case in a directory that isn't writable: exit 1, naming
+ `--no-t3`, as I measured it. If the build reads there instead, the builder
+ changes this test to assert correct counts and records the correction in
+ the BUILD-LOG entry. Skipped as root too.
+
+Also:
+- Each other fail-closed case in section 4 has its own test, including a
+ removed schema column and each symlink.
+- A missing `orchestration_events` gives `unknown` for the diagnostic and
+ the same counts.
+- The class fix is proven against HEAD's `messageKind`. An uppercase class
+ in either preamble counts as agent after the fix and as human before it.
+- The JSON lists each seat's threads and the unmapped threads.
- A read against the live database gives the counts in this brief, allowing
- for messages sent since.
-- The ledger README's counting rules name the new source, the mapping rule
- and the exclusions.
+ for messages sent since. It exits 0 with no header conflict.
+- The ledger README's counting rules name the new source, both flags, the
+ mapping rule and the header check, and the exclusions. That includes the
+ symlinked-checkout case and the other-project blind spot.
- No suite runs the ledger tests, so the BUILD-LOG entry names the test file.
## Not in scope
@@ -194,5 +301,6 @@
- Claude Code transcripts (`~/.claude/projects`) and Codex sessions
(`~/.codex/sessions`). T3's database already holds every message T3
delivered, so those files would only duplicate it.
-- Any write to T3, any T3 API call, or anything that needs T3 running.
+- Any T3 API call, anything that needs T3 running, and any write beyond
+ SQLite's own `-wal` and `-shm` handling.
- Fixing the two tmux misclassifications Filbert found in 6a.
+198
View File
@@ -0,0 +1,198 @@
# Ledger: a read-only T3 thread source for Table 2 (Gate F brief)
Brief only, no code. Darkwing wrote it on 2026-09-26 at Sage's request. Filbert
reviews it, and Jason sees it on the decision sheet before anyone builds it.
Issue #1506.
## Why
Table 2 counts user messages per seat from `.pi/state/<seat>/sessions/*.jsonl`
only. Development seats now run in T3 on the Claude and Codex harnesses, so
their prompts, Jason's included, never reach a Pi log. Today the Human column
can't see T3 at all, and the zero it shows for T3 seats means "no source", not
"no human prompts". 6a (ef0020ad) taught `messageKind` the T3 header, but no
source the ledger reads contains one. Gate F (QUEUE row 6) passes when
Filbert's item closes with zero human messages from Jason. While the ledger
can't see T3, a zero there proves nothing.
## Where T3 keeps messages
T3 keeps its state in one SQLite database, `~/.t3/userdata/state.sqlite`, in
WAL mode (`state.sqlite-wal` and `state.sqlite-shm` sit beside it). Three
projection tables are enough:
- `projection_projects`: `project_id`, `workspace_root`, `deleted_at`.
- `projection_threads`: `thread_id`, `project_id`, `title`, `archived_at`,
`deleted_at`.
- `projection_thread_messages`: `message_id` (primary key), `thread_id`,
`role` (`user` or `assistant`), `text`, `created_at` (ISO UTC).
One more table is optional. In `orchestration_events`, each
`thread.message-sent` event carries `metadata_json.origin`. Messages typed in
the T3 app carry an `appVersion` there. Messages sent through T3's API or MCP
tools, which is how seats talk to each other, don't. See the cross-check below.
The same directory also holds `secrets/`, `clerk-tokens.json` and other
settings files. The reader opens `state.sqlite` and nothing else, and it
selects named columns only, never `*`.
## Reading it, with T3 running or not
The file stays on disk whether T3 runs or not. The reader opens it with Node's
built-in `node:sqlite` (`DatabaseSync`, `file:<path>?mode=ro`, `readOnly:
true`). That needs no dependency, and Node 26.8.1 prints no warning for it. I
read the live database this way today, while T3 was running, with no errors
and no locks. A WAL reader sees every committed message, including those still
in the `-wal` file.
Two rules:
- Never open with `immutable=1` and never copy the file. Both skip the WAL
and silently lose the newest messages. A copy of the three files is also
not atomic.
- If T3 stopped uncleanly and left a `-wal` without its `-shm`, a read-only
connection may be unable to rebuild the index. If the open fails, the
ledger reports it and refuses. I have not tested this case or the fully
stopped case. Both are acceptance checks below.
## Jason or agent
Reuse the 6a rule. The first line of `text` decides: a T3 header or the tmux
preamble counts as agent, `control-board` as the sender counts as board, and
anything else counts as human. Messages with role `user` count; assistant
messages don't.
6a has a defect this source would expose. Its regex allows only a lowercase
class (`class=[a-z-]+`). Seats send uppercase classes: Sage's DECISION, INFO,
REVIEW-REQUEST and REVIEW-NOTE, and my own REVIEW-REQUEST. In this project's
threads, 16 real agent headers fail on that alone and would count as human.
The fix is to make the class match case-insensitive. It belongs in this build
or just before it, reviewed with it. The ms-communications table lists
lowercase names, so the fix follows what seats send, not the table.
Cross-check, read at 2026-09-26T20:54Z for the mosaic-stack project (209
user messages outside imported and deleted threads, every one with its
`thread.message-sent` event):
| T3 origin | Header matches 6a | Count |
|---|---|---|
| typed in the app (has `appVersion`) | no | 99 |
| sent through the API (no `appVersion`) | yes | 80 |
| sent through the API | no, uppercase class | 16 |
| sent through the API | no, free-text roles | 14 |
No message typed in the app carries a header, and every API message in this
project carries one of the three forms. The 14 free-text ones are older
Discord Bot thread headers such as `[from: SetSpark coordinator (…) -> to:
Discord Bot (…)]`, written before the guide fixed the format. With the class
fix they still count as human. That's 14 wrong human counts, all dated
2026-09-17 to 2026-09-22.
Recommendation: the header rule decides, as Sage asked. The reader also
reports one diagnostic number, not used in any table: user messages the rule
calls human that T3 recorded as sent through the API. That count is how the
uppercase-class bug showed up, and it would catch the next format drift. The
origin field is T3's internal metadata, not a documented contract, so it
shouldn't decide anything. I'd make it JSON only, so Table 2's layout stays
the same.
## Thread to seat
A thread counts for this checkout only if its project's `workspace_root` is
the ledger's repository root. That is `/mnt/storage/src/mosaic-stack`, project
`34050c07`.
Thread IDs change whenever Jason starts a new thread for a seat, so there's no
fixed map. T3-AGENT-COMMS.md already names threads after the seat ("Darkwing",
"Sage", "Dewey in Claude"). Proposed rule: a thread belongs to seat `<s>` when
`<s>` is a real directory under `agents/` and the lower-cased title equals
`<s>` or starts with `<s>` followed by a space. Several threads can map to one
seat. Their counts add up, as several Pi session files already do.
Today that maps Sage, Darkwing, Filbert, Dewey and Rocko (one thread each,
created 2026-09-26), plus "Darkwing in Claude" (archived) and "Dewey in
Claude". Three threads map to no seat. Two are imported and excluded anyway
("FINDINGS.md review" and "[dragon-lin:darkwing -> …"). The third is
"Discord Bot" with 68 user messages: 54 without a header, and the 14
free-text headers above. The guide's own advice, titles like `review:
<topic>`, will produce more unmapped threads.
Unmapped threads go in one Table 2 row, `t3:unmapped`, so Jason's messages
there still count toward the Human column and the human-per-closed ratio. The
other choice is to drop them, which would hide those 54 headerless prompts.
That is Jason's decision. I recommend the row.
A seat's row sums its Pi and T3 counts. JSON splits them by source. Nothing is
counted twice: every T3 session today runs on `claudeAgent` or `codex`, which
don't write `.pi/state`, and Filbert found no T3 header in any Pi log.
Excluded, with the reason stated in the README:
- Imported threads (`thread_id` starting `import:`, events marked
`historyImport`). They are partial copies of Claude Code sessions, not T3
traffic: 55 user messages in two threads here.
- Deleted threads (`deleted_at` set). Across all projects there are 3, with
3 messages. Archived threads count.
## What fails closed
With the T3 source on, each of these refuses the report with exit 1, the
code the ledger already uses for unreadable session evidence. The report
never falls back to Pi logs alone. As with `--no-issues`, `--no-t3` turns the
source off, and the report then says T3 was not read.
- The database is missing, unreadable, or won't open read-only (including
the `-wal` without `-shm` case). This differs from the Pi reader, which
treats a missing `.pi` as no messages. A missing Pi directory means no Pi
seats ran here. A missing T3 database on this host means the path or T3
changed, and a silent zero is the failure Gate F exists to prevent.
- A required table or column is missing. The reader checks `PRAGMA
table_info` and names what's missing. This catches a T3 upgrade that
changes the schema.
- No project row, or more than one non-deleted row, for this repository root.
- A counted row has a bad `role`, non-string `text`, or a `created_at` that
doesn't parse. The Pi reader already refuses malformed JSONL and bad
timestamps the same way.
- `state.sqlite` or `~/.t3/userdata` is a symlink. The Pi reader skips
symlinked entries instead. For one named file, skipping would be another
silent zero, so this reader refuses.
The source never writes to the database. It never reads other files in
`~/.t3`, and it passes no message text beyond `messageKind` and
`issueNumbers`, the same rule as for Pi logs. The one outside effect is
SQLite's own: a WAL reader takes read locks in the `-shm` file, as T3's own
connections do.
## Decisions for Jason
1. The source is on by default, with `--no-t3` to turn it off. The other
choice is off by default with `--t3` to turn it on. I recommend on by
default, because Gate F exists to count these messages.
2. Unmapped threads get a `t3:unmapped` row. The other choice is to drop
them. I recommend the row.
3. The 14 free-text headers from 09-17 to 09-22 stay counted as human. Fixing
them would mean loosening the header grammar for history only, and I don't
recommend it.
## Acceptance for the build
- Fixture databases built with `node:sqlite` in a temp dir, in WAL mode:
seat threads and an unmapped thread; imported, deleted and archived
threads; all three header forms, uppercase classes included; a message
outside the date range; another project with the same seat titles.
- Each fail-closed case above has its own test, including a schema column
removed and `-wal` without `-shm`. One test opens a database whose newest
message is still in the WAL and counts it. Another reads a database closed
cleanly with no writer attached, which is the T3-stopped case.
- The class fix is proven against HEAD's `messageKind`: an uppercase class
counts as agent after the fix and as human before it.
- A read against the live database gives the counts in this brief, allowing
for messages sent since.
- The ledger README's counting rules name the new source, the mapping rule
and the exclusions.
- No suite runs the ledger tests, so the BUILD-LOG entry names the test file.
## Not in scope
- Claude Code transcripts (`~/.claude/projects`) and Codex sessions
(`~/.codex/sessions`). T3's database already holds every message T3
delivered, so those files would only duplicate it.
- Any write to T3, any T3 API call, or anything that needs T3 running.
- Fixing the two tmux misclassifications Filbert found in 6a.
@@ -0,0 +1,74 @@
--- r2.md
+++ docs/plans/2026-09-26_ledger-t3-source.md
@@ -3,9 +3,9 @@
Brief only, no code. Darkwing wrote it on 2026-09-26 at Sage's request, issue
#1506. R1 (sha256 08959a05) went to Filbert, whose review asked for
revisions: `agents/filbert/work/ledger-t3-source-review-2026-09-26.md`, sha256
-19dda29a. This is R2. It takes every finding, and it records Sage's rulings
-on the three open questions. Section 1 has one measurement that differs from
-the review.
+19dda29a. R2 (sha256 e8300cb6) took every finding and recorded Sage's
+rulings on the three open questions. Filbert approved R2 with three nits,
+review sha256 bb02d8d3. This is R3, which takes the nits.
## Why
@@ -68,10 +68,10 @@
| T3 stopped cleanly, no `-wal` or `-shm` | yes | reads, then leaves an empty `-wal` and a 32 KiB `-shm` |
| T3 stopped cleanly | no | fails, SQLite 1544 "attempt to write a readonly database" |
-In every case the main file's bytes stayed the same. The last row is where
-Filbert and I differ. His review says the stopped-case read works with the
-directory read-only. In my run it failed with and without the read
-transaction. The build's test settles it. Either way a failed open is exit 1.
+In every case the main file's bytes stayed the same. Filbert's first
+review said the last case reads. His test had reused a database whose empty
+`-wal` and `-shm` were still present. On a true clean stop he also got 1544,
+and his review records the correction. A failed open is exit 1.
So the accurate claim: the reader never writes the main database file. Like
any SQLite connection, it may create or update `-wal` and `-shm` beside it
@@ -198,7 +198,9 @@
The source is on by default (Sage's ruling). `--no-t3` turns it off, and the
report then says T3 was not read. `--t3-db <path>` reads another database
file instead of `~/.t3/userdata/state.sqlite`. It exists for fixtures and
-gets the same checks.
+gets the same checks. The JSON records the database path read and whether
+it was the default. When it wasn't, the text report adds one line naming the
+path, so a Gate F result can't come from a fixture unnoticed.
Each of these refuses the report with exit 1, the code the ledger already
uses for unreadable session evidence. The report never falls back to Pi logs
@@ -248,7 +250,9 @@
fixture's temp directory. A test that forgets `--t3-db` or `--no-t3` then
finds no database and fails closed. The existing tests aren't about T3. Each
gets an empty fixture database at the fixture `HOME`'s default path, with
-one project row for the fixture root. So they run with the source on, and
+one project row for the fixture root. That row stores
+`fs.realpathSync(root)`, because the CLI resolves its root through realpath
+and a symlinked temp directory would otherwise not match. So they run with the source on, and
their expected rows don't change. One test asserts that a `HOME` with no
database exits 1 and names `--no-t3`.
@@ -277,9 +281,7 @@
`--no-t3`. Skipped when the tests run as root, where the mode bits don't
bind.
- The stopped case in a directory that isn't writable: exit 1, naming
- `--no-t3`, as I measured it. If the build reads there instead, the builder
- changes this test to assert correct counts and records the correction in
- the BUILD-LOG entry. Skipped as root too.
+ `--no-t3`. Skipped as root too.
Also:
- Each other fail-closed case in section 4 has its own test, including a
@@ -288,7 +290,9 @@
the same counts.
- The class fix is proven against HEAD's `messageKind`. An uppercase class
in either preamble counts as agent after the fix and as human before it.
-- The JSON lists each seat's threads and the unmapped threads.
+- The JSON lists each seat's threads and the unmapped threads, and the
+ database path with whether it was the default. A `--t3-db` run prints the
+ path line in the text report, and a default run doesn't.
- A read against the live database gives the counts in this brief, allowing
for messages sent since. It exits 0 with no header conflict.
- The ledger README's counting rules name the new source, both flags, the
+306
View File
@@ -0,0 +1,306 @@
# Ledger: a read-only T3 thread source for Table 2 (Gate F brief)
Brief only, no code. Darkwing wrote it on 2026-09-26 at Sage's request, issue
#1506. R1 (sha256 08959a05) went to Filbert, whose review asked for
revisions: `agents/filbert/work/ledger-t3-source-review-2026-09-26.md`, sha256
19dda29a. This is R2. It takes every finding, and it records Sage's rulings
on the three open questions. Section 1 has one measurement that differs from
the review.
## Why
Table 2 counts user messages per seat from `.pi/state/<seat>/sessions/*.jsonl`
only. Development seats now run in T3 on the Claude and Codex harnesses, so
their prompts, Jason's included, never reach a Pi log. Today the Human column
can't see T3 at all, and the zero it shows for T3 seats means "no source", not
"no human prompts". 6a (ef0020ad) taught `messageKind` the T3 header, but no
source the ledger reads contains one. Gate F (QUEUE row 6) passes when
Filbert's item closes with zero human messages from Jason. While the ledger
can't see T3, a zero there proves nothing.
## Where T3 keeps messages
T3 keeps its state in one SQLite database, `~/.t3/userdata/state.sqlite`, in
WAL mode (`state.sqlite-wal` and `state.sqlite-shm` sit beside it). The counts
need three projection tables:
- `projection_projects`: `project_id`, `workspace_root`, `deleted_at`.
- `projection_threads`: `thread_id`, `project_id`, `title`, `archived_at`,
`deleted_at`.
- `projection_thread_messages`: `message_id` (primary key), `thread_id`,
`role` (`user` or `assistant`), `text`, `created_at` (ISO UTC).
The JSON diagnostic reads one more. In `orchestration_events`, each
`thread.message-sent` event carries `metadata_json.origin`. Messages typed in
the T3 app carry an `appVersion` there. Messages sent through T3's API or MCP
tools, which is how seats talk to each other, don't.
The same directory also holds `secrets/`, `clerk-tokens.json` and other
settings files. The reader opens `state.sqlite` and nothing else, and it
selects named columns only, never `*`.
## 1. Reading it, with T3 running or not
The reader uses Node's built-in `node:sqlite` (`DatabaseSync`). That needs no
dependency, and Node 26.8.1 (SQLite 3.53.4) prints no warning for it.
- **URI.** Build it with `pathToFileURL(dbPath)` and set `mode=ro` through
`searchParams`, then pass `readOnly: true`. A `?`, `#` or `%` in the home
path would break a string-built URI.
- **One snapshot.** Run every query, from the schema checks through the
diagnostic, inside one `BEGIN` … `COMMIT`. In autocommit mode each
statement sees its own snapshot while T3 writes between them.
- **Busy timeout.** Set `DatabaseSync`'s `timeout` to 5 s. A transient
`SQLITE_BUSY` during a T3 checkpoint then waits instead of failing. A busy
error after the timeout exits 1 like any open failure.
- **No `immutable=1` and no copy.** Both lose the WAL. Filbert found worse
than lost messages: with a table created inside the WAL, `immutable=1`
fails with `no such table`.
What happens on disk. Filbert and I both tested these in scratch
directories:
| State | Directory writable | Result |
|---|---|---|
| T3 running, writer attached, newest rows only in `-wal` | yes | reads them |
| `-wal` without `-shm` (writer killed, `-shm` removed) | yes | reads the WAL rows and creates `-shm` |
| `-wal` without `-shm` | no | open fails, SQLite 14 |
| T3 stopped cleanly, no `-wal` or `-shm` | yes | reads, then leaves an empty `-wal` and a 32 KiB `-shm` |
| T3 stopped cleanly | no | fails, SQLite 1544 "attempt to write a readonly database" |
In every case the main file's bytes stayed the same. The last row is where
Filbert and I differ. His review says the stopped-case read works with the
directory read-only. In my run it failed with and without the read
transaction. The build's test settles it. Either way a failed open is exit 1.
So the accurate claim: the reader never writes the main database file. Like
any SQLite connection, it may create or update `-wal` and `-shm` beside it
and takes read locks in `-shm`. T3 opens normally afterwards.
## 2. Jason or agent
Reuse the 6a rule. The first line of `text` decides: a T3 header or the tmux
preamble counts as agent, `control-board` as the sender counts as board, and
anything else counts as human. Messages with role `user` count; assistant
messages don't.
The class fix rides in this build (Sage's ruling). HEAD's
`packages/ledger/src/ledger.mjs:81` (tmux) and `:83` (T3) both allow only
`class=[a-z-]+`. Both become case-insensitive. Seats send uppercase classes:
Sage's DECISION, INFO, REVIEW-REQUEST and REVIEW-NOTE, and my own
REVIEW-REQUEST. In this project's threads 16 real agent headers failed on
that alone at 20:54Z. The ms-communications table lists lowercase names, so
the fix follows what seats send, not the table.
Cross-check, read at 2026-09-26T20:54Z for the mosaic-stack project (209
user messages outside imported and deleted threads, every one with its
`thread.message-sent` event). Filbert's later read agreed, plus messages sent
since.
| T3 origin | Header matches 6a | Count |
|---|---|---|
| typed in the app (has `appVersion`) | no | 99 |
| sent through the API (no `appVersion`) | yes | 80 |
| sent through the API | no, uppercase class | 16 |
| sent through the API | no, free-text roles | 14 |
No message typed in the app carries a header, and every API message in this
project carries one of the three forms. The 14 free-text ones are older
Discord Bot thread headers such as `[from: SetSpark coordinator (…) -> to:
Discord Bot (…)]`, written before the guide fixed the format. Sage ruled they
stay as recorded: they count as human, dated 2026-09-17 to 2026-09-22.
The header rule decides. The JSON also carries one diagnostic that feeds no
table or total: user messages the rule calls human that T3 recorded as sent
through the API. That number exposed the class bug and would catch the next
format drift. `origin` is T3's internal metadata, not a documented contract,
so it decides nothing. If `orchestration_events` or a column it needs is
missing, the diagnostic reads `unknown` and the report goes on (Sage's
ruling on F5). Missing tables the counts depend on still exit 1.
## 3. Thread to seat
**Project.** A thread counts for this checkout only if its project's
`workspace_root` equals the ledger's repository root, byte for byte. The CLI
already takes that root from the realpath of its own URL, today
`/mnt/storage/src/mosaic-stack`, project `34050c07`. So a T3 project opened
through the compatibility symlink `~/src/mosaic-stack-dev-test` doesn't
match, and "no project row" is the right refusal. The README says so.
**Title rule.** Thread IDs change whenever Jason starts a new thread for a
seat, so there's no fixed map. T3-AGENT-COMMS.md already names threads after
the seat ("Darkwing", "Sage", "Dewey in Claude"). A thread belongs to seat
`<s>` when `<s>` is a real directory under `agents/` and the lower-cased
title equals `<s>` or starts with `<s>` followed by a space. So "Sagebrush"
stays unmapped. Several threads can map to one seat, and their counts add
up, as several Pi session files already do.
Today that maps Sage, Darkwing, Filbert, Dewey and Rocko (one thread each,
created 2026-09-26, titles set by hand), plus "Darkwing in Claude" (archived)
and "Dewey in Claude". Researcher has a directory and no thread. Three
threads map to no seat. Two are imported and excluded anyway ("FINDINGS.md
review" and "[dragon-lin:darkwing -> …"). The third is "Discord Bot" with 68
user messages: 54 without a header, and the 14 free-text headers.
Titles are current state, and T3 can write them itself. They go wrong three
ways. T3 auto-titles an unnamed thread from Jason's first prompt, so "Rocko
review of the plan" maps to rocko. A rename moves the whole history to
another row. A seat thread titled for a topic drops into `t3:unmapped`.
None of this changes the Human total or the human-per-closed ratio. It only
moves counts between rows, but Gate F reads one seat's row.
**Header cross-check.** The headers already say which seat a thread belongs
to. For every user message whose header matches the fixed 6a rule and whose
`to:` id equals the message's own `thread_id`:
- in a mapped thread, the `to:` role, lower-cased, must equal that thread's
seat;
- in an unmapped thread, the `to:` role must not be a seat name.
A conflict exits 1 and names the thread id, its title and both roles. A
header whose `to:` id is some other thread is not checked. The check reads
message text only, not T3 metadata. In a live read at 21:02Z every header
agreed: all 104 addressed to their own thread carried the full thread id and
named that thread's seat (Sage 40, Darkwing 15, Filbert 18, Dewey 15, Rocko
16).
It catches a seat thread renamed to another seat or to a topic, once any
agent writes to it. It also catches an auto-titled thread that agents
address by a different seat. It misses a thread no agent ever writes to.
Such a thread can only add human counts to a seat's row, never hide them, so
for Gate F it errs toward a visible failure. The README says so.
**Unmapped row.** Unmapped threads go in one Table 2 row, `t3:unmapped`
(Sage's ruling), so their human messages still reach the Human column and
the human-per-closed ratio.
**Mapping in the JSON.** For each seat, the T3 thread ids and titles that
made its row, and the unmapped thread ids and titles. Anyone checking a Gate
F result can then see which threads the row came from.
A seat's row sums its Pi and T3 counts, and the JSON splits them by source.
Nothing is counted twice. Every T3 session today runs on `claudeAgent` or
`codex`, which don't write `.pi/state`, and Filbert found no T3 header in any
Pi log (6a record).
**Excluded,** with the reason stated in the README:
- Imported threads (`thread_id` starting `import:`, events marked
`historyImport`). They are partial copies of Claude Code sessions, not T3
traffic: 55 user messages in two threads here.
- Deleted threads (`deleted_at` set). Across all projects there are 3, with
3 messages. Archived threads count.
- Threads in other T3 projects. Live, there is a project at `/home/jwoltje`
and a deleted one at `/mnt/storage/src`. A thread in either could work on
this repository and would not be counted. The workspace-root rule is still
the right one, but the README names this blind spot.
## 4. What fails closed
The source is on by default (Sage's ruling). `--no-t3` turns it off, and the
report then says T3 was not read. `--t3-db <path>` reads another database
file instead of `~/.t3/userdata/state.sqlite`. It exists for fixtures and
gets the same checks.
Each of these refuses the report with exit 1, the code the ledger already
uses for unreadable session evidence. The report never falls back to Pi logs
alone. Where the database is missing or won't open, the message names
`--no-t3`.
- The database is missing or unreadable, or won't open read-only. That
includes a directory that isn't writable when SQLite needs to create
`-shm`, and a busy error after the timeout. The Pi reader treats a missing
`.pi` as no messages, and this departs from it on purpose. A missing Pi
directory means no Pi seats ran here. A missing T3 database on this host
means the path or T3 changed, and a silent zero is the failure Gate F
exists to prevent.
- `~/.t3`, `~/.t3/userdata` or `state.sqlite` is a symlink. With `--t3-db`,
the file and its directory are checked. The Pi reader checks every
ancestor too, but it skips symlinked entries. Skipping one named file
would be another silent zero, so this reader refuses.
- A table or column the counts need is missing. The reader checks `PRAGMA
table_info` and names what's missing. This catches a T3 upgrade that
changes the schema.
- No project row, or more than one non-deleted row, for this repository root.
- A counted row has a bad `role`, non-string `text`, or a `created_at` that
doesn't parse. The Pi reader already refuses malformed JSONL and bad
timestamps the same way.
- A header conflicts with the title mapping (section 3).
The reader never reads other files in `~/.t3`. It passes no message text
beyond `messageKind`, `issueNumbers` and the header's `to:` role and id, the
same rule as for Pi logs.
## 5. Rulings
Sage ruled on the three questions R1 put to Jason, as lead calls:
1. The source is on by default. A missing or unreadable database exits 1,
and the message names `--no-t3`.
2. Unmapped threads get the `t3:unmapped` row.
3. The 14 free-text headers stay as recorded. They show only in the JSON
diagnostic.
Sage also ruled that the class fix rides in this build, and that a missing
diagnostic table reads `unknown` (F5).
## 6. Acceptance for the build
**No test opens the real `~/.t3`.** Both places in
`packages/ledger/tests/ledger.test.mjs` that spawn `cli.mjs` (the shared
`run()` helper and the direct `spawnSync` at line 66) set `HOME` to the
fixture's temp directory. A test that forgets `--t3-db` or `--no-t3` then
finds no database and fails closed. The existing tests aren't about T3. Each
gets an empty fixture database at the fixture `HOME`'s default path, with
one project row for the fixture root. So they run with the source on, and
their expected rows don't change. One test asserts that a `HOME` with no
database exits 1 and names `--no-t3`.
Fixture databases are built with `node:sqlite` in a temp directory, in WAL
mode:
- seat threads and an unmapped thread; imported, deleted and archived
threads; a message outside the date range;
- all three header forms, with uppercase classes in both the tmux preamble
and the T3 header;
- a thread with the same seat title in another project;
- a seat thread renamed to another seat, with an agent header to its own
id, which exits 1;
- a "Sagebrush" title, which stays unmapped;
- a thread titled "Researcher", which maps to the seat that has no thread
live.
WAL states, each with its own test:
- The newest message is only in `-wal`, with the writer still attached (the
live-T3 case). It is counted.
- T3 stopped: the database closed cleanly with no writer. Counts are
correct, and the main file's bytes are unchanged afterwards.
- `-wal` without `-shm` in a writable directory: made by a child writer with
`wal_autocheckpoint=0` that is SIGKILLed, then `-shm` deleted. The WAL
rows are counted.
- `-wal` without `-shm` in a directory that isn't writable: exit 1, naming
`--no-t3`. Skipped when the tests run as root, where the mode bits don't
bind.
- The stopped case in a directory that isn't writable: exit 1, naming
`--no-t3`, as I measured it. If the build reads there instead, the builder
changes this test to assert correct counts and records the correction in
the BUILD-LOG entry. Skipped as root too.
Also:
- Each other fail-closed case in section 4 has its own test, including a
removed schema column and each symlink.
- A missing `orchestration_events` gives `unknown` for the diagnostic and
the same counts.
- The class fix is proven against HEAD's `messageKind`. An uppercase class
in either preamble counts as agent after the fix and as human before it.
- The JSON lists each seat's threads and the unmapped threads.
- A read against the live database gives the counts in this brief, allowing
for messages sent since. It exits 0 with no header conflict.
- The ledger README's counting rules name the new source, both flags, the
mapping rule and the header check, and the exclusions. That includes the
symlinked-checkout case and the other-project blind spot.
- No suite runs the ledger tests, so the BUILD-LOG entry names the test file.
## Not in scope
- Claude Code transcripts (`~/.claude/projects`) and Codex sessions
(`~/.codex/sessions`). T3's database already holds every message T3
delivered, so those files would only duplicate it.
- Any T3 API call, anything that needs T3 running, and any write beyond
SQLite's own `-wal` and `-shm` handling.
- Fixing the two tmux misclassifications Filbert found in 6a.
@@ -0,0 +1,20 @@
76833a3bf536bb9592a0040cdb10d9a8351cf0b3bd2828feee928d3415136a79 docs/plans/BRIEF-TEMPLATE.md
5d4b4c7a4624ef267d76d4c032dbcddf3a3d7e7b73a5cfed06953307992bbde7 packages/queue/package.json
576d8ed44a19e1cca96fd7128ca34fcc62d840580b2c0936fa3b296df2e72ddf packages/queue/README.md
188ade96cabb73e06b6b8fbe3d30e8d4d174843877f3a151dcf08082c92d3068 packages/queue/src/cli.mjs
7a851814dfff6f392de814fc31f8dc8cbe9c79cd40ef71313dca1939115ee879 packages/queue/src/errors.mjs
b18e120cb9ddba4c5576d7bc7f7f378ed86e6ec1e084d436474b80765261d2e5 packages/queue/src/io.mjs
52d9f68f01f29e84943fc359fdb1d1ddfaf58d1650c6b15b253b83f1daba9927 packages/queue/src/lock.mjs
c11235a6b99acf6baf1257c63eced410060065ef4f21f79a18860adfa19571cf packages/queue/src/queue.mjs
756cbc9ab13de85757cc24f903d02e8a1d20bb45bb8020c5e1d91e26fbaacaa4 packages/queue/src/store.mjs
6005da4c809cb9045f9480e1e29077b8e7ab185c3ed91567717e8cc13d67b5b0 packages/queue/tests/commit.test.mjs
d29d58427c712a69e8a818d8c74ce724d519ddea8780cc0b5f0b0035cec0f498 packages/queue/tests/data.test.mjs
5cccea50d5a40e07891a090dea001c095a26f4a6c2e0af40c144b1d00f239b5e packages/queue/tests/fixtures/kill-at.mjs
59cc8092fbcddbe9854da7d5014f706f4ffb86573f62ad909aa0b0e09c6d99b9 packages/queue/tests/fixtures/lock-child.mjs
5769b3618918fe36398a75e449d644932332ad5a60c3127098a1d011eda2c182 packages/queue/tests/helpers.mjs
9f98a388ce91438c3238be36049bcd5171b365c68d4b7bf1ae5ff910f4b7b1e8 packages/queue/tests/lock.test.mjs
2a3d2be8cb25b6e7cd18ba56393a284415c66e7ee26a3148fa39f32885efbd35 packages/queue/tests/store.test.mjs
74378acbd41ef21a0b171b08aa85677e4c471966d2ffd8cfb310adb0e044b246 packages/queue/tests/write.test.mjs
3cbd40575dc728dc5407c5029f4f5fff747ce93a5508807233f4362ad37d7f9c scripts/git-hooks/pre-commit
2632078bea45e0249b3fdd9a335100bade7c9106222931603ede9d414c404f54 scripts/queue-commit.sh
92cea23b9ada2edb1b0482ca2daf5864666cc1f546e1f2e6558c3826f1ddf9e7 scripts/test-queue.sh
@@ -0,0 +1,20 @@
20363f5dafbb1be8b7380d7603fd04cf38f5284457b634c9a98ce5a6d8e4832a docs/plans/BRIEF-TEMPLATE.md
5d4b4c7a4624ef267d76d4c032dbcddf3a3d7e7b73a5cfed06953307992bbde7 packages/queue/package.json
9ebdb6a3f239051a39e63fcf8f59c8fba540bb3cc63b7880d15e6c6f1e549a09 packages/queue/README.md
711db25594d78e0ba603a9e91221f6c32241ce1d9217b01bd4da3a99967ae871 packages/queue/src/cli.mjs
7a851814dfff6f392de814fc31f8dc8cbe9c79cd40ef71313dca1939115ee879 packages/queue/src/errors.mjs
b18e120cb9ddba4c5576d7bc7f7f378ed86e6ec1e084d436474b80765261d2e5 packages/queue/src/io.mjs
1095cb6611f2d8d38f535930bbe03208e854b4a57176cea813f7ea0487b3c4f1 packages/queue/src/lock.mjs
8e9230901f550b829ef55e754819c9cd5703e98e447fcebdba6ce5dbef11b0e9 packages/queue/src/queue.mjs
172cf529b2c0e915fbd4a130faa5dbec9023bb19160012246801b16482b4ffaa packages/queue/src/store.mjs
74d04d0de9f4e068fe66bb465ed1575fb38b6ea6592fca6cd6fbacdd553fe9e1 packages/queue/tests/commit.test.mjs
e3f774f823bfb1bcb6025cc3688d72ec110144d5df064557d1645a62472fd663 packages/queue/tests/data.test.mjs
5cccea50d5a40e07891a090dea001c095a26f4a6c2e0af40c144b1d00f239b5e packages/queue/tests/fixtures/kill-at.mjs
59cc8092fbcddbe9854da7d5014f706f4ffb86573f62ad909aa0b0e09c6d99b9 packages/queue/tests/fixtures/lock-child.mjs
5769b3618918fe36398a75e449d644932332ad5a60c3127098a1d011eda2c182 packages/queue/tests/helpers.mjs
a2dbe3dc69b9d53c42246e41e61b9b7d2395697a53ca12ef3481965b43321ff6 packages/queue/tests/lock.test.mjs
34f4b0e3eeadc882ffcc6ba7f0b439651957f2f0c41c9387cf8fb51901f46202 packages/queue/tests/store.test.mjs
70e8a068efda8da8fe1e1628cc1cd5b7fa7796a475941bf7349747244c002f1b packages/queue/tests/write.test.mjs
3cbd40575dc728dc5407c5029f4f5fff747ce93a5508807233f4362ad37d7f9c scripts/git-hooks/pre-commit
2632078bea45e0249b3fdd9a335100bade7c9106222931603ede9d414c404f54 scripts/queue-commit.sh
92cea23b9ada2edb1b0482ca2daf5864666cc1f546e1f2e6558c3826f1ddf9e7 scripts/test-queue.sh
+186
View File
@@ -0,0 +1,186 @@
# Queue A1 build (#1508), candidate for review
Darkwing built this on 2026-09-26 from section 8 of
`agents/filbert/work/queue-as-data-plan-2026-09-26.md` (sha256 282fabbb,
the only spec), split as Sage approved: render is in A1, `move in-review`
needs `--candidate` until piece D, and a round's issue is the row's first
issue. Filbert reviews the code; Sage commits after the suites. Base is HEAD
3a209eea. Nothing is committed, staged or pushed.
A stray pkill at 22:02:32Z stopped my first turn with only
`src/errors.mjs` and `src/io.mjs` on disk. I reread both against what I had
meant them to be. They match: the exit-code class, and the file layer with
`realIo` as the only layer the CLI uses. Everything else was written after
the restart.
## Files
`build-manifest.sha256` pins the 20 files. `build.patch` (sha256 419804f2)
adds all 20 as new files with their modes. It applies cleanly to 3a209eea,
and the applied tree matches the manifest and passes `scripts/test-queue.sh`.
- `packages/queue/src/`: `errors.mjs`, `io.mjs` (the fault-injectable file
layer), `lock.mjs` (lock and unlock gate, 8.4), `queue.mjs` (serialization,
replay, the transition matrix, `next`, render), `store.mjs` (canonical
checks, the write path, witness, views, snapshot, verify), `cli.mjs`.
- `packages/queue/tests/`: data 19, lock 17, store 18, write 20, commit 21
tests, plus `helpers.mjs` and two child fixtures.
- `packages/queue/package.json` and `README.md`. The package has no
dependencies.
- `scripts/queue-commit.sh` (0755): the 8.12 procedure and
`--install-hook`.
- `scripts/git-hooks/pre-commit` (0755, POSIX sh): the queue guard.
- `scripts/test-queue.sh` (0755): the suite, in the style of
`test-discord.sh`.
- `docs/plans/BRIEF-TEMPLATE.md`: the 8.13 template.
## Which path runs `verify` once genesis is in
`scripts/test-queue.sh` runs `node packages/queue/src/cli.mjs verify` when
`git cat-file -e HEAD:docs/plans/queue.json` succeeds. At HEAD today there is
no `queue.json`, so it prints `skip queue verify: HEAD has no
docs/plans/queue.json (before the genesis commit)` and stays green. A2 moves
that call to `scripts/mosaic queue verify` when it adds the dispatch.
`queue-commit.sh` also calls `node packages/queue/src/cli.mjs` directly
(`snapshot`, then `verify --snapshot` from HEAD's archive) until A2.
## Sage's five conditions
1. Nothing ran against the canonical `.git`. After all runs, `.git/hooks`
holds only the samples, `.git` has no `mosaic-queue*` file, and
`git config --show-scope --get-all core.hooksPath` returns nothing in any
scope (rc 1). Every hook install, genesis, lock and gate test runs in a
scratch repo under the system temp directory. Suite runs used a
`--shared` clone at `/tmp/qa1-verify`.
2. `docs/plans/QUEUE.md`, `AGENTS.md` and `docs/TOOLS.md` are unedited.
`docs/SESSIONS.md` shows as modified in the working tree, but that was
someone else's edit before my session began; I didn't touch it.
3. `scripts/test-queue.sh` is green at HEAD with no `queue.json`: 19 checks
passed, `verify` skipped as above.
4. H is recorded before the canary. `commit.test.mjs` has "H recorded before
the canary": a shim commits on the first `git hook run`, and
`queue-commit.sh` exits 1 with `refs/heads/<branch> moved since <H>;
nothing published`. A second test moves HEAD after `commit-tree` with the
same result. Mutation M1 (read H after the canary) fails the first test.
5. The fault file layer is reachable only from tests. Faults enter through
the options the API takes (`io`, `proc`, `hook`, `now`, `readOrder`,
`lockWaitMs`); `cli.mjs` passes none. The only `process.env` read in
`src/` is the default `env` in `store.mjs`'s context.
## Bugs found while building
- A nested `node --test` inherits `NODE_TEST_CONTEXT` and exits 0 whatever
its tests do. Step 4's run of HEAD's archived tests therefore passed with a
failing test in the archive. `queue-commit.sh` and `test-queue.sh` now run
it under `env -u NODE_TEST_CONTEXT`, and a test commits an archive with a
failing test and expects a refusal (mutation M4). Other suites in this repo
that nest `node --test` may have the same blind spot. I haven't checked
them.
- git 2.55 does not hold `index.lock` while the commit editor is open. The
plan expected a paused `git commit -e` to block step 8. It doesn't: the
paused commit loses later at its own HEAD update with `cannot lock ref
'HEAD': is at C but expected H`. The test now asserts that outcome.
Nothing is lost, but the reason differs from the plan's.
- ext4 hands a freed inode number straight back. My first test for release's
inode check wrote a byte-identical lock after unlinking the original and
got the same inode back, so it proved nothing. It now writes a copy and
renames it over the lock, which guarantees a new inode.
## Choices the spec left open
- Verb names `release` and `set`. `add` requires `--gate`. A null brief is
allowed only on rows that genesis creates as done. `sync --op` is
optional.
- Reads need no actor. `next` with no seat and no `$MOSAIC_AGENT_NAME`
refuses.
- `accept-history` accepts a stale table but not an unknown one. When a
write succeeds but the table write is skipped, the CLI warns and exits 0.
- An invalid witness is treated as absent.
- `GIT_DIR`, `GIT_WORK_TREE` and `GIT_COMMON_DIR` refuse in the CLI.
`queue-commit.sh` also refuses `GIT_INDEX_FILE`, `GIT_OBJECT_DIRECTORY`
and `GIT_ALTERNATE_OBJECT_DIRECTORIES`.
- Leftover temp files are unlinked. Genesis uses `link`, so it cannot
replace an existing file.
- `unlock` works on a missing or invalid lock file.
- `note` on a blocked row edits `blockedReason`.
- Messages already name `scripts/mosaic queue`. The lost-history refusal
names `sync` when the file holds genesis alone.
- `--candidate` auto-detects: an existing file is a manifest, anything else
is a commit reachable from `refs/heads` or `refs/tags`.
- Only `--install-hook` is privileged in `queue-commit.sh` (jason or sage).
The commit itself relies on the protocol that the lead runs it.
- After the snapshot, `queue-commit.sh` checks the genesis entry's branch
and root against the current branch and root. For `--genesis` it also
checks that `H:<map>` is the map blob genesis read.
- Step 8 compares the index's two queue entries with H's before it looks
for `index.lock`, so "someone staged a queue path" is reported ahead of a
lock.
## Deferred
- 8.12's test of `verify-commit` on a prospective tree belongs to piece D,
which adds `verify-commit`. It is not in A1.
- A2 holds the real migration map, the QUEUE.md markers and header, the
row-7 pointer, the golden render, `scripts/mosaic` dispatch and
`docs/TOOLS.md`.
- Sage adds `queue` to the suite list when A1 lands (lead decision 20).
- Bootstrap happens after A2's map and markers land:
`scripts/queue-commit.sh --install-hook --by sage`, then `queue genesis`,
then `scripts/queue-commit.sh --genesis -m MSG`.
## Verification
In `/tmp/qa1-verify` (HEAD 3a209eea plus the 20 files):
| Suite | Result |
|---|---|
| config | 24/24 |
| task | 90/90 |
| foundation | 43/43 |
| conductor | 17/17 |
| release | 14/14 |
| auth | 15/15 |
| discord | 63/63 |
| extension-package | 18/18 |
| queue | 19 checks; `node --test` 95/95 |
The queue tests take about 18 s and were stable over two runs. A combined
`node --test` run over every package came to 474/474.
I also ran the post-genesis path in a scratch repo: install the hook,
genesis, `--genesis` commit, then `test-queue.sh`. `verify` printed `ok
verify rev 1: file valid, witness matches, view current`. After a hand edit
to one table cell it failed with `view unknown`.
### Mutations
Each mutation went into the verify clone, the queue tests ran, and the
original was restored. Every one was caught; the number is how many tests
failed.
| Id | Mutation | Failing tests |
|---|---|---|
| M1 | read H after the canary | 1 |
| M2 | drop the step-7 guard recheck | 2 |
| M3 | drop step 8's entry comparison | 1 |
| M4 | keep `NODE_TEST_CONTEXT` | 1 |
| M5 | drop step 1's staged-path check | 1 |
| M6 | `update-ref` without the old value | 2 |
| M7 | skip the canary | 2 |
| M8 | guard hook always passes | 21 |
| M9 | drop step 8's `index.lock` check | 1 |
| M10 | drop the exec-bit check | 2 |
| M11 | drop the `core.hooksPath` check | 1 |
| M12 | drop the map-blob check | 1 |
| S1 | drop the "unchanged since read" check | 1 |
| S2 | swallow the directory fsync error | 2 |
| S3 | drop the recheck under the lock for unlocked reads | 2 |
| S4 | drop the unlock-gate check | 2 |
| S5a | release ignores the inode | 1 |
| S5b | release ignores the record bytes | 1 |
| S6 | write the witness before the rename | 10 |
| S7 | drop genesis's fsync | 1 |
| S8 | treat a reused pid as dead | 11 |
S5 survived at first: the delayed-release test was caught by the byte
comparison alone. The inode test in `lock.test.mjs` closes that gap.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,819 @@
diff --git a/docs/plans/BRIEF-TEMPLATE.md b/docs/plans/BRIEF-TEMPLATE.md
index c5ece4ba..1b0facef 100644
--- a/docs/plans/BRIEF-TEMPLATE.md
+++ b/docs/plans/BRIEF-TEMPLATE.md
@@ -13,7 +13,8 @@ Rules the queue enforces (queue-as-data plan 8.13):
refuse until the lead re-pins it.
`queued` means the brief exists, not that it is accepted. The row moves to
-`briefed` when its owner accepts it.
+`briefed` when a privileged actor (jason or sage) accepts it; the owner
+can't.
---
diff --git a/packages/queue/README.md b/packages/queue/README.md
index 74956816..1c8774fa 100644
--- a/packages/queue/README.md
+++ b/packages/queue/README.md
@@ -44,7 +44,18 @@ Exit codes: 0 ok; 1 the operation failed; 2 invalid data or refused;
Until piece D, `move ID in-review` needs `--candidate`: an existing file is
read as a manifest (one `<sha256> <path>` line per file), anything else as a
commit reachable from `refs/heads` or `refs/tags`. The candidate is frozen
-for the round. The review's issue is the row's first issue.
+for the round.
+
+The review's issue follows lead decision 23. A row with no issues can't
+request review. A row with one issue uses it. A row with several needs
+`--issue N`, one of its issues. Later rounds keep the previous round's issue
+unless `--issue` names another; if the row no longer lists the kept issue,
+the request refuses until `--issue` names one.
+
+`move ID done` from in-review needs `--evidence
+comment=<id>,round=<n>,candidate=<digest>`. The round must be the current
+one and the digest its candidate's, so a comment from an earlier round
+can't close a later one, even when the candidate is the same.
## Where the files live
diff --git a/packages/queue/src/cli.mjs b/packages/queue/src/cli.mjs
index c5517747..e8df80f7 100644
--- a/packages/queue/src/cli.mjs
+++ b/packages/queue/src/cli.mjs
@@ -4,7 +4,7 @@
// Reads: list | show ID | next [SEAT]
// Changes: add --piece TEXT --gate TEXT --brief PATH#ANCHOR [--issue N]... [--note TEXT]
// [--owner SEAT] [--gate-owner SEAT] [--after ID[:settled]]... [--reviewer SEAT]... [--required]
-// move ID STATE [--reason TEXT] [--candidate COMMIT|MANIFEST] [--evidence TEXT]
+// move ID STATE [--reason TEXT] [--candidate COMMIT|MANIFEST] [--issue N] [--evidence TEXT]
// release ID | assign ID SEAT | note ID TEXT | set ID FIELD VALUE [--reason TEXT]
// genesis --root PATH --branch NAME --map PATH
// accept-history --reason TEXT --yes
@@ -23,7 +23,7 @@ import { list, mutate, next, renderView, show, snapshot, sync, unlock, verify, v
const USAGE = [
"usage: queue list | show ID | next [SEAT]",
" queue add --op ID --piece TEXT --gate TEXT --brief PATH#ANCHOR [--issue N]... [--note TEXT] [--owner SEAT] [--gate-owner SEAT] [--after ID[:settled]]... [--reviewer SEAT]... [--required]",
- " queue move ID STATE --op ID [--reason TEXT] [--candidate COMMIT|MANIFEST] [--evidence TEXT]",
+ " queue move ID STATE --op ID [--reason TEXT] [--candidate COMMIT|MANIFEST] [--issue N] [--evidence TEXT]",
" queue release ID --op ID | assign ID SEAT --op ID | note ID TEXT --op ID",
` queue set ID FIELD VALUE --op ID [--reason TEXT] (fields: ${SET_FIELDS.join(", ")})`,
" queue genesis --op ID --root PATH --branch NAME --map PATH",
@@ -134,8 +134,12 @@ export function run(argv, opts = {}) {
});
}
case "move":
- allow(flags, [...CHANGE, "--reason", "--candidate", "--evidence"]); positional(pos, 2, "move ID STATE");
- return change("move", { id: intArg(pos[0], "ID"), to: pos[1], reason: f("--reason"), candidate: f("--candidate"), evidence: f("--evidence") });
+ allow(flags, [...CHANGE, "--reason", "--candidate", "--issue", "--evidence"]); positional(pos, 2, "move ID STATE");
+ if ((flags.get("--issue") ?? []).length > 1) throw usage("move takes one --issue");
+ return change("move", {
+ id: intArg(pos[0], "ID"), to: pos[1], reason: f("--reason"), candidate: f("--candidate"), evidence: f("--evidence"),
+ issue: flags.has("--issue") ? intArg(flags.get("--issue")[0].replace(/^#/, ""), "--issue") : null,
+ });
case "release":
allow(flags, CHANGE); positional(pos, 1, "release ID");
return change("release", { id: intArg(pos[0], "ID") });
diff --git a/packages/queue/src/lock.mjs b/packages/queue/src/lock.mjs
index 56c43f0f..34e88e3b 100644
--- a/packages/queue/src/lock.mjs
+++ b/packages/queue/src/lock.mjs
@@ -70,6 +70,7 @@ function describe(c) {
function publish(io, target, bytes, hook, waitMs, stepMs) {
const tmp = `${target}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
let fd;
+ let st;
try {
fd = io.openExcl(tmp, 0o600);
} catch (err) {
@@ -82,6 +83,9 @@ function publish(io, target, bytes, hook, waitMs, stepMs) {
fd = null;
const back = io.readFile(tmp);
if (!back.equals(bytes)) throw Object.assign(new Error("read-back differs from the record"), { code: "EREADBACK" });
+ // The link gives the target this inode, so read it before linking:
+ // nothing that can fail runs between a successful link and the return.
+ st = io.stat(tmp);
} catch (err) {
if (fd !== null) { try { io.close(fd); } catch { /* already failing */ } }
unlinkQuiet(io, tmp);
@@ -99,7 +103,6 @@ function publish(io, target, bytes, hook, waitMs, stepMs) {
sleepMs(stepMs);
continue;
}
- const st = io.stat(tmp);
return { linked: true, dev: st.dev, ino: st.ino, bytes };
}
} finally {
@@ -122,8 +125,14 @@ export function acquire({ gitDir, io, proc = realProc, op = null, verb, waitMs =
const handle = { path, dev: got.dev, ino: got.ino, bytes: got.bytes };
hook("lock-linked");
const gate = join(gitDir, GATE_NAME);
- if (lstatOrNull(io, gate) !== null) {
- const c = classify(readOrNull(io, gate), proc);
+ let c = null;
+ try {
+ if (lstatOrNull(io, gate) !== null) c = classify(readOrNull(io, gate), proc);
+ } catch (err) {
+ const left = release(handle, io);
+ throw new QueueError(`cannot check the unlock gate ${gate}: ${errno(err)}; ${left ?? "lock released"}`, 1);
+ }
+ if (c !== null) {
release(handle, io);
throw new QueueError(`unlock gate ${gate} is present (${describe(c)}); check it with \`scripts/mosaic queue unlock --check-gate\``, 2);
}
@@ -154,18 +163,29 @@ export function unlock({ gitDir, io, proc = realProc, hook = () => {} }) {
}
const gate = { path: gatePath, dev: got.dev, ino: got.ino, bytes };
hook("gate-held");
+ let result;
+ let failure = null;
try {
const lockBytes = readOrNull(io, lockPath);
- if (lockBytes === null) return "no queue lock present; nothing removed";
- const c = classify(lockBytes, proc);
- if (c.state !== "dead" && c.state !== "mismatch") {
- throw new QueueError(`queue lock owner is ${describe(c)}; unlock refuses`, 2);
+ if (lockBytes === null) {
+ result = "no queue lock present; nothing removed";
+ } else {
+ const c = classify(lockBytes, proc);
+ if (c.state !== "dead" && c.state !== "mismatch") {
+ throw new QueueError(`queue lock owner is ${describe(c)}; unlock refuses`, 2);
+ }
+ io.unlink(lockPath);
+ result = `removed queue lock (${describe(c)}): ${lockBytes.toString("utf8").trim()}`;
}
- io.unlink(lockPath);
- return `removed queue lock (${describe(c)}): ${lockBytes.toString("utf8").trim()}`;
- } finally {
- release(gate, io);
+ } catch (err) {
+ failure = err;
}
+ let msg;
+ try { msg = release(gate, io); } catch (err) { msg = `cannot release the unlock gate (${errno(err)})`; }
+ // Like the lock, a swapped gate is reported on success and on refusal (8.4).
+ if (msg && failure instanceof Error) failure.message += `\nwarning: ${msg}`;
+ if (failure) throw failure;
+ return msg ? `${result}\nwarning: ${msg}` : result;
}
export function checkGate({ gitDir, io, proc = realProc }) {
diff --git a/packages/queue/src/queue.mjs b/packages/queue/src/queue.mjs
index 9999ba18..06e77c17 100644
--- a/packages/queue/src/queue.mjs
+++ b/packages/queue/src/queue.mjs
@@ -208,7 +208,7 @@ export function validateRow(row) {
checkNames(row.reviewers, `${w} reviewers`);
if (row.review !== null) {
keysExactly(row.review, ["issue", "rounds"], `${w} review`);
- if (row.review.issue !== null) checkId(row.review.issue, `${w} review issue`);
+ checkId(row.review.issue, `${w} review issue`);
if (!Array.isArray(row.review.rounds) || row.review.rounds.length === 0) throw refuse(`${w} review needs at least one round`);
row.review.rounds.forEach((r, i) => checkRound(r, i + 1));
}
@@ -338,6 +338,7 @@ export function canonArgs(verb, a) {
reason: nullable(a.reason, (v) => checkText(v, "reason")),
candidate: nullable(a.candidate, (v) => checkText(v, "candidate", { max: 300 })),
evidence: nullable(a.evidence, (v) => checkText(v, "evidence")),
+ issue: nullable(a.issue, (v) => checkId(v, "issue")),
};
case "release":
return { id: checkId(a.id) };
@@ -393,11 +394,30 @@ function afterSatisfied(rows, row) {
return missing;
}
-// `comment=<id>,candidate=<digest>`: the J5 evidence before Piece D.
+// `comment=<id>,round=<n>,candidate=<digest>`: the J5 evidence before Piece D.
export function parseReviewEvidence(text) {
- const m = /^comment=([1-9][0-9]{0,19}),candidate=([0-9a-f]{40}|[0-9a-f]{64})$/.exec(text ?? "");
- if (!m) throw refuse("in-review to done needs --evidence comment=<id>,candidate=<digest> for the current round");
- return { comment: m[1], candidate: m[2] };
+ const m = /^comment=([1-9][0-9]{0,19}),round=([1-9][0-9]{0,5}),candidate=([0-9a-f]{40}|[0-9a-f]{64})$/.exec(text ?? "");
+ if (!m) throw refuse("in-review to done needs --evidence comment=<id>,round=<n>,candidate=<digest> for the current round");
+ return { comment: m[1], round: Number(m[2]), candidate: m[3] };
+}
+
+// The issue a review round posts to (lead decision 23). The row must list
+// one; with several, --issue names it. A later round keeps the previous
+// round's issue unless --issue names another, and the kept issue must still
+// be one of the row's.
+function reviewIssue(row, issue) {
+ const list = row.issues.map((n) => `#${n}`).join(", ");
+ if (row.issues.length === 0) throw refuse(`row ${row.id} lists no issues; a privileged actor sets one before review`);
+ if (issue !== null) {
+ if (!row.issues.includes(issue)) throw refuse(`--issue #${issue} is not one of row ${row.id}'s issues (${list})`);
+ return issue;
+ }
+ if (row.review) {
+ if (!row.issues.includes(row.review.issue)) throw refuse(`row ${row.id}'s review issue #${row.review.issue} is no longer one of its issues (${list}); name one with --issue`);
+ return row.review.issue;
+ }
+ if (row.issues.length > 1) throw refuse(`row ${row.id} lists several issues (${list}); name the review's issue with --issue`);
+ return row.issues[0];
}
function touch(row, entry) {
@@ -424,7 +444,7 @@ function getRow(rows, id) {
}
function applyMove(rows, row, entry, resolved) {
- const { to, reason, candidate, evidence } = entry.args;
+ const { to, reason, candidate, evidence, issue } = entry.args;
const by = entry.by;
const from = row.state;
const illegal = () => refuse(`row ${row.id}: ${from}→${to} is not a transition`);
@@ -432,9 +452,11 @@ function applyMove(rows, row, entry, resolved) {
if (reason !== null && to !== "blocked") throw refuse("--reason applies only to a move to blocked");
if (candidate !== null && !(from === "in-progress" && to === "in-review")) throw refuse("--candidate applies only to in-progress→in-review");
if (evidence !== null && to !== "done") throw refuse("--evidence applies only to a move to done");
+ if (issue !== null && !(from === "in-progress" && to === "in-review")) throw refuse("--issue applies only to in-progress→in-review");
let next = { ...row, state: to };
let round = null;
let cand = null;
+ let revIssue = null;
if (to === "blocked") {
if (from === "blocked") throw refuse(`row ${row.id} is already blocked; update the reason with note`);
if (!NON_TERMINAL.has(from)) throw illegal();
@@ -457,11 +479,12 @@ function applyMove(rows, row, entry, resolved) {
} else if (from === "in-progress" && to === "in-review") {
if (row.claim === null || by !== row.claim.seat) throw refuse(`only the claimant (${row.claim?.seat ?? "nobody"}) may request review of row ${row.id}`);
if (candidate === null) throw refuse("in-progress→in-review needs --candidate <commit|manifest>");
+ revIssue = reviewIssue(row, issue);
cand = checkCandidate(resolved.candidate);
const rounds = row.review ? row.review.rounds : [];
round = rounds.length + 1;
next.review = {
- issue: row.review ? row.review.issue : (row.issues[0] ?? null),
+ issue: revIssue,
rounds: [...rounds, { n: round, op: entry.op, by, at: entry.at, candidate: cand, request: "none" }],
};
} else if (from === "in-review" && (to === "in-progress" || to === "waiting-on-jason")) {
@@ -479,6 +502,7 @@ function applyMove(rows, row, entry, resolved) {
const ev = parseReviewEvidence(evidence);
const cur = row.review?.rounds.at(-1);
if (!cur) throw refuse(`row ${row.id} has no review round to cite`);
+ if (ev.round !== cur.n) throw refuse(`evidence names round ${ev.round}; row ${row.id} is in round ${cur.n}`);
if (ev.candidate !== cur.candidate.digest) throw refuse(`evidence candidate ${ev.candidate} is not round ${cur.n}'s candidate ${cur.candidate.digest}`);
round = cur.n;
next.claim = null;
@@ -491,7 +515,7 @@ function applyMove(rows, row, entry, resolved) {
throw illegal();
}
next = touch(next, entry);
- return { row: next, result: { row: row.id, from, to, round, candidate: cand } };
+ return { row: next, result: { row: row.id, from, to, round, issue: revIssue, candidate: cand } };
}
function applySet(rows, row, entry, resolved) {
@@ -588,7 +612,7 @@ export function applyEntry(state, entry, resolved) {
const out = applyMove(rows, row, entry, resolved);
rows.set(row.id, out.row);
const r = out.result;
- result = { ...r, receipt: receipt(entry, rev, `row ${row.id} ${r.from}→${r.to}${r.round ? ` round ${r.round}` : ""}`) };
+ result = { ...r, receipt: receipt(entry, rev, `row ${row.id} ${r.from}→${r.to}${r.round ? ` round ${r.round}` : ""}${r.issue ? ` on #${r.issue}` : ""}`) };
break;
}
case "release": {
diff --git a/packages/queue/src/store.mjs b/packages/queue/src/store.mjs
index 97f8784c..c3a66b8c 100644
--- a/packages/queue/src/store.mjs
+++ b/packages/queue/src/store.mjs
@@ -242,7 +242,11 @@ function writeWitness(ctx, loc, doc, bytes) {
unlinkQuiet(ctx.io, tmp);
throw err;
}
- ctx.io.fsyncDir(loc.gitDir);
+ try {
+ ctx.io.fsyncDir(loc.gitDir);
+ } catch (err) {
+ throw Object.assign(new Error(errno(err)), { code: err.code, renamed: true });
+ }
}
function confirmTail(ctx, loc, cur) {
@@ -316,6 +320,7 @@ function writeView(ctx, loc, before, parts, body, tag) {
const now = readOrNull(ctx.io, loc.viewPath);
if (now === null || !now.equals(before)) return stale;
const tmp = `${loc.viewPath}.${tag}.tmp`;
+ let renamed = false;
try {
const mode = Number(ctx.io.stat(loc.viewPath).mode & 0o777n);
unlinkQuiet(ctx.io, tmp);
@@ -326,9 +331,11 @@ function writeView(ctx, loc, before, parts, body, tag) {
return stale;
}
ctx.io.rename(tmp, loc.viewPath);
+ renamed = true;
ctx.io.fsyncDir(loc.docsDir);
return null;
} catch (err) {
+ if (renamed) return `the view is written but not confirmed durable (${errno(err)}); the op stands; after a host crash, check the table with \`${FIX} verify\``;
unlinkQuiet(ctx.io, tmp);
return `the view write failed (${errno(err)}); the op stands and the view is stale; run \`${FIX} render\``;
}
@@ -390,7 +397,8 @@ function commitWrite(ctx, loc, cur, doc, bytes, op, view, body, exclusive) {
try {
writeWitness(ctx, loc, doc, bytes);
} catch (err) {
- throw new QueueError(`uncertain ${op} rev ${rev}: durable, witness not updated (${errno(err)})`, 3);
+ const what = err.renamed ? "witness written, its directory fsync failed" : "witness not updated";
+ throw new QueueError(`uncertain ${op} rev ${rev}: durable, ${what} (${errno(err)})`, 3);
}
ctx.hook("witnessed");
const warn = writeView(ctx, loc, view.bytes, view.parts, body, op);
@@ -402,14 +410,19 @@ function withLock(ctx, loc, { op = null, verb }, fn) {
checkPlatform(ctx.io, [loc.docsDir, loc.gitDir]);
const handle = acquire({ gitDir: loc.gitDir, io: ctx.io, proc: ctx.proc, op, verb, waitMs: ctx.lockWaitMs, stepMs: ctx.lockStepMs, hook: ctx.hook });
const res = { out: [], err: [], code: 0 };
+ let failure = null;
try {
ctx.hook("locked");
fn(res);
- } finally {
- let msg;
- try { msg = release(handle, ctx.io); } catch (err) { msg = `cannot release the queue lock (${errno(err)})`; }
- if (msg) res.err.push(`warning: ${msg}`);
+ } catch (err) {
+ failure = err;
}
+ let msg;
+ try { msg = release(handle, ctx.io); } catch (err) { msg = `cannot release the queue lock (${errno(err)})`; }
+ // A refusal still reports what release found (8.4).
+ if (msg && failure instanceof Error) failure.message += `\nwarning: ${msg}`;
+ else if (msg) res.err.push(`warning: ${msg}`);
+ if (failure) throw failure;
return res;
}
@@ -763,5 +776,6 @@ export function unlock(opts, { checkGateOnly = false } = {}) {
const ctx = makeCtx(opts);
const loc = unlockLoc(ctx);
if (checkGateOnly) return { out: [checkGate({ gitDir: loc.gitDir, io: ctx.io, proc: ctx.proc }).line], err: [], code: 0 };
- return { out: [unlockLock({ gitDir: loc.gitDir, io: ctx.io, proc: ctx.proc, hook: ctx.hook })], err: [], code: 0 };
+ const [line, ...warnings] = unlockLock({ gitDir: loc.gitDir, io: ctx.io, proc: ctx.proc, hook: ctx.hook }).split("\n");
+ return { out: [line], err: warnings, code: 0 };
}
diff --git a/packages/queue/tests/commit.test.mjs b/packages/queue/tests/commit.test.mjs
index 55105163..9a1b6ec1 100644
--- a/packages/queue/tests/commit.test.mjs
+++ b/packages/queue/tests/commit.test.mjs
@@ -152,7 +152,12 @@ fi`);
assert.equal(r.blob("HEAD", "src.txt"), "src\n");
});
-test("F1: a commit whose guard ran before update-ref fails at its own HEAD update", async (t) => {
+// A commit paused in its editor after its guard passed against H. Whether
+// git holds index.lock during the editor depends on the form: git 2.55
+// doesn't for plain `commit -e` and does for `commit -e -- path`. Step 8
+// reconciles when the lock is free and exits 3 when it isn't; either way
+// the paused commit loses at its own HEAD update.
+async function pausedCommit(t, form) {
const r = ready(t);
note(r);
stageFile(r, "src.txt");
@@ -161,27 +166,38 @@ test("F1: a commit whose guard ran before update-ref fails at its own HEAD updat
const go = join(r.ctl, "editor-go");
const editor = join(r.ctl, "editor.sh");
writeFileSync(editor, `#!/bin/sh\n: > ${q(started)}\nwhile [ ! -e ${q(go)} ]; do sleep 0.05; done\necho "ordinary" > "$1"\n`, { mode: 0o755 });
- const child = spawn("git", ["-C", r.root, "commit", "-e", "-q"], { env: { ...r.env, GIT_EDITOR: editor }, stdio: ["ignore", "pipe", "pipe"] });
+ const child = spawn("git", ["-C", r.root, "commit", "-e", "-q", ...form], { env: { ...r.env, GIT_EDITOR: editor }, stdio: ["ignore", "pipe", "pipe"] });
let childErr = "";
child.stderr.on("data", (d) => { childErr += d; });
const exited = new Promise((resolve) => child.on("exit", resolve));
for (let i = 0; i < 200 && !existsSync(started); i++) sleepMs(50);
assert.ok(existsSync(started), "the editor never started");
- // The paused commit ran its guard against H and holds index.lock.
+ const locked = existsSync(join(r.gitDir, "index.lock"));
+ t.diagnostic(`git commit -e${form.map((a) => ` ${a}`).join("")}: index.lock ${locked ? "held" : "free"} during the editor`);
const res = r.qc(["-m", "queue rev 1"]);
writeFileSync(go, "");
const code = await exited;
- // git 2.55 does not hold index.lock while the editor runs, so step 8
- // reconciles; the paused commit then loses at its HEAD update.
- assert.equal(res.code, 0, res.err);
+ assert.equal(res.code, locked ? 3 : 0, `index.lock ${locked ? "held" : "free"} during the editor: ${res.err}`);
+ if (locked) assert.match(res.err, /another git process holds \.git\/index\.lock/);
const c = r.head();
assert.equal(r.g("rev-parse", "HEAD^").trim(), h);
+ assert.equal(r.revAt(c), 1);
assert.notEqual(code, 0);
assert.match(childErr, new RegExp(`cannot lock ref 'HEAD': is at ${c} but expected ${h}`));
assert.equal(r.head(), c, "the old queue landed on top of C");
+ if (locked) r.g("reset", "-q", "--", "docs/plans/queue.json", "docs/plans/QUEUE.md");
assert.equal(r.g("diff", "--cached", "--name-only").trim(), "src.txt");
r.g("commit", "-q", "-m", "ordinary");
assert.equal(r.revAt("HEAD"), 1);
+ return locked;
+}
+
+test("F1: a plain `commit -e` whose guard ran before update-ref fails at its own HEAD update", async (t) => {
+ await pausedCommit(t, []);
+});
+
+test("F1: a `commit -e -- path` whose guard ran before update-ref fails at its own HEAD update", async (t) => {
+ await pausedCommit(t, ["--", "src.txt"]);
});
test("F1: step 8 with index.lock held exits 3, and ordinary commits stay refused until the printed command runs", (t) => {
diff --git a/packages/queue/tests/data.test.mjs b/packages/queue/tests/data.test.mjs
index 2e6c019f..99080d95 100644
--- a/packages/queue/tests/data.test.mjs
+++ b/packages/queue/tests/data.test.mjs
@@ -3,8 +3,8 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
- CALLER_OP_RE, LOG_OP_RE, applyEntry, buildDoc, canonArgs, classifyView, countHeading, genesisReceipt, genesisRows,
- gitBlobId, loadDoc, nextFor, parseManifest, parseMigrationMap, render, rowsArray, serialize, sha256, splitView,
+ CALLER_OP_RE, LOG_OP_RE, STATES, applyEntry, buildDoc, canonArgs, classifyView, countHeading, genesisReceipt, genesisRows,
+ gitBlobId, loadDoc, nextFor, parseManifest, parseMigrationMap, render, rowsArray, serialize, sha256, splitView, validateRow,
} from "../src/queue.mjs";
import { QueueError } from "../src/errors.mjs";
import { MAP_ROWS, mapText } from "./helpers.mjs";
@@ -49,7 +49,7 @@ function refused(fn, re) {
assert.throws(fn, (err) => err instanceof QueueError && err.code === 2 && re.test(err.message));
}
-const mv = (id, to, extra = {}) => ({ id, to, reason: null, candidate: null, evidence: null, ...extra });
+const mv = (id, to, extra = {}) => ({ id, to, reason: null, candidate: null, evidence: null, issue: null, ...extra });
const row = (doc, id) => doc.rows.find((r) => r.id === id);
const MANIFEST = `${"c".repeat(64)} packages/queue/src/queue.mjs\n`;
const CAND = { kind: "manifest", digest: sha256(MANIFEST), text: MANIFEST };
@@ -156,7 +156,7 @@ test("matrix: release, review round, changes requested and waiting-on-jason", ()
const rv = row(d, 9).review;
assert.equal(rv.issue, 1508);
assert.deepEqual(rv.rounds.map((r) => [r.n, r.request, r.candidate.digest]), [[1, "none", CAND.digest]]);
- assert.match(d.log.at(-1).result.receipt, /in-progress→in-review round 1$/);
+ assert.match(d.log.at(-1).result.receipt, /in-progress→in-review round 1 on #1508$/);
refused(() => step(d, "move", mv(9, "in-progress"), "dewey"), /claimed by darkwing/);
d = step(d, "move", mv(9, "in-progress"), "darkwing");
assert.equal(row(d, 9).claim.seat, "darkwing");
@@ -176,9 +176,11 @@ test("matrix: release, review round, changes requested and waiting-on-jason", ()
test("matrix J5: in-review→done by the gate owner with evidence naming the current round", () => {
let d = row9Started();
d = step(d, "move", mv(9, "in-review", { candidate: "x" }), "darkwing", { candidate: CAND });
- const ev = `comment=4242,candidate=${CAND.digest}`;
- refused(() => step(d, "move", mv(9, "done"), "filbert"), /--evidence comment=<id>,candidate=<digest>/);
- refused(() => step(d, "move", mv(9, "done", { evidence: `comment=1,candidate=${"d".repeat(64)}` }), "filbert"), /is not round 1's candidate/);
+ const ev = `comment=4242,round=1,candidate=${CAND.digest}`;
+ refused(() => step(d, "move", mv(9, "done"), "filbert"), /--evidence comment=<id>,round=<n>,candidate=<digest>/);
+ refused(() => step(d, "move", mv(9, "done", { evidence: `comment=4242,candidate=${CAND.digest}` }), "filbert"), /round=<n>/);
+ refused(() => step(d, "move", mv(9, "done", { evidence: `comment=1,round=1,candidate=${"d".repeat(64)}` }), "filbert"), /is not round 1's candidate/);
+ refused(() => step(d, "move", mv(9, "done", { evidence: `comment=4242,round=2,candidate=${CAND.digest}` }), "filbert"), /evidence names round 2; row 9 is in round 1/);
refused(() => step(d, "move", mv(9, "done", { evidence: ev }), "rocko"), /only the gate owner \(filbert\)/);
const done = step(d, "move", mv(9, "done", { evidence: ev }), "filbert");
assert.equal(row(done, 9).state, "done");
@@ -187,6 +189,155 @@ test("matrix J5: in-review→done by the gate owner with evidence naming the cur
let e = step(genesisDoc(), "move", mv(11, "in-progress"), "dewey");
e = step(e, "move", mv(11, "in-review", { candidate: "x" }), "dewey", { candidate: CAND });
refused(() => step(e, "move", mv(11, "done", { evidence: ev }), "jason"), /gate is Jason's/);
+ // Changes requested, then the same candidate again: round 1's comment
+ // does not close round 2 (8.7, R3).
+ d = step(d, "move", mv(9, "in-progress"), "darkwing");
+ d = step(d, "move", mv(9, "in-review", { candidate: "x" }), "darkwing", { candidate: CAND });
+ assert.deepEqual(row(d, 9).review.rounds.map((r) => r.candidate.digest), [CAND.digest, CAND.digest]);
+ refused(() => step(d, "move", mv(9, "done", { evidence: ev }), "filbert"), /evidence names round 1; row 9 is in round 2/);
+ const done2 = step(d, "move", mv(9, "done", { evidence: `comment=4343,round=2,candidate=${CAND.digest}` }), "filbert");
+ assert.equal(done2.log.at(-1).result.round, 2);
+});
+
+// A row 12 owned by darkwing with the given issues, started, so the next
+// move is the review request.
+function row12Started(issues, { gateOwner = "filbert" } = {}) {
+ let d = step(genesisDoc(), "add", { piece: "N", gate: "g", brief: "docs/plans/brief-b.md#Queue", issues, owner: "darkwing", gateOwner }, "sage", { brief: brief() });
+ d = step(d, "move", mv(12, "briefed"), "sage");
+ return step(d, "move", mv(12, "in-progress"), "darkwing");
+}
+
+const review = (d, extra = {}) => step(d, "move", mv(12, "in-review", { candidate: "x", ...extra }), "darkwing", { candidate: CAND });
+const again = (d) => step(d, "move", mv(12, "in-progress"), "darkwing");
+
+test("review issue, lead decision 23: none refuses, one is used, several need --issue, later rounds keep it", () => {
+ // No issues: refused before the round opens.
+ refused(() => review(row12Started([])), /row 12 lists no issues; a privileged actor sets one before review/);
+ // One issue: used without --issue; --issue may name it; any other refuses.
+ const one = review(row12Started([1508]));
+ assert.equal(row(one, 12).review.issue, 1508);
+ assert.match(one.log.at(-1).result.receipt, /round 1 on #1508$/);
+ assert.equal(one.log.at(-1).result.issue, 1508);
+ refused(() => review(row12Started([1508]), { issue: 1495 }), /--issue #1495 is not one of row 12's issues \(#1508\)/);
+ // Several: --issue is required and must be one of the row's; the lowest
+ // number is not a default.
+ const several = row12Started([1495, 1508]);
+ refused(() => review(several), /row 12 lists several issues \(#1495, #1508\); name the review's issue with --issue/);
+ refused(() => review(several, { issue: 1600 }), /--issue #1600 is not one of row 12's issues \(#1495, #1508\)/);
+ let d = review(several, { issue: 1508 });
+ assert.equal(row(d, 12).review.issue, 1508);
+ // Later rounds keep the previous round's issue unless --issue names another.
+ d = review(again(d));
+ assert.deepEqual([row(d, 12).review.issue, row(d, 12).review.rounds.length], [1508, 2]);
+ d = review(again(d), { issue: 1495 });
+ assert.deepEqual([row(d, 12).review.issue, row(d, 12).review.rounds.length], [1495, 3]);
+ // A kept issue the row no longer lists refuses until --issue names one.
+ d = step(again(d), "set", { id: 12, field: "issues", value: [1508, 1600] }, "sage");
+ refused(() => review(d), /review issue #1495 is no longer one of its issues \(#1508, #1600\); name one with --issue/);
+ assert.equal(row(review(d, { issue: 1600 }), 12).review.issue, 1600);
+ // --issue belongs to the review request only.
+ refused(() => step(several, "move", mv(12, "blocked", { reason: "x", issue: 1508 }), "darkwing"), /--issue applies only to in-progress→in-review/);
+});
+
+test("the row schema refuses a review with a null issue", () => {
+ const r = structuredClone(row(review(row12Started([1508])), 12));
+ validateRow(r);
+ r.review.issue = null;
+ refused(() => validateRow(r), /review issue must be a positive integer/);
+});
+
+// R1: every state × target × actor class against 8.7's table, written from
+// the spec rather than from queue.mjs. Row 12 is owned by darkwing; the
+// gate owner is filbert, jason or the owner; rocko is any other seat.
+const ACTORS = ["darkwing", "filbert", "rocko", "sage", "jason"];
+const PRIV = new Set(["sage", "jason"]);
+
+function specAllows({ from, prev, to, by, gateOwner, required }) {
+ const own = by === "darkwing";
+ const priv = PRIV.has(by);
+ if (from === "done") return false;
+ if (to === "blocked") return ["queued", "briefed", "in-progress", "in-review", "waiting-on-jason"].includes(from) && (own || priv);
+ if (from === "blocked") return to === prev && (own || priv);
+ const edge = `${from}→${to}`;
+ switch (edge) {
+ case "queued→briefed": return priv;
+ case "briefed→in-progress": return own;
+ case "in-progress→in-review": return own; // the claimant is the owner
+ case "in-review→in-progress": case "in-review→waiting-on-jason": return own || priv;
+ case "waiting-on-jason→done": return by === "jason" || by === "sage"; // sage with evidence, which the case supplies
+ case "in-review→done": return gateOwner !== "jason" && (by === gateOwner || priv);
+ case "queued→parked": case "briefed→parked": return by === "jason" && !required;
+ case "parked→queued": return by === "jason";
+ default: return false; // in-progress→briefed is `release`, not `move`
+ }
+}
+
+function matrixStates(gateOwner, required) {
+ let q = step(genesisDoc(), "add", {
+ piece: "M", gate: "g", brief: "docs/plans/brief-b.md#Queue", issues: [1508], owner: "darkwing", gateOwner, required,
+ }, "sage", { brief: brief() });
+ const out = [{ from: "queued", doc: q }];
+ if (!required) out.push({ from: "parked", doc: step(q, "move", mv(12, "parked"), "jason") });
+ const b = step(q, "move", mv(12, "briefed"), "sage");
+ const p = step(b, "move", mv(12, "in-progress"), "darkwing");
+ const r = review(p);
+ const w = step(r, "move", mv(12, "waiting-on-jason"), "sage");
+ out.push({ from: "briefed", doc: b }, { from: "in-progress", doc: p }, { from: "in-review", doc: r }, { from: "waiting-on-jason", doc: w });
+ out.push({ from: "done", doc: step(w, "move", mv(12, "done"), "jason") });
+ for (const s of [...out]) {
+ if (!["done", "parked"].includes(s.from)) out.push({ from: "blocked", prev: s.from, doc: step(s.doc, "move", mv(12, "blocked", { reason: "r" }), "sage") });
+ }
+ return out;
+}
+
+function matrixArgs(from, to) {
+ const extra = {};
+ if (to === "blocked") extra.reason = "r";
+ if (from === "in-progress" && to === "in-review") extra.candidate = "x";
+ if (to === "done" && from === "in-review") extra.evidence = `comment=1,round=1,candidate=${CAND.digest}`;
+ if (to === "done" && from === "waiting-on-jason") extra.evidence = "Jason approved in thread X";
+ return mv(12, to, extra);
+}
+
+test("matrix R1: every state × target × actor class matches 8.7, gate owner jason or not, required or not", () => {
+ let allowed = 0;
+ let refusals = 0;
+ for (const gateOwner of ["filbert", "jason", "darkwing"]) {
+ for (const required of [false, true]) {
+ for (const { from, prev = null, doc } of matrixStates(gateOwner, required)) {
+ assert.equal(row(doc, 12).state, from);
+ for (const to of STATES) {
+ for (const by of ACTORS) {
+ const c = { from, prev, to, by, gateOwner, required };
+ const label = JSON.stringify(c);
+ let got;
+ try {
+ got = step(doc, "move", matrixArgs(from, to), by, { candidate: CAND });
+ } catch (err) {
+ assert.ok(err instanceof QueueError && err.code === 2, `${label}: ${err.stack}`);
+ assert.equal(specAllows(c), false, `${label} refused: ${err.message}`);
+ refusals++;
+ continue;
+ }
+ assert.equal(specAllows(c), true, `${label} was allowed`);
+ const r = row(got, 12);
+ assert.equal(r.state, to, label);
+ if (to === "blocked") assert.equal(r.previousState, from, label);
+ if (from === "blocked") assert.deepEqual([r.previousState, r.blockedReason], [null, null], label);
+ allowed++;
+ }
+ }
+ // release: the claimant or a privileged actor, from in-progress only.
+ for (const by of ACTORS) {
+ const ok = from === "in-progress" && (by === "darkwing" || PRIV.has(by));
+ const label = JSON.stringify({ release: from, by, gateOwner, required });
+ if (ok) assert.deepEqual([row(step(doc, "release", { id: 12 }, by), 12).state], ["briefed"], label);
+ else assert.throws(() => step(doc, "release", { id: 12 }, by), (err) => err instanceof QueueError && err.code === 2, label);
+ }
+ }
+ }
+ }
+ assert.ok(allowed > 100 && refusals > 1000, `allowed ${allowed}, refused ${refusals}`);
});
test("matrix: blocked keeps the claim and returns only to previousState", () => {
@@ -301,7 +452,7 @@ test("next: resume, then review, then start, then wait, then nothing; lowest id
let d = genesisDoc();
const add = (owner, extra = {}) => ({ piece: `p-${owner}`, gate: "g", brief: "docs/plans/brief-b.md#Queue", owner, ...extra });
d = step(d, "add", add("rocko"), "sage", { brief: brief() }); // 12
- d = step(d, "add", add("rocko", { reviewers: ["darkwing"] }), "sage", { brief: brief() }); // 13
+ d = step(d, "add", add("rocko", { reviewers: ["darkwing"], issues: [1508] }), "sage", { brief: brief() }); // 13
d = step(d, "add", add("darkwing"), "sage", { brief: brief() }); // 14
for (const id of [12, 13, 14]) d = step(d, "move", mv(id, "briefed"), "sage");
const rows = () => loadDoc(Buffer.from(serialize(d))).state.rows;
diff --git a/packages/queue/tests/lock.test.mjs b/packages/queue/tests/lock.test.mjs
index 9b6b2174..f1b109d9 100644
--- a/packages/queue/tests/lock.test.mjs
+++ b/packages/queue/tests/lock.test.mjs
@@ -89,6 +89,26 @@ test("a link error other than EEXIST refuses", (t) => {
assert.deepEqual(readdirSync(d), []);
});
+test("an error after the link releases the lock: unreadable gate, failing temp stat", (t) => {
+ const d = dir(t);
+ writeFileSync(join(d, GATE_NAME), record({ verb: "unlock", op: null }));
+ const denied = { ...realIo, readFile: (p) => (p.endsWith(GATE_NAME) ? (() => { throw Object.assign(new Error("denied"), { code: "EACCES" }); })() : realIo.readFile(p)) };
+ refused(() => acquire({ gitDir: d, io: denied, verb: "move" }), /cannot check the unlock gate .*EACCES; lock released$/, 1);
+ assert.deepEqual(readdirSync(d), [GATE_NAME]);
+ rmSync(join(d, GATE_NAME));
+ const badStat = { ...realIo, stat: () => { throw Object.assign(new Error("io"), { code: "EIO" }); } };
+ refused(() => acquire({ gitDir: d, io: badStat, verb: "move" }), /cannot write the lock record .*EIO; no lock taken/, 1);
+ assert.deepEqual(readdirSync(d), []);
+ // A stat that fails from its second call on: publish stats once, before the
+ // link, so nothing after the link can fail and strand the lock.
+ let stats = 0;
+ const lateStat = { ...realIo, stat: (p) => { if (++stats > 1) throw Object.assign(new Error("io"), { code: "EIO" }); return realIo.stat(p); } };
+ const h = acquire({ gitDir: d, io: lateStat, verb: "move" });
+ assert.equal(stats, 1);
+ assert.equal(release(h, realIo), null);
+ assert.deepEqual(readdirSync(d), []);
+});
+
test("a paused holder: another writer waits 10 s, then refuses naming it live", async (t) => {
const d = dir(t);
const child = spawn(process.execPath, [join(HERE, "fixtures", "lock-child.mjs"), d, "hold"], { stdio: ["ignore", "pipe", "ignore"] });
@@ -150,6 +170,22 @@ test("a writer publishing during an unlock, gate first: the writer releases and
assert.deepEqual(readdirSync(d), []);
});
+test("a gate swapped while held is left in place and reported, on success and on refusal (N1)", (t) => {
+ const d = dir(t);
+ const gate = join(d, GATE_NAME);
+ // A copy renamed over the gate: same bytes, a new inode.
+ const swap = () => { writeFileSync(`${gate}.copy`, readFileSync(gate)); renameSync(`${gate}.copy`, gate); };
+ const out = unlock({ gitDir: d, io: realIo, hook: (name) => { if (name === "gate-held") swap(); } });
+ assert.match(out, /^no queue lock present; nothing removed\nwarning: lock .*mosaic-queue\.unlock is not the one this process took; left in place$/);
+ assert.equal(existsSync(gate), true);
+ rmSync(gate);
+ writeFileSync(join(d, LOCK_NAME), record({}));
+ refused(() => unlock({ gitDir: d, io: realIo, hook: (name) => { if (name === "gate-held") swap(); } }),
+ /owner is live: .*; unlock refuses\nwarning: lock .*mosaic-queue\.unlock is not the one this process took; left in place$/);
+ assert.equal(existsSync(gate), true);
+ assert.equal(existsSync(join(d, LOCK_NAME)), true);
+});
+
test("a reused pid within one boot is mismatch; unlock removes the lock and never signals the process", async (t) => {
const d = dir(t);
const s = await sleeper(t);
@@ -177,6 +213,12 @@ test("a foreign host is unknown whatever the local pid says; unlock refuses", as
refused(() => acquire({ gitDir: d, io: realIo, verb: "move", waitMs: 0 }), /unknown: .*recorded on host some-other-host; unlock refuses this too/);
refused(() => unlock({ gitDir: d, io: realIo }), /owner is unknown: .*; unlock refuses/);
assert.equal(existsSync(join(d, LOCK_NAME)), true);
+ // A real foreign host has its own boot id. Host is tested before boot, so
+ // this is still unknown, never mismatch, and unlock still refuses.
+ writeFileSync(join(d, LOCK_NAME), record({ pid: await deadPid(), host: "some-other-host", boot: OTHER_BOOT }));
+ assert.equal(classify(readFileSync(join(d, LOCK_NAME)), realProc).state, "unknown");
+ refused(() => unlock({ gitDir: d, io: realIo }), /owner is unknown: .*recorded on host some-other-host/);
+ assert.equal(existsSync(join(d, LOCK_NAME)), true);
});
test("unreadable /proc: classification is unknown and acquire refuses", (t) => {
diff --git a/packages/queue/tests/store.test.mjs b/packages/queue/tests/store.test.mjs
index 7033b076..70425a73 100644
--- a/packages/queue/tests/store.test.mjs
+++ b/packages/queue/tests/store.test.mjs
@@ -166,10 +166,28 @@ test("Rocko's S4 schedule: a lost result, another writer, then the retry opens n
const review = ["move", "9", "in-review", "--candidate", "HEAD", "--op", "review-9-00001"];
cli(repo, review, { by: "darkwing" }); // result lost
ok(cli(repo, ["note", "9", "looking now", "--op", "note-9-000001"], { by: "filbert" }));
- ok(cli(repo, review, { by: "darkwing" }), /round 1 \(already recorded at rev 3\)/);
+ ok(cli(repo, review, { by: "darkwing" }), /round 1 on #1508 \(already recorded at rev 3\)/);
assert.equal(row(repo, 9).review.rounds.length, 1);
});
+test("the review issue and the evidence round through the CLI (lead decision 23, 8.7)", (t) => {
+ const repo = ready(t);
+ ok(cli(repo, ["move", "6", "blocked", "--reason", "paused", "--op", "block-6-00001"], { by: "darkwing" }));
+ ok(cli(repo, ["set", "9", "issues", "1495,1508", "--op", "issues-9-0001"], { by: "sage" }));
+ ok(cli(repo, ["move", "9", "in-progress", "--op", "start-9-00001"], { by: "darkwing" }));
+ const review = (op, ...extra) => ["move", "9", "in-review", "--candidate", "HEAD", "--op", op, ...extra];
+ no(cli(repo, review("review-9-00001"), { by: "darkwing" }), 2, /lists several issues \(#1495, #1508\); name the review's issue with --issue/);
+ no(cli(repo, review("review-9-00001", "--issue", "1495", "--issue", "1508"), { by: "darkwing" }), 4, /one --issue/);
+ no(cli(repo, review("review-9-00001", "--issue", "#1600"), { by: "darkwing" }), 2, /--issue #1600 is not one of row 9's issues/);
+ ok(cli(repo, review("review-9-00001", "--issue", "#1508"), { by: "darkwing" }), /in-progress→in-review round 1 on #1508$/m);
+ const head = repo.g("rev-parse", "HEAD").trim();
+ no(cli(repo, ["move", "9", "done", "--evidence", `comment=7,candidate=${head}`, "--op", "done-9-000001"], { by: "filbert" }), 2, /round=<n>/);
+ ok(cli(repo, ["move", "9", "in-progress", "--op", "changes-9-0001"], { by: "darkwing" }));
+ ok(cli(repo, review("review-9-00002"), { by: "darkwing" }), /round 2 on #1508$/m);
+ no(cli(repo, ["move", "9", "done", "--evidence", `comment=7,round=1,candidate=${head}`, "--op", "done-9-000001"], { by: "filbert" }), 2, /evidence names round 1; row 9 is in round 2/);
+ ok(cli(repo, ["move", "9", "done", "--evidence", `comment=8,round=2,candidate=${head}`, "--op", "done-9-000002"], { by: "filbert" }), /in-review→done round 2$/m);
+});
+
test("claims and add defaults through the CLI; candidates are manifests or reachable commits", (t) => {
const repo = ready(t);
ok(cli(repo, ["add", "--op", "add-by-dewey-1", "--piece", "Mine", "--gate", "tests", "--brief", "docs/plans/brief-b.md#Template"], { by: "dewey" }));
diff --git a/packages/queue/tests/write.test.mjs b/packages/queue/tests/write.test.mjs
index 0d0781c0..35e9829a 100644
--- a/packages/queue/tests/write.test.mjs
+++ b/packages/queue/tests/write.test.mjs
@@ -3,7 +3,7 @@
// racing a writer (8.4, F2).
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
-import { readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
+import { readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { test } from "node:test";
import { cli, genesisCommitted, load, scratchRepo } from "./helpers.mjs";
@@ -41,6 +41,7 @@ function faultIo(m, name, match, code) {
return {
...real,
openExcl: (p, mode) => { const fd = real.openExcl(p, mode); paths.set(fd, p); return fd; },
+ openRead: (p) => { const fd = real.openRead(p); paths.set(fd, p); return fd; },
close: (fd) => { paths.delete(fd); real.close(fd); },
write: (fd, b, off, len) => (hit("write", paths.get(fd)) ? (code === "SHORT" ? 0 : fail()) : real.write(fd, b, off, len)),
fsync: (fd) => (hit("fsync", paths.get(fd)) ? fail() : real.fsync(fd)),
@@ -114,6 +115,65 @@ test("a witness write failure: uncertain, durable, exit 3; the view is untouched
assert.match(m.store.mutate(o(repo), note(9, "x", "note-9-000001")).out[0], /already recorded at rev 1/);
});
+test("the .git fsync after the witness rename fails: uncertain, exit 3, the witness says so", async (t) => {
+ const { repo, m } = await ready(t);
+ const io = faultIo(m, "fsyncDir", (d) => d === repo.gitDir, "EIO");
+ throwsCode(() => m.store.mutate(o(repo, { io }), note(9, "x", "note-9-000001")), 3, /^uncertain note-9-000001 rev 1: durable, witness written, its directory fsync failed \(EIO\)$/);
+ assert.equal(revOf(repo), 1);
+ assert.equal(witness(repo).revision, 1);
+ assert.equal(shownRev(repo), 0);
+ assert.match(m.store.mutate(o(repo), note(9, "x", "note-9-000001")).out[0], /already recorded at rev 1/);
+});
+
+test("confirming a tail fsyncs queue.json and docs/plans before the witness; either failure changes nothing", async (t) => {
+ const { repo, m } = await ready(t);
+ const docs = join(repo.root, "docs/plans");
+ throwsCode(() => m.store.mutate(o(repo, { io: faultIo(m, "fsyncDir", (d) => d === docs, "EIO") }), note(9, "x", "note-9-000001")), 3, /uncertain/);
+ for (const io of [faultIo(m, "fsync", (p) => p === repo.queuePath, "EIO"), faultIo(m, "fsyncDir", (d) => d === docs, "EIO")]) {
+ throwsCode(() => m.store.sync(o(repo, { io })), 1, /^cannot confirm rev 1 durable \(EIO\); nothing changed$/);
+ assert.equal(witness(repo).revision, 0);
+ }
+ assert.match(m.store.sync(o(repo)).out.join("\n"), /durable now, never acknowledged: note-9-000001/);
+ assert.equal(witness(repo).revision, 1);
+});
+
+test("the docs/plans fsync after the view rename fails: the op stands, the view is written, a warning says so", async (t) => {
+ const { repo, m } = await ready(t);
+ const docs = join(repo.root, "docs/plans");
+ let calls = 0;
+ const io = { ...m.io.realIo, fsyncDir: (d) => { if (d === docs && ++calls === 2) throw Object.assign(new Error("EIO"), { code: "EIO" }); m.io.realIo.fsyncDir(d); } };
+ const r = m.store.mutate(o(repo, { io }), note(9, "x", "note-9-000001"));
+ assert.equal(calls, 2);
+ assert.match(r.out[0], /^ok note-9-000001 rev 1/);
+ assert.match(r.err.join("\n"), /the view is written but not confirmed durable \(EIO\); the op stands/);
+ assert.equal(shownRev(repo), 1);
+ assert.deepEqual(tmps(repo), []);
+});
+
+test("a lock swapped while held is left in place and reported, on a receipt and on a refusal", async (t) => {
+ const { repo, m } = await ready(t);
+ const lock = join(repo.gitDir, "mosaic-queue.lock");
+ // Another inode with the same bytes, as a delayed unlock and relock would leave.
+ const swap = (name) => { if (name === "locked") { writeFileSync(`${lock}.copy`, readFileSync(lock)); renameSync(`${lock}.copy`, lock); } };
+ const done = m.store.mutate(o(repo, { hook: swap }), note(9, "x", "note-9-000001"));
+ assert.match(done.out[0], /^ok note-9-000001 rev 1/);
+ assert.match(done.err.join("\n"), /warning: lock .* is not the one this process took; left in place/);
+ unlinkSync(lock);
+ throwsCode(() => m.store.mutate(o(repo, { hook: swap }), note(9, "y", "note-9-000002", "rocko")), 2,
+ /may note row 9[^]*\nwarning: lock .* is not the one this process took; left in place$/);
+ unlinkSync(lock);
+});
+
+test("unlock prints a swapped gate's warning on stderr, the result on stdout", async (t) => {
+ const { repo, m } = await ready(t);
+ const gate = join(repo.gitDir, "mosaic-queue.unlock");
+ const swap = (name) => { if (name === "gate-held") { writeFileSync(`${gate}.copy`, readFileSync(gate)); renameSync(`${gate}.copy`, gate); } };
+ const r = m.store.unlock(o(repo, { hook: swap }));
+ assert.deepEqual(r.out, ["no queue lock present; nothing removed"]);
+ assert.match(r.err.join("\n"), /^warning: lock .*mosaic-queue\.unlock is not the one this process took; left in place$/);
+ unlinkSync(gate);
+});
+
test("a view write that fails keeps the op and reports a stale view", async (t) => {
const { repo, m } = await ready(t);
const io = faultIo(m, "rename", (p) => p === repo.viewPath, "EIO");
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# N13 check (#1508): a failing nested `node --test` must fail test-foundation.sh
# and test-discord.sh even when a parent runner's NODE_TEST_CONTEXT is set.
# Runs in a scratch clone only: n13-check.sh CLONE (a clone of HEAD with
# node_modules linked). It copies this checkout's two suites into the clone,
# plants a failing test in each suite's test directory, and restores after.
set -uo pipefail
SRC="$(cd "$(dirname "$0")/../../../.." && pwd)"
CLONE="${1:?usage: n13-check.sh CLONE}"
[ "$(cd "$CLONE" && pwd)" != "$SRC" ] || { echo "refusing to run in the canonical checkout" >&2; exit 4; }
cd "$CLONE" || exit 1
PLANT='import { test } from "node:test";
import assert from "node:assert/strict";
test("N13 planted failure", () => assert.equal(1, 2));'
FAILS=0
expect() { # NAME WANT GOT
if [ "$2" = "$3" ]; then echo "ok $1 (rc $3)"; else echo "FAIL $1 (want rc $2, got $3)"; FAILS=$((FAILS+1)); fi
}
run() { # SUITE -> rc, output in /tmp/n13-<suite>-<tag>.txt
NODE_TEST_CONTEXT=child-v8 NO_COLOR=1 "scripts/test-$1.sh" >"/tmp/n13-$1-$2.txt" 2>&1
}
for pair in foundation:scripts/foundation discord:packages/discord/tests; do
suite=${pair%%:*} dir=${pair#*:}
git checkout -q -- "scripts/test-$suite.sh"
printf '%s\n' "$PLANT" >"$dir/zz-n13-planted.test.mjs"
run "$suite" head-planted; expect "$suite at HEAD, planted failure, parent context set" 0 $?
cp -p "$SRC/scripts/test-$suite.sh" "scripts/test-$suite.sh"
run "$suite" fixed-planted; expect "$suite fixed, planted failure, parent context set" 1 $?
rm -f "$dir/zz-n13-planted.test.mjs"
run "$suite" fixed-clean; expect "$suite fixed, no planted failure, parent context set" 0 $?
sed -i 's/node_tests() { env -u NODE_TEST_CONTEXT node --test/node_tests() { node --test/' "scripts/test-$suite.sh"
run "$suite" mutant-clean; expect "$suite with the clearing removed, no planted failure" 1 $?
git checkout -q -- "scripts/test-$suite.sh"
done
git status --short
echo "n13 check: $FAILS failed"
[ "$FAILS" -eq 0 ]
+59
View File
@@ -0,0 +1,59 @@
# N13: nested `node --test` in two suites (#1508)
Darkwing, 2026-09-26. Filbert's note N13, put in DEFERRED by Sage as a
small reviewed item before A2. Nothing is committed, staged or pushed.
## The defect
`scripts/test-foundation.sh:76` and `scripts/test-discord.sh:142` start a
nested `node --test` without clearing `NODE_TEST_CONTEXT`. Under a parent
test runner the nested run reports to that runner and exits 0 whatever its
tests do. I reproduced it at HEAD 40a02d2b: with a failing test planted in
each suite's test directory and `NODE_TEST_CONTEXT=child-v8` set, both
suites exit 0. The check line reads `OK node --test ... (summary
missing)`. In a control run of foundation without the variable, the same
planted test fails the suite, so only a run under a parent runner is
blind. I ran that control for foundation only.
## The change
`n13.patch` (sha256 00868b2f) changes only the two suites, +20 −2 lines.
Each suite now has a `node_tests` function that runs `env -u
NODE_TEST_CONTEXT node --test`, and uses it for its test run. Each also
gets one new check. It writes a failing test into the sandbox, runs it
through `node_tests` with `NODE_TEST_CONTEXT=child-v8` set, and requires
exit 1 and `✖ planted failure` in the output. If someone drops the `env
-u`, that check fails on every run, not only under a parent runner.
| File | sha256 |
|---|---|
| `scripts/test-foundation.sh` | 60f04822 |
| `scripts/test-discord.sh` | 2ad3be74 |
| `n13-check.sh` | 6a231759 |
## The check
`n13-check.sh CLONE` runs in a scratch clone only and refuses the
canonical checkout. For each suite it plants a failing test in the real
test directory and runs the whole suite with `NODE_TEST_CONTEXT=child-v8`:
| Suite | Case | Exit |
|---|---|---|
| foundation | HEAD, planted failure | 0 (the defect) |
| foundation | fixed, planted failure | 1 |
| foundation | fixed, no planted failure | 0 |
| foundation | fixed but `env -u` removed, no planted failure | 1 |
| discord | HEAD, planted failure | 0 (the defect) |
| discord | fixed, planted failure | 1 |
| discord | fixed, no planted failure | 0 |
| discord | fixed but `env -u` removed, no planted failure | 1 |
In the fixed runs with the planted failure, the suite prints `FAIL node
--test ...` with the pass count and the planted test's name. The last row
of each is the mutation: the new check alone fails the suite.
All nine suites pass at 40a02d2b with this change and the queue A1
candidate: foundation 44 and discord 64, one more check each than before.
I found no other nested `node --test` in the suites. `test-queue.sh` and
`queue-commit.sh` already clear the variable.
+44
View File
@@ -0,0 +1,44 @@
diff --git a/scripts/test-discord.sh b/scripts/test-discord.sh
index 6044100c..9ec4ddda 100755
--- a/scripts/test-discord.sh
+++ b/scripts/test-discord.sh
@@ -139,7 +139,16 @@ else
fi
# --- the seven offline groups ---
-node --test --test-reporter=spec packages/discord/tests/ >"$SANDBOX/node-test.log" 2>&1
+# A nested `node --test` inherits a parent runner's NODE_TEST_CONTEXT, reports
+# to that runner and exits 0 whatever its tests do, so the suite clears it
+# (#1508 N13). The planted failing test proves a failure still fails here.
+node_tests() { env -u NODE_TEST_CONTEXT node --test "$@"; }
+mkdir -p "$SANDBOX/planted"
+printf '%s\n' 'import { test } from "node:test";' 'import assert from "node:assert/strict";' 'test("planted failure", () => assert.equal(1, 2));' >"$SANDBOX/planted/planted.test.mjs"
+NODE_TEST_CONTEXT=child-v8 node_tests "$SANDBOX/planted/" >"$SANDBOX/planted.log" 2>&1
+[ $? -eq 1 ] && grep -q '^✖ planted failure' "$SANDBOX/planted.log"
+check "a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT" $?
+node_tests --test-reporter=spec packages/discord/tests/ >"$SANDBOX/node-test.log" 2>&1
NODE_RC=$?
check "node --test packages/discord/tests/ ($(grep -E '^ℹ pass' "$SANDBOX/node-test.log" | tr -d '\n' || echo 'summary missing'))" $NODE_RC
if [ "$NODE_RC" -ne 0 ]; then
diff --git a/scripts/test-foundation.sh b/scripts/test-foundation.sh
index 251eb674..b94dab5c 100755
--- a/scripts/test-foundation.sh
+++ b/scripts/test-foundation.sh
@@ -73,7 +73,16 @@ done
check "checked-in demo bundles equal a fresh generation" $DEMO_OK
# --- unit, CLI, privacy, non-effect and fixture-index tests ---
-node --test scripts/foundation/ >"$SANDBOX/node-test.log" 2>&1
+# A nested `node --test` inherits a parent runner's NODE_TEST_CONTEXT, reports
+# to that runner and exits 0 whatever its tests do, so the suite clears it
+# (#1508 N13). The planted failing test proves a failure still fails here.
+node_tests() { env -u NODE_TEST_CONTEXT node --test "$@"; }
+mkdir -p "$SANDBOX/planted"
+printf '%s\n' 'import { test } from "node:test";' 'import assert from "node:assert/strict";' 'test("planted failure", () => assert.equal(1, 2));' >"$SANDBOX/planted/planted.test.mjs"
+NODE_TEST_CONTEXT=child-v8 node_tests "$SANDBOX/planted/" >"$SANDBOX/planted.log" 2>&1
+[ $? -eq 1 ] && grep -q '^✖ planted failure' "$SANDBOX/planted.log"
+check "a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT" $?
+node_tests scripts/foundation/ >"$SANDBOX/node-test.log" 2>&1
NODE_RC=$?
check "node --test scripts/foundation/ ($(grep -E '^ℹ pass' "$SANDBOX/node-test.log" | tr -d '\n' || echo 'summary missing'))" $NODE_RC
[ "$NODE_RC" -ne 0 ] && grep -E "^✖|AssertionError" "$SANDBOX/node-test.log" | head -20
+159
View File
@@ -0,0 +1,159 @@
# Queue A1 (#1508), revision r1
Darkwing, 2026-09-26. This answers Filbert's review
(`agents/filbert/work/queue-a1-review-2026-09-26.md`, sha256 6933b885) and
Sage's lead decision 23 (40a02d2b). Nothing is committed, staged or pushed.
## Files
| File | sha256 | What it is |
|---|---|---|
| `delta-r1.patch` | b733b894 | the change on top of `build.patch` |
| `build-manifest-r1.sha256` | 85a8a453 | all 20 files after the delta |
The delta changes 11 of the 20 files, +410 −49 lines. It adds no file and changes no mode.
In a fresh clone at 3a209eea, `build.patch` and then `delta-r1.patch` apply
cleanly, and the result matches the new manifest 20/20.
## Required changes
**R1, the matrix.** `data.test.mjs` has a new test, "matrix R1". Its
oracle, `specAllows`, is written from 8.7's table, not from `queue.mjs`.
The test replays a row into every reachable state and tries every target
in `STATES` as five actors: darkwing (the row's owner), filbert, rocko,
sage and jason. It does this with the gate owner as filbert, jason and the
row's owner, and with the row required and not. Release gets the same
treatment. That is 273 allowed moves and 2487 refusals, each compared with
what `applyOp` does. Your R1 mutation and the agent's two survivors each
fail it (R1a to R1c below).
**R2, the review issue, as lead decision 23 rules.** `move in-review`
refuses a row with no issues. A row with one issue uses it. A row with
several needs `--issue N`, and N must be one of them. A later round keeps
the previous round's issue unless `--issue` names another. `--issue` is
accepted only on in-progress→in-review, and giving it twice is a usage
error (exit 4). The receipt now ends `round N on #ISSUE`.
The ruling didn't cover one case: a later round whose kept issue the row
no longer lists, after a `set issues`. I refuse it until `--issue` names
one of the row's issues. Falling back to the first issue would be the
silent choice decision 23 replaced.
The row schema now refuses `review.issue: null`, so a hand-built file
can't hold one either. `set piece` and `set gate` stay privileged only
(decision 23, point 2); the code already did that, and nothing changed.
Tests: "review issue, lead decision 23" in `data.test.mjs` covers the four
cases and a refused `--issue` that isn't in the row. "the review issue and
the evidence round through the CLI" in `store.test.mjs` runs the same
through the CLI. "the row schema refuses a review with a null issue"
checks `validateRow`. Mutations D1 to D5.
**R3, the evidence round.** The format is now
`comment=<id>,round=<n>,candidate=<digest>`. `move done` compares the
round with the current one and refuses `evidence names round 1; row 12 is
in round 2`. The J5 test refuses evidence with no round and with a wrong
round, then sends a row back and re-requests it with the same candidate:
round-1 evidence is refused and round-2 evidence closes it. Mutation E1.
## Notes I took
- **N1.** `withLock` appends the release warning to a refusal's message.
`unlock` now does the same for the gate: a swapped gate is left in place
and reported, on a refusal and on success. On success the CLI prints the
warning on stderr and the result on stdout. Mutations W1, G1, G2, U1.
- **N2.** In `acquire`, an error checking the gate releases the lock, then
refuses with `cannot check the unlock gate ...; lock released`. In
`publish`, the temp file's stat now runs before the link, so nothing
that can fail runs between a successful link and the return. The test
covers an unreadable gate, a stat that fails before the link, and a stat
that fails from its second call on. The last case came late. L1 (a
second stat after the link) survived my first mutation run, so I added
it; L1 is now caught.
- **N3.** `write.test.mjs` has three fault tests: the `queue.json` and
`docs/plans` fsyncs in `confirmTail` (sync exits 1, nothing changes);
the `.git` fsync after the witness rename (exit 3); the `docs/plans`
fsync after the view rename (the op stands, a warning says the view
isn't confirmed durable). Mutations F1 to F4.
- **N4.** The foreign-host test adds a record with another boot id. It
must still classify `unknown`. Mutation H1 swaps the two checks.
- **N6.** The message now says `durable, witness written, its directory
fsync failed` when the rename happened, and `witness not updated` only
when it didn't.
- **N9.** `BRIEF-TEMPLATE.md`: `briefed` when a privileged actor (jason or
sage) accepts it; the owner can't.
- **N14.** You were right. The paused-editor test is now `pausedCommit(t,
form)`, which checks whether `index.lock` exists at the pause and
asserts on that: exit 3 and `another git process holds .git/index.lock`
when held, exit 0 when free. Either way the paused commit then fails
with `cannot lock ref 'HEAD': is at C but expected H`. Two forms run on
git 2.55.0: plain `commit -e` (the lock was free, exit 0) and `commit -e
-- src.txt` (the lock was held, exit 3). The test no longer pins a git
version, and the stale comment is gone.
## A correction to build.md
build.md says "`unlock` works on a missing or invalid lock file". That's
wrong, as you said. It means a missing or invalid `queue.json`. `unlock`
refuses an invalid lock. build.md stays as sent, since your review pins
it.
## Notes not taken
N5, N7, N8, N10, N11, N12, N15 and N16. They don't block, and none is in
the files this round had to touch for a reason. N8 (the `\` escape in
`cell()`) and N11 (replay looser than the CLI on op ids) are cheapest
before genesis. That's Sage's call; I can take them in A2.
## Verification
At 3a209eea plus the candidate (`/tmp/qa1-verify`): `test-queue.sh` 19
checks, `node --test` 107/107 (data 22, lock 19, store 19, write 25,
commit 22), `verify` skipped because HEAD has no `queue.json`.
At today's HEAD, 40a02d2b, plus the candidate and the N13 change
(`/tmp/n13-verify`): config 24, task 90, foundation 44, conductor 17,
release 14, auth 15, discord 64, extension-package 18, queue 19 with
107/107. Foundation and discord each gained one check from N13. I reran
the queue suite there after the last change; the other suites can't reach
`packages/queue`.
The canonical `.git` is unchanged: `.git/hooks` holds only samples, no
`mosaic-queue*` file, and `git config --show-scope --get-all
core.hooksPath` returns nothing (rc 1). Every run was in a `--shared`
clone under `/tmp`.
### Mutations
Each mutation went into the verify clone, the queue tests ran, and the
file was restored from the candidate. All 20 files matched the candidate
after each run. The number is how many tests failed.
| Id | Mutation | Failing tests |
|---|---|---|
| R1a | drop the owner check on unblock | 1 |
| R1b | let the owner move waiting-on-jason→in-progress | 1 |
| R1c | check the owner on block only when not queued | 1 |
| D1 | allow review with no issues | 1 |
| D2 | require `--issue` with one issue | 9 |
| D3a | take the first of several issues | 2 |
| D3b | accept an `--issue` the row doesn't list | 2 |
| D4a | never keep the previous round's issue | 2 |
| D4b | keep an issue the row no longer lists | 1 |
| D5 | schema allows a null review issue | 1 |
| E1 | ignore the evidence round | 2 |
| L1 | stat the temp again after the link | 1 |
| L2 | don't release the lock when the gate check fails | 1 |
| F1 | drop `confirmTail`'s `queue.json` fsync | 1 |
| F2 | drop `confirmTail`'s `docs/plans` fsync | 1 |
| F3 | drop the `.git` fsync after the witness rename | 1 |
| F4 | drop the `docs/plans` fsync after the view rename | 1 |
| W1 | drop the release warning on a refusal | 1 |
| H1 | check boot before host | 1 |
| G1 | drop the gate warning on a refusal | 1 |
| G2 | drop the gate warning on success | 2 |
| U1 | print the gate warning nowhere in the CLI | 1 |
R1a to E1 ran before the last lock and store changes, which touch neither
`queue.mjs` nor the tests that caught them. L1 to U1 ran on the final
candidate. L1 first survived with 0 failures, as noted under N2.
@@ -0,0 +1,21 @@
b18afc13db533b5cbfac8701234b25dde87c9c1b4fda970e738faf87b7a6d324 packages/queue/README.md
a1e72713398a3f706e4beb5ad012798ab6a55f2f76c2f3144db94011a45a3231 packages/queue/src/cli.mjs
962d0e6548121933030980b937f55ca5913ee3859ff2b8888095bd273dbf561e packages/queue/src/io.mjs
05682738482a56001bac168168a6aeb7e624f8ed1661a6c6592274f13da38c89 packages/queue/src/lock.mjs
b87756a89c3bdeb03bddd1b1e51706ae93975b01f008237076ffe34557a71de3 packages/queue/src/queue.mjs
b7708293363d33093a1106c2346c26ce5d48e052342abbec17c59ee058a4fb05 packages/queue/src/store.mjs
70a11be54d8bddd3db0fd52555a1bdf481efef0cae671ee8e1aab546e3ddd36a packages/queue/tests/data.test.mjs
b1c90f99eb2b5005ebc461dab5793164b80d7e846573f788606040ef88f5819e packages/queue/tests/helpers.mjs
e7c9b23b6aa28321754c1c649f8bd5ee2c97bfea0d4e180ae699a4a1f5c60c76 packages/queue/tests/lock.test.mjs
42120f0009815bedd3887b10e3630a694711825e4dee29a475733dbd212a49eb packages/queue/tests/store.test.mjs
f79195520a1108ce5748ad6833eb03d2bb3ef97d74d83807ee1079e461ebb1f4 packages/queue/tests/write.test.mjs
86c2a4f806e99fd09baf623dc7b3c95c1a630d147fe89c4fe5b93793fa4c5f50 scripts/mosaic
b6aec15a75305bb48bca20a665bd919111a0071ffd7a1cc3631df2beaaac5a1b scripts/test-queue.sh
dc32c0e0a865fe6b3623a808c4e9d8e8b284a3e74c36b3519554ea02d3a3ee5f packages/queue/tests/dispatch.test.mjs
0d83ab1e73663de0f3da719078e7d756b8ec8f828ea63c2ce446bf21fb27c754 packages/queue/tests/migration.test.mjs
3306b486be165b88c554ba13283cab4c79a5b67fbe3c2b6bfe72f987e1eddf07 packages/queue/tests/fixtures/genesis-render.md
fb43d5855726e517f0eaacd626debb110e8ea6c948c97b35ad8971ec0885d151 packages/queue/tests/fixtures/mosaic-pre-a2.sh
8f1927f3f11f260e71e7235a69efcbf10c8fd62bf263ddb89c3029f5e61833b7 packages/queue/tests/fixtures/queue-marked.md
012ddce99d03932512d79b6ae7ba74786e050469e800cfb00fda683a2dfe42a8 agents/darkwing/work/queue-migration-map.md
cdc49c447504f6c9f1c40da9ec0d89bf4d21c5e2a0730b1f12d1f20fbea63edb agents/darkwing/work/queue-a2/map-check.mjs
b2a738f0da52783dc8e8a7c6033ce62582582e587eb6e5b6bde988ea4b406f3e agents/darkwing/work/queue-a2/carry-forward.md
+202
View File
@@ -0,0 +1,202 @@
# Queue A2 (#1508), candidate for review
Darkwing, 2026-09-27. A2 is migration, render and dispatch (lead decision
20), plus every item Sage carried forward from A1's review (decision 26:
N5, N8, N10, N11, N12, P1, P2, P3). Base is HEAD c9539baa; A1 is 34a72af9.
Filbert reviews. Sage commits, then installs the hook and runs genesis.
Nothing is committed, staged or pushed.
## Files
`build.patch` (sha256 `dc0be7ce74aaaaaf43deebe68af439d1b2b80c77aad6538420e929de35e2f21f`) changes 13 files and adds 8.
`build-manifest.sha256` (sha256 `782bcb62e555659333a35888d418d986da63bdf522f38cafa447db7ddd0074b7`) pins all 21 after the patch.
In a fresh clone at c9539baa the patch applies and the result matches
the manifest 21/21, file modes included. `git apply` warns about one
blank line at the end of `fixtures/genesis-render.md`. It belongs there:
the render ends with one, and the golden has to match byte for byte.
- `packages/queue/src/`: `queue.mjs` (N8, N10, N11, P2), `store.mjs` (N12,
N11's shared check, P1's helper, P3's caller), `lock.mjs` (P1, P3),
`io.mjs` (N5), `cli.mjs` (usage comment).
- `scripts/mosaic`: `queue` execs `packages/queue/src/cli.mjs` with the
remaining arguments. Every other call reaches the seat CLI as before.
- `scripts/test-queue.sh`: syntax checks for `scripts/mosaic` and the new
fixture; `scripts/mosaic queue help` always; after genesis, `verify` and
`render --check` on the live queue through `scripts/mosaic queue`.
- `packages/queue/README.md`: the dispatch, N5, N7, N8, N10, N12, P2, the
map and `map-check.mjs`, two new test files.
- Tests: 107 in A1, 122 now. New: `dispatch.test.mjs` (3),
`migration.test.mjs` (3), and 9 more in data, lock, store and write.
Fixtures: `mosaic-pre-a2.sh` (the script before A2), `queue-marked.md`
(QUEUE.md with the markers), `genesis-render.md` (the golden render).
- `agents/darkwing/work/queue-migration-map.md`: the genesis input.
- `agents/darkwing/work/queue-a2/map-check.mjs`: the drift check.
- `agents/darkwing/work/queue-a2/carry-forward.md`: the item list Sage
confirmed (773dbd75), with the r1 section added after it.
Outside the patch, for Sage to apply (condition 2 keeps them out of A2):
- `queue-md.patch` (sha256 `2ca8f689e1dcda5bb30e9af4c3f867242d5239d72e13001369c7b7aca7956402`): the two markers, a header that points
seats at `scripts/mosaic queue next`, and a line freezing the old log of
table changes. It applies to HEAD.
- `tools-md.patch` (sha256 `c53aea1f6e871e17d04335ef60250adcdbe64f6466d9ff641042c00094d1c3a8`): a "Work queue" section in
`docs/TOOLS.md`. It applies to HEAD. Optional; the README already has
the detail.
## The five conditions
1. **Nothing ran against the canonical `.git`.** Tests use scratch
repositories under the temp directory. The dry run below used
`/tmp/qa2-dry`. Checked after all runs: no `mosaic-queue*` file in
`.git/`, `.git/hooks/pre-commit` absent, `core.hooksPath` unset in every
scope, nothing staged.
2. **No QUEUE.md, AGENTS.md or TOOLS.md edits.** The two proposals are
patch files. `git status` shows none of the three modified.
3. **`scripts/test-queue.sh` is green at a HEAD with no `queue.json`:**
24/24, the live checks skipped. After the dry-run genesis it ran 26/26, with `verify` and
`render --check` on the live queue.
4. **H is recorded before the canary.** `queue-commit.sh` is unchanged
from A1, and its test "F1: H is recorded before the canary, so HEAD
moving during the canary makes update-ref fail" passes.
5. **The fault layer is reachable only from tests.** N5 narrows this
further: tmpfs was the one fault-free path open to the CLI, and now
only a layer with `allowTmpfs: true` gets it. The test asserts
`realIo` has no such key and that `"yes"` doesn't count.
## Carried-forward items
| Item | Change | Test |
|---|---|---|
| N8 | `piece`, `gate`, `note`, move `reason`, brief anchor and `blockedReason` refuse `\` and `<`, at the CLI and in replay | "text the table shows refuses \ and <, everywhere it enters"; "every accepted text renders to nine cells on every row" (GFM's cell rule, no `marked` import) |
| N11 | Replay holds every log entry, round op and claim op to `CALLER_OP_RE` and refuses `.outcome`, genesis and `accept-history` included. `LOG_OP_RE` stays for Piece D's derived ids; no verb derives one yet | "replay holds every op id to the caller's rule" |
| N10 | `set issues` moves `closes` only if it equalled the old issues; a narrowed `closes` keeps its intersection, and the receipt says `(kept narrowed)` | "set issues keeps a logged narrowing of closes" |
| P2 | Each round records `issue`; `review` is `{rounds}` only. A later round keeps the last round's issue | "the row schema refuses a round with a null issue, and the A1 review shape", and A1's R2 tests updated |
| P1 | Both gate paths in `acquire` release through `releaseOrWarn`; a failed release is a message naming the lock, not a stack trace | "a release that fails on a gate path is reported, never a stack trace" |
| P3 | `lock.unlock` returns `{result, warning}`; nothing splits on newlines | "unlock keeps a multi-line lock record on stdout" |
| N5 | tmpfs left `FS_TYPES`; only `allowTmpfs === true` admits it | "tmpfs passes only a test layer that allows it" |
| N12 | `--by` still wins; a different non-empty `MOSAIC_AGENT_NAME` adds a stderr warning on success and on refusal. Nothing is logged | "--by that differs from MOSAIC_AGENT_NAME warns on stderr and logs nothing more" |
The rest, as `carry-forward.md` records and decision 26 confirmed: N7 is a
README line (the leftover `.git/mosaic-queue.lock.<pid>.<hex>.tmp` is
removed by hand). N15, N16, N13-a and ext2/ext3 are won't-do.
## Migration
The map (`queue-migration-map.md`) is built from QUEUE.md blob c8e3d34e,
which HEAD has. `node agents/darkwing/work/queue-a2/map-check.mjs` prints
`ok: QUEUE.md matches the map (blob c8e3d34e)`. Run it again just before
genesis. It exits 1 and lists the rows if QUEUE.md moved.
map-check did its job once already. HEAD moved from 8dba3ff7 to c9539baa
while I worked, and it reported row 5 (the CHAT-03 brief pinned, lead
decision 33). I rebuilt row 5's note, `queue-md.patch`, the marked fixture
and the golden render against the new blob. No other row changed.
The map's "Choices Sage should check" has eight items. None blocks review.
The ones that change what genesis writes: row 7 stays a live row (decision
27), row 10's owner stays `coordinator` with `queue assign 10 sage`
recommended as the first op, rows 12 and 13 have Sage as gate owner, row 16
stays `waiting-on-jason` unless #1510 is closed, and rows 9 to 12 close
nothing so row 13 closes #1508. Rows 9 to 13 have no `after`: decision 29
took the wait on row 6 off them, and `after: 9 done` on row 10 would wait
on a gate that needs row 10.
Dry run in a shared clone (`/tmp/qa2-dry`), the candidate plus
`queue-md.patch` and `tools-md.patch` committed on top of c9539baa. I
reran it from scratch after the rebase:
0. `map-check.mjs`: `ok: QUEUE.md matches the map (blob c8e3d34e)`.
1. `scripts/test-queue.sh`: 24/24, live checks skipped.
2. `scripts/queue-commit.sh --install-hook --by sage`: installed; canary
passed.
3. `scripts/mosaic queue genesis --root /tmp/qa2-dry --branch a2-dry --map
… --op genesis-2026-09-27 --by sage`: `ok genesis-2026-09-27 rev 0
genesis 30 rows`.
4. `scripts/queue-commit.sh --genesis`: committed.
5. `verify --current`: briefs match HEAD. `render --check`: current.
`legacyView` equals the marked body byte for byte, and `mapBlob` is the
map's blob.
6. `next`: darkwing resumes 9, sage resumes 7, dewey resumes 5, filbert
and rocko have nothing.
7. `scripts/test-queue.sh`: 26/26 with the live checks.
8. `queue assign 10 sage --op assign-10-dry --by sage`: `ok … rev 1 row 10 owner:
coordinator→sage`.
The dry clone's hook is `.git/hooks/pre-commit`, mode 0755, blob
abdf14e7. `core.hooksPath` is unset there too.
## Choices I made
- **N8 refuses instead of escaping.** An escape has to be right for every
Markdown renderer that reads QUEUE.md; a refusal doesn't. No line in
today's table has either character.
- **P2 drops `review.issue`.** The last round's issue is the kept one, so
a second copy could only disagree. Piece D reads `rounds[].issue`.
- **N10's edge.** A `closes` narrowed to nothing stays empty whatever the
new issues are. `set closes` with a reason is the way to widen it again.
- **N12 warns on a refusal too,** so a seat that mistyped `--by` sees it on
the error it was reading anyway.
- **`scripts/mosaic queue` uses `exec`,** so exit codes and signals are the
queue CLI's own. Any first argument other than exactly `queue` goes to
the seat CLI, as before; the dispatch test compares argv, cwd,
environment and stdin against the pre-A2 script.
## Mutations
Each mutation ran alone in a shared clone of the candidate, with
`node --test packages/queue/tests/`. A killed mutation fails at least one
test.
46 mutations. Each one below failed at least one test; the number is
how many.
| Item | Mutations |
|---|---|
| N8 | backslash 2, lt 2, noteVerb 1, rowNote 2, anchor 1, reason 1 |
| N11 | entryShape 1, outcome 2, genesisOnly 1, roundOp 1, claimOp 1 |
| N10 | always 2, noIntersect 1, receipt 1 |
| P2 | keptFirst 1, noCheck 1, reviewIssue 10 |
| P1 | gateErr 1, gatePresent 1, gatePresentMsg 1, withLock 1 |
| P3 | joined 2, storeSplit 1 |
| N5 | inTypes 1, truthy 1, anyType 1 |
| N12 | noWarnOk 1, noWarnErr 1, envWins 1, emptyEnv 1 |
| Dispatch | noShift 1, unsetArg 1, prefix 1, noExec 1 |
| Golden render | mapGate 1, mapState 1 |
| map-check | fixed26 1, owner 1, issues 1, lineDiff 1, parkedPiece 1 |
Five of them survived the first pass. Each now has a test that kills it:
- N11 roundOp and claimOp (replay stops checking a review round's op or a
claim's op). The N11 test now builds a claimed row and a reviewed row and
refuses an op of 73 characters and one ending `.outcome` in each.
- P1 withLock (the post-op release goes back to the throwing `release`).
New write test: the lock's unlink fails with EACCES after the op, and the
receipt and a refusal both carry "cannot release the queue lock (EACCES)".
- N12 emptyEnv (an empty `MOSAIC_AGENT_NAME` warns). The N12 test now runs
with it empty and expects no stderr.
- Dispatch noExec (`node` without `exec`, so a successful queue call falls
through to the seat CLI). The dispatch test now runs a queue call that
exits 0 and checks that only the queue CLI ran.
The map and golden-render mutations ran again after the rebase and were
still killed.
## Suites
All nine green at the final candidate: config 24, task 90, foundation
44, conductor 17, release 14, auth 15, discord 64, extension-package 18,
queue 24 (no `queue.json` at HEAD). `node --test packages/queue/tests/`:
122/122.
## After approval (Sage)
1. Check the canonical tree against `build-manifest.sha256`, then commit
the 21 files by path.
2. Apply `queue-md.patch` (and `tools-md.patch` if wanted) and commit.
3. `node agents/darkwing/work/queue-a2/map-check.mjs` must print `ok`.
4. `scripts/queue-commit.sh --install-hook --by sage`.
5. `scripts/mosaic queue genesis --root /mnt/storage/src/mosaic-stack
--branch refactor --map agents/darkwing/work/queue-migration-map.md
--op <id> --by sage`.
6. `scripts/queue-commit.sh --genesis -m MSG`.
7. `scripts/test-queue.sh` runs the live checks from here on.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
# Queue A2 (#1508): items carried from A1 review
Darkwing, 2026-09-26. This file records Sage's ruling on A1 r1 so it isn't
lost before A2 starts. A2's brief lists the two required items. A2's build
note repeats each disposition. Note numbers are Filbert's, from
`agents/filbert/work/queue-a1-review-2026-09-26.md` (sha256 6933b885).
## Required in A2, with tests
Both change what replay accepts or what the table shows, so they land
before genesis. Genesis follows A2.
- **N8. `cell()` escapes `|` but not `\`.** Piece text `a\| done | x`
renders so that `marked` splits it into an extra cell. Raw HTML passes
through too.
- The change: refuse `\` and `<` in rendered text fields at the CLI and
in replay. A refusal holds up better than an escape we would have to
get right for every Markdown renderer. None of the 34 table lines in
today's QUEUE.md contains either character, so the migration loses
nothing.
- The test: every text field with `\`, `<` and `a\| done | x` is
refused. A render of the allowed characters splits, by GFM's cell
rule, into the same number of cells on every row. `marked` is only a
transitive dependency in this repo, so the test doesn't import it.
- The mutation: allow `\`, and the cell-count test must fail.
- **N11. Replay is looser than the CLI on op ids.** `LOG_OP_RE` allows 80
characters for any entry, `accept-history` may end in `.outcome`, and
the genesis op isn't checked.
- The change: replay applies `CALLER_OP_RE` to every op a caller chose.
It allows the longer form only for the op ids the CLI derives. It
refuses `.outcome` on `accept-history` and pattern-checks the genesis
op.
- The test: a hand-built file with each of the three refused shapes
fails replay, and every op id the CLI writes still replays.
- The mutations: restore each looser check in turn.
## Dispositions of the other notes
| Note | Disposition | Reason |
|---|---|---|
| N5 tmpfs accepted outside tests; `0xef53` also matches ext2 and ext3 | A2: tmpfs becomes a test-only option, like the other fault options. ext2 and ext3: won't do | `statfs` can't tell ext2, ext3 and ext4 apart. The README names ext4, and the canonical checkout is ext4. |
| N7 temp files from killed acquires stay in `.git/` | A2: README line only | The files are small, carry the dead pid in their name, and never block a lock. Removing them safely needs the same liveness check `unlock` has, which isn't worth it for the space involved. |
| N10 `set issues` resets `closes`, undoing a logged narrowing | A2: fix with a test | It changes replayed state, so it lands before genesis. New rule: `closes` becomes the new issues only if it equalled the old issues. Otherwise it keeps its intersection with the new issues, and the log entry says so. |
| N12 `--by` silently overrides `MOSAIC_AGENT_NAME` | A2: stderr warning, no log field | Both values are self-asserted (J2), so a logged mismatch proves nothing a seat can't avoid. A warning catches the honest mistake, a typo or the wrong seat's shell. |
| N15 `--install-hook --by` is self-asserted | Won't do | J2. It's protocol: Sage runs the install at bootstrap. A check on a claimed name adds nothing. |
| N16 the hook refuses the first commit on an unborn HEAD | Won't do | The hook is installed only in the canonical checkout, which has history. It fails closed. |
Sage confirmed this file as written (773dbd75) on 2026-09-26. N5, N10 and
N12 stay in A2. N10 has to land before genesis because it changes replayed
state.
## From Filbert's r1 approval
Filbert approved A1 r1 and N13 on 2026-09-26 (review
`agents/filbert/work/queue-a1-review-r1-2026-09-26.md`, sha256 e464be6c).
He listed these as non-blocking and suitable for A2. The dispositions
below are my proposal, and Sage rules on them.
| Note | Proposed disposition | Reason |
|---|---|---|
| P1 `acquire` calls `release()` unguarded on both gate paths | A2: fix with a test | If release throws (EACCES on `.git`), the CLI prints a stack trace and doesn't say the lock stayed. The fix guards it the way `withLock` does and names the lock left behind. |
| P2 `--issue` on a later round overwrites `review.issue`; rounds don't record their own issue | A2: each round records its issue, with a test | Piece D posts per round, and the round's own issue is the evidence of where it posted. It changes the round schema and replayed state, so it lands before genesis, like N10. |
| P3 `unlock` splits its result on newlines, so a hand-formatted record spills onto stderr | A2: fix with a test | `lock.mjs`'s `unlock` returns the result and the warning separately, so nothing is split on text. |
| N13-a `n13-check.sh` copies the suites from the canonical tree, not from the pinned patch | Won't do | N13 is approved on `n13.patch` and the two suite hashes, and Filbert applied the patch himself. The check script is evidence, not shipped code. |
N13-b is Sage's: check the canonical working tree against both pins
before committing from it.
@@ -0,0 +1,80 @@
#!/usr/bin/env node
// Usage: node agents/darkwing/work/queue-a2/map-check.mjs [QUEUE.md] [MAP]
//
// Run before genesis. The map names the QUEUE.md blob it was built from.
// This lists every table line that differs from that blob, then every row
// whose piece, owner or issues no longer match the map. Reads only; the
// one git call is `cat-file`. Exit 0 no drift, 1 drift, 4 usage.
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const top = join(dirname(fileURLToPath(import.meta.url)), "../../../..");
const { parseMigrationMap } = await import(join(top, "packages/queue/src/queue.mjs"));
// Table lines by id: `| N | ...` rows, then the parked table's items
// numbered on from the highest row, as the map numbers them.
export function tableLines(text) {
const out = new Map();
const items = [];
let parked = false;
for (const line of text.split("\n")) {
if (line.startsWith("## ")) parked = line.startsWith("## Parked");
const m = /^\| (\d+) \|/.exec(line);
if (m && !parked) out.set(Number(m[1]), line);
else if (parked && line.startsWith("| ") && !line.startsWith("| Item ") && !line.startsWith("|---")) items.push(line);
}
let next = Math.max(0, ...out.keys()) + 1;
for (const line of items) out.set(next++, line);
return out;
}
const cells = (line) => line.slice(2, -2).split(" | ");
export function check(queueText, mapText, oldText) {
const drift = [];
const now = tableLines(queueText);
const then = tableLines(oldText);
for (const id of new Set([...now.keys(), ...then.keys()])) {
if (now.get(id) !== then.get(id)) drift.push(`row ${id}: ${!then.has(id) ? "added" : !now.has(id) ? "removed" : "changed"} since the map's QUEUE.md blob`);
}
const map = parseMigrationMap(mapText);
const byId = new Map(map.rows.map((r) => [r.id, r]));
for (const [id, line] of now) {
const r = byId.get(id);
if (!r) { drift.push(`row ${id}: in QUEUE.md, not in the map`); continue; }
const c = cells(line);
if (!/^\d+$/.test(c[0])) {
if (c[0] !== r.piece) drift.push(`row ${id}: parked item ${JSON.stringify(c[0])} is not the map's piece`);
continue;
}
if (c[1] !== r.piece) drift.push(`row ${id}: piece differs from the map`);
const owner = /^[a-z][a-z0-9-]*/.exec(c[2])?.[0];
if (owner !== r.owner) drift.push(`row ${id}: owner ${owner} in QUEUE.md, ${r.owner} in the map`);
const issues = [...c[3].matchAll(/#(\d+)/g)].map((x) => Number(x[1]));
if (issues.join() !== r.issues.join()) drift.push(`row ${id}: issues ${issues.join(",") || "none"} in QUEUE.md, ${r.issues.join(",") || "none"} in the map`);
}
for (const id of byId.keys()) if (!now.has(id)) drift.push(`row ${id}: in the map, not in QUEUE.md`);
return drift;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const args = process.argv.slice(2);
if (args.length > 2) {
console.error("usage: map-check.mjs [QUEUE.md] [MAP]");
process.exit(4);
}
const queueText = readFileSync(args[0] ?? join(top, "docs/plans/QUEUE.md"), "utf8");
const mapText = readFileSync(args[1] ?? join(top, "agents/darkwing/work/queue-migration-map.md"), "utf8");
const blob = /QUEUE\.md` blob `([0-9a-f]{40})`/.exec(mapText)?.[1];
if (!blob) {
console.error("the map names no QUEUE.md blob");
process.exit(1);
}
const oldText = execFileSync("git", ["-C", top, "cat-file", "blob", blob]).toString("utf8");
const drift = check(queueText, mapText, oldText);
for (const d of drift) console.log(d);
console.log(drift.length ? `${drift.length} differences; update the map before genesis` : `ok: QUEUE.md matches the map (blob ${blob.slice(0, 8)})`);
process.exit(drift.length ? 1 : 0);
}
@@ -0,0 +1,65 @@
diff --git a/docs/plans/QUEUE.md b/docs/plans/QUEUE.md
index c8e3d34..dd155c6 100644
--- a/docs/plans/QUEUE.md
+++ b/docs/plans/QUEUE.md
@@ -1,29 +1,32 @@
# QUEUE — the one task list
-Read this first. One row per piece. Nobody needs to read the prose plans to
-know what is next; the Brief column says which section to open only when you
-are the owner of that row.
+Read this first. One row per piece. The table between the markers is
+rendered from `docs/plans/queue.json`, and `scripts/mosaic queue` is its only
+writer. Don't edit the table by hand: `queue verify` and the commit hook
+refuse a table that isn't the render. `packages/queue/README.md` has the verbs.
How to find your next thing:
-- **Jason**: the first row whose State starts with `Jason:`.
-- **A seat**: the first row where Owner is you and State is `in progress`,
- `in review` or `briefed`. If there is none, you have nothing; say so on the
- board and stop.
-- **Sage, project lead** (Jason's ruling 2026-09-26; Darkwing before that): update this table at every gate, before
- anything else is written. CURRENT.md is the narrative log; this table wins
+- **A seat**: run `scripts/mosaic queue next`. It names the row to resume,
+ review or start, or says there is nothing; if nothing, say so on the board
+ and stop.
+- **Jason**: rows in state `waiting-on-jason`, and parked rows to reopen.
+- **Sage, project lead** (Jason's ruling 2026-09-26): changes the queue with
+ `scripts/mosaic queue` and commits `queue.json` with QUEUE.md through
+ `scripts/queue-commit.sh`. CURRENT.md is the narrative log; the queue wins
if they disagree.
-States: `queued` (no brief yet) → `briefed` (brief written, not started) →
-`in progress` → `in review` → `Jason: <what he must do>` → `done`. `parked`
-means not before the rows above it and not without Jason's say. `required`
-means it cannot be parked or reordered below `queued` rows; only Jason moves it.
+States: `queued` (brief exists, not accepted) → `briefed` → `in-progress` →
+`in-review` → `waiting-on-jason` → `done`. `blocked` returns to the state it
+left. `parked` rows wait for Jason to reopen them. A `required` row can't be
+parked, and only Jason clears the flag. `after` names the rows a piece waits
+for.
-Brief locations: "plan page" is `docs/plans/2026-09-12_control-board-mvp.md`.
Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
## Pieces (in order)
+<!-- mosaic-queue:begin -->
Lead: Sage from 2026-09-26 (Jason's ruling); Darkwing is a collaborating seat.
Jason is preparing the target for the next phase; until it arrives, the
priority below stands.
@@ -79,8 +82,13 @@ Gate F or when blocked."
| Console features outside the refined session-chat brief, including Fresh creation and model switching | Deferred by WEBUI Q1; required history/control/stop now belong to row 5, not this parked item | `2026-09-13_webui-session-chat.md` |
| Open gaps from the MVP work | Fixed only when a gate needs them | DEFERRED.md, "Open" |
+<!-- mosaic-queue:end -->
+
## Log of table changes
+Frozen at genesis. Since then the log in `docs/plans/queue.json` records every
+change; the entries below are history.
+
- 2026-09-13 — created; rows 1 to 8 taken from CURRENT.md, DEFERRED.md and the plan page.
- 2026-09-13 — rows 9 to 13 added (#1508): the process itself becomes data with one writer and ledger checks. Jason: "I want this iron-clad." Required, not parked; rule: these rows cannot be moved to parked, only to done.
@@ -0,0 +1,29 @@
diff --git a/docs/TOOLS.md b/docs/TOOLS.md
index 5f993af0..02e0de9e 100644
--- a/docs/TOOLS.md
+++ b/docs/TOOLS.md
@@ -147,6 +147,24 @@ npm-global `mosaic` CLI; run by path. Exit codes: the launch script's own
once it runs; before that 1 could not start, 2 invalid config or seat, 4
usage. Details and the record's fields: `packages/seat/README.md`.
+## Work queue (`scripts/mosaic queue`)
+
+```bash
+scripts/mosaic queue list | show ID | next [SEAT]
+scripts/mosaic queue add|move|release|assign|note|set ... --op ID [--by NAME]
+scripts/mosaic queue verify [--current] | render [--check] | sync | unlock [--check-gate]
+scripts/queue-commit.sh -m MSG
+```
+
+`docs/plans/queue.json` holds the rows and an append-only log; the table in
+`docs/plans/QUEUE.md` between the `mosaic-queue` markers is rendered from it.
+Canonical checkout only. Every change needs an `--op ID` chosen before the
+first attempt and reused on retry; only an op whose `ok <op> rev N` receipt
+printed is done. The lead commits queue changes with `scripts/queue-commit.sh`;
+the pre-commit guard refuses any other commit that stages the two queue files.
+Exit codes: `0` ok · `1` failed · `2` invalid or refused · `3` uncertain,
+retry the same op · `4` usage. Details: `packages/queue/README.md`.
+
## Discord connector (`scripts/discord.sh`)
```bash
+247
View File
@@ -0,0 +1,247 @@
# Queue D (#1508, row 12), candidate for review, round 2
Darkwing, 2026-09-27. Piece D is review requests, per plan section 8.9
(`agents/filbert/work/queue-as-data-plan-2026-09-26.md`). Moving a row
with reviewers to in-review posts one request comment on its issue as the
acting seat, and the log records what happened. The row closes on the
reviewers' recorded verdicts. The candidate also carries the DEFERRED
item Sage attached to rows 12 and 13: `test-queue.sh` skips its live checks
outside the canonical root.
Base is 8ffbd73b. The patch applies unchanged to HEAD cdcedb27, which
since then has changed only `lead-decisions.md`, `QUEUE.md` and
`queue.json`. Filbert reviews D. The helper patch is separate
(`helper.md`); Sage approved it in lead decision 39, and it ships in the
D commit. Sage commits. Nothing is committed, staged or pushed.
Round 2 fixes Filbert's C1
(`agents/filbert/work/queue-d-review-r1-2026-09-27.md`): on a request
round, in-review→waiting-on-jason now makes the same checks as
in-review→done. See "Waiting-on-jason on a request round" below.
## Files
`build.patch` (sha256
`28d0790e797e1f24f295f70b2e0162bb1b4b0a154b220c0527f7d6624f0aa1c8`) changes 7 files and adds 3.
`build-manifest.sha256` (sha256
`af57ead2697a88d74a9214783dfa35ba3bd171aa32a594584f018181e261e956`) pins all 10 after the
patch. In a fresh clone at cdcedb27 the patch applies and the result
matches the manifest 10/10.
- `packages/queue/src/review.mjs` (new): the credential check, the request
body, the helper call under the deadline, and the reading of each
answer.
- `packages/queue/src/queue.mjs`: `SEMANTICS` 2, the five review verbs,
the round, attempt and receipt shapes, and the rules for moves and done.
- `packages/queue/src/store.mjs`: the request step after the move is
logged, the resolve check, `review verify-commit`, and the retry and
settle hints.
- `packages/queue/src/cli.mjs`: the `review` subcommands.
- `packages/queue/README.md`: a "Review requests" section, verbs, exit 3,
known limits, tests.
- `scripts/test-queue.sh`: the live checks skip outside the canonical root.
- Tests: `review.test.mjs` (new, 20 tests), `fixtures/fake-gitea.mjs`
(new), `fixtures/kill-at.mjs` (a step can be `NAME#N`, the Nth time it is
reached), `store.test.mjs` (two A2 tests set row 9's reviewers to none
first, so their moves post nothing; a note there moves from filbert to
sage, since filbert is no longer a reviewer). 122 tests at A2, 142 now.
Outside the patch:
- `helper.patch` (sha256 `48edd46b93c54908e9d59737aab78a33e007ddfafa1e6ad4d34333015bb75430`): raw per-seat token files in
`scripts/gitea-api.sh`, lead decision 37. Rocko reviewed two rounds,
and Sage approved the result (decisions 38 and 39); notes in
`helper.md`. D's tests don't need it, but the live round does.
- `tools-md.patch` (sha256 `d30d65b842bd56077ccd9164eccd946f2aa9f53290d02eba61f284960d230e14`): `docs/TOOLS.md`, the review
commands and the raw token file. It is Sage's file, so it's a proposal.
## How a request goes
1. **Intent.** `move ID in-review --candidate C --op OP` on a row with
reviewers logs the round and its first attempt, `requesting`, under the
lock, like any other op. If that write doesn't finish, nothing is sent.
2. **Pre-send.** Without the lock: the credential file check (`lstat`
only), then `GET user`, which must return the acting seat's login, or
`jarvis` for sage. A failure here is `failed`, and the POST never runs.
3. **POST** one comment on the round's issue through
`scripts/gitea-api.sh`, under `timeout -s KILL 30`. The body carries
two markers: `<!-- mosaic-queue-op: OP -->` and
`<!-- mosaic-queue-round: row=N round=R candidate=DIGEST -->`.
4. **Outcome.** A second entry, `OP.outcome`, under the lock. It holds the
HTTP status, the comment id and a fixed detail string. Nothing from the
response body is logged or printed.
Exit 0 is posted, 1 is failed (400, 401, 403, 404, 422, or pre-send), and
3 is uncertain (anything else). A same-op retry prints the logged state and
sends nothing. An uncertain or `requesting` attempt prints where to look
and the two commands that settle it.
## Choices I made
- **The CLI shape of `resolve`.** The plan has
`review resolve ID --attempt REQOP --posted <id>`. I made it
`review resolve ID REQOP --comment N`, and abandon takes the attempt the
same way. The attempt is always required, and `--comment` is the flag
name `record` uses for a comment id too.
- **Resolve checks the author.** The plan lists issue, marker, round and
candidate. I added the comment's author: it must be the requester's
login, fetched with the resolver's own token. Otherwise anyone could
copy the markers into a comment of their own.
- **Done on a request round** needs an approval recorded by every listed
reviewer in the current round, and refuses `--evidence`. If the reviewers
were removed after the round opened, done refuses until a privileged
actor sets them. A round from before D, or on a row with no reviewers,
closes with `--evidence` as in A2.
- **Waiting-on-jason on a request round** (round 2, C1). The plan's table
let the owner move in-review→waiting-on-jason with no other check, so a
Jason-gated row could reach Jason, and then close, with no reviewer's
verdict. The move now refuses while a request is unresolved, and on a
request round it needs an approval recorded by every listed reviewer.
`requireApprovals` in `queue.mjs` holds the approval checks that done
and this move share. A round with no request is unchanged, so v1 replay
is too. Filbert approved round 2 and asked for one more check (n1): an
approval from an earlier round doesn't count in the current one. The
Jason-gated test now has rocko approve round 2 first, and the move
refuses until filbert approves round 2 as well. Jason gets no exemption, because the owner makes this move.
In-review→in-progress, the changes path, is unchanged.
- **The owner records no verdict,** even when listed as a reviewer.
- **A late outcome on a done row.** `review-outcome` is the one entry a
done row accepts, so a POST that answers after the row closed is still
recorded. It can only set `conflict`; nothing reopens the row, and
`review resolve` on it refuses without fetching the comment.
- **A late `uncertain` after a resolve** keeps `posted`: the resolve saw
the comment. A late `failed` after a resolve is `conflict`, because the
server said it refused a comment someone found.
- **Semantics per entry.** Every log entry already records `semantics`.
Entries at 1 replay under A2's rules, so the live log (rev 13, all
semantics 1) loads unchanged. Review verbs need 2, checked in the entry
shape and again in apply.
- **Body limit.** A body over 60,000 bytes is a pre-send `failed`, and
nothing is sent. A 700-line manifest is enough to reach it.
- **`verify-commit` on a commit candidate** compares every path the
candidate changed against its first parent, by blob and mode, and
requires the paths it deleted to be absent in REF. A `diff-tree` line it
can't parse refuses with exit 1 instead of being skipped.
- **The claim refusal message.** `ownerOrPriv` read "darkwing cannot
resolve a request on". It now names the owner and the privileged actors
in every case, with the claim first when there is one. No A2 test
matched the old text; the resolve test pins the new one.
- **`test-queue.sh`.** After genesis it reads `canonicalRoot` from
`HEAD:docs/plans/queue.json` and compares it with the real path of the
toplevel. If they differ, it prints
`skip queue verify and render --check: this checkout (TOP) is not the queue's canonical root (CANON)`
and passes. An empty `canonicalRoot` is a failure.
## Tests
`review.test.mjs` runs each case in a scratch repository with genesis
committed. `scripts/gitea-api.sh` there is a stub that runs
`fixtures/fake-gitea.mjs`: same argv and output as the helper, calls
logged to a file, rules from a scenario file, and posted comments served
back by id. The token files are dummies under the scratch directory. No
test reads a real token or opens `~/.mosaic` or `~/.t3`.
Against the test list in 8.9:
| 8.9 asks for | Test |
|---|---|
| a kill before the POST, after it, at the outcome write, while retaking the lock | "a same-op retry after a kill sends nothing, even with a stale view" (steps `pre-send`, `posted`, `outcome`, `locked#2`), and "a held lock at the outcome exits 3" |
| posted; each listed 4xx; 5xx; request failed; timeout; 201 without an id | "each transport answer maps to posted, failed or uncertain" (deadline 300 ms) |
| a new op while `requesting` or `uncertain` refuses | "an unresolved request blocks a new request, a new round, waiting-on-jason and done", which also reaches waiting-on-jason with a late POST still out, and Jason's close refuses |
| (round 2, C1) a Jason-gated row skips its reviewers | "a Jason-gated row reaches waiting-on-jason only on every reviewer's approval": Filbert's steps refuse, then pass after both reviewers approve a later round; reviewers removed refuse too |
| a same-op retry after a stale view sends nothing | the kill test |
| a late POST after abandon; a late outcome after resolve, same id and another | "late outcomes": rounds 1 to 3, plus a late 500 (stays posted) and a late 422 (conflict); "a late POST on a closed row" (abandoned, approved and closed while the POST was out) |
| resolve with the wrong issue, marker, round or candidate | "resolve checks the comment", which adds author, id, 404, 500, a transport failure and the wrong credential |
| `GET user` mismatch and timeout | "the pre-send checks" |
| the lead posts as jarvis; `sage` as a login refuses | "the pre-send checks" and "the lead's request refuses a token for login sage" |
| the credential checks: mode, another seat's path, the default file | "the credential file" (also unset, relative, missing, symlink, a linked directory, and jarvis's path for sage) |
| request, changes, new candidate, approval, pinned | "request, changes, a new candidate, approval", which also checks no file lands under `docs/plans/reviews/` or `agents/*/work/` |
Every test that retries, kills or answers late counts the POSTs the fake
saw. None sees a second POST for one attempt.
## Mutations
61 hand-written mutants: 29 in `queue.mjs`, 16 in `store.mjs`, 16 in
`review.mjs`. Each runs alone against the whole queue test directory.
Against the round 2 tests, 59 are killed, the same result as round 1.
Round 2 adds six in `queue.mjs` for C1, run against the round 2 tests.
All six are killed:
- the round 1 branch restored (waiting-on-jason checks only the actor);
- the approval check dropped from waiting-on-jason;
- the unresolved check dropped from waiting-on-jason;
- the unresolved check dropped from waiting-on-jason→done;
- the no-reviewers check dropped from `requireApprovals`;
- the approval check applied to every round, not only request rounds.
Seven survived earlier runs. Five of them now have a test that kills them:
a second outcome for one attempt, the owner recording a verdict, done on a
row whose reviewers were removed, a same-op resolve retry that fetched the
comment again, and a resolve on a done row that fetched the comment before
the log refused it. The last one is the new test "a late POST on a closed
row".
Two survive, and each is equivalent while the other stays:
- the semantics check in `applyEntry` dropped;
- the semantics check in the entry shape dropped.
Every entry passes the shape check before it is applied, so either check
alone refuses a review verb at semantics 1. With both dropped, the
semantics test fails.
## Suites
In the scratch clone with the patch and the helper patch applied: config
24/0, task 90/0, foundation 44/0, conductor 17/0, release 14/0, auth 15/0,
discord 64/0, extension-package 18/0, queue 27/0. `node --test
packages/queue/tests/` passes 142, and `node --test packages/ledger/tests/`
passes 58.
The scratch clone isn't the canonical root, so the queue suite printed its
skip line for the two live checks. In the canonical tree they run.
In a fresh clone at cdcedb27 with `build.patch` applied: the manifest
matches 10/10, the queue tests pass 142, the queue suite 27/0. With
`helper.patch` applied on top, the ledger tests pass 58.
## Known limits
- **Scope of darkwing's and dewey's tokens.** They hold
`write:repository`. If that doesn't cover an issue comment, the live
POST gets 403, which is `failed` with nothing posted (8.9 says so). The
scope question then goes to Sage.
- **Post to outcome.** A kill between the POST and the outcome entry
leaves the attempt `requesting` with a comment on the issue. The queue
never posts again on its own; a person resolves it.
- **Verdict comments aren't fetched.** `review record` takes the
reviewer's comment id on trust, as the queue takes `--by`.
- **Commit candidates** stay checkable only while a ref keeps the commit.
- The helper's own limits are in `helper.md`.
## After approval (Sage)
1. Apply `build.patch` to the canonical tree and check it against
`build-manifest.sha256`. Run `scripts/test-queue.sh` and
`node --test packages/queue/tests/`. Commit the 10 files by path.
2. Apply `helper.patch` for the same commit (decision 39). The live
round needs it: without it the helper refuses a raw token file.
3. `tools-md.patch` if you want it.
4. Row 12 lists no reviewers, so moving it to in-review now would open a
round that posts nothing. Set them first:
`scripts/mosaic queue set 12 reviewers filbert --op OP --by sage`, and
add rocko if the helper counts as part of this round.
The live round, which is mine:
5. `MOSAIC_GITEA_CREDENTIAL_FILE=~/.mosaic/fleet/agents/darkwing/secrets/gitea-mosaicstack-darkwing.token
scripts/mosaic queue move 12 in-review --candidate <D's commit> --op OP --by darkwing`.
The queue `lstat`s the file, the helper reads it, and `GET user` must
answer `darkwing`. The request goes on #1508.
6. Exit 0 means posted. Exit 1 with HTTP 403 is the scope question above.
Exit 3 means I look on #1508 for the marker and resolve, or ask you to
abandon.
7. Filbert posts a verdict on #1508 and runs `review record 12`. Then
`move 12 done`, and `queue-commit.sh` for the queue ops.
+184
View File
@@ -0,0 +1,184 @@
# Raw per-seat token files in `scripts/gitea-api.sh` (row 12, #1508)
Darkwing, 2026-09-27, after round 2 (lead decision 38). Lead decision 37 approved this patch. It ships next
to the Piece D candidate but is a separate patch, and Rocko reviews it
because it handles a credential. Filbert reviews D. Sage commits both.
Nothing is committed, staged or pushed.
`helper.patch` (sha256 `48edd46b93c54908e9d59737aab78a33e007ddfafa1e6ad4d34333015bb75430`)
changes `scripts/gitea-api.sh` and adds
`packages/ledger/tests/gitea-helper-raw.test.mjs`. It applies to HEAD
8efc0ff3 by itself. With only this patch applied, `node --test
packages/ledger/tests/` passes 58/58. D does not depend on it, and it does
not depend on D.
## Why
Each seat's Gitea token is at
`~/.mosaic/fleet/agents/<seat>/secrets/gitea-mosaicstack-<seat>.token`.
These files hold the bare token, not the JSON `mosaic.gitea.json` shape
that the helper reads today. Without this change the helper refuses them,
so D's live round can't post as the seat.
## What changed
Both old node snippets, the one that printed the base URL and the one that
wrote the curl config, are now one script, `CRED_JS`, run in two modes:
`base` and `cfg`. Each mode runs every check before it prints anything.
1. `lstat`: a regular file, not a symlink, no group or other bits.
Unchanged.
2. Read the file. A read error exits 3. It used to be caught by the JSON
parse's catch, with the same result.
3. `JSON.parse`. If the text parses, the JSON path runs unchanged: `url`
must be `https://git.mosaicstack.dev` (trailing slashes stripped), and
`api_token` must be a nonempty string. A non-`SyntaxError`, such as
`null.mosaicstack`, exits 3.
4. The raw path runs only on a `SyntaxError`. It accepts the file only if
all of these hold:
- `stat` size is 40 or 41;
- the byte length of the text equals the `stat` size;
- the text matches `^[0-9a-f]{40}\n?$`.
Then the base URL is the fixed string `https://git.mosaicstack.dev`,
and the token is the first 40 characters. Anything else exits 3
before any request.
5. The second read runs, and must succeed, before curl starts. Round 1
ran it inside `curl -K <(…)`, where bash drops its exit status, so a
refusal gave curl an empty config and the request still went out.
Now `CFG="$(node -e "$CRED_JS" cfg)" || exit 3` and `[ -n "$CFG" ] ||
exit 3` run first, and curl reads `-K <(printf '%s\n' "$CFG")`.
`printf` is a builtin, so the token is in no argv. Both curl branches,
with and without a body, use it.
6. `export -n CFG` runs right after the checked assignment, before any
child starts. A `CFG` inherited from the caller's environment keeps its
export attribute when assigned, and `SHELLOPTS=allexport` in the
environment exports every assignment. Either way the config would reach
curl's environment, and the body file's `mktemp` and `chmod` too.
## Decision 37, condition by condition
1. **The JSON path is unchanged.** The last test runs a good JSON file,
four refused ones, and content that parses as JSON but would pass the
raw pattern: 40 decimal digits, with and without a newline. Those take
the JSON path and refuse.
2. **One line of token characters.** The format is 40 lowercase hex
characters. I checked by `stat` that the real files are 40 bytes (jarvis)
or 41 (the others), all mode 600. I then ran the patched `CRED_JS` in
`base` mode against my own file only. It printed
`https://git.mosaicstack.dev` and exited 0, so darkwing's file matches
the pattern. Nothing printed the token, and I read no other seat's file.
The other seats' files are unconfirmed beyond size and mode. If one
doesn't match, the helper exits 3 before any request. The test refuses
16 bad contents before curl runs: empty, 39 characters, 41 characters,
upper case, CRLF, a trailing CR, a trailing space, a trailing tab, two
newlines, a leading space, a trailing space at 40, a second line, a
quote, non-hex, non-ASCII, and 80 characters.
3. **Fixed base URL.** The raw path assigns the literal. The test sets
`MOSAIC_GITEA_URL`, `GITEA_URL` and `MOSAIC_GITEA_BASE_URL` to another
host, and curl still gets `https://git.mosaicstack.dev/api/v1/user`.
4. **File checks and the config stream.** Modes 640, 604, 660, 644, 000
and 200, a symlink, a missing file and a directory all refuse before
curl runs. 000 and 200 pass the group and other check, and the read
refuses them. The stub curl records its argv and its `-K` stream. The
token is in the stream only, never in argv, stdout or stderr. Every
token in the test is a dummy that the test writes.
5. **Only the acting seat's file.** That is D's job, not the helper's. D
refuses unless `MOSAIC_GITEA_CREDENTIAL_FILE` resolves to
`…/agents/<login>/secrets/gitea-mosaicstack-<login>.token` for the
acting seat, or jarvis when Sage acts, and before posting it checks that
`GET user` returns that login. See `review.mjs` `credCheck` and
`checkUser` in the D candidate.
6. This review.
## Round 1 and what changed for round 2
Rocko's round 1 (`agents/rocko/work/gitea-helper-raw-r1-review-2026-09-27.md`)
found one blocker: a second read that refused still let curl run with an
empty config, and a 2xx then exited 0. My round 1 notes called that a 401,
which the server doesn't guarantee. Sage agreed it belongs in this patch.
Step 5 above is the fix.
The new test, "a file that changes between the two reads refuses before
curl runs", uses the git stub, which runs between the two reads, to
change a valid dummy file to invalid text, to `{}`, to mode 644, to a
symlink, and to missing. It runs each change for `GET user` and for a POST
with a dummy body. Every case exits 3 with no curl call and nothing on
stdout. It also changes a valid JSON file to invalid text. As a control,
both calls reach curl with the right config when nothing changes. The
first test now also checks that the token is not in the environment curl
gets.
Rocko's round 2 (`agents/rocko/work/gitea-helper-raw-r2-review-2026-09-27.md`)
closed that blocker and found another: the inherited export attribute in
step 6. Lead decision 38 took the fix, `export -n CFG`, with no round 3;
Sage checks it with Rocko's reproducer. The test "the token reaches no
child environment, even with an inherited CFG or SHELLOPTS=allexport" runs
raw and JSON dummy files, GET and POST, with an exported harmless `CFG`,
with `SHELLOPTS=allexport`, and with both. Each call must exit 0 with the
token in curl's config stream and not in its environment, argv, stdout or
stderr. I found the `SHELLOPTS` route while checking the fix; `export -n`
covers it too, so I added no second line for it.
Not in this patch: the predictable response path
`/tmp/gitea-api-response.$$`. Sage has it on DEFERRED as "Gitea helper
response-file hardening".
## What I'd like you to look at
- **The second read now re-validates.** Before, `gen_curl_cfg` parsed the
file again without the `lstat` checks. Now both reads run every check.
So a file swapped for a symlink or a wider mode between the two reads
refuses. On the JSON path this is a tightening.
- **The token now sits in a shell variable** for the rest of the script.
`export -n` keeps it out of every child's environment, and nothing
prints it. Before, it existed only in the pipe.
- **`lstat` then `readFile` is still not atomic.** A swap between those two
calls inside one read is the pre-existing race Rocko noted. This patch
doesn't claim to close it.
- **`text.slice(0, 40)`** relies on the pattern having matched. The only
accepted texts are the token alone or the token plus `\n`.
## Mutations
25 mutants, each run alone against `gitea-helper-raw.test.mjs`: 19 of
`CRED_JS` and 6 of the gate and the export. 15 are killed:
- uppercase allowed, any trailing whitespace allowed, the `^` anchor
dropped;
- a raw file refused outright (the old behaviour);
- an env override of the raw base URL;
- the token taken with its newline;
- the mode check dropped;
- the JSON path's host check or token check dropped, or its `|| {}`;
- `cfg` output in `base` mode;
- the read's own `try` dropped (killed by the 000 and 200 modes; I added
those after this mutant first survived);
- both gate checks dropped, and the round 1 code (no gate, curl reading
`<(node … cfg)`), killed by the between-reads test;
- `export -n CFG` dropped, killed by the inherited-environment test.
Ten survive, and each is equivalent while the other checks stay:
- `\n*$` for `\n?$`: the size check caps the file at 41 bytes;
- the size check dropped: the pattern caps the length;
- the byte-length check dropped: the pattern is ASCII, so a match means
bytes equal characters; the check differs only if the file changes
between `lstat` and the read;
- the `SyntaxError` test dropped: the only non-`SyntaxError` is JSON
`null`, which then fails the raw pattern;
- `text.trim()` for `text.slice(0, 40)`: same result on every accepted
text;
- the `isSymbolicLink` test dropped: `lstat` of a symlink is never
`isFile`;
- `!== "base"` for `=== "cfg"`: the script calls only these two modes;
- `|| true` for `|| exit 3` on the `CFG` line: node prints nothing when it
refuses, so `[ -n "$CFG" ]` refuses;
- `[ -n "$CFG" ]` dropped: `|| exit 3` refuses first. Dropping
`|| exit 3` outright is the same, since `set -e` exits on the failed
assignment;
- `export CFG=…` for `CFG=…`: `export -n` on the next line clears it. In
round 2 this one was killed; the fix makes it equivalent.
I kept the size, byte-length and symlink checks anyway. Decision 37 asks
for size plus pattern, and the other two cost nothing.
+285
View File
@@ -0,0 +1,285 @@
# Queue E (#1508, row 13), candidate for review, round 2
Darkwing, 2026-09-27. Piece E is the ledger's queue section, per plan
section 8.10 (`agents/filbert/work/queue-as-data-plan-2026-09-26.md`) and
the brief's "Piece E: ledger checks the queue". The ledger now checks
`docs/plans/queue.json` against Gitea and the seat registrations and prints
the result above the weekly table. Filbert reviews E. Sage commits. Nothing
is committed, staged or pushed.
Round 2 answers Filbert's round 1 review
(`agents/filbert/work/queue-e-review-r1-2026-09-27.md`, sha256 81f26f2e…):
C1, plus n1, n2 and n3. See "Round 2" below. The round 1 files are kept
unchanged in `r1/` (patch 02a01c29…, manifest cad51929…, build.md
d3bdb826…).
Round 1 was amended before its verdict for lead decision 40 (origin
2333d837): a full open-issue page is undecided only while some issue a row
closes has no known state. See "The full page" below.
Base is f3f48cfd. Nothing under `packages/ledger`, `packages/queue/src` or
`packages/seat/src` changed between it and origin 2333d837. In a fresh
clone at 2333d837 the patch applies, the result matches the manifest 5/5,
and `node --test packages/ledger/tests/` passes 78/78 in three runs.
## Files
`build.patch` (sha256
`ab1f12cad711284f8a722ea51fa73cd8e344c703701f8b76957ae33de091ae84`) changes 3
files and adds 2. `build-manifest.sha256` (sha256
`0b20bbca6c9e0a00d39dc7aae0ca2e268b6908c4b24e9f9b401df8268d29c5fe`) pins all 5
after the patch.
- `packages/ledger/src/queue-checks.mjs` (new): reads the queue, classifies
owners, makes the issue calls, runs the checks, lists protected changes,
formats the section.
- `packages/ledger/src/cli.mjs`: `--no-queue`, `--unsupported-runtime SEAT`
(repeatable), the queue read before any Gitea call, the section above the
table and under `queue` in `--json`.
- `packages/ledger/README.md`: a "Queue section" with the rules, the call
budget, the result levels and the weekly routine. The heading "One Gitea
call" becomes "Gitea calls", and the exit-code paragraph names the queue.
- `packages/ledger/tests/queue-checks.test.mjs` (new, 20 tests).
- `packages/ledger/tests/ledger.test.mjs`: the fixture copies
`packages/queue/src` and `packages/seat/src`, which the ledger now
imports, and its runs pass `--no-queue`. Those tests cover the weekly
table; the new file covers the queue section. 58 tests before, 78 now.
`docs/TOOLS.md` has no ledger entry today, so there is no TOOLS patch. The
README is the reference, as it was for Piece 3.
## What a run prints
```
Queue checks: queue.json revision 19, as of 2026-09-27T16:09:10.883Z
owner darkwing (row 13): exempt, declared with --unsupported-runtime
...
liveness: 0 pid-present (unverified), 3 exempt, 0 pid-unknown, 0 missing, 0 invalid, 0 pid-gone
declared unsupported runtime: darkwing, dewey, sage
queue issue checks: open list (full page), 2 lookups
protected changes in range: 0 (not checks; confirm the actors)
queue: 0 violations; result reduced pass
```
Each finding is a line `violation|undecided|disposition <check> <row> <#issue>:
<message>`, so its identity (check, row, issue) can be read off the line for
the same-day remediation run. The JSON carries the same objects.
## Choices and where they differ from the plan
1. **A new module.** The plan's file list puts `queueChecks` in
`ledger.mjs`. I put the section in `queue-checks.mjs`, as `t3.mjs` did
for the T3 source, so `ledger.mjs` stays the metric code. The CLI
wires it in.
2. **Registrations through `readRegistration`**, not the control board's
scan. It returns a record, null or a thrown error per seat, with no text
to parse, and it keeps the board's import chain out of the ledger.
Liveness is its own signal-0 probe; EPERM counts as present.
3. **Closure uses `closes`, not `issues`** (J6). Rows 9 to 12 name #1508
but close nothing, so they can be done while it is open. The brief's
literal "a row naming an open issue" would flag them.
4. **A malformed or absent pid is `invalid`, not `pid-unknown`.** The plan's
table puts it under `pid-unknown`. `validateRegistration` rejects a pid
that is not a positive integer or null, so the record fails validation
first. `invalid` is a violation where `pid-unknown` is undecided, so the
difference fails closed. `pid-unknown` is a valid record with a null pid.
5. **A registration for another checkout is `missing`.** The plan defines
missing as no registration for (canonical root, `repo`, seat). A `repo`
record whose `sessionsDir` is not under the queue's canonical root
matches the seat name but not the root.
6. **An unreadable config makes every non-exempt owner `invalid`**, and
the report still runs. It does not refuse, because the other checks don't
need the data root.
7. **Refusals.** A missing, symlinked or hand-edited `queue.json` exits 1
before any Gitea call; the message names `--no-queue`. A failed or
malformed open-list call exits 2, like the metric call, and names
`--no-issues`. A failed lookup, a 404, a pull request or a number that
doesn't match leaves only that issue unknown.
8. **On by default.** `--no-queue` skips the section and prints `Queue: not
checked (--no-queue)`. It can't be combined with `--unsupported-runtime`.
`--unsupported-runtime` is the only repeatable flag; a repeated seat is
refused.
9. **Age is as of the run**, not `--until`: the gate is about today's queue,
and the table's range doesn't move it. "More than 14 days" is whole days,
so a row required on 2026-09-13 turns on 2026-09-28.
10. **Exit 0 whenever a report was computed.** The result is in the text and
the JSON, like every other number the ledger prints.
11. **Protected changes (added).** The 8.10 checks don't include it, but R4
and R10 ("E lists every protected change from [the journal]"), J2 and
the queue README's trust boundary ("piece E lists changes for review")
do. So the section lists every log entry dated inside `--since`/`--until`
that changes a row which is required or parked before or after the
entry, with rev, verb, claimed actor and rows. It replays the log up to
the range with the queue's own `replay` and `applyEntry`. It is a list,
not a check: it never changes the result. For 2026-09-20 to 2026-09-26
it lists nothing, because genesis was 2026-09-27; for a range that
includes today it lists 18 entries at rev 19, on rows 8 to 13 and 26 to 30.
## Round 2
- **C1, an ISO `requiredSince` never aged.** `set required` and `add
--required` write an ISO time, and round 1 appended `T00:00:00Z` to it,
which parses to NaN. Now `Date.parse` reads the value as it is (a bare
date parses as 00:00Z), and the result is floored to its UTC day. One
deviation from the suggested fix: an ISO time counts from 00:00Z of its
day, not from its hour, so both forms age in whole UTC days and a Monday
run's result doesn't depend on the hour a row was made required. A row
required at 23:59Z on 2026-09-13 turns on 2026-09-28, as a date-only row
does. A value that doesn't parse is an `age-invalid` violation. I chose
a violation over undecided because row 13's gate counts violations, and
a bad value is a queue defect. The queue validator lets one through:
`ISO_RE` checks the shape, so `2026-13-01T00:00:00.000Z` passes it. That
gap is in `packages/queue/src/queue.mjs`, outside E; I'm raising it as a
follow-up, not fixing it here.
- **n1, the orphaned curl.** Each queue call now runs as `timeout -s KILL
60 gitea-api.sh GET ...`, as D's `callTool` does, so the kill takes the
helper's process group. A kill reads `no answer within 60 s`, and exit
126 or 127 from `timeout` (no helper) reads `gitea-api.sh unavailable`.
The metric call in `ledger.mjs` still uses execFileSync's timeout. It
isn't in this patch, so that is a follow-up too.
- **n2, a reopened issue.** Closed evidence from the metric page now needs
`state: "closed"` and a `closed_at`. Either one alone leads to a lookup.
- **n3.** The budget detail is `over the lookup budget`, so the message
reads `unknown (over the lookup budget)`.
Two tests are new. One covers an ISO `requiredSince` at 15 days (fails), at
14 (passes), at 23:59Z fifteen days back (fails), and one that doesn't
parse (`age-invalid`, result fail). The other gives the calls a hanging
helper that starts a hanging child and a 1-second deadline. Both calls
return at the deadline and neither process survives. A missing helper
reads `gitea-api.sh unavailable`. The metric fixture adds a reopened issue
and a `state: "closed"` entry with no `closed_at`, and both are looked up.
Round 2 mutants, all 12 killed:
1. The round 1 template restored.
2. No floor to the UTC day.
3. No NaN guard.
4. NaN as undecided.
5. Ceiling instead of floor.
6. The metric check on `closed_at` alone.
7. The metric check on `state` alone.
8. A Node timeout with SIGKILL in place of `timeout`, with ETIMEDOUT
ignored. The orphan assertion kills it.
9. The kill flag always false.
10. The kill flag read from the exit status alone.
11. No 126/127 mapping.
12. The old budget detail.
A first version kept the suggested `DATE_RE` branch, and dropping it was an
equivalent mutant because `Date.parse` already reads a bare date as 00:00Z.
I removed the branch.
## The full page (lead decision 40)
mosaicstack/stack has 50 or more open issues, so the open list is always a
full page. Plan 8.10 made a full page undecided on its own. But an issue
missing from the page is looked up, so the page matters only when an issue
is left without a known state, and that issue is already undecided. Sage
approved the change. Now `open-list-full` is added only when the page is
full and some issue a row closes is `unknown`, and its message names
those issues. The text still prints `open list (full page)`, and the JSON
keeps `openListFull`.
Tests cover a full page with every issue resolved (no undecided item), an
open issue off the page found by lookup (its done row fails), and a full
page with an issue past the budget (still incomplete). They drive the fake
helper end to end through `issueStates` and `queueChecks`.
## Live run (read-only)
Round 2, in a fresh clone at 2333d837 with the patch applied, through the
new `timeout` path: the same result as below with the exemptions, 0
violations, `reduced pass`, 2 lookups, exit 0.
Round 1, at 2333d837 in the verify clone, with my own token file in
`MOSAIC_GITEA_CREDENTIAL_FILE`, for 2026-09-20 to 2026-09-26: exit 0, the
metric call, the open list and 2 lookups. That is GET only, and nothing
was posted.
- Plain run: 3 violations, result `fail`. Rows 13 (darkwing) and 5 (dewey)
are `pid-gone`, from old pi launches; row 7 (sage) is `missing`. All
three seats run in T3, which writes no registration.
- With `--unsupported-runtime` for darkwing, dewey and sage: 0 violations,
result `reduced pass`. Before the amendment the same run was
`incomplete`, from the full page alone.
- No issue violations: every issue in a done row's `closes` is closed.
This is not the acceptance run. That is a dated run posted on #1508 after
approval.
## Lead decision 40
Sage ruled on the five points I raised:
1. The full page: approved as amended above.
2. Row 7's brief pins `packages/ledger/README.md` at blob 3a2ce27c. Sage
re-pins it with `set 7 brief` in a queue commit right after E lands.
3. Row 7 stays. Its gate is Jason's, so Q9 is amended. The weekly routine
stays in the README.
4. The age rule stays. From 2026-09-28 the Monday run lists rows 9, 10, 11
and 13, which is accurate.
5. The T3 exemption is accepted as built. The weekly run declares every T3
seat that owns an active row.
## Tests
`packages/ledger/tests/queue-checks.test.mjs`. In-process checks run on
fixture rows with a fixed clock and an injected pid probe. CLI tests use a
scratch repository whose `queue.json` the real queue CLI wrote, a temporary
config and data root, and a fake `gitea-api.sh` that routes the open list,
single issues and the metric page and logs every call. No test reads a real
token, registration, config or `~/.t3`; HOME and MOSAIC_CONFIG are
temporary.
- Issues: done with the issue open (fail) or closed (pass); a multi-row
issue open while one closer is pending, closed early (disposition), and
open with both done; rows 9 to 12's shape (issues without closes);
unknown and not run (incomplete); a full page is undecided only beside an
unknown issue.
- Issue calls: open list first, metric page next, then at most 10 lookups
in order and `unknown (budget)` after; a pull request on the open list is
not an issue; lookups of a pull request, a mismatched number and a 404
are unknown; a metric entry with no `closed_at` is not closed evidence;
a full page. The open list refuses on exit 3, exit 1, bad JSON and bad or
closed records, and never echoes the helper's stderr.
- Owners: every class in one run, including another checkout and a broken
record; two rows for one owner; briefed and done owners not checked; no
config.
- Age: 15 days fails, 14 doesn't; done and not-required rows are skipped;
the legacy bound at 20 days (fail), exactly 14 and 3 (undecided); an ISO
`requiredSince` by UTC day, and one that doesn't parse (round 2).
- Deadline: a hanging helper and its child are both killed (round 2).
- `pidAlive`: running, exited, and EPERM (pid 1, non-root).
- `readQueue`: a real queue, a hand edit, a missing file, a symlink.
- Protected changes: genesis, a note on a required row, a note on an
ordinary row (not listed), an unpark by jason (listed from the row
before), and range boundaries (start included, end excluded).
- CLI: section above the table; `--json` key; a clean queue prints `queue:
0 violations; result reduced pass`; call counts (3 with issues, 0 with
`--no-issues`, 1 with `--no-queue`); refusals cost no call; flag errors.
Mutation testing, round 1: 37 hand-made mutants of `queue-checks.mjs` and the CLI
wiring (boundaries, each class, each result level, budget, filters,
refusals, the protected-change range and guard, the full-page rule). All
37 are killed. The
first pass left three alive (the 14-day genesis edge, EPERM, a null
`closed_at`) and a later one three more (the before-row guard and both
range edges); the tests above were added for them.
Round 2 adds the 12 listed above.
Suites: `node --test packages/ledger/tests/` 78/78, three runs;
`packages/queue/tests` and `packages/seat/tests` 161/161.
## Verify
```sh
git clone -q /mnt/storage/src/mosaic-stack /tmp/e && cd /tmp/e
git checkout -q 2333d837
git apply /mnt/storage/src/mosaic-stack/agents/darkwing/work/queue-e/build.patch
sha256sum -c /mnt/storage/src/mosaic-stack/agents/darkwing/work/queue-e/build-manifest.sha256
ln -s /mnt/storage/src/mosaic-stack/node_modules node_modules
node --test packages/ledger/tests/
node packages/ledger/src/cli.mjs --since 2026-09-20 --until 2026-09-26 --no-t3 --no-issues
```
+878
View File
@@ -0,0 +1,878 @@
# Queue migration map (#1508, A2)
Darkwing, 2026-09-27. This is the reviewed input to `queue genesis --map
agents/darkwing/work/queue-migration-map.md`. Genesis reads the one
`json queue-map` block at the end. Everything above it is for review.
Built from `docs/plans/QUEUE.md` blob `c8e3d34e6efcb98070ebf226157ac027811ffa54`
(HEAD c9539baa). If a row in QUEUE.md changes before genesis, the map has to
change with it. `queue-a2/map-check.mjs` lists the table lines that differ
from that blob and any piece or owner that no longer matches.
## What genesis keeps
- Rows 1 to 25 keep their ids. The five parked items become rows 26 to 30,
in table order, with state `parked`, owner `unassigned` and no issue
(plan Q9). `highWater` is 30 and nothing is retired.
- The text between the QUEUE.md markers goes into genesis as `legacyView`,
byte for byte. That covers every row, the parked table, the start message
and the priority paragraph. A shortened note loses nothing, because the
full State text is there.
- The State column becomes `state` plus `note`. Piece and Gate are copied
verbatim. Every text passes A2's rule: no `\` or `<`, piece and gate at
most 300 characters, notes at most 500.
- Each row has one brief, `{path, anchor}`, and genesis pins the blob. Every
anchor occurs once as a heading in HEAD. Done and parked rows keep their
briefs; `verify --current` skips them.
- `createdAt` comes from the log of table changes. Parked items 1, 2, 3 and 5
date from 7c8e530a, the commit that created QUEUE.md (2026-09-13 UTC). Item
4 has no evidence before 2026-09-26, so it is `unknown`.
- `requiredSince` for rows 9 to 13 is 2026-09-13, the log line that made them
required.
- `reviewers` holds seats under `agents/` only. A lane named for a fleet seat
(`rev-code-02`, `orch-01`) stays in the note.
- Genesis gives a claim to each `in-progress`, `in-review` and
`waiting-on-jason` row: 5, 7, 9, 11 and 16.
## Choices Sage should check
1. **Row 7 stays a row.** Plan Q9 retired it, but lead decision 27 made it
Sage's weekly ledger run, with `packages/ledger/README.md` as its brief.
The map keeps it as `in-progress` with Sage's claim, so `queue next sage`
resumes it and Sage records the weekly number with `queue note 7`.
Retiring it would instead need `retired: [7]` and a pointer line under
the markers.
2. **Row 10 keeps owner `coordinator`.** No seat can act as `coordinator`,
so only Jason or Sage can move the row, and its work edits AGENTS.md,
which is Sage's. I recommend `queue assign 10 sage` as the first
operation after genesis, so the change is logged instead of hidden in
the migration.
3. **Rows 10, 12 and 13 have no `after`.** QUEUE.md says "after row 9", but
row 9 stays open until Gate G, and Gate G also verifies row 10, so `after:
9 done` would hold row 10 behind a gate that needs it. A's code is in place
once genesis is committed, and each note says so.
4. **Row 11 is `waiting-on-jason`.** QUEUE.md says the `queue add` refusal
lands with A2. It landed in A1: `add` runs the same brief check genesis
does. Only the gate is left, and the gate is Jason's.
5. **Gate owners.** Jason holds every gate except rows 12 and 13, which the
map gives to Sage. Both are checks the lead can see: a review round with
no new file under `reviews/`, and the ledger run Sage does each Monday.
With Sage as gate owner, J5 lets `in-review` → `done` happen without
Jason.
6. **Row 16 stays open, as `waiting-on-jason`.** Its State doesn't say done.
Lead decision 29's "rows 14 to 25 are done" sits in the #1509 paragraph,
and row 16 is #1510. If #1510 is closed, change the row to `done` before
the map is committed.
7. **`closes`.** Rows 9 to 12 close nothing and row 13 closes #1508, so piece
E won't report #1508 open under a done row 9. This is J6's narrowing, and
this line is its reason. Every other row closes its issues.
8. **Row 5's note** matches QUEUE.md at c9539baa: the CHAT-03 brief is
pinned, and Sage names the source author after A2 lands. When the row
changes before genesis, the note changes with it.
## Row by row
| # | State | Owner; reviewers | Issues | Required | After | Gate owner | Created | Why |
|---|---|---|---|---|---|---|---|---|
| 1 | done | darkwing | #1503 | no | none | jason | 2026-09-13 | Done per lead decision 29. |
| 2 | done | darkwing | #1504 | no | none | jason | 2026-09-13 | Brief: the plan page's Step 3. |
| 3 | done | darkwing | #1505 | no | none | jason | 2026-09-13 | |
| 4 | done | darkwing | #1506 | no | none | jason | 2026-09-13 | |
| 5 | in-progress | dewey; filbert, rocko | #1507 | no | none | jason | 2026-09-13 | Rocko added as reviewer (adversarial lane). The note is shortened; the full State text is in `legacyView`. |
| 6 | done | darkwing; dewey | #1511, #1512 | no | none | jason | 2026-09-13 | Done per lead decision 29. One brief per row, so the plan page's Piece 5; the other two briefs are named in the note. Dewey is the reviewer; Filbert authored the pilot. |
| 7 | in-progress | sage | none | no | none | jason | 2026-09-13 | Kept as a live row, not retired (see question 1). Recurring, so `in-progress` with Sage's claim. |
| 8 | parked | unassigned | none | no | none | jason | 2026-09-13 | Brief: Sage's stub (J9). |
| 9 | in-progress | darkwing; filbert | #1508; closes none | yes, 2026-09-13 | none | jason | 2026-09-13 | No `after`: lead decision 29 took the wait on row 6 off rows 9 to 13. The note is written as of genesis. |
| 10 | briefed | coordinator | #1508; closes none | yes, 2026-09-13 | none | jason | 2026-09-13 | Owner kept as `coordinator` (see question 2). No `after`: see question 3. |
| 11 | waiting-on-jason | darkwing | #1508; closes none | yes, 2026-09-13 | none | jason | 2026-09-13 | Both parts are in A1 already, so only the gate is left (see question 4). |
| 12 | briefed | darkwing | #1508; closes none | yes, 2026-09-13 | none | sage | 2026-09-13 | Gate owner `sage` (see question 5). |
| 13 | briefed | darkwing; filbert | #1508 | yes, 2026-09-13 | none | sage | 2026-09-13 | Closes #1508; rows 9 to 12 close nothing. Gate owner `sage` (question 5). |
| 14 | done | coordinator; filbert | #1509 | no | none | jason | 2026-09-13 | Reviewer lane named Filbert or orch-01; Filbert kept, orch-01 isn't a seat under `agents/`. |
| 15 | done | coordinator | #1509 | no | none | jason | 2026-09-13 | |
| 16 | waiting-on-jason | darkwing; filbert | #1510 | no | none | jason | 2026-09-13 | Kept open as `waiting-on-jason` (see question 6). |
| 17 | done | coordinator | #1509 | no | none | jason | 2026-09-13 | |
| 18 | done | darkwing | #1509 | no | none | jason | 2026-09-13 | The note keeps the coordinator's part. |
| 19 | done | coordinator | #1509 | no | none | jason | 2026-09-13 | |
| 20 | done | coordinator | #1509 | no | none | jason | 2026-09-13 | Done: lead decision 29 closed #1509 with rows 14 to 25 done. |
| 21 | done | coordinator | #1509 | no | none | jason | 2026-09-14 | Done: lead decision 29. `rev-code-02` isn't a seat under `agents/`, so no reviewer. |
| 22 | done | darkwing; filbert | #1503 | no | none | jason | 2026-09-14 | Done: #1503 closed (lead decision 29). |
| 23 | done | coordinator | #1509 | no | none | jason | 2026-09-16 | Done per lead decision 29. |
| 24 | done | coordinator | #1509 | no | none | jason | 2026-09-16 | Done per lead decision 29. |
| 25 | done | coordinator | #1509 | no | none | jason | 2026-09-20 | Done per lead decision 29. |
| 26 | parked | unassigned | none | no | none | jason | 2026-09-13 | Parked item 1. |
| 27 | parked | unassigned | none | no | none | jason | 2026-09-13 | Parked item 2. |
| 28 | parked | unassigned | none | no | none | jason | 2026-09-13 | Parked item 3. |
| 29 | parked | unassigned | none | no | none | jason | unknown | Parked item 4. First committed 2026-09-26 (0f5b7cb9); no earlier evidence, so `unknown`. |
| 30 | parked | unassigned | none | no | none | jason | 2026-09-13 | Parked item 5. |
## Map
```json queue-map
{
"rows": [
{
"id": 1,
"piece": "Control board MVP (scanner + page)",
"owner": "darkwing",
"issues": [
1503
],
"closes": [
1503
],
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "A passed 2026-09-12",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-12_control-board-mvp.md",
"anchor": "Control board MVP — plan"
},
"after": [],
"reviewers": [],
"note": "done 2026-09-27 (Sage, lead decision 29): D-001 MVP delivered, Gate A passed 2026-09-12, attention status operator-accepted (row 22), relaunch activity, attribution and Host guard landed; #1503 closed; cross-harness board work belongs to Gate E (row 5)",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 2,
"piece": "Seat registration and `mosaic seat task`",
"owner": "darkwing",
"issues": [
1504
],
"closes": [
1504
],
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "B passed 2026-09-12",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-12_control-board-mvp.md",
"anchor": "Step 3: daily use and fixes"
},
"after": [],
"reviewers": [],
"note": "done 2026-09-13",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 3,
"piece": "Reply-from-board (piece 2 on the plan page)",
"owner": "darkwing",
"issues": [
1505
],
"closes": [
1505
],
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "C passed 2026-09-12",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-12_control-board-mvp.md",
"anchor": "Piece 2: reply-from-board"
},
"after": [],
"reviewers": [],
"note": "done 2026-09-12",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 4,
"piece": "Ledger (piece 3 on the plan page)",
"owner": "darkwing",
"issues": [
1506
],
"closes": [
1506
],
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "D: 18.9 human messages per closed issue, week of 2026-09-06",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-12_control-board-mvp.md",
"anchor": "Piece 3: ledger (numbers for the rails)"
},
"after": [],
"reviewers": [],
"note": "done 2026-09-13",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 5,
"piece": "WebUI first screen on the Console design (piece 4 on the plan page)",
"owner": "dewey",
"issues": [
1507
],
"closes": [
1507
],
"state": "in-progress",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "E: all-seat interactive demonstration then Jason workday ruling; live cutover separately approved",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-13_webui-session-chat.md",
"anchor": "WebUI session chat: refined brief and delivery plan"
},
"after": [],
"reviewers": [
"filbert",
"rocko"
],
"note": "CHAT-00/01/01C published (370823b3, 28d4e98a, b023841c); CHAT-02 done: backend a5beb6d9, Console c9e771cf, live check passed 2026-09-26; CHAT-03 brief approved and pinned 2026-09-27 (BRIEF.md 1ef15ac0, rescoped to Gate E, lead decisions 30 to 33; Filbert r3 approve, Rocko blocker closed, Filbert scope check pass); source author named by Sage after queue A2 lands; CHAT-04..08 not chartered; Gate E blocked",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 6,
"piece": "Darkwing on point: darkwing assigns and gates filbert's work (piece 5 on the plan page)",
"owner": "darkwing",
"issues": [
1511,
1512
],
"closes": [
1511,
1512
],
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "F: filbert's item closes with zero human messages from Jason; code phase does not claim Gate F",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-12_control-board-mvp.md",
"anchor": "Piece 5: darkwing on point (orchestrator behaviour)"
},
"after": [],
"reviewers": [
"dewey"
],
"note": "done 2026-09-27 (lead decision 29): #1511 and #1512 landed in af4203ca (pushed), closed; Gate F not passed (2 human messages in filbert's T3 thread; the first 11 days predate the T3 source); Gate G (row 9) carries the test. Filbert authored the pilot. Briefs also: 2026-09-15_relaunch-activity.md, 2026-09-14_task-attribution.md",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 7,
"piece": "Weekly ledger run and rails number",
"owner": "sage",
"issues": [],
"closes": [],
"state": "in-progress",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "under 10 human messages per closed issue for the week of 2026-09-13",
"gateOwner": "jason",
"brief": {
"path": "packages/ledger/README.md",
"anchor": "Ledger"
},
"after": [],
"reviewers": [],
"note": "recurring, every Monday (lead decision 27); Jason reads; 09-13..19: 47.7, 09-20..26: 33.0 human messages per closed issue",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 8,
"piece": "Fleet seats (`~/.mosaic`) onto `mosaic launch`",
"owner": "unassigned",
"issues": [],
"closes": [],
"state": "parked",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "Jason's call",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-26_fleet-seats-onto-mosaic-launch.md",
"anchor": "Row 8: fleet seats onto `mosaic launch` (stub brief)"
},
"after": [],
"reviewers": [],
"note": "parked by current owner direction; no `~/.mosaic` changes during internal bootstrap",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 9,
"piece": "Queue as data: `docs/plans/queue.json`, `mosaic queue` is the only writer",
"owner": "darkwing",
"issues": [
1508
],
"closes": [],
"state": "in-progress",
"previousState": null,
"required": true,
"requiredSince": "2026-09-13",
"gate": "G: a fresh seat told only \"run `mosaic queue next` and do it\" starts its piece with zero human messages",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-13_queue-as-data.md",
"anchor": "Piece A: queue record and `mosaic queue` (QUEUE row 9)"
},
"after": [],
"reviewers": [
"filbert"
],
"note": "A1 (journal, lock, CLI, verify) committed 34a72af9; A2 (migration, render, dispatch) committed with this map; Filbert approved both; genesis and hook install by Sage; Gate G also verifies row 10",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 10,
"piece": "Seats read the queue, not CURRENT.md (AGENTS.md cadence, seat context files)",
"owner": "coordinator",
"issues": [
1508
],
"closes": [],
"state": "briefed",
"previousState": null,
"required": true,
"requiredSince": "2026-09-13",
"gate": "verified by Gate G",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-13_queue-as-data.md",
"anchor": "Piece B: seats read the queue, not CURRENT.md (QUEUE row 10)"
},
"after": [],
"reviewers": [],
"note": "AGENTS.md lines done 2026-09-13; the rest starts once genesis is committed",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 11,
"piece": "Brief template `docs/plans/BRIEF-TEMPLATE.md`; `queue add` refuses a missing brief",
"owner": "darkwing",
"issues": [
1508
],
"closes": [],
"state": "waiting-on-jason",
"previousState": null,
"required": true,
"requiredSince": "2026-09-13",
"gate": "two briefs accepted by Jason with no scope question",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-13_queue-as-data.md",
"anchor": "Piece C: brief template (QUEUE row 11)"
},
"after": [],
"reviewers": [],
"note": "template and the `queue add` brief refusal both committed with A1 (34a72af9); the gate counts the first briefs added through the queue",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 12,
"piece": "Reviews as issue comments posted by `queue move ID in-review`, not files in `docs/plans/reviews/`",
"owner": "darkwing",
"issues": [
1508
],
"closes": [],
"state": "briefed",
"previousState": null,
"required": true,
"requiredSince": "2026-09-13",
"gate": "one review round with no new file under reviews/",
"gateOwner": "sage",
"brief": {
"path": "docs/plans/2026-09-13_queue-as-data.md",
"anchor": "Piece D: reviews through a channel, not files (QUEUE row 12)"
},
"after": [],
"reviewers": [],
"note": "starts once genesis is committed; the live posting test needs the per-seat credential file (8.9)",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 13,
"piece": "Ledger \"queue\" section: issue/row/seat drift printed with the weekly number",
"owner": "darkwing",
"issues": [
1508
],
"closes": [
1508
],
"state": "briefed",
"previousState": null,
"required": true,
"requiredSince": "2026-09-13",
"gate": "first run Monday 2026-09-21, zero violations or every one moved same day",
"gateOwner": "sage",
"brief": {
"path": "docs/plans/2026-09-13_queue-as-data.md",
"anchor": "Piece E: ledger checks the queue (QUEUE row 13)"
},
"after": [],
"reviewers": [
"filbert"
],
"note": "starts once genesis is committed; closes #1508 when done, so rows 9 to 12 close nothing; first run is the Monday after E is approved (Q7)",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 14,
"piece": "Discord connector pilot: Sage answers in Shared Signals (chat only, no tools, no repo writes)",
"owner": "coordinator",
"issues": [
1509
],
"closes": [
1509
],
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "H: offline suite green, eight-step live pilot with private receipts, then Jason says the reply reads as Sage",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-13_discord-connector-pilot.md",
"anchor": "Discord connector pilot: Sage on Shared Signals"
},
"after": [],
"reviewers": [
"filbert"
],
"note": "done: rev-code-02 APPROVE 26170 (round 9), committed 786e379c; pilot steps 1-8 done with private receipts, Gate H passed (Jason, 2026-09-13: replies read as Sage); connector left running for MVP iteration; reviewer lane was Filbert or orch-01",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 15,
"piece": "Discord connector: eyes reaction on every admitted message as a read receipt (MVP iteration 1)",
"owner": "coordinator",
"issues": [
1509
],
"closes": [
1509
],
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "Jason sees the reaction on a live message",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-13_discord-connector-pilot.md",
"anchor": "11. MVP iteration, after the pilot"
},
"after": [],
"reviewers": [],
"note": "done: committed 93d6b624, live check passed 19:21 UTC (turn record receipt ok, Jason: test is successful), receipt `mvp1-read-receipt-20260913T192158Z.json` in the private evidence dir; reaction placed at admission before the engine runs, outcome in the turn record, none on drops or refusals; `scripts/test-discord.sh` 28/28 (90 node tests)",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 16,
"piece": "Repository-native development bootstrap: five internal agents, Darkwing coordination",
"owner": "darkwing",
"issues": [
1510
],
"closes": [
1510
],
"state": "waiting-on-jason",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "source approved; Researcher live response passed 26216; existing native Rocko lock preserved, no new Rocko model test; no MVP acceptance/publication",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-13_internal-development-bootstrap.md",
"anchor": "Repository-native development bootstrap"
},
"after": [],
"reviewers": [
"filbert"
],
"note": "source approved by internal Filbert, exact R1 receipt 26204; six offline tests independently pass in both copies; 24 config tests and five no-effect checks are author evidence",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 17,
"piece": "Discord connector: systemd user service with a supervised pre-start (`recover`, exit 3 never retried) (MVP iteration 2)",
"owner": "coordinator",
"issues": [
1509
],
"closes": [
1509
],
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "the Sage connector runs under `mosaic-discord@shared-signals`, survives a kill with a clean restart, and stays down behind `discord.sh stop`",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-13_discord-connector-pilot.md",
"anchor": "11. MVP iteration, after the pilot"
},
"after": [],
"reviewers": [],
"note": "done, operator-verified by Jason 2026-09-13 (all four steps): suite 40/40 (95 node tests); Sage seat migrated 19:35 UTC, SIGKILL recovered in 16 s with the dead lock cleared, brake held (exit 3, no restart), released and READY; receipt `mvp2-service-unit-*.json` in the private evidence dir; the first cut (ExecStartPre) looped and was replaced by `run --supervised` before any traffic",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 18,
"piece": "Control board row for the Discord connector (MVP iteration 3): discovery from binding files, liveness from run.lock, reply refused",
"owner": "darkwing",
"issues": [
1509
],
"closes": [
1509
],
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "a Sage (discord) row on the board shows live, offline and braked correctly, and reply from the board is refused",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-14_discord-board-row.md",
"anchor": "Discord connector board row"
},
"after": [],
"reviewers": [],
"note": "done for bounded local delivery; Jason accepted visual test (parfait), R3 26257 reviewed, 322 tests and live 409 refusal verified; owner by Jason's ruling 2026-09-13, coordinator answered connector-side questions; also briefed in the pilot page section 11",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 19,
"piece": "Discord connector: binding reload without a restart (`reload` verb, SIGHUP, `systemctl --user reload`); channels, users, limits and guildName apply in place, identity, engine and context stay fixed, an invalid file is refused and the old binding kept (MVP iteration 4)",
"owner": "coordinator",
"issues": [
1509
],
"closes": [
1509
],
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "edit the binding, run `scripts/discord.sh reload shared-signals`, the change applies with no restart, a broken edit is refused and journaled",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-13_discord-connector-pilot.md",
"anchor": "11. MVP iteration, after the pilot"
},
"after": [],
"reviewers": [],
"note": "done: caaef941; live 00:03 UTC: reload applied Carmen's entry with no restart, unknown key refused by the CLI (exit 2), fixed key refused in the process with the binding kept, `systemctl --user reload` applied; suite 41/41 (101 node tests); receipt `mvp4-5-reload-carmen-*.json`",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 20,
"piece": "Discord connector: per-user channel allowlist in the binding and Carmen enrolled (all listed rooms except #sage-admin) (MVP iteration 5)",
"owner": "coordinator",
"issues": [
1509
],
"closes": [
1509
],
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "Carmen gets a reply in #general and silence in #sage-admin; Jason unchanged",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-13_discord-connector-pilot.md",
"anchor": "11. MVP iteration, after the pilot"
},
"after": [],
"reviewers": [],
"note": "done: caaef941 (`users[].channels` allowlist, `channel-not-for-user` drop); Carmen enrolled live by reload 00:03 UTC; her first message was the remaining check; #1509 closed with rows 14 to 25 done (lead decision 29)",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 21,
"piece": "Discord connector: read-only tools for the Discord Sage through a Mosaic pi extension confined to declared roots (MVP iteration 6)",
"owner": "coordinator",
"issues": [
1509
],
"closes": [
1509
],
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "Sage answers a question from a file under a declared root with the reads in the turn record; a read outside the roots is refused and recorded",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-14_discord-readonly-tools.md",
"anchor": "Discord Sage: read-only tools (iteration 6)"
},
"after": [],
"reviewers": [],
"note": "approved: rev-code-02 round 2 verdict 26276 (tree 43f0329b), committed; Jason ruled R1–R7 2026-09-14 (roots docs/ and agents/sage/, Carmen included); done with rows 14 to 25 when #1509 closed (lead decision 29); reviewer per Q12",
"blockedReason": null,
"createdAt": "2026-09-14"
},
{
"id": 22,
"piece": "Board attention status: completed replies idle, explicit human input waiting",
"owner": "darkwing",
"issues": [
1503
],
"closes": [
1503
],
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "operator accepted full targeted status sequence: explicit request waiting, Seen hides attention but preserves waiting, completion returns idle; broader MVP/cross-harness acceptance and publication separate",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-13_board-attention-status.md",
"anchor": "Board attention status correction"
},
"after": [],
"reviewers": [
"filbert"
],
"note": "source approved, receipt 26248; reviewer 193 per copy, author union 213 pass; actual Researcher scan idle; operator accepted (row 1); #1503 closed (lead decision 29)",
"blockedReason": null,
"createdAt": "2026-09-14"
},
{
"id": 23,
"piece": "Discord connector: writes confined to the `shared-signals` root plus web fetch and search for the Discord Sage (MVP iteration 7)",
"owner": "coordinator",
"issues": [
1509
],
"closes": [
1509
],
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "Sage writes a naming shortlist into the repository from #ideas with the write and web calls in the turn record; a write outside the root is refused",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-16_discord-write-and-web-tools.md",
"anchor": "Discord Sage: writes into the strategy repository, and web research (#1509, QUEUE row 23)"
},
"after": [],
"reviewers": [],
"note": "done 2026-09-27 (Sage, lead decision 29): committed and pushed 1685deb4; live turns wrote `vault/Businesses/naming.md` with web_search and web_fetch in the turn record; the outside-root refusal rests on the offline suite, not a live turn; reviewer per Q12",
"blockedReason": null,
"createdAt": "2026-09-16"
},
{
"id": 24,
"piece": "Discord connector: git verbs (status, commit, pull ff-only, push) for the Discord Sage on the `shared-signals` root, seat identity through the existing credential helper (MVP iteration 8)",
"owner": "coordinator",
"issues": [
1509
],
"closes": [
1509
],
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "Sage commits and pushes a decision file from #ideas; GitHub shows Sage as author with a Requested-by trailer; no token in any record",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-16_discord-git-tools.md",
"anchor": "Discord Sage: git for the strategy repository (#1509, QUEUE row 24)"
},
"after": [],
"reviewers": [],
"note": "done 2026-09-27 (Sage, lead decision 29): committed and pushed 1949ed8d; live-configured 2026-09-18, never used from Discord; records now go through row 25 and SetSpark cutover freezes `vault/`, so first real use is the check and a failure opens a new issue; reviewer per Q12",
"blockedReason": null,
"createdAt": "2026-09-16"
},
{
"id": 25,
"piece": "Discord connector: SetSpark record client for the Discord Sage, fixed verbs against setspark-api, connector-verified approvals (MVP iteration 9)",
"owner": "coordinator",
"issues": [
1509
],
"closes": [
1509
],
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "Sage creates one work item from #sage-admin and the API shows it with revision 1; a proposal is approved by the Approve button and the audit row carries Sage's key id and Jason's Discord id separately; rev-code-02 approves on #1509",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-20_discord-setspark-client.md",
"anchor": "Discord Sage: SetSpark record client (#1509, QUEUE row 25)"
},
"after": [],
"reviewers": [],
"note": "done 2026-09-26: committed and pushed 43d7574d, live check passed (DEC-010, request 2, approved by button at 21:38:58Z, lead decision 19); approver rule enforced by the service since shared-signals cc74d92 (lead decisions 24 and 28); reviewer per Q12",
"blockedReason": null,
"createdAt": "2026-09-20"
},
{
"id": 26,
"piece": "Registry increment 3 (headless identity-env leak)",
"owner": "unassigned",
"issues": [],
"closes": [],
"state": "parked",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "Jason reopens",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/CURRENT.md",
"anchor": "Completed checkpoint: #1500 increment 2 (historical)"
},
"after": [],
"reviewers": [],
"note": "#1500 closed on increment 2; Jason has not asked for 3",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 27,
"piece": "Auth/provider/harness registry review",
"owner": "unassigned",
"issues": [],
"closes": [],
"state": "parked",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "Jason reopens",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-03_auth-provider-harness-registry.md",
"anchor": "Harness declaration + centralized auth/provider registry"
},
"after": [],
"reviewers": [],
"note": "Paused for owner alignment",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 28,
"piece": "CI runners, second real adapter, push automation",
"owner": "unassigned",
"issues": [],
"closes": [],
"state": "parked",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "Jason reopens",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/ROADMAP.md",
"anchor": "Explicitly deferred"
},
"after": [],
"reviewers": [],
"note": "Deferred by owner 2026-09-03",
"blockedReason": null,
"createdAt": "2026-09-13"
},
{
"id": 29,
"piece": "Console features outside the refined session-chat brief, including Fresh creation and model switching",
"owner": "unassigned",
"issues": [],
"closes": [],
"state": "parked",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "Jason reopens",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/2026-09-13_webui-session-chat.md",
"anchor": "WebUI session chat: refined brief and delivery plan"
},
"after": [],
"reviewers": [],
"note": "Deferred by WEBUI Q1; required history/control/stop now belong to row 5, not this parked item",
"blockedReason": null,
"createdAt": "unknown"
},
{
"id": 30,
"piece": "Open gaps from the MVP work",
"owner": "unassigned",
"issues": [],
"closes": [],
"state": "parked",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "Jason reopens",
"gateOwner": "jason",
"brief": {
"path": "docs/plans/DEFERRED.md",
"anchor": "Open"
},
"after": [],
"reviewers": [],
"note": "Fixed only when a gate needs them",
"blockedReason": null,
"createdAt": "2026-09-13"
}
],
"retired": [],
"highWater": 30
}
```
@@ -0,0 +1,9 @@
{
"at": "2026-09-15T00:46:26.639Z",
"issue": 1512,
"startedAt": "2026-09-11T00:00:00.000Z",
"lastActivity": "2026-09-10T00:00:00.000Z",
"lastAssistantText": "OLD_FIXTURE_REPLY",
"oldReplyStillPresented": true,
"syntheticOnly": true
}
@@ -0,0 +1,6 @@
{
"discordDependencyCommit": "1ac812d3d5f3939221e7578502a10240ac82a6af",
"verifiedTrackedDependencyFiles": 31,
"sevenWorkingAndFrozenPinsUnchanged": true,
"scope": "byte comparison, not proof of snapshot construction history"
}
@@ -0,0 +1,58 @@
# #1512 inherited engine diagnosis
Rocko returned a read-only investigation. No source correction, live connector
operation or authority transfer follows from it. Filbert's seven source pins
remain the #1512 candidate; they do not change Discord engine code.
## A. Test synchronization and cleanup
Rocko reports the tool-turn test asserts engine.busy immediately after prompt
resolution at agent_end, while busy clears on a subsequent agent_settled event.
Separate pipe reads allow the assertion to run between events. Another basic
prompt test uses the same assumption. Success-only engine.stop cleanup leaves
the fake child and pipes open after an assertion fails, hiding the final spec
report and making the test file hang.
Darkwing independently ran Rocko's scratch relay with DELAY=50 and finally cleanup.
It failed 'busy after plain resolved', true versus false, and exited in 148ms.
Log: /tmp/darkwing-1512-race-confirmation.txt. This confirms the race mechanism,
not the exact historical assertion from the earlier truncated spec log.
Rocko's scratch directory:
/tmp/claude-1000/-mnt-storage-src-mosaic-stack/0422f20f-4d8c-43fe-b429-a2730cc1ab9d/scratchpad/1512/
Includes proxy-pi.mjs, race-repro.test.mjs, hang-repro.test.mjs,
hang-noforce.txt, hang-force.txt and stress/base-prev run logs.
He reports natural busy failures at lines56/75 under contention and deterministic
hang behavior without finally cleanup. Darkwing did not repeat load stress.
## B. Timeout test synchronization
Rocko reports a 100ms timeout test reads fake-pi's command log before the child
has created it or recorded abort under contention. That assertion can also skip
success-only cleanup. He observed this in both prior and row21 code. Not the
specific failed test named in Darkwing's first run.
## C. Separate engine behavior concern
Rocko reports a next-prompt refusal after a client timeout when a done pending
entry remains, busy is false and agent_start has not yet been consumed. prompt()
then omits followUp because it considers only pending entries not marked done.
The still-streaming fake engine refuses that prompt. This is a potential real
engine defect, not merely the busy assertion race. No live incident is established.
The row21 owner retains this code. Darkwing notified orch-01 on its verified named
socket and requested ownership/disposition, not a code takeover. A focused owner
fix needs regression coverage and review. Suggested direction from Rocko: account
for unsettled pending runs when choosing followUp; do not blindly change logic
from this note alone. Test cleanup and event synchronization also need correction.
## Provenance and gates
Rocko reports frozen Discord files match row21 commit1ac812d3, not the stated
archive basec4fc8e7d in HANDOFF. Darkwing requested Filbert's construction evidence
and append-only provenance correction outside frozen R1. Do not rewrite the old
handoff or infer its base from a mutable HEAD.
The later 351/351 bounded run remains valid narrow evidence; the earlier failure
is not erased. Integration stays held for owner disposition and provenance
reconciliation. No live action, publication, operator acceptance or GateF claim.
@@ -0,0 +1 @@
{"number": 1512, "creator": "darkwing", "charter": "docs/plans/2026-09-15_relaunch-activity.md"}
@@ -0,0 +1,89 @@
1e1aef2c74434e388d61b03407cd323336d20b7ef2106893d16a39638ee12a64 packages/control-board/package.json
8966f1f98ea81b4525345fc4889db5e0e5263ca41b5132ac8dd9bd99a2c88522 packages/control-board/src/discord.mjs
d6ac9b7b9661e6c85e912612b847c14ed38b7596b7db0a86a692da202f2a9f9c packages/control-board/src/serve.mjs
3f8822e97b2ba26b7035f86cf9bac421039957faab7f4543a1ed22c4568db23d packages/control-board/tests/attention-flow.test.mjs
5429fedc72af5cd9b7ddfa3b70e31aa7ecb4c511c51e0b640a16f9773912496a packages/control-board/tests/attention.test.mjs
64a67c91021de0c0ca583b65f8eed23e7e14415f2c18677b80754a7178363d04 packages/control-board/tests/discord.test.mjs
06a7eab5fa07ca83a210c5317e99ecccff8e8085197b7f66bbbecd67a4d8d541 packages/control-board/tests/scan.test.mjs
41bcaf8685437f130edc98aad92cfdb62e8201ee1861531ee9fa99069f681885 packages/control-board/tests/serve.test.mjs
ce0d74970496f197f507741261b8bf0b8e9c89909a44627eb11221ab407a6a8b packages/discord/README.md
587e01d440096c04ca8f4ae5ea096b4eab846989824f3effde12bbd66c71c6f9 packages/discord/extension/readonly-tools.mjs
d6a144b3c396c02e069a76d0d65bcd842a84f9de618b0172f1e1f7fed42757f9 packages/discord/fixtures/binding.example.json
4bfa50b150d49086e6438625c78261b58e6afb90601a9fb7ea464cc03faa29fb packages/discord/fixtures/claim-worker.mjs
012ce50053e0a38bcb2aebc4011c24ed41a93d4d7b79790f7ba0fc0049f0bc16 packages/discord/fixtures/legacy-owner-worker.mjs
a52c6bfc9bd26a3b021d3a96bddb0f5c45845d752ca4579ef04ef60667f110f3 packages/discord/package.json
573b015bc7a929903e33657f3cf79f0c6da1e927b3f9188f842ee867ec097d3f packages/discord/src/authorize.mjs
73e96f8421b88e3eb71666fb98673f10f659d0cd3535c5ac2897d3e3d431bab3 packages/discord/src/binding.mjs
a12c5a7b41060253ee12a3019f5ec930597f7866df247eb949bdf149223e5e93 packages/discord/src/cli.mjs
ba2132fe37d8ba4c04ea32e88aef7faae27fda4d2121426ab6c373aabe8b9b21 packages/discord/src/connector.mjs
18c583cb72c33bc7e41b9904dade98777e3e3fe8ff0820a56fc85404e8505904 packages/discord/src/context.mjs
67a5aca259731763caac0f199be8cae95c2606d740083eca2dff489b59353bf3 packages/discord/src/engine-pi.mjs
917b0e6c4f100e93f056b925e38d97eacdd8b2596e39fbd3f88c0b62b973d8ed packages/discord/src/errors.mjs
2d7f7fcbe62aac7b724a66714cd24d5634bd63f7660ef5979b8d4fccb0689b08 packages/discord/src/gateway.mjs
db33880f88dc2c7708015efecd0c6db604bc40387ea0dd6259d91f621a5b022c packages/discord/src/journal.mjs
1b83781c46cc8d9437b957e8757ea64a4da15a08d80706745770a06e411e72ff packages/discord/src/rest.mjs
063bbd5b47cd9f0d8a34adde09a5c63bd52d2d88be02a381662406ab07fcbe89 packages/discord/src/tools.mjs
a4ce74917ad68c82bd7ea07d6039813a94cec724c74f18e1e65a8f762d2cb7e1 packages/discord/systemd/[email protected]
1c1629800b1f1a417bff87e51f716cdc75a6725183a3f518096d21704de39804 packages/discord/tests/authorize.test.mjs
bcead9d2a44da3a12085dab2276f0afea5fd850061ac9dd9c4b64952d07712ee packages/discord/tests/binding.test.mjs
fc6eaa88db5d56d563ac2a83ace7db70c40cb328664c9782af8aaacf4ca4e978 packages/discord/tests/connector.test.mjs
b1cbc424a44d99d5ef6782fa4e17866030953ffa9a6e9ef97bac99d6b20dd576 packages/discord/tests/context.test.mjs
cb30bb0571756c66ff8177189f4722411245bf2f9b49b5a5d219f5009e0758f5 packages/discord/tests/engine.test.mjs
0930b8d13c10febd5ac98e69795c66a5313162c4e20d0e1f446ffdfc0c73bf64 packages/discord/tests/fake-pi.mjs
2fb4d80d228591d8b537a73995403c77c4fcc35fcfab3d8d8ad2705638825c71 packages/discord/tests/gateway.test.mjs
3da0370367aa6d7dea1e0e5edcc62350ad88399577f895b2bd29965e64360e2a packages/discord/tests/helpers.mjs
c70fc1da93e4bead261b2935098918804dfafd29efbf2e4a0256fadfc13c62b4 packages/discord/tests/journal.test.mjs
6ce9e42a35145df726449b01e2b89d9a468ee6bccc4cc754175e0e67f26f43af packages/discord/tests/recover.test.mjs
2c3c9b3a38f6aadb7c490f464573df3ad0dc207457838bdb727f4fbf29b69b75 packages/discord/tests/rest.test.mjs
a1c8203fecc706ea97896eff91a07145446440a7e0ca98b36ed2b038b30d5473 packages/discord/tests/tools.test.mjs
601db1de6d4838785f334dd93dd191d444276f306e016b3a4137659eaae76561 packages/ledger/README.md
e33fc5be5cdeb69f6f443a44c5b02a7bc346ac74dd69f984538e424a8d1b5de9 packages/ledger/src/cli.mjs
fbd95e45c5753cb44e39dfead0c6e0d2a5f07859dd9411f1dd659dde98af2ffc packages/ledger/src/ledger.mjs
c836ecd64068cccf7f515ab699c6a218e5ea1618c39e7e88be21bfa74d96414e packages/ledger/tests/gitea-helper.test.mjs
8e838a5333cbe6aafda5d72b810eb2840c4439973841d9c2bf5b55bc660455a3 packages/ledger/tests/ledger.test.mjs
bfaa5eed44c9eba9c195c38008db785d9c78e58f7544be7e46fd32155708f256 packages/mosaic/README.md
12838980706273f2a3daf84a1a78d55e06018c1f78587cc9db4527a8aa8b074f packages/mosaic/package.json
dd07bea32281e66d0f7d8485595eb403305fac1ca5943daa67a73bc0666caecc packages/mosaic/src/cli/main.mjs
ed9920d465f74387566443544131226050dec8f13efefaad2d48157c8808f162 packages/mosaic/src/execution.mjs
fa5c5fe977916418e7d2c4f6ceeedb6f3bcb6ae854f680bec07f7930b937b1f4 packages/mosaic/src/fixture-store.mjs
ac64c9c32ba928cc557107a47e127350efd724a7d8cdf1f399630964fe621879 packages/mosaic/src/index.mjs
7a3e66fc20e3e51d838c66e2cb0f3c2b73047ffabccb93532bd325a8a65e68c2 packages/mosaic/src/materialize-fixture.mjs
e4d2e55e077f38c5523d0211e6ec90e301804d8f6896674de2bbeaf0b049be58 packages/mosaic/src/records.mjs
28f87b70d0d42d4f4f25a7d72f138e87dcfcd79d542b43da7fb208444f4deeb7 packages/mosaic/src/refresh-fixture.mjs
d33985227f536117bcfad1d7f5ecd777adf02c073c8a9bff634c4b32f2b85e48 packages/mosaic/src/registry.mjs
545c126314e14fb772dbe082adcec554e84bbfdef8f93e231ae825ed5b8ec845 packages/mosaic/tests/fixtures/valid/auth/accounts/openai-codex/homelab-openai/account.json
3bcf68dd33fd1e5b219ffaf3a15880718cb3751ac07f132d2466c408e2fda6f3 packages/mosaic/tests/fixtures/valid/auth/providers/ollama-remote.json
d214e39539b5de9d6f2350b5d681cfa340ff2d255c215d25499aeb10711fcd7f packages/mosaic/tests/fixtures/valid/auth/providers/openai-codex.json
e986f8fd65a5a501510dc6e706d772a5c0ddc336864ff6c41405f3901749ffe1 packages/mosaic/tests/fixtures/valid/auth/settings/research-default.json
ff1b45df2503ccd796c220364ea217c5eb1ab1eaf22ffa8094ff0851dfb20ed3 packages/mosaic/tests/fixtures/valid/harnesses/pi.json
44bcb47057dbaaff69992c8e7355b8195f3db6f8ccb7bd05bb2ea913e3940ea3 packages/mosaic/tests/materialize.test.mjs
7747bace9da600ef93776b3e473efdd3f1f57420aaa3fff207a1fd0c6c5b5e0f packages/mosaic/tests/refresh.test.mjs
1c2f018de202003ddf69f333fc1c066d265b312a431007742aebd7ccca5a93a7 packages/mosaic/tests/registry.test.mjs
1891119852df1930505bbec6e0543b95f82d4ff3834956fe433d9f58c3204645 packages/mosaic/tests/safety.test.mjs
4bb04a3188ab9c5c985206da83bc1cc6f9be042a072aad75224560e7acf57e29 packages/seat/README.md
26ee149f870ff56ad6d1c3d7c3b4b2301879c59f02d5840078c07e7964cc2175 packages/seat/package.json
a2880bd7b66cd9d6fc3588cc739eadc5db7fe806f81da86e0ac09bbff15a3099 packages/seat/src/cli.mjs
eec970961a667c9e614d1474d33c31a019b2cf1b0bf931a51458d874bca13a15 packages/seat/src/seat.mjs
c06f2fc951ce56c905a98175dc82966893a2ba801405fb407c6c59363d029288 packages/seat/tests/seat.test.mjs
6eca43194632017ffed73d62ba76c74435c4b661f4ec3fc76d8b0bae8bcf9089 packages/webui/README.md
ee81d2b902a0b250f32d997d27085b9c76a7c98acdd0a0b55c65cf9342047b4e packages/webui/package.json
20f8091e5fb3fcaba583b601ce258999d44afbe569e93c979938ace759b4eef4 packages/webui/src/cli.mjs
a30ddcd349703aff7464c34bef3fffdff405ee50c113440d7c8693c02d210972 packages/webui/src/public/assets/fonts/manrope-400.woff2
a30ddcd349703aff7464c34bef3fffdff405ee50c113440d7c8693c02d210972 packages/webui/src/public/assets/fonts/manrope-500.woff2
a30ddcd349703aff7464c34bef3fffdff405ee50c113440d7c8693c02d210972 packages/webui/src/public/assets/fonts/manrope-600.woff2
a30ddcd349703aff7464c34bef3fffdff405ee50c113440d7c8693c02d210972 packages/webui/src/public/assets/fonts/manrope-700.woff2
e01b637272e0cbdfb240184dd98ea5cc671556d9894dae2668d92ab2c906787c packages/webui/src/public/assets/fonts/manrope-OFL.txt
0d9aaaaa963b675d94d8fbee0cf47a8ecf03ea412f8b14aba96416375e5a2bb5 packages/webui/src/public/assets/fonts/manrope-sources.txt
d568df6dca14ae336db3b627639c8181c088649a21ffe992f9bfacc20b5b6238 packages/webui/src/public/brand.js
1ce47447339a274dd51c0168838a5e58e0addaf2200e04625bc1da5bc9279097 packages/webui/src/public/console.css
db055d5b8a3710f4164193550ac25b7c5d223dd890ae3e9d73daaada38849557 packages/webui/src/public/index.html
7add449911677c662a05ad5d9c2462f919e759574fe4b85bd13a705cc54f6856 packages/webui/src/public/live.css
b3e6e240fa93555e30321f897405839856b3438ef44b1cea73d2f5fbd899077e packages/webui/src/public/shared/app.css
c0c1a360ce44a7439e67eea36b9237c59621aa3fd78571a823fd23dc15a950f6 packages/webui/src/serve.mjs
b1c2860e69bb099fda476c291c41adddf736dcaf99b68620d5401ce92396336a packages/webui/tests/browser-edge.test.mjs
3ff14de07e87cc624433db6425b9d05e40d98f61e40f2bbf1fbee7431f9d73c1 packages/webui/tests/browser.mjs
01636f05a8a59e8aaf7da000e631b83718b79a5e5bbc36d5b67738215cc250a7 packages/webui/tests/browser.test.mjs
24cf26c357164857f29be4f47204812d560310ee79dfbfebd7aa48f7a84ba9b0 packages/webui/tests/discord.test.mjs
0aea0461d9a63b43e7cd2fea7cbaf335af48470b01481d3507a48bce8c355f79 packages/webui/tests/fixture.mjs
15e77422890bc50cdb700484ca7e16ac32476aa8ec61d5575a6e0a920694bc4c packages/webui/tests/serve.test.mjs
2c822c787f1e6e5779aa74a5c054845de31f5f990e5a4b97a1398b9aaa856a7e scripts/test-discord.sh
@@ -0,0 +1,17 @@
{
"recordedAt": "2026-09-15T01:13:19.864188+00:00",
"author": "Filbert",
"issue": 1512,
"kind": "append-only provenance correction; supersedes only HANDOFF archive-base statement",
"frozenCandidate": "/tmp/relaunch-activity-r1-8ckPxKjN",
"candidateManifestSHA256": "47769fada74c5684dd0ca32c41cea9dcf60e039f5a07657546e17a240af13e9d",
"incorrectEarlyCheckpoint": "c4fc8e7d7f76b2b8cda6a6a0f6fc760d37235c98",
"verifiedArchiveContentBase": "1ac812d3d5f3939221e7578502a10240ac82a6af",
"construction": "git archive HEAD | tar -x -C \"$SNAP\", then working packages/control-board, packages/webui and packages/seat overlays, charter and red log copies. HEAD was not pinned in the archive command. The earlier checkpoint was incorrectly reused in HANDOFF.",
"verification": "All 4509 archive files outside the three overlay package directories match 1ac812d3 byte-for-byte, with no differences or missing files. c4fc8e7d comparison had 21 differing files and predates additional new files. All tracked Discord files and scripts/test-discord.sh match 1ac812d3. Seven candidate hashes still match original manifest.",
"dependencyManifest": "DEPENDENCIES.sha256",
"dependencyManifestSHA256": "deae75b488b2f9172e7168bba83e442a251c294464ee6200527c23ef4599f968",
"dependencyFiles": 89,
"dependencyScope": "All files in six tested packages except seven authored candidate paths, plus scripts/test-discord.sh, pinned from frozen R1 rather than current checkout.",
"limits": "R1 and all prior evidence unchanged. Prior 351-pass serialized receipts describe this actual dependency set, not c4fc8e7d dependencies. Intermittent inherited engine/fixture failure and timeout remain recorded and not green. No source changes, live operations, publication or Gate F approval."
}
@@ -0,0 +1,43 @@
# #1512 R1 source review
Filbert author. Frozen /tmp/relaunch-activity-r1-8ckPxKjN, seven files.
CANDIDATE.sha256 manifest
47769fada74c5684dd0ca32c41cea9dcf60e039f5a07657546e17a240af13e9d.
Darkwing independently verified all seven working/frozen pins and eleven
inherited test pins, read RED.txt and reviewed the scoped scanner/CLI/UI delta.
Darkwing approves the seven source changes. Relaunch notice requires positive
row and matching-registration PID liveness, matching session path, and strictly
newer finite timestamps. Connector registration is excluded. Existing historical
fields, state, Seen, attribution and reply gates remain unchanged. CLI and both
presentations replace current activity with the notice and label retained history.
This does not establish native process-incarnation authentication or resolve old
waiting/error state.
Dewey independently APPROVED scoped UX on the same seven pins. Frozen targeted
scanner/browser suites 8/8 passed, four Chromium cases and 330 contrast samples.
Both fixture pages at 320px preserve history, show the notice without page overflow,
and resume normal presentation after new activity. Historical error label reviewed
in source; the browser fixture uses waiting, not error. No screenshot-based visual
review, real relaunch, live observation or Gate F evidence claimed.
## Verification qualification and integration hold
Darkwing's first exact serialized six-package run timed out at 120 seconds.
/tmp/darkwing-relaunch-r1-tests.txt records an inherited Discord engine test
failure, 'a run with tool turns settles once', before the hang. It is NOT green.
No test runner remained after the harness timeout. Detailed assertion output was
not captured by that reporter before the hang.
An isolated TAP diagnostic of that engine case passed, using test-force-exit and
a 5-second test timeout. This is diagnostic only, not full-suite acceptance.
/tmp/darkwing-relaunch-engine-probe.txt.
A full frozen TAP run with test-concurrency=1 and test-timeout=15000, WITHOUT
force-exit, passed 351/351, zero failed/cancelled/skipped, in 14.27 seconds.
/tmp/darkwing-relaunch-r1-diagnostic.txt. The first failure remains unexplained;
the later pass does not erase it or establish default-concurrency success.
Hold integration while Rocko performs bounded read-only diagnosis of the inherited
engine failure. No author source correction requested yet. No live changes,
publication, operator acceptance or Gate F claimed. Preserve R1 and prior freezes.
@@ -0,0 +1,95 @@
# #1512 R1 candidate re-run against HEAD, 2026-09-26
Requested by Sage over T3 on 2026-09-26. Integration was held because of the
row 21 engine test race, and `1685deb4` fixed that race. This record is new
evidence. The R1 files next to it are unchanged.
## Snapshot
- `/tmp/relaunch-activity-r1-rerun-oupRiqIC`: `git archive 21e3e908`, plus the
working `packages/control-board`, `packages/webui` and `packages/seat`
(node_modules excluded), plus a symlink to the root `node_modules`.
- `CANDIDATE.sha256` in the snapshot hashes to `47769fad…`, which is the R1
candidate manifest. All 7 candidate pins match.
- 71 of the 89 R1 dependency pins are unchanged. The other 18 are
`packages/discord/**` and `scripts/test-discord.sh`, and they equal HEAD.
`engine-pi.mjs`, `engine.test.mjs`, `fake-pi.mjs` and `helpers.mjs` in the
snapshot are byte-identical to HEAD.
- Node v26.8.1. Test union: control-board, webui, seat, mosaic, ledger and
discord tests. The count rose from 351 at R1 to 397 because rows 23 to 25
added Discord tests.
## Results
| Run | Flags | Result | Log (`/tmp/`) | sha256 prefix |
|---|---|---|---|---|
| A1 | `--test-concurrency=1 --test-timeout=15000` (R1 form) | 397/397, rc 0, 19.9 s | darkwing-1512-rerun-A1-bounded.txt | 8b34a64a660d5948 |
| A2 | same | 397/397, rc 0, 19.7 s | darkwing-1512-rerun-A2-bounded.txt | 5c8b96209f5b8b2c |
| A3 | same | 397/397, rc 0, 19.4 s | darkwing-1512-rerun-A3-bounded.txt | e9829f454bf2b63f |
| B1 | `--test-concurrency=1` | 397/397, rc 0, 19.0 s | darkwing-1512-rerun-B1-serial-notimeout.txt | bb6eaedf6250c410 |
| C1 | default concurrency | 395/397, rc 1, hung until I ended a leaked child | darkwing-1512-rerun-C1-default-concurrency.txt | 5200188e1bcd8b15 |
| C2 | default concurrency | 395/397, rc 1, hung until I ended a leaked child | darkwing-1512-rerun-C2-default-concurrency.txt | 1f244a4835000e31 |
In C1 and C2, the same two tests failed, both in `packages/discord/tests/engine.test.mjs`:
- Test 205 (line 100), "a prompt while streaming is held…": ENOENT on
`commands.jsonl`.
- Test 208 (line 146), "tool events from a run that outlived its timeout…":
`engine refused prompt: agent is streaming; specify streamingBehavior`.
Both runs stopped making progress after every test had finished and the
`after()` hook had removed the temp root. The only thing left was a `fake-pi.mjs`
child. I sent that child SIGTERM (C1 pid 3636242, C2 pid 3668771). The file
then exited and the runner printed its summary. Apart from that, I didn't
touch either run.
## Control: clean HEAD, no #1512 files
Snapshot `/tmp/darkwing-head-21e3e908-zinKkQ` (`git archive 21e3e908` only).
Same union, default concurrency:
| Run | Result | Log (`/tmp/`) | sha256 prefix |
|---|---|---|---|
| 1 | rc 124 at the 240 s cap, 178 ok, interrupted in seat and webui files | darkwing-head-default-1.txt | 799acad299d0b20e |
| 2 | rc 124 at the 240 s cap, same shape | darkwing-head-default-2.txt | e9028b28c02ffc86 |
| 3 | 369/370, rc 1, test 187 = the line 146 engine failure | darkwing-head-default-3.txt | fcd85bd474f2c210 |
| discord only | 162/162, rc 0 | darkwing-head-discord-only.txt | 5f068557caee7fc7 |
| seat + webui only | 21/21, rc 0 | darkwing-head-seat-webui.txt | 9952b57707577a92 |
`engine.test.mjs` alone in the candidate snapshot passed 5 of 5 times, 11/11
each run (`darkwing-engine-alone-{1..5}.txt`).
Dewey was running `packages/webui/tests/` from the canonical checkout during
part of this window. That added load. It doesn't explain the HEAD control, and
the two engine failures reproduce without it.
## Reading
The #1512 candidate passes its R1 acceptance command 3 of 3 times at 397/397,
and it passes the serial run with no timeout. Every default-concurrency failure
is in committed `#1509` Discord engine code, and clean HEAD fails the same way.
I found nothing that implicates the seven candidate files.
Two engine defects are exposed under load. They belong to `#1509`, not to #1512:
1. **Test race plus leak, `engine.test.mjs:100`.** The test reads the fake's
command log 20 ms after the second prompt. Under load the fake hasn't written
the file yet, so the read fails with ENOENT. The test has no `try/finally`,
so `engine.stop()` never runs, the fake-pi child stays alive, and the test
file never exits. That is the hang. The fix is the pattern `1685deb4`
already uses elsewhere: wait with `until()` and stop in `finally`. Six other
tests in the file also stop without `finally`.
2. **Engine race, `engine-pi.mjs`, seen through `engine.test.mjs:146`.**
`busy` is `state.busy || pending.some((t) => !t.done)`. Suppose a turn times
out before the engine has read pi's `agent_start`. `failTurn` marks it done,
`state.busy` is still false, and `busy` reads false. The next prompt then goes
straight to pi, and pi refuses it because it's still streaming. This fails
closed: the prompt errors and nothing is misattributed. Production timeouts
are long, so this should be rare there. It's still a real race. One option is
to count a sent turn toward `busy` until pi's own end or settle event arrives,
not only until the client gives up on it.
Rocko's finding C, a follow-up sent after a timeout, looks superseded. Since
`1685deb4`, the engine never sends a follow-up (held prompts), and the line 100
test asserts that `streamingBehavior` is absent. Defect 2 is the timeout race
that remains.
@@ -0,0 +1,18 @@
{
"at": "2026-09-15T00:11:38.757844+00:00",
"newBackend": [
3414098,
"76594640"
],
"health": true,
"pageMatchesReviewedR2": true,
"allRowsHaveAttributionField": true,
"inapplicableAttributionNull": true,
"legacyRegisteredUnknownCount": 2,
"agentPaneIdentitiesUnchanged": true,
"connectorIdentityUnchanged": true,
"allRegistrationBytesUnchanged": true,
"rocko": [],
"rockoWritePerformed": false,
"blocker": "Rocko registration PID 185602 is dead; native context/lock PID is 3707667. No identity repair authorized."
}
@@ -0,0 +1,2 @@
{"at": "2026-09-15T00:10:51.631297+00:00", "event": "owner-authorized-restart-intent", "oldBackend": [3769124, "72871926"], "agents": {"default/darkwing": [[2733924, "12863634"]], "default/dewey": [[934346, "466065"]], "default/filbert": [[72183, "100870"]], "default/researcher": [[173699, "66404285"]], "mosaic-fleet/rocko": [[90599, "128275"]]}, "connector": [3022843, "67887873"], "registrationHashes": {"seats/repo/darkwing/registration.json": "4eda287e7f3bee8967052d90d5a15fb8bb61457e25991192bfaffcaba92c6075", "seats/repo/filbert/registration.json": "efe469ed81855c7e4d4667146a2e520c3a5f953a534cf4a90d6739d2e6335767", "seats/repo/rocko/registration.json": "2195325104fdaf13e5a8d351143f6c046144b9b99865d18b21d01564ac23b327", "seats/repo/dewey/registration.json": "92960db9f3f3d5c56c5cc79d23f39b3df3f1dcdeaae61bccbf0e62d98563aa6c", "seats/repo/researcher/registration.json": "77816e560918d2a9534ff2c97ef201605fbb5b6ef8e341d4dd948684082563fa"}, "configHash": "8e6b3a4049c7ab11ab6d842d974da0bcf07985cf4db988995436ed4b94b44338", "rockoWriteBlocked": {"registeredPid": 185602, "nativePid": 3707667}, "pinsVerified": 18}
{"at": "2026-09-15T00:10:51.650299+00:00", "event": "replacement-started", "newPid": 3414098, "oldExitedGracefully": true, "log": "/tmp/task-attribution-backend-gmaofryr.log"}
@@ -0,0 +1,7 @@
{
"at": "2026-09-14T14:08:03.786Z",
"issue": 1511,
"exit": 4,
"explicitByRejected": true,
"liveFilesChanged": false
}
@@ -0,0 +1,34 @@
# Proposed #1511 local test envelope
Original proposal retained below. Jason authorized the backend update, completed
2026-09-15. Rocko's stale registration blocked that target before mutation.
Jason then explicitly authorized Filbert instead and confirmed Claude console
integration remains separate. The Filbert metadata update and live API checks
passed; operator visual acceptance is pending. Receipt: live-test.jsonl.
No Rocko registration or process was changed.
- Reverify the approved R2 pins and current backend PID/start/command/config.
Record the five native agent identities and connector service identity.
- Restart only the control-board backend on loopback 7331, using its existing
command/config. Do not restart agents, WebUI or the Discord connector.
- Verify health and that the updated reader accepts existing records. Check
actual asset loading before claiming either presentation loaded R2.
- For the visual test only, update the repository-native Rocko registration's
task to '#1511 task attribution user test', with explicit setter darkwing.
Use the reviewed seat task command and explicit repo layout. First verify the
registration matches the intended native session; a mismatch blocks the write.
- Preserve the original registration privately outside the repository. Never
print its contents. Assert only task, taskSetBy and updatedAt change, with all
identity fields and startedAt preserved. No other registration may change.
- Verify API selection and ask Jason to inspect the 'set by darkwing' tag and
caller-claim wording. Legacy registered attribution remains unknown; inferred
and connector tasks remain not applicable. Agent/connector identities unchanged.
Rollback: before any attribution write, an incompatible old reader can be restored
only from the retained prior artifact. After the one authorized write, restoring
an old reader requires restoring that record's original task metadata first, with
an exact unchanged-current-record check. If anything else changed, stop rather
than overwrite it. Keep the compatible reader while resolving the conflict.
No broad metadata migration or automatic rollback over concurrent edits.
This envelope does not authorize publication or the later Filbert behavior task.
@@ -0,0 +1,3 @@
{"at": "2026-09-15T00:36:13.204764+00:00", "event": "owner-authorized-filbert-test-write", "target": "repo/filbert", "backup": "/tmp/task-attribution-filbert-k3o7zimk/registration-before.json", "nativeIdentity": [793364, "56603274"], "backend": [3414098, "76594640"], "agents": {"default/darkwing": [[2733924, "12863634"]], "default/dewey": [[934346, "466065"]], "default/filbert": [[72183, "100870"]], "default/researcher": [[173699, "66404285"]], "mosaic-fleet/rocko": [[90599, "128275"]]}, "connector": [3022843, "67887873"], "beforeHash": "efe469ed81855c7e4d4667146a2e520c3a5f953a534cf4a90d6739d2e6335767"}
{"at": "2026-09-15T00:36:13.852595+00:00", "event": "verified", "agent": "filbert", "task": "#1511 task attribution user test", "taskSource": "registration", "taskSetBy": "darkwing", "afterHash": "1bc90238adcb0210fcd027d76ac2d87e292d5fe29e6892d4c3efad4fddb93edd", "onlyAllowedTaskMetadataChanged": true, "allOtherRegistrationsUnchanged": true, "agentAndConnectorIdentitiesUnchanged": true, "backendUnchanged": true, "operatorVisualAcceptance": "pending"}
{"at": "2026-09-15T00:47:20.173624+00:00", "event": "operator-visual-confirmed", "tableEvidence": "/tmp/pi-clipboard-afe5713e-77ed-4be6-9a24-d455178ed1f0.png", "inspectorConfirmed": "darkwing (as claimed by the caller, not verified)", "scope": "bounded local attribution delivery; publication and Gate F separate"}
@@ -0,0 +1,15 @@
d289ec7d3a45c541346ece6b32908d75efe4ca0ad47df729bcf493d9bc9be718 packages/seat/src/seat.mjs
a2880bd7b66cd9d6fc3588cc739eadc5db7fe806f81da86e0ac09bbff15a3099 packages/seat/src/cli.mjs
4bb04a3188ab9c5c985206da83bc1cc6f9be042a072aad75224560e7acf57e29 packages/seat/README.md
c06f2fc951ce56c905a98175dc82966893a2ba801405fb407c6c59363d029288 packages/seat/tests/seat.test.mjs
a40c57d011ce54956856c77e6758ca12dcd0ffd4be0d53ae2707b7d3fe3708b1 packages/control-board/src/scan.mjs
7f9b3b684e8af9f933d23cab956d32aad345b61be5001f8f6f591d3a91c57d12 packages/control-board/src/page.html
5f3bf97fd90045b2b1f08f2922ec0f6b33b3729bef39f9bfcbe0229dcd6e2080 packages/control-board/README.md
06a7eab5fa07ca83a210c5317e99ecccff8e8085197b7f66bbbecd67a4d8d541 packages/control-board/tests/scan.test.mjs
41bcaf8685437f130edc98aad92cfdb62e8201ee1861531ee9fa99069f681885 packages/control-board/tests/serve.test.mjs
64a67c91021de0c0ca583b65f8eed23e7e14415f2c18677b80754a7178363d04 packages/control-board/tests/discord.test.mjs
772d564b974de9d30df296d538e7878ceb2421f479ed07285456e7d43dfef8b9 packages/webui/src/public/app.js
0aea0461d9a63b43e7cd2fea7cbaf335af48470b01481d3507a48bce8c355f79 packages/webui/tests/fixture.mjs
d45b138bdb5a3eaf6cf2470b7b2beed16de0a635ad1868c8d25336b4040fe163 packages/webui/tests/browser.test.mjs
d39e5d1e1b7e3d4aa6689e20faabeee6691656e8661f866d98731912eb140d06 packages/webui/tests/browser-edge.test.mjs
c97fd047a970326c096a4771f9bd49496fb6ff244047ea5c1e59bdd7adaeefa2 packages/webui/tests/discord.test.mjs
@@ -0,0 +1,46 @@
# #1511 R1 combined review
Author Rocko. Frozen candidate /tmp/task-attribution-r1-frozen-sRoqqr5m.
15-file manifest: r1-manifest.sha256, SHA256
0185cc65c973f6d89c394e2e9fba67fccd794b8ad2be2720a8bb3700b2b018a3.
Darkwing independently matched every author pin against frozen and working files.
The author snapshot contains selected files, not a standalone runnable checkout.
Both independent lanes returned before correction authorization.
## Filbert backend verdict
APPROVE AS SOURCE for exact R1, subject to separate presentation review.
Reported independent serialized seat/board/WebUI 141/141 in both working tree
and dependency-complete overlay /tmp/filbert-task-attribution-review-EaqP6CYc.
Log: TASK-ATTRIBUTION-TESTS.txt in overlay. Working launcher regressions 6/6,
/tmp/filbert-task-attribution-launchers.txt. Additional invalid-environment and
control-character probes passed. All 15 pins matched after tests.
Confirmed optional v1 compatibility, precedence/refusal rules, preservation of
other fields and task-selected attribution only. No backend blocker.
Nonblocking comment correction: bounded identifier syntax does not guarantee
absence of private identifiers. Numeric IDs and email-like names still fit.
Remove that inaccurate privacy claim in a separately pinned R2.
## Dewey UX verdict
REQUEST CHANGES, R1-U1. WebUI inspector maps null/inapplicable attribution to
unknown, conflating a transcript/connector task with a legacy registered task
whose setter is unknown. Preserve null as not applicable or a missing-value
marker. Keep unknown for a selected legacy registration. Add assertions for
legacy unknown, transcript/Discord null, and transition from a known setter
without stale attribution. Existing edge expectation currently enshrines the bug.
Dewey independently ran matching working-tree browser, browser-edge and Discord
suites, 3/3 passed, 330 contrast samples. Frozen/working pins matched before and
after. No screenshots, live observation or full backend verdict claimed.
Escaping, placement and reply boundaries otherwise passed his scoped review.
## Disposition
R1 is not approved for integration. Preserve its snapshot and manifest. Rocko
corrects R1-U1 and the inaccurate comment, reruns applicable checks, and supplies
R2 with changed/inherited pins. Both review lanes then verify the exact R2.
The missing docs/TOOLS.md usage flag is required additive documentation; preserve
other owners' dirty edits when adding it. No Gate F, live change or publication.
@@ -0,0 +1,15 @@
eec970961a667c9e614d1474d33c31a019b2cf1b0bf931a51458d874bca13a15 packages/seat/src/seat.mjs
a2880bd7b66cd9d6fc3588cc739eadc5db7fe806f81da86e0ac09bbff15a3099 packages/seat/src/cli.mjs
4bb04a3188ab9c5c985206da83bc1cc6f9be042a072aad75224560e7acf57e29 packages/seat/README.md
c06f2fc951ce56c905a98175dc82966893a2ba801405fb407c6c59363d029288 packages/seat/tests/seat.test.mjs
a40c57d011ce54956856c77e6758ca12dcd0ffd4be0d53ae2707b7d3fe3708b1 packages/control-board/src/scan.mjs
7f9b3b684e8af9f933d23cab956d32aad345b61be5001f8f6f591d3a91c57d12 packages/control-board/src/page.html
5f3bf97fd90045b2b1f08f2922ec0f6b33b3729bef39f9bfcbe0229dcd6e2080 packages/control-board/README.md
06a7eab5fa07ca83a210c5317e99ecccff8e8085197b7f66bbbecd67a4d8d541 packages/control-board/tests/scan.test.mjs
41bcaf8685437f130edc98aad92cfdb62e8201ee1861531ee9fa99069f681885 packages/control-board/tests/serve.test.mjs
64a67c91021de0c0ca583b65f8eed23e7e14415f2c18677b80754a7178363d04 packages/control-board/tests/discord.test.mjs
954d98fa2e6b264ed751857f0b76bb36c966c1ef59e4b091eb0b5351e84783d7 packages/webui/src/public/app.js
0aea0461d9a63b43e7cd2fea7cbaf335af48470b01481d3507a48bce8c355f79 packages/webui/tests/fixture.mjs
01636f05a8a59e8aaf7da000e631b83718b79a5e5bbc36d5b67738215cc250a7 packages/webui/tests/browser.test.mjs
b1c2860e69bb099fda476c291c41adddf736dcaf99b68620d5401ce92396336a packages/webui/tests/browser-edge.test.mjs
24cf26c357164857f29be4f47204812d560310ee79dfbfebd7aa48f7a84ba9b0 packages/webui/tests/discord.test.mjs

Some files were not shown because too many files have changed in this diff Show More