@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
@@ -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);
@@ -71,7 +71,7 @@ async function detectVerificationCommands(root) {
71
71
  .filter((proposal) => proposal.confidence === "high")
72
72
  .map(verificationCommandFromProposal);
73
73
  }
74
- function buildConfig(profiles, existing, reviewTargets = [], detectedCommands = []) {
74
+ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands = [], completionGateEnabled = false) {
75
75
  // Absent reviewRoles means external review is disabled; an empty selection
76
76
  // must therefore omit the field rather than write an empty array.
77
77
  const reviewRoles = reviewTargets.length > 0
@@ -88,6 +88,9 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
88
88
  features: existing?.features ?? {
89
89
  stopVerification: {
90
90
  enabled: false
91
+ },
92
+ completionGate: {
93
+ enabled: completionGateEnabled
91
94
  }
92
95
  },
93
96
  pathMappings: existing?.pathMappings ?? [],
@@ -95,7 +98,7 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
95
98
  ...(reviewRoles === undefined ? {} : { reviewRoles: [...reviewRoles] })
96
99
  };
97
100
  }
98
- async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = []) {
101
+ async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = [], completionGateEnabled = false) {
99
102
  const current = await readCurrentFile(root, CONFIG_PATH);
100
103
  const owned = findOwnedArtifact(existingManifest, CONFIG_PATH);
101
104
  if (current !== null && owned === undefined) {
@@ -131,7 +134,7 @@ async function planConfig(root, profiles, existingManifest, suppliedConfig, revi
131
134
  existingConfig.verification.commands.length === 0
132
135
  ? await detectVerificationCommands(root)
133
136
  : [];
134
- const config = buildConfig(profiles, existingConfig, reviewTargets, detectedCommands);
137
+ const config = buildConfig(profiles, existingConfig, reviewTargets, detectedCommands, completionGateEnabled);
135
138
  const content = `${JSON.stringify(config, null, 2)}\n`;
136
139
  return {
137
140
  operation: {
@@ -376,6 +379,18 @@ export async function createInstallPlan(options) {
376
379
  ? resolveProfiles(options.profiles)
377
380
  : resolveCapabilities(options.existingConfig.value);
378
381
  assertLoopProfileSupport(options.scope, options.harness, resolved.capabilities);
382
+ const completionGateEnabled = options.existingConfig?.value.features.completionGate.enabled ??
383
+ options.completionGateEnabled === true;
384
+ if (completionGateEnabled &&
385
+ (options.scope !== "project" ||
386
+ !options.harness.includes("agy") ||
387
+ !resolved.capabilities.includes("project-loop"))) {
388
+ throw new AgentOpsError("COMPLETION_GATE_UNSUPPORTED", "The completion gate requires project scope with the agy harness and loop profile.");
389
+ }
390
+ if (completionGateEnabled &&
391
+ !resolved.capabilities.includes("completion-gate")) {
392
+ resolved.capabilities.push("completion-gate");
393
+ }
379
394
  await assertCodexLoopConfiguration(options.root, options.harness, resolved.capabilities);
380
395
  const existing = await readExistingManifest(options.root);
381
396
  assertCompatibleManifest(existing?.manifest ?? null, options.scope, options.harness, options.allowHarnessChange === true);
@@ -461,7 +476,7 @@ export async function createInstallPlan(options) {
461
476
  : [];
462
477
  const operations = [];
463
478
  const artifacts = [];
464
- const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? []);
479
+ const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? [], completionGateEnabled);
465
480
  operations.push(config.operation);
466
481
  artifacts.push(config.record);
467
482
  for (const artifact of contribution.artifacts) {
@@ -33,5 +33,8 @@ export function resolveCapabilities(config) {
33
33
  if (config.features.stopVerification.enabled) {
34
34
  resolved.capabilities.push("optional-stop-verify");
35
35
  }
36
+ if (config.features.completionGate.enabled) {
37
+ resolved.capabilities.push("completion-gate");
38
+ }
36
39
  return resolved;
37
40
  }
@@ -328,7 +328,7 @@ export function validateConfig(value) {
328
328
  if (!isRecord(root.features)) {
329
329
  return failure("INVALID_TYPE", "$.features", "features must be an object.");
330
330
  }
331
- const featuresUnknown = unknownFieldFailure(root.features, ["stopVerification"], "$.features");
331
+ const featuresUnknown = unknownFieldFailure(root.features, ["completionGate", "stopVerification"], "$.features");
332
332
  if (featuresUnknown !== undefined) {
333
333
  return featuresUnknown;
334
334
  }
@@ -342,6 +342,16 @@ export function validateConfig(value) {
342
342
  if (typeof root.features.stopVerification.enabled !== "boolean") {
343
343
  return failure("INVALID_FEATURE", "$.features.stopVerification.enabled", "stopVerification.enabled must be a boolean.");
344
344
  }
345
+ if (!isRecord(root.features.completionGate)) {
346
+ return failure("INVALID_TYPE", "$.features.completionGate", "completionGate must be an object.");
347
+ }
348
+ const completionGateUnknown = unknownFieldFailure(root.features.completionGate, ["enabled"], "$.features.completionGate");
349
+ if (completionGateUnknown !== undefined) {
350
+ return completionGateUnknown;
351
+ }
352
+ if (typeof root.features.completionGate.enabled !== "boolean") {
353
+ return failure("INVALID_FEATURE", "$.features.completionGate.enabled", "completionGate.enabled must be a boolean.");
354
+ }
345
355
  if (!isRecord(root.verification)) {
346
356
  return failure("INVALID_TYPE", "$.verification", "verification must be an object.");
347
357
  }
@@ -77,6 +77,9 @@ export class TaskService {
77
77
  !/^[a-f0-9]{64}$/u.test(input.policyConfigHash)) {
78
78
  throw taskError("TASK_POLICY_CONFIG_INVALID", "Policy config hash must be a lowercase SHA-256 digest.");
79
79
  }
80
+ if (input.sessionId !== undefined) {
81
+ assertSessionId(input.sessionId);
82
+ }
80
83
  const task = {
81
84
  schemaVersion: TASK_SCHEMA_VERSION,
82
85
  id: this.#generateId(),
@@ -118,6 +121,20 @@ export class TaskService {
118
121
  policyConfigHash: input.policyConfigHash ?? null
119
122
  };
120
123
  state.tasks.push(record);
124
+ if (input.sessionId !== undefined) {
125
+ const currentIndex = state.sessions.findIndex(({ sessionId }) => sessionId === input.sessionId);
126
+ const attachment = {
127
+ sessionId: input.sessionId,
128
+ taskId: record.task.id,
129
+ attachedAt: now
130
+ };
131
+ if (currentIndex === -1) {
132
+ state.sessions.push(attachment);
133
+ }
134
+ else {
135
+ state.sessions[currentIndex] = attachment;
136
+ }
137
+ }
121
138
  return cloneRecord(record);
122
139
  });
123
140
  }
@@ -6,10 +6,12 @@ Use `--harness all` to select agy, Codex, Claude Code, and opencode, or pass a
6
6
  comma-separated subset such as `codex,opencode`. `both` remains an input alias
7
7
  for the legacy Codex plus Claude selection.
8
8
 
9
- Project agy, Codex, and opencode installations share the managed supplemental
10
- `AGENTS.md` routing block and the `.agent-ops/AGENTS.md` rules artifact. The
11
- block loads the managed baseline while project-specific instructions remain
12
- authoritative. Claude uses the corresponding `CLAUDE.md` route and
9
+ Project agy uses a managed supplemental `GEMINI.md` routing block and
10
+ `.agent-ops/GEMINI.md` baseline. This follows the official agy CLI rule that a
11
+ workspace-root `GEMINI.md` or `AGENTS.md` is loaded at startup. Codex and
12
+ opencode share the corresponding `AGENTS.md` route and `.agent-ops/AGENTS.md`
13
+ artifact. Each block loads the managed baseline while project-specific
14
+ instructions remain authoritative. Claude uses the corresponding `CLAUDE.md` route and
13
15
  `.agent-ops/CLAUDE.md` artifact. Opencode additionally gets
14
16
  the agent-ops-owned `.opencode/plugins/agent-ops.js` file; `opencode.json` is
15
17
  never modified. The plugin is generated with the installed absolute runtime
@@ -147,6 +149,21 @@ user hooks live in `.gemini/config/hooks.json`. User-scope rules modify the
147
149
  shared Gemini rule surface at `.gemini/GEMINI.md`. agy 1.1.12 or newer is
148
150
  required for machine-readable `/hooks` diagnostics.
149
151
 
152
+ For `agy` plus `loop`, the interactive installer recommends
153
+ `features.completionGate.enabled`; non-interactive installs require the explicit
154
+ `--completion-gate` flag. The gate uses the documented `conversationId`,
155
+ `terminationReason`, and `fullyIdle` Stop fields and returns the documented
156
+ `decision: "continue"` only for a final changed conversation that lacks current
157
+ task, verification, or review proof. Pure Q&A, analysis, read-only diagnostics,
158
+ error stops, max-step stops, and non-idle stops continue normally. It does not
159
+ change Codex, Claude Code, or OpenCode Stop behavior. For headless execution use
160
+ `agent-ops agy-run -- <agy arguments>`; a user-approved one-time escape is
161
+ `agent-ops allow-stop --session <conversationId>` and is guarded by agy's
162
+ documented `force_ask` decision.
163
+
164
+ Official references: [agy CLI workspace rule files](https://www.antigravity.google/docs/cli/best-practices/)
165
+ and [Antigravity hook contracts](https://www.antigravity.google/docs/hooks/).
166
+
150
167
  The full Codex/Claude loop runs `SessionStart`, `UserPromptSubmit`, `PreToolUse`,
151
168
  `PermissionRequest`, `PostToolUse`, `PreCompact`, `PostCompact`,
152
169
  `SubagentStart`, and `SubagentStop`, but never adds `Stop`. It blocks only
@@ -183,8 +200,9 @@ classified invalid installed configuration. The managed OpenCode
183
200
  `tool.execute.before` plugin can throw its documented command-policy denial or
184
201
  unavailable-runtime error for its supported Bash surface. Codex is explicitly
185
202
  non-enforcing (`unknown`). These are agent-ops output and plugin contracts, not
186
- proof that a host honors a denial. `SessionStart` and `Stop` failure paths stay
187
- fail-open for every adapter.
203
+ proof that a host honors a denial. `SessionStart` and ordinary Stop verification
204
+ failure paths stay fail-open. Only the explicitly enabled agy completion gate
205
+ fails closed at final Stop.
188
206
 
189
207
  Claude's invalid-config fallback has four safeguards: (1) an absent project
190
208
  configuration stays fail-open, so only an invalid `.agent-ops/config.json` can
@@ -196,7 +214,7 @@ that shell variable. The variable is read only from the hook-process environment
196
214
  and cannot be set in agent-ops configuration, a manifest, or managed files.
197
215
 
198
216
  `guardrails` installs command policy but does not enable Stop verification. Stop
199
- is a separate config-v2 feature and must be explicitly enabled with at least
217
+ is a separate config-v3 feature and must be explicitly enabled with at least
200
218
  one confirmed command:
201
219
 
202
220
  ```json
@@ -4,12 +4,12 @@ This is the normative English specification for bounded, evidence-driven work.
4
4
 
5
5
  The harness adapter rules cover agy, Codex, Claude Code, and opencode. The
6
6
  opencode integration is a generated local plugin; it does not manage
7
- `opencode.json`. agy shares project `AGENTS.md` routing, uses native hooks, and
7
+ `opencode.json`. agy uses project `GEMINI.md` routing, uses native hooks, and
8
8
  is explicitly degraded where its lifecycle surface is smaller than the full
9
9
  loop.
10
10
 
11
- Configuration is versioned independently from the manifest. Config v1 migrates
12
- to config v2 with Stop verification disabled; changing the capability requires
11
+ Configuration is versioned independently from the manifest. Older configs migrate
12
+ to config v3 with Stop verification and the agy completion gate disabled; changing a capability requires
13
13
  a confirmed project `agent-ops update`, which also refreshes trust when
14
14
  verifiers exist. Stop verification is
15
15
  explicit, trusted, report-only, and never completes a task. Dry-run plans keep
@@ -108,6 +108,7 @@ The current registration matrix is intentionally asymmetric:
108
108
  | --- | --- | --- | --- | --- |
109
109
  | lifecycle-summary | degraded | supported | supported | degraded |
110
110
  | command-policy | supported | unknown | supported | supported |
111
+ | completion-gate | supported | unsupported | unsupported | unsupported |
111
112
  | optional-stop-verify | degraded | unsupported | supported | degraded |
112
113
 
113
114
  For runtime-failure handling, only `command-policy` is fail-closed. Claude
@@ -118,11 +119,14 @@ surface. Codex remains `unknown` and never emits a denial. Fixture tests assert
118
119
  these wire and plugin shapes only; they do not prove that a host honors a
119
120
  denial. Every `SessionStart` and `Stop` failure path remains fail-open.
120
121
 
121
- The agy adapter uses project `AGENTS.md` routing alongside Codex and OpenCode;
122
+ The agy adapter uses native project `GEMINI.md` routing to
123
+ `.agent-ops/GEMINI.md`;
122
124
  at user scope it manages `.agent-ops/GEMINI.md` and a managed block in the
123
125
  shared `.gemini/GEMINI.md` rule surface. Its native hooks use camelCase input,
124
- return `decision: "deny"` for command-policy blocks, and never force a Stop
125
- continuation. On Windows the generated command is invoked through `cmd /c`.
126
+ return `decision: "deny"` for command-policy blocks, and, only when the
127
+ completion gate is explicitly enabled, return `decision: "continue"` for an
128
+ unproven final changed conversation. Read-only conversations stop normally.
129
+ On Windows the generated command is invoked through `cmd /c`.
126
130
 
127
131
  Stop verification is explicit, trusted, report-only, and disabled by default.
128
132
  Every Stop result continues the native harness and may carry only bounded