@goodtek/vibeops 0.2.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +444 -0
  4. package/dist/agent/loader.js +71 -0
  5. package/dist/agent/prompt.js +66 -0
  6. package/dist/bootstrap/installer.js +149 -0
  7. package/dist/bootstrap/manifest.js +15 -0
  8. package/dist/bootstrap/substitute.js +35 -0
  9. package/dist/cli.js +241 -0
  10. package/dist/commands/agent-list.js +32 -0
  11. package/dist/commands/agent-prompt.js +59 -0
  12. package/dist/commands/agent-show.js +26 -0
  13. package/dist/commands/github-init.js +554 -0
  14. package/dist/commands/github-status.js +164 -0
  15. package/dist/commands/init.js +179 -0
  16. package/dist/commands/notion-init.js +764 -0
  17. package/dist/commands/notion-sync.js +405 -0
  18. package/dist/commands/notion-test.js +595 -0
  19. package/dist/commands/plan.js +114 -0
  20. package/dist/commands/status.js +17 -0
  21. package/dist/commands/task-check.js +155 -0
  22. package/dist/commands/task-done.js +98 -0
  23. package/dist/commands/task-generate.js +206 -0
  24. package/dist/commands/task-pull.js +277 -0
  25. package/dist/commands/task-rollback.js +174 -0
  26. package/dist/commands/task-start.js +90 -0
  27. package/dist/lib/brief.js +349 -0
  28. package/dist/lib/config.js +158 -0
  29. package/dist/lib/filesystem.js +67 -0
  30. package/dist/lib/git.js +237 -0
  31. package/dist/lib/github-cli.js +247 -0
  32. package/dist/lib/inquirer-helpers.js +111 -0
  33. package/dist/lib/logger.js +42 -0
  34. package/dist/lib/notion-client.js +459 -0
  35. package/dist/lib/notion-discovery.js +671 -0
  36. package/dist/lib/notion-env.js +140 -0
  37. package/dist/lib/notion-mappers.js +148 -0
  38. package/dist/lib/notion-schema.js +272 -0
  39. package/dist/lib/notion-sync.js +337 -0
  40. package/dist/lib/notion-target.js +247 -0
  41. package/dist/lib/package-json.js +133 -0
  42. package/dist/lib/paths.js +26 -0
  43. package/dist/lib/project-docs.js +95 -0
  44. package/dist/lib/prompt-builder.js +125 -0
  45. package/dist/lib/task-generator.js +183 -0
  46. package/dist/lib/task-prompt.js +23 -0
  47. package/dist/lib/task-pull.js +354 -0
  48. package/dist/lib/task-scaffold.js +128 -0
  49. package/dist/lib/task-summary.js +276 -0
  50. package/dist/lib/task.js +364 -0
  51. package/dist/status/collector.js +103 -0
  52. package/dist/status/format.js +177 -0
  53. package/dist/types/brief.js +126 -0
  54. package/dist/types/config.js +17 -0
  55. package/dist/types/task.js +1 -0
  56. package/dist/version.js +8 -0
  57. package/package.json +61 -0
  58. package/templates/.cursor/rules/00-project-governance.mdc +28 -0
  59. package/templates/.cursor/rules/01-agent-orchestration.mdc +48 -0
  60. package/templates/.cursor/rules/02-task-workflow.mdc +38 -0
  61. package/templates/.cursor/rules/03-git-safety.mdc +30 -0
  62. package/templates/.cursor/rules/04-docs-update.mdc +22 -0
  63. package/templates/.vibeops/agents/architect.md +47 -0
  64. package/templates/.vibeops/agents/builder.md +38 -0
  65. package/templates/.vibeops/agents/docs.md +54 -0
  66. package/templates/.vibeops/agents/orchestrator.md +40 -0
  67. package/templates/.vibeops/agents/planner.md +60 -0
  68. package/templates/.vibeops/agents/recovery.md +49 -0
  69. package/templates/.vibeops/agents/reviewer.md +47 -0
  70. package/templates/.vibeops/agents/tester.md +43 -0
  71. package/templates/.vibeops/prompts/create-plan.md +33 -0
  72. package/templates/.vibeops/prompts/generate-tasks.md +41 -0
  73. package/templates/.vibeops/prompts/implement-task.md +39 -0
  74. package/templates/.vibeops/prompts/review-task.md +34 -0
  75. package/templates/.vibeops/prompts/rollback.md +32 -0
  76. package/templates/.vibeops/prompts/start-project.md +39 -0
  77. package/templates/.vibeops/workflows/notion-sync.md +53 -0
  78. package/templates/.vibeops/workflows/project-start.md +73 -0
  79. package/templates/.vibeops/workflows/rollback.md +45 -0
  80. package/templates/.vibeops/workflows/task-lifecycle.md +71 -0
  81. package/templates/AGENTS.md +98 -0
  82. package/templates/docs/logs/README.md +38 -0
  83. package/templates/docs/project/00-overview.md +27 -0
  84. package/templates/docs/project/01-requirements.md +30 -0
  85. package/templates/docs/project/02-mvp-scope.md +36 -0
  86. package/templates/docs/project/03-architecture.md +34 -0
  87. package/templates/docs/project/04-tech-stack.md +29 -0
  88. package/templates/docs/project/05-current-state.md +35 -0
  89. package/templates/docs/project/06-decisions.md +20 -0
  90. package/templates/docs/project/07-backlog.md +23 -0
  91. package/templates/docs/project/08-env.md +29 -0
  92. package/templates/docs/project/09-deployment.md +28 -0
  93. package/templates/docs/tasks/TASK-000-template.md +72 -0
