@kylecheng3146/agent-ops 0.2.8 → 0.3.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/dist/packages/cli/src/args.js +48 -2
- package/dist/packages/cli/src/bin.js +55 -35
- 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 +88 -0
- package/dist/packages/cli/src/commands/review.js +6 -4
- package/dist/packages/cli/src/commands/task.js +27 -3
- package/dist/packages/cli/src/hook-entry.js +2 -1
- package/dist/packages/cli/src/hook-process.js +34 -7
- package/dist/packages/cli/src/parallel-deps.js +89 -0
- package/dist/runtime/src/adapters/agy/output.js +2 -1
- package/dist/runtime/src/adapters/claude/config.js +6 -2
- 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 +303 -0
- package/dist/runtime/src/parallel/guard.js +76 -0
- package/dist/runtime/src/parallel/manage.js +147 -0
- package/dist/runtime/src/parallel/service.js +281 -0
- package/dist/runtime/src/review/attestation.js +125 -50
- package/dist/runtime/src/review/execute.js +32 -5
- package/dist/runtime/src/schema/validate.js +50 -1
- package/dist/runtime/src/task/completion.js +1 -1
- package/dist/runtime/src/task/service.js +20 -4
- package/dist/runtime/src/task/store.js +7 -2
- package/docs/en/guides/configuration.md +63 -0
- package/docs/en/spec/delegation.md +10 -0
- package/docs/zh-TW/guides/configuration.md +55 -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 }),
|
|
@@ -12,8 +12,6 @@ import { readRecordedSessionId } from "../../../runtime/src/hooks/codex-loop.js"
|
|
|
12
12
|
import { NpmRegistryClient } from "../../../runtime/src/registry/npm.js";
|
|
13
13
|
import { TaskService } from "../../../runtime/src/task/service.js";
|
|
14
14
|
import { FileTaskStore } from "../../../runtime/src/task/store.js";
|
|
15
|
-
import { FileTrustStore } from "../../../runtime/src/security/trust.js";
|
|
16
|
-
import { localStatePaths } from "../../../runtime/src/security/permissions.js";
|
|
17
15
|
import { calculateConfigHash } from "../../../runtime/src/config/hash.js";
|
|
18
16
|
import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
|
|
19
17
|
import { calculateSourceFingerprint } from "../../../runtime/src/verify/source-fingerprint.js";
|
|
@@ -22,6 +20,7 @@ import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn
|
|
|
22
20
|
import { runCli } from "./cli.js";
|
|
23
21
|
import { loadEffectiveConfig, repositoryTrust, repositoryTrustBinding } from "./context.js";
|
|
24
22
|
import { runHookProcess } from "./hook-process.js";
|
|
23
|
+
import { gitRunner, trustStore, worktreeDependencies } from "./parallel-deps.js";
|
|
25
24
|
import { selectYesNo, writeBanner } from "./ui.js";
|
|
26
25
|
import { CLI_VERSION } from "./version.js";
|
|
27
26
|
import { createCommandRegistry } from "./commands/index.js";
|
|
@@ -42,6 +41,9 @@ import { CompletionGateService } from "../../../runtime/src/hooks/completion-gat
|
|
|
42
41
|
import { formatUpdatePlan, runUpdateCommand } from "./commands/update.js";
|
|
43
42
|
import { errorEnvelope } from "./output.js";
|
|
44
43
|
import { runAgyHeadless } from "./agy-headless.js";
|
|
44
|
+
import { runWorktreeCommand } from "./commands/parallel.js";
|
|
45
|
+
import { ensureSessionWorktree, resolveCheckouts } from "../../../runtime/src/parallel/service.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 {
|
|
@@ -76,29 +78,6 @@ async function installedManifest(root) {
|
|
|
76
78
|
async function installedHarness(root) {
|
|
77
79
|
return (await installedManifest(root))?.harness ?? [...HARNESS_IDS];
|
|
78
80
|
}
|
|
79
|
-
function gitRunner(root) {
|
|
80
|
-
return {
|
|
81
|
-
run: async (gitArgs) => {
|
|
82
|
-
try {
|
|
83
|
-
return {
|
|
84
|
-
exitCode: 0,
|
|
85
|
-
stdout: execFileSync("git", [...gitArgs], {
|
|
86
|
-
cwd: root,
|
|
87
|
-
encoding: "buffer",
|
|
88
|
-
stdio: ["ignore", "pipe", "ignore"]
|
|
89
|
-
})
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
catch (error) {
|
|
93
|
-
const failure = error;
|
|
94
|
-
return {
|
|
95
|
-
exitCode: failure.status ?? 1,
|
|
96
|
-
stdout: failure.stdout ?? new Uint8Array()
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
81
|
async function confirmInit(plan, trust, warnings = []) {
|
|
103
82
|
writeBanner({
|
|
104
83
|
isTTY: process.stdout.isTTY === true,
|
|
@@ -111,16 +90,22 @@ async function confirmPlan(text) {
|
|
|
111
90
|
process.stdout.write(text);
|
|
112
91
|
return await selectYesNo("Apply this installation plan?", { input: process.stdin, output: process.stdout }, false);
|
|
113
92
|
}
|
|
114
|
-
function trustStore() {
|
|
115
|
-
const state = localStatePaths(process.env.AGENT_OPS_HOME ?? homedir());
|
|
116
|
-
return new FileTrustStore(state.trustStore, state.anchorDirectory);
|
|
117
|
-
}
|
|
118
93
|
async function plannedTrustBinding(root, projectConfig) {
|
|
119
94
|
const config = (await loadEffectiveConfig(root, "project", projectConfig)).config;
|
|
120
95
|
return config.verification.commands.length === 0
|
|
121
96
|
? null
|
|
122
97
|
: await repositoryTrustBinding(root, config, CLI_VERSION);
|
|
123
98
|
}
|
|
99
|
+
async function worktreeDoctorProbe(root) {
|
|
100
|
+
let statuses;
|
|
101
|
+
try {
|
|
102
|
+
statuses = await listWorktrees(worktreeDependencies(), root);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return { status: "PASS", message: "No agent-ops worktrees to inspect." };
|
|
106
|
+
}
|
|
107
|
+
return worktreeDoctorResult(statuses, Date.now());
|
|
108
|
+
}
|
|
124
109
|
const argv = process.argv.slice(2);
|
|
125
110
|
function runAgy(args, options = {}) {
|
|
126
111
|
return execFileSync("agy", [...args], {
|
|
@@ -167,7 +152,7 @@ else if (argv[0] === "hook") {
|
|
|
167
152
|
stdin: process.stdin,
|
|
168
153
|
writeStdout: (value) => process.stdout.write(value),
|
|
169
154
|
writeStderr: (value) => process.stderr.write(value)
|
|
170
|
-
}, CLI_VERSION);
|
|
155
|
+
}, CLI_VERSION, { worktree: worktreeDependencies() });
|
|
171
156
|
}
|
|
172
157
|
else {
|
|
173
158
|
process.exitCode = await runCli(argv, {
|
|
@@ -188,7 +173,8 @@ else {
|
|
|
188
173
|
"task",
|
|
189
174
|
"verify",
|
|
190
175
|
"review",
|
|
191
|
-
"allow-stop"
|
|
176
|
+
"allow-stop",
|
|
177
|
+
"worktree"
|
|
192
178
|
].map((command) => [command, async (args) => {
|
|
193
179
|
const root = args.scope === "user"
|
|
194
180
|
? process.env.AGENT_OPS_HOME ?? homedir()
|
|
@@ -224,6 +210,13 @@ else {
|
|
|
224
210
|
confirm: async (plan, trust, warnings) => await confirmInit(plan, trust, warnings)
|
|
225
211
|
});
|
|
226
212
|
}
|
|
213
|
+
if (args.command === "worktree") {
|
|
214
|
+
return await runWorktreeCommand({
|
|
215
|
+
args,
|
|
216
|
+
cwd: root,
|
|
217
|
+
deps: worktreeDependencies()
|
|
218
|
+
});
|
|
219
|
+
}
|
|
227
220
|
if (args.command === "doctor") {
|
|
228
221
|
const doctorManifest = await installedManifest(root);
|
|
229
222
|
const config = (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config;
|
|
@@ -307,7 +300,8 @@ else {
|
|
|
307
300
|
}
|
|
308
301
|
: { status };
|
|
309
302
|
},
|
|
310
|
-
reviewTarget: async (target, deep) => await probeReviewTarget(target, { cwd: root, deep })
|
|
303
|
+
reviewTarget: async (target, deep) => await probeReviewTarget(target, { cwd: root, deep }),
|
|
304
|
+
worktrees: async () => await worktreeDoctorProbe(root)
|
|
311
305
|
},
|
|
312
306
|
...(args.checkAuth === true
|
|
313
307
|
? { checkReviewTargetAuth: true }
|
|
@@ -384,14 +378,40 @@ else {
|
|
|
384
378
|
// run inside a session is never told which session it is in.
|
|
385
379
|
const sessionId = process.env.AGENT_OPS_SESSION_ID ??
|
|
386
380
|
await readRecordedSessionId(root);
|
|
387
|
-
const
|
|
388
|
-
?
|
|
381
|
+
const createConfig = args.action === "create"
|
|
382
|
+
? (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config
|
|
389
383
|
: undefined;
|
|
384
|
+
const policyConfigHash = createConfig === undefined
|
|
385
|
+
? undefined
|
|
386
|
+
: calculateConfigHash(createConfig);
|
|
387
|
+
// Auto mode starts the work where it belongs: a task created from
|
|
388
|
+
// the main checkout lands in the session's own worktree.
|
|
389
|
+
const worktreeDeps = worktreeDependencies();
|
|
390
|
+
const fromMain = createConfig?.worktree?.mode === "auto" &&
|
|
391
|
+
args.scope !== "user" &&
|
|
392
|
+
await resolveCheckouts(worktreeDeps, root)
|
|
393
|
+
.then(({ mainRoot, currentRoot }) => mainRoot === currentRoot)
|
|
394
|
+
.catch(() => false);
|
|
390
395
|
return await runTaskCommand({
|
|
391
396
|
args,
|
|
392
397
|
service: taskService,
|
|
393
398
|
...(policyConfigHash === undefined ? {} : { policyConfigHash }),
|
|
394
|
-
...(sessionId === undefined ? {} : { sessionId })
|
|
399
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
400
|
+
...(fromMain
|
|
401
|
+
? {
|
|
402
|
+
sessionWorktree: async (session) => {
|
|
403
|
+
const record = await ensureSessionWorktree(worktreeDeps, {
|
|
404
|
+
cwd: root,
|
|
405
|
+
sessionId: session
|
|
406
|
+
});
|
|
407
|
+
return {
|
|
408
|
+
path: record.path,
|
|
409
|
+
base: record.base,
|
|
410
|
+
service: new TaskService(new FileTaskStore(join(record.path, ".agent-ops", "tasks", "state.json"), record.path))
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
: {})
|
|
395
415
|
});
|
|
396
416
|
}
|
|
397
417
|
if (args.command === "review") {
|
|
@@ -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,88 @@
|
|
|
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 and review with --base ${record.base},`,
|
|
32
|
+
`then agent-ops worktree finish ${record.name} from ${record.mainRoot}, which`,
|
|
33
|
+
"completes every task in the worktree and merges",
|
|
34
|
+
"(Claude Code: ExitWorktree with action keep before finishing; do not run task complete in the worktree)."
|
|
35
|
+
].join("\n");
|
|
36
|
+
}
|
|
37
|
+
function finishText(result) {
|
|
38
|
+
const { record } = result;
|
|
39
|
+
return [
|
|
40
|
+
`Merged ${record.branch} into ${record.targetBranch} at ${result.mergedHead}${result.rebased ? " after a clean rebase and re-verification" : ""}.`,
|
|
41
|
+
`Completed and recorded in git notes (refs/notes/agent-ops): ${result.taskIds.join(", ")}.`,
|
|
42
|
+
`Removed ${record.path}.`,
|
|
43
|
+
...result.warnings.map((warning) => `Warning: ${warning}`)
|
|
44
|
+
].join("\n");
|
|
45
|
+
}
|
|
46
|
+
export async function runWorktreeCommand(options) {
|
|
47
|
+
const { args } = options;
|
|
48
|
+
if (args.action === "list") {
|
|
49
|
+
const worktrees = await listWorktrees(options.deps, options.cwd);
|
|
50
|
+
return okEnvelope("WORKTREE_LIST", { worktrees, text: listText(worktrees, Date.now()) });
|
|
51
|
+
}
|
|
52
|
+
if (args.action === "resume") {
|
|
53
|
+
const record = await resumeWorktree(options.deps, {
|
|
54
|
+
cwd: options.cwd,
|
|
55
|
+
name: args.worktreeName,
|
|
56
|
+
sessionId: args.sessionId ?? process.env.AGENT_OPS_SESSION_ID
|
|
57
|
+
});
|
|
58
|
+
return okEnvelope("WORKTREE_RESUMED", {
|
|
59
|
+
record,
|
|
60
|
+
text: [
|
|
61
|
+
`Session ${record.sessionId} now owns worktree ${record.name}.`,
|
|
62
|
+
`Path: ${record.path}`,
|
|
63
|
+
`- Claude Code: EnterWorktree with path ${record.path}`,
|
|
64
|
+
`- Codex and agy: run every command with ${record.path} as its working directory`,
|
|
65
|
+
"Its next Stop is judged against everything already in the worktree."
|
|
66
|
+
].join("\n")
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (args.action === "remove") {
|
|
70
|
+
const removed = await removeWorktree(options.deps, {
|
|
71
|
+
cwd: options.cwd,
|
|
72
|
+
name: args.worktreeName,
|
|
73
|
+
force: args.force === true
|
|
74
|
+
});
|
|
75
|
+
return okEnvelope("WORKTREE_REMOVED", { ...removed, text: `Removed ${removed.record.path} and ${removed.record.branch}.` });
|
|
76
|
+
}
|
|
77
|
+
if (args.action === "finish") {
|
|
78
|
+
const finished = await finishWorktree(options.deps, { cwd: options.cwd, name: args.worktreeName });
|
|
79
|
+
return okEnvelope("WORKTREE_FINISHED", { ...finished, text: finishText(finished) });
|
|
80
|
+
}
|
|
81
|
+
const sessionId = args.sessionId ?? process.env.AGENT_OPS_SESSION_ID;
|
|
82
|
+
const result = await addWorktree(options.deps, {
|
|
83
|
+
cwd: options.cwd,
|
|
84
|
+
name: args.worktreeName,
|
|
85
|
+
sessionId
|
|
86
|
+
});
|
|
87
|
+
return okEnvelope("WORKTREE_CREATED", { ...result, text: addText(result) });
|
|
88
|
+
}
|
|
@@ -273,7 +273,7 @@ async function reusableReview(options, sourceFingerprint, context) {
|
|
|
273
273
|
if (options.root === undefined) {
|
|
274
274
|
return null;
|
|
275
275
|
}
|
|
276
|
-
const attestation = await findReviewAttestation(options.root, sourceFingerprint);
|
|
276
|
+
const attestation = await findReviewAttestation(options.root, sourceFingerprint, context?.taskId);
|
|
277
277
|
if (attestation === null || attestation.taskId !== context?.taskId) {
|
|
278
278
|
return null;
|
|
279
279
|
}
|
|
@@ -284,7 +284,7 @@ async function reusableReview(options, sourceFingerprint, context) {
|
|
|
284
284
|
context?.policyConfigHash !== options.policyConfigHash) {
|
|
285
285
|
return null;
|
|
286
286
|
}
|
|
287
|
-
const artifact = await readReviewReportArtifact(options.root, sourceFingerprint);
|
|
287
|
+
const artifact = await readReviewReportArtifact(options.root, sourceFingerprint, attestation.taskId);
|
|
288
288
|
if (artifact === null ||
|
|
289
289
|
artifact.taskId !== attestation.taskId ||
|
|
290
290
|
artifact.report === undefined ||
|
|
@@ -488,7 +488,7 @@ export async function runReviewCommand(options) {
|
|
|
488
488
|
})
|
|
489
489
|
});
|
|
490
490
|
}
|
|
491
|
-
await invalidateReviewAttestation(options.root, sourceFingerprint);
|
|
491
|
+
await invalidateReviewAttestation(options.root, sourceFingerprint, context?.taskId);
|
|
492
492
|
pendingInvalidation = false;
|
|
493
493
|
}
|
|
494
494
|
}
|
|
@@ -605,7 +605,9 @@ export async function runReviewCommand(options) {
|
|
|
605
605
|
await options.tasks.recordEvidence(context.taskId, Object.fromEntries(finalResult.results.map((item) => [
|
|
606
606
|
item.criterionId,
|
|
607
607
|
item.evidence.map((reference) => `review:${finalResult.harness}:${reference}`)
|
|
608
|
-
]))
|
|
608
|
+
])),
|
|
609
|
+
// What `worktree finish` completes this task against.
|
|
610
|
+
scope?.mode === "base" ? scope.resolvedBase : null);
|
|
609
611
|
}
|
|
610
612
|
const message = finalResult.status === "PASS"
|
|
611
613
|
? "Independent review passed."
|
|
@@ -87,7 +87,10 @@ export async function runTaskCommand(options) {
|
|
|
87
87
|
if (options.args.title === undefined) {
|
|
88
88
|
throw new AgentOpsError("TASK_TITLE_REQUIRED", "Task creation requires --title.");
|
|
89
89
|
}
|
|
90
|
-
const
|
|
90
|
+
const worktree = sessionId === undefined || options.sessionWorktree === undefined
|
|
91
|
+
? undefined
|
|
92
|
+
: await options.sessionWorktree(sessionId);
|
|
93
|
+
const record = await (worktree?.service ?? options.service).create({
|
|
91
94
|
title: options.args.title,
|
|
92
95
|
criteria: (options.args.criteria ?? []).map(parseCriterion),
|
|
93
96
|
...(options.policyConfigHash === undefined
|
|
@@ -98,9 +101,30 @@ export async function runTaskCommand(options) {
|
|
|
98
101
|
: { parentTaskId: options.args.parentTaskId }),
|
|
99
102
|
...(sessionId === undefined ? {} : { sessionId })
|
|
100
103
|
});
|
|
101
|
-
|
|
104
|
+
const created = taskEnvelope(action, "TASK_CREATED", sessionId === undefined
|
|
102
105
|
? `Created task ${record.task.id}.`
|
|
103
|
-
:
|
|
106
|
+
: record.task.parentTaskId === undefined
|
|
107
|
+
? `Created and attached task ${record.task.id}.`
|
|
108
|
+
: `Created subtask ${record.task.id}; the session stays on the top of its task tree.`, record);
|
|
109
|
+
if (worktree === undefined || created.data === null)
|
|
110
|
+
return created;
|
|
111
|
+
return {
|
|
112
|
+
...created,
|
|
113
|
+
data: {
|
|
114
|
+
...created.data,
|
|
115
|
+
text: [
|
|
116
|
+
`Created in this session's worktree ${worktree.path}.`,
|
|
117
|
+
"Work only inside that path from now on:",
|
|
118
|
+
`- Claude Code: EnterWorktree with path ${worktree.path}`,
|
|
119
|
+
`- Codex and agy: run every command with ${worktree.path} as its working directory`,
|
|
120
|
+
`Commit, then run verify and review there with --base ${worktree.base};`,
|
|
121
|
+
"then leave the worktree (Claude Code: ExitWorktree with action keep) and run",
|
|
122
|
+
"agent-ops worktree finish from the main checkout, which completes the task and merges.",
|
|
123
|
+
"",
|
|
124
|
+
created.data.text
|
|
125
|
+
].join("\n")
|
|
126
|
+
}
|
|
127
|
+
};
|
|
104
128
|
}
|
|
105
129
|
if (action === "status") {
|
|
106
130
|
if (options.args.taskId === undefined && sessionId === undefined) {
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// node <this file> <harness> <event> --managed-by=agent-ops
|
|
4
4
|
import { CLI_VERSION } from "./version.js";
|
|
5
5
|
import { runHookProcess } from "./hook-process.js";
|
|
6
|
+
import { worktreeDependencies } from "./parallel-deps.js";
|
|
6
7
|
process.exitCode = await runHookProcess(process.argv.slice(2), {
|
|
7
8
|
stdin: process.stdin,
|
|
8
9
|
writeStdout: (value) => process.stdout.write(value),
|
|
9
10
|
writeStderr: (value) => process.stderr.write(value)
|
|
10
|
-
}, CLI_VERSION);
|
|
11
|
+
}, CLI_VERSION, { worktree: worktreeDependencies() });
|
|
@@ -9,6 +9,8 @@ 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";
|
|
13
|
+
import { ensureSessionWorktree } from "../../../runtime/src/parallel/service.js";
|
|
12
14
|
import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
|
|
13
15
|
import { TaskService } from "../../../runtime/src/task/service.js";
|
|
14
16
|
import { FileTaskStore } from "../../../runtime/src/task/store.js";
|
|
@@ -208,6 +210,26 @@ function shouldBuildStopVerification(harness, event, config, rawInput) {
|
|
|
208
210
|
}
|
|
209
211
|
return harnessDescriptor(harness).control.registrations.some(({ capability, support }) => capability === "optional-stop-verify" && support !== "unsupported");
|
|
210
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* The gate of one checkout. A session redirected into a worktree is judged by
|
|
215
|
+
* that worktree's gate, built the same way from the worktree's own config.
|
|
216
|
+
*/
|
|
217
|
+
function completionGateFor(root, config, gitRunner) {
|
|
218
|
+
return new CompletionGateService({
|
|
219
|
+
root,
|
|
220
|
+
config,
|
|
221
|
+
gitRunner,
|
|
222
|
+
taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
|
|
223
|
+
evidenceStore: new FileEvidenceStore(root, root),
|
|
224
|
+
forRoot: async (worktree) => {
|
|
225
|
+
const outcome = await loadProjectHookConfig(worktree);
|
|
226
|
+
if (outcome.kind !== "loaded" || !outcome.config.features.completionGate.enabled) {
|
|
227
|
+
throw new Error("The redirected worktree has no enabled completion gate.");
|
|
228
|
+
}
|
|
229
|
+
return completionGateFor(worktree, outcome.config, defaultGitRunner(worktree));
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
211
233
|
/**
|
|
212
234
|
* Runs one hook invocation. Exit code stays zero because native JSON carries
|
|
213
235
|
* decisions; only an explicitly installed agy completion gate fails closed.
|
|
@@ -319,13 +341,17 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
319
341
|
const completionGate = (harnessId === "agy" || harnessId === "claude") &&
|
|
320
342
|
config.features.completionGate.enabled
|
|
321
343
|
? dependencies.completionGate ?? {
|
|
322
|
-
handle: async (normalized) => await
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
344
|
+
handle: async (normalized) => await completionGateFor(root, config, gitRunner).handle(normalized)
|
|
345
|
+
}
|
|
346
|
+
: undefined;
|
|
347
|
+
const worktreeGuard = config.worktree?.mode === "auto"
|
|
348
|
+
? async (write) => {
|
|
349
|
+
const mainRoot = await resolveMainRoot(gitRunner);
|
|
350
|
+
return mainRoot === null
|
|
351
|
+
? { action: "continue", status: "UNKNOWN", code: "WORKTREE_GUARD_UNAVAILABLE" }
|
|
352
|
+
: await evaluateWorktreeWrite(mainRoot, write.paths, write.sessionId, dependencies.worktree === undefined
|
|
353
|
+
? undefined
|
|
354
|
+
: async (sessionId) => (await ensureSessionWorktree(dependencies.worktree, { cwd: mainRoot, sessionId })).path);
|
|
329
355
|
}
|
|
330
356
|
: undefined;
|
|
331
357
|
const output = await runHookCommand({
|
|
@@ -334,6 +360,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
334
360
|
stdin: rawInput,
|
|
335
361
|
config,
|
|
336
362
|
trusted,
|
|
363
|
+
...(worktreeGuard === undefined ? {} : { worktreeGuard }),
|
|
337
364
|
...(dependencies.advisory === undefined
|
|
338
365
|
? {}
|
|
339
366
|
: { advisory: dependencies.advisory }),
|