@sven1103/opencode-worktree-workflow 0.6.3 → 1.0.0-alpha.1

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/src/index.js CHANGED
@@ -1,637 +1,351 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { realpathSync } from "node:fs";
1
3
  import fs from "node:fs/promises";
2
4
  import path from "node:path";
3
5
 
4
- import { parse } from "jsonc-parser";
5
- import { tool } from "@opencode-ai/plugin";
6
-
7
- const DEFAULTS = {
8
- branchPrefix: "wt/",
9
- remote: "origin",
10
- baseBranch: null,
11
- worktreeRoot: ".worktrees/$REPO",
12
- cleanupMode: "preview",
13
- protectedBranches: [],
14
- };
15
-
16
- const RESULT_SCHEMA_VERSION = "1.0.0";
17
-
18
- async function pathExists(targetPath) {
19
- try {
20
- await fs.access(targetPath);
21
- return true;
22
- } catch {
23
- return false;
24
- }
25
- }
26
-
27
- function isMissingGitRepositoryError(message) {
28
- return /not a git repository/i.test(message);
29
- }
30
-
31
- function isMissingRemoteError(message, remote) {
32
- return new RegExp(`No such remote:?\s+${remote}|does not appear to be a git repository|Could not read from remote repository`, "i").test(message);
33
- }
34
-
35
- async function readJsonFile(filePath) {
36
- if (!(await pathExists(filePath))) {
37
- return null;
38
- }
39
-
40
- const source = await fs.readFile(filePath, "utf8");
41
- const data = parse(source);
42
- return data && typeof data === "object" ? data : null;
43
- }
44
-
45
- function normalizeBranchPrefix(prefix) {
46
- if (!prefix) {
47
- return "";
48
- }
49
-
50
- return prefix.endsWith("/") ? prefix : `${prefix}/`;
51
- }
52
-
53
- function slugifyTitle(title) {
54
- return title
55
- .normalize("NFKD")
56
- .replace(/[\u0300-\u036f]/g, "")
57
- .toLowerCase()
58
- .replace(/[^a-z0-9]+/g, "-")
59
- .replace(/^-+|-+$/g, "")
60
- .replace(/-{2,}/g, "-");
61
- }
62
-
63
- function formatRootTemplate(template, repoRoot) {
64
- const repoName = path.basename(repoRoot);
65
- return template
66
- .replaceAll("$REPO", repoName)
67
- .replaceAll("$ROOT", repoRoot)
68
- .replaceAll("$ROOT_PARENT", path.dirname(repoRoot));
69
- }
70
-
71
- function parseShortBranch(branchRef) {
72
- const prefix = "refs/heads/";
73
- return branchRef.startsWith(prefix) ? branchRef.slice(prefix.length) : branchRef;
74
- }
75
-
76
- function parseWorktreeList(output) {
77
- const entries = [];
78
- let current = null;
79
-
80
- for (const rawLine of output.split("\n")) {
81
- const line = rawLine.trim();
82
- if (!line) {
83
- if (current) {
84
- entries.push(current);
85
- current = null;
86
- }
87
- continue;
88
- }
89
-
90
- if (line.startsWith("worktree ")) {
91
- if (current) {
92
- entries.push(current);
93
- }
94
- current = { path: line.slice("worktree ".length) };
95
- continue;
96
- }
97
-
98
- if (!current) {
99
- continue;
100
- }
101
-
102
- if (line.startsWith("HEAD ")) {
103
- current.head = line.slice("HEAD ".length);
104
- continue;
105
- }
106
-
107
- if (line.startsWith("branch ")) {
108
- current.branchRef = line.slice("branch ".length);
109
- current.branch = parseShortBranch(current.branchRef);
110
- continue;
111
- }
112
-
113
- if (line === "detached") {
114
- current.detached = true;
115
- }
116
- }
117
-
118
- if (current) {
119
- entries.push(current);
120
- }
121
-
122
- return entries;
123
- }
124
-
125
- function shellQuote(value) {
126
- return `'${String(value).replaceAll("'", `'"'"'`)}'`;
127
- }
128
-
129
- function formatWorktreeSummary(item) {
130
- return `${item.branch || "(detached)"} -> ${item.path}${item.head ? ` (${item.head.slice(0, 12)})` : ""}`;
131
- }
132
-
133
- function formatCopyPasteCommands(item) {
134
- const selector = item.branch || item.path;
135
- const branchFlag = item.status === "safe" ? "-d" : "-D";
6
+ import { createWorktreeWorkflowService, __internalService, isMissingGitRepositoryError, isMissingRemoteError } from "./core/worktree-service.js";
7
+ import {
8
+ buildWorkspaceContext,
9
+ buildWtCleanCommandPromptParts,
10
+ buildWtNewCommandPromptParts,
11
+ classifyToolExecution,
12
+ decideContinuity,
13
+ deriveTaskTitle,
14
+ deriveWorkspaceRole,
15
+ extractHandoffArtifactPath,
16
+ getToolRewritePolicy,
17
+ hasOpaqueRepoRootAbsoluteReference,
18
+ inferTaskLifecycleTransition,
19
+ rewriteRepoScopedPathIntoWorktree,
20
+ } from "./core/task-binding.js";
21
+ import { createRuntimeStateStore } from "./runtime/state-store.js";
136
22
 
137
- return [
138
- ` copy: /wt-clean apply ${selector}`,
139
- ` git: git worktree remove ${shellQuote(item.path)} && git branch ${branchFlag} ${shellQuote(item.branch)}`,
140
- ];
141
- }
142
-
143
- function formatPreviewSection(title, items, { includeCommands = false } = {}) {
144
- if (items.length === 0) {
145
- return [title, "- none"];
146
- }
147
-
148
- const lines = [title];
149
-
150
- for (const item of items) {
151
- lines.push(`- ${formatWorktreeSummary(item)}: ${item.reason}`);
152
-
153
- if (includeCommands && item.branch) {
154
- lines.push(...formatCopyPasteCommands(item));
155
- }
156
- }
157
-
158
- return lines;
23
+ function publishStructuredResult(context, result) {
24
+ context.metadata({ metadata: { result } });
25
+ return result.message || JSON.stringify(result, null, 2);
159
26
  }
160
27
 
161
- function formatPreview(grouped, defaultBranch) {
162
- return [
163
- `Worktrees connected to this repository against ${defaultBranch}:`,
164
- "",
165
- ...formatPreviewSection("Safe to clean automatically:", grouped.safe, { includeCommands: true }),
166
- "",
167
- ...formatPreviewSection("Needs review before cleanup:", grouped.review, { includeCommands: true }),
168
- "",
169
- ...formatPreviewSection("Not cleanable here:", grouped.blocked),
170
- "",
171
- "Run `/wt-clean apply` to remove only the safe group.",
172
- "Run `/wt-clean apply <branch-or-path>` to also remove selected review items.",
173
- ].join("\n");
174
- }
28
+ const WORKSPACE_SYSTEM_CONTEXT_MARKER = "Active workspace context:";
175
29
 
176
- function formatPrepareSummary(result) {
30
+ function formatWorkspaceSystemContext(workspaceContext) {
177
31
  return [
178
- `Created worktree for "${result.title}".`,
179
- `- branch: ${result.branch}`,
180
- `- worktree: ${result.worktree_path}`,
181
- `- default branch: ${result.default_branch}`,
182
- `- base branch: ${result.base_branch}`,
183
- `- base ref: ${result.base_ref}`,
184
- `- base commit: ${result.base_commit}`,
32
+ WORKSPACE_SYSTEM_CONTEXT_MARKER,
33
+ `- task_id: ${workspaceContext.task_id}`,
34
+ `- task_title: ${workspaceContext.task_title}`,
35
+ `- worktree_path: ${workspaceContext.worktree_path}`,
36
+ "Policy: operate within this worktree for repository actions.",
185
37
  ].join("\n");
186
38
  }
187
39
 
188
- function formatCleanupSummary(defaultBranch, removed, failed, requestedSelectors) {
189
- const lines = [`Cleaned worktrees relative to ${defaultBranch}:`];
190
-
191
- if (removed.length === 0) {
192
- lines.push("- none removed");
193
- } else {
194
- for (const item of removed) {
195
- const modeLabel = item.selected ? "selected" : "auto";
196
- lines.push(`- removed (${modeLabel}) ${item.branch} -> ${item.path}`);
197
- }
198
- }
199
-
200
- if (requestedSelectors.length > 0) {
201
- lines.push("");
202
- lines.push("Requested selectors:");
203
- for (const selector of requestedSelectors) {
204
- lines.push(`- ${selector}`);
205
- }
206
- }
207
-
208
- if (failed.length > 0) {
209
- lines.push("");
210
- lines.push("Cleanup skipped for:");
211
- for (const item of failed) {
212
- lines.push(`- ${item.branch || item.selector} -> ${item.path || "(no path)"}: ${item.reason}`);
40
+ function createGitRunner($, directory) {
41
+ return async function git(args, options = {}) {
42
+ const cwd = options.cwd ?? directory;
43
+ const command = `git ${args.map((arg) => $.escape(String(arg))).join(" ")}`;
44
+ const result = await $`${{ raw: command }}`.cwd(cwd).quiet().nothrow();
45
+ const stdout = result.text().trim();
46
+ const stderr = result.stderr.toString("utf8").trim();
47
+ if (!options.allowFailure && result.exitCode !== 0) {
48
+ throw new Error(stderr || stdout || `Git command failed: ${command}`);
213
49
  }
214
- }
215
-
216
- return lines.join("\n");
217
- }
218
-
219
- function toStructuredCleanupItem(item) {
220
- return {
221
- branch: item.branch ?? null,
222
- worktree_path: item.path ?? item.worktree_path ?? null,
223
- head: item.head ?? null,
224
- status: item.status ?? null,
225
- reason: item.reason ?? null,
226
- detached: Boolean(item.detached),
227
- selectable: typeof item.selectable === "boolean" ? item.selectable : null,
50
+ return { stdout, stderr, exitCode: result.exitCode };
228
51
  };
229
52
  }
230
53
 
231
- function toStructuredCleanupFailure(item) {
232
- return {
233
- selector: item.selector ?? null,
234
- branch: item.branch ?? null,
235
- worktree_path: item.path ?? item.worktree_path ?? null,
236
- head: item.head ?? null,
237
- status: item.status ?? null,
238
- reason: item.reason ?? null,
239
- detached: Boolean(item.detached),
240
- selectable: typeof item.selectable === "boolean" ? item.selectable : null,
241
- };
242
- }
243
-
244
- function buildPrepareResult({ title, branch, worktreePath, defaultBranch, baseBranch, baseRef, baseCommit }) {
245
- const result = {
246
- schema_version: RESULT_SCHEMA_VERSION,
247
- ok: true,
248
- title,
249
- branch,
250
- worktree_path: worktreePath,
251
- default_branch: defaultBranch,
252
- base_branch: baseBranch,
253
- base_ref: baseRef,
254
- base_commit: baseCommit,
255
- created: true,
256
- };
257
-
258
- return {
259
- ...result,
260
- message: formatPrepareSummary(result),
261
- };
54
+ function resolveSafeHandoffPath({ rawPath, repoRoot, directory }) {
55
+ if (typeof rawPath !== "string" || !rawPath.trim()) return null;
56
+ const candidate = path.resolve(path.isAbsolute(rawPath) ? rawPath : path.join(directory, rawPath));
57
+ const normalized = candidate.split(path.sep).join("/");
58
+ const safePattern = /\/\.opencode\/sessions\/[A-Za-z0-9_-]+\/handoffs\/[A-Za-z0-9._-]+\.json$/;
59
+ if (!safePattern.test(normalized)) return null;
60
+ const base = path.resolve(repoRoot);
61
+ const canonicalBase = (() => {
62
+ try {
63
+ return realpathSync(base);
64
+ } catch {
65
+ return base;
66
+ }
67
+ })();
68
+ const canonicalCandidate = (() => {
69
+ try {
70
+ return realpathSync(candidate);
71
+ } catch {
72
+ return candidate;
73
+ }
74
+ })();
75
+ const relative = path.relative(canonicalBase, canonicalCandidate);
76
+ const insideRepo = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
77
+ if (!insideRepo) return null;
78
+ return canonicalCandidate;
262
79
  }
263
80
 
264
- function publishStructuredResult(context, result) {
265
- context.metadata({
266
- metadata: {
267
- result,
81
+ async function enrichHandoffArtifact(filePath, workspaceContext) {
82
+ const source = await fs.readFile(filePath, "utf8");
83
+ const parsed = JSON.parse(source);
84
+ const payload = parsed && typeof parsed === "object" && typeof parsed.payload === "object" ? parsed.payload : {};
85
+ const next = {
86
+ ...parsed,
87
+ payload: {
88
+ ...payload,
89
+ ...workspaceContext,
268
90
  },
269
- });
270
-
271
- return result.message || JSON.stringify(result, null, 2);
272
- }
273
-
274
- function buildCleanupPreviewResult({ defaultBranch, baseBranch, baseRef, grouped }) {
275
- const structuredGroups = {
276
- safe: grouped.safe.map(toStructuredCleanupItem),
277
- review: grouped.review.map(toStructuredCleanupItem),
278
- blocked: grouped.blocked.map(toStructuredCleanupItem),
279
- };
280
-
281
- return {
282
- schema_version: RESULT_SCHEMA_VERSION,
283
- ok: true,
284
- mode: "preview",
285
- default_branch: defaultBranch,
286
- base_branch: baseBranch,
287
- base_ref: baseRef,
288
- groups: structuredGroups,
289
- message: formatPreview(grouped, baseBranch),
290
91
  };
92
+ await fs.writeFile(filePath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
291
93
  }
292
94
 
293
- function buildCleanupApplyResult({ defaultBranch, baseBranch, baseRef, removed, failed, requestedSelectors }) {
294
- return {
295
- schema_version: RESULT_SCHEMA_VERSION,
296
- ok: true,
297
- mode: "apply",
298
- default_branch: defaultBranch,
299
- base_branch: baseBranch,
300
- base_ref: baseRef,
301
- requested_selectors: requestedSelectors,
302
- removed: removed.map((item) => ({
303
- ...toStructuredCleanupItem(item),
304
- selected: Boolean(item.selected),
305
- })),
306
- failed: failed.map(toStructuredCleanupFailure),
307
- message: formatCleanupSummary(baseBranch, removed, failed, requestedSelectors),
308
- };
309
- }
310
-
311
- function splitCleanupToken(value) {
312
- if (typeof value !== "string") {
313
- return [];
95
+ async function readJsonIfExists(filePath) {
96
+ try {
97
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
98
+ } catch (error) {
99
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return null;
100
+ throw error;
314
101
  }
315
-
316
- return value
317
- .trim()
318
- .split(/\s+/)
319
- .filter(Boolean);
320
102
  }
321
103
 
322
- function parseCleanupRawArguments(raw) {
323
- const tokens = splitCleanupToken(raw);
324
-
325
- if (tokens[0] === "apply") {
326
- return {
327
- mode: "apply",
328
- selectors: tokens.slice(1),
329
- };
330
- }
331
-
332
- if (tokens[0] === "preview") {
333
- return {
334
- mode: "preview",
335
- selectors: tokens.slice(1),
336
- };
337
- }
338
-
339
- return {
340
- mode: null,
341
- selectors: tokens,
342
- };
104
+ function inferLifecycleSignalFromResultArtifact(resultArtifact) {
105
+ const status = typeof resultArtifact?.status === "string" ? resultArtifact.status : "";
106
+ const resultType = typeof resultArtifact?.result_type === "string" ? resultArtifact.result_type : "";
107
+ if (status === "blocked" || resultType === "blocked") return "block";
108
+ if (status === "done" || resultType === "implementation_summary") return "complete";
109
+ return null;
343
110
  }
344
111
 
345
- function normalizeCleanupArgs(args, config) {
346
- const selectors = Array.isArray(args.selectors) ? [...args.selectors] : [];
347
- const normalizedSelectors = [];
348
- const rawArgs = parseCleanupRawArguments(args.raw);
349
- let explicitMode = rawArgs.mode;
350
-
351
- if (rawArgs.selectors.length > 0) {
352
- selectors.unshift(...rawArgs.selectors);
353
- }
354
-
355
- if (typeof args.mode === "string" && args.mode.trim()) {
356
- const modeValue = args.mode.trim();
357
- if (modeValue === "apply" || modeValue === "preview") {
358
- explicitMode = modeValue;
359
- } else {
360
- selectors.unshift(...splitCleanupToken(modeValue));
361
- }
362
- }
363
-
364
- for (const selector of selectors) {
365
- if (typeof selector !== "string") {
366
- continue;
367
- }
368
-
369
- if (selector.includes(" ")) {
370
- normalizedSelectors.push(...splitCleanupToken(selector));
371
- continue;
372
- }
373
-
374
- normalizedSelectors.push(selector);
112
+ async function resolveTaskResultLifecycleSignal(handoffPath) {
113
+ const handoff = await readJsonIfExists(handoffPath);
114
+ if (!handoff || typeof handoff !== "object") return null;
115
+ const handoffID = typeof handoff.handoff_id === "string" ? handoff.handoff_id : null;
116
+ if (!handoffID) return null;
117
+ const sessionDir = path.dirname(path.dirname(handoffPath));
118
+ const resultsDir = path.join(sessionDir, "results");
119
+ let resultFiles = [];
120
+ try {
121
+ resultFiles = await fs.readdir(resultsDir);
122
+ } catch (error) {
123
+ if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) throw error;
375
124
  }
376
-
377
- const inlineApply = normalizedSelectors[0] === "apply";
378
-
379
- if (inlineApply) {
380
- normalizedSelectors.shift();
125
+ const candidates = [];
126
+ for (const fileName of resultFiles) {
127
+ if (!fileName.endsWith(".json")) continue;
128
+ const filePath = path.join(resultsDir, fileName);
129
+ const artifact = await readJsonIfExists(filePath);
130
+ if (!artifact || artifact.source_handoff_id !== handoffID) continue;
131
+ const signal = inferLifecycleSignalFromResultArtifact(artifact);
132
+ if (!signal) continue;
133
+ candidates.push({ filePath, artifact, signal });
381
134
  }
382
-
383
- const mode = explicitMode === "apply" || inlineApply ? "apply" : explicitMode || config.cleanupMode;
384
-
135
+ if (candidates.length === 0) return null;
136
+ candidates.sort((a, b) => {
137
+ const aCreated = typeof a.artifact?.created_at === "string" ? a.artifact.created_at : "";
138
+ const bCreated = typeof b.artifact?.created_at === "string" ? b.artifact.created_at : "";
139
+ return aCreated.localeCompare(bCreated);
140
+ });
141
+ const latest = candidates[candidates.length - 1];
385
142
  return {
386
- mode,
387
- selectors: normalizedSelectors,
143
+ signal: latest.signal,
144
+ handoff,
145
+ result_artifact_path: latest.filePath,
388
146
  };
389
147
  }
390
148
 
391
149
  export const __internal = {
392
- RESULT_SCHEMA_VERSION,
393
- buildCleanupApplyResult,
394
- buildCleanupPreviewResult,
395
- buildPrepareResult,
396
- classifyEntry,
150
+ RESULT_SCHEMA_VERSION: __internalService.RESULT_SCHEMA_VERSION,
151
+ ...__internalService,
397
152
  isMissingGitRepositoryError,
398
153
  isMissingRemoteError,
399
- parseCleanupRawArguments,
400
- normalizeCleanupArgs,
401
- toStructuredCleanupFailure,
402
- toStructuredCleanupItem,
154
+ decideContinuity,
155
+ classifyToolExecution,
156
+ deriveTaskTitle,
157
+ deriveWorkspaceRole,
158
+ extractHandoffArtifactPath,
159
+ buildWorkspaceContext,
160
+ inferTaskLifecycleTransition,
161
+ getToolRewritePolicy,
162
+ rewriteRepoScopedPathIntoWorktree,
163
+ hasOpaqueRepoRootAbsoluteReference,
403
164
  };
404
165
 
405
- function selectorMatches(item, selector) {
406
- const normalized = path.resolve(selector);
407
- return item.branch === selector || item.path === normalized;
408
- }
409
-
410
- function classifyEntry(entry, repoRoot, activeWorktree, protectedBranches, mergedIntoBase) {
411
- const entryPath = path.resolve(entry.path);
412
- const branchName = entry.branch;
413
- const item = {
414
- branch: branchName,
415
- path: entryPath,
416
- head: entry.head,
417
- detached: Boolean(entry.detached),
418
- };
419
-
420
- if (!branchName || entry.detached) {
421
- return {
422
- ...item,
423
- status: "blocked",
424
- reason: !branchName ? "no branch" : "detached HEAD",
425
- selectable: false,
426
- };
427
- }
428
-
429
- if (entryPath === path.resolve(repoRoot)) {
430
- return {
431
- ...item,
432
- status: "blocked",
433
- reason: entryPath === activeWorktree ? "repository root, current worktree, protected branch" : "repository root",
434
- selectable: false,
435
- };
436
- }
437
-
438
- if (entryPath === activeWorktree) {
439
- return {
440
- ...item,
441
- status: "blocked",
442
- reason: "current worktree",
443
- selectable: false,
444
- };
445
- }
446
-
447
- if (protectedBranches.has(branchName)) {
448
- return {
449
- ...item,
450
- status: "blocked",
451
- reason: "protected branch",
452
- selectable: false,
453
- };
454
- }
455
-
456
- if (mergedIntoBase) {
457
- return {
458
- ...item,
459
- status: "safe",
460
- reason: "merged into base branch by git ancestry",
461
- selectable: true,
462
- };
463
- }
464
-
465
- return {
466
- ...item,
467
- status: "review",
468
- reason: "not merged into base branch by git ancestry",
469
- selectable: true,
470
- };
471
- }
472
-
473
166
  export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
474
- async function git(args, options = {}) {
475
- const cwd = options.cwd ?? directory;
476
- const command = `git ${args.map((arg) => $.escape(String(arg))).join(" ")}`;
477
- const result = await $`${{ raw: command }}`.cwd(cwd).quiet().nothrow();
478
- const stdout = result.text().trim();
479
- const stderr = result.stderr.toString("utf8").trim();
167
+ const service = createWorktreeWorkflowService({
168
+ directory,
169
+ git: createGitRunner($, directory),
170
+ stateStore: createRuntimeStateStore(),
171
+ });
480
172
 
481
- if (!options.allowFailure && result.exitCode !== 0) {
482
- throw new Error(stderr || stdout || `Git command failed: ${command}`);
173
+ async function onToolExecuteBefore(input) {
174
+ const toolName = input?.tool?.name ?? input?.toolName;
175
+ const args = input?.args || {};
176
+ const classification = classifyToolExecution({ toolName, args });
177
+ if (classification.bypass) return input;
178
+ const rewritePolicy = getToolRewritePolicy({ toolName });
179
+
180
+ const sessionID = input?.sessionID ?? input?.context?.sessionID;
181
+ let binding = null;
182
+
183
+ if (classification.requiresIsolation) {
184
+ if (!sessionID) throw new Error(`Isolation required for ${toolName || "tool"} but sessionID is missing.`);
185
+ const repoRoot = await service.getRepoRoot();
186
+ for (const key of rewritePolicy.opaqueArgKeys) {
187
+ if (hasOpaqueRepoRootAbsoluteReference({ value: args[key], repoRoot })) {
188
+ throw new Error(`Blocked: ${toolName} ${key} includes repo-root absolute path that cannot be safely rewritten.`);
189
+ }
190
+ }
191
+ binding = await service.ensureActiveWorktree({
192
+ sessionID,
193
+ title: deriveTaskTitle({ toolName, args, sessionID }),
194
+ workspaceRole: deriveWorkspaceRole({ subagentType: args.subagent_type }),
195
+ });
196
+ } else if (sessionID) {
197
+ const repoRoot = await service.getRepoRoot();
198
+ const { activeTask } = await service.getSessionBinding({ repoRoot, sessionID });
199
+ if (activeTask?.worktree_path) binding = { repoRoot, task: activeTask };
483
200
  }
484
201
 
485
- return {
486
- stdout,
487
- stderr,
488
- exitCode: result.exitCode,
489
- };
490
- }
202
+ if (!binding) return input;
491
203
 
492
- async function getRepoRoot() {
493
- try {
494
- const result = await git(["rev-parse", "--show-toplevel"]);
495
- return result.stdout;
496
- } catch (error) {
497
- if (isMissingGitRepositoryError(error.message || "")) {
498
- throw new Error(
499
- "This command must run inside a git repository. Initialize a repository first or run it from an existing repo root.",
500
- );
204
+ if (toolName === "task") {
205
+ const handoffPath = resolveSafeHandoffPath({
206
+ rawPath: extractHandoffArtifactPath(args.prompt),
207
+ repoRoot: binding.repoRoot,
208
+ directory,
209
+ });
210
+ if (!handoffPath) {
211
+ throw new Error("Blocked: Task delegation prompt must reference a safe handoff artifact path.");
501
212
  }
502
-
503
- throw error;
213
+ const workspaceContext = buildWorkspaceContext({ task: binding.task, workspaceRole: deriveWorkspaceRole({ subagentType: args.subagent_type }) });
214
+ await enrichHandoffArtifact(handoffPath, workspaceContext);
215
+ return {
216
+ ...input,
217
+ args: {
218
+ ...args,
219
+ prompt: `${args.prompt}\n\nWorkspace binding:\n- task_id: ${workspaceContext.task_id}\n- worktree_path: ${workspaceContext.worktree_path}\n- workspace_role: ${workspaceContext.workspace_role}`,
220
+ },
221
+ };
504
222
  }
505
- }
506
223
 
507
- async function loadWorkflowConfig(repoRoot) {
508
- const [projectConfig, projectConfigC, sidecarConfig] = await Promise.all([
509
- readJsonFile(path.join(repoRoot, "opencode.json")),
510
- readJsonFile(path.join(repoRoot, "opencode.jsonc")),
511
- readJsonFile(path.join(repoRoot, ".opencode", "worktree-workflow.json")),
512
- ]);
513
-
514
- const merged = {
515
- ...DEFAULTS,
516
- ...(projectConfig?.worktreeWorkflow ?? {}),
517
- ...(projectConfigC?.worktreeWorkflow ?? {}),
518
- ...(sidecarConfig ?? {}),
519
- };
520
-
521
- return {
522
- branchPrefix: normalizeBranchPrefix(merged.branchPrefix ?? DEFAULTS.branchPrefix),
523
- remote: merged.remote || DEFAULTS.remote,
524
- baseBranch: typeof merged.baseBranch === "string" && merged.baseBranch.trim() ? merged.baseBranch.trim() : null,
525
- cleanupMode: merged.cleanupMode === "apply" ? "apply" : DEFAULTS.cleanupMode,
526
- protectedBranches: Array.isArray(merged.protectedBranches)
527
- ? merged.protectedBranches.filter((value) => typeof value === "string")
528
- : [],
529
- worktreeRoot: path.resolve(repoRoot, formatRootTemplate(merged.worktreeRoot || DEFAULTS.worktreeRoot, repoRoot)),
530
- };
531
- }
532
-
533
- async function getDefaultBranch(repoRoot, remote) {
534
- const remoteHead = await git(
535
- ["symbolic-ref", "--quiet", "--short", `refs/remotes/${remote}/HEAD`],
536
- { cwd: repoRoot, allowFailure: true },
537
- );
538
-
539
- if (remoteHead.exitCode === 0 && remoteHead.stdout.startsWith(`${remote}/`)) {
540
- return remoteHead.stdout.slice(remote.length + 1);
224
+ const nextArgs = { ...args };
225
+ if (toolName === "bash" && nextArgs.workdir == null && nextArgs.cwd == null) {
226
+ nextArgs.workdir = binding.task.worktree_path;
227
+ nextArgs.cwd = binding.task.worktree_path;
541
228
  }
542
-
543
- const remoteShow = await git(["remote", "show", remote], {
544
- cwd: repoRoot,
545
- allowFailure: true,
546
- });
547
-
548
- if (remoteShow.exitCode === 0) {
549
- const match = remoteShow.stdout.match(/HEAD branch: (.+)/);
550
- if (match?.[1]) {
551
- return match[1].trim();
229
+ if ((toolName === "glob" || toolName === "grep") && nextArgs.path == null) {
230
+ nextArgs.path = binding.task.worktree_path;
231
+ }
232
+ for (const key of rewritePolicy.pathArgKeys) {
233
+ if (key in nextArgs) {
234
+ nextArgs[key] = rewriteRepoScopedPathIntoWorktree({
235
+ value: nextArgs[key],
236
+ repoRoot: binding.repoRoot,
237
+ worktreePath: binding.task.worktree_path,
238
+ });
552
239
  }
553
240
  }
554
241
 
555
- for (const candidate of ["main", "master", "trunk", "develop"]) {
556
- const hasLocal = await git(["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`], {
557
- cwd: repoRoot,
558
- allowFailure: true,
559
- });
560
- if (hasLocal.exitCode === 0) {
561
- return candidate;
562
- }
242
+ return { ...input, args: nextArgs };
243
+ }
563
244
 
564
- const hasRemote = await git(["show-ref", "--verify", "--quiet", `refs/remotes/${remote}/${candidate}`], {
565
- cwd: repoRoot,
566
- allowFailure: true,
245
+ async function onToolExecuteAfter(input) {
246
+ const toolName = input?.tool?.name ?? input?.toolName;
247
+ const args = input?.args || {};
248
+ const sessionID = input?.sessionID ?? input?.context?.sessionID;
249
+ if (toolName === "worktree_prepare" && sessionID) {
250
+ const result = input?.metadata?.result ?? input?.result;
251
+ if (result?.branch && result?.worktree_path) {
252
+ const repoRoot = await service.getRepoRoot();
253
+ await service.updateStateForPrepare(repoRoot, sessionID, result, "manual");
254
+ }
255
+ }
256
+ if (toolName === "task" && sessionID) {
257
+ const repoRoot = await service.getRepoRoot();
258
+ const handoffPath = resolveSafeHandoffPath({
259
+ rawPath: extractHandoffArtifactPath(args.prompt),
260
+ repoRoot,
261
+ directory,
567
262
  });
568
- if (hasRemote.exitCode === 0) {
569
- return candidate;
263
+ if (handoffPath) {
264
+ try {
265
+ const lifecycle = await resolveTaskResultLifecycleSignal(handoffPath);
266
+ if (lifecycle?.signal) {
267
+ const persisted = await service.recordTaskLifecycleSignal({
268
+ repoRoot,
269
+ sessionID,
270
+ taskID: lifecycle.handoff?.payload?.task_id,
271
+ worktreePath: lifecycle.handoff?.payload?.worktree_path,
272
+ signal: lifecycle.signal,
273
+ });
274
+ if (persisted && lifecycle.signal === "complete") {
275
+ try {
276
+ const advisory = await service.buildCleanupAdvisoryPreview({ repoRoot, activeWorktree: input?.context?.worktree ?? input?.worktree ?? directory });
277
+ const parts = Array.isArray(input?.output?.parts) ? [...input.output.parts] : [];
278
+ parts.push({ type: "text", text: advisory.message });
279
+ await service.recordToolUsage({ sessionID });
280
+ return {
281
+ ...input,
282
+ output: {
283
+ ...(input?.output && typeof input.output === "object" ? input.output : {}),
284
+ parts,
285
+ },
286
+ metadata: {
287
+ ...(input?.metadata && typeof input.metadata === "object" ? input.metadata : {}),
288
+ advisory_cleanup_preview: advisory,
289
+ },
290
+ };
291
+ } catch {
292
+ // Advisory preview is non-fatal.
293
+ }
294
+ }
295
+ }
296
+ } catch {
297
+ // Artifact correlation/lifecycle inference is non-fatal.
298
+ }
570
299
  }
571
300
  }
301
+ if (sessionID) await service.recordToolUsage({ sessionID });
302
+ return input;
303
+ }
572
304
 
573
- const currentBranch = await git(["branch", "--show-current"], {
574
- cwd: repoRoot,
575
- allowFailure: true,
576
- });
305
+ async function onCommandExecuteBefore(input) {
306
+ const commandName = input?.command?.name ?? input?.name;
307
+ const normalizedName = typeof commandName === "string" ? commandName.replace(/^\//, "") : "";
308
+ if (normalizedName !== "wt-new" && normalizedName !== "wt-clean") return input;
577
309
 
578
- if (currentBranch.stdout) {
579
- return currentBranch.stdout;
580
- }
581
-
582
- throw new Error("Could not determine the default branch for this repository.");
583
- }
310
+ const argsText = typeof input?.arguments === "string" ? input.arguments : typeof input?.args === "string" ? input.args : "";
311
+ const parts = normalizedName === "wt-new" ? buildWtNewCommandPromptParts(argsText) : buildWtCleanCommandPromptParts(argsText);
584
312
 
585
- async function resolveBaseBranch(repoRoot, remote, configuredBaseBranch) {
586
- return configuredBaseBranch || getDefaultBranch(repoRoot, remote);
313
+ return {
314
+ ...input,
315
+ output: {
316
+ ...(input?.output && typeof input.output === "object" ? input.output : {}),
317
+ parts,
318
+ },
319
+ };
587
320
  }
588
321
 
589
- async function getBaseRef(repoRoot, remote, baseBranch) {
590
- try {
591
- await git(["fetch", "--prune", remote, baseBranch], { cwd: repoRoot });
592
- } catch (error) {
593
- if (isMissingRemoteError(error.message || "", remote)) {
594
- throw new Error(
595
- `Could not fetch base branch information from remote \"${remote}\". Configure the expected remote in .opencode/worktree-workflow.json or add that remote to this repository.`,
596
- );
597
- }
322
+ async function onExperimentalChatSystemTransform(input) {
323
+ const sessionID = input?.sessionID ?? input?.context?.sessionID;
324
+ const existingSystem = typeof input?.system === "string" ? input.system : "";
325
+ if (!sessionID || existingSystem.includes(WORKSPACE_SYSTEM_CONTEXT_MARKER)) return input;
598
326
 
599
- throw error;
600
- }
327
+ const repoRoot = await service.getRepoRoot();
328
+ const { activeTask } = await service.getSessionBinding({ repoRoot, sessionID });
329
+ if (!activeTask?.worktree_path) return input;
601
330
 
602
- const remoteRef = `refs/remotes/${remote}/${baseBranch}`;
603
- const remoteExists = await git(["show-ref", "--verify", "--quiet", remoteRef], {
604
- cwd: repoRoot,
605
- allowFailure: true,
331
+ const workspaceContext = buildWorkspaceContext({
332
+ task: activeTask,
333
+ workspaceRole: activeTask.workspace_role,
606
334
  });
607
-
608
- return remoteExists.exitCode === 0 ? `${remote}/${baseBranch}` : baseBranch;
609
- }
610
-
611
- async function resolveBaseTarget(repoRoot, config) {
612
- const defaultBranch = await getDefaultBranch(repoRoot, config.remote);
613
- const baseBranch = await resolveBaseBranch(repoRoot, config.remote, config.baseBranch);
614
- const baseRef = await getBaseRef(repoRoot, config.remote, baseBranch);
615
-
335
+ const injected = formatWorkspaceSystemContext(workspaceContext);
616
336
  return {
617
- defaultBranch,
618
- baseBranch,
619
- baseRef,
337
+ ...input,
338
+ system: existingSystem ? `${existingSystem}\n\n${injected}` : injected,
620
339
  };
621
340
  }
622
341
 
623
- async function ensureBranchDoesNotExist(repoRoot, branchName) {
624
- const exists = await git(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
625
- cwd: repoRoot,
626
- allowFailure: true,
627
- });
628
-
629
- if (exists.exitCode === 0) {
630
- throw new Error(`Local branch already exists: ${branchName}`);
631
- }
632
- }
633
-
634
342
  return {
343
+ hooks: {
344
+ "command.execute.before": onCommandExecuteBefore,
345
+ "experimental.chat.system.transform": onExperimentalChatSystemTransform,
346
+ "tool.execute.before": onToolExecuteBefore,
347
+ "tool.execute.after": onToolExecuteAfter,
348
+ },
635
349
  tool: {
636
350
  worktree_prepare: tool({
637
351
  description: "Create a synced git worktree from a descriptive title",
@@ -640,50 +354,8 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
640
354
  },
641
355
  async execute(args, context) {
642
356
  context.metadata({ title: `Create worktree: ${args.title}` });
643
-
644
- const repoRoot = await getRepoRoot();
645
- const config = await loadWorkflowConfig(repoRoot);
646
- const { defaultBranch, baseBranch, baseRef } = await resolveBaseTarget(repoRoot, config);
647
- const baseCommit = (await git(["rev-parse", baseRef], { cwd: repoRoot })).stdout;
648
- const slug = slugifyTitle(args.title);
649
-
650
- if (!slug) {
651
- throw new Error("Could not derive a branch name from the provided title.");
652
- }
653
-
654
- const branchName = `${config.branchPrefix}${slug}`;
655
- const worktreePath = path.join(config.worktreeRoot, slug);
656
-
657
- await ensureBranchDoesNotExist(repoRoot, branchName);
658
-
659
- if (await pathExists(worktreePath)) {
660
- throw new Error(`Worktree path already exists: ${worktreePath}`);
661
- }
662
-
663
- await fs.mkdir(config.worktreeRoot, { recursive: true });
664
- await git(["worktree", "add", "-b", branchName, worktreePath, baseRef], {
665
- cwd: repoRoot,
666
- });
667
-
668
- const branchCommit = (await git(["rev-parse", branchName], { cwd: repoRoot })).stdout;
669
- if (branchCommit !== baseCommit) {
670
- throw new Error(
671
- `New branch ${branchName} does not match ${baseBranch} at ${baseCommit}. Found ${branchCommit} instead.`,
672
- );
673
- }
674
-
675
- return publishStructuredResult(
676
- context,
677
- buildPrepareResult({
678
- title: args.title,
679
- branch: branchName,
680
- worktreePath,
681
- defaultBranch,
682
- baseBranch,
683
- baseRef,
684
- baseCommit,
685
- }),
686
- );
357
+ const result = await service.prepare({ title: args.title, sessionID: context.sessionID });
358
+ return publishStructuredResult(context, result);
687
359
  },
688
360
  }),
689
361
  worktree_cleanup: tool({
@@ -691,160 +363,18 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
691
363
  args: {
692
364
  mode: tool.schema.string().optional().describe("Preview cleanup candidates or remove them"),
693
365
  raw: tool.schema.string().optional().describe("Raw cleanup arguments from slash commands"),
694
- selectors: tool.schema
695
- .array(tool.schema.string())
696
- .default([])
697
- .describe("Optional branch names or worktree paths to remove explicitly"),
366
+ selectors: tool.schema.array(tool.schema.string()).default([]).describe("Optional branch names or worktree paths to remove explicitly"),
698
367
  },
699
368
  async execute(args, context) {
700
- const repoRoot = await getRepoRoot();
701
- const config = await loadWorkflowConfig(repoRoot);
702
- const normalizedArgs = normalizeCleanupArgs(args, config);
703
-
704
- context.metadata({ title: `Clean worktrees (${normalizedArgs.mode})` });
705
-
706
- const { defaultBranch, baseBranch, baseRef } = await resolveBaseTarget(repoRoot, config);
707
- const activeWorktree = path.resolve(context.worktree || repoRoot);
708
- const worktreeList = await git(["worktree", "list", "--porcelain"], { cwd: repoRoot });
709
- const entries = parseWorktreeList(worktreeList.stdout);
710
- const protectedBranches = new Set([defaultBranch, baseBranch, ...config.protectedBranches]);
711
- const grouped = {
712
- safe: [],
713
- review: [],
714
- blocked: [],
715
- };
716
-
717
- for (const entry of entries) {
718
- const branchName = entry.branch;
719
- let mergedIntoBase = false;
720
-
721
- if (branchName && !entry.detached) {
722
- const merged = await git(["merge-base", "--is-ancestor", branchName, baseRef], {
723
- cwd: repoRoot,
724
- allowFailure: true,
725
- });
726
-
727
- mergedIntoBase = merged.exitCode === 0;
728
- }
729
-
730
- const classified = classifyEntry(
731
- entry,
732
- repoRoot,
733
- activeWorktree,
734
- protectedBranches,
735
- mergedIntoBase,
736
- );
737
-
738
- grouped[classified.status].push(classified);
739
- }
740
-
741
- if (normalizedArgs.mode !== "apply") {
742
- return publishStructuredResult(
743
- context,
744
- buildCleanupPreviewResult({
745
- defaultBranch,
746
- baseBranch,
747
- baseRef,
748
- grouped,
749
- }),
750
- );
751
- }
752
-
753
- const requestedSelectors = [...new Set(normalizedArgs.selectors || [])];
754
- const selected = [];
755
- const failed = [];
756
-
757
- for (const selector of requestedSelectors) {
758
- const match = [...grouped.safe, ...grouped.review, ...grouped.blocked].find((item) =>
759
- selectorMatches(item, selector),
760
- );
761
-
762
- if (!match) {
763
- failed.push({
764
- selector,
765
- reason: "selector did not match any connected worktree",
766
- });
767
- continue;
768
- }
769
-
770
- if (!match.selectable) {
771
- failed.push({
772
- ...match,
773
- selector,
774
- reason: `cannot remove via selector: ${match.reason}`,
775
- });
776
- continue;
777
- }
778
-
779
- selected.push({
780
- ...match,
781
- selector,
782
- });
783
- }
784
-
785
- const targets = [...grouped.safe];
786
-
787
- for (const item of selected) {
788
- if (!targets.some((target) => target.path === item.path)) {
789
- targets.push({
790
- ...item,
791
- selector: item.selector ?? null,
792
- selected: true,
793
- });
794
- }
795
- }
796
-
797
- const removed = [];
798
-
799
- for (const candidate of targets) {
800
- const removeWorktree = await git(["worktree", "remove", candidate.path], {
801
- cwd: repoRoot,
802
- allowFailure: true,
803
- });
804
-
805
- if (removeWorktree.exitCode !== 0) {
806
- failed.push({
807
- ...candidate,
808
- reason: removeWorktree.stderr || removeWorktree.stdout || "worktree remove failed",
809
- });
810
- continue;
811
- }
812
-
813
- const deleteBranch = await git(
814
- ["branch", candidate.status === "safe" ? "-d" : "-D", candidate.branch],
815
- {
816
- cwd: repoRoot,
817
- allowFailure: true,
818
- },
819
- );
820
-
821
- if (deleteBranch.exitCode !== 0) {
822
- failed.push({
823
- ...candidate,
824
- reason: deleteBranch.stderr || deleteBranch.stdout || "branch delete failed",
825
- });
826
- continue;
827
- }
828
-
829
- removed.push(candidate);
830
- }
831
-
832
- await git(["worktree", "prune"], {
833
- cwd: repoRoot,
834
- allowFailure: true,
369
+ const result = await service.cleanup({
370
+ mode: args.mode,
371
+ raw: args.raw,
372
+ selectors: args.selectors,
373
+ worktree: context.worktree,
374
+ sessionID: context.sessionID,
835
375
  });
836
-
837
- return publishStructuredResult(
838
- context,
839
- buildCleanupApplyResult({
840
- defaultBranch,
841
- baseBranch,
842
- baseRef,
843
- removed,
844
- failed,
845
- requestedSelectors,
846
- }),
847
- );
376
+ context.metadata({ title: `Clean worktrees (${result.mode})` });
377
+ return publishStructuredResult(context, result);
848
378
  },
849
379
  }),
850
380
  },