@kylecheng3146/agent-ops 0.1.20 → 0.1.21

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.
Files changed (36) hide show
  1. package/README.md +23 -4
  2. package/dist/packages/cli/src/agy-headless.js +18 -0
  3. package/dist/packages/cli/src/args.js +29 -3
  4. package/dist/packages/cli/src/bin.js +54 -3
  5. package/dist/packages/cli/src/cli.js +3 -0
  6. package/dist/packages/cli/src/commands/allow-stop.js +11 -0
  7. package/dist/packages/cli/src/commands/hook.js +12 -1
  8. package/dist/packages/cli/src/commands/init.js +4 -1
  9. package/dist/packages/cli/src/commands/task.js +5 -2
  10. package/dist/packages/cli/src/context.js +4 -1
  11. package/dist/packages/cli/src/hook-process.js +59 -5
  12. package/dist/packages/cli/src/wizard.js +24 -0
  13. package/dist/runtime/src/adapters/agy/config.js +17 -7
  14. package/dist/runtime/src/adapters/agy/events.js +8 -0
  15. package/dist/runtime/src/adapters/agy/input.js +21 -3
  16. package/dist/runtime/src/adapters/agy/output.js +8 -4
  17. package/dist/runtime/src/config/explain.js +5 -0
  18. package/dist/runtime/src/config/migrate.js +15 -1
  19. package/dist/runtime/src/contracts.js +1 -1
  20. package/dist/runtime/src/hooks/completion-gate.js +209 -0
  21. package/dist/runtime/src/hooks/dispatch.js +6 -0
  22. package/dist/runtime/src/install/doctor.js +3 -1
  23. package/dist/runtime/src/install/harness.js +5 -1
  24. package/dist/runtime/src/install/ownership.js +9 -2
  25. package/dist/runtime/src/install/plan.js +19 -4
  26. package/dist/runtime/src/install/profiles.js +3 -0
  27. package/dist/runtime/src/schema/validate.js +11 -1
  28. package/dist/runtime/src/task/service.js +17 -0
  29. package/docs/en/guides/configuration.md +25 -7
  30. package/docs/en/spec/README.md +3 -3
  31. package/docs/en/spec/harness-adapters.md +7 -3
  32. package/docs/zh-TW/guides/configuration.md +22 -6
  33. package/docs/zh-TW/spec/README.md +4 -4
  34. package/docs/zh-TW/spec/harness-adapters.md +6 -3
  35. package/package.json +1 -1
  36. package/schemas/config.schema.json +12 -2
package/README.md CHANGED
@@ -68,7 +68,8 @@ agent-ops uninstall --dry-run --json
68
68
 
69
69
  `loop` is an explicit, project-only profile for agy, Codex, and Claude Code.
70
70
  agy installs its native `PreInvocation`, `PreToolUse(run_command)`, and optional
71
- `Stop` subset directly in `.agents/hooks.json`; doctor reports its loop as
71
+ `Stop` subset directly in `.agents/hooks.json`, plus project routing in
72
+ `GEMINI.md` to `.agent-ops/GEMINI.md`; doctor reports its loop as
72
73
  degraded because prompt, permission, compact, and subagent events are unavailable.
73
74
  Claude Code supports native Windows through a generated PowerShell launcher;
74
75
  Codex's loop launcher still requires POSIX-compatible `bash`. Preview it
@@ -80,6 +81,24 @@ agent-ops init --dry-run --scope project --harness codex,claude --profile loop -
80
81
  agent-ops init --scope project --harness codex,claude --profile loop --yes
