@kylecheng3146/agent-ops 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -6
- package/dist/packages/cli/src/args.js +33 -1
- package/dist/packages/cli/src/bin.js +40 -3
- package/dist/packages/cli/src/cli.js +13 -2
- package/dist/packages/cli/src/codex-loop-process.js +70 -0
- package/dist/packages/cli/src/commands/hook.js +16 -1
- package/dist/packages/cli/src/commands/init.js +4 -1
- package/dist/packages/cli/src/commands/review.js +97 -10
- package/dist/packages/cli/src/commands/update.js +3 -0
- package/dist/packages/cli/src/context.js +60 -0
- package/dist/packages/cli/src/hook-process.js +128 -15
- package/dist/packages/cli/src/loop-entry.js +8 -0
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/cli/src/wizard.js +71 -7
- package/dist/runtime/src/adapters/claude/config.js +57 -11
- package/dist/runtime/src/adapters/claude/events.js +7 -0
- package/dist/runtime/src/adapters/claude/output.js +2 -1
- package/dist/runtime/src/adapters/codex/config.js +39 -4
- package/dist/runtime/src/adapters/codex/events.js +7 -0
- package/dist/runtime/src/config/merge.js +17 -2
- package/dist/runtime/src/fs/managed-block.js +35 -18
- package/dist/runtime/src/hooks/codex-loop.js +439 -0
- package/dist/runtime/src/install/codex-loop.js +139 -0
- package/dist/runtime/src/install/doctor.js +108 -9
- package/dist/runtime/src/install/harness.js +8 -10
- package/dist/runtime/src/install/ownership.js +37 -2
- package/dist/runtime/src/install/plan.js +81 -9
- package/dist/runtime/src/install/profiles.js +5 -3
- package/dist/runtime/src/install/uninstall.js +1 -1
- package/dist/runtime/src/install/update.js +5 -1
- package/dist/runtime/src/logging/local-log.js +25 -0
- package/dist/runtime/src/review/execute.js +120 -0
- package/dist/runtime/src/review/extract.js +71 -0
- package/dist/runtime/src/review/invocation.js +52 -0
- package/dist/runtime/src/review/probe.js +48 -0
- package/dist/runtime/src/review/result.js +2 -2
- package/dist/runtime/src/review/roles.js +35 -0
- package/dist/runtime/src/review/runner.js +38 -4
- package/dist/runtime/src/schema/validate.js +70 -1
- package/dist/runtime/src/task/service.js +40 -0
- package/docs/en/guides/configuration.md +138 -2
- package/docs/en/spec/harness-adapters.md +50 -12
- package/docs/en/spec/review.md +37 -4
- package/docs/zh-TW/guides/configuration.md +126 -5
- package/docs/zh-TW/spec/harness-adapters.md +44 -12
- package/docs/zh-TW/spec/review.md +33 -3
- package/package.json +1 -1
- package/schemas/config.schema.json +30 -1
- package/schemas/manifest.schema.json +12 -1
|
@@ -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 {
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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,9 +1,49 @@
|
|
|
1
1
|
import { CliArgumentError } from "./args.js";
|
|
2
|
+
import { DEFAULT_REVIEW_TARGETS } from "../../../runtime/src/review/roles.js";
|
|
2
3
|
import { HARNESS_IDS, resolveHarnessSelection } from "../../../runtime/src/install/harness.js";
|
|
3
4
|
import { selectOption, selectOptions } from "./ui.js";
|
|
4
5
|
const SCOPES = new Set(["project", "user"]);
|
|
5
|
-
const PROFILES = new Set(["advisory", "core", "guardrails"]);
|
|
6
|
+
const PROFILES = new Set(["advisory", "core", "guardrails", "loop"]);
|
|
6
7
|
const DEFAULT_HARNESS = [];
|
|
8
|
+
const REVIEW_TARGET_SET = new Set(DEFAULT_REVIEW_TARGETS);
|
|
9
|
+
const REVIEW_TARGET_CHOICES = DEFAULT_REVIEW_TARGETS.map((id) => ({
|
|
10
|
+
label: id,
|
|
11
|
+
value: id,
|
|
12
|
+
description: id === "codex"
|
|
13
|
+
? "Runs with -s read-only; stdout is the bare final message."
|
|
14
|
+
: id === "agy"
|
|
15
|
+
? "Antigravity CLI; runs with --sandbox --mode plan."
|
|
16
|
+
: "Runs with --permission-mode plan; tried last when it is the host."
|
|
17
|
+
}));
|
|
18
|
+
function selectReviewTargets(raw) {
|
|
19
|
+
const values = raw
|
|
20
|
+
.split(",")
|
|
21
|
+
.map((value) => value.trim())
|
|
22
|
+
.filter((value) => value.length > 0);
|
|
23
|
+
for (const value of values) {
|
|
24
|
+
if (!REVIEW_TARGET_SET.has(value)) {
|
|
25
|
+
throw new CliArgumentError("CLI_INVALID_VALUE", `Invalid review target: ${value}`, "--review-target");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Declared order wins: the chain order is the option list, not click order.
|
|
29
|
+
return DEFAULT_REVIEW_TARGETS.filter((target) => values.includes(target));
|
|
30
|
+
}
|
|
31
|
+
function affirmative(raw) {
|
|
32
|
+
return /^(y|yes)$/i.test(raw.trim());
|
|
33
|
+
}
|
|
34
|
+
async function probeReviewTargets(targets, setup) {
|
|
35
|
+
const probe = setup.probeReviewTarget;
|
|
36
|
+
if (probe === undefined) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const target of targets) {
|
|
40
|
+
if (!(await probe(target))) {
|
|
41
|
+
setup.warn?.(`${target} is not usable yet (missing or unauthenticated). ` +
|
|
42
|
+
`Install it or run: ${target} login, ` +
|
|
43
|
+
"then: agent-ops doctor --check-auth");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
7
47
|
const SCOPE_CHOICES = [
|
|
8
48
|
{ label: "project", value: "project" },
|
|
9
49
|
{ label: "user", value: "user" }
|
|
@@ -24,6 +64,11 @@ const PROFILE_CHOICES = [
|
|
|
24
64
|
label: "guardrails",
|
|
25
65
|
value: "guardrails",
|
|
26
66
|
description: "Blocks high-confidence unsafe commands; Stop verification is a separate opt-in feature."
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
label: "loop",
|
|
70
|
+
value: "loop",
|
|
71
|
+
description: "Installs the shared Codex and Claude Code local loop with secret and destructive-command interception."
|
|
27
72
|
}
|
|
28
73
|
];
|
|
29
74
|
const WIZARD_SUBTITLE = "Safe setup for Codex, Claude Code, and opencode with profile-driven rules, verification, and hooks.";
|
|
@@ -74,11 +119,11 @@ function selectProfiles(raw) {
|
|
|
74
119
|
if (values.length === 0 ||
|
|
75
120
|
values.some((value) => !PROFILES.has(value)) ||
|
|
76
121
|
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
|
|
122
|
+
throw new CliArgumentError("CLI_INVALID_VALUE", "Profiles must be a unique comma-separated list of core, advisory, guardrails, or loop.");
|
|
78
123
|
}
|
|
79
124
|
return values;
|
|
80
125
|
}
|
|
81
|
-
export async function completeInitChoices(args, io) {
|
|
126
|
+
export async function completeInitChoices(args, io, setup = {}) {
|
|
82
127
|
if (args.command !== "init" ||
|
|
83
128
|
(args.scope !== undefined &&
|
|
84
129
|
args.harness !== undefined &&
|
|
@@ -110,13 +155,27 @@ export async function completeInitChoices(args, io) {
|
|
|
110
155
|
: await selectOptions("Profiles (multi-select: ↑↓ move, Space toggle, Enter confirm)", PROFILE_CHOICES, selectorIo, [], {
|
|
111
156
|
selectAll: true,
|
|
112
157
|
selectAllLabel: "Select all",
|
|
113
|
-
selectAllDescription: "Enable core, advisory, and
|
|
158
|
+
selectAllDescription: "Enable core, advisory, guardrails, and loop together."
|
|
114
159
|
});
|
|
160
|
+
const enabled = args.reviewTargets !== undefined ||
|
|
161
|
+
(await selectOption("External review: call another agent CLI to review your work?", [
|
|
162
|
+
{ label: "no", value: false, description: "Default. Nothing is spawned." },
|
|
163
|
+
{
|
|
164
|
+
label: "yes",
|
|
165
|
+
value: true,
|
|
166
|
+
description: "Pick target CLIs; each is probed for authentication."
|
|
167
|
+
}
|
|
168
|
+
], selectorIo));
|
|
169
|
+
const reviewTargets = args.reviewTargets ?? (enabled
|
|
170
|
+
? await selectOptions("Review targets (multi-select: tried in listed order)", REVIEW_TARGET_CHOICES, selectorIo, [])
|
|
171
|
+
: []);
|
|
172
|
+
await probeReviewTargets(reviewTargets, setup);
|
|
115
173
|
return {
|
|
116
174
|
...args,
|
|
117
175
|
scope,
|
|
118
176
|
harness,
|
|
119
|
-
profiles
|
|
177
|
+
profiles,
|
|
178
|
+
...(reviewTargets.length === 0 ? {} : { reviewTargets })
|
|
120
179
|
};
|
|
121
180
|
}
|
|
122
181
|
const session = await createPromptSession(io);
|
|
@@ -127,12 +186,17 @@ export async function completeInitChoices(args, io) {
|
|
|
127
186
|
selectHarness(await session.question(`Harness (${HARNESS_IDS.join(",")}): `));
|
|
128
187
|
const profiles = args.profiles.length > 0
|
|
129
188
|
? args.profiles
|
|
130
|
-
: selectProfiles(await session.question("Profiles (core,advisory,guardrails) [core]: "));
|
|
189
|
+
: selectProfiles(await session.question("Profiles (core,advisory,guardrails,loop) [core]: "));
|
|
190
|
+
const reviewTargets = args.reviewTargets ?? (affirmative(await session.question("Enable external review by another agent CLI? [y/N]: "))
|
|
191
|
+
? selectReviewTargets(await session.question(`Review targets (${DEFAULT_REVIEW_TARGETS.join(",")}): `))
|
|
192
|
+
: []);
|
|
193
|
+
await probeReviewTargets(reviewTargets, setup);
|
|
131
194
|
return {
|
|
132
195
|
...args,
|
|
133
196
|
scope,
|
|
134
197
|
harness,
|
|
135
|
-
profiles
|
|
198
|
+
profiles,
|
|
199
|
+
...(reviewTargets.length === 0 ? {} : { reviewTargets })
|
|
136
200
|
};
|
|
137
201
|
}
|
|
138
202
|
finally {
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import { AgentOpsError } from "../../fs/paths.js";
|
|
2
|
+
const CLAUDE_LOOP_EVENTS = [
|
|
3
|
+
"SessionStart",
|
|
4
|
+
"UserPromptSubmit",
|
|
5
|
+
"PreToolUse",
|
|
6
|
+
"PermissionRequest",
|
|
7
|
+
"PostToolUse",
|
|
8
|
+
"PreCompact",
|
|
9
|
+
"PostCompact",
|
|
10
|
+
"SubagentStart",
|
|
11
|
+
"SubagentStop"
|
|
12
|
+
];
|
|
13
|
+
const CLAUDE_HOOK_MARKER = "--managed-by=agent-ops";
|
|
14
|
+
const CLAUDE_LOOP_LAUNCHER = "${CLAUDE_PROJECT_DIR}/.claude/hooks/agent-ops-loop.sh";
|
|
2
15
|
export function claudeSettingsTarget(scope) {
|
|
3
16
|
return scope === "project"
|
|
4
17
|
? {
|
|
@@ -18,7 +31,7 @@ function commandHook(event, runtimePath) {
|
|
|
18
31
|
runtimePath,
|
|
19
32
|
"claude",
|
|
20
33
|
event,
|
|
21
|
-
|
|
34
|
+
CLAUDE_HOOK_MARKER
|
|
22
35
|
],
|
|
23
36
|
timeout: 30
|
|
24
37
|
};
|
|
@@ -29,6 +42,25 @@ function matcherGroup(event, runtimePath) {
|
|
|
29
42
|
hooks: [commandHook(event, runtimePath)]
|
|
30
43
|
};
|
|
31
44
|
}
|
|
45
|
+
function loopMatcherGroup(event) {
|
|
46
|
+
return {
|
|
47
|
+
...(event === "PreToolUse" || event === "PermissionRequest"
|
|
48
|
+
? { matcher: "Bash" }
|
|
49
|
+
: {}),
|
|
50
|
+
hooks: [
|
|
51
|
+
{
|
|
52
|
+
type: "command",
|
|
53
|
+
command: "bash",
|
|
54
|
+
args: [
|
|
55
|
+
CLAUDE_LOOP_LAUNCHER,
|
|
56
|
+
event,
|
|
57
|
+
CLAUDE_HOOK_MARKER
|
|
58
|
+
],
|
|
59
|
+
timeout: 30
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
};
|
|
63
|
+
}
|
|
32
64
|
export function buildClaudeHookSettings(capabilities, runtimePath) {
|
|
33
65
|
if (runtimePath.length === 0 ||
|
|
34
66
|
runtimePath.length > 4096 ||
|
|
@@ -36,11 +68,18 @@ export function buildClaudeHookSettings(capabilities, runtimePath) {
|
|
|
36
68
|
throw new AgentOpsError("CLAUDE_HOOK_PATH_INVALID", "Claude hook runtime path is invalid.");
|
|
37
69
|
}
|
|
38
70
|
const hooks = {};
|
|
39
|
-
if (capabilities.includes("
|
|
40
|
-
|
|
71
|
+
if (capabilities.includes("project-loop")) {
|
|
72
|
+
for (const event of CLAUDE_LOOP_EVENTS) {
|
|
73
|
+
hooks[event] = [loopMatcherGroup(event)];
|
|
74
|
+
}
|
|
41
75
|
}
|
|
42
|
-
|
|
43
|
-
|
|
76
|
+
else {
|
|
77
|
+
if (capabilities.includes("lifecycle-summary")) {
|
|
78
|
+
hooks.SessionStart = [matcherGroup("SessionStart", runtimePath)];
|
|
79
|
+
}
|
|
80
|
+
if (capabilities.includes("command-policy")) {
|
|
81
|
+
hooks.PreToolUse = [matcherGroup("PreToolUse", runtimePath)];
|
|
82
|
+
}
|
|
44
83
|
}
|
|
45
84
|
if (capabilities.includes("optional-stop-verify")) {
|
|
46
85
|
hooks.Stop = [matcherGroup("Stop", runtimePath)];
|
|
@@ -50,19 +89,26 @@ export function buildClaudeHookSettings(capabilities, runtimePath) {
|
|
|
50
89
|
function isRecord(value) {
|
|
51
90
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
52
91
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Matches only the two command shapes agent-ops actually generates. This is
|
|
94
|
+
* reused by installation inspection so a foreign hook cannot masquerade as
|
|
95
|
+
* ours merely by carrying the marker string.
|
|
96
|
+
*/
|
|
97
|
+
export function isClaudeManagedHandler(handler) {
|
|
98
|
+
if (!isRecord(handler) || !Array.isArray(handler.args)) {
|
|
57
99
|
return false;
|
|
58
100
|
}
|
|
59
|
-
return handler.
|
|
101
|
+
return ((handler.command === "node" &&
|
|
102
|
+
handler.args[3] === CLAUDE_HOOK_MARKER) ||
|
|
103
|
+
(handler.command === "bash" &&
|
|
104
|
+
handler.args[0] === CLAUDE_LOOP_LAUNCHER &&
|
|
105
|
+
handler.args[2] === CLAUDE_HOOK_MARKER));
|
|
60
106
|
}
|
|
61
107
|
function withoutOwnedHandlers(value) {
|
|
62
108
|
if (!isRecord(value) || !Array.isArray(value.hooks)) {
|
|
63
109
|
return value;
|
|
64
110
|
}
|
|
65
|
-
const hooks = value.hooks.filter((handler) => !
|
|
111
|
+
const hooks = value.hooks.filter((handler) => !isClaudeManagedHandler(handler));
|
|
66
112
|
return hooks.length === 0 ? null : { ...value, hooks };
|
|
67
113
|
}
|
|
68
114
|
function hookRecord(settings) {
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
export const CLAUDE_SUPPORTED_EVENTS = [
|
|
2
2
|
"SessionStart",
|
|
3
|
+
"UserPromptSubmit",
|
|
3
4
|
"PreToolUse",
|
|
5
|
+
"PermissionRequest",
|
|
6
|
+
"PostToolUse",
|
|
7
|
+
"PreCompact",
|
|
8
|
+
"PostCompact",
|
|
9
|
+
"SubagentStart",
|
|
10
|
+
"SubagentStop",
|
|
4
11
|
"Stop"
|
|
5
12
|
];
|
|
6
13
|
export function claudeNonInteractiveTrust(printMode) {
|
|
@@ -6,6 +6,7 @@ function json(value) {
|
|
|
6
6
|
};
|
|
7
7
|
}
|
|
8
8
|
export function claudeHookOutput(event, result) {
|
|
9
|
+
const denialReason = result.remedy === undefined ? result.code : `${result.code}: ${result.remedy}`;
|
|
9
10
|
if (event === "Stop" && result.evidence !== undefined) {
|
|
10
11
|
return json({
|
|
11
12
|
systemMessage: `agent-ops: ${result.code}`,
|
|
@@ -20,7 +21,7 @@ export function claudeHookOutput(event, result) {
|
|
|
20
21
|
hookSpecificOutput: {
|
|
21
22
|
hookEventName: "PreToolUse",
|
|
22
23
|
permissionDecision: "deny",
|
|
23
|
-
permissionDecisionReason:
|
|
24
|
+
permissionDecisionReason: denialReason
|
|
24
25
|
}
|
|
25
26
|
});
|
|
26
27
|
}
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { AgentOpsError } from "../../fs/paths.js";
|
|
2
2
|
export const CODEX_MANAGED_MARKER = "--managed-by=agent-ops";
|
|
3
|
+
const CODEX_LOOP_EVENTS = [
|
|
4
|
+
"SessionStart",
|
|
5
|
+
"UserPromptSubmit",
|
|
6
|
+
"PreToolUse",
|
|
7
|
+
"PermissionRequest",
|
|
8
|
+
"PostToolUse",
|
|
9
|
+
"PreCompact",
|
|
10
|
+
"PostCompact",
|
|
11
|
+
"SubagentStart",
|
|
12
|
+
"SubagentStop"
|
|
13
|
+
];
|
|
3
14
|
/**
|
|
4
15
|
* Releases up to 0.1.4 registered a bare `agent-ops` command resolved through
|
|
5
16
|
* PATH. Detection still recognizes it so an update replaces it instead of
|
|
@@ -35,6 +46,23 @@ function matcherGroup(event, runtimePath) {
|
|
|
35
46
|
hooks: [commandHook(event, runtimePath)]
|
|
36
47
|
};
|
|
37
48
|
}
|
|
49
|
+
function loopMatcherGroup(event) {
|
|
50
|
+
const command = `bash "$(git rev-parse --show-toplevel)/.codex/hooks/agent-ops-loop.sh" ${event} ${CODEX_MANAGED_MARKER}`;
|
|
51
|
+
return {
|
|
52
|
+
...(event === "PreToolUse" || event === "PermissionRequest"
|
|
53
|
+
? { matcher: "^Bash$" }
|
|
54
|
+
: {}),
|
|
55
|
+
hooks: [
|
|
56
|
+
{
|
|
57
|
+
type: "command",
|
|
58
|
+
command,
|
|
59
|
+
commandWindows: command,
|
|
60
|
+
timeout: 30,
|
|
61
|
+
statusMessage: `Running agent-ops ${event}`
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
};
|
|
65
|
+
}
|
|
38
66
|
export function codexHookTarget(scope) {
|
|
39
67
|
return {
|
|
40
68
|
path: ".codex/hooks.json",
|
|
@@ -45,11 +73,18 @@ export function codexHookTarget(scope) {
|
|
|
45
73
|
export function buildCodexHookConfig(capabilities, runtimePath) {
|
|
46
74
|
assertRuntimePath(runtimePath);
|
|
47
75
|
const hooks = {};
|
|
48
|
-
if (capabilities.includes("
|
|
49
|
-
|
|
76
|
+
if (capabilities.includes("project-loop")) {
|
|
77
|
+
for (const event of CODEX_LOOP_EVENTS) {
|
|
78
|
+
hooks[event] = [loopMatcherGroup(event)];
|
|
79
|
+
}
|
|
50
80
|
}
|
|
51
|
-
|
|
52
|
-
|
|
81
|
+
else {
|
|
82
|
+
if (capabilities.includes("lifecycle-summary")) {
|
|
83
|
+
hooks.SessionStart = [matcherGroup("SessionStart", runtimePath)];
|
|
84
|
+
}
|
|
85
|
+
if (capabilities.includes("command-policy")) {
|
|
86
|
+
hooks.PreToolUse = [matcherGroup("PreToolUse", runtimePath)];
|
|
87
|
+
}
|
|
53
88
|
}
|
|
54
89
|
if (capabilities.includes("optional-stop-verify")) {
|
|
55
90
|
hooks.Stop = [matcherGroup("Stop", runtimePath)];
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
export const CODEX_SUPPORTED_EVENTS = [
|
|
2
2
|
"SessionStart",
|
|
3
|
+
"UserPromptSubmit",
|
|
3
4
|
"PreToolUse",
|
|
5
|
+
"PermissionRequest",
|
|
6
|
+
"PostToolUse",
|
|
7
|
+
"PreCompact",
|
|
8
|
+
"PostCompact",
|
|
9
|
+
"SubagentStart",
|
|
10
|
+
"SubagentStop",
|
|
4
11
|
"Stop"
|
|
5
12
|
];
|
|
6
13
|
export function codexMatcherSupport(event) {
|
|
@@ -107,6 +107,7 @@ export function mergeConfigLayers(inputLayers) {
|
|
|
107
107
|
const commands = new Map();
|
|
108
108
|
const mappings = new Map();
|
|
109
109
|
const exceptions = new Map();
|
|
110
|
+
const reviewRoles = new Map();
|
|
110
111
|
let schemaVersion;
|
|
111
112
|
let features;
|
|
112
113
|
for (const layer of layers) {
|
|
@@ -128,6 +129,12 @@ export function mergeConfigLayers(inputLayers) {
|
|
|
128
129
|
}
|
|
129
130
|
mappings.set(key, effective(mapping, layer));
|
|
130
131
|
}
|
|
132
|
+
// Keyed by role so a project can override one role without inheriting the
|
|
133
|
+
// rest. Review targets are a capability choice, not a guardrail, so no
|
|
134
|
+
// monotonic restriction applies.
|
|
135
|
+
for (const reviewRole of layer.config.reviewRoles ?? []) {
|
|
136
|
+
reviewRoles.set(reviewRole.role, effective(reviewRole, layer));
|
|
137
|
+
}
|
|
131
138
|
for (const securityException of layer.config.securityExceptions) {
|
|
132
139
|
const key = exceptionKey(securityException);
|
|
133
140
|
const existing = exceptions.get(key);
|
|
@@ -150,7 +157,8 @@ export function mergeConfigLayers(inputLayers) {
|
|
|
150
157
|
profiles: [...profiles.values()],
|
|
151
158
|
verificationCommands: [...commands.values()],
|
|
152
159
|
pathMappings: [...mappings.values()],
|
|
153
|
-
securityExceptions: [...exceptions.values()]
|
|
160
|
+
securityExceptions: [...exceptions.values()],
|
|
161
|
+
reviewRoles: [...reviewRoles.values()]
|
|
154
162
|
};
|
|
155
163
|
const config = {
|
|
156
164
|
schemaVersion: schemaVersion.value,
|
|
@@ -160,7 +168,14 @@ export function mergeConfigLayers(inputLayers) {
|
|
|
160
168
|
},
|
|
161
169
|
features: provenance.features.value,
|
|
162
170
|
pathMappings: provenance.pathMappings.map(({ value }) => value),
|
|
163
|
-
securityExceptions: provenance.securityExceptions.map(({ value }) => value)
|
|
171
|
+
securityExceptions: provenance.securityExceptions.map(({ value }) => value),
|
|
172
|
+
// Absent, not empty: an empty array would read as "configured with no
|
|
173
|
+
// targets" rather than "external review disabled".
|
|
174
|
+
...(provenance.reviewRoles.length === 0
|
|
175
|
+
? {}
|
|
176
|
+
: {
|
|
177
|
+
reviewRoles: provenance.reviewRoles.map(({ value }) => value)
|
|
178
|
+
})
|
|
164
179
|
};
|
|
165
180
|
const validation = validateConfig(config);
|
|
166
181
|
if (!validation.ok) {
|