@goodtek/vibeops 2.5.0 → 2.5.2

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,19 @@ All notable changes to VibeOps are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 2.5.2 - 2026-07-20
8
+
9
+ ### Fixed
10
+
11
+ - **`task add` / integration sync:** Governance-only dirt (`.vibeops.json`, `AGENTS.md`, `.vibeops/`, docs, `.cursor/rules/`) no longer blocks preflight. Right after `init`, `task add` works without stash. App-file dirt still fails and lists the blocking paths.
12
+
13
+ ## 2.5.1 - 2026-07-20
14
+
15
+ ### Fixed
16
+
17
+ - **`task add`:** Preflight-syncs the integration branch **before** creating a TASK file. On ff-only failure, prints a classified diagnosis (dirty / ahead / diverged) with copy-paste fix commands. Incomplete adds (file created, branch missing) resume on the next `task add` instead of blocking with “finish ship first”.
18
+ - **`vibeops pull`:** Uses the same integration sync diagnosis when `--ff-only` fails.
19
+
7
20
  ## 2.5.0 - 2026-07-19
8
21
 
9
22
  ### Breaking
package/README.md CHANGED
@@ -7,21 +7,49 @@
7
7
 
8
8
  VibeOps bootstraps an **agent-friendly repo**, starts numbered **TASK** files on Git branches, and runs a clear GitFlow lifecycle. You plan and implement in your chosen agent; the CLI handles files, Git, and short LLM assists.
9
9
 
