@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
@@ -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) {
@@ -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({
@@ -88,7 +88,9 @@ export async function createUpdatePlan(options) {
88
88
  "manifest",
89
89
  "markers"
90
90
  ]) {
91
- if (statusOf(report, id) !== "PASS") {
91
+ const status = statusOf(report, id);
92
+ if (status !== "PASS" &&
93
+ !(id === "markers" && status === "DEGRADED")) {
92
94
  throw new AgentOpsError("UPDATE_INSTALLATION_INVALID", `Update requires a passing ${id} doctor check.`);
93
95
  }
94
96
  }
@@ -103,13 +105,17 @@ export async function createUpdatePlan(options) {
103
105
  const installation = await createInstallPlan({
104
106
  root: options.root,
105
107
  scope: report.manifest.scope,
106
- harness: report.manifest.harness,
108
+ harness: options.harness ?? report.manifest.harness,
107
109
  profiles: configPreview.migrated.profiles,
108
110
  adapters: options.adapters,
109
111
  toolkitVersion: targetVersion,
112
+ allowHarnessChange: true,
110
113
  ...(options.hookRuntimePath === undefined
111
114
  ? {}
112
115
  : { hookRuntimePath: options.hookRuntimePath }),
116
+ ...(options.hookTargets === undefined
117
+ ? {}
118
+ : { hookTargets: options.hookTargets }),
113
119
  existingConfig: {
114
120
  value: configPreview.migrated,
115
121
  sourceHash: configPreview.sourceHash
@@ -1,11 +1,12 @@
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
5
  const PROFILE_VALUES = new Set(["advisory", "core", "guardrails"]);
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",
@@ -38,12 +39,12 @@ function unknownFieldFailure(value, allowed, path) {
38
39
  ? undefined
39
40
  : failure("UNKNOWN_FIELD", `${path}.${unknown}`, `Unknown field: ${unknown}`);
40
41
  }
41
- function validateRoot(value, allowed) {
42
+ function validateRoot(value, allowed, expectedVersion) {
42
43
  if (!isRecord(value)) {
43
44
  return failure("INVALID_TYPE", "$", "Expected an object.");
44
45
  }
45
- if (value.schemaVersion !== SCHEMA_VERSION) {
46
- return failure("SCHEMA_VERSION_UNSUPPORTED", "$.schemaVersion", `Expected schemaVersion ${SCHEMA_VERSION}.`);
46
+ if (value.schemaVersion !== expectedVersion) {
47
+ return failure("SCHEMA_VERSION_UNSUPPORTED", "$.schemaVersion", `Expected schemaVersion ${expectedVersion}.`);
47
48
  }
48
49
  return unknownFieldFailure(value, allowed, "$") ?? value;
49
50
  }
@@ -277,12 +278,13 @@ function validateSecurityException(value, path) {
277
278
  }
278
279
  export function validateConfig(value) {
279
280
  const root = validateRoot(value, [
281
+ "features",
280
282
  "pathMappings",
281
283
  "profiles",
282
284
  "schemaVersion",
283
285
  "securityExceptions",
284
286
  "verification"
285
- ]);
287
+ ], CONFIG_SCHEMA_VERSION);
286
288
  if (isFailure(root)) {
287
289
  return root;
288
290
  }
@@ -306,6 +308,23 @@ export function validateConfig(value) {
306
308
  if (!hasUniqueStrings(root.profiles)) {
307
309
  return failure("DUPLICATE_ID", "$.profiles", "Profiles must be unique.");
308
310
  }
311
+ if (!isRecord(root.features)) {
312
+ return failure("INVALID_TYPE", "$.features", "features must be an object.");
313
+ }
314
+ const featuresUnknown = unknownFieldFailure(root.features, ["stopVerification"], "$.features");
315
+ if (featuresUnknown !== undefined) {
316
+ return featuresUnknown;
317
+ }
318
+ if (!isRecord(root.features.stopVerification)) {
319
+ return failure("INVALID_TYPE", "$.features.stopVerification", "stopVerification must be an object.");
320
+ }
321
+ const stopVerificationUnknown = unknownFieldFailure(root.features.stopVerification, ["enabled"], "$.features.stopVerification");
322
+ if (stopVerificationUnknown !== undefined) {
323
+ return stopVerificationUnknown;
324
+ }
325
+ if (typeof root.features.stopVerification.enabled !== "boolean") {
326
+ return failure("INVALID_FEATURE", "$.features.stopVerification.enabled", "stopVerification.enabled must be a boolean.");
327
+ }
309
328
  if (!isRecord(root.verification)) {
310
329
  return failure("INVALID_TYPE", "$.verification", "verification must be an object.");
311
330
  }
@@ -327,6 +346,10 @@ export function validateConfig(value) {
327
346
  }
328
347
  commandIds.add(command.value.id);
329
348
  }
349
+ if (root.features.stopVerification.enabled === true &&
350
+ root.verification.commands.length === 0) {
351
+ return failure("STOP_VERIFICATION_COMMANDS_REQUIRED", "$.features.stopVerification.enabled", "Stop verification requires at least one verification command.");
352
+ }
330
353
  if (!Array.isArray(root.pathMappings)) {
331
354
  return failure("INVALID_TYPE", "$.pathMappings", "pathMappings must be an array.");
332
355
  }
@@ -372,7 +395,7 @@ function validateCriterion(value, path) {
372
395
  return success(value);
373
396
  }
374
397
  export function validateTask(value) {
375
- const root = validateRoot(value, ["criteria", "id", "schemaVersion", "title"]);
398
+ const root = validateRoot(value, ["criteria", "id", "schemaVersion", "title"], TASK_SCHEMA_VERSION);
376
399
  if (isFailure(root)) {
377
400
  return root;
378
401
  }
@@ -433,7 +456,7 @@ export function validateEvidence(value) {
433
456
  "taskId",
434
457
  "testCount",
435
458
  "toolVersions"
436
- ]);
459
+ ], EVIDENCE_SCHEMA_VERSION);
437
460
  if (isFailure(root)) {
438
461
  return root;
439
462
  }
@@ -556,22 +579,18 @@ function validateManagedHook(value, path) {
556
579
  return success(value);
557
580
  }
558
581
  export function validateManifest(value) {
559
- const root = validateRoot(value, [
560
- "artifacts",
561
- "harness",
562
- "hooks",
563
- "markers",
564
- "schemaVersion",
565
- "scope"
566
- ]);
582
+ const root = validateRoot(value, ["artifacts", "harness", "hooks", "markers", "schemaVersion", "scope"], MANIFEST_SCHEMA_VERSION);
567
583
  if (isFailure(root)) {
568
584
  return root;
569
585
  }
570
586
  if (typeof root.scope !== "string" || !SCOPE_VALUES.has(root.scope)) {
571
587
  return failure("INVALID_SCOPE", "$.scope", "Unsupported install scope.");
572
588
  }
573
- if (typeof root.harness !== "string" || !HARNESS_VALUES.has(root.harness)) {
574
- return failure("INVALID_HARNESS", "$.harness", "Unsupported harness.");
589
+ if (!Array.isArray(root.harness) ||
590
+ root.harness.length === 0 ||
591
+ !root.harness.every((id) => typeof id === "string" && HARNESS_VALUES.has(id)) ||
592
+ new Set(root.harness).size !== root.harness.length) {
593
+ return failure("INVALID_HARNESS", "$.harness", "Harness must be a non-empty list of unique supported harnesses.");
575
594
  }
576
595
  if (!Array.isArray(root.artifacts) || !Array.isArray(root.markers)) {
577
596
  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) {
@@ -0,0 +1,113 @@
1
+ import { runVerificationCommand } from "./spawn.js";
2
+ import { evaluateTestCount, parseTestCount } from "./test-count.js";
3
+ function classifyTestCountCode(code) {
4
+ const classes = {
5
+ TEST_COUNT_BELOW_MINIMUM: "test-count-below-minimum",
6
+ TEST_COUNT_INVALID: "test-count-invalid",
7
+ TEST_COUNT_OK: "none",
8
+ TEST_COUNT_REQUIREMENT_INVALID: "test-count-requirement-invalid",
9
+ TEST_COUNT_UNPARSEABLE: "test-count-unparseable",
10
+ ZERO_TESTS: "zero-tests"
11
+ };
12
+ return classes[code];
13
+ }
14
+ function untrustedResult(commandId) {
15
+ return {
16
+ commandId,
17
+ status: "UNKNOWN",
18
+ failureClass: "repository-untrusted",
19
+ exitCode: null,
20
+ signal: null,
21
+ timedOut: false,
22
+ durationMs: 0,
23
+ stdout: "",
24
+ stderr: "",
25
+ stdoutTruncated: false,
26
+ stderrTruncated: false
27
+ };
28
+ }
29
+ function classifyCommand(command, spawned) {
30
+ if (command.evidence.kind === "file") {
31
+ return {
32
+ status: spawned.status === "PASS" ? "UNKNOWN" : spawned.status,
33
+ failureClass: spawned.status === "PASS"
34
+ ? "file-evidence-unsupported"
35
+ : spawned.failureClass,
36
+ testCount: null
37
+ };
38
+ }
39
+ if (command.evidence.kind !== "test-count") {
40
+ return {
41
+ status: spawned.status,
42
+ failureClass: spawned.failureClass,
43
+ testCount: null
44
+ };
45
+ }
46
+ const testCount = parseTestCount(`${spawned.stdout}\n${spawned.stderr}`);
47
+ if (spawned.status !== "PASS") {
48
+ return {
49
+ status: spawned.status,
50
+ failureClass: spawned.failureClass,
51
+ testCount
52
+ };
53
+ }
54
+ const evaluation = evaluateTestCount(testCount, command.evidence.minimum);
55
+ return {
56
+ status: evaluation.status,
57
+ failureClass: classifyTestCountCode(evaluation.code),
58
+ testCount: evaluation.testCount
59
+ };
60
+ }
61
+ function diagnostic(spawned) {
62
+ return spawned.stderr || spawned.stdout || spawned.failureClass;
63
+ }
64
+ export async function executeConfiguredCommand(command, options) {
65
+ let spawned;
66
+ if (!options.trusted) {
67
+ spawned = untrustedResult(command.id);
68
+ }
69
+ else {
70
+ const runOptions = {
71
+ cwd: options.cwd,
72
+ ...(options.runner === undefined
73
+ ? {}
74
+ : { runner: options.runner }),
75
+ ...(options.env === undefined ? {} : { env: options.env }),
76
+ ...(options.now === undefined ? {} : { now: options.now }),
77
+ ...(options.outputLimitBytes === undefined
78
+ ? {}
79
+ : { outputLimitBytes: options.outputLimitBytes }),
80
+ ...(options.terminationGraceMs === undefined
81
+ ? {}
82
+ : { terminationGraceMs: options.terminationGraceMs })
83
+ };
84
+ spawned = await runVerificationCommand(command, runOptions);
85
+ }
86
+ const classified = classifyCommand(command, spawned);
87
+ return {
88
+ commandId: command.id,
89
+ required: command.required,
90
+ status: classified.status,
91
+ failureClass: classified.failureClass,
92
+ exitCode: spawned.exitCode,
93
+ signal: spawned.signal,
94
+ timedOut: spawned.timedOut,
95
+ testCount: classified.testCount,
96
+ diagnostic: diagnostic(spawned),
97
+ stdout: spawned.stdout,
98
+ stderr: spawned.stderr,
99
+ stdoutTruncated: spawned.stdoutTruncated,
100
+ stderrTruncated: spawned.stderrTruncated
101
+ };
102
+ }
103
+ export function aggregateVerificationStatus(results) {
104
+ const required = results.filter((result) => result.required);
105
+ const gating = required.length > 0 ? required : results;
106
+ if (gating.some((result) => result.status === "FAIL")) {
107
+ return "FAIL";
108
+ }
109
+ if (gating.some((result) => result.status === "UNKNOWN")) {
110
+ return "UNKNOWN";
111
+ }
112
+ return "PASS";
113
+ }