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,212 @@
1
+ /**
2
+ * `worktree_create` tool (T07, DESIGN §5.3).
3
+ *
4
+ * Thin tool layer — parameters schema, the §5.2.0 repo-gate call, and a
5
+ * projection of the WorktreeService.create record into the §5.3 return
6
+ * shape. No git calls and no state writes happen here: the engine is the
7
+ * injected `service` (lib/worktree-service.js) and the repo gate is the
8
+ * injected `resolveRepo` (or a real resolveRepoRoot binding built from the
9
+ * injected `git` port + `cfg`), exactly the deps T10's apply() assembles.
10
+ *
11
+ * deps contract:
12
+ * - service (required) createWorktreeService() product;
13
+ * - resolveRepo (optional) async ({ repoArg, sessionCwd }) =>
14
+ * { canonical, repoKey } — a T10-prebound §5.2.0 gate;
15
+ * - git + cfg (fallback) when resolveRepo is absent, the REAL gate is
16
+ * bound here from the injected GitPort plus
17
+ * cfg.workspacePaths / cfg.allowedRoots;
18
+ * - sessionCwd (optional) fallback when the calling exec carries no
19
+ * session header cwd;
20
+ * - sessionId (optional) fallback when exec.agent.session.id is absent.
21
+ *
22
+ * json-safe discipline (E3): the return object is built with conditional
23
+ * spreads only — no key ever carries an `undefined` value.
24
+ *
25
+ * Error policy: engine/gate errors already start with a machine-matchable
26
+ * code (`branch_exists — …`, `repo_not_registered: …`) and are re-thrown
27
+ * untouched; anything uncoded gets a `worktree_create:` prefix so the
28
+ * surfaced message always names the tool.
29
+ */
30
+
31
+ import { defineTool } from '@deepseek-ai/dsh-tools'
32
+ import { resolveRepoRoot } from '../repo-gate.js'
33
+
34
+ /** Registered tool name (DESIGN §5.3). */
35
+ const TOOL_NAME = 'worktree_create'
36
+
37
+ /** A message already starting with a stable snake_case code stays verbatim. */
38
+ const CODED_MESSAGE = /^[a-z][a-z0-9_]*(\s*—|:)/
39
+
40
+ /**
41
+ * Error surfacing (DESIGN §5.3 error family): coded engine/gate errors pass
42
+ * through unchanged; uncoded ones (e.g. a TypeError from a bad wiring) get
43
+ * the tool-name prefix.
44
+ */
45
+ function surfaceError(error) {
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
+ /**
52
+ * Bind the §5.2.0 repo gate. Prefer a T10-injected `resolveRepo`; otherwise
53
+ * build the real `resolveRepoRoot` binding from the injected git port and
54
+ * cfg. This module never spawns git itself — the gate is the only seam.
55
+ */
56
+ function makeRepoResolver(deps) {
57
+ if (typeof deps.resolveRepo === 'function') return deps.resolveRepo
58
+ const { git, cfg = {} } = deps
59
+ if (!git || typeof git.isGitRepo !== 'function') {
60
+ throw new Error(
61
+ `${TOOL_NAME}: deps must carry either resolveRepo(opts) or a git port with isGitRepo(cwd) so the repo gate can be bound`,
62
+ )
63
+ }
64
+ return (opts) =>
65
+ resolveRepoRoot({
66
+ repoArg: opts.repoArg,
67
+ sessionCwd: opts.sessionCwd,
68
+ workspacePaths: cfg.workspacePaths,
69
+ allowedRoots: cfg.allowedRoots,
70
+ git,
71
+ })
72
+ }
73
+
74
+ /** §5.3 composition example, appended verbatim to the description. */
75
+ const COMPOSITION_EXAMPLE =
76
+ 'Composition example: '
77
+ + '1. worktree_create(task: "api-types") → path P1; '
78
+ + '2. worktree_create(task: "docs-refresh") → path P2; '
79
+ + '3. subagent(prompt: "…implement X…", cwd: P1) and subagent(prompt: "…update docs…", cwd: P2) in parallel — each task writes in its own worktree, no overlap; '
80
+ + '4. worktree_merge(worktree_id: id1) → succeeded; '
81
+ + '5. worktree_merge(worktree_id: id2) → conflicted (conflict list + retained scene); '
82
+ + '6. worktree_queue(action: "list") → observe, resolve out-of-band in the retained worktree; '
83
+ + '7. worktree_queue(action: "resolve", job_id: …) → release the integration branch; '
84
+ + '8. worktree_queue(action: "retry", job_id: …) → re-queue (optional alternative); '
85
+ + '9. worktree_cleanup(worktree_id: id1) → remove the merged worktree and its branch.'
86
+
87
+ /**
88
+ * Register the `worktree_create` tool (DESIGN §5.3).
89
+ *
90
+ * @param {Object} ctx host ctx (needs ctx.tools.register)
91
+ * @param {Object} deps see the module header for the contract
92
+ */
93
+ export function registerWorktreeCreateTool(ctx, deps = {}) {
94
+ const { service } = deps
95
+ if (!service || typeof service.create !== 'function') {
96
+ throw new Error(
97
+ `${TOOL_NAME}: deps.service must be a WorktreeService (createWorktreeService product)`,
98
+ )
99
+ }
100
+ const resolveRepo = makeRepoResolver(deps)
101
+
102
+ ctx.tools.register(defineTool({
103
+ name: TOOL_NAME,
104
+ description:
105
+ 'Create an isolated git worktree for ONE parallel task: a dedicated branch dsh-wt/<session-short>/<task> checked out at a private path under the plugin worktree root, so concurrently dispatched subagents never write into the same working directory. '
106
+ + 'The base ref is resolved to a concrete commit oid at create time (never a mutable HEAD), and the returned path is where the task must do all of its work. '
107
+ + COMPOSITION_EXAMPLE,
108
+ parameters: {
109
+ task: {
110
+ type: 'string',
111
+ required: true,
112
+ description:
113
+ 'Short task slug identifying the parallel task this worktree isolates (e.g. "refactor-auth", "fix-231"). Sanitized into the branch name dsh-wt/<session>/<task>.',
114
+ },
115
+ repo_root: {
116
+ type: 'string',
117
+ description:
118
+ 'Absolute git repository root. Defaults to the current session working directory. Must resolve inside a registered workspace, the session cwd subtree, or a configured allowedRoot.',
119
+ },
120
+ base_ref: {
121
+ type: 'string',
122
+ description:
123
+ 'Git ref to branch from (branch / tag / commit oid / HEAD). Defaults to HEAD of the repository. Never falls back to a mutable HEAD when the queue expects a fixed base.',
124
+ },
125
+ integration_branch: {
126
+ type: 'string',
127
+ description:
128
+ 'Branch that worktree_merge will integrate into (default: dsh-wt/integration/<session>, bootstrapped from repo HEAD on first merge). Must not be a branch already checked out in another worktree of this repo.',
129
+ },
130
+ note: {
131
+ type: 'string',
132
+ description: 'Optional free-text note stored on the worktree record.',
133
+ },
134
+ },
135
+ output: {
136
+ schema: {
137
+ type: 'object',
138
+ additionalProperties: false,
139
+ properties: {
140
+ kind: { type: 'string', required: true, const: 'worktree' },
141
+ id: { type: 'string', required: true },
142
+ repo_root: { type: 'string', required: true },
143
+ branch: { type: 'string', required: true },
144
+ path: { type: 'string', required: true },
145
+ base_commit: { type: 'string', required: true },
146
+ integration_branch: { type: 'string', required: true },
147
+ delegate_hint: { type: 'string', required: true },
148
+ note: { type: 'string' },
149
+ },
150
+ },
151
+ render: (_args, value) => [{
152
+ type: 'text',
153
+ text: `created worktree ${value.id} (branch ${value.branch}) at ${value.path}; delegate via the subagent tool with cwd: ${value.path}`,
154
+ }],
155
+ },
156
+ // State write with a check-then-act section (the maxWorktrees gate), so
157
+ // sibling calls must not overlap (undeclared would mean the same — this
158
+ // is just explicit; DESIGN §7.2 reserves the writeup for worktree_merge).
159
+ isConcurrencySafe: () => false,
160
+ async execute(args, exec) {
161
+ try {
162
+ const session = exec && exec.agent ? exec.agent.session : undefined
163
+ const sessionCwd =
164
+ (session && session.header && session.header.cwd) || deps.sessionCwd
165
+
166
+ // §5.2.0 gate FIRST (its repo_unresolved / repo_unknown /
167
+ // not_a_git_repo / repo_not_registered codes are the tool's front
168
+ // door), then the session-id requirement the branch name derives
169
+ // from.
170
+ const { canonical, repoKey } = await resolveRepo({
171
+ repoArg: args.repo_root,
172
+ ...(sessionCwd !== undefined ? { sessionCwd } : {}),
173
+ })
174
+
175
+ const sessionId = (session && session.id) || deps.sessionId
176
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
177
+ throw new Error(
178
+ `${TOOL_NAME}: session_unresolved — no calling agent session id (exec.agent.session.id) and none injected; the task branch name derives from it`,
179
+ )
180
+ }
181
+
182
+ const record = await service.create({
183
+ task: args.task,
184
+ repoRoot: canonical,
185
+ repoKey,
186
+ ...(args.base_ref !== undefined ? { baseRef: args.base_ref } : {}),
187
+ ...(args.integration_branch !== undefined
188
+ ? { integrationBranch: args.integration_branch }
189
+ : {}),
190
+ ...(args.note !== undefined ? { note: args.note } : {}),
191
+ sessionId,
192
+ })
193
+
194
+ return {
195
+ kind: 'worktree',
196
+ id: record.id,
197
+ repo_root: record.repoRoot,
198
+ branch: record.branch,
199
+ path: record.path,
200
+ base_commit: record.baseCommit,
201
+ integration_branch: record.integrationBranch,
202
+ delegate_hint:
203
+ `Delegate parallel work here via the subagent tool with cwd: ${record.path} `
204
+ + '(each parallel task gets its own worktree; integrate with worktree_merge).',
205
+ ...(typeof record.note === 'string' ? { note: record.note } : {}),
206
+ }
207
+ } catch (error) {
208
+ surfaceError(error)
209
+ }
210
+ },
211
+ }))
212
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * `worktree_list` tool (T07, DESIGN §5.4).
3
+ *
4
+ * Thin tool layer: parameters schema, the §5.2.0 repo-gate call (repo_root
5
+ * is resolved + admitted so the filter is a canonical path), the §5.4
6
+ * worktree/queue projections, and the `include_integration` extension that
7
+ * surfaces conflict-retained integration worktrees. No git calls, no state
8
+ * writes — `service.list` (engine) and the injected `store` (read-only
9
+ * projections the service does not expose) are the only collaborators.
10
+ *
11
+ * deps contract:
12
+ * - service (required) createWorktreeService() product;
13
+ * - store (optional, needed for include_integration) the SAME
14
+ * StateStore instance the service was built with (read-only
15
+ * access for the retained-worktree projection);
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; a canonical
23
+ * repo_root key only appears when the caller supplied repo_root.
24
+ */
25
+
26
+ import { defineTool } from '@deepseek-ai/dsh-tools'
27
+ import { resolveRepoRoot } from '../repo-gate.js'
28
+
29
+ /** Registered tool name (DESIGN §5.4). */
30
+ const TOOL_NAME = 'worktree_list'
31
+
32
+ /** A message already starting with a stable snake_case code stays verbatim. */
33
+ const CODED_MESSAGE = /^[a-z][a-z0-9_]*(\s*—|:)/
34
+
35
+ /** Coded engine/gate errors pass through; uncoded ones get the tool prefix. */
36
+ function surfaceError(error) {
37
+ const message = String(error instanceof Error ? error.message : error)
38
+ if (CODED_MESSAGE.test(message)) throw error
39
+ throw new Error(`${TOOL_NAME}: ${message}`)
40
+ }
41
+
42
+ /** Bind the §5.2.0 gate: injected prebound resolver, else the real gate. */
43
+ function makeRepoResolver(deps) {
44
+ if (typeof deps.resolveRepo === 'function') return deps.resolveRepo
45
+ const { git, cfg = {} } = deps
46
+ if (!git || typeof git.isGitRepo !== 'function') {
47
+ throw new Error(
48
+ `${TOOL_NAME}: deps must carry either resolveRepo(opts) or a git port with isGitRepo(cwd) so the repo gate can be bound`,
49
+ )
50
+ }
51
+ return (opts) =>
52
+ resolveRepoRoot({
53
+ repoArg: opts.repoArg,
54
+ sessionCwd: opts.sessionCwd,
55
+ workspacePaths: cfg.workspacePaths,
56
+ allowedRoots: cfg.allowedRoots,
57
+ git,
58
+ })
59
+ }
60
+
61
+ /**
62
+ * Conflict-retained integration worktrees, deduped, ascending by path:
63
+ * every conflicted job's recorded integrationWorktree (a `.integration/`
64
+ * path retained as the live conflict scene; DESIGN §6.3 step 10).
65
+ */
66
+ function retainedIntegrationWorktrees(store) {
67
+ const paths = new Set()
68
+ for (const job of Object.values(store.jobs)) {
69
+ if (job.state !== 'conflicted') continue
70
+ if (typeof job.integrationWorktree === 'string' && job.integrationWorktree.length > 0) {
71
+ paths.add(job.integrationWorktree)
72
+ }
73
+ }
74
+ return [...paths].sort()
75
+ }
76
+
77
+ /**
78
+ * Register the `worktree_list` tool (DESIGN §5.4).
79
+ *
80
+ * @param {Object} ctx host ctx (needs ctx.tools.register)
81
+ * @param {Object} deps see the module header for the contract
82
+ */
83
+ export function registerWorktreeListTool(ctx, deps = {}) {
84
+ const { service } = deps
85
+ if (!service || typeof service.list !== 'function') {
86
+ throw new Error(
87
+ `${TOOL_NAME}: deps.service must be a WorktreeService (createWorktreeService product)`,
88
+ )
89
+ }
90
+ const resolveRepo = makeRepoResolver(deps)
91
+
92
+ ctx.tools.register(defineTool({
93
+ name: TOOL_NAME,
94
+ description:
95
+ 'List the git worktrees managed by this plugin (optionally filtered by repository) with each record\'s state and an `orphaned` flag when the path no longer appears in `git worktree list` (crash / manual removal), plus a merge-queue summary. '
96
+ + 'Use this after worktree_create to review the fleet, and before worktree_cleanup to find records worth pruning. Set include_integration true to also list temporary integration worktrees retained from conflicts (their scenes are out-of-band resolvable; see worktree_queue).',
97
+ parameters: {
98
+ repo_root: {
99
+ type: 'string',
100
+ description:
101
+ 'Filter by repository (defaults: all repos known to the state store).',
102
+ },
103
+ include_integration: {
104
+ type: 'boolean',
105
+ description:
106
+ 'Also list temporary integration worktrees retained from conflicts (default false).',
107
+ },
108
+ },
109
+ output: {
110
+ schema: {
111
+ type: 'object',
112
+ additionalProperties: false,
113
+ properties: {
114
+ kind: { type: 'string', required: true, const: 'list' },
115
+ repo_root: { type: 'string' },
116
+ worktrees: {
117
+ type: 'array',
118
+ required: true,
119
+ items: {
120
+ type: 'object',
121
+ additionalProperties: true,
122
+ properties: {
123
+ id: { type: 'string', required: true },
124
+ task: { type: 'string', required: true },
125
+ branch: { type: 'string', required: true },
126
+ path: { type: 'string', required: true },
127
+ state: { type: 'string', required: true },
128
+ head_commit: { type: 'string' },
129
+ dirty: { type: 'boolean' },
130
+ merge_state: { type: 'string' },
131
+ orphaned: { type: 'boolean' },
132
+ },
133
+ },
134
+ },
135
+ retained_integration_worktrees: {
136
+ type: 'array',
137
+ items: { type: 'string' },
138
+ },
139
+ queue_summary: {
140
+ type: 'object',
141
+ required: true,
142
+ additionalProperties: false,
143
+ properties: {
144
+ active_jobs: {
145
+ type: 'array',
146
+ required: true,
147
+ items: {
148
+ type: 'object',
149
+ additionalProperties: false,
150
+ properties: {
151
+ repo_root: {
152
+ required: true,
153
+ oneOf: [{ type: 'string' }, { type: 'null' }],
154
+ },
155
+ integration_branch: { type: 'string', required: true },
156
+ job_id: { type: 'string', required: true },
157
+ state: { type: 'string', required: true },
158
+ },
159
+ },
160
+ },
161
+ queued_count: { type: 'integer', required: true },
162
+ },
163
+ },
164
+ },
165
+ },
166
+ render: (_args, value) => [{
167
+ type: 'text',
168
+ text: `${value.worktrees.length} worktree(s)`
169
+ + (value.retained_integration_worktrees
170
+ ? `; ${value.retained_integration_worktrees.length} retained integration worktree(s)`
171
+ : '')
172
+ + `; queue: ${value.queue_summary.queued_count} queued`,
173
+ }],
174
+ },
175
+ isConcurrencySafe: () => true,
176
+ async execute(args, exec) {
177
+ try {
178
+ let canonical
179
+ if (args.repo_root !== undefined) {
180
+ const session = exec && exec.agent ? exec.agent.session : undefined
181
+ const sessionCwd =
182
+ (session && session.header && session.header.cwd) || deps.sessionCwd
183
+ canonical = (await resolveRepo({
184
+ repoArg: args.repo_root,
185
+ ...(sessionCwd !== undefined ? { sessionCwd } : {}),
186
+ })).canonical
187
+ }
188
+
189
+ const { worktrees, queueSummary } = await service.list(
190
+ canonical !== undefined ? { repoRoot: canonical } : {},
191
+ )
192
+
193
+ let retained
194
+ if (args.include_integration === true) {
195
+ const store = deps.store
196
+ if (!store || typeof store.jobs !== 'object') {
197
+ throw new Error(
198
+ `${TOOL_NAME}: deps.store (the StateStore the service was built with) is required for include_integration`,
199
+ )
200
+ }
201
+ retained = retainedIntegrationWorktrees(store)
202
+ }
203
+
204
+ return {
205
+ kind: 'list',
206
+ ...(canonical !== undefined ? { repo_root: canonical } : {}),
207
+ worktrees: worktrees.map((record) => ({
208
+ id: record.id,
209
+ task: record.task,
210
+ branch: record.branch,
211
+ path: record.path,
212
+ state: record.state,
213
+ ...(record.headCommit !== undefined ? { head_commit: record.headCommit } : {}),
214
+ ...(record.dirty !== undefined ? { dirty: record.dirty } : {}),
215
+ ...(record.mergeJobId !== undefined ? { merge_state: record.mergeJobId } : {}),
216
+ ...(record.orphaned !== undefined ? { orphaned: record.orphaned } : {}),
217
+ })),
218
+ ...(retained !== undefined ? { retained_integration_worktrees: retained } : {}),
219
+ queue_summary: {
220
+ active_jobs: queueSummary.activeJobs.map((job) => ({
221
+ repo_root: job.repoRoot ?? null,
222
+ integration_branch: job.integrationBranch,
223
+ job_id: job.jobId,
224
+ state: job.state,
225
+ })),
226
+ queued_count: queueSummary.queuedCount,
227
+ },
228
+ }
229
+ } catch (error) {
230
+ surfaceError(error)
231
+ }
232
+ },
233
+ }))
234
+ }