@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/README.md +74 -1
- package/package.json +1 -1
- package/src/cli.js +21 -13
- package/src/core/task-binding.js +154 -0
- package/src/core/worktree-service.js +653 -0
- package/src/index.js +293 -763
- package/src/runtime/state-store.js +300 -0
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
|
|
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
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 {
|
|
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
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|