@kylecheng3146/agent-ops 0.1.4 → 0.1.5

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 (78) hide show
  1. package/README.md +88 -13
  2. package/dist/packages/cli/src/args.js +39 -6
  3. package/dist/packages/cli/src/bin.js +33 -11
  4. package/dist/packages/cli/src/cli.js +6 -1
  5. package/dist/packages/cli/src/commands/doctor.js +31 -9
  6. package/dist/packages/cli/src/commands/hook.js +18 -16
  7. package/dist/packages/cli/src/commands/init.js +9 -5
  8. package/dist/packages/cli/src/commands/review.js +5 -1
  9. package/dist/packages/cli/src/commands/uninstall.js +6 -5
  10. package/dist/packages/cli/src/commands/update.js +13 -5
  11. package/dist/packages/cli/src/context.js +9 -3
  12. package/dist/packages/cli/src/hook-process.js +109 -7
  13. package/dist/packages/cli/src/plan-output.js +9 -4
  14. package/dist/packages/cli/src/public-plan.js +62 -0
  15. package/dist/packages/cli/src/version.js +1 -1
  16. package/dist/packages/cli/src/wizard.js +27 -11
  17. package/dist/runtime/src/adapters/claude/config.js +0 -8
  18. package/dist/runtime/src/adapters/claude/events.js +26 -0
  19. package/dist/runtime/src/adapters/claude/output.js +6 -6
  20. package/dist/runtime/src/adapters/claude/surfaces.js +70 -0
  21. package/dist/runtime/src/adapters/codex/config.js +29 -11
  22. package/dist/runtime/src/adapters/codex/events.js +26 -0
  23. package/dist/runtime/src/adapters/codex/output.js +10 -0
  24. package/dist/runtime/src/adapters/codex/surfaces.js +12 -0
  25. package/dist/runtime/src/adapters/opencode/config.js +170 -0
  26. package/dist/runtime/src/adapters/opencode/events.js +49 -0
  27. package/dist/runtime/src/adapters/opencode/input.js +32 -0
  28. package/dist/runtime/src/adapters/opencode/output.js +23 -0
  29. package/dist/runtime/src/adapters/opencode/surfaces.js +23 -0
  30. package/dist/runtime/src/config/explain.js +7 -0
  31. package/dist/runtime/src/config/hash.js +24 -0
  32. package/dist/runtime/src/config/merge.js +6 -2
  33. package/dist/runtime/src/config/migrate.js +19 -4
  34. package/dist/runtime/src/contracts.js +10 -1
  35. package/dist/runtime/src/fs/manifest.js +32 -1
  36. package/dist/runtime/src/fs/transaction.js +14 -2
  37. package/dist/runtime/src/hooks/advisory.js +16 -0
  38. package/dist/runtime/src/hooks/stop-service.js +70 -0
  39. package/dist/runtime/src/hooks/stop-verify.js +4 -1
  40. package/dist/runtime/src/install/doctor.js +119 -9
  41. package/dist/runtime/src/install/harness.js +296 -35
  42. package/dist/runtime/src/install/hooks.js +22 -17
  43. package/dist/runtime/src/install/ownership.js +110 -34
  44. package/dist/runtime/src/install/plan.js +207 -28
  45. package/dist/runtime/src/install/probes.js +9 -43
  46. package/dist/runtime/src/install/profiles.js +8 -1
  47. package/dist/runtime/src/install/surface-inspection.js +296 -0
  48. package/dist/runtime/src/install/surfaces.js +11 -0
  49. package/dist/runtime/src/install/uninstall.js +10 -3
  50. package/dist/runtime/src/install/update.js +8 -2
  51. package/dist/runtime/src/schema/validate.js +37 -18
  52. package/dist/runtime/src/task/service.js +3 -3
  53. package/dist/runtime/src/task/store.js +12 -3
  54. package/dist/runtime/src/verify/command-executor.js +113 -0
  55. package/dist/runtime/src/verify/evidence.js +4 -24
  56. package/dist/runtime/src/verify/service.js +20 -92
  57. package/dist/runtime/src/verify/spawn.js +6 -1
  58. package/docs/en/guides/configuration.md +83 -0
  59. package/docs/en/guides/quickstart.md +9 -0
  60. package/docs/en/guides/security.md +5 -0
  61. package/docs/en/spec/README.md +9 -0
  62. package/docs/en/spec/harness-adapters.md +66 -2
  63. package/docs/en/spec/maintenance.md +11 -0
  64. package/docs/en/spec/review.md +11 -0
  65. package/docs/zh-TW/guides/configuration.md +77 -0
  66. package/docs/zh-TW/guides/quickstart.md +9 -0
  67. package/docs/zh-TW/guides/security.md +5 -0
  68. package/docs/zh-TW/spec/README.md +9 -0
  69. package/docs/zh-TW/spec/harness-adapters.md +57 -3
  70. package/docs/zh-TW/spec/maintenance.md +11 -1
  71. package/docs/zh-TW/spec/review.md +10 -0
  72. package/package.json +4 -2
  73. package/schemas/config.schema.json +55 -1
  74. package/schemas/manifest.schema.json +7 -2
  75. package/templates/common/AGENTS.block.md +2 -1
  76. package/templates/common/CLAUDE.block.md +2 -1
  77. package/dist/runtime/src/review/claude-runner.js +0 -4
  78. package/dist/runtime/src/review/codex-runner.js +0 -4
