@kylecheng3146/agent-ops 0.1.21 → 0.1.24
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 +10 -6
- package/dist/packages/cli/src/bin.js +67 -35
- package/dist/packages/cli/src/cli.js +2 -2
- package/dist/packages/cli/src/commands/hook.js +7 -12
- package/dist/packages/cli/src/commands/review.js +39 -24
- package/dist/packages/cli/src/hook-process.js +9 -0
- package/dist/runtime/src/hooks/completion-gate.js +18 -54
- package/dist/runtime/src/hooks/dispatch.js +11 -3
- package/dist/runtime/src/install/ownership.js +7 -0
- package/dist/runtime/src/install/plan.js +1 -0
- package/dist/runtime/src/install/uninstall.js +2 -0
- package/dist/runtime/src/review/attestation.js +12 -1
- 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/security/trust.js +1 -2
- package/dist/runtime/src/task/completion.js +62 -0
- package/dist/runtime/src/task/service.js +71 -7
- package/dist/runtime/src/verify/change-surface.js +11 -2
- package/dist/runtime/src/verify/evidence.js +9 -1
- package/dist/runtime/src/verify/service.js +9 -1
- package/dist/runtime/src/verify/spawn.js +30 -8
- package/docs/en/guides/configuration.md +12 -0
- package/docs/en/spec/acceptance-and-evidence.md +25 -0
- package/docs/en/spec/review.md +11 -0
- package/docs/zh-TW/guides/configuration.md +10 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +26 -1
- package/docs/zh-TW/spec/review.md +10 -0
- package/package.json +1 -1
|
@@ -336,8 +336,9 @@ export function parseArgs(argv) {
|
|
|
336
336
|
if (command !== "update" && targetVersion !== undefined) {
|
|
337
337
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--target-version may be used only with update.");
|
|
338
338
|
}
|
|
339
|
-
if (base !== undefined && command !== "verify" && command !== "review"
|
|
340
|
-
|
|
339
|
+
if (base !== undefined && command !== "verify" && command !== "review" &&
|
|
340
|
+
!(command === "task" && action === "complete")) {
|
|
341
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--base may be used only with verify, review or task complete.");
|
|
341
342
|
}
|
|
342
343
|
if (hookTargets.length > 0 &&
|
|
343
344
|
command !== "init" &&
|
|
@@ -348,7 +349,9 @@ export function parseArgs(argv) {
|
|
|
348
349
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--check-auth may be used only with doctor.");
|
|
349
350
|
}
|
|
350
351
|
if (reviewTargets.length > 0 && command !== "init") {
|
|
351
|
-
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED",
|
|
352
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", command === "review"
|
|
353
|
+
? "--review-target configures init; for one review run, use --harness <target>."
|
|
354
|
+
: "--review-target may be used only with init.");
|
|
352
355
|
}
|
|
353
356
|
if (completionGate !== undefined && command !== "init") {
|
|
354
357
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--completion-gate may be used only with init.");
|
|
@@ -399,9 +402,8 @@ export function parseArgs(argv) {
|
|
|
399
402
|
evidence.length > 0 ||
|
|
400
403
|
dryRun ||
|
|
401
404
|
yes ||
|
|
402
|
-
base !== undefined ||
|
|
403
405
|
(taskId !== undefined && sessionId !== undefined))) {
|
|
404
|
-
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Verify accepts only scope, task or session, and json options.");
|
|
406
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Verify accepts only scope, task or session, base, and json options.");
|
|
405
407
|
}
|
|
406
408
|
if (command === "review" &&
|
|
407
409
|
(title !== undefined || sessionId !== undefined)) {
|
|
@@ -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";
|
|
@@ -353,7 +353,12 @@ else {
|
|
|
353
353
|
: { targetVersion: updateArgs.targetVersion })
|
|
354
354
|
});
|
|
355
355
|
}
|
|
356
|
-
const taskService = new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)
|
|
356
|
+
const taskService = new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root), { completion: {
|
|
357
|
+
root,
|
|
358
|
+
gitRunner: gitRunner(root),
|
|
359
|
+
...(args.base === undefined ? {} : { base: args.base }),
|
|
360
|
+
loadConfig: async () => (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config
|
|
361
|
+
} });
|
|
357
362
|
if (args.command === "allow-stop") {
|
|
358
363
|
const config = (await loadEffectiveConfig(root, "project")).config;
|
|
359
364
|
return await runAllowStopCommand({
|
|
@@ -383,42 +388,69 @@ else {
|
|
|
383
388
|
const reviewSessionId = process.env.AGENT_OPS_SESSION_ID;
|
|
384
389
|
const reviewConfig = (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config;
|
|
385
390
|
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
|
-
...(
|
|
391
|
+
const selectedReviewTarget = args.harness?.[0];
|
|
392
|
+
const plannedReviewTargets = orderChain(selectedReviewTarget === undefined
|
|
393
|
+
? reviewRole?.targets ?? []
|
|
394
|
+
: [selectedReviewTarget], detectHostTarget(process.env));
|
|
395
|
+
const controller = new AbortController();
|
|
396
|
+
let interruptedBy;
|
|
397
|
+
const interrupt = (signal) => {
|
|
398
|
+
interruptedBy ??= signal;
|
|
399
|
+
controller.abort(signal);
|
|
400
|
+
};
|
|
401
|
+
const onSigint = () => interrupt("SIGINT");
|
|
402
|
+
const onSigterm = () => interrupt("SIGTERM");
|
|
403
|
+
process.once("SIGINT", onSigint);
|
|
404
|
+
process.once("SIGTERM", onSigterm);
|
|
405
|
+
try {
|
|
406
|
+
return await runReviewCommand({
|
|
407
|
+
args,
|
|
408
|
+
authorized: args.yes,
|
|
409
|
+
tasks: taskService,
|
|
410
|
+
...(args.taskId === undefined ? {} : { taskId: args.taskId }),
|
|
411
|
+
...(reviewSessionId === undefined
|
|
407
412
|
? {}
|
|
408
|
-
: {
|
|
409
|
-
...(
|
|
413
|
+
: { sessionId: reviewSessionId }),
|
|
414
|
+
...(reviewConfig.reviewRoles === undefined
|
|
410
415
|
? {}
|
|
411
|
-
: {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
416
|
+
: { roles: reviewConfig.reviewRoles }),
|
|
417
|
+
targets: plannedReviewTargets,
|
|
418
|
+
root,
|
|
419
|
+
gitRunner: gitRunner(root),
|
|
420
|
+
policyConfigHash: calculateConfigHash(reviewConfig),
|
|
421
|
+
currentPolicyConfigHash: async () => calculateConfigHash((await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config),
|
|
422
|
+
config: reviewConfig,
|
|
423
|
+
evidenceStore: new FileEvidenceStore(root, root),
|
|
424
|
+
execute: createReviewExecutor({
|
|
425
|
+
targets: plannedReviewTargets,
|
|
426
|
+
cwd: root,
|
|
427
|
+
...(reviewRole?.model === undefined
|
|
428
|
+
? {}
|
|
429
|
+
: { model: reviewRole.model }),
|
|
430
|
+
...(reviewRole?.effort === undefined
|
|
431
|
+
? {}
|
|
432
|
+
: { effort: reviewRole.effort }),
|
|
433
|
+
...(reviewRole?.timeoutMs === undefined
|
|
434
|
+
? {}
|
|
435
|
+
: { timeoutMs: reviewRole.timeoutMs }),
|
|
436
|
+
signal: controller.signal,
|
|
437
|
+
onProgress: (line) => {
|
|
417
438
|
process.stderr.write(`${line}\n`);
|
|
418
439
|
}
|
|
419
|
-
}
|
|
420
|
-
})
|
|
421
|
-
}
|
|
440
|
+
})
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
if (error instanceof ReviewInterruptedError &&
|
|
445
|
+
interruptedBy !== undefined) {
|
|
446
|
+
process.exit(interruptedBy === "SIGINT" ? 130 : 143);
|
|
447
|
+
}
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
finally {
|
|
451
|
+
process.removeListener("SIGINT", onSigint);
|
|
452
|
+
process.removeListener("SIGTERM", onSigterm);
|
|
453
|
+
}
|
|
422
454
|
}
|
|
423
455
|
if (args.command === "config") {
|
|
424
456
|
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
|
|
@@ -48,7 +48,7 @@ Options:
|
|
|
48
48
|
--criterion <json> Repeatable
|
|
49
49
|
--evidence <criterion-id=reference> Repeatable
|
|
50
50
|
--session <id>
|
|
51
|
-
--base <git-ref> Verify/review a clean committed range
|
|
51
|
+
--base <git-ref> Verify/review/complete a clean committed range
|
|
52
52
|
--dry-run
|
|
53
53
|
--json
|
|
54
54
|
--yes
|
|
@@ -20,25 +20,20 @@ export function normalizeHookInput(harness, input) {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* Advisory failures stay fail-open; an enabled completion Stop fails closed
|
|
24
|
+
* through the host's native decision protocol (still exit code 0).
|
|
25
25
|
*/
|
|
26
26
|
export async function runHookCommand(options) {
|
|
27
27
|
try {
|
|
28
28
|
const { capabilities } = options.config.profiles.length === 0
|
|
29
29
|
? { capabilities: [] }
|
|
30
30
|
: resolveCapabilities(options.config);
|
|
31
|
-
|
|
32
|
-
try {
|
|
33
|
-
input = JSON.parse(options.stdin);
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
return { exitCode: 0, stdout: "", stderr: "" };
|
|
37
|
-
}
|
|
31
|
+
const input = JSON.parse(options.stdin);
|
|
38
32
|
const descriptor = harnessDescriptor(options.harness);
|
|
39
33
|
const normalized = normalizeHookInput(options.harness, input);
|
|
40
|
-
if (normalized === null
|
|
41
|
-
|
|
34
|
+
if (normalized === null ||
|
|
35
|
+
(options.completionGate !== undefined && options.event === "Stop" && normalized.event !== "stop")) {
|
|
36
|
+
throw new Error("Invalid hook input.");
|
|
42
37
|
}
|
|
43
38
|
const stopRegistration = descriptor.control.registrations.find(({ capability }) => capability === "optional-stop-verify");
|
|
44
39
|
const stopVerification = options.stopVerification !== undefined &&
|
|
@@ -57,7 +52,7 @@ export async function runHookCommand(options) {
|
|
|
57
52
|
return descriptor.runtime.formatOutput(options.event, result);
|
|
58
53
|
}
|
|
59
54
|
catch {
|
|
60
|
-
if (options.harness === "agy" && options.completionGate !== undefined) {
|
|
55
|
+
if (options.harness === "agy" && options.event === "Stop" && options.completionGate !== undefined) {
|
|
61
56
|
return harnessDescriptor("agy").runtime.formatOutput(options.event, {
|
|
62
57
|
action: "block",
|
|
63
58
|
status: "UNKNOWN",
|
|
@@ -1,7 +1,7 @@
|
|
|
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 { saveReviewAttestation } from "../../../../runtime/src/review/attestation.js";
|
|
4
|
+
import { invalidateReviewAttestation, saveReviewAttestation } from "../../../../runtime/src/review/attestation.js";
|
|
5
5
|
import { 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";
|
|
@@ -75,7 +75,8 @@ async function taskContext(options) {
|
|
|
75
75
|
policyConfigHash: record.policyConfigHash,
|
|
76
76
|
evidence: record.evidence,
|
|
77
77
|
failureFingerprint: record.failureFingerprint,
|
|
78
|
-
criteria
|
|
78
|
+
criteria,
|
|
79
|
+
allCriteriaReviewed: criteria.length === record.task.criteria.length
|
|
79
80
|
};
|
|
80
81
|
}
|
|
81
82
|
function newestEvidence(values) {
|
|
@@ -213,6 +214,9 @@ function sourceChangedResult(result) {
|
|
|
213
214
|
model: result.model,
|
|
214
215
|
effort: result.effort,
|
|
215
216
|
prompt: result.prompt,
|
|
217
|
+
...(result.plannedTargets === undefined
|
|
218
|
+
? {}
|
|
219
|
+
: { plannedTargets: result.plannedTargets }),
|
|
216
220
|
...(result.scope === undefined ? {} : { scope: result.scope }),
|
|
217
221
|
...(result.independence === undefined
|
|
218
222
|
? {}
|
|
@@ -229,7 +233,22 @@ function sourceChangedResult(result) {
|
|
|
229
233
|
export async function runReviewCommand(options) {
|
|
230
234
|
const role = resolveReviewRole(options.role ?? "independent-review", options.roles ?? []);
|
|
231
235
|
const selectedHarness = harness(options.args.harness);
|
|
232
|
-
const
|
|
236
|
+
const configuredTargets = role?.targets ?? [];
|
|
237
|
+
if (selectedHarness !== undefined &&
|
|
238
|
+
!configuredTargets.includes(selectedHarness)) {
|
|
239
|
+
throw new AgentOpsError("REVIEW_TARGET_NOT_CONFIGURED", `${selectedHarness} is not enabled for independent-review. Configure it with init --review-target ${selectedHarness}.`);
|
|
240
|
+
}
|
|
241
|
+
const plannedTargets = selectedHarness === undefined
|
|
242
|
+
? options.targets ?? configuredTargets
|
|
243
|
+
: [selectedHarness];
|
|
244
|
+
const target = plannedTargets[0] ?? selectedHarness ?? "codex";
|
|
245
|
+
const resultBase = {
|
|
246
|
+
harness: target,
|
|
247
|
+
plannedTargets,
|
|
248
|
+
model: role?.model ?? options.model ?? "configured",
|
|
249
|
+
effort: role?.effort ?? options.effort ?? "configured",
|
|
250
|
+
prompt: ""
|
|
251
|
+
};
|
|
233
252
|
const context = await taskContext(options);
|
|
234
253
|
const evidenceRequirements = (options.args.evidence ?? []).map((value) => {
|
|
235
254
|
const separator = value.indexOf("=");
|
|
@@ -253,41 +272,38 @@ export async function runReviewCommand(options) {
|
|
|
253
272
|
const reason = scopeReason(error);
|
|
254
273
|
if (reason !== undefined) {
|
|
255
274
|
return notRunEnvelope({
|
|
275
|
+
...resultBase,
|
|
256
276
|
status: "NOT_RUN",
|
|
257
|
-
reason
|
|
258
|
-
harness: target,
|
|
259
|
-
model: role?.model ?? options.model ?? "configured",
|
|
260
|
-
effort: role?.effort ?? options.effort ?? "configured",
|
|
261
|
-
prompt: ""
|
|
277
|
+
reason
|
|
262
278
|
});
|
|
263
279
|
}
|
|
264
280
|
throw error;
|
|
265
281
|
}
|
|
266
282
|
if (scope.changedFiles.some(isReviewerPolicyPath)) {
|
|
267
283
|
return notRunEnvelope({
|
|
284
|
+
...resultBase,
|
|
268
285
|
status: "NOT_RUN",
|
|
269
286
|
reason: "reviewer-policy-changed",
|
|
270
|
-
harness: target,
|
|
271
|
-
model: role?.model ?? options.model ?? "configured",
|
|
272
|
-
effort: role?.effort ?? options.effort ?? "configured",
|
|
273
|
-
prompt: "",
|
|
274
287
|
scope
|
|
275
288
|
});
|
|
276
289
|
}
|
|
277
290
|
sourceFingerprint = await calculateSourceFingerprint(options.root, scope, options.gitRunner);
|
|
291
|
+
if (options.authorized) {
|
|
292
|
+
await invalidateReviewAttestation(options.root, sourceFingerprint);
|
|
293
|
+
}
|
|
278
294
|
if (context !== undefined && options.policyConfigHash !== undefined) {
|
|
279
295
|
if (context.policyConfigHash === null) {
|
|
280
296
|
return notRunEnvelope({
|
|
297
|
+
...resultBase,
|
|
281
298
|
status: "NOT_RUN", reason: "reviewer-policy-baseline-missing",
|
|
282
|
-
|
|
283
|
-
effort: role?.effort ?? options.effort ?? "configured", prompt: "", scope
|
|
299
|
+
scope
|
|
284
300
|
});
|
|
285
301
|
}
|
|
286
302
|
if (context.policyConfigHash !== options.policyConfigHash) {
|
|
287
303
|
return notRunEnvelope({
|
|
304
|
+
...resultBase,
|
|
288
305
|
status: "NOT_RUN", reason: "reviewer-policy-changed",
|
|
289
|
-
|
|
290
|
-
effort: role?.effort ?? options.effort ?? "configured", prompt: "", scope
|
|
306
|
+
scope
|
|
291
307
|
});
|
|
292
308
|
}
|
|
293
309
|
}
|
|
@@ -295,9 +311,9 @@ export async function runReviewCommand(options) {
|
|
|
295
311
|
const preflight = await preflightReview(options, context, sourceFingerprint);
|
|
296
312
|
if (!preflight.ok) {
|
|
297
313
|
return notRunEnvelope({
|
|
314
|
+
...resultBase,
|
|
298
315
|
status: "NOT_RUN", reason: preflight.reason,
|
|
299
|
-
|
|
300
|
-
effort: role?.effort ?? options.effort ?? "configured", prompt: "", scope
|
|
316
|
+
scope
|
|
301
317
|
});
|
|
302
318
|
}
|
|
303
319
|
verification = preflight.summary;
|
|
@@ -324,12 +340,9 @@ export async function runReviewCommand(options) {
|
|
|
324
340
|
: undefined;
|
|
325
341
|
if (reason !== undefined) {
|
|
326
342
|
return notRunEnvelope({
|
|
343
|
+
...resultBase,
|
|
327
344
|
status: "NOT_RUN",
|
|
328
|
-
reason
|
|
329
|
-
harness: target,
|
|
330
|
-
model: role?.model ?? options.model ?? "configured",
|
|
331
|
-
effort: role?.effort ?? options.effort ?? "configured",
|
|
332
|
-
prompt: ""
|
|
345
|
+
reason
|
|
333
346
|
});
|
|
334
347
|
}
|
|
335
348
|
}
|
|
@@ -338,6 +351,7 @@ export async function runReviewCommand(options) {
|
|
|
338
351
|
const result = await runIndependentReview({
|
|
339
352
|
invocation: {
|
|
340
353
|
harness: target,
|
|
354
|
+
plannedTargets,
|
|
341
355
|
model: role?.model ?? options.model ?? "configured",
|
|
342
356
|
effort: role?.effort ?? options.effort ?? "configured",
|
|
343
357
|
packet,
|
|
@@ -412,7 +426,8 @@ export async function runReviewCommand(options) {
|
|
|
412
426
|
// changes again.
|
|
413
427
|
if (options.root !== undefined &&
|
|
414
428
|
result.status === "PASS" &&
|
|
415
|
-
sourceFingerprint !== undefined
|
|
429
|
+
sourceFingerprint !== undefined &&
|
|
430
|
+
(context === undefined || context.allCriteriaReviewed)) {
|
|
416
431
|
await saveReviewAttestation(options.root, {
|
|
417
432
|
schemaVersion: 1,
|
|
418
433
|
...(context === undefined ? {} : { taskId: context.taskId }),
|
|
@@ -267,6 +267,15 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
267
267
|
return 0;
|
|
268
268
|
}
|
|
269
269
|
const config = configOutcome.config;
|
|
270
|
+
if (completionGateInstalled && !config.features.completionGate.enabled) {
|
|
271
|
+
writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
|
|
272
|
+
action: "block",
|
|
273
|
+
status: "UNKNOWN",
|
|
274
|
+
code: "COMPLETION_GATE_CONFIG_DISABLED",
|
|
275
|
+
remedy: "Restore completionGate.enabled or explicitly uninstall the completion gate."
|
|
276
|
+
}));
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
270
279
|
const trustStatus = dependencies.trust === undefined
|
|
271
280
|
? await repositoryTrust(root, config, cliVersion)
|
|
272
281
|
: await dependencies.trust(root, config, cliVersion);
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import { calculateConfigHash } from "../config/hash.js";
|
|
3
2
|
import { sha256 } from "../fs/hash.js";
|
|
4
3
|
import { AgentOpsError } from "../fs/paths.js";
|
|
5
|
-
import { findReviewAttestation } from "../review/attestation.js";
|
|
6
|
-
import { validateEvidence, validateTaskAgainstConfig } from "../schema/validate.js";
|
|
7
4
|
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
8
|
-
import {
|
|
5
|
+
import { checkTaskCompletionEvidence, findIncompleteSubtask } from "../task/completion.js";
|
|
9
6
|
import { collectChangeSurface } from "../verify/change-surface.js";
|
|
10
7
|
import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
|
|
11
8
|
const FINGERPRINT = /^[a-f0-9]{64}$/u;
|
|
@@ -101,34 +98,10 @@ export class CompletionGateService {
|
|
|
101
98
|
return { ...state, permitFingerprint: fingerprint };
|
|
102
99
|
});
|
|
103
100
|
}
|
|
104
|
-
#isPermitCommand(event
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const commandIndex = tokens.indexOf("allow-stop");
|
|
109
|
-
return commandIndex >= 0 &&
|
|
110
|
-
tokens[commandIndex + 1] === "--session" &&
|
|
111
|
-
(tokens[commandIndex + 2] === sessionId ||
|
|
112
|
-
tokens[commandIndex + 2] === "$AGENT_OPS_SESSION_ID");
|
|
113
|
-
}
|
|
114
|
-
async #hasCurrentEvidence(taskId, criterionId, command, references, configHash, sourceFingerprint) {
|
|
115
|
-
for (const reference of references) {
|
|
116
|
-
if (reference.startsWith("review:"))
|
|
117
|
-
continue;
|
|
118
|
-
const validation = validateEvidence(await this.#options.evidenceStore.load(reference));
|
|
119
|
-
if (!validation.ok)
|
|
120
|
-
continue;
|
|
121
|
-
const evidence = validation.value;
|
|
122
|
-
if (evidence.taskId === taskId &&
|
|
123
|
-
evidence.criterionId === criterionId &&
|
|
124
|
-
evidence.commandId === command.id &&
|
|
125
|
-
evidence.configHash === configHash &&
|
|
126
|
-
evidence.sourceFingerprint === sourceFingerprint &&
|
|
127
|
-
isPassingVerificationEvidence(command, evidence)) {
|
|
128
|
-
return true;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return false;
|
|
101
|
+
#isPermitCommand(event) {
|
|
102
|
+
const commands = event.event === "command" ? [event] :
|
|
103
|
+
event.event === "command-batch" ? event.commands : [];
|
|
104
|
+
return commands.some(({ command, args }) => [command, ...args].includes("allow-stop"));
|
|
132
105
|
}
|
|
133
106
|
async #validateTask(sessionId, sourceFingerprint) {
|
|
134
107
|
let stored;
|
|
@@ -143,30 +116,20 @@ export class CompletionGateService {
|
|
|
143
116
|
if (stored.status !== "complete") {
|
|
144
117
|
return gateResult("block", "FAIL", "COMPLETION_GATE_TASK_INCOMPLETE", "Complete the attached task after verification and review.");
|
|
145
118
|
}
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return gateResult("block", "FAIL", "COMPLETION_GATE_TASK_STALE", "Recreate or re-verify the task against the current config.");
|
|
150
|
-
}
|
|
151
|
-
for (const criterion of stored.task.criteria) {
|
|
152
|
-
for (const commandId of criterion.verifierIds) {
|
|
153
|
-
const command = this.#options.config.verification.commands.find(({ id }) => id === commandId);
|
|
154
|
-
if (command === undefined) {
|
|
155
|
-
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_EVIDENCE_UNAVAILABLE", "Configured task evidence cannot be resolved.");
|
|
156
|
-
}
|
|
157
|
-
if (command.required &&
|
|
158
|
-
!(await this.#hasCurrentEvidence(stored.task.id, criterion.id, command, stored.evidence[criterion.id] ?? [], configHash, sourceFingerprint))) {
|
|
159
|
-
return gateResult("block", "FAIL", "COMPLETION_GATE_EVIDENCE_REQUIRED", "Run agent-ops verify and complete the task with current PASS evidence.");
|
|
160
|
-
}
|
|
161
|
-
}
|
|
119
|
+
const unfinished = findIncompleteSubtask(await this.#options.taskService.list(), stored.task.id);
|
|
120
|
+
if (unfinished !== undefined) {
|
|
121
|
+
return gateResult("block", "FAIL", "COMPLETION_GATE_SUBTASK_INCOMPLETE", `Complete subtask ${unfinished.task.id} before its parent.`);
|
|
162
122
|
}
|
|
163
|
-
const
|
|
164
|
-
if (
|
|
165
|
-
return gateResult("block",
|
|
123
|
+
const problem = await checkTaskCompletionEvidence(stored, { ...this.#options, sourceFingerprint });
|
|
124
|
+
if (problem !== null) {
|
|
125
|
+
return gateResult("block", problem.status, `COMPLETION_GATE_${problem.code}`, problem.remedy);
|
|
166
126
|
}
|
|
167
127
|
return null;
|
|
168
128
|
}
|
|
169
129
|
async handle(event) {
|
|
130
|
+
if (this.#isPermitCommand(event)) {
|
|
131
|
+
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_PERMIT_CONFIRMATION", "Allow this command only to grant one Stop for the current source fingerprint.");
|
|
132
|
+
}
|
|
170
133
|
const sessionId = event.sessionId;
|
|
171
134
|
if (sessionId === undefined) {
|
|
172
135
|
return event.event === "stop"
|
|
@@ -176,11 +139,12 @@ export class CompletionGateService {
|
|
|
176
139
|
if (event.event === "session-start") {
|
|
177
140
|
return await this.initialize(sessionId);
|
|
178
141
|
}
|
|
179
|
-
if (this.#isPermitCommand(event, sessionId)) {
|
|
180
|
-
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_PERMIT_CONFIRMATION", "Allow this command only to grant one Stop for the current source fingerprint.");
|
|
181
|
-
}
|
|
182
142
|
if (event.event !== "stop")
|
|
183
143
|
return null;
|
|
144
|
+
if (event.terminationReason === undefined ||
|
|
145
|
+
(event.terminationReason === "model_stop" && event.fullyIdle === undefined)) {
|
|
146
|
+
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_STOP_INPUT_INVALID", "Restore the Stop termination reason and fullyIdle metadata before stopping.");
|
|
147
|
+
}
|
|
184
148
|
if (event.terminationReason !== "model_stop" || event.fullyIdle !== true) {
|
|
185
149
|
return gateResult("continue", "PASS", "COMPLETION_GATE_NON_FINAL_STOP");
|
|
186
150
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
1
2
|
import { evaluateGuardrail } from "../guardrails/evaluate.js";
|
|
2
3
|
import { runStopVerification } from "./stop-verify.js";
|
|
3
4
|
function continueWith(status, code) {
|
|
@@ -36,9 +37,16 @@ function evaluateCommands(commands, scope) {
|
|
|
36
37
|
}
|
|
37
38
|
export async function dispatchHookEvent(event, options) {
|
|
38
39
|
if (options.completionGate !== undefined) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
try {
|
|
41
|
+
const result = await options.completionGate.handle(event);
|
|
42
|
+
if (result !== null)
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error instanceof AgentOpsError && error.code === "CHANGE_SURFACE_TRACKED_RUNTIME") {
|
|
47
|
+
return { action: "block", status: "FAIL", code: "COMPLETION_GATE_TRACKED_RUNTIME", remedy: error.message };
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
42
50
|
}
|
|
43
51
|
}
|
|
44
52
|
if (event.event === "unsupported") {
|
|
@@ -86,6 +86,13 @@ export function assertSupportedManifestOwnership(manifest, root) {
|
|
|
86
86
|
pathKey(".agent-ops/config.json")
|
|
87
87
|
]);
|
|
88
88
|
const optionalArtifactPaths = new Set();
|
|
89
|
+
// Optional for manifests installed before runtime output was ignored.
|
|
90
|
+
if (manifest.scope === "project") {
|
|
91
|
+
expectedArtifactPaths.set(".agent-ops/.gitignore", {
|
|
92
|
+
path: ".agent-ops/.gitignore", ids: new Set(["runtime-ignore"])
|
|
93
|
+
});
|
|
94
|
+
optionalArtifactPaths.add(".agent-ops/.gitignore");
|
|
95
|
+
}
|
|
89
96
|
const expectedMarkers = new Map();
|
|
90
97
|
const expectedMarkerPaths = new Set();
|
|
91
98
|
const loopHarnesses = selectedLoopHarnesses(harnesses);
|
|
@@ -435,6 +435,7 @@ export async function createInstallPlan(options) {
|
|
|
435
435
|
});
|
|
436
436
|
const contribution = {
|
|
437
437
|
artifacts: [
|
|
438
|
+
...(options.scope === "project" ? [{ id: "runtime-ignore", path: ".agent-ops/.gitignore", content: "/tasks/\n/reviews/\n" }] : []),
|
|
438
439
|
...baseContribution.artifacts,
|
|
439
440
|
...loopContribution.artifacts
|
|
440
441
|
],
|
|
@@ -22,6 +22,8 @@ function selectedSet(harnesses) {
|
|
|
22
22
|
function artifactOwners(manifest, artifact) {
|
|
23
23
|
if (artifact.id === "config")
|
|
24
24
|
return [];
|
|
25
|
+
if (artifact.id === "runtime-ignore")
|
|
26
|
+
return manifest.harness;
|
|
25
27
|
if (artifact.id === "opencode-plugin")
|
|
26
28
|
return ["opencode"];
|
|
27
29
|
if (artifact.id === "claude-loop-launcher" ||
|
|
@@ -57,9 +57,20 @@ export async function findReviewAttestation(root, sourceFingerprint) {
|
|
|
57
57
|
return null;
|
|
58
58
|
}
|
|
59
59
|
try {
|
|
60
|
-
|
|
60
|
+
const attestation = parseAttestation(JSON.parse(source));
|
|
61
|
+
return attestation?.sourceFingerprint === sourceFingerprint ? attestation : null;
|
|
61
62
|
}
|
|
62
63
|
catch {
|
|
63
64
|
return null;
|
|
64
65
|
}
|
|
65
66
|
}
|
|
67
|
+
/** A new authorized attempt supersedes any earlier PASS for this source. */
|
|
68
|
+
export async function invalidateReviewAttestation(root, sourceFingerprint) {
|
|
69
|
+
if (!FINGERPRINT_PATTERN.test(sourceFingerprint)) {
|
|
70
|
+
throw new AgentOpsError("REVIEW_ATTESTATION_INVALID", "Invalid source fingerprint.");
|
|
71
|
+
}
|
|
72
|
+
const path = attestationPath(root, sourceFingerprint);
|
|
73
|
+
if (await readPrivateFile(path, root) !== null) {
|
|
74
|
+
await writePrivateFile(path, "null\n", root);
|
|
75
|
+
}
|
|
76
|
+
}
|