@@ -0,0 +1,277 @@
1
+ import { resolve } from "node:path";
2
+ import { bold, cyan, dim, gray, green, log, yellow } from "../lib/logger.js";
3
+ import { maskToken } from "../lib/notion-env.js";
4
+ import { notionApiError } from "../lib/notion-client.js";
5
+ import { createNotionClient, fetchSchemas, loadSyncContext, } from "../lib/notion-sync.js";
6
+ import { executePullEntry, planPull, } from "../lib/task-pull.js";
7
+ function emptyReport(cwd, dryRun) {
8
+ return {
9
+ cwd,
10
+ ok: false,
11
+ dryRun,
12
+ tokenMasked: null,
13
+ notion: null,
14
+ filter: { projectId: "", statusNames: [], limit: 0 },
15
+ considered: 0,
16
+ entries: [],
17
+ skipped: [],
18
+ trace: [],
19
+ errors: [],
20
+ };
21
+ }
22
+ function parseStatusList(raw) {
23
+ if (typeof raw !== "string" || raw.trim().length === 0)
24
+ return ["Planned"];
25
+ return raw
26
+ .split(",")
27
+ .map((s) => s.trim())
28
+ .filter((s) => s.length > 0);
29
+ }
30
+ function parseLimit(raw) {
31
+ if (typeof raw === "number")
32
+ return raw;
33
+ if (typeof raw === "string" && raw.length > 0) {
34
+ const n = Number.parseInt(raw, 10);
35
+ if (Number.isFinite(n) && n > 0)
36
+ return n;
37
+ }
38
+ return 20;
39
+ }
40
+ export async function taskPullCommand(options = {}) {
41
+ const cwd = resolve(options.cwd ?? process.cwd());
42
+ const dryRun = options.dryRun === true;
43
+ const wantJson = options.json === true;
44
+ const verbose = options.verbose === true;
45
+ const report = emptyReport(cwd, dryRun);
46
+ const ctxRes = await loadSyncContext(cwd);
47
+ if (!ctxRes.ok) {
48
+ report.errors.push({ reason: ctxRes.reason, message: ctxRes.message });
49
+ return finalize(report, wantJson, verbose);
50
+ }
51
+ const ctx = ctxRes;
52
+ report.tokenMasked = maskToken(ctx.token);
53
+ report.notion = {
54
+ projectsTargetId: ctx.notion.projectsTargetId,
55
+ tasksTargetId: ctx.notion.tasksTargetId,
56
+ projectsDatabaseId: ctx.notion.projectsDatabaseId,
57
+ tasksDatabaseId: ctx.notion.tasksDatabaseId,
58
+ };
59
+ const statusNames = parseStatusList(options.status);
60
+ const limit = parseLimit(options.limit);
61
+ report.filter = {
62
+ projectId: ctx.project.projectId,
63
+ statusNames: [...statusNames],
64
+ limit,
65
+ };
66
+ let client;
67
+ try {
68
+ client = await createNotionClient(ctx.token);
69
+ }
70
+ catch (err) {
71
+ const apiErr = notionApiError(err);
72
+ report.errors.push({
73
+ reason: "sdk-load",
74
+ message: `Failed to load @notionhq/client — ${apiErr.message}`,
75
+ details: apiErr,
76
+ });
77
+ return finalize(report, wantJson, verbose);
78
+ }
79
+ const schemaRes = await fetchSchemas(client, ctx.notion);
80
+ if (!schemaRes.ok) {
81
+ report.errors.push({
82
+ reason: schemaRes.reason,
83
+ message: explainNotionError(schemaRes.error),
84
+ details: schemaRes.error,
85
+ });
86
+ return finalize(report, wantJson, verbose);
87
+ }
88
+ const violations = [
89
+ ...schemaRes.projects.violations,
90
+ ...schemaRes.tasks.violations,
91
+ ];
92
+ if (violations.length > 0) {
93
+ report.errors.push({
94
+ reason: "schema",
95
+ message: `Notion DB schema does not match VibeOps requirements (${violations.length} violation${violations.length === 1 ? "" : "s"}). Inspect details with \`vibeops notion test\`.`,
96
+ details: violations,
97
+ });
98
+ return finalize(report, wantJson, verbose);
99
+ }
100
+ let plan;
101
+ try {
102
+ plan = await planPull({
103
+ cwd,
104
+ client,
105
+ tasksDataSourceId: schemaRes.tasks.resolvedId,
106
+ projectId: ctx.project.projectId,
107
+ statusNames,
108
+ limit,
109
+ });
110
+ }
111
+ catch (err) {
112
+ const apiErr = notionApiError(err);
113
+ report.errors.push({
114
+ reason: "query",
115
+ message: explainNotionError(apiErr),
116
+ details: apiErr,
117
+ });
118
+ return finalize(report, wantJson, verbose);
119
+ }
120
+ report.considered = plan.considered;
121
+ for (const e of plan.entries) {
122
+ report.entries.push({
123
+ taskId: e.taskId,
124
+ title: e.title,
125
+ status: e.status,
126
+ mvpPhase: e.mvpPhase,
127
+ pageId: e.pageId,
128
+ docsRelativePath: e.docsRelativePath,
129
+ notionNeedsDocsPath: e.notionNeedsDocsPath,
130
+ ...(e.detail !== undefined ? { detail: e.detail } : {}),
131
+ });
132
+ }
133
+ for (const s of plan.skipped) {
134
+ report.skipped.push({
135
+ pageId: s.pageId,
136
+ taskId: s.taskId,
137
+ reason: s.reason,
138
+ docsRelativePath: s.docsRelativePath,
139
+ ...(s.detail !== undefined ? { detail: s.detail } : {}),
140
+ });
141
+ }
142
+ for (const t of plan.trace) {
143
+ report.trace.push({
144
+ taskId: t.taskId,
145
+ pageId: t.pageId,
146
+ notionDocsPath: t.notionDocsPath,
147
+ localResolvedPath: t.localResolvedPath,
148
+ decision: t.decision,
149
+ reason: t.reason,
150
+ });
151
+ }
152
+ if (dryRun) {
153
+ report.ok = true;
154
+ return finalize(report, wantJson, verbose);
155
+ }
156
+ let mutateFailed = false;
157
+ for (let i = 0; i < plan.entries.length; i++) {
158
+ const entry = plan.entries[i];
159
+ try {
160
+ const res = await executePullEntry(client, entry);
161
+ report.entries[i].created = true;
162
+ report.entries[i].notionUpdated = res.notionUpdated;
163
+ }
164
+ catch (err) {
165
+ mutateFailed = true;
166
+ const apiErr = notionApiError(err);
167
+ report.errors.push({
168
+ reason: "pull-execute",
169
+ message: `${entry.taskId}: ${explainNotionError(apiErr)}`,
170
+ details: apiErr,
171
+ });
172
+ }
173
+ }
174
+ report.ok = !mutateFailed;
175
+ finalize(report, wantJson, verbose);
176
+ }
177
+ function explainNotionError(err) {
178
+ const tail = err.status ? ` (HTTP ${err.status})` : "";
179
+ switch (err.code) {
180
+ case "unauthorized":
181
+ return `NOTION_TOKEN was rejected. Verify the integration is not expired and the value is correct.${tail}`;
182
+ case "restricted_resource":
183
+ return `The Notion DB is not shared with the integration. Add it via Notion DB → Connections.${tail}`;
184
+ case "object_not_found":
185
+ return `Notion resource not found. Verify the database id / page id.${tail}`;
186
+ case "validation_error":
187
+ return `Request rejected (validation_error): ${err.message}${tail}`;
188
+ case "rate_limited":
189
+ return `Notion API rate limit — retry shortly.${tail}`;
190
+ case "request_timeout":
191
+ case "ETIMEDOUT":
192
+ return `Notion API 5s timeout. Check your network.${tail}`;
193
+ default:
194
+ return `${err.code}: ${err.message}${tail}`;
195
+ }
196
+ }
197
+ function finalize(report, wantJson, verbose) {
198
+ if (wantJson) {
199
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
200
+ process.exitCode = report.ok ? 0 : 1;
201
+ return;
202
+ }
203
+ log.info(bold("vibeops task pull"));
204
+ log.info(` ${dim("cwd")} ${report.cwd}${report.dryRun ? ` ${yellow("[dry-run]")}` : ""}`);
205
+ if (report.tokenMasked)
206
+ log.info(` ${dim("token")} ${report.tokenMasked}`);
207
+ if (report.filter.projectId.length > 0) {
208
+ log.info(` ${dim("filter")} Project ID=${cyan(report.filter.projectId)} Status ∈ {${report.filter.statusNames
209
+ .map((s) => cyan(s))
210
+ .join(", ")}} ${dim(`limit=${report.filter.limit}`)}`);
211
+ }
212
+ log.blank();
213
+ for (const e of report.errors) {
214
+ log.error(`${cyan(e.reason)} — ${e.message}`);
215
+ }
216
+ if (report.errors.length > 0)
217
+ log.blank();
218
+ log.info(` ${bold("considered")} ${report.considered} rows ${dim("→")} ${green(`new ${report.entries.length}`)} ${yellow(`skipped ${report.skipped.length}`)}`);
219
+ log.blank();
220
+ if (report.entries.length > 0) {
221
+ log.info(bold("would create"));
222
+ for (const e of report.entries) {
223
+ const tag = e.created ? green("✓") : yellow("·");
224
+ const sync = e.notionUpdated ? cyan(" notion.docsPath←") : "";
225
+ log.info(` ${tag} ${cyan(e.taskId)} ${e.title}${gray(` status=${e.status} phase=${e.mvpPhase}`)}${sync}`);
226
+ log.info(` ${dim(e.docsRelativePath)}`);
227
+ if (verbose && typeof e.detail === "string" && e.detail.length > 0) {
228
+ for (const line of e.detail.split(/\r?\n/)) {
229
+ log.info(` ${dim(line)}`);
230
+ }
231
+ }
232
+ }
233
+ log.blank();
234
+ }
235
+ if (report.skipped.length > 0) {
236
+ log.info(bold("skipped"));
237
+ for (const s of report.skipped) {
238
+ log.info(` ${yellow("·")} ${cyan(s.taskId)} ${gray(s.reason)} ${dim(s.docsRelativePath)}`);
239
+ if (typeof s.detail === "string" && s.detail.length > 0) {
240
+ // Print the per-skip detail unconditionally — the new mismatch /
241
+ // duplicate-task-id branches NEED to call out the Notion docs path
242
+ // and the action the user should take.
243
+ for (const line of s.detail.split(/\r?\n/)) {
244
+ log.info(` ${dim(line)}`);
245
+ }
246
+ }
247
+ }
248
+ log.blank();
249
+ }
250
+ if (verbose && report.trace.length > 0) {
251
+ log.info(bold("trace"));
252
+ for (const t of report.trace) {
253
+ log.info(` ${cyan(t.taskId)} ${gray(t.decision)} ${dim(`page=${t.pageId}`)}`);
254
+ log.info(` ${dim(`notion docs path : ${t.notionDocsPath || "(empty)"}`)}`);
255
+ log.info(` ${dim(`local resolved : ${t.localResolvedPath || "(none)"}`)}`);
256
+ log.info(` ${dim(`reason : ${t.reason}`)}`);
257
+ }
258
+ log.blank();
259
+ }
260
+ if (report.dryRun) {
261
+ log.info(yellow(" dry-run — no file or Notion mutation performed."));
262
+ }
263
+ if (report.ok && report.errors.length === 0) {
264
+ log.ok(report.dryRun
265
+ ? "Pull plan OK — re-run without --dry-run to apply."
266
+ : `task pull complete — ${report.entries.filter((e) => e.created).length} file(s) created.`);
267
+ process.exitCode = 0;
268
+ }
269
+ else if (report.ok) {
270
+ log.warn("Some rows were skipped.");
271
+ process.exitCode = 0;
272
+ }
273
+ else {
274
+ log.error("task pull failed — see errors above.");
275
+ process.exitCode = 1;
276
+ }
277
+ }
@@ -0,0 +1,174 @@
1
+ import { relative, resolve } from "node:path";
2
+ import { gitBranchExists, gitCheckout, gitDeleteBranch, gitResetHard, readGitInfo, } from "../lib/git.js";
3
+ import { bold, cyan, dim, log, red, yellow } from "../lib/logger.js";
4
+ import { projectPaths } from "../lib/paths.js";
5
+ import { findTaskFile, readGitContext, readTaskFile, statusDisplay } from "../lib/task.js";
6
+ function relOrAbs(root, p) {
7
+ const r = relative(root, p);
8
+ return r === "" ? "." : r.startsWith("..") ? p : r;
9
+ }
10
+ function shellQuote(s) {
11
+ return /^[A-Za-z0-9._/\-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
12
+ }
13
+ function strategyCommands(strategy, ctx) {
14
+ switch (strategy) {
15
+ case "branch-delete":
16
+ return [
17
+ `git switch ${shellQuote(ctx.baseBranch)}`,
18
+ `git branch -D ${shellQuote(ctx.taskBranch)}`,
19
+ ];
20
+ case "reset-base":
21
+ return [
22
+ `git switch ${shellQuote(ctx.taskBranch)}`,
23
+ `git reset --hard ${shellQuote(ctx.baseCommit)}`,
24
+ ];
25
+ case "revert-merge":
26
+ return [
27
+ `git switch ${shellQuote(ctx.baseBranch)}`,
28
+ `# find the merge commit:`,
29
+ `git log --oneline --merges ${shellQuote(ctx.baseBranch)} | head -5`,
30
+ `# then:`,
31
+ `git revert -m 1 <merge-sha>`,
32
+ ];
33
+ }
34
+ }
35
+ function strategyRisk(strategy) {
36
+ switch (strategy) {
37
+ case "branch-delete":
38
+ return "The task branch will be deleted along with any unmerged changes on it.";
39
+ case "reset-base":
40
+ return "Hard-reset the current branch to the base commit. Uncommitted and unpushed changes will be lost.";
41
+ case "revert-merge":
42
+ return "For already-merged-and-pushed work: adds new revert commits while keeping history. VibeOps never force-pushes.";
43
+ }
44
+ }
45
+ const STRATEGY_LIST = ["branch-delete", "reset-base", "revert-merge"];
46
+ function isDestructive(strategy) {
47
+ return strategy === "reset-base";
48
+ }
49
+ export async function taskRollbackCommand(taskId, options = {}) {
50
+ const cwd = resolve(options.cwd ?? process.cwd());
51
+ const paths = projectPaths(cwd);
52
+ const taskFile = await findTaskFile(paths.docsTasks, taskId);
53
+ if (!taskFile) {
54
+ log.error(`TASK not found: ${taskId} (looked in ${relOrAbs(cwd, paths.docsTasks)})`);
55
+ process.exitCode = 1;
56
+ return;
57
+ }
58
+ const meta = await readTaskFile(taskFile);
59
+ const ctx = await readGitContext(taskFile);
60
+ const git = await readGitInfo(cwd);
61
+ log.info(bold(`vibeops task rollback ${meta.id}`));
62
+ log.info(` ${dim("file")} ${relOrAbs(cwd, taskFile)}`);
63
+ log.info(` ${dim("status")} ${statusDisplay(meta.status)}`);
64
+ if (git.isRepo) {
65
+ log.info(` ${dim("branch")} ${git.branch ?? dim("(detached)")}`);
66
+ log.info(` ${dim("dirty")} ${git.dirty ? yellow("yes") : "no"}`);
67
+ }
68
+ else {
69
+ log.info(` ${red("✗")} not a git repository`);
70
+ }
71
+ log.blank();
72
+ if (ctx === null) {
73
+ log.error(`Git Context not recorded in ${relOrAbs(cwd, taskFile)}. Was \`vibeops task start ${meta.id}\` ever run?`);
74
+ log.info(`If you started the branch manually, add a "## Git Context" section to the TASK file with: Base Branch, Base Commit, Task Branch, Started At.`);
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+ log.info(bold("Git Context (from TASK file)"));
79
+ log.info(` ${dim("base branch")} ${ctx.baseBranch}`);
80
+ log.info(` ${dim("base commit")} ${ctx.baseCommit}`);
81
+ log.info(` ${dim("task branch")} ${cyan(ctx.taskBranch)}`);
82
+ log.info(` ${dim("started at")} ${ctx.startedAt}`);
83
+ log.blank();
84
+ log.info(bold("Available strategies"));
85
+ for (const s of STRATEGY_LIST) {
86
+ const destructive = isDestructive(s);
87
+ const tag = destructive ? red("destructive") : yellow("non-destructive");
88
+ log.info(` ${bold(s)} ${dim("·")} ${tag}`);
89
+ log.info(` ${dim(strategyRisk(s))}`);
90
+ for (const cmd of strategyCommands(s, ctx)) {
91
+ if (cmd.startsWith("#")) {
92
+ log.info(` ${dim(cmd)}`);
93
+ }
94
+ else {
95
+ log.info(` ${dim("$")} ${cmd}`);
96
+ }
97
+ }
98
+ log.blank();
99
+ }
100
+ const wantConfirm = options.confirm === true || options.confirmDestructive === true;
101
+ if (!wantConfirm) {
102
+ log.info(`${yellow("!")} guidance only. add ${cyan("--confirm")} (non-destructive) or ${cyan("--confirm-destructive")} (allows hard reset) to actually run.`);
103
+ return;
104
+ }
105
+ const strategy = options.strategy ?? "branch-delete";
106
+ if (!STRATEGY_LIST.includes(strategy)) {
107
+ log.error(`Unknown strategy: ${strategy}. Choose from: ${STRATEGY_LIST.join(", ")}.`);
108
+ process.exitCode = 1;
109
+ return;
110
+ }
111
+ if (strategy === "revert-merge") {
112
+ log.info(`${yellow("!")} ${bold("revert-merge")} is never executed automatically. Run the commands above manually. ` +
113
+ `(VibeOps never force-pushes, regardless of flag combination.)`);
114
+ return;
115
+ }
116
+ if (isDestructive(strategy) && options.confirmDestructive !== true) {
117
+ log.error(`${strategy} is a destructive operation. --confirm alone is not enough; pass --confirm-destructive to proceed.`);
118
+ process.exitCode = 1;
119
+ return;
120
+ }
121
+ if (!git.isRepo) {
122
+ log.error("Cannot run rollback: not a git repository.");
123
+ process.exitCode = 1;
124
+ return;
125
+ }
126
+ if (git.dirty === true && options.confirmDestructive !== true) {
127
+ log.error("Working tree is dirty. Commit / stash first, or rerun with --confirm-destructive to acknowledge the risk.");
128
+ process.exitCode = 1;
129
+ return;
130
+ }
131
+ if (options.dryRun === true) {
132
+ log.info(bold(`dry-run — would run for strategy=${strategy}:`));
133
+ for (const cmd of strategyCommands(strategy, ctx)) {
134
+ if (cmd.startsWith("#"))
135
+ continue;
136
+ log.info(` ${dim("$")} ${cmd}`);
137
+ }
138
+ log.blank();
139
+ log.info(dim("no git command was executed."));
140
+ return;
141
+ }
142
+ if (strategy === "branch-delete") {
143
+ if (git.branch === ctx.taskBranch) {
144
+ log.info(`switching off ${ctx.taskBranch} → ${ctx.baseBranch}`);
145
+ await gitCheckout(cwd, ctx.baseBranch);
146
+ }
147
+ if (!(await gitBranchExists(cwd, ctx.taskBranch))) {
148
+ log.warn(`task branch already absent: ${ctx.taskBranch}`);
149
+ }
150
+ else {
151
+ if (options.keepBranch === true) {
152
+ log.info(`--keep-branch given → leaving ${ctx.taskBranch} intact.`);
153
+ }
154
+ else {
155
+ await gitDeleteBranch(cwd, ctx.taskBranch, { force: true });
156
+ log.ok(`deleted branch: ${ctx.taskBranch}`);
157
+ }
158
+ }
159
+ log.blank();
160
+ log.info(`Done. Current branch: ${cyan(ctx.baseBranch)}. TASK file Status is unchanged on purpose — edit it manually if needed.`);
161
+ return;
162
+ }
163
+ if (strategy === "reset-base") {
164
+ if (git.branch !== ctx.taskBranch) {
165
+ log.info(`switching to ${ctx.taskBranch}`);
166
+ await gitCheckout(cwd, ctx.taskBranch);
167
+ }
168
+ await gitResetHard(cwd, ctx.baseCommit);
169
+ log.ok(`hard reset ${ctx.taskBranch} → ${ctx.baseCommit}`);
170
+ log.blank();
171
+ log.info(`Done. Branch ${cyan(ctx.taskBranch)} now points at ${ctx.baseCommit}. TASK file Status is unchanged on purpose.`);
172
+ return;
173
+ }
174
+ }
@@ -0,0 +1,90 @@
1
+ import { relative, resolve } from "node:path";
2
+ import { branchNameForTaskFile, findTaskFile, upsertGitContext, updateInlineStatus, } from "../lib/task.js";
3
+ import { detectDefaultBranch, gitBranchExists, gitCheckoutNewBranch, gitHeadCommit, readGitInfo, } from "../lib/git.js";
4
+ import { bold, cyan, dim, log } from "../lib/logger.js";
5
+ import { buildTaskPromptString } from "../lib/task-prompt.js";
6
+ import { projectPaths } from "../lib/paths.js";
7
+ function relOrAbs(root, p) {
8
+ const r = relative(root, p);
9
+ return r === "" ? "." : r.startsWith("..") ? p : r;
10
+ }
11
+ export async function taskStartCommand(taskId, options = {}) {
12
+ const cwd = resolve(options.cwd ?? process.cwd());
13
+ const paths = projectPaths(cwd);
14
+ const agentName = options.agent ?? "builder";
15
+ const taskFile = await findTaskFile(paths.docsTasks, taskId);
16
+ if (!taskFile) {
17
+ log.error(`TASK not found: ${taskId} (looked in ${relOrAbs(cwd, paths.docsTasks)})`);
18
+ process.exitCode = 1;
19
+ return;
20
+ }
21
+ const git = await readGitInfo(cwd);
22
+ if (!git.isRepo) {
23
+ log.error(`Not a git repository: ${cwd}`);
24
+ process.exitCode = 1;
25
+ return;
26
+ }
27
+ if (git.dirty === true && options.allowDirty !== true) {
28
+ log.error("Git working tree is dirty. Commit or stash first, or rerun with --allow-dirty.");
29
+ process.exitCode = 1;
30
+ return;
31
+ }
32
+ const baseBranch = git.branch ?? (await detectDefaultBranch(cwd)) ?? "main";
33
+ const baseCommit = (await gitHeadCommit(cwd)) ?? "";
34
+ if (baseCommit.length === 0) {
35
+ log.error("Failed to read HEAD commit. Repo has no commits yet?");
36
+ process.exitCode = 1;
37
+ return;
38
+ }
39
+ const taskBranch = branchNameForTaskFile(taskFile);
40
+ const startedAt = new Date().toISOString();
41
+ const ctx = { baseBranch, baseCommit, taskBranch, startedAt };
42
+ log.info(bold(`vibeops task start ${taskId}`));
43
+ log.info(` ${dim("file")} ${relOrAbs(cwd, taskFile)}`);
44
+ log.info(` ${dim("base branch")} ${baseBranch}`);
45
+ log.info(` ${dim("base commit")} ${baseCommit}`);
46
+ log.info(` ${dim("task branch")} ${cyan(taskBranch)}`);
47
+ log.info(` ${dim("started at")} ${startedAt}`);
48
+ log.blank();
49
+ if (options.dryRun === true) {
50
+ log.info(bold("dry-run — would perform:"));
51
+ log.info(` · git checkout -b ${taskBranch} ${baseBranch}`);
52
+ log.info(` · update Status → In Progress in ${relOrAbs(cwd, taskFile)}`);
53
+ log.info(` · upsert "## Git Context" section in ${relOrAbs(cwd, taskFile)}`);
54
+ log.info(` · build Cursor prompt for agent "${agentName}"`);
55
+ log.blank();
56
+ log.info(dim("no files were written and no git command was executed."));
57
+ return;
58
+ }
59
+ if (await gitBranchExists(cwd, taskBranch)) {
60
+ log.error(`Task branch already exists: ${taskBranch}`);
61
+ log.info(` Use a different TASK or delete the existing branch first: \`git branch -D ${taskBranch}\`.`);
62
+ process.exitCode = 1;
63
+ return;
64
+ }
65
+ await gitCheckoutNewBranch(cwd, taskBranch, baseBranch);
66
+ log.ok(`checked out new branch: ${taskBranch}`);
67
+ await updateInlineStatus(taskFile, "in_progress");
68
+ await upsertGitContext(taskFile, ctx);
69
+ log.ok(`updated ${relOrAbs(cwd, taskFile)} (Status + Git Context)`);
70
+ log.blank();
71
+ const promptResult = await buildTaskPromptString({
72
+ projectRoot: paths.root,
73
+ agentsDir: paths.vibeopsAgents,
74
+ agentName,
75
+ taskFilePath: taskFile,
76
+ });
77
+ if (!promptResult.ok) {
78
+ log.warn(`agent "${agentName}" not found in ${relOrAbs(cwd, paths.vibeopsAgents)} — skipping prompt.`);
79
+ if (promptResult.available.length > 0) {
80
+ log.info(`Available agents: ${promptResult.available.join(", ")}`);
81
+ }
82
+ log.info(`Run \`vibeops task prompt ${taskId} --agent <name>\` later to generate the Cursor prompt.`);
83
+ return;
84
+ }
85
+ log.info(bold(`Cursor prompt (agent: ${agentName}):`));
86
+ log.info(dim("─".repeat(60)));
87
+ log.raw(promptResult.prompt.endsWith("\n") ? promptResult.prompt : `${promptResult.prompt}\n`);
88
+ log.info(dim("─".repeat(60)));
89
+ log.info(`Copy the block above into Cursor. When done, run \`vibeops task check ${taskId}\`.`);
90
+ }