Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 9 additions & 15 deletions index.cjs
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
"use strict";

async function loadPlugin() {
const mod = await import("./src/index.js");
return mod.default || mod.WorktreeWorkflowPlugin;
}
const plugin = {
id: "@sven1103/opencode-worktree-workflow",
async server(...args) {
const mod = await import("./src/index.js");
return mod.WorktreeWorkflowPlugin(...args);
},
};

async function WorktreeWorkflowPlugin(...args) {
const plugin = await loadPlugin();
if (typeof plugin !== "function") {
throw new TypeError("Plugin export is not a function");
}
return plugin(...args);
}

module.exports = WorktreeWorkflowPlugin;
module.exports.default = WorktreeWorkflowPlugin;
module.exports.WorktreeWorkflowPlugin = WorktreeWorkflowPlugin;
module.exports = plugin;
module.exports.default = plugin;
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,16 @@
"require": "./index.cjs",
"import": "./src/index.js",
"default": "./index.cjs"
},
"./server": {
"require": "./index.cjs",
"import": "./src/index.js",
"default": "./index.cjs"
}
},
"oc-plugin": [
"server"
],
"files": [
"index.cjs",
"src",
Expand Down
104 changes: 44 additions & 60 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,21 +163,23 @@ export const __internal = {
hasOpaqueRepoRootAbsoluteReference,
};

export const pluginID = "@sven1103/opencode-worktree-workflow";

export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
const service = createWorktreeWorkflowService({
directory,
git: createGitRunner($, directory),
stateStore: createRuntimeStateStore(),
});

async function onToolExecuteBefore(input) {
const toolName = input?.tool?.name ?? input?.toolName;
const args = input?.args || {};
async function onToolExecuteBefore(input, output) {
const toolName = input?.tool;
const args = output?.args || {};
const classification = classifyToolExecution({ toolName, args });
if (classification.bypass) return input;
if (classification.bypass) return;
const rewritePolicy = getToolRewritePolicy({ toolName });

const sessionID = input?.sessionID ?? input?.context?.sessionID;
const sessionID = input?.sessionID;
let binding = null;

if (classification.requiresIsolation) {
Expand All @@ -199,7 +201,7 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
if (activeTask?.worktree_path) binding = { repoRoot, task: activeTask };
}

if (!binding) return input;
if (!binding) return;

if (toolName === "task") {
const handoffPath = resolveSafeHandoffPath({
Expand All @@ -212,13 +214,11 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
}
const workspaceContext = buildWorkspaceContext({ task: binding.task, workspaceRole: deriveWorkspaceRole({ subagentType: args.subagent_type }) });
await enrichHandoffArtifact(handoffPath, workspaceContext);
return {
...input,
args: {
...args,
prompt: `${args.prompt}\n\nWorkspace binding:\n- task_id: ${workspaceContext.task_id}\n- worktree_path: ${workspaceContext.worktree_path}\n- workspace_role: ${workspaceContext.workspace_role}`,
},
output.args = {
...args,
prompt: `${args.prompt}\n\nWorkspace binding:\n- task_id: ${workspaceContext.task_id}\n- worktree_path: ${workspaceContext.worktree_path}\n- workspace_role: ${workspaceContext.workspace_role}`,
};
return;
}

const nextArgs = { ...args };
Expand All @@ -239,15 +239,15 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
}
}

return { ...input, args: nextArgs };
output.args = nextArgs;
}

async function onToolExecuteAfter(input) {
const toolName = input?.tool?.name ?? input?.toolName;
async function onToolExecuteAfter(input, output) {
const toolName = input?.tool;
const args = input?.args || {};
const sessionID = input?.sessionID ?? input?.context?.sessionID;
const sessionID = input?.sessionID;
if (toolName === "worktree_prepare" && sessionID) {
const result = input?.metadata?.result ?? input?.result;
const result = output?.metadata?.result;
if (result?.branch && result?.worktree_path) {
const repoRoot = await service.getRepoRoot();
await service.updateStateForPrepare(repoRoot, sessionID, result, "manual");
Expand All @@ -273,21 +273,14 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
});
if (persisted && lifecycle.signal === "complete") {
try {
const advisory = await service.buildCleanupAdvisoryPreview({ repoRoot, activeWorktree: input?.context?.worktree ?? input?.worktree ?? directory });
const parts = Array.isArray(input?.output?.parts) ? [...input.output.parts] : [];
parts.push({ type: "text", text: advisory.message });
const advisory = await service.buildCleanupAdvisoryPreview({ repoRoot, activeWorktree: directory });
await service.recordToolUsage({ sessionID });
return {
...input,
output: {
...(input?.output && typeof input.output === "object" ? input.output : {}),
parts,
},
metadata: {
...(input?.metadata && typeof input.metadata === "object" ? input.metadata : {}),
advisory_cleanup_preview: advisory,
},
output.output = output.output ? `${output.output}\n\n${advisory.message}` : advisory.message;
output.metadata = {
...(output?.metadata && typeof output.metadata === "object" ? output.metadata : {}),
advisory_cleanup_preview: advisory,
};
return;
} catch {
// Advisory preview is non-fatal.
}
Expand All @@ -299,53 +292,39 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
}
}
if (sessionID) await service.recordToolUsage({ sessionID });
return input;
}

async function onCommandExecuteBefore(input) {
const commandName = input?.command?.name ?? input?.name;
const normalizedName = typeof commandName === "string" ? commandName.replace(/^\//, "") : "";
if (normalizedName !== "wt-new" && normalizedName !== "wt-clean") return input;
async function onCommandExecuteBefore(input, output) {
const normalizedName = typeof input?.command === "string" ? input.command.replace(/^\//, "") : "";
if (normalizedName !== "wt-new" && normalizedName !== "wt-clean") return;

const argsText = typeof input?.arguments === "string" ? input.arguments : typeof input?.args === "string" ? input.args : "";
const argsText = typeof input?.arguments === "string" ? input.arguments : "";
const parts = normalizedName === "wt-new" ? buildWtNewCommandPromptParts(argsText) : buildWtCleanCommandPromptParts(argsText);

return {
...input,
output: {
...(input?.output && typeof input.output === "object" ? input.output : {}),
parts,
},
};
output.parts = parts;
}

async function onExperimentalChatSystemTransform(input) {
const sessionID = input?.sessionID ?? input?.context?.sessionID;
const existingSystem = typeof input?.system === "string" ? input.system : "";
if (!sessionID || existingSystem.includes(WORKSPACE_SYSTEM_CONTEXT_MARKER)) return input;
async function onExperimentalChatSystemTransform(input, output) {
const sessionID = input?.sessionID;
const existingSystem = Array.isArray(output?.system) ? output.system : [];
if (!sessionID || existingSystem.some((entry) => entry.includes(WORKSPACE_SYSTEM_CONTEXT_MARKER))) return;

const repoRoot = await service.getRepoRoot();
const { activeTask } = await service.getSessionBinding({ repoRoot, sessionID });
if (!activeTask?.worktree_path) return input;
if (!activeTask?.worktree_path) return;

const workspaceContext = buildWorkspaceContext({
task: activeTask,
workspaceRole: activeTask.workspace_role,
});
const injected = formatWorkspaceSystemContext(workspaceContext);
return {
...input,
system: existingSystem ? `${existingSystem}\n\n${injected}` : injected,
};
output.system = [...existingSystem, injected];
}

return {
hooks: {
"command.execute.before": onCommandExecuteBefore,
"experimental.chat.system.transform": onExperimentalChatSystemTransform,
"tool.execute.before": onToolExecuteBefore,
"tool.execute.after": onToolExecuteAfter,
},
"command.execute.before": onCommandExecuteBefore,
"experimental.chat.system.transform": onExperimentalChatSystemTransform,
"tool.execute.before": onToolExecuteBefore,
"tool.execute.after": onToolExecuteAfter,
tool: {
worktree_prepare: tool({
description: "Create a synced git worktree from a descriptive title",
Expand Down Expand Up @@ -381,4 +360,9 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
};
};

export default WorktreeWorkflowPlugin;
const plugin = {
id: pluginID,
server: WorktreeWorkflowPlugin,
};

export default plugin;
52 changes: 42 additions & 10 deletions test-support/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";

import { WorktreeWorkflowPlugin } from "../src/index.js";
import pluginModule from "../src/index.js";

const execFileAsync = promisify(execFile);

Expand Down Expand Up @@ -110,7 +110,7 @@ async function createRemoteRepo() {
}

async function createPlugin(repoPath) {
return WorktreeWorkflowPlugin({
return pluginModule.server({
$: createShell(repoPath),
directory: repoPath,
});
Expand All @@ -132,30 +132,62 @@ async function executeToolWithMetadata(execute, args, worktree, { sessionID = "t
}

async function runToolExecuteBeforeHook(plugin, input) {
return plugin.hooks["tool.execute.before"](input);
const output = { args: { ...(input?.args || {}) } };
await plugin["tool.execute.before"]({
tool: input?.tool ?? input?.toolName,
sessionID: input?.sessionID,
callID: input?.callID ?? "test-call",
}, output);
return { ...input, args: output.args };
}

async function runToolExecuteAfterHook(plugin, input) {
return plugin.hooks["tool.execute.after"](input);
const output = {
title: input?.output?.title ?? "",
output: input?.output?.output ?? "",
metadata: {
...(input?.metadata ?? {}),
...(input?.result ? { result: input.result } : {}),
},
};
await plugin["tool.execute.after"]({
tool: input?.tool ?? input?.toolName,
sessionID: input?.sessionID,
callID: input?.callID ?? "test-call",
args: input?.args || {},
}, output);
return { ...input, output };
}

async function runToolExecuteAfterHookWithOutput(plugin, input) {
const output = await runToolExecuteAfterHook(plugin, input);
const textOutput = typeof output?.output?.output === "string" ? output.output.output : "";
return {
output,
advisoryTextParts: Array.isArray(output?.output?.parts)
? output.output.parts.filter((part) => part?.type === "text" && typeof part?.text === "string").map((part) => part.text)
: [],
advisoryMetadata: output?.metadata?.advisory_cleanup_preview ?? null,
advisoryTextParts: textOutput ? [textOutput] : [],
advisoryMetadata: output?.output?.metadata?.advisory_cleanup_preview ?? null,
};
}

async function runCommandExecuteBeforeHook(plugin, input) {
return plugin.hooks["command.execute.before"](input);
const output = { parts: input?.output?.parts || [] };
await plugin["command.execute.before"]({
command: typeof input?.command === "string" ? input.command : input?.command?.name,
sessionID: input?.sessionID ?? "test-session",
arguments: input?.arguments ?? "",
}, output);
return { ...input, output };
}

async function runExperimentalChatSystemTransformHook(plugin, input) {
return plugin.hooks["experimental.chat.system.transform"](input);
const output = {
system: typeof input?.system === "string" ? [input.system] : Array.isArray(input?.system) ? [...input.system] : [],
};
await plugin["experimental.chat.system.transform"]({
sessionID: input?.sessionID,
model: input?.model ?? { id: "test-model" },
}, output);
return { ...input, system: output.system.join("\n\n") };
}

async function createHandoffArtifact(repoPath, sessionID, handoffID, payload = {}) {
Expand Down
9 changes: 5 additions & 4 deletions test/plugin-entrypoint.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."
test("package root resolves to a callable plugin factory for require loaders", async () => {
const pluginModule = require(repoRoot);

assert.equal(typeof pluginModule, "function");
assert.equal(typeof pluginModule, "object");
assert.equal(pluginModule.id, "@sven1103/opencode-worktree-workflow");
assert.equal(typeof pluginModule.server, "function");
assert.equal(pluginModule, pluginModule.default);
assert.equal(pluginModule, pluginModule.WorktreeWorkflowPlugin);

const plugin = await pluginModule({
const plugin = await pluginModule.server({
$: Object.assign(
() => ({
cwd() {
Expand All @@ -39,5 +40,5 @@ test("package root resolves to a callable plugin factory for require loaders", a
});

assert.equal(typeof plugin, "object");
assert.equal(typeof plugin.hooks["tool.execute.before"], "function");
assert.equal(typeof plugin["tool.execute.before"], "function");
});
Loading