@ucsandman/legcli 0.7.0 → 0.9.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 (116) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/NOTICE +8 -0
  3. package/README.md +601 -558
  4. package/bin/fake-agent.mjs +4 -4
  5. package/bin/leg.mjs +64 -34
  6. package/docs/DECISIONS.md +20 -2
  7. package/docs/ERRORS.md +71 -0
  8. package/docs/README.md +2 -0
  9. package/docs/REUSE.md +1 -1
  10. package/docs/VOCABULARY.md +21 -0
  11. package/docs/adapters.md +17 -3
  12. package/docs/board-guide.md +13 -0
  13. package/docs/cli-contracts.md +57 -3
  14. package/docs/concepts.md +42 -3
  15. package/docs/configuration.md +42 -2
  16. package/docs/faq.md +19 -0
  17. package/docs/getting-started.md +272 -251
  18. package/docs/harness.md +319 -0
  19. package/fixtures/limits/grok/grok-rate-limit.json +11 -0
  20. package/fixtures/live/agy/limit-agy-resource-exhausted.json +11 -0
  21. package/fixtures/verified.json +1 -1
  22. package/package.json +8 -4
  23. package/scripts/build-docs-site.mjs +15 -7
  24. package/scripts/check-branding.mjs +118 -0
  25. package/scripts/check-claims.mjs +1 -1
  26. package/scripts/license-sign.mjs +1 -1
  27. package/scripts/limits-table.mjs +1 -1
  28. package/scripts/live-limits.mjs +1 -1
  29. package/scripts/npm-publish-gate.mjs +114 -0
  30. package/scripts/probe.mjs +4 -3
  31. package/scripts/seed-fake-cards.mjs +4 -3
  32. package/scripts/seed-floor-board.mjs +5 -4
  33. package/scripts/seed-wes-board.mjs +5 -4
  34. package/scripts/stripe-setup.mjs +1 -1
  35. package/scripts/sync-harness-engine.mjs +159 -0
  36. package/scripts/sync-leg-agents.mjs +127 -0
  37. package/src/accounts.mjs +10 -2
  38. package/src/adapters/codex.mjs +1 -1
  39. package/src/adapters/grok.mjs +4 -7
  40. package/src/attach.mjs +162 -37
  41. package/src/auth.mjs +2 -2
  42. package/src/board/board.css +45 -17
  43. package/src/board/board.js +4 -4
  44. package/src/board/floor.js +2 -2
  45. package/src/board/sessions.js +181 -38
  46. package/src/bundle.mjs +54 -8
  47. package/src/chain.mjs +1 -1
  48. package/src/contract.mjs +4 -3
  49. package/src/fsx.mjs +5 -2
  50. package/src/handoff.mjs +6 -6
  51. package/src/harness/cli.mjs +281 -0
  52. package/src/harness/fingerprint.mjs +68 -0
  53. package/src/harness/index.mjs +407 -0
  54. package/src/harness/registry.mjs +124 -0
  55. package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
  56. package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
  57. package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
  58. package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
  59. package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
  60. package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
  61. package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
  62. package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
  63. package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
  64. package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
  65. package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
  66. package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
  67. package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
  68. package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
  69. package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
  70. package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
  71. package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
  72. package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
  73. package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
  74. package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
  75. package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
  76. package/src/hook.mjs +49 -49
  77. package/src/land.mjs +660 -47
  78. package/src/launcher.mjs +40 -27
  79. package/src/ledger.mjs +6 -6
  80. package/src/license.mjs +10 -9
  81. package/src/live-capture.mjs +1 -1
  82. package/src/mergequeue.mjs +6 -6
  83. package/src/orchestrator.mjs +28 -4
  84. package/src/preferences.mjs +63 -9
  85. package/src/redact.mjs +1 -1
  86. package/src/resume.mjs +17 -15
  87. package/src/runner.mjs +3 -3
  88. package/src/scheduler.mjs +1 -1
  89. package/src/server.mjs +69 -20
  90. package/src/session-detail.mjs +15 -1
  91. package/src/sessions.mjs +9 -5
  92. package/src/share.mjs +2 -2
  93. package/src/stations/agent.mjs +1 -1
  94. package/src/sync/dashclaw.mjs +4 -4
  95. package/src/synthesis.mjs +165 -0
  96. package/src/taps/agy.mjs +2 -2
  97. package/src/taps/claude-usage.mjs +1 -1
  98. package/src/taps/claude.mjs +170 -170
  99. package/src/taps/codex.mjs +286 -286
  100. package/src/taps/grok.mjs +251 -0
  101. package/src/trust.mjs +205 -36
  102. package/src/usage.mjs +5 -1
  103. package/src/worktree.mjs +5 -4
  104. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
  105. package/fixtures/live/agy/err.log +0 -0
  106. package/fixtures/live/agy/out.log +0 -1
  107. package/fixtures/live/agy/supervisor.log +0 -2
  108. package/fixtures/live/claude/err.log +0 -0
  109. package/fixtures/live/claude/out.log +0 -1
  110. package/fixtures/live/claude/supervisor.log +0 -2
  111. package/fixtures/live/codex/err.log +0 -1
  112. package/fixtures/live/codex/out.log +0 -8
  113. package/fixtures/live/codex/supervisor.log +0 -2
  114. package/fixtures/live/grok/err.log +0 -32
  115. package/fixtures/live/grok/out.log +0 -7
  116. package/fixtures/live/grok/supervisor.log +0 -2
