weir-harness 1.2.0 → 1.2.2

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 (99) hide show
  1. package/cordis.patch.yml +0 -2
  2. package/lib/client.blackboard-model.js +172 -56
  3. package/lib/client.capability-manager.js +0 -1
  4. package/lib/client.capability-model.js +378 -31
  5. package/lib/client.chain-model.js +80 -0
  6. package/lib/client.disabled-categories-editor.js +22 -0
  7. package/lib/client.edit-lock-maintenance.js +239 -246
  8. package/lib/client.edit-lock-panel.js +110 -0
  9. package/lib/client.hash-edit-model.js +48 -0
  10. package/lib/client.js +267 -1549
  11. package/lib/client.lsp-model.js +92 -0
  12. package/lib/client.lsp-panel.js +11 -16
  13. package/lib/client.lsp-toggle.js +18 -0
  14. package/lib/client.notify-permissions.js +112 -20
  15. package/lib/client.notify-web.js +14 -11
  16. package/lib/client.robash-model.js +26 -0
  17. package/lib/client.settings-page.js +287 -17
  18. package/lib/client.worktree-model.js +225 -0
  19. package/package.json +5 -2
  20. package/src/blackboard/durable.js +12 -0
  21. package/src/blackboard/index.js +2 -2
  22. package/src/blackboard/kernel.js +23 -6
  23. package/src/blackboard/notify.js +10 -3
  24. package/src/blackboard/remote.js +31 -13
  25. package/src/blackboard/storage.js +13 -5
  26. package/src/capabilities/apply-engine.js +189 -101
  27. package/src/capabilities/defaults-transaction.js +136 -1
  28. package/src/capabilities/lifecycle-snapshot.js +83 -204
  29. package/src/capabilities/mcp-admission.js +1 -23
  30. package/src/capabilities/mcp-drain.js +30 -19
  31. package/src/capabilities/mcp-facade.js +23 -3
  32. package/src/capabilities/preset-library.js +247 -0
  33. package/src/capabilities/preset-package.js +212 -1
  34. package/src/capabilities/same-name-policy.js +3 -41
  35. package/src/capabilities/selection-notify.js +324 -21
  36. package/src/capabilities/skill-office-adapter.js +7 -1
  37. package/src/capabilities/skill-selection-plugin.js +315 -729
  38. package/src/capabilities/store/checked-contract.d.ts +5 -0
  39. package/src/capabilities/store/fs-adapter.js +52 -0
  40. package/src/capabilities/store/lock.js +86 -0
  41. package/src/capabilities/store/paths.js +5 -0
  42. package/src/capabilities/store/store.js +105 -5
  43. package/src/creative-guide/guide.js +1 -1
  44. package/src/delegate/child-composition.js +258 -0
  45. package/src/delegate/group-coordinator.js +25 -27
  46. package/src/delegate/index.js +21 -2
  47. package/src/delegate/rehydrate.js +24 -4
  48. package/src/delegate/robash-guard-core.js +2 -2
  49. package/src/delegate/spawn-adapter.js +214 -100
  50. package/src/delegate/status-tool.js +9 -6
  51. package/src/delegate/supervision-mount.js +35 -6
  52. package/src/delegate/tool.js +29 -4
  53. package/src/{shared → delegate}/whitelist-defaults.js +2 -2
  54. package/src/edit-lock/admin-ledger.js +8 -11
  55. package/src/edit-lock/admin-recovery.js +2 -1
  56. package/src/edit-lock/admission.js +12 -2
  57. package/src/edit-lock/binding-guidance.js +28 -10
  58. package/src/edit-lock/binding-machine.js +312 -0
  59. package/src/edit-lock/derived-facts.js +67 -0
  60. package/src/edit-lock/index.js +44 -244
  61. package/src/edit-lock/inspect.js +113 -1
  62. package/src/edit-lock/lifecycle.js +8 -5
  63. package/src/edit-lock/limits.js +61 -0
  64. package/src/edit-lock/maintenance.js +5 -2
  65. package/src/edit-lock/manager.js +6 -2
  66. package/src/edit-lock/operation-history.js +1 -1
  67. package/src/edit-lock/peer-client.js +9 -2
  68. package/src/edit-lock/peer-transport.js +7 -1
  69. package/src/edit-lock/remote.js +11 -4
  70. package/src/edit-lock/state.js +2 -4
  71. package/src/edit-lock/view.js +10 -11
  72. package/src/lsp/admin.js +2 -24
  73. package/src/lsp/index.js +1 -1
  74. package/src/lsp/manager.js +1 -1
  75. package/src/notify/permissions-admin.js +1 -1
  76. package/src/{lsp/child-process.js → process/index.js} +23 -6
  77. package/src/settings/env-admin.js +1 -1
  78. package/src/settings/index.js +1 -1
  79. package/src/settings/sections.js +0 -54
  80. package/src/shared/audit.js +12 -0
  81. package/src/shared/child-scope.js +3 -1
  82. package/src/shared/http-json.js +28 -0
  83. package/src/{delegate/audit-readers.js → shared/terminal-evidence.js} +34 -4
  84. package/src/worktree/errors.js +3 -0
  85. package/src/worktree/index.js +1 -2
  86. package/src/worktree/lane-core.js +641 -0
  87. package/src/worktree/lane-views.js +147 -0
  88. package/src/worktree/lane-workflows.js +853 -0
  89. package/src/worktree/lanes.js +24 -1513
  90. package/src/worktree/runner.js +5 -4
  91. package/src/capabilities/content-pin.js +0 -88
  92. package/src/capabilities/content-refresh.js +0 -331
  93. package/src/capabilities/distribution-manifest.js +0 -78
  94. package/src/capabilities/isolated-exec.js +0 -106
  95. package/src/capabilities/publisher.js +0 -129
  96. package/src/capabilities/skill-admission.js +0 -181
  97. package/src/capabilities/update-lifecycle.js +0 -106
  98. package/src/edit-lock/remote-service.js +0 -28
  99. package/src/worktree/contract-guidance.test.js +0 -100
