@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/usage.mjs ADDED
@@ -0,0 +1,179 @@
1
+ // usage — per (agent, account) limit state and the handoff chooser.
2
+ // $BATON_HOME/usage/<agent>--<account>.json:
3
+ // { agent, account, five_hour: {pct, resets_at}|null, seven_day: {...}|null,
4
+ // limited_until: epoch-seconds|null, limited_reason, limited_at,
5
+ // source, observed_at, available_at, updated_at }
6
+ // Sources: claude statusline JSON (rate_limits.*) and StopFailure rate_limit;
7
+ // codex app-server/rollout rate limits (identified by window duration) and
8
+ // the usage-limit error; agy only the wall itself (no percent exposed).
9
+ import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { home } from './store.mjs'
12
+ import { writeJsonAtomic, withFileLock } from './fsx.mjs'
13
+ import { AGENTS } from './sessions.mjs'
14
+
15
+ export const WARN_PCT = Number((process.env.LEG_WARN_PCT || process.env.BATON_WARN_PCT) || 85)
16
+ // A limit hit with no reset time from the agent: assume the 5-hour window.
17
+ const DEFAULT_LIMIT_S = 5 * 3600
18
+
19
+ export function usageDir() { return join(home(), 'usage') }
20
+ export function usageFile(agent, account = 'default') { return join(usageDir(), `${agent}--${account}.json`) }
21
+
22
+ export function readUsage(agent, account = 'default') {
23
+ const f = usageFile(agent, account)
24
+ if (!existsSync(f)) return emptyUsage(agent, account)
25
+ try { return { ...emptyUsage(agent, account), ...JSON.parse(readFileSync(f, 'utf8')) } } catch { return emptyUsage(agent, account) }
26
+ }
27
+
28
+ function emptyUsage(agent, account) {
29
+ return { agent, account, five_hour: null, seven_day: null, limited_until: null, limited_reason: null, limited_at: null, source: null, observed_at: null, available_at: null, updated_at: null }
30
+ }
31
+
32
+ export function listUsage() {
33
+ const dir = usageDir()
34
+ if (!existsSync(dir)) return []
35
+ return readdirSync(dir).filter((n) => n.endsWith('.json')).sort().map((n) => { try { return JSON.parse(readFileSync(join(dir, n), 'utf8')) } catch { return null } }).filter(Boolean)
36
+ }
37
+
38
+ function write(u) {
39
+ mkdirSync(usageDir(), { recursive: true })
40
+ writeJsonAtomic(usageFile(u.agent, u.account), { ...u, updated_at: new Date().toISOString() })
41
+ }
42
+
43
+ // read → mutate → write under one cross-process lock, so a percentage write
44
+ // from the poller/status-line never erases a wall a hook set in the same moment
45
+ // (that race handed the baton straight back to a walled login).
46
+ function mutate(agent, account, fn) {
47
+ mkdirSync(usageDir(), { recursive: true })
48
+ return withFileLock(usageFile(agent, account) + '.lock', () => {
49
+ const u = readUsage(agent, account)
50
+ const out = fn(u)
51
+ if (out === false) return u
52
+ const next = out ?? u
53
+ write(next)
54
+ return next
55
+ })
56
+ }
57
+
58
+ // windows: { five_hour: {pct, resets_at}|null, seven_day: ... }
59
+ // `available` must be an explicit backend answer. Percentages cannot clear a
60
+ // wall: Codex's rate-limit schema says null availability is unknown, even when
61
+ // a window is below 100%.
62
+ export function recordUsage(agent, account, windows, source, { observed_at = new Date().toISOString(), available = null } = {}) {
63
+ let applied = false
64
+ const value = mutate(agent, account, (u) => {
65
+ const seenMs = Date.parse(observed_at)
66
+ const currentMs = Date.parse(u.observed_at ?? u.updated_at ?? 0)
67
+ if (Number.isFinite(seenMs) && Number.isFinite(currentMs) && seenMs < currentMs) return false
68
+ if (windows.five_hour !== undefined) u.five_hour = windows.five_hour
69
+ if (windows.seven_day !== undefined) u.seven_day = windows.seven_day
70
+ u.source = source
71
+ u.observed_at = Number.isFinite(seenMs) ? new Date(seenMs).toISOString() : new Date().toISOString()
72
+ applied = true
73
+ if (available === true) u.available_at = u.observed_at
74
+ // A window that has reset clears an old wall.
75
+ const nowS = Math.floor(Date.now() / 1000)
76
+ if (u.limited_until && u.limited_until <= nowS) { u.limited_until = null; u.limited_reason = null; u.limited_at = null }
77
+ const wallMs = Date.parse(u.limited_at ?? u.updated_at ?? 0)
78
+ if (u.limited_until && available === true && (!Number.isFinite(wallMs) || !Number.isFinite(seenMs) || seenMs >= wallMs)) {
79
+ u.limited_until = null
80
+ u.limited_reason = null
81
+ u.limited_at = null
82
+ } else if (available === false) {
83
+ const windows = [u.five_hour, u.seven_day].filter((w) => w && Number.isFinite(w.resets_at) && w.resets_at > nowS)
84
+ windows.sort((a, b) => (b.pct ?? 0) - (a.pct ?? 0))
85
+ u.limited_until = windows.length ? windows[0].resets_at : nowS + DEFAULT_LIMIT_S
86
+ u.limited_reason = 'usage_limit_exceeded'
87
+ u.limited_at = u.observed_at
88
+ }
89
+ return u
90
+ })
91
+ return { ...value, usage_applied: applied }
92
+ }
93
+
94
+ export function markLimited(agent, account, { resets_at = null, reason = 'limit', source, observed_at = new Date().toISOString() } = {}) {
95
+ let applied = false
96
+ const value = mutate(agent, account, (u) => {
97
+ const seenMs = Date.parse(observed_at)
98
+ const currentMs = Math.max(Date.parse(u.limited_at ?? 0) || 0, Date.parse(u.available_at ?? 0) || 0)
99
+ if (Number.isFinite(seenMs) && Number.isFinite(currentMs) && seenMs < currentMs) return false
100
+ const nowS = Math.floor(Date.now() / 1000)
101
+ let until = Number.isFinite(resets_at) && resets_at > nowS ? resets_at : null
102
+ if (!until) {
103
+ // Prefer the window that actually walled (highest used %) over the soonest
104
+ // reset: a weekly wall (100% / days away) must not be recorded as the
105
+ // 5-hour window's near reset, or the account is handed back and re-walls.
106
+ const windows = [u.five_hour, u.seven_day].filter((w) => w && Number.isFinite(w.resets_at) && w.resets_at > nowS)
107
+ windows.sort((a, b) => (b.pct ?? 0) - (a.pct ?? 0))
108
+ until = windows.length ? windows[0].resets_at : nowS + DEFAULT_LIMIT_S
109
+ }
110
+ u.limited_until = until
111
+ u.limited_reason = reason
112
+ u.limited_at = Number.isFinite(seenMs) ? new Date(seenMs).toISOString() : new Date().toISOString()
113
+ u.observed_at = u.limited_at
114
+ if (source) u.source = source
115
+ applied = true
116
+ return u
117
+ })
118
+ return { ...value, wall_applied: applied }
119
+ }
120
+
121
+ export function clearLimited(agent, account) {
122
+ return mutate(agent, account, (u) => { u.limited_until = null; u.limited_reason = null; u.limited_at = null; return u })
123
+ }
124
+
125
+ export function usageIsStale(u, nowMs = Date.now(), maxAgeMs = 5 * 60 * 1000) {
126
+ const observedMs = Date.parse(u?.observed_at ?? u?.updated_at ?? 0)
127
+ return !Number.isFinite(observedMs) || nowMs - observedMs > maxAgeMs
128
+ }
129
+
130
+ export function isAvailable(u, nowS = Math.floor(Date.now() / 1000)) {
131
+ return !(u.limited_until && u.limited_until > nowS)
132
+ }
133
+
134
+ // Highest used percentage across the windows we know (for warnings).
135
+ export function pressure(u) {
136
+ const p = [u.five_hour?.pct, u.seven_day?.pct].filter((x) => Number.isFinite(x))
137
+ return p.length ? Math.max(...p) : null
138
+ }
139
+
140
+ // Which window is closest to the wall, for the warning text.
141
+ export function hottest(u) {
142
+ const w = [['5h', u.five_hour], ['7d', u.seven_day]].filter(([, x]) => x && Number.isFinite(x.pct))
143
+ w.sort((a, b) => b[1].pct - a[1].pct)
144
+ return w.length ? { window: w[0][0], ...w[0][1] } : null
145
+ }
146
+
147
+ // The chain after (agent, account): other accounts of the same agent first,
148
+ // then every other agent in the saved order. The order is an absolute priority
149
+ // list, not a rotation anchored on the current agent: an agent parked last
150
+ // stays last whichever agent the terminal started on (codex → claude → agy
151
+ // hands claude to codex, never to agy first).
152
+ // accounts: { claude: ['default', 'work'], codex: ['default'], agy: ['default'] }
153
+ export function candidates({ agent, account = 'default', accounts, order = AGENTS }) {
154
+ const out = []
155
+ for (const a of accounts[agent] ?? ['default']) if (a !== account) out.push({ agent, account: a })
156
+ for (const ag of order) if (ag !== agent) for (const a of accounts[ag] ?? ['default']) out.push({ agent: ag, account: a })
157
+ return out
158
+ }
159
+
160
+ // → { next: {agent, account} | null, out: [{agent, account, resets_at}] sorted by reset }
161
+ export function chooseNext({ agent, account, accounts, installed, order = AGENTS, nowS = Math.floor(Date.now() / 1000) }) {
162
+ const out = []
163
+ for (const c of candidates({ agent, account, accounts, order })) {
164
+ if (installed && installed[c.agent] === false) continue
165
+ const u = readUsage(c.agent, c.account)
166
+ if (isAvailable(u, nowS)) return { next: c, out }
167
+ out.push({ ...c, resets_at: u.limited_until, reason: u.limited_reason })
168
+ }
169
+ out.sort((a, b) => (a.resets_at ?? Infinity) - (b.resets_at ?? Infinity))
170
+ return { next: null, out }
171
+ }
172
+
173
+ export function fmtReset(epochS) {
174
+ if (!Number.isFinite(epochS)) return 'unknown'
175
+ const d = new Date(epochS * 1000)
176
+ const mins = Math.round((epochS * 1000 - Date.now()) / 60000)
177
+ const rel = mins < 60 ? `${Math.max(0, mins)}m` : mins < 48 * 60 ? `${Math.floor(mins / 60)}h${mins % 60}m` : `${Math.floor(mins / 1440)}d`
178
+ return `${d.toLocaleString()} (in ${rel})`
179
+ }
package/src/wait.mjs ADDED
@@ -0,0 +1,30 @@
1
+ // wait — when every option is walled, the terminal stays open and counts down
2
+ // to the first reset instead of exiting. Pure timing here; src/attach.mjs
3
+ // wires the countdown line, the session record and the restart from the
4
+ // bundle around it.
5
+ export function fmtCountdown(secs) {
6
+ const s = Math.max(0, Math.floor(secs))
7
+ const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const r = s % 60
8
+ return h ? `${h}h${String(m).padStart(2, '0')}m${String(r).padStart(2, '0')}s` : m ? `${m}m${String(r).padStart(2, '0')}s` : `${r}s`
9
+ }
10
+
11
+ // Resolve 'ready' once resetsAt (epoch seconds) has passed, 'cancelled' when
12
+ // the signal aborts or isCancelled() says so. onTick(remainingSecs) runs every
13
+ // tickMs and once at the start. A null resetsAt resolves 'ready' at once.
14
+ export function waitForReset({ resetsAt, tickMs = 1000, signal = null, isCancelled = () => false, onTick = () => {} } = {}) {
15
+ return new Promise((resolvePromise) => {
16
+ let timer = null
17
+ const finish = (r) => { if (timer) clearInterval(timer); signal?.removeEventListener?.('abort', onAbort); resolvePromise(r) }
18
+ const onAbort = () => finish('cancelled')
19
+ const check = () => {
20
+ if (signal?.aborted || isCancelled()) return finish('cancelled')
21
+ const remaining = Number.isFinite(resetsAt) ? resetsAt - Date.now() / 1000 : 0
22
+ if (remaining <= 0) return finish('ready')
23
+ onTick(remaining)
24
+ }
25
+ signal?.addEventListener?.('abort', onAbort, { once: true })
26
+ check()
27
+ // the interval is what keeps the process alive while it waits: never unref it
28
+ timer = setInterval(check, tickMs)
29
+ })
30
+ }
@@ -0,0 +1,207 @@
1
+ // worktree — one git worktree per card under <repo>/.baton-worktrees/<cardId>,
2
+ // on branch baton/<cardId>. Every git call runs with MSYS_NO_PATHCONV=1 (see
3
+ // git-snapshot.mjs) so Git Bash on Windows never rewrites an absolute path
4
+ // argument. Never pushes; never deletes outside <repo>/.baton-worktrees/.
5
+ import { execFileSync } from 'node:child_process'
6
+ import { existsSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
7
+ import { join, resolve, sep, isAbsolute } from 'node:path'
8
+ import { homedir } from 'node:os'
9
+ import { canonPath, realPath } from './fsx.mjs'
10
+
11
+ function git(cwd, argv) {
12
+ return execFileSync('git', argv, { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
13
+ }
14
+
15
+ // git reports long, real paths; callers may hand us short or symlinked ones
16
+ function samePath(a, b) {
17
+ return canonPath(a) === canonPath(b)
18
+ }
19
+
20
+ function isUnder(child, parent) {
21
+ const c = canonPath(child)
22
+ const p = canonPath(parent)
23
+ return c === p || c.startsWith(p + sep)
24
+ }
25
+
26
+ function isNestedWorktreePath(resolved) {
27
+ const segments = resolved.split(sep)
28
+ return segments.some((s) => { const l = process.platform === 'win32' ? s.toLowerCase() : s; return l === '.leg-worktrees' || l === '.baton-worktrees'; })
29
+ }
30
+
31
+ export function worktreePath(repo, cardId) {
32
+ // long real path: git reports worktrees that way, and the short 8.3 form a
33
+ // caller may pass must never become the stored worktree path
34
+ const root = realPath(repo); const oldPath = join(root, '.baton-worktrees', cardId); if (existsSync(oldPath)) return oldPath; return join(root, '.leg-worktrees', cardId);
35
+ }
36
+
37
+ export function branchName(cardId) {
38
+ return `leg/${cardId}`
39
+ }
40
+
41
+ export function validateRepo(repo) {
42
+ const resolved = realPath(repo)
43
+ 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')))
44
+ if (samePath(resolved, legHome)) {
45
+ throw new Error(`refusing to use LEG_HOME as a repo: ${resolved}`)
46
+ }
47
+ if (isNestedWorktreePath(resolved)) {
48
+ throw new Error('refusing a nested worktree path as a repo')
49
+ }
50
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
51
+ throw new Error(`not a directory: ${resolved}`)
52
+ }
53
+ try {
54
+ git(resolved, ['rev-parse', '--show-toplevel'])
55
+ } catch {
56
+ throw new Error(`not a git repo: ${resolved}`)
57
+ }
58
+ try {
59
+ git(resolved, ['rev-parse', 'HEAD'])
60
+ } catch {
61
+ throw new Error(`repo has no commits: ${resolved}`)
62
+ }
63
+ return resolved
64
+ }
65
+
66
+ function parseWorktreeList(output) {
67
+ const entries = []
68
+ let current = null
69
+ for (const line of output.split(/\r?\n/)) {
70
+ if (line.startsWith('worktree ')) {
71
+ current = { path: resolve(line.slice('worktree '.length)), head: null, branch: null }
72
+ entries.push(current)
73
+ } else if (line.startsWith('HEAD ') && current) {
74
+ current.head = line.slice('HEAD '.length)
75
+ } else if (line.startsWith('branch ') && current) {
76
+ current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
77
+ }
78
+ }
79
+ return entries
80
+ }
81
+
82
+ export function list(repo) {
83
+ const resolved = resolve(repo)
84
+ const out = git(resolved, ['worktree', 'list', '--porcelain'])
85
+ return parseWorktreeList(out)
86
+ }
87
+
88
+ // Never committed by a landing: Baton's own directories, and the local state a
89
+ // DashClaw hook writes into every directory an agent runs in.
90
+ export function ensureExcludeEntries(repo) {
91
+ // a linked worktree or submodule has `.git` as a FILE, so the shared info dir
92
+ // must come from git, not from assuming <repo>/.git is a directory
93
+ let gitDir
94
+ try { gitDir = git(repo, ['rev-parse', '--git-common-dir']).trim() } catch { gitDir = join(repo, '.git') }
95
+ const infoDir = isAbsolute(gitDir) ? join(gitDir, 'info') : join(repo, gitDir, 'info')
96
+ mkdirSync(infoDir, { recursive: true })
97
+ const excludePath = join(infoDir, 'exclude')
98
+ // .env / .env.* so a Land never commits a secret the agent left in the
99
+ // worktree; the placeholder files are re-included, since `.env.*` otherwise
100
+ // swallows the .env.example an agent was asked to update (an exclude entry
101
+ // has no effect on a file the repo already tracks)
102
+ const needed = ['.leg-worktrees/', '.leg/', '.baton-worktrees/', '.baton/', '.context-handoffs/', '.dashclaw-local/', '.env', '.env.*', '!.env.example', '!.env.sample']
103
+ const content = existsSync(excludePath) ? readFileSync(excludePath, 'utf8') : ''
104
+ const lines = content.split(/\r?\n/)
105
+ const missing = needed.filter((n) => !lines.includes(n))
106
+ if (missing.length === 0) return
107
+ const joiner = content.length && !content.endsWith('\n') ? '\n' : ''
108
+ writeFileSync(excludePath, content + joiner + missing.join('\n') + '\n')
109
+ }
110
+
111
+ // The branch this repo actually calls its trunk: origin's default if there is
112
+ // one, else whatever the root checkout is on.
113
+ function defaultBranch(repo) {
114
+ for (const argv of [['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], ['symbolic-ref', '--short', 'HEAD']]) {
115
+ try { return git(repo, argv).trim().replace(/^origin\//, '') } catch {}
116
+ }
117
+ return null
118
+ }
119
+
120
+ export function ensure(repo, cardId, { trunk = 'main' } = {}) {
121
+ const resolvedRepo = validateRepo(repo)
122
+ const wtPath = worktreePath(resolvedRepo, cardId)
123
+ const branch = branchName(cardId)
124
+
125
+ const already = list(resolvedRepo).find((w) => samePath(w.path, wtPath))
126
+ if (already && existsSync(wtPath)) {
127
+ ensureExcludeEntries(resolvedRepo)
128
+ return { path: wtPath, branch, created: false }
129
+ }
130
+ // git still lists a worktree whose directory was deleted by hand; prune the
131
+ // stale admin entry so `worktree add` below does not refuse the path
132
+ if (already) git(resolvedRepo, ['worktree', 'prune'])
133
+
134
+ let branchExists = true
135
+ try {
136
+ git(resolvedRepo, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])
137
+ } catch {
138
+ branchExists = false
139
+ }
140
+
141
+ const result = { path: wtPath, branch, created: true }
142
+ if (branchExists) {
143
+ git(resolvedRepo, ['worktree', 'add', wtPath, branch])
144
+ } else {
145
+ // A card cut from HEAD because its trunk does not exist runs its whole
146
+ // agent chain and then bounces every land attempt until it fails: refuse
147
+ // here, while it has cost nothing, and name the branch this repo uses.
148
+ try {
149
+ git(resolvedRepo, ['rev-parse', '--verify', '--quiet', trunk])
150
+ } catch {
151
+ const actual = defaultBranch(resolvedRepo)
152
+ throw new Error(`trunk ${trunk} does not exist in ${resolvedRepo}${actual ? `; this repo's default branch is ${actual} (add the card with --trunk ${actual})` : ''}`)
153
+ }
154
+ git(resolvedRepo, ['worktree', 'add', '-b', branch, wtPath, trunk])
155
+ }
156
+
157
+ ensureExcludeEntries(resolvedRepo)
158
+ return result
159
+ }
160
+
161
+ export function remove(repo, cardId, { deleteBranch = false, force = false } = {}) {
162
+ const resolvedRepo = resolve(repo)
163
+ const wtPath = worktreePath(resolvedRepo, cardId)
164
+ const legWt = join(resolvedRepo, '.leg-worktrees'); const batonWt = join(resolvedRepo, '.baton-worktrees'); if (!isUnder(wtPath, legWt) && !isUnder(wtPath, batonWt)) { throw new Error(`refusing to remove a path outside .leg-worktrees: ${wtPath}`); }
165
+
166
+ const branch = branchName(cardId)
167
+ const already = list(resolvedRepo).find((w) => samePath(w.path, wtPath))
168
+ let removed = false
169
+ if (already) {
170
+ git(resolvedRepo, ['worktree', 'remove', ...(force ? ['--force'] : []), wtPath])
171
+ git(resolvedRepo, ['worktree', 'prune'])
172
+ removed = true
173
+ }
174
+
175
+ // `git branch -d` refuses an unmerged branch; only escalate to `-D` (which
176
+ // discards commits) when the caller explicitly forces it. branchUnmerged
177
+ // tells the caller work would be lost.
178
+ let branchDeleted = false
179
+ let branchUnmerged = false
180
+ if (deleteBranch) {
181
+ try {
182
+ git(resolvedRepo, ['branch', '-d', branch])
183
+ branchDeleted = true
184
+ } catch {
185
+ branchUnmerged = true
186
+ if (force) { try { git(resolvedRepo, ['branch', '-D', branch]); branchDeleted = true } catch { branchDeleted = false } }
187
+ }
188
+ }
189
+
190
+ return { removed, branchDeleted, branchUnmerged }
191
+ }
192
+
193
+ // Uncommitted files in a card's worktree (empty when the worktree is clean or
194
+ // absent). Used by the board's Remove to refuse discarding agent work silently.
195
+ // A git that cannot answer throws: "status failed" must never reach that guard
196
+ // as "clean", which is the reading that force-deletes the directory.
197
+ export function worktreeDirty(repo, cardId) {
198
+ const wtPath = worktreePath(realPath(repo), cardId)
199
+ if (!existsSync(wtPath)) return []
200
+ return git(wtPath, ['status', '--porcelain']).split(/\r?\n/).filter(Boolean)
201
+ }
202
+
203
+ export function isWorktreeOf(repo, cardId) {
204
+ const resolvedRepo = resolve(repo)
205
+ const wtPath = worktreePath(resolvedRepo, cardId)
206
+ return list(resolvedRepo).some((w) => samePath(w.path, wtPath))
207
+ }