@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/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ All notable changes to VibeOps are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 3.1.1 - 2026-10-15
8
+
9
+ ### Fixed
10
+
11
+ - **`task ship` with deletions / renames:** a staged deletion (`D `, e.g. `git rm --cached <path>` + file removed) or a staged rename made ship fail with `fatal: pathspec '<path>' did not match any files`, because every dirty path was passed to `git add`. Ship now parses `git status --porcelain=v1 -z` and runs `git add -A -- :(literal)<path>` only for untracked / unstaged paths; staged changes are committed as they are. Unstaged deletions of tracked files are staged as deletions. Names with spaces or non-ASCII characters (previously quoted by porcelain output) are staged correctly (TASK-022).
12
+
13
+ ## 3.1.0 - 2026-10-03
14
+
15
+ ### Added
16
+
17
+ - **Worktree-safe lifecycle:** VibeOps never needs to check out the integration branch, so it works when another `git worktree` has `develop` checked out (previously `fatal: 'develop' is already used by worktree at …`).
18
+ - `task add`: `git fetch <remote> <integration>`, then `git switch -c task/NNN-slug --no-track <remote>/<integration>`.
19
+ - The local integration branch is fast-forwarded only where safe: in this worktree with `merge --ff-only`; otherwise with `git fetch . refs/remotes/<remote>/<integration>:refs/heads/<integration>` — git itself refuses non-fast-forward updates and branches checked out in any worktree, and that refusal is reported as "left for the owning worktree", not an error.
20
+ - `task sync`: verifies the merge **before** moving HEAD; leaves the task branch by switching to the integration branch, or `git switch --detach <remote>/<integration>` when another worktree owns it; deletes local + remote task branch.
21
+ - `task del` / `task ship --new-cycle` (branch recreate) use the same rules; `vibeops pull` refuses to switch (exit 1, names the worktree) instead of failing inside git.
22
+ - **Repository lock:** `task add` (id allocation → TASK file → branch), `task sync`, `task del`, `task ship --new-cycle` branch handling and `vibeops pull` run under an exclusive lock file `<git-common-dir>/vibeops-task.lock` (created atomically with content via temp file + `link`; pid, host, start time, operation, cwd). Another holder → wait up to `lock.waitSeconds` (default 60), then a clear error naming the holder. Stale lock (pid dead on this host, or older than `lock.staleSeconds`, default 600, on any host) → taken over under a breaker file with inode + token re-check, so concurrent takeovers never produce two holders. `.vibeops.json` `lock` is strictly validated. The git common dir must be on a local filesystem.
23
+ - **Collision-free TASK ids:** next id = 1 + max over local `docs/tasks`, `docs/tasks` on `<remote>/<integration>`, every worktree's `docs/tasks`, and VibeOps task branches (local + `git fetch --prune <remote> +refs/heads/task/*:refs/remotes/<remote>/task/*`). A branch counts only in the generated form `task/<NNN>[-<slug>]` (NNN zero-padded ≥ 3 digits, slug = lowercase `a-z0-9` words joined by single hyphens) **and** when its tree contains that TASK file — `task/2026-q4-plan` does not inflate ids. If the remote cannot be fetched, `task add` refuses without creating a TASK file.
24
+ - **Cross-machine id guard:** `task ship` runs `git ls-remote --heads <remote> 'task/*'` right before pushing and refuses when a different task branch with the same TASK number exists on the remote (or when the remote cannot be listed).
25
+
26
+ ### Fixed
27
+
28
+ - **Shared `refs/stash`:** governance changes were set aside with `stash push` / `stash pop`, which act on the top of the stash list shared by all worktrees — another worktree's entry could be popped and the TASK file lost. Untracked governance files are no longer stashed (they move with the switch); tracked changes are saved with `git stash create` + `git stash store`, re-applied by SHA, and exactly that entry is dropped.
29
+
30
+ ### Changed
31
+
32
+ - `task sync` checks merge verification before switching branches (a refused sync now leaves HEAD on the task branch).
33
+ - New task branches are created with `--no-track`, so they never inherit `<remote>/<integration>` as upstream; `task ship` sets the upstream on first push as before.
34
+ - Git Context `baseCommit` is recorded as a full SHA (was a 7-character prefix).
35
+ - All git commands run with `LC_ALL=C` / `LANG=C` so parsed output (porcelain, refusal messages) is never localized.
36
+
7
37
  ## 3.0.0 - 2026-10-03
8
38
 
9
39
  ### Breaking changes
package/README.md CHANGED
@@ -153,15 +153,53 @@ vibeops llm use auto # auto | codex-oauth | cursor-agent | openai
153
153
  ## Git
154
154
 
155
155
  - **Init** records branch policy in `.vibeops.json` (`integrationBranch`, `productionBranch`, `host`). Interactive remote setup: ask GitHub/GitLab, create or connect; soft-gate when `gh`/`glab` is missing.
