@kylecheng3146/agent-ops 0.2.8 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/packages/cli/src/args.js +48 -2
  2. package/dist/packages/cli/src/bin.js +55 -35
  3. package/dist/packages/cli/src/cli.js +31 -0
  4. package/dist/packages/cli/src/commands/hook.js +4 -1
  5. package/dist/packages/cli/src/commands/parallel.js +88 -0
  6. package/dist/packages/cli/src/commands/review.js +6 -4
  7. package/dist/packages/cli/src/commands/task.js +27 -3
  8. package/dist/packages/cli/src/hook-entry.js +2 -1
  9. package/dist/packages/cli/src/hook-process.js +34 -7
  10. package/dist/packages/cli/src/parallel-deps.js +89 -0
  11. package/dist/runtime/src/adapters/agy/output.js +2 -1
  12. package/dist/runtime/src/adapters/claude/config.js +6 -2
  13. package/dist/runtime/src/adapters/claude/input.js +24 -0
  14. package/dist/runtime/src/adapters/claude/output.js +2 -1
  15. package/dist/runtime/src/config/merge.js +10 -2
  16. package/dist/runtime/src/hooks/codex-loop.js +11 -1
  17. package/dist/runtime/src/hooks/completion-gate.js +141 -7
  18. package/dist/runtime/src/hooks/dispatch.js +5 -0
  19. package/dist/runtime/src/install/doctor.js +4 -1
  20. package/dist/runtime/src/install/harness.js +1 -1
  21. package/dist/runtime/src/install/plan.js +4 -1
  22. package/dist/runtime/src/parallel/finish.js +303 -0
  23. package/dist/runtime/src/parallel/guard.js +76 -0
  24. package/dist/runtime/src/parallel/manage.js +147 -0
  25. package/dist/runtime/src/parallel/service.js +281 -0
  26. package/dist/runtime/src/review/attestation.js +125 -50
  27. package/dist/runtime/src/review/execute.js +32 -5
  28. package/dist/runtime/src/schema/validate.js +50 -1
  29. package/dist/runtime/src/task/completion.js +1 -1
  30. package/dist/runtime/src/task/service.js +20 -4
  31. package/dist/runtime/src/task/store.js +7 -2
  32. package/docs/en/guides/configuration.md +63 -0
  33. package/docs/en/spec/delegation.md +10 -0
  34. package/docs/zh-TW/guides/configuration.md +55 -0
  35. package/docs/zh-TW/spec/delegation.md +11 -1
  36. package/package.json +1 -1
  37. package/schemas/config.schema.json +35 -0
