@goodtek/vibeops 3.0.0 → 3.1.0
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 +24 -0
- package/README.md +41 -3
- package/dist/commands/pull.js +16 -0
- package/dist/commands/task-add.js +175 -97
- package/dist/commands/task-del.js +7 -0
- package/dist/commands/task-ship.js +7 -0
- package/dist/commands/task-sync.js +60 -28
- package/dist/lib/git-integration-sync.js +77 -71
- package/dist/lib/git-worktree.js +214 -0
- package/dist/lib/git.js +63 -54
- package/dist/lib/task-del.js +7 -5
- package/dist/lib/task-id-allocation.js +192 -0
- package/dist/lib/task-lock.js +356 -0
- package/dist/lib/task-new-cycle.js +12 -1
- package/dist/lib/task-push-pr.js +7 -0
- package/dist/lib/task-reship.js +27 -12
- package/dist/lib/task-start.js +35 -26
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@ All notable changes to VibeOps are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 3.1.0 - 2026-10-03
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **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 …`).
|
|
12
|
+
- `task add`: `git fetch <remote> <integration>`, then `git switch -c task/NNN-slug --no-track <remote>/<integration>`.
|
|
13
|
+
- 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.
|
|
14
|
+
- `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.
|
|
15
|
+
- `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.
|
|
16
|
+
- **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.
|
|
17
|
+
- **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.
|
|
18
|
+
- **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).
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **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.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- `task sync` checks merge verification before switching branches (a refused sync now leaves HEAD on the task branch).
|
|
27
|
+
- 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.
|
|
28
|
+
- Git Context `baseCommit` is recorded as a full SHA (was a 7-character prefix).
|
|
29
|
+
- All git commands run with `LC_ALL=C` / `LANG=C` so parsed output (porcelain, refusal messages) is never localized.
|
|
30
|
+
|
|
7
31
|
## 3.0.0 - 2026-10-03
|
|
8
32
|
|
|
9
33
|
### 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
|
|
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
|
|
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**.
|
package/dist/commands/pull.js
CHANGED
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (
|
|
69
|
-
log.
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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(
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
183
|
+
let draftMarkdown;
|
|
145
184
|
if (dryRun || nonInteractive) {
|
|
146
|
-
const fb = fallbackTaskDraft(
|
|
185
|
+
const fb = fallbackTaskDraft(provisionalId, idea);
|
|
147
186
|
title = fb.title;
|
|
148
187
|
slug = fb.slug;
|
|
149
|
-
|
|
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
|
-
|
|
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(
|
|
201
|
+
const fb = fallbackTaskDraft(provisionalId, idea);
|
|
163
202
|
title = fb.title;
|
|
164
203
|
slug = fb.slug;
|
|
165
|
-
|
|
204
|
+
draftMarkdown = fb.markdown;
|
|
166
205
|
}
|
|
167
206
|
}
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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,
|
|
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(` ·
|
|
63
|
-
|
|
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
|