@goodtek/vibeops 2.2.1 → 2.3.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 CHANGED
@@ -4,6 +4,16 @@ All notable changes to VibeOps are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 2.3.0 - 2026-06-09
8
+
9
+ ### Added
10
+
11
+ - **`vibeops task del [TASK-NNN]`** — cancel a TASK before merge: delete `docs/tasks/*.md`, close open MR/PR (unless `--no-close-mr`), delete local/remote `task/*` branch. Refuses when the MR is **merged** into integration.
12
+
13
+ ### Flags
14
+
15
+ - `--dry-run`, `--force` (dirty working tree), `--no-remote-delete`, `--no-close-mr`, `--cwd`
16
+
7
17
  ## 2.2.1 - 2026-06-09
8
18
 
9
19
  ### Fixed
package/README.md CHANGED
@@ -13,6 +13,7 @@ VibeOps bootstraps an **agent-friendly repo**, starts numbered **TASK** files on
13
13
  |---------|---------|
14
14
  | `vibeops init` | Core docs + agent packs (cursor / claude / codex) |
15
15
  | `vibeops task add` | New `TASK-NNN` file + task branch |
16
+ | `vibeops task del` | Cancel TASK before merge (md + branch + close open MR) |
16
17
  | `vibeops task ship` | LLM summary, commit, push, open MR/PR (Status → **Shipped**) |
17
18
  | `vibeops task reship` | Shipped follow-up: move uncommitted work to task branch, commit, new MR/PR (Status stays Shipped) |
18
19
  | `vibeops task merge` | Merge TASK MR/PR into integration branch (default: squash) |
@@ -97,6 +98,7 @@ pnpm install && pnpm build && pnpm smoke
97
98
 
98
99
  - **`init`**: `--clients`, `--yes`, `--dry-run`, `--force`, `--git`, `--initial-commit`, `--git-policy gitflow|trunk`, `--integration-branch`, `--production-branch`, `--allow-no-remote`, `--cwd`
99
100
  - **`task add`**: `--dry-run`, `--non-interactive --idea "…"`
101
+ - **`task del`**: `--dry-run`, `--force`, `--no-remote-delete`, `--no-close-mr`
100
102
  - **`task ship`**: `--dry-run`, `--no-pr`
101
103
  - **`task reship`**: `--dry-run`, `--no-pr`, `--no-integrate`, `--recreate-branch`, `--skip-llm`, `--allow-open-mr`
102
104
  - **`task merge`**: `--dry-run`, `--merge`, `--rebase`
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import { initCommand } from "./commands/init.js";
5
5
  import { llmConnectCommand, llmStatusCommand, llmUseCommand } from "./commands/llm.js";
6
6
  import { statusCommand } from "./commands/status.js";
7
7
  import { taskAddCommand } from "./commands/task-add.js";
8
+ import { taskDelCommand } from "./commands/task-del.js";
8
9
  import { taskMergeCommand } from "./commands/task-merge.js";
9
10
  import { taskReleaseCommand } from "./commands/task-release.js";
10
11
  import { taskShipCommand } from "./commands/task-ship.js";
@@ -58,7 +59,9 @@ program
58
59
  gitHost: opts.gitHost,
59
60
  });
60
61
  });
61
- const task = program.command("task").description("TASK lifecycle (add · ship · reship · merge · sync · release)");
62
+ const task = program
63
+ .command("task")
64
+ .description("TASK lifecycle (add · del · ship · reship · merge · sync · release)");
62
65
  task
63
66
  .command("add")
64
67
  .description("Create next TASK-NNN, task branch, In Progress")
@@ -74,6 +77,23 @@ task
74
77
  cwd: opts.cwd,
75
78
  });
76
79
  });
