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