@goodtek/vibeops 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +444 -0
  4. package/dist/agent/loader.js +71 -0
  5. package/dist/agent/prompt.js +66 -0
  6. package/dist/bootstrap/installer.js +149 -0
  7. package/dist/bootstrap/manifest.js +15 -0
  8. package/dist/bootstrap/substitute.js +35 -0
  9. package/dist/cli.js +241 -0
  10. package/dist/commands/agent-list.js +32 -0
  11. package/dist/commands/agent-prompt.js +59 -0
  12. package/dist/commands/agent-show.js +26 -0
  13. package/dist/commands/github-init.js +554 -0
  14. package/dist/commands/github-status.js +164 -0
  15. package/dist/commands/init.js +179 -0
  16. package/dist/commands/notion-init.js +764 -0
  17. package/dist/commands/notion-sync.js +405 -0
  18. package/dist/commands/notion-test.js +595 -0
  19. package/dist/commands/plan.js +114 -0
  20. package/dist/commands/status.js +17 -0
  21. package/dist/commands/task-check.js +155 -0
  22. package/dist/commands/task-done.js +98 -0
  23. package/dist/commands/task-generate.js +206 -0
  24. package/dist/commands/task-pull.js +277 -0
  25. package/dist/commands/task-rollback.js +174 -0
  26. package/dist/commands/task-start.js +90 -0
  27. package/dist/lib/brief.js +349 -0
  28. package/dist/lib/config.js +158 -0
  29. package/dist/lib/filesystem.js +67 -0
  30. package/dist/lib/git.js +237 -0
  31. package/dist/lib/github-cli.js +247 -0
  32. package/dist/lib/inquirer-helpers.js +111 -0
  33. package/dist/lib/logger.js +42 -0
  34. package/dist/lib/notion-client.js +459 -0
  35. package/dist/lib/notion-discovery.js +671 -0
  36. package/dist/lib/notion-env.js +140 -0
  37. package/dist/lib/notion-mappers.js +148 -0
  38. package/dist/lib/notion-schema.js +272 -0
  39. package/dist/lib/notion-sync.js +337 -0
  40. package/dist/lib/notion-target.js +247 -0
  41. package/dist/lib/package-json.js +133 -0
  42. package/dist/lib/paths.js +26 -0
  43. package/dist/lib/project-docs.js +95 -0
  44. package/dist/lib/prompt-builder.js +125 -0
  45. package/dist/lib/task-generator.js +183 -0
  46. package/dist/lib/task-prompt.js +23 -0
  47. package/dist/lib/task-pull.js +354 -0
  48. package/dist/lib/task-scaffold.js +128 -0
  49. package/dist/lib/task-summary.js +276 -0
  50. package/dist/lib/task.js +364 -0
  51. package/dist/status/collector.js +103 -0
  52. package/dist/status/format.js +177 -0
  53. package/dist/types/brief.js +126 -0
  54. package/dist/types/config.js +17 -0
  55. package/dist/types/task.js +1 -0
  56. package/dist/version.js +8 -0
  57. package/package.json +61 -0
  58. package/templates/.cursor/rules/00-project-governance.mdc +28 -0
  59. package/templates/.cursor/rules/01-agent-orchestration.mdc +48 -0
  60. package/templates/.cursor/rules/02-task-workflow.mdc +38 -0
  61. package/templates/.cursor/rules/03-git-safety.mdc +30 -0
  62. package/templates/.cursor/rules/04-docs-update.mdc +22 -0
  63. package/templates/.vibeops/agents/architect.md +47 -0
  64. package/templates/.vibeops/agents/builder.md +38 -0
  65. package/templates/.vibeops/agents/docs.md +54 -0
  66. package/templates/.vibeops/agents/orchestrator.md +40 -0
  67. package/templates/.vibeops/agents/planner.md +60 -0
  68. package/templates/.vibeops/agents/recovery.md +49 -0
  69. package/templates/.vibeops/agents/reviewer.md +47 -0
  70. package/templates/.vibeops/agents/tester.md +43 -0
  71. package/templates/.vibeops/prompts/create-plan.md +33 -0
  72. package/templates/.vibeops/prompts/generate-tasks.md +41 -0
  73. package/templates/.vibeops/prompts/implement-task.md +39 -0
  74. package/templates/.vibeops/prompts/review-task.md +34 -0
  75. package/templates/.vibeops/prompts/rollback.md +32 -0
  76. package/templates/.vibeops/prompts/start-project.md +39 -0
  77. package/templates/.vibeops/workflows/notion-sync.md +53 -0
  78. package/templates/.vibeops/workflows/project-start.md +73 -0
  79. package/templates/.vibeops/workflows/rollback.md +45 -0
  80. package/templates/.vibeops/workflows/task-lifecycle.md +71 -0
  81. package/templates/AGENTS.md +98 -0
  82. package/templates/docs/logs/README.md +38 -0
  83. package/templates/docs/project/00-overview.md +27 -0
  84. package/templates/docs/project/01-requirements.md +30 -0
  85. package/templates/docs/project/02-mvp-scope.md +36 -0
  86. package/templates/docs/project/03-architecture.md +34 -0
  87. package/templates/docs/project/04-tech-stack.md +29 -0
  88. package/templates/docs/project/05-current-state.md +35 -0
  89. package/templates/docs/project/06-decisions.md +20 -0
  90. package/templates/docs/project/07-backlog.md +23 -0
  91. package/templates/docs/project/08-env.md +29 -0
  92. package/templates/docs/project/09-deployment.md +28 -0
  93. package/templates/docs/tasks/TASK-000-template.md +72 -0
@@ -0,0 +1,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
+ }
@@ -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
+ }