@kylecheng3146/agent-ops 0.3.7 → 0.4.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 (46) hide show
  1. package/README.md +35 -0
  2. package/dist/packages/cli/src/args.js +93 -9
  3. package/dist/packages/cli/src/bin.js +151 -104
  4. package/dist/packages/cli/src/cli.js +54 -5
  5. package/dist/packages/cli/src/commands/batch.js +106 -0
  6. package/dist/packages/cli/src/commands/hook.js +4 -1
  7. package/dist/packages/cli/src/commands/init.js +1 -0
  8. package/dist/packages/cli/src/commands/parallel.js +16 -3
  9. package/dist/packages/cli/src/commands/review.js +22 -0
  10. package/dist/packages/cli/src/commands/uninstall.js +3 -2
  11. package/dist/packages/cli/src/commands/update.js +1 -0
  12. package/dist/packages/cli/src/context.js +28 -3
  13. package/dist/packages/cli/src/hook-process.js +14 -3
  14. package/dist/packages/cli/src/public-plan.js +32 -2
  15. package/dist/runtime/src/adapters/claude/config.js +1 -1
  16. package/dist/runtime/src/adapters/claude/input.js +17 -0
  17. package/dist/runtime/src/adapters/claude/output.js +9 -0
  18. package/dist/runtime/src/hooks/codex-loop.js +67 -7
  19. package/dist/runtime/src/hooks/completion-gate.js +27 -2
  20. package/dist/runtime/src/hooks/dispatch.js +5 -0
  21. package/dist/runtime/src/install/apply.js +2 -0
  22. package/dist/runtime/src/install/doctor.js +8 -1
  23. package/dist/runtime/src/install/harness.js +17 -6
  24. package/dist/runtime/src/install/plan.js +9 -0
  25. package/dist/runtime/src/install/preauth.js +276 -0
  26. package/dist/runtime/src/install/probes.js +32 -0
  27. package/dist/runtime/src/install/uninstall.js +29 -11
  28. package/dist/runtime/src/install/update.js +1 -0
  29. package/dist/runtime/src/parallel/enter.js +43 -0
  30. package/dist/runtime/src/parallel/finish.js +2 -1
  31. package/dist/runtime/src/parallel/guard.js +20 -1
  32. package/dist/runtime/src/parallel/manage.js +38 -0
  33. package/dist/runtime/src/parallel/service.js +3 -1
  34. package/dist/runtime/src/review/batch-guard.js +11 -0
  35. package/dist/runtime/src/review/batch-select.js +37 -0
  36. package/dist/runtime/src/review/batch.js +202 -0
  37. package/dist/runtime/src/review/execute.js +10 -7
  38. package/dist/runtime/src/review/host-sandbox.js +9 -0
  39. package/dist/runtime/src/review/invocation.js +15 -2
  40. package/dist/runtime/src/review/probe.js +51 -15
  41. package/dist/runtime/src/review/render.js +4 -0
  42. package/docs/en/guides/configuration.md +51 -1
  43. package/docs/en/spec/review.md +70 -0
  44. package/docs/zh-TW/guides/configuration.md +41 -0
  45. package/docs/zh-TW/spec/review.md +71 -1
  46. package/package.json +1 -1
@@ -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";
@@ -27,6 +29,7 @@ Commands:
27
29
  Manage independent task acceptance state
28
30
  verify Run configured verification
29
31
  review Run an independent review
32
+ batch Verify and review a parent task's subtasks together
30
33
  allow-stop Grant one fingerprint-bound completion-gate Stop permit (requires --session)
31
34
  agy-run Run headless agy with a process-exit completion recheck
32
35
  worktree <add|finish|list|resume|remove>
@@ -45,13 +48,16 @@ Options:
45
48
  --check-auth-target <target> Repeatable doctor filter with --check-auth
46
49
  --task <id>
47
50
  --parent <task-id> Task create: record a subtask of this
48
- task; task status: list its subtasks
51
+ task; task status: list its subtasks;
52
+ batch: the task whose subtasks it covers
53
+ --parent-base <git-ref> Batch only: base for the parent task
54
+ --width <n> Batch only: reviews allowed at once (default 2)
49
55
  --target-version <version> Update target version (offline-capable)
