@kylecheng3146/agent-ops 0.1.6 → 0.1.8

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 (43) hide show
  1. package/README.md +27 -0
  2. package/dist/packages/cli/src/args.js +47 -0
  3. package/dist/packages/cli/src/bin.js +74 -25
  4. package/dist/packages/cli/src/cli.js +13 -1
  5. package/dist/packages/cli/src/commands/init.js +4 -1
  6. package/dist/packages/cli/src/commands/review.js +371 -27
  7. package/dist/packages/cli/src/commands/task.js +4 -1
  8. package/dist/packages/cli/src/commands/verify.js +13 -1
  9. package/dist/packages/cli/src/version.js +1 -1
  10. package/dist/packages/cli/src/wizard.js +62 -3
  11. package/dist/runtime/src/config/merge.js +17 -2
  12. package/dist/runtime/src/contracts.js +1 -1
  13. package/dist/runtime/src/install/doctor.js +42 -1
  14. package/dist/runtime/src/install/plan.js +11 -5
  15. package/dist/runtime/src/review/execute.js +180 -0
  16. package/dist/runtime/src/review/extract.js +69 -0
  17. package/dist/runtime/src/review/invocation.js +116 -0
  18. package/dist/runtime/src/review/packet.js +42 -5
  19. package/dist/runtime/src/review/probe.js +72 -0
  20. package/dist/runtime/src/review/render.js +62 -0
  21. package/dist/runtime/src/review/report.js +183 -0
  22. package/dist/runtime/src/review/result.js +2 -2
  23. package/dist/runtime/src/review/roles.js +35 -0
  24. package/dist/runtime/src/review/runner.js +98 -12
  25. package/dist/runtime/src/review/scope.js +123 -0
  26. package/dist/runtime/src/schema/validate.js +80 -0
  27. package/dist/runtime/src/task/service.js +46 -1
  28. package/dist/runtime/src/task/store.js +16 -4
  29. package/dist/runtime/src/verify/change-surface.js +38 -2
  30. package/dist/runtime/src/verify/command-executor.js +4 -1
  31. package/dist/runtime/src/verify/evidence.js +36 -0
  32. package/dist/runtime/src/verify/scope.js +1 -2
  33. package/dist/runtime/src/verify/service.js +66 -9
  34. package/dist/runtime/src/verify/source-fingerprint.js +49 -0
  35. package/dist/runtime/src/verify/spawn.js +9 -3
  36. package/docs/en/guides/configuration.md +68 -0
  37. package/docs/en/spec/review.md +37 -4
  38. package/docs/zh-TW/guides/configuration.md +60 -0
  39. package/docs/zh-TW/spec/review.md +33 -3
  40. package/package.json +1 -1
  41. package/schemas/config.schema.json +29 -0
  42. package/schemas/evidence.schema.json +16 -1
  43. package/schemas/review-report.schema.json +48 -0
@@ -73,6 +73,10 @@ export class TaskService {
73
73
  this.#now = options.now ?? (() => new Date().toISOString());
74
74
  }
75
75
  async create(input) {
76
+ if (input.policyConfigHash !== undefined &&
77
+ !/^[a-f0-9]{64}$/u.test(input.policyConfigHash)) {
78
+ throw taskError("TASK_POLICY_CONFIG_INVALID", "Policy config hash must be a lowercase SHA-256 digest.");
79
+ }
76
80
  const task = {
77
81
  schemaVersion: TASK_SCHEMA_VERSION,
78
82
  id: this.#generateId(),
@@ -96,7 +100,8 @@ export class TaskService {
96
100
  updatedAt: now,
97
101
  completedAt: null,
98
102
  archivedAt: null,
99
- failureFingerprint: null
103
+ failureFingerprint: null,
104
+ policyConfigHash: input.policyConfigHash ?? null
100
105
  };
101
106
  state.tasks.push(record);
102
107
  return cloneRecord(record);
@@ -181,6 +186,46 @@ export class TaskService {
181
186
  return cloneRecord(completed);
182
187
  });
183
188
  }
