@kylecheng3146/agent-ops 0.1.1 → 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,68 @@
1
+ import { buildClaudeHookSettings } from "../adapters/claude/config.js";
2
+ import { buildCodexHookConfig } from "../adapters/codex/config.js";
3
+ import { resolveProfiles } from "./profiles.js";
4
+ const CLAUDE_HOOK_MARKER = "--managed-by=agent-ops";
5
+ const CODEX_COMMAND_PREFIX = "agent-ops hook codex ";
6
+ function isRecord(value) {
7
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8
+ }
9
+ function hasManagedHandler(source, events, isManaged) {
10
+ if (!isRecord(source) || !isRecord(source.hooks)) {
11
+ return false;
12
+ }
13
+ const registered = source.hooks;
14
+ return events.every((event) => {
15
+ const groups = registered[event];
16
+ return (Array.isArray(groups) &&
17
+ groups.some((group) => isRecord(group) &&
18
+ Array.isArray(group.hooks) &&
19
+ group.hooks.some(isManaged)));
20
+ });
21
+ }
22
+ function isManagedClaudeHandler(handler) {
23
+ return (isRecord(handler) &&
24
+ Array.isArray(handler.args) &&
25
+ handler.args.includes(CLAUDE_HOOK_MARKER));
26
+ }
27
+ function isManagedCodexHandler(handler) {
28
+ return (isRecord(handler) &&
29
+ typeof handler.command === "string" &&
30
+ handler.command.startsWith(CODEX_COMMAND_PREFIX));
31
+ }
32
+ /**
33
+ * Hook registration is satisfied when every hook event implied by the
34
+ * installed profiles carries an agent-ops owned handler for every installed
35
+ * harness. Installations without hook capabilities have nothing to register.
36
+ */
37
+ export function hookRegistrationSatisfied(input) {
38
+ const { capabilities } = resolveProfiles(input.profiles);
39
+ const claudeEvents = Object.keys(buildClaudeHookSettings(capabilities, "probe").hooks);
40
+ const codexEvents = Object.keys(buildCodexHookConfig(capabilities).hooks);
41
+ if (claudeEvents.length === 0 && codexEvents.length === 0) {
42
+ return true;
43
+ }
44
+ if (input.harness !== "codex" &&
45
+ !hasManagedHandler(input.claudeSettings, claudeEvents, isManagedClaudeHandler)) {
46
+ return false;
47
+ }
48
+ return (input.harness === "claude" ||
49
+ hasManagedHandler(input.codexHooks, codexEvents, isManagedCodexHandler));
50
+ }
51
+ /**
52
+ * Smoke availability stays UNKNOWN until the repository declares a
53
+ * verification command; the toolkit never invents one.
54
+ */
55
+ export function smokeAvailabilityStatus(config) {
56
+ return config.verification.commands.length > 0 ? "PASS" : "UNKNOWN";
57
+ }
58
+ /**
59
+ * Installation approval never grants trust, so an ungranted repository is
60
+ * unconfigured rather than broken. A stale binding is a real failure.
61
+ */
62
+ export function repositoryTrustStatus(trust) {
63
+ return trust === "TRUSTED"
64
+ ? "PASS"
65
+ : trust === "STALE"
66
+ ? "FAIL"
67
+ : "UNKNOWN";
68
+ }
@@ -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
  }
@@ -241,7 +241,9 @@ async function readWindowsProcessIdentity(processId) {
241
241
  ], {
242
242
  encoding: "utf8",
243
243
  maxBuffer: 4096,
244
- timeout: 2000,
244
+ // ponytail: cold PowerShell start on a loaded machine routinely
245
+ // passes two seconds; the lock budget above still bounds the wait.
246
+ timeout: 10_000,
245
247
  windowsHide: true
246
248
  });
247
249
  const startedAt = result.stdout.trim();
@@ -251,10 +253,27 @@ async function readWindowsProcessIdentity(processId) {
251
253
  return null;
252
254
  }
253
255
  }
256
+ /**
257
+ * A live process cannot change its own start time, so a successful self
258
+ * lookup never expires. Every other entry keeps the short TTL: foreign
259
+ * process IDs get recycled, and a failed self lookup must stay retryable
260
+ * instead of pinning the fallback identity for the whole run.
261
+ */
262
+ export function reuseCachedProcessIdentity(entry, processId, selfProcessId, now) {
263
+ if (entry === undefined) {
264
+ return false;
265
+ }
266
+ if (processId === selfProcessId &&
267
+ entry.identity !== null &&
268
+ !entry.identity.startsWith("runtime:")) {
269
+ return true;
270
+ }
271
+ return now - entry.checkedAt <= PROCESS_IDENTITY_CACHE_MS;
272
+ }
254
273
  async function readProcessIdentity(processId) {
255
274
  const cached = processIdentityCache.get(processId);
256
- if (cached !== undefined &&
257
- Date.now() - cached.checkedAt <= PROCESS_IDENTITY_CACHE_MS) {
275
+ if (reuseCachedProcessIdentity(cached, processId, process.pid, Date.now()) &&
276
+ cached !== undefined) {
258
277
  return cached.identity;
259
278
  }
260
279
  let identity;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Evidence-driven development loops for Codex and Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,6 +26,12 @@
26
26
  "items": {
27
27
  "$ref": "#/$defs/managedMarker"
28
28
  }
29
+ },
30
+ "hooks": {
31
+ "type": "array",
32
+ "items": {
33
+ "$ref": "#/$defs/managedHook"
34
+ }
29
35
  }
30
36
  },
31
37
  "$defs": {
@@ -104,6 +110,33 @@
104
110
  }
105
111
  }
106
112
  },
113
+ "managedHook": {
114
+ "type": "object",
115
+ "additionalProperties": false,
116
+ "required": ["id", "path", "harness", "events", "owner"],
117
+ "properties": {
118
+ "id": {
119
+ "$ref": "#/$defs/id"
120
+ },
121
+ "path": {
122
+ "$ref": "#/$defs/relativePath"
123
+ },
124
+ "harness": {
125
+ "enum": ["codex", "claude"]
126
+ },
127
+ "events": {
128
+ "type": "array",
129
+ "minItems": 1,
130
+ "uniqueItems": true,
131
+ "items": {
132
+ "enum": ["SessionStart", "PreToolUse", "Stop"]
133
+ }
134
+ },
135
+ "owner": {
136
+ "const": "agent-ops"
137
+ }
138
+ }
139
+ },
107
140
  "nonEmptyString": {
108
141
  "type": "string",
109
142
  "pattern": "[^\\s\\u0000]",