@sven1103/opencode-worktree-workflow 0.6.2 → 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/README.md CHANGED
@@ -12,6 +12,22 @@ To get the workflow running in a project:
12
12
  4. If you want policy guidance for when to isolate work, install the skill from [Co-shipped skill](#co-shipped-skill).
13
13
  5. If you need to understand how the local fallback works, see [CLI fallback](#cli-fallback).
14
14
 
15
+ ## Verify installation
16
+
17
+ After setup, verify the surface you installed:
18
+
19
+ - Plugin: confirm OpenCode exposes `worktree_prepare` and `worktree_cleanup`.
20
+ - CLI: run `npx opencode-worktree-workflow --help`.
21
+ - Slash commands: confirm `/wt-new <title>` and `/wt-clean` are available.
22
+
23
+ Quick smoke check:
24
+
25
+ ```sh
26
+ npx opencode-worktree-workflow wt-new "docs smoke check" --json
27
+ ```
28
+
29
+ That should return a structured result with a `worktree_path`. For release verification before installing, see `docs/releases.md`.
30
+
15
31
  ## Recommended setup
16
32
 
17
33
  Install the package once:
@@ -146,12 +162,55 @@ If your setup uses installed skill files, copy the released `SKILL.md` into a `w
146
162
  ## What the plugin provides
147
163
 
148
164
  - `worktree_prepare`: create a worktree and matching branch from the latest configured base-branch commit, or the default branch when no base branch is configured
149
- - `worktree_cleanup`: preview all connected worktrees against the configured base branch, auto-clean safe ones, and optionally remove selected review items
165
+ - `worktree_cleanup`: preview connected worktrees against the configured base branch and, when explicitly applied, remove safe or selected review items
150
166
 
151
167
  This package now ships the plugin capability, a CLI fallback surface, thin slash commands, and a co-shipped policy skill.
152
168
 
153
169
  These native tools are exposed inside OpenCode after the plugin is loaded. They are not terminal commands.
154
170
 
171
+ ## Lifecycle hooks (determinism)
172
+
173
+ If you use this plugin in autonomous agent tasks, the key feature is that it uses OpenCode lifecycle hooks to make workspace selection deterministic.
174
+
175
+ Hooks implemented by the plugin:
176
+
177
+ - `tool.execute.before`
178
+ - Enforces isolation for repo-mutating tools by ensuring a session-scoped active worktree exists (requires `sessionID`).
179
+ - Rewrites tool inputs so they operate inside the active worktree by default:
180
+ - `bash`: injects `workdir`/`cwd` when missing
181
+ - `glob`/`grep`: injects `path` when missing
182
+ - known path arguments are rewritten from repo-root paths into the bound `worktree_path`
183
+ - Blocks unsafe calls that cannot be rewritten safely (for example repo-root absolute paths in opaque arguments).
184
+ - For delegated `task` calls: requires the prompt to reference a safe handoff artifact path under `.opencode/sessions/<session>/handoffs/*.json`, then enriches that handoff with the active workspace context (`task_id`, `worktree_path`, `workspace_role`).
185
+
186
+ - `tool.execute.after`
187
+ - Records tool usage for the session (used to keep runtime state and cleanup advice consistent across steps).
188
+ - After a delegated `task`, correlates the handoff with result artifacts to infer lifecycle transitions (complete/block) and persists them.
189
+ - When completion is detected, emits an advisory cleanup preview (non-fatal if it cannot be generated).
190
+
191
+ - `experimental.chat.system.transform`
192
+ - Injects an "Active workspace context" block into the system prompt when an active worktree is bound, so agents and tools see the same worktree context every step.
193
+
194
+ - `command.execute.before`
195
+ - Implements `/wt-new` and `/wt-clean` as thin prompt shims (it rewrites the command output parts so the agent calls `worktree_prepare` / `worktree_cleanup`).
196
+
197
+ Net effect: once a `sessionID` is in play, the plugin keeps a stable session-to-worktree binding and consistently routes repository actions into that worktree. This removes prompt-only ambiguity ("did the agent remember to cd?") and is what makes the workflow reproducible.
198
+
199
+ ## Typical usage
200
+
201
+ Most users only need one of these flows:
202
+
203
+ 1. Plugin only: use `worktree_prepare` and `worktree_cleanup` directly in OpenCode.
204
+ 2. Plugin plus slash commands: use `/wt-new <title>` to start isolated work and `/wt-clean` to preview cleanup.
205
+ 3. CLI fallback: use `npx opencode-worktree-workflow wt-new "<title>"` when native tools are unavailable.
206
+
207
+ Typical manual flow:
208
+
209
+ 1. Create a worktree with `worktree_prepare`, `/wt-new <title>`, or `npx opencode-worktree-workflow wt-new "<title>"`.
210
+ 2. Do the task inside the returned `worktree_path`.
211
+ 3. Preview cleanup with `worktree_cleanup`, `/wt-clean`, or `npx opencode-worktree-workflow wt-clean preview`.
212
+ 4. Apply cleanup only when deletion is intentional.
213
+
155
214
  ## Structured contract
156
215
 
157
216
  The package now exposes a versioned structured contract with a `schema_version` field. Native tools return human-readable text and publish the structured result in tool metadata, while CLI `--json` prints the same structured object directly.
@@ -240,6 +299,20 @@ Supported settings:
240
299
  - `cleanupMode`: default cleanup behavior, either `preview` or `apply`
241
300
  - `protectedBranches`: branches that should never be auto-cleaned
242
301
 
302
+ ## Uninstall
303
+
304
+ Remove only the surfaces you installed:
305
+
306
+ - npm package: `npm remove @sven1103/opencode-worktree-workflow`
307
+ - OpenCode plugin entry: remove `@sven1103/opencode-worktree-workflow` from `opencode.json`
308
+ - Slash commands: delete `wt-new.md` and `wt-clean.md` from `.opencode/commands/` or `~/.config/opencode/commands/`
309
+ - Skill: delete `SKILL.md` from your `worktree-workflow/` skill folder
310
+
311
+ Optional cleanup:
312
+
313
+ - remove `.opencode/worktree-workflow.json` if you created it only for this plugin
314
+ - remove persisted runtime state from `OPENCODE_WORKTREE_STATE_DIR` or the platform default state directory if you no longer want stored task bindings
315
+
243
316
  ## Publish workflow
244
317
 
245
318
  This repo is prepared for npm publishing from GitHub Actions using npm trusted publishing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sven1103/opencode-worktree-workflow",
3
- "version": "0.6.2",
3
+ "version": "1.0.0-alpha.1",
4
4
  "description": "OpenCode plugin for creating and cleaning up git worktrees.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: worktree-workflow
3
- description: Use this skill when you need to decide whether a task should move into a git worktree, when repo root is still safe, or when you need to choose between native worktree tools and the standard CLI fallback.
3
+ description: Use this skill when working on changes that should be isolated from the main repo, such as refactoring multiple files, making risky edits, experimenting, creating parallel work (like branches or worktrees), or when you are unsure whether it is safe to edit directly in the repo root. Use it when you are orchestrating tasks for implementation and review. Not needed for small, simple one-file edits.
4
4
  ---
5
5
 
6
6
  ## When to use me
@@ -49,6 +49,27 @@ description: Use this skill when you need to decide whether a task should move i
49
49
  - Use cleanup apply only when deletion is clearly intended and controlled by the orchestrating runtime.
50
50
  - Treat slash commands as manual human entry points, not as the canonical agent interface.
51
51
 
52
+ ## Result reporting
53
+
54
+ - Always surface the concrete outcome of worktree operations to the user.
55
+ - Do not summarize away important details.
56
+
57
+ When a worktree is created, include:
58
+ - The worktree path
59
+ - The branch name (if created or used)
60
+ - The base branch
61
+ - Whether it was newly created or reused
62
+
63
+ When cleanup is run:
64
+ - Show the preview or the list of affected worktrees
65
+ - Clearly distinguish preview vs applied changes
66
+
67
+ When commands are executed inside a worktree:
68
+ - Mention that the worktree path is the active working directory
69
+ - Include relevant command results (status, diff summary, errors)
70
+
71
+ Prefer structured, explicit output over vague summaries.
72
+
52
73
  ## Boundaries
53
74
 
54
75
  - Do not encode runtime storage, session artifact, or orchestration file-layout details here.
package/src/cli.js CHANGED
@@ -5,7 +5,8 @@ import fs from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { promisify } from "node:util";
7
7
 
8
- import { WorktreeWorkflowPlugin } from "./index.js";
8
+ import { createWorktreeWorkflowService } from "./core/worktree-service.js";
9
+ import { createRuntimeStateStore } from "./runtime/state-store.js";
9
10
 
10
11
  const execFileAsync = promisify(execFile);
11
12
 
@@ -115,21 +116,26 @@ export async function run(argv = process.argv.slice(2)) {
115
116
  return;
116
117
  }
117
118
 
118
- const plugin = await WorktreeWorkflowPlugin({
119
- $: createShell(process.cwd()),
119
+ const shell = createShell(process.cwd());
120
+ const git = async (args, options = {}) => {
121
+ const cwd = options.cwd ?? process.cwd();
122
+ const command = `git ${args.map((arg) => shell.escape(String(arg))).join(" ")}`;
123
+ const result = await shell`${{ raw: command }}`.cwd(cwd).quiet().nothrow();
124
+ const stdout = result.text().trim();
125
+ const stderr = result.stderr.toString("utf8").trim();
126
+ if (!options.allowFailure && result.exitCode !== 0) {
127
+ throw new Error(stderr || stdout || `Git command failed: ${command}`);
128
+ }
129
+ return { stdout, stderr, exitCode: result.exitCode };
130
+ };
131
+ const service = createWorktreeWorkflowService({
120
132
  directory: process.cwd(),
133
+ git,
134
+ stateStore: createRuntimeStateStore(),
121
135
  });
122
136
 
123
137
  let result;
124
138
  let structuredResult = null;
125
- const toolContext = {
126
- metadata(input) {
127
- if (input?.metadata?.result) {
128
- structuredResult = input.metadata.result;
129
- }
130
- },
131
- worktree: process.cwd(),
132
- };
133
139
 
134
140
  if (command === "wt-new") {
135
141
  const title = rest.join(" ").trim();
@@ -138,10 +144,12 @@ export async function run(argv = process.argv.slice(2)) {
138
144
  throw new Error("wt-new requires a descriptive title.");
139
145
  }
140
146
 
141
- result = await plugin.tool.worktree_prepare.execute({ title }, toolContext);
147
+ structuredResult = await service.prepare({ title, sessionID: "cli" });
148
+ result = structuredResult.message;
142
149
  } else if (command === "wt-clean") {
143
150
  const raw = rest.join(" ").trim();
144
- result = await plugin.tool.worktree_cleanup.execute({ raw, selectors: [] }, toolContext);
151
+ structuredResult = await service.cleanup({ raw, selectors: [], worktree: process.cwd(), sessionID: "cli" });
152
+ result = structuredResult.message;
145
153
  } else {
146
154
  throw new Error(`Unknown command: ${command}`);
147
155
  }
@@ -0,0 +1,154 @@
1
+ import path from "node:path";
2
+
3
+ export function decideContinuity({ hasActiveTask = false, continuationSignal = false, distinctObjectiveSignal = false, alternativeRequested = false, ambiguous = false } = {}) {
4
+ if (!hasActiveTask) {
5
+ return { decision: "create-new", reason: "no-active-task" };
6
+ }
7
+
8
+ if (distinctObjectiveSignal || alternativeRequested) {
9
+ return { decision: "create-new", reason: alternativeRequested ? "alternative-requested" : "distinct-objective" };
10
+ }
11
+
12
+ if (ambiguous) {
13
+ return { decision: "ask-user", reason: "ambiguous-continuity" };
14
+ }
15
+
16
+ if (continuationSignal) {
17
+ return { decision: "reuse-active", reason: "clear-continuation" };
18
+ }
19
+
20
+ return { decision: "ask-user", reason: "insufficient-signal" };
21
+ }
22
+
23
+ export function inferTaskLifecycleTransition({ currentStatus = "inactive", explicitSignal = "none" } = {}) {
24
+ if (explicitSignal === "activate") return "active";
25
+ if (explicitSignal === "deactivate") return "inactive";
26
+ if (explicitSignal === "complete") return "completed";
27
+ if (explicitSignal === "block") return "blocked";
28
+ return currentStatus;
29
+ }
30
+
31
+ const READ_ONLY_TOOLS = new Set(["read", "glob", "grep", "webfetch"]);
32
+ const WORKTREE_CONTROL_TOOLS = new Set(["worktree_prepare", "worktree_cleanup"]);
33
+ const MUTATING_TOOLS = new Set(["write", "edit", "apply_patch"]);
34
+ const WORKSPACE_ROLES = new Set(["planner", "implementer", "reviewer", "linear-flow"]);
35
+ const READ_ONLY_GIT_COMMAND = /^\s*git\s+(status|diff|log|show|rev-parse|symbolic-ref|branch\s+--show-current|remote\s+show)\b/i;
36
+
37
+ export function classifyToolExecution({ toolName, args = {} } = {}) {
38
+ const name = typeof toolName === "string" ? toolName : "";
39
+ if (WORKTREE_CONTROL_TOOLS.has(name)) return { requiresIsolation: false, bypass: true, kind: "worktree-control" };
40
+ if (READ_ONLY_TOOLS.has(name)) return { requiresIsolation: false, bypass: false, kind: "read-only" };
41
+ if (name === "task") return { requiresIsolation: true, bypass: false, kind: "delegation" };
42
+ if (MUTATING_TOOLS.has(name)) return { requiresIsolation: true, bypass: false, kind: "mutating" };
43
+ if (name === "bash") {
44
+ const command = typeof args.command === "string" ? args.command : "";
45
+ if (READ_ONLY_GIT_COMMAND.test(command)) return { requiresIsolation: false, bypass: false, kind: "read-only" };
46
+ return { requiresIsolation: true, bypass: false, kind: "mutating" };
47
+ }
48
+ return { requiresIsolation: true, bypass: false, kind: "unknown" };
49
+ }
50
+
51
+ export function deriveTaskTitle({ explicitTitle, toolName, args = {}, sessionID } = {}) {
52
+ if (typeof explicitTitle === "string" && explicitTitle.trim()) return explicitTitle.trim();
53
+ if (typeof args.title === "string" && args.title.trim()) return args.title.trim();
54
+ if (typeof args.prompt === "string" && args.prompt.trim()) return args.prompt.trim().slice(0, 80);
55
+ if (typeof args.description === "string" && args.description.trim()) return args.description.trim().slice(0, 80);
56
+ const shortID = typeof sessionID === "string" && sessionID ? sessionID.slice(0, 8) : "auto";
57
+ if (typeof toolName === "string" && toolName) return `${toolName}-${shortID}`;
58
+ return `task-${shortID}`;
59
+ }
60
+
61
+ export function deriveWorkspaceRole({ subagentType } = {}) {
62
+ if (typeof subagentType !== "string") return "linear-flow";
63
+ const normalized = subagentType.trim().toLowerCase();
64
+ return WORKSPACE_ROLES.has(normalized) ? normalized : "linear-flow";
65
+ }
66
+
67
+ export function extractHandoffArtifactPath(prompt) {
68
+ if (typeof prompt !== "string" || !prompt.trim()) return null;
69
+ const match = prompt.match(/(?:^|\s)(\/?[^\s]*\.opencode\/sessions\/[A-Za-z0-9_-]+\/handoffs\/[A-Za-z0-9._-]+\.json)(?:\s|$)/);
70
+ return match?.[1] ?? null;
71
+ }
72
+
73
+ export function buildWorkspaceContext({ task, workspaceRole } = {}) {
74
+ return {
75
+ task_id: task?.task_id ?? null,
76
+ task_title: task?.title ?? null,
77
+ worktree_path: task?.worktree_path ?? null,
78
+ workspace_role: workspaceRole || "linear-flow",
79
+ lifecycle_state: task?.status ?? "active",
80
+ };
81
+ }
82
+
83
+ const REWRITE_POLICIES = {
84
+ read: { pathArgKeys: ["filePath"], opaqueArgKeys: [] },
85
+ write: { pathArgKeys: ["filePath"], opaqueArgKeys: [] },
86
+ edit: { pathArgKeys: ["filePath"], opaqueArgKeys: [] },
87
+ glob: { pathArgKeys: ["path"], opaqueArgKeys: [] },
88
+ grep: { pathArgKeys: ["path"], opaqueArgKeys: [] },
89
+ bash: { pathArgKeys: ["workdir", "cwd"], opaqueArgKeys: ["command"] },
90
+ apply_patch: { pathArgKeys: [], opaqueArgKeys: ["patchText"] },
91
+ };
92
+
93
+ export function getToolRewritePolicy({ toolName } = {}) {
94
+ return REWRITE_POLICIES[toolName] || { pathArgKeys: [], opaqueArgKeys: [] };
95
+ }
96
+
97
+ function isInsideRepoRoot(candidatePath, repoRoot) {
98
+ const relative = path.relative(repoRoot, candidatePath);
99
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
100
+ }
101
+
102
+ function isInsideWorktree(candidatePath, worktreePath) {
103
+ const relative = path.relative(worktreePath, candidatePath);
104
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
105
+ }
106
+
107
+ export function rewriteRepoScopedPathIntoWorktree({ value, repoRoot, worktreePath } = {}) {
108
+ if (typeof value !== "string" || !value.trim()) return value;
109
+ const normalizedRepoRoot = path.resolve(repoRoot);
110
+ const normalizedWorktreePath = path.resolve(worktreePath);
111
+ if (!path.isAbsolute(value)) return path.join(normalizedWorktreePath, value);
112
+ const resolvedValue = path.resolve(value);
113
+ if (isInsideWorktree(resolvedValue, normalizedWorktreePath)) return resolvedValue;
114
+ if (!isInsideRepoRoot(resolvedValue, normalizedRepoRoot)) return value;
115
+ const relative = path.relative(normalizedRepoRoot, resolvedValue);
116
+ return relative ? path.join(normalizedWorktreePath, relative) : normalizedWorktreePath;
117
+ }
118
+
119
+ function isBoundaryChar(char) {
120
+ if (!char) return true;
121
+ return /[\s"'`()[\]{}<>,:;=]/.test(char) || char === "/" || char === "\\";
122
+ }
123
+
124
+ export function hasOpaqueRepoRootAbsoluteReference({ value, repoRoot } = {}) {
125
+ if (typeof value !== "string" || !value) return false;
126
+ const normalizedRepoRoot = path.resolve(repoRoot).replaceAll("\\", "/");
127
+ const normalizedValue = value.replaceAll("\\", "/");
128
+ let index = normalizedValue.indexOf(normalizedRepoRoot);
129
+ while (index !== -1) {
130
+ const before = index > 0 ? normalizedValue[index - 1] : "";
131
+ const after = normalizedValue[index + normalizedRepoRoot.length] || "";
132
+ if (isBoundaryChar(before) && isBoundaryChar(after)) return true;
133
+ index = normalizedValue.indexOf(normalizedRepoRoot, index + 1);
134
+ }
135
+ return false;
136
+ }
137
+
138
+ export function buildWtNewCommandPromptParts(argumentsText = "") {
139
+ const title = typeof argumentsText === "string" ? argumentsText.trim() : "";
140
+ if (!title) {
141
+ return [{ type: "text", text: "Usage: /wt-new <title>\nExample: /wt-new improve checkout retry logic" }];
142
+ }
143
+ return [
144
+ {
145
+ type: "text",
146
+ text: `Call worktree_prepare with ${JSON.stringify({ title })}. Return the tool result and treat its worktree_path as the active workspace for follow-up work.`,
147
+ },
148
+ ];
149
+ }
150
+
151
+ export function buildWtCleanCommandPromptParts(argumentsText = "") {
152
+ const raw = typeof argumentsText === "string" ? argumentsText : "";
153
+ return [{ type: "text", text: `Call worktree_cleanup with ${JSON.stringify({ raw })}. Return the tool result.` }];
154
+ }