@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.
Files changed (31) hide show
  1. package/dist/packages/cli/src/args.js +34 -1
  2. package/dist/packages/cli/src/bin.js +48 -3
  3. package/dist/packages/cli/src/cli.js +13 -4
  4. package/dist/packages/cli/src/commands/parallel.js +3 -1
  5. package/dist/packages/cli/src/commands/review.js +11 -3
  6. package/dist/packages/cli/src/commands/verify.js +1 -1
  7. package/dist/packages/cli/src/hook-process.js +10 -1
  8. package/dist/runtime/src/contracts.js +1 -1
  9. package/dist/runtime/src/install/doctor.js +31 -1
  10. package/dist/runtime/src/parallel/finish.js +32 -10
  11. package/dist/runtime/src/parallel/manage.js +30 -1
  12. package/dist/runtime/src/parallel/service.js +111 -5
  13. package/dist/runtime/src/review/execute.js +125 -9
  14. package/dist/runtime/src/review/extract.js +40 -0
  15. package/dist/runtime/src/review/invocation.js +3 -0
  16. package/dist/runtime/src/review/usage.js +19 -0
  17. package/dist/runtime/src/schema/validate.js +1 -1
  18. package/dist/runtime/src/task/completion.js +2 -0
  19. package/dist/runtime/src/task/service.js +77 -1
  20. package/dist/runtime/src/verify/command-executor.js +6 -2
  21. package/dist/runtime/src/verify/evidence.js +2 -1
  22. package/dist/runtime/src/verify/service.js +5 -2
  23. package/dist/runtime/src/verify/test-count.js +89 -89
  24. package/docs/en/guides/configuration.md +26 -4
  25. package/docs/en/spec/acceptance-and-evidence.md +5 -1
  26. package/docs/en/spec/delegation.md +1 -1
  27. package/docs/zh-TW/guides/configuration.md +20 -4
  28. package/docs/zh-TW/spec/acceptance-and-evidence.md +6 -2
  29. package/docs/zh-TW/spec/delegation.md +1 -1
  30. package/package.json +1 -1
  31. 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 files and .worktreeinclude matches,
196
- inherit trust for an identical config, run worktree.setup, and
197
- point the session's completion gate at the new worktree.
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.schemaVersion !== 2 ||
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 selected = newestEvidence(found.current);
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 = 2;
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 the branch the main checkout is on,
195
- * by fast-forward only, then retires the worktree. Run from the main checkout.
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
- if (onBranch !== record.targetBranch) {
215
- throw finishError("WORKTREE_TARGET_MOVED", `The main checkout is on ${onBranch || "a detached HEAD"}; switch it back to ${record.targetBranch} to finish.`);
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
- await git(deps, mainRoot, ["merge", "--ff-only", record.branch], "WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}.`);
280
- const mergedHead = await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
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 the main checkout's
190
- * HEAD and makes it a working agent-ops checkout for `sessionId`.
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
- const targetBranch = await git(deps, mainRoot, ["symbolic-ref", "--short", "-q", "HEAD"], "WORKTREE_DETACHED_HEAD", "The main checkout must be on a branch to merge back into.");
200
- const base = await git(deps, mainRoot, ["rev-parse", "--verify", "HEAD^{commit}"], "WORKTREE_NO_COMMIT", "The main checkout has no commit to branch from.");
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
+ }