@ucsandman/legcli 0.7.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.
Files changed (184) hide show
  1. package/CHANGELOG.md +560 -0
  2. package/LICENSE +58 -0
  3. package/NOTICE +4 -0
  4. package/README.md +558 -0
  5. package/bin/fake-agent.mjs +143 -0
  6. package/bin/leg.mjs +449 -0
  7. package/docs/DECISIONS.md +119 -0
  8. package/docs/DEMO.md +214 -0
  9. package/docs/DEVIATIONS.md +182 -0
  10. package/docs/ERRORS.md +326 -0
  11. package/docs/README.md +40 -0
  12. package/docs/REUSE.md +289 -0
  13. package/docs/ROADMAP-v2.md +55 -0
  14. package/docs/VOCABULARY.md +243 -0
  15. package/docs/adapters.md +300 -0
  16. package/docs/board-guide.md +579 -0
  17. package/docs/cli-contracts.md +401 -0
  18. package/docs/concepts.md +426 -0
  19. package/docs/configuration.md +220 -0
  20. package/docs/faq.md +206 -0
  21. package/docs/getting-started.md +251 -0
  22. package/docs/real-run.md +80 -0
  23. package/docs/review-2026-09-14.md +72 -0
  24. package/docs/screenshots/board-400px.png +0 -0
  25. package/docs/screenshots/board-details-open.png +0 -0
  26. package/docs/screenshots/board-done.png +0 -0
  27. package/docs/screenshots/board-drawer.png +0 -0
  28. package/docs/screenshots/board-empty.png +0 -0
  29. package/docs/screenshots/board-handoff.png +0 -0
  30. package/docs/screenshots/board-running.png +0 -0
  31. package/docs/screenshots/demo-1-claude-running.png +0 -0
  32. package/docs/screenshots/demo-2-limit-hit.png +0 -0
  33. package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
  34. package/docs/screenshots/demo-4-codex-running.png +0 -0
  35. package/docs/screenshots/demo-5-done.png +0 -0
  36. package/docs/screenshots/floor-final-1280.png +0 -0
  37. package/docs/screenshots/floor-final-400.png +0 -0
  38. package/docs/screenshots/floor-landing.png +0 -0
  39. package/docs/screenshots/floor.png +0 -0
  40. package/docs/screenshots/share-guest-1280.png +0 -0
  41. package/docs/screenshots/share-owner-1280.png +0 -0
  42. package/docs/screenshots/terminals-1280.png +0 -0
  43. package/docs/screenshots/thumb-done.jpg +0 -0
  44. package/docs/screenshots/thumb-handoff-details.jpg +0 -0
  45. package/docs/screenshots/thumb-limit-hit.jpg +0 -0
  46. package/docs/screenshots/youtube-thumbnail.jpg +0 -0
  47. package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
  48. package/fixtures/limits/claude/claude-budget-limit.json +11 -0
  49. package/fixtures/limits/claude/claude-max-turns.json +11 -0
  50. package/fixtures/limits/claude/claude-model-limit.json +11 -0
  51. package/fixtures/limits/claude/claude-session-limit.json +11 -0
  52. package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
  53. package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
  54. package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
  55. package/fixtures/limits/codex/codex-skills-notice.json +11 -0
  56. package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
  57. package/fixtures/limits/codex/codex-usage-limit.json +11 -0
  58. package/fixtures/limits/generic/generic-429.json +11 -0
  59. package/fixtures/limits/generic/generic-overloaded.json +11 -0
  60. package/fixtures/limits/generic/generic-quota.json +11 -0
  61. package/fixtures/limits/generic/generic-rate-limit.json +11 -0
  62. package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
  63. package/fixtures/limits/generic/generic-usage-limit.json +11 -0
  64. package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
  65. package/fixtures/limits/negative/auth-source-set.json +11 -0
  66. package/fixtures/limits/negative/compile-error.json +11 -0
  67. package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
  68. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
  69. package/fixtures/live/agy/cmd.txt +1 -0
  70. package/fixtures/live/agy/err.log +0 -0
  71. package/fixtures/live/agy/out.log +1 -0
  72. package/fixtures/live/agy/parsed.json +5 -0
  73. package/fixtures/live/agy/run.json +18 -0
  74. package/fixtures/live/agy/supervisor.log +2 -0
  75. package/fixtures/live/claude/cmd.txt +1 -0
  76. package/fixtures/live/claude/err.log +0 -0
  77. package/fixtures/live/claude/limit-rate_limit.json +21 -0
  78. package/fixtures/live/claude/out.log +1 -0
  79. package/fixtures/live/claude/parsed.json +11 -0
  80. package/fixtures/live/claude/run.json +18 -0
  81. package/fixtures/live/claude/supervisor.log +2 -0
  82. package/fixtures/live/codex/cmd.txt +1 -0
  83. package/fixtures/live/codex/err.log +1 -0
  84. package/fixtures/live/codex/last.md +2 -0
  85. package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
  86. package/fixtures/live/codex/out.log +8 -0
  87. package/fixtures/live/codex/parsed.json +15 -0
  88. package/fixtures/live/codex/run.json +18 -0
  89. package/fixtures/live/codex/supervisor.log +2 -0
  90. package/fixtures/live/grok/cmd.txt +1 -0
  91. package/fixtures/live/grok/err.log +32 -0
  92. package/fixtures/live/grok/out.log +7 -0
  93. package/fixtures/live/grok/parsed.json +5 -0
  94. package/fixtures/live/grok/run.json +18 -0
  95. package/fixtures/live/grok/supervisor.log +2 -0
  96. package/fixtures/verified.json +34 -0
  97. package/package.json +68 -0
  98. package/scripts/board-shots.mjs +69 -0
  99. package/scripts/build-docs-site.mjs +308 -0
  100. package/scripts/check-claims.mjs +150 -0
  101. package/scripts/clean-clone-check.sh +42 -0
  102. package/scripts/license-sign.mjs +23 -0
  103. package/scripts/limits-table.mjs +38 -0
  104. package/scripts/live-limits.mjs +36 -0
  105. package/scripts/privacy-check.mjs +63 -0
  106. package/scripts/probe.mjs +92 -0
  107. package/scripts/seed-fake-cards.mjs +34 -0
  108. package/scripts/seed-floor-board.mjs +69 -0
  109. package/scripts/seed-wes-board.mjs +91 -0
  110. package/scripts/stripe-setup.mjs +99 -0
  111. package/scripts/vercel-env.mjs +40 -0
  112. package/src/accounts.mjs +119 -0
  113. package/src/adapters/agy.mjs +56 -0
  114. package/src/adapters/claude.mjs +54 -0
  115. package/src/adapters/codex.mjs +73 -0
  116. package/src/adapters/common.mjs +32 -0
  117. package/src/adapters/fake.mjs +48 -0
  118. package/src/adapters/grok.mjs +51 -0
  119. package/src/adapters/index.mjs +27 -0
  120. package/src/adapters/resolve.mjs +40 -0
  121. package/src/attach.mjs +592 -0
  122. package/src/auth.mjs +85 -0
  123. package/src/board/board.css +543 -0
  124. package/src/board/board.js +1307 -0
  125. package/src/board/favicon.svg +1 -0
  126. package/src/board/floor.html +127 -0
  127. package/src/board/floor.js +670 -0
  128. package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
  129. package/src/board/fonts/OFL-azeret-mono.txt +94 -0
  130. package/src/board/fonts/atkinson-board.woff2 +0 -0
  131. package/src/board/fonts/azeret-board.woff2 +0 -0
  132. package/src/board/index.html +251 -0
  133. package/src/board/sessions.js +1451 -0
  134. package/src/bundle.mjs +103 -0
  135. package/src/cards.mjs +114 -0
  136. package/src/chain.mjs +264 -0
  137. package/src/commands.mjs +79 -0
  138. package/src/contract.mjs +73 -0
  139. package/src/env.mjs +19 -0
  140. package/src/fsx.mjs +72 -0
  141. package/src/git-snapshot.mjs +152 -0
  142. package/src/handoff.mjs +171 -0
  143. package/src/hook.mjs +49 -0
  144. package/src/land.mjs +106 -0
  145. package/src/launcher.mjs +272 -0
  146. package/src/leases.mjs +86 -0
  147. package/src/ledger.mjs +369 -0
  148. package/src/license.mjs +168 -0
  149. package/src/limits.mjs +97 -0
  150. package/src/live-capture.mjs +69 -0
  151. package/src/mergequeue.mjs +225 -0
  152. package/src/orchestrator.mjs +365 -0
  153. package/src/pipeline.mjs +100 -0
  154. package/src/preferences.mjs +47 -0
  155. package/src/presets.mjs +23 -0
  156. package/src/ratelimit.mjs +49 -0
  157. package/src/redact.mjs +47 -0
  158. package/src/resume.mjs +354 -0
  159. package/src/runner.mjs +438 -0
  160. package/src/scheduler.mjs +118 -0
  161. package/src/server.mjs +845 -0
  162. package/src/session-detail.mjs +129 -0
  163. package/src/sessions.mjs +202 -0
  164. package/src/share.mjs +163 -0
  165. package/src/stations/agent.mjs +42 -0
  166. package/src/stations/build.mjs +9 -0
  167. package/src/stations/human.mjs +5 -0
  168. package/src/stations/land.mjs +6 -0
  169. package/src/stations/plan.mjs +8 -0
  170. package/src/stations/pr.mjs +31 -0
  171. package/src/stations/review.mjs +8 -0
  172. package/src/stations/test.mjs +27 -0
  173. package/src/store.mjs +90 -0
  174. package/src/sync/dashclaw.mjs +70 -0
  175. package/src/sync/index.mjs +54 -0
  176. package/src/sync/workboard.mjs +54 -0
  177. package/src/taps/agy.mjs +64 -0
  178. package/src/taps/claude-usage.mjs +67 -0
  179. package/src/taps/claude.mjs +170 -0
  180. package/src/taps/codex.mjs +286 -0
  181. package/src/trust.mjs +323 -0
  182. package/src/usage.mjs +179 -0
  183. package/src/wait.mjs +30 -0
  184. package/src/worktree.mjs +207 -0
