diff --git a/docs/working/hook-harness-design-spec.md b/docs/working/hook-harness-design-spec.md
index d584ba8..033335b 100644
--- a/docs/working/hook-harness-design-spec.md
+++ b/docs/working/hook-harness-design-spec.md
@@ -8,6 +8,17 @@ This document defines a deterministic worktree harness for OpenCode using plugin
The current worktree workflow relies on skills and orchestrator prompts to convince agents to call `worktree_prepare` and `worktree_cleanup` at the right time. That behavior is not reliable enough because skills are still prompt text, and agents can skip or misapply them. The result is nondeterministic workspace selection and inconsistent cleanup behavior.
+## Validated Runtime Constraints
+
+Validation against current OpenCode runtime behavior showed an important host boundary:
+
+- `tool.execute.before` does fire for built-in tools
+- hook payload mutation is only reliable when mutating the existing object in place
+- replacing `output.args` or `output.parts` wholesale is not a safe assumption
+- the documented plugin model is centered on the host-selected `worktree`, not on plugins acting as a general multi-worktree router for every built-in tool
+
+This means the harness can still use hooks as an enforcement layer, but only for capabilities it can actually control. Transparent routing of all built-in tools across multiple task worktrees is not a host-guaranteed primitive today.
+
## Goals
- Make worktree creation deterministic for non-trivial editable work.
@@ -16,6 +27,7 @@ The current worktree workflow relies on skills and orchestrator prompts to convi
- Keep the primary orchestrator as the only authority that can create, switch, fork, or cleanup-manage worktrees.
- Keep the skill policy-oriented and move enforcement into plugin hooks.
- Keep the existing `worktree_prepare` and `worktree_cleanup` tool contract as the public capability surface.
+- Preserve an enforceable routing story for mutating work even if OpenCode does not provide a fully general built-in tool redirection contract.
## Non-Goals
@@ -74,6 +86,8 @@ Recommended OpenCode hooks:
- `command.execute.before`: slash-command normalization and parity
- `experimental.chat.system.transform`: model-facing context injection only
+Hooks remain the right place for classification, state adoption, prompt context, and live payload mutation. They should not be treated as proof that the plugin can transparently re-home every built-in tool into an arbitrary task worktree.
+
### Runtime State Layer
The authoritative runtime state should live outside the repo in plugin-managed storage keyed by canonical repo root and `sessionID`. Optional diagnostic mirrors inside `.opencode/` are acceptable, but they must not be authoritative.
@@ -182,11 +196,13 @@ Responsibilities:
- classify the call as read-only, mutating, or delegation
- resolve task continuity
- create or select a bound worktree if isolation is required
-- rewrite execution context and repo-scoped paths into the bound worktree
+- rewrite execution context and repo-scoped paths into the bound worktree when the runtime contract safely permits it
- block when safe provisioning or safe rewriting is not possible
This hook is the main choke point because it runs immediately before the action happens.
+For hard guarantees, this hook should prefer plugin-controlled execution paths over assumptions about undocumented built-in routing semantics.
+
### `tool.execute.after`
Responsibilities:
@@ -204,6 +220,8 @@ Responsibilities:
- route `/wt-new` and `/wt-clean` through the same state model
- keep slash commands as human UX only, not a separate control plane
+This hook should mutate existing prompt parts in place when possible. It should not assume replacing `output.parts` wholesale is honored by the host.
+
### `experimental.chat.system.transform`
Responsibilities:
@@ -214,6 +232,8 @@ Responsibilities:
This hook improves model behavior but is not part of enforcement.
+It should be treated as advisory context only, never as the mechanism that makes routing correct.
+
## Delegation Policy
Task-tool calls are the canonical delegation boundary.
@@ -287,16 +307,34 @@ Provenance should affect messaging and cleanup recommendations, but not whether
## Path and Workdir Rewriting
-The harness should rewrite execution into the bound worktree by default.
+The harness should rewrite execution into the bound worktree only where the runtime contract is actually enforceable.
### Rewrite Rules
-- always rewrite repo-scoped workdir or cwd into the active worktree
-- always rewrite repo-scoped file paths into the active worktree
+- mutate live path-like arguments in place when the runtime exposes a stable mutable payload
+- prefer plugin-owned mutating tools when execution location must be guaranteed
+- rewrite repo-scoped workdir or cwd only when the called tool actually honors those fields as execution inputs
+- rewrite repo-scoped file paths only when the path is an explicit structured argument that can be rewritten safely
- never rewrite clearly external absolute paths
- block rather than guess when a path cannot be safely classified
-This should make workspace isolation deterministic without depending on agents to remember worktree-local paths.
+This can make parts of workspace isolation deterministic without depending on agents to remember worktree-local paths, but only for tools whose execution semantics are actually under plugin control.
+
+### Enforceable Routing Boundary
+
+The harness should distinguish three classes of execution:
+
+1. Plugin-owned tools
+2. Built-in tools with safe in-place mutable structured arguments
+3. Built-in tools whose effective execution context is owned by OpenCode
+
+For class 1, the plugin can guarantee execution in the bound task worktree.
+
+For class 2, the plugin can often steer execution by mutating the live payload in place, but must still treat that as host-contract-sensitive behavior.
+
+For class 3, the plugin may only provide context, advisory blocking, or explicit failure rather than claim transparent routing.
+
+If hard routing guarantees are required for mutating work, the plugin should own the relevant mutating tools rather than rely on undocumented built-in routing semantics.
## Privilege Model
@@ -421,7 +459,7 @@ This makes workspace context portable and inspectable across fresh-context subag
## Acceptance Criteria
-- A mutating tool call in a non-trivial task does not execute in repo root when no bound worktree exists.
+- A mutating task that requires hard workspace isolation is either routed through a plugin-controlled execution path or blocked when no safe enforcement path exists.
- A Task-tool editable delegation causes worktree binding before delegation.
- Planner, implementer, and reviewer reuse the same worktree for one linear task unless the orchestrator explicitly forks.
- Manual `worktree_prepare` is adopted into managed runtime state.
@@ -429,6 +467,7 @@ This makes workspace context portable and inspectable across fresh-context subag
- Cleanup advice appears only at defined advisory moments.
- Provisioning or rewrite failures block execution unless the user explicitly overrides.
- Model prompt injection improves context but is not required for enforcement correctness.
+- The plugin does not claim transparent built-in multi-worktree routing beyond what the validated host/runtime contract can enforce.
## Test Matrix
@@ -445,6 +484,8 @@ This makes workspace context portable and inspectable across fresh-context subag
- rewrite ambiguity blocks execution
- explicit override allows the scoped root fallback and is visibly recorded
- cleanup advice fires at completion but not on every successful command
+- hook payload rewrites mutate existing `args` and `parts` objects in place
+- plugin-owned mutating tools execute against the bound task worktree
## Recommended Implementation Sequence
@@ -453,12 +494,13 @@ This makes workspace context portable and inspectable across fresh-context subag
3. Implement task continuity and binding APIs.
4. Add `tool.execute.before` enforcement.
5. Add Task-tool delegation handling.
-6. Add repo-scoped path and workdir rewriting.
-7. Add `tool.execute.after` adoption and advisory signaling.
-8. Add `command.execute.before` parity handling.
-9. Extend the handoff schema with workspace fields.
-10. Add `experimental.chat.system.transform` context injection.
-11. Add invariant-focused tests.
+6. Add repo-scoped path and workdir rewriting for safe mutable payloads.
+7. Add plugin-owned mutating tools for hard routing guarantees.
+8. Add `tool.execute.after` adoption and advisory signaling.
+9. Add `command.execute.before` parity handling.
+10. Extend the handoff schema with workspace fields.
+11. Add `experimental.chat.system.transform` context injection.
+12. Add invariant-focused tests.
## Suggested GitHub Tracking Breakdown
@@ -476,7 +518,8 @@ Track the work as separate but ordered issues:
## Definition of Done
- The harness, not the skill, is the authoritative enforcement layer for workspace isolation.
-- Orchestrated editable work reliably lands in a task-scoped worktree.
+- Orchestrated editable work reliably lands in a task-scoped worktree when routed through an enforceable plugin-controlled path.
- Linear flows reuse one worktree by default.
- Cleanup remains advisory-only.
- Public tools and slash commands still work, but all paths converge on one shared lifecycle model.
+- The documented architecture clearly distinguishes advisory hook steering from plugin-owned hard routing.
diff --git a/docs/working/implementation-plan.md b/docs/working/implementation-plan.md
new file mode 100644
index 0000000..6d3f95c
--- /dev/null
+++ b/docs/working/implementation-plan.md
@@ -0,0 +1,275 @@
+# Worktree Workflow Implementation Plan
+
+## Purpose
+
+This document captures the agreed implementation plan for evolving the worktree workflow into a package with a stable machine contract, a CLI fallback path, thin human-facing slash commands, and orchestration-friendly task workspace handling.
+
+## Goals
+
+- Keep the package as the canonical capability layer.
+- Support both native plugin execution and `bunx` CLI fallback.
+- Keep slash commands as first-class human UX, but make them thin wrappers.
+- Co-ship the skill with the worktree workflow package for now because they form a strong functional unit.
+- Keep orchestration details in the primary agent/runtime layer even if the skill is shipped from the same repo.
+- Enable task-scoped shared worktrees for orchestrated planning, execution, and review.
+- Preserve a serious plugin-side enforcement path for mutating work even if OpenCode does not guarantee general built-in multi-worktree routing.
+
+## Runtime Constraint Update
+
+Validation against current OpenCode runtime behavior showed that hook payload replacement is not a safe assumption for built-in tools. In practice, plugin logic must mutate live payload objects in place, and hard execution-location guarantees should be reserved for tool paths the plugin owns directly.
+
+This changes the implementation shape:
+
+- hooks remain useful for classification, state, handoff context, and safe in-place argument mutation
+- transparent routing of every built-in tool should not be treated as a guaranteed platform capability
+- plugin-owned mutating tools are the preferred path where hard worktree routing guarantees are required
+
+## Phase 1: Package Contract Hardening
+
+### Scope
+
+- Define a versioned structured result contract for `worktree_prepare`.
+- Define a versioned structured result contract for `worktree_cleanup` in both `preview` and `apply` modes.
+- Refactor internal package design so logic returns objects first and human text is rendered second.
+- Move argument normalization fully into package internals.
+
+### Acceptance Criteria
+
+- Native plugin calls can return structured data without parsing prose.
+- `worktree_prepare` always returns:
+ - `ok`
+ - `title`
+ - `branch`
+ - `worktree_path`
+ - `default_branch`
+ - `base_branch`
+ - `base_ref`
+ - `base_commit`
+ - `created`
+- `worktree_cleanup` preview always returns grouped structured items for `safe`, `review`, and `blocked`.
+- `worktree_cleanup` apply always returns `requested_selectors`, `removed`, and `failed`.
+- Partial cleanup is represented as a successful reconciliation result, not a top-level failure.
+- Existing human-readable output can still be produced from the structured result.
+
+## Phase 2: CLI Fallback Surface
+
+### Scope
+
+- Add a CLI entrypoint to the same npm package.
+- Expose commands like:
+ - `wt-new
`
+ - `wt-clean [preview|apply] [selectors...]`
+- Add `--json` output for orchestration and scripting.
+- Ensure CLI and native plugin use the same underlying implementation.
+
+### Acceptance Criteria
+
+- The package can be run via `bunx` without repo-local helper scripts.
+- `wt-new --json` returns the same core fields as native `worktree_prepare`.
+- `wt-clean --json` returns the same core fields as native `worktree_cleanup`.
+- CLI argument handling does not require duplicated logic in skills or slash commands.
+- CLI human output remains readable for manual use.
+
+## Phase 3: Slash Command Simplification
+
+### Scope
+
+- Reduce `/wt-new` to a minimal wrapper around the canonical package capability.
+- Reduce `/wt-clean` to a minimal wrapper around the canonical package capability.
+- Remove duplicated invocation semantics from command files where package logic can own them.
+
+### Acceptance Criteria
+
+- Slash commands remain usable for human-triggered workflows.
+- Slash commands no longer carry unique business logic that must stay in sync with the package.
+- Behavior of slash commands matches the native tool/CLI contract.
+- Human users can still run preview/apply cleanup ergonomically.
+
+## Phase 4: Compatibility Contract
+
+### Scope
+
+- Publish a short compatibility contract document.
+- Define the package-internal compatibility contract between native plugin, CLI, slash commands, and the co-shipped skill.
+- Document stable required fields for native and CLI paths.
+
+### Acceptance Criteria
+
+- The skill can target a documented contract rather than package internals.
+- Native plugin, CLI, slash commands, and the co-shipped skill share one documented contract.
+- The repo can still split the skill into a separate distribution later without changing the core contract.
+- A new team can understand how native plugin, CLI fallback, and slash commands relate.
+
+## Phase 5: Org Skill Design
+
+### Scope
+
+- Draft a `worktree-workflow` skill covering:
+ - root is shared and only for tiny/root-safe tasks
+ - task-scoped worktrees are the default for non-trivial editable work
+ - one task worktree is shared across planning, execution, and review unless work diverges
+ - divergent worktrees are only for conflicting or independent concurrent branches
+ - cleanup is preview-first
+ - capability ladder: native tool -> `bunx` CLI -> tiny/root-safe fallback only
+- Explicitly define `tiny/root-safe`.
+- Keep the skill abstract about runtime storage and session artifacts.
+- Ship the skill from this repository for the initial release.
+
+### Acceptance Criteria
+
+- The skill does not mention `.opencode/sessions/...` or concrete storage mechanics.
+- The skill defines when to isolate work, not how orchestration persists it.
+- The skill supports both native-plugin and `bunx` fallback environments.
+- The skill does not duplicate package argument normalization logic.
+- The skill can later move to a separate repo without changing its behavioral contract.
+
+## Phase 6: Coding Boss Orchestration Contract
+
+### Scope
+
+- Extend `coding-boss` so it:
+ - selects root vs task worktree before delegation
+ - creates or selects one task-scoped worktree for non-trivial editable work
+ - shares that same worktree across planner, implementer, and reviewer for one task
+ - creates divergent worktrees only when work truly branches
+ - centralizes cleanup apply authority
+- Define explicit workspace context passed in editable handoffs:
+ - `task_title`
+ - `worktree_path`
+ - `workspace_role`
+ - `lifecycle_state`
+
+### Acceptance Criteria
+
+- Editable delegated tasks always carry explicit workspace context.
+- Subagents do not choose their own workspace.
+- Linear flows reuse the same task worktree.
+- Divergent worktrees are created only by the orchestrator.
+- Cleanup apply is orchestrator-only.
+
+## Phase 6.5: Plugin-Owned Mutating Tool Surface
+
+### Scope
+
+- Add plugin-owned mutating tools where hard worktree routing guarantees are required.
+- Start with the narrowest serious set:
+ - `write`
+ - `edit`
+ - `apply_patch`
+ - optionally `bash` for mutating shell execution
+- Keep read-only tools host-owned unless there is a clear need for parity.
+- Route these plugin-owned tools through the active task binding and shared core service.
+
+### Acceptance Criteria
+
+- Mutating operations that must be isolated execute against the bound task worktree without depending on undocumented built-in hook replacement behavior.
+- The plugin can block unsafe repo-root mutation even when host-owned built-in routing is ambiguous.
+- The architecture clearly distinguishes advisory steering of host-owned tools from hard routing of plugin-owned mutating tools.
+
+## Phase 7: Session-State Runtime Metadata
+
+### Scope
+
+- Extend session artifacts to track task and workspace metadata.
+- Add lifecycle tracking for:
+ - `planned`
+ - `active`
+ - `review`
+ - `done`
+- Register, update, and unregister task worktrees in session state.
+- Include minimal audit and intent fields:
+ - `task_title`
+ - `worktree_path`
+ - `workspace_role`
+ - `lifecycle_state`
+ - `created_by`
+ - `created_at`
+ - optional `parent_task_id`
+ - optional `user_intent`
+
+### Acceptance Criteria
+
+- Active task worktrees can be identified from session or runtime state.
+- Follow-on handoffs can resume the same workspace reliably.
+- Divergent worktrees can be traced back to parent task lineage.
+- Cleanup logic can check runtime state before removal.
+- No repo-committed files are required for orchestration metadata.
+
+## Phase 8: Cleanup Governance
+
+### Scope
+
+- Keep plugin cleanup classification based on git and worktree facts only.
+- Add orchestrator-side protections for active or retained worktrees.
+- Auto-run cleanup preview when a task reaches `done`.
+- Never auto-apply cleanup.
+- Show a retained-worktree notice when cleanup is not applied.
+- Fail closed for apply if runtime state is missing or inconsistent, and downgrade uncertain items to review.
+
+### Acceptance Criteria
+
+- A merged branch is not enough by itself for auto-clean in orchestrated flows.
+- Active or retained task worktrees are never auto-removed.
+- Cleanup preview can still run even when runtime state is incomplete.
+- Cleanup apply only proceeds when both git facts and runtime state allow it.
+- Users are informed when a finished task leaves behind a retained worktree.
+
+## Phase 9: Testing Strategy
+
+### Scope
+
+- Add plugin tests for:
+ - structured schemas
+ - classification logic
+ - cleanup partial-success behavior
+- Add CLI tests for:
+ - argument parsing
+ - parity with native plugin schema
+- Add slash command tests for thin wrapper behavior only.
+- Add skill tests for:
+ - root vs worktree policy
+ - native tool vs `bunx` fallback selection
+ - stop behavior when isolation is unavailable
+- Add integration tests for:
+ - skill -> native plugin path
+ - skill -> `bunx` fallback path
+ - plugin-owned mutating tool path
+
+### Acceptance Criteria
+
+- Every layer has a clearly owned contract test surface.
+- Native plugin and CLI produce compatible structured outputs.
+- Skill decisions can be validated independently of package internals.
+- At least one happy path exists for both native and fallback execution modes.
+- At least one happy path proves hard-routed mutation through a plugin-owned tool.
+
+## Recommended Work Split
+
+- Track A: package contract and CLI
+- Track B: slash commands, compatibility docs, and co-shipped skill packaging
+- Track C: skill drafting
+- Track D: `coding-boss` orchestration and session metadata
+- Track E: cleanup governance and tests
+
+## Suggested Milestone Order
+
+1. Package structured contract
+2. CLI fallback
+3. Slash command thinning
+4. Compatibility doc
+5. Skill draft and co-shipping setup
+6. `coding-boss` orchestration changes
+7. Plugin-owned mutating tools
+8. Session metadata and lifecycle
+9. Cleanup governance
+10. Integration tests
+
+## Definition of Done
+
+- A non-trivial coding task can be routed by `coding-boss` into a task-scoped worktree.
+- Planner, implementer, and reviewer can share the same explicit `worktree_path`.
+- The same task can run with either native plugin or `bunx` fallback.
+- Hard-isolated mutating execution does not depend on undocumented built-in tool redirection behavior.
+- Cleanup preview works with structured output.
+- Cleanup apply is orchestrator-controlled and protected by runtime task state.
+- Skill, package, CLI, and slash commands each have a clean, non-duplicated responsibility boundary even when shipped from the same repo.
diff --git a/src/core/worktree-service.js b/src/core/worktree-service.js
index a20be47..80b1ffb 100644
--- a/src/core/worktree-service.js
+++ b/src/core/worktree-service.js
@@ -32,6 +32,18 @@ export function isMissingRemoteError(message, remote) {
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);
}
+function escapeRegExp(value) {
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+function isPlaceholderBranchName(value) {
+ return /^\((?:unknown|none)\)$/i.test(String(value || "").trim());
+}
+
+function isMissingRemoteBranchError(message, branch) {
+ return new RegExp(`couldn't find remote ref\\s+${escapeRegExp(branch)}|remote branch\\s+${escapeRegExp(branch)}\\s+not found`, "i").test(message);
+}
+
async function readJsonFile(filePath) {
if (!(await pathExists(filePath))) {
return null;
@@ -358,7 +370,7 @@ function classifyEntry(entry, repoRoot, activeWorktree, protectedBranches, merge
return { ...item, status: "review", reason: "not merged into base branch by git ancestry", selectable: true };
}
-export function createWorktreeWorkflowService({ directory, git, stateStore }) {
+export function createWorktreeWorkflowService({ directory, git, stateStore, logger = null }) {
async function computeCleanupPreview({ repoRoot, activeWorktree }) {
const config = await loadWorkflowConfig(repoRoot);
const { defaultBranch, baseBranch, baseRef } = await resolveBaseTarget(repoRoot, config);
@@ -406,11 +418,16 @@ export function createWorktreeWorkflowService({ directory, git, stateStore }) {
}
async function getDefaultBranch(repoRoot, remote) {
const remoteHead = await git(["symbolic-ref", "--quiet", "--short", `refs/remotes/${remote}/HEAD`], { cwd: repoRoot, allowFailure: true });
- if (remoteHead.exitCode === 0 && remoteHead.stdout.startsWith(`${remote}/`)) return remoteHead.stdout.slice(remote.length + 1);
+ if (remoteHead.exitCode === 0 && remoteHead.stdout.startsWith(`${remote}/`)) {
+ const branch = remoteHead.stdout.slice(remote.length + 1).trim();
+ if (branch && !isPlaceholderBranchName(branch)) return branch;
+ }
+ const remoteUrl = await git(["remote", "get-url", remote], { cwd: repoRoot, allowFailure: true });
const remoteShow = await git(["remote", "show", remote], { cwd: repoRoot, allowFailure: true });
if (remoteShow.exitCode === 0) {
const match = remoteShow.stdout.match(/HEAD branch: (.+)/);
- if (match?.[1]) return match[1].trim();
+ const branch = match?.[1]?.trim();
+ if (branch && !isPlaceholderBranchName(branch)) return branch;
}
for (const candidate of ["main", "master", "trunk", "develop"]) {
if ((await git(["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`], { cwd: repoRoot, allowFailure: true })).exitCode === 0) return candidate;
@@ -418,7 +435,10 @@ export function createWorktreeWorkflowService({ directory, git, stateStore }) {
}
const currentBranch = await git(["branch", "--show-current"], { cwd: repoRoot, allowFailure: true });
if (currentBranch.stdout) return currentBranch.stdout;
- throw new Error("Could not determine the default branch for this repository.");
+ if (remoteUrl.exitCode !== 0) {
+ throw new Error(`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.`);
+ }
+ throw new Error(`Remote \"${remote}\" does not advertise a default branch yet, and this repository has no local branches to use as a fallback. Create and push the initial base branch, or configure \"baseBranch\" in .opencode/worktree-workflow.json once that branch exists.`);
}
async function resolveBaseTarget(repoRoot, config) {
const defaultBranch = await getDefaultBranch(repoRoot, config.remote);
@@ -429,6 +449,9 @@ export function createWorktreeWorkflowService({ directory, git, stateStore }) {
if (isMissingRemoteError(error.message || "", config.remote)) {
throw new Error(`Could not fetch base branch information from remote \"${config.remote}\". Configure the expected remote in .opencode/worktree-workflow.json or add that remote to this repository.`);
}
+ if (isMissingRemoteBranchError(error.message || "", baseBranch)) {
+ throw new Error(`Remote \"${config.remote}\" does not have branch \"${baseBranch}\" yet. Create and push the initial base branch, or configure \"baseBranch\" in .opencode/worktree-workflow.json once that branch exists.`);
+ }
throw error;
}
const remoteRef = `refs/remotes/${config.remote}/${baseBranch}`;
@@ -443,6 +466,7 @@ export function createWorktreeWorkflowService({ directory, git, stateStore }) {
async function updateStateForPrepare(repoRoot, sessionID, prepared, createdBy = "manual", workspaceRole = "linear-flow") {
if (!sessionID || !stateStore) return;
const state = await stateStore.loadSessionState(repoRoot, sessionID);
+ const previous = stateStore.findTaskByID(state, prepared.branch) || stateStore.findTaskByWorktreePath(state, prepared.worktree_path);
const next = stateStore.setActiveTask(
stateStore.upsertTask(state, {
task_id: prepared.branch,
@@ -456,6 +480,14 @@ export function createWorktreeWorkflowService({ directory, git, stateStore }) {
prepared.branch,
);
await stateStore.saveSessionState(repoRoot, sessionID, next);
+ logger?.info(previous ? "session_binding_updated" : "session_binding_created", {
+ session_id: sessionID,
+ task_id: prepared.branch,
+ branch: prepared.branch,
+ worktree_path: prepared.worktree_path,
+ created_by: createdBy,
+ workspace_role: workspaceRole,
+ });
}
async function updateStateForCleanup(repoRoot, sessionID, removed) {
if (!sessionID || !stateStore || removed.length === 0) return;
@@ -474,6 +506,11 @@ export function createWorktreeWorkflowService({ directory, git, stateStore }) {
});
if (stateStore.getActiveTask(state) === taskID) {
state = stateStore.setActiveTask(state, null);
+ logger?.info("session_binding_cleared", {
+ session_id: sessionID,
+ task_id: taskID,
+ reason: "cleanup",
+ });
}
}
await stateStore.saveSessionState(repoRoot, sessionID, state);
@@ -521,6 +558,14 @@ export function createWorktreeWorkflowService({ directory, git, stateStore }) {
);
await stateStore.saveSessionState(repoRoot, sessionID, next);
const refreshed = stateStore.getActiveTaskRecord(next);
+ logger?.info("session_binding_updated", {
+ session_id: sessionID,
+ task_id: refreshed?.task_id,
+ branch: refreshed?.branch,
+ worktree_path: refreshed?.worktree_path,
+ created_by: refreshed?.created_by,
+ workspace_role: refreshed?.workspace_role,
+ });
return { repoRoot, task: refreshed };
}
return { repoRoot, task: activeTask };
@@ -569,6 +614,11 @@ export function createWorktreeWorkflowService({ directory, git, stateStore }) {
});
if (stateStore.getActiveTask(state) === current.task_id) {
state = stateStore.setActiveTask(state, null);
+ logger?.info("session_binding_cleared", {
+ session_id: sessionID,
+ task_id: current.task_id,
+ reason: nextStatus === "blocked" ? "blocked" : "completed",
+ });
}
await stateStore.saveSessionState(repoRoot, sessionID, state);
return { task_id: current.task_id, status: nextStatus };
diff --git a/src/index.js b/src/index.js
index 5c70ede..4821146 100644
--- a/src/index.js
+++ b/src/index.js
@@ -18,6 +18,7 @@ import {
inferTaskLifecycleTransition,
rewriteRepoScopedPathIntoWorktree,
} from "./core/task-binding.js";
+import { createDecisionLogger } from "./runtime/decision-logger.js";
import { createRuntimeStateStore } from "./runtime/state-store.js";
function publishStructuredResult(context, result) {
@@ -165,11 +166,28 @@ export const __internal = {
export const pluginID = "@sven1103/opencode-worktree-workflow";
+function isInsideRoot(candidatePath, rootPath) {
+ const relative = path.relative(path.resolve(rootPath), path.resolve(candidatePath));
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
+}
+
+function deriveRewriteSkipReason({ value, binding }) {
+ if (value == null || (typeof value === "string" && !value.trim())) return "arg-missing";
+ if (typeof value !== "string") return "arg-missing";
+ if (!path.isAbsolute(value)) return "arg-missing";
+ const resolved = path.resolve(value);
+ if (isInsideRoot(resolved, binding.task.worktree_path)) return "already-in-worktree";
+ if (!isInsideRoot(resolved, binding.repoRoot)) return "outside-repo-root";
+ return "arg-missing";
+}
+
export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
+ const logger = createDecisionLogger();
const service = createWorktreeWorkflowService({
directory,
git: createGitRunner($, directory),
stateStore: createRuntimeStateStore(),
+ logger,
});
async function onToolExecuteBefore(input, output) {
@@ -187,6 +205,7 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
const repoRoot = await service.getRepoRoot();
for (const key of rewritePolicy.opaqueArgKeys) {
if (hasOpaqueRepoRootAbsoluteReference({ value: args[key], repoRoot })) {
+ logger.info("tool_path_rewrite_skipped", { tool: toolName, arg_key: key, reason: "opaque-repo-root-reference", session_id: sessionID });
throw new Error(`Blocked: ${toolName} ${key} includes repo-root absolute path that cannot be safely rewritten.`);
}
}
@@ -201,7 +220,12 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
if (activeTask?.worktree_path) binding = { repoRoot, task: activeTask };
}
- if (!binding) return;
+ if (!binding) {
+ for (const key of rewritePolicy.pathArgKeys) {
+ logger.info("tool_path_rewrite_skipped", { tool: toolName, arg_key: key, reason: "no-active-binding", session_id: sessionID });
+ }
+ return;
+ }
if (toolName === "task") {
const handoffPath = resolveSafeHandoffPath({
@@ -214,32 +238,49 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
}
const workspaceContext = buildWorkspaceContext({ task: binding.task, workspaceRole: deriveWorkspaceRole({ subagentType: args.subagent_type }) });
await enrichHandoffArtifact(handoffPath, workspaceContext);
- 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}`,
- };
+ 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 };
- if (toolName === "bash" && nextArgs.workdir == null && nextArgs.cwd == null) {
- nextArgs.workdir = binding.task.worktree_path;
- nextArgs.cwd = binding.task.worktree_path;
+ if (toolName === "bash" && args.workdir == null && args.cwd == null) {
+ args.workdir = binding.task.worktree_path;
+ args.cwd = binding.task.worktree_path;
}
- if ((toolName === "glob" || toolName === "grep") && nextArgs.path == null) {
- nextArgs.path = binding.task.worktree_path;
+ if ((toolName === "glob" || toolName === "grep") && args.path == null) {
+ args.path = binding.task.worktree_path;
}
for (const key of rewritePolicy.pathArgKeys) {
- if (key in nextArgs) {
- nextArgs[key] = rewriteRepoScopedPathIntoWorktree({
- value: nextArgs[key],
- repoRoot: binding.repoRoot,
- worktreePath: binding.task.worktree_path,
+ if (!(key in args)) {
+ logger.info("tool_path_rewrite_skipped", { tool: toolName, arg_key: key, reason: "arg-missing", session_id: sessionID, task_id: binding.task.task_id });
+ continue;
+ }
+ const beforePath = args[key];
+ const rewritten = rewriteRepoScopedPathIntoWorktree({
+ value: beforePath,
+ repoRoot: binding.repoRoot,
+ worktreePath: binding.task.worktree_path,
+ });
+ args[key] = rewritten;
+ if (rewritten !== beforePath) {
+ logger.info("tool_path_rewrite_applied", { tool: toolName, arg_key: key, session_id: sessionID, task_id: binding.task.task_id });
+ logger.debug("tool_path_rewrite_applied", {
+ tool: toolName,
+ arg_key: key,
+ session_id: sessionID,
+ task_id: binding.task.task_id,
+ before_path: beforePath,
+ after_path: rewritten,
+ });
+ } else {
+ logger.info("tool_path_rewrite_skipped", {
+ tool: toolName,
+ arg_key: key,
+ reason: deriveRewriteSkipReason({ value: beforePath, binding }),
+ session_id: sessionID,
+ task_id: binding.task.task_id,
});
}
}
-
- output.args = nextArgs;
}
async function onToolExecuteAfter(input, output) {
@@ -271,8 +312,8 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
worktreePath: lifecycle.handoff?.payload?.worktree_path,
signal: lifecycle.signal,
});
- if (persisted && lifecycle.signal === "complete") {
- try {
+ if (persisted && lifecycle.signal === "complete") {
+ try {
const advisory = await service.buildCleanupAdvisoryPreview({ repoRoot, activeWorktree: directory });
await service.recordToolUsage({ sessionID });
output.output = output.output ? `${output.output}\n\n${advisory.message}` : advisory.message;
@@ -280,13 +321,23 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
...(output?.metadata && typeof output.metadata === "object" ? output.metadata : {}),
advisory_cleanup_preview: advisory,
};
- return;
- } catch {
- // Advisory preview is non-fatal.
- }
- }
- }
- } catch {
+ return;
+ } catch (error) {
+ logger.info("nonfatal_plugin_error", {
+ stage: "task_after_hook_advisory_preview",
+ message: error instanceof Error ? error.message : String(error),
+ session_id: sessionID,
+ });
+ // Advisory preview is non-fatal.
+ }
+ }
+ }
+ } catch (error) {
+ logger.info("nonfatal_plugin_error", {
+ stage: "task_after_hook_lifecycle_inference",
+ message: error instanceof Error ? error.message : String(error),
+ session_id: sessionID,
+ });
// Artifact correlation/lifecycle inference is non-fatal.
}
}
@@ -300,6 +351,10 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
const argsText = typeof input?.arguments === "string" ? input.arguments : "";
const parts = normalizedName === "wt-new" ? buildWtNewCommandPromptParts(argsText) : buildWtCleanCommandPromptParts(argsText);
+ if (Array.isArray(output?.parts)) {
+ output.parts.splice(0, output.parts.length, ...parts);
+ return;
+ }
output.parts = parts;
}
@@ -317,6 +372,10 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
workspaceRole: activeTask.workspace_role,
});
const injected = formatWorkspaceSystemContext(workspaceContext);
+ if (Array.isArray(output?.system)) {
+ output.system.push(injected);
+ return;
+ }
output.system = [...existingSystem, injected];
}
diff --git a/src/runtime/decision-logger.js b/src/runtime/decision-logger.js
new file mode 100644
index 0000000..16fdc40
--- /dev/null
+++ b/src/runtime/decision-logger.js
@@ -0,0 +1,71 @@
+const LEVEL_PRIORITY = {
+ silent: 0,
+ info: 1,
+ debug: 2,
+};
+
+const BLOCKED_KEY_PATTERN = /(prompt|content|secret|token|password|patch(text)?|payload|body)/i;
+const MAX_STRING_LENGTH = 300;
+
+function normalizeLevel(level) {
+ if (typeof level !== "string") return "info";
+ const normalized = level.trim().toLowerCase();
+ return Object.hasOwn(LEVEL_PRIORITY, normalized) ? normalized : "info";
+}
+
+function sanitizeValue(value) {
+ if (value == null) return value;
+ if (typeof value === "string") return value.length > MAX_STRING_LENGTH ? `${value.slice(0, MAX_STRING_LENGTH)}…` : value;
+ if (typeof value === "number" || typeof value === "boolean") return value;
+ if (Array.isArray(value)) return value.slice(0, 20).map((item) => sanitizeValue(item)).filter((item) => item !== undefined);
+ if (typeof value === "object") {
+ const next = {};
+ for (const [key, child] of Object.entries(value)) {
+ if (BLOCKED_KEY_PATTERN.test(key)) continue;
+ const sanitized = sanitizeValue(child);
+ if (sanitized !== undefined) next[key] = sanitized;
+ }
+ return next;
+ }
+ return undefined;
+}
+
+function sanitizeFields(fields) {
+ if (!fields || typeof fields !== "object") return {};
+ const next = {};
+ for (const [key, value] of Object.entries(fields)) {
+ if (BLOCKED_KEY_PATTERN.test(key)) continue;
+ const sanitized = sanitizeValue(value);
+ if (sanitized !== undefined) next[key] = sanitized;
+ }
+ return next;
+}
+
+export function createDecisionLogger({ level = process.env.OPENCODE_WORKTREE_LOG_LEVEL, write } = {}) {
+ const configuredLevel = normalizeLevel(level);
+ const writer = typeof write === "function" ? write : (line) => console.log(line);
+
+ function shouldLog(logLevel) {
+ return LEVEL_PRIORITY[configuredLevel] >= LEVEL_PRIORITY[logLevel];
+ }
+
+ function log(logLevel, event, fields) {
+ if (!shouldLog(logLevel)) return;
+ if (typeof event !== "string" || !event.trim()) return;
+ writer(JSON.stringify({
+ ts: new Date().toISOString(),
+ level: logLevel,
+ event: event.trim(),
+ ...sanitizeFields(fields),
+ }));
+ }
+
+ return {
+ info(event, fields) {
+ log("info", event, fields);
+ },
+ debug(event, fields) {
+ log("debug", event, fields);
+ },
+ };
+}
diff --git a/test-support/helpers.js b/test-support/helpers.js
index e3ffc0c..af8f0fd 100644
--- a/test-support/helpers.js
+++ b/test-support/helpers.js
@@ -109,6 +109,28 @@ async function createRemoteRepo() {
};
}
+async function createEmptyRemoteRepo() {
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "worktree-empty-remote-"));
+ const stateDir = path.join(tempRoot, "runtime-state");
+ const remotePath = path.join(tempRoot, "remote.git");
+ const repoPath = path.join(tempRoot, "repo");
+
+ await execFileAsync("git", ["init", "--bare", remotePath]);
+ await execFileAsync("git", ["clone", remotePath, repoPath]);
+ await git(repoPath, ["config", "user.email", "test@example.com"]);
+ await git(repoPath, ["config", "user.name", "Test User"]);
+ await git(repoPath, ["config", "commit.gpgsign", "false"]);
+
+ return {
+ tempRoot,
+ stateDir,
+ repoPath,
+ async cleanup() {
+ await fs.rm(tempRoot, { recursive: true, force: true });
+ },
+ };
+}
+
async function createPlugin(repoPath) {
return pluginModule.server({
$: createShell(repoPath),
@@ -243,6 +265,7 @@ function withStateDirEnv(stateDir) {
export {
commitFile,
+ createEmptyRemoteRepo,
createPlugin,
createRemoteRepo,
execFileAsync,
diff --git a/test/cli.test.js b/test/cli.test.js
index 16d9ba0..fc099ce 100644
--- a/test/cli.test.js
+++ b/test/cli.test.js
@@ -6,7 +6,7 @@ import path from "node:path";
import { __internal } from "../src/index.js";
import { isInvokedAsScript, parseCliArgs } from "../src/cli.js";
-import { createPlugin, createRemoteRepo, execFileAsync, executeToolWithMetadata, withStateDirEnv } from "../test-support/helpers.js";
+import { createEmptyRemoteRepo, createPlugin, createRemoteRepo, execFileAsync, executeToolWithMetadata, withStateDirEnv } from "../test-support/helpers.js";
const cliPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../src/cli.js");
@@ -186,3 +186,16 @@ test("CLI surfaces an actionable error when the configured remote is missing", a
await fixture.cleanup();
}
});
+
+test("CLI surfaces an actionable error when the remote has no base branch yet", async () => {
+ const fixture = await createEmptyRemoteRepo();
+
+ try {
+ await assert.rejects(
+ execFileAsync("node", [cliPath, "wt-clean", "preview"], { cwd: fixture.repoPath }),
+ /does not have branch "main" yet/i,
+ );
+ } finally {
+ await fixture.cleanup();
+ }
+});
diff --git a/test/hook-enforcement.test.js b/test/hook-enforcement.test.js
index 60d65d8..95a8b18 100644
--- a/test/hook-enforcement.test.js
+++ b/test/hook-enforcement.test.js
@@ -33,6 +33,30 @@ test("tool.execute.before provisions worktree and rewrites mutating filePath", a
}
});
+test("tool.execute.before mutates the existing args object in place", async () => {
+ const fixture = await createRemoteRepo();
+ const previous = process.env.OPENCODE_WORKTREE_STATE_DIR;
+ process.env.OPENCODE_WORKTREE_STATE_DIR = fixture.stateDir;
+
+ try {
+ const plugin = await createPlugin(fixture.repoPath);
+ const args = { filePath: "tracked.txt" };
+ const output = { args };
+
+ await plugin["tool.execute.before"]({
+ tool: "write",
+ sessionID: "hook-session-in-place-tool",
+ callID: "test-call",
+ }, output);
+
+ assert.equal(output.args, args);
+ assert.notEqual(args.filePath, "tracked.txt");
+ } finally {
+ process.env.OPENCODE_WORKTREE_STATE_DIR = previous;
+ await fixture.cleanup();
+ }
+});
+
test("tool.execute.before keeps read-only tools in repo root", async () => {
const fixture = await createRemoteRepo();
const previous = process.env.OPENCODE_WORKTREE_STATE_DIR;
@@ -412,6 +436,27 @@ test("command.execute.before normalizes /wt-new and /wt-clean prompts", async ()
}
});
+test("command.execute.before mutates the existing parts array in place", async () => {
+ const fixture = await createRemoteRepo();
+ try {
+ const plugin = await createPlugin(fixture.repoPath);
+ const parts = [{ type: "text", text: "original" }];
+ const output = { parts };
+
+ await plugin["command.execute.before"]({
+ command: "wt-new",
+ sessionID: "test-session",
+ arguments: "In place command rewrite",
+ }, output);
+
+ assert.equal(output.parts, parts);
+ assert.match(parts[0].text, /worktree_prepare/);
+ assert.match(parts[0].text, /In place command rewrite/);
+ } finally {
+ await fixture.cleanup();
+ }
+});
+
test("manual cleanup adopts untracked worktree into state as completed manual task", async () => {
const fixture = await createRemoteRepo();
const previous = process.env.OPENCODE_WORKTREE_STATE_DIR;