@goodtek/vibeops 3.0.0 → 3.1.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/lib/git.js CHANGED
@@ -4,9 +4,16 @@ import { dim, log } from "./logger.js";
4
4
  const exec = promisify(execFile);
5
5
  /** Avoid `maxBuffer` errors on large `git commit` / `git add` output. */
6
6
  const DEFAULT_GIT_MAX_BUFFER = 64 * 1024 * 1024;
7
+ /**
8
+ * Environment for every git call: output that VibeOps parses (porcelain, refusal
9
+ * messages) must not be localized.
10
+ */
11
+ export function gitEnv() {
12
+ return { ...process.env, LC_ALL: "C", LANG: "C", LANGUAGE: "C" };
13
+ }
7
14
  async function tryGit(cwd, args) {
8
15
  try {
9
- const { stdout } = await exec("git", args, { cwd });
16
+ const { stdout } = await exec("git", args, { cwd, env: gitEnv() });
10
17
  return { stdout };
11
18
  }
12
19
  catch {
@@ -16,6 +23,7 @@ async function tryGit(cwd, args) {
16
23
  export async function runGit(cwd, args, options = {}) {
17
24
  const { stdout, stderr } = await exec("git", args, {
18
25
  cwd,
26
+ env: gitEnv(),
19
27
  maxBuffer: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER,
20
28
  });
21
29
  return { stdout, stderr };
@@ -134,69 +142,68 @@ export async function gitCreateBranch(cwd, name, startPoint) {
134
142
  await runGit(cwd, args);
135
143
  }
136
144
  /**
137
- * When only governance / VibeOps paths are dirty, stash them so `git switch` can proceed.
138
- * Callers that switch branches should restore with {@link restoreGovernanceStashAfterSwitch}.
145
+ * When only governance / VibeOps paths are dirty, set aside the TRACKED
146
+ * governance modifications so `git switch` can proceed. Untracked files are not
147
+ * stashed — they move with the switch.
148
+ *
149
+ * `refs/stash` is shared by every worktree of a repository, so this never uses
150
+ * `stash push` / `stash pop` (which act on the top entry, possibly another
151
+ * worktree's). It creates a stash commit (`git stash create`), records it in the
152
+ * stash list for safety (`git stash store`), and returns its SHA; restore with
153
+ * {@link restoreGovernanceStashAfterSwitch}, which applies and drops exactly that
154
+ * entry. Returns null when nothing was set aside.
139
155
  */
140
156
  export async function stashGovernanceIfBlocking(cwd) {
141
157
  const git = await readGitInfo(cwd);
142
158
  if (git.dirty !== true)
143
- return false;
159
+ return null;
144
160
  const gov = await gitGovernanceOnlyDirty(cwd);
145
161
  if (!gov.onlyGovernance || gov.allPaths.length === 0)
146
- return false;
147
- const entries = parsePorcelain(await gitStatusPorcelain(cwd));
148
- const tracked = [];
149
- const untracked = [];
150
- for (const e of entries) {
151
- if (!isGovernanceDocumentationPath(e.path))
152
- continue;
153
- if (e.untracked)
154
- untracked.push(e.path);
155
- else
156
- tracked.push(e.path);
157
- }
158
- let didStash = false;
159
- if (tracked.length > 0) {
160
- await runGit(cwd, [
161
- "stash",
162
- "push",
163
- "-m",
164
- "vibeops: governance before branch switch",
165
- "--",
166
- ...tracked,
167
- ]);
168
- didStash = true;
169
- }
170
- if (untracked.length > 0) {
171
- try {
172
- await runGit(cwd, [
173
- "stash",
174
- "push",
175
- "-u",
176
- "-m",
177
- "vibeops: governance untracked before branch switch",
178
- "--",
179
- ...untracked,
180
- ]);
181
- didStash = true;
182
- }
183
- catch {
184
- // Untracked generated files (e.g. cursor-implement-*.md) may not stash; switch can still proceed.
185
- }
186
- }
187
- return didStash;
162
+ return null;
163
+ const tracked = (await gitStatusEntries(cwd))
164
+ .filter((e) => !e.untracked && isGovernanceDocumentationPath(e.path))
165
+ .map((e) => e.path);
166
+ if (tracked.length === 0)
167
+ return null;
168
+ const message = "vibeops: governance before branch switch";
169
+ const { stdout } = await runGit(cwd, ["stash", "create", message]);
170
+ const sha = stdout.trim();
171
+ if (sha.length === 0)
172
+ return null;
173
+ await runGit(cwd, ["stash", "store", "-q", "-m", message, sha]);
174
+ await runGit(cwd, ["restore", "--source=HEAD", "--staged", "--worktree", "--", ...tracked]);
175
+ return sha;
188
176
  }
189
- /** Re-apply governance paths stashed for a branch switch (e.g. new `docs/tasks/*.md`). */
190
- export async function restoreGovernanceStashAfterSwitch(cwd, didStash) {
191
- if (!didStash)
177
+ /**
178
+ * Re-apply the governance changes set aside by {@link stashGovernanceIfBlocking}
179
+ * (by SHA) and drop exactly that stash entry — never another worktree's.
180
+ */
181
+ export async function restoreGovernanceStashAfterSwitch(cwd, stashSha) {
182
+ if (stashSha === null)
183
+ return;
184
+ try {
185
+ await runGit(cwd, ["stash", "apply", "-q", stashSha]);
186
+ }
187
+ catch (e) {
188
+ const msg = e instanceof Error ? e.message.split("\n")[0] : String(e);
189
+ log.warn(`Could not restore governance changes (${msg}). They are kept in the stash list: ${dim(`git stash apply ${stashSha}`)}`);
192
190
  return;
191
+ }
193
192
  try {
194
- await runGit(cwd, ["stash", "pop"]);
195
- log.info(dim("Restored stashed governance paths on the task branch."));
193
+ const { stdout } = await runGit(cwd, ["stash", "list", "--format=%H"]);
194
+ const index = stdout.split("\n").indexOf(stashSha);
195
+ if (index >= 0) {
196
+ const ref = `stash@{${index}}`;
197
+ // Re-check right before dropping: the list is shared with other worktrees.
198
+ if ((await gitRevParse(cwd, ref)) === stashSha) {
199
+ await runGit(cwd, ["stash", "drop", "-q", ref]);
200
+ }
201
+ }
202
+ log.info(dim("Restored governance changes after the branch switch."));
196
203
  }
197
204
  catch (e) {
198
- const msg = e instanceof Error ? e.message : String(e);
199
- log.warn(`Could not restore stashed governance files (${msg}). Run ${dim("git stash pop")} manually.`);
205
+ const msg = e instanceof Error ? e.message.split("\n")[0] : String(e);
206
+ log.warn(`Restored governance changes; could not drop stash entry ${stashSha} (${msg}).`);
200
207
  }
201
208
  }
202
209
  export async function gitCheckout(cwd, ref) {
@@ -207,12 +214,14 @@ export async function gitCheckout(cwd, ref) {
207
214
  await runGit(cwd, ["switch", ref]);
208
215
  await restoreGovernanceStashAfterSwitch(cwd, stashed);
209
216
  }
210
- export async function gitCheckoutNewBranch(cwd, name, startPoint) {
217
+ export async function gitCheckoutNewBranch(cwd, name, startPoint, opts = {}) {
211
218
  const stashed = await stashGovernanceIfBlocking(cwd);
212
219
  if (stashed) {
213
220
  log.info(dim("Stashed governance-only changes (.vibeops/, docs/) before branch switch."));
214
221
  }
215
222
  const args = ["switch", "-c", name];
223
+ if (opts.noTrack === true)
224
+ args.push("--no-track");
216
225
  if (typeof startPoint === "string" && startPoint.length > 0)
217
226
  args.push(startPoint);
218
227
  await runGit(cwd, args);
@@ -247,52 +256,51 @@ export async function gitStatusPorcelain(cwd) {
247
256
  return [];
248
257
  return res.stdout.split("\n").filter((line) => line.length > 0);
249
258
  }
250
- function parsePorcelainLine(line) {
251
- if (line.length < 3)
252
- return null;
253
- const code = line.slice(0, 2);
254
- const rest = line.slice(3);
255
- if (code === "??") {
256
- return { code, staged: false, unstaged: false, untracked: true, path: rest };
257
- }
258
- if (code === "!!") {
259
- return null;
260
- }
261
- const x = code.charAt(0);
262
- const y = code.charAt(1);
263
- // Rename / copy: `R old -> new` or `C old -> new`
264
- if (x === "R" || x === "C") {
265
- const arrow = rest.indexOf(" -> ");
266
- if (arrow >= 0) {
267
- const origPath = rest.slice(0, arrow);
268
- const path = rest.slice(arrow + 4);
269
- return {
270
- code,
271
- staged: true,
272
- unstaged: y !== " " && y !== "?",
273
- untracked: false,
274
- path,
275
- origPath,
276
- };
277
- }
278
- }
279
- return {
280
- code,
281
- staged: x !== " " && x !== "?",
282
- unstaged: y !== " " && y !== "?",
283
- untracked: false,
284
- path: rest,
285
- };
286
- }
287
- function parsePorcelain(lines) {
259
+ /**
260
+ * Parse `git status --porcelain=v1 -z`: records are NUL-terminated, paths are
261
+ * never quoted, and a rename / copy record is followed by its source path as a
262
+ * separate NUL-terminated field (`XY new\0old\0`).
263
+ */
264
+ export function parsePorcelainZ(stdout) {
265
+ const fields = stdout.split("\0");
288
266
  const out = [];
289
- for (const line of lines) {
290
- const entry = parsePorcelainLine(line);
291
- if (entry !== null)
292
- out.push(entry);
267
+ for (let i = 0; i < fields.length; i++) {
268
+ const record = fields[i];
269
+ if (record.length < 4)
270
+ continue;
271
+ const code = record.slice(0, 2);
272
+ const path = record.slice(3);
273
+ if (code === "!!")
274
+ continue;
275
+ if (code === "??") {
276
+ out.push({ code, staged: false, unstaged: false, untracked: true, path });
277
+ continue;
278
+ }
279
+ const x = code.charAt(0);
280
+ const y = code.charAt(1);
281
+ let origPath;
282
+ if (x === "R" || x === "C" || y === "R" || y === "C") {
283
+ origPath = fields[i + 1];
284
+ i += 1;
285
+ }
286
+ out.push({
287
+ code,
288
+ staged: x !== " ",
289
+ unstaged: y !== " ",
290
+ untracked: false,
291
+ path,
292
+ ...(typeof origPath === "string" && origPath.length > 0 ? { origPath } : {}),
293
+ });
293
294
  }
294
295
  return out;
295
296
  }
297
+ /** Parsed `git status --porcelain=v1 -z`; empty when the repo is unreadable. */
298
+ async function gitStatusEntries(cwd) {
299
+ const res = await tryGit(cwd, ["status", "--porcelain=v1", "-z"]);
300
+ if (!res)
301
+ return [];
302
+ return parsePorcelainZ(res.stdout);
303
+ }
296
304
  /** Paths under these prefixes may stay uncommitted across `start` / `done` / doc updates. */
297
305
  const GOVERNANCE_DOC_REL_PREFIXES = [
298
306
  "docs/tasks/",
@@ -380,7 +388,7 @@ export async function tryResolveGovernanceUnmerged(cwd) {
380
388
  return resolved;
381
389
  }
382
390
  export async function listWorkingTreeRelPaths(cwd) {
383
- const entries = parsePorcelain(await gitStatusPorcelain(cwd));
391
+ const entries = await gitStatusEntries(cwd);
384
392
  const all = new Set();
385
393
  for (const e of entries) {
386
394
  all.add(e.path);
@@ -407,13 +415,29 @@ export function partitionPathsForAutoCommit(paths, opts = {}) {
407
415
  }
408
416
  return { committable, excluded, unmerged };
409
417
  }
418
+ /**
419
+ * Paths whose working-tree state is not yet in the index: untracked files and
420
+ * entries with a Y-column change (modified, deleted, type-changed, unmerged).
421
+ * Index-only changes — a staged deletion (`D `), the source of a staged rename
422
+ * (`R old -> new`) — are already recorded and exist in neither the index nor
423
+ * the working tree, so `git add` must not be given them (it fails with
424
+ * `pathspec … did not match any files`).
425
+ */
426
+ export async function listUnstagedRelPaths(cwd) {
427
+ const entries = await gitStatusEntries(cwd);
428
+ return entries.filter((e) => e.untracked || e.unstaged).map((e) => e.path);
429
+ }
430
+ /**
431
+ * Stage `paths` with git's own semantics: `-A` records modifications, new files
432
+ * and deletions alike; `:(literal)` keeps names with glob characters exact.
433
+ */
410
434
  export async function gitAddPaths(cwd, paths) {
411
435
  if (paths.length === 0)
412
436
  return;
413
437
  const chunkSize = 80;
414
438
  for (let i = 0; i < paths.length; i += chunkSize) {
415
- const slice = paths.slice(i, i + chunkSize);
416
- await runGit(cwd, ["add", "--", ...slice]);
439
+ const slice = paths.slice(i, i + chunkSize).map((p) => `:(literal)${p}`);
440
+ await runGit(cwd, ["add", "-A", "--", ...slice]);
417
441
  }
418
442
  }
419
443
  /**
@@ -423,8 +447,7 @@ export async function gitAddPaths(cwd, paths) {
423
447
  * block the next TASK unless application code is also dirty.
424
448
  */
425
449
  export async function gitGovernanceOnlyDirty(cwd) {
426
- const lines = await gitStatusPorcelain(cwd);
427
- const entries = parsePorcelain(lines);
450
+ const entries = await gitStatusEntries(cwd);
428
451
  const all = new Set();
429
452
  for (const e of entries) {
430
453
  all.add(e.path);
@@ -441,15 +464,15 @@ export async function gitGovernanceOnlyDirty(cwd) {
441
464
  };
442
465
  }
443
466
  export async function gitWorkingTreeChangedFiles(cwd) {
444
- const entries = parsePorcelain(await gitStatusPorcelain(cwd));
467
+ const entries = await gitStatusEntries(cwd);
445
468
  return entries.filter((e) => e.unstaged).map((e) => e.path);
446
469
  }
447
470
  export async function gitStagedChangedFiles(cwd) {
448
- const entries = parsePorcelain(await gitStatusPorcelain(cwd));
471
+ const entries = await gitStatusEntries(cwd);
449
472
  return entries.filter((e) => e.staged).map((e) => e.path);
450
473
  }
451
474
  export async function gitUntrackedFiles(cwd) {
452
- const entries = parsePorcelain(await gitStatusPorcelain(cwd));
475
+ const entries = await gitStatusEntries(cwd);
453
476
  return entries.filter((e) => e.untracked).map((e) => e.path);
454
477
  }
455
478
  export async function gitCommittedChangedFilesSince(baseCommit, cwd) {
@@ -458,7 +481,7 @@ export async function gitCommittedChangedFilesSince(baseCommit, cwd) {
458
481
  return gitDiffNameOnly(cwd, `${baseCommit}..HEAD`);
459
482
  }
460
483
  export async function gitAllChangedFilesSinceTaskStart(baseCommit, cwd) {
461
- const entries = parsePorcelain(await gitStatusPorcelain(cwd));
484
+ const entries = await gitStatusEntries(cwd);
462
485
  const working = entries.filter((e) => e.unstaged).map((e) => e.path);
463
486
  const staged = entries.filter((e) => e.staged).map((e) => e.path);
464
487
  const untracked = entries.filter((e) => e.untracked).map((e) => e.path);
@@ -2,7 +2,8 @@ import { resolve } from "node:path";
2
2
  import { deleteFile } from "./filesystem.js";
3
3
  import { GitConfigError, requireGitConfig } from "./git-config.js";
4
4
  import { mergeRequestLabel } from "./git-host.js";
5
- import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitGovernanceOnlyDirty, gitRemoteBranchExists, gitSwitchToBranch, readGitInfo, } from "./git.js";
5
+ import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitGovernanceOnlyDirty, gitRemoteBranchExists, readGitInfo, } from "./git.js";
6
+ import { leaveTaskBranch } from "./git-worktree.js";
6
7
  import { dim, log } from "./logger.js";
7
8
  import { closeMergeRequest, findMergeRequestByBranches, probeMergeRequestCli, } from "./pr-create.js";
8
9
  import { normalizeTaskRef } from "./resolve-task.js";
@@ -83,12 +84,13 @@ export async function deleteTaskBranches(opts) {
83
84
  }
84
85
  const git = await readGitInfo(cwd);
85
86
  if (git.branch === opts.taskBranch) {
86
- const switched = await gitSwitchToBranch(cwd, opts.integrationBranch, opts.remote);
87
- if (!switched) {
88
- log.error(`Integration branch "${opts.integrationBranch}" not found.`);
87
+ // Worktree-safe (D-006): detach when another worktree owns the integration branch.
88
+ const left = await leaveTaskBranch(cwd, opts.remote, opts.integrationBranch);
89
+ if (!left.ok) {
90
+ log.error(left.message);
89
91
  return false;
90
92
  }
91
- log.ok(`On ${opts.integrationBranch}`);
93
+ log.ok(left.on === "integration" ? `On ${opts.integrationBranch}` : `Detached at ${left.ref}`);
92
94
  }
93
95
  if (await gitBranchExists(cwd, opts.taskBranch)) {
94
96
  try {
@@ -1,5 +1,5 @@
1
1
  import { dim, log } from "./logger.js";
2
- import { gitAddPaths, gitPush, listWorkingTreeRelPaths, partitionPathsForAutoCommit, readGitInfo, runGit, } from "./git.js";
2
+ import { gitAddPaths, gitPush, listUnstagedRelPaths, listWorkingTreeRelPaths, partitionPathsForAutoCommit, readGitInfo, runGit, } from "./git.js";
3
3
  export function featCommitMessageFor(taskId, title) {
4
4
  const slug = title.replace(/^TASK-\d+\s*[:\-]\s*/i, "").trim() || taskId;
5
5
  return `feat(${taskId.toLowerCase()}): ${slug}`;
@@ -31,7 +31,12 @@ export function taskScopedCommitMessage(taskId, rawMessage, defaultType = "feat"
31
31
  }
32
32
  return `${defaultType}(${scope}): ${msg}`;
33
33
  }
34
- /** Commit all committable dirty paths; returns whether a commit was created. */
34
+ /**
35
+ * Commit all committable dirty paths (staged and unstaged, including deletions
36
+ * and renames); returns whether a commit was created. Only paths whose
37
+ * working-tree state is not yet in the index are passed to `git add`; staged
38
+ * changes are already part of the commit.
39
+ */
35
40
  export async function commitDirtyWorkingTree(cwd, message, dryRun) {
36
41
  const git = await readGitInfo(cwd);
37
42
  if (!git.isRepo || git.dirty !== true)
@@ -46,7 +51,8 @@ export async function commitDirtyWorkingTree(cwd, message, dryRun) {
46
51
  log.info(dim(` would commit ${committable.length} path(s): ${message}`));
47
52
  return true;
48
53
  }
49
- await gitAddPaths(cwd, committable);
54
+ const committableSet = new Set(committable);
55
+ await gitAddPaths(cwd, (await listUnstagedRelPaths(cwd)).filter((p) => committableSet.has(p)));
50
56
  await runGit(cwd, ["commit", "-q", "-m", message]);
51
57
  log.ok(`Committed: ${message}`);
52
58
  return true;
@@ -0,0 +1,192 @@
1
+ import { readdir, realpath } from "node:fs/promises";
2
+ import { basename, dirname, join, relative } from "node:path";
3
+ import { gitRemoteUrl, runGit } from "./git.js";
4
+ import { currentWorktreeRoot, listWorktrees } from "./git-worktree.js";
5
+ /**
6
+ * Collision-free TASK ids (D-006).
7
+ *
8
+ * next id = 1 + max TASK number found in ANY of:
9
+ * 1. local `docs/tasks` (this worktree, including uncommitted files);
10
+ * 2. `docs/tasks` on `<remote>/<integration>` (`git ls-tree`), else local `<integration>`;
11
+ * 3. VibeOps task branches — local heads and `<remote>/task/*` (refreshed with
12
+ * `git fetch --prune <remote> +refs/heads/task/*:refs/remotes/<remote>/task/*`) —
13
+ * counted only when the branch name has the generated form AND the branch's
14
+ * tree contains that TASK file (so `task/2026-q4-plan` does not inflate ids);
15
+ * 4. `docs/tasks` in every worktree from `git worktree list` (covers task branches
16
+ * whose TASK file is not committed yet).
17
+ *
18
+ * A configured remote whose task branches cannot be fetched is an error (fail
19
+ * closed) — an id could otherwise collide with an unmerged branch.
20
+ */
21
+ const TASK_FILE_RE = /^TASK-(\d+)(?:-|\.md$)/i;
22
+ /**
23
+ * Generated task branch form: `task/<NNN>` or `task/<NNN>-<slug>`, where NNN is the
24
+ * zero-padded TASK number (≥ 3 digits, `formatTaskId`) and slug is `slugify` output
25
+ * (`[a-z0-9]+` joined by single hyphens) — i.e. `branchNameForTaskFile` of
26
+ * `TASK-<NNN>-<slug>.md`.
27
+ */
28
+ export const GENERATED_TASK_BRANCH_RE = /^task\/(\d{3,})(?:-([a-z0-9]+(?:-[a-z0-9]+)*))?$/;
29
+ export function taskNumberFromFilename(name) {
30
+ const m = TASK_FILE_RE.exec(name.trim());
31
+ return m ? Number.parseInt(m[1], 10) : null;
32
+ }
33
+ /**
34
+ * TASK number of a branch in the generated form (accepts `refs/heads/` and
35
+ * `refs/remotes/<remote>/` prefixes), else null.
36
+ */
37
+ export function taskNumberFromBranch(ref) {
38
+ const name = ref
39
+ .trim()
40
+ .replace(/^refs\/heads\//, "")
41
+ .replace(/^refs\/remotes\/[^/]+\//, "");
42
+ const m = GENERATED_TASK_BRANCH_RE.exec(name);
43
+ return m ? Number.parseInt(m[1], 10) : null;
44
+ }
45
+ export class TaskIdAllocationError extends Error {
46
+ constructor(message) {
47
+ super(message);
48
+ this.name = "TaskIdAllocationError";
49
+ }
50
+ }
51
+ async function maxInDir(dir) {
52
+ let max = 0;
53
+ let names;
54
+ try {
55
+ names = await readdir(dir);
56
+ }
57
+ catch {
58
+ return 0;
59
+ }
60
+ for (const n of names) {
61
+ const v = taskNumberFromFilename(n);
62
+ if (v !== null)
63
+ max = Math.max(max, v);
64
+ }
65
+ return max;
66
+ }
67
+ function maxOf(values) {
68
+ let max = 0;
69
+ for (const v of values)
70
+ if (v !== null)
71
+ max = Math.max(max, v);
72
+ return max;
73
+ }
74
+ async function refExists(cwd, ref) {
75
+ try {
76
+ await runGit(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
77
+ return true;
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
83
+ async function branchHasTaskFile(cwd, ref, tasksRel, n) {
84
+ try {
85
+ const { stdout } = await runGit(cwd, ["ls-tree", "--name-only", `${ref}:${tasksRel}`]);
86
+ return stdout.split("\n").some((f) => taskNumberFromFilename(f) === n);
87
+ }
88
+ catch {
89
+ return false; // no docs/tasks in that tree
90
+ }
91
+ }
92
+ /**
93
+ * Cross-machine guard for `task ship`: refuse to push `taskBranch` when the remote
94
+ * already has a different generated task branch with the same TASK number (another
95
+ * machine allocated the same id). Fails closed when the remote cannot be listed.
96
+ */
97
+ export async function assertTaskIdFreeOnRemote(cwd, remote, taskBranch) {
98
+ const n = taskNumberFromBranch(taskBranch);
99
+ if (n === null)
100
+ return { ok: true };
101
+ if ((await gitRemoteUrl(cwd, remote)) === null)
102
+ return { ok: true };
103
+ let stdout;
104
+ try {
105
+ ({ stdout } = await runGit(cwd, ["ls-remote", "--heads", remote, "task/*"]));
106
+ }
107
+ catch (e) {
108
+ const msg = e instanceof Error ? e.message.split("\n")[0] : String(e);
109
+ return {
110
+ ok: false,
111
+ message: `Cannot list task branches on ${remote} (${msg}) — refusing to push without checking for a TASK id collision.`,
112
+ };
113
+ }
114
+ const clashes = stdout
115
+ .split("\n")
116
+ .map((l) => (l.split("\t")[1] ?? "").replace(/^refs\/heads\//, ""))
117
+ .filter((name) => name.length > 0 && name !== taskBranch && taskNumberFromBranch(name) === n);
118
+ if (clashes.length > 0) {
119
+ return {
120
+ ok: false,
121
+ message: `TASK id collision: ${remote} already has ${clashes.join(", ")} for TASK-${String(n).padStart(3, "0")}. Not pushing ${taskBranch}. Renumber this TASK (file + branch) and rerun.`,
122
+ };
123
+ }
124
+ return { ok: true };
125
+ }
126
+ export async function allocateTaskId(input) {
127
+ const { cwd, tasksDir, remote, integrationBranch } = input;
128
+ const root = await currentWorktreeRoot(cwd);
129
+ // Compare canonical paths (macOS /var → /private/var) to get the repo-relative dir.
130
+ const tasksAbs = await realpath(tasksDir).catch(async () => join(await realpath(dirname(tasksDir)), basename(tasksDir)));
131
+ const tasksRel = relative(root, tasksAbs).split("\\").join("/");
132
+ if (tasksRel.startsWith("..") || tasksRel.length === 0) {
133
+ throw new Error(`TASK directory ${tasksDir} is not inside the worktree ${root}.`);
134
+ }
135
+ // 1. local docs/tasks
136
+ const localFiles = await maxInDir(tasksDir);
137
+ // 2. integration tree
138
+ let integrationTree = 0;
139
+ const treeRef = (await refExists(cwd, `refs/remotes/${remote}/${integrationBranch}`))
140
+ ? `${remote}/${integrationBranch}`
141
+ : (await refExists(cwd, `refs/heads/${integrationBranch}`))
142
+ ? integrationBranch
143
+ : null;
144
+ if (treeRef !== null) {
145
+ const { stdout } = await runGit(cwd, ["ls-tree", "--name-only", `${treeRef}:${tasksRel}`]).catch(() => ({ stdout: "" }));
146
+ integrationTree = maxOf(stdout.split("\n").map(taskNumberFromFilename));
147
+ }
148
+ // 3. VibeOps task branches (local + freshly fetched remote), with TASK-file evidence
149
+ if (input.fetchRemote !== false && (await gitRemoteUrl(cwd, remote)) !== null) {
150
+ try {
151
+ await runGit(cwd, [
152
+ "fetch",
153
+ "--prune",
154
+ remote,
155
+ `+refs/heads/task/*:refs/remotes/${remote}/task/*`,
156
+ ]);
157
+ }
158
+ catch (e) {
159
+ const msg = e instanceof Error ? e.message.split("\n")[0] : String(e);
160
+ throw new TaskIdAllocationError(`Cannot fetch task branches from ${remote} (${msg}) — refusing to pick a TASK id that may collide. Check network / auth and rerun.`);
161
+ }
162
+ }
163
+ const { stdout: refs } = await runGit(cwd, [
164
+ "for-each-ref",
165
+ "--format=%(refname)",
166
+ "refs/heads/task/",
167
+ `refs/remotes/${remote}/task/`,
168
+ ]);
169
+ let branches = 0;
170
+ for (const ref of refs.split("\n").filter((r) => r.length > 0)) {
171
+ const n = taskNumberFromBranch(ref);
172
+ if (n === null || n <= branches)
173
+ continue;
174
+ if (await branchHasTaskFile(cwd, ref, tasksRel, n))
175
+ branches = n;
176
+ }
177
+ // 4. every worktree's docs/tasks
178
+ let worktrees = 0;
179
+ for (const wt of await listWorktrees(cwd)) {
180
+ if (wt.bare)
181
+ continue;
182
+ worktrees = Math.max(worktrees, await maxInDir(join(wt.path, tasksRel)));
183
+ }
184
+ const sources = {
185
+ localFiles,
186
+ integrationTree,
187
+ branches,
188
+ worktrees,
189
+ };
190
+ const max = Math.max(...Object.values(sources));
191
+ return { next: max + 1, max, sources };
192
+ }