@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/bundle.mjs ADDED
@@ -0,0 +1,103 @@
1
+ // bundle — keeps a context-handoff-bundle current for an interactive session
2
+ // and turns it into the prompt the next agent starts from. Reuses the v0.1
3
+ // seam (src/handoff.mjs: chb(), resolveChb) so the CLI is still the only
4
+ // writer of bundle files. One bundle per session (`save --update <slug>`).
5
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
6
+ import { spawnSync } from 'node:child_process'
7
+ import { join } from 'node:path'
8
+ import { chb, ensureExcluded } from './handoff.mjs'
9
+ import { scrub } from './redact.mjs'
10
+ import { updateSession, workRoot } from './sessions.mjs'
11
+ import { perSessionFile, writeHandoffPointer } from './resume.mjs'
12
+
13
+ const LEG_DIRS = /^(\.leg|\.baton|\.context-handoffs|\.dashclaw-local)[\\/]/
14
+ const bullets = (items) => items.filter(Boolean).map((x) => `- ${String(x).replace(/\r?\n/g, ' ').trim()}`)
15
+
16
+ function git(cwd, args) {
17
+ const r = spawnSync('git', args, { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
18
+ // trimEnd only: a porcelain line starts with a space (" M README.md")
19
+ return r.status === 0 ? r.stdout.trimEnd() : ''
20
+ }
21
+
22
+ export function slugFor(session) { return `leg-${session.session_id}`.toLowerCase().replace(/[^a-z0-9-]+/g, '-').slice(0, 80) }
23
+
24
+ // Notes in the CLI's section vocabulary; see src/handoff.mjs buildNotes.
25
+ export function sessionNotes(session, { messages = [], why = 'handoff' } = {}) {
26
+ const cwd = workRoot(session)
27
+ const stat = git(cwd, ['diff', '--stat'])
28
+ const dirty = git(cwd, ['status', '--porcelain']).split('\n').filter(Boolean).map((l) => l.slice(3).replace(/^"|"$/g, '')).filter((f) => !LEG_DIRS.test(f)).slice(0, 60)
29
+ const recent = git(cwd, ['log', '--oneline', '-5'])
30
+ const lines = [
31
+ '## Scope', '',
32
+ `Task: ${session.task ?? '(no prompt recorded yet; read the transcript)'}`,
33
+ `Interactive ${session.agent} session ${session.session_id} in ${session.cwd}${session.branch ? ` on branch ${session.branch}` : ''} stopped: ${why}. The next agent continues in the same directory.`,
34
+ '', '## Projects mentioned', '', `- ${session.repo_name ?? session.cwd}`, '',
35
+ '## Findings', '',
36
+ ...bullets(messages.slice(-8).map((m, i) => `${m.role === 'user' ? 'Human said' : 'Agent said'} (${i + 1}): ${m.text.slice(0, 600)}`)),
37
+ ...bullets([`Diff since session start: ${stat ? stat.split('\n').pop() : 'clean working tree'}`]),
38
+ ...bullets(dirty.map((f) => `Dirty file: ${f}`)),
39
+ ...bullets((session.files_touched ?? []).slice(0, 50).map((f) => `Edited this session: ${f}`)),
40
+ ...bullets(recent ? [`Recent commits: ${recent.replace(/\n/g, ' | ')}`] : []),
41
+ '', '## Opportunities', '',
42
+ ...bullets(['Next agent: read this bundle, inspect `git status` and `git diff`, continue the task from the last agent message, and do not ask the human to restate the task.']),
43
+ '', '## Open questions', '',
44
+ ...bullets([`Why the previous agent stopped: ${why}`, session.limit?.detail ? `Limit text: ${session.limit.detail}` : null]),
45
+ '', '## Evidence anchors', '',
46
+ ...bullets([...dirty, ...(session.files_touched ?? [])].slice(0, 50)),
47
+ ...bullets([session.transcript_path ? `transcript: ${session.transcript_path}` : null]),
48
+ '',
49
+ ]
50
+ return scrub(lines.join('\n'))
51
+ }
52
+
53
+ // Save (or refresh) the session's bundle. Returns { bundle_id, path } or throws.
54
+ export function saveSessionBundle(session, { messages = [], why = 'checkpoint' } = {}) {
55
+ // a session in its own worktree keeps its bundle and RESUME.md there, where the next agent starts
56
+ const cwd = workRoot(session)
57
+ const legDir = join(cwd, '.leg')
58
+ mkdirSync(legDir, { recursive: true })
59
+ if (session.repo) for (const pat of ['.leg/', '.baton/', '.context-handoffs/']) ensureExcluded(session.repo, pat)
60
+ const notesPath = join(legDir, `session-${session.session_id}.md`)
61
+ writeFileSync(notesPath, sessionNotes(session, { messages, why }))
62
+ const slug = slugFor(session)
63
+ const title = `leg ${session.agent} session ${session.session_id}`
64
+ const base = ['save', '--repo-local', '--title', title, '--slug', slug, '--notes', notesPath, '--tag', 'leg', '--tag', session.agent]
65
+ let r = session.bundle?.id ? chb([...base, '--update', slug], { cwd }) : { status: 1 }
66
+ if (r.status !== 0) r = chb(base, { cwd })
67
+ if (r.status !== 0) throw new Error(`context-handoff-bundle save failed (exit ${r.status}): ${scrub(r.stderr || r.stdout).slice(0, 400)}`)
68
+ let out
69
+ try { out = JSON.parse(r.stdout) } catch { throw new Error(`context-handoff-bundle save printed no JSON: ${scrub(r.stdout).slice(0, 200)}`) }
70
+ const bundle = { id: out.bundle_id, path: join(cwd, '.context-handoffs', out.bundle_id), notes: notesPath, quality: out.quality ?? null, updated_at: new Date().toISOString(), why }
71
+ updateSession(session.session_id, { bundle })
72
+ return bundle
73
+ }
74
+
75
+ // The resume text for the next agent: chb load into .baton/RESUME.md, plus a
76
+ // short pointer prompt (argv stays small; the bundle carries the context).
77
+ export function resumePrompt(session, bundle, next) {
78
+ const cwd = workRoot(session)
79
+ let loaded = ''
80
+ try {
81
+ const r = chb(['load', bundle.id], { cwd })
82
+ if (r.status === 0) loaded = r.stdout
83
+ } catch {}
84
+ const header = `# Leg handoff\n\nPrevious agent: ${session.agent} (${session.account}). Reason: ${session.limit?.reason ?? session.handoff?.reason ?? 'handoff requested'}${session.limit?.detail ? `, ${session.limit.detail}` : ''}.\nNext agent: ${next.agent} (${next.account}).\nBundle: ${bundle.path}\n\n`
85
+ const body = header + (loaded || readFileSync(bundle.notes, 'utf8'))
86
+ // src/resume.mjs owns both files: the per-session one so two sessions sharing
87
+ // one checkout (--no-worktree, or two started in the same instant) never
88
+ // overwrite each other's handoff, and RESUME.md, the copy everyone opens.
89
+ // Both are stamped with the git state and the live terminals they describe,
90
+ // so `baton resume --check` can tell a reader when they stopped being true.
91
+ const why = session.limit?.reason ?? session.handoff?.reason ?? 'handoff requested'
92
+ writeHandoffPointer(session, body, { bundle, why })
93
+ const perSession = perSessionFile(cwd, session.session_id)
94
+ const task = session.task ? `\n\nThe task, as the human first stated it: ${session.task.slice(0, 700)}` : ''
95
+ // the absolute path: the next agent is spawned in the session's cwd, which is
96
+ // a subdirectory of the work root whenever Baton was started in one
97
+ return `You are taking over an interactive coding session from ${session.agent}, which hit its usage limit. Read ${perSession} (the context handoff bundle is at ${bundle.path}), check git status and git diff, then continue the work from where it stopped. Do not ask the human to restate the task.${task}`
98
+ }
99
+
100
+ // Freshness, never existence: src/resume.mjs recomputes it from git at read
101
+ // time. `resumeFileExists` used to live here and answered "a file is on disk",
102
+ // which every caller then read as "the handoff it describes is still true".
103
+ export { resumeVerdict } from './resume.mjs'
package/src/cards.mjs ADDED
@@ -0,0 +1,114 @@
1
+ // cards — create a card from loosely typed input (CLI flags or a JSON body):
2
+ // parse the chain, build and validate the pipeline, then hand the ledger the
3
+ // exact shape. Shared by bin/baton.mjs and src/server.mjs.
4
+ import { execFileSync } from 'node:child_process'
5
+ import { existsSync, statSync } from 'node:fs'
6
+ import { resolve, sep, join } from 'node:path'
7
+ import { canonPath } from './fsx.mjs'
8
+ import { homedir } from 'node:os'
9
+ import { buildPipeline, validatePipeline, loadAdapterModes, parseChain } from './pipeline.mjs'
10
+ import { PRESET_NAMES } from './presets.mjs'
11
+ import { ledgerCreate, readCard } from './store.mjs'
12
+ import { humanAction } from './orchestrator.mjs'
13
+
14
+ export class CardInputError extends Error {}
15
+
16
+ function samePath(a, b) {
17
+ return canonPath(a) === canonPath(b)
18
+ }
19
+
20
+ function isParentOf(parent, child) {
21
+ return canonPath(child).startsWith(canonPath(parent) + sep)
22
+ }
23
+
24
+ function list(v) {
25
+ if (Array.isArray(v)) return v.map(String).map((x) => x.trim()).filter(Boolean)
26
+ return String(v ?? '').split(',').map((x) => x.trim()).filter(Boolean)
27
+ }
28
+
29
+ // "adapter=value,adapter=value" or {adapter: value}
30
+ function kv(raw) {
31
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw
32
+ const m = {}
33
+ for (const part of list(raw)) {
34
+ const eq = part.indexOf('=')
35
+ if (eq <= 0) throw new CardInputError(`expected adapter=value, got "${part}"`)
36
+ m[part.slice(0, eq).trim()] = part.slice(eq + 1)
37
+ }
38
+ return m
39
+ }
40
+
41
+ export async function createCard(input, actor = { type: 'human', id: 'local' }) {
42
+ if (!input.repo) throw new CardInputError('missing repo')
43
+ // stored as given (resolved); every comparison below is canonical, and the
44
+ // worktree path is derived from the real long form in src/worktree.mjs
45
+ const repo = resolve(String(input.repo))
46
+ if (!existsSync(repo)) throw new CardInputError(`repo not found: ${repo}`)
47
+ if (!statSync(repo).isDirectory()) throw new CardInputError(`repo is not a directory: ${repo}`)
48
+ let top
49
+ try {
50
+ top = execFileSync('git', ['-C', repo, 'rev-parse', '--show-toplevel'], { windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } }).trim()
51
+ } catch {
52
+ throw new CardInputError(`repo is not a git repository: ${repo}`)
53
+ }
54
+ const resolvedTop = resolve(top)
55
+ if (!samePath(resolvedTop, repo)) throw new CardInputError(`repo is not the repository root: ${repo} (root is ${resolvedTop})`)
56
+ // the trunk must exist now: a card whose trunk is missing would otherwise be
57
+ // refused by the worktree step on every scheduler tick, an error event each time
58
+ const trunk = String(input.trunk || 'main')
59
+ const gitq = (args) => { try { return execFileSync('git', ['-C', repo, ...args], { windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' }, stdio: ['ignore', 'pipe', 'ignore'] }).trim() } catch { return null } }
60
+ if (gitq(['rev-parse', '--verify', '--quiet', `refs/heads/${trunk}`]) === null) {
61
+ const actual = gitq(['symbolic-ref', '--short', 'HEAD'])
62
+ throw new CardInputError(`trunk ${trunk} does not exist in ${repo}${actual ? `; this repo's default branch is ${actual} (add the card with --trunk ${actual})` : ''}`)
63
+ }
64
+ const legHome = resolve(process.env.LEG_HOME || process.env.BATON_HOME || (existsSync(join(homedir(), '.leg')) ? join(homedir(), '.leg') : existsSync(join(homedir(), '.baton')) ? join(homedir(), '.baton') : join(homedir(), '.leg')))
65
+ if (samePath(repo, legHome) || isParentOf(repo, legHome)) throw new CardInputError('repo cannot contain LEG_HOME')
66
+ const task = String(input.task ?? '').trim()
67
+ if (!task) throw new CardInputError('missing task')
68
+ if (!input.chain || (Array.isArray(input.chain) && !input.chain.length)) throw new CardInputError('missing chain (e.g. claude,codex)')
69
+ let chain
70
+ try { chain = parseChain(input.chain) } catch (err) { throw new CardInputError(err.message) }
71
+ const modes = kv(input.mode)
72
+ const turns = kv(input.maxTurns ?? input.max_turns)
73
+ const fakeModes = kv(input.fakeMode ?? input.fake_mode)
74
+ const fakeFixtures = kv(input.fakeFixture ?? input.fake_fixture)
75
+ const fakeTargets = kv(input.fakeTarget ?? input.fake_target)
76
+ const fakeContents = kv(input.fakeContent ?? input.fake_content)
77
+ const models = kv(input.model)
78
+ const approve = list(input.approve)
79
+ chain = chain.map((e) => ({
80
+ ...e,
81
+ ...(modes[e.adapter] ? { mode: modes[e.adapter] } : {}),
82
+ ...(turns[e.adapter] ? { maxTurns: parseInt(turns[e.adapter], 10) } : {}),
83
+ ...(models[e.adapter] ? { model: models[e.adapter] } : {}),
84
+ ...(fakeModes[e.adapter] ? { fakeMode: fakeModes[e.adapter] } : {}),
85
+ ...(fakeFixtures[e.adapter] ? { fakeFixture: fakeFixtures[e.adapter] } : {}),
86
+ ...(fakeTargets[e.adapter] ? { fakeTarget: fakeTargets[e.adapter] } : {}),
87
+ ...(fakeContents[e.adapter] ? { fakeContent: fakeContents[e.adapter] } : {}),
88
+ ...(approve.includes(e.adapter) ? { approve: true } : {}),
89
+ }))
90
+ const pipelineArg = input.pipeline ?? 'build'
91
+ let pipeline
92
+ try {
93
+ if (Array.isArray(pipelineArg)) pipeline = buildPipeline({ stations: pipelineArg, chain })
94
+ else if (typeof pipelineArg === 'string' && pipelineArg.trim().startsWith('[')) pipeline = buildPipeline({ stations: JSON.parse(pipelineArg), chain })
95
+ else if (PRESET_NAMES.includes(pipelineArg)) pipeline = buildPipeline({ preset: pipelineArg, chain })
96
+ else pipeline = buildPipeline({ file: pipelineArg, chain })
97
+ validatePipeline(pipeline, await loadAdapterModes())
98
+ } catch (err) {
99
+ throw new CardInputError(`invalid pipeline: ${err.message}`)
100
+ }
101
+ const landMode = input.landMode ?? input.land_mode ?? 'ff'
102
+ if (!['ff', 'pr'].includes(landMode)) throw new CardInputError(`invalid land mode "${landMode}" (ff or pr)`)
103
+ const leases = list(input.leases)
104
+ const slug = (input.slug || task.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 30)) || 'card'
105
+ const id = ledgerCreate({
106
+ slug, task, repo,
107
+ chain: chain.map((e) => ({ adapter: e.adapter, mode: e.mode ?? null, max_turns: e.maxTurns ?? null, ...(e.model ? { model: e.model } : {}) })),
108
+ pipeline, leases, trunk: input.trunk || 'main', 'land-mode': landMode,
109
+ 'test-command': input.testCommand ?? input.test_command ?? null, title: input.title || null,
110
+ actor: JSON.stringify(actor),
111
+ })
112
+ if (input.queue) humanAction(id, 'enqueue', {}, actor)
113
+ return readCard(id)
114
+ }
package/src/chain.mjs ADDED
@@ -0,0 +1,264 @@
1
+ // chain — the card state machine. Pure: `transition(card, action, payload)`
2
+ // returns the patched card plus the ledger events to append; no I/O. Two
3
+ // machines live here: the station machine (a card moves through its pipeline)
4
+ // and the chain machine (inside an agent station, legs hand off to the next
5
+ // adapter on limit / incomplete / no_progress / stalled / failed).
6
+ // TRANSITIONS is data so tests print the table and docs embed it.
7
+ import { stationIndex } from './pipeline.mjs'
8
+
9
+ export const TERMINAL = ['done', 'failed', 'killed']
10
+ export const NON_TERMINAL = ['backlog', 'queued', 'running', 'handing_off', 'waiting_human', 'needs_approval', 'paused']
11
+
12
+ // [from, action, to, note]
13
+ export const TRANSITIONS = [
14
+ ['backlog', 'enqueue', 'queued', 'human adds the card to the floor (or `card run`)'],
15
+ ['queued', 'start', 'running', 'scheduler starts a leg (agent/test/land station) under the card\'s leases'],
16
+ ['queued', 'reach_human', 'waiting_human', 'a human station is next'],
17
+ ['running', 'leg:completed', 'queued', 'agent leg completed → next station (leg 0)'],
18
+ ['running', 'leg:completed', 'done', 'agent leg completed and it was the last station'],
19
+ ['running', 'leg:completed', 'waiting_human', 'agent leg completed and a human station is next'],
20
+ ['running', 'leg:handoff', 'handing_off', 'limit | incomplete | no_progress | stalled | failed and the chain has a next leg'],
21
+ ['running', 'leg:handoff', 'failed', 'same outcomes with the chain exhausted'],
22
+ ['running', 'leg:auth_failed', 'failed', 'Baton/environment fault; no advance; human fixes and clicks Rerun'],
23
+ ['running', 'leg:launch_failed', 'failed', 'same'],
24
+ ['running', 'leg:killed', 'killed', 'the leg was killed from the board'],
25
+ ['handing_off', 'bundle_written', 'queued', 'next leg (leg+1) queued at the same station'],
26
+ ['handing_off', 'bundle_written', 'needs_approval', 'next chain entry has approve: true'],
27
+ ['running', 'test:green', 'queued', 'test station passed → next station'],
28
+ ['running', 'test:green', 'done', 'test station passed and it was last'],
29
+ ['running', 'test:red', 'queued', 'bounce to the nearest earlier build station (or the first agent station) with bounce_reason'],
30
+ ['running', 'land:landed', 'done', 'land station merged (phase 7: real merge queue)'],
31
+ ['running', 'land:bounced', 'queued', 'land red/conflict → bounce to build with the failure attached (phase 7)'],
32
+ ['running', 'land:failed', 'failed', 'land attempts exhausted (phase 7)'],
33
+ ['running', 'pause', 'paused', 'human: kill the child, write a bundle'],
34
+ ['paused', 'resume', 'queued', 'human: same station and leg; prompt = bundle load + contract'],
35
+ ['*non-terminal*', 'kill', 'killed', 'human'],
36
+ ['*non-terminal*', 'reassign', 'queued', 'human: rewrite the current station\'s chain from the current leg'],
37
+ ['running', 'handoff_now', 'handing_off', 'human: as if the leg ended incomplete'],
38
+ ['needs_approval', 'approve', 'queued', 'human'],
39
+ ['waiting_human', 'approve', 'queued', 'human: the human station is done → next station'],
40
+ ['waiting_human', 'approve', 'done', 'human station was last'],
41
+ ['*terminal*', 'rerun', 'queued', 'human: station 0, leg 0, same worktree, new run number'],
42
+ ]
43
+
44
+ export class IllegalTransition extends Error {
45
+ constructor(from, action, to) {
46
+ super(`illegal transition ${from} --${action}--> ${to ?? '?'}`)
47
+ this.name = 'IllegalTransition'
48
+ }
49
+ }
50
+
51
+ const HANDOFF_OUTCOMES = ['limit', 'incomplete', 'no_progress', 'stalled', 'failed']
52
+ const NO_ADVANCE_OUTCOMES = ['auth_failed', 'launch_failed']
53
+
54
+ function station(card) {
55
+ const i = stationIndex(card.pipeline, card.station)
56
+ return { i, s: card.pipeline[i] ?? null }
57
+ }
58
+
59
+ function ev(type, summary, body) {
60
+ const e = { type, summary }
61
+ if (body) e.body = body
62
+ return e
63
+ }
64
+
65
+ // Where the card goes after the station at index i is done.
66
+ function advance(card, i, events) {
67
+ const next = card.pipeline[i + 1]
68
+ events.push(ev('station_done', `station ${card.pipeline[i].name} done${next ? ` → ${next.name}` : ''}`))
69
+ if (!next) {
70
+ events.push(ev('done', `card done: all ${card.pipeline.length} station(s) complete`))
71
+ return { ...card, status: 'done', leg: 0 }
72
+ }
73
+ if (next.kind === 'human') return { ...card, station: next.name, leg: 0, status: 'waiting_human' }
74
+ return { ...card, station: next.name, leg: 0, status: 'queued' }
75
+ }
76
+
77
+ // The agent station a red test or a bounced land goes back to: the nearest
78
+ // earlier `build`, else the first agent station BEFORE the failing one. An
79
+ // agent station after it is never a target: the card would skip the station
80
+ // it just failed and finish `done` with red tests.
81
+ function bounceTarget(card, i) {
82
+ for (let k = i - 1; k >= 0; k--) {
83
+ const s = card.pipeline[k]
84
+ if (s.kind === 'agent' && s.name === 'build') return s
85
+ }
86
+ return card.pipeline.slice(0, i).find((s) => s.kind === 'agent') ?? null
87
+ }
88
+
89
+ function assertStatus(card, action, allowed) {
90
+ if (!allowed.includes(card.status)) throw new IllegalTransition(card.status, action)
91
+ }
92
+
93
+ export function transition(card, action, payload = {}) {
94
+ const events = []
95
+ const { i, s } = station(card)
96
+
97
+ switch (action) {
98
+ case 'enqueue': {
99
+ assertStatus(card, action, ['backlog'])
100
+ const first = card.pipeline[0]
101
+ if (!first) throw new IllegalTransition(card.status, action, 'queued')
102
+ // No ledger event: the status change itself is the record (the board
103
+ // reads card.json); events are reserved for things that happened.
104
+ if (first.kind === 'human') return { card: { ...card, station: first.name, leg: 0, status: 'waiting_human' }, events: [] }
105
+ return { card: { ...card, station: first.name, leg: 0, status: 'queued' }, events: [] }
106
+ }
107
+ case 'start': {
108
+ assertStatus(card, action, ['queued'])
109
+ if (!s) throw new IllegalTransition(card.status, action, 'running')
110
+ if (s.kind === 'human') return { card: { ...card, status: 'waiting_human' }, events: [] }
111
+ return { card: { ...card, status: 'running' }, events: [] }
112
+ }
113
+ case 'leg_result': {
114
+ // payload: { outcome, handoff, signal, adapter, run }
115
+ assertStatus(card, action, ['running'])
116
+ if (!s || s.kind !== 'agent') throw new IllegalTransition(card.status, action)
117
+ const { outcome } = payload
118
+ const tag = `${payload.adapter ?? s.chain[card.leg]?.adapter} leg ${card.leg}${payload.signal && payload.signal !== 'none' ? ` (${payload.signal})` : ''}`
119
+ if (outcome === 'completed') return { card: advance(card, i, events), events }
120
+ if (outcome === 'killed') {
121
+ events.push(ev('killed', `${tag} killed`))
122
+ return { card: { ...card, status: 'killed' }, events }
123
+ }
124
+ if (NO_ADVANCE_OUTCOMES.includes(outcome)) {
125
+ events.push(ev('failed', `${tag}: ${outcome}; not advancing (fix the environment and rerun)`))
126
+ return { card: { ...card, status: 'failed', failure: outcome }, events }
127
+ }
128
+ if (HANDOFF_OUTCOMES.includes(outcome)) {
129
+ const next = s.chain[card.leg + 1]
130
+ if (!next) {
131
+ events.push(ev('failed', `${tag}: ${outcome}; chain exhausted at ${s.name}`))
132
+ return { card: { ...card, status: 'failed', failure: outcome }, events }
133
+ }
134
+ return { card: { ...card, status: 'handing_off', handoff_outcome: outcome, next_leg: card.leg + 1 }, events }
135
+ }
136
+ throw new Error(`unknown leg outcome "${outcome}"`)
137
+ }
138
+ case 'bundle_written': {
139
+ assertStatus(card, action, ['handing_off'])
140
+ const nextLeg = card.next_leg ?? card.leg + 1
141
+ const entry = s?.chain?.[nextLeg]
142
+ if (!entry) throw new IllegalTransition(card.status, action)
143
+ const from = s.chain[card.leg]?.adapter
144
+ if (entry.approve) {
145
+ events.push(ev('approval_needed', `${from} → ${entry.adapter} needs approval before leg ${nextLeg}`))
146
+ return { card: { ...card, leg: nextLeg, status: 'needs_approval', next_leg: null }, events }
147
+ }
148
+ // The handoff_written event (orchestrator) already names from → to; the
149
+ // next leg_started (supervisor) names the adapter. No extra event here.
150
+ void from
151
+ return { card: { ...card, leg: nextLeg, status: 'queued', next_leg: null }, events }
152
+ }
153
+ case 'test_result': {
154
+ assertStatus(card, action, ['running'])
155
+ if (!s || s.kind !== 'test') throw new IllegalTransition(card.status, action)
156
+ if (payload.green) return { card: advance(card, i, events), events }
157
+ const target = bounceTarget(card, i)
158
+ // test and land bounces share one counter (land_attempts) and one cap, so a
159
+ // card that never goes green cannot loop forever
160
+ const attempts = (card.land_attempts ?? 0) + 1
161
+ const max = Math.max(1, parseInt((process.env.LEG_MAX_LAND_ATTEMPTS || process.env.BATON_MAX_LAND_ATTEMPTS) || '3', 10) || 3)
162
+ if (!target || attempts >= max) {
163
+ events.push(ev('failed', target ? `test red after ${attempts} attempt(s)` : 'test red and no agent station to bounce to', payload.reason))
164
+ return { card: { ...card, status: 'failed', failure: 'test', land_attempts: attempts }, events }
165
+ }
166
+ events.push(ev('bounced', `test red (attempt ${attempts}) → ${target.name}`, payload.reason))
167
+ // the next build leg starts from the bounce bundle (the failure is in its Open findings)
168
+ return { card: { ...card, station: target.name, leg: 0, status: 'queued', land_attempts: attempts, bounce_reason: payload.reason ?? 'test red', resume_from_bundle: true }, events }
169
+ }
170
+ case 'land_result': {
171
+ // payload: { landed, bounced, reason } — phase 7 fills the real queue
172
+ assertStatus(card, action, ['running'])
173
+ if (!s || s.kind !== 'land') throw new IllegalTransition(card.status, action)
174
+ if (payload.landed) {
175
+ events.push(ev('landed', payload.summary ?? 'landed on trunk', payload.body))
176
+ return { card: advance(card, i, events), events }
177
+ }
178
+ if (payload.pr) {
179
+ // pr land mode: the human merges; the card waits at the land station
180
+ events.push(ev('approval_needed', payload.summary ?? `pull request opened: ${payload.url ?? ''}`))
181
+ return { card: { ...card, status: 'waiting_human', pr_url: payload.url ?? null }, events }
182
+ }
183
+ const attempts = (card.land_attempts ?? 0) + 1
184
+ const maxAttempts = Math.max(1, parseInt((process.env.LEG_MAX_LAND_ATTEMPTS || process.env.BATON_MAX_LAND_ATTEMPTS) || '3', 10) || 3)
185
+ const target = payload.bounced ? bounceTarget(card, i) : null
186
+ if (target && attempts < maxAttempts) {
187
+ events.push(ev('bounced', `land bounced (attempt ${attempts}) → ${target.name}`, payload.reason))
188
+ return { card: { ...card, station: target.name, leg: 0, status: 'queued', land_attempts: attempts, bounce_reason: payload.reason ?? 'land bounced', resume_from_bundle: true }, events }
189
+ }
190
+ events.push(ev('failed', payload.bounced && !target ? 'land bounced and no agent station to bounce to' : `land failed after ${attempts} attempt(s)`, payload.reason))
191
+ return { card: { ...card, status: 'failed', land_attempts: attempts, failure: 'land' }, events }
192
+ }
193
+ // ---- human actions ----
194
+ case 'pause': {
195
+ assertStatus(card, action, ['running'])
196
+ events.push(ev('paused', 'paused by human'))
197
+ return { card: { ...card, status: 'paused' }, events }
198
+ }
199
+ case 'resume': {
200
+ assertStatus(card, action, ['paused'])
201
+ events.push(ev('resumed', `resumed at ${card.station} leg ${card.leg}`))
202
+ return { card: { ...card, status: 'queued', resume_from_bundle: true }, events }
203
+ }
204
+ case 'kill': {
205
+ assertStatus(card, action, NON_TERMINAL)
206
+ events.push(ev('killed', 'killed by human'))
207
+ return { card: { ...card, status: 'killed', kill_requested: true }, events }
208
+ }
209
+ case 'reassign': {
210
+ assertStatus(card, action, NON_TERMINAL)
211
+ if (!s || s.kind !== 'agent') throw new IllegalTransition(card.status, action)
212
+ if (!payload.adapter) throw new Error('reassign needs an adapter')
213
+ const entry = { adapter: payload.adapter }
214
+ if (payload.mode) entry.mode = payload.mode
215
+ const chain = [...s.chain.slice(0, card.leg), entry, ...s.chain.slice(card.leg + 1)]
216
+ const pipeline = card.pipeline.map((st) => (st.name === s.name ? { ...st, chain } : st))
217
+ events.push(ev('reassigned', `leg ${card.leg} at ${s.name} reassigned to ${payload.adapter}${payload.mode ? ` (${payload.mode})` : ''}`))
218
+ return { card: { ...card, pipeline, status: 'queued', kill_requested: card.status === 'running' }, events }
219
+ }
220
+ case 'handoff_now': {
221
+ assertStatus(card, action, ['running'])
222
+ if (!s || s.kind !== 'agent') throw new IllegalTransition(card.status, action)
223
+ const next = s.chain[card.leg + 1]
224
+ if (!next) throw new IllegalTransition(card.status, action, 'handing_off')
225
+ events.push(ev('status', 'handoff requested by human'))
226
+ return { card: { ...card, status: 'handing_off', handoff_outcome: 'incomplete', next_leg: card.leg + 1, kill_requested: true }, events }
227
+ }
228
+ case 'approve': {
229
+ assertStatus(card, action, ['needs_approval', 'waiting_human'])
230
+ if (card.status === 'needs_approval') {
231
+ events.push(ev('approved', `leg ${card.leg} at ${card.station} approved`))
232
+ return { card: { ...card, status: 'queued' }, events }
233
+ }
234
+ events.push(ev('approved', `human station ${card.station} approved`))
235
+ return { card: advance(card, i, events), events }
236
+ }
237
+ case 'rerun': {
238
+ assertStatus(card, action, TERMINAL)
239
+ const first = card.pipeline[0]
240
+ events.push(ev('status', `rerun from ${first.name} leg 0`))
241
+ return { card: { ...card, station: first.name, leg: 0, status: first.kind === 'human' ? 'waiting_human' : 'queued', land_attempts: 0, bounce_reason: null, failure: null, kill_requested: false }, events }
242
+ }
243
+ default:
244
+ throw new Error(`unknown action "${action}"`)
245
+ }
246
+ }
247
+
248
+ export const HUMAN_ACTIONS = ['pause', 'resume', 'kill', 'reassign', 'handoff_now', 'approve', 'rerun']
249
+
250
+ // Which human buttons make sense for a card right now (the board asks this).
251
+ export function availableActions(card) {
252
+ const out = []
253
+ const st = card.status
254
+ const { s } = station(card)
255
+ if (st === 'running') out.push('pause')
256
+ // the transition refuses handoff_now without a next chain entry, so the board must not offer it
257
+ if (st === 'running' && s?.kind === 'agent' && s.chain?.[card.leg + 1]) out.push('handoff_now')
258
+ if (st === 'paused') out.push('resume')
259
+ if (st === 'needs_approval' || st === 'waiting_human') out.push('approve')
260
+ if (NON_TERMINAL.includes(st)) { out.push('kill'); if (s?.kind === 'agent') out.push('reassign') }
261
+ if (TERMINAL.includes(st)) out.push('rerun')
262
+ if (st === 'backlog') out.push('enqueue')
263
+ return out
264
+ }
@@ -0,0 +1,79 @@
1
+ // commands — run a repository command (tests) as argv, never through a shell.
2
+ // `npm`/`npx`/`node` resolve to node + their JS entry so the Windows .cmd shim
3
+ // is never needed (LESSONS 07-11).
4
+ import { spawnSync, spawn } from 'node:child_process'
5
+ import { existsSync } from 'node:fs'
6
+ import { join, dirname } from 'node:path'
7
+ import { resolveNpmCliEntry } from './adapters/resolve.mjs'
8
+ import { scrub } from './runner.mjs'
9
+
10
+ function tokenize(cmd) {
11
+ return cmd.match(/(?:[^\s"]+|"[^"]*")+/g)?.map((t) => t.replace(/^"|"$/g, '')) ?? []
12
+ }
13
+
14
+ export function resolveCommand(cmd) {
15
+ const tokens = tokenize(cmd)
16
+ if (!tokens.length) throw new Error('empty test command')
17
+ const [bin, ...rest] = tokens
18
+ if (bin === 'node') return { bin: process.execPath, args: rest }
19
+ if (bin === 'npm' || bin === 'npx') {
20
+ const entry = join(dirname(process.execPath), 'node_modules', 'npm', 'bin', `${bin}-cli.js`)
21
+ const found = existsSync(entry) ? entry : resolveNpmCliEntry('npm', bin)
22
+ if (!found) throw new Error(`cannot resolve ${bin}'s JS entry`)
23
+ return { bin: process.execPath, args: [found, ...rest] }
24
+ }
25
+ return { bin, args: rest }
26
+ }
27
+
28
+ // A nested `node --test` inherits NODE_TEST_CONTEXT from a parent test runner
29
+ // and then reports to that parent instead of exiting red; the repo's tests
30
+ // must run as a plain process.
31
+ function commandEnv() {
32
+ const env = { ...process.env, MSYS_NO_PATHCONV: '1', CI: '1' }
33
+ for (const k of Object.keys(env)) if (k.startsWith('NODE_TEST')) delete env[k]
34
+ return env
35
+ }
36
+
37
+ export function runCommand(cmd, cwd, { timeoutMs = 600000, tailLines = 40 } = {}) {
38
+ const { bin, args } = resolveCommand(cmd)
39
+ const r = spawnSync(bin, args, { cwd, windowsHide: true, encoding: 'utf8', timeout: timeoutMs, env: commandEnv() })
40
+ const tail = scrub(`${r.stdout ?? ''}\n${r.stderr ?? ''}`).trim().split('\n').slice(-tailLines).join('\n')
41
+ return { green: r.status === 0, status: r.status, timedOut: r.error?.code === 'ETIMEDOUT', tail, command: `${bin} ${args.join(' ')}` }
42
+ }
43
+
44
+ // The same result without blocking the event loop: the board server lands a
45
+ // terminal's branch in-process, and a long test run must not freeze the board.
46
+ export function runCommandAsync(cmd, cwd, { timeoutMs = 600000, tailLines = 40 } = {}) {
47
+ const { bin, args } = resolveCommand(cmd)
48
+ return new Promise((resolvePromise) => {
49
+ let out = ''
50
+ let timedOut = false
51
+ let done = false
52
+ const child = spawn(bin, args, { cwd, windowsHide: true, env: commandEnv() })
53
+ const keep = (d) => { out += d; if (out.length > 2e6) out = out.slice(-1e6) }
54
+ child.stdout.on('data', keep)
55
+ child.stderr.on('data', keep)
56
+ // On timeout, kill the whole process TREE: child.kill() ends only the npm
57
+ // node process, and a grandchild (a watcher, a dev server) keeps the stdio
58
+ // pipes open so 'close' never fires and the landing hangs in 'landing'
59
+ // forever, wedging the repo's merge queue. taskkill /T /F takes the tree.
60
+ const timer = setTimeout(() => {
61
+ timedOut = true
62
+ if (process.platform === 'win32' && child.pid) { try { spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true }) } catch {} }
63
+ else { try { child.kill('SIGKILL') } catch {} }
64
+ }, timeoutMs)
65
+ const finish = (status, err) => {
66
+ if (done) return
67
+ done = true
68
+ clearTimeout(timer)
69
+ const tail = scrub(`${out}${err ? `\n${err.message}` : ''}`).trim().split('\n').slice(-tailLines).join('\n')
70
+ resolvePromise({ green: status === 0 && !timedOut, status, timedOut, tail, command: `${bin} ${args.join(' ')}` })
71
+ }
72
+ child.on('error', (err) => finish(null, err))
73
+ // resolve on 'exit' (fires when the process ends) as well as 'close' (waits
74
+ // for every inherited pipe to close, which a detached grandchild can hold
75
+ // open indefinitely); finish is idempotent via `done`
76
+ child.on('exit', (code) => finish(code))
77
+ child.on('close', (code) => finish(code))
78
+ })
79
+ }
@@ -0,0 +1,73 @@
1
+ // contract — the per-leg prompt. Every CLI gets the same contract file at
2
+ // .baton/CONTRACT.md in the worktree (CLI-agnostic completion: write
3
+ // .baton/DONE when finished). Leg 1 prompt = the contract; a later leg or a
4
+ // resume = the handoff bundle's resume text + the contract.
5
+ import { mkdirSync, writeFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+
8
+ const TEMPLATES = {
9
+ plan: () => import('./stations/plan.mjs'),
10
+ build: () => import('./stations/build.mjs'),
11
+ review: () => import('./stations/review.mjs'),
12
+ }
13
+
14
+ export async function stationTemplate(name = 'build') {
15
+ const load = TEMPLATES[name] ?? TEMPLATES.build
16
+ return (await load()).default
17
+ }
18
+
19
+ export async function renderContract({ card, station, leg, entry, worktree, resumed = false, bounceReason = null }) {
20
+ const t = await stationTemplate(station.prompt ?? station.name)
21
+ const lines = [
22
+ `# Leg contract, card ${card.card_id}, station ${station.name}, leg ${leg} (${entry?.adapter ?? 'agent'})`,
23
+ '',
24
+ '## Task',
25
+ '',
26
+ card.task,
27
+ '',
28
+ '## Station goal',
29
+ '',
30
+ t.goal,
31
+ '',
32
+ ...t.deliverables.map((d) => `- ${d}`),
33
+ '',
34
+ ]
35
+ if (bounceReason) {
36
+ lines.push('## Why this card came back', '', bounceReason, '')
37
+ }
38
+ if (resumed) {
39
+ lines.push('## Continuation', '',
40
+ 'A previous agent already worked on this card in this same directory. Its handoff resume is above this contract; .leg/PROGRESS.md holds its notes. Continue from there; do not start over.', '')
41
+ }
42
+ lines.push(
43
+ '## Rules',
44
+ '',
45
+ `- Work only inside this directory: ${worktree}. It is a git worktree on its own branch; commit as you go or leave changes uncommitted, both are fine.`,
46
+ '- Keep .leg/PROGRESS.md updated as you go: one line per meaningful step, newest last. It is how the next agent (or a human) picks up if you stop early.',
47
+ '- Do not push, do not create remotes, do not open pull requests, do not change git config.',
48
+ '- Do not touch anything under .leg/ except PROGRESS.md and DONE.',
49
+ '- No interactive prompt will be answered; if you need a permission you do not have, write what you need to .leg/PROGRESS.md and stop.',
50
+ '',
51
+ '## Finish',
52
+ '',
53
+ 'When the task is finished and verified, write the file .leg/DONE containing one line that summarizes what you did. Without that file Leg treats the run as unfinished and hands it to the next agent.',
54
+ '',
55
+ )
56
+ return lines.join('\n')
57
+ }
58
+
59
+ export function writeContract(worktree, text) {
60
+ const dir = join(worktree, '.leg')
61
+ mkdirSync(dir, { recursive: true })
62
+ const path = join(dir, 'CONTRACT.md')
63
+ writeFileSync(path, text)
64
+ return path
65
+ }
66
+
67
+ export function legPrompt({ contractText, resumeText = null }) {
68
+ const head = 'You are working on a Leg card. The contract below is also saved at .leg/CONTRACT.md.'
69
+ if (resumeText) {
70
+ return `${head}\n\n=== HANDOFF RESUME (from the previous agent) ===\n\n${resumeText.trim()}\n\n=== CONTRACT ===\n\n${contractText}`
71
+ }
72
+ return `${head}\n\n${contractText}`
73
+ }