@@ -0,0 +1,853 @@
1
+ // Lane decision workflows: the phases that carry a user or policy decision —
2
+ // open / setup, binding (reserve for a child; the disputed-binding card),
3
+ // landing, cleanup / abandon, watches, repository config. Decision cards,
4
+ // auto-approve and removal semantics live only in this module. Every
5
+ // state-machine transition goes through the core's apply(); the direct
6
+ // ledger writes here are non-transition writes only (open's append, the
7
+ // subscribe check-vs-insert, the reconcile command's rebuild). ctx-free:
8
+ // all effects reach the injected deps through the core context.
9
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
10
+ import { isAbsolute, join, resolve as resolvePath } from 'node:path'
11
+ import { randomUUID } from 'node:crypto'
12
+ import { WORKTREE_CODES, WorktreeError } from './errors.js'
13
+ import { LANDABLE_FROM, isActive, nextFor } from './state.js'
14
+ import { createWatch, normalizeWatchStates, upsertWatch, watchTimeoutMinutesOf } from './watches.js'
15
+ import { branchFor, laneIdFor, parseRepoConfig, scopesOverlap, setupManagerFor, suggestChecks } from './rules.js'
16
+ import { bareSetupCommand, setupMissingReason } from './pkgmgr.js'
17
+ import { ensureExclude } from './exclude.js'
18
+ import { renderChildContract, renderWatchHit } from './prompts.js'
19
+ import { cardCopy, cardLocale, hasDirtyContent } from './cards.js'
20
+ import { CONFIG_FILE, pathKey } from './lane-core.js'
21
+
22
+ const SETUP_TIMEOUT_MS = 600_000
23
+
24
+ /**
25
+ * Model-facing English residue warning appended to cleanup/abandon tool
26
+ * results (the human-facing card copy localizes its own in cards.js).
27
+ * @param {{ operations: number, locks: number }} residue
28
+ */
29
+ function residueNote(residue) {
30
+ const parts = []
31
+ if (residue.operations > 0) parts.push(`${residue.operations} unresolved Edit Lock operation(s)`)
32
+ if (residue.locks > 0) parts.push(`${residue.locks} Edit Lock lock(s)`)
33
+ return `warning: the lane's session left ${parts.join(' and ')} in the management authority; locks are reclaimed by the stale-lock sweep, unresolved publications settle via crash self-heal (dead process) or the Edit Lock maintenance panel's one-click recovery`
34
+ }
35
+
36
+ /** @param {ReturnType<typeof import('./lane-core.js').createLaneCore>} core */
37
+ export function createLaneWorkflows(core) {
38
+ const { deps, git, now, pid, asking, approveModeSafe, cwdOf, config, repoFor, laneOf, apply, patchLane, commitBinding, notifyOwner, residueOf, result, readConfig, logFile, refresh, reconcileNote, reconcileBinding, armWatch, disarmWatch } = core
39
+
40
+ // ─── open / setup ─────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * The invocation for a DERIVED setup: resolved through deps.resolveSetup
44
+ * when available (system tool first, DSH bundled runtime second), else the
45
+ * legacy bare command. A user-configured setup never reaches this helper.
46
+ * @param {string} manager
47
+ * @returns {Promise<{ command: string, display: string } | { error: string }>}
48
+ */
49
+ async function deriveSetup(manager) {
50
+ if (deps.resolveSetup) {
51
+ const resolution = await deps.resolveSetup(manager)
52
+ if (!resolution.ok) return { error: setupMissingReason(manager) }
53
+ return { command: resolution.command, display: resolution.display }
54
+ }
55
+ const display = bareSetupCommand(manager)
56
+ return { command: display, display }
57
+ }
58
+
59
+
60
+ /**
61
+ * @param {any} session - the owning (main) session
62
+ * @param {{ title: string, scope?: string[] }} args
63
+ */
64
+ async function open(session, args) {
65
+ const settingsNow = config()
66
+ if (typeof args?.title !== 'string' || args.title.trim().length === 0) throw new Error('worktree_open: title must be a non-empty string')
67
+ const scope = args.scope ?? []
68
+ if (!Array.isArray(scope) || scope.some((glob) => typeof glob !== 'string' || glob.trim().length === 0)) throw new Error('worktree_open: scope must be an array of non-empty path globs')
69
+ const repo = await repoFor(cwdOf(session))
70
+ const base = await git.currentBranch(repo.mainRoot)
71
+ if (!base) throw new WorktreeError(WORKTREE_CODES.DETACHED_HEAD, 'the main worktree is on a detached HEAD; check out a branch first', { next: { waitFor: 'user', hint: 'the user checks out a base branch' } })
72
+ const baseCommit = await git.revParse(repo.mainRoot, 'HEAD')
73
+ await refresh(repo)
74
+ ensureExclude(repo.commonDir, repo.root)
75
+ const at = now()
76
+ const lane = await repo.ledger.update((/** @type {any} */ ledger) => {
77
+ const active = ledger.lanes.filter(isActive)
78
+ if (active.length >= settingsNow.maxActive) {
79
+ const lanes = active.map((/** @type {any} */ lane) => ({
80
+ id: lane.id, state: lane.state,
81
+ hint: lane.state === 'landable'
82
+ ? `call worktree_land({ lane: "${lane.id}" })`
83
+ : `wait for lane ${lane.id} or call worktree_abandon({ lane: "${lane.id}" })`,
84
+ }))
85
+ const hint = (lanes.find((lane) => lane.state === 'landable') ?? lanes[0]).hint
86
+ throw new WorktreeError(WORKTREE_CODES.MAX_ACTIVE, `${active.length} lanes are active (limit ${settingsNow.maxActive}):\n${lanes.map((lane) => `${lane.id} · ${lane.state} · ${lane.hint}`).join('\n')}`, {
87
+ data: { lanes }, next: { waitFor: 'user', hint },
88
+ })
89
+ }
90
+ const clash = active.find((/** @type {any} */ other) => scopesOverlap(scope, other.scope ?? []))
91
+ if (clash) {
92
+ const overlapping = {
93
+ scope: scope.filter((glob) => scopesOverlap([glob], clash.scope)),
94
+ laneScope: clash.scope.filter((glob) => scopesOverlap(scope, [glob])),
95
+ }
96
+ const hint = `narrow the new scope to avoid ${overlapping.laneScope.join(', ')} or wait for lane ${clash.id} to land before opening it`
97
+ throw new WorktreeError(WORKTREE_CODES.SCOPE_OVERLAP, `scope ${overlapping.scope.join(', ')} overlaps lane ${clash.id} (${overlapping.laneScope.join(', ')}); ${hint}`, {
98
+ lane: clash.id, data: { overlapping }, next: { waitFor: 'user', hint },
99
+ })
100
+ }
101
+ const seq = ledger.seq + 1
102
+ const id = laneIdFor(args.title, seq)
103
+ const record = {
104
+ id,
105
+ title: args.title.trim(),
106
+ path: join(repo.rootPath, id),
107
+ branch: branchFor(id),
108
+ base: { branch: base, commit: baseCommit },
109
+ scope,
110
+ state: 'preparing',
111
+ ownerSession: session.id ?? null,
112
+ boundChild: null,
113
+ baseMoved: false,
114
+ landableTree: null,
115
+ setup: { status: 'pending' },
116
+ check: null,
117
+ land: null,
118
+ cleanup: null,
119
+ reason: null,
120
+ createdAt: at,
121
+ updatedAt: at,
122
+ history: [],
123
+ }
124
+ return { ledger: { ...ledger, seq, lanes: [...ledger.lanes, record] }, result: record }
125
+ })
126
+ try {
127
+ if (await git.branchExists(repo.mainRoot, lane.branch)) throw new WorktreeError(WORKTREE_CODES.BRANCH_EXISTS, `branch ${lane.branch} already exists`)
128
+ mkdirSync(repo.rootPath, { recursive: true })
129
+ await git.worktreeAdd(repo.mainRoot, lane.path, lane.branch, baseCommit)
130
+ } catch (error) {
131
+ await repo.ledger.update((/** @type {any} */ ledger) => ({ ledger: { ...ledger, lanes: ledger.lanes.filter((/** @type {any} */ entry) => entry.id !== lane.id) } }))
132
+ throw error
133
+ }
134
+ deps.audit('open', { lane: lane.id, title: lane.title, branch: lane.branch, base: lane.base, scope }, repo.mainRoot, lane.ownerSession)
135
+ let command = null
136
+ let display = null
137
+ /** @type {{ kind: 'configured' } | { kind: 'derived', manager: string } | null} */
138
+ let provenance = null
139
+ let configError = null
140
+ if (settingsNow.autoSetup) {
141
+ try {
142
+ const configured = readConfig(repo).setup ?? null
143
+ if (configured) {
144
+ command = display = configured
145
+ provenance = { kind: 'configured' }
146
+ } else {
147
+ const manager = setupManagerFor(readdirSync(lane.path))
148
+ if (manager) {
149
+ provenance = { kind: 'derived', manager }
150
+ const derived = await deriveSetup(manager)
151
+ if ('error' in derived) configError = derived.error.replaceAll('<lane>', lane.id)
152
+ else ({ command, display } = derived)
153
+ }
154
+ }
155
+ } catch (error) {
156
+ configError = /** @type {any} */ (error)?.message ?? String(error)
157
+ }
158
+ }
159
+ if (configError) {
160
+ const failed = await apply(repo, lane.id, { type: 'setup-fail', reason: configError, patch: { setup: { status: 'failed' } } })
161
+ return result(failed, `lane opened at ${failed.path}, but setup could not start: ${configError}`)
162
+ }
163
+ if (!command) {
164
+ const ready = await apply(repo, lane.id, { type: 'setup-ok', patch: { setup: { status: settingsNow.autoSetup ? 'none' : 'disabled' } } })
165
+ return result(ready, `lane opened at ${ready.path} on ${ready.branch} (base ${base})`)
166
+ }
167
+ const preparing = await patchLane(repo, lane.id, () => ({ setup: { status: 'running', display: display ?? command, provenance } }))
168
+ void runSetup(repo, lane.id, command, session, display ?? command)
169
+ return result(preparing, `lane opened at ${preparing.path}; running setup: ${display ?? command}`)
170
+ }
171
+
172
+ /** @param {any} repo @param {string} laneId @param {string} command @param {any} session @param {string} [display] */
173
+ async function runSetup(repo, laneId, command, session, display = command) {
174
+ const log = logFile(repo, laneId, '0-setup')
175
+ /** @type {{ ok: boolean, reason?: string }} */
176
+ let outcome
177
+ if (!deps.shellRun) {
178
+ outcome = { ok: false, reason: `${WORKTREE_CODES.SHELL_UNAVAILABLE}: no shell executor is available to run "${command}"` }
179
+ } else {
180
+ try {
181
+ const run = await deps.shellRun({ command, cwd: laneOf(repo, laneId).path, timeoutMs: SETUP_TIMEOUT_MS, session })
182
+ writeFileSync(log, `$ ${display}\n\n${run.output}`)
183
+ outcome = run.code === 0
184
+ ? { ok: true }
185
+ : { ok: false, reason: run.denied ? `sandbox denied the setup (exit ${run.code}); switch the session permission and retry with /worktree setup ${laneId}, or skip with /worktree setup ${laneId} --skip` : run.timedOut ? 'setup timed out' : `setup exited ${run.code}` }
186
+ } catch (error) {
187
+ outcome = { ok: false, reason: `setup could not run: ${/** @type {any} */ (error)?.message ?? error}` }
188
+ }
189
+ }
190
+ try {
191
+ const lane = await apply(repo, laneId, outcome.ok
192
+ ? { type: 'setup-ok', patch: { setup: { ...laneOf(repo, laneId).setup, status: 'ok', display, log } } }
193
+ : { type: 'setup-fail', reason: outcome.reason, patch: { setup: { ...laneOf(repo, laneId).setup, status: 'failed', display, log } } })
194
+ notifyOwner(lane, outcome.ok ? `setup finished: ${display}` : `${outcome.reason}; log ${log}`)
195
+ } catch {
196
+ // the lane moved on (abandoned) while setup ran: nothing to report
197
+ }
198
+ }
199
+
200
+ /** /worktree setup <id> [--skip] @param {any} session @param {string} laneId @param {{ skip?: boolean }} options */
201
+ async function setup(session, laneId, options = {}) {
202
+ const repo = await repoFor(cwdOf(session))
203
+ if (options.skip) {
204
+ const lane = await apply(repo, laneId, { type: 'setup-ok', by: 'user', patch: { setup: { ...laneOf(repo, laneId).setup, status: 'skipped' } } })
205
+ return result(lane, 'setup skipped by the user')
206
+ }
207
+ const current = laneOf(repo, laneId)
208
+ // Execution strings are NEVER taken from the ledger: a stored display
209
+ // may be the bare or unquoted form, and tools change between attempts.
210
+ // Provenance decides the source — config wins, otherwise the lane's
211
+ // lockfile (or the manager recorded at open) — and derived commands are
212
+ // resolved fresh on every attempt.
213
+ let command = null
214
+ let display = current.setup?.display ?? null
215
+ /** @type {string | null} */
216
+ let manager = null
217
+ try {
218
+ const configured = readConfig(repo).setup ?? null
219
+ if (configured) {
220
+ command = display = configured
221
+ } else {
222
+ manager = setupManagerFor(readdirSync(current.path)) ?? current.setup?.provenance?.manager ?? null
223
+ if (manager) {
224
+ const derived = await deriveSetup(manager)
225
+ if ('error' in derived) {
226
+ // Still setup-failed: keep the state legal and refresh the reason.
227
+ const failed = await patchLane(repo, laneId, (record) => ({
228
+ setup: { ...record.setup, status: 'failed' },
229
+ reason: derived.error.replaceAll('<lane>', laneId),
230
+ }))
231
+ return result(failed, `setup could not start: ${failed.reason}`)
232
+ }
233
+ ({ command, display } = derived)
234
+ }
235
+ }
236
+ } catch (error) {
237
+ throw new Error(/** @type {any} */ (error)?.message ?? String(error))
238
+ }
239
+ if (!command) {
240
+ const lane = await apply(repo, laneId, { type: 'setup-ok', by: 'user', patch: { setup: { status: 'none' } } })
241
+ return result(lane, 'no setup command applies; lane is ready')
242
+ }
243
+ const provenance = manager ? { kind: 'derived', manager } : { kind: 'configured' }
244
+ const lane = await apply(repo, laneId, { type: 'setup-retry', by: 'user', patch: { setup: { status: 'running', display: display ?? command, provenance } } })
245
+ void runSetup(repo, laneId, command, session, display ?? command)
246
+ return result(lane, `setup restarted: ${display ?? command}`)
247
+ }
248
+
249
+ // ─── binding (reserve a lane for a child; the disputed-binding card) ────
250
+
251
+ /**
252
+ * The disputed-binding card (design D4): the reconciliation outcome leads
253
+ * and the only constructive choice is an explicit force-reclaim.
254
+ * @param {any} agent @param {any} lane @param {any} reconciliation @param {any} copy @param {any} repo @param {AbortSignal} [signal]
255
+ * @returns {Promise<boolean>} whether the user confirmed the force-reclaim
256
+ */
257
+ async function askForceReclaim(agent, lane, reconciliation, copy, repo, signal) {
258
+ if (!deps.ask) return false
259
+ /** @type {any} */
260
+ let answer
261
+ try {
262
+ answer = await deps.ask(agent, [{
263
+ id: 'abandon',
264
+ header: copy.abandonHeader,
265
+ question: copy.abandonQuestion(lane.title),
266
+ detail: copy.abandonDisputedDetail(lane, lane.boundChild, reconciliation?.verdict ?? null, repo.mainRoot, residueOf(repo, lane)),
267
+ options: [
268
+ { label: copy.forceReclaim, description: copy.forceReclaimDescription },
269
+ { label: copy.cancel, description: copy.abandonDescriptions.cancel },
270
+ ],
271
+ }], signal)
272
+ } catch {
273
+ return false
274
+ }
275
+ return answer?.answers?.find((/** @type {any} */ entry) => entry.id === 'abandon')?.selected?.[0] === copy.forceReclaim
276
+ }
277
+
278
+ /**
279
+ * Reserve a lane for a child before it is spawned. Writers move the lane to
280
+ * `working` under the lock (a second writer is refused LANE_BUSY);
281
+ * read-only investigators leave the lane state alone.
282
+ * @param {any} parentSession @param {string} laneId @param {{ readOnly: boolean }} options
283
+ */
284
+ async function prepareBind(parentSession, laneId, { readOnly }) {
285
+ const repo = await repoFor(cwdOf(parentSession))
286
+ const spec = (/** @type {any} */ lane) => ({ laneId: lane.id, lanePath: pathKey(lane.path), scope: lane.scope?.length ? lane.scope : null, readOnly })
287
+ if (readOnly) {
288
+ const lane = laneOf(repo, laneId)
289
+ if (!isActive(lane) && lane.state !== 'kept') throw new WorktreeError(WORKTREE_CODES.LANE_NOT_DISPATCHABLE, `lane ${laneId} is ${lane.state}`, { lane: laneId })
290
+ return { lane, repo, spec: spec(lane), contract: renderChildContract(lane, { readOnly: true }), commit: async () => {}, rollback: async () => {} }
291
+ }
292
+ const nonce = `pending:${randomUUID()}`
293
+ /** @type {any} */
294
+ let before
295
+ // Zombie-binding reconciliation (D1): a lane stuck in `working` on a dead
296
+ // binding settles first, so the bind proceeds from the lane's post-settle
297
+ // state; a standing refusal is re-thrown atomically inside apply below.
298
+ const stuck = laneOf(repo, laneId)
299
+ if (stuck.state === 'working' && stuck.boundChild) await reconcileBinding(repo, stuck, parentSession)
300
+ const lane = await apply(repo, laneId, { type: 'bind', patch: { boundChild: nonce }, code: WORKTREE_CODES.LANE_NOT_DISPATCHABLE }, (current) => {
301
+ if (current.state === 'working' && current.boundChild) throw new WorktreeError(WORKTREE_CODES.LANE_BUSY, `lane ${laneId} already has a bound worker (${current.boundChild})`, { lane: laneId, next: nextFor(current) })
302
+ before = current
303
+ })
304
+ return {
305
+ lane,
306
+ repo,
307
+ spec: spec(lane),
308
+ contract: renderChildContract(lane, { readOnly: false }),
309
+ /**
310
+ * Commit the spawned child's id onto the reservation — authoritative
311
+ * (lane-binding-authoritative-commit): the core's commitBinding CAS
312
+ * checks nonce AND `working` inside the ledger lock, so a reservation
313
+ * reclaimed mid-spawn (force-reclaim / reconcile --rebuild) or a lane
314
+ * reconciled to `abandoned` refuses LANE_RESERVATION_LOST instead of
315
+ * silently authorising the child. Success and refusal are both audited
316
+ * (D4); the refusal's model-facing copy and `next` live here (D2).
317
+ * @param {string} childId
318
+ */
319
+ commit: async (childId) => {
320
+ try {
321
+ const committed = await commitBinding(repo, laneId, nonce, childId)
322
+ deps.audit('bind', { lane: laneId, nonce, child: childId, outcome: 'committed' }, repo.mainRoot, parentSession?.id ?? null)
323
+ return committed
324
+ } catch (error) {
325
+ if (/** @type {any} */ (error)?.code !== WORKTREE_CODES.LANE_RESERVATION_LOST) throw error
326
+ const facts = /** @type {any} */ (error).data ?? {}
327
+ deps.audit('bind', { lane: laneId, nonce, child: null, outcome: 'refused', state: facts.state ?? null, reservationHeld: facts.reservationHeld ?? false }, repo.mainRoot, parentSession?.id ?? null)
328
+ /** @type {any} */
329
+ let current = null
330
+ try {
331
+ current = laneOf(repo, laneId)
332
+ } catch {
333
+ // the lane record is gone (a rebuild dropped it): no nextFor
334
+ }
335
+ const state = facts.state ?? current?.state ?? 'gone'
336
+ const detail = facts.reservationHeld
337
+ ? `the lane left the working state (now ${state})`
338
+ : `the reservation was reclaimed while the worker spawned (the lane is now ${state})`
339
+ throw new WorktreeError(WORKTREE_CODES.LANE_RESERVATION_LOST, `lane ${laneId} refused the binding commit: ${detail}; the spawned child was never authorised and is torn down`, {
340
+ lane: laneId,
341
+ next: (current ? nextFor(current) : null) ?? { waitFor: 'user', hint: 'the lane is gone or abandoned; open a fresh lane with worktree_open and re-delegate' },
342
+ })
343
+ }
344
+ },
345
+ rollback: async () => {
346
+ await repo.ledger.update((/** @type {any} */ ledger) => {
347
+ const index = ledger.lanes.findIndex((/** @type {any} */ entry) => entry.id === laneId)
348
+ if (index === -1 || ledger.lanes[index].boundChild !== nonce) return {}
349
+ ledger.lanes[index] = before
350
+ return { ledger }
351
+ })
352
+ },
353
+ }
354
+ }
355
+
356
+ // ─── landing ──────────────────────────────────────────────────────────
357
+
358
+ /**
359
+ * worktree_land (agent asks the user) or /worktree land (the user's
360
+ * command is the approval).
361
+ * @param {any} agent - the calling main agent
362
+ * @param {string} laneId
363
+ * @param {{ userApproved?: boolean, signal?: AbortSignal }} [options]
364
+ */
365
+ async function land(agent, laneId, options = {}) {
366
+ const session = agent?.session
367
+ const repo = await repoFor(cwdOf(session))
368
+ const { mainBranch } = await refresh(repo)
369
+ const lane = laneOf(repo, laneId)
370
+ if (!LANDABLE_FROM.includes(lane.state) || !lane.landableTree) {
371
+ throw new WorktreeError(WORKTREE_CODES.NOT_LANDABLE, `lane ${laneId} is ${lane.state}${lane.reason ? ` (${lane.reason})` : ''}; only a checked, landable lane can be merged`, { lane: laneId, next: nextFor(lane) })
372
+ }
373
+ const tree = await git.tree(lane.path)
374
+ if (tree !== lane.landableTree) throw new WorktreeError(WORKTREE_CODES.STALE_LANDABLE, `lane ${laneId} changed after it was checked`, { lane: laneId, next: { tool: 'worktree_check', args: { lane: laneId } } })
375
+ if (mainBranch !== lane.base.branch) {
376
+ throw new WorktreeError(WORKTREE_CODES.BASE_MOVED, `the main worktree is on ${mainBranch ?? 'a detached HEAD'}, not ${lane.base.branch}`, { lane: laneId, next: { waitFor: 'user', hint: `the user checks out ${lane.base.branch} in the main worktree` } })
377
+ }
378
+ const precheck = await git.mergeTreeCheck(repo.mainRoot, lane.base.branch, lane.branch)
379
+ if (!precheck.clean) {
380
+ const conflicted = await apply(repo, laneId, { type: 'conflict', reason: `conflicts with ${lane.base.branch}: ${precheck.conflicts.join(', ')}`, patch: { conflicts: precheck.conflicts } })
381
+ return result(conflicted, `not merged: ${precheck.conflicts.length} conflicting path(s)`, { conflicts: precheck.conflicts })
382
+ }
383
+ if (!(await git.indexEmpty(repo.mainRoot))) {
384
+ throw new WorktreeError(WORKTREE_CODES.MAIN_STAGED, 'the main worktree has staged changes; commit or unstage them before merging', { lane: laneId, next: { waitFor: 'user', hint: 'the user commits or unstages the staged changes' } })
385
+ }
386
+ const changed = await git.changedFiles(repo.mainRoot, lane.base.branch, lane.branch)
387
+ const dirty = (await git.status(repo.mainRoot)).map((entry) => entry.path)
388
+ const overlap = dirty.filter((path) => changed.includes(path))
389
+ if (overlap.length > 0) {
390
+ throw new WorktreeError(WORKTREE_CODES.MAIN_DIRTY_OVERLAP, `uncommitted changes in the main worktree touch lane files: ${overlap.join(', ')}`, { lane: laneId, data: { paths: overlap }, next: { waitFor: 'user', hint: 'the user commits or stashes those files' } })
391
+ }
392
+ const heads = { main: await git.revParse(repo.mainRoot, 'HEAD'), lane: await git.revParse(lane.path, 'HEAD') }
393
+ const stat = await git.diffStat(repo.mainRoot, lane.base.branch, lane.branch)
394
+ const by = options.userApproved ? 'user' : 'host'
395
+ const approveMode = approveModeSafe(agent?.session)
396
+ const auto = approveMode !== 'manual'
397
+ // Auto-approve mode (design D3): the approval card is bypassed entirely —
398
+ // no `awaiting-approval`, no ask funnel, no card-open re-verification
399
+ // window (the entry tree check above already bound the merge target).
400
+ // Every precheck before this point is shared with the manual path. The
401
+ // manual path below stays byte-identical.
402
+ if (!options.userApproved && !auto) {
403
+ const declined = (/** @type {string} */ reason, /** @type {string | undefined} */ feedback) => apply(repo, laneId, { type: 'decline', reason, patch: { decline: { reason, feedback: feedback ?? null, at: now() } } })
404
+ if (!deps.ask) {
405
+ await apply(repo, laneId, { type: 'ask' })
406
+ const lane2 = await declined('no user-question answerer is available')
407
+ return result(lane2, 'not merged: nobody could be asked for approval')
408
+ }
409
+ await apply(repo, laneId, { type: 'ask', patch: { asking: { pid, at: now() } } })
410
+ asking.add(laneId)
411
+ const commits = await git.commits(repo.mainRoot, lane.base.branch, lane.branch)
412
+ const copy = cardCopy(cardLocale(deps.localeOf?.(session?.id)))
413
+ const mergeLabel = copy.mergeOption(lane.base.branch)
414
+ /** @type {any} */
415
+ let answer
416
+ try {
417
+ answer = await deps.ask(agent, [{
418
+ id: 'merge',
419
+ header: copy.mergeHeader,
420
+ question: copy.mergeQuestion(lane.title, lane.base.branch),
421
+ detail: copy.mergeDetail({ lane, commits, stat, verification: lane.check }),
422
+ options: [{ label: mergeLabel, description: copy.mergeOptionDescription(lane.branch) }, { label: copy.notNow, description: copy.notNowDescription }],
423
+ }], options.signal)
424
+ } catch (error) {
425
+ const lane2 = await declined(`approval not given (${/** @type {any} */ (error)?.code ?? /** @type {any} */ (error)?.message ?? 'cancelled'})`)
426
+ return result(lane2, 'not merged: the approval card was dismissed or unavailable')
427
+ } finally {
428
+ asking.delete(laneId)
429
+ }
430
+ const reply = answer?.answers?.find((/** @type {any} */ entry) => entry.id === 'merge')
431
+ const approved = reply?.selected?.length === 1 && reply.selected[0] === mergeLabel && !reply.custom
432
+ if (!approved) {
433
+ const lane2 = await declined(reply?.custom ? 'the user replied instead of approving' : 'the user chose not to merge now', reply?.custom)
434
+ return result(lane2, 'not merged: the user did not approve', reply?.custom ? { feedback: reply.custom } : {})
435
+ }
436
+ const sameMain = (await git.revParse(repo.mainRoot, 'HEAD')) === heads.main && (await git.currentBranch(repo.mainRoot)) === lane.base.branch
437
+ const sameLane = (await git.revParse(lane.path, 'HEAD')) === heads.lane && (await git.tree(lane.path)) === lane.landableTree
438
+ if (!sameMain || !sameLane) {
439
+ await declined('the main worktree or the lane changed while the approval card was open')
440
+ throw new WorktreeError(WORKTREE_CODES.STALE_LANDABLE, 'the main worktree or the lane changed while the approval card was open; nothing was merged', { lane: laneId, next: { tool: 'worktree_check', args: { lane: laneId } } })
441
+ }
442
+ }
443
+ const merged = await git.mergeNoFf(repo.mainRoot, lane.branch, `merge(lane): ${lane.title} (${lane.id})`)
444
+ if (!merged.ok) {
445
+ const conflicted = await apply(repo, laneId, { type: 'conflict', reason: `merge failed and was aborted: ${merged.detail}` })
446
+ return result(conflicted, 'not merged: git refused the merge (aborted, main worktree unchanged)')
447
+ }
448
+ const landed = await apply(repo, laneId, { type: 'land', by, audit: auto ? { auto: true, approveMode } : undefined, patch: { land: { commit: merged.commit, at: now(), by, stat } } })
449
+ const diff = await git.diff(repo.mainRoot, `${merged.commit}^1`, merged.commit, 60_000).catch(() => '')
450
+ if (!auto) {
451
+ return result(landed, `merged ${lane.branch} into ${lane.base.branch} as ${merged.commit?.slice(0, 7)}`, { merge: { commit: merged.commit, stat }, diff })
452
+ }
453
+ // Auto cleanup after the merge (design D3): auto-keep → mode worktree,
454
+ // auto-clean → mode all. The existing cleanup() body runs unchanged —
455
+ // scratch sync first, never --force on an auto path, the Edit Lock residue
456
+ // probe feeding the summary. A blocked removal stays `landed` and is
457
+ // reported concisely with the typed --force retry instead of a false
458
+ // success (the user can clean up later).
459
+ const cleanupMode = approveMode === 'auto-keep' ? 'worktree' : 'all'
460
+ try {
461
+ const cleaned = await cleanup(session, laneId, /** @type {'worktree' | 'all'} */ (cleanupMode), { by: 'host', auto: true, approveMode })
462
+ return result(landed, `merged ${lane.branch} into ${lane.base.branch} as ${merged.commit?.slice(0, 7)}; auto cleanup (mode ${cleanupMode}): ${cleaned.summary}`, { merge: { commit: merged.commit, stat }, diff, cleanup: { state: cleaned.state, summary: cleaned.summary }, next: cleaned.next })
463
+ } catch (error) {
464
+ const message = String(/** @type {any} */ (error)?.message ?? error)
465
+ const next = /** @type {any} */ (error)?.next ?? null
466
+ return result(landed, `merged ${lane.branch} into ${lane.base.branch} as ${merged.commit?.slice(0, 7)}; auto cleanup blocked: ${message}`, { merge: { commit: merged.commit, stat }, diff, cleanup: { error: message, ...(next ? { next } : {}) } })
467
+ }
468
+ }
469
+
470
+ /**
471
+ * The post-merge cleanup card (user chooses keep / worktree / all).
472
+ * @param {any} agent @param {string} laneId @param {AbortSignal} [signal]
473
+ */
474
+ async function askCleanup(agent, laneId, signal) {
475
+ if (!deps.ask) return null
476
+ const repo = await repoFor(cwdOf(agent.session))
477
+ const lane = laneOf(repo, laneId)
478
+ if (lane.state !== 'landed') return null
479
+ const copy = cardCopy(cardLocale(deps.localeOf?.(agent.session?.id)))
480
+ // Read-only dirty probe BEFORE the card: the card discloses the counts and
481
+ // a destructive choice is then the user's informed explicit force removal.
482
+ const dirty = existsSync(lane.path) ? await git.dirtyCounts(lane.path) : null
483
+ const dirtyCard = hasDirtyContent(dirty)
484
+ /** @type {any} */
485
+ let answer
486
+ try {
487
+ answer = await deps.ask(agent, [{
488
+ id: 'cleanup',
489
+ header: copy.cleanupHeader,
490
+ question: copy.cleanupQuestion(lane.title),
491
+ detail: copy.cleanupDetail(lane, lane.land?.stat ?? { files: 0, added: 0, removed: 0 }, repo.mainRoot, residueOf(repo, lane), dirtyCard ? dirty : null),
492
+ options: [
493
+ { label: copy.choices.keep, description: copy.cleanupDescriptions.keep },
494
+ { label: copy.choices.worktree, description: dirtyCard ? copy.cleanupDescriptionsDirty.worktree : copy.cleanupDescriptions.worktree },
495
+ { label: copy.choices.all, description: dirtyCard ? copy.cleanupDescriptionsDirty.all : copy.cleanupDescriptions.all },
496
+ ],
497
+ }], signal)
498
+ } catch {
499
+ return null
500
+ }
501
+ const choice = answer?.answers?.find((/** @type {any} */ entry) => entry.id === 'cleanup')?.selected?.[0]
502
+ const mode = Object.entries(copy.choices).find(([, label]) => label === choice)?.[0]
503
+ if (!mode) return null
504
+ return cleanup(agent.session, laneId, /** @type {'keep' | 'worktree' | 'all'} */ (mode), { by: 'user', ...(mode !== 'keep' && dirtyCard ? { force: true } : {}) })
505
+ }
506
+
507
+ // ─── cleanup / abandon ────────────────────────────────────────────────
508
+
509
+ /** Copy the lane's .weir scratch into the main repository. @param {any} repo @param {any} lane */
510
+ function syncScratch(repo, lane) {
511
+ const source = join(lane.path, '.weir')
512
+ if (!existsSync(source)) return null
513
+ const target = join(repo.mainRoot, '.weir', 'lanes', lane.id)
514
+ mkdirSync(target, { recursive: true })
515
+ cpSync(source, target, { recursive: true, force: true })
516
+ return target
517
+ }
518
+
519
+ /**
520
+ * Concise blocked-removal error: the reason names the dirty counts when the
521
+ * probe sees them (never the raw git `fatal:` output), and `next` names the
522
+ * exact typed `--force` retry — the in-product escape for a lane that cannot
523
+ * be removed. Force stays the USER's privilege, so the hint directs whoever
524
+ * reads it (the model included) to the user's typed command.
525
+ * @param {any} lane @param {string} mode
526
+ */
527
+ async function blockedRemovalError(lane, mode) {
528
+ const dirty = existsSync(lane.path) ? await git.dirtyCounts(lane.path) : { modified: 0, untracked: 0 }
529
+ const reason = dirty.modified + dirty.untracked > 0
530
+ ? `removal blocked: the worktree contains modified or untracked files (${dirty.modified} modified, ${dirty.untracked} untracked)`
531
+ : 'removal blocked: git refused to remove the worktree (it may be locked)'
532
+ return new WorktreeError(WORKTREE_CODES.REMOVE_BLOCKED, reason, {
533
+ lane: lane.id,
534
+ next: { waitFor: 'user', hint: `the user retries with the explicit force override: /worktree clean ${lane.id} ${mode} --force` },
535
+ })
536
+ }
537
+
538
+ /**
539
+ * Auto paths (design D3) reuse this exact body with `{ by: 'host', auto: true,
540
+ * approveMode }`: the scratch sync, the removal (forced ONLY behind the
541
+ * user's informed explicit choice — a disclosed card choice or a typed
542
+ * `--force`, never an auto/model path), the residue warning and the
543
+ * blocked-removal semantics are identical; only the audit payload gains the
544
+ * auto marker.
545
+ * @param {any} session @param {string} laneId @param {'keep' | 'worktree' | 'all'} mode @param {{ by?: string, auto?: boolean, approveMode?: string, force?: boolean }} [options]
546
+ */
547
+ async function cleanup(session, laneId, mode, options = {}) {
548
+ if (!['keep', 'worktree', 'all'].includes(mode)) throw new Error('cleanup mode must be keep, worktree, or all')
549
+ const repo = await repoFor(cwdOf(session))
550
+ const lane = laneOf(repo, laneId)
551
+ const by = options.by ?? 'user'
552
+ if (!['landed', 'kept', 'abandoned'].includes(lane.state)) {
553
+ throw new WorktreeError(WORKTREE_CODES.ILLEGAL_TRANSITION, `lane ${laneId} is ${lane.state}; cleanup applies to merged (or abandoned) lanes`, { lane: laneId, next: nextFor(lane) })
554
+ }
555
+ if (mode === 'keep') {
556
+ if (lane.state !== 'landed') return result(lane, 'nothing to do: the worktree is already kept')
557
+ const kept = await apply(repo, laneId, { type: 'keep', by, patch: { cleanup: { mode: 'keep', at: now(), by } } })
558
+ return result(kept, `kept ${kept.path}`)
559
+ }
560
+ // Read-only residue probe BEFORE anything is removed (design D5): a
561
+ // warning only, never a gate, and it never mutates the authority.
562
+ const residue = residueOf(repo, lane)
563
+ const scratch = existsSync(lane.path) ? syncScratch(repo, lane) : null
564
+ // `force` reaches git only from an informed explicit user choice (a
565
+ // disclosed card choice via askCleanup, or the typed `--force`); auto and
566
+ // model paths never force.
567
+ const force = options.force === true && options.auto !== true
568
+ if (existsSync(lane.path)) {
569
+ try {
570
+ await git.worktreeRemove(repo.mainRoot, lane.path, { force })
571
+ } catch (error) {
572
+ if (!(error instanceof WorktreeError) || error.code !== WORKTREE_CODES.REMOVE_BLOCKED) throw error
573
+ // State unchanged: the blocked removal is recorded on the lane (so it
574
+ // stays retryable) and reported concisely with the --force escape.
575
+ const blocked = await blockedRemovalError(lane, mode)
576
+ await patchLane(repo, laneId, (current) => ({ cleanup: { ...(current.cleanup ?? {}), blocked: blocked.reason } }))
577
+ throw blocked
578
+ }
579
+ }
580
+ let branchNote = ''
581
+ if (mode === 'all' && (await git.branchExists(repo.mainRoot, lane.branch))) {
582
+ try {
583
+ await git.branchDelete(repo.mainRoot, lane.branch)
584
+ branchNote = `; deleted ${lane.branch}`
585
+ } catch (error) {
586
+ branchNote = `; kept ${lane.branch} (${/** @type {any} */ (error)?.message ?? error})`
587
+ }
588
+ }
589
+ const record = { mode, at: now(), by, scratch }
590
+ const next = lane.state === 'abandoned'
591
+ ? await patchLane(repo, laneId, () => ({ cleanup: record }))
592
+ : await apply(repo, laneId, { type: 'clean', by, patch: { cleanup: record } })
593
+ deps.audit('cleanup', { lane: laneId, mode, by, scratch, ...(force ? { forced: true } : {}), ...(options.auto === true ? { auto: true, approveMode: options.approveMode ?? null } : {}) }, repo.mainRoot, lane.ownerSession)
594
+ return result(next, `removed ${lane.path}${branchNote}${scratch ? `; scratch copied to ${scratch}` : ''}${residue ? `; ${residueNote(residue)}` : ''}`)
595
+ }
596
+
597
+ /**
598
+ * @param {any} agent @param {string} laneId
599
+ * @param {{ mode?: 'keep' | 'worktree' | 'all', force?: boolean, signal?: AbortSignal }} [options] - a mode is the user's typed choice (command path); `force` is the typed `--force` override
600
+ */
601
+ async function abandon(agent, laneId, options = {}) {
602
+ const repo = await repoFor(cwdOf(agent?.session))
603
+ let lane = laneOf(repo, laneId)
604
+ if (!isActive(lane)) throw new WorktreeError(WORKTREE_CODES.ILLEGAL_TRANSITION, `lane ${laneId} is already ${lane.state}`, { lane: laneId })
605
+ // Zombie-binding reconciliation BEFORE the confirmation card (design
606
+ // D1/D4): a binding with terminal evidence and no live side settles here
607
+ // through the childSettled path; a standing one is disclosed on the card
608
+ // with an explicit force-reclaim choice.
609
+ /** @type {any} */
610
+ let reconciliation = null
611
+ if (lane.boundChild) {
612
+ reconciliation = await reconcileBinding(repo, lane, agent?.session)
613
+ if (reconciliation?.settled) lane = reconciliation.lane
614
+ if (!isActive(lane)) throw new WorktreeError(WORKTREE_CODES.ILLEGAL_TRANSITION, `lane ${laneId} is already ${lane.state}`, { lane: laneId })
615
+ }
616
+ const unmerged = (await git.branchExists(repo.mainRoot, lane.branch)) ? await git.unmergedCount(repo.mainRoot, lane.base.branch, lane.branch) : 0
617
+ let mode = options.mode
618
+ // Auto-approve path (design D3): no card, the mapped mode decides —
619
+ // auto-keep removes the worktree and keeps the branch, auto-clean also
620
+ // deletes it. Force-reclaim is NEVER auto: a binding the reconciliation
621
+ // would not clear skips this branch entirely and falls back to the manual
622
+ // card below (reconciliation outcome + force-reclaim choice) in EVERY mode.
623
+ const approveMode = approveModeSafe(agent?.session)
624
+ const auto = !mode && approveMode !== 'manual' && !lane.boundChild
625
+ if (auto) mode = approveMode === 'auto-keep' ? 'worktree' : 'all'
626
+ const copy = cardCopy(cardLocale(deps.localeOf?.(agent?.session?.id)))
627
+ // Card-path facts: whether a destructive card choice is the user's informed
628
+ // explicit force removal (the card disclosed the dirty counts).
629
+ let cardChoice = false
630
+ let dirtyCard = false
631
+ if (!mode) {
632
+ if (!deps.ask) throw new WorktreeError(WORKTREE_CODES.MAIN_AGENT_ONLY, 'abandoning needs the user\'s confirmation and no answerer is available')
633
+ // A binding the reconciliation would not clear is the user's call
634
+ // (design D4): the disputed card states the outcome, and the explicit
635
+ // force-reclaim settles through the same path, audited as forced.
636
+ if (lane.boundChild) {
637
+ const reclaim = await askForceReclaim(agent, lane, reconciliation, copy, repo, options.signal)
638
+ if (!reclaim) return result(lane, 'not abandoned: the user cancelled')
639
+ reconciliation = await reconcileBinding(repo, lane, agent?.session, { forced: true })
640
+ lane = laneOf(repo, laneId)
641
+ if (lane.boundChild) throw new WorktreeError(WORKTREE_CODES.LANE_BUSY, `lane ${laneId} is still bound to ${lane.boundChild}; the force-reclaim could not settle it`, { lane: laneId })
642
+ }
643
+ // Read-only dirty probe BEFORE the card: the card discloses the counts
644
+ // and the destructive options name the permanent destruction.
645
+ const dirty = existsSync(lane.path) ? await git.dirtyCounts(lane.path) : null
646
+ dirtyCard = hasDirtyContent(dirty)
647
+ /** @type {any} */
648
+ let answer
649
+ try {
650
+ answer = await deps.ask(agent, [{
651
+ id: 'abandon',
652
+ header: copy.abandonHeader,
653
+ question: copy.abandonQuestion(lane.title),
654
+ detail: copy.abandonDetail(lane, unmerged, repo.mainRoot, residueOf(repo, lane), reconciliation?.settled ? { child: reconciliation.childId, forced: reconciliation.forced } : null, dirtyCard ? dirty : null),
655
+ options: [
656
+ { label: copy.choices.keep, description: copy.abandonDescriptions.keep },
657
+ { label: copy.choices.worktree, description: dirtyCard ? copy.abandonDescriptionsDirty.worktree : copy.abandonDescriptions.worktree },
658
+ { label: copy.choices.all, description: (dirtyCard ? copy.abandonDescriptionsDirty.all : copy.abandonDescriptions.all)(unmerged) },
659
+ { label: copy.cancel, description: copy.abandonDescriptions.cancel },
660
+ ],
661
+ }], options.signal)
662
+ } catch {
663
+ return result(lane, 'not abandoned: the confirmation card was dismissed')
664
+ }
665
+ const choice = answer?.answers?.find((/** @type {any} */ entry) => entry.id === 'abandon')?.selected?.[0]
666
+ mode = /** @type {any} */ (Object.entries(copy.choices).find(([, label]) => label === choice)?.[0])
667
+ if (!mode) return result(lane, 'not abandoned: the user cancelled')
668
+ cardChoice = true
669
+ }
670
+ if (lane.boundChild) {
671
+ const detail = reconciliation?.verdict ? ` (binding reconciliation: ${reconcileNote(reconciliation.verdict)}; call worktree_abandon without a mode and use the card's force-reclaim)` : ''
672
+ throw new WorktreeError(WORKTREE_CODES.LANE_BUSY, `lane ${laneId} has a running worker; stop it first${detail}`, { lane: laneId })
673
+ }
674
+ // Removal is forced ONLY behind the user's informed explicit choice: a
675
+ // destructive choice on the dirty-disclosing card, or the typed `--force`.
676
+ // Typed-mode and auto paths (auto never reaches the card) never force.
677
+ const force = (options.force === true && !auto) || (cardChoice && dirtyCard)
678
+ const auditExtra = { ...(auto ? { auto: true, approveMode } : {}), ...(force ? { forced: true } : {}) }
679
+ const abandoned = await apply(repo, laneId, { type: 'abandon', by: auto ? 'host' : 'user', reason: auto ? `abandoned automatically (${approveMode} mode)` : 'abandoned by the user', audit: Object.keys(auditExtra).length > 0 ? auditExtra : undefined, patch: { cleanup: { mode, at: now(), by: auto ? 'host' : 'user' } } })
680
+ if (mode === 'keep') return result(abandoned, `abandoned; ${abandoned.path} and ${abandoned.branch} are kept`)
681
+ // Read-only residue probe BEFORE anything is removed (design D5): a
682
+ // warning only, never a gate, and it never mutates the authority. The
683
+ // confirmation card already carried the warning when there was one.
684
+ const residue = residueOf(repo, lane)
685
+ const scratch = existsSync(lane.path) ? syncScratch(repo, lane) : null
686
+ if (existsSync(lane.path)) {
687
+ try {
688
+ await git.worktreeRemove(repo.mainRoot, lane.path, { force })
689
+ } catch (error) {
690
+ if (!(error instanceof WorktreeError) || error.code !== WORKTREE_CODES.REMOVE_BLOCKED) throw error
691
+ // Abandoned, but the worktree stays: recorded as kept+blocked so a
692
+ // later cleanup retries once the user types the explicit override.
693
+ const blocked = await blockedRemovalError(lane, mode)
694
+ await patchLane(repo, laneId, (current) => ({ cleanup: { ...current.cleanup, mode: 'keep', blocked: blocked.reason } }))
695
+ throw blocked
696
+ }
697
+ }
698
+ let branchNote = ''
699
+ if (mode === 'all' && (await git.branchExists(repo.mainRoot, lane.branch))) {
700
+ await git.branchDelete(repo.mainRoot, lane.branch, { force: unmerged > 0 })
701
+ branchNote = `; deleted ${lane.branch}${unmerged > 0 ? ` (${unmerged} unmerged commit(s) discarded)` : ''}`
702
+ }
703
+ const final = await patchLane(repo, laneId, (current) => ({ cleanup: { ...current.cleanup, scratch } }))
704
+ return result(final, `abandoned; removed ${lane.path}${branchNote}${residue ? `; ${residueNote(residue)}` : ''}`)
705
+ }
706
+
707
+ // ─── watches (lane state subscriptions) ────────────────────────────────
708
+
709
+ /**
710
+ * worktree_watch: subscribe the calling session to conclusion states of one
711
+ * lane in this repository (including lanes another session opened). One-shot:
712
+ * the first target state hit — or the expiry — delivers exactly one notice
713
+ * to the SUBSCRIBER and removes the watch. Re-subscribing the same lane
714
+ * replaces the old watch. A lane already in a target state hits immediately
715
+ * (delivered once, never stored). The lifetime comes from the
716
+ * worktreeWatchTimeoutMinutes setting, frozen into `expiresAt` at subscribe
717
+ * time; the model cannot override it (no timeout parameter).
718
+ * @param {any} session - the subscribing (main) session
719
+ * @param {{ lane: string, states: string[] }} args
720
+ */
721
+ async function watch(session, args) {
722
+ const states = normalizeWatchStates(args?.states)
723
+ const laneId = typeof args?.lane === 'string' ? args.lane : ''
724
+ const sessionId = session?.id
725
+ if (typeof sessionId !== 'string' || !sessionId) throw new Error('worktree_watch: the subscribing session has no id')
726
+ const repo = await repoFor(cwdOf(session))
727
+ // Reconcile first so the immediate-hit test below reads the lane's true
728
+ // state (and so expired watches are pruned before this one is stored).
729
+ await refresh(repo)
730
+ const timeoutMinutes = watchTimeoutMinutesOf(config())
731
+ const entry = createWatch({ id: randomUUID(), laneId, sessionId, states, now: now(), timeoutMinutes })
732
+ // Check-vs-insert is atomic inside the ledger lock: a lane that reaches a
733
+ // target state concurrently either hits BEFORE (immediate path) or AFTER
734
+ // (the transition's own hit scan) this subscribe — never both, never none.
735
+ const outcome = await repo.ledger.update((/** @type {any} */ ledger) => {
736
+ const current = ledger.lanes.find((/** @type {any} */ record) => record.id === laneId)
737
+ if (!current) throw new WorktreeError(WORKTREE_CODES.UNKNOWN_LANE, `no lane "${laneId}" in ${repo.rootPath}`)
738
+ if (states.includes(current.state)) return { result: { lane: current, immediate: true, replaced: null } }
739
+ const replaced = upsertWatch(ledger, entry)
740
+ return { ledger, result: { lane: current, immediate: false, replaced } }
741
+ })
742
+ const { lane, immediate, replaced } = /** @type {any} */ (outcome)
743
+ if (replaced) disarmWatch(replaced.id)
744
+ if (immediate) {
745
+ deps.audit('watch', { lane: lane.id, sessionId, states, outcome: 'hit-immediate' }, repo.mainRoot, sessionId)
746
+ try {
747
+ deps.notify(sessionId, renderWatchHit(lane))
748
+ } catch (error) {
749
+ deps.logger?.warn?.(`worktree: watch-hit notification failed: ${/** @type {any} */ (error)?.message ?? error}`)
750
+ }
751
+ return result(lane, `already ${lane.state}; the watch fired immediately and was not stored`, { hit: lane.state })
752
+ }
753
+ armWatch(repo, entry)
754
+ deps.audit('watch', { lane: lane.id, sessionId, states, expiresAt: entry.expiresAt, replaced: replaced?.id ?? null, outcome: 'subscribed' }, repo.mainRoot, sessionId)
755
+ return result(lane, `watching for ${states.join(', ')} until ${new Date(entry.expiresAt).toISOString()}`, { watch: { lane: lane.id, states: entry.states, expiresAt: entry.expiresAt } })
756
+ }
757
+
758
+ // ─── repository config ────────────────────────────────────────────────
759
+
760
+ /** /worktree init: current config + detected suggestions (never written here). @param {any} session */
761
+ async function initSuggestions(session) {
762
+ const repo = await repoFor(cwdOf(session))
763
+ let current = null
764
+ let error = null
765
+ try {
766
+ current = readConfig(repo)
767
+ } catch (caught) {
768
+ error = String(/** @type {any} */ (caught)?.message ?? caught)
769
+ }
770
+ const files = readdirSync(repo.mainRoot)
771
+ let packageJson
772
+ try {
773
+ packageJson = JSON.parse(readFileSync(join(repo.mainRoot, 'package.json'), 'utf8'))
774
+ } catch {
775
+ packageJson = undefined
776
+ }
777
+ // The setup suggestion must be an invocation that works on THIS host
778
+ // (bundled-runtime resolution), not a bare manager name — the user may
779
+ // accept it verbatim into the config.
780
+ let setup = null
781
+ let setupWarning = null
782
+ const manager = setupManagerFor(files)
783
+ if (manager) {
784
+ const derived = await deriveSetup(manager)
785
+ if ('error' in derived) {
786
+ setup = bareSetupCommand(manager)
787
+ setupWarning = setupMissingReason(manager).replace('setup needs', 'suggestion note:')
788
+ } else {
789
+ setup = derived.command
790
+ }
791
+ }
792
+ return { file: join(repo.rootPath, CONFIG_FILE), current, error, suggested: { setup, setupWarning, check: suggestChecks({ packageJson, fileNames: files }) } }
793
+ }
794
+
795
+ /** Write the repository config after the user confirmed it. @param {any} session @param {any} value */
796
+ async function writeConfig(session, value) {
797
+ const repo = await repoFor(cwdOf(session))
798
+ const parsed = parseRepoConfig(value)
799
+ ensureExclude(repo.commonDir, repo.root)
800
+ mkdirSync(repo.rootPath, { recursive: true })
801
+ const file = join(repo.rootPath, CONFIG_FILE)
802
+ writeFileSync(file, `${JSON.stringify({ ...(parsed.setup ? { setup: parsed.setup } : {}), check: parsed.check }, null, 2)}\n`)
803
+ return { file, config: parsed }
804
+ }
805
+
806
+ /** /worktree reconcile [--rebuild] @param {any} session @param {{ rebuild?: boolean }} [options] */
807
+ async function reconcileCommand(session, options = {}) {
808
+ const repo = await repoFor(cwdOf(session))
809
+ if (options.rebuild) {
810
+ const worktrees = await git.worktreeList(repo.mainRoot)
811
+ const prefix = `${pathKey(repo.rootPath)}/`
812
+ const at = now()
813
+ const lanes = worktrees
814
+ .filter((entry) => pathKey(entry.path).startsWith(prefix) && entry.branch?.startsWith('weir/'))
815
+ .map((entry) => {
816
+ const id = /** @type {string} */ (entry.branch).slice('weir/'.length)
817
+ return {
818
+ id, title: id, path: entry.path, branch: /** @type {string} */ (entry.branch), base: { branch: 'main', commit: null }, scope: [], state: 'working',
819
+ ownerSession: session?.id ?? null, boundChild: null, baseMoved: false, landableTree: null, setup: { status: 'unknown' }, check: null, land: null,
820
+ cleanup: null, reason: 'rebuilt from git; base assumed from the main worktree branch', createdAt: at, updatedAt: at, history: [],
821
+ }
822
+ })
823
+ const mainBranch = await git.currentBranch(repo.mainRoot)
824
+ for (const lane of lanes) lane.base.branch = mainBranch ?? 'main'
825
+ const seq = lanes.reduce((max, lane) => Math.max(max, Number(/-(\d+)$/.exec(lane.id)?.[1] ?? 0)), 0)
826
+ await repo.ledger.replace({ schemaVersion: 1, seq, lanes })
827
+ deps.audit('reconcile', { rebuilt: lanes.map((lane) => lane.id) }, repo.mainRoot, session?.id ?? null)
828
+ return { rebuilt: lanes.map((lane) => lane.id), unmanaged: [] }
829
+ }
830
+ const outcome = await refresh(repo)
831
+ return { rebuilt: null, unmanaged: outcome.unmanaged }
832
+ }
833
+
834
+ /** Normalized absolute path of a tool argument relative to a session cwd. @param {string} cwd @param {string} path */
835
+ function resolveArgPath(cwd, path) {
836
+ return pathKey(isAbsolute(path) ? path : resolvePath(cwd, path))
837
+ }
838
+
839
+ /** The resolved global auto-approve default (command echo; never a raw invalid value). */
840
+ const approveGlobalSafe = () => {
841
+ try {
842
+ return deps.approveGlobal?.() ?? 'auto-clean'
843
+ } catch {
844
+ return 'auto-clean'
845
+ }
846
+ }
847
+
848
+ return {
849
+ open, setup, prepareBind, land, askCleanup, cleanup, abandon, watch,
850
+ initSuggestions, writeConfig, reconcile: reconcileCommand, resolveArgPath,
851
+ approveGlobal: approveGlobalSafe,
852
+ }
853
+ }