@kylecheng3146/agent-ops 0.3.0 → 0.3.2

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.
@@ -193,6 +193,20 @@ export async function completeInitChoices(args, io, setup = {}) {
193
193
  const reviewTargets = args.reviewTargets ?? (enabled && eligibleReviewChoices.length > 0
194
194
  ? await selectOptions("Review targets (multi-select: tried in listed order)", eligibleReviewChoices, selectorIo, REVIEW_TARGET_ORDER.filter((target) => harness.includes(target)), { startEmpty: true })
195
195
  : []);
196
+ const worktree = args.worktree ?? (scope === "project"
197
+ ? (await selectOption("Enable Git worktree isolation for parallel agent sessions?", [
198
+ {
199
+ label: "yes",
200
+ value: "auto",
201
+ description: "Recommended. Isolates each agent session in a dedicated worktree."
202
+ },
203
+ {
204
+ label: "no",
205
+ value: "off",
206
+ description: "Keep a single shared checkout."
207
+ }
208
+ ], selectorIo))
209
+ : undefined);
196
210
  await probeReviewTargets(reviewTargets, setup);
197
211
  return {
198
212
  ...args,
@@ -200,7 +214,8 @@ export async function completeInitChoices(args, io, setup = {}) {
200
214
  harness,
201
215
  profiles,
202
216
  ...(completionGate ? { completionGate: true } : {}),
203
- ...(reviewTargets.length === 0 ? {} : { reviewTargets })
217
+ ...(reviewTargets.length === 0 ? {} : { reviewTargets }),
218
+ ...(worktree !== undefined ? { worktree } : {})
204
219
  };
205
220
  }
206
221
  const session = await createPromptSession(io);
@@ -220,13 +235,17 @@ export async function completeInitChoices(args, io, setup = {}) {
220
235
  ? selectReviewTargets(await session.question(`Review targets (${defaultReviewTargets.join(",") || "none"}): `), defaultReviewTargets)
221
236
  : []);
222
237
  await probeReviewTargets(reviewTargets, setup);
238
+ const worktree = args.worktree ?? (scope === "project"
239
+ ? (!/^(n|no)$/i.test((await session.question("Enable Git worktree isolation for parallel agent sessions? [Y/n]: ")).trim()) ? "auto" : "off")
240
+ : undefined);
223
241
  return {
224
242
  ...args,
225
243
  scope,
226
244
  harness,
227
245
  profiles,
228
246
  ...(completionGate ? { completionGate: true } : {}),
229
- ...(reviewTargets.length === 0 ? {} : { reviewTargets })
247
+ ...(reviewTargets.length === 0 ? {} : { reviewTargets }),
248
+ ...(worktree !== undefined ? { worktree } : {})
230
249
  };
231
250
  }
232
251
  finally {
@@ -42,7 +42,8 @@ function commandHook(event, runtimePath, completionGate = false) {
42
42
  CLAUDE_HOOK_MARKER,
43
43
  ...(completionGate ? ["--completion-gate"] : [])
44
44
  ],
45
- timeout: 30
45
+ // PreToolUse may create the session's worktree, setup steps included.
46
+ timeout: event === "PreToolUse" ? 600 : 30
46
47
  };
47
48
  }
