@kylecheng3146/agent-ops 0.1.4 → 0.1.6

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 (84) hide show
  1. package/README.md +164 -13
  2. package/dist/packages/cli/src/args.js +40 -7
  3. package/dist/packages/cli/src/bin.js +35 -11
  4. package/dist/packages/cli/src/cli.js +7 -2
  5. package/dist/packages/cli/src/codex-loop-process.js +70 -0
  6. package/dist/packages/cli/src/commands/doctor.js +31 -9
  7. package/dist/packages/cli/src/commands/hook.js +33 -16
  8. package/dist/packages/cli/src/commands/init.js +9 -5
  9. package/dist/packages/cli/src/commands/review.js +5 -1
  10. package/dist/packages/cli/src/commands/uninstall.js +6 -5
  11. package/dist/packages/cli/src/commands/update.js +16 -5
  12. package/dist/packages/cli/src/context.js +69 -3
  13. package/dist/packages/cli/src/hook-process.js +231 -16
  14. package/dist/packages/cli/src/loop-entry.js +8 -0
  15. package/dist/packages/cli/src/plan-output.js +9 -4
  16. package/dist/packages/cli/src/public-plan.js +62 -0
  17. package/dist/packages/cli/src/version.js +1 -1
  18. package/dist/packages/cli/src/wizard.js +36 -15
  19. package/dist/runtime/src/adapters/claude/config.js +57 -19
  20. package/dist/runtime/src/adapters/claude/events.js +33 -0
  21. package/dist/runtime/src/adapters/claude/output.js +8 -7
  22. package/dist/runtime/src/adapters/claude/surfaces.js +70 -0
  23. package/dist/runtime/src/adapters/codex/config.js +66 -13
  24. package/dist/runtime/src/adapters/codex/events.js +33 -0
  25. package/dist/runtime/src/adapters/codex/output.js +10 -0
  26. package/dist/runtime/src/adapters/codex/surfaces.js +12 -0
  27. package/dist/runtime/src/adapters/opencode/config.js +170 -0
  28. package/dist/runtime/src/adapters/opencode/events.js +49 -0
  29. package/dist/runtime/src/adapters/opencode/input.js +32 -0
  30. package/dist/runtime/src/adapters/opencode/output.js +23 -0
  31. package/dist/runtime/src/adapters/opencode/surfaces.js +23 -0
  32. package/dist/runtime/src/config/explain.js +7 -0
  33. package/dist/runtime/src/config/hash.js +24 -0
  34. package/dist/runtime/src/config/merge.js +6 -2
  35. package/dist/runtime/src/config/migrate.js +19 -4
  36. package/dist/runtime/src/contracts.js +10 -1
  37. package/dist/runtime/src/fs/managed-block.js +35 -18
  38. package/dist/runtime/src/fs/manifest.js +32 -1
  39. package/dist/runtime/src/fs/transaction.js +14 -2
  40. package/dist/runtime/src/hooks/advisory.js +16 -0
  41. package/dist/runtime/src/hooks/codex-loop.js +439 -0
  42. package/dist/runtime/src/hooks/stop-service.js +70 -0
  43. package/dist/runtime/src/hooks/stop-verify.js +4 -1
  44. package/dist/runtime/src/install/codex-loop.js +139 -0
  45. package/dist/runtime/src/install/doctor.js +182 -14
  46. package/dist/runtime/src/install/harness.js +294 -35
  47. package/dist/runtime/src/install/hooks.js +22 -17
  48. package/dist/runtime/src/install/ownership.js +146 -35
  49. package/dist/runtime/src/install/plan.js +276 -31
  50. package/dist/runtime/src/install/probes.js +9 -43
  51. package/dist/runtime/src/install/profiles.js +12 -3
  52. package/dist/runtime/src/install/surface-inspection.js +296 -0
  53. package/dist/runtime/src/install/surfaces.js +11 -0
  54. package/dist/runtime/src/install/uninstall.js +11 -4
  55. package/dist/runtime/src/install/update.js +13 -3
  56. package/dist/runtime/src/logging/local-log.js +25 -0
  57. package/dist/runtime/src/schema/validate.js +45 -19
  58. package/dist/runtime/src/task/service.js +3 -3
  59. package/dist/runtime/src/task/store.js +12 -3
  60. package/dist/runtime/src/verify/command-executor.js +113 -0
  61. package/dist/runtime/src/verify/evidence.js +4 -24
  62. package/dist/runtime/src/verify/service.js +20 -92
  63. package/dist/runtime/src/verify/spawn.js +6 -1
  64. package/docs/en/guides/configuration.md +159 -0
  65. package/docs/en/guides/quickstart.md +9 -0
  66. package/docs/en/guides/security.md +5 -0
  67. package/docs/en/spec/README.md +9 -0
  68. package/docs/en/spec/harness-adapters.md +104 -2
  69. package/docs/en/spec/maintenance.md +11 -0
  70. package/docs/en/spec/review.md +11 -0
  71. package/docs/zh-TW/guides/configuration.md +145 -0
  72. package/docs/zh-TW/guides/quickstart.md +9 -0
  73. package/docs/zh-TW/guides/security.md +5 -0
  74. package/docs/zh-TW/spec/README.md +9 -0
  75. package/docs/zh-TW/spec/harness-adapters.md +89 -3
  76. package/docs/zh-TW/spec/maintenance.md +11 -1
  77. package/docs/zh-TW/spec/review.md +10 -0
  78. package/package.json +4 -2
  79. package/schemas/config.schema.json +56 -2
  80. package/schemas/manifest.schema.json +19 -3
  81. package/templates/common/AGENTS.block.md +2 -1
  82. package/templates/common/CLAUDE.block.md +2 -1
  83. package/dist/runtime/src/review/claude-runner.js +0 -4
  84. package/dist/runtime/src/review/codex-runner.js +0 -4
