@kylecheng3146/agent-ops 0.1.19 → 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 (53) hide show
  1. package/README.md +38 -12
  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 +113 -12
  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 +43 -10
  9. package/dist/packages/cli/src/commands/review.js +3 -0
  10. package/dist/packages/cli/src/commands/task.js +5 -2
  11. package/dist/packages/cli/src/commands/uninstall.js +20 -3
  12. package/dist/packages/cli/src/context.js +4 -1
  13. package/dist/packages/cli/src/hook-process.js +59 -5
  14. package/dist/packages/cli/src/ui.js +3 -1
  15. package/dist/packages/cli/src/wizard.js +36 -6
  16. package/dist/runtime/src/adapters/agy/config.js +115 -0
  17. package/dist/runtime/src/adapters/agy/events.js +35 -0
  18. package/dist/runtime/src/adapters/agy/input.js +48 -0
  19. package/dist/runtime/src/adapters/agy/output.js +28 -0
  20. package/dist/runtime/src/adapters/agy/surfaces.js +9 -0
  21. package/dist/runtime/src/config/explain.js +5 -0
  22. package/dist/runtime/src/config/migrate.js +15 -1
  23. package/dist/runtime/src/contracts.js +1 -1
  24. package/dist/runtime/src/hooks/completion-gate.js +209 -0
  25. package/dist/runtime/src/hooks/dispatch.js +6 -0
  26. package/dist/runtime/src/install/codex-loop.js +6 -2
  27. package/dist/runtime/src/install/doctor.js +17 -0
  28. package/dist/runtime/src/install/harness.js +56 -1
  29. package/dist/runtime/src/install/hooks.js +6 -1
  30. package/dist/runtime/src/install/ownership.js +15 -4
  31. package/dist/runtime/src/install/plan.js +21 -6
  32. package/dist/runtime/src/install/probes.js +61 -0
  33. package/dist/runtime/src/install/profiles.js +3 -0
  34. package/dist/runtime/src/install/surface-inspection.js +10 -1
  35. package/dist/runtime/src/install/uninstall.js +337 -6
  36. package/dist/runtime/src/review/execute.js +23 -20
  37. package/dist/runtime/src/review/extract.js +5 -11
  38. package/dist/runtime/src/review/render.js +6 -1
  39. package/dist/runtime/src/review/roles.js +4 -0
  40. package/dist/runtime/src/review/runner.js +7 -0
  41. package/dist/runtime/src/schema/validate.js +13 -3
  42. package/dist/runtime/src/task/service.js +17 -0
  43. package/docs/en/guides/configuration.md +46 -12
  44. package/docs/en/spec/README.md +7 -4
  45. package/docs/en/spec/harness-adapters.md +24 -9
  46. package/docs/en/spec/review.md +6 -0
  47. package/docs/zh-TW/guides/configuration.md +41 -11
  48. package/docs/zh-TW/spec/README.md +6 -4
  49. package/docs/zh-TW/spec/harness-adapters.md +21 -10
  50. package/docs/zh-TW/spec/review.md +5 -0
  51. package/package.json +2 -2
  52. package/schemas/config.schema.json +12 -2
  53. package/schemas/manifest.schema.json +2 -2
@@ -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
  }
@@ -134,12 +134,16 @@ export function planLoopContribution(options) {
134
134
  return { artifacts: [], blocks: [] };
135
135
  }
136
136
  const harnesses = selectedLoopHarnesses(options.harnesses);
