@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
package/README.md CHANGED
@@ -35,7 +35,7 @@ installation plan.
35
35
 
36
36
  The interactive multi-select screens start with no harness or profile selected.
37
37
  Choose at least one of `codex`, `claude`, and `opencode`, and at least one of
38
- the `core`, `advisory`, and `guardrails` profiles before confirming. For
38
+ the `core`, `advisory`, `guardrails`, and `loop` profiles before confirming. For
39
39
  scripted use, `--harness all` selects all three harnesses; comma-separated
40
40
  selections such as `codex,opencode` are also supported. The legacy `both` value
41
41
  remains an alias for `codex,claude`.
@@ -64,6 +64,54 @@ agent-ops update --yes --json
64
64
  agent-ops uninstall --dry-run --json
65
65
  ```
66
66
 
67
+ ### Project-local loop
68
+
69
+ `loop` is an explicit, project-only profile for Codex, Claude Code, or both.
70
+ It requires a POSIX-compatible `bash`; the generated native launchers are
71
+ `.sh` files, so Windows is not currently a supported loop host. Preview it
72
+ first, then install only after reviewing the plan. `loop` also implies the
73
+ `core` baseline, so the project retains the managed rules and routing files:
74
+
75
+ ```bash
76
+ agent-ops init --dry-run --scope project --harness codex,claude --profile loop --json
77
+ agent-ops init --scope project --harness codex,claude --profile loop --yes
78
+ ```
79
+
80
+ It creates one small managed launcher per selected native host:
81
+
82
+ - Codex: `.codex/hooks/agent-ops-loop.sh`, `.codex/config.toml` when absent,
83
+ `.codex/loop-goal.md`, `.codex/loop-state.md`, and
84
+ `.codex/loop-telemetry.jsonl`.
85
+ - Claude Code: `.claude/hooks/agent-ops-loop.sh`, `.claude/loop-goal.md`,
86
+ `.claude/loop-state.md`, and `.claude/loop-telemetry.jsonl`.
87
+
88
+ The launchers delegate to one installed Node runtime; agent-ops does not copy
89
+ project-specific policy code into either hook directory. It adds an exact,
90
+ hash-commented `.gitignore` block for the goal, state, and telemetry files.
91
+ Those files and `.codex/config.toml` remain user-owned: update never overwrites
92
+ them, and uninstall keeps them while removing only launchers, hook handlers,
93
+ and the managed ignore block.
94
+
95
+ The loop registers `SessionStart`, `UserPromptSubmit`, `PreToolUse`,
96
+ `PermissionRequest`, `PostToolUse`, `PreCompact`, `PostCompact`,
97
+ `SubagentStart`, and `SubagentStop`; it intentionally does not register
98
+ `Stop`. On high-confidence matches, it blocks literal credential-shaped user
99
+ prompts or Bash commands, plus dangerous Bash commands such as broad recursive
100
+ deletion or `git reset --hard`. Codex uses its documented exit-code denial path; Claude Code
101
+ uses its documented native JSON denial shapes. Permission and escalation
102
+ requests are never auto-approved or denied by the loop, so the harness's normal
103
+ approval prompt remains authoritative.
104
+
105
+ Session context is bounded and derives from the redacted goal plus a telemetry
106
+ count. Telemetry records only timestamp, event, outcome, and rule code; it
107
+ never stores raw prompts, Bash commands, or credentials and is byte-rotated.
108
+ Before compaction, the loop writes a bounded, redacted Git-status snapshot into
109
+ its own block inside `loop-state.md`, preserving surrounding user text. This is
110
+ a guardrail, not a complete sandbox or a replacement for each harness's own
111
+ permissions. Review and trust the generated hook configuration in Codex and
112
+ Claude Code before use. An existing Codex `.codex/config.toml` with an explicit
113
+ `[features]` / `hooks = false` setting stops installation before any write.
114
+
67
115
  Use `--scope user` with user-home installations. Keep `--dry-run` for any
68
116
  operation you want to inspect before applying; non-interactive automation should
69
117
  pass `--yes` only after reviewing the plan. Add `--json` when another tool will
@@ -121,6 +169,12 @@ The commands after `init --yes` are post-apply operations. `doctor` reports
121
169
  `trust grant` runs, and `smoke-availability` until the configuration declares a
122
170
  verification command.
123
171
 
172
+ `artifact-staleness` reports `DEGRADED` with `UPDATE_REQUIRED` when a toolkit
173
+ upgrade or effective profile or capability change makes intact managed rules
174
+ differ from the current baseline. Run `agent-ops update` to regenerate them.
175
+ Missing, altered, or hash-mismatched managed artifacts remain `FAIL` under
176
+ `artifacts`.
177
+
124
178
  Installing the `advisory` or `guardrails` profile registers the lifecycle and
125
179
  command-policy hooks implied by those profiles for the selected harnesses.
126
180
  Claude Code and Codex use their native JSON settings files; opencode uses the agent-ops-owned
@@ -132,11 +186,24 @@ scope, the opencode plugin is placed under
132
186
  `$XDG_CONFIG_HOME/opencode/` or `$OPENCODE_CONFIG_DIR/` when that location is
133
187
  inside the managed user root. The shims call `agent-ops hook <harness> <event>`
134
188
  through the installed absolute runtime path. Advisory failures remain
135
- fail-open; command-policy failures are fail-closed only where the native
136
- harness can block the tool. Claude and Codex lifecycle summaries are
137
- `supported`; OpenCode's app-scoped initialization is `degraded` rather than
138
- per-session coverage. Codex command denial remains `unknown` until native
139
- blocking is confirmed.
189
+ fail-open. Runtime-failure enforcement is deliberately narrow: Claude Code can
190
+ emit its documented `PreToolUse` denial shape for a classified invalid installed
191
+ configuration, and the managed OpenCode `tool.execute.before` plugin throws
192
+ its documented command-policy denial or unavailable-runtime error for its
193
+ supported Bash surface. Codex command policy is `unknown` and explicitly
194
+ non-enforcing for the ordinary `guardrails` profile. The project-local `loop`
195
+ profile uses its separate native hook policy described above. These are output
196
+ and plugin contracts, not proof that a host
197
+ honors a denial. Claude and Codex lifecycle summaries are `supported`; OpenCode's
198
+ app-scoped initialization is `degraded` rather than per-session coverage.
199
+
200
+ Claude's invalid-config fallback has four safeguards: only an invalid (not
201
+ absent) `.agent-ops/config.json` can reach it; a safely read manifest must list
202
+ the current harness; `AGENT_OPS_DISABLE=1` must not be set; and Claude's denial
203
+ reason names the config file with a repair or temporary shell-disable remedy.
204
+ `AGENT_OPS_DISABLE=1` is a human-shell recovery variable only. Agent-ops never
205
+ reads it from configuration, a manifest, or another file it writes. Every
206
+ `SessionStart` and `Stop` failure path remains fail-open.
140
207
 
141
208
  `guardrails` enables command policy only; it does not imply Stop verification.
142
209
  Stop verification is an explicit, disabled-by-default config feature. Enable it
@@ -173,6 +240,15 @@ files authoritative. Existing installations with the previous canonical
173
240
  wording are migrated by `agent-ops update`; changed managed blocks still fail
174
241
  closed.
175
242
 
243
+ ### Rejected proposals and deliberate boundaries
244
+
245
+ The following proposals are deliberately rejected: emitting a model-visible
246
+ `SessionStart` advisory summary, inspecting user-authored Markdown link
247
+ integrity in `doctor`, and creating backups for agent-authored rule edits.
248
+ Transactional backups remain limited to agent-ops apply operations. Agent-ops
249
+ also does not add a git-workflow instruction to the generated baseline, which
250
+ stays project-neutral.
251
+
176
252
  Dry-run plans keep harness settings writes opaque: human and JSON output expose
177
253
  only the expected hash, content hash, and a safe summary. Use
178
254
  `--hook-target <harness>=<surface-id>` when selecting a non-default discovered
@@ -12,7 +12,7 @@ export const COMMAND_NAMES = [
12
12
  ];
13
13
  const COMMAND_SET = new Set(COMMAND_NAMES);
14
14
  const SCOPES = new Set(["project", "user"]);
15
- const PROFILES = new Set(["advisory", "core", "guardrails"]);
15
+ const PROFILES = new Set(["advisory", "core", "guardrails", "loop"]);
16
16
  export class CliArgumentError extends Error {
17
17
  code;
18
18
  option;
@@ -131,6 +131,7 @@ else {
131
131
  const config = (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config;
132
132
  return await runDoctorCommand({
133
133
  root,
134
+ toolkitVersion: CLI_VERSION,
134
135
  probes: {
135
136
  hookRegistration: async () => hookRegistrationSatisfied({
136
137
  harness: await installedHarness(root),
@@ -157,6 +158,7 @@ else {
157
158
  adapters: commonHarnessAdapters(),
158
159
  registry: new NpmRegistryClient(),
159
160
  isTTY,
161
+ toolkitVersion: CLI_VERSION,
160
162
  hookRuntimePath: HOOK_RUNTIME_PATH,
161
163
  ...(args.hookTargets === undefined
162
164
  ? {}
@@ -31,7 +31,7 @@ Options:
31
31
  --scope <project|user>
32
32
  --harness <all|both|claude|codex|opencode|comma-separated> Init/update
33
33
  --hook-target <harness=surface-id> Repeatable advanced init/update option
34
- --profile <core|advisory|guardrails> Repeatable
34
+ --profile <core|advisory|guardrails|loop> Repeatable
35
35
  --task <id>
36
36
  --target-version <version> Update target version (offline-capable)
37
37
  --title <text>
@@ -0,0 +1,70 @@
1
+ import { PROJECT_LOOP_EVENTS, runProjectLoop } from "../../../runtime/src/hooks/codex-loop.js";
2
+ const MAX_LOOP_INPUT_BYTES = 64 * 1024;
3
+ function isLoopHarness(value) {
4
+ return value === "claude" || value === "codex";
5
+ }
6
+ function isLoopEvent(value) {
7
+ return (value !== undefined &&
8
+ PROJECT_LOOP_EVENTS.includes(value));
9
+ }
10
+ async function readStdin(stream) {
11
+ const chunks = [];
12
+ let total = 0;
13
+ for await (const chunk of stream) {
14
+ const buffer = Buffer.isBuffer(chunk)
15
+ ? chunk
16
+ : Buffer.from(String(chunk), "utf8");
17
+ total += buffer.byteLength;
18
+ if (total > MAX_LOOP_INPUT_BYTES) {
19
+ return null;
20
+ }
21
+ chunks.push(buffer);
22
+ }
23
+ return Buffer.concat(chunks, total).toString("utf8");
24
+ }
25
+ function parseInput(source) {
26
+ if (source === null) {
27
+ return null;
28
+ }
29
+ try {
30
+ return JSON.parse(source);
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ /**
37
+ * Process boundary for the generated Bash launchers. Invalid input stays
38
+ * fail-open; the runtime owns every policy decision and output shape.
39
+ */
40
+ export async function runLoopProcess(argv, io, dependencies = {}) {
41
+ const [harness, event] = argv;
42
+ if (!isLoopHarness(harness) || !isLoopEvent(event)) {
43
+ return 0;
44
+ }
45
+ try {
46
+ const result = await runProjectLoop({
47
+ harness,
48
+ event,
49
+ input: parseInput(await readStdin(io.stdin)),
50
+ root: dependencies.root ?? process.cwd(),
51
+ ...(dependencies.now === undefined ? {} : { now: dependencies.now }),
52
+ ...(dependencies.gitStatus === undefined
53
+ ? {}
54
+ : { gitStatus: dependencies.gitStatus }),
55
+ ...(dependencies.telemetryMaxBytes === undefined
56
+ ? {}
57
+ : { telemetryMaxBytes: dependencies.telemetryMaxBytes })
58
+ });
59
+ if (result.stdout.length > 0) {
60
+ io.writeStdout(result.stdout);
61
+ }
62
+ if (result.stderr.length > 0) {
63
+ io.writeStderr(result.stderr);
64
+ }
65
+ return result.exitCode;
66
+ }
67
+ catch {
68
+ return 0;
69
+ }
70
+ }
@@ -7,6 +7,18 @@ export const HOOK_EVENTS = [
7
7
  "PreToolUse",
8
8
  "Stop"
9
9
  ];
10
+ /**
11
+ * Keep adapter normalization at the command boundary so exceptional hook
12
+ * paths use the same native-event interpretation as normal dispatch.
13
+ */
14
+ export function normalizeHookInput(harness, input) {
15
+ try {
16
+ return harnessDescriptor(harness).runtime.normalizeInput(input);
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
10
22
  /**
11
23
  * Hooks are advisory infrastructure: every failure path stays fail-open with
12
24
  * exit code 0 so a broken toolkit can never wedge the harness.
@@ -24,7 +36,10 @@ export async function runHookCommand(options) {
24
36
  return { exitCode: 0, stdout: "", stderr: "" };
25
37
  }
26
38
  const descriptor = harnessDescriptor(options.harness);
27
- const normalized = descriptor.runtime.normalizeInput(input);
39
+ const normalized = normalizeHookInput(options.harness, input);
40
+ if (normalized === null) {
41
+ return { exitCode: 0, stdout: "", stderr: "" };
42
+ }
28
43
  const stopRegistration = descriptor.control.registrations.find(({ capability }) => capability === "optional-stop-verify");
29
44
  const stopVerification = options.stopVerification !== undefined &&
30
45
  stopRegistration?.support !== "unsupported"
@@ -38,6 +38,9 @@ export async function runUpdateCommand(options) {
38
38
  ...(options.targetVersion === undefined
39
39
  ? {}
40
40
  : { targetVersion: options.targetVersion }),
41
+ ...(options.toolkitVersion === undefined
42
+ ? {}
43
+ : { toolkitVersion: options.toolkitVersion }),
41
44
  ...(options.hookRuntimePath === undefined
42
45
  ? {}
43
46
  : { hookRuntimePath: options.hookRuntimePath }),
@@ -79,6 +79,66 @@ export async function loadEffectiveConfig(root, scope) {
79
79
  }
80
80
  return mergeConfigLayers(layers);
81
81
  }
82
+ /**
83
+ * Classifies only configuration that can participate in a project hook. The
84
+ * regular command loader intentionally keeps its throwing contract so CLI
85
+ * commands continue to surface configuration errors to the human.
86
+ */
87
+ export async function loadProjectHookConfig(root) {
88
+ const home = process.env.AGENT_OPS_HOME ?? homedir();
89
+ const userPath = join(home, ".agent-ops", "config.json");
90
+ const projectPath = join(root, ".agent-ops", "config.json");
91
+ const layers = [defaultConfigLayer()];
92
+ let project;
93
+ try {
94
+ project = await loadOptionalConfig(projectPath);
95
+ }
96
+ catch {
97
+ return { kind: "invalid", path: projectPath };
98
+ }
99
+ if (project === null && projectPath === userPath) {
100
+ return { kind: "absent", config: DEFAULT_CONFIG };
101
+ }
102
+ if (projectPath !== userPath) {
103
+ try {
104
+ const user = await loadOptionalConfig(userPath);
105
+ if (user !== null) {
106
+ layers.push({
107
+ source: "user",
108
+ sourcePath: user.sourcePath,
109
+ config: user.config
110
+ });
111
+ }
112
+ }
113
+ catch {
114
+ // A user-level error cannot make a project with no own config deny a
115
+ // tool call, but it remains a classified failure for an installed
116
+ // project that does own a config.
117
+ return project === null
118
+ ? { kind: "absent", config: DEFAULT_CONFIG }
119
+ : { kind: "invalid", path: userPath };
120
+ }
121
+ }
122
+ if (project === null) {
123
+ try {
124
+ return { kind: "absent", config: mergeConfigLayers(layers).config };
125
+ }
126
+ catch {
127
+ return { kind: "absent", config: DEFAULT_CONFIG };
128
+ }
129
+ }
130
+ layers.push({
131
+ source: "project",
132
+ sourcePath: project.sourcePath,
133
+ config: project.config
134
+ });
135
+ try {
136
+ return { kind: "loaded", config: mergeConfigLayers(layers).config };
137
+ }
138
+ catch {
139
+ return { kind: "invalid", path: projectPath };
140
+ }
141
+ }
82
142
  export function repositoryRemoteUrl(root) {
83
143
  try {
84
144
  return execFileSync("git", ["config", "--get", "remote.origin.url"], {
@@ -1,14 +1,21 @@
1
1
  import { execFile as execFileCallback } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open } from "node:fs/promises";
4
+ import { join } from "node:path";
2
5
  import { promisify } from "node:util";
3
6
  import { calculateConfigHash } from "../../../runtime/src/config/hash.js";
7
+ import { parseInstallManifest, PROJECT_MANIFEST_PATH } from "../../../runtime/src/fs/manifest.js";
8
+ import { resolveContainedPath } from "../../../runtime/src/fs/paths.js";
9
+ import { redactSecrets } from "../../../runtime/src/security/redact.js";
4
10
  import { claudeStopRecursionMarker } from "../../../runtime/src/adapters/claude/input.js";
5
11
  import { HARNESS_IDS, harnessDescriptor } from "../../../runtime/src/install/harness.js";
6
12
  import { STOP_VERIFICATION_ENV, StopVerificationService } from "../../../runtime/src/hooks/stop-service.js";
7
13
  import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
8
- import { runHookCommand, HOOK_EVENTS } from "./commands/hook.js";
9
- import { loadEffectiveConfig, repositoryTrust } from "./context.js";
14
+ import { normalizeHookInput, runHookCommand, HOOK_EVENTS } from "./commands/hook.js";
15
+ import { loadProjectHookConfig, repositoryTrust } from "./context.js";
10
16
  const HARNESSES = new Set(HARNESS_IDS);
11
17
  const MAX_HOOK_INPUT_BYTES = 1024 * 1024;
18
+ const MAX_HOOK_MANIFEST_BYTES = 1024 * 1024;
12
19
  const execFile = promisify(execFileCallback);
13
20
  async function readStdin(stream) {
14
21
  const chunks = [];
@@ -33,6 +40,99 @@ function parseInput(source) {
33
40
  return null;
34
41
  }
35
42
  }
43
+ function writeHookOutput(io, output) {
44
+ if (output.stdout.length > 0) {
45
+ io.writeStdout(output.stdout);
46
+ }
47
+ if (output.stderr.length > 0) {
48
+ io.writeStderr(output.stderr);
49
+ }
50
+ }
51
+ function failOpenOutput(harness, event) {
52
+ return harnessDescriptor(harness).runtime.formatOutput(event, {
53
+ action: "continue",
54
+ status: "PASS",
55
+ code: "HOOK_FAIL_OPEN"
56
+ });
57
+ }
58
+ async function readInstalledManifestForHarness(root, harness) {
59
+ try {
60
+ const path = await resolveContainedPath(root, PROJECT_MANIFEST_PATH);
61
+ const before = await lstat(path, { bigint: true });
62
+ if (!before.isFile() ||
63
+ before.size > BigInt(MAX_HOOK_MANIFEST_BYTES)) {
64
+ return false;
65
+ }
66
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
67
+ try {
68
+ const opened = await handle.stat({ bigint: true });
69
+ const resolvedAgain = await resolveContainedPath(root, PROJECT_MANIFEST_PATH);
70
+ const after = await lstat(resolvedAgain, { bigint: true });
71
+ if (!opened.isFile() ||
72
+ opened.size > BigInt(MAX_HOOK_MANIFEST_BYTES) ||
73
+ after.dev !== before.dev ||
74
+ after.ino !== before.ino) {
75
+ return false;
76
+ }
77
+ const chunks = [];
78
+ let totalBytes = 0;
79
+ while (totalBytes <= MAX_HOOK_MANIFEST_BYTES) {
80
+ const chunk = Buffer.alloc(Math.min(64 * 1024, MAX_HOOK_MANIFEST_BYTES + 1 - totalBytes));
81
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
82
+ if (bytesRead === 0) {
83
+ return parseInstallManifest(Buffer.concat(chunks, totalBytes).toString("utf8")).harness.includes(harness);
84
+ }
85
+ chunks.push(chunk.subarray(0, bytesRead));
86
+ totalBytes += bytesRead;
87
+ }
88
+ return false;
89
+ }
90
+ finally {
91
+ await handle.close();
92
+ }
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
98
+ async function hookConfigOutcome(root, loadConfig) {
99
+ if (loadConfig === undefined) {
100
+ return await loadProjectHookConfig(root);
101
+ }
102
+ try {
103
+ return { kind: "loaded", config: await loadConfig(root) };
104
+ }
105
+ catch {
106
+ return {
107
+ kind: "invalid",
108
+ path: join(root, ".agent-ops", "config.json")
109
+ };
110
+ }
111
+ }
112
+ async function invalidConfigOutput(options) {
113
+ const descriptor = harnessDescriptor(options.harness);
114
+ const normalized = normalizeHookInput(options.harness, options.input);
115
+ if (normalized === null) {
116
+ return failOpenOutput(options.harness, options.event);
117
+ }
118
+ if (options.event !== "PreToolUse" ||
119
+ normalized.event !== "command" ||
120
+ !(await readInstalledManifestForHarness(options.root, options.harness))) {
121
+ return failOpenOutput(options.harness, options.event);
122
+ }
123
+ const registration = descriptor.control.registrations.find(({ nativeEvent, normalizedEvent }) => nativeEvent === options.event && normalizedEvent === normalized.event);
124
+ if (registration === undefined || registration.runtimeFailure === "fail-open") {
125
+ return failOpenOutput(options.harness, options.event);
126
+ }
127
+ if (options.harness === "opencode" &&
128
+ registration.runtimeFailure === "fail-closed") {
129
+ // The generated OpenCode plugin already turns an unavailable runtime into
130
+ // its documented blocking error. Keep that contract in the shim.
131
+ return undefined;
132
+ }
133
+ const remedy = `Fix ${redactSecrets(options.configPath)}, or set AGENT_OPS_DISABLE=1 in your shell to temporarily disable agent-ops.`;
134
+ return descriptor.runtime.formatRuntimeFailure(options.event, registration.capability, remedy);
135
+ }
36
136
  function defaultGitRunner(root) {
37
137
  return {
38
138
  run: async (args) => {
@@ -109,18 +209,36 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
109
209
  }
110
210
  try {
111
211
  const root = dependencies.root ?? process.cwd();
112
- const config = dependencies.loadConfig === undefined
113
- ? (await loadEffectiveConfig(root, "project")).config
114
- : await dependencies.loadConfig(root);
212
+ const harnessId = harness;
213
+ const hookEvent = event;
214
+ if (process.env.AGENT_OPS_DISABLE === "1") {
215
+ writeHookOutput(io, failOpenOutput(harnessId, hookEvent));
216
+ return 0;
217
+ }
218
+ const rawInput = await readStdin(io.stdin);
219
+ const parsedInput = parseInput(rawInput);
220
+ const configOutcome = await hookConfigOutcome(root, dependencies.loadConfig);
221
+ if (configOutcome.kind === "invalid") {
222
+ const output = await invalidConfigOutput({
223
+ root,
224
+ harness: harnessId,
225
+ event: hookEvent,
226
+ input: parsedInput,
227
+ configPath: configOutcome.path
228
+ });
229
+ if (output !== undefined) {
230
+ writeHookOutput(io, output);
231
+ }
232
+ return 0;
233
+ }
234
+ const config = configOutcome.config;
115
235
  const trustStatus = dependencies.trust === undefined
116
236
  ? await repositoryTrust(root, config, cliVersion)
117
237
  : await dependencies.trust(root, config, cliVersion);
118
- const rawInput = await readStdin(io.stdin);
119
- const parsedInput = parseInput(rawInput);
120
238
  const trusted = trustStatus === "TRUSTED";
121
239
  const gitRunner = dependencies.gitRunner ?? defaultGitRunner(root);
122
240
  const processRunner = dependencies.processRunner ?? new NodeVerificationProcessRunner();
123
- const stopVerification = shouldBuildStopVerification(harness, event, config, parsedInput)
241
+ const stopVerification = shouldBuildStopVerification(harnessId, event, config, parsedInput)
124
242
  ? stopVerificationOptions({
125
243
  harness: harness,
126
244
  rawInput: parsedInput,
@@ -133,7 +251,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
133
251
  : undefined;
134
252
  const output = await runHookCommand({
135
253
  harness: harness,
136
- event: event,
254
+ event: hookEvent,
137
255
  stdin: rawInput,
138
256
  config,
139
257
  trusted,
@@ -142,12 +260,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
142
260
  : { advisory: dependencies.advisory }),
143
261
  ...(stopVerification === undefined ? {} : { stopVerification })
144
262
  });
145
- if (output.stdout.length > 0) {
146
- io.writeStdout(output.stdout);
147
- }
148
- if (output.stderr.length > 0) {
149
- io.writeStderr(output.stderr);
150
- }
263
+ writeHookOutput(io, output);
151
264
  }
152
265
  catch {
153
266
  // ponytail: fail-open by design; hook failures stay invisible to the harness.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ // Entry point used only by the generated project-local loop launchers.
3
+ import { runLoopProcess } from "./codex-loop-process.js";
4
+ process.exitCode = await runLoopProcess(process.argv.slice(2), {
5
+ stdin: process.stdin,
6
+ writeStdout: (value) => process.stdout.write(value),
7
+ writeStderr: (value) => process.stderr.write(value)
8
+ });
@@ -1,3 +1,3 @@
1
1
  // Single source of the published CLI version. Release preparation updates
2
2
  // this constant; bin.ts and the hook entry both read it from here.
3
- export const CLI_VERSION = "0.1.5";
3
+ export const CLI_VERSION = "0.1.6";
@@ -2,7 +2,7 @@ import { CliArgumentError } from "./args.js";
2
2
  import { HARNESS_IDS, resolveHarnessSelection } from "../../../runtime/src/install/harness.js";
3
3
  import { selectOption, selectOptions } from "./ui.js";
4
4
  const SCOPES = new Set(["project", "user"]);
5
- const PROFILES = new Set(["advisory", "core", "guardrails"]);
5
+ const PROFILES = new Set(["advisory", "core", "guardrails", "loop"]);
6
6
  const DEFAULT_HARNESS = [];
7
7
  const SCOPE_CHOICES = [
8
8
  { label: "project", value: "project" },
@@ -24,6 +24,11 @@ const PROFILE_CHOICES = [
24
24
  label: "guardrails",
25
25
  value: "guardrails",
26
26
  description: "Blocks high-confidence unsafe commands; Stop verification is a separate opt-in feature."
27
+ },
28
+ {
29
+ label: "loop",
30
+ value: "loop",
31
+ description: "Installs the shared Codex and Claude Code local loop with secret and destructive-command interception."
27
32
  }
28
33
  ];
29
34
  const WIZARD_SUBTITLE = "Safe setup for Codex, Claude Code, and opencode with profile-driven rules, verification, and hooks.";
@@ -74,7 +79,7 @@ function selectProfiles(raw) {
74
79
  if (values.length === 0 ||
75
80
  values.some((value) => !PROFILES.has(value)) ||
76
81
  new Set(values).size !== values.length) {
77
- throw new CliArgumentError("CLI_INVALID_VALUE", "Profiles must be a unique comma-separated list of core, advisory, or guardrails.");
82
+ throw new CliArgumentError("CLI_INVALID_VALUE", "Profiles must be a unique comma-separated list of core, advisory, guardrails, or loop.");
78
83
  }
79
84
  return values;
80
85
  }
@@ -110,7 +115,7 @@ export async function completeInitChoices(args, io) {
110
115
  : await selectOptions("Profiles (multi-select: ↑↓ move, Space toggle, Enter confirm)", PROFILE_CHOICES, selectorIo, [], {
111
116
  selectAll: true,
112
117
  selectAllLabel: "Select all",
113
- selectAllDescription: "Enable core, advisory, and guardrails together."
118
+ selectAllDescription: "Enable core, advisory, guardrails, and loop together."
114
119
  });
115
120
  return {
116
121
  ...args,
@@ -127,7 +132,7 @@ export async function completeInitChoices(args, io) {
127
132
  selectHarness(await session.question(`Harness (${HARNESS_IDS.join(",")}): `));
128
133
  const profiles = args.profiles.length > 0
129
134
  ? args.profiles
130
- : selectProfiles(await session.question("Profiles (core,advisory,guardrails) [core]: "));
135
+ : selectProfiles(await session.question("Profiles (core,advisory,guardrails,loop) [core]: "));
131
136
  return {
132
137
  ...args,
133
138
  scope,