@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
@@ -0,0 +1,89 @@
1
+ import { execFile, execFileSync } from "node:child_process";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
5
+ import { localStatePaths } from "../../../runtime/src/security/permissions.js";
6
+ import { FileTrustStore } from "../../../runtime/src/security/trust.js";
7
+ import { TaskService } from "../../../runtime/src/task/service.js";
8
+ import { FileTaskStore } from "../../../runtime/src/task/store.js";
9
+ import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
10
+ import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
11
+ import { loadEffectiveConfig, repositoryTrust, repositoryTrustBinding } from "./context.js";
12
+ import { CLI_VERSION } from "./version.js";
13
+ // Shared by the CLI and the hook entry point: Claude Code runs hooks through
14
+ // hook-entry.js, never bin.js, and its worktree guard needs these too.
15
+ export function gitRunner(root) {
16
+ return {
17
+ run: async (gitArgs) => {
18
+ try {
19
+ return {
20
+ exitCode: 0,
21
+ stdout: execFileSync("git", [...gitArgs], {
22
+ cwd: root,
23
+ encoding: "buffer",
24
+ stdio: ["ignore", "pipe", "ignore"]
25
+ })
26
+ };
27
+ }
28
+ catch (error) {
29
+ const failure = error;
30
+ return {
31
+ exitCode: failure.status ?? 1,
32
+ stdout: failure.stdout ?? new Uint8Array()
33
+ };
34
+ }
35
+ }
36
+ };
37
+ }
38
+ export function trustStore() {
39
+ const state = localStatePaths(process.env.AGENT_OPS_HOME ?? homedir());
40
+ return new FileTrustStore(state.trustStore, state.anchorDirectory);
41
+ }
42
+ export function worktreeDependencies() {
43
+ const gateFor = async (root, config) => new CompletionGateService({
44
+ root,
45
+ config,
46
+ gitRunner: gitRunner(root),
47
+ taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
48
+ evidenceStore: new FileEvidenceStore(root, root)
49
+ });
50
+ return {
51
+ git: async (cwd, gitArgs) => await new Promise((resolve) => {
52
+ execFile("git", [...gitArgs], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => resolve({
53
+ exitCode: error === null ? 0 : typeof error.code === "number" ? error.code : 1,
54
+ stdout,
55
+ stderr
56
+ }));
57
+ }),
58
+ loadConfig: async (root) => (await loadEffectiveConfig(root, "project")).config,
59
+ trust: {
60
+ status: async (root, config) => await repositoryTrust(root, config, CLI_VERSION),
61
+ grant: async (root, config) => await trustStore().grant(await repositoryTrustBinding(root, config, CLI_VERSION)),
62
+ revoke: async (root, config) => {
63
+ await trustStore().revoke(await repositoryTrustBinding(root, config, CLI_VERSION));
64
+ }
65
+ },
66
+ gate: gateFor,
67
+ tasks: (root, base) => new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root), base === undefined ? {} : { completion: {
68
+ root,
69
+ gitRunner: gitRunner(root),
70
+ base,
71
+ loadConfig: async () => (await loadEffectiveConfig(root, "project")).config
72
+ } }),
73
+ processRunner: new NodeVerificationProcessRunner(),
74
+ runSetup: async (cwd, step) => await new Promise((resolve) => {
75
+ execFile(step.command, [...step.args], {
76
+ cwd,
77
+ encoding: "utf8",
78
+ timeout: step.timeoutMs,
79
+ maxBuffer: 16 * 1024 * 1024,
80
+ ...(process.platform === "win32" ? { shell: true } : {})
81
+ }, (error, stdout, stderr) => resolve({
82
+ exitCode: error === null
83
+ ? 0
84
+ : typeof error.code === "number" && error.killed !== true ? error.code : null,
85
+ output: `${stdout}${stderr}`
86
+ }));
87
+ })
88
+ };
89
+ }
@@ -12,7 +12,8 @@ export function agyHookOutput(event, result) {
12
12
  ? `agent-ops: ${result.code}`
13
13
  : `agent-ops: ${result.code}: ${result.remedy}`;
14
14
  const value = event === "PreToolUse"
15
- ? result.code === "COMPLETION_GATE_PERMIT_CONFIRMATION"
15
+ ? result.code === "COMPLETION_GATE_PERMIT_CONFIRMATION" ||
16
+ result.code === "WORKTREE_REMOVE_CONFIRMATION"
16
17
  ? { decision: "force_ask", reason }
17
18
  : result.action === "block"
18
19
  ? { decision: "deny", reason }
@@ -11,6 +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
15
  const CLAUDE_LOOP_LAUNCHER = "${CLAUDE_PROJECT_DIR}/.claude/hooks/agent-ops-loop.sh";
15
16
  const CLAUDE_WINDOWS_LOOP_LAUNCHER = "${CLAUDE_PROJECT_DIR}/.claude/hooks/agent-ops-loop.ps1";
16
17
  export function claudeSettingsTarget(scope) {
@@ -41,12 +42,15 @@ function commandHook(event, runtimePath, completionGate = false) {
41
42
  CLAUDE_HOOK_MARKER,
42
43
  ...(completionGate ? ["--completion-gate"] : [])
43
44
  ],
44
- timeout: 30
45
+ // PreToolUse may create the session's worktree, setup steps included.
46
+ timeout: event === "PreToolUse" ? 600 : 30
45
47
  };
46
48
  }
