@kylecheng3146/agent-ops 0.1.2 → 0.1.3

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.
@@ -0,0 +1,102 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { loadConfigFile } from "../../../runtime/src/config/load.js";
5
+ import { mergeConfigLayers } from "../../../runtime/src/config/merge.js";
6
+ import { sha256 } from "../../../runtime/src/fs/hash.js";
7
+ import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
8
+ import { localStatePaths } from "../../../runtime/src/security/permissions.js";
9
+ import { calculateTrustBinding, FileTrustStore } from "../../../runtime/src/security/trust.js";
10
+ export const DEFAULT_CONFIG = {
11
+ schemaVersion: 1,
12
+ profiles: [],
13
+ verification: { commands: [] },
14
+ pathMappings: [],
15
+ securityExceptions: []
16
+ };
17
+ function defaultConfigLayer() {
18
+ return {
19
+ source: "default",
20
+ sourcePath: "built-in defaults",
21
+ config: DEFAULT_CONFIG
22
+ };
23
+ }
24
+ async function loadOptionalConfig(path) {
25
+ try {
26
+ return await loadConfigFile(path);
27
+ }
28
+ catch (error) {
29
+ if (error instanceof AgentOpsError &&
30
+ error.code === "CONFIG_READ_FAILED" &&
31
+ typeof error.cause === "object" &&
32
+ error.cause !== null &&
33
+ "code" in error.cause &&
34
+ error.cause.code === "ENOENT") {
35
+ return null;
36
+ }
37
+ throw error;
38
+ }
39
+ }
40
+ export async function loadEffectiveConfig(root, scope) {
41
+ const home = process.env.AGENT_OPS_HOME ?? homedir();
42
+ const userPath = join(home, ".agent-ops", "config.json");
43
+ const projectPath = join(root, ".agent-ops", "config.json");
44
+ const layers = [defaultConfigLayer()];
45
+ if (scope === "user") {
46
+ const user = await loadOptionalConfig(userPath);
47
+ if (user !== null) {
48
+ layers.push({
49
+ source: "user",
50
+ sourcePath: user.sourcePath,
51
+ config: user.config
52
+ });
53
+ }
54
+ return mergeConfigLayers(layers);
55
+ }
56
+ if (projectPath !== userPath) {
57
+ const user = await loadOptionalConfig(userPath);
58
+ if (user !== null) {
59
+ layers.push({
60
+ source: "user",
61
+ sourcePath: user.sourcePath,
62
+ config: user.config
63
+ });
64
+ }
65
+ }
66
+ const project = await loadOptionalConfig(projectPath);
67
+ if (project !== null) {
68
+ layers.push({
69
+ source: "project",
70
+ sourcePath: project.sourcePath,
71
+ config: project.config
72
+ });
73
+ }
74
+ return mergeConfigLayers(layers);
75
+ }
76
+ export function repositoryRemoteUrl(root) {
77
+ try {
78
+ return execFileSync("git", ["config", "--get", "remote.origin.url"], {
79
+ cwd: root,
80
+ encoding: "utf8"
81
+ }).trim();
82
+ }
83
+ catch {
84
+ return `local:${root}`;
85
+ }
86
+ }
87
+ export async function repositoryTrust(root, config, cliVersion) {
88
+ const home = process.env.AGENT_OPS_HOME ?? homedir();
89
+ const state = localStatePaths(home);
90
+ try {
91
+ const binding = await calculateTrustBinding({
92
+ repositoryPath: root,
93
+ remoteUrl: repositoryRemoteUrl(root),
94
+ configHash: sha256(JSON.stringify(config)),
95
+ runtimeHash: sha256(cliVersion)
96
+ });
97
+ return (await new FileTrustStore(state.trustStore, state.anchorDirectory).status(binding)).status;
98
+ }
99
+ catch {
100
+ return "UNTRUSTED";
101
+ }
102
+ }
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ // Entry point registered in Claude settings as:
3
+ // node <this file> <harness> <event> --managed-by=agent-ops
4
+ import { CLI_VERSION } from "./version.js";
5
+ import { runHookProcess } from "./hook-process.js";
6
+ process.exitCode = await runHookProcess(process.argv.slice(2), {
7
+ stdin: process.stdin,
8
+ writeStdout: (value) => process.stdout.write(value),
9
+ writeStderr: (value) => process.stderr.write(value)
10
+ }, CLI_VERSION);
@@ -0,0 +1,54 @@
1
+ import { runHookCommand, HOOK_EVENTS } from "./commands/hook.js";
2
+ import { loadEffectiveConfig, repositoryTrust } from "./context.js";
3
+ const HARNESSES = new Set(["codex", "claude"]);
4
+ const MAX_HOOK_INPUT_BYTES = 1024 * 1024;
5
+ async function readStdin(stream) {
6
+ const chunks = [];
7
+ let total = 0;
8
+ for await (const chunk of stream) {
9
+ const buffer = Buffer.isBuffer(chunk)
10
+ ? chunk
11
+ : Buffer.from(String(chunk), "utf8");
12
+ total += buffer.byteLength;
13
+ if (total > MAX_HOOK_INPUT_BYTES) {
14
+ return "";
15
+ }
16
+ chunks.push(buffer);
17
+ }
18
+ return Buffer.concat(chunks, total).toString("utf8");
19
+ }
20
+ /**
21
+ * Runs one hook invocation. Always resolves to exit code 0: a hook that
22
+ * cannot answer must never block the harness it advises.
23
+ */
24
+ export async function runHookProcess(argv, io, cliVersion) {
25
+ const [harness, event] = argv;
26
+ if (harness === undefined ||
27
+ !HARNESSES.has(harness) ||
28
+ event === undefined ||
29
+ !HOOK_EVENTS.includes(event)) {
30
+ io.writeStderr("Usage: agent-ops hook <codex|claude> <SessionStart|PreToolUse|Stop>\n");
31
+ return 0;
32
+ }
33
+ try {
34
+ const root = process.cwd();
35
+ const config = (await loadEffectiveConfig(root, "project")).config;
36
+ const output = await runHookCommand({
37
+ harness: harness,
38
+ event: event,
39
+ stdin: await readStdin(io.stdin),
40
+ config,
41
+ trusted: (await repositoryTrust(root, config, cliVersion)) === "TRUSTED"
42
+ });
43
+ if (output.stdout.length > 0) {
44
+ io.writeStdout(output.stdout);
45
+ }
46
+ if (output.stderr.length > 0) {
47
+ io.writeStderr(output.stderr);
48
+ }
49
+ }
50
+ catch {
51
+ // ponytail: fail-open by design; hook failures stay invisible to the harness.
52
+ }
53
+ return 0;
54
+ }
@@ -0,0 +1,132 @@
1
+ // figlet "agent-ops" -f Standard, trimmed of trailing blank lines/columns.
2
+ const BANNER = [
3
+ " _",
4
+ " __ _ __ _ ___ _ __ | |_ ___ _ __ ___ ",
5
+ " / _\` |/ _\` |/ _ \\ '_ \\| __|____ / _ \\| '_ \\/ __|",
6
+ " | (_| | (_| | __/ | | | ||_____| (_) | |_) \\__ \\",
7
+ " \\__,_|\\__, |\\___|_| |_|\\__| \\___/| .__/|___/",
8
+ " |___/ |_|"
9
+ ].join("\n");
10
+ const TAGLINE = "loop engineering toolkit";
11
+ const MIN_BANNER_COLUMNS = 54;
12
+ const RAIL = "\u2502";
13
+ const DIAMOND = "\u25c6";
14
+ const DOT_ON = "\u25cf";
15
+ const DOT_OFF = "\u25cb";
16
+ function envColorPreference() {
17
+ if (process.env.NO_COLOR !== undefined) {
18
+ return "off";
19
+ }
20
+ if (process.env.FORCE_COLOR !== undefined) {
21
+ return "on";
22
+ }
23
+ return undefined;
24
+ }
25
+ function useColor(output) {
26
+ const preference = envColorPreference();
27
+ return preference === undefined ? output.isTTY : preference === "on";
28
+ }
29
+ function paint(output, code, text) {
30
+ return useColor(output) ? `\u001b[${code}m${text}\u001b[0m` : text;
31
+ }
32
+ const dim = (output, text) => paint(output, "2", text);
33
+ const bold = (output, text) => paint(output, "1", text);
34
+ const green = (output, text) => paint(output, "32", text);
35
+ const cyan = (output, text) => paint(output, "36", text);
36
+ /**
37
+ * Decorative only: skipped for --json output, non-interactive runs, and
38
+ * terminals too narrow to render the wordmark without wrapping.
39
+ */
40
+ export function writeBanner(output) {
41
+ if (!output.isTTY || (output.columns ?? 0) < MIN_BANNER_COLUMNS) {
42
+ return;
43
+ }
44
+ output.write(`${cyan(output, BANNER)}\n${dim(output, TAGLINE)}\n\n`);
45
+ }
46
+ function eraseLines(write, count) {
47
+ for (let index = 0; index < count; index += 1) {
48
+ write("\u001b[1A\u001b[2K");
49
+ }
50
+ }
51
+ function renderChoice(outputLike, question, value) {
52
+ const yes = value
53
+ ? bold(outputLike, green(outputLike, `${DOT_ON} Yes`))
54
+ : dim(outputLike, `${DOT_OFF} Yes`);
55
+ const no = !value
56
+ ? bold(outputLike, green(outputLike, `${DOT_ON} No`))
57
+ : dim(outputLike, `${DOT_OFF} No`);
58
+ return [
59
+ `${bold(outputLike, DIAMOND)} ${question}`,
60
+ `${dim(outputLike, RAIL)} ${yes} / ${no}`
61
+ ].join("\n");
62
+ }
63
+ async function typedFallback(question, io, defaultValue) {
64
+ const { createInterface } = await import("node:readline/promises");
65
+ const readline = createInterface({ input: io.input, output: io.output });
66
+ try {
67
+ const suffix = defaultValue ? "Y/n" : "y/N";
68
+ const answer = (await readline.question(`${question} [${suffix}]: `)).trim().toLowerCase();
69
+ if (answer === "") {
70
+ return defaultValue;
71
+ }
72
+ return answer === "y" || answer === "yes";
73
+ }
74
+ finally {
75
+ readline.close();
76
+ }
77
+ }
78
+ /**
79
+ * Arrow-key Yes/No selector. Falls back to a typed y/N prompt when stdin
80
+ * cannot enter raw mode (piped input, or a stub stream in tests), so the
81
+ * same call works under both a real terminal and test harnesses.
82
+ */
83
+ export async function selectYesNo(question, io, defaultValue = false) {
84
+ if (typeof io.input.setRawMode !== "function" ||
85
+ io.input.isTTY !== true) {
86
+ return await typedFallback(question, io, defaultValue);
87
+ }
88
+ const outputLike = {
89
+ isTTY: true,
90
+ columns: io.output.columns,
91
+ write: (value) => io.output.write(value)
92
+ };
93
+ const { emitKeypressEvents } = await import("node:readline");
94
+ emitKeypressEvents(io.input);
95
+ io.input.setRawMode(true);
96
+ io.input.resume();
97
+ let value = defaultValue;
98
+ let rendered = renderChoice(outputLike, question, value);
99
+ io.output.write(`${rendered}\n`);
100
+ return await new Promise((resolve) => {
101
+ const cleanup = () => {
102
+ io.input.setRawMode?.(false);
103
+ io.input.pause();
104
+ io.input.removeListener("keypress", onKeypress);
105
+ };
106
+ const redraw = () => {
107
+ eraseLines((value_) => io.output.write(value_), rendered.split("\n").length);
108
+ rendered = renderChoice(outputLike, question, value);
109
+ io.output.write(`${rendered}\n`);
110
+ };
111
+ const onKeypress = (_chunk, key) => {
112
+ if (key?.ctrl === true && key.name === "c") {
113
+ cleanup();
114
+ process.exit(130);
115
+ }
116
+ if (key?.name === "left" ||
117
+ key?.name === "right" ||
118
+ key?.name === "tab" ||
119
+ key?.name === "h" ||
120
+ key?.name === "l") {
121
+ value = !value;
122
+ redraw();
123
+ return;
124
+ }
125
+ if (key?.name === "return" || key?.name === "space") {
126
+ cleanup();
127
+ resolve(value);
128
+ }
129
+ };
130
+ io.input.on("keypress", onKeypress);
131
+ });
132
+ }
@@ -0,0 +1,3 @@
1
+ // Single source of the published CLI version. Release preparation updates
2
+ // this constant; bin.ts and the hook entry both read it from here.
3
+ export const CLI_VERSION = "0.1.3";
@@ -83,6 +83,25 @@ function hookRecord(settings) {
83
83
  }
