@kylecheng3146/agent-ops 0.1.18 → 0.1.20

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 (39) hide show
  1. package/README.md +16 -9
  2. package/dist/packages/cli/src/bin.js +59 -9
  3. package/dist/packages/cli/src/cli.js +1 -1
  4. package/dist/packages/cli/src/commands/init.js +39 -9
  5. package/dist/packages/cli/src/commands/review.js +3 -0
  6. package/dist/packages/cli/src/commands/uninstall.js +20 -3
  7. package/dist/packages/cli/src/ui.js +3 -1
  8. package/dist/packages/cli/src/wizard.js +12 -6
  9. package/dist/runtime/src/adapters/agy/config.js +105 -0
  10. package/dist/runtime/src/adapters/agy/events.js +27 -0
  11. package/dist/runtime/src/adapters/agy/input.js +30 -0
  12. package/dist/runtime/src/adapters/agy/output.js +24 -0
  13. package/dist/runtime/src/adapters/agy/surfaces.js +9 -0
  14. package/dist/runtime/src/fs/managed-block.js +23 -10
  15. package/dist/runtime/src/install/codex-loop.js +6 -2
  16. package/dist/runtime/src/install/doctor.js +15 -0
  17. package/dist/runtime/src/install/harness.js +52 -1
  18. package/dist/runtime/src/install/hooks.js +6 -1
  19. package/dist/runtime/src/install/ownership.js +12 -8
  20. package/dist/runtime/src/install/plan.js +2 -2
  21. package/dist/runtime/src/install/probes.js +61 -0
  22. package/dist/runtime/src/install/surface-inspection.js +10 -1
  23. package/dist/runtime/src/install/uninstall.js +337 -6
  24. package/dist/runtime/src/review/execute.js +23 -20
  25. package/dist/runtime/src/review/extract.js +5 -11
  26. package/dist/runtime/src/review/render.js +6 -1
  27. package/dist/runtime/src/review/roles.js +4 -0
  28. package/dist/runtime/src/review/runner.js +7 -0
  29. package/dist/runtime/src/schema/validate.js +2 -2
  30. package/docs/en/guides/configuration.md +22 -6
  31. package/docs/en/spec/README.md +5 -2
  32. package/docs/en/spec/harness-adapters.md +20 -9
  33. package/docs/en/spec/review.md +6 -0
  34. package/docs/zh-TW/guides/configuration.md +20 -6
  35. package/docs/zh-TW/spec/README.md +4 -2
  36. package/docs/zh-TW/spec/harness-adapters.md +18 -10
  37. package/docs/zh-TW/spec/review.md +5 -0
  38. package/package.json +2 -2
  39. package/schemas/manifest.schema.json +2 -2
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Loop Engineering Toolkit
2
2
 
3
3
  Loop Engineering Toolkit is an evidence-driven development-loop toolkit for
4
- Codex, Claude Code, and opencode. It is designed to turn acceptance criteria, explicit
4
+ agy, Codex, Claude Code, and opencode. It is designed to turn acceptance criteria, explicit
5
5
  verification, safe lifecycle hooks, and independent review into a repeatable
6
6
  engineering workflow.
7
7
 
@@ -34,9 +34,9 @@ automation. The wizard never writes files until you review and confirm its
34
34
  installation plan.
35
35
 
36
36
  The interactive multi-select screens start with no harness or profile selected.
37
- Choose at least one of `codex`, `claude`, and `opencode`, and at least one of
37
+ Choose at least one of `agy`, `codex`, `claude`, and `opencode`, and at least one of
38
38
  the `core`, `advisory`, `guardrails`, and `loop` profiles before confirming. For
39
- scripted use, `--harness all` selects all three harnesses; comma-separated
39
+ scripted use, `--harness all` selects all four harnesses; comma-separated
40
40
  selections such as `codex,opencode` are also supported. The legacy `both` value
41
41
  remains an alias for `codex,claude`.
42
42
 
@@ -66,7 +66,10 @@ agent-ops uninstall --dry-run --json
66
66
 
67
67
  ### Project-local loop
68
68
 
