@kylecheng3146/agent-ops 0.1.1 → 0.1.2

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.
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { createInterface } from "node:readline/promises";
5
6
  import { execFileSync } from "node:child_process";
6
7
  import { commonHarnessAdapters } from "../../../runtime/src/install/harness.js";
8
+ import { hookRegistrationSatisfied, repositoryTrustStatus, smokeAvailabilityStatus } from "../../../runtime/src/install/probes.js";
9
+ import { parseInstallManifest } from "../../../runtime/src/fs/manifest.js";
7
10
  import { NpmRegistryClient } from "../../../runtime/src/registry/npm.js";
8
11
  import { TaskService } from "../../../runtime/src/task/service.js";
9
12
  import { FileTaskStore } from "../../../runtime/src/task/store.js";
@@ -28,7 +31,7 @@ import { runTrustCommand } from "./commands/trust.js";
28
31
  import { runVerifyCommand } from "./commands/verify.js";
29
32
  import { formatUpdatePlan, runUpdateCommand } from "./commands/update.js";
30
33
  import { errorEnvelope } from "./output.js";
31
- const CLI_VERSION = "0.1.0";
34
+ const CLI_VERSION = "0.1.2";
32
35
  const DEFAULT_CONFIG = {
33
36
  schemaVersion: 1,
34
37
  profiles: [],
@@ -95,6 +98,23 @@ async function loadEffectiveConfig(root, scope) {
95
98
  }
96
99
  return mergeConfigLayers(layers);
97
100
  }
101
+ async function readOptionalJson(path) {
102
+ try {
103
+ return JSON.parse(await readFile(path, "utf8"));
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ async function installedHarness(root) {
110
+ try {
111
+ return parseInstallManifest(await readFile(join(root, ".agent-ops", "manifest.json"), "utf8")).harness;
112
+ }
113
+ catch {
114
+ // ponytail: no readable manifest means demand hooks for both harnesses.
115
+ return "both";
116
+ }
117
+ }
98
118
  async function repositoryTrust(root, config) {
99
119
  const home = process.env.AGENT_OPS_HOME ?? homedir();
100
120
  const state = localStatePaths(home);
@@ -175,7 +195,20 @@ process.exitCode = await runCli(process.argv.slice(2), {
175
195
  });
176
196
  }
177
197
  if (args.command === "doctor") {
178
- return await runDoctorCommand({ root });
198
+ const config = (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config;
199
+ return await runDoctorCommand({
200
+ root,
201
+ probes: {
202
+ hookRegistration: async () => hookRegistrationSatisfied({
203
+ harness: await installedHarness(root),
204
+ profiles: config.profiles,
205
+ claudeSettings: await readOptionalJson(join(root, ".claude", "settings.json")),
206
+ codexHooks: await readOptionalJson(join(root, ".codex", "hooks.json"))
207
+ }),
208
+ repositoryTrust: async () => repositoryTrustStatus(await repositoryTrust(root, config)),
209
+ smokeAvailability: () => smokeAvailabilityStatus(config)
210
+ }
211
+ });
179
212
  }
180
213
  if (args.command === "uninstall") {
181
214
  return await runUninstallCommand({
@@ -6,6 +6,17 @@ import { fileURLToPath } from "node:url";
6
6
  import { sha256 } from "./hash.js";
7
7
  import { AgentOpsError, resolveContainedPath } from "./paths.js";
8
8
  export { AgentOpsError } from "./paths.js";
9
+ /**
10
+ * Identity must come from bigint stats. Windows file indexes pack a sequence
11
+ * number above the 2^53 boundary, so a numeric `ino` silently rounds and an
12
+ * untouched file looks like it changed.
13
+ */
14
+ export function fileIdentity(status) {
15
+ return {
16
+ device: status.dev.toString(),
17
+ inode: status.ino.toString()
18
+ };
19
+ }
9
20
  const MUTATION_WORKER_PATH = fileURLToPath(new URL("./mutation-worker.js", import.meta.url));
10
21
  function isMissing(error) {
11
22
  return (typeof error === "object" &&
@@ -25,10 +36,11 @@ async function captureParentGuard(parent) {
25
36
  normalizedPath(canonicalPath) !== normalizedPath(parent)) {
26
37
  throw new AgentOpsError("PRECONDITION_CHANGED", `Destination directory changed before mutation: ${parent}`);
27
38
  }
39
+ const identity = fileIdentity(status);
28
40
  return {
29
41
  expectedParentPath: canonicalPath,
30
- parentDevice: status.dev.toString(),
31
- parentInode: status.ino.toString()
42
+ parentDevice: identity.device,
43
+ parentInode: identity.inode
32
44
  };
33
45
  }
34
46
  async function runAnchoredMutation(targetPath, action, expectedHash, content, mode) {
@@ -162,20 +174,21 @@ async function createBackup(snapshot, recoveryDirectory) {
162
174
  async function snapshotOperation(root, operation) {
163
175
  const targetPath = await resolveContainedPath(root, operation.path);
164
176
  try {
165
- const status = await lstat(targetPath);
177
+ const status = await lstat(targetPath, { bigint: true });
166
178
  if (!status.isFile()) {
167
179
  throw new AgentOpsError("UNSUPPORTED_FILE_TYPE", `Managed target must be a regular file: ${operation.path}`);
168
180
  }
169
181
  const content = await readFile(targetPath);
182
+ const identity = fileIdentity(status);
170
183
  return {
171
184
  operation,
172
185
  targetPath,
173
186
  existed: true,
174
187
  content,
175
- mode: status.mode & 0o777,
188
+ mode: Number(status.mode) & 0o777,
176
189
  actualHash: sha256(content),
177
- device: status.dev.toString(),
178
- inode: status.ino.toString(),
190
+ device: identity.device,
191
+ inode: identity.inode,
179
192
  backupPath: null,
180
193
  createdDirectories: []
181
194
  };
@@ -219,10 +232,7 @@ async function currentIdentity(path) {
219
232
  if (!status.isFile() || status.isSymbolicLink()) {
220
233
  throw new AgentOpsError("PRECONDITION_CHANGED", `A managed path is no longer a regular file: ${path}`);
221
234
  }
222
- return {
223
- device: status.dev.toString(),
224
- inode: status.ino.toString()
225
- };
235
+ return fileIdentity(status);
226
236
  }
227
237
  catch (error) {
228
238
  if (isMissing(error)) {
@@ -165,9 +165,13 @@ async function checkProbe(id, probe) {
165
165
  return check(id, "UNKNOWN", "No probe was provided.");
166
166
  }
167
167
  try {
168
- return (await probe())
169
- ? check(id, "PASS", "Probe passed.")
170
- : check(id, "FAIL", "Probe failed.");
168
+ const result = await probe();
169
+ const status = typeof result === "boolean" ? (result ? "PASS" : "FAIL") : result;
170
+ return check(id, status, status === "PASS"
171
+ ? "Probe passed."
172
+ : status === "FAIL"
173
+ ? "Probe failed."
174
+ : "Probe has nothing to verify yet.");
171
175
  }
172
176
  catch {
173
177
  return check(id, "FAIL", "Probe failed.");
@@ -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
+ }
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.2",
4
4
  "description": "Evidence-driven development loops for Codex and Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",