@kylecheng3146/agent-ops 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -6
- package/dist/packages/cli/src/args.js +1 -1
- package/dist/packages/cli/src/bin.js +2 -0
- package/dist/packages/cli/src/cli.js +1 -1
- package/dist/packages/cli/src/codex-loop-process.js +70 -0
- package/dist/packages/cli/src/commands/hook.js +16 -1
- package/dist/packages/cli/src/commands/update.js +3 -0
- package/dist/packages/cli/src/context.js +60 -0
- package/dist/packages/cli/src/hook-process.js +128 -15
- package/dist/packages/cli/src/loop-entry.js +8 -0
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/cli/src/wizard.js +9 -4
- package/dist/runtime/src/adapters/claude/config.js +57 -11
- package/dist/runtime/src/adapters/claude/events.js +7 -0
- package/dist/runtime/src/adapters/claude/output.js +2 -1
- package/dist/runtime/src/adapters/codex/config.js +39 -4
- package/dist/runtime/src/adapters/codex/events.js +7 -0
- package/dist/runtime/src/fs/managed-block.js +35 -18
- package/dist/runtime/src/hooks/codex-loop.js +439 -0
- package/dist/runtime/src/install/codex-loop.js +139 -0
- package/dist/runtime/src/install/doctor.js +66 -8
- package/dist/runtime/src/install/harness.js +8 -10
- package/dist/runtime/src/install/ownership.js +37 -2
- package/dist/runtime/src/install/plan.js +70 -4
- package/dist/runtime/src/install/profiles.js +5 -3
- package/dist/runtime/src/install/uninstall.js +1 -1
- package/dist/runtime/src/install/update.js +5 -1
- package/dist/runtime/src/logging/local-log.js +25 -0
- package/dist/runtime/src/schema/validate.js +8 -1
- package/docs/en/guides/configuration.md +78 -2
- package/docs/en/spec/harness-adapters.md +50 -12
- package/docs/zh-TW/guides/configuration.md +73 -5
- package/docs/zh-TW/spec/harness-adapters.md +44 -12
- package/package.json +1 -1
- package/schemas/config.schema.json +1 -1
- package/schemas/manifest.schema.json +12 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildClaudeHookSettings, mergeClaudeSettings, stripClaudeManagedHooks } from "../adapters/claude/config.js";
|
|
1
|
+
import { buildClaudeHookSettings, isClaudeManagedHandler, mergeClaudeSettings, stripClaudeManagedHooks } from "../adapters/claude/config.js";
|
|
2
2
|
import { CLAUDE_CAPABILITY_REGISTRATIONS } from "../adapters/claude/events.js";
|
|
3
3
|
import { normalizeClaudeHookInput } from "../adapters/claude/input.js";
|
|
4
4
|
import { claudeHookOutput } from "../adapters/claude/output.js";
|
|
@@ -20,7 +20,6 @@ export const HARNESS_IDS = [
|
|
|
20
20
|
"claude",
|
|
21
21
|
"opencode"
|
|
22
22
|
];
|
|
23
|
-
const CLAUDE_HOOK_MARKER = "--managed-by=agent-ops";
|
|
24
23
|
function isRecord(value) {
|
|
25
24
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
26
25
|
}
|
|
@@ -60,12 +59,13 @@ function jsonHookRegistered(control, source, capabilities) {
|
|
|
60
59
|
group.hooks.some(control.isManagedHandler)));
|
|
61
60
|
});
|
|
62
61
|
}
|
|
63
|
-
function runtimeFailureResult(capability, registrations) {
|
|
62
|
+
function runtimeFailureResult(capability, registrations, remedy) {
|
|
64
63
|
const runtimeFailure = registrations.find((registration) => registration.capability === capability)?.runtimeFailure;
|
|
65
64
|
return {
|
|
66
65
|
action: runtimeFailure === "fail-closed" ? "block" : "continue",
|
|
67
66
|
status: "UNKNOWN",
|
|
68
|
-
code: `${capability.replaceAll("-", "_").toUpperCase()}_UNAVAILABLE
|
|
67
|
+
code: `${capability.replaceAll("-", "_").toUpperCase()}_UNAVAILABLE`,
|
|
68
|
+
...(remedy === undefined ? {} : { remedy })
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
71
|
function createJsonDescriptor(options) {
|
|
@@ -89,7 +89,7 @@ function createJsonDescriptor(options) {
|
|
|
89
89
|
runtime: {
|
|
90
90
|
normalizeInput: options.normalizeInput,
|
|
91
91
|
formatOutput: options.formatOutput,
|
|
92
|
-
formatRuntimeFailure: (event, capability) => options.formatOutput(event, runtimeFailureResult(capability, options.registrations))
|
|
92
|
+
formatRuntimeFailure: (event, capability, remedy) => options.formatOutput(event, runtimeFailureResult(capability, options.registrations, remedy))
|
|
93
93
|
}
|
|
94
94
|
};
|
|
95
95
|
}
|
|
@@ -136,9 +136,7 @@ const DESCRIPTORS = {
|
|
|
136
136
|
buildHooks: (capabilities, runtimePath) => buildClaudeHookSettings(capabilities, runtimePath),
|
|
137
137
|
mergeHooks: (existing, managed) => mergeClaudeSettings(existing, managed),
|
|
138
138
|
stripHooks: (existing) => stripClaudeManagedHooks(existing),
|
|
139
|
-
isManagedHandler:
|
|
140
|
-
Array.isArray(handler.args) &&
|
|
141
|
-
handler.args.includes(CLAUDE_HOOK_MARKER),
|
|
139
|
+
isManagedHandler: isClaudeManagedHandler,
|
|
142
140
|
registrations: CLAUDE_CAPABILITY_REGISTRATIONS,
|
|
143
141
|
normalizeInput: normalizeClaudeHookInput,
|
|
144
142
|
formatOutput: (event, result) => claudeHookOutput(event, result)
|
|
@@ -158,7 +156,7 @@ const DESCRIPTORS = {
|
|
|
158
156
|
runtime: {
|
|
159
157
|
normalizeInput: normalizeOpencodeHookInput,
|
|
160
158
|
formatOutput: (event, result) => opencodeHookOutput(event, result),
|
|
161
|
-
formatRuntimeFailure: (event, capability) => opencodeHookOutput(event, runtimeFailureResult(capability, OPENCODE_CAPABILITY_REGISTRATIONS))
|
|
159
|
+
formatRuntimeFailure: (event, capability, remedy) => opencodeHookOutput(event, runtimeFailureResult(capability, OPENCODE_CAPABILITY_REGISTRATIONS, remedy))
|
|
162
160
|
}
|
|
163
161
|
}
|
|
164
162
|
};
|
|
@@ -229,7 +227,7 @@ export function resolveHarnessSelection(value) {
|
|
|
229
227
|
}
|
|
230
228
|
export const COMMON_AGENTS_BLOCK = DESCRIPTORS.codex.control.routing.desired;
|
|
231
229
|
export const COMMON_CLAUDE_BLOCK = DESCRIPTORS.claude.control.routing.desired;
|
|
232
|
-
function managedRules(descriptor, context) {
|
|
230
|
+
export function managedRules(descriptor, context) {
|
|
233
231
|
const lines = [
|
|
234
232
|
"# Loop Engineering",
|
|
235
233
|
"",
|
|
@@ -2,9 +2,10 @@ import { applyManagedBlock, managedBlockMarkers } from "../fs/managed-block.js";
|
|
|
2
2
|
import { AgentOpsError } from "../fs/paths.js";
|
|
3
3
|
import { harnessDescriptor, harnessHookPath, routingBlockId, selectHarnessHookSurface, rulesArtifactId } from "./harness.js";
|
|
4
4
|
import { isOpencodePluginPath } from "../adapters/opencode/config.js";
|
|
5
|
+
import { LOOP_MARKER_ID, LOOP_MARKER_VERSION, loopIgnoreContent, loopLauncherArtifactId, loopLauncherPath, selectedLoopHarnesses } from "./codex-loop.js";
|
|
5
6
|
function expectedMarker(manifest, id, markerId) {
|
|
6
7
|
const descriptor = harnessDescriptor(id);
|
|
7
|
-
const markers = managedBlockMarkers(markerId, 1);
|
|
8
|
+
const markers = managedBlockMarkers(markerId, 1, "html");
|
|
8
9
|
return {
|
|
9
10
|
id: markerId,
|
|
10
11
|
path: manifest.scope === "project"
|
|
@@ -12,10 +13,23 @@ function expectedMarker(manifest, id, markerId) {
|
|
|
12
13
|
: `.${id}/${descriptor.control.instructionFile}`,
|
|
13
14
|
startMarker: markers.start,
|
|
14
15
|
endMarker: markers.end,
|
|
16
|
+
markerStyle: "html",
|
|
15
17
|
content: descriptor.control.routing.desired,
|
|
16
18
|
legacyContent: descriptor.control.routing.legacy
|
|
17
19
|
};
|
|
18
20
|
}
|
|
21
|
+
function expectedLoopMarker(manifest) {
|
|
22
|
+
const markers = managedBlockMarkers(LOOP_MARKER_ID, LOOP_MARKER_VERSION, "hash");
|
|
23
|
+
return {
|
|
24
|
+
id: LOOP_MARKER_ID,
|
|
25
|
+
path: ".gitignore",
|
|
26
|
+
startMarker: markers.start,
|
|
27
|
+
endMarker: markers.end,
|
|
28
|
+
markerStyle: "hash",
|
|
29
|
+
content: loopIgnoreContent(manifest.harness),
|
|
30
|
+
legacyContent: []
|
|
31
|
+
};
|
|
32
|
+
}
|
|
19
33
|
function pathKey(path) {
|
|
20
34
|
return path.toLowerCase();
|
|
21
35
|
}
|
|
@@ -64,6 +78,14 @@ export function assertSupportedManifestOwnership(manifest, root) {
|
|
|
64
78
|
]);
|
|
65
79
|
const expectedMarkers = new Map();
|
|
66
80
|
const expectedMarkerPaths = new Set();
|
|
81
|
+
const loopHarnesses = selectedLoopHarnesses(harnesses);
|
|
82
|
+
const hasLoopArtifacts = manifest.artifacts.some(({ id }) => loopHarnesses.some((harness) => id === loopLauncherArtifactId(harness)));
|
|
83
|
+
const hasLoopMarker = manifest.markers.some(({ id }) => id === LOOP_MARKER_ID);
|
|
84
|
+
const hasLoop = hasLoopArtifacts || hasLoopMarker;
|
|
85
|
+
if (hasLoop &&
|
|
86
|
+
(manifest.scope !== "project" || loopHarnesses.length === 0)) {
|
|
87
|
+
throw manifestOwnershipError();
|
|
88
|
+
}
|
|
67
89
|
const recordedOpencodePluginPath = manifest.artifacts.find(({ id }) => id === "opencode-plugin")?.path;
|
|
68
90
|
if (recordedOpencodePluginPath !== undefined &&
|
|
69
91
|
(!harnesses.includes("opencode") ||
|
|
@@ -110,6 +132,18 @@ export function assertSupportedManifestOwnership(manifest, root) {
|
|
|
110
132
|
});
|
|
111
133
|
}
|
|
112
134
|
}
|
|
135
|
+
if (hasLoop) {
|
|
136
|
+
for (const harness of loopHarnesses) {
|
|
137
|
+
const path = loopLauncherPath(harness);
|
|
138
|
+
expectedArtifactPaths.set(pathKey(path), {
|
|
139
|
+
path,
|
|
140
|
+
ids: new Set([loopLauncherArtifactId(harness)])
|
|
141
|
+
});
|
|
142
|
+
requiredArtifactPaths.add(pathKey(path));
|
|
143
|
+
}
|
|
144
|
+
expectedMarkerPaths.add(pathKey(".gitignore"));
|
|
145
|
+
expectedMarkers.set(LOOP_MARKER_ID, expectedLoopMarker(manifest));
|
|
146
|
+
}
|
|
113
147
|
const opencodePluginPath = harnesses.includes("opencode")
|
|
114
148
|
? recordedOpencodePluginPath ??
|
|
115
149
|
harnessHookPath("opencode", manifest.scope, root)
|
|
@@ -170,7 +204,8 @@ export function assertExpectedManagedBlock(source, marker, expected) {
|
|
|
170
204
|
const expectedBlock = applyManagedBlock("", {
|
|
171
205
|
id: expected.id,
|
|
172
206
|
version: 1,
|
|
173
|
-
content
|
|
207
|
+
content,
|
|
208
|
+
markerStyle: expected.markerStyle
|
|
174
209
|
}).replace(/\n$/u, "");
|
|
175
210
|
if (currentBlock === expectedBlock) {
|
|
176
211
|
return kind;
|
|
@@ -7,6 +7,7 @@ import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
|
7
7
|
import { validateConfig } from "../schema/validate.js";
|
|
8
8
|
import { planHarnessContributions, harnessDescriptor, selectHarnessHookSurface } from "./harness.js";
|
|
9
9
|
import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
|
|
10
|
+
import { codexHooksExplicitlyDisabled, loopLauncherArtifactId, loopSeeds, planLoopContribution, selectedLoopHarnesses } from "./codex-loop.js";
|
|
10
11
|
import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
|
|
11
12
|
import { planHookRegistration, planHookRemoval } from "./hooks.js";
|
|
12
13
|
import { resolveCapabilities, resolveProfiles } from "./profiles.js";
|
|
@@ -116,7 +117,7 @@ function assertUniqueContributions(artifacts, blocks) {
|
|
|
116
117
|
}
|
|
117
118
|
const markerBoundaries = new Set();
|
|
118
119
|
for (const block of blocks) {
|
|
119
|
-
const markers = managedBlockMarkers(block.id, block.version);
|
|
120
|
+
const markers = managedBlockMarkers(block.id, block.version, block.markerStyle);
|
|
120
121
|
const key = pathKey(block.path);
|
|
121
122
|
if (ids.has(block.id) ||
|
|
122
123
|
artifactPaths.has(key) ||
|
|
@@ -198,9 +199,53 @@ async function planArtifactRemoval(root, artifact) {
|
|
|
198
199
|
expectedHash: current.hash
|
|
199
200
|
};
|
|
200
201
|
}
|
|
202
|
+
async function planCreateOnceSeeds(root, seeds) {
|
|
203
|
+
const operations = [];
|
|
204
|
+
for (const seed of seeds) {
|
|
205
|
+
const current = await readCurrentFile(root, seed.path);
|
|
206
|
+
if (current === null) {
|
|
207
|
+
operations.push({
|
|
208
|
+
kind: "write",
|
|
209
|
+
path: seed.path,
|
|
210
|
+
content: seed.content,
|
|
211
|
+
expectedHash: null
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return operations;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* User-owned seed files belong to the first loop install for each harness.
|
|
219
|
+
* An existing loop launcher is the durable installation record: it prevents an
|
|
220
|
+
* update from recreating a file a user intentionally removed, while still
|
|
221
|
+
* allowing loop to be enabled later or for a newly added harness.
|
|
222
|
+
*/
|
|
223
|
+
function loopHarnessesNeedingSeeds(harnesses, existingManifest) {
|
|
224
|
+
const existingArtifactIds = new Set(existingManifest?.artifacts.map(({ id }) => id) ?? []);
|
|
225
|
+
return selectedLoopHarnesses(harnesses).filter((harness) => !existingArtifactIds.has(loopLauncherArtifactId(harness)));
|
|
226
|
+
}
|
|
201
227
|
function markerKey(path, id) {
|
|
202
228
|
return `${pathKey(path)}\0${id}`;
|
|
203
229
|
}
|
|
230
|
+
function assertLoopProfileSupport(scope, harness, capabilities) {
|
|
231
|
+
if (!capabilities.includes("project-loop")) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (scope !== "project" ||
|
|
235
|
+
!harness.some((id) => id === "codex" || id === "claude")) {
|
|
236
|
+
throw new AgentOpsError("LOOP_PROFILE_UNSUPPORTED", "The loop profile requires project scope and the Codex or Claude harness.");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async function assertCodexLoopConfiguration(root, harness, capabilities) {
|
|
240
|
+
if (!capabilities.includes("project-loop") ||
|
|
241
|
+
!harness.includes("codex")) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const config = await readCurrentFile(root, ".codex/config.toml");
|
|
245
|
+
if (config !== null && codexHooksExplicitlyDisabled(config.content)) {
|
|
246
|
+
throw new AgentOpsError("CODEX_LOOP_HOOKS_DISABLED", "Codex loop installation requires [features] hooks = true; the existing .codex/config.toml explicitly disables hooks.");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
204
249
|
async function planBlocks(root, blocks, removals = [], expectedMarkers = new Map()) {
|
|
205
250
|
const grouped = new Map();
|
|
206
251
|
for (const block of blocks) {
|
|
@@ -238,7 +283,7 @@ async function planBlocks(root, blocks, removals = [], expectedMarkers = new Map
|
|
|
238
283
|
throw new AgentOpsError("MANIFEST_OWNERSHIP_INVALID", "The manifest contains an unsupported managed block.");
|
|
239
284
|
}
|
|
240
285
|
assertExpectedManagedBlock(content, marker, expected);
|
|
241
|
-
content = removeManagedBlock(content, marker.id);
|
|
286
|
+
content = removeManagedBlock(content, marker.id, expected.markerStyle);
|
|
242
287
|
}
|
|
243
288
|
for (const block of pathBlocks) {
|
|
244
289
|
content = applyManagedBlock(content, block);
|
|
@@ -260,7 +305,7 @@ async function planBlocks(root, blocks, removals = [], expectedMarkers = new Map
|
|
|
260
305
|
});
|
|
261
306
|
}
|
|
262
307
|
for (const block of pathBlocks) {
|
|
263
|
-
const markers = managedBlockMarkers(block.id, block.version);
|
|
308
|
+
const markers = managedBlockMarkers(block.id, block.version, block.markerStyle);
|
|
264
309
|
records.push({
|
|
265
310
|
id: block.id,
|
|
266
311
|
path,
|
|
@@ -282,6 +327,8 @@ export async function createInstallPlan(options) {
|
|
|
282
327
|
const resolved = options.existingConfig === undefined
|
|
283
328
|
? resolveProfiles(options.profiles)
|
|
284
329
|
: resolveCapabilities(options.existingConfig.value);
|
|
330
|
+
assertLoopProfileSupport(options.scope, options.harness, resolved.capabilities);
|
|
331
|
+
await assertCodexLoopConfiguration(options.root, options.harness, resolved.capabilities);
|
|
285
332
|
const existing = await readExistingManifest(options.root);
|
|
286
333
|
assertCompatibleManifest(existing?.manifest ?? null, options.scope, options.harness, options.allowHarnessChange === true);
|
|
287
334
|
const existingOpencodePluginPath = existing?.manifest.artifacts.find(({ id }) => id === "opencode-plugin")?.path;
|
|
@@ -300,7 +347,7 @@ export async function createInstallPlan(options) {
|
|
|
300
347
|
explicitHookTargets.size > 0) {
|
|
301
348
|
throw new AgentOpsError("HOOK_TARGET_REQUIRES_RUNTIME", "An explicit hook target requires a hook runtime path.");
|
|
302
349
|
}
|
|
303
|
-
const
|
|
350
|
+
const baseContribution = await planHarnessContributions(options.harness, {
|
|
304
351
|
root: options.root,
|
|
305
352
|
scope: options.scope,
|
|
306
353
|
profiles: resolved.profiles,
|
|
@@ -315,6 +362,21 @@ export async function createInstallPlan(options) {
|
|
|
315
362
|
? {}
|
|
316
363
|
: { opencodePluginPath: existingOpencodePluginPath })
|
|
317
364
|
}, options.adapters);
|
|
365
|
+
const loopContribution = planLoopContribution({
|
|
366
|
+
scope: options.scope,
|
|
367
|
+
harnesses: options.harness,
|
|
368
|
+
capabilities: resolved.capabilities,
|
|
369
|
+
...(options.hookRuntimePath === undefined
|
|
370
|
+
? {}
|
|
371
|
+
: { hookRuntimePath: options.hookRuntimePath })
|
|
372
|
+
});
|
|
373
|
+
const contribution = {
|
|
374
|
+
artifacts: [
|
|
375
|
+
...baseContribution.artifacts,
|
|
376
|
+
...loopContribution.artifacts
|
|
377
|
+
],
|
|
378
|
+
blocks: [...baseContribution.blocks, ...loopContribution.blocks]
|
|
379
|
+
};
|
|
318
380
|
assertUniqueContributions(contribution.artifacts, contribution.blocks);
|
|
319
381
|
const reconcileExisting = existing !== null;
|
|
320
382
|
const expectedExistingMarkers = reconcileExisting
|
|
@@ -359,6 +421,10 @@ export async function createInstallPlan(options) {
|
|
|
359
421
|
operations.push(planned.operation);
|
|
360
422
|
artifacts.push(planned.record);
|
|
361
423
|
}
|
|
424
|
+
if (resolved.capabilities.includes("project-loop")) {
|
|
425
|
+
const seedHarnesses = loopHarnessesNeedingSeeds(options.harness, existing?.manifest ?? null);
|
|
426
|
+
operations.push(...await planCreateOnceSeeds(options.root, loopSeeds(seedHarnesses)));
|
|
427
|
+
}
|
|
362
428
|
artifacts.push(...preservedArtifacts);
|
|
363
429
|
for (const artifact of artifactsToRemove) {
|
|
364
430
|
operations.push(await planArtifactRemoval(options.root, artifact));
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { AgentOpsError } from "../fs/paths.js";
|
|
2
|
-
const PROFILE_ORDER = ["core", "advisory", "guardrails"];
|
|
2
|
+
const PROFILE_ORDER = ["core", "advisory", "guardrails", "loop"];
|
|
3
3
|
export const PROFILE_CAPABILITIES = {
|
|
4
4
|
core: ["rules", "task", "verify", "review"],
|
|
5
5
|
advisory: ["lifecycle-summary", "local-log"],
|
|
6
|
-
guardrails: ["command-policy"]
|
|
6
|
+
guardrails: ["command-policy"],
|
|
7
|
+
loop: ["project-loop"]
|
|
7
8
|
};
|
|
8
9
|
export function resolveProfiles(inputProfiles) {
|
|
9
10
|
if (inputProfiles.length === 0) {
|
|
10
11
|
throw new AgentOpsError("PROFILE_REQUIRED", "At least one installation profile is required.");
|
|
11
12
|
}
|
|
12
13
|
const selectedProfiles = new Set(inputProfiles);
|
|
13
|
-
if (selectedProfiles.has("guardrails")
|
|
14
|
+
if (selectedProfiles.has("guardrails") ||
|
|
15
|
+
selectedProfiles.has("loop")) {
|
|
14
16
|
selectedProfiles.add("core");
|
|
15
17
|
}
|
|
16
18
|
const profiles = PROFILE_ORDER.filter((profile) => selectedProfiles.has(profile));
|
|
@@ -86,7 +86,7 @@ async function planMarkerFiles(root, markers, expectedMarkers) {
|
|
|
86
86
|
}
|
|
87
87
|
assertExpectedManagedBlock(content, marker, expected);
|
|
88
88
|
try {
|
|
89
|
-
content = removeManagedBlock(content, marker.id);
|
|
89
|
+
content = removeManagedBlock(content, marker.id, expected.markerStyle);
|
|
90
90
|
}
|
|
91
91
|
catch (error) {
|
|
92
92
|
throw new AgentOpsError("MANAGED_BLOCK_CHANGED", `Managed block cannot be removed safely: ${path}`, { cause: error });
|
|
@@ -9,6 +9,7 @@ import { createInstallPlan } from "./plan.js";
|
|
|
9
9
|
const PACKAGE_NAME = "@kylecheng3146/agent-ops";
|
|
10
10
|
const CONFIG_PATH = ".agent-ops/config.json";
|
|
11
11
|
const MAX_UPDATE_CONFIG_BYTES = 1024 * 1024;
|
|
12
|
+
const TOOLKIT_VERSION_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u;
|
|
12
13
|
async function readBoundedConfig(root) {
|
|
13
14
|
const resolvedPath = await resolveContainedPath(root, CONFIG_PATH);
|
|
14
15
|
const before = await lstat(resolvedPath, { bigint: true });
|
|
@@ -82,6 +83,9 @@ export async function createUpdatePlan(options) {
|
|
|
82
83
|
if (targetVersion === undefined) {
|
|
83
84
|
throw new AgentOpsError("UPDATE_TARGET_REQUIRED", "Update requires a target version or an explicit registry client.");
|
|
84
85
|
}
|
|
86
|
+
if (!TOOLKIT_VERSION_PATTERN.test(targetVersion)) {
|
|
87
|
+
throw new AgentOpsError("INVALID_TOOLKIT_VERSION", "Toolkit version must be a valid semantic version.");
|
|
88
|
+
}
|
|
85
89
|
const report = await doctorInstallation({ root: options.root });
|
|
86
90
|
for (const id of [
|
|
87
91
|
"node-version",
|
|
@@ -108,7 +112,7 @@ export async function createUpdatePlan(options) {
|
|
|
108
112
|
harness: options.harness ?? report.manifest.harness,
|
|
109
113
|
profiles: configPreview.migrated.profiles,
|
|
110
114
|
adapters: options.adapters,
|
|
111
|
-
toolkitVersion: targetVersion,
|
|
115
|
+
toolkitVersion: options.toolkitVersion ?? targetVersion,
|
|
112
116
|
allowHarnessChange: true,
|
|
113
117
|
...(options.hookRuntimePath === undefined
|
|
114
118
|
? {}
|
|
@@ -83,6 +83,31 @@ function sanitizeEvent(value) {
|
|
|
83
83
|
result: value.result
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
|
+
if (value.type === "loop-event") {
|
|
87
|
+
if (!hasExactKeys(value, ["code", "event", "outcome", "type"]) ||
|
|
88
|
+
typeof value.code !== "string" ||
|
|
89
|
+
!ID_PATTERN.test(value.code) ||
|
|
90
|
+
![
|
|
91
|
+
"permission-request",
|
|
92
|
+
"post-compact",
|
|
93
|
+
"post-tool-use",
|
|
94
|
+
"pre-compact",
|
|
95
|
+
"pre-tool-use",
|
|
96
|
+
"session-start",
|
|
97
|
+
"subagent-start",
|
|
98
|
+
"subagent-stop",
|
|
99
|
+
"user-prompt-submit"
|
|
100
|
+
].includes(String(value.event)) ||
|
|
101
|
+
!["allowed", "blocked", "observed"].includes(String(value.outcome))) {
|
|
102
|
+
return invalidEvent();
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
type: "loop-event",
|
|
106
|
+
event: value.event,
|
|
107
|
+
outcome: value.outcome,
|
|
108
|
+
code: value.code
|
|
109
|
+
};
|
|
110
|
+
}
|
|
86
111
|
return invalidEvent();
|
|
87
112
|
}
|
|
88
113
|
function serialize(stored) {
|
|
@@ -2,7 +2,7 @@ import { CONFIG_SCHEMA_VERSION, EVIDENCE_SCHEMA_VERSION, MANIFEST_SCHEMA_VERSION
|
|
|
2
2
|
const ID_PATTERN = /^[a-z][a-z0-9-]{0,127}$/;
|
|
3
3
|
const HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
4
4
|
const WINDOWS_RESERVED_SEGMENT = /^(?:aux|com[1-9]|con|lpt[1-9]|nul|prn)(?:\..*)?$/i;
|
|
5
|
-
const PROFILE_VALUES = new Set(["advisory", "core", "guardrails"]);
|
|
5
|
+
const PROFILE_VALUES = new Set(["advisory", "core", "guardrails", "loop"]);
|
|
6
6
|
const EVIDENCE_KINDS = new Set(["exit-code", "file", "test-count"]);
|
|
7
7
|
const SCOPE_VALUES = new Set(["project", "user"]);
|
|
8
8
|
const HARNESS_VALUES = new Set(["claude", "codex", "opencode"]);
|
|
@@ -10,7 +10,14 @@ const HARNESS_VALUES = new Set(["claude", "codex", "opencode"]);
|
|
|
10
10
|
const HOOK_HARNESS_VALUES = new Set(["claude", "codex"]);
|
|
11
11
|
const HOOK_EVENT_VALUES = new Set([
|
|
12
12
|
"SessionStart",
|
|
13
|
+
"UserPromptSubmit",
|
|
13
14
|
"PreToolUse",
|
|
15
|
+
"PermissionRequest",
|
|
16
|
+
"PostToolUse",
|
|
17
|
+
"PreCompact",
|
|
18
|
+
"PostCompact",
|
|
19
|
+
"SubagentStart",
|
|
20
|
+
"SubagentStop",
|
|
14
21
|
"Stop"
|
|
15
22
|
]);
|
|
16
23
|
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
@@ -23,12 +23,83 @@ configured with `$OPENCODE_CONFIG_DIR`, the plugin is placed under its
|
|
|
23
23
|
`plugins/` directory instead. The installer discovers writable harness
|
|
24
24
|
surfaces and applies the selected target policy; use
|
|
25
25
|
`--hook-target <harness>=<surface-id>` when the managed default is not the
|
|
26
|
-
intended surface. Project-local Claude
|
|
26
|
+
intended surface. Project-local Claude hooks use `.claude/settings.json` by
|
|
27
|
+
default; select `.claude/settings.local.json` explicitly when that is the
|
|
28
|
+
intended surface.
|
|
27
29
|
Advisory and guardrail hooks are registered only when the selected profile
|
|
28
30
|
implies them. Advisory runs through the real SessionStart path and is
|
|
29
31
|
fail-open. Claude and Codex lifecycle support is `supported`; OpenCode begins
|
|
30
32
|
at app initialization and is honestly reported as `degraded`.
|
|
31
33
|
|
|
34
|
+
### Project-local loop profile
|
|
35
|
+
|
|
36
|
+
`--profile loop` is an opt-in project-scope profile. Select `codex`, `claude`,
|
|
37
|
+
or both (for example, `--harness codex,claude`); it requires a
|
|
38
|
+
POSIX-compatible `bash` and does not support Windows launchers yet. Start with
|
|
39
|
+
a dry run:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
agent-ops init --dry-run --scope project --harness codex,claude --profile loop --json
|
|
43
|
+
agent-ops init --scope project --harness codex,claude --profile loop --yes
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
For each selected supported harness, agent-ops owns exactly one small launcher:
|
|
47
|
+
`.codex/hooks/agent-ops-loop.sh` or `.claude/hooks/agent-ops-loop.sh`. Both
|
|
48
|
+
launchers delegate to the same installed Node runtime, so they do not copy a
|
|
49
|
+
project-specific loop script. Codex also gets `.codex/config.toml` only when it
|
|
50
|
+
is absent. First installation seeds, without replacing existing content,
|
|
51
|
+
`loop-goal.md`, `loop-state.md`, and `loop-telemetry.jsonl` under the selected
|
|
52
|
+
harness directory. A hash-commented `.gitignore` block ignores those local
|
|
53
|
+
files.
|
|
54
|
+
|
|
55
|
+
The loop runs `SessionStart`, `UserPromptSubmit`, `PreToolUse`,
|
|
56
|
+
`PermissionRequest`, `PostToolUse`, `PreCompact`, `PostCompact`,
|
|
57
|
+
`SubagentStart`, and `SubagentStop`, but never adds `Stop`. It blocks only
|
|
58
|
+
high-confidence literal secrets in prompts or Bash commands, plus dangerous
|
|
59
|
+
Bash commands (including broad recursive deletion and `git reset --hard`). Codex uses its native
|
|
60
|
+
exit-code blocking mechanism; Claude Code receives its documented native JSON
|
|
61
|
+
decision shape. A `PermissionRequest`, including
|
|
62
|
+
`sandbox_permissions: "require_escalated"`, only records an outcome and emits
|
|
63
|
+
no allow or deny decision, preserving the host's normal approval flow.
|
|
64
|
+
|
|
65
|
+
Session context, telemetry, and compaction state are deliberately bounded.
|
|
66
|
+
Telemetry contains only timestamp, event, outcome, and rule identifier—not raw
|
|
67
|
+
prompts, commands, or credentials—and rotates by byte size. A pre-compaction
|
|
68
|
+
Git-status snapshot is redacted and written into a dedicated block in
|
|
69
|
+
`loop-state.md`, leaving surrounding user content intact. Installer update and
|
|
70
|
+
uninstall own only the launchers, native handler registrations, and exact
|
|
71
|
+
`.gitignore` block; goals, state, telemetry, and `config.toml` remain local
|
|
72
|
+
user files. If an existing `.codex/config.toml` explicitly says
|
|
73
|
+
`[features]` then `hooks = false`, planning stops with
|
|
74
|
+
`CODEX_LOOP_HOOKS_DISABLED` before any write.
|
|
75
|
+
|
|
76
|
+
Codex and Claude Code require their normal project-hook trust/review flow for
|
|
77
|
+
these generated handlers. The loop is a focused guardrail, not a complete
|
|
78
|
+
sandbox, permission bypass, or Stop-verification feature. See the [Codex hook
|
|
79
|
+
documentation](https://developers.openai.com/codex/config-advanced#hooks) and
|
|
80
|
+
the [Claude Code hook documentation](https://code.claude.com/docs/en/hooks)
|
|
81
|
+
before enabling it.
|
|
82
|
+
|
|
83
|
+
### Runtime-failure safeguards
|
|
84
|
+
|
|
85
|
+
For the ordinary `guardrails` profile, `command-policy` is the only capability with a fail-closed failure mode. Claude
|
|
86
|
+
Code can emit its documented denial shape at native `PreToolUse` for a
|
|
87
|
+
classified invalid installed configuration. The managed OpenCode
|
|
88
|
+
`tool.execute.before` plugin can throw its documented command-policy denial or
|
|
89
|
+
unavailable-runtime error for its supported Bash surface. Codex is explicitly
|
|
90
|
+
non-enforcing (`unknown`). These are agent-ops output and plugin contracts, not
|
|
91
|
+
proof that a host honors a denial. `SessionStart` and `Stop` failure paths stay
|
|
92
|
+
fail-open for every adapter.
|
|
93
|
+
|
|
94
|
+
Claude's invalid-config fallback has four safeguards: (1) an absent project
|
|
95
|
+
configuration stays fail-open, so only an invalid `.agent-ops/config.json` can
|
|
96
|
+
reach the fallback; (2) the manifest must safely prove that the current harness
|
|
97
|
+
is installed; (3) a human can export `AGENT_OPS_DISABLE=1` in the shell before
|
|
98
|
+
launching the host to restore fail-open temporarily; and (4) a Claude Code
|
|
99
|
+
denial names the config path and tells the user to repair it or temporarily set
|
|
100
|
+
that shell variable. The variable is read only from the hook-process environment
|
|
101
|
+
and cannot be set in agent-ops configuration, a manifest, or managed files.
|
|
102
|
+
|
|
32
103
|
`guardrails` installs command policy but does not enable Stop verification. Stop
|
|
33
104
|
is a separate config-v2 feature and must be explicitly enabled with at least
|
|
34
105
|
one confirmed command:
|
|
@@ -58,7 +129,12 @@ agent-ops update
|
|
|
58
129
|
agent-ops trust grant
|
|
59
130
|
```
|
|
60
131
|
|
|
61
|
-
Without `update`, doctor
|
|
132
|
+
Without `update`, doctor can report `UPDATE_REQUIRED` for registration drift.
|
|
133
|
+
Separately, after a toolkit upgrade or effective profile or capability change
|
|
134
|
+
alters an intact path-independent managed rules artifact,
|
|
135
|
+
`artifact-staleness` reports `DEGRADED` with `UPDATE_REQUIRED`. `agent-ops
|
|
136
|
+
update` regenerates the artifact and clears that result; a missing or
|
|
137
|
+
hash-mismatched artifact remains an `artifacts` `FAIL`. Without the new trust
|
|
62
138
|
grant, trust-gated hooks remain stale. Stop is report-only: it continues the
|
|
63
139
|
harness for `PASS`, `FAIL`, or `UNKNOWN`, emits only bounded command ID, exit
|
|
64
140
|
code, test-count, config-hash, and timestamp evidence, and never completes a
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
OpenCode plugin behavior in this document was checked against the [official
|
|
4
4
|
plugin documentation](https://opencode.ai/docs/plugins/) and [Bun shell
|
|
5
|
-
documentation](https://bun.sh/docs/runtime/shell) on 2026-07-31.
|
|
6
|
-
|
|
5
|
+
documentation](https://bun.sh/docs/runtime/shell) on 2026-07-31. Codex and
|
|
6
|
+
Claude Code loop-hook behavior was checked against their [Codex hook
|
|
7
|
+
documentation](https://developers.openai.com/codex/config-advanced#hooks) and
|
|
8
|
+
[Claude Code hook documentation](https://code.claude.com/docs/en/hooks) on
|
|
9
|
+
2026-08-03. Revalidate: when any vendor reference changes.
|
|
7
10
|
|
|
8
11
|
## HARNESS-ADAPTER-001
|
|
9
12
|
|
|
@@ -42,18 +45,19 @@ capabilities and MUST track generated source as one whole-file artifact.
|
|
|
42
45
|
|
|
43
46
|
The opencode shim MUST invoke the absolute runtime path from the selected
|
|
44
47
|
project directory, MUST fail open for
|
|
45
|
-
advisory events, and MUST
|
|
48
|
+
advisory events, and MUST throw its documented command-policy error when the
|
|
46
49
|
runtime is unavailable.
|
|
47
50
|
|
|
48
51
|
- Trigger: The generated plugin invokes `agent-ops` or receives an invalid runtime decision.
|
|
49
52
|
- Action: Keep normalization and native output encoding in the runtime adapter,
|
|
50
|
-
throw
|
|
51
|
-
the shared advisory implementation. App-scoped
|
|
52
|
-
degraded for per-session lifecycle fidelity.
|
|
53
|
+
throw its documented policy reason for a deny decision, and run
|
|
54
|
+
lifecycle-summary through the shared advisory implementation. App-scoped
|
|
55
|
+
plugin initialization remains degraded for per-session lifecycle fidelity.
|
|
53
56
|
- Evidence: Shim import tests cover allow, deny, and missing-runtime behavior;
|
|
54
|
-
doctor reports OpenCode lifecycle
|
|
55
|
-
|
|
56
|
-
-
|
|
57
|
+
denial fixtures assert output shape only; doctor reports OpenCode lifecycle
|
|
58
|
+
support as `DEGRADED`.
|
|
59
|
+
- Positive: `When the runtime is unavailable, SessionStart stays fail-open and the generated plugin throws its documented command-policy error for a Bash pre-tool hook.`
|
|
60
|
+
- Negative: `Fall back to a PATH-resolved agent-ops executable, claim an OpenCode host honors a thrown denial, or claim app initialization is a per-session Stop-equivalent.`
|
|
57
61
|
|
|
58
62
|
## HARNESS-ADAPTER-005
|
|
59
63
|
|
|
@@ -67,11 +71,31 @@ decoding, normalized events, native output encoding, and runtime-failure output.
|
|
|
67
71
|
including its support level and runtime-failure mode; do not add native
|
|
68
72
|
events to a universal union.
|
|
69
73
|
- Evidence: Every declared `supported` registration is exercised through the
|
|
70
|
-
real CLI hook process
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
real CLI hook process; denial-shape fixtures assert documented wire shapes,
|
|
75
|
+
not host runtime enforcement; unsupported Stop/lifecycle registrations are
|
|
76
|
+
not reported as enforcement success.
|
|
77
|
+
- Positive: `A fail-closed Claude command-policy runtime failure produces the documented PreToolUse denial shape through runHookCommand.`
|
|
73
78
|
- Negative: `Mark SessionStart supported while dispatchHookEvent has no advisory implementation.`
|
|
74
79
|
|
|
80
|
+
## HARNESS-ADAPTER-006
|
|
81
|
+
|
|
82
|
+
The project-local `loop` profile MUST be opt-in, project scoped, and use one
|
|
83
|
+
shared runtime behind minimal Codex and Claude Code launchers. It MUST NOT copy
|
|
84
|
+
policy into project-specific scripts or alter an ordinary permission request.
|
|
85
|
+
|
|
86
|
+
- Trigger: A project selects `loop` with Codex, Claude Code, or both.
|
|
87
|
+
- Action: Generate only the selected `.codex/hooks/agent-ops-loop.sh` and/or
|
|
88
|
+
`.claude/hooks/agent-ops-loop.sh` launchers, register the documented loop
|
|
89
|
+
lifecycle events except `Stop`, and preserve foreign hook groups. Block only
|
|
90
|
+
high-confidence literal credentials at `UserPromptSubmit` or Bash
|
|
91
|
+
`PreToolUse`, and dangerous Bash commands at `PreToolUse`, using the documented native denial shape. Emit no
|
|
92
|
+
decision for `PermissionRequest`, including escalated permissions.
|
|
93
|
+
- Evidence: Install-plan, loop-runtime, update, uninstall, and doctor tests
|
|
94
|
+
cover generated paths, Codex/Claude wire output, privacy bounds,
|
|
95
|
+
configuration conflict handling, state preservation, and registration drift.
|
|
96
|
+
- Positive: `A Claude PreToolUse dangerous Bash command receives a native deny while a PermissionRequest produces no allow or deny decision.`
|
|
97
|
+
- Negative: `Copy a project loop policy into both shell launchers, auto-approve sandbox escalation, or add a loop Stop handler.`
|
|
98
|
+
|
|
75
99
|
The current registration matrix is intentionally asymmetric:
|
|
76
100
|
|
|
77
101
|
| Capability | Codex | Claude Code | OpenCode |
|
|
@@ -80,6 +104,20 @@ The current registration matrix is intentionally asymmetric:
|
|
|
80
104
|
| command-policy | unknown | supported | supported |
|
|
81
105
|
| optional-stop-verify | unsupported | supported | degraded |
|
|
82
106
|
|
|
107
|
+
For runtime-failure handling, only `command-policy` is fail-closed. Claude
|
|
108
|
+
Code can emit its documented `PreToolUse` denial shape for a classified invalid
|
|
109
|
+
installed configuration; the managed OpenCode `tool.execute.before` plugin can
|
|
110
|
+
throw its documented denial or unavailable-runtime error for its supported Bash
|
|
111
|
+
surface. Codex remains `unknown` and never emits a denial. Fixture tests assert
|
|
112
|
+
these wire and plugin shapes only; they do not prove that a host honors a
|
|
113
|
+
denial. Every `SessionStart` and `Stop` failure path remains fail-open.
|
|
114
|
+
|
|
83
115
|
Stop verification is explicit, trusted, report-only, and disabled by default.
|
|
84
116
|
Every Stop result continues the native harness and may carry only bounded
|
|
85
117
|
command evidence; it is never task-completion evidence.
|
|
118
|
+
|
|
119
|
+
The `loop` profile is separate from the ordinary capability matrix above. It
|
|
120
|
+
stores only bounded local event metadata, returns bounded redacted session
|
|
121
|
+
context, and preserves local goal, state, telemetry, and Codex TOML files on
|
|
122
|
+
update or uninstall. A clearly parsed `[features]` / `hooks = false` in an
|
|
123
|
+
existing Codex configuration MUST reject loop planning before any write.
|