@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.
@@ -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
+ }
@@ -0,0 +1,263 @@
1
+ import { chmod, copyFile, lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join, sep } from "node:path";
3
+ import { calculateConfigHash } from "../config/hash.js";
4
+ import { parseInstallManifest } from "../fs/manifest.js";
5
+ import { AgentOpsError } from "../fs/paths.js";
6
+ import { writeSessionMarker } from "../hooks/codex-loop.js";
7
+ import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
8
+ /**
9
+ * One Git worktree per writing session, so parallel sessions stop sharing the
10
+ * change surface every fingerprint is taken over. Worktrees live inside the
11
+ * main checkout, kept out of its surface by `.git/info/exclude` — never by
12
+ * `.gitignore`, whose edit would itself be a change the gate has to explain.
13
+ */
14
+ export const WORKTREE_DIRECTORY = ".worktrees";
15
+ export const WORKTREE_BRANCH_PREFIX = "agent-ops/";
16
+ export const WORKTREE_RECORD_PATH = ".agent-ops/tasks/worktree.json";
17
+ const EXCLUDE_LINE = `/${WORKTREE_DIRECTORY}/`;
18
+ const NAME = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/u;
19
+ const SESSION = /^[^\0\r\n]{1,256}$/u;
20
+ const DEFAULT_SETUP_TIMEOUT_MS = 10 * 60 * 1000;
21
+ function worktreeError(code, message) {
22
+ return new AgentOpsError(code, message);
23
+ }
24
+ async function git(deps, cwd, args, code, message) {
25
+ const result = await deps.git(cwd, args);
26
+ if (result.exitCode !== 0) {
27
+ const detail = result.stderr.trim().split("\n")[0];
28
+ throw worktreeError(code, detail === undefined || detail === "" ? message : `${message} ${detail}`);
29
+ }
30
+ return result.stdout.trim();
31
+ }
32
+ export function assertWorktreeName(name) {
33
+ if (name === undefined || !NAME.test(name) || name.includes("..")) {
34
+ throw worktreeError("WORKTREE_NAME_INVALID", "A worktree name is 1-64 lowercase letters, digits, '.', '_' or '-', starting and ending with a letter or digit.");
35
+ }
36
+ return name;
37
+ }
38
+ export function assertSessionId(sessionId) {
39
+ if (sessionId === undefined || !SESSION.test(sessionId)) {
40
+ throw worktreeError("WORKTREE_SESSION_REQUIRED", "Pass --session <id>: the conversation that will work in this worktree.");
41
+ }
42
+ return sessionId;
43
+ }
44
+ /**
45
+ * The main checkout and the checkout `cwd` is in. They differ inside a linked
46
+ * worktree. A repository whose common directory is not `<root>/.git` (bare,
47
+ * or a separate git dir) has no main checkout to put worktrees in.
48
+ */
49
+ export async function resolveCheckouts(deps, cwd) {
50
+ const commonDir = await realpath(await git(deps, cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"], "WORKTREE_NOT_GIT", "Worktrees require a Git repository."));
51
+ const currentRoot = await realpath(await git(deps, cwd, ["rev-parse", "--show-toplevel"], "WORKTREE_NOT_GIT", "Worktrees require a Git checkout."));
52
+ if (basename(commonDir) !== ".git") {
53
+ throw worktreeError("WORKTREE_LAYOUT_UNSUPPORTED", "Worktrees need a main checkout whose Git directory is <root>/.git.");
54
+ }
55
+ return { mainRoot: dirname(commonDir), currentRoot, commonDir };
56
+ }
57
+ export function worktreePath(mainRoot, name) {
58
+ return join(mainRoot, WORKTREE_DIRECTORY, name);
59
+ }
60
+ /** True when `path` is inside `<mainRoot>/.worktrees/`. */
61
+ export function insideWorktreeDirectory(mainRoot, path) {
62
+ return path.startsWith(`${join(mainRoot, WORKTREE_DIRECTORY)}${sep}`);
63
+ }
64
+ async function exists(path) {
65
+ try {
66
+ await lstat(path);
67
+ return true;
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
73
+ async function ensureExcluded(deps, mainRoot, commonDir) {
74
+ const probe = await deps.git(mainRoot, ["check-ignore", "-q", "--no-index", `${WORKTREE_DIRECTORY}/probe`]);
75
+ if (probe.exitCode === 0)
76
+ return;
77
+ const path = join(commonDir, "info", "exclude");
78
+ let current = "";
79
+ try {
80
+ current = await readFile(path, "utf8");
81
+ }
82
+ catch {
83
+ await mkdir(dirname(path), { recursive: true });
84
+ }
85
+ if (current.split(/\r?\n/u).includes(EXCLUDE_LINE))
86
+ return;
87
+ const separator = current.length === 0 || current.endsWith("\n") ? "" : "\n";
88
+ await writeFile(path, `${current}${separator}${EXCLUDE_LINE}\n`);
89
+ }
90
+ function portableRelative(path) {
91
+ return path.length > 0 && !path.startsWith("/") && !path.startsWith("~") &&
92
+ !path.includes("\\") && !path.includes("\0") &&
93
+ path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
94
+ }
95
+ /**
96
+ * Files Git does not carry into a new worktree but the session needs there:
97
+ * what agent-ops installed (rules, hooks, its own config) and whatever
98
+ * `.worktreeinclude` names (gitignore syntax: `.env`, `local.properties`).
99
+ * Only files missing from the new checkout are copied, so a tracked file
100
+ * always keeps the branch's version.
101
+ */
102
+ async function carriedFiles(deps, mainRoot) {
103
+ // The config is what makes the new checkout an agent-ops checkout at all:
104
+ // carried even when no manifest lists it.
105
+ const paths = new Set([".agent-ops/config.json"]);
106
+ const manifestSource = await readFile(join(mainRoot, ".agent-ops", "manifest.json"), "utf8").catch(() => null);
107
+ if (manifestSource !== null) {
108
+ paths.add(".agent-ops/manifest.json");
109
+ try {
110
+ const manifest = parseInstallManifest(manifestSource);
111
+ for (const { path } of [...manifest.artifacts, ...manifest.markers, ...(manifest.hooks ?? [])]) {
112
+ paths.add(path);
113
+ }
114
+ }
115
+ catch {
116
+ // An unreadable manifest still leaves the manifest itself to copy.
117
+ }
118
+ }
119
+ if (await exists(join(mainRoot, ".worktreeinclude"))) {
120
+ const listed = await git(deps, mainRoot, ["ls-files", "-z", "--others", "--ignored", "--exclude-from=.worktreeinclude"], "WORKTREE_INCLUDE_INVALID", ".worktreeinclude could not be read.");
121
+ for (const path of listed.split("\0"))
122
+ paths.add(path);
123
+ }
124
+ return [...paths].filter((path) => portableRelative(path) &&
125
+ !path.startsWith(`${WORKTREE_DIRECTORY}/`) &&
126
+ !path.startsWith(".agent-ops/tasks/") &&
127
+ !path.startsWith(".agent-ops/reviews/")).sort();
128
+ }
129
+ async function copyMissing(mainRoot, target, paths) {
130
+ const copied = [];
131
+ for (const path of paths) {
132
+ const source = join(mainRoot, ...path.split("/"));
133
+ const destination = join(target, ...path.split("/"));
134
+ let entry;
135
+ try {
136
+ entry = await lstat(source);
137
+ }
138
+ catch {
139
+ continue;
140
+ }
141
+ if (!entry.isFile() || await exists(destination))
142
+ continue;
143
+ await mkdir(dirname(destination), { recursive: true });
144
+ await copyFile(source, destination);
145
+ await chmod(destination, entry.mode & 0o777);
146
+ copied.push(path);
147
+ }
148
+ return copied;
149
+ }
150
+ export async function readWorktreeRecord(root) {
151
+ const source = await readPrivateFile(join(root, ...WORKTREE_RECORD_PATH.split("/")), root).catch(() => null);
152
+ if (source === null)
153
+ return null;
154
+ try {
155
+ const value = JSON.parse(source);
156
+ return value.schemaVersion === 1 && typeof value.name === "string" &&
157
+ typeof value.branch === "string" && typeof value.path === "string" &&
158
+ typeof value.mainRoot === "string" && typeof value.targetBranch === "string" &&
159
+ typeof value.base === "string" && typeof value.sessionId === "string" &&
160
+ typeof value.createdAt === "string"
161
+ ? value
162
+ : null;
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ }
168
+ export async function writeWorktreeRecord(record) {
169
+ await writePrivateFile(join(record.path, ...WORKTREE_RECORD_PATH.split("/")), `${JSON.stringify(record, null, 2)}\n`, record.path);
170
+ }
171
+ /** Points the session at the worktree from both sides of the gate. */
172
+ export async function bindSession(deps, record, mainConfig, baselineFingerprint) {
173
+ for (const harness of ["claude", "codex"]) {
174
+ if (await exists(join(record.mainRoot, `.${harness}`))) {
175
+ await writeSessionMarker({ root: record.path, harness, sessionId: record.sessionId });
176
+ }
177
+ }
178
+ if (!mainConfig.features.completionGate.enabled)
179
+ return;
180
+ const worktreeConfig = await deps.loadConfig(record.path);
181
+ await (await deps.gate(record.path, worktreeConfig)).seed(record.sessionId, baselineFingerprint);
182
+ await (await deps.gate(record.mainRoot, mainConfig)).redirect(record.sessionId, record.path);
183
+ }
184
+ export async function removeCheckout(deps, record, force) {
185
+ await git(deps, record.mainRoot, ["worktree", "remove", ...(force ? ["--force"] : []), record.path], "WORKTREE_REMOVE_FAILED", `Git could not remove ${record.path}.`);
186
+ await git(deps, record.mainRoot, ["branch", force ? "-D" : "-d", record.branch], "WORKTREE_REMOVE_FAILED", `Git could not delete ${record.branch}.`);
187
+ }
188
+ /**
189
+ * Creates `.worktrees/<name>` on `agent-ops/<name>` from the main checkout's
190
+ * HEAD and makes it a working agent-ops checkout for `sessionId`.
191
+ */
192
+ export async function addWorktree(deps, options) {
193
+ const name = assertWorktreeName(options.name);
194
+ const sessionId = assertSessionId(options.sessionId);
195
+ const { mainRoot, currentRoot, commonDir } = await resolveCheckouts(deps, options.cwd);
196
+ if (currentRoot !== mainRoot) {
197
+ throw worktreeError("WORKTREE_NESTED", `Run worktree commands from the main checkout (${mainRoot}), not from inside a worktree.`);
198
+ }
199
+ const targetBranch = await git(deps, mainRoot, ["symbolic-ref", "--short", "-q", "HEAD"], "WORKTREE_DETACHED_HEAD", "The main checkout must be on a branch to merge back into.");
200
+ const base = await git(deps, mainRoot, ["rev-parse", "--verify", "HEAD^{commit}"], "WORKTREE_NO_COMMIT", "The main checkout has no commit to branch from.");
201
+ const path = worktreePath(mainRoot, name);
202
+ const branch = `${WORKTREE_BRANCH_PREFIX}${name}`;
203
+ if (await exists(path)) {
204
+ throw worktreeError("WORKTREE_EXISTS", `${path} already exists; pick another name or resume it.`);
205
+ }
206
+ if ((await deps.git(mainRoot, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`])).exitCode === 0) {
207
+ throw worktreeError("WORKTREE_EXISTS", `Branch ${branch} already exists; pick another name.`);
208
+ }
209
+ const mainConfig = await deps.loadConfig(mainRoot);
210
+ const mainTrust = await deps.trust.status(mainRoot, mainConfig);
211
+ const setupSteps = mainConfig.worktree?.setup ?? [];
212
+ if (setupSteps.length > 0 && mainTrust !== "TRUSTED") {
213
+ throw worktreeError("WORKTREE_SETUP_UNTRUSTED", "worktree.setup runs repository commands; run agent-ops trust grant in the main checkout first.");
214
+ }
215
+ await ensureExcluded(deps, mainRoot, commonDir);
216
+ await git(deps, mainRoot, ["worktree", "add", "-b", branch, path, base], "WORKTREE_ADD_FAILED", "Git could not create the worktree.");
217
+ const record = {
218
+ schemaVersion: 1,
219
+ name,
220
+ branch,
221
+ path: await realpath(path),
222
+ mainRoot,
223
+ targetBranch,
224
+ base,
225
+ sessionId,
226
+ createdAt: deps.now?.() ?? new Date().toISOString()
227
+ };
228
+ let trusted = false;
229
+ try {
230
+ const copied = await copyMissing(mainRoot, record.path, await carriedFiles(deps, mainRoot));
231
+ const worktreeConfig = await deps.loadConfig(record.path);
232
+ // The same commands the user already trusted, and nothing else: any
233
+ // difference in config leaves the new checkout untrusted.
234
+ if (mainTrust === "TRUSTED" && calculateConfigHash(worktreeConfig) === calculateConfigHash(mainConfig)) {
235
+ await deps.trust.grant(record.path, worktreeConfig);
236
+ trusted = true;
237
+ }
238
+ const setup = [];
239
+ for (const step of setupSteps) {
240
+ const result = await deps.runSetup(record.path, {
241
+ ...step,
242
+ timeoutMs: step.timeoutMs ?? DEFAULT_SETUP_TIMEOUT_MS
243
+ });
244
+ const label = [step.command, ...step.args].join(" ");
245
+ if (result.exitCode !== 0) {
246
+ throw worktreeError("WORKTREE_SETUP_FAILED", `Setup step failed (${label}, exit ${String(result.exitCode)}); the worktree was removed. ${result.output.trim().split("\n").slice(-5).join("\n")}`.trim());
247
+ }
248
+ setup.push(label);
249
+ }
250
+ await writeWorktreeRecord(record);
251
+ await bindSession(deps, record, mainConfig);
252
+ return { record, copied, trusted, setup };
253
+ }
254
+ catch (error) {
255
+ if (mainConfig.features.completionGate.enabled) {
256
+ await (await deps.gate(mainRoot, mainConfig)).redirect(sessionId, null).catch(() => undefined);
257
+ }
258
+ if (trusted)
259
+ await deps.trust.revoke(record.path, mainConfig).catch(() => undefined);
260
+ await removeCheckout(deps, record, true).catch(() => undefined);
261
+ throw error;
262
+ }
263
+ }
@@ -300,7 +300,8 @@ export function validateConfig(value) {
300
300
  "reviewRoles",
301
301
  "schemaVersion",
302
302
  "securityExceptions",
303
- "verification"
303
+ "verification",
304
+ "worktree"
304
305
  ], CONFIG_SCHEMA_VERSION);
305
306
  if (isFailure(root)) {
306
307
  return root;
@@ -411,8 +412,56 @@ export function validateConfig(value) {
411
412
  roles.add(role.value.role);
412
413
  }
413
414
  }
415
+ if (root.worktree !== undefined) {
416
+ const worktree = validateWorktree(root.worktree, "$.worktree");
417
+ if (!worktree.ok) {
418
+ return worktree;
419
+ }
420
+ }
414
421
  return success(root);
415
422
  }
423
+ function validateWorktree(value, path) {
424
+ if (!isRecord(value)) {
425
+ return failure("INVALID_TYPE", path, "worktree must be an object.");
426
+ }
427
+ const unknown = unknownFieldFailure(value, ["mode", "setup"], path);
428
+ if (unknown !== undefined) {
429
+ return unknown;
430
+ }
431
+ if (value.mode !== "auto" && value.mode !== "off") {
432
+ return failure("INVALID_WORKTREE_MODE", `${path}.mode`, `Unsupported worktree mode: ${String(value.mode)}`);
433
+ }
434
+ if (value.setup === undefined) {
435
+ return success(value);
436
+ }
437
+ if (!Array.isArray(value.setup)) {
438
+ return failure("INVALID_TYPE", `${path}.setup`, "setup must be an array.");
439
+ }
440
+ for (const [index, step] of value.setup.entries()) {
441
+ const stepPath = `${path}.setup[${index}]`;
442
+ if (!isRecord(step)) {
443
+ return failure("INVALID_TYPE", stepPath, "Expected a setup command object.");
444
+ }
445
+ const stepUnknown = unknownFieldFailure(step, ["args", "command", "timeoutMs"], stepPath);
446
+ if (stepUnknown !== undefined) {
447
+ return stepUnknown;
448
+ }
449
+ if (!isNonEmptyString(step.command)) {
450
+ return failure("INVALID_COMMAND", `${stepPath}.command`, "command must be a non-empty string.");
451
+ }
452
+ if (!isStringArray(step.args) ||
453
+ step.args.some((argument) => argument.includes("\0"))) {
454
+ return failure("INVALID_ARGS", `${stepPath}.args`, "args must be an array of strings.");
455
+ }
456
+ if (step.timeoutMs !== undefined &&
457
+ (!Number.isSafeInteger(step.timeoutMs) ||
458
+ step.timeoutMs <= 0 ||
459
+ step.timeoutMs > MAX_TIMEOUT_MS)) {
460
+ return failure("INVALID_TIMEOUT", `${stepPath}.timeoutMs`, "timeoutMs must be a positive integer.");
461
+ }
462
+ }
463
+ return success(value);
464
+ }
416
465
  function validateReviewRole(value, path) {
417
466
  if (!isRecord(value)) {
418
467
  return failure("INVALID_TYPE", path, "Expected a review role object.");
@@ -132,6 +132,48 @@ agent-ops doctor --check-auth # one real print call per target
132
132
  reports what to do but never fixes it: every target authenticates through
133
133
  interactive OAuth, so there is no `--fix`. Run `<target> login` yourself.
134
134
 
135
+ ### Parallel sessions in worktrees
136
+
137
+ Verification, review and the completion gate all fingerprint the whole Git
138
+ change surface, so two conversations editing one checkout void each other's
139
+ evidence. Give each editing conversation its own worktree instead:
140
+
141
+ ```json
142
+ {
143
+ "worktree": {
144
+ "mode": "auto",
145
+ "setup": [{ "command": "pnpm", "args": ["install", "--frozen-lockfile"] }]
146
+ }
147
+ }
148
+ ```
149
+
150
+ - `mode: "auto"` routes every editing conversation through
151
+ `agent-ops worktree add <name> --session <id>`, run from the main checkout.
152
+ On Claude Code a direct Edit or Write of the main checkout outside
153
+ `.worktrees/` is denied (run `agent-ops update` once so the PreToolUse hook
154
+ matches the file tools). Absent or `"off"` keeps today's single checkout.
155
+ - `add` creates `.worktrees/<name>` on branch `agent-ops/<name>` from the main
156
+ checkout's HEAD and excludes `/.worktrees/` through `.git/info/exclude`,
157
+ never `.gitignore`. It copies the ignored files agent-ops installed and any
158
+ file matched by a root `.worktreeinclude` (gitignore syntax, for example
159
+ `.env` or `local.properties`); a file Git already checked out is never
160
+ overwritten. Trust is inherited only when the main checkout is trusted and
161
+ the worktree's effective config is identical.
162
+ - `setup` commands run in the new worktree (default timeout ten minutes) for
163
+ what Git does not carry, such as installed dependencies. They are part of
164
+ the trusted config, so they need no separate approval; a failing step
165
+ removes the worktree again.
166
+ - The session's completion gate follows it: Claude Code enters the worktree
167
+ with EnterWorktree, and a host that cannot move a session (agy) is judged by
168
+ the worktree's gate through a redirect the main checkout records.
169
+ - `agent-ops worktree finish <name>` merges by fast-forward only, one finish
170
+ at a time. If the target moved, it rebases; a clean rebase whose own patch
171
+ is unchanged is re-verified before merging, and a conflict is reported with
172
+ the `refs/notes/agent-ops` intent of the work that landed first.
173
+ - `worktree list`, `resume <name> --session <id>` and `remove <name>` manage
174
+ what is left; `remove --force` discards work and asks the user first.
175
+ `doctor` reports worktrees idle for more than seven days.
176
+
135
177
  ### Project-local loop profile
136
178
 
137
179
  `--profile loop` is an opt-in project-scope profile. Select `codex`, `claude`,
@@ -29,3 +29,13 @@ Concurrent delegated writers MUST NOT share one working tree.
29
29
  - Evidence: `agent-ops verify` returns a status other than `UNKNOWN` for the run.
30
30
  - Positive: `Read-only scans run in parallel; the single edit runs in the coordinator worktree.`
31
31
  - Negative: `Two subagents edit the same files while verification runs.`
32
+
33
+ ## DELEGATE-ISOLATION-002
34
+
35
+ Parallel editing conversations in one repository MUST each work in their own agent-ops worktree.
36
+
37
+ - Trigger: `worktree.mode` is `auto`, or a second conversation will edit the repository while another is still open.
38
+ - Action: Run `agent-ops worktree add <name> --session <id>` from the main checkout before the first edit, work only in the printed path, and merge with `agent-ops worktree finish <name>` after the task completes against the printed `--base`.
39
+ - Evidence: `agent-ops worktree list` shows one worktree per editing session, and each finish reports a fast-forward merge.
40
+ - Positive: `Two conversations edit .worktrees/login and .worktrees/cart; each verifies, reviews and finishes on its own.`
41
+ - Negative: `Two conversations edit the main checkout at once, so each one's verification and review is voided by the other's writes.`
@@ -116,6 +116,46 @@ agent-ops doctor --check-auth # 每個目標一次真實 print 呼叫
116
116
  不會代為修復:所有目標都經由互動式 OAuth 認證,因此沒有 `--fix`。
117
117
  請自行執行 `<target> login`。
118
118
 
119
+ ### 在 worktree 中並行 session
120
+
121
+ Verification、review 與 completion gate 都對整個 Git change surface 計算
122
+ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方的 evidence。
123
+ 請讓每個修改中的對話使用自己的 worktree:
124
+
125
+ ```json
126
+ {
127
+ "worktree": {
128
+ "mode": "auto",
129
+ "setup": [{ "command": "pnpm", "args": ["install", "--frozen-lockfile"] }]
130
+ }
131
+ }
132
+ ```
133
+
134
+ - `mode: "auto"` 讓每個修改中的對話都先在主 checkout 執行
135
+ `agent-ops worktree add <name> --session <id>`。Claude Code 直接以 Edit 或
136
+ Write 修改主 checkout 中 `.worktrees/` 以外的檔案會被拒絕(需先執行一次
137
+ `agent-ops update`,讓 PreToolUse hook 比對檔案工具)。未設定或 `"off"` 則維持
138
+ 目前的單一 checkout。
139
+ - `add` 從主 checkout 的 HEAD 建立 `.worktrees/<name>` 與 branch
140
+ `agent-ops/<name>`,並透過 `.git/info/exclude`(而非 `.gitignore`)排除
141
+ `/.worktrees/`。它會複製 agent-ops 安裝的 ignored 檔案,以及根目錄
142
+ `.worktreeinclude`(gitignore 語法,例如 `.env` 或 `local.properties`)比對到
143
+ 的檔案;Git 已 checkout 的檔案一律不覆寫。只有主 checkout 已 trusted 且
144
+ worktree 的 effective config 完全相同時才繼承 trust。
145
+ - `setup` 指令在新 worktree 中執行(預設 timeout 十分鐘),處理 Git 不會帶過去
146
+ 的東西,例如已安裝的相依套件。它屬於 trusted config 的一部分,不需另外核准;
147
+ 任一步驟失敗會移除該 worktree。
148
+ - Session 的 completion gate 會跟著它:Claude Code 以 EnterWorktree 進入
149
+ worktree;無法移動 session 的 host(agy)則透過主 checkout 記錄的 redirect,
150
+ 由 worktree 的 gate 判定。
151
+ - `agent-ops worktree finish <name>` 只以 fast-forward 合併,一次只執行一個
152
+ finish。Target 若已前進會先 rebase;沒有衝突且自身 patch 不變的 rebase 會先
153
+ 重新驗證再合併;衝突則連同先合併那份工作在 `refs/notes/agent-ops` 的意圖一起
154
+ 回報。
155
+ - `worktree list`、`resume <name> --session <id>` 與 `remove <name>` 管理剩下的
156
+ worktree;`remove --force` 會丟棄工作,執行前會先詢問使用者。`doctor` 會回報
157
+ 閒置超過七天的 worktree。
158
+
119
159
  ### Project-local loop profile
120
160
 
121
161
  `--profile loop` 是明確 opt-in 的 project-scope profile。請選擇 `codex`、
@@ -1,6 +1,6 @@
1
1
  # 委派
2
2
 
3
- English source version: 2026-07-23. Revalidate: when the English specification changes.
3
+ English source version: 2026-09-24. Revalidate: when the English specification changes.
4
4
 
5
5
  ## DELEGATE-SCOPE-001
6
6
 
@@ -31,3 +31,13 @@ English source version: 2026-07-23. Revalidate: when the English specification c
31
31
  - Evidence: `agent-ops verify` 該次執行回傳非 `UNKNOWN` 的狀態。
32
32
  - Positive: `唯讀掃描並行執行;唯一的修改在協調者工作區進行。`
33
33
  - Negative: `兩個 subagent 在驗證執行期間修改相同檔案。`
34
+
35
+ ## DELEGATE-ISOLATION-002
36
+
37
+ 同一個 repository 中並行修改檔案的對話 MUST 各自在自己的 agent-ops worktree 中工作。
38
+
39
+ - Trigger: `worktree.mode` 為 `auto`,或另一個對話仍開著時,第二個對話也要修改這個 repository。
40
+ - Action: 第一次修改前,在主 checkout 執行 `agent-ops worktree add <name> --session <id>`,只在印出的路徑中工作;task 以印出的 `--base` 完成後,用 `agent-ops worktree finish <name>` 合併。
41
+ - Evidence: `agent-ops worktree list` 顯示每個修改中的 session 各有一個 worktree,且每次 finish 都回報 fast-forward 合併。
42
+ - Positive: `兩個對話分別修改 .worktrees/login 與 .worktrees/cart;各自驗證、review 並 finish。`
43
+ - Negative: `兩個對話同時修改主 checkout,彼此的寫入讓對方的驗證與 review 全部作廢。`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "description": "Evidence-driven development loops for agy, Codex, Claude Code, and opencode",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -119,6 +119,41 @@
119
119
  }
120
120
  }
121
121
  },
122
+ "worktree": {
123
+ "type": "object",
124
+ "additionalProperties": false,
125
+ "required": ["mode"],
126
+ "properties": {
127
+ "mode": {
128
+ "enum": ["auto", "off"]
129
+ },
130
+ "setup": {
131
+ "type": "array",
132
+ "items": {
133
+ "type": "object",
134
+ "additionalProperties": false,
135
+ "required": ["command", "args"],
136
+ "properties": {
137
+ "command": {
138
+ "$ref": "#/$defs/nonEmptyString"
139
+ },
140
+ "args": {
141
+ "type": "array",
142
+ "items": {
143
+ "type": "string",
144
+ "pattern": "^[^\\u0000]*$"
145
+ }
146
+ },
147
+ "timeoutMs": {
148
+ "type": "integer",
149
+ "minimum": 1,
150
+ "maximum": 2147483647
151
+ }
152
+ }
153
+ }
154
+ }
155
+ }
156
+ },
122
157
  "securityExceptions": {
123
158
  "type": "array",
124
159
  "items": {