@kylecheng3146/agent-ops 0.1.20 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) 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 +35 -5
  4. package/dist/packages/cli/src/bin.js +115 -37
  5. package/dist/packages/cli/src/cli.js +4 -1
  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/review.js +31 -21
  10. package/dist/packages/cli/src/commands/task.js +5 -2
  11. package/dist/packages/cli/src/context.js +4 -1
  12. package/dist/packages/cli/src/hook-process.js +59 -5
  13. package/dist/packages/cli/src/wizard.js +24 -0
  14. package/dist/runtime/src/adapters/agy/config.js +17 -7
  15. package/dist/runtime/src/adapters/agy/events.js +8 -0
  16. package/dist/runtime/src/adapters/agy/input.js +21 -3
  17. package/dist/runtime/src/adapters/agy/output.js +8 -4
  18. package/dist/runtime/src/config/explain.js +5 -0
  19. package/dist/runtime/src/config/migrate.js +15 -1
  20. package/dist/runtime/src/contracts.js +1 -1
  21. package/dist/runtime/src/hooks/completion-gate.js +209 -0
  22. package/dist/runtime/src/hooks/dispatch.js +6 -0
  23. package/dist/runtime/src/install/doctor.js +3 -1
  24. package/dist/runtime/src/install/harness.js +5 -1
  25. package/dist/runtime/src/install/ownership.js +9 -2
  26. package/dist/runtime/src/install/plan.js +19 -4
  27. package/dist/runtime/src/install/profiles.js +3 -0
  28. package/dist/runtime/src/review/execute.js +40 -7
  29. package/dist/runtime/src/review/render.js +3 -1
  30. package/dist/runtime/src/review/runner.js +1 -0
  31. package/dist/runtime/src/schema/validate.js +11 -1
  32. package/dist/runtime/src/task/service.js +17 -0
  33. package/dist/runtime/src/verify/spawn.js +30 -8
  34. package/docs/en/guides/configuration.md +37 -7
  35. package/docs/en/spec/README.md +3 -3
  36. package/docs/en/spec/harness-adapters.md +7 -3
  37. package/docs/en/spec/review.md +11 -0
  38. package/docs/zh-TW/guides/configuration.md +32 -6
  39. package/docs/zh-TW/spec/README.md +4 -4
  40. package/docs/zh-TW/spec/harness-adapters.md +6 -3
  41. package/docs/zh-TW/spec/review.md +10 -0
  42. package/package.json +1 -1
  43. package/schemas/config.schema.json +12 -2
@@ -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
  }
@@ -12,13 +12,17 @@ export function agyHookOutput(event, result) {
12
12
  ? `agent-ops: ${result.code}`
13
13
  : `agent-ops: ${result.code}: ${result.remedy}`;
14
14
  const value = event === "PreToolUse"
15
- ? result.action === "block"
16
- ? { decision: "deny", reason }
17
- : { decision: "allow" }
15
+ ? result.code === "COMPLETION_GATE_PERMIT_CONFIRMATION"
16
+ ? { decision: "force_ask", reason }
17
+ : result.action === "block"
18
+ ? { decision: "deny", reason }
19
+ : { decision: "allow" }
18
20
  : event === "SessionStart"
19
21
  ? { injectSteps: [{ ephemeralMessage: reason }] }
20
22
  : event === "Stop"
21
- ? { decision: "allow", reason }
23
+ ? result.action === "block" && result.code.startsWith("COMPLETION_GATE_")
24
+ ? { decision: "continue", reason }
25
+ : { decision: "allow", reason }
22
26
  : {};
23
27
  return { exitCode: 0, stdout: JSON.stringify(value), stderr: "" };
24
28
  }
