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,641 @@
1
+ // Lane core: the ledger + check engine every lane mutation in the product
2
+ // funnels through. Owns the repo-resolution plumbing, this instance's
3
+ // watch-expiry timers, refresh/reconciliation, zombie-binding settlement
4
+ // (reconcileBinding / childSettled), and the single apply() that performs
5
+ // every state-machine transition — one-shot watch hits are collected in the
6
+ // same atomic ledger write (design D3), audit + delivery after the write.
7
+ // Workflows and views build on this module; it never imports them.
8
+ // ctx-free by construction: git, shell, questions, notifications, audit and
9
+ // the session-mode reader are injected (src/worktree/index.js wires them).
10
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
11
+ import { dirname, join, sep } from 'node:path'
12
+ import { WORKTREE_CODES, WorktreeError } from './errors.js'
13
+ import { CHECKABLE, LANDABLE_FROM, nextFor, transition } from './state.js'
14
+ import { collectWatchHits, pruneExpiredWatches, removeWatchForExpiry } from './watches.js'
15
+ import { normalizeRoot, parseRepoConfig } from './rules.js'
16
+ import { reconcile } from './reconcile.js'
17
+ import { createLedger } from './ledger.js'
18
+ import { renderNotice, renderWatchExpired, renderWatchHit } from './prompts.js'
19
+ import { authorityResidue } from './authority.js'
20
+
21
+ export const CONFIG_FILE = '.config.json'
22
+ const LOG_TAIL_LINES = 40
23
+
24
+ /** Audit kind per state-machine event (AUDIT_SUBTYPES.worktree). */
25
+ const AUDIT_KIND = Object.freeze({
26
+ 'setup-ok': 'setup', 'setup-fail': 'setup', 'setup-retry': 'setup', bind: 'bind', checked: 'checked',
27
+ 'check-pass': 'check', 'check-fail': 'check', invalidate: 'invalidate', ask: 'ask', decline: 'decline',
28
+ conflict: 'conflict', land: 'land', keep: 'cleanup', clean: 'cleanup', abandon: 'abandon', missing: 'abandon',
29
+ })
30
+
31
+ /**
32
+ * Comparable form of a path: realpath when it exists, '/'-separated, and
33
+ * case-folded on win32.
34
+ * @param {string} path
35
+ * @param {string} [platform]
36
+ */
37
+ export function pathKey(path, platform = process.platform) {
38
+ let real = path
39
+ try {
40
+ real = realpathSync.native(path)
41
+ } catch {
42
+ // a path that does not exist yet keeps its lexical form
43
+ let parent = dirname(path)
44
+ const tail = [path.slice(parent.length + 1)]
45
+ while (parent !== dirname(parent)) {
46
+ try {
47
+ real = join(realpathSync.native(parent), ...tail.reverse())
48
+ break
49
+ } catch {
50
+ tail.push(parent.slice(dirname(parent).length + 1))
51
+ parent = dirname(parent)
52
+ }
53
+ }
54
+ }
55
+ const slashed = real.split(sep).join('/').replace(/\/+$/, '')
56
+ return platform === 'win32' ? slashed.toLowerCase() : slashed
57
+ }
58
+
59
+ /**
60
+ * @typedef {object} LaneServiceDeps
61
+ * @property {ReturnType<typeof import('./git.js').createGit>} git
62
+ * @property {null | ((request: { command: string, cwd: string, timeoutMs: number, session?: any }) => Promise<{ code: number, output: string, denied: boolean, timedOut: boolean }>)} shellRun
63
+ * @property {() => { enabled: boolean, root: string, maxActive: number, autoSetup: boolean }} settings
64
+ * @property {null | ((agent: any, questions: any[], signal?: AbortSignal) => Promise<{ answers: Array<{ id: string, selected: string[], custom?: string }> }>)} ask
65
+ * @property {(sessionId: string, text: string) => void} notify
66
+ * @property {(type: string, data: any, root: string, sessionId?: string | null) => void} audit
67
+ * @property {(session: any) => boolean} modeOf
68
+ * @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)
69
+ * @property {(session: any) => 'session' | 'global'} [approveModeSourceOf] - where the effective mode comes from; absent = global
70
+ * @property {() => 'manual' | 'auto-keep' | 'auto-clean'} [approveGlobal] - the resolved global default (module default when unset/invalid); absent = auto-clean
71
+ * @property {(sessionId: string | undefined) => string | undefined} [localeOf] - the GUI language last reported for a session
72
+ * @property {() => number} [now]
73
+ * @property {(handler: () => void, delayMs: number) => any} [setTimer] - watch-expiry scheduler (tests); defaults to an unref'd setTimeout
74
+ * @property {(handle: any) => void} [clearTimer]
75
+ * @property {number} [pid] - this process id (tests)
76
+ * @property {{ warn?: (message: string) => void }} [logger]
77
+ * @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
78
+ * @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
79
+ */
80
+
81
+ /** @param {LaneServiceDeps} deps */
82
+ export function createLaneCore(deps) {
83
+ const { git, settings } = deps
84
+ const now = deps.now ?? Date.now
85
+ /** Effective auto-approve mode for a session, fail-safe to `manual` so a missing dep (tests, older wiring) never auto-approves. @param {any} session */
86
+ const approveModeSafe = (session) => {
87
+ try {
88
+ return deps.approveModeOf?.(session) ?? 'manual'
89
+ } catch {
90
+ return 'manual'
91
+ }
92
+ }
93
+ /** Where the effective mode comes from ('session' override vs 'global' setting). @param {any} session */
94
+ const approveModeSourceSafe = (session) => {
95
+ try {
96
+ return deps.approveModeSourceOf?.(session) ?? 'global'
97
+ } catch {
98
+ return 'global'
99
+ }
100
+ }
101
+ /** @type {Map<string, Promise<any>>} cwd+root → repo */
102
+ const pending = new Map()
103
+ /** @type {Map<string, any>} cwd+root → resolved repo (sync access for the prompt board) */
104
+ const resolved = new Map()
105
+ /** @type {Promise<number[] | null> | null} */
106
+ let versionProbe = null
107
+ /** Lanes whose approval card is open in THIS process. @type {Set<string>} */
108
+ const asking = new Set()
109
+ const pid = deps.pid ?? process.pid
110
+ /** Whether an approval card recorded in the ledger is still open somewhere. @param {any} lane */
111
+ const askAlive = (lane) => {
112
+ const owner = lane.asking?.pid
113
+ if (owner === pid) return asking.has(lane.id)
114
+ if (typeof owner !== 'number') return false
115
+ try {
116
+ process.kill(owner, 0)
117
+ return true
118
+ } catch {
119
+ return false
120
+ }
121
+ }
122
+
123
+ // ─── watch-expiry timers (this instance only; the ledger owns the truth) ─
124
+
125
+ /** Watch-expiry timers armed by THIS service instance, watch id → handle.
126
+ * The lock arbitrates between instances: whichever instance removes the
127
+ * record under the ledger lock delivers that watch's single expiry notice. */
128
+ const watchTimers = new Map()
129
+ const setTimer = deps.setTimer ?? ((/** @type {() => void} */ handler, /** @type {number} */ delayMs) => {
130
+ const timer = setTimeout(handler, delayMs)
131
+ timer.unref?.()
132
+ return timer
133
+ })
134
+ const clearTimer = deps.clearTimer ?? clearTimeout
135
+
136
+ /** @param {string} watchId */
137
+ function disarmWatch(watchId) {
138
+ const handle = watchTimers.get(watchId)
139
+ if (handle !== undefined) {
140
+ clearTimer(handle)
141
+ watchTimers.delete(watchId)
142
+ }
143
+ }
144
+
145
+ /** @param {any} repo @param {any} entry */
146
+ function armWatch(repo, entry) {
147
+ if (watchTimers.has(entry.id)) return
148
+ const delay = entry.expiresAt - now()
149
+ if (delay <= 0) return
150
+ watchTimers.set(entry.id, setTimer(() => {
151
+ watchTimers.delete(entry.id)
152
+ void expireWatch(repo, entry.id).catch((error) => deps.logger?.warn?.(`worktree: watch expiry failed: ${/** @type {any} */ (error)?.message ?? error}`))
153
+ }, delay))
154
+ }
155
+
156
+ /**
157
+ * An expiry timer fired: remove the watch under the ledger lock; only the
158
+ * instance that actually removes the record delivers the expiry notice
159
+ * (the watch's single delivery), so two instances can never double-deliver.
160
+ * @param {any} repo @param {string} watchId
161
+ */
162
+ async function expireWatch(repo, watchId) {
163
+ const removed = await repo.ledger.update((/** @type {any} */ ledger) => {
164
+ const watch = removeWatchForExpiry(ledger, watchId, now())
165
+ return watch ? { ledger, result: watch } : {}
166
+ })
167
+ if (!removed) return
168
+ deps.audit('watch', { lane: removed.laneId, sessionId: removed.sessionId, states: removed.states, outcome: 'expired' }, repo.mainRoot, removed.sessionId)
169
+ try {
170
+ deps.notify(removed.sessionId, renderWatchExpired(removed))
171
+ } catch (error) {
172
+ deps.logger?.warn?.(`worktree: watch-expiry notification failed: ${/** @type {any} */ (error)?.message ?? error}`)
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Ledger lazy-cleanup + per-instance timer arming (design D4). Runs on every
178
+ * refresh — which is also the host-load path: watches whose deadline passed
179
+ * while the host was down are pruned silently + audited (never delivered,
180
+ * never hit-tested); every live watch gets this instance's expiry timer.
181
+ * @param {any} repo
182
+ */
183
+ async function syncWatches(repo) {
184
+ const pruned = await repo.ledger.update((/** @type {any} */ ledger) => {
185
+ const expired = pruneExpiredWatches(ledger, now())
186
+ return expired.length > 0 ? { ledger, result: expired } : {}
187
+ })
188
+ for (const watch of pruned ?? []) {
189
+ deps.audit('watch', { lane: watch.laneId, sessionId: watch.sessionId, states: watch.states, outcome: 'pruned' }, repo.mainRoot, watch.sessionId)
190
+ }
191
+ for (const entry of repo.ledger.read().watches) armWatch(repo, entry)
192
+ }
193
+
194
+ const cwdOf = (/** @type {any} */ session) => {
195
+ const cwd = session?.header?.cwd
196
+ if (typeof cwd !== 'string' || !cwd) throw new WorktreeError(WORKTREE_CODES.NOT_A_REPO, 'this session has no working directory')
197
+ return cwd
198
+ }
199
+
200
+ function config() {
201
+ const value = settings()
202
+ if (!value.enabled) throw new WorktreeError(WORKTREE_CODES.WORKTREE_DISABLED, 'worktree lanes are disabled (worktreeEnabled is off)')
203
+ return value
204
+ }
205
+
206
+ /** @param {string} cwd */
207
+ async function repoFor(cwd) {
208
+ const root = normalizeRoot(config().root)
209
+ const key = `${cwd}\0${root}`
210
+ if (!pending.has(key)) {
211
+ const promise = resolveRepo(cwd, root).then((repo) => {
212
+ resolved.set(key, repo)
213
+ return repo
214
+ })
215
+ promise.catch(() => pending.delete(key))
216
+ pending.set(key, promise)
217
+ }
218
+ return pending.get(key)
219
+ }
220
+
221
+ /** @param {string} cwd @param {string} root */
222
+ async function resolveRepo(cwd, root) {
223
+ versionProbe ??= git.version(cwd)
224
+ const version = await versionProbe
225
+ if (!git.supported(version)) {
226
+ versionProbe = null
227
+ throw new WorktreeError(WORKTREE_CODES.GIT_TOO_OLD, version
228
+ ? `git ${version.join('.')} is too old; worktree lanes need git 2.38 or newer`
229
+ : `git could not be run (${git.lastVersionError ?? 'unknown error'}); worktree lanes need git 2.38 or newer`)
230
+ }
231
+ const info = await git.repoOf(cwd)
232
+ if (!info) throw new WorktreeError(WORKTREE_CODES.NOT_A_REPO, `${cwd} is not inside a git repository`)
233
+ const mainRoot = realpathSync.native(info.mainRoot)
234
+ const rootPath = join(mainRoot, ...root.split('/'))
235
+ return { cwd, mainRoot, commonDir: info.commonDir, root, rootPath, ledger: createLedger(rootPath, { now }), version, unmanaged: /** @type {string[]} */ ([]) }
236
+ }
237
+
238
+ /** Resolved repo for sync callers (prompt board), or null. @param {any} session */
239
+ function repoCached(session) {
240
+ try {
241
+ const value = settings()
242
+ if (!value.enabled) return null
243
+ const cwd = cwdOf(session)
244
+ const key = `${cwd}\0${normalizeRoot(value.root)}`
245
+ if (!resolved.has(key) && !pending.has(key)) void repoFor(cwd).catch(() => {})
246
+ return resolved.get(key) ?? null
247
+ } catch {
248
+ return null
249
+ }
250
+ }
251
+
252
+ // ─── ledger helpers ───────────────────────────────────────────────────
253
+
254
+ /** @param {any} repo @param {string} laneId */
255
+ function laneOf(repo, laneId) {
256
+ const lane = repo.ledger.read().lanes.find((/** @type {any} */ entry) => entry.id === laneId)
257
+ if (!lane) throw new WorktreeError(WORKTREE_CODES.UNKNOWN_LANE, `no lane "${laneId}" in ${repo.rootPath}`)
258
+ return lane
259
+ }
260
+
261
+ /**
262
+ * Transition one lane under the ledger lock; `check` runs inside the lock
263
+ * against the current record (throw to refuse).
264
+ * @param {any} repo @param {string} laneId @param {any} event @param {(lane: any, ledger: any) => void} [check]
265
+ */
266
+ async function apply(repo, laneId, event, check) {
267
+ /** Watches this transition consumed (removed in the same atomic write). @type {any[]} */
268
+ let hits = []
269
+ const lane = await repo.ledger.update((/** @type {any} */ ledger) => {
270
+ const index = ledger.lanes.findIndex((/** @type {any} */ entry) => entry.id === laneId)
271
+ if (index === -1) throw new WorktreeError(WORKTREE_CODES.UNKNOWN_LANE, `no lane "${laneId}"`)
272
+ check?.(ledger.lanes[index], ledger)
273
+ const next = transition(ledger.lanes[index], { at: now(), ...event })
274
+ ledger.lanes[index] = next
275
+ // One-shot watches: a hit is removed in the SAME atomic write as the
276
+ // state transition, so it can never be collected twice (design D3).
277
+ hits = collectWatchHits(ledger, next)
278
+ return { ledger, result: next }
279
+ })
280
+ const kind = AUDIT_KIND[/** @type {keyof typeof AUDIT_KIND} */ (event.type)]
281
+ // `event.audit` carries extra payload for the SAME kind (the auto-approve
282
+ // marker on land/abandon) without a new audit type; transition() ignores
283
+ // the key, so it never leaks into the lane record.
284
+ 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)
285
+ for (const hit of hits) {
286
+ disarmWatch(hit.id)
287
+ try {
288
+ deps.notify(hit.sessionId, renderWatchHit(lane))
289
+ } catch (error) {
290
+ deps.logger?.warn?.(`worktree: watch-hit notification failed: ${/** @type {any} */ (error)?.message ?? error}`)
291
+ }
292
+ }
293
+ return lane
294
+ }
295
+
296
+ /** Non-state field update (no transition). @param {any} repo @param {string} laneId @param {(lane: any) => any} patch */
297
+ async function patchLane(repo, laneId, patch) {
298
+ return repo.ledger.update((/** @type {any} */ ledger) => {
299
+ const index = ledger.lanes.findIndex((/** @type {any} */ entry) => entry.id === laneId)
300
+ if (index === -1) return {}
301
+ ledger.lanes[index] = { ...ledger.lanes[index], ...patch(ledger.lanes[index]), updatedAt: now() }
302
+ return { ledger, result: ledger.lanes[index] }
303
+ })
304
+ }
305
+
306
+ /**
307
+ * Binding-commit CAS (lane-binding-authoritative-commit D1): the nonce check
308
+ * and the childId swap happen in ONE atomic ledger write — under the same
309
+ * exclusive lock the reservation clearers (force-reclaim's childSettled and
310
+ * reconcile --rebuild's ledger.replace) go through, so a reclaimed
311
+ * reservation can never be committed onto. Refuses LANE_RESERVATION_LOST
312
+ * when the lane no longer holds the nonce or is no longer `working`; the
313
+ * refusal carries the structured facts (state, reservationHeld) and
314
+ * lane-workflows owns the model-facing copy (D2). Deliberately separate
315
+ * from patchLane: a refused commit is a structured refusal, not an empty
316
+ * patch.
317
+ * @param {any} repo @param {string} laneId @param {string} nonce @param {string} childId
318
+ */
319
+ async function commitBinding(repo, laneId, nonce, childId) {
320
+ return repo.ledger.update((/** @type {any} */ ledger) => {
321
+ const index = ledger.lanes.findIndex((/** @type {any} */ entry) => entry.id === laneId)
322
+ if (index === -1) {
323
+ throw new WorktreeError(WORKTREE_CODES.LANE_RESERVATION_LOST, `lane "${laneId}" no longer exists in ${repo.rootPath}`, {
324
+ lane: laneId,
325
+ data: { state: null, reservationHeld: false },
326
+ })
327
+ }
328
+ const current = ledger.lanes[index]
329
+ if (current.boundChild !== nonce || current.state !== 'working') {
330
+ throw new WorktreeError(WORKTREE_CODES.LANE_RESERVATION_LOST, `lane ${laneId} is ${current.state} and ${current.boundChild === nonce ? 'still holds' : 'no longer holds'} the reservation`, {
331
+ lane: laneId,
332
+ data: { state: current.state, reservationHeld: current.boundChild === nonce },
333
+ })
334
+ }
335
+ ledger.lanes[index] = { ...current, boundChild: childId, updatedAt: now() }
336
+ return { ledger, result: ledger.lanes[index] }
337
+ })
338
+ }
339
+
340
+ /** @param {any} lane @param {string} [detail] */
341
+ function notifyOwner(lane, detail) {
342
+ if (!lane?.ownerSession) return
343
+ try {
344
+ deps.notify(lane.ownerSession, renderNotice(lane, detail))
345
+ } catch (error) {
346
+ deps.logger?.warn?.(`worktree: notification failed: ${/** @type {any} */ (error)?.message ?? error}`)
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Best-effort Edit Lock authority-residue probe (design D5): does the
352
+ * lane's owner session still own unresolved operations or locks in the
353
+ * management domain? Warning input only — read-only, and a failed check
354
+ * is silent (never blocks the cleanup/abandon transition).
355
+ * @param {any} repo @param {any} lane @returns {{ operations: number, locks: number } | null}
356
+ */
357
+ function residueOf(repo, lane) {
358
+ try {
359
+ return (deps.authorityResidue ?? authorityResidue)(repo.mainRoot, lane?.ownerSession) ?? null
360
+ } catch (error) {
361
+ deps.logger?.warn?.(`worktree: authority-residue check failed: ${/** @type {any} */ (error)?.message ?? error}`)
362
+ return null
363
+ }
364
+ }
365
+
366
+ /** @param {any} lane */
367
+ function result(lane, summary, extra = {}) {
368
+ return {
369
+ lane: lane.id, state: lane.state, summary, path: lane.path, branch: lane.branch, next: nextFor(lane),
370
+ ...(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 })) } } : {}),
371
+ ...extra,
372
+ }
373
+ }
374
+
375
+ /** @param {any} repo */
376
+ function readConfig(repo) {
377
+ const file = join(repo.rootPath, CONFIG_FILE)
378
+ if (!existsSync(file)) return parseRepoConfig(undefined)
379
+ let raw
380
+ try {
381
+ raw = JSON.parse(readFileSync(file, 'utf8'))
382
+ } catch (error) {
383
+ throw new Error(`invalid ${file}: ${/** @type {any} */ (error)?.message ?? error}`)
384
+ }
385
+ try {
386
+ return parseRepoConfig(raw)
387
+ } catch (error) {
388
+ throw new Error(`invalid ${file}: ${/** @type {any} */ (error)?.message ?? error}`)
389
+ }
390
+ }
391
+
392
+ /** @param {any} repo @param {string} laneId @param {string} name */
393
+ function logFile(repo, laneId, name) {
394
+ const dir = join(repo.rootPath, '.logs', laneId)
395
+ mkdirSync(dir, { recursive: true })
396
+ return join(dir, `${name}.log`)
397
+ }
398
+
399
+ // ─── reconciliation ───────────────────────────────────────────────────
400
+
401
+ /** @param {any} repo */
402
+ async function refresh(repo) {
403
+ // Watch maintenance first (design D4): prune watches that expired while
404
+ // the host was down (silent + audited) and arm this instance's timers;
405
+ // the reconcile transitions below then hit only live watches.
406
+ await syncWatches(repo)
407
+ const worktrees = await git.worktreeList(repo.mainRoot)
408
+ const ledger = repo.ledger.read()
409
+ const mainBranch = await git.currentBranch(repo.mainRoot)
410
+ /** @type {Map<string, string | null>} */
411
+ const trees = new Map()
412
+ for (const lane of ledger.lanes) {
413
+ if (LANDABLE_FROM.includes(lane.state) && lane.landableTree && existsSync(lane.path)) trees.set(lane.id, await git.tree(lane.path))
414
+ }
415
+ const outcome = reconcile({ lanes: ledger.lanes, worktrees, rootPath: repo.rootPath, mainBranch, trees, normalize: (path) => pathKey(path) })
416
+ for (const event of outcome.events) {
417
+ await apply(repo, event.id, { type: event.type, reason: event.reason }).catch(() => {})
418
+ }
419
+ // An approval card dies with the tool call that raised it (restart,
420
+ // crash): such a lane is declined, never left waiting forever.
421
+ for (const lane of ledger.lanes) {
422
+ if (lane.state === 'awaiting-approval' && !askAlive(lane)) {
423
+ await apply(repo, lane.id, { type: 'decline', reason: 'the approval card was closed before an answer (the tool call ended)' }).catch(() => {})
424
+ }
425
+ }
426
+ const flagsChanged = ledger.lanes.some((/** @type {any} */ lane) => outcome.baseMoved.has(lane.id) && Boolean(lane.baseMoved) !== outcome.baseMoved.get(lane.id))
427
+ if (flagsChanged) {
428
+ await repo.ledger.update((/** @type {any} */ current) => {
429
+ for (const lane of current.lanes) if (outcome.baseMoved.has(lane.id)) lane.baseMoved = outcome.baseMoved.get(lane.id)
430
+ return { ledger: current }
431
+ })
432
+ }
433
+ if (outcome.unmanaged.length && outcome.unmanaged.join() !== repo.unmanaged.join()) {
434
+ deps.audit('reconcile', { unmanaged: outcome.unmanaged }, repo.mainRoot, null)
435
+ }
436
+ repo.unmanaged = outcome.unmanaged
437
+ return { mainBranch, unmanaged: outcome.unmanaged }
438
+ }
439
+
440
+ // ─── binding reconciliation (zombie-lane settlement) ────────────────────
441
+
442
+ /** One-phrase reconciliation outcome for LANE_BUSY messages. @param {any} verdict */
443
+ function reconcileNote(verdict) {
444
+ if (verdict?.childAlive) return 'the bound worker is still live'
445
+ if (verdict?.ownerAlive) return 'the lane owner session is still live'
446
+ return 'no terminal evidence exists for the bound worker (offline is not dead)'
447
+ }
448
+
449
+ /**
450
+ * Zombie-binding reconciliation (worktree-zombie-lane-reclamation D1/D2):
451
+ * the read-only liveness probe runs before any LANE_BUSY refusal. Only
452
+ * terminal evidence ∧ both sides dead settles the binding — through the
453
+ * exact childSettled path, so a reconciled settlement is indistinguishable
454
+ * from a live one downstream — and the settlement is audited
455
+ * (worktree/reconcile-binding), exactly once (the call that actually
456
+ * cleared the binding audits; a concurrent/repeat reconcile finds the
457
+ * binding gone and audits nothing). A refusal leaves the ledger untouched.
458
+ * `forced` skips the probe: the user's explicit card decision (D4).
459
+ * @param {any} repo @param {any} lane @param {any} session @param {{ forced?: boolean }} [options]
460
+ * @returns {Promise<{ settled: boolean, lane: any, verdict: any, childId: string, forced: boolean } | null>} null = no binding, or no probe available
461
+ */
462
+ async function reconcileBinding(repo, lane, session, options = {}) {
463
+ if (!lane?.boundChild) return null
464
+ const childId = lane.boundChild
465
+ const forced = options.forced === true
466
+ /** @type {any} */
467
+ let verdict = null
468
+ if (!forced) {
469
+ if (typeof deps.bindingLiveness !== 'function') return null
470
+ try {
471
+ verdict = await deps.bindingLiveness(childId, lane.ownerSession ?? null, { root: repo.mainRoot })
472
+ } catch (error) {
473
+ deps.logger?.warn?.(`worktree: binding liveness probe failed for lane ${lane.id}: ${/** @type {any} */ (error)?.message ?? error}`)
474
+ return null
475
+ }
476
+ if (!verdict || verdict.childAlive || verdict.ownerAlive || !verdict.terminalEvidence) {
477
+ return { settled: false, lane, verdict, childId, forced }
478
+ }
479
+ }
480
+ const cleared = await childSettled(childId, session)
481
+ const after = laneOf(repo, lane.id)
482
+ if (cleared != null) {
483
+ deps.audit('reconcile-binding', {
484
+ lane: lane.id,
485
+ child: childId,
486
+ ownerSession: lane.ownerSession ?? null,
487
+ forced,
488
+ evidence: verdict?.terminalEvidence ?? null,
489
+ childAlive: verdict?.childAlive ?? null,
490
+ ownerAlive: verdict?.ownerAlive ?? null,
491
+ }, repo.mainRoot, lane.ownerSession)
492
+ }
493
+ return { settled: after.boundChild == null, lane: after, verdict, childId, forced }
494
+ }
495
+
496
+ /**
497
+ * A bound writer settled: free the lane and run the host check. `silent`
498
+ * suppresses the immediate notice when the caller reports the outcome
499
+ * itself (foreground result / job result); background verification still
500
+ * notifies when it finishes.
501
+ * @param {string} childId @param {any} parentSession @param {{ silent?: boolean }} [options]
502
+ */
503
+ async function childSettled(childId, parentSession, options = {}) {
504
+ let repo
505
+ try {
506
+ repo = await repoFor(cwdOf(parentSession))
507
+ } catch {
508
+ return null
509
+ }
510
+ const lane = repo.ledger.read().lanes.find((/** @type {any} */ entry) => entry.boundChild === childId)
511
+ if (!lane) return null
512
+ await patchLane(repo, lane.id, () => ({ boundChild: null }))
513
+ const checked = await runChecks(repo, lane.id, parentSession, { trigger: 'settle', silent: options.silent })
514
+ return { ...checked, notice: renderNotice(checked, checked.reason ?? undefined) }
515
+ }
516
+
517
+ // ─── checks ───────────────────────────────────────────────────────────
518
+
519
+ /**
520
+ * Mandatory preconditions, then (when configured) verification in the
521
+ * background. Returns the lane as of the precondition outcome.
522
+ * @param {any} repo @param {string} laneId @param {any} session @param {{ trigger: string, awaitVerification?: boolean, silent?: boolean }} options
523
+ */
524
+ async function runChecks(repo, laneId, session, options) {
525
+ const tell = (/** @type {any} */ lane, /** @type {string} */ detail) => {
526
+ if (!options.silent) notifyOwner(lane, detail)
527
+ }
528
+ const lane = laneOf(repo, laneId)
529
+ if (!CHECKABLE.includes(lane.state)) return lane
530
+ if (lane.boundChild) {
531
+ // Zombie-binding reconciliation (D1) before the refusal: only terminal
532
+ // evidence ∧ both sides dead clears the binding (audited); the settle
533
+ // path then ran this very check, so return its outcome.
534
+ const outcome = await reconcileBinding(repo, lane, session)
535
+ if (outcome?.settled) return outcome.lane
536
+ const detail = outcome?.verdict ? ` (binding reconciliation: ${reconcileNote(outcome.verdict)}; the abandon confirmation card offers a force-reclaim)` : ''
537
+ 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) })
538
+ }
539
+ const changes = await git.status(lane.path)
540
+ /** @type {{ to: string, reason: string } | null} */
541
+ let failed = null
542
+ if (changes.length > 0) failed = { to: 'dirty', reason: `${changes.length} uncommitted path(s): ${changes.slice(0, 5).map((entry) => entry.path).join(', ')}` }
543
+ else if ((await git.currentBranch(lane.path)) !== lane.branch) failed = { to: 'branch-moved', reason: `HEAD is not on ${lane.branch}` }
544
+ 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}` }
545
+ if (failed) {
546
+ const next = await apply(repo, laneId, { type: 'checked', to: failed.to, reason: failed.reason })
547
+ tell(next, failed.reason)
548
+ return next
549
+ }
550
+ const tree = await git.tree(lane.path)
551
+ /** @type {any} */
552
+ let repoConfig
553
+ try {
554
+ repoConfig = readConfig(repo)
555
+ } catch (error) {
556
+ const checking = await apply(repo, laneId, { type: 'checked', to: 'checking', patch: { check: { enabled: true, tree, results: [] } } })
557
+ const failedLane = await apply(repo, checking.id, { type: 'check-fail', reason: /** @type {any} */ (error)?.message ?? String(error) })
558
+ tell(failedLane, failedLane.reason)
559
+ return failedLane
560
+ }
561
+ if (repoConfig.check.length === 0) {
562
+ const landable = await apply(repo, laneId, { type: 'checked', to: 'landable', patch: { landableTree: tree, check: { enabled: false, tree } } })
563
+ tell(landable, 'mandatory checks passed; verification is not enabled for this repository')
564
+ return landable
565
+ }
566
+ const checking = await apply(repo, laneId, { type: 'checked', to: 'checking', patch: { check: { enabled: true, tree, results: [], startedAt: now() } } })
567
+ const run = runVerification(repo, laneId, repoConfig.check, session, tree)
568
+ if (options.awaitVerification) return run
569
+ void run
570
+ return checking
571
+ }
572
+
573
+ /** @param {any} repo @param {string} laneId @param {Array<{ name: string, run: string, timeoutSec: number }>} commands @param {any} session @param {string | null} tree */
574
+ async function runVerification(repo, laneId, commands, session, tree) {
575
+ const lanePath = laneOf(repo, laneId).path
576
+ /** @type {any[]} */
577
+ const results = []
578
+ /** @type {string | null} */
579
+ let failure = null
580
+ let tail = ''
581
+ if (!deps.shellRun) failure = `${WORKTREE_CODES.SHELL_UNAVAILABLE}: no shell executor is available for verification`
582
+ for (const [index, command] of commands.entries()) {
583
+ if (failure) break
584
+ const log = logFile(repo, laneId, `${index + 1}-${command.name.replace(/[^A-Za-z0-9_.-]+/g, '-')}`)
585
+ const started = now()
586
+ try {
587
+ const run = await /** @type {any} */ (deps.shellRun)({ command: command.run, cwd: lanePath, timeoutMs: command.timeoutSec * 1000, session })
588
+ writeFileSync(log, run.output)
589
+ results.push({ name: command.name, run: command.run, exit: run.code, ms: now() - started, log, denied: run.denied })
590
+ if (run.code !== 0) {
591
+ failure = `${command.name} ${run.timedOut ? 'timed out' : `exited ${run.code}`}${run.denied ? ' (sandbox denied)' : ''}`
592
+ tail = run.output.split(/\r?\n/).slice(-LOG_TAIL_LINES).join('\n')
593
+ }
594
+ } catch (error) {
595
+ results.push({ name: command.name, run: command.run, exit: -1, ms: now() - started, log })
596
+ failure = `${command.name} could not run: ${/** @type {any} */ (error)?.message ?? error}`
597
+ }
598
+ }
599
+ if (!failure) {
600
+ const after = await git.status(lanePath)
601
+ if (after.length > 0 || (await git.tree(lanePath)) !== tree) failure = 'verification modified lane content'
602
+ }
603
+ try {
604
+ const lane = failure
605
+ ? await apply(repo, laneId, { type: 'check-fail', reason: failure, patch: { check: { enabled: true, tree, results, finishedAt: now() } } })
606
+ : await apply(repo, laneId, { type: 'check-pass', patch: { landableTree: tree, check: { enabled: true, tree, results, finishedAt: now() } } })
607
+ const last = results.at(-1)
608
+ notifyOwner(lane, failure ? `${failure}; log ${last?.log ?? 'n/a'}${tail ? `\n${tail}` : ''}` : `verification passed: ${results.map((entry) => entry.name).join(', ')}`)
609
+ return lane
610
+ } catch {
611
+ return null
612
+ }
613
+ }
614
+
615
+ /**
616
+ * worktree_check / /worktree check.
617
+ * @param {any} session @param {string} laneId @param {{ verificationOnly?: boolean, awaitVerification?: boolean }} [options]
618
+ */
619
+ async function check(session, laneId, options = {}) {
620
+ const repo = await repoFor(cwdOf(session))
621
+ await refresh(repo)
622
+ const current = laneOf(repo, laneId)
623
+ if (options.verificationOnly && readConfig(repo).check.length === 0) {
624
+ 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) })
625
+ }
626
+ if (!CHECKABLE.includes(current.state)) {
627
+ throw new WorktreeError(WORKTREE_CODES.ILLEGAL_TRANSITION, `lane ${laneId} is ${current.state} and cannot be checked`, { lane: laneId, next: nextFor(current) })
628
+ }
629
+ const lane = await runChecks(repo, laneId, session, { trigger: 'check', awaitVerification: options.awaitVerification })
630
+ return result(lane, `lane ${lane.state}${lane.reason ? `: ${lane.reason}` : ''}`)
631
+ }
632
+
633
+ return {
634
+ deps, git, now, pid, asking,
635
+ approveModeSafe, approveModeSourceSafe,
636
+ cwdOf, config, repoFor, repoCached,
637
+ armWatch, disarmWatch,
638
+ laneOf, apply, patchLane, commitBinding, notifyOwner, residueOf, result, readConfig, logFile,
639
+ refresh, reconcileNote, reconcileBinding, childSettled, check,
640
+ }
641
+ }