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,330 @@
1
+ /**
2
+ * `worktree_queue` tool (T09, DESIGN §5.7).
3
+ *
4
+ * Thin tool layer over the MergeQueue engine's four commands:
5
+ * - `list` → listJobs + branchHolders projections (§5.7 list shape);
6
+ * - `cancel` / `retry` / `resolve` → the command state machine (idempotent
7
+ * where the source is; invalid transitions throw invalid_job_state which
8
+ * this layer re-throws with its code so the host maps it to isError).
9
+ *
10
+ * No git calls, no direct state writes — the engine is the injected
11
+ * `queue`; the repo_root filter goes through the §5.2.0 gate (injected
12
+ * `resolveRepo`, or the real binding built from `git` + `cfg`).
13
+ *
14
+ * deps contract:
15
+ * - queue (required) createMergeQueue() product;
16
+ * - resolveRepo (optional) prebound §5.2.0 gate ({ repoArg, sessionCwd });
17
+ * - git + cfg (fallback) real resolveRepoRoot binding when resolveRepo
18
+ * is absent;
19
+ * - sessionCwd (optional) fallback when the calling exec has no session
20
+ * header cwd.
21
+ *
22
+ * json-safe discipline (E3): conditional spreads everywhere; the optional
23
+ * job fields (conflict_files / integrated_commit / error) appear ONLY when
24
+ * they carry a value.
25
+ */
26
+
27
+ import { defineTool } from '@deepseek-ai/dsh-tools'
28
+ import { resolveRepoRoot } from '../repo-gate.js'
29
+ import { MergeError } from '../merge-queue.js'
30
+
31
+ /** Registered tool name (DESIGN §5.7). */
32
+ const TOOL_NAME = 'worktree_queue'
33
+
34
+ /** A message already starting with a stable snake_case code stays verbatim. */
35
+ const CODED_MESSAGE = /^[a-z][a-z0-9_]*(\s*—|:)/
36
+
37
+ /**
38
+ * Error surfacing: MergeError codes prefix the re-thrown message
39
+ * (`invalid_job_state — …`); coded plain errors pass through; uncoded ones
40
+ * get the tool-name prefix.
41
+ */
42
+ function surfaceError(error) {
43
+ if (error instanceof MergeError) {
44
+ throw new Error(`${error.code} — ${error.message}`)
45
+ }
46
+ const message = String(error instanceof Error ? error.message : error)
47
+ if (CODED_MESSAGE.test(message)) throw error
48
+ throw new Error(`${TOOL_NAME}: ${message}`)
49
+ }
50
+
51
+ /** Bind the §5.2.0 gate: injected prebound resolver, else the real gate. */
52
+ function makeRepoResolver(deps) {
53
+ if (typeof deps.resolveRepo === 'function') return deps.resolveRepo
54
+ const { git, cfg = {} } = deps
55
+ if (!git || typeof git.isGitRepo !== 'function') {
56
+ throw new Error(
57
+ `${TOOL_NAME}: deps must carry either resolveRepo(opts) or a git port with isGitRepo(cwd) so the repo gate can be bound`,
58
+ )
59
+ }
60
+ return (opts) =>
61
+ resolveRepoRoot({
62
+ repoArg: opts.repoArg,
63
+ sessionCwd: opts.sessionCwd,
64
+ workspacePaths: cfg.workspacePaths,
65
+ allowedRoots: cfg.allowedRoots,
66
+ git,
67
+ })
68
+ }
69
+
70
+ /**
71
+ * §5.7 job row projection: the DESIGN key set with conditional optional
72
+ * keys (conflict_files / integrated_commit / error) — repoRoot null maps to
73
+ * a JSON null (the engine enriches unknown repos as null, and a null is a
74
+ * value, not an undefined hole).
75
+ */
76
+ function jobRow(job) {
77
+ return {
78
+ job_id: job.id,
79
+ worktree_id: job.worktreeId,
80
+ task: job.task ?? null,
81
+ repo_root: job.repoRoot ?? null,
82
+ integration_branch: job.integrationBranch,
83
+ state: job.state,
84
+ order_index: Number(job.orderIndex) || 0,
85
+ ...(Array.isArray(job.conflictFiles) && job.conflictFiles.length > 0
86
+ ? { conflict_files: [...job.conflictFiles] }
87
+ : {}),
88
+ ...(job.integratedCommit !== undefined && job.integratedCommit !== null
89
+ ? { integrated_commit: job.integratedCommit }
90
+ : {}),
91
+ ...(job.error !== undefined && job.error !== null ? { error: job.error } : {}),
92
+ created_at: Number(job.createdAt) || 0,
93
+ }
94
+ }
95
+
96
+ /**
97
+ * §5.7 branch_holders projection: the jobs currently holding a branch slot
98
+ * (queued/applying/conflicted) — who is blocking the queue, at a glance.
99
+ */
100
+ function holderRow(holder) {
101
+ return {
102
+ repo_root: holder.repoRoot ?? null,
103
+ integration_branch: holder.integrationBranch,
104
+ job_id: holder.jobId,
105
+ state: holder.state,
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Whether a command RELEASED the branch (the engine's resolve/retry then
111
+ * kick a background drain): `true` only when the job HELD the branch slot
112
+ * before the command (conflicted) and left the active set after it
113
+ * (resolved/queued — queued jobs sit waiting, they do not hold a blockage;
114
+ * cancel's queued job never did either). Idempotent repeats change nothing
115
+ * → false.
116
+ *
117
+ * Both arguments are SCALAR states captured around the command dispatch —
118
+ * never job-record references: `store.jobs[id]` hands out the live record,
119
+ * so a `before` reference would alias `after` whenever a command (or a
120
+ * future engine change) mutated the record in place, silently collapsing
121
+ * `released` to false.
122
+ */
123
+ function releasedBranch(stateBefore, stateAfter) {
124
+ const heldBefore = stateBefore === 'conflicted'
125
+ const activeAfter = stateAfter === 'conflicted' || stateAfter === 'applying'
126
+ return heldBefore && !activeAfter
127
+ }
128
+
129
+ /**
130
+ * Register the `worktree_queue` tool (DESIGN §5.7).
131
+ *
132
+ * @param {Object} ctx host ctx (needs ctx.tools.register)
133
+ * @param {Object} deps see the module header for the contract
134
+ */
135
+ export function registerWorktreeQueueTool(ctx, deps = {}) {
136
+ const { queue } = deps
137
+ const required = ['listJobs', 'branchHolders', 'cancel', 'retry', 'resolve']
138
+ for (const method of required) {
139
+ if (!queue || typeof queue[method] !== 'function') {
140
+ throw new Error(
141
+ `${TOOL_NAME}: deps.queue must be a MergeQueue (createMergeQueue product: ${required.join('/')})`,
142
+ )
143
+ }
144
+ }
145
+ const store = deps.store
146
+ if (
147
+ !store
148
+ || typeof store.jobs !== 'object'
149
+ || typeof store.findActiveJob !== 'function'
150
+ || typeof store.findQueuedJobs !== 'function'
151
+ ) {
152
+ throw new Error(
153
+ `${TOOL_NAME}: deps.store must be the StateStore the queue was built with (jobs map + findActiveJob/findQueuedJobs for the released probe)`,
154
+ )
155
+ }
156
+ const resolveRepo = makeRepoResolver(deps)
157
+
158
+ ctx.tools.register(defineTool({
159
+ name: TOOL_NAME,
160
+ description:
161
+ 'Inspect or command the merge queue: list merge jobs (with per-repo/branch filters) plus the branch_holders view of who currently holds each integration branch (queued/applying/conflicted); cancel a queued job; retry a failed/conflicted job (re-queues at the tail; the old retained integration worktree is left for the operator); or resolve a conflicted job whose conflicts were fixed out-of-band in its retained integration worktree (releases the branch so queued jobs proceed). '
162
+ + 'Commands are idempotent on their terminal state and reject invalid transitions (invalid_job_state) — e.g. cancel only works on a queued job.',
163
+ parameters: {
164
+ action: {
165
+ type: 'string',
166
+ required: true,
167
+ enum: ['list', 'cancel', 'retry', 'resolve'],
168
+ description: 'Queue operation: list jobs + branch holders, or command one job.',
169
+ },
170
+ repo_root: {
171
+ type: 'string',
172
+ description: 'list: filter by repo (default all).',
173
+ },
174
+ integration_branch: {
175
+ type: 'string',
176
+ description: 'list: filter by integration branch.',
177
+ },
178
+ job_id: {
179
+ type: 'string',
180
+ description: 'cancel/retry/resolve: the merge job id.',
181
+ },
182
+ },
183
+ output: {
184
+ schema: {
185
+ oneOf: [
186
+ {
187
+ type: 'object',
188
+ additionalProperties: false,
189
+ properties: {
190
+ kind: { type: 'string', required: true, const: 'queue' },
191
+ jobs: {
192
+ type: 'array',
193
+ required: true,
194
+ items: {
195
+ type: 'object',
196
+ additionalProperties: true,
197
+ properties: {
198
+ job_id: { type: 'string', required: true },
199
+ worktree_id: { type: 'string', required: true },
200
+ task: {
201
+ required: true,
202
+ oneOf: [{ type: 'string' }, { type: 'null' }],
203
+ },
204
+ repo_root: {
205
+ required: true,
206
+ oneOf: [{ type: 'string' }, { type: 'null' }],
207
+ },
208
+ integration_branch: { type: 'string', required: true },
209
+ state: { type: 'string', required: true },
210
+ order_index: { type: 'integer', required: true },
211
+ conflict_files: { type: 'array', items: { type: 'string' } },
212
+ integrated_commit: { type: 'string' },
213
+ error: { type: 'string' },
214
+ created_at: { type: 'integer', required: true },
215
+ },
216
+ },
217
+ },
218
+ branch_holders: {
219
+ type: 'array',
220
+ required: true,
221
+ items: {
222
+ type: 'object',
223
+ additionalProperties: false,
224
+ properties: {
225
+ repo_root: {
226
+ required: true,
227
+ oneOf: [{ type: 'string' }, { type: 'null' }],
228
+ },
229
+ integration_branch: { type: 'string', required: true },
230
+ job_id: { type: 'string', required: true },
231
+ state: { type: 'string', required: true },
232
+ },
233
+ },
234
+ },
235
+ },
236
+ },
237
+ {
238
+ type: 'object',
239
+ additionalProperties: false,
240
+ properties: {
241
+ kind: { type: 'string', required: true, const: 'queue_command' },
242
+ action: {
243
+ type: 'string',
244
+ required: true,
245
+ enum: ['cancel', 'retry', 'resolve'],
246
+ },
247
+ job_id: { type: 'string', required: true },
248
+ state: { type: 'string', required: true },
249
+ released: { type: 'boolean', required: true },
250
+ },
251
+ },
252
+ ],
253
+ },
254
+ render: (_args, value) => {
255
+ if (value.kind === 'queue') {
256
+ const byState = {}
257
+ for (const job of value.jobs) byState[job.state] = (byState[job.state] ?? 0) + 1
258
+ const summary = Object.entries(byState).map(([s, n]) => `${s}=${n}`).join(' ')
259
+ return [{
260
+ type: 'text',
261
+ text: `queue: ${value.jobs.length} job(s)${summary ? ` (${summary})` : ''}; ${value.branch_holders.length} branch holder(s)`,
262
+ }]
263
+ }
264
+ return [{
265
+ type: 'text',
266
+ text: `queue ${value.action} ${value.job_id}: ${value.state}${value.released ? ' (branch released, drain kicked)' : ''}`,
267
+ }]
268
+ },
269
+ },
270
+ // Mutates shared queue state (command transitions + the release drain);
271
+ // list is read-mostly but shares the declaration for simplicity and
272
+ // safety — a stale list interleaved with a command would mislead.
273
+ isConcurrencySafe: () => false,
274
+ async execute(args, exec) {
275
+ try {
276
+ if (args.action === 'list') {
277
+ let repoKey
278
+ if (args.repo_root !== undefined) {
279
+ const session = exec && exec.agent ? exec.agent.session : undefined
280
+ const sessionCwd =
281
+ (session && session.header && session.header.cwd) || deps.sessionCwd
282
+ repoKey = (
283
+ await resolveRepo({
284
+ repoArg: args.repo_root,
285
+ ...(sessionCwd !== undefined ? { sessionCwd } : {}),
286
+ })
287
+ ).repoKey
288
+ }
289
+ const jobs = queue.listJobs({
290
+ ...(repoKey !== undefined ? { repoKey } : {}),
291
+ ...(args.integration_branch !== undefined
292
+ ? { integrationBranch: args.integration_branch }
293
+ : {}),
294
+ })
295
+ return {
296
+ kind: 'queue',
297
+ jobs: jobs.map(jobRow),
298
+ branch_holders: queue.branchHolders().map(holderRow),
299
+ }
300
+ }
301
+
302
+ // cancel / retry / resolve: job_id is REQUIRED (the compiled schema
303
+ // cannot express "required only when action != list", so the tool
304
+ // enforces it loudly).
305
+ if (typeof args.job_id !== 'string' || args.job_id.length === 0) {
306
+ throw new Error(
307
+ `${TOOL_NAME}: job_id_missing — action "${args.action}" requires the merge job id (get one from action "list")`,
308
+ )
309
+ }
310
+ const command = { cancel: queue.cancel, retry: queue.retry, resolve: queue.resolve }[
311
+ args.action
312
+ ]
313
+ // Scalar snapshots around the dispatch (see releasedBranch): a
314
+ // record reference would alias the post-command record.
315
+ const stateBefore = store.jobs[args.job_id]?.state
316
+ const result = command({ jobId: args.job_id })
317
+ const stateAfter = store.jobs[args.job_id]?.state
318
+ return {
319
+ kind: 'queue_command',
320
+ action: args.action,
321
+ job_id: result.jobId,
322
+ state: result.state,
323
+ released: releasedBranch(stateBefore, stateAfter),
324
+ }
325
+ } catch (error) {
326
+ surfaceError(error)
327
+ }
328
+ },
329
+ }))
330
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * `worktree_status` tool (T07, DESIGN §5.5).
3
+ *
4
+ * Thin tool layer: parameters schema, worktree_id-or-path input resolution,
5
+ * the §5.5 status projection, and the `merge` projection from the StateStore
6
+ * job records (the service's status() does not read jobs). No git calls, no
7
+ * state writes.
8
+ *
9
+ * deps contract:
10
+ * - service (required) createWorktreeService() product;
11
+ * - store (required) the SAME StateStore instance the service was built
12
+ * with (read-only access for the merge-job projection);
13
+ * - cfg (optional) reserved for T10 wiring parity (unused today).
14
+ *
15
+ * json-safe discipline (E3): conditional spreads everywhere; `merge` is
16
+ * ALWAYS present — either a projection object or an explicit `null`
17
+ * (never an undefined-valued key).
18
+ */
19
+
20
+ import { defineTool } from '@deepseek-ai/dsh-tools'
21
+
22
+ /** Registered tool name (DESIGN §5.5). */
23
+ const TOOL_NAME = 'worktree_status'
24
+
25
+ /** A message already starting with a stable snake_case code stays verbatim. */
26
+ const CODED_MESSAGE = /^[a-z][a-z0-9_]*(\s*—|:)/
27
+
28
+ /** Coded engine/gate errors pass through; uncoded ones get the tool prefix. */
29
+ function surfaceError(error) {
30
+ const message = String(error instanceof Error ? error.message : error)
31
+ if (CODED_MESSAGE.test(message)) throw error
32
+ throw new Error(`${TOOL_NAME}: ${message}`)
33
+ }
34
+
35
+ /**
36
+ * §5.5 merge projection: the LATEST job whose worktreeId matches (createdAt
37
+ * desc, insertion-order tiebreak) — a record's mergeJobId points at its
38
+ * current job, but a re-queued task accumulates history and the caller wants
39
+ * the freshest state. No matching job → `null` (never undefined).
40
+ */
41
+ function mergeProjection(store, worktreeId) {
42
+ let best = null
43
+ let bestAt = -Infinity
44
+ let bestSeq = -1
45
+ const jobs = Object.values(store.jobs)
46
+ for (let index = 0; index < jobs.length; index += 1) {
47
+ const job = jobs[index]
48
+ if (job.worktreeId !== worktreeId) continue
49
+ const at = Number(job.createdAt) || 0
50
+ if (at > bestAt || (at === bestAt && index > bestSeq)) {
51
+ best = job
52
+ bestAt = at
53
+ bestSeq = index
54
+ }
55
+ }
56
+ if (best === null) return null
57
+ return {
58
+ job_id: best.id,
59
+ state: best.state,
60
+ ...(Array.isArray(best.conflictFiles) && best.conflictFiles.length > 0
61
+ ? { conflict_files: [...best.conflictFiles] }
62
+ : {}),
63
+ ...(best.integratedCommit !== undefined ? { integrated_commit: best.integratedCommit } : {}),
64
+ ...(best.integrationWorktree !== undefined
65
+ ? { integration_worktree: best.integrationWorktree }
66
+ : {}),
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Register the `worktree_status` tool (DESIGN §5.5).
72
+ *
73
+ * @param {Object} ctx host ctx (needs ctx.tools.register)
74
+ * @param {Object} deps see the module header for the contract
75
+ */
76
+ export function registerWorktreeStatusTool(ctx, deps = {}) {
77
+ const { service, store } = deps
78
+ if (!service || typeof service.status !== 'function') {
79
+ throw new Error(
80
+ `${TOOL_NAME}: deps.service must be a WorktreeService (createWorktreeService product)`,
81
+ )
82
+ }
83
+ if (!store || typeof store.jobs !== 'object') {
84
+ throw new Error(
85
+ `${TOOL_NAME}: deps.store must be the StateStore the service was built with (merge-job projection reads store.jobs)`,
86
+ )
87
+ }
88
+
89
+ ctx.tools.register(defineTool({
90
+ name: TOOL_NAME,
91
+ description:
92
+ 'Report the live git status of one managed worktree: its record state, current HEAD, whether the tree is dirty, the per-file change list (porcelain-mapped: added / modified / deleted / renamed), whether HEAD has moved past the recorded base commit, and the state of its merge job (merge: null while it has never been queued). '
93
+ + 'Use this before worktree_merge to review uncommitted work, and after a conflicted merge to inspect the retained scene. `vanished: true` in the result means the worktree directory disappeared (crash or manual removal).',
94
+ parameters: {
95
+ worktree_id: {
96
+ type: 'string',
97
+ description: 'Worktree id from worktree_create (alternative to path).',
98
+ },
99
+ path: {
100
+ type: 'string',
101
+ description: 'Absolute worktree path (alternative to worktree_id).',
102
+ },
103
+ },
104
+ output: {
105
+ schema: {
106
+ type: 'object',
107
+ additionalProperties: false,
108
+ properties: {
109
+ kind: { type: 'string', required: true, const: 'status' },
110
+ id: { type: 'string', required: true },
111
+ branch: { type: 'string', required: true },
112
+ path: { type: 'string', required: true },
113
+ state: { type: 'string', required: true },
114
+ head: { type: 'string' },
115
+ dirty: { type: 'boolean' },
116
+ base_commit: { type: 'string' },
117
+ ahead_of_base: { type: 'boolean' },
118
+ changes: {
119
+ type: 'array',
120
+ required: true,
121
+ items: {
122
+ type: 'object',
123
+ additionalProperties: false,
124
+ properties: {
125
+ path: { type: 'string', required: true },
126
+ status: { type: 'string', required: true },
127
+ },
128
+ },
129
+ },
130
+ merge: {
131
+ required: true,
132
+ oneOf: [
133
+ {
134
+ type: 'object',
135
+ additionalProperties: false,
136
+ properties: {
137
+ job_id: { type: 'string', required: true },
138
+ state: { type: 'string', required: true },
139
+ conflict_files: { type: 'array', items: { type: 'string' } },
140
+ integrated_commit: { type: 'string' },
141
+ integration_worktree: { type: 'string' },
142
+ },
143
+ },
144
+ { type: 'null' },
145
+ ],
146
+ },
147
+ vanished: { type: 'boolean' },
148
+ },
149
+ },
150
+ render: (_args, value) => [{
151
+ type: 'text',
152
+ text: `worktree ${value.id} (${value.branch}) state=${value.state}`
153
+ + ` dirty=${value.dirty === undefined ? 'unknown' : value.dirty}`
154
+ + ` changes=${value.changes.length}`
155
+ + ` merge=${value.merge === null ? 'none' : `${value.merge.state} (${value.merge.job_id})`}`,
156
+ }],
157
+ },
158
+ isConcurrencySafe: () => true,
159
+ async execute(args) {
160
+ try {
161
+ const idOrPath =
162
+ args.worktree_id !== undefined ? args.worktree_id : args.path
163
+ if (idOrPath === undefined || idOrPath === '') {
164
+ throw new Error(
165
+ `${TOOL_NAME}: worktree_id_or_path_missing — pass worktree_id (from worktree_create) or the absolute worktree path`,
166
+ )
167
+ }
168
+
169
+ const s = await service.status(idOrPath)
170
+ const merge = mergeProjection(store, s.id)
171
+
172
+ return {
173
+ kind: 'status',
174
+ id: s.id,
175
+ branch: s.branch,
176
+ path: s.path,
177
+ state: s.state,
178
+ ...(s.head !== undefined ? { head: s.head } : {}),
179
+ ...(s.dirty !== undefined ? { dirty: s.dirty } : {}),
180
+ ...(s.baseCommit !== undefined ? { base_commit: s.baseCommit } : {}),
181
+ ...(s.ahead_of_base !== undefined ? { ahead_of_base: s.ahead_of_base } : {}),
182
+ changes: (Array.isArray(s.changes) ? s.changes : []).map((entry) => ({
183
+ path: entry.path,
184
+ status: entry.status,
185
+ })),
186
+ merge,
187
+ ...(s.vanished !== undefined ? { vanished: s.vanished } : {}),
188
+ }
189
+ } catch (error) {
190
+ surfaceError(error)
191
+ }
192
+ },
193
+ }))
194
+ }