@kylecheng3146/agent-ops 0.3.6 → 0.4.0

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 +49 -5
  2. package/dist/packages/cli/src/bin.js +17 -35
  3. package/dist/packages/cli/src/cli.js +27 -2
  4. package/dist/packages/cli/src/commands/hook.js +4 -1
  5. package/dist/packages/cli/src/commands/init.js +1 -0
  6. package/dist/packages/cli/src/commands/parallel.js +16 -3
  7. package/dist/packages/cli/src/commands/uninstall.js +3 -2
  8. package/dist/packages/cli/src/commands/update.js +1 -0
  9. package/dist/packages/cli/src/context.js +28 -3
  10. package/dist/packages/cli/src/hook-process.js +14 -3
  11. package/dist/packages/cli/src/public-plan.js +32 -2
  12. package/dist/runtime/src/adapters/claude/config.js +1 -1
  13. package/dist/runtime/src/adapters/claude/input.js +17 -0
  14. package/dist/runtime/src/adapters/claude/output.js +9 -0
  15. package/dist/runtime/src/hooks/codex-loop.js +67 -7
  16. package/dist/runtime/src/hooks/completion-gate.js +27 -2
  17. package/dist/runtime/src/hooks/dispatch.js +5 -0
  18. package/dist/runtime/src/install/apply.js +2 -0
  19. package/dist/runtime/src/install/doctor.js +12 -1
  20. package/dist/runtime/src/install/harness.js +16 -5
  21. package/dist/runtime/src/install/plan.js +9 -0
  22. package/dist/runtime/src/install/preauth.js +265 -0
  23. package/dist/runtime/src/install/probes.js +32 -0
  24. package/dist/runtime/src/install/uninstall.js +29 -11
  25. package/dist/runtime/src/install/update.js +1 -0
  26. package/dist/runtime/src/parallel/enter.js +43 -0
  27. package/dist/runtime/src/parallel/finish.js +2 -1
  28. package/dist/runtime/src/parallel/guard.js +20 -1
  29. package/dist/runtime/src/parallel/manage.js +38 -0
  30. package/dist/runtime/src/parallel/service.js +3 -1
  31. package/dist/runtime/src/review/execute.js +15 -7
  32. package/dist/runtime/src/review/host-sandbox.js +45 -1
  33. package/dist/runtime/src/review/probe.js +44 -12
  34. package/dist/runtime/src/review/render.js +9 -0
  35. package/docs/en/guides/configuration.md +61 -4
  36. package/docs/zh-TW/guides/configuration.md +49 -1
  37. package/package.json +1 -1
@@ -17,7 +17,7 @@ const SCOPES = new Set(["project", "user"]);
17
17
  const PROFILES = new Set(["advisory", "core", "guardrails", "loop"]);
18
18
  // opencode is absent: it has no read-only flag, so it cannot review.
19
19
  const REVIEW_TARGETS = new Set(["agy", "claude", "codex"]);
