@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
@@ -0,0 +1,69 @@
1
+ // live-capture — the first time a real limit signal arrives from an agent
2
+ // (claude StopFailure, codex usage_limit_exceeded, agy RESOURCE_EXHAUSTED),
3
+ // keep the payload with secrets scrubbed, so the docs rows that say
4
+ // "docs-only" can become "observed-live" with evidence. One file per
5
+ // (agent, signal); a later arrival never overwrites the first. A payload a
6
+ // `baton sessions simulate-limit` produced is marked and never captured.
7
+ // Where: BATON_LIVE_DIR, else this checkout's fixtures/live/ when it exists
8
+ // (a dev clone), else <BATON_HOME>/live/. In a dev clone the matching docs
9
+ // rows are flipped in the same call (scripts/live-limits.mjs).
10
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
11
+ import { join, dirname, resolve } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { homedir } from 'node:os'
14
+ import { spawnSync } from 'node:child_process'
15
+ import { redact } from './redact.mjs'
16
+ import { home } from './store.mjs'
17
+
18
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
19
+ const FIXTURES_LIVE = join(ROOT, 'fixtures', 'live')
20
+ const FLIP_SCRIPT = join(ROOT, 'scripts', 'live-limits.mjs')
21
+
22
+ // A real limit payload (claude StopFailure, codex, agy) carries cwd,
23
+ // transcript_path and scratchpad_dir. redact() strips secret shapes and the
24
+ // values of the well-known key variables this process holds; this also folds
25
+ // the home and repo roots to placeholders (in every slash form, including the
26
+ // doubled backslashes JSON escaping produces) so nothing path-shaped is written
27
+ // into a git-tracked fixtures/live/ file.
28
+ function redactPaths(s) {
29
+ let out = redact(String(s))
30
+ for (const [root, tag] of [[homedir(), '[redacted-home]'], [ROOT, '[redacted-repo]']]) {
31
+ if (!root) continue
32
+ for (const variant of [root.replace(/\\/g, '\\\\'), root.replace(/\\/g, '/'), root]) out = out.split(variant).join(tag)
33
+ }
34
+ return out
35
+ }
36
+
37
+ export function liveDir() {
38
+ if (process.env.LEG_LIVE_DIR || process.env.BATON_LIVE_DIR) return process.env.LEG_LIVE_DIR || process.env.BATON_LIVE_DIR
39
+ if (existsSync(FIXTURES_LIVE) && existsSync(join(ROOT, '.git'))) return FIXTURES_LIVE
40
+ return join(home(), 'live')
41
+ }
42
+
43
+ export function isSimulated(payload) {
44
+ if (payload && typeof payload === 'object' && (payload.leg_simulated || payload.baton_simulated)) return true
45
+ return /simulated by (?:leg|baton)/i.test(typeof payload === 'string' ? payload : JSON.stringify(payload ?? ''))
46
+ }
47
+
48
+ export function livePath(agent, signal, dir = liveDir()) {
49
+ return join(dir, agent, `limit-${String(signal).replace(/[^a-z0-9_-]+/gi, '_')}.json`)
50
+ }
51
+
52
+ // → { path, written: bool } | null (simulated, or nothing to keep)
53
+ export function captureLive(agent, signal, payload, { sessionId = null, flipDocs = true } = {}) {
54
+ if (!agent || !signal || payload === undefined || isSimulated(payload)) return null
55
+ const dir = liveDir()
56
+ const file = livePath(agent, signal, dir)
57
+ if (existsSync(file)) return { path: file, written: false }
58
+ mkdirSync(dirname(file), { recursive: true })
59
+ const record = {
60
+ agent, signal, captured_at: new Date().toISOString(), session_id: sessionId,
61
+ source: 'observed-live',
62
+ payload: JSON.parse(redactPaths(JSON.stringify(payload))),
63
+ }
64
+ writeFileSync(file, JSON.stringify(record, null, 2) + '\n')
65
+ if (flipDocs && dir === FIXTURES_LIVE) {
66
+ try { spawnSync(process.execPath, [FLIP_SCRIPT], { windowsHide: true, encoding: 'utf8', timeout: 8000 }) } catch {}
67
+ }
68
+ return { path: file, written: true }
69
+ }
@@ -0,0 +1,225 @@
1
+ // mergequeue — the land station. One land at a time per repo root, FIFO: the
2
+ // queue key is the canonical root (two spellings of one path are one queue) and
3
+ // the turn itself is a file lock under BATON_HOME, so a `baton card run` CLI
4
+ // and the board server cannot land into one checkout at the same time.
5
+ // land(card, worktree):
6
+ // 1. root must be on <trunk> and clean, else bounce `dirty-trunk` (root untouched)
7
+ // 1b. commit whatever the agents left uncommitted in the worktree
8
+ // 2. rebase the card branch onto trunk in the worktree; abort + bounce `rebase-conflict` with the file list, or `rebase-failed` when git failed for another reason
9
+ // 3. run the test command (card.test_command → package.json test → pytest → none + land_warning); red → bounce `tests-red` with the tail
10
+ // 4. from the root: git merge --ff-only baton/<id>; trunk moved meanwhile → one retry, then bounce `trunk-moved`
11
+ // 5. success → { landed, sha, files, insertions, deletions, duration_ms }
12
+ // No force flags, no remote writes, no hard resets on the root, ever.
13
+ import { spawnSync } from 'node:child_process'
14
+ import { existsSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs'
15
+ import { join, resolve } from 'node:path'
16
+ import { createHash } from 'node:crypto'
17
+ import { branchName } from './worktree.mjs'
18
+ import { runCommandAsync } from './commands.mjs'
19
+ import { canonPath, withFileLock, writeJsonAtomic } from './fsx.mjs'
20
+ import { home } from './store.mjs'
21
+
22
+ const TEST_TIMEOUT_MS = Number((process.env.LEG_LAND_TEST_TIMEOUT_MS || process.env.BATON_LAND_TEST_TIMEOUT_MS) || 600000)
23
+ const queues = new Map() // canonical repo root → tail promise
24
+ // A turn covers the commit, the rebase and the repo's whole test run, so a
25
+ // claim is only stale once its holder died: the test budget plus the git work
26
+ // around it. The guard lock around the claim file protects two file writes and
27
+ // keeps a short staleness of its own.
28
+ const LOCK_STALE_MS = TEST_TIMEOUT_MS + 120000
29
+ const LOCK_GUARD_STALE_MS = 5000
30
+ const LOCK_POLL_MS = 250
31
+
32
+ function git(cwd, args, { ok = true } = {}) {
33
+ const r = spawnSync('git', args, { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
34
+ if (ok && r.status !== 0) throw new Error(`git ${args.join(' ')} failed (exit ${r.status}): ${(r.stderr || r.stdout).trim().slice(0, 500)}`)
35
+ return r
36
+ }
37
+
38
+ // A rebase/merge/cherry-pick the agent or a human left mid-flight. Landing must
39
+ // never commit that half-state and must never abort an operation it did not
40
+ // start (aborting a rebase throws away the uncommitted work sitting on it).
41
+ function operationInProgress(worktree) {
42
+ for (const p of ['rebase-merge', 'rebase-apply', 'MERGE_HEAD', 'CHERRY_PICK_HEAD']) {
43
+ const rel = git(worktree, ['rev-parse', '--git-path', p], { ok: false }).stdout.trim()
44
+ if (rel && existsSync(resolve(worktree, rel))) return p
45
+ }
46
+ return null
47
+ }
48
+
49
+ export function trunkHead(repo) {
50
+ return git(repo, ['rev-parse', 'HEAD']).stdout.trim()
51
+ }
52
+
53
+ export function rootState(repo, trunk) {
54
+ const branch = git(repo, ['symbolic-ref', '--short', '-q', 'HEAD'], { ok: false }).stdout.trim() || 'DETACHED'
55
+ const dirty = git(repo, ['status', '--porcelain']).stdout.split(/\r?\n/).filter(Boolean)
56
+ return { branch, onTrunk: branch === trunk, dirty }
57
+ }
58
+
59
+ // Commit the agents' work so the branch can be rebased and merged. The commit
60
+ // skips git hooks (--no-verify): the repo's test command is Baton's gate, and
61
+ // interactive commit hooks (linters, wire-dark style checks) belong to humans
62
+ // typing commits. BATON_COMMIT_VERIFY=1 runs them anyway.
63
+ export function commitWorktree(worktree, message) {
64
+ const dirty = git(worktree, ['status', '--porcelain']).stdout.split(/\r?\n/).filter(Boolean)
65
+ if (!dirty.length) return { committed: false }
66
+ git(worktree, ['add', '-A'])
67
+ const verify = (process.env.LEG_COMMIT_VERIFY || process.env.BATON_COMMIT_VERIFY) === '1' ? [] : ['--no-verify']
68
+ git(worktree, ['-c', 'user.email=baton@localhost', '-c', 'user.name=baton', 'commit', '-q', ...verify, '-m', message])
69
+ return { committed: true, files: dirty.length }
70
+ }
71
+
72
+ export function resolveTestCommand(card, worktree) {
73
+ if (card.test_command) return { command: card.test_command, source: 'card' }
74
+ const pkg = join(worktree, 'package.json')
75
+ if (existsSync(pkg)) {
76
+ try {
77
+ const p = JSON.parse(readFileSync(pkg, 'utf8'))
78
+ if (p.scripts?.test) return { command: 'npm test', source: 'package.json' }
79
+ } catch {}
80
+ }
81
+ if (existsSync(join(worktree, 'pyproject.toml'))) return { command: 'pytest', source: 'pyproject.toml' }
82
+ return { command: null, source: 'none' }
83
+ }
84
+
85
+ function runTests(command, worktree) {
86
+ return runCommandAsync(command, worktree, { timeoutMs: TEST_TIMEOUT_MS, tailLines: 40 })
87
+ }
88
+
89
+ function bounce(reason, detail, extra = {}) {
90
+ return { landed: false, bounced: true, reason, detail, ...extra }
91
+ }
92
+
93
+ // A non-zero `git rebase` is a conflict only when git names unmerged files.
94
+ // Anything else (a hook that refuses, an upstream git cannot use) is a
95
+ // different failure, and the bounce reason is the next agent's brief: it must
96
+ // never read as a conflict in files git never mentioned.
97
+ function rebaseBounce(worktree, trunk, result, preSha, when) {
98
+ const files = git(worktree, ['diff', '--name-only', '--diff-filter=U'], { ok: false }).stdout.split(/\r?\n/).filter(Boolean)
99
+ git(worktree, ['rebase', '--abort'], { ok: false })
100
+ const detail = (result.stderr || result.stdout).trim().slice(0, 300)
101
+ if (files.length) return bounce('rebase-conflict', `rebase onto ${trunk}${when} conflicted in: ${files.join(', ')}`, { files, pre_sha: preSha })
102
+ return bounce('rebase-failed', `rebase onto ${trunk}${when} failed: ${detail || '(no git output)'}`, { files, pre_sha: preSha })
103
+ }
104
+
105
+ function claimFile(key) {
106
+ const dir = join(home(), 'locks')
107
+ mkdirSync(dir, { recursive: true })
108
+ return join(dir, `land-${createHash('sha1').update(key).digest('hex').slice(0, 16)}.json`)
109
+ }
110
+
111
+ function claimHolder(file) {
112
+ try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null }
113
+ }
114
+
115
+ // withFileLock guards the read-modify-write of the claim; the claim it leaves
116
+ // behind outlives that guard, which is what makes the turn last a whole land.
117
+ function takeTurn(file, token) {
118
+ return withFileLock(`${file}.lock`, () => {
119
+ const held = claimHolder(file)
120
+ if (held && held.token !== token && Date.now() - held.ts < LOCK_STALE_MS) return false
121
+ writeJsonAtomic(file, { token, pid: process.pid, ts: Date.now() })
122
+ return true
123
+ }, { staleMs: LOCK_GUARD_STALE_MS })
124
+ }
125
+
126
+ function dropTurn(file, token) {
127
+ withFileLock(`${file}.lock`, () => {
128
+ const held = claimHolder(file)
129
+ if (!held || held.token === token) { try { unlinkSync(file) } catch {} }
130
+ }, { staleMs: LOCK_GUARD_STALE_MS })
131
+ }
132
+
133
+ async function withRepoTurn(repo, fn) {
134
+ const file = claimFile(canonPath(repo))
135
+ const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
136
+ while (!takeTurn(file, token)) await new Promise((r) => setTimeout(r, LOCK_POLL_MS))
137
+ try { return await fn() } finally { dropTurn(file, token) }
138
+ }
139
+
140
+ async function landNow(card, worktree, { onWarning = () => {}, allowDirtyRoot = false } = {}) {
141
+ const t0 = Date.now()
142
+ const repo = card.repo
143
+ const trunk = card.trunk || 'main'
144
+ const branch = branchName(card.card_id)
145
+
146
+ const checkedOut = git(worktree, ['symbolic-ref', '--short', '-q', 'HEAD'], { ok: false }).stdout.trim() || 'DETACHED'
147
+ if (checkedOut !== branch) return bounce('worktree-branch', `worktree is on ${checkedOut}, not ${branch}; switch it back before landing`)
148
+
149
+ const busy = operationInProgress(worktree)
150
+ if (busy) return bounce('worktree-busy', `a ${busy.replace(/-/g, ' ')} is already in progress in ${worktree}; finish or abort it there before landing (Baton will not touch a rebase it did not start)`)
151
+
152
+ const root = rootState(repo, trunk)
153
+ if (!root.onTrunk) return bounce('dirty-trunk', `repo root is on ${root.branch}, not ${trunk}; check out ${trunk} and retry`)
154
+ // allowDirtyRoot (a terminal's Land): the root is a live checkout, dirty by
155
+ // nature; git's own fast-forward still refuses to overwrite a local change
156
+ if (root.dirty.length && !allowDirtyRoot) return bounce('dirty-trunk', `repo root has ${root.dirty.length} uncommitted change(s): ${root.dirty.slice(0, 10).join(', ')}`)
157
+
158
+ const committed = commitWorktree(worktree, `baton: ${card.title ?? card.card_id}`)
159
+ const preSha = git(worktree, ['rev-parse', 'HEAD']).stdout.trim()
160
+ const trunkBefore = trunkHead(repo)
161
+
162
+ const rebase = git(worktree, ['rebase', trunk], { ok: false })
163
+ if (rebase.status !== 0) return rebaseBounce(worktree, trunk, rebase, preSha, '')
164
+
165
+ const tc = resolveTestCommand(card, worktree)
166
+ let tests = null
167
+ if (!tc.command) {
168
+ onWarning(`no test command (card, package.json, pyproject.toml); landing untested`)
169
+ } else {
170
+ tests = await runTests(tc.command, worktree)
171
+ if (!tests.green) {
172
+ return bounce('tests-red', `${tests.command} exit ${tests.status}${tests.timedOut ? ' (timed out)' : ''}:\n${tests.tail}`, { test_tail: tests.tail, pre_sha: preSha })
173
+ }
174
+ }
175
+
176
+ let merge = git(repo, ['merge', '--ff-only', branch], { ok: false })
177
+ let retried = false
178
+ if (merge.status !== 0 && trunkHead(repo) !== trunkBefore) {
179
+ // trunk moved while we tested: rebase once more and retry the ff
180
+ retried = true
181
+ const again = git(worktree, ['rebase', trunk], { ok: false })
182
+ if (again.status !== 0) return rebaseBounce(worktree, trunk, again, preSha, ` after ${trunk} moved`)
183
+ if (tc.command) {
184
+ tests = await runTests(tc.command, worktree)
185
+ if (!tests.green) {
186
+ return bounce('tests-red', `${tests.command} exit ${tests.status}${tests.timedOut ? ' (timed out)' : ''} after ${trunk} moved:\n${tests.tail}`, { test_tail: tests.tail, pre_sha: preSha })
187
+ }
188
+ }
189
+ merge = git(repo, ['merge', '--ff-only', branch], { ok: false })
190
+ }
191
+ if (merge.status !== 0 && /would be overwritten by merge/.test(merge.stderr || merge.stdout)) {
192
+ const files = (merge.stderr || merge.stdout).split(/\r?\n/).filter((l) => /^\s+\S/.test(l)).map((l) => l.trim())
193
+ return bounce('dirty-trunk', `the ${trunk} checkout has local changes this landing would overwrite: ${files.join(', ') || '(see git output)'}`, { files })
194
+ }
195
+ if (merge.status !== 0) {
196
+ return bounce('trunk-moved', `fast-forward of ${trunk} failed${retried ? ' after one retry' : ''}: ${(merge.stderr || merge.stdout).trim().slice(0, 300)}`, { retried })
197
+ }
198
+ const sha = trunkHead(repo)
199
+ const stat = git(repo, ['diff', '--shortstat', `${trunkBefore}..${sha}`], { ok: false }).stdout.trim()
200
+ const files = git(repo, ['diff', '--name-only', `${trunkBefore}..${sha}`], { ok: false }).stdout.split(/\r?\n/).filter(Boolean)
201
+ const m = /(\d+) insertion/.exec(stat)
202
+ const d = /(\d+) deletion/.exec(stat)
203
+ return {
204
+ landed: true, sha, sha_before: trunkBefore, files, insertions: m ? Number(m[1]) : 0, deletions: d ? Number(d[1]) : 0,
205
+ duration_ms: Date.now() - t0, retried, committed: committed.committed, tests: tests ? { command: tests.command, tail: tests.tail.split('\n').slice(-3).join(' | ') } : null,
206
+ summary: `landed on ${trunk}: ${trunkBefore.slice(0, 7)} → ${sha.slice(0, 7)} (${files.length} file${files.length === 1 ? '' : 's'}, +${m ? m[1] : 0}/-${d ? d[1] : 0})${tests ? '' : ' [untested]'}`,
207
+ body: JSON.stringify({ sha, files, insertions: m ? Number(m[1]) : 0, deletions: d ? Number(d[1]) : 0, duration_ms: Date.now() - t0, retried, tests: tests?.command ?? null }),
208
+ }
209
+ }
210
+
211
+ // FIFO per repo root: the caller awaits its turn, here and in every other
212
+ // process landing into the same root.
213
+ export function land(card, worktree, opts) {
214
+ const key = canonPath(card.repo)
215
+ const prev = queues.get(key) ?? Promise.resolve()
216
+ const mine = prev.catch(() => {}).then(() => withRepoTurn(card.repo, () => landNow(card, worktree, opts)))
217
+ queues.set(key, mine)
218
+ // the bookkeeping chain must never reject on its own: landNow's throw is the
219
+ // caller's to handle (it returns `mine`), and an unhandled rejection here
220
+ // would take the whole board server process down with it
221
+ mine.catch(() => {}).finally(() => { if (queues.get(key) === mine) queues.delete(key) })
222
+ return mine
223
+ }
224
+
225
+ export function isLanding(repo) { return queues.has(canonPath(repo)) }
@@ -0,0 +1,365 @@
1
+ // orchestrator — moves one card through its pipeline: worktree, contract,
2
+ // runner legs, classification (from run.json), chain transitions, handoff
3
+ // bundles, test and (stub) land stations. Every state change goes through the
4
+ // ledger with an actor; the chain machine (src/chain.mjs) decides, this file
5
+ // only executes.
6
+ import { execFileSync, spawnSync } from 'node:child_process'
7
+ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, unlinkSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+ import { transition } from './chain.mjs'
10
+ import { stationIndex } from './pipeline.mjs'
11
+ import { ensure as ensureWorktree } from './worktree.mjs'
12
+ import { renderContract, writeContract, legPrompt } from './contract.mjs'
13
+ import { writeHandoff, loadResume } from './handoff.mjs'
14
+ import { runCommandAsync } from './commands.mjs'
15
+ import { landCard } from './land.mjs'
16
+ import { resolveTestCommand } from './mergequeue.mjs'
17
+ import {
18
+ RUNNER, BATON_ACTOR, readCard, ledgerAppend, ledgerUpdate, cardDir, sleep,
19
+ } from './store.mjs'
20
+ import { scrub, updateRun } from './runner.mjs'
21
+ import * as agentStation from './stations/agent.mjs'
22
+ import * as testStation from './stations/test.mjs'
23
+ import * as humanStation from './stations/human.mjs'
24
+
25
+ // Station kinds that run inside runCard; land stays inline below (it drives
26
+ // the merge queue). Each handler gets the orchestrator's helpers as `ops`.
27
+ const KIND_HANDLERS = { agent: agentStation, test: testStation, human: humanStation }
28
+
29
+ const POLL_MS = Number(process.env.BATON_POLL_MS || 2000)
30
+ const WAITING = ['done', 'failed', 'killed', 'paused', 'waiting_human', 'needs_approval']
31
+ // Patchable card keys the chain machine may change; everything else is a
32
+ // named ledger flag.
33
+ const PATCH_KEYS = ['pipeline', 'leases', 'land_attempts', 'bounce_reason', 'kill_requested', 'next_leg', 'handoff_outcome', 'resume_from_bundle', 'failure', 'pr_url']
34
+
35
+ const log = (msg) => { if (process.env.BATON_QUIET !== '1') process.stderr.write(`[baton] ${msg}\n`) }
36
+
37
+ // Persist a chain transition: card fields via ledger update, events via append.
38
+ export function apply(id, before, result, actor = BATON_ACTOR) {
39
+ const next = result.card
40
+ const patch = {}
41
+ for (const k of PATCH_KEYS) {
42
+ if (JSON.stringify(next[k]) !== JSON.stringify(before[k])) patch[k] = next[k] ?? null
43
+ }
44
+ ledgerUpdate(id, {
45
+ status: next.status !== before.status ? next.status : undefined,
46
+ station: next.station !== before.station ? next.station : undefined,
47
+ leg: next.leg !== before.leg ? next.leg : undefined,
48
+ patch,
49
+ })
50
+ for (const e of result.events) {
51
+ ledgerAppend(id, { actor, type: e.type, summary: e.summary, body: e.body, station: next.station, leg: next.leg })
52
+ }
53
+ return readCard(id)
54
+ }
55
+
56
+ export function step(id, action, payload, actor = BATON_ACTOR) {
57
+ const card = readCard(id)
58
+ if (!card) throw new Error(`card not found: ${id}`)
59
+ return apply(id, card, transition(card, action, payload), actor)
60
+ }
61
+
62
+ function runJsonPath(id, n) { return join(cardDir(id), 'runs', String(n), 'run.json') }
63
+
64
+ function latestRun(id) {
65
+ const dir = join(cardDir(id), 'runs')
66
+ if (!existsSync(dir)) return null
67
+ const ns = readdirSync(dir).map(Number).filter(Number.isInteger).sort((a, b) => b - a)
68
+ for (const n of ns) { try { return JSON.parse(readFileSync(runJsonPath(id, n), 'utf8')) } catch {} }
69
+ return null
70
+ }
71
+
72
+ // A run whose verdict no orchestrator has consumed: the latest run has no
73
+ // settled_at. Happens after `baton down` (agents killed, the supervisor wrote
74
+ // its verdict, the server that would apply it was already gone) or a crashed
75
+ // server. runCard re-attaches to it instead of launching a fresh leg.
76
+ export function unsettledRun(id) {
77
+ const r = latestRun(id)
78
+ return r && !r.settled_at ? r : null
79
+ }
80
+
81
+ // A run nobody alive is driving: unsettled and its driver (the orchestrator
82
+ // process that launched or re-attached to it) is gone. The scheduler picks
83
+ // these up; a live driver is left alone so two processes never apply one verdict.
84
+ export function orphanedRun(id) {
85
+ const r = unsettledRun(id)
86
+ return r && !(r.driver_pid && pidAlive(r.driver_pid)) ? r : null
87
+ }
88
+
89
+ // The claim on a card: one file per card, created atomically ('wx') at the top
90
+ // of runCard and removed when it returns. run.json's driver_pid only exists
91
+ // once a leg has launched; before that (worktree add, the start step, the
92
+ // contract) a card had no owner, and a `card run` beside a scheduler tick, or
93
+ // two schedulers, could both launch a leg into one worktree.
94
+ function driverLockPath(id) { return join(cardDir(id), 'driver.lock') }
95
+
96
+ function readDriver(id) {
97
+ try { return JSON.parse(readFileSync(driverLockPath(id), 'utf8')) } catch { return null }
98
+ }
99
+
100
+ // The pid of another live process driving this card, else null.
101
+ export function driverAlive(id) {
102
+ const d = readDriver(id)
103
+ return d?.pid && d.pid !== process.pid && pidAlive(d.pid) ? d.pid : null
104
+ }
105
+
106
+ function claimDriver(id) {
107
+ const f = driverLockPath(id)
108
+ for (let i = 0; i < 3; i++) {
109
+ try {
110
+ writeFileSync(f, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }) + '\n', { flag: 'wx' })
111
+ return { ok: true }
112
+ } catch (err) {
113
+ if (err.code !== 'EEXIST') throw err
114
+ }
115
+ const d = readDriver(id)
116
+ if (d?.pid === process.pid) return { ok: true }
117
+ if (d?.pid && pidAlive(d.pid)) return { ok: false, pid: d.pid }
118
+ // the holder died, or the file is not readable yet: a claim being written
119
+ // right now is younger than a second, a torn one from a crash is not
120
+ let stale = Boolean(d?.pid)
121
+ if (!stale) { try { stale = Date.now() - statSync(f).mtimeMs > 1000 } catch { stale = true } }
122
+ if (!stale) { const t = Date.now() + 50; while (Date.now() < t) { /* spin */ } continue }
123
+ try { unlinkSync(f) } catch {}
124
+ }
125
+ return { ok: false, pid: readDriver(id)?.pid ?? null }
126
+ }
127
+
128
+ function releaseDriver(id) {
129
+ if (readDriver(id)?.pid === process.pid) { try { unlinkSync(driverLockPath(id)) } catch {} }
130
+ }
131
+
132
+ function patchRun(id, n, fields) {
133
+ if (!n) return
134
+ try { updateRun(id, n, (cur) => (cur ? { ...cur, ...fields } : null)) } catch {}
135
+ }
136
+
137
+ function settleRun(id, run) { patchRun(id, run?.run, { settled_at: new Date().toISOString() }) }
138
+
139
+ function pidAlive(pid) {
140
+ if (!pid) return false
141
+ try { process.kill(pid, 0); return true } catch { return false }
142
+ }
143
+
144
+ const FINAL_RUN = ['exited', 'killed', 'failed', 'orphaned']
145
+
146
+ async function waitForRun(id, n, { pollMs = POLL_MS } = {}) {
147
+ let deadSince = null
148
+ let interrupted = null
149
+ for (;;) {
150
+ let run = null
151
+ try { run = JSON.parse(readFileSync(runJsonPath(id, n), 'utf8')) } catch {}
152
+ if (run && FINAL_RUN.includes(run.status)) {
153
+ if (run.status === 'orphaned' && !run.outcome) return { ...run, outcome: 'failed', handoff: true, signal: 'none', reason: 'supervisor died before the leg finished' }
154
+ return run
155
+ }
156
+ const supervisorAlive = Boolean(run?.supervisor_pid) && pidAlive(run.supervisor_pid)
157
+ // A human acted on the card (kill, pause, hand off now, reassign): the leg
158
+ // is over for us. Wait for the supervisor to finish writing (it is
159
+ // classifying the kill) so the next leg's launch never finds this run
160
+ // still active, then report the interruption.
161
+ const card = readCard(id)
162
+ if (card && card.status !== 'running') interrupted ??= { ...(run ?? {}), status: 'interrupted', outcome: 'killed', handoff: false }
163
+ if (interrupted && (!run || !supervisorAlive)) return interrupted
164
+ // A dead supervisor never writes `exited`; do not wait on it forever.
165
+ if (run && ['launching', 'running'].includes(run.status) && run.supervisor_pid && !supervisorAlive) {
166
+ deadSince ??= Date.now()
167
+ if (Date.now() - deadSince > 5000) {
168
+ patchRun(id, n, { status: 'orphaned' })
169
+ return { ...run, status: 'orphaned', outcome: 'failed', handoff: true, signal: 'none', reason: `supervisor pid ${run.supervisor_pid} died before the leg finished` }
170
+ }
171
+ } else deadSince = null
172
+ await sleep(pollMs)
173
+ }
174
+ }
175
+
176
+ function changedFiles(worktree) {
177
+ const r = spawnSync('git', ['status', '--porcelain'], { cwd: worktree, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
178
+ if (r.status !== 0) return []
179
+ return r.stdout.split(/\r?\n/).filter(Boolean).map((l) => l.slice(3).trim()).filter((f) => f && !f.startsWith('.baton'))
180
+ }
181
+
182
+ function diffStat(worktree) {
183
+ const r = spawnSync('git', ['diff', '--stat'], { cwd: worktree, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
184
+ return r.status === 0 ? r.stdout : ''
185
+ }
186
+
187
+ async function runLeg(card, station, worktree) {
188
+ const entry = station.chain[card.leg]
189
+ if (!entry) throw new Error(`no chain entry for leg ${card.leg} at ${station.name}`)
190
+ const resumed = card.leg > 0 || card.resume_from_bundle === true
191
+ const contractText = await renderContract({ card, station, leg: card.leg, entry, worktree, resumed, bounceReason: card.bounce_reason })
192
+ writeContract(worktree, contractText)
193
+ let resumeText = null
194
+ if (resumed) {
195
+ try { resumeText = loadResume(worktree) } catch (err) { log(`no bundle to resume from: ${err.message}`) }
196
+ }
197
+ const prompt = legPrompt({ contractText, resumeText })
198
+ const promptFile = join(cardDir(card.card_id), `prompt-${station.name}-leg${card.leg}.txt`)
199
+ writeFileSync(promptFile, prompt)
200
+ const args = ['launch', '--card', card.card_id, '--adapter', entry.adapter, '--prompt-file', promptFile, '--cwd', worktree, '--driver-pid', String(process.pid)]
201
+ if (entry.mode) args.push('--mode', entry.mode)
202
+ if (entry.maxTurns) args.push('--max-turns', String(entry.maxTurns))
203
+ if (entry.model) args.push('--model', String(entry.model))
204
+ if (entry.network) args.push('--network', '1')
205
+ const extraEnv = {}
206
+ if (entry.fakeMode) {
207
+ // "a;b;c" = one mode per landing attempt (demo: break-test on the first run, fix-test after the bounce)
208
+ const modes = String(entry.fakeMode).split(';').map((m) => m.trim()).filter(Boolean)
209
+ extraEnv.FAKE_MODE = modes[Math.min(card.land_attempts ?? 0, modes.length - 1)]
210
+ }
211
+ if (entry.fakeFixture) extraEnv.FAKE_LIMIT_FIXTURE = entry.fakeFixture
212
+ if (entry.fakeTarget) extraEnv.FAKE_TARGET = entry.fakeTarget
213
+ if (entry.fakeContent) extraEnv.FAKE_CONTENT = entry.fakeContent
214
+ extraEnv.FAKE_TRUNK = card.trunk || 'main'
215
+ if (Object.keys(extraEnv).length) args.push('--env-json', JSON.stringify(extraEnv))
216
+ let launch
217
+ try {
218
+ launch = JSON.parse(execFileSync(process.execPath, [RUNNER, ...args], { windowsHide: true, encoding: 'utf8', env: process.env }))
219
+ } catch (err) {
220
+ const text = String(err.stderr ?? err.stdout ?? err.message)
221
+ return { status: 'failed', outcome: 'launch_failed', handoff: true, signal: 'none', reason: scrub(text).slice(0, 300), run: null, exit_code: null }
222
+ }
223
+ if (card.resume_from_bundle) ledgerUpdate(card.card_id, { patch: { resume_from_bundle: false } })
224
+ const run = await waitForRun(card.card_id, launch.run)
225
+ return run
226
+ }
227
+
228
+ export { resolveCommand } from './commands.mjs'
229
+ // async: the test station runs inside the board server's scheduler, and a
230
+ // long suite must not freeze the board (src/commands.mjs)
231
+ const runTestCommand = (cmd, cwd) => runCommandAsync(cmd, cwd, { tailLines: 20 })
232
+
233
+ function handoffOn(card, station, run, worktree, extra = []) {
234
+ const entry = station.chain[card.leg]
235
+ const runDir = run?.run ? join(cardDir(card.card_id), 'runs', String(run.run)) : null
236
+ try {
237
+ const h = writeHandoff({ card, station, leg: card.leg, entry, run, worktree, runDir, extra, changedFiles: changedFiles(worktree), diffStat: diffStat(worktree) })
238
+ ledgerUpdate(card.card_id, { patch: { last_bundle: h.bundle_id } })
239
+ ledgerAppend(card.card_id, {
240
+ type: 'handoff_written', station: station.name, leg: card.leg,
241
+ summary: `handoff bundle ${h.bundle_id} (${h.quality ?? 'unscored'}) after ${run?.outcome ?? 'handoff'}; next: ${station.chain[card.leg + 1]?.adapter ?? 'none'}`,
242
+ body: `notes: ${h.notes_path}`,
243
+ })
244
+ return h
245
+ } catch (err) {
246
+ ledgerAppend(card.card_id, { type: 'error', station: station.name, leg: card.leg, summary: `handoff bundle failed: ${scrub(err.message).slice(0, 200)}` })
247
+ return null
248
+ }
249
+ }
250
+
251
+ // Run a card until it reaches a waiting or terminal state. Returns the card.
252
+ export async function runCard(id, { actor = BATON_ACTOR } = {}) {
253
+ let card = readCard(id)
254
+ if (!card) throw new Error(`card not found: ${id}`)
255
+ const claim = claimDriver(id)
256
+ if (!claim.ok) {
257
+ log(`card ${id} is driven by pid ${claim.pid}; not attaching`)
258
+ return card
259
+ }
260
+ try {
261
+ return await driveCard(id, card, actor)
262
+ } finally { releaseDriver(id) }
263
+ }
264
+
265
+ async function driveCard(id, card, actor) {
266
+ if (card.status === 'backlog') card = step(id, 'enqueue', {}, actor)
267
+ const wt = ensureWorktree(card.repo, card.card_id, { trunk: card.trunk || 'main' })
268
+ if (card.worktree !== wt.path) {
269
+ ledgerUpdate(id, { patch: { worktree: wt.path } })
270
+ card = readCard(id)
271
+ }
272
+ for (;;) {
273
+ card = readCard(id)
274
+ if (WAITING.includes(card.status)) return card
275
+ if (card.status === 'handing_off') {
276
+ // Recovering a crash mid-handoff: finish the handoff step.
277
+ const st = card.pipeline[stationIndex(card.pipeline, card.station)]
278
+ card = step(id, 'bundle_written', {}, actor)
279
+ void st
280
+ continue
281
+ }
282
+ if (card.status === 'queued') {
283
+ card = step(id, 'start', {}, actor)
284
+ if (card.status !== 'running') return card
285
+ }
286
+ if (card.status !== 'running') return card
287
+ const st = card.pipeline[stationIndex(card.pipeline, card.station)]
288
+ if (!st) throw new Error(`card ${id}: unknown station ${card.station}`)
289
+ const handler = KIND_HANDLERS[st.kind]
290
+ if (handler) {
291
+ const r = await handler.run({ id, card, station: st, worktree: wt.path, actor, ops: OPS })
292
+ card = r.card
293
+ if (r.done) return card
294
+ continue
295
+ }
296
+ if (st.kind === 'land') {
297
+ const r = await landCard(card, wt.path)
298
+ const fresh = readCard(id)
299
+ if (fresh.status !== 'running') {
300
+ // a human acted while the land ran; trunk may have moved anyway, and
301
+ // the ledger must say so even though the card no longer advances
302
+ if (r.landed) ledgerAppend(id, { type: 'landed', station: st.name, leg: 0, summary: `landed on trunk after the card was ${fresh.status}` })
303
+ return fresh
304
+ }
305
+ if (r.bounced) {
306
+ // the failure travels with the card: Open findings of the bounce bundle
307
+ handoffOn(card, { name: st.name, chain: [] }, { outcome: `land-${r.reason}`, reason: r.detail ?? r.reason, exit_code: null, adapter: 'land' }, wt.path,
308
+ [`land failure: ${r.reason}`, r.detail ?? '', ...(r.files?.length ? [`conflicting files: ${r.files.join(', ')}`] : [])].filter(Boolean))
309
+ }
310
+ if (!r.landed && !r.bounced && !r.pr) {
311
+ ledgerAppend(id, { type: 'error', station: st.name, leg: 0, summary: `land failed: ${scrub(r.reason ?? 'unknown').slice(0, 200)}` })
312
+ card = step(id, 'land_result', { bounced: true, reason: r.reason }, actor)
313
+ continue
314
+ }
315
+ card = step(id, 'land_result', { ...r, reason: r.detail ? `${r.reason}: ${r.detail}` : r.reason }, actor)
316
+ continue
317
+ }
318
+ throw new Error(`unknown station kind ${st.kind}`)
319
+ }
320
+ }
321
+
322
+ // Human actions from the CLI or the board. Returns the updated card.
323
+ export function humanAction(id, action, payload = {}, actor = { type: 'human', id: 'local' }) {
324
+ const card = readCard(id)
325
+ if (!card) throw new Error(`card not found: ${id}`)
326
+ const result = transition(card, action, payload)
327
+ const next = apply(id, card, result, actor)
328
+ if (['kill', 'pause', 'reassign', 'handoff_now'].includes(action) && card.status === 'running') killActiveRun(id)
329
+ return next
330
+ }
331
+
332
+ // Kill the agent process of the active run. The run record gets
333
+ // kill_requested so the supervisor classifies the exit as `killed` (a human
334
+ // decision), not `stalled` or `failed`. A run still `launching` has no
335
+ // agent_pid yet: its supervisor is killed with its whole tree instead, so an
336
+ // agent it spawns a moment later dies with it rather than running a full leg
337
+ // under a card the board already shows as killed.
338
+ export function killActiveRun(id) {
339
+ const runsDir = join(cardDir(id), 'runs')
340
+ if (!existsSync(runsDir)) return false
341
+ let killed = false
342
+ const runs = readdirSync(runsDir).map(Number).filter((n) => Number.isInteger(n) && n > 0)
343
+ for (const n of runs) {
344
+ let run
345
+ try { run = JSON.parse(readFileSync(join(runsDir, String(n), 'run.json'), 'utf8')) } catch { continue }
346
+ if (!['launching', 'running'].includes(run.status)) continue
347
+ patchRun(id, n, { kill_requested: true })
348
+ if (run.agent_pid) {
349
+ if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(run.agent_pid), '/T', '/F'], { windowsHide: true, encoding: 'utf8' })
350
+ else { try { process.kill(run.agent_pid, 'SIGKILL') } catch {} }
351
+ killed = true
352
+ } else if (run.supervisor_pid && pidAlive(run.supervisor_pid)) {
353
+ if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(run.supervisor_pid), '/T', '/F'], { windowsHide: true, encoding: 'utf8' })
354
+ else { try { process.kill(-run.supervisor_pid, 'SIGKILL') } catch { try { process.kill(run.supervisor_pid, 'SIGKILL') } catch {} } }
355
+ killed = true
356
+ }
357
+ }
358
+ return killed
359
+ }
360
+
361
+ // Helpers handed to the station-kind modules (see src/stations/*.mjs).
362
+ const OPS = {
363
+ readCard, ledgerAppend, step, apply, transition, handoffOn, runLeg, waitForRun,
364
+ unsettledRun, settleRun, patchRun, pidAlive, log, resolveTestCommand, runTestCommand,
365
+ }