@ucsandman/legcli 0.13.1 → 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/bin/leg.mjs CHANGED
@@ -5,32 +5,15 @@
5
5
  // leg card ls [--json] | show <id> | run <id> | rm <id> [--delete-branch] | events <id>
6
6
  // leg card <pause|resume|kill|approve|handoff-now|rerun> <id> | reassign <id> --adapter a [--mode m]
7
7
  // leg scheduler start [--ticks N] [--interval-ms N] | status | stop
8
+ //
9
+ // Startup cost matters here: every command, `--version` included, paid for
10
+ // loading the whole module graph (orchestrator, scheduler, board, attach…)
11
+ // before main() even ran. Each command group below imports only what it
12
+ // needs, inside its own branch, so `leg --version` and friends stay cheap.
8
13
  import { rmSync, appendFileSync, readFileSync } from 'node:fs'
9
14
  import { join, dirname, resolve } from 'node:path'
10
15
  import { fileURLToPath } from 'node:url'
11
16
  import { spawnSync } from 'node:child_process'
12
- import { PRESET_NAMES } from '../src/presets.mjs'
13
- import { readCard, listCards, readEvents, readRuns, cardDir } from '../src/store.mjs'
14
- import { runCard, humanAction } from '../src/orchestrator.mjs'
15
- import { createCard, CardInputError } from '../src/cards.mjs'
16
- import { remove as removeWorktree } from '../src/worktree.mjs'
17
- import { pruneSessionWorktree } from '../src/land.mjs'
18
- import { createScheduler, schedulerStatus, pidfile, MAX_CONCURRENT } from '../src/scheduler.mjs'
19
- import { availableActions } from '../src/chain.mjs'
20
- import { up, down, stopBoard, status, openBoard } from '../src/launcher.mjs'
21
- import { attach, ensureBoard } from '../src/attach.mjs'
22
- import { readShare, addPerson, removePerson, rotate as rotateToken, turnOn, turnOff, linkFor, personNamed, scheme, tlsConfigured, ROLES } from '../src/share.mjs'
23
- import { normalizeHandoffOrder, ladderFor, readPreferences, writePreferences } from '../src/preferences.mjs'
24
- import { MODEL_ALIASES } from '../src/buckets.mjs'
25
- import { SUPERVISED_AGENTS, listSessions, readSession, readEvents as readSessionEvents, requestControl, removeSession, isActive, readLand, sessionDir, appendEvent } from '../src/sessions.mjs'
26
- import { addAccount, removeAccount, listAccountRows, readAccounts, LAYOUT } from '../src/accounts.mjs'
27
- import { listUsage, fmtReset, readUsage, isAvailable, candidates, binding, wallActive, evaluateLadder, rungLabel } from '../src/usage.mjs'
28
- import { home } from '../src/store.mjs'
29
- import { entitlement, allows, describe as describeLicense, activate as activateLicense, deactivate as deactivateLicense, refresh as refreshLicense, licensePath, BUY_URL } from '../src/license.mjs'
30
- import { resumeVerdict, bodyOf, ago } from '../src/resume.mjs'
31
- import { harnessCommand } from '../src/harness/cli.mjs'
32
- import { adapterCommand } from '../src/adapters/cli.mjs'
33
- import { historyCommand, worktreesCommand } from '../src/history/cli.mjs'
34
17
 
35
18
  const SRC = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'src')
36
19
  // one source of truth for the version, so the help text cannot drift from the package
@@ -52,6 +35,7 @@ function parseArgs(argv) {
52
35
  }
53
36
 