48
49
  function matcherGroup(event, runtimePath, completionGate = false) {
@@ -228,7 +228,7 @@ export class CompletionGateService {
228
228
  this.#options.config.worktree?.mode === "auto" &&
229
229
  state.root === undefined &&
230
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.`);
231
+ return gateResult("continue", "UNKNOWN", "COMPLETION_GATE_WORKTREE_MODE", `worktree.mode is auto. Session: ${sessionId}. Before your first edit run agent-ops task create from this checkout, which creates this session's worktree and puts the task there (or agent-ops worktree add <name> --session ${shellQuote(sessionId)}), and work only inside the printed path. Verify and review there, then finish with agent-ops worktree finish from this checkout, which completes the tasks; do not run task complete in the worktree.`);
232
232
  }
233
233
  return gateResult("continue", changed ? "UNKNOWN" : "PASS", changed ? "COMPLETION_GATE_CHANGED" : "COMPLETION_GATE_READY", changed
234
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}.`
@@ -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.", "", "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.", "");
314
+ lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria, verification, and independent review;", "completing one never completes its parent.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "", "Parallel conversations in one repository each need their own worktree.", "When `.agent-ops/config.json` sets `worktree.mode` to `auto`, run", "`agent-ops task create` from the main checkout before your first edit:", "it creates this session's worktree and puts the task there (`agent-ops", "worktree add <name> --session <session-id>` does the same without a", "task). Work only inside the printed path", "(Claude Code: EnterWorktree with that path; otherwise use it as every", "command's working directory). Commit, then run verify and review for", "each task with the printed `--base`; do not run `task complete` in the", "worktree. Leave it (Claude Code: ExitWorktree with action keep) and run", "`agent-ops worktree finish <name>` from the main checkout: it completes", "every task in the worktree, subtasks first, then merges. A finish that", "reports an incomplete task names it; verify or review that task again", "in the worktree and finish again. A finish that reports a conflict is", "resolved once, as a new task whose criteria cover both intents; if its", "verification or review fails, reset as instructed and report.", "");
315
315
  }
316
316
  if (context.capabilities.includes("lifecycle-summary")) {
317
317
  lines.push("Advisory lifecycle summaries and local logs are informational. Advisory", "failures must remain fail-open and cannot become verification evidence.", "");
@@ -0,0 +1,63 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ const LOCKFILES = [
4
+ { path: "bun.lock", packageManager: "bun" },
5
+ { path: "bun.lockb", packageManager: "bun" },
6
+ { path: "npm-shrinkwrap.json", packageManager: "npm" },
7
+ { path: "package-lock.json", packageManager: "npm" },
8
+ { path: "pnpm-lock.yaml", packageManager: "pnpm" },
9
+ { path: "yarn.lock", packageManager: "yarn" }
10
+ ];
11
+ const SETUP_COMMANDS = {
12
+ npm: ["ci"],
13
+ pnpm: ["install", "--frozen-lockfile"],
14
+ yarn: ["install", "--immutable"],
15
+ bun: ["install", "--frozen-lockfile"]
16
+ };
17
+ function hasErrorCode(error, code) {
18
+ return error instanceof Error && "code" in error && error.code === code;
19
+ }
20
+ async function regularFileExists(filePath) {
21
+ try {
22
+ const status = await lstat(filePath);
23
+ return status.isFile() && !status.isSymbolicLink();
24
+ }
25
+ catch (error) {
26
+ if (hasErrorCode(error, "ENOENT")) {
27
+ return false;
28
+ }
29
+ throw error;
30
+ }
31
+ }
32
+ /**
33
+ * Detects project lockfiles and proposes safe, deterministic dependency installation
34
+ * commands for freshly cloned worktrees. Returns an empty array if detection is
35
+ * ambiguous or unsupported.
36
+ */
37
+ export async function detectWorktreeSetup(root) {
38
+ const detected = await Promise.all(LOCKFILES.map(async (def) => (await regularFileExists(join(root, def.path))) ? def.packageManager : null));
39
+ const managers = [...new Set(detected.filter((m) => m !== null))];
40
+ if (managers.length !== 1) {
41
+ return [];
42
+ }
43
+ const manager = managers[0];
44
+ const args = SETUP_COMMANDS[manager];
45
+ return [{ command: manager, args: [...args] }];
46
+ }
47
+ /**
48
+ * Returns a warning message if local environment files (.env) are detected
49
+ * without a .worktreeinclude file to carry them into new worktrees.
50
+ */
51
+ export async function checkWorktreeEnvWarning(root) {
52
+ const hasWorktreeInclude = await regularFileExists(join(root, ".worktreeinclude"));
53
+ if (hasWorktreeInclude) {
54
+ return undefined;
55
+ }
56
+ const envCandidates = [".env", ".env.local"];
57
+ for (const candidate of envCandidates) {
58
+ if (await regularFileExists(join(root, candidate))) {
59
+ return "Local environment files (.env) detected without .worktreeinclude; consider creating .worktreeinclude to copy them to new worktrees.";
60
+ }
61
+ }
62
+ return undefined;
63
+ }
@@ -78,7 +78,7 @@ async function detectVerificationCommands(root) {
78
78
  blockers: discovery.decisions.map((decision) => `${decision.adapter}: ${decision.message}`)
79
79
  };
80
80
  }
81
- function buildConfig(profiles, existing, reviewTargets = [], detectedCommands = [], completionGateEnabled = false) {
81
+ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands = [], completionGateEnabled = false, worktree) {
82
82
  // Absent reviewRoles means external review is disabled; an empty selection
83
83
  // must therefore omit the field rather than write an empty array.
84
84
  const reviewRoles = reviewTargets.length > 0
@@ -88,6 +88,9 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
88
88
  existing.verification.commands.length > 0
89
89
  ? existing.verification
90
90
  : { commands: [...detectedCommands] };
91
+ const effectiveWorktree = worktree !== undefined
92
+ ? (worktree === null ? undefined : structuredClone(worktree))
93
+ : (existing?.worktree === undefined ? undefined : structuredClone(existing.worktree));
91
94
  return {
92
95
  schemaVersion: CONFIG_SCHEMA_VERSION,
93
96
  profiles: [...profiles],
@@ -103,12 +106,10 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
103
106
  pathMappings: existing?.pathMappings ?? [],
104
107
  securityExceptions: existing?.securityExceptions ?? [],
105
108
  ...(reviewRoles === undefined ? {} : { reviewRoles: [...reviewRoles] }),
106
- ...(existing?.worktree === undefined
107
- ? {}
108
- : { worktree: structuredClone(existing.worktree) })
109
+ ...(effectiveWorktree === undefined ? {} : { worktree: effectiveWorktree })
109
110
  };
110
111
  }
111
- async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = [], completionGateEnabled = false) {
112
+ async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = [], completionGateEnabled = false, worktree) {
112
113
  const current = await readCurrentFile(root, CONFIG_PATH);
113
114
  const owned = findOwnedArtifact(existingManifest, CONFIG_PATH);
114
115
  if (current !== null && owned === undefined) {
@@ -144,7 +145,7 @@ async function planConfig(root, profiles, existingManifest, suppliedConfig, revi
144
145
  existingConfig.verification.commands.length === 0
145
146
  ? await detectVerificationCommands(root)
146
147
  : { commands: [], blockers: [] };
147
- const config = buildConfig(profiles, existingConfig, reviewTargets, detected.commands, completionGateEnabled);
148
+ const config = buildConfig(profiles, existingConfig, reviewTargets, detected.commands, completionGateEnabled, worktree);
148
149
  const content = `${JSON.stringify(config, null, 2)}\n`;
149
150
  return {
150
151
  operation: {
@@ -493,7 +494,7 @@ export async function createInstallPlan(options) {
493
494
  : [];
494
495
  const operations = [];
495
496
  const artifacts = [];
496
- const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? [], completionGateEnabled);
497
+ const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? [], completionGateEnabled, options.worktree);
497
498
  operations.push(config.operation);
498
499
  artifacts.push(config.record);
499
500
  for (const artifact of contribution.artifacts) {
@@ -123,7 +123,10 @@ export async function createUpdatePlan(options) {
123
123
  existingConfig: {
124
124
  value: configPreview.migrated,
125
125
  sourceHash: configPreview.sourceHash
126
- }
126
+ },
127
+ ...(options.worktree === undefined
128
+ ? {}
129
+ : { worktree: options.worktree })
127
130
  });
128
131
  return {
129
132
  targetVersion,
@@ -159,12 +159,35 @@ async function targetIntents(deps, cwd, target) {
159
159
  }
160
160
  return intents;
161
161
  }
162
- function noteText(record, task) {
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) {
163
181
  return [
164
- `agent-ops task ${task.task.id}: ${task.task.title}`,
165
182
  `worktree: ${record.name} (${record.branch})`,
166
- "criteria:",
167
- ...task.task.criteria.map(({ id, description }) => `- ${id}: ${description}`)
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
+ })
168
191
  ].join("\n");
169
192
  }
170
193
  /**
@@ -200,12 +223,29 @@ export async function finishWorktree(deps, options) {
200
223
  throw finishError("WORKTREE_NOTHING_TO_MERGE", `${record.branch} has no commits beyond ${record.targetBranch}; remove the worktree instead.`);
201
224
  }
202
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
+ }
203
244
  const worktreeGate = await deps.gate(record.path, worktreeConfig);
204
245
  const problem = await worktreeGate.validate(record.sessionId);
205
246
  if (problem !== null) {
206
247
  throw finishError("WORKTREE_TASK_INCOMPLETE", `The worktree's task is not ready to merge (${problem.code}). ${problem.remedy ?? ""}`.trim());
207
248
  }
