@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.
Files changed (36) hide show
  1. package/README.md +82 -6
  2. package/dist/packages/cli/src/args.js +1 -1
  3. package/dist/packages/cli/src/bin.js +2 -0
  4. package/dist/packages/cli/src/cli.js +1 -1
  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/update.js +3 -0
  8. package/dist/packages/cli/src/context.js +60 -0
  9. package/dist/packages/cli/src/hook-process.js +128 -15
  10. package/dist/packages/cli/src/loop-entry.js +8 -0
  11. package/dist/packages/cli/src/version.js +1 -1
  12. package/dist/packages/cli/src/wizard.js +9 -4
  13. package/dist/runtime/src/adapters/claude/config.js +57 -11
  14. package/dist/runtime/src/adapters/claude/events.js +7 -0
  15. package/dist/runtime/src/adapters/claude/output.js +2 -1
  16. package/dist/runtime/src/adapters/codex/config.js +39 -4
  17. package/dist/runtime/src/adapters/codex/events.js +7 -0
  18. package/dist/runtime/src/fs/managed-block.js +35 -18
  19. package/dist/runtime/src/hooks/codex-loop.js +439 -0
  20. package/dist/runtime/src/install/codex-loop.js +139 -0
  21. package/dist/runtime/src/install/doctor.js +66 -8
  22. package/dist/runtime/src/install/harness.js +8 -10
  23. package/dist/runtime/src/install/ownership.js +37 -2
  24. package/dist/runtime/src/install/plan.js +70 -4
  25. package/dist/runtime/src/install/profiles.js +5 -3
  26. package/dist/runtime/src/install/uninstall.js +1 -1
  27. package/dist/runtime/src/install/update.js +5 -1
  28. package/dist/runtime/src/logging/local-log.js +25 -0
  29. package/dist/runtime/src/schema/validate.js +8 -1
  30. package/docs/en/guides/configuration.md +78 -2
  31. package/docs/en/spec/harness-adapters.md +50 -12
  32. package/docs/zh-TW/guides/configuration.md +73 -5
  33. package/docs/zh-TW/spec/harness-adapters.md +44 -12
  34. package/package.json +1 -1
  35. package/schemas/config.schema.json +1 -1
  36. 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: (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";
@@ -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 contribution = await planHarnessContributions(options.harness, {
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 settings require that explicit target.
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 reports `UPDATE_REQUIRED`; without the new trust
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. Revalidate:
6
- when either vendor reference changes.
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 fail closed for command-policy events when the
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 the policy reason for a deny decision, and run lifecycle-summary through
51
- the shared advisory implementation. App-scoped plugin initialization remains
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 support as `DEGRADED`.
55
- - Positive: `A missing runtime does not block SessionStart but blocks a bash tool before execution.`
56
- - Negative: `Fall back to a PATH-resolved agent-ops executable or claim app initialization is a per-session Stop-equivalent.`
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, and unsupported Stop/lifecycle registrations are not
71
- reported as enforcement success.
72
- - Positive: `Claude command-policy reaches a native PreToolUse denial through runHookCommand.`
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.