@kylecheng3146/agent-ops 0.3.1 → 0.3.3

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.
@@ -86,6 +86,7 @@ export function parseArgs(argv) {
86
86
  let versionSeen = false;
87
87
  let worktreeName;
88
88
  let force = false;
89
+ let worktree;
89
90
  for (let index = 0; index < argv.length; index += 1) {
90
91
  const token = argv[index];
91
92
  if (token === undefined) {
@@ -229,6 +230,18 @@ export function parseArgs(argv) {
229
230
  }
230
231
  completionGate = true;
231
232
  break;
233
+ case "--worktree": {
234
+ if (worktree !== undefined) {
235
+ duplicate(token);
236
+ }
237
+ const value = readOptionValue(argv, index, token);
238
+ if (value !== "auto" && value !== "off") {
239
+ throw new CliArgumentError("CLI_INVALID_VALUE", `Worktree mode must be auto or off: ${value}`, token);
240
+ }
241
+ worktree = value;
242
+ index += 1;
243
+ break;
244
+ }
232
245
  case "--dry-run":
233
246
  if (dryRun) {
234
247
  duplicate(token);
@@ -367,6 +380,7 @@ export function parseArgs(argv) {
367
380
  dryRun ||
368
381
  yes ||
369
382
  rerun ||
383
+ worktree !== undefined ||
370
384
  force) {
371
385
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Only --json may be combined with global help or version.");
372
386
  }
@@ -432,6 +446,9 @@ export function parseArgs(argv) {
432
446
  if (completionGate !== undefined && command !== "init") {
433
447
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--completion-gate may be used only with init.");
434
448
  }
449
+ if (worktree !== undefined && command !== "init" && command !== "update") {
450
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--worktree may be used only with init or update.");
451
+ }
435
452
  if (command === "task") {
436
453
  if (harness !== undefined ||
437
454
  profiles.length > 0 ||
@@ -551,6 +568,7 @@ export function parseArgs(argv) {
551
568
  rerun,
552
569
  dryRun,
553
570
  json,
554
- yes
571
+ yes,
572
+ ...(worktree === undefined ? {} : { worktree })
555
573
  };
556
574
  }
@@ -348,6 +348,7 @@ else {
348
348
  trustStore: store,
349
349
  calculateTrustBinding: async (config) => await plannedTrustBinding(root, config),
350
350
  confirm: async (plan, trust) => await confirmPlan(formatUpdatePlan(plan, trust)),
351
+ promptWorktree: async (message) => await selectYesNo(message, { input: process.stdin, output: process.stdout }, true),
351
352
  ...(updateArgs.targetVersion === undefined
352
353
  ? {}
353
354
  : { targetVersion: updateArgs.targetVersion })
@@ -39,6 +39,7 @@ Options:
39
39
  --profile <core|advisory|guardrails|loop> Repeatable
40
40
  --review-target <codex|agy|claude> Repeatable init option; review pair order
41
41
  --completion-gate Init only: enable the project-loop completion gate
42
+ --worktree <auto|off> Init/update: configure session worktree isolation
42
43
  --check-auth Doctor only: probe selected review targets'
43
44
  authentication with one real call
44
45
  --check-auth-target <target> Repeatable doctor filter with --check-auth
@@ -75,6 +76,7 @@ Options:
75
76
  --profile <core|advisory|guardrails|loop> Repeatable
76
77
  --review-target <codex|agy|claude> Repeatable, in fallback-chain order
77
78
  --completion-gate Enable the project-loop completion gate
79
+ --worktree <auto|off> Configure session worktree isolation
78
80
  --dry-run Print the plan without writing
79
81
  --json
80
82
  --yes
@@ -114,6 +116,7 @@ Options:
114
116
  --scope <project|user>
115
117
  --harness <all|both|agy|claude|codex|opencode|comma-separated>
116
118
  --target-version <version> Offline-capable update target
119
+ --worktree <auto|off> Configure session worktree isolation
117
120
  --dry-run
118
121
  --json
119
122
  --yes
@@ -5,6 +5,7 @@ import { okEnvelope } from "../output.js";
5
5
  import { formatOperationPlan } from "../plan-output.js";
6
6
  import { toPublicInstallPlan } from "../public-plan.js";
7
7
  import { formatTrustChange, planTrustGrant } from "./trust.js";
8
+ import { checkWorktreeEnvWarning, detectWorktreeSetup } from "../../../../runtime/src/install/parallel-setup.js";
8
9
  export function formatInstallPlan(plan, trust, warnings = []) {
9
10
  const hooks = plan.manifest.hooks ?? [];
10
11
  const detectedVerification = plan.detectedVerification;
@@ -14,6 +15,13 @@ export function formatInstallPlan(plan, trust, warnings = []) {
14
15
  `Scope: ${plan.scope}`,
15
16
  `Harness: ${plan.harness}`,
16
17
  `Profiles: ${plan.profiles.join(", ")}`,
18
+ ...(plan.config.worktree === undefined
19
+ ? []
20
+ : [
21
+ `Worktree: mode=${plan.config.worktree.mode}${plan.config.worktree.setup && plan.config.worktree.setup.length > 0
22
+ ? `, setup=${plan.config.worktree.setup.map((s) => `${s.command} ${s.args.join(" ")}`.trim()).join("; ")}`
23
+ : ""}`
24
+ ]),
17
25
  ...(plan.scope === "user" && plan.harness.includes("agy")
18
26
  ? ["Notice: .gemini/GEMINI.md is a shared Gemini rule surface."]
19
27
  : []),
@@ -85,6 +93,11 @@ export async function runInitCommand(options) {
85
93
  args.profiles.length === 0) {
86
94
  throw new AgentOpsError("INIT_CHOICES_REQUIRED", "Init requires complete scope, harness, and profile choices.");
87
95
  }
96
+ const worktreeConfig = args.worktree === "auto"
97
+ ? { mode: "auto", setup: await detectWorktreeSetup(options.root) }
98
+ : args.worktree === "off"
99
+ ? { mode: "off" }
100
+ : undefined;
88
101
  const plan = await createInstallPlan({
89
102
  root: options.root,
90
103
  scope: args.scope,
@@ -105,7 +118,10 @@ export async function runInitCommand(options) {
105
118
  : { reviewTargets: args.reviewTargets }),
106
119
  ...(args.completionGate === undefined
107
120
  ? {}
108
- : { completionGateEnabled: args.completionGate })
121
+ : { completionGateEnabled: args.completionGate }),
122
+ ...(worktreeConfig === undefined
123
+ ? {}
124
+ : { worktree: worktreeConfig })
109
125
  });
110
126
  const trust = await trustChange(options, plan);
111
127
  // An installation with no verifier looks finished and is not: every task
@@ -121,7 +137,14 @@ export async function runInitCommand(options) {
121
137
  : ` Detection stopped because — ${plan.verificationBlockers.join("; ")}`)
122
138
  ]
123
139
  : [];
124
- const warnings = [...verificationWarnings, ...(plan.harness.includes("agy") && options.agyWarning !== undefined
140
+ const worktreeEnvWarning = plan.config.worktree?.mode === "auto"
141
+ ? await checkWorktreeEnvWarning(options.root)
142
+ : undefined;
143
+ const worktreeWarnings = worktreeEnvWarning === undefined ? [] : [worktreeEnvWarning];
144
+ const warnings = [
145
+ ...verificationWarnings,
146
+ ...worktreeWarnings,
147
+ ...(plan.harness.includes("agy") && options.agyWarning !== undefined
125
148
  ? (() => {
126
149
  try {
127
150
  const warning = options.agyWarning();
@@ -131,7 +154,8 @@ export async function runInitCommand(options) {
131
154
  return ["agy could not be probed; run `agent-ops doctor` to verify it."];
132
155
  }
133
156
  })()
134
- : [])];
157
+ : [])
158
+ ];
135
159
  if (args.dryRun) {
136
160
  return okEnvelope("INIT_PLAN_READY", {
137
161
  applied: false,
@@ -1,3 +1,6 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { detectWorktreeSetup } from "../../../../runtime/src/install/parallel-setup.js";
1
4
  import { applyUpdatePlan, createUpdatePlan } from "../../../../runtime/src/install/update.js";
2
5
  import { okEnvelope } from "../output.js";
3
6
  import { formatOperationPlan } from "../plan-output.js";
@@ -10,6 +13,13 @@ export function formatUpdatePlan(plan, trust) {
10
13
  metadata: [
11
14
  `Target version: ${plan.targetVersion}`,
12
15
  `Harness: ${plan.installation.harness.join(", ")}`,
16
+ ...(plan.installation.config.worktree === undefined
17
+ ? []
18
+ : [
19
+ `Worktree: mode=${plan.installation.config.worktree.mode}${plan.installation.config.worktree.setup && plan.installation.config.worktree.setup.length > 0
20
+ ? `, setup=${plan.installation.config.worktree.setup.map((s) => `${s.command} ${s.args.join(" ")}`.trim()).join("; ")}`
21
+ : ""}`
22
+ ]),
13
23
  ...(trust === undefined ? [] : formatTrustChange(trust)),
14
24
  `Schema migrations: ${plan.migrationSteps.length === 0
15
25
  ? "none"
@@ -47,7 +57,40 @@ async function trustChange(options, plan) {
47
57
  ? { action: "skipped", reason: "no-verification-commands" }
48
58
  : await planTrustGrant(binding, options.trustStore);
49
59
  }
60
+ async function existingConfigHasWorktree(root) {
61
+ try {
62
+ const content = await readFile(join(root, ".agent-ops", "config.json"), "utf8");
63
+ const parsed = JSON.parse(content);
64
+ return parsed.worktree !== undefined;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
50
70
  export async function runUpdateCommand(options) {
71
+ let worktree;
72
+ if (options.args.worktree === "auto") {
73
+ worktree = {
74
+ mode: "auto",
75
+ setup: await detectWorktreeSetup(options.root)
76
+ };
77
+ }
78
+ else if (options.args.worktree === "off") {
79
+ worktree = { mode: "off" };
80
+ }
81
+ else if (options.args.worktree === undefined &&
82
+ options.promptWorktree !== undefined &&
83
+ options.isTTY &&
84
+ !options.args.yes &&
85
+ !(await existingConfigHasWorktree(options.root))) {
86
+ const shouldEnable = await options.promptWorktree("Enable Git worktree isolation for parallel agent sessions?");
87
+ if (shouldEnable) {
88
+ worktree = {
89
+ mode: "auto",
90
+ setup: await detectWorktreeSetup(options.root)
91
+ };
92
+ }
93
+ }
51
94
  const plan = await createUpdatePlan({
52
95
  root: options.root,
53
96
  adapters: options.adapters,
@@ -57,9 +100,9 @@ export async function runUpdateCommand(options) {
57
100
  ...(options.registry === undefined
58
101
  ? {}
59
102
  : { registry: options.registry }),
60
- ...(options.targetVersion === undefined
103
+ ...((options.targetVersion ?? options.args.targetVersion) === undefined
61
104
  ? {}
62
- : { targetVersion: options.targetVersion }),
105
+ : { targetVersion: options.targetVersion ?? options.args.targetVersion }),
63
106
  ...(options.toolkitVersion === undefined
64
107
  ? {}
65
108
  : { toolkitVersion: options.toolkitVersion }),
@@ -68,7 +111,8 @@ export async function runUpdateCommand(options) {
68
111
  : { hookRuntimePath: options.hookRuntimePath }),
69
112
  ...((options.hookTargets ?? options.args.hookTargets) === undefined
70
113
  ? {}
71
- : { hookTargets: options.hookTargets ?? options.args.hookTargets })
114
+ : { hookTargets: options.hookTargets ?? options.args.hookTargets }),
115
+ ...(worktree === undefined ? {} : { worktree })
72
116
  });
73
117
  const trust = await trustChange(options, plan);
74
118
  if (options.args.dryRun) {
@@ -193,6 +193,20 @@ export async function completeInitChoices(args, io, setup = {}) {
193
193
  const reviewTargets = args.reviewTargets ?? (enabled && eligibleReviewChoices.length > 0
194
194
  ? await selectOptions("Review targets (multi-select: tried in listed order)", eligibleReviewChoices, selectorIo, REVIEW_TARGET_ORDER.filter((target) => harness.includes(target)), { startEmpty: true })
195
195
  : []);
196
+ const worktree = args.worktree ?? (scope === "project"
197
+ ? (await selectOption("Enable Git worktree isolation for parallel agent sessions?", [
198
+ {
199
+ label: "yes",
200
+ value: "auto",
201
+ description: "Recommended. Isolates each agent session in a dedicated worktree."
202
+ },
203
+ {
204
+ label: "no",
205
+ value: "off",
206
+ description: "Keep a single shared checkout."
207
+ }
208
+ ], selectorIo))
209
+ : undefined);
196
210
  await probeReviewTargets(reviewTargets, setup);
197
211
  return {
198
212
  ...args,
@@ -200,7 +214,8 @@ export async function completeInitChoices(args, io, setup = {}) {
200
214
  harness,
201
215
  profiles,
202
216
  ...(completionGate ? { completionGate: true } : {}),
203
- ...(reviewTargets.length === 0 ? {} : { reviewTargets })
217
+ ...(reviewTargets.length === 0 ? {} : { reviewTargets }),
218
+ ...(worktree !== undefined ? { worktree } : {})
204
219
  };
205
220
  }
206
221
  const session = await createPromptSession(io);
@@ -220,13 +235,17 @@ export async function completeInitChoices(args, io, setup = {}) {
220
235
  ? selectReviewTargets(await session.question(`Review targets (${defaultReviewTargets.join(",") || "none"}): `), defaultReviewTargets)
221
236
  : []);
222
237
  await probeReviewTargets(reviewTargets, setup);
238
+ const worktree = args.worktree ?? (scope === "project"
239
+ ? (!/^(n|no)$/i.test((await session.question("Enable Git worktree isolation for parallel agent sessions? [Y/n]: ")).trim()) ? "auto" : "off")
240
+ : undefined);
223
241
  return {
224
242
  ...args,
225
243
  scope,
226
244
  harness,
227
245
  profiles,
228
246
  ...(completionGate ? { completionGate: true } : {}),
229
- ...(reviewTargets.length === 0 ? {} : { reviewTargets })
247
+ ...(reviewTargets.length === 0 ? {} : { reviewTargets }),
248
+ ...(worktree !== undefined ? { worktree } : {})
230
249
  };
231
250
  }
232
251
  finally {
@@ -131,7 +131,7 @@ export function buildOpencodePlugin(capabilities, runtimePath) {
131
131
  lines.push(" // opencode initializes plugins once per app, not once per session.", " await runManagedHook($, \"SessionStart\", {", " event: \"SessionStart\",", " projectRoot", " });");
132
132
  }
133
133
  if (events.includes("PreToolUse")) {
134
- lines.push(' hooks["tool.execute.before"] = async (input, output) => {', ' if (input?.tool !== "bash") return;', " await runManagedHook($, \"PreToolUse\", {", " event: \"PreToolUse\",", " projectRoot,", " input,", " output", " });", " };");
134
+ lines.push(' const WRITE_TOOLS = ["bash", "edit", "write", "patch", "apply_patch"];', ' hooks["tool.execute.before"] = async (input, output) => {', ' const tool = typeof input?.tool === "string" ? input.tool.toLowerCase() : "";', " if (!WRITE_TOOLS.includes(tool)) return;", " await runManagedHook($, \"PreToolUse\", {", " event: \"PreToolUse\",", " projectRoot,", " input,", " output", " });", " };");
135
135
  }
136
136
  if (events.includes("Stop")) {
137
137
  lines.push(" hooks.event = async ({ event }) => {", ' if (event?.type !== "session.idle") return;', " await runManagedHook($, \"Stop\", { event: \"Stop\", projectRoot });", " };");
@@ -1,8 +1,54 @@
1
+ import { resolve } from "node:path";
1
2
  import { normalizeHookEvent } from "../../hooks/normalize.js";
2
3
  import { normalizeShellHookEvent } from "../../hooks/shell.js";
4
+ /**
5
+ * opencode tools that write a file without a shell (lowercase tool names).
6
+ * `apply_patch` is the GPT-model replacement for `edit`/`write`; older
7
+ * servers still report `patch`.
8
+ */
9
+ export const OPENCODE_FILE_TOOLS = new Set([
10
+ "edit",
11
+ "write",
12
+ "patch",
13
+ "apply_patch"
14
+ ]);
3
15
  function isRecord(value) {
4
16
  return typeof value === "object" && value !== null && !Array.isArray(value);
5
17
  }
18
+ function sessionIdOf(value) {
19
+ const sessionId = value.sessionID;
20
+ return typeof sessionId === "string" && sessionId.length > 0
21
+ ? sessionId
22
+ : undefined;
23
+ }
24
+ /**
25
+ * Paths embedded in `apply_patch` marker lines, relative to the project root:
26
+ * `*** Add File:`, `*** Update File:`, `*** Move to:`, `*** Delete File:`.
27
+ */
28
+ function patchTextPaths(patchText) {
29
+ const paths = [];
30
+ const marker = /^\*\*\*\s+(?:Add File|Update File|Move to|Delete File):\s*(.+?)\s*$/gmu;
31
+ for (const match of patchText.matchAll(marker)) {
32
+ const target = match[1]?.trim();
33
+ if (target !== undefined && target.length > 0 && !target.includes("\0")) {
34
+ paths.push(target);
35
+ }
36
+ }
37
+ return paths;
38
+ }
39
+ function fileWritePaths(args, projectRoot) {
40
+ const direct = args.filePath ?? args.file_path ?? args.path;
41
+ if (typeof direct === "string" && direct.length > 0 && !direct.includes("\0")) {
42
+ return [resolve(projectRoot, direct)];
43
+ }
44
+ if (typeof args.patchText === "string") {
45
+ const marked = patchTextPaths(args.patchText);
46
+ if (marked.length > 0) {
47
+ return marked.map((target) => resolve(projectRoot, target));
48
+ }
49
+ }
50
+ return null;
51
+ }
6
52
  /**
7
53
  * The managed plugin forwards opencode's own hook payload untouched under
8
54
  * `input`, so every field this adapter reads is documented plugin input rather
@@ -21,7 +67,27 @@ export function normalizeOpencodeHookInput(input) {
21
67
  }
22
68
  if (input.event === "PreToolUse" &&
23
69
  isRecord(input.input) &&
24
- input.input.tool === "bash" &&
70
+ typeof input.input.tool === "string" &&
71
+ OPENCODE_FILE_TOOLS.has(input.input.tool.toLowerCase()) &&
72
+ isRecord(input.output) &&
73
+ isRecord(input.output.args) &&
74
+ typeof projectRoot === "string" &&
75
+ projectRoot.length > 0) {
76
+ const paths = fileWritePaths(input.output.args, projectRoot);
77
+ if (paths !== null) {
78
+ const sessionId = sessionIdOf(input.input);
79
+ return {
80
+ event: "file-write",
81
+ projectRoot,
82
+ paths,
83
+ ...(sessionId === undefined ? {} : { sessionId })
84
+ };
85
+ }
86
+ }
87
+ if (input.event === "PreToolUse" &&
88
+ isRecord(input.input) &&
89
+ typeof input.input.tool === "string" &&
90
+ input.input.tool.toLowerCase() === "bash" &&
25
91
  isRecord(input.output) &&
26
92
  isRecord(input.output.args) &&
27
93
  typeof input.output.args.command === "string" &&
@@ -0,0 +1,63 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ const LOCKFILES = [
4
+ { path: "bun.lock", packageManager: "bun" },
5
+ { path: "bun.lockb", packageManager: "bun" },
6
+ { path: "npm-shrinkwrap.json", packageManager: "npm" },
7
+ { path: "package-lock.json", packageManager: "npm" },
8
+ { path: "pnpm-lock.yaml", packageManager: "pnpm" },
9
+ { path: "yarn.lock", packageManager: "yarn" }
10
+ ];
11
+ const SETUP_COMMANDS = {
12
+ npm: ["ci"],
13
+ pnpm: ["install", "--frozen-lockfile"],
14
+ yarn: ["install", "--immutable"],
15
+ bun: ["install", "--frozen-lockfile"]
16
+ };
17
+ function hasErrorCode(error, code) {
18
+ return error instanceof Error && "code" in error && error.code === code;
19
+ }
20
+ async function regularFileExists(filePath) {
21
+ try {
22
+ const status = await lstat(filePath);
23
+ return status.isFile() && !status.isSymbolicLink();
24
+ }
25
+ catch (error) {
26
+ if (hasErrorCode(error, "ENOENT")) {
27
+ return false;
28
+ }
29
+ throw error;
30
+ }
31
+ }
32
+ /**
33
+ * Detects project lockfiles and proposes safe, deterministic dependency installation
34
+ * commands for freshly cloned worktrees. Returns an empty array if detection is
35
+ * ambiguous or unsupported.
36
+ */
37
+ export async function detectWorktreeSetup(root) {
38
+ const detected = await Promise.all(LOCKFILES.map(async (def) => (await regularFileExists(join(root, def.path))) ? def.packageManager : null));
39
+ const managers = [...new Set(detected.filter((m) => m !== null))];
40
+ if (managers.length !== 1) {
41
+ return [];
42
+ }
43
+ const manager = managers[0];
44
+ const args = SETUP_COMMANDS[manager];
45
+ return [{ command: manager, args: [...args] }];
46
+ }
47
+ /**
48
+ * Returns a warning message if local environment files (.env) are detected
49
+ * without a .worktreeinclude file to carry them into new worktrees.
50
+ */
51
+ export async function checkWorktreeEnvWarning(root) {
52
+ const hasWorktreeInclude = await regularFileExists(join(root, ".worktreeinclude"));
53
+ if (hasWorktreeInclude) {
54
+ return undefined;
55
+ }
56
+ const envCandidates = [".env", ".env.local"];
57
+ for (const candidate of envCandidates) {
58
+ if (await regularFileExists(join(root, candidate))) {
59
+ return "Local environment files (.env) detected without .worktreeinclude; consider creating .worktreeinclude to copy them to new worktrees.";
60
+ }
61
+ }
62
+ return undefined;
63
+ }
@@ -78,7 +78,7 @@ async function detectVerificationCommands(root) {
78
78
  blockers: discovery.decisions.map((decision) => `${decision.adapter}: ${decision.message}`)
79
79
  };
80
80
  }
81
- function buildConfig(profiles, existing, reviewTargets = [], detectedCommands = [], completionGateEnabled = false) {
81
+ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands = [], completionGateEnabled = false, worktree) {
82
82
  // Absent reviewRoles means external review is disabled; an empty selection
83
83
  // must therefore omit the field rather than write an empty array.
84
84
  const reviewRoles = reviewTargets.length > 0
@@ -88,6 +88,9 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
88
88
  existing.verification.commands.length > 0
89
89
  ? existing.verification
90
90
  : { commands: [...detectedCommands] };
91
+ const effectiveWorktree = worktree !== undefined
92
+ ? (worktree === null ? undefined : structuredClone(worktree))
93
+ : (existing?.worktree === undefined ? undefined : structuredClone(existing.worktree));
91
94
  return {
92
95
  schemaVersion: CONFIG_SCHEMA_VERSION,
93
96
  profiles: [...profiles],
@@ -103,12 +106,10 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
103
106
  pathMappings: existing?.pathMappings ?? [],
104
107
  securityExceptions: existing?.securityExceptions ?? [],
105
108
  ...(reviewRoles === undefined ? {} : { reviewRoles: [...reviewRoles] }),
106
- ...(existing?.worktree === undefined
107
- ? {}
108
- : { worktree: structuredClone(existing.worktree) })
109
+ ...(effectiveWorktree === undefined ? {} : { worktree: effectiveWorktree })
109
110
  };
110
111
  }
111
- async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = [], completionGateEnabled = false) {
112
+ async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = [], completionGateEnabled = false, worktree) {
112
113
  const current = await readCurrentFile(root, CONFIG_PATH);
113
114
  const owned = findOwnedArtifact(existingManifest, CONFIG_PATH);
114
115
  if (current !== null && owned === undefined) {
@@ -144,7 +145,7 @@ async function planConfig(root, profiles, existingManifest, suppliedConfig, revi
144
145
  existingConfig.verification.commands.length === 0
145
146
  ? await detectVerificationCommands(root)
146
147
  : { commands: [], blockers: [] };
147
- const config = buildConfig(profiles, existingConfig, reviewTargets, detected.commands, completionGateEnabled);
148
+ const config = buildConfig(profiles, existingConfig, reviewTargets, detected.commands, completionGateEnabled, worktree);
148
149
  const content = `${JSON.stringify(config, null, 2)}\n`;
149
150
  return {
150
151
  operation: {
@@ -493,7 +494,7 @@ export async function createInstallPlan(options) {
493
494
  : [];
494
495
  const operations = [];
495
496
  const artifacts = [];
496
- const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? [], completionGateEnabled);
497
+ const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? [], completionGateEnabled, options.worktree);
497
498
  operations.push(config.operation);
498
499
  artifacts.push(config.record);
499
500
  for (const artifact of contribution.artifacts) {
@@ -123,7 +123,10 @@ export async function createUpdatePlan(options) {
123
123
  existingConfig: {
124
124
  value: configPreview.migrated,
125
125
  sourceHash: configPreview.sourceHash
126
- }
126
+ },
127
+ ...(options.worktree === undefined
128
+ ? {}
129
+ : { worktree: options.worktree })
127
130
  });
128
131
  return {
129
132
  targetVersion,
@@ -56,7 +56,7 @@ ensureWorktree) {
56
56
  action: "block",
57
57
  status: "FAIL",
58
58
  code: "WORKTREE_CREATED",
59
- remedy: `worktree.mode is auto, so this session now has its own worktree at ${worktree}. Enter it (Claude Code: EnterWorktree with path ${worktree}) and redo this edit there, at ${join(worktree, relative(mainRoot, target))}.`
59
+ remedy: `worktree.mode is auto, so this session now has its own worktree at ${worktree}. Enter it (Claude Code: EnterWorktree with path ${worktree}; otherwise use the worktree path as every command's working directory) and redo this edit there, at ${join(worktree, relative(mainRoot, target))}.`
60
60
  };
61
61
  }
62
62
  catch (error) {
@@ -68,7 +68,7 @@ ensureWorktree) {
68
68
  action: "block",
69
69
  status: "FAIL",
70
70
  code: "WORKTREE_REQUIRED",
71
- remedy: `worktree.mode is auto, so the main checkout is shared and not edited directly.${failure} Run \`agent-ops worktree add <name> --session ${session}\` in ${mainRoot}, enter the printed path (Claude Code: EnterWorktree with that path), and edit there.`
71
+ remedy: `worktree.mode is auto, so the main checkout is shared and not edited directly.${failure} Run \`agent-ops worktree add <name> --session ${session}\` in ${mainRoot}, enter the printed path (Claude Code: EnterWorktree with that path; otherwise use it as every command's working directory), and edit there.`
72
72
  };
73
73
  }
74
74
  }
@@ -34,6 +34,30 @@ export async function probeReviewTarget(target, options) {
34
34
  ? await mkdtemp(join(tmpdir(), "agent-ops-review-probe-"))
35
35
  : options.cwd;
36
36
  try {
37
+ // agy's sandbox only answers inside a project-shaped directory: an empty
38
+ // temp dir hangs or denies file access, which this probe then misreads as
39
+ // unauthenticated. Cloning first matches the review attempt, which already
40
+ // runs inside a disposable clone; a failed clone falls back to the plain
41
+ // temp dir, which is exactly today's behavior.
42
+ let invocationCwd = directory;
43
+ if (deep && target === "agy") {
44
+ const clone = join(directory, "repository");
45
+ const cloned = await runVerificationCommand({
46
+ id: "review-probe-clone",
47
+ command: "git",
48
+ args: ["clone", "--no-hardlinks", "--quiet", "--", options.cwd, clone],
49
+ cwd: directory,
50
+ required: true,
51
+ evidence: { kind: "exit-code" },
52
+ timeoutMs: Math.min(probeTimeoutMs(options.timeoutMs), 30_000)
53
+ }, {
54
+ cwd: directory,
55
+ ...(options.runner === undefined ? {} : { runner: options.runner })
56
+ });
57
+ if (cloned.status === "PASS") {
58
+ invocationCwd = clone;
59
+ }
60
+ }
37
61
  const invocation = buildProbeInvocation({
38
62
  target,
39
63
  prompt: PROBE_PROMPT,
@@ -48,12 +72,12 @@ export async function probeReviewTarget(target, options) {
48
72
  id: `review-probe-${target}`,
49
73
  command: invocation.command,
50
74
  args: deep ? [...invocation.args] : ["--version"],
51
- cwd: directory,
75
+ cwd: invocationCwd,
52
76
  required: true,
53
77
  evidence: { kind: "exit-code" },
54
78
  timeoutMs: probeTimeoutMs(options.timeoutMs)
55
79
  }, {
56
- cwd: directory,
80
+ cwd: invocationCwd,
57
81
  ...(options.runner === undefined ? {} : { runner: options.runner }),
58
82
  ...(deep
59
83
  ? {
@@ -149,17 +149,18 @@ evidence. Give each editing conversation its own worktree instead:
149
149
 
150
150
  - `mode: "auto"` routes every editing conversation through
151
151
  `agent-ops worktree add <name> --session <id>`, run from the main checkout.
152
- On Claude Code a direct Edit or Write of the main checkout outside
152
+ On Claude Code and opencode a direct Edit or Write of the main checkout outside
153
153
  `.worktrees/` is denied (run `agent-ops update` once so the PreToolUse hook
154
154
  matches the file tools). Absent or `"off"` keeps today's single checkout.
155
155
  - `agent-ops task create` run from the main checkout with a known session
156
156
  creates that session's worktree first (or reuses it) and records the task
157
157
  there, so the work starts in the worktree. Its output names the path to
158
158
  enter and the `--base` to verify against.
159
- - As a fallback on Claude Code, the first denied edit creates that worktree:
159
+ - As a fallback on Claude Code and opencode, the first denied edit creates that worktree:
160
160
  `.worktrees/session-<first eight characters of the session id>`, setup
161
161
  included, bound to the session. The denial names the path to enter with
162
- EnterWorktree; a later denial for the same session reuses it. If creation
162
+ EnterWorktree on Claude Code, or to use as every command's working directory
163
+ otherwise; a later denial for the same session reuses it. If creation
163
164
  fails, the denial says why and falls back to the manual `worktree add`.
164
165
  Run `agent-ops update` once so the PreToolUse hook gets the 600-second
165
166
  timeout setup needs.
@@ -132,16 +132,17 @@ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方
132
132
  ```
133
133
 
134
134
  - `mode: "auto"` 讓每個修改中的對話都先在主 checkout 執行
135
- `agent-ops worktree add <name> --session <id>`。Claude Code 直接以 Edit 或
135
+ `agent-ops worktree add <name> --session <id>`。Claude Code 與 opencode 直接以 Edit 或
136
136
  Write 修改主 checkout 中 `.worktrees/` 以外的檔案會被拒絕(需先執行一次
137
137
  `agent-ops update`,讓 PreToolUse hook 比對檔案工具)。未設定或 `"off"` 則維持
138
138
  目前的單一 checkout。
139
139
  - 在主 checkout、已知 session 的情況下執行 `agent-ops task create`,會先建立(或重用)
140
140
  該 session 的 worktree,並把 task 記錄在那裡,讓工作一開始就在 worktree 內。輸出會列出
141
141
  要進入的路徑與 verify 用的 `--base`。
142
- - 作為 Claude Code 上的後備,第一次被拒絕的編輯會自動建立該 worktree:
142
+ - 作為 Claude Code 與 opencode 上的後備,第一次被拒絕的編輯會自動建立該 worktree:
143
143
  `.worktrees/session-<session id 前八個字元>`,包含 setup,並綁定該 session。
144
- 拒絕訊息會列出要以 EnterWorktree 進入的路徑;同一 session 之後被拒絕時會重用它。
144
+ 拒絕訊息會列出要進入的路徑(Claude Code 以 EnterWorktree 進入,其他 harness
145
+ 則作為每條命令的工作目錄);同一 session 之後被拒絕時會重用它。
145
146
  建立失敗時,拒絕訊息會說明原因並退回手動 `worktree add`。需先執行一次
146
147
  `agent-ops update`,讓 PreToolUse hook 取得 setup 所需的 600 秒逾時。
147
148
  - `add` 從主 checkout 的 HEAD 建立 `.worktrees/<name>` 與 branch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Evidence-driven development loops for agy, Codex, Claude Code, and opencode",
5
5
  "type": "module",
6
6
  "license": "MIT",