@kylecheng3146/agent-ops 0.3.0 → 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.
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { execFile, execFileSync, spawn } from "node:child_process";
6
+ import { execFileSync, spawn } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { commonHarnessAdapters, harnessHookPath, HARNESS_IDS } from "../../../runtime/src/install/harness.js";
9
9
  import { agyRuntimeStatus, agyVersionSupported, hookRegistrationDrift, repositoryTrustStatus, smokeAvailabilityStatus } from "../../../runtime/src/install/probes.js";
@@ -12,8 +12,6 @@ import { readRecordedSessionId } from "../../../runtime/src/hooks/codex-loop.js"
12
12
  import { NpmRegistryClient } from "../../../runtime/src/registry/npm.js";
13
13
  import { TaskService } from "../../../runtime/src/task/service.js";
14
14
  import { FileTaskStore } from "../../../runtime/src/task/store.js";
15
- import { FileTrustStore } from "../../../runtime/src/security/trust.js";
16
- import { localStatePaths } from "../../../runtime/src/security/permissions.js";
17
15
  import { calculateConfigHash } from "../../../runtime/src/config/hash.js";
18
16
  import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
19
17
  import { calculateSourceFingerprint } from "../../../runtime/src/verify/source-fingerprint.js";
@@ -22,6 +20,7 @@ import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn
22
20
  import { runCli } from "./cli.js";
23
21
  import { loadEffectiveConfig, repositoryTrust, repositoryTrustBinding } from "./context.js";
24
22
  import { runHookProcess } from "./hook-process.js";
23
+ import { gitRunner, trustStore, worktreeDependencies } from "./parallel-deps.js";
25
24
  import { selectYesNo, writeBanner } from "./ui.js";
26
25
  import { CLI_VERSION } from "./version.js";
27
26
  import { createCommandRegistry } from "./commands/index.js";
@@ -43,6 +42,7 @@ import { formatUpdatePlan, runUpdateCommand } from "./commands/update.js";
43
42
  import { errorEnvelope } from "./output.js";
44
43
  import { runAgyHeadless } from "./agy-headless.js";
45
44
  import { runWorktreeCommand } from "./commands/parallel.js";
45
+ import { ensureSessionWorktree, resolveCheckouts } from "../../../runtime/src/parallel/service.js";
46
46
  import { listWorktrees, worktreeDoctorResult } from "../../../runtime/src/parallel/manage.js";
47
47
  const HOOK_RUNTIME_PATH = fileURLToPath(new URL("./hook-entry.js", import.meta.url));
