@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.
@@ -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
  ],
@@ -3,6 +3,16 @@ import { safeTaskText } from "../task/render.js";
3
3
  function safe(value) {
4
4
  return safeTaskText(redactSecrets(value));
5
5
  }
6
+ /**
7
+ * NOT_RUN reasons that mean the task's verification evidence, not the reviewer,
8
+ * stopped the run: every one of them is cleared by producing fresh evidence.
9
+ */
10
+ const VERIFICATION_EVIDENCE_REASONS = new Set([
11
+ "stale-verification",
12
+ "missing-verification-evidence",
13
+ "unreadable-verification-evidence",
14
+ "verification-not-passed"
15
+ ]);
6
16
  function lineList(values) {
7
17
  return values.length === 0 ? ["- none"] : values.map((value) => `- ${safe(value)}`);
8
18
  }
@@ -51,6 +61,40 @@ export function renderReviewResult(result) {
51
61
  result.attempts?.some((attempt) => attempt.reason === "login-required")) {
52
62
  lines.push("Run: agent-ops doctor --check-auth to verify target authentication.");
53
63
  }
64
+ // Evidence is pinned to the source it was produced from, so any edit to a
65
+ // changed file after the verifier ran — a doc rewritten by a later step
66
+ // counts — voids it. Without the next command the caller reads
67
+ // "stale-verification" as a review failure and retries review instead.
68
+ if (result.reason === "stale-verification") {
69
+ lines.push("The source changed after this evidence was recorded, so it no longer " +
70
+ "describes the worktree under review.");
71
+ }
72
+ const verifyCommand = `agent-ops verify --task ${result.taskId ?? "<task-id>"}`;
73
+ if (result.reason === "verification-not-passed") {
74
+ // No review invitation: the verifier failed, and review is refused until
75
+ // a passing run replaces that evidence. Naming review here is what sent
76
+ // the caller back to the command that had just refused them.
77
+ lines.push("The recorded verification did not pass. Fix what failed, then run: " +
78
+ `${verifyCommand}. Re-running review cannot turn a failing verifier ` +
79
+ "into a PASS.");
80
+ }
81
+ else if (VERIFICATION_EVIDENCE_REASONS.has(result.reason ?? "")) {
82
+ lines.push(`Run: ${verifyCommand}, then run this review again.`);
83
+ }
84
+ if (result.reason === "host-sandboxed") {
85
+ lines.push("No target ran: the sandbox around this process blocks the network a " +
86
+ "reviewer needs. Run agent-ops review outside the sandbox, or grant " +
87
+ "this command escalated execution and run it again.");
88
+ }
89
+ // Deliberately not the authentication line: a stalled reviewer started and
90
+ // then went silent, so re-running it with more permission only spends the
91
+ // same wait again.
92
+ if (result.reason === "stalled" ||
93
+ result.attempts?.some((attempt) => attempt.reason === "stalled")) {
94
+ lines.push("A stalled reviewer is usually blocked by the host sandbox. Escalating " +
95
+ "permission and retrying does not help; run agent-ops review outside " +
96
+ "the sandbox instead.");
97
+ }
54
98
  return `${lines.join("\n")}\n`;
55
99
  }
56
100
  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.1",
4
4
  "description": "Evidence-driven development loops for agy, Codex, Claude Code, and opencode",
5
5
  "type": "module",
6
6
  "license": "MIT",