84
84
  return settings.hooks;
85
85
  }
86
+ /**
87
+ * Removes every agent-ops owned handler and leaves foreign hooks untouched.
88
+ */
89
+ export function stripClaudeManagedHooks(existing) {
90
+ if (!isRecord(existing)) {
91
+ throw new AgentOpsError("CLAUDE_SETTINGS_INVALID", "Claude settings must be a JSON object.");
92
+ }
93
+ const existingHooks = hookRecord(existing);
94
+ const hooks = {};
95
+ for (const [eventName, groups] of Object.entries(existingHooks)) {
96
+ const preserved = groups
97
+ .map(withoutOwnedHandlers)
98
+ .filter((group) => group !== null);
99
+ if (preserved.length > 0) {
100
+ hooks[eventName] = preserved;
101
+ }
102
+ }
103
+ return { ...existing, hooks };
104
+ }
86
105
  export function mergeClaudeSettings(existing, managed) {
87
106
  if (!isRecord(existing)) {
88
107
  throw new AgentOpsError("CLAUDE_SETTINGS_INVALID", "Claude settings must be a JSON object.");
@@ -67,6 +67,22 @@ function hookRecord(value) {
67
67
  }
68
68
  return value.hooks;
69
69
  }
70
+ /**
71
+ * Removes every agent-ops owned handler and leaves foreign hooks untouched.
72
+ */
73
+ export function stripCodexManagedHooks(existing) {
74
+ const existingHooks = hookRecord(existing);
75
+ const hooks = {};
76
+ for (const [eventName, groups] of Object.entries(existingHooks)) {
77
+ const preserved = groups
78
+ .map(withoutOwnedHandlers)
79
+ .filter((group) => group !== null);
80
+ if (preserved.length > 0) {
81
+ hooks[eventName] = preserved;
82
+ }
83
+ }
84
+ return { ...existing, hooks };
85
+ }
70
86
  export function mergeCodexHookConfig(existing, managed) {
71
87
  if (!isRecord(existing)) {
72
88
  throw new AgentOpsError("CODEX_HOOK_CONFIG_INVALID", "Codex hook configuration must be a JSON object.");
@@ -57,7 +57,7 @@ export function commonHarnessAdapters() {
57
57
  };
58
58
  });
59
59
  }
