@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.
Files changed (49) hide show
  1. package/README.md +104 -6
  2. package/dist/packages/cli/src/args.js +33 -1
  3. package/dist/packages/cli/src/bin.js +40 -3
  4. package/dist/packages/cli/src/cli.js +13 -2
  5. package/dist/packages/cli/src/codex-loop-process.js +70 -0
  6. package/dist/packages/cli/src/commands/hook.js +16 -1
  7. package/dist/packages/cli/src/commands/init.js +4 -1
  8. package/dist/packages/cli/src/commands/review.js +97 -10
  9. package/dist/packages/cli/src/commands/update.js +3 -0
  10. package/dist/packages/cli/src/context.js +60 -0
  11. package/dist/packages/cli/src/hook-process.js +128 -15
  12. package/dist/packages/cli/src/loop-entry.js +8 -0
  13. package/dist/packages/cli/src/version.js +1 -1
  14. package/dist/packages/cli/src/wizard.js +71 -7
  15. package/dist/runtime/src/adapters/claude/config.js +57 -11
  16. package/dist/runtime/src/adapters/claude/events.js +7 -0
  17. package/dist/runtime/src/adapters/claude/output.js +2 -1
  18. package/dist/runtime/src/adapters/codex/config.js +39 -4
  19. package/dist/runtime/src/adapters/codex/events.js +7 -0
  20. package/dist/runtime/src/config/merge.js +17 -2
  21. package/dist/runtime/src/fs/managed-block.js +35 -18
  22. package/dist/runtime/src/hooks/codex-loop.js +439 -0
  23. package/dist/runtime/src/install/codex-loop.js +139 -0
  24. package/dist/runtime/src/install/doctor.js +108 -9
  25. package/dist/runtime/src/install/harness.js +8 -10
  26. package/dist/runtime/src/install/ownership.js +37 -2
  27. package/dist/runtime/src/install/plan.js +81 -9
  28. package/dist/runtime/src/install/profiles.js +5 -3
  29. package/dist/runtime/src/install/uninstall.js +1 -1
  30. package/dist/runtime/src/install/update.js +5 -1
  31. package/dist/runtime/src/logging/local-log.js +25 -0
  32. package/dist/runtime/src/review/execute.js +120 -0
  33. package/dist/runtime/src/review/extract.js +71 -0
  34. package/dist/runtime/src/review/invocation.js +52 -0
  35. package/dist/runtime/src/review/probe.js +48 -0
  36. package/dist/runtime/src/review/result.js +2 -2
  37. package/dist/runtime/src/review/roles.js +35 -0
  38. package/dist/runtime/src/review/runner.js +38 -4
  39. package/dist/runtime/src/schema/validate.js +70 -1
  40. package/dist/runtime/src/task/service.js +40 -0
  41. package/docs/en/guides/configuration.md +138 -2
  42. package/docs/en/spec/harness-adapters.md +50 -12
  43. package/docs/en/spec/review.md +37 -4
  44. package/docs/zh-TW/guides/configuration.md +126 -5
  45. package/docs/zh-TW/spec/harness-adapters.md +44 -12
  46. package/docs/zh-TW/spec/review.md +33 -3
  47. package/package.json +1 -1
  48. package/schemas/config.schema.json +30 -1
  49. 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 check("artifacts", "FAIL", "Artifacts cannot be verified without a valid manifest.");
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
- if (sha256(content) !== artifact.hash ||
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 failures.length === 0
144
- ? check("artifacts", "PASS", "All managed artifacts match their hashes.")
145
- : check("artifacts", "FAIL", `Managed artifacts failed verification: ${failures.join(", ")}.`);
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
- await checkArtifacts(options.root, manifest.manifest),
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: (handler) => isRecord(handler) &&
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 contribution = await planHarnessContributions(options.harness, {
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) {