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
@@ -1,1517 +1,28 @@
1
- // Worktree lane service: the host side of the lane pipeline. Every lane
2
- // mutation in the product — tools, /worktree commands, child settlement —
3
- // goes through this module, which reads facts from git, decides with the pure
4
- // rule/state modules, persists through the ledger, and reports the next step.
5
- // ctx-free by construction: git, shell, questions, notifications, audit and
6
- // the session-mode reader are injected (src/worktree/index.js wires them).
7
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
8
- import { dirname, isAbsolute, join, resolve as resolvePath, sep } from 'node:path'
9
- import { randomUUID } from 'node:crypto'
10
- import { WORKTREE_CODES, WorktreeError } from './errors.js'
11
- import { CHECKABLE, DISPATCHABLE, LANDABLE_FROM, TRANSIENT, isActive, nextFor, transition } from './state.js'
12
- import { collectWatchHits, createWatch, normalizeWatchStates, pruneExpiredWatches, removeWatchForExpiry, upsertWatch, watchFacts, watchTimeoutMinutesOf } from './watches.js'
13
- import { branchFor, laneIdFor, normalizeRoot, parseRepoConfig, scopesOverlap, setupManagerFor, suggestChecks } from './rules.js'
14
- import { bareSetupCommand, setupMissingReason } from './pkgmgr.js'
15
- import { reconcile } from './reconcile.js'
16
- import { createLedger } from './ledger.js'
17
- import { ensureExclude, hasExclude } from './exclude.js'
18
- import { renderBoard, renderChildContract, renderNotice, renderWatchExpired, renderWatchHit } from './prompts.js'
19
- import { cardCopy, cardLocale, hasDirtyContent } from './cards.js'
20
- import { authorityResidue } from './authority.js'
21
-
22
- export const CONFIG_FILE = '.config.json'
23
- const SETUP_TIMEOUT_MS = 600_000
24
- const LOG_TAIL_LINES = 40
25
-
26
- /** Audit kind per state-machine event (AUDIT_SUBTYPES.worktree). */
27
- const AUDIT_KIND = Object.freeze({
28
- 'setup-ok': 'setup', 'setup-fail': 'setup', 'setup-retry': 'setup', bind: 'bind', checked: 'checked',
29
- 'check-pass': 'check', 'check-fail': 'check', invalidate: 'invalidate', ask: 'ask', decline: 'decline',
30
- conflict: 'conflict', land: 'land', keep: 'cleanup', clean: 'cleanup', abandon: 'abandon', missing: 'abandon',
31
- })
32
-
33
- /**
34
- * Model-facing English residue warning appended to cleanup/abandon tool
35
- * results (the human-facing card copy localizes its own in cards.js).
36
- * @param {{ operations: number, locks: number }} residue
37
- */
38
- function residueNote(residue) {
39
- const parts = []
40
- if (residue.operations > 0) parts.push(`${residue.operations} unresolved Edit Lock operation(s)`)
41
- if (residue.locks > 0) parts.push(`${residue.locks} Edit Lock lock(s)`)
42
- 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`
43
- }
44
-
45
- /**
46
- * Comparable form of a path: realpath when it exists, '/'-separated, and
47
- * case-folded on win32.
48
- * @param {string} path
49
- * @param {string} [platform]
50
- */
51
- export function pathKey(path, platform = process.platform) {
52
- let real = path
53
- try {
54
- real = realpathSync.native(path)
55
- } catch {
56
- // a path that does not exist yet keeps its lexical form
57
- let parent = dirname(path)
58
- const tail = [path.slice(parent.length + 1)]
59
- while (parent !== dirname(parent)) {
60
- try {
61
- real = join(realpathSync.native(parent), ...tail.reverse())
62
- break
63
- } catch {
64
- tail.push(parent.slice(dirname(parent).length + 1))
65
- parent = dirname(parent)
66
- }
67
- }
68
- }
69
- const slashed = real.split(sep).join('/').replace(/\/+$/, '')
70
- return platform === 'win32' ? slashed.toLowerCase() : slashed
71
- }
72
-
73
- /**
74
- * @typedef {object} LaneServiceDeps
75
- * @property {ReturnType<typeof import('./git.js').createGit>} git
76
- * @property {null | ((request: { command: string, cwd: string, timeoutMs: number, session?: any }) => Promise<{ code: number, output: string, denied: boolean, timedOut: boolean }>)} shellRun
77
- * @property {() => { enabled: boolean, root: string, maxActive: number, autoSetup: boolean }} settings
78
- * @property {null | ((agent: any, questions: any[], signal?: AbortSignal) => Promise<{ answers: Array<{ id: string, selected: string[], custom?: string }> }>)} ask
79
- * @property {(sessionId: string, text: string) => void} notify
80
- * @property {(type: string, data: any, root: string, sessionId?: string | null) => void} audit
81
- * @property {(session: any) => boolean} modeOf
82
- * @property {(session: any) => 'manual' | 'auto-keep' | 'auto-clean'} [approveModeOf] - the session's effective auto-approve mode (projection override ?? global setting ?? module default); absent = manual (no auto paths)
83
- * @property {(session: any) => 'session' | 'global'} [approveModeSourceOf] - where the effective mode comes from; absent = global
84
- * @property {() => 'manual' | 'auto-keep' | 'auto-clean'} [approveGlobal] - the resolved global default (module default when unset/invalid); absent = auto-clean
85
- * @property {(sessionId: string | undefined) => string | undefined} [localeOf] - the GUI language last reported for a session
86
- * @property {() => number} [now]
87
- * @property {(handler: () => void, delayMs: number) => any} [setTimer] - watch-expiry scheduler (tests); defaults to an unref'd setTimeout
88
- * @property {(handle: any) => void} [clearTimer]
89
- * @property {number} [pid] - this process id (tests)
90
- * @property {{ warn?: (message: string) => void }} [logger]
91
- * @property {(root: string, sessionId: string | null | undefined) => ({ operations: number, locks: number } | null)} [authorityResidue] - read-only Edit Lock residue probe feeding the cleanup/abandon warning (tests); defaults to the bounded snapshot read in ./authority.js
92
- * @property {(boundChild: string, ownerSession: string | null, context: { root: string }) => Promise<{ childAlive: boolean, ownerAlive: boolean, terminalEvidence: { source: string, detail: string } | null }>} [bindingLiveness] - read-only zombie-binding probe (design D2); absent = LANE_BUSY refusals stand unreconciled
93
- */
94
-
95
- /** @param {LaneServiceDeps} deps */
1
+ // Worktree lane service: the single entry point for every lane change in the
2
+ // product. Every lane mutation — tools, /worktree commands, child settlement —
3
+ // still goes through this module, which is now a thin facade assembling the
4
+ // three lane modules: lane-core.js owns the ledger + check engine (the one
5
+ // apply() every state change goes through), lane-workflows.js the decision
6
+ // workflows (cards, auto-approve, removal semantics), lane-views.js the
7
+ // read-only views. ctx-free by construction: git, shell, questions,
8
+ // notifications, audit and the session-mode reader are injected
9
+ // (src/worktree/index.js wires them).
10
+ import { createLaneCore } from './lane-core.js'
11
+ import { createLaneWorkflows } from './lane-workflows.js'
12
+ import { createLaneViews } from './lane-views.js'
13
+
14
+ export { CONFIG_FILE, pathKey } from './lane-core.js'
15
+
16
+ /** @param {import('./lane-core.js').LaneServiceDeps} deps */
96
17
  export function createLaneService(deps) {
97
- const { git, settings } = deps
98
- const now = deps.now ?? Date.now
99
- /** Effective auto-approve mode for a session, fail-safe to `manual` so a missing dep (tests, older wiring) never auto-approves. @param {any} session */
100
- const approveModeSafe = (session) => {
101
- try {
102
- return deps.approveModeOf?.(session) ?? 'manual'
103
- } catch {
104
- return 'manual'
105
- }
106
- }
107
- /** Where the effective mode comes from ('session' override vs 'global' setting). @param {any} session */
108
- const approveModeSourceSafe = (session) => {
109
- try {
110
- return deps.approveModeSourceOf?.(session) ?? 'global'
111
- } catch {
112
- return 'global'
113
- }
114
- }
115
- /** @type {Map<string, Promise<any>>} cwd+root → repo */
116
- const pending = new Map()
117
- /** @type {Map<string, any>} cwd+root → resolved repo (sync access for the prompt board) */
118
- const resolved = new Map()
119
- /** @type {Promise<number[] | null> | null} */
120
- let versionProbe = null
121
- /** Lanes whose approval card is open in THIS process. @type {Set<string>} */
122
- const asking = new Set()
123
- const pid = deps.pid ?? process.pid
124
- /** Whether an approval card recorded in the ledger is still open somewhere. @param {any} lane */
125
- const askAlive = (lane) => {
126
- const owner = lane.asking?.pid
127
- if (owner === pid) return asking.has(lane.id)
128
- if (typeof owner !== 'number') return false
129
- try {
130
- process.kill(owner, 0)
131
- return true
132
- } catch {
133
- return false
134
- }
135
- }
136
-
137
- // ─── watch-expiry timers (this instance only; the ledger owns the truth) ─
138
-
139
- /** Watch-expiry timers armed by THIS service instance, watch id → handle.
140
- * The lock arbitrates between instances: whichever instance removes the
141
- * record under the ledger lock delivers that watch's single expiry notice. */
142
- const watchTimers = new Map()
143
- const setTimer = deps.setTimer ?? ((/** @type {() => void} */ handler, /** @type {number} */ delayMs) => {
144
- const timer = setTimeout(handler, delayMs)
145
- timer.unref?.()
146
- return timer
147
- })
148
- const clearTimer = deps.clearTimer ?? clearTimeout
149
-
150
- /** @param {string} watchId */
151
- function disarmWatch(watchId) {
152
- const handle = watchTimers.get(watchId)
153
- if (handle !== undefined) {
154
- clearTimer(handle)
155
- watchTimers.delete(watchId)
156
- }
157
- }
158
-
159
- /** @param {any} repo @param {any} entry */
160
- function armWatch(repo, entry) {
161
- if (watchTimers.has(entry.id)) return
162
- const delay = entry.expiresAt - now()
163
- if (delay <= 0) return
164
- watchTimers.set(entry.id, setTimer(() => {
165
- watchTimers.delete(entry.id)
166
- void expireWatch(repo, entry.id).catch((error) => deps.logger?.warn?.(`worktree: watch expiry failed: ${/** @type {any} */ (error)?.message ?? error}`))
167
- }, delay))
168
- }
169
-
170
- /**
171
- * An expiry timer fired: remove the watch under the ledger lock; only the
172
- * instance that actually removes the record delivers the expiry notice
173
- * (the watch's single delivery), so two instances can never double-deliver.
174
- * @param {any} repo @param {string} watchId
175
- */
176
- async function expireWatch(repo, watchId) {
177
- const removed = await repo.ledger.update((/** @type {any} */ ledger) => {
178
- const watch = removeWatchForExpiry(ledger, watchId, now())
179
- return watch ? { ledger, result: watch } : {}
180
- })
181
- if (!removed) return
182
- deps.audit('watch', { lane: removed.laneId, sessionId: removed.sessionId, states: removed.states, outcome: 'expired' }, repo.mainRoot, removed.sessionId)
183
- try {
184
- deps.notify(removed.sessionId, renderWatchExpired(removed))
185
- } catch (error) {
186
- deps.logger?.warn?.(`worktree: watch-expiry notification failed: ${/** @type {any} */ (error)?.message ?? error}`)
187
- }
188
- }
189
-
190
- /**
191
- * Ledger lazy-cleanup + per-instance timer arming (design D4). Runs on every
192
- * refresh — which is also the host-load path: watches whose deadline passed
193
- * while the host was down are pruned silently + audited (never delivered,
194
- * never hit-tested); every live watch gets this instance's expiry timer.
195
- * @param {any} repo
196
- */
197
- async function syncWatches(repo) {
198
- const pruned = await repo.ledger.update((/** @type {any} */ ledger) => {
199
- const expired = pruneExpiredWatches(ledger, now())
200
- return expired.length > 0 ? { ledger, result: expired } : {}
201
- })
202
- for (const watch of pruned ?? []) {
203
- deps.audit('watch', { lane: watch.laneId, sessionId: watch.sessionId, states: watch.states, outcome: 'pruned' }, repo.mainRoot, watch.sessionId)
204
- }
205
- for (const entry of repo.ledger.read().watches) armWatch(repo, entry)
206
- }
207
-
208
- const cwdOf = (/** @type {any} */ session) => {
209
- const cwd = session?.header?.cwd
210
- if (typeof cwd !== 'string' || !cwd) throw new WorktreeError(WORKTREE_CODES.NOT_A_REPO, 'this session has no working directory')
211
- return cwd
212
- }
213
-
214
- function config() {
215
- const value = settings()
216
- if (!value.enabled) throw new WorktreeError(WORKTREE_CODES.WORKTREE_DISABLED, 'worktree lanes are disabled (worktreeEnabled is off)')
217
- return value
218
- }
219
-
220
- /** @param {string} cwd */
221
- async function repoFor(cwd) {
222
- const root = normalizeRoot(config().root)
223
- const key = `${cwd}\0${root}`
224
- if (!pending.has(key)) {
225
- const promise = resolveRepo(cwd, root).then((repo) => {
226
- resolved.set(key, repo)
227
- return repo
228
- })
229
- promise.catch(() => pending.delete(key))
230
- pending.set(key, promise)
231
- }
232
- return pending.get(key)
233
- }
234
-
235
- /** @param {string} cwd @param {string} root */
236
- async function resolveRepo(cwd, root) {
237
- versionProbe ??= git.version(cwd)
238
- const version = await versionProbe
239
- if (!git.supported(version)) {
240
- versionProbe = null
241
- throw new WorktreeError(WORKTREE_CODES.GIT_TOO_OLD, version
242
- ? `git ${version.join('.')} is too old; worktree lanes need git 2.38 or newer`
243
- : `git could not be run (${git.lastVersionError ?? 'unknown error'}); worktree lanes need git 2.38 or newer`)
244
- }
245
- const info = await git.repoOf(cwd)
246
- if (!info) throw new WorktreeError(WORKTREE_CODES.NOT_A_REPO, `${cwd} is not inside a git repository`)
247
- const mainRoot = realpathSync.native(info.mainRoot)
248
- const rootPath = join(mainRoot, ...root.split('/'))
249
- return { cwd, mainRoot, commonDir: info.commonDir, root, rootPath, ledger: createLedger(rootPath, { now }), version, unmanaged: /** @type {string[]} */ ([]) }
250
- }
251
-
252
- /** Resolved repo for sync callers (prompt board), or null. @param {any} session */
253
- function repoCached(session) {
254
- try {
255
- const value = settings()
256
- if (!value.enabled) return null
257
- const cwd = cwdOf(session)
258
- const key = `${cwd}\0${normalizeRoot(value.root)}`
259
- if (!resolved.has(key) && !pending.has(key)) void repoFor(cwd).catch(() => {})
260
- return resolved.get(key) ?? null
261
- } catch {
262
- return null
263
- }
264
- }
265
-
266
- // ─── ledger helpers ───────────────────────────────────────────────────
267
-
268
- /** @param {any} repo @param {string} laneId */
269
- function laneOf(repo, laneId) {
270
- const lane = repo.ledger.read().lanes.find((/** @type {any} */ entry) => entry.id === laneId)
271
- if (!lane) throw new WorktreeError(WORKTREE_CODES.UNKNOWN_LANE, `no lane "${laneId}" in ${repo.rootPath}`)
272
- return lane
273
- }
274
-
275
- /**
276
- * Transition one lane under the ledger lock; `check` runs inside the lock
277
- * against the current record (throw to refuse).
278
- * @param {any} repo @param {string} laneId @param {any} event @param {(lane: any, ledger: any) => void} [check]
279
- */
280
- async function apply(repo, laneId, event, check) {
281
- /** Watches this transition consumed (removed in the same atomic write). @type {any[]} */
282
- let hits = []
283
- const lane = await repo.ledger.update((/** @type {any} */ ledger) => {
284
- const index = ledger.lanes.findIndex((/** @type {any} */ entry) => entry.id === laneId)
285
- if (index === -1) throw new WorktreeError(WORKTREE_CODES.UNKNOWN_LANE, `no lane "${laneId}"`)
286
- check?.(ledger.lanes[index], ledger)
287
- const next = transition(ledger.lanes[index], { at: now(), ...event })
288
- ledger.lanes[index] = next
289
- // One-shot watches: a hit is removed in the SAME atomic write as the
290
- // state transition, so it can never be collected twice (design D3).
291
- hits = collectWatchHits(ledger, next)
292
- return { ledger, result: next }
293
- })
294
- const kind = AUDIT_KIND[/** @type {keyof typeof AUDIT_KIND} */ (event.type)]
295
- // `event.audit` carries extra payload for the SAME kind (the auto-approve
296
- // marker on land/abandon) without a new audit type; transition() ignores
297
- // the key, so it never leaks into the lane record.
298
- if (kind) deps.audit(kind, { lane: lane.id, from: lane.history.at(-1)?.from, to: lane.state, event: event.type, reason: event.reason ?? null, by: event.by ?? 'host', ...(event.audit ?? {}) }, repo.mainRoot, lane.ownerSession)
299
- for (const hit of hits) {
300
- disarmWatch(hit.id)
301
- try {
302
- deps.notify(hit.sessionId, renderWatchHit(lane))
303
- } catch (error) {
304
- deps.logger?.warn?.(`worktree: watch-hit notification failed: ${/** @type {any} */ (error)?.message ?? error}`)
305
- }
306
- }
307
- return lane
308
- }
309
-
310
- /** Non-state field update (no transition). @param {any} repo @param {string} laneId @param {(lane: any) => any} patch */
311
- async function patchLane(repo, laneId, patch) {
312
- return repo.ledger.update((/** @type {any} */ ledger) => {
313
- const index = ledger.lanes.findIndex((/** @type {any} */ entry) => entry.id === laneId)
314
- if (index === -1) return {}
315
- ledger.lanes[index] = { ...ledger.lanes[index], ...patch(ledger.lanes[index]), updatedAt: now() }
316
- return { ledger, result: ledger.lanes[index] }
317
- })
318
- }
319
-
320
- /** @param {any} lane @param {string} [detail] */
321
- function notifyOwner(lane, detail) {
322
- if (!lane?.ownerSession) return
323
- try {
324
- deps.notify(lane.ownerSession, renderNotice(lane, detail))
325
- } catch (error) {
326
- deps.logger?.warn?.(`worktree: notification failed: ${/** @type {any} */ (error)?.message ?? error}`)
327
- }
328
- }
329
-
330
- /**
331
- * Best-effort Edit Lock authority-residue probe (design D5): does the
332
- * lane's owner session still own unresolved operations or locks in the
333
- * management domain? Warning input only — read-only, and a failed check
334
- * is silent (never blocks the cleanup/abandon transition).
335
- * @param {any} repo @param {any} lane @returns {{ operations: number, locks: number } | null}
336
- */
337
- function residueOf(repo, lane) {
338
- try {
339
- return (deps.authorityResidue ?? authorityResidue)(repo.mainRoot, lane?.ownerSession) ?? null
340
- } catch (error) {
341
- deps.logger?.warn?.(`worktree: authority-residue check failed: ${/** @type {any} */ (error)?.message ?? error}`)
342
- return null
343
- }
344
- }
345
-
346
- /** @param {any} lane */
347
- function result(lane, summary, extra = {}) {
348
- return {
349
- lane: lane.id, state: lane.state, summary, path: lane.path, branch: lane.branch, next: nextFor(lane),
350
- ...(lane.check?.enabled && Array.isArray(lane.check.results) && lane.check.results.length ? { check: { results: lane.check.results.map((/** @type {any} */ entry) => ({ name: entry.name, exit: entry.exit, ms: entry.ms })) } } : {}),
351
- ...extra,
352
- }
353
- }
354
-
355
- /** @param {any} repo */
356
- function readConfig(repo) {
357
- const file = join(repo.rootPath, CONFIG_FILE)
358
- if (!existsSync(file)) return parseRepoConfig(undefined)
359
- let raw
360
- try {
361
- raw = JSON.parse(readFileSync(file, 'utf8'))
362
- } catch (error) {
363
- throw new Error(`invalid ${file}: ${/** @type {any} */ (error)?.message ?? error}`)
364
- }
365
- try {
366
- return parseRepoConfig(raw)
367
- } catch (error) {
368
- throw new Error(`invalid ${file}: ${/** @type {any} */ (error)?.message ?? error}`)
369
- }
370
- }
371
-
372
- /** @param {any} repo @param {string} laneId @param {string} name */
373
- function logFile(repo, laneId, name) {
374
- const dir = join(repo.rootPath, '.logs', laneId)
375
- mkdirSync(dir, { recursive: true })
376
- return join(dir, `${name}.log`)
377
- }
378
-
379
- // ─── reconciliation ───────────────────────────────────────────────────
380
-
381
- /** @param {any} repo */
382
- async function refresh(repo) {
383
- // Watch maintenance first (design D4): prune watches that expired while
384
- // the host was down (silent + audited) and arm this instance's timers;
385
- // the reconcile transitions below then hit only live watches.
386
- await syncWatches(repo)
387
- const worktrees = await git.worktreeList(repo.mainRoot)
388
- const ledger = repo.ledger.read()
389
- const mainBranch = await git.currentBranch(repo.mainRoot)
390
- /** @type {Map<string, string | null>} */
391
- const trees = new Map()
392
- for (const lane of ledger.lanes) {
393
- if (LANDABLE_FROM.includes(lane.state) && lane.landableTree && existsSync(lane.path)) trees.set(lane.id, await git.tree(lane.path))
394
- }
395
- const outcome = reconcile({ lanes: ledger.lanes, worktrees, rootPath: repo.rootPath, mainBranch, trees, normalize: (path) => pathKey(path) })
396
- for (const event of outcome.events) {
397
- await apply(repo, event.id, { type: event.type, reason: event.reason }).catch(() => {})
398
- }
399
- // An approval card dies with the tool call that raised it (restart,
400
- // crash): such a lane is declined, never left waiting forever.
401
- for (const lane of ledger.lanes) {
402
- if (lane.state === 'awaiting-approval' && !askAlive(lane)) {
403
- await apply(repo, lane.id, { type: 'decline', reason: 'the approval card was closed before an answer (the tool call ended)' }).catch(() => {})
404
- }
405
- }
406
- const flagsChanged = ledger.lanes.some((/** @type {any} */ lane) => outcome.baseMoved.has(lane.id) && Boolean(lane.baseMoved) !== outcome.baseMoved.get(lane.id))
407
- if (flagsChanged) {
408
- await repo.ledger.update((/** @type {any} */ current) => {
409
- for (const lane of current.lanes) if (outcome.baseMoved.has(lane.id)) lane.baseMoved = outcome.baseMoved.get(lane.id)
410
- return { ledger: current }
411
- })
412
- }
413
- if (outcome.unmanaged.length && outcome.unmanaged.join() !== repo.unmanaged.join()) {
414
- deps.audit('reconcile', { unmanaged: outcome.unmanaged }, repo.mainRoot, null)
415
- }
416
- repo.unmanaged = outcome.unmanaged
417
- return { mainBranch, unmanaged: outcome.unmanaged }
418
- }
419
-
420
- // ─── open / setup ─────────────────────────────────────────────────────
421
-
422
- /**
423
- * The invocation for a DERIVED setup: resolved through deps.resolveSetup
424
- * when available (system tool first, DSH bundled runtime second), else the
425
- * legacy bare command. A user-configured setup never reaches this helper.
426
- * @param {string} manager
427
- * @returns {Promise<{ command: string, display: string } | { error: string }>}
428
- */
429
- async function deriveSetup(manager) {
430
- if (deps.resolveSetup) {
431
- const resolution = await deps.resolveSetup(manager)
432
- if (!resolution.ok) return { error: setupMissingReason(manager) }
433
- return { command: resolution.command, display: resolution.display }
434
- }
435
- const display = bareSetupCommand(manager)
436
- return { command: display, display }
437
- }
438
-
439
-
440
- /**
441
- * @param {any} session - the owning (main) session
442
- * @param {{ title: string, scope?: string[] }} args
443
- */
444
- async function open(session, args) {
445
- const settingsNow = config()
446
- if (typeof args?.title !== 'string' || args.title.trim().length === 0) throw new Error('worktree_open: title must be a non-empty string')
447
- const scope = args.scope ?? []
448
- 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')
449
- const repo = await repoFor(cwdOf(session))
450
- const base = await git.currentBranch(repo.mainRoot)
451
- 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' } })
452
- const baseCommit = await git.revParse(repo.mainRoot, 'HEAD')
453
- await refresh(repo)
454
- ensureExclude(repo.commonDir, repo.root)
455
- const at = now()
456
- const lane = await repo.ledger.update((/** @type {any} */ ledger) => {
457
- const active = ledger.lanes.filter(isActive)
458
- if (active.length >= settingsNow.maxActive) {
459
- const lanes = active.map((/** @type {any} */ lane) => ({
460
- id: lane.id, state: lane.state,
461
- hint: lane.state === 'landable'
462
- ? `call worktree_land({ lane: "${lane.id}" })`
463
- : `wait for lane ${lane.id} or call worktree_abandon({ lane: "${lane.id}" })`,
464
- }))
465
- const hint = (lanes.find((lane) => lane.state === 'landable') ?? lanes[0]).hint
466
- 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')}`, {
467
- data: { lanes }, next: { waitFor: 'user', hint },
468
- })
469
- }
470
- const clash = active.find((/** @type {any} */ other) => scopesOverlap(scope, other.scope ?? []))
471
- if (clash) {
472
- const overlapping = {
473
- scope: scope.filter((glob) => scopesOverlap([glob], clash.scope)),
474
- laneScope: clash.scope.filter((glob) => scopesOverlap(scope, [glob])),
475
- }
476
- const hint = `narrow the new scope to avoid ${overlapping.laneScope.join(', ')} or wait for lane ${clash.id} to land before opening it`
477
- throw new WorktreeError(WORKTREE_CODES.SCOPE_OVERLAP, `scope ${overlapping.scope.join(', ')} overlaps lane ${clash.id} (${overlapping.laneScope.join(', ')}); ${hint}`, {
478
- lane: clash.id, data: { overlapping }, next: { waitFor: 'user', hint },
479
- })
480
- }
481
- const seq = ledger.seq + 1
482
- const id = laneIdFor(args.title, seq)
483
- const record = {
484
- id,
485
- title: args.title.trim(),
486
- path: join(repo.rootPath, id),
487
- branch: branchFor(id),
488
- base: { branch: base, commit: baseCommit },
489
- scope,
490
- state: 'preparing',
491
- ownerSession: session.id ?? null,
492
- boundChild: null,
493
- baseMoved: false,
494
- landableTree: null,
495
- setup: { status: 'pending' },
496
- check: null,
497
- land: null,
498
- cleanup: null,
499
- reason: null,
500
- createdAt: at,
501
- updatedAt: at,
502
- history: [],
503
- }
504
- return { ledger: { ...ledger, seq, lanes: [...ledger.lanes, record] }, result: record }
505
- })
506
- try {
507
- if (await git.branchExists(repo.mainRoot, lane.branch)) throw new WorktreeError(WORKTREE_CODES.BRANCH_EXISTS, `branch ${lane.branch} already exists`)
508
- mkdirSync(repo.rootPath, { recursive: true })
509
- await git.worktreeAdd(repo.mainRoot, lane.path, lane.branch, baseCommit)
510
- } catch (error) {
511
- await repo.ledger.update((/** @type {any} */ ledger) => ({ ledger: { ...ledger, lanes: ledger.lanes.filter((/** @type {any} */ entry) => entry.id !== lane.id) } }))
512
- throw error
513
- }
514
- deps.audit('open', { lane: lane.id, title: lane.title, branch: lane.branch, base: lane.base, scope }, repo.mainRoot, lane.ownerSession)
515
- let command = null
516
- let display = null
517
- /** @type {{ kind: 'configured' } | { kind: 'derived', manager: string } | null} */
518
- let provenance = null
519
- let configError = null
520
- if (settingsNow.autoSetup) {
521
- try {
522
- const configured = readConfig(repo).setup ?? null
523
- if (configured) {
524
- command = display = configured
525
- provenance = { kind: 'configured' }
526
- } else {
527
- const manager = setupManagerFor(readdirSync(lane.path))
528
- if (manager) {
529
- provenance = { kind: 'derived', manager }
530
- const derived = await deriveSetup(manager)
531
- if ('error' in derived) configError = derived.error.replaceAll('<lane>', lane.id)
532
- else ({ command, display } = derived)
533
- }
534
- }
535
- } catch (error) {
536
- configError = /** @type {any} */ (error)?.message ?? String(error)
537
- }
538
- }
539
- if (configError) {
540
- const failed = await apply(repo, lane.id, { type: 'setup-fail', reason: configError, patch: { setup: { status: 'failed' } } })
541
- return result(failed, `lane opened at ${failed.path}, but setup could not start: ${configError}`)
542
- }
543
- if (!command) {
544
- const ready = await apply(repo, lane.id, { type: 'setup-ok', patch: { setup: { status: settingsNow.autoSetup ? 'none' : 'disabled' } } })
545
- return result(ready, `lane opened at ${ready.path} on ${ready.branch} (base ${base})`)
546
- }
547
- const preparing = await patchLane(repo, lane.id, () => ({ setup: { status: 'running', display: display ?? command, provenance } }))
548
- void runSetup(repo, lane.id, command, session, display ?? command)
549
- return result(preparing, `lane opened at ${preparing.path}; running setup: ${display ?? command}`)
550
- }
551
-
552
- /** @param {any} repo @param {string} laneId @param {string} command @param {any} session @param {string} [display] */
553
- async function runSetup(repo, laneId, command, session, display = command) {
554
- const log = logFile(repo, laneId, '0-setup')
555
- /** @type {{ ok: boolean, reason?: string }} */
556
- let outcome
557
- if (!deps.shellRun) {
558
- outcome = { ok: false, reason: `${WORKTREE_CODES.SHELL_UNAVAILABLE}: no shell executor is available to run "${command}"` }
559
- } else {
560
- try {
561
- const run = await deps.shellRun({ command, cwd: laneOf(repo, laneId).path, timeoutMs: SETUP_TIMEOUT_MS, session })
562
- writeFileSync(log, `$ ${display}\n\n${run.output}`)
563
- outcome = run.code === 0
564
- ? { ok: true }
565
- : { 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}` }
566
- } catch (error) {
567
- outcome = { ok: false, reason: `setup could not run: ${/** @type {any} */ (error)?.message ?? error}` }
568
- }
569
- }
570
- try {
571
- const lane = await apply(repo, laneId, outcome.ok
572
- ? { type: 'setup-ok', patch: { setup: { ...laneOf(repo, laneId).setup, status: 'ok', display, log } } }
573
- : { type: 'setup-fail', reason: outcome.reason, patch: { setup: { ...laneOf(repo, laneId).setup, status: 'failed', display, log } } })
574
- notifyOwner(lane, outcome.ok ? `setup finished: ${display}` : `${outcome.reason}; log ${log}`)
575
- } catch {
576
- // the lane moved on (abandoned) while setup ran: nothing to report
577
- }
578
- }
579
-
580
- /** /worktree setup <id> [--skip] @param {any} session @param {string} laneId @param {{ skip?: boolean }} options */
581
- async function setup(session, laneId, options = {}) {
582
- const repo = await repoFor(cwdOf(session))
583
- if (options.skip) {
584
- const lane = await apply(repo, laneId, { type: 'setup-ok', by: 'user', patch: { setup: { ...laneOf(repo, laneId).setup, status: 'skipped' } } })
585
- return result(lane, 'setup skipped by the user')
586
- }
587
- const current = laneOf(repo, laneId)
588
- // Execution strings are NEVER taken from the ledger: a stored display
589
- // may be the bare or unquoted form, and tools change between attempts.
590
- // Provenance decides the source — config wins, otherwise the lane's
591
- // lockfile (or the manager recorded at open) — and derived commands are
592
- // resolved fresh on every attempt.
593
- let command = null
594
- let display = current.setup?.display ?? null
595
- /** @type {string | null} */
596
- let manager = null
597
- try {
598
- const configured = readConfig(repo).setup ?? null
599
- if (configured) {
600
- command = display = configured
601
- } else {
602
- manager = setupManagerFor(readdirSync(current.path)) ?? current.setup?.provenance?.manager ?? null
603
- if (manager) {
604
- const derived = await deriveSetup(manager)
605
- if ('error' in derived) {
606
- // Still setup-failed: keep the state legal and refresh the reason.
607
- const failed = await patchLane(repo, laneId, (record) => ({
608
- setup: { ...record.setup, status: 'failed' },
609
- reason: derived.error.replaceAll('<lane>', laneId),
610
- }))
611
- return result(failed, `setup could not start: ${failed.reason}`)
612
- }
613
- ({ command, display } = derived)
614
- }
615
- }
616
- } catch (error) {
617
- throw new Error(/** @type {any} */ (error)?.message ?? String(error))
618
- }
619
- if (!command) {
620
- const lane = await apply(repo, laneId, { type: 'setup-ok', by: 'user', patch: { setup: { status: 'none' } } })
621
- return result(lane, 'no setup command applies; lane is ready')
622
- }
623
- const provenance = manager ? { kind: 'derived', manager } : { kind: 'configured' }
624
- const lane = await apply(repo, laneId, { type: 'setup-retry', by: 'user', patch: { setup: { status: 'running', display: display ?? command, provenance } } })
625
- void runSetup(repo, laneId, command, session, display ?? command)
626
- return result(lane, `setup restarted: ${display ?? command}`)
627
- }
628
-
629
- // ─── binding ──────────────────────────────────────────────────────────
630
-
631
- /** One-phrase reconciliation outcome for LANE_BUSY messages. @param {any} verdict */
632
- function reconcileNote(verdict) {
633
- if (verdict?.childAlive) return 'the bound worker is still live'
634
- if (verdict?.ownerAlive) return 'the lane owner session is still live'
635
- return 'no terminal evidence exists for the bound worker (offline is not dead)'
636
- }
637
-
638
- /**
639
- * Zombie-binding reconciliation (worktree-zombie-lane-reclamation D1/D2):
640
- * the read-only liveness probe runs before any LANE_BUSY refusal. Only
641
- * terminal evidence ∧ both sides dead settles the binding — through the
642
- * exact childSettled path, so a reconciled settlement is indistinguishable
643
- * from a live one downstream — and the settlement is audited
644
- * (worktree/reconcile-binding), exactly once (the call that actually
645
- * cleared the binding audits; a concurrent/repeat reconcile finds the
646
- * binding gone and audits nothing). A refusal leaves the ledger untouched.
647
- * `forced` skips the probe: the user's explicit card decision (D4).
648
- * @param {any} repo @param {any} lane @param {any} session @param {{ forced?: boolean }} [options]
649
- * @returns {Promise<{ settled: boolean, lane: any, verdict: any, childId: string, forced: boolean } | null>} null = no binding, or no probe available
650
- */
651
- async function reconcileBinding(repo, lane, session, options = {}) {
652
- if (!lane?.boundChild) return null
653
- const childId = lane.boundChild
654
- const forced = options.forced === true
655
- /** @type {any} */
656
- let verdict = null
657
- if (!forced) {
658
- if (typeof deps.bindingLiveness !== 'function') return null
659
- try {
660
- verdict = await deps.bindingLiveness(childId, lane.ownerSession ?? null, { root: repo.mainRoot })
661
- } catch (error) {
662
- deps.logger?.warn?.(`worktree: binding liveness probe failed for lane ${lane.id}: ${/** @type {any} */ (error)?.message ?? error}`)
663
- return null
664
- }
665
- if (!verdict || verdict.childAlive || verdict.ownerAlive || !verdict.terminalEvidence) {
666
- return { settled: false, lane, verdict, childId, forced }
667
- }
668
- }
669
- const cleared = await childSettled(childId, session)
670
- const after = laneOf(repo, lane.id)
671
- if (cleared != null) {
672
- deps.audit('reconcile-binding', {
673
- lane: lane.id,
674
- child: childId,
675
- ownerSession: lane.ownerSession ?? null,
676
- forced,
677
- evidence: verdict?.terminalEvidence ?? null,
678
- childAlive: verdict?.childAlive ?? null,
679
- ownerAlive: verdict?.ownerAlive ?? null,
680
- }, repo.mainRoot, lane.ownerSession)
681
- }
682
- return { settled: after.boundChild == null, lane: after, verdict, childId, forced }
683
- }
684
-
685
- /**
686
- * The disputed-binding card (design D4): the reconciliation outcome leads
687
- * and the only constructive choice is an explicit force-reclaim.
688
- * @param {any} agent @param {any} lane @param {any} reconciliation @param {any} copy @param {any} repo @param {AbortSignal} [signal]
689
- * @returns {Promise<boolean>} whether the user confirmed the force-reclaim
690
- */
691
- async function askForceReclaim(agent, lane, reconciliation, copy, repo, signal) {
692
- if (!deps.ask) return false
693
- /** @type {any} */
694
- let answer
695
- try {
696
- answer = await deps.ask(agent, [{
697
- id: 'abandon',
698
- header: copy.abandonHeader,
699
- question: copy.abandonQuestion(lane.title),
700
- detail: copy.abandonDisputedDetail(lane, lane.boundChild, reconciliation?.verdict ?? null, repo.mainRoot, residueOf(repo, lane)),
701
- options: [
702
- { label: copy.forceReclaim, description: copy.forceReclaimDescription },
703
- { label: copy.cancel, description: copy.abandonDescriptions.cancel },
704
- ],
705
- }], signal)
706
- } catch {
707
- return false
708
- }
709
- return answer?.answers?.find((/** @type {any} */ entry) => entry.id === 'abandon')?.selected?.[0] === copy.forceReclaim
710
- }
711
-
712
- /**
713
- * Reserve a lane for a child before it is spawned. Writers move the lane to
714
- * `working` under the lock (a second writer is refused LANE_BUSY);
715
- * read-only investigators leave the lane state alone.
716
- * @param {any} parentSession @param {string} laneId @param {{ readOnly: boolean }} options
717
- */
718
- async function prepareBind(parentSession, laneId, { readOnly }) {
719
- const repo = await repoFor(cwdOf(parentSession))
720
- const spec = (/** @type {any} */ lane) => ({ laneId: lane.id, lanePath: pathKey(lane.path), scope: lane.scope?.length ? lane.scope : null, readOnly })
721
- if (readOnly) {
722
- const lane = laneOf(repo, laneId)
723
- if (!isActive(lane) && lane.state !== 'kept') throw new WorktreeError(WORKTREE_CODES.LANE_NOT_DISPATCHABLE, `lane ${laneId} is ${lane.state}`, { lane: laneId })
724
- return { lane, repo, spec: spec(lane), contract: renderChildContract(lane, { readOnly: true }), commit: async () => {}, rollback: async () => {} }
725
- }
726
- const nonce = `pending:${randomUUID()}`
727
- /** @type {any} */
728
- let before
729
- // Zombie-binding reconciliation (D1): a lane stuck in `working` on a dead
730
- // binding settles first, so the bind proceeds from the lane's post-settle
731
- // state; a standing refusal is re-thrown atomically inside apply below.
732
- const stuck = laneOf(repo, laneId)
733
- if (stuck.state === 'working' && stuck.boundChild) await reconcileBinding(repo, stuck, parentSession)
734
- const lane = await apply(repo, laneId, { type: 'bind', patch: { boundChild: nonce }, code: WORKTREE_CODES.LANE_NOT_DISPATCHABLE }, (current) => {
735
- 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) })
736
- before = current
737
- })
738
- return {
739
- lane,
740
- repo,
741
- spec: spec(lane),
742
- contract: renderChildContract(lane, { readOnly: false }),
743
- /** @param {string} childId */
744
- commit: async (childId) => {
745
- await patchLane(repo, laneId, (current) => (current.boundChild === nonce ? { boundChild: childId } : {}))
746
- },
747
- rollback: async () => {
748
- await repo.ledger.update((/** @type {any} */ ledger) => {
749
- const index = ledger.lanes.findIndex((/** @type {any} */ entry) => entry.id === laneId)
750
- if (index === -1 || ledger.lanes[index].boundChild !== nonce) return {}
751
- ledger.lanes[index] = before
752
- return { ledger }
753
- })
754
- },
755
- }
756
- }
757
-
758
- /**
759
- * A bound writer settled: free the lane and run the host check. `silent`
760
- * suppresses the immediate notice when the caller reports the outcome
761
- * itself (foreground result / job result); background verification still
762
- * notifies when it finishes.
763
- * @param {string} childId @param {any} parentSession @param {{ silent?: boolean }} [options]
764
- */
765
- async function childSettled(childId, parentSession, options = {}) {
766
- let repo
767
- try {
768
- repo = await repoFor(cwdOf(parentSession))
769
- } catch {
770
- return null
771
- }
772
- const lane = repo.ledger.read().lanes.find((/** @type {any} */ entry) => entry.boundChild === childId)
773
- if (!lane) return null
774
- await patchLane(repo, lane.id, () => ({ boundChild: null }))
775
- const checked = await runChecks(repo, lane.id, parentSession, { trigger: 'settle', silent: options.silent })
776
- return { ...checked, notice: renderNotice(checked, checked.reason ?? undefined) }
777
- }
778
-
779
- // ─── checks ───────────────────────────────────────────────────────────
780
-
781
- /**
782
- * Mandatory preconditions, then (when configured) verification in the
783
- * background. Returns the lane as of the precondition outcome.
784
- * @param {any} repo @param {string} laneId @param {any} session @param {{ trigger: string, awaitVerification?: boolean, silent?: boolean }} options
785
- */
786
- async function runChecks(repo, laneId, session, options) {
787
- const tell = (/** @type {any} */ lane, /** @type {string} */ detail) => {
788
- if (!options.silent) notifyOwner(lane, detail)
789
- }
790
- const lane = laneOf(repo, laneId)
791
- if (!CHECKABLE.includes(lane.state)) return lane
792
- if (lane.boundChild) {
793
- // Zombie-binding reconciliation (D1) before the refusal: only terminal
794
- // evidence ∧ both sides dead clears the binding (audited); the settle
795
- // path then ran this very check, so return its outcome.
796
- const outcome = await reconcileBinding(repo, lane, session)
797
- if (outcome?.settled) return outcome.lane
798
- const detail = outcome?.verdict ? ` (binding reconciliation: ${reconcileNote(outcome.verdict)}; the abandon confirmation card offers a force-reclaim)` : ''
799
- throw new WorktreeError(WORKTREE_CODES.LANE_BUSY, `lane ${laneId} has a running worker; the host checks it when the worker settles${detail}`, { lane: laneId, next: nextFor(lane) })
800
- }
801
- const changes = await git.status(lane.path)
802
- /** @type {{ to: string, reason: string } | null} */
803
- let failed = null
804
- if (changes.length > 0) failed = { to: 'dirty', reason: `${changes.length} uncommitted path(s): ${changes.slice(0, 5).map((entry) => entry.path).join(', ')}` }
805
- else if ((await git.currentBranch(lane.path)) !== lane.branch) failed = { to: 'branch-moved', reason: `HEAD is not on ${lane.branch}` }
806
- else if ((await git.aheadBehind(repo.mainRoot, lane.base.branch, lane.branch)).ahead === 0) failed = { to: 'no-commits', reason: `no commits ahead of ${lane.base.branch}` }
807
- if (failed) {
808
- const next = await apply(repo, laneId, { type: 'checked', to: failed.to, reason: failed.reason })
809
- tell(next, failed.reason)
810
- return next
811
- }
812
- const tree = await git.tree(lane.path)
813
- /** @type {any} */
814
- let repoConfig
815
- try {
816
- repoConfig = readConfig(repo)
817
- } catch (error) {
818
- const checking = await apply(repo, laneId, { type: 'checked', to: 'checking', patch: { check: { enabled: true, tree, results: [] } } })
819
- const failedLane = await apply(repo, checking.id, { type: 'check-fail', reason: /** @type {any} */ (error)?.message ?? String(error) })
820
- tell(failedLane, failedLane.reason)
821
- return failedLane
822
- }
823
- if (repoConfig.check.length === 0) {
824
- const landable = await apply(repo, laneId, { type: 'checked', to: 'landable', patch: { landableTree: tree, check: { enabled: false, tree } } })
825
- tell(landable, 'mandatory checks passed; verification is not enabled for this repository')
826
- return landable
827
- }
828
- const checking = await apply(repo, laneId, { type: 'checked', to: 'checking', patch: { check: { enabled: true, tree, results: [], startedAt: now() } } })
829
- const run = runVerification(repo, laneId, repoConfig.check, session, tree)
830
- if (options.awaitVerification) return run
831
- void run
832
- return checking
833
- }
834
-
835
- /** @param {any} repo @param {string} laneId @param {Array<{ name: string, run: string, timeoutSec: number }>} commands @param {any} session @param {string | null} tree */
836
- async function runVerification(repo, laneId, commands, session, tree) {
837
- const lanePath = laneOf(repo, laneId).path
838
- /** @type {any[]} */
839
- const results = []
840
- /** @type {string | null} */
841
- let failure = null
842
- let tail = ''
843
- if (!deps.shellRun) failure = `${WORKTREE_CODES.SHELL_UNAVAILABLE}: no shell executor is available for verification`
844
- for (const [index, command] of commands.entries()) {
845
- if (failure) break
846
- const log = logFile(repo, laneId, `${index + 1}-${command.name.replace(/[^A-Za-z0-9_.-]+/g, '-')}`)
847
- const started = now()
848
- try {
849
- const run = await /** @type {any} */ (deps.shellRun)({ command: command.run, cwd: lanePath, timeoutMs: command.timeoutSec * 1000, session })
850
- writeFileSync(log, run.output)
851
- results.push({ name: command.name, run: command.run, exit: run.code, ms: now() - started, log, denied: run.denied })
852
- if (run.code !== 0) {
853
- failure = `${command.name} ${run.timedOut ? 'timed out' : `exited ${run.code}`}${run.denied ? ' (sandbox denied)' : ''}`
854
- tail = run.output.split(/\r?\n/).slice(-LOG_TAIL_LINES).join('\n')
855
- }
856
- } catch (error) {
857
- results.push({ name: command.name, run: command.run, exit: -1, ms: now() - started, log })
858
- failure = `${command.name} could not run: ${/** @type {any} */ (error)?.message ?? error}`
859
- }
860
- }
861
- if (!failure) {
862
- const after = await git.status(lanePath)
863
- if (after.length > 0 || (await git.tree(lanePath)) !== tree) failure = 'verification modified lane content'
864
- }
865
- try {
866
- const lane = failure
867
- ? await apply(repo, laneId, { type: 'check-fail', reason: failure, patch: { check: { enabled: true, tree, results, finishedAt: now() } } })
868
- : await apply(repo, laneId, { type: 'check-pass', patch: { landableTree: tree, check: { enabled: true, tree, results, finishedAt: now() } } })
869
- const last = results.at(-1)
870
- notifyOwner(lane, failure ? `${failure}; log ${last?.log ?? 'n/a'}${tail ? `\n${tail}` : ''}` : `verification passed: ${results.map((entry) => entry.name).join(', ')}`)
871
- return lane
872
- } catch {
873
- return null
874
- }
875
- }
876
-
877
- /**
878
- * worktree_check / /worktree check.
879
- * @param {any} session @param {string} laneId @param {{ verificationOnly?: boolean, awaitVerification?: boolean }} [options]
880
- */
881
- async function check(session, laneId, options = {}) {
882
- const repo = await repoFor(cwdOf(session))
883
- await refresh(repo)
884
- const current = laneOf(repo, laneId)
885
- if (options.verificationOnly && readConfig(repo).check.length === 0) {
886
- throw new WorktreeError(WORKTREE_CODES.VERIFICATION_DISABLED, 'verification is not enabled for this repository (no "check" in the worktree config)', { lane: laneId, next: nextFor(current) })
887
- }
888
- if (!CHECKABLE.includes(current.state)) {
889
- throw new WorktreeError(WORKTREE_CODES.ILLEGAL_TRANSITION, `lane ${laneId} is ${current.state} and cannot be checked`, { lane: laneId, next: nextFor(current) })
890
- }
891
- const lane = await runChecks(repo, laneId, session, { trigger: 'check', awaitVerification: options.awaitVerification })
892
- return result(lane, `lane ${lane.state}${lane.reason ? `: ${lane.reason}` : ''}`)
893
- }
894
-
895
- // ─── landing ──────────────────────────────────────────────────────────
896
-
897
- /**
898
- * worktree_land (agent asks the user) or /worktree land (the user's
899
- * command is the approval).
900
- * @param {any} agent - the calling main agent
901
- * @param {string} laneId
902
- * @param {{ userApproved?: boolean, signal?: AbortSignal }} [options]
903
- */
904
- async function land(agent, laneId, options = {}) {
905
- const session = agent?.session
906
- const repo = await repoFor(cwdOf(session))
907
- const { mainBranch } = await refresh(repo)
908
- const lane = laneOf(repo, laneId)
909
- if (!LANDABLE_FROM.includes(lane.state) || !lane.landableTree) {
910
- 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) })
911
- }
912
- const tree = await git.tree(lane.path)
913
- 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 } } })
914
- if (mainBranch !== lane.base.branch) {
915
- 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` } })
916
- }
917
- const precheck = await git.mergeTreeCheck(repo.mainRoot, lane.base.branch, lane.branch)
918
- if (!precheck.clean) {
919
- const conflicted = await apply(repo, laneId, { type: 'conflict', reason: `conflicts with ${lane.base.branch}: ${precheck.conflicts.join(', ')}`, patch: { conflicts: precheck.conflicts } })
920
- return result(conflicted, `not merged: ${precheck.conflicts.length} conflicting path(s)`, { conflicts: precheck.conflicts })
921
- }
922
- if (!(await git.indexEmpty(repo.mainRoot))) {
923
- 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' } })
924
- }
925
- const changed = await git.changedFiles(repo.mainRoot, lane.base.branch, lane.branch)
926
- const dirty = (await git.status(repo.mainRoot)).map((entry) => entry.path)
927
- const overlap = dirty.filter((path) => changed.includes(path))
928
- if (overlap.length > 0) {
929
- 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' } })
930
- }
931
- const heads = { main: await git.revParse(repo.mainRoot, 'HEAD'), lane: await git.revParse(lane.path, 'HEAD') }
932
- const stat = await git.diffStat(repo.mainRoot, lane.base.branch, lane.branch)
933
- const by = options.userApproved ? 'user' : 'host'
934
- const approveMode = approveModeSafe(agent?.session)
935
- const auto = approveMode !== 'manual'
936
- // Auto-approve mode (design D3): the approval card is bypassed entirely —
937
- // no `awaiting-approval`, no ask funnel, no card-open re-verification
938
- // window (the entry tree check above already bound the merge target).
939
- // Every precheck before this point is shared with the manual path. The
940
- // manual path below stays byte-identical.
941
- if (!options.userApproved && !auto) {
942
- const declined = (/** @type {string} */ reason, /** @type {string | undefined} */ feedback) => apply(repo, laneId, { type: 'decline', reason, patch: { decline: { reason, feedback: feedback ?? null, at: now() } } })
943
- if (!deps.ask) {
944
- await apply(repo, laneId, { type: 'ask' })
945
- const lane2 = await declined('no user-question answerer is available')
946
- return result(lane2, 'not merged: nobody could be asked for approval')
947
- }
948
- await apply(repo, laneId, { type: 'ask', patch: { asking: { pid, at: now() } } })
949
- asking.add(laneId)
950
- const commits = await git.commits(repo.mainRoot, lane.base.branch, lane.branch)
951
- const copy = cardCopy(cardLocale(deps.localeOf?.(session?.id)))
952
- const mergeLabel = copy.mergeOption(lane.base.branch)
953
- /** @type {any} */
954
- let answer
955
- try {
956
- answer = await deps.ask(agent, [{
957
- id: 'merge',
958
- header: copy.mergeHeader,
959
- question: copy.mergeQuestion(lane.title, lane.base.branch),
960
- detail: copy.mergeDetail({ lane, commits, stat, verification: lane.check }),
961
- options: [{ label: mergeLabel, description: copy.mergeOptionDescription(lane.branch) }, { label: copy.notNow, description: copy.notNowDescription }],
962
- }], options.signal)
963
- } catch (error) {
964
- const lane2 = await declined(`approval not given (${/** @type {any} */ (error)?.code ?? /** @type {any} */ (error)?.message ?? 'cancelled'})`)
965
- return result(lane2, 'not merged: the approval card was dismissed or unavailable')
966
- } finally {
967
- asking.delete(laneId)
968
- }
969
- const reply = answer?.answers?.find((/** @type {any} */ entry) => entry.id === 'merge')
970
- const approved = reply?.selected?.length === 1 && reply.selected[0] === mergeLabel && !reply.custom
971
- if (!approved) {
972
- const lane2 = await declined(reply?.custom ? 'the user replied instead of approving' : 'the user chose not to merge now', reply?.custom)
973
- return result(lane2, 'not merged: the user did not approve', reply?.custom ? { feedback: reply.custom } : {})
974
- }
975
- const sameMain = (await git.revParse(repo.mainRoot, 'HEAD')) === heads.main && (await git.currentBranch(repo.mainRoot)) === lane.base.branch
976
- const sameLane = (await git.revParse(lane.path, 'HEAD')) === heads.lane && (await git.tree(lane.path)) === lane.landableTree
977
- if (!sameMain || !sameLane) {
978
- await declined('the main worktree or the lane changed while the approval card was open')
979
- 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 } } })
980
- }
981
- }
982
- const merged = await git.mergeNoFf(repo.mainRoot, lane.branch, `merge(lane): ${lane.title} (${lane.id})`)
983
- if (!merged.ok) {
984
- const conflicted = await apply(repo, laneId, { type: 'conflict', reason: `merge failed and was aborted: ${merged.detail}` })
985
- return result(conflicted, 'not merged: git refused the merge (aborted, main worktree unchanged)')
986
- }
987
- const landed = await apply(repo, laneId, { type: 'land', by, audit: auto ? { auto: true, approveMode } : undefined, patch: { land: { commit: merged.commit, at: now(), by, stat } } })
988
- const diff = await git.diff(repo.mainRoot, `${merged.commit}^1`, merged.commit, 60_000).catch(() => '')
989
- if (!auto) {
990
- return result(landed, `merged ${lane.branch} into ${lane.base.branch} as ${merged.commit?.slice(0, 7)}`, { merge: { commit: merged.commit, stat }, diff })
991
- }
992
- // Auto cleanup after the merge (design D3): auto-keep → mode worktree,
993
- // auto-clean → mode all. The existing cleanup() body runs unchanged —
994
- // scratch sync first, never --force on an auto path, the Edit Lock residue
995
- // probe feeding the summary. A blocked removal stays `landed` and is
996
- // reported concisely with the typed --force retry instead of a false
997
- // success (the user can clean up later).
998
- const cleanupMode = approveMode === 'auto-keep' ? 'worktree' : 'all'
999
- try {
1000
- const cleaned = await cleanup(session, laneId, /** @type {'worktree' | 'all'} */ (cleanupMode), { by: 'host', auto: true, approveMode })
1001
- 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 })
1002
- } catch (error) {
1003
- const message = String(/** @type {any} */ (error)?.message ?? error)
1004
- const next = /** @type {any} */ (error)?.next ?? null
1005
- 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 } : {}) } })
1006
- }
1007
- }
1008
-
1009
- /**
1010
- * The post-merge cleanup card (user chooses keep / worktree / all).
1011
- * @param {any} agent @param {string} laneId @param {AbortSignal} [signal]
1012
- */
1013
- async function askCleanup(agent, laneId, signal) {
1014
- if (!deps.ask) return null
1015
- const repo = await repoFor(cwdOf(agent.session))
1016
- const lane = laneOf(repo, laneId)
1017
- if (lane.state !== 'landed') return null
1018
- const copy = cardCopy(cardLocale(deps.localeOf?.(agent.session?.id)))
1019
- // Read-only dirty probe BEFORE the card: the card discloses the counts and
1020
- // a destructive choice is then the user's informed explicit force removal.
1021
- const dirty = existsSync(lane.path) ? await git.dirtyCounts(lane.path) : null
1022
- const dirtyCard = hasDirtyContent(dirty)
1023
- /** @type {any} */
1024
- let answer
1025
- try {
1026
- answer = await deps.ask(agent, [{
1027
- id: 'cleanup',
1028
- header: copy.cleanupHeader,
1029
- question: copy.cleanupQuestion(lane.title),
1030
- detail: copy.cleanupDetail(lane, lane.land?.stat ?? { files: 0, added: 0, removed: 0 }, repo.mainRoot, residueOf(repo, lane), dirtyCard ? dirty : null),
1031
- options: [
1032
- { label: copy.choices.keep, description: copy.cleanupDescriptions.keep },
1033
- { label: copy.choices.worktree, description: dirtyCard ? copy.cleanupDescriptionsDirty.worktree : copy.cleanupDescriptions.worktree },
1034
- { label: copy.choices.all, description: dirtyCard ? copy.cleanupDescriptionsDirty.all : copy.cleanupDescriptions.all },
1035
- ],
1036
- }], signal)
1037
- } catch {
1038
- return null
1039
- }
1040
- const choice = answer?.answers?.find((/** @type {any} */ entry) => entry.id === 'cleanup')?.selected?.[0]
1041
- const mode = Object.entries(copy.choices).find(([, label]) => label === choice)?.[0]
1042
- if (!mode) return null
1043
- return cleanup(agent.session, laneId, /** @type {'keep' | 'worktree' | 'all'} */ (mode), { by: 'user', ...(mode !== 'keep' && dirtyCard ? { force: true } : {}) })
1044
- }
1045
-
1046
- // ─── cleanup / abandon ────────────────────────────────────────────────
1047
-
1048
- /** Copy the lane's .weir scratch into the main repository. @param {any} repo @param {any} lane */
1049
- function syncScratch(repo, lane) {
1050
- const source = join(lane.path, '.weir')
1051
- if (!existsSync(source)) return null
1052
- const target = join(repo.mainRoot, '.weir', 'lanes', lane.id)
1053
- mkdirSync(target, { recursive: true })
1054
- cpSync(source, target, { recursive: true, force: true })
1055
- return target
1056
- }
1057
-
1058
- /**
1059
- * Concise blocked-removal error: the reason names the dirty counts when the
1060
- * probe sees them (never the raw git `fatal:` output), and `next` names the
1061
- * exact typed `--force` retry — the in-product escape for a lane that cannot
1062
- * be removed. Force stays the USER's privilege, so the hint directs whoever
1063
- * reads it (the model included) to the user's typed command.
1064
- * @param {any} lane @param {string} mode
1065
- */
1066
- async function blockedRemovalError(lane, mode) {
1067
- const dirty = existsSync(lane.path) ? await git.dirtyCounts(lane.path) : { modified: 0, untracked: 0 }
1068
- const reason = dirty.modified + dirty.untracked > 0
1069
- ? `removal blocked: the worktree contains modified or untracked files (${dirty.modified} modified, ${dirty.untracked} untracked)`
1070
- : 'removal blocked: git refused to remove the worktree (it may be locked)'
1071
- return new WorktreeError(WORKTREE_CODES.REMOVE_BLOCKED, reason, {
1072
- lane: lane.id,
1073
- next: { waitFor: 'user', hint: `the user retries with the explicit force override: /worktree clean ${lane.id} ${mode} --force` },
1074
- })
1075
- }
1076
-
1077
- /**
1078
- * Auto paths (design D3) reuse this exact body with `{ by: 'host', auto: true,
1079
- * approveMode }`: the scratch sync, the removal (forced ONLY behind the
1080
- * user's informed explicit choice — a disclosed card choice or a typed
1081
- * `--force`, never an auto/model path), the residue warning and the
1082
- * blocked-removal semantics are identical; only the audit payload gains the
1083
- * auto marker.
1084
- * @param {any} session @param {string} laneId @param {'keep' | 'worktree' | 'all'} mode @param {{ by?: string, auto?: boolean, approveMode?: string, force?: boolean }} [options]
1085
- */
1086
- async function cleanup(session, laneId, mode, options = {}) {
1087
- if (!['keep', 'worktree', 'all'].includes(mode)) throw new Error('cleanup mode must be keep, worktree, or all')
1088
- const repo = await repoFor(cwdOf(session))
1089
- const lane = laneOf(repo, laneId)
1090
- const by = options.by ?? 'user'
1091
- if (!['landed', 'kept', 'abandoned'].includes(lane.state)) {
1092
- throw new WorktreeError(WORKTREE_CODES.ILLEGAL_TRANSITION, `lane ${laneId} is ${lane.state}; cleanup applies to merged (or abandoned) lanes`, { lane: laneId, next: nextFor(lane) })
1093
- }
1094
- if (mode === 'keep') {
1095
- if (lane.state !== 'landed') return result(lane, 'nothing to do: the worktree is already kept')
1096
- const kept = await apply(repo, laneId, { type: 'keep', by, patch: { cleanup: { mode: 'keep', at: now(), by } } })
1097
- return result(kept, `kept ${kept.path}`)
1098
- }
1099
- // Read-only residue probe BEFORE anything is removed (design D5): a
1100
- // warning only, never a gate, and it never mutates the authority.
1101
- const residue = residueOf(repo, lane)
1102
- const scratch = existsSync(lane.path) ? syncScratch(repo, lane) : null
1103
- // `force` reaches git only from an informed explicit user choice (a
1104
- // disclosed card choice via askCleanup, or the typed `--force`); auto and
1105
- // model paths never force.
1106
- const force = options.force === true && options.auto !== true
1107
- if (existsSync(lane.path)) {
1108
- try {
1109
- await git.worktreeRemove(repo.mainRoot, lane.path, { force })
1110
- } catch (error) {
1111
- if (!(error instanceof WorktreeError) || error.code !== WORKTREE_CODES.REMOVE_BLOCKED) throw error
1112
- // State unchanged: the blocked removal is recorded on the lane (so it
1113
- // stays retryable) and reported concisely with the --force escape.
1114
- const blocked = await blockedRemovalError(lane, mode)
1115
- await patchLane(repo, laneId, (current) => ({ cleanup: { ...(current.cleanup ?? {}), blocked: blocked.reason } }))
1116
- throw blocked
1117
- }
1118
- }
1119
- let branchNote = ''
1120
- if (mode === 'all' && (await git.branchExists(repo.mainRoot, lane.branch))) {
1121
- try {
1122
- await git.branchDelete(repo.mainRoot, lane.branch)
1123
- branchNote = `; deleted ${lane.branch}`
1124
- } catch (error) {
1125
- branchNote = `; kept ${lane.branch} (${/** @type {any} */ (error)?.message ?? error})`
1126
- }
1127
- }
1128
- const record = { mode, at: now(), by, scratch }
1129
- const next = lane.state === 'abandoned'
1130
- ? await patchLane(repo, laneId, () => ({ cleanup: record }))
1131
- : await apply(repo, laneId, { type: 'clean', by, patch: { cleanup: record } })
1132
- deps.audit('cleanup', { lane: laneId, mode, by, scratch, ...(force ? { forced: true } : {}), ...(options.auto === true ? { auto: true, approveMode: options.approveMode ?? null } : {}) }, repo.mainRoot, lane.ownerSession)
1133
- return result(next, `removed ${lane.path}${branchNote}${scratch ? `; scratch copied to ${scratch}` : ''}${residue ? `; ${residueNote(residue)}` : ''}`)
1134
- }
1135
-
1136
- /**
1137
- * @param {any} agent @param {string} laneId
1138
- * @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
1139
- */
1140
- async function abandon(agent, laneId, options = {}) {
1141
- const repo = await repoFor(cwdOf(agent?.session))
1142
- let lane = laneOf(repo, laneId)
1143
- if (!isActive(lane)) throw new WorktreeError(WORKTREE_CODES.ILLEGAL_TRANSITION, `lane ${laneId} is already ${lane.state}`, { lane: laneId })
1144
- // Zombie-binding reconciliation BEFORE the confirmation card (design
1145
- // D1/D4): a binding with terminal evidence and no live side settles here
1146
- // through the childSettled path; a standing one is disclosed on the card
1147
- // with an explicit force-reclaim choice.
1148
- /** @type {any} */
1149
- let reconciliation = null
1150
- if (lane.boundChild) {
1151
- reconciliation = await reconcileBinding(repo, lane, agent?.session)
1152
- if (reconciliation?.settled) lane = reconciliation.lane
1153
- if (!isActive(lane)) throw new WorktreeError(WORKTREE_CODES.ILLEGAL_TRANSITION, `lane ${laneId} is already ${lane.state}`, { lane: laneId })
1154
- }
1155
- const unmerged = (await git.branchExists(repo.mainRoot, lane.branch)) ? await git.unmergedCount(repo.mainRoot, lane.base.branch, lane.branch) : 0
1156
- let mode = options.mode
1157
- // Auto-approve path (design D3): no card, the mapped mode decides —
1158
- // auto-keep removes the worktree and keeps the branch, auto-clean also
1159
- // deletes it. Force-reclaim is NEVER auto: a binding the reconciliation
1160
- // would not clear skips this branch entirely and falls back to the manual
1161
- // card below (reconciliation outcome + force-reclaim choice) in EVERY mode.
1162
- const approveMode = approveModeSafe(agent?.session)
1163
- const auto = !mode && approveMode !== 'manual' && !lane.boundChild
1164
- if (auto) mode = approveMode === 'auto-keep' ? 'worktree' : 'all'
1165
- const copy = cardCopy(cardLocale(deps.localeOf?.(agent?.session?.id)))
1166
- // Card-path facts: whether a destructive card choice is the user's informed
1167
- // explicit force removal (the card disclosed the dirty counts).
1168
- let cardChoice = false
1169
- let dirtyCard = false
1170
- if (!mode) {
1171
- if (!deps.ask) throw new WorktreeError(WORKTREE_CODES.MAIN_AGENT_ONLY, 'abandoning needs the user\'s confirmation and no answerer is available')
1172
- // A binding the reconciliation would not clear is the user's call
1173
- // (design D4): the disputed card states the outcome, and the explicit
1174
- // force-reclaim settles through the same path, audited as forced.
1175
- if (lane.boundChild) {
1176
- const reclaim = await askForceReclaim(agent, lane, reconciliation, copy, repo, options.signal)
1177
- if (!reclaim) return result(lane, 'not abandoned: the user cancelled')
1178
- reconciliation = await reconcileBinding(repo, lane, agent?.session, { forced: true })
1179
- lane = laneOf(repo, laneId)
1180
- 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 })
1181
- }
1182
- // Read-only dirty probe BEFORE the card: the card discloses the counts
1183
- // and the destructive options name the permanent destruction.
1184
- const dirty = existsSync(lane.path) ? await git.dirtyCounts(lane.path) : null
1185
- dirtyCard = hasDirtyContent(dirty)
1186
- /** @type {any} */
1187
- let answer
1188
- try {
1189
- answer = await deps.ask(agent, [{
1190
- id: 'abandon',
1191
- header: copy.abandonHeader,
1192
- question: copy.abandonQuestion(lane.title),
1193
- detail: copy.abandonDetail(lane, unmerged, repo.mainRoot, residueOf(repo, lane), reconciliation?.settled ? { child: reconciliation.childId, forced: reconciliation.forced } : null, dirtyCard ? dirty : null),
1194
- options: [
1195
- { label: copy.choices.keep, description: copy.abandonDescriptions.keep },
1196
- { label: copy.choices.worktree, description: dirtyCard ? copy.abandonDescriptionsDirty.worktree : copy.abandonDescriptions.worktree },
1197
- { label: copy.choices.all, description: (dirtyCard ? copy.abandonDescriptionsDirty.all : copy.abandonDescriptions.all)(unmerged) },
1198
- { label: copy.cancel, description: copy.abandonDescriptions.cancel },
1199
- ],
1200
- }], options.signal)
1201
- } catch {
1202
- return result(lane, 'not abandoned: the confirmation card was dismissed')
1203
- }
1204
- const choice = answer?.answers?.find((/** @type {any} */ entry) => entry.id === 'abandon')?.selected?.[0]
1205
- mode = /** @type {any} */ (Object.entries(copy.choices).find(([, label]) => label === choice)?.[0])
1206
- if (!mode) return result(lane, 'not abandoned: the user cancelled')
1207
- cardChoice = true
1208
- }
1209
- if (lane.boundChild) {
1210
- const detail = reconciliation?.verdict ? ` (binding reconciliation: ${reconcileNote(reconciliation.verdict)}; call worktree_abandon without a mode and use the card's force-reclaim)` : ''
1211
- throw new WorktreeError(WORKTREE_CODES.LANE_BUSY, `lane ${laneId} has a running worker; stop it first${detail}`, { lane: laneId })
1212
- }
1213
- // Removal is forced ONLY behind the user's informed explicit choice: a
1214
- // destructive choice on the dirty-disclosing card, or the typed `--force`.
1215
- // Typed-mode and auto paths (auto never reaches the card) never force.
1216
- const force = (options.force === true && !auto) || (cardChoice && dirtyCard)
1217
- const auditExtra = { ...(auto ? { auto: true, approveMode } : {}), ...(force ? { forced: true } : {}) }
1218
- 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' } } })
1219
- if (mode === 'keep') return result(abandoned, `abandoned; ${abandoned.path} and ${abandoned.branch} are kept`)
1220
- // Read-only residue probe BEFORE anything is removed (design D5): a
1221
- // warning only, never a gate, and it never mutates the authority. The
1222
- // confirmation card already carried the warning when there was one.
1223
- const residue = residueOf(repo, lane)
1224
- const scratch = existsSync(lane.path) ? syncScratch(repo, lane) : null
1225
- if (existsSync(lane.path)) {
1226
- try {
1227
- await git.worktreeRemove(repo.mainRoot, lane.path, { force })
1228
- } catch (error) {
1229
- if (!(error instanceof WorktreeError) || error.code !== WORKTREE_CODES.REMOVE_BLOCKED) throw error
1230
- // Abandoned, but the worktree stays: recorded as kept+blocked so a
1231
- // later cleanup retries once the user types the explicit override.
1232
- const blocked = await blockedRemovalError(lane, mode)
1233
- await patchLane(repo, laneId, (current) => ({ cleanup: { ...current.cleanup, mode: 'keep', blocked: blocked.reason } }))
1234
- throw blocked
1235
- }
1236
- }
1237
- let branchNote = ''
1238
- if (mode === 'all' && (await git.branchExists(repo.mainRoot, lane.branch))) {
1239
- await git.branchDelete(repo.mainRoot, lane.branch, { force: unmerged > 0 })
1240
- branchNote = `; deleted ${lane.branch}${unmerged > 0 ? ` (${unmerged} unmerged commit(s) discarded)` : ''}`
1241
- }
1242
- const final = await patchLane(repo, laneId, (current) => ({ cleanup: { ...current.cleanup, scratch } }))
1243
- return result(final, `abandoned; removed ${lane.path}${branchNote}${residue ? `; ${residueNote(residue)}` : ''}`)
1244
- }
1245
-
1246
- // ─── watches (lane state subscriptions) ────────────────────────────────
1247
-
1248
- /**
1249
- * worktree_watch: subscribe the calling session to conclusion states of one
1250
- * lane in this repository (including lanes another session opened). One-shot:
1251
- * the first target state hit — or the expiry — delivers exactly one notice
1252
- * to the SUBSCRIBER and removes the watch. Re-subscribing the same lane
1253
- * replaces the old watch. A lane already in a target state hits immediately
1254
- * (delivered once, never stored). The lifetime comes from the
1255
- * worktreeWatchTimeoutMinutes setting, frozen into `expiresAt` at subscribe
1256
- * time; the model cannot override it (no timeout parameter).
1257
- * @param {any} session - the subscribing (main) session
1258
- * @param {{ lane: string, states: string[] }} args
1259
- */
1260
- async function watch(session, args) {
1261
- const states = normalizeWatchStates(args?.states)
1262
- const laneId = typeof args?.lane === 'string' ? args.lane : ''
1263
- const sessionId = session?.id
1264
- if (typeof sessionId !== 'string' || !sessionId) throw new Error('worktree_watch: the subscribing session has no id')
1265
- const repo = await repoFor(cwdOf(session))
1266
- // Reconcile first so the immediate-hit test below reads the lane's true
1267
- // state (and so expired watches are pruned before this one is stored).
1268
- await refresh(repo)
1269
- const timeoutMinutes = watchTimeoutMinutesOf(config())
1270
- const entry = createWatch({ id: randomUUID(), laneId, sessionId, states, now: now(), timeoutMinutes })
1271
- // Check-vs-insert is atomic inside the ledger lock: a lane that reaches a
1272
- // target state concurrently either hits BEFORE (immediate path) or AFTER
1273
- // (the transition's own hit scan) this subscribe — never both, never none.
1274
- const outcome = await repo.ledger.update((/** @type {any} */ ledger) => {
1275
- const current = ledger.lanes.find((/** @type {any} */ record) => record.id === laneId)
1276
- if (!current) throw new WorktreeError(WORKTREE_CODES.UNKNOWN_LANE, `no lane "${laneId}" in ${repo.rootPath}`)
1277
- if (states.includes(current.state)) return { result: { lane: current, immediate: true, replaced: null } }
1278
- const replaced = upsertWatch(ledger, entry)
1279
- return { ledger, result: { lane: current, immediate: false, replaced } }
1280
- })
1281
- const { lane, immediate, replaced } = /** @type {any} */ (outcome)
1282
- if (replaced) disarmWatch(replaced.id)
1283
- if (immediate) {
1284
- deps.audit('watch', { lane: lane.id, sessionId, states, outcome: 'hit-immediate' }, repo.mainRoot, sessionId)
1285
- try {
1286
- deps.notify(sessionId, renderWatchHit(lane))
1287
- } catch (error) {
1288
- deps.logger?.warn?.(`worktree: watch-hit notification failed: ${/** @type {any} */ (error)?.message ?? error}`)
1289
- }
1290
- return result(lane, `already ${lane.state}; the watch fired immediately and was not stored`, { hit: lane.state })
1291
- }
1292
- armWatch(repo, entry)
1293
- deps.audit('watch', { lane: lane.id, sessionId, states, expiresAt: entry.expiresAt, replaced: replaced?.id ?? null, outcome: 'subscribed' }, repo.mainRoot, sessionId)
1294
- return result(lane, `watching for ${states.join(', ')} until ${new Date(entry.expiresAt).toISOString()}`, { watch: { lane: lane.id, states: entry.states, expiresAt: entry.expiresAt } })
1295
- }
1296
-
1297
- // ─── views ────────────────────────────────────────────────────────────
1298
-
1299
- /** Legal actions per lane for the panel (disabled ones carry the reason). @param {any} lane */
1300
- function actionsFor(lane) {
1301
- const deny = (/** @type {string} */ reason) => ({ enabled: false, reason })
1302
- const allow = { enabled: true }
1303
- const exists = !['cleaned'].includes(lane.state) && !(lane.state === 'abandoned' && lane.cleanup?.mode !== 'keep')
1304
- return {
1305
- diff: lane.state === 'preparing' ? deny('setup is still running') : allow,
1306
- check: !CHECKABLE.includes(lane.state) ? deny(`not available while ${lane.state}`) : lane.boundChild ? deny('a worker is running in this lane') : allow,
1307
- land: !LANDABLE_FROM.includes(lane.state) ? deny(`only a landable lane can be merged (now ${lane.state})`) : lane.baseMoved ? deny(`the main worktree is not on ${lane.base.branch}`) : allow,
1308
- clean: ['landed', 'kept'].includes(lane.state) || (lane.state === 'abandoned' && lane.cleanup?.mode === 'keep') ? allow : deny('cleanup applies after merging'),
1309
- abandon: isActive(lane) ? (lane.boundChild ? deny('a worker is running in this lane') : allow) : deny(`already ${lane.state}`),
1310
- setup: lane.state === 'setup-failed' ? allow : deny('setup only reruns after a failure'),
1311
- copyPath: exists ? allow : deny('the worktree was removed'),
1312
- }
1313
- }
1314
-
1315
- /**
1316
- * Full read-only view for the GUI panel.
1317
- * @param {any} session
1318
- * @param {{ mode?: boolean, approve?: string | null, approveSource?: 'session' | 'global' }} [options] - cold-read override: a boolean
1319
- * `mode` replaces the live projection read (a pseudo session folded from
1320
- * the persisted log by sessionQuery has no live projection cells); `approve`
1321
- * and `approveSource` carry the cold-folded auto-approve override the same
1322
- * way (null = no override → the global default answers).
1323
- */
1324
- async function view(session, options) {
1325
- const viewMode = () => (typeof options?.mode === 'boolean' ? options.mode : safeMode(session))
1326
- const viewApproveMode = () => (options && typeof options.approve === 'string' ? options.approve : approveModeSafe(session))
1327
- const viewApproveModeSource = () => (options && (options.approveSource === 'session' || options.approveSource === 'global') ? options.approveSource : approveModeSourceSafe(session))
1328
- let repo
1329
- try {
1330
- repo = await repoFor(cwdOf(session))
1331
- } catch (error) {
1332
- const known = error instanceof WorktreeError
1333
- return { available: false, mode: viewMode(), approveMode: viewApproveMode(), approveModeSource: viewApproveModeSource(), error: known ? error.toJSON() : { code: 'ERROR', message: String(/** @type {any} */ (error)?.message ?? error) } }
1334
- }
1335
- try {
1336
- const { mainBranch, unmanaged } = await refresh(repo)
1337
- const ledger = repo.ledger.read()
1338
- /** @type {any} */
1339
- let verification
1340
- try {
1341
- const repoConfig = readConfig(repo)
1342
- verification = { enabled: repoConfig.check.length > 0, commands: repoConfig.check.map((entry) => entry.name), setup: repoConfig.setup }
1343
- } catch (error) {
1344
- verification = { enabled: true, commands: [], error: String(/** @type {any} */ (error)?.message ?? error) }
1345
- }
1346
- const lanes = []
1347
- for (const lane of [...ledger.lanes].reverse()) {
1348
- const exists = existsSync(lane.path)
1349
- let counts = null
1350
- let stat = null
1351
- if ((await git.branchExists(repo.mainRoot, lane.branch).catch(() => false))) {
1352
- counts = await git.aheadBehind(repo.mainRoot, lane.base.branch, lane.branch).catch(() => null)
1353
- stat = await git.diffStat(repo.mainRoot, lane.base.branch, lane.branch).catch(() => null)
1354
- }
1355
- lanes.push({
1356
- ...lane,
1357
- history: (lane.history ?? []).slice(-12),
1358
- exists,
1359
- ahead: counts?.ahead ?? null,
1360
- behind: counts?.behind ?? null,
1361
- stat,
1362
- next: nextFor(lane),
1363
- transient: TRANSIENT.includes(lane.state),
1364
- ...watchFacts(ledger.watches, lane.id),
1365
- actions: actionsFor(lane),
1366
- })
1367
- }
1368
- return {
1369
- available: true,
1370
- mode: viewMode(),
1371
- approveMode: viewApproveMode(),
1372
- approveModeSource: viewApproveModeSource(),
1373
- ownedBySession: lanes.filter((lane) => lane.ownerSession === session?.id).map((lane) => lane.id),
1374
- repo: { mainRoot: repo.mainRoot, root: repo.root, rootPath: repo.rootPath, branch: mainBranch, gitVersion: repo.version?.join('.') ?? null, exclude: hasExclude(repo.commonDir, repo.root), verification },
1375
- lanes,
1376
- unmanaged,
1377
- }
1378
- } catch (error) {
1379
- const known = error instanceof WorktreeError
1380
- return { available: false, mode: viewMode(), approveMode: viewApproveMode(), approveModeSource: viewApproveModeSource(), error: known ? error.toJSON() : { code: 'ERROR', message: String(/** @type {any} */ (error)?.message ?? error) } }
1381
- }
1382
- }
1383
-
1384
- /** @param {any} session */
1385
- function safeMode(session) {
1386
- try {
1387
- return deps.modeOf(session) === true
1388
- } catch {
1389
- return false
1390
- }
1391
- }
1392
-
1393
- /** @param {any} session @param {string} laneId */
1394
- async function diffOf(session, laneId) {
1395
- const repo = await repoFor(cwdOf(session))
1396
- const lane = laneOf(repo, laneId)
1397
- if (lane.land?.commit) return git.diff(repo.mainRoot, `${lane.land.commit}^1`, lane.land.commit)
1398
- return git.diff(repo.mainRoot, lane.base.branch, lane.branch)
1399
- }
1400
-
1401
- /** Runtime-context board (sync; empty until the repo resolved once). @param {any} session */
1402
- function board(session) {
1403
- const repo = repoCached(session)
1404
- const mode = safeMode(session)
1405
- if (!repo) return mode ? renderBoard({ lanes: [], mode }) : ''
1406
- try {
1407
- const ledger = repo.ledger.read()
1408
- return renderBoard({ lanes: ledger.lanes, watches: ledger.watches, mode })
1409
- } catch {
1410
- return ''
1411
- }
1412
- }
1413
-
1414
- /** Lane ids this session owns / active counts for quick status. @param {any} session */
1415
- function summaryOf(session) {
1416
- const repo = repoCached(session)
1417
- if (!repo) return null
1418
- try {
1419
- const lanes = repo.ledger.read().lanes.filter((/** @type {any} */ lane) => lane.ownerSession === session?.id)
1420
- return { active: lanes.filter(isActive).length, awaiting: lanes.filter((/** @type {any} */ lane) => lane.state === 'awaiting-approval').length }
1421
- } catch {
1422
- return null
1423
- }
1424
- }
1425
-
1426
- // ─── repository config ────────────────────────────────────────────────
1427
-
1428
- /** /worktree init: current config + detected suggestions (never written here). @param {any} session */
1429
- async function initSuggestions(session) {
1430
- const repo = await repoFor(cwdOf(session))
1431
- let current = null
1432
- let error = null
1433
- try {
1434
- current = readConfig(repo)
1435
- } catch (caught) {
1436
- error = String(/** @type {any} */ (caught)?.message ?? caught)
1437
- }
1438
- const files = readdirSync(repo.mainRoot)
1439
- let packageJson
1440
- try {
1441
- packageJson = JSON.parse(readFileSync(join(repo.mainRoot, 'package.json'), 'utf8'))
1442
- } catch {
1443
- packageJson = undefined
1444
- }
1445
- // The setup suggestion must be an invocation that works on THIS host
1446
- // (bundled-runtime resolution), not a bare manager name — the user may
1447
- // accept it verbatim into the config.
1448
- let setup = null
1449
- let setupWarning = null
1450
- const manager = setupManagerFor(files)
1451
- if (manager) {
1452
- const derived = await deriveSetup(manager)
1453
- if ('error' in derived) {
1454
- setup = bareSetupCommand(manager)
1455
- setupWarning = setupMissingReason(manager).replace('setup needs', 'suggestion note:')
1456
- } else {
1457
- setup = derived.command
1458
- }
1459
- }
1460
- return { file: join(repo.rootPath, CONFIG_FILE), current, error, suggested: { setup, setupWarning, check: suggestChecks({ packageJson, fileNames: files }) } }
1461
- }
1462
-
1463
- /** Write the repository config after the user confirmed it. @param {any} session @param {any} value */
1464
- async function writeConfig(session, value) {
1465
- const repo = await repoFor(cwdOf(session))
1466
- const parsed = parseRepoConfig(value)
1467
- ensureExclude(repo.commonDir, repo.root)
1468
- mkdirSync(repo.rootPath, { recursive: true })
1469
- const file = join(repo.rootPath, CONFIG_FILE)
1470
- writeFileSync(file, `${JSON.stringify({ ...(parsed.setup ? { setup: parsed.setup } : {}), check: parsed.check }, null, 2)}\n`)
1471
- return { file, config: parsed }
1472
- }
1473
-
1474
- /** /worktree reconcile [--rebuild] @param {any} session @param {{ rebuild?: boolean }} [options] */
1475
- async function reconcileCommand(session, options = {}) {
1476
- const repo = await repoFor(cwdOf(session))
1477
- if (options.rebuild) {
1478
- const worktrees = await git.worktreeList(repo.mainRoot)
1479
- const prefix = `${pathKey(repo.rootPath)}/`
1480
- const at = now()
1481
- const lanes = worktrees
1482
- .filter((entry) => pathKey(entry.path).startsWith(prefix) && entry.branch?.startsWith('weir/'))
1483
- .map((entry) => {
1484
- const id = /** @type {string} */ (entry.branch).slice('weir/'.length)
1485
- return {
1486
- id, title: id, path: entry.path, branch: /** @type {string} */ (entry.branch), base: { branch: 'main', commit: null }, scope: [], state: 'working',
1487
- ownerSession: session?.id ?? null, boundChild: null, baseMoved: false, landableTree: null, setup: { status: 'unknown' }, check: null, land: null,
1488
- cleanup: null, reason: 'rebuilt from git; base assumed from the main worktree branch', createdAt: at, updatedAt: at, history: [],
1489
- }
1490
- })
1491
- const mainBranch = await git.currentBranch(repo.mainRoot)
1492
- for (const lane of lanes) lane.base.branch = mainBranch ?? 'main'
1493
- const seq = lanes.reduce((max, lane) => Math.max(max, Number(/-(\d+)$/.exec(lane.id)?.[1] ?? 0)), 0)
1494
- await repo.ledger.replace({ schemaVersion: 1, seq, lanes })
1495
- deps.audit('reconcile', { rebuilt: lanes.map((lane) => lane.id) }, repo.mainRoot, session?.id ?? null)
1496
- return { rebuilt: lanes.map((lane) => lane.id), unmanaged: [] }
1497
- }
1498
- const outcome = await refresh(repo)
1499
- return { rebuilt: null, unmanaged: outcome.unmanaged }
1500
- }
1501
-
1502
- /** Normalized absolute path of a tool argument relative to a session cwd. @param {string} cwd @param {string} path */
1503
- function resolveArgPath(cwd, path) {
1504
- return pathKey(isAbsolute(path) ? path : resolvePath(cwd, path))
1505
- }
1506
-
1507
- /** The resolved global auto-approve default (command echo; never a raw invalid value). */
1508
- const approveGlobalSafe = () => {
1509
- try {
1510
- return deps.approveGlobal?.() ?? 'auto-clean'
1511
- } catch {
1512
- return 'auto-clean'
1513
- }
1514
- }
18
+ const core = createLaneCore(deps)
19
+ const { repoFor, refresh, childSettled, check } = core
20
+ const {
21
+ open, setup, prepareBind, land, askCleanup, cleanup, abandon, watch,
22
+ initSuggestions, writeConfig, reconcile: reconcileCommand, resolveArgPath,
23
+ approveGlobal: approveGlobalSafe,
24
+ } = createLaneWorkflows(core)
25
+ const { view, diffOf, board, summaryOf, actionsFor } = createLaneViews(core)
1515
26
  return {
1516
27
  repoFor, refresh, open, setup, prepareBind, childSettled, check, land, askCleanup, cleanup, abandon, watch,
1517
28
  view, diffOf, board, summaryOf, initSuggestions, writeConfig, reconcile: reconcileCommand, resolveArgPath, actionsFor,