50
56
  --title <text>
51
57
  --criterion <json> Repeatable
52
58
  --evidence <criterion-id=reference> Repeatable
53
59
  --session <id>
54
- --base <git-ref> Verify/review/complete a clean committed range
60
+ --base <git-ref> Verify/review/batch/complete a clean committed range
55
61
  --dry-run
56
62
  --json
57
63
  --yes
@@ -106,6 +112,9 @@ Options:
106
112
  --scope <project|user>
107
113
  --harness <all|both|agy|claude|codex|opencode|comma-separated>
108
114
  --check-auth Probe each review target's authentication with one real call
115
+ --output <file>
116
+ Write the JSON envelope to <file> (mode 0600) and print one
117
+ summary line, so no shell redirect is needed
109
118
  --json
110
119
  `,
111
120
  update: `Usage: agent-ops update [options]
@@ -172,8 +181,30 @@ fresh adversarial reviewer from the configured pair.
172
181
  Options:
173
182
  --task <id> Required task whose original criteria are reviewed
174
183
  --base <git-ref> Review a clean committed range
184
+ --output <file> Write the JSON envelope to <file> (mode 0600) and print
185
+ one summary line, so no shell redirect is needed
175
186
  --json
176
187
  --yes Required authorization for both reviewer sessions
188
+ `,
189
+ batch: `Usage: agent-ops batch --parent <task-id> --yes [options]
190
+
191
+ Verify and review the active subtasks of a parent task, and the parent itself,
192
+ together. Verify runs one task at a time and skips a task that already has
193
+ fresh PASS evidence; each review starts as soon as its own verify passes.
194
+ Nothing may commit or edit the worktree while it runs.
195
+
196
+ Options:
197
+ --parent <task-id> Required task whose active subtasks and itself are covered
198
+ --base <git-ref> Base for the subtasks (default: each task's recorded
199
+ review base, then the worktree base)
200
+ --parent-base <git-ref>
201
+ Base for the parent task (same default)
202
+ --width <n> Reviews allowed at once (default 2); a transient
203
+ NOT_RUN lowers it to 1 and earns one retry
204
+ --output <file> Write the JSON envelope to <file> (mode 0600) and print
205
+ one summary line, so no shell redirect is needed
206
+ --json
207
+ --yes Required authorization for the reviewer sessions
177
208
  `,
178
209
  "allow-stop": `Usage: agent-ops allow-stop --session <id> [options]
179
210
 
@@ -186,6 +217,7 @@ Options:
186
217
  --json
187
218
  `,
188
219
  worktree: `Usage: agent-ops worktree <add|finish|resume|remove> <name> [options]
220
+ agent-ops worktree commit --message <text> [--json]
189
221
  agent-ops worktree list [--json]
190
222
 
191
223
  Give one writing session its own Git worktree, so parallel sessions stop
@@ -205,6 +237,9 @@ voiding each other's verification and review. Run it from the main checkout.
205
237
  branch to the worktree's (after a clean rebase and
206
238
  re-verification if the branch moved), record the task in
207
239
  git notes, and remove the worktree. One finish runs at a time.
240
+ commit Stage every change in the current worktree and commit it with
241
+ --message. Refuses the main checkout and a worktree another
242
+ session owns, and needs no Git in the shell.
208
243
  list Every agent-ops worktree: session, commits ahead, uncommitted
209
244
  changes, task status and last activity.
210
245
  resume <name>
@@ -221,6 +256,7 @@ Options:
221
256
  add: branch finish merges back into (defaults to the current
222
257
  branch; required when the main checkout is detached)
223
258
  --force remove: discard uncommitted or unmerged work
259
+ --message <text> commit: the commit message (also -m)
224
260
  --json
