@kylecheng3146/agent-ops 0.1.5 → 0.1.7
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 +104 -6
- package/dist/packages/cli/src/args.js +33 -1
- package/dist/packages/cli/src/bin.js +40 -3
- package/dist/packages/cli/src/cli.js +13 -2
- 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/init.js +4 -1
- package/dist/packages/cli/src/commands/review.js +97 -10
- 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 +71 -7
- 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/config/merge.js +17 -2
- 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 +108 -9
- 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 +81 -9
- 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/review/execute.js +120 -0
- package/dist/runtime/src/review/extract.js +71 -0
- package/dist/runtime/src/review/invocation.js +52 -0
- package/dist/runtime/src/review/probe.js +48 -0
- package/dist/runtime/src/review/result.js +2 -2
- package/dist/runtime/src/review/roles.js +35 -0
- package/dist/runtime/src/review/runner.js +38 -4
- package/dist/runtime/src/schema/validate.js +70 -1
- package/dist/runtime/src/task/service.js +40 -0
- package/docs/en/guides/configuration.md +138 -2
- package/docs/en/spec/harness-adapters.md +50 -12
- package/docs/en/spec/review.md +37 -4
- package/docs/zh-TW/guides/configuration.md +126 -5
- package/docs/zh-TW/spec/harness-adapters.md +44 -12
- package/docs/zh-TW/spec/review.md +33 -3
- package/package.json +1 -1
- package/schemas/config.schema.json +30 -1
- package/schemas/manifest.schema.json +12 -1
|
@@ -6,8 +6,8 @@ import { resolveContainedPath } from "../fs/paths.js";
|
|
|
6
6
|
import { validateConfig } from "../schema/validate.js";
|
|
7
7
|
import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
|
|
8
8
|
import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
|
|
9
|
-
import { harnessDescriptor } from "./harness.js";
|
|
10
|
-
import { resolveProfiles } from "./profiles.js";
|
|
9
|
+
import { harnessDescriptor, managedRules } from "./harness.js";
|
|
10
|
+
import { resolveCapabilities, resolveProfiles } from "./profiles.js";
|
|
11
11
|
import { inspectHarnessSurfaces, inspectHarnessRegistrations } from "./surface-inspection.js";
|
|
12
12
|
const CONFIG_PATH = ".agent-ops/config.json";
|
|
13
13
|
const MINIMUM_NODE_VERSION = [22, 14, 0];
|
|
@@ -124,25 +124,81 @@ async function checkConfig(root) {
|
|
|
124
124
|
}
|
|
125
125
|
async function checkArtifacts(root, manifest) {
|
|
126
126
|
if (manifest === undefined) {
|
|
127
|
-
return
|
|
127
|
+
return {
|
|
128
|
+
check: check("artifacts", "FAIL", "Artifacts cannot be verified without a valid manifest."),
|
|
129
|
+
hashesByPath: new Map()
|
|
130
|
+
};
|
|
128
131
|
}
|
|
129
132
|
const failures = [];
|
|
133
|
+
const hashesByPath = new Map();
|
|
130
134
|
for (const artifact of manifest.artifacts) {
|
|
131
135
|
try {
|
|
132
136
|
const content = await readContained(root, artifact.path);
|
|
133
|
-
|
|
137
|
+
const hash = sha256(content);
|
|
138
|
+
if (hash !== artifact.hash ||
|
|
134
139
|
(artifact.id === "opencode-plugin" &&
|
|
135
140
|
!isOpencodeManagedPlugin(content.toString("utf8")))) {
|
|
136
141
|
failures.push(artifact.path);
|
|
137
142
|
}
|
|
143
|
+
else {
|
|
144
|
+
hashesByPath.set(artifact.path, hash);
|
|
145
|
+
}
|
|
138
146
|
}
|
|
139
147
|
catch {
|
|
140
148
|
failures.push(artifact.path);
|
|
141
149
|
}
|
|
142
150
|
}
|
|
143
|
-
return
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
return {
|
|
152
|
+
check: failures.length === 0
|
|
153
|
+
? check("artifacts", "PASS", "All managed artifacts match their hashes.")
|
|
154
|
+
: check("artifacts", "FAIL", `Managed artifacts failed verification: ${failures.join(", ")}.`),
|
|
155
|
+
hashesByPath
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function checkArtifactStaleness(manifest, config, artifacts, toolkitVersion) {
|
|
159
|
+
if (manifest === undefined || config === undefined) {
|
|
160
|
+
return check("artifact-staleness", "UNKNOWN", "Managed artifact staleness cannot be assessed without a valid manifest and configuration.");
|
|
161
|
+
}
|
|
162
|
+
if (artifacts.check.status !== "PASS") {
|
|
163
|
+
return check("artifact-staleness", "UNKNOWN", "Managed artifact staleness cannot be assessed until artifact integrity passes.");
|
|
164
|
+
}
|
|
165
|
+
if (toolkitVersion === undefined) {
|
|
166
|
+
return check("artifact-staleness", "UNKNOWN", "Managed artifact staleness cannot be assessed without the running toolkit version.");
|
|
167
|
+
}
|
|
168
|
+
const expectedHashesByPath = new Map();
|
|
169
|
+
try {
|
|
170
|
+
const resolved = config.profiles.length === 0
|
|
171
|
+
? { profiles: [], capabilities: [] }
|
|
172
|
+
: resolveCapabilities(config);
|
|
173
|
+
for (const id of manifest.harness) {
|
|
174
|
+
const descriptor = harnessDescriptor(id);
|
|
175
|
+
const path = `.agent-ops/${descriptor.control.instructionFile}`;
|
|
176
|
+
if (!expectedHashesByPath.has(path)) {
|
|
177
|
+
expectedHashesByPath.set(path, sha256(managedRules(descriptor, {
|
|
178
|
+
scope: manifest.scope,
|
|
179
|
+
profiles: resolved.profiles,
|
|
180
|
+
capabilities: resolved.capabilities,
|
|
181
|
+
toolkitVersion
|
|
182
|
+
})));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return check("artifact-staleness", "UNKNOWN", "Managed artifact staleness could not be assessed safely.");
|
|
188
|
+
}
|
|
189
|
+
const stalePaths = [];
|
|
190
|
+
for (const [path, expectedHash] of expectedHashesByPath) {
|
|
191
|
+
const actualHash = artifacts.hashesByPath.get(path);
|
|
192
|
+
if (actualHash === undefined) {
|
|
193
|
+
return check("artifact-staleness", "UNKNOWN", "Managed artifact staleness could not be assessed safely.");
|
|
194
|
+
}
|
|
195
|
+
if (actualHash !== expectedHash) {
|
|
196
|
+
stalePaths.push(path);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return stalePaths.length === 0
|
|
200
|
+
? check("artifact-staleness", "PASS", "Managed artifacts match the current toolkit and configuration.")
|
|
201
|
+
: check("artifact-staleness", "DEGRADED", `Managed artifacts need update: ${stalePaths.join(", ")}; run agent-ops update.`, "UPDATE_REQUIRED");
|
|
146
202
|
}
|
|
147
203
|
async function checkMarkers(root, manifest) {
|
|
148
204
|
if (manifest === undefined) {
|
|
@@ -282,22 +338,65 @@ async function checkRegistrationDrift(root, manifest, config) {
|
|
|
282
338
|
return check("registration-drift", "UNKNOWN", "Hook registration drift could not be assessed safely.");
|
|
283
339
|
}
|
|
284
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Guidance lives in `message` rather than a `remediation` field: as of this
|
|
343
|
+
* check, `remediation` does not exist on DoctorCheck. Because target
|
|
344
|
+
* authentication failures surface as one unexplained review failure — the
|
|
345
|
+
* chain deliberately does not sniff stderr for "not logged in" — this text is
|
|
346
|
+
* the operator's only route out, so it names the exact command.
|
|
347
|
+
*/
|
|
348
|
+
async function checkReviewTargets(config, probe, checkAuth) {
|
|
349
|
+
const targets = config?.reviewRoles?.find((role) => role.role === "independent-review")?.targets ?? [];
|
|
350
|
+
if (targets.length === 0) {
|
|
351
|
+
return check("review-targets", "PASS", "External review disabled. Re-run agent-ops init to enable.");
|
|
352
|
+
}
|
|
353
|
+
if (probe === undefined) {
|
|
354
|
+
return check("review-targets", "PASS", `External review targets: ${targets.join(", ")}. ` +
|
|
355
|
+
"Login state unverified; run: agent-ops doctor --check-auth");
|
|
356
|
+
}
|
|
357
|
+
for (const target of targets) {
|
|
358
|
+
const result = await probe(target, checkAuth);
|
|
359
|
+
if (result === "missing-executable") {
|
|
360
|
+
return check("review-targets", "FAIL", `${target} not found. Install it, or remove "${target}" from ` +
|
|
361
|
+
"reviewRoles[].targets.", "UPDATE_REQUIRED");
|
|
362
|
+
}
|
|
363
|
+
if (result === "ineligible") {
|
|
364
|
+
return check("review-targets", "FAIL", `${target} has no read-only mode and cannot review. Remove ` +
|
|
365
|
+
`"${target}" from reviewRoles[].targets.`, "UPDATE_REQUIRED");
|
|
366
|
+
}
|
|
367
|
+
if (result === "timeout") {
|
|
368
|
+
return check("review-targets", "FAIL", `${target} did not answer in time. Re-run: ` +
|
|
369
|
+
"agent-ops doctor --check-auth", "UPDATE_REQUIRED");
|
|
370
|
+
}
|
|
371
|
+
if (checkAuth && result !== "ok") {
|
|
372
|
+
return check("review-targets", "FAIL", `${target} is installed but not authenticated, or it rejected the ` +
|
|
373
|
+
`call. Run: ${target} login`, "UPDATE_REQUIRED");
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return check("review-targets", "PASS", checkAuth
|
|
377
|
+
? `External review targets authenticated: ${targets.join(", ")}.`
|
|
378
|
+
: `External review targets: ${targets.join(", ")}. ` +
|
|
379
|
+
"Login state unverified; run: agent-ops doctor --check-auth");
|
|
380
|
+
}
|
|
285
381
|
export async function doctorInstallation(options) {
|
|
286
382
|
const manifest = await checkManifest(options.root);
|
|
287
383
|
const config = await checkConfig(options.root);
|
|
384
|
+
const artifacts = await checkArtifacts(options.root, manifest.manifest);
|
|
288
385
|
const surfaceInventory = await checkSurfaceInventory(options.root, manifest.manifest, config.config);
|
|
289
386
|
const checks = [
|
|
290
387
|
checkNodeVersion(options.nodeVersion ?? process.versions.node),
|
|
291
388
|
manifest.check,
|
|
292
389
|
config.check,
|
|
293
|
-
|
|
390
|
+
artifacts.check,
|
|
391
|
+
checkArtifactStaleness(manifest.manifest, config.config, artifacts, options.toolkitVersion),
|
|
294
392
|
await checkMarkers(options.root, manifest.manifest),
|
|
295
393
|
surfaceInventory.check,
|
|
296
394
|
await checkRegistrationDrift(options.root, manifest.manifest, config.config),
|
|
297
395
|
await checkProbe("hook-registration", options.probes?.hookRegistration),
|
|
298
396
|
checkLifecycleSummary(manifest.manifest, config.config),
|
|
299
397
|
await checkProbe("repository-trust", options.probes?.repositoryTrust),
|
|
300
|
-
await checkProbe("smoke-availability", options.probes?.smokeAvailability)
|
|
398
|
+
await checkProbe("smoke-availability", options.probes?.smokeAvailability),
|
|
399
|
+
await checkReviewTargets(config.config, options.probes?.reviewTarget, options.checkReviewTargetAuth === true)
|
|
301
400
|
];
|
|
302
401
|
return {
|
|
303
402
|
checks,
|
|
@@ -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";
|
|
@@ -39,7 +40,12 @@ async function readCurrentFile(root, path) {
|
|
|
39
40
|
throw error;
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
|
-
function formatConfig(profiles, existing) {
|
|
43
|
+
function formatConfig(profiles, existing, reviewTargets = []) {
|
|
44
|
+
// Absent reviewRoles means external review is disabled; an empty selection
|
|
45
|
+
// must therefore omit the field rather than write an empty array.
|
|
46
|
+
const reviewRoles = reviewTargets.length > 0
|
|
47
|
+
? [{ role: "independent-review", targets: [...reviewTargets] }]
|
|
48
|
+
: existing?.reviewRoles;
|
|
43
49
|
return `${JSON.stringify({
|
|
44
50
|
schemaVersion: CONFIG_SCHEMA_VERSION,
|
|
45
51
|
profiles,
|
|
@@ -50,10 +56,11 @@ function formatConfig(profiles, existing) {
|
|
|
50
56
|
}
|
|
51
57
|
},
|
|
52
58
|
pathMappings: existing?.pathMappings ?? [],
|
|
53
|
-
securityExceptions: existing?.securityExceptions ?? []
|
|
59
|
+
securityExceptions: existing?.securityExceptions ?? [],
|
|
60
|
+
...(reviewRoles === undefined ? {} : { reviewRoles })
|
|
54
61
|
}, null, 2)}\n`;
|
|
55
62
|
}
|
|
56
|
-
async function planConfig(root, profiles, existingManifest, suppliedConfig) {
|
|
63
|
+
async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = []) {
|
|
57
64
|
const current = await readCurrentFile(root, CONFIG_PATH);
|
|
58
65
|
const owned = findOwnedArtifact(existingManifest, CONFIG_PATH);
|
|
59
66
|
if (current !== null && owned === undefined) {
|
|
@@ -85,7 +92,7 @@ async function planConfig(root, profiles, existingManifest, suppliedConfig) {
|
|
|
85
92
|
}
|
|
86
93
|
existingConfig = result.value;
|
|
87
94
|
}
|
|
88
|
-
const content = formatConfig(profiles, existingConfig);
|
|
95
|
+
const content = formatConfig(profiles, existingConfig, reviewTargets);
|
|
89
96
|
return {
|
|
90
97
|
operation: {
|
|
91
98
|
kind: "write",
|
|
@@ -116,7 +123,7 @@ function assertUniqueContributions(artifacts, blocks) {
|
|
|
116
123
|
}
|
|
117
124
|
const markerBoundaries = new Set();
|
|
118
125
|
for (const block of blocks) {
|
|
119
|
-
const markers = managedBlockMarkers(block.id, block.version);
|
|
126
|
+
const markers = managedBlockMarkers(block.id, block.version, block.markerStyle);
|
|
120
127
|
const key = pathKey(block.path);
|
|
121
128
|
if (ids.has(block.id) ||
|
|
122
129
|
artifactPaths.has(key) ||
|
|
@@ -198,9 +205,53 @@ async function planArtifactRemoval(root, artifact) {
|
|
|
198
205
|
expectedHash: current.hash
|
|
199
206
|
};
|
|
200
207
|
}
|
|
208
|
+
async function planCreateOnceSeeds(root, seeds) {
|
|
209
|
+
const operations = [];
|
|
210
|
+
for (const seed of seeds) {
|
|
211
|
+
const current = await readCurrentFile(root, seed.path);
|
|
212
|
+
if (current === null) {
|
|
213
|
+
operations.push({
|
|
214
|
+
kind: "write",
|
|
215
|
+
path: seed.path,
|
|
216
|
+
content: seed.content,
|
|
217
|
+
expectedHash: null
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return operations;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* User-owned seed files belong to the first loop install for each harness.
|
|
225
|
+
* An existing loop launcher is the durable installation record: it prevents an
|
|
226
|
+
* update from recreating a file a user intentionally removed, while still
|
|
227
|
+
* allowing loop to be enabled later or for a newly added harness.
|
|
228
|
+
*/
|
|
229
|
+
function loopHarnessesNeedingSeeds(harnesses, existingManifest) {
|
|
230
|
+
const existingArtifactIds = new Set(existingManifest?.artifacts.map(({ id }) => id) ?? []);
|
|
231
|
+
return selectedLoopHarnesses(harnesses).filter((harness) => !existingArtifactIds.has(loopLauncherArtifactId(harness)));
|
|
232
|
+
}
|
|
201
233
|
function markerKey(path, id) {
|
|
202
234
|
return `${pathKey(path)}\0${id}`;
|
|
203
235
|
}
|
|
236
|
+
function assertLoopProfileSupport(scope, harness, capabilities) {
|
|
237
|
+
if (!capabilities.includes("project-loop")) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (scope !== "project" ||
|
|
241
|
+
!harness.some((id) => id === "codex" || id === "claude")) {
|
|
242
|
+
throw new AgentOpsError("LOOP_PROFILE_UNSUPPORTED", "The loop profile requires project scope and the Codex or Claude harness.");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async function assertCodexLoopConfiguration(root, harness, capabilities) {
|
|
246
|
+
if (!capabilities.includes("project-loop") ||
|
|
247
|
+
!harness.includes("codex")) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const config = await readCurrentFile(root, ".codex/config.toml");
|
|
251
|
+
if (config !== null && codexHooksExplicitlyDisabled(config.content)) {
|
|
252
|
+
throw new AgentOpsError("CODEX_LOOP_HOOKS_DISABLED", "Codex loop installation requires [features] hooks = true; the existing .codex/config.toml explicitly disables hooks.");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
204
255
|
async function planBlocks(root, blocks, removals = [], expectedMarkers = new Map()) {
|
|
205
256
|
const grouped = new Map();
|
|
206
257
|
for (const block of blocks) {
|
|
@@ -238,7 +289,7 @@ async function planBlocks(root, blocks, removals = [], expectedMarkers = new Map
|
|
|
238
289
|
throw new AgentOpsError("MANIFEST_OWNERSHIP_INVALID", "The manifest contains an unsupported managed block.");
|
|
239
290
|
}
|
|
240
291
|
assertExpectedManagedBlock(content, marker, expected);
|
|
241
|
-
content = removeManagedBlock(content, marker.id);
|
|
292
|
+
content = removeManagedBlock(content, marker.id, expected.markerStyle);
|
|
242
293
|
}
|
|
243
294
|
for (const block of pathBlocks) {
|
|
244
295
|
content = applyManagedBlock(content, block);
|
|
@@ -260,7 +311,7 @@ async function planBlocks(root, blocks, removals = [], expectedMarkers = new Map
|
|
|
260
311
|
});
|
|
261
312
|
}
|
|
262
313
|
for (const block of pathBlocks) {
|
|
263
|
-
const markers = managedBlockMarkers(block.id, block.version);
|
|
314
|
+
const markers = managedBlockMarkers(block.id, block.version, block.markerStyle);
|
|
264
315
|
records.push({
|
|
265
316
|
id: block.id,
|
|
266
317
|
path,
|
|
@@ -282,6 +333,8 @@ export async function createInstallPlan(options) {
|
|
|
282
333
|
const resolved = options.existingConfig === undefined
|
|
283
334
|
? resolveProfiles(options.profiles)
|
|
284
335
|
: resolveCapabilities(options.existingConfig.value);
|
|
336
|
+
assertLoopProfileSupport(options.scope, options.harness, resolved.capabilities);
|
|
337
|
+
await assertCodexLoopConfiguration(options.root, options.harness, resolved.capabilities);
|
|
285
338
|
const existing = await readExistingManifest(options.root);
|
|
286
339
|
assertCompatibleManifest(existing?.manifest ?? null, options.scope, options.harness, options.allowHarnessChange === true);
|
|
287
340
|
const existingOpencodePluginPath = existing?.manifest.artifacts.find(({ id }) => id === "opencode-plugin")?.path;
|
|
@@ -300,7 +353,7 @@ export async function createInstallPlan(options) {
|
|
|
300
353
|
explicitHookTargets.size > 0) {
|
|
301
354
|
throw new AgentOpsError("HOOK_TARGET_REQUIRES_RUNTIME", "An explicit hook target requires a hook runtime path.");
|
|
302
355
|
}
|
|
303
|
-
const
|
|
356
|
+
const baseContribution = await planHarnessContributions(options.harness, {
|
|
304
357
|
root: options.root,
|
|
305
358
|
scope: options.scope,
|
|
306
359
|
profiles: resolved.profiles,
|
|
@@ -315,6 +368,21 @@ export async function createInstallPlan(options) {
|
|
|
315
368
|
? {}
|
|
316
369
|
: { opencodePluginPath: existingOpencodePluginPath })
|
|
317
370
|
}, options.adapters);
|
|
371
|
+
const loopContribution = planLoopContribution({
|
|
372
|
+
scope: options.scope,
|
|
373
|
+
harnesses: options.harness,
|
|
374
|
+
capabilities: resolved.capabilities,
|
|
375
|
+
...(options.hookRuntimePath === undefined
|
|
376
|
+
? {}
|
|
377
|
+
: { hookRuntimePath: options.hookRuntimePath })
|
|
378
|
+
});
|
|
379
|
+
const contribution = {
|
|
380
|
+
artifacts: [
|
|
381
|
+
...baseContribution.artifacts,
|
|
382
|
+
...loopContribution.artifacts
|
|
383
|
+
],
|
|
384
|
+
blocks: [...baseContribution.blocks, ...loopContribution.blocks]
|
|
385
|
+
};
|
|
318
386
|
assertUniqueContributions(contribution.artifacts, contribution.blocks);
|
|
319
387
|
const reconcileExisting = existing !== null;
|
|
320
388
|
const expectedExistingMarkers = reconcileExisting
|
|
@@ -351,7 +419,7 @@ export async function createInstallPlan(options) {
|
|
|
351
419
|
: [];
|
|
352
420
|
const operations = [];
|
|
353
421
|
const artifacts = [];
|
|
354
|
-
const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig);
|
|
422
|
+
const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? []);
|
|
355
423
|
operations.push(config.operation);
|
|
356
424
|
artifacts.push(config.record);
|
|
357
425
|
for (const artifact of contribution.artifacts) {
|
|
@@ -359,6 +427,10 @@ export async function createInstallPlan(options) {
|
|
|
359
427
|
operations.push(planned.operation);
|
|
360
428
|
artifacts.push(planned.record);
|
|
361
429
|
}
|
|
430
|
+
if (resolved.capabilities.includes("project-loop")) {
|
|
431
|
+
const seedHarnesses = loopHarnessesNeedingSeeds(options.harness, existing?.manifest ?? null);
|
|
432
|
+
operations.push(...await planCreateOnceSeeds(options.root, loopSeeds(seedHarnesses)));
|
|
433
|
+
}
|
|
362
434
|
artifacts.push(...preservedArtifacts);
|
|
363
435
|
for (const artifact of artifactsToRemove) {
|
|
364
436
|
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) {
|