@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.
@@ -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
- throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--base may be used only with verify or review.");
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", "--review-target may be used only with init.");
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" && harness !== undefined && harness.length !== 1) {
417
+ if (command === "review" &&
418
+ harness !== undefined &&
419
+ (harness.length !== 1 || !REVIEW_TARGETS.has(harness[0] ?? ""))) {
416
420
  invalidValue("--harness", harness.join(","));
417
421
  }
418
422
  if (command === "allow-stop" &&
@@ -29,9 +29,9 @@ import { formatInstallPlan, runInitCommand } from "./commands/init.js";
29
29
  import { formatUninstallPlan, runUninstallCommand } from "./commands/uninstall.js";
30
30
  import { runTaskCommand } from "./commands/task.js";
31
31
  import { runReviewCommand } from "./commands/review.js";
32
- import { createReviewExecutor } from "../../../runtime/src/review/execute.js";
32
+ import { createReviewExecutor, ReviewInterruptedError } from "../../../runtime/src/review/execute.js";
33
33
  import { probeReviewTarget } from "../../../runtime/src/review/probe.js";
34
- import { resolveReviewRole } from "../../../runtime/src/review/roles.js";
34
+ import { detectHostTarget, orderChain, resolveReviewRole } from "../../../runtime/src/review/roles.js";
35
35
  import { runTrustCommand } from "./commands/trust.js";
36
36
  import { runVerifyCommand } from "./commands/verify.js";
37
37
  import { runAllowStopCommand } from "./commands/allow-stop.js";
@@ -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
- return await runReviewCommand({
387
- args,
388
- authorized: args.yes,
389
- tasks: taskService,
390
- ...(args.taskId === undefined ? {} : { taskId: args.taskId }),
391
- ...(reviewSessionId === undefined
392
- ? {}
393
- : { sessionId: reviewSessionId }),
394
- ...(reviewConfig.reviewRoles === undefined
395
- ? {}
396
- : { roles: reviewConfig.reviewRoles }),
397
- root,
398
- gitRunner: gitRunner(root),
399
- policyConfigHash: calculateConfigHash(reviewConfig),
400
- currentPolicyConfigHash: async () => calculateConfigHash((await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config),
401
- config: reviewConfig,
402
- evidenceStore: new FileEvidenceStore(root, root),
403
- execute: createReviewExecutor({
404
- targets: reviewRole?.targets ?? [],
405
- cwd: root,
406
- ...(reviewRole?.model === undefined
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
- : { model: reviewRole.model }),
409
- ...(reviewRole?.effort === undefined
413
+ : { sessionId: reviewSessionId }),
414
+ ...(reviewConfig.reviewRoles === undefined
410
415
  ? {}
411
- : { effort: reviewRole.effort }),
412
- ...(reviewRole?.timeoutMs === undefined
413
- ? {}
414
- : { timeoutMs: reviewRole.timeoutMs }),
415
- onProgress: (line) => {
416
- if (!args.json) {
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
- * Hooks are advisory infrastructure: every failure path stays fail-open with
24
- * exit code 0 so a broken toolkit can never wedge the harness.
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
- let input;
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
- return { exitCode: 0, stdout: "", stderr: "" };
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 target = role?.targets[0] ?? selectedHarness ?? "codex";
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
- harness: target, model: role?.model ?? options.model ?? "configured",
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
- harness: target, model: role?.model ?? options.model ?? "configured",
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
- harness: target, model: role?.model ?? options.model ?? "configured",
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 { isPassingVerificationEvidence } from "../verify/evidence.js";
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, sessionId) {
105
- if (event.event !== "command")
106
- return false;
107
- const tokens = [event.command, ...event.args];
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 taskValidation = validateTaskAgainstConfig(stored.task, this.#options.config);
147
- const configHash = calculateConfigHash(this.#options.config);
148
- if (!taskValidation.ok || stored.policyConfigHash !== configHash) {
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 attestation = await findReviewAttestation(this.#options.root, sourceFingerprint);
164
- if (attestation === null || attestation.taskId !== stored.task.id) {
165
- return gateResult("block", "FAIL", "COMPLETION_GATE_REVIEW_REQUIRED", "Run agent-ops review --yes for the attached task and current source.");
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
- const result = await options.completionGate.handle(event);
40
- if (result !== null) {
41
- return result;
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
- return parseAttestation(JSON.parse(source));
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
+ }