@kylecheng3146/agent-ops 0.2.8 → 0.3.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.
@@ -9,13 +9,15 @@ export const COMMAND_NAMES = [
9
9
  "task",
10
10
  "verify",
11
11
  "review",
12
- "allow-stop"
12
+ "allow-stop",
13
+ "worktree"
13
14
  ];
14
15
  const COMMAND_SET = new Set(COMMAND_NAMES);
15
16
  const SCOPES = new Set(["project", "user"]);
16
17
  const PROFILES = new Set(["advisory", "core", "guardrails", "loop"]);
17
18
  // opencode is absent: it has no read-only flag, so it cannot review.
18
19
  const REVIEW_TARGETS = new Set(["agy", "claude", "codex"]);
20
+ export const WORKTREE_ACTIONS = ["add", "finish", "list", "resume", "remove"];
19
21
  export class CliArgumentError extends Error {
20
22
  code;
21
23
  option;
@@ -82,6 +84,8 @@ export function parseArgs(argv) {
82
84
  let rerun = false;
83
85
  let helpSeen = false;
84
86
  let versionSeen = false;
87
+ let worktreeName;
88
+ let force = false;
85
89
  for (let index = 0; index < argv.length; index += 1) {
86
90
  const token = argv[index];
87
91
  if (token === undefined) {
@@ -243,6 +247,12 @@ export function parseArgs(argv) {
243
247
  }
244
248
  yes = true;
245
249
  break;
250
+ case "--force":
251
+ if (force) {
252
+ duplicate(token);
253
+ }
254
+ force = true;
255
+ break;
246
256
  case "--rerun":
247
257
  if (rerun) {
248
258
  duplicate(token);
@@ -292,6 +302,16 @@ export function parseArgs(argv) {
292
302
  action = token;
293
303
  break;
294
304
  }
305
+ if (command === "worktree" &&
306
+ action === undefined &&
307
+ WORKTREE_ACTIONS.includes(token)) {
308
+ action = token;
309
+ break;
310
+ }
311
+ if (command === "worktree" && action !== undefined && action !== "list" && worktreeName === undefined) {
312
+ worktreeName = token;
313
+ break;
314
+ }
295
315
  if (command !== undefined) {
296
316
  throw new CliArgumentError("CLI_UNEXPECTED_ARGUMENT", `Unexpected argument: ${token}`);
297
317
  }
@@ -346,7 +366,8 @@ export function parseArgs(argv) {
346
366
  checkAuthTargets.length > 0 ||
347
367
  dryRun ||
348
368
  yes ||
349
- rerun) {
369
+ rerun ||
370
+ force) {
350
371
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Only --json may be combined with global help or version.");
351
372
  }
352
373
  command = helpSeen ? "help" : "version";
@@ -357,6 +378,12 @@ export function parseArgs(argv) {
357
378
  if (command === "trust" && action === undefined) {
358
379
  throw new CliArgumentError("CLI_ACTION_REQUIRED", "The trust command requires one of: status, grant, revoke.");
359
380
  }
381
+ if (command === "worktree" && action === undefined) {
382
+ throw new CliArgumentError("CLI_ACTION_REQUIRED", `The worktree command requires one of: ${WORKTREE_ACTIONS.join(", ")}.`);
383
+ }
384
+ if (command === "worktree" && action !== "list" && worktreeName === undefined) {
385
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", `worktree ${action} requires a worktree name.`);
386
+ }
360
387
  if (command === "task" && action === undefined) {
361
388
  throw new CliArgumentError("CLI_ACTION_REQUIRED", "The task command requires one of: create, status, attach, complete, archive, export.");
362
389
  }
@@ -369,6 +396,7 @@ export function parseArgs(argv) {
369
396
  command !== "verify" &&
370
397
  command !== "review" &&
371
398
  command !== "allow-stop" &&
399
+ !(command === "worktree" && sessionId !== undefined && taskId === undefined) &&
372
400
  (hasTaskTargetOptions || hasTaskMutationOptions)) {
373
401
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Task target options may be used only with task or verify.");
374
402
  }
@@ -483,9 +511,27 @@ export function parseArgs(argv) {
483
511
  yes)) {
484
512
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "allow-stop requires only --session (and optional --json).");
485
513
  }
514
+ if (command === "worktree" &&
515
+ (scope !== undefined ||
516
+ harness !== undefined ||
517
+ profiles.length > 0 ||
518
+ hasTaskMutationOptions ||
519
+ dryRun ||
520
+ yes ||
521
+ rerun)) {
522
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "worktree accepts only a name, --session and --json.");
523
+ }
524
+ if (force && !(command === "worktree" && action === "remove")) {
525
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--force may be used only with worktree remove.");
526
+ }
527
+ if (command === "worktree" && action !== "add" && action !== "resume" && sessionId !== undefined) {
528
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", `worktree ${action} takes the session from the worktree; omit --session.`);
529
+ }
486
530
  return {
487
531
  command,
488
532
  ...(action === undefined ? {} : { action }),
533
+ ...(worktreeName === undefined ? {} : { worktreeName }),
534
+ ...(force ? { force } : {}),
489
535
  ...(scope === undefined ? {} : { scope }),
490
536
  ...(harness === undefined ? {} : { harness }),
491
537
  ...(hookTargets.length === 0 ? {} : { hookTargets }),
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { execFileSync, spawn } from "node:child_process";
6
+ import { execFile, execFileSync, spawn } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { commonHarnessAdapters, harnessHookPath, HARNESS_IDS } from "../../../runtime/src/install/harness.js";
9
9
  import { agyRuntimeStatus, agyVersionSupported, hookRegistrationDrift, repositoryTrustStatus, smokeAvailabilityStatus } from "../../../runtime/src/install/probes.js";
@@ -42,6 +42,8 @@ import { CompletionGateService } from "../../../runtime/src/hooks/completion-gat
42
42
  import { formatUpdatePlan, runUpdateCommand } from "./commands/update.js";
43
43
  import { errorEnvelope } from "./output.js";
44
44
  import { runAgyHeadless } from "./agy-headless.js";
45
+ import { runWorktreeCommand } from "./commands/parallel.js";
46
+ import { listWorktrees, worktreeDoctorResult } from "../../../runtime/src/parallel/manage.js";
45
47
  const HOOK_RUNTIME_PATH = fileURLToPath(new URL("./hook-entry.js", import.meta.url));
46
48
  async function readOptionalText(path) {
47
49
  try {
@@ -121,6 +123,59 @@ async function plannedTrustBinding(root, projectConfig) {
121
123
  ? null
122
124
  : await repositoryTrustBinding(root, config, CLI_VERSION);
123
125
  }
126
+ function worktreeDependencies() {
127
+ const gateFor = async (root, config) => new CompletionGateService({
128
+ root,
129
+ config,
130
+ gitRunner: gitRunner(root),
131
+ taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
132
+ evidenceStore: new FileEvidenceStore(root, root)
133
+ });
134
+ return {
135
+ git: async (cwd, gitArgs) => await new Promise((resolve) => {
136
+ execFile("git", [...gitArgs], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => resolve({
137
+ exitCode: error === null ? 0 : typeof error.code === "number" ? error.code : 1,
138
+ stdout,
139
+ stderr
140
+ }));
141
+ }),
142
+ loadConfig: async (root) => (await loadEffectiveConfig(root, "project")).config,
143
+ trust: {
144
+ status: async (root, config) => await repositoryTrust(root, config, CLI_VERSION),
145
+ grant: async (root, config) => await trustStore().grant(await repositoryTrustBinding(root, config, CLI_VERSION)),
146
+ revoke: async (root, config) => {
147
+ await trustStore().revoke(await repositoryTrustBinding(root, config, CLI_VERSION));
148
+ }
149
+ },
150
+ gate: gateFor,
151
+ tasks: (root) => new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
152
+ processRunner: new NodeVerificationProcessRunner(),
153
+ runSetup: async (cwd, step) => await new Promise((resolve) => {
154
+ execFile(step.command, [...step.args], {
155
+ cwd,
156
+ encoding: "utf8",
157
+ timeout: step.timeoutMs,
158
+ maxBuffer: 16 * 1024 * 1024,
159
+ ...(process.platform === "win32" ? { shell: true } : {})
160
+ }, (error, stdout, stderr) => resolve({
161
+ exitCode: error === null
162
+ ? 0
163
+ : typeof error.code === "number" && error.killed !== true ? error.code : null,
164
+ output: `${stdout}${stderr}`
165
+ }));
166
+ })
167
+ };
168
+ }
169
+ async function worktreeDoctorProbe(root) {
170
+ let statuses;
171
+ try {
172
+ statuses = await listWorktrees(worktreeDependencies(), root);
173
+ }
174
+ catch {
175
+ return { status: "PASS", message: "No agent-ops worktrees to inspect." };
176
+ }
177
+ return worktreeDoctorResult(statuses, Date.now());
178
+ }
124
179
  const argv = process.argv.slice(2);
125
180
  function runAgy(args, options = {}) {
126
181
  return execFileSync("agy", [...args], {
@@ -188,7 +243,8 @@ else {
188
243
  "task",
189
244
  "verify",
190
245
  "review",
191
- "allow-stop"
246
+ "allow-stop",
247
+ "worktree"
192
248
  ].map((command) => [command, async (args) => {
193
249
  const root = args.scope === "user"
194
250
  ? process.env.AGENT_OPS_HOME ?? homedir()
@@ -224,6 +280,13 @@ else {
224
280
  confirm: async (plan, trust, warnings) => await confirmInit(plan, trust, warnings)
225
281
  });
226
282
  }
283
+ if (args.command === "worktree") {
284
+ return await runWorktreeCommand({
285
+ args,
286
+ cwd: root,
287
+ deps: worktreeDependencies()
288
+ });
289
+ }
227
290
  if (args.command === "doctor") {
228
291
  const doctorManifest = await installedManifest(root);
229
292
  const config = (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config;
@@ -307,7 +370,8 @@ else {
307
370
  }
308
371
  : { status };
309
372
  },
310
- reviewTarget: async (target, deep) => await probeReviewTarget(target, { cwd: root, deep })
373
+ reviewTarget: async (target, deep) => await probeReviewTarget(target, { cwd: root, deep }),
374
+ worktrees: async () => await worktreeDoctorProbe(root)
311
375
  },
312
376
  ...(args.checkAuth === true
313
377
  ? { checkReviewTargetAuth: true }
@@ -29,6 +29,8 @@ Commands:
29
29
  review Run an independent review
30
30
  allow-stop Grant one fingerprint-bound completion-gate Stop permit (requires --session)
31
31
  agy-run Run headless agy with a process-exit completion recheck
32
+ worktree <add|finish|list|resume|remove>
33
+ Give one session its own Git worktree for parallel work
32
34
 
33
35
  Options:
34
36
  --scope <project|user>
@@ -179,6 +181,35 @@ agent cannot self-authorize it.
179
181
  Options:
180
182
  --session <id> Required
181
183
  --json
184
+ `,
185
+ worktree: `Usage: agent-ops worktree <add|finish|resume|remove> <name> [options]
186
+ agent-ops worktree list [--json]
187
+
188
+ Give one writing session its own Git worktree, so parallel sessions stop
189
+ voiding each other's verification and review. Run it from the main checkout.
190
+
191
+ add <name> Create .worktrees/<name> on branch agent-ops/<name> from HEAD,
192
+ copy the ignored agent-ops files and .worktreeinclude matches,
193
+ inherit trust for an identical config, run worktree.setup, and
194
+ point the session's completion gate at the new worktree.
195
+ finish <name>
196
+ Once its task is complete, fast-forward the main checkout's
197
+ branch to the worktree's (after a clean rebase and
198
+ re-verification if the branch moved), record the task in
199
+ git notes, and remove the worktree. One finish runs at a time.
200
+ list Every agent-ops worktree: session, commits ahead, uncommitted
201
+ changes, task status and last activity.
202
+ resume <name>
203
+ Hand a worktree whose session ended to --session <id>.
204
+ remove <name>
205
+ Delete the worktree and its branch. Uncommitted or unmerged
206
+ work needs --force, which the completion gate asks the user
207
+ about.
208
+
209
+ Options:
210
+ --session <id> add, resume: the conversation that will work there (required)
211
+ --force remove: discard uncommitted or unmerged work
212
+ --json
182
213
  `
183
214
  };
184
215
  function wantsJson(argv) {
@@ -47,7 +47,10 @@ export async function runHookCommand(options) {
47
47
  ...(stopVerification === undefined ? {} : { stopVerification }),
48
48
  ...(options.completionGate === undefined
49
49
  ? {}
50
- : { completionGate: options.completionGate })
50
+ : { completionGate: options.completionGate }),
51
+ ...(options.worktreeGuard === undefined
52
+ ? {}
53
+ : { worktreeGuard: options.worktreeGuard })
51
54
  });
52
55
  return descriptor.runtime.formatOutput(options.event, result);
53
56
  }
@@ -0,0 +1,87 @@
1
+ import { okEnvelope } from "../output.js";
2
+ import { addWorktree } from "../../../../runtime/src/parallel/service.js";
3
+ import { finishWorktree } from "../../../../runtime/src/parallel/finish.js";
4
+ import { idleWorktrees, listWorktrees, removeWorktree, resumeWorktree } from "../../../../runtime/src/parallel/manage.js";
5
+ function listText(statuses, now) {
6
+ if (statuses.length === 0)
7
+ return "No agent-ops worktrees.";
8
+ const idle = new Set(idleWorktrees(statuses, now).map(({ record }) => record.name));
9
+ return statuses.map(({ record, dirty, ahead, taskStatus, lastActivity }) => [
10
+ `${record.name}${idle.has(record.name) ? " (idle)" : ""}`,
11
+ ` path: ${record.path}`,
12
+ ` session: ${record.sessionId}`,
13
+ ` branch: ${record.branch}, ${ahead} commit(s) ahead of ${record.targetBranch}${dirty ? ", uncommitted changes" : ""}`,
14
+ ` task: ${taskStatus}`,
15
+ ` last activity: ${lastActivity}`
16
+ ].join("\n")).join("\n");
17
+ }
18
+ function addText(result) {
19
+ const { record } = result;
20
+ return [
21
+ `Created worktree ${record.name} for session ${record.sessionId}.`,
22
+ `Path: ${record.path}`,
23
+ `Branch: ${record.branch} (from ${record.targetBranch} at ${record.base})`,
24
+ `Copied: ${result.copied.length === 0 ? "nothing" : result.copied.join(", ")}`,
25
+ `Trust: ${result.trusted ? "inherited" : "not inherited; run agent-ops trust grant inside the worktree"}`,
26
+ ...(result.setup.length === 0 ? [] : [`Setup: ${result.setup.join("; ")}`]),
27
+ "",
28
+ "Work only inside that path from now on:",
29
+ `- Claude Code: EnterWorktree with path ${record.path}`,
30
+ `- Codex and agy: run every command with ${record.path} as its working directory`,
31
+ `Finish with: commit, agent-ops verify/review/task complete with --base ${record.base},`,
32
+ `then agent-ops worktree finish ${record.name} from ${record.mainRoot}`,
33
+ "(Claude Code: ExitWorktree with action keep before finishing)."
34
+ ].join("\n");
35
+ }
36
+ function finishText(result) {
37
+ const { record } = result;
38
+ return [
39
+ `Merged ${record.branch} into ${record.targetBranch} at ${result.mergedHead}${result.rebased ? " after a clean rebase and re-verification" : ""}.`,
40
+ `Task ${result.taskId} is recorded in git notes (refs/notes/agent-ops).`,
41
+ `Removed ${record.path}.`,
42
+ ...result.warnings.map((warning) => `Warning: ${warning}`)
43
+ ].join("\n");
44
+ }
45
+ export async function runWorktreeCommand(options) {
46
+ const { args } = options;
47
+ if (args.action === "list") {
48
+ const worktrees = await listWorktrees(options.deps, options.cwd);
49
+ return okEnvelope("WORKTREE_LIST", { worktrees, text: listText(worktrees, Date.now()) });
50
+ }
51
+ if (args.action === "resume") {
52
+ const record = await resumeWorktree(options.deps, {
53
+ cwd: options.cwd,
54
+ name: args.worktreeName,
55
+ sessionId: args.sessionId ?? process.env.AGENT_OPS_SESSION_ID
56
+ });
57
+ return okEnvelope("WORKTREE_RESUMED", {
58
+ record,
59
+ text: [
60
+ `Session ${record.sessionId} now owns worktree ${record.name}.`,
61
+ `Path: ${record.path}`,
62
+ `- Claude Code: EnterWorktree with path ${record.path}`,
63
+ `- Codex and agy: run every command with ${record.path} as its working directory`,
64
+ "Its next Stop is judged against everything already in the worktree."
65
+ ].join("\n")
66
+ });
67
+ }
68
+ if (args.action === "remove") {
69
+ const removed = await removeWorktree(options.deps, {
70
+ cwd: options.cwd,
71
+ name: args.worktreeName,
72
+ force: args.force === true
73
+ });
74
+ return okEnvelope("WORKTREE_REMOVED", { ...removed, text: `Removed ${removed.record.path} and ${removed.record.branch}.` });
75
+ }
76
+ if (args.action === "finish") {
77
+ const finished = await finishWorktree(options.deps, { cwd: options.cwd, name: args.worktreeName });
78
+ return okEnvelope("WORKTREE_FINISHED", { ...finished, text: finishText(finished) });
79
+ }
80
+ const sessionId = args.sessionId ?? process.env.AGENT_OPS_SESSION_ID;
81
+ const result = await addWorktree(options.deps, {
82
+ cwd: options.cwd,
83
+ name: args.worktreeName,
84
+ sessionId
85
+ });
86
+ return okEnvelope("WORKTREE_CREATED", { ...result, text: addText(result) });
87
+ }
@@ -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 { evaluateWorktreeWrite, resolveMainRoot } from "../../../runtime/src/parallel/guard.js";
12
13
  import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
13
14
  import { TaskService } from "../../../runtime/src/task/service.js";
14
15
  import { FileTaskStore } from "../../../runtime/src/task/store.js";
@@ -208,6 +209,26 @@ function shouldBuildStopVerification(harness, event, config, rawInput) {
208
209
  }
209
210
  return harnessDescriptor(harness).control.registrations.some(({ capability, support }) => capability === "optional-stop-verify" && support !== "unsupported");
210
211
  }
212
+ /**
213
+ * The gate of one checkout. A session redirected into a worktree is judged by
214
+ * that worktree's gate, built the same way from the worktree's own config.
215
+ */
216
+ function completionGateFor(root, config, gitRunner) {
217
+ return new CompletionGateService({
218
+ root,
219
+ config,
220
+ gitRunner,
221
+ taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
222
+ evidenceStore: new FileEvidenceStore(root, root),
223
+ forRoot: async (worktree) => {
224
+ const outcome = await loadProjectHookConfig(worktree);
225
+ if (outcome.kind !== "loaded" || !outcome.config.features.completionGate.enabled) {
226
+ throw new Error("The redirected worktree has no enabled completion gate.");
227
+ }
228
+ return completionGateFor(worktree, outcome.config, defaultGitRunner(worktree));
229
+ }
230
+ });
231
+ }
211
232
  /**
212
233
  * Runs one hook invocation. Exit code stays zero because native JSON carries
213
234
  * decisions; only an explicitly installed agy completion gate fails closed.
@@ -319,13 +340,15 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
319
340
  const completionGate = (harnessId === "agy" || harnessId === "claude") &&
320
341
  config.features.completionGate.enabled
321
342
  ? dependencies.completionGate ?? {
322
- handle: async (normalized) => await new CompletionGateService({
323
- root,
324
- config,
325
- gitRunner,
326
- taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
327
- evidenceStore: new FileEvidenceStore(root, root)
328
- }).handle(normalized)
343
+ handle: async (normalized) => await completionGateFor(root, config, gitRunner).handle(normalized)
344
+ }
345
+ : undefined;
346
+ const worktreeGuard = config.worktree?.mode === "auto"
347
+ ? async (write) => {
348
+ const mainRoot = await resolveMainRoot(gitRunner);
349
+ return mainRoot === null
350
+ ? { action: "continue", status: "UNKNOWN", code: "WORKTREE_GUARD_UNAVAILABLE" }
351
+ : await evaluateWorktreeWrite(mainRoot, write.paths, write.sessionId);
329
352
  }
330
353
  : undefined;
331
354
  const output = await runHookCommand({
@@ -334,6 +357,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
334
357
  stdin: rawInput,
335
358
  config,
336
359
  trusted,
360
+ ...(worktreeGuard === undefined ? {} : { worktreeGuard }),
337
361
  ...(dependencies.advisory === undefined
338
362
  ? {}
339
363
  : { advisory: dependencies.advisory }),
@@ -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) {
@@ -46,7 +47,9 @@ function commandHook(event, runtimePath, completionGate = false) {
46
47
  }
47
48
  function matcherGroup(event, runtimePath, completionGate = false) {
48
49
  return {
49
- ...(event === "PreToolUse" ? { matcher: "Bash" } : {}),
50
+ // File tools as well as Bash: in worktree auto mode the guard must see a
51
+ // direct edit of the shared checkout, which never passes through a shell.
52
+ ...(event === "PreToolUse" ? { matcher: CLAUDE_PRE_TOOL_MATCHER } : {}),
50
53
  hooks: [commandHook(event, runtimePath, completionGate)]
51
54
  };
52
55
  }
@@ -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),