@goodtek/vibeops 0.4.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/CHANGELOG.md +177 -0
- package/README.md +34 -54
- package/dist/cli.js +156 -214
- package/dist/commands/done.js +160 -0
- package/dist/commands/next.js +283 -0
- package/dist/commands/plan.js +53 -250
- package/dist/commands/rollback.js +126 -0
- package/dist/commands/start.js +129 -0
- package/dist/commands/task-add.js +200 -0
- package/dist/commands/task-status.js +122 -0
- package/dist/lib/brief.js +1 -1
- package/dist/lib/cursor-task-prompt.js +60 -0
- package/dist/lib/done-follow-up.js +308 -0
- package/dist/lib/git.js +189 -7
- package/dist/lib/guide-state.js +69 -0
- package/dist/lib/llm-complete.js +62 -0
- package/dist/lib/mvp-artifacts.js +204 -0
- package/dist/lib/mvp-constants.js +15 -0
- package/dist/lib/mvp-diff-summary.js +43 -0
- package/dist/lib/mvp-done-summary.js +335 -0
- package/dist/lib/resolve-command-task.js +40 -0
- package/dist/lib/slug.js +11 -0
- package/dist/lib/task-add-build-prompt.js +39 -0
- package/dist/lib/task-add-cursor.js +81 -0
- package/dist/lib/task-add-llm.js +268 -0
- package/dist/lib/task-merge.js +220 -0
- package/dist/lib/task-scaffold.js +56 -74
- package/dist/lib/task-status-brief.js +132 -0
- package/dist/lib/task.js +89 -37
- package/dist/lib/workflow-guide.js +485 -0
- package/dist/status/collector.js +2 -2
- package/dist/status/format.js +6 -2
- package/package.json +2 -1
- package/templates/docs/tasks/TASK-000-template.md +1 -1
|
@@ -1,107 +1,78 @@
|
|
|
1
1
|
import { basename, join } from "node:path";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
10
|
+
return max + 1;
|
|
37
11
|
}
|
|
38
|
-
export function
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
${
|
|
27
|
+
${draft.mvpPhase}
|
|
50
28
|
|
|
51
29
|
## Goal
|
|
52
30
|
|
|
53
|
-
(
|
|
31
|
+
${draft.idea.trim()}
|
|
54
32
|
|
|
55
33
|
## Background
|
|
56
34
|
|
|
57
|
-
(
|
|
35
|
+
${draft.idea.trim()}${spawnedNote}
|
|
58
36
|
|
|
59
37
|
## Scope
|
|
60
38
|
|
|
61
|
-
-
|
|
62
|
-
- (scaffold — item 2)
|
|
39
|
+
- Deliver the goal above in a single focused pass.
|
|
63
40
|
|
|
64
41
|
## Out of Scope
|
|
65
42
|
|
|
66
|
-
-
|
|
43
|
+
- Unrelated backlog items and broad refactors not required for this slice.
|
|
67
44
|
|
|
68
45
|
## Acceptance Criteria
|
|
69
46
|
|
|
70
|
-
1.
|
|
71
|
-
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
|
-
- (
|
|
52
|
+
- (add paths before implementation)
|
|
76
53
|
|
|
77
54
|
## Expected Files to Change
|
|
78
55
|
|
|
79
|
-
-
|
|
80
|
-
- update: (scaffold)
|
|
56
|
+
- (list as you implement)
|
|
81
57
|
|
|
82
58
|
## Risks
|
|
83
59
|
|
|
84
|
-
-
|
|
60
|
+
- Scope creep from the parent TASK — keep this slice narrow.
|
|
85
61
|
|
|
86
62
|
## Test Plan
|
|
87
63
|
|
|
88
|
-
-
|
|
64
|
+
- \`vibeops task check ${draft.id}\`
|
|
65
|
+
- Project-specific checks for the change
|
|
89
66
|
|
|
90
67
|
## Rollback Plan
|
|
91
68
|
|
|
92
|
-
-
|
|
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. (
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
136
|
-
|
|
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
|
|
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}\``,
|