@kylecheng3146/agent-ops 0.1.19 → 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 +38 -12
- 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 +113 -12
- 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 +43 -10
- package/dist/packages/cli/src/commands/review.js +3 -0
- package/dist/packages/cli/src/commands/task.js +5 -2
- package/dist/packages/cli/src/commands/uninstall.js +20 -3
- package/dist/packages/cli/src/context.js +4 -1
- package/dist/packages/cli/src/hook-process.js +59 -5
- package/dist/packages/cli/src/ui.js +3 -1
- package/dist/packages/cli/src/wizard.js +36 -6
- package/dist/runtime/src/adapters/agy/config.js +115 -0
- package/dist/runtime/src/adapters/agy/events.js +35 -0
- package/dist/runtime/src/adapters/agy/input.js +48 -0
- package/dist/runtime/src/adapters/agy/output.js +28 -0
- package/dist/runtime/src/adapters/agy/surfaces.js +9 -0
- 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/codex-loop.js +6 -2
- package/dist/runtime/src/install/doctor.js +17 -0
- package/dist/runtime/src/install/harness.js +56 -1
- package/dist/runtime/src/install/hooks.js +6 -1
- package/dist/runtime/src/install/ownership.js +15 -4
- package/dist/runtime/src/install/plan.js +21 -6
- package/dist/runtime/src/install/probes.js +61 -0
- package/dist/runtime/src/install/profiles.js +3 -0
- package/dist/runtime/src/install/surface-inspection.js +10 -1
- package/dist/runtime/src/install/uninstall.js +337 -6
- package/dist/runtime/src/review/execute.js +23 -20
- package/dist/runtime/src/review/extract.js +5 -11
- package/dist/runtime/src/review/render.js +6 -1
- package/dist/runtime/src/review/roles.js +4 -0
- package/dist/runtime/src/review/runner.js +7 -0
- package/dist/runtime/src/schema/validate.js +13 -3
- package/dist/runtime/src/task/service.js +17 -0
- package/docs/en/guides/configuration.md +46 -12
- package/docs/en/spec/README.md +7 -4
- package/docs/en/spec/harness-adapters.md +24 -9
- package/docs/en/spec/review.md +6 -0
- package/docs/zh-TW/guides/configuration.md +41 -11
- package/docs/zh-TW/spec/README.md +6 -4
- package/docs/zh-TW/spec/harness-adapters.md +21 -10
- package/docs/zh-TW/spec/review.md +5 -0
- package/package.json +2 -2
- package/schemas/config.schema.json +12 -2
- package/schemas/manifest.schema.json +2 -2
|
@@ -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
|
}
|
|
@@ -134,12 +134,16 @@ export function planLoopContribution(options) {
|
|
|
134
134
|
return { artifacts: [], blocks: [] };
|
|
135
135
|
}
|
|
136
136
|
const harnesses = selectedLoopHarnesses(options.harnesses);
|
|
137
|
-
if (options.scope !== "project" ||
|
|
138
|
-
|
|
137
|
+
if (options.scope !== "project" ||
|
|
138
|
+
(harnesses.length === 0 && !options.harnesses.includes("agy"))) {
|
|
139
|
+
throw new AgentOpsError("LOOP_PROFILE_UNSUPPORTED", "The loop profile requires project scope and the agy, Codex, or Claude harness.");
|
|
139
140
|
}
|
|
140
141
|
if (options.hookRuntimePath === undefined) {
|
|
141
142
|
throw new AgentOpsError("LOOP_RUNTIME_REQUIRED", "The loop profile requires the installed hook runtime path.");
|
|
142
143
|
}
|
|
144
|
+
if (harnesses.length === 0) {
|
|
145
|
+
return { artifacts: [], blocks: [] };
|
|
146
|
+
}
|
|
143
147
|
const artifacts = harnesses.flatMap((harness) => [
|
|
144
148
|
{
|
|
145
149
|
id: loopLauncherArtifactId(harness),
|
|
@@ -309,6 +309,16 @@ function checkLifecycleSummary(manifest, config) {
|
|
|
309
309
|
}
|
|
310
310
|
return check("lifecycle-summary", "PASS", "Lifecycle summary is reachable for every selected harness.");
|
|
311
311
|
}
|
|
312
|
+
function checkProjectLoop(manifest, config) {
|
|
313
|
+
if (manifest === undefined || config === undefined || !config.profiles.includes("loop")) {
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
return manifest.harness.includes("agy")
|
|
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.")
|
|
320
|
+
: check("project-loop", "PASS", "Project loop events are fully registered.");
|
|
321
|
+
}
|
|
312
322
|
async function checkSurfaceInventory(root, manifest, config) {
|
|
313
323
|
if (manifest === undefined || config === undefined) {
|
|
314
324
|
return {
|
|
@@ -420,6 +430,13 @@ export async function doctorInstallation(options) {
|
|
|
420
430
|
await checkRegistrationDrift(options.root, manifest.manifest, config.config),
|
|
421
431
|
await checkProbe("hook-registration", options.probes?.hookRegistration),
|
|
422
432
|
checkLifecycleSummary(manifest.manifest, config.config),
|
|
433
|
+
...(() => {
|
|
434
|
+
const projectLoop = checkProjectLoop(manifest.manifest, config.config);
|
|
435
|
+
return projectLoop === undefined ? [] : [projectLoop];
|
|
436
|
+
})(),
|
|
437
|
+
...(manifest.manifest?.harness.includes("agy") === true
|
|
438
|
+
? [await checkProbe("agy-runtime", options.probes?.agyRuntime)]
|
|
439
|
+
: []),
|
|
423
440
|
await checkProbe("repository-trust", options.probes?.repositoryTrust),
|
|
424
441
|
await checkProbe("smoke-availability", options.probes?.smokeAvailability),
|
|
425
442
|
await checkReviewTargets(config.config, options.probes?.reviewTarget, options.checkReviewTargetAuth === true)
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { buildAgyHookSettings, isAgyHookRegistered, isAgyManagedHook, mergeAgyHooks, stripAgyHooks } from "../adapters/agy/config.js";
|
|
2
|
+
import { AGY_CAPABILITY_REGISTRATIONS } from "../adapters/agy/events.js";
|
|
3
|
+
import { normalizeAgyHookInput } from "../adapters/agy/input.js";
|
|
4
|
+
import { agyHookOutput } from "../adapters/agy/output.js";
|
|
5
|
+
import { agySurfaces } from "../adapters/agy/surfaces.js";
|
|
1
6
|
import { buildClaudeHookSettings, isClaudeManagedHandler, mergeClaudeSettings, stripClaudeManagedHooks } from "../adapters/claude/config.js";
|
|
2
7
|
import { CLAUDE_CAPABILITY_REGISTRATIONS } from "../adapters/claude/events.js";
|
|
3
8
|
import { normalizeClaudeHookInput } from "../adapters/claude/input.js";
|
|
@@ -16,6 +21,7 @@ import { opencodeSurfaces } from "../adapters/opencode/surfaces.js";
|
|
|
16
21
|
import { AgentOpsError } from "../fs/paths.js";
|
|
17
22
|
import { findSurfaceById, findSurfaceByPath, isWritableSurface } from "./surfaces.js";
|
|
18
23
|
export const HARNESS_IDS = [
|
|
24
|
+
"agy",
|
|
19
25
|
"codex",
|
|
20
26
|
"claude",
|
|
21
27
|
"opencode"
|
|
@@ -108,7 +114,52 @@ const CLAUDE_ROUTING = {
|
|
|
108
114
|
"## Loop Engineering\n\nUse `.agent-ops/CLAUDE.md` as the canonical Loop Engineering specification for this project.\n"
|
|
109
115
|
]
|
|
110
116
|
};
|
|
117
|
+
const AGY_ROUTING = {
|
|
118
|
+
desired: "## Loop Engineering\n\nLoad `.agent-ops/GEMINI.md` as the agent-ops managed baseline.\nProject-specific instructions in this file remain authoritative.\n",
|
|
119
|
+
legacy: []
|
|
120
|
+
};
|
|
111
121
|
const DESCRIPTORS = {
|
|
122
|
+
agy: {
|
|
123
|
+
id: "agy",
|
|
124
|
+
control: {
|
|
125
|
+
instructionFile: "GEMINI.md",
|
|
126
|
+
routing: AGY_ROUTING,
|
|
127
|
+
hookPath: ".agents/hooks.json",
|
|
128
|
+
hookPathForScope: (scope) => scope === "project" ? ".agents/hooks.json" : ".gemini/config/hooks.json",
|
|
129
|
+
surfaces: agySurfaces,
|
|
130
|
+
ownSettingsKeys: [],
|
|
131
|
+
buildHooks: buildAgyHookSettings,
|
|
132
|
+
mergeHooks: mergeAgyHooks,
|
|
133
|
+
stripHooks: stripAgyHooks,
|
|
134
|
+
isManagedHandler: isAgyManagedHook,
|
|
135
|
+
registrations: AGY_CAPABILITY_REGISTRATIONS,
|
|
136
|
+
hookRegistered: (source, capabilities) => isAgyHookRegistered(parseJsonSource(source), capabilities),
|
|
137
|
+
plan: async (context) => {
|
|
138
|
+
if (context.scope === "project") {
|
|
139
|
+
return await planCommonHarnessContribution("agy", context);
|
|
140
|
+
}
|
|
141
|
+
const descriptor = DESCRIPTORS.agy;
|
|
142
|
+
return {
|
|
143
|
+
artifacts: [{
|
|
144
|
+
id: "gemini-rules",
|
|
145
|
+
path: ".agent-ops/GEMINI.md",
|
|
146
|
+
content: managedRules(descriptor, context)
|
|
147
|
+
}],
|
|
148
|
+
blocks: [{
|
|
149
|
+
id: "agy-routing",
|
|
150
|
+
path: ".gemini/GEMINI.md",
|
|
151
|
+
version: 1,
|
|
152
|
+
content: AGY_ROUTING.desired
|
|
153
|
+
}]
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
runtime: {
|
|
158
|
+
normalizeInput: normalizeAgyHookInput,
|
|
159
|
+
formatOutput: agyHookOutput,
|
|
160
|
+
formatRuntimeFailure: (event, capability, remedy) => agyHookOutput(event, runtimeFailureResult(capability, AGY_CAPABILITY_REGISTRATIONS, remedy))
|
|
161
|
+
}
|
|
162
|
+
},
|
|
112
163
|
codex: createJsonDescriptor({
|
|
113
164
|
id: "codex",
|
|
114
165
|
instructionFile: "AGENTS.md",
|
|
@@ -230,6 +281,7 @@ export function resolveHarnessSelection(value) {
|
|
|
230
281
|
}
|
|
231
282
|
export const COMMON_AGENTS_BLOCK = DESCRIPTORS.codex.control.routing.desired;
|
|
232
283
|
export const COMMON_CLAUDE_BLOCK = DESCRIPTORS.claude.control.routing.desired;
|
|
284
|
+
export const COMMON_GEMINI_BLOCK = DESCRIPTORS.agy.control.routing.desired;
|
|
233
285
|
export function managedRules(descriptor, context) {
|
|
234
286
|
const lines = [
|
|
235
287
|
"# Loop Engineering",
|
|
@@ -244,7 +296,7 @@ export function managedRules(descriptor, context) {
|
|
|
244
296
|
""
|
|
245
297
|
];
|
|
246
298
|
if (context.capabilities.includes("rules")) {
|
|
247
|
-
lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain independent review before claiming completion, via", " `agent-ops review --yes` (or the CLI's equivalent invocation). Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
|
|
299
|
+
lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain independent review before claiming completion, via", " `agent-ops review --yes` (or the CLI's equivalent invocation). Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Set `AGENT_OPS_HOST` to the current CLI id when invoking review so", " agent-ops tries a different CLI first and uses isolated self-review", " only when no other configured reviewer is usable.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
|
|
248
300
|
}
|
|
249
301
|
if (context.capabilities.includes("task")) {
|
|
250
302
|
lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria, verification, and independent review;", "completing one never completes its parent.", "");
|
|
@@ -255,6 +307,9 @@ export function managedRules(descriptor, context) {
|
|
|
255
307
|
if (context.capabilities.includes("command-policy")) {
|
|
256
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.", "");
|
|
257
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
|
+
}
|
|
258
313
|
lines.push(`This file is routed from the active ${descriptor.control.instructionFile}.`, "");
|
|
259
314
|
return lines.join("\n");
|
|
260
315
|
}
|
|
@@ -35,7 +35,9 @@ export function planHookRegistration(options) {
|
|
|
35
35
|
}
|
|
36
36
|
const path = options.path ?? hookRegistrationPath(options.harness, options.scope);
|
|
37
37
|
const managed = descriptor.control.buildHooks(options.capabilities, options.runtimePath, options.platform);
|
|
38
|
-
const events = Object.keys(managed.hooks)
|
|
38
|
+
const events = Object.keys(managed.hooks).map((event) => options.harness === "agy" && event === "PreInvocation"
|
|
39
|
+
? "SessionStart"
|
|
40
|
+
: event);
|
|
39
41
|
if (events.length === 0) {
|
|
40
42
|
return null;
|
|
41
43
|
}
|
|
@@ -54,6 +56,9 @@ export function planHookRegistration(options) {
|
|
|
54
56
|
};
|
|
55
57
|
}
|
|
56
58
|
function onlyManagedRemains(harness, value) {
|
|
59
|
+
if (harness === "agy") {
|
|
60
|
+
return Object.keys(value).length === 0;
|
|
61
|
+
}
|
|
57
62
|
const ownKeys = new Set(harnessDescriptor(harness).control.ownSettingsKeys ?? []);
|
|
58
63
|
const hooks = value.hooks;
|
|
59
64
|
return (Object.keys(value).every((key) => ownKeys.has(key)) &&
|
|
@@ -4,13 +4,15 @@ 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,
|
|
11
11
|
path: manifest.scope === "project"
|
|
12
12
|
? descriptor.control.instructionFile
|
|
13
|
-
:
|
|
13
|
+
: id === "agy"
|
|
14
|
+
? ".gemini/GEMINI.md"
|
|
15
|
+
: `.${id}/${descriptor.control.instructionFile}`,
|
|
14
16
|
startMarker: markers.start,
|
|
15
17
|
endMarker: markers.end,
|
|
16
18
|
markerStyle: "html",
|
|
@@ -18,6 +20,13 @@ function expectedMarker(manifest, id, markerId) {
|
|
|
18
20
|
legacyContent: descriptor.control.routing.legacy
|
|
19
21
|
};
|
|
20
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
|
+
}
|
|
21
30
|
function expectedLoopMarker(manifest) {
|
|
22
31
|
const markers = managedBlockMarkers(LOOP_MARKER_ID, LOOP_MARKER_VERSION, "hash");
|
|
23
32
|
return {
|
|
@@ -96,7 +105,7 @@ export function assertSupportedManifestOwnership(manifest, root) {
|
|
|
96
105
|
throw manifestOwnershipError();
|
|
97
106
|
}
|
|
98
107
|
for (const id of harnesses) {
|
|
99
|
-
const descriptor =
|
|
108
|
+
const descriptor = manifestDescriptor(manifest, id);
|
|
100
109
|
const artifactPath = `.agent-ops/${descriptor.control.instructionFile}`;
|
|
101
110
|
const artifactKey = pathKey(artifactPath);
|
|
102
111
|
const artifactEntry = expectedArtifactPaths.get(artifactKey);
|
|
@@ -112,7 +121,9 @@ export function assertSupportedManifestOwnership(manifest, root) {
|
|
|
112
121
|
requiredArtifactPaths.add(artifactKey);
|
|
113
122
|
const markerPath = manifest.scope === "project"
|
|
114
123
|
? descriptor.control.instructionFile
|
|
115
|
-
:
|
|
124
|
+
: id === "agy"
|
|
125
|
+
? ".gemini/GEMINI.md"
|
|
126
|
+
: `.${id}/${descriptor.control.instructionFile}`;
|
|
116
127
|
const markerKey = pathKey(markerPath);
|
|
117
128
|
expectedMarkerPaths.add(markerKey);
|
|
118
129
|
const currentId = routingBlockId(id, manifest.scope, descriptor);
|
|
@@ -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: {
|
|
@@ -280,8 +283,8 @@ function assertLoopProfileSupport(scope, harness, capabilities) {
|
|
|
280
283
|
return;
|
|
281
284
|
}
|
|
282
285
|
if (scope !== "project" ||
|
|
283
|
-
!harness.some((id) => id === "codex" || id === "claude")) {
|
|
284
|
-
throw new AgentOpsError("LOOP_PROFILE_UNSUPPORTED", "The loop profile requires project scope and the Codex or Claude harness.");
|
|
286
|
+
!harness.some((id) => id === "agy" || id === "codex" || id === "claude")) {
|
|
287
|
+
throw new AgentOpsError("LOOP_PROFILE_UNSUPPORTED", "The loop profile requires project scope and the agy, Codex, or Claude harness.");
|
|
285
288
|
}
|
|
286
289
|
}
|
|
287
290
|
async function assertCodexLoopConfiguration(root, harness, capabilities) {
|
|
@@ -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) {
|
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
import { harnessDescriptor } from "./harness.js";
|
|
2
2
|
import { resolveCapabilities } from "./profiles.js";
|
|
3
|
+
const MINIMUM_AGY_VERSION = [1, 1, 12];
|
|
4
|
+
export function agyVersionSupported(versionOutput) {
|
|
5
|
+
const match = /\b(\d+)\.(\d+)\.(\d+)\b/u.exec(versionOutput);
|
|
6
|
+
const version = match?.slice(1).map(Number);
|
|
7
|
+
return version !== undefined && !version.some((part, index) => part < MINIMUM_AGY_VERSION[index] &&
|
|
8
|
+
version.slice(0, index).every((prior, priorIndex) => prior === MINIMUM_AGY_VERSION[priorIndex]));
|
|
9
|
+
}
|
|
10
|
+
export function agyRuntimeStatus(versionOutput, hooksOutput, expectedEvents = []) {
|
|
11
|
+
const match = /\b(\d+)\.(\d+)\.(\d+)\b/u.exec(versionOutput);
|
|
12
|
+
if (!agyVersionSupported(versionOutput)) {
|
|
13
|
+
return {
|
|
14
|
+
status: "FAIL",
|
|
15
|
+
message: "agy 1.1.12 or newer is required.",
|
|
16
|
+
remediation: "Update agy, then run `agent-ops doctor` again."
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(hooksOutput);
|
|
21
|
+
const hooks = parsed.command?.data?.hooks;
|
|
22
|
+
const loaded = Array.isArray(hooks) && hooks.some((hook) => {
|
|
23
|
+
if (typeof hook !== "object" || hook === null || Array.isArray(hook))
|
|
24
|
+
return false;
|
|
25
|
+
const value = hook;
|
|
26
|
+
const actions = value.actions;
|
|
27
|
+
if (!(value.name === "agent-ops" &&
|
|
28
|
+
value.enabled === true &&
|
|
29
|
+
Array.isArray(actions) &&
|
|
30
|
+
actions.length > 0 &&
|
|
31
|
+
actions.every((action) => typeof action === "object" && action !== null && !Array.isArray(action))))
|
|
32
|
+
return false;
|
|
33
|
+
return expectedEvents.every((expected) => actions.some((action) => {
|
|
34
|
+
const nativeEvent = expected === "SessionStart" ? "PreInvocation" : expected;
|
|
35
|
+
return (typeof action === "object" && action !== null && !Array.isArray(action) &&
|
|
36
|
+
action.event === nativeEvent &&
|
|
37
|
+
typeof action.command === "string" &&
|
|
38
|
+
action.command.endsWith(` agy ${expected} --managed-by=agent-ops`));
|
|
39
|
+
}));
|
|
40
|
+
});
|
|
41
|
+
if (!Array.isArray(hooks) || (expectedEvents.length > 0 && !loaded)) {
|
|
42
|
+
return {
|
|
43
|
+
status: "FAIL",
|
|
44
|
+
message: "agy is installed, but its loaded hook list does not include agent-ops.",
|
|
45
|
+
code: "UPDATE_REQUIRED",
|
|
46
|
+
remediation: "Run `agent-ops update`, restart agy, then run doctor again."
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
status: "PASS",
|
|
51
|
+
message: expectedEvents.length > 0
|
|
52
|
+
? `agy ${match?.[0]} loaded the agent-ops hook.`
|
|
53
|
+
: `agy ${match?.[0]} meets the minimum supported version.`
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return {
|
|
58
|
+
status: "FAIL",
|
|
59
|
+
message: "agy returned an unreadable /hooks response.",
|
|
60
|
+
remediation: "Run `agy -p \"/hooks\" --output-format json` and inspect the result."
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
3
64
|
/**
|
|
4
65
|
* Returns the harness ids missing an agent-ops owned handler for the hook
|
|
5
66
|
* events implied by the installed profiles. Empty when installations without
|
|
@@ -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
|
}
|
|
@@ -90,6 +90,9 @@ function managedJsonCount(source, isManagedHandler) {
|
|
|
90
90
|
}
|
|
91
91
|
return jsonHandlerCounts(source, isManagedHandler)?.managed ?? 0;
|
|
92
92
|
}
|
|
93
|
+
function desiredHookEvents(harness, events) {
|
|
94
|
+
return events.map((event) => harness === "agy" && event === "PreInvocation" ? "SessionStart" : event);
|
|
95
|
+
}
|
|
93
96
|
export async function inspectHarnessRegistrations(options) {
|
|
94
97
|
const capabilities = desiredCapabilities(options.config);
|
|
95
98
|
const statuses = [];
|
|
@@ -100,7 +103,7 @@ export async function inspectHarnessRegistrations(options) {
|
|
|
100
103
|
const recordedEvents = hookRecord?.events ?? [];
|
|
101
104
|
const desiredEvents = control.buildHooks === undefined
|
|
102
105
|
? []
|
|
103
|
-
: Object.keys(control.buildHooks(capabilities, "probe").hooks);
|
|
106
|
+
: desiredHookEvents(harness, Object.keys(control.buildHooks(capabilities, "probe").hooks));
|
|
104
107
|
if (control.buildHooks !== undefined) {
|
|
105
108
|
const surfaces = harnessSurfaces(harness, options.manifest.scope, options.root);
|
|
106
109
|
const writableJsonSurfaces = surfaces.filter((surface) => isWritableSurface(surface) && surface.representation === "json");
|
|
@@ -186,6 +189,12 @@ function jsonHandlerCounts(source, isManagedHandler) {
|
|
|
186
189
|
if (!isRecord(parsed)) {
|
|
187
190
|
return null;
|
|
188
191
|
}
|
|
192
|
+
if (isManagedHandler?.(parsed) === true) {
|
|
193
|
+
return {
|
|
194
|
+
managed: 1,
|
|
195
|
+
foreign: Math.max(0, Object.keys(parsed).length - 1)
|
|
196
|
+
};
|
|
197
|
+
}
|
|
189
198
|
const hooks = parsed.hooks;
|
|
190
199
|
if (hooks === undefined) {
|
|
191
200
|
return { managed: 0, foreign: 0 };
|