208
- const task = await deps.tasks(record.path).status({ sessionId: record.sessionId });
209
249
  const head = await git(deps, record.path, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
210
250
  const fastForward = (await deps.git(record.path, ["merge-base", "--is-ancestor", target, "HEAD"])).exitCode === 0;
211
251
  if (!fastForward) {
@@ -248,7 +288,7 @@ export async function finishWorktree(deps, options) {
248
288
  warnings.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
249
289
  }
250
290
  };
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."));
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."));
252
292
  if (gateEnabled) {
253
293
  await attempt("gate", async () => {
254
294
  const mainGate = await deps.gate(mainRoot, mainConfig);
@@ -258,6 +298,6 @@ export async function finishWorktree(deps, options) {
258
298
  }
259
299
  await attempt("trust", async () => await deps.trust.revoke(record.path, worktreeConfig));
260
300
  await attempt("remove", async () => await removeCheckout(deps, record, true));
261
- return { record, mergedHead, rebased: !fastForward, taskId: task.task.id, warnings };
301
+ return { record, mergedHead, rebased: !fastForward, taskIds: tree.map(({ task }) => task.task.id), warnings };
262
302
  });
263
303
  }
@@ -1,5 +1,5 @@
1
1
  import { realpath } from "node:fs/promises";
2
- import { basename, dirname, join, sep } from "node:path";
2
+ import { basename, dirname, join, relative, sep } from "node:path";
3
3
  import { insideWorktreeDirectory } from "./service.js";
4
4
  /** The main checkout of the repository `runner` runs in, or null. */
5
5
  export async function resolveMainRoot(runner) {
@@ -41,17 +41,34 @@ export async function canonicalPath(path) {
41
41
  * `.worktrees/<name>`. Paths outside the repository are not this guard's
42
42
  * business.
43
43
  */
44
- export async function evaluateWorktreeWrite(mainRoot, paths, sessionId) {
44
+ export async function evaluateWorktreeWrite(mainRoot, paths, sessionId,
45
+ /** Creates or reuses this session's worktree and returns its path. */
46
+ ensureWorktree) {
45
47
  for (const path of paths) {
46
48
  const target = await canonicalPath(path);
47
49
  const inMain = target === mainRoot || target.startsWith(`${mainRoot}${sep}`);
48
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
+ }
49
66
  const session = sessionId ?? "<session-id>";
50
67
  return {
51
68
  action: "block",
52
69
  status: "FAIL",
53
70
  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.`
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.`
55
72
  };
56
73
  }
57
74
  }
@@ -261,3 +261,21 @@ export async function addWorktree(deps, options) {
261
261
  throw error;
262
262
  }
263
263
  }
264
+ /** `session-` plus the session id's first eight name-safe characters. */
265
+ export function sessionWorktreeName(sessionId) {
266
+ const slug = sessionId.toLowerCase().replace(/[^a-z0-9]/gu, "").slice(0, 8);
267
+ return `session-${slug === "" ? "0" : slug}`;
268
+ }
269
+ /**
270
+ * The worktree `sessionId` works in, created on first use. One already bound
271
+ * to the same session is reused, so a session blocked twice before it moves
272
+ * still gets exactly one worktree.
273
+ */
274
+ export async function ensureSessionWorktree(deps, options) {
275
+ const { mainRoot } = await resolveCheckouts(deps, options.cwd);
276
+ const name = sessionWorktreeName(options.sessionId);
277
+ const existing = await readWorktreeRecord(worktreePath(mainRoot, name));
278
+ if (existing?.sessionId === options.sessionId)
279
+ return existing;
280
+ return (await addWorktree(deps, { cwd: mainRoot, name, sessionId: options.sessionId })).record;
281
+ }
@@ -39,8 +39,18 @@ function safePreflight(attempt) {
39
39
  ...(attempt.diagnostic === undefined ? {} : { diagnostic: safeText(attempt.diagnostic) })
40
40
  };
41
41
  }
42
- function artifactPath(root, fingerprint) {
43
- return join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"), `${fingerprint}${REVIEW_REPORT_ARTIFACT_SUFFIX}`);
42
+ /**
43
+ * A task's review records live under `<fingerprint>.<task id>`, so a parent
44
+ * and its subtask reviewed on the same source each keep their own PASS until
45
+ * `worktree finish` completes both. A bare `<fingerprint>` is a record with no
46
+ * task, or one written before records were kept per task; readers fall back
47
+ * to it.
48
+ */
49
+ function recordKey(fingerprint, taskId) {
50
+ return taskId === undefined ? fingerprint : `${fingerprint}.${taskId}`;
51
+ }
52
+ function artifactPath(root, fingerprint, taskId) {
53
+ return join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"), `${recordKey(fingerprint, taskId)}${REVIEW_REPORT_ARTIFACT_SUFFIX}`);
44
54
  }
45
55
  export function reviewReportDigest(report) {
46
56
  return sha256(JSON.stringify(redactReviewReport(report)));
@@ -127,7 +137,8 @@ function matchingReportArtifact(value, attestation) {
127
137
  attempts.every((attempt, index) => attempt.sessionId === attestation.reviewSessionIds[index]));
128
138
  }
129
139
  async function hasMatchingReportArtifact(root, attestation) {
130
- const source = await readPrivateFile(artifactPath(root, attestation.sourceFingerprint), root);
140
+ // `reportArtifact` was validated to name this fingerprint's own record.
141
+ const source = await readPrivateFile(join(root, ...attestation.reportArtifact.split("/")), root);
131
142
  if (source === null) {
132
143
  return false;
133
144
  }
@@ -150,12 +161,12 @@ export async function saveReviewReportArtifact(root, result, sourceFingerprint,
150
161
  if (Buffer.byteLength(serialized, "utf8") > REPORT_ARTIFACT_MAX_BYTES) {
151
162
  throw new AgentOpsError("REVIEW_REPORT_ARTIFACT_TOO_LARGE", "Review report artifact exceeds the capture limit.");
152
163
  }
153
- const relativePath = `${REVIEW_ATTESTATION_DIRECTORY}/${sourceFingerprint}${REVIEW_REPORT_ARTIFACT_SUFFIX}`;
154
- await writePrivateFile(artifactPath(root, sourceFingerprint), serialized, root);
164
+ const relativePath = `${REVIEW_ATTESTATION_DIRECTORY}/${recordKey(sourceFingerprint, taskId)}${REVIEW_REPORT_ARTIFACT_SUFFIX}`;
165
+ await writePrivateFile(artifactPath(root, sourceFingerprint, taskId), serialized, root);
155
166
  return relativePath;
156
167
  }
157
- function attestationPath(root, fingerprint) {
158
- return join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"), `${fingerprint}.json`);
168
+ function attestationPath(root, fingerprint, taskId) {
169
+ return join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"), `${recordKey(fingerprint, taskId)}.json`);
159
170
  }
160
171
  function parseAttestation(value) {
161
172
  if (typeof value !== "object" || value === null) {
@@ -183,8 +194,12 @@ function parseAttestation(value) {
183
194
  typeof record.adversarialReportDigest !== "string" ||
184
195
  !DIGEST_PATTERN.test(record.adversarialReportDigest) ||
185
196
  typeof record.reportArtifact !== "string" ||
186
- record.reportArtifact !==
187
- `${REVIEW_ATTESTATION_DIRECTORY}/${record.sourceFingerprint}${REVIEW_REPORT_ARTIFACT_SUFFIX}` ||
197
+ // Its own per-task record, or the bare one an older review wrote.
198
+ ![
199
+ recordKey(record.sourceFingerprint, record.taskId),
200
+ record.sourceFingerprint
201
+ ].some((key) => record.reportArtifact ===
202
+ `${REVIEW_ATTESTATION_DIRECTORY}/${key}${REVIEW_REPORT_ARTIFACT_SUFFIX}`) ||
188
203
  (record.hostTarget !== undefined && !isReviewTarget(record.hostTarget)) ||
189
204
  typeof record.createdAt !== "string" ||
190
205
  !Number.isFinite(Date.parse(record.createdAt))) {
@@ -214,33 +229,61 @@ export async function saveReviewAttestation(root, attestation) {
214
229
  if (!(await hasMatchingReportArtifact(root, validated))) {
215
230
  throw new AgentOpsError("REVIEW_ATTESTATION_INVALID", "Review attestation has no matching report artifact.");
216
231
  }
217
- const relativePath = `${REVIEW_ATTESTATION_DIRECTORY}/${validated.sourceFingerprint}.json`;
218
- await writePrivateFile(attestationPath(root, validated.sourceFingerprint), `${JSON.stringify(validated, null, 2)}\n`, root);
232
+ const relativePath = `${REVIEW_ATTESTATION_DIRECTORY}/${recordKey(validated.sourceFingerprint, validated.taskId)}.json`;
233
+ await writePrivateFile(attestationPath(root, validated.sourceFingerprint, validated.taskId), `${JSON.stringify(validated, null, 2)}\n`, root);
219
234
  return relativePath;
220
235
  }
221
236
  /**
222
- * Returns the attestation recorded for this exact source state, or null. A
223
- * malformed file reads as absent: a gate must fail closed on garbage, never
224
- * treat it as a passing review.
237
+ * The record keys that may hold `taskId`'s review of this source: its own,
238
+ * then the bare one. Without a task, every task's record for the source.
225
239
  */
226
- export async function findReviewAttestation(root, sourceFingerprint) {
227
- if (!FINGERPRINT_PATTERN.test(sourceFingerprint)) {
228
- return null;
229
- }
230
- const source = await readPrivateFile(attestationPath(root, sourceFingerprint), root);
231
- if (source === null) {
232
- return null;
240
+ async function candidateKeys(root, sourceFingerprint, taskId, suffix) {
241
+ if (taskId !== undefined) {
242
+ return [recordKey(sourceFingerprint, taskId), sourceFingerprint];
233
243
  }
244
+ let names = [];
234
245
  try {
235
- const attestation = parseAttestation(JSON.parse(source));
236
- return attestation?.sourceFingerprint === sourceFingerprint &&
237
- await hasMatchingReportArtifact(root, attestation)
238
- ? attestation
239
- : null;
246
+ names = await readdir(join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/")));
240
247
  }
241
248
  catch {
249
+ // No reviews yet.
250
+ }
251
+ const own = names
252
+ .filter((name) => name.startsWith(`${sourceFingerprint}.`) && name.endsWith(suffix))
253
+ .map((name) => name.slice(0, -suffix.length))
254
+ .filter((key) => key !== sourceFingerprint && !key.endsWith(".reports") &&
255
+ TASK_ID_PATTERN.test(key.slice(sourceFingerprint.length + 1)));
256
+ return [sourceFingerprint, ...own];
257
+ }
258
+ /**
259
+ * Returns the attestation recorded for this exact source state, or null. A
260
+ * malformed file reads as absent: a gate must fail closed on garbage, never
261
+ * treat it as a passing review. With `taskId`, only that task's record or the
262
+ * bare one; callers still check the task the attestation names.
263
+ */
264
+ export async function findReviewAttestation(root, sourceFingerprint, taskId) {
265
+ if (!FINGERPRINT_PATTERN.test(sourceFingerprint) ||
266
+ (taskId !== undefined && !TASK_ID_PATTERN.test(taskId))) {
242
267
  return null;
243
268
  }
269
+ for (const key of await candidateKeys(root, sourceFingerprint, taskId, ".json")) {
270
+ const source = await readPrivateFile(join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"), `${key}.json`), root);
271
+ if (source === null) {
272
+ continue;
273
+ }
274
+ try {
275
+ const attestation = parseAttestation(JSON.parse(source));
276
+ if (attestation?.sourceFingerprint === sourceFingerprint &&
277
+ recordKey(sourceFingerprint, key === sourceFingerprint ? undefined : attestation.taskId) === key &&
278
+ await hasMatchingReportArtifact(root, attestation)) {
279
+ return attestation;
280
+ }
281
+ }
282
+ catch {
283
+ continue;
284
+ }
285
+ }
286
+ return null;
244
287
  }
245
288
  /**
246
289
  * The stored report behind an attestation, for a caller that wants to reuse
@@ -248,35 +291,64 @@ export async function findReviewAttestation(root, sourceFingerprint) {
248
291
  * parses, matches this exact source and records a PASS: a reuse path must fail
249
292
  * closed exactly like the gates that read the attestation.
250
293
  */
251
- export async function readReviewReportArtifact(root, sourceFingerprint) {
252
- if (!FINGERPRINT_PATTERN.test(sourceFingerprint)) {
294
+ export async function readReviewReportArtifact(root, sourceFingerprint, taskId) {
295
+ if (!FINGERPRINT_PATTERN.test(sourceFingerprint) ||
296
+ (taskId !== undefined && !TASK_ID_PATTERN.test(taskId))) {
253
297
  return null;
254
298
  }
255
- const source = await readPrivateFile(artifactPath(root, sourceFingerprint), root);
299
+ const keys = taskId === undefined
300
+ ? [sourceFingerprint]
301
+ : [recordKey(sourceFingerprint, taskId), sourceFingerprint];
302
+ for (const key of keys) {
303
+ const source = await readPrivateFile(join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"), `${key}${REVIEW_REPORT_ARTIFACT_SUFFIX}`), root);
304
+ if (source === null) {
305
+ continue;
306
+ }
307
+ try {
308
+ const value = JSON.parse(source);
309
+ if (value.sourceFingerprint === sourceFingerprint &&
310
+ value.status === "PASS" &&
311
+ value.report !== undefined &&
312
+ value.adversarial !== undefined) {
313
+ return value;
314
+ }
315
+ }
316
+ catch {
317
+ continue;
318
+ }
319
+ }
320
+ return null;
321
+ }
322
+ /**
323
+ * A new authorized attempt supersedes any earlier PASS for this source: the
324
+ * task's own record, and the bare one when it is this task's (or no task's).
325
+ * Another task's bare record from before per-task records is left alone.
326
+ */
327
+ export async function invalidateReviewAttestation(root, sourceFingerprint, taskId) {
328
+ if (!FINGERPRINT_PATTERN.test(sourceFingerprint) ||
329
+ (taskId !== undefined && !TASK_ID_PATTERN.test(taskId))) {
330
+ throw new AgentOpsError("REVIEW_ATTESTATION_INVALID", "Invalid source fingerprint.");
331
+ }
332
+ if (taskId !== undefined) {
333
+ const own = attestationPath(root, sourceFingerprint, taskId);
334
+ if (await readPrivateFile(own, root) !== null) {
335
+ await writePrivateFile(own, "null\n", root);
336
+ }
337
+ }
338
+ const bare = attestationPath(root, sourceFingerprint);
339
+ const source = await readPrivateFile(bare, root);
256
340
  if (source === null) {
257
- return null;
341
+ return;
258
342
  }
343
+ let owner;
259
344
  try {
260
- const value = JSON.parse(source);
261
- return value.sourceFingerprint === sourceFingerprint &&
262
- value.status === "PASS" &&
263
- value.report !== undefined &&
264
- value.adversarial !== undefined
265
- ? value
266
- : null;
345
+ owner = JSON.parse(source)?.taskId;
267
346
  }
268
347
  catch {
269
- return null;
270
- }
271
- }
272
- /** A new authorized attempt supersedes any earlier PASS for this source. */
273
- export async function invalidateReviewAttestation(root, sourceFingerprint) {
274
- if (!FINGERPRINT_PATTERN.test(sourceFingerprint)) {
275
- throw new AgentOpsError("REVIEW_ATTESTATION_INVALID", "Invalid source fingerprint.");
348
+ owner = undefined;
276
349
  }
277
- const path = attestationPath(root, sourceFingerprint);
278
- if (await readPrivateFile(path, root) !== null) {
279
- await writePrivateFile(path, "null\n", root);
350
+ if (taskId === undefined || owner === undefined || owner === taskId) {
351
+ await writePrivateFile(bare, "null\n", root);
280
352
  }
281
353
  }
282
354
  /** Prior findings stay a short checklist, never a second report to digest. */
@@ -328,12 +400,15 @@ export async function findPriorFailingFindings(root, taskId) {
328
400
  // ponytail: linear scan of every report; index by task if the directory grows large.
329
401
  let latest;
330
402
  for (const name of names) {
331
- const fingerprint = name.slice(0, -REVIEW_REPORT_ARTIFACT_SUFFIX.length);
332
- if (!name.endsWith(REVIEW_REPORT_ARTIFACT_SUFFIX) || !FINGERPRINT_PATTERN.test(fingerprint)) {
403
+ // `<fingerprint>` or `<fingerprint>.<task id>`, then the suffix.
404
+ const key = name.slice(0, -REVIEW_REPORT_ARTIFACT_SUFFIX.length);
405
+ const fingerprint = key.slice(0, 64);
406
+ if (!name.endsWith(REVIEW_REPORT_ARTIFACT_SUFFIX) || !FINGERPRINT_PATTERN.test(fingerprint) ||
407
+ (key !== fingerprint && key !== recordKey(fingerprint, taskId))) {
333
408
  continue;
334
409
  }
335
410
  try {
336
- const source = await readPrivateFile(artifactPath(root, fingerprint), root);
411
+ const source = await readPrivateFile(join(directory, name), root);
337
412
  if (source === null) {
338
413
  continue;
339
414
  }