@kylecheng3146/agent-ops 0.1.21 → 0.1.22

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.
@@ -348,7 +348,9 @@ export function parseArgs(argv) {
348
348
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--check-auth may be used only with doctor.");
349
349
  }
350
350
  if (reviewTargets.length > 0 && command !== "init") {
351
- throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--review-target may be used only with init.");
351
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", command === "review"
352
+ ? "--review-target configures init; for one review run, use --harness <target>."
353
+ : "--review-target may be used only with init.");
352
354
  }
353
355
  if (completionGate !== undefined && command !== "init") {
354
356
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--completion-gate may be used only with init.");
@@ -412,7 +414,9 @@ export function parseArgs(argv) {
412
414
  (criteria.length > 0 || evidence.length > 0)) {
413
415
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Review criteria and evidence require --task.");
414
416
  }
415
- if (command === "review" && harness !== undefined && harness.length !== 1) {
417
+ if (command === "review" &&
418
+ harness !== undefined &&
419
+ (harness.length !== 1 || !REVIEW_TARGETS.has(harness[0] ?? ""))) {
416
420
  invalidValue("--harness", harness.join(","));
417
421
  }
418
422
  if (command === "allow-stop" &&
@@ -29,9 +29,9 @@ import { formatInstallPlan, runInitCommand } from "./commands/init.js";
29
29
  import { formatUninstallPlan, runUninstallCommand } from "./commands/uninstall.js";
30
30
  import { runTaskCommand } from "./commands/task.js";
31
31
  import { runReviewCommand } from "./commands/review.js";
32
- import { createReviewExecutor } from "../../../runtime/src/review/execute.js";
32
+ import { createReviewExecutor, ReviewInterruptedError } from "../../../runtime/src/review/execute.js";
33
33
  import { probeReviewTarget } from "../../../runtime/src/review/probe.js";
34
- import { resolveReviewRole } from "../../../runtime/src/review/roles.js";
34
+ import { detectHostTarget, orderChain, resolveReviewRole } from "../../../runtime/src/review/roles.js";
35
35
  import { runTrustCommand } from "./commands/trust.js";
36
36
  import { runVerifyCommand } from "./commands/verify.js";
37
37
  import { runAllowStopCommand } from "./commands/allow-stop.js";
@@ -383,42 +383,69 @@ else {
383
383
  const reviewSessionId = process.env.AGENT_OPS_SESSION_ID;
384
384
  const reviewConfig = (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config;
385
385
  const reviewRole = resolveReviewRole("independent-review", reviewConfig.reviewRoles ?? []);
386
- return await runReviewCommand({
387
- args,
388
- authorized: args.yes,
389
- tasks: taskService,
390
- ...(args.taskId === undefined ? {} : { taskId: args.taskId }),
391
- ...(reviewSessionId === undefined
392
- ? {}
393
- : { sessionId: reviewSessionId }),
394
- ...(reviewConfig.reviewRoles === undefined
395
- ? {}
396
- : { roles: reviewConfig.reviewRoles }),
397
- root,
398
- gitRunner: gitRunner(root),
399
- policyConfigHash: calculateConfigHash(reviewConfig),
400
- currentPolicyConfigHash: async () => calculateConfigHash((await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config),
401
- config: reviewConfig,
402
- evidenceStore: new FileEvidenceStore(root, root),
403
- execute: createReviewExecutor({
404
- targets: reviewRole?.targets ?? [],
405
- cwd: root,
406
- ...(reviewRole?.model === undefined
407
- ? {}
408
- : { model: reviewRole.model }),
409
- ...(reviewRole?.effort === undefined
386
+ const selectedReviewTarget = args.harness?.[0];
387
+ const plannedReviewTargets = orderChain(selectedReviewTarget === undefined
388
+ ? reviewRole?.targets ?? []
389
+ : [selectedReviewTarget], detectHostTarget(process.env));
390
+ const controller = new AbortController();
391
+ let interruptedBy;
392
+ const interrupt = (signal) => {
393
+ interruptedBy ??= signal;
394
+ controller.abort(signal);
395
+ };
396
+ const onSigint = () => interrupt("SIGINT");
397
+ const onSigterm = () => interrupt("SIGTERM");
398
+ process.once("SIGINT", onSigint);
399
+ process.once("SIGTERM", onSigterm);
400
+ try {
401
+ return await runReviewCommand({
402
+ args,
403
+ authorized: args.yes,
404
+ tasks: taskService,
405
+ ...(args.taskId === undefined ? {} : { taskId: args.taskId }),
406
+ ...(reviewSessionId === undefined
410
407
  ? {}
411
- : { effort: reviewRole.effort }),
412
- ...(reviewRole?.timeoutMs === undefined
408
+ : { sessionId: reviewSessionId }),
409
+ ...(reviewConfig.reviewRoles === undefined
413
410
  ? {}
414
- : { timeoutMs: reviewRole.timeoutMs }),
415
- onProgress: (line) => {
416
- if (!args.json) {
411
+ : { roles: reviewConfig.reviewRoles }),
412
+ targets: plannedReviewTargets,
413
+ root,
414
+ gitRunner: gitRunner(root),
415
+ policyConfigHash: calculateConfigHash(reviewConfig),
416
+ currentPolicyConfigHash: async () => calculateConfigHash((await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config),
417
+ config: reviewConfig,
418
+ evidenceStore: new FileEvidenceStore(root, root),
419
+ execute: createReviewExecutor({
420
+ targets: plannedReviewTargets,
421
+ cwd: root,
422
+ ...(reviewRole?.model === undefined
423
+ ? {}
424
+ : { model: reviewRole.model }),
425
+ ...(reviewRole?.effort === undefined
426
+ ? {}
427
+ : { effort: reviewRole.effort }),
428
+ ...(reviewRole?.timeoutMs === undefined
429
+ ? {}
430
+ : { timeoutMs: reviewRole.timeoutMs }),
431
+ signal: controller.signal,
432
+ onProgress: (line) => {
417
433
  process.stderr.write(`${line}\n`);
418
434
  }
419
- }
420
- })
421
- });
435
+ })
436
+ });
437
+ }
438
+ catch (error) {
439
+ if (error instanceof ReviewInterruptedError &&
440
+ interruptedBy !== undefined) {
441
+ process.exit(interruptedBy === "SIGINT" ? 130 : 143);
442
+ }
443
+ throw error;
444
+ }
445
+ finally {
446
+ process.removeListener("SIGINT", onSigint);
447
+ process.removeListener("SIGTERM", onSigterm);
448
+ }
422
449
  }
423
450
  if (args.command === "config") {
424
451
  return explainConfigCommand(await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project"));
@@ -32,7 +32,7 @@ Commands:
32
32
 
33
33
  Options:
34
34
  --scope <project|user>
35
- --harness <all|both|agy|claude|codex|opencode|comma-separated> Init/update/uninstall
35
+ --harness <all|both|agy|claude|codex|opencode|comma-separated> Init/update/uninstall; review accepts one configured target
36
36
  --hook-target <harness=surface-id> Repeatable advanced init/update option
37
37
  --profile <core|advisory|guardrails|loop> Repeatable
38
38
  --review-target <codex|agy|claude> Repeatable init option; external review
@@ -213,6 +213,9 @@ function sourceChangedResult(result) {
213
213
  model: result.model,
214
214
  effort: result.effort,
215
215
  prompt: result.prompt,
216
+ ...(result.plannedTargets === undefined
217
+ ? {}
218
+ : { plannedTargets: result.plannedTargets }),
216
219
  ...(result.scope === undefined ? {} : { scope: result.scope }),
217
220
  ...(result.independence === undefined
218
221
  ? {}
@@ -229,7 +232,22 @@ function sourceChangedResult(result) {
229
232
  export async function runReviewCommand(options) {
230
233
  const role = resolveReviewRole(options.role ?? "independent-review", options.roles ?? []);
231
234
  const selectedHarness = harness(options.args.harness);
232
- const target = role?.targets[0] ?? selectedHarness ?? "codex";
235
+ const configuredTargets = role?.targets ?? [];
236
+ if (selectedHarness !== undefined &&
237
+ !configuredTargets.includes(selectedHarness)) {
238
+ throw new AgentOpsError("REVIEW_TARGET_NOT_CONFIGURED", `${selectedHarness} is not enabled for independent-review. Configure it with init --review-target ${selectedHarness}.`);
239
+ }
240
+ const plannedTargets = selectedHarness === undefined
241
+ ? options.targets ?? configuredTargets
242
+ : [selectedHarness];
243
+ const target = plannedTargets[0] ?? selectedHarness ?? "codex";
244
+ const resultBase = {
245
+ harness: target,
246
+ plannedTargets,
247
+ model: role?.model ?? options.model ?? "configured",
248
+ effort: role?.effort ?? options.effort ?? "configured",
249
+ prompt: ""
250
+ };
233
251
  const context = await taskContext(options);
234
252
  const evidenceRequirements = (options.args.evidence ?? []).map((value) => {
235
253
  const separator = value.indexOf("=");
@@ -253,24 +271,18 @@ export async function runReviewCommand(options) {
253
271
  const reason = scopeReason(error);
254
272
  if (reason !== undefined) {
255
273
  return notRunEnvelope({
274
+ ...resultBase,
256
275
  status: "NOT_RUN",
257
- reason,
258
- harness: target,
259
- model: role?.model ?? options.model ?? "configured",
260
- effort: role?.effort ?? options.effort ?? "configured",
261
- prompt: ""
276
+ reason
262
277
  });
263
278
  }
264
279
  throw error;
265
280
  }
266
281
  if (scope.changedFiles.some(isReviewerPolicyPath)) {
267
282
  return notRunEnvelope({
283
+ ...resultBase,
268
284
  status: "NOT_RUN",
269
285
  reason: "reviewer-policy-changed",
270
- harness: target,
271
- model: role?.model ?? options.model ?? "configured",
272
- effort: role?.effort ?? options.effort ?? "configured",
273
- prompt: "",
274
286
  scope
275
287
  });
276
288
  }
@@ -278,16 +290,16 @@ export async function runReviewCommand(options) {
278
290
  if (context !== undefined && options.policyConfigHash !== undefined) {
279
291
  if (context.policyConfigHash === null) {
280
292
  return notRunEnvelope({
293
+ ...resultBase,
281
294
  status: "NOT_RUN", reason: "reviewer-policy-baseline-missing",
282
- harness: target, model: role?.model ?? options.model ?? "configured",
283
- effort: role?.effort ?? options.effort ?? "configured", prompt: "", scope
295
+ scope
284
296
  });
285
297
  }
286
298
  if (context.policyConfigHash !== options.policyConfigHash) {
287
299
  return notRunEnvelope({
300
+ ...resultBase,
288
301
  status: "NOT_RUN", reason: "reviewer-policy-changed",
289
- harness: target, model: role?.model ?? options.model ?? "configured",
290
- effort: role?.effort ?? options.effort ?? "configured", prompt: "", scope
302
+ scope
291
303
  });
292
304
  }
293
305
  }
@@ -295,9 +307,9 @@ export async function runReviewCommand(options) {
295
307
  const preflight = await preflightReview(options, context, sourceFingerprint);
296
308
  if (!preflight.ok) {
297
309
  return notRunEnvelope({
310
+ ...resultBase,
298
311
  status: "NOT_RUN", reason: preflight.reason,
299
- harness: target, model: role?.model ?? options.model ?? "configured",
300
- effort: role?.effort ?? options.effort ?? "configured", prompt: "", scope
312
+ scope
301
313
  });
302
314
  }
303
315
  verification = preflight.summary;
@@ -324,12 +336,9 @@ export async function runReviewCommand(options) {
324
336
  : undefined;
325
337
  if (reason !== undefined) {
326
338
  return notRunEnvelope({
339
+ ...resultBase,
327
340
  status: "NOT_RUN",
328
- reason,
329
- harness: target,
330
- model: role?.model ?? options.model ?? "configured",
331
- effort: role?.effort ?? options.effort ?? "configured",
332
- prompt: ""
341
+ reason
333
342
  });
334
343
  }
335
344
  }
@@ -338,6 +347,7 @@ export async function runReviewCommand(options) {
338
347
  const result = await runIndependentReview({
339
348
  invocation: {
340
349
  harness: target,
350
+ plannedTargets,
341
351
  model: role?.model ?? options.model ?? "configured",
342
352
  effort: role?.effort ?? options.effort ?? "configured",
343
353
  packet,
@@ -15,6 +15,14 @@ import { buildAdversarialPrompt, buildReviewPrompt } from "./runner.js";
15
15
  * review while looking like an unavailable target.
16
16
  */
17
17
  export const DEFAULT_REVIEW_TIMEOUT_MS = 900_000;
18
+ export class ReviewInterruptedError extends Error {
19
+ signal;
20
+ constructor(signal) {
21
+ super("Independent review was interrupted.");
22
+ this.name = "ReviewInterruptedError";
23
+ this.signal = signal;
24
+ }
25
+ }
18
26
  // USER is load-bearing, not cosmetic: a credential store keyed by account name
19
27
  // — the macOS keychain claude reads — cannot be opened without it, and its
20
28
  // absence surfaces as "Not logged in" on an install that is logged in.
@@ -81,8 +89,7 @@ const REQUIRED_HELP_FLAGS = {
81
89
  */
82
90
  const ADVANCING = new Set([
83
91
  "missing-executable",
84
- "spawn-failed",
85
- "timeout"
92
+ "spawn-failed"
86
93
  ]);
87
94
  const DIAGNOSTIC_MAX_CHARS = 200;
88
95
  /**
@@ -114,6 +121,16 @@ function rejectedCallReason(output) {
114
121
  }
115
122
  return "capability-unavailable";
116
123
  }
124
+ function throwIfInterrupted(target, options, failureClass) {
125
+ if (failureClass !== "aborted" && options.signal?.aborted !== true) {
126
+ return;
127
+ }
128
+ const signal = typeof options.signal?.reason === "string"
129
+ ? options.signal.reason
130
+ : undefined;
131
+ options.onProgress?.(`${target}: review interrupted${signal === undefined ? "" : ` by ${signal}`}`);
132
+ throw new ReviewInterruptedError(signal);
133
+ }
117
134
  async function snapshotRepository(request, destination, options) {
118
135
  const cloned = await runVerificationCommand({
119
136
  id: `review-snapshot-${request.label}`,
@@ -125,8 +142,10 @@ async function snapshotRepository(request, destination, options) {
125
142
  timeoutMs: Math.min(options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS, 60_000)
126
143
  }, {
127
144
  cwd: dirname(destination),
128
- ...(options.runner === undefined ? {} : { runner: options.runner })
145
+ ...(options.runner === undefined ? {} : { runner: options.runner }),
146
+ ...(options.signal === undefined ? {} : { signal: options.signal })
129
147
  });
148
+ throwIfInterrupted(request.target, options, cloned.failureClass);
130
149
  if (cloned.status !== "PASS") {
131
150
  return firstComplaint(cloned.stderr, cloned.stdout) ??
132
151
  `git clone failed (${cloned.failureClass})`;
@@ -184,6 +203,8 @@ async function attemptTarget(request, options) {
184
203
  return skip("capability-unavailable", "no read-only mode is available for this target", "skipping");
185
204
  }
186
205
  const environment = isolatedReviewEnvironment(target, attemptDirectory, options.env ?? process.env);
206
+ throwIfInterrupted(target, options);
207
+ options.onProgress?.(`${target}: checking reviewer capability`);
187
208
  const capability = await runVerificationCommand({
188
209
  id: `review-capability-${request.label}`,
189
210
  command: invocation.command,
@@ -196,8 +217,10 @@ async function attemptTarget(request, options) {
196
217
  cwd: attemptDirectory,
197
218
  ...(options.runner === undefined ? {} : { runner: options.runner }),
198
219
  env: environment,
199
- replaceEnv: true
220
+ replaceEnv: true,
221
+ ...(options.signal === undefined ? {} : { signal: options.signal })
200
222
  });
223
+ throwIfInterrupted(target, options, capability.failureClass);
201
224
  const help = `${capability.stdout}\n${capability.stderr}`;
202
225
  const missingFlags = (REQUIRED_HELP_FLAGS[target] ?? []).filter((flag) => !help.includes(flag));
203
226
  if (capability.status !== "PASS" ||
@@ -238,6 +261,8 @@ async function attemptTarget(request, options) {
238
261
  if (invocation === undefined) {
239
262
  return skip("capability-unavailable", "review invocation disappeared", "skipping");
240
263
  }
264
+ throwIfInterrupted(target, options);
265
+ options.onProgress?.(`${target}: review started (timeout: ${Math.ceil((options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS) / 1_000)}s)`);
241
266
  const spawned = await runVerificationCommand({
242
267
  id: `review-${request.label}`,
243
268
  command: invocation.command,
@@ -254,8 +279,13 @@ async function attemptTarget(request, options) {
254
279
  : { outputLimitBytes: options.outputLimitBytes }),
255
280
  stdin: invocation.stdin,
256
281
  env: environment,
257
- replaceEnv: true
282
+ replaceEnv: true,
283
+ ...(options.signal === undefined ? {} : { signal: options.signal })
258
284
  });
285
+ throwIfInterrupted(target, options, spawned.failureClass);
286
+ if (spawned.failureClass === "timeout") {
287
+ return skip("timeout", "the reviewer exceeded its timeout");
288
+ }
259
289
  if (ADVANCING.has(spawned.failureClass)) {
260
290
  return {
261
291
  ...skip("missing-cli", `the process did not complete: ${spawned.failureClass}`),
@@ -273,6 +303,9 @@ async function attemptTarget(request, options) {
273
303
  return skip(rejectedCallReason(output), firstComplaint(spawned.stderr, spawned.stdout) ??
274
304
  `the call was rejected with exit ${spawned.exitCode ?? "unknown"} and no output`);
275
305
  }
306
+ if (spawned.failureClass === "signal-exit") {
307
+ return skip("capability-unavailable", `the reviewer exited after ${spawned.signal ?? "an unknown signal"}`);
308
+ }
276
309
  const payload = extractReviewObject(target, spawned.stdout);
277
310
  const parsed = payload === undefined
278
311
  ? undefined
@@ -343,8 +376,8 @@ export function createReviewExecutor(options) {
343
376
  prompt: buildAdversarialPrompt({ ...request.invocation, harness: target }, primary)
344
377
  }, options);
345
378
  if (outcome.kind === "skip") {
346
- // Recorded, not just reported: progress is suppressed under --json,
347
- // and without this a PASS with no `adversarial` field cannot be told
379
+ // Recorded, not just reported: stderr progress is transient, and
380
+ // without this a PASS with no `adversarial` field cannot be told
348
381
  // apart from a PASS that had no second target to challenge it.
349
382
  attempts.push({
350
383
  target,
@@ -7,9 +7,11 @@ function lineList(values) {
7
7
  return values.length === 0 ? ["- none"] : values.map((value) => `- ${safe(value)}`);
8
8
  }
9
9
  export function renderReviewResult(result) {
10
+ const plannedTargets = result.plannedTargets ?? [result.harness];
10
11
  const lines = [
11
12
  `Independent review: ${result.status}`,
12
- `Reviewer: ${result.harness}; model: ${safe(result.model)}; effort: ${safe(result.effort)}.`
13
+ `Reviewer: ${result.harness}; model: ${safe(result.model)}; effort: ${safe(result.effort)}.`,
14
+ `Planned reviewers: ${plannedTargets.length === 0 ? "none" : plannedTargets.join(" → ")}.`
13
15
  ];
14
16
  if (result.scope !== undefined) {
15
17
  lines.push(result.scope.mode === "base"
@@ -160,6 +160,7 @@ function safeReport(report) {
160
160
  export async function runIndependentReview(options) {
161
161
  const base = {
162
162
  harness: options.invocation.harness,
163
+ plannedTargets: options.invocation.plannedTargets ?? [options.invocation.harness],
163
164
  model: options.invocation.model,
164
165
  effort: options.invocation.effort,
165
166
  prompt: buildReviewPrompt(options.invocation),
@@ -270,6 +270,9 @@ function hasAcknowledgedShell(command) {
270
270
  export async function runVerificationCommand(command, options) {
271
271
  const now = options.now ?? Date.now;
272
272
  const startedAt = now();
273
+ if (options.signal?.aborted === true) {
274
+ return emptyResult(command.id, "aborted", elapsedMilliseconds(startedAt, now()));
275
+ }
273
276
  if (!hasAcknowledgedShell(command)) {
274
277
  return emptyResult(command.id, "shell-risk-unacknowledged", elapsedMilliseconds(startedAt, now()));
275
278
  }
@@ -298,21 +301,35 @@ export async function runVerificationCommand(command, options) {
298
301
  const timeout = new Promise((resolve) => {
299
302
  timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
300
303
  });
304
+ let removeAbortListener = () => { };
305
+ const aborted = new Promise((resolve) => {
306
+ const signal = options.signal;
307
+ if (signal === undefined) {
308
+ return;
309
+ }
310
+ const onAbort = () => resolve({ kind: "abort" });
311
+ signal.addEventListener("abort", onAbort, { once: true });
312
+ removeAbortListener = () => signal.removeEventListener("abort", onAbort);
313
+ });
301
314
  const outcome = await Promise.race([
302
315
  running.completion.then((completion) => ({
303
316
  kind: "completion",
304
317
  completion
305
318
  })),
306
- timeout
319
+ timeout,
320
+ aborted
307
321
  ]);
322
+ removeAbortListener();
308
323
  if (timer !== undefined) {
309
324
  clearTimeout(timer);
310
325
  }
311
326
  let completion;
312
327
  let timedOut = false;
328
+ let wasAborted = false;
313
329
  let terminationFailed = false;
314
- if (outcome.kind === "timeout") {
315
- timedOut = true;
330
+ if (outcome.kind === "timeout" || outcome.kind === "abort") {
331
+ timedOut = outcome.kind === "timeout";
332
+ wasAborted = outcome.kind === "abort";
316
333
  try {
317
334
  await running.terminateTree(terminationGrace);
318
335
  }
@@ -330,14 +347,19 @@ export async function runVerificationCommand(command, options) {
330
347
  settleCapturedOutput(stdout, terminationGrace),
331
348
  settleCapturedOutput(stderr, terminationGrace)
332
349
  ]);
333
- const classified = timedOut
350
+ const classified = wasAborted
334
351
  ? {
335
352
  status: terminationFailed ? "UNKNOWN" : "FAIL",
336
- failureClass: terminationFailed
337
- ? "termination-failed"
338
- : "timeout"
353
+ failureClass: "aborted"
339
354
  }
340
- : classifyCompletion(completion, capturedStdout.failed || capturedStderr.failed);
355
+ : timedOut
356
+ ? {
357
+ status: terminationFailed ? "UNKNOWN" : "FAIL",
358
+ failureClass: terminationFailed
359
+ ? "termination-failed"
360
+ : "timeout"
361
+ }
362
+ : classifyCompletion(completion, capturedStdout.failed || capturedStderr.failed);
341
363
  return {
342
364
  commandId: command.id,
343
365
  status: classified.status,
@@ -55,6 +55,12 @@ A bare review uses the built-in `change-quality` criterion; `--task` uses the
55
55
  task criteria and requires fresh PASS evidence for required checks. The full
56
56
  report is printed, and PASS persists only a source-fingerprint attestation.
57
57
 
58
+ `--review-target` belongs to `init` and configures that persistent chain.
59
+ For one run, `review --harness <target>` narrows the chain to exactly one
60
+ already-configured target; it never enables a target absent from project
61
+ policy. The configured model, effort, and timeout still apply. Review JSON
62
+ includes `plannedTargets` in the actual host-adjusted order.
63
+
58
64
  Every attempt starts from a fresh session and disposable repository clone with
59
65
  native read-only mode.
60
66
  Claude uses complete safe-mode isolation. Codex and Agy preserve their existing
@@ -88,6 +94,12 @@ failure, oversized output, or unparseable output. Every attempt and reason is
88
94
  preserved in human and JSON output. A `PASS` or `FAIL` verdict is **terminal**,
89
95
  so the chain cannot shop for a passing review.
90
96
 
97
+ Capability checks and model starts are reported on stderr, including under
98
+ `--json`; stdout remains one final JSON envelope and raw reviewer output is
99
+ never streamed. SIGINT or SIGTERM terminates the active reviewer process tree,
100
+ does not advance the fallback chain, and never writes an attestation. An
101
+ exhausted timeout chain reports `timeout`, not `missing-cli`.
102
+
91
103
  If Claude Code is the host (`CLAUDECODE` is set), `claude` is moved to the end
92
104
  of the chain. It still runs when it is the only configured target, with a
93
105
  `reviewer == host` warning.
@@ -31,6 +31,12 @@ when an installation supports multiple harnesses.
31
31
  - Positive: `review --harness claude` resolves one target.
32
32
  - Negative: `Run one review invocation against every installed harness implicitly.`
33
33
 
34
+ The explicit target MUST already exist in the configured independent-review
35
+ role. It narrows the configured chain to one target while preserving model,
36
+ effort, and timeout policy. Without `--harness`, host-aware ordering applies to
37
+ the complete configured chain. Every result carries that order as
38
+ `plannedTargets`.
39
+
34
40
  ## REVIEW-READONLY-001
35
41
 
36
42
  A review target MUST be launched with its own read-only mechanism, and a target
@@ -48,6 +54,11 @@ different CLI from the hosting CLI; when no other usable target exists,
48
54
  same-target fresh review is allowed but MUST render as `DEGRADED: isolated
49
55
  self-review`. A resumed development session is never an independent review.
50
56
 
57
+ Capability and model-start progress goes to stderr even when stdout is JSON.
58
+ Raw reviewer output remains bounded and unstreamed. SIGINT or SIGTERM aborts
59
+ the active process tree without fallback or attestation; timeout remains a
60
+ distinct NOT_RUN reason rather than being flattened to `missing-cli`.
61
+
51
62
  ## REVIEW-CHAIN-001
52
63
 
53
64
  Configured targets form an ordered fallback chain that MUST advance only when
@@ -49,6 +49,11 @@ Claude 與 Codex lifecycle support 為 `supported`,OpenCode 從 app initializa
49
49
  `--task` 使用 task criteria,並要求必要驗證的最新 PASS evidence。完整 report
50
50
  會顯示給人看,PASS 後只持久化 source-fingerprint attestation。
51
51
 
52
+ `--review-target` 只屬於 `init`,用來設定持久 fallback chain。單次執行可用
53
+ `review --harness <target>`,將 chain 縮窄為一個已在 project policy 啟用的
54
+ target;它不會臨時啟用未設定的 reviewer。既有 model、effort 與 timeout 仍會
55
+ 沿用,review JSON 的 `plannedTargets` 會列出經 host 調整後的實際順序。
56
+
52
57
  每次嘗試都從全新 session、一次性 repository clone 與原生唯讀模式啟動。Claude 使用完整 safe-mode
53
58
  隔離;Codex 與 Agy 為了支援既有 OAuth 登入而保留登入環境,因此 context
54
59
  隔離較弱。Agy 會取得一次性 clone,即使 sandboxed plan mode 寫入 cwd,也無法
@@ -77,6 +82,11 @@ agent-ops 刻意不傳會繞過權限邊界的 `--dangerously-skip-permissions`
77
82
  都會保留每次 attempt 及原因。`PASS` 或 `FAIL` 判定是**終局**,因此不會產生
78
83
  自動化的 review shopping。
79
84
 
85
+ Capability check 與模型啟動進度都寫到 stderr,包括 `--json` 模式;stdout
86
+ 仍只有最終 JSON envelope,且不會串流 reviewer 原始輸出。SIGINT 或 SIGTERM
87
+ 會終止目前 reviewer 的完整 process tree、不進入 fallback,也不寫入
88
+ attestation。整條 chain 逾時時回報 `timeout`,不會誤報 `missing-cli`。
89
+
80
90
  若 host 是 Claude Code(`CLAUDECODE` 已設定),`claude` 會被移到鏈尾。
81
91
  當它是唯一設定的目標時仍會執行,並附上 `reviewer == host` 警告。
82
92
 
@@ -32,6 +32,11 @@ English source version: 2026-07-23. Revalidate: when the English specification c
32
32
  - Positive: `review --harness claude` 解析成一個 target。
33
33
  - Negative: `讓一次 review invocation 隱式跑過所有已安裝 harness。`
34
34
 
35
+ 明確指定的 target MUST 已存在於 configured independent-review role。它只會將
36
+ configured chain 縮窄為單一 target,並保留 model、effort 與 timeout policy。
37
+ 未提供 `--harness` 時,完整 configured chain 仍套用 host-aware ordering;每個
38
+ 結果都以 `plannedTargets` 保存這個實際順序。
39
+
35
40
  ## REVIEW-READONLY-001
36
41
 
37
42
  review target MUST 以其自身的唯讀機制啟動;沒有唯讀機制的 target MUST 被跳過,而非在無沙箱狀態下執行。
@@ -47,6 +52,11 @@ disposable repository clone 中執行。review chain 優先選擇不同於 hosti
47
52
  target;沒有其他可用 target 時,才允許同 CLI 的 fresh review,但輸出 MUST 明確
48
53
  標示 `DEGRADED: isolated self-review`。不得 resume 開發 session 作為獨立審查。
49
54
 
55
+ Capability 與模型啟動進度即使在 JSON 模式也寫到 stderr;reviewer 原始輸出仍
56
+ 維持 bounded capture,不直接串流。SIGINT 或 SIGTERM 會中止 active process
57
+ tree,不 fallback、不寫 attestation;timeout 保留獨立 NOT_RUN reason,不得被
58
+ 扁平化為 `missing-cli`。
59
+
50
60
  ## REVIEW-CHAIN-001
51
61
 
52
62
  已設定的 targets 組成有序後備鏈,MUST 僅在「沒有審到」時換下一家,且 MUST NOT 在取得判定後繼續往下試。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Evidence-driven development loops for agy, Codex, Claude Code, and opencode",
5
5
  "type": "module",
6
6
  "license": "MIT",