47
49
  function matcherGroup(event, runtimePath, completionGate = false) {
48
50
  return {
49
- ...(event === "PreToolUse" ? { matcher: "Bash" } : {}),
51
+ // File tools as well as Bash: in worktree auto mode the guard must see a
52
+ // direct edit of the shared checkout, which never passes through a shell.
53
+ ...(event === "PreToolUse" ? { matcher: CLAUDE_PRE_TOOL_MATCHER } : {}),
50
54
  hooks: [commandHook(event, runtimePath, completionGate)]
51
55
  };
52
56
  }
@@ -1,5 +1,13 @@
1
+ import { resolve } from "node:path";
1
2
  import { normalizeHookEvent } from "../../hooks/normalize.js";
2
3
  import { normalizeShellHookEvent } from "../../hooks/shell.js";
4
+ /** Claude's tools that write a file without a shell. */
5
+ export const CLAUDE_FILE_TOOLS = new Set([
6
+ "Edit",
7
+ "MultiEdit",
8
+ "NotebookEdit",
9
+ "Write"
10
+ ]);
3
11
  function isRecord(value) {
4
12
  return typeof value === "object" && value !== null && !Array.isArray(value);
5
13
  }
@@ -39,6 +47,22 @@ export function normalizeClaudeHookInput(input) {
39
47
  fullyIdle: input.stop_hook_active !== true
40
48
  };
41
49
  }
50
+ if (input.hook_event_name === "PreToolUse" &&
51
+ typeof input.tool_name === "string" &&
52
+ CLAUDE_FILE_TOOLS.has(input.tool_name) &&
53
+ isRecord(input.tool_input) &&
54
+ typeof projectRoot === "string" &&
55
+ projectRoot.length > 0) {
56
+ const target = input.tool_input.file_path ?? input.tool_input.notebook_path;
57
+ if (typeof target === "string" && target.length > 0 && !target.includes("\0")) {
58
+ return {
59
+ event: "file-write",
60
+ projectRoot,
61
+ paths: [resolve(projectRoot, target)],
62
+ ...(sessionId === undefined ? {} : { sessionId })
63
+ };
64
+ }
65
+ }
42
66
  if (input.hook_event_name === "PreToolUse" &&
43
67
  input.tool_name === "Bash" &&
44
68
  isRecord(input.tool_input) &&
@@ -19,7 +19,8 @@ export function claudeHookOutput(event, result) {
19
19
  });
20
20
  }
