@kylecheng3146/agent-ops 0.1.24 → 0.2.0

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);
@@ -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 ||
@@ -230,7 +234,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
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",
@@ -246,7 +250,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
246
250
  const configOutcome = await hookConfigOutcome(root, dependencies.loadConfig);
247
251
  if (configOutcome.kind === "invalid") {
248
252
  if (completionGateInstalled) {
249
- writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
253
+ writeHookOutput(io, harnessDescriptor(harness).runtime.formatOutput("Stop", {
250
254
  action: "block",
251
255
  status: "UNKNOWN",
252
256
  code: "COMPLETION_GATE_CONFIG_INVALID",
@@ -268,7 +272,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
268
272
  }
269
273
  const config = configOutcome.config;
270
274
  if (completionGateInstalled && !config.features.completionGate.enabled) {
271
- writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
275
+ writeHookOutput(io, harnessDescriptor(harness).runtime.formatOutput("Stop", {
272
276
  action: "block",
273
277
  status: "UNKNOWN",
274
278
  code: "COMPLETION_GATE_CONFIG_DISABLED",
@@ -293,7 +297,12 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
293
297
  processRunner
294
298
  })
295
299
  : undefined;
296
- const completionGate = harnessId === "agy" && config.features.completionGate.enabled
300
+ // Not `completionGateInstalled`: that is the Stop handler's own marker,
301
+ // and the gate also answers PreToolUse — where it turns a self-issued
302
+ // `allow-stop` into a question for the user. Narrowing this to Stop takes
303
+ // the escape hatch's approval step away.
304
+ const completionGate = (harnessId === "agy" || harnessId === "claude") &&
305
+ config.features.completionGate.enabled
297
306
  ? dependencies.completionGate ?? {
298
307
  handle: async (normalized) => await new CompletionGateService({
299
308
  root,
@@ -320,7 +329,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
320
329
  }
321
330
  catch {
322
331
  if (completionGateInstalled) {
323
- writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
332
+ writeHookOutput(io, harnessDescriptor(harness).runtime.formatOutput("Stop", {
324
333
  action: "block",
325
334
  status: "UNKNOWN",
326
335
  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,
@@ -308,7 +308,7 @@ export function managedRules(descriptor, context) {
308
308
  lines.push("Command policy guards high-confidence unsafe actions. Explicitly enabled", "Stop verification is report-only and never marks a task complete by itself.", "");
309
309
  }
310
310
  if (context.capabilities.includes("completion-gate")) {
311
- lines.push("The agy completion gate applies only when this conversation creates a", "Git-visible net change after its first PreInvocation baseline. Read-only", "questions and analysis stop normally. A changed conversation must be", "attached to one task with two to five acceptance criteria; current PASS", "verification evidence, a PASS review attestation, and completed task state", "are all required before Stop. Error, max-step, and non-idle stops are not", "blocked. The gate inspects evidence but never runs tests or review itself.", "A user may approve `agent-ops allow-stop --session <conversationId>` for", "one Stop bound to the current source fingerprint; the PreToolUse hook must", "return `force_ask`, so the agent cannot self-authorize this escape hatch.", "For headless or CI enforcement, launch agy through", "`agent-ops agy-run -- <agy arguments>`.", "");
311
+ lines.push("The completion gate runs on agy and Claude Code. It applies only when", "this conversation creates a Git-visible net change after its first", "session baseline. Read-only", "questions and analysis stop normally. A changed conversation must be", "attached to one task with two to five acceptance criteria; current PASS", "verification evidence, a PASS review attestation, and completed task state", "are all required before Stop. Error, max-step, and non-idle stops are not", "blocked. The gate inspects evidence but never runs tests or review itself.", "A user may approve `agent-ops allow-stop --session <conversationId>` for", "one Stop bound to the current source fingerprint; the PreToolUse hook", "asks the user, so the agent cannot self-authorize this escape hatch.", "For headless or CI enforcement, launch agy through", "`agent-ops agy-run -- <agy arguments>`.", "");
312
312
  }
313
313
  lines.push(`This file is routed from the active ${descriptor.control.instructionFile}.`, "");
314
314
  return lines.join("\n");
@@ -65,11 +65,18 @@ function verificationCommandFromProposal(proposal) {
65
65
  async function detectVerificationCommands(root) {
66
66
  const discovery = await discoverProject(root);
67
67
  if (discovery.kind !== "project") {
68
- return [];
68
+ return { commands: [], blockers: [discovery.message] };
69
69
  }
70
- return discovery.proposals
70
+ const commands = discovery.proposals
71
71
  .filter((proposal) => proposal.confidence === "high")
72
72
  .map(verificationCommandFromProposal);
73
+ // What detection could not settle on its own. Kept even when commands were
74
+ // found, because an installation that ends with no verifier has to be able
75
+ // to say why: silence there leaves a loop that can never complete a task.
76
+ return {
77
+ commands,
78
+ blockers: discovery.decisions.map((decision) => `${decision.adapter}: ${decision.message}`)
79
+ };
73
80
  }
74
81
  function buildConfig(profiles, existing, reviewTargets = [], detectedCommands = [], completionGateEnabled = false) {
75
82
  // Absent reviewRoles means external review is disabled; an empty selection
@@ -130,11 +137,11 @@ async function planConfig(root, profiles, existingManifest, suppliedConfig, revi
130
137
  }
131
138
  existingConfig = result.value;
132
139
  }
133
- const detectedCommands = existingConfig === undefined ||
140
+ const detected = existingConfig === undefined ||
134
141
  existingConfig.verification.commands.length === 0
135
142
  ? await detectVerificationCommands(root)
136
- : [];
137
- const config = buildConfig(profiles, existingConfig, reviewTargets, detectedCommands, completionGateEnabled);
143
+ : { commands: [], blockers: [] };
144
+ const config = buildConfig(profiles, existingConfig, reviewTargets, detected.commands, completionGateEnabled);
138
145
  const content = `${JSON.stringify(config, null, 2)}\n`;
139
146
  return {
140
147
  operation: {
@@ -150,7 +157,8 @@ async function planConfig(root, profiles, existingManifest, suppliedConfig, revi
150
157
  owner: "agent-ops"
151
158
  },
152
159
  config,
153
- detectedVerification: detectedCommands
160
+ detectedVerification: detected.commands,
161
+ verificationBlockers: detected.blockers
154
162
  };
155
163
  }
156
164
  function pathKey(path) {
@@ -381,11 +389,16 @@ export async function createInstallPlan(options) {
381
389
  assertLoopProfileSupport(options.scope, options.harness, resolved.capabilities);
382
390
  const completionGateEnabled = options.existingConfig?.value.features.completionGate.enabled ??
383
391
  options.completionGateEnabled === true;
392
+ // agy and Claude Code are the hosts whose Stop hook can refuse a stop.
393
+ // codex never fires Stop under `codex exec` and rejects
394
+ // `permissionDecision:ask`, so its permit could not be user-approved;
395
+ // opencode's plugin can only deny a tool call.
396
+ const gateHosts = ["agy", "claude"];
384
397
  if (completionGateEnabled &&
385
398
  (options.scope !== "project" ||
386
- !options.harness.includes("agy") ||
399
+ !gateHosts.some((host) => options.harness.includes(host)) ||
387
400
  !resolved.capabilities.includes("project-loop"))) {
388
- throw new AgentOpsError("COMPLETION_GATE_UNSUPPORTED", "The completion gate requires project scope with the agy harness and loop profile.");
401
+ throw new AgentOpsError("COMPLETION_GATE_UNSUPPORTED", "The completion gate requires project scope with the agy or claude harness and loop profile.");
389
402
  }
390
403
  if (completionGateEnabled &&
391
404
  !resolved.capabilities.includes("completion-gate")) {
@@ -589,6 +602,7 @@ export async function createInstallPlan(options) {
589
602
  config: config.config,
590
603
  manifest,
591
604
  operations,
592
- detectedVerification: config.detectedVerification
605
+ detectedVerification: config.detectedVerification,
606
+ verificationBlockers: config.verificationBlockers
593
607
  };
594
608
  }
@@ -7,6 +7,23 @@ export function agyVersionSupported(versionOutput) {
7
7
  return version !== undefined && !version.some((part, index) => part < MINIMUM_AGY_VERSION[index] &&
8
8
  version.slice(0, index).every((prior, priorIndex) => prior === MINIMUM_AGY_VERSION[priorIndex]));
9
9
  }
10
+ /**
11
+ * Whether a loaded hook command is the managed handler for this event. The
12
+ * event and the ownership marker are both required, but flags may sit between
13
+ * them: the Stop handler carries `--completion-gate` when the project loop is
14
+ * enabled, and matching the whole tail as one string reported every gated
15
+ * installation as unmanaged — a failure `agent-ops update` could never fix,
16
+ * because update installs exactly the command being rejected.
17
+ */
18
+ function isManagedHookCommand(command, event) {
19
+ const marker = " --managed-by=agent-ops";
20
+ if (!command.endsWith(marker)) {
21
+ return false;
22
+ }
23
+ const flags = command.slice(0, -marker.length);
24
+ return flags.endsWith(` agy ${event}`) ||
25
+ flags.includes(` agy ${event} --`);
26
+ }
10
27
  export function agyRuntimeStatus(versionOutput, hooksOutput, expectedEvents = []) {
11
28
  const match = /\b(\d+)\.(\d+)\.(\d+)\b/u.exec(versionOutput);
12
29
  if (!agyVersionSupported(versionOutput)) {
@@ -35,7 +52,7 @@ export function agyRuntimeStatus(versionOutput, hooksOutput, expectedEvents = []
35
52
  return (typeof action === "object" && action !== null && !Array.isArray(action) &&
36
53
  action.event === nativeEvent &&
37
54
  typeof action.command === "string" &&
38
- action.command.endsWith(` agy ${expected} --managed-by=agent-ops`));
55
+ isManagedHookCommand(action.command, expected));
39
56
  }));
40
57
  });
41
58
  if (!Array.isArray(hooks) || (expectedEvents.length > 0 && !loaded)) {
@@ -1,4 +1,4 @@
1
- import { chmod, copyFile, lstat, mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
1
+ import { chmod, copyFile, lstat, mkdir, mkdtemp, realpath, rm, stat } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { runVerificationCommand } from "../verify/spawn.js";
@@ -7,6 +7,7 @@ import { extractReviewObject } from "./extract.js";
7
7
  import { buildTargetInvocation } from "./invocation.js";
8
8
  import { reviewReportResults, reviewReportStatus, validateReviewReport } from "./report.js";
9
9
  import { detectHostTarget, orderChain } from "./roles.js";
10
+ import { BIND_DEPENDENT_TARGETS, detectHostRestriction } from "./host-sandbox.js";
10
11
  import { buildAdversarialPrompt, buildReviewPrompt } from "./runner.js";
11
12
  /**
12
13
  * Full repository reviews need far more headroom than the lightweight auth
@@ -15,6 +16,15 @@ import { buildAdversarialPrompt, buildReviewPrompt } from "./runner.js";
15
16
  * review while looking like an unavailable target.
16
17
  */
17
18
  export const DEFAULT_REVIEW_TIMEOUT_MS = 900_000;
19
+ /**
20
+ * How long a reviewer may produce nothing at all before it is treated as
21
+ * wedged rather than slow. A reviewer the host sandbox has blocked never
22
+ * writes another byte, and waiting out the full review timeout spends 15
23
+ * minutes per target — 45 for a three-target chain — to learn that. Progress
24
+ * output and a growing log file both count, so a reviewer that is merely
25
+ * thinking hard is never cut off.
26
+ */
27
+ export const DEFAULT_STALL_IDLE_MS = 90_000;
18
28
  export class ReviewInterruptedError extends Error {
19
29
  signal;
20
30
  constructor(signal) {
@@ -121,6 +131,54 @@ function rejectedCallReason(output) {
121
131
  }
122
132
  return "capability-unavailable";
123
133
  }
134
+ /**
135
+ * Watches one reviewer for silence. Two things count as a sign of life: a byte
136
+ * on either stream, reported through `beat`, and growth of the target's own log
137
+ * file, polled here because a target that buffers stdout until it answers has
138
+ * no other observable heartbeat.
139
+ */
140
+ function watchForStall(logFile, parent, idleMs) {
141
+ const controller = new AbortController();
142
+ const pollMs = Math.max(20, Math.min(5_000, Math.floor(idleMs / 3)));
143
+ let lastBeat = Date.now();
144
+ let logSize = -1;
145
+ let stalled = false;
146
+ const beat = () => {
147
+ lastBeat = Date.now();
148
+ };
149
+ const check = async () => {
150
+ if (logFile !== undefined) {
151
+ try {
152
+ const info = await stat(logFile);
153
+ if (info.size > logSize) {
154
+ logSize = info.size;
155
+ beat();
156
+ }
157
+ }
158
+ catch {
159
+ // The target has not created its log yet, which is not a heartbeat.
160
+ }
161
+ }
162
+ if (!stalled && Date.now() - lastBeat >= idleMs) {
163
+ stalled = true;
164
+ controller.abort("stalled");
165
+ }
166
+ };
167
+ const timer = setInterval(() => {
168
+ void check();
169
+ }, pollMs);
170
+ timer.unref();
171
+ return {
172
+ signal: parent === undefined
173
+ ? controller.signal
174
+ : AbortSignal.any([parent, controller.signal]),
175
+ stalled: () => stalled,
176
+ beat,
177
+ stop: () => {
178
+ clearInterval(timer);
179
+ }
180
+ };
181
+ }
124
182
  function throwIfInterrupted(target, options, failureClass) {
125
183
  if (failureClass !== "aborted" && options.signal?.aborted !== true) {
126
184
  return;
@@ -187,13 +245,14 @@ async function attemptTarget(request, options) {
187
245
  });
188
246
  const attemptDirectory = await mkdtemp(join(tmpdir(), "agent-ops-review-"));
189
247
  try {
248
+ const agyLog = target === "agy"
249
+ ? join(attemptDirectory, "agy.log")
250
+ : undefined;
190
251
  const invocationRequest = {
191
252
  target,
192
253
  prompt: request.prompt,
193
254
  repositoryRoot: request.repositoryRoot,
194
- ...(target === "agy"
195
- ? { logFile: join(attemptDirectory, "agy.log") }
196
- : {}),
255
+ ...(agyLog === undefined ? {} : { logFile: agyLog }),
197
256
  ...(options.model === undefined ? {} : { model: options.model }),
198
257
  ...(options.effort === undefined ? {} : { effort: options.effort })
199
258
  };
@@ -237,6 +296,14 @@ async function attemptTarget(request, options) {
237
296
  : firstComplaint(capability.stderr, capability.stdout) ??
238
297
  `help probe failed (${capability.failureClass})`, "skipping");
239
298
  }
299
+ // agy takes its log file unconditionally; claude's equivalent is passed
300
+ // only when this install advertises it, so an older CLI keeps reviewing
301
+ // and merely loses the file heartbeat.
302
+ const heartbeatLog = target === "agy"
303
+ ? agyLog
304
+ : target === "claude" && help.includes("--debug-file")
305
+ ? join(attemptDirectory, "claude-debug.log")
306
+ : undefined;
240
307
  const snapshotRoot = join(attemptDirectory, "repository");
241
308
  const snapshotError = await snapshotRepository(request, snapshotRoot, options);
242
309
  if (snapshotError !== undefined) {
@@ -251,10 +318,20 @@ async function attemptTarget(request, options) {
251
318
  "Run every repository-relative inspection in that directory.",
252
319
  "For terminal commands, use only git status, git diff, git log, or git show; " +
253
320
  "read specific files with file-reading tools instead of ls, find, cat, or rg.",
321
+ // agy's only read-only mode is plan mode, and plan mode's default
322
+ // job is to author an implementation plan and then ask the caller
323
+ // whether to proceed. Under `--print` that question ends the one
324
+ // turn it gets, so the review comes back empty after minutes of
325
+ // work. Saying what the turn is for is what keeps it answering.
326
+ "You are answering a review question, not planning work. Do not write " +
327
+ "an implementation plan. Do not create or edit any file. Do not ask " +
328
+ "the user anything. Reply with the JSON object the schema requires " +
329
+ "and nothing else.",
254
330
  request.prompt
255
331
  ].join("\n")
256
332
  }
257
333
  : {}),
334
+ ...(heartbeatLog === undefined ? {} : { logFile: heartbeatLog }),
258
335
  repositoryRoot: snapshotRoot
259
336
  });
260
337
  executionDirectory = snapshotRoot;
@@ -263,25 +340,42 @@ async function attemptTarget(request, options) {
263
340
  }
264
341
  throwIfInterrupted(target, options);
265
342
  options.onProgress?.(`${target}: review started (timeout: ${Math.ceil((options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS) / 1_000)}s)`);
266
- const spawned = await runVerificationCommand({
267
- id: `review-${request.label}`,
268
- command: invocation.command,
269
- args: [...invocation.args],
270
- cwd: executionDirectory,
271
- required: true,
272
- evidence: { kind: "exit-code" },
273
- timeoutMs: options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS
274
- }, {
275
- cwd: executionDirectory,
276
- ...(options.runner === undefined ? {} : { runner: options.runner }),
277
- ...(options.outputLimitBytes === undefined
278
- ? {}
279
- : { outputLimitBytes: options.outputLimitBytes }),
280
- stdin: invocation.stdin,
281
- env: environment,
282
- replaceEnv: true,
283
- ...(options.signal === undefined ? {} : { signal: options.signal })
284
- });
343
+ const stallIdleMs = options.stallIdleMs ?? DEFAULT_STALL_IDLE_MS;
344
+ const stallWatch = watchForStall(heartbeatLog, options.signal, stallIdleMs);
345
+ let spawned;
346
+ try {
347
+ spawned = await runVerificationCommand({
348
+ id: `review-${request.label}`,
349
+ command: invocation.command,
350
+ args: [...invocation.args],
351
+ cwd: executionDirectory,
352
+ required: true,
353
+ evidence: { kind: "exit-code" },
354
+ timeoutMs: options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS
355
+ }, {
356
+ cwd: executionDirectory,
357
+ ...(options.runner === undefined ? {} : { runner: options.runner }),
358
+ ...(options.outputLimitBytes === undefined
359
+ ? {}
360
+ : { outputLimitBytes: options.outputLimitBytes }),
361
+ stdin: invocation.stdin,
362
+ env: environment,
363
+ replaceEnv: true,
364
+ signal: stallWatch.signal,
365
+ onActivity: stallWatch.beat
366
+ });
367
+ }
368
+ finally {
369
+ stallWatch.stop();
370
+ }
371
+ // Ahead of `throwIfInterrupted`, which reads the same `aborted` failure
372
+ // class as a user interrupt. A stall is this executor's own abort, and
373
+ // ends one target rather than the whole review.
374
+ if (stallWatch.stalled() && options.signal?.aborted !== true) {
375
+ return skip("stalled", `no output for ${Math.max(1, Math.round(stallIdleMs / 1_000))}s; the reviewer is ` +
376
+ "probably blocked by the host sandbox, and retrying with more " +
377
+ "permission will not help");
378
+ }
285
379
  throwIfInterrupted(target, options, spawned.failureClass);
286
380
  if (spawned.failureClass === "timeout") {
287
381
  return skip("timeout", "the reviewer exceeded its timeout");
@@ -345,8 +439,38 @@ async function attemptTarget(request, options) {
345
439
  export function createReviewExecutor(options) {
346
440
  const report = options.onProgress ?? (() => { });
347
441
  const host = detectHostTarget(options.env ?? process.env);
348
- const chain = orderChain(options.targets, host);
442
+ const ordered = orderChain(options.targets, host);
349
443
  return async (request) => {
444
+ // Asked before anything is spent. A reviewer inherits this process's
445
+ // sandbox, so a restriction found here is a restriction every target in
446
+ // the chain would hit — one at a time, minutes apart.
447
+ const restriction = await detectHostRestriction({
448
+ env: options.env ?? process.env,
449
+ ...(options.probeBind === undefined ? {} : { probeBind: options.probeBind })
450
+ });
451
+ if (restriction === "network-blocked") {
452
+ report("host: the sandbox around this process blocks network access, so no " +
453
+ "reviewer can answer → not running the chain");
454
+ return {
455
+ status: "NOT_RUN",
456
+ reason: "host-sandboxed",
457
+ ...(host === undefined ? {} : { harness: host }),
458
+ attempts: []
459
+ };
460
+ }
461
+ // Only agy needs a loopback listener, so a bind-blocked host is not a dead
462
+ // end — it is a reason to spend the other targets first rather than
463
+ // discovering the same failure at the head of the chain every time.
464
+ const chain = restriction === "bind-blocked"
465
+ ? [
466
+ ...ordered.filter((target) => !BIND_DEPENDENT_TARGETS.includes(target)),
467
+ ...ordered.filter((target) => BIND_DEPENDENT_TARGETS.includes(target))
468
+ ]
469
+ : ordered;
470
+ if (restriction === "bind-blocked" && chain.join() !== ordered.join()) {
471
+ report("host: this process cannot open a loopback listener, so targets that " +
472
+ `need one run last (chain: ${chain.join(" → ")})`);
473
+ }
350
474
  const expectedCriterionIds = request.invocation.packet.criteria.map((criterion) => criterion.id);
351
475
  const repositoryRoot = await realpath(options.cwd);
352
476
  const shared = {
@@ -0,0 +1,49 @@
1
+ import { createServer } from "node:net";
2
+ /** Targets that need a loopback listener of their own to answer at all. */
3
+ export const BIND_DEPENDENT_TARGETS = ["agy"];
4
+ const BIND_PROBE_TIMEOUT_MS = 2_000;
5
+ /**
6
+ * Opens and immediately closes a loopback listener on an ephemeral port.
7
+ * Cheap enough to run before every review, and it exercises exactly the
8
+ * capability a sandboxed host withholds.
9
+ */
10
+ export async function probeLoopbackBind() {
11
+ return await new Promise((resolve) => {
12
+ const server = createServer();
13
+ let settled = false;
14
+ const finish = (value) => {
15
+ if (settled) {
16
+ return;
17
+ }
18
+ settled = true;
19
+ clearTimeout(timer);
20
+ server.close(() => resolve(value));
21
+ };
22
+ const timer = setTimeout(() => finish(false), BIND_PROBE_TIMEOUT_MS);
23
+ timer.unref();
24
+ server.once("error", () => {
25
+ if (!settled) {
26
+ settled = true;
27
+ clearTimeout(timer);
28
+ resolve(false);
29
+ }
30
+ });
31
+ server.listen(0, "127.0.0.1", () => finish(true));
32
+ });
33
+ }
34
+ /**
35
+ * The host's restriction, read from what the host publishes about itself and
36
+ * then, when that says nothing, from what this process can actually do. Codex
37
+ * declares both facts in the environment; nothing else does, so the probe is
38
+ * what covers every other host.
39
+ */
40
+ export async function detectHostRestriction(options = {}) {
41
+ const env = options.env ?? process.env;
42
+ // Declared and total: no reviewer can reach its own API, so probing the
43
+ // narrower loopback capability would only add latency to a settled answer.
44
+ if (env.CODEX_SANDBOX_NETWORK_DISABLED === "1") {
45
+ return "network-blocked";
46
+ }
47
+ const probe = options.probeBind ?? probeLoopbackBind;
48
+ return (await probe()) ? "none" : "bind-blocked";
49
+ }
@@ -66,6 +66,23 @@ export const READ_ONLY_ARGS = {
66
66
  claude: ["--permission-mode", "plan"],
67
67
  codex: ["-s", "read-only"]
68
68
  };
69
+ /**
70
+ * Where a target writes its running log, when it has one. This is the only
71
+ * heartbeat available for a target whose stdout stays silent until the answer
72
+ * arrives: the file grows while the reviewer works, and stops growing when it
73
+ * is wedged. claude's flag is passed opportunistically by the caller, so an
74
+ * install that predates `--debug-file` still reviews — it only loses the
75
+ * heartbeat.
76
+ */
77
+ function logArgs(target, logFile) {
78
+ if (logFile === undefined) {
79
+ return [];
80
+ }
81
+ if (target === "agy") {
82
+ return ["--log-file", logFile];
83
+ }
84
+ return target === "claude" ? ["--debug-file", logFile] : [];
85
+ }
69
86
  /** Per-target customization suppression. */
70
87
  function isolationArgs(target) {
71
88
  return target === "claude"
@@ -131,9 +148,7 @@ export function buildTargetInvocation(request) {
131
148
  ...(request.repositoryRoot === undefined
132
149
  ? []
133
150
  : ["--add-dir", request.repositoryRoot]),
134
- ...(request.target !== "agy" || request.logFile === undefined
135
- ? []
136
- : ["--log-file", request.logFile]),
151
+ ...logArgs(request.target, request.logFile),
137
152
  ...isolationArgs(request.target),
138
153
  ...shared
139
154
  ],
@@ -51,6 +51,20 @@ export function renderReviewResult(result) {
51
51
  result.attempts?.some((attempt) => attempt.reason === "login-required")) {
52
52
  lines.push("Run: agent-ops doctor --check-auth to verify target authentication.");
53
53
  }
54
+ if (result.reason === "host-sandboxed") {
55
+ lines.push("No target ran: the sandbox around this process blocks the network a " +
56
+ "reviewer needs. Run agent-ops review outside the sandbox, or grant " +
57
+ "this command escalated execution and run it again.");
58
+ }
59
+ // Deliberately not the authentication line: a stalled reviewer started and
60
+ // then went silent, so re-running it with more permission only spends the
61
+ // same wait again.
62
+ if (result.reason === "stalled" ||
63
+ result.attempts?.some((attempt) => attempt.reason === "stalled")) {
64
+ lines.push("A stalled reviewer is usually blocked by the host sandbox. Escalating " +
65
+ "permission and retrying does not help; run agent-ops review outside " +
66
+ "the sandbox instead.");
67
+ }
54
68
  return `${lines.join("\n")}\n`;
55
69
  }
56
70
  const report = result.report;
@@ -207,7 +207,12 @@ export class TaskService {
207
207
  if (current.status === "archived") {
208
208
  throw taskError("TASK_NOT_ACTIVE", "An archived task cannot be completed.");
209
209
  }
210
- const submitted = normalizeEvidence(current.task, evidenceInput);
210
+ // Supplying nothing means "the evidence this task already carries".
211
+ // Re-typing it changes no outcome — the union below adds the recorded
212
+ // references to whatever was submitted, so evidence can never be dropped
213
+ // by naming less of it — and forcing a caller to copy references back out
214
+ // of the task store buys nothing but the chance to mistype them.
215
+ const submitted = normalizeEvidence(current.task, Object.keys(evidenceInput).length === 0 ? current.evidence : evidenceInput);
211
216
  // A caller cannot hide a recorded failure by submitting only older PASS references.
212
217
  const evidence = normalizeEvidence(current.task, Object.fromEntries(Object.entries(submitted).map(([criterionId, references]) => [criterionId,
213
218
  [...new Set([...(current.evidence[criterionId] ?? []), ...references])]])));
@@ -45,7 +45,7 @@ async function* readableBytes(stream) {
45
45
  * last, and its failure list just before it, so head-truncating a large run
46
46
  * discards exactly the part that carries the evidence.
47
47
  */
48
- async function captureOutput(stream, limit) {
48
+ async function captureOutput(stream, limit, onActivity) {
49
49
  const chunks = [];
50
50
  let storedBytes = 0;
51
51
  let truncated = false;
@@ -72,6 +72,7 @@ async function captureOutput(stream, limit) {
72
72
  try {
73
73
  for await (const value of stream) {
74
74
  retain(Buffer.from(value));
75
+ onActivity?.();
75
76
  }
76
77
  }
77
78
  catch {
@@ -295,8 +296,8 @@ export async function runVerificationCommand(command, options) {
295
296
  catch {
296
297
  return emptyResult(command.id, "spawn-failed", elapsedMilliseconds(startedAt, now()));
297
298
  }
298
- const stdout = captureOutput(running.stdout, outputLimit);
299
- const stderr = captureOutput(running.stderr, outputLimit);
299
+ const stdout = captureOutput(running.stdout, outputLimit, options.onActivity);
300
+ const stderr = captureOutput(running.stderr, outputLimit, options.onActivity);
300
301
  let timer;
301
302
  const timeout = new Promise((resolve) => {
302
303
  timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
@@ -161,17 +161,21 @@ user hooks live in `.gemini/config/hooks.json`. User-scope rules modify the
161
161
  shared Gemini rule surface at `.gemini/GEMINI.md`. agy 1.1.12 or newer is
162
162
  required for machine-readable `/hooks` diagnostics.
163
163
 
164
- For `agy` plus `loop`, the interactive installer recommends
164
+ For `agy` or `claude` plus `loop`, the interactive installer recommends
165
165
  `features.completionGate.enabled`; non-interactive installs require the explicit
166
- `--completion-gate` flag. The gate uses the documented `conversationId`,
166
+ `--completion-gate` flag. These are the two hosts whose Stop hook can refuse a
167
+ stop: codex never fires its Stop hook under `codex exec` and rejects
168
+ `permissionDecision: ask`, so its permit could not be user-approved, and
169
+ OpenCode's plugin can only deny a tool call. The gate uses the documented `conversationId`,
167
170
  `terminationReason`, and `fullyIdle` Stop fields and returns the documented
168
171
  `decision: "continue"` only for a final changed conversation that lacks current
169
172
  task, verification, or review proof. Pure Q&A, analysis, read-only diagnostics,
170
- error stops, max-step stops, and non-idle stops continue normally. It does not
171
- change Codex, Claude Code, or OpenCode Stop behavior. For headless execution use
172
- `agent-ops agy-run -- <agy arguments>`; a user-approved one-time escape is
173
- `agent-ops allow-stop --session <conversationId>` and is guarded by agy's
174
- documented `force_ask` decision.
173
+ error stops, max-step stops, and non-idle stops continue normally. Claude Code
174
+ enforces the same gate through its own Stop contract, answering a refusal with
175
+ `decision: "block"`. It does not change Codex or OpenCode Stop behavior. For
176
+ headless execution use `agent-ops agy-run -- <agy arguments>`; a user-approved
177
+ one-time escape is `agent-ops allow-stop --session <conversationId>`, guarded by
178
+ agy's documented `force_ask` decision and by Claude's `permissionDecision: ask`.
175
179
 
176
180
  Official references: [agy CLI workspace rule files](https://www.antigravity.google/docs/cli/best-practices/)
177
181
  and [Antigravity hook contracts](https://www.antigravity.google/docs/hooks/).
@@ -213,8 +217,8 @@ classified invalid installed configuration. The managed OpenCode
213
217
  unavailable-runtime error for its supported Bash surface. Codex is explicitly
214
218
  non-enforcing (`unknown`). These are agent-ops output and plugin contracts, not
215
219
  proof that a host honors a denial. `SessionStart` and ordinary Stop verification
216
- failure paths stay fail-open. Only the explicitly enabled agy completion gate
217
- fails closed at final Stop.
220
+ failure paths stay fail-open. Only the explicitly enabled completion gate fails
221
+ closed at final Stop, on agy and Claude Code.
218
222
 
219
223
  Claude's invalid-config fallback has four safeguards: (1) an absent project
220
224
  configuration stays fail-open, so only an invalid `.agent-ops/config.json` can
@@ -143,16 +143,19 @@ agy 會安裝原生 `PreInvocation` 與 `PreToolUse(run_command)` 子集,docto
143
143
  位於 `.gemini/config/hooks.json`;user scope 會修改共享 Gemini rule surface
144
144
  `.gemini/GEMINI.md`。機器可讀的 `/hooks` 診斷要求 agy 1.1.12 以上。
145
145
 
146
- `agy` 搭配 `loop` 時,互動式 installer 會建議啟用
146
+ `agy` 或 `claude` 搭配 `loop` 時,互動式 installer 會建議啟用
147
147
  `features.completionGate.enabled`;非互動安裝必須明確傳入
148
- `--completion-gate`。閘門使用官方定義的 `conversationId`、
148
+ `--completion-gate`。這兩者是 Stop hook 能真正拒絕收工的 host:codex 在
149
+ `codex exec` 下不會觸發 Stop hook,且拒絕 `permissionDecision: ask`,其 permit
150
+ 無法交由使用者核准;OpenCode plugin 只能拒絕單一 tool call。閘門使用官方定義的 `conversationId`、
149
151
  `terminationReason` 與 `fullyIdle` Stop 欄位,只有在本次 conversation 產生
150
152
  Git-visible net change 且缺少當前 task、驗證或 review 證據時,才回傳官方定義的
151
153
  `decision: "continue"`。純問答、分析、唯讀診斷、錯誤、max-step 與 non-idle Stop
152
- 都正常結束;本版不改變 Codex、Claude Code OpenCode Stop 行為。Headless
154
+ 都正常結束。Claude Code 以自身的 Stop contract 執行同一道閘門,拒絕時回傳
155
+ `decision: "block"`;本版不改變 Codex 或 OpenCode 的 Stop 行為。Headless
153
156
  請使用 `agent-ops agy-run -- <agy arguments>`;使用者可核准一次
154
- `agent-ops allow-stop --session <conversationId>`,該命令由官方定義的
155
- `force_ask` 強制詢問。
157
+ `agent-ops allow-stop --session <conversationId>`,該命令在 agy 由官方定義的
158
+ `force_ask` 強制詢問,在 Claude Code 則由 `permissionDecision: ask` 強制詢問。
156
159
 
157
160
  官方依據:[agy CLI workspace rule file](https://www.antigravity.google/docs/cli/best-practices/)
158
161
  與 [Antigravity hook contract](https://www.antigravity.google/docs/hooks/)。
@@ -191,7 +194,8 @@ OpenCode `tool.execute.before` plugin 可在其支援的 Bash surface
191
194
  上 throw 文件化的 command-policy denial 或 unavailable-runtime error。Codex 明確
192
195
  不執行強制措施(`unknown`)。這些是 agent-ops 的 output 與 plugin contract,不
193
196
  證明 host 會實際遵守 denial。所有 `SessionStart` 與一般 Stop verification failure
194
- path 都維持 fail-open;只有明確啟用的 agy completion gate 會在 final Stop fail-closed。
197
+ path 都維持 fail-open;只有明確啟用的 completion gate 會在 final Stop
198
+ fail-closed,適用於 agy 與 Claude Code。
195
199
 
196
200
  Claude 的無效 config fallback 有四項防護:(1) 缺少 project configuration 時保持
197
201
  fail-open,因此只有無效的 `.agent-ops/config.json` 能進入 fallback;(2) manifest
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.1.24",
3
+ "version": "0.2.0",
4
4
  "description": "Evidence-driven development loops for agy, Codex, Claude Code, and opencode",
5
5
  "type": "module",
6
6
  "license": "MIT",