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