@goodtek/vibeops 0.3.0 → 0.7.22

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/dist/cli.js CHANGED
@@ -1,265 +1,207 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import { agentListCommand } from "./commands/agent-list.js";
4
- import { agentPromptCommand } from "./commands/agent-prompt.js";
5
- import { agentShowCommand } from "./commands/agent-show.js";
3
+ import { doneCommand } from "./commands/done.js";
6
4
  import { githubInitCommand } from "./commands/github-init.js";
7
5
  import { githubStatusCommand } from "./commands/github-status.js";
8
6
  import { initCommand } from "./commands/init.js";
7
+ import { nextCommand } from "./commands/next.js";
9
8
  import { notionInitCommand } from "./commands/notion-init.js";
10
9
  import { notionSyncCommand } from "./commands/notion-sync.js";
11
10
  import { notionTestCommand } from "./commands/notion-test.js";
12
11
  import { planCommand } from "./commands/plan.js";
12
+ import { rollbackCommand } from "./commands/rollback.js";
13
+ import { startCommand } from "./commands/start.js";
13
14
  import { statusCommand } from "./commands/status.js";
14
- import { taskCheckCommand } from "./commands/task-check.js";
15
- import { taskDoneCommand } from "./commands/task-done.js";
16
- import { taskGenerateCommand } from "./commands/task-generate.js";
17
- import { taskPullCommand } from "./commands/task-pull.js";
18
- import { taskRollbackCommand } from "./commands/task-rollback.js";
19
- import { taskStartCommand } from "./commands/task-start.js";
15
+ import { taskAddCommand } from "./commands/task-add.js";
16
+ import { taskStatusCommand } from "./commands/task-status.js";
20
17
  import { VERSION } from "./version.js";
21
18
  const program = new Command();
22
19
  program
23
20
  .name("vibeops")
24
- .description("VibeOps — a local CLI that keeps Cursor-based vibe coding on rails.\n" +
25
- " It installs docs, Cursor rules, AGENTS.md, agents, TASK templates,\n" +
26
- " and Git/Notion workflows into a project, then drives work one TASK at a time.")
21
+ .description("VibeOps — plan an MVP, build in Cursor (drag mvp-build.md), finish with Git rails.\n" +
22
+ " Commands: init · plan · start · done · next · status · task add · task status (+ optional notion · github · rollback)")
27
23
  .version(VERSION, "-v, --version", "Print the VibeOps version");
28
24
  program
29
25
  .command("init")