225
261
  `
226
262
  };
@@ -275,11 +311,24 @@ export async function runCli(argv, io, services) {
275
311
  return writeAndReturn(io, errorEnvelope("CLI_COMMAND_UNAVAILABLE", `Command is not implemented yet: ${args.command}`), args.json, 1);
276
312
  }
277
313
  const result = await execute(args);
278
- return writeAndReturn(io, result, args.json, result.status === "ok"
314
+ const exitCode = result.status === "ok"
279
315
  ? 0
280
- : result.code === "REVIEW_NOT_RUN"
316
+ : result.code === "REVIEW_NOT_RUN" || result.code === "BATCH_NOT_RUN"
281
317
  ? 2
282
- : 1);
318
+ : 1;
319
+ if (args.output !== undefined) {
320
+ // The envelope goes to the file whole; stdout carries one line, so an
321
+ // agent needs neither a shell redirect nor the whole report in context.
322
+ try {
323
+ await writeFile(resolve(args.output), `${JSON.stringify(result.data === undefined ? { ...result, data: null } : result)}\n`, { mode: 0o600 });
324
+ }
325
+ catch {
326
+ throw new AgentOpsError("CLI_OUTPUT_WRITE_FAILED", `Could not write ${args.output}.`);
327
+ }
328
+ io.writeStdout(`agent-ops: ${result.code} (${result.status}); envelope written to ${args.output}\n`);
329
+ return exitCode;
330
+ }
331
+ return writeAndReturn(io, result, args.json, exitCode);
283
332
  }
284
333
  catch (error) {
285
334
  if (error instanceof CliArgumentError) {
@@ -0,0 +1,106 @@
1
+ import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
2
+ import { DEFAULT_BATCH_WIDTH, runBatch } from "../../../../runtime/src/review/batch.js";
3
+ import { resolveBatchBase, selectBatchTasks } from "../../../../runtime/src/review/batch-select.js";
4
+ import { redactSecrets } from "../../../../runtime/src/security/redact.js";
5
+ import { safeTaskText } from "../../../../runtime/src/task/render.js";
6
+ import { errorEnvelope } from "../output.js";
7
+ function outcomeLabel(task) {
8
+ if (task.aborted) {
9
+ return "aborted";
10
+ }
11
+ if (task.review === null) {
12
+ return task.verify === "FAIL" ? "no review (verify failed)" : "not reviewed";
13
+ }
14
+ const reason = task.review.reason === undefined ? "" : ` ${task.review.reason}`;
15
+ return `${task.review.status}${reason}${task.review.reused === true ? " (reused)" : ""}${task.retried ? " (retried)" : ""}`;
16
+ }
17
+ function renderBatch(data) {
18
+ const lines = [
19
+ `Batch of ${data.tasks.length} task(s) under ${data.parentTaskId}, width ${data.width}.`,
20
+ "",
21
+ ...data.tasks.map((task) => `- ${task.taskId} verify ${task.verify} review ${outcomeLabel(task)} base ${task.base.slice(0, 12)} (${task.baseSource}) ${safeTaskText(redactSecrets(task.title))}`)
22
+ ];
23
+ if (data.tasks.some((task) => task.aborted)) {
24
+ lines.push("", "The source changed or the run was interrupted; unfinished tasks were aborted. Run the batch again.");
25
+ }
26
+ return lines.join("\n");
27
+ }
28
+ function isPass(task) {
29
+ return !task.aborted && task.review?.status === "PASS";
30
+ }
31
+ /**
32
+ * The envelope's code follows the worst outcome that needs a human: a failed
33
+ * verify or review (fix the code) before a run that could not finish (run it
34
+ * again), so one defect is never hidden behind an unrelated NOT_RUN.
35
+ */
36
+ function classify(tasks) {
37
+ if (tasks.every(isPass)) {
38
+ return "BATCH_RESULT";
39
+ }
40
+ return tasks.some((task) => task.verify === "FAIL" || task.review?.status === "FAIL")
41
+ ? "BATCH_FAILED"
42
+ : "BATCH_NOT_RUN";
43
+ }
44
+ export async function runBatchCommand(options) {
45
+ const { args } = options;
46
+ const parentTaskId = args.parentTaskId;
47
+ if (parentTaskId === undefined) {
48
+ throw new AgentOpsError("BATCH_PARENT_REQUIRED", "Batch requires --parent.");
49
+ }
50
+ const selected = selectBatchTasks(await options.tasks.list(), parentTaskId);
51
+ const plan = selected.map((record) => ({
52
+ record,
53
+ ...resolveBatchBase(record, record.task.id === parentTaskId, {
54
+ ...(args.base === undefined ? {} : { base: args.base }),
55
+ ...(args.parentBase === undefined ? {} : { parentBase: args.parentBase }),
56
+ ...(options.worktreeBase === undefined ? {} : { worktreeBase: options.worktreeBase })
57
+ })
58
+ }));
59
+ const baseOf = new Map(plan.map((item) => [item.record.task.id, item.base]));
60
+ const base = (id) => baseOf.get(id);
61
+ // The first answer is the clean-tree check: a batch reviews committed ranges.
62
+ if (!await options.guard()) {
63
+ return {
64
+ ...errorEnvelope("BATCH_DIRTY_WORKTREE", "Batch needs a clean working tree. Commit or discard your changes first."),
65
+ data: null
66
+ };
67
+ }
68
+ const width = args.width ?? DEFAULT_BATCH_WIDTH;
69
+ const outcomes = await runBatch(plan.map((item) => item.record.task.id), {
70
+ isVerified: (id) => options.isVerified(id, base(id)),
71
+ verify: (id, signal) => options.verify(id, base(id), signal),
72
+ review: (id, signal) => options.review(id, base(id), signal),
73
+ guard: options.guard
74
+ }, {
75
+ width,
76
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
77
+ ...(options.onProgress === undefined ? {} : { onProgress: options.onProgress })
78
+ });
79
+ const byId = new Map(outcomes.map((outcome) => [outcome.id, outcome]));
80
+ const tasks = plan.map((item) => {
81
+ const outcome = byId.get(item.record.task.id);
82
+ return {
83
+ taskId: item.record.task.id,
84
+ title: item.record.task.title,
85
+ base: item.base,
86
+ baseSource: item.source,
87
+ verify: outcome.verify,
88
+ review: outcome.review,
89
+ retried: outcome.retried,
90
+ aborted: outcome.aborted
91
+ };
92
+ });
93
+ const code = classify(tasks);
94
+ const passed = tasks.filter(isPass).length;
95
+ const message = code === "BATCH_RESULT"
96
+ ? `Batch passed: ${passed} of ${tasks.length} task(s).`
97
+ : code === "BATCH_FAILED"
98
+ ? `Batch failed: ${passed} of ${tasks.length} task(s) passed.`
99
+ : `Batch was not completed: ${passed} of ${tasks.length} task(s) passed.`;
100
+ const summary = { message, parentTaskId, width, tasks };
101
+ const data = { ...summary, text: `${message}\n\n${renderBatch(summary)}` };
102
+ if (code === "BATCH_RESULT") {
103
+ return { code, status: "ok", data, errors: [] };
104
+ }
105
+ return { code, status: "error", data, errors: [{ code, message }] };
106
+ }
@@ -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,
@@ -196,6 +196,28 @@ async function preflightReview(options, context, sourceFingerprint) {
196
196
  summary: { status: "PASS", sourceFingerprint, commands }
197
197
  };
198
198
  }
199
+ /**
200
+ * Whether the task already holds current PASS verification evidence for the
201
+ * source a review of `base` (or the working tree) would measure. This is the
202
+ * same preflight `review` runs before it spawns, so a batch that skips verify
203
+ * on a true answer never reaches a review that then reports stale evidence.
204
+ */
205
+ export async function hasFreshVerification(options, taskId, base) {
206
+ if (options.root === undefined || options.gitRunner === undefined) {
207
+ return false;
208
+ }
209
+ const context = await taskContext({ ...options, taskId });
210
+ if (context === undefined) {
211
+ return false;
212
+ }
213
+ const scope = await resolveReviewScope({
214
+ root: options.root,
215
+ runner: options.gitRunner,
216
+ ...(base === undefined ? {} : { base })
217
+ });
218
+ const fingerprint = await calculateSourceFingerprint(options.root, scope, options.gitRunner);
219
+ return (await preflightReview(options, context, fingerprint)).ok;
220
+ }
199
221
  function scopeReason(error) {
200
222
  if (!(error instanceof AgentOpsError)) {
201
223
  return undefined;
@@ -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