@ucsandman/legcli 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/DECISIONS.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  Durable product and design decisions that the code does not explain on its own. One entry per decision, newest first.
4
4
 
5
+ ## 2026-09-18: a second claude login shares the conversation store, so an account switch keeps the conversation
6
+
7
+ - **What.** `LAYOUT.claude.share` gains `projects`: `leg accounts add claude <name>` junctions Claude Code's conversation store into the account directory beside the harness directories, and `refreshAccount()` adds a missing junction to an account made before this before every launch. `keepsConversation()` in `src/usage.mjs` is the one rule that decides `--resume` over the bundle, for the terminal's switch and the board picker's `keeps_conversation` alike: a same-login downshift (as before), or another login of the same agent under which the transcript file exists. The history index skips a junctioned `projects` so a conversation is listed once.
8
+ - **Why share rather than copy.** Wes runs two 20x logins and wants a Fable or weekly wall on one to continue on the other without re-explaining anything. Copying the one transcript at hand-off would have to write into `~/.claude/projects` on the way back, and that directory is on the README's "read, never written" list. A junction inside `$LEG_HOME/accounts/` keeps that promise: Claude Code writes its own store, Leg reads it. It also carries the auto-memory under `projects/<cwd>/memory/`, which is the same human's and should follow them.
9
+ - **Why the rule checks the file.** A `--resume` into a login that cannot see the transcript opens an empty conversation and loses the bundle too. So the rule asks whether the file exists under the destination home at the moment of the switch, and says bundle when it does not. A false answer costs one bundle prime; a wrong true would cost the context.
10
+ - **Why a cross-login upshift resumes when a same-login upshift does not.** The same-login rule stays as decided on 2026-09-17: the re-read at the stronger model's rate is paid on the login that is already low. Across logins the destination is a fresh window, which is the reason the human bought it.
11
+ - **What this rules out.** codex across logins: `codex resume` under a second `CODEX_HOME` is unobserved, so codex rungs keep taking the bundle and claim nothing.
12
+
5
13
  ## 2026-09-18: usage is polled by the board, once per login; a rung's model is a catalog entry validated by shape
6
14
 
7
15
  - **What.** The board process runs one usage poller per login (`src/usage-poll.mjs`): 60s, doubling to ten minutes on any refusal, one status line when a login's reading fails and one when it is back. Terminals poll nothing and receive the windows on their session record. `GET /api/models` lists what each installed CLI can run, read from claude's aliases, codex's `models_cache.json` and `config.toml`, and the `models` commands of agy and grok, cached an hour under `<LEG_HOME>/models/`. A rung's `model` is validated by shape (lower-case id characters) for every provider and by membership for claude only.
@@ -181,3 +181,36 @@ tests. Rows for the shape changes a later reader would otherwise wonder about.
181
181
  | 2026-09-14 | src/resume.mjs idle pointer | a checkpoint bundle was described as "the last hand-off", and the per-session file was named unconditionally | `lineage.to` decides hand-off vs checkpoint, and the file is named only when it exists on disk | seen in the live pointer for this repo: it claimed a hand-off that never happened and pointed at a `RESUME-<id>.md` that was never written, which is the same class of lie the module exists to stop |
182
182
  | 2026-09-14 | src/board/sessions.js `renderDrawer()` | the whole panel is rebuilt every 3 s poll so relative timestamps stay honest | still rebuilt, but every scrollable box carries a stable `data-scroll-key` and its offset is carried across | the rebuild reset the task box, each message and the timeline to the top every three seconds, so a long message could not be read at all |
183
183
  | 2026-09-17 | src/board/sessions.js `verdictLines()` | spec A.5 lists "a model bucket came back and a terminal is still downshifted" below the two branches that print a standing percentage | the came-back branch is tested before them, right after the model-walled branch | as written it can never fire: "one login carries every live terminal" and "several logins carry work" both return for any login that has a figure at all, so the only state change worth telling a downshifted reader about was unreachable |
