@goodtek/vibeops 2.5.2 → 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 +55 -0
- package/README.md +89 -4
- 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-merge.js +31 -4
- package/dist/commands/task-release.js +28 -4
- package/dist/commands/task-ship.js +7 -0
- package/dist/commands/task-sync.js +60 -28
- package/dist/lib/check-rollup.js +128 -0
- package/dist/lib/config.js +3 -0
- 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/merge-config.js +125 -0
- package/dist/lib/merge-request-readiness.js +20 -1
- package/dist/lib/pr-create.js +293 -103
- 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,61 @@ 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
|
+
|
|
31
|
+
## 3.0.0 - 2026-10-03
|
|
32
|
+
|
|
33
|
+
### Breaking changes
|
|
34
|
+
|
|
35
|
+
- **`task merge` / `task release` refuse to merge a PR/MR with no checks at all** (default `merge.allowNoChecks: false`). Previously such PRs merged.
|
|
36
|
+
**Migration (repos without CI):** add to `.vibeops.json`:
|
|
37
|
+
```json
|
|
38
|
+
{ "merge": { "allowNoChecks": true } }
|
|
39
|
+
```
|
|
40
|
+
A warning is printed on each such merge.
|
|
41
|
+
- **Requires gh ≥ 2.13.0** (`gh pr merge --match-head-commit`). Check with `gh --version`; upgrade e.g. `brew upgrade gh`.
|
|
42
|
+
- **Invalid `.vibeops.json` `merge` block** (non-object, unknown key, wrong type, out-of-range number) makes `task merge` / `task release` exit 1.
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **`task merge` / `task release` (GitHub):** GitHub Actions checks (`CheckRun`) were read from a `state` field they do not have, so a **failed Actions check counted as green** and the PR merged. Checks are now classified from `status` + `conclusion` (CheckRun) and `state` (StatusContext); missing or undocumented values fail closed.
|
|
47
|
+
- **`task merge` / `task release` (GitLab):** `manual` and unknown head-pipeline statuses no longer count as mergeable; `scheduled` / `waiting_for_callback` / `canceling` count as running. A `405` on merge no longer falls back to scheduling auto-merge; it re-runs the gate and retries once with `--auto-merge=false`.
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- **Merge gate:** refuse on any failed check (immediately), wait bounded on pending checks then refuse, refuse on merge conflicts. No override flag. Merges are pinned to the checked commit (`gh pr merge --match-head-commit`, `glab mr merge --sha`).
|
|
52
|
+
- **`.vibeops.json` `merge` block:** `requiredChecks`, `releaseRequiredChecks` (exact or `*` glob; absent → `required check "X" never ran`), `waitTimeoutSeconds` (900), `pollIntervalSeconds` (5), `emptyRollupGraceSeconds` (30), `allowNoChecks` (false). Strictly validated; `task release --dry-run` shows the release required checks.
|
|
53
|
+
|
|
54
|
+
### Behaviour changes
|
|
55
|
+
|
|
56
|
+
- No checks at all: refused after a 30 s grace (`emptyRollupGraceSeconds`) for checks not registered yet — see Breaking changes.
|
|
57
|
+
- **Host output is verified:** missing or empty `state` / `mergeable` / `headRefOid` / `statusCheckRollup` from `gh pr view` (GitLab: `state` / `sha` / `merge_status` / `detailed_merge_status` / `head_pipeline`) refuses the merge instead of being treated as green.
|
|
58
|
+
- **GitLab `manual` head pipelines refuse** (previously merged when the MR was otherwise mergeable).
|
|
59
|
+
- **Pending checks are always waited for** on `task merge` / `task release` (up to `waitTimeoutSeconds`); a PR whose checks are still running is no longer merged or auto-merge-scheduled.
|
|
60
|
+
- `glab mr merge --sha` verified with glab 1.106.
|
|
61
|
+
|
|
7
62
|
## 2.5.2 - 2026-07-20
|
|
8
63
|
|
|
9
64
|
### Fixed
|
package/README.md
CHANGED
|
@@ -153,15 +153,100 @@ 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
|
-
- **`task merge`**: merge MR/PR into integration (
|
|
159
|
-
- **`task sync`**: integration
|
|
160
|
-
- **`pull`**: fetch + switch to integration branch + `git pull --ff-only` (one command).
|
|
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`**: 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
|
+
|
|
203
|
+
## Merge gate
|
|
204
|
+
|
|
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**.
|
|
206
|
+
|
|
207
|
+
`task merge` and `task release` are a client-side CI gate (useful when the host plan has no branch protection / required checks, e.g. private GitHub repos on the free plan). **Where server-side branch protection / required checks are available, enable them — the host remains the final authority;** this gate only stops VibeOps itself from merging red work (a human can still merge in the host UI).
|
|
208
|
+
|
|
209
|
+
Before calling `gh pr merge` / `glab mr merge`:
|
|
210
|
+
|
|
211
|
+
- **GitHub:** every item in the PR `statusCheckRollup` is classified from GitHub's documented enums — `CheckRun` (Actions) by `status` + `conclusion`, `StatusContext` by `state`:
|
|
212
|
+
- **passed:** `SUCCESS`, `NEUTRAL`, `SKIPPED`
|
|
213
|
+
- **failed:** `FAILURE`, `CANCELLED`, `TIMED_OUT`, `ACTION_REQUIRED`, `STARTUP_FAILURE`, `STALE`, `ERROR`
|
|
214
|
+
- **pending:** CheckRun `REQUESTED`, `QUEUED`, `IN_PROGRESS`, `WAITING`, `PENDING`; StatusContext `PENDING`, `EXPECTED`
|
|
215
|
+
- **anything missing or undocumented fails closed** (treated as failed). Several checks with the same name: any failure fails.
|
|
216
|
+
- **GitLab:** the MR head pipeline must be `success` or `skipped`. `failed`, `canceled`, `manual` and unknown statuses refuse; `created` / `pending` / `running` / `scheduled` / … wait.
|
|
217
|
+
- **Host output is verified:** if `gh pr view` lacks or has empty `state`, `mergeable`, `headRefOid`, or `statusCheckRollup` is not a list (GitLab: `state`, `sha`, `merge_status`, `detailed_merge_status`, `head_pipeline`), the merge is refused.
|
|
218
|
+
- **Failed check → refuse immediately** (exit code 1, checks named). **Pending → wait** up to `waitTimeoutSeconds`, then refuse. There is no override flag: fix CI (re-run `task ship`) and rerun `task merge`.
|
|
219
|
+
- **No checks at all → refuse** (after `emptyRollupGraceSeconds`, which covers checks not yet registered right after a push) unless `allowNoChecks: true`.
|
|
220
|
+
- **The merge is pinned to the checked commit:** `gh pr merge --match-head-commit <sha>` (requires **gh ≥ 2.13.0**) / `glab mr merge --auto-merge=false --sha <sha>` (verified with glab 1.106). A push after the check makes the host refuse. A GitLab `405` re-runs the gate and retries once with the same flags.
|
|
221
|
+
|
|
222
|
+
### Configuration (`.vibeops.json` → `merge`)
|
|
223
|
+
|
|
224
|
+
```json
|
|
225
|
+
{
|
|
226
|
+
"merge": {
|
|
227
|
+
"requiredChecks": ["build", "lint*"],
|
|
228
|
+
"releaseRequiredChecks": ["release smoke"],
|
|
229
|
+
"waitTimeoutSeconds": 900,
|
|
230
|
+
"pollIntervalSeconds": 5,
|
|
231
|
+
"emptyRollupGraceSeconds": 30,
|
|
232
|
+
"allowNoChecks": false
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
| Key | Default | Allowed | Meaning |
|
|
238
|
+
|-----|---------|---------|---------|
|
|
239
|
+
| `requiredChecks` | `[]` | array of non-empty strings | Checks that must be present and green for `task merge`. A missing one refuses: `required check "X" never ran`. |
|
|
240
|
+
| `releaseRequiredChecks` | `[]` | array of non-empty strings | Same for `task release` (integration → production). Shown by `task release --dry-run`. |
|
|
241
|
+
| `waitTimeoutSeconds` | `900` | integer 1–7200 | Max wait for pending checks. |
|
|
242
|
+
| `pollIntervalSeconds` | `5` | integer 1–300, ≤ timeout | Poll interval while waiting. |
|
|
243
|
+
| `emptyRollupGraceSeconds` | `30` | integer 0–600 | How long an empty check list is re-polled before "no checks" applies. |
|
|
244
|
+
| `allowNoChecks` | `false` | boolean | Merge a PR that has no checks at all (repo without CI). Prints one warning per such merge. |
|
|
245
|
+
|
|
246
|
+
- Check names are exact (`CheckRun.name` / `StatusContext.context`) or a `*` glob (`"lint*"`, `"* · linux"`). Required checks are GitHub-only; GitLab gates on the head pipeline.
|
|
247
|
+
- The block is validated strictly: it must be an object, unknown keys (typos) and wrong types are errors, and `task merge` / `task release` exit 1 naming the key.
|
|
248
|
+
- **Recommended:** list your CI jobs in `requiredChecks`. Without it, the gate only sees the checks that happened to start — a workflow that never triggers is invisible.
|
|
249
|
+
|
|
165
250
|
## License
|
|
166
251
|
|
|
167
252
|
MIT
|
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);
|