@kylecheng3146/agent-ops 0.2.8 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/packages/cli/src/args.js +48 -2
  2. package/dist/packages/cli/src/bin.js +55 -35
  3. package/dist/packages/cli/src/cli.js +31 -0
  4. package/dist/packages/cli/src/commands/hook.js +4 -1
  5. package/dist/packages/cli/src/commands/parallel.js +88 -0
  6. package/dist/packages/cli/src/commands/review.js +6 -4
  7. package/dist/packages/cli/src/commands/task.js +27 -3
  8. package/dist/packages/cli/src/hook-entry.js +2 -1
  9. package/dist/packages/cli/src/hook-process.js +34 -7
  10. package/dist/packages/cli/src/parallel-deps.js +89 -0
  11. package/dist/runtime/src/adapters/agy/output.js +2 -1
  12. package/dist/runtime/src/adapters/claude/config.js +6 -2
  13. package/dist/runtime/src/adapters/claude/input.js +24 -0
  14. package/dist/runtime/src/adapters/claude/output.js +2 -1
  15. package/dist/runtime/src/config/merge.js +10 -2
  16. package/dist/runtime/src/hooks/codex-loop.js +11 -1
  17. package/dist/runtime/src/hooks/completion-gate.js +141 -7
  18. package/dist/runtime/src/hooks/dispatch.js +5 -0
  19. package/dist/runtime/src/install/doctor.js +4 -1
  20. package/dist/runtime/src/install/harness.js +1 -1
  21. package/dist/runtime/src/install/plan.js +4 -1
  22. package/dist/runtime/src/parallel/finish.js +303 -0
  23. package/dist/runtime/src/parallel/guard.js +76 -0
  24. package/dist/runtime/src/parallel/manage.js +147 -0
  25. package/dist/runtime/src/parallel/service.js +281 -0
  26. package/dist/runtime/src/review/attestation.js +125 -50
  27. package/dist/runtime/src/review/execute.js +32 -5
  28. package/dist/runtime/src/schema/validate.js +50 -1
  29. package/dist/runtime/src/task/completion.js +1 -1
  30. package/dist/runtime/src/task/service.js +20 -4
  31. package/dist/runtime/src/task/store.js +7 -2
  32. package/docs/en/guides/configuration.md +63 -0
  33. package/docs/en/spec/delegation.md +10 -0
  34. package/docs/zh-TW/guides/configuration.md +55 -0
  35. package/docs/zh-TW/spec/delegation.md +11 -1
  36. package/package.json +1 -1
  37. package/schemas/config.schema.json +35 -0
@@ -9,13 +9,15 @@ export const COMMAND_NAMES = [
9
9
  "task",
10
10
  "verify",
11
11
  "review",
12
- "allow-stop"
12
+ "allow-stop",
13
+ "worktree"
13
14
  ];
14
15
  const COMMAND_SET = new Set(COMMAND_NAMES);
15
16
  const SCOPES = new Set(["project", "user"]);
16
17
  const PROFILES = new Set(["advisory", "core", "guardrails", "loop"]);
17
18
  // opencode is absent: it has no read-only flag, so it cannot review.
18
19
  const REVIEW_TARGETS = new Set(["agy", "claude", "codex"]);