30
- .description("Install the VibeOps workflow files into the current directory")
31
- .option("--dry-run", "Show what would be created without writing any files")
32
- .option("--force", "Overwrite existing files (use with care)")
33
- .option("--cwd <path>", "Run against a different directory")
34
- .option("--name <projectName>", "Project name written into .vibeops.json")
35
- .option("--git", "Initialize a Git repository without prompting")
36
- .option("--no-git", "Skip Git initialization and commits")
37
- .option("--initial-commit", "Run `git add .` and create an initial commit")
38
- .option("--no-initial-commit", "Do not create an initial commit")
39
- .option("--default-branch <name>", "Default Git branch name (default `main`)")
40
- .option("--commit-message <message>", "Initial commit message (default 'chore: initialize vibeops project')")
41
- .action(async (options) => {
42
- await initCommand(options);
43
- });
44
- program
45
- .command("status")
46
- .description("Show VibeOps installation, TASK counts, and integration state")
47
- .option("--json", "Print machine-readable JSON")
48
- .option("--cwd <path>", "Inspect a different directory")
49
- .action(async (options) => {
50
- await statusCommand(options);
26
+ .description("Install the VibeOps workflow into the current directory")
27
+ .option("--dry-run", "Show what would be created without writing files")
28
+ .option("--force", "Overwrite existing files")
29
+ .option("--cwd <path>", "Target directory")
30
+ .option("--name <projectName>", "Project name in .vibeops.json")
31
+ .option("--git", "Initialize Git without prompting")
32
+ .option("--no-git", "Skip Git")
33
+ .option("--initial-commit", "Create initial commit")
34
+ .option("--no-initial-commit", "Skip initial commit")
35
+ .option("--default-branch <name>", "Default branch (default main)")
36
+ .option("--commit-message <message>", "Initial commit message")
37
+ .action(async (opts) => {
38
+ await initCommand(opts);
51
39
  });
52
40
  program
53
41
  .command("plan")
54
- .description("Requires `vibeops init`: LLM planning → ProjectBrief + planning prompt; optional --apply-planner runs the planner via the same LLM (docs + TASKs, commit, push, Notion). Use --legacy-wizard for the fixed 20-question flow")
55
- .option("--idea <text>", "One-line idea default (use `Name: idea` to extract the project name)")
56
- .option("--from <path>", "Read an existing brief markdown and regenerate the prompt")
57
- .option("--output <path>", "Output path for the Cursor planning prompt (default `.vibeops/generated/plan-prompt.md`)")
58
- .option("--non-interactive", "Skip prompts and use the supplied values plus safe placeholders (no LLM)")
59
- .option("--legacy-wizard", "Use the original 20-question wizard instead of an LLM planning session")
60
- .option("--provider <id>", "LLM provider when several are available: openai | codex-oauth | cursor-agent")
61
- .option("--model <id>", "LLM model id for the selected provider (skips interactive model list; same as setting VIBEOPS_*_MODEL for that path)")
62
- .option("--cwd <path>", "Run against a different directory")
63
- .option("--apply-planner", "After writing the brief + prompt: call the same LLM provider to emit docs/project + docs/tasks; then (TTY) ask commit → push → Notion in order, or (--non-interactive) commit unless --no-git-commit, push only with --push, Notion unless --no-notion-sync")
64
- .option("--apply-dry-run", "With --apply-planner (or alone): call the LLM and list files that would be written; no writes, git, or Notion")
65
- .option("--no-git-commit", "With --apply-planner: skip git commit after writes")
66
- .option("--push", "With --apply-planner: run git push after commit (TTY: optional `vibeops github init` before push; skips the push Yes/No prompt)")
67
- .option("--no-notion-sync", "With --apply-planner: skip Notion sync even when the brief enables it")
68
- .action(async (options) => {
69
- const provider = options.provider === "openai" ||
70
- options.provider === "codex-oauth" ||
71
- options.provider === "cursor-agent"
72
- ? options.provider
42
+ .description("LLM planning → brief + TASK-mvp + .vibeops/generated/mvp-build.md")
43
+ .option("--idea <text>", "One-line idea (optional Name: idea prefix)")
44
+ .option("--from <path>", "Regenerate MVP artifacts from an existing brief")
45
+ .option("--non-interactive", "Placeholders only (CI); no LLM")
46
+ .option("--provider <id>", "openai | codex-oauth | cursor-agent")
47
+ .option("--model <id>", "LLM model id")
48
+ .option("--cwd <path>", "Target directory")
49
+ .action(async (opts) => {
50
+ const provider = opts.provider === "openai" ||
51
+ opts.provider === "codex-oauth" ||
52
+ opts.provider === "cursor-agent"
53
+ ? opts.provider
73
54
  : undefined;
74
55
  await planCommand({
75
- idea: options.idea,
76
- from: options.from,
77
- output: options.output,
78
- nonInteractive: options.nonInteractive,
79
- legacyWizard: options.legacyWizard === true,
56
+ idea: opts.idea,
57
+ from: opts.from,
58
+ nonInteractive: opts.nonInteractive,
80
59
  provider,
81
- llmModel: typeof options.model === "string" ? options.model : undefined,
82
- cwd: options.cwd,
83
- applyPlanner: options.applyPlanner === true,
84
- applyPlannerDryRun: options.applyDryRun === true,
85
- noGitCommit: options.noGitCommit === true,
86
- gitPush: options.push === true,
87
- noNotionSync: options.noNotionSync === true,
60
+ llmModel: opts.model,
61
+ cwd: opts.cwd,
88
62
  });
89
63
  });
90
- const agent = program
91
- .command("agent")
92
- .description("Inspect the `.vibeops/agents/*` agent definitions");
93
- agent
94
- .command("list")
95
- .description("List available agents")
96
- .option("--json", "Print machine-readable JSON")
97
- .option("--cwd <path>", "Inspect a different directory")
98
- .action(async (options) => {
99
- await agentListCommand(options);
100
- });
101
- agent
102
- .command("show <name>")
103
- .description("Print an agent definition body")
104
- .option("--raw", "Include frontmatter in the output")
105
- .option("--cwd <path>", "Inspect a different directory")
106
- .action(async (name, options) => {
107
- await agentShowCommand(name, options);
64
+ program
65
+ .command("start [taskRef]")
66
+ .description("Create task branch (default: TASK-mvp, or active backlog TASK if no MVP file)")
67
+ .option("--dry-run", "Show plan only")
68
+ .option("--allow-dirty", "Allow dirty working tree")
69
+ .option("--cwd <path>", "Target directory")
70
+ .action(async (taskRef, opts) => {
71
+ await startCommand(taskRef, opts);
108
72
  });
109
- agent
110
- .command("prompt <name> <taskId>")
111
- .description("Print a Cursor-ready prompt built from the agent + TASK context")
112
- .option("--context <path...>", "Additional context file paths")
113
- .option("--cwd <path>", "Inspect a different directory")
114
- .action(async (name, taskId, options) => {
115
- await agentPromptCommand(name, taskId, options);
73
+ program
74
+ .command("done [taskRef]")
75
+ .description("Finish TASK: auto-fill Result/Test Result + summary md, merge to main, Notion sync (default: TASK-mvp or active backlog)")
76
+ .option("--dry-run", "Show plan only")
77
+ .option("--no-merge", "Skip push / merge / branch delete")
78
+ .option("--skip-summary", "Skip last-done-summary.md and TASK auto-fill")
79
+ .option("--refresh-task-sections", "Overwrite existing Result / Test Result")
80
+ .option("--merge-via-pr", "Merge via gh PR")
81
+ .option("--allow-dirty", "Allow dirty tree during merge")
82
+ .option("--no-notion-sync", "Skip automatic Notion sync after done")
83
+ .option("--cwd <path>", "Target directory")
84
+ .action(async (taskRef, opts) => {
85
+ await doneCommand(taskRef, opts);
116
86
  });
117
- const task = program
118
- .command("task")
119
- .description("TASK lifecycle commands");
120
- task
121
- .command("generate")
122
- .description("Build a Cursor prompt for generating TASK files, or with --scaffold write skeleton TASK markdown")
123
- .option("--from <path>", "Primary backlog/brief markdown to feed into the prompt")
124
- .option("--output <path>", "Output path for the generated prompt (default `.vibeops/generated/task-generate-prompt.md`)")
125
- .option("--count <number>", "Suggested TASK count for Cursor (default 8, warns above 20)")
126
- .option("--phase <name>", "Generate TASKs for a specific phase label only (e.g. 'MVP 4')")
127
- .option("--scaffold", "Write skeleton TASK markdown files directly, without an LLM")
128
- .option("--dry-run", "Print the plan without writing or modifying files")
129
- .option("--cwd <path>", "Run against a different directory")
130
- .action(async (options) => {
131
- await taskGenerateCommand(options);
87
+ program
88
+ .command("next")
89
+ .description("Interactive guide for the MVP workflow (↑/↓ · Yes/No)")
90
+ .option("--dry-run", "On runnable steps, print only")
91
+ .option("--non-interactive", "Print panel once")
92
+ .option("--execute", "With --non-interactive, run the runnable step once")
93
+ .option("--merge-via-pr", "On finish: merge via GitHub PR")
94
+ .option("--allow-dirty", "Allow dirty tree on merge")
95
+ .option("--cwd <path>", "Target directory")
96
+ .action(async (opts) => {
97
+ await nextCommand(opts);
132
98
  });
133
- task
134
- .command("start <taskId>")
135
- .description("Confirm a clean working tree (or docs-only governance changes), create the task branch, record Status/Git context, and print a Builder prompt")
136
- .option("--dry-run", "Print the plan without touching files or Git")
137
- .option("--allow-dirty", "Proceed even if the Git working tree is dirty (including non-doc changes)")
138
- .option("--agent <name>", "Agent to build the prompt with (default `builder`)")
139
- .option("--cwd <path>", "Run against a different directory")
140
- .action(async (taskId, options) => {
141
- await taskStartCommand(taskId, options);
99
+ program
100
+ .command("status")
101
+ .description("Installation, TASK-mvp state, Git, Notion, GitHub")
102
+ .option("--json", "JSON output")
103
+ .option("--cwd <path>", "Target directory")
104
+ .action(async (opts) => {
105
+ await statusCommand(opts);
142
106
  });
107
+ const task = program.command("task").description("Backlog TASK files");
143
108
  task
144
- .command("prompt <taskId>")
145
- .description("Print a Cursor-ready prompt built from the TASK + agent context")
146
- .option("--agent <name>", "Agent name (orchestrator / planner / architect / builder / reviewer / tester / docs / recovery)")
147
- .option("--context <path...>", "Additional context file paths")
148
- .option("--cwd <path>", "Inspect a different directory")
149
- .action(async (taskId, options) => {
150
- await agentPromptCommand(options.agent ?? "builder", taskId, {
151
- cwd: options.cwd,
152
- context: options.context,
109
+ .command("add")
110
+ .description("Interactive: close open TASK (optional), create next TASK-NNN, branch checkout, In Progress")
111
+ .option("--dry-run", "Show plan only (no writes, no LLM planning session)")
112
+ .option("--non-interactive", "CI: minimal scaffold without prompts (optional --idea)")
113
+ .option("--idea <text>", "Short description (CI / smoke only)")
114
+ .option("--cwd <path>", "Target directory")
115
+ .action(async (opts) => {
116
+ await taskAddCommand({
117
+ dryRun: Boolean(opts.dryRun),
118
+ nonInteractive: Boolean(opts.nonInteractive),
119
+ idea: opts.idea,
120
+ cwd: opts.cwd,
153
121
  });
154
122
  });
155
123
  task
156
- .command("check <taskId>")
157
- .description("Read-only check: git diff/log + acceptance criteria + doc updates + Result fields + a Reviewer prompt")
158
- .option("--strict", "Exit with code 1 if any required item is missing")
159
- .option("--agent <name>", "Agent to build the prompt with (default `reviewer`)")
160
- .option("--cwd <path>", "Inspect a different directory")
161
- .action(async (taskId, options) => {
162
- await taskCheckCommand(taskId, options);
163
- });
164
- task
165
- .command("done <taskId>")
166
- .description("Validate Result/Test Result, move Status to Review, and print a commit message (no auto-commit)")
167
- .option("--dry-run", "Print the plan without touching files")
168
- .option("--finalize", "Move Status to Done instead of Review (use after human review)")
169
- .option("--cwd <path>", "Run against a different directory")
170
- .action(async (taskId, options) => {
171
- await taskDoneCommand(taskId, options);
172
- });
173
- task
174
- .command("rollback <taskId>")
175
- .description("Default: advisory only. --confirm: non-destructive rollback. --confirm-destructive: hard reset.")
176
- .option("--confirm", "Allow non-destructive rollback execution (branch-delete etc.)")
177
- .option("--confirm-destructive", "Allow destructive rollback execution (reset --hard etc.)")
178
- .option("--strategy <name>", "branch-delete | reset-base | revert-merge (default branch-delete)")
179
- .option("--keep-branch", "Keep the task branch even when using branch-delete")
180
- .option("--dry-run", "Print the plan without running any git commands, even with --confirm")
181
- .option("--cwd <path>", "Run against a different directory")
182
- .action(async (taskId, options) => {
183
- await taskRollbackCommand(taskId, options);
184
- });
185
- task
186
- .command("pull")
187
- .description("Generate `docs/tasks/*.md` skeletons from Notion Tasks DB rows (defaults to Status = Planned)")
188
- .option("--dry-run", "Print the plan without touching files or Notion")
189
- .option("--json", "Print machine-readable JSON")
190
- .option("--status <name>", "Notion Status values to pull (comma-separated, e.g. 'Planned,Ready'). Default `Planned`")
191
- .option("--limit <number>", "Maximum rows to pull from Notion (default 20, max 100)")
192
- .option("--cwd <path>", "Run against a different directory")
193
- .option("--verbose", "Print a decision trace per considered row (taskId / pageId / docsPath / reason)")
194
- .action(async (options) => {
195
- await taskPullCommand(options);
124
+ .command("status")
125
+ .description("Briefing on current TASK progress and recommended next step")
126
+ .option("--json", "JSON output")
127
+ .option("--cwd <path>", "Target directory")
128
+ .action(async (opts) => {
129
+ await taskStatusCommand({
130
+ json: Boolean(opts.json),
131
+ cwd: opts.cwd,
132
+ });
196
133
  });
197
- const notion = program
198
- .command("notion")
199
- .description("Notion dashboard sync");
134
+ program
135
+ .command("rollback [taskRef]")
136
+ .description("Advisory rollback for TASK-mvp (--confirm / --confirm-destructive to execute)")
137
+ .option("--confirm", "Run non-destructive rollback")
138
+ .option("--confirm-destructive", "Allow destructive rollback (reset --hard)")
139
+ .option("--strategy <name>", "branch-delete | reset-base | revert-merge")
140
+ .option("--keep-branch", "Keep task branch when deleting")
141
+ .option("--dry-run", "Print plan only")
142
+ .option("--cwd <path>", "Target directory")
143
+ .action(async (taskRef, opts) => {
144
+ await rollbackCommand(taskRef, opts);
145
+ });
146
+ const notion = program.command("notion").description("Optional Notion dashboard sync");
200
147
  notion
201
148
  .command("init")
202
- .description("Interactive setup: use arrow keys + Enter to pick Yes/No, then write `.vibeops.json` `notion` section and `.vibeops.env(.example)`")
203
- .option("--dry-run", "Print the plan without changing files (no interactive prompts)")
204
- .option("--enable", "Set `notion.enabled = true` and skip the first prompt")
205
- .option("--projects-db <id>", "Set `notion.projectsDatabaseId` (skip the prompt)")
206
- .option("--tasks-db <id>", "Set `notion.tasksDatabaseId` (skip the prompt)")
207
- .option("--non-interactive", "Force non-interactive mode in a TTY (use flag values + safe defaults only)")
208
- .option("--cwd <path>", "Run against a different directory")
209
- .action(async (options) => {
210
- await notionInitCommand(options);
149
+ .description("Configure Notion in .vibeops.json and .vibeops.env")
150
+ .option("--dry-run", "Plan only")
151
+ .option("--enable", "Enable Notion")
152
+ .option("--projects-db <id>", "Projects database id")
153
+ .option("--tasks-db <id>", "Tasks database id")
154
+ .option("--non-interactive", "No prompts")
155
+ .option("--cwd <path>", "Target directory")
156
+ .action(async (opts) => {
157
+ await notionInitCommand(opts);
211
158
  });
212
159
  notion
213
160
  .command("test")
214
- .description("Read-only check: Notion API auth + access to Projects/Tasks DBs + required-property schema validation")
215
- .option("--json", "Print machine-readable JSON")
216
- .option("--debug-shape", "Also print a token-safe diagnostic of the Projects/Tasks retrieve responses (top-level keys, data_sources, etc.)")
217
- .option("--cwd <path>", "Run against a different directory")
218
- .action(async (options) => {
219
- await notionTestCommand(options);
161
+ .description("Verify Notion token and database schema")
162
+ .option("--json", "JSON output")
163
+ .option("--debug-shape", "Print API shape diagnostics")
164
+ .option("--cwd <path>", "Target directory")
165
+ .action(async (opts) => {
166
+ await notionTestCommand(opts);
220
167
  });
221
168
  notion
222
169
  .command("sync")
223
- .description("Push `docs/project` + `docs/tasks` metadata into Notion Projects/Tasks DBs (read-only on local files)")
224
- .option("--dry-run", "Print the plan without any Notion mutation (queries only)")
225
- .option("--json", "Print machine-readable JSON")
226
- .option("--only-tasks", "Sync the Tasks DB only (leave the Project row alone)")
227
- .option("--only-project", "Sync the Project DB only (leave Task rows alone)")
228
- .option("--cwd <path>", "Run against a different directory")
229
- .action(async (options) => {
230
- await notionSyncCommand(options);
231
- });
232
- const github = program
233
- .command("github")
234
- .description("GitHub repository integration");
170
+ .description("Push docs/project + TASK metadata to Notion")
171
+ .option("--dry-run", "Query only")
172
+ .option("--json", "JSON output")
173
+ .option("--only-tasks", "Tasks DB only")
174
+ .option("--only-project", "Project DB only")
175
+ .option("--cwd <path>", "Target directory")
176
+ .action(async (opts) => {
177
+ await notionSyncCommand(opts);
178
+ });
179
+ const github = program.command("github").description("Optional GitHub remote setup");
235
180
  github
236
181
  .command("status")
237
- .description("Read-only check: gh install/auth + git remotes + `.vibeops.json` github section + `package.json` repo fields")
238
- .option("--json", "Print machine-readable JSON")
239
- .option("--cwd <path>", "Inspect a different directory")
240
- .action(async (options) => {
241
- await githubStatusCommand(options);
182
+ .description("gh auth, remotes, .vibeops.json github section")
183
+ .option("--json", "JSON output")
184
+ .option("--cwd <path>", "Target directory")
185
+ .action(async (opts) => {
186
+ await githubStatusCommand(opts);
242
187
  });
243
188
  github
244
189
  .command("init")
245
- .description("Interactive setup: check gh auth, manage the git remote, optionally `gh repo create`, then update `.vibeops.json` and `package.json`")
246
- .option("--dry-run", "Print the plan without running gh / git or writing files")
247
- .option("--yes", "Skip interactive prompts and use safe defaults")
248
- .option("--owner <owner>", "GitHub owner (user or org)")
249
- .option("--repo <repo>", "GitHub repo name")
250
- .option("--public", "Force visibility = public (skip the prompt)")
251
- .option("--private", "Force visibility = private (skip the prompt)")
252
- .option("--remote <name>", "Git remote name (default `origin`)")
253
- .option("--connect <ownerOrUrl>", "Connect to an existing repo instead of creating one (owner/repo or https/ssh URL)")
254
- .option("--no-package-update", "Do not modify `package.json` repository/homepage/bugs fields")
255
- .option("--cwd <path>", "Run against a different directory")
256
- .action(async (options) => {
190
+ .description("Connect or create a GitHub repo and update config")
191
+ .option("--dry-run", "Plan only")
192
+ .option("--yes", "Non-interactive defaults")
193
+ .option("--owner <owner>", "GitHub owner")
194
+ .option("--repo <repo>", "Repo name")
195
+ .option("--public", "Public repo")
196
+ .option("--private", "Private repo")
197
+ .option("--remote <name>", "Remote name (default origin)")
198
+ .option("--connect <ownerOrUrl>", "Existing repo")
199
+ .option("--no-package-update", "Skip package.json repo fields")
200
+ .option("--cwd <path>", "Target directory")
201
+ .action(async (opts) => {
257
202
  await githubInitCommand({
258
- ...options,
259
- // Commander turns `--no-package-update` into `packageUpdate: false`.
260
- // Our command type still accepts the legacy `noPackageUpdate`
261
- // shape for tests / direct invocation.
262
- noPackageUpdate: options.packageUpdate === false,
203
+ ...opts,
204
+ noPackageUpdate: opts.packageUpdate === false,
263
205
  });
264
206
  });
265
207
  program.parseAsync(process.argv).catch((err) => {
@@ -0,0 +1,160 @@
1
+ import { relative, resolve } from "node:path";
2
+ import { askYesNo } from "../lib/inquirer-helpers.js";
3
+ import { readText } from "../lib/filesystem.js";
4
+ import { bold, dim, green, log, yellow } from "../lib/logger.js";
5
+ import { writeDoneSummary } from "../lib/mvp-done-summary.js";
6
+ import { mergeTaskBranch } from "../lib/task-merge.js";
7
+ import { projectPaths } from "../lib/paths.js";
8
+ import { commandTaskNotFoundMessage, resolveCommandTask, } from "../lib/resolve-command-task.js";
9
+ import { hasNonEmptySection, parseTaskFilename, readGitContext, readTaskFile, updateInlineStatus, } from "../lib/task.js";
10
+ import { runDoneFollowUp } from "../lib/done-follow-up.js";
11
+ import { gitAddPaths, listWorkingTreeRelPaths, partitionPathsForAutoCommit, readGitInfo, runGit, } from "../lib/git.js";
12
+ function relOrAbs(root, p) {
13
+ const r = relative(root, p);
14
+ return r === "" ? "." : r.startsWith("..") ? p : r;
15
+ }
16
+ function commitMessageFor(taskId, title) {
17
+ const trimmed = (title || taskId).trim();
18
+ const slug = trimmed.replace(/^TASK-(?:\d+|mvp)\s*[·:\-]\s*/i, "").trim() || trimmed;
19
+ return `feat(${taskId.toLowerCase()}): ${slug}`;
20
+ }
21
+ async function tryCommitDirty(cwd, message, dryRun) {
22
+ const git = await readGitInfo(cwd);
23
+ if (!git.isRepo || git.dirty !== true)
24
+ return false;
25
+ const { committable, excluded } = partitionPathsForAutoCommit(await listWorkingTreeRelPaths(cwd), { unmerged: [] });
26
+ if (committable.length === 0) {
27
+ if (excluded.length > 0) {
28
+ log.warn("Dirty tree is only node_modules/.next-style paths — add them to .gitignore; commit skipped.");
29
+ }
30
+ return false;
31
+ }
32
+ if (excluded.length > 0) {
33
+ log.warn(`Skipping ${excluded.length} build/install path(s) from auto-commit (e.g. ${excluded[0]}).`);
34
+ }
35
+ if (dryRun) {
36
+ log.info(dim(` would: git add (${committable.length} paths) && git commit -m "${message}"`));
37
+ return true;
38
+ }
39
+ try {
40
+ await gitAddPaths(cwd, committable);
41
+ await runGit(cwd, ["commit", "-q", "-m", message]);
42
+ log.ok(`Committed: ${message}`);
43
+ return true;
44
+ }
45
+ catch (e) {
46
+ const msg = e instanceof Error ? e.message : String(e);
47
+ log.warn(`Commit skipped (${msg}).`);
48
+ return false;
49
+ }
50
+ }
51
+ /** Finalize TASK-mvp: auto-fill Result/Test Result, summary md, merge, Notion. */
52
+ export async function doneCommand(taskRef, options = {}) {
53
+ const cwd = resolve(options.cwd ?? process.cwd());
54
+ const paths = projectPaths(cwd);
55
+ const dryRun = options.dryRun === true;
56
+ const interactive = !dryRun && process.stdin.isTTY === true;
57
+ const resolved = await resolveCommandTask(paths, cwd, taskRef);
58
+ if (resolved === null) {
59
+ log.error(commandTaskNotFoundMessage(taskRef));
60
+ process.exitCode = 1;
61
+ return;
62
+ }
63
+ const { taskFile, source } = resolved;
64
+ if (source === "backlog-active") {
65
+ log.info(dim(`No TASK-mvp — using ${resolved.taskId}.`));
66
+ }
67
+ const meta = await readTaskFile(taskFile);
68
+ log.info(bold(`vibeops done ${meta.id}`));
69
+ log.info(` ${dim("file")} ${relOrAbs(cwd, taskFile)}`);
70
+ log.blank();
71
+ if (options.skipSummary !== true) {
72
+ log.step(bold("Summary + TASK sections"));
73
+ await writeDoneSummary({
74
+ cwd,
75
+ taskId: meta.id,
76
+ taskTitle: meta.title,
77
+ taskFile,
78
+ dryRun,
79
+ refreshTaskSections: options.refreshTaskSections === true,
80
+ });
81
+ log.blank();
82
+ }
83
+ const body = await readText(taskFile);
84
+ const resultOk = hasNonEmptySection(body, "Result");
85
+ const testResultOk = hasNonEmptySection(body, "Test Result");
86
+ log.info(bold("Required sections"));
87
+ let missing = 0;
88
+ for (const [label, ok] of [
89
+ ["Result", resultOk],
90
+ ["Test Result", testResultOk],
91
+ ]) {
92
+ if (!ok)
93
+ missing++;
94
+ log.info(` ${ok ? green("✓") : yellow("·")} ${label}`);
95
+ }
96
+ log.blank();
97
+ if (missing > 0) {
98
+ log.error(`Could not auto-fill Result / Test Result. Connect Codex OAuth, Cursor Agent CLI, or OPENAI_API_KEY — or fill sections manually.`);
99
+ process.exitCode = 1;
100
+ return;
101
+ }
102
+ if (dryRun) {
103
+ log.info(bold("dry-run — would:"));
104
+ log.info(` · Status → Done`);
105
+ log.info(` · commit if dirty, then merge (unless --no-merge)`);
106
+ log.info(` · Notion sync (if enabled)`);
107
+ await runDoneFollowUp({
108
+ cwd,
109
+ taskFile,
110
+ taskTitle: meta.title,
111
+ dryRun: true,
112
+ noNotionSync: options.noNotionSync,
113
+ interactive,
114
+ });
115
+ return;
116
+ }
117
+ await updateInlineStatus(taskFile, "done");
118
+ log.ok(`Status → Done`);
119
+ const parts = parseTaskFilename(taskFile);
120
+ const commitMsg = commitMessageFor(parts.id, meta.title);
121
+ await tryCommitDirty(cwd, commitMsg, false);
122
+ const gitCtx = await readGitContext(taskFile);
123
+ if (options.noMerge !== true && gitCtx !== null) {
124
+ let doMerge = !interactive;
125
+ if (interactive) {
126
+ doMerge = await askYesNo({
127
+ message: `Push, merge into ${gitCtx.baseBranch}, and delete ${gitCtx.taskBranch}?`,
128
+ nonInteractive: false,
129
+ defaultValue: true,
130
+ });
131
+ }
132
+ if (doMerge) {
133
+ const mergeResult = await mergeTaskBranch({
134
+ cwd,
135
+ taskId: meta.id,
136
+ taskTitle: meta.title,
137
+ gitCtx,
138
+ mode: options.mergeViaPr === true ? "pr" : "direct",
139
+ allowDirty: options.allowDirty,
140
+ });
141
+ if (!mergeResult.ok) {
142
+ process.exitCode = 1;
143
+ }
144
+ }
145
+ else {
146
+ log.info(dim("Merge skipped — run `vibeops next` later."));
147
+ }
148
+ }
149
+ else if (gitCtx === null) {
150
+ log.info(dim("No Git Context — merge skipped. Run `vibeops start` before the next MVP."));
151
+ }
152
+ await runDoneFollowUp({
153
+ cwd,
154
+ taskFile,
155
+ taskTitle: meta.title,
156
+ dryRun: false,
157
+ noNotionSync: options.noNotionSync,
158
+ interactive,
159
+ });
160
+ }