@kylecheng3146/agent-ops 0.3.0 → 0.3.2

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
  }
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { execFile, execFileSync, spawn } from "node:child_process";
6
+ import { execFileSync, spawn } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { commonHarnessAdapters, harnessHookPath, HARNESS_IDS } from "../../../runtime/src/install/harness.js";
9
9
  import { agyRuntimeStatus, agyVersionSupported, hookRegistrationDrift, repositoryTrustStatus, smokeAvailabilityStatus } from "../../../runtime/src/install/probes.js";
@@ -12,8 +12,6 @@ import { readRecordedSessionId } from "../../../runtime/src/hooks/codex-loop.js"
12
12
  import { NpmRegistryClient } from "../../../runtime/src/registry/npm.js";
13
13
  import { TaskService } from "../../../runtime/src/task/service.js";
14
14
  import { FileTaskStore } from "../../../runtime/src/task/store.js";
15
- import { FileTrustStore } from "../../../runtime/src/security/trust.js";
16
- import { localStatePaths } from "../../../runtime/src/security/permissions.js";
17
15
  import { calculateConfigHash } from "../../../runtime/src/config/hash.js";
18
16
  import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
19
17
  import { calculateSourceFingerprint } from "../../../runtime/src/verify/source-fingerprint.js";
@@ -22,6 +20,7 @@ import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn
22
20
  import { runCli } from "./cli.js";
23
21
  import { loadEffectiveConfig, repositoryTrust, repositoryTrustBinding } from "./context.js";
24
22
  import { runHookProcess } from "./hook-process.js";
23
+ import { gitRunner, trustStore, worktreeDependencies } from "./parallel-deps.js";
25
24
  import { selectYesNo, writeBanner } from "./ui.js";
26
25
  import { CLI_VERSION } from "./version.js";
27
26
  import { createCommandRegistry } from "./commands/index.js";
@@ -43,6 +42,7 @@ import { formatUpdatePlan, runUpdateCommand } from "./commands/update.js";
43
42
  import { errorEnvelope } from "./output.js";
44
43
  import { runAgyHeadless } from "./agy-headless.js";
45
44
  import { runWorktreeCommand } from "./commands/parallel.js";
45
+ import { ensureSessionWorktree, resolveCheckouts } from "../../../runtime/src/parallel/service.js";
46
46
  import { listWorktrees, worktreeDoctorResult } from "../../../runtime/src/parallel/manage.js";
47
47
  const HOOK_RUNTIME_PATH = fileURLToPath(new URL("./hook-entry.js", import.meta.url));