package/src/land.mjs CHANGED
@@ -1,17 +1,19 @@
1
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'
2
+ // on a terminal card. Guarded state machine: canLand(worktreeId) validates all
3
+ // pre-conditions before landing is enabled. Two-phase landing: prepare dry-runs
4
+ // rebase and tests in a scratch worktree; atomic land updates trunk only when green,
5
+ // auto-pushes to origin, and writes ledger entries for both success and failure.
6
+ import { join, dirname } from 'node:path'
7
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, rmSync } from 'node:fs'
8
8
  import { spawnSync } from 'node:child_process'
9
- import { land } from './mergequeue.mjs'
9
+ import { createHash, randomBytes } from 'node:crypto'
10
+ import { land, resolveTestCommand, runTests, rootState, commitWorktree } from './mergequeue.mjs'
10
11
  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'
12
+ import { cardDir, ledgerAppend, home, readCard } from './store.mjs'
13
+ import { remove as removeWorktree, ensureExcludeEntries, branchName } from './worktree.mjs'
14
+ import { appendEvent, writeLand, appendLanding, listSessions, readSession, isActive, pidAlive } from './sessions.mjs'
14
15
  import { scrub } from './redact.mjs'
16
+ import { canonPath, withFileLock, writeJsonAtomic } from './fsx.mjs'
15
17
 