10
+ **Current release:** [`@goodtek/vibeops@2.5.1`](https://www.npmjs.com/package/@goodtek/vibeops) — interactive init remote (GitHub/GitLab), state-aware `task ship` only (no `task reship`), `status` Now/Next card, clearer `task add` integration sync errors.
11
+
10
12
  ## Commands
11
13
 
12
14
  | Command | Purpose |
13
15
  |---------|---------|
14
- | `vibeops init` | Core docs + agent packs (cursor / claude / codex) |
16
+ | `vibeops init` | Core docs + agent packs; interactive Git host + create/connect remote |
15
17
  | `vibeops task add` | New `TASK-NNN` file + task branch |
16
18
  | `vibeops task del` | Cancel TASK before merge (md + branch + close open MR) |
17
- | `vibeops task ship` | State-aware submit: open a new PR · update the open PR · start a new PR cycle after merge (Status → **Shipped**) |
19
+ | `vibeops task ship` | State-aware submit: new PR · update open PR · new PR cycle after merge (Status → **Shipped**) |
18
20
  | `vibeops task merge` | Merge TASK MR/PR into integration branch (default: squash) |
19
21
  | `vibeops task sync` | After merge: ff-only integration pull, delete task branches (no TASK md edits) |
20
22
  | `vibeops task release` | Release PR: integration → production (GitFlow) |
21
23
  | `vibeops pull` | Fetch remote + update integration branch (e.g. develop) |
22
- | `vibeops status` | Now / Next briefing: focus TASK, Git, what to run next |
24
+ | `vibeops status` | **Now / Next** card — focus TASK, checklist, what to run next |
23
25
  | `vibeops llm` | Connect LLM providers (`connect` · `status` · `use`) |
24
26
 
27
+ ## Installation
28
+
29
+ Node.js 20+.
30
+
31
+ ```bash
32
+ npm install -g @goodtek/vibeops@2.5.1
33
+ # or latest: npm install -g @goodtek/vibeops
34
+ vibeops --version # expect 2.5.1
35
+ ```
36
+
37
+ If `vibeops --version` stays on an old release after install, check for a **shell alias** or **Volta** shim shadowing npm:
38
+
39
+ ```bash
40
+ type -a vibeops
41
+ # alias → unalias vibeops (and remove from ~/.zshrc)
42
+ # ~/.volta/bin/vibeops → volta install @goodtek/vibeops@2.5.1
43
+ ```
44
+
45
+ **Upgrading from before 2.5:** `task reship` was removed. Use `vibeops task ship` (re-run while the PR is open; after merge use confirm or `--new-cycle`).
46
+
47
+ Development:
48
+
49
+ ```bash
50
+ pnpm install && pnpm build && pnpm smoke
51
+ ```
52
+
25
53
  ## Init
26
54
 
27
55
  Interactive init asks which agents to install, Git branch policy, then **where the remote lives** (GitHub / GitLab / skip) and whether to **create** or **connect** a repository. If `gh`/`glab` is missing, init prints install hints (optional `brew install` with consent) or accepts a URL — it does not block the rest of VibeOps.
@@ -83,27 +111,31 @@ vibeops task release
83
111
 
84
112
  Only one TASK **In Progress** at a time (`task add` blocks otherwise). **Shipped** slices do not block the next add; merge on the host or with `task merge`, then optional `task sync`.
85
113
 
86
- ## LLM (optional)
114
+ `task add` fast-forward-pulls the integration branch first. If that fails (diverged / **app** dirty / local-ahead), it prints the cause and fix commands and **does not** create a TASK file. VibeOps/governance-only changes (e.g. `.vibeops.json` after `init`) do **not** block. If a previous run left a TASK file without a branch, rerun `task add` to resume.
87
115
 
88
- For **`task add`** / **`task ship`** only (not for coding in the IDE). When `-m` is omitted, `ship` uses a connected provider to draft the commit subject:
116
+ ## Status
89
117
 
90
118
  ```bash
91
- vibeops llm connect
92
- vibeops llm use auto # auto | codex-oauth | cursor-agent | openai
119
+ vibeops status
93
120
  ```
94
121
 
95
- ## Installation
122
+ Prints a short card:
96
123
 
97
- Node.js 20+.
124
+ - **NOW** — focus TASK, stage, Result/Test checklist, branch, PR
125
+ - **NEXT** — one primary action (copy-pasteable command)
126
+ - footer — project, task counts, LLM (dim)
98
127
 
99
128
  ```bash
100
- npm install -g @goodtek/vibeops
129
+ vibeops status --json # machine-readable
101
130
  ```
102
131
 
103
- Development:
132
+ ## LLM (optional)
133
+
134
+ For **`task add`** / **`task ship`** only (not for coding in the IDE). When `-m` is omitted, `ship` uses a connected provider to draft the commit subject:
104
135
 
105
136
  ```bash
106
- pnpm install && pnpm build && pnpm smoke
137
+ vibeops llm connect
138
+ vibeops llm use auto # auto | codex-oauth | cursor-agent | openai
107
139
  ```
108
140
 
109
141
  ## Flags (common)
@@ -1,5 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
  import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
3
+ import { diagnoseIntegrationSync, printIntegrationSyncDiagnosis, } from "../lib/git-integration-sync.js";
3
4
  import { gitFetchRemote, gitPullFastForwardOnly, gitRemoteBranchExists, gitRemoteUrl, gitSwitchToBranch, readGitInfo, } from "../lib/git.js";
4
5
  import { bold, cyan, dim, log } from "../lib/logger.js";
5
6
  /**
@@ -73,7 +74,13 @@ export async function pullCommand(options = {}) {
73
74
  }
74
75
  catch (e) {
75
76
  const msg = e instanceof Error ? e.message : String(e);
76
- log.error(`Could not fast-forward ${integrationBranch}: ${msg}. Commit or stash local changes, then rerun \`vibeops pull\`.`);
77
+ const diagnosis = await diagnoseIntegrationSync(cwd, remote, integrationBranch);
78
+ if (!diagnosis.ok) {
79
+ printIntegrationSyncDiagnosis(diagnosis);
80
+ }
81
+ else {
82
+ log.error(`Could not fast-forward ${integrationBranch}: ${msg}. Commit or stash local changes, then rerun \`vibeops pull\`.`);
83
+ }
77
84
  process.exitCode = 1;
78
85
  }
79
86
  }
@@ -1,6 +1,7 @@
1
1
  import { resolve } from "node:path";
2
2
  import { pathExists, writeText } from "../lib/filesystem.js";
3
3
  import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
4
+ import { ensureIntegrationSynced, printIntegrationSyncDiagnosis, } from "../lib/git-integration-sync.js";
4
5
  import { askInput } from "../lib/inquirer-helpers.js";
5
6
  import { bold, cyan, dim, log, yellow } from "../lib/logger.js";
6
7
  import { projectPaths } from "../lib/paths.js";
@@ -8,7 +9,7 @@ import { slugify } from "../lib/slug.js";
8
9
  import { fallbackTaskDraft, llmScaffoldTask } from "../lib/task-add-llm.js";
9
10
  import { allocateNextTaskNumber, formatTaskId, uniqueTaskPath, } from "../lib/task-scaffold.js";
10
11
  import { findBlockingTask, relPath } from "../lib/task-context.js";
11
- import { startTaskBranch } from "../lib/task-start.js";
12
+ import { isIncompleteTaskStart, startTaskBranch } from "../lib/task-start.js";
12
13
  import { loadActionableTasks, readGitContext } from "../lib/task.js";
13
14
  async function loadGitConfigOrNull(root, dryRun) {
14
15
  try {
@@ -25,6 +26,31 @@ async function loadGitConfigOrNull(root, dryRun) {
25
26
  throw e;
26
27
  }
27
28
  }
29
+ async function finishWithBranch(root, taskId, filePath, gitCfg, opts) {
30
+ const relFile = relPath(root, filePath);
31
+ log.blank();
32
+ log.step("Starting task branch…");
33
+ const started = await startTaskBranch({
34
+ cwd: root,
35
+ taskFile: filePath,
36
+ integrationBranch: gitCfg.integrationBranch,
37
+ remote: gitCfg.remote,
38
+ allowDirty: true,
39
+ dryRun: opts.dryRun,
40
+ skipIntegrationPull: opts.skipIntegrationPull,
41
+ });
42
+ if (!started) {
43
+ process.exitCode = 1;
44
+ return;
45
+ }
46
+ if (opts.dryRun)
47
+ return;
48
+ log.blank();
49
+ log.info(bold("Next in Cursor"));
50
+ log.info(` Ask: @${relFile} — plan Scope / Acceptance Criteria`);
51
+ log.info(` Agent: same file — implement`);
52
+ log.info(` Ship: ${cyan(`vibeops task ship ${taskId}`)}`);
53
+ }
28
54
  export async function taskAddCommand(opts = {}) {
29
55
  const root = resolve(opts.cwd ?? process.cwd());
30
56
  const dryRun = opts.dryRun === true;
@@ -36,7 +62,34 @@ export async function taskAddCommand(opts = {}) {
36
62
  return;
37
63
  const paths = projectPaths(root);
38
64
  const blocking = await findBlockingTask(paths, root);
39
- if (blocking !== null) {
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
+ });
91
+ return;
92
+ }
40
93
  const ctx = await readGitContext(blocking.filePath);
41
94
  log.warn(`${yellow("Blocked")} — ${bold(blocking.id)} is still open (${blocking.title || "no title"}).`);
42
95
  if (ctx) {
@@ -49,6 +102,34 @@ export async function taskAddCommand(opts = {}) {
49
102
  process.exitCode = 1;
50
103
  return;
51
104
  }
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);
116
+ log.blank();
117
+ log.info(dim("No TASK file was created. Fix the integration branch, then rerun task add."));
118
+ 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));
127
+ }
128
+ else {
129
+ log.info(dim(`Integration ${gitCfg.integrationBranch} is ready.`));
130
+ }
131
+ log.blank();
132
+ }
52
133
  const ideaDefault = "New work slice";
53
134
  const idea = await askInput({
54
135
  message: "What are you doing now? (short)",
@@ -106,22 +187,8 @@ export async function taskAddCommand(opts = {}) {
106
187
  }
107
188
  await writeText(filePath, markdown);
108
189
  log.ok(`Created ${bold(taskId)} → ${cyan(relFile)}`);
109
- log.blank();
110
- log.step("Starting task branch…");
111
- const started = await startTaskBranch({
112
- cwd: root,
113
- taskFile: filePath,
114
- integrationBranch: gitCfg.integrationBranch,
115
- remote: gitCfg.remote,
116
- allowDirty: true,
190
+ await finishWithBranch(root, taskId, filePath, gitCfg, {
191
+ dryRun: false,
192
+ skipIntegrationPull: true,
117
193
  });
118
- if (!started) {
119
- process.exitCode = 1;
120
- return;
121
- }
122
- log.blank();
123
- log.info(bold("Next in Cursor"));
124
- log.info(` Ask: @${relFile} — plan Scope / Acceptance Criteria`);
125
- log.info(` Agent: same file — implement`);
126
- log.info(` Ship: ${cyan(`vibeops task ship ${taskId}`)}`);
127
194
  }
@@ -0,0 +1,232 @@
1
+ import { gitFetchRemote, gitGovernanceOnlyDirty, gitLeftRightCount, gitPullFastForwardOnly, gitRemoteBranchExists, gitRemoteUrl, gitRevParse, gitSwitchToBranch, readGitInfo, restoreGovernanceStashAfterSwitch, stashGovernanceIfBlocking, } from "./git.js";
2
+ import { cyan, dim, log } from "./logger.js";
3
+ function remoteRef(remote, branch) {
4
+ return `${remote}/${branch}`;
5
+ }
6
+ /**
7
+ * Classify why `git pull --ff-only` would fail for the integration branch.
8
+ * Call after fetch when possible so `origin/branch` is current.
9
+ *
10
+ * Governance-only dirt (`.vibeops.json`, docs/, `.vibeops/`, …) does **not**
11
+ * block — `task add` right after `init` must work.
12
+ */
13
+ export async function diagnoseIntegrationSync(cwd, remote, integrationBranch) {
14
+ if ((await gitRemoteUrl(cwd, remote)) === null) {
15
+ return {
16
+ ok: true,
17
+ kind: "no_remote",
18
+ summary: `No git remote "${remote}" — using local ${integrationBranch} only.`,
19
+ fixes: [],
20
+ };
21
+ }
22
+ if (!(await gitRemoteBranchExists(cwd, remote, integrationBranch))) {
23
+ return {
24
+ ok: true,
25
+ kind: "no_remote_branch",
26
+ summary: `No ${remote}/${integrationBranch} yet — local branch is fine for first push.`,
27
+ fixes: [],
28
+ };
29
+ }
30
+ const git = await readGitInfo(cwd);
31
+ if (git.dirty === true) {
32
+ const gov = await gitGovernanceOnlyDirty(cwd);
33
+ if (!gov.onlyGovernance) {
34
+ const blocking = gov.nonGovernancePaths.slice(0, 8);
35
+ const more = gov.nonGovernancePaths.length > blocking.length
36
+ ? ` (+${gov.nonGovernancePaths.length - blocking.length} more)`
37
+ : "";
38
+ return {
39
+ ok: false,
40
+ kind: "dirty",
41
+ summary: `Working tree has app changes — cannot fast-forward ${integrationBranch}.`,
42
+ fixes: [
43
+ "git status",
44
+ `# Blocking (non-governance): ${blocking.join(", ")}${more}`,
45
+ "git stash push -u -m \"vibeops: before sync\"",
46
+ `git checkout ${integrationBranch}`,
47
+ `git pull --ff-only ${remote} ${integrationBranch}`,
48
+ "git stash pop # if you stashed",
49
+ "vibeops task add",
50
+ ],
51
+ };
52
+ }
53
+ // Governance-only dirty (.vibeops.json after init, docs, …) — proceed.
54
+ }
55
+ const localSha = await gitRevParse(cwd, integrationBranch);
56
+ const remoteSha = await gitRevParse(cwd, remoteRef(remote, integrationBranch));
57
+ if (localSha === null || remoteSha === null) {
58
+ return {
59
+ ok: false,
60
+ kind: "pull_failed",
61
+ summary: `Could not resolve ${integrationBranch} or ${remote}/${integrationBranch}.`,
62
+ fixes: [
63
+ `git fetch ${remote}`,
64
+ `git checkout ${integrationBranch}`,
65
+ `git pull --ff-only ${remote} ${integrationBranch}`,
66
+ ],
67
+ };
68
+ }
69
+ if (localSha === remoteSha) {
70
+ return {
71
+ ok: true,
72
+ kind: "ok",
73
+ summary: `${integrationBranch} matches ${remote}/${integrationBranch}.`,
74
+ fixes: [],
75
+ };
76
+ }
77
+ const counts = await gitLeftRightCount(cwd, integrationBranch, remoteRef(remote, integrationBranch));
78
+ const ahead = counts?.left ?? 0;
79
+ const behind = counts?.right ?? 0;
80
+ if (ahead > 0 && behind > 0) {
81
+ return {
82
+ ok: false,
83
+ kind: "diverged",
84
+ summary: `Local ${integrationBranch} and ${remote}/${integrationBranch} have diverged (local +${ahead}, remote +${behind}).`,
85
+ fixes: [
86
+ `git fetch ${remote}`,
87
+ `git checkout ${integrationBranch}`,
88
+ `# Prefer remote (discards local-only commits on ${integrationBranch}):`,
89
+ `git reset --hard ${remote}/${integrationBranch}`,
90
+ `# Or keep local commits: git pull --rebase ${remote} ${integrationBranch}`,
91
+ "vibeops task add",
92
+ ],
93
+ ahead,
94
+ behind,
95
+ };
96
+ }
97
+ if (ahead > 0 && behind === 0) {
98
+ return {
99
+ ok: false,
100
+ kind: "ahead",
101
+ summary: `Local ${integrationBranch} is ${ahead} commit(s) ahead of ${remote}/${integrationBranch} — fast-forward pull cannot apply.`,
102
+ fixes: [
103
+ `git checkout ${integrationBranch}`,
104
+ `# Push local commits, or reset to remote if they should not exist:`,
105
+ `git push -u ${remote} ${integrationBranch}`,
106
+ `# or: git reset --hard ${remote}/${integrationBranch}`,
107
+ "vibeops task add",
108
+ ],
109
+ ahead,
110
+ behind,
111
+ };
112
+ }
113
+ // behind only — ff-only should work
114
+ return {
115
+ ok: true,
116
+ kind: "ok",
117
+ summary: `${integrationBranch} is ${behind} commit(s) behind ${remote}/${integrationBranch} (ff-only OK).`,
118
+ fixes: [],
119
+ ahead,
120
+ behind,
121
+ };
122
+ }
123
+ export function printIntegrationSyncDiagnosis(d) {
124
+ if (d.ok) {
125
+ if (d.kind !== "ok" && d.kind !== "no_remote_branch")
126
+ log.info(dim(d.summary));
127
+ return;
128
+ }
129
+ log.error(d.summary);
130
+ if (d.fixes.length > 0) {
131
+ log.blank();
132
+ log.info(dim("Fix (run in this repo):"));
133
+ for (const line of d.fixes) {
134
+ if (line.startsWith("#"))
135
+ log.info(dim(` ${line}`));
136
+ else
137
+ log.info(` ${cyan(line)}`);
138
+ }
139
+ }
140
+ }
141
+ /**
142
+ * Fetch + ensure local integration can be used as the base for a new task branch.
143
+ * Switches to the integration branch, then `--ff-only` pull when needed. Never force-resets.
144
+ * Governance-only dirt is stashed around switch/pull so post-`init` `.vibeops.json` edits do not block.
145
+ */
146
+ export async function ensureIntegrationSynced(opts) {
147
+ const { cwd, remote, integrationBranch } = opts;
148
+ const doFetch = opts.fetch !== false;
149
+ if (opts.dryRun === true) {
150
+ const d = await diagnoseIntegrationSync(cwd, remote, integrationBranch);
151
+ return { ok: d.ok || d.kind === "no_remote_branch", diagnosis: d, pulled: false };
152
+ }
153
+ if ((await gitRemoteUrl(cwd, remote)) === null) {
154
+ const d = await diagnoseIntegrationSync(cwd, remote, integrationBranch);
155
+ return { ok: true, diagnosis: d, pulled: false };
156
+ }
157
+ if (doFetch) {
158
+ try {
159
+ await gitFetchRemote(cwd, remote);
160
+ }
161
+ catch (e) {
162
+ const msg = e instanceof Error ? e.message : String(e);
163
+ log.warn(dim(`git fetch failed (${msg}) — diagnosing with local refs.`));
164
+ }
165
+ }
166
+ const git = await readGitInfo(cwd);
167
+ if (git.branch !== integrationBranch) {
168
+ const switched = await gitSwitchToBranch(cwd, integrationBranch, remote);
169
+ if (!switched) {
170
+ return {
171
+ ok: false,
172
+ diagnosis: {
173
+ ok: false,
174
+ kind: "pull_failed",
175
+ summary: `Could not switch to integration branch "${integrationBranch}".`,
176
+ fixes: [
177
+ "git status",
178
+ `git checkout ${integrationBranch}`,
179
+ `# If checkout fails due to local changes: git stash -u`,
180
+ `git pull --ff-only ${remote} ${integrationBranch}`,
181
+ "vibeops task add",
182
+ ],
183
+ },
184
+ pulled: false,
185
+ };
186
+ }
187
+ }
188
+ let diagnosis = await diagnoseIntegrationSync(cwd, remote, integrationBranch);
189
+ if (!diagnosis.ok) {
190
+ return { ok: false, diagnosis, pulled: false };
191
+ }
192
+ if (diagnosis.kind === "no_remote_branch") {
193
+ return { ok: true, diagnosis, pulled: false };
194
+ }
195
+ const localSha = await gitRevParse(cwd, integrationBranch);
196
+ const remoteSha = await gitRevParse(cwd, remoteRef(remote, integrationBranch));
197
+ if (localSha !== null && remoteSha !== null && localSha === remoteSha) {
198
+ return { ok: true, diagnosis, pulled: false };
199
+ }
200
+ // Stash governance dirt so ff-only pull cannot be blocked by `.vibeops.json` etc.
201
+ const stashed = await stashGovernanceIfBlocking(cwd);
202
+ try {
203
+ await gitPullFastForwardOnly(cwd, remote, integrationBranch);
204
+ diagnosis = {
205
+ ok: true,
206
+ kind: "ok",
207
+ summary: `Pulled latest ${remote}/${integrationBranch} (--ff-only).`,
208
+ fixes: [],
209
+ };
210
+ return { ok: true, diagnosis, pulled: true };
211
+ }
212
+ catch (e) {
213
+ const msg = e instanceof Error ? e.message : String(e);
214
+ diagnosis = await diagnoseIntegrationSync(cwd, remote, integrationBranch);
215
+ if (diagnosis.ok) {
216
+ diagnosis = {
217
+ ok: false,
218
+ kind: "pull_failed",
219
+ summary: `git pull --ff-only failed: ${msg}`,
220
+ fixes: [
221
+ `git checkout ${integrationBranch}`,
222
+ `git pull --ff-only ${remote} ${integrationBranch}`,
223
+ "vibeops task add",
224
+ ],
225
+ };
226
+ }
227
+ return { ok: false, diagnosis, pulled: false };
228
+ }
229
+ finally {
230
+ await restoreGovernanceStashAfterSwitch(cwd, stashed);
231
+ }
232
+ }
package/dist/lib/git.js CHANGED
@@ -299,12 +299,17 @@ const GOVERNANCE_DOC_REL_PREFIXES = [
299
299
  "docs/project/",
300
300
  "docs/logs/",
301
301
  ".vibeops/",
302
+ ".cursor/rules/",
302
303
  ];
304
+ /** Root files owned by vibeops init / config (not under `.vibeops/`). */
305
+ const GOVERNANCE_DOC_EXACT = new Set([".vibeops.json", "AGENTS.md"]);
303
306
  function normalizeRepoRelPath(p) {
304
307
  return p.replace(/\\/g, "/").replace(/^\/+/, "");
305
308
  }
306
309
  export function isGovernanceDocumentationPath(repoRelativePath) {
307
310
  const n = normalizeRepoRelPath(repoRelativePath);
311
+ if (GOVERNANCE_DOC_EXACT.has(n))
312
+ return true;
308
313
  for (const prefix of GOVERNANCE_DOC_REL_PREFIXES) {
309
314
  if (n.startsWith(prefix))
310
315
  return true;
@@ -519,6 +524,31 @@ export async function gitDeleteRemoteBranch(cwd, remote, branch) {
519
524
  export async function gitPullFastForwardOnly(cwd, remote, branch) {
520
525
  await runGit(cwd, ["pull", "--ff-only", remote, branch]);
521
526
  }
527
+ /**
528
+ * Commits reachable only from `left` vs only from `right` (git left-right).
529
+ * Example: `develop...origin/develop` → { left: local-only, right: remote-only }.
530
+ */
531
+ export async function gitLeftRightCount(cwd, leftRef, rightRef) {
532
+ try {
533
+ const { stdout } = await runGit(cwd, [
534
+ "rev-list",
535
+ "--left-right",
536
+ "--count",
537
+ `${leftRef}...${rightRef}`,
538
+ ]);
539
+ const parts = stdout.trim().split(/\s+/);
540
+ if (parts.length < 2)
541
+ return null;
542
+ const left = Number.parseInt(parts[0], 10);
543
+ const right = Number.parseInt(parts[1], 10);
544
+ if (!Number.isFinite(left) || !Number.isFinite(right))
545
+ return null;
546
+ return { left, right };
547
+ }
548
+ catch {
549
+ return null;
550
+ }
551
+ }
522
552
  /** Merge `remote/branch` into the current HEAD (follow-up integration). */
523
553
  export async function gitMergeRemoteBranch(cwd, remote, branch) {
524
554
  await runGit(cwd, ["merge", `${remote}/${branch}`, "--no-edit"]);
@@ -1,11 +1,19 @@
1
1
  import { relative, resolve } from "node:path";
2
2
  import { branchNameForTaskFile, readGitContext, readTaskFile, updateInlineStatus, upsertGitContext, } from "./task.js";
3
- import { gitBranchExists, gitCheckout, gitCheckoutNewBranch, gitGovernanceOnlyDirty, gitHeadCommit, gitPullFastForwardOnly, gitRemoteBranchExists, gitSwitchToBranch, gitRemoteUrl, readGitInfo, } from "./git.js";
3
+ import { gitBranchExists, gitCheckout, gitCheckoutNewBranch, gitGovernanceOnlyDirty, gitHeadCommit, gitSwitchToBranch, readGitInfo, } from "./git.js";
4
+ import { ensureIntegrationSynced, printIntegrationSyncDiagnosis, } from "./git-integration-sync.js";
4
5
  import { cyan, dim, log } from "./logger.js";
5
6
  function relOrAbs(root, p) {
6
7
  const r = relative(root, p);
7
8
  return r === "" ? "." : r.startsWith("..") ? p : r;
8
9
  }
10
+ /** True when In Progress TASK has no task branch yet (failed mid-add). */
11
+ export async function isIncompleteTaskStart(cwd, taskFile) {
12
+ const ctx = await readGitContext(taskFile);
13
+ if (ctx === null)
14
+ return true;
15
+ return !(await gitBranchExists(cwd, ctx.taskBranch));
16
+ }
9
17
  /** Create or resume task branch from integration branch and mark TASK In Progress. */
10
18
  export async function startTaskBranch(opts) {
11
19
  const cwd = resolve(opts.cwd);
@@ -49,20 +57,21 @@ export async function startTaskBranch(opts) {
49
57
  }
50
58
  }
51
59
  // Ensure integration branch is up-to-date before creating a new task branch.
52
- if (!branchExists) {
53
- const remoteUrl = await gitRemoteUrl(cwd, remote);
54
- if (remoteUrl) {
55
- const hasRemoteBranch = await gitRemoteBranchExists(cwd, remote, integrationBranch);
56
- if (hasRemoteBranch) {
57
- try {
58
- await gitPullFastForwardOnly(cwd, remote, integrationBranch);
59
- log.info(dim(`Pulled latest ${remote}/${integrationBranch} (--ff-only).`));
60
- }
61
- catch {
62
- log.warn(`Could not fast-forward pull ${remote}/${integrationBranch}. Resolve manually, then rerun task add.`);
63
- return false;
64
- }
65
- }
60
+ if (!branchExists && opts.skipIntegrationPull !== true) {
61
+ const synced = await ensureIntegrationSynced({
62
+ cwd,
63
+ remote,
64
+ integrationBranch,
65
+ fetch: true,
66
+ });
67
+ if (!synced.ok) {
68
+ printIntegrationSyncDiagnosis(synced.diagnosis);
69
+ log.blank();
70
+ log.info(dim(`TASK file is already created — after fixing sync, rerun ${cyan("vibeops task add")} to resume the branch (does not create a second TASK).`));
71
+ return false;
72
+ }
73
+ if (synced.pulled) {
74
+ log.info(dim(synced.diagnosis.summary));
66
75
  }
67
76
  }
68
77
  const baseBranch = integrationBranch;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodtek/vibeops",
3
- "version": "2.5.0",
3
+ "version": "2.5.2",
4
4
  "description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.15.9",
@@ -39,7 +39,9 @@
39
39
  "smoke": "pnpm typecheck && pnpm build && node --test \"tests/*.test.mjs\" && node scripts/smoke.mjs",
40
40
  "clean": "rm -rf dist",
41
41
  "prepack": "pnpm clean && pnpm build",
42
- "publish:dry": "pnpm publish --dry-run --no-git-checks"
42
+ "publish:dry": "pnpm publish --dry-run --no-git-checks",
43
+ "publish:npm": "scripts/npm-publish.sh",
44
+ "publish:npm:dry": "scripts/npm-publish.sh --dry-run"
43
45
  },
44
46
  "keywords": [
45
47
  "cursor",