@kylecheng3146/agent-ops 0.1.24 → 0.2.1

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.
@@ -284,8 +284,27 @@ export function parseArgs(argv) {
284
284
  if (helpSeen && versionSeen) {
285
285
  throw new CliArgumentError("CLI_CONFLICTING_ACTION", "--help and --version cannot be combined.");
286
286
  }
287
- if ((helpSeen || versionSeen) && command !== undefined) {
288
- throw new CliArgumentError("CLI_CONFLICTING_ACTION", "Global help or version cannot be combined with a command.");
287
+ // `<command> --help` asks about that command, so it answers instead of
288
+ // failing: an agent that cannot read a command's own option shapes guesses
289
+ // at them one rejected call at a time. Other options are ignored rather than
290
+ // rejected, so `task create --criterion <wrong> --help` still explains the
291
+ // shape it got wrong.
292
+ if (helpSeen &&
293
+ command !== undefined &&
294
+ command !== "help" &&
295
+ command !== "version") {
296
+ return {
297
+ command: "help",
298
+ helpTopic: command,
299
+ ...(action === undefined ? {} : { helpAction: action }),
300
+ profiles: [],
301
+ dryRun: false,
302
+ json,
303
+ yes: false
304
+ };
305
+ }
306
+ if (versionSeen && command !== undefined) {
307
+ throw new CliArgumentError("CLI_CONFLICTING_ACTION", "Global version cannot be combined with a command.");
289
308
  }
290
309
  if (helpSeen || versionSeen) {
291
310
  if (scope !== undefined ||
@@ -27,7 +27,7 @@ Commands:
27
27
  Manage independent task acceptance state
28
28
  verify Run configured verification
29
29
  review Run an independent review
30
- allow-stop Grant one fingerprint-bound agy Stop permit (requires --session)
30
+ allow-stop Grant one fingerprint-bound completion-gate Stop permit (requires --session)
31
31
  agy-run Run headless agy with a process-exit completion recheck
32
32
 
33
33
  Options:
@@ -37,7 +37,7 @@ Options:
37
37
  --profile <core|advisory|guardrails|loop> Repeatable
38
38
  --review-target <codex|agy|claude> Repeatable init option; external review
39
39
  targets in fallback-chain order
40
- --completion-gate Init only: enable the agy project-loop gate
40
+ --completion-gate Init only: enable the project-loop completion gate
41
41
  --check-auth Doctor only: probe each review target's
42
42
  authentication with one real call
43
43
  --task <id>
@@ -55,6 +55,134 @@ Options:
55
55
  --help
56
56
  --version
57
57
  `;
58
+ /**
59
+ * What `<command> --help` answers. Each entry states the option shapes that
60
+ * command actually accepts, because the global list cannot say which options
61
+ * belong to which command — and a caller that guesses learns only by being
62
+ * rejected.
63
+ */
64
+ export const COMMAND_HELP_TEXT = {
65
+ init: `Usage: agent-ops init [options]
66
+
67
+ Plan or install agent-ops into this repository.
68
+
69
+ Options:
70
+ --scope <project|user>
71
+ --harness <all|both|agy|claude|codex|opencode|comma-separated>
72
+ --hook-target <harness=surface-id> Repeatable
73
+ --profile <core|advisory|guardrails|loop> Repeatable
74
+ --review-target <codex|agy|claude> Repeatable, in fallback-chain order
75
+ --completion-gate Enable the project-loop completion gate
76
+ --dry-run Print the plan without writing
77
+ --json
78
+ --yes
79
+ `,
80
+ config: `Usage: agent-ops config explain [options]
81
+
82
+ Show where each effective configuration value came from.
83
+
84
+ Options:
85
+ --scope <project|user>
86
+ --json
87
+ `,
88
+ trust: `Usage: agent-ops trust <status|grant|revoke> [options]
89
+
90
+ Manage the explicit trust record this repository's commands require.
91
+
92
+ Options:
93
+ --scope <project|user>
94
+ --json
95
+ --yes
96
+ `,
97
+ doctor: `Usage: agent-ops doctor [options]
98
+
99
+ Diagnose the installation and report remediation for each failed check.
100
+
101
+ Options:
102
+ --scope <project|user>
103
+ --harness <all|both|agy|claude|codex|opencode|comma-separated>
104
+ --check-auth Probe each review target's authentication with one real call
105
+ --json
106
+ `,
107
+ update: `Usage: agent-ops update [options]
108
+
109
+ Update managed artifacts to this toolkit version.
110
+
111
+ Options:
112
+ --scope <project|user>
113
+ --harness <all|both|agy|claude|codex|opencode|comma-separated>
114
+ --target-version <version> Offline-capable update target
115
+ --dry-run
116
+ --json
117
+ --yes
118
+ `,
119
+ uninstall: `Usage: agent-ops uninstall [options]
120
+
121
+ Remove managed artifacts, leaving foreign handlers in place.
122
+
123
+ Options:
124
+ --scope <project|user>
125
+ --harness <all|both|agy|claude|codex|opencode|comma-separated>
126
+ --dry-run
127
+ --json
128
+ --yes
129
+ `,
130
+ task: `Usage: agent-ops task <create|status|attach|complete|archive|export> [options]
131
+
132
+ Manage independent task acceptance state. Task commands accept none of
133
+ --harness, --profile, --dry-run or --yes.
134
+
135
+ Options:
136
+ --title <text> create
137
+ --criterion <json> create, repeatable, two to five total
138
+ --parent <task-id> create: record a subtask; status: list subtasks
139
+ --task <id> status, attach, complete, archive, export
140
+ --session <id> attach, status
141
+ --evidence <criterion-id=reference> complete, repeatable
142
+ --base <git-ref> complete: a clean committed range
143
+ --json
144
+
145
+ Each --criterion is one JSON object with exactly these keys:
146
+
147
+ {"id":"kebab-id","description":"what must hold","verifierIds":["node-test"]}
148
+
149
+ Every criterion needs at least one verifierIds entry naming a verification
150
+ command id configured in .agent-ops/config.json. No other key is accepted.
151
+ `,
152
+ verify: `Usage: agent-ops verify [options]
153
+
154
+ Run the configured verification commands and record their evidence.
155
+
156
+ Options:
157
+ --task <id>
158
+ --session <id>
159
+ --base <git-ref> Verify a clean committed range
160
+ --json
161
+ `,
162
+ review: `Usage: agent-ops review [options]
163
+
164
+ Run one independent read-only review against the configured target chain.
165
+
166
+ Options:
167
+ --task <id>
168
+ --session <id>
169
+ --criterion <id> Repeatable: review only these task criteria
170
+ --harness <target> One configured review target
171
+ --base <git-ref>
172
+ --json
173
+ --yes Authorize the review call
174
+ `,
175
+ "allow-stop": `Usage: agent-ops allow-stop --session <id> [options]
176
+
177
+ Grant one fingerprint-bound Stop permit for the completion gate, on agy or
178
+ Claude Code. Requires user approval: the PreToolUse hook asks the user, so an
179
+ agent cannot self-authorize it.
180
+
181
+ Options:
182
+ --session <id> Required
183
+ --json
184
+ `
185
+ };
58
186
  function wantsJson(argv) {
59
187
  return argv.includes("--json");
60
188
  }
@@ -80,7 +208,11 @@ export async function runCli(argv, io, services) {
80
208
  return writeAndReturn(io, errorEnvelope("CLI_INTERNAL_ERROR", "Unable to parse command arguments."), json, 1);
81
209
  }
82
210
  if (args.command === "help") {
83
- return writeAndReturn(io, okEnvelope("CLI_HELP", { text: HELP_TEXT }), args.json, 0);
211
+ const topic = args.helpTopic;
212
+ return writeAndReturn(io, okEnvelope("CLI_HELP", {
213
+ text: topic === undefined ? HELP_TEXT : COMMAND_HELP_TEXT[topic],
214
+ ...(topic === undefined ? {} : { topic })
215
+ }), args.json, 0);
84
216
  }
85
217
  if (args.command === "version") {
86
218
  return writeAndReturn(io, okEnvelope("CLI_VERSION", { version: services.version }), args.json, 0);
@@ -52,8 +52,12 @@ export async function runHookCommand(options) {
52
52
  return descriptor.runtime.formatOutput(options.event, result);
53
53
  }
54
54
  catch {
55
- if (options.harness === "agy" && options.event === "Stop" && options.completionGate !== undefined) {
56
- return harnessDescriptor("agy").runtime.formatOutput(options.event, {
55
+ // Fail closed on every host that enforces the gate: an exception here is
56
+ // exactly the case where a silent empty output would wave the stop through.
57
+ if ((options.harness === "agy" || options.harness === "claude") &&
58
+ options.event === "Stop" &&
59
+ options.completionGate !== undefined) {
60
+ return harnessDescriptor(options.harness).runtime.formatOutput(options.event, {
57
61
  action: "block",
58
62
  status: "UNKNOWN",
59
63
  code: "COMPLETION_GATE_UNAVAILABLE",
@@ -108,17 +108,30 @@ export async function runInitCommand(options) {
108
108
  : { completionGateEnabled: args.completionGate })
109
109
  });
110
110
  const trust = await trustChange(options, plan);
111
- const warnings = plan.harness.includes("agy") && options.agyWarning !== undefined
112
- ? (() => {
113
- try {
114
- const warning = options.agyWarning();
115
- return warning === undefined ? [] : [warning];
116
- }
117
- catch {
118
- return ["agy could not be probed; run `agent-ops doctor` to verify it."];
119
- }
120
- })()
111
+ // An installation with no verifier looks finished and is not: every task
112
+ // completion needs current PASS evidence from a required verifier, so the
113
+ // loop can never close. Detection stays conservative on purpose — it will
114
+ // not guess a test command — which makes saying so out loud the whole fix.
115
+ const verificationWarnings = plan.config.verification.commands.length === 0
116
+ ? [
117
+ "No verification command is configured, so no task can be completed. " +
118
+ "Add verification.commands to .agent-ops/config.json." +
119
+ (plan.verificationBlockers.length === 0
120
+ ? ""
121
+ : ` Detection stopped because — ${plan.verificationBlockers.join("; ")}`)
122
+ ]
121
123
  : [];
124
+ const warnings = [...verificationWarnings, ...(plan.harness.includes("agy") && options.agyWarning !== undefined
125
+ ? (() => {
126
+ try {
127
+ const warning = options.agyWarning();
128
+ return warning === undefined ? [] : [warning];
129
+ }
130
+ catch {
131
+ return ["agy could not be probed; run `agent-ops doctor` to verify it."];
132
+ }
133
+ })()
134
+ : [])];
122
135
  if (args.dryRun) {
123
136
  return okEnvelope("INIT_PLAN_READY", {
124
137
  applied: false,
@@ -124,11 +124,16 @@ async function currentEvidence(options, context, criterionId, commandId, configH
124
124
  return { current, hasReference, unreadable, stale };
125
125
  }
126
126
  async function preflightReview(options, context, sourceFingerprint) {
127
+ // A recorded failure is not stale evidence, and saying so sends the caller
128
+ // to re-run the verifier that just failed. The tests failed; that is the
129
+ // report.
130
+ if (context.failureFingerprint !== null) {
131
+ return { ok: false, reason: "verification-not-passed" };
132
+ }
127
133
  if (options.config === undefined ||
128
134
  options.evidenceStore === undefined ||
129
135
  options.root === undefined ||
130
- options.gitRunner === undefined ||
131
- context.failureFingerprint !== null) {
136
+ options.gitRunner === undefined) {
132
137
  return { ok: false, reason: "stale-verification" };
133
138
  }
134
139
  const configHash = calculateConfigHash(options.config);
@@ -313,6 +318,7 @@ export async function runReviewCommand(options) {
313
318
  return notRunEnvelope({
314
319
  ...resultBase,
315
320
  status: "NOT_RUN", reason: preflight.reason,
321
+ taskId: context.taskId,
316
322
  scope
317
323
  });
318
324
  }
@@ -214,7 +214,11 @@ function shouldBuildStopVerification(harness, event, config, rawInput) {
214
214
  */
215
215
  export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
216
216
  const [harness, event] = argv;
217
- const completionGateInstalled = harness === "agy" &&
217
+ // Both hosts whose Stop hook can actually refuse a stop. codex never fires
218
+ // Stop under `codex exec` and rejects `permissionDecision:ask`, so its
219
+ // escape hatch could not be user-approved; opencode can only deny a tool
220
+ // call, never a stop.
221
+ const completionGateInstalled = (harness === "agy" || harness === "claude") &&
218
222
  event === "Stop" &&
219
223
  argv.includes("--completion-gate");
220
224
  if (harness === undefined ||
@@ -225,12 +229,12 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
225
229
  return 0;
226
230
  }
227
231
  try {
228
- const root = dependencies.root ?? process.cwd();
232
+ let root = dependencies.root ?? process.cwd();
229
233
  const harnessId = harness;
230
234
  const hookEvent = event;
231
235
  if (process.env.AGENT_OPS_DISABLE === "1") {
232
236
  if (completionGateInstalled) {
233
- writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
237
+ writeHookOutput(io, harnessDescriptor(harness).runtime.formatOutput("Stop", {
234
238
  action: "block",
235
239
  status: "UNKNOWN",
236
240
  code: "COMPLETION_GATE_DISABLE_REJECTED",
@@ -243,10 +247,25 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
243
247
  }
244
248
  const rawInput = await readStdin(io.stdin);
245
249
  const parsedInput = withInjectedSession(harnessId, parseInput(rawInput));
250
+ if (dependencies.root === undefined) {
251
+ const inputRoot = typeof parsedInput === "object" &&
252
+ parsedInput !== null &&
253
+ Array.isArray(parsedInput.workspacePaths) &&
254
+ typeof parsedInput.workspacePaths[0] === "string"
255
+ ? parsedInput.workspacePaths[0]
256
+ : typeof parsedInput === "object" &&
257
+ parsedInput !== null &&
258
+ typeof parsedInput.projectRoot === "string"
259
+ ? parsedInput.projectRoot
260
+ : undefined;
261
+ if (inputRoot !== undefined) {
262
+ root = inputRoot;
263
+ }
264
+ }
246
265
  const configOutcome = await hookConfigOutcome(root, dependencies.loadConfig);
247
266
  if (configOutcome.kind === "invalid") {
248
267
  if (completionGateInstalled) {
249
- writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
268
+ writeHookOutput(io, harnessDescriptor(harness).runtime.formatOutput("Stop", {
250
269
  action: "block",
251
270
  status: "UNKNOWN",
252
271
  code: "COMPLETION_GATE_CONFIG_INVALID",
@@ -268,7 +287,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
268
287
  }
269
288
  const config = configOutcome.config;
270
289
  if (completionGateInstalled && !config.features.completionGate.enabled) {
271
- writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
290
+ writeHookOutput(io, harnessDescriptor(harness).runtime.formatOutput("Stop", {
272
291
  action: "block",
273
292
  status: "UNKNOWN",
274
293
  code: "COMPLETION_GATE_CONFIG_DISABLED",
@@ -293,7 +312,12 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
293
312
  processRunner
294
313
  })
295
314
  : undefined;
296
- const completionGate = harnessId === "agy" && config.features.completionGate.enabled
315
+ // Not `completionGateInstalled`: that is the Stop handler's own marker,
316
+ // and the gate also answers PreToolUse — where it turns a self-issued
317
+ // `allow-stop` into a question for the user. Narrowing this to Stop takes
318
+ // the escape hatch's approval step away.
319
+ const completionGate = (harnessId === "agy" || harnessId === "claude") &&
320
+ config.features.completionGate.enabled
297
321
  ? dependencies.completionGate ?? {
298
322
  handle: async (normalized) => await new CompletionGateService({
299
323
  root,
@@ -320,7 +344,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
320
344
  }
321
345
  catch {
322
346
  if (completionGateInstalled) {
323
- writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
347
+ writeHookOutput(io, harnessDescriptor(harness).runtime.formatOutput("Stop", {
324
348
  action: "block",
325
349
  status: "UNKNOWN",
326
350
  code: "COMPLETION_GATE_UNAVAILABLE",
@@ -24,7 +24,13 @@ export function claudeSettingsTarget(scope) {
24
24
  requiresWorkspaceTrust: false
25
25
  };
26
26
  }
27
- function commandHook(event, runtimePath) {
27
+ /**
28
+ * The gate flag trails the ownership marker rather than preceding it, unlike
29
+ * agy's flat command string. The marker's position is what identifies a
30
+ * managed handler here, and moving it would make every existing installation
31
+ * read as foreign.
32
+ */
33
+ function commandHook(event, runtimePath, completionGate = false) {
28
34
  return {
29
35
  type: "command",
30
36
  command: "node",
@@ -32,15 +38,16 @@ function commandHook(event, runtimePath) {
32
38
  runtimePath,
33
39
  "claude",
34
40
  event,
35
- CLAUDE_HOOK_MARKER
41
+ CLAUDE_HOOK_MARKER,
42
+ ...(completionGate ? ["--completion-gate"] : [])
36
43
  ],
37
44
  timeout: 30
38
45
  };
39
46
  }
40
- function matcherGroup(event, runtimePath) {
47
+ function matcherGroup(event, runtimePath, completionGate = false) {
41
48
  return {
42
49
  ...(event === "PreToolUse" ? { matcher: "Bash" } : {}),
43
- hooks: [commandHook(event, runtimePath)]
50
+ hooks: [commandHook(event, runtimePath, completionGate)]
44
51
  };
45
52
  }
46
53
  function powershellLoopCommand(event) {
@@ -82,6 +89,21 @@ export function buildClaudeHookSettings(capabilities, runtimePath, platform = pr
82
89
  for (const event of CLAUDE_LOOP_EVENTS) {
83
90
  hooks[event] = [loopMatcherGroup(event, platform)];
84
91
  }
92
+ // The loop launcher runs a different process, and the completion gate does
93
+ // not live there. Its PreToolUse role is narrow but load-bearing: it is
94
+ // what turns a self-issued `allow-stop` into a question for the user, so
95
+ // the gate needs a handler of its own beside the loop's.
96
+ if (capabilities.includes("completion-gate")) {
97
+ // SessionStart for the same reason: the gate records its per-session
98
+ // baseline there, and a gate that never sees a session start refuses
99
+ // every stop as uninitialized.
100
+ for (const event of ["SessionStart", "PreToolUse"]) {
101
+ hooks[event] = [
102
+ ...(hooks[event] ?? []),
103
+ matcherGroup(event, runtimePath)
104
+ ];
105
+ }
106
+ }
85
107
  }
86
108
  else {
87
109
  if (capabilities.includes("lifecycle-summary")) {
@@ -91,7 +113,12 @@ export function buildClaudeHookSettings(capabilities, runtimePath, platform = pr
91
113
  hooks.PreToolUse = [matcherGroup("PreToolUse", runtimePath)];
92
114
  }
93
115
  }
94
- if (capabilities.includes("optional-stop-verify")) {
116
+ // The gate supersedes report-only Stop verification: one managed Stop
117
+ // handler, and the gated one already reports everything the other would.
118
+ if (capabilities.includes("completion-gate")) {
119
+ hooks.Stop = [matcherGroup("Stop", runtimePath, true)];
120
+ }
121
+ else if (capabilities.includes("optional-stop-verify")) {
95
122
  hooks.Stop = [matcherGroup("Stop", runtimePath)];
96
123
  }
97
124
  return { hooks };
@@ -99,6 +126,28 @@ export function buildClaudeHookSettings(capabilities, runtimePath, platform = pr
99
126
  function isRecord(value) {
100
127
  return typeof value === "object" && value !== null && !Array.isArray(value);
101
128
  }
129
+ /**
130
+ * The argument vector `commandHook` produces, and nothing else. The ownership
131
+ * marker alone is not proof: it is a plain string anyone may write, and a
132
+ * handler mistaken for ours is a handler `update` rewrites and `uninstall`
133
+ * deletes.
134
+ */
135
+ function isManagedNodeArgs(args) {
136
+ if (!Array.isArray(args) || args.length < 4 || args.length > 5) {
137
+ return false;
138
+ }
139
+ const [runtimePath, harness, event, marker, gate] = args;
140
+ // The event is checked for shape, not membership: a handler an older
141
+ // agent-ops wrote for an event this version no longer knows is still ours to
142
+ // remove, and rejecting it would orphan it in the user's settings forever.
143
+ return (typeof runtimePath === "string" &&
144
+ runtimePath.length > 0 &&
145
+ harness === "claude" &&
146
+ typeof event === "string" &&
147
+ event.length > 0 &&
148
+ marker === CLAUDE_HOOK_MARKER &&
149
+ (gate === undefined || gate === "--completion-gate"));
150
+ }
102
151
  /**
103
152
  * Matches only the two command shapes agent-ops actually generates. This is
104
153
  * reused by installation inspection so a foreign hook cannot masquerade as
@@ -109,11 +158,12 @@ export function isClaudeManagedHandler(handler) {
109
158
  return false;
110
159
  }
111
160
  return ((handler.command === "node" &&
112
- Array.isArray(handler.args) &&
113
- handler.args[3] === CLAUDE_HOOK_MARKER) ||
161
+ isManagedNodeArgs(handler.args)) ||
114
162
  (handler.command === "bash" &&
115
163
  Array.isArray(handler.args) &&
164
+ handler.args.length === 3 &&
116
165
  handler.args[0] === CLAUDE_LOOP_LAUNCHER &&
166
+ CLAUDE_LOOP_EVENTS.includes(handler.args[1]) &&
117
167
  handler.args[2] === CLAUDE_HOOK_MARKER) ||
118
168
  (handler.shell === "powershell" &&
119
169
  handler.args === undefined &&
@@ -37,5 +37,13 @@ export const CLAUDE_CAPABILITY_REGISTRATIONS = [
37
37
  surfaceId: "claude-settings",
38
38
  support: "supported",
39
39
  runtimeFailure: "fail-open"
40
+ },
41
+ {
42
+ capability: "completion-gate",
43
+ normalizedEvent: "stop",
44
+ nativeEvent: "Stop",
45
+ surfaceId: "claude-settings",
46
+ support: "supported",
47
+ runtimeFailure: "fail-closed"
40
48
  }
41
49
  ];
@@ -13,17 +13,31 @@ export function normalizeClaudeHookInput(input) {
13
13
  return normalizeHookEvent(input);
14
14
  }
15
15
  const projectRoot = input.cwd;
16
+ // The completion gate keys its per-session baseline on this. Without it every
17
+ // Stop is refused for the wrong reason and no SessionStart ever records a
18
+ // baseline to refuse against.
19
+ const sessionId = typeof input.session_id === "string"
20
+ ? input.session_id
21
+ : undefined;
16
22
  if (input.hook_event_name === "SessionStart") {
17
- return normalizeHookEvent({
18
- event: "session-start",
19
- projectRoot
20
- });
23
+ return {
24
+ ...normalizeHookEvent({ event: "session-start", projectRoot }),
25
+ ...(sessionId === undefined ? {} : { sessionId })
26
+ };
21
27
  }
22
28
  if (input.hook_event_name === "Stop") {
23
- return normalizeHookEvent({
29
+ // Claude publishes no termination reason: its Stop hook fires when the
30
+ // assistant has finished, which is agy's `model_stop`. The one distinction
31
+ // it does publish is recursion — a Stop the hook itself caused — and that
32
+ // is exactly the not-yet-idle case the gate lets through.
33
+ const stop = normalizeHookEvent({ event: "stop", projectRoot });
34
+ return {
24
35
  event: "stop",
25
- projectRoot
26
- });
36
+ projectRoot: stop.projectRoot,
37
+ ...(sessionId === undefined ? {} : { sessionId }),
38
+ terminationReason: "model_stop",
39
+ fullyIdle: input.stop_hook_active !== true
40
+ };
27
41
  }
28
42
  if (input.hook_event_name === "PreToolUse" &&
29
43
  input.tool_name === "Bash" &&
@@ -7,6 +7,30 @@ function json(value) {
7
7
  }
8
8
  export function claudeHookOutput(event, result) {
9
9
  const denialReason = result.remedy === undefined ? result.code : `${result.code}: ${result.remedy}`;
10
+ // The completion gate carries no verification evidence of its own — it reads
11
+ // evidence rather than producing it — so it never reaches the branch below
12
+ // and needs its own refusal.
13
+ if (event === "Stop" &&
14
+ result.action === "block" &&
15
+ result.code.startsWith("COMPLETION_GATE_")) {
16
+ return json({
17
+ decision: "block",
18
+ reason: `agent-ops: ${denialReason}`
19
+ });
20
+ }
21
+ if (event === "PreToolUse" &&
22
+ result.code === "COMPLETION_GATE_PERMIT_CONFIRMATION") {
23
+ // Asked, never allowed: a one-time Stop permit is the user's to grant, and
24
+ // an agent that could answer this for itself would hold the key to its own
25
+ // gate.
26
+ return json({
27
+ hookSpecificOutput: {
28
+ hookEventName: "PreToolUse",
29
+ permissionDecision: "ask",
30
+ permissionDecisionReason: denialReason
31
+ }
32
+ });
33
+ }
10
34
  if (event === "Stop" && result.evidence !== undefined) {
11
35
  if (result.status === "FAIL") {
12
36
  const failed = result.evidence.commandResults
@@ -133,7 +133,7 @@ export class CompletionGateService {
133
133
  const sessionId = event.sessionId;
134
134
  if (sessionId === undefined) {
135
135
  return event.event === "stop"
136
- ? gateResult("block", "UNKNOWN", "COMPLETION_GATE_SESSION_REQUIRED", "Agy did not provide conversationId; run doctor and use a one-time permit only after restoring hook input.")
136
+ ? gateResult("block", "UNKNOWN", "COMPLETION_GATE_SESSION_REQUIRED", "The host did not provide a session identifier; run doctor and use a one-time permit only after restoring hook input.")
137
137
  : null;
138
138
  }
139
139
  if (event.event === "session-start") {
@@ -4,6 +4,7 @@ import { sha256 } from "../fs/hash.js";
4
4
  import { parseInstallManifest, PROJECT_MANIFEST_PATH } from "../fs/manifest.js";
5
5
  import { resolveContainedPath } from "../fs/paths.js";
6
6
  import { validateConfig } from "../schema/validate.js";
7
+ import { BIND_DEPENDENT_TARGETS, detectHostRestriction } from "../review/host-sandbox.js";
7
8
  import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
8
9
  import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
9
10
  import { harnessDescriptor, managedRules } from "./harness.js";
@@ -371,6 +372,49 @@ async function checkRegistrationDrift(root, manifest, config) {
371
372
  return check("registration-drift", "UNKNOWN", "Hook registration drift could not be assessed safely.", undefined, "No action needed; drift could not be computed.");
372
373
  }
373
374
  }
375
+ /**
376
+ * Whether anything can ever be verified here. Completion requires current PASS
377
+ * evidence from a required verifier, so an empty list is not a light
378
+ * configuration — it is a loop that cannot close, and an installation reaches
379
+ * that state quietly whenever stack detection declines to guess.
380
+ */
381
+ function checkVerificationCommands(config) {
382
+ if (config === undefined) {
383
+ return check("verification-commands", "UNKNOWN", "Configuration could not be read, so verifiers are unknown.");
384
+ }
385
+ if (config.verification.commands.length === 0) {
386
+ return check("verification-commands", "DEGRADED", "No verification command is configured, so no task can be completed.",
387
+ // Codeless on purpose: the fix is an edit to the configuration file, not
388
+ // an agent-ops command, and a code here would force a non-zero exit on
389
+ // every installation whose stack detection declined to guess.
390
+ undefined, `Add verification.commands to ${CONFIG_PATH}, then run doctor again.`);
391
+ }
392
+ const required = config.verification.commands.filter(({ required: isRequired }) => isRequired);
393
+ if (required.length === 0) {
394
+ return check("verification-commands", "DEGRADED", "Every configured verification command is optional, so no criterion can " +
395
+ "be satisfied by one.", undefined, `Mark at least one command in ${CONFIG_PATH} as required.`);
396
+ }
397
+ return check("verification-commands", "PASS", `Required verifiers: ${required.map(({ id }) => id).join(", ")}.`);
398
+ }
399
+ /**
400
+ * What the surrounding host withholds from a reviewer. Reported separately
401
+ * from `review-targets` on purpose: a sandbox that blocks the network makes an
402
+ * authenticated CLI answer "not logged in", and reading that verdict as a
403
+ * credential problem is the misdiagnosis this check exists to prevent.
404
+ */
405
+ async function checkHostSandbox(detect) {
406
+ const restriction = await (detect ?? detectHostRestriction)();
407
+ if (restriction === "network-blocked") {
408
+ return check("host-sandbox", "DEGRADED", "This process runs in a sandbox with no network access, so no review " +
409
+ "target can answer. Any authentication verdict below is unreliable.", undefined, "Run agent-ops outside the sandbox, or grant it escalated execution.");
410
+ }
411
+ if (restriction === "bind-blocked") {
412
+ return check("host-sandbox", "DEGRADED", `This process cannot open a loopback listener, so review targets that ` +
413
+ `need one (${BIND_DEPENDENT_TARGETS.join(", ")}) run last and may be ` +
414
+ "unable to answer.", undefined, "Run agent-ops outside the sandbox, or grant it escalated execution.");
415
+ }
416
+ return check("host-sandbox", "PASS", "No host sandbox restriction affects review targets.");
417
+ }
374
418
  async function checkReviewTargets(config, probe, checkAuth) {
375
419
  const targets = config?.reviewRoles?.find((role) => role.role === "independent-review")?.targets ?? [];
376
420
  if (targets.length === 0) {
@@ -439,7 +483,9 @@ export async function doctorInstallation(options) {
439
483
  : []),
440
484
  await checkProbe("repository-trust", options.probes?.repositoryTrust),
441
485
  await checkProbe("smoke-availability", options.probes?.smokeAvailability),
442
- await checkReviewTargets(config.config, options.probes?.reviewTarget, options.checkReviewTargetAuth === true)
486
+ await checkReviewTargets(config.config, options.probes?.reviewTarget, options.checkReviewTargetAuth === true),
487
+ await checkHostSandbox(options.probes?.hostRestriction),
488
+ checkVerificationCommands(config.config)
443
489
  ];
444
490
  return {
445
491
  checks,