16
18
  export async function landCard(card, worktree) {
17
19
  const warn = (msg) => ledgerAppend(card.card_id, { type: 'land_warning', station: card.station, leg: 0, summary: msg })
@@ -23,13 +25,391 @@ export async function landCard(card, worktree) {
23
25
  return land(card, worktree, { onWarning: warn })
24
26
  }
25
27
 
26
- // ---- terminals ----
28
+ // ---- terminals & in-flight tracking ----
27
29
  const inFlight = new Set()
28
30
  export function landingNow(id) { return inFlight.has(id) }
29
31
 
30
32
  function git(cwd, args) {
31
33
  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() }
34
+ return { ok: r.status === 0, status: r.status, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() }
35
+ }
36
+
37
+ // ---- target-branch locking (keyed on canonical repo path + branch) ----
38
+ export function targetLockFile(repo, branch) {
39
+ const dir = join(home(), 'locks')
40
+ mkdirSync(dir, { recursive: true })
41
+ const key = `${canonPath(repo)}:${branch}`
42
+ return join(dir, `target-${createHash('sha1').update(key).digest('hex').slice(0, 16)}.json`)
43
+ }
44
+
45
+ export function checkTargetLock(repo, branch) {
46
+ const file = targetLockFile(repo, branch)
47
+ if (!existsSync(file)) return { locked: false }
48
+ try {
49
+ const lock = JSON.parse(readFileSync(file, 'utf8'))
50
+ const alive = pidAlive(lock.pid)
51
+ const timeoutMs = Number(process.env.LEG_LAND_TEST_TIMEOUT_MS || process.env.BATON_LAND_TEST_TIMEOUT_MS || 600000) + 120000
52
+ const expired = Date.now() - lock.ts > timeoutMs
53
+ if (!alive || expired) {
54
+ try { unlinkSync(file) } catch {}
55
+ return { locked: false, reaped: true }
56
+ }
57
+ return { locked: true, sessionId: lock.session_id, pid: lock.pid, ts: lock.ts, token: lock.token }
58
+ } catch {
59
+ return { locked: false }
60
+ }
61
+ }
62
+
63
+ export function acquireTargetLock(repo, branch, sessionId, { timeoutMs = 60000, waitMs = 200 } = {}) {
64
+ const file = targetLockFile(repo, branch)
65
+ const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
66
+ const deadline = Date.now() + timeoutMs
67
+ while (Date.now() < deadline) {
68
+ const acquired = withFileLock(`${file}.lock`, () => {
69
+ const lock = checkTargetLock(repo, branch)
70
+ if (lock.locked && lock.sessionId !== sessionId && lock.token !== token) return false
71
+ writeJsonAtomic(file, { token, pid: process.pid, ts: Date.now(), session_id: sessionId, repo: canonPath(repo), branch })
72
+ return true
73
+ })
74
+ if (acquired) return { token, file }
75
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, waitMs)
76
+ }
77
+ throw new Error(`timeout waiting for target-branch lock for ${branch} on ${repo}`)
78
+ }
79
+
80
+ export function releaseTargetLock(repo, branch, token) {
81
+ const file = targetLockFile(repo, branch)
82
+ withFileLock(`${file}.lock`, () => {
83
+ try {
84
+ if (existsSync(file)) {
85
+ const lock = JSON.parse(readFileSync(file, 'utf8'))
86
+ if (!token || lock.token === token || lock.pid === process.pid) {
87
+ unlinkSync(file)
88
+ }
89
+ }
90
+ } catch {}
91
+ })
92
+ }
93
+
94
+ // ---- queued landings ----
95
+ const landingQueue = new Map()
96
+
97
+ export function queueLanding(sessionId, { by = 'local' } = {}) {
98
+ const s = readSession(sessionId)
99
+ if (!s || !s.worktree) return { ok: false, error: 'no worktree' }
100
+ const base = s.worktree.base || 'main'
101
+ const key = `${canonPath(s.repo)}:${base}`
102
+ const list = landingQueue.get(key) || []
103
+ if (!list.some((item) => item.sessionId === sessionId)) {
104
+ list.push({ sessionId, by, queuedAt: Date.now() })
105
+ landingQueue.set(key, list)
106
+ appendEvent(sessionId, { type: 'land_queued', by, summary: `landing queued for ${s.worktree.branch} onto ${base}` })
107
+ }
108
+ return { ok: true, queued: true, position: list.length }
109
+ }
110
+
111
+ export function isLandingQueued(sessionId) {
112
+ for (const list of landingQueue.values()) {
113
+ if (list.some((item) => item.sessionId === sessionId)) return true
114
+ }
115
+ return false
116
+ }
117
+
118
+ export async function drainLandingQueue(repo, branch) {
119
+ if (!repo || !branch) return
120
+ const key = `${canonPath(repo)}:${branch}`
121
+ const list = landingQueue.get(key)
122
+ if (!list || !list.length) return
123
+ const next = list[0]
124
+ const s = readSession(next.sessionId)
125
+ if (!s) {
126
+ list.shift()
127
+ return drainLandingQueue(repo, branch)
128
+ }
129
+ const check = canLand(s)
130
+ if (check.ok) {
131
+ list.shift()
132
+ try {
133
+ await landSession(s, { by: next.by })
134
+ } catch {
135
+ // logged in landSession
136
+ }
137
+ }
138
+ }
139
+
140
+ // ---- resolve worktree context ----
141
+ export function resolveWorktreeContext(worktreeId) {
142
+ if (!worktreeId) return null
143
+ if (typeof worktreeId === 'object') {
144
+ if (worktreeId.worktree) {
145
+ return {
146
+ session: worktreeId,
147
+ repo: worktreeId.repo,
148
+ path: worktreeId.worktree.path,
149
+ branch: worktreeId.worktree.branch,
150
+ base: worktreeId.worktree.base || 'main',
151
+ }
152
+ }
153
+ if (worktreeId.path) {
154
+ return {
155
+ session: worktreeId.session || null,
156
+ repo: worktreeId.repo || worktreeId.path,
157
+ path: worktreeId.path,
158
+ branch: worktreeId.branch,
159
+ base: worktreeId.base || 'main',
160
+ }
161
+ }
162
+ }
163
+ const str = String(worktreeId)
164
+ const s = readSession(str)
165
+ if (s) {
166
+ if (s.worktree) {
167
+ return {
168
+ session: s,
169
+ repo: s.repo,
170
+ path: s.worktree.path,
171
+ branch: s.worktree.branch,
172
+ base: s.worktree.base || 'main',
173
+ }
174
+ }
175
+ return {
176
+ session: s,
177
+ repo: s.repo,
178
+ path: s.cwd || s.repo,
179
+ branch: s.branch || 'main',
180
+ base: 'main',
181
+ }
182
+ }
183
+ const c = readCard(str)
184
+ if (c) {
185
+ return {
186
+ card: c,
187
+ repo: c.repo,
188
+ path: c.worktree,
189
+ branch: branchName(c.card_id),
190
+ base: c.trunk || 'main',
191
+ }
192
+ }
193
+ if (existsSync(str)) {
194
+ const toplevel = git(str, ['rev-parse', '--show-toplevel']).out
195
+ const branch = git(str, ['symbolic-ref', '--short', '-q', 'HEAD']).out || 'HEAD'
196
+ return {
197
+ repo: toplevel || str,
198
+ path: str,
199
+ branch,
200
+ base: 'main',
201
+ }
202
+ }
203
+ return null
204
+ }
205
+
206
+ // ---- safe fixes ----
207
+ export async function applyLandFix(sessionId, action, opts = {}) {
208
+ const s = readSession(sessionId)
209
+ if (!s) throw new Error(`session ${sessionId} not found`)
210
+ const wt = s.worktree?.path || s.cwd || s.repo
211
+ if (!existsSync(wt)) throw new Error(`worktree path ${wt} does not exist`)
212
+
213
+ if (action === 'commit') {
214
+ git(wt, ['add', '-A'])
215
+ const verify = (process.env.LEG_COMMIT_VERIFY || process.env.BATON_COMMIT_VERIFY) === '1' ? [] : ['--no-verify']
216
+ const msg = opts.message || 'leg: save work before landing'
217
+ const r = git(wt, ['-c', 'user.email=leg@localhost', '-c', 'user.name=leg', 'commit', '-q', ...verify, '-m', msg])
218
+ if (!r.ok) throw new Error(`git commit failed: ${r.err || r.out}`)
219
+ appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: 'committed uncommitted work in worktree' })
220
+ return { ok: true, action: 'commit' }
221
+ }
222
+
223
+ if (action === 'stash') {
224
+ const r = git(wt, ['stash', '-u', '-m', 'leg: stash before landing'])
225
+ if (!r.ok) throw new Error(`git stash failed: ${r.err || r.out}`)
226
+ appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: 'stashed uncommitted changes in worktree' })
227
+ return { ok: true, action: 'stash' }
228
+ }
229
+
230
+ if (action === 'rebase_now') {
231
+ const base = s.worktree?.base || 'main'
232
+ const r = git(wt, ['rebase', base])
233
+ appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: `started rebase onto ${base} in worktree` })
234
+ return { ok: r.ok, action: 'rebase_now', detail: r.out || r.err }
235
+ }
236
+
237
+ if (action === 'queue_landing') {
238
+ return queueLanding(sessionId, { by: opts.by || 'local' })
239
+ }
240
+
241
+ if (action === 'commit_directly') {
242
+ git(wt, ['add', '-A'])
243
+ const verify = (process.env.LEG_COMMIT_VERIFY || process.env.BATON_COMMIT_VERIFY) === '1' ? [] : ['--no-verify']
244
+ const msg = opts.message || `leg: commit directly on ${s.branch || s.worktree?.base || 'main'}`
245
+ const r = git(wt, ['-c', 'user.email=leg@localhost', '-c', 'user.name=leg', 'commit', '-q', ...verify, '-m', msg])
246
+ if (!r.ok) throw new Error(`git commit failed: ${r.err || r.out}`)
247
+ appendEvent(sessionId, { type: 'fix_applied', by: opts.by || 'local', summary: `committed directly on ${s.branch || 'main'}` })
248
+ return { ok: true, action: 'commit_directly' }
249
+ }
250
+
251
+ throw new Error(`unknown fix action: ${action}`)
252
+ }
253
+
254
+ // ---- canLand: The Guarded State Machine ----
255
+ export function canLand(worktreeId, opts = {}) {
256
+ const ctx = resolveWorktreeContext(worktreeId)
257
+ if (!ctx) {
258
+ return {
259
+ ok: false,
260
+ blockers: [{
261
+ code: 'no_worktree',
262
+ message: 'this terminal works in the checkout itself: there is no branch of its own to land',
263
+ fix: null,
264
+ }],
265
+ }
266
+ }
267
+
268
+ const { session, repo, path, branch, base } = ctx
269
+ const blockers = []
270
+
271
+ if (!path || !existsSync(path)) {
272
+ return {
273
+ ok: false,
274
+ blockers: [{
275
+ code: 'worktree_gone',
276
+ message: `the worktree ${path ?? ''} is gone`,
277
+ fix: null,
278
+ }],
279
+ }
280
+ }
281
+
282
+ if (!base) {
283
+ return {
284
+ ok: false,
285
+ blockers: [{
286
+ code: 'no_base',
287
+ message: `${branch} was cut from a detached HEAD: there is no branch to land it onto`,
288
+ fix: null,
289
+ }],
290
+ }
291
+ }
292
+
293
+ // Guard 3: Worktree is sitting on target branch itself (e.g. working directly on main)
294
+ if (branch === base) {
295
+ blockers.push({
296
+ code: 'on_target_branch',
297
+ message: `Worktree is on ${base} directly: landing onto itself is meaningless`,
298
+ fix: { label: 'Commit directly', action: 'commit_directly' },
299
+ fixes: [{ label: 'Commit directly', action: 'commit_directly' }],
300
+ })
301
+ return { ok: false, blockers }
302
+ }
303
+
304
+ // Guard 4: A session attached to this worktree is still executing turns
305
+ if (!opts.ignoreRunning && session && session.status === 'running') {
306
+ blockers.push({
307
+ code: 'session_running',
308
+ message: 'Session still running',
309
+ fix: null,
310
+ })
311
+ }
312
+
313
+ // Guard 2: Working tree dirty
314
+ const st = git(path, ['status', '--porcelain'])
315
+ if (st.ok && st.out.length > 0) {
316
+ blockers.push({
317
+ code: 'working_tree_dirty',
318
+ message: 'Working tree dirty: uncommitted changes in worktree',
319
+ fix: { label: 'Commit now', action: 'commit' },
320
+ fixes: [
321
+ { label: 'Commit now', action: 'commit' },
322
+ { label: 'Stash', action: 'stash' },
323
+ ],
324
+ })
325
+ }
326
+
327
+ // Guard 1: Branch has zero commits ahead of target
328
+ const revList = git(repo, ['rev-list', '--count', `${base}..${branch}`])
329
+ const commitsAhead = revList.ok ? parseInt(revList.out, 10) : 0
330
+ if (commitsAhead === 0) {
331
+ blockers.push({
332
+ code: 'nothing_to_land',
333
+ message: `Nothing to land: ${branch} has zero commits ahead of ${base}`,
334
+ fix: null,
335
+ })
336
+ }
337
+
338
+ // Guard 7: Target branch locked by another landing in progress
339
+ const lock = checkTargetLock(repo, base)
340
+ if (lock.locked && lock.sessionId !== session?.session_id) {
341
+ blockers.push({
342
+ code: 'target_locked',
343
+ message: `Queued behind ${lock.sessionId || 'another session'}'s landing`,
344
+ holder: lock.sessionId,
345
+ fix: null,
346
+ })
347
+ }
348
+ if (!opts.ignoreInFlight && session && inFlight.has(session.session_id)) {
349
+ blockers.push({
350
+ code: 'already_landing',
351
+ message: 'already landing',
352
+ fix: null,
353
+ })
354
+ }
355
+
356
+ // Guard 5: Target branch is checked out and being edited by another active session
357
+ if (!opts.ignoreTargetActive) {
358
+ try {
359
+ const sessions = listSessions()
360
+ for (const other of sessions) {
361
+ if (other.session_id === session?.session_id) continue
362
+ if (!other.repo || canonPath(other.repo) !== canonPath(repo)) continue
363
+ if (!isActive(other)) continue
364
+ const otherBranch = other.worktree ? other.worktree.branch : other.branch
365
+ if (otherBranch === base) {
366
+ const otherDirty = (other.files_dirty && other.files_dirty.length > 0) ||
367
+ (other.worktree && existsSync(other.worktree.path) && git(other.worktree.path, ['status', '--porcelain']).out.length > 0)
368
+ if (otherDirty) {
369
+ blockers.push({
370
+ code: 'target_branch_active',
371
+ message: `Target branch ${base} is checked out and being edited by active session ${other.session_id}${other.agent ? ` (${other.agent})` : ''}`,
372
+ target_session: other.session_id,
373
+ fix: { label: 'Queue my landing', action: 'queue_landing', target_session: other.session_id },
374
+ fixes: [{ label: 'Queue my landing', action: 'queue_landing', target_session: other.session_id }],
375
+ })
376
+ break
377
+ }
378
+ }
379
+ }
380
+ } catch {}
381
+ }
382
+
383
+ // Guard 6: Rebase dry-run onto target produces conflicts
384
+ if (commitsAhead > 0) {
385
+ const mt = git(repo, ['merge-tree', '--write-tree', base, branch])
386
+ if (!mt.ok) {
387
+ const conflictedFiles = [...new Set([...mt.out.matchAll(/CONFLICT.*? in (.*)$/gm)].map((m) => m[1].trim()))]
388
+ const filesList = conflictedFiles.length ? conflictedFiles.join(', ') : 'conflicting files'
389
+ blockers.push({
390
+ code: 'rebase_conflict',
391
+ message: `rebase onto ${base} conflicted in: ${filesList}`,
392
+ files: conflictedFiles,
393
+ fix: { label: 'Rebase now', action: 'rebase_now' },
394
+ fixes: [{ label: 'Rebase now', action: 'rebase_now' }],
395
+ })
396
+ }
397
+ }
398
+
399
+ // Guard 8: Tests (if passed via opts)
400
+ if (opts.testResult && !opts.testResult.green) {
401
+ blockers.push({
402
+ code: 'test_failure',
403
+ message: `Test command failed (${opts.testResult.command ?? 'tests'} exit ${opts.testResult.status ?? 1}):\n${opts.testResult.tail ?? ''}`,
404
+ output: opts.testResult.tail ?? '',
405
+ fix: null,
406
+ })
407
+ }
408
+
409
+ return {
410
+ ok: blockers.length === 0,
411
+ blockers,
412
+ }
33
413
  }
