@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/env.mjs ADDED
@@ -0,0 +1,19 @@
1
+ // Child-process variable sanitization shared by every adapter. Lives in its
2
+ // own module (re-exported by src/runner.mjs) so adapters can import it without
3
+ // a top-level-await import cycle through the runner.
4
+ //
5
+ // Subscription auth only: every provider API key and base-URL override goes
6
+ // (Anthropic, OpenAI, and the Google/Gemini set agy honours), and so do the session markers
7
+ // a Claude Code parent would leak into the child (a leaked session key
8
+ // overrides the machine login and kills the run with "Invalid API key"). The
9
+ // print ceiling must be 0 for a detached claude -p.
10
+ export function sanitizeEnv(env, { interactive = false } = {}) {
11
+ const out = { ...env }
12
+ for (const key of Object.keys(out)) {
13
+ if (/^(ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ANTHROPIC_BASE_URL|ANTHROPIC_CUSTOM_HEADERS|OPENAI_API_KEY|OPENAI_BASE_URL|OPENAI_API_BASE|GEMINI_API_KEY|GOOGLE_API_KEY|GOOGLE_GEMINI_BASE_URL|GOOGLE_GENAI_USE_VERTEXAI|GOOGLE_GENAI_USE_ENTERPRISE|GOOGLE_CLOUD_PROJECT|GOOGLE_CLOUD_LOCATION|GOOGLE_APPLICATION_CREDENTIALS|CLAUDECODE|CLAUDE_CODE_\w+|CLAUDE_EFFORT|CLAUDE_PLUGIN_DATA)$/.test(key)) {
14
+ delete out[key]
15
+ }
16
+ }
17
+ if (!interactive) out.CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS = '0'
18
+ return out
19
+ }
package/src/fsx.mjs ADDED
@@ -0,0 +1,72 @@
1
+ // fsx — atomic JSON writes that survive Windows. Readers (board, orchestrator,
2
+ // tests) poll card.json and run.json; write-then-rename keeps them from seeing
3
+ // a torn file, but on Windows a rename over a file another process has open
4
+ // fails with EPERM/EBUSY for a moment. Retry briefly, then fall back to a
5
+ // direct write rather than lose the record (the supervisor's final run.json
6
+ // once went missing this way and the orchestrator read a stale 'running').
7
+ import { writeFileSync, renameSync, unlinkSync, existsSync, realpathSync, openSync, closeSync, statSync } from 'node:fs'
8
+ import { resolve, dirname, basename, join } from 'node:path'
9
+
10
+ // Canonical form for comparing paths: symlinks resolved and, on Windows, 8.3
11
+ // short names expanded (a GitHub runner's temp dir is C:\Users\RUNNER~1\...
12
+ // while git reports C:\Users\runneradmin\...). A path that does not exist
13
+ // yet is canonicalised through its deepest existing ancestor.
14
+ export function canonPath(p) {
15
+ const out = realPath(p)
16
+ return process.platform === 'win32' ? out.toLowerCase() : out
17
+ }
18
+
19
+ // The real, long-form path (case preserved): what Baton stores and hands to
20
+ // git, so a short or symlinked input never leaks into card.json or worktrees.
21
+ export function realPath(p) {
22
+ let base = resolve(p)
23
+ const rest = []
24
+ while (!existsSync(base)) {
25
+ const parent = dirname(base)
26
+ if (parent === base) break
27
+ rest.unshift(basename(base))
28
+ base = parent
29
+ }
30
+ let out = base
31
+ try { out = realpathSync.native(base) } catch {}
32
+ return rest.length ? join(out, ...rest) : out
33
+ }
34
+
35
+ const sleepSync = (ms) => { const t = Date.now() + ms; while (Date.now() < t) { /* spin */ } }
36
+
37
+ // Cross-process advisory lock around a read-modify-write of a shared JSON file.
38
+ // `openSync(..., 'wx')` is atomic-create, so only one process (a hook, a tap, a
39
+ // poller) holds it at a time. A lock older than staleMs (a crashed holder) is
40
+ // stolen. If it cannot be acquired within the budget, fn runs anyway rather
41
+ // than hang the caller (a Claude Code hook must never block the user's turn).
42
+ export function withFileLock(lockPath, fn, { retries = 60, waitMs = 20, staleMs = 5000 } = {}) {
43
+ let fd = null
44
+ for (let i = 0; i < retries; i++) {
45
+ try { fd = openSync(lockPath, 'wx'); break } catch (err) {
46
+ // EEXIST: held. EPERM/EACCES/EBUSY: Windows, the holder's unlink is
47
+ // still pending and the path is briefly untouchable; that is "held" too,
48
+ // not a reason to run unlocked.
49
+ if (!['EEXIST', 'EPERM', 'EACCES', 'EBUSY'].includes(err.code)) break
50
+ try { if (Date.now() - statSync(lockPath).mtimeMs > staleMs) { unlinkSync(lockPath); continue } } catch {}
51
+ sleepSync(waitMs)
52
+ }
53
+ }
54
+ try { return fn() } finally { if (fd !== null) { try { closeSync(fd) } catch {} try { unlinkSync(lockPath) } catch {} } }
55
+ }
56
+
57
+ export function writeJsonAtomic(file, obj) {
58
+ const text = JSON.stringify(obj, null, 2) + '\n'
59
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
60
+ writeFileSync(tmp, text)
61
+ for (let i = 0; i < 20; i++) {
62
+ try {
63
+ renameSync(tmp, file)
64
+ return
65
+ } catch (err) {
66
+ if (!['EPERM', 'EBUSY', 'EACCES', 'EEXIST'].includes(err.code)) { try { unlinkSync(tmp) } catch {} throw err }
67
+ sleepSync(25)
68
+ }
69
+ }
70
+ try { unlinkSync(tmp) } catch {}
71
+ writeFileSync(file, text)
72
+ }
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ // Ported 2026-09-10 from private ucsandman team tooling; see NOTICE and docs/REUSE.md.
3
+ // git-snapshot — read-only git state snapshot. Prints ONE compact JSON object to stdout. Never mutates the repo.
4
+ // See PROTOCOL.md § Git workflow (coding tasks).
5
+ import { execFileSync } from 'node:child_process'
6
+ import { existsSync, statSync } from 'node:fs'
7
+ import { resolve } from 'node:path'
8
+
9
+ function die(code, msg) {
10
+ process.stderr.write(msg + '\n')
11
+ process.exit(code)
12
+ }
13
+
14
+ function parseArgs(argv) {
15
+ const args = {}
16
+ for (let i = 0; i < argv.length; i += 2) {
17
+ if (!argv[i].startsWith('--') || argv[i + 1] === undefined) {
18
+ die(2, `bad argument pair near "${argv[i]}"`)
19
+ }
20
+ args[argv[i].slice(2)] = argv[i + 1]
21
+ }
22
+ return args
23
+ }
24
+
25
+ const args = parseArgs(process.argv.slice(2))
26
+
27
+ const repoArg = args['repo']
28
+ if (!repoArg) die(2, 'missing --repo <path>')
29
+ const repo = resolve(repoArg)
30
+ if (!existsSync(repo) || !statSync(repo).isDirectory()) {
31
+ die(2, `not a directory: ${repo}`)
32
+ }
33
+
34
+ let maxFiles = 20
35
+ if (args['max-files'] !== undefined) {
36
+ maxFiles = parseInt(args['max-files'], 10)
37
+ if (!Number.isInteger(maxFiles) || maxFiles < 1) {
38
+ die(2, `invalid --max-files "${args['max-files']}"`)
39
+ }
40
+ }
41
+
42
+ function git(gitArgs) {
43
+ // MSYS_NO_PATHCONV: Git Bash would rewrite a leading-slash argument into a
44
+ // Windows path (LESSONS 07-09); harmless elsewhere.
45
+ return execFileSync('git', gitArgs, { cwd: repo, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
46
+ }
47
+
48
+ try {
49
+ git(['rev-parse', '--is-inside-work-tree'])
50
+ } catch {
51
+ die(2, `not a git repo: ${repo}`)
52
+ }
53
+
54
+ let branch
55
+ try {
56
+ branch = git(['symbolic-ref', '--short', '-q', 'HEAD']).trim()
57
+ } catch {
58
+ branch = 'DETACHED'
59
+ }
60
+
61
+ let head, head_subject
62
+ try {
63
+ head = git(['rev-parse', '--short', 'HEAD']).trim()
64
+ head_subject = git(['log', '-1', '--format=%s']).trim()
65
+ } catch {
66
+ die(2, `repo has no commits: ${repo}`)
67
+ }
68
+
69
+ const statusLines = git(['status', '--porcelain']).split(/\r?\n/).filter(Boolean)
70
+ let staged = 0
71
+ let modified = 0
72
+ let untracked = 0
73
+ for (const line of statusLines) {
74
+ if (line.startsWith('??')) {
75
+ untracked++
76
+ continue
77
+ }
78
+ if (line[0] !== ' ') staged++
79
+ if (line[1] !== ' ') modified++
80
+ }
81
+ const dirty = {
82
+ staged,
83
+ modified,
84
+ untracked,
85
+ files: statusLines.slice(0, maxFiles),
86
+ truncated: statusLines.length > maxFiles,
87
+ }
88
+
89
+ let submodules_dirty = []
90
+ try {
91
+ const subLines = git(['submodule', 'status']).split(/\r?\n/).filter(Boolean)
92
+ submodules_dirty = subLines
93
+ .filter((l) => l[0] === '+' || l[0] === 'U')
94
+ .map((l) => l.slice(1).trim().split(/\s+/)[1])
95
+ .filter(Boolean)
96
+ } catch {
97
+ submodules_dirty = []
98
+ }
99
+
100
+ let recommendation
101
+ if (dirty.staged === 0 && dirty.modified === 0 && dirty.untracked === 0) {
102
+ recommendation = 'branch'
103
+ } else {
104
+ recommendation = 'worktree'
105
+ }
106
+
107
+ const snapshot = {
108
+ ts: new Date().toISOString(),
109
+ repo,
110
+ branch,
111
+ head,
112
+ head_subject,
113
+ dirty,
114
+ submodules_dirty,
115
+ recommendation,
116
+ }
117
+
118
+ if (args['diff-since']) {
119
+ const since = args['diff-since']
120
+ let logLines
121
+ let numstatLines
122
+ try {
123
+ logLines = git(['log', '--oneline', `${since}..HEAD`]).split(/\r?\n/).filter(Boolean)
124
+ numstatLines = git(['diff', '--numstat', `${since}..HEAD`]).split(/\r?\n/).filter(Boolean)
125
+ } catch (err) {
126
+ die(2, `invalid --diff-since ref "${since}": ${err.message}`)
127
+ }
128
+ const commits = logLines.map((l) => {
129
+ const sp = l.indexOf(' ')
130
+ return sp === -1 ? { hash: l, subject: '' } : { hash: l.slice(0, sp), subject: l.slice(sp + 1) }
131
+ })
132
+ let insertions = 0
133
+ let deletions = 0
134
+ const files = []
135
+ for (const line of numstatLines) {
136
+ const [ins, del, path] = line.split('\t')
137
+ if (ins !== '-') insertions += parseInt(ins, 10)
138
+ if (del !== '-') deletions += parseInt(del, 10)
139
+ files.push(path)
140
+ }
141
+ snapshot.diff = {
142
+ since,
143
+ commits,
144
+ changed_files: numstatLines.length,
145
+ insertions,
146
+ deletions,
147
+ files: files.slice(0, maxFiles),
148
+ truncated: files.length > maxFiles,
149
+ }
150
+ }
151
+
152
+ process.stdout.write(JSON.stringify(snapshot) + '\n')
@@ -0,0 +1,171 @@
1
+ // handoff — the context-handoff-bundle seam. Baton never re-implements the
2
+ // bundle format: it writes a structured notes file, calls the CLI as an argv
3
+ // subprocess (`save --repo-local` inside the worktree so the next agent finds
4
+ // the bundle in its cwd), validates the bundle, and later `load`s the resume.
5
+ import { spawnSync } from 'node:child_process'
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+ import { scrub } from './runner.mjs'
9
+
10
+ const MIN_VERSION = [0, 4, 0]
11
+
12
+ let resolved = null
13
+
14
+ // `context-handoff-bundle` on PATH (a real exe from pip's Scripts dir on
15
+ // Windows, a script elsewhere), else `python -m context_handoff_bundle`.
16
+ export function resolveChb() {
17
+ if (resolved) return resolved
18
+ const chbEnv = process.env.LEG_CHB_BIN || process.env.BATON_CHB_BIN;
19
+ if (chbEnv) {
20
+ resolved = { bin: chbEnv, prefix: [] }
21
+ return resolved
22
+ }
23
+ const direct = spawnSync('context-handoff-bundle', ['--help'], { windowsHide: true, encoding: 'utf8', timeout: 20000 })
24
+ if (!direct.error && direct.status === 0) {
25
+ resolved = { bin: 'context-handoff-bundle', prefix: [] }
26
+ return resolved
27
+ }
28
+ for (const py of ['python', 'python3', 'py']) {
29
+ const r = spawnSync(py, ['-m', 'context_handoff_bundle', '--help'], { windowsHide: true, encoding: 'utf8', timeout: 20000 })
30
+ if (!r.error && r.status === 0) {
31
+ resolved = { bin: py, prefix: ['-m', 'context_handoff_bundle'] }
32
+ return resolved
33
+ }
34
+ }
35
+ throw new Error('context-handoff-bundle not found: pip install -U context-handoff-bundle')
36
+ }
37
+
38
+ export function chb(args, { cwd, timeout = 120000 } = {}) {
39
+ const { bin, prefix } = resolveChb()
40
+ const r = spawnSync(bin, [...prefix, ...args], { cwd, windowsHide: true, encoding: 'utf8', timeout, env: process.env })
41
+ if (r.error) throw new Error(`context-handoff-bundle ${args[0]} failed to start: ${r.error.message}`)
42
+ return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
43
+ }
44
+
45
+ // Version from package metadata (works on releases without --version).
46
+ export function chbVersion() {
47
+ const v = chb(['--version'])
48
+ let text = v.status === 0 ? v.stdout.trim().split(/\s+/).pop() : null
49
+ if (!text) {
50
+ const r = spawnSync('python', ['-c', "from importlib.metadata import version; print(version('context-handoff-bundle'))"], { windowsHide: true, encoding: 'utf8', timeout: 20000 })
51
+ text = r.status === 0 ? r.stdout.trim() : null
52
+ }
53
+ return text
54
+ }
55
+
56
+ export function versionOk(v = chbVersion()) {
57
+ if (!v) return false
58
+ const parts = v.split('.').map((x) => parseInt(x, 10))
59
+ for (let i = 0; i < 3; i++) {
60
+ if ((parts[i] ?? 0) > MIN_VERSION[i]) return true
61
+ if ((parts[i] ?? 0) < MIN_VERSION[i]) return false
62
+ }
63
+ return true
64
+ }
65
+
66
+ function bullets(items) {
67
+ return items.filter(Boolean).map((x) => `- ${String(x).replace(/\r?\n/g, ' ').trim()}`)
68
+ }
69
+
70
+ // Notes in the CLI's own section vocabulary (Scope / Findings / Opportunities /
71
+ // Open questions / Evidence anchors) carrying Baton's four parts: Task, Done so
72
+ // far, Diff, Open findings. Anything not under a known heading is dropped by
73
+ // the parser, so every Baton line lives under one of those five.
74
+ export function buildNotes({ card, station, leg, entry, run, progress = '', lastMessage = null, diff = null, diffStat = '', changedFiles = [], extra = [] }) {
75
+ const outcome = run?.outcome ?? 'handoff'
76
+ const signal = run?.signal && run.signal !== 'none' ? ` (${run.signal})` : ''
77
+ const progressLines = progress.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(-40)
78
+ const diffLine = diff
79
+ ? `Diff since leg start: ${diff.files ?? 0} file(s) changed${diff.head_at_start && diff.head && diff.head !== diff.head_at_start ? `, HEAD moved ${diff.head_at_start.slice(0, 7)} → ${diff.head.slice(0, 7)}` : ''}${diffStat ? `; ${diffStat.trim().split(/\r?\n/).pop()}` : ''}`
80
+ : 'Diff since leg start: (no git evidence)'
81
+ const lines = [
82
+ '## Scope',
83
+ '',
84
+ `Task: ${card.task}`,
85
+ `Card ${card.card_id}, station ${station.name}, leg ${leg} (${entry?.adapter ?? run?.adapter ?? 'agent'}) stopped with outcome ${outcome}${signal}. The next agent resumes in the same worktree.`,
86
+ '',
87
+ '## Projects mentioned',
88
+ '',
89
+ `- ${card.card_id}`,
90
+ '',
91
+ '## Findings',
92
+ '',
93
+ ...bullets(progressLines.length ? progressLines.map((l, i) => `Done so far ${i + 1}: ${l}`) : ['Done so far: (no PROGRESS.md entries from the previous agent)']),
94
+ ...bullets([lastMessage ? `Last message from the previous agent: ${scrub(String(lastMessage)).slice(0, 600)}` : null]),
95
+ ...bullets([diffLine]),
96
+ ...bullets(changedFiles.slice(0, 50).map((f) => `Diff touches ${f}`)),
97
+ '',
98
+ '## Opportunities',
99
+ '',
100
+ ...bullets([
101
+ 'Next agent: read .leg/PROGRESS.md and .leg/CONTRACT.md, continue from the last done step, then write .leg/DONE.',
102
+ progressLines.length ? null : 'Next agent: the previous run left no progress notes; check the diff first.',
103
+ ]),
104
+ '',
105
+ '## Open questions',
106
+ '',
107
+ ...bullets([
108
+ `Open findings: previous leg ended ${outcome}${signal}: ${run?.reason ?? 'no reason recorded'}`,
109
+ run?.exit_code !== undefined && run?.exit_code !== null ? `Open findings: exit code ${run.exit_code}` : null,
110
+ ...extra.map((e) => `Open findings: ${e}`),
111
+ ]),
112
+ '',
113
+ '## Evidence anchors',
114
+ '',
115
+ ...bullets(changedFiles.slice(0, 50)),
116
+ ...bullets(['.leg/PROGRESS.md', '.leg/CONTRACT.md']),
117
+ '',
118
+ ]
119
+ return scrub(lines.join('\n'))
120
+ }
121
+
122
+ function tailFile(path, lines = 15) {
123
+ if (!existsSync(path)) return ''
124
+ return readFileSync(path, 'utf8').trim().split('\n').slice(-lines).join(' | ')
125
+ }
126
+
127
+ export function ensureExcluded(repo, pattern) {
128
+ const f = join(repo, '.git', 'info', 'exclude')
129
+ try {
130
+ const cur = existsSync(f) ? readFileSync(f, 'utf8') : ''
131
+ if (!cur.split(/\r?\n/).includes(pattern)) appendFileSync(f, `${cur.endsWith('\n') || !cur ? '' : '\n'}${pattern}\n`)
132
+ } catch {}
133
+ }
134
+
135
+ // Writes the notes, saves the bundle repo-local in the worktree, validates it.
136
+ // Returns { bundle_id, path, notes_path, quality, score } (throws on failure).
137
+ export function writeHandoff({ card, station, leg, entry, run, worktree, runDir, extra = [], changedFiles = [], diffStat = '' }) {
138
+ const pPath = existsSync(join(worktree, '.leg', 'PROGRESS.md')) ? join(worktree, '.leg', 'PROGRESS.md') : join(worktree, '.baton', 'PROGRESS.md');
139
+ const progress = existsSync(pPath) ? readFileSync(pPath, 'utf8') : '';
140
+ let lastMessage = null
141
+ if (runDir && existsSync(join(runDir, 'last.md'))) lastMessage = readFileSync(join(runDir, 'last.md'), 'utf8')
142
+ if (!lastMessage && run?.last_message) lastMessage = run.last_message
143
+ const stderrTail = runDir ? tailFile(join(runDir, 'err.log')) : ''
144
+ const notes = buildNotes({
145
+ card, station, leg, entry, run, progress, lastMessage, diff: run?.diff ?? null, diffStat, changedFiles,
146
+ extra: [...extra, stderrTail && run?.exit_code !== 0 ? `stderr tail: ${scrub(stderrTail).slice(0, 800)}` : null].filter(Boolean),
147
+ })
148
+ const legDir = join(worktree, '.leg')
149
+ mkdirSync(legDir, { recursive: true })
150
+ const notesPath = join(legDir, `handoff-${station.name}-leg${leg}.md`)
151
+ writeFileSync(notesPath, notes)
152
+ if (card.repo) ensureExcluded(card.repo, '.context-handoffs/')
153
+ const slug = `baton-${card.card_id}-${station.name}-leg${leg}`.toLowerCase().replace(/[^a-z0-9-]+/g, '-').slice(0, 80)
154
+ const title = `baton ${card.card_id} ${station.name} leg ${leg} ${entry?.adapter ?? run?.adapter ?? 'agent'}`
155
+ const save = chb(['save', '--repo-local', '--title', title, '--slug', slug, '--notes', notesPath, '--tag', 'baton'], { cwd: worktree })
156
+ if (save.status !== 0) throw new Error(`context-handoff-bundle save failed (exit ${save.status}): ${scrub(save.stderr || save.stdout).slice(0, 500)}`)
157
+ let out
158
+ try { out = JSON.parse(save.stdout) } catch { throw new Error(`context-handoff-bundle save printed no JSON: ${scrub(save.stdout).slice(0, 300)}`) }
159
+ const bundleId = out.bundle_id
160
+ const bundlePath = join(worktree, '.context-handoffs', bundleId)
161
+ const validate = chb(['validate', bundlePath], { cwd: worktree })
162
+ if (validate.status !== 0) throw new Error(`context-handoff-bundle validate failed: ${scrub(validate.stderr || validate.stdout).slice(0, 500)}`)
163
+ return { bundle_id: bundleId, path: bundlePath, notes_path: notesPath, quality: out.quality ?? null, score: out.score ?? null }
164
+ }
165
+
166
+ // The resume text the next leg's prompt starts with.
167
+ export function loadResume(worktree, query = 'latest') {
168
+ const r = chb(['load', query], { cwd: worktree })
169
+ if (r.status !== 0) throw new Error(`context-handoff-bundle load failed (exit ${r.status}): ${scrub(r.stderr || r.stdout).slice(0, 500)}`)
170
+ return r.stdout
171
+ }
package/src/hook.mjs ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // hook — the process Claude Code runs for a Baton session's hooks and status
3
+ // line (wired by src/taps/claude.mjs through `--settings`). Reads the JSON
4
+ // payload on stdin, updates the session record, exits 0 always: a broken hook
5
+ // must never stall the user's session.
6
+ // node hook.mjs claude-hook --session <id>
7
+ // node hook.mjs claude-statusline --session <id>
8
+ // The status line entry records rate_limits when a Claude Code build runs it
9
+ // (2.1.268 does not; see src/taps/claude-usage.mjs) and prints one Baton line.
10
+ import { appendFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { handleHook, handleStatusline } from './taps/claude.mjs'
13
+ import { sessionDir } from './sessions.mjs'
14
+ import { captureLive } from './live-capture.mjs'
15
+
16
+ function readStdin() {
17
+ return new Promise((resolve) => {
18
+ let d = ''
19
+ const t = setTimeout(() => resolve(d), 4000)
20
+ process.stdin.setEncoding('utf8')
21
+ process.stdin.on('data', (c) => { d += c })
22
+ process.stdin.on('end', () => { clearTimeout(t); resolve(d) })
23
+ process.stdin.on('error', () => { clearTimeout(t); resolve(d) })
24
+ })
25
+ }
26
+
27
+ const [kind, ...rest] = process.argv.slice(2)
28
+ const i = rest.indexOf('--session')
29
+ const sessionId = i !== -1 ? rest[i + 1] : null
30
+
31
+ try {
32
+ const raw = await readStdin()
33
+ let payload = {}
34
+ try { payload = JSON.parse(raw) } catch {}
35
+ if (!sessionId) process.exit(0)
36
+ if (kind === 'claude-hook') {
37
+ const line = handleHook(sessionId, payload)
38
+ try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} ${payload.hook_event_name ?? '?'} ${line}\n`) } catch {}
39
+ // the first real StopFailure per error kind is kept as evidence (never a simulated one)
40
+ if (payload.hook_event_name === 'StopFailure' && payload.error) {
41
+ try { captureLive('claude', String(payload.error), payload, { sessionId }) } catch {}
42
+ }
43
+ } else if (kind === 'claude-statusline') {
44
+ const { text } = handleStatusline(sessionId, payload)
45
+ try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} statusline rate_limits=${JSON.stringify(payload.rate_limits ?? null)}\n`) } catch {}
46
+ process.stdout.write(text + '\n')
47
+ }
48
+ } catch {}
49
+ process.exit(0)
package/src/land.mjs ADDED
@@ -0,0 +1,106 @@
1
+ // land — the land station handler the orchestrator calls, and the Land button
2
+ // on a terminal card. `ff` mode runs the merge queue (src/mergequeue.mjs); `pr`
3
+ // mode opens a pull request through the gh stub (src/stations/pr.mjs) and
4
+ // parks the card for a human. A terminal session in its own worktree lands its
5
+ // branch (baton/<session-id>) through the same queue.
6
+ import { join } from 'node:path'
7
+ import { existsSync } from 'node:fs'
8
+ import { spawnSync } from 'node:child_process'
9
+ import { land } from './mergequeue.mjs'
10
+ import { openPr } from './stations/pr.mjs'
11
+ import { cardDir, ledgerAppend } from './store.mjs'
12
+ import { remove as removeWorktree, ensureExcludeEntries } from './worktree.mjs'
13
+ import { appendEvent, writeLand, appendLanding } from './sessions.mjs'
14
+ import { scrub } from './redact.mjs'
15
+
16
+ export async function landCard(card, worktree) {
17
+ const warn = (msg) => ledgerAppend(card.card_id, { type: 'land_warning', station: card.station, leg: 0, summary: msg })
18
+ if (card.land_mode === 'pr') {
19
+ const r = openPr({ card: { ...card, worktree }, runDir: join(cardDir(card.card_id), 'land') })
20
+ if (!r.ok) return { landed: false, bounced: false, pr: false, reason: `pr mode: ${r.error}` }
21
+ return { landed: false, pr: true, url: r.url, summary: `pull request opened: ${r.url ?? '(no url)'}`, argv: r.argv }
22
+ }
23
+ return land(card, worktree, { onWarning: warn })
24
+ }
25
+
26
+ // ---- terminals ----
27
+ const inFlight = new Set()
28
+ export function landingNow(id) { return inFlight.has(id) }
29
+
30
+ function git(cwd, args) {
31
+ const r = spawnSync('git', args, { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
32
+ return { ok: r.status === 0, out: (r.stdout ?? '').trim() }
33
+ }
34
+
35
+ // Why this session cannot land right now, or null.
36
+ export function landBlocker(s) {
37
+ if (!s.worktree) return 'this terminal works in the checkout itself: there is no branch of its own to land'
38
+ if (!s.worktree.base) return `${s.worktree.branch} was cut from a detached HEAD: there is no branch to land it onto`
39
+ if (!existsSync(s.worktree.path)) return `the worktree ${s.worktree.path} is gone`
40
+ if (inFlight.has(s.session_id)) return 'already landing'
41
+ return null
42
+ }
43
+
44
+ // The Land button: commit what the agent left in the worktree, rebase the
45
+ // branch onto its base, run the tests, fast-forward the base (never a merge
46
+ // commit); a bounce carries the reason. land.json holds the card's state,
47
+ // landings.jsonl who landed what. Resolves with the queue's result.
48
+ export async function landSession(session, { by = 'local' } = {}) {
49
+ const id = session.session_id
50
+ const { path, branch, base } = session.worktree
51
+ inFlight.add(id)
52
+ writeLand(id, { state: 'landing', at: new Date().toISOString(), by, branch, base })
53
+ appendEvent(id, { type: 'land_requested', by, summary: `land requested by ${by}: ${branch} onto ${base}` })
54
+ let r
55
+ try {
56
+ await new Promise((res) => setImmediate(res)) // the caller answers the request first
57
+ ensureExcludeEntries(session.repo) // the commit step adds everything else the agent left
58
+ const task = String(session.task ?? 'terminal session').split('\n')[0].slice(0, 60)
59
+ r = await land({ card_id: id, repo: session.repo, trunk: base, title: `${session.agent} ${id.split('-').pop()}: ${task}`, test_command: null }, path, {
60
+ allowDirtyRoot: true,
61
+ onWarning: (msg) => appendEvent(id, { type: 'land_warning', by, summary: msg }),
62
+ })
63
+ } catch (err) {
64
+ r = { landed: false, bounced: true, reason: 'error', detail: String(err.message) }
65
+ } finally {
66
+ inFlight.delete(id)
67
+ }
68
+ const at = new Date().toISOString()
69
+ if (r.landed && r.sha === r.sha_before) {
70
+ writeLand(id, { state: 'noop', at, by, branch, base })
71
+ appendEvent(id, { type: 'land_noop', by, summary: `nothing to land: ${branch} has no changes beyond ${base}` })
72
+ } else if (r.landed) {
73
+ const commits = git(session.repo, ['rev-list', '--reverse', `${r.sha_before}..${r.sha}`]).out.split('\n').filter(Boolean)
74
+ const tested = Boolean(r.tests)
75
+ appendLanding({ repo: session.repo, trunk: base, session_id: id, agent: session.agent, account: session.account, by, sha: r.sha, sha_before: r.sha_before, commits, files: r.files, insertions: r.insertions, deletions: r.deletions, tested })
76
+ writeLand(id, { state: 'landed', at, by, branch, base, sha: r.sha, files: r.files, insertions: r.insertions, deletions: r.deletions, tested, summary: r.summary })
77
+ appendEvent(id, { type: 'landed', by, summary: `${r.summary} · Land pressed by ${by}` })
78
+ } else {
79
+ const detail = scrub(String(r.detail ?? '')).slice(0, 4000)
80
+ writeLand(id, { state: 'bounced', at, by, branch, base, reason: r.reason, detail, files: r.files ?? [] })
81
+ appendEvent(id, { type: 'bounced', by, summary: `land bounced (${r.reason}): ${detail.split('\n')[0].slice(0, 200)}`, body: detail })
82
+ }
83
+ return r
84
+ }
85
+
86
+ // Removing a finished session takes its worktree and branch with it only when
87
+ // nothing is lost: a clean worktree whose branch is already in its base.
88
+ export function pruneSessionWorktree(s) {
89
+ const wt = s.worktree
90
+ if (!wt || !existsSync(wt.path)) return { removed: false, reason: 'no worktree on disk' }
91
+ const status = git(wt.path, ['status', '--porcelain'])
92
+ // a status check that fails must not read as clean
93
+ if (!status.ok) return { removed: false, reason: `cannot verify ${wt.path}: git status failed` }
94
+ if (status.out) return { removed: false, reason: `uncommitted changes in ${wt.path}` }
95
+ // "already landed" is true not just for a fast-forward ancestor but also for a
96
+ // squash- or rebase-merge and a cherry-pick: git cherry marks a commit whose
97
+ // patch is already upstream with '-', so no '+' line means the work is in base
98
+ const cherry = wt.base ? git(s.repo, ['cherry', wt.base, wt.branch]) : { ok: false, out: '' }
99
+ const ancestor = wt.base && git(s.repo, ['merge-base', '--is-ancestor', wt.branch, wt.base]).ok
100
+ const merges = wt.base ? git(s.repo, ['rev-list', '--merges', `${wt.base}..${wt.branch}`]) : { ok: false, out: '' }
101
+ const patchEquivalent = cherry.ok && merges.ok && !merges.out && !cherry.out.split('\n').some((l) => l.startsWith('+'))
102
+ const merged = wt.base && (ancestor || patchEquivalent)
103
+ if (!merged) return { removed: false, reason: `${wt.branch} has commits that are not on ${wt.base ?? 'any branch'}` }
104
+ const removed = removeWorktree(s.repo, s.session_id, { deleteBranch: true })
105
+ return { removed: removed.removed, branchDeleted: removed.branchDeleted, branch: wt.branch }
106
+ }