@@ -2,6 +2,11 @@ export function explainConfig(merged) {
2
2
  return {
3
3
  schemaVersion: merged.config.schemaVersion,
4
4
  features: {
5
+ completionGate: {
6
+ enabled: merged.config.features.completionGate.enabled,
7
+ source: merged.provenance.features.source,
8
+ sourcePath: merged.provenance.features.sourcePath
9
+ },
5
10
  stopVerification: {
6
11
  enabled: merged.config.features.stopVerification.enabled,
7
12
  source: merged.provenance.features.source,
@@ -25,7 +25,7 @@ const MIGRATIONS = new Map([
25
25
  const { schemaVersion: _schemaVersion, ...rest } = input;
26
26
  return {
27
27
  ...rest,
28
- schemaVersion: CONFIG_SCHEMA_VERSION,
28
+ schemaVersion: 2,
29
29
  features: {
30
30
  stopVerification: {
31
31
  enabled: false
@@ -33,6 +33,20 @@ const MIGRATIONS = new Map([
33
33
  }
34
34
  };
35
35
  }
36
+ ],
37
+ [
38
+ 2,
39
+ (input) => {
40
+ const { schemaVersion: _schemaVersion, features, ...rest } = input;
41
+ return {
42
+ ...rest,
43
+ schemaVersion: CONFIG_SCHEMA_VERSION,
44
+ features: {
45
+ ...(isRecord(features) ? features : {}),
46
+ completionGate: { enabled: false }
47
+ }
48
+ };
49
+ }
36
50
  ]
37
51
  ]);
38
52
  function schemaVersionOf(value) {
@@ -1,4 +1,4 @@
1
- export const CONFIG_SCHEMA_VERSION = 2;
1
+ export const CONFIG_SCHEMA_VERSION = 3;
2
2
  export const TASK_SCHEMA_VERSION = 1;
3
3
  export const EVIDENCE_SCHEMA_VERSION = 2;
4
4
  /** @deprecated Use the document-specific schema version constants. */
@@ -0,0 +1,209 @@
1
+ import { join } from "node:path";
2
+ import { calculateConfigHash } from "../config/hash.js";
3
+ import { sha256 } from "../fs/hash.js";
4
+ import { AgentOpsError } from "../fs/paths.js";
5
+ import { findReviewAttestation } from "../review/attestation.js";
6
+ import { validateEvidence, validateTaskAgainstConfig } from "../schema/validate.js";
7
+ import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
8
+ import { isPassingVerificationEvidence } from "../verify/evidence.js";
9
+ import { collectChangeSurface } from "../verify/change-surface.js";
10
+ import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
11
+ const FINGERPRINT = /^[a-f0-9]{64}$/u;
12
+ const SESSION = /^[^\0\r\n]{1,256}$/u;
13
+ function gateResult(action, status, code, remedy) {
14
+ return { action, status, code, ...(remedy === undefined ? {} : { remedy }) };
15
+ }
16
+ function statePath(root, sessionId) {
17
+ return join(root, ".agent-ops", "tasks", "completion-gate", `${sha256(sessionId)}.json`);
18
+ }
19
+ function parseState(source, sessionId) {
20
+ if (source === null)
21
+ return null;
22
+ let value;
23
+ try {
24
+ value = JSON.parse(source);
25
+ }
26
+ catch {
27
+ throw new AgentOpsError("COMPLETION_GATE_STATE_INVALID", "Completion-gate state is not valid JSON.");
28
+ }
29
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
30
+ throw new AgentOpsError("COMPLETION_GATE_STATE_INVALID", "Completion-gate state is invalid.");
31
+ }
32
+ const record = value;
33
+ if (Object.keys(record).sort().join(",") !==
34
+ "baselineFingerprint,permitFingerprint,schemaVersion,sessionId" ||
35
+ record.schemaVersion !== 1 ||
36
+ record.sessionId !== sessionId ||
37
+ typeof record.baselineFingerprint !== "string" ||
38
+ !FINGERPRINT.test(record.baselineFingerprint) ||
39
+ (record.permitFingerprint !== null &&
40
+ (typeof record.permitFingerprint !== "string" ||
41
+ !FINGERPRINT.test(record.permitFingerprint)))) {
42
+ throw new AgentOpsError("COMPLETION_GATE_STATE_INVALID", "Completion-gate state is invalid.");
43
+ }
44
+ return record;
45
+ }
46
+ export class FileCompletionGateStore {
47
+ #root;
48
+ constructor(root) {
49
+ this.#root = root;
50
+ }
51
+ async read(sessionId) {
52
+ if (!SESSION.test(sessionId)) {
53
+ throw new AgentOpsError("COMPLETION_GATE_SESSION_INVALID", "Completion gate requires a valid session identity.");
54
+ }
55
+ const path = statePath(this.#root, sessionId);
56
+ return await withPrivateFileLock(path, this.#root, async () => parseState(await readPrivateFile(path, this.#root), sessionId));
57
+ }
58
+ async mutate(sessionId, action) {
59
+ if (!SESSION.test(sessionId)) {
60
+ throw new AgentOpsError("COMPLETION_GATE_SESSION_INVALID", "Completion gate requires a valid session identity.");
61
+ }
62
+ const path = statePath(this.#root, sessionId);
63
+ return await withPrivateFileLock(path, this.#root, async () => {
64
+ const next = action(parseState(await readPrivateFile(path, this.#root), sessionId));
65
+ parseState(JSON.stringify(next), sessionId);
66
+ await writePrivateFile(path, `${JSON.stringify(next, null, 2)}\n`, this.#root);
67
+ return next;
68
+ });
69
+ }
70
+ }
71
+ export class CompletionGateService {
72
+ #options;
73
+ #store;
74
+ constructor(options) {
75
+ this.#options = options;
76
+ this.#store = options.stateStore ?? new FileCompletionGateStore(options.root);
77
+ }
78
+ async #fingerprint() {
79
+ const surface = await collectChangeSurface(this.#options.gitRunner);
80
+ return await calculateSourceFingerprint(this.#options.root, { mode: "worktree", changedFiles: surface.paths }, this.#options.gitRunner);
81
+ }
82
+ async initialize(sessionId) {
83
+ const fingerprint = await this.#fingerprint();
84
+ const state = await this.#store.mutate(sessionId, (current) => current ?? {
85
+ schemaVersion: 1,
86
+ sessionId,
87
+ baselineFingerprint: fingerprint,
88
+ permitFingerprint: null
89
+ });
90
+ const changed = state.baselineFingerprint !== fingerprint;
91
+ return gateResult("continue", changed ? "UNKNOWN" : "PASS", changed ? "COMPLETION_GATE_CHANGED" : "COMPLETION_GATE_READY", changed
92
+ ? `Git-visible changes require an attached completed task, current PASS evidence, and a PASS review. Session: ${sessionId}. Create with --session ${sessionId}; one-time permit: agent-ops allow-stop --session ${sessionId}.`
93
+ : undefined);
94
+ }
95
+ async grantPermit(sessionId) {
96
+ const fingerprint = await this.#fingerprint();
97
+ await this.#store.mutate(sessionId, (state) => {
98
+ if (state === null) {
99
+ throw new AgentOpsError("COMPLETION_GATE_NOT_INITIALIZED", "The session has no completion-gate baseline.");
100
+ }
101
+ return { ...state, permitFingerprint: fingerprint };
102
+ });
103
+ }
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;
132
+ }
133
+ async #validateTask(sessionId, sourceFingerprint) {
134
+ let stored;
135
+ try {
136
+ stored = await this.#options.taskService.status({ sessionId });
137
+ }
138
+ catch (error) {
139
+ return error instanceof AgentOpsError && error.code === "TASK_SESSION_UNATTACHED"
140
+ ? gateResult("block", "FAIL", "COMPLETION_GATE_TASK_REQUIRED", "Attach this conversation to a formal task.")
141
+ : gateResult("block", "UNKNOWN", "COMPLETION_GATE_TASK_UNAVAILABLE", "Repair task state with agent-ops doctor before stopping.");
142
+ }
143
+ if (stored.status !== "complete") {
144
+ return gateResult("block", "FAIL", "COMPLETION_GATE_TASK_INCOMPLETE", "Complete the attached task after verification and review.");
145
+ }
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
+ }
162
+ }
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.");
166
+ }
167
+ return null;
168
+ }
169
+ async handle(event) {
170
+ const sessionId = event.sessionId;
171
+ if (sessionId === undefined) {
172
+ return event.event === "stop"
173
+ ? gateResult("block", "UNKNOWN", "COMPLETION_GATE_SESSION_REQUIRED", "Agy did not provide conversationId; run doctor and use a one-time permit only after restoring hook input.")
174
+ : null;
175
+ }
176
+ if (event.event === "session-start") {
177
+ return await this.initialize(sessionId);
178
+ }
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
+ if (event.event !== "stop")
183
+ return null;
184
+ if (event.terminationReason !== "model_stop" || event.fullyIdle !== true) {
185
+ return gateResult("continue", "PASS", "COMPLETION_GATE_NON_FINAL_STOP");
186
+ }
187
+ const fingerprint = await this.#fingerprint();
188
+ const state = await this.#store.read(sessionId);
189
+ if (state === null) {
190
+ return gateResult("block", "UNKNOWN", "COMPLETION_GATE_NOT_INITIALIZED", "The session baseline is unavailable; continue once so PreInvocation can initialize it.");
191
+ }
192
+ if (state.baselineFingerprint !== fingerprint && state.permitFingerprint !== fingerprint) {
193
+ const failure = await this.#validateTask(sessionId, fingerprint);
194
+ if (failure !== null)
195
+ return failure;
196
+ }
197
+ await this.#store.mutate(sessionId, (current) => {
198
+ if (current === null) {
199
+ throw new AgentOpsError("COMPLETION_GATE_NOT_INITIALIZED", "The session baseline disappeared.");
200
+ }
201
+ return {
202
+ ...current,
203
+ baselineFingerprint: fingerprint,
204
+ permitFingerprint: null
205
+ };
206
+ });
207
+ return gateResult("continue", "PASS", "COMPLETION_GATE_ALLOWED");
208
+ }
209
+ }
@@ -35,6 +35,12 @@ function evaluateCommands(commands, scope) {
35
35
  return warning ?? continueWith("PASS", "GUARDRAIL_ALLOWED");
36
36
  }
37
37
  export async function dispatchHookEvent(event, options) {
38
+ if (options.completionGate !== undefined) {
39
+ const result = await options.completionGate.handle(event);
40
+ if (result !== null) {
41
+ return result;
42
+ }
43
+ }
38
44
  if (event.event === "unsupported") {
39
45
  return continueWith("UNKNOWN", "HOOK_EVENT_UNSUPPORTED");
40
46
  }
@@ -314,7 +314,9 @@ function checkProjectLoop(manifest, config) {
314
314
  return undefined;
315
315
  }
316
316
  return manifest.harness.includes("agy")
317
- ? check("project-loop", "DEGRADED", "agy loop uses only PreInvocation and PreToolUse(run_command); prompt, permission, compact, and subagent events are unavailable.")
317
+ ? check("project-loop", "DEGRADED", config.features.completionGate.enabled
318
+ ? "agy completion gate uses PreInvocation, PreToolUse(run_command), and Stop; native Stop continuation is host-bounded. Use `agent-ops agy-run -- <args>` for a process-exit gate in headless or CI runs."
319
+ : "agy loop uses only PreInvocation and PreToolUse(run_command); prompt, permission, compact, and subagent events are unavailable.")
318
320
  : check("project-loop", "PASS", "Project loop events are fully registered.");
319
321
  }
320
322
  async function checkSurfaceInventory(root, manifest, config) {
@@ -136,7 +136,7 @@ const DESCRIPTORS = {
136
136
  hookRegistered: (source, capabilities) => isAgyHookRegistered(parseJsonSource(source), capabilities),
137
137
  plan: async (context) => {
138
138
  if (context.scope === "project") {
139
- return await planCommonHarnessContribution("codex", context);
139
+ return await planCommonHarnessContribution("agy", context);
140
140
  }
141
141
  const descriptor = DESCRIPTORS.agy;
142
142
  return {
@@ -281,6 +281,7 @@ export function resolveHarnessSelection(value) {
281
281
  }
282
282
  export const COMMON_AGENTS_BLOCK = DESCRIPTORS.codex.control.routing.desired;
283
283
  export const COMMON_CLAUDE_BLOCK = DESCRIPTORS.claude.control.routing.desired;
284
+ export const COMMON_GEMINI_BLOCK = DESCRIPTORS.agy.control.routing.desired;
284
285
  export function managedRules(descriptor, context) {
285
286
  const lines = [
286
287
  "# Loop Engineering",
@@ -306,6 +307,9 @@ export function managedRules(descriptor, context) {
306
307
  if (context.capabilities.includes("command-policy")) {
307
308
  lines.push("Command policy guards high-confidence unsafe actions. Explicitly enabled", "Stop verification is report-only and never marks a task complete by itself.", "");
308
309
  }
310
+ if (context.capabilities.includes("completion-gate")) {
311
+ lines.push("The agy completion gate applies only when this conversation creates a", "Git-visible net change after its first PreInvocation baseline. Read-only", "questions and analysis stop normally. A changed conversation must be", "attached to one task with two to five acceptance criteria; current PASS", "verification evidence, a PASS review attestation, and completed task state", "are all required before Stop. Error, max-step, and non-idle stops are not", "blocked. The gate inspects evidence but never runs tests or review itself.", "A user may approve `agent-ops allow-stop --session <conversationId>` for", "one Stop bound to the current source fingerprint; the PreToolUse hook must", "return `force_ask`, so the agent cannot self-authorize this escape hatch.", "For headless or CI enforcement, launch agy through", "`agent-ops agy-run -- <agy arguments>`.", "");
312
+ }
309
313
  lines.push(`This file is routed from the active ${descriptor.control.instructionFile}.`, "");
310
314
  return lines.join("\n");
311
315
  }
@@ -4,7 +4,7 @@ import { harnessDescriptor, harnessHookPath, routingBlockId, selectHarnessHookSu
4
4
  import { isOpencodePluginPath } from "../adapters/opencode/config.js";
5
5
  import { LOOP_MARKER_ID, LOOP_MARKER_VERSION, loopIgnoreContent, loopLauncherArtifactId, loopWindowsLauncherArtifactId, loopWindowsLauncherPath, loopLauncherPath, selectedLoopHarnesses } from "./codex-loop.js";
6
6
  function expectedMarker(manifest, id, markerId) {
7
- const descriptor = harnessDescriptor(id === "agy" && manifest.scope === "project" ? "codex" : id);
7
+ const descriptor = manifestDescriptor(manifest, id);
8
8
  const markers = managedBlockMarkers(markerId, 1, "html");
9
9
  return {
10
10
  id: markerId,
@@ -20,6 +20,13 @@ function expectedMarker(manifest, id, markerId) {
20
20
  legacyContent: descriptor.control.routing.legacy
21
21
  };
22
22
  }
23
+ function manifestDescriptor(manifest, id) {
24
+ const legacyProjectAgy = id === "agy" &&
25
+ manifest.scope === "project" &&
26
+ !manifest.artifacts.some(({ path }) => path === ".agent-ops/GEMINI.md") &&
27
+ !manifest.markers.some(({ id: markerId }) => markerId === "agy-routing");
28
+ return harnessDescriptor(legacyProjectAgy ? "codex" : id);
29
+ }
23
30
  function expectedLoopMarker(manifest) {
24
31
  const markers = managedBlockMarkers(LOOP_MARKER_ID, LOOP_MARKER_VERSION, "hash");
25
32
  return {
@@ -98,7 +105,7 @@ export function assertSupportedManifestOwnership(manifest, root) {
98
105
  throw manifestOwnershipError();
99
106
  }
100
107
  for (const id of harnesses) {
101
- const descriptor = harnessDescriptor(id === "agy" && manifest.scope === "project" ? "codex" : id);
108
+ const descriptor = manifestDescriptor(manifest, id);
102
109
  const artifactPath = `.agent-ops/${descriptor.control.instructionFile}`;
103
110
  const artifactKey = pathKey(artifactPath);
104
111
  const artifactEntry = expectedArtifactPaths.get(artifactKey);