21
21
  if (event === "PreToolUse" &&
22
- result.code === "COMPLETION_GATE_PERMIT_CONFIRMATION") {
22
+ (result.code === "COMPLETION_GATE_PERMIT_CONFIRMATION" ||
23
+ result.code === "WORKTREE_REMOVE_CONFIRMATION")) {
23
24
  // Asked, never allowed: a one-time Stop permit is the user's to grant, and
24
25
  // an agent that could answer this for itself would hold the key to its own
25
26
  // gate.
@@ -110,9 +110,15 @@ export function mergeConfigLayers(inputLayers) {
110
110
  const reviewRoles = new Map();
111
111
  let schemaVersion;
112
112
  let features;
113
+ let worktree;
113
114
  for (const layer of layers) {
114
115
  schemaVersion = effective(layer.config.schemaVersion, layer);
115
116
  features = effective(layer.config.features, layer);
117
+ // Whole-block override: a workflow choice, not a guardrail, so a project
118
+ // may turn a user-level `auto` off.
119
+ if (layer.config.worktree !== undefined) {
120
+ worktree = effective(layer.config.worktree, layer);
121
+ }
116
122
  for (const profile of layer.config.profiles) {
117
123
  profiles.set(profile, effective(profile, layer));
118
124
  }
@@ -158,7 +164,8 @@ export function mergeConfigLayers(inputLayers) {
158
164
  verificationCommands: [...commands.values()],
159
165
  pathMappings: [...mappings.values()],
160
166
  securityExceptions: [...exceptions.values()],
161
- reviewRoles: [...reviewRoles.values()]
167
+ reviewRoles: [...reviewRoles.values()],
168
+ ...(worktree === undefined ? {} : { worktree })
162
169
  };
163
170
  const config = {
164
171
  schemaVersion: schemaVersion.value,
@@ -175,7 +182,8 @@ export function mergeConfigLayers(inputLayers) {
175
182
  ? {}
176
183
  : {
177
184
  reviewRoles: provenance.reviewRoles.map(({ value }) => value)
178
- })
185
+ }),
186
+ ...(worktree === undefined ? {} : { worktree: worktree.value })
179
187
  };
180
188
  const validation = validateConfig(config);
181
189
  if (!validation.ok) {
@@ -407,7 +407,7 @@ function parseSessionMarker(source) {
407
407
  * ponytail: last writer wins. Two concurrent sessions in one checkout already
408
408
  * violate the one-writer rule; pass `--session` explicitly if you need that.
409
409
  */
410
- async function writeSessionMarker(options) {
410
+ export async function writeSessionMarker(options) {
411
411
  const path = loopPath(options.root, options.harness, "loop-state.md");
412
412
  await withPrivateFileLock(path, options.root, async () => {
413
413
  const source = await readPrivateFile(path, options.root);
@@ -491,6 +491,8 @@ async function restoreState(options) {
491
491
  function clamp(value, limit) {
492
492
  return value.length <= limit ? value : `${value.slice(0, limit - 14)}\n[truncated]`;
493
493
  }
494
+ /** Printed only when it is a plain token: it lands verbatim in agent context. */
495
+ const PRINTABLE_SESSION = /^[A-Za-z0-9._:-]{1,128}$/u;
494
496
  function sessionContext(options) {
495
497
  // Task state is assembled first and clamped on its own budget, so a long
496
498
  // goal can no longer crowd out the one thing recovery exists to carry.
@@ -516,8 +518,15 @@ function sessionContext(options) {
516
518
  return [];
517
519
  })();
518
520
  const telemetry = `Telemetry: ${options.telemetryEntries} recent redacted event(s).`;
521
+ // The id every `--session` flag wants. Hooks hear it; the agent otherwise
522
+ // never does, and a shared checkout's recorded marker belongs to whichever
523
+ // session started last.
524
+ const session = options.sessionId !== undefined && PRINTABLE_SESSION.test(options.sessionId)
525
+ ? [`Session: ${options.sessionId}`]
526
+ : [];
519
527
  const fixed = [
520
528
  "agent-ops project loop is active.",
529
+ ...session,
521
530
  "",
522
531
  ...restored,
523
532
  telemetry
@@ -648,6 +657,7 @@ export async function runProjectLoop(options) {
648
657
  telemetryCount(root, options.harness)
649
658
  ]);
650
659
  return sessionOutput(options.harness, sessionContext({
660
+ ...(sessionId === undefined ? {} : { sessionId }),
651
661
  goal: safeGoalContext(goal),
652
662
  telemetryEntries,
653
663
  mode: restoreMode(options.input),
@@ -1,20 +1,37 @@
1
- import { join } from "node:path";
1
+ import { lstat, readdir } from "node:fs/promises";
2
+ import { isAbsolute, join } from "node:path";
2
3
  import { sha256 } from "../fs/hash.js";
3
4
  import { AgentOpsError } from "../fs/paths.js";
4
5
  import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
5
6
  import { checkTaskCompletionEvidence, findIncompleteSubtask } from "../task/completion.js";
6
7
  import { collectBaseChangePaths, collectChangeSurface } from "../verify/change-surface.js";
7
8
  import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
9
+ import { readWorktreeRecord } from "../parallel/service.js";
8
10
  const FINGERPRINT = /^[a-f0-9]{64}$/u;
9
11
  const SESSION = /^[^\0\r\n]{1,256}$/u;
12
+ function gateDirectory(root) {
13
+ return join(root, ".agent-ops", "tasks", "completion-gate");
14
+ }
10
15
  function shellQuote(value) {
11
16
  return `'${value.replaceAll("'", "'\"'\"'")}'`;
12
17
  }
18
+ async function isDirectory(path) {
19
+ try {
20
+ return (await lstat(path)).isDirectory();
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
13
26
  function gateResult(action, status, code, remedy) {
14
27
  return { action, status, code, ...(remedy === undefined ? {} : { remedy }) };
15
28
  }
16
29
  function statePath(root, sessionId) {
17
- return join(root, ".agent-ops", "tasks", "completion-gate", `${sha256(sessionId)}.json`);
30
+ return join(gateDirectory(root), `${sha256(sessionId)}.json`);
31
+ }
32
+ function validRoot(value) {
33
+ return typeof value === "string" && value.length > 0 && value.length <= 4096 &&
34
+ !value.includes("\0") && isAbsolute(value);
18
35
  }
19
36
  function parseState(source, sessionId) {
20
37
  if (source === null)
@@ -30,8 +47,9 @@ function parseState(source, sessionId) {
30
47
  throw new AgentOpsError("COMPLETION_GATE_STATE_INVALID", "Completion-gate state is invalid.");
31
48
  }
32
49
  const record = value;
33
- if (Object.keys(record).sort().join(",") !==
34
- "baselineFingerprint,permitFingerprint,schemaVersion,sessionId" ||
50
+ const keys = Object.keys(record).filter((key) => key !== "root").sort().join(",");
51
+ if (keys !== "baselineFingerprint,permitFingerprint,schemaVersion,sessionId" ||
52
+ ("root" in record && !validRoot(record.root)) ||
35
53
  record.schemaVersion !== 1 ||
36
54
  record.sessionId !== sessionId ||
37
55
  typeof record.baselineFingerprint !== "string" ||
@@ -55,6 +73,32 @@ export class FileCompletionGateStore {
55
73
  const path = statePath(this.#root, sessionId);
56
74
  return await withPrivateFileLock(path, this.#root, async () => parseState(await readPrivateFile(path, this.#root), sessionId));
57
75
  }
76
+ /** Every recorded session. An unreadable record is skipped, not repaired. */
77
+ async all() {
78
+ let names;
79
+ try {
80
+ names = await readdir(gateDirectory(this.#root));
81
+ }
82
+ catch {
83
+ return [];
84
+ }
85
+ const states = [];
86
+ for (const name of names.filter((entry) => /^[a-f0-9]{64}\.json$/u.test(entry))) {
87
+ try {
88
+ const source = await readPrivateFile(join(gateDirectory(this.#root), name), this.#root);
89
+ const record = source === null ? null : JSON.parse(source);
90
+ if (typeof record?.sessionId === "string") {
91
+ const state = await this.read(record.sessionId);
92
+ if (state !== null)
93
+ states.push(state);
94
+ }
95
+ }
96
+ catch {
97
+ continue;
98
+ }
99
+ }
100
+ return states;
101
+ }
58
102
  async mutate(sessionId, action) {
59
103
  if (!SESSION.test(sessionId)) {
60
104
  throw new AgentOpsError("COMPLETION_GATE_SESSION_INVALID", "Completion gate requires a valid session identity.");
@@ -68,6 +112,11 @@ export class FileCompletionGateStore {
68
112
  });
69
113
  }
70
114
  }
115
+ /** What the gate measures: the whole Git-visible change surface of `root`. */
116
+ export async function currentGateFingerprint(root, gitRunner) {
117
+ const surface = await collectChangeSurface(gitRunner);
118
+ return await calculateSourceFingerprint(root, { mode: "worktree", changedFiles: surface.paths }, gitRunner);
119
+ }
71
120
  export class CompletionGateService {
72
121
  #options;
73
122
  #store;
@@ -76,8 +125,63 @@ export class CompletionGateService {
76
125
  this.#store = options.stateStore ?? new FileCompletionGateStore(options.root);
77
126
  }
78
127
  async #fingerprint() {
79
- const surface = await collectChangeSurface(this.#options.gitRunner);
80
- return await calculateSourceFingerprint(this.#options.root, { mode: "worktree", changedFiles: surface.paths }, this.#options.gitRunner);
128
+ return await currentGateFingerprint(this.#options.root, this.#options.gitRunner);
129
+ }
130
+ /**
131
+ * Starts (or restarts) a session's record at a known baseline: the current
132
+ * source by default, or an explicit one a caller wants every Stop measured
133
+ * against.
134
+ */
135
+ async seed(sessionId, baselineFingerprint) {
136
+ const baseline = baselineFingerprint ?? await this.#fingerprint();
137
+ await this.#store.mutate(sessionId, () => ({
138
+ schemaVersion: 1,
139
+ sessionId,
140
+ baselineFingerprint: baseline,
141
+ permitFingerprint: null
142
+ }));
143
+ }
144
+ /** Points this checkout's record for a session at another worktree, or clears it. */
145
+ async redirect(sessionId, root) {
146
+ if (root === null && (await this.#store.read(sessionId))?.root === undefined) {
147
+ return;
148
+ }
149
+ const fingerprint = await this.#fingerprint();
150
+ await this.#store.mutate(sessionId, (current) => {
151
+ const { root: _previous, ...base } = current ?? {
152
+ schemaVersion: 1,
153
+ sessionId,
154
+ baselineFingerprint: fingerprint,
155
+ permitFingerprint: null
156
+ };
157
+ return root === null ? base : { ...base, root };
158
+ });
159
+ }
160
+ /**
161
+ * Moves every session whose baseline is `from` to `to`: a merge that moved
162
+ * HEAD under a session which changed nothing must not read as its change.
163
+ */
164
+ async rebase(from, to) {
165
+ let moved = 0;
166
+ for (const state of await this.#store.all()) {
167
+ if (state.baselineFingerprint !== from)
168
+ continue;
169
+ await this.#store.mutate(state.sessionId, (current) => current === null || current.baselineFingerprint !== from
170
+ ? current ?? state
171
+ : { ...current, baselineFingerprint: to });
172
+ moved += 1;
173
+ }
174
+ return moved;
175
+ }
176
+ async sessions() {
177
+ return await this.#store.all();
178
+ }
179
+ /**
180
+ * Whether the session's attached task would let it stop right now: complete,
181
+ * with current evidence and review. Null means it would.
182
+ */
183
+ async validate(sessionId) {
184
+ return await this.#validateTask(sessionId);
81
185
  }
82
186
  /**
83
187
  * The fingerprint the task's evidence should carry. Committed work leaves an
@@ -120,6 +224,12 @@ export class CompletionGateService {
120
224
  permitFingerprint: null
121
225
  });
122
226
  const changed = state.baselineFingerprint !== fingerprint;
227
+ if (!changed &&
228
+ this.#options.config.worktree?.mode === "auto" &&
229
+ state.root === undefined &&
230
+ await readWorktreeRecord(this.#options.root) === null) {
231
+ return gateResult("continue", "UNKNOWN", "COMPLETION_GATE_WORKTREE_MODE", `worktree.mode is auto. Session: ${sessionId}. Before your first edit run agent-ops task create from this checkout, which creates this session's worktree and puts the task there (or agent-ops worktree add <name> --session ${shellQuote(sessionId)}), and work only inside the printed path. Verify and review there, then finish with agent-ops worktree finish from this checkout, which completes the tasks; do not run task complete in the worktree.`);
232
+ }
123
233
  return gateResult("continue", changed ? "UNKNOWN" : "PASS", changed ? "COMPLETION_GATE_CHANGED" : "COMPLETION_GATE_READY", changed
124
234
  ? `Git-visible changes require an attached completed task, current PASS evidence, and a PASS review. Session: ${sessionId}. Create with --session ${sessionId}; one-time permit: agent-ops allow-stop --session ${sessionId}.`
125
235
  : undefined);
@@ -165,7 +275,19 @@ export class CompletionGateService {
165
275
  }
166
276
  return null;
167
277
  }
278
+ /** `agent-ops worktree remove --force`: discards work that exists nowhere else. */
279
+ #isForcedRemove(event) {
280
+ const commands = event.event === "command" ? [event] :
281
+ event.event === "command-batch" ? event.commands : [];
282
+ return commands.some(({ command, args }) => {
283
+ const words = [command, ...args];
284
+ return words.includes("worktree") && words.includes("remove") && words.includes("--force");
285
+ });
286
+ }
168
287
  async handle(event) {
288
+ if (this.#isForcedRemove(event)) {
289
+ return gateResult("block", "UNKNOWN", "WORKTREE_REMOVE_CONFIRMATION", "Allow this only if the user wants the worktree's uncommitted or unmerged work discarded.");
290
+ }
169
291
  if (this.#isPermitCommand(event)) {
170
292
  return gateResult("block", "UNKNOWN", "COMPLETION_GATE_PERMIT_CONFIRMATION", "Allow this command only to grant one Stop for the current source fingerprint.");
171
293
  }
@@ -192,7 +314,19 @@ export class CompletionGateService {
192
314
  if (state === null) {
193
315
  return gateResult("block", "UNKNOWN", "COMPLETION_GATE_NOT_INITIALIZED", "The session baseline is unavailable; continue once so PreInvocation can initialize it.");
194
316
  }
195
- if (state.baselineFingerprint !== fingerprint && state.permitFingerprint !== fingerprint) {
317
+ const changed = state.baselineFingerprint !== fingerprint && state.permitFingerprint !== fingerprint;
318
+ if (state.root !== undefined && state.root !== this.#options.root) {
319
+ // This checkout only has to stay untouched; the work, its task and its
320
+ // evidence live in the worktree, and that gate decides.
321
+ if (changed) {
322
+ return gateResult("block", "FAIL", "COMPLETION_GATE_MAIN_CHANGED", `This session works in ${state.root}, but this checkout changed too. Move those edits into the worktree, or ask the user for a one-time permit.`);
323
+ }
324
+ if (!await isDirectory(state.root) || this.#options.forRoot === undefined) {
325
+ return gateResult("block", "UNKNOWN", "COMPLETION_GATE_WORKTREE_MISSING", `The worktree ${state.root} is unavailable. Run agent-ops worktree list, then resume or remove it.`);
326
+ }
327
+ return await (await this.#options.forRoot(state.root)).handle(event);
328
+ }
329
+ if (changed) {
196
330
  const failure = await this.#validateTask(sessionId);
197
331
  if (failure !== null)
198
332
  return failure;
@@ -52,6 +52,11 @@ export async function dispatchHookEvent(event, options) {
52
52
  if (event.event === "unsupported") {
53
53
  return continueWith("UNKNOWN", "HOOK_EVENT_UNSUPPORTED");
54
54
  }
55
+ if (event.event === "file-write") {
56
+ return options.worktreeGuard === undefined
57
+ ? continueWith("PASS", "HOOK_NOOP")
58
+ : await options.worktreeGuard(event);
59
+ }
55
60
  if (event.event === "session-start" &&
56
61
  options.capabilities.includes("lifecycle-summary") &&
57
62
  options.advisory !== undefined) {
@@ -495,7 +495,10 @@ export async function doctorInstallation(options) {
495
495
  await checkProbe("smoke-availability", options.probes?.smokeAvailability),
496
496
  await checkReviewTargets(config.config, options.probes?.reviewTarget, options.checkReviewTargetAuth === true, options.checkAuthTargets),
497
497
  await checkHostSandbox(options.probes?.hostRestriction),
498
- checkVerificationCommands(config.config)
498
+ checkVerificationCommands(config.config),
499
+ ...(options.probes?.worktrees === undefined
500
+ ? []
501
+ : [await checkProbe("worktrees", options.probes.worktrees)])
499
502
  ];
500
503
  return {
501
504
  checks,
@@ -311,7 +311,7 @@ export function managedRules(descriptor, context) {
311
311
  lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain complete independent review before claiming completion, via", " `agent-ops review --task <task-id> --yes` (or the CLI's equivalent invocation).", " Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Start this command through the trusted outer host runner as its first", " and only invocation. On Codex, the outer tool request must use", " `sandbox_permissions: \"require_escalated\"` so network and loopback", " are available; in that same request, unset", " `CODEX_SANDBOX_NETWORK_DISABLED` and set `AGENT_OPS_HOST` when three", " targets are configured. The Codex shell form is", " `env -u CODEX_SANDBOX_NETWORK_DISABLED AGENT_OPS_HOST=<current-host> agent-ops review --task <task-id> --yes`.", " Do not run it once in the restricted sandbox and retry; changing the", " variable inside that sandbox is not elevation.", " Review runs exactly two fresh sessions: with three configured targets,", " set `AGENT_OPS_HOST` to the current CLI id so it is excluded; with two", " targets both run in configured order; with one target it runs twice.", " The first session is necessary; a PASS then requires an adversarial", " re-check carrying the first report. A first FAIL or NOT_RUN stops.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
312
312
  }
313
313
  if (context.capabilities.includes("task")) {
314
- lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria, verification, and independent review;", "completing one never completes its parent.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "");
314
+ lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria, verification, and independent review;", "completing one never completes its parent.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "", "Parallel conversations in one repository each need their own worktree.", "When `.agent-ops/config.json` sets `worktree.mode` to `auto`, run", "`agent-ops task create` from the main checkout before your first edit:", "it creates this session's worktree and puts the task there (`agent-ops", "worktree add <name> --session <session-id>` does the same without a", "task). Work only inside the printed path", "(Claude Code: EnterWorktree with that path; otherwise use it as every", "command's working directory). Commit, then run verify and review for", "each task with the printed `--base`; do not run `task complete` in the", "worktree. Leave it (Claude Code: ExitWorktree with action keep) and run", "`agent-ops worktree finish <name>` from the main checkout: it completes", "every task in the worktree, subtasks first, then merges. A finish that", "reports an incomplete task names it; verify or review that task again", "in the worktree and finish again. A finish that reports a conflict is", "resolved once, as a new task whose criteria cover both intents; if its", "verification or review fails, reset as instructed and report.", "");
315
315
  }
316
316
  if (context.capabilities.includes("lifecycle-summary")) {
317
317
  lines.push("Advisory lifecycle summaries and local logs are informational. Advisory", "failures must remain fail-open and cannot become verification evidence.", "");
@@ -102,7 +102,10 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
102
102
  },
103
103
  pathMappings: existing?.pathMappings ?? [],
104
104
  securityExceptions: existing?.securityExceptions ?? [],
105
- ...(reviewRoles === undefined ? {} : { reviewRoles: [...reviewRoles] })
105
+ ...(reviewRoles === undefined ? {} : { reviewRoles: [...reviewRoles] }),
106
+ ...(existing?.worktree === undefined
107
+ ? {}
108
+ : { worktree: structuredClone(existing.worktree) })
106
109
  };
107
110
  }
108
111
  async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = [], completionGateEnabled = false) {