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.
- package/AGENTS.md +120 -0
- package/CHANGELOG.md +266 -0
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/README.zh.md +151 -0
- package/SECURITY.md +56 -0
- package/cordis.patch.yml +30 -0
- package/docs/DESIGN.md +783 -0
- package/docs/TASKS.md +164 -0
- package/lib/config.js +126 -0
- package/lib/engine-face.js +328 -0
- package/lib/git-port.js +773 -0
- package/lib/index.js +402 -0
- package/lib/merge-queue.js +832 -0
- package/lib/naming.js +107 -0
- package/lib/repo-gate.js +202 -0
- package/lib/state-store.js +512 -0
- package/lib/tools/worktree-cleanup.js +127 -0
- package/lib/tools/worktree-create.js +212 -0
- package/lib/tools/worktree-list.js +234 -0
- package/lib/tools/worktree-merge.js +396 -0
- package/lib/tools/worktree-queue.js +330 -0
- package/lib/tools/worktree-status.js +194 -0
- package/lib/worktree-service.js +673 -0
- package/package.json +55 -0
- package/scripts/link-harness-dsh-tools.sh +95 -0
- package/scripts/lint.js +142 -0
|
@@ -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
|
+
}
|