@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,303 @@
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
+ /** Each tree top first, its subtasks after it; `depth` counts parents. */
163
+ function taskTree(tasks) {
164
+ const ids = new Set(tasks.map(({ task }) => task.id));
165
+ const ordered = [];
166
+ const visit = (parent, depth) => {
167
+ for (const task of tasks) {
168
+ const own = task.task.parentTaskId !== undefined && ids.has(task.task.parentTaskId)
169
+ ? task.task.parentTaskId
170
+ : undefined;
171
+ if (own === parent) {
172
+ ordered.push({ task, depth });
173
+ visit(task.task.id, depth + 1);
174
+ }
175
+ }
176
+ };
177
+ visit(undefined, 0);
178
+ return ordered;
179
+ }
180
+ function noteText(record, tasks) {
181
+ return [
182
+ `worktree: ${record.name} (${record.branch})`,
183
+ ...tasks.flatMap(({ task, depth }) => {
184
+ const indent = " ".repeat(depth);
185
+ return [
186
+ `${indent}agent-ops task ${task.task.id}: ${task.task.title}`,
187
+ `${indent}criteria:`,
188
+ ...task.task.criteria.map(({ id, description }) => `${indent}- ${id}: ${description}`)
189
+ ];
190
+ })
191
+ ].join("\n");
192
+ }
193
+ /**
194
+ * Brings a worktree's reviewed work into the branch the main checkout is on,
195
+ * by fast-forward only, then retires the worktree. Run from the main checkout.
196
+ */
197
+ export async function finishWorktree(deps, options) {
198
+ const name = assertWorktreeName(options.name);
199
+ const { mainRoot, currentRoot, commonDir } = await resolveCheckouts(deps, options.cwd);
200
+ if (currentRoot !== mainRoot) {
201
+ throw finishError("WORKTREE_NESTED", `Run worktree finish from the main checkout (${mainRoot}); Claude Code: ExitWorktree with action keep first.`);
202
+ }
203
+ const record = await readWorktreeRecord(worktreePath(mainRoot, name));
204
+ if (record === null) {
205
+ throw finishError("WORKTREE_NOT_FOUND", `No agent-ops worktree named ${name}; see agent-ops worktree list.`);
206
+ }
207
+ return await withFinishLock(deps, commonDir, async () => {
208
+ const mainRunner = runner(deps, mainRoot);
209
+ const worktreeRunner = runner(deps, record.path);
210
+ if ((await collectChangeSurface(mainRunner)).paths.length > 0) {
211
+ throw finishError("WORKTREE_MAIN_DIRTY", "The main checkout has uncommitted changes; a fast-forward would mix them in.");
212
+ }
213
+ const onBranch = (await deps.git(mainRoot, ["symbolic-ref", "--short", "-q", "HEAD"])).stdout.trim();
214
+ if (onBranch !== record.targetBranch) {
215
+ throw finishError("WORKTREE_TARGET_MOVED", `The main checkout is on ${onBranch || "a detached HEAD"}; switch it back to ${record.targetBranch} to finish.`);
216
+ }
217
+ if ((await collectChangeSurface(worktreeRunner)).paths.length > 0) {
218
+ throw finishError("WORKTREE_DIRTY", `Commit or discard the uncommitted changes in ${record.path} first.`);
219
+ }
220
+ const target = await git(deps, mainRoot, ["rev-parse", "--verify", `${record.targetBranch}^{commit}`], "WORKTREE_TARGET_MISSING", `${record.targetBranch} does not resolve to a commit.`);
221
+ 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."));
222
+ if (ahead === 0) {
223
+ throw finishError("WORKTREE_NOTHING_TO_MERGE", `${record.branch} has no commits beyond ${record.targetBranch}; remove the worktree instead.`);
224
+ }
225
+ const worktreeConfig = await deps.loadConfig(record.path);
226
+ // The whole branch merges, so every task in the worktree completes first,
227
+ // subtasks before their parents, each over the range its review covered.
228
+ // Before any rebase: rebasing rewrites the commits those fingerprints name.
229
+ const tree = taskTree((await deps.tasks(record.path).list()).filter(({ status }) => status !== "archived"));
230
+ if (tree.length === 0) {
231
+ throw finishError("WORKTREE_TASK_INCOMPLETE", "The worktree has no task; create one, verify and review it before finishing.");
232
+ }
233
+ for (const { task } of [...tree].sort((left, right) => right.depth - left.depth)) {
234
+ if (task.status === "complete")
235
+ continue;
236
+ const base = task.reviewBase ?? record.base;
237
+ try {
238
+ await deps.tasks(record.path, base).complete(task.task.id, {});
239
+ }
240
+ catch (error) {
241
+ throw finishError("WORKTREE_TASK_INCOMPLETE", `Task ${task.task.id} (${task.task.title}) could not be completed against ${base}: ${error instanceof Error ? error.message : String(error)}`);
242
+ }
243
+ }
244
+ const worktreeGate = await deps.gate(record.path, worktreeConfig);
245
+ const problem = await worktreeGate.validate(record.sessionId);
246
+ if (problem !== null) {
247
+ throw finishError("WORKTREE_TASK_INCOMPLETE", `The worktree's task is not ready to merge (${problem.code}). ${problem.remedy ?? ""}`.trim());
248
+ }
249
+ const head = await git(deps, record.path, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
250
+ const fastForward = (await deps.git(record.path, ["merge-base", "--is-ancestor", target, "HEAD"])).exitCode === 0;
251
+ if (!fastForward) {
252
+ const before = await patchHash(deps, record.path, target);
253
+ const rebase = await deps.git(record.path, ["rebase", "--no-autostash", target]);
254
+ if (rebase.exitCode !== 0) {
255
+ const files = (await deps.git(record.path, ["diff", "--name-only", "--diff-filter=U"])).stdout
256
+ .split("\n").map((line) => line.trim()).filter(Boolean);
257
+ await deps.git(record.path, ["rebase", "--abort"]);
258
+ await deps.git(record.path, ["reset", "--hard", head]);
259
+ throw new WorktreeConflictError(record, target, head, files, await targetIntents(deps, record.path, target));
260
+ }
261
+ const reset = async () => { await deps.git(record.path, ["reset", "--hard", head]); };
262
+ if (await patchHash(deps, record.path, target) !== before) {
263
+ await reset();
264
+ 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.`);
265
+ }
266
+ if (await deps.trust.status(record.path, worktreeConfig) !== "TRUSTED") {
267
+ await reset();
268
+ throw finishError("WORKTREE_UNTRUSTED", "Re-verifying the rebased branch runs repository commands; run agent-ops trust grant in the worktree.");
269
+ }
270
+ const failed = await reverify(deps, record.path, worktreeConfig);
271
+ if (failed.length > 0) {
272
+ await reset();
273
+ 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.`);
274
+ }
275
+ }
276
+ const mainConfig = await deps.loadConfig(mainRoot);
277
+ const gateEnabled = mainConfig.features.completionGate.enabled;
278
+ const before = gateEnabled ? await currentGateFingerprint(mainRoot, mainRunner) : "";
279
+ await git(deps, mainRoot, ["merge", "--ff-only", record.branch], "WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}.`);
280
+ const mergedHead = await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
281
+ // The merge happened; everything below is cleanup, reported, never undone.
282
+ const warnings = [];
283
+ const attempt = async (label, step) => {
284
+ try {
285
+ await step();
286
+ }
287
+ catch (error) {
288
+ warnings.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
289
+ }
290
+ };
291
+ await attempt("note", async () => await git(deps, mainRoot, ["notes", `--ref=${NOTES_REF}`, "add", "-f", "-m", noteText(record, tree), mergedHead], "WORKTREE_NOTE_FAILED", "Git could not write the agent-ops note."));
292
+ if (gateEnabled) {
293
+ await attempt("gate", async () => {
294
+ const mainGate = await deps.gate(mainRoot, mainConfig);
295
+ await mainGate.redirect(record.sessionId, null);
296
+ await mainGate.rebase(before, await currentGateFingerprint(mainRoot, mainRunner));
297
+ });
298
+ }
299
+ await attempt("trust", async () => await deps.trust.revoke(record.path, worktreeConfig));
300
+ await attempt("remove", async () => await removeCheckout(deps, record, true));
301
+ return { record, mergedHead, rebased: !fastForward, taskIds: tree.map(({ task }) => task.task.id), warnings };
302
+ });
303
+ }
@@ -0,0 +1,76 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { basename, dirname, join, relative, 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
+ /** Creates or reuses this session's worktree and returns its path. */
46
+ ensureWorktree) {
47
+ for (const path of paths) {
48
+ const target = await canonicalPath(path);
49
+ const inMain = target === mainRoot || target.startsWith(`${mainRoot}${sep}`);
50
+ if (inMain && !insideWorktreeDirectory(mainRoot, target)) {
51
+ let failure = "";
52
+ if (sessionId !== undefined && ensureWorktree !== undefined) {
53
+ try {
54
+ const worktree = await ensureWorktree(sessionId);
55
+ return {
56
+ action: "block",
57
+ status: "FAIL",
58
+ code: "WORKTREE_CREATED",
59
+ remedy: `worktree.mode is auto, so this session now has its own worktree at ${worktree}. Enter it (Claude Code: EnterWorktree with path ${worktree}) and redo this edit there, at ${join(worktree, relative(mainRoot, target))}.`
60
+ };
61
+ }
62
+ catch (error) {
63
+ failure = ` Creating this session's worktree failed: ${error instanceof Error ? error.message : String(error)}`;
64
+ }
65
+ }
66
+ const session = sessionId ?? "<session-id>";
67
+ return {
68
+ action: "block",
69
+ status: "FAIL",
70
+ code: "WORKTREE_REQUIRED",
71
+ remedy: `worktree.mode is auto, so the main checkout is shared and not edited directly.${failure} Run \`agent-ops worktree add <name> --session ${session}\` in ${mainRoot}, enter the printed path (Claude Code: EnterWorktree with that path), and edit there.`
72
+ };
73
+ }
74
+ }
75
+ return { action: "continue", status: "PASS", code: "WORKTREE_WRITE_ALLOWED" };
76
+ }
@@ -0,0 +1,147 @@
1
+ import { readdir, realpath, stat } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { sha256 } from "../fs/hash.js";
4
+ import { AgentOpsError } from "../fs/paths.js";
5
+ import { collectChangeSurface } from "../verify/change-surface.js";
6
+ import { assertSessionId, assertWorktreeName, bindSession, insideWorktreeDirectory, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath, writeWorktreeRecord } from "./service.js";
7
+ /**
8
+ * A baseline no source state hashes to. A resumed session inherits whatever
9
+ * the last one left in the worktree, so none of it counts as "unchanged".
10
+ */
11
+ export const RESUMED_BASELINE = sha256("agent-ops-worktree-resumed");
12
+ export const IDLE_DAYS = 7;
13
+ function manageError(code, message) {
14
+ return new AgentOpsError(code, message);
15
+ }
16
+ async function mainCheckout(deps, cwd) {
17
+ const { mainRoot, currentRoot } = await resolveCheckouts(deps, cwd);
18
+ if (currentRoot !== mainRoot) {
19
+ throw manageError("WORKTREE_NESTED", `Run this from the main checkout (${mainRoot}).`);
20
+ }
21
+ return mainRoot;
22
+ }
23
+ async function mtime(path) {
24
+ try {
25
+ return (await stat(path)).mtimeMs;
26
+ }
27
+ catch {
28
+ return 0;
29
+ }
30
+ }
31
+ /** The last time anything agent-ops tracks moved in the worktree. */
32
+ async function lastActivity(record) {
33
+ const gate = join(record.path, ".agent-ops", "tasks", "completion-gate");
34
+ const times = [
35
+ Date.parse(record.createdAt) || 0,
36
+ await mtime(join(record.path, ".agent-ops", "tasks", "state.json")),
37
+ ...await Promise.all((await readdir(gate).catch(() => []))
38
+ .map(async (name) => await mtime(join(gate, name))))
39
+ ];
40
+ return new Date(Math.max(...times)).toISOString();
41
+ }
42
+ async function describe(deps, record) {
43
+ const runner = {
44
+ run: async (args) => {
45
+ const result = await deps.git(record.path, args);
46
+ return { exitCode: result.exitCode, stdout: Buffer.from(result.stdout, "utf8") };
47
+ }
48
+ };
49
+ const dirty = (await collectChangeSurface(runner).catch(() => ({ paths: ["?"] }))).paths.length > 0;
50
+ const count = await deps.git(record.path, ["rev-list", "--count", `${record.targetBranch}..HEAD`]);
51
+ const taskStatus = await deps.tasks(record.path).status({ sessionId: record.sessionId })
52
+ .then((task) => `${task.task.id} ${task.status}`)
53
+ .catch(() => "none");
54
+ return {
55
+ record,
56
+ dirty,
57
+ ahead: count.exitCode === 0 ? Number(count.stdout.trim()) : 0,
58
+ taskStatus,
59
+ lastActivity: await lastActivity(record)
60
+ };
61
+ }
62
+ /** Every agent-ops worktree of this repository, from any of its checkouts. */
63
+ export async function listWorktrees(deps, cwd) {
64
+ const { mainRoot } = await resolveCheckouts(deps, cwd);
65
+ const listing = await deps.git(mainRoot, ["worktree", "list", "--porcelain"]);
66
+ if (listing.exitCode !== 0) {
67
+ throw manageError("WORKTREE_LIST_FAILED", "Git could not list worktrees.");
68
+ }
69
+ const statuses = [];
70
+ for (const line of listing.stdout.split("\n")) {
71
+ if (!line.startsWith("worktree "))
72
+ continue;
73
+ // Git prints forward slashes on Windows; mainRoot is a native real path.
74
+ const listed = resolve(line.slice("worktree ".length));
75
+ const path = await realpath(listed).catch(() => listed);
76
+ if (!insideWorktreeDirectory(mainRoot, path))
77
+ continue;
78
+ const record = await readWorktreeRecord(path);
79
+ if (record !== null)
80
+ statuses.push(await describe(deps, record));
81
+ }
82
+ return statuses.sort((left, right) => left.record.name.localeCompare(right.record.name));
83
+ }
84
+ export function idleWorktrees(statuses, now) {
85
+ return statuses.filter(({ lastActivity: at }) => now - Date.parse(at) > IDLE_DAYS * 24 * 60 * 60 * 1000);
86
+ }
87
+ /** Doctor's reading of the worktrees: idle ones are worth a look, not a failure. */
88
+ export function worktreeDoctorResult(statuses, now) {
89
+ const idle = idleWorktrees(statuses, now);
90
+ return idle.length === 0
91
+ ? { status: "PASS", message: `${statuses.length} agent-ops worktree(s), none idle.` }
92
+ : {
93
+ status: "DEGRADED",
94
+ message: `Idle for more than ${IDLE_DAYS} days: ${idle.map(({ record }) => record.name).join(", ")}.`,
95
+ remediation: "Resume, finish or remove them; see agent-ops worktree list."
96
+ };
97
+ }
98
+ async function requireRecord(mainRoot, name) {
99
+ const record = await readWorktreeRecord(worktreePath(mainRoot, name));
100
+ if (record === null) {
101
+ throw manageError("WORKTREE_NOT_FOUND", `No agent-ops worktree named ${name}; see agent-ops worktree list.`);
102
+ }
103
+ return record;
104
+ }
105
+ /**
106
+ * Hands a worktree whose session ended to a new one: the task, the gate and
107
+ * the record follow the new id, and the new session's first Stop has to
108
+ * account for everything already in the worktree.
109
+ */
110
+ export async function resumeWorktree(deps, options) {
111
+ const name = assertWorktreeName(options.name);
112
+ const sessionId = assertSessionId(options.sessionId);
113
+ const mainRoot = await mainCheckout(deps, options.cwd);
114
+ const previous = await requireRecord(mainRoot, name);
115
+ const tasks = deps.tasks(previous.path);
116
+ const attached = await tasks.status({ sessionId: previous.sessionId }).catch(() => null);
117
+ if (attached !== null)
118
+ await tasks.attach(sessionId, attached.task.id);
119
+ const record = { ...previous, sessionId };
120
+ await writeWorktreeRecord(record);
121
+ const mainConfig = await deps.loadConfig(mainRoot);
122
+ if (mainConfig.features.completionGate.enabled && previous.sessionId !== sessionId) {
123
+ await (await deps.gate(mainRoot, mainConfig)).redirect(previous.sessionId, null);
124
+ }
125
+ await bindSession(deps, record, mainConfig, RESUMED_BASELINE);
126
+ return record;
127
+ }
128
+ /**
129
+ * Deletes a worktree and its branch. Work that exists nowhere else — an
130
+ * uncommitted change or a commit the target lacks — needs `force`.
131
+ */
132
+ export async function removeWorktree(deps, options) {
133
+ const name = assertWorktreeName(options.name);
134
+ const mainRoot = await mainCheckout(deps, options.cwd);
135
+ const status = await describe(deps, await requireRecord(mainRoot, name));
136
+ if ((status.dirty || status.ahead > 0) && !options.force) {
137
+ throw manageError("WORKTREE_UNMERGED", `${name} has ${status.dirty ? "uncommitted changes" : ""}${status.dirty && status.ahead > 0 ? " and " : ""}${status.ahead > 0 ? `${status.ahead} commit(s) not in ${status.record.targetBranch}` : ""}. Finish it, or remove it with --force to discard that work (the user is asked).`);
138
+ }
139
+ const { record } = status;
140
+ const mainConfig = await deps.loadConfig(mainRoot);
141
+ if (mainConfig.features.completionGate.enabled) {
142
+ await (await deps.gate(mainRoot, mainConfig)).redirect(record.sessionId, null);
143
+ }
144
+ await deps.trust.revoke(record.path, await deps.loadConfig(record.path)).catch(() => undefined);
145
+ await removeCheckout(deps, record, true);
146
+ return status;
147
+ }