package/src/ledger.mjs ADDED
@@ -0,0 +1,369 @@
1
+ #!/usr/bin/env node
2
+ // Ported 2026-09-10 from private ucsandman team tooling; see NOTICE and docs/REUSE.md.
3
+ // ledger — the ONLY writer of Baton's card ledger files under $BATON_HOME/cards/<id>/.
4
+ // Subcommands: create | append | update | sync. Every event carries a validated
5
+ // actor, the card id, the station and the leg, and lands in that actor's own
6
+ // events-<actor-key>.jsonl. Importable: readEvents, parseActor, actorKey.
7
+ import { mkdirSync, readFileSync, writeFileSync, appendFileSync, readdirSync, existsSync, unlinkSync, renameSync } from 'node:fs'
8
+ import { writeJsonAtomic, withFileLock } from './fsx.mjs'
9
+ import { join, resolve } from 'node:path'
10
+ import { homedir } from 'node:os'
11
+ import { fileURLToPath } from 'node:url'
12
+ import { SECRET_PATTERNS } from './redact.mjs'
13
+ import { notify } from './sync/index.mjs'
14
+ import { dashclawConfig, record } from './sync/dashclaw.mjs'
15
+
16
+ export const EVENT_TYPES = ['card_created', 'leg_started', 'leg_progress', 'leg_exited',
17
+ 'limit_detected', 'handoff_written', 'leg_resumed', 'station_done', 'bounced', 'landed',
18
+ 'land_warning', 'land_retry', 'blocked_by', 'scheduler_started', 'scheduler_stopped',
19
+ 'approval_needed', 'approved', 'reassigned', 'paused', 'resumed', 'killed', 'done',
20
+ 'failed', 'error', 'status']
21
+ export const STATUSES = ['backlog', 'queued', 'running', 'handing_off', 'waiting_human',
22
+ 'needs_approval', 'paused', 'done', 'failed', 'killed']
23
+ const CLOSED = ['done', 'failed', 'killed']
24
+ // card.json keys `update --patch` may set (everything else goes through a named flag)
25
+ export const PATCHABLE = ['pipeline', 'leases', 'land_attempts', 'land_mode', 'test_command', 'title', 'trunk',
26
+ 'bounce_reason', 'kill_requested', 'worktree', 'next_leg', 'handoff_outcome', 'resume_from_bundle', 'failure', 'last_bundle', 'pr_url']
27
+ const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,39}$/i
28
+
29
+ export const ROOT = process.env.BATON_HOME || join(homedir(), '.baton')
30
+
31
+ function die(code, msg) {
32
+ process.stderr.write(msg + '\n')
33
+ process.exit(code)
34
+ }
35
+
36
+ function parseArgs(argv) {
37
+ const args = {}
38
+ for (let i = 0; i < argv.length; i += 2) {
39
+ if (!argv[i].startsWith('--') || argv[i + 1] === undefined) {
40
+ die(2, `bad argument pair near "${argv[i]}"`)
41
+ }
42
+ args[argv[i].slice(2)] = argv[i + 1]
43
+ }
44
+ return args
45
+ }
46
+
47
+ function need(args, key, allowed) {
48
+ const v = args[key]
49
+ if (!v) die(2, `missing --${key}`)
50
+ if (allowed && !allowed.includes(v)) {
51
+ die(2, `invalid --${key} "${v}" (allowed: ${allowed.join(', ')})`)
52
+ }
53
+ return v
54
+ }
55
+
56
+ // Actor: who wrote the event. {type:'agent', adapter, model?} | {type:'human', id} | {type:'baton'}.
57
+ // Returns the normalized actor or null when the shape is wrong.
58
+ export function parseActor(raw) {
59
+ let a = raw
60
+ if (typeof raw === 'string') {
61
+ try { a = JSON.parse(raw) } catch { return null }
62
+ }
63
+ if (!a || typeof a !== 'object') return null
64
+ if (a.type === 'agent') {
65
+ if (typeof a.adapter !== 'string' || !NAME_RE.test(a.adapter)) return null
66
+ if (a.model !== undefined && typeof a.model !== 'string') return null
67
+ return a.model === undefined ? { type: 'agent', adapter: a.adapter } : { type: 'agent', adapter: a.adapter, model: a.model }
68
+ }
69
+ if (a.type === 'human') {
70
+ if (typeof a.id !== 'string' || !NAME_RE.test(a.id)) return null
71
+ return { type: 'human', id: a.id }
72
+ }
73
+ if (a.type === 'leg') return { type: 'leg' }
74
+ if (a.type === 'baton') return { type: 'baton' }
75
+ return null
76
+ }
77
+
78
+ export function actorKey(actor) {
79
+ if (actor.type === 'agent') return `agent-${actor.adapter.toLowerCase()}`
80
+ if (actor.type === 'human') return `human-${actor.id.toLowerCase()}`
81
+ if (actor.type === 'leg') return 'leg'
82
+ return 'baton'
83
+ }
84
+
85
+ const ACTOR_HELP = 'invalid --actor (expected JSON {"type":"agent","adapter":"<name>"} | {"type":"human","id":"<id>"} | {"type":"leg"} | {"type":"baton"})'
86
+
87
+ function cardDir(id) {
88
+ const dir = join(ROOT, 'cards', id)
89
+ if (!existsSync(join(dir, 'card.json'))) die(3, `card not found: ${id}`)
90
+ return dir
91
+ }
92
+
93
+ function readCard(dir) {
94
+ const file = join(dir, 'card.json')
95
+ // the atomic write's direct-write fallback (src/fsx.mjs) can be seen torn
96
+ // for a moment; retry before calling the file corrupt
97
+ for (let i = 0; ; i++) {
98
+ try {
99
+ return JSON.parse(readFileSync(file, 'utf8'))
100
+ } catch {
101
+ if (i >= 5) die(2, `corrupt card.json: ${file}`)
102
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20)
103
+ }
104
+ }
105
+ }
106
+
107
+ function writeActive() {
108
+ const cardsRoot = join(ROOT, 'cards')
109
+ const lines = ['# Active cards', '']
110
+ if (existsSync(cardsRoot)) {
111
+ for (const id of readdirSync(cardsRoot).sort()) {
112
+ const file = join(cardsRoot, id, 'card.json')
113
+ if (!existsSync(file)) continue
114
+ let c
115
+ try {
116
+ c = JSON.parse(readFileSync(file, 'utf8'))
117
+ } catch {
118
+ process.stderr.write(`warning: skipping corrupt card.json: ${file}\n`)
119
+ continue
120
+ }
121
+ if (CLOSED.includes(c.status)) continue
122
+ lines.push(`- ${c.card_id} [${c.status}] station=${c.station} leg=${c.leg} — ${String(c.task).slice(0, 100)}`)
123
+ }
124
+ }
125
+ if (lines.length === 2) lines.push('(none)')
126
+ mkdirSync(ROOT, { recursive: true })
127
+ writeFileSync(join(ROOT, 'ACTIVE.md'), lines.join('\n') + '\n')
128
+ }
129
+
130
+ function appendEvent(id, ev) {
131
+ appendFileSync(join(ROOT, 'cards', id, `events-${actorKey(ev.actor)}.jsonl`),
132
+ JSON.stringify(ev) + '\n')
133
+ }
134
+
135
+ // All writers' events for one card, merged and sorted by ts (stable within a writer).
136
+ export function readEvents(id) {
137
+ const dir = join(ROOT, 'cards', id)
138
+ if (!existsSync(dir)) return []
139
+ const events = []
140
+ for (const name of readdirSync(dir).sort()) {
141
+ if (!/^events-.*\.jsonl$/.test(name)) continue
142
+ const lines = readFileSync(join(dir, name), 'utf8').split('\n').filter(Boolean)
143
+ for (let i = 0; i < lines.length; i++) {
144
+ try { events.push({ ev: JSON.parse(lines[i]), file: name, i }) } catch { continue }
145
+ }
146
+ }
147
+ events.sort((a, b) => (a.ev.ts < b.ev.ts ? -1 : a.ev.ts > b.ev.ts ? 1 : a.file === b.file ? a.i - b.i : a.file < b.file ? -1 : 1))
148
+ return events.map((e) => e.ev)
149
+ }
150
+
151
+ // --- optional syncs (src/sync): best-effort, never block the ledger ---
152
+ async function syncNotify(kind, ev, card) {
153
+ const id = ev?.card_id ?? card?.card_id ?? null
154
+ try {
155
+ await notify({
156
+ kind, ev, card, home: ROOT,
157
+ report: (summary) => {
158
+ if (!id) return
159
+ appendEvent(id, { ts: now(), card_id: id, actor: { type: 'baton' }, station: card?.station ?? '-', leg: card?.leg ?? 0, type: 'status', summary })
160
+ },
161
+ })
162
+ } catch {}
163
+ }
164
+
165
+ function unsyncedFiles(onlyCardId) {
166
+ const cardsRoot = join(ROOT, 'cards')
167
+ if (!existsSync(cardsRoot)) return []
168
+ const ids = onlyCardId ? [onlyCardId] : readdirSync(cardsRoot).sort()
169
+ return ids
170
+ .map((id) => join(cardsRoot, id, 'unsynced.jsonl'))
171
+ .filter((f) => existsSync(f) || existsSync(`${f}.flushing`))
172
+ }
173
+
174
+ const readLines = (f) => (existsSync(f) ? readFileSync(f, 'utf8').split('\n').filter(Boolean) : [])
175
+
176
+ // `ledger sync [--card id]`: replay buffered DashClaw records; exit 1 while
177
+ // any remain, 2 when sync is off. The buffer is renamed out from under live
178
+ // appenders first (a rename is atomic; a ledger write that fails meanwhile
179
+ // starts a fresh unsynced.jsonl, which is never rewritten here), and the
180
+ // records that still fail go back by append, behind those. A flush that died
181
+ // mid-way leaves the .flushing file, picked up first next time.
182
+ async function flushUnsynced(onlyCardId) {
183
+ const cfg = dashclawConfig()
184
+ if (!cfg) {
185
+ process.stderr.write('ledger sync: DashClaw sync is off (needs BATON_SYNC_DASHCLAW=1, DASHCLAW_URL and DASHCLAW_API_KEY); nothing flushed\n')
186
+ return 'off'
187
+ }
188
+ let anyRemaining = false
189
+ for (const file of unsyncedFiles(onlyCardId)) {
190
+ const work = `${file}.flushing`
191
+ const lines = readLines(work)
192
+ if (existsSync(file)) { renameSync(file, work); lines.push(...readLines(work)) } else if (!lines.length) continue
193
+ const survivors = []
194
+ for (const line of lines) {
195
+ let ok = false
196
+ try {
197
+ const op = JSON.parse(line)
198
+ ok = (await record(cfg, op.ev, op.card)).ok
199
+ } catch { ok = false }
200
+ if (!ok) survivors.push(line)
201
+ }
202
+ if (survivors.length) {
203
+ appendFileSync(file, survivors.join('\n') + '\n')
204
+ anyRemaining = true
205
+ }
206
+ unlinkSync(work)
207
+ }
208
+ return anyRemaining
209
+ }
210
+
211
+ function assertNoSecrets(...values) {
212
+ for (const v of values) {
213
+ if (!v) continue
214
+ for (const [name, re] of SECRET_PATTERNS) {
215
+ if (re.test(v)) die(2, `refusing to log: value matches a secret pattern (${name})`)
216
+ }
217
+ }
218
+ }
219
+
220
+ function parseChain(raw) {
221
+ let chain
222
+ try { chain = JSON.parse(raw) } catch { return null }
223
+ if (!Array.isArray(chain) || chain.length === 0) return null
224
+ const out = []
225
+ for (const entry of chain) {
226
+ if (!entry || typeof entry !== 'object') return null
227
+ if (typeof entry.adapter !== 'string' || !NAME_RE.test(entry.adapter)) return null
228
+ const e = { adapter: entry.adapter, mode: entry.mode ?? null, max_turns: entry.max_turns ?? null }
229
+ if (e.mode !== null && typeof e.mode !== 'string') return null
230
+ if (e.max_turns !== null && !Number.isInteger(e.max_turns)) return null
231
+ if (entry.model !== undefined) e.model = String(entry.model)
232
+ out.push(e)
233
+ }
234
+ return out
235
+ }
236
+
237
+ const now = () => new Date().toISOString()
238
+
239
+ async function main() {
240
+ const [cmd, ...rest] = process.argv.slice(2)
241
+ const args = parseArgs(rest)
242
+
243
+ if (cmd === 'create') {
244
+ const slug = need(args, 'slug')
245
+ if (!/^[a-z0-9][a-z0-9-]{0,29}$/.test(slug)) {
246
+ die(2, `invalid --slug "${slug}" (lowercase alphanumerics and hyphens, max 30)`)
247
+ }
248
+ const task = need(args, 'task')
249
+ const repo = need(args, 'repo')
250
+ const chain = parseChain(need(args, 'chain'))
251
+ if (!chain) die(2, 'invalid --chain (expected a non-empty JSON array of {adapter, mode?, max_turns?, model?})')
252
+ const actor = parseActor(args.actor ?? '{"type":"human","id":"local"}')
253
+ if (!actor) die(2, ACTOR_HELP)
254
+ assertNoSecrets(task, args.title)
255
+ // Pipeline: validated upstream by src/pipeline.mjs (bin/baton.mjs); here
256
+ // only the shape is checked. Default = the `build` preset over the chain.
257
+ let pipeline
258
+ try {
259
+ pipeline = args.pipeline ? JSON.parse(args.pipeline)
260
+ : [{ name: 'build', kind: 'agent', prompt: 'build', chain: chain.map((c) => ({ adapter: c.adapter, ...(c.mode ? { mode: c.mode } : {}), ...(c.max_turns ? { maxTurns: c.max_turns } : {}), ...(c.model ? { model: c.model } : {}) })) }]
261
+ } catch { die(2, 'invalid --pipeline (expected a JSON array of stations)') }
262
+ if (!Array.isArray(pipeline) || !pipeline.length || pipeline.some((s) => !s || typeof s.name !== 'string' || typeof s.kind !== 'string')) {
263
+ die(2, 'invalid --pipeline (expected a non-empty JSON array of {name, kind, chain?, prompt?})')
264
+ }
265
+ let leases = []
266
+ if (args.leases) {
267
+ try { leases = JSON.parse(args.leases) } catch { die(2, 'invalid --leases (expected a JSON array of path globs)') }
268
+ if (!Array.isArray(leases) || leases.some((l) => typeof l !== 'string')) die(2, 'invalid --leases (expected a JSON array of path globs)')
269
+ }
270
+ const landMode = args['land-mode'] ?? 'ff'
271
+ if (!['ff', 'pr'].includes(landMode)) die(2, `invalid --land-mode "${landMode}" (allowed: ff, pr)`)
272
+ const d = new Date()
273
+ const pad = (n) => String(n).padStart(2, '0')
274
+ const id = `card-${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}-${slug}`
275
+ const dir = join(ROOT, 'cards', id)
276
+ if (existsSync(dir)) die(2, `card already exists: ${id}`)
277
+ mkdirSync(join(dir, 'runs'), { recursive: true })
278
+ const card = {
279
+ card_id: id, title: args.title || task.slice(0, 80), task, repo, trunk: args.trunk || 'main', chain,
280
+ pipeline, station: '-', leg: 0, leases, status: 'backlog',
281
+ land_mode: landMode, test_command: args['test-command'] || null, land_attempts: 0,
282
+ worktree: null, bounce_reason: null, kill_requested: false,
283
+ session_id: null, created_at: now(), updated_at: now(), actor,
284
+ }
285
+ writeJsonAtomic(join(dir, 'card.json'), card)
286
+ const createdEvent = {
287
+ ts: now(), card_id: id, actor, station: card.station, leg: card.leg, type: 'card_created',
288
+ summary: `card created (chain=${chain.map((c) => c.adapter).join('>')})`,
289
+ }
290
+ appendEvent(id, createdEvent)
291
+ writeActive()
292
+ await syncNotify('create', createdEvent, card)
293
+ process.stdout.write(id + '\n')
294
+ } else if (cmd === 'append') {
295
+ const id = need(args, 'card')
296
+ assertNoSecrets(args.summary, args.body)
297
+ const card = readCard(cardDir(id))
298
+ const actor = parseActor(need(args, 'actor'))
299
+ if (!actor) die(2, ACTOR_HELP)
300
+ let leg = card.leg
301
+ if (args.leg !== undefined) {
302
+ leg = parseInt(args.leg, 10)
303
+ if (!Number.isInteger(leg) || leg < 0) die(2, `invalid --leg "${args.leg}"`)
304
+ }
305
+ const ev = {
306
+ ts: now(), card_id: id, actor,
307
+ station: args.station || card.station, leg,
308
+ type: need(args, 'type', EVENT_TYPES),
309
+ summary: need(args, 'summary'),
310
+ }
311
+ if (args.body) ev.body = args.body
312
+ if (args.action) ev.action_id = args.action
313
+ appendEvent(id, ev)
314
+ await syncNotify('append', ev, card)
315
+ } else if (cmd === 'update') {
316
+ const id = need(args, 'card')
317
+ const dir = cardDir(id)
318
+ // validate every argument first: a die() inside the lock would leave it behind
319
+ const patch = {}
320
+ if (args.status) patch.status = need(args, 'status', STATUSES)
321
+ if (args['session-id']) patch.session_id = args['session-id']
322
+ if (args.station) patch.station = args.station
323
+ if (args.leg !== undefined) {
324
+ const leg = parseInt(args.leg, 10)
325
+ if (!Number.isInteger(leg) || leg < 0) die(2, `invalid --leg "${args.leg}"`)
326
+ patch.leg = leg
327
+ }
328
+ if (args.patch) {
329
+ // Orchestrator/board fields; allowlisted so the ledger stays the schema owner.
330
+ let p
331
+ try { p = JSON.parse(args.patch) } catch { die(2, 'invalid --patch (expected a JSON object)') }
332
+ if (!p || typeof p !== 'object' || Array.isArray(p)) die(2, 'invalid --patch (expected a JSON object)')
333
+ for (const k of Object.keys(p)) {
334
+ if (!PATCHABLE.includes(k)) die(2, `--patch key "${k}" not allowed (allowed: ${PATCHABLE.join(', ')})`)
335
+ patch[k] = p[k]
336
+ }
337
+ }
338
+ // "the ONLY writer" is this program, not one process: the orchestrator,
339
+ // the board, the CLI and every supervisor run their own `ledger update`
340
+ // child, so the read-modify-write happens under the card's lock or a
341
+ // human Kill is silently overwritten by a driver's status write.
342
+ const card = withFileLock(join(dir, '.card.lock'), () => {
343
+ const cur = readCard(dir)
344
+ Object.assign(cur, patch, { updated_at: now() })
345
+ writeJsonAtomic(join(dir, 'card.json'), cur)
346
+ return cur
347
+ })
348
+ writeActive()
349
+ if (patch.status) await syncNotify('status', null, card)
350
+ } else if (cmd === 'log') {
351
+ // Non-card events (scheduler start/stop): $BATON_HOME/events-<actor-key>.jsonl
352
+ assertNoSecrets(args.summary, args.body)
353
+ const actor = parseActor(need(args, 'actor'))
354
+ if (!actor) die(2, ACTOR_HELP)
355
+ const ev = { ts: now(), card_id: null, actor, station: '-', leg: 0, type: need(args, 'type', EVENT_TYPES), summary: need(args, 'summary') }
356
+ if (args.body) ev.body = args.body
357
+ mkdirSync(ROOT, { recursive: true })
358
+ appendFileSync(join(ROOT, `events-${actorKey(actor)}.jsonl`), JSON.stringify(ev) + '\n')
359
+ } else if (cmd === 'sync') {
360
+ const anyRemaining = await flushUnsynced(args.card)
361
+ process.exit(anyRemaining === 'off' ? 2 : anyRemaining ? 1 : 0)
362
+ } else {
363
+ die(2, `unknown command "${cmd ?? ''}" (expected create|append|update|log|sync)`)
364
+ }
365
+ }
366
+
367
+ const isMain = process.argv[1]
368
+ && resolve(process.argv[1]).toLowerCase() === fileURLToPath(import.meta.url).toLowerCase()
369
+ if (isMain) await main()
@@ -0,0 +1,168 @@
1
+ // license — the paid-product gate. A license key is a signed, self-contained
2
+ // token that Baton checks offline with the public key embedded below; the
3
+ // private key never leaves the seller's machine. Two plans:
4
+ //
5
+ // personal one-time purchase; every release dated on or before the key's
6
+ // updates_until activates, later releases refuse the key but the
7
+ // installed one keeps working (the Sublime Text shape)
8
+ // team per-seat subscription; the key carries an expiry a few days past
9
+ // the billing period, `baton license refresh` fetches a renewed one
10
+ //
11
+ // There is no trial. Baton pays off in the moment a limit lands mid-flow, which
12
+ // is not a thing a fortnight of evaluation reliably contains; the risk reversal
13
+ // is a 30-day money-back guarantee instead, which costs no code and no expiry
14
+ // machinery. Key shape: BATON-<base64url payload>.<base64url signature> where
15
+ // the signature is Ed25519 over the payload bytes exactly as encoded.
16
+ import { createPublicKey, createPrivateKey, verify as cryptoVerify, sign as cryptoSign, createHash } from 'node:crypto'
17
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, chmodSync } from 'node:fs'
18
+ import { join } from 'node:path'
19
+ import { home } from './store.mjs'
20
+
21
+ export const PUBLIC_KEY_B64 = 'MCowBQYDK2VwAyEAIpVQymHHJAkIrZHv0u4o0bgfFmtW3Crm7uMwYHP53X8='
22
+ // The suite has to exercise the gate itself — `baton share on` refusing a
23
+ // Personal key, `baton <agent>` refusing nothing at all — and it cannot sign a
24
+ // key for the real public key, which is the point of the real public key. This
25
+ // env seam lets a spawned CLI verify against a throwaway pair. It weakens
26
+ // nothing: Baton ships as readable JavaScript, so anyone who would set this
27
+ // could edit the constant above instead.
28
+ const ACTIVE_PUBLIC_KEY = process.env.LEG_PUBLIC_KEY_B64 || process.env.BATON_PUBLIC_KEY_B64 || PUBLIC_KEY_B64
29
+ // The date this release was cut. A personal key activates when this is on or
30
+ // before its updates_until. Bumped with every published version.
31
+ export const RELEASE_DATE = '2026-09-15'
32
+ export const GUARANTEE_DAYS = 30
33
+ export const SITE = process.env.LEG_SITE || process.env.BATON_SITE || 'https://legcli.com'
34
+ export const BUY_URL = `${SITE}/#pricing`
35
+ export const PLANS = {
36
+ personal: { label: 'Personal', gates: ['run'] },
37
+ team: { label: 'Team', gates: ['run', 'share'] },
38
+ }
39
+ export const PREFIX = 'LEG-'
40
+ export const LEGACY_PREFIX = 'BATON-'
41
+
42
+ const b64u = (buf) => Buffer.from(buf).toString('base64url')
43
+ const unb64u = (s) => Buffer.from(s, 'base64url')
44
+ const isoToday = () => new Date().toISOString().slice(0, 10)
45
+
46
+ export function signLicense(payload, privateKeyB64) {
47
+ const key = createPrivateKey({ key: Buffer.from(privateKeyB64, 'base64'), format: 'der', type: 'pkcs8' })
48
+ const body = Buffer.from(JSON.stringify(payload), 'utf8')
49
+ const sig = cryptoSign(null, body, key)
50
+ return `${PREFIX}${b64u(body)}.${b64u(sig)}`
51
+ }
52
+
53
+ export function parseLicense(key) {
54
+ const s = String(key ?? '').trim()
55
+ let rest
56
+ if (s.startsWith(PREFIX)) {
57
+ rest = s.slice(PREFIX.length)
58
+ } else if (s.startsWith(LEGACY_PREFIX)) {
59
+ rest = s.slice(LEGACY_PREFIX.length)
60
+ } else {
61
+ throw new Error('malformed')
62
+ }
63
+ const [p, sig, extra] = rest.split('.')
64
+ if (!p || !sig || extra !== undefined) throw new Error('malformed')
65
+ let payload
66
+ try { payload = JSON.parse(unb64u(p).toString('utf8')) } catch { throw new Error('malformed') }
67
+ if (!payload || typeof payload !== 'object' || payload.v !== 1) throw new Error('malformed')
68
+ return { payload, body: unb64u(p), sig: unb64u(sig) }
69
+ }
70
+
71
+ // { ok, reason, payload }. reason is one of: malformed, bad-signature,
72
+ // unknown-plan, personal-updates-ended, team-expired.
73
+ export function verifyLicense(key, { publicKeyB64 = ACTIVE_PUBLIC_KEY, today = isoToday(), releaseDate = RELEASE_DATE } = {}) {
74
+ let parsed
75
+ try { parsed = parseLicense(key) } catch (e) { return { ok: false, reason: e.message } }
76
+ const pub = createPublicKey({ key: Buffer.from(publicKeyB64, 'base64'), format: 'der', type: 'spki' })
77
+ let good = false
78
+ try { good = cryptoVerify(null, parsed.body, pub, parsed.sig) } catch { good = false }
79
+ if (!good) return { ok: false, reason: 'bad-signature', payload: parsed.payload }
80
+ const p = parsed.payload
81
+ if (!PLANS[p.plan]) return { ok: false, reason: 'unknown-plan', payload: p }
82
+ if (p.plan === 'personal' && !(typeof p.updates_until === 'string' && p.updates_until >= releaseDate)) return { ok: false, reason: 'personal-updates-ended', payload: p }
83
+ if (p.plan === 'team' && !(typeof p.expires === 'string' && p.expires >= today)) return { ok: false, reason: 'team-expired', payload: p }
84
+ return { ok: true, payload: p }
85
+ }
86
+
87
+ export function licensePath() { return join(home(), 'license.json') }
88
+
89
+ function readJson(f) { try { return JSON.parse(readFileSync(f, 'utf8')) } catch { return null } }
90
+ function writeJson(f, obj) {
91
+ mkdirSync(join(f, '..'), { recursive: true, mode: 0o700 })
92
+ if (process.platform !== 'win32' && existsSync(f)) chmodSync(f, 0o600)
93
+ writeFileSync(f, JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 })
94
+ }
95
+
96
+ export function readLicense() {
97
+ const l = readJson(licensePath())
98
+ return l && typeof l.key === 'string' ? l : null
99
+ }
100
+
101
+ export function activate(key, opts = {}) {
102
+ const v = verifyLicense(key, opts)
103
+ if (!v.ok) { const e = new Error(explain(v.reason, v.payload)); e.reason = v.reason; throw e }
104
+ const parsed = parseLicense(key)
105
+ const legKey = `LEG-${b64u(parsed.body)}.${b64u(parsed.sig)}`
106
+ writeJson(licensePath(), { key: legKey, activated_at: new Date().toISOString(), id: v.payload.id, plan: v.payload.plan })
107
+ return v.payload
108
+ }
109
+
110
+ export function deactivate() {
111
+ const had = existsSync(licensePath())
112
+ if (had) unlinkSync(licensePath())
113
+ return had
114
+ }
115
+
116
+ // What this machine may do right now: a valid key, or nothing, with the reason
117
+ // the stored key (if any) was refused.
118
+ export function entitlement({ today = isoToday(), releaseDate = RELEASE_DATE } = {}) {
119
+ const lic = readLicense()
120
+ if (!lic) return { ok: false, plan: 'none', reason: 'no-license', refused: null }
121
+ const v = verifyLicense(lic.key, { today, releaseDate })
122
+ if (v.ok) return { ok: true, plan: v.payload.plan, seats: v.payload.seats ?? 1, payload: v.payload, source: 'license' }
123
+ return { ok: false, plan: 'none', reason: v.reason, refused: v.reason }
124
+ }
125
+
126
+ export function allows(ent, gate) {
127
+ if (!ent.ok) return false
128
+ return (PLANS[ent.plan]?.gates ?? []).includes(gate)
129
+ }
130
+
131
+ export function explain(reason, payload) {
132
+ switch (reason) {
133
+ case 'malformed': return 'that is not a Leg license key (expected LEG-<payload>.<signature>)'
134
+ case 'bad-signature': return 'the key\'s signature does not check out; copy it again from your receipt'
135
+ case 'unknown-plan': return `the key names a plan this version does not know (${payload?.plan})`
136
+ case 'personal-updates-ended': return `this Personal key covers releases up to ${payload?.updates_until}; this release is dated ${RELEASE_DATE}. Keep the version you have, or renew at ${BUY_URL}`
137
+ case 'team-expired': return `this Team key expired on ${payload?.expires}; run "leg license refresh" (the subscription renews it) or see ${BUY_URL}`
138
+ case 'no-license': return `Leg needs a license key. Buy one at ${BUY_URL} (${GUARANTEE_DAYS}-day money-back guarantee), then: leg license activate <key>`
139
+ default: return String(reason)
140
+ }
141
+ }
142
+
143
+ export function describe(ent) {
144
+ if (!ent.ok) return `no license: ${explain(ent.reason)}`
145
+ const p = ent.payload
146
+ const until = p.plan === 'personal' ? `updates through ${p.updates_until}` : `renews; valid through ${p.expires}`
147
+ return `${PLANS[p.plan].label} license ${p.id}${p.seats > 1 ? `, ${p.seats} seats` : ''}, ${until}`
148
+ }
149
+
150
+ // Team renewal: ask the site for a fresh key for this subscription. Offline
151
+ // or refused, the current key stays in place until it expires.
152
+ export async function refresh({ site = SITE, fetchImpl = globalThis.fetch } = {}) {
153
+ const lic = readLicense()
154
+ if (!lic) throw new Error('no license to refresh; leg license activate <key>')
155
+ const { payload } = parseLicense(lic.key)
156
+ if (payload.plan !== 'team') throw new Error('only Team keys renew; a Personal key does not expire')
157
+ const r = await fetchImpl(`${site}/api/key`, {
158
+ method: 'POST',
159
+ headers: { 'Content-Type': 'application/json' },
160
+ body: JSON.stringify({ key: lic.key }),
161
+ })
162
+ if (!r.ok) throw new Error(`the site answered ${r.status}`)
163
+ const j = await r.json()
164
+ if (!j.key) throw new Error(j.error || 'no key in the answer')
165
+ return activate(j.key)
166
+ }
167
+
168
+ export function emailHash(email) { return createHash('sha256').update(String(email).trim().toLowerCase()).digest('hex').slice(0, 16) }
package/src/limits.mjs ADDED
@@ -0,0 +1,97 @@
1
+ // limits — classify how a leg ended. Built from fixtures/limits/**/*.json,
2
+ // each tagged observed-live or docs-only with the command or URL that produced
3
+ // it; docs/cli-contracts.md's Limit signals table is generated from the same
4
+ // files (scripts/limits-table.mjs). Only explicit signals become `limit`;
5
+ // everything else that stops early is still handed off, under a truer name.
6
+ import { readdirSync, readFileSync } from 'node:fs'
7
+ import { join, dirname, resolve } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ const FIXTURES = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'fixtures', 'limits')
11
+
12
+ export const OUTCOMES = ['completed', 'incomplete', 'no_progress', 'limit', 'stalled',
13
+ 'auth_failed', 'launch_failed', 'killed', 'failed']
14
+
15
+ function loadSignals() {
16
+ const out = []
17
+ for (const dir of readdirSync(FIXTURES).sort()) {
18
+ for (const file of readdirSync(join(FIXTURES, dir)).sort()) {
19
+ if (!file.endsWith('.json')) continue
20
+ const s = JSON.parse(readFileSync(join(FIXTURES, dir, file), 'utf8'))
21
+ for (const k of ['id', 'adapter', 'source', 'produced_by', 'where', 'text', 'classification']) {
22
+ if (s[k] === undefined) throw new Error(`fixture ${dir}/${file} missing ${k}`)
23
+ }
24
+ s.re = new RegExp(s.pattern ?? s.text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
25
+ s.group = dir
26
+ out.push(s)
27
+ }
28
+ }
29
+ return out
30
+ }
31
+
32
+ export const SIGNALS = loadSignals()
33
+
34
+ const AUTH_SOURCE_RE = /another auth source is set/i
35
+
36
+ function haystack(where, input) {
37
+ const stdout = input.stdout ?? ''
38
+ const stderr = input.stderr ?? ''
39
+ const result = input.result ? (typeof input.result === 'string' ? input.result : JSON.stringify(input.result)) : ''
40
+ if (where === 'stdout') return stdout
41
+ if (where === 'stderr') return stderr
42
+ if (where === 'result.json') return result
43
+ if (where === 'exit_code') return ''
44
+ return `${stdout}\n${stderr}\n${result}`
45
+ }
46
+
47
+ function matches(s, input) {
48
+ if (s.where === 'exit_code') return s.exit_code !== null && input.exitCode === s.exit_code
49
+ if (s.re.source === '^$') return false // the empty-stdout negative fixture is handled by the no_progress rule
50
+ return s.re.test(haystack(s.where, input))
51
+ }
52
+
53
+ // Adapter-specific signals first, then generic ("*"), so the signal name in
54
+ // the ledger is the most specific one that fired. The bare `fake` adapter
55
+ // replays any recorded fixture, so for it every adapter's signals are eligible
56
+ // (still before the generic ones); `fake-claude` / `fake-codex` emulate one CLI
57
+ // and are classified as that CLI (the runner passes `adapter.emulates`).
58
+ function firstMatch(input, classification) {
59
+ const own = SIGNALS.filter((s) => s.adapter === input.adapter && s.classification === classification)
60
+ const others = input.adapter === 'fake'
61
+ ? SIGNALS.filter((s) => s.adapter !== '*' && s.adapter !== 'fake' && s.classification === classification)
62
+ : []
63
+ const generic = SIGNALS.filter((s) => s.adapter === '*' && s.classification === classification)
64
+ return [...own, ...others, ...generic].find((s) => matches(s, input)) ?? null
65
+ }
66
+
67
+ // input: { adapter, exitCode, stdout, stderr, result, doneMarker, diff:{changed,files},
68
+ // killedByTimer, killedByHuman, spawnError }
69
+ // → { outcome, signal, handoff, reason }
70
+ export function classify(input) {
71
+ const i = { stdout: '', stderr: '', result: null, doneMarker: false, diff: null, ...input }
72
+ const out = (outcome, handoff, reason, signal = 'none') => ({ outcome, signal, handoff, reason })
73
+
74
+ if (i.spawnError) return out('launch_failed', true, `spawn error: ${i.spawnError}`)
75
+ if (AUTH_SOURCE_RE.test(i.stderr)) {
76
+ return out('auth_failed', false, 'stderr says another auth source is set (subscription login shadowed)', 'auth-source-set')
77
+ }
78
+ const auth = firstMatch(i, 'auth')
79
+ if (auth) return out('auth_failed', false, `auth signal: ${auth.id}`, auth.id)
80
+ if (i.killedByHuman) return out('killed', false, 'killed from the board')
81
+ if (i.killedByTimer) return out('stalled', true, 'kill timer fired before the leg finished')
82
+ if (i.exitCode === 0 && i.doneMarker) return out('completed', false, 'exit 0 and .leg/DONE present')
83
+ const limit = firstMatch(i, 'limit')
84
+ if (limit) return out('limit', true, `${limit.source} limit signal: ${limit.id}`, limit.id)
85
+ const launch = firstMatch(i, 'launch')
86
+ if (launch) return out('launch_failed', true, `launch signal: ${launch.id}`, launch.id)
87
+ const budget = firstMatch(i, 'budget')
88
+ const signal = budget ? budget.id : 'none'
89
+ const changed = Boolean(i.diff && (i.diff.changed || (i.diff.files ?? 0) > 0))
90
+ if (i.exitCode === 0 && !i.doneMarker && changed) {
91
+ return out('incomplete', true, budget ? `budget signal ${budget.id}; exit 0 with changes but no DONE marker` : 'exit 0 with changes but no DONE marker', signal)
92
+ }
93
+ if (i.exitCode === 0 && !i.doneMarker) {
94
+ return out('no_progress', true, 'exit 0, no DONE marker, no changes', signal)
95
+ }
96
+ return out('failed', true, budget ? `budget signal ${budget.id}; exit ${i.exitCode}` : `exit ${i.exitCode}`, signal)
97
+ }