@@ -0,0 +1,281 @@
1
+ import { chmod, copyFile, lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join, sep } from "node:path";
3
+ import { calculateConfigHash } from "../config/hash.js";
4
+ import { parseInstallManifest } from "../fs/manifest.js";
5
+ import { AgentOpsError } from "../fs/paths.js";
6
+ import { writeSessionMarker } from "../hooks/codex-loop.js";
7
+ import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
8
+ /**
9
+ * One Git worktree per writing session, so parallel sessions stop sharing the
10
+ * change surface every fingerprint is taken over. Worktrees live inside the
11
+ * main checkout, kept out of its surface by `.git/info/exclude` — never by
12
+ * `.gitignore`, whose edit would itself be a change the gate has to explain.
13
+ */
14
+ export const WORKTREE_DIRECTORY = ".worktrees";
15
+ export const WORKTREE_BRANCH_PREFIX = "agent-ops/";
16
+ export const WORKTREE_RECORD_PATH = ".agent-ops/tasks/worktree.json";
17
+ const EXCLUDE_LINE = `/${WORKTREE_DIRECTORY}/`;
18
+ const NAME = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/u;
19
+ const SESSION = /^[^\0\r\n]{1,256}$/u;
20
+ const DEFAULT_SETUP_TIMEOUT_MS = 10 * 60 * 1000;
21
+ function worktreeError(code, message) {
22
+ return new AgentOpsError(code, message);
23
+ }
24
+ async function git(deps, cwd, args, code, message) {
25
+ const result = await deps.git(cwd, args);
26
+ if (result.exitCode !== 0) {
27
+ const detail = result.stderr.trim().split("\n")[0];
28
+ throw worktreeError(code, detail === undefined || detail === "" ? message : `${message} ${detail}`);
29
+ }
30
+ return result.stdout.trim();
31
+ }
32
+ export function assertWorktreeName(name) {
33
+ if (name === undefined || !NAME.test(name) || name.includes("..")) {
34
+ throw worktreeError("WORKTREE_NAME_INVALID", "A worktree name is 1-64 lowercase letters, digits, '.', '_' or '-', starting and ending with a letter or digit.");
35
+ }
36
+ return name;
37
+ }
38
+ export function assertSessionId(sessionId) {
39
+ if (sessionId === undefined || !SESSION.test(sessionId)) {
40
+ throw worktreeError("WORKTREE_SESSION_REQUIRED", "Pass --session <id>: the conversation that will work in this worktree.");
41
+ }
42
+ return sessionId;
43
+ }
44
+ /**
45
+ * The main checkout and the checkout `cwd` is in. They differ inside a linked
46
+ * worktree. A repository whose common directory is not `<root>/.git` (bare,
47
+ * or a separate git dir) has no main checkout to put worktrees in.
48
+ */
49
+ export async function resolveCheckouts(deps, cwd) {
50
+ const commonDir = await realpath(await git(deps, cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"], "WORKTREE_NOT_GIT", "Worktrees require a Git repository."));
51
+ const currentRoot = await realpath(await git(deps, cwd, ["rev-parse", "--show-toplevel"], "WORKTREE_NOT_GIT", "Worktrees require a Git checkout."));
52
+ if (basename(commonDir) !== ".git") {
53
+ throw worktreeError("WORKTREE_LAYOUT_UNSUPPORTED", "Worktrees need a main checkout whose Git directory is <root>/.git.");
54
+ }
55
+ return { mainRoot: dirname(commonDir), currentRoot, commonDir };
56
+ }
57
+ export function worktreePath(mainRoot, name) {
58
+ return join(mainRoot, WORKTREE_DIRECTORY, name);
59
+ }
60
+ /** True when `path` is inside `<mainRoot>/.worktrees/`. */
61
+ export function insideWorktreeDirectory(mainRoot, path) {
62
+ return path.startsWith(`${join(mainRoot, WORKTREE_DIRECTORY)}${sep}`);
63
+ }
64
+ async function exists(path) {
65
+ try {
66
+ await lstat(path);
67
+ return true;
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
73
+ async function ensureExcluded(deps, mainRoot, commonDir) {
74
+ const probe = await deps.git(mainRoot, ["check-ignore", "-q", "--no-index", `${WORKTREE_DIRECTORY}/probe`]);
75
+ if (probe.exitCode === 0)
76
+ return;
77
+ const path = join(commonDir, "info", "exclude");
78
+ let current = "";
79
+ try {
80
+ current = await readFile(path, "utf8");
81
+ }
82
+ catch {
83
+ await mkdir(dirname(path), { recursive: true });
84
+ }
85
+ if (current.split(/\r?\n/u).includes(EXCLUDE_LINE))
86
+ return;
87
+ const separator = current.length === 0 || current.endsWith("\n") ? "" : "\n";
88
+ await writeFile(path, `${current}${separator}${EXCLUDE_LINE}\n`);
89
+ }
90
+ function portableRelative(path) {
91
+ return path.length > 0 && !path.startsWith("/") && !path.startsWith("~") &&
92
+ !path.includes("\\") && !path.includes("\0") &&
93
+ path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
94
+ }
95
+ /**
96
+ * Files Git does not carry into a new worktree but the session needs there:
97
+ * what agent-ops installed (rules, hooks, its own config) and whatever
98
+ * `.worktreeinclude` names (gitignore syntax: `.env`, `local.properties`).
99
+ * Only files missing from the new checkout are copied, so a tracked file
100
+ * always keeps the branch's version.
101
+ */
102
+ async function carriedFiles(deps, mainRoot) {
103
+ // The config is what makes the new checkout an agent-ops checkout at all:
104
+ // carried even when no manifest lists it.
105
+ const paths = new Set([".agent-ops/config.json"]);
106
+ const manifestSource = await readFile(join(mainRoot, ".agent-ops", "manifest.json"), "utf8").catch(() => null);
107
+ if (manifestSource !== null) {
108
+ paths.add(".agent-ops/manifest.json");
109
+ try {
110
+ const manifest = parseInstallManifest(manifestSource);
111
+ for (const { path } of [...manifest.artifacts, ...manifest.markers, ...(manifest.hooks ?? [])]) {
112
+ paths.add(path);
113
+ }
114
+ }
115
+ catch {
116
+ // An unreadable manifest still leaves the manifest itself to copy.
117
+ }
118
+ }
119
+ if (await exists(join(mainRoot, ".worktreeinclude"))) {
120
+ const listed = await git(deps, mainRoot, ["ls-files", "-z", "--others", "--ignored", "--exclude-from=.worktreeinclude"], "WORKTREE_INCLUDE_INVALID", ".worktreeinclude could not be read.");
121
+ for (const path of listed.split("\0"))
122
+ paths.add(path);
123
+ }
124
+ return [...paths].filter((path) => portableRelative(path) &&
125
+ !path.startsWith(`${WORKTREE_DIRECTORY}/`) &&
126
+ !path.startsWith(".agent-ops/tasks/") &&
127
+ !path.startsWith(".agent-ops/reviews/")).sort();
128
+ }
129
+ async function copyMissing(mainRoot, target, paths) {
130
+ const copied = [];
131
+ for (const path of paths) {
132
+ const source = join(mainRoot, ...path.split("/"));
133
+ const destination = join(target, ...path.split("/"));
134
+ let entry;
135
+ try {
136
+ entry = await lstat(source);
137
+ }
138
+ catch {
139
+ continue;
140
+ }
141
+ if (!entry.isFile() || await exists(destination))
142
+ continue;
143
+ await mkdir(dirname(destination), { recursive: true });
144
+ await copyFile(source, destination);
145
+ await chmod(destination, entry.mode & 0o777);
146
+ copied.push(path);
147
+ }
148
+ return copied;
149
+ }
150
+ export async function readWorktreeRecord(root) {
151
+ const source = await readPrivateFile(join(root, ...WORKTREE_RECORD_PATH.split("/")), root).catch(() => null);
152
+ if (source === null)
153
+ return null;
154
+ try {
155
+ const value = JSON.parse(source);
156
+ return value.schemaVersion === 1 && typeof value.name === "string" &&
157
+ typeof value.branch === "string" && typeof value.path === "string" &&
158
+ typeof value.mainRoot === "string" && typeof value.targetBranch === "string" &&
159
+ typeof value.base === "string" && typeof value.sessionId === "string" &&
160
+ typeof value.createdAt === "string"
161
+ ? value
162
+ : null;
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ }
168
+ export async function writeWorktreeRecord(record) {
169
+ await writePrivateFile(join(record.path, ...WORKTREE_RECORD_PATH.split("/")), `${JSON.stringify(record, null, 2)}\n`, record.path);
170
+ }
171
+ /** Points the session at the worktree from both sides of the gate. */
172
+ export async function bindSession(deps, record, mainConfig, baselineFingerprint) {
173
+ for (const harness of ["claude", "codex"]) {
174
+ if (await exists(join(record.mainRoot, `.${harness}`))) {
175
+ await writeSessionMarker({ root: record.path, harness, sessionId: record.sessionId });
176
+ }
177
+ }
178
+ if (!mainConfig.features.completionGate.enabled)
179
+ return;
180
+ const worktreeConfig = await deps.loadConfig(record.path);
181
+ await (await deps.gate(record.path, worktreeConfig)).seed(record.sessionId, baselineFingerprint);
182
+ await (await deps.gate(record.mainRoot, mainConfig)).redirect(record.sessionId, record.path);
183
+ }
184
+ export async function removeCheckout(deps, record, force) {
185
+ await git(deps, record.mainRoot, ["worktree", "remove", ...(force ? ["--force"] : []), record.path], "WORKTREE_REMOVE_FAILED", `Git could not remove ${record.path}.`);
186
+ await git(deps, record.mainRoot, ["branch", force ? "-D" : "-d", record.branch], "WORKTREE_REMOVE_FAILED", `Git could not delete ${record.branch}.`);
187
+ }
188
+ /**
189
+ * Creates `.worktrees/<name>` on `agent-ops/<name>` from the main checkout's
190
+ * HEAD and makes it a working agent-ops checkout for `sessionId`.
191
+ */
192
+ export async function addWorktree(deps, options) {
193
+ const name = assertWorktreeName(options.name);
194
+ const sessionId = assertSessionId(options.sessionId);
195
+ const { mainRoot, currentRoot, commonDir } = await resolveCheckouts(deps, options.cwd);
196
+ if (currentRoot !== mainRoot) {
197
+ throw worktreeError("WORKTREE_NESTED", `Run worktree commands from the main checkout (${mainRoot}), not from inside a worktree.`);
198
+ }
199
+ const targetBranch = await git(deps, mainRoot, ["symbolic-ref", "--short", "-q", "HEAD"], "WORKTREE_DETACHED_HEAD", "The main checkout must be on a branch to merge back into.");
200
+ const base = await git(deps, mainRoot, ["rev-parse", "--verify", "HEAD^{commit}"], "WORKTREE_NO_COMMIT", "The main checkout has no commit to branch from.");
201
+ const path = worktreePath(mainRoot, name);
202
+ const branch = `${WORKTREE_BRANCH_PREFIX}${name}`;
203
+ if (await exists(path)) {
204
+ throw worktreeError("WORKTREE_EXISTS", `${path} already exists; pick another name or resume it.`);
205
+ }
206
+ if ((await deps.git(mainRoot, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`])).exitCode === 0) {
207
+ throw worktreeError("WORKTREE_EXISTS", `Branch ${branch} already exists; pick another name.`);
208
+ }
209
+ const mainConfig = await deps.loadConfig(mainRoot);
210
+ const mainTrust = await deps.trust.status(mainRoot, mainConfig);
211
+ const setupSteps = mainConfig.worktree?.setup ?? [];
212
+ if (setupSteps.length > 0 && mainTrust !== "TRUSTED") {
213
+ throw worktreeError("WORKTREE_SETUP_UNTRUSTED", "worktree.setup runs repository commands; run agent-ops trust grant in the main checkout first.");
214
+ }
215
+ await ensureExcluded(deps, mainRoot, commonDir);
216
+ await git(deps, mainRoot, ["worktree", "add", "-b", branch, path, base], "WORKTREE_ADD_FAILED", "Git could not create the worktree.");
217
+ const record = {
218
+ schemaVersion: 1,
219
+ name,
220
+ branch,
221
+ path: await realpath(path),
222
+ mainRoot,
223
+ targetBranch,
224
+ base,
225
+ sessionId,
226
+ createdAt: deps.now?.() ?? new Date().toISOString()
227
+ };
228
+ let trusted = false;
229
+ try {
230
+ const copied = await copyMissing(mainRoot, record.path, await carriedFiles(deps, mainRoot));
231
+ const worktreeConfig = await deps.loadConfig(record.path);
232
+ // The same commands the user already trusted, and nothing else: any
233
+ // difference in config leaves the new checkout untrusted.
234
+ if (mainTrust === "TRUSTED" && calculateConfigHash(worktreeConfig) === calculateConfigHash(mainConfig)) {
235
+ await deps.trust.grant(record.path, worktreeConfig);
236
+ trusted = true;
237
+ }
238
+ const setup = [];
239
+ for (const step of setupSteps) {
240
+ const result = await deps.runSetup(record.path, {
241
+ ...step,
242
+ timeoutMs: step.timeoutMs ?? DEFAULT_SETUP_TIMEOUT_MS
243
+ });
244
+ const label = [step.command, ...step.args].join(" ");
245
+ if (result.exitCode !== 0) {
246
+ throw worktreeError("WORKTREE_SETUP_FAILED", `Setup step failed (${label}, exit ${String(result.exitCode)}); the worktree was removed. ${result.output.trim().split("\n").slice(-5).join("\n")}`.trim());
247
+ }
248
+ setup.push(label);
249
+ }
250
+ await writeWorktreeRecord(record);
251
+ await bindSession(deps, record, mainConfig);
252
+ return { record, copied, trusted, setup };
253
+ }
254
+ catch (error) {
255
+ if (mainConfig.features.completionGate.enabled) {
256
+ await (await deps.gate(mainRoot, mainConfig)).redirect(sessionId, null).catch(() => undefined);
257
+ }
258
+ if (trusted)
259
+ await deps.trust.revoke(record.path, mainConfig).catch(() => undefined);
260
+ await removeCheckout(deps, record, true).catch(() => undefined);
261
+ throw error;
262
+ }
263
+ }
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`,
@@ -300,7 +300,8 @@ export function validateConfig(value) {
300
300
  "reviewRoles",
301
301
  "schemaVersion",
302
302
  "securityExceptions",
303
- "verification"
303
+ "verification",
304
+ "worktree"
304
305
  ], CONFIG_SCHEMA_VERSION);
305
306
  if (isFailure(root)) {
306
307
  return root;
@@ -411,8 +412,56 @@ export function validateConfig(value) {
411
412
  roles.add(role.value.role);
412
413
  }
413
414
  }
415
+ if (root.worktree !== undefined) {
416
+ const worktree = validateWorktree(root.worktree, "$.worktree");
417
+ if (!worktree.ok) {
418
+ return worktree;
419
+ }
420
+ }
414
421
  return success(root);
415
422
  }
423
+ function validateWorktree(value, path) {
424
+ if (!isRecord(value)) {
425
+ return failure("INVALID_TYPE", path, "worktree must be an object.");
426
+ }
427
+ const unknown = unknownFieldFailure(value, ["mode", "setup"], path);
428
+ if (unknown !== undefined) {
429
+ return unknown;
430
+ }
431
+ if (value.mode !== "auto" && value.mode !== "off") {
432
+ return failure("INVALID_WORKTREE_MODE", `${path}.mode`, `Unsupported worktree mode: ${String(value.mode)}`);
433
+ }
434
+ if (value.setup === undefined) {
435
+ return success(value);
436
+ }
437
+ if (!Array.isArray(value.setup)) {
438
+ return failure("INVALID_TYPE", `${path}.setup`, "setup must be an array.");
439
+ }
440
+ for (const [index, step] of value.setup.entries()) {
441
+ const stepPath = `${path}.setup[${index}]`;
442
+ if (!isRecord(step)) {
443
+ return failure("INVALID_TYPE", stepPath, "Expected a setup command object.");
444
+ }
445
+ const stepUnknown = unknownFieldFailure(step, ["args", "command", "timeoutMs"], stepPath);
446
+ if (stepUnknown !== undefined) {
447
+ return stepUnknown;
448
+ }
449
+ if (!isNonEmptyString(step.command)) {
450
+ return failure("INVALID_COMMAND", `${stepPath}.command`, "command must be a non-empty string.");
451
+ }
452
+ if (!isStringArray(step.args) ||
453
+ step.args.some((argument) => argument.includes("\0"))) {
454
+ return failure("INVALID_ARGS", `${stepPath}.args`, "args must be an array of strings.");
455
+ }
456
+ if (step.timeoutMs !== undefined &&
457
+ (!Number.isSafeInteger(step.timeoutMs) ||
458
+ step.timeoutMs <= 0 ||
459
+ step.timeoutMs > MAX_TIMEOUT_MS)) {
460
+ return failure("INVALID_TIMEOUT", `${stepPath}.timeoutMs`, "timeoutMs must be a positive integer.");
461
+ }
462
+ }
463
+ return success(value);
464
+ }
416
465
  function validateReviewRole(value, path) {
417
466
  if (!isRecord(value)) {
418
467
  return failure("INVALID_TYPE", path, "Expected a review role object.");