@kylecheng3146/agent-ops 0.3.7 → 0.4.1
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 +35 -0
- package/dist/packages/cli/src/args.js +93 -9
- package/dist/packages/cli/src/bin.js +151 -104
- package/dist/packages/cli/src/cli.js +54 -5
- package/dist/packages/cli/src/commands/batch.js +106 -0
- package/dist/packages/cli/src/commands/hook.js +4 -1
- package/dist/packages/cli/src/commands/init.js +1 -0
- package/dist/packages/cli/src/commands/parallel.js +16 -3
- package/dist/packages/cli/src/commands/review.js +22 -0
- package/dist/packages/cli/src/commands/uninstall.js +3 -2
- package/dist/packages/cli/src/commands/update.js +1 -0
- package/dist/packages/cli/src/context.js +28 -3
- package/dist/packages/cli/src/hook-process.js +14 -3
- package/dist/packages/cli/src/public-plan.js +32 -2
- package/dist/runtime/src/adapters/claude/config.js +1 -1
- package/dist/runtime/src/adapters/claude/input.js +17 -0
- package/dist/runtime/src/adapters/claude/output.js +9 -0
- package/dist/runtime/src/hooks/codex-loop.js +67 -7
- package/dist/runtime/src/hooks/completion-gate.js +27 -2
- package/dist/runtime/src/hooks/dispatch.js +5 -0
- package/dist/runtime/src/install/apply.js +2 -0
- package/dist/runtime/src/install/doctor.js +8 -1
- package/dist/runtime/src/install/harness.js +17 -6
- package/dist/runtime/src/install/plan.js +9 -0
- package/dist/runtime/src/install/preauth.js +276 -0
- package/dist/runtime/src/install/probes.js +32 -0
- package/dist/runtime/src/install/uninstall.js +29 -11
- package/dist/runtime/src/install/update.js +1 -0
- package/dist/runtime/src/parallel/enter.js +43 -0
- package/dist/runtime/src/parallel/finish.js +2 -1
- package/dist/runtime/src/parallel/guard.js +20 -1
- package/dist/runtime/src/parallel/manage.js +38 -0
- package/dist/runtime/src/parallel/service.js +3 -1
- package/dist/runtime/src/review/batch-guard.js +11 -0
- package/dist/runtime/src/review/batch-select.js +37 -0
- package/dist/runtime/src/review/batch.js +202 -0
- package/dist/runtime/src/review/execute.js +10 -7
- package/dist/runtime/src/review/host-sandbox.js +9 -0
- package/dist/runtime/src/review/invocation.js +15 -2
- package/dist/runtime/src/review/probe.js +51 -15
- package/dist/runtime/src/review/render.js +4 -0
- package/docs/en/guides/configuration.md +51 -1
- package/docs/en/spec/review.md +70 -0
- package/docs/zh-TW/guides/configuration.md +41 -0
- package/docs/zh-TW/spec/review.md +71 -1
- package/package.json +1 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
1
3
|
import { CliArgumentError, parseArgs } from "./args.js";
|
|
2
4
|
import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
|
|
3
5
|
import { errorEnvelope, okEnvelope, writeEnvelope } from "./output.js";
|
|
@@ -27,6 +29,7 @@ Commands:
|
|
|
27
29
|
Manage independent task acceptance state
|
|
28
30
|
verify Run configured verification
|
|
29
31
|
review Run an independent review
|
|
32
|
+
batch Verify and review a parent task's subtasks together
|
|
30
33
|
allow-stop Grant one fingerprint-bound completion-gate Stop permit (requires --session)
|
|
31
34
|
agy-run Run headless agy with a process-exit completion recheck
|
|
32
35
|
worktree <add|finish|list|resume|remove>
|
|
@@ -45,13 +48,16 @@ Options:
|
|
|
45
48
|
--check-auth-target <target> Repeatable doctor filter with --check-auth
|
|
46
49
|
--task <id>
|
|
47
50
|
--parent <task-id> Task create: record a subtask of this
|
|
48
|
-
task; task status: list its subtasks
|
|
51
|
+
task; task status: list its subtasks;
|
|
52
|
+
batch: the task whose subtasks it covers
|
|
53
|
+
--parent-base <git-ref> Batch only: base for the parent task
|
|
54
|
+
--width <n> Batch only: reviews allowed at once (default 2)
|
|
49
55
|
--target-version <version> Update target version (offline-capable)
|
|
50
56
|
--title <text>
|
|
51
57
|
--criterion <json> Repeatable
|
|
52
58
|
--evidence <criterion-id=reference> Repeatable
|
|
53
59
|
--session <id>
|
|
54
|
-
--base <git-ref> Verify/review/complete a clean committed range
|
|
60
|
+
--base <git-ref> Verify/review/batch/complete a clean committed range
|
|
55
61
|
--dry-run
|
|
56
62
|
--json
|
|
57
63
|
--yes
|
|
@@ -106,6 +112,9 @@ Options:
|
|
|
106
112
|
--scope <project|user>
|
|
107
113
|
--harness <all|both|agy|claude|codex|opencode|comma-separated>
|
|
108
114
|
--check-auth Probe each review target's authentication with one real call
|
|
115
|
+
--output <file>
|
|
116
|
+
Write the JSON envelope to <file> (mode 0600) and print one
|
|
117
|
+
summary line, so no shell redirect is needed
|
|
109
118
|
--json
|
|
110
119
|
`,
|
|
111
120
|
update: `Usage: agent-ops update [options]
|
|
@@ -172,8 +181,30 @@ fresh adversarial reviewer from the configured pair.
|
|
|
172
181
|
Options:
|
|
173
182
|
--task <id> Required task whose original criteria are reviewed
|
|
174
183
|
--base <git-ref> Review a clean committed range
|
|
184
|
+
--output <file> Write the JSON envelope to <file> (mode 0600) and print
|
|
185
|
+
one summary line, so no shell redirect is needed
|
|
175
186
|
--json
|
|
176
187
|
--yes Required authorization for both reviewer sessions
|
|
188
|
+
`,
|
|
189
|
+
batch: `Usage: agent-ops batch --parent <task-id> --yes [options]
|
|
190
|
+
|
|
191
|
+
Verify and review the active subtasks of a parent task, and the parent itself,
|
|
192
|
+
together. Verify runs one task at a time and skips a task that already has
|
|
193
|
+
fresh PASS evidence; each review starts as soon as its own verify passes.
|
|
194
|
+
Nothing may commit or edit the worktree while it runs.
|
|
195
|
+
|
|
196
|
+
Options:
|
|
197
|
+
--parent <task-id> Required task whose active subtasks and itself are covered
|
|
198
|
+
--base <git-ref> Base for the subtasks (default: each task's recorded
|
|
199
|
+
review base, then the worktree base)
|
|
200
|
+
--parent-base <git-ref>
|
|
201
|
+
Base for the parent task (same default)
|
|
202
|
+
--width <n> Reviews allowed at once (default 2); a transient
|
|
203
|
+
NOT_RUN lowers it to 1 and earns one retry
|
|
204
|
+
--output <file> Write the JSON envelope to <file> (mode 0600) and print
|
|
205
|
+
one summary line, so no shell redirect is needed
|
|
206
|
+
--json
|
|
207
|
+
--yes Required authorization for the reviewer sessions
|
|
177
208
|
`,
|
|
178
209
|
"allow-stop": `Usage: agent-ops allow-stop --session <id> [options]
|
|
179
210
|
|
|
@@ -186,6 +217,7 @@ Options:
|
|
|
186
217
|
--json
|
|
187
218
|
`,
|
|
188
219
|
worktree: `Usage: agent-ops worktree <add|finish|resume|remove> <name> [options]
|
|
220
|
+
agent-ops worktree commit --message <text> [--json]
|
|
189
221
|
agent-ops worktree list [--json]
|
|
190
222
|
|
|
191
223
|
Give one writing session its own Git worktree, so parallel sessions stop
|
|
@@ -205,6 +237,9 @@ voiding each other's verification and review. Run it from the main checkout.
|
|
|
205
237
|
branch to the worktree's (after a clean rebase and
|
|
206
238
|
re-verification if the branch moved), record the task in
|
|
207
239
|
git notes, and remove the worktree. One finish runs at a time.
|
|
240
|
+
commit Stage every change in the current worktree and commit it with
|
|
241
|
+
--message. Refuses the main checkout and a worktree another
|
|
242
|
+
session owns, and needs no Git in the shell.
|
|
208
243
|
list Every agent-ops worktree: session, commits ahead, uncommitted
|
|
209
244
|
changes, task status and last activity.
|
|
210
245
|
resume <name>
|
|
@@ -221,6 +256,7 @@ Options:
|
|
|
221
256
|
add: branch finish merges back into (defaults to the current
|
|
222
257
|
branch; required when the main checkout is detached)
|
|
223
258
|
--force remove: discard uncommitted or unmerged work
|
|
259
|
+
--message <text> commit: the commit message (also -m)
|
|
224
260
|
--json
|
|
225
261
|
`
|
|
226
262
|
};
|
|
@@ -275,11 +311,24 @@ export async function runCli(argv, io, services) {
|
|
|
275
311
|
return writeAndReturn(io, errorEnvelope("CLI_COMMAND_UNAVAILABLE", `Command is not implemented yet: ${args.command}`), args.json, 1);
|
|
276
312
|
}
|
|
277
313
|
const result = await execute(args);
|
|
278
|
-
|
|
314
|
+
const exitCode = result.status === "ok"
|
|
279
315
|
? 0
|
|
280
|
-
: result.code === "REVIEW_NOT_RUN"
|
|
316
|
+
: result.code === "REVIEW_NOT_RUN" || result.code === "BATCH_NOT_RUN"
|
|
281
317
|
? 2
|
|
282
|
-
: 1
|
|
318
|
+
: 1;
|
|
319
|
+
if (args.output !== undefined) {
|
|
320
|
+
// The envelope goes to the file whole; stdout carries one line, so an
|
|
321
|
+
// agent needs neither a shell redirect nor the whole report in context.
|
|
322
|
+
try {
|
|
323
|
+
await writeFile(resolve(args.output), `${JSON.stringify(result.data === undefined ? { ...result, data: null } : result)}\n`, { mode: 0o600 });
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
throw new AgentOpsError("CLI_OUTPUT_WRITE_FAILED", `Could not write ${args.output}.`);
|
|
327
|
+
}
|
|
328
|
+
io.writeStdout(`agent-ops: ${result.code} (${result.status}); envelope written to ${args.output}\n`);
|
|
329
|
+
return exitCode;
|
|
330
|
+
}
|
|
331
|
+
return writeAndReturn(io, result, args.json, exitCode);
|
|
283
332
|
}
|
|
284
333
|
catch (error) {
|
|
285
334
|
if (error instanceof CliArgumentError) {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
|
|
2
|
+
import { DEFAULT_BATCH_WIDTH, runBatch } from "../../../../runtime/src/review/batch.js";
|
|
3
|
+
import { resolveBatchBase, selectBatchTasks } from "../../../../runtime/src/review/batch-select.js";
|
|
4
|
+
import { redactSecrets } from "../../../../runtime/src/security/redact.js";
|
|
5
|
+
import { safeTaskText } from "../../../../runtime/src/task/render.js";
|
|
6
|
+
import { errorEnvelope } from "../output.js";
|
|
7
|
+
function outcomeLabel(task) {
|
|
8
|
+
if (task.aborted) {
|
|
9
|
+
return "aborted";
|
|
10
|
+
}
|
|
11
|
+
if (task.review === null) {
|
|
12
|
+
return task.verify === "FAIL" ? "no review (verify failed)" : "not reviewed";
|
|
13
|
+
}
|
|
14
|
+
const reason = task.review.reason === undefined ? "" : ` ${task.review.reason}`;
|
|
15
|
+
return `${task.review.status}${reason}${task.review.reused === true ? " (reused)" : ""}${task.retried ? " (retried)" : ""}`;
|
|
16
|
+
}
|
|
17
|
+
function renderBatch(data) {
|
|
18
|
+
const lines = [
|
|
19
|
+
`Batch of ${data.tasks.length} task(s) under ${data.parentTaskId}, width ${data.width}.`,
|
|
20
|
+
"",
|
|
21
|
+
...data.tasks.map((task) => `- ${task.taskId} verify ${task.verify} review ${outcomeLabel(task)} base ${task.base.slice(0, 12)} (${task.baseSource}) ${safeTaskText(redactSecrets(task.title))}`)
|
|
22
|
+
];
|
|
23
|
+
if (data.tasks.some((task) => task.aborted)) {
|
|
24
|
+
lines.push("", "The source changed or the run was interrupted; unfinished tasks were aborted. Run the batch again.");
|
|
25
|
+
}
|
|
26
|
+
return lines.join("\n");
|
|
27
|
+
}
|
|
28
|
+
function isPass(task) {
|
|
29
|
+
return !task.aborted && task.review?.status === "PASS";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The envelope's code follows the worst outcome that needs a human: a failed
|
|
33
|
+
* verify or review (fix the code) before a run that could not finish (run it
|
|
34
|
+
* again), so one defect is never hidden behind an unrelated NOT_RUN.
|
|
35
|
+
*/
|
|
36
|
+
function classify(tasks) {
|
|
37
|
+
if (tasks.every(isPass)) {
|
|
38
|
+
return "BATCH_RESULT";
|
|
39
|
+
}
|
|
40
|
+
return tasks.some((task) => task.verify === "FAIL" || task.review?.status === "FAIL")
|
|
41
|
+
? "BATCH_FAILED"
|
|
42
|
+
: "BATCH_NOT_RUN";
|
|
43
|
+
}
|
|
44
|
+
export async function runBatchCommand(options) {
|
|
45
|
+
const { args } = options;
|
|
46
|
+
const parentTaskId = args.parentTaskId;
|
|
47
|
+
if (parentTaskId === undefined) {
|
|
48
|
+
throw new AgentOpsError("BATCH_PARENT_REQUIRED", "Batch requires --parent.");
|
|
49
|
+
}
|
|
50
|
+
const selected = selectBatchTasks(await options.tasks.list(), parentTaskId);
|
|
51
|
+
const plan = selected.map((record) => ({
|
|
52
|
+
record,
|
|
53
|
+
...resolveBatchBase(record, record.task.id === parentTaskId, {
|
|
54
|
+
...(args.base === undefined ? {} : { base: args.base }),
|
|
55
|
+
...(args.parentBase === undefined ? {} : { parentBase: args.parentBase }),
|
|
56
|
+
...(options.worktreeBase === undefined ? {} : { worktreeBase: options.worktreeBase })
|
|
57
|
+
})
|
|
58
|
+
}));
|
|
59
|
+
const baseOf = new Map(plan.map((item) => [item.record.task.id, item.base]));
|
|
60
|
+
const base = (id) => baseOf.get(id);
|
|
61
|
+
// The first answer is the clean-tree check: a batch reviews committed ranges.
|
|
62
|
+
if (!await options.guard()) {
|
|
63
|
+
return {
|
|
64
|
+
...errorEnvelope("BATCH_DIRTY_WORKTREE", "Batch needs a clean working tree. Commit or discard your changes first."),
|
|
65
|
+
data: null
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const width = args.width ?? DEFAULT_BATCH_WIDTH;
|
|
69
|
+
const outcomes = await runBatch(plan.map((item) => item.record.task.id), {
|
|
70
|
+
isVerified: (id) => options.isVerified(id, base(id)),
|
|
71
|
+
verify: (id, signal) => options.verify(id, base(id), signal),
|
|
72
|
+
review: (id, signal) => options.review(id, base(id), signal),
|
|
73
|
+
guard: options.guard
|
|
74
|
+
}, {
|
|
75
|
+
width,
|
|
76
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
77
|
+
...(options.onProgress === undefined ? {} : { onProgress: options.onProgress })
|
|
78
|
+
});
|
|
79
|
+
const byId = new Map(outcomes.map((outcome) => [outcome.id, outcome]));
|
|
80
|
+
const tasks = plan.map((item) => {
|
|
81
|
+
const outcome = byId.get(item.record.task.id);
|
|
82
|
+
return {
|
|
83
|
+
taskId: item.record.task.id,
|
|
84
|
+
title: item.record.task.title,
|
|
85
|
+
base: item.base,
|
|
86
|
+
baseSource: item.source,
|
|
87
|
+
verify: outcome.verify,
|
|
88
|
+
review: outcome.review,
|
|
89
|
+
retried: outcome.retried,
|
|
90
|
+
aborted: outcome.aborted
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
const code = classify(tasks);
|
|
94
|
+
const passed = tasks.filter(isPass).length;
|
|
95
|
+
const message = code === "BATCH_RESULT"
|
|
96
|
+
? `Batch passed: ${passed} of ${tasks.length} task(s).`
|
|
97
|
+
: code === "BATCH_FAILED"
|
|
98
|
+
? `Batch failed: ${passed} of ${tasks.length} task(s) passed.`
|
|
99
|
+
: `Batch was not completed: ${passed} of ${tasks.length} task(s) passed.`;
|
|
100
|
+
const summary = { message, parentTaskId, width, tasks };
|
|
101
|
+
const data = { ...summary, text: `${message}\n\n${renderBatch(summary)}` };
|
|
102
|
+
if (code === "BATCH_RESULT") {
|
|
103
|
+
return { code, status: "ok", data, errors: [] };
|
|
104
|
+
}
|
|
105
|
+
return { code, status: "error", data, errors: [{ code, message }] };
|
|
106
|
+
}
|
|
@@ -50,7 +50,10 @@ export async function runHookCommand(options) {
|
|
|
50
50
|
: { completionGate: options.completionGate }),
|
|
51
51
|
...(options.worktreeGuard === undefined
|
|
52
52
|
? {}
|
|
53
|
-
: { worktreeGuard: options.worktreeGuard })
|
|
53
|
+
: { worktreeGuard: options.worktreeGuard }),
|
|
54
|
+
...(options.worktreeEnter === undefined
|
|
55
|
+
? {}
|
|
56
|
+
: { worktreeEnter: options.worktreeEnter })
|
|
54
57
|
});
|
|
55
58
|
return descriptor.runtime.formatOutput(options.event, result);
|
|
56
59
|
}
|
|
@@ -119,6 +119,7 @@ export async function runInitCommand(options) {
|
|
|
119
119
|
...(args.completionGate === undefined
|
|
120
120
|
? {}
|
|
121
121
|
: { completionGateEnabled: args.completionGate }),
|
|
122
|
+
...(options.codexHome === undefined ? {} : { codexHome: options.codexHome }),
|
|
122
123
|
...(worktreeConfig === undefined
|
|
123
124
|
? {}
|
|
124
125
|
: { worktree: worktreeConfig })
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { okEnvelope } from "../output.js";
|
|
2
|
+
import { resolveCommandSessionId, sessionIdFromEnvironment } from "../../../../runtime/src/hooks/codex-loop.js";
|
|
2
3
|
import { addWorktree } from "../../../../runtime/src/parallel/service.js";
|
|
3
4
|
import { finishWorktree } from "../../../../runtime/src/parallel/finish.js";
|
|
4
|
-
import { idleWorktrees, listWorktrees, removeWorktree, resumeWorktree } from "../../../../runtime/src/parallel/manage.js";
|
|
5
|
+
import { commitWorktree, idleWorktrees, listWorktrees, removeWorktree, resumeWorktree } from "../../../../runtime/src/parallel/manage.js";
|
|
5
6
|
function listText(statuses, now) {
|
|
6
7
|
if (statuses.length === 0)
|
|
7
8
|
return "No agent-ops worktrees.";
|
|
@@ -49,11 +50,23 @@ export async function runWorktreeCommand(options) {
|
|
|
49
50
|
const worktrees = await listWorktrees(options.deps, options.cwd);
|
|
50
51
|
return okEnvelope("WORKTREE_LIST", { worktrees, text: listText(worktrees, Date.now()) });
|
|
51
52
|
}
|
|
53
|
+
if (args.action === "commit") {
|
|
54
|
+
const committed = await commitWorktree(options.deps, {
|
|
55
|
+
cwd: options.cwd,
|
|
56
|
+
sessionId: args.sessionId ?? await resolveCommandSessionId(options.cwd),
|
|
57
|
+
message: args.worktreeMessage ?? ""
|
|
58
|
+
});
|
|
59
|
+
return okEnvelope("WORKTREE_COMMITTED", {
|
|
60
|
+
...committed,
|
|
61
|
+
text: `Committed ${committed.commit} in ${committed.record.path}.\n` +
|
|
62
|
+
`Verify and review with --base ${committed.record.base} (or the commit before your first change), then finish from ${committed.record.mainRoot}.`
|
|
63
|
+
});
|
|
64
|
+
}
|
|
52
65
|
if (args.action === "resume") {
|
|
53
66
|
const record = await resumeWorktree(options.deps, {
|
|
54
67
|
cwd: options.cwd,
|
|
55
68
|
name: args.worktreeName,
|
|
56
|
-
sessionId: args.sessionId ??
|
|
69
|
+
sessionId: args.sessionId ?? sessionIdFromEnvironment()
|
|
57
70
|
});
|
|
58
71
|
return okEnvelope("WORKTREE_RESUMED", {
|
|
59
72
|
record,
|
|
@@ -78,7 +91,7 @@ export async function runWorktreeCommand(options) {
|
|
|
78
91
|
const finished = await finishWorktree(options.deps, { cwd: options.cwd, name: args.worktreeName });
|
|
79
92
|
return okEnvelope("WORKTREE_FINISHED", { ...finished, text: finishText(finished) });
|
|
80
93
|
}
|
|
81
|
-
const sessionId = args.sessionId ??
|
|
94
|
+
const sessionId = args.sessionId ?? sessionIdFromEnvironment();
|
|
82
95
|
const result = await addWorktree(options.deps, {
|
|
83
96
|
cwd: options.cwd,
|
|
84
97
|
name: args.worktreeName,
|
|
@@ -196,6 +196,28 @@ async function preflightReview(options, context, sourceFingerprint) {
|
|
|
196
196
|
summary: { status: "PASS", sourceFingerprint, commands }
|
|
197
197
|
};
|
|
198
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Whether the task already holds current PASS verification evidence for the
|
|
201
|
+
* source a review of `base` (or the working tree) would measure. This is the
|
|
202
|
+
* same preflight `review` runs before it spawns, so a batch that skips verify
|
|
203
|
+
* on a true answer never reaches a review that then reports stale evidence.
|
|
204
|
+
*/
|
|
205
|
+
export async function hasFreshVerification(options, taskId, base) {
|
|
206
|
+
if (options.root === undefined || options.gitRunner === undefined) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
const context = await taskContext({ ...options, taskId });
|
|
210
|
+
if (context === undefined) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
const scope = await resolveReviewScope({
|
|
214
|
+
root: options.root,
|
|
215
|
+
runner: options.gitRunner,
|
|
216
|
+
...(base === undefined ? {} : { base })
|
|
217
|
+
});
|
|
218
|
+
const fingerprint = await calculateSourceFingerprint(options.root, scope, options.gitRunner);
|
|
219
|
+
return (await preflightReview(options, context, fingerprint)).ok;
|
|
220
|
+
}
|
|
199
221
|
function scopeReason(error) {
|
|
200
222
|
if (!(error instanceof AgentOpsError)) {
|
|
201
223
|
return undefined;
|
|
@@ -56,7 +56,8 @@ async function trustChange(options, plan) {
|
|
|
56
56
|
: await planTrustRevoke(binding, options.trustStore);
|
|
57
57
|
}
|
|
58
58
|
export async function runUninstallCommand(options) {
|
|
59
|
-
const
|
|
59
|
+
const planOptions = options.codexHome === undefined ? {} : { codexHome: options.codexHome };
|
|
60
|
+
const plan = await createUninstallPlan(options.root, options.args.harness, planOptions);
|
|
60
61
|
if (!plan.installed) {
|
|
61
62
|
return okEnvelope("UNINSTALL_NOT_INSTALLED", {
|
|
62
63
|
applied: false,
|
|
@@ -80,7 +81,7 @@ export async function runUninstallCommand(options) {
|
|
|
80
81
|
!(await options.confirm(plan, trust))) {
|
|
81
82
|
return uninstallError("UNINSTALL_CANCELLED", "Uninstall was cancelled; no files were changed.", plan, trust);
|
|
82
83
|
}
|
|
83
|
-
await applyUninstallPlan(options.root, plan);
|
|
84
|
+
await applyUninstallPlan(options.root, plan, planOptions);
|
|
84
85
|
if (trust.action === "revoke") {
|
|
85
86
|
try {
|
|
86
87
|
if (options.trustStore === undefined ||
|
|
@@ -112,6 +112,7 @@ export async function runUpdateCommand(options) {
|
|
|
112
112
|
...((options.hookTargets ?? options.args.hookTargets) === undefined
|
|
113
113
|
? {}
|
|
114
114
|
: { hookTargets: options.hookTargets ?? options.args.hookTargets }),
|
|
115
|
+
...(options.codexHome === undefined ? {} : { codexHome: options.codexHome }),
|
|
115
116
|
...(worktree === undefined ? {} : { worktree })
|
|
116
117
|
});
|
|
117
118
|
const trust = await trustChange(options, plan);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
+
import { basename, dirname, join, sep } from "node:path";
|
|
4
5
|
import { calculateConfigHash } from "../../../runtime/src/config/hash.js";
|
|
5
6
|
import { sha256 } from "../../../runtime/src/fs/hash.js";
|
|
6
7
|
import { loadConfigFile } from "../../../runtime/src/config/load.js";
|
|
@@ -23,6 +24,30 @@ export const DEFAULT_CONFIG = {
|
|
|
23
24
|
pathMappings: [],
|
|
24
25
|
securityExceptions: []
|
|
25
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* Where a checkout's project config lives. Inside an agent-ops worktree it is
|
|
29
|
+
* the main checkout's: a branch must not change the config that decides its
|
|
30
|
+
* own gate, verifiers and trust before that change is merged, and a copy of a
|
|
31
|
+
* gitignored file can be missing or stale. Anything git cannot confirm is a
|
|
32
|
+
* worktree of this repository falls back to the checkout itself.
|
|
33
|
+
*/
|
|
34
|
+
export function projectConfigRoot(root) {
|
|
35
|
+
try {
|
|
36
|
+
const common = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
37
|
+
if (basename(common) !== ".git") {
|
|
38
|
+
return root;
|
|
39
|
+
}
|
|
40
|
+
const main = realpathSync(dirname(common));
|
|
41
|
+
const here = realpathSync(root);
|
|
42
|
+
return here.startsWith(`${join(main, ".worktrees")}${sep}`) &&
|
|
43
|
+
existsSync(join(main, ".agent-ops", "config.json"))
|
|
44
|
+
? main
|
|
45
|
+
: root;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return root;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
26
51
|
function defaultConfigLayer() {
|
|
27
52
|
return {
|
|
28
53
|
source: "default",
|
|
@@ -49,7 +74,7 @@ async function loadOptionalConfig(path) {
|
|
|
49
74
|
export async function loadEffectiveConfig(root, scope, projectOverride) {
|
|
50
75
|
const home = process.env.AGENT_OPS_HOME ?? homedir();
|
|
51
76
|
const userPath = join(home, ".agent-ops", "config.json");
|
|
52
|
-
const projectPath = join(root, ".agent-ops", "config.json");
|
|
77
|
+
const projectPath = join(scope === "user" ? root : projectConfigRoot(root), ".agent-ops", "config.json");
|
|
53
78
|
const layers = [defaultConfigLayer()];
|
|
54
79
|
if (scope === "user") {
|
|
55
80
|
const user = await loadOptionalConfig(userPath);
|
|
@@ -92,7 +117,7 @@ export async function loadEffectiveConfig(root, scope, projectOverride) {
|
|
|
92
117
|
export async function loadProjectHookConfig(root) {
|
|
93
118
|
const home = process.env.AGENT_OPS_HOME ?? homedir();
|
|
94
119
|
const userPath = join(home, ".agent-ops", "config.json");
|
|
95
|
-
const projectPath = join(root, ".agent-ops", "config.json");
|
|
120
|
+
const projectPath = join(projectConfigRoot(root), ".agent-ops", "config.json");
|
|
96
121
|
const layers = [defaultConfigLayer()];
|
|
97
122
|
let project;
|
|
98
123
|
try {
|
|
@@ -9,6 +9,7 @@ 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 { evaluateWorktreeEnter } from "../../../runtime/src/parallel/enter.js";
|
|
12
13
|
import { evaluateWorktreeWrite, resolveMainRoot } from "../../../runtime/src/parallel/guard.js";
|
|
13
14
|
import { ensureSessionWorktree, recordSessionOrigin } from "../../../runtime/src/parallel/service.js";
|
|
14
15
|
import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
|
|
@@ -214,11 +215,12 @@ function shouldBuildStopVerification(harness, event, config, rawInput) {
|
|
|
214
215
|
* The gate of one checkout. A session redirected into a worktree is judged by
|
|
215
216
|
* that worktree's gate, built the same way from the worktree's own config.
|
|
216
217
|
*/
|
|
217
|
-
function completionGateFor(root, config, gitRunner) {
|
|
218
|
+
function completionGateFor(root, config, gitRunner, harness) {
|
|
218
219
|
return new CompletionGateService({
|
|
219
220
|
root,
|
|
220
221
|
config,
|
|
221
222
|
gitRunner,
|
|
223
|
+
...(harness === undefined ? {} : { harness }),
|
|
222
224
|
taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
|
|
223
225
|
evidenceStore: new FileEvidenceStore(root, root),
|
|
224
226
|
forRoot: async (worktree) => {
|
|
@@ -226,7 +228,7 @@ function completionGateFor(root, config, gitRunner) {
|
|
|
226
228
|
if (outcome.kind !== "loaded" || !outcome.config.features.completionGate.enabled) {
|
|
227
229
|
throw new Error("The redirected worktree has no enabled completion gate.");
|
|
228
230
|
}
|
|
229
|
-
return completionGateFor(worktree, outcome.config, defaultGitRunner(worktree));
|
|
231
|
+
return completionGateFor(worktree, outcome.config, defaultGitRunner(worktree), harness);
|
|
230
232
|
}
|
|
231
233
|
});
|
|
232
234
|
}
|
|
@@ -341,7 +343,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
341
343
|
const completionGate = (harnessId === "agy" || harnessId === "claude") &&
|
|
342
344
|
config.features.completionGate.enabled
|
|
343
345
|
? dependencies.completionGate ?? {
|
|
344
|
-
handle: async (normalized) => await completionGateFor(root, config, gitRunner).handle(normalized)
|
|
346
|
+
handle: async (normalized) => await completionGateFor(root, config, gitRunner, harnessId).handle(normalized)
|
|
345
347
|
}
|
|
346
348
|
: undefined;
|
|
347
349
|
const worktreeGuard = config.worktree?.mode === "auto"
|
|
@@ -354,6 +356,14 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
354
356
|
: async (sessionId) => (await ensureSessionWorktree(dependencies.worktree, { cwd: mainRoot, sessionId })).path);
|
|
355
357
|
}
|
|
356
358
|
: undefined;
|
|
359
|
+
const worktreeEnter = config.worktree?.mode === "auto"
|
|
360
|
+
? async (enter) => {
|
|
361
|
+
const mainRoot = await resolveMainRoot(gitRunner);
|
|
362
|
+
return mainRoot === null
|
|
363
|
+
? { action: "continue", status: "UNKNOWN", code: "WORKTREE_GUARD_UNAVAILABLE" }
|
|
364
|
+
: await evaluateWorktreeEnter(mainRoot, enter.path, enter.sessionId);
|
|
365
|
+
}
|
|
366
|
+
: undefined;
|
|
357
367
|
if (hookEvent === "SessionStart" && config.worktree?.mode === "auto" &&
|
|
358
368
|
dependencies.worktree !== undefined && typeof parsedInput === "object" && parsedInput !== null) {
|
|
359
369
|
const fields = parsedInput;
|
|
@@ -370,6 +380,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
370
380
|
config,
|
|
371
381
|
trusted,
|
|
372
382
|
...(worktreeGuard === undefined ? {} : { worktreeGuard }),
|
|
383
|
+
...(worktreeEnter === undefined ? {} : { worktreeEnter }),
|
|
373
384
|
...(dependencies.advisory === undefined
|
|
374
385
|
? {}
|
|
375
386
|
: { advisory: dependencies.advisory }),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import { redactSecrets } from "../../../runtime/src/security/redact.js";
|
|
2
3
|
import { sha256 } from "../../../runtime/src/fs/hash.js";
|
|
3
4
|
const OPAQUE_SUMMARY = "Opaque managed settings content withheld.";
|
|
@@ -35,6 +36,29 @@ export function toPublicOperation(operation) {
|
|
|
35
36
|
export function toPublicOperations(operations) {
|
|
36
37
|
return operations.map(toPublicOperation);
|
|
37
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Pre-authorization changes join the ordinary operations, so every preview and
|
|
41
|
+
* confirmation shows them. A file in Codex's home is shown by absolute path;
|
|
42
|
+
* a file agent-ops does not wholly own is described, never printed.
|
|
43
|
+
*/
|
|
44
|
+
export function toPublicPreauthorization(changes) {
|
|
45
|
+
return (changes ?? []).map((change) => {
|
|
46
|
+
const path = publicPath(join(change.root, change.operation.path));
|
|
47
|
+
const operation = change.operation;
|
|
48
|
+
if (operation.kind === "remove") {
|
|
49
|
+
return { kind: "remove", path, expectedHash: operation.expectedHash };
|
|
50
|
+
}
|
|
51
|
+
return change.ownedContent
|
|
52
|
+
? { kind: "write", path, expectedHash: operation.expectedHash, content: operation.content }
|
|
53
|
+
: {
|
|
54
|
+
kind: "write",
|
|
55
|
+
path,
|
|
56
|
+
expectedHash: operation.expectedHash,
|
|
57
|
+
contentHash: sha256(operation.content),
|
|
58
|
+
summary: redactSecrets(change.summary)
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
38
62
|
export function toPublicInstallPlan(plan, trust) {
|
|
39
63
|
return {
|
|
40
64
|
scope: plan.scope,
|
|
@@ -42,7 +66,10 @@ export function toPublicInstallPlan(plan, trust) {
|
|
|
42
66
|
profiles: plan.profiles,
|
|
43
67
|
capabilities: plan.capabilities,
|
|
44
68
|
manifest: plan.manifest,
|
|
45
|
-
operations:
|
|
69
|
+
operations: [
|
|
70
|
+
...toPublicOperations(plan.operations),
|
|
71
|
+
...toPublicPreauthorization(plan.preauthorization)
|
|
72
|
+
],
|
|
46
73
|
detectedVerification: plan.detectedVerification,
|
|
47
74
|
...(trust === undefined ? {} : { trust })
|
|
48
75
|
};
|
|
@@ -59,7 +86,10 @@ export function toPublicUninstallPlan(plan, trust) {
|
|
|
59
86
|
installed: plan.installed,
|
|
60
87
|
manifest: plan.manifest,
|
|
61
88
|
manifestHash: plan.manifestHash,
|
|
62
|
-
operations:
|
|
89
|
+
operations: [
|
|
90
|
+
...toPublicOperations(plan.operations),
|
|
91
|
+
...toPublicPreauthorization(plan.preauthorization)
|
|
92
|
+
],
|
|
63
93
|
...(trust === undefined ? {} : { trust })
|
|
64
94
|
};
|
|
65
95
|
}
|
|
@@ -11,7 +11,7 @@ const CLAUDE_LOOP_EVENTS = [
|
|
|
11
11
|
"SubagentStop"
|
|
12
12
|
];
|
|
13
13
|
const CLAUDE_HOOK_MARKER = "--managed-by=agent-ops";
|
|
14
|
-
export const CLAUDE_PRE_TOOL_MATCHER = "Bash|Edit|MultiEdit|NotebookEdit|Write";
|
|
14
|
+
export const CLAUDE_PRE_TOOL_MATCHER = "Bash|Edit|MultiEdit|NotebookEdit|Write|EnterWorktree";
|
|
15
15
|
const CLAUDE_LOOP_LAUNCHER = "${CLAUDE_PROJECT_DIR}/.claude/hooks/agent-ops-loop.sh";
|
|
16
16
|
const CLAUDE_WINDOWS_LOOP_LAUNCHER = "${CLAUDE_PROJECT_DIR}/.claude/hooks/agent-ops-loop.ps1";
|
|
17
17
|
export function claudeSettingsTarget(scope) {
|
|
@@ -63,6 +63,23 @@ export function normalizeClaudeHookInput(input) {
|
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
|
+
if (input.hook_event_name === "PreToolUse" &&
|
|
67
|
+
input.tool_name === "EnterWorktree" &&
|
|
68
|
+
isRecord(input.tool_input) &&
|
|
69
|
+
typeof projectRoot === "string" &&
|
|
70
|
+
projectRoot.length > 0) {
|
|
71
|
+
const target = input.tool_input.path;
|
|
72
|
+
// A `name` asks for a new worktree of Claude's own, which is not this
|
|
73
|
+
// hook's business.
|
|
74
|
+
if (typeof target === "string" && target.length > 0 && !target.includes("\0")) {
|
|
75
|
+
return {
|
|
76
|
+
event: "enter-worktree",
|
|
77
|
+
projectRoot,
|
|
78
|
+
path: resolve(projectRoot, target),
|
|
79
|
+
...(sessionId === undefined ? {} : { sessionId })
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
66
83
|
if (input.hook_event_name === "PreToolUse" &&
|
|
67
84
|
input.tool_name === "Bash" &&
|
|
68
85
|
isRecord(input.tool_input) &&
|
|
@@ -32,6 +32,15 @@ export function claudeHookOutput(event, result) {
|
|
|
32
32
|
}
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
|
+
if (event === "PreToolUse" && result.decision === "allow") {
|
|
36
|
+
return json({
|
|
37
|
+
hookSpecificOutput: {
|
|
38
|
+
hookEventName: "PreToolUse",
|
|
39
|
+
permissionDecision: "allow",
|
|
40
|
+
permissionDecisionReason: denialReason
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
35
44
|
if (event === "Stop" && result.evidence !== undefined) {
|
|
36
45
|
if (result.status === "FAIL") {
|
|
37
46
|
const failed = result.evidence.commandResults
|