@kylecheng3146/agent-ops 0.1.3 → 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 (80) 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 +8 -8
  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 +29 -7
  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/ui.js +242 -1
  16. package/dist/packages/cli/src/version.js +1 -1
  17. package/dist/packages/cli/src/wizard.js +68 -2
  18. package/dist/runtime/src/adapters/claude/config.js +0 -8
  19. package/dist/runtime/src/adapters/claude/events.js +26 -0
  20. package/dist/runtime/src/adapters/claude/output.js +6 -6
  21. package/dist/runtime/src/adapters/claude/surfaces.js +70 -0
  22. package/dist/runtime/src/adapters/codex/config.js +29 -11
  23. package/dist/runtime/src/adapters/codex/events.js +26 -0
  24. package/dist/runtime/src/adapters/codex/output.js +10 -0
  25. package/dist/runtime/src/adapters/codex/surfaces.js +12 -0
  26. package/dist/runtime/src/adapters/opencode/config.js +170 -0
  27. package/dist/runtime/src/adapters/opencode/events.js +49 -0
  28. package/dist/runtime/src/adapters/opencode/input.js +32 -0
  29. package/dist/runtime/src/adapters/opencode/output.js +23 -0
  30. package/dist/runtime/src/adapters/opencode/surfaces.js +23 -0
  31. package/dist/runtime/src/config/explain.js +7 -0
  32. package/dist/runtime/src/config/hash.js +24 -0
  33. package/dist/runtime/src/config/merge.js +6 -2
  34. package/dist/runtime/src/config/migrate.js +19 -4
  35. package/dist/runtime/src/contracts.js +10 -1
  36. package/dist/runtime/src/fs/manifest.js +32 -1
  37. package/dist/runtime/src/fs/transaction.js +14 -2
  38. package/dist/runtime/src/hooks/advisory.js +16 -0
  39. package/dist/runtime/src/hooks/stop-service.js +70 -0
  40. package/dist/runtime/src/hooks/stop-verify.js +4 -1
  41. package/dist/runtime/src/install/doctor.js +119 -9
  42. package/dist/runtime/src/install/harness.js +296 -35
  43. package/dist/runtime/src/install/hooks.js +22 -17
  44. package/dist/runtime/src/install/ownership.js +110 -34
  45. package/dist/runtime/src/install/plan.js +207 -28
  46. package/dist/runtime/src/install/probes.js +9 -43
  47. package/dist/runtime/src/install/profiles.js +8 -1
  48. package/dist/runtime/src/install/surface-inspection.js +296 -0
  49. package/dist/runtime/src/install/surfaces.js +11 -0
  50. package/dist/runtime/src/install/uninstall.js +10 -3
  51. package/dist/runtime/src/install/update.js +8 -2
  52. package/dist/runtime/src/schema/validate.js +37 -18
  53. package/dist/runtime/src/task/service.js +3 -3
  54. package/dist/runtime/src/task/store.js +12 -3
  55. package/dist/runtime/src/verify/command-executor.js +113 -0
  56. package/dist/runtime/src/verify/evidence.js +4 -24
  57. package/dist/runtime/src/verify/service.js +20 -92
  58. package/dist/runtime/src/verify/spawn.js +6 -1
  59. package/docs/en/guides/configuration.md +83 -0
  60. package/docs/en/guides/quickstart.md +9 -0
  61. package/docs/en/guides/security.md +5 -0
  62. package/docs/en/spec/README.md +9 -0
  63. package/docs/en/spec/harness-adapters.md +66 -2
  64. package/docs/en/spec/maintenance.md +11 -0
  65. package/docs/en/spec/review.md +11 -0
  66. package/docs/zh-TW/guides/configuration.md +77 -0
  67. package/docs/zh-TW/guides/quickstart.md +9 -0
  68. package/docs/zh-TW/guides/security.md +5 -0
  69. package/docs/zh-TW/spec/README.md +9 -0
  70. package/docs/zh-TW/spec/harness-adapters.md +57 -3
  71. package/docs/zh-TW/spec/maintenance.md +11 -1
  72. package/docs/zh-TW/spec/review.md +10 -0
  73. package/package.json +8 -4
  74. package/postinstall.cjs +102 -0
  75. package/schemas/config.schema.json +55 -1
  76. package/schemas/manifest.schema.json +7 -2
  77. package/templates/common/AGENTS.block.md +2 -1
  78. package/templates/common/CLAUDE.block.md +2 -1
  79. package/dist/runtime/src/review/claude-runner.js +0 -4
  80. package/dist/runtime/src/review/codex-runner.js +0 -4
@@ -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)
@@ -1,27 +1,24 @@
1
1
  import { applyManagedBlock, managedBlockMarkers } from "../fs/managed-block.js";
2
2
  import { AgentOpsError } from "../fs/paths.js";