20
+ export const WORKTREE_ACTIONS = ["add", "finish", "list", "resume", "remove"];
19
21
  export class CliArgumentError extends Error {
20
22
  code;
21
23
  option;
@@ -82,6 +84,8 @@ export function parseArgs(argv) {
82
84
  let rerun = false;
83
85
  let helpSeen = false;
84
86
  let versionSeen = false;
87
+ let worktreeName;
88
+ let force = false;
85
89
  for (let index = 0; index < argv.length; index += 1) {
86
90
  const token = argv[index];
87
91
  if (token === undefined) {
@@ -243,6 +247,12 @@ export function parseArgs(argv) {
243
247
  }
244
248
  yes = true;
245
249
  break;
250
+ case "--force":
251
+ if (force) {
252
+ duplicate(token);
253
+ }
254
+ force = true;
255
+ break;
246
256
  case "--rerun":
247
257
  if (rerun) {
248
258
  duplicate(token);
@@ -292,6 +302,16 @@ export function parseArgs(argv) {
292
302
  action = token;
293
303
  break;
294
304
  }
305
+ if (command === "worktree" &&
306
+ action === undefined &&
307
+ WORKTREE_ACTIONS.includes(token)) {
308
+ action = token;
309
+ break;
310
+ }
311
+ if (command === "worktree" && action !== undefined && action !== "list" && worktreeName === undefined) {
312
+ worktreeName = token;
313
+ break;
314
+ }
295
315
  if (command !== undefined) {
296
316
  throw new CliArgumentError("CLI_UNEXPECTED_ARGUMENT", `Unexpected argument: ${token}`);
297
317
  }
@@ -346,7 +366,8 @@ export function parseArgs(argv) {
346
366
  checkAuthTargets.length > 0 ||
347
367
  dryRun ||
348
368
  yes ||
349
- rerun) {
369
+ rerun ||
370
+ force) {
350
371
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Only --json may be combined with global help or version.");
351
372
  }
352
373
  command = helpSeen ? "help" : "version";
@@ -357,6 +378,12 @@ export function parseArgs(argv) {
357
378
  if (command === "trust" && action === undefined) {
358
379
  throw new CliArgumentError("CLI_ACTION_REQUIRED", "The trust command requires one of: status, grant, revoke.");
359
380
  }
381
+ if (command === "worktree" && action === undefined) {
382
+ throw new CliArgumentError("CLI_ACTION_REQUIRED", `The worktree command requires one of: ${WORKTREE_ACTIONS.join(", ")}.`);
383
+ }
384
+ if (command === "worktree" && action !== "list" && worktreeName === undefined) {
385
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", `worktree ${action} requires a worktree name.`);
386
+ }
360
387
  if (command === "task" && action === undefined) {
361
388
  throw new CliArgumentError("CLI_ACTION_REQUIRED", "The task command requires one of: create, status, attach, complete, archive, export.");
362
389
  }
@@ -369,6 +396,7 @@ export function parseArgs(argv) {
369
396
  command !== "verify" &&
370
397
  command !== "review" &&
371
398
  command !== "allow-stop" &&
399
+ !(command === "worktree" && sessionId !== undefined && taskId === undefined) &&
372
400
  (hasTaskTargetOptions || hasTaskMutationOptions)) {
373
401
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Task target options may be used only with task or verify.");
374
402
  }
@@ -483,9 +511,27 @@ export function parseArgs(argv) {
483
511
  yes)) {
484
512
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "allow-stop requires only --session (and optional --json).");
485
513
  }
514
+ if (command === "worktree" &&
515
+ (scope !== undefined ||
516
+ harness !== undefined ||
517
+ profiles.length > 0 ||
518
+ hasTaskMutationOptions ||
519
+ dryRun ||
520
+ yes ||
521
+ rerun)) {
522
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "worktree accepts only a name, --session and --json.");
523
+ }
524
+ if (force && !(command === "worktree" && action === "remove")) {
525
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--force may be used only with worktree remove.");
526
+ }
527
+ if (command === "worktree" && action !== "add" && action !== "resume" && sessionId !== undefined) {
528
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", `worktree ${action} takes the session from the worktree; omit --session.`);
529
+ }
486
530
  return {
487
531
  command,
488
532
  ...(action === undefined ? {} : { action }),
533
+ ...(worktreeName === undefined ? {} : { worktreeName }),
534
+ ...(force ? { force } : {}),
489
535
  ...(scope === undefined ? {} : { scope }),
490
536
  ...(harness === undefined ? {} : { harness }),
491
537
  ...(hookTargets.length === 0 ? {} : { hookTargets }),
@@ -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";
@@ -42,6 +41,9 @@ import { CompletionGateService } from "../../../runtime/src/hooks/completion-gat
42
41
  import { formatUpdatePlan, runUpdateCommand } from "./commands/update.js";
43
42
  import { errorEnvelope } from "./output.js";
44
43
  import { runAgyHeadless } from "./agy-headless.js";
44
+ import { runWorktreeCommand } from "./commands/parallel.js";
45
+ import { ensureSessionWorktree, resolveCheckouts } from "../../../runtime/src/parallel/service.js";
46
+ import { listWorktrees, worktreeDoctorResult } from "../../../runtime/src/parallel/manage.js";
45
47
  const HOOK_RUNTIME_PATH = fileURLToPath(new URL("./hook-entry.js", import.meta.url));
46
48
  async function readOptionalText(path) {
47
49
  try {
@@ -76,29 +78,6 @@ async function installedManifest(root) {
76
78
  async function installedHarness(root) {
77
79
  return (await installedManifest(root))?.harness ?? [...HARNESS_IDS];
78
80
  }
79
- function gitRunner(root) {
80
- return {
81
- run: async (gitArgs) => {
82
- try {
83
- return {
84
- exitCode: 0,
85
- stdout: execFileSync("git", [...gitArgs], {
86
- cwd: root,
87
- encoding: "buffer",
88
- stdio: ["ignore", "pipe", "ignore"]
89
- })
90
- };
91
- }
92
- catch (error) {
93
- const failure = error;
94
- return {
95
- exitCode: failure.status ?? 1,
96
- stdout: failure.stdout ?? new Uint8Array()
97
- };
98
- }
99
- }
100
- };
101
- }
102
81
  async function confirmInit(plan, trust, warnings = []) {
103
82
  writeBanner({
104
83
  isTTY: process.stdout.isTTY === true,
@@ -111,16 +90,22 @@ async function confirmPlan(text) {
111
90
  process.stdout.write(text);
112
91
  return await selectYesNo("Apply this installation plan?", { input: process.stdin, output: process.stdout }, false);
113
92
  }
114
- function trustStore() {
115
- const state = localStatePaths(process.env.AGENT_OPS_HOME ?? homedir());
116
- return new FileTrustStore(state.trustStore, state.anchorDirectory);
117
- }
118
93
  async function plannedTrustBinding(root, projectConfig) {
119
94
  const config = (await loadEffectiveConfig(root, "project", projectConfig)).config;
120
95
  return config.verification.commands.length === 0
121
96
  ? null
122
97
  : await repositoryTrustBinding(root, config, CLI_VERSION);
123
98
  }
99
+ async function worktreeDoctorProbe(root) {
100
+ let statuses;
101
+ try {
102
+ statuses = await listWorktrees(worktreeDependencies(), root);
103
+ }
104
+ catch {
105
+ return { status: "PASS", message: "No agent-ops worktrees to inspect." };
106
+ }
107
+ return worktreeDoctorResult(statuses, Date.now());
108
+ }
124
109
  const argv = process.argv.slice(2);
125
110
  function runAgy(args, options = {}) {
126
111
  return execFileSync("agy", [...args], {
@@ -167,7 +152,7 @@ else if (argv[0] === "hook") {
167
152
  stdin: process.stdin,
168
153
  writeStdout: (value) => process.stdout.write(value),
169
154
  writeStderr: (value) => process.stderr.write(value)
170
- }, CLI_VERSION);
155
+ }, CLI_VERSION, { worktree: worktreeDependencies() });
171
156
  }
172
157
  else {
173
158
  process.exitCode = await runCli(argv, {
@@ -188,7 +173,8 @@ else {
188
173
  "task",
189
174
  "verify",
190
175
  "review",
191
- "allow-stop"
176
+ "allow-stop",
177
+ "worktree"
192
178
  ].map((command) => [command, async (args) => {
193
179
  const root = args.scope === "user"
194
180
  ? process.env.AGENT_OPS_HOME ?? homedir()
@@ -224,6 +210,13 @@ else {
224
210
  confirm: async (plan, trust, warnings) => await confirmInit(plan, trust, warnings)
225
211
  });
226
212
  }
213
+ if (args.command === "worktree") {
214
+ return await runWorktreeCommand({
215
+ args,
216
+ cwd: root,
217
+ deps: worktreeDependencies()
218
+ });
219
+ }
227
220
  if (args.command === "doctor") {
228
221
  const doctorManifest = await installedManifest(root);
229
222
  const config = (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config;
@@ -307,7 +300,8 @@ else {
307
300
  }
308
301
  : { status };
309
302
  },
310
- reviewTarget: async (target, deep) => await probeReviewTarget(target, { cwd: root, deep })
303
+ reviewTarget: async (target, deep) => await probeReviewTarget(target, { cwd: root, deep }),
304
+ worktrees: async () => await worktreeDoctorProbe(root)
311
305
  },
312
306
  ...(args.checkAuth === true
313
307
  ? { checkReviewTargetAuth: true }
@@ -384,14 +378,40 @@ else {
384
378
  // run inside a session is never told which session it is in.
385
379
  const sessionId = process.env.AGENT_OPS_SESSION_ID ??
386
380
  await readRecordedSessionId(root);
387
- const policyConfigHash = args.action === "create"
388
- ? calculateConfigHash((await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config)
381
+ const createConfig = args.action === "create"
382
+ ? (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config
389
383
  : undefined;
384
+ const policyConfigHash = createConfig === undefined
385
+ ? undefined
386
+ : calculateConfigHash(createConfig);
387
+ // Auto mode starts the work where it belongs: a task created from
388
+ // the main checkout lands in the session's own worktree.
389
+ const worktreeDeps = worktreeDependencies();
390
+ const fromMain = createConfig?.worktree?.mode === "auto" &&
391
+ args.scope !== "user" &&
392
+ await resolveCheckouts(worktreeDeps, root)
393
+ .then(({ mainRoot, currentRoot }) => mainRoot === currentRoot)
394
+ .catch(() => false);
390
395
  return await runTaskCommand({
391
396
  args,
392
397
  service: taskService,
393
398
  ...(policyConfigHash === undefined ? {} : { policyConfigHash }),
394
- ...(sessionId === undefined ? {} : { sessionId })
399
+ ...(sessionId === undefined ? {} : { sessionId }),
400
+ ...(fromMain
401
+ ? {
402
+ sessionWorktree: async (session) => {
403
+ const record = await ensureSessionWorktree(worktreeDeps, {
404
+ cwd: root,
405
+ sessionId: session
406
+ });
407
+ return {
408
+ path: record.path,
409
+ base: record.base,
410
+ service: new TaskService(new FileTaskStore(join(record.path, ".agent-ops", "tasks", "state.json"), record.path))
411
+ };
412
+ }
413
+ }
414
+ : {})
395
415
  });
396
416
  }
397
417
  if (args.command === "review") {
@@ -29,6 +29,8 @@ Commands:
29
29
  review Run an independent review
30
30
  allow-stop Grant one fingerprint-bound completion-gate Stop permit (requires --session)
31
31
  agy-run Run headless agy with a process-exit completion recheck
32
+ worktree <add|finish|list|resume|remove>
33
+ Give one session its own Git worktree for parallel work
32
34
 
33
35
  Options:
34
36
  --scope <project|user>
@@ -179,6 +181,35 @@ agent cannot self-authorize it.
179
181
  Options:
180
182
  --session <id> Required
181
183
  --json
184
+ `,
185
+ worktree: `Usage: agent-ops worktree <add|finish|resume|remove> <name> [options]
186
+ agent-ops worktree list [--json]
187
+
188
+ Give one writing session its own Git worktree, so parallel sessions stop
189
+ voiding each other's verification and review. Run it from the main checkout.
190
+
191
+ add <name> Create .worktrees/<name> on branch agent-ops/<name> from HEAD,
192
+ copy the ignored agent-ops files and .worktreeinclude matches,
193
+ inherit trust for an identical config, run worktree.setup, and
194
+ point the session's completion gate at the new worktree.
195
+ finish <name>
196
+ Once its task is complete, fast-forward the main checkout's
197
+ branch to the worktree's (after a clean rebase and
198
+ re-verification if the branch moved), record the task in
199
+ git notes, and remove the worktree. One finish runs at a time.
200
+ list Every agent-ops worktree: session, commits ahead, uncommitted
201
+ changes, task status and last activity.
202
+ resume <name>
203
+ Hand a worktree whose session ended to --session <id>.
204
+ remove <name>
205
+ Delete the worktree and its branch. Uncommitted or unmerged
206
+ work needs --force, which the completion gate asks the user
207
+ about.
208
+
209
+ Options:
210
+ --session <id> add, resume: the conversation that will work there (required)
211
+ --force remove: discard uncommitted or unmerged work
212
+ --json
182
213
  `
183
214
  };
184
215
  function wantsJson(argv) {
@@ -47,7 +47,10 @@ export async function runHookCommand(options) {
47
47
  ...(stopVerification === undefined ? {} : { stopVerification }),
48
48
  ...(options.completionGate === undefined
49
49
  ? {}
50
- : { completionGate: options.completionGate })
50
+ : { completionGate: options.completionGate }),
51
+ ...(options.worktreeGuard === undefined
52
+ ? {}
53
+ : { worktreeGuard: options.worktreeGuard })
51
54
  });
52
55
  return descriptor.runtime.formatOutput(options.event, result);
53
56
  }
@@ -0,0 +1,88 @@
1
+ import { okEnvelope } from "../output.js";
2
+ import { addWorktree } from "../../../../runtime/src/parallel/service.js";
3
+ import { finishWorktree } from "../../../../runtime/src/parallel/finish.js";
4
+ import { idleWorktrees, listWorktrees, removeWorktree, resumeWorktree } from "../../../../runtime/src/parallel/manage.js";
5
+ function listText(statuses, now) {
6
+ if (statuses.length === 0)
7
+ return "No agent-ops worktrees.";
8
+ const idle = new Set(idleWorktrees(statuses, now).map(({ record }) => record.name));
9
+ return statuses.map(({ record, dirty, ahead, taskStatus, lastActivity }) => [
10
+ `${record.name}${idle.has(record.name) ? " (idle)" : ""}`,
11
+ ` path: ${record.path}`,
12
+ ` session: ${record.sessionId}`,
13
+ ` branch: ${record.branch}, ${ahead} commit(s) ahead of ${record.targetBranch}${dirty ? ", uncommitted changes" : ""}`,
14
+ ` task: ${taskStatus}`,
15
+ ` last activity: ${lastActivity}`
16
+ ].join("\n")).join("\n");
17
+ }
18
+ function addText(result) {
19
+ const { record } = result;
20
+ return [
21
+ `Created worktree ${record.name} for session ${record.sessionId}.`,
22
+ `Path: ${record.path}`,
23
+ `Branch: ${record.branch} (from ${record.targetBranch} at ${record.base})`,
24
+ `Copied: ${result.copied.length === 0 ? "nothing" : result.copied.join(", ")}`,
25
+ `Trust: ${result.trusted ? "inherited" : "not inherited; run agent-ops trust grant inside the worktree"}`,
26
+ ...(result.setup.length === 0 ? [] : [`Setup: ${result.setup.join("; ")}`]),
27
+ "",
28
+ "Work only inside that path from now on:",
29
+ `- Claude Code: EnterWorktree with path ${record.path}`,
30
+ `- Codex and agy: run every command with ${record.path} as its working directory`,
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)."
35
+ ].join("\n");
36
+ }
37
+ function finishText(result) {
38
+ const { record } = result;
39
+ return [
40
+ `Merged ${record.branch} into ${record.targetBranch} at ${result.mergedHead}${result.rebased ? " after a clean rebase and re-verification" : ""}.`,
41
+ `Completed and recorded in git notes (refs/notes/agent-ops): ${result.taskIds.join(", ")}.`,
42
+ `Removed ${record.path}.`,
43
+ ...result.warnings.map((warning) => `Warning: ${warning}`)
44
+ ].join("\n");
45
+ }
46
+ export async function runWorktreeCommand(options) {
47
+ const { args } = options;
48
+ if (args.action === "list") {
49
+ const worktrees = await listWorktrees(options.deps, options.cwd);
50
+ return okEnvelope("WORKTREE_LIST", { worktrees, text: listText(worktrees, Date.now()) });
51
+ }
52
+ if (args.action === "resume") {
53
+ const record = await resumeWorktree(options.deps, {
54
+ cwd: options.cwd,
55
+ name: args.worktreeName,
56
+ sessionId: args.sessionId ?? process.env.AGENT_OPS_SESSION_ID
57
+ });
58
+ return okEnvelope("WORKTREE_RESUMED", {
59
+ record,
60
+ text: [
61
+ `Session ${record.sessionId} now owns worktree ${record.name}.`,
62
+ `Path: ${record.path}`,
63
+ `- Claude Code: EnterWorktree with path ${record.path}`,
64
+ `- Codex and agy: run every command with ${record.path} as its working directory`,
65
+ "Its next Stop is judged against everything already in the worktree."
66
+ ].join("\n")
67
+ });
68
+ }
69
+ if (args.action === "remove") {
70
+ const removed = await removeWorktree(options.deps, {
71
+ cwd: options.cwd,
72
+ name: args.worktreeName,
73
+ force: args.force === true
74
+ });
75
+ return okEnvelope("WORKTREE_REMOVED", { ...removed, text: `Removed ${removed.record.path} and ${removed.record.branch}.` });
76
+ }
77
+ if (args.action === "finish") {
78
+ const finished = await finishWorktree(options.deps, { cwd: options.cwd, name: args.worktreeName });
79
+ return okEnvelope("WORKTREE_FINISHED", { ...finished, text: finishText(finished) });
80
+ }
81
+ const sessionId = args.sessionId ?? process.env.AGENT_OPS_SESSION_ID;
82
+ const result = await addWorktree(options.deps, {
83
+ cwd: options.cwd,
84
+ name: args.worktreeName,
85
+ sessionId
86
+ });
87
+ return okEnvelope("WORKTREE_CREATED", { ...result, text: addText(result) });
88
+ }
@@ -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) {
@@ -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() });
@@ -9,6 +9,8 @@ import { resolveContainedPath } from "../../../runtime/src/fs/paths.js";
9
9
  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
+ import { evaluateWorktreeWrite, resolveMainRoot } from "../../../runtime/src/parallel/guard.js";
13
+ import { ensureSessionWorktree } from "../../../runtime/src/parallel/service.js";
12
14
  import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
13
15
  import { TaskService } from "../../../runtime/src/task/service.js";
14
16
  import { FileTaskStore } from "../../../runtime/src/task/store.js";
@@ -208,6 +210,26 @@ function shouldBuildStopVerification(harness, event, config, rawInput) {
208
210
  }
209
211
  return harnessDescriptor(harness).control.registrations.some(({ capability, support }) => capability === "optional-stop-verify" && support !== "unsupported");
210
212
  }
213
+ /**
214
+ * The gate of one checkout. A session redirected into a worktree is judged by
215
+ * that worktree's gate, built the same way from the worktree's own config.
216
+ */
217
+ function completionGateFor(root, config, gitRunner) {
218
+ return new CompletionGateService({
219
+ root,
220
+ config,
221
+ gitRunner,
222
+ taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
223
+ evidenceStore: new FileEvidenceStore(root, root),
224
+ forRoot: async (worktree) => {
225
+ const outcome = await loadProjectHookConfig(worktree);
226
+ if (outcome.kind !== "loaded" || !outcome.config.features.completionGate.enabled) {
227
+ throw new Error("The redirected worktree has no enabled completion gate.");
228
+ }
229
+ return completionGateFor(worktree, outcome.config, defaultGitRunner(worktree));
230
+ }
231
+ });
232
+ }
211
233
  /**
212
234
  * Runs one hook invocation. Exit code stays zero because native JSON carries
213
235
  * decisions; only an explicitly installed agy completion gate fails closed.
@@ -319,13 +341,17 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
319
341
  const completionGate = (harnessId === "agy" || harnessId === "claude") &&
320
342
  config.features.completionGate.enabled
321
343
  ? dependencies.completionGate ?? {
322
- handle: async (normalized) => await new CompletionGateService({
323
- root,
324
- config,
325
- gitRunner,
326
- taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
327
- evidenceStore: new FileEvidenceStore(root, root)
328
- }).handle(normalized)
344
+ handle: async (normalized) => await completionGateFor(root, config, gitRunner).handle(normalized)
345
+ }
346
+ : undefined;
347
+ const worktreeGuard = config.worktree?.mode === "auto"
348
+ ? async (write) => {
349
+ const mainRoot = await resolveMainRoot(gitRunner);
350
+ return mainRoot === null
351
+ ? { action: "continue", status: "UNKNOWN", code: "WORKTREE_GUARD_UNAVAILABLE" }
352
+ : await evaluateWorktreeWrite(mainRoot, write.paths, write.sessionId, dependencies.worktree === undefined
353
+ ? undefined
354
+ : async (sessionId) => (await ensureSessionWorktree(dependencies.worktree, { cwd: mainRoot, sessionId })).path);
329
355
  }
330
356
  : undefined;
331
357
  const output = await runHookCommand({
@@ -334,6 +360,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
334
360
  stdin: rawInput,
335
361
  config,
336
362
  trusted,
363
+ ...(worktreeGuard === undefined ? {} : { worktreeGuard }),
337
364
  ...(dependencies.advisory === undefined
338
365
  ? {}
339
366
  : { advisory: dependencies.advisory }),