dsh-plugin-worktrees 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,512 @@
1
+ /**
2
+ * StateStore — DESIGN §7 state & persistence for dsh-worktrees.
3
+ *
4
+ * One JSON file, atomically replaced on every write. Family precedent:
5
+ * dsh-plugin-subagents `lib/registry.js` (tmp + rename, owner-only 0600,
6
+ * `__proto__` key guard); dsh-atomic-write's `writeFileAtomic` semantics
7
+ * (wx exclusive create + rename + mode enforcement + symlink resistance)
8
+ * are the spec this implements locally, zero-dependency.
9
+ *
10
+ * Write discipline (§7.1):
11
+ * deep-copy + sanitize the in-memory state → `JSON.stringify(,2)` →
12
+ * tmp file in the SAME directory as the target — `openSync(tmp, "wx")`
13
+ * (exclusive create; a symlink pre-planted at the tmp name fails loudly
14
+ * instead of being followed), mode 0o600, best-effort chmod to force the
15
+ * mode on filesystems that ignore the create mode → `renameSync` atomic
16
+ * replace. A random suffix on the tmp name makes collisions (and hence
17
+ * EEXIST races between concurrent writers) practically impossible; a
18
+ * failed write removes the tmp best-effort so nothing litters the state
19
+ * directory.
20
+ *
21
+ * Read discipline (§7.1):
22
+ * `load()` reads the file (missing → initial state; unparseable → loud
23
+ * error naming the path), rebuilds every record through the same
24
+ * sanitizer (dangerous keys — `__proto__` / `constructor` / `prototype` —
25
+ * are dropped RECURSIVELY, in both the load and the upsert direction),
26
+ * and prunes terminal merge jobs to the newest 200.
27
+ *
28
+ * After `load()`, the in-memory index IS the truth (§7.2: single process;
29
+ * all writes happen inside the serial apply chain or a tool's single-step
30
+ * critical section). Mutations go through the upsert methods; `persist()`
31
+ * flushes the snapshot back atomically. The map getters hand out the live
32
+ * internal maps (zero-copy read access for services); the contract is
33
+ * read-mostly — structural abuse through those references is bounded
34
+ * because every persist() re-sanitizes a deep copy, so nothing dangerous
35
+ * or non-JSON ever reaches disk and the JSON round-trip stays exact.
36
+ */
37
+ import {
38
+ chmodSync,
39
+ closeSync,
40
+ mkdirSync,
41
+ openSync,
42
+ readFileSync,
43
+ renameSync,
44
+ rmSync,
45
+ writeFileSync,
46
+ } from "node:fs";
47
+ import { randomBytes } from "node:crypto";
48
+ import { dirname } from "node:path";
49
+
50
+ /**
51
+ * Merge-job states that end the job's lifecycle (DESIGN §5.2.2 state
52
+ * machine). Terminal jobs are pruned to the newest `keep` (§7.1); `conflicted`
53
+ * is NOT terminal — it is an active state whose worktree is the live scene.
54
+ */
55
+ export const TERMINAL_JOB_STATES = new Set([
56
+ "succeeded",
57
+ "failed",
58
+ "cancelled",
59
+ "resolved",
60
+ ]);
61
+
62
+ /**
63
+ * Merge-job states that hold the per-branch queue slot (§6 invariants: at
64
+ * most one active job per repo+integrationBranch).
65
+ */
66
+ export const ACTIVE_JOB_STATES = new Set(["queued", "applying", "conflicted"]);
67
+
68
+ /**
69
+ * Key names that must never become own properties of stored records: an
70
+ * own `__proto__` data property (exactly what `JSON.parse` produces for a
71
+ * hostile document) pollutes prototypes the moment anyone merges the record
72
+ * with `Object.assign`; `constructor` / `prototype` are the classic gadget
73
+ * chain companions. Dropped on upsert AND on load, recursively — the
74
+ * registry.js precedent.
75
+ */
76
+ const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
77
+
78
+ /** Terminal-job retention bound applied on every load() (§7.1). */
79
+ const DEFAULT_PRUNE_KEEP = 200;
80
+
81
+ /** @returns {StateFile} */
82
+ function initialState() {
83
+ return { version: 1, worktrees: {}, jobs: {}, repos: {} };
84
+ }
85
+
86
+ function isPlainObject(value) {
87
+ return value !== null && typeof value === "object" && !Array.isArray(value);
88
+ }
89
+
90
+ /**
91
+ * Deep copy that produces ONLY JSON-round-trip-safe data and drops dangerous
92
+ * keys at every level. This is simultaneously:
93
+ * - the upsert-time intake sanitizer (hostile record fields never enter the
94
+ * in-memory index),
95
+ * - the persist-time deep copy (§7.1: write = deep copy → stringify), and
96
+ * - the load-time sanitizer (a hand-tampered state file cannot smuggle a
97
+ * `__proto__` key back into memory).
98
+ *
99
+ * JSON semantics are mirrored exactly so `persist() → load()` compares
100
+ * deep-equal: `undefined` / functions / symbols are dropped from objects and
101
+ * become `null` inside arrays, non-finite numbers become `null`, and objects
102
+ * with a `toJSON()` (e.g. Date) are converted through it. A true cycle
103
+ * throws a loud TypeError (a DAG — the same sub-object referenced twice — is
104
+ * fine and stringifies twice, exactly like JSON.stringify).
105
+ *
106
+ * @param {unknown} value
107
+ * @param {boolean} inArray array-position context: JSON nulls unrepresentable
108
+ * values instead of dropping them.
109
+ * @param {Set<object>} seen cycle guard (entries removed on exit, so only
110
+ * cycles trip it, not shared references).
111
+ * @returns {unknown}
112
+ */
113
+ function sanitizeDeep(value, inArray = false, seen = new Set()) {
114
+ if (value === null) return null;
115
+ const type = typeof value;
116
+ if (type === "string" || type === "boolean") return value;
117
+ if (type === "number") return Number.isFinite(value) ? value : null;
118
+ if (type === "undefined" || type === "symbol" || type === "function" || type === "bigint") {
119
+ return inArray ? null : undefined; // caller drops the undefined entries
120
+ }
121
+ if (typeof value.toJSON === "function") {
122
+ return sanitizeDeep(value.toJSON(), inArray, seen);
123
+ }
124
+ if (seen.has(value)) {
125
+ throw new TypeError("state-store: circular reference in state record");
126
+ }
127
+ seen.add(value);
128
+ try {
129
+ if (Array.isArray(value)) {
130
+ return value.map((element) => sanitizeDeep(element, true, seen));
131
+ }
132
+ const out = {};
133
+ for (const [key, entry] of Object.entries(value)) {
134
+ if (DANGEROUS_KEYS.has(key)) continue;
135
+ const clean = sanitizeDeep(entry, false, seen);
136
+ if (clean !== undefined) out[key] = clean;
137
+ }
138
+ return out;
139
+ } finally {
140
+ seen.delete(value);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Rebuild one top-level map from parsed data: non-object maps fail LOUD
146
+ * (the crash reconcile must not silently trust a corrupt file), while
147
+ * non-object ENTRIES are unusable as records and are dropped.
148
+ *
149
+ * @param {unknown} value
150
+ * @param {string} name
151
+ * @param {string} path
152
+ * @returns {Record<string, object>}
153
+ */
154
+ function requireMap(value, name, path) {
155
+ if (value === undefined) return {};
156
+ if (!isPlainObject(value)) {
157
+ throw new Error(
158
+ `state-store: state file ${path} is corrupt: "${name}" must be an object`,
159
+ );
160
+ }
161
+ const out = {};
162
+ for (const [key, entry] of Object.entries(value)) {
163
+ if (DANGEROUS_KEYS.has(key)) continue;
164
+ if (isPlainObject(entry)) out[key] = entry;
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /**
170
+ * @typedef {object} WorktreeRecord DESIGN §5.2.2 (+ §10 seam fields).
171
+ * @property {string} id
172
+ * @property {string} repoKey
173
+ * @property {string} repoRoot
174
+ * @property {string} branch
175
+ * @property {string} path
176
+ * @property {string} baseCommit
177
+ * @property {string} integrationBranch
178
+ * @property {string} sessionId
179
+ * @property {string} task
180
+ * @property {"active"|"merging"|"merged"|"conflicted"|"abandoned"|"vanished"} state
181
+ * @property {string} [headCommit]
182
+ * @property {string} [mergeJobId]
183
+ * @property {string} [note]
184
+ * @property {"tool"|"dag"} [origin] creation provenance (§10 seam);
185
+ * absent on records written before the engine facade existed — the
186
+ * sanitize pass preserves whatever is present, old files stay valid.
187
+ * @property {string} [correlationId] create-time correlation token (the
188
+ * DAG attempt id); the reuse-ownership gate's evidence. Optional for the
189
+ * same backward-compatibility reason.
190
+ * @property {number} createdAt
191
+ * @property {number} updatedAt
192
+ */
193
+
194
+ /**
195
+ * @typedef {object} MergeJobRecord DESIGN §5.2.2.
196
+ * @property {string} id
197
+ * @property {string} repoKey
198
+ * @property {string} integrationBranch
199
+ * @property {string} worktreeId
200
+ * @property {string} sourceBranch
201
+ * @property {string} sourceHead
202
+ * @property {number} orderIndex
203
+ * @property {"queued"|"applying"|"succeeded"|"conflicted"|"failed"|"cancelled"|"resolved"} state
204
+ * @property {string} [integrationWorktree]
205
+ * @property {string} [integrationHeadBefore]
206
+ * @property {string} [integratedCommit]
207
+ * @property {string[]} [conflictFiles]
208
+ * @property {string} [error]
209
+ * @property {string} [message]
210
+ * @property {"tool"|"dag"} origin
211
+ * @property {string} [correlationId]
212
+ * @property {number} createdAt
213
+ * @property {number} updatedAt
214
+ */
215
+
216
+ /**
217
+ * @typedef {object} RepoRecord DESIGN §5.2.2 — crash-reconcile enumeration
218
+ * source (`repoKey → { root, lastSeenAt }`).
219
+ * @property {string} root
220
+ * @property {number} lastSeenAt
221
+ */
222
+
223
+ /**
224
+ * @typedef {object} StateFile state.json root shape.
225
+ * @property {number} version
226
+ * @property {Record<string, WorktreeRecord>} worktrees
227
+ * @property {Record<string, MergeJobRecord>} jobs
228
+ * @property {Record<string, RepoRecord>} repos
229
+ */
230
+
231
+ /**
232
+ * Create a StateStore bound to one state.json path. Pure ESM, zero runtime
233
+ * dependencies (node:fs / node:crypto / node:path only), Node >= 18.
234
+ *
235
+ * @param {{path: string}} options `path` is required — the default location
236
+ * (`~/.dsh/dsh-worktrees/state.json`) is resolved by the config layer
237
+ * (DESIGN §7.1 `statePath`), not here.
238
+ * @returns {{
239
+ * load: () => StateFile,
240
+ * persist: () => void,
241
+ * worktrees: Record<string, WorktreeRecord>,
242
+ * jobs: Record<string, MergeJobRecord>,
243
+ * repos: Record<string, RepoRecord>,
244
+ * upsertWorktree: (record: WorktreeRecord) => WorktreeRecord,
245
+ * upsertJob: (record: MergeJobRecord) => MergeJobRecord,
246
+ * pruneTerminalJobs: (keep?: number) => number,
247
+ * findActiveJob: (repoKey: string, integrationBranch: string) => MergeJobRecord | undefined,
248
+ * findQueuedJobs: (repoKey: string, integrationBranch: string) => MergeJobRecord[],
249
+ * nextOrderIndex: (repoKey: string, integrationBranch: string) => number,
250
+ * }}
251
+ */
252
+ export function createStateStore({ path }) {
253
+ if (typeof path !== "string" || path.length === 0) {
254
+ throw new Error("state-store: createStateStore requires a { path } string");
255
+ }
256
+
257
+ let state = initialState();
258
+
259
+ /**
260
+ * Read the state file into memory (startup path; §7.1 "读 = 启动一次").
261
+ * Missing file → initial state. Unreadable/unparseable/corrupt-shaped
262
+ * file → loud error naming the path. Terminal jobs are pruned to the
263
+ * newest 200 immediately; the pruned result flushes on the next persist().
264
+ *
265
+ * @returns {StateFile}
266
+ */
267
+ function load() {
268
+ let raw;
269
+ try {
270
+ raw = readFileSync(path, "utf8");
271
+ } catch (error) {
272
+ if (error && error.code === "ENOENT") {
273
+ state = initialState();
274
+ pruneTerminalJobs(DEFAULT_PRUNE_KEEP);
275
+ return state;
276
+ }
277
+ throw new Error(`state-store: cannot read state file ${path}: ${error.message}`);
278
+ }
279
+ let doc;
280
+ try {
281
+ doc = JSON.parse(raw);
282
+ } catch (error) {
283
+ throw new Error(`state-store: state file ${path} is not valid JSON: ${error.message}`);
284
+ }
285
+ if (!isPlainObject(doc)) {
286
+ throw new Error(
287
+ `state-store: state file ${path} is corrupt: expected a JSON object at the root`,
288
+ );
289
+ }
290
+ const clean = sanitizeDeep(doc);
291
+ state = {
292
+ version:
293
+ typeof clean.version === "number" && Number.isFinite(clean.version)
294
+ ? clean.version
295
+ : 1,
296
+ worktrees: requireMap(clean.worktrees, "worktrees", path),
297
+ jobs: requireMap(clean.jobs, "jobs", path),
298
+ repos: requireMap(clean.repos, "repos", path),
299
+ };
300
+ pruneTerminalJobs(DEFAULT_PRUNE_KEEP);
301
+ return state;
302
+ }
303
+
304
+ /**
305
+ * Atomically flush the in-memory state to disk (§7.1 write discipline).
306
+ * Throws on failure (after best-effort tmp cleanup) — callers in the apply
307
+ * chain surface the error; the previous state file is never damaged.
308
+ */
309
+ function persist() {
310
+ const snapshot = sanitizeDeep(state);
311
+ const json = JSON.stringify(snapshot, null, 2);
312
+ mkdirSync(dirname(path), { recursive: true });
313
+ const tmp = `${path}.${randomBytes(6).toString("hex")}.tmp`;
314
+ try {
315
+ // "wx" = O_CREAT | O_EXCL: exclusive create. A leftover tmp (or a
316
+ // pre-planted symlink) at this random name fails loudly instead of
317
+ // being followed or silently overwritten.
318
+ const fd = openSync(tmp, "wx", 0o600);
319
+ try {
320
+ writeFileSync(fd, json, "utf8");
321
+ } finally {
322
+ closeSync(fd);
323
+ }
324
+ // Best-effort mode enforcement (registry.js precedent): belt-and-braces
325
+ // against filesystems that weaken the create mode under umask.
326
+ try {
327
+ chmodSync(tmp, 0o600);
328
+ } catch {
329
+ /* best-effort */
330
+ }
331
+ renameSync(tmp, path);
332
+ } catch (error) {
333
+ // Never leave a half-written tmp behind; rethrow loudly.
334
+ try {
335
+ rmSync(tmp, { force: true });
336
+ } catch {
337
+ /* best-effort */
338
+ }
339
+ throw new Error(`state-store: failed to persist state to ${path}: ${error.message}`, {
340
+ cause: error,
341
+ });
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Upsert one record into a map by id: sanitizer first (dangerous keys and
347
+ * non-JSON values never enter the index), then `updatedAt = Date.now()`;
348
+ * `createdAt` is set on first insert and PRESERVED across updates (a
349
+ * caller re-upserting a fetched record keeps its original createdAt too).
350
+ *
351
+ * @param {Record<string, object>} map
352
+ * @param {unknown} record
353
+ * @param {"Worktree"|"Job"} kind
354
+ */
355
+ function upsert(map, record, kind) {
356
+ if (!isPlainObject(record)) {
357
+ throw new Error(`state-store: upsert${kind} requires a record object`);
358
+ }
359
+ const id = record.id;
360
+ if (typeof id !== "string" || id.length === 0) {
361
+ throw new Error(`state-store: upsert${kind} requires a record with a non-empty string id`);
362
+ }
363
+ if (DANGEROUS_KEYS.has(id)) {
364
+ // Assigning such an id as a key would set the map's prototype instead
365
+ // of creating an entry — refuse loudly (ids are internal, generated).
366
+ throw new Error(`state-store: upsert${kind} refused dangerous record id "${id}"`);
367
+ }
368
+ const clean = sanitizeDeep(record);
369
+ const existing = map[id];
370
+ if (existing !== undefined && existing.createdAt !== undefined) {
371
+ clean.createdAt = existing.createdAt;
372
+ }
373
+ if (clean.createdAt === undefined) {
374
+ clean.createdAt = Date.now();
375
+ }
376
+ clean.updatedAt = Date.now();
377
+ map[id] = clean;
378
+ return clean;
379
+ }
380
+
381
+ /**
382
+ * Upsert a worktree record (§5.2.2 shape). Not auto-persisted — callers
383
+ * flush explicitly inside their critical section.
384
+ * @param {WorktreeRecord} record
385
+ * @returns {WorktreeRecord}
386
+ */
387
+ function upsertWorktree(record) {
388
+ return upsert(state.worktrees, record, "Worktree");
389
+ }
390
+
391
+ /**
392
+ * Upsert a merge-job record (§5.2.2 shape). Not auto-persisted.
393
+ * @param {MergeJobRecord} record
394
+ * @returns {MergeJobRecord}
395
+ */
396
+ function upsertJob(record) {
397
+ return upsert(state.jobs, record, "Job");
398
+ }
399
+
400
+ /**
401
+ * Prune terminal jobs (succeeded/failed/cancelled/resolved) to the newest
402
+ * `keep` by updatedAt, deleting the rest. Active jobs are never touched.
403
+ * Ties on updatedAt (same millisecond) break by insertion order — the
404
+ * OLDEST entries are pruned deterministically (registry.js precedent).
405
+ *
406
+ * @param {number} [keep=200]
407
+ * @returns {number} count of pruned jobs.
408
+ */
409
+ function pruneTerminalJobs(keep = DEFAULT_PRUNE_KEEP) {
410
+ const limit = Math.max(0, Math.floor(Number(keep) || 0));
411
+ const terminal = [];
412
+ const ids = Object.keys(state.jobs);
413
+ for (let index = 0; index < ids.length; index += 1) {
414
+ const job = state.jobs[ids[index]];
415
+ if (job !== undefined && TERMINAL_JOB_STATES.has(job.state)) {
416
+ terminal.push({ id: ids[index], at: Number(job.updatedAt) || 0, seq: index });
417
+ }
418
+ }
419
+ if (terminal.length <= limit) return 0;
420
+ terminal.sort((a, b) => b.at - a.at || b.seq - a.seq); // newest first
421
+ for (const { id } of terminal.slice(limit)) {
422
+ delete state.jobs[id];
423
+ }
424
+ return terminal.length - limit;
425
+ }
426
+
427
+ /**
428
+ * The active (queued/applying/conflicted) job for one repo+integration
429
+ * branch, or undefined. The invariant allows at most one; if a crash ever
430
+ * leaves several, the newest by updatedAt wins (most useful for reconcile).
431
+ *
432
+ * @param {string} repoKey
433
+ * @param {string} integrationBranch
434
+ * @returns {MergeJobRecord | undefined}
435
+ */
436
+ function findActiveJob(repoKey, integrationBranch) {
437
+ let best = null;
438
+ for (const job of Object.values(state.jobs)) {
439
+ if (job.repoKey !== repoKey || job.integrationBranch !== integrationBranch) continue;
440
+ if (!ACTIVE_JOB_STATES.has(job.state)) continue;
441
+ const at = Number(job.updatedAt) || 0;
442
+ if (best === null || at >= (Number(best.updatedAt) || 0)) best = job;
443
+ }
444
+ return best === null ? undefined : best;
445
+ }
446
+
447
+ /**
448
+ * Queued jobs for one repo+integration branch, ascending by orderIndex
449
+ * (stable: equal/missing orderIndex keeps insertion order; missing sorts
450
+ * last). This is the drain order of the serial apply chain.
451
+ *
452
+ * @param {string} repoKey
453
+ * @param {string} integrationBranch
454
+ * @returns {MergeJobRecord[]}
455
+ */
456
+ function findQueuedJobs(repoKey, integrationBranch) {
457
+ const orderIndexOf = (job) => {
458
+ const n = Number(job.orderIndex);
459
+ return Number.isFinite(n) ? n : Infinity;
460
+ };
461
+ return Object.values(state.jobs)
462
+ .filter(
463
+ (job) =>
464
+ job.repoKey === repoKey &&
465
+ job.integrationBranch === integrationBranch &&
466
+ job.state === "queued",
467
+ )
468
+ .sort((a, b) => orderIndexOf(a) - orderIndexOf(b));
469
+ }
470
+
471
+ /**
472
+ * Next queue slot for one repo+integration branch: max orderIndex across
473
+ * that branch's jobs (any state) + 1. With no jobs the first slot is 0;
474
+ * successive calls without deletions are strictly monotonic.
475
+ *
476
+ * @param {string} repoKey
477
+ * @param {string} integrationBranch
478
+ * @returns {number}
479
+ */
480
+ function nextOrderIndex(repoKey, integrationBranch) {
481
+ let max = -1;
482
+ for (const job of Object.values(state.jobs)) {
483
+ if (job.repoKey !== repoKey || job.integrationBranch !== integrationBranch) continue;
484
+ const n = Number(job.orderIndex);
485
+ if (Number.isFinite(n) && n > max) max = n;
486
+ }
487
+ return max + 1;
488
+ }
489
+
490
+ return {
491
+ load,
492
+ persist,
493
+ upsertWorktree,
494
+ upsertJob,
495
+ pruneTerminalJobs,
496
+ findActiveJob,
497
+ findQueuedJobs,
498
+ nextOrderIndex,
499
+ // Live internal maps (read-mostly contract — see the file header). The
500
+ // getters re-read `state` on every access, so a load() rebinding is
501
+ // always reflected.
502
+ get worktrees() {
503
+ return state.worktrees;
504
+ },
505
+ get jobs() {
506
+ return state.jobs;
507
+ },
508
+ get repos() {
509
+ return state.repos;
510
+ },
511
+ };
512
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `worktree_cleanup` tool (T09, DESIGN §5.8).
3
+ *
4
+ * Thin tool layer: parameters schema, worktree_id-or-path input resolution,
5
+ * and a projection of the WorktreeService.cleanup result into the §5.8
6
+ * return shape. The double-confirmation protection (force && acknowledge,
7
+ * two INDEPENDENT booleans) lives in the engine; `cleanup_protected`
8
+ * errors re-throw verbatim so the surfaced message keeps its evidence
9
+ * (sourceHead / integration head / the force+acknowledge usage hint).
10
+ *
11
+ * deps contract:
12
+ * - service (required) createWorktreeService() product;
13
+ * - cfg (optional) reserved for T10 wiring parity (unused today).
14
+ *
15
+ * json-safe discipline (E3): the vanished-path `note` is a conditional
16
+ * spread; no key ever carries an `undefined` value.
17
+ */
18
+
19
+ import { defineTool } from '@deepseek-ai/dsh-tools'
20
+
21
+ /** Registered tool name (DESIGN §5.8). */
22
+ const TOOL_NAME = 'worktree_cleanup'
23
+
24
+ /** A message already starting with a stable snake_case code stays verbatim. */
25
+ const CODED_MESSAGE = /^[a-z][a-z0-9_]*(\s*—|:)/
26
+
27
+ /** Coded engine errors pass through; uncoded ones get the tool prefix. */
28
+ function surfaceError(error) {
29
+ const message = String(error instanceof Error ? error.message : error)
30
+ if (CODED_MESSAGE.test(message)) throw error
31
+ throw new Error(`${TOOL_NAME}: ${message}`)
32
+ }
33
+
34
+ /**
35
+ * Register the `worktree_cleanup` tool (DESIGN §5.8).
36
+ *
37
+ * @param {Object} ctx host ctx (needs ctx.tools.register)
38
+ * @param {Object} deps see the module header for the contract
39
+ */
40
+ export function registerWorktreeCleanupTool(ctx, deps = {}) {
41
+ const { service } = deps
42
+ if (!service || typeof service.cleanup !== 'function') {
43
+ throw new Error(
44
+ `${TOOL_NAME}: deps.service must be a WorktreeService (createWorktreeService product)`,
45
+ )
46
+ }
47
+
48
+ ctx.tools.register(defineTool({
49
+ name: TOOL_NAME,
50
+ description:
51
+ 'Remove one managed worktree and — unless keep_branch — its task branch, or prune a vanished record. '
52
+ + 'PROTECTION: a worktree whose work is NOT yet integrated is only removed with the DOUBLE confirmation force:true AND acknowledge:true (two independent booleans — a single force is not enough); otherwise cleanup_protected is raised with the evidence. '
53
+ + 'Unintegrated means EITHER the task branch head has not reached the integration branch, OR the integration branch does not exist yet (no merge has run) while the worktree holds commits past its base — both are unmerged work. '
54
+ + 'A merged worktree (worktree_merge succeeded) needs no force. Use worktree_list first to pick the record.',
55
+ parameters: {
56
+ worktree_id: {
57
+ type: 'string',
58
+ description: 'Worktree id (alternative: path).',
59
+ },
60
+ path: {
61
+ type: 'string',
62
+ description: 'Absolute worktree path (alternative: worktree_id).',
63
+ },
64
+ force: {
65
+ type: 'boolean',
66
+ description:
67
+ 'Also remove a worktree whose branch is NOT yet integrated (default false → protected).',
68
+ },
69
+ acknowledge: {
70
+ type: 'boolean',
71
+ description:
72
+ 'Second confirmation gate: must be true together with force to delete unmerged work. Never implied.',
73
+ },
74
+ keep_branch: {
75
+ type: 'boolean',
76
+ description: 'Remove the worktree directory but keep the branch (default false).',
77
+ },
78
+ },
79
+ output: {
80
+ schema: {
81
+ type: 'object',
82
+ additionalProperties: false,
83
+ properties: {
84
+ kind: { type: 'string', required: true, const: 'cleanup' },
85
+ id: { type: 'string', required: true },
86
+ removed_worktree: { type: 'boolean', required: true },
87
+ removed_branch: { type: 'boolean', required: true },
88
+ note: { type: 'string' },
89
+ },
90
+ },
91
+ render: (_args, value) => [{
92
+ type: 'text',
93
+ text: `cleanup ${value.id}: worktree=${value.removed_worktree ? 'removed' : 'absent'} branch=${value.removed_branch ? 'removed' : 'kept'}${value.note ? ` (${value.note})` : ''}`,
94
+ }],
95
+ },
96
+ // Deletes shared state (record + git objects) behind a check-then-act
97
+ // protection gate; sibling calls must never interleave.
98
+ isConcurrencySafe: () => false,
99
+ async execute(args) {
100
+ try {
101
+ const idOrPath = args.worktree_id !== undefined ? args.worktree_id : args.path
102
+ if (idOrPath === undefined || idOrPath === '') {
103
+ throw new Error(
104
+ `${TOOL_NAME}: worktree_id_or_path_missing — pass worktree_id (from worktree_create) or the absolute worktree path`,
105
+ )
106
+ }
107
+
108
+ const result = await service.cleanup({
109
+ idOrPath,
110
+ ...(args.force !== undefined ? { force: args.force } : {}),
111
+ ...(args.acknowledge !== undefined ? { acknowledge: args.acknowledge } : {}),
112
+ ...(args.keep_branch !== undefined ? { keepBranch: args.keep_branch } : {}),
113
+ })
114
+
115
+ return {
116
+ kind: 'cleanup',
117
+ id: result.id,
118
+ removed_worktree: result.removedWorktree,
119
+ removed_branch: result.removedBranch,
120
+ ...(typeof result.note === 'string' ? { note: result.note } : {}),
121
+ }
122
+ } catch (error) {
123
+ surfaceError(error)
124
+ }
125
+ },
126
+ }))
127
+ }