@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.
- package/README.md +88 -13
- package/dist/packages/cli/src/args.js +39 -6
- package/dist/packages/cli/src/bin.js +33 -11
- package/dist/packages/cli/src/cli.js +6 -1
- package/dist/packages/cli/src/commands/doctor.js +31 -9
- package/dist/packages/cli/src/commands/hook.js +18 -16
- package/dist/packages/cli/src/commands/init.js +9 -5
- package/dist/packages/cli/src/commands/review.js +5 -1
- package/dist/packages/cli/src/commands/uninstall.js +6 -5
- package/dist/packages/cli/src/commands/update.js +13 -5
- package/dist/packages/cli/src/context.js +9 -3
- package/dist/packages/cli/src/hook-process.js +109 -7
- package/dist/packages/cli/src/plan-output.js +9 -4
- package/dist/packages/cli/src/public-plan.js +62 -0
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/cli/src/wizard.js +27 -11
- package/dist/runtime/src/adapters/claude/config.js +0 -8
- package/dist/runtime/src/adapters/claude/events.js +26 -0
- package/dist/runtime/src/adapters/claude/output.js +6 -6
- package/dist/runtime/src/adapters/claude/surfaces.js +70 -0
- package/dist/runtime/src/adapters/codex/config.js +29 -11
- package/dist/runtime/src/adapters/codex/events.js +26 -0
- package/dist/runtime/src/adapters/codex/output.js +10 -0
- package/dist/runtime/src/adapters/codex/surfaces.js +12 -0
- package/dist/runtime/src/adapters/opencode/config.js +170 -0
- package/dist/runtime/src/adapters/opencode/events.js +49 -0
- package/dist/runtime/src/adapters/opencode/input.js +32 -0
- package/dist/runtime/src/adapters/opencode/output.js +23 -0
- package/dist/runtime/src/adapters/opencode/surfaces.js +23 -0
- package/dist/runtime/src/config/explain.js +7 -0
- package/dist/runtime/src/config/hash.js +24 -0
- package/dist/runtime/src/config/merge.js +6 -2
- package/dist/runtime/src/config/migrate.js +19 -4
- package/dist/runtime/src/contracts.js +10 -1
- package/dist/runtime/src/fs/manifest.js +32 -1
- package/dist/runtime/src/fs/transaction.js +14 -2
- package/dist/runtime/src/hooks/advisory.js +16 -0
- package/dist/runtime/src/hooks/stop-service.js +70 -0
- package/dist/runtime/src/hooks/stop-verify.js +4 -1
- package/dist/runtime/src/install/doctor.js +119 -9
- package/dist/runtime/src/install/harness.js +296 -35
- package/dist/runtime/src/install/hooks.js +22 -17
- package/dist/runtime/src/install/ownership.js +110 -34
- package/dist/runtime/src/install/plan.js +207 -28
- package/dist/runtime/src/install/probes.js +9 -43
- package/dist/runtime/src/install/profiles.js +8 -1
- package/dist/runtime/src/install/surface-inspection.js +296 -0
- package/dist/runtime/src/install/surfaces.js +11 -0
- package/dist/runtime/src/install/uninstall.js +10 -3
- package/dist/runtime/src/install/update.js +8 -2
- package/dist/runtime/src/schema/validate.js +37 -18
- package/dist/runtime/src/task/service.js +3 -3
- package/dist/runtime/src/task/store.js +12 -3
- package/dist/runtime/src/verify/command-executor.js +113 -0
- package/dist/runtime/src/verify/evidence.js +4 -24
- package/dist/runtime/src/verify/service.js +20 -92
- package/dist/runtime/src/verify/spawn.js +6 -1
- package/docs/en/guides/configuration.md +83 -0
- package/docs/en/guides/quickstart.md +9 -0
- package/docs/en/guides/security.md +5 -0
- package/docs/en/spec/README.md +9 -0
- package/docs/en/spec/harness-adapters.md +66 -2
- package/docs/en/spec/maintenance.md +11 -0
- package/docs/en/spec/review.md +11 -0
- package/docs/zh-TW/guides/configuration.md +77 -0
- package/docs/zh-TW/guides/quickstart.md +9 -0
- package/docs/zh-TW/guides/security.md +5 -0
- package/docs/zh-TW/spec/README.md +9 -0
- package/docs/zh-TW/spec/harness-adapters.md +57 -3
- package/docs/zh-TW/spec/maintenance.md +11 -1
- package/docs/zh-TW/spec/review.md +10 -0
- package/package.json +4 -2
- package/schemas/config.schema.json +55 -1
- package/schemas/manifest.schema.json +7 -2
- package/templates/common/AGENTS.block.md +2 -1
- package/templates/common/CLAUDE.block.md +2 -1
- package/dist/runtime/src/review/claude-runner.js +0 -4
- package/dist/runtime/src/review/codex-runner.js +0 -4
|
@@ -5,11 +5,17 @@ import { parseInstallManifest, PROJECT_MANIFEST_PATH } from "../fs/manifest.js";
|
|
|
5
5
|
import { resolveContainedPath } from "../fs/paths.js";
|
|
6
6
|
import { validateConfig } from "../schema/validate.js";
|
|
7
7
|
import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
|
|
8
|
+
import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
|
|
9
|
+
import { harnessDescriptor } from "./harness.js";
|
|
10
|
+
import { resolveProfiles } from "./profiles.js";
|
|
11
|
+
import { inspectHarnessSurfaces, inspectHarnessRegistrations } from "./surface-inspection.js";
|
|
8
12
|
const CONFIG_PATH = ".agent-ops/config.json";
|
|
9
13
|
const MINIMUM_NODE_VERSION = [22, 14, 0];
|
|
10
14
|
const MAX_DOCTOR_FILE_BYTES = 1024 * 1024;
|
|
11
|
-
function check(id, status, message) {
|
|
12
|
-
return
|
|
15
|
+
function check(id, status, message, code) {
|
|
16
|
+
return code === undefined
|
|
17
|
+
? { id, status, message }
|
|
18
|
+
: { id, status, message, code };
|
|
13
19
|
}
|
|
14
20
|
function parseNodeVersion(version) {
|
|
15
21
|
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version);
|
|
@@ -80,7 +86,7 @@ async function readContainedText(root, path) {
|
|
|
80
86
|
async function checkManifest(root) {
|
|
81
87
|
try {
|
|
82
88
|
const manifest = parseInstallManifest(await readContainedText(root, PROJECT_MANIFEST_PATH));
|
|
83
|
-
assertSupportedManifestOwnership(manifest);
|
|
89
|
+
assertSupportedManifestOwnership(manifest, root);
|
|
84
90
|
return {
|
|
85
91
|
check: check("manifest", "PASS", "Installation manifest is valid."),
|
|
86
92
|
manifest
|
|
@@ -124,7 +130,9 @@ async function checkArtifacts(root, manifest) {
|
|
|
124
130
|
for (const artifact of manifest.artifacts) {
|
|
125
131
|
try {
|
|
126
132
|
const content = await readContained(root, artifact.path);
|
|
127
|
-
if (sha256(content) !== artifact.hash
|
|
133
|
+
if (sha256(content) !== artifact.hash ||
|
|
134
|
+
(artifact.id === "opencode-plugin" &&
|
|
135
|
+
!isOpencodeManagedPlugin(content.toString("utf8")))) {
|
|
128
136
|
failures.push(artifact.path);
|
|
129
137
|
}
|
|
130
138
|
}
|
|
@@ -141,7 +149,8 @@ async function checkMarkers(root, manifest) {
|
|
|
141
149
|
return check("markers", "FAIL", "Managed blocks cannot be verified without a valid manifest.");
|
|
142
150
|
}
|
|
143
151
|
const failures = [];
|
|
144
|
-
const
|
|
152
|
+
const legacyPaths = [];
|
|
153
|
+
const expectedMarkers = assertSupportedManifestOwnership(manifest, root);
|
|
145
154
|
for (const marker of manifest.markers) {
|
|
146
155
|
try {
|
|
147
156
|
const source = await readContainedText(root, marker.path);
|
|
@@ -150,15 +159,22 @@ async function checkMarkers(root, manifest) {
|
|
|
150
159
|
failures.push(marker.path);
|
|
151
160
|
continue;
|
|
152
161
|
}
|
|
153
|
-
assertExpectedManagedBlock(source, marker, expected);
|
|
162
|
+
const match = assertExpectedManagedBlock(source, marker, expected);
|
|
163
|
+
if (match === "legacy") {
|
|
164
|
+
legacyPaths.push(marker.path);
|
|
165
|
+
}
|
|
154
166
|
}
|
|
155
167
|
catch {
|
|
156
168
|
failures.push(marker.path);
|
|
157
169
|
}
|
|
158
170
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
171
|
+
if (failures.length > 0) {
|
|
172
|
+
return check("markers", "FAIL", `Managed block markers failed verification: ${failures.join(", ")}.`);
|
|
173
|
+
}
|
|
174
|
+
if (legacyPaths.length > 0) {
|
|
175
|
+
return check("markers", "DEGRADED", `Legacy managed routing blocks need migration: ${legacyPaths.join(", ")}.`);
|
|
176
|
+
}
|
|
177
|
+
return check("markers", "PASS", "All managed block markers are intact.");
|
|
162
178
|
}
|
|
163
179
|
async function checkProbe(id, probe) {
|
|
164
180
|
if (probe === undefined) {
|
|
@@ -177,21 +193,115 @@ async function checkProbe(id, probe) {
|
|
|
177
193
|
return check(id, "FAIL", "Probe failed.");
|
|
178
194
|
}
|
|
179
195
|
}
|
|
196
|
+
function checkLifecycleSummary(manifest, config) {
|
|
197
|
+
if (manifest === undefined || config === undefined) {
|
|
198
|
+
return check("lifecycle-summary", "UNKNOWN", "Lifecycle summary cannot be assessed without a valid manifest and configuration.");
|
|
199
|
+
}
|
|
200
|
+
const hasLifecycleSummary = config.profiles.length > 0 &&
|
|
201
|
+
resolveProfiles(config.profiles).capabilities.includes("lifecycle-summary");
|
|
202
|
+
if (!hasLifecycleSummary) {
|
|
203
|
+
return check("lifecycle-summary", "PASS", "Lifecycle summary has no harness-specific degradation.");
|
|
204
|
+
}
|
|
205
|
+
const registrations = manifest.harness.map((id) => ({
|
|
206
|
+
id,
|
|
207
|
+
registration: harnessDescriptor(id).control.registrations.find((candidate) => candidate.capability === "lifecycle-summary")
|
|
208
|
+
}));
|
|
209
|
+
const missing = registrations
|
|
210
|
+
.filter(({ registration }) => registration === undefined)
|
|
211
|
+
.map(({ id }) => id);
|
|
212
|
+
if (missing.length > 0) {
|
|
213
|
+
return check("lifecycle-summary", "UNKNOWN", `Lifecycle summary registration is missing for ${missing.join(", ")}.`);
|
|
214
|
+
}
|
|
215
|
+
const unsupported = registrations
|
|
216
|
+
.filter(({ registration }) => registration?.support === "unsupported")
|
|
217
|
+
.map(({ id }) => id);
|
|
218
|
+
if (unsupported.length > 0) {
|
|
219
|
+
return check("lifecycle-summary", "UNSUPPORTED", `Lifecycle summary is not dispatched for ${unsupported.join(", ")}; advisory runtime wiring is unavailable.`);
|
|
220
|
+
}
|
|
221
|
+
const degraded = registrations
|
|
222
|
+
.filter(({ registration }) => registration?.support === "degraded")
|
|
223
|
+
.map(({ id }) => id);
|
|
224
|
+
if (degraded.length > 0) {
|
|
225
|
+
return check("lifecycle-summary", "DEGRADED", `Lifecycle summary is degraded for ${degraded.join(", ")}.`);
|
|
226
|
+
}
|
|
227
|
+
const unknown = registrations
|
|
228
|
+
.filter(({ registration }) => registration?.support === "unknown")
|
|
229
|
+
.map(({ id }) => id);
|
|
230
|
+
if (unknown.length > 0) {
|
|
231
|
+
return check("lifecycle-summary", "UNKNOWN", `Lifecycle summary support is unknown for ${unknown.join(", ")}.`);
|
|
232
|
+
}
|
|
233
|
+
return check("lifecycle-summary", "PASS", "Lifecycle summary is reachable for every selected harness.");
|
|
234
|
+
}
|
|
235
|
+
async function checkSurfaceInventory(root, manifest, config) {
|
|
236
|
+
if (manifest === undefined || config === undefined) {
|
|
237
|
+
return {
|
|
238
|
+
check: check("surface-inventory", "UNKNOWN", "Harness surfaces cannot be inventoried without a valid manifest and configuration."),
|
|
239
|
+
surfaces: []
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
const surfaces = await inspectHarnessSurfaces({
|
|
244
|
+
root,
|
|
245
|
+
scope: manifest.scope,
|
|
246
|
+
harness: manifest.harness,
|
|
247
|
+
profiles: config.profiles
|
|
248
|
+
});
|
|
249
|
+
const unknown = surfaces.filter(({ status }) => status === "unknown");
|
|
250
|
+
return {
|
|
251
|
+
check: check("surface-inventory", unknown.length > 0 ? "UNKNOWN" : "PASS", unknown.length > 0
|
|
252
|
+
? `${unknown.length} harness surface(s) could not be inspected.`
|
|
253
|
+
: "Harness surfaces were inventoried without exposing settings values."),
|
|
254
|
+
surfaces
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return {
|
|
259
|
+
check: check("surface-inventory", "UNKNOWN", "Harness surfaces could not be inspected safely."),
|
|
260
|
+
surfaces: []
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async function checkRegistrationDrift(root, manifest, config) {
|
|
265
|
+
if (manifest === undefined || config === undefined) {
|
|
266
|
+
return check("registration-drift", "UNKNOWN", "Hook registration drift cannot be assessed without a valid manifest and configuration.");
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const statuses = await inspectHarnessRegistrations({
|
|
270
|
+
root,
|
|
271
|
+
manifest,
|
|
272
|
+
config
|
|
273
|
+
});
|
|
274
|
+
const drifted = statuses
|
|
275
|
+
.filter(({ registered }) => !registered)
|
|
276
|
+
.map(({ harness }) => harness);
|
|
277
|
+
return drifted.length === 0
|
|
278
|
+
? check("registration-drift", "PASS", "Managed hook registrations match the desired capabilities.")
|
|
279
|
+
: check("registration-drift", "FAIL", `Hook registration drift detected for ${drifted.join(", ")}; run agent-ops update.`, "UPDATE_REQUIRED");
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return check("registration-drift", "UNKNOWN", "Hook registration drift could not be assessed safely.");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
180
285
|
export async function doctorInstallation(options) {
|
|
181
286
|
const manifest = await checkManifest(options.root);
|
|
182
287
|
const config = await checkConfig(options.root);
|
|
288
|
+
const surfaceInventory = await checkSurfaceInventory(options.root, manifest.manifest, config.config);
|
|
183
289
|
const checks = [
|
|
184
290
|
checkNodeVersion(options.nodeVersion ?? process.versions.node),
|
|
185
291
|
manifest.check,
|
|
186
292
|
config.check,
|
|
187
293
|
await checkArtifacts(options.root, manifest.manifest),
|
|
188
294
|
await checkMarkers(options.root, manifest.manifest),
|
|
295
|
+
surfaceInventory.check,
|
|
296
|
+
await checkRegistrationDrift(options.root, manifest.manifest, config.config),
|
|
189
297
|
await checkProbe("hook-registration", options.probes?.hookRegistration),
|
|
298
|
+
checkLifecycleSummary(manifest.manifest, config.config),
|
|
190
299
|
await checkProbe("repository-trust", options.probes?.repositoryTrust),
|
|
191
300
|
await checkProbe("smoke-availability", options.probes?.smokeAvailability)
|
|
192
301
|
];
|
|
193
302
|
return {
|
|
194
303
|
checks,
|
|
304
|
+
surfaces: surfaceInventory.surfaces,
|
|
195
305
|
...(manifest.manifest === undefined
|
|
196
306
|
? {}
|
|
197
307
|
: { manifest: manifest.manifest }),
|
|
@@ -1,8 +1,235 @@
|
|
|
1
|
+
import { buildClaudeHookSettings, mergeClaudeSettings, stripClaudeManagedHooks } from "../adapters/claude/config.js";
|
|
2
|
+
import { CLAUDE_CAPABILITY_REGISTRATIONS } from "../adapters/claude/events.js";
|
|
3
|
+
import { normalizeClaudeHookInput } from "../adapters/claude/input.js";
|
|
4
|
+
import { claudeHookOutput } from "../adapters/claude/output.js";
|
|
5
|
+
import { claudeSurfaces } from "../adapters/claude/surfaces.js";
|
|
6
|
+
import { buildCodexHookConfig, CODEX_MANAGED_MARKER, mergeCodexHookConfig, stripCodexManagedHooks } from "../adapters/codex/config.js";
|
|
7
|
+
import { CODEX_CAPABILITY_REGISTRATIONS } from "../adapters/codex/events.js";
|
|
8
|
+
import { normalizeCodexHookInput } from "../adapters/codex/input.js";
|
|
9
|
+
import { codexHookOutput } from "../adapters/codex/output.js";
|
|
10
|
+
import { codexSurfaces } from "../adapters/codex/surfaces.js";
|
|
11
|
+
import { buildOpencodePlugin, isOpencodePluginRegistered, opencodePluginTarget } from "../adapters/opencode/config.js";
|
|
12
|
+
import { OPENCODE_CAPABILITY_REGISTRATIONS } from "../adapters/opencode/events.js";
|
|
13
|
+
import { normalizeOpencodeHookInput } from "../adapters/opencode/input.js";
|
|
14
|
+
import { opencodeHookOutput } from "../adapters/opencode/output.js";
|
|
15
|
+
import { opencodeSurfaces } from "../adapters/opencode/surfaces.js";
|
|
1
16
|
import { AgentOpsError } from "../fs/paths.js";
|
|
2
|
-
|
|
3
|
-
export const
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
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
|
|
32
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
//
|
|
23
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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)
|