184
+
185
+ ## 2026-09-18: product, performance and architecture pass (0.15.0)
186
+
187
+ Measured first (`scratchpad/prof/BASELINE.md`, reproduction commands in the
188
+ CHANGELOG entry), then changed. Rows for the shape changes a later reader
189
+ would otherwise wonder about; the designs not built are in
190
+ `docs/review-2026-09-18.md`.
191
+
192
+ | date | file | old shape | new shape | why |
193
+ |------|------|-----------|-----------|-----|
194
+ | 2026-09-18 | src/accounts.mjs, src/usage.mjs, src/attach.mjs, src/server.mjs | a hand-off to a second claude login always took the bundle; only a same-login downshift kept the conversation | the account junctions claude's `projects` store; one rule, `keepsConversation()`, decides for the terminal and the picker, and checks the transcript file is reachable under the destination home at the switch | a second 20x login should continue the conversation, not re-read it from a bundle; the junction keeps "~/.claude/projects: read, never written" true |
195
+ | 2026-09-18 | src/history/providers/claude.mjs | every claude home was scanned, junction or not | a home whose `projects` is a link is skipped and reported `shared: true` | the same transcript listed twice from two logins |
196
+ | 2026-09-18 | src/digest.mjs, bin/leg.mjs, src/server.mjs | nothing read the records back as an answer to a person; the audit trail was a flat list | `leg digest [--since]` and `GET /api/digest` (owner only): volume first, what needs you, per repository, walls standing now | the question after eight hours away had no reader; a window with nothing in it must print its counts, not a blank |
197
+ | 2026-09-18 | bin/leg.mjs | 26 static imports; the agent dispatch first; `parseTarget` synchronous | one `await import()` per command group, inside its branch; the agent dispatch after every named group; `parseTarget` async and loads buckets/accounts only for a two-part target | `leg --version` loaded 68 modules for one readFileSync: 117 ms wall, 79 ms CPU, 254 fs calls; now 1 module, 52 ms, 4 fs calls |
198
+ | 2026-09-18 | src/limits.mjs | `SIGNALS = loadSignals()` at import (24 fixture reads, one RegExp each) | a Proxy over the same array that loads on first property access | every command paid for the fixture tree; test/ and scripts/limits-table.mjs read `SIGNALS.length` at their own top level, so a loader function was not an option |
199
+ | 2026-09-18 | src/scheduler-status.mjs (new), src/scheduler.mjs, src/launcher.mjs | `pidfile`, `schedulerStatus`, `MAX_CONCURRENT` lived in scheduler.mjs | a leaf module, re-exported from scheduler.mjs | the launcher pulled orchestrator, land, mergequeue, stations, chain, runner and limits to answer "is the scheduler running" |
200
+ | 2026-09-18 | src/git.mjs (new), src/attach.mjs, src/bundle.mjs | six git processes per poll round (three `rev-parse`, `status --porcelain`, `@{upstream}`, `rev-list --count`), each wrapper private, no timeout, no maxBuffer | one `status --porcelain=v2 --branch` carries head, branch, upstream, ahead/behind and the dirty list; `aheadFromStatus` uses `# branch.ab` or a one-pair head cache; 20 s timeout, 8 MB buffer | an idle terminal spawned 59.5 git processes a minute and blocked its own loop 4.4 to 10.3 s/min; now 10.9/min and 2.1 s/min, CPU 1.7 to 0.1 s/min |
201
+ | 2026-09-18 | src/git.mjs parseStatus | a rename printed the old and new names with the quotes half-stripped | the new name | the file on disk is the one a human recognises; pinned in test/git-status.test.mjs |
202
+ | 2026-09-18 | src/attach.mjs ensureBoard | polled /api/health every 200 ms for up to 15 s before the agent got its first instruction | `wait: false` for a terminal: the agent starts at once, the wait runs behind it, `claimBoardBeforeExit` (2 s at most) still writes the pidfile before a sub-second session exits; `leg share on|off` keeps the blocking form | the one terminal of the day that starts the board waited ~790 ms doing nothing (to-agent 1,280 to 261 ms); a board without a pidfile is one `leg down` cannot stop |
203
+ | 2026-09-18 | src/attach.mjs installedAgents | a `--version` subprocess per bare-name agent per launch, 8 s timeout each | `$LEG_HOME/installed.json`, one day per resolved bin; a `*_BIN` override is asked every time and never cached | a stub pointed at on purpose must be probed; a real CLI on PATH answers the same for a day |
204
+ | 2026-09-18 | src/bundle.mjs, src/handoff.mjs, src/attach.mjs | the periodic checkpoint ran `chb save` synchronously inside the poll tick | `saveSessionBundleAsync` (execFile), one at a time through `checkpointGate`, awaited before the hand-off save; warning, limit and hand-off saves stay synchronous | a python subprocess with a 120 s timeout froze limit detection and every board button for its whole run |
205
+ | 2026-09-18 | src/handoff.mjs | `scrub` imported from runner.mjs | from redact.mjs | the terminal path pulled the card runner's whole graph for one function |
206
+ | 2026-09-18 | src/sessions.mjs updateSession | `withFileLock` defaults: past 1.2 s the read-modify-write ran unlocked | `retries: 250, staleMs: 10000`, run.json's budget | the most-written file in Leg (runner poll, claude hook, usage poller, board actions, every `leg` command); losing a patch is the race the lock exists for |
207
+ | 2026-09-18 | src/sessions.mjs, src/attach.mjs | `rmSync(control.json)` at exit | `clearControl` under `.control.lock` | a bare unlink could delete a board request mid-write |
208
+ | 2026-09-18 | src/attach.mjs | the 12-leg stop said "run leg again in this directory to continue from the bundle" | names the bundle path and `leg resume`; says a fresh `leg <agent>` does not load it | nothing reloads a bundle on a fresh launch; the line promised a hand-off that never happened |
209
+ | 2026-09-18 | src/server.mjs health tick | `pushSessions()` unconditionally every 10 s per client | the stat fingerprint decides; a liveness pass (`reapLost`) runs on the tick because a dead runner moves no file; a card entering or leaving a human-waiting status forces one push | an idle board with 43 terminals did 7,294 fs calls, 8 git processes and 1.1 CPU s a minute for nothing; the first cut of this lost the dead-runner case, and the test that kills a real child now pins it |
210
+ | 2026-09-18 | src/server.mjs sessionsView | `readUsage` per rung per terminal, again for `capacity`, again for the accounts payload (143 reads per answer) | one reader per view | a login is read at most once per answer, and every part of the answer comes from the same reading |
211
+ | 2026-09-18 | src/synthesis.mjs | three `existsSync` per terminal per view | one `readdirSync` per checkout per view (`sessionFileIndex`) | 172 of 512 fs calls per answer, for files that mostly do not exist |
212
+ | 2026-09-18 | src/server.mjs, src/history/worktrees.mjs | `/api/worktrees` ran up to 40 git processes synchronously on the board's loop (9 to 25 s cold) | `listWorktreesAsync` (execFile, four in flight), one refresh per query, the last list served while a refresh runs | the worst `/api/health` during a cold call fell from 11.8 s to 3.2 s; the rest is `listHistory` inside `gather()`, still synchronous |
213
+ | 2026-09-18 | src/server.mjs /api/trunk | every card ledger read on every 2 s floor poll | a 15 s cache keyed by the window, cleared by any card change and by a landing | 27.5 to 0.5 ms p50, 113 to 15 fs calls per request |
214
+ | 2026-09-18 | src/session-detail.mjs | one `git ls-files` per file the terminal touched, every 3 s the drawer is open | one `git ls-files -z -- <paths>` for the list | 7 to 2 processes per detail answer, 1,247 to 305 ms |
215
+ | 2026-09-18 | src/server.mjs rows | every field of session.json on every push | `argv`, `runner_pid`, `head_at_start`, `checkpoints`, `agent_sessions`, `runtime_capabilities`, `files_touched` dropped from the row; `GET /api/sessions/<id>` still hands over the record | nothing under src/board/ read them; 8% off every push |
216
+ | 2026-09-18 | src/history/worktrees.mjs | the git env built by a helper | `MSYS_NO_PATHCONV: '1'` spelled out at each spawn site | test/lessons.test.mjs checks every git spawn line in src/ for it, and the helper hid it from that check |
package/docs/ERRORS.md CHANGED
@@ -21,7 +21,7 @@ and "CI green" after a push means the run, not the publish; `npm view
21
21
  **Fixed by deploying from the repo root with a root `.vercelignore` and
22
22
  `--archive=tgz`; the recipe is in DECISIONS.md under "the site deploys itself
23
23
  from git".** The `--yes` also auto-created an empty Vercel project named `leg`
24
- that still needs `vercel project rm leg`. The lesson: on a project with a Root
24
+ that still needs `vercel project rm leg` (2026-09-18 later: `vercel project ls` no longer lists a `leg` project, so it is gone). The lesson: on a project with a Root
25
25
  Directory set, the CLI must run from the repo root, and `--yes` is a consent to
26
26
  create projects, not only to skip a confirmation.
27
27
 
@@ -86,7 +86,18 @@ event names its actor, a board that reads only the ledger, and an auth/bind seam
86
86
  The same probe shape applies to naming the terminal tab: whether codex, agy
87
87
  and grok leave an OSC 2 title alone once the child starts drawing is
88
88
  assumed, not known, which is why the tab title is the browser's and not the
89
- terminal's.
89
+ terminal's. 0.15.0 made the claude half of this real across logins too (a
90
+ second login shares the conversation store, so a login switch keeps the
91
+ conversation): the codex probe now also covers a second `CODEX_HOME` with
92
+ its `sessions` store shared the same way.
93
+ 7. **`stalled` and `repeating` on the row.** Two derived states from data the
94
+ record already holds: no turn, file write or commit for N minutes while not
95
+ waiting on a human; the same test red across two legs of one card. Printed
96
+ only, never acted on; a `looping` state waits for a false-positive study on
97
+ real transcripts. Design in `docs/review-2026-09-18.md`.
98
+ 8. **The digest on the board.** `leg digest` and `/api/digest` shipped in
99
+ 0.15.0; the panel above Terminals on the first load of the day is the
100
+ human surface it still lacks. Same review doc.
90
101
 
91
102
  Also on the list: per-station prompt templates editable from the board, lease
92
103
  suggestions from the diff of the previous leg, and a floor view that shows
package/docs/concepts.md CHANGED
@@ -19,7 +19,7 @@ Each session gets a directory under `$LEG_HOME/sessions/<id>/`
19
19
 
20
20
  | file | what it holds |
21
21
  |------|---------------|
22
- | `session.json` | the live record the board renders; the runner is its only writer, and every write is one atomic replace |
22
+ | `session.json` | the live record the board renders. The runner owns it, and the board's usage poller, Claude Code's hooks and a board action patch fields on it too; every write is one atomic replace under `.session.lock` (`updateSession`), so no writer can lose another's field |
23
23
  | `events.jsonl` | the timeline (see [VOCABULARY.md](VOCABULARY.md#session-event-types)) |
24
24
  | `control.json` | requests from the board to the runner, for example `{ handoff: true }` |
25
25
  | `hook.log` | what Claude Code's hooks sent, claude sessions only |
@@ -70,12 +70,17 @@ override, so agy stays one account.
70
70
  Your harness is shared into an extra account, never copied into a fork that
71
71
  drifts: the directories are junctions back to the real home (claude: `hooks`,
72
72
  `skills`, `agents`, `commands`, `plugins`, `rules`, `scripts`,
73
- `output-styles`, `tools`; codex: `skills`, `prompts`, `rules`, `plugins`,
74
- `agents`, `hooks`, `memories`, `superpowers`), and the settings files are
75
- copied fresh before every launch (claude: `settings.json`,
73
+ `output-styles`, `tools`, `projects`; codex: `skills`, `prompts`, `rules`,
74
+ `plugins`, `agents`, `hooks`, `memories`, `superpowers`), and the settings
75
+ files are copied fresh before every launch (claude: `settings.json`,
76
76
  `settings.local.json`, `CLAUDE.md`, `keybindings.json`, `statusline.ps1`,
77
77
  `statusline-combined.ps1`; codex: `config.toml`, `AGENTS.md`). Only the login
78
- itself lives in the account directory.
78
+ itself lives in the account directory. claude's `projects` is its
79
+ conversation store, shared so that a hand-off to the second login can
80
+ `--resume` the transcript the first one was writing (see Handoff, step 3) and
81
+ so the same human's auto-memory follows them; `refreshAccount()` adds a
82
+ junction an older account is missing before every launch, and the history
83
+ index skips a junctioned store so a conversation is listed once.
79
84
 
80
85
  `leg accounts add <claude|codex> <name>` creates one and prints the single
81
86
  line to paste to log in. `leg accounts rm` removes the junctions as links,
@@ -161,7 +166,10 @@ in that picker ("the next option in the order") is still a press of the button.
161
166
  `context-handoff-bundle save --repo-local --slug leg-<session id>`, with
162
167
  `--update <slug>` after the first time, so one bundle per session is updated
163
168
  in place. A checkpoint runs about every two minutes while the session has
164
- turns, and at every warning, limit and hand-off.
169
+ turns, and at every warning, limit and hand-off. The periodic checkpoint runs
170
+ beside the terminal's poll rather than inside it, one at a time, so a slow
171
+ save never stops the terminal noticing a limit or a board button; a warning,
172
+ a limit and a hand-off save are still finished before the next leg starts.
165
173
  2. **Choose.** `candidates()` lists the other accounts of the same agent first,
166
174
  then walks the ladder, and after each rung, the other accounts of that
167
175
  rung's agent. `evaluateLadder()` walks that list top to bottom and takes the
@@ -191,14 +199,22 @@ in that picker ("the next option in the order") is still a press of the button.
191
199
  ladder for an active terminal; the wrapper reads it again at the transition
192
200
  and during all-out waiting. Machine Settings is copied only when a new
193
201
  terminal starts.
194
- 3. **Switch.** The agent process is stopped and the terminal restored. A
195
- same-login move to a weaker claude model (a downshift, by the
196
- `fable, opus, sonnet, haiku` order) with the agent's own session id on the
197
- record keeps the conversation instead: Leg runs
198
- `claude --resume <agent_session_id> --model <alias>`, no bundle is written
199
- into a prompt, and the ledger says so. Every other rung, including an
200
- upshift back to a stronger model, a different account, or a different
201
- agent, takes the bundle: the `context-handoff-bundle load <id>` output
202
+ 3. **Switch.** The agent process is stopped and the terminal restored. Two
203
+ claude moves keep the conversation instead of taking the bundle, decided by
204
+ one rule both the terminal and the board's picker read
205
+ (`keepsConversation()` in `src/usage.mjs`): a same-login move to a weaker
206
+ model (a downshift, by the `fable, opus, sonnet, haiku` order), which runs
207
+ `claude --resume <agent_session_id> --model <alias>`; and a move to another
208
+ claude login whose home can see the transcript through its `projects`
209
+ junction, which runs `claude --resume <agent_session_id>` under that
210
+ login's `CLAUDE_CONFIG_DIR` (with `--model` only when the rung names one).
211
+ Both need the agent's own session id on the record; the second also needs
212
+ the transcript file to exist under the destination home, checked at the
213
+ switch, so a login made before the junction existed takes the bundle until
214
+ its next launch adds it. No bundle is written into a prompt, and the
215
+ ledger says `kept the conversation`. Every other rung, including a
216
+ same-login upshift back to a stronger model or a different agent, takes
217
+ the bundle: the `context-handoff-bundle load <id>` output
202
218
  (with the `## Synthesis` section prepended if