69
- `loop` is an explicit, project-only profile for Codex, Claude Code, or both.
69
+ `loop` is an explicit, project-only profile for agy, Codex, and Claude Code.
70
+ agy installs its native `PreInvocation`, `PreToolUse(run_command)`, and optional
71
+ `Stop` subset directly in `.agents/hooks.json`; doctor reports its loop as
72
+ degraded because prompt, permission, compact, and subagent events are unavailable.
70
73
  Claude Code supports native Windows through a generated PowerShell launcher;
71
74
  Codex's loop launcher still requires POSIX-compatible `bash`. Preview it
72
75
  first, then install only after reviewing the plan. `loop` also implies the
@@ -286,13 +289,17 @@ to a deterministic worktree (or `--base`) scope. `--task` reviews keep their
286
289
  task criteria and require current verification evidence. The detailed report
287
290
  is displayed; a minimal source-fingerprint attestation is persisted after PASS.
288
291
 
289
- Each attempt uses a fresh temporary cwd, a small allowlisted environment, and a
290
- target-native read-only mode. Claude additionally uses its complete safe mode;
291
- Codex ignores user config and persistence, while Agy runs in sandboxed plan
292
- mode against a disposable repository clone. Codex and Agy preserve their
292
+ Each attempt uses a fresh temporary session and disposable repository clone, a
293
+ small allowlisted environment, and a target-native read-only mode. Claude
294
+ additionally uses its complete safe mode; Codex ignores user config and
295
+ persistence, while Agy runs in sandboxed plan mode. Codex and Agy preserve their
293
296
  existing login environment, so their context isolation is intentionally weaker
294
297
  than Claude's. `opencode` is not a review target.
295
298
 
299
+ The chain prefers a reviewer CLI different from the hosting CLI. If none is
300
+ usable, a fresh same-target session is allowed but is rendered as
301
+ `DEGRADED: isolated self-review`; a development session is never resumed.
302
+
296
303
  The first valid `PASS` or `FAIL` is final. Attempts that produce no verdict —
297
304
  including login failures and unparseable output — advance to the next target
298
305
  and remain visible in the result's `attempts`. `--yes` is still required for
@@ -313,7 +320,7 @@ See [Configuration](docs/en/guides/configuration.md) for the full contract.
313
320
  - Treat command output and current filesystem state as evidence.
314
321
  - Keep advisory automation separate from blocking guardrails.
315
322
  - Preserve user configuration through managed, reversible updates.
316
- - Support Codex, Claude Code, and opencode without project-specific assumptions.
323
+ - Support agy, Codex, Claude Code, and opencode without project-specific assumptions.
317
324
  - Collect no network telemetry.
318
325
 
319
326
  ## Project status
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  import { execFileSync } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { commonHarnessAdapters, harnessHookPath, HARNESS_IDS } from "../../../runtime/src/install/harness.js";
8
- import { hookRegistrationDrift, repositoryTrustStatus, smokeAvailabilityStatus } from "../../../runtime/src/install/probes.js";
8
+ import { agyRuntimeStatus, agyVersionSupported, hookRegistrationDrift, repositoryTrustStatus, smokeAvailabilityStatus } from "../../../runtime/src/install/probes.js";
9
9
  import { parseInstallManifest } from "../../../runtime/src/fs/manifest.js";
10
10
  import { NpmRegistryClient } from "../../../runtime/src/registry/npm.js";
11
11
  import { TaskService } from "../../../runtime/src/task/service.js";
@@ -92,13 +92,13 @@ function gitRunner(root) {
92
92
  }
93
93
  };
94
94
  }