156
- - **`task add`**: `task/<slug>` from **integration** (e.g. `develop`).
156
+ - **`task add`**: `task/<NNN-slug>` from **`<remote>/<integration>`** (e.g. `origin/develop`), after `git fetch`. See [Worktrees](#worktrees) and [TASK ids](#task-ids).
157
157
  - **`task ship`** (state-aware): **no PR** → commit + ship metadata (Status **Shipped**) → push → open MR/PR; **open PR** → commit + push the same branch, CI re-runs, no new PR; **merged PR** → new PR cycle (carries uncommitted work onto the task branch, integrates `develop`, opens a **new** PR). Use `--new-cycle` in non-interactive mode. Commit messages are TASK-id-scoped (`feat(task-001): …`). Refuses when HEAD is not the task branch.
158
158
  - **`task merge`**: merge MR/PR into integration (TASK md unchanged) — **only when every check is green** (see [Merge gate](#merge-gate)).
159
- - **`task sync`**: integration ff-only pull → delete `task/*` branches (TASK md unchanged).
160
- - **`pull`**: fetch + switch to integration branch + `git pull --ff-only` (one command).
159
+ - **`task sync`**: verify merge → leave the task branch (switch to integration, or detach at `<remote>/<integration>` when another worktree owns it) → fast-forward local integration where safe → delete local + remote `task/*` branch (TASK md unchanged).
160
+ - **`pull`**: fetch + switch to integration branch + `git pull --ff-only` (one command). Refuses (exit 1) when another worktree has the integration branch checked out — run it there.
161
161
  - **`task release`**: integration → production PR + merge (skipped on trunk policy).
162
162
  - **`status`**: Now / Next card — focus stage, checklist, PR, and the next command to run.
163
163
  - No force-push to shared branches.
164
164
 
165
+ ## Worktrees
166
+
167
+ VibeOps supports several `git worktree` checkouts of one repo working in parallel. It never needs to check out the integration branch:
168
+
169
+ - New task branches start from `<remote>/<integration>` (`git switch -c task/NNN-slug --no-track origin/develop`).
170
+ - The local integration branch is fast-forwarded only when safe: in the worktree that has it checked out (`merge --ff-only`), otherwise with `git fetch . refs/remotes/origin/develop:refs/heads/develop`, which git refuses for non-fast-forward updates and for a branch checked out in another worktree — then it is left alone and the log says so (update it in that worktree).
171
+ - `task sync` / `task del` / `task ship --new-cycle` leave the task branch by detaching at `<remote>/<integration>` when the integration branch is checked out elsewhere.
172
+ - Governance changes set aside for a branch switch are restored by stash SHA, never by `stash pop` (the stash list is shared by all worktrees). Untracked files are not stashed.
173
+ - Nothing is ever forced; local integration commits that are not on the remote stop `task add` / `task sync` with fix commands.
174
+
175
+ ### Repository lock
176
+
177
+ `task add`, `task sync`, `task del`, `task ship --new-cycle` (branch step) and `vibeops pull` take an exclusive lock, `<git-common-dir>/vibeops-task.lock` (shared by all worktrees), recording pid, host, start time, operation and directory. A second command waits, then fails with a message naming the holder.
178
+
179
+ - The lock file is created atomically with its content (temp file + `link`), so it is never seen half-written.
180
+ - **Stale** = holder on this host whose pid is not running, or a lock older than `staleSeconds` (any host; covers pid reuse). A start time in the future (clock skew) is respected, not stale.
181
+ - Every removal (release or stale takeover) happens under a short breaker file `vibeops-task.lock.break` and only after re-checking the lock's inode and token, so concurrent takeovers of one stale lock never yield two holders.
182
+ - Breaker timings are internal (not in `.vibeops.json`) because the breaker is held for microseconds and only matters after a crash: a breaker older than 30 s is treated as left by a crash; waiting for it is bounded by 60 s (a stale takeover waits at most `waitSeconds`; a release always uses the full 60 s so the lock is not left behind).
183
+ - **The git common dir must be on a local filesystem.** Network filesystems (NFS, SMB, cloud-synced folders) are not supported: their `link` / rename / O_EXCL semantics and clocks are not reliable enough for the lock.
184
+
185
+ | `.vibeops.json` `lock` key | Default | Allowed | Meaning |
186
+ |---|---|---|---|
187
+ | `waitSeconds` | `60` | integer 0–600 | How long to wait for another holder. |
188
+ | `staleSeconds` | `600` | integer 60–86400 | A lock older than this is stale (any host). On this host a lock is also stale as soon as its pid is no longer running. |
189
+
190
+ ## TASK ids
191
+
192
+ `task add` picks **1 + the highest TASK number found anywhere** so parallel worktrees and unmerged branches never reuse an id:
193
+
194
+ 1. `docs/tasks` in this worktree (including uncommitted files);
195
+ 2. `docs/tasks` on `<remote>/<integration>` (`git ls-tree`);
196
+ 3. `docs/tasks` in every worktree (`git worktree list`) — covers task branches whose TASK file is not committed yet;
197
+ 4. VibeOps task branches, local and on the remote (`git fetch --prune <remote> '+refs/heads/task/*:refs/remotes/<remote>/task/*'`).
198
+
199
+ A branch counts only when its name has the **generated form** `task/<NNN>` or `task/<NNN>-<slug>` — NNN is the TASK number zero-padded to at least 3 digits, slug is lowercase `a-z0-9` words joined by single hyphens (exactly what `task add` creates from `TASK-<NNN>-<slug>.md`) — **and** its tree contains `docs/tasks/TASK-<NNN>…md`. Other branches (`task/2026-q4-plan`, `task/task-020-x`) are ignored.
200
+
201
+ If a configured remote cannot be fetched, `task add` stops without creating a TASK file. Across machines, `task ship` re-checks the remote (`git ls-remote`) right before pushing and refuses if another task branch with the same number already exists there.
202
+
165
203
  ## Merge gate
166
204
 
167
205
  > **3.0.0 breaking:** a PR/MR with **no checks at all** is refused by default. Repos without CI: set `"merge": {"allowNoChecks": true}` in `.vibeops.json`. Requires **gh ≥ 2.13.0**.
@@ -2,11 +2,19 @@ import { resolve } from "node:path";
2
2
  import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
3
3
  import { diagnoseIntegrationSync, printIntegrationSyncDiagnosis, } from "../lib/git-integration-sync.js";
4
4
  import { gitFetchRemote, gitPullFastForwardOnly, gitRemoteBranchExists, gitRemoteUrl, gitSwitchToBranch, readGitInfo, } from "../lib/git.js";
5
+ import { branchCheckedOutElsewhere } from "../lib/git-worktree.js";
5
6
  import { bold, cyan, dim, log } from "../lib/logger.js";
7
+ import { runUnderTaskLock } from "../lib/task-lock.js";
6
8
  /**
7
9
  * One-step remote sync: fetch, switch to integration branch, fast-forward pull.
8
10
  */
9
11
  export async function pullCommand(options = {}) {
12
+ if (options.dryRun === true)
13
+ return pullUnlocked(options);
14
+ // D-006: branch switch / fast-forward are serialized across worktrees.
15
+ await runUnderTaskLock(resolve(options.cwd ?? process.cwd()), "pull", () => pullUnlocked(options));
16
+ }
17
+ async function pullUnlocked(options) {
10
18
  const cwd = resolve(options.cwd ?? process.cwd());
11
19
  const dryRun = options.dryRun === true;
12
20
  let gitCfg;
@@ -56,6 +64,14 @@ export async function pullCommand(options = {}) {
56
64
  log.warn(`git fetch failed (${msg}). Continuing with local refs.`);
57
65
  }
58
66
  if (needsSwitch) {
67
+ // Worktree-safe (D-006): git refuses to check out a branch another worktree has.
68
+ const owner = await branchCheckedOutElsewhere(cwd, integrationBranch);
69
+ if (owner !== null) {
70
+ log.error(`${integrationBranch} is checked out in worktree ${owner} — not switching this worktree.`);
71
+ log.info(dim(`Fetched ${remote}. Update ${integrationBranch} there: vibeops pull --cwd "${owner}"`));
72
+ process.exitCode = 1;
73
+ return;
74
+ }
59
75
  const switched = await gitSwitchToBranch(cwd, integrationBranch, remote);
60
76
  if (!switched) {
61
77
  log.error(`Branch "${integrationBranch}" not found locally or on ${remote}. Push integration first or check the remote URL.`);
@@ -9,6 +9,8 @@ import { slugify } from "../lib/slug.js";
9
9
  import { fallbackTaskDraft, llmScaffoldTask } from "../lib/task-add-llm.js";
10
10
  import { allocateNextTaskNumber, formatTaskId, uniqueTaskPath, } from "../lib/task-scaffold.js";
11
11
  import { findBlockingTask, relPath } from "../lib/task-context.js";
12
+ import { allocateTaskId, TaskIdAllocationError } from "../lib/task-id-allocation.js";
13
+ import { readTaskLockConfig, TaskLockConfigError, TaskLockHeldError, withTaskLock, } from "../lib/task-lock.js";
12
14
  import { isIncompleteTaskStart, startTaskBranch } from "../lib/task-start.js";
13
15
  import { loadActionableTasks, readGitContext } from "../lib/task.js";
14
16
  async function loadGitConfigOrNull(root, dryRun) {
@@ -51,6 +53,24 @@ async function finishWithBranch(root, taskId, filePath, gitCfg, opts) {
51
53
  log.info(` Agent: same file — implement`);
52
54
  log.info(` Ship: ${cyan(`vibeops task ship ${taskId}`)}`);
53
55
  }
56
+ /**
57
+ * Run `fn` under the repository task lock (D-006). Lock errors are reported and
58
+ * end the command with exit code 1.
59
+ */
60
+ async function underLock(root, lockCfg, fn) {
61
+ try {
62
+ return await withTaskLock(root, "task add", fn, lockCfg);
63
+ }
64
+ catch (e) {
65
+ if (e instanceof TaskLockHeldError) {
66
+ log.error(e.message);
67
+ log.info(dim("No TASK file was created."));
68
+ process.exitCode = 1;
69
+ return "done";
70
+ }
71
+ throw e;
72
+ }
73
+ }
54
74
  export async function taskAddCommand(opts = {}) {
55
75
  const root = resolve(opts.cwd ?? process.cwd());
56
76
  const dryRun = opts.dryRun === true;
@@ -60,76 +80,96 @@ export async function taskAddCommand(opts = {}) {
60
80
  const gitCfg = await loadGitConfigOrNull(root, dryRun);
61
81
  if (!dryRun && gitCfg === null)
62
82
  return;
63
- const paths = projectPaths(root);
64
- const blocking = await findBlockingTask(paths, root);
65
- // Resume: In Progress file exists but task branch / Git Context was never finished.
66
- if (blocking !== null && gitCfg !== null) {
67
- const incomplete = await isIncompleteTaskStart(root, blocking.filePath);
68
- if (incomplete) {
69
- log.warn(`${yellow("Incomplete")} — ${bold(blocking.id)} exists but the task branch was not created.`);
70
- log.info(` ${dim("file")} ${relPath(root, blocking.filePath)}`);
71
- log.info(dim("Resuming branch setup (will not create a new TASK)…"));
72
- if (!dryRun) {
73
- const synced = await ensureIntegrationSynced({
74
- cwd: root,
75
- remote: gitCfg.remote,
76
- integrationBranch: gitCfg.integrationBranch,
77
- fetch: true,
78
- });
79
- if (!synced.ok) {
80
- printIntegrationSyncDiagnosis(synced.diagnosis);
81
- process.exitCode = 1;
82
- return;
83
- }
84
- if (synced.pulled)
85
- log.info(dim(synced.diagnosis.summary));
86
- }
87
- await finishWithBranch(root, blocking.id, blocking.filePath, gitCfg, {
88
- dryRun,
89
- skipIntegrationPull: true,
90
- });
83
+ let lockCfg;
84
+ try {
85
+ lockCfg = await readTaskLockConfig(root);
86
+ }
87
+ catch (e) {
88
+ if (e instanceof TaskLockConfigError) {
89
+ log.error(e.message);
90
+ process.exitCode = 1;
91
91
  return;
92
92
  }
93
- const ctx = await readGitContext(blocking.filePath);
94
- log.warn(`${yellow("Blocked")} — ${bold(blocking.id)} is still open (${blocking.title || "no title"}).`);
95
- if (ctx) {
96
- log.info(` ${dim("branch")} ${ctx.taskBranch}`);
97
- }
98
- log.info(` ${dim("file")} ${relPath(root, blocking.filePath)}`);
99
- log.blank();
100
- log.info(`Finish it first: ${cyan(`vibeops task ship ${blocking.id}`)}.`);
101
- log.info(`Then run ${cyan("vibeops task add")} again.`);
102
- process.exitCode = 1;
103
- return;
93
+ throw e;
104
94
  }
105
- // Preflight: sync integration BEFORE writing a new TASK file (avoids half-created TASKs).
106
- if (!dryRun && gitCfg !== null) {
107
- log.step("Checking integration branch…");
108
- const synced = await ensureIntegrationSynced({
109
- cwd: root,
110
- remote: gitCfg.remote,
111
- integrationBranch: gitCfg.integrationBranch,
112
- fetch: true,
113
- });
114
- if (!synced.ok) {
115
- printIntegrationSyncDiagnosis(synced.diagnosis);
95
+ const paths = projectPaths(root);
96
+ // Phase 1 (locked): resume an incomplete add, or preflight the integration branch.
97
+ const phase1 = async () => {
98
+ const blocking = await findBlockingTask(paths, root);
99
+ // Resume: In Progress file exists but task branch / Git Context was never finished.
100
+ if (blocking !== null && gitCfg !== null) {
101
+ const incomplete = await isIncompleteTaskStart(root, blocking.filePath);
102
+ if (incomplete) {
103
+ log.warn(`${yellow("Incomplete")} — ${bold(blocking.id)} exists but the task branch was not created.`);
104
+ log.info(` ${dim("file")} ${relPath(root, blocking.filePath)}`);
105
+ log.info(dim("Resuming branch setup (will not create a new TASK)…"));
106
+ if (!dryRun) {
107
+ const synced = await ensureIntegrationSynced({
108
+ cwd: root,
109
+ remote: gitCfg.remote,
110
+ integrationBranch: gitCfg.integrationBranch,
111
+ fetch: true,
112
+ });
113
+ if (!synced.ok) {
114
+ printIntegrationSyncDiagnosis(synced.diagnosis);
115
+ process.exitCode = 1;
116
+ return "done";
117
+ }
118
+ if (synced.pulled)
119
+ log.info(dim(synced.diagnosis.summary));
120
+ }
121
+ await finishWithBranch(root, blocking.id, blocking.filePath, gitCfg, {
122
+ dryRun,
123
+ skipIntegrationPull: true,
124
+ });
125
+ return "done";
126
+ }
127
+ const ctx = await readGitContext(blocking.filePath);
128
+ log.warn(`${yellow("Blocked")} — ${bold(blocking.id)} is still open (${blocking.title || "no title"}).`);
129
+ if (ctx) {
130
+ log.info(` ${dim("branch")} ${ctx.taskBranch}`);
131
+ }
132
+ log.info(` ${dim("file")} ${relPath(root, blocking.filePath)}`);
116
133
  log.blank();
117
- log.info(dim("No TASK file was created. Fix the integration branch, then rerun task add."));
134
+ log.info(`Finish it first: ${cyan(`vibeops task ship ${blocking.id}`)}.`);
135
+ log.info(`Then run ${cyan("vibeops task add")} again.`);
118
136
  process.exitCode = 1;
119
- return;
120
- }
121
- if (synced.pulled) {
122
- log.ok(synced.diagnosis.summary);
123
- }
124
- else if (synced.diagnosis.kind === "no_remote_branch" ||
125
- synced.diagnosis.kind === "no_remote") {
126
- log.info(dim(synced.diagnosis.summary));
137
+ return "done";
127
138
  }
128
- else {
129
- log.info(dim(`Integration ${gitCfg.integrationBranch} is ready.`));
139
+ // Preflight: sync integration BEFORE writing a new TASK file (avoids half-created TASKs).
140
+ if (!dryRun && gitCfg !== null) {
141
+ log.step("Checking integration branch…");
142
+ const synced = await ensureIntegrationSynced({
143
+ cwd: root,
144
+ remote: gitCfg.remote,
145
+ integrationBranch: gitCfg.integrationBranch,
146
+ fetch: true,
147
+ });
148
+ if (!synced.ok) {
149
+ printIntegrationSyncDiagnosis(synced.diagnosis);
150
+ log.blank();
151
+ log.info(dim("No TASK file was created. Fix the integration branch, then rerun task add."));
152
+ process.exitCode = 1;
153
+ return "done";
154
+ }
155
+ if (synced.pulled) {
156
+ log.ok(synced.diagnosis.summary);
157
+ }
158
+ else if (synced.diagnosis.kind === "no_remote_branch" ||
159
+ synced.diagnosis.kind === "no_remote") {
160
+ log.info(dim(synced.diagnosis.summary));
161
+ }
162
+ else {
163
+ log.info(dim(`Integration ${gitCfg.integrationBranch} is ready.`));
164
+ }
165
+ log.blank();
130
166
  }
131
- log.blank();
132
- }
167
+ return "continue";
168
+ };
169
+ if ((dryRun ? await phase1() : await underLock(root, lockCfg, phase1)) === "done")
170
+ return;
171
+ // Phase 2 (unlocked): ask + draft. The draft uses a provisional id; the final id
172
+ // is allocated under the lock in phase 3 so a slow prompt / LLM never holds it.
133
173
  const ideaDefault = "New work slice";
134
174
  const idea = await askInput({
135
175
  message: "What are you doing now? (short)",
@@ -137,58 +177,96 @@ export async function taskAddCommand(opts = {}) {
137
177
  default: opts.idea?.trim() || ideaDefault,
138
178
  required: true,
139
179
  });
140
- const tasks = await loadActionableTasks(paths.docsTasks);
141
- const taskId = formatTaskId(allocateNextTaskNumber(tasks));
180
+ const provisionalId = formatTaskId(allocateNextTaskNumber(await loadActionableTasks(paths.docsTasks)));
142
181
  let title;
143
182
  let slug;
144
- let markdown;
183
+ let draftMarkdown;
145
184
  if (dryRun || nonInteractive) {
146
- const fb = fallbackTaskDraft(taskId, idea);
185
+ const fb = fallbackTaskDraft(provisionalId, idea);
147
186
  title = fb.title;
148
187
  slug = fb.slug;
149
- markdown = fb.markdown;
188
+ draftMarkdown = fb.markdown;
150
189
  }
151
190
  else {
152
- const llm = await llmScaffoldTask({ cwd: root, taskId, idea });
191
+ const llm = await llmScaffoldTask({ cwd: root, taskId: provisionalId, idea });
153
192
  if (llm !== null) {
154
193
  title = llm.title;
155
194
  slug = llm.slug;
156
- markdown = llm.markdown;
195
+ draftMarkdown = llm.markdown;
157
196
  log.skip(`Scaffold via ${llm.provider}`);
158
197
  }
159
198
  else {
160
199
  log.warn("LLM unavailable — using minimal TASK template.");
161
200
  log.info(dim(` Run ${cyan("vibeops llm connect")} to set up Codex, Cursor Agent CLI, or OpenAI.`));
162
- const fb = fallbackTaskDraft(taskId, idea);
201
+ const fb = fallbackTaskDraft(provisionalId, idea);
163
202
  title = fb.title;
164
203
  slug = fb.slug;
165
- markdown = fb.markdown;
204
+ draftMarkdown = fb.markdown;
166
205
  }
167
206
  }
168
- const { filePath } = uniqueTaskPath(paths.docsTasks, taskId, slugify(slug || title), tasks.map((t) => t.filePath));
169
- const relFile = relPath(root, filePath);
170
- const integration = gitCfg?.integrationBranch ?? "integration";
171
- if (dryRun) {
172
- log.info(`[dry-run] Would create ${bold(taskId)} → ${cyan(relFile)}`);
173
- log.info(dim(` branch task/${slugify(slug || title).replace(/^(\d+)-/, "$1-")} from ${integration}`));
174
- if (gitCfg) {
175
- await startTaskBranch({
176
- cwd: root,
177
- taskFile: filePath,
178
- integrationBranch: gitCfg.integrationBranch,
179
- remote: gitCfg.remote,
180
- dryRun: true,
181
- });
207
+ // Phase 3 (locked): allocate id → write TASK file → create branch.
208
+ const phase3 = async () => {
209
+ const tasks = await loadActionableTasks(paths.docsTasks);
210
+ let taskNumber = allocateNextTaskNumber(tasks);
211
+ if (gitCfg !== null) {
212
+ // D-006: ids are unique across the integration branch, every worktree and
213
+ // every local / remote VibeOps task branch — not just this worktree's docs/tasks.
214
+ try {
215
+ const alloc = await allocateTaskId({
216
+ cwd: root,
217
+ tasksDir: paths.docsTasks,
218
+ remote: gitCfg.remote,
219
+ integrationBranch: gitCfg.integrationBranch,
220
+ fetchRemote: !dryRun,
221
+ });
222
+ taskNumber = Math.max(taskNumber, alloc.next);
223
+ }
224
+ catch (e) {
225
+ if (e instanceof TaskIdAllocationError) {
226
+ log.error(e.message);
227
+ log.info(dim("No TASK file was created."));
228
+ process.exitCode = 1;
229
+ return "done";
230
+ }
231
+ if (!dryRun)
232
+ throw e;
233
+ // dry-run outside a git repo: local docs/tasks only.
234
+ }
182
235
  }
183
- return;
184
- }
185
- if (await pathExists(filePath)) {
186
- throw new Error(`TASK file already exists: ${relFile}`);
187
- }
188
- await writeText(filePath, markdown);
189
- log.ok(`Created ${bold(taskId)} → ${cyan(relFile)}`);
190
- await finishWithBranch(root, taskId, filePath, gitCfg, {
191
- dryRun: false,
192
- skipIntegrationPull: true,
193
- });
236
+ const taskId = formatTaskId(taskNumber);
237
+ const markdown = taskId === provisionalId
238
+ ? draftMarkdown
239
+ : draftMarkdown.replace(new RegExp(`\\b${provisionalId}\\b`, "g"), taskId);
240
+ const { filePath } = uniqueTaskPath(paths.docsTasks, taskId, slugify(slug || title), tasks.map((t) => t.filePath));
241
+ const relFile = relPath(root, filePath);
242
+ const integration = gitCfg?.integrationBranch ?? "integration";
243
+ if (dryRun) {
244
+ log.info(`[dry-run] Would create ${bold(taskId)} → ${cyan(relFile)}`);
245
+ log.info(dim(` branch task/${slugify(slug || title).replace(/^(\d+)-/, "$1-")} from ${gitCfg?.remote ?? "origin"}/${integration}`));
246
+ if (gitCfg) {
247
+ await startTaskBranch({
248
+ cwd: root,
249
+ taskFile: filePath,
250
+ integrationBranch: gitCfg.integrationBranch,
251
+ remote: gitCfg.remote,
252
+ dryRun: true,
253
+ });
254
+ }
255
+ return "done";
256
+ }
257
+ if (await pathExists(filePath)) {
258
+ throw new Error(`TASK file already exists: ${relFile}`);
259
+ }
260
+ await writeText(filePath, markdown);
261
+ log.ok(`Created ${bold(taskId)} → ${cyan(relFile)}`);
262
+ await finishWithBranch(root, taskId, filePath, gitCfg, {
263
+ dryRun: false,
264
+ skipIntegrationPull: true,
265
+ });
266
+ return "done";
267
+ };
268
+ if (dryRun)
269
+ await phase3();
270
+ else
271
+ await underLock(root, lockCfg, phase3);
194
272
  }
@@ -4,7 +4,14 @@ import { projectPaths } from "../lib/paths.js";
4
4
  import { relPath } from "../lib/task-context.js";
5
5
  import { assertTaskDeletable, assertWorkingTreeForDel, closeOpenTaskMergeRequestIfNeeded, deleteTaskBranches, deleteTaskMarkdown, loadGitConfigForDel, resolveDelTarget, taskDelNotFoundMessage, validateTaskBranchForDel, } from "../lib/task-del.js";
6
6
  import { readGitContext, statusDisplay } from "../lib/task.js";
7
+ import { runUnderTaskLock } from "../lib/task-lock.js";
7
8
  export async function taskDelCommand(taskRef, options = {}) {
9
+ if (options.dryRun === true)
10
+ return taskDelUnlocked(taskRef, options);
11
+ // D-006: branch switch / stash / delete are serialized across worktrees.
12
+ await runUnderTaskLock(resolve(options.cwd ?? process.cwd()), "task del", () => taskDelUnlocked(taskRef, options));
13
+ }
14
+ async function taskDelUnlocked(taskRef, options) {
8
15
  const cwd = resolve(options.cwd ?? process.cwd());
9
16
  const dryRun = options.dryRun === true;
10
17
  const paths = projectPaths(cwd);
@@ -15,6 +15,7 @@ import { applyTaskShipMemory, fallbackResultSections, llmCompleteTaskShip, write
15
15
  import { detectShipPrState, prRefLabel, resolveShipAction, } from "../lib/task-ship-state.js";
16
16
  import { finishTaskWithPullRequest } from "../lib/task-push-pr.js";
17
17
  import { hasNonEmptySection, parseTaskFilename, readGitContext, readTaskFile, updateInlineStatus, } from "../lib/task.js";
18
+ import { assertTaskIdFreeOnRemote } from "../lib/task-id-allocation.js";
18
19
  function describePushFailure(message) {
19
20
  const lower = message.toLowerCase();
20
21
  if (lower.includes("protected") ||
@@ -245,6 +246,12 @@ async function runUpdateOpenPr(ctx) {
245
246
  log.info(`Nothing to ship (working tree clean, PR ${prRefLabel(prCtx.number)} up to date).`);
246
247
  return;
247
248
  }
249
+ const idFree = await assertTaskIdFreeOnRemote(cwd, remote, gitCtx.taskBranch);
250
+ if (!idFree.ok) {
251
+ log.error(idFree.message);
252
+ process.exitCode = 1;
253
+ return;
254
+ }
248
255
  try {
249
256
  await gitPush(cwd, remote, gitCtx.taskBranch, false);
250
257
  log.ok(`Pushed ${gitCtx.taskBranch} → ${remote}`);
@@ -1,11 +1,13 @@
1
1
  import { resolve } from "node:path";
2
2
  import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
3
3
  import { detectGitHost } from "../lib/git-host.js";
4
- import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitFetchRemote, gitPullFastForwardOnly, gitRemoteBranchExists, gitRemoteUrl, gitSwitchToBranch, readGitInfo, } from "../lib/git.js";
4
+ import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitFetchRemote, gitRemoteBranchExists, gitRemoteUrl, readGitInfo, } from "../lib/git.js";
5
+ import { branchCheckedOutElsewhere, fastForwardLocalIntegration, leaveTaskBranch, logLocalIntegrationUpdate, } from "../lib/git-worktree.js";
5
6
  import { bold, cyan, dim, log } from "../lib/logger.js";
6
7
  import { projectPaths } from "../lib/paths.js";
7
8
  import { taskNotFoundMessage } from "../lib/resolve-task.js";
8
9
  import { relPath } from "../lib/task-context.js";
10
+ import { runUnderTaskLock } from "../lib/task-lock.js";
9
11
  import { resolveLifecycleTarget } from "../lib/task-lifecycle-target.js";
10
12
  import { checkTaskSyncReady } from "../lib/task-sync-guard.js";
11
13
  import { readTaskFile } from "../lib/task.js";
@@ -13,6 +15,13 @@ function isProtectedBranch(name, integration, production) {
13
15
  return name === integration || name === production || name === "main" || name === "master";
14
16
  }
15
17
  export async function taskSyncCommand(taskRef, options = {}) {
18
+ const cwd = resolve(options.cwd ?? process.cwd());
19
+ if (options.dryRun === true)
20
+ return taskSyncUnlocked(taskRef, options);
21
+ // D-006: branch switch / stash / delete are serialized across worktrees.
22
+ await runUnderTaskLock(cwd, "task sync", () => taskSyncUnlocked(taskRef, options));
23
+ }
24
+ async function taskSyncUnlocked(taskRef, options) {
16
25
  const cwd = resolve(options.cwd ?? process.cwd());
17
26
  const dryRun = options.dryRun === true;
18
27
  const paths = projectPaths(cwd);
@@ -57,10 +66,20 @@ export async function taskSyncCommand(taskRef, options = {}) {
57
66
  if (meta.status === "shipped" && options.force !== true) {
58
67
  log.info(dim("Would verify MR is merged and integration branch contains task commits before cleanup."));
59
68
  }
69
+ const owner = (await readGitInfo(cwd)).isRepo
70
+ ? await branchCheckedOutElsewhere(cwd, integrationBranch)
71
+ : null;
60
72
  log.info(bold("dry-run — would:"));
61
73
  log.info(` · git fetch ${remote} --prune`);
62
- log.info(` · git switch ${integrationBranch}`);
63
- log.info(` · git pull --ff-only ${remote} ${integrationBranch}`);
74
+ log.info(` · verify the MR is merged (${remote}/${integrationBranch} contains the task commits)`);
75
+ if (owner === null) {
76
+ log.info(` · if on ${taskBranch}: git switch ${integrationBranch}`);
77
+ log.info(` · fast-forward local ${integrationBranch} to ${remote}/${integrationBranch} (--ff-only)`);
78
+ }
79
+ else {
80
+ log.info(` · if on ${taskBranch}: git switch --detach ${remote}/${integrationBranch}`);
81
+ log.info(dim(` · ${integrationBranch} is checked out in worktree ${owner} — left unchanged`));
82
+ }
64
83
  log.info(` · git branch -D ${taskBranch}`);
65
84
  if (options.noRemoteDelete !== true) {
66
85
  log.info(` · git push ${remote} --delete ${taskBranch} (if exists)`);
@@ -76,9 +95,6 @@ export async function taskSyncCommand(taskRef, options = {}) {
76
95
  process.exitCode = 1;
77
96
  return;
78
97
  }
79
- if (git.branch === taskBranch) {
80
- log.info(dim(`On ${taskBranch} — switching to ${integrationBranch} first.`));
81
- }
82
98
  try {
83
99
  await gitFetchRemote(cwd, remote);
84
100
  log.ok(`Fetched ${remote} (--prune)`);
@@ -87,32 +103,11 @@ export async function taskSyncCommand(taskRef, options = {}) {
87
103
  const msg = e instanceof Error ? e.message : String(e);
88
104
  log.warn(`git fetch failed (${msg}). Continuing with local refs.`);
89
105
  }
90
- const switched = await gitSwitchToBranch(cwd, integrationBranch, remote);
91
- if (!switched) {
92
- log.error(`Integration branch "${integrationBranch}" not found locally or on ${remote}.`);
93
- process.exitCode = 1;
94
- return;
95
- }
96
- log.ok(`On ${integrationBranch}`);
97
- if (await gitRemoteBranchExists(cwd, remote, integrationBranch)) {
98
- try {
99
- await gitPullFastForwardOnly(cwd, remote, integrationBranch);
100
- log.ok(`Up to date with ${remote}/${integrationBranch} (--ff-only)`);
101
- }
102
- catch (e) {
103
- const msg = e instanceof Error ? e.message : String(e);
104
- log.error(`Could not fast-forward ${integrationBranch}: ${msg}. Resolve locally, then rerun.`);
105
- process.exitCode = 1;
106
- return;
107
- }
108
- }
109
- else {
110
- log.warn(`No ${remote}/${integrationBranch} — skipped pull.`);
111
- }
112
106
  const remoteUrl = await gitRemoteUrl(cwd, remote);
113
107
  const host = remoteUrl !== null && detectGitHost(remoteUrl) !== null
114
108
  ? detectGitHost(remoteUrl)
115
109
  : gitCfg.host;
110
+ // Verify the merge BEFORE moving HEAD or touching any branch.
116
111
  const syncGuard = await checkTaskSyncReady({
117
112
  cwd,
118
113
  taskFile,
@@ -131,6 +126,43 @@ export async function taskSyncCommand(taskRef, options = {}) {
131
126
  return;
132
127
  }
133
128
  log.ok(`Ready to sync — ${integrationBranch} contains the task commits.`);
129
+ // Worktree-safe (D-006): leave the task branch without requiring the
130
+ // integration branch when another worktree has it checked out.
131
+ if (git.branch === taskBranch) {
132
+ const left = await leaveTaskBranch(cwd, remote, integrationBranch);
133
+ if (!left.ok) {
134
+ log.error(left.message);
135
+ process.exitCode = 1;
136
+ return;
137
+ }
138
+ log.ok(left.on === "integration" ? `On ${integrationBranch}` : `Detached at ${left.ref}`);
139
+ }
140
+ if (await gitRemoteBranchExists(cwd, remote, integrationBranch)) {
141
+ try {
142
+ const update = await fastForwardLocalIntegration(cwd, remote, integrationBranch);
143
+ if (update.kind === "up_to_date") {
144
+ log.ok(`Local ${integrationBranch} is up to date with ${remote}/${integrationBranch}`);
145
+ }
146
+ else if (update.kind === "not_fast_forward") {
147
+ // Same outcome as the former `git pull --ff-only` failure: stop before cleanup.
148
+ log.error(`Local ${integrationBranch} has commits not on ${remote}/${integrationBranch} — cannot fast-forward (never forced). Push or reset it, then rerun.`);
149
+ process.exitCode = 1;
150
+ return;
151
+ }
152
+ else {
153
+ logLocalIntegrationUpdate(update, remote, integrationBranch);
154
+ }
155
+ }
156
+ catch (e) {
157
+ const msg = e instanceof Error ? e.message : String(e);
158
+ log.error(`Could not fast-forward ${integrationBranch}: ${msg}. Resolve locally, then rerun.`);
159
+ process.exitCode = 1;
160
+ return;
161
+ }
162
+ }
163
+ else {
164
+ log.warn(`No ${remote}/${integrationBranch} — skipped fast-forward.`);
165
+ }
134
166
  if (await gitBranchExists(cwd, taskBranch)) {
135
167
  try {
136
168
  // Guard verified integration contains the work — squash merges leave task/* SHAs