48
48
  async function readOptionalText(path) {
@@ -78,29 +78,6 @@ async function installedManifest(root) {
78
78
  async function installedHarness(root) {
79
79
  return (await installedManifest(root))?.harness ?? [...HARNESS_IDS];
80
80
  }
81
- function gitRunner(root) {
82
- return {
83
- run: async (gitArgs) => {
84
- try {
85
- return {
86
- exitCode: 0,
87
- stdout: execFileSync("git", [...gitArgs], {
88
- cwd: root,
89
- encoding: "buffer",
90
- stdio: ["ignore", "pipe", "ignore"]
91
- })
92
- };
93
- }
94
- catch (error) {
95
- const failure = error;
96
- return {
97
- exitCode: failure.status ?? 1,
98
- stdout: failure.stdout ?? new Uint8Array()
99
- };
100
- }
101
- }
102
- };
103
- }
104
81
  async function confirmInit(plan, trust, warnings = []) {
105
82
  writeBanner({
106
83
  isTTY: process.stdout.isTTY === true,
@@ -113,59 +90,12 @@ async function confirmPlan(text) {
113
90
  process.stdout.write(text);
114
91
  return await selectYesNo("Apply this installation plan?", { input: process.stdin, output: process.stdout }, false);
115
92
  }
116
- function trustStore() {
117
- const state = localStatePaths(process.env.AGENT_OPS_HOME ?? homedir());
118
- return new FileTrustStore(state.trustStore, state.anchorDirectory);
119
- }
120
93
  async function plannedTrustBinding(root, projectConfig) {
121
94
  const config = (await loadEffectiveConfig(root, "project", projectConfig)).config;
122
95
  return config.verification.commands.length === 0
123
96
  ? null
124
97
  : await repositoryTrustBinding(root, config, CLI_VERSION);
125
98
  }
126
- function worktreeDependencies() {
127
- const gateFor = async (root, config) => new CompletionGateService({
128
- root,
129
- config,
130
- gitRunner: gitRunner(root),
131
- taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
132
- evidenceStore: new FileEvidenceStore(root, root)
133
- });
134
- return {
135
- git: async (cwd, gitArgs) => await new Promise((resolve) => {
136
- execFile("git", [...gitArgs], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => resolve({
137
- exitCode: error === null ? 0 : typeof error.code === "number" ? error.code : 1,
138
- stdout,
139
- stderr
140
- }));
141
- }),
142
- loadConfig: async (root) => (await loadEffectiveConfig(root, "project")).config,
143
- trust: {
144
- status: async (root, config) => await repositoryTrust(root, config, CLI_VERSION),
145
- grant: async (root, config) => await trustStore().grant(await repositoryTrustBinding(root, config, CLI_VERSION)),
146
- revoke: async (root, config) => {
147
- await trustStore().revoke(await repositoryTrustBinding(root, config, CLI_VERSION));
148
- }
149
- },
150
- gate: gateFor,
151
- tasks: (root) => new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
152
- processRunner: new NodeVerificationProcessRunner(),
153
- runSetup: async (cwd, step) => await new Promise((resolve) => {
154
- execFile(step.command, [...step.args], {
155
- cwd,
156
- encoding: "utf8",
157
- timeout: step.timeoutMs,
158
- maxBuffer: 16 * 1024 * 1024,
159
- ...(process.platform === "win32" ? { shell: true } : {})
160
- }, (error, stdout, stderr) => resolve({
161
- exitCode: error === null
162
- ? 0
163
- : typeof error.code === "number" && error.killed !== true ? error.code : null,
164
- output: `${stdout}${stderr}`
165
- }));
166
- })
167
- };
168
- }
169
99
  async function worktreeDoctorProbe(root) {
170
100
  let statuses;
171
101
  try {
@@ -222,7 +152,7 @@ else if (argv[0] === "hook") {
222
152
  stdin: process.stdin,
223
153
  writeStdout: (value) => process.stdout.write(value),
224
154
  writeStderr: (value) => process.stderr.write(value)
225
- }, CLI_VERSION);
155
+ }, CLI_VERSION, { worktree: worktreeDependencies() });
226
156
  }
227
157
  else {
228
158
  process.exitCode = await runCli(argv, {
@@ -448,14 +378,40 @@ else {
448
378
  // run inside a session is never told which session it is in.
449
379
  const sessionId = process.env.AGENT_OPS_SESSION_ID ??
450
380
  await readRecordedSessionId(root);
451
- const policyConfigHash = args.action === "create"
452
- ? calculateConfigHash((await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config)
381
+ const createConfig = args.action === "create"
382
+ ? (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config
453
383
  : undefined;
384
+ const policyConfigHash = createConfig === undefined
385
+ ? undefined
386
+ : calculateConfigHash(createConfig);
387
+ // Auto mode starts the work where it belongs: a task created from
388
+ // the main checkout lands in the session's own worktree.
389
+ const worktreeDeps = worktreeDependencies();
390
+ const fromMain = createConfig?.worktree?.mode === "auto" &&
391
+ args.scope !== "user" &&
392
+ await resolveCheckouts(worktreeDeps, root)
393
+ .then(({ mainRoot, currentRoot }) => mainRoot === currentRoot)
394
+ .catch(() => false);
454
395
  return await runTaskCommand({
455
396
  args,
456
397
  service: taskService,
457
398
  ...(policyConfigHash === undefined ? {} : { policyConfigHash }),
458
- ...(sessionId === undefined ? {} : { sessionId })
399
+ ...(sessionId === undefined ? {} : { sessionId }),
400
+ ...(fromMain
401
+ ? {
402
+ sessionWorktree: async (session) => {
403
+ const record = await ensureSessionWorktree(worktreeDeps, {
404
+ cwd: root,
405
+ sessionId: session
406
+ });
407
+ return {
408
+ path: record.path,
409
+ base: record.base,
410
+ service: new TaskService(new FileTaskStore(join(record.path, ".agent-ops", "tasks", "state.json"), record.path))
411
+ };
412
+ }
413
+ }
414
+ : {})
459
415
  });
460
416
  }
461
417
  if (args.command === "review") {
@@ -28,16 +28,17 @@ function addText(result) {
28
28
  "Work only inside that path from now on:",
29
29
  `- Claude Code: EnterWorktree with path ${record.path}`,
30
30
  `- Codex and agy: run every command with ${record.path} as its working directory`,
31
- `Finish with: commit, agent-ops verify/review/task complete with --base ${record.base},`,
32
- `then agent-ops worktree finish ${record.name} from ${record.mainRoot}`,
33
- "(Claude Code: ExitWorktree with action keep before finishing)."
31
+ `Finish with: commit, agent-ops verify and review with --base ${record.base},`,
32
+ `then agent-ops worktree finish ${record.name} from ${record.mainRoot}, which`,
33
+ "completes every task in the worktree and merges",
34
+ "(Claude Code: ExitWorktree with action keep before finishing; do not run task complete in the worktree)."
34
35
  ].join("\n");
35
36
  }
36
37
  function finishText(result) {
37
38
  const { record } = result;
38
39
  return [
39
40
  `Merged ${record.branch} into ${record.targetBranch} at ${result.mergedHead}${result.rebased ? " after a clean rebase and re-verification" : ""}.`,
40
- `Task ${result.taskId} is recorded in git notes (refs/notes/agent-ops).`,
41
+ `Completed and recorded in git notes (refs/notes/agent-ops): ${result.taskIds.join(", ")}.`,
41
42
  `Removed ${record.path}.`,
42
43
  ...result.warnings.map((warning) => `Warning: ${warning}`)
43
44
  ].join("\n");
@@ -273,7 +273,7 @@ async function reusableReview(options, sourceFingerprint, context) {
273
273
  if (options.root === undefined) {
274
274
  return null;
275
275
  }
276
- const attestation = await findReviewAttestation(options.root, sourceFingerprint);
276
+ const attestation = await findReviewAttestation(options.root, sourceFingerprint, context?.taskId);
277
277
  if (attestation === null || attestation.taskId !== context?.taskId) {
278
278
  return null;
279
279
  }
@@ -284,7 +284,7 @@ async function reusableReview(options, sourceFingerprint, context) {
284
284
  context?.policyConfigHash !== options.policyConfigHash) {
285
285
  return null;
286
286
  }
287
- const artifact = await readReviewReportArtifact(options.root, sourceFingerprint);
287
+ const artifact = await readReviewReportArtifact(options.root, sourceFingerprint, attestation.taskId);
288
288
  if (artifact === null ||
289
289
  artifact.taskId !== attestation.taskId ||
290
290
  artifact.report === undefined ||
@@ -488,7 +488,7 @@ export async function runReviewCommand(options) {
488
488
  })
489
489
  });
490
490
  }
491
- await invalidateReviewAttestation(options.root, sourceFingerprint);
491
+ await invalidateReviewAttestation(options.root, sourceFingerprint, context?.taskId);
492
492
  pendingInvalidation = false;
493
493
  }
494
494
  }
@@ -605,7 +605,9 @@ export async function runReviewCommand(options) {
605
605
  await options.tasks.recordEvidence(context.taskId, Object.fromEntries(finalResult.results.map((item) => [
606
606
  item.criterionId,
607
607
  item.evidence.map((reference) => `review:${finalResult.harness}:${reference}`)
608
- ])));
608
+ ])),
609
+ // What `worktree finish` completes this task against.
610
+ scope?.mode === "base" ? scope.resolvedBase : null);
609
611
  }
610
612
  const message = finalResult.status === "PASS"
611
613
  ? "Independent review passed."
@@ -87,7 +87,10 @@ export async function runTaskCommand(options) {
87
87
  if (options.args.title === undefined) {
88
88
  throw new AgentOpsError("TASK_TITLE_REQUIRED", "Task creation requires --title.");
89
89
  }
90
- const record = await options.service.create({
90
+ const worktree = sessionId === undefined || options.sessionWorktree === undefined
91
+ ? undefined
92
+ : await options.sessionWorktree(sessionId);
93
+ const record = await (worktree?.service ?? options.service).create({
91
94
  title: options.args.title,
92
95
  criteria: (options.args.criteria ?? []).map(parseCriterion),
93
96
  ...(options.policyConfigHash === undefined
@@ -98,9 +101,30 @@ export async function runTaskCommand(options) {
98
101
  : { parentTaskId: options.args.parentTaskId }),
99
102
  ...(sessionId === undefined ? {} : { sessionId })
100
103
  });
101
- return taskEnvelope(action, "TASK_CREATED", sessionId === undefined
104
+ const created = taskEnvelope(action, "TASK_CREATED", sessionId === undefined
102
105
  ? `Created task ${record.task.id}.`
103
- : `Created and attached task ${record.task.id}.`, record);
106
+ : record.task.parentTaskId === undefined
107
+ ? `Created and attached task ${record.task.id}.`
108
+ : `Created subtask ${record.task.id}; the session stays on the top of its task tree.`, record);
109
+ if (worktree === undefined || created.data === null)
110
+ return created;
111
+ return {
112
+ ...created,
113
+ data: {
114
+ ...created.data,
115
+ text: [
116
+ `Created in this session's worktree ${worktree.path}.`,
117
+ "Work only inside that path from now on:",
118
+ `- Claude Code: EnterWorktree with path ${worktree.path}`,
119
+ `- Codex and agy: run every command with ${worktree.path} as its working directory`,
120
+ `Commit, then run verify and review there with --base ${worktree.base};`,
121
+ "then leave the worktree (Claude Code: ExitWorktree with action keep) and run",
122
+ "agent-ops worktree finish from the main checkout, which completes the task and merges.",
123
+ "",
124
+ created.data.text
125
+ ].join("\n")
126
+ }
127
+ };
104
128
  }
105
129
  if (action === "status") {
106
130
  if (options.args.taskId === undefined && sessionId === undefined) {
@@ -3,8 +3,9 @@
3
3
  // node <this file> <harness> <event> --managed-by=agent-ops
4
4
  import { CLI_VERSION } from "./version.js";
5
5
  import { runHookProcess } from "./hook-process.js";
6
+ import { worktreeDependencies } from "./parallel-deps.js";
6
7
  process.exitCode = await runHookProcess(process.argv.slice(2), {
7
8
  stdin: process.stdin,
8
9
  writeStdout: (value) => process.stdout.write(value),
9
10
  writeStderr: (value) => process.stderr.write(value)
10
- }, CLI_VERSION);
11
+ }, CLI_VERSION, { worktree: worktreeDependencies() });
@@ -10,6 +10,7 @@ import { redactSecrets } from "../../../runtime/src/security/redact.js";
10
10
  import { claudeStopRecursionMarker } from "../../../runtime/src/adapters/claude/input.js";
11
11
  import { HARNESS_IDS, harnessDescriptor } from "../../../runtime/src/install/harness.js";
12
12
  import { evaluateWorktreeWrite, resolveMainRoot } from "../../../runtime/src/parallel/guard.js";
13
+ import { ensureSessionWorktree } from "../../../runtime/src/parallel/service.js";
13
14
  import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
14
15
  import { TaskService } from "../../../runtime/src/task/service.js";
15
16
  import { FileTaskStore } from "../../../runtime/src/task/store.js";
@@ -348,7 +349,9 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
348
349
  const mainRoot = await resolveMainRoot(gitRunner);
349
350
  return mainRoot === null
350
351
  ? { action: "continue", status: "UNKNOWN", code: "WORKTREE_GUARD_UNAVAILABLE" }
351
- : await evaluateWorktreeWrite(mainRoot, write.paths, write.sessionId);
352
+ : await evaluateWorktreeWrite(mainRoot, write.paths, write.sessionId, dependencies.worktree === undefined
353
+ ? undefined
354
+ : async (sessionId) => (await ensureSessionWorktree(dependencies.worktree, { cwd: mainRoot, sessionId })).path);
352
355
  }
353
356
  : undefined;
354
357
  const output = await runHookCommand({
@@ -0,0 +1,89 @@
1
+ import { execFile, execFileSync } from "node:child_process";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
5
+ import { localStatePaths } from "../../../runtime/src/security/permissions.js";
6
+ import { FileTrustStore } from "../../../runtime/src/security/trust.js";
7
+ import { TaskService } from "../../../runtime/src/task/service.js";
8
+ import { FileTaskStore } from "../../../runtime/src/task/store.js";
9
+ import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
10
+ import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
11
+ import { loadEffectiveConfig, repositoryTrust, repositoryTrustBinding } from "./context.js";
12
+ import { CLI_VERSION } from "./version.js";
13
+ // Shared by the CLI and the hook entry point: Claude Code runs hooks through
14
+ // hook-entry.js, never bin.js, and its worktree guard needs these too.
15
+ export function gitRunner(root) {
16
+ return {
17
+ run: async (gitArgs) => {
18
+ try {
19
+ return {
20
+ exitCode: 0,
21
+ stdout: execFileSync("git", [...gitArgs], {
22
+ cwd: root,
23
+ encoding: "buffer",
24
+ stdio: ["ignore", "pipe", "ignore"]
25
+ })
26
+ };
27
+ }
28
+ catch (error) {
29
+ const failure = error;
30
+ return {
31
+ exitCode: failure.status ?? 1,
32
+ stdout: failure.stdout ?? new Uint8Array()
33
+ };
34
+ }
35
+ }
36
+ };
37
+ }
38
+ export function trustStore() {
39
+ const state = localStatePaths(process.env.AGENT_OPS_HOME ?? homedir());
40
+ return new FileTrustStore(state.trustStore, state.anchorDirectory);
41
+ }
42
+ export function worktreeDependencies() {
43
+ const gateFor = async (root, config) => new CompletionGateService({
44
+ root,
45
+ config,
46
+ gitRunner: gitRunner(root),
47
+ taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
48
+ evidenceStore: new FileEvidenceStore(root, root)
49
+ });
50
+ return {
51
+ git: async (cwd, gitArgs) => await new Promise((resolve) => {
52
+ execFile("git", [...gitArgs], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => resolve({
53
+ exitCode: error === null ? 0 : typeof error.code === "number" ? error.code : 1,
54
+ stdout,
55
+ stderr
56
+ }));
57
+ }),
58
+ loadConfig: async (root) => (await loadEffectiveConfig(root, "project")).config,
59
+ trust: {
60
+ status: async (root, config) => await repositoryTrust(root, config, CLI_VERSION),
61
+ grant: async (root, config) => await trustStore().grant(await repositoryTrustBinding(root, config, CLI_VERSION)),
62
+ revoke: async (root, config) => {
63
+ await trustStore().revoke(await repositoryTrustBinding(root, config, CLI_VERSION));
64
+ }
65
+ },
66
+ gate: gateFor,
67
+ tasks: (root, base) => new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root), base === undefined ? {} : { completion: {
68
+ root,
69
+ gitRunner: gitRunner(root),
70
+ base,
71
+ loadConfig: async () => (await loadEffectiveConfig(root, "project")).config
72
+ } }),
73
+ processRunner: new NodeVerificationProcessRunner(),
74
+ runSetup: async (cwd, step) => await new Promise((resolve) => {
75
+ execFile(step.command, [...step.args], {
76
+ cwd,
77
+ encoding: "utf8",
78
+ timeout: step.timeoutMs,
79
+ maxBuffer: 16 * 1024 * 1024,
80
+ ...(process.platform === "win32" ? { shell: true } : {})
81
+ }, (error, stdout, stderr) => resolve({
82
+ exitCode: error === null
83
+ ? 0
84
+ : typeof error.code === "number" && error.killed !== true ? error.code : null,
85
+ output: `${stdout}${stderr}`
86
+ }));
87
+ })
88
+ };
89
+ }
@@ -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.", "");
@@ -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
  }
@@ -28,6 +28,17 @@ export const DEFAULT_REVIEW_TIMEOUT_MS = 900_000;
28
28
  * thinking hard is never cut off.
29
29
  */
30
30
  export const DEFAULT_STALL_IDLE_MS = 90_000;
31
+ /**
32
+ * agy writes nothing to stdout, stderr or its log while one generation
33
+ * streams, so a long think is indistinguishable from a wedge. Its own logs
34
+ * show normal generations silent for up to ~177s; 90s cut real reviews off.
35
+ */
36
+ export const AGY_STALL_IDLE_MS = 240_000;
37
+ /**
38
+ * A call the service dropped before answering. It produced no verdict, so
39
+ * one fresh retry is not review shopping.
40
+ */
41
+ const TRANSIENT_NETWORK = /\b(?:network issue|503|service is currently unavailable|ECONNRESET|ETIMEDOUT|socket hang up)\b/iu;
31
42
  /** Two rounds at the per-target default: the previous worst case, now bounded. */
32
43
  export const DEFAULT_REVIEW_CHAIN_TIMEOUT_MS = 2 * DEFAULT_REVIEW_TIMEOUT_MS;
33
44
  /**
@@ -425,7 +436,8 @@ async function attemptTarget(request, options) {
425
436
  }
426
437
  throwIfInterrupted(target, options);
427
438
  options.onProgress?.(`${target}: review started (timeout: ${Math.ceil((options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS) / 1_000)}s)`);
428
- const stallIdleMs = options.stallIdleMs ?? DEFAULT_STALL_IDLE_MS;
439
+ const stallIdleMs = options.stallIdleMs ??
440
+ (target === "agy" ? AGY_STALL_IDLE_MS : DEFAULT_STALL_IDLE_MS);
429
441
  const stallWatch = watchForStall(heartbeatLog, options.signal, stallIdleMs);
430
442
  let spawned;
431
443
  try {
@@ -481,8 +493,11 @@ async function attemptTarget(request, options) {
481
493
  }
482
494
  if (spawned.failureClass === "nonzero-exit") {
483
495
  const output = `${spawned.stderr}\n${spawned.stdout}`;
484
- return skip(rejectedCallReason(output), firstComplaint(spawned.stderr, spawned.stdout) ??
485
- `the call was rejected with exit ${spawned.exitCode ?? "unknown"} and no output`);
496
+ return {
497
+ ...skip(rejectedCallReason(output), firstComplaint(spawned.stderr, spawned.stdout) ??
498
+ `the call was rejected with exit ${spawned.exitCode ?? "unknown"} and no output`),
499
+ ...(TRANSIENT_NETWORK.test(output) ? { transient: true } : {})
500
+ };
486
501
  }
487
502
  if (spawned.failureClass === "signal-exit") {
488
503
  return skip("capability-unavailable", `the reviewer exited after ${spawned.signal ?? "an unknown signal"}`);
@@ -522,6 +537,18 @@ async function attemptTarget(request, options) {
522
537
  await rm(attemptDirectory, { recursive: true, force: true });
523
538
  }
524
539
  }
540
+ /**
541
+ * `attemptTarget`, retried once in a fresh session when the service dropped
542
+ * the call. Only once: a service that is down stays down.
543
+ */
544
+ async function attemptTargetWithRetry(request, options) {
545
+ const first = await attemptTarget(request, options);
546
+ if (first.kind !== "skip" || first.transient !== true || budgetSpent(options)) {
547
+ return first;
548
+ }
549
+ options.onProgress?.(`${request.target}: ${first.diagnostic} → retrying once`);
550
+ return attemptTarget(request, options);
551
+ }
525
552
  /** Builds the `execute` callback with one necessary and one adversarial review. */
526
553
  export function createReviewExecutor(options) {
527
554
  const report = options.onProgress ?? (() => { });
@@ -630,7 +657,7 @@ export function createReviewExecutor(options) {
630
657
  report("chain: review deadline reached before the first round");
631
658
  return { status: "NOT_RUN", reason: "timeout", attempts: [] };
632
659
  }
633
- const primaryOutcome = await attemptTarget({
660
+ const primaryOutcome = await attemptTargetWithRetry({
634
661
  ...shared,
635
662
  target: primaryTarget,
636
663
  label: `${primaryTarget}-primary`,
@@ -739,7 +766,7 @@ export function createReviewExecutor(options) {
739
766
  };
740
767
  }
741
768
  // The second round draws on what is left, never a fresh full budget.
742
- const adversarialOutcome = await attemptTarget({
769
+ const adversarialOutcome = await attemptTargetWithRetry({
743
770
  ...shared,
744
771
  target: adversarialTarget,
745
772
  label: `${adversarialTarget}-adversarial`,
@@ -38,7 +38,7 @@ export async function checkTaskCompletionEvidence(stored, options) {
38
38
  }
39
39
  }
40
40
  }
41
- const attestation = await findReviewAttestation(root, sourceFingerprint);
41
+ const attestation = await findReviewAttestation(root, sourceFingerprint, stored.task.id);
42
42
  if (attestation === null || attestation.taskId !== stored.task.id) {
43
43
  return { status: "FAIL", code: "REVIEW_REQUIRED", remedy: `Run agent-ops review --task ${stored.task.id} --yes for the whole task and current source.` };
44
44
  }
@@ -130,9 +130,17 @@ export class TaskService {
130
130
  state.tasks.push(record);
131
131
  if (input.sessionId !== undefined) {
132
132
  const currentIndex = state.sessions.findIndex(({ sessionId }) => sessionId === input.sessionId);
133
+ // A subtask keeps the session on the top of its tree: the completion
134
+ // gate checks the attached task and everything under it, so attaching
135
+ // the subtask would let a Stop through with its parent unfinished.
136
+ let rootId = record.task.id;
137
+ for (let parentId = record.task.parentTaskId; parentId !== undefined;) {
138
+ rootId = parentId;
139
+ parentId = state.tasks.find(({ task }) => task.id === parentId)?.task.parentTaskId;
140
+ }
133
141
  const attachment = {
134
142
  sessionId: input.sessionId,
135
- taskId: record.task.id,
143
+ taskId: rootId,
136
144
  attachedAt: now
137
145
  };
138
146
  if (currentIndex === -1) {
@@ -304,7 +312,12 @@ export class TaskService {
304
312
  * task accepts evidence: a completed record must stay exactly as it was
305
313
  * verified.
306
314
  */
307
- async recordEvidence(taskId, evidenceInput) {
315
+ async recordEvidence(taskId, evidenceInput,
316
+ /**
317
+ * A PASS review's base: set records it, null clears it (the review covered
318
+ * uncommitted work), undefined leaves the record's value alone.
319
+ */
320
+ reviewBase) {
308
321
  const now = assertTimestamp(this.#now());
309
322
  return await this.#store.mutate((state) => {
310
323
  const current = findTask(state, taskId);
@@ -328,10 +341,13 @@ export class TaskService {
328
341
  ...new Set([...(evidence[criterionId] ?? []), ...references])
329
342
  ];
330
343
  }
344
+ const { reviewBase: previousBase, ...rest } = current;
345
+ const base = reviewBase === undefined ? previousBase : reviewBase ?? undefined;
331
346
  const updated = {
332
- ...current,
347
+ ...rest,
333
348
  evidence,
334
- updatedAt: now
349
+ updatedAt: now,
350
+ ...(base === undefined ? {} : { reviewBase: base })
335
351
  };
336
352
  replaceTask(state, updated);
337
353
  return cloneRecord(updated);
@@ -66,7 +66,7 @@ function parseTaskRecord(value) {
66
66
  ];
67
67
  // Every record written before an optional field existed must still parse, so
68
68
  // the accepted shapes are every combination of them.
69
- const optionalKeys = ["completionBase", "failureFingerprint", "policyConfigHash"];
69
+ const optionalKeys = ["completionBase", "failureFingerprint", "policyConfigHash", "reviewBase"];
70
70
  const allowedKeys = new Set(Array.from({ length: 1 << optionalKeys.length }, (_unused, mask) => [
71
71
  ...baseKeys,
72
72
  ...optionalKeys.filter((_key, position) => (mask & (1 << position)) !== 0)
@@ -146,6 +146,10 @@ function parseTaskRecord(value) {
146
146
  (typeof completionBase !== "string" || !/^[a-f0-9]{40,64}$/u.test(completionBase))) {
147
147
  return invalidState("Task state contains an invalid completion base.");
148
148
  }
149
+ if (value.reviewBase !== undefined &&
150
+ (typeof value.reviewBase !== "string" || !/^[a-f0-9]{40,64}$/u.test(value.reviewBase))) {
151
+ return invalidState("Task state contains an invalid review base.");
152
+ }
149
153
  return {
150
154
  task: task.value,
151
155
  status,
@@ -156,7 +160,8 @@ function parseTaskRecord(value) {
156
160
  archivedAt: value.archivedAt,
157
161
  failureFingerprint,
158
162
  policyConfigHash,
159
- completionBase
163
+ completionBase,
164
+ ...(value.reviewBase === undefined ? {} : { reviewBase: value.reviewBase })
160
165
  };
161
166
  }
162
167
  function parseSession(value) {
@@ -152,6 +152,17 @@ evidence. Give each editing conversation its own worktree instead:
152
152
  On Claude Code a direct Edit or Write of the main checkout outside
153
153
  `.worktrees/` is denied (run `agent-ops update` once so the PreToolUse hook
154
154
  matches the file tools). Absent or `"off"` keeps today's single checkout.
155
+ - `agent-ops task create` run from the main checkout with a known session
156
+ creates that session's worktree first (or reuses it) and records the task
157
+ there, so the work starts in the worktree. Its output names the path to
158
+ enter and the `--base` to verify against.
159
+ - As a fallback on Claude Code, the first denied edit creates that worktree:
160
+ `.worktrees/session-<first eight characters of the session id>`, setup
161
+ included, bound to the session. The denial names the path to enter with
162
+ EnterWorktree; a later denial for the same session reuses it. If creation
163
+ fails, the denial says why and falls back to the manual `worktree add`.
164
+ Run `agent-ops update` once so the PreToolUse hook gets the 600-second
165
+ timeout setup needs.
155
166
  - `add` creates `.worktrees/<name>` on branch `agent-ops/<name>` from the main
156
167
  checkout's HEAD and excludes `/.worktrees/` through `.git/info/exclude`,
157
168
  never `.gitignore`. It copies the ignored files agent-ops installed and any
@@ -166,10 +177,20 @@ evidence. Give each editing conversation its own worktree instead:
166
177
  - The session's completion gate follows it: Claude Code enters the worktree
167
178
  with EnterWorktree, and a host that cannot move a session (agy) is judged by
168
179
  the worktree's gate through a redirect the main checkout records.
180
+ - In a worktree, verify and review each task but do not run `task complete`
181
+ (Claude Code refuses it inside an isolated worktree session). `agent-ops
182
+ worktree finish <name>` completes every non-archived task in the worktree,
183
+ subtasks before their parents, each against the base its PASS review
184
+ recorded (the worktree's base when none was recorded), before any rebase.
185
+ If one cannot complete, nothing merges and the error names the task.
186
+ Creating a subtask keeps the session attached to the top of its tree, and
187
+ each task keeps its own review record, so a parent and a subtask reviewed
188
+ on the same source do not overwrite each other.
169
189
  - `agent-ops worktree finish <name>` merges by fast-forward only, one finish
170
190
  at a time. If the target moved, it rebases; a clean rebase whose own patch
171
191
  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.
192
+ the `refs/notes/agent-ops` intent of the work that landed first. The note
193
+ records every task of the worktree, subtasks indented under their parent.
173
194
  - `worktree list`, `resume <name> --session <id>` and `remove <name>` manage
174
195
  what is left; `remove --force` discards work and asks the user first.
175
196
  `doctor` reports worktrees idle for more than seven days.
@@ -136,6 +136,14 @@ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方
136
136
  Write 修改主 checkout 中 `.worktrees/` 以外的檔案會被拒絕(需先執行一次
137
137
  `agent-ops update`,讓 PreToolUse hook 比對檔案工具)。未設定或 `"off"` 則維持
138
138
  目前的單一 checkout。
139
+ - 在主 checkout、已知 session 的情況下執行 `agent-ops task create`,會先建立(或重用)
140
+ 該 session 的 worktree,並把 task 記錄在那裡,讓工作一開始就在 worktree 內。輸出會列出
141
+ 要進入的路徑與 verify 用的 `--base`。
142
+ - 作為 Claude Code 上的後備,第一次被拒絕的編輯會自動建立該 worktree:
143
+ `.worktrees/session-<session id 前八個字元>`,包含 setup,並綁定該 session。
144
+ 拒絕訊息會列出要以 EnterWorktree 進入的路徑;同一 session 之後被拒絕時會重用它。
145
+ 建立失敗時,拒絕訊息會說明原因並退回手動 `worktree add`。需先執行一次
146
+ `agent-ops update`,讓 PreToolUse hook 取得 setup 所需的 600 秒逾時。
139
147
  - `add` 從主 checkout 的 HEAD 建立 `.worktrees/<name>` 與 branch
140
148
  `agent-ops/<name>`,並透過 `.git/info/exclude`(而非 `.gitignore`)排除
141
149
  `/.worktrees/`。它會複製 agent-ops 安裝的 ignored 檔案,以及根目錄
@@ -148,10 +156,17 @@ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方
148
156
  - Session 的 completion gate 會跟著它:Claude Code 以 EnterWorktree 進入
149
157
  worktree;無法移動 session 的 host(agy)則透過主 checkout 記錄的 redirect,
150
158
  由 worktree 的 gate 判定。
159
+ - 在 worktree 裡對每個 task 執行 verify 與 review,但不要執行 `task complete`
160
+ (Claude Code 在隔離的 worktree session 中會拒絕它)。`agent-ops worktree finish
161
+ <name>` 會在任何 rebase 之前 complete worktree 內每個未封存的 task,先 subtask
162
+ 後 parent,各自使用其 PASS review 記錄的 base(沒有記錄時使用 worktree 的 base)。
163
+ 任一 task 無法完成時不會合併,錯誤訊息會指出是哪個 task。建立 subtask 時 session
164
+ 維持附著在 task 樹最上層,且每個 task 各自保留 review 紀錄,所以在同一份原始碼上
165
+ review 的 parent 與 subtask 不會互相覆蓋。
151
166
  - `agent-ops worktree finish <name>` 只以 fast-forward 合併,一次只執行一個
152
167
  finish。Target 若已前進會先 rebase;沒有衝突且自身 patch 不變的 rebase 會先
153
168
  重新驗證再合併;衝突則連同先合併那份工作在 `refs/notes/agent-ops` 的意圖一起
154
- 回報。
169
+ 回報。Note 會記錄 worktree 的每個 task,subtask 縮排在其 parent 之下。
155
170
  - `worktree list`、`resume <name> --session <id>` 與 `remove <name>` 管理剩下的
156
171
  worktree;`remove --force` 會丟棄工作,執行前會先詢問使用者。`doctor` 會回報
157
172
  閒置超過七天的 worktree。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Evidence-driven development loops for agy, Codex, Claude Code, and opencode",
5
5
  "type": "module",
6
6
  "license": "MIT",