@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.
- package/dist/packages/cli/src/args.js +21 -2
- package/dist/packages/cli/src/cli.js +135 -3
- package/dist/packages/cli/src/commands/hook.js +6 -2
- package/dist/packages/cli/src/commands/init.js +23 -10
- package/dist/packages/cli/src/commands/review.js +8 -2
- package/dist/packages/cli/src/hook-process.js +31 -7
- package/dist/runtime/src/adapters/claude/config.js +57 -7
- package/dist/runtime/src/adapters/claude/events.js +8 -0
- package/dist/runtime/src/adapters/claude/input.js +21 -7
- package/dist/runtime/src/adapters/claude/output.js +24 -0
- package/dist/runtime/src/hooks/completion-gate.js +1 -1
- package/dist/runtime/src/install/doctor.js +47 -1
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/install/plan.js +23 -9
- package/dist/runtime/src/install/probes.js +18 -1
- package/dist/runtime/src/review/execute.js +148 -24
- package/dist/runtime/src/review/host-sandbox.js +49 -0
- package/dist/runtime/src/review/invocation.js +18 -3
- package/dist/runtime/src/review/render.js +44 -0
- package/dist/runtime/src/task/service.js +6 -1
- package/dist/runtime/src/verify/spawn.js +4 -3
- package/docs/en/guides/configuration.md +13 -9
- package/docs/zh-TW/guides/configuration.md +10 -6
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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:
|
|
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(
|
|
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
|
|
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
|
-
...(
|
|
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
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
:
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
171
|
-
|
|
172
|
-
`
|
|
173
|
-
`agent-ops
|
|
174
|
-
|
|
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
|
|
217
|
-
|
|
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
|
|
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
|
-
|
|
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;只有明確啟用的
|
|
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
|