@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
|
@@ -13,7 +13,10 @@ function formatUninstallPlan(plan, trust) {
|
|
|
13
13
|
? []
|
|
14
14
|
: [
|
|
15
15
|
`Scope: ${plan.manifest.scope}`,
|
|
16
|
-
`Harness: ${plan.manifest.harness}
|
|
16
|
+
`Harness: ${plan.manifest.harness}`,
|
|
17
|
+
...(plan.selectedHarnesses === undefined
|
|
18
|
+
? []
|
|
19
|
+
: [`Removing harness: ${plan.selectedHarnesses.join(", ")}`])
|
|
17
20
|
])
|
|
18
21
|
],
|
|
19
22
|
operations: toPublicUninstallPlan(plan).operations
|
|
@@ -31,6 +34,18 @@ async function trustChange(options, plan) {
|
|
|
31
34
|
if (plan.manifest?.scope === "user") {
|
|
32
35
|
return { action: "skipped", reason: "user-scope" };
|
|
33
36
|
}
|
|
37
|
+
if (plan.resultingManifest !== undefined) {
|
|
38
|
+
if (options.calculateTrustBinding === undefined ||
|
|
39
|
+
options.trustStore === undefined) {
|
|
40
|
+
return { action: "skipped", reason: "not-configured" };
|
|
41
|
+
}
|
|
42
|
+
const binding = await options.calculateTrustBinding();
|
|
43
|
+
if (binding === null) {
|
|
44
|
+
return { action: "skipped", reason: "no-verification-commands" };
|
|
45
|
+
}
|
|
46
|
+
const status = await options.trustStore.status(binding);
|
|
47
|
+
return { action: "unchanged", binding, status: status.status };
|
|
48
|
+
}
|
|
34
49
|
if (options.calculateTrustBinding === undefined ||
|
|
35
50
|
options.trustStore === undefined) {
|
|
36
51
|
return { action: "skipped", reason: "not-configured" };
|
|
@@ -41,7 +56,7 @@ async function trustChange(options, plan) {
|
|
|
41
56
|
: await planTrustRevoke(binding, options.trustStore);
|
|
42
57
|
}
|
|
43
58
|
export async function runUninstallCommand(options) {
|
|
44
|
-
const plan = await createUninstallPlan(options.root);
|
|
59
|
+
const plan = await createUninstallPlan(options.root, options.args.harness);
|
|
45
60
|
if (!plan.installed) {
|
|
46
61
|
return okEnvelope("UNINSTALL_NOT_INSTALLED", {
|
|
47
62
|
applied: false,
|
|
@@ -80,7 +95,9 @@ export async function runUninstallCommand(options) {
|
|
|
80
95
|
return okEnvelope("UNINSTALL_APPLIED", {
|
|
81
96
|
applied: true,
|
|
82
97
|
plan: toPublicUninstallPlan(plan, trust),
|
|
83
|
-
message:
|
|
98
|
+
message: plan.selectedHarnesses === undefined
|
|
99
|
+
? "Managed installation content was removed."
|
|
100
|
+
: `Managed ${plan.selectedHarnesses.join(", ")} harness content was removed; remaining harnesses were preserved.`
|
|
84
101
|
});
|
|
85
102
|
}
|
|
86
103
|
export { formatUninstallPlan };
|
|
@@ -9,12 +9,15 @@ import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
|
|
|
9
9
|
import { localStatePaths } from "../../../runtime/src/security/permissions.js";
|
|
10
10
|
import { calculateTrustBinding, FileTrustStore } from "../../../runtime/src/security/trust.js";
|
|
11
11
|
export const DEFAULT_CONFIG = {
|
|
12
|
-
schemaVersion:
|
|
12
|
+
schemaVersion: 3,
|
|
13
13
|
profiles: [],
|
|
14
14
|
verification: { commands: [] },
|
|
15
15
|
features: {
|
|
16
16
|
stopVerification: {
|
|
17
17
|
enabled: false
|
|
18
|
+
},
|
|
19
|
+
completionGate: {
|
|
20
|
+
enabled: false
|
|
18
21
|
}
|
|
19
22
|
},
|
|
20
23
|
pathMappings: [],
|
|
@@ -9,6 +9,10 @@ import { resolveContainedPath } from "../../../runtime/src/fs/paths.js";
|
|
|
9
9
|
import { redactSecrets } from "../../../runtime/src/security/redact.js";
|
|
10
10
|
import { claudeStopRecursionMarker } from "../../../runtime/src/adapters/claude/input.js";
|
|
11
11
|
import { HARNESS_IDS, harnessDescriptor } from "../../../runtime/src/install/harness.js";
|
|
12
|
+
import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
|
|
13
|
+
import { TaskService } from "../../../runtime/src/task/service.js";
|
|
14
|
+
import { FileTaskStore } from "../../../runtime/src/task/store.js";
|
|
15
|
+
import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
|
|
12
16
|
import { STOP_VERIFICATION_ENV, StopVerificationService } from "../../../runtime/src/hooks/stop-service.js";
|
|
13
17
|
import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
|
|
14
18
|
import { normalizeHookInput, runHookCommand, HOOK_EVENTS } from "./commands/hook.js";
|
|
@@ -40,6 +44,16 @@ function parseInput(source) {
|
|
|
40
44
|
return null;
|
|
41
45
|
}
|
|
42
46
|
}
|
|
47
|
+
function withInjectedSession(harness, input) {
|
|
48
|
+
const sessionId = process.env.AGENT_OPS_SESSION_ID;
|
|
49
|
+
return harness === "agy" &&
|
|
50
|
+
sessionId !== undefined &&
|
|
51
|
+
typeof input === "object" &&
|
|
52
|
+
input !== null &&
|
|
53
|
+
!Array.isArray(input)
|
|
54
|
+
? { ...input, conversationId: sessionId }
|
|
55
|
+
: input;
|
|
56
|
+
}
|
|
43
57
|
function writeHookOutput(io, output) {
|
|
44
58
|
if (output.stdout.length > 0) {
|
|
45
59
|
io.writeStdout(output.stdout);
|
|
@@ -195,11 +209,14 @@ function shouldBuildStopVerification(harness, event, config, rawInput) {
|
|
|
195
209
|
return harnessDescriptor(harness).control.registrations.some(({ capability, support }) => capability === "optional-stop-verify" && support !== "unsupported");
|
|
196
210
|
}
|
|
197
211
|
/**
|
|
198
|
-
* Runs one hook invocation.
|
|
199
|
-
*
|
|
212
|
+
* Runs one hook invocation. Exit code stays zero because native JSON carries
|
|
213
|
+
* decisions; only an explicitly installed agy completion gate fails closed.
|
|
200
214
|
*/
|
|
201
215
|
export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
202
216
|
const [harness, event] = argv;
|
|
217
|
+
const completionGateInstalled = harness === "agy" &&
|
|
218
|
+
event === "Stop" &&
|
|
219
|
+
argv.includes("--completion-gate");
|
|
203
220
|
if (harness === undefined ||
|
|
204
221
|
!HARNESSES.has(harness) ||
|
|
205
222
|
event === undefined ||
|
|
@@ -212,13 +229,31 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
212
229
|
const harnessId = harness;
|
|
213
230
|
const hookEvent = event;
|
|
214
231
|
if (process.env.AGENT_OPS_DISABLE === "1") {
|
|
232
|
+
if (completionGateInstalled) {
|
|
233
|
+
writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
|
|
234
|
+
action: "block",
|
|
235
|
+
status: "UNKNOWN",
|
|
236
|
+
code: "COMPLETION_GATE_DISABLE_REJECTED",
|
|
237
|
+
remedy: "Use a user-approved one-time permit instead of disabling agent-ops."
|
|
238
|
+
}));
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
215
241
|
writeHookOutput(io, failOpenOutput(harnessId, hookEvent));
|
|
216
242
|
return 0;
|
|
217
243
|
}
|
|
218
244
|
const rawInput = await readStdin(io.stdin);
|
|
219
|
-
const parsedInput = parseInput(rawInput);
|
|
245
|
+
const parsedInput = withInjectedSession(harnessId, parseInput(rawInput));
|
|
220
246
|
const configOutcome = await hookConfigOutcome(root, dependencies.loadConfig);
|
|
221
247
|
if (configOutcome.kind === "invalid") {
|
|
248
|
+
if (completionGateInstalled) {
|
|
249
|
+
writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
|
|
250
|
+
action: "block",
|
|
251
|
+
status: "UNKNOWN",
|
|
252
|
+
code: "COMPLETION_GATE_CONFIG_INVALID",
|
|
253
|
+
remedy: `Fix ${redactSecrets(configOutcome.path)} and run agent-ops doctor.`
|
|
254
|
+
}));
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
222
257
|
const output = await invalidConfigOutput({
|
|
223
258
|
root,
|
|
224
259
|
harness: harnessId,
|
|
@@ -249,6 +284,17 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
249
284
|
processRunner
|
|
250
285
|
})
|
|
251
286
|
: undefined;
|
|
287
|
+
const completionGate = harnessId === "agy" && config.features.completionGate.enabled
|
|
288
|
+
? dependencies.completionGate ?? {
|
|
289
|
+
handle: async (normalized) => await new CompletionGateService({
|
|
290
|
+
root,
|
|
291
|
+
config,
|
|
292
|
+
gitRunner,
|
|
293
|
+
taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
|
|
294
|
+
evidenceStore: new FileEvidenceStore(root, root)
|
|
295
|
+
}).handle(normalized)
|
|
296
|
+
}
|
|
297
|
+
: undefined;
|
|
252
298
|
const output = await runHookCommand({
|
|
253
299
|
harness: harness,
|
|
254
300
|
event: hookEvent,
|
|
@@ -258,12 +304,20 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
258
304
|
...(dependencies.advisory === undefined
|
|
259
305
|
? {}
|
|
260
306
|
: { advisory: dependencies.advisory }),
|
|
261
|
-
...(stopVerification === undefined ? {} : { stopVerification })
|
|
307
|
+
...(stopVerification === undefined ? {} : { stopVerification }),
|
|
308
|
+
...(completionGate === undefined ? {} : { completionGate })
|
|
262
309
|
});
|
|
263
310
|
writeHookOutput(io, output);
|
|
264
311
|
}
|
|
265
312
|
catch {
|
|
266
|
-
|
|
313
|
+
if (completionGateInstalled) {
|
|
314
|
+
writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
|
|
315
|
+
action: "block",
|
|
316
|
+
status: "UNKNOWN",
|
|
317
|
+
code: "COMPLETION_GATE_UNAVAILABLE",
|
|
318
|
+
remedy: "Run agent-ops doctor; use a user-approved one-time permit only after diagnosis."
|
|
319
|
+
}));
|
|
320
|
+
}
|
|
267
321
|
}
|
|
268
322
|
return 0;
|
|
269
323
|
}
|
|
@@ -217,7 +217,9 @@ export async function selectOption(question, choices, io, defaultIndex = 0) {
|
|
|
217
217
|
export async function selectOptions(question, choices, io, defaultValues = [], options = {}) {
|
|
218
218
|
assertChoices(choices);
|
|
219
219
|
const defaultIndexes = new Set(choices.flatMap((choice, index) => defaultValues.includes(choice.value) ? [index] : []));
|
|
220
|
-
if (defaultIndexes.size === 0 &&
|
|
220
|
+
if (defaultIndexes.size === 0 &&
|
|
221
|
+
options.selectAll !== true &&
|
|
222
|
+
options.startEmpty !== true) {
|
|
221
223
|
defaultIndexes.add(0);
|
|
222
224
|
}
|
|
223
225
|
if (typeof io.input.setRawMode !== "function" ||
|
|
@@ -15,11 +15,14 @@ const REVIEW_TARGET_CHOICES = DEFAULT_REVIEW_TARGETS.map((id) => ({
|
|
|
15
15
|
? "Runs in sandboxed plan mode against a disposable clone."
|
|
16
16
|
: "Runs in fresh safe mode with context isolation."
|
|
17
17
|
}));
|
|
18
|
-
function selectReviewTargets(raw) {
|
|
18
|
+
function selectReviewTargets(raw, defaults = []) {
|
|
19
19
|
const values = raw
|
|
20
20
|
.split(",")
|
|
21
21
|
.map((value) => value.trim())
|
|
22
22
|
.filter((value) => value.length > 0);
|
|
23
|
+
if (values.length === 0) {
|
|
24
|
+
return REVIEW_TARGET_ORDER.filter((target) => defaults.includes(target));
|
|
25
|
+
}
|
|
23
26
|
for (const value of values) {
|
|
24
27
|
if (!REVIEW_TARGET_SET.has(value)) {
|
|
25
28
|
throw new CliArgumentError("CLI_INVALID_VALUE", `Invalid review target: ${value}`, "--review-target");
|
|
@@ -31,6 +34,11 @@ function selectReviewTargets(raw) {
|
|
|
31
34
|
function affirmative(raw) {
|
|
32
35
|
return /^(y|yes)$/i.test(raw.trim());
|
|
33
36
|
}
|
|
37
|
+
function completionGateEligible(scope, harness, profiles) {
|
|
38
|
+
return scope === "project" &&
|
|
39
|
+
harness.includes("agy") &&
|
|
40
|
+
profiles.includes("loop");
|
|
41
|
+
}
|
|
34
42
|
async function probeReviewTargets(targets, setup) {
|
|
35
43
|
const probe = setup.probeReviewTarget;
|
|
36
44
|
if (probe === undefined) {
|
|
@@ -68,10 +76,10 @@ const PROFILE_CHOICES = [
|
|
|
68
76
|
{
|
|
69
77
|
label: "loop",
|
|
70
78
|
value: "loop",
|
|
71
|
-
description: "Installs the
|
|
79
|
+
description: "Installs the local loop; agy uses its supported lifecycle subset and is reported as degraded."
|
|
72
80
|
}
|
|
73
81
|
];
|
|
74
|
-
const WIZARD_SUBTITLE = "Safe setup for Codex, Claude Code, and opencode with profile-driven rules, verification, and hooks.";
|
|
82
|
+
const WIZARD_SUBTITLE = "Safe setup for agy, Codex, Claude Code, and opencode with profile-driven rules, verification, and hooks.";
|
|
75
83
|
async function createPromptSession(io) {
|
|
76
84
|
if (!io.isTTY) {
|
|
77
85
|
throw new CliArgumentError("CLI_INTERACTIVE_REQUIRED", "Missing init choices require an interactive terminal.");
|
|
@@ -146,6 +154,7 @@ export async function completeInitChoices(args, io, setup = {}) {
|
|
|
146
154
|
const scope = args.scope ?? await selectOption("Scope", SCOPE_CHOICES, selectorIo);
|
|
147
155
|
const harness = args.harness ??
|
|
148
156
|
await selectOptions("Harness (multi-select: ↑↓ move, Space toggle, Enter confirm)", HARNESS_CHOICES, selectorIo, [...DEFAULT_HARNESS], {
|
|
157
|
+
startEmpty: true,
|
|
149
158
|
selectAll: true,
|
|
150
159
|
selectAllLabel: "Select all",
|
|
151
160
|
selectAllDescription: "Install for every supported harness."
|
|
@@ -157,6 +166,20 @@ export async function completeInitChoices(args, io, setup = {}) {
|
|
|
157
166
|
selectAllLabel: "Select all",
|
|
158
167
|
selectAllDescription: "Enable core, advisory, guardrails, and loop together."
|
|
159
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);
|
|
160
183
|
const enabled = args.reviewTargets !== undefined ||
|
|
161
184
|
(await selectOption("External review: call another agent CLI to review your work?", [
|
|
162
185
|
{ label: "no", value: false, description: "Default. Nothing is spawned." },
|
|
@@ -166,8 +189,9 @@ export async function completeInitChoices(args, io, setup = {}) {
|
|
|
166
189
|
description: "Pick target CLIs; each is probed for authentication."
|
|
167
190
|
}
|
|
168
191
|
], selectorIo));
|
|
169
|
-
const
|
|
170
|
-
|
|
192
|
+
const eligibleReviewChoices = REVIEW_TARGET_CHOICES.filter((choice) => harness.includes(choice.value));
|
|
193
|
+
const reviewTargets = args.reviewTargets ?? (enabled && eligibleReviewChoices.length > 0
|
|
194
|
+
? await selectOptions("Review targets (multi-select: tried in listed order)", eligibleReviewChoices, selectorIo, REVIEW_TARGET_ORDER.filter((target) => harness.includes(target)), { startEmpty: true })
|
|
171
195
|
: []);
|
|
172
196
|
await probeReviewTargets(reviewTargets, setup);
|
|
173
197
|
return {
|
|
@@ -175,6 +199,7 @@ export async function completeInitChoices(args, io, setup = {}) {
|
|
|
175
199
|
scope,
|
|
176
200
|
harness,
|
|
177
201
|
profiles,
|
|
202
|
+
...(completionGate ? { completionGate: true } : {}),
|
|
178
203
|
...(reviewTargets.length === 0 ? {} : { reviewTargets })
|
|
179
204
|
};
|
|
180
205
|
}
|
|
@@ -187,8 +212,12 @@ export async function completeInitChoices(args, io, setup = {}) {
|
|
|
187
212
|
const profiles = args.profiles.length > 0
|
|
188
213
|
? args.profiles
|
|
189
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);
|
|
218
|
+
const defaultReviewTargets = REVIEW_TARGET_ORDER.filter((target) => harness.includes(target));
|
|
190
219
|
const reviewTargets = args.reviewTargets ?? (affirmative(await session.question("Enable external review by another agent CLI? [y/N]: "))
|
|
191
|
-
? selectReviewTargets(await session.question(`Review targets (${
|
|
220
|
+
? selectReviewTargets(await session.question(`Review targets (${defaultReviewTargets.join(",") || "none"}): `), defaultReviewTargets)
|
|
192
221
|
: []);
|
|
193
222
|
await probeReviewTargets(reviewTargets, setup);
|
|
194
223
|
return {
|
|
@@ -196,6 +225,7 @@ export async function completeInitChoices(args, io, setup = {}) {
|
|
|
196
225
|
scope,
|
|
197
226
|
harness,
|
|
198
227
|
profiles,
|
|
228
|
+
...(completionGate ? { completionGate: true } : {}),
|
|
199
229
|
...(reviewTargets.length === 0 ? {} : { reviewTargets })
|
|
200
230
|
};
|
|
201
231
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { AgentOpsError } from "../../fs/paths.js";
|
|
2
|
+
const MARKER = "--managed-by=agent-ops";
|
|
3
|
+
function record(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
5
|
+
? value
|
|
6
|
+
: null;
|
|
7
|
+
}
|
|
8
|
+
function handler(runtimePath, event, platform, completionGate = false) {
|
|
9
|
+
const gateFlag = completionGate ? " --completion-gate" : "";
|
|
10
|
+
const command = platform === "win32"
|
|
11
|
+
? `cmd /c node ${JSON.stringify(runtimePath).replaceAll("\\\\", "\\")} agy ${event}${gateFlag} ${MARKER}`
|
|
12
|
+
: `node ${JSON.stringify(runtimePath)} agy ${event}${gateFlag} ${MARKER}`;
|
|
13
|
+
return {
|
|
14
|
+
type: "command",
|
|
15
|
+
command,
|
|
16
|
+
timeout: 30
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function exactKeys(value, keys) {
|
|
20
|
+
return Object.keys(value).length === keys.length && keys.every((key) => key in value);
|
|
21
|
+
}
|
|
22
|
+
function managedHandler(value, event) {
|
|
23
|
+
const candidate = record(value);
|
|
24
|
+
return candidate !== null &&
|
|
25
|
+
exactKeys(candidate, ["type", "command", "timeout"]) &&
|
|
26
|
+
candidate.type === "command" &&
|
|
27
|
+
candidate.timeout === 30 &&
|
|
28
|
+
typeof candidate.command === "string" &&
|
|
29
|
+
(candidate.command.startsWith("node \"") ||
|
|
30
|
+
candidate.command.startsWith("cmd /c node \"")) &&
|
|
31
|
+
(candidate.command.endsWith(` agy ${event} ${MARKER}`) ||
|
|
32
|
+
(event === "Stop" &&
|
|
33
|
+
candidate.command.endsWith(` agy Stop --completion-gate ${MARKER}`)));
|
|
34
|
+
}
|
|
35
|
+
function managedEvent(value, event) {
|
|
36
|
+
if (!Array.isArray(value) || value.length !== 1)
|
|
37
|
+
return false;
|
|
38
|
+
if (event !== "PreToolUse") {
|
|
39
|
+
return managedHandler(value[0], event === "PreInvocation" ? "SessionStart" : "Stop");
|
|
40
|
+
}
|
|
41
|
+
const group = record(value[0]);
|
|
42
|
+
return group !== null &&
|
|
43
|
+
exactKeys(group, ["matcher", "hooks"]) &&
|
|
44
|
+
group.matcher === "run_command" &&
|
|
45
|
+
Array.isArray(group.hooks) &&
|
|
46
|
+
group.hooks.length === 1 &&
|
|
47
|
+
managedHandler(group.hooks[0], "PreToolUse");
|
|
48
|
+
}
|
|
49
|
+
export function buildAgyHookSettings(capabilities, runtimePath, platform = process.platform) {
|
|
50
|
+
if (runtimePath.length === 0 ||
|
|
51
|
+
runtimePath.length > 4096 ||
|
|
52
|
+
/[\0\r\n]/u.test(runtimePath) ||
|
|
53
|
+
(platform === "win32" && runtimePath.includes('"'))) {
|
|
54
|
+
throw new AgentOpsError("AGY_HOOK_PATH_INVALID", "agy hook runtime path is invalid.");
|
|
55
|
+
}
|
|
56
|
+
const hooks = {};
|
|
57
|
+
if (capabilities.includes("lifecycle-summary") || capabilities.includes("project-loop")) {
|
|
58
|
+
hooks.PreInvocation = [handler(runtimePath, "SessionStart", platform)];
|
|
59
|
+
}
|
|
60
|
+
if (capabilities.includes("command-policy") || capabilities.includes("project-loop")) {
|
|
61
|
+
hooks.PreToolUse = [{ matcher: "run_command", hooks: [handler(runtimePath, "PreToolUse", platform)] }];
|
|
62
|
+
}
|
|
63
|
+
if (capabilities.includes("optional-stop-verify") ||
|
|
64
|
+
capabilities.includes("completion-gate")) {
|
|
65
|
+
hooks.Stop = [handler(runtimePath, "Stop", platform, capabilities.includes("completion-gate"))];
|
|
66
|
+
}
|
|
67
|
+
return { hooks };
|
|
68
|
+
}
|
|
69
|
+
export function isAgyManagedHook(value) {
|
|
70
|
+
const named = record(value)?.["agent-ops"];
|
|
71
|
+
const hook = record(named);
|
|
72
|
+
if (hook === null || hook.enabled !== true)
|
|
73
|
+
return false;
|
|
74
|
+
const events = Object.keys(hook).filter((key) => key !== "enabled");
|
|
75
|
+
return events.length > 0 &&
|
|
76
|
+
events.every((event) => (event === "PreInvocation" || event === "PreToolUse" || event === "Stop") &&
|
|
77
|
+
managedEvent(hook[event], event));
|
|
78
|
+
}
|
|
79
|
+
export function isAgyHookRegistered(value, capabilities) {
|
|
80
|
+
const expected = buildAgyHookSettings(capabilities, "probe").hooks;
|
|
81
|
+
if (Object.keys(expected).length === 0)
|
|
82
|
+
return true;
|
|
83
|
+
const named = record(record(value)?.["agent-ops"]);
|
|
84
|
+
if (named === null || !isAgyManagedHook(value))
|
|
85
|
+
return false;
|
|
86
|
+
const eventsMatch = Object.keys(expected)
|
|
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}`);
|
|
94
|
+
}
|
|
95
|
+
export function mergeAgyHooks(existing, managed) {
|
|
96
|
+
const source = record(existing);
|
|
97
|
+
if (source === null) {
|
|
98
|
+
throw new AgentOpsError("AGY_HOOKS_INVALID", "agy hooks must be a JSON object.");
|
|
99
|
+
}
|
|
100
|
+
if (source["agent-ops"] !== undefined && !isAgyManagedHook(source)) {
|
|
101
|
+
throw new AgentOpsError("AGY_HOOK_NAME_CONFLICT", "Refusing to replace the existing non-agent-ops hook named agent-ops.");
|
|
102
|
+
}
|
|
103
|
+
return { ...source, "agent-ops": { enabled: true, ...managed.hooks } };
|
|
104
|
+
}
|
|
105
|
+
export function stripAgyHooks(existing) {
|
|
106
|
+
const source = record(existing);
|
|
107
|
+
if (source === null) {
|
|
108
|
+
throw new AgentOpsError("AGY_HOOKS_INVALID", "agy hooks must be a JSON object.");
|
|
109
|
+
}
|
|
110
|
+
if (source["agent-ops"] !== undefined && !isAgyManagedHook(source)) {
|
|
111
|
+
throw new AgentOpsError("AGY_HOOK_NAME_CONFLICT", "The agent-ops hook is not owned by agent-ops.");
|
|
112
|
+
}
|
|
113
|
+
const { "agent-ops": _owned, ...rest } = source;
|
|
114
|
+
return rest;
|
|
115
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const AGY_CAPABILITY_REGISTRATIONS = [
|
|
2
|
+
{
|
|
3
|
+
capability: "lifecycle-summary",
|
|
4
|
+
normalizedEvent: "session-start",
|
|
5
|
+
nativeEvent: "SessionStart",
|
|
6
|
+
hostEvent: "PreInvocation",
|
|
7
|
+
surfaceId: "agy-hooks",
|
|
8
|
+
support: "degraded",
|
|
9
|
+
runtimeFailure: "fail-open"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
capability: "command-policy",
|
|
13
|
+
normalizedEvent: "command",
|
|
14
|
+
nativeEvent: "PreToolUse",
|
|
15
|
+
surfaceId: "agy-hooks",
|
|
16
|
+
support: "supported",
|
|
17
|
+
runtimeFailure: "fail-closed"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
capability: "completion-gate",
|
|
21
|
+
normalizedEvent: "stop",
|
|
22
|
+
nativeEvent: "Stop",
|
|
23
|
+
surfaceId: "agy-hooks",
|
|
24
|
+
support: "supported",
|
|
25
|
+
runtimeFailure: "fail-closed"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
capability: "optional-stop-verify",
|
|
29
|
+
normalizedEvent: "stop",
|
|
30
|
+
nativeEvent: "Stop",
|
|
31
|
+
surfaceId: "agy-hooks",
|
|
32
|
+
support: "degraded",
|
|
33
|
+
runtimeFailure: "fail-open"
|
|
34
|
+
}
|
|
35
|
+
];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { normalizeHookEvent } from "../../hooks/normalize.js";
|
|
2
|
+
import { normalizeShellHookEvent } from "../../hooks/shell.js";
|
|
3
|
+
function record(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
5
|
+
? value
|
|
6
|
+
: null;
|
|
7
|
+
}
|
|
8
|
+
export function normalizeAgyHookInput(input) {
|
|
9
|
+
const value = record(input);
|
|
10
|
+
if (value === null)
|
|
11
|
+
return normalizeHookEvent(input);
|
|
12
|
+
const workspacePaths = Array.isArray(value.workspacePaths)
|
|
13
|
+
? value.workspacePaths
|
|
14
|
+
: [];
|
|
15
|
+
const projectRoot = typeof workspacePaths[0] === "string"
|
|
16
|
+
? workspacePaths[0]
|
|
17
|
+
: undefined;
|
|
18
|
+
const sessionId = typeof value.conversationId === "string"
|
|
19
|
+
? value.conversationId
|
|
20
|
+
: undefined;
|
|
21
|
+
const toolCall = record(value.toolCall);
|
|
22
|
+
const args = record(toolCall?.args);
|
|
23
|
+
if (toolCall?.name === "run_command" && typeof args?.CommandLine === "string") {
|
|
24
|
+
return {
|
|
25
|
+
...normalizeShellHookEvent(args.CommandLine, projectRoot),
|
|
26
|
+
...(sessionId === undefined ? {} : { sessionId })
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (typeof value.terminationReason === "string") {
|
|
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
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (typeof value.invocationNum === "number") {
|
|
41
|
+
return {
|
|
42
|
+
event: "session-start",
|
|
43
|
+
projectRoot: projectRoot ?? process.cwd(),
|
|
44
|
+
...(sessionId === undefined ? {} : { sessionId })
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return normalizeHookEvent({ event: "unsupported", projectRoot });
|
|
48
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function agyHookOutput(event, result) {
|
|
2
|
+
if (result.action === "continue" && result.status === "PASS") {
|
|
3
|
+
return {
|
|
4
|
+
exitCode: 0,
|
|
5
|
+
stdout: event === "PreToolUse" || event === "Stop"
|
|
6
|
+
? JSON.stringify({ decision: "allow" })
|
|
7
|
+
: "{}",
|
|
8
|
+
stderr: ""
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
const reason = result.remedy === undefined
|
|
12
|
+
? `agent-ops: ${result.code}`
|
|
13
|
+
: `agent-ops: ${result.code}: ${result.remedy}`;
|
|
14
|
+
const value = event === "PreToolUse"
|
|
15
|
+
? result.code === "COMPLETION_GATE_PERMIT_CONFIRMATION"
|
|
16
|
+
? { decision: "force_ask", reason }
|
|
17
|
+
: result.action === "block"
|
|
18
|
+
? { decision: "deny", reason }
|
|
19
|
+
: { decision: "allow" }
|
|
20
|
+
: event === "SessionStart"
|
|
21
|
+
? { injectSteps: [{ ephemeralMessage: reason }] }
|
|
22
|
+
: event === "Stop"
|
|
23
|
+
? result.action === "block" && result.code.startsWith("COMPLETION_GATE_")
|
|
24
|
+
? { decision: "continue", reason }
|
|
25
|
+
: { decision: "allow", reason }
|
|
26
|
+
: {};
|
|
27
|
+
return { exitCode: 0, stdout: JSON.stringify(value), stderr: "" };
|
|
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) {
|