@kylecheng3146/agent-ops 0.1.20 → 0.1.21
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/README.md +23 -4
- package/dist/packages/cli/src/agy-headless.js +18 -0
- package/dist/packages/cli/src/args.js +29 -3
- package/dist/packages/cli/src/bin.js +54 -3
- package/dist/packages/cli/src/cli.js +3 -0
- package/dist/packages/cli/src/commands/allow-stop.js +11 -0
- package/dist/packages/cli/src/commands/hook.js +12 -1
- package/dist/packages/cli/src/commands/init.js +4 -1
- package/dist/packages/cli/src/commands/task.js +5 -2
- package/dist/packages/cli/src/context.js +4 -1
- package/dist/packages/cli/src/hook-process.js +59 -5
- package/dist/packages/cli/src/wizard.js +24 -0
- package/dist/runtime/src/adapters/agy/config.js +17 -7
- package/dist/runtime/src/adapters/agy/events.js +8 -0
- package/dist/runtime/src/adapters/agy/input.js +21 -3
- package/dist/runtime/src/adapters/agy/output.js +8 -4
- package/dist/runtime/src/config/explain.js +5 -0
- package/dist/runtime/src/config/migrate.js +15 -1
- package/dist/runtime/src/contracts.js +1 -1
- package/dist/runtime/src/hooks/completion-gate.js +209 -0
- package/dist/runtime/src/hooks/dispatch.js +6 -0
- package/dist/runtime/src/install/doctor.js +3 -1
- package/dist/runtime/src/install/harness.js +5 -1
- package/dist/runtime/src/install/ownership.js +9 -2
- package/dist/runtime/src/install/plan.js +19 -4
- package/dist/runtime/src/install/profiles.js +3 -0
- package/dist/runtime/src/schema/validate.js +11 -1
- package/dist/runtime/src/task/service.js +17 -0
- package/docs/en/guides/configuration.md +25 -7
- package/docs/en/spec/README.md +3 -3
- package/docs/en/spec/harness-adapters.md +7 -3
- package/docs/zh-TW/guides/configuration.md +22 -6
- package/docs/zh-TW/spec/README.md +4 -4
- package/docs/zh-TW/spec/harness-adapters.md +6 -3
- package/package.json +1 -1
- package/schemas/config.schema.json +12 -2
package/README.md
CHANGED
|
@@ -68,7 +68,8 @@ agent-ops uninstall --dry-run --json
|
|
|
68
68
|
|
|
69
69
|
`loop` is an explicit, project-only profile for agy, Codex, and Claude Code.
|
|
70
70
|
agy installs its native `PreInvocation`, `PreToolUse(run_command)`, and optional
|
|
71
|
-
`Stop` subset directly in `.agents/hooks.json
|
|
71
|
+
`Stop` subset directly in `.agents/hooks.json`, plus project routing in
|
|
72
|
+
`GEMINI.md` to `.agent-ops/GEMINI.md`; doctor reports its loop as
|
|
72
73
|
degraded because prompt, permission, compact, and subagent events are unavailable.
|
|
73
74
|
Claude Code supports native Windows through a generated PowerShell launcher;
|
|
74
75
|
Codex's loop launcher still requires POSIX-compatible `bash`. Preview it
|
|
@@ -80,6 +81,24 @@ agent-ops init --dry-run --scope project --harness codex,claude --profile loop -
|
|
|
80
81
|
agent-ops init --scope project --harness codex,claude --profile loop --yes
|
|
81
82
|
```
|
|
82
83
|
|
|
84
|
+
For a project agy loop, the wizard recommends the hard completion gate. In
|
|
85
|
+
automation, opt in explicitly:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
agent-ops init --scope project --harness agy --profile loop --completion-gate --yes
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The gate lets pure Q&A, analysis, and read-only diagnostics stop normally. It
|
|
92
|
+
activates only after that conversation creates a Git-visible net change, then
|
|
93
|
+
requires an attached completed task with two to five criteria, current PASS
|
|
94
|
+
evidence, and a PASS review for the current source fingerprint. It never runs
|
|
95
|
+
tests or review from `Stop`. Error, max-step, and non-idle stops continue. A
|
|
96
|
+
user can approve one fingerprint-bound escape with
|
|
97
|
+
`agent-ops allow-stop --session <conversationId>`. Use
|
|
98
|
+
`agent-ops agy-run -- <agy arguments>` for a process-exit recheck in headless or
|
|
99
|
+
CI use. Codex, Claude Code, and OpenCode retain their existing non-blocking Stop
|
|
100
|
+
behavior in this release.
|
|
101
|
+
|
|
83
102
|
On native Windows, select `--harness claude` unless Codex is running in a
|
|
84
103
|
POSIX environment; the Codex loop still invokes `bash`.
|
|
85
104
|
|
|
@@ -250,10 +269,10 @@ After changing Stop configuration, run `agent-ops update`; its approved plan
|
|
|
250
269
|
updates native registrations and the exact trust binding together. Stop is
|
|
251
270
|
report-only: `PASS`, `FAIL`, and `UNKNOWN` continue
|
|
252
271
|
the harness, emit bounded command evidence, and never complete a task.
|
|
253
|
-
|
|
254
|
-
|
|
272
|
+
Older configs migrate to config v3 with both Stop verification and the agy
|
|
273
|
+
completion gate disabled; migration invalidates the old trust binding.
|
|
255
274
|
|
|
256
|
-
The generated `AGENTS.md` and `
|
|
275
|
+
The generated `AGENTS.md`, `CLAUDE.md`, and agy `GEMINI.md` routing blocks are supplemental: they
|
|
257
276
|
load the managed baseline while leaving project-specific instructions in those
|
|
258
277
|
files authoritative. Existing installations with the previous canonical
|
|
259
278
|
wording are migrated by `agent-ops update`; changed managed blocks still fail
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Headless wrapper: native Stop is bounded by agy, process exit is not. */
|
|
2
|
+
export async function runAgyHeadless(options) {
|
|
3
|
+
await options.gate.initialize(options.sessionId);
|
|
4
|
+
const exitCode = await options.run(options.args, {
|
|
5
|
+
...(options.env ?? process.env),
|
|
6
|
+
AGENT_OPS_SESSION_ID: options.sessionId
|
|
7
|
+
});
|
|
8
|
+
if (exitCode !== 0)
|
|
9
|
+
return exitCode;
|
|
10
|
+
const result = await options.gate.handle({
|
|
11
|
+
event: "stop",
|
|
12
|
+
projectRoot: options.root,
|
|
13
|
+
sessionId: options.sessionId,
|
|
14
|
+
terminationReason: "model_stop",
|
|
15
|
+
fullyIdle: true
|
|
16
|
+
});
|
|
17
|
+
return result?.action === "block" ? 2 : 0;
|
|
18
|
+
}
|
|
@@ -8,7 +8,8 @@ export const COMMAND_NAMES = [
|
|
|
8
8
|
"uninstall",
|
|
9
9
|
"task",
|
|
10
10
|
"verify",
|
|
11
|
-
"review"
|
|
11
|
+
"review",
|
|
12
|
+
"allow-stop"
|
|
12
13
|
];
|
|
13
14
|
const COMMAND_SET = new Set(COMMAND_NAMES);
|
|
14
15
|
const SCOPES = new Set(["project", "user"]);
|
|
@@ -73,6 +74,7 @@ export function parseArgs(argv) {
|
|
|
73
74
|
const criteria = [];
|
|
74
75
|
const evidence = [];
|
|
75
76
|
let checkAuth = false;
|
|
77
|
+
let completionGate;
|
|
76
78
|
let dryRun = false;
|
|
77
79
|
let json = false;
|
|
78
80
|
let yes = false;
|
|
@@ -203,6 +205,12 @@ export function parseArgs(argv) {
|
|
|
203
205
|
}
|
|
204
206
|
checkAuth = true;
|
|
205
207
|
break;
|
|
208
|
+
case "--completion-gate":
|
|
209
|
+
if (completionGate !== undefined) {
|
|
210
|
+
duplicate(token);
|
|
211
|
+
}
|
|
212
|
+
completionGate = true;
|
|
213
|
+
break;
|
|
206
214
|
case "--dry-run":
|
|
207
215
|
if (dryRun) {
|
|
208
216
|
duplicate(token);
|
|
@@ -291,6 +299,7 @@ export function parseArgs(argv) {
|
|
|
291
299
|
criteria.length > 0 ||
|
|
292
300
|
evidence.length > 0 ||
|
|
293
301
|
reviewTargets.length > 0 ||
|
|
302
|
+
completionGate !== undefined ||
|
|
294
303
|
sessionId !== undefined ||
|
|
295
304
|
base !== undefined ||
|
|
296
305
|
checkAuth ||
|
|
@@ -317,6 +326,7 @@ export function parseArgs(argv) {
|
|
|
317
326
|
if (command !== "task" &&
|
|
318
327
|
command !== "verify" &&
|
|
319
328
|
command !== "review" &&
|
|
329
|
+
command !== "allow-stop" &&
|
|
320
330
|
(hasTaskTargetOptions || hasTaskMutationOptions)) {
|
|
321
331
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Task target options may be used only with task or verify.");
|
|
322
332
|
}
|
|
@@ -340,6 +350,9 @@ export function parseArgs(argv) {
|
|
|
340
350
|
if (reviewTargets.length > 0 && command !== "init") {
|
|
341
351
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--review-target may be used only with init.");
|
|
342
352
|
}
|
|
353
|
+
if (completionGate !== undefined && command !== "init") {
|
|
354
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--completion-gate may be used only with init.");
|
|
355
|
+
}
|
|
343
356
|
if (command === "task") {
|
|
344
357
|
if (harness !== undefined ||
|
|
345
358
|
profiles.length > 0 ||
|
|
@@ -349,8 +362,7 @@ export function parseArgs(argv) {
|
|
|
349
362
|
}
|
|
350
363
|
const taskOptionInvalid = (action === "create" &&
|
|
351
364
|
(taskId !== undefined ||
|
|
352
|
-
evidence.length > 0 ||
|
|
353
|
-
sessionId !== undefined)) ||
|
|
365
|
+
evidence.length > 0)) ||
|
|
354
366
|
(action === "status" &&
|
|
355
367
|
(title !== undefined ||
|
|
356
368
|
criteria.length > 0 ||
|
|
@@ -403,6 +415,19 @@ export function parseArgs(argv) {
|
|
|
403
415
|
if (command === "review" && harness !== undefined && harness.length !== 1) {
|
|
404
416
|
invalidValue("--harness", harness.join(","));
|
|
405
417
|
}
|
|
418
|
+
if (command === "allow-stop" &&
|
|
419
|
+
(sessionId === undefined ||
|
|
420
|
+
scope !== undefined ||
|
|
421
|
+
harness !== undefined ||
|
|
422
|
+
taskId !== undefined ||
|
|
423
|
+
profiles.length > 0 ||
|
|
424
|
+
title !== undefined ||
|
|
425
|
+
criteria.length > 0 ||
|
|
426
|
+
evidence.length > 0 ||
|
|
427
|
+
dryRun ||
|
|
428
|
+
yes)) {
|
|
429
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "allow-stop requires only --session (and optional --json).");
|
|
430
|
+
}
|
|
406
431
|
return {
|
|
407
432
|
command,
|
|
408
433
|
...(action === undefined ? {} : { action }),
|
|
@@ -415,6 +440,7 @@ export function parseArgs(argv) {
|
|
|
415
440
|
...(targetVersion === undefined ? {} : { targetVersion }),
|
|
416
441
|
...(title === undefined ? {} : { title }),
|
|
417
442
|
...(reviewTargets.length === 0 ? {} : { reviewTargets }),
|
|
443
|
+
...(completionGate === undefined ? {} : { completionGate }),
|
|
418
444
|
...(criteria.length === 0 ? {} : { criteria }),
|
|
419
445
|
...(evidence.length === 0 ? {} : { evidence }),
|
|
420
446
|
...(sessionId === undefined ? {} : { sessionId }),
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
5
|
-
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
8
|
import { commonHarnessAdapters, harnessHookPath, HARNESS_IDS } from "../../../runtime/src/install/harness.js";
|
|
8
9
|
import { agyRuntimeStatus, agyVersionSupported, hookRegistrationDrift, repositoryTrustStatus, smokeAvailabilityStatus } from "../../../runtime/src/install/probes.js";
|
|
@@ -33,8 +34,11 @@ import { probeReviewTarget } from "../../../runtime/src/review/probe.js";
|
|
|
33
34
|
import { resolveReviewRole } from "../../../runtime/src/review/roles.js";
|
|
34
35
|
import { runTrustCommand } from "./commands/trust.js";
|
|
35
36
|
import { runVerifyCommand } from "./commands/verify.js";
|
|
37
|
+
import { runAllowStopCommand } from "./commands/allow-stop.js";
|
|
38
|
+
import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
|
|
36
39
|
import { formatUpdatePlan, runUpdateCommand } from "./commands/update.js";
|
|
37
40
|
import { errorEnvelope } from "./output.js";
|
|
41
|
+
import { runAgyHeadless } from "./agy-headless.js";
|
|
38
42
|
const HOOK_RUNTIME_PATH = fileURLToPath(new URL("./hook-entry.js", import.meta.url));
|
|
39
43
|
async function readOptionalText(path) {
|
|
40
44
|
try {
|
|
@@ -122,7 +126,40 @@ function runAgy(args, options = {}) {
|
|
|
122
126
|
...(process.platform === "win32" ? { shell: true } : {})
|
|
123
127
|
});
|
|
124
128
|
}
|
|
125
|
-
if (argv[0] === "
|
|
129
|
+
if (argv[0] === "agy-run") {
|
|
130
|
+
try {
|
|
131
|
+
const root = process.cwd();
|
|
132
|
+
const config = (await loadEffectiveConfig(root, "project")).config;
|
|
133
|
+
if (!config.features.completionGate.enabled) {
|
|
134
|
+
throw new Error("agy-run requires features.completionGate.enabled.");
|
|
135
|
+
}
|
|
136
|
+
const taskService = new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root));
|
|
137
|
+
const gate = new CompletionGateService({
|
|
138
|
+
root,
|
|
139
|
+
config,
|
|
140
|
+
gitRunner: gitRunner(root),
|
|
141
|
+
taskService,
|
|
142
|
+
evidenceStore: new FileEvidenceStore(root, root)
|
|
143
|
+
});
|
|
144
|
+
const agyArgs = argv[1] === "--" ? argv.slice(2) : argv.slice(1);
|
|
145
|
+
process.exitCode = await runAgyHeadless({
|
|
146
|
+
root,
|
|
147
|
+
sessionId: `agy-headless-${randomUUID()}`,
|
|
148
|
+
args: agyArgs,
|
|
149
|
+
gate,
|
|
150
|
+
run: async (args, env) => await new Promise((resolve) => {
|
|
151
|
+
const child = spawn("agy", [...args], { cwd: root, env, stdio: "inherit" });
|
|
152
|
+
child.once("error", () => resolve(1));
|
|
153
|
+
child.once("exit", (code) => resolve(code ?? 1));
|
|
154
|
+
})
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
process.stderr.write(`${error instanceof Error ? error.message : "agy-run failed."}\n`);
|
|
159
|
+
process.exitCode = 2;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else if (argv[0] === "hook") {
|
|
126
163
|
process.exitCode = await runHookProcess(argv.slice(1), {
|
|
127
164
|
stdin: process.stdin,
|
|
128
165
|
writeStdout: (value) => process.stdout.write(value),
|
|
@@ -147,7 +184,8 @@ else {
|
|
|
147
184
|
"uninstall",
|
|
148
185
|
"task",
|
|
149
186
|
"verify",
|
|
150
|
-
"review"
|
|
187
|
+
"review",
|
|
188
|
+
"allow-stop"
|
|
151
189
|
].map((command) => [command, async (args) => {
|
|
152
190
|
const root = args.scope === "user"
|
|
153
191
|
? process.env.AGENT_OPS_HOME ?? homedir()
|
|
@@ -316,6 +354,19 @@ else {
|
|
|
316
354
|
});
|
|
317
355
|
}
|
|
318
356
|
const taskService = new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root));
|
|
357
|
+
if (args.command === "allow-stop") {
|
|
358
|
+
const config = (await loadEffectiveConfig(root, "project")).config;
|
|
359
|
+
return await runAllowStopCommand({
|
|
360
|
+
args,
|
|
361
|
+
gate: new CompletionGateService({
|
|
362
|
+
root,
|
|
363
|
+
config,
|
|
364
|
+
gitRunner: gitRunner(root),
|
|
365
|
+
taskService,
|
|
366
|
+
evidenceStore: new FileEvidenceStore(root, root)
|
|
367
|
+
})
|
|
368
|
+
});
|
|
369
|
+
}
|
|
319
370
|
if (args.command === "task") {
|
|
320
371
|
const sessionId = process.env.AGENT_OPS_SESSION_ID;
|
|
321
372
|
const policyConfigHash = args.action === "create"
|
|
@@ -27,6 +27,8 @@ Commands:
|
|
|
27
27
|
Manage independent task acceptance state
|
|
28
28
|
verify Run configured verification
|
|
29
29
|
review Run an independent review
|
|
30
|
+
allow-stop Grant one fingerprint-bound agy Stop permit (requires --session)
|
|
31
|
+
agy-run Run headless agy with a process-exit completion recheck
|
|
30
32
|
|
|
31
33
|
Options:
|
|
32
34
|
--scope <project|user>
|
|
@@ -35,6 +37,7 @@ Options:
|
|
|
35
37
|
--profile <core|advisory|guardrails|loop> Repeatable
|
|
36
38
|
--review-target <codex|agy|claude> Repeatable init option; external review
|
|
37
39
|
targets in fallback-chain order
|
|
40
|
+
--completion-gate Init only: enable the agy project-loop gate
|
|
38
41
|
--check-auth Doctor only: probe each review target's
|
|
39
42
|
authentication with one real call
|
|
40
43
|
--task <id>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
|
|
2
|
+
import { okEnvelope } from "../output.js";
|
|
3
|
+
export async function runAllowStopCommand(options) {
|
|
4
|
+
if (options.args.command !== "allow-stop" || options.args.sessionId === undefined) {
|
|
5
|
+
throw new AgentOpsError("COMPLETION_GATE_SESSION_REQUIRED", "allow-stop requires --session.");
|
|
6
|
+
}
|
|
7
|
+
await options.gate.grantPermit(options.args.sessionId);
|
|
8
|
+
return okEnvelope("COMPLETION_GATE_PERMIT_GRANTED", {
|
|
9
|
+
message: "One Stop is permitted for this conversation and current source fingerprint."
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -49,11 +49,22 @@ export async function runHookCommand(options) {
|
|
|
49
49
|
capabilities,
|
|
50
50
|
trusted: options.trusted,
|
|
51
51
|
advisory: options.advisory ?? runLifecycleAdvisory,
|
|
52
|
-
...(stopVerification === undefined ? {} : { stopVerification })
|
|
52
|
+
...(stopVerification === undefined ? {} : { stopVerification }),
|
|
53
|
+
...(options.completionGate === undefined
|
|
54
|
+
? {}
|
|
55
|
+
: { completionGate: options.completionGate })
|
|
53
56
|
});
|
|
54
57
|
return descriptor.runtime.formatOutput(options.event, result);
|
|
55
58
|
}
|
|
56
59
|
catch {
|
|
60
|
+
if (options.harness === "agy" && options.completionGate !== undefined) {
|
|
61
|
+
return harnessDescriptor("agy").runtime.formatOutput(options.event, {
|
|
62
|
+
action: "block",
|
|
63
|
+
status: "UNKNOWN",
|
|
64
|
+
code: "COMPLETION_GATE_UNAVAILABLE",
|
|
65
|
+
remedy: "Run agent-ops doctor; a user-approved one-time permit may be used after diagnosing the failure."
|
|
66
|
+
});
|
|
67
|
+
}
|
|
57
68
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
58
69
|
}
|
|
59
70
|
}
|
|
@@ -102,7 +102,10 @@ export async function runInitCommand(options) {
|
|
|
102
102
|
: { hookTargets: options.hookTargets ?? args.hookTargets }),
|
|
103
103
|
...(args.reviewTargets === undefined
|
|
104
104
|
? {}
|
|
105
|
-
: { reviewTargets: args.reviewTargets })
|
|
105
|
+
: { reviewTargets: args.reviewTargets }),
|
|
106
|
+
...(args.completionGate === undefined
|
|
107
|
+
? {}
|
|
108
|
+
: { completionGateEnabled: args.completionGate })
|
|
106
109
|
});
|
|
107
110
|
const trust = await trustChange(options, plan);
|
|
108
111
|
const warnings = plan.harness.includes("agy") && options.agyWarning !== undefined
|
|
@@ -95,9 +95,12 @@ export async function runTaskCommand(options) {
|
|
|
95
95
|
: { policyConfigHash: options.policyConfigHash }),
|
|
96
96
|
...(options.args.parentTaskId === undefined
|
|
97
97
|
? {}
|
|
98
|
-
: { parentTaskId: options.args.parentTaskId })
|
|
98
|
+
: { parentTaskId: options.args.parentTaskId }),
|
|
99
|
+
...(sessionId === undefined ? {} : { sessionId })
|
|
99
100
|
});
|
|
100
|
-
return taskEnvelope(action, "TASK_CREATED",
|
|
101
|
+
return taskEnvelope(action, "TASK_CREATED", sessionId === undefined
|
|
102
|
+
? `Created task ${record.task.id}.`
|
|
103
|
+
: `Created and attached task ${record.task.id}.`, record);
|
|
101
104
|
}
|
|
102
105
|
if (action === "status") {
|
|
103
106
|
if (options.args.taskId === undefined && sessionId === undefined) {
|
|
@@ -9,12 +9,15 @@ import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
|
|
|
9
9
|
import { localStatePaths } from "../../../runtime/src/security/permissions.js";
|
|
10
10
|
import { calculateTrustBinding, FileTrustStore } from "../../../runtime/src/security/trust.js";
|
|
11
11
|
export const DEFAULT_CONFIG = {
|
|
12
|
-
schemaVersion:
|
|
12
|
+
schemaVersion: 3,
|
|
13
13
|
profiles: [],
|
|
14
14
|
verification: { commands: [] },
|
|
15
15
|
features: {
|
|
16
16
|
stopVerification: {
|
|
17
17
|
enabled: false
|
|
18
|
+
},
|
|
19
|
+
completionGate: {
|
|
20
|
+
enabled: false
|
|
18
21
|
}
|
|
19
22
|
},
|
|
20
23
|
pathMappings: [],
|
|
@@ -9,6 +9,10 @@ import { resolveContainedPath } from "../../../runtime/src/fs/paths.js";
|
|
|
9
9
|
import { redactSecrets } from "../../../runtime/src/security/redact.js";
|
|
10
10
|
import { claudeStopRecursionMarker } from "../../../runtime/src/adapters/claude/input.js";
|
|
11
11
|
import { HARNESS_IDS, harnessDescriptor } from "../../../runtime/src/install/harness.js";
|
|
12
|
+
import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
|
|
13
|
+
import { TaskService } from "../../../runtime/src/task/service.js";
|
|
14
|
+
import { FileTaskStore } from "../../../runtime/src/task/store.js";
|
|
15
|
+
import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
|
|
12
16
|
import { STOP_VERIFICATION_ENV, StopVerificationService } from "../../../runtime/src/hooks/stop-service.js";
|
|
13
17
|
import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
|
|
14
18
|
import { normalizeHookInput, runHookCommand, HOOK_EVENTS } from "./commands/hook.js";
|
|
@@ -40,6 +44,16 @@ function parseInput(source) {
|
|
|
40
44
|
return null;
|
|
41
45
|
}
|
|
42
46
|
}
|
|
47
|
+
function withInjectedSession(harness, input) {
|
|
48
|
+
const sessionId = process.env.AGENT_OPS_SESSION_ID;
|
|
49
|
+
return harness === "agy" &&
|
|
50
|
+
sessionId !== undefined &&
|
|
51
|
+
typeof input === "object" &&
|
|
52
|
+
input !== null &&
|
|
53
|
+
!Array.isArray(input)
|
|
54
|
+
? { ...input, conversationId: sessionId }
|
|
55
|
+
: input;
|
|
56
|
+
}
|
|
43
57
|
function writeHookOutput(io, output) {
|
|
44
58
|
if (output.stdout.length > 0) {
|
|
45
59
|
io.writeStdout(output.stdout);
|
|
@@ -195,11 +209,14 @@ function shouldBuildStopVerification(harness, event, config, rawInput) {
|
|
|
195
209
|
return harnessDescriptor(harness).control.registrations.some(({ capability, support }) => capability === "optional-stop-verify" && support !== "unsupported");
|
|
196
210
|
}
|
|
197
211
|
/**
|
|
198
|
-
* Runs one hook invocation.
|
|
199
|
-
*
|
|
212
|
+
* Runs one hook invocation. Exit code stays zero because native JSON carries
|
|
213
|
+
* decisions; only an explicitly installed agy completion gate fails closed.
|
|
200
214
|
*/
|
|
201
215
|
export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
202
216
|
const [harness, event] = argv;
|
|
217
|
+
const completionGateInstalled = harness === "agy" &&
|
|
218
|
+
event === "Stop" &&
|
|
219
|
+
argv.includes("--completion-gate");
|
|
203
220
|
if (harness === undefined ||
|
|
204
221
|
!HARNESSES.has(harness) ||
|
|
205
222
|
event === undefined ||
|
|
@@ -212,13 +229,31 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
212
229
|
const harnessId = harness;
|
|
213
230
|
const hookEvent = event;
|
|
214
231
|
if (process.env.AGENT_OPS_DISABLE === "1") {
|
|
232
|
+
if (completionGateInstalled) {
|
|
233
|
+
writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
|
|
234
|
+
action: "block",
|
|
235
|
+
status: "UNKNOWN",
|
|
236
|
+
code: "COMPLETION_GATE_DISABLE_REJECTED",
|
|
237
|
+
remedy: "Use a user-approved one-time permit instead of disabling agent-ops."
|
|
238
|
+
}));
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
215
241
|
writeHookOutput(io, failOpenOutput(harnessId, hookEvent));
|
|
216
242
|
return 0;
|
|
217
243
|
}
|
|
218
244
|
const rawInput = await readStdin(io.stdin);
|
|
219
|
-
const parsedInput = parseInput(rawInput);
|
|
245
|
+
const parsedInput = withInjectedSession(harnessId, parseInput(rawInput));
|
|
220
246
|
const configOutcome = await hookConfigOutcome(root, dependencies.loadConfig);
|
|
221
247
|
if (configOutcome.kind === "invalid") {
|
|
248
|
+
if (completionGateInstalled) {
|
|
249
|
+
writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
|
|
250
|
+
action: "block",
|
|
251
|
+
status: "UNKNOWN",
|
|
252
|
+
code: "COMPLETION_GATE_CONFIG_INVALID",
|
|
253
|
+
remedy: `Fix ${redactSecrets(configOutcome.path)} and run agent-ops doctor.`
|
|
254
|
+
}));
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
222
257
|
const output = await invalidConfigOutput({
|
|
223
258
|
root,
|
|
224
259
|
harness: harnessId,
|
|
@@ -249,6 +284,17 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
249
284
|
processRunner
|
|
250
285
|
})
|
|
251
286
|
: undefined;
|
|
287
|
+
const completionGate = harnessId === "agy" && config.features.completionGate.enabled
|
|
288
|
+
? dependencies.completionGate ?? {
|
|
289
|
+
handle: async (normalized) => await new CompletionGateService({
|
|
290
|
+
root,
|
|
291
|
+
config,
|
|
292
|
+
gitRunner,
|
|
293
|
+
taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
|
|
294
|
+
evidenceStore: new FileEvidenceStore(root, root)
|
|
295
|
+
}).handle(normalized)
|
|
296
|
+
}
|
|
297
|
+
: undefined;
|
|
252
298
|
const output = await runHookCommand({
|
|
253
299
|
harness: harness,
|
|
254
300
|
event: hookEvent,
|
|
@@ -258,12 +304,20 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
258
304
|
...(dependencies.advisory === undefined
|
|
259
305
|
? {}
|
|
260
306
|
: { advisory: dependencies.advisory }),
|
|
261
|
-
...(stopVerification === undefined ? {} : { stopVerification })
|
|
307
|
+
...(stopVerification === undefined ? {} : { stopVerification }),
|
|
308
|
+
...(completionGate === undefined ? {} : { completionGate })
|
|
262
309
|
});
|
|
263
310
|
writeHookOutput(io, output);
|
|
264
311
|
}
|
|
265
312
|
catch {
|
|
266
|
-
|
|
313
|
+
if (completionGateInstalled) {
|
|
314
|
+
writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
|
|
315
|
+
action: "block",
|
|
316
|
+
status: "UNKNOWN",
|
|
317
|
+
code: "COMPLETION_GATE_UNAVAILABLE",
|
|
318
|
+
remedy: "Run agent-ops doctor; use a user-approved one-time permit only after diagnosis."
|
|
319
|
+
}));
|
|
320
|
+
}
|
|
267
321
|
}
|
|
268
322
|
return 0;
|
|
269
323
|
}
|
|
@@ -34,6 +34,11 @@ function selectReviewTargets(raw, defaults = []) {
|
|
|
34
34
|
function affirmative(raw) {
|
|
35
35
|
return /^(y|yes)$/i.test(raw.trim());
|
|
36
36
|
}
|
|
37
|
+
function completionGateEligible(scope, harness, profiles) {
|
|
38
|
+
return scope === "project" &&
|
|
39
|
+
harness.includes("agy") &&
|
|
40
|
+
profiles.includes("loop");
|
|
41
|
+
}
|
|
37
42
|
async function probeReviewTargets(targets, setup) {
|
|
38
43
|
const probe = setup.probeReviewTarget;
|
|
39
44
|
if (probe === undefined) {
|
|
@@ -161,6 +166,20 @@ export async function completeInitChoices(args, io, setup = {}) {
|
|
|
161
166
|
selectAllLabel: "Select all",
|
|
162
167
|
selectAllDescription: "Enable core, advisory, guardrails, and loop together."
|
|
163
168
|
});
|
|
169
|
+
const completionGate = args.completionGate ?? (completionGateEligible(scope, harness, profiles)
|
|
170
|
+
? await selectOption("Enable the agy completion gate?", [
|
|
171
|
+
{
|
|
172
|
+
label: "yes",
|
|
173
|
+
value: true,
|
|
174
|
+
description: "Recommended. Blocks changed sessions until task evidence and review pass."
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
label: "no",
|
|
178
|
+
value: false,
|
|
179
|
+
description: "Keep advisory loop behavior."
|
|
180
|
+
}
|
|
181
|
+
], selectorIo)
|
|
182
|
+
: false);
|
|
164
183
|
const enabled = args.reviewTargets !== undefined ||
|
|
165
184
|
(await selectOption("External review: call another agent CLI to review your work?", [
|
|
166
185
|
{ label: "no", value: false, description: "Default. Nothing is spawned." },
|
|
@@ -180,6 +199,7 @@ export async function completeInitChoices(args, io, setup = {}) {
|
|
|
180
199
|
scope,
|
|
181
200
|
harness,
|
|
182
201
|
profiles,
|
|
202
|
+
...(completionGate ? { completionGate: true } : {}),
|
|
183
203
|
...(reviewTargets.length === 0 ? {} : { reviewTargets })
|
|
184
204
|
};
|
|
185
205
|
}
|
|
@@ -192,6 +212,9 @@ export async function completeInitChoices(args, io, setup = {}) {
|
|
|
192
212
|
const profiles = args.profiles.length > 0
|
|
193
213
|
? args.profiles
|
|
194
214
|
: selectProfiles(await session.question("Profiles (core,advisory,guardrails,loop) [core]: "));
|
|
215
|
+
const completionGate = args.completionGate ?? (completionGateEligible(scope, harness, profiles)
|
|
216
|
+
? !/^(n|no)$/i.test((await session.question("Enable the recommended agy completion gate? [Y/n]: ")).trim())
|
|
217
|
+
: false);
|
|
195
218
|
const defaultReviewTargets = REVIEW_TARGET_ORDER.filter((target) => harness.includes(target));
|
|
196
219
|
const reviewTargets = args.reviewTargets ?? (affirmative(await session.question("Enable external review by another agent CLI? [y/N]: "))
|
|
197
220
|
? selectReviewTargets(await session.question(`Review targets (${defaultReviewTargets.join(",") || "none"}): `), defaultReviewTargets)
|
|
@@ -202,6 +225,7 @@ export async function completeInitChoices(args, io, setup = {}) {
|
|
|
202
225
|
scope,
|
|
203
226
|
harness,
|
|
204
227
|
profiles,
|
|
228
|
+
...(completionGate ? { completionGate: true } : {}),
|
|
205
229
|
...(reviewTargets.length === 0 ? {} : { reviewTargets })
|
|
206
230
|
};
|
|
207
231
|
}
|
|
@@ -5,10 +5,11 @@ function record(value) {
|
|
|
5
5
|
? value
|
|
6
6
|
: null;
|
|
7
7
|
}
|
|
8
|
-
function handler(runtimePath, event, platform) {
|
|
8
|
+
function handler(runtimePath, event, platform, completionGate = false) {
|
|
9
|
+
const gateFlag = completionGate ? " --completion-gate" : "";
|
|
9
10
|
const command = platform === "win32"
|
|
10
|
-
? `cmd /c node ${JSON.stringify(runtimePath).replaceAll("\\\\", "\\")} agy ${event} ${MARKER}`
|
|
11
|
-
: `node ${JSON.stringify(runtimePath)} agy ${event} ${MARKER}`;
|
|
11
|
+
? `cmd /c node ${JSON.stringify(runtimePath).replaceAll("\\\\", "\\")} agy ${event}${gateFlag} ${MARKER}`
|
|
12
|
+
: `node ${JSON.stringify(runtimePath)} agy ${event}${gateFlag} ${MARKER}`;
|
|
12
13
|
return {
|
|
13
14
|
type: "command",
|
|
14
15
|
command,
|
|
@@ -27,7 +28,9 @@ function managedHandler(value, event) {
|
|
|
27
28
|
typeof candidate.command === "string" &&
|
|
28
29
|
(candidate.command.startsWith("node \"") ||
|
|
29
30
|
candidate.command.startsWith("cmd /c node \"")) &&
|
|
30
|
-
candidate.command.endsWith(` agy ${event} ${MARKER}`)
|
|
31
|
+
(candidate.command.endsWith(` agy ${event} ${MARKER}`) ||
|
|
32
|
+
(event === "Stop" &&
|
|
33
|
+
candidate.command.endsWith(` agy Stop --completion-gate ${MARKER}`)));
|
|
31
34
|
}
|
|
32
35
|
function managedEvent(value, event) {
|
|
33
36
|
if (!Array.isArray(value) || value.length !== 1)
|
|
@@ -57,8 +60,9 @@ export function buildAgyHookSettings(capabilities, runtimePath, platform = proce
|
|
|
57
60
|
if (capabilities.includes("command-policy") || capabilities.includes("project-loop")) {
|
|
58
61
|
hooks.PreToolUse = [{ matcher: "run_command", hooks: [handler(runtimePath, "PreToolUse", platform)] }];
|
|
59
62
|
}
|
|
60
|
-
if (capabilities.includes("optional-stop-verify")
|
|
61
|
-
|
|
63
|
+
if (capabilities.includes("optional-stop-verify") ||
|
|
64
|
+
capabilities.includes("completion-gate")) {
|
|
65
|
+
hooks.Stop = [handler(runtimePath, "Stop", platform, capabilities.includes("completion-gate"))];
|
|
62
66
|
}
|
|
63
67
|
return { hooks };
|
|
64
68
|
}
|
|
@@ -79,8 +83,14 @@ export function isAgyHookRegistered(value, capabilities) {
|
|
|
79
83
|
const named = record(record(value)?.["agent-ops"]);
|
|
80
84
|
if (named === null || !isAgyManagedHook(value))
|
|
81
85
|
return false;
|
|
82
|
-
|
|
86
|
+
const eventsMatch = Object.keys(expected)
|
|
83
87
|
.every((event) => managedEvent(named[event], event));
|
|
88
|
+
if (!eventsMatch || !capabilities.includes("completion-gate")) {
|
|
89
|
+
return eventsMatch;
|
|
90
|
+
}
|
|
91
|
+
const stop = Array.isArray(named.Stop) ? record(named.Stop[0]) : null;
|
|
92
|
+
return typeof stop?.command === "string" &&
|
|
93
|
+
stop.command.endsWith(` agy Stop --completion-gate ${MARKER}`);
|
|
84
94
|
}
|
|
85
95
|
export function mergeAgyHooks(existing, managed) {
|
|
86
96
|
const source = record(existing);
|
|
@@ -16,6 +16,14 @@ export const AGY_CAPABILITY_REGISTRATIONS = [
|
|
|
16
16
|
support: "supported",
|
|
17
17
|
runtimeFailure: "fail-closed"
|
|
18
18
|
},
|
|
19
|
+
{
|
|
20
|
+
capability: "completion-gate",
|
|
21
|
+
normalizedEvent: "stop",
|
|
22
|
+
nativeEvent: "Stop",
|
|
23
|
+
surfaceId: "agy-hooks",
|
|
24
|
+
support: "supported",
|
|
25
|
+
runtimeFailure: "fail-closed"
|
|
26
|
+
},
|
|
19
27
|
{
|
|
20
28
|
capability: "optional-stop-verify",
|
|
21
29
|
normalizedEvent: "stop",
|
|
@@ -15,16 +15,34 @@ export function normalizeAgyHookInput(input) {
|
|
|
15
15
|
const projectRoot = typeof workspacePaths[0] === "string"
|
|
16
16
|
? workspacePaths[0]
|
|
17
17
|
: undefined;
|
|
18
|
+
const sessionId = typeof value.conversationId === "string"
|
|
19
|
+
? value.conversationId
|
|
20
|
+
: undefined;
|
|
18
21
|
const toolCall = record(value.toolCall);
|
|
19
22
|
const args = record(toolCall?.args);
|
|
20
23
|
if (toolCall?.name === "run_command" && typeof args?.CommandLine === "string") {
|
|
21
|
-
return
|
|
24
|
+
return {
|
|
25
|
+
...normalizeShellHookEvent(args.CommandLine, projectRoot),
|
|
26
|
+
...(sessionId === undefined ? {} : { sessionId })
|
|
27
|
+
};
|
|
22
28
|
}
|
|
23
29
|
if (typeof value.terminationReason === "string") {
|
|
24
|
-
return
|
|
30
|
+
return {
|
|
31
|
+
event: "stop",
|
|
32
|
+
projectRoot: projectRoot ?? process.cwd(),
|
|
33
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
34
|
+
terminationReason: value.terminationReason,
|
|
35
|
+
...(typeof value.fullyIdle === "boolean"
|
|
36
|
+
? { fullyIdle: value.fullyIdle }
|
|
37
|
+
: {})
|
|
38
|
+
};
|
|
25
39
|
}
|
|
26
40
|
if (typeof value.invocationNum === "number") {
|
|
27
|
-
return
|
|
41
|
+
return {
|
|
42
|
+
event: "session-start",
|
|
43
|
+
projectRoot: projectRoot ?? process.cwd(),
|
|
44
|
+
...(sessionId === undefined ? {} : { sessionId })
|
|
45
|
+
};
|
|
28
46
|
}
|
|
29
47
|
return normalizeHookEvent({ event: "unsupported", projectRoot });
|
|
30
48
|
}
|