@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.
@@ -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 worktree add <name> --session ${shellQuote(sessionId)} and work only inside the printed path.`);
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 worktree add <name> --session <session-id>` from the main", "checkout before your first edit, and 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, review and", "`task complete` with the printed `--base`; leave the worktree (Claude", "Code: ExitWorktree with action keep) and run `agent-ops worktree finish", "<name>` from the main checkout. 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) {
@@ -0,0 +1,263 @@
1
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { sha256 } from "../fs/hash.js";
4
+ import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
5
+ import { currentGateFingerprint } from "../hooks/completion-gate.js";
6
+ import { collectChangeSurface } from "../verify/change-surface.js";
7
+ import { aggregateVerificationStatus, executeConfiguredCommand } from "../verify/command-executor.js";
8
+ import { assertWorktreeName, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath } from "./service.js";
9
+ export const NOTES_REF = "agent-ops";
10
+ const LOCK_NAME = "agent-ops-finish.lock";
11
+ const LOCK_POLL_MS = 2_000;
12
+ const LOCK_WAIT_MS = 30 * 60 * 1000;
13
+ // A holder that has not finished in this long has died without its pid being
14
+ // reused by something we can see; the lock is reclaimed.
15
+ const LOCK_STALE_MS = 2 * 60 * 60 * 1000;
16
+ const MAX_INTENTS = 20;
17
+ /**
18
+ * The work and the moved target touch the same lines. The rebase was undone;
19
+ * this carries what the agent needs to redo it deliberately: the files, and
20
+ * what the tasks already merged into the target meant to achieve.
21
+ */
22
+ export class WorktreeConflictError extends AgentOpsError {
23
+ files;
24
+ intents;
25
+ target;
26
+ constructor(record, target, head, files, intents) {
27
+ super("WORKTREE_REBASE_CONFLICT", [
28
+ `Rebasing ${record.branch} onto ${record.targetBranch} (${target}) conflicts in: ${files.join(", ")}.`,
29
+ "The rebase was aborted and the branch is unchanged.",
30
+ intents.length === 0
31
+ ? "No agent-ops notes describe the target-side commits."
32
+ : ["What the target-side work intended:", ...intents.map(({ commit, note }) => `--- ${commit}\n${note}`)].join("\n"),
33
+ `Resolve it in ${record.path}: git rebase ${target}, satisfy both intents, git rebase --continue;`,
34
+ "then create a new task whose criteria cover both intents, attach it to this session,",
35
+ `run verify, review and task complete with --base ${target}, and finish again.`,
36
+ `One attempt only: if that verification or review fails, git reset --hard ${head}`,
37
+ "and stop to report instead of retrying."
38
+ ].join("\n"));
39
+ this.files = files;
40
+ this.intents = intents;
41
+ this.target = target;
42
+ }
43
+ }
44
+ function finishError(code, message) {
45
+ return new AgentOpsError(code, message);
46
+ }
47
+ async function git(deps, cwd, args, code, message) {
48
+ const result = await deps.git(cwd, args);
49
+ if (result.exitCode !== 0) {
50
+ const detail = result.stderr.trim().split("\n")[0];
51
+ throw finishError(code, detail === undefined || detail === "" ? message : `${message} ${detail}`);
52
+ }
53
+ return result.stdout.trim();
54
+ }
55
+ function runner(deps, cwd) {
56
+ return {
57
+ run: async (args) => {
58
+ const result = await deps.git(cwd, args);
59
+ return { exitCode: result.exitCode, stdout: Buffer.from(result.stdout, "utf8") };
60
+ }
61
+ };
62
+ }
63
+ function alive(pid) {
64
+ try {
65
+ process.kill(pid, 0);
66
+ return true;
67
+ }
68
+ catch (error) {
69
+ return error.code === "EPERM";
70
+ }
71
+ }
72
+ async function staleLock(path) {
73
+ try {
74
+ const owner = JSON.parse(await readFile(join(path, "owner.json"), "utf8"));
75
+ if (typeof owner.pid !== "number" || typeof owner.at !== "number")
76
+ return true;
77
+ return !alive(owner.pid) || Date.now() - owner.at > LOCK_STALE_MS;
78
+ }
79
+ catch {
80
+ // A holder between mkdir and writing its owner file looks like this too;
81
+ // only an old ownerless lock is abandoned.
82
+ try {
83
+ return Date.now() - (await stat(path)).mtimeMs > 60_000;
84
+ }
85
+ catch {
86
+ return true;
87
+ }
88
+ }
89
+ }
90
+ /**
91
+ * One finish at a time per repository: each one moves the target, and every
92
+ * later one must rebase onto that. The lock lives in the Git common directory
93
+ * so every worktree of the repository sees the same one.
94
+ */
95
+ export async function withFinishLock(deps, commonDir, action) {
96
+ const path = join(commonDir, LOCK_NAME);
97
+ const deadline = Date.now() + (deps.lockWaitMs ?? LOCK_WAIT_MS);
98
+ const sleep = deps.sleep ?? (async (ms) => await new Promise((resolve) => setTimeout(resolve, ms)));
99
+ for (;;) {
100
+ try {
101
+ await mkdir(path);
102
+ await writeFile(join(path, "owner.json"), JSON.stringify({ pid: process.pid, at: Date.now() }));
103
+ break;
104
+ }
105
+ catch (error) {
106
+ if (error.code !== "EEXIST")
107
+ throw error;
108
+ if (await staleLock(path)) {
109
+ await rm(path, { recursive: true, force: true });
110
+ continue;
111
+ }
112
+ if (Date.now() >= deadline) {
113
+ throw finishError("WORKTREE_FINISH_BUSY", "Another worktree finish is still running; try again later.");
114
+ }
115
+ await sleep(LOCK_POLL_MS);
116
+ }
117
+ }
118
+ try {
119
+ return await action();
120
+ }
121
+ finally {
122
+ await rm(path, { recursive: true, force: true });
123
+ }
124
+ }
125
+ /**
126
+ * The branch's own change, independent of where it is based: hunk positions
127
+ * and blob ids move with a rebase, the change itself does not.
128
+ */
129
+ async function patchHash(deps, cwd, target) {
130
+ const diff = await git(deps, cwd, ["diff", "--no-color", "--no-ext-diff", "--no-textconv", `${target}...HEAD`], "WORKTREE_DIFF_FAILED", "Git could not diff the branch.");
131
+ return sha256(diff.split("\n")
132
+ .filter((line) => !line.startsWith("index "))
133
+ .map((line) => line.startsWith("@@") ? "@@" : line)
134
+ .join("\n"));
135
+ }
136
+ async function reverify(deps, root, config) {
137
+ const results = [];
138
+ for (const command of config.verification.commands) {
139
+ results.push(await executeConfiguredCommand(command, {
140
+ cwd: command.cwd === "." ? root : await resolveContainedPath(root, command.cwd),
141
+ trusted: true,
142
+ ...(deps.processRunner === undefined ? {} : { runner: deps.processRunner })
143
+ }));
144
+ }
145
+ return aggregateVerificationStatus(results) === "PASS"
146
+ ? []
147
+ : results.filter((result) => result.required && result.status !== "PASS").map(({ commandId }) => commandId);
148
+ }
149
+ async function targetIntents(deps, cwd, target) {
150
+ const commits = (await git(deps, cwd, ["log", "--format=%H", `HEAD..${target}`], "WORKTREE_LOG_FAILED", "Git could not list the target-side commits.")).split("\n").filter(Boolean);
151
+ const intents = [];
152
+ for (const commit of commits) {
153
+ const note = await deps.git(cwd, ["notes", `--ref=${NOTES_REF}`, "show", commit]);
154
+ if (note.exitCode === 0 && note.stdout.trim() !== "") {
155
+ intents.push({ commit, note: note.stdout.trim() });
156
+ if (intents.length >= MAX_INTENTS)
157
+ break;
158
+ }
159
+ }
160
+ return intents;
161
+ }
162
+ function noteText(record, task) {
163
+ return [
164
+ `agent-ops task ${task.task.id}: ${task.task.title}`,
165
+ `worktree: ${record.name} (${record.branch})`,
166
+ "criteria:",
167
+ ...task.task.criteria.map(({ id, description }) => `- ${id}: ${description}`)
168
+ ].join("\n");
169
+ }
170
+ /**
171
+ * Brings a worktree's reviewed work into the branch the main checkout is on,
172
+ * by fast-forward only, then retires the worktree. Run from the main checkout.
173
+ */
174
+ export async function finishWorktree(deps, options) {
175
+ const name = assertWorktreeName(options.name);
176
+ const { mainRoot, currentRoot, commonDir } = await resolveCheckouts(deps, options.cwd);
177
+ if (currentRoot !== mainRoot) {
178
+ throw finishError("WORKTREE_NESTED", `Run worktree finish from the main checkout (${mainRoot}); Claude Code: ExitWorktree with action keep first.`);
179
+ }
180
+ const record = await readWorktreeRecord(worktreePath(mainRoot, name));
181
+ if (record === null) {
182
+ throw finishError("WORKTREE_NOT_FOUND", `No agent-ops worktree named ${name}; see agent-ops worktree list.`);
183
+ }
184
+ return await withFinishLock(deps, commonDir, async () => {
185
+ const mainRunner = runner(deps, mainRoot);
186
+ const worktreeRunner = runner(deps, record.path);
187
+ if ((await collectChangeSurface(mainRunner)).paths.length > 0) {
188
+ throw finishError("WORKTREE_MAIN_DIRTY", "The main checkout has uncommitted changes; a fast-forward would mix them in.");
189
+ }
190
+ const onBranch = (await deps.git(mainRoot, ["symbolic-ref", "--short", "-q", "HEAD"])).stdout.trim();
191
+ if (onBranch !== record.targetBranch) {
192
+ throw finishError("WORKTREE_TARGET_MOVED", `The main checkout is on ${onBranch || "a detached HEAD"}; switch it back to ${record.targetBranch} to finish.`);
193
+ }
194
+ if ((await collectChangeSurface(worktreeRunner)).paths.length > 0) {
195
+ throw finishError("WORKTREE_DIRTY", `Commit or discard the uncommitted changes in ${record.path} first.`);
196
+ }
197
+ const target = await git(deps, mainRoot, ["rev-parse", "--verify", `${record.targetBranch}^{commit}`], "WORKTREE_TARGET_MISSING", `${record.targetBranch} does not resolve to a commit.`);
198
+ const ahead = Number(await git(deps, record.path, ["rev-list", "--count", `${target}..HEAD`], "WORKTREE_LOG_FAILED", "Git could not compare the branch with its target."));
199
+ if (ahead === 0) {
200
+ throw finishError("WORKTREE_NOTHING_TO_MERGE", `${record.branch} has no commits beyond ${record.targetBranch}; remove the worktree instead.`);
201
+ }
202
+ const worktreeConfig = await deps.loadConfig(record.path);
203
+ const worktreeGate = await deps.gate(record.path, worktreeConfig);
204
+ const problem = await worktreeGate.validate(record.sessionId);
205
+ if (problem !== null) {
206
+ throw finishError("WORKTREE_TASK_INCOMPLETE", `The worktree's task is not ready to merge (${problem.code}). ${problem.remedy ?? ""}`.trim());
207
+ }
208
+ const task = await deps.tasks(record.path).status({ sessionId: record.sessionId });
209
+ const head = await git(deps, record.path, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
210
+ const fastForward = (await deps.git(record.path, ["merge-base", "--is-ancestor", target, "HEAD"])).exitCode === 0;
211
+ if (!fastForward) {
212
+ const before = await patchHash(deps, record.path, target);
213
+ const rebase = await deps.git(record.path, ["rebase", "--no-autostash", target]);
214
+ if (rebase.exitCode !== 0) {
215
+ const files = (await deps.git(record.path, ["diff", "--name-only", "--diff-filter=U"])).stdout
216
+ .split("\n").map((line) => line.trim()).filter(Boolean);
217
+ await deps.git(record.path, ["rebase", "--abort"]);
218
+ await deps.git(record.path, ["reset", "--hard", head]);
219
+ throw new WorktreeConflictError(record, target, head, files, await targetIntents(deps, record.path, target));
220
+ }
221
+ const reset = async () => { await deps.git(record.path, ["reset", "--hard", head]); };
222
+ if (await patchHash(deps, record.path, target) !== before) {
223
+ await reset();
224
+ throw finishError("WORKTREE_REBASE_CHANGED", `Rebasing onto ${target} changed the branch's own diff, so the review no longer covers it. The branch was reset; rebase it yourself and review the result as a new task.`);
225
+ }
226
+ if (await deps.trust.status(record.path, worktreeConfig) !== "TRUSTED") {
227
+ await reset();
228
+ throw finishError("WORKTREE_UNTRUSTED", "Re-verifying the rebased branch runs repository commands; run agent-ops trust grant in the worktree.");
229
+ }
230
+ const failed = await reverify(deps, record.path, worktreeConfig);
231
+ if (failed.length > 0) {
232
+ await reset();
233
+ throw finishError("WORKTREE_REVERIFY_FAILED", `After rebasing onto ${target}, verification failed: ${failed.join(", ")}. The branch was reset; fix it as a new task and finish again.`);
234
+ }
235
+ }
236
+ const mainConfig = await deps.loadConfig(mainRoot);
237
+ const gateEnabled = mainConfig.features.completionGate.enabled;
238
+ const before = gateEnabled ? await currentGateFingerprint(mainRoot, mainRunner) : "";
239
+ await git(deps, mainRoot, ["merge", "--ff-only", record.branch], "WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}.`);
240
+ const mergedHead = await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
241
+ // The merge happened; everything below is cleanup, reported, never undone.
242
+ const warnings = [];
243
+ const attempt = async (label, step) => {
244
+ try {
245
+ await step();
246
+ }
247
+ catch (error) {
248
+ warnings.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
249
+ }
250
+ };
251
+ await attempt("note", async () => await git(deps, mainRoot, ["notes", `--ref=${NOTES_REF}`, "add", "-f", "-m", noteText(record, task), mergedHead], "WORKTREE_NOTE_FAILED", "Git could not write the agent-ops note."));
252
+ if (gateEnabled) {
253
+ await attempt("gate", async () => {
254
+ const mainGate = await deps.gate(mainRoot, mainConfig);
255
+ await mainGate.redirect(record.sessionId, null);
256
+ await mainGate.rebase(before, await currentGateFingerprint(mainRoot, mainRunner));
257
+ });
258
+ }
259
+ await attempt("trust", async () => await deps.trust.revoke(record.path, worktreeConfig));
260
+ await attempt("remove", async () => await removeCheckout(deps, record, true));
261
+ return { record, mergedHead, rebased: !fastForward, taskId: task.task.id, warnings };
262
+ });
263
+ }
@@ -0,0 +1,59 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { basename, dirname, join, sep } from "node:path";
3
+ import { insideWorktreeDirectory } from "./service.js";
4
+ /** The main checkout of the repository `runner` runs in, or null. */
5
+ export async function resolveMainRoot(runner) {
6
+ const result = await runner.run(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
7
+ if (result.exitCode !== 0)
8
+ return null;
9
+ try {
10
+ const commonDir = await realpath(new TextDecoder().decode(result.stdout).trim());
11
+ return basename(commonDir) === ".git" ? dirname(commonDir) : null;
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ /**
18
+ * The real path of `path`, resolved through its deepest existing ancestor: a
19
+ * Write creates files that do not exist yet, and a symlinked spelling of the
20
+ * repository must not decide whether an edit lands in the main checkout.
21
+ */
22
+ export async function canonicalPath(path) {
23
+ const pending = [];
24
+ let current = path;
25
+ for (;;) {
26
+ try {
27
+ return join(await realpath(current), ...pending.reverse());
28
+ }
29
+ catch {
30
+ const parent = dirname(current);
31
+ if (parent === current)
32
+ return path;
33
+ pending.push(basename(current));
34
+ current = parent;
35
+ }
36
+ }
37
+ }
38
+ /**
39
+ * In worktree auto mode the main checkout is shared by every session, so no
40
+ * session writes it directly: each edit belongs in that session's own
41
+ * `.worktrees/<name>`. Paths outside the repository are not this guard's
42
+ * business.
43
+ */
44
+ export async function evaluateWorktreeWrite(mainRoot, paths, sessionId) {
45
+ for (const path of paths) {
46
+ const target = await canonicalPath(path);
47
+ const inMain = target === mainRoot || target.startsWith(`${mainRoot}${sep}`);
48
+ if (inMain && !insideWorktreeDirectory(mainRoot, target)) {
49
+ const session = sessionId ?? "<session-id>";
50
+ return {
51
+ action: "block",
52
+ status: "FAIL",
53
+ code: "WORKTREE_REQUIRED",
54
+ remedy: `worktree.mode is auto, so the main checkout is shared and not edited directly. Run \`agent-ops worktree add <name> --session ${session}\` in ${mainRoot}, enter the printed path (Claude Code: EnterWorktree with that path), and edit there.`
55
+ };
56
+ }
57
+ }
58
+ return { action: "continue", status: "PASS", code: "WORKTREE_WRITE_ALLOWED" };
59
+ }