@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.
- 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 +7 -2
- package/dist/packages/cli/src/hook-process.js +15 -6
- 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 +14 -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
|
@@ -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
|
-
|
|
288
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
18
|
-
event: "session-start",
|
|
19
|
-
|
|
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
|
-
|
|
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", "
|
|
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
|
|
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
|
],
|
|
@@ -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
|
-
|
|
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
|