95
- async function confirmInit(plan, trust) {
95
+ async function confirmInit(plan, trust, warnings = []) {
96
96
  writeBanner({
97
97
  isTTY: process.stdout.isTTY === true,
98
98
  columns: process.stdout.columns,
99
99
  write: (value) => process.stdout.write(value)
100
100
  });
101
- return await confirmPlan(formatInstallPlan(plan, trust));
101
+ return await confirmPlan(formatInstallPlan(plan, trust, warnings));
102
102
  }
103
103
  async function confirmPlan(text) {
104
104
  process.stdout.write(text);
@@ -115,6 +115,13 @@ async function plannedTrustBinding(root, projectConfig) {
115
115
  : await repositoryTrustBinding(root, config, CLI_VERSION);
116
116
  }
117
117
  const argv = process.argv.slice(2);
118
+ function runAgy(args, options = {}) {
119
+ return execFileSync("agy", [...args], {
120
+ ...options,
121
+ encoding: "utf8",
122
+ ...(process.platform === "win32" ? { shell: true } : {})
123
+ });
124
+ }
118
125
  if (argv[0] === "hook") {
119
126
  process.exitCode = await runHookProcess(argv.slice(1), {
120
127
  stdin: process.stdin,
@@ -157,20 +164,53 @@ else {
157
164
  isTTY,
158
165
  toolkitVersion: CLI_VERSION,
159
166
  hookRuntimePath: HOOK_RUNTIME_PATH,
167
+ agyWarning: () => {
168
+ try {
169
+ const output = runAgy(["--version"], { timeout: 5_000 });
170
+ return agyVersionSupported(output)
171
+ ? undefined
172
+ : "agy is installed, but version 1.1.12 or newer is required; run `agent-ops doctor` after updating.";
173
+ }
174
+ catch {
175
+ return "agy is not installed or could not be started; install agy 1.1.12 or newer, then run `agent-ops doctor`.";
176
+ }
177
+ },
160
178
  ...(args.hookTargets === undefined
161
179
  ? {}
162
180
  : { hookTargets: args.hookTargets }),
163
181
  trustStore: store,
164
182
  calculateTrustBinding: async (config) => await plannedTrustBinding(root, config),
165
- confirm: async (plan, trust) => await confirmInit(plan, trust)
183
+ confirm: async (plan, trust, warnings) => await confirmInit(plan, trust, warnings)
166
184
  });
167
185
  }
168
186
  if (args.command === "doctor") {
187
+ const doctorManifest = await installedManifest(root);
169
188
  const config = (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config;
170
189
  return await runDoctorCommand({
171
190
  root,
172
191
  toolkitVersion: CLI_VERSION,
173
192
  probes: {
193
+ ...(doctorManifest?.harness.includes("agy") === true
194
+ ? {
195
+ agyRuntime: () => {
196
+ try {
197
+ return agyRuntimeStatus(runAgy(["--version"]), runAgy([
198
+ "-p", "/hooks", "--output-format", "json",
199
+ ...(doctorManifest.scope === "project"
200
+ ? ["--new-project"]
201
+ : [])
202
+ ], { cwd: root }), doctorManifest.hooks?.find(({ harness }) => harness === "agy")?.events ?? []);
203
+ }
204
+ catch {
205
+ return {
206
+ status: "FAIL",
207
+ message: "agy is missing or could not report its loaded hooks.",
208
+ remediation: "Install agy 1.1.12 or newer and run `agent-ops doctor` again."
209
+ };
210
+ }
211
+ }
212
+ }
213
+ : {}),
174
214
  hookRegistration: async () => {
175
215
  const drifted = hookRegistrationDrift({
176
216
  harness: await installedHarness(root),
@@ -246,23 +286,33 @@ else {
246
286
  }
247
287
  if (args.command === "update") {
248
288
  const store = trustStore();
289
+ const manifest = await installedManifest(root);
290
+ const addAgy = isTTY &&
291
+ !args.yes &&
292
+ args.harness === undefined &&
293
+ manifest !== null &&
294
+ !manifest.harness.includes("agy") &&
295
+ await selectYesNo("Add newly supported harness: agy?", { input: process.stdin, output: process.stdout }, false);
296
+ const updateArgs = addAgy
297
+ ? { ...args, harness: [...(manifest?.harness ?? []), "agy"] }
298
+ : args;
249
299
  return await runUpdateCommand({
250
- args,
300
+ args: updateArgs,
251
301
  root,
252
302
  adapters: commonHarnessAdapters(),
253
303
  registry: new NpmRegistryClient(),
254
304
  isTTY,
255
305
  toolkitVersion: CLI_VERSION,
256
306
  hookRuntimePath: HOOK_RUNTIME_PATH,
257
- ...(args.hookTargets === undefined
307
+ ...(updateArgs.hookTargets === undefined
258
308
  ? {}
259
- : { hookTargets: args.hookTargets }),
309
+ : { hookTargets: updateArgs.hookTargets }),
260
310
  trustStore: store,
261
311
  calculateTrustBinding: async (config) => await plannedTrustBinding(root, config),
262
312
  confirm: async (plan, trust) => await confirmPlan(formatUpdatePlan(plan, trust)),
263
- ...(args.targetVersion === undefined
313
+ ...(updateArgs.targetVersion === undefined
264
314
  ? {}
265
- : { targetVersion: args.targetVersion })
315
+ : { targetVersion: updateArgs.targetVersion })
266
316
  });
267
317
  }
268
318
  const taskService = new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root));
@@ -30,7 +30,7 @@ Commands:
30
30
 
31
31
  Options:
32
32
  --scope <project|user>
33
- --harness <all|both|claude|codex|opencode|comma-separated> Init/update
33
+ --harness <all|both|agy|claude|codex|opencode|comma-separated> Init/update/uninstall
34
34
  --hook-target <harness=surface-id> Repeatable advanced init/update option
35
35
  --profile <core|advisory|guardrails|loop> Repeatable
36
36
  --review-target <codex|agy|claude> Repeatable init option; external review
@@ -5,7 +5,7 @@ import { okEnvelope } from "../output.js";
5
5
  import { formatOperationPlan } from "../plan-output.js";
6
6
  import { toPublicInstallPlan } from "../public-plan.js";
7
7
  import { formatTrustChange, planTrustGrant } from "./trust.js";
8
- export function formatInstallPlan(plan, trust) {
8
+ export function formatInstallPlan(plan, trust, warnings = []) {
9
9
  const hooks = plan.manifest.hooks ?? [];
10
10
  const detectedVerification = plan.detectedVerification;
11
11
  return formatOperationPlan({
@@ -14,6 +14,12 @@ export function formatInstallPlan(plan, trust) {
14
14
  `Scope: ${plan.scope}`,
15
15
  `Harness: ${plan.harness}`,
16
16
  `Profiles: ${plan.profiles.join(", ")}`,
17
+ ...(plan.scope === "user" && plan.harness.includes("agy")
18
+ ? ["Notice: .gemini/GEMINI.md is a shared Gemini rule surface."]
19
+ : []),
20
+ ...(warnings.length === 0
21
+ ? []
22
+ : ["Warnings:", ...warnings.map((warning) => ` - ${warning}`)]),
17
23
  ...(trust === undefined ? [] : formatTrustChange(trust)),
18
24
  ...(hooks.length === 0
19
25
  ? ["Hooks: none selected"]
@@ -42,11 +48,19 @@ function appliedMessage(plan) {
42
48
  ...hooks.map((hook) => `- ${hook.harness}: ${hook.path} (${hook.events.join(", ")})`)
43
49
  ].join("\n");
44
50
  }
45
- function initError(code, message, plan, trust, applied = false) {
51
+ function initError(code, message, plan, trust, warnings = [], applied = false) {
52
+ const displayMessage = warnings.length === 0
53
+ ? message
54
+ : `${message}\nWarnings:\n${warnings.map((warning) => `- ${warning}`).join("\n")}`;
46
55
  return {
47
56
  code,
48
57
  status: "error",
49
- data: { applied, plan: toPublicInstallPlan(plan, trust), message },
58
+ data: {
59
+ applied,
60
+ plan: toPublicInstallPlan(plan, trust),
61
+ message: displayMessage,
62
+ ...(warnings.length === 0 ? {} : { warnings })
63
+ },
50
64
  errors: [{ code, message }]
51
65
  };
52
66
  }
@@ -91,21 +105,33 @@ export async function runInitCommand(options) {
91
105
  : { reviewTargets: args.reviewTargets })
92
106
  });
93
107
  const trust = await trustChange(options, plan);
108
+ const warnings = plan.harness.includes("agy") && options.agyWarning !== undefined
109
+ ? (() => {
110
+ try {
111
+ const warning = options.agyWarning();
112
+ return warning === undefined ? [] : [warning];
113
+ }
114
+ catch {
115
+ return ["agy could not be probed; run `agent-ops doctor` to verify it."];
116
+ }
117
+ })()
118
+ : [];
94
119
  if (args.dryRun) {
95
120
  return okEnvelope("INIT_PLAN_READY", {
96
121
  applied: false,
97
122
  plan: toPublicInstallPlan(plan, trust),
98
123
  message: "Installation plan calculated; no files were written.",
99
- text: formatInstallPlan(plan, trust)
124
+ ...(warnings.length === 0 ? {} : { warnings }),
125
+ text: formatInstallPlan(plan, trust, warnings)
100
126
  });
101
127
  }
102
128
  if (!args.yes && !options.isTTY) {
103
- return initError("INIT_CONFIRMATION_REQUIRED", "Non-interactive init requires --yes after all choices are explicit.", plan, trust);
129
+ return initError("INIT_CONFIRMATION_REQUIRED", "Non-interactive init requires --yes after all choices are explicit.", plan, trust, warnings);
104
130
  }
105
131
  if (!args.yes &&
106
132
  options.isTTY &&
107
- !(await options.confirm(plan, trust))) {
108
- return initError("INIT_CANCELLED", "Installation was cancelled; no files were written.", plan, trust);
133
+ !(await options.confirm(plan, trust, warnings))) {
134
+ return initError("INIT_CANCELLED", "Installation was cancelled; no files were written.", plan, trust, warnings);
109
135
  }
110
136
  await applyInstallPlan(options.root, plan);
111
137
  if (trust.action === "grant") {
@@ -116,12 +142,16 @@ export async function runInitCommand(options) {
116
142
  await options.trustStore.grant(trust.binding);
117
143
  }
118
144
  catch {
119
- return initError("INIT_TRUST_FAILED", "Installation was applied, but repository trust was not granted. Run `agent-ops trust grant --yes`.", plan, trust, true);
145
+ return initError("INIT_TRUST_FAILED", "Installation was applied, but repository trust was not granted. Run `agent-ops trust grant --yes`.", plan, trust, warnings, true);
120
146
  }
121
147
  }
148
+ const message = appliedMessage(plan);
122
149
  return okEnvelope("INIT_APPLIED", {
123
150
  applied: true,
124
151
  plan: toPublicInstallPlan(plan, trust),
125
- message: appliedMessage(plan)
152
+ message: warnings.length === 0
153
+ ? message
154
+ : `${message}\nWarnings:\n${warnings.map((warning) => `- ${warning}`).join("\n")}`,
155
+ ...(warnings.length === 0 ? {} : { warnings })
126
156
  });
127
157
  }
@@ -217,6 +217,9 @@ function sourceChangedResult(result) {
217
217
  ...(result.independence === undefined
218
218
  ? {}
219
219
  : { independence: result.independence }),
220
+ ...(result.sessionIsolation === undefined
221
+ ? {}
222
+ : { sessionIsolation: result.sessionIsolation }),
220
223
  ...(result.attempts === undefined ? {} : { attempts: result.attempts }),
221
224
  ...(result.verification === undefined
222
225
  ? {}
@@ -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 };
@@ -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");
@@ -68,10 +71,10 @@ const PROFILE_CHOICES = [
68
71
  {
69
72
  label: "loop",
70
73
  value: "loop",
71
- description: "Installs the shared Codex and Claude Code local loop with secret and destructive-command interception."
74
+ description: "Installs the local loop; agy uses its supported lifecycle subset and is reported as degraded."
72
75
  }
73
76
  ];
74
- const WIZARD_SUBTITLE = "Safe setup for Codex, Claude Code, and opencode with profile-driven rules, verification, and hooks.";
77
+ const WIZARD_SUBTITLE = "Safe setup for agy, Codex, Claude Code, and opencode with profile-driven rules, verification, and hooks.";
75
78
  async function createPromptSession(io) {
76
79
  if (!io.isTTY) {
77
80
  throw new CliArgumentError("CLI_INTERACTIVE_REQUIRED", "Missing init choices require an interactive terminal.");
@@ -146,6 +149,7 @@ export async function completeInitChoices(args, io, setup = {}) {
146
149
  const scope = args.scope ?? await selectOption("Scope", SCOPE_CHOICES, selectorIo);
147
150
  const harness = args.harness ??
148
151
  await selectOptions("Harness (multi-select: ↑↓ move, Space toggle, Enter confirm)", HARNESS_CHOICES, selectorIo, [...DEFAULT_HARNESS], {
152
+ startEmpty: true,
149
153
  selectAll: true,
150
154
  selectAllLabel: "Select all",
151
155
  selectAllDescription: "Install for every supported harness."
@@ -166,8 +170,9 @@ export async function completeInitChoices(args, io, setup = {}) {
166
170
  description: "Pick target CLIs; each is probed for authentication."
167
171
  }
168
172
  ], selectorIo));
169
- const reviewTargets = args.reviewTargets ?? (enabled
170
- ? await selectOptions("Review targets (multi-select: tried in listed order)", REVIEW_TARGET_CHOICES, selectorIo, [])
173
+ const eligibleReviewChoices = REVIEW_TARGET_CHOICES.filter((choice) => harness.includes(choice.value));
174
+ const reviewTargets = args.reviewTargets ?? (enabled && eligibleReviewChoices.length > 0
175
+ ? await selectOptions("Review targets (multi-select: tried in listed order)", eligibleReviewChoices, selectorIo, REVIEW_TARGET_ORDER.filter((target) => harness.includes(target)), { startEmpty: true })
171
176
  : []);
172
177
  await probeReviewTargets(reviewTargets, setup);
173
178
  return {
@@ -187,8 +192,9 @@ export async function completeInitChoices(args, io, setup = {}) {
187
192
  const profiles = args.profiles.length > 0
188
193
  ? args.profiles
189
194
  : selectProfiles(await session.question("Profiles (core,advisory,guardrails,loop) [core]: "));
195
+ const defaultReviewTargets = REVIEW_TARGET_ORDER.filter((target) => harness.includes(target));
190
196
  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(",")}): `))
197
+ ? selectReviewTargets(await session.question(`Review targets (${defaultReviewTargets.join(",") || "none"}): `), defaultReviewTargets)
192
198
  : []);
193
199
  await probeReviewTargets(reviewTargets, setup);
194
200
  return {
@@ -0,0 +1,105 @@
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) {
9
+ const command = platform === "win32"
10
+ ? `cmd /c node ${JSON.stringify(runtimePath).replaceAll("\\\\", "\\")} agy ${event} ${MARKER}`
11
+ : `node ${JSON.stringify(runtimePath)} agy ${event} ${MARKER}`;
12
+ return {
13
+ type: "command",
14
+ command,
15
+ timeout: 30
16
+ };
17
+ }
18
+ function exactKeys(value, keys) {
19
+ return Object.keys(value).length === keys.length && keys.every((key) => key in value);
20
+ }
21
+ function managedHandler(value, event) {
22
+ const candidate = record(value);
23
+ return candidate !== null &&
24
+ exactKeys(candidate, ["type", "command", "timeout"]) &&
25
+ candidate.type === "command" &&
26
+ candidate.timeout === 30 &&
27
+ typeof candidate.command === "string" &&
28
+ (candidate.command.startsWith("node \"") ||
29
+ candidate.command.startsWith("cmd /c node \"")) &&
30
+ candidate.command.endsWith(` agy ${event} ${MARKER}`);
31
+ }
32
+ function managedEvent(value, event) {
33
+ if (!Array.isArray(value) || value.length !== 1)
34
+ return false;
35
+ if (event !== "PreToolUse") {
36
+ return managedHandler(value[0], event === "PreInvocation" ? "SessionStart" : "Stop");
37
+ }
38
+ const group = record(value[0]);
39
+ return group !== null &&
40
+ exactKeys(group, ["matcher", "hooks"]) &&
41
+ group.matcher === "run_command" &&
42
+ Array.isArray(group.hooks) &&
43
+ group.hooks.length === 1 &&
44
+ managedHandler(group.hooks[0], "PreToolUse");
45
+ }
46
+ export function buildAgyHookSettings(capabilities, runtimePath, platform = process.platform) {
47
+ if (runtimePath.length === 0 ||
48
+ runtimePath.length > 4096 ||
49
+ /[\0\r\n]/u.test(runtimePath) ||
50
+ (platform === "win32" && runtimePath.includes('"'))) {
51
+ throw new AgentOpsError("AGY_HOOK_PATH_INVALID", "agy hook runtime path is invalid.");
52
+ }
53
+ const hooks = {};
54
+ if (capabilities.includes("lifecycle-summary") || capabilities.includes("project-loop")) {
55
+ hooks.PreInvocation = [handler(runtimePath, "SessionStart", platform)];
56
+ }
57
+ if (capabilities.includes("command-policy") || capabilities.includes("project-loop")) {
58
+ hooks.PreToolUse = [{ matcher: "run_command", hooks: [handler(runtimePath, "PreToolUse", platform)] }];
59
+ }
60
+ if (capabilities.includes("optional-stop-verify")) {
61
+ hooks.Stop = [handler(runtimePath, "Stop", platform)];
62
+ }
63
+ return { hooks };
64
+ }
65
+ export function isAgyManagedHook(value) {
66
+ const named = record(value)?.["agent-ops"];
67
+ const hook = record(named);
68
+ if (hook === null || hook.enabled !== true)
69
+ return false;
70
+ const events = Object.keys(hook).filter((key) => key !== "enabled");
71
+ return events.length > 0 &&
72
+ events.every((event) => (event === "PreInvocation" || event === "PreToolUse" || event === "Stop") &&
73
+ managedEvent(hook[event], event));
74
+ }
75
+ export function isAgyHookRegistered(value, capabilities) {
76
+ const expected = buildAgyHookSettings(capabilities, "probe").hooks;
77
+ if (Object.keys(expected).length === 0)
78
+ return true;
79
+ const named = record(record(value)?.["agent-ops"]);
80
+ if (named === null || !isAgyManagedHook(value))
81
+ return false;
82
+ return Object.keys(expected)
83
+ .every((event) => managedEvent(named[event], event));
84
+ }
85
+ export function mergeAgyHooks(existing, managed) {
86
+ const source = record(existing);
87
+ if (source === null) {
88
+ throw new AgentOpsError("AGY_HOOKS_INVALID", "agy hooks must be a JSON object.");
89
+ }
90
+ if (source["agent-ops"] !== undefined && !isAgyManagedHook(source)) {
91
+ throw new AgentOpsError("AGY_HOOK_NAME_CONFLICT", "Refusing to replace the existing non-agent-ops hook named agent-ops.");
92
+ }
93
+ return { ...source, "agent-ops": { enabled: true, ...managed.hooks } };
94
+ }
95
+ export function stripAgyHooks(existing) {
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", "The agent-ops hook is not owned by agent-ops.");
102
+ }
103
+ const { "agent-ops": _owned, ...rest } = source;
104
+ return rest;
105
+ }
@@ -0,0 +1,27 @@
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: "optional-stop-verify",
21
+ normalizedEvent: "stop",
22
+ nativeEvent: "Stop",
23
+ surfaceId: "agy-hooks",
24
+ support: "degraded",
25
+ runtimeFailure: "fail-open"
26
+ }
27
+ ];
@@ -0,0 +1,30 @@
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 toolCall = record(value.toolCall);
19
+ const args = record(toolCall?.args);
20
+ if (toolCall?.name === "run_command" && typeof args?.CommandLine === "string") {
21
+ return normalizeShellHookEvent(args.CommandLine, projectRoot);
22
+ }
23
+ if (typeof value.terminationReason === "string") {
24
+ return normalizeHookEvent({ event: "stop", projectRoot });
25
+ }
26
+ if (typeof value.invocationNum === "number") {
27
+ return normalizeHookEvent({ event: "session-start", projectRoot });
28
+ }
29
+ return normalizeHookEvent({ event: "unsupported", projectRoot });
30
+ }
@@ -0,0 +1,24 @@
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.action === "block"
16
+ ? { decision: "deny", reason }
17
+ : { decision: "allow" }
18
+ : event === "SessionStart"
19
+ ? { injectSteps: [{ ephemeralMessage: reason }] }
20
+ : event === "Stop"
21
+ ? { decision: "allow", reason }
22
+ : {};
23
+ return { exitCode: 0, stdout: JSON.stringify(value), stderr: "" };
24
+ }
@@ -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
+ }