203
219
  `.leg/SYNTHESIS-<session-id>.md` is present) is written to
204
220
  `.leg/RESUME-<session-id>.md` and copied to `.leg/RESUME.md`, and the next
@@ -314,6 +330,28 @@ on:
314
330
  `leg share off` puts the board back on `127.0.0.1` and every link stops
315
331
  working; `leg share rotate <name>` replaces one.
316
332
 
333
+ ## The digest
334
+
335
+ `leg digest [--since 8h|2d|<iso>] [--json]` and `GET /api/digest?since=`
336
+ (owner only) answer the question a person asks after eight hours away
337
+ (`src/digest.mjs`). It reads the session records, their `events.jsonl`, the
338
+ cards' ledgers, `landings.jsonl` and the usage records, and writes nothing.
339
+ The record is `{ since, until, volume, attention, repos, walls }`: `volume`
340
+ is what was read (terminals, cards, landings and events in the window, and
341
+ the sessions and cards on disk), printed on the first line so a quiet night
342
+ and an empty home cannot read the same; `attention` is what needs a person,
343
+ ranked (a live terminal waiting on a question, a card in `waiting_human`,
344
+ `needs_approval` or `paused`, a card that failed in the window, a terminal
345
+ that was lost in the window); `repos` groups every terminal that moved or is
346
+ live, every card that moved or is live, and every landing, by repository;
347
+ `walls` is every login and every model that is out right now, soonest reset
348
+ first. A terminal counts when it moved in the window or is live now, and its
349
+ entry carries the events worth a line (`limit`, `handoff`, `all_out`,
350
+ `lost`, `ended`, `landed`, `bounced`, `continued`, `harness_blocked`), never
351
+ the running commentary. The audit trail ([Share](#share-more-than-one-human))
352
+ is the same data as a flat list of actors; the digest is the same data
353
+ grouped by what to do about it.
354
+
317
355
  ## Cards, stations and pipelines
318
356
 
319
357
  A **card is a terminal you are not sitting at.** It has the same register
@@ -35,7 +35,7 @@ These apply to `leg claude|codex|agy|grok`.
35
35
  | `LEG_WAIT_TICK_MS` | `1000` | how often the all-out countdown redraws and re-checks Ctrl-C / End while waiting for the first reset | `src/attach.mjs` |
36
36
  | `LEG_USAGE_POLL_MS` | `60000` | how often the board asks each login's usage source (one poller per login, not one per terminal); Claude uses its usage endpoint, Codex read-only app-server rate limits, Grok its billing proxy | `src/usage-poll.mjs` |
37
37
  | `LEG_USAGE_POLL_MAX_MS` | `600000` | the longest the board waits between readings for one login: a refusal (429, timeout, no login) doubles the wait up to this, and the first good reading puts it back to `LEG_USAGE_POLL_MS` | `src/usage-poll.mjs` |
38
- | `LEG_ATTACH_POLL_MS` | `2000` | how often the session loop re-reads the taps; git is re-read every third poll | `src/attach.mjs` |
38
+ | `LEG_ATTACH_POLL_MS` | `2000` | how often the session loop re-reads the taps; git is re-read every third poll, as one `git status --porcelain=v2 --branch` (`src/git.mjs`) that carries the head, the branch, the dirty list and the upstream's own ahead count | `src/attach.mjs` |
39
39
  | `LEG_CLAUDE_USAGE_URL` | `https://api.anthropic.com/api/oauth/usage` | the usage endpoint, for a test double | `src/taps/claude-usage.mjs` |
40
40
  | `LEG_CLAUDE_ARGS`, `LEG_CODEX_ARGS`, `LEG_AGY_ARGS` | (none) | space-separated extra arguments for a leg Leg starts on its own after a hand-off (your own `leg <agent> …` args never apply to the next agent); e.g. `LEG_CODEX_ARGS="-m gpt-5.3-codex-spark"` keeps a test chain on cheap models | `src/attach.mjs` |
41
41
  | `LEG_LIVE_DIR` | `fixtures/live/` in a dev clone, else `~/.leg/live/` | where the first real limit payload per agent and signal is kept, secrets scrubbed (`src/live-capture.mjs`); a `leg sessions simulate-limit` payload is never kept | `src/live-capture.mjs`, `scripts/live-limits.mjs` |
@@ -133,7 +133,7 @@ permission, so it is always on.
133
133
 
134
134
  | variable | default | meaning | read in |
135
135
  |----------|---------|---------|---------|
136
- | `LEG_HOME` | `~/.leg` | where sessions, usage, accounts, cards, runs and the pidfiles live | `src/store.mjs`, `src/ledger.mjs`, `src/runner.mjs`, `src/worktree.mjs`, `src/cards.mjs`, `src/sessions.mjs`, `src/usage.mjs`, `src/accounts.mjs` |
136
+ | `LEG_HOME` | `~/.leg` | where sessions, usage, accounts, cards, runs, the pidfiles and `installed.json` (which agent CLIs answered `--version`, remembered for a day per resolved bin) live | `src/store.mjs`, `src/ledger.mjs`, `src/runner.mjs`, `src/worktree.mjs`, `src/cards.mjs`, `src/sessions.mjs`, `src/usage.mjs`, `src/accounts.mjs` |
137
137
  | `LEG_PORT` | `4747` | board server port | `src/server.mjs`, `src/launcher.mjs`, `bin/leg.mjs` (`open`) |
138
138
  | `LEG_BIND` | `127.0.0.1` | board server bind address | `src/server.mjs`, `src/launcher.mjs` |
139
139
  | `LEG_TOKEN` | (none) | bearer token required for `/api/*` and the event stream once set | `src/server.mjs`, `src/auth.mjs` |
@@ -205,7 +205,9 @@ home. `src/accounts.mjs` `LAYOUT` is the whole definition.
205
205
  accounts.json the named accounts per agent
206
206
  accounts/claude/<name>/
207
207
  hooks/ skills/ agents/ commands/ plugins/ rules/ scripts/
208
- output-styles/ tools/ junctions back to ~/.claude
208
+ output-styles/ tools/ projects/ junctions back to ~/.claude (projects is
209
+ the conversation store: a hand-off to this
210
+ login can --resume the same transcript)
209
211
  settings.json settings.local.json CLAUDE.md keybindings.json
210
212
  statusline.ps1 statusline-combined.ps1
211
213
  copies, refreshed before every launch
@@ -0,0 +1,172 @@
1
+ # Review notes, 2026-09-18: product, performance and architecture pass
2
+
3
+ A requested first-principles pass over Leg as a product and as a program.
4
+ Working file, not published to the docs site. What shipped from it is in
5
+ `CHANGELOG.md` (0.15.0); what did not is designed here so the next pass can
6
+ pick it up without re-deriving it. Measurements are from
7
+ `scratchpad/prof/BASELINE.md` (kept out of the repo) and are summarised in
8
+ the CHANGELOG entry with their reproduction commands.
9
+
10
+ ## What Leg is, and what it should become
11
+
12
+ Today Leg is a usage-wall relay: the same terminal keeps going when a login
13
+ stops. Around that wedge a lot arrived in one week: rungs of (agent, login,
14
+ model), a board, cards and pipelines, a machine-wide history index, a
15
+ portable harness, a shared board with roles and an audit trail. The wedge is
16
+ still the identity, and the reviews agree on the two things that make it
17
+ more than a relay:
18
+
19
+ 1. **The terminal should never lose its conversation.** A same-login
20
+ downshift already kept it. 0.15.0 keeps it across logins too (the
21
+ `projects` junction plus one `keepsConversation` rule). The remaining gap
22
+ is codex across logins, unprobed.
23
+ 2. **Leg holds the record of everything every agent did on this machine**,
24
+ and until 0.15.0 nothing read it back as an answer to a person. `leg
25
+ digest` is the first reader. The audit trail was the second-last.
26
+
27
+ Where it should go, in one sentence: from "a relay that keeps the terminal
28
+ alive" to "the layer that knows what every agent is doing, what it cost, and
29
+ what needs a person, and never loses the thread". Not an orchestrator that
30
+ invents work; a control layer over work the human started.
31
+
32
+ ## Gaps found, ranked
33
+
34
+ | gap | evidence | status |
35
+ |---|---|---|
36
+ | a second login of the same vendor lost the conversation | `isDownshift` required the same account | **shipped 0.15.0** |
37
+ | nothing summarised what happened while you were away | audit trail is a flat list; ledger counts on the board | **shipped 0.15.0** (`leg digest`) |
38
+ | an idle terminal spent ~60 git processes a minute | `gitInfo` + `aheadCount` every 6 s | **shipped 0.15.0** (one `status --porcelain=v2 --branch`) |
39
+ | the board rebuilt its whole view every 10 s for nothing | health timer bypassed the fingerprint | **shipped 0.15.0** |
40
+ | every command loaded 68 modules, `--version` included | eager imports in `bin/leg.mjs`, two gratuitous edges | **shipped 0.15.0** |
41
+ | the checkpoint bundle froze limit detection for the length of a python spawn | sync `chb save` inside the poll interval | **shipped 0.15.0** |
42
+ | `.leg/DONE` is self-reported; a card is `completed` on the agent's word | `classify()` rule 6 | designed below (verify the claim) |
43
+ | a wedged agent looks like a thinking one; a loop burns the ladder | no stall/loop signal beyond `quiet Nm` | designed below |
44
+ | every new session re-learns the repository | SYNTHESIS dies with its session | designed below (project brain) |
45
+ | cards and terminals are two substrates for one idea | two stores, two status vocabularies, two hand-off loops | designed below (one leg primitive) |
46
+ | every ledger write is a Node process (85–114 ms) | `execFileSync(node, ledger.mjs)` per event | designed below |
47
+ | `codex resume <id> -m <model>` and a second `CODEX_HOME` unprobed | roadmap item 6 | still open |
48
+
49
+ ## Designs for what was not built
50
+
51
+ ### Verify the claim (cards)
52
+
53
+ `classify()` rule 6 says exit 0 plus `.leg/DONE` is `completed`. Add a
54
+ `verify` policy on the card (`test|none`, default `test` for pipelines with
55
+ no `test` station, else `none` because the station already runs it): on
56
+ `completed`, run `resolveTestCommand(repo)` in the worktree with the land
57
+ timeout, record `{ verified: 'green'|'red'|'untested', command, tail, at }`
58
+ on the run, and treat `red` as `incomplete` (hands off with the tail in the
59
+ bundle's Open questions). A DONE with an empty diff stays `completed`: an
60
+ analysis card legitimately changes nothing, so a zero diff is not evidence
61
+ of a lie. Surface: the card row's sentence (`claimed done; tests red (3
62
+ failures)`), `leg card show`. Tests: a fake adapter writing DONE with green,
63
+ red and no test command. Not built now because the plain `build` preset is
64
+ the one place it applies and the `build-land` preset already verifies.
65
+
66
+ ### Stalled, looping, repeating
67
+
68
+ Three derived states, printed only from measured facts:
69
+
70
+ - `stalled`: live, not waiting on a human, and no turn, no file write and no
71
+ commit for N minutes (N per agent, default 12). Data: `last_activity`,
72
+ `files_touched`, `head`. The row says `stalled 14m` where it says `quiet
73
+ 14m` today; the head count includes it.
74
+ - `looping`: the same (tool, path) or the same normalised error line three
75
+ times in the last 40 transcript-tail entries (`messagesFromLines` exists).
76
+ Row: `looping: Edit src/server.mjs 4x in 6m`. Never automatic action; a
77
+ Hand off now button already sits beside it.
78
+ - `repeating`: the same test name red across two legs of one card
79
+ (`bounce_reason` history). Stops the chain instead of spending the next
80
+ rung, the way `LEG_MAX_LAND_ATTEMPTS` already caps landings.
81
+
82
+ Not built now: `looping` needs a false-positive study on real transcripts
83
+ before it can print anything ("print nothing rather than a wrong number").
84
+ `stalled` and `repeating` are a day each and should come first.
85
+
86
+ ### The project brain
87
+
88
+ `<repo>/.leg/BRAIN.md`, written by Leg's code and never by an agent turn:
89
+ decisions (dated, with who decided), ruled-out approaches, invariants (from
90
+ `CLAUDE.md`/`AGENTS.md`), hot files (from `files_touched` frequency) and open
91
+ questions. At hand-off the session's SYNTHESIS delta merges into it; every
92
+ first prompt of a leg and every card contract opens with its invariants and
93
+ ruled-out list before the bundle dump. `leg brain` prints it, `leg brain
94
+ --check` reports staleness like `leg resume --check`, `leg decide "<text>"`
95
+ appends. It subsumes `.leg/SYNTHESIS-<id>.md` as the per-session delta. Not
96
+ built now because it is a new file in the human's repository with a format
97
+ that has to be right the first time; it deserves its own pass with the
98
+ synthesis schema's authors.
99
+
100
+ ### One leg primitive
101
+
102
+ The terminal loop (`src/attach.mjs`) and the card loop
103
+ (`src/orchestrator.mjs` + `src/chain.mjs`) do the same four steps: bundle,
104
+ choose next, switch, wait. What differs is real: the terminal chooses from
105
+ live usage at hand-off time and keeps one TTY; the card walks a static chain
106
+ and classifies post-mortem. The extractable pieces with no semantic change
107
+ are `bundle → prompt` and `prepare harness → proceed?` as shared functions,
108
+ and a per-agent tap table (`PER_AGENT[agent].poll(session, ctx) → patch`)
109
+ so a fifth agent is a table row. Making a card's chain a ladder evaluated at
110
+ hand-off time is the product decision that would unify the rest; it belongs
111
+ in the roadmap, not in a refactor.
112
+
113
+ ### In-process ledger writes
114
+
115
+ `src/store.mjs` spawns `node src/ledger.mjs` per event (85 ms append, 114 ms
116
+ update; a scheduler tick that newly blocks six cards spends 487 ms in six of
117
+ them). The single-writer rule is a file lock (`.card.lock`), not a process
118
+ boundary, so the functions can be exported and called in-process. Three
119
+ things make it a refactor: `die()` exits, `assertNoSecrets` exits on a
120
+ secret-looking summary (in-process that must throw, never take the board
121
+ down), and `syncNotify` is async. Also `writeActive()` rescans every
122
+ `card.json` on every write and should be debounced. Not built now: medium
123
+ risk on the card path, which is the v0.1 extra, and the terminal path never
124
+ touches it.
125
+
126
+ ### One event schema
127
+
128
+ Sessions have ~24 event types, cards 28 (`ledger.mjs EVENT_TYPES`), with
129
+ different actor shapes. `audit.mjs` and `digest.mjs` both merge the two by
130
+ hand. One `{ v, ts, subject:{kind,id}, actor, type, summary, measured }`
131
+ line format would let the audit, the digest, a future `leg events --follow`
132
+ and an outside subscriber read one stream. Do it when a third reader
133
+ appears.
134
+
135
+ ## Rejected, with the reason
136
+
137
+ - **Answering a permission prompt from the board.** `stdio: 'inherit'` is
138
+ the architecture; a PTY between the human and their agent breaks "the same
139
+ agent you always run".
140
+ - **Routing by headroom instead of rung order.** "Rung 1 first, every time"
141
+ is the contract the ladder editor teaches; a sort by headroom makes the
142
+ next destination unpredictable. A `when: below:N` rung already expresses
143
+ the honest version.
144
+ - **Inferred workflows and automatic undo.** A recipe guessed from three
145
+ observations is a wrong number with a button; undoing a leg destroys work
146
+ that may be the only copy.
147
+ - **Marking a DONE with no diff as a false claim.** An analysis task changes
148
+ nothing and is still done.
149
+ - **Pooling a team's logins as one capacity market.** The README's terms
150
+ section draws the same-vendor line at an explicit human act.
151
+
152
+ ## What Leg must never do without a human
153
+
154
+ Spend credits (`may_spend` off); push or open a PR; write a settings file
155
+ outside `leg harness enable`'s owned regions; move a credential; delete a
156
+ worktree with uncommitted work or an unmerged branch; pick a second login of
157
+ the same vendor the human did not add; read message bodies wholesale into an
158
+ index; send anything off the machine; answer a permission prompt; print a
159
+ number it does not have.
160
+
161
+ ## Next three moves
162
+
163
+ 1. **Probe codex across logins**: `codex resume <id> -m <model>` under a
164
+ second `CODEX_HOME` with the `sessions` store junctioned the way
165
+ `projects` now is for claude. One live run settles whether the second
166
+ `keepsConversation` case can include codex.
167
+ 2. **`stalled` and `repeating`** as above: two derived states from data
168
+ already on the record, each with a fixture-driven test, no automatic
169
+ action.
170
+ 3. **A digest panel on the board** above Terminals on the first load of the
171
+ day, dismissible, fed by `/api/digest`; the CLI and the route exist, the
172
+ board is the human surface.
@@ -2,7 +2,7 @@
2
2
  "_comment": "The single source of truth for every dated, numbered claim Leg makes in public. scripts/check-claims.mjs asserts that each surface listed here states exactly these values, and --strict additionally compares the pinned CLI versions against what those CLIs currently publish. Re-verify on a real machine, then edit this file and every surface in the same commit.",
3
3
  "verifiedOn": "2026-09-11",
4
4
  "verifiedOnLong": "11 September 2026",
5
- "tests": 926,
5
+ "tests": 955,
6
6
  "clis": {
7
7
  "claude": {
8
8
  "label": "Claude Code",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ucsandman/legcli",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Usage-limit monitor and automatic handoff for Claude Code, Codex, agy and Grok. Type leg claude|codex|agy|grok and get the same interactive agent with a board alongside, auto-approve on by default, usage tracking per agent and account, a live context handoff bundle, and at the limit the next agent continuing in the same terminal. 14-day free trial, then $79 once, 30-day money-back guarantee.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
package/src/accounts.mjs CHANGED
@@ -6,8 +6,8 @@
6
6
  // back into it with directory junctions, and the settings file is refreshed
7
7
  // from the real home before every launch. Only the login lives in the
8
8
  // account dir. agy 1.2.0 has no config-dir override, so it stays one account.
9
- import { existsSync, mkdirSync, readFileSync, copyFileSync, readdirSync, lstatSync, symlinkSync, rmSync } from 'node:fs'
10
- import { join } from 'node:path'
9
+ import { existsSync, mkdirSync, readFileSync, copyFileSync, readdirSync, lstatSync, symlinkSync, rmSync, realpathSync } from 'node:fs'
10
+ import { join, relative, dirname, basename, resolve, isAbsolute } from 'node:path'
11
11
  import { homedir } from 'node:os'
12
12
  import { home } from './store.mjs'
13
13
  import { writeJsonAtomic } from './fsx.mjs'
@@ -19,7 +19,12 @@ export const LAYOUT = {
19
19
  claude: {
20
20
  env: 'CLAUDE_CONFIG_DIR',
21
21
  home: () => process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'),
22
- share: ['hooks', 'skills', 'agents', 'commands', 'plugins', 'rules', 'scripts', 'output-styles', 'tools'],
22
+ // `projects` is the conversation store (projects/<encoded cwd>/<id>.jsonl,
23
+ // and the auto-memory beside it). Shared, not copied, so a hand-off to the
24
+ // second login can run `claude --resume <id>` on the transcript the first
25
+ // login was writing, and the same human's memory follows them. Claude Code
26
+ // does the writing; Leg reads that directory and never writes it.
27
+ share: ['hooks', 'skills', 'agents', 'commands', 'plugins', 'rules', 'scripts', 'output-styles', 'tools', 'projects'],
23
28
  copy: ['settings.json', 'settings.local.json', 'CLAUDE.md', 'keybindings.json', 'statusline.ps1', 'statusline-combined.ps1'],
24
29
  login: (dir) => `$env:CLAUDE_CONFIG_DIR='${dir}'; claude auth login`,
25
30
  },
@@ -73,6 +78,45 @@ export function envFor(agent, account) {
73
78
  return { [l.env]: accountDir(agent, account) }
74
79
  }
75
80
 
81
+ // The config directory a login runs from: the CLI's own home for `default`,
82
+ // the account directory for anything else. Null for an agent Leg does not know.
83
+ export function homeFor(agent, account = 'default') {
84
+ const l = LAYOUT[agent]
85
+ if (!l) return null
86
+ return account === 'default' ? l.home() : accountDir(agent, account)
87
+ }
88
+
89
+ // Whether the conversation file one login was writing is visible to another
90
+ // login of the same agent: the transcript's path relative to the source home
91
+ // must exist under the destination home. True through the `projects` junction
92
+ // an account carries; false for an account made before that junction existed
93
+ // (until its next launch adds it), for a transcript outside the source home,
94
+ // and for an agent with no per-account home at all. A false answer means the
95
+ // bundle, never a `--resume` that would open an empty conversation.
96
+ export function transcriptReachable(agent, transcriptPath, { from = 'default', to = 'default' } = {}) {
97
+ if (!transcriptPath || from === to || !LAYOUT[agent]?.env) return false
98
+ const dst = homeFor(agent, to)
99
+ if (!dst) return false
100
+ const where = realDir(dirname(transcriptPath))
101
+ // the transcript's place inside whichever home really holds it: the source
102
+ // login's, the destination's, or the CLI's own. A path a CLI reported through
103
+ // an account's junction resolves to the real home, and a record that kept
104
+ // the first login's path while the terminal moved on is still one file.
105
+ for (const account of new Set([from, to, 'default'])) {
106
+ const h = homeFor(agent, account)
107
+ if (!h) continue
108
+ const rel = relative(realDir(h), where)
109
+ if (rel.startsWith('..') || isAbsolute(rel)) continue
110
+ return existsSync(join(dst, rel, basename(transcriptPath)))
111
+ }
112
+ return false
113
+ }
114
+
115
+ // A directory's real path, for comparing a transcript's location against a
116
+ // home that may itself be reached through a junction; the path as given when
117
+ // it does not exist.
118
+ function realDir(p) { try { return realpathSync.native(p) } catch { return resolve(p) } }
119
+
76
120
  function junction(target, link) {
77
121
  if (existsSync(link)) return false
78
122
  if (!existsSync(target)) return false
@@ -80,6 +124,22 @@ function junction(target, link) {
80
124
  return true
81
125
  }
82
126
 
127
+ // The shared directories, junctioned into the account dir when they are not
128
+ // there yet. Runs at creation and before every launch, so an account made by
129
+ // an older Leg picks up a directory added to `share` since (the `projects`
130
+ // store, for one) the next time it starts.
131
+ export function ensureShared(agent, name) {
132
+ if (name === 'default') return []
133
+ const l = LAYOUT[agent]
134
+ if (!l?.env) return []
135
+ const dir = accountDir(agent, name)
136
+ if (!existsSync(dir)) return []
137
+ const src = l.home()
138
+ const shared = []
139
+ for (const d of l.share) if (junction(join(src, d), join(dir, d))) shared.push(d)
140
+ return shared
141
+ }
142
+
83
143
  // Create the account dir, junction the shared harness in, copy the settings.
84
144
  export function addAccount(agent, name) {
85
145
  if (!NAME_RE.test(name) || name === 'default') throw new Error(`invalid account name "${name}" (letters, digits, - and _; not "default")`)
@@ -88,9 +148,7 @@ export function addAccount(agent, name) {
88
148
  if (!l.env) throw new Error(`${agent} has no config-dir override in the installed version; extra accounts are not possible`)
89
149
  const dir = accountDir(agent, name)
90
150
  mkdirSync(dir, { recursive: true })
91
- const src = l.home()
92
- const shared = []
93
- for (const d of l.share) if (junction(join(src, d), join(dir, d))) shared.push(d)
151
+ const shared = ensureShared(agent, name)
94
152
  refreshAccount(agent, name)
95
153
  const acc = readAccounts()
96
154
  if (!acc[agent]) acc[agent] = ['default']
@@ -98,11 +156,13 @@ export function addAccount(agent, name) {
98
156
  return { dir, shared, login: l.login(dir), env: l.env }
99
157
  }
100
158
 
101
- // Before each launch: bring the copied files up to date with the real home.
159
+ // Before each launch: bring the copied files up to date with the real home,
160
+ // and add any shared directory the account is still missing.
102
161
  export function refreshAccount(agent, name) {
103
162
  if (name === 'default') return []
104
163
  const l = LAYOUT[agent]
105
164
  const dir = accountDir(agent, name)
165
+ ensureShared(agent, name)
106
166
  const copied = []
107
167
  for (const f of l.copy) {
108
168
  const s = join(l.home(), f)