@kylecheng3146/agent-ops 0.2.1 → 0.2.3
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/README.md +26 -14
- package/dist/packages/cli/src/args.js +34 -11
- package/dist/packages/cli/src/bin.js +21 -9
- package/dist/packages/cli/src/cli.js +15 -13
- package/dist/packages/cli/src/commands/review.js +123 -32
- package/dist/runtime/src/adapters/claude/output.js +11 -1
- package/dist/runtime/src/hooks/completion-gate.js +40 -4
- package/dist/runtime/src/hooks/shell.js +290 -42
- package/dist/runtime/src/install/doctor.js +17 -7
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/review/attestation.js +180 -5
- package/dist/runtime/src/review/execute.js +183 -113
- package/dist/runtime/src/review/host-sandbox.js +3 -2
- package/dist/runtime/src/review/probe.js +3 -0
- package/dist/runtime/src/review/render.js +21 -6
- package/dist/runtime/src/review/roles.js +29 -20
- package/dist/runtime/src/review/runner.js +46 -31
- package/dist/runtime/src/task/completion.js +1 -1
- package/dist/runtime/src/task/service.js +8 -2
- package/dist/runtime/src/task/store.js +16 -7
- package/docs/en/guides/configuration.md +29 -27
- package/docs/en/spec/review.md +55 -28
- package/docs/zh-TW/guides/configuration.md +26 -24
- package/docs/zh-TW/spec/review.md +50 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -301,12 +301,12 @@ the project configuration defines those workflows.
|
|
|
301
301
|
|
|
302
302
|
### External review
|
|
303
303
|
|
|
304
|
-
`agent-ops review` can hand
|
|
305
|
-
judged by the agent that produced it.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
is
|
|
304
|
+
`agent-ops review --task <id> --yes` can hand a complete task-bound review to
|
|
305
|
+
agent CLIs, so the work is not judged only by the agent that produced it.
|
|
306
|
+
Enable review targets during `agent-ops init` (the default is off). Every task
|
|
307
|
+
criterion and its original description are reviewed; partial criteria,
|
|
308
|
+
generic reviews, and missing `--yes` are rejected. Current verification
|
|
309
|
+
evidence is required before any reviewer starts.
|
|
310
310
|
|
|
311
311
|
Each attempt uses a fresh temporary session and disposable repository clone, a
|
|
312
312
|
small allowlisted environment, and a target-native read-only mode. Claude
|
|
@@ -315,14 +315,26 @@ persistence, while Agy runs in sandboxed plan mode. Codex and Agy preserve their
|
|
|
315
315
|
existing login environment, so their context isolation is intentionally weaker
|
|
316
316
|
than Claude's. `opencode` is not a review target.
|
|
317
317
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
and
|
|
325
|
-
|
|
318
|
+
Exactly two fresh sessions are planned. With three configured targets, the
|
|
319
|
+
explicit `AGENT_OPS_HOST` is excluded and `agy` is primary when available; with
|
|
320
|
+
two targets both run in configured order; with one target the same CLI is run
|
|
321
|
+
twice. The first session is the necessary review. Only its PASS starts the
|
|
322
|
+
second adversarial session, which receives the first full redacted report as
|
|
323
|
+
untrusted data. The first FAIL or NOT_RUN stops; a second FAIL is final FAIL,
|
|
324
|
+
and a missing second verdict is NOT_RUN. A same-target pair is still
|
|
325
|
+
independent because the sessions and clones are fresh.
|
|
326
|
+
|
|
327
|
+
Host network or loopback restrictions return `REVIEW_NOT_RUN` before any
|
|
328
|
+
reviewer is started. A trusted outer host runner may rerun the exact same
|
|
329
|
+
command once with both capabilities; the result remains NOT_RUN if that host
|
|
330
|
+
is unavailable. There is no automatic permission bypass inside the repository.
|
|
331
|
+
Reviewer attempts and preflight diagnostics remain visible in JSON. `--yes` is
|
|
332
|
+
required because a complete run spends provider quota twice.
|
|
333
|
+
|
|
334
|
+
After a complete PASS, the full redacted primary and adversarial reports are
|
|
335
|
+
stored privately under `.agent-ops/reviews/`; the source-fingerprint attestation
|
|
336
|
+
stores only the task, reviewer/session identities, report digests, and artifact
|
|
337
|
+
reference. Any artifact or attestation write failure is NOT_RUN, never PASS.
|
|
326
338
|
|
|
327
339
|
Authentication is diagnosed, never guessed:
|
|
328
340
|
|
|
@@ -74,6 +74,7 @@ export function parseArgs(argv) {
|
|
|
74
74
|
const criteria = [];
|
|
75
75
|
const evidence = [];
|
|
76
76
|
let checkAuth = false;
|
|
77
|
+
const checkAuthTargets = [];
|
|
77
78
|
let completionGate;
|
|
78
79
|
let dryRun = false;
|
|
79
80
|
let json = false;
|
|
@@ -205,6 +206,18 @@ export function parseArgs(argv) {
|
|
|
205
206
|
}
|
|
206
207
|
checkAuth = true;
|
|
207
208
|
break;
|
|
209
|
+
case "--check-auth-target": {
|
|
210
|
+
const value = readOptionValue(argv, index, token);
|
|
211
|
+
if (!REVIEW_TARGETS.has(value)) {
|
|
212
|
+
invalidValue(token, value);
|
|
213
|
+
}
|
|
214
|
+
if (checkAuthTargets.includes(value)) {
|
|
215
|
+
duplicate(`${token} ${value}`);
|
|
216
|
+
}
|
|
217
|
+
checkAuthTargets.push(value);
|
|
218
|
+
index += 1;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
208
221
|
case "--completion-gate":
|
|
209
222
|
if (completionGate !== undefined) {
|
|
210
223
|
duplicate(token);
|
|
@@ -322,6 +335,7 @@ export function parseArgs(argv) {
|
|
|
322
335
|
sessionId !== undefined ||
|
|
323
336
|
base !== undefined ||
|
|
324
337
|
checkAuth ||
|
|
338
|
+
checkAuthTargets.length > 0 ||
|
|
325
339
|
dryRun ||
|
|
326
340
|
yes) {
|
|
327
341
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Only --json may be combined with global help or version.");
|
|
@@ -367,9 +381,15 @@ export function parseArgs(argv) {
|
|
|
367
381
|
if (checkAuth && command !== "doctor") {
|
|
368
382
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--check-auth may be used only with doctor.");
|
|
369
383
|
}
|
|
384
|
+
if (checkAuthTargets.length > 0 && command !== "doctor") {
|
|
385
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--check-auth-target may be used only with doctor.");
|
|
386
|
+
}
|
|
387
|
+
if (checkAuthTargets.length > 0 && !checkAuth) {
|
|
388
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--check-auth-target requires --check-auth.");
|
|
389
|
+
}
|
|
370
390
|
if (reviewTargets.length > 0 && command !== "init") {
|
|
371
391
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", command === "review"
|
|
372
|
-
? "--review-target configures init;
|
|
392
|
+
? "--review-target configures init; review uses the configured reviewer pair."
|
|
373
393
|
: "--review-target may be used only with init.");
|
|
374
394
|
}
|
|
375
395
|
if (completionGate !== undefined && command !== "init") {
|
|
@@ -425,18 +445,20 @@ export function parseArgs(argv) {
|
|
|
425
445
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Verify accepts only scope, task or session, base, and json options.");
|
|
426
446
|
}
|
|
427
447
|
if (command === "review" &&
|
|
428
|
-
(title !== undefined || sessionId !== undefined)) {
|
|
429
|
-
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Review accepts
|
|
448
|
+
(title !== undefined || sessionId !== undefined || dryRun)) {
|
|
449
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Review accepts task, base, json, and yes options.");
|
|
430
450
|
}
|
|
431
|
-
if (command === "review" &&
|
|
432
|
-
|
|
433
|
-
(criteria.length > 0 || evidence.length > 0)) {
|
|
434
|
-
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Review criteria and evidence require --task.");
|
|
451
|
+
if (command === "review" && taskId === undefined) {
|
|
452
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Review requires --task for the complete task-bound review.");
|
|
435
453
|
}
|
|
436
|
-
if (command === "review" &&
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
454
|
+
if (command === "review" && !yes) {
|
|
455
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Review requires --yes to authorize both reviewer sessions.");
|
|
456
|
+
}
|
|
457
|
+
if (command === "review" && harness !== undefined) {
|
|
458
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Complete review uses the configured reviewer pair; omit --harness.");
|
|
459
|
+
}
|
|
460
|
+
if (command === "review" && (criteria.length > 0 || evidence.length > 0)) {
|
|
461
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Complete review covers every task criterion; omit --criterion and --evidence.");
|
|
440
462
|
}
|
|
441
463
|
if (command === "allow-stop" &&
|
|
442
464
|
(sessionId === undefined ||
|
|
@@ -469,6 +491,7 @@ export function parseArgs(argv) {
|
|
|
469
491
|
...(sessionId === undefined ? {} : { sessionId }),
|
|
470
492
|
...(base === undefined ? {} : { base }),
|
|
471
493
|
...(checkAuth ? { checkAuth } : {}),
|
|
494
|
+
...(checkAuthTargets.length === 0 ? {} : { checkAuthTargets }),
|
|
472
495
|
dryRun,
|
|
473
496
|
json,
|
|
474
497
|
yes
|
|
@@ -15,6 +15,7 @@ import { FileTrustStore } from "../../../runtime/src/security/trust.js";
|
|
|
15
15
|
import { localStatePaths } from "../../../runtime/src/security/permissions.js";
|
|
16
16
|
import { calculateConfigHash } from "../../../runtime/src/config/hash.js";
|
|
17
17
|
import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
|
|
18
|
+
import { calculateSourceFingerprint } from "../../../runtime/src/verify/source-fingerprint.js";
|
|
18
19
|
import { VerificationService } from "../../../runtime/src/verify/service.js";
|
|
19
20
|
import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
|
|
20
21
|
import { runCli } from "./cli.js";
|
|
@@ -31,7 +32,8 @@ import { runTaskCommand } from "./commands/task.js";
|
|
|
31
32
|
import { runReviewCommand } from "./commands/review.js";
|
|
32
33
|
import { createReviewExecutor, ReviewInterruptedError } from "../../../runtime/src/review/execute.js";
|
|
33
34
|
import { probeReviewTarget } from "../../../runtime/src/review/probe.js";
|
|
34
|
-
import {
|
|
35
|
+
import { resolveReviewScope } from "../../../runtime/src/review/scope.js";
|
|
36
|
+
import { resolveReviewRole } from "../../../runtime/src/review/roles.js";
|
|
35
37
|
import { runTrustCommand } from "./commands/trust.js";
|
|
36
38
|
import { runVerifyCommand } from "./commands/verify.js";
|
|
37
39
|
import { runAllowStopCommand } from "./commands/allow-stop.js";
|
|
@@ -308,7 +310,10 @@ else {
|
|
|
308
310
|
},
|
|
309
311
|
...(args.checkAuth === true
|
|
310
312
|
? { checkReviewTargetAuth: true }
|
|
311
|
-
: {})
|
|
313
|
+
: {}),
|
|
314
|
+
...(args.checkAuthTargets === undefined
|
|
315
|
+
? {}
|
|
316
|
+
: { checkAuthTargets: args.checkAuthTargets })
|
|
312
317
|
});
|
|
313
318
|
}
|
|
314
319
|
if (args.command === "uninstall") {
|
|
@@ -386,12 +391,10 @@ else {
|
|
|
386
391
|
}
|
|
387
392
|
if (args.command === "review") {
|
|
388
393
|
const reviewSessionId = process.env.AGENT_OPS_SESSION_ID;
|
|
394
|
+
const reviewGit = gitRunner(root);
|
|
389
395
|
const reviewConfig = (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config;
|
|
390
396
|
const reviewRole = resolveReviewRole("independent-review", reviewConfig.reviewRoles ?? []);
|
|
391
|
-
const
|
|
392
|
-
const plannedReviewTargets = orderChain(selectedReviewTarget === undefined
|
|
393
|
-
? reviewRole?.targets ?? []
|
|
394
|
-
: [selectedReviewTarget], detectHostTarget(process.env));
|
|
397
|
+
const configuredReviewTargets = reviewRole?.targets ?? [];
|
|
395
398
|
const controller = new AbortController();
|
|
396
399
|
let interruptedBy;
|
|
397
400
|
const interrupt = (signal) => {
|
|
@@ -414,15 +417,15 @@ else {
|
|
|
414
417
|
...(reviewConfig.reviewRoles === undefined
|
|
415
418
|
? {}
|
|
416
419
|
: { roles: reviewConfig.reviewRoles }),
|
|
417
|
-
targets:
|
|
420
|
+
targets: configuredReviewTargets,
|
|
418
421
|
root,
|
|
419
|
-
gitRunner:
|
|
422
|
+
gitRunner: reviewGit,
|
|
420
423
|
policyConfigHash: calculateConfigHash(reviewConfig),
|
|
421
424
|
currentPolicyConfigHash: async () => calculateConfigHash((await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config),
|
|
422
425
|
config: reviewConfig,
|
|
423
426
|
evidenceStore: new FileEvidenceStore(root, root),
|
|
424
427
|
execute: createReviewExecutor({
|
|
425
|
-
targets:
|
|
428
|
+
targets: configuredReviewTargets,
|
|
426
429
|
cwd: root,
|
|
427
430
|
...(reviewRole?.model === undefined
|
|
428
431
|
? {}
|
|
@@ -433,6 +436,15 @@ else {
|
|
|
433
436
|
...(reviewRole?.timeoutMs === undefined
|
|
434
437
|
? {}
|
|
435
438
|
: { timeoutMs: reviewRole.timeoutMs }),
|
|
439
|
+
preflightTarget: async (target) => await probeReviewTarget(target, { cwd: root, deep: true }),
|
|
440
|
+
verifySourceFingerprint: async (expected) => {
|
|
441
|
+
const currentScope = await resolveReviewScope({
|
|
442
|
+
root,
|
|
443
|
+
runner: reviewGit,
|
|
444
|
+
...(args.base === undefined ? {} : { base: args.base })
|
|
445
|
+
});
|
|
446
|
+
return await calculateSourceFingerprint(root, currentScope, reviewGit) === expected;
|
|
447
|
+
},
|
|
436
448
|
signal: controller.signal,
|
|
437
449
|
onProgress: (line) => {
|
|
438
450
|
process.stderr.write(`${line}\n`);
|
|
@@ -32,14 +32,14 @@ Commands:
|
|
|
32
32
|
|
|
33
33
|
Options:
|
|
34
34
|
--scope <project|user>
|
|
35
|
-
--harness <all|both|agy|claude|codex|opencode|comma-separated> Init/update/uninstall; review
|
|
35
|
+
--harness <all|both|agy|claude|codex|opencode|comma-separated> Init/update/uninstall; review uses configured targets
|
|
36
36
|
--hook-target <harness=surface-id> Repeatable advanced init/update option
|
|
37
37
|
--profile <core|advisory|guardrails|loop> Repeatable
|
|
38
|
-
--review-target <codex|agy|claude> Repeatable init option;
|
|
39
|
-
targets in fallback-chain order
|
|
38
|
+
--review-target <codex|agy|claude> Repeatable init option; review pair order
|
|
40
39
|
--completion-gate Init only: enable the project-loop completion gate
|
|
41
|
-
--check-auth Doctor only: probe
|
|
40
|
+
--check-auth Doctor only: probe selected review targets'
|
|
42
41
|
authentication with one real call
|
|
42
|
+
--check-auth-target <target> Repeatable doctor filter with --check-auth
|
|
43
43
|
--task <id>
|
|
44
44
|
--parent <task-id> Task create: record a subtask of this
|
|
45
45
|
task; task status: list its subtasks
|
|
@@ -159,18 +159,16 @@ Options:
|
|
|
159
159
|
--base <git-ref> Verify a clean committed range
|
|
160
160
|
--json
|
|
161
161
|
`,
|
|
162
|
-
review: `Usage: agent-ops review [options]
|
|
162
|
+
review: `Usage: agent-ops review --task <id> --yes [options]
|
|
163
163
|
|
|
164
|
-
Run
|
|
164
|
+
Run the complete task-bound review: one necessary reviewer followed by one
|
|
165
|
+
fresh adversarial reviewer from the configured pair.
|
|
165
166
|
|
|
166
167
|
Options:
|
|
167
|
-
--task <id>
|
|
168
|
-
--
|
|
169
|
-
--criterion <id> Repeatable: review only these task criteria
|
|
170
|
-
--harness <target> One configured review target
|
|
171
|
-
--base <git-ref>
|
|
168
|
+
--task <id> Required task whose original criteria are reviewed
|
|
169
|
+
--base <git-ref> Review a clean committed range
|
|
172
170
|
--json
|
|
173
|
-
--yes
|
|
171
|
+
--yes Required authorization for both reviewer sessions
|
|
174
172
|
`,
|
|
175
173
|
"allow-stop": `Usage: agent-ops allow-stop --session <id> [options]
|
|
176
174
|
|
|
@@ -234,7 +232,11 @@ export async function runCli(argv, io, services) {
|
|
|
234
232
|
return writeAndReturn(io, errorEnvelope("CLI_COMMAND_UNAVAILABLE", `Command is not implemented yet: ${args.command}`), args.json, 1);
|
|
235
233
|
}
|
|
236
234
|
const result = await execute(args);
|
|
237
|
-
return writeAndReturn(io, result, args.json, result.status === "ok"
|
|
235
|
+
return writeAndReturn(io, result, args.json, result.status === "ok"
|
|
236
|
+
? 0
|
|
237
|
+
: result.code === "REVIEW_NOT_RUN"
|
|
238
|
+
? 2
|
|
239
|
+
: 1);
|
|
238
240
|
}
|
|
239
241
|
catch (error) {
|
|
240
242
|
if (error instanceof CliArgumentError) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { buildReviewPacket } from "../../../../runtime/src/review/packet.js";
|
|
2
2
|
import { runIndependentReview } from "../../../../runtime/src/review/runner.js";
|
|
3
3
|
import { renderReviewResult } from "../../../../runtime/src/review/render.js";
|
|
4
|
-
import { invalidateReviewAttestation, saveReviewAttestation } from "../../../../runtime/src/review/attestation.js";
|
|
5
|
-
import { resolveReviewRole } from "../../../../runtime/src/review/roles.js";
|
|
4
|
+
import { invalidateReviewAttestation, reviewReportDigest, saveReviewAttestation, saveReviewReportArtifact } from "../../../../runtime/src/review/attestation.js";
|
|
5
|
+
import { detectHostTarget, planReviewTargets, resolveReviewRole } from "../../../../runtime/src/review/roles.js";
|
|
6
6
|
import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
|
|
7
7
|
import { assertSafeSupportingPaths, isReviewerPolicyPath, resolveReviewScope, reviewScopeSignature } from "../../../../runtime/src/review/scope.js";
|
|
8
8
|
import { calculateSourceFingerprint } from "../../../../runtime/src/verify/source-fingerprint.js";
|
|
@@ -222,6 +222,12 @@ function sourceChangedResult(result) {
|
|
|
222
222
|
...(result.plannedTargets === undefined
|
|
223
223
|
? {}
|
|
224
224
|
: { plannedTargets: result.plannedTargets }),
|
|
225
|
+
...(result.sourceFingerprint === undefined
|
|
226
|
+
? {}
|
|
227
|
+
: { sourceFingerprint: result.sourceFingerprint }),
|
|
228
|
+
...(result.hostTarget === undefined
|
|
229
|
+
? {}
|
|
230
|
+
: { hostTarget: result.hostTarget }),
|
|
225
231
|
...(result.scope === undefined ? {} : { scope: result.scope }),
|
|
226
232
|
...(result.independence === undefined
|
|
227
233
|
? {}
|
|
@@ -230,11 +236,90 @@ function sourceChangedResult(result) {
|
|
|
230
236
|
? {}
|
|
231
237
|
: { sessionIsolation: result.sessionIsolation }),
|
|
232
238
|
...(result.attempts === undefined ? {} : { attempts: result.attempts }),
|
|
239
|
+
...(result.report === undefined ? {} : { report: result.report }),
|
|
240
|
+
...(result.adversarial === undefined
|
|
241
|
+
? {}
|
|
242
|
+
: { adversarial: result.adversarial }),
|
|
233
243
|
...(result.verification === undefined
|
|
234
244
|
? {}
|
|
235
245
|
: { verification: result.verification })
|
|
236
246
|
};
|
|
237
247
|
}
|
|
248
|
+
function hasCompleteReviewEvidence(result) {
|
|
249
|
+
const attempts = result.attempts;
|
|
250
|
+
const plannedTargets = result.plannedTargets;
|
|
251
|
+
return result.status === "PASS" &&
|
|
252
|
+
result.report !== undefined &&
|
|
253
|
+
result.adversarial !== undefined &&
|
|
254
|
+
result.adversarial.refuted === false &&
|
|
255
|
+
result.sessionIsolation === "fresh" &&
|
|
256
|
+
plannedTargets !== undefined &&
|
|
257
|
+
plannedTargets.length === 2 &&
|
|
258
|
+
result.harness === plannedTargets[0] &&
|
|
259
|
+
result.adversarial.target === plannedTargets[1] &&
|
|
260
|
+
attempts !== undefined &&
|
|
261
|
+
attempts.length === 2 &&
|
|
262
|
+
attempts.every((attempt, index) => attempt.target === plannedTargets[index] &&
|
|
263
|
+
attempt.status === "PASS" &&
|
|
264
|
+
attempt.sessionId !== undefined);
|
|
265
|
+
}
|
|
266
|
+
async function persistReviewEvidence(options, result, sourceFingerprint, context) {
|
|
267
|
+
if (options.root === undefined || sourceFingerprint === undefined) {
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
270
|
+
const complete = hasCompleteReviewEvidence(result);
|
|
271
|
+
const artifactResult = result.status === "PASS" && !complete
|
|
272
|
+
? {
|
|
273
|
+
...result,
|
|
274
|
+
status: "NOT_RUN",
|
|
275
|
+
reason: "adversarial-review-missing"
|
|
276
|
+
}
|
|
277
|
+
: result;
|
|
278
|
+
let reportArtifact;
|
|
279
|
+
try {
|
|
280
|
+
reportArtifact = await saveReviewReportArtifact(options.root, artifactResult, sourceFingerprint, context?.taskId);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return {
|
|
284
|
+
...result,
|
|
285
|
+
status: "NOT_RUN",
|
|
286
|
+
reason: "evidence-write-failed"
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if (!complete) {
|
|
290
|
+
return artifactResult;
|
|
291
|
+
}
|
|
292
|
+
if (result.status !== "PASS" || context?.allCriteriaReviewed === false) {
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const attempts = result.attempts;
|
|
297
|
+
const plannedTargets = result.plannedTargets;
|
|
298
|
+
await saveReviewAttestation(options.root, {
|
|
299
|
+
schemaVersion: 2,
|
|
300
|
+
...(context === undefined ? {} : { taskId: context.taskId }),
|
|
301
|
+
harness: result.harness,
|
|
302
|
+
status: "PASS",
|
|
303
|
+
sourceFingerprint,
|
|
304
|
+
reviewTargets: [plannedTargets[0], plannedTargets[1]],
|
|
305
|
+
reviewSessionIds: [attempts[0].sessionId, attempts[1].sessionId],
|
|
306
|
+
sessionIsolation: "fresh",
|
|
307
|
+
primaryReportDigest: reviewReportDigest(result.report),
|
|
308
|
+
adversarialReportDigest: reviewReportDigest(result.adversarial.report),
|
|
309
|
+
reportArtifact,
|
|
310
|
+
...(result.hostTarget === undefined ? {} : { hostTarget: result.hostTarget }),
|
|
311
|
+
createdAt: new Date().toISOString()
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
return {
|
|
316
|
+
...result,
|
|
317
|
+
status: "NOT_RUN",
|
|
318
|
+
reason: "evidence-write-failed"
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
238
323
|
export async function runReviewCommand(options) {
|
|
239
324
|
const role = resolveReviewRole(options.role ?? "independent-review", options.roles ?? []);
|
|
240
325
|
const selectedHarness = harness(options.args.harness);
|
|
@@ -243,17 +328,30 @@ export async function runReviewCommand(options) {
|
|
|
243
328
|
!configuredTargets.includes(selectedHarness)) {
|
|
244
329
|
throw new AgentOpsError("REVIEW_TARGET_NOT_CONFIGURED", `${selectedHarness} is not enabled for independent-review. Configure it with init --review-target ${selectedHarness}.`);
|
|
245
330
|
}
|
|
246
|
-
const
|
|
331
|
+
const configuredSelection = selectedHarness === undefined
|
|
247
332
|
? options.targets ?? configuredTargets
|
|
248
333
|
: [selectedHarness];
|
|
334
|
+
const targetPlan = planReviewTargets(configuredSelection, detectHostTarget(options.env ?? process.env));
|
|
335
|
+
const hostTarget = detectHostTarget(options.env ?? process.env);
|
|
336
|
+
const plannedTargets = targetPlan.reason === undefined
|
|
337
|
+
? targetPlan.targets
|
|
338
|
+
: configuredSelection;
|
|
249
339
|
const target = plannedTargets[0] ?? selectedHarness ?? "codex";
|
|
250
340
|
const resultBase = {
|
|
251
341
|
harness: target,
|
|
252
342
|
plannedTargets,
|
|
253
343
|
model: role?.model ?? options.model ?? "configured",
|
|
254
344
|
effort: role?.effort ?? options.effort ?? "configured",
|
|
255
|
-
prompt: ""
|
|
345
|
+
prompt: "",
|
|
346
|
+
...(hostTarget === undefined ? {} : { hostTarget })
|
|
256
347
|
};
|
|
348
|
+
if (targetPlan.reason !== undefined) {
|
|
349
|
+
return notRunEnvelope({
|
|
350
|
+
...resultBase,
|
|
351
|
+
status: "NOT_RUN",
|
|
352
|
+
reason: targetPlan.reason
|
|
353
|
+
});
|
|
354
|
+
}
|
|
257
355
|
const context = await taskContext(options);
|
|
258
356
|
const evidenceRequirements = (options.args.evidence ?? []).map((value) => {
|
|
259
357
|
const separator = value.indexOf("=");
|
|
@@ -358,6 +456,8 @@ export async function runReviewCommand(options) {
|
|
|
358
456
|
invocation: {
|
|
359
457
|
harness: target,
|
|
360
458
|
plannedTargets,
|
|
459
|
+
...(sourceFingerprint === undefined ? {} : { sourceFingerprint }),
|
|
460
|
+
...(hostTarget === undefined ? {} : { hostTarget }),
|
|
361
461
|
model: role?.model ?? options.model ?? "configured",
|
|
362
462
|
effort: role?.effort ?? options.effort ?? "configured",
|
|
363
463
|
packet,
|
|
@@ -408,55 +508,46 @@ export async function runReviewCommand(options) {
|
|
|
408
508
|
if (reviewScopeSignature(scope) !== reviewScopeSignature(postflight) ||
|
|
409
509
|
(options.policyConfigHash !== undefined && currentHash !== options.policyConfigHash) ||
|
|
410
510
|
postflightFingerprint !== sourceFingerprint) {
|
|
411
|
-
|
|
511
|
+
const changed = await persistReviewEvidence(options, sourceChangedResult(result), sourceFingerprint, context);
|
|
512
|
+
return notRunEnvelope(changed);
|
|
412
513
|
}
|
|
413
514
|
}
|
|
414
515
|
catch {
|
|
415
|
-
|
|
516
|
+
const changed = await persistReviewEvidence(options, sourceChangedResult(result), sourceFingerprint, context);
|
|
517
|
+
return notRunEnvelope(changed);
|
|
416
518
|
}
|
|
417
519
|
}
|
|
520
|
+
const persisted = await persistReviewEvidence(options, result, sourceFingerprint, context);
|
|
521
|
+
if (persisted.status === "NOT_RUN") {
|
|
522
|
+
return notRunEnvelope(persisted);
|
|
523
|
+
}
|
|
524
|
+
const finalResult = persisted;
|
|
418
525
|
// Evidence is only appended while the task is active: a completed record
|
|
419
526
|
// must stay exactly as it was verified.
|
|
420
527
|
if (context !== undefined &&
|
|
421
528
|
options.tasks !== undefined &&
|
|
422
|
-
|
|
529
|
+
finalResult.status === "PASS" &&
|
|
423
530
|
context.active &&
|
|
424
|
-
|
|
425
|
-
await options.tasks.recordEvidence(context.taskId, Object.fromEntries(
|
|
531
|
+
finalResult.results !== undefined) {
|
|
532
|
+
await options.tasks.recordEvidence(context.taskId, Object.fromEntries(finalResult.results.map((item) => [
|
|
426
533
|
item.criterionId,
|
|
427
|
-
item.evidence.map((reference) => `review:${
|
|
534
|
+
item.evidence.map((reference) => `review:${finalResult.harness}:${reference}`)
|
|
428
535
|
])));
|
|
429
536
|
}
|
|
430
|
-
|
|
431
|
-
// source fingerprint, so it stops satisfying the gate the moment the tree
|
|
432
|
-
// changes again.
|
|
433
|
-
if (options.root !== undefined &&
|
|
434
|
-
result.status === "PASS" &&
|
|
435
|
-
sourceFingerprint !== undefined &&
|
|
436
|
-
(context === undefined || context.allCriteriaReviewed)) {
|
|
437
|
-
await saveReviewAttestation(options.root, {
|
|
438
|
-
schemaVersion: 1,
|
|
439
|
-
...(context === undefined ? {} : { taskId: context.taskId }),
|
|
440
|
-
harness: result.harness,
|
|
441
|
-
status: "PASS",
|
|
442
|
-
sourceFingerprint,
|
|
443
|
-
createdAt: new Date().toISOString()
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
const message = result.status === "PASS"
|
|
537
|
+
const message = finalResult.status === "PASS"
|
|
447
538
|
? "Independent review passed."
|
|
448
|
-
:
|
|
539
|
+
: finalResult.status === "FAIL"
|
|
449
540
|
? "Independent review failed."
|
|
450
541
|
: "Independent review was not run.";
|
|
451
542
|
const data = {
|
|
452
543
|
message,
|
|
453
|
-
result,
|
|
454
|
-
text: renderReviewResult(
|
|
544
|
+
result: finalResult,
|
|
545
|
+
text: renderReviewResult(finalResult)
|
|
455
546
|
};
|
|
456
|
-
if (
|
|
547
|
+
if (finalResult.status === "PASS") {
|
|
457
548
|
return okEnvelope("REVIEW_RESULT", data);
|
|
458
549
|
}
|
|
459
|
-
const code =
|
|
550
|
+
const code = finalResult.status === "FAIL" ? "REVIEW_FAILED" : "REVIEW_NOT_RUN";
|
|
460
551
|
return {
|
|
461
552
|
code,
|
|
462
553
|
status: "error",
|
|
@@ -40,7 +40,8 @@ export function claudeHookOutput(event, result) {
|
|
|
40
40
|
decision: "block",
|
|
41
41
|
reason: `agent-ops: ${result.code} reported FAIL for ${failed.join(", ")}. ` +
|
|
42
42
|
"Resolve every failing item — an unsatisfied independent-review " +
|
|
43
|
-
"gate is cleared by running `agent-ops review
|
|
43
|
+
"gate is cleared by running `agent-ops review --task <task-id> --yes` " +
|
|
44
|
+
"to a PASS — then " +
|
|
44
45
|
"stop again.",
|
|
45
46
|
evidence: result.evidence
|
|
46
47
|
});
|
|
@@ -53,6 +54,15 @@ export function claudeHookOutput(event, result) {
|
|
|
53
54
|
if (result.action === "continue" && result.status === "PASS") {
|
|
54
55
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
55
56
|
}
|
|
57
|
+
// An advisory PreToolUse result carries nothing the user can act on: the
|
|
58
|
+
// command runs either way. Saying so on every unparsed command — a heredoc,
|
|
59
|
+
// a substitution — trains the reader to ignore the hook that also refuses
|
|
60
|
+
// stops. A result with a remedy still speaks.
|
|
61
|
+
if (event === "PreToolUse" &&
|
|
62
|
+
result.action === "continue" &&
|
|
63
|
+
result.remedy === undefined) {
|
|
64
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
65
|
+
}
|
|
56
66
|
if (event === "PreToolUse" && result.action === "block") {
|
|
57
67
|
return json({
|
|
58
68
|
hookSpecificOutput: {
|
|
@@ -3,7 +3,7 @@ import { sha256 } from "../fs/hash.js";
|
|
|
3
3
|
import { AgentOpsError } from "../fs/paths.js";
|
|
4
4
|
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
5
5
|
import { checkTaskCompletionEvidence, findIncompleteSubtask } from "../task/completion.js";
|
|
6
|
-
import { collectChangeSurface } from "../verify/change-surface.js";
|
|
6
|
+
import { collectBaseChangePaths, collectChangeSurface } from "../verify/change-surface.js";
|
|
7
7
|
import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
|
|
8
8
|
const FINGERPRINT = /^[a-f0-9]{64}$/u;
|
|
9
9
|
const SESSION = /^[^\0\r\n]{1,256}$/u;
|
|
@@ -76,6 +76,38 @@ export class CompletionGateService {
|
|
|
76
76
|
const surface = await collectChangeSurface(this.#options.gitRunner);
|
|
77
77
|
return await calculateSourceFingerprint(this.#options.root, { mode: "worktree", changedFiles: surface.paths }, this.#options.gitRunner);
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* The fingerprint the task's evidence should carry. Committed work leaves an
|
|
81
|
+
* empty worktree surface: there is nothing left to measure there, and the
|
|
82
|
+
* evidence names the `--base` range `task complete` was given instead. That
|
|
83
|
+
* range is recomputed here rather than trusted, so evidence for a range that
|
|
84
|
+
* no longer ends at HEAD still fails.
|
|
85
|
+
*/
|
|
86
|
+
async #evidenceFingerprint(completionBase) {
|
|
87
|
+
const worktree = await this.#fingerprint();
|
|
88
|
+
if (completionBase === null) {
|
|
89
|
+
return worktree;
|
|
90
|
+
}
|
|
91
|
+
const surface = await collectChangeSurface(this.#options.gitRunner);
|
|
92
|
+
if (surface.paths.length > 0) {
|
|
93
|
+
return worktree;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const changedFiles = await collectBaseChangePaths(this.#options.gitRunner, completionBase);
|
|
97
|
+
if (changedFiles.length === 0) {
|
|
98
|
+
return worktree;
|
|
99
|
+
}
|
|
100
|
+
return await calculateSourceFingerprint(this.#options.root, {
|
|
101
|
+
mode: "base",
|
|
102
|
+
baseRef: completionBase,
|
|
103
|
+
resolvedBase: completionBase,
|
|
104
|
+
changedFiles
|
|
105
|
+
}, this.#options.gitRunner);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return worktree;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
79
111
|
async initialize(sessionId) {
|
|
80
112
|
const fingerprint = await this.#fingerprint();
|
|
81
113
|
const state = await this.#store.mutate(sessionId, (current) => current ?? {
|
|
@@ -103,7 +135,7 @@ export class CompletionGateService {
|
|
|
103
135
|
event.event === "command-batch" ? event.commands : [];
|
|
104
136
|
return commands.some(({ command, args }) => [command, ...args].includes("allow-stop"));
|
|
105
137
|
}
|
|
106
|
-
async #validateTask(sessionId
|
|
138
|
+
async #validateTask(sessionId) {
|
|
107
139
|
let stored;
|
|
108
140
|
try {
|
|
109
141
|
stored = await this.#options.taskService.status({ sessionId });
|
|
@@ -120,7 +152,11 @@ export class CompletionGateService {
|
|
|
120
152
|
if (unfinished !== undefined) {
|
|
121
153
|
return gateResult("block", "FAIL", "COMPLETION_GATE_SUBTASK_INCOMPLETE", `Complete subtask ${unfinished.task.id} before its parent.`);
|
|
122
154
|
}
|
|
123
|
-
const
|
|
155
|
+
const sourceFingerprint = await this.#evidenceFingerprint(stored.completionBase);
|
|
156
|
+
const problem = await checkTaskCompletionEvidence(stored, {
|
|
157
|
+
...this.#options,
|
|
158
|
+
sourceFingerprint
|
|
159
|
+
});
|
|
124
160
|
if (problem !== null) {
|
|
125
161
|
return gateResult("block", problem.status, `COMPLETION_GATE_${problem.code}`, problem.remedy);
|
|
126
162
|
}
|
|
@@ -154,7 +190,7 @@ export class CompletionGateService {
|
|
|
154
190
|
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_NOT_INITIALIZED", "The session baseline is unavailable; continue once so PreInvocation can initialize it.");
|
|
155
191
|
}
|
|
156
192
|
if (state.baselineFingerprint !== fingerprint && state.permitFingerprint !== fingerprint) {
|
|
157
|
-
const failure = await this.#validateTask(sessionId
|
|
193
|
+
const failure = await this.#validateTask(sessionId);
|
|
158
194
|
if (failure !== null)
|
|
159
195
|
return failure;
|
|
160
196
|
}
|