@goodtek/vibeops 0.4.0 → 1.0.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 (106) hide show
  1. package/CHANGELOG.md +198 -1
  2. package/README.md +62 -402
  3. package/dist/bootstrap/installer.js +4 -16
  4. package/dist/cli.js +52 -244
  5. package/dist/commands/init.js +3 -3
  6. package/dist/commands/status.js +145 -8
  7. package/dist/commands/task-add.js +100 -0
  8. package/dist/commands/task-done.js +117 -74
  9. package/dist/lib/config.js +2 -118
  10. package/dist/lib/git.js +190 -8
  11. package/dist/lib/llm-complete.js +62 -0
  12. package/dist/lib/paths.js +7 -3
  13. package/dist/lib/plan-llm-openai.js +0 -63
  14. package/dist/lib/resolve-task.js +34 -0
  15. package/dist/lib/slug.js +11 -0
  16. package/dist/lib/task-add-llm.js +81 -0
  17. package/dist/lib/task-context.js +68 -0
  18. package/dist/lib/task-done-llm.js +148 -0
  19. package/dist/lib/task-merge.js +115 -0
  20. package/dist/lib/task-scaffold.js +70 -92
  21. package/dist/lib/task-start.js +72 -0
  22. package/dist/lib/task-summary.js +12 -239
  23. package/dist/lib/task.js +80 -37
  24. package/dist/types/config.js +0 -16
  25. package/package.json +3 -4
  26. package/templates/.cursor/rules/01-task-source-of-truth.mdc +12 -0
  27. package/templates/.cursor/rules/02-git-safety.mdc +11 -0
  28. package/templates/.cursor/rules/03-docs-on-done.mdc +13 -0
  29. package/templates/.cursor/skills/implement-task/SKILL.md +22 -0
  30. package/templates/.cursor/skills/plan-task/SKILL.md +19 -0
  31. package/templates/AGENTS.md +26 -87
  32. package/templates/docs/logs/README.md +4 -35
  33. package/templates/docs/project/03-architecture.md +10 -29
  34. package/templates/docs/project/05-current-state.md +7 -27
  35. package/templates/docs/project/06-decisions.md +6 -17
  36. package/templates/docs/tasks/TASK-000-template.md +9 -44
  37. package/dist/agent/loader.js +0 -71
  38. package/dist/agent/prompt.js +0 -66
  39. package/dist/commands/agent-list.js +0 -32
  40. package/dist/commands/agent-prompt.js +0 -59
  41. package/dist/commands/agent-show.js +0 -26
  42. package/dist/commands/github-init.js +0 -554
  43. package/dist/commands/github-status.js +0 -164
  44. package/dist/commands/notion-init.js +0 -764
  45. package/dist/commands/notion-sync.js +0 -405
  46. package/dist/commands/notion-test.js +0 -595
  47. package/dist/commands/plan.js +0 -343
  48. package/dist/commands/task-check.js +0 -155
  49. package/dist/commands/task-generate.js +0 -206
  50. package/dist/commands/task-pull.js +0 -277
  51. package/dist/commands/task-rollback.js +0 -174
  52. package/dist/commands/task-start.js +0 -102
  53. package/dist/lib/brief.js +0 -356
  54. package/dist/lib/github-cli.js +0 -247
  55. package/dist/lib/notion-client.js +0 -459
  56. package/dist/lib/notion-discovery.js +0 -671
  57. package/dist/lib/notion-env.js +0 -140
  58. package/dist/lib/notion-mappers.js +0 -148
  59. package/dist/lib/notion-schema.js +0 -272
  60. package/dist/lib/notion-sync.js +0 -337
  61. package/dist/lib/notion-target.js +0 -247
  62. package/dist/lib/package-json.js +0 -133
  63. package/dist/lib/plan-apply-planner.js +0 -131
  64. package/dist/lib/plan-codex-pkce.js +0 -234
  65. package/dist/lib/plan-llm-models.js +0 -237
  66. package/dist/lib/plan-llm-normalize.js +0 -146
  67. package/dist/lib/plan-llm-session.js +0 -682
  68. package/dist/lib/plan-post-apply-setup.js +0 -116
  69. package/dist/lib/project-docs.js +0 -95
  70. package/dist/lib/prompt-builder.js +0 -135
  71. package/dist/lib/task-generator.js +0 -183
  72. package/dist/lib/task-prompt.js +0 -23
  73. package/dist/lib/task-pull.js +0 -354
  74. package/dist/status/collector.js +0 -103
  75. package/dist/status/format.js +0 -177
  76. package/dist/types/brief.js +0 -126
  77. package/templates/.cursor/rules/00-project-governance.mdc +0 -28
  78. package/templates/.cursor/rules/01-agent-orchestration.mdc +0 -48
  79. package/templates/.cursor/rules/02-task-workflow.mdc +0 -38
  80. package/templates/.cursor/rules/03-git-safety.mdc +0 -30
  81. package/templates/.cursor/rules/04-docs-update.mdc +0 -22
  82. package/templates/.vibeops/agents/architect.md +0 -47
  83. package/templates/.vibeops/agents/builder.md +0 -38
  84. package/templates/.vibeops/agents/docs.md +0 -54
  85. package/templates/.vibeops/agents/orchestrator.md +0 -40
  86. package/templates/.vibeops/agents/planner.md +0 -60
  87. package/templates/.vibeops/agents/recovery.md +0 -49
  88. package/templates/.vibeops/agents/reviewer.md +0 -47
  89. package/templates/.vibeops/agents/tester.md +0 -43
  90. package/templates/.vibeops/prompts/create-plan.md +0 -33
  91. package/templates/.vibeops/prompts/generate-tasks.md +0 -41
  92. package/templates/.vibeops/prompts/implement-task.md +0 -39
  93. package/templates/.vibeops/prompts/review-task.md +0 -34
  94. package/templates/.vibeops/prompts/rollback.md +0 -32
  95. package/templates/.vibeops/prompts/start-project.md +0 -39
  96. package/templates/.vibeops/workflows/notion-sync.md +0 -53
  97. package/templates/.vibeops/workflows/project-start.md +0 -73
  98. package/templates/.vibeops/workflows/rollback.md +0 -45
  99. package/templates/.vibeops/workflows/task-lifecycle.md +0 -71
  100. package/templates/docs/project/00-overview.md +0 -27
  101. package/templates/docs/project/01-requirements.md +0 -30
  102. package/templates/docs/project/02-mvp-scope.md +0 -36
  103. package/templates/docs/project/04-tech-stack.md +0 -29
  104. package/templates/docs/project/07-backlog.md +0 -23
  105. package/templates/docs/project/08-env.md +0 -29
  106. package/templates/docs/project/09-deployment.md +0 -28