80
+ task
81
+ .command("del [taskRef]")
82
+ .description("Delete TASK before merge: remove md, close open MR/PR, delete task branch")
83
+ .option("--dry-run", "Plan only")
84
+ .option("--force", "Proceed with a dirty working tree (changes may remain)")
85
+ .option("--no-remote-delete", "Keep the task branch on the remote")
86
+ .option("--no-close-mr", "Do not close an open MR/PR")
87
+ .option("--cwd <path>", "Target directory")
88
+ .action(async (taskRef, opts) => {
89
+ await taskDelCommand(taskRef, {
90
+ dryRun: Boolean(opts.dryRun),
91
+ force: Boolean(opts.force),
92
+ noRemoteDelete: Boolean(opts.noRemoteDelete),
93
+ noCloseMr: Boolean(opts.noCloseMr),
94
+ cwd: opts.cwd,
95
+ });
96
+ });
77
97
  task
78
98
  .command("ship [taskRef]")
79
99
  .description("Submit TASK: LLM summary, commit, push, open MR/PR (Status → Shipped)")
@@ -0,0 +1,104 @@
1
+ import { resolve } from "node:path";
2
+ import { bold, cyan, dim, log } from "../lib/logger.js";
3
+ import { projectPaths } from "../lib/paths.js";
4
+ import { relPath } from "../lib/task-context.js";
5
+ import { assertTaskDeletable, assertWorkingTreeForDel, closeOpenTaskMergeRequestIfNeeded, deleteTaskBranches, deleteTaskMarkdown, loadGitConfigForDel, resolveDelTarget, taskDelNotFoundMessage, validateTaskBranchForDel, } from "../lib/task-del.js";
6
+ import { readGitContext, statusDisplay } from "../lib/task.js";
7
+ export async function taskDelCommand(taskRef, options = {}) {
8
+ const cwd = resolve(options.cwd ?? process.cwd());
9
+ const dryRun = options.dryRun === true;
10
+ const paths = projectPaths(cwd);
11
+ const target = await resolveDelTarget(paths, cwd, taskRef);
12
+ if (target === null) {
13
+ log.error(taskDelNotFoundMessage(taskRef));
14
+ process.exitCode = 1;
15
+ return;
16
+ }
17
+ const { taskId, taskFile, meta, taskBranch } = target;
18
+ const relFile = relPath(cwd, taskFile);
19
+ log.info(bold(`vibeops task del ${taskId}`));
20
+ log.info(` ${dim("file")} ${relFile}`);
21
+ log.info(` ${dim("branch")} ${taskBranch}`);
22
+ log.info(` ${dim("status")} ${statusDisplay(meta.status)}`);
23
+ log.blank();
24
+ const deletable = await assertTaskDeletable(cwd, target);
25
+ if (!deletable.ok) {
26
+ log.error(deletable.reason);
27
+ process.exitCode = 1;
28
+ return;
29
+ }
30
+ const gitCfg = dryRun
31
+ ? await loadGitConfigForDel(cwd).catch(() => null)
32
+ : await loadGitConfigForDel(cwd);
33
+ if (!dryRun && gitCfg === null) {
34
+ process.exitCode = 1;
35
+ return;
36
+ }
37
+ const integrationBranch = gitCfg?.integrationBranch ?? "develop";
38
+ const productionBranch = gitCfg?.productionBranch ?? "main";
39
+ const remote = gitCfg?.remote ?? "origin";
40
+ if (!validateTaskBranchForDel(taskBranch, integrationBranch, productionBranch)) {
41
+ process.exitCode = 1;
42
+ return;
43
+ }
44
+ if (dryRun) {
45
+ log.info(bold("dry-run — would:"));
46
+ const ctx = await readGitContext(taskFile);
47
+ if (ctx !== null) {
48
+ await closeOpenTaskMergeRequestIfNeeded({
49
+ cwd,
50
+ taskFile,
51
+ taskBranch,
52
+ baseBranch: ctx.baseBranch,
53
+ dryRun: true,
54
+ skipClose: options.noCloseMr === true,
55
+ });
56
+ }
57
+ log.info(` · git switch ${integrationBranch} (if on ${taskBranch})`);
58
+ await deleteTaskBranches({
59
+ cwd,
60
+ taskBranch,
61
+ integrationBranch,
62
+ remote,
63
+ dryRun: true,
64
+ noRemoteDelete: options.noRemoteDelete === true,
65
+ });
66
+ await deleteTaskMarkdown(taskFile, true);
67
+ log.blank();
68
+ log.info(`Next: ${cyan("vibeops task add")}`);
69
+ return;
70
+ }
71
+ if (!(await assertWorkingTreeForDel(cwd, options.force === true))) {
72
+ process.exitCode = 1;
73
+ return;
74
+ }
75
+ const ctx = await readGitContext(taskFile);
76
+ if (ctx === null) {
77
+ log.error("Git Context missing.");
78
+ process.exitCode = 1;
79
+ return;
80
+ }
81
+ await closeOpenTaskMergeRequestIfNeeded({
82
+ cwd,
83
+ taskFile,
84
+ taskBranch,
85
+ baseBranch: ctx.baseBranch,
86
+ dryRun: false,
87
+ skipClose: options.noCloseMr === true,
88
+ });
89
+ const branchesOk = await deleteTaskBranches({
90
+ cwd,
91
+ taskBranch,
92
+ integrationBranch,
93
+ remote,
94
+ dryRun: false,
95
+ noRemoteDelete: options.noRemoteDelete === true,
96
+ });
97
+ if (!branchesOk) {
98
+ process.exitCode = 1;
99
+ return;
100
+ }
101
+ await deleteTaskMarkdown(taskFile, false);
102
+ log.blank();
103
+ log.info(`Next: ${cyan("vibeops task add")}`);
104
+ }
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { dirname, relative } from "node:path";
3
3
  export async function pathExists(path) {
4
4
  try {
@@ -45,6 +45,9 @@ export async function writeText(path, contents) {
45
45
  await ensureDir(dirname(path));
46
46
  await writeFile(path, contents, "utf-8");
47
47
  }
48
+ export async function deleteFile(path) {
49
+ await rm(path, { force: true });
50
+ }
48
51
  export async function walk(root) {
49
52
  const out = [];
50
53
  async function visit(dir) {
@@ -197,6 +197,25 @@ export async function getMergeRequestState(cwd, host, url) {
197
197
  return "unknown";
198
198
  }
199
199
  }
200
+ export async function closeMergeRequest(opts) {
201
+ const ref = prRefFromUrl(opts.url);
202
+ const label = mergeRequestLabel(opts.host);
203
+ if (opts.dryRun === true) {
204
+ log.info(`would close ${label} ${ref}`);
205
+ return;
206
+ }
207
+ if (opts.host === "gitlab") {
208
+ await execFileAsync("glab", ["mr", "close", ref], {
209
+ cwd: opts.cwd,
210
+ maxBuffer: 4 * 1024 * 1024,
211
+ });
212
+ return;
213
+ }
214
+ await execFileAsync("gh", ["pr", "close", ref], {
215
+ cwd: opts.cwd,
216
+ maxBuffer: 4 * 1024 * 1024,
217
+ });
218
+ }
200
219
  export async function mergeMergeRequest(opts) {
201
220
  const ref = prRefFromUrl(opts.url);
202
221
  const method = opts.method ?? "squash";
@@ -0,0 +1,193 @@
1
+ import { resolve } from "node:path";
2
+ import { deleteFile } from "./filesystem.js";
3
+ import { GitConfigError, requireGitConfig } from "./git-config.js";
4
+ import { mergeRequestLabel } from "./git-host.js";
5
+ import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitGovernanceOnlyDirty, gitRemoteBranchExists, gitSwitchToBranch, readGitInfo, } from "./git.js";
6
+ import { dim, log } from "./logger.js";
7
+ import { closeMergeRequest, findMergeRequestByBranches, probeMergeRequestCli, } from "./pr-create.js";
8
+ import { normalizeTaskRef } from "./resolve-task.js";
9
+ import { getTaskMergeRequestLifecycle, resolveGitHostForCwd } from "./task-effective-status.js";
10
+ import { findTaskFile, loadActionableTasks, pickInProgressTask, readGitContext, readTaskFile, } from "./task.js";
11
+ export async function resolveDelTarget(paths, cwd, taskRef) {
12
+ const trimmed = taskRef?.trim();
13
+ let taskId;
14
+ let taskFile;
15
+ if (trimmed !== undefined && trimmed.length > 0) {
16
+ taskId = normalizeTaskRef(trimmed);
17
+ taskFile = await findTaskFile(paths.docsTasks, taskId);
18
+ }
19
+ else {
20
+ const inProgress = pickInProgressTask(await loadActionableTasks(paths.docsTasks));
21
+ if (inProgress === null) {
22
+ return null;
23
+ }
24
+ taskId = inProgress.id;
25
+ taskFile = inProgress.filePath;
26
+ }
27
+ if (taskFile === null) {
28
+ return null;
29
+ }
30
+ const meta = await readTaskFile(taskFile);
31
+ const ctx = await readGitContext(taskFile);
32
+ if (ctx === null) {
33
+ log.error(`No Git Context on ${taskId}. Cannot delete.`);
34
+ return null;
35
+ }
36
+ return { taskId, taskFile, meta, taskBranch: ctx.taskBranch };
37
+ }
38
+ export function taskDelNotFoundMessage(taskRef) {
39
+ if (taskRef?.trim()) {
40
+ return `TASK not found: ${normalizeTaskRef(taskRef)}. Check docs/tasks/.`;
41
+ }
42
+ return "No In Progress TASK found. Pass `vibeops task del TASK-NNN`.";
43
+ }
44
+ export async function assertTaskDeletable(cwd, target) {
45
+ const lifecycle = await getTaskMergeRequestLifecycle(cwd, target.taskFile);
46
+ if (lifecycle === "merged") {
47
+ return {
48
+ ok: false,
49
+ reason: `${target.taskId} is merged into integration — task del is not allowed. ` +
50
+ "Revert on develop if needed, or keep the TASK file as history.",
51
+ };
52
+ }
53
+ return { ok: true };
54
+ }
55
+ export async function assertWorkingTreeForDel(cwd, allowDirty) {
56
+ const git = await readGitInfo(cwd);
57
+ if (git.dirty !== true)
58
+ return true;
59
+ const gov = await gitGovernanceOnlyDirty(cwd);
60
+ if (gov.onlyGovernance)
61
+ return true;
62
+ if (allowDirty) {
63
+ log.warn("Uncommitted changes remain in the working tree after delete.");
64
+ return true;
65
+ }
66
+ log.error("Working tree is dirty. Commit, stash, restore, or rerun with --force to discard uncommitted work.");
67
+ return false;
68
+ }
69
+ function isProtectedBranch(name, integration, production) {
70
+ return name === integration || name === production || name === "main" || name === "master";
71
+ }
72
+ export async function deleteTaskBranches(opts) {
73
+ const cwd = resolve(opts.cwd);
74
+ if (opts.dryRun) {
75
+ if (await gitBranchExists(cwd, opts.taskBranch)) {
76
+ log.info(dim(` would git branch -D ${opts.taskBranch}`));
77
+ }
78
+ if (opts.noRemoteDelete !== true &&
79
+ (await gitRemoteBranchExists(cwd, opts.remote, opts.taskBranch))) {
80
+ log.info(dim(` would git push ${opts.remote} --delete ${opts.taskBranch}`));
81
+ }
82
+ return true;
83
+ }
84
+ const git = await readGitInfo(cwd);
85
+ if (git.branch === opts.taskBranch) {
86
+ const switched = await gitSwitchToBranch(cwd, opts.integrationBranch, opts.remote);
87
+ if (!switched) {
88
+ log.error(`Integration branch "${opts.integrationBranch}" not found.`);
89
+ return false;
90
+ }
91
+ log.ok(`On ${opts.integrationBranch}`);
92
+ }
93
+ if (await gitBranchExists(cwd, opts.taskBranch)) {
94
+ try {
95
+ await gitDeleteBranch(cwd, opts.taskBranch, { force: true });
96
+ log.ok(`Deleted local branch ${opts.taskBranch}`);
97
+ }
98
+ catch (e) {
99
+ const msg = e instanceof Error ? e.message : String(e);
100
+ log.error(`Could not delete local ${opts.taskBranch}: ${msg}`);
101
+ return false;
102
+ }
103
+ }
104
+ else {
105
+ log.info(dim(`Local branch ${opts.taskBranch} not found — skipped.`));
106
+ }
107
+ if (opts.noRemoteDelete !== true &&
108
+ (await gitRemoteBranchExists(cwd, opts.remote, opts.taskBranch))) {
109
+ try {
110
+ await gitDeleteRemoteBranch(cwd, opts.remote, opts.taskBranch);
111
+ log.ok(`Deleted ${opts.remote}/${opts.taskBranch}`);
112
+ }
113
+ catch (e) {
114
+ const msg = e instanceof Error ? e.message : String(e);
115
+ log.warn(`Could not delete remote ${opts.taskBranch}: ${msg}`);
116
+ }
117
+ }
118
+ else if (opts.noRemoteDelete === true) {
119
+ log.info(dim("Remote branch delete skipped (--no-remote-delete)."));
120
+ }
121
+ return true;
122
+ }
123
+ export async function closeOpenTaskMergeRequestIfNeeded(opts) {
124
+ if (opts.skipClose) {
125
+ log.info(dim("Open MR/PR close skipped (--no-close-mr)."));
126
+ return true;
127
+ }
128
+ const lifecycle = await getTaskMergeRequestLifecycle(opts.cwd, opts.taskFile);
129
+ if (lifecycle !== "open")
130
+ return true;
131
+ const host = await resolveGitHostForCwd(opts.cwd);
132
+ if (host === null) {
133
+ log.warn("Could not resolve Git host — close the MR/PR manually.");
134
+ return true;
135
+ }
136
+ const open = await findMergeRequestByBranches({
137
+ cwd: opts.cwd,
138
+ host,
139
+ headBranch: opts.taskBranch,
140
+ baseBranch: opts.baseBranch,
141
+ state: "open",
142
+ });
143
+ if (open === null)
144
+ return true;
145
+ const label = mergeRequestLabel(host);
146
+ if (!(await probeMergeRequestCli(host))) {
147
+ log.warn(`Open ${label}: ${open.url} — close it manually before deleting the branch.`);
148
+ return true;
149
+ }
150
+ try {
151
+ await closeMergeRequest({ cwd: opts.cwd, host, url: open.url, dryRun: opts.dryRun });
152
+ if (!opts.dryRun)
153
+ log.ok(`Closed ${label}`);
154
+ }
155
+ catch (e) {
156
+ const msg = e instanceof Error ? e.message : String(e);
157
+ log.warn(`Could not close ${label}: ${msg}`);
158
+ log.info(dim(`Close manually: ${open.url}`));
159
+ }
160
+ return true;
161
+ }
162
+ export async function deleteTaskMarkdown(taskFile, dryRun) {
163
+ if (dryRun) {
164
+ log.info(dim(` would delete ${taskFile}`));
165
+ return;
166
+ }
167
+ await deleteFile(taskFile);
168
+ log.ok(`Deleted TASK file`);
169
+ }
170
+ export async function loadGitConfigForDel(cwd) {
171
+ try {
172
+ const cfg = await requireGitConfig(cwd);
173
+ return {
174
+ remote: cfg.remote,
175
+ integrationBranch: cfg.integrationBranch,
176
+ productionBranch: cfg.productionBranch,
177
+ };
178
+ }
179
+ catch (e) {
180
+ if (e instanceof GitConfigError) {
181
+ log.error(e.message);
182
+ return null;
183
+ }
184
+ throw e;
185
+ }
186
+ }
187
+ export function validateTaskBranchForDel(taskBranch, integrationBranch, productionBranch) {
188
+ if (isProtectedBranch(taskBranch, integrationBranch, productionBranch)) {
189
+ log.error(`Refusing to delete protected branch: ${taskBranch}`);
190
+ return false;
191
+ }
192
+ return true;
193
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodtek/vibeops",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.15.9",