@@ -1,13 +1,16 @@
1
1
  import { lstat, readFile } from "node:fs/promises";
2
- import { SCHEMA_VERSION } from "../contracts.js";
2
+ import { CONFIG_SCHEMA_VERSION, MANIFEST_SCHEMA_VERSION } from "../contracts.js";
3
3
  import { sha256 } from "../fs/hash.js";
4
- import { applyManagedBlock, managedBlockMarkers } from "../fs/managed-block.js";
4
+ import { applyManagedBlock, managedBlockMarkers, removeManagedBlock } from "../fs/managed-block.js";
5
5
  import { formatInstallManifest, parseInstallManifest } from "../fs/manifest.js";
6
6
  import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
7
7
  import { validateConfig } from "../schema/validate.js";
8
- import { planHarnessContributions, requestedHarnessIds } from "./harness.js";
9
- import { hookRegistrationPath, planHookRegistration } from "./hooks.js";
10
- import { resolveProfiles } from "./profiles.js";
8
+ import { planHarnessContributions, harnessDescriptor, selectHarnessHookSurface } from "./harness.js";
9
+ import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
10
+ import { codexHooksExplicitlyDisabled, loopLauncherArtifactId, loopSeeds, planLoopContribution, selectedLoopHarnesses } from "./codex-loop.js";
11
+ import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
12
+ import { planHookRegistration, planHookRemoval } from "./hooks.js";
13
+ import { resolveCapabilities, resolveProfiles } from "./profiles.js";
11
14
  const CONFIG_PATH = ".agent-ops/config.json";
12
15
  const MANIFEST_PATH = ".agent-ops/manifest.json";
13
16
  const MAX_PLANNED_FILE_BYTES = 1024 * 1024;
@@ -39,9 +42,14 @@ async function readCurrentFile(root, path) {
39
42
  }
