@kylecheng3146/agent-ops 0.1.18 → 0.1.20
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 +16 -9
- package/dist/packages/cli/src/bin.js +59 -9
- package/dist/packages/cli/src/cli.js +1 -1
- package/dist/packages/cli/src/commands/init.js +39 -9
- package/dist/packages/cli/src/commands/review.js +3 -0
- package/dist/packages/cli/src/commands/uninstall.js +20 -3
- package/dist/packages/cli/src/ui.js +3 -1
- package/dist/packages/cli/src/wizard.js +12 -6
- package/dist/runtime/src/adapters/agy/config.js +105 -0
- package/dist/runtime/src/adapters/agy/events.js +27 -0
- package/dist/runtime/src/adapters/agy/input.js +30 -0
- package/dist/runtime/src/adapters/agy/output.js +24 -0
- package/dist/runtime/src/adapters/agy/surfaces.js +9 -0
- package/dist/runtime/src/fs/managed-block.js +23 -10
- package/dist/runtime/src/install/codex-loop.js +6 -2
- package/dist/runtime/src/install/doctor.js +15 -0
- package/dist/runtime/src/install/harness.js +52 -1
- package/dist/runtime/src/install/hooks.js +6 -1
- package/dist/runtime/src/install/ownership.js +12 -8
- package/dist/runtime/src/install/plan.js +2 -2
- package/dist/runtime/src/install/probes.js +61 -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 +2 -2
- package/docs/en/guides/configuration.md +22 -6
- package/docs/en/spec/README.md +5 -2
- package/docs/en/spec/harness-adapters.md +20 -9
- package/docs/en/spec/review.md +6 -0
- package/docs/zh-TW/guides/configuration.md +20 -6
- package/docs/zh-TW/spec/README.md +4 -2
- package/docs/zh-TW/spec/harness-adapters.md +18 -10
- package/docs/zh-TW/spec/review.md +5 -0
- package/package.json +2 -2
- package/schemas/manifest.schema.json +2 -2
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Loop Engineering Toolkit
|
|
2
2
|
|
|
3
3
|
Loop Engineering Toolkit is an evidence-driven development-loop toolkit for
|
|
4
|
-
Codex, Claude Code, and opencode. It is designed to turn acceptance criteria, explicit
|
|
4
|
+
agy, Codex, Claude Code, and opencode. It is designed to turn acceptance criteria, explicit
|
|
5
5
|
verification, safe lifecycle hooks, and independent review into a repeatable
|
|
6
6
|
engineering workflow.
|
|
7
7
|
|
|
@@ -34,9 +34,9 @@ automation. The wizard never writes files until you review and confirm its
|
|
|
34
34
|
installation plan.
|
|
35
35
|
|
|
36
36
|
The interactive multi-select screens start with no harness or profile selected.
|
|
37
|
-
Choose at least one of `codex`, `claude`, and `opencode`, and at least one of
|
|
37
|
+
Choose at least one of `agy`, `codex`, `claude`, and `opencode`, and at least one of
|
|
38
38
|
the `core`, `advisory`, `guardrails`, and `loop` profiles before confirming. For
|
|
39
|
-
scripted use, `--harness all` selects all
|
|
39
|
+
scripted use, `--harness all` selects all four harnesses; comma-separated
|
|
40
40
|
selections such as `codex,opencode` are also supported. The legacy `both` value
|
|
41
41
|
remains an alias for `codex,claude`.
|
|
42
42
|
|
|
@@ -66,7 +66,10 @@ agent-ops uninstall --dry-run --json
|
|
|
66
66
|
|
|
67
67
|
### Project-local loop
|
|
68
68
|
|
|
69
|
-
`loop` is an explicit, project-only profile for Codex, Claude Code
|
|
69
|
+
`loop` is an explicit, project-only profile for agy, Codex, and Claude Code.
|
|
70
|
+
agy installs its native `PreInvocation`, `PreToolUse(run_command)`, and optional
|
|
71
|
+
`Stop` subset directly in `.agents/hooks.json`; doctor reports its loop as
|
|
72
|
+
degraded because prompt, permission, compact, and subagent events are unavailable.
|
|
70
73
|
Claude Code supports native Windows through a generated PowerShell launcher;
|
|
71
74
|
Codex's loop launcher still requires POSIX-compatible `bash`. Preview it
|
|
72
75
|
first, then install only after reviewing the plan. `loop` also implies the
|
|
@@ -286,13 +289,17 @@ to a deterministic worktree (or `--base`) scope. `--task` reviews keep their
|
|
|
286
289
|
task criteria and require current verification evidence. The detailed report
|
|
287
290
|
is displayed; a minimal source-fingerprint attestation is persisted after PASS.
|
|
288
291
|
|
|
289
|
-
Each attempt uses a fresh temporary
|
|
290
|
-
target-native read-only mode. Claude
|
|
291
|
-
|
|
292
|
-
|
|
292
|
+
Each attempt uses a fresh temporary session and disposable repository clone, a
|
|
293
|
+
small allowlisted environment, and a target-native read-only mode. Claude
|
|
294
|
+
additionally uses its complete safe mode; Codex ignores user config and
|
|
295
|
+
persistence, while Agy runs in sandboxed plan mode. Codex and Agy preserve their
|
|
293
296
|
existing login environment, so their context isolation is intentionally weaker
|
|
294
297
|
than Claude's. `opencode` is not a review target.
|
|
295
298
|
|
|
299
|
+
The chain prefers a reviewer CLI different from the hosting CLI. If none is
|
|
300
|
+
usable, a fresh same-target session is allowed but is rendered as
|
|
301
|
+
`DEGRADED: isolated self-review`; a development session is never resumed.
|
|
302
|
+
|
|
296
303
|
The first valid `PASS` or `FAIL` is final. Attempts that produce no verdict —
|
|
297
304
|
including login failures and unparseable output — advance to the next target
|
|
298
305
|
and remain visible in the result's `attempts`. `--yes` is still required for
|
|
@@ -313,7 +320,7 @@ See [Configuration](docs/en/guides/configuration.md) for the full contract.
|
|
|
313
320
|
- Treat command output and current filesystem state as evidence.
|
|
314
321
|
- Keep advisory automation separate from blocking guardrails.
|
|
315
322
|
- Preserve user configuration through managed, reversible updates.
|
|
316
|
-
- Support Codex, Claude Code, and opencode without project-specific assumptions.
|
|
323
|
+
- Support agy, Codex, Claude Code, and opencode without project-specific assumptions.
|
|
317
324
|
- Collect no network telemetry.
|
|
318
325
|
|
|
319
326
|
## Project status
|
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { commonHarnessAdapters, harnessHookPath, HARNESS_IDS } from "../../../runtime/src/install/harness.js";
|
|
8
|
-
import { hookRegistrationDrift, repositoryTrustStatus, smokeAvailabilityStatus } from "../../../runtime/src/install/probes.js";
|
|
8
|
+
import { agyRuntimeStatus, agyVersionSupported, hookRegistrationDrift, repositoryTrustStatus, smokeAvailabilityStatus } from "../../../runtime/src/install/probes.js";
|
|
9
9
|
import { parseInstallManifest } from "../../../runtime/src/fs/manifest.js";
|
|
10
10
|
import { NpmRegistryClient } from "../../../runtime/src/registry/npm.js";
|
|
11
11
|
import { TaskService } from "../../../runtime/src/task/service.js";
|
|
@@ -92,13 +92,13 @@ function gitRunner(root) {
|
|
|
92
92
|
}
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
|
-
async function confirmInit(plan, trust) {
|
|
95
|
+
async function confirmInit(plan, trust, warnings = []) {
|
|
96
96
|
writeBanner({
|
|
97
97
|
isTTY: process.stdout.isTTY === true,
|
|
98
98
|
columns: process.stdout.columns,
|
|
99
99
|
write: (value) => process.stdout.write(value)
|
|
100
100
|
});
|
|
101
|
-
return await confirmPlan(formatInstallPlan(plan, trust));
|
|
101
|
+
return await confirmPlan(formatInstallPlan(plan, trust, warnings));
|
|
102
102
|
}
|
|
103
103
|
async function confirmPlan(text) {
|
|
104
104
|
process.stdout.write(text);
|
|
@@ -115,6 +115,13 @@ async function plannedTrustBinding(root, projectConfig) {
|
|
|
115
115
|
: await repositoryTrustBinding(root, config, CLI_VERSION);
|
|
116
116
|
}
|
|
117
117
|
const argv = process.argv.slice(2);
|
|
118
|
+
function runAgy(args, options = {}) {
|
|
119
|
+
return execFileSync("agy", [...args], {
|
|
120
|
+
...options,
|
|
121
|
+
encoding: "utf8",
|
|
122
|
+
...(process.platform === "win32" ? { shell: true } : {})
|
|
123
|
+
});
|
|
124
|
+
}
|
|
118
125
|
if (argv[0] === "hook") {
|
|
119
126
|
process.exitCode = await runHookProcess(argv.slice(1), {
|
|
120
127
|
stdin: process.stdin,
|
|
@@ -157,20 +164,53 @@ else {
|
|
|
157
164
|
isTTY,
|
|
158
165
|
toolkitVersion: CLI_VERSION,
|
|
159
166
|
hookRuntimePath: HOOK_RUNTIME_PATH,
|
|
167
|
+
agyWarning: () => {
|
|
168
|
+
try {
|
|
169
|
+
const output = runAgy(["--version"], { timeout: 5_000 });
|
|
170
|
+
return agyVersionSupported(output)
|
|
171
|
+
? undefined
|
|
172
|
+
: "agy is installed, but version 1.1.12 or newer is required; run `agent-ops doctor` after updating.";
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return "agy is not installed or could not be started; install agy 1.1.12 or newer, then run `agent-ops doctor`.";
|
|
176
|
+
}
|
|
177
|
+
},
|
|
160
178
|
...(args.hookTargets === undefined
|
|
161
179
|
? {}
|
|
162
180
|
: { hookTargets: args.hookTargets }),
|
|
163
181
|
trustStore: store,
|
|
164
182
|
calculateTrustBinding: async (config) => await plannedTrustBinding(root, config),
|
|
165
|
-
confirm: async (plan, trust) => await confirmInit(plan, trust)
|
|
183
|
+
confirm: async (plan, trust, warnings) => await confirmInit(plan, trust, warnings)
|
|
166
184
|
});
|
|
167
185
|
}
|
|
168
186
|
if (args.command === "doctor") {
|
|
187
|
+
const doctorManifest = await installedManifest(root);
|
|
169
188
|
const config = (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config;
|
|
170
189
|
return await runDoctorCommand({
|
|
171
190
|
root,
|
|
172
191
|
toolkitVersion: CLI_VERSION,
|
|
173
192
|
probes: {
|
|
193
|
+
...(doctorManifest?.harness.includes("agy") === true
|
|
194
|
+
? {
|
|
195
|
+
agyRuntime: () => {
|
|
196
|
+
try {
|
|
197
|
+
return agyRuntimeStatus(runAgy(["--version"]), runAgy([
|
|
198
|
+
"-p", "/hooks", "--output-format", "json",
|
|
199
|
+
...(doctorManifest.scope === "project"
|
|
200
|
+
? ["--new-project"]
|
|
201
|
+
: [])
|
|
202
|
+
], { cwd: root }), doctorManifest.hooks?.find(({ harness }) => harness === "agy")?.events ?? []);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return {
|
|
206
|
+
status: "FAIL",
|
|
207
|
+
message: "agy is missing or could not report its loaded hooks.",
|
|
208
|
+
remediation: "Install agy 1.1.12 or newer and run `agent-ops doctor` again."
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
: {}),
|
|
174
214
|
hookRegistration: async () => {
|
|
175
215
|
const drifted = hookRegistrationDrift({
|
|
176
216
|
harness: await installedHarness(root),
|
|
@@ -246,23 +286,33 @@ else {
|
|
|
246
286
|
}
|
|
247
287
|
if (args.command === "update") {
|
|
248
288
|
const store = trustStore();
|
|
289
|
+
const manifest = await installedManifest(root);
|
|
290
|
+
const addAgy = isTTY &&
|
|
291
|
+
!args.yes &&
|
|
292
|
+
args.harness === undefined &&
|
|
293
|
+
manifest !== null &&
|
|
294
|
+
!manifest.harness.includes("agy") &&
|
|
295
|
+
await selectYesNo("Add newly supported harness: agy?", { input: process.stdin, output: process.stdout }, false);
|
|
296
|
+
const updateArgs = addAgy
|
|
297
|
+
? { ...args, harness: [...(manifest?.harness ?? []), "agy"] }
|
|
298
|
+
: args;
|
|
249
299
|
return await runUpdateCommand({
|
|
250
|
-
args,
|
|
300
|
+
args: updateArgs,
|
|
251
301
|
root,
|
|
252
302
|
adapters: commonHarnessAdapters(),
|
|
253
303
|
registry: new NpmRegistryClient(),
|
|
254
304
|
isTTY,
|
|
255
305
|
toolkitVersion: CLI_VERSION,
|
|
256
306
|
hookRuntimePath: HOOK_RUNTIME_PATH,
|
|
257
|
-
...(
|
|
307
|
+
...(updateArgs.hookTargets === undefined
|
|
258
308
|
? {}
|
|
259
|
-
: { hookTargets:
|
|
309
|
+
: { hookTargets: updateArgs.hookTargets }),
|
|
260
310
|
trustStore: store,
|
|
261
311
|
calculateTrustBinding: async (config) => await plannedTrustBinding(root, config),
|
|
262
312
|
confirm: async (plan, trust) => await confirmPlan(formatUpdatePlan(plan, trust)),
|
|
263
|
-
...(
|
|
313
|
+
...(updateArgs.targetVersion === undefined
|
|
264
314
|
? {}
|
|
265
|
-
: { targetVersion:
|
|
315
|
+
: { targetVersion: updateArgs.targetVersion })
|
|
266
316
|
});
|
|
267
317
|
}
|
|
268
318
|
const taskService = new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root));
|
|
@@ -30,7 +30,7 @@ Commands:
|
|
|
30
30
|
|
|
31
31
|
Options:
|
|
32
32
|
--scope <project|user>
|
|
33
|
-
--harness <all|both|claude|codex|opencode|comma-separated> Init/update
|
|
33
|
+
--harness <all|both|agy|claude|codex|opencode|comma-separated> Init/update/uninstall
|
|
34
34
|
--hook-target <harness=surface-id> Repeatable advanced init/update option
|
|
35
35
|
--profile <core|advisory|guardrails|loop> Repeatable
|
|
36
36
|
--review-target <codex|agy|claude> Repeatable init option; external review
|
|
@@ -5,7 +5,7 @@ import { okEnvelope } from "../output.js";
|
|
|
5
5
|
import { formatOperationPlan } from "../plan-output.js";
|
|
6
6
|
import { toPublicInstallPlan } from "../public-plan.js";
|
|
7
7
|
import { formatTrustChange, planTrustGrant } from "./trust.js";
|
|
8
|
-
export function formatInstallPlan(plan, trust) {
|
|
8
|
+
export function formatInstallPlan(plan, trust, warnings = []) {
|
|
9
9
|
const hooks = plan.manifest.hooks ?? [];
|
|
10
10
|
const detectedVerification = plan.detectedVerification;
|
|
11
11
|
return formatOperationPlan({
|
|
@@ -14,6 +14,12 @@ export function formatInstallPlan(plan, trust) {
|
|
|
14
14
|
`Scope: ${plan.scope}`,
|
|
15
15
|
`Harness: ${plan.harness}`,
|
|
16
16
|
`Profiles: ${plan.profiles.join(", ")}`,
|
|
17
|
+
...(plan.scope === "user" && plan.harness.includes("agy")
|
|
18
|
+
? ["Notice: .gemini/GEMINI.md is a shared Gemini rule surface."]
|
|
19
|
+
: []),
|
|
20
|
+
...(warnings.length === 0
|
|
21
|
+
? []
|
|
22
|
+
: ["Warnings:", ...warnings.map((warning) => ` - ${warning}`)]),
|
|
17
23
|
...(trust === undefined ? [] : formatTrustChange(trust)),
|
|
18
24
|
...(hooks.length === 0
|
|
19
25
|
? ["Hooks: none selected"]
|
|
@@ -42,11 +48,19 @@ function appliedMessage(plan) {
|
|
|
42
48
|
...hooks.map((hook) => `- ${hook.harness}: ${hook.path} (${hook.events.join(", ")})`)
|
|
43
49
|
].join("\n");
|
|
44
50
|
}
|
|
45
|
-
function initError(code, message, plan, trust, applied = false) {
|
|
51
|
+
function initError(code, message, plan, trust, warnings = [], applied = false) {
|
|
52
|
+
const displayMessage = warnings.length === 0
|
|
53
|
+
? message
|
|
54
|
+
: `${message}\nWarnings:\n${warnings.map((warning) => `- ${warning}`).join("\n")}`;
|
|
46
55
|
return {
|
|
47
56
|
code,
|
|
48
57
|
status: "error",
|
|
49
|
-
data: {
|
|
58
|
+
data: {
|
|
59
|
+
applied,
|
|
60
|
+
plan: toPublicInstallPlan(plan, trust),
|
|
61
|
+
message: displayMessage,
|
|
62
|
+
...(warnings.length === 0 ? {} : { warnings })
|
|
63
|
+
},
|
|
50
64
|
errors: [{ code, message }]
|
|
51
65
|
};
|
|
52
66
|
}
|
|
@@ -91,21 +105,33 @@ export async function runInitCommand(options) {
|
|
|
91
105
|
: { reviewTargets: args.reviewTargets })
|
|
92
106
|
});
|
|
93
107
|
const trust = await trustChange(options, plan);
|
|
108
|
+
const warnings = plan.harness.includes("agy") && options.agyWarning !== undefined
|
|
109
|
+
? (() => {
|
|
110
|
+
try {
|
|
111
|
+
const warning = options.agyWarning();
|
|
112
|
+
return warning === undefined ? [] : [warning];
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return ["agy could not be probed; run `agent-ops doctor` to verify it."];
|
|
116
|
+
}
|
|
117
|
+
})()
|
|
118
|
+
: [];
|
|
94
119
|
if (args.dryRun) {
|
|
95
120
|
return okEnvelope("INIT_PLAN_READY", {
|
|
96
121
|
applied: false,
|
|
97
122
|
plan: toPublicInstallPlan(plan, trust),
|
|
98
123
|
message: "Installation plan calculated; no files were written.",
|
|
99
|
-
|
|
124
|
+
...(warnings.length === 0 ? {} : { warnings }),
|
|
125
|
+
text: formatInstallPlan(plan, trust, warnings)
|
|
100
126
|
});
|
|
101
127
|
}
|
|
102
128
|
if (!args.yes && !options.isTTY) {
|
|
103
|
-
return initError("INIT_CONFIRMATION_REQUIRED", "Non-interactive init requires --yes after all choices are explicit.", plan, trust);
|
|
129
|
+
return initError("INIT_CONFIRMATION_REQUIRED", "Non-interactive init requires --yes after all choices are explicit.", plan, trust, warnings);
|
|
104
130
|
}
|
|
105
131
|
if (!args.yes &&
|
|
106
132
|
options.isTTY &&
|
|
107
|
-
!(await options.confirm(plan, trust))) {
|
|
108
|
-
return initError("INIT_CANCELLED", "Installation was cancelled; no files were written.", plan, trust);
|
|
133
|
+
!(await options.confirm(plan, trust, warnings))) {
|
|
134
|
+
return initError("INIT_CANCELLED", "Installation was cancelled; no files were written.", plan, trust, warnings);
|
|
109
135
|
}
|
|
110
136
|
await applyInstallPlan(options.root, plan);
|
|
111
137
|
if (trust.action === "grant") {
|
|
@@ -116,12 +142,16 @@ export async function runInitCommand(options) {
|
|
|
116
142
|
await options.trustStore.grant(trust.binding);
|
|
117
143
|
}
|
|
118
144
|
catch {
|
|
119
|
-
return initError("INIT_TRUST_FAILED", "Installation was applied, but repository trust was not granted. Run `agent-ops trust grant --yes`.", plan, trust, true);
|
|
145
|
+
return initError("INIT_TRUST_FAILED", "Installation was applied, but repository trust was not granted. Run `agent-ops trust grant --yes`.", plan, trust, warnings, true);
|
|
120
146
|
}
|
|
121
147
|
}
|
|
148
|
+
const message = appliedMessage(plan);
|
|
122
149
|
return okEnvelope("INIT_APPLIED", {
|
|
123
150
|
applied: true,
|
|
124
151
|
plan: toPublicInstallPlan(plan, trust),
|
|
125
|
-
message:
|
|
152
|
+
message: warnings.length === 0
|
|
153
|
+
? message
|
|
154
|
+
: `${message}\nWarnings:\n${warnings.map((warning) => `- ${warning}`).join("\n")}`,
|
|
155
|
+
...(warnings.length === 0 ? {} : { warnings })
|
|
126
156
|
});
|
|
127
157
|
}
|
|
@@ -217,6 +217,9 @@ function sourceChangedResult(result) {
|
|
|
217
217
|
...(result.independence === undefined
|
|
218
218
|
? {}
|
|
219
219
|
: { independence: result.independence }),
|
|
220
|
+
...(result.sessionIsolation === undefined
|
|
221
|
+
? {}
|
|
222
|
+
: { sessionIsolation: result.sessionIsolation }),
|
|
220
223
|
...(result.attempts === undefined ? {} : { attempts: result.attempts }),
|
|
221
224
|
...(result.verification === undefined
|
|
222
225
|
? {}
|
|
@@ -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 };
|
|
@@ -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");
|
|
@@ -68,10 +71,10 @@ const PROFILE_CHOICES = [
|
|
|
68
71
|
{
|
|
69
72
|
label: "loop",
|
|
70
73
|
value: "loop",
|
|
71
|
-
description: "Installs the
|
|
74
|
+
description: "Installs the local loop; agy uses its supported lifecycle subset and is reported as degraded."
|
|
72
75
|
}
|
|
73
76
|
];
|
|
74
|
-
const WIZARD_SUBTITLE = "Safe setup for Codex, Claude Code, and opencode with profile-driven rules, verification, and hooks.";
|
|
77
|
+
const WIZARD_SUBTITLE = "Safe setup for agy, Codex, Claude Code, and opencode with profile-driven rules, verification, and hooks.";
|
|
75
78
|
async function createPromptSession(io) {
|
|
76
79
|
if (!io.isTTY) {
|
|
77
80
|
throw new CliArgumentError("CLI_INTERACTIVE_REQUIRED", "Missing init choices require an interactive terminal.");
|
|
@@ -146,6 +149,7 @@ export async function completeInitChoices(args, io, setup = {}) {
|
|
|
146
149
|
const scope = args.scope ?? await selectOption("Scope", SCOPE_CHOICES, selectorIo);
|
|
147
150
|
const harness = args.harness ??
|
|
148
151
|
await selectOptions("Harness (multi-select: ↑↓ move, Space toggle, Enter confirm)", HARNESS_CHOICES, selectorIo, [...DEFAULT_HARNESS], {
|
|
152
|
+
startEmpty: true,
|
|
149
153
|
selectAll: true,
|
|
150
154
|
selectAllLabel: "Select all",
|
|
151
155
|
selectAllDescription: "Install for every supported harness."
|
|
@@ -166,8 +170,9 @@ export async function completeInitChoices(args, io, setup = {}) {
|
|
|
166
170
|
description: "Pick target CLIs; each is probed for authentication."
|
|
167
171
|
}
|
|
168
172
|
], selectorIo));
|
|
169
|
-
const
|
|
170
|
-
|
|
173
|
+
const eligibleReviewChoices = REVIEW_TARGET_CHOICES.filter((choice) => harness.includes(choice.value));
|
|
174
|
+
const reviewTargets = args.reviewTargets ?? (enabled && eligibleReviewChoices.length > 0
|
|
175
|
+
? await selectOptions("Review targets (multi-select: tried in listed order)", eligibleReviewChoices, selectorIo, REVIEW_TARGET_ORDER.filter((target) => harness.includes(target)), { startEmpty: true })
|
|
171
176
|
: []);
|
|
172
177
|
await probeReviewTargets(reviewTargets, setup);
|
|
173
178
|
return {
|
|
@@ -187,8 +192,9 @@ export async function completeInitChoices(args, io, setup = {}) {
|
|
|
187
192
|
const profiles = args.profiles.length > 0
|
|
188
193
|
? args.profiles
|
|
189
194
|
: selectProfiles(await session.question("Profiles (core,advisory,guardrails,loop) [core]: "));
|
|
195
|
+
const defaultReviewTargets = REVIEW_TARGET_ORDER.filter((target) => harness.includes(target));
|
|
190
196
|
const reviewTargets = args.reviewTargets ?? (affirmative(await session.question("Enable external review by another agent CLI? [y/N]: "))
|
|
191
|
-
? selectReviewTargets(await session.question(`Review targets (${
|
|
197
|
+
? selectReviewTargets(await session.question(`Review targets (${defaultReviewTargets.join(",") || "none"}): `), defaultReviewTargets)
|
|
192
198
|
: []);
|
|
193
199
|
await probeReviewTargets(reviewTargets, setup);
|
|
194
200
|
return {
|
|
@@ -0,0 +1,105 @@
|
|
|
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) {
|
|
9
|
+
const command = platform === "win32"
|
|
10
|
+
? `cmd /c node ${JSON.stringify(runtimePath).replaceAll("\\\\", "\\")} agy ${event} ${MARKER}`
|
|
11
|
+
: `node ${JSON.stringify(runtimePath)} agy ${event} ${MARKER}`;
|
|
12
|
+
return {
|
|
13
|
+
type: "command",
|
|
14
|
+
command,
|
|
15
|
+
timeout: 30
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function exactKeys(value, keys) {
|
|
19
|
+
return Object.keys(value).length === keys.length && keys.every((key) => key in value);
|
|
20
|
+
}
|
|
21
|
+
function managedHandler(value, event) {
|
|
22
|
+
const candidate = record(value);
|
|
23
|
+
return candidate !== null &&
|
|
24
|
+
exactKeys(candidate, ["type", "command", "timeout"]) &&
|
|
25
|
+
candidate.type === "command" &&
|
|
26
|
+
candidate.timeout === 30 &&
|
|
27
|
+
typeof candidate.command === "string" &&
|
|
28
|
+
(candidate.command.startsWith("node \"") ||
|
|
29
|
+
candidate.command.startsWith("cmd /c node \"")) &&
|
|
30
|
+
candidate.command.endsWith(` agy ${event} ${MARKER}`);
|
|
31
|
+
}
|
|
32
|
+
function managedEvent(value, event) {
|
|
33
|
+
if (!Array.isArray(value) || value.length !== 1)
|
|
34
|
+
return false;
|
|
35
|
+
if (event !== "PreToolUse") {
|
|
36
|
+
return managedHandler(value[0], event === "PreInvocation" ? "SessionStart" : "Stop");
|
|
37
|
+
}
|
|
38
|
+
const group = record(value[0]);
|
|
39
|
+
return group !== null &&
|
|
40
|
+
exactKeys(group, ["matcher", "hooks"]) &&
|
|
41
|
+
group.matcher === "run_command" &&
|
|
42
|
+
Array.isArray(group.hooks) &&
|
|
43
|
+
group.hooks.length === 1 &&
|
|
44
|
+
managedHandler(group.hooks[0], "PreToolUse");
|
|
45
|
+
}
|
|
46
|
+
export function buildAgyHookSettings(capabilities, runtimePath, platform = process.platform) {
|
|
47
|
+
if (runtimePath.length === 0 ||
|
|
48
|
+
runtimePath.length > 4096 ||
|
|
49
|
+
/[\0\r\n]/u.test(runtimePath) ||
|
|
50
|
+
(platform === "win32" && runtimePath.includes('"'))) {
|
|
51
|
+
throw new AgentOpsError("AGY_HOOK_PATH_INVALID", "agy hook runtime path is invalid.");
|
|
52
|
+
}
|
|
53
|
+
const hooks = {};
|
|
54
|
+
if (capabilities.includes("lifecycle-summary") || capabilities.includes("project-loop")) {
|
|
55
|
+
hooks.PreInvocation = [handler(runtimePath, "SessionStart", platform)];
|
|
56
|
+
}
|
|
57
|
+
if (capabilities.includes("command-policy") || capabilities.includes("project-loop")) {
|
|
58
|
+
hooks.PreToolUse = [{ matcher: "run_command", hooks: [handler(runtimePath, "PreToolUse", platform)] }];
|
|
59
|
+
}
|
|
60
|
+
if (capabilities.includes("optional-stop-verify")) {
|
|
61
|
+
hooks.Stop = [handler(runtimePath, "Stop", platform)];
|
|
62
|
+
}
|
|
63
|
+
return { hooks };
|
|
64
|
+
}
|
|
65
|
+
export function isAgyManagedHook(value) {
|
|
66
|
+
const named = record(value)?.["agent-ops"];
|
|
67
|
+
const hook = record(named);
|
|
68
|
+
if (hook === null || hook.enabled !== true)
|
|
69
|
+
return false;
|
|
70
|
+
const events = Object.keys(hook).filter((key) => key !== "enabled");
|
|
71
|
+
return events.length > 0 &&
|
|
72
|
+
events.every((event) => (event === "PreInvocation" || event === "PreToolUse" || event === "Stop") &&
|
|
73
|
+
managedEvent(hook[event], event));
|
|
74
|
+
}
|
|
75
|
+
export function isAgyHookRegistered(value, capabilities) {
|
|
76
|
+
const expected = buildAgyHookSettings(capabilities, "probe").hooks;
|
|
77
|
+
if (Object.keys(expected).length === 0)
|
|
78
|
+
return true;
|
|
79
|
+
const named = record(record(value)?.["agent-ops"]);
|
|
80
|
+
if (named === null || !isAgyManagedHook(value))
|
|
81
|
+
return false;
|
|
82
|
+
return Object.keys(expected)
|
|
83
|
+
.every((event) => managedEvent(named[event], event));
|
|
84
|
+
}
|
|
85
|
+
export function mergeAgyHooks(existing, managed) {
|
|
86
|
+
const source = record(existing);
|
|
87
|
+
if (source === null) {
|
|
88
|
+
throw new AgentOpsError("AGY_HOOKS_INVALID", "agy hooks must be a JSON object.");
|
|
89
|
+
}
|
|
90
|
+
if (source["agent-ops"] !== undefined && !isAgyManagedHook(source)) {
|
|
91
|
+
throw new AgentOpsError("AGY_HOOK_NAME_CONFLICT", "Refusing to replace the existing non-agent-ops hook named agent-ops.");
|
|
92
|
+
}
|
|
93
|
+
return { ...source, "agent-ops": { enabled: true, ...managed.hooks } };
|
|
94
|
+
}
|
|
95
|
+
export function stripAgyHooks(existing) {
|
|
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", "The agent-ops hook is not owned by agent-ops.");
|
|
102
|
+
}
|
|
103
|
+
const { "agent-ops": _owned, ...rest } = source;
|
|
104
|
+
return rest;
|
|
105
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
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: "optional-stop-verify",
|
|
21
|
+
normalizedEvent: "stop",
|
|
22
|
+
nativeEvent: "Stop",
|
|
23
|
+
surfaceId: "agy-hooks",
|
|
24
|
+
support: "degraded",
|
|
25
|
+
runtimeFailure: "fail-open"
|
|
26
|
+
}
|
|
27
|
+
];
|
|
@@ -0,0 +1,30 @@
|
|
|
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 toolCall = record(value.toolCall);
|
|
19
|
+
const args = record(toolCall?.args);
|
|
20
|
+
if (toolCall?.name === "run_command" && typeof args?.CommandLine === "string") {
|
|
21
|
+
return normalizeShellHookEvent(args.CommandLine, projectRoot);
|
|
22
|
+
}
|
|
23
|
+
if (typeof value.terminationReason === "string") {
|
|
24
|
+
return normalizeHookEvent({ event: "stop", projectRoot });
|
|
25
|
+
}
|
|
26
|
+
if (typeof value.invocationNum === "number") {
|
|
27
|
+
return normalizeHookEvent({ event: "session-start", projectRoot });
|
|
28
|
+
}
|
|
29
|
+
return normalizeHookEvent({ event: "unsupported", projectRoot });
|
|
30
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
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.action === "block"
|
|
16
|
+
? { decision: "deny", reason }
|
|
17
|
+
: { decision: "allow" }
|
|
18
|
+
: event === "SessionStart"
|
|
19
|
+
? { injectSteps: [{ ephemeralMessage: reason }] }
|
|
20
|
+
: event === "Stop"
|
|
21
|
+
? { decision: "allow", reason }
|
|
22
|
+
: {};
|
|
23
|
+
return { exitCode: 0, stdout: JSON.stringify(value), stderr: "" };
|
|
24
|
+
}
|