54
37
  async function cardAdd(args) {
38
+ const { createCard, CardInputError } = await import('../src/cards.mjs')
55
39
  try {
56
40
  const card = await createCard({
57
41
  repo: args.repo, task: args.task, chain: args.chain, pipeline: args.pipeline,
@@ -74,7 +58,10 @@ async function cardAdd(args) {
74
58
  // start the next option in the same terminal. The payload is marked
75
59
  // simulated: it is never kept as live evidence, and the wall it records
76
60
  // clears after two minutes. codex has no Leg-owned input, so it is refused.
77
- function simulateLimit(s, { message = null } = {}) {
61
+ // `sessionsApi` is the already-imported src/sessions.mjs namespace: the
62
+ // `sessions` command group loads it once and passes it through.
63
+ function simulateLimit(sessionsApi, s, { message = null } = {}) {
64
+ const { isActive, sessionDir, appendEvent, readSession } = sessionsApi
78
65
  if (!isActive(s)) die(3, `session ${s.session_id} is not active`)
79
66
  if (['limit', 'handing_off'].includes(s.status)) die(3, `session ${s.session_id} is already ${s.status}`)
80
67
  if (s.agent === 'claude') {
@@ -105,13 +92,16 @@ function simulateLimit(s, { message = null } = {}) {
105
92
  // unambiguous. Two are not, so the second is read as an account when that
106
93
  // account exists and as a model when the agent has one by that name; a word
107
94
  // that is neither is refused by name rather than guessed at.
108
- export function parseTarget(value, { die: fail = (code, msg) => { throw new Error(msg) } } = {}) {
95
+ // Async so the agent/account lookups (buckets.mjs, accounts.mjs) load only
96
+ // when a two-part target is actually given, not on every CLI invocation.
97
+ export async function parseTarget(value, { die: fail = (code, msg) => { throw new Error(msg) } } = {}) {
109
98
  const parts = String(value).split('/').filter(Boolean)
110
99
  const agent = parts[0]
111
100
  if (!agent) fail(2, 'usage: --to <agent>[/<account>[/<model>]]')
112
- const models = MODEL_ALIASES[agent] ?? []
113
101
  if (parts.length >= 3) return { agent, account: parts[1], model: parts[2].toLowerCase() }
114
102
  if (parts.length === 2) {
103
+ const [{ MODEL_ALIASES }, { readAccounts }] = await Promise.all([import('../src/buckets.mjs'), import('../src/accounts.mjs')])
104
+ const models = MODEL_ALIASES[agent] ?? []
115
105
  const second = parts[1]
116
106
  const accounts = readAccounts()[agent] ?? ['default']
117
107
  if (accounts.includes(second)) return { agent, account: second, model: null }
@@ -124,25 +114,28 @@ export function parseTarget(value, { die: fail = (code, msg) => { throw new Erro
124
114
  // What a rung is doing right now, in the words the board uses: the wall and its
125
115
  // clock, else the percentage of the bucket that binds it, else "no figure".
126
116
  // Never a guess: an agent that publishes no number says so.
127
- function rungState(rung) {
128
- const u = readUsage(rung.agent, rung.account)
117
+ // `usage` is the already-imported src/usage.mjs namespace.
118
+ function rungState(rung, usage) {
119
+ const u = usage.readUsage(rung.agent, rung.account)
129
120
  const wall = rung.model ? u.walls?.[rung.model] : null
130
- if (wall && wallActive(wall)) return `${rung.model} out until ${fmtReset(wall.limited_until)}`
131
- if (!isAvailable(u)) return `at its limit until ${fmtReset(u.limited_until)}`
132
- const b = binding(u, rung.model ?? null)
121
+ if (wall && usage.wallActive(wall)) return `${rung.model} out until ${usage.fmtReset(wall.limited_until)}`
122
+ if (!usage.isAvailable(u)) return `at its limit until ${usage.fmtReset(u.limited_until)}`
123
+ const b = usage.binding(u, rung.model ?? null)
133
124
  if (b && Number.isFinite(b.percent)) return `${Math.round(b.percent)}% of the ${b.model ? b.model + ' ' : ''}${b.kind === 'session' || b.kind === 'five_hour' ? '5h' : 'week'} window`
134
125
  return 'no figure'
135
126
  }
136
127
 
137
- function printLadder() {
138
- const prefs = readPreferences()
128
+ // `prefsApi`/`usage` are the already-imported src/preferences.mjs and
129
+ // src/usage.mjs namespaces (the `ladder` command group loads them once).
130
+ function printLadder(prefsApi, usage) {
131
+ const prefs = prefsApi.readPreferences()
139
132
  const ladder = prefs.handoff_ladder
140
- const rows = evaluateLadder({ from: null, list: ladder, maySpend: prefs.may_spend, reserve: prefs.reserve, automatic: true, climbBack: prefs.climb_back, ladder })
133
+ const rows = usage.evaluateLadder({ from: null, list: ladder, maySpend: prefs.may_spend, reserve: prefs.reserve, automatic: true, climbBack: prefs.climb_back, ladder })
141
134
  out('The ladder a terminal falls down when its login stops. Rung 1 first, every time.')
142
135
  ladder.forEach((rung, i) => {
143
136
  const r = rows[i]
144
137
  const when = rung.when === 'always' ? '' : ` when ${rung.when}`
145
- out(` ${String(i + 1).padEnd(2)} ${rungLabel(rung).padEnd(20)} ${rungState(rung).padEnd(34)} ${r.ok ? 'ready' : r.reason}${when}`)
138
+ out(` ${String(i + 1).padEnd(2)} ${usage.rungLabel(rung).padEnd(20)} ${rungState(rung, usage).padEnd(34)} ${r.ok ? 'ready' : r.reason}${when}`)
146
139
  })
147
140
  out('')
148
141
  out(`spending: ${prefs.may_spend ? 'on (a credits or metered rung may be taken unattended)' : 'off (a credits or metered rung is skipped unattended)'} · leg ladder spend on|off`)
@@ -152,38 +145,39 @@ function printLadder() {
152
145
  out(`order (what older readers see): ${prefs.handoff_order.join(' → ')}`)
153
146
  }
154
147
 
155
- function ladderCommand(cmd, args) {
156
- if (!cmd || cmd === 'ls' || cmd === 'show') return printLadder()
157
- const prefs = readPreferences()
148
+ async function ladderCommand(cmd, args) {
149
+ const [prefsApi, usage] = await Promise.all([import('../src/preferences.mjs'), import('../src/usage.mjs')])
150
+ if (!cmd || cmd === 'ls' || cmd === 'show') return printLadder(prefsApi, usage)
151
+ const prefs = prefsApi.readPreferences()
158
152
  const ladder = prefs.handoff_ladder.map((r) => ({ ...r }))
159
153
  if (cmd === 'set') {
160
154
  const [nRaw, target] = args._
161
155
  const n = parseInt(nRaw, 10)
162
156
  if (!Number.isFinite(n) || n < 1) die(2, 'usage: leg ladder set <n> <agent>[/<account>[/<model>]] [--when always|below:N|walled-only]')
163
157
  if (!target) die(2, 'usage: leg ladder set <n> <agent>[/<account>[/<model>]] [--when always|below:N|walled-only]')
164
- const want = parseTarget(target, { die })
158
+ const want = await parseTarget(target, { die })
165
159
  const rung = { ...want, when: typeof args.when === 'string' ? args.when : 'always' }
166
160
  const at = Math.min(n, ladder.length + 1) - 1
167
161
  ladder[at] = rung
168
162
  try {
169
- const saved = writePreferences({ handoff_ladder: ladder })
170
- out(`rung ${at + 1} is ${rungLabel(saved.handoff_ladder[at])}${rung.when !== 'always' ? `, when ${rung.when}` : ''}`)
163
+ const saved = prefsApi.writePreferences({ handoff_ladder: ladder })
164
+ out(`rung ${at + 1} is ${usage.rungLabel(saved.handoff_ladder[at])}${rung.when !== 'always' ? `, when ${rung.when}` : ''}`)
171
165
  } catch (err) { die(2, err.message) }
172
- return printLadder()
166
+ return printLadder(prefsApi, usage)
173
167
  }
174
168
  if (cmd === 'rm') {
175
169
  const n = parseInt(args._[0], 10)
176
170
  if (!Number.isFinite(n) || n < 1 || n > ladder.length) die(2, `usage: leg ladder rm <n> (1..${ladder.length})`)
177
171
  if (ladder.length === 1) die(2, 'that is the only rung left: a ladder with no rungs has nowhere to hand off to')
178
172
  const [gone] = ladder.splice(n - 1, 1)
179
- try { writePreferences({ handoff_ladder: ladder }) } catch (err) { die(2, err.message) }
180
- out(`removed rung ${n}: ${rungLabel(gone)}`)
181
- return printLadder()
173
+ try { prefsApi.writePreferences({ handoff_ladder: ladder }) } catch (err) { die(2, err.message) }
174
+ out(`removed rung ${n}: ${usage.rungLabel(gone)}`)
175
+ return printLadder(prefsApi, usage)
182
176
  }
183
177
  if (cmd === 'spend') {
184
178
  const v = args._[0]
185
179
  if (!['on', 'off'].includes(v)) die(2, 'usage: leg ladder spend on|off')
186
- const saved = writePreferences({ may_spend: v === 'on' })
180
+ const saved = prefsApi.writePreferences({ may_spend: v === 'on' })
187
181
  return out(saved.may_spend
188
182
  ? 'spending is ON: an unattended hand-off may take a rung that bills credits.'
189
183
  : 'spending is OFF: an unattended hand-off skips any rung that bills credits, and says so in the ledger.')
@@ -238,13 +232,10 @@ async function main() {
238
232
  out('\nPassing the leg to the next runner when limits hit.')
239
233
  return
240
234
  }
241
- if (SUPERVISED_AGENTS.includes(group)) {
242
- // leg claude|codex|agy|grok [agent args...]: everything after the agent name
243
- // goes straight through.
244
- const code = await attach(group, [cmd, ...rest].filter((x) => x !== undefined), { open: (process.env.LEG_NO_OPEN || process.env.BATON_NO_OPEN) !== '1' })
245
- process.exit(code)
246
- }
247
235
  if (group === 'sessions') {
236
+ const sessionsApi = await import('../src/sessions.mjs')
237
+ const { listSessions, readSession, isActive, removeSession, readLand, requestControl } = sessionsApi
238
+ const readSessionEvents = sessionsApi.readEvents
248
239
  const list = listSessions()
249
240
  if (cmd === 'ls' || !cmd) {
250
241
  if (args.json) return out(JSON.stringify(list, null, 2))
@@ -263,17 +254,20 @@ async function main() {
263
254
  // is not a destination, is not installed, or is at its wall must be
264
255
  // refused now, not silently turn into "whatever is next".
265
256
  if (typeof args.to === 'string') {
266
- const want = parseTarget(args.to, { die })
257
+ const [{ normalizeHandoffOrder, ladderFor }, usage, { readAccounts }] = await Promise.all([
258
+ import('../src/preferences.mjs'), import('../src/usage.mjs'), import('../src/accounts.mjs'),
259
+ ])
260
+ const want = await parseTarget(args.to, { die })
267
261
  const order = normalizeHandoffOrder(s.handoff_order)
268
262
  const ladder = ladderFor(s)
269
- const chain = candidates({ agent: s.agent, account: s.account, model: s.model ?? null, accounts: readAccounts(), order, ladder })
263
+ const chain = usage.candidates({ agent: s.agent, account: s.account, model: s.model ?? null, accounts: readAccounts(), order, ladder })
270
264
  const hit = chain.find((c) => c.agent === want.agent && c.account === want.account && (want.model ? (c.model ?? null) === want.model : true))
271
- const label = rungLabel(want)
272
- if (!hit) die(2, `${label} is not a destination for this terminal (${chain.map((c) => rungLabel(c)).join(', ') || 'none'})`)
265
+ const label = usage.rungLabel(want)
266
+ if (!hit) die(2, `${label} is not a destination for this terminal (${chain.map((c) => usage.rungLabel(c)).join(', ') || 'none'})`)
273
267
  if (s.installed && s.installed[want.agent] === false) die(3, `${label} is not installed on this machine`)
274
- const u = readUsage(want.agent, want.account)
275
- if (!isAvailable(u)) die(3, `${label} is at its usage limit until ${fmtReset(u.limited_until)}; pick another, or drop --to to take the next option in the order`)
276
- if (hit.model && wallActive(u.walls?.[hit.model])) die(3, `${label} is out until ${fmtReset(u.walls[hit.model].limited_until)}; pick another rung, or drop --to to take the next open one`)
268
+ const u = usage.readUsage(want.agent, want.account)
269
+ if (!usage.isAvailable(u)) die(3, `${label} is at its usage limit until ${usage.fmtReset(u.limited_until)}; pick another, or drop --to to take the next option in the order`)
270
+ if (hit.model && usage.wallActive(u.walls?.[hit.model])) die(3, `${label} is out until ${usage.fmtReset(u.walls[hit.model].limited_until)}; pick another rung, or drop --to to take the next open one`)
277
271
  const target = { agent: hit.agent, account: hit.account, ...(hit.model ? { model: hit.model } : {}) }
278
272
  requestControl(id, { handoff: true, target })
279
273
  return out(`handoff to ${label} requested for ${id}`)
@@ -291,6 +285,7 @@ async function main() {
291
285
  // the CLI twin never orphans a worktree the board can no longer reach
292
286
  if (s.worktree) {
293
287
  try {
288
+ const { pruneSessionWorktree } = await import('../src/land.mjs')
294
289
  const r = pruneSessionWorktree(s)
295
290
  out(r.removed
296
291
  ? `removed worktree ${s.worktree.path}${r.branchDeleted ? ` and branch ${s.worktree.branch}` : `; kept branch ${s.worktree.branch}`}`
@@ -303,7 +298,7 @@ async function main() {
303
298
  // is the only way to reach a per-model wall without waiting for one:
304
299
  // --message "You've reached your Fable limit." walls fable and leaves the
305
300
  // rest of the login open (src/buckets.mjs).
306
- if (cmd === 'simulate-limit') return simulateLimit(s, { message: typeof args.message === 'string' ? args.message : null })
301
+ if (cmd === 'simulate-limit') return simulateLimit(sessionsApi, s, { message: typeof args.message === 'string' ? args.message : null })
307
302
  die(2, `unknown sessions command "${cmd}" (ls|show|events|handoff|end|rm|simulate-limit)`)
308
303
  }
309
304
  if (group === 'ladder') {
@@ -311,10 +306,21 @@ async function main() {
311
306
  // state and the same skip reasons the board's picker shows.
312
307
  return ladderCommand(cmd, args)
313
308
  }
309
+ if (group === 'digest') {
310
+ // What happened while you were away: terminals, cards, landings and walls
311
+ // in a window, grouped by repository, what needs you first. Read only.
312
+ // Loaded here and not at the top: a command most sessions never run.
313
+ const a = parseArgs([cmd, ...rest].filter((x) => x !== undefined))
314
+ const { buildDigest, renderDigest, DEFAULT_SINCE } = await import('../src/digest.mjs')
315
+ let d
316
+ try { d = buildDigest({ since: typeof a.since === 'string' ? a.since : DEFAULT_SINCE }) } catch (err) { die(2, err.message) }
317
+ return out(a.json ? JSON.stringify(d, null, 2) : renderDigest(d))
318
+ }
314
319
  if (group === 'resume') {
315
320
  // The read side of the pointer. Freshness is never read out of the file:
316
321
  // it is recomputed from git here, now, so a resume file cannot describe a
317
322
  // picture that is no longer true to whoever is standing in the repo.
323
+ const { resumeVerdict, bodyOf, ago } = await import('../src/resume.mjs')
318
324
  const a = parseArgs([cmd, ...rest].filter((x) => x !== undefined))
319
325
  const where = typeof a.path === 'string' ? resolve(a.path) : process.cwd()
320
326
  const v = resumeVerdict(where)
@@ -345,6 +351,9 @@ async function main() {
345
351
  if (group === 'share') {
346
352
  // Multiplayer, off by default: the board binds a shared address only once
347
353
  // at least one person has a token, and every human has their own.
354
+ const { readShare, addPerson, removePerson, rotate: rotateToken, turnOn, turnOff, linkFor, personNamed, scheme, tlsConfigured, ROLES } = await import('../src/share.mjs')
355
+ const { stopBoard } = await import('../src/launcher.mjs')
356
+ const { ensureBoard } = await import('../src/attach.mjs')
348
357
  const share = readShare()
349
358
  // only the listener moves: the agents running under it are not part of who
350
359
  // may look at the board
@@ -374,6 +383,7 @@ async function main() {
374
383
  if (cmd === 'on') {
375
384
  const a = parseArgs(rest)
376
385
  // more than one human is the Team plan
386
+ const { entitlement, allows, describe: describeLicense, BUY_URL } = await import('../src/license.mjs')
377
387
  const ent = entitlement()
378
388
  if (!allows(ent, 'share')) die(2, ent.ok ? `leg share is part of the Team plan (per seat); this machine has a ${ent.plan} license. ${BUY_URL}` : describeLicense(ent))
379
389
  try {
@@ -428,6 +438,7 @@ async function main() {
428
438
  die(2, `unknown share command "${cmd}" (status|on|add|rotate|rm|off)`)
429
439
  }
430
440
  if (group === 'accounts') {
441
+ const { addAccount, removeAccount, listAccountRows, LAYOUT } = await import('../src/accounts.mjs')
431
442
  if (cmd === 'add') {
432
443
  const [agent, name] = args._
433
444
  if (!agent || !name) die(2, 'usage: leg accounts add <claude|codex> <name>')
@@ -451,6 +462,7 @@ async function main() {
451
462
  return out(`removed ${agent} account "${name}" (your real ${LAYOUT[agent]?.home() ?? 'home'} was not touched)`)
452
463
  }
453
464
  if (cmd === 'ls' || !cmd) {
465
+ const { listUsage, fmtReset } = await import('../src/usage.mjs')
454
466
  const usage = Object.fromEntries(listUsage().map((u) => [`${u.agent}--${u.account}`, u]))
455
467
  for (const r of listAccountRows()) {
456
468
  const u = usage[`${r.agent}--${r.name}`]
@@ -465,12 +477,14 @@ async function main() {
465
477
  if (group === 'harness') {
466
478
  // The portable harness: the working environment a hand-off carries with
467
479
  // the task. Off until `leg harness enable` (src/harness/index.mjs).
480
+ const { harnessCommand } = await import('../src/harness/cli.mjs')
468
481
  const code = await harnessCommand(cmd, args, { out, die })
469
482
  process.exit(code)
470
483
  }
471
484
  if (group === 'adapter' || group === 'adapters') {
472
485
  // Custom adapters: any CLI as a card agent, from a JSON spec on disk
473
486
  // (src/adapters/custom.mjs). The built-ins need none of this.
487
+ const { adapterCommand } = await import('../src/adapters/cli.mjs')
474
488
  const code = await adapterCommand(cmd, args, { out, die })
475
489
  process.exit(code)
476
490
  }
@@ -479,6 +493,7 @@ async function main() {
479
493
  // stores hold: a read-only index (src/history/index.mjs). `continue`
480
494
  // starts a normal supervised leg on one of them. `leg history --json` is
481
495
  // `leg history ls --json`: a leading flag names no verb.
496
+ const { historyCommand, worktreesCommand } = await import('../src/history/cli.mjs')
482
497
  const isHelp = cmd === '--help' || cmd === '-h' || cmd === 'help' || args.help || args.h
483
498
  const bare = typeof cmd === 'string' && cmd.startsWith('--')
484
499
  const verb = isHelp ? 'help' : (bare ? 'ls' : cmd)
@@ -492,11 +507,13 @@ async function main() {
492
507
  if (group === 'license') {
493
508
  // The paid gate. Keys verify offline against the public key in
494
509
  // src/license.mjs; nothing here talks to the network except refresh.
510
+ const { entitlement, describe: describeLicense, activate: activateLicense, deactivate: deactivateLicense, refresh: refreshLicense, licensePath, BUY_URL } = await import('../src/license.mjs')
495
511
  if (!cmd || cmd === 'status') {
496
- const ent = entitlement()
512
+ // looking does not start the trial clock; the first session does
513
+ const ent = entitlement({ startTrial: false })
497
514
  out(describeLicense(ent))
498
515
  if (ent.source === 'license') out(`stored at ${licensePath()}`)
499
- if (!ent.ok) out(`Buy: ${BUY_URL} then: leg license activate <key>`)
516
+ if (!ent.ok || ent.source === 'trial') out(`Buy: ${BUY_URL} then: leg license activate <key>`)
500
517
  return
501
518
  }
502
519
  if (cmd === 'activate') {
@@ -508,7 +525,7 @@ async function main() {
508
525
  } catch (err) { die(2, err.message) }
509
526
  return
510
527
  }
511
- if (cmd === 'deactivate') return out(deactivateLicense() ? `removed ${licensePath()}; Leg needs a key again before it will run` : 'no license was stored')
528
+ if (cmd === 'deactivate') return out(deactivateLicense() ? `removed ${licensePath()}; Leg is back on the trial if it has days left, otherwise it needs a key` : 'no license was stored')
512
529
  if (cmd === 'refresh') {
513
530
  try { const p = await refreshLicense(); out(`renewed ${p.plan} license ${p.id}, valid through ${p.expires}`) } catch (err) { die(2, err.message) }
514
531
  return
@@ -518,18 +535,22 @@ async function main() {
518
535
  if (group === 'uninstall') {
519
536
  // Leg never edits ~/.claude or ~/.codex; everything it added lives under
520
537
  // $LEG_HOME (sessions, usage, extra-account dirs, cards).
538
+ const { home } = await import('../src/store.mjs')
521
539
  const dir = home()
522
540
  if (!args.yes) {
523
541
  out(`leg uninstall removes ${dir} (sessions, usage, extra-account dirs, cards, board pidfile) and nothing else.`)
524
542
  out('Your real ~/.claude, ~/.codex and agy homes are never touched. Re-run with --yes to do it.')
525
543
  return
526
544
  }
545
+ const { listAccountRows, removeAccount } = await import('../src/accounts.mjs')
546
+ const { down } = await import('../src/launcher.mjs')
527
547
  for (const r of listAccountRows()) if (r.name !== 'default') removeAccount(r.agent, r.name)
528
548
  await down()
529
549
  rmSync(dir, { recursive: true, force: true })
530
550
  return out(`removed ${dir}; now: npm rm -g @ucsandman/legcli`)
531
551
  }
532
552
  if (group === 'card') {
553
+ const { readCard, listCards, readEvents, readRuns, cardDir } = await import('../src/store.mjs')
533
554
  if (cmd === 'add') return cardAdd(args)
534
555
  if (cmd === 'ls') {
535
556
  const cards = listCards()
@@ -542,6 +563,7 @@ async function main() {
542
563
  const card = readCard(id) || die(3, `card not found: ${id}`)
543
564
  if (cmd === 'show') {
544
565
  if (args.json) return out(JSON.stringify({ card, runs: readRuns(id) }, null, 2))
566
+ const { availableActions } = await import('../src/chain.mjs')
545
567
  out(fmtCard(card))
546
568
  out(` repo: ${card.repo}`)
547
569
  out(` worktree: ${card.worktree ?? '(none yet)'}`)
@@ -556,12 +578,14 @@ async function main() {
556
578
  return
557
579
  }
558
580
  if (cmd === 'run') {
581
+ const { runCard } = await import('../src/orchestrator.mjs')
559
582
  const final = await runCard(id)
560
583
  out(`${final.card_id} ${final.status} at ${final.station}`)
561
584
  process.exit(final.status === 'done' ? 0 : 1)
562
585
  }
563
586
  if (cmd === 'rm') {
564
587
  try {
588
+ const { remove: removeWorktree } = await import('../src/worktree.mjs')
565
589
  const r = removeWorktree(card.repo, id, { deleteBranch: Boolean(args['delete-branch']), force: Boolean(args.force) })
566
590
  if (args['delete-branch'] && r.branchUnmerged && !r.branchDeleted) out(`kept branch leg/${id}: it has commits not on its base (rerun with --force to discard them)`)
567
591
  } catch (err) { die(3, `worktree: ${err.message}`) }
@@ -570,6 +594,7 @@ async function main() {
570
594
  }
571
595
  const human = { queue: 'enqueue', pause: 'pause', resume: 'resume', kill: 'kill', approve: 'approve', 'handoff-now': 'handoff_now', rerun: 'rerun', reassign: 'reassign' }[cmd]
572
596
  if (human) {
597
+ const { humanAction } = await import('../src/orchestrator.mjs')
573
598
  const payload = human === 'reassign' ? { adapter: args.adapter || die(2, 'reassign needs --adapter'), mode: args.mode } : {}
574
599
  const next = humanAction(id, human, payload, { type: 'human', id: args.actor || 'local' })
575
600
  return out(`${next.card_id} ${next.status} at ${next.station} leg ${next.leg}`)
@@ -577,6 +602,7 @@ async function main() {
577
602
  die(2, `unknown card command "${cmd}" (add|ls|show|run|rm|events|queue|pause|resume|kill|approve|handoff-now|rerun|reassign)`)
578
603
  }
579
604
  if (group === 'scheduler') {
605
+ const { createScheduler, schedulerStatus, pidfile, MAX_CONCURRENT } = await import('../src/scheduler.mjs')
580
606
  if (cmd === 'start') {
581
607
  const ticks = args.ticks ? parseInt(args.ticks, 10) : Infinity
582
608
  const running = schedulerStatus()
@@ -602,19 +628,34 @@ async function main() {
602
628
  die(2, `unknown scheduler command "${cmd}" (start|status|stop)`)
603
629
  }
604
630
  if (group === 'up') {
631
+ const { up } = await import('../src/launcher.mjs')
605
632
  const a = parseArgs([cmd, ...rest].filter((x) => x !== undefined))
606
633
  const code = await up({ dry: Boolean(a.dry), open: !a['no-open'], port: a.port !== undefined ? parseInt(a.port, 10) : undefined, bind: a.bind })
607
634
  process.exit(code)
608
635
  }
609
- if (group === 'down') process.exit(await down())
610
- if (group === 'status') process.exit(await status())
636
+ if (group === 'down') { const { down } = await import('../src/launcher.mjs'); process.exit(await down()) }
637
+ if (group === 'status') { const { status } = await import('../src/launcher.mjs'); process.exit(await status()) }
611
638
  if (group === 'open') {
639
+ const { openBoard } = await import('../src/launcher.mjs')
612
640
  const port = (process.env.LEG_PORT || process.env.BATON_PORT) || 4747
613
641
  const url = `http://127.0.0.1:${port}`
614
642
  out(openBoard(url) ? `opened ${url}` : `could not open a browser; visit ${url}`)
615
643
  return
616
644
  }
617
- if (group && group !== '--help' && group !== 'help') die(2, `unknown command "${group}" (claude|codex|agy|grok|sessions|ladder|history|worktrees|resume|accounts|harness|license|share|up|down|status|open|card|scheduler|uninstall)`)
645
+ // Everything above is a named command group. What is left is either a
646
+ // supervised agent (`leg claude|codex|agy|grok [args...]`, everything after
647
+ // the agent name goes straight through) or unknown. SUPERVISED_AGENTS and
648
+ // attach() are loaded here, last, so no other command pays for them.
649
+ if (group && group !== '--help' && group !== 'help') {
650
+ const { SUPERVISED_AGENTS } = await import('../src/sessions.mjs')
651
+ if (SUPERVISED_AGENTS.includes(group)) {
652
+ const { attach } = await import('../src/attach.mjs')
653
+ const code = await attach(group, [cmd, ...rest].filter((x) => x !== undefined), { open: (process.env.LEG_NO_OPEN || process.env.BATON_NO_OPEN) !== '1' })
654
+ process.exit(code)
655
+ }
656
+ die(2, `unknown command "${group}" (claude|codex|agy|grok|sessions|ladder|history|worktrees|digest|resume|accounts|harness|license|share|up|down|status|open|card|scheduler|uninstall)`)
657
+ }
658
+ const { PRESET_NAMES } = await import('../src/presets.mjs')
618
659
  out(`leg ${VERSION}, your coding agents, with a board alongside and a handoff when one hits its limit
619
660
  claude|codex|agy|grok [args...] the normal interactive agent in this terminal; args pass straight through
620
661
  the board opens once, the session shows as a card, usage is tracked, a limit hands off
@@ -633,6 +674,8 @@ async function main() {
633
674
  history show|continue <id> | refresh | providers
634
675
  one conversation, or start leg <agent> on it where the agent can resume by id
635
676
  worktrees [--repo r] [--json] every checkout Leg can see: git's, its own, the ones conversations worked in
677
+ digest [--since 8h|2d|<iso>] [--json] what happened while you were away: what needs you, then every
678
+ terminal, card, landing and wall in the window, grouped by repository
636
679
  resume [--check] [--json] [--path <dir>] the hand-off waiting in this checkout, and whether it is still true
637
680
  freshness is recomputed from git at read time; --check prints only the verdict
638
681
  exit 0 current, 1 stale or unstamped, 3 no pointer here
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.
@@ -100,7 +108,7 @@ The repository stays private (see the 2026-09-11 license decision). That left ev
100
108
  ## 2026-09-11: Leg is a commercial product; the site stays on the free Vercel address
101
109
 
102
110
  - **License.** Wes: "if we're trying to sell this thing it shouldn't be open source and MIT." The repo stays private and the package ships under the Leg License Agreement (LICENSE): commercial, source readable in the package for inspection and own-use modification, no redistribution, no working around the license check. The FSL option from the pricing research was dropped for the same reason. Versions 0.2.0 and 0.3.0 remain available under MIT.
103
- - **Pricing.** Personal $79 once with 12 months of releases (Sublime shape); Team $12 per seat per month (adds `leg share`); no trial, and a 30-day money-back guarantee as the risk reversal instead (2026-09-15: a trial suits daily-habit products, and Leg's value is bursty, it pays off in the moment a limit lands, which a fortnight of evaluation does not reliably contain; a buyer already paying for two or three agent subscriptions is not price-sensitive at $79, they are trust-sensitive, and a trial does not answer trust). Keys are Ed25519 tokens signed with a private key that lives only in the seller's `.env` and the site's Vercel env; the public key is in `src/license.mjs`. A Personal key is a window over `RELEASE_DATE`, so every release bumps that constant.
111
+ - **Pricing.** Personal $79 once with 12 months of releases (Sublime shape); Team $12 per seat per month (adds `leg share`); ~~no trial, and a 30-day money-back guarantee as the risk reversal instead~~ superseded 2026-09-18 (2026-09-15: a trial suits daily-habit products, and Leg's value is bursty, it pays off in the moment a limit lands, which a fortnight of evaluation does not reliably contain; a buyer already paying for two or three agent subscriptions is not price-sensitive at $79, they are trust-sensitive, and a trial does not answer trust). 2026-09-18, Wes, ahead of the Hacker News post: the 14-day trial from 0.4.0 is back, started by the first session and recorded in `$LEG_HOME/trial.json` with every gate open, and the 30-day guarantee stays as the second net after buying. A launch audience installs to look, and "buy first" on a repo they found ten minutes ago is a wall the guarantee does not lower. Keys are Ed25519 tokens signed with a private key that lives only in the seller's `.env` and the site's Vercel env; the public key is in `src/license.mjs`. A Personal key is a window over `RELEASE_DATE`, so every release bumps that constant.
104
112
  - **Checkout.** Stripe payment links (live) with automatic tax, `site/api/key` and `site/api/webhook` on Vercel functions, Resend from `legcli@practicalsystems.io`. `scripts/stripe-setup.mjs` is idempotent per site origin; a test-mode purchase was run end to end on 2026-09-11 (checkout, thanks page, key activated in the CLI, webhook 200 twice).
105
113
  - **Domain.** A `legcli.com` purchase ($11.25) was started and cancelled at Wes's "just deploy it to a free vercel site"; nothing was bought. The site is https://legcli.com/.
106
114
  - **Not a lawyer.** The license text was drafted in-session; a review before the first sale outside the US is Wes's call.
@@ -116,6 +124,7 @@ The repository stays private (see the 2026-09-11 license decision). That left ev
116
124
  - **Root Directory had to change first.** It was `.`, which is correct when deploying from inside `site/` but would have published the repo root on a git build: no `index.html`, and `api/key` and `api/webhook` gone, so a purchase in flight would not have received its key.
117
125
  - ~~**Not every push.** `site/vercel.json` carries `ignoreCommand: git diff --quiet HEAD^ HEAD .`, which Vercel maps to the Ignored Build Step. A commit that touches nothing under `site/` cancels the build. The command failing (a shallow clone with no `HEAD^`) exits non-zero, which builds, so the failure mode is a redundant deploy rather than a missed one.~~ **Superseded 2026-09-15: the failure mode was a MISSED deploy.** `HEAD^ HEAD` compares one commit, not the push. Fast-forwarding five commits to main where only the first touched `site/` left the tip with no `site/` change, so Vercel cancelled the build and the redesigned site never went out while the repo said it had. The `ignoreCommand` is removed: every push to `main` deploys the site. Vercel's own "Skip deployments when there are no changes to the root directory" toggle is the safe version of this idea, because it compares against the last deployment rather than against `HEAD^`.
118
126
  - **Agent sessions cannot do this part.** `vercel --prod` and `vercel git connect` are both denied by the harness classifier as production deploys, and the CLI auth token cannot be read. Wes ran both.
127
+ - **2026-09-18: the git deploy did not fire, and the manual fallback has two traps.** The push of 0.13.1 (`19667ba`) produced no `vercel[bot]` deployment on GitHub (commit status stayed `pending`, no statuses), while the pushes an hour earlier had; the account had an overdue invoice at the time, which is the likeliest cause, and the git integration should be re-checked in the Vercel dashboard after a rename (the GitHub repository was renamed since the connection was made; `git remote -v` has the current name and the project still says `baton`). The fallback that works, run from the **repo root**, not `site/`: a root `.vercel/project.json` copied from `site/.vercel/` (gitignored), a root `.vercelignore` of `*`, `!site`, `!site/**`, `site/.vercel`, `site/.env*`, then `vercel --prod --yes --archive=tgz`. Trap one: `vercel --prod --yes` from inside `site/` uploads `site/` as the root, Vercel applies Root Directory `site` on top of it, and the deployment errors with an empty message. Trap two: that same `--yes` auto-linked the repo root to a **new** Vercel project named `leg` (empty, no domain); it is junk and `vercel project rm leg` removes it. Trap three: with no `.vercelignore` the CLI uploads the untracked probe debris too and refuses at 15,000 files.
119
128
 
120
129
 
121
130
  ## 2026-09-15: the board is a dark product surface, and DESIGN.md was the defect
@@ -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
@@ -3,6 +3,28 @@
3
3
  What broke, why, and what fixed it. One entry per failure, newest first. A first
4
4
  occurrence has to be written down or a repeat is never countable.
5
5
 
6
+ ## 2026-09-18: 0.14.0 pushed, CI green everywhere, and npm still served 0.13.1
7
+
8
+ **Fixed by putting the `repository` block back into the lockfile root
9
+ (`packages[""]`) and pushing again.** `scripts/npm-publish-gate.mjs` requires
10
+ `package-lock.json`'s root `repository.url` to equal the trusted-publisher
11
+ binding, and npm 10.9 (the local install) does not write that block; npm 11
12
+ does, which is where the 0.13.1 lockfile got it. `npm version 0.14.0` on npm
13
+ 10 rewrote the lockfile without it, the test matrix and the site deploy passed,
14
+ and only the `publish-npm` job failed, on the gate, before publishing anything.
15
+ The lesson: a lockfile touched by a different npm major is a release change,
16
+ and "CI green" after a push means the run, not the publish; `npm view
17
+ @ucsandman/legcli version` is the check.
18
+
19
+ ## 2026-09-18: `vercel --prod --yes` from `site/` failed the deploy and created a stray Vercel project
20
+
21
+ **Fixed by deploying from the repo root with a root `.vercelignore` and
22
+ `--archive=tgz`; the recipe is in DECISIONS.md under "the site deploys itself
23
+ from git".** The `--yes` also auto-created an empty Vercel project named `leg`
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
+ Directory set, the CLI must run from the repo root, and `--yes` is a consent to
26
+ create projects, not only to skip a confirmation.
27
+
6
28
  ## 2026-09-18: the terminal's opening `next:` line named the same agent twice and no model
7
29
 
8
30
  **Fixed in `src/attach.mjs`: the line maps the chain through `rungLabel`, so it
@@ -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