81
82
  ```
82
83
 
84
+ For a project agy loop, the wizard recommends the hard completion gate. In
85
+ automation, opt in explicitly:
86
+
87
+ ```bash
88
+ agent-ops init --scope project --harness agy --profile loop --completion-gate --yes
89
+ ```
90
+
91
+ The gate lets pure Q&A, analysis, and read-only diagnostics stop normally. It
92
+ activates only after that conversation creates a Git-visible net change, then
93
+ requires an attached completed task with two to five criteria, current PASS
94
+ evidence, and a PASS review for the current source fingerprint. It never runs
95
+ tests or review from `Stop`. Error, max-step, and non-idle stops continue. A
96
+ user can approve one fingerprint-bound escape with
97
+ `agent-ops allow-stop --session <conversationId>`. Use
98
+ `agent-ops agy-run -- <agy arguments>` for a process-exit recheck in headless or
99
+ CI use. Codex, Claude Code, and OpenCode retain their existing non-blocking Stop
100
+ behavior in this release.
101
+
83
102
  On native Windows, select `--harness claude` unless Codex is running in a
84
103
  POSIX environment; the Codex loop still invokes `bash`.
85
104
 
@@ -250,10 +269,10 @@ After changing Stop configuration, run `agent-ops update`; its approved plan
250
269
  updates native registrations and the exact trust binding together. Stop is
251
270
  report-only: `PASS`, `FAIL`, and `UNKNOWN` continue
252
271
  the harness, emit bounded command evidence, and never complete a task.
253
- Config v1 migrates to config v2 with Stop disabled; migration invalidates the
254
- old trust binding, and pre-v1 binaries cannot read the migrated config.
272
+ Older configs migrate to config v3 with both Stop verification and the agy
273
+ completion gate disabled; migration invalidates the old trust binding.
255
274
 
256
- The generated `AGENTS.md` and `CLAUDE.md` routing blocks are supplemental: they
275
+ The generated `AGENTS.md`, `CLAUDE.md`, and agy `GEMINI.md` routing blocks are supplemental: they
257
276
  load the managed baseline while leaving project-specific instructions in those
258
277
  files authoritative. Existing installations with the previous canonical
259
278
  wording are migrated by `agent-ops update`; changed managed blocks still fail
@@ -0,0 +1,18 @@
1
+ /** Headless wrapper: native Stop is bounded by agy, process exit is not. */
2
+ export async function runAgyHeadless(options) {
3
+ await options.gate.initialize(options.sessionId);
4
+ const exitCode = await options.run(options.args, {
5
+ ...(options.env ?? process.env),
6
+ AGENT_OPS_SESSION_ID: options.sessionId
7
+ });
8
+ if (exitCode !== 0)
9
+ return exitCode;
10
+ const result = await options.gate.handle({
11
+ event: "stop",
12
+ projectRoot: options.root,
13
+ sessionId: options.sessionId,
14
+ terminationReason: "model_stop",
15
+ fullyIdle: true
16
+ });
17
+ return result?.action === "block" ? 2 : 0;
18
+ }
@@ -8,7 +8,8 @@ export const COMMAND_NAMES = [
8
8
  "uninstall",
9
9
  "task",
10
10
  "verify",
11
- "review"
11
+ "review",
12
+ "allow-stop"
12
13
  ];
13
14
  const COMMAND_SET = new Set(COMMAND_NAMES);
14
15
  const SCOPES = new Set(["project", "user"]);
@@ -73,6 +74,7 @@ export function parseArgs(argv) {
73
74
  const criteria = [];
74
75
  const evidence = [];
75
76
  let checkAuth = false;
77
+ let completionGate;
76
78
  let dryRun = false;
77
79
  let json = false;
78
80
  let yes = false;
@@ -203,6 +205,12 @@ export function parseArgs(argv) {
203
205
  }
204
206
  checkAuth = true;
205
207
  break;
208
+ case "--completion-gate":
209
+ if (completionGate !== undefined) {
210
+ duplicate(token);
211
+ }
212
+ completionGate = true;
213
+ break;
206
214
  case "--dry-run":
207
215
  if (dryRun) {
208
216
  duplicate(token);
@@ -291,6 +299,7 @@ export function parseArgs(argv) {
291
299
  criteria.length > 0 ||
292
300
  evidence.length > 0 ||
293
301
  reviewTargets.length > 0 ||
302
+ completionGate !== undefined ||
294
303
  sessionId !== undefined ||
295
304
  base !== undefined ||
296
305
  checkAuth ||
@@ -317,6 +326,7 @@ export function parseArgs(argv) {
317
326
  if (command !== "task" &&
318
327
  command !== "verify" &&
319
328
  command !== "review" &&
329
+ command !== "allow-stop" &&
320
330
  (hasTaskTargetOptions || hasTaskMutationOptions)) {
321
331
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Task target options may be used only with task or verify.");
322
332
  }
@@ -340,6 +350,9 @@ export function parseArgs(argv) {
340
350
  if (reviewTargets.length > 0 && command !== "init") {
341
351
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--review-target may be used only with init.");
342
352
  }
353
+ if (completionGate !== undefined && command !== "init") {
354
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--completion-gate may be used only with init.");
355
+ }
343
356
  if (command === "task") {
344
357
  if (harness !== undefined ||
345
358
  profiles.length > 0 ||
@@ -349,8 +362,7 @@ export function parseArgs(argv) {
349
362
  }
350
363
  const taskOptionInvalid = (action === "create" &&
351
364
  (taskId !== undefined ||
352
- evidence.length > 0 ||
353
- sessionId !== undefined)) ||
365
+ evidence.length > 0)) ||
354
366
  (action === "status" &&
355
367
  (title !== undefined ||
356
368
  criteria.length > 0 ||
@@ -403,6 +415,19 @@ export function parseArgs(argv) {
403
415
  if (command === "review" && harness !== undefined && harness.length !== 1) {
404
416
  invalidValue("--harness", harness.join(","));
405
417
  }
418
+ if (command === "allow-stop" &&
419
+ (sessionId === undefined ||
420
+ scope !== undefined ||
421
+ harness !== undefined ||
422
+ taskId !== undefined ||
423
+ profiles.length > 0 ||
424
+ title !== undefined ||
425
+ criteria.length > 0 ||
426
+ evidence.length > 0 ||
427
+ dryRun ||
428
+ yes)) {
429
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "allow-stop requires only --session (and optional --json).");
430
+ }
406
431
  return {
407
432
  command,
408
433
  ...(action === undefined ? {} : { action }),
@@ -415,6 +440,7 @@ export function parseArgs(argv) {
415
440
  ...(targetVersion === undefined ? {} : { targetVersion }),
416
441
  ...(title === undefined ? {} : { title }),
417
442
  ...(reviewTargets.length === 0 ? {} : { reviewTargets }),
443
+ ...(completionGate === undefined ? {} : { completionGate }),
418
444
  ...(criteria.length === 0 ? {} : { criteria }),
419
445
  ...(evidence.length === 0 ? {} : { evidence }),
420
446
  ...(sessionId === undefined ? {} : { sessionId }),
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from "node:fs/promises";
3
+ import { randomUUID } from "node:crypto";
3
4
  import { homedir } from "node:os";
4
5
  import { join } from "node:path";
5
- import { execFileSync } from "node:child_process";
6
+ import { execFileSync, spawn } from "node:child_process";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import { commonHarnessAdapters, harnessHookPath, HARNESS_IDS } from "../../../runtime/src/install/harness.js";
8
9
  import { agyRuntimeStatus, agyVersionSupported, hookRegistrationDrift, repositoryTrustStatus, smokeAvailabilityStatus } from "../../../runtime/src/install/probes.js";
@@ -33,8 +34,11 @@ import { probeReviewTarget } from "../../../runtime/src/review/probe.js";
33
34
  import { resolveReviewRole } from "../../../runtime/src/review/roles.js";
34
35
  import { runTrustCommand } from "./commands/trust.js";
35
36
  import { runVerifyCommand } from "./commands/verify.js";
37
+ import { runAllowStopCommand } from "./commands/allow-stop.js";
38
+ import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
36
39
  import { formatUpdatePlan, runUpdateCommand } from "./commands/update.js";
37
40
  import { errorEnvelope } from "./output.js";
41
+ import { runAgyHeadless } from "./agy-headless.js";
38
42
  const HOOK_RUNTIME_PATH = fileURLToPath(new URL("./hook-entry.js", import.meta.url));
39
43
  async function readOptionalText(path) {
40
44
  try {
@@ -122,7 +126,40 @@ function runAgy(args, options = {}) {
122
126
  ...(process.platform === "win32" ? { shell: true } : {})
123
127
  });
124
128
  }
125
- if (argv[0] === "hook") {
129
+ if (argv[0] === "agy-run") {
130
+ try {
131
+ const root = process.cwd();
132
+ const config = (await loadEffectiveConfig(root, "project")).config;
133
+ if (!config.features.completionGate.enabled) {
134
+ throw new Error("agy-run requires features.completionGate.enabled.");
135
+ }
136
+ const taskService = new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root));
137
+ const gate = new CompletionGateService({
138
+ root,
139
+ config,
140
+ gitRunner: gitRunner(root),
141
+ taskService,
142
+ evidenceStore: new FileEvidenceStore(root, root)
143
+ });
144
+ const agyArgs = argv[1] === "--" ? argv.slice(2) : argv.slice(1);
145
+ process.exitCode = await runAgyHeadless({
146
+ root,
147
+ sessionId: `agy-headless-${randomUUID()}`,
148
+ args: agyArgs,
149
+ gate,
150
+ run: async (args, env) => await new Promise((resolve) => {
151
+ const child = spawn("agy", [...args], { cwd: root, env, stdio: "inherit" });
152
+ child.once("error", () => resolve(1));
153
+ child.once("exit", (code) => resolve(code ?? 1));
154
+ })
155
+ });
156
+ }
157
+ catch (error) {
158
+ process.stderr.write(`${error instanceof Error ? error.message : "agy-run failed."}\n`);
159
+ process.exitCode = 2;
160
+ }
161
+ }
162
+ else if (argv[0] === "hook") {
126
163
  process.exitCode = await runHookProcess(argv.slice(1), {
127
164
  stdin: process.stdin,
128
165
  writeStdout: (value) => process.stdout.write(value),
@@ -147,7 +184,8 @@ else {
147
184
  "uninstall",
148
185
  "task",
149
186
  "verify",
150
- "review"
187
+ "review",
188
+ "allow-stop"
151
189
  ].map((command) => [command, async (args) => {
152
190
  const root = args.scope === "user"
153
191
  ? process.env.AGENT_OPS_HOME ?? homedir()
@@ -316,6 +354,19 @@ else {
316
354
  });
317
355
  }
318
356
  const taskService = new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root));
357
+ if (args.command === "allow-stop") {
358
+ const config = (await loadEffectiveConfig(root, "project")).config;
359
+ return await runAllowStopCommand({
360
+ args,
361
+ gate: new CompletionGateService({
362
+ root,
363
+ config,
364
+ gitRunner: gitRunner(root),
365
+ taskService,
366
+ evidenceStore: new FileEvidenceStore(root, root)
367
+ })
368
+ });
369
+ }
319
370
  if (args.command === "task") {
320
371
  const sessionId = process.env.AGENT_OPS_SESSION_ID;
321
372
  const policyConfigHash = args.action === "create"
@@ -27,6 +27,8 @@ Commands:
27
27
  Manage independent task acceptance state
28
28
  verify Run configured verification
29
29
  review Run an independent review
30
+ allow-stop Grant one fingerprint-bound agy Stop permit (requires --session)
31
+ agy-run Run headless agy with a process-exit completion recheck
30
32
 
31
33
  Options:
32
34
  --scope <project|user>
@@ -35,6 +37,7 @@ Options:
35
37
  --profile <core|advisory|guardrails|loop> Repeatable
36
38
  --review-target <codex|agy|claude> Repeatable init option; external review
37
39
  targets in fallback-chain order
40
+ --completion-gate Init only: enable the agy project-loop gate
38
41
  --check-auth Doctor only: probe each review target's
39
42
  authentication with one real call
40
43
  --task <id>
@@ -0,0 +1,11 @@
1
+ import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
2
+ import { okEnvelope } from "../output.js";
3
+ export async function runAllowStopCommand(options) {
4
+ if (options.args.command !== "allow-stop" || options.args.sessionId === undefined) {
5
+ throw new AgentOpsError("COMPLETION_GATE_SESSION_REQUIRED", "allow-stop requires --session.");
6
+ }
7
+ await options.gate.grantPermit(options.args.sessionId);
8
+ return okEnvelope("COMPLETION_GATE_PERMIT_GRANTED", {
9
+ message: "One Stop is permitted for this conversation and current source fingerprint."
10
+ });
11
+ }
@@ -49,11 +49,22 @@ export async function runHookCommand(options) {
49
49
  capabilities,
50
50
  trusted: options.trusted,
51
51
  advisory: options.advisory ?? runLifecycleAdvisory,
52
- ...(stopVerification === undefined ? {} : { stopVerification })
52
+ ...(stopVerification === undefined ? {} : { stopVerification }),
53
+ ...(options.completionGate === undefined
54
+ ? {}
55
+ : { completionGate: options.completionGate })
53
56
  });
54
57
  return descriptor.runtime.formatOutput(options.event, result);
55
58
  }
56
59
  catch {
60
+ if (options.harness === "agy" && options.completionGate !== undefined) {
61
+ return harnessDescriptor("agy").runtime.formatOutput(options.event, {
62
+ action: "block",
63
+ status: "UNKNOWN",
64
+ code: "COMPLETION_GATE_UNAVAILABLE",
65
+ remedy: "Run agent-ops doctor; a user-approved one-time permit may be used after diagnosing the failure."
66
+ });
67
+ }
57
68
  return { exitCode: 0, stdout: "", stderr: "" };
58
69
  }
59
70
  }
@@ -102,7 +102,10 @@ export async function runInitCommand(options) {
102
102
  : { hookTargets: options.hookTargets ?? args.hookTargets }),
103
103
  ...(args.reviewTargets === undefined
104
104
  ? {}
105
- : { reviewTargets: args.reviewTargets })
105
+ : { reviewTargets: args.reviewTargets }),
106
+ ...(args.completionGate === undefined
107
+ ? {}
108
+ : { completionGateEnabled: args.completionGate })
106
109
  });
107
110
  const trust = await trustChange(options, plan);
108
111
  const warnings = plan.harness.includes("agy") && options.agyWarning !== undefined
@@ -95,9 +95,12 @@ export async function runTaskCommand(options) {
95
95
  : { policyConfigHash: options.policyConfigHash }),
96
96
  ...(options.args.parentTaskId === undefined
97
97
  ? {}
98
- : { parentTaskId: options.args.parentTaskId })
98
+ : { parentTaskId: options.args.parentTaskId }),
99
+ ...(sessionId === undefined ? {} : { sessionId })
99
100
  });
100
- return taskEnvelope(action, "TASK_CREATED", `Created task ${record.task.id}.`, record);
101
+ return taskEnvelope(action, "TASK_CREATED", sessionId === undefined
102
+ ? `Created task ${record.task.id}.`
103
+ : `Created and attached task ${record.task.id}.`, record);
101
104
  }
102
105
  if (action === "status") {
103
106
  if (options.args.taskId === undefined && sessionId === undefined) {
@@ -9,12 +9,15 @@ import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
9
9
  import { localStatePaths } from "../../../runtime/src/security/permissions.js";
10
10
  import { calculateTrustBinding, FileTrustStore } from "../../../runtime/src/security/trust.js";
11
11
  export const DEFAULT_CONFIG = {
12
- schemaVersion: 2,
12
+ schemaVersion: 3,
13
13
  profiles: [],
14
14
  verification: { commands: [] },
15
15
  features: {
16
16
  stopVerification: {
17
17
  enabled: false
18
+ },
19
+ completionGate: {
20
+ enabled: false
18
21
  }
19
22
  },
20
23
  pathMappings: [],
@@ -9,6 +9,10 @@ import { resolveContainedPath } from "../../../runtime/src/fs/paths.js";
9
9
  import { redactSecrets } from "../../../runtime/src/security/redact.js";
10
10
  import { claudeStopRecursionMarker } from "../../../runtime/src/adapters/claude/input.js";
11
11
  import { HARNESS_IDS, harnessDescriptor } from "../../../runtime/src/install/harness.js";
12
+ import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
13
+ import { TaskService } from "../../../runtime/src/task/service.js";
14
+ import { FileTaskStore } from "../../../runtime/src/task/store.js";
15
+ import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
12
16
  import { STOP_VERIFICATION_ENV, StopVerificationService } from "../../../runtime/src/hooks/stop-service.js";
13
17
  import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
14
18
  import { normalizeHookInput, runHookCommand, HOOK_EVENTS } from "./commands/hook.js";
@@ -40,6 +44,16 @@ function parseInput(source) {
40
44
  return null;
41
45
  }
42
46
  }
47
+ function withInjectedSession(harness, input) {
48
+ const sessionId = process.env.AGENT_OPS_SESSION_ID;
49
+ return harness === "agy" &&
50
+ sessionId !== undefined &&
51
+ typeof input === "object" &&
52
+ input !== null &&
53
+ !Array.isArray(input)
54
+ ? { ...input, conversationId: sessionId }
55
+ : input;
56
+ }
43
57
  function writeHookOutput(io, output) {
44
58
  if (output.stdout.length > 0) {
45
59
  io.writeStdout(output.stdout);
@@ -195,11 +209,14 @@ function shouldBuildStopVerification(harness, event, config, rawInput) {
195
209
  return harnessDescriptor(harness).control.registrations.some(({ capability, support }) => capability === "optional-stop-verify" && support !== "unsupported");
196
210
  }
197
211
  /**
198
- * Runs one hook invocation. Always resolves to exit code 0: a hook that
199
- * cannot answer must never block the harness it advises.
212
+ * Runs one hook invocation. Exit code stays zero because native JSON carries
213
+ * decisions; only an explicitly installed agy completion gate fails closed.
200
214
  */
201
215
  export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
202
216
  const [harness, event] = argv;
217
+ const completionGateInstalled = harness === "agy" &&
218
+ event === "Stop" &&
219
+ argv.includes("--completion-gate");
203
220
  if (harness === undefined ||
204
221
  !HARNESSES.has(harness) ||
205
222
  event === undefined ||
@@ -212,13 +229,31 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
212
229
  const harnessId = harness;
213
230
  const hookEvent = event;
214
231
  if (process.env.AGENT_OPS_DISABLE === "1") {
232
+ if (completionGateInstalled) {
233
+ writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
234
+ action: "block",
235
+ status: "UNKNOWN",
236
+ code: "COMPLETION_GATE_DISABLE_REJECTED",
237
+ remedy: "Use a user-approved one-time permit instead of disabling agent-ops."
238
+ }));
239
+ return 0;
240
+ }
215
241
  writeHookOutput(io, failOpenOutput(harnessId, hookEvent));
216
242
  return 0;
217
243
  }
218
244
  const rawInput = await readStdin(io.stdin);
219
- const parsedInput = parseInput(rawInput);
245
+ const parsedInput = withInjectedSession(harnessId, parseInput(rawInput));
220
246
  const configOutcome = await hookConfigOutcome(root, dependencies.loadConfig);
221
247
  if (configOutcome.kind === "invalid") {
248
+ if (completionGateInstalled) {
249
+ writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
250
+ action: "block",
251
+ status: "UNKNOWN",
252
+ code: "COMPLETION_GATE_CONFIG_INVALID",
253
+ remedy: `Fix ${redactSecrets(configOutcome.path)} and run agent-ops doctor.`
254
+ }));
255
+ return 0;
256
+ }
222
257
  const output = await invalidConfigOutput({
223
258
  root,
224
259
  harness: harnessId,
@@ -249,6 +284,17 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
249
284
  processRunner
250
285
  })
251
286
  : undefined;
287
+ const completionGate = harnessId === "agy" && config.features.completionGate.enabled
288
+ ? dependencies.completionGate ?? {
289
+ handle: async (normalized) => await new CompletionGateService({
290
+ root,
291
+ config,
292
+ gitRunner,
293
+ taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
294
+ evidenceStore: new FileEvidenceStore(root, root)
295
+ }).handle(normalized)
296
+ }
297
+ : undefined;
252
298
  const output = await runHookCommand({
253
299
  harness: harness,
254
300
  event: hookEvent,
@@ -258,12 +304,20 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
258
304
  ...(dependencies.advisory === undefined
259
305
  ? {}
260
306
  : { advisory: dependencies.advisory }),
261
- ...(stopVerification === undefined ? {} : { stopVerification })
307
+ ...(stopVerification === undefined ? {} : { stopVerification }),
308
+ ...(completionGate === undefined ? {} : { completionGate })
262
309
  });
263
310
  writeHookOutput(io, output);
264
311
  }
265
312
  catch {
266
- // ponytail: fail-open by design; hook failures stay invisible to the harness.
313
+ if (completionGateInstalled) {
314
+ writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
315
+ action: "block",
316
+ status: "UNKNOWN",
317
+ code: "COMPLETION_GATE_UNAVAILABLE",
318
+ remedy: "Run agent-ops doctor; use a user-approved one-time permit only after diagnosis."
319
+ }));
320
+ }
267
321
  }
268
322
  return 0;
269
323
  }
@@ -34,6 +34,11 @@ function selectReviewTargets(raw, defaults = []) {
34
34
  function affirmative(raw) {
35
35
  return /^(y|yes)$/i.test(raw.trim());
36
36
  }
37
+ function completionGateEligible(scope, harness, profiles) {
38
+ return scope === "project" &&
39
+ harness.includes("agy") &&
40
+ profiles.includes("loop");
41
+ }
37
42
  async function probeReviewTargets(targets, setup) {
38
43
  const probe = setup.probeReviewTarget;
39
44
  if (probe === undefined) {
@@ -161,6 +166,20 @@ export async function completeInitChoices(args, io, setup = {}) {
161
166
  selectAllLabel: "Select all",
162
167
  selectAllDescription: "Enable core, advisory, guardrails, and loop together."
163
168
  });
169
+ const completionGate = args.completionGate ?? (completionGateEligible(scope, harness, profiles)
170
+ ? await selectOption("Enable the agy completion gate?", [
171
+ {
172
+ label: "yes",
173
+ value: true,
174
+ description: "Recommended. Blocks changed sessions until task evidence and review pass."
175
+ },
176
+ {
177
+ label: "no",
178
+ value: false,
179
+ description: "Keep advisory loop behavior."
180
+ }
181
+ ], selectorIo)
182
+ : false);
164
183
  const enabled = args.reviewTargets !== undefined ||
165
184
  (await selectOption("External review: call another agent CLI to review your work?", [
166
185
  { label: "no", value: false, description: "Default. Nothing is spawned." },
@@ -180,6 +199,7 @@ export async function completeInitChoices(args, io, setup = {}) {
180
199
  scope,
181
200
  harness,
182
201
  profiles,
202
+ ...(completionGate ? { completionGate: true } : {}),
183
203
  ...(reviewTargets.length === 0 ? {} : { reviewTargets })
184
204
  };
185
205
  }
@@ -192,6 +212,9 @@ export async function completeInitChoices(args, io, setup = {}) {
192
212
  const profiles = args.profiles.length > 0
193
213
  ? args.profiles
194
214
  : selectProfiles(await session.question("Profiles (core,advisory,guardrails,loop) [core]: "));
215
+ const completionGate = args.completionGate ?? (completionGateEligible(scope, harness, profiles)
216
+ ? !/^(n|no)$/i.test((await session.question("Enable the recommended agy completion gate? [Y/n]: ")).trim())
217
+ : false);
195
218
  const defaultReviewTargets = REVIEW_TARGET_ORDER.filter((target) => harness.includes(target));
196
219
  const reviewTargets = args.reviewTargets ?? (affirmative(await session.question("Enable external review by another agent CLI? [y/N]: "))
197
220
  ? selectReviewTargets(await session.question(`Review targets (${defaultReviewTargets.join(",") || "none"}): `), defaultReviewTargets)
@@ -202,6 +225,7 @@ export async function completeInitChoices(args, io, setup = {}) {
202
225
  scope,
203
226
  harness,
204
227
  profiles,
228
+ ...(completionGate ? { completionGate: true } : {}),
205
229
  ...(reviewTargets.length === 0 ? {} : { reviewTargets })
206
230
  };
207
231
  }
@@ -5,10 +5,11 @@ function record(value) {
5
5
  ? value
6
6
  : null;
7
7
  }
8
- function handler(runtimePath, event, platform) {
8
+ function handler(runtimePath, event, platform, completionGate = false) {
9
+ const gateFlag = completionGate ? " --completion-gate" : "";
9
10
  const command = platform === "win32"
10
- ? `cmd /c node ${JSON.stringify(runtimePath).replaceAll("\\\\", "\\")} agy ${event} ${MARKER}`
11
- : `node ${JSON.stringify(runtimePath)} agy ${event} ${MARKER}`;
11
+ ? `cmd /c node ${JSON.stringify(runtimePath).replaceAll("\\\\", "\\")} agy ${event}${gateFlag} ${MARKER}`
12
+ : `node ${JSON.stringify(runtimePath)} agy ${event}${gateFlag} ${MARKER}`;
12
13
  return {
13
14
  type: "command",
14
15
  command,
@@ -27,7 +28,9 @@ function managedHandler(value, event) {
27
28
  typeof candidate.command === "string" &&
28
29
  (candidate.command.startsWith("node \"") ||
29
30
  candidate.command.startsWith("cmd /c node \"")) &&
30
- candidate.command.endsWith(` agy ${event} ${MARKER}`);
31
+ (candidate.command.endsWith(` agy ${event} ${MARKER}`) ||
32
+ (event === "Stop" &&
33
+ candidate.command.endsWith(` agy Stop --completion-gate ${MARKER}`)));
31
34
  }
32
35
  function managedEvent(value, event) {
33
36
  if (!Array.isArray(value) || value.length !== 1)
@@ -57,8 +60,9 @@ export function buildAgyHookSettings(capabilities, runtimePath, platform = proce
57
60
  if (capabilities.includes("command-policy") || capabilities.includes("project-loop")) {
58
61
  hooks.PreToolUse = [{ matcher: "run_command", hooks: [handler(runtimePath, "PreToolUse", platform)] }];
59
62
  }
60
- if (capabilities.includes("optional-stop-verify")) {
61
- hooks.Stop = [handler(runtimePath, "Stop", platform)];
63
+ if (capabilities.includes("optional-stop-verify") ||
64
+ capabilities.includes("completion-gate")) {
65
+ hooks.Stop = [handler(runtimePath, "Stop", platform, capabilities.includes("completion-gate"))];
62
66
  }
63
67
  return { hooks };
64
68
  }
@@ -79,8 +83,14 @@ export function isAgyHookRegistered(value, capabilities) {
79
83
  const named = record(record(value)?.["agent-ops"]);
80
84
  if (named === null || !isAgyManagedHook(value))
81
85
  return false;
82
- return Object.keys(expected)
86
+ const eventsMatch = Object.keys(expected)
83
87
  .every((event) => managedEvent(named[event], event));
88
+ if (!eventsMatch || !capabilities.includes("completion-gate")) {
89
+ return eventsMatch;
90
+ }
91
+ const stop = Array.isArray(named.Stop) ? record(named.Stop[0]) : null;
92
+ return typeof stop?.command === "string" &&
93
+ stop.command.endsWith(` agy Stop --completion-gate ${MARKER}`);
84
94
  }
85
95
  export function mergeAgyHooks(existing, managed) {
86
96
  const source = record(existing);
@@ -16,6 +16,14 @@ export const AGY_CAPABILITY_REGISTRATIONS = [
16
16
  support: "supported",
17
17
  runtimeFailure: "fail-closed"
18
18
  },
19
+ {
20
+ capability: "completion-gate",
21
+ normalizedEvent: "stop",
22
+ nativeEvent: "Stop",
23
+ surfaceId: "agy-hooks",
24
+ support: "supported",
25
+ runtimeFailure: "fail-closed"
26
+ },
19
27
  {
20
28
  capability: "optional-stop-verify",
21
29
  normalizedEvent: "stop",
@@ -15,16 +15,34 @@ export function normalizeAgyHookInput(input) {
15
15
  const projectRoot = typeof workspacePaths[0] === "string"
16
16
  ? workspacePaths[0]
17
17
  : undefined;
18
+ const sessionId = typeof value.conversationId === "string"
19
+ ? value.conversationId
20
+ : undefined;
18
21
  const toolCall = record(value.toolCall);
19
22
  const args = record(toolCall?.args);
20
23
  if (toolCall?.name === "run_command" && typeof args?.CommandLine === "string") {
21
- return normalizeShellHookEvent(args.CommandLine, projectRoot);
24
+ return {
25
+ ...normalizeShellHookEvent(args.CommandLine, projectRoot),
26
+ ...(sessionId === undefined ? {} : { sessionId })
27
+ };
22
28
  }
23
29
  if (typeof value.terminationReason === "string") {
24
- return normalizeHookEvent({ event: "stop", projectRoot });
30
+ return {
31
+ event: "stop",
32
+ projectRoot: projectRoot ?? process.cwd(),
33
+ ...(sessionId === undefined ? {} : { sessionId }),
34
+ terminationReason: value.terminationReason,
35
+ ...(typeof value.fullyIdle === "boolean"
36
+ ? { fullyIdle: value.fullyIdle }
37
+ : {})
38
+ };
25
39
  }
26
40
  if (typeof value.invocationNum === "number") {
27
- return normalizeHookEvent({ event: "session-start", projectRoot });
41
+ return {
42
+ event: "session-start",
43
+ projectRoot: projectRoot ?? process.cwd(),
44
+ ...(sessionId === undefined ? {} : { sessionId })
45
+ };
28
46
  }
29
47
  return normalizeHookEvent({ event: "unsupported", projectRoot });
30
48
  }