@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.
@@ -1,107 +1,78 @@
1
1
  import { basename, join } from "node:path";
2
- import { pathExists, writeText } from "./filesystem.js";
3
- import { formatTaskId, highestTaskNumber } from "./task.js";
4
- import { slugify } from "./task-generator.js";
5
- export async function planScaffoldEntries(inputs) {
6
- const startNumber = (await highestTaskNumber(inputs.tasksDir)) + 1;
7
- const baseSlug = slugify(inputs.slug ?? "planned-task", "planned-task");
8
- const baseTitle = inputs.title ?? "(scaffolded TASK — fill in)";
9
- const phase = inputs.phase ?? "(unassigned)";
10
- const entries = [];
11
- let cursor = startNumber;
12
- for (let i = 0; i < inputs.count; i++) {
13
- // skip numbers whose file already exists on disk so users running scaffold
14
- // twice in a row don't collide with previously generated skeletons.
15
- let candidatePath = "";
16
- let chosen = -1;
17
- while (chosen < 0) {
18
- const fileName = `${formatTaskId(cursor)}-${baseSlug}.md`;
19
- const abs = join(inputs.tasksDir, fileName);
20
- if (!(await pathExists(abs))) {
21
- chosen = cursor;
22
- candidatePath = abs;
23
- }
24
- cursor++;
25
- }
26
- entries.push({
27
- id: formatTaskId(chosen),
28
- number: chosen,
29
- slug: baseSlug,
30
- title: baseTitle,
31
- fileName: basename(candidatePath),
32
- absPath: candidatePath,
33
- phase,
34
- });
2
+ import { statusDisplay } from "./task.js";
3
+ export function allocateNextTaskNumber(tasks) {
4
+ let max = 0;
5
+ for (const t of tasks) {
6
+ const m = /^TASK-(\d+)$/i.exec(t.id.trim());
7
+ if (m)
8
+ max = Math.max(max, Number.parseInt(m[1], 10));
35
9
  }
36
- return { entries, startNumber };
10
+ return max + 1;
37
11
  }
38
- export function renderScaffoldMarkdown(entry) {
39
- return `# ${entry.id} · ${entry.title}
40
-
41
- > This file is a skeleton produced by \`vibeops task generate --scaffold\`. \`vibeops task done\` will refuse to advance the task until every section is filled in.
12
+ export function formatTaskId(number) {
13
+ return `TASK-${String(number).padStart(3, "0")}`;
14
+ }
15
+ export function buildWorkNowTaskMarkdown(draft) {
16
+ const spawnedNote = draft.spawnedFrom
17
+ ? `\n\nWork-now slice while **${draft.spawnedFrom}** is still open — not a pre-planned backlog item scheduled after later TASK numbers.`
18
+ : "";
19
+ return `# ${draft.id}: ${draft.title}
42
20
 
43
21
  ## Status
44
22
 
45
- planned
23
+ ${statusDisplay("planned")}
46
24
 
47
25
  ## MVP Phase
48
26
 
49
- ${entry.phase}
27
+ ${draft.mvpPhase}
50
28
 
51
29
  ## Goal
52
30
 
53
- (scaffold — describe in 2-4 sentences what becomes possible when this TASK ships.)
31
+ ${draft.idea.trim()}
54
32
 
55
33
  ## Background
56
34
 
57
- (scaffold — why now, which earlier TASKs or decisions this builds on.)
35
+ ${draft.idea.trim()}${spawnedNote}
58
36
 
59
37
  ## Scope
60
38
 
61
- - (scaffold — item 1)
62
- - (scaffold — item 2)
39
+ - Deliver the goal above in a single focused pass.
63
40
 
64
41
  ## Out of Scope
65
42
 
66
- - (scaffold — items intentionally excluded)
43
+ - Unrelated backlog items and broad refactors not required for this slice.
67
44
 
68
45
  ## Acceptance Criteria
69
46
 
70
- 1. (scaffold — verifiable statement 1)
71
- 2. (scaffold — verifiable statement 2)
47
+ 1. Goal and Scope are met and verifiable from the repo or commands in Test Plan.
48
+ 2. Result and Test Result sections are filled before \`vibeops done\`.
72
49
 
73
50
  ## Files to Inspect First
74
51
 
75
- - (scaffold)
52
+ - (add paths before implementation)
76
53
 
77
54
  ## Expected Files to Change
78
55
 
79
- - new: (scaffold)
80
- - update: (scaffold)
56
+ - (list as you implement)
81
57
 
82
58
  ## Risks
83
59
 
84
- - (scaffold)
60
+ - Scope creep from the parent TASK — keep this slice narrow.
85
61
 
86
62
  ## Test Plan
87
63
 
88
- - (scaffold)
64
+ - \`vibeops task check ${draft.id}\`
65
+ - Project-specific checks for the change
89
66
 
90
67
  ## Rollback Plan
91
68
 
92
- - (scaffold — branch deletion or another recovery flow)
93
-
94
- ## Git Context
95
-
96
- (populated by \`vibeops task start ${entry.id}\`)
97
-
98
- ## Notion Page
99
-
100
- (populated by \`vibeops notion sync\`)
69
+ - \`vibeops rollback ${draft.id}\` (advisory) or revert the task branch commits.
101
70
 
102
71
  ## Implementation Plan
103
72
 
104
- 1. (scaffold)
73
+ 1. Confirm Scope with the open parent TASK (if any).
74
+ 2. Implement on \`vibeops start ${draft.id}\`.
75
+ 3. Fill Result and Test Result, then \`vibeops done ${draft.id}\`.
105
76
 
106
77
  ## Result
107
78
 
@@ -109,20 +80,31 @@ ${entry.phase}
109
80
 
110
81
  ## Test Result
111
82
 
112
- (not yet)
113
-
114
- ## Review Notes
115
-
116
83
  (not yet)
117
84
  `;
118
85
  }
119
- export async function writeScaffoldFiles(plan) {
120
- const written = [];
121
- for (const entry of plan.entries) {
122
- if (await pathExists(entry.absPath))
123
- continue;
124
- await writeText(entry.absPath, renderScaffoldMarkdown(entry));
125
- written.push(entry.absPath);
86
+ export function taskFilename(taskId, slug) {
87
+ return `${taskId}-${slug}.md`;
88
+ }
89
+ export function uniqueTaskPath(tasksDir, taskId, baseSlug, existingPaths) {
90
+ const names = new Set(existingPaths.map((p) => basename(p)));
91
+ let slug = baseSlug;
92
+ let name = taskFilename(taskId, slug);
93
+ let n = 2;
94
+ while (names.has(name)) {
95
+ slug = `${baseSlug}-${n}`;
96
+ name = taskFilename(taskId, slug);
97
+ n++;
126
98
  }
127
- return written;
99
+ return { slug, filePath: join(tasksDir, name) };
100
+ }
101
+ export function titleFromIdea(idea, explicitTitle) {
102
+ const t = explicitTitle?.trim();
103
+ if (t && t.length > 0)
104
+ return t;
105
+ const oneLine = idea.replace(/\s+/g, " ").trim();
106
+ if (oneLine.length <= 80)
107
+ return oneLine;
108
+ const cut = oneLine.slice(0, 77).trimEnd();
109
+ return `${cut}...`;
128
110
  }
@@ -0,0 +1,132 @@
1
+ import { join, relative } from "node:path";
2
+ import { readConfig } from "./config.js";
3
+ import { pathExists, readText } from "./filesystem.js";
4
+ import { gitBranchExists, readGitInfo } from "./git.js";
5
+ import { MVP_BUILD_PROMPT_REL } from "./mvp-constants.js";
6
+ import { taskBuildPromptRel } from "./task-add-build-prompt.js";
7
+ import { projectPaths } from "./paths.js";
8
+ import { cursorImplementPromptRel } from "./cursor-task-prompt.js";
9
+ import { summarizeGoal } from "./task-summary.js";
10
+ import { branchNameForTaskFile, countTasks, findAcceptanceCriteria, hasNonEmptySection, isMvpTaskId, isOnTaskBranch, loadActionableTasks, readGitContext, statusDisplay, } from "./task.js";
11
+ import { inferGuideStep, } from "./workflow-guide.js";
12
+ function relTaskPath(cwd, filePath) {
13
+ const r = relative(cwd, filePath).replace(/\\/g, "/");
14
+ return r.startsWith("..") ? filePath : r;
15
+ }
16
+ function toBriefTask(cwd, meta) {
17
+ return {
18
+ id: meta.id,
19
+ title: meta.title,
20
+ status: statusDisplay(meta.status),
21
+ mvpPhase: meta.mvpPhase,
22
+ file: relTaskPath(cwd, meta.filePath),
23
+ };
24
+ }
25
+ async function collectArtifacts(cwd, task) {
26
+ const out = [];
27
+ if (task === null)
28
+ return out;
29
+ if (isMvpTaskId(task.id) && (await pathExists(join(cwd, MVP_BUILD_PROMPT_REL)))) {
30
+ out.push(MVP_BUILD_PROMPT_REL);
31
+ }
32
+ const buildRel = taskBuildPromptRel(task.id);
33
+ if (await pathExists(join(cwd, buildRel)))
34
+ out.push(buildRel);
35
+ const implRel = cursorImplementPromptRel(task.id);
36
+ if (await pathExists(join(cwd, implRel)))
37
+ out.push(implRel);
38
+ return out;
39
+ }
40
+ export async function collectTaskStatusBrief(cwd) {
41
+ const paths = projectPaths(cwd);
42
+ const config = await readConfig(cwd);
43
+ const isVibeopsProject = config !== null;
44
+ const emptyWorkflow = {
45
+ stepId: "no-mvp",
46
+ stepTitle: "No TASK yet",
47
+ suggestedCommand: "vibeops plan",
48
+ youDo: ["Run planning to create TASK-mvp and the build prompt."],
49
+ };
50
+ if (!isVibeopsProject) {
51
+ const git = await readGitInfo(cwd);
52
+ return {
53
+ isVibeopsProject: false,
54
+ projectName: null,
55
+ mode: "greenfield",
56
+ activeTask: null,
57
+ referenceTask: null,
58
+ inProgressTasks: [],
59
+ counts: null,
60
+ workflow: emptyWorkflow,
61
+ git: {
62
+ isRepo: git.isRepo,
63
+ branch: git.branch,
64
+ dirty: git.dirty,
65
+ onTaskBranch: null,
66
+ expectedTaskBranch: null,
67
+ taskBranchExists: null,
68
+ baseBranch: null,
69
+ startedAt: null,
70
+ },
71
+ sections: null,
72
+ artifacts: [],
73
+ };
74
+ }
75
+ const guideCtx = await inferGuideStep({ cwd, paths, config });
76
+ const actionable = await loadActionableTasks(paths.docsTasks);
77
+ const counts = countTasks(actionable);
78
+ const inProgress = actionable
79
+ .filter((t) => t.status === "in_progress")
80
+ .map((t) => toBriefTask(cwd, t));
81
+ const active = guideCtx.task;
82
+ const reference = guideCtx.referenceTask !== null ? toBriefTask(cwd, guideCtx.referenceTask) : null;
83
+ let sections = null;
84
+ let expectedBranch = null;
85
+ let branchExists = null;
86
+ let gitCtx = null;
87
+ if (active !== null) {
88
+ const body = await readText(active.filePath);
89
+ gitCtx = await readGitContext(active.filePath);
90
+ expectedBranch = branchNameForTaskFile(active.filePath);
91
+ branchExists = await gitBranchExists(cwd, expectedBranch);
92
+ sections = {
93
+ goalExcerpt: summarizeGoal(body, 280),
94
+ resultFilled: hasNonEmptySection(body, "Result"),
95
+ testResultFilled: hasNonEmptySection(body, "Test Result"),
96
+ acceptanceCriteriaCount: findAcceptanceCriteria(body).length,
97
+ };
98
+ }
99
+ const git = await readGitInfo(cwd);
100
+ const onTaskBranch = active !== null && gitCtx !== null ? isOnTaskBranch(git.branch, gitCtx) : null;
101
+ const artifacts = await collectArtifacts(cwd, active);
102
+ return {
103
+ isVibeopsProject: true,
104
+ projectName: config.name ?? null,
105
+ mode: guideCtx.mode,
106
+ activeTask: active !== null ? toBriefTask(cwd, active) : null,
107
+ referenceTask: reference,
108
+ inProgressTasks: inProgress,
109
+ counts,
110
+ workflow: {
111
+ stepId: guideCtx.step.id,
112
+ stepTitle: guideCtx.step.title,
113
+ suggestedCommand: guideCtx.step.vibeopsCommand,
114
+ youDo: guideCtx.step.youDo,
115
+ },
116
+ git: {
117
+ isRepo: git.isRepo,
118
+ branch: git.branch,
119
+ dirty: git.dirty,
120
+ onTaskBranch,
121
+ expectedTaskBranch: expectedBranch ?? gitCtx?.taskBranch ?? null,
122
+ taskBranchExists: branchExists,
123
+ baseBranch: gitCtx?.baseBranch ?? null,
124
+ startedAt: gitCtx?.startedAt ?? null,
125
+ },
126
+ sections,
127
+ artifacts,
128
+ };
129
+ }
130
+ export function taskStatusBriefToJson(brief) {
131
+ return JSON.stringify(brief, null, 2);
132
+ }
package/dist/lib/task.js CHANGED
@@ -2,6 +2,7 @@ import { readdir } from "node:fs/promises";
2
2
  import { basename, extname, join } from "node:path";
3
3
  import matter from "gray-matter";
4
4
  import { isDirectory, readText, writeText } from "./filesystem.js";
5
+ import { readGitInfo } from "./git.js";
5
6
  const KNOWN_STATUSES = new Set([
6
7
  "planned",
7
8
  "in_progress",
@@ -32,7 +33,11 @@ export function statusDisplay(status) {
32
33
  }
33
34
  }
34
35
  function extractIdFromFilename(file) {
35
- const m = /^(TASK-\d+)/i.exec(basename(file));
36
+ const name = basename(file);
37
+ const mvp = /^TASK-mvp/i.exec(name);
38
+ if (mvp)
39
+ return "TASK-mvp";
40
+ const m = /^(TASK-\d+)/i.exec(name);
36
41
  return m ? m[1].toUpperCase() : basename(file, ".md");
37
42
  }
38
43
  function extractTitleFromBody(body) {
@@ -98,7 +103,7 @@ export async function scanTasks(tasksDir) {
98
103
  return [];
99
104
  const entries = await readdir(tasksDir, { withFileTypes: true });
100
105
  const files = entries
101
- .filter((e) => e.isFile() && e.name.endsWith(".md") && /^TASK-\d+/i.test(e.name))
106
+ .filter((e) => e.isFile() && e.name.endsWith(".md") && /^TASK-(?:\d+|mvp)/i.test(e.name))
102
107
  .map((e) => join(tasksDir, e.name))
103
108
  .sort();
104
109
  const out = [];
@@ -125,59 +130,87 @@ export function countTasks(tasks) {
125
130
  counts[t.status]++;
126
131
  return counts;
127
132
  }
133
+ export function pickInProgressTask(tasks) {
134
+ return tasks.find((t) => t.status === "in_progress") ?? null;
135
+ }
128
136
  export function pickNextTask(tasks) {
129
- const inProgress = tasks.find((t) => t.status === "in_progress");
137
+ const inProgress = pickInProgressTask(tasks);
130
138
  if (inProgress)
131
139
  return inProgress;
132
140
  const review = tasks.find((t) => t.status === "review");
133
141
  if (review)
134
142
  return review;
135
- const planned = tasks.find((t) => t.status === "planned");
136
- return planned ?? null;
143
+ const planned = tasks.filter((t) => t.status === "planned");
144
+ const mvp = planned.find((t) => t.id.toUpperCase() === "TASK-MVP");
145
+ return mvp ?? planned[0] ?? null;
146
+ }
147
+ const TEMPLATE_TASK_ID = "TASK-000";
148
+ export function filterActionableTasks(tasks) {
149
+ return tasks.filter((t) => t.id.toUpperCase() !== TEMPLATE_TASK_ID);
150
+ }
151
+ export function isMvpTaskId(id) {
152
+ return id.toUpperCase() === "TASK-MVP";
153
+ }
154
+ function taskSortKey(id) {
155
+ if (isMvpTaskId(id))
156
+ return 1_000_000;
157
+ const m = /^TASK-(\d+)$/i.exec(id.trim());
158
+ return m ? Number.parseInt(m[1], 10) : 0;
159
+ }
160
+ /** Latest finished TASK by numeric id (TASK-mvp sorts after numbered tasks). */
161
+ export function pickLatestDoneTask(tasks) {
162
+ const done = filterActionableTasks(tasks).filter((t) => t.status === "done");
163
+ if (done.length === 0)
164
+ return null;
165
+ return [...done].sort((a, b) => taskSortKey(b.id) - taskSortKey(a.id))[0];
166
+ }
167
+ export async function loadActionableTasks(tasksDir) {
168
+ return filterActionableTasks(await scanTasks(tasksDir));
169
+ }
170
+ /** True when HEAD is on the TASK's recorded task branch. */
171
+ export function isOnTaskBranch(gitBranch, ctx) {
172
+ return (typeof gitBranch === "string" &&
173
+ ctx !== null &&
174
+ gitBranch === ctx.taskBranch);
175
+ }
176
+ /**
177
+ * Prefer the TASK for the current `task/*` branch so `next` does not jump to the
178
+ * next Planned TASK while merge / `vibeops done` is still pending.
179
+ */
180
+ export async function pickActiveTask(tasksDir, cwd) {
181
+ const actionable = await loadActionableTasks(tasksDir);
182
+ const git = await readGitInfo(cwd);
183
+ if (git.isRepo && typeof git.branch === "string" && git.branch.startsWith("task/")) {
184
+ for (const t of actionable) {
185
+ const ctx = await readGitContext(t.filePath);
186
+ if (isOnTaskBranch(git.branch, ctx))
187
+ return t;
188
+ }
189
+ }
190
+ return pickNextTask(actionable);
137
191
  }
138
192
  export async function findTaskFile(tasksDir, taskId) {
139
193
  const all = await scanTasks(tasksDir);
140
- const target = taskId.toUpperCase();
194
+ const normalized = taskId.trim();
195
+ const target = /^mvp$/i.test(normalized) || /^task-mvp$/i.test(normalized)
196
+ ? "TASK-MVP"
197
+ : normalized.toUpperCase();
141
198
  for (const t of all) {
142
199
  if (t.id.toUpperCase() === target)
143
200
  return t.filePath;
144
201
  }
145
202
  return null;
146
203
  }
147
- /**
148
- * Read the highest TASK number currently present in `tasksDir`.
149
- * Considers only filenames that match `TASK-NNN-*.md` (the `TASK-000-template.md`
150
- * scaffolding file is included — callers can subtract its `0` if they want to
151
- * skip the placeholder).
152
- *
153
- * Returns 0 if no TASK files exist or the directory is missing.
154
- */
155
- export async function highestTaskNumber(tasksDir) {
156
- if (!(await isDirectory(tasksDir)))
157
- return 0;
158
- const entries = await readdir(tasksDir, { withFileTypes: true });
159
- let max = 0;
160
- for (const e of entries) {
161
- if (!e.isFile() || !e.name.endsWith(".md"))
162
- continue;
163
- const m = /^TASK-(\d+)/i.exec(e.name);
164
- if (!m)
165
- continue;
166
- const n = Number.parseInt(m[1], 10);
167
- if (Number.isFinite(n) && n > max)
168
- max = n;
169
- }
170
- return max;
171
- }
172
- export async function nextTaskNumber(tasksDir) {
173
- return (await highestTaskNumber(tasksDir)) + 1;
174
- }
175
- export function formatTaskId(n, width = 3) {
176
- return `TASK-${String(n).padStart(width, "0")}`;
177
- }
178
204
  const TASK_FILENAME_RE = /^TASK-(\d+)(?:-(.+))?$/i;
205
+ const TASK_MVP_FILENAME_RE = /^TASK-mvp(?:-(.+))?$/i;
179
206
  export function parseTaskFilename(filePath) {
180
207
  const stem = basename(filePath, extname(filePath));
208
+ const mvp = TASK_MVP_FILENAME_RE.exec(stem);
209
+ if (mvp) {
210
+ const tail = (mvp[1] ?? "").trim().toLowerCase();
211
+ const slug = tail.length > 0 ? `mvp-${tail}` : "mvp";
212
+ return { id: "TASK-mvp", number: "mvp", slug };
213
+ }
181
214
  const m = TASK_FILENAME_RE.exec(stem);
182
215
  if (!m) {
183
216
  return { id: stem.toUpperCase(), number: "000", slug: stem.toLowerCase() };
@@ -235,6 +268,18 @@ export function isPlaceholderContent(content) {
235
268
  return true; // legacy Korean placeholder
236
269
  if (/^_*\(none\)_*$/i.test(trimmed))
237
270
  return true;
271
+ if (/^\(not yet\)$/i.test(trimmed))
272
+ return true;
273
+ if (/^\(scaffold/i.test(trimmed))
274
+ return true;
275
+ if (/^pending\.?$/i.test(trimmed))
276
+ return true;
277
+ if (/^tbd\.?$/i.test(trimmed))
278
+ return true;
279
+ if (/^todo\.?$/i.test(trimmed))
280
+ return true;
281
+ if (/^-{1,3}$/.test(trimmed))
282
+ return true;
238
283
  return false;
239
284
  }
240
285
  export function hasNonEmptySection(body, title) {
@@ -306,6 +351,13 @@ export async function updateInlineStatus(filePath, next) {
306
351
  await writeText(filePath, updated);
307
352
  }
308
353
  }
354
+ export async function updateTaskSection(filePath, title, content) {
355
+ const raw = await readText(filePath);
356
+ const updated = replaceSectionContent(raw, title, content);
357
+ if (updated !== raw) {
358
+ await writeText(filePath, updated);
359
+ }
360
+ }
309
361
  function renderGitContextBlock(ctx) {
310
362
  const lines = [
311
363
  `- Base Branch: \`${ctx.baseBranch}\``,