@kylecheng3146/agent-ops 0.1.20 → 0.1.22
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 +35 -5
- package/dist/packages/cli/src/bin.js +115 -37
- package/dist/packages/cli/src/cli.js +4 -1
- 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/review.js +31 -21
- 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/review/execute.js +40 -7
- package/dist/runtime/src/review/render.js +3 -1
- package/dist/runtime/src/review/runner.js +1 -0
- package/dist/runtime/src/schema/validate.js +11 -1
- package/dist/runtime/src/task/service.js +17 -0
- package/dist/runtime/src/verify/spawn.js +30 -8
- package/docs/en/guides/configuration.md +37 -7
- package/docs/en/spec/README.md +3 -3
- package/docs/en/spec/harness-adapters.md +7 -3
- package/docs/en/spec/review.md +11 -0
- package/docs/zh-TW/guides/configuration.md +32 -6
- package/docs/zh-TW/spec/README.md +4 -4
- package/docs/zh-TW/spec/harness-adapters.md +6 -3
- package/docs/zh-TW/spec/review.md +10 -0
- package/package.json +1 -1
- package/schemas/config.schema.json +12 -2
|
@@ -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
|
}
|
|
@@ -12,13 +12,17 @@ export function agyHookOutput(event, result) {
|
|
|
12
12
|
? `agent-ops: ${result.code}`
|
|
13
13
|
: `agent-ops: ${result.code}: ${result.remedy}`;
|
|
14
14
|
const value = event === "PreToolUse"
|
|
15
|
-
? result.
|
|
16
|
-
? { decision: "
|
|
17
|
-
:
|
|
15
|
+
? result.code === "COMPLETION_GATE_PERMIT_CONFIRMATION"
|
|
16
|
+
? { decision: "force_ask", reason }
|
|
17
|
+
: result.action === "block"
|
|
18
|
+
? { decision: "deny", reason }
|
|
19
|
+
: { decision: "allow" }
|
|
18
20
|
: event === "SessionStart"
|
|
19
21
|
? { injectSteps: [{ ephemeralMessage: reason }] }
|
|
20
22
|
: event === "Stop"
|
|
21
|
-
?
|
|
23
|
+
? result.action === "block" && result.code.startsWith("COMPLETION_GATE_")
|
|
24
|
+
? { decision: "continue", reason }
|
|
25
|
+
: { decision: "allow", reason }
|
|
22
26
|
: {};
|
|
23
27
|
return { exitCode: 0, stdout: JSON.stringify(value), stderr: "" };
|
|
24
28
|
}
|
|
@@ -2,6 +2,11 @@ export function explainConfig(merged) {
|
|
|
2
2
|
return {
|
|
3
3
|
schemaVersion: merged.config.schemaVersion,
|
|
4
4
|
features: {
|
|
5
|
+
completionGate: {
|
|
6
|
+
enabled: merged.config.features.completionGate.enabled,
|
|
7
|
+
source: merged.provenance.features.source,
|
|
8
|
+
sourcePath: merged.provenance.features.sourcePath
|
|
9
|
+
},
|
|
5
10
|
stopVerification: {
|
|
6
11
|
enabled: merged.config.features.stopVerification.enabled,
|
|
7
12
|
source: merged.provenance.features.source,
|
|
@@ -25,7 +25,7 @@ const MIGRATIONS = new Map([
|
|
|
25
25
|
const { schemaVersion: _schemaVersion, ...rest } = input;
|
|
26
26
|
return {
|
|
27
27
|
...rest,
|
|
28
|
-
schemaVersion:
|
|
28
|
+
schemaVersion: 2,
|
|
29
29
|
features: {
|
|
30
30
|
stopVerification: {
|
|
31
31
|
enabled: false
|
|
@@ -33,6 +33,20 @@ const MIGRATIONS = new Map([
|
|
|
33
33
|
}
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
|
+
],
|
|
37
|
+
[
|
|
38
|
+
2,
|
|
39
|
+
(input) => {
|
|
40
|
+
const { schemaVersion: _schemaVersion, features, ...rest } = input;
|
|
41
|
+
return {
|
|
42
|
+
...rest,
|
|
43
|
+
schemaVersion: CONFIG_SCHEMA_VERSION,
|
|
44
|
+
features: {
|
|
45
|
+
...(isRecord(features) ? features : {}),
|
|
46
|
+
completionGate: { enabled: false }
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
36
50
|
]
|
|
37
51
|
]);
|
|
38
52
|
function schemaVersionOf(value) {
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { calculateConfigHash } from "../config/hash.js";
|
|
3
|
+
import { sha256 } from "../fs/hash.js";
|
|
4
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
5
|
+
import { findReviewAttestation } from "../review/attestation.js";
|
|
6
|
+
import { validateEvidence, validateTaskAgainstConfig } from "../schema/validate.js";
|
|
7
|
+
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
8
|
+
import { isPassingVerificationEvidence } from "../verify/evidence.js";
|
|
9
|
+
import { collectChangeSurface } from "../verify/change-surface.js";
|
|
10
|
+
import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
|
|
11
|
+
const FINGERPRINT = /^[a-f0-9]{64}$/u;
|
|
12
|
+
const SESSION = /^[^\0\r\n]{1,256}$/u;
|
|
13
|
+
function gateResult(action, status, code, remedy) {
|
|
14
|
+
return { action, status, code, ...(remedy === undefined ? {} : { remedy }) };
|
|
15
|
+
}
|
|
16
|
+
function statePath(root, sessionId) {
|
|
17
|
+
return join(root, ".agent-ops", "tasks", "completion-gate", `${sha256(sessionId)}.json`);
|
|
18
|
+
}
|
|
19
|
+
function parseState(source, sessionId) {
|
|
20
|
+
if (source === null)
|
|
21
|
+
return null;
|
|
22
|
+
let value;
|
|
23
|
+
try {
|
|
24
|
+
value = JSON.parse(source);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new AgentOpsError("COMPLETION_GATE_STATE_INVALID", "Completion-gate state is not valid JSON.");
|
|
28
|
+
}
|
|
29
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
30
|
+
throw new AgentOpsError("COMPLETION_GATE_STATE_INVALID", "Completion-gate state is invalid.");
|
|
31
|
+
}
|
|
32
|
+
const record = value;
|
|
33
|
+
if (Object.keys(record).sort().join(",") !==
|
|
34
|
+
"baselineFingerprint,permitFingerprint,schemaVersion,sessionId" ||
|
|
35
|
+
record.schemaVersion !== 1 ||
|
|
36
|
+
record.sessionId !== sessionId ||
|
|
37
|
+
typeof record.baselineFingerprint !== "string" ||
|
|
38
|
+
!FINGERPRINT.test(record.baselineFingerprint) ||
|
|
39
|
+
(record.permitFingerprint !== null &&
|
|
40
|
+
(typeof record.permitFingerprint !== "string" ||
|
|
41
|
+
!FINGERPRINT.test(record.permitFingerprint)))) {
|
|
42
|
+
throw new AgentOpsError("COMPLETION_GATE_STATE_INVALID", "Completion-gate state is invalid.");
|
|
43
|
+
}
|
|
44
|
+
return record;
|
|
45
|
+
}
|
|
46
|
+
export class FileCompletionGateStore {
|
|
47
|
+
#root;
|
|
48
|
+
constructor(root) {
|
|
49
|
+
this.#root = root;
|
|
50
|
+
}
|
|
51
|
+
async read(sessionId) {
|
|
52
|
+
if (!SESSION.test(sessionId)) {
|
|
53
|
+
throw new AgentOpsError("COMPLETION_GATE_SESSION_INVALID", "Completion gate requires a valid session identity.");
|
|
54
|
+
}
|
|
55
|
+
const path = statePath(this.#root, sessionId);
|
|
56
|
+
return await withPrivateFileLock(path, this.#root, async () => parseState(await readPrivateFile(path, this.#root), sessionId));
|
|
57
|
+
}
|
|
58
|
+
async mutate(sessionId, action) {
|
|
59
|
+
if (!SESSION.test(sessionId)) {
|
|
60
|
+
throw new AgentOpsError("COMPLETION_GATE_SESSION_INVALID", "Completion gate requires a valid session identity.");
|
|
61
|
+
}
|
|
62
|
+
const path = statePath(this.#root, sessionId);
|
|
63
|
+
return await withPrivateFileLock(path, this.#root, async () => {
|
|
64
|
+
const next = action(parseState(await readPrivateFile(path, this.#root), sessionId));
|
|
65
|
+
parseState(JSON.stringify(next), sessionId);
|
|
66
|
+
await writePrivateFile(path, `${JSON.stringify(next, null, 2)}\n`, this.#root);
|
|
67
|
+
return next;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export class CompletionGateService {
|
|
72
|
+
#options;
|
|
73
|
+
#store;
|
|
74
|
+
constructor(options) {
|
|
75
|
+
this.#options = options;
|
|
76
|
+
this.#store = options.stateStore ?? new FileCompletionGateStore(options.root);
|
|
77
|
+
}
|
|
78
|
+
async #fingerprint() {
|
|
79
|
+
const surface = await collectChangeSurface(this.#options.gitRunner);
|
|
80
|
+
return await calculateSourceFingerprint(this.#options.root, { mode: "worktree", changedFiles: surface.paths }, this.#options.gitRunner);
|
|
81
|
+
}
|
|
82
|
+
async initialize(sessionId) {
|
|
83
|
+
const fingerprint = await this.#fingerprint();
|
|
84
|
+
const state = await this.#store.mutate(sessionId, (current) => current ?? {
|
|
85
|
+
schemaVersion: 1,
|
|
86
|
+
sessionId,
|
|
87
|
+
baselineFingerprint: fingerprint,
|
|
88
|
+
permitFingerprint: null
|
|
89
|
+
});
|
|
90
|
+
const changed = state.baselineFingerprint !== fingerprint;
|
|
91
|
+
return gateResult("continue", changed ? "UNKNOWN" : "PASS", changed ? "COMPLETION_GATE_CHANGED" : "COMPLETION_GATE_READY", changed
|
|
92
|
+
? `Git-visible changes require an attached completed task, current PASS evidence, and a PASS review. Session: ${sessionId}. Create with --session ${sessionId}; one-time permit: agent-ops allow-stop --session ${sessionId}.`
|
|
93
|
+
: undefined);
|
|
94
|
+
}
|
|
95
|
+
async grantPermit(sessionId) {
|
|
96
|
+
const fingerprint = await this.#fingerprint();
|
|
97
|
+
await this.#store.mutate(sessionId, (state) => {
|
|
98
|
+
if (state === null) {
|
|
99
|
+
throw new AgentOpsError("COMPLETION_GATE_NOT_INITIALIZED", "The session has no completion-gate baseline.");
|
|
100
|
+
}
|
|
101
|
+
return { ...state, permitFingerprint: fingerprint };
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
#isPermitCommand(event, sessionId) {
|
|
105
|
+
if (event.event !== "command")
|
|
106
|
+
return false;
|
|
107
|
+
const tokens = [event.command, ...event.args];
|
|
108
|
+
const commandIndex = tokens.indexOf("allow-stop");
|
|
109
|
+
return commandIndex >= 0 &&
|
|
110
|
+
tokens[commandIndex + 1] === "--session" &&
|
|
111
|
+
(tokens[commandIndex + 2] === sessionId ||
|
|
112
|
+
tokens[commandIndex + 2] === "$AGENT_OPS_SESSION_ID");
|
|
113
|
+
}
|
|
114
|
+
async #hasCurrentEvidence(taskId, criterionId, command, references, configHash, sourceFingerprint) {
|
|
115
|
+
for (const reference of references) {
|
|
116
|
+
if (reference.startsWith("review:"))
|
|
117
|
+
continue;
|
|
118
|
+
const validation = validateEvidence(await this.#options.evidenceStore.load(reference));
|
|
119
|
+
if (!validation.ok)
|
|
120
|
+
continue;
|
|
121
|
+
const evidence = validation.value;
|
|
122
|
+
if (evidence.taskId === taskId &&
|
|
123
|
+
evidence.criterionId === criterionId &&
|
|
124
|
+
evidence.commandId === command.id &&
|
|
125
|
+
evidence.configHash === configHash &&
|
|
126
|
+
evidence.sourceFingerprint === sourceFingerprint &&
|
|
127
|
+
isPassingVerificationEvidence(command, evidence)) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
async #validateTask(sessionId, sourceFingerprint) {
|
|
134
|
+
let stored;
|
|
135
|
+
try {
|
|
136
|
+
stored = await this.#options.taskService.status({ sessionId });
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
return error instanceof AgentOpsError && error.code === "TASK_SESSION_UNATTACHED"
|
|
140
|
+
? gateResult("block", "FAIL", "COMPLETION_GATE_TASK_REQUIRED", "Attach this conversation to a formal task.")
|
|
141
|
+
: gateResult("block", "UNKNOWN", "COMPLETION_GATE_TASK_UNAVAILABLE", "Repair task state with agent-ops doctor before stopping.");
|
|
142
|
+
}
|
|
143
|
+
if (stored.status !== "complete") {
|
|
144
|
+
return gateResult("block", "FAIL", "COMPLETION_GATE_TASK_INCOMPLETE", "Complete the attached task after verification and review.");
|
|
145
|
+
}
|
|
146
|
+
const taskValidation = validateTaskAgainstConfig(stored.task, this.#options.config);
|
|
147
|
+
const configHash = calculateConfigHash(this.#options.config);
|
|
148
|
+
if (!taskValidation.ok || stored.policyConfigHash !== configHash) {
|
|
149
|
+
return gateResult("block", "FAIL", "COMPLETION_GATE_TASK_STALE", "Recreate or re-verify the task against the current config.");
|
|
150
|
+
}
|
|
151
|
+
for (const criterion of stored.task.criteria) {
|
|
152
|
+
for (const commandId of criterion.verifierIds) {
|
|
153
|
+
const command = this.#options.config.verification.commands.find(({ id }) => id === commandId);
|
|
154
|
+
if (command === undefined) {
|
|
155
|
+
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_EVIDENCE_UNAVAILABLE", "Configured task evidence cannot be resolved.");
|
|
156
|
+
}
|
|
157
|
+
if (command.required &&
|
|
158
|
+
!(await this.#hasCurrentEvidence(stored.task.id, criterion.id, command, stored.evidence[criterion.id] ?? [], configHash, sourceFingerprint))) {
|
|
159
|
+
return gateResult("block", "FAIL", "COMPLETION_GATE_EVIDENCE_REQUIRED", "Run agent-ops verify and complete the task with current PASS evidence.");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const attestation = await findReviewAttestation(this.#options.root, sourceFingerprint);
|
|
164
|
+
if (attestation === null || attestation.taskId !== stored.task.id) {
|
|
165
|
+
return gateResult("block", "FAIL", "COMPLETION_GATE_REVIEW_REQUIRED", "Run agent-ops review --yes for the attached task and current source.");
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
async handle(event) {
|
|
170
|
+
const sessionId = event.sessionId;
|
|
171
|
+
if (sessionId === undefined) {
|
|
172
|
+
return event.event === "stop"
|
|
173
|
+
? gateResult("block", "UNKNOWN", "COMPLETION_GATE_SESSION_REQUIRED", "Agy did not provide conversationId; run doctor and use a one-time permit only after restoring hook input.")
|
|
174
|
+
: null;
|
|
175
|
+
}
|
|
176
|
+
if (event.event === "session-start") {
|
|
177
|
+
return await this.initialize(sessionId);
|
|
178
|
+
}
|
|
179
|
+
if (this.#isPermitCommand(event, sessionId)) {
|
|
180
|
+
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_PERMIT_CONFIRMATION", "Allow this command only to grant one Stop for the current source fingerprint.");
|
|
181
|
+
}
|
|
182
|
+
if (event.event !== "stop")
|
|
183
|
+
return null;
|
|
184
|
+
if (event.terminationReason !== "model_stop" || event.fullyIdle !== true) {
|
|
185
|
+
return gateResult("continue", "PASS", "COMPLETION_GATE_NON_FINAL_STOP");
|
|
186
|
+
}
|
|
187
|
+
const fingerprint = await this.#fingerprint();
|
|
188
|
+
const state = await this.#store.read(sessionId);
|
|
189
|
+
if (state === null) {
|
|
190
|
+
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_NOT_INITIALIZED", "The session baseline is unavailable; continue once so PreInvocation can initialize it.");
|
|
191
|
+
}
|
|
192
|
+
if (state.baselineFingerprint !== fingerprint && state.permitFingerprint !== fingerprint) {
|
|
193
|
+
const failure = await this.#validateTask(sessionId, fingerprint);
|
|
194
|
+
if (failure !== null)
|
|
195
|
+
return failure;
|
|
196
|
+
}
|
|
197
|
+
await this.#store.mutate(sessionId, (current) => {
|
|
198
|
+
if (current === null) {
|
|
199
|
+
throw new AgentOpsError("COMPLETION_GATE_NOT_INITIALIZED", "The session baseline disappeared.");
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
...current,
|
|
203
|
+
baselineFingerprint: fingerprint,
|
|
204
|
+
permitFingerprint: null
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
return gateResult("continue", "PASS", "COMPLETION_GATE_ALLOWED");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -35,6 +35,12 @@ function evaluateCommands(commands, scope) {
|
|
|
35
35
|
return warning ?? continueWith("PASS", "GUARDRAIL_ALLOWED");
|
|
36
36
|
}
|
|
37
37
|
export async function dispatchHookEvent(event, options) {
|
|
38
|
+
if (options.completionGate !== undefined) {
|
|
39
|
+
const result = await options.completionGate.handle(event);
|
|
40
|
+
if (result !== null) {
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
38
44
|
if (event.event === "unsupported") {
|
|
39
45
|
return continueWith("UNKNOWN", "HOOK_EVENT_UNSUPPORTED");
|
|
40
46
|
}
|
|
@@ -314,7 +314,9 @@ function checkProjectLoop(manifest, config) {
|
|
|
314
314
|
return undefined;
|
|
315
315
|
}
|
|
316
316
|
return manifest.harness.includes("agy")
|
|
317
|
-
? check("project-loop", "DEGRADED",
|
|
317
|
+
? check("project-loop", "DEGRADED", config.features.completionGate.enabled
|
|
318
|
+
? "agy completion gate uses PreInvocation, PreToolUse(run_command), and Stop; native Stop continuation is host-bounded. Use `agent-ops agy-run -- <args>` for a process-exit gate in headless or CI runs."
|
|
319
|
+
: "agy loop uses only PreInvocation and PreToolUse(run_command); prompt, permission, compact, and subagent events are unavailable.")
|
|
318
320
|
: check("project-loop", "PASS", "Project loop events are fully registered.");
|
|
319
321
|
}
|
|
320
322
|
async function checkSurfaceInventory(root, manifest, config) {
|
|
@@ -136,7 +136,7 @@ const DESCRIPTORS = {
|
|
|
136
136
|
hookRegistered: (source, capabilities) => isAgyHookRegistered(parseJsonSource(source), capabilities),
|
|
137
137
|
plan: async (context) => {
|
|
138
138
|
if (context.scope === "project") {
|
|
139
|
-
return await planCommonHarnessContribution("
|
|
139
|
+
return await planCommonHarnessContribution("agy", context);
|
|
140
140
|
}
|
|
141
141
|
const descriptor = DESCRIPTORS.agy;
|
|
142
142
|
return {
|
|
@@ -281,6 +281,7 @@ export function resolveHarnessSelection(value) {
|
|
|
281
281
|
}
|
|
282
282
|
export const COMMON_AGENTS_BLOCK = DESCRIPTORS.codex.control.routing.desired;
|
|
283
283
|
export const COMMON_CLAUDE_BLOCK = DESCRIPTORS.claude.control.routing.desired;
|
|
284
|
+
export const COMMON_GEMINI_BLOCK = DESCRIPTORS.agy.control.routing.desired;
|
|
284
285
|
export function managedRules(descriptor, context) {
|
|
285
286
|
const lines = [
|
|
286
287
|
"# Loop Engineering",
|
|
@@ -306,6 +307,9 @@ export function managedRules(descriptor, context) {
|
|
|
306
307
|
if (context.capabilities.includes("command-policy")) {
|
|
307
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.", "");
|
|
308
309
|
}
|
|
310
|
+
if (context.capabilities.includes("completion-gate")) {
|
|
311
|
+
lines.push("The agy completion gate applies only when this conversation creates a", "Git-visible net change after its first PreInvocation baseline. Read-only", "questions and analysis stop normally. A changed conversation must be", "attached to one task with two to five acceptance criteria; current PASS", "verification evidence, a PASS review attestation, and completed task state", "are all required before Stop. Error, max-step, and non-idle stops are not", "blocked. The gate inspects evidence but never runs tests or review itself.", "A user may approve `agent-ops allow-stop --session <conversationId>` for", "one Stop bound to the current source fingerprint; the PreToolUse hook must", "return `force_ask`, so the agent cannot self-authorize this escape hatch.", "For headless or CI enforcement, launch agy through", "`agent-ops agy-run -- <agy arguments>`.", "");
|
|
312
|
+
}
|
|
309
313
|
lines.push(`This file is routed from the active ${descriptor.control.instructionFile}.`, "");
|
|
310
314
|
return lines.join("\n");
|
|
311
315
|
}
|
|
@@ -4,7 +4,7 @@ import { harnessDescriptor, harnessHookPath, routingBlockId, selectHarnessHookSu
|
|
|
4
4
|
import { isOpencodePluginPath } from "../adapters/opencode/config.js";
|
|
5
5
|
import { LOOP_MARKER_ID, LOOP_MARKER_VERSION, loopIgnoreContent, loopLauncherArtifactId, loopWindowsLauncherArtifactId, loopWindowsLauncherPath, loopLauncherPath, selectedLoopHarnesses } from "./codex-loop.js";
|
|
6
6
|
function expectedMarker(manifest, id, markerId) {
|
|
7
|
-
const descriptor =
|
|
7
|
+
const descriptor = manifestDescriptor(manifest, id);
|
|
8
8
|
const markers = managedBlockMarkers(markerId, 1, "html");
|
|
9
9
|
return {
|
|
10
10
|
id: markerId,
|
|
@@ -20,6 +20,13 @@ function expectedMarker(manifest, id, markerId) {
|
|
|
20
20
|
legacyContent: descriptor.control.routing.legacy
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
|
+
function manifestDescriptor(manifest, id) {
|
|
24
|
+
const legacyProjectAgy = id === "agy" &&
|
|
25
|
+
manifest.scope === "project" &&
|
|
26
|
+
!manifest.artifacts.some(({ path }) => path === ".agent-ops/GEMINI.md") &&
|
|
27
|
+
!manifest.markers.some(({ id: markerId }) => markerId === "agy-routing");
|
|
28
|
+
return harnessDescriptor(legacyProjectAgy ? "codex" : id);
|
|
29
|
+
}
|
|
23
30
|
function expectedLoopMarker(manifest) {
|
|
24
31
|
const markers = managedBlockMarkers(LOOP_MARKER_ID, LOOP_MARKER_VERSION, "hash");
|
|
25
32
|
return {
|
|
@@ -98,7 +105,7 @@ export function assertSupportedManifestOwnership(manifest, root) {
|
|
|
98
105
|
throw manifestOwnershipError();
|
|
99
106
|
}
|
|
100
107
|
for (const id of harnesses) {
|
|
101
|
-
const descriptor =
|
|
108
|
+
const descriptor = manifestDescriptor(manifest, id);
|
|
102
109
|
const artifactPath = `.agent-ops/${descriptor.control.instructionFile}`;
|
|
103
110
|
const artifactKey = pathKey(artifactPath);
|
|
104
111
|
const artifactEntry = expectedArtifactPaths.get(artifactKey);
|