@kylecheng3146/agent-ops 0.2.8 → 0.3.0
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/dist/packages/cli/src/args.js +48 -2
- package/dist/packages/cli/src/bin.js +67 -3
- package/dist/packages/cli/src/cli.js +31 -0
- package/dist/packages/cli/src/commands/hook.js +4 -1
- package/dist/packages/cli/src/commands/parallel.js +87 -0
- package/dist/packages/cli/src/hook-process.js +31 -7
- package/dist/runtime/src/adapters/agy/output.js +2 -1
- package/dist/runtime/src/adapters/claude/config.js +4 -1
- package/dist/runtime/src/adapters/claude/input.js +24 -0
- package/dist/runtime/src/adapters/claude/output.js +2 -1
- package/dist/runtime/src/config/merge.js +10 -2
- package/dist/runtime/src/hooks/codex-loop.js +11 -1
- package/dist/runtime/src/hooks/completion-gate.js +141 -7
- package/dist/runtime/src/hooks/dispatch.js +5 -0
- package/dist/runtime/src/install/doctor.js +4 -1
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/install/plan.js +4 -1
- package/dist/runtime/src/parallel/finish.js +263 -0
- package/dist/runtime/src/parallel/guard.js +59 -0
- package/dist/runtime/src/parallel/manage.js +147 -0
- package/dist/runtime/src/parallel/service.js +263 -0
- package/dist/runtime/src/schema/validate.js +50 -1
- package/docs/en/guides/configuration.md +42 -0
- package/docs/en/spec/delegation.md +10 -0
- package/docs/zh-TW/guides/configuration.md +40 -0
- package/docs/zh-TW/spec/delegation.md +11 -1
- package/package.json +1 -1
- package/schemas/config.schema.json +35 -0
|
@@ -9,13 +9,15 @@ export const COMMAND_NAMES = [
|
|
|
9
9
|
"task",
|
|
10
10
|
"verify",
|
|
11
11
|
"review",
|
|
12
|
-
"allow-stop"
|
|
12
|
+
"allow-stop",
|
|
13
|
+
"worktree"
|
|
13
14
|
];
|
|
14
15
|
const COMMAND_SET = new Set(COMMAND_NAMES);
|
|
15
16
|
const SCOPES = new Set(["project", "user"]);
|
|
16
17
|
const PROFILES = new Set(["advisory", "core", "guardrails", "loop"]);
|
|
17
18
|
// opencode is absent: it has no read-only flag, so it cannot review.
|
|
18
19
|
const REVIEW_TARGETS = new Set(["agy", "claude", "codex"]);
|
|
20
|
+
export const WORKTREE_ACTIONS = ["add", "finish", "list", "resume", "remove"];
|
|
19
21
|
export class CliArgumentError extends Error {
|
|
20
22
|
code;
|
|
21
23
|
option;
|
|
@@ -82,6 +84,8 @@ export function parseArgs(argv) {
|
|
|
82
84
|
let rerun = false;
|
|
83
85
|
let helpSeen = false;
|
|
84
86
|
let versionSeen = false;
|
|
87
|
+
let worktreeName;
|
|
88
|
+
let force = false;
|
|
85
89
|
for (let index = 0; index < argv.length; index += 1) {
|
|
86
90
|
const token = argv[index];
|
|
87
91
|
if (token === undefined) {
|
|
@@ -243,6 +247,12 @@ export function parseArgs(argv) {
|
|
|
243
247
|
}
|
|
244
248
|
yes = true;
|
|
245
249
|
break;
|
|
250
|
+
case "--force":
|
|
251
|
+
if (force) {
|
|
252
|
+
duplicate(token);
|
|
253
|
+
}
|
|
254
|
+
force = true;
|
|
255
|
+
break;
|
|
246
256
|
case "--rerun":
|
|
247
257
|
if (rerun) {
|
|
248
258
|
duplicate(token);
|
|
@@ -292,6 +302,16 @@ export function parseArgs(argv) {
|
|
|
292
302
|
action = token;
|
|
293
303
|
break;
|
|
294
304
|
}
|
|
305
|
+
if (command === "worktree" &&
|
|
306
|
+
action === undefined &&
|
|
307
|
+
WORKTREE_ACTIONS.includes(token)) {
|
|
308
|
+
action = token;
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
if (command === "worktree" && action !== undefined && action !== "list" && worktreeName === undefined) {
|
|
312
|
+
worktreeName = token;
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
295
315
|
if (command !== undefined) {
|
|
296
316
|
throw new CliArgumentError("CLI_UNEXPECTED_ARGUMENT", `Unexpected argument: ${token}`);
|
|
297
317
|
}
|
|
@@ -346,7 +366,8 @@ export function parseArgs(argv) {
|
|
|
346
366
|
checkAuthTargets.length > 0 ||
|
|
347
367
|
dryRun ||
|
|
348
368
|
yes ||
|
|
349
|
-
rerun
|
|
369
|
+
rerun ||
|
|
370
|
+
force) {
|
|
350
371
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Only --json may be combined with global help or version.");
|
|
351
372
|
}
|
|
352
373
|
command = helpSeen ? "help" : "version";
|
|
@@ -357,6 +378,12 @@ export function parseArgs(argv) {
|
|
|
357
378
|
if (command === "trust" && action === undefined) {
|
|
358
379
|
throw new CliArgumentError("CLI_ACTION_REQUIRED", "The trust command requires one of: status, grant, revoke.");
|
|
359
380
|
}
|
|
381
|
+
if (command === "worktree" && action === undefined) {
|
|
382
|
+
throw new CliArgumentError("CLI_ACTION_REQUIRED", `The worktree command requires one of: ${WORKTREE_ACTIONS.join(", ")}.`);
|
|
383
|
+
}
|
|
384
|
+
if (command === "worktree" && action !== "list" && worktreeName === undefined) {
|
|
385
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", `worktree ${action} requires a worktree name.`);
|
|
386
|
+
}
|
|
360
387
|
if (command === "task" && action === undefined) {
|
|
361
388
|
throw new CliArgumentError("CLI_ACTION_REQUIRED", "The task command requires one of: create, status, attach, complete, archive, export.");
|
|
362
389
|
}
|
|
@@ -369,6 +396,7 @@ export function parseArgs(argv) {
|
|
|
369
396
|
command !== "verify" &&
|
|
370
397
|
command !== "review" &&
|
|
371
398
|
command !== "allow-stop" &&
|
|
399
|
+
!(command === "worktree" && sessionId !== undefined && taskId === undefined) &&
|
|
372
400
|
(hasTaskTargetOptions || hasTaskMutationOptions)) {
|
|
373
401
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Task target options may be used only with task or verify.");
|
|
374
402
|
}
|
|
@@ -483,9 +511,27 @@ export function parseArgs(argv) {
|
|
|
483
511
|
yes)) {
|
|
484
512
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "allow-stop requires only --session (and optional --json).");
|
|
485
513
|
}
|
|
514
|
+
if (command === "worktree" &&
|
|
515
|
+
(scope !== undefined ||
|
|
516
|
+
harness !== undefined ||
|
|
517
|
+
profiles.length > 0 ||
|
|
518
|
+
hasTaskMutationOptions ||
|
|
519
|
+
dryRun ||
|
|
520
|
+
yes ||
|
|
521
|
+
rerun)) {
|
|
522
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "worktree accepts only a name, --session and --json.");
|
|
523
|
+
}
|
|
524
|
+
if (force && !(command === "worktree" && action === "remove")) {
|
|
525
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--force may be used only with worktree remove.");
|
|
526
|
+
}
|
|
527
|
+
if (command === "worktree" && action !== "add" && action !== "resume" && sessionId !== undefined) {
|
|
528
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", `worktree ${action} takes the session from the worktree; omit --session.`);
|
|
529
|
+
}
|
|
486
530
|
return {
|
|
487
531
|
command,
|
|
488
532
|
...(action === undefined ? {} : { action }),
|
|
533
|
+
...(worktreeName === undefined ? {} : { worktreeName }),
|
|
534
|
+
...(force ? { force } : {}),
|
|
489
535
|
...(scope === undefined ? {} : { scope }),
|
|
490
536
|
...(harness === undefined ? {} : { harness }),
|
|
491
537
|
...(hookTargets.length === 0 ? {} : { hookTargets }),
|
|
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { execFileSync, spawn } from "node:child_process";
|
|
6
|
+
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { commonHarnessAdapters, harnessHookPath, HARNESS_IDS } from "../../../runtime/src/install/harness.js";
|
|
9
9
|
import { agyRuntimeStatus, agyVersionSupported, hookRegistrationDrift, repositoryTrustStatus, smokeAvailabilityStatus } from "../../../runtime/src/install/probes.js";
|
|
@@ -42,6 +42,8 @@ import { CompletionGateService } from "../../../runtime/src/hooks/completion-gat
|
|
|
42
42
|
import { formatUpdatePlan, runUpdateCommand } from "./commands/update.js";
|
|
43
43
|
import { errorEnvelope } from "./output.js";
|
|
44
44
|
import { runAgyHeadless } from "./agy-headless.js";
|
|
45
|
+
import { runWorktreeCommand } from "./commands/parallel.js";
|
|
46
|
+
import { listWorktrees, worktreeDoctorResult } from "../../../runtime/src/parallel/manage.js";
|
|
45
47
|
const HOOK_RUNTIME_PATH = fileURLToPath(new URL("./hook-entry.js", import.meta.url));
|
|
46
48
|
async function readOptionalText(path) {
|
|
47
49
|
try {
|
|
@@ -121,6 +123,59 @@ async function plannedTrustBinding(root, projectConfig) {
|
|
|
121
123
|
? null
|
|
122
124
|
: await repositoryTrustBinding(root, config, CLI_VERSION);
|
|
123
125
|
}
|
|
126
|
+
function worktreeDependencies() {
|
|
127
|
+
const gateFor = async (root, config) => new CompletionGateService({
|
|
128
|
+
root,
|
|
129
|
+
config,
|
|
130
|
+
gitRunner: gitRunner(root),
|
|
131
|
+
taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
|
|
132
|
+
evidenceStore: new FileEvidenceStore(root, root)
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
git: async (cwd, gitArgs) => await new Promise((resolve) => {
|
|
136
|
+
execFile("git", [...gitArgs], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => resolve({
|
|
137
|
+
exitCode: error === null ? 0 : typeof error.code === "number" ? error.code : 1,
|
|
138
|
+
stdout,
|
|
139
|
+
stderr
|
|
140
|
+
}));
|
|
141
|
+
}),
|
|
142
|
+
loadConfig: async (root) => (await loadEffectiveConfig(root, "project")).config,
|
|
143
|
+
trust: {
|
|
144
|
+
status: async (root, config) => await repositoryTrust(root, config, CLI_VERSION),
|
|
145
|
+
grant: async (root, config) => await trustStore().grant(await repositoryTrustBinding(root, config, CLI_VERSION)),
|
|
146
|
+
revoke: async (root, config) => {
|
|
147
|
+
await trustStore().revoke(await repositoryTrustBinding(root, config, CLI_VERSION));
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
gate: gateFor,
|
|
151
|
+
tasks: (root) => new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
|
|
152
|
+
processRunner: new NodeVerificationProcessRunner(),
|
|
153
|
+
runSetup: async (cwd, step) => await new Promise((resolve) => {
|
|
154
|
+
execFile(step.command, [...step.args], {
|
|
155
|
+
cwd,
|
|
156
|
+
encoding: "utf8",
|
|
157
|
+
timeout: step.timeoutMs,
|
|
158
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
159
|
+
...(process.platform === "win32" ? { shell: true } : {})
|
|
160
|
+
}, (error, stdout, stderr) => resolve({
|
|
161
|
+
exitCode: error === null
|
|
162
|
+
? 0
|
|
163
|
+
: typeof error.code === "number" && error.killed !== true ? error.code : null,
|
|
164
|
+
output: `${stdout}${stderr}`
|
|
165
|
+
}));
|
|
166
|
+
})
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
async function worktreeDoctorProbe(root) {
|
|
170
|
+
let statuses;
|
|
171
|
+
try {
|
|
172
|
+
statuses = await listWorktrees(worktreeDependencies(), root);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return { status: "PASS", message: "No agent-ops worktrees to inspect." };
|
|
176
|
+
}
|
|
177
|
+
return worktreeDoctorResult(statuses, Date.now());
|
|
178
|
+
}
|
|
124
179
|
const argv = process.argv.slice(2);
|
|
125
180
|
function runAgy(args, options = {}) {
|
|
126
181
|
return execFileSync("agy", [...args], {
|
|
@@ -188,7 +243,8 @@ else {
|
|
|
188
243
|
"task",
|
|
189
244
|
"verify",
|
|
190
245
|
"review",
|
|
191
|
-
"allow-stop"
|
|
246
|
+
"allow-stop",
|
|
247
|
+
"worktree"
|
|
192
248
|
].map((command) => [command, async (args) => {
|
|
193
249
|
const root = args.scope === "user"
|
|
194
250
|
? process.env.AGENT_OPS_HOME ?? homedir()
|
|
@@ -224,6 +280,13 @@ else {
|
|
|
224
280
|
confirm: async (plan, trust, warnings) => await confirmInit(plan, trust, warnings)
|
|
225
281
|
});
|
|
226
282
|
}
|
|
283
|
+
if (args.command === "worktree") {
|
|
284
|
+
return await runWorktreeCommand({
|
|
285
|
+
args,
|
|
286
|
+
cwd: root,
|
|
287
|
+
deps: worktreeDependencies()
|
|
288
|
+
});
|
|
289
|
+
}
|
|
227
290
|
if (args.command === "doctor") {
|
|
228
291
|
const doctorManifest = await installedManifest(root);
|
|
229
292
|
const config = (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config;
|
|
@@ -307,7 +370,8 @@ else {
|
|
|
307
370
|
}
|
|
308
371
|
: { status };
|
|
309
372
|
},
|
|
310
|
-
reviewTarget: async (target, deep) => await probeReviewTarget(target, { cwd: root, deep })
|
|
373
|
+
reviewTarget: async (target, deep) => await probeReviewTarget(target, { cwd: root, deep }),
|
|
374
|
+
worktrees: async () => await worktreeDoctorProbe(root)
|
|
311
375
|
},
|
|
312
376
|
...(args.checkAuth === true
|
|
313
377
|
? { checkReviewTargetAuth: true }
|
|
@@ -29,6 +29,8 @@ Commands:
|
|
|
29
29
|
review Run an independent review
|
|
30
30
|
allow-stop Grant one fingerprint-bound completion-gate Stop permit (requires --session)
|
|
31
31
|
agy-run Run headless agy with a process-exit completion recheck
|
|
32
|
+
worktree <add|finish|list|resume|remove>
|
|
33
|
+
Give one session its own Git worktree for parallel work
|
|
32
34
|
|
|
33
35
|
Options:
|
|
34
36
|
--scope <project|user>
|
|
@@ -179,6 +181,35 @@ agent cannot self-authorize it.
|
|
|
179
181
|
Options:
|
|
180
182
|
--session <id> Required
|
|
181
183
|
--json
|
|
184
|
+
`,
|
|
185
|
+
worktree: `Usage: agent-ops worktree <add|finish|resume|remove> <name> [options]
|
|
186
|
+
agent-ops worktree list [--json]
|
|
187
|
+
|
|
188
|
+
Give one writing session its own Git worktree, so parallel sessions stop
|
|
189
|
+
voiding each other's verification and review. Run it from the main checkout.
|
|
190
|
+
|
|
191
|
+
add <name> Create .worktrees/<name> on branch agent-ops/<name> from HEAD,
|
|
192
|
+
copy the ignored agent-ops files and .worktreeinclude matches,
|
|
193
|
+
inherit trust for an identical config, run worktree.setup, and
|
|
194
|
+
point the session's completion gate at the new worktree.
|
|
195
|
+
finish <name>
|
|
196
|
+
Once its task is complete, fast-forward the main checkout's
|
|
197
|
+
branch to the worktree's (after a clean rebase and
|
|
198
|
+
re-verification if the branch moved), record the task in
|
|
199
|
+
git notes, and remove the worktree. One finish runs at a time.
|
|
200
|
+
list Every agent-ops worktree: session, commits ahead, uncommitted
|
|
201
|
+
changes, task status and last activity.
|
|
202
|
+
resume <name>
|
|
203
|
+
Hand a worktree whose session ended to --session <id>.
|
|
204
|
+
remove <name>
|
|
205
|
+
Delete the worktree and its branch. Uncommitted or unmerged
|
|
206
|
+
work needs --force, which the completion gate asks the user
|
|
207
|
+
about.
|
|
208
|
+
|
|
209
|
+
Options:
|
|
210
|
+
--session <id> add, resume: the conversation that will work there (required)
|
|
211
|
+
--force remove: discard uncommitted or unmerged work
|
|
212
|
+
--json
|
|
182
213
|
`
|
|
183
214
|
};
|
|
184
215
|
function wantsJson(argv) {
|
|
@@ -47,7 +47,10 @@ export async function runHookCommand(options) {
|
|
|
47
47
|
...(stopVerification === undefined ? {} : { stopVerification }),
|
|
48
48
|
...(options.completionGate === undefined
|
|
49
49
|
? {}
|
|
50
|
-
: { completionGate: options.completionGate })
|
|
50
|
+
: { completionGate: options.completionGate }),
|
|
51
|
+
...(options.worktreeGuard === undefined
|
|
52
|
+
? {}
|
|
53
|
+
: { worktreeGuard: options.worktreeGuard })
|
|
51
54
|
});
|
|
52
55
|
return descriptor.runtime.formatOutput(options.event, result);
|
|
53
56
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { okEnvelope } from "../output.js";
|
|
2
|
+
import { addWorktree } from "../../../../runtime/src/parallel/service.js";
|
|
3
|
+
import { finishWorktree } from "../../../../runtime/src/parallel/finish.js";
|
|
4
|
+
import { idleWorktrees, listWorktrees, removeWorktree, resumeWorktree } from "../../../../runtime/src/parallel/manage.js";
|
|
5
|
+
function listText(statuses, now) {
|
|
6
|
+
if (statuses.length === 0)
|
|
7
|
+
return "No agent-ops worktrees.";
|
|
8
|
+
const idle = new Set(idleWorktrees(statuses, now).map(({ record }) => record.name));
|
|
9
|
+
return statuses.map(({ record, dirty, ahead, taskStatus, lastActivity }) => [
|
|
10
|
+
`${record.name}${idle.has(record.name) ? " (idle)" : ""}`,
|
|
11
|
+
` path: ${record.path}`,
|
|
12
|
+
` session: ${record.sessionId}`,
|
|
13
|
+
` branch: ${record.branch}, ${ahead} commit(s) ahead of ${record.targetBranch}${dirty ? ", uncommitted changes" : ""}`,
|
|
14
|
+
` task: ${taskStatus}`,
|
|
15
|
+
` last activity: ${lastActivity}`
|
|
16
|
+
].join("\n")).join("\n");
|
|
17
|
+
}
|
|
18
|
+
function addText(result) {
|
|
19
|
+
const { record } = result;
|
|
20
|
+
return [
|
|
21
|
+
`Created worktree ${record.name} for session ${record.sessionId}.`,
|
|
22
|
+
`Path: ${record.path}`,
|
|
23
|
+
`Branch: ${record.branch} (from ${record.targetBranch} at ${record.base})`,
|
|
24
|
+
`Copied: ${result.copied.length === 0 ? "nothing" : result.copied.join(", ")}`,
|
|
25
|
+
`Trust: ${result.trusted ? "inherited" : "not inherited; run agent-ops trust grant inside the worktree"}`,
|
|
26
|
+
...(result.setup.length === 0 ? [] : [`Setup: ${result.setup.join("; ")}`]),
|
|
27
|
+
"",
|
|
28
|
+
"Work only inside that path from now on:",
|
|
29
|
+
`- Claude Code: EnterWorktree with path ${record.path}`,
|
|
30
|
+
`- Codex and agy: run every command with ${record.path} as its working directory`,
|
|
31
|
+
`Finish with: commit, agent-ops verify/review/task complete with --base ${record.base},`,
|
|
32
|
+
`then agent-ops worktree finish ${record.name} from ${record.mainRoot}`,
|
|
33
|
+
"(Claude Code: ExitWorktree with action keep before finishing)."
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
|
36
|
+
function finishText(result) {
|
|
37
|
+
const { record } = result;
|
|
38
|
+
return [
|
|
39
|
+
`Merged ${record.branch} into ${record.targetBranch} at ${result.mergedHead}${result.rebased ? " after a clean rebase and re-verification" : ""}.`,
|
|
40
|
+
`Task ${result.taskId} is recorded in git notes (refs/notes/agent-ops).`,
|
|
41
|
+
`Removed ${record.path}.`,
|
|
42
|
+
...result.warnings.map((warning) => `Warning: ${warning}`)
|
|
43
|
+
].join("\n");
|
|
44
|
+
}
|
|
45
|
+
export async function runWorktreeCommand(options) {
|
|
46
|
+
const { args } = options;
|
|
47
|
+
if (args.action === "list") {
|
|
48
|
+
const worktrees = await listWorktrees(options.deps, options.cwd);
|
|
49
|
+
return okEnvelope("WORKTREE_LIST", { worktrees, text: listText(worktrees, Date.now()) });
|
|
50
|
+
}
|
|
51
|
+
if (args.action === "resume") {
|
|
52
|
+
const record = await resumeWorktree(options.deps, {
|
|
53
|
+
cwd: options.cwd,
|
|
54
|
+
name: args.worktreeName,
|
|
55
|
+
sessionId: args.sessionId ?? process.env.AGENT_OPS_SESSION_ID
|
|
56
|
+
});
|
|
57
|
+
return okEnvelope("WORKTREE_RESUMED", {
|
|
58
|
+
record,
|
|
59
|
+
text: [
|
|
60
|
+
`Session ${record.sessionId} now owns worktree ${record.name}.`,
|
|
61
|
+
`Path: ${record.path}`,
|
|
62
|
+
`- Claude Code: EnterWorktree with path ${record.path}`,
|
|
63
|
+
`- Codex and agy: run every command with ${record.path} as its working directory`,
|
|
64
|
+
"Its next Stop is judged against everything already in the worktree."
|
|
65
|
+
].join("\n")
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (args.action === "remove") {
|
|
69
|
+
const removed = await removeWorktree(options.deps, {
|
|
70
|
+
cwd: options.cwd,
|
|
71
|
+
name: args.worktreeName,
|
|
72
|
+
force: args.force === true
|
|
73
|
+
});
|
|
74
|
+
return okEnvelope("WORKTREE_REMOVED", { ...removed, text: `Removed ${removed.record.path} and ${removed.record.branch}.` });
|
|
75
|
+
}
|
|
76
|
+
if (args.action === "finish") {
|
|
77
|
+
const finished = await finishWorktree(options.deps, { cwd: options.cwd, name: args.worktreeName });
|
|
78
|
+
return okEnvelope("WORKTREE_FINISHED", { ...finished, text: finishText(finished) });
|
|
79
|
+
}
|
|
80
|
+
const sessionId = args.sessionId ?? process.env.AGENT_OPS_SESSION_ID;
|
|
81
|
+
const result = await addWorktree(options.deps, {
|
|
82
|
+
cwd: options.cwd,
|
|
83
|
+
name: args.worktreeName,
|
|
84
|
+
sessionId
|
|
85
|
+
});
|
|
86
|
+
return okEnvelope("WORKTREE_CREATED", { ...result, text: addText(result) });
|
|
87
|
+
}
|
|
@@ -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 { evaluateWorktreeWrite, resolveMainRoot } from "../../../runtime/src/parallel/guard.js";
|
|
12
13
|
import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
|
|
13
14
|
import { TaskService } from "../../../runtime/src/task/service.js";
|
|
14
15
|
import { FileTaskStore } from "../../../runtime/src/task/store.js";
|
|
@@ -208,6 +209,26 @@ function shouldBuildStopVerification(harness, event, config, rawInput) {
|
|
|
208
209
|
}
|
|
209
210
|
return harnessDescriptor(harness).control.registrations.some(({ capability, support }) => capability === "optional-stop-verify" && support !== "unsupported");
|
|
210
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* The gate of one checkout. A session redirected into a worktree is judged by
|
|
214
|
+
* that worktree's gate, built the same way from the worktree's own config.
|
|
215
|
+
*/
|
|
216
|
+
function completionGateFor(root, config, gitRunner) {
|
|
217
|
+
return new CompletionGateService({
|
|
218
|
+
root,
|
|
219
|
+
config,
|
|
220
|
+
gitRunner,
|
|
221
|
+
taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
|
|
222
|
+
evidenceStore: new FileEvidenceStore(root, root),
|
|
223
|
+
forRoot: async (worktree) => {
|
|
224
|
+
const outcome = await loadProjectHookConfig(worktree);
|
|
225
|
+
if (outcome.kind !== "loaded" || !outcome.config.features.completionGate.enabled) {
|
|
226
|
+
throw new Error("The redirected worktree has no enabled completion gate.");
|
|
227
|
+
}
|
|
228
|
+
return completionGateFor(worktree, outcome.config, defaultGitRunner(worktree));
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
211
232
|
/**
|
|
212
233
|
* Runs one hook invocation. Exit code stays zero because native JSON carries
|
|
213
234
|
* decisions; only an explicitly installed agy completion gate fails closed.
|
|
@@ -319,13 +340,15 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
319
340
|
const completionGate = (harnessId === "agy" || harnessId === "claude") &&
|
|
320
341
|
config.features.completionGate.enabled
|
|
321
342
|
? dependencies.completionGate ?? {
|
|
322
|
-
handle: async (normalized) => await
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
343
|
+
handle: async (normalized) => await completionGateFor(root, config, gitRunner).handle(normalized)
|
|
344
|
+
}
|
|
345
|
+
: undefined;
|
|
346
|
+
const worktreeGuard = config.worktree?.mode === "auto"
|
|
347
|
+
? async (write) => {
|
|
348
|
+
const mainRoot = await resolveMainRoot(gitRunner);
|
|
349
|
+
return mainRoot === null
|
|
350
|
+
? { action: "continue", status: "UNKNOWN", code: "WORKTREE_GUARD_UNAVAILABLE" }
|
|
351
|
+
: await evaluateWorktreeWrite(mainRoot, write.paths, write.sessionId);
|
|
329
352
|
}
|
|
330
353
|
: undefined;
|
|
331
354
|
const output = await runHookCommand({
|
|
@@ -334,6 +357,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
334
357
|
stdin: rawInput,
|
|
335
358
|
config,
|
|
336
359
|
trusted,
|
|
360
|
+
...(worktreeGuard === undefined ? {} : { worktreeGuard }),
|
|
337
361
|
...(dependencies.advisory === undefined
|
|
338
362
|
? {}
|
|
339
363
|
: { advisory: dependencies.advisory }),
|
|
@@ -12,7 +12,8 @@ export function agyHookOutput(event, result) {
|
|
|
12
12
|
? `agent-ops: ${result.code}`
|
|
13
13
|
: `agent-ops: ${result.code}: ${result.remedy}`;
|
|
14
14
|
const value = event === "PreToolUse"
|
|
15
|
-
? result.code === "COMPLETION_GATE_PERMIT_CONFIRMATION"
|
|
15
|
+
? result.code === "COMPLETION_GATE_PERMIT_CONFIRMATION" ||
|
|
16
|
+
result.code === "WORKTREE_REMOVE_CONFIRMATION"
|
|
16
17
|
? { decision: "force_ask", reason }
|
|
17
18
|
: result.action === "block"
|
|
18
19
|
? { decision: "deny", reason }
|
|
@@ -11,6 +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
15
|
const CLAUDE_LOOP_LAUNCHER = "${CLAUDE_PROJECT_DIR}/.claude/hooks/agent-ops-loop.sh";
|
|
15
16
|
const CLAUDE_WINDOWS_LOOP_LAUNCHER = "${CLAUDE_PROJECT_DIR}/.claude/hooks/agent-ops-loop.ps1";
|
|
16
17
|
export function claudeSettingsTarget(scope) {
|
|
@@ -46,7 +47,9 @@ function commandHook(event, runtimePath, completionGate = false) {
|
|
|
46
47
|
}
|
|
47
48
|
function matcherGroup(event, runtimePath, completionGate = false) {
|
|
48
49
|
return {
|
|
49
|
-
|
|
50
|
+
// File tools as well as Bash: in worktree auto mode the guard must see a
|
|
51
|
+
// direct edit of the shared checkout, which never passes through a shell.
|
|
52
|
+
...(event === "PreToolUse" ? { matcher: CLAUDE_PRE_TOOL_MATCHER } : {}),
|
|
50
53
|
hooks: [commandHook(event, runtimePath, completionGate)]
|
|
51
54
|
};
|
|
52
55
|
}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
1
2
|
import { normalizeHookEvent } from "../../hooks/normalize.js";
|
|
2
3
|
import { normalizeShellHookEvent } from "../../hooks/shell.js";
|
|
4
|
+
/** Claude's tools that write a file without a shell. */
|
|
5
|
+
export const CLAUDE_FILE_TOOLS = new Set([
|
|
6
|
+
"Edit",
|
|
7
|
+
"MultiEdit",
|
|
8
|
+
"NotebookEdit",
|
|
9
|
+
"Write"
|
|
10
|
+
]);
|
|
3
11
|
function isRecord(value) {
|
|
4
12
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
13
|
}
|
|
@@ -39,6 +47,22 @@ export function normalizeClaudeHookInput(input) {
|
|
|
39
47
|
fullyIdle: input.stop_hook_active !== true
|
|
40
48
|
};
|
|
41
49
|
}
|
|
50
|
+
if (input.hook_event_name === "PreToolUse" &&
|
|
51
|
+
typeof input.tool_name === "string" &&
|
|
52
|
+
CLAUDE_FILE_TOOLS.has(input.tool_name) &&
|
|
53
|
+
isRecord(input.tool_input) &&
|
|
54
|
+
typeof projectRoot === "string" &&
|
|
55
|
+
projectRoot.length > 0) {
|
|
56
|
+
const target = input.tool_input.file_path ?? input.tool_input.notebook_path;
|
|
57
|
+
if (typeof target === "string" && target.length > 0 && !target.includes("\0")) {
|
|
58
|
+
return {
|
|
59
|
+
event: "file-write",
|
|
60
|
+
projectRoot,
|
|
61
|
+
paths: [resolve(projectRoot, target)],
|
|
62
|
+
...(sessionId === undefined ? {} : { sessionId })
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
42
66
|
if (input.hook_event_name === "PreToolUse" &&
|
|
43
67
|
input.tool_name === "Bash" &&
|
|
44
68
|
isRecord(input.tool_input) &&
|
|
@@ -19,7 +19,8 @@ export function claudeHookOutput(event, result) {
|
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
21
|
if (event === "PreToolUse" &&
|
|
22
|
-
result.code === "COMPLETION_GATE_PERMIT_CONFIRMATION"
|
|
22
|
+
(result.code === "COMPLETION_GATE_PERMIT_CONFIRMATION" ||
|
|
23
|
+
result.code === "WORKTREE_REMOVE_CONFIRMATION")) {
|
|
23
24
|
// Asked, never allowed: a one-time Stop permit is the user's to grant, and
|
|
24
25
|
// an agent that could answer this for itself would hold the key to its own
|
|
25
26
|
// gate.
|
|
@@ -110,9 +110,15 @@ export function mergeConfigLayers(inputLayers) {
|
|
|
110
110
|
const reviewRoles = new Map();
|
|
111
111
|
let schemaVersion;
|
|
112
112
|
let features;
|
|
113
|
+
let worktree;
|
|
113
114
|
for (const layer of layers) {
|
|
114
115
|
schemaVersion = effective(layer.config.schemaVersion, layer);
|
|
115
116
|
features = effective(layer.config.features, layer);
|
|
117
|
+
// Whole-block override: a workflow choice, not a guardrail, so a project
|
|
118
|
+
// may turn a user-level `auto` off.
|
|
119
|
+
if (layer.config.worktree !== undefined) {
|
|
120
|
+
worktree = effective(layer.config.worktree, layer);
|
|
121
|
+
}
|
|
116
122
|
for (const profile of layer.config.profiles) {
|
|
117
123
|
profiles.set(profile, effective(profile, layer));
|
|
118
124
|
}
|
|
@@ -158,7 +164,8 @@ export function mergeConfigLayers(inputLayers) {
|
|
|
158
164
|
verificationCommands: [...commands.values()],
|
|
159
165
|
pathMappings: [...mappings.values()],
|
|
160
166
|
securityExceptions: [...exceptions.values()],
|
|
161
|
-
reviewRoles: [...reviewRoles.values()]
|
|
167
|
+
reviewRoles: [...reviewRoles.values()],
|
|
168
|
+
...(worktree === undefined ? {} : { worktree })
|
|
162
169
|
};
|
|
163
170
|
const config = {
|
|
164
171
|
schemaVersion: schemaVersion.value,
|
|
@@ -175,7 +182,8 @@ export function mergeConfigLayers(inputLayers) {
|
|
|
175
182
|
? {}
|
|
176
183
|
: {
|
|
177
184
|
reviewRoles: provenance.reviewRoles.map(({ value }) => value)
|
|
178
|
-
})
|
|
185
|
+
}),
|
|
186
|
+
...(worktree === undefined ? {} : { worktree: worktree.value })
|
|
179
187
|
};
|
|
180
188
|
const validation = validateConfig(config);
|
|
181
189
|
if (!validation.ok) {
|
|
@@ -407,7 +407,7 @@ function parseSessionMarker(source) {
|
|
|
407
407
|
* ponytail: last writer wins. Two concurrent sessions in one checkout already
|
|
408
408
|
* violate the one-writer rule; pass `--session` explicitly if you need that.
|
|
409
409
|
*/
|
|
410
|
-
async function writeSessionMarker(options) {
|
|
410
|
+
export async function writeSessionMarker(options) {
|
|
411
411
|
const path = loopPath(options.root, options.harness, "loop-state.md");
|
|
412
412
|
await withPrivateFileLock(path, options.root, async () => {
|
|
413
413
|
const source = await readPrivateFile(path, options.root);
|
|
@@ -491,6 +491,8 @@ async function restoreState(options) {
|
|
|
491
491
|
function clamp(value, limit) {
|
|
492
492
|
return value.length <= limit ? value : `${value.slice(0, limit - 14)}\n[truncated]`;
|
|
493
493
|
}
|
|
494
|
+
/** Printed only when it is a plain token: it lands verbatim in agent context. */
|
|
495
|
+
const PRINTABLE_SESSION = /^[A-Za-z0-9._:-]{1,128}$/u;
|
|
494
496
|
function sessionContext(options) {
|
|
495
497
|
// Task state is assembled first and clamped on its own budget, so a long
|
|
496
498
|
// goal can no longer crowd out the one thing recovery exists to carry.
|
|
@@ -516,8 +518,15 @@ function sessionContext(options) {
|
|
|
516
518
|
return [];
|
|
517
519
|
})();
|
|
518
520
|
const telemetry = `Telemetry: ${options.telemetryEntries} recent redacted event(s).`;
|
|
521
|
+
// The id every `--session` flag wants. Hooks hear it; the agent otherwise
|
|
522
|
+
// never does, and a shared checkout's recorded marker belongs to whichever
|
|
523
|
+
// session started last.
|
|
524
|
+
const session = options.sessionId !== undefined && PRINTABLE_SESSION.test(options.sessionId)
|
|
525
|
+
? [`Session: ${options.sessionId}`]
|
|
526
|
+
: [];
|
|
519
527
|
const fixed = [
|
|
520
528
|
"agent-ops project loop is active.",
|
|
529
|
+
...session,
|
|
521
530
|
"",
|
|
522
531
|
...restored,
|
|
523
532
|
telemetry
|
|
@@ -648,6 +657,7 @@ export async function runProjectLoop(options) {
|
|
|
648
657
|
telemetryCount(root, options.harness)
|
|
649
658
|
]);
|
|
650
659
|
return sessionOutput(options.harness, sessionContext({
|
|
660
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
651
661
|
goal: safeGoalContext(goal),
|
|
652
662
|
telemetryEntries,
|
|
653
663
|
mode: restoreMode(options.input),
|