@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
@@ -13,7 +13,10 @@ function formatUninstallPlan(plan, trust) {
13
13
  ? []
14
14
  : [
15
15
  `Scope: ${plan.manifest.scope}`,
16
- `Harness: ${plan.manifest.harness}`
16
+ `Harness: ${plan.manifest.harness}`,
17
+ ...(plan.selectedHarnesses === undefined
18
+ ? []
19
+ : [`Removing harness: ${plan.selectedHarnesses.join(", ")}`])
17
20
  ])
18
21
  ],
19
22
  operations: toPublicUninstallPlan(plan).operations
@@ -31,6 +34,18 @@ async function trustChange(options, plan) {
31
34
  if (plan.manifest?.scope === "user") {
32
35
  return { action: "skipped", reason: "user-scope" };
33
36
  }
37
+ if (plan.resultingManifest !== undefined) {
38
+ if (options.calculateTrustBinding === undefined ||
39
+ options.trustStore === undefined) {
40
+ return { action: "skipped", reason: "not-configured" };
41
+ }
42
+ const binding = await options.calculateTrustBinding();
43
+ if (binding === null) {
44
+ return { action: "skipped", reason: "no-verification-commands" };
45
+ }
46
+ const status = await options.trustStore.status(binding);
47
+ return { action: "unchanged", binding, status: status.status };
48
+ }
34
49
  if (options.calculateTrustBinding === undefined ||
35
50
  options.trustStore === undefined) {
36
51
  return { action: "skipped", reason: "not-configured" };
@@ -41,7 +56,7 @@ async function trustChange(options, plan) {
41
56
  : await planTrustRevoke(binding, options.trustStore);
42
57
  }
43
58
  export async function runUninstallCommand(options) {
44
- const plan = await createUninstallPlan(options.root);
59
+ const plan = await createUninstallPlan(options.root, options.args.harness);
45
60
  if (!plan.installed) {
46
61
  return okEnvelope("UNINSTALL_NOT_INSTALLED", {
47
62
  applied: false,
@@ -80,7 +95,9 @@ export async function runUninstallCommand(options) {
80
95
  return okEnvelope("UNINSTALL_APPLIED", {
81
96
  applied: true,
82
97
  plan: toPublicUninstallPlan(plan, trust),
83
- message: "Managed installation content was removed."
98
+ message: plan.selectedHarnesses === undefined
99
+ ? "Managed installation content was removed."
100
+ : `Managed ${plan.selectedHarnesses.join(", ")} harness content was removed; remaining harnesses were preserved.`
84
101
  });
85
102
  }
86
103
  export { formatUninstallPlan };
@@ -9,12 +9,15 @@ import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
9
9
  import { localStatePaths } from "../../../runtime/src/security/permissions.js";
10
10
  import { calculateTrustBinding, FileTrustStore } from "../../../runtime/src/security/trust.js";
11
11
  export const DEFAULT_CONFIG = {
12
- schemaVersion: 2,
12
+ schemaVersion: 3,
13
13
  profiles: [],
14
14
  verification: { commands: [] },
15
15
  features: {
16
16
  stopVerification: {
17
17
  enabled: false
18
+ },
19
+ completionGate: {
20
+ enabled: false
18
21
  }
19
22
  },
20
23
  pathMappings: [],
@@ -9,6 +9,10 @@ import { resolveContainedPath } from "../../../runtime/src/fs/paths.js";
9
9
  import { redactSecrets } from "../../../runtime/src/security/redact.js";
10
10
  import { claudeStopRecursionMarker } from "../../../runtime/src/adapters/claude/input.js";
11
11
  import { HARNESS_IDS, harnessDescriptor } from "../../../runtime/src/install/harness.js";
12
+ import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
13
+ import { TaskService } from "../../../runtime/src/task/service.js";
14
+ import { FileTaskStore } from "../../../runtime/src/task/store.js";
15
+ import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
12
16
  import { STOP_VERIFICATION_ENV, StopVerificationService } from "../../../runtime/src/hooks/stop-service.js";
13
17
  import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
14
18
  import { normalizeHookInput, runHookCommand, HOOK_EVENTS } from "./commands/hook.js";
@@ -40,6 +44,16 @@ function parseInput(source) {
40
44
  return null;
41
45
  }
42
46
  }
47
+ function withInjectedSession(harness, input) {
48
+ const sessionId = process.env.AGENT_OPS_SESSION_ID;
49
+ return harness === "agy" &&
50
+ sessionId !== undefined &&
51
+ typeof input === "object" &&
52
+ input !== null &&
53
+ !Array.isArray(input)
54
+ ? { ...input, conversationId: sessionId }
55
+ : input;
56
+ }
43
57
  function writeHookOutput(io, output) {
44
58
  if (output.stdout.length > 0) {
45
59
  io.writeStdout(output.stdout);
@@ -195,11 +209,14 @@ function shouldBuildStopVerification(harness, event, config, rawInput) {
195
209
  return harnessDescriptor(harness).control.registrations.some(({ capability, support }) => capability === "optional-stop-verify" && support !== "unsupported");
196
210
  }
197
211
  /**
198
- * Runs one hook invocation. Always resolves to exit code 0: a hook that
199
- * cannot answer must never block the harness it advises.
212
+ * Runs one hook invocation. Exit code stays zero because native JSON carries
213
+ * decisions; only an explicitly installed agy completion gate fails closed.
200
214
  */
201
215
  export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
202
216
  const [harness, event] = argv;
217
+ const completionGateInstalled = harness === "agy" &&
218
+ event === "Stop" &&
219
+ argv.includes("--completion-gate");
203
220
  if (harness === undefined ||
204
221
  !HARNESSES.has(harness) ||
205
222
  event === undefined ||
@@ -212,13 +229,31 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
212
229
  const harnessId = harness;
213
230
  const hookEvent = event;
214
231
  if (process.env.AGENT_OPS_DISABLE === "1") {
232
+ if (completionGateInstalled) {
233
+ writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
234
+ action: "block",
235
+ status: "UNKNOWN",
236
+ code: "COMPLETION_GATE_DISABLE_REJECTED",
237
+ remedy: "Use a user-approved one-time permit instead of disabling agent-ops."
238
+ }));
239
+ return 0;
240
+ }
215
241
  writeHookOutput(io, failOpenOutput(harnessId, hookEvent));
216
242
  return 0;
217
243
  }
218
244
  const rawInput = await readStdin(io.stdin);
219
- const parsedInput = parseInput(rawInput);
245
+ const parsedInput = withInjectedSession(harnessId, parseInput(rawInput));
220
246
  const configOutcome = await hookConfigOutcome(root, dependencies.loadConfig);
221
247
  if (configOutcome.kind === "invalid") {
248
+ if (completionGateInstalled) {
249
+ writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
250
+ action: "block",
251
+ status: "UNKNOWN",
252
+ code: "COMPLETION_GATE_CONFIG_INVALID",
253
+ remedy: `Fix ${redactSecrets(configOutcome.path)} and run agent-ops doctor.`
254
+ }));
255
+ return 0;
256
+ }
222
257
  const output = await invalidConfigOutput({
223
258
  root,
224
259
  harness: harnessId,
@@ -249,6 +284,17 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
249
284
  processRunner
250
285
  })
251
286
  : undefined;
287
+ const completionGate = harnessId === "agy" && config.features.completionGate.enabled
288
+ ? dependencies.completionGate ?? {
289
+ handle: async (normalized) => await new CompletionGateService({
290
+ root,
291
+ config,
292
+ gitRunner,
293
+ taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
294
+ evidenceStore: new FileEvidenceStore(root, root)
295
+ }).handle(normalized)
296
+ }
297
+ : undefined;
252
298
  const output = await runHookCommand({
253
299
  harness: harness,
254
300
  event: hookEvent,
@@ -258,12 +304,20 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
258
304
  ...(dependencies.advisory === undefined
259
305
  ? {}
260
306
  : { advisory: dependencies.advisory }),
261
- ...(stopVerification === undefined ? {} : { stopVerification })
307
+ ...(stopVerification === undefined ? {} : { stopVerification }),
308
+ ...(completionGate === undefined ? {} : { completionGate })
262
309
  });
263
310
  writeHookOutput(io, output);
264
311
  }
265
312
  catch {
266
- // ponytail: fail-open by design; hook failures stay invisible to the harness.
313
+ if (completionGateInstalled) {
314
+ writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
315
+ action: "block",
316
+ status: "UNKNOWN",
317
+ code: "COMPLETION_GATE_UNAVAILABLE",
318
+ remedy: "Run agent-ops doctor; use a user-approved one-time permit only after diagnosis."
319
+ }));
320
+ }
267
321
  }