189
+ /**
190
+ * Append evidence for some criteria without completing the task. Unlike
191
+ * `complete`, the input may be partial — an independent review covers the
192
+ * criteria it was asked about, not necessarily all of them. Only an active
193
+ * task accepts evidence: a completed record must stay exactly as it was
194
+ * verified.
195
+ */
196
+ async recordEvidence(taskId, evidenceInput) {
197
+ const now = assertTimestamp(this.#now());
198
+ return await this.#store.mutate((state) => {
199
+ const current = findTask(state, taskId);
200
+ if (current.status !== "active") {
201
+ throw taskError("TASK_NOT_ACTIVE", "Only an active task can record additional evidence.");
202
+ }
203
+ const criterionIds = new Set(current.task.criteria.map((criterion) => criterion.id));
204
+ const evidence = Object.fromEntries(Object.entries(current.evidence).map(([criterionId, references]) => [
205
+ criterionId,
206
+ [...references]
207
+ ]));
208
+ for (const [criterionId, references] of Object.entries(evidenceInput)) {
209
+ if (!criterionIds.has(criterionId)) {
210
+ throw taskError("TASK_EVIDENCE_UNKNOWN_CRITERION", `Unknown criterion: ${criterionId}`);
211
+ }
212
+ if (references.length === 0 ||
213
+ references.some((reference) => typeof reference !== "string" || reference.trim().length === 0)) {
214
+ throw taskError("TASK_EVIDENCE_INVALID", `Evidence for ${criterionId} must be non-empty references.`);
215
+ }
216
+ evidence[criterionId] = [
217
+ ...new Set([...(evidence[criterionId] ?? []), ...references])
218
+ ];
219
+ }
220
+ const updated = {
221
+ ...current,
222
+ evidence,
223
+ updatedAt: now
224
+ };
225
+ replaceTask(state, updated);
226
+ return cloneRecord(updated);
227
+ });
228
+ }
184
229
  async archive(taskId) {
185
230
  const now = assertTimestamp(this.#now());
186
231
  return await this.#store.mutate((state) => {
@@ -64,9 +64,13 @@ function parseTaskRecord(value) {
64
64
  "task",
65
65
  "updatedAt"
66
66
  ];
67
- if (!isRecord(value) ||
68
- (!hasExactKeys(value, baseKeys) &&
69
- !hasExactKeys(value, [...baseKeys, "failureFingerprint"]))) {
67
+ const allowedKeys = new Set([
68
+ baseKeys.join("\0"),
69
+ [...baseKeys, "failureFingerprint"].sort().join("\0"),
70
+ [...baseKeys, "policyConfigHash"].sort().join("\0"),
71
+ [...baseKeys, "failureFingerprint", "policyConfigHash"].sort().join("\0")
72
+ ]);
73
+ if (!isRecord(value) || !allowedKeys.has(Object.keys(value).sort().join("\0"))) {
70
74
  return invalidState("Task state contains an invalid task record.");
71
75
  }
72
76
  const task = validateTask(value.task);
@@ -127,6 +131,13 @@ function parseTaskRecord(value) {
127
131
  recordedAt: fingerprint.recordedAt
128
132
  };
129
133
  }
134
+ const policyConfigHash = value.policyConfigHash === undefined
135
+ ? null
136
+ : value.policyConfigHash;
137
+ if (policyConfigHash !== null &&
138
+ (typeof policyConfigHash !== "string" || !/^[a-f0-9]{64}$/u.test(policyConfigHash))) {
139
+ return invalidState("Task state contains an invalid policy config hash.");
140
+ }
130
141
  return {
131
142
  task: task.value,
132
143
  status,
@@ -135,7 +146,8 @@ function parseTaskRecord(value) {
135
146
  updatedAt: value.updatedAt,
136
147
  completedAt: value.completedAt,
137
148
  archivedAt: value.archivedAt,
138
- failureFingerprint
149
+ failureFingerprint,
150
+ policyConfigHash
139
151
  };
140
152
  }
141
153
  function parseSession(value) {
@@ -4,7 +4,7 @@ const WINDOWS_RESERVED_SEGMENT = /^(?:aux|com[1-9]|con|lpt[1-9]|nul|prn)(?:\..*)
4
4
  function sortedUnique(values) {
5
5
  return [...new Set(values)].sort();
6
6
  }
7
- function normalizePortablePath(path) {
7
+ export function normalizePortablePath(path) {
8
8
  if (path.length === 0 ||
9
9
  path.includes("\\") ||
10
10
  path.startsWith("/") ||
@@ -31,7 +31,7 @@ function normalizePortablePath(path) {
31
31
  }
32
32
  return normalizedSegments.join("/");
33
33
  }
34
- function parseNulPaths(stdout) {
34
+ export function parseNulPaths(stdout) {
35
35
  if (stdout.byteLength === 0) {
36
36
  return [];
37
37
  }
@@ -64,11 +64,19 @@ export async function collectChangeSurface(runner) {
64
64
  "diff",
65
65
  "--cached",
66
66
  "--name-only",
67
+ "--full-name",
68
+ "--no-renames",
69
+ "--no-ext-diff",
70
+ "--no-textconv",
67
71
  "-z"
68
72
  ]);
69
73
  const unstaged = await collectPaths(runner, [
70
74
  "diff",
71
75
  "--name-only",
76
+ "--full-name",
77
+ "--no-renames",
78
+ "--no-ext-diff",
79
+ "--no-textconv",
72
80
  "-z"
73
81
  ]);
74
82
  const untracked = await collectPaths(runner, [
@@ -84,3 +92,31 @@ export async function collectChangeSurface(runner) {
84
92
  paths: sortedUnique([...staged, ...unstaged, ...untracked])
85
93
  };
86
94
  }
95
+ function decodeCommit(stdout) {
96
+ let text;
97
+ try {
98
+ text = new TextDecoder("utf-8", { fatal: true }).decode(stdout).trim();
99
+ }
100
+ catch (error) {
101
+ throw new AgentOpsError("CHANGE_SURFACE_INVALID_OUTPUT", "Git commit output is not valid UTF-8.", { cause: error });
102
+ }
103
+ if (!/^[a-f0-9]{40,64}$/u.test(text)) {
104
+ throw new AgentOpsError("CHANGE_SURFACE_INVALID_OUTPUT", "Git did not return one commit object ID.");
105
+ }
106
+ return text;
107
+ }
108
+ export async function resolveGitCommit(runner, ref) {
109
+ const result = await runner.run([
110
+ "rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`
111
+ ]);
112
+ if (result.exitCode !== 0) {
113
+ throw new AgentOpsError("CHANGE_SURFACE_GIT_FAILED", "The requested base ref does not resolve to a commit.");
114
+ }
115
+ return decodeCommit(result.stdout);
116
+ }
117
+ export async function collectBaseChangePaths(runner, base) {
118
+ return await collectPaths(runner, [
119
+ "diff", "--name-only", "--full-name", "--no-renames", "--no-ext-diff",
120
+ "--no-textconv", "-z", `${base}...HEAD`
121
+ ]);
122
+ }
@@ -102,7 +102,10 @@ export async function executeConfiguredCommand(command, options) {
102
102
  }
103
103
  export function aggregateVerificationStatus(results) {
104
104
  const required = results.filter((result) => result.required);
105
- const gating = required.length > 0 ? required : results;
105
+ if (required.length === 0) {
106
+ return "PASS";
107
+ }
108
+ const gating = required;
106
109
  if (gating.some((result) => result.status === "FAIL")) {
107
110
  return "FAIL";
108
111
  }
@@ -3,6 +3,7 @@ import { EVIDENCE_SCHEMA_VERSION } from "../contracts.js";
3
3
  import { sha256 } from "../fs/hash.js";
4
4
  import { AgentOpsError } from "../fs/paths.js";
5
5
  import { calculateConfigHash } from "../config/hash.js";
6
+ import { evaluateTestCount } from "./test-count.js";
6
7
  export { calculateConfigHash } from "../config/hash.js";
7
8
  import { validateEvidence } from "../schema/validate.js";
8
9
  import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
@@ -34,10 +35,24 @@ export function buildVerificationEvidence(input) {
34
35
  finishedAt: input.finishedAt,
35
36
  exitCode: input.exitCode,
36
37
  testCount: input.testCount,
38
+ status: input.status,
39
+ failureClass: redactSecrets(input.failureClass),
40
+ sourceFingerprint: input.sourceFingerprint,
37
41
  toolVersions: redactRecord(input.toolVersions),
38
42
  configHash: calculateConfigHash(input.config)
39
43
  });
40
44
  }
45
+ /** Confirms that a persisted PASS still matches the current command contract. */
46
+ export function isPassingVerificationEvidence(command, evidence) {
47
+ if (evidence.status !== "PASS" ||
48
+ evidence.exitCode !== 0 ||
49
+ evidence.failureClass !== "none" ||
50
+ command.evidence.kind === "file") {
51
+ return false;
52
+ }
53
+ return command.evidence.kind !== "test-count" ||
54
+ evaluateTestCount(evidence.testCount, command.evidence.minimum).status === "PASS";
55
+ }
41
56
  export class FileEvidenceStore {
42
57
  #root;
43
58
  #anchorDirectory;
@@ -66,4 +81,25 @@ export class FileEvidenceStore {
66
81
  }
67
82
  return relativePath;
68
83
  }
84
+ async load(reference) {
85
+ const segments = reference.split("/");
86
+ if (!reference.startsWith(".agent-ops/tasks/evidence/") ||
87
+ segments.some((segment) => segment.length === 0 ||
88
+ segment === "." ||
89
+ segment === ".." ||
90
+ !/^[A-Za-z0-9._-]+$/u.test(segment))) {
91
+ return null;
92
+ }
93
+ const path = join(this.#root, ...segments);
94
+ const source = await readPrivateFile(path, this.#anchorDirectory);
95
+ if (source === null) {
96
+ return null;
97
+ }
98
+ try {
99
+ return JSON.parse(source);
100
+ }
101
+ catch {
102
+ throw new AgentOpsError("EVIDENCE_INVALID", "Stored evidence is not valid JSON.");
103
+ }
104
+ }
69
105
  }
@@ -1,4 +1,3 @@
1
- import { AgentOpsError } from "../fs/paths.js";
2
1
  function sortedUnique(values) {
3
2
  return [...new Set(values)].sort();
4
3
  }
@@ -18,7 +17,7 @@ function sameVerifierIds(left, right) {
18
17
  }
19
18
  function fallbackSelection(reason, evidence) {
20
19
  if (evidence.requiredVerifierIds.length === 0) {
21
- throw new AgentOpsError("VERIFICATION_SCOPE_EMPTY", `Verification scope fallback (${reason}) has no required commands.`);
20
+ return { verifierIds: [], fallback: true, reason, evidence };
22
21
  }
23
22
  return {
24
23
  verifierIds: evidence.requiredVerifierIds,
@@ -1,6 +1,8 @@
1
1
  import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
2
2
  import { validateTaskAgainstConfig } from "../schema/validate.js";
3
3
  import { collectChangeSurface } from "./change-surface.js";
4
+ import { resolveReviewScope, reviewScopeSignature } from "../review/scope.js";
5
+ import { calculateSourceFingerprint } from "./source-fingerprint.js";
4
6
  import { buildVerificationEvidence } from "./evidence.js";
5
7
  import { createFailureFingerprint } from "./fingerprint.js";
6
8
  import { aggregateVerificationStatus, executeConfiguredCommand } from "./command-executor.js";
@@ -41,7 +43,7 @@ export class VerificationService {
41
43
  }
42
44
  return await resolveContainedPath(this.#options.root, command.cwd);
43
45
  }
44
- async #persistEvidence(task, command, startedAt, finishedAt, result, exitCode) {
46
+ async #persistEvidence(task, command, startedAt, finishedAt, result, sourceFingerprint) {
45
47
  const references = [];
46
48
  for (const criterion of relevantCriteria(task, command.id)) {
47
49
  const evidence = buildVerificationEvidence({
@@ -51,8 +53,11 @@ export class VerificationService {
51
53
  scope: this.#options.scope,
52
54
  startedAt,
53
55
  finishedAt,
54
- exitCode,
56
+ exitCode: result.exitCode,
55
57
  testCount: result.testCount,
58
+ status: result.status,
59
+ failureClass: result.failureClass,
60
+ sourceFingerprint,
56
61
  toolVersions: this.#options.toolVersions ?? {},
57
62
  config: this.#options.config
58
63
  });
@@ -84,7 +89,6 @@ export class VerificationService {
84
89
  diagnostics: result.diagnostic
85
90
  });
86
91
  const finishedAt = (this.#options.now ?? (() => new Date().toISOString()))();
87
- const evidenceReferences = await this.#persistEvidence(task, command, startedAt, finishedAt, result, result.exitCode);
88
92
  return {
89
93
  commandId: command.id,
90
94
  required: command.required,
@@ -94,7 +98,9 @@ export class VerificationService {
94
98
  timedOut: result.timedOut,
95
99
  testCount: result.testCount,
96
100
  diagnostic: fingerprint?.diagnostics ?? "",
97
- evidenceReferences
101
+ evidenceReferences: [],
102
+ startedAt,
103
+ finishedAt
98
104
  };
99
105
  }
100
106
  async verify(taskId) {
@@ -107,23 +113,72 @@ export class VerificationService {
107
113
  throw verificationError("VERIFICATION_INPUT_INVALID", validation.errors[0]?.message ??
108
114
  "Task and verification configuration are incompatible.");
109
115
  }
110
- const surface = await collectChangeSurface(this.#options.gitRunner);
116
+ const reviewScope = await resolveReviewScope({
117
+ root: this.#options.root,
118
+ runner: this.#options.gitRunner,
119
+ ...(this.#options.base === undefined ? {} : { base: this.#options.base })
120
+ });
121
+ const sourceFingerprint = await calculateSourceFingerprint(this.#options.root, reviewScope, this.#options.gitRunner);
122
+ const worktreeSurface = reviewScope.mode === "worktree"
123
+ ? await collectChangeSurface(this.#options.gitRunner)
124
+ : { staged: [], unstaged: [], untracked: [], paths: reviewScope.changedFiles };
125
+ const surface = worktreeSurface;
111
126
  const selection = selectVerificationScope(surface.paths, this.#options.config);
112
127
  const results = [];
113
128
  for (const commandId of selection.verifierIds) {
114
129
  results.push(await this.#runCommand(validation.value, commandById(this.#options.config, commandId)));
115
130
  }
116
- const status = aggregateVerificationStatus(results);
131
+ let status = aggregateVerificationStatus(results);
132
+ let sourceChanged = false;
133
+ const postflightScope = await resolveReviewScope({
134
+ root: this.#options.root,
135
+ runner: this.#options.gitRunner,
136
+ ...(this.#options.base === undefined ? {} : { base: this.#options.base })
137
+ });
138
+ const postflightFingerprint = await calculateSourceFingerprint(this.#options.root, postflightScope, this.#options.gitRunner);
139
+ if (reviewScopeSignature(reviewScope) !== reviewScopeSignature(postflightScope) ||
140
+ sourceFingerprint !== postflightFingerprint) {
141
+ status = "UNKNOWN";
142
+ sourceChanged = true;
143
+ }
117
144
  let signal = null;
118
145
  if (status === "PASS") {
146
+ const taskEvidence = {};
147
+ for (const [index, result] of results.entries()) {
148
+ const command = commandById(this.#options.config, result.commandId);
149
+ const references = await this.#persistEvidence(validation.value, command, result.startedAt, result.finishedAt, result, sourceFingerprint);
150
+ results[index] = { ...result, evidenceReferences: references };
151
+ for (const [criterionIndex, criterion] of relevantCriteria(validation.value, result.commandId).entries()) {
152
+ const reference = references[criterionIndex];
153
+ if (reference !== undefined) {
154
+ taskEvidence[criterion.id] = [
155
+ ...(taskEvidence[criterion.id] ?? []),
156
+ reference
157
+ ];
158
+ }
159
+ }
160
+ }
161
+ if (Object.keys(taskEvidence).length > 0) {
162
+ await this.#options.taskService.recordEvidence(taskId, taskEvidence);
163
+ }
119
164
  await this.#options.taskService.clearFailure(taskId);
120
165
  }
121
166
  else {
122
167
  const required = results.filter((result) => result.required);
123
- const gating = required.length > 0 ? required : results;
168
+ const gating = required;
124
169
  const failed = gating.find((result) => result.status !== "PASS");
125
170
  if (failed === undefined) {
126
- throw verificationError("VERIFICATION_RESULT_INVALID", "Verification failed without a gating result.");
171
+ if (!sourceChanged) {
172
+ throw verificationError("VERIFICATION_RESULT_INVALID", "Verification failed without a required result.");
173
+ }
174
+ const advanced = await this.#options.taskService.recordFailure(taskId, createFailureFingerprint({
175
+ commandId: "source-snapshot",
176
+ failureClass: "source-changed-during-verification",
177
+ exitCategory: "no-exit",
178
+ diagnostics: "source changed during verification"
179
+ }));
180
+ signal = advanced.signal;
181
+ return { taskId, status, surface, selection, results, signal, reviewScope, sourceFingerprint };
127
182
  }
128
183
  const advanced = await this.#options.taskService.recordFailure(taskId, createFailureFingerprint({
129
184
  commandId: failed.commandId,
@@ -139,7 +194,9 @@ export class VerificationService {
139
194
  surface,
140
195
  selection,
141
196
  results,
142
- signal
197
+ signal,
198
+ reviewScope,
199
+ sourceFingerprint
143
200
  };
144
201
  }
145
202
  }
@@ -0,0 +1,49 @@
1
+ import { readFile, lstat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { sha256 } from "../fs/hash.js";
4
+ import { AgentOpsError } from "../fs/paths.js";
5
+ import { resolveGitCommit } from "./change-surface.js";
6
+ export async function calculateSourceFingerprint(root, scope, runner) {
7
+ const head = await resolveGitCommit(runner, "HEAD");
8
+ if (scope.mode === "base") {
9
+ return sha256(JSON.stringify({
10
+ domain: "agent-ops-source-v1",
11
+ mode: "base",
12
+ head,
13
+ base: scope.resolvedBase,
14
+ paths: [...scope.changedFiles]
15
+ }));
16
+ }
17
+ const paths = [];
18
+ for (const path of scope.changedFiles) {
19
+ const absolute = join(root, ...path.split("/"));
20
+ let entry;
21
+ try {
22
+ entry = await lstat(absolute);
23
+ }
24
+ catch (error) {
25
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
26
+ paths.push({ path, kind: "deleted" });
27
+ continue;
28
+ }
29
+ throw error;
30
+ }
31
+ if (!entry.isFile()) {
32
+ throw new AgentOpsError("SOURCE_SNAPSHOT_UNAVAILABLE", "Review source contains an unsupported entry.");
33
+ }
34
+ const bytes = await readFile(absolute);
35
+ paths.push({
36
+ path,
37
+ kind: "file",
38
+ executable: (entry.mode & 0o111) !== 0,
39
+ hash: sha256(bytes)
40
+ });
41
+ }
42
+ return sha256(JSON.stringify({
43
+ domain: "agent-ops-source-v1",
44
+ mode: "worktree",
45
+ head,
46
+ base: null,
47
+ paths
48
+ }));
49
+ }
@@ -139,13 +139,17 @@ export class NodeVerificationProcessRunner {
139
139
  cwd: request.cwd,
140
140
  detached: this.#platform !== "win32",
141
141
  env: {
142
- ...process.env,
142
+ ...(request.replaceEnv === true ? {} : process.env),
143
143
  ...(request.env ?? {})
144
144
  },
145
145
  shell: request.shell,
146
- stdio: ["ignore", "pipe", "pipe"],
146
+ stdio: [request.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
147
147
  windowsHide: true
148
148
  });
149
+ if (request.stdin !== undefined && child.stdin !== null) {
150
+ child.stdin.on("error", () => { });
151
+ child.stdin.end(request.stdin);
152
+ }
149
153
  let settled = false;
150
154
  const completion = new Promise((resolve) => {
151
155
  child.once("error", (error) => {
@@ -263,7 +267,9 @@ export async function runVerificationCommand(command, options) {
263
267
  args: [...command.args],
264
268
  cwd: options.cwd,
265
269
  shell: command.shell === true,
266
- ...(options.env === undefined ? {} : { env: options.env })
270
+ ...(options.env === undefined ? {} : { env: options.env }),
271
+ ...(options.replaceEnv === true ? { replaceEnv: true } : {}),
272
+ ...(options.stdin === undefined ? {} : { stdin: options.stdin })
267
273
  });
268
274
  }
269
275
  catch {
@@ -31,6 +31,74 @@ implies them. Advisory runs through the real SessionStart path and is
31
31
  fail-open. Claude and Codex lifecycle support is `supported`; OpenCode begins
32
32
  at app initialization and is honestly reported as `degraded`.
33
33
 
34
+ ### External review targets
35
+
36
+ `agent-ops review` can call another agent CLI to review your work. It is
37
+ disabled by default: an absent `reviewRoles` field, an absent
38
+ `--review-target` flag, and the interactive question's default all mean off.
39
+ Enable it during `agent-ops init`, or by hand:
40
+
41
+ ```json
42
+ {
43
+ "reviewRoles": [
44
+ { "role": "independent-review", "targets": ["claude"] }
45
+ ]
46
+ }
47
+ ```
48
+
49
+ `targets` is an **ordered fallback chain**. Every review locks to the
50
+ staged/unstaged/untracked surface (or a clean `--base <ref>...HEAD` range),
51
+ requires fresh PASS evidence for required checks, and prints a complete native
52
+ schema report without persisting it.
53
+
54
+ Every attempt starts from a fresh temporary cwd with a narrow environment. The
55
+ following target identities may be configured; only Claude currently meets the
56
+ complete context-isolation contract and can execute automatically:
57
+
58
+ | Target | Invocation | Read-only |
59
+ | --- | --- | --- |
60
+ | `codex` | `codex exec` | retained; `capability-unavailable` |
61
+ | `agy` (Antigravity) | `agy -p` | retained; `capability-unavailable` |
62
+ | `claude` | `claude -p` | `--permission-mode plan --safe-mode` |
63
+
64
+ `opencode` is **not** a review target even though it is a supported harness.
65
+ Its `--agent plan` is rejected as a subagent and silently falls back to a
66
+ writable agent, so it cannot satisfy the read-only precondition. A target with
67
+ no read-only flag is skipped rather than run unsandboxed.
68
+
69
+ The chain advances only when no review happened — the executable is missing,
70
+ the spawn failed, or the attempt timed out (120s per target by default,
71
+ overridable with `timeoutMs`). A `FAIL` verdict is **terminal**: the chain
72
+ never retries another target after a real verdict, because that would be
73
+ automated review shopping. Unparseable output is terminal too, since it points
74
+ at a prompt or CLI-version mismatch worth surfacing.
75
+
76
+ If Claude Code is the host (`CLAUDECODE` is set), `claude` is moved to the end
77
+ of the chain. It still runs when it is the only configured target, with a
78
+ `reviewer == host` warning.
79
+
80
+ Criterion descriptions come from the task bound to the current session, so a
81
+ review needs an attached task created under the current policy configuration;
82
+ `--criterion` filters those ids. Run `agent-ops verify` first: review rejects
83
+ stale, failed, or source-mismatched required evidence before any model call.
84
+ Compact PASS evidence is appended with a `review:<target>:` prefix; the full
85
+ human-readable report is transient. Completed tasks are never rewritten.
86
+
87
+ `--yes` is still required for every review run: init selection decides which
88
+ targets are permitted, `--yes` decides whether to spend money now.
89
+
90
+ Because target authentication is not sniffed from stderr, an unauthenticated
91
+ CLI surfaces as one review failure. Diagnose it with:
92
+
93
+ ```bash
94
+ agent-ops doctor # presence only: no tokens, no network
95
+ agent-ops doctor --check-auth # one real print call per target
96
+ ```
97
+
98
+ `--check-auth` is a dedicated flag; `--yes` stays inert for doctor. Doctor
99
+ reports what to do but never fixes it: every target authenticates through
100
+ interactive OAuth, so there is no `--fix`. Run `<target> login` yourself.
101
+
34
102
  ### Project-local loop profile
35
103
 
36
104
  `--profile loop` is an opt-in project-scope profile. Select `codex`, `claude`,
@@ -22,11 +22,44 @@ A review result MUST preserve PASS, FAIL, or NOT_RUN and MUST NOT convert NOT_RU
22
22
 
23
23
  ## REVIEW-HARNESS-001
24
24
 
25
- A review invocation MUST resolve to exactly one concrete harness, even when an
26
- installation supports multiple harnesses.
25
+ A review invocation MUST resolve to exactly one concrete review target, even
26
+ when an installation supports multiple harnesses.
27
27
 
28
28
  - Trigger: Running `review` with a harness selection.
29
- - Action: Select one of `codex`, `claude`, or `opencode`; keep multi-harness installation separate from review execution.
29
+ - Action: Select one of `codex`, `agy`, or `claude`; keep multi-harness installation separate from review execution.
30
30
  - Evidence: Argument parsing rejects `all`, `both`, and comma-separated multi-harness values for review.
31
- - Positive: `review --harness opencode` resolves one harness.
31
+ - Positive: `review --harness claude` resolves one target.
32
32
  - Negative: `Run one review invocation against every installed harness implicitly.`
33
+
34
+ ## REVIEW-READONLY-001
35
+
36
+ A review target MUST be launched with its own read-only mechanism, and a target
37
+ without one MUST be skipped rather than run unsandboxed.
38
+
39
+ - Trigger: Building a review invocation for a configured target.
40
+ - Action: Pass `-s read-only` (codex), `--sandbox --mode plan` (agy), or `--permission-mode plan` (claude); treat any other target as ineligible.
41
+ - Evidence: The spawned argv contains the target's read-only flags.
42
+ - Positive: `opencode is not a review target: --agent plan silently falls back to a writable agent.`
43
+ - Negative: `Trust the prompt to stop the reviewer from editing files.`
44
+
45
+ ## REVIEW-CHAIN-001
46
+
47
+ Configured targets form an ordered fallback chain that MUST advance only when
48
+ no review happened, and MUST NOT advance past a verdict.
49
+
50
+ - Trigger: A configured target is missing, fails to spawn, or times out.
51
+ - Action: Try the next target; on PASS, FAIL, or unparseable output, stop and report that outcome.
52
+ - Evidence: The number of spawned attempts matches the failures that preceded the verdict.
53
+ - Positive: `codex FAIL is final; agy is never asked for a second opinion.`
54
+ - Negative: `Retry other targets after a FAIL until one reports PASS.`
55
+
56
+ ## REVIEW-CONTRACT-001
57
+
58
+ A response that breaks the reply contract MUST be reported as NOT_RUN, not as
59
+ FAIL.
60
+
61
+ - Trigger: The reviewer omits, duplicates, or invents a criterion, or returns blank evidence.
62
+ - Action: Report `NOT_RUN` with reason `unparseable-output`, write no evidence, and keep FAIL for judged inadequacy.
63
+ - Evidence: The result reason distinguishes a protocol violation from a verdict.
64
+ - Positive: `NOT_RUN: unparseable-output; one criterion was missing.`
65
+ - Negative: `Record a failed review because the model's JSON was malformed.`