@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
@@ -0,0 +1,296 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, open } from "node:fs/promises";
3
+ import { resolveContainedPath } from "../fs/paths.js";
4
+ import { harnessDescriptor, harnessSurfaceByPath, harnessSurfaces } from "./harness.js";
5
+ import { resolveCapabilities, resolveProfiles } from "./profiles.js";
6
+ import { isWritableSurface } from "./surfaces.js";
7
+ const MAX_SURFACE_FILE_BYTES = 1024 * 1024;
8
+ function isMissing(error) {
9
+ return (typeof error === "object" &&
10
+ error !== null &&
11
+ "code" in error &&
12
+ error.code === "ENOENT");
13
+ }
14
+ function isRecord(value) {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+ async function readSurface(root, surface) {
18
+ if (surface.scope === "external") {
19
+ return null;
20
+ }
21
+ let resolvedPath;
22
+ try {
23
+ resolvedPath = await resolveContainedPath(root, surface.path);
24
+ }
25
+ catch (error) {
26
+ if (isMissing(error)) {
27
+ return null;
28
+ }
29
+ throw error;
30
+ }
31
+ let before;
32
+ try {
33
+ before = await lstat(resolvedPath, { bigint: true });
34
+ }
35
+ catch (error) {
36
+ if (isMissing(error)) {
37
+ return null;
38
+ }
39
+ throw error;
40
+ }
41
+ if (!before.isFile() ||
42
+ before.size > BigInt(MAX_SURFACE_FILE_BYTES)) {
43
+ throw new Error("Surface is not a bounded regular file.");
44
+ }
45
+ const handle = await open(resolvedPath, constants.O_RDONLY |
46
+ constants.O_NOFOLLOW |
47
+ constants.O_NONBLOCK);
48
+ try {
49
+ const opened = await handle.stat({ bigint: true });
50
+ const resolvedAgain = await resolveContainedPath(root, surface.path);
51
+ const after = await lstat(resolvedAgain, { bigint: true });
52
+ if (!opened.isFile() ||
53
+ opened.size > BigInt(MAX_SURFACE_FILE_BYTES) ||
54
+ before.dev !== after.dev ||
55
+ before.ino !== after.ino) {
56
+ throw new Error("Surface identity changed during inspection.");
57
+ }
58
+ const chunks = [];
59
+ let totalBytes = 0;
60
+ while (totalBytes <= MAX_SURFACE_FILE_BYTES) {
61
+ const remaining = MAX_SURFACE_FILE_BYTES + 1 - totalBytes;
62
+ const chunk = Buffer.alloc(Math.min(64 * 1024, remaining));
63
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
64
+ if (bytesRead === 0) {
65
+ return {
66
+ source: Buffer.concat(chunks, totalBytes).toString("utf8")
67
+ };
68
+ }
69
+ chunks.push(chunk.subarray(0, bytesRead));
70
+ totalBytes += bytesRead;
71
+ }
72
+ throw new Error("Surface exceeded its inspection limit.");
73
+ }
74
+ finally {
75
+ await handle.close();
76
+ }
77
+ }
78
+ function sameEvents(left, right) {
79
+ return (left.length === right.length &&
80
+ left.every((event) => right.includes(event)));
81
+ }
82
+ function desiredCapabilities(config) {
83
+ return config.profiles.length === 0
84
+ ? []
85
+ : resolveCapabilities(config).capabilities;
86
+ }
87
+ function managedJsonCount(source, isManagedHandler) {
88
+ if (source === null) {
89
+ return 0;
90
+ }
91
+ return jsonHandlerCounts(source, isManagedHandler)?.managed ?? 0;
92
+ }
93
+ export async function inspectHarnessRegistrations(options) {
94
+ const capabilities = desiredCapabilities(options.config);
95
+ const statuses = [];
96
+ for (const harness of options.manifest.harness) {
97
+ const descriptor = harnessDescriptor(harness);
98
+ const control = descriptor.control;
99
+ const hookRecord = options.manifest.hooks?.find(({ harness: recordHarness }) => recordHarness === harness);
100
+ const recordedEvents = hookRecord?.events ?? [];
101
+ const desiredEvents = control.buildHooks === undefined
102
+ ? []
103
+ : Object.keys(control.buildHooks(capabilities, "probe").hooks);
104
+ if (control.buildHooks !== undefined) {
105
+ const surfaces = harnessSurfaces(harness, options.manifest.scope, options.root);
106
+ const writableJsonSurfaces = surfaces.filter((surface) => isWritableSurface(surface) && surface.representation === "json");
107
+ let managedCount = 0;
108
+ for (const surface of writableJsonSurfaces) {
109
+ try {
110
+ const read = await readSurface(options.root, surface);
111
+ managedCount += managedJsonCount(read?.source ?? null, control.isManagedHandler);
112
+ }
113
+ catch {
114
+ // An unsafe surface is treated as drift below when it is selected.
115
+ managedCount += 1;
116
+ }
117
+ }
118
+ const selectedSurface = hookRecord === undefined
119
+ ? writableJsonSurfaces.find((surface) => surface.access === "managed-default")
120
+ : harnessSurfaceByPath(harness, options.manifest.scope, hookRecord.path, options.root);
121
+ let source = null;
122
+ if (selectedSurface !== undefined) {
123
+ try {
124
+ source = (await readSurface(options.root, selectedSurface))?.source ??
125
+ null;
126
+ }
127
+ catch {
128
+ source = null;
129
+ }
130
+ }
131
+ const registered = hookRecord === undefined
132
+ ? desiredEvents.length === 0 && managedCount === 0
133
+ : selectedSurface !== undefined &&
134
+ sameEvents(desiredEvents, recordedEvents) &&
135
+ (desiredEvents.length === 0
136
+ ? managedCount === 0
137
+ : source !== null &&
138
+ control.hookRegistered(source, capabilities));
139
+ statuses.push({
140
+ harness,
141
+ registered,
142
+ desiredEvents,
143
+ recordedEvents
144
+ });
145
+ continue;
146
+ }
147
+ const pluginArtifact = options.manifest.artifacts.find(({ id }) => id === "opencode-plugin");
148
+ const hasDesiredPlugin = control.registrations.some((registration) => capabilities.includes(registration.capability));
149
+ let registered = !hasDesiredPlugin && pluginArtifact === undefined;
150
+ if (hasDesiredPlugin && pluginArtifact !== undefined) {
151
+ const discovered = harnessSurfaces(harness, options.manifest.scope, options.root).find((surface) => surface.id === "opencode-plugin");
152
+ const selectedSurface = discovered === undefined
153
+ ? undefined
154
+ : { ...discovered, path: pluginArtifact.path };
155
+ try {
156
+ const source = selectedSurface === undefined
157
+ ? null
158
+ : (await readSurface(options.root, selectedSurface))?.source ?? null;
159
+ registered =
160
+ source !== null && control.hookRegistered(source, capabilities);
161
+ }
162
+ catch {
163
+ registered = false;
164
+ }
165
+ }
166
+ if (!hasDesiredPlugin && pluginArtifact !== undefined) {
167
+ registered = false;
168
+ }
169
+ statuses.push({
170
+ harness,
171
+ registered,
172
+ desiredEvents,
173
+ recordedEvents
174
+ });
175
+ }
176
+ return statuses;
177
+ }
178
+ function jsonHandlerCounts(source, isManagedHandler) {
179
+ let parsed;
180
+ try {
181
+ parsed = JSON.parse(source);
182
+ }
183
+ catch {
184
+ return null;
185
+ }
186
+ if (!isRecord(parsed)) {
187
+ return null;
188
+ }
189
+ const hooks = parsed.hooks;
190
+ if (hooks === undefined) {
191
+ return { managed: 0, foreign: 0 };
192
+ }
193
+ if (!isRecord(hooks)) {
194
+ return null;
195
+ }
196
+ let managed = 0;
197
+ let foreign = 0;
198
+ for (const groups of Object.values(hooks)) {
199
+ if (!Array.isArray(groups)) {
200
+ return null;
201
+ }
202
+ for (const group of groups) {
203
+ if (!isRecord(group) || !Array.isArray(group.hooks)) {
204
+ return null;
205
+ }
206
+ for (const handler of group.hooks) {
207
+ if (isManagedHandler?.(handler) === true) {
208
+ managed += 1;
209
+ }
210
+ else {
211
+ foreign += 1;
212
+ }
213
+ }
214
+ }
215
+ }
216
+ return { managed, foreign };
217
+ }
218
+ function handlerCounts(surface, source, capabilities, isManagedHandler, hookRegistered) {
219
+ if (surface.representation === "json") {
220
+ return jsonHandlerCounts(source, isManagedHandler);
221
+ }
222
+ if (surface.representation === "javascript") {
223
+ return {
224
+ managed: hookRegistered(source, capabilities) ? 1 : 0,
225
+ foreign: 0
226
+ };
227
+ }
228
+ return { managed: 0, foreign: 0 };
229
+ }
230
+ export async function inspectHarnessSurfaces(options) {
231
+ const capabilities = options.profiles.length === 0
232
+ ? []
233
+ : resolveProfiles(options.profiles).capabilities;
234
+ const statuses = [];
235
+ for (const harness of options.harness) {
236
+ const descriptor = harnessDescriptor(harness);
237
+ for (const surface of harnessSurfaces(harness, options.scope, options.root)) {
238
+ if (surface.scope === "external") {
239
+ statuses.push({
240
+ harness,
241
+ surfaceId: surface.id,
242
+ path: surface.path,
243
+ status: "unknown",
244
+ managedHandlerCount: 0,
245
+ foreignHandlerCount: 0
246
+ });
247
+ continue;
248
+ }
249
+ try {
250
+ const read = await readSurface(options.root, surface);
251
+ if (read === null) {
252
+ statuses.push({
253
+ harness,
254
+ surfaceId: surface.id,
255
+ path: surface.path,
256
+ status: "missing",
257
+ managedHandlerCount: 0,
258
+ foreignHandlerCount: 0
259
+ });
260
+ continue;
261
+ }
262
+ const counts = handlerCounts(surface, read.source, capabilities, descriptor.control.isManagedHandler, descriptor.control.hookRegistered);
263
+ if (counts === null) {
264
+ statuses.push({
265
+ harness,
266
+ surfaceId: surface.id,
267
+ path: surface.path,
268
+ status: "unknown",
269
+ managedHandlerCount: 0,
270
+ foreignHandlerCount: 0
271
+ });
272
+ continue;
273
+ }
274
+ statuses.push({
275
+ harness,
276
+ surfaceId: surface.id,
277
+ path: surface.path,
278
+ status: counts.managed > 0 ? "managed" : "foreign",
279
+ managedHandlerCount: counts.managed,
280
+ foreignHandlerCount: counts.foreign
281
+ });
282
+ }
283
+ catch {
284
+ statuses.push({
285
+ harness,
286
+ surfaceId: surface.id,
287
+ path: surface.path,
288
+ status: "unknown",
289
+ managedHandlerCount: 0,
290
+ foreignHandlerCount: 0
291
+ });
292
+ }
293
+ }
294
+ }
295
+ return statuses;
296
+ }
@@ -0,0 +1,11 @@
1
+ export function findSurfaceById(surfaces, id) {
2
+ return surfaces.find((surface) => surface.id === id);
3
+ }
4
+ export function findSurfaceByPath(surfaces, path) {
5
+ return surfaces.find((surface) => surface.path === path);
6
+ }
7
+ export function isWritableSurface(surface) {
8
+ return (surface !== undefined &&
9
+ (surface.access === "managed-default" ||
10
+ surface.access === "managed-opt-in"));
11
+ }
@@ -7,6 +7,7 @@ import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
7
7
  import { FileTransaction } from "../fs/transaction.js";
8
8
  import { planHookRemoval } from "./hooks.js";
9
9
  import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
10
+ import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
10
11
  const MANIFEST_PATH = ".agent-ops/manifest.json";
11
12
  const MAX_UNINSTALL_FILE_BYTES = 1024 * 1024;
12
13
  function isMissing(error) {
@@ -85,7 +86,7 @@ async function planMarkerFiles(root, markers, expectedMarkers) {
85
86
  }
86
87
  assertExpectedManagedBlock(content, marker, expected);
87
88
  try {
88
- content = removeManagedBlock(content, marker.id);
89
+ content = removeManagedBlock(content, marker.id, expected.markerStyle);
89
90
  }
90
91
  catch (error) {
91
92
  throw new AgentOpsError("MANAGED_BLOCK_CHANGED", `Managed block cannot be removed safely: ${path}`, { cause: error });
@@ -117,13 +118,17 @@ export async function createUninstallPlan(root) {
117
118
  };
118
119
  }
119
120
  const manifest = parseInstallManifest(currentManifest.content);
120
- const expectedMarkers = assertSupportedManifestOwnership(manifest);
121
+ const expectedMarkers = assertSupportedManifestOwnership(manifest, root);
121
122
  const operations = [];
122
123
  for (const artifact of manifest.artifacts) {
123
124
  const current = await readCurrentFile(root, artifact.path);
124
125
  if (current === null || current.hash !== artifact.hash) {
125
126
  throw new AgentOpsError("MANAGED_ARTIFACT_CHANGED", `Managed artifact changed after installation: ${artifact.path}`);
126
127
  }
128
+ if (artifact.id === "opencode-plugin" &&
129
+ !isOpencodeManagedPlugin(current.content)) {
130
+ throw new AgentOpsError("MANIFEST_OWNERSHIP_INVALID", `The recorded opencode plugin is not an agent-ops managed plugin: ${artifact.path}`);
131
+ }
127
132
  operations.push({
128
133
  kind: "remove",
129
134
  path: artifact.path,
@@ -141,13 +146,15 @@ export async function createUninstallPlan(root) {
141
146
  ? {
142
147
  kind: "remove",
143
148
  path: hook.path,
144
- expectedHash: current.hash
149
+ expectedHash: current.hash,
150
+ disclosure: removal.disclosure
145
151
  }
146
152
  : {
147
153
  kind: "write",
148
154
  path: hook.path,
149
155
  content: removal.content,
150
- expectedHash: current.hash
156
+ expectedHash: current.hash,
157
+ disclosure: removal.disclosure
151
158
  });
152
159
  }
153
160
  operations.push({
@@ -9,6 +9,7 @@ import { createInstallPlan } from "./plan.js";
9
9
  const PACKAGE_NAME = "@kylecheng3146/agent-ops";
10
10
  const CONFIG_PATH = ".agent-ops/config.json";
11
11
  const MAX_UPDATE_CONFIG_BYTES = 1024 * 1024;
12
+ const TOOLKIT_VERSION_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u;
12
13
  async function readBoundedConfig(root) {
13
14
  const resolvedPath = await resolveContainedPath(root, CONFIG_PATH);
14
15
  const before = await lstat(resolvedPath, { bigint: true });
@@ -82,13 +83,18 @@ export async function createUpdatePlan(options) {
82
83
  if (targetVersion === undefined) {
83
84
  throw new AgentOpsError("UPDATE_TARGET_REQUIRED", "Update requires a target version or an explicit registry client.");
84
85
  }
86
+ if (!TOOLKIT_VERSION_PATTERN.test(targetVersion)) {
87
+ throw new AgentOpsError("INVALID_TOOLKIT_VERSION", "Toolkit version must be a valid semantic version.");
88
+ }
85
89
  const report = await doctorInstallation({ root: options.root });
86
90
  for (const id of [
87
91
  "node-version",
88
92
  "manifest",
89
93
  "markers"
90
94
  ]) {
91
- if (statusOf(report, id) !== "PASS") {
95
+ const status = statusOf(report, id);
96
+ if (status !== "PASS" &&
97
+ !(id === "markers" && status === "DEGRADED")) {
92
98
  throw new AgentOpsError("UPDATE_INSTALLATION_INVALID", `Update requires a passing ${id} doctor check.`);
93
99
  }
94
100
  }
@@ -103,13 +109,17 @@ export async function createUpdatePlan(options) {
103
109
  const installation = await createInstallPlan({
104
110
  root: options.root,
105
111
  scope: report.manifest.scope,
106
- harness: report.manifest.harness,
112
+ harness: options.harness ?? report.manifest.harness,
107
113
  profiles: configPreview.migrated.profiles,
108
114
  adapters: options.adapters,
109
- toolkitVersion: targetVersion,
115
+ toolkitVersion: options.toolkitVersion ?? targetVersion,
116
+ allowHarnessChange: true,
110
117
  ...(options.hookRuntimePath === undefined
111
118
  ? {}
112
119
  : { hookRuntimePath: options.hookRuntimePath }),
120
+ ...(options.hookTargets === undefined
121
+ ? {}
122
+ : { hookTargets: options.hookTargets }),
113
123
  existingConfig: {
114
124
  value: configPreview.migrated,
115
125
  sourceHash: configPreview.sourceHash
@@ -83,6 +83,31 @@ function sanitizeEvent(value) {
83
83
  result: value.result
84
84
  };
85
85
  }
86
+ if (value.type === "loop-event") {
87
+ if (!hasExactKeys(value, ["code", "event", "outcome", "type"]) ||
88
+ typeof value.code !== "string" ||
89
+ !ID_PATTERN.test(value.code) ||
90
+ ![
91
+ "permission-request",
92
+ "post-compact",
93
+ "post-tool-use",
94
+ "pre-compact",
95
+ "pre-tool-use",
96
+ "session-start",
97
+ "subagent-start",
98
+ "subagent-stop",
99
+ "user-prompt-submit"
100
+ ].includes(String(value.event)) ||
101
+ !["allowed", "blocked", "observed"].includes(String(value.outcome))) {
102
+ return invalidEvent();
103
+ }
104
+ return {
105
+ type: "loop-event",
106
+ event: value.event,
107
+ outcome: value.outcome,
108
+ code: value.code
109
+ };
110
+ }
86
111
  return invalidEvent();
87
112
  }
88
113
  function serialize(stored) {
@@ -1,15 +1,23 @@
1
- import { SCHEMA_VERSION } from "../contracts.js";
1
+ import { CONFIG_SCHEMA_VERSION, EVIDENCE_SCHEMA_VERSION, MANIFEST_SCHEMA_VERSION, TASK_SCHEMA_VERSION } from "../contracts.js";
2
2
  const ID_PATTERN = /^[a-z][a-z0-9-]{0,127}$/;
3
3
  const HASH_PATTERN = /^[a-f0-9]{64}$/;
4
4
  const WINDOWS_RESERVED_SEGMENT = /^(?:aux|com[1-9]|con|lpt[1-9]|nul|prn)(?:\..*)?$/i;
5
- const PROFILE_VALUES = new Set(["advisory", "core", "guardrails"]);
5
+ const PROFILE_VALUES = new Set(["advisory", "core", "guardrails", "loop"]);
6
6
  const EVIDENCE_KINDS = new Set(["exit-code", "file", "test-count"]);
7
7
  const SCOPE_VALUES = new Set(["project", "user"]);
8
- const HARNESS_VALUES = new Set(["both", "claude", "codex"]);
8
+ const HARNESS_VALUES = new Set(["claude", "codex", "opencode"]);
9
+ // opencode's plugin is a managed artifact, not a ManagedHookRecord entry.
9
10
  const HOOK_HARNESS_VALUES = new Set(["claude", "codex"]);
10
11
  const HOOK_EVENT_VALUES = new Set([
11
12
  "SessionStart",
13
+ "UserPromptSubmit",
12
14
  "PreToolUse",
15
+ "PermissionRequest",
16
+ "PostToolUse",
17
+ "PreCompact",
18
+ "PostCompact",
19
+ "SubagentStart",
20
+ "SubagentStop",
13
21
  "Stop"
14
22
  ]);
15
23
  const MAX_TIMEOUT_MS = 2_147_483_647;
@@ -38,12 +46,12 @@ function unknownFieldFailure(value, allowed, path) {
38
46
  ? undefined
39
47
  : failure("UNKNOWN_FIELD", `${path}.${unknown}`, `Unknown field: ${unknown}`);
40
48
  }
41
- function validateRoot(value, allowed) {
49
+ function validateRoot(value, allowed, expectedVersion) {
42
50
  if (!isRecord(value)) {
43
51
  return failure("INVALID_TYPE", "$", "Expected an object.");
44
52
  }
45
- if (value.schemaVersion !== SCHEMA_VERSION) {
46
- return failure("SCHEMA_VERSION_UNSUPPORTED", "$.schemaVersion", `Expected schemaVersion ${SCHEMA_VERSION}.`);
53
+ if (value.schemaVersion !== expectedVersion) {
54
+ return failure("SCHEMA_VERSION_UNSUPPORTED", "$.schemaVersion", `Expected schemaVersion ${expectedVersion}.`);
47
55
  }
48
56
  return unknownFieldFailure(value, allowed, "$") ?? value;
49
57
  }
@@ -277,12 +285,13 @@ function validateSecurityException(value, path) {
277
285
  }
278
286
  export function validateConfig(value) {
279
287
  const root = validateRoot(value, [
288
+ "features",
280
289
  "pathMappings",
281
290
  "profiles",
282
291
  "schemaVersion",
283
292
  "securityExceptions",
284
293
  "verification"
285
- ]);
294
+ ], CONFIG_SCHEMA_VERSION);
286
295
  if (isFailure(root)) {
287
296
  return root;
288
297
  }
@@ -306,6 +315,23 @@ export function validateConfig(value) {
306
315
  if (!hasUniqueStrings(root.profiles)) {
307
316
  return failure("DUPLICATE_ID", "$.profiles", "Profiles must be unique.");
308
317
  }
318
+ if (!isRecord(root.features)) {
319
+ return failure("INVALID_TYPE", "$.features", "features must be an object.");
320
+ }
321
+ const featuresUnknown = unknownFieldFailure(root.features, ["stopVerification"], "$.features");
322
+ if (featuresUnknown !== undefined) {
323
+ return featuresUnknown;
324
+ }
325
+ if (!isRecord(root.features.stopVerification)) {
326
+ return failure("INVALID_TYPE", "$.features.stopVerification", "stopVerification must be an object.");
327
+ }
328
+ const stopVerificationUnknown = unknownFieldFailure(root.features.stopVerification, ["enabled"], "$.features.stopVerification");
329
+ if (stopVerificationUnknown !== undefined) {
330
+ return stopVerificationUnknown;
331
+ }
332
+ if (typeof root.features.stopVerification.enabled !== "boolean") {
333
+ return failure("INVALID_FEATURE", "$.features.stopVerification.enabled", "stopVerification.enabled must be a boolean.");
334
+ }
309
335
  if (!isRecord(root.verification)) {
310
336
  return failure("INVALID_TYPE", "$.verification", "verification must be an object.");
311
337
  }
@@ -327,6 +353,10 @@ export function validateConfig(value) {
327
353
  }
328
354
  commandIds.add(command.value.id);
329
355
  }
356
+ if (root.features.stopVerification.enabled === true &&
357
+ root.verification.commands.length === 0) {
358
+ return failure("STOP_VERIFICATION_COMMANDS_REQUIRED", "$.features.stopVerification.enabled", "Stop verification requires at least one verification command.");
359
+ }
330
360
  if (!Array.isArray(root.pathMappings)) {
331
361
  return failure("INVALID_TYPE", "$.pathMappings", "pathMappings must be an array.");
332
362
  }
@@ -372,7 +402,7 @@ function validateCriterion(value, path) {
372
402
  return success(value);
373
403
  }
374
404
  export function validateTask(value) {
375
- const root = validateRoot(value, ["criteria", "id", "schemaVersion", "title"]);
405
+ const root = validateRoot(value, ["criteria", "id", "schemaVersion", "title"], TASK_SCHEMA_VERSION);
376
406
  if (isFailure(root)) {
377
407
  return root;
378
408
  }
@@ -433,7 +463,7 @@ export function validateEvidence(value) {
433
463
  "taskId",
434
464
  "testCount",
435
465
  "toolVersions"
436
- ]);
466
+ ], EVIDENCE_SCHEMA_VERSION);
437
467
  if (isFailure(root)) {
438
468
  return root;
439
469
  }
@@ -556,22 +586,18 @@ function validateManagedHook(value, path) {
556
586
  return success(value);
557
587
  }
558
588
  export function validateManifest(value) {
559
- const root = validateRoot(value, [
560
- "artifacts",
561
- "harness",
562
- "hooks",
563
- "markers",
564
- "schemaVersion",
565
- "scope"
566
- ]);
589
+ const root = validateRoot(value, ["artifacts", "harness", "hooks", "markers", "schemaVersion", "scope"], MANIFEST_SCHEMA_VERSION);
567
590
  if (isFailure(root)) {
568
591
  return root;
569
592
  }
570
593
  if (typeof root.scope !== "string" || !SCOPE_VALUES.has(root.scope)) {
571
594
  return failure("INVALID_SCOPE", "$.scope", "Unsupported install scope.");
572
595
  }
573
- if (typeof root.harness !== "string" || !HARNESS_VALUES.has(root.harness)) {
574
- return failure("INVALID_HARNESS", "$.harness", "Unsupported harness.");
596
+ if (!Array.isArray(root.harness) ||
597
+ root.harness.length === 0 ||
598
+ !root.harness.every((id) => typeof id === "string" && HARNESS_VALUES.has(id)) ||
599
+ new Set(root.harness).size !== root.harness.length) {
600
+ return failure("INVALID_HARNESS", "$.harness", "Harness must be a non-empty list of unique supported harnesses.");
575
601
  }
576
602
  if (!Array.isArray(root.artifacts) || !Array.isArray(root.markers)) {
577
603
  return failure("INVALID_TYPE", "$.artifacts", "artifacts and markers must be arrays.");
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { SCHEMA_VERSION } from "../contracts.js";
2
+ import { TASK_SCHEMA_VERSION } from "../contracts.js";
3
3
  import { AgentOpsError } from "../fs/paths.js";
4
4
  import { validateTask } from "../schema/validate.js";
5
5
  import { renderTaskMarkdown } from "./render.js";
@@ -74,7 +74,7 @@ export class TaskService {
74
74
  }
75
75
  async create(input) {
76
76
  const task = {
77
- schemaVersion: SCHEMA_VERSION,
77
+ schemaVersion: TASK_SCHEMA_VERSION,
78
78
  id: this.#generateId(),
79
79
  title: input.title,
80
80
  criteria: [...input.criteria]
@@ -117,7 +117,7 @@ export class TaskService {
117
117
  const state = await this.#store.read();
118
118
  if (query.taskId !== undefined) {
119
119
  return cloneRecord(findTask({
120
- schemaVersion: 1,
120
+ schemaVersion: TASK_SCHEMA_VERSION,
121
121
  tasks: [...state.tasks],
122
122
  sessions: [...state.sessions]
123
123
  }, query.taskId));
@@ -1,4 +1,5 @@
1
1
  import { lstat } from "node:fs/promises";
2
+ import { TASK_SCHEMA_VERSION } from "../contracts.js";
2
3
  import { AgentOpsError } from "../fs/paths.js";
3
4
  import { validateTask } from "../schema/validate.js";
4
5
  import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
@@ -154,7 +155,11 @@ function parseSession(value) {
154
155
  }
155
156
  function parseState(source) {
156
157
  if (source === null) {
157
- return { schemaVersion: 1, tasks: [], sessions: [] };
158
+ return {
159
+ schemaVersion: TASK_SCHEMA_VERSION,
160
+ tasks: [],
161
+ sessions: []
162
+ };
158
163
  }
159
164
  let value;
160
165
  try {
@@ -165,7 +170,7 @@ function parseState(source) {
165
170
  }
166
171
  if (!isRecord(value) ||
167
172
  !hasExactKeys(value, ["schemaVersion", "sessions", "tasks"]) ||
168
- value.schemaVersion !== 1 ||
173
+ value.schemaVersion !== TASK_SCHEMA_VERSION ||
169
174
  !Array.isArray(value.tasks) ||
170
175
  !Array.isArray(value.sessions)) {
171
176
  return invalidState("Task state has an unsupported structure.");
@@ -184,7 +189,11 @@ function parseState(source) {
184
189
  })) {
185
190
  return invalidState("Session attachments must reference known tasks.");
186
191
  }
187
- return { schemaVersion: 1, tasks, sessions };
192
+ return {
193
+ schemaVersion: TASK_SCHEMA_VERSION,
194
+ tasks,
195
+ sessions
196
+ };
188
197
  }
189
198
  function positiveMaxBytes(value) {
190
199
  if (value === undefined) {