@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.
- package/dist/packages/cli/src/args.js +6 -2
- package/dist/packages/cli/src/bin.js +61 -34
- package/dist/packages/cli/src/cli.js +1 -1
- package/dist/packages/cli/src/commands/review.js +31 -21
- package/dist/runtime/src/review/execute.js +40 -7
- package/dist/runtime/src/review/render.js +3 -1
- package/dist/runtime/src/review/runner.js +1 -0
- package/dist/runtime/src/verify/spawn.js +30 -8
- package/docs/en/guides/configuration.md +12 -0
- package/docs/en/spec/review.md +11 -0
- package/docs/zh-TW/guides/configuration.md +10 -0
- package/docs/zh-TW/spec/review.md +10 -0
- package/package.json +1 -1
|
@@ -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",
|
|
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" &&
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
...(
|
|
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
|
-
: {
|
|
412
|
-
...(
|
|
408
|
+
: { sessionId: reviewSessionId }),
|
|
409
|
+
...(reviewConfig.reviewRoles === undefined
|
|
413
410
|
? {}
|
|
414
|
-
: {
|
|
415
|
-
|
|
416
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
347
|
-
//
|
|
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 =
|
|
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 =
|
|
350
|
+
const classified = wasAborted
|
|
334
351
|
? {
|
|
335
352
|
status: terminationFailed ? "UNKNOWN" : "FAIL",
|
|
336
|
-
failureClass:
|
|
337
|
-
? "termination-failed"
|
|
338
|
-
: "timeout"
|
|
353
|
+
failureClass: "aborted"
|
|
339
354
|
}
|
|
340
|
-
:
|
|
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.
|
package/docs/en/spec/review.md
CHANGED
|
@@ -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 在取得判定後繼續往下試。
|