@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.
- package/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +444 -0
- package/dist/agent/loader.js +71 -0
- package/dist/agent/prompt.js +66 -0
- package/dist/bootstrap/installer.js +149 -0
- package/dist/bootstrap/manifest.js +15 -0
- package/dist/bootstrap/substitute.js +35 -0
- package/dist/cli.js +241 -0
- package/dist/commands/agent-list.js +32 -0
- package/dist/commands/agent-prompt.js +59 -0
- package/dist/commands/agent-show.js +26 -0
- package/dist/commands/github-init.js +554 -0
- package/dist/commands/github-status.js +164 -0
- package/dist/commands/init.js +179 -0
- package/dist/commands/notion-init.js +764 -0
- package/dist/commands/notion-sync.js +405 -0
- package/dist/commands/notion-test.js +595 -0
- package/dist/commands/plan.js +114 -0
- package/dist/commands/status.js +17 -0
- package/dist/commands/task-check.js +155 -0
- package/dist/commands/task-done.js +98 -0
- package/dist/commands/task-generate.js +206 -0
- package/dist/commands/task-pull.js +277 -0
- package/dist/commands/task-rollback.js +174 -0
- package/dist/commands/task-start.js +90 -0
- package/dist/lib/brief.js +349 -0
- package/dist/lib/config.js +158 -0
- package/dist/lib/filesystem.js +67 -0
- package/dist/lib/git.js +237 -0
- package/dist/lib/github-cli.js +247 -0
- package/dist/lib/inquirer-helpers.js +111 -0
- package/dist/lib/logger.js +42 -0
- package/dist/lib/notion-client.js +459 -0
- package/dist/lib/notion-discovery.js +671 -0
- package/dist/lib/notion-env.js +140 -0
- package/dist/lib/notion-mappers.js +148 -0
- package/dist/lib/notion-schema.js +272 -0
- package/dist/lib/notion-sync.js +337 -0
- package/dist/lib/notion-target.js +247 -0
- package/dist/lib/package-json.js +133 -0
- package/dist/lib/paths.js +26 -0
- package/dist/lib/project-docs.js +95 -0
- package/dist/lib/prompt-builder.js +125 -0
- package/dist/lib/task-generator.js +183 -0
- package/dist/lib/task-prompt.js +23 -0
- package/dist/lib/task-pull.js +354 -0
- package/dist/lib/task-scaffold.js +128 -0
- package/dist/lib/task-summary.js +276 -0
- package/dist/lib/task.js +364 -0
- package/dist/status/collector.js +103 -0
- package/dist/status/format.js +177 -0
- package/dist/types/brief.js +126 -0
- package/dist/types/config.js +17 -0
- package/dist/types/task.js +1 -0
- package/dist/version.js +8 -0
- package/package.json +61 -0
- package/templates/.cursor/rules/00-project-governance.mdc +28 -0
- package/templates/.cursor/rules/01-agent-orchestration.mdc +48 -0
- package/templates/.cursor/rules/02-task-workflow.mdc +38 -0
- package/templates/.cursor/rules/03-git-safety.mdc +30 -0
- package/templates/.cursor/rules/04-docs-update.mdc +22 -0
- package/templates/.vibeops/agents/architect.md +47 -0
- package/templates/.vibeops/agents/builder.md +38 -0
- package/templates/.vibeops/agents/docs.md +54 -0
- package/templates/.vibeops/agents/orchestrator.md +40 -0
- package/templates/.vibeops/agents/planner.md +60 -0
- package/templates/.vibeops/agents/recovery.md +49 -0
- package/templates/.vibeops/agents/reviewer.md +47 -0
- package/templates/.vibeops/agents/tester.md +43 -0
- package/templates/.vibeops/prompts/create-plan.md +33 -0
- package/templates/.vibeops/prompts/generate-tasks.md +41 -0
- package/templates/.vibeops/prompts/implement-task.md +39 -0
- package/templates/.vibeops/prompts/review-task.md +34 -0
- package/templates/.vibeops/prompts/rollback.md +32 -0
- package/templates/.vibeops/prompts/start-project.md +39 -0
- package/templates/.vibeops/workflows/notion-sync.md +53 -0
- package/templates/.vibeops/workflows/project-start.md +73 -0
- package/templates/.vibeops/workflows/rollback.md +45 -0
- package/templates/.vibeops/workflows/task-lifecycle.md +71 -0
- package/templates/AGENTS.md +98 -0
- package/templates/docs/logs/README.md +38 -0
- package/templates/docs/project/00-overview.md +27 -0
- package/templates/docs/project/01-requirements.md +30 -0
- package/templates/docs/project/02-mvp-scope.md +36 -0
- package/templates/docs/project/03-architecture.md +34 -0
- package/templates/docs/project/04-tech-stack.md +29 -0
- package/templates/docs/project/05-current-state.md +35 -0
- package/templates/docs/project/06-decisions.md +20 -0
- package/templates/docs/project/07-backlog.md +23 -0
- package/templates/docs/project/08-env.md +29 -0
- package/templates/docs/project/09-deployment.md +28 -0
- package/templates/docs/tasks/TASK-000-template.md +72 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small text-extraction helpers used by `vibeops notion sync` and
|
|
3
|
+
* `vibeops task pull`.
|
|
4
|
+
*
|
|
5
|
+
* Everything here is read-only (`extract*`) or returns new strings the caller
|
|
6
|
+
* may write back (`upsertNotionPageSection`, `renderPulledTaskMarkdown`).
|
|
7
|
+
*
|
|
8
|
+
* Goal/Background extraction is deliberately heuristic:
|
|
9
|
+
* - we strip markdown bullet/numbering prefixes,
|
|
10
|
+
* - we collapse blank-line groups so the first non-empty paragraph wins,
|
|
11
|
+
* - we truncate to `NOTION_TEXT_LIMIT` (default 1500 chars).
|
|
12
|
+
*
|
|
13
|
+
* We never include placeholder content such as `(not yet)`, legacy localized
|
|
14
|
+
* placeholders, or `(scaffolded ...)`; those become an empty string.
|
|
15
|
+
*/
|
|
16
|
+
import { readTextOrNull, writeText } from "./filesystem.js";
|
|
17
|
+
import { isPlaceholderContent, readSection } from "./task.js";
|
|
18
|
+
import { truncate } from "./notion-mappers.js";
|
|
19
|
+
const PLACEHOLDER_RE = new RegExp(String.raw `^\(.*(not yet|fill in|unassigned|scaffold|\uBBF8\uC218\uD589|\uBBF8\uC815|\uCC44\uC6CC\uB77C).*\)$`, "i");
|
|
20
|
+
function stripBullet(line) {
|
|
21
|
+
return line.replace(/^\s*(?:[-*+]|\d+\.)\s+/, "").trim();
|
|
22
|
+
}
|
|
23
|
+
function isHeading(line) {
|
|
24
|
+
return /^#{1,6}\s+/.test(line);
|
|
25
|
+
}
|
|
26
|
+
function compressBlank(lines) {
|
|
27
|
+
const out = [];
|
|
28
|
+
let blank = false;
|
|
29
|
+
for (const raw of lines) {
|
|
30
|
+
const line = raw.replace(/\s+$/u, "");
|
|
31
|
+
if (line.length === 0) {
|
|
32
|
+
if (!blank && out.length > 0)
|
|
33
|
+
out.push("");
|
|
34
|
+
blank = true;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
blank = false;
|
|
38
|
+
out.push(line);
|
|
39
|
+
}
|
|
40
|
+
return out.join("\n").trim();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Pull a flattened, single-string summary from a `## Section`. Bullet points
|
|
44
|
+
* and numbered list items are joined with " · ". Placeholder bodies become "".
|
|
45
|
+
*/
|
|
46
|
+
export function summarizeSection(body, title, limit) {
|
|
47
|
+
const raw = readSection(body, title);
|
|
48
|
+
if (raw.length === 0)
|
|
49
|
+
return "";
|
|
50
|
+
if (isPlaceholderContent(raw))
|
|
51
|
+
return "";
|
|
52
|
+
const lines = raw.split("\n");
|
|
53
|
+
const cleaned = [];
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
const t = line.trim();
|
|
56
|
+
if (t.length === 0) {
|
|
57
|
+
cleaned.push("");
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (PLACEHOLDER_RE.test(t))
|
|
61
|
+
continue;
|
|
62
|
+
if (isHeading(t))
|
|
63
|
+
continue;
|
|
64
|
+
cleaned.push(stripBullet(t));
|
|
65
|
+
}
|
|
66
|
+
const compact = compressBlank(cleaned);
|
|
67
|
+
return truncate(compact, limit);
|
|
68
|
+
}
|
|
69
|
+
export function summarizeGoal(body, limit) {
|
|
70
|
+
const goal = summarizeSection(body, "Goal", limit);
|
|
71
|
+
if (goal.length > 0)
|
|
72
|
+
return goal;
|
|
73
|
+
return summarizeSection(body, "Background", limit);
|
|
74
|
+
}
|
|
75
|
+
export function summarizeResult(body, limit) {
|
|
76
|
+
return summarizeSection(body, "Result", limit);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Best-effort: pull the very first non-heading paragraph from an arbitrary
|
|
80
|
+
* markdown body (e.g. `docs/project/00-overview.md`). Returns "" if nothing
|
|
81
|
+
* useful is found.
|
|
82
|
+
*/
|
|
83
|
+
export function summarizeMarkdownLead(body, limit) {
|
|
84
|
+
const lines = body.split("\n");
|
|
85
|
+
const collected = [];
|
|
86
|
+
let started = false;
|
|
87
|
+
for (const raw of lines) {
|
|
88
|
+
const t = raw.trim();
|
|
89
|
+
if (t.length === 0) {
|
|
90
|
+
if (started)
|
|
91
|
+
break;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (isHeading(t)) {
|
|
95
|
+
if (started)
|
|
96
|
+
break;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (t.startsWith(">"))
|
|
100
|
+
continue;
|
|
101
|
+
started = true;
|
|
102
|
+
collected.push(stripBullet(t));
|
|
103
|
+
}
|
|
104
|
+
return truncate(collected.join(" ").trim(), limit);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Heuristic: scan `docs/project/05-current-state.md` (or any markdown) for an
|
|
108
|
+
* "MVP 1 ~ N" hint. Falls back to the first heading-derived MVP token.
|
|
109
|
+
*/
|
|
110
|
+
export function detectCurrentPhase(body) {
|
|
111
|
+
if (body.length === 0)
|
|
112
|
+
return "";
|
|
113
|
+
const m = body.match(/MVP\s*\d+(?:\s*[·•:\-]\s*[^\n]+)?/i);
|
|
114
|
+
if (m)
|
|
115
|
+
return m[0].trim().replace(/\s+/g, " ");
|
|
116
|
+
return "";
|
|
117
|
+
}
|
|
118
|
+
export function renderNotionPageBlock(inputs) {
|
|
119
|
+
return [
|
|
120
|
+
`- Page ID: \`${inputs.pageId}\``,
|
|
121
|
+
`- Docs Path: \`${inputs.docsRelativePath}\``,
|
|
122
|
+
].join("\n");
|
|
123
|
+
}
|
|
124
|
+
const NOTION_PAGE_RE = /^-\s+Page ID:\s*`([^`]+)`/m;
|
|
125
|
+
export function readNotionPageId(body) {
|
|
126
|
+
const section = readSection(body, "Notion Page");
|
|
127
|
+
if (section.length === 0)
|
|
128
|
+
return null;
|
|
129
|
+
const m = section.match(NOTION_PAGE_RE);
|
|
130
|
+
return m ? m[1].trim() : null;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Replace the body of an existing `## Notion Page` section, or append the
|
|
134
|
+
* section to the file if it doesn't exist yet. Returns the new file body
|
|
135
|
+
* (caller is responsible for writing it back).
|
|
136
|
+
*/
|
|
137
|
+
export function upsertNotionPageSection(body, inputs) {
|
|
138
|
+
const block = renderNotionPageBlock(inputs);
|
|
139
|
+
const HEADING_RE = /^(##+)\s+(.+?)\s*$/;
|
|
140
|
+
const lines = body.split("\n");
|
|
141
|
+
let startIdx = -1;
|
|
142
|
+
let endIdx = -1;
|
|
143
|
+
let level = 2;
|
|
144
|
+
for (let i = 0; i < lines.length; i++) {
|
|
145
|
+
const m = HEADING_RE.exec(lines[i]);
|
|
146
|
+
if (!m)
|
|
147
|
+
continue;
|
|
148
|
+
if (m[2].trim().toLowerCase() !== "notion page")
|
|
149
|
+
continue;
|
|
150
|
+
startIdx = i;
|
|
151
|
+
level = m[1].length;
|
|
152
|
+
let j = i + 1;
|
|
153
|
+
while (j < lines.length) {
|
|
154
|
+
const nm = HEADING_RE.exec(lines[j]);
|
|
155
|
+
if (nm && nm[1].length <= level)
|
|
156
|
+
break;
|
|
157
|
+
j++;
|
|
158
|
+
}
|
|
159
|
+
endIdx = j;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
if (startIdx < 0) {
|
|
163
|
+
const trimmed = body.replace(/\s+$/u, "");
|
|
164
|
+
return `${trimmed}\n\n## Notion Page\n\n${block}\n`;
|
|
165
|
+
}
|
|
166
|
+
const before = lines.slice(0, startIdx + 1);
|
|
167
|
+
const after = lines.slice(endIdx);
|
|
168
|
+
return [...before, "", block, "", ...after].join("\n").replace(/\n{3,}/g, "\n\n");
|
|
169
|
+
}
|
|
170
|
+
export async function writeNotionPageSection(filePath, inputs) {
|
|
171
|
+
const current = await readTextOrNull(filePath);
|
|
172
|
+
if (current === null)
|
|
173
|
+
return false;
|
|
174
|
+
const next = upsertNotionPageSection(current, inputs);
|
|
175
|
+
if (next === current)
|
|
176
|
+
return false;
|
|
177
|
+
await writeText(filePath, next);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Render the markdown body for a TASK file that `vibeops task pull` is
|
|
182
|
+
* creating from a Notion row. Always uses the 18-section skeleton so the
|
|
183
|
+
* downstream `vibeops task done` validation can still run.
|
|
184
|
+
*
|
|
185
|
+
* Only the fields Notion actually carries get prefilled; everything else
|
|
186
|
+
* stays a placeholder.
|
|
187
|
+
*/
|
|
188
|
+
export function renderPulledTaskMarkdown(inputs) {
|
|
189
|
+
const title = inputs.title.length > 0 ? inputs.title : "(pulled from Notion — fill in)";
|
|
190
|
+
const status = inputs.status.length > 0 ? inputs.status : "planned";
|
|
191
|
+
const phase = inputs.mvpPhase.length > 0 ? inputs.mvpPhase : "(unassigned)";
|
|
192
|
+
const summary = inputs.summary.length > 0 ? inputs.summary : "(Notion Summary is empty — fill in.)";
|
|
193
|
+
const notionBlock = renderNotionPageBlock({
|
|
194
|
+
pageId: inputs.pageId,
|
|
195
|
+
docsRelativePath: inputs.docsRelativePath,
|
|
196
|
+
});
|
|
197
|
+
return `# ${inputs.taskId} · ${title}
|
|
198
|
+
|
|
199
|
+
> This file was generated by \`vibeops task pull\` from Notion metadata. The body is empty — the Builder/Planner Agent should fill it in.
|
|
200
|
+
|
|
201
|
+
## Status
|
|
202
|
+
|
|
203
|
+
${status}
|
|
204
|
+
|
|
205
|
+
## MVP Phase
|
|
206
|
+
|
|
207
|
+
${phase}
|
|
208
|
+
|
|
209
|
+
## Goal
|
|
210
|
+
|
|
211
|
+
${summary}
|
|
212
|
+
|
|
213
|
+
## Background
|
|
214
|
+
|
|
215
|
+
(pulled — fill in context beyond the Notion Summary.)
|
|
216
|
+
|
|
217
|
+
## Scope
|
|
218
|
+
|
|
219
|
+
- (pulled — Scope item 1)
|
|
220
|
+
- (pulled — Scope item 2)
|
|
221
|
+
|
|
222
|
+
## Out of Scope
|
|
223
|
+
|
|
224
|
+
- (pulled)
|
|
225
|
+
|
|
226
|
+
## Acceptance Criteria
|
|
227
|
+
|
|
228
|
+
1. (pulled — verifiable statement 1)
|
|
229
|
+
2. (pulled — verifiable statement 2)
|
|
230
|
+
|
|
231
|
+
## Files to Inspect First
|
|
232
|
+
|
|
233
|
+
- (pulled)
|
|
234
|
+
|
|
235
|
+
## Expected Files to Change
|
|
236
|
+
|
|
237
|
+
- new: (pulled)
|
|
238
|
+
- update: (pulled)
|
|
239
|
+
|
|
240
|
+
## Risks
|
|
241
|
+
|
|
242
|
+
- (pulled)
|
|
243
|
+
|
|
244
|
+
## Test Plan
|
|
245
|
+
|
|
246
|
+
- (pulled)
|
|
247
|
+
|
|
248
|
+
## Rollback Plan
|
|
249
|
+
|
|
250
|
+
- (pulled)
|
|
251
|
+
|
|
252
|
+
## Git Context
|
|
253
|
+
|
|
254
|
+
(populated by \`vibeops task start ${inputs.taskId}\`)
|
|
255
|
+
|
|
256
|
+
## Notion Page
|
|
257
|
+
|
|
258
|
+
${notionBlock}
|
|
259
|
+
|
|
260
|
+
## Implementation Plan
|
|
261
|
+
|
|
262
|
+
1. (pulled)
|
|
263
|
+
|
|
264
|
+
## Result
|
|
265
|
+
|
|
266
|
+
(not yet)
|
|
267
|
+
|
|
268
|
+
## Test Result
|
|
269
|
+
|
|
270
|
+
(not yet)
|
|
271
|
+
|
|
272
|
+
## Review Notes
|
|
273
|
+
|
|
274
|
+
(not yet)
|
|
275
|
+
`;
|
|
276
|
+
}
|
package/dist/lib/task.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { basename, extname, join } from "node:path";
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
import { isDirectory, readText, writeText } from "./filesystem.js";
|
|
5
|
+
const KNOWN_STATUSES = new Set([
|
|
6
|
+
"planned",
|
|
7
|
+
"in_progress",
|
|
8
|
+
"review",
|
|
9
|
+
"blocked",
|
|
10
|
+
"done",
|
|
11
|
+
]);
|
|
12
|
+
export function normalizeStatus(value) {
|
|
13
|
+
if (typeof value === "string") {
|
|
14
|
+
const s = value.toLowerCase().replace(/\s+/g, "_");
|
|
15
|
+
if (KNOWN_STATUSES.has(s))
|
|
16
|
+
return s;
|
|
17
|
+
}
|
|
18
|
+
return "planned";
|
|
19
|
+
}
|
|
20
|
+
export function statusDisplay(status) {
|
|
21
|
+
switch (status) {
|
|
22
|
+
case "planned":
|
|
23
|
+
return "Planned";
|
|
24
|
+
case "in_progress":
|
|
25
|
+
return "In Progress";
|
|
26
|
+
case "review":
|
|
27
|
+
return "Review";
|
|
28
|
+
case "blocked":
|
|
29
|
+
return "Blocked";
|
|
30
|
+
case "done":
|
|
31
|
+
return "Done";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function extractIdFromFilename(file) {
|
|
35
|
+
const m = /^(TASK-\d+)/i.exec(basename(file));
|
|
36
|
+
return m ? m[1].toUpperCase() : basename(file, ".md");
|
|
37
|
+
}
|
|
38
|
+
function extractTitleFromBody(body) {
|
|
39
|
+
const lines = body.split("\n");
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
const m = /^#\s+(.*)$/.exec(line.trim());
|
|
42
|
+
if (m)
|
|
43
|
+
return m[1].trim();
|
|
44
|
+
}
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
47
|
+
function extractInlineStatus(body) {
|
|
48
|
+
const re = /^##\s+Status\s*$/im;
|
|
49
|
+
const idx = body.search(re);
|
|
50
|
+
if (idx < 0)
|
|
51
|
+
return null;
|
|
52
|
+
const after = body.slice(idx).split("\n");
|
|
53
|
+
for (let i = 1; i < after.length; i++) {
|
|
54
|
+
const line = after[i].trim();
|
|
55
|
+
if (line.length === 0)
|
|
56
|
+
continue;
|
|
57
|
+
if (line.startsWith("#"))
|
|
58
|
+
break;
|
|
59
|
+
return normalizeStatus(line);
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
function extractInlineMvpPhase(body) {
|
|
64
|
+
const re = /^##\s+MVP Phase\s*$/im;
|
|
65
|
+
const idx = body.search(re);
|
|
66
|
+
if (idx < 0)
|
|
67
|
+
return undefined;
|
|
68
|
+
const after = body.slice(idx).split("\n");
|
|
69
|
+
for (let i = 1; i < after.length; i++) {
|
|
70
|
+
const line = after[i].trim();
|
|
71
|
+
if (line.length === 0)
|
|
72
|
+
continue;
|
|
73
|
+
if (line.startsWith("#"))
|
|
74
|
+
break;
|
|
75
|
+
return line;
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
export async function readTaskFile(filePath) {
|
|
80
|
+
const raw = await readText(filePath);
|
|
81
|
+
const parsed = matter(raw);
|
|
82
|
+
const data = parsed.data;
|
|
83
|
+
const body = parsed.content;
|
|
84
|
+
const idFromFm = typeof data["id"] === "string" ? data["id"] : null;
|
|
85
|
+
const id = idFromFm ?? extractIdFromFilename(filePath);
|
|
86
|
+
const titleFromFm = typeof data["title"] === "string" ? data["title"] : null;
|
|
87
|
+
const title = titleFromFm ?? extractTitleFromBody(body);
|
|
88
|
+
const status = data["status"] !== undefined
|
|
89
|
+
? normalizeStatus(data["status"])
|
|
90
|
+
: (extractInlineStatus(body) ?? "planned");
|
|
91
|
+
const mvpPhaseFromFm = typeof data["mvpPhase"] === "string" ? data["mvpPhase"] : undefined;
|
|
92
|
+
const mvpPhase = mvpPhaseFromFm ?? extractInlineMvpPhase(body);
|
|
93
|
+
const priority = typeof data["priority"] === "string" ? data["priority"] : undefined;
|
|
94
|
+
return { id, title, status, mvpPhase, priority, filePath };
|
|
95
|
+
}
|
|
96
|
+
export async function scanTasks(tasksDir) {
|
|
97
|
+
if (!(await isDirectory(tasksDir)))
|
|
98
|
+
return [];
|
|
99
|
+
const entries = await readdir(tasksDir, { withFileTypes: true });
|
|
100
|
+
const files = entries
|
|
101
|
+
.filter((e) => e.isFile() && e.name.endsWith(".md") && /^TASK-\d+/i.test(e.name))
|
|
102
|
+
.map((e) => join(tasksDir, e.name))
|
|
103
|
+
.sort();
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const f of files) {
|
|
106
|
+
try {
|
|
107
|
+
out.push(await readTaskFile(f));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// skip unreadable files
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
export function countTasks(tasks) {
|
|
116
|
+
const counts = {
|
|
117
|
+
total: tasks.length,
|
|
118
|
+
planned: 0,
|
|
119
|
+
in_progress: 0,
|
|
120
|
+
review: 0,
|
|
121
|
+
blocked: 0,
|
|
122
|
+
done: 0,
|
|
123
|
+
};
|
|
124
|
+
for (const t of tasks)
|
|
125
|
+
counts[t.status]++;
|
|
126
|
+
return counts;
|
|
127
|
+
}
|
|
128
|
+
export function pickNextTask(tasks) {
|
|
129
|
+
const inProgress = tasks.find((t) => t.status === "in_progress");
|
|
130
|
+
if (inProgress)
|
|
131
|
+
return inProgress;
|
|
132
|
+
const review = tasks.find((t) => t.status === "review");
|
|
133
|
+
if (review)
|
|
134
|
+
return review;
|
|
135
|
+
const planned = tasks.find((t) => t.status === "planned");
|
|
136
|
+
return planned ?? null;
|
|
137
|
+
}
|
|
138
|
+
export async function findTaskFile(tasksDir, taskId) {
|
|
139
|
+
const all = await scanTasks(tasksDir);
|
|
140
|
+
const target = taskId.toUpperCase();
|
|
141
|
+
for (const t of all) {
|
|
142
|
+
if (t.id.toUpperCase() === target)
|
|
143
|
+
return t.filePath;
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
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
|
+
const TASK_FILENAME_RE = /^TASK-(\d+)(?:-(.+))?$/i;
|
|
179
|
+
export function parseTaskFilename(filePath) {
|
|
180
|
+
const stem = basename(filePath, extname(filePath));
|
|
181
|
+
const m = TASK_FILENAME_RE.exec(stem);
|
|
182
|
+
if (!m) {
|
|
183
|
+
return { id: stem.toUpperCase(), number: "000", slug: stem.toLowerCase() };
|
|
184
|
+
}
|
|
185
|
+
const number = m[1];
|
|
186
|
+
const tail = (m[2] ?? "").trim().toLowerCase();
|
|
187
|
+
const slug = tail.length > 0 ? `${number}-${tail}` : number;
|
|
188
|
+
return { id: `TASK-${number}`, number, slug };
|
|
189
|
+
}
|
|
190
|
+
export function branchNameForTaskFile(filePath) {
|
|
191
|
+
return `task/${parseTaskFilename(filePath).slug}`;
|
|
192
|
+
}
|
|
193
|
+
const HEADING_RE = /^(##+)\s+(.+?)\s*$/;
|
|
194
|
+
function locateSection(text, title, level = 2) {
|
|
195
|
+
const lines = text.split("\n");
|
|
196
|
+
const wantTitle = title.toLowerCase();
|
|
197
|
+
for (let i = 0; i < lines.length; i++) {
|
|
198
|
+
const m = HEADING_RE.exec(lines[i]);
|
|
199
|
+
if (!m)
|
|
200
|
+
continue;
|
|
201
|
+
if (m[1].length !== level)
|
|
202
|
+
continue;
|
|
203
|
+
if (m[2].trim().toLowerCase() !== wantTitle)
|
|
204
|
+
continue;
|
|
205
|
+
const start = i;
|
|
206
|
+
const contentStart = i + 1;
|
|
207
|
+
let j = i + 1;
|
|
208
|
+
while (j < lines.length) {
|
|
209
|
+
const nm = HEADING_RE.exec(lines[j]);
|
|
210
|
+
if (nm && nm[1].length <= level)
|
|
211
|
+
break;
|
|
212
|
+
j++;
|
|
213
|
+
}
|
|
214
|
+
return { start, end: j, contentStart, level, title: m[2].trim() };
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
export function readSection(body, title) {
|
|
219
|
+
const block = locateSection(body, title);
|
|
220
|
+
if (block === null)
|
|
221
|
+
return "";
|
|
222
|
+
const lines = body.split("\n").slice(block.contentStart, block.end);
|
|
223
|
+
// trim leading/trailing blank lines
|
|
224
|
+
while (lines.length > 0 && lines[0].trim().length === 0)
|
|
225
|
+
lines.shift();
|
|
226
|
+
while (lines.length > 0 && lines[lines.length - 1].trim().length === 0)
|
|
227
|
+
lines.pop();
|
|
228
|
+
return lines.join("\n");
|
|
229
|
+
}
|
|
230
|
+
export function isPlaceholderContent(content) {
|
|
231
|
+
const trimmed = content.trim();
|
|
232
|
+
if (trimmed.length === 0)
|
|
233
|
+
return true;
|
|
234
|
+
if (/^\(.*\uBBF8\uC218\uD589.*\)$/.test(trimmed))
|
|
235
|
+
return true; // legacy Korean placeholder
|
|
236
|
+
if (/^_*\(none\)_*$/i.test(trimmed))
|
|
237
|
+
return true;
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
export function hasNonEmptySection(body, title) {
|
|
241
|
+
return !isPlaceholderContent(readSection(body, title));
|
|
242
|
+
}
|
|
243
|
+
export function findExpectedFiles(body) {
|
|
244
|
+
const content = readSection(body, "Expected Files to Change");
|
|
245
|
+
if (content.length === 0)
|
|
246
|
+
return [];
|
|
247
|
+
const out = [];
|
|
248
|
+
for (const raw of content.split("\n")) {
|
|
249
|
+
const line = raw.trim();
|
|
250
|
+
if (!line.startsWith("- "))
|
|
251
|
+
continue;
|
|
252
|
+
let rest = line.slice(2).trim();
|
|
253
|
+
rest = rest.replace(/^(\uC2E0\uADDC|\uAC31\uC2E0|new|update|update:|\uC2E0\uADDC:|\uAC31\uC2E0:)\s*[::-]?\s*/i, ""); // matches both English and legacy Korean status prefixes
|
|
254
|
+
const tickMatch = rest.match(/`([^`]+)`/);
|
|
255
|
+
if (tickMatch) {
|
|
256
|
+
out.push(tickMatch[1].trim());
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
const candidate = rest.split(/[\s,]/)[0];
|
|
260
|
+
if (typeof candidate === "string" && candidate.length > 0 && /[./]/.test(candidate)) {
|
|
261
|
+
out.push(candidate);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
export function findAcceptanceCriteria(body) {
|
|
268
|
+
const content = readSection(body, "Acceptance Criteria");
|
|
269
|
+
if (content.length === 0)
|
|
270
|
+
return [];
|
|
271
|
+
const out = [];
|
|
272
|
+
for (const raw of content.split("\n")) {
|
|
273
|
+
const line = raw.trim();
|
|
274
|
+
const m = /^(\d+)\.\s+(.+)$/.exec(line);
|
|
275
|
+
if (m)
|
|
276
|
+
out.push(m[2].trim());
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
function replaceSectionContent(body, title, newContent) {
|
|
281
|
+
const lines = body.split("\n");
|
|
282
|
+
const block = locateSection(body, title);
|
|
283
|
+
if (block === null)
|
|
284
|
+
return body;
|
|
285
|
+
const before = lines.slice(0, block.contentStart);
|
|
286
|
+
const after = lines.slice(block.end);
|
|
287
|
+
const contentLines = ["", newContent.trim(), ""];
|
|
288
|
+
return [...before, ...contentLines, ...after].join("\n");
|
|
289
|
+
}
|
|
290
|
+
function insertSectionAfter(body, afterTitle, newTitle, newContent) {
|
|
291
|
+
const block = locateSection(body, afterTitle);
|
|
292
|
+
const lines = body.split("\n");
|
|
293
|
+
const newSection = ["", `## ${newTitle}`, "", newContent.trim(), ""];
|
|
294
|
+
if (block === null) {
|
|
295
|
+
return [...lines, ...newSection].join("\n");
|
|
296
|
+
}
|
|
297
|
+
const before = lines.slice(0, block.end);
|
|
298
|
+
const after = lines.slice(block.end);
|
|
299
|
+
return [...before, ...newSection, ...after].join("\n");
|
|
300
|
+
}
|
|
301
|
+
export async function updateInlineStatus(filePath, next) {
|
|
302
|
+
const raw = await readText(filePath);
|
|
303
|
+
const display = statusDisplay(next);
|
|
304
|
+
const updated = replaceSectionContent(raw, "Status", display);
|
|
305
|
+
if (updated !== raw) {
|
|
306
|
+
await writeText(filePath, updated);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function renderGitContextBlock(ctx) {
|
|
310
|
+
const lines = [
|
|
311
|
+
`- Base Branch: \`${ctx.baseBranch}\``,
|
|
312
|
+
`- Base Commit: \`${ctx.baseCommit}\``,
|
|
313
|
+
`- Task Branch: \`${ctx.taskBranch}\``,
|
|
314
|
+
`- Started At: \`${ctx.startedAt}\``,
|
|
315
|
+
];
|
|
316
|
+
if (typeof ctx.doneAt === "string" && ctx.doneAt.length > 0) {
|
|
317
|
+
lines.push(`- Done At: \`${ctx.doneAt}\``);
|
|
318
|
+
}
|
|
319
|
+
return lines.join("\n");
|
|
320
|
+
}
|
|
321
|
+
export async function upsertGitContext(filePath, ctx) {
|
|
322
|
+
const raw = await readText(filePath);
|
|
323
|
+
const block = renderGitContextBlock(ctx);
|
|
324
|
+
let updated;
|
|
325
|
+
if (locateSection(raw, "Git Context")) {
|
|
326
|
+
updated = replaceSectionContent(raw, "Git Context", block);
|
|
327
|
+
}
|
|
328
|
+
else if (locateSection(raw, "MVP Phase")) {
|
|
329
|
+
updated = insertSectionAfter(raw, "MVP Phase", "Git Context", block);
|
|
330
|
+
}
|
|
331
|
+
else if (locateSection(raw, "Status")) {
|
|
332
|
+
updated = insertSectionAfter(raw, "Status", "Git Context", block);
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
updated = `${raw.trimEnd()}\n\n## Git Context\n\n${block}\n`;
|
|
336
|
+
}
|
|
337
|
+
if (updated !== raw) {
|
|
338
|
+
await writeText(filePath, updated);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const GIT_CTX_RE = {
|
|
342
|
+
baseBranch: /^-\s+Base Branch:\s*`([^`]+)`/m,
|
|
343
|
+
baseCommit: /^-\s+Base Commit:\s*`([^`]+)`/m,
|
|
344
|
+
taskBranch: /^-\s+Task Branch:\s*`([^`]+)`/m,
|
|
345
|
+
startedAt: /^-\s+Started At:\s*`([^`]+)`/m,
|
|
346
|
+
doneAt: /^-\s+Done At:\s*`([^`]+)`/m,
|
|
347
|
+
};
|
|
348
|
+
export async function readGitContext(filePath) {
|
|
349
|
+
const raw = await readText(filePath);
|
|
350
|
+
const content = readSection(raw, "Git Context");
|
|
351
|
+
if (content.length === 0)
|
|
352
|
+
return null;
|
|
353
|
+
const baseBranch = content.match(GIT_CTX_RE.baseBranch)?.[1];
|
|
354
|
+
const baseCommit = content.match(GIT_CTX_RE.baseCommit)?.[1];
|
|
355
|
+
const taskBranch = content.match(GIT_CTX_RE.taskBranch)?.[1];
|
|
356
|
+
const startedAt = content.match(GIT_CTX_RE.startedAt)?.[1];
|
|
357
|
+
if (!baseBranch || !baseCommit || !taskBranch || !startedAt)
|
|
358
|
+
return null;
|
|
359
|
+
const doneAt = content.match(GIT_CTX_RE.doneAt)?.[1];
|
|
360
|
+
const ctx = { baseBranch, baseCommit, taskBranch, startedAt };
|
|
361
|
+
if (typeof doneAt === "string" && doneAt.length > 0)
|
|
362
|
+
ctx.doneAt = doneAt;
|
|
363
|
+
return ctx;
|
|
364
|
+
}
|