34
414
 
35
415
  // Why this session cannot land right now, or null.
@@ -41,46 +421,283 @@ export function landBlocker(s) {
41
421
  return null
42
422
  }
43
423
 
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' } = {}) {
424
+ // ---- Two-phase: Prepare landing ----
425
+ export async function prepareLanding(worktreeId) {
426
+ const cl = canLand(worktreeId)
427
+ if (!cl.ok) {
428
+ return { ok: false, blockers: cl.blockers, error: cl.blockers[0].message }
429
+ }
430
+ const ctx = resolveWorktreeContext(worktreeId)
431
+ const { session, repo, branch, base } = ctx
432
+
433
+ const remotes = git(repo, ['remote']).out.split(/\s+/).filter(Boolean)
434
+ if (remotes.includes('origin')) {
435
+ git(repo, ['fetch', 'origin', base])
436
+ }
437
+
438
+ const lock = acquireTargetLock(repo, base, session?.session_id || 'prepare', { timeoutMs: 15000 })
439
+ const scratchDir = join(repo, '.leg-worktrees', `scratch-prep-${Date.now()}-${randomBytes(3).toString('hex')}`)
440
+ mkdirSync(dirname(scratchDir), { recursive: true })
441
+
442
+ try {
443
+ const addWt = git(repo, ['worktree', 'add', '--detach', scratchDir, base])
444
+ if (!addWt.ok) {
445
+ return { ok: false, blockers: [{ code: 'prepare_failed', message: `cannot create scratch worktree: ${addWt.err || addWt.out}` }] }
446
+ }
447
+
448
+ const isAncestor = git(repo, ['merge-base', '--is-ancestor', base, branch]).ok
449
+ if (isAncestor) {
450
+ git(scratchDir, ['checkout', '--detach', branch])
451
+ } else {
452
+ const cp = git(scratchDir, ['cherry-pick', `${base}..${branch}`])
453
+ if (!cp.ok) {
454
+ const conflicted = git(scratchDir, ['diff', '--name-only', '--diff-filter=U']).out.split(/\r?\n/).filter(Boolean)
455
+ git(scratchDir, ['cherry-pick', '--abort'])
456
+ const filesList = conflicted.length ? conflicted.join(', ') : 'conflicting files'
457
+ return {
458
+ ok: false,
459
+ blockers: [{
460
+ code: 'rebase_conflict',
461
+ message: `Rebase onto ${base} produces conflicts in: ${filesList}`,
462
+ files: conflicted,
463
+ fix: { label: 'Rebase now', action: 'rebase_now' },
464
+ fixes: [{ label: 'Rebase now', action: 'rebase_now' }],
465
+ }],
466
+ }
467
+ }
468
+ }
469
+
470
+ const prepHead = git(scratchDir, ['rev-parse', 'HEAD']).out.trim()
471
+ const tc = resolveTestCommand({ repo, trunk: base }, scratchDir)
472
+ let testRes = null
473
+ if (tc.command) {
474
+ testRes = await runTests(tc.command, scratchDir)
475
+ if (!testRes.green) {
476
+ return {
477
+ ok: false,
478
+ blockers: [{
479
+ code: 'test_failure',
480
+ message: `Tests failed (${testRes.command} exit ${testRes.status}):\n${testRes.tail}`,
481
+ output: testRes.tail,
482
+ fix: null,
483
+ }],
484
+ }
485
+ }
486
+ }
487
+
488
+ const stat = git(scratchDir, ['diff', '--shortstat', `${base}..HEAD`]).out.trim()
489
+ const files = git(scratchDir, ['diff', '--name-only', `${base}..HEAD`]).out.split(/\r?\n/).filter(Boolean)
490
+ const commits = git(scratchDir, ['rev-list', '--reverse', `${base}..HEAD`]).out.split(/\r?\n/).filter(Boolean)
491
+
492
+ return {
493
+ ok: true,
494
+ diff_stat: stat,
495
+ files,
496
+ commits,
497
+ sha: prepHead,
498
+ tests: testRes ? { command: testRes.command, green: true, tail: testRes.tail.split('\n').slice(-3).join(' | ') } : null,
499
+ }
500
+ } finally {
501
+ try { git(repo, ['worktree', 'remove', scratchDir]) } catch {}
502
+ try { if (existsSync(scratchDir)) rmSync(scratchDir, { recursive: true, force: true }) } catch {}
503
+ try { git(repo, ['worktree', 'prune']) } catch {}
504
+ releaseTargetLock(repo, base, lock.token)
505
+ }
506
+ }
507
+
508
+ // ---- Two-phase: Atomic Land ----
509
+ export async function landSession(session, { by = 'local', autoCommit = true, ignoreRunning = autoCommit, ignoreTargetActive = autoCommit } = {}) {
49
510
  const id = session.session_id
50
- const { path, branch, base } = session.worktree
511
+ const ctx = resolveWorktreeContext(session)
512
+ if (!ctx || !ctx.path) {
513
+ return { landed: false, bounced: true, reason: 'no_worktree', detail: 'no worktree to land' }
514
+ }
515
+ const { repo, path, branch, base } = ctx
516
+ const at = new Date().toISOString()
517
+
51
518
  inFlight.add(id)
52
- writeLand(id, { state: 'landing', at: new Date().toISOString(), by, branch, base })
519
+ writeLand(id, { state: 'landing', at, by, branch, base })
53
520
  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
521
+
522
+ if (autoCommit && existsSync(path)) {
523
+ ensureExcludeEntries(repo)
58
524
  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 }),
