@kylecheng3146/agent-ops 0.1.4 → 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 (84) hide show
  1. package/README.md +164 -13
  2. package/dist/packages/cli/src/args.js +40 -7
  3. package/dist/packages/cli/src/bin.js +35 -11
  4. package/dist/packages/cli/src/cli.js +7 -2
  5. package/dist/packages/cli/src/codex-loop-process.js +70 -0
  6. package/dist/packages/cli/src/commands/doctor.js +31 -9
  7. package/dist/packages/cli/src/commands/hook.js +33 -16
  8. package/dist/packages/cli/src/commands/init.js +9 -5
  9. package/dist/packages/cli/src/commands/review.js +5 -1
  10. package/dist/packages/cli/src/commands/uninstall.js +6 -5
  11. package/dist/packages/cli/src/commands/update.js +16 -5
  12. package/dist/packages/cli/src/context.js +69 -3
  13. package/dist/packages/cli/src/hook-process.js +231 -16
  14. package/dist/packages/cli/src/loop-entry.js +8 -0
  15. package/dist/packages/cli/src/plan-output.js +9 -4
  16. package/dist/packages/cli/src/public-plan.js +62 -0
  17. package/dist/packages/cli/src/version.js +1 -1
  18. package/dist/packages/cli/src/wizard.js +36 -15
  19. package/dist/runtime/src/adapters/claude/config.js +57 -19
  20. package/dist/runtime/src/adapters/claude/events.js +33 -0
  21. package/dist/runtime/src/adapters/claude/output.js +8 -7
  22. package/dist/runtime/src/adapters/claude/surfaces.js +70 -0
  23. package/dist/runtime/src/adapters/codex/config.js +66 -13
  24. package/dist/runtime/src/adapters/codex/events.js +33 -0
  25. package/dist/runtime/src/adapters/codex/output.js +10 -0
  26. package/dist/runtime/src/adapters/codex/surfaces.js +12 -0
  27. package/dist/runtime/src/adapters/opencode/config.js +170 -0
  28. package/dist/runtime/src/adapters/opencode/events.js +49 -0
  29. package/dist/runtime/src/adapters/opencode/input.js +32 -0
  30. package/dist/runtime/src/adapters/opencode/output.js +23 -0
  31. package/dist/runtime/src/adapters/opencode/surfaces.js +23 -0
  32. package/dist/runtime/src/config/explain.js +7 -0
  33. package/dist/runtime/src/config/hash.js +24 -0
  34. package/dist/runtime/src/config/merge.js +6 -2
  35. package/dist/runtime/src/config/migrate.js +19 -4
  36. package/dist/runtime/src/contracts.js +10 -1
  37. package/dist/runtime/src/fs/managed-block.js +35 -18
  38. package/dist/runtime/src/fs/manifest.js +32 -1
  39. package/dist/runtime/src/fs/transaction.js +14 -2
  40. package/dist/runtime/src/hooks/advisory.js +16 -0
  41. package/dist/runtime/src/hooks/codex-loop.js +439 -0
  42. package/dist/runtime/src/hooks/stop-service.js +70 -0
  43. package/dist/runtime/src/hooks/stop-verify.js +4 -1
  44. package/dist/runtime/src/install/codex-loop.js +139 -0
  45. package/dist/runtime/src/install/doctor.js +182 -14
  46. package/dist/runtime/src/install/harness.js +294 -35
  47. package/dist/runtime/src/install/hooks.js +22 -17
  48. package/dist/runtime/src/install/ownership.js +146 -35
  49. package/dist/runtime/src/install/plan.js +276 -31
  50. package/dist/runtime/src/install/probes.js +9 -43
  51. package/dist/runtime/src/install/profiles.js +12 -3
  52. package/dist/runtime/src/install/surface-inspection.js +296 -0
  53. package/dist/runtime/src/install/surfaces.js +11 -0
  54. package/dist/runtime/src/install/uninstall.js +11 -4
  55. package/dist/runtime/src/install/update.js +13 -3
  56. package/dist/runtime/src/logging/local-log.js +25 -0
  57. package/dist/runtime/src/schema/validate.js +45 -19
  58. package/dist/runtime/src/task/service.js +3 -3
  59. package/dist/runtime/src/task/store.js +12 -3
  60. package/dist/runtime/src/verify/command-executor.js +113 -0
  61. package/dist/runtime/src/verify/evidence.js +4 -24
  62. package/dist/runtime/src/verify/service.js +20 -92
  63. package/dist/runtime/src/verify/spawn.js +6 -1
  64. package/docs/en/guides/configuration.md +159 -0
  65. package/docs/en/guides/quickstart.md +9 -0
  66. package/docs/en/guides/security.md +5 -0
  67. package/docs/en/spec/README.md +9 -0
  68. package/docs/en/spec/harness-adapters.md +104 -2
  69. package/docs/en/spec/maintenance.md +11 -0
  70. package/docs/en/spec/review.md +11 -0
  71. package/docs/zh-TW/guides/configuration.md +145 -0
  72. package/docs/zh-TW/guides/quickstart.md +9 -0
  73. package/docs/zh-TW/guides/security.md +5 -0
  74. package/docs/zh-TW/spec/README.md +9 -0
  75. package/docs/zh-TW/spec/harness-adapters.md +89 -3
  76. package/docs/zh-TW/spec/maintenance.md +11 -1
  77. package/docs/zh-TW/spec/review.md +10 -0
  78. package/package.json +4 -2
  79. package/schemas/config.schema.json +56 -2
  80. package/schemas/manifest.schema.json +19 -3
  81. package/templates/common/AGENTS.block.md +2 -1
  82. package/templates/common/CLAUDE.block.md +2 -1
  83. package/dist/runtime/src/review/claude-runner.js +0 -4
  84. package/dist/runtime/src/review/codex-runner.js +0 -4
