@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
|
@@ -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);
|
|
@@ -71,7 +71,7 @@ async function detectVerificationCommands(root) {
|
|
|
71
71
|
.filter((proposal) => proposal.confidence === "high")
|
|
72
72
|
.map(verificationCommandFromProposal);
|
|
73
73
|
}
|
|
74
|
-
function buildConfig(profiles, existing, reviewTargets = [], detectedCommands = []) {
|
|
74
|
+
function buildConfig(profiles, existing, reviewTargets = [], detectedCommands = [], completionGateEnabled = false) {
|
|
75
75
|
// Absent reviewRoles means external review is disabled; an empty selection
|
|
76
76
|
// must therefore omit the field rather than write an empty array.
|
|
77
77
|
const reviewRoles = reviewTargets.length > 0
|
|
@@ -88,6 +88,9 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
|
|
|
88
88
|
features: existing?.features ?? {
|
|
89
89
|
stopVerification: {
|
|
90
90
|
enabled: false
|
|
91
|
+
},
|
|
92
|
+
completionGate: {
|
|
93
|
+
enabled: completionGateEnabled
|
|
91
94
|
}
|
|
92
95
|
},
|
|
93
96
|
pathMappings: existing?.pathMappings ?? [],
|
|
@@ -95,7 +98,7 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
|
|
|
95
98
|
...(reviewRoles === undefined ? {} : { reviewRoles: [...reviewRoles] })
|
|
96
99
|
};
|
|
97
100
|
}
|
|
98
|
-
async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = []) {
|
|
101
|
+
async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = [], completionGateEnabled = false) {
|
|
99
102
|
const current = await readCurrentFile(root, CONFIG_PATH);
|
|
100
103
|
const owned = findOwnedArtifact(existingManifest, CONFIG_PATH);
|
|
101
104
|
if (current !== null && owned === undefined) {
|
|
@@ -131,7 +134,7 @@ async function planConfig(root, profiles, existingManifest, suppliedConfig, revi
|
|
|
131
134
|
existingConfig.verification.commands.length === 0
|
|
132
135
|
? await detectVerificationCommands(root)
|
|
133
136
|
: [];
|
|
134
|
-
const config = buildConfig(profiles, existingConfig, reviewTargets, detectedCommands);
|
|
137
|
+
const config = buildConfig(profiles, existingConfig, reviewTargets, detectedCommands, completionGateEnabled);
|
|
135
138
|
const content = `${JSON.stringify(config, null, 2)}\n`;
|
|
136
139
|
return {
|
|
137
140
|
operation: {
|
|
@@ -376,6 +379,18 @@ export async function createInstallPlan(options) {
|
|
|
376
379
|
? resolveProfiles(options.profiles)
|
|
377
380
|
: resolveCapabilities(options.existingConfig.value);
|
|
378
381
|
assertLoopProfileSupport(options.scope, options.harness, resolved.capabilities);
|
|
382
|
+
const completionGateEnabled = options.existingConfig?.value.features.completionGate.enabled ??
|
|
383
|
+
options.completionGateEnabled === true;
|
|
384
|
+
if (completionGateEnabled &&
|
|
385
|
+
(options.scope !== "project" ||
|
|
386
|
+
!options.harness.includes("agy") ||
|
|
387
|
+
!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.");
|
|
389
|
+
}
|
|
390
|
+
if (completionGateEnabled &&
|
|
391
|
+
!resolved.capabilities.includes("completion-gate")) {
|
|
392
|
+
resolved.capabilities.push("completion-gate");
|
|
393
|
+
}
|
|
379
394
|
await assertCodexLoopConfiguration(options.root, options.harness, resolved.capabilities);
|
|
380
395
|
const existing = await readExistingManifest(options.root);
|
|
381
396
|
assertCompatibleManifest(existing?.manifest ?? null, options.scope, options.harness, options.allowHarnessChange === true);
|
|
@@ -461,7 +476,7 @@ export async function createInstallPlan(options) {
|
|
|
461
476
|
: [];
|
|
462
477
|
const operations = [];
|
|
463
478
|
const artifacts = [];
|
|
464
|
-
const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? []);
|
|
479
|
+
const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? [], completionGateEnabled);
|
|
465
480
|
operations.push(config.operation);
|
|
466
481
|
artifacts.push(config.record);
|
|
467
482
|
for (const artifact of contribution.artifacts) {
|
|
@@ -33,5 +33,8 @@ export function resolveCapabilities(config) {
|
|
|
33
33
|
if (config.features.stopVerification.enabled) {
|
|
34
34
|
resolved.capabilities.push("optional-stop-verify");
|
|
35
35
|
}
|
|
36
|
+
if (config.features.completionGate.enabled) {
|
|
37
|
+
resolved.capabilities.push("completion-gate");
|
|
38
|
+
}
|
|
36
39
|
return resolved;
|
|
37
40
|
}
|
|
@@ -328,7 +328,7 @@ export function validateConfig(value) {
|
|
|
328
328
|
if (!isRecord(root.features)) {
|
|
329
329
|
return failure("INVALID_TYPE", "$.features", "features must be an object.");
|
|
330
330
|
}
|
|
331
|
-
const featuresUnknown = unknownFieldFailure(root.features, ["stopVerification"], "$.features");
|
|
331
|
+
const featuresUnknown = unknownFieldFailure(root.features, ["completionGate", "stopVerification"], "$.features");
|
|
332
332
|
if (featuresUnknown !== undefined) {
|
|
333
333
|
return featuresUnknown;
|
|
334
334
|
}
|
|
@@ -342,6 +342,16 @@ export function validateConfig(value) {
|
|
|
342
342
|
if (typeof root.features.stopVerification.enabled !== "boolean") {
|
|
343
343
|
return failure("INVALID_FEATURE", "$.features.stopVerification.enabled", "stopVerification.enabled must be a boolean.");
|
|
344
344
|
}
|
|
345
|
+
if (!isRecord(root.features.completionGate)) {
|
|
346
|
+
return failure("INVALID_TYPE", "$.features.completionGate", "completionGate must be an object.");
|
|
347
|
+
}
|
|
348
|
+
const completionGateUnknown = unknownFieldFailure(root.features.completionGate, ["enabled"], "$.features.completionGate");
|
|
349
|
+
if (completionGateUnknown !== undefined) {
|
|
350
|
+
return completionGateUnknown;
|
|
351
|
+
}
|
|
352
|
+
if (typeof root.features.completionGate.enabled !== "boolean") {
|
|
353
|
+
return failure("INVALID_FEATURE", "$.features.completionGate.enabled", "completionGate.enabled must be a boolean.");
|
|
354
|
+
}
|
|
345
355
|
if (!isRecord(root.verification)) {
|
|
346
356
|
return failure("INVALID_TYPE", "$.verification", "verification must be an object.");
|
|
347
357
|
}
|
|
@@ -77,6 +77,9 @@ export class TaskService {
|
|
|
77
77
|
!/^[a-f0-9]{64}$/u.test(input.policyConfigHash)) {
|
|
78
78
|
throw taskError("TASK_POLICY_CONFIG_INVALID", "Policy config hash must be a lowercase SHA-256 digest.");
|
|
79
79
|
}
|
|
80
|
+
if (input.sessionId !== undefined) {
|
|
81
|
+
assertSessionId(input.sessionId);
|
|
82
|
+
}
|
|
80
83
|
const task = {
|
|
81
84
|
schemaVersion: TASK_SCHEMA_VERSION,
|
|
82
85
|
id: this.#generateId(),
|
|
@@ -118,6 +121,20 @@ export class TaskService {
|
|
|
118
121
|
policyConfigHash: input.policyConfigHash ?? null
|
|
119
122
|
};
|
|
120
123
|
state.tasks.push(record);
|
|
124
|
+
if (input.sessionId !== undefined) {
|
|
125
|
+
const currentIndex = state.sessions.findIndex(({ sessionId }) => sessionId === input.sessionId);
|
|
126
|
+
const attachment = {
|
|
127
|
+
sessionId: input.sessionId,
|
|
128
|
+
taskId: record.task.id,
|
|
129
|
+
attachedAt: now
|
|
130
|
+
};
|
|
131
|
+
if (currentIndex === -1) {
|
|
132
|
+
state.sessions.push(attachment);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
state.sessions[currentIndex] = attachment;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
121
138
|
return cloneRecord(record);
|
|
122
139
|
});
|
|
123
140
|
}
|
|
@@ -6,10 +6,12 @@ Use `--harness all` to select agy, Codex, Claude Code, and opencode, or pass a
|
|
|
6
6
|
comma-separated subset such as `codex,opencode`. `both` remains an input alias
|
|
7
7
|
for the legacy Codex plus Claude selection.
|
|
8
8
|
|
|
9
|
-
Project agy
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
Project agy uses a managed supplemental `GEMINI.md` routing block and
|
|
10
|
+
`.agent-ops/GEMINI.md` baseline. This follows the official agy CLI rule that a
|
|
11
|
+
workspace-root `GEMINI.md` or `AGENTS.md` is loaded at startup. Codex and
|
|
12
|
+
opencode share the corresponding `AGENTS.md` route and `.agent-ops/AGENTS.md`
|
|
13
|
+
artifact. Each block loads the managed baseline while project-specific
|
|
14
|
+
instructions remain authoritative. Claude uses the corresponding `CLAUDE.md` route and
|
|
13
15
|
`.agent-ops/CLAUDE.md` artifact. Opencode additionally gets
|
|
14
16
|
the agent-ops-owned `.opencode/plugins/agent-ops.js` file; `opencode.json` is
|
|
15
17
|
never modified. The plugin is generated with the installed absolute runtime
|
|
@@ -147,6 +149,21 @@ user hooks live in `.gemini/config/hooks.json`. User-scope rules modify the
|
|
|
147
149
|
shared Gemini rule surface at `.gemini/GEMINI.md`. agy 1.1.12 or newer is
|
|
148
150
|
required for machine-readable `/hooks` diagnostics.
|
|
149
151
|
|
|
152
|
+
For `agy` plus `loop`, the interactive installer recommends
|
|
153
|
+
`features.completionGate.enabled`; non-interactive installs require the explicit
|
|
154
|
+
`--completion-gate` flag. The gate uses the documented `conversationId`,
|
|
155
|
+
`terminationReason`, and `fullyIdle` Stop fields and returns the documented
|
|
156
|
+
`decision: "continue"` only for a final changed conversation that lacks current
|
|
157
|
+
task, verification, or review proof. Pure Q&A, analysis, read-only diagnostics,
|
|
158
|
+
error stops, max-step stops, and non-idle stops continue normally. It does not
|
|
159
|
+
change Codex, Claude Code, or OpenCode Stop behavior. For headless execution use
|
|
160
|
+
`agent-ops agy-run -- <agy arguments>`; a user-approved one-time escape is
|
|
161
|
+
`agent-ops allow-stop --session <conversationId>` and is guarded by agy's
|
|
162
|
+
documented `force_ask` decision.
|
|
163
|
+
|
|
164
|
+
Official references: [agy CLI workspace rule files](https://www.antigravity.google/docs/cli/best-practices/)
|
|
165
|
+
and [Antigravity hook contracts](https://www.antigravity.google/docs/hooks/).
|
|
166
|
+
|
|
150
167
|
The full Codex/Claude loop runs `SessionStart`, `UserPromptSubmit`, `PreToolUse`,
|
|
151
168
|
`PermissionRequest`, `PostToolUse`, `PreCompact`, `PostCompact`,
|
|
152
169
|
`SubagentStart`, and `SubagentStop`, but never adds `Stop`. It blocks only
|
|
@@ -183,8 +200,9 @@ classified invalid installed configuration. The managed OpenCode
|
|
|
183
200
|
`tool.execute.before` plugin can throw its documented command-policy denial or
|
|
184
201
|
unavailable-runtime error for its supported Bash surface. Codex is explicitly
|
|
185
202
|
non-enforcing (`unknown`). These are agent-ops output and plugin contracts, not
|
|
186
|
-
proof that a host honors a denial. `SessionStart` and
|
|
187
|
-
fail-open
|
|
203
|
+
proof that a host honors a denial. `SessionStart` and ordinary Stop verification
|
|
204
|
+
failure paths stay fail-open. Only the explicitly enabled agy completion gate
|
|
205
|
+
fails closed at final Stop.
|
|
188
206
|
|
|
189
207
|
Claude's invalid-config fallback has four safeguards: (1) an absent project
|
|
190
208
|
configuration stays fail-open, so only an invalid `.agent-ops/config.json` can
|
|
@@ -196,7 +214,7 @@ that shell variable. The variable is read only from the hook-process environment
|
|
|
196
214
|
and cannot be set in agent-ops configuration, a manifest, or managed files.
|
|
197
215
|
|
|
198
216
|
`guardrails` installs command policy but does not enable Stop verification. Stop
|
|
199
|
-
is a separate config-
|
|
217
|
+
is a separate config-v3 feature and must be explicitly enabled with at least
|
|
200
218
|
one confirmed command:
|
|
201
219
|
|
|
202
220
|
```json
|
package/docs/en/spec/README.md
CHANGED
|
@@ -4,12 +4,12 @@ This is the normative English specification for bounded, evidence-driven work.
|
|
|
4
4
|
|
|
5
5
|
The harness adapter rules cover agy, Codex, Claude Code, and opencode. The
|
|
6
6
|
opencode integration is a generated local plugin; it does not manage
|
|
7
|
-
`opencode.json`. agy
|
|
7
|
+
`opencode.json`. agy uses project `GEMINI.md` routing, uses native hooks, and
|
|
8
8
|
is explicitly degraded where its lifecycle surface is smaller than the full
|
|
9
9
|
loop.
|
|
10
10
|
|
|
11
|
-
Configuration is versioned independently from the manifest.
|
|
12
|
-
to config
|
|
11
|
+
Configuration is versioned independently from the manifest. Older configs migrate
|
|
12
|
+
to config v3 with Stop verification and the agy completion gate disabled; changing a capability requires
|
|
13
13
|
a confirmed project `agent-ops update`, which also refreshes trust when
|
|
14
14
|
verifiers exist. Stop verification is
|
|
15
15
|
explicit, trusted, report-only, and never completes a task. Dry-run plans keep
|
|
@@ -108,6 +108,7 @@ The current registration matrix is intentionally asymmetric:
|
|
|
108
108
|
| --- | --- | --- | --- | --- |
|
|
109
109
|
| lifecycle-summary | degraded | supported | supported | degraded |
|
|
110
110
|
| command-policy | supported | unknown | supported | supported |
|
|
111
|
+
| completion-gate | supported | unsupported | unsupported | unsupported |
|
|
111
112
|
| optional-stop-verify | degraded | unsupported | supported | degraded |
|
|
112
113
|
|
|
113
114
|
For runtime-failure handling, only `command-policy` is fail-closed. Claude
|
|
@@ -118,11 +119,14 @@ surface. Codex remains `unknown` and never emits a denial. Fixture tests assert
|
|
|
118
119
|
these wire and plugin shapes only; they do not prove that a host honors a
|
|
119
120
|
denial. Every `SessionStart` and `Stop` failure path remains fail-open.
|
|
120
121
|
|
|
121
|
-
The agy adapter uses project `
|
|
122
|
+
The agy adapter uses native project `GEMINI.md` routing to
|
|
123
|
+
`.agent-ops/GEMINI.md`;
|
|
122
124
|
at user scope it manages `.agent-ops/GEMINI.md` and a managed block in the
|
|
123
125
|
shared `.gemini/GEMINI.md` rule surface. Its native hooks use camelCase input,
|
|
124
|
-
return `decision: "deny"` for command-policy blocks, and
|
|
125
|
-
|
|
126
|
+
return `decision: "deny"` for command-policy blocks, and, only when the
|
|
127
|
+
completion gate is explicitly enabled, return `decision: "continue"` for an
|
|
128
|
+
unproven final changed conversation. Read-only conversations stop normally.
|
|
129
|
+
On Windows the generated command is invoked through `cmd /c`.
|
|
126
130
|
|
|
127
131
|
Stop verification is explicit, trusted, report-only, and disabled by default.
|
|
128
132
|
Every Stop result continues the native harness and may carry only bounded
|