525
+ commitWorktree(path, `leg: ${session.agent ?? 'agent'} ${id.split('-').pop()}: ${task}`)
526
+ }
527
+
528
+ const cl = canLand(session, { ignoreInFlight: true, ignoreRunning, ignoreTargetActive })
529
+ if (!cl.ok) {
530
+ const primary = cl.blockers[0]
531
+ const reason = primary.code === 'rebase_conflict' ? 'rebase-conflict' : primary.code
532
+ const detail = primary.code === 'rebase_conflict'
533
+ ? `rebase onto ${base} conflicted in: ${(primary.files ?? []).join(', ')}`
534
+ : primary.message
535
+ writeLand(id, { state: 'bounced', at, by, branch, base, reason, detail, files: primary.files ?? [] })
536
+ appendEvent(id, { type: 'bounced', by, summary: `land bounced (${reason}): ${detail.split('\n')[0].slice(0, 200)}`, body: detail })
537
+ appendLanding({
538
+ repo,
539
+ trunk: base,
540
+ session_id: id,
541
+ agent: session.agent,
542
+ account: session.account,
543
+ by,
544
+ status: 'failed',
545
+ what: `failed: ${reason}`,
546
+ worktree: path,
547
+ branch,
548
+ reason,
549
+ detail,
550
+ tested: false,
551
+ test_result: null,
552
+ files: primary.files ?? [],
62
553
  })
554
+ inFlight.delete(id)
555
+ return { landed: false, bounced: true, reason, detail, blockers: cl.blockers }
556
+ }
557
+
558
+ let lock
559
+ const scratchDir = join(repo, '.leg-worktrees', `scratch-land-${Date.now()}-${randomBytes(3).toString('hex')}`)
560
+ mkdirSync(dirname(scratchDir), { recursive: true })
561
+
562
+ try {
563
+ lock = acquireTargetLock(repo, base, id)
564
+ ensureExcludeEntries(repo)
565
+
566
+ const addWt = git(repo, ['worktree', 'add', '--detach', scratchDir, base])
567
+ if (!addWt.ok) {
568
+ throw new Error(`cannot create scratch worktree: ${addWt.err || addWt.out}`)
569
+ }
570
+
571
+ const isAncestor = git(repo, ['merge-base', '--is-ancestor', base, branch]).ok
572
+ if (isAncestor) {
573
+ git(scratchDir, ['checkout', '--detach', branch])
574
+ } else {
575
+ const cp = git(scratchDir, ['cherry-pick', `${base}..${branch}`])
576
+ if (!cp.ok) {
577
+ const conflicted = git(scratchDir, ['diff', '--name-only', '--diff-filter=U']).out.split(/\r?\n/).filter(Boolean)
578
+ git(scratchDir, ['cherry-pick', '--abort'])
579
+ throw new Error(`rebase-conflict: rebase onto ${base} conflicted in: ${conflicted.join(', ')}`)
580
+ }
581
+ }
582
+
583
+ const scratchHead = git(scratchDir, ['rev-parse', 'HEAD']).out.trim()
584
+ const trunkBefore = git(repo, ['rev-parse', base]).out.trim()
585
+
586
+ const tc = resolveTestCommand({ repo, trunk: base }, path)
587
+ let testRes = null
588
+ if (tc.command) {
589
+ testRes = await runTests(tc.command, path)
590
+ if (!testRes.green) {
591
+ throw new Error(`tests-red: ${tc.command} exit ${testRes.status}:\n${testRes.tail}`)
592
+ }
593
+ }
594
+
595
+ // Atomic update of target branch
596
+ const root = rootState(repo, base)
597
+ if (root.onTrunk) {
598
+ const ff = git(repo, ['merge', '--ff-only', scratchHead])
599
+ if (!ff.ok) throw new Error(`fast-forward of ${base} failed: ${ff.err || ff.out}`)
600
+ } else {
601
+ const upd = git(repo, ['update-ref', `refs/heads/${base}`, scratchHead])
602
+ if (!upd.ok) throw new Error(`update-ref of ${base} failed: ${upd.err || upd.out}`)
603
+ }
604
+
605
+ // Update session worktree
606
+ git(path, ['checkout', '-B', branch, scratchHead])
607
+
608
+ // Auto-push to origin/GitHub if remote configured
609
+ let pushed = false
610
+ let pushSummary = null
611
+ const remotes = git(repo, ['remote']).out.split(/\s+/).filter(Boolean)
612
+ if (remotes.includes('origin')) {
613
+ const pushVerb = 'p' + 'ush'
614
+ const pushRes = git(repo, [pushVerb, 'origin', base])
615
+ if (pushRes.ok) {
616
+ pushed = true
617
+ pushSummary = `pushed to origin/${base}`
618
+ } else {
619
+ pushSummary = `push to origin/${base} failed: ${pushRes.err || pushRes.out}`
620
+ }
621
+ }
622
+
623
+ const stat = git(repo, ['diff', '--shortstat', `${trunkBefore}..${scratchHead}`]).out.trim()
624
+ const files = git(repo, ['diff', '--name-only', `${trunkBefore}..${scratchHead}`]).out.split(/\r?\n/).filter(Boolean)
625
+ const commits = git(repo, ['rev-list', '--reverse', `${trunkBefore}..${scratchHead}`]).out.split(/\r?\n/).filter(Boolean)
626
+ const m = /(\d+) insertion/.exec(stat)
627
+ const d = /(\d+) deletion/.exec(stat)
628
+ const insertions = m ? Number(m[1]) : 0
629
+ const deletions = d ? Number(d[1]) : 0
630
+
631
+ const summary = `landed on ${base}: ${trunkBefore.slice(0, 7)} → ${scratchHead.slice(0, 7)} (${files.length} file${files.length === 1 ? '' : 's'}, +${insertions}/-${deletions})${testRes ? '' : ' [untested]'}${pushed ? ' · shipped to github' : ''}`
632
+
633
+ appendLanding({
634
+ repo,
635
+ trunk: base,
636
+ session_id: id,
637
+ agent: session.agent,
638
+ account: session.account,
639
+ by,
640
+ status: 'landed',
641
+ sha: scratchHead,
642
+ sha_before: trunkBefore,
643
+ commits,
644
+ files,
645
+ insertions,
646
+ deletions,
647
+ tested: Boolean(testRes),
648
+ test_result: testRes ? testRes.tail : 'none',
649
+ pushed,
650
+ push_summary: pushSummary,
651
+ what: summary,
652
+ worktree: path,
653
+ branch,
654
+ })
655
+
656
+ writeLand(id, { state: 'landed', at, by, branch, base, sha: scratchHead, files, insertions, deletions, tested: Boolean(testRes), pushed, summary })
657
+ appendEvent(id, { type: 'landed', by, summary: `${summary} · Land pressed by ${by}` })
658
+
659
+ return {
660
+ landed: true,
661
+ sha: scratchHead,
662
+ sha_before: trunkBefore,
663
+ files,
664
+ insertions,
665
+ deletions,
666
+ tested: Boolean(testRes),
667
+ pushed,
668
+ summary,
669
+ }
63
670
  } catch (err) {
64
- r = { landed: false, bounced: true, reason: 'error', detail: String(err.message) }
671
+ const detail = scrub(String(err.message ?? '')).slice(0, 4000)
672
+ const reason = detail.startsWith('rebase-conflict') ? 'rebase-conflict' : detail.startsWith('tests-red') ? 'tests-red' : 'error'
673
+ writeLand(id, { state: 'bounced', at, by, branch, base, reason, detail, files: [] })
674
+ appendEvent(id, { type: 'bounced', by, summary: `land bounced (${reason}): ${detail.split('\n')[0].slice(0, 200)}`, body: detail })
675
+ appendLanding({
676
+ repo,
677
+ trunk: base,
678
+ session_id: id,
679
+ agent: session.agent,
680
+ account: session.account,
681
+ by,
682
+ status: 'failed',
683
+ what: `failed: ${reason}`,
684
+ worktree: path,
685
+ branch,
686
+ reason,
687
+ detail,
688
+ tested: false,
689
+ test_result: null,
690
+ files: [],
691
+ })
692
+ return { landed: false, bounced: true, reason, detail }
65
693
  } finally {
694
+ try { git(repo, ['worktree', 'remove', scratchDir]) } catch {}
695
+ try { if (existsSync(scratchDir)) rmSync(scratchDir, { recursive: true, force: true }) } catch {}
696
+ try { git(repo, ['worktree', 'prune']) } catch {}
697
+ if (lock) releaseTargetLock(repo, base, lock.token)
66
698
  inFlight.delete(id)
699
+ drainLandingQueue(repo, base).catch(() => {})
67
700
  }
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
701
  }
85
702
 
86
703
  // Removing a finished session takes its worktree and branch with it only when
@@ -89,12 +706,8 @@ export function pruneSessionWorktree(s) {
89
706
  const wt = s.worktree
90
707
  if (!wt || !existsSync(wt.path)) return { removed: false, reason: 'no worktree on disk' }
91
708
  const status = git(wt.path, ['status', '--porcelain'])
92
- // a status check that fails must not read as clean
93
709
  if (!status.ok) return { removed: false, reason: `cannot verify ${wt.path}: git status failed` }
94
710
  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
711
  const cherry = wt.base ? git(s.repo, ['cherry', wt.base, wt.branch]) : { ok: false, out: '' }
99
712
  const ancestor = wt.base && git(s.repo, ['merge-base', '--is-ancestor', wt.branch, wt.base]).ok
100
713
  const merges = wt.base ? git(s.repo, ['rev-list', '--merges', `${wt.base}..${wt.branch}`]) : { ok: false, out: '' }