@@ -1,21 +1,33 @@
1
- import { normalizeClaudeHookInput } from "../../../../runtime/src/adapters/claude/input.js";
2
- import { claudeHookOutput } from "../../../../runtime/src/adapters/claude/output.js";
3
- import { normalizeCodexHookInput } from "../../../../runtime/src/adapters/codex/input.js";
4
- import { codexHookOutput } from "../../../../runtime/src/adapters/codex/output.js";
1
+ import { runLifecycleAdvisory } from "../../../../runtime/src/hooks/advisory.js";
5
2
  import { dispatchHookEvent } from "../../../../runtime/src/hooks/dispatch.js";
6
- import { resolveProfiles } from "../../../../runtime/src/install/profiles.js";
3
+ import { resolveCapabilities } from "../../../../runtime/src/install/profiles.js";
4
+ import { harnessDescriptor } from "../../../../runtime/src/install/harness.js";
7
5
  export const HOOK_EVENTS = [
8
6
  "SessionStart",
9
7
  "PreToolUse",
10
8
  "Stop"
11
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
+ }
12
22
  /**
13
23
  * Hooks are advisory infrastructure: every failure path stays fail-open with
14
24
  * exit code 0 so a broken toolkit can never wedge the harness.
15
25
  */
16
26
  export async function runHookCommand(options) {
17
27
  try {
18
- const { capabilities } = resolveProfiles(options.config.profiles);
28
+ const { capabilities } = options.config.profiles.length === 0
29
+ ? { capabilities: [] }
30
+ : resolveCapabilities(options.config);
19
31
  let input;
20
32
  try {
21
33
  input = JSON.parse(options.stdin);
@@ -23,18 +35,23 @@ export async function runHookCommand(options) {
23
35
  catch {
24
36
  return { exitCode: 0, stdout: "", stderr: "" };
25
37
  }
26
- const event = options.harness === "claude"
27
- ? normalizeClaudeHookInput(input)
28
- : normalizeCodexHookInput(input);
29
- const result = await dispatchHookEvent(event, {
38
+ const descriptor = harnessDescriptor(options.harness);
39
+ const normalized = normalizeHookInput(options.harness, input);
40
+ if (normalized === null) {
41
+ return { exitCode: 0, stdout: "", stderr: "" };
42
+ }
43
+ const stopRegistration = descriptor.control.registrations.find(({ capability }) => capability === "optional-stop-verify");
44
+ const stopVerification = options.stopVerification !== undefined &&
45
+ stopRegistration?.support !== "unsupported"
46
+ ? options.stopVerification
47
+ : undefined;
48
+ const result = await dispatchHookEvent(normalized, {
30
49
  capabilities,
31
- trusted: options.trusted
50
+ trusted: options.trusted,
51
+ advisory: options.advisory ?? runLifecycleAdvisory,
52
+ ...(stopVerification === undefined ? {} : { stopVerification })
32
53
  });
33
- if (options.harness === "claude") {
34
- return claudeHookOutput(options.event, result);
35
- }
36
- const codex = codexHookOutput(options.event, result);
37
- return { exitCode: 0, stdout: codex.stdout, stderr: "" };
54
+ return descriptor.runtime.formatOutput(options.event, result);
38
55
  }
39
56
  catch {
40
57
  return { exitCode: 0, stdout: "", stderr: "" };
@@ -3,6 +3,7 @@ import { createInstallPlan } from "../../../../runtime/src/install/plan.js";
3
3
  import { applyInstallPlan } from "../../../../runtime/src/install/apply.js";
4
4
  import { okEnvelope } from "../output.js";
5
5
  import { formatOperationPlan } from "../plan-output.js";
6
+ import { toPublicInstallPlan } from "../public-plan.js";
6
7
  export function formatInstallPlan(plan) {
7
8
  const hooks = plan.manifest.hooks ?? [];
8
9
  return formatOperationPlan({
@@ -18,7 +19,7 @@ export function formatInstallPlan(plan) {
18
19
  ...hooks.map((hook) => ` - ${hook.harness}: ${hook.path} (${hook.events.join(", ")})`)
19
20
  ])
20
21
  ],
21
- operations: plan.operations
22
+ operations: toPublicInstallPlan(plan).operations
22
23
  });
23
24
  }
24
25
  function appliedMessage(plan) {
@@ -36,7 +37,7 @@ function initError(code, message, plan) {
36
37
  return {
37
38
  code,
38
39
  status: "error",
39
- data: { applied: false, plan, message },
40
+ data: { applied: false, plan: toPublicInstallPlan(plan), message },
40
41
  errors: [{ code, message }]
41
42
  };
42
43
  }
@@ -59,12 +60,15 @@ export async function runInitCommand(options) {
59
60
  : { toolkitVersion: options.toolkitVersion }),
60
61
  ...(options.hookRuntimePath === undefined
61
62
  ? {}
62
- : { hookRuntimePath: options.hookRuntimePath })
63
+ : { hookRuntimePath: options.hookRuntimePath }),
64
+ ...((options.hookTargets ?? args.hookTargets) === undefined
65
+ ? {}
66
+ : { hookTargets: options.hookTargets ?? args.hookTargets })
63
67
  });
64
68
  if (args.dryRun) {
65
69
  return okEnvelope("INIT_PLAN_READY", {
66
70
  applied: false,
67
- plan,
71
+ plan: toPublicInstallPlan(plan),
68
72
  message: "Installation plan calculated; no files were written.",
69
73
  text: formatInstallPlan(plan)
70
74
  });
@@ -80,7 +84,7 @@ export async function runInitCommand(options) {
80
84
  await applyInstallPlan(options.root, plan);
81
85
  return okEnvelope("INIT_APPLIED", {
82
86
  applied: true,
83
- plan,
87
+ plan: toPublicInstallPlan(plan),
84
88
  message: appliedMessage(plan)
85
89
  });
86
90
  }
@@ -2,8 +2,12 @@ import { buildReviewPacket } from "../../../../runtime/src/review/packet.js";
2
2
  import { runIndependentReview } from "../../../../runtime/src/review/runner.js";
3
3
  import { resolveReviewRole } from "../../../../runtime/src/review/roles.js";
4
4
  import { okEnvelope } from "../output.js";
5
+ /**
6
+ * Review runs against one harness. Argument parsing already rejects a
7
+ * multi-harness selection here, so the first entry is the whole selection.
8
+ */
5
9
  function harness(value) {
6
- return value === "claude" ? "claude" : "codex";
10
+ return value?.[0] ?? "codex";
7
11
  }
8
12
  export async function runReviewCommand(options) {
9
13
  const ids = options.args.criteria ?? [];
@@ -1,6 +1,7 @@
1
1
  import { applyUninstallPlan, createUninstallPlan } from "../../../../runtime/src/install/uninstall.js";
2
2
  import { okEnvelope } from "../output.js";
3
3
  import { formatOperationPlan } from "../plan-output.js";
4
+ import { toPublicUninstallPlan } from "../public-plan.js";
4
5
  function formatUninstallPlan(plan) {
5
6
  return formatOperationPlan({
6
7
  title: "Uninstall plan",
@@ -13,14 +14,14 @@ function formatUninstallPlan(plan) {
13
14
  `Harness: ${plan.manifest.harness}`
14
15
  ])
15
16
  ],
16
- operations: plan.operations
17
+ operations: toPublicUninstallPlan(plan).operations
17
18
  });
18
19
  }
19
20
  function uninstallError(code, message, plan) {
20
21
  return {
21
22
  code,
22
23
  status: "error",
23
- data: { applied: false, plan, message },
24
+ data: { applied: false, plan: toPublicUninstallPlan(plan), message },
24
25
  errors: [{ code, message }]
25
26
  };
26
27
  }
@@ -29,14 +30,14 @@ export async function runUninstallCommand(options) {
29
30
  if (!plan.installed) {
30
31
  return okEnvelope("UNINSTALL_NOT_INSTALLED", {
31
32
  applied: false,
32
- plan,
33
+ plan: toPublicUninstallPlan(plan),
33
34
  message: "No managed installation exists."
34
35
  });
35
36
  }
36
37
  if (options.args.dryRun) {
37
38
  return okEnvelope("UNINSTALL_PLAN_READY", {
38
39
  applied: false,
39
- plan,
40
+ plan: toPublicUninstallPlan(plan),
40
41
  message: "Uninstall plan calculated; no files were changed.",
41
42
  text: formatUninstallPlan(plan)
42
43
  });
@@ -51,7 +52,7 @@ export async function runUninstallCommand(options) {
51
52
  await applyUninstallPlan(options.root, plan);
52
53
  return okEnvelope("UNINSTALL_APPLIED", {
53
54
  applied: true,
54
- plan,
55
+ plan: toPublicUninstallPlan(plan),
55
56
  message: "Managed installation content was removed."
56
57
  });
57
58
  }
@@ -1,25 +1,27 @@
1
1
  import { applyUpdatePlan, createUpdatePlan } from "../../../../runtime/src/install/update.js";
2
2
  import { okEnvelope } from "../output.js";
3
3
  import { formatOperationPlan } from "../plan-output.js";
4
+ import { toPublicUpdatePlan } from "../public-plan.js";
4
5
  export function formatUpdatePlan(plan) {
5
6
  return formatOperationPlan({
6
7
  title: "Update plan",
7
8
  metadata: [
8
9
  `Target version: ${plan.targetVersion}`,
10
+ `Harness: ${plan.installation.harness.join(", ")}`,
9
11
  `Schema migrations: ${plan.migrationSteps.length === 0
10
12
  ? "none"
11
13
  : plan.migrationSteps
12
14
  .map(({ fromVersion, toVersion }) => `${fromVersion} -> ${toVersion}`)
13
15
  .join(", ")}`
14
16
  ],
15
- operations: plan.installation.operations
17
+ operations: toPublicUpdatePlan(plan).installation.operations
16
18
  });
17
19
  }
18
20
  function updateError(code, message, plan) {
19
21
  return {
20
22
  code,
21
23
  status: "error",
22
- data: { applied: false, plan, message },
24
+ data: { applied: false, plan: toPublicUpdatePlan(plan), message },
23
25
  errors: [{ code, message }]
24
26
  };
25
27
  }
@@ -27,20 +29,29 @@ export async function runUpdateCommand(options) {
27
29
  const plan = await createUpdatePlan({
28
30
  root: options.root,
29
31
  adapters: options.adapters,
32
+ ...(options.args.harness === undefined
33
+ ? {}
34
+ : { harness: options.args.harness }),
30
35
  ...(options.registry === undefined
31
36
  ? {}
32
37
  : { registry: options.registry }),
33
38
  ...(options.targetVersion === undefined
34
39
  ? {}
35
40
  : { targetVersion: options.targetVersion }),
41
+ ...(options.toolkitVersion === undefined
42
+ ? {}
43
+ : { toolkitVersion: options.toolkitVersion }),
36
44
  ...(options.hookRuntimePath === undefined
37
45
  ? {}
38
- : { hookRuntimePath: options.hookRuntimePath })
46
+ : { hookRuntimePath: options.hookRuntimePath }),
47
+ ...((options.hookTargets ?? options.args.hookTargets) === undefined
48
+ ? {}
49
+ : { hookTargets: options.hookTargets ?? options.args.hookTargets })
39
50
  });
40
51
  if (options.args.dryRun) {
41
52
  return okEnvelope("UPDATE_PLAN_READY", {
42
53
  applied: false,
43
- plan,
54
+ plan: toPublicUpdatePlan(plan),
44
55
  message: "Update plan calculated; no files were changed.",
45
56
  text: formatUpdatePlan(plan)
46
57
  });
@@ -55,7 +66,7 @@ export async function runUpdateCommand(options) {
55
66
  await applyUpdatePlan(options.root, plan);
56
67
  return okEnvelope("UPDATE_APPLIED", {
57
68
  applied: true,
58
- plan,
69
+ plan: toPublicUpdatePlan(plan),
59
70
  message: `Managed installation updated to ${plan.targetVersion}.`
60
71
  });
61
72
  }
@@ -1,16 +1,22 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { calculateConfigHash } from "../../../runtime/src/config/hash.js";
5
+ import { sha256 } from "../../../runtime/src/fs/hash.js";
4
6
  import { loadConfigFile } from "../../../runtime/src/config/load.js";
5
7
  import { mergeConfigLayers } from "../../../runtime/src/config/merge.js";
6
- import { sha256 } from "../../../runtime/src/fs/hash.js";
7
8
  import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
8
9
  import { localStatePaths } from "../../../runtime/src/security/permissions.js";
9
10
  import { calculateTrustBinding, FileTrustStore } from "../../../runtime/src/security/trust.js";
10
11
  export const DEFAULT_CONFIG = {
11
- schemaVersion: 1,
12
+ schemaVersion: 2,
12
13
  profiles: [],
13
14
  verification: { commands: [] },
15
+ features: {
16
+ stopVerification: {
17
+ enabled: false
18
+ }
19
+ },
14
20
  pathMappings: [],
15
21
  securityExceptions: []
16
22
  };
@@ -73,6 +79,66 @@ export async function loadEffectiveConfig(root, scope) {
73
79
  }
74
80
  return mergeConfigLayers(layers);
75
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
+ }
76
142
  export function repositoryRemoteUrl(root) {
77
143
  try {
78
144
  return execFileSync("git", ["config", "--get", "remote.origin.url"], {
@@ -91,7 +157,7 @@ export async function repositoryTrust(root, config, cliVersion) {
91
157
  const binding = await calculateTrustBinding({
92
158
  repositoryPath: root,
93
159
  remoteUrl: repositoryRemoteUrl(root),
94
- configHash: sha256(JSON.stringify(config)),
160
+ configHash: calculateConfigHash(config),
95
161
  runtimeHash: sha256(cliVersion)
96
162
  });
97
163
  return (await new FileTrustStore(state.trustStore, state.anchorDirectory).status(binding)).status;
@@ -1,7 +1,22 @@
1
- import { runHookCommand, HOOK_EVENTS } from "./commands/hook.js";
2
- import { loadEffectiveConfig, repositoryTrust } from "./context.js";
3
- const HARNESSES = new Set(["codex", "claude"]);
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";
5
+ import { promisify } from "node:util";
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";
10
+ import { claudeStopRecursionMarker } from "../../../runtime/src/adapters/claude/input.js";
11
+ import { HARNESS_IDS, harnessDescriptor } from "../../../runtime/src/install/harness.js";
12
+ import { STOP_VERIFICATION_ENV, StopVerificationService } from "../../../runtime/src/hooks/stop-service.js";
13
+ import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
14
+ import { normalizeHookInput, runHookCommand, HOOK_EVENTS } from "./commands/hook.js";
15
+ import { loadProjectHookConfig, repositoryTrust } from "./context.js";
16
+ const HARNESSES = new Set(HARNESS_IDS);
4
17
  const MAX_HOOK_INPUT_BYTES = 1024 * 1024;
18
+ const MAX_HOOK_MANIFEST_BYTES = 1024 * 1024;
19
+ const execFile = promisify(execFileCallback);
5
20
  async function readStdin(stream) {
6
21
  const chunks = [];
7
22
  let total = 0;
@@ -17,35 +32,235 @@ async function readStdin(stream) {
17
32
  }
18
33
  return Buffer.concat(chunks, total).toString("utf8");
19
34
  }
35
+ function parseInput(source) {
36
+ try {
37
+ return JSON.parse(source);
38
+ }
39
+ catch {
40
+ return null;
41
+ }
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
+ }
136
+ function defaultGitRunner(root) {
137
+ return {
138
+ run: async (args) => {
139
+ try {
140
+ const result = await execFile("git", [...args], {
141
+ cwd: root,
142
+ encoding: "buffer"
143
+ });
144
+ return {
145
+ exitCode: 0,
146
+ stdout: result.stdout
147
+ };
148
+ }
149
+ catch (error) {
150
+ const failure = error;
151
+ return {
152
+ exitCode: failure.status ?? 1,
153
+ stdout: failure.stdout ?? new Uint8Array()
154
+ };
155
+ }
156
+ }
157
+ };
158
+ }
159
+ function stopVerificationOptions(input) {
160
+ const nativeRecursion = input.harness === "claude" &&
161
+ claudeStopRecursionMarker(input.rawInput);
162
+ const recursionActive = nativeRecursion || process.env[STOP_VERIFICATION_ENV] === "1";
163
+ const configHash = calculateConfigHash(input.config);
164
+ return {
165
+ confirmedConfig: true,
166
+ trusted: input.trusted,
167
+ scopeMapped: true,
168
+ recursionMarker: recursionActive,
169
+ configHash,
170
+ verify: async () => await new StopVerificationService({
171
+ root: input.root,
172
+ config: input.config,
173
+ trusted: input.trusted,
174
+ gitRunner: input.gitRunner,
175
+ processRunner: input.processRunner,
176
+ recursionActive,
177
+ configHash
178
+ }).verify()
179
+ };
180
+ }
181
+ function shouldBuildStopVerification(harness, event, config, rawInput) {
182
+ if (event !== "Stop" || !config.features.stopVerification.enabled) {
183
+ return false;
184
+ }
185
+ let normalized;
186
+ try {
187
+ normalized = harnessDescriptor(harness).runtime.normalizeInput(rawInput);
188
+ }
189
+ catch {
190
+ return false;
191
+ }
192
+ if (normalized.event !== "stop") {
193
+ return false;
194
+ }
195
+ return harnessDescriptor(harness).control.registrations.some(({ capability, support }) => capability === "optional-stop-verify" && support !== "unsupported");
196
+ }
20
197
  /**
21
198
  * Runs one hook invocation. Always resolves to exit code 0: a hook that
22
199
  * cannot answer must never block the harness it advises.
23
200
  */
24
- export async function runHookProcess(argv, io, cliVersion) {
201
+ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
25
202
  const [harness, event] = argv;
26
203
  if (harness === undefined ||
27
204
  !HARNESSES.has(harness) ||
28
205
  event === undefined ||
29
206
  !HOOK_EVENTS.includes(event)) {
30
- io.writeStderr("Usage: agent-ops hook <codex|claude> <SessionStart|PreToolUse|Stop>\n");
207
+ io.writeStderr(`Usage: agent-ops hook <${HARNESS_IDS.join("|")}> <SessionStart|PreToolUse|Stop>\n`);
31
208
  return 0;
32
209
  }
33
210
  try {
34
- const root = process.cwd();
35
- const config = (await loadEffectiveConfig(root, "project")).config;
211
+ const root = dependencies.root ?? process.cwd();
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;
235
+ const trustStatus = dependencies.trust === undefined
236
+ ? await repositoryTrust(root, config, cliVersion)
237
+ : await dependencies.trust(root, config, cliVersion);
238
+ const trusted = trustStatus === "TRUSTED";
239
+ const gitRunner = dependencies.gitRunner ?? defaultGitRunner(root);
240
+ const processRunner = dependencies.processRunner ?? new NodeVerificationProcessRunner();
241
+ const stopVerification = shouldBuildStopVerification(harnessId, event, config, parsedInput)
242
+ ? stopVerificationOptions({
243
+ harness: harness,
244
+ rawInput: parsedInput,
245
+ root,
246
+ config,
247
+ trusted,
248
+ gitRunner,
249
+ processRunner
250
+ })
251
+ : undefined;
36
252
  const output = await runHookCommand({
37
253
  harness: harness,
38
- event: event,
39
- stdin: await readStdin(io.stdin),
254
+ event: hookEvent,
255
+ stdin: rawInput,
40
256
  config,
41
- trusted: (await repositoryTrust(root, config, cliVersion)) === "TRUSTED"
257
+ trusted,
258
+ ...(dependencies.advisory === undefined
259
+ ? {}
260
+ : { advisory: dependencies.advisory }),
261
+ ...(stopVerification === undefined ? {} : { stopVerification })
42
262
  });
43
- if (output.stdout.length > 0) {
44
- io.writeStdout(output.stdout);
45
- }
46
- if (output.stderr.length > 0) {
47
- io.writeStderr(output.stderr);
48
- }
263
+ writeHookOutput(io, output);
49
264
  }
50
265
  catch {
51
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
+ });
@@ -18,10 +18,15 @@ export function formatOperationPlan(options) {
18
18
  for (const operation of options.operations) {
19
19
  lines.push(`- ${operation.kind} ${escapeTerminalText(operation.path)}`, ` expected: ${operation.expectedHash ?? "<absent>"}`);
20
20
  if (operation.kind === "write") {
21
- lines.push(" content:", ...operation.content
22
- .replace(/\n$/u, "")
23
- .split("\n")
24
- .map((line) => ` ${escapeTerminalText(line)}`));
21
+ if ("contentHash" in operation) {
22
+ lines.push(` content: <opaque ${escapeTerminalText(operation.contentHash)}>`, ` summary: ${escapeTerminalText(operation.summary)}`);
23
+ }
24
+ else {
25
+ lines.push(" content:", ...operation.content
26
+ .replace(/\n$/u, "")
27
+ .split("\n")
28
+ .map((line) => ` ${escapeTerminalText(line)}`));
29
+ }
25
30
  }
26
31
  }
27
32
  return `${lines.join("\n")}\n`;