@kylecheng3146/agent-ops 0.3.3 → 0.3.5
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 +34 -1
- package/dist/packages/cli/src/bin.js +48 -3
- package/dist/packages/cli/src/cli.js +13 -4
- package/dist/packages/cli/src/commands/parallel.js +3 -1
- package/dist/packages/cli/src/commands/review.js +11 -3
- package/dist/packages/cli/src/commands/verify.js +1 -1
- package/dist/packages/cli/src/hook-process.js +10 -1
- package/dist/runtime/src/contracts.js +1 -1
- package/dist/runtime/src/install/doctor.js +31 -1
- package/dist/runtime/src/parallel/finish.js +32 -10
- package/dist/runtime/src/parallel/manage.js +30 -1
- package/dist/runtime/src/parallel/service.js +111 -5
- package/dist/runtime/src/review/execute.js +125 -9
- package/dist/runtime/src/review/extract.js +40 -0
- package/dist/runtime/src/review/invocation.js +3 -0
- package/dist/runtime/src/review/usage.js +19 -0
- package/dist/runtime/src/schema/validate.js +1 -1
- package/dist/runtime/src/task/completion.js +2 -0
- package/dist/runtime/src/task/service.js +77 -1
- package/dist/runtime/src/verify/command-executor.js +6 -2
- package/dist/runtime/src/verify/evidence.js +2 -1
- package/dist/runtime/src/verify/service.js +5 -2
- package/dist/runtime/src/verify/test-count.js +89 -89
- package/docs/en/guides/configuration.md +26 -4
- package/docs/en/spec/acceptance-and-evidence.md +5 -1
- package/docs/en/spec/delegation.md +1 -1
- package/docs/zh-TW/guides/configuration.md +20 -4
- package/docs/zh-TW/spec/acceptance-and-evidence.md +6 -2
- package/docs/zh-TW/spec/delegation.md +1 -1
- package/package.json +1 -1
- package/schemas/evidence.schema.json +1 -1
|
@@ -86,6 +86,8 @@ export function parseArgs(argv) {
|
|
|
86
86
|
let versionSeen = false;
|
|
87
87
|
let worktreeName;
|
|
88
88
|
let force = false;
|
|
89
|
+
let worktreeFrom;
|
|
90
|
+
let worktreeTargetBranch;
|
|
89
91
|
let worktree;
|
|
90
92
|
for (let index = 0; index < argv.length; index += 1) {
|
|
91
93
|
const token = argv[index];
|
|
@@ -266,6 +268,22 @@ export function parseArgs(argv) {
|
|
|
266
268
|
}
|
|
267
269
|
force = true;
|
|
268
270
|
break;
|
|
271
|
+
case "--from": {
|
|
272
|
+
if (worktreeFrom !== undefined) {
|
|
273
|
+
duplicate(token);
|
|
274
|
+
}
|
|
275
|
+
worktreeFrom = readOptionValue(argv, index, token);
|
|
276
|
+
index += 1;
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
case "--target-branch": {
|
|
280
|
+
if (worktreeTargetBranch !== undefined) {
|
|
281
|
+
duplicate(token);
|
|
282
|
+
}
|
|
283
|
+
worktreeTargetBranch = readOptionValue(argv, index, token);
|
|
284
|
+
index += 1;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
269
287
|
case "--rerun":
|
|
270
288
|
if (rerun) {
|
|
271
289
|
duplicate(token);
|
|
@@ -381,6 +399,8 @@ export function parseArgs(argv) {
|
|
|
381
399
|
yes ||
|
|
382
400
|
rerun ||
|
|
383
401
|
worktree !== undefined ||
|
|
402
|
+
worktreeFrom !== undefined ||
|
|
403
|
+
worktreeTargetBranch !== undefined ||
|
|
384
404
|
force) {
|
|
385
405
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Only --json may be combined with global help or version.");
|
|
386
406
|
}
|
|
@@ -536,11 +556,22 @@ export function parseArgs(argv) {
|
|
|
536
556
|
dryRun ||
|
|
537
557
|
yes ||
|
|
538
558
|
rerun)) {
|
|
539
|
-
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "worktree accepts only a name, --session and --json.");
|
|
559
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "worktree accepts only a name, --session, --from, --target-branch and --json.");
|
|
540
560
|
}
|
|
541
561
|
if (force && !(command === "worktree" && action === "remove")) {
|
|
542
562
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--force may be used only with worktree remove.");
|
|
543
563
|
}
|
|
564
|
+
if ((worktreeFrom !== undefined || worktreeTargetBranch !== undefined) &&
|
|
565
|
+
!(command === "worktree" && action === "add")) {
|
|
566
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--from and --target-branch may be used only with worktree add.");
|
|
567
|
+
}
|
|
568
|
+
if (worktreeFrom !== undefined && worktreeFrom.trim() === "") {
|
|
569
|
+
throw new CliArgumentError("CLI_INVALID_VALUE", "Invalid value for --from.", "--from");
|
|
570
|
+
}
|
|
571
|
+
if (worktreeTargetBranch !== undefined &&
|
|
572
|
+
!/^[A-Za-z0-9._/-]{1,128}$/u.test(worktreeTargetBranch)) {
|
|
573
|
+
throw new CliArgumentError("CLI_INVALID_VALUE", `Invalid value for --target-branch: ${worktreeTargetBranch}`, "--target-branch");
|
|
574
|
+
}
|
|
544
575
|
if (command === "worktree" && action !== "add" && action !== "resume" && sessionId !== undefined) {
|
|
545
576
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", `worktree ${action} takes the session from the worktree; omit --session.`);
|
|
546
577
|
}
|
|
@@ -563,6 +594,8 @@ export function parseArgs(argv) {
|
|
|
563
594
|
...(evidence.length === 0 ? {} : { evidence }),
|
|
564
595
|
...(sessionId === undefined ? {} : { sessionId }),
|
|
565
596
|
...(base === undefined ? {} : { base }),
|
|
597
|
+
...(worktreeFrom === undefined ? {} : { worktreeFrom }),
|
|
598
|
+
...(worktreeTargetBranch === undefined ? {} : { worktreeTargetBranch }),
|
|
566
599
|
...(checkAuth ? { checkAuth } : {}),
|
|
567
600
|
...(checkAuthTargets.length === 0 ? {} : { checkAuthTargets }),
|
|
568
601
|
rerun,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { readFile, readdir, stat } 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";
|
|
@@ -43,7 +43,8 @@ import { errorEnvelope } from "./output.js";
|
|
|
43
43
|
import { runAgyHeadless } from "./agy-headless.js";
|
|
44
44
|
import { runWorktreeCommand } from "./commands/parallel.js";
|
|
45
45
|
import { ensureSessionWorktree, resolveCheckouts } from "../../../runtime/src/parallel/service.js";
|
|
46
|
-
import { listWorktrees, worktreeDoctorResult } from "../../../runtime/src/parallel/manage.js";
|
|
46
|
+
import { listWorktrees, branchLockDoctorResult, worktreeDoctorResult } from "../../../runtime/src/parallel/manage.js";
|
|
47
|
+
import { detectGhostFiles, ghostFilesDoctorResult } from "../../../runtime/src/install/doctor.js";
|
|
47
48
|
const HOOK_RUNTIME_PATH = fileURLToPath(new URL("./hook-entry.js", import.meta.url));
|
|
48
49
|
async function readOptionalText(path) {
|
|
49
50
|
try {
|
|
@@ -106,6 +107,48 @@ async function worktreeDoctorProbe(root) {
|
|
|
106
107
|
}
|
|
107
108
|
return worktreeDoctorResult(statuses, Date.now());
|
|
108
109
|
}
|
|
110
|
+
const GHOST_SCAN_ALLOWLIST = new Set([
|
|
111
|
+
".git", ".worktrees", ".agent-ops", ".tmp", "node_modules", "dist"
|
|
112
|
+
]);
|
|
113
|
+
async function worktreeBranchLockProbe(root) {
|
|
114
|
+
try {
|
|
115
|
+
const result = await worktreeDependencies().git(root, ["worktree", "list", "--porcelain"]);
|
|
116
|
+
if (result.exitCode !== 0) {
|
|
117
|
+
return { status: "UNKNOWN", message: "Git could not list worktrees." };
|
|
118
|
+
}
|
|
119
|
+
return branchLockDoctorResult(result.stdout);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return { status: "UNKNOWN", message: "Git could not list worktrees." };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function rootGhostFilesProbe(root) {
|
|
126
|
+
try {
|
|
127
|
+
const listing = await worktreeDependencies().git(root, ["ls-files", "--others", "--exclude-standard", "-z"]);
|
|
128
|
+
const untracked = listing.exitCode === 0
|
|
129
|
+
? new Set(listing.stdout.split("\0").filter((entry) => entry.length > 0 && !entry.includes("/")))
|
|
130
|
+
: new Set();
|
|
131
|
+
const names = (await readdir(root)).filter((name) => !GHOST_SCAN_ALLOWLIST.has(name)).slice(0, 512);
|
|
132
|
+
const candidates = await Promise.all(names.map(async (name) => {
|
|
133
|
+
try {
|
|
134
|
+
const info = await stat(join(root, name));
|
|
135
|
+
return {
|
|
136
|
+
name,
|
|
137
|
+
size: info.size,
|
|
138
|
+
isFile: info.isFile(),
|
|
139
|
+
tracked: !untracked.has(name)
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return { name, size: -1, isFile: false, tracked: true };
|
|
144
|
+
}
|
|
145
|
+
}));
|
|
146
|
+
return ghostFilesDoctorResult(detectGhostFiles(candidates.filter((candidate) => candidate.size >= 0)));
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return { status: "UNKNOWN", message: "The repository root could not be scanned." };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
109
152
|
const argv = process.argv.slice(2);
|
|
110
153
|
function runAgy(args, options = {}) {
|
|
111
154
|
return execFileSync("agy", [...args], {
|
|
@@ -301,7 +344,9 @@ else {
|
|
|
301
344
|
: { status };
|
|
302
345
|
},
|
|
303
346
|
reviewTarget: async (target, deep) => await probeReviewTarget(target, { cwd: root, deep }),
|
|
304
|
-
worktrees: async () => await worktreeDoctorProbe(root)
|
|
347
|
+
worktrees: async () => await worktreeDoctorProbe(root),
|
|
348
|
+
worktreeBranchLock: async () => await worktreeBranchLockProbe(root),
|
|
349
|
+
rootGhostFiles: async () => await rootGhostFilesProbe(root)
|
|
305
350
|
},
|
|
306
351
|
...(args.checkAuth === true
|
|
307
352
|
? { checkReviewTargetAuth: true }
|
|
@@ -191,10 +191,15 @@ Options:
|
|
|
191
191
|
Give one writing session its own Git worktree, so parallel sessions stop
|
|
192
192
|
voiding each other's verification and review. Run it from the main checkout.
|
|
193
193
|
|
|
194
|
-
add <name> Create .worktrees/<name> on branch agent-ops/<name> from HEAD
|
|
195
|
-
copy the ignored agent-ops
|
|
196
|
-
inherit trust for an
|
|
197
|
-
|
|
194
|
+
add <name> Create .worktrees/<name> on branch agent-ops/<name> from HEAD
|
|
195
|
+
(or --from <commit|branch|tag>), copy the ignored agent-ops
|
|
196
|
+
files and .worktreeinclude matches, inherit trust for an
|
|
197
|
+
identical config, run worktree.setup, and point the session's
|
|
198
|
+
completion gate at the new worktree. When the main checkout is
|
|
199
|
+
detached, --target-branch <branch> is required and names the
|
|
200
|
+
branch finish merges back into.
|
|
201
|
+
A branch already checked out in another worktree is rejected
|
|
202
|
+
before Git runs; see agent-ops worktree list.
|
|
198
203
|
finish <name>
|
|
199
204
|
Once its task is complete, fast-forward the main checkout's
|
|
200
205
|
branch to the worktree's (after a clean rebase and
|
|
@@ -211,6 +216,10 @@ voiding each other's verification and review. Run it from the main checkout.
|
|
|
211
216
|
|
|
212
217
|
Options:
|
|
213
218
|
--session <id> add, resume: the conversation that will work there (required)
|
|
219
|
+
--from <ref> add: branch the new worktree from this commit, branch or tag (default HEAD)
|
|
220
|
+
--target-branch <branch>
|
|
221
|
+
add: branch finish merges back into (defaults to the current
|
|
222
|
+
branch; required when the main checkout is detached)
|
|
214
223
|
--force remove: discard uncommitted or unmerged work
|
|
215
224
|
--json
|
|
216
225
|
`
|
|
@@ -82,7 +82,9 @@ export async function runWorktreeCommand(options) {
|
|
|
82
82
|
const result = await addWorktree(options.deps, {
|
|
83
83
|
cwd: options.cwd,
|
|
84
84
|
name: args.worktreeName,
|
|
85
|
-
sessionId
|
|
85
|
+
sessionId,
|
|
86
|
+
...(args.worktreeFrom === undefined ? {} : { from: args.worktreeFrom }),
|
|
87
|
+
...(args.worktreeTargetBranch === undefined ? {} : { targetBranch: args.worktreeTargetBranch })
|
|
86
88
|
});
|
|
87
89
|
return okEnvelope("WORKTREE_CREATED", { ...result, text: addText(result) });
|
|
88
90
|
}
|
|
@@ -111,8 +111,7 @@ async function currentEvidence(options, context, criterionId, commandId, configH
|
|
|
111
111
|
continue;
|
|
112
112
|
}
|
|
113
113
|
const evidence = validation.value;
|
|
114
|
-
if (evidence.
|
|
115
|
-
evidence.taskId !== context.taskId ||
|
|
114
|
+
if (evidence.taskId !== context.taskId ||
|
|
116
115
|
evidence.criterionId !== criterionId ||
|
|
117
116
|
evidence.commandId !== commandId ||
|
|
118
117
|
evidence.configHash !== configHash ||
|
|
@@ -137,6 +136,9 @@ async function preflightReview(options, context, sourceFingerprint) {
|
|
|
137
136
|
options.gitRunner === undefined) {
|
|
138
137
|
return { ok: false, reason: "stale-verification" };
|
|
139
138
|
}
|
|
139
|
+
if (context.criteria.some((criterion) => !options.config.verification.commands.some(({ id, required }) => required && (criterion.verifierIds ?? []).includes(id)))) {
|
|
140
|
+
return { ok: false, reason: "missing-verification-evidence" };
|
|
141
|
+
}
|
|
140
142
|
const configHash = calculateConfigHash(options.config);
|
|
141
143
|
const commands = [];
|
|
142
144
|
for (const criterion of context.criteria) {
|
|
@@ -146,7 +148,13 @@ async function preflightReview(options, context, sourceFingerprint) {
|
|
|
146
148
|
return { ok: false, reason: "missing-verification-evidence" };
|
|
147
149
|
}
|
|
148
150
|
const found = await currentEvidence(options, context, criterion.id, commandId, configHash, sourceFingerprint);
|
|
149
|
-
const
|
|
151
|
+
const compatible = command.evidence.kind === "test-count"
|
|
152
|
+
? found.current.filter(({ evidence }) => evidence.schemaVersion === 3)
|
|
153
|
+
: found.current;
|
|
154
|
+
if (command.required && found.current.length > 0 && compatible.length === 0) {
|
|
155
|
+
return { ok: false, reason: "stale-verification" };
|
|
156
|
+
}
|
|
157
|
+
const selected = newestEvidence(compatible);
|
|
150
158
|
if (command.required !== true) {
|
|
151
159
|
if (selected !== undefined) {
|
|
152
160
|
commands.push({
|
|
@@ -57,7 +57,7 @@ function publicReport(report) {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
function formatResult(result) {
|
|
60
|
-
const testCount = result.testCount === null ? "" : ` (tests: ${result.testCount})`;
|
|
60
|
+
const testCount = result.testCount === null ? "" : ` (passed tests: ${result.testCount})`;
|
|
61
61
|
const lines = [
|
|
62
62
|
`- ${result.status} ${safe(result.commandId)}${testCount}`
|
|
63
63
|
];
|
|
@@ -10,7 +10,7 @@ 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
12
|
import { evaluateWorktreeWrite, resolveMainRoot } from "../../../runtime/src/parallel/guard.js";
|
|
13
|
-
import { ensureSessionWorktree } from "../../../runtime/src/parallel/service.js";
|
|
13
|
+
import { ensureSessionWorktree, recordSessionOrigin } from "../../../runtime/src/parallel/service.js";
|
|
14
14
|
import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
|
|
15
15
|
import { TaskService } from "../../../runtime/src/task/service.js";
|
|
16
16
|
import { FileTaskStore } from "../../../runtime/src/task/store.js";
|
|
@@ -354,6 +354,15 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
354
354
|
: async (sessionId) => (await ensureSessionWorktree(dependencies.worktree, { cwd: mainRoot, sessionId })).path);
|
|
355
355
|
}
|
|
356
356
|
: undefined;
|
|
357
|
+
if (hookEvent === "SessionStart" && config.worktree?.mode === "auto" &&
|
|
358
|
+
dependencies.worktree !== undefined && typeof parsedInput === "object" && parsedInput !== null) {
|
|
359
|
+
const fields = parsedInput;
|
|
360
|
+
const sessionId = harnessId === "agy" ? fields.conversationId : fields.session_id;
|
|
361
|
+
if (typeof sessionId === "string") {
|
|
362
|
+
// Advisory: a session that records nothing falls back to HEAD at add time.
|
|
363
|
+
await recordSessionOrigin(dependencies.worktree, { cwd: root, sessionId }).catch(() => undefined);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
357
366
|
const output = await runHookCommand({
|
|
358
367
|
harness: harness,
|
|
359
368
|
event: hookEvent,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const CONFIG_SCHEMA_VERSION = 3;
|
|
2
2
|
export const TASK_SCHEMA_VERSION = 1;
|
|
3
|
-
export const EVIDENCE_SCHEMA_VERSION =
|
|
3
|
+
export const EVIDENCE_SCHEMA_VERSION = 3;
|
|
4
4
|
/** @deprecated Use the document-specific schema version constants. */
|
|
5
5
|
export const SCHEMA_VERSION = CONFIG_SCHEMA_VERSION;
|
|
6
6
|
/**
|
|
@@ -396,6 +396,30 @@ function checkVerificationCommands(config) {
|
|
|
396
396
|
}
|
|
397
397
|
return check("verification-commands", "PASS", `Required verifiers: ${required.map(({ id }) => id).join(", ")}.`);
|
|
398
398
|
}
|
|
399
|
+
const GHOST_NAME_PATTERN = /[\s\x00-\x1f\x7f\u2000-\u206f\ufeff]/u;
|
|
400
|
+
/** Names doctor treats as unparseable pollution rather than user files. Contentful tracked files never reach this predicate. */
|
|
401
|
+
export function isGhostFileName(name) {
|
|
402
|
+
return name.length === 0 || GHOST_NAME_PATTERN.test(name);
|
|
403
|
+
}
|
|
404
|
+
/** Root entries worth flagging: untracked regular files that are empty or unparseably named. Warns, never blocks. */
|
|
405
|
+
export function detectGhostFiles(entries) {
|
|
406
|
+
return entries
|
|
407
|
+
.filter((entry) => entry.isFile && !entry.tracked && (entry.size === 0 || isGhostFileName(entry.name)))
|
|
408
|
+
.map((entry) => entry.name)
|
|
409
|
+
.sort();
|
|
410
|
+
}
|
|
411
|
+
/** Doctor's reading of the repository root: stray pollution is worth a look, not a failure. */
|
|
412
|
+
export function ghostFilesDoctorResult(names) {
|
|
413
|
+
if (names.length === 0) {
|
|
414
|
+
return { status: "PASS", message: "No stray files at the repository root." };
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
status: "DEGRADED",
|
|
418
|
+
message: `Unexpected file(s) at the repository root: ${names.join(", ")}.`,
|
|
419
|
+
code: "ROOT_GHOST_FILE",
|
|
420
|
+
remediation: `Inspect and remove them: ${names.map((name) => `rm ${JSON.stringify(name)}`).join("; ")}.`
|
|
421
|
+
};
|
|
422
|
+
}
|
|
399
423
|
/**
|
|
400
424
|
* What the surrounding host withholds from a reviewer. Reported separately
|
|
401
425
|
* from `review-targets` on purpose: a sandbox that blocks the network makes an
|
|
@@ -498,7 +522,13 @@ export async function doctorInstallation(options) {
|
|
|
498
522
|
checkVerificationCommands(config.config),
|
|
499
523
|
...(options.probes?.worktrees === undefined
|
|
500
524
|
? []
|
|
501
|
-
: [await checkProbe("worktrees", options.probes.worktrees)])
|
|
525
|
+
: [await checkProbe("worktrees", options.probes.worktrees)]),
|
|
526
|
+
...(options.probes?.worktreeBranchLock === undefined
|
|
527
|
+
? []
|
|
528
|
+
: [await checkProbe("worktree-branch-lock", options.probes.worktreeBranchLock)]),
|
|
529
|
+
...(options.probes?.rootGhostFiles === undefined
|
|
530
|
+
? []
|
|
531
|
+
: [await checkProbe("root-ghost-files", options.probes.rootGhostFiles)])
|
|
502
532
|
];
|
|
503
533
|
return {
|
|
504
534
|
checks,
|
|
@@ -5,7 +5,7 @@ import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
|
5
5
|
import { currentGateFingerprint } from "../hooks/completion-gate.js";
|
|
6
6
|
import { collectChangeSurface } from "../verify/change-surface.js";
|
|
7
7
|
import { aggregateVerificationStatus, executeConfiguredCommand } from "../verify/command-executor.js";
|
|
8
|
-
import { assertWorktreeName, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath } from "./service.js";
|
|
8
|
+
import { assertWorktreeName, parseWorktreeListPorcelain, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath } from "./service.js";
|
|
9
9
|
export const NOTES_REF = "agent-ops";
|
|
10
10
|
const LOCK_NAME = "agent-ops-finish.lock";
|
|
11
11
|
const LOCK_POLL_MS = 2_000;
|
|
@@ -191,8 +191,9 @@ function noteText(record, tasks) {
|
|
|
191
191
|
].join("\n");
|
|
192
192
|
}
|
|
193
193
|
/**
|
|
194
|
-
* Brings a worktree's reviewed work into
|
|
195
|
-
*
|
|
194
|
+
* Brings a worktree's reviewed work into its target branch by fast-forward
|
|
195
|
+
* only, then retires the worktree. Run from the main checkout, which may be on
|
|
196
|
+
* another branch: then only the target's ref moves.
|
|
196
197
|
*/
|
|
197
198
|
export async function finishWorktree(deps, options) {
|
|
198
199
|
const name = assertWorktreeName(options.name);
|
|
@@ -207,12 +208,24 @@ export async function finishWorktree(deps, options) {
|
|
|
207
208
|
return await withFinishLock(deps, commonDir, async () => {
|
|
208
209
|
const mainRunner = runner(deps, mainRoot);
|
|
209
210
|
const worktreeRunner = runner(deps, record.path);
|
|
210
|
-
if ((await collectChangeSurface(mainRunner)).paths.length > 0) {
|
|
211
|
-
throw finishError("WORKTREE_MAIN_DIRTY", "The main checkout has uncommitted changes; a fast-forward would mix them in.");
|
|
212
|
-
}
|
|
213
211
|
const onBranch = (await deps.git(mainRoot, ["symbolic-ref", "--short", "-q", "HEAD"])).stdout.trim();
|
|
214
|
-
|
|
215
|
-
|
|
212
|
+
// On the target, the merge rewrites the checkout's files, so its changes
|
|
213
|
+
// would mix in. Anywhere else only the target's ref moves and the checkout
|
|
214
|
+
// is never touched.
|
|
215
|
+
const onTarget = onBranch === record.targetBranch;
|
|
216
|
+
if (onTarget) {
|
|
217
|
+
if ((await collectChangeSurface(mainRunner)).paths.length > 0) {
|
|
218
|
+
throw finishError("WORKTREE_MAIN_DIRTY", "The main checkout has uncommitted changes; a fast-forward would mix them in.");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
const listing = await deps.git(mainRoot, ["worktree", "list", "--porcelain"]);
|
|
223
|
+
const holder = listing.exitCode === 0
|
|
224
|
+
? parseWorktreeListPorcelain(listing.stdout).find(({ branch }) => branch === record.targetBranch)
|
|
225
|
+
: undefined;
|
|
226
|
+
if (holder !== undefined) {
|
|
227
|
+
throw finishError("WORKTREE_BRANCH_CHECKED_OUT", `${record.targetBranch} is checked out at ${holder.path}; moving it would desync that checkout. Switch it away or finish from there.`);
|
|
228
|
+
}
|
|
216
229
|
}
|
|
217
230
|
if ((await collectChangeSurface(worktreeRunner)).paths.length > 0) {
|
|
218
231
|
throw finishError("WORKTREE_DIRTY", `Commit or discard the uncommitted changes in ${record.path} first.`);
|
|
@@ -276,8 +289,17 @@ export async function finishWorktree(deps, options) {
|
|
|
276
289
|
const mainConfig = await deps.loadConfig(mainRoot);
|
|
277
290
|
const gateEnabled = mainConfig.features.completionGate.enabled;
|
|
278
291
|
const before = gateEnabled ? await currentGateFingerprint(mainRoot, mainRunner) : "";
|
|
279
|
-
|
|
280
|
-
|
|
292
|
+
let mergedHead;
|
|
293
|
+
if (onTarget) {
|
|
294
|
+
await git(deps, mainRoot, ["merge", "--ff-only", record.branch], "WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}.`);
|
|
295
|
+
mergedHead = await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
// Compare-and-swap on the target's value read above: a target that moved
|
|
299
|
+
// since fails here instead of being overwritten.
|
|
300
|
+
mergedHead = await git(deps, mainRoot, ["rev-parse", "--verify", `${record.branch}^{commit}`], "WORKTREE_LOG_FAILED", `Git could not read ${record.branch}.`);
|
|
301
|
+
await git(deps, mainRoot, ["update-ref", "-m", `agent-ops finish ${record.branch}`, `refs/heads/${record.targetBranch}`, mergedHead, target], "WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}.`);
|
|
302
|
+
}
|
|
281
303
|
// The merge happened; everything below is cleanup, reported, never undone.
|
|
282
304
|
const warnings = [];
|
|
283
305
|
const attempt = async (label, step) => {
|
|
@@ -3,7 +3,7 @@ import { join, resolve } from "node:path";
|
|
|
3
3
|
import { sha256 } from "../fs/hash.js";
|
|
4
4
|
import { AgentOpsError } from "../fs/paths.js";
|
|
5
5
|
import { collectChangeSurface } from "../verify/change-surface.js";
|
|
6
|
-
import { assertSessionId, assertWorktreeName, bindSession, insideWorktreeDirectory, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath, writeWorktreeRecord } from "./service.js";
|
|
6
|
+
import { assertSessionId, assertWorktreeName, bindSession, insideWorktreeDirectory, parseWorktreeListPorcelain, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath, writeWorktreeRecord } from "./service.js";
|
|
7
7
|
/**
|
|
8
8
|
* A baseline no source state hashes to. A resumed session inherits whatever
|
|
9
9
|
* the last one left in the worktree, so none of it counts as "unchanged".
|
|
@@ -95,6 +95,35 @@ export function worktreeDoctorResult(statuses, now) {
|
|
|
95
95
|
remediation: "Resume, finish or remove them; see agent-ops worktree list."
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
|
+
/** Branches checked out in more than one worktree. Scans every worktree: Git's branch lock does not distinguish agent-ops checkouts from foreign ones. */
|
|
99
|
+
export function detectDuplicateBranchCheckouts(porcelain) {
|
|
100
|
+
const byBranch = new Map();
|
|
101
|
+
for (const entry of parseWorktreeListPorcelain(porcelain)) {
|
|
102
|
+
if (entry.branch === null)
|
|
103
|
+
continue;
|
|
104
|
+
const paths = byBranch.get(entry.branch) ?? [];
|
|
105
|
+
paths.push(entry.path);
|
|
106
|
+
byBranch.set(entry.branch, paths);
|
|
107
|
+
}
|
|
108
|
+
return [...byBranch]
|
|
109
|
+
.filter(([, paths]) => paths.length > 1)
|
|
110
|
+
.map(([branch, paths]) => ({ branch, paths: [...paths].sort() }))
|
|
111
|
+
.sort((left, right) => left.branch.localeCompare(right.branch));
|
|
112
|
+
}
|
|
113
|
+
/** Doctor's reading of the branch lock: a duplicated checkout blocks switching back, so it fails. */
|
|
114
|
+
export function branchLockDoctorResult(porcelain) {
|
|
115
|
+
const duplicates = detectDuplicateBranchCheckouts(porcelain);
|
|
116
|
+
if (duplicates.length === 0) {
|
|
117
|
+
return { status: "PASS", message: "No branch is checked out in more than one worktree." };
|
|
118
|
+
}
|
|
119
|
+
const detail = duplicates.map(({ branch, paths }) => `${branch} (${paths.join(", ")})`).join("; ");
|
|
120
|
+
return {
|
|
121
|
+
status: "FAIL",
|
|
122
|
+
message: `Branch checked out more than once: ${detail}.`,
|
|
123
|
+
code: "WORKTREE_BRANCH_LOCKED",
|
|
124
|
+
remediation: "Run git worktree list; switch the extra checkout to another branch, or remove it (agent-ops worktree remove <name> --force discards that work)."
|
|
125
|
+
};
|
|
126
|
+
}
|
|
98
127
|
async function requireRecord(mainRoot, name) {
|
|
99
128
|
const record = await readWorktreeRecord(worktreePath(mainRoot, name));
|
|
100
129
|
if (record === null) {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { chmod, copyFile, lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, join, sep } from "node:path";
|
|
3
3
|
import { calculateConfigHash } from "../config/hash.js";
|
|
4
|
+
import { sha256 } from "../fs/hash.js";
|
|
4
5
|
import { parseInstallManifest } from "../fs/manifest.js";
|
|
5
6
|
import { AgentOpsError } from "../fs/paths.js";
|
|
6
7
|
import { writeSessionMarker } from "../hooks/codex-loop.js";
|
|
7
|
-
import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
8
|
+
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
8
9
|
/**
|
|
9
10
|
* One Git worktree per writing session, so parallel sessions stop sharing the
|
|
10
11
|
* change surface every fingerprint is taken over. Worktrees live inside the
|
|
@@ -17,6 +18,7 @@ export const WORKTREE_RECORD_PATH = ".agent-ops/tasks/worktree.json";
|
|
|
17
18
|
const EXCLUDE_LINE = `/${WORKTREE_DIRECTORY}/`;
|
|
18
19
|
const NAME = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/u;
|
|
19
20
|
const SESSION = /^[^\0\r\n]{1,256}$/u;
|
|
21
|
+
const TARGET_BRANCH = /^[A-Za-z0-9._/-]{1,128}$/u;
|
|
20
22
|
const DEFAULT_SETUP_TIMEOUT_MS = 10 * 60 * 1000;
|
|
21
23
|
function worktreeError(code, message) {
|
|
22
24
|
return new AgentOpsError(code, message);
|
|
@@ -61,6 +63,40 @@ export function worktreePath(mainRoot, name) {
|
|
|
61
63
|
export function insideWorktreeDirectory(mainRoot, path) {
|
|
62
64
|
return path.startsWith(`${join(mainRoot, WORKTREE_DIRECTORY)}${sep}`);
|
|
63
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Parses `git worktree list --porcelain` into path/branch pairs.
|
|
68
|
+
* Detached checkouts yield a null branch.
|
|
69
|
+
*/
|
|
70
|
+
export function parseWorktreeListPorcelain(output) {
|
|
71
|
+
const entries = [];
|
|
72
|
+
let currentPath;
|
|
73
|
+
let currentBranch;
|
|
74
|
+
const flush = () => {
|
|
75
|
+
if (currentPath !== undefined) {
|
|
76
|
+
entries.push({ path: currentPath, branch: currentBranch ?? null });
|
|
77
|
+
}
|
|
78
|
+
currentPath = undefined;
|
|
79
|
+
currentBranch = undefined;
|
|
80
|
+
};
|
|
81
|
+
for (const line of output.split("\n")) {
|
|
82
|
+
if (line.startsWith("worktree ")) {
|
|
83
|
+
flush();
|
|
84
|
+
currentPath = line.slice("worktree ".length).trim();
|
|
85
|
+
}
|
|
86
|
+
else if (line.startsWith("branch ")) {
|
|
87
|
+
const ref = line.slice("branch ".length).trim();
|
|
88
|
+
currentBranch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
|
|
89
|
+
}
|
|
90
|
+
else if (line === "detached") {
|
|
91
|
+
currentBranch = null;
|
|
92
|
+
}
|
|
93
|
+
else if (line === "") {
|
|
94
|
+
flush();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
flush();
|
|
98
|
+
return entries.filter((entry) => entry.path.length > 0);
|
|
99
|
+
}
|
|
64
100
|
async function exists(path) {
|
|
65
101
|
try {
|
|
66
102
|
await lstat(path);
|
|
@@ -186,18 +222,48 @@ export async function removeCheckout(deps, record, force) {
|
|
|
186
222
|
await git(deps, record.mainRoot, ["branch", force ? "-D" : "-d", record.branch], "WORKTREE_REMOVE_FAILED", `Git could not delete ${record.branch}.`);
|
|
187
223
|
}
|
|
188
224
|
/**
|
|
189
|
-
* Creates `.worktrees/<name>` on `agent-ops/<name>` from
|
|
190
|
-
*
|
|
225
|
+
* Creates `.worktrees/<name>` on `agent-ops/<name>` from `from` and makes it a
|
|
226
|
+
* working agent-ops checkout for `sessionId`. With neither `from` nor
|
|
227
|
+
* `targetBranch`, the branch recorded when the session began is both; without
|
|
228
|
+
* a record, HEAD and its branch. When the main checkout is detached and
|
|
229
|
+
* nothing is recorded, `targetBranch` names the branch `finish` merges back
|
|
230
|
+
* into. A branch already checked out in another worktree is rejected before
|
|
231
|
+
* Git runs, so the failure names the conflicting path.
|
|
191
232
|
*/
|
|
192
233
|
export async function addWorktree(deps, options) {
|
|
193
234
|
const name = assertWorktreeName(options.name);
|
|
194
235
|
const sessionId = assertSessionId(options.sessionId);
|
|
236
|
+
if (options.targetBranch !== undefined && !TARGET_BRANCH.test(options.targetBranch)) {
|
|
237
|
+
throw worktreeError("WORKTREE_TARGET_INVALID", `Invalid --target-branch ${options.targetBranch}; use 1-128 letters, digits, '.', '_', '/' or '-'.`);
|
|
238
|
+
}
|
|
239
|
+
if (options.from !== undefined && options.from.trim() === "") {
|
|
240
|
+
throw worktreeError("WORKTREE_BASE_INVALID", "Invalid --from: a commit hash, branch or tag is required.");
|
|
241
|
+
}
|
|
195
242
|
const { mainRoot, currentRoot, commonDir } = await resolveCheckouts(deps, options.cwd);
|
|
196
243
|
if (currentRoot !== mainRoot) {
|
|
197
244
|
throw worktreeError("WORKTREE_NESTED", `Run worktree commands from the main checkout (${mainRoot}), not from inside a worktree.`);
|
|
198
245
|
}
|
|
199
|
-
|
|
200
|
-
|
|
246
|
+
// Without an explicit target or base, the branch the main checkout was on
|
|
247
|
+
// when the session began, not the one it is on now: the user may have
|
|
248
|
+
// switched since.
|
|
249
|
+
const origin = options.targetBranch === undefined && options.from === undefined
|
|
250
|
+
? await readSessionOrigin(mainRoot, sessionId) : null;
|
|
251
|
+
if (origin !== null &&
|
|
252
|
+
(await deps.git(mainRoot, ["show-ref", "--verify", "--quiet", `refs/heads/${origin}`])).exitCode !== 0) {
|
|
253
|
+
throw worktreeError("WORKTREE_ORIGIN_MISSING", `The session began on ${origin}, which no longer exists; pass --target-branch to agent-ops worktree add.`);
|
|
254
|
+
}
|
|
255
|
+
const headRef = await deps.git(mainRoot, ["symbolic-ref", "--short", "-q", "HEAD"]);
|
|
256
|
+
const targetBranch = options.targetBranch ?? origin ?? headRef.stdout.trim();
|
|
257
|
+
if (targetBranch === "") {
|
|
258
|
+
throw worktreeError("WORKTREE_TARGET_REQUIRED", "The main checkout is detached; pass --target-branch <branch> naming the branch finish merges back into.");
|
|
259
|
+
}
|
|
260
|
+
if (!TARGET_BRANCH.test(targetBranch)) {
|
|
261
|
+
throw worktreeError("WORKTREE_TARGET_INVALID", `Invalid target branch ${targetBranch}; use 1-128 letters, digits, '.', '_', '/' or '-'.`);
|
|
262
|
+
}
|
|
263
|
+
const fromRef = options.from ?? (origin === null ? "HEAD" : `refs/heads/${origin}`);
|
|
264
|
+
const base = await git(deps, mainRoot, ["rev-parse", "--verify", `${fromRef}^{commit}`], options.from === undefined ? "WORKTREE_NO_COMMIT" : "WORKTREE_BASE_INVALID", options.from === undefined
|
|
265
|
+
? "The main checkout has no commit to branch from."
|
|
266
|
+
: `Cannot resolve --from ${options.from} to a commit.`);
|
|
201
267
|
const path = worktreePath(mainRoot, name);
|
|
202
268
|
const branch = `${WORKTREE_BRANCH_PREFIX}${name}`;
|
|
203
269
|
if (await exists(path)) {
|
|
@@ -206,6 +272,17 @@ export async function addWorktree(deps, options) {
|
|
|
206
272
|
if ((await deps.git(mainRoot, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`])).exitCode === 0) {
|
|
207
273
|
throw worktreeError("WORKTREE_EXISTS", `Branch ${branch} already exists; pick another name.`);
|
|
208
274
|
}
|
|
275
|
+
const listing = await deps.git(mainRoot, ["worktree", "list", "--porcelain"]);
|
|
276
|
+
if (listing.exitCode === 0) {
|
|
277
|
+
for (const entry of parseWorktreeListPorcelain(listing.stdout)) {
|
|
278
|
+
if (entry.branch !== targetBranch && entry.branch !== branch)
|
|
279
|
+
continue;
|
|
280
|
+
const listed = await realpath(entry.path).catch(() => entry.path);
|
|
281
|
+
if (listed === mainRoot)
|
|
282
|
+
continue;
|
|
283
|
+
throw worktreeError("WORKTREE_BRANCH_CHECKED_OUT", `Branch ${entry.branch} is already checked out at ${entry.path}; switch it back, finish or remove that worktree first (see agent-ops worktree list).`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
209
286
|
const mainConfig = await deps.loadConfig(mainRoot);
|
|
210
287
|
const mainTrust = await deps.trust.status(mainRoot, mainConfig);
|
|
211
288
|
const setupSteps = mainConfig.worktree?.setup ?? [];
|
|
@@ -279,3 +356,32 @@ export async function ensureSessionWorktree(deps, options) {
|
|
|
279
356
|
return existing;
|
|
280
357
|
return (await addWorktree(deps, { cwd: mainRoot, name, sessionId: options.sessionId })).record;
|
|
281
358
|
}
|
|
359
|
+
function sessionOriginPath(mainRoot, sessionId) {
|
|
360
|
+
return join(mainRoot, ".agent-ops", "tasks", "session-origin", sha256(sessionId));
|
|
361
|
+
}
|
|
362
|
+
/** The branch recorded when `sessionId` began, or null. */
|
|
363
|
+
export async function readSessionOrigin(mainRoot, sessionId) {
|
|
364
|
+
const value = (await readPrivateFile(sessionOriginPath(mainRoot, sessionId), mainRoot).catch(() => null))?.trim();
|
|
365
|
+
return value !== undefined && TARGET_BRANCH.test(value) ? value : null;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Pins the branch the main checkout is on to `sessionId`, first write wins:
|
|
369
|
+
* SessionStart fires again on resume, compact and clear, by which time the
|
|
370
|
+
* user may have moved. Nothing is recorded from inside a worktree or on a
|
|
371
|
+
* detached HEAD, so a later add falls back to reading HEAD itself.
|
|
372
|
+
*/
|
|
373
|
+
export async function recordSessionOrigin(deps, options) {
|
|
374
|
+
if (!SESSION.test(options.sessionId))
|
|
375
|
+
return;
|
|
376
|
+
const { mainRoot, currentRoot } = await resolveCheckouts(deps, options.cwd);
|
|
377
|
+
if (currentRoot !== mainRoot)
|
|
378
|
+
return;
|
|
379
|
+
const branch = (await deps.git(mainRoot, ["symbolic-ref", "--short", "-q", "HEAD"])).stdout.trim();
|
|
380
|
+
if (!TARGET_BRANCH.test(branch))
|
|
381
|
+
return;
|
|
382
|
+
const path = sessionOriginPath(mainRoot, options.sessionId);
|
|
383
|
+
await withPrivateFileLock(path, mainRoot, async () => {
|
|
384
|
+
if (await readPrivateFile(path, mainRoot) === null)
|
|
385
|
+
await writePrivateFile(path, `${branch}\n`, mainRoot);
|
|
386
|
+
});
|
|
387
|
+
}
|