137
- if (options.scope !== "project" || harnesses.length === 0) {
138
- throw new AgentOpsError("LOOP_PROFILE_UNSUPPORTED", "The loop profile requires project scope and the Codex or Claude harness.");
137
+ if (options.scope !== "project" ||
138
+ (harnesses.length === 0 && !options.harnesses.includes("agy"))) {
139
+ throw new AgentOpsError("LOOP_PROFILE_UNSUPPORTED", "The loop profile requires project scope and the agy, Codex, or Claude harness.");
139
140
  }
140
141
  if (options.hookRuntimePath === undefined) {
141
142
  throw new AgentOpsError("LOOP_RUNTIME_REQUIRED", "The loop profile requires the installed hook runtime path.");
142
143
  }
144
+ if (harnesses.length === 0) {
145
+ return { artifacts: [], blocks: [] };
146
+ }
143
147
  const artifacts = harnesses.flatMap((harness) => [
144
148
  {
145
149
  id: loopLauncherArtifactId(harness),
@@ -309,6 +309,16 @@ function checkLifecycleSummary(manifest, config) {
309
309
  }
310
310
  return check("lifecycle-summary", "PASS", "Lifecycle summary is reachable for every selected harness.");
311
311
  }
312
+ function checkProjectLoop(manifest, config) {
313
+ if (manifest === undefined || config === undefined || !config.profiles.includes("loop")) {
314
+ return undefined;
315
+ }
316
+ return manifest.harness.includes("agy")
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.")
320
+ : check("project-loop", "PASS", "Project loop events are fully registered.");
321
+ }
312
322
  async function checkSurfaceInventory(root, manifest, config) {
313
323
  if (manifest === undefined || config === undefined) {
314
324
  return {
@@ -420,6 +430,13 @@ export async function doctorInstallation(options) {
420
430
  await checkRegistrationDrift(options.root, manifest.manifest, config.config),
421
431
  await checkProbe("hook-registration", options.probes?.hookRegistration),
422
432
  checkLifecycleSummary(manifest.manifest, config.config),
433
+ ...(() => {
434
+ const projectLoop = checkProjectLoop(manifest.manifest, config.config);
435
+ return projectLoop === undefined ? [] : [projectLoop];
436
+ })(),
437
+ ...(manifest.manifest?.harness.includes("agy") === true
438
+ ? [await checkProbe("agy-runtime", options.probes?.agyRuntime)]
439
+ : []),
423
440
  await checkProbe("repository-trust", options.probes?.repositoryTrust),
424
441
  await checkProbe("smoke-availability", options.probes?.smokeAvailability),
425
442
  await checkReviewTargets(config.config, options.probes?.reviewTarget, options.checkReviewTargetAuth === true)
@@ -1,3 +1,8 @@
1
+ import { buildAgyHookSettings, isAgyHookRegistered, isAgyManagedHook, mergeAgyHooks, stripAgyHooks } from "../adapters/agy/config.js";
2
+ import { AGY_CAPABILITY_REGISTRATIONS } from "../adapters/agy/events.js";
3
+ import { normalizeAgyHookInput } from "../adapters/agy/input.js";
4
+ import { agyHookOutput } from "../adapters/agy/output.js";
5
+ import { agySurfaces } from "../adapters/agy/surfaces.js";
1
6
  import { buildClaudeHookSettings, isClaudeManagedHandler, mergeClaudeSettings, stripClaudeManagedHooks } from "../adapters/claude/config.js";
2
7
  import { CLAUDE_CAPABILITY_REGISTRATIONS } from "../adapters/claude/events.js";
3
8
  import { normalizeClaudeHookInput } from "../adapters/claude/input.js";
@@ -16,6 +21,7 @@ import { opencodeSurfaces } from "../adapters/opencode/surfaces.js";
16
21
  import { AgentOpsError } from "../fs/paths.js";
17
22
  import { findSurfaceById, findSurfaceByPath, isWritableSurface } from "./surfaces.js";
18
23
  export const HARNESS_IDS = [
24
+ "agy",
19
25
  "codex",
20
26
  "claude",
21
27
  "opencode"
@@ -108,7 +114,52 @@ const CLAUDE_ROUTING = {
108
114
  "## Loop Engineering\n\nUse `.agent-ops/CLAUDE.md` as the canonical Loop Engineering specification for this project.\n"
109
115
  ]
110
116
  };
117
+ const AGY_ROUTING = {
118
+ desired: "## Loop Engineering\n\nLoad `.agent-ops/GEMINI.md` as the agent-ops managed baseline.\nProject-specific instructions in this file remain authoritative.\n",
119
+ legacy: []
120
+ };
111
121
  const DESCRIPTORS = {
122
+ agy: {
123
+ id: "agy",
124
+ control: {
125
+ instructionFile: "GEMINI.md",
126
+ routing: AGY_ROUTING,
127
+ hookPath: ".agents/hooks.json",
128
+ hookPathForScope: (scope) => scope === "project" ? ".agents/hooks.json" : ".gemini/config/hooks.json",
129
+ surfaces: agySurfaces,
130
+ ownSettingsKeys: [],
131
+ buildHooks: buildAgyHookSettings,
132
+ mergeHooks: mergeAgyHooks,
133
+ stripHooks: stripAgyHooks,
134
+ isManagedHandler: isAgyManagedHook,
135
+ registrations: AGY_CAPABILITY_REGISTRATIONS,
136
+ hookRegistered: (source, capabilities) => isAgyHookRegistered(parseJsonSource(source), capabilities),
137
+ plan: async (context) => {
138
+ if (context.scope === "project") {
139
+ return await planCommonHarnessContribution("agy", context);
140
+ }
141
+ const descriptor = DESCRIPTORS.agy;
142
+ return {
143
+ artifacts: [{
144
+ id: "gemini-rules",
145
+ path: ".agent-ops/GEMINI.md",
146
+ content: managedRules(descriptor, context)
147
+ }],
148
+ blocks: [{
149
+ id: "agy-routing",
150
+ path: ".gemini/GEMINI.md",
151
+ version: 1,
152
+ content: AGY_ROUTING.desired
153
+ }]
154
+ };
155
+ }
156
+ },
157
+ runtime: {
158
+ normalizeInput: normalizeAgyHookInput,
159
+ formatOutput: agyHookOutput,
160
+ formatRuntimeFailure: (event, capability, remedy) => agyHookOutput(event, runtimeFailureResult(capability, AGY_CAPABILITY_REGISTRATIONS, remedy))
161
+ }
162
+ },
112
163
  codex: createJsonDescriptor({
113
164
  id: "codex",
114
165
  instructionFile: "AGENTS.md",
@@ -230,6 +281,7 @@ export function resolveHarnessSelection(value) {
230
281
  }
231
282
  export const COMMON_AGENTS_BLOCK = DESCRIPTORS.codex.control.routing.desired;
232
283
  export const COMMON_CLAUDE_BLOCK = DESCRIPTORS.claude.control.routing.desired;
284
+ export const COMMON_GEMINI_BLOCK = DESCRIPTORS.agy.control.routing.desired;
233
285
  export function managedRules(descriptor, context) {
234
286
  const lines = [
235
287
  "# Loop Engineering",
@@ -244,7 +296,7 @@ export function managedRules(descriptor, context) {
244
296
  ""
245
297
  ];
246
298
  if (context.capabilities.includes("rules")) {
247
- lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain independent review before claiming completion, via", " `agent-ops review --yes` (or the CLI's equivalent invocation). Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
299
+ lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain independent review before claiming completion, via", " `agent-ops review --yes` (or the CLI's equivalent invocation). Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Set `AGENT_OPS_HOST` to the current CLI id when invoking review so", " agent-ops tries a different CLI first and uses isolated self-review", " only when no other configured reviewer is usable.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
248
300
  }
249
301
  if (context.capabilities.includes("task")) {
250
302
  lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria, verification, and independent review;", "completing one never completes its parent.", "");
@@ -255,6 +307,9 @@ export function managedRules(descriptor, context) {
255
307
  if (context.capabilities.includes("command-policy")) {
256
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.", "");
257
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
+ }
258
313
  lines.push(`This file is routed from the active ${descriptor.control.instructionFile}.`, "");
259
314
  return lines.join("\n");
260
315
  }
@@ -35,7 +35,9 @@ export function planHookRegistration(options) {
35
35
  }
36
36
  const path = options.path ?? hookRegistrationPath(options.harness, options.scope);
37
37
  const managed = descriptor.control.buildHooks(options.capabilities, options.runtimePath, options.platform);
38
- const events = Object.keys(managed.hooks);
38
+ const events = Object.keys(managed.hooks).map((event) => options.harness === "agy" && event === "PreInvocation"
39
+ ? "SessionStart"
40
+ : event);
39
41
  if (events.length === 0) {
40
42
  return null;
41
43
  }
@@ -54,6 +56,9 @@ export function planHookRegistration(options) {
54
56
  };
55
57
  }
56
58
  function onlyManagedRemains(harness, value) {
59
+ if (harness === "agy") {
60
+ return Object.keys(value).length === 0;
61
+ }
57
62
  const ownKeys = new Set(harnessDescriptor(harness).control.ownSettingsKeys ?? []);
58
63
  const hooks = value.hooks;
59
64
  return (Object.keys(value).every((key) => ownKeys.has(key)) &&
@@ -4,13 +4,15 @@ 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);
7
+ const descriptor = manifestDescriptor(manifest, id);
8
8
  const markers = managedBlockMarkers(markerId, 1, "html");
9
9
  return {
10
10
  id: markerId,
11
11
  path: manifest.scope === "project"
12
12
  ? descriptor.control.instructionFile
13
- : `.${id}/${descriptor.control.instructionFile}`,
13
+ : id === "agy"
14
+ ? ".gemini/GEMINI.md"
15
+ : `.${id}/${descriptor.control.instructionFile}`,
14
16
  startMarker: markers.start,
15
17
  endMarker: markers.end,
16
18
  markerStyle: "html",
@@ -18,6 +20,13 @@ function expectedMarker(manifest, id, markerId) {
18
20
  legacyContent: descriptor.control.routing.legacy
19
21
  };
20
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
+ }
21
30
  function expectedLoopMarker(manifest) {
22
31
  const markers = managedBlockMarkers(LOOP_MARKER_ID, LOOP_MARKER_VERSION, "hash");
23
32
  return {
@@ -96,7 +105,7 @@ export function assertSupportedManifestOwnership(manifest, root) {
96
105
  throw manifestOwnershipError();
97
106
  }
98
107
  for (const id of harnesses) {
99
- const descriptor = harnessDescriptor(id);
108
+ const descriptor = manifestDescriptor(manifest, id);
100
109
  const artifactPath = `.agent-ops/${descriptor.control.instructionFile}`;
101
110
  const artifactKey = pathKey(artifactPath);
102
111
  const artifactEntry = expectedArtifactPaths.get(artifactKey);
@@ -112,7 +121,9 @@ export function assertSupportedManifestOwnership(manifest, root) {
112
121
  requiredArtifactPaths.add(artifactKey);
113
122
  const markerPath = manifest.scope === "project"
114
123
  ? descriptor.control.instructionFile
115
- : `.${id}/${descriptor.control.instructionFile}`;
124
+ : id === "agy"
125
+ ? ".gemini/GEMINI.md"
126
+ : `.${id}/${descriptor.control.instructionFile}`;
116
127
  const markerKey = pathKey(markerPath);
117
128
  expectedMarkerPaths.add(markerKey);
118
129
  const currentId = routingBlockId(id, manifest.scope, descriptor);
@@ -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: {
@@ -280,8 +283,8 @@ function assertLoopProfileSupport(scope, harness, capabilities) {
280
283
  return;
281
284
  }
282
285
  if (scope !== "project" ||
283
- !harness.some((id) => id === "codex" || id === "claude")) {
284
- throw new AgentOpsError("LOOP_PROFILE_UNSUPPORTED", "The loop profile requires project scope and the Codex or Claude harness.");
286
+ !harness.some((id) => id === "agy" || id === "codex" || id === "claude")) {
287
+ throw new AgentOpsError("LOOP_PROFILE_UNSUPPORTED", "The loop profile requires project scope and the agy, Codex, or Claude harness.");
285
288
  }
286
289
  }
287
290
  async function assertCodexLoopConfiguration(root, harness, capabilities) {
@@ -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) {
@@ -1,5 +1,66 @@
1
1
  import { harnessDescriptor } from "./harness.js";
2
2
  import { resolveCapabilities } from "./profiles.js";
3
+ const MINIMUM_AGY_VERSION = [1, 1, 12];
4
+ export function agyVersionSupported(versionOutput) {
5
+ const match = /\b(\d+)\.(\d+)\.(\d+)\b/u.exec(versionOutput);
6
+ const version = match?.slice(1).map(Number);
7
+ return version !== undefined && !version.some((part, index) => part < MINIMUM_AGY_VERSION[index] &&
8
+ version.slice(0, index).every((prior, priorIndex) => prior === MINIMUM_AGY_VERSION[priorIndex]));
9
+ }
10
+ export function agyRuntimeStatus(versionOutput, hooksOutput, expectedEvents = []) {
11
+ const match = /\b(\d+)\.(\d+)\.(\d+)\b/u.exec(versionOutput);
12
+ if (!agyVersionSupported(versionOutput)) {
13
+ return {
14
+ status: "FAIL",
15
+ message: "agy 1.1.12 or newer is required.",
16
+ remediation: "Update agy, then run `agent-ops doctor` again."
17
+ };
18
+ }
19
+ try {
20
+ const parsed = JSON.parse(hooksOutput);
21
+ const hooks = parsed.command?.data?.hooks;
22
+ const loaded = Array.isArray(hooks) && hooks.some((hook) => {
23
+ if (typeof hook !== "object" || hook === null || Array.isArray(hook))
24
+ return false;
25
+ const value = hook;
26
+ const actions = value.actions;
27
+ if (!(value.name === "agent-ops" &&
28
+ value.enabled === true &&
29
+ Array.isArray(actions) &&
30
+ actions.length > 0 &&
31
+ actions.every((action) => typeof action === "object" && action !== null && !Array.isArray(action))))
32
+ return false;
33
+ return expectedEvents.every((expected) => actions.some((action) => {
34
+ const nativeEvent = expected === "SessionStart" ? "PreInvocation" : expected;
35
+ return (typeof action === "object" && action !== null && !Array.isArray(action) &&
36
+ action.event === nativeEvent &&
37
+ typeof action.command === "string" &&
38
+ action.command.endsWith(` agy ${expected} --managed-by=agent-ops`));
39
+ }));
40
+ });
41
+ if (!Array.isArray(hooks) || (expectedEvents.length > 0 && !loaded)) {
42
+ return {
43
+ status: "FAIL",
44
+ message: "agy is installed, but its loaded hook list does not include agent-ops.",
45
+ code: "UPDATE_REQUIRED",
46
+ remediation: "Run `agent-ops update`, restart agy, then run doctor again."
47
+ };
48
+ }
49
+ return {
50
+ status: "PASS",
51
+ message: expectedEvents.length > 0
52
+ ? `agy ${match?.[0]} loaded the agent-ops hook.`
53
+ : `agy ${match?.[0]} meets the minimum supported version.`
54
+ };
55
+ }
56
+ catch {
57
+ return {
58
+ status: "FAIL",
59
+ message: "agy returned an unreadable /hooks response.",
60
+ remediation: "Run `agy -p \"/hooks\" --output-format json` and inspect the result."
61
+ };
62
+ }
63
+ }
3
64
  /**
4
65
  * Returns the harness ids missing an agent-ops owned handler for the hook
5
66
  * events implied by the installed profiles. Empty when installations without
@@ -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
  }
@@ -90,6 +90,9 @@ function managedJsonCount(source, isManagedHandler) {
90
90
  }
91
91
  return jsonHandlerCounts(source, isManagedHandler)?.managed ?? 0;
92
92
  }
93
+ function desiredHookEvents(harness, events) {
94
+ return events.map((event) => harness === "agy" && event === "PreInvocation" ? "SessionStart" : event);
95
+ }
93
96
  export async function inspectHarnessRegistrations(options) {
94
97
  const capabilities = desiredCapabilities(options.config);
95
98
  const statuses = [];
@@ -100,7 +103,7 @@ export async function inspectHarnessRegistrations(options) {
100
103
  const recordedEvents = hookRecord?.events ?? [];
101
104
  const desiredEvents = control.buildHooks === undefined
102
105
  ? []
103
- : Object.keys(control.buildHooks(capabilities, "probe").hooks);
106
+ : desiredHookEvents(harness, Object.keys(control.buildHooks(capabilities, "probe").hooks));
104
107
  if (control.buildHooks !== undefined) {
105
108
  const surfaces = harnessSurfaces(harness, options.manifest.scope, options.root);
106
109
  const writableJsonSurfaces = surfaces.filter((surface) => isWritableSurface(surface) && surface.representation === "json");
@@ -186,6 +189,12 @@ function jsonHandlerCounts(source, isManagedHandler) {
186
189
  if (!isRecord(parsed)) {
187
190
  return null;
188
191
  }
192
+ if (isManagedHandler?.(parsed) === true) {
193
+ return {
194
+ managed: 1,
195
+ foreign: Math.max(0, Object.keys(parsed).length - 1)
196
+ };
197
+ }
189
198
  const hooks = parsed.hooks;
190
199
  if (hooks === undefined) {
191
200
  return { managed: 0, foreign: 0 };