@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.
- package/CHANGELOG.md +560 -0
- package/LICENSE +58 -0
- package/NOTICE +4 -0
- package/README.md +558 -0
- package/bin/fake-agent.mjs +143 -0
- package/bin/leg.mjs +449 -0
- package/docs/DECISIONS.md +119 -0
- package/docs/DEMO.md +214 -0
- package/docs/DEVIATIONS.md +182 -0
- package/docs/ERRORS.md +326 -0
- package/docs/README.md +40 -0
- package/docs/REUSE.md +289 -0
- package/docs/ROADMAP-v2.md +55 -0
- package/docs/VOCABULARY.md +243 -0
- package/docs/adapters.md +300 -0
- package/docs/board-guide.md +579 -0
- package/docs/cli-contracts.md +401 -0
- package/docs/concepts.md +426 -0
- package/docs/configuration.md +220 -0
- package/docs/faq.md +206 -0
- package/docs/getting-started.md +251 -0
- package/docs/real-run.md +80 -0
- package/docs/review-2026-09-14.md +72 -0
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-done.png +0 -0
- package/docs/screenshots/board-drawer.png +0 -0
- package/docs/screenshots/board-empty.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/board-running.png +0 -0
- package/docs/screenshots/demo-1-claude-running.png +0 -0
- package/docs/screenshots/demo-2-limit-hit.png +0 -0
- package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
- package/docs/screenshots/demo-4-codex-running.png +0 -0
- package/docs/screenshots/demo-5-done.png +0 -0
- package/docs/screenshots/floor-final-1280.png +0 -0
- package/docs/screenshots/floor-final-400.png +0 -0
- package/docs/screenshots/floor-landing.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/share-guest-1280.png +0 -0
- package/docs/screenshots/share-owner-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/docs/screenshots/thumb-done.jpg +0 -0
- package/docs/screenshots/thumb-handoff-details.jpg +0 -0
- package/docs/screenshots/thumb-limit-hit.jpg +0 -0
- package/docs/screenshots/youtube-thumbnail.jpg +0 -0
- package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
- package/fixtures/limits/claude/claude-budget-limit.json +11 -0
- package/fixtures/limits/claude/claude-max-turns.json +11 -0
- package/fixtures/limits/claude/claude-model-limit.json +11 -0
- package/fixtures/limits/claude/claude-session-limit.json +11 -0
- package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
- package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-skills-notice.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit.json +11 -0
- package/fixtures/limits/generic/generic-429.json +11 -0
- package/fixtures/limits/generic/generic-overloaded.json +11 -0
- package/fixtures/limits/generic/generic-quota.json +11 -0
- package/fixtures/limits/generic/generic-rate-limit.json +11 -0
- package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
- package/fixtures/limits/generic/generic-usage-limit.json +11 -0
- package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
- package/fixtures/limits/negative/auth-source-set.json +11 -0
- package/fixtures/limits/negative/compile-error.json +11 -0
- package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
- package/fixtures/live/agy/cmd.txt +1 -0
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +1 -0
- package/fixtures/live/agy/parsed.json +5 -0
- package/fixtures/live/agy/run.json +18 -0
- package/fixtures/live/agy/supervisor.log +2 -0
- package/fixtures/live/claude/cmd.txt +1 -0
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/limit-rate_limit.json +21 -0
- package/fixtures/live/claude/out.log +1 -0
- package/fixtures/live/claude/parsed.json +11 -0
- package/fixtures/live/claude/run.json +18 -0
- package/fixtures/live/claude/supervisor.log +2 -0
- package/fixtures/live/codex/cmd.txt +1 -0
- package/fixtures/live/codex/err.log +1 -0
- package/fixtures/live/codex/last.md +2 -0
- package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
- package/fixtures/live/codex/out.log +8 -0
- package/fixtures/live/codex/parsed.json +15 -0
- package/fixtures/live/codex/run.json +18 -0
- package/fixtures/live/codex/supervisor.log +2 -0
- package/fixtures/live/grok/cmd.txt +1 -0
- package/fixtures/live/grok/err.log +32 -0
- package/fixtures/live/grok/out.log +7 -0
- package/fixtures/live/grok/parsed.json +5 -0
- package/fixtures/live/grok/run.json +18 -0
- package/fixtures/live/grok/supervisor.log +2 -0
- package/fixtures/verified.json +34 -0
- package/package.json +68 -0
- package/scripts/board-shots.mjs +69 -0
- package/scripts/build-docs-site.mjs +308 -0
- package/scripts/check-claims.mjs +150 -0
- package/scripts/clean-clone-check.sh +42 -0
- package/scripts/license-sign.mjs +23 -0
- package/scripts/limits-table.mjs +38 -0
- package/scripts/live-limits.mjs +36 -0
- package/scripts/privacy-check.mjs +63 -0
- package/scripts/probe.mjs +92 -0
- package/scripts/seed-fake-cards.mjs +34 -0
- package/scripts/seed-floor-board.mjs +69 -0
- package/scripts/seed-wes-board.mjs +91 -0
- package/scripts/stripe-setup.mjs +99 -0
- package/scripts/vercel-env.mjs +40 -0
- package/src/accounts.mjs +119 -0
- package/src/adapters/agy.mjs +56 -0
- package/src/adapters/claude.mjs +54 -0
- package/src/adapters/codex.mjs +73 -0
- package/src/adapters/common.mjs +32 -0
- package/src/adapters/fake.mjs +48 -0
- package/src/adapters/grok.mjs +51 -0
- package/src/adapters/index.mjs +27 -0
- package/src/adapters/resolve.mjs +40 -0
- package/src/attach.mjs +592 -0
- package/src/auth.mjs +85 -0
- package/src/board/board.css +543 -0
- package/src/board/board.js +1307 -0
- package/src/board/favicon.svg +1 -0
- package/src/board/floor.html +127 -0
- package/src/board/floor.js +670 -0
- package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
- package/src/board/fonts/OFL-azeret-mono.txt +94 -0
- package/src/board/fonts/atkinson-board.woff2 +0 -0
- package/src/board/fonts/azeret-board.woff2 +0 -0
- package/src/board/index.html +251 -0
- package/src/board/sessions.js +1451 -0
- package/src/bundle.mjs +103 -0
- package/src/cards.mjs +114 -0
- package/src/chain.mjs +264 -0
- package/src/commands.mjs +79 -0
- package/src/contract.mjs +73 -0
- package/src/env.mjs +19 -0
- package/src/fsx.mjs +72 -0
- package/src/git-snapshot.mjs +152 -0
- package/src/handoff.mjs +171 -0
- package/src/hook.mjs +49 -0
- package/src/land.mjs +106 -0
- package/src/launcher.mjs +272 -0
- package/src/leases.mjs +86 -0
- package/src/ledger.mjs +369 -0
- package/src/license.mjs +168 -0
- package/src/limits.mjs +97 -0
- package/src/live-capture.mjs +69 -0
- package/src/mergequeue.mjs +225 -0
- package/src/orchestrator.mjs +365 -0
- package/src/pipeline.mjs +100 -0
- package/src/preferences.mjs +47 -0
- package/src/presets.mjs +23 -0
- package/src/ratelimit.mjs +49 -0
- package/src/redact.mjs +47 -0
- package/src/resume.mjs +354 -0
- package/src/runner.mjs +438 -0
- package/src/scheduler.mjs +118 -0
- package/src/server.mjs +845 -0
- package/src/session-detail.mjs +129 -0
- package/src/sessions.mjs +202 -0
- package/src/share.mjs +163 -0
- package/src/stations/agent.mjs +42 -0
- package/src/stations/build.mjs +9 -0
- package/src/stations/human.mjs +5 -0
- package/src/stations/land.mjs +6 -0
- package/src/stations/plan.mjs +8 -0
- package/src/stations/pr.mjs +31 -0
- package/src/stations/review.mjs +8 -0
- package/src/stations/test.mjs +27 -0
- package/src/store.mjs +90 -0
- package/src/sync/dashclaw.mjs +70 -0
- package/src/sync/index.mjs +54 -0
- package/src/sync/workboard.mjs +54 -0
- package/src/taps/agy.mjs +64 -0
- package/src/taps/claude-usage.mjs +67 -0
- package/src/taps/claude.mjs +170 -0
- package/src/taps/codex.mjs +286 -0
- package/src/trust.mjs +323 -0
- package/src/usage.mjs +179 -0
- package/src/wait.mjs +30 -0
- 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()
|
package/src/license.mjs
ADDED
|
@@ -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
|
+
}
|