@@ -5,11 +5,17 @@ import { parseInstallManifest, PROJECT_MANIFEST_PATH } from "../fs/manifest.js";
5
5
  import { resolveContainedPath } from "../fs/paths.js";
6
6
  import { validateConfig } from "../schema/validate.js";
7
7
  import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
8
+ import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
9
+ import { harnessDescriptor } from "./harness.js";
10
+ import { resolveProfiles } from "./profiles.js";
11
+ import { inspectHarnessSurfaces, inspectHarnessRegistrations } from "./surface-inspection.js";
8
12
  const CONFIG_PATH = ".agent-ops/config.json";
9
13
  const MINIMUM_NODE_VERSION = [22, 14, 0];
10
14
  const MAX_DOCTOR_FILE_BYTES = 1024 * 1024;
11
- function check(id, status, message) {
12
- return { id, status, message };
15
+ function check(id, status, message, code) {
16
+ return code === undefined
17
+ ? { id, status, message }
18
+ : { id, status, message, code };
13
19
  }
14
20
  function parseNodeVersion(version) {
15
21
  const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version);
@@ -80,7 +86,7 @@ async function readContainedText(root, path) {
80
86
  async function checkManifest(root) {
81
87
  try {
82
88
  const manifest = parseInstallManifest(await readContainedText(root, PROJECT_MANIFEST_PATH));
83
- assertSupportedManifestOwnership(manifest);
89
+ assertSupportedManifestOwnership(manifest, root);
84
90
  return {
85
91
  check: check("manifest", "PASS", "Installation manifest is valid."),
86
92
  manifest
@@ -124,7 +130,9 @@ async function checkArtifacts(root, manifest) {
124
130
  for (const artifact of manifest.artifacts) {
125
131
  try {
126
132
  const content = await readContained(root, artifact.path);
127
- if (sha256(content) !== artifact.hash) {
133
+ if (sha256(content) !== artifact.hash ||
134
+ (artifact.id === "opencode-plugin" &&
135
+ !isOpencodeManagedPlugin(content.toString("utf8")))) {
128
136
  failures.push(artifact.path);
129
137
  }
130
138
  }
@@ -141,7 +149,8 @@ async function checkMarkers(root, manifest) {
141
149
  return check("markers", "FAIL", "Managed blocks cannot be verified without a valid manifest.");
142
150
  }
143
151
  const failures = [];
144
- const expectedMarkers = assertSupportedManifestOwnership(manifest);
152
+ const legacyPaths = [];
153
+ const expectedMarkers = assertSupportedManifestOwnership(manifest, root);
145
154
  for (const marker of manifest.markers) {
146
155
  try {
147
156
  const source = await readContainedText(root, marker.path);
@@ -150,15 +159,22 @@ async function checkMarkers(root, manifest) {
150
159
  failures.push(marker.path);
151
160
  continue;
152
161
  }
153
- assertExpectedManagedBlock(source, marker, expected);
162
+ const match = assertExpectedManagedBlock(source, marker, expected);
163
+ if (match === "legacy") {
164
+ legacyPaths.push(marker.path);
165
+ }
154
166
  }
155
167
  catch {
156
168
  failures.push(marker.path);
157
169
  }
158
170
  }
159
- return failures.length === 0
160
- ? check("markers", "PASS", "All managed block markers are intact.")
161
- : check("markers", "FAIL", `Managed block markers failed verification: ${failures.join(", ")}.`);
171
+ if (failures.length > 0) {
172
+ return check("markers", "FAIL", `Managed block markers failed verification: ${failures.join(", ")}.`);
173
+ }
174
+ if (legacyPaths.length > 0) {
175
+ return check("markers", "DEGRADED", `Legacy managed routing blocks need migration: ${legacyPaths.join(", ")}.`);
176
+ }
177
+ return check("markers", "PASS", "All managed block markers are intact.");
162
178
  }
163
179
  async function checkProbe(id, probe) {
164
180
  if (probe === undefined) {
@@ -177,21 +193,115 @@ async function checkProbe(id, probe) {
177
193
  return check(id, "FAIL", "Probe failed.");
178
194
  }
179
195
  }
196
+ function checkLifecycleSummary(manifest, config) {
197
+ if (manifest === undefined || config === undefined) {
198
+ return check("lifecycle-summary", "UNKNOWN", "Lifecycle summary cannot be assessed without a valid manifest and configuration.");
199
+ }
200
+ const hasLifecycleSummary = config.profiles.length > 0 &&
201
+ resolveProfiles(config.profiles).capabilities.includes("lifecycle-summary");
202
+ if (!hasLifecycleSummary) {
203
+ return check("lifecycle-summary", "PASS", "Lifecycle summary has no harness-specific degradation.");
204
+ }
205
+ const registrations = manifest.harness.map((id) => ({
206
+ id,
207
+ registration: harnessDescriptor(id).control.registrations.find((candidate) => candidate.capability === "lifecycle-summary")
208
+ }));
209
+ const missing = registrations
210
+ .filter(({ registration }) => registration === undefined)
211
+ .map(({ id }) => id);
212
+ if (missing.length > 0) {
213
+ return check("lifecycle-summary", "UNKNOWN", `Lifecycle summary registration is missing for ${missing.join(", ")}.`);
214
+ }
215
+ const unsupported = registrations
216
+ .filter(({ registration }) => registration?.support === "unsupported")
217
+ .map(({ id }) => id);
218
+ if (unsupported.length > 0) {
219
+ return check("lifecycle-summary", "UNSUPPORTED", `Lifecycle summary is not dispatched for ${unsupported.join(", ")}; advisory runtime wiring is unavailable.`);
220
+ }
221
+ const degraded = registrations
222
+ .filter(({ registration }) => registration?.support === "degraded")
223
+ .map(({ id }) => id);
224
+ if (degraded.length > 0) {
225
+ return check("lifecycle-summary", "DEGRADED", `Lifecycle summary is degraded for ${degraded.join(", ")}.`);
226
+ }
227
+ const unknown = registrations
228
+ .filter(({ registration }) => registration?.support === "unknown")
229
+ .map(({ id }) => id);
230
+ if (unknown.length > 0) {
231
+ return check("lifecycle-summary", "UNKNOWN", `Lifecycle summary support is unknown for ${unknown.join(", ")}.`);
232
+ }
233
+ return check("lifecycle-summary", "PASS", "Lifecycle summary is reachable for every selected harness.");
234
+ }
235
+ async function checkSurfaceInventory(root, manifest, config) {
236
+ if (manifest === undefined || config === undefined) {
237
+ return {
238
+ check: check("surface-inventory", "UNKNOWN", "Harness surfaces cannot be inventoried without a valid manifest and configuration."),
239
+ surfaces: []
240
+ };
241
+ }
242
+ try {
243
+ const surfaces = await inspectHarnessSurfaces({
244
+ root,
245
+ scope: manifest.scope,
246
+ harness: manifest.harness,
247
+ profiles: config.profiles
248
+ });
249
+ const unknown = surfaces.filter(({ status }) => status === "unknown");
250
+ return {
251
+ check: check("surface-inventory", unknown.length > 0 ? "UNKNOWN" : "PASS", unknown.length > 0
252
+ ? `${unknown.length} harness surface(s) could not be inspected.`
253
+ : "Harness surfaces were inventoried without exposing settings values."),
254
+ surfaces
255
+ };
256
+ }
257
+ catch {
258
+ return {
259
+ check: check("surface-inventory", "UNKNOWN", "Harness surfaces could not be inspected safely."),
260
+ surfaces: []
261
+ };
262
+ }
263
+ }
264
+ async function checkRegistrationDrift(root, manifest, config) {
265
+ if (manifest === undefined || config === undefined) {
266
+ return check("registration-drift", "UNKNOWN", "Hook registration drift cannot be assessed without a valid manifest and configuration.");
267
+ }
268
+ try {
269
+ const statuses = await inspectHarnessRegistrations({
270
+ root,
271
+ manifest,
272
+ config
273
+ });
274
+ const drifted = statuses
275
+ .filter(({ registered }) => !registered)
276
+ .map(({ harness }) => harness);
277
+ return drifted.length === 0
278
+ ? check("registration-drift", "PASS", "Managed hook registrations match the desired capabilities.")
279
+ : check("registration-drift", "FAIL", `Hook registration drift detected for ${drifted.join(", ")}; run agent-ops update.`, "UPDATE_REQUIRED");
280
+ }
281
+ catch {
282
+ return check("registration-drift", "UNKNOWN", "Hook registration drift could not be assessed safely.");
283
+ }
284
+ }
180
285
  export async function doctorInstallation(options) {
181
286
  const manifest = await checkManifest(options.root);
182
287
  const config = await checkConfig(options.root);
288
+ const surfaceInventory = await checkSurfaceInventory(options.root, manifest.manifest, config.config);
183
289
  const checks = [
184
290
  checkNodeVersion(options.nodeVersion ?? process.versions.node),
185
291
  manifest.check,
186
292
  config.check,
187
293
  await checkArtifacts(options.root, manifest.manifest),
188
294
  await checkMarkers(options.root, manifest.manifest),
295
+ surfaceInventory.check,
296
+ await checkRegistrationDrift(options.root, manifest.manifest, config.config),
189
297
  await checkProbe("hook-registration", options.probes?.hookRegistration),
298
+ checkLifecycleSummary(manifest.manifest, config.config),
190
299
  await checkProbe("repository-trust", options.probes?.repositoryTrust),
191
300
  await checkProbe("smoke-availability", options.probes?.smokeAvailability)
192
301
  ];
193
302
  return {
194
303
  checks,
304
+ surfaces: surfaceInventory.surfaces,
195
305
  ...(manifest.manifest === undefined
196
306
  ? {}
197
307
  : { manifest: manifest.manifest }),
@@ -1,8 +1,235 @@
1
+ import { buildClaudeHookSettings, mergeClaudeSettings, stripClaudeManagedHooks } from "../adapters/claude/config.js";
2
+ import { CLAUDE_CAPABILITY_REGISTRATIONS } from "../adapters/claude/events.js";
3
+ import { normalizeClaudeHookInput } from "../adapters/claude/input.js";
4
+ import { claudeHookOutput } from "../adapters/claude/output.js";
5
+ import { claudeSurfaces } from "../adapters/claude/surfaces.js";
6
+ import { buildCodexHookConfig, CODEX_MANAGED_MARKER, mergeCodexHookConfig, stripCodexManagedHooks } from "../adapters/codex/config.js";
7
+ import { CODEX_CAPABILITY_REGISTRATIONS } from "../adapters/codex/events.js";
8
+ import { normalizeCodexHookInput } from "../adapters/codex/input.js";
9
+ import { codexHookOutput } from "../adapters/codex/output.js";
10
+ import { codexSurfaces } from "../adapters/codex/surfaces.js";
11
+ import { buildOpencodePlugin, isOpencodePluginRegistered, opencodePluginTarget } from "../adapters/opencode/config.js";
12
+ import { OPENCODE_CAPABILITY_REGISTRATIONS } from "../adapters/opencode/events.js";
13
+ import { normalizeOpencodeHookInput } from "../adapters/opencode/input.js";
14
+ import { opencodeHookOutput } from "../adapters/opencode/output.js";
15
+ import { opencodeSurfaces } from "../adapters/opencode/surfaces.js";
1
16
  import { AgentOpsError } from "../fs/paths.js";
2
- export const COMMON_AGENTS_BLOCK = "## Loop Engineering\n\nUse `.agent-ops/AGENTS.md` as the canonical Loop Engineering specification for this project.\n";
3
- export const COMMON_CLAUDE_BLOCK = "## Loop Engineering\n\nUse `.agent-ops/CLAUDE.md` as the canonical Loop Engineering specification for this project.\n";
4
- function managedRules(id, context) {
5
- const instructionFile = id === "codex" ? "AGENTS.md" : "CLAUDE.md";
17
+ import { findSurfaceById, findSurfaceByPath, isWritableSurface } from "./surfaces.js";
18
+ export const HARNESS_IDS = [
19
+ "codex",
20
+ "claude",
21
+ "opencode"
22
+ ];
23
+ const CLAUDE_HOOK_MARKER = "--managed-by=agent-ops";
24
+ function isRecord(value) {
25
+ return typeof value === "object" && value !== null && !Array.isArray(value);
26
+ }
27
+ function parseJsonSource(source) {
28
+ if (typeof source !== "string") {
29
+ return source;
30
+ }
31
+ if (source.trim().length === 0) {
32
+ return {};
33
+ }
34
+ try {
35
+ return JSON.parse(source);
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ function jsonHookRegistered(control, source, capabilities) {
42
+ if (control.buildHooks === undefined ||
43
+ control.isManagedHandler === undefined) {
44
+ return false;
45
+ }
46
+ const events = Object.keys(control.buildHooks(capabilities, "probe").hooks);
47
+ if (events.length === 0) {
48
+ return true;
49
+ }
50
+ const parsed = parseJsonSource(source);
51
+ if (!isRecord(parsed) || !isRecord(parsed.hooks)) {
52
+ return false;
53
+ }
54
+ const hooks = parsed.hooks;
55
+ return events.every((event) => {
56
+ const groups = hooks[event];
57
+ return (Array.isArray(groups) &&
58
+ groups.some((group) => isRecord(group) &&
59
+ Array.isArray(group.hooks) &&
60
+ group.hooks.some(control.isManagedHandler)));
61
+ });
62
+ }
63
+ function runtimeFailureResult(capability, registrations) {
64
+ const runtimeFailure = registrations.find((registration) => registration.capability === capability)?.runtimeFailure;
65
+ return {
66
+ action: runtimeFailure === "fail-closed" ? "block" : "continue",
67
+ status: "UNKNOWN",
68
+ code: `${capability.replaceAll("-", "_").toUpperCase()}_UNAVAILABLE`
69
+ };
70
+ }
71
+ function createJsonDescriptor(options) {
72
+ const control = {
73
+ instructionFile: options.instructionFile,
74
+ routing: options.routing,
75
+ hookPath: options.hookPath,
76
+ surfaces: options.surfaces,
77
+ ownSettingsKeys: options.ownSettingsKeys,
78
+ buildHooks: options.buildHooks,
79
+ mergeHooks: options.mergeHooks,
80
+ stripHooks: options.stripHooks,
81
+ isManagedHandler: options.isManagedHandler,
82
+ registrations: options.registrations,
83
+ plan: (context) => planCommonHarnessContribution(options.id, context),
84
+ hookRegistered: (source, capabilities) => jsonHookRegistered(control, source, capabilities)
85
+ };
86
+ return {
87
+ id: options.id,
88
+ control,
89
+ runtime: {
90
+ normalizeInput: options.normalizeInput,
91
+ formatOutput: options.formatOutput,
92
+ formatRuntimeFailure: (event, capability) => options.formatOutput(event, runtimeFailureResult(capability, options.registrations))
93
+ }
94
+ };
95
+ }
96
+ const AGENTS_ROUTING = {
97
+ desired: "## Loop Engineering\n\nLoad `.agent-ops/AGENTS.md` as the agent-ops managed baseline.\nProject-specific instructions in this file remain authoritative.\n",
98
+ legacy: [
99
+ "## Loop Engineering\n\nUse `.agent-ops/AGENTS.md` as the canonical Loop Engineering specification for this project.\n"
100
+ ]
101
+ };
102
+ const CLAUDE_ROUTING = {
103
+ desired: "## Loop Engineering\n\nLoad `.agent-ops/CLAUDE.md` as the agent-ops managed baseline.\nProject-specific instructions in this file remain authoritative.\n",
104
+ legacy: [
105
+ "## Loop Engineering\n\nUse `.agent-ops/CLAUDE.md` as the canonical Loop Engineering specification for this project.\n"
106
+ ]
107
+ };
108
+ const DESCRIPTORS = {
109
+ codex: createJsonDescriptor({
110
+ id: "codex",
111
+ instructionFile: "AGENTS.md",
112
+ routing: AGENTS_ROUTING,
113
+ hookPath: ".codex/hooks.json",
114
+ surfaces: codexSurfaces,
115
+ ownSettingsKeys: ["hooks", "description"],
116
+ buildHooks: (capabilities, runtimePath) => buildCodexHookConfig(capabilities, runtimePath),
117
+ mergeHooks: (existing, managed) => mergeCodexHookConfig(existing, managed),
118
+ stripHooks: (existing) => stripCodexManagedHooks(existing),
119
+ isManagedHandler: (handler) => isRecord(handler) &&
120
+ typeof handler.command === "string" &&
121
+ handler.command.includes(CODEX_MANAGED_MARKER),
122
+ registrations: CODEX_CAPABILITY_REGISTRATIONS,
123
+ normalizeInput: normalizeCodexHookInput,
124
+ formatOutput: (event, result) => {
125
+ const output = codexHookOutput(event, result);
126
+ return { exitCode: 0, stdout: output.stdout, stderr: "" };
127
+ }
128
+ }),
129
+ claude: createJsonDescriptor({
130
+ id: "claude",
131
+ instructionFile: "CLAUDE.md",
132
+ routing: CLAUDE_ROUTING,
133
+ hookPath: ".claude/settings.json",
134
+ surfaces: claudeSurfaces,
135
+ ownSettingsKeys: ["hooks"],
136
+ buildHooks: (capabilities, runtimePath) => buildClaudeHookSettings(capabilities, runtimePath),
137
+ mergeHooks: (existing, managed) => mergeClaudeSettings(existing, managed),
138
+ stripHooks: (existing) => stripClaudeManagedHooks(existing),
139
+ isManagedHandler: (handler) => isRecord(handler) &&
140
+ Array.isArray(handler.args) &&
141
+ handler.args.includes(CLAUDE_HOOK_MARKER),
142
+ registrations: CLAUDE_CAPABILITY_REGISTRATIONS,
143
+ normalizeInput: normalizeClaudeHookInput,
144
+ formatOutput: (event, result) => claudeHookOutput(event, result)
145
+ }),
146
+ opencode: {
147
+ id: "opencode",
148
+ control: {
149
+ instructionFile: "AGENTS.md",
150
+ routing: AGENTS_ROUTING,
151
+ hookPath: opencodePluginTarget("project").path,
152
+ hookPathForScope: (scope, root) => opencodePluginTarget(scope, root).path,
153
+ surfaces: opencodeSurfaces,
154
+ registrations: OPENCODE_CAPABILITY_REGISTRATIONS,
155
+ plan: (context) => planCommonHarnessContribution("opencode", context),
156
+ hookRegistered: (source, capabilities) => isOpencodePluginRegistered(source, capabilities)
157
+ },
158
+ runtime: {
159
+ normalizeInput: normalizeOpencodeHookInput,
160
+ formatOutput: (event, result) => opencodeHookOutput(event, result),
161
+ formatRuntimeFailure: (event, capability) => opencodeHookOutput(event, runtimeFailureResult(capability, OPENCODE_CAPABILITY_REGISTRATIONS))
162
+ }
163
+ }
164
+ };
165
+ export function harnessDescriptor(id) {
166
+ const descriptor = DESCRIPTORS[id];
167
+ if (descriptor === undefined) {
168
+ throw new AgentOpsError("HARNESS_ADAPTER_MISSING", `Missing harness adapter: ${id}`);
169
+ }
170
+ return descriptor;
171
+ }
172
+ export function harnessHookPath(id, scope, root) {
173
+ const descriptor = harnessDescriptor(id);
174
+ return descriptor.control.hookPathForScope?.(scope, root) ??
175
+ descriptor.control.hookPath;
176
+ }
177
+ export function harnessSurfaces(id, scope, root) {
178
+ return harnessDescriptor(id).control.surfaces(scope, root);
179
+ }
180
+ export function harnessSurfaceById(id, scope, surfaceId, root) {
181
+ return harnessSurfaces(id, scope, root).find((surface) => surface.id === surfaceId);
182
+ }
183
+ export function harnessSurfaceByPath(id, scope, path, root) {
184
+ return harnessSurfaces(id, scope, root).find((surface) => surface.path === path);
185
+ }
186
+ export function selectHarnessHookSurface(options) {
187
+ const descriptor = harnessDescriptor(options.harness);
188
+ if (descriptor.control.buildHooks === undefined) {
189
+ throw new AgentOpsError("HOOK_TARGET_UNSUPPORTED", `Harness hook targets are not supported for ${options.harness}.`);
190
+ }
191
+ const surfaces = harnessSurfaces(options.harness, options.scope, options.root);
192
+ const selected = options.surfaceId === undefined
193
+ ? options.persistedPath === undefined
194
+ ? surfaces.find((surface) => surface.access === "managed-default" &&
195
+ surface.representation === "json")
196
+ : findSurfaceByPath(surfaces, options.persistedPath)
197
+ : findSurfaceById(surfaces, options.surfaceId);
198
+ if (selected === undefined ||
199
+ selected.scope !== options.scope ||
200
+ !isWritableSurface(selected) ||
201
+ selected.representation !== "json") {
202
+ const requested = options.surfaceId ?? options.persistedPath ?? "default surface";
203
+ throw new AgentOpsError("HOOK_TARGET_INVALID", `Harness hook target is not a writable ${options.scope} surface: ${options.harness}=${requested}.`);
204
+ }
205
+ return selected;
206
+ }
207
+ export function isHarnessId(value) {
208
+ return Object.hasOwn(DESCRIPTORS, value);
209
+ }
210
+ /**
211
+ * `both` predates opencode and remains an input alias. `all` follows the
212
+ * registry and selects every registered harness.
213
+ */
214
+ const HARNESS_ALIASES = {
215
+ all: HARNESS_IDS,
216
+ both: ["codex", "claude"]
217
+ };
218
+ export function resolveHarnessSelection(value) {
219
+ const alias = HARNESS_ALIASES[value.trim()];
220
+ if (alias !== undefined) {
221
+ return [...alias];
222
+ }
223
+ const ids = value.split(",").map((entry) => entry.trim());
224
+ return ids.length > 0 &&
225
+ ids.every(isHarnessId) &&
226
+ new Set(ids).size === ids.length
227
+ ? ids
228
+ : null;
229
+ }
230
+ export const COMMON_AGENTS_BLOCK = DESCRIPTORS.codex.control.routing.desired;
231
+ export const COMMON_CLAUDE_BLOCK = DESCRIPTORS.claude.control.routing.desired;
232
+ function managedRules(descriptor, context) {
6
233
  const lines = [
7
234
  "# Loop Engineering",
8
235
  "",
@@ -22,44 +249,75 @@ function managedRules(id, context) {
22
249
  lines.push("Advisory lifecycle summaries and local logs are informational. Advisory", "failures must remain fail-open and cannot become verification evidence.", "");
23
250
  }
24
251
  if (context.capabilities.includes("command-policy")) {
25
- lines.push("Command policy guards high-confidence unsafe actions. Optional Stop", "verification never marks a task complete by itself.", "");
252
+ lines.push("Command policy guards high-confidence unsafe actions. Explicitly enabled", "Stop verification is report-only and never marks a task complete by itself.", "");
26
253
  }
27
- lines.push(`This file is routed from the active ${instructionFile}.`, "");
254
+ lines.push(`This file is routed from the active ${descriptor.control.instructionFile}.`, "");
28
255
  return lines.join("\n");
29
256
  }
257
+ function instructionStem(instructionFile) {
258
+ return instructionFile.replace(/\.md$/iu, "").toLowerCase();
259
+ }
260
+ export function rulesArtifactId(descriptor) {
261
+ return `${instructionStem(descriptor.control.instructionFile)}-rules`;
262
+ }
263
+ export function routingBlockId(id, scope, descriptor = harnessDescriptor(id)) {
264
+ return scope === "project" && descriptor.control.instructionFile === "AGENTS.md"
265
+ ? "agents-routing"
266
+ : `${id}-routing`;
267
+ }
268
+ function dedupeByPath(entries) {
269
+ const kept = [];
270
+ for (const entry of entries) {
271
+ const duplicate = kept.some((existing) => existing.path === entry.path && existing.content === entry.content);
272
+ if (!duplicate) {
273
+ kept.push(entry);
274
+ }
275
+ }
276
+ return kept;
277
+ }
278
+ async function planCommonHarnessContribution(id, context) {
279
+ const descriptor = harnessDescriptor(id);
280
+ const artifacts = [
281
+ {
282
+ id: rulesArtifactId(descriptor),
283
+ path: `.agent-ops/${descriptor.control.instructionFile}`,
284
+ content: managedRules(descriptor, context)
285
+ }
286
+ ];
287
+ if (id === "opencode" && context.runtimePath !== undefined) {
288
+ const plugin = buildOpencodePlugin(context.capabilities, context.runtimePath);
289
+ if (plugin !== null) {
290
+ artifacts.push({
291
+ id: "opencode-plugin",
292
+ path: context.opencodePluginPath ??
293
+ opencodePluginTarget(context.scope, context.root).path,
294
+ content: plugin
295
+ });
296
+ }
297
+ }
298
+ return {
299
+ artifacts,
300
+ blocks: [
301
+ {
302
+ id: routingBlockId(id, context.scope, descriptor),
303
+ path: context.scope === "project"
304
+ ? descriptor.control.instructionFile
305
+ : `.${id}/${descriptor.control.instructionFile}`,
306
+ version: 1,
307
+ content: descriptor.control.routing.desired
308
+ }
309
+ ]
310
+ };
311
+ }
30
312
  export function commonHarnessAdapters() {
31
- return ["codex", "claude"].map((id) => {
32
- const instructionFile = id === "codex" ? "AGENTS.md" : "CLAUDE.md";
33
- const blockContent = id === "codex" ? COMMON_AGENTS_BLOCK : COMMON_CLAUDE_BLOCK;
313
+ return HARNESS_IDS.map((id) => {
314
+ const descriptor = harnessDescriptor(id);
34
315
  return {
35
316
  id,
36
- async plan(context) {
37
- return {
38
- artifacts: [
39
- {
40
- id: `${id}-rules`,
41
- path: `.agent-ops/${instructionFile}`,
42
- content: managedRules(id, context)
43
- }
44
- ],
45
- blocks: [
46
- {
47
- id: `${id}-routing`,
48
- path: context.scope === "project"
49
- ? instructionFile
50
- : `.${id}/${instructionFile}`,
51
- version: 1,
52
- content: blockContent
53
- }
54
- ]
55
- };
56
- }
317
+ plan: (context) => descriptor.control.plan(context)
57
318
  };
58
319
  });
59
320
  }
60
- export function requestedHarnessIds(harness) {
61
- return harness === "both" ? ["codex", "claude"] : [harness];
62
- }
63
321
  function selectAdapter(id, adapters) {
64
322
  const matches = adapters.filter((adapter) => adapter.id === id);
65
323
  if (matches.length === 0) {
@@ -75,7 +333,7 @@ function selectAdapter(id, adapters) {
75
333
  return adapter;
76
334
  }
77
335
  export async function planHarnessContributions(harness, context, adapters) {
78
- const selectedAdapters = requestedHarnessIds(harness).map((id) => selectAdapter(id, adapters));
336
+ const selectedAdapters = harness.map((id) => selectAdapter(id, adapters));
79
337
  const artifacts = [];
80
338
  const blocks = [];
81
339
  for (const adapter of selectedAdapters) {
@@ -83,5 +341,8 @@ export async function planHarnessContributions(harness, context, adapters) {
83
341
  artifacts.push(...contribution.artifacts);
84
342
  blocks.push(...contribution.blocks);
85
343
  }
86
- return { artifacts, blocks };
344
+ return {
345
+ artifacts: dedupeByPath(artifacts),
346
+ blocks: dedupeByPath(blocks)
347
+ };
87
348
  }
@@ -1,6 +1,5 @@
1
- import { buildClaudeHookSettings, mergeClaudeSettings, stripClaudeManagedHooks } from "../adapters/claude/config.js";
2
- import { buildCodexHookConfig, mergeCodexHookConfig, stripCodexManagedHooks } from "../adapters/codex/config.js";
3
1
  import { AgentOpsError } from "../fs/paths.js";
2
+ import { harnessDescriptor, harnessHookPath } from "./harness.js";
4
3
  export const CLAUDE_HOOK_PATH = ".claude/settings.json";
5
4
  export const CODEX_HOOK_PATH = ".codex/hooks.json";
6
5
  function format(value) {
@@ -17,31 +16,34 @@ function parseSettings(path, source) {
17
16
  throw new AgentOpsError("HOOK_SETTINGS_INVALID_JSON", `Hook settings are not valid JSON: ${path}`, { cause: error });
18
17
  }
19
18
  }
20
- export function hookRegistrationPath(harness, scope) {
19
+ export function hookRegistrationPath(harness, scope, root) {
21
20
  // ponytail: user scope resolves against AGENT_OPS_HOME, so the same
22
- // relative path serves both scopes.
23
- void scope;
24
- return harness === "claude" ? CLAUDE_HOOK_PATH : CODEX_HOOK_PATH;
21
+ // root can resolve a scope-specific harness path.
22
+ return harnessHookPath(harness, scope, root);
25
23
  }
26
24
  /**
27
25
  * Builds the merged settings file for one harness, preserving foreign hooks.
28
26
  * Returns null when the selected capabilities register no events at all.
29
27
  */
30
28
  export function planHookRegistration(options) {
31
- const path = hookRegistrationPath(options.harness, options.scope);
32
- const managed = options.harness === "claude"
33
- ? buildClaudeHookSettings(options.capabilities, options.runtimePath)
34
- : buildCodexHookConfig(options.capabilities);
29
+ const descriptor = harnessDescriptor(options.harness);
30
+ if (descriptor.control.buildHooks === undefined ||
31
+ descriptor.control.mergeHooks === undefined) {
32
+ // File-backed adapters (currently opencode) track their plugin as a
33
+ // managed artifact rather than as a ManagedHookRecord.
34
+ return null;
35
+ }
36
+ const path = options.path ?? hookRegistrationPath(options.harness, options.scope);
37
+ const managed = descriptor.control.buildHooks(options.capabilities, options.runtimePath);
35
38
  const events = Object.keys(managed.hooks);
36
39
  if (events.length === 0) {
37
40
  return null;
38
41
  }
39
42
  const existing = parseSettings(path, options.currentSource);
40
- const merged = options.harness === "claude"
41
- ? mergeClaudeSettings(existing, managed)
42
- : mergeCodexHookConfig(existing, managed);
43
+ const merged = descriptor.control.mergeHooks(existing, managed);
43
44
  return {
44
45
  content: format(merged),
46
+ disclosure: "opaque",
45
47
  record: {
46
48
  id: `${options.harness}-hooks`,
47
49
  path,
@@ -52,7 +54,7 @@ export function planHookRegistration(options) {
52
54
  };
53
55
  }
54
56
  function onlyManagedRemains(harness, value) {
55
- const ownKeys = new Set(harness === "codex" ? ["hooks", "description"] : ["hooks"]);
57
+ const ownKeys = new Set(harnessDescriptor(harness).control.ownSettingsKeys ?? []);
56
58
  const hooks = value.hooks;
57
59
  return (Object.keys(value).every((key) => ownKeys.has(key)) &&
58
60
  typeof hooks === "object" &&
@@ -64,12 +66,15 @@ function onlyManagedRemains(harness, value) {
64
66
  * when nothing but agent-ops content is left, so foreign settings survive.
65
67
  */
66
68
  export function planHookRemoval(record, currentSource) {
69
+ const descriptor = harnessDescriptor(record.harness);
70
+ if (descriptor.control.stripHooks === undefined) {
71
+ throw new AgentOpsError("HOOK_REMOVAL_UNSUPPORTED", `Harness hook records are not supported for ${record.harness}.`);
72
+ }
67
73
  const existing = parseSettings(record.path, currentSource);
68
- const stripped = record.harness === "claude"
69
- ? stripClaudeManagedHooks(existing)
70
- : stripCodexManagedHooks(existing);
74
+ const stripped = descriptor.control.stripHooks(existing);
71
75
  return {
72
76
  path: record.path,
77
+ disclosure: "opaque",
73
78
  content: onlyManagedRemains(record.harness, stripped)
74
79
  ? null
75
80
  : format(stripped)