@@ -1,206 +0,0 @@
1
- import { isAbsolute, join, relative, resolve } from "node:path";
2
- import { pathExists, writeText } from "../lib/filesystem.js";
3
- import { bold, cyan, dim, log, yellow } from "../lib/logger.js";
4
- import { projectPaths } from "../lib/paths.js";
5
- import { collectInputDocs, } from "../lib/project-docs.js";
6
- import { formatTaskId, nextTaskNumber } from "../lib/task.js";
7
- import { buildTaskGeneratePrompt, } from "../lib/task-generator.js";
8
- import { planScaffoldEntries, renderScaffoldMarkdown, writeScaffoldFiles, } from "../lib/task-scaffold.js";
9
- import { VERSION } from "../version.js";
10
- const DEFAULT_PROMPT_REL = ".vibeops/generated/task-generate-prompt.md";
11
- const DEFAULT_COUNT = 8;
12
- const COUNT_SOFT_CAP = 20;
13
- const BRIEF_REL = ".vibeops/brief/project-brief.md";
14
- function toAbsolute(root, candidate) {
15
- return isAbsolute(candidate) ? candidate : resolve(root, candidate);
16
- }
17
- function relDisplay(root, abs) {
18
- const r = relative(root, abs);
19
- return r.length === 0 ? "." : r;
20
- }
21
- function parseCount(raw) {
22
- if (raw === undefined)
23
- return { count: DEFAULT_COUNT, warning: false };
24
- const n = typeof raw === "number" ? raw : Number.parseInt(String(raw).trim(), 10);
25
- if (!Number.isFinite(n) || n <= 0) {
26
- return {
27
- count: DEFAULT_COUNT,
28
- warning: false,
29
- error: `--count must be a positive integer (got: "${String(raw)}"). Falling back to default ${DEFAULT_COUNT}.`,
30
- };
31
- }
32
- return { count: Math.floor(n), warning: n > COUNT_SOFT_CAP };
33
- }
34
- function presentInventory(docs) {
35
- const present = [];
36
- const missing = [];
37
- if (docs.from)
38
- (docs.from.content !== null ? present : missing).push(docs.from);
39
- for (const slot of docs.slots)
40
- (slot.content !== null ? present : missing).push(slot);
41
- if (docs.brief.content !== null)
42
- present.push(docs.brief);
43
- else
44
- missing.push(docs.brief);
45
- return { present, missing };
46
- }
47
- export async function taskGenerateCommand(options = {}) {
48
- const cwd = resolve(options.cwd ?? process.cwd());
49
- const paths = projectPaths(cwd);
50
- const dryRun = options.dryRun === true;
51
- const scaffold = options.scaffold === true;
52
- const briefAbs = join(cwd, BRIEF_REL);
53
- const parsedCount = parseCount(options.count);
54
- if (parsedCount.error)
55
- log.warn(parsedCount.error);
56
- const count = parsedCount.count;
57
- const countWarning = parsedCount.warning;
58
- if (countWarning) {
59
- log.warn(`--count ${count} is large (soft cap ${COUNT_SOFT_CAP}). Continuing, but the Planner Agent may push back.`);
60
- }
61
- const fromAbs = options.from ? toAbsolute(cwd, options.from) : undefined;
62
- if (typeof fromAbs === "string" && !(await pathExists(fromAbs))) {
63
- log.error(`--from path not found: ${fromAbs}`);
64
- log.info(`If you meant a relative path, run from the project root or pass --cwd <path>.`);
65
- process.exitCode = 1;
66
- return;
67
- }
68
- log.info(bold(scaffold ? "vibeops task generate --scaffold" : "vibeops task generate"));
69
- log.info(` ${dim("cwd")} ${cwd}`);
70
- log.info(` ${dim("mode")} ${scaffold ? "scaffold (write skeleton TASK files)" : "prompt (build Cursor prompt)"}`);
71
- if (options.phase)
72
- log.info(` ${dim("phase")} ${options.phase}`);
73
- log.info(` ${dim("count")} ${count}${countWarning ? yellow(" (above soft cap)") : ""}`);
74
- if (fromAbs)
75
- log.info(` ${dim("--from")} ${relDisplay(cwd, fromAbs)}`);
76
- if (dryRun)
77
- log.info(` ${dim("dry-run")} on (no file writes)`);
78
- log.blank();
79
- if (scaffold) {
80
- await runScaffold({
81
- tasksDir: paths.docsTasks,
82
- cwd,
83
- count,
84
- phase: options.phase,
85
- dryRun,
86
- });
87
- return;
88
- }
89
- await runPromptMode({
90
- cwd,
91
- paths,
92
- options,
93
- fromAbs,
94
- briefAbs,
95
- count,
96
- countWarning,
97
- dryRun,
98
- });
99
- }
100
- async function runPromptMode(inputs) {
101
- const { cwd, paths, options, fromAbs, briefAbs, count, countWarning, dryRun } = inputs;
102
- const promptAbs = typeof options.output === "string"
103
- ? toAbsolute(cwd, options.output)
104
- : join(cwd, DEFAULT_PROMPT_REL);
105
- const docs = await collectInputDocs({ cwd, fromPath: options.from, fromAbs, briefAbs });
106
- const { present, missing } = presentInventory(docs);
107
- log.info(bold("Input documents"));
108
- for (const s of present)
109
- log.info(` ${cyan("✓")} ${relDisplay(cwd, s.path)} ${dim(`(${s.label})`)}`);
110
- for (const s of missing)
111
- log.info(` ${dim("·")} ${relDisplay(cwd, s.path)} ${dim(`(${s.label} — missing)`)}`);
112
- log.blank();
113
- const nextNum = await nextTaskNumber(paths.docsTasks);
114
- const nextId = formatTaskId(nextNum);
115
- log.info(bold("Plan"));
116
- log.info(` ${dim("next TASK id")} ${nextId}`);
117
- log.info(` ${dim("prompt output")} ${relDisplay(cwd, promptAbs)}`);
118
- log.blank();
119
- if (dryRun) {
120
- log.info(bold("dry-run — would perform:"));
121
- log.info(` · read ${present.length} input doc(s) (skipped ${missing.length} missing slot${missing.length === 1 ? "" : "s"})`);
122
- log.info(` · build Cursor prompt for ~${count} TASK starting at ${nextId}`);
123
- log.info(` · write prompt to ${relDisplay(cwd, promptAbs)}`);
124
- log.info(` · no LLM / Cursor / Notion / GitHub / Git call`);
125
- log.blank();
126
- log.info(dim("no files were written."));
127
- return;
128
- }
129
- const promptInputs = {
130
- cwd,
131
- vibeopsVersion: VERSION,
132
- generatedAt: new Date().toISOString(),
133
- nextTaskId: nextId,
134
- count,
135
- outputPath: promptAbs,
136
- outputRelative: relDisplay(cwd, promptAbs),
137
- docs,
138
- countWarning,
139
- ...(typeof options.phase === "string" && options.phase.length > 0
140
- ? { phase: options.phase }
141
- : {}),
142
- };
143
- const md = buildTaskGeneratePrompt(promptInputs);
144
- await writeText(promptAbs, md);
145
- log.ok(`Cursor task-generate prompt: ${relDisplay(cwd, promptAbs)}`);
146
- log.blank();
147
- log.info(bold("Next steps"));
148
- log.info(` 1) Open a new Cursor chat and paste the full contents of ${cyan(relDisplay(cwd, promptAbs))}.`);
149
- log.info(` 2) Review the Planner Agent's \`docs/tasks/TASK-NNN-*.md\` files with \`git diff\` and commit.`);
150
- log.info(` 3) Start the first new TASK with ${cyan(`vibeops task start ${nextId}`)}.`);
151
- if (missing.length > 0) {
152
- log.blank();
153
- log.info(`${yellow("!")} ${missing.length} input slot(s) are missing on disk. The Planner Agent will see them as "missing"; decide whether to fill them this round.`);
154
- }
155
- }
156
- async function runScaffold(inputs) {
157
- const plan = await planScaffoldEntries({
158
- tasksDir: inputs.tasksDir,
159
- count: inputs.count,
160
- ...(typeof inputs.phase === "string" && inputs.phase.length > 0
161
- ? { phase: inputs.phase }
162
- : {}),
163
- });
164
- log.info(bold("Scaffold plan"));
165
- log.info(` ${dim("start at")} ${formatTaskId(plan.startNumber)}`);
166
- log.info(` ${dim("count")} ${plan.entries.length}`);
167
- log.blank();
168
- log.info(bold("Files to create"));
169
- for (const e of plan.entries) {
170
- log.info(` · ${relDisplay(inputs.cwd, e.absPath)} ${dim(`(${e.phase})`)}`);
171
- }
172
- log.blank();
173
- if (inputs.dryRun) {
174
- log.info(dim("dry-run — no files were written."));
175
- log.info(`Skeleton preview (first entry, ${plan.entries[0]?.id ?? "TASK-???"}):`);
176
- log.blank();
177
- log.info(dim("─".repeat(60)));
178
- log.raw(renderScaffoldMarkdown(plan.entries[0] ?? fallbackPreviewEntry(plan.startNumber, inputs.phase)));
179
- log.info(dim("─".repeat(60)));
180
- return;
181
- }
182
- const written = await writeScaffoldFiles(plan);
183
- for (const abs of written)
184
- log.ok(`created ${relDisplay(inputs.cwd, abs)}`);
185
- if (written.length < plan.entries.length) {
186
- const skipped = plan.entries.length - written.length;
187
- log.warn(`${skipped} planned file(s) already existed and were skipped (won't overwrite).`);
188
- }
189
- log.blank();
190
- log.info(bold("Next steps"));
191
- log.info(` 1) Fill the empty sections of each scaffolded TASK file (in Cursor, via the Planner or Architect Agent).`);
192
- log.info(` 2) After filling, run \`git add docs/tasks/ && git commit\`.`);
193
- log.info(` 3) Start the first TASK with ${cyan(`vibeops task start ${plan.entries[0]?.id ?? "TASK-???"}`)}.`);
194
- }
195
- function fallbackPreviewEntry(start, phase) {
196
- const id = formatTaskId(start);
197
- return {
198
- id,
199
- number: start,
200
- slug: "planned-task",
201
- title: "(scaffolded TASK — fill in)",
202
- fileName: `${id}-planned-task.md`,
203
- absPath: `${id}-planned-task.md`,
204
- phase: phase ?? "(unassigned)",
205
- };
206
- }
@@ -1,277 +0,0 @@
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
- }
@@ -1,174 +0,0 @@
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
- }