48
48
  async function readOptionalText(path) {
@@ -78,29 +78,6 @@ async function installedManifest(root) {
78
78
  async function installedHarness(root) {
79
79
  return (await installedManifest(root))?.harness ?? [...HARNESS_IDS];
80
80
  }
81
- function gitRunner(root) {
82
- return {
83
- run: async (gitArgs) => {
84
- try {
85
- return {
86
- exitCode: 0,
87
- stdout: execFileSync("git", [...gitArgs], {
88
- cwd: root,
89
- encoding: "buffer",
90
- stdio: ["ignore", "pipe", "ignore"]
91
- })
92
- };
93
- }
94
- catch (error) {
95
- const failure = error;
96
- return {
97
- exitCode: failure.status ?? 1,
98
- stdout: failure.stdout ?? new Uint8Array()
99
- };
100
- }
101
- }
102
- };
103
- }
104
81
  async function confirmInit(plan, trust, warnings = []) {
105
82
  writeBanner({
106
83
  isTTY: process.stdout.isTTY === true,
@@ -113,59 +90,12 @@ async function confirmPlan(text) {
113
90
  process.stdout.write(text);
114
91
  return await selectYesNo("Apply this installation plan?", { input: process.stdin, output: process.stdout }, false);
115
92
  }
116
- function trustStore() {
117
- const state = localStatePaths(process.env.AGENT_OPS_HOME ?? homedir());
118
- return new FileTrustStore(state.trustStore, state.anchorDirectory);
119
- }
120
93
  async function plannedTrustBinding(root, projectConfig) {
121
94
  const config = (await loadEffectiveConfig(root, "project", projectConfig)).config;
122
95
  return config.verification.commands.length === 0
123
96
  ? null
124
97
  : await repositoryTrustBinding(root, config, CLI_VERSION);
125
98
  }
126
- function worktreeDependencies() {
127
- const gateFor = async (root, config) => new CompletionGateService({
128
- root,
129
- config,
130
- gitRunner: gitRunner(root),
131
- taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
132
- evidenceStore: new FileEvidenceStore(root, root)
133
- });
134
- return {
135
- git: async (cwd, gitArgs) => await new Promise((resolve) => {
136
- execFile("git", [...gitArgs], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => resolve({
137
- exitCode: error === null ? 0 : typeof error.code === "number" ? error.code : 1,
138
- stdout,
139
- stderr
140
- }));
141
- }),
142
- loadConfig: async (root) => (await loadEffectiveConfig(root, "project")).config,
143
- trust: {
144
- status: async (root, config) => await repositoryTrust(root, config, CLI_VERSION),
145
- grant: async (root, config) => await trustStore().grant(await repositoryTrustBinding(root, config, CLI_VERSION)),
146
- revoke: async (root, config) => {
147
- await trustStore().revoke(await repositoryTrustBinding(root, config, CLI_VERSION));
148
- }
149
- },
150
- gate: gateFor,
151
- tasks: (root) => new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
152
- processRunner: new NodeVerificationProcessRunner(),
153
- runSetup: async (cwd, step) => await new Promise((resolve) => {
154
- execFile(step.command, [...step.args], {
155
- cwd,
156
- encoding: "utf8",
157
- timeout: step.timeoutMs,
158
- maxBuffer: 16 * 1024 * 1024,
159
- ...(process.platform === "win32" ? { shell: true } : {})
160
- }, (error, stdout, stderr) => resolve({
161
- exitCode: error === null
162
- ? 0
163
- : typeof error.code === "number" && error.killed !== true ? error.code : null,
164
- output: `${stdout}${stderr}`
165
- }));
166
- })
167
- };
168
- }
169
99
  async function worktreeDoctorProbe(root) {
170
100
  let statuses;
171
101
  try {
@@ -222,7 +152,7 @@ else if (argv[0] === "hook") {
222
152
  stdin: process.stdin,
223
153
  writeStdout: (value) => process.stdout.write(value),
224
154
  writeStderr: (value) => process.stderr.write(value)
225
- }, CLI_VERSION);
155
+ }, CLI_VERSION, { worktree: worktreeDependencies() });
226
156
  }
227
157
  else {
228
158
  process.exitCode = await runCli(argv, {
@@ -418,6 +348,7 @@ else {
418
348
  trustStore: store,
419
349
  calculateTrustBinding: async (config) => await plannedTrustBinding(root, config),
420
350
  confirm: async (plan, trust) => await confirmPlan(formatUpdatePlan(plan, trust)),
351
+ promptWorktree: async (message) => await selectYesNo(message, { input: process.stdin, output: process.stdout }, true),
421
352
  ...(updateArgs.targetVersion === undefined
422
353
  ? {}
423
354
  : { targetVersion: updateArgs.targetVersion })
@@ -448,14 +379,40 @@ else {
448
379
  // run inside a session is never told which session it is in.
449
380
  const sessionId = process.env.AGENT_OPS_SESSION_ID ??
450
381
  await readRecordedSessionId(root);
451
- const policyConfigHash = args.action === "create"
452
- ? calculateConfigHash((await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config)
382
+ const createConfig = args.action === "create"
383
+ ? (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config
453
384
  : undefined;
385
+ const policyConfigHash = createConfig === undefined
386
+ ? undefined
387
+ : calculateConfigHash(createConfig);
388
+ // Auto mode starts the work where it belongs: a task created from
389
+ // the main checkout lands in the session's own worktree.
390
+ const worktreeDeps = worktreeDependencies();
391
+ const fromMain = createConfig?.worktree?.mode === "auto" &&
392
+ args.scope !== "user" &&
393
+ await resolveCheckouts(worktreeDeps, root)
394
+ .then(({ mainRoot, currentRoot }) => mainRoot === currentRoot)
395
+ .catch(() => false);
454
396
  return await runTaskCommand({
455
397
  args,
456
398
  service: taskService,
457
399
  ...(policyConfigHash === undefined ? {} : { policyConfigHash }),
458
- ...(sessionId === undefined ? {} : { sessionId })
400
+ ...(sessionId === undefined ? {} : { sessionId }),
401
+ ...(fromMain
402
+ ? {
403
+ sessionWorktree: async (session) => {
404
+ const record = await ensureSessionWorktree(worktreeDeps, {
405
+ cwd: root,
406
+ sessionId: session
407
+ });
408
+ return {
409
+ path: record.path,
410
+ base: record.base,
411
+ service: new TaskService(new FileTaskStore(join(record.path, ".agent-ops", "tasks", "state.json"), record.path))
412
+ };
413
+ }
414
+ }
415
+ : {})
459
416
  });
460
417
  }
461
418
  if (args.command === "review") {
@@ -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,
@@ -28,16 +28,17 @@ function addText(result) {
28
28
  "Work only inside that path from now on:",
29
29
  `- Claude Code: EnterWorktree with path ${record.path}`,
30
30
  `- Codex and agy: run every command with ${record.path} as its working directory`,
31
- `Finish with: commit, agent-ops verify/review/task complete with --base ${record.base},`,
32
- `then agent-ops worktree finish ${record.name} from ${record.mainRoot}`,
33
- "(Claude Code: ExitWorktree with action keep before finishing)."
31
+ `Finish with: commit, agent-ops verify and review with --base ${record.base},`,
32
+ `then agent-ops worktree finish ${record.name} from ${record.mainRoot}, which`,
33
+ "completes every task in the worktree and merges",
34
+ "(Claude Code: ExitWorktree with action keep before finishing; do not run task complete in the worktree)."
34
35
  ].join("\n");
35
36
  }
36
37
  function finishText(result) {
37
38
  const { record } = result;
38
39
  return [
39
40
  `Merged ${record.branch} into ${record.targetBranch} at ${result.mergedHead}${result.rebased ? " after a clean rebase and re-verification" : ""}.`,
40
- `Task ${result.taskId} is recorded in git notes (refs/notes/agent-ops).`,
41
+ `Completed and recorded in git notes (refs/notes/agent-ops): ${result.taskIds.join(", ")}.`,
41
42
  `Removed ${record.path}.`,
42
43
  ...result.warnings.map((warning) => `Warning: ${warning}`)
43
44
  ].join("\n");
@@ -273,7 +273,7 @@ async function reusableReview(options, sourceFingerprint, context) {
273
273
  if (options.root === undefined) {
274
274
  return null;
275
275
  }
276
- const attestation = await findReviewAttestation(options.root, sourceFingerprint);
276
+ const attestation = await findReviewAttestation(options.root, sourceFingerprint, context?.taskId);
277
277
  if (attestation === null || attestation.taskId !== context?.taskId) {
278
278
  return null;
279
279
  }
@@ -284,7 +284,7 @@ async function reusableReview(options, sourceFingerprint, context) {
284
284
  context?.policyConfigHash !== options.policyConfigHash) {
285
285
  return null;
286
286
  }
287
- const artifact = await readReviewReportArtifact(options.root, sourceFingerprint);
287
+ const artifact = await readReviewReportArtifact(options.root, sourceFingerprint, attestation.taskId);
288
288
  if (artifact === null ||
289
289
  artifact.taskId !== attestation.taskId ||
290
290
  artifact.report === undefined ||
@@ -488,7 +488,7 @@ export async function runReviewCommand(options) {
488
488
  })
489
489
  });
490
490
  }
491
- await invalidateReviewAttestation(options.root, sourceFingerprint);
491
+ await invalidateReviewAttestation(options.root, sourceFingerprint, context?.taskId);
492
492
  pendingInvalidation = false;
493
493
  }
494
494
  }
@@ -605,7 +605,9 @@ export async function runReviewCommand(options) {
605
605
  await options.tasks.recordEvidence(context.taskId, Object.fromEntries(finalResult.results.map((item) => [
606
606
  item.criterionId,
607
607
  item.evidence.map((reference) => `review:${finalResult.harness}:${reference}`)
608
- ])));
608
+ ])),
609
+ // What `worktree finish` completes this task against.
610
+ scope?.mode === "base" ? scope.resolvedBase : null);
609
611
  }
610
612
  const message = finalResult.status === "PASS"
611
613
  ? "Independent review passed."
@@ -87,7 +87,10 @@ export async function runTaskCommand(options) {
87
87
  if (options.args.title === undefined) {
88
88
  throw new AgentOpsError("TASK_TITLE_REQUIRED", "Task creation requires --title.");
89
89
  }
90
- const record = await options.service.create({
90
+ const worktree = sessionId === undefined || options.sessionWorktree === undefined
91
+ ? undefined
92
+ : await options.sessionWorktree(sessionId);
93
+ const record = await (worktree?.service ?? options.service).create({
91
94
  title: options.args.title,
92
95
  criteria: (options.args.criteria ?? []).map(parseCriterion),
93
96
  ...(options.policyConfigHash === undefined
@@ -98,9 +101,30 @@ export async function runTaskCommand(options) {
98
101
  : { parentTaskId: options.args.parentTaskId }),
99
102
  ...(sessionId === undefined ? {} : { sessionId })
100
103
  });
101
- return taskEnvelope(action, "TASK_CREATED", sessionId === undefined
104
+ const created = taskEnvelope(action, "TASK_CREATED", sessionId === undefined
102
105
  ? `Created task ${record.task.id}.`
103
- : `Created and attached task ${record.task.id}.`, record);
106
+ : record.task.parentTaskId === undefined
107
+ ? `Created and attached task ${record.task.id}.`
108
+ : `Created subtask ${record.task.id}; the session stays on the top of its task tree.`, record);
109
+ if (worktree === undefined || created.data === null)
110
+ return created;
111
+ return {
112
+ ...created,
113
+ data: {
114
+ ...created.data,
115
+ text: [
116
+ `Created in this session's worktree ${worktree.path}.`,
117
+ "Work only inside that path from now on:",
118
+ `- Claude Code: EnterWorktree with path ${worktree.path}`,
119
+ `- Codex and agy: run every command with ${worktree.path} as its working directory`,
120
+ `Commit, then run verify and review there with --base ${worktree.base};`,
121
+ "then leave the worktree (Claude Code: ExitWorktree with action keep) and run",
122
+ "agent-ops worktree finish from the main checkout, which completes the task and merges.",
123
+ "",
124
+ created.data.text
125
+ ].join("\n")
126
+ }
127
+ };
104
128
  }
105
129
  if (action === "status") {
106
130
  if (options.args.taskId === undefined && sessionId === undefined) {
@@ -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) {
@@ -3,8 +3,9 @@
3
3
  // node <this file> <harness> <event> --managed-by=agent-ops
4
4
  import { CLI_VERSION } from "./version.js";
5
5
  import { runHookProcess } from "./hook-process.js";
6
+ import { worktreeDependencies } from "./parallel-deps.js";
6
7
  process.exitCode = await runHookProcess(process.argv.slice(2), {
7
8
  stdin: process.stdin,
8
9
  writeStdout: (value) => process.stdout.write(value),
9
10
  writeStderr: (value) => process.stderr.write(value)
10
- }, CLI_VERSION);
11
+ }, CLI_VERSION, { worktree: worktreeDependencies() });
@@ -10,6 +10,7 @@ import { redactSecrets } from "../../../runtime/src/security/redact.js";
10
10
  import { claudeStopRecursionMarker } from "../../../runtime/src/adapters/claude/input.js";
11
11
  import { HARNESS_IDS, harnessDescriptor } from "../../../runtime/src/install/harness.js";
12
12
  import { evaluateWorktreeWrite, resolveMainRoot } from "../../../runtime/src/parallel/guard.js";
13
+ import { ensureSessionWorktree } from "../../../runtime/src/parallel/service.js";
13
14
  import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
14
15
  import { TaskService } from "../../../runtime/src/task/service.js";
15
16
  import { FileTaskStore } from "../../../runtime/src/task/store.js";
@@ -348,7 +349,9 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
348
349
  const mainRoot = await resolveMainRoot(gitRunner);
349
350
  return mainRoot === null
350
351
  ? { action: "continue", status: "UNKNOWN", code: "WORKTREE_GUARD_UNAVAILABLE" }
351
- : await evaluateWorktreeWrite(mainRoot, write.paths, write.sessionId);
352
+ : await evaluateWorktreeWrite(mainRoot, write.paths, write.sessionId, dependencies.worktree === undefined
353
+ ? undefined
354
+ : async (sessionId) => (await ensureSessionWorktree(dependencies.worktree, { cwd: mainRoot, sessionId })).path);
352
355
  }
353
356
  : undefined;
354
357
  const output = await runHookCommand({
@@ -0,0 +1,89 @@
1
+ import { execFile, execFileSync } from "node:child_process";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
5
+ import { localStatePaths } from "../../../runtime/src/security/permissions.js";
6
+ import { FileTrustStore } from "../../../runtime/src/security/trust.js";
7
+ import { TaskService } from "../../../runtime/src/task/service.js";
8
+ import { FileTaskStore } from "../../../runtime/src/task/store.js";
9
+ import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
10
+ import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
11
+ import { loadEffectiveConfig, repositoryTrust, repositoryTrustBinding } from "./context.js";
12
+ import { CLI_VERSION } from "./version.js";
13
+ // Shared by the CLI and the hook entry point: Claude Code runs hooks through
14
+ // hook-entry.js, never bin.js, and its worktree guard needs these too.
15
+ export function gitRunner(root) {
16
+ return {
17
+ run: async (gitArgs) => {
18
+ try {
19
+ return {
20
+ exitCode: 0,
21
+ stdout: execFileSync("git", [...gitArgs], {
22
+ cwd: root,
23
+ encoding: "buffer",
24
+ stdio: ["ignore", "pipe", "ignore"]
25
+ })
26
+ };
27
+ }
28
+ catch (error) {
29
+ const failure = error;
30
+ return {
31
+ exitCode: failure.status ?? 1,
32
+ stdout: failure.stdout ?? new Uint8Array()
33
+ };
34
+ }
35
+ }
36
+ };
37
+ }
38
+ export function trustStore() {
39
+ const state = localStatePaths(process.env.AGENT_OPS_HOME ?? homedir());
40
+ return new FileTrustStore(state.trustStore, state.anchorDirectory);
41
+ }
42
+ export function worktreeDependencies() {
43
+ const gateFor = async (root, config) => new CompletionGateService({
44
+ root,
45
+ config,
46
+ gitRunner: gitRunner(root),
47
+ taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
48
+ evidenceStore: new FileEvidenceStore(root, root)
49
+ });
50
+ return {
51
+ git: async (cwd, gitArgs) => await new Promise((resolve) => {
52
+ execFile("git", [...gitArgs], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => resolve({
53
+ exitCode: error === null ? 0 : typeof error.code === "number" ? error.code : 1,
54
+ stdout,
55
+ stderr
56
+ }));
57
+ }),
58
+ loadConfig: async (root) => (await loadEffectiveConfig(root, "project")).config,
59
+ trust: {
60
+ status: async (root, config) => await repositoryTrust(root, config, CLI_VERSION),
61
+ grant: async (root, config) => await trustStore().grant(await repositoryTrustBinding(root, config, CLI_VERSION)),
62
+ revoke: async (root, config) => {
63
+ await trustStore().revoke(await repositoryTrustBinding(root, config, CLI_VERSION));
64
+ }
65
+ },
66
+ gate: gateFor,
67
+ tasks: (root, base) => new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root), base === undefined ? {} : { completion: {
68
+ root,
69
+ gitRunner: gitRunner(root),
70
+ base,
71
+ loadConfig: async () => (await loadEffectiveConfig(root, "project")).config
72
+ } }),
73
+ processRunner: new NodeVerificationProcessRunner(),
74
+ runSetup: async (cwd, step) => await new Promise((resolve) => {
75
+ execFile(step.command, [...step.args], {
76
+ cwd,
77
+ encoding: "utf8",
78
+ timeout: step.timeoutMs,
79
+ maxBuffer: 16 * 1024 * 1024,
80
+ ...(process.platform === "win32" ? { shell: true } : {})
81
+ }, (error, stdout, stderr) => resolve({
82
+ exitCode: error === null
83
+ ? 0
84
+ : typeof error.code === "number" && error.killed !== true ? error.code : null,
85
+ output: `${stdout}${stderr}`
86
+ }));
87
+ })
88
+ };
89
+ }