20
- export const WORKTREE_ACTIONS = ["add", "finish", "list", "resume", "remove"];
20
+ export const WORKTREE_ACTIONS = ["add", "commit", "finish", "list", "resume", "remove"];
21
21
  export class CliArgumentError extends Error {
22
22
  code;
23
23
  option;
@@ -87,6 +87,8 @@ export function parseArgs(argv) {
87
87
  let worktreeName;
88
88
  let force = false;
89
89
  let worktreeFrom;
90
+ let worktreeMessage;
91
+ let output;
90
92
  let worktreeTargetBranch;
91
93
  let worktree;
92
94
  for (let index = 0; index < argv.length; index += 1) {
@@ -276,6 +278,23 @@ export function parseArgs(argv) {
276
278
  index += 1;
277
279
  break;
278
280
  }
281
+ case "-m":
282
+ case "--message": {
283
+ if (worktreeMessage !== undefined) {
284
+ duplicate(token);
285
+ }
286
+ worktreeMessage = readOptionValue(argv, index, token);
287
+ index += 1;
288
+ break;
289
+ }
290
+ case "--output": {
291
+ if (output !== undefined) {
292
+ duplicate(token);
293
+ }
294
+ output = readOptionValue(argv, index, token);
295
+ index += 1;
296
+ break;
297
+ }
279
298
  case "--target-branch": {
280
299
  if (worktreeTargetBranch !== undefined) {
281
300
  duplicate(token);
@@ -339,7 +358,11 @@ export function parseArgs(argv) {
339
358
  action = token;
340
359
  break;
341
360
  }
342
- if (command === "worktree" && action !== undefined && action !== "list" && worktreeName === undefined) {
361
+ if (command === "worktree" &&
362
+ action !== undefined &&
363
+ action !== "list" &&
364
+ action !== "commit" &&
365
+ worktreeName === undefined) {
343
366
  worktreeName = token;
344
367
  break;
345
368
  }
@@ -400,6 +423,8 @@ export function parseArgs(argv) {
400
423
  rerun ||
401
424
  worktree !== undefined ||
402
425
  worktreeFrom !== undefined ||
426
+ worktreeMessage !== undefined ||
427
+ output !== undefined ||
403
428
  worktreeTargetBranch !== undefined ||
404
429
  force) {
405
430
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Only --json may be combined with global help or version.");
@@ -415,9 +440,15 @@ export function parseArgs(argv) {
415
440
  if (command === "worktree" && action === undefined) {
416
441
  throw new CliArgumentError("CLI_ACTION_REQUIRED", `The worktree command requires one of: ${WORKTREE_ACTIONS.join(", ")}.`);
417
442
  }
418
- if (command === "worktree" && action !== "list" && worktreeName === undefined) {
443
+ if (command === "worktree" && action !== "list" && action !== "commit" && worktreeName === undefined) {
419
444
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", `worktree ${action} requires a worktree name.`);
420
445
  }
446
+ if (command === "worktree" && action === "commit" && (worktreeMessage === undefined || worktreeMessage.trim() === "")) {
447
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "worktree commit requires --message (-m).", "--message");
448
+ }
449
+ if (worktreeMessage !== undefined && !(command === "worktree" && action === "commit")) {
450
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--message may be used only with worktree commit.", "--message");
451
+ }
421
452
  if (command === "task" && action === undefined) {
422
453
  throw new CliArgumentError("CLI_ACTION_REQUIRED", "The task command requires one of: create, status, attach, complete, archive, export.");
423
454
  }
@@ -556,7 +587,13 @@ export function parseArgs(argv) {
556
587
  dryRun ||
557
588
  yes ||
558
589
  rerun)) {
559
- throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "worktree accepts only a name, --session, --from, --target-branch and --json.");
590
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "worktree accepts only a name, --session, --from, --target-branch, --message and --json.");
591
+ }
592
+ if (output !== undefined && command !== "review" && command !== "doctor") {
593
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--output may be used only with review or doctor.", "--output");
594
+ }
595
+ if (output !== undefined && (output.trim() === "" || output.includes("\0"))) {
596
+ throw new CliArgumentError("CLI_INVALID_VALUE", "Invalid value for --output.", "--output");
560
597
  }
561
598
  if (force && !(command === "worktree" && action === "remove")) {
562
599
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--force may be used only with worktree remove.");
@@ -572,7 +609,12 @@ export function parseArgs(argv) {
572
609
  !/^[A-Za-z0-9._/-]{1,128}$/u.test(worktreeTargetBranch)) {
573
610
  throw new CliArgumentError("CLI_INVALID_VALUE", `Invalid value for --target-branch: ${worktreeTargetBranch}`, "--target-branch");
574
611
  }
575
- if (command === "worktree" && action !== "add" && action !== "resume" && sessionId !== undefined) {
612
+ // commit acts as a session (it refuses another's worktree), so it may be told which.
613
+ if (command === "worktree" &&
614
+ action !== "add" &&
615
+ action !== "resume" &&
616
+ action !== "commit" &&
617
+ sessionId !== undefined) {
576
618
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", `worktree ${action} takes the session from the worktree; omit --session.`);
577
619
  }
578
620
  return {
@@ -595,6 +637,8 @@ export function parseArgs(argv) {
595
637
  ...(sessionId === undefined ? {} : { sessionId }),
596
638
  ...(base === undefined ? {} : { base }),
597
639
  ...(worktreeFrom === undefined ? {} : { worktreeFrom }),
640
+ ...(worktreeMessage === undefined ? {} : { worktreeMessage }),
641
+ ...(output === undefined ? {} : { output }),
598
642
  ...(worktreeTargetBranch === undefined ? {} : { worktreeTargetBranch }),
599
643
  ...(checkAuth ? { checkAuth } : {}),
600
644
  ...(checkAuthTargets.length === 0 ? {} : { checkAuthTargets }),
@@ -6,9 +6,10 @@ import { join } from "node:path";
6
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
- import { agyRuntimeStatus, agyVersionSupported, hookRegistrationDrift, repositoryTrustStatus, smokeAvailabilityStatus } from "../../../runtime/src/install/probes.js";
9
+ import { agyRuntimeStatus, agyVersionSupported, hookRegistrationDrift, repositoryTrustProbe, smokeAvailabilityStatus } from "../../../runtime/src/install/probes.js";
10
10
  import { parseInstallManifest } from "../../../runtime/src/fs/manifest.js";
11
- import { readRecordedSessionId } from "../../../runtime/src/hooks/codex-loop.js";
11
+ import { readRecordedSessionId, resolveCommandSessionId, sessionIdFromEnvironment } from "../../../runtime/src/hooks/codex-loop.js";
12
+ import { codexHomeDirectory } from "../../../runtime/src/install/preauth.js";
12
13
  import { NpmRegistryClient } from "../../../runtime/src/registry/npm.js";
13
14
  import { TaskService } from "../../../runtime/src/task/service.js";
14
15
  import { FileTaskStore } from "../../../runtime/src/task/store.js";
@@ -31,7 +32,7 @@ import { formatUninstallPlan, runUninstallCommand } from "./commands/uninstall.j
31
32
  import { runTaskCommand } from "./commands/task.js";
32
33
  import { runReviewCommand } from "./commands/review.js";
33
34
  import { createReviewExecutor, ReviewInterruptedError } from "../../../runtime/src/review/execute.js";
34
- import { probeReviewTarget } from "../../../runtime/src/review/probe.js";
35
+ import { probeReviewTargetDetailed } from "../../../runtime/src/review/probe.js";
35
36
  import { resolveReviewScope } from "../../../runtime/src/review/scope.js";
36
37
  import { resolveReviewRole } from "../../../runtime/src/review/roles.js";
37
38
  import { runTrustCommand } from "./commands/trust.js";
@@ -234,6 +235,7 @@ else {
234
235
  isTTY,
235
236
  toolkitVersion: CLI_VERSION,
236
237
  hookRuntimePath: HOOK_RUNTIME_PATH,
238
+ codexHome: codexHomeDirectory(),
237
239
  agyWarning: () => {
238
240
  try {
239
241
  const output = runAgy(["--version"], { timeout: 5_000 });
@@ -304,34 +306,7 @@ else {
304
306
  };
305
307
  },
306
308
  repositoryTrust: async () => {
307
- const trust = await repositoryTrust(root, config, CLI_VERSION);
308
- const status = repositoryTrustStatus(trust);
309
- if (trust === "STALE") {
310
- return {
311
- status,
312
- message: "Repository trust binding is stale.",
313
- code: "TRUST_REQUIRED",
314
- remediation: "Run `agent-ops trust grant`."
315
- };
316
- }
317
- if (trust === "UNTRUSTED") {
318
- const verificationConfigured = config.verification.commands.length > 0;
319
- return {
320
- status,
321
- message: verificationConfigured
322
- ? "Repository is not trusted; Stop verification will not run."
323
- : "Repository is not trusted.",
324
- ...(verificationConfigured
325
- ? {
326
- code: "TRUST_REQUIRED",
327
- remediation: "Run `agent-ops trust grant`."
328
- }
329
- : {
330
- remediation: "No action needed; trust is only required once verification.commands is set."
331
- })
332
- };
333
- }
334
- return { status };
309
+ return repositoryTrustProbe(await repositoryTrust(root, config, CLI_VERSION), config.verification.commands.length > 0);
335
310
  },
336
311
  smokeAvailability: () => {
337
312
  const status = smokeAvailabilityStatus(config);
@@ -343,7 +318,7 @@ else {
343
318
  }
344
319
  : { status };
345
320
  },
346
- reviewTarget: async (target, deep) => await probeReviewTarget(target, { cwd: root, deep }),
321
+ reviewTarget: async (target, deep) => await probeReviewTargetDetailed(target, { cwd: root, deep }),
347
322
  worktrees: async () => await worktreeDoctorProbe(root),
348
323
  worktreeBranchLock: async () => await worktreeBranchLockProbe(root),
349
324
  rootGhostFiles: async () => await rootGhostFilesProbe(root)
@@ -362,6 +337,7 @@ else {
362
337
  args,
363
338
  root,
364
339
  isTTY,
340
+ codexHome: codexHomeDirectory(),
365
341
  trustStore: store,
366
342
  calculateTrustBinding: async () => await plannedTrustBinding(root),
367
343
  confirm: async (plan, trust) => await confirmPlan(formatUninstallPlan(plan, trust))
@@ -387,6 +363,7 @@ else {
387
363
  isTTY,
388
364
  toolkitVersion: CLI_VERSION,
389
365
  hookRuntimePath: HOOK_RUNTIME_PATH,
366
+ codexHome: codexHomeDirectory(),
390
367
  ...(updateArgs.hookTargets === undefined
391
368
  ? {}
392
369
  : { hookTargets: updateArgs.hookTargets }),
@@ -422,8 +399,13 @@ else {
422
399
  // Explicit `--session` wins, then an injected identity, then the
423
400
  // id a SessionStart hook recorded for this checkout: a command
424
401
  // run inside a session is never told which session it is in.
425
- const sessionId = process.env.AGENT_OPS_SESSION_ID ??
426
- await readRecordedSessionId(root);
402
+ // Only create, attach and status act as a session; a contested
403
+ // record refuses those rather than guess, and leaves the rest.
404
+ const actsAsSession = args.action === "create" ||
405
+ args.action === "attach" || args.action === "status";
406
+ const sessionId = args.sessionId ?? (actsAsSession
407
+ ? await resolveCommandSessionId(root)
408
+ : sessionIdFromEnvironment() ?? await readRecordedSessionId(root));
427
409
  const createConfig = args.action === "create"
428
410
  ? (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config
429
411
  : undefined;
@@ -507,7 +489,7 @@ else {
507
489
  ...(reviewRole?.timeoutMs === undefined
508
490
  ? {}
509
491
  : { timeoutMs: reviewRole.timeoutMs }),
510
- preflightTarget: async (target, budget) => await probeReviewTarget(target, {
492
+ preflightTarget: async (target, budget) => await probeReviewTargetDetailed(target, {
511
493
  cwd: root,
512
494
  deep: true,
513
495
  // The probe answers within the chain's remaining budget
@@ -1,3 +1,5 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
1
3
  import { CliArgumentError, parseArgs } from "./args.js";
2
4
  import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
3
5
  import { errorEnvelope, okEnvelope, writeEnvelope } from "./output.js";
@@ -106,6 +108,9 @@ Options:
106
108
  --scope <project|user>
107
109
  --harness <all|both|agy|claude|codex|opencode|comma-separated>
108
110
  --check-auth Probe each review target's authentication with one real call
111
+ --output <file>
112
+ Write the JSON envelope to <file> (mode 0600) and print one
113
+ summary line, so no shell redirect is needed
109
114
  --json
110
115
  `,
111
116
  update: `Usage: agent-ops update [options]
@@ -172,6 +177,8 @@ fresh adversarial reviewer from the configured pair.
172
177
  Options:
173
178
  --task <id> Required task whose original criteria are reviewed
174
179
  --base <git-ref> Review a clean committed range
180
+ --output <file> Write the JSON envelope to <file> (mode 0600) and print
181
+ one summary line, so no shell redirect is needed
175
182
  --json
176
183
  --yes Required authorization for both reviewer sessions
177
184
  `,
@@ -186,6 +193,7 @@ Options:
186
193
  --json
187
194
  `,
188
195
  worktree: `Usage: agent-ops worktree <add|finish|resume|remove> <name> [options]
196
+ agent-ops worktree commit --message <text> [--json]
189
197
  agent-ops worktree list [--json]
190
198
 
191
199
  Give one writing session its own Git worktree, so parallel sessions stop
@@ -205,6 +213,9 @@ voiding each other's verification and review. Run it from the main checkout.
205
213
  branch to the worktree's (after a clean rebase and
206
214
  re-verification if the branch moved), record the task in
207
215
  git notes, and remove the worktree. One finish runs at a time.
216
+ commit Stage every change in the current worktree and commit it with
217
+ --message. Refuses the main checkout and a worktree another
218
+ session owns, and needs no Git in the shell.
208
219
  list Every agent-ops worktree: session, commits ahead, uncommitted
209
220
  changes, task status and last activity.
210
221
  resume <name>
@@ -221,6 +232,7 @@ Options:
221
232
  add: branch finish merges back into (defaults to the current
222
233
  branch; required when the main checkout is detached)
223
234
  --force remove: discard uncommitted or unmerged work
235
+ --message <text> commit: the commit message (also -m)
224
236
  --json
225
237
  `
226
238
  };
@@ -275,11 +287,24 @@ export async function runCli(argv, io, services) {
275
287
  return writeAndReturn(io, errorEnvelope("CLI_COMMAND_UNAVAILABLE", `Command is not implemented yet: ${args.command}`), args.json, 1);
276
288
  }
277
289
  const result = await execute(args);
278
- return writeAndReturn(io, result, args.json, result.status === "ok"
290
+ const exitCode = result.status === "ok"
279
291
  ? 0
280
292
  : result.code === "REVIEW_NOT_RUN"
281
293
  ? 2
282
- : 1);
294
+ : 1;
295
+ if (args.output !== undefined) {
296
+ // The envelope goes to the file whole; stdout carries one line, so an
297
+ // agent needs neither a shell redirect nor the whole report in context.
298
+ try {
299
+ await writeFile(resolve(args.output), `${JSON.stringify(result.data === undefined ? { ...result, data: null } : result)}\n`, { mode: 0o600 });
300
+ }
301
+ catch {
302
+ throw new AgentOpsError("CLI_OUTPUT_WRITE_FAILED", `Could not write ${args.output}.`);
303
+ }
304
+ io.writeStdout(`agent-ops: ${result.code} (${result.status}); envelope written to ${args.output}\n`);
305
+ return exitCode;
306
+ }
307
+ return writeAndReturn(io, result, args.json, exitCode);
283
308
  }
284
309
  catch (error) {
285
310
  if (error instanceof CliArgumentError) {
@@ -50,7 +50,10 @@ export async function runHookCommand(options) {
50
50
  : { completionGate: options.completionGate }),
51
51
  ...(options.worktreeGuard === undefined
52
52
  ? {}
53
- : { worktreeGuard: options.worktreeGuard })
53
+ : { worktreeGuard: options.worktreeGuard }),
54
+ ...(options.worktreeEnter === undefined
55
+ ? {}
56
+ : { worktreeEnter: options.worktreeEnter })
54
57
  });
55
58
  return descriptor.runtime.formatOutput(options.event, result);
56
59
  }
@@ -119,6 +119,7 @@ export async function runInitCommand(options) {
119
119
  ...(args.completionGate === undefined
120
120
  ? {}
121
121
  : { completionGateEnabled: args.completionGate }),
122
+ ...(options.codexHome === undefined ? {} : { codexHome: options.codexHome }),
122
123
  ...(worktreeConfig === undefined
123
124
  ? {}
124
125
  : { worktree: worktreeConfig })
@@ -1,7 +1,8 @@
1
1
  import { okEnvelope } from "../output.js";
2
+ import { resolveCommandSessionId, sessionIdFromEnvironment } from "../../../../runtime/src/hooks/codex-loop.js";
2
3
  import { addWorktree } from "../../../../runtime/src/parallel/service.js";
3
4
  import { finishWorktree } from "../../../../runtime/src/parallel/finish.js";
4
- import { idleWorktrees, listWorktrees, removeWorktree, resumeWorktree } from "../../../../runtime/src/parallel/manage.js";
5
+ import { commitWorktree, idleWorktrees, listWorktrees, removeWorktree, resumeWorktree } from "../../../../runtime/src/parallel/manage.js";
5
6
  function listText(statuses, now) {
6
7
  if (statuses.length === 0)
7
8
  return "No agent-ops worktrees.";
@@ -49,11 +50,23 @@ export async function runWorktreeCommand(options) {
49
50
  const worktrees = await listWorktrees(options.deps, options.cwd);
50
51
  return okEnvelope("WORKTREE_LIST", { worktrees, text: listText(worktrees, Date.now()) });
51
52
  }
53
+ if (args.action === "commit") {
54
+ const committed = await commitWorktree(options.deps, {
55
+ cwd: options.cwd,
56
+ sessionId: args.sessionId ?? await resolveCommandSessionId(options.cwd),
57
+ message: args.worktreeMessage ?? ""
58
+ });
59
+ return okEnvelope("WORKTREE_COMMITTED", {
60
+ ...committed,
61
+ text: `Committed ${committed.commit} in ${committed.record.path}.\n` +
62
+ `Verify and review with --base ${committed.record.base} (or the commit before your first change), then finish from ${committed.record.mainRoot}.`
63
+ });
64
+ }
52
65
  if (args.action === "resume") {
53
66
  const record = await resumeWorktree(options.deps, {
54
67
  cwd: options.cwd,
55
68
  name: args.worktreeName,
56
- sessionId: args.sessionId ?? process.env.AGENT_OPS_SESSION_ID
69
+ sessionId: args.sessionId ?? sessionIdFromEnvironment()
57
70
  });
58
71
  return okEnvelope("WORKTREE_RESUMED", {
59
72
  record,
@@ -78,7 +91,7 @@ export async function runWorktreeCommand(options) {
78
91
  const finished = await finishWorktree(options.deps, { cwd: options.cwd, name: args.worktreeName });
79
92
  return okEnvelope("WORKTREE_FINISHED", { ...finished, text: finishText(finished) });
80
93
  }
81
- const sessionId = args.sessionId ?? process.env.AGENT_OPS_SESSION_ID;
94
+ const sessionId = args.sessionId ?? sessionIdFromEnvironment();
82
95
  const result = await addWorktree(options.deps, {
83
96
  cwd: options.cwd,
84
97
  name: args.worktreeName,
@@ -56,7 +56,8 @@ async function trustChange(options, plan) {
56
56
  : await planTrustRevoke(binding, options.trustStore);
57
57
  }
58
58
  export async function runUninstallCommand(options) {
59
- const plan = await createUninstallPlan(options.root, options.args.harness);
59
+ const planOptions = options.codexHome === undefined ? {} : { codexHome: options.codexHome };
60
+ const plan = await createUninstallPlan(options.root, options.args.harness, planOptions);
60
61
  if (!plan.installed) {
61
62
  return okEnvelope("UNINSTALL_NOT_INSTALLED", {
62
63
  applied: false,
@@ -80,7 +81,7 @@ export async function runUninstallCommand(options) {
80
81
  !(await options.confirm(plan, trust))) {
81
82
  return uninstallError("UNINSTALL_CANCELLED", "Uninstall was cancelled; no files were changed.", plan, trust);
82
83
  }
83
- await applyUninstallPlan(options.root, plan);
84
+ await applyUninstallPlan(options.root, plan, planOptions);
84
85
  if (trust.action === "revoke") {
85
86
  try {
86
87
  if (options.trustStore === undefined ||
@@ -112,6 +112,7 @@ export async function runUpdateCommand(options) {
112
112
  ...((options.hookTargets ?? options.args.hookTargets) === undefined
113
113
  ? {}
114
114
  : { hookTargets: options.hookTargets ?? options.args.hookTargets }),
115
+ ...(options.codexHome === undefined ? {} : { codexHome: options.codexHome }),
115
116
  ...(worktree === undefined ? {} : { worktree })
116
117
  });
117
118
  const trust = await trustChange(options, plan);
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { existsSync, realpathSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
- import { join } from "node:path";
4
+ import { basename, dirname, join, sep } from "node:path";
4
5
  import { calculateConfigHash } from "../../../runtime/src/config/hash.js";
5
6
  import { sha256 } from "../../../runtime/src/fs/hash.js";
6
7
  import { loadConfigFile } from "../../../runtime/src/config/load.js";
@@ -23,6 +24,30 @@ export const DEFAULT_CONFIG = {
23
24
  pathMappings: [],
24
25
  securityExceptions: []
25
26
  };
27
+ /**
28
+ * Where a checkout's project config lives. Inside an agent-ops worktree it is
29
+ * the main checkout's: a branch must not change the config that decides its
30
+ * own gate, verifiers and trust before that change is merged, and a copy of a
31
+ * gitignored file can be missing or stale. Anything git cannot confirm is a
32
+ * worktree of this repository falls back to the checkout itself.
33
+ */
34
+ export function projectConfigRoot(root) {
35
+ try {
36
+ const common = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
37
+ if (basename(common) !== ".git") {
38
+ return root;
39
+ }
40
+ const main = realpathSync(dirname(common));
41
+ const here = realpathSync(root);
42
+ return here.startsWith(`${join(main, ".worktrees")}${sep}`) &&
43
+ existsSync(join(main, ".agent-ops", "config.json"))
44
+ ? main
45
+ : root;
46
+ }
47
+ catch {
48
+ return root;
49
+ }
50
+ }
26
51
  function defaultConfigLayer() {
27
52
  return {
28
53
  source: "default",
@@ -49,7 +74,7 @@ async function loadOptionalConfig(path) {
49
74
  export async function loadEffectiveConfig(root, scope, projectOverride) {
50
75
  const home = process.env.AGENT_OPS_HOME ?? homedir();
51
76
  const userPath = join(home, ".agent-ops", "config.json");
52
- const projectPath = join(root, ".agent-ops", "config.json");
77
+ const projectPath = join(scope === "user" ? root : projectConfigRoot(root), ".agent-ops", "config.json");
53
78
  const layers = [defaultConfigLayer()];
54
79
  if (scope === "user") {
55
80
  const user = await loadOptionalConfig(userPath);
@@ -92,7 +117,7 @@ export async function loadEffectiveConfig(root, scope, projectOverride) {
92
117
  export async function loadProjectHookConfig(root) {
93
118
  const home = process.env.AGENT_OPS_HOME ?? homedir();
94
119
  const userPath = join(home, ".agent-ops", "config.json");
95
- const projectPath = join(root, ".agent-ops", "config.json");
120
+ const projectPath = join(projectConfigRoot(root), ".agent-ops", "config.json");
96
121
  const layers = [defaultConfigLayer()];
97
122
  let project;
98
123
  try {
@@ -9,6 +9,7 @@ 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 { evaluateWorktreeEnter } from "../../../runtime/src/parallel/enter.js";
12
13
  import { evaluateWorktreeWrite, resolveMainRoot } from "../../../runtime/src/parallel/guard.js";
13
14
  import { ensureSessionWorktree, recordSessionOrigin } from "../../../runtime/src/parallel/service.js";
14
15
  import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
@@ -214,11 +215,12 @@ function shouldBuildStopVerification(harness, event, config, rawInput) {
214
215
  * The gate of one checkout. A session redirected into a worktree is judged by
215
216
  * that worktree's gate, built the same way from the worktree's own config.
216
217
  */
217
- function completionGateFor(root, config, gitRunner) {
218
+ function completionGateFor(root, config, gitRunner, harness) {
218
219
  return new CompletionGateService({
219
220
  root,
220
221
  config,
221
222
  gitRunner,
223
+ ...(harness === undefined ? {} : { harness }),
222
224
  taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
223
225
  evidenceStore: new FileEvidenceStore(root, root),
224
226
  forRoot: async (worktree) => {
@@ -226,7 +228,7 @@ function completionGateFor(root, config, gitRunner) {
226
228
  if (outcome.kind !== "loaded" || !outcome.config.features.completionGate.enabled) {
227
229
  throw new Error("The redirected worktree has no enabled completion gate.");
228
230
  }
229
- return completionGateFor(worktree, outcome.config, defaultGitRunner(worktree));
231
+ return completionGateFor(worktree, outcome.config, defaultGitRunner(worktree), harness);
230
232
  }
231
233
  });
232
234
  }
@@ -341,7 +343,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
341
343
  const completionGate = (harnessId === "agy" || harnessId === "claude") &&
342
344
  config.features.completionGate.enabled
343
345
  ? dependencies.completionGate ?? {
344
- handle: async (normalized) => await completionGateFor(root, config, gitRunner).handle(normalized)
346
+ handle: async (normalized) => await completionGateFor(root, config, gitRunner, harnessId).handle(normalized)
345
347
  }
346
348
  : undefined;
347
349
  const worktreeGuard = config.worktree?.mode === "auto"
@@ -354,6 +356,14 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
354
356
  : async (sessionId) => (await ensureSessionWorktree(dependencies.worktree, { cwd: mainRoot, sessionId })).path);
355
357
  }
356
358
  : undefined;
359
+ const worktreeEnter = config.worktree?.mode === "auto"
360
+ ? async (enter) => {
361
+ const mainRoot = await resolveMainRoot(gitRunner);
362
+ return mainRoot === null
363
+ ? { action: "continue", status: "UNKNOWN", code: "WORKTREE_GUARD_UNAVAILABLE" }
364
+ : await evaluateWorktreeEnter(mainRoot, enter.path, enter.sessionId);
365
+ }
366
+ : undefined;
357
367
  if (hookEvent === "SessionStart" && config.worktree?.mode === "auto" &&
358
368
  dependencies.worktree !== undefined && typeof parsedInput === "object" && parsedInput !== null) {
359
369
  const fields = parsedInput;
@@ -370,6 +380,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
370
380
  config,
371
381
  trusted,
372
382
  ...(worktreeGuard === undefined ? {} : { worktreeGuard }),
383
+ ...(worktreeEnter === undefined ? {} : { worktreeEnter }),
373
384
  ...(dependencies.advisory === undefined
374
385
  ? {}
375
386
  : { advisory: dependencies.advisory }),
@@ -1,3 +1,4 @@
1
+ import { join } from "node:path";
1
2
  import { redactSecrets } from "../../../runtime/src/security/redact.js";
2
3
  import { sha256 } from "../../../runtime/src/fs/hash.js";
3
4
  const OPAQUE_SUMMARY = "Opaque managed settings content withheld.";
@@ -35,6 +36,29 @@ export function toPublicOperation(operation) {
35
36
  export function toPublicOperations(operations) {
36
37
  return operations.map(toPublicOperation);
37
38
  }
39
+ /**
40
+ * Pre-authorization changes join the ordinary operations, so every preview and
41
+ * confirmation shows them. A file in Codex's home is shown by absolute path;
42
+ * a file agent-ops does not wholly own is described, never printed.
43
+ */
44
+ export function toPublicPreauthorization(changes) {
45
+ return (changes ?? []).map((change) => {
46
+ const path = publicPath(join(change.root, change.operation.path));
47
+ const operation = change.operation;
48
+ if (operation.kind === "remove") {
49
+ return { kind: "remove", path, expectedHash: operation.expectedHash };
50
+ }
51
+ return change.ownedContent
52
+ ? { kind: "write", path, expectedHash: operation.expectedHash, content: operation.content }
53
+ : {
54
+ kind: "write",
55
+ path,
56
+ expectedHash: operation.expectedHash,
57
+ contentHash: sha256(operation.content),
58
+ summary: redactSecrets(change.summary)
59
+ };
60
+ });
61
+ }
38
62
  export function toPublicInstallPlan(plan, trust) {
39
63
  return {
40
64
  scope: plan.scope,
@@ -42,7 +66,10 @@ export function toPublicInstallPlan(plan, trust) {
42
66
  profiles: plan.profiles,
43
67
  capabilities: plan.capabilities,
44
68
  manifest: plan.manifest,
45
- operations: toPublicOperations(plan.operations),
69
+ operations: [
70
+ ...toPublicOperations(plan.operations),
71
+ ...toPublicPreauthorization(plan.preauthorization)
72
+ ],
46
73
  detectedVerification: plan.detectedVerification,
47
74
  ...(trust === undefined ? {} : { trust })
48
75
  };
@@ -59,7 +86,10 @@ export function toPublicUninstallPlan(plan, trust) {
59
86
  installed: plan.installed,
60
87
  manifest: plan.manifest,
61
88
  manifestHash: plan.manifestHash,
62
- operations: toPublicOperations(plan.operations),
89
+ operations: [
90
+ ...toPublicOperations(plan.operations),
91
+ ...toPublicPreauthorization(plan.preauthorization)
92
+ ],
63
93
  ...(trust === undefined ? {} : { trust })
64
94
  };
65
95
  }
@@ -11,7 +11,7 @@ const CLAUDE_LOOP_EVENTS = [
11
11
  "SubagentStop"
12
12
  ];
13
13
  const CLAUDE_HOOK_MARKER = "--managed-by=agent-ops";
14
- export const CLAUDE_PRE_TOOL_MATCHER = "Bash|Edit|MultiEdit|NotebookEdit|Write";
14
+ export const CLAUDE_PRE_TOOL_MATCHER = "Bash|Edit|MultiEdit|NotebookEdit|Write|EnterWorktree";
15
15
  const CLAUDE_LOOP_LAUNCHER = "${CLAUDE_PROJECT_DIR}/.claude/hooks/agent-ops-loop.sh";
16
16
  const CLAUDE_WINDOWS_LOOP_LAUNCHER = "${CLAUDE_PROJECT_DIR}/.claude/hooks/agent-ops-loop.ps1";
17
17
  export function claudeSettingsTarget(scope) {
@@ -63,6 +63,23 @@ export function normalizeClaudeHookInput(input) {
63
63
  };
64
64
  }
65
65
  }
66
+ if (input.hook_event_name === "PreToolUse" &&
67
+ input.tool_name === "EnterWorktree" &&
68
+ isRecord(input.tool_input) &&
69
+ typeof projectRoot === "string" &&
70
+ projectRoot.length > 0) {
71
+ const target = input.tool_input.path;
72
+ // A `name` asks for a new worktree of Claude's own, which is not this
73
+ // hook's business.
74
+ if (typeof target === "string" && target.length > 0 && !target.includes("\0")) {
75
+ return {
76
+ event: "enter-worktree",
77
+ projectRoot,
78
+ path: resolve(projectRoot, target),
79
+ ...(sessionId === undefined ? {} : { sessionId })
80
+ };
81
+ }
82
+ }
66
83
  if (input.hook_event_name === "PreToolUse" &&
67
84
  input.tool_name === "Bash" &&
68
85
  isRecord(input.tool_input) &&
@@ -32,6 +32,15 @@ export function claudeHookOutput(event, result) {
32
32
  }
33
33
  });
34
34
  }
35
+ if (event === "PreToolUse" && result.decision === "allow") {
36
+ return json({
37
+ hookSpecificOutput: {
38
+ hookEventName: "PreToolUse",
39
+ permissionDecision: "allow",
40
+ permissionDecisionReason: denialReason
41
+ }
42
+ });
43
+ }
35
44
  if (event === "Stop" && result.evidence !== undefined) {
36
45
  if (result.status === "FAIL") {
37
46
  const failed = result.evidence.commandResults