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,396 @@
1
+ /**
2
+ * `worktree_merge` tool (T09, DESIGN §5.6 — the core write tool).
3
+ *
4
+ * Thin tool layer over the MergeQueue engine: load the worktree record,
5
+ * `queue.collect` (auto-commit uncommitted task work; a clean tree at base
6
+ * is the NORMAL `no_changes` outcome), `queue.enqueue` (origin 'tool'),
7
+ * `queue.drain` (serial, to the branch's natural stopping point), then map
8
+ * THIS job's outcome into the §5.6 oneOf five-state return. No git calls
9
+ * and no state writes happen here — the engine is the injected `queue` and
10
+ * the record/job reads go through the injected `store` (the same StateStore
11
+ * the queue was built with).
12
+ *
13
+ * `active_job_exists` is deliberately a RETURN STATE, never a throw
14
+ * (DESIGN §5.6): when the drain was blocked by another conflicted/applying
15
+ * job while this job is still queued, the tool returns
16
+ * `state:'failed', error:'active_job_exists: job <id> holds <branch> …'`.
17
+ *
18
+ * deps contract:
19
+ * - queue (required) createMergeQueue() product (collect/enqueue/drain);
20
+ * - store (required) the SAME StateStore the queue was built with
21
+ * (worktree record lookup + post-drain job re-read);
22
+ * - git (required when integration_branch overrides are vetted) the
23
+ * GitPort — used ONLY for the P1-B validateBranch probe of
24
+ * an explicit override (never for the merge itself; that
25
+ * stays inside the queue).
26
+ *
27
+ * json-safe discipline (E3): the five return shapes are built with
28
+ * conditional spreads only — the `no_changes` shape simply has no job_id
29
+ * key (no job was created), and no key ever carries an `undefined` value.
30
+ *
31
+ * Error policy: coded engine/gate errors (`worktree_not_found — …`) pass
32
+ * through unchanged; MergeError from the queue is re-thrown with its
33
+ * machine-matchable code prefix (`git_operation_failed — …`); anything
34
+ * uncoded gets the tool-name prefix.
35
+ */
36
+
37
+ import { defineTool } from '@deepseek-ai/dsh-tools'
38
+ import { MergeError } from '../merge-queue.js'
39
+
40
+ /** Registered tool name (DESIGN §5.6). */
41
+ const TOOL_NAME = 'worktree_merge'
42
+
43
+ /** A message already starting with a stable snake_case code stays verbatim. */
44
+ const CODED_MESSAGE = /^[a-z][a-z0-9_]*(\s*—|:)/
45
+
46
+ /**
47
+ * Error surfacing: MergeError carries its code on `error.code` but not in
48
+ * the message text, so it is re-thrown WITH the code prefix (DESIGN §5.6
49
+ * error family is machine-matchable); coded plain errors pass through;
50
+ * uncoded ones get the tool-name prefix.
51
+ */
52
+ function surfaceError(error) {
53
+ if (error instanceof MergeError) {
54
+ throw new Error(`${error.code} — ${error.message}`)
55
+ }
56
+ const message = String(error instanceof Error ? error.message : error)
57
+ if (CODED_MESSAGE.test(message)) throw error
58
+ throw new Error(`${TOOL_NAME}: ${message}`)
59
+ }
60
+
61
+ /** §5.6 resolution_hint — the DESIGN sentence with the retained scene path. */
62
+ function resolutionHint(scenePath) {
63
+ return (
64
+ `The integration worktree is RETAINED at ${scenePath} with the conflict markers in place. `
65
+ + `Resolve out-of-band (edit there, or abandon), then worktree_queue(action:'resolve'|'retry').`
66
+ )
67
+ }
68
+
69
+ /**
70
+ * Register the `worktree_merge` tool (DESIGN §5.6).
71
+ *
72
+ * @param {Object} ctx host ctx (needs ctx.tools.register)
73
+ * @param {Object} deps see the module header for the contract
74
+ */
75
+ export function registerWorktreeMergeTool(ctx, deps = {}) {
76
+ const { queue, store, git } = deps
77
+ if (
78
+ !queue
79
+ || typeof queue.collect !== 'function'
80
+ || typeof queue.enqueue !== 'function'
81
+ || typeof queue.drain !== 'function'
82
+ ) {
83
+ throw new Error(
84
+ `${TOOL_NAME}: deps.queue must be a MergeQueue (createMergeQueue product: collect/enqueue/drain)`,
85
+ )
86
+ }
87
+ if (
88
+ !store
89
+ || typeof store.worktrees !== 'object'
90
+ || typeof store.jobs !== 'object'
91
+ || typeof store.findActiveJob !== 'function'
92
+ || typeof store.findQueuedJobs !== 'function'
93
+ ) {
94
+ throw new Error(
95
+ `${TOOL_NAME}: deps.store must be the StateStore the queue was built with (worktrees/jobs maps + findActiveJob/findQueuedJobs)`,
96
+ )
97
+ }
98
+
99
+ ctx.tools.register(defineTool({
100
+ name: TOOL_NAME,
101
+ description:
102
+ 'Integrate one task worktree\'s branch into its integration branch: auto-commit any uncommitted work in the task worktree, enqueue a merge job, and drain that branch\'s queue serially until it is empty or a conflict holds the branch. '
103
+ + 'Returns the job\'s outcome: succeeded (integrated_commit), conflicted (conflict_files + the RETAINED integration worktree with the markers in place — resolve out-of-band, then worktree_queue resolve/retry), queued (queued_ahead), no_changes (a clean tree at base — nothing to integrate), or failed (error). '
104
+ + 'With autoCollect=false a DIRTY worktree fails as "dirty_not_collected" — uncommitted work is never silently skipped; commit it first or enable autoCollect. '
105
+ + 'A branch held by another conflicted/applying job maps to failed with an "active_job_exists: …" error — resolve or retry that job first; this is an expected business state, not an exception.',
106
+ parameters: {
107
+ worktree_id: {
108
+ type: 'string',
109
+ required: true,
110
+ description: 'Worktree id from worktree_create whose branch should be integrated now.',
111
+ },
112
+ integration_branch: {
113
+ type: 'string',
114
+ description: 'Overrides the integration branch recorded at create time (rarely needed).',
115
+ },
116
+ message: {
117
+ type: 'string',
118
+ description:
119
+ 'Merge commit message. Default: "dsh-worktrees: integrate <task> (<short source head>)".',
120
+ },
121
+ commit_message: {
122
+ type: 'string',
123
+ description:
124
+ 'Commit message for the auto-collected commit of uncommitted changes in the task worktree (default: "dsh-worktrees: collect <task>").',
125
+ },
126
+ },
127
+ output: {
128
+ schema: {
129
+ oneOf: [
130
+ {
131
+ type: 'object',
132
+ additionalProperties: false,
133
+ properties: {
134
+ kind: { type: 'string', required: true, const: 'merge' },
135
+ job_id: { type: 'string', required: true },
136
+ worktree_id: { type: 'string', required: true },
137
+ state: { type: 'string', required: true, const: 'succeeded' },
138
+ integrated_commit: { type: 'string', required: true },
139
+ integration_branch: { type: 'string', required: true },
140
+ },
141
+ },
142
+ {
143
+ type: 'object',
144
+ additionalProperties: false,
145
+ properties: {
146
+ kind: { type: 'string', required: true, const: 'merge' },
147
+ job_id: { type: 'string', required: true },
148
+ worktree_id: { type: 'string', required: true },
149
+ state: { type: 'string', required: true, const: 'conflicted' },
150
+ conflict_files: {
151
+ type: 'array',
152
+ required: true,
153
+ items: { type: 'string' },
154
+ },
155
+ integration_worktree: { type: 'string', required: true },
156
+ resolution_hint: { type: 'string', required: true },
157
+ },
158
+ },
159
+ {
160
+ type: 'object',
161
+ additionalProperties: false,
162
+ properties: {
163
+ kind: { type: 'string', required: true, const: 'merge' },
164
+ job_id: { type: 'string', required: true },
165
+ worktree_id: { type: 'string', required: true },
166
+ state: { type: 'string', required: true, const: 'queued' },
167
+ queued_ahead: { type: 'integer', required: true },
168
+ },
169
+ },
170
+ {
171
+ type: 'object',
172
+ additionalProperties: false,
173
+ properties: {
174
+ kind: { type: 'string', required: true, const: 'merge' },
175
+ worktree_id: { type: 'string', required: true },
176
+ state: { type: 'string', required: true, const: 'no_changes' },
177
+ },
178
+ },
179
+ {
180
+ type: 'object',
181
+ additionalProperties: false,
182
+ properties: {
183
+ kind: { type: 'string', required: true, const: 'merge' },
184
+ job_id: { type: 'string', required: true },
185
+ worktree_id: { type: 'string', required: true },
186
+ state: { type: 'string', required: true, const: 'failed' },
187
+ error: { type: 'string', required: true },
188
+ },
189
+ },
190
+ ],
191
+ },
192
+ render: (_args, value) => {
193
+ let detail = ''
194
+ if (value.state === 'succeeded') {
195
+ detail = ` → ${value.integrated_commit} on ${value.integration_branch}`
196
+ } else if (value.state === 'conflicted') {
197
+ detail = ` — ${value.conflict_files.length} file(s) conflicted; scene retained at ${value.integration_worktree}`
198
+ } else if (value.state === 'queued') {
199
+ detail = ` (queued_ahead: ${value.queued_ahead})`
200
+ } else if (value.state === 'failed') {
201
+ detail = ` — ${value.error}`
202
+ }
203
+ return [{ type: 'text', text: `merge ${value.worktree_id}: ${value.state}${detail}` }]
204
+ },
205
+ },
206
+ // Shared queue state is mutated (enqueue + drain) and the whole body is
207
+ // a check-then-act section — sibling calls must never overlap (the
208
+ // engine's per-branch promise chain is the second line of defence).
209
+ isConcurrencySafe: () => false,
210
+ async execute(args) {
211
+ try {
212
+ // Step 1 (§5.6): the worktree record must exist.
213
+ const record = store.worktrees[args.worktree_id]
214
+ if (record === undefined) {
215
+ throw new Error(
216
+ `worktree_not_found — no worktree record matches id "${args.worktree_id}"`,
217
+ )
218
+ }
219
+ const integrationBranch =
220
+ typeof args.integration_branch === 'string' && args.integration_branch.length > 0
221
+ ? args.integration_branch
222
+ : record.integrationBranch
223
+
224
+ // Step 0 — the branch-holder pre-check (source applyNext step 1,
225
+ // L506-520 semantics, surfaced at the TOOL layer per DESIGN §5.6):
226
+ // an applying/conflicted job holds the integration branch, so
227
+ // enqueueing would only stack a job that can never apply. This is
228
+ // the `active_job_exists` RETURN STATE — an expected business
229
+ // state, never a throw. A QUEUED holder does not block (it is what
230
+ // our own drain would apply next).
231
+ const holder = store.findActiveJob(record.repoKey, integrationBranch)
232
+ if (holder !== undefined && holder.state !== 'queued') {
233
+ return {
234
+ kind: 'merge',
235
+ job_id: holder.id,
236
+ worktree_id: record.id,
237
+ state: 'failed',
238
+ error:
239
+ `active_job_exists: job ${holder.id} holds ${integrationBranch} `
240
+ + `(state=${holder.state}); resolve or retry it first`,
241
+ }
242
+ }
243
+
244
+ // Step 2: collect. A clean tree at base is the no_changes NORMAL
245
+ // outcome — return WITHOUT enqueueing (no job_id exists, so the
246
+ // key is simply absent from the return shape). A DIRTY tree with
247
+ // autoCollect=false is the explicit `dirty_not_collected` terminal
248
+ // (audit P2): the uncommitted files were never integrated, so
249
+ // proceeding to a merge of the OLD head would report a misleading
250
+ // succeeded. Stop loud, no job is created.
251
+ const collected = await queue.collect(record, args.commit_message)
252
+ if (collected.state === 'no_changes') {
253
+ return { kind: 'merge', worktree_id: record.id, state: 'no_changes' }
254
+ }
255
+ if (collected.state === 'dirty_not_collected') {
256
+ return {
257
+ kind: 'merge',
258
+ worktree_id: record.id,
259
+ state: 'failed',
260
+ error:
261
+ 'dirty_not_collected: the worktree has uncommitted changes while autoCollect=false, ' +
262
+ 'so nothing was integrated; commit the work first (or enable the autoCollect config) ' +
263
+ 'and call worktree_merge again',
264
+ }
265
+ }
266
+
267
+ // Step 2b (audit P1-B): an EXPLICIT integration_branch override gets
268
+ // the same check-ref-format vetting the create entry applies — the
269
+ // recorded default was vetted at create time, the override arrives
270
+ // raw from the model and flows into `git branch <name> <startPoint>`
271
+ // at apply time.
272
+ if (
273
+ typeof args.integration_branch === 'string'
274
+ && args.integration_branch.length > 0
275
+ && args.integration_branch !== record.integrationBranch
276
+ ) {
277
+ if (!git || typeof git.validateBranch !== 'function') {
278
+ throw new Error(
279
+ `${TOOL_NAME}: deps.git must be a GitPort (validateBranch) to vet an integration_branch override`,
280
+ )
281
+ }
282
+ const valid = await git.validateBranch(record.repoRoot, args.integration_branch)
283
+ if (!valid) {
284
+ throw new Error(
285
+ 'invalid_integration_branch — integration branch name ' +
286
+ `"${args.integration_branch}" is rejected by git check-ref-format --branch ` +
287
+ '(legal branch names: no leading "-" or "-", no "..", no ASCII control characters ' +
288
+ 'or spaces, no trailing ".lock", no "@{"/"@"-only, no ref-component starting with ' +
289
+ '"."; slashes are allowed, e.g. "dsh-wt/integration/x")',
290
+ )
291
+ }
292
+ }
293
+
294
+ // Step 3 + 4: enqueue (origin 'tool'), then drain the branch to its
295
+ // natural stopping point (empty or a conflict holding the branch).
296
+ const job = await queue.enqueue({
297
+ repoKey: record.repoKey,
298
+ repoRoot: record.repoRoot,
299
+ integrationBranch,
300
+ worktreeId: record.id,
301
+ sourceBranch: record.branch,
302
+ sourceHead: collected.sourceHead,
303
+ ...(args.message !== undefined ? { message: args.message } : {}),
304
+ origin: 'tool',
305
+ })
306
+ const outcome = await queue.drain(record.repoKey, integrationBranch)
307
+
308
+ // Step 5: THIS job's outcome, re-read from the store (the drain may
309
+ // also have applied jobs enqueued by other calls).
310
+ const fresh = store.jobs[job.id] ?? job
311
+ if (fresh.state === 'succeeded') {
312
+ return {
313
+ kind: 'merge',
314
+ job_id: job.id,
315
+ worktree_id: record.id,
316
+ state: 'succeeded',
317
+ integrated_commit: fresh.integratedCommit,
318
+ integration_branch: integrationBranch,
319
+ }
320
+ }
321
+ if (fresh.state === 'conflicted') {
322
+ const scene = fresh.integrationWorktree
323
+ return {
324
+ kind: 'merge',
325
+ job_id: job.id,
326
+ worktree_id: record.id,
327
+ state: 'conflicted',
328
+ conflict_files: Array.isArray(fresh.conflictFiles) ? [...fresh.conflictFiles] : [],
329
+ integration_worktree: scene,
330
+ resolution_hint: resolutionHint(scene),
331
+ }
332
+ }
333
+ if (fresh.state === 'failed') {
334
+ return {
335
+ kind: 'merge',
336
+ job_id: job.id,
337
+ worktree_id: record.id,
338
+ state: 'failed',
339
+ error: String(fresh.error ?? 'merge failed'),
340
+ }
341
+ }
342
+ if (fresh.state === 'cancelled') {
343
+ // Defensive: an external cancel raced the drain. Map into the
344
+ // five-state contract instead of inventing a sixth state.
345
+ return {
346
+ kind: 'merge',
347
+ job_id: job.id,
348
+ worktree_id: record.id,
349
+ state: 'failed',
350
+ error: `merge job ${job.id} was cancelled before it could apply`,
351
+ }
352
+ }
353
+
354
+ // Our job is STILL queued — the drain stopped before reaching it.
355
+ // blockedBy another conflicted/applying job → active_job_exists as
356
+ // a RETURN STATE (DESIGN §5.6: expected business state, not throw).
357
+ const blocker = outcome !== undefined && outcome !== null ? outcome.blockedBy : undefined
358
+ if (
359
+ blocker !== undefined
360
+ && blocker !== null
361
+ && blocker.id !== job.id
362
+ && (blocker.state === 'conflicted' || blocker.state === 'applying')
363
+ ) {
364
+ return {
365
+ kind: 'merge',
366
+ job_id: job.id,
367
+ worktree_id: record.id,
368
+ state: 'failed',
369
+ error:
370
+ `active_job_exists: job ${blocker.id} holds ${integrationBranch} `
371
+ + `(state=${blocker.state}); resolve or retry it first`,
372
+ }
373
+ }
374
+
375
+ // Defensive fallback (unreachable with the serial chain): report
376
+ // the queue position — how many queued jobs sit ahead of ours.
377
+ const myOrder = Number(fresh.orderIndex) || 0
378
+ const queuedAhead = store
379
+ .findQueuedJobs(record.repoKey, integrationBranch)
380
+ .filter(
381
+ (other) => other.id !== job.id && (Number(other.orderIndex) || 0) < myOrder,
382
+ )
383
+ .length
384
+ return {
385
+ kind: 'merge',
386
+ job_id: job.id,
387
+ worktree_id: record.id,
388
+ state: 'queued',
389
+ queued_ahead: queuedAhead,
390
+ }
391
+ } catch (error) {
392
+ surfaceError(error)
393
+ }
394
+ },
395
+ }))
396
+ }