40
43
  function formatConfig(profiles, existing) {
41
44
  return `${JSON.stringify({
42
- schemaVersion: SCHEMA_VERSION,
45
+ schemaVersion: CONFIG_SCHEMA_VERSION,
43
46
  profiles,
44
47
  verification: existing?.verification ?? { commands: [] },
48
+ features: existing?.features ?? {
49
+ stopVerification: {
50
+ enabled: false
51
+ }
52
+ },
45
53
  pathMappings: existing?.pathMappings ?? [],
46
54
  securityExceptions: existing?.securityExceptions ?? []
47
55
  }, null, 2)}\n`;
@@ -109,7 +117,7 @@ function assertUniqueContributions(artifacts, blocks) {
109
117
  }
110
118
  const markerBoundaries = new Set();
111
119
  for (const block of blocks) {
112
- const markers = managedBlockMarkers(block.id, block.version);
120
+ const markers = managedBlockMarkers(block.id, block.version, block.markerStyle);
113
121
  const key = pathKey(block.path);
114
122
  if (ids.has(block.id) ||
115
123
  artifactPaths.has(key) ||
@@ -131,9 +139,12 @@ async function readExistingManifest(root) {
131
139
  hash: current.hash
132
140
  };
133
141
  }
134
- function assertCompatibleManifest(existing, scope, harness) {
142
+ function assertCompatibleManifest(existing, scope, harness, allowHarnessChange) {
143
+ const harnessChanged = existing !== null &&
144
+ (existing.harness.length !== harness.length ||
145
+ !existing.harness.every((id) => harness.includes(id)));
135
146
  if (existing !== null &&
136
- (existing.scope !== scope || existing.harness !== harness)) {
147
+ (existing.scope !== scope || (harnessChanged && !allowHarnessChange))) {
137
148
  throw new AgentOpsError("INSTALL_ALREADY_CONFIGURED", "Use update to change the scope or harness of an existing installation.");
138
149
  }
139
150
  }
@@ -144,6 +155,11 @@ function findOwnedArtifact(manifest, path) {
144
155
  async function planArtifact(root, artifact, existingManifest) {
145
156
  const current = await readCurrentFile(root, artifact.path);
146
157
  const owned = findOwnedArtifact(existingManifest, artifact.path);
158
+ if (artifact.id === "opencode-plugin" &&
159
+ current !== null &&
160
+ !isOpencodeManagedPlugin(current.content)) {
161
+ throw new AgentOpsError("MANIFEST_OWNERSHIP_INVALID", `The recorded opencode plugin is not an agent-ops managed plugin: ${artifact.path}`);
162
+ }
147
163
  if (current !== null && owned === undefined) {
148
164
  throw new AgentOpsError("UNMANAGED_INSTALL_PATH", `Refusing to replace an unmanaged install artifact: ${artifact.path}`);
149
165
  }
@@ -168,30 +184,128 @@ async function planArtifact(root, artifact, existingManifest) {
168
184
  }
169
185
  };
170
186
  }
171
- async function planBlocks(root, blocks) {
187
+ async function planArtifactRemoval(root, artifact) {
188
+ const current = await readCurrentFile(root, artifact.path);
189
+ if (current === null || current.hash !== artifact.hash) {
190
+ throw new AgentOpsError("MANAGED_ARTIFACT_CHANGED", `Managed artifact changed after installation: ${artifact.path}`);
191
+ }
192
+ if (artifact.id === "opencode-plugin" &&
193
+ !isOpencodeManagedPlugin(current.content)) {
194
+ throw new AgentOpsError("MANIFEST_OWNERSHIP_INVALID", `The recorded opencode plugin is not an agent-ops managed plugin: ${artifact.path}`);
195
+ }
196
+ return {
197
+ kind: "remove",
198
+ path: artifact.path,
199
+ expectedHash: current.hash
200
+ };
201
+ }
202
+ async function planCreateOnceSeeds(root, seeds) {
203
+ const operations = [];
204
+ for (const seed of seeds) {
205
+ const current = await readCurrentFile(root, seed.path);
206
+ if (current === null) {
207
+ operations.push({
208
+ kind: "write",
209
+ path: seed.path,
210
+ content: seed.content,
211
+ expectedHash: null
212
+ });
213
+ }
214
+ }
215
+ return operations;
216
+ }
217
+ /**
218
+ * User-owned seed files belong to the first loop install for each harness.
219
+ * An existing loop launcher is the durable installation record: it prevents an
220
+ * update from recreating a file a user intentionally removed, while still
221
+ * allowing loop to be enabled later or for a newly added harness.
222
+ */
223
+ function loopHarnessesNeedingSeeds(harnesses, existingManifest) {
224
+ const existingArtifactIds = new Set(existingManifest?.artifacts.map(({ id }) => id) ?? []);
225
+ return selectedLoopHarnesses(harnesses).filter((harness) => !existingArtifactIds.has(loopLauncherArtifactId(harness)));
226
+ }
227
+ function markerKey(path, id) {
228
+ return `${pathKey(path)}\0${id}`;
229
+ }
230
+ function assertLoopProfileSupport(scope, harness, capabilities) {
231
+ if (!capabilities.includes("project-loop")) {
232
+ return;
233
+ }
234
+ if (scope !== "project" ||
235
+ !harness.some((id) => id === "codex" || id === "claude")) {
236
+ throw new AgentOpsError("LOOP_PROFILE_UNSUPPORTED", "The loop profile requires project scope and the Codex or Claude harness.");
237
+ }
238
+ }
239
+ async function assertCodexLoopConfiguration(root, harness, capabilities) {
240
+ if (!capabilities.includes("project-loop") ||
241
+ !harness.includes("codex")) {
242
+ return;
243
+ }
244
+ const config = await readCurrentFile(root, ".codex/config.toml");
245
+ if (config !== null && codexHooksExplicitlyDisabled(config.content)) {
246
+ throw new AgentOpsError("CODEX_LOOP_HOOKS_DISABLED", "Codex loop installation requires [features] hooks = true; the existing .codex/config.toml explicitly disables hooks.");
247
+ }
248
+ }
249
+ async function planBlocks(root, blocks, removals = [], expectedMarkers = new Map()) {
172
250
  const grouped = new Map();
173
251
  for (const block of blocks) {
174
- const existing = grouped.get(block.path) ?? [];
175
- existing.push(block);
176
- grouped.set(block.path, existing);
252
+ const key = pathKey(block.path);
253
+ const existing = grouped.get(key) ?? {
254
+ path: block.path,
255
+ blocks: [],
256
+ removals: []
257
+ };
258
+ existing.blocks.push(block);
259
+ existing.path = block.path;
260
+ grouped.set(key, existing);
261
+ }
262
+ for (const marker of removals) {
263
+ const key = pathKey(marker.path);
264
+ const existing = grouped.get(key) ?? {
265
+ path: marker.path,
266
+ blocks: [],
267
+ removals: []
268
+ };
269
+ existing.removals.push(marker);
270
+ grouped.set(key, existing);
177
271
  }
178
272
  const operations = [];
179
273
  const records = [];
180
- for (const [path, pathBlocks] of grouped) {
274
+ for (const { path, blocks: pathBlocks, removals: pathRemovals } of grouped.values()) {
181
275
  const current = await readCurrentFile(root, path);
276
+ if (pathRemovals.length > 0 && current === null) {
277
+ throw new AgentOpsError("MANAGED_BLOCK_CHANGED", `Managed block file is missing: ${path}`);
278
+ }
182
279
  let content = current?.content ?? "";
280
+ for (const marker of pathRemovals) {
281
+ const expected = expectedMarkers.get(marker.id);
282
+ if (expected === undefined) {
283
+ throw new AgentOpsError("MANIFEST_OWNERSHIP_INVALID", "The manifest contains an unsupported managed block.");
284
+ }
285
+ assertExpectedManagedBlock(content, marker, expected);
286
+ content = removeManagedBlock(content, marker.id, expected.markerStyle);
287
+ }
183
288
  for (const block of pathBlocks) {
184
289
  content = applyManagedBlock(content, block);
185
290
  }
186
291
  const hash = sha256(content);
187
- operations.push({
188
- kind: "write",
189
- path,
190
- content,
191
- expectedHash: current?.hash ?? null
192
- });
292
+ if (content.length === 0 && current !== null) {
293
+ operations.push({
294
+ kind: "remove",
295
+ path,
296
+ expectedHash: current.hash
297
+ });
298
+ }
299
+ else {
300
+ operations.push({
301
+ kind: "write",
302
+ path,
303
+ content,
304
+ expectedHash: current?.hash ?? null
305
+ });
306
+ }
193
307
  for (const block of pathBlocks) {
194
- const markers = managedBlockMarkers(block.id, block.version);
308
+ const markers = managedBlockMarkers(block.id, block.version, block.markerStyle);
195
309
  records.push({
196
310
  id: block.id,
197
311
  path,
@@ -210,18 +324,93 @@ export async function createInstallPlan(options) {
210
324
  .test(options.toolkitVersion)) {
211
325
  throw new AgentOpsError("INVALID_TOOLKIT_VERSION", "Toolkit version must be a valid semantic version.");
212
326
  }
213
- const resolved = resolveProfiles(options.profiles);
214
- const contribution = await planHarnessContributions(options.harness, {
327
+ const resolved = options.existingConfig === undefined
328
+ ? resolveProfiles(options.profiles)
329
+ : resolveCapabilities(options.existingConfig.value);
330
+ assertLoopProfileSupport(options.scope, options.harness, resolved.capabilities);
331
+ await assertCodexLoopConfiguration(options.root, options.harness, resolved.capabilities);
332
+ const existing = await readExistingManifest(options.root);
333
+ assertCompatibleManifest(existing?.manifest ?? null, options.scope, options.harness, options.allowHarnessChange === true);
334
+ const existingOpencodePluginPath = existing?.manifest.artifacts.find(({ id }) => id === "opencode-plugin")?.path;
335
+ const explicitHookTargets = new Map();
336
+ for (const target of options.hookTargets ?? []) {
337
+ if (explicitHookTargets.has(target.harness) ||
338
+ !options.harness.includes(target.harness)) {
339
+ throw new AgentOpsError("HOOK_TARGET_INVALID", `Hook target must name one selected harness exactly once: ${target.harness}.`);
340
+ }
341
+ if (harnessDescriptor(target.harness).control.buildHooks === undefined) {
342
+ throw new AgentOpsError("HOOK_TARGET_UNSUPPORTED", `Harness hook targets are not supported for ${target.harness}.`);
343
+ }
344
+ explicitHookTargets.set(target.harness, target.surfaceId);
345
+ }
346
+ if (options.hookRuntimePath === undefined &&
347
+ explicitHookTargets.size > 0) {
348
+ throw new AgentOpsError("HOOK_TARGET_REQUIRES_RUNTIME", "An explicit hook target requires a hook runtime path.");
349
+ }
350
+ const baseContribution = await planHarnessContributions(options.harness, {
351
+ root: options.root,
215
352
  scope: options.scope,
216
353
  profiles: resolved.profiles,
217
354
  capabilities: resolved.capabilities,
355
+ ...(options.hookRuntimePath === undefined
356
+ ? {}
357
+ : { runtimePath: options.hookRuntimePath }),
218
358
  ...(options.toolkitVersion === undefined
219
359
  ? {}
220
- : { toolkitVersion: options.toolkitVersion })
360
+ : { toolkitVersion: options.toolkitVersion }),
361
+ ...(existingOpencodePluginPath === undefined
362
+ ? {}
363
+ : { opencodePluginPath: existingOpencodePluginPath })
221
364
  }, options.adapters);
365
+ const loopContribution = planLoopContribution({
366
+ scope: options.scope,
367
+ harnesses: options.harness,
368
+ capabilities: resolved.capabilities,
369
+ ...(options.hookRuntimePath === undefined
370
+ ? {}
371
+ : { hookRuntimePath: options.hookRuntimePath })
372
+ });
373
+ const contribution = {
374
+ artifacts: [
375
+ ...baseContribution.artifacts,
376
+ ...loopContribution.artifacts
377
+ ],
378
+ blocks: [...baseContribution.blocks, ...loopContribution.blocks]
379
+ };
222
380
  assertUniqueContributions(contribution.artifacts, contribution.blocks);
223
- const existing = await readExistingManifest(options.root);
224
- assertCompatibleManifest(existing?.manifest ?? null, options.scope, options.harness);
381
+ const reconcileExisting = existing !== null;
382
+ const expectedExistingMarkers = reconcileExisting
383
+ ? assertSupportedManifestOwnership(existing.manifest, options.root)
384
+ : new Map();
385
+ const desiredArtifactPaths = new Set([
386
+ pathKey(CONFIG_PATH),
387
+ ...contribution.artifacts.map(({ path }) => pathKey(path))
388
+ ]);
389
+ const preservedArtifacts = options.hookRuntimePath === undefined &&
390
+ options.allowHarnessChange === true &&
391
+ options.harness.includes("opencode")
392
+ ? (existing?.manifest.artifacts.filter(({ id }) => id === "opencode-plugin") ?? [])
393
+ : [];
394
+ for (const artifact of preservedArtifacts) {
395
+ const current = await readCurrentFile(options.root, artifact.path);
396
+ if (current === null || current.hash !== artifact.hash) {
397
+ throw new AgentOpsError("MANAGED_ARTIFACT_CHANGED", `Managed artifact changed after installation: ${artifact.path}`);
398
+ }
399
+ if (artifact.id === "opencode-plugin" &&
400
+ !isOpencodeManagedPlugin(current.content)) {
401
+ throw new AgentOpsError("MANIFEST_OWNERSHIP_INVALID", `The recorded opencode plugin is not an agent-ops managed plugin: ${artifact.path}`);
402
+ }
403
+ }
404
+ for (const artifact of preservedArtifacts) {
405
+ desiredArtifactPaths.add(pathKey(artifact.path));
406
+ }
407
+ const artifactsToRemove = reconcileExisting
408
+ ? existing.manifest.artifacts.filter(({ path }) => !desiredArtifactPaths.has(pathKey(path)))
409
+ : [];
410
+ const desiredMarkerKeys = new Set(contribution.blocks.map(({ path, id }) => markerKey(path, id)));
411
+ const markersToRemove = reconcileExisting
412
+ ? existing.manifest.markers.filter(({ path, id }) => !desiredMarkerKeys.has(markerKey(path, id)))
413
+ : [];
225
414
  const operations = [];
226
415
  const artifacts = [];
227
416
  const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig);
@@ -232,15 +421,39 @@ export async function createInstallPlan(options) {
232
421
  operations.push(planned.operation);
233
422
  artifacts.push(planned.record);
234
423
  }
235
- const plannedBlocks = await planBlocks(options.root, contribution.blocks);
424
+ if (resolved.capabilities.includes("project-loop")) {
425
+ const seedHarnesses = loopHarnessesNeedingSeeds(options.harness, existing?.manifest ?? null);
426
+ operations.push(...await planCreateOnceSeeds(options.root, loopSeeds(seedHarnesses)));
427
+ }
428
+ artifacts.push(...preservedArtifacts);
429
+ for (const artifact of artifactsToRemove) {
430
+ operations.push(await planArtifactRemoval(options.root, artifact));
431
+ }
432
+ const plannedBlocks = await planBlocks(options.root, contribution.blocks, markersToRemove, expectedExistingMarkers);
236
433
  operations.push(...plannedBlocks.operations);
237
434
  const hooks = [];
238
435
  if (options.hookRuntimePath !== undefined) {
239
- for (const harness of requestedHarnessIds(options.harness)) {
240
- const current = await readCurrentFile(options.root, hookRegistrationPath(harness, options.scope));
436
+ for (const harness of options.harness) {
437
+ if (harnessDescriptor(harness).control.buildHooks === undefined) {
438
+ continue;
439
+ }
440
+ const existingHook = existing?.manifest.hooks?.find(({ harness: recordHarness }) => recordHarness === harness);
441
+ const selectedSurface = selectHarnessHookSurface({
442
+ harness,
443
+ scope: options.scope,
444
+ root: options.root,
445
+ ...(explicitHookTargets.has(harness)
446
+ ? { surfaceId: explicitHookTargets.get(harness) }
447
+ : existingHook === undefined
448
+ ? {}
449
+ : { persistedPath: existingHook.path })
450
+ });
451
+ const hookPath = selectedSurface.path;
452
+ const current = await readCurrentFile(options.root, hookPath);
241
453
  const planned = planHookRegistration({
242
454
  harness,
243
455
  scope: options.scope,
456
+ path: hookPath,
244
457
  capabilities: resolved.capabilities,
245
458
  runtimePath: options.hookRuntimePath,
246
459
  currentSource: current?.content ?? null
@@ -252,13 +465,45 @@ export async function createInstallPlan(options) {
252
465
  kind: "write",
253
466
  path: planned.record.path,
254
467
  content: planned.content,
255
- expectedHash: current?.hash ?? null
468
+ expectedHash: current?.hash ?? null,
469
+ disclosure: planned.disclosure
256
470
  });
257
471
  hooks.push(planned.record);
258
472
  }
259
473
  }
474
+ else if (existing !== null) {
475
+ const selectedHarnesses = new Set(options.harness);
476
+ hooks.push(...(existing.manifest.hooks ?? []).filter(({ harness }) => selectedHarnesses.has(harness)));
477
+ }
478
+ if (reconcileExisting) {
479
+ const desiredHookPaths = new Set(hooks.map(({ path }) => pathKey(path)));
480
+ for (const hook of existing.manifest.hooks ?? []) {
481
+ if (desiredHookPaths.has(pathKey(hook.path))) {
482
+ continue;
483
+ }
484
+ const current = await readCurrentFile(options.root, hook.path);
485
+ if (current === null) {
486
+ continue;
487
+ }
488
+ const removal = planHookRemoval(hook, current.content);
489
+ operations.push(removal.content === null
490
+ ? {
491
+ kind: "remove",
492
+ path: hook.path,
493
+ expectedHash: current.hash,
494
+ disclosure: removal.disclosure
495
+ }
496
+ : {
497
+ kind: "write",
498
+ path: hook.path,
499
+ content: removal.content,
500
+ expectedHash: current.hash,
501
+ disclosure: removal.disclosure
502
+ });
503
+ }
504
+ }
260
505
  const manifest = {
261
- schemaVersion: SCHEMA_VERSION,
506
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
262
507
  scope: options.scope,
263
508
  harness: options.harness,
264
509
  artifacts,
@@ -1,52 +1,18 @@
1
- import { buildClaudeHookSettings } from "../adapters/claude/config.js";
2
- import { buildCodexHookConfig } from "../adapters/codex/config.js";
3
- import { resolveProfiles } from "./profiles.js";
4
- const CLAUDE_HOOK_MARKER = "--managed-by=agent-ops";
5
- const CODEX_COMMAND_PREFIX = "agent-ops hook codex ";
6
- function isRecord(value) {
7
- return typeof value === "object" && value !== null && !Array.isArray(value);
8
- }
9
- function hasManagedHandler(source, events, isManaged) {
10
- if (!isRecord(source) || !isRecord(source.hooks)) {
11
- return false;
12
- }
13
- const registered = source.hooks;
14
- return events.every((event) => {
15
- const groups = registered[event];
16
- return (Array.isArray(groups) &&
17
- groups.some((group) => isRecord(group) &&
18
- Array.isArray(group.hooks) &&
19
- group.hooks.some(isManaged)));
20
- });
21
- }
22
- function isManagedClaudeHandler(handler) {
23
- return (isRecord(handler) &&
24
- Array.isArray(handler.args) &&
25
- handler.args.includes(CLAUDE_HOOK_MARKER));
26
- }
27
- function isManagedCodexHandler(handler) {
28
- return (isRecord(handler) &&
29
- typeof handler.command === "string" &&
30
- handler.command.startsWith(CODEX_COMMAND_PREFIX));
31
- }
1
+ import { harnessDescriptor } from "./harness.js";
2
+ import { resolveCapabilities } from "./profiles.js";
32
3
  /**
33
4
  * Hook registration is satisfied when every hook event implied by the
34
5
  * installed profiles carries an agent-ops owned handler for every installed
35
6
  * harness. Installations without hook capabilities have nothing to register.
36
7
  */
37
8
  export function hookRegistrationSatisfied(input) {
38
- const { capabilities } = resolveProfiles(input.profiles);
39
- const claudeEvents = Object.keys(buildClaudeHookSettings(capabilities, "probe").hooks);
40
- const codexEvents = Object.keys(buildCodexHookConfig(capabilities).hooks);
41
- if (claudeEvents.length === 0 && codexEvents.length === 0) {
42
- return true;
43
- }
44
- if (input.harness !== "codex" &&
45
- !hasManagedHandler(input.claudeSettings, claudeEvents, isManagedClaudeHandler)) {
46
- return false;
47
- }
48
- return (input.harness === "claude" ||
49
- hasManagedHandler(input.codexHooks, codexEvents, isManagedCodexHandler));
9
+ const capabilities = input.config.profiles.length === 0
10
+ ? []
11
+ : resolveCapabilities(input.config).capabilities;
12
+ return input.harness.every((id) => {
13
+ const descriptor = harnessDescriptor(id);
14
+ return descriptor.control.hookRegistered(input.sources[id], capabilities);
15
+ });
50
16
  }
51
17
  /**
52
18
  * Smoke availability stays UNKNOWN until the repository declares a
@@ -1,16 +1,18 @@
1
1
  import { AgentOpsError } from "../fs/paths.js";
2
- const PROFILE_ORDER = ["core", "advisory", "guardrails"];
2
+ const PROFILE_ORDER = ["core", "advisory", "guardrails", "loop"];
3
3
  export const PROFILE_CAPABILITIES = {
4
4
  core: ["rules", "task", "verify", "review"],
5
5
  advisory: ["lifecycle-summary", "local-log"],
6
- guardrails: ["command-policy", "optional-stop-verify"]
6
+ guardrails: ["command-policy"],
7
+ loop: ["project-loop"]
7
8
  };
8
9
  export function resolveProfiles(inputProfiles) {
9
10
  if (inputProfiles.length === 0) {
10
11
  throw new AgentOpsError("PROFILE_REQUIRED", "At least one installation profile is required.");
11
12
  }
12
13
  const selectedProfiles = new Set(inputProfiles);
13
- if (selectedProfiles.has("guardrails")) {
14
+ if (selectedProfiles.has("guardrails") ||
15
+ selectedProfiles.has("loop")) {
14
16
  selectedProfiles.add("core");
15
17
  }
16
18
  const profiles = PROFILE_ORDER.filter((profile) => selectedProfiles.has(profile));
@@ -26,3 +28,10 @@ export function resolveProfiles(inputProfiles) {
26
28
  }
27
29
  return { profiles: [...profiles], capabilities };
28
30
  }
31
+ export function resolveCapabilities(config) {
32
+ const resolved = resolveProfiles(config.profiles);
33
+ if (config.features.stopVerification.enabled) {
34
+ resolved.capabilities.push("optional-stop-verify");
35
+ }
36
+ return resolved;
37
+ }