3
- import { COMMON_AGENTS_BLOCK, COMMON_CLAUDE_BLOCK } from "./harness.js";
4
- import { hookRegistrationPath } from "./hooks.js";
5
- function selectedHarnesses(manifest) {
6
- return manifest.harness === "both"
7
- ? ["codex", "claude"]
8
- : [manifest.harness];
9
- }
10
- function expectedMarker(manifest, id) {
11
- const isCodex = id === "codex";
12
- const markerId = `${id}-routing`;
3
+ import { harnessDescriptor, harnessHookPath, routingBlockId, selectHarnessHookSurface, rulesArtifactId } from "./harness.js";
4
+ import { isOpencodePluginPath } from "../adapters/opencode/config.js";
5
+ function expectedMarker(manifest, id, markerId) {
6
+ const descriptor = harnessDescriptor(id);
13
7
  const markers = managedBlockMarkers(markerId, 1);
14
- const instructionFile = isCodex ? "AGENTS.md" : "CLAUDE.md";
15
8
  return {
16
9
  id: markerId,
17
10
  path: manifest.scope === "project"
18
- ? instructionFile
19
- : `.${id}/${instructionFile}`,
11
+ ? descriptor.control.instructionFile
12
+ : `.${id}/${descriptor.control.instructionFile}`,
20
13
  startMarker: markers.start,
21
14
  endMarker: markers.end,
22
- content: isCodex ? COMMON_AGENTS_BLOCK : COMMON_CLAUDE_BLOCK
15
+ content: descriptor.control.routing.desired,
16
+ legacyContent: descriptor.control.routing.legacy
23
17
  };
24
18
  }
19
+ function pathKey(path) {
20
+ return path.toLowerCase();
21
+ }
25
22
  function manifestOwnershipError() {
26
23
  return new AgentOpsError("MANIFEST_OWNERSHIP_INVALID", "The manifest does not match a supported managed installation shape.");
27
24
  }
@@ -29,37 +26,109 @@ function manifestOwnershipError() {
29
26
  * Hook records are optional: installations without hook capabilities, and
30
27
  * manifests written before hook registration existed, carry none.
31
28
  */
32
- function assertSupportedHookRecords(manifest, harnesses) {
29
+ function assertSupportedHookRecords(manifest, harnesses, root) {
33
30
  const selected = new Set(harnesses);
34
31
  const seen = new Set();
35
32
  for (const hook of manifest.hooks ?? []) {
36
33
  if (!selected.has(hook.harness) ||
37
34
  seen.has(hook.harness) ||
35
+ harnessDescriptor(hook.harness).control.buildHooks === undefined ||
38
36
  hook.id !== `${hook.harness}-hooks` ||
39
- hook.path !== hookRegistrationPath(hook.harness, manifest.scope) ||
40
37
  hook.events.length === 0) {
41
38
  throw manifestOwnershipError();
42
39
  }
40
+ try {
41
+ selectHarnessHookSurface({
42
+ harness: hook.harness,
43
+ scope: manifest.scope,
44
+ root,
45
+ persistedPath: hook.path
46
+ });
47
+ }
48
+ catch {
49
+ throw manifestOwnershipError();
50
+ }
43
51
  seen.add(hook.harness);
44
52
  }
45
53
  }
46
- export function assertSupportedManifestOwnership(manifest) {
47
- const harnesses = selectedHarnesses(manifest);
48
- const expectedArtifacts = new Map([
49
- ["config", ".agent-ops/config.json"]
54
+ export function assertSupportedManifestOwnership(manifest, root) {
55
+ const harnesses = manifest.harness;
56
+ const expectedArtifactPaths = new Map([
57
+ [
58
+ pathKey(".agent-ops/config.json"),
59
+ { path: ".agent-ops/config.json", ids: new Set(["config"]) }
60
+ ]
61
+ ]);
62
+ const requiredArtifactPaths = new Set([
63
+ pathKey(".agent-ops/config.json")
50
64
  ]);
51
65
  const expectedMarkers = new Map();
66
+ const expectedMarkerPaths = new Set();
67
+ const recordedOpencodePluginPath = manifest.artifacts.find(({ id }) => id === "opencode-plugin")?.path;
68
+ if (recordedOpencodePluginPath !== undefined &&
69
+ (!harnesses.includes("opencode") ||
70
+ !isOpencodePluginPath(manifest.scope, recordedOpencodePluginPath))) {
71
+ throw manifestOwnershipError();
72
+ }
52
73
  for (const id of harnesses) {
53
- expectedArtifacts.set(`${id}-rules`, `.agent-ops/${id === "codex" ? "AGENTS.md" : "CLAUDE.md"}`);
54
- const marker = expectedMarker(manifest, id);
55
- expectedMarkers.set(marker.id, marker);
74
+ const descriptor = harnessDescriptor(id);
75
+ const artifactPath = `.agent-ops/${descriptor.control.instructionFile}`;
76
+ const artifactKey = pathKey(artifactPath);
77
+ const artifactEntry = expectedArtifactPaths.get(artifactKey);
78
+ const artifactIds = artifactEntry?.ids ?? new Set();
79
+ artifactIds.add(rulesArtifactId(descriptor));
80
+ // Accept the PR 2 per-harness spelling while update rewrites it to the
81
+ // path-derived ID. This keeps already-installed manifests removable.
82
+ artifactIds.add(`${id}-rules`);
83
+ expectedArtifactPaths.set(artifactKey, {
84
+ path: artifactEntry?.path ?? artifactPath,
85
+ ids: artifactIds
86
+ });
87
+ requiredArtifactPaths.add(artifactKey);
88
+ const markerPath = manifest.scope === "project"
89
+ ? descriptor.control.instructionFile
90
+ : `.${id}/${descriptor.control.instructionFile}`;
91
+ const markerKey = pathKey(markerPath);
92
+ expectedMarkerPaths.add(markerKey);
93
+ const currentId = routingBlockId(id, manifest.scope, descriptor);
94
+ const markerIds = new Set([currentId, `${id}-routing`]);
95
+ for (const markerId of markerIds) {
96
+ expectedMarkers.set(markerId, expectedMarker(manifest, id, markerId));
97
+ }
98
+ if (id === "opencode") {
99
+ const pluginPath = recordedOpencodePluginPath ?? harnessHookPath(id, manifest.scope, root);
100
+ const pluginKey = pathKey(pluginPath);
101
+ const pluginEntry = expectedArtifactPaths.get(pluginKey);
102
+ if (pluginEntry !== undefined && pluginEntry.path !== pluginPath) {
103
+ throw manifestOwnershipError();
104
+ }
105
+ const pluginIds = pluginEntry?.ids ?? new Set();
106
+ pluginIds.add("opencode-plugin");
107
+ expectedArtifactPaths.set(pluginKey, {
108
+ path: pluginPath,
109
+ ids: pluginIds
110
+ });
111
+ }
56
112
  }
57
- if (manifest.artifacts.length !== expectedArtifacts.size ||
58
- manifest.markers.length !== expectedMarkers.size) {
113
+ const opencodePluginPath = harnesses.includes("opencode")
114
+ ? recordedOpencodePluginPath ??
115
+ harnessHookPath("opencode", manifest.scope, root)
116
+ : null;
117
+ const optionalArtifactCount = opencodePluginPath !== null &&
118
+ expectedArtifactPaths.has(pathKey(opencodePluginPath))
119
+ ? 1
120
+ : 0;
121
+ if ((manifest.artifacts.length !== requiredArtifactPaths.size &&
122
+ manifest.artifacts.length !== requiredArtifactPaths.size +
123
+ optionalArtifactCount) ||
124
+ manifest.markers.length !== expectedMarkerPaths.size) {
59
125
  throw manifestOwnershipError();
60
126
  }
61
127
  for (const artifact of manifest.artifacts) {
62
- if (expectedArtifacts.get(artifact.id) !== artifact.path) {
128
+ const expected = expectedArtifactPaths.get(pathKey(artifact.path));
129
+ if (expected === undefined ||
130
+ expected.path !== artifact.path ||
131
+ !expected.ids.has(artifact.id)) {
63
132
  throw manifestOwnershipError();
64
133
  }
65
134
  }
@@ -72,7 +141,7 @@ export function assertSupportedManifestOwnership(manifest) {
72
141
  throw manifestOwnershipError();
73
142
  }
74
143
  }
75
- assertSupportedHookRecords(manifest, harnesses);
144
+ assertSupportedHookRecords(manifest, harnesses, root);
76
145
  return expectedMarkers;
77
146
  }
78
147
  function exactMarkerCount(source, marker) {
@@ -92,13 +161,20 @@ export function assertExpectedManagedBlock(source, marker, expected) {
92
161
  startIndex >= endIndex) {
93
162
  throw new AgentOpsError("MANAGED_BLOCK_CHANGED", `Managed block boundaries changed after installation: ${marker.path}`);
94
163
  }
95
- const expectedBlock = applyManagedBlock("", {
96
- id: expected.id,
97
- version: 1,
98
- content: expected.content
99
- }).replace(/\n$/u, "");
100
164
  const currentBlock = source.slice(startIndex, endIndex + marker.endMarker.length);
101
- if (currentBlock !== expectedBlock) {
102
- throw new AgentOpsError("MANAGED_BLOCK_CHANGED", `Managed block content changed after installation: ${marker.path}`);
165
+ const candidates = [
166
+ ["desired", expected.content],
167
+ ...expected.legacyContent.map((content) => ["legacy", content])
168
+ ];
169
+ for (const [kind, content] of candidates) {
170
+ const expectedBlock = applyManagedBlock("", {
171
+ id: expected.id,
172
+ version: 1,
173
+ content
174
+ }).replace(/\n$/u, "");
175
+ if (currentBlock === expectedBlock) {
176
+ return kind;
177
+ }
103
178
  }
179
+ throw new AgentOpsError("MANAGED_BLOCK_CHANGED", `Managed block content changed after installation: ${marker.path}`);
104
180
  }