268
322
  return 0;
269
323
  }
@@ -217,7 +217,9 @@ export async function selectOption(question, choices, io, defaultIndex = 0) {
217
217
  export async function selectOptions(question, choices, io, defaultValues = [], options = {}) {
218
218
  assertChoices(choices);
219
219
  const defaultIndexes = new Set(choices.flatMap((choice, index) => defaultValues.includes(choice.value) ? [index] : []));
220
- if (defaultIndexes.size === 0 && options.selectAll !== true) {
220
+ if (defaultIndexes.size === 0 &&
221
+ options.selectAll !== true &&
222
+ options.startEmpty !== true) {
221
223
  defaultIndexes.add(0);
222
224
  }
223
225
  if (typeof io.input.setRawMode !== "function" ||
@@ -15,11 +15,14 @@ const REVIEW_TARGET_CHOICES = DEFAULT_REVIEW_TARGETS.map((id) => ({
15
15
  ? "Runs in sandboxed plan mode against a disposable clone."
16
16
  : "Runs in fresh safe mode with context isolation."
17
17
  }));
18
- function selectReviewTargets(raw) {
18
+ function selectReviewTargets(raw, defaults = []) {
19
19
  const values = raw
20
20
  .split(",")
21
21
  .map((value) => value.trim())
22
22
  .filter((value) => value.length > 0);
23
+ if (values.length === 0) {
24
+ return REVIEW_TARGET_ORDER.filter((target) => defaults.includes(target));
25
+ }
23
26
  for (const value of values) {
24
27
  if (!REVIEW_TARGET_SET.has(value)) {
25
28
  throw new CliArgumentError("CLI_INVALID_VALUE", `Invalid review target: ${value}`, "--review-target");
@@ -31,6 +34,11 @@ function selectReviewTargets(raw) {
31
34
  function affirmative(raw) {
32
35
  return /^(y|yes)$/i.test(raw.trim());
33
36
  }
37
+ function completionGateEligible(scope, harness, profiles) {
38
+ return scope === "project" &&
39
+ harness.includes("agy") &&
40
+ profiles.includes("loop");
41
+ }
34
42
  async function probeReviewTargets(targets, setup) {
35
43
  const probe = setup.probeReviewTarget;
36
44
  if (probe === undefined) {
@@ -68,10 +76,10 @@ const PROFILE_CHOICES = [
68
76
  {
69
77
  label: "loop",
70
78
  value: "loop",
71
- description: "Installs the shared Codex and Claude Code local loop with secret and destructive-command interception."
79
+ description: "Installs the local loop; agy uses its supported lifecycle subset and is reported as degraded."
72
80
  }
73
81
  ];
74
- const WIZARD_SUBTITLE = "Safe setup for Codex, Claude Code, and opencode with profile-driven rules, verification, and hooks.";
82
+ const WIZARD_SUBTITLE = "Safe setup for agy, Codex, Claude Code, and opencode with profile-driven rules, verification, and hooks.";
75
83
  async function createPromptSession(io) {
76
84
  if (!io.isTTY) {
77
85
  throw new CliArgumentError("CLI_INTERACTIVE_REQUIRED", "Missing init choices require an interactive terminal.");
@@ -146,6 +154,7 @@ export async function completeInitChoices(args, io, setup = {}) {
146
154
  const scope = args.scope ?? await selectOption("Scope", SCOPE_CHOICES, selectorIo);
147
155
  const harness = args.harness ??
148
156
  await selectOptions("Harness (multi-select: ↑↓ move, Space toggle, Enter confirm)", HARNESS_CHOICES, selectorIo, [...DEFAULT_HARNESS], {
157
+ startEmpty: true,
149
158
  selectAll: true,
150
159
  selectAllLabel: "Select all",
151
160
  selectAllDescription: "Install for every supported harness."
@@ -157,6 +166,20 @@ export async function completeInitChoices(args, io, setup = {}) {
157
166
  selectAllLabel: "Select all",
158
167
  selectAllDescription: "Enable core, advisory, guardrails, and loop together."
159
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);
160
183
  const enabled = args.reviewTargets !== undefined ||
161
184
  (await selectOption("External review: call another agent CLI to review your work?", [
162
185
  { label: "no", value: false, description: "Default. Nothing is spawned." },
@@ -166,8 +189,9 @@ export async function completeInitChoices(args, io, setup = {}) {
166
189
  description: "Pick target CLIs; each is probed for authentication."
167
190
  }
168
191
  ], selectorIo));
169
- const reviewTargets = args.reviewTargets ?? (enabled
170
- ? await selectOptions("Review targets (multi-select: tried in listed order)", REVIEW_TARGET_CHOICES, selectorIo, [])
192
+ const eligibleReviewChoices = REVIEW_TARGET_CHOICES.filter((choice) => harness.includes(choice.value));
193
+ const reviewTargets = args.reviewTargets ?? (enabled && eligibleReviewChoices.length > 0
194
+ ? await selectOptions("Review targets (multi-select: tried in listed order)", eligibleReviewChoices, selectorIo, REVIEW_TARGET_ORDER.filter((target) => harness.includes(target)), { startEmpty: true })
171
195
  : []);
172
196
  await probeReviewTargets(reviewTargets, setup);
173
197
  return {
@@ -175,6 +199,7 @@ export async function completeInitChoices(args, io, setup = {}) {
175
199
  scope,
176
200
  harness,
177
201
  profiles,
202
+ ...(completionGate ? { completionGate: true } : {}),
178
203
  ...(reviewTargets.length === 0 ? {} : { reviewTargets })
179
204
  };
180
205
  }
@@ -187,8 +212,12 @@ export async function completeInitChoices(args, io, setup = {}) {
187
212
  const profiles = args.profiles.length > 0
188
213
  ? args.profiles
189
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);
218
+ const defaultReviewTargets = REVIEW_TARGET_ORDER.filter((target) => harness.includes(target));
190
219
  const reviewTargets = args.reviewTargets ?? (affirmative(await session.question("Enable external review by another agent CLI? [y/N]: "))
191
- ? selectReviewTargets(await session.question(`Review targets (${DEFAULT_REVIEW_TARGETS.join(",")}): `))
220
+ ? selectReviewTargets(await session.question(`Review targets (${defaultReviewTargets.join(",") || "none"}): `), defaultReviewTargets)
192
221
  : []);
193
222
  await probeReviewTargets(reviewTargets, setup);
194
223
  return {
@@ -196,6 +225,7 @@ export async function completeInitChoices(args, io, setup = {}) {
196
225
  scope,
197
226
  harness,
198
227
  profiles,
228
+ ...(completionGate ? { completionGate: true } : {}),
199
229
  ...(reviewTargets.length === 0 ? {} : { reviewTargets })
200
230
  };
201
231
  }
@@ -0,0 +1,115 @@
1
+ import { AgentOpsError } from "../../fs/paths.js";
2
+ const MARKER = "--managed-by=agent-ops";
3
+ function record(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value)
5
+ ? value
6
+ : null;
7
+ }
8
+ function handler(runtimePath, event, platform, completionGate = false) {
9
+ const gateFlag = completionGate ? " --completion-gate" : "";
10
+ const command = platform === "win32"
11
+ ? `cmd /c node ${JSON.stringify(runtimePath).replaceAll("\\\\", "\\")} agy ${event}${gateFlag} ${MARKER}`
12
+ : `node ${JSON.stringify(runtimePath)} agy ${event}${gateFlag} ${MARKER}`;
13
+ return {
14
+ type: "command",
15
+ command,
16
+ timeout: 30
17
+ };
18
+ }
19
+ function exactKeys(value, keys) {
20
+ return Object.keys(value).length === keys.length && keys.every((key) => key in value);
21
+ }
22
+ function managedHandler(value, event) {
23
+ const candidate = record(value);
24
+ return candidate !== null &&
25
+ exactKeys(candidate, ["type", "command", "timeout"]) &&
26
+ candidate.type === "command" &&
27
+ candidate.timeout === 30 &&
28
+ typeof candidate.command === "string" &&
29
+ (candidate.command.startsWith("node \"") ||
30
+ candidate.command.startsWith("cmd /c node \"")) &&
31
+ (candidate.command.endsWith(` agy ${event} ${MARKER}`) ||
32
+ (event === "Stop" &&
33
+ candidate.command.endsWith(` agy Stop --completion-gate ${MARKER}`)));
34
+ }
35
+ function managedEvent(value, event) {
36
+ if (!Array.isArray(value) || value.length !== 1)
37
+ return false;
38
+ if (event !== "PreToolUse") {
39
+ return managedHandler(value[0], event === "PreInvocation" ? "SessionStart" : "Stop");
40
+ }
41
+ const group = record(value[0]);
42
+ return group !== null &&
43
+ exactKeys(group, ["matcher", "hooks"]) &&
44
+ group.matcher === "run_command" &&
45
+ Array.isArray(group.hooks) &&
46
+ group.hooks.length === 1 &&
47
+ managedHandler(group.hooks[0], "PreToolUse");
48
+ }
49
+ export function buildAgyHookSettings(capabilities, runtimePath, platform = process.platform) {
50
+ if (runtimePath.length === 0 ||
51
+ runtimePath.length > 4096 ||
52
+ /[\0\r\n]/u.test(runtimePath) ||
53
+ (platform === "win32" && runtimePath.includes('"'))) {
54
+ throw new AgentOpsError("AGY_HOOK_PATH_INVALID", "agy hook runtime path is invalid.");
55
+ }
56
+ const hooks = {};
57
+ if (capabilities.includes("lifecycle-summary") || capabilities.includes("project-loop")) {
58
+ hooks.PreInvocation = [handler(runtimePath, "SessionStart", platform)];
59
+ }
60
+ if (capabilities.includes("command-policy") || capabilities.includes("project-loop")) {
61
+ hooks.PreToolUse = [{ matcher: "run_command", hooks: [handler(runtimePath, "PreToolUse", platform)] }];
62
+ }
63
+ if (capabilities.includes("optional-stop-verify") ||
64
+ capabilities.includes("completion-gate")) {
65
+ hooks.Stop = [handler(runtimePath, "Stop", platform, capabilities.includes("completion-gate"))];
66
+ }
67
+ return { hooks };
68
+ }
69
+ export function isAgyManagedHook(value) {
70
+ const named = record(value)?.["agent-ops"];
71
+ const hook = record(named);
72
+ if (hook === null || hook.enabled !== true)
73
+ return false;
74
+ const events = Object.keys(hook).filter((key) => key !== "enabled");
75
+ return events.length > 0 &&
76
+ events.every((event) => (event === "PreInvocation" || event === "PreToolUse" || event === "Stop") &&
77
+ managedEvent(hook[event], event));
78
+ }
79
+ export function isAgyHookRegistered(value, capabilities) {
80
+ const expected = buildAgyHookSettings(capabilities, "probe").hooks;
81
+ if (Object.keys(expected).length === 0)
82
+ return true;
83
+ const named = record(record(value)?.["agent-ops"]);
84
+ if (named === null || !isAgyManagedHook(value))
85
+ return false;
86
+ const eventsMatch = Object.keys(expected)
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}`);
94
+ }
95
+ export function mergeAgyHooks(existing, managed) {
96
+ const source = record(existing);
97
+ if (source === null) {
98
+ throw new AgentOpsError("AGY_HOOKS_INVALID", "agy hooks must be a JSON object.");
99
+ }
100
+ if (source["agent-ops"] !== undefined && !isAgyManagedHook(source)) {
101
+ throw new AgentOpsError("AGY_HOOK_NAME_CONFLICT", "Refusing to replace the existing non-agent-ops hook named agent-ops.");
102
+ }
103
+ return { ...source, "agent-ops": { enabled: true, ...managed.hooks } };
104
+ }
105
+ export function stripAgyHooks(existing) {
106
+ const source = record(existing);
107
+ if (source === null) {
108
+ throw new AgentOpsError("AGY_HOOKS_INVALID", "agy hooks must be a JSON object.");
109
+ }
110
+ if (source["agent-ops"] !== undefined && !isAgyManagedHook(source)) {
111
+ throw new AgentOpsError("AGY_HOOK_NAME_CONFLICT", "The agent-ops hook is not owned by agent-ops.");
112
+ }
113
+ const { "agent-ops": _owned, ...rest } = source;
114
+ return rest;
115
+ }
@@ -0,0 +1,35 @@
1
+ export const AGY_CAPABILITY_REGISTRATIONS = [
2
+ {
3
+ capability: "lifecycle-summary",
4
+ normalizedEvent: "session-start",
5
+ nativeEvent: "SessionStart",
6
+ hostEvent: "PreInvocation",
7
+ surfaceId: "agy-hooks",
8
+ support: "degraded",
9
+ runtimeFailure: "fail-open"
10
+ },
11
+ {
12
+ capability: "command-policy",
13
+ normalizedEvent: "command",
14
+ nativeEvent: "PreToolUse",
15
+ surfaceId: "agy-hooks",
16
+ support: "supported",
17
+ runtimeFailure: "fail-closed"
18
+ },
19
+ {
20
+ capability: "completion-gate",
21
+ normalizedEvent: "stop",
22
+ nativeEvent: "Stop",
23
+ surfaceId: "agy-hooks",
24
+ support: "supported",
25
+ runtimeFailure: "fail-closed"
26
+ },
27
+ {
28
+ capability: "optional-stop-verify",
29
+ normalizedEvent: "stop",
30
+ nativeEvent: "Stop",
31
+ surfaceId: "agy-hooks",
32
+ support: "degraded",
33
+ runtimeFailure: "fail-open"
34
+ }
35
+ ];
@@ -0,0 +1,48 @@
1
+ import { normalizeHookEvent } from "../../hooks/normalize.js";
2
+ import { normalizeShellHookEvent } from "../../hooks/shell.js";
3
+ function record(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value)
5
+ ? value
6
+ : null;
7
+ }
8
+ export function normalizeAgyHookInput(input) {
9
+ const value = record(input);
10
+ if (value === null)
11
+ return normalizeHookEvent(input);
12
+ const workspacePaths = Array.isArray(value.workspacePaths)
13
+ ? value.workspacePaths
14
+ : [];
15
+ const projectRoot = typeof workspacePaths[0] === "string"
16
+ ? workspacePaths[0]
17
+ : undefined;
18
+ const sessionId = typeof value.conversationId === "string"
19
+ ? value.conversationId
20
+ : undefined;
21
+ const toolCall = record(value.toolCall);
22
+ const args = record(toolCall?.args);
23
+ if (toolCall?.name === "run_command" && typeof args?.CommandLine === "string") {
24
+ return {
25
+ ...normalizeShellHookEvent(args.CommandLine, projectRoot),
26
+ ...(sessionId === undefined ? {} : { sessionId })
27
+ };
28
+ }
29
+ if (typeof value.terminationReason === "string") {
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
+ };
39
+ }
40
+ if (typeof value.invocationNum === "number") {
41
+ return {
42
+ event: "session-start",
43
+ projectRoot: projectRoot ?? process.cwd(),
44
+ ...(sessionId === undefined ? {} : { sessionId })
45
+ };
46
+ }
47
+ return normalizeHookEvent({ event: "unsupported", projectRoot });
48
+ }
@@ -0,0 +1,28 @@
1
+ export function agyHookOutput(event, result) {
2
+ if (result.action === "continue" && result.status === "PASS") {
3
+ return {
4
+ exitCode: 0,
5
+ stdout: event === "PreToolUse" || event === "Stop"
6
+ ? JSON.stringify({ decision: "allow" })
7
+ : "{}",
8
+ stderr: ""
9
+ };
10
+ }
11
+ const reason = result.remedy === undefined
12
+ ? `agent-ops: ${result.code}`
13
+ : `agent-ops: ${result.code}: ${result.remedy}`;
14
+ const value = event === "PreToolUse"
15
+ ? result.code === "COMPLETION_GATE_PERMIT_CONFIRMATION"
16
+ ? { decision: "force_ask", reason }
17
+ : result.action === "block"
18
+ ? { decision: "deny", reason }
19
+ : { decision: "allow" }
20
+ : event === "SessionStart"
21
+ ? { injectSteps: [{ ephemeralMessage: reason }] }
22
+ : event === "Stop"
23
+ ? result.action === "block" && result.code.startsWith("COMPLETION_GATE_")
24
+ ? { decision: "continue", reason }
25
+ : { decision: "allow", reason }
26
+ : {};
27
+ return { exitCode: 0, stdout: JSON.stringify(value), stderr: "" };
28
+ }
@@ -0,0 +1,9 @@
1
+ export function agySurfaces(scope) {
2
+ return [{
3
+ id: "agy-hooks",
4
+ path: scope === "project" ? ".agents/hooks.json" : ".gemini/config/hooks.json",
5
+ scope,
6
+ access: "managed-default",
7
+ representation: "json"
8
+ }];
9
+ }
@@ -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. */