60
- function requestedHarnessIds(harness) {
60
+ export function requestedHarnessIds(harness) {
61
61
  return harness === "both" ? ["codex", "claude"] : [harness];
62
62
  }
63
63
  function selectAdapter(id, adapters) {
@@ -0,0 +1,77 @@
1
+ import { buildClaudeHookSettings, mergeClaudeSettings, stripClaudeManagedHooks } from "../adapters/claude/config.js";
2
+ import { buildCodexHookConfig, mergeCodexHookConfig, stripCodexManagedHooks } from "../adapters/codex/config.js";
3
+ import { AgentOpsError } from "../fs/paths.js";
4
+ export const CLAUDE_HOOK_PATH = ".claude/settings.json";
5
+ export const CODEX_HOOK_PATH = ".codex/hooks.json";
6
+ function format(value) {
7
+ return `${JSON.stringify(value, null, 2)}\n`;
8
+ }
9
+ function parseSettings(path, source) {
10
+ if (source === null || source.trim().length === 0) {
11
+ return {};
12
+ }
13
+ try {
14
+ return JSON.parse(source);
15
+ }
16
+ catch (error) {
17
+ throw new AgentOpsError("HOOK_SETTINGS_INVALID_JSON", `Hook settings are not valid JSON: ${path}`, { cause: error });
18
+ }
19
+ }
20
+ export function hookRegistrationPath(harness, scope) {
21
+ // ponytail: user scope resolves against AGENT_OPS_HOME, so the same
22
+ // relative path serves both scopes.
23
+ void scope;
24
+ return harness === "claude" ? CLAUDE_HOOK_PATH : CODEX_HOOK_PATH;
25
+ }
26
+ /**
27
+ * Builds the merged settings file for one harness, preserving foreign hooks.
28
+ * Returns null when the selected capabilities register no events at all.
29
+ */
30
+ export function planHookRegistration(options) {
31
+ const path = hookRegistrationPath(options.harness, options.scope);
32
+ const managed = options.harness === "claude"
33
+ ? buildClaudeHookSettings(options.capabilities, options.runtimePath)
34
+ : buildCodexHookConfig(options.capabilities);
35
+ const events = Object.keys(managed.hooks);
36
+ if (events.length === 0) {
37
+ return null;
38
+ }
39
+ const existing = parseSettings(path, options.currentSource);
40
+ const merged = options.harness === "claude"
41
+ ? mergeClaudeSettings(existing, managed)
42
+ : mergeCodexHookConfig(existing, managed);
43
+ return {
44
+ content: format(merged),
45
+ record: {
46
+ id: `${options.harness}-hooks`,
47
+ path,
48
+ harness: options.harness,
49
+ events,
50
+ owner: "agent-ops"
51
+ }
52
+ };
53
+ }
54
+ function onlyManagedRemains(harness, value) {
55
+ const ownKeys = new Set(harness === "codex" ? ["hooks", "description"] : ["hooks"]);
56
+ const hooks = value.hooks;
57
+ return (Object.keys(value).every((key) => ownKeys.has(key)) &&
58
+ typeof hooks === "object" &&
59
+ hooks !== null &&
60
+ Object.keys(hooks).length === 0);
61
+ }
62
+ /**
63
+ * Strips owned handlers from a hook settings file. The file is removed only
64
+ * when nothing but agent-ops content is left, so foreign settings survive.
65
+ */
66
+ export function planHookRemoval(record, currentSource) {
67
+ const existing = parseSettings(record.path, currentSource);
68
+ const stripped = record.harness === "claude"
69
+ ? stripClaudeManagedHooks(existing)
70
+ : stripCodexManagedHooks(existing);
71
+ return {
72
+ path: record.path,
73
+ content: onlyManagedRemains(record.harness, stripped)
74
+ ? null
75
+ : format(stripped)
76
+ };
77
+ }
@@ -1,6 +1,7 @@
1
1
  import { applyManagedBlock, managedBlockMarkers } from "../fs/managed-block.js";
2
2
  import { AgentOpsError } from "../fs/paths.js";
3
3
  import { COMMON_AGENTS_BLOCK, COMMON_CLAUDE_BLOCK } from "./harness.js";
4
+ import { hookRegistrationPath } from "./hooks.js";
4
5
  function selectedHarnesses(manifest) {
5
6
  return manifest.harness === "both"
6
7
  ? ["codex", "claude"]
@@ -24,6 +25,24 @@ function expectedMarker(manifest, id) {
24
25
  function manifestOwnershipError() {
25
26
  return new AgentOpsError("MANIFEST_OWNERSHIP_INVALID", "The manifest does not match a supported managed installation shape.");
26
27
  }
28
+ /**
29
+ * Hook records are optional: installations without hook capabilities, and
30
+ * manifests written before hook registration existed, carry none.
31
+ */
32
+ function assertSupportedHookRecords(manifest, harnesses) {
33
+ const selected = new Set(harnesses);
34
+ const seen = new Set();
35
+ for (const hook of manifest.hooks ?? []) {
36
+ if (!selected.has(hook.harness) ||
37
+ seen.has(hook.harness) ||
38
+ hook.id !== `${hook.harness}-hooks` ||
39
+ hook.path !== hookRegistrationPath(hook.harness, manifest.scope) ||
40
+ hook.events.length === 0) {
41
+ throw manifestOwnershipError();
42
+ }
43
+ seen.add(hook.harness);
44
+ }
45
+ }
27
46
  export function assertSupportedManifestOwnership(manifest) {
28
47
  const harnesses = selectedHarnesses(manifest);
29
48
  const expectedArtifacts = new Map([
@@ -53,6 +72,7 @@ export function assertSupportedManifestOwnership(manifest) {
53
72
  throw manifestOwnershipError();
54
73
  }
55
74
  }
75
+ assertSupportedHookRecords(manifest, harnesses);
56
76
  return expectedMarkers;
57
77
  }
58
78
  function exactMarkerCount(source, marker) {
@@ -5,7 +5,8 @@ import { applyManagedBlock, managedBlockMarkers } from "../fs/managed-block.js";
5
5
  import { formatInstallManifest, parseInstallManifest } from "../fs/manifest.js";
6
6
  import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
7
7
  import { validateConfig } from "../schema/validate.js";
8
- import { planHarnessContributions } from "./harness.js";
8
+ import { planHarnessContributions, requestedHarnessIds } from "./harness.js";
9
+ import { hookRegistrationPath, planHookRegistration } from "./hooks.js";
9
10
  import { resolveProfiles } from "./profiles.js";
10
11
  const CONFIG_PATH = ".agent-ops/config.json";
11
12
  const MANIFEST_PATH = ".agent-ops/manifest.json";
@@ -233,12 +234,36 @@ export async function createInstallPlan(options) {
233
234
  }
234
235
  const plannedBlocks = await planBlocks(options.root, contribution.blocks);
235
236
  operations.push(...plannedBlocks.operations);
237
+ const hooks = [];
238
+ if (options.hookRuntimePath !== undefined) {
239
+ for (const harness of requestedHarnessIds(options.harness)) {
240
+ const current = await readCurrentFile(options.root, hookRegistrationPath(harness, options.scope));
241
+ const planned = planHookRegistration({
242
+ harness,
243
+ scope: options.scope,
244
+ capabilities: resolved.capabilities,
245
+ runtimePath: options.hookRuntimePath,
246
+ currentSource: current?.content ?? null
247
+ });
248
+ if (planned === null) {
249
+ continue;
250
+ }
251
+ operations.push({
252
+ kind: "write",
253
+ path: planned.record.path,
254
+ content: planned.content,
255
+ expectedHash: current?.hash ?? null
256
+ });
257
+ hooks.push(planned.record);
258
+ }
259
+ }
236
260
  const manifest = {
237
261
  schemaVersion: SCHEMA_VERSION,
238
262
  scope: options.scope,
239
263
  harness: options.harness,
240
264
  artifacts,
241
- markers: plannedBlocks.records
265
+ markers: plannedBlocks.records,
266
+ ...(hooks.length === 0 ? {} : { hooks })
242
267
  };
243
268
  operations.push({
244
269
  kind: "write",
@@ -5,6 +5,7 @@ import { removeManagedBlock } from "../fs/managed-block.js";
5
5
  import { parseInstallManifest } from "../fs/manifest.js";
6
6
  import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
7
7
  import { FileTransaction } from "../fs/transaction.js";
8
+ import { planHookRemoval } from "./hooks.js";
8
9
  import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
9
10
  const MANIFEST_PATH = ".agent-ops/manifest.json";
10
11
  const MAX_UNINSTALL_FILE_BYTES = 1024 * 1024;
@@ -130,6 +131,25 @@ export async function createUninstallPlan(root) {
130
131
  });
131
132
  }
132
133
  operations.push(...await planMarkerFiles(root, manifest.markers, expectedMarkers));
134
+ for (const hook of manifest.hooks ?? []) {
135
+ const current = await readCurrentFile(root, hook.path);
136
+ if (current === null) {
137
+ continue;
138
+ }
139
+ const removal = planHookRemoval(hook, current.content);
140
+ operations.push(removal.content === null
141
+ ? {
142
+ kind: "remove",
143
+ path: hook.path,
144
+ expectedHash: current.hash
145
+ }
146
+ : {
147
+ kind: "write",
148
+ path: hook.path,
149
+ content: removal.content,
150
+ expectedHash: current.hash
151
+ });
152
+ }
133
153
  operations.push({
134
154
  kind: "remove",
135
155
  path: MANIFEST_PATH,
@@ -149,6 +169,7 @@ function allowedPaths(plan) {
149
169
  return new Set([
150
170
  ...plan.manifest.artifacts.map(({ path }) => path.toLowerCase()),
151
171
  ...plan.manifest.markers.map(({ path }) => path.toLowerCase()),
172
+ ...(plan.manifest.hooks ?? []).map(({ path }) => path.toLowerCase()),
152
173
  MANIFEST_PATH
153
174
  ]);
154
175
  }
@@ -187,6 +208,13 @@ async function validateUninstalled(root, manifest) {
187
208
  throw new AgentOpsError("UNINSTALL_VALIDATION_FAILED", `Managed block still exists: ${marker.path}`);
188
209
  }
189
210
  }
211
+ for (const hook of manifest.hooks ?? []) {
212
+ const current = await readCurrentFile(root, hook.path);
213
+ if (current !== null &&
214
+ planHookRemoval(hook, current.content).content !== current.content) {
215
+ throw new AgentOpsError("UNINSTALL_VALIDATION_FAILED", `Managed hook handlers still exist: ${hook.path}`);
216
+ }
217
+ }
190
218
  if (await readCurrentFile(root, MANIFEST_PATH) !== null) {
191
219
  throw new AgentOpsError("UNINSTALL_VALIDATION_FAILED", "Installation manifest still exists.");
192
220
  }
@@ -107,6 +107,9 @@ export async function createUpdatePlan(options) {
107
107
  profiles: configPreview.migrated.profiles,
108
108
  adapters: options.adapters,
109
109
  toolkitVersion: targetVersion,
110
+ ...(options.hookRuntimePath === undefined
111
+ ? {}
112
+ : { hookRuntimePath: options.hookRuntimePath }),
110
113
  existingConfig: {
111
114
  value: configPreview.migrated,
112
115
  sourceHash: configPreview.sourceHash
@@ -6,6 +6,12 @@ const PROFILE_VALUES = new Set(["advisory", "core", "guardrails"]);
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(["both", "claude", "codex"]);
9
+ const HOOK_HARNESS_VALUES = new Set(["claude", "codex"]);
10
+ const HOOK_EVENT_VALUES = new Set([
11
+ "SessionStart",
12
+ "PreToolUse",
13
+ "Stop"
14
+ ]);
9
15
  const MAX_TIMEOUT_MS = 2_147_483_647;
10
16
  const MAX_EXIT_CODE = 4_294_967_295;
11
17
  function failure(code, path, message) {
@@ -520,10 +526,40 @@ function validateManagedMarker(value, path) {
520
526
  }
521
527
  return success(value);
522
528
  }
529
+ function validateManagedHook(value, path) {
530
+ if (!isRecord(value)) {
531
+ return failure("INVALID_TYPE", path, "Expected a managed hook record.");
532
+ }
533
+ const unknown = unknownFieldFailure(value, ["events", "harness", "id", "owner", "path"], path);
534
+ if (unknown !== undefined) {
535
+ return unknown;
536
+ }
537
+ if (!isIdentifier(value.id)) {
538
+ return failure("INVALID_ID", `${path}.id`, "Invalid managed entry ID.");
539
+ }
540
+ if (!isSafeRelativePath(value.path) || value.path === ".") {
541
+ return failure("INVALID_RELATIVE_PATH", `${path}.path`, "Managed paths must be project-relative.");
542
+ }
543
+ if (typeof value.harness !== "string" ||
544
+ !HOOK_HARNESS_VALUES.has(value.harness)) {
545
+ return failure("INVALID_HARNESS", `${path}.harness`, "Hook records must name a single harness.");
546
+ }
547
+ if (!isStringArray(value.events) ||
548
+ value.events.length === 0 ||
549
+ new Set(value.events).size !== value.events.length ||
550
+ value.events.some((event) => !HOOK_EVENT_VALUES.has(event))) {
551
+ return failure("INVALID_HOOK_EVENTS", `${path}.events`, "Hook records must list distinct supported events.");
552
+ }
553
+ if (value.owner !== "agent-ops") {
554
+ return failure("INVALID_OWNER", `${path}.owner`, "Managed entries must be owned by agent-ops.");
555
+ }
556
+ return success(value);
557
+ }
523
558
  export function validateManifest(value) {
524
559
  const root = validateRoot(value, [
525
560
  "artifacts",
526
561
  "harness",
562
+ "hooks",
527
563
  "markers",
528
564
  "schemaVersion",
529
565
  "scope"
@@ -580,5 +616,26 @@ export function validateManifest(value) {
580
616
  markerBoundaries.add(startBoundary);
581
617
  markerBoundaries.add(endBoundary);
582
618
  }
619
+ if (root.hooks !== undefined) {
620
+ if (!Array.isArray(root.hooks)) {
621
+ return failure("INVALID_TYPE", "$.hooks", "hooks must be an array.");
622
+ }
623
+ const hookPaths = new Set();
624
+ for (const [index, hookValue] of root.hooks.entries()) {
625
+ const hook = validateManagedHook(hookValue, `$.hooks[${index}]`);
626
+ if (!hook.ok) {
627
+ return hook;
628
+ }
629
+ const hookPathKey = hook.value.path.toLowerCase();
630
+ if (entryIds.has(hook.value.id)) {
631
+ return failure("DUPLICATE_ID", `$.hooks[${index}].id`, `Duplicate manifest entry ID: ${hook.value.id}`);
632
+ }
633
+ if (artifactPaths.has(hookPathKey) || hookPaths.has(hookPathKey)) {
634
+ return failure("DUPLICATE_OWNERSHIP", `$.hooks[${index}].path`, `Hook path is owned more than once: ${hook.value.path}`);
635
+ }
636
+ entryIds.add(hook.value.id);
637
+ hookPaths.add(hookPathKey);
638
+ }
639
+ }
583
640
  return success(root);
584
641
  }