@goodtek/vibeops 0.4.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +198 -1
- package/README.md +62 -402
- package/dist/bootstrap/installer.js +4 -16
- package/dist/cli.js +52 -244
- package/dist/commands/init.js +3 -3
- package/dist/commands/status.js +145 -8
- package/dist/commands/task-add.js +100 -0
- package/dist/commands/task-done.js +117 -74
- package/dist/lib/config.js +2 -118
- package/dist/lib/git.js +190 -8
- package/dist/lib/llm-complete.js +62 -0
- package/dist/lib/paths.js +7 -3
- package/dist/lib/plan-llm-openai.js +0 -63
- package/dist/lib/resolve-task.js +34 -0
- package/dist/lib/slug.js +11 -0
- package/dist/lib/task-add-llm.js +81 -0
- package/dist/lib/task-context.js +68 -0
- package/dist/lib/task-done-llm.js +148 -0
- package/dist/lib/task-merge.js +115 -0
- package/dist/lib/task-scaffold.js +70 -92
- package/dist/lib/task-start.js +72 -0
- package/dist/lib/task-summary.js +12 -239
- package/dist/lib/task.js +80 -37
- package/dist/types/config.js +0 -16
- package/package.json +3 -4
- package/templates/.cursor/rules/01-task-source-of-truth.mdc +12 -0
- package/templates/.cursor/rules/02-git-safety.mdc +11 -0
- package/templates/.cursor/rules/03-docs-on-done.mdc +13 -0
- package/templates/.cursor/skills/implement-task/SKILL.md +22 -0
- package/templates/.cursor/skills/plan-task/SKILL.md +19 -0
- package/templates/AGENTS.md +26 -87
- package/templates/docs/logs/README.md +4 -35
- package/templates/docs/project/03-architecture.md +10 -29
- package/templates/docs/project/05-current-state.md +7 -27
- package/templates/docs/project/06-decisions.md +6 -17
- package/templates/docs/tasks/TASK-000-template.md +9 -44
- package/dist/agent/loader.js +0 -71
- package/dist/agent/prompt.js +0 -66
- package/dist/commands/agent-list.js +0 -32
- package/dist/commands/agent-prompt.js +0 -59
- package/dist/commands/agent-show.js +0 -26
- package/dist/commands/github-init.js +0 -554
- package/dist/commands/github-status.js +0 -164
- package/dist/commands/notion-init.js +0 -764
- package/dist/commands/notion-sync.js +0 -405
- package/dist/commands/notion-test.js +0 -595
- package/dist/commands/plan.js +0 -343
- package/dist/commands/task-check.js +0 -155
- package/dist/commands/task-generate.js +0 -206
- package/dist/commands/task-pull.js +0 -277
- package/dist/commands/task-rollback.js +0 -174
- package/dist/commands/task-start.js +0 -102
- package/dist/lib/brief.js +0 -356
- package/dist/lib/github-cli.js +0 -247
- package/dist/lib/notion-client.js +0 -459
- package/dist/lib/notion-discovery.js +0 -671
- package/dist/lib/notion-env.js +0 -140
- package/dist/lib/notion-mappers.js +0 -148
- package/dist/lib/notion-schema.js +0 -272
- package/dist/lib/notion-sync.js +0 -337
- package/dist/lib/notion-target.js +0 -247
- package/dist/lib/package-json.js +0 -133
- package/dist/lib/plan-apply-planner.js +0 -131
- package/dist/lib/plan-codex-pkce.js +0 -234
- package/dist/lib/plan-llm-models.js +0 -237
- package/dist/lib/plan-llm-normalize.js +0 -146
- package/dist/lib/plan-llm-session.js +0 -682
- package/dist/lib/plan-post-apply-setup.js +0 -116
- package/dist/lib/project-docs.js +0 -95
- package/dist/lib/prompt-builder.js +0 -135
- package/dist/lib/task-generator.js +0 -183
- package/dist/lib/task-prompt.js +0 -23
- package/dist/lib/task-pull.js +0 -354
- package/dist/status/collector.js +0 -103
- package/dist/status/format.js +0 -177
- package/dist/types/brief.js +0 -126
- package/templates/.cursor/rules/00-project-governance.mdc +0 -28
- package/templates/.cursor/rules/01-agent-orchestration.mdc +0 -48
- package/templates/.cursor/rules/02-task-workflow.mdc +0 -38
- package/templates/.cursor/rules/03-git-safety.mdc +0 -30
- package/templates/.cursor/rules/04-docs-update.mdc +0 -22
- package/templates/.vibeops/agents/architect.md +0 -47
- package/templates/.vibeops/agents/builder.md +0 -38
- package/templates/.vibeops/agents/docs.md +0 -54
- package/templates/.vibeops/agents/orchestrator.md +0 -40
- package/templates/.vibeops/agents/planner.md +0 -60
- package/templates/.vibeops/agents/recovery.md +0 -49
- package/templates/.vibeops/agents/reviewer.md +0 -47
- package/templates/.vibeops/agents/tester.md +0 -43
- package/templates/.vibeops/prompts/create-plan.md +0 -33
- package/templates/.vibeops/prompts/generate-tasks.md +0 -41
- package/templates/.vibeops/prompts/implement-task.md +0 -39
- package/templates/.vibeops/prompts/review-task.md +0 -34
- package/templates/.vibeops/prompts/rollback.md +0 -32
- package/templates/.vibeops/prompts/start-project.md +0 -39
- package/templates/.vibeops/workflows/notion-sync.md +0 -53
- package/templates/.vibeops/workflows/project-start.md +0 -73
- package/templates/.vibeops/workflows/rollback.md +0 -45
- package/templates/.vibeops/workflows/task-lifecycle.md +0 -71
- package/templates/docs/project/00-overview.md +0 -27
- package/templates/docs/project/01-requirements.md +0 -30
- package/templates/docs/project/02-mvp-scope.md +0 -36
- package/templates/docs/project/04-tech-stack.md +0 -29
- package/templates/docs/project/07-backlog.md +0 -23
- package/templates/docs/project/08-env.md +0 -29
- package/templates/docs/project/09-deployment.md +0 -28
|
@@ -1,107 +1,59 @@
|
|
|
1
1
|
import { basename, join } from "node:path";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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 const V3_TASK_SECTIONS = [
|
|
4
|
+
"Status",
|
|
5
|
+
"Goal",
|
|
6
|
+
"Scope",
|
|
7
|
+
"Out of Scope",
|
|
8
|
+
"Acceptance Criteria",
|
|
9
|
+
"Test Plan",
|
|
10
|
+
"Git Context",
|
|
11
|
+
"Result",
|
|
12
|
+
"Test Result",
|
|
13
|
+
];
|
|
14
|
+
export function allocateNextTaskNumber(tasks) {
|
|
15
|
+
let max = 0;
|
|
16
|
+
for (const t of tasks) {
|
|
17
|
+
const m = /^TASK-(\d+)$/i.exec(t.id.trim());
|
|
18
|
+
if (m)
|
|
19
|
+
max = Math.max(max, Number.parseInt(m[1], 10));
|
|
35
20
|
}
|
|
36
|
-
return
|
|
21
|
+
return max + 1;
|
|
37
22
|
}
|
|
38
|
-
export function
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
|
|
23
|
+
export function formatTaskId(number) {
|
|
24
|
+
return `TASK-${String(number).padStart(3, "0")}`;
|
|
25
|
+
}
|
|
26
|
+
export function buildMinimalTaskMarkdown(draft) {
|
|
27
|
+
return `# ${draft.id}: ${draft.title}
|
|
42
28
|
|
|
43
29
|
## Status
|
|
44
30
|
|
|
45
|
-
planned
|
|
46
|
-
|
|
47
|
-
## MVP Phase
|
|
48
|
-
|
|
49
|
-
${entry.phase}
|
|
31
|
+
${statusDisplay("planned")}
|
|
50
32
|
|
|
51
33
|
## Goal
|
|
52
34
|
|
|
53
|
-
(
|
|
54
|
-
|
|
55
|
-
## Background
|
|
56
|
-
|
|
57
|
-
(scaffold — why now, which earlier TASKs or decisions this builds on.)
|
|
35
|
+
${draft.idea.trim()}
|
|
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 refactors and features outside this slice.
|
|
67
44
|
|
|
68
45
|
## Acceptance Criteria
|
|
69
46
|
|
|
70
|
-
1.
|
|
71
|
-
2.
|
|
72
|
-
|
|
73
|
-
## Files to Inspect First
|
|
74
|
-
|
|
75
|
-
- (scaffold)
|
|
76
|
-
|
|
77
|
-
## Expected Files to Change
|
|
78
|
-
|
|
79
|
-
- new: (scaffold)
|
|
80
|
-
- update: (scaffold)
|
|
81
|
-
|
|
82
|
-
## Risks
|
|
83
|
-
|
|
84
|
-
- (scaffold)
|
|
47
|
+
1. Goal and Scope are met and verifiable via Test Plan.
|
|
48
|
+
2. Result and Test Result are filled before \`vibeops task done\`.
|
|
85
49
|
|
|
86
50
|
## Test Plan
|
|
87
51
|
|
|
88
|
-
-
|
|
89
|
-
|
|
90
|
-
## Rollback Plan
|
|
91
|
-
|
|
92
|
-
- (scaffold — branch deletion or another recovery flow)
|
|
52
|
+
- Project-specific checks for this change
|
|
93
53
|
|
|
94
54
|
## Git Context
|
|
95
55
|
|
|
96
|
-
(populated by \`vibeops task
|
|
97
|
-
|
|
98
|
-
## Notion Page
|
|
99
|
-
|
|
100
|
-
(populated by \`vibeops notion sync\`)
|
|
101
|
-
|
|
102
|
-
## Implementation Plan
|
|
103
|
-
|
|
104
|
-
1. (scaffold)
|
|
56
|
+
(populated by \`vibeops task add\`)
|
|
105
57
|
|
|
106
58
|
## Result
|
|
107
59
|
|
|
@@ -109,20 +61,46 @@ ${entry.phase}
|
|
|
109
61
|
|
|
110
62
|
## Test Result
|
|
111
63
|
|
|
112
|
-
(not yet)
|
|
113
|
-
|
|
114
|
-
## Review Notes
|
|
115
|
-
|
|
116
64
|
(not yet)
|
|
117
65
|
`;
|
|
118
66
|
}
|
|
119
|
-
export
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
67
|
+
export function taskFilename(taskId, slug) {
|
|
68
|
+
return `${taskId}-${slug}.md`;
|
|
69
|
+
}
|
|
70
|
+
export function uniqueTaskPath(tasksDir, taskId, baseSlug, existingPaths) {
|
|
71
|
+
const names = new Set(existingPaths.map((p) => basename(p)));
|
|
72
|
+
let slug = baseSlug;
|
|
73
|
+
let name = taskFilename(taskId, slug);
|
|
74
|
+
let n = 2;
|
|
75
|
+
while (names.has(name)) {
|
|
76
|
+
slug = `${baseSlug}-${n}`;
|
|
77
|
+
name = taskFilename(taskId, slug);
|
|
78
|
+
n++;
|
|
79
|
+
}
|
|
80
|
+
return { slug, filePath: join(tasksDir, name) };
|
|
81
|
+
}
|
|
82
|
+
export function titleFromIdea(idea) {
|
|
83
|
+
const oneLine = idea.replace(/\s+/g, " ").trim();
|
|
84
|
+
if (oneLine.length <= 80)
|
|
85
|
+
return oneLine;
|
|
86
|
+
return `${oneLine.slice(0, 77).trimEnd()}...`;
|
|
87
|
+
}
|
|
88
|
+
export function ensureV3Sections(markdown) {
|
|
89
|
+
let body = markdown;
|
|
90
|
+
for (const section of V3_TASK_SECTIONS) {
|
|
91
|
+
const re = new RegExp(`^##\\s+${section.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "im");
|
|
92
|
+
if (!re.test(body)) {
|
|
93
|
+
body = `${body.trimEnd()}\n\n## ${section}\n\n(not yet)\n`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return body;
|
|
97
|
+
}
|
|
98
|
+
export function normalizeTaskHeader(markdown, taskId, title) {
|
|
99
|
+
const lines = markdown.split("\n");
|
|
100
|
+
const header = `# ${taskId}: ${title}`;
|
|
101
|
+
if (lines.length > 0 && /^#\s+/.test(lines[0])) {
|
|
102
|
+
lines[0] = header;
|
|
103
|
+
return lines.join("\n");
|
|
126
104
|
}
|
|
127
|
-
return
|
|
105
|
+
return `${header}\n\n${markdown}`;
|
|
128
106
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { relative, resolve } from "node:path";
|
|
2
|
+
import { branchNameForTaskFile, readGitContext, readTaskFile, updateInlineStatus, upsertGitContext, } from "./task.js";
|
|
3
|
+
import { detectDefaultBranch, gitBranchExists, gitCheckout, gitCheckoutNewBranch, gitGovernanceOnlyDirty, gitHeadCommit, readGitInfo, } from "./git.js";
|
|
4
|
+
import { cyan, dim, log } from "./logger.js";
|
|
5
|
+
function relOrAbs(root, p) {
|
|
6
|
+
const r = relative(root, p);
|
|
7
|
+
return r === "" ? "." : r.startsWith("..") ? p : r;
|
|
8
|
+
}
|
|
9
|
+
/** Create or resume task branch and mark TASK In Progress (used by `task add`). */
|
|
10
|
+
export async function startTaskBranch(opts) {
|
|
11
|
+
const cwd = resolve(opts.cwd);
|
|
12
|
+
const taskFile = opts.taskFile;
|
|
13
|
+
const meta = await readTaskFile(taskFile);
|
|
14
|
+
const git = await readGitInfo(cwd);
|
|
15
|
+
if (!git.isRepo) {
|
|
16
|
+
if (opts.dryRun) {
|
|
17
|
+
log.info(dim("dry-run — would require a git repository"));
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
log.error("Not a git repository. Run `vibeops init --git` first.");
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
if (git.dirty === true && opts.allowDirty !== true) {
|
|
24
|
+
const gov = await gitGovernanceOnlyDirty(cwd);
|
|
25
|
+
if (!gov.onlyGovernance) {
|
|
26
|
+
log.error("Git working tree is dirty. Commit or stash, or rerun with --allow-dirty.");
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
log.warn("Only governance paths are dirty — proceeding.");
|
|
30
|
+
}
|
|
31
|
+
const baseBranch = git.branch ?? (await detectDefaultBranch(cwd)) ?? "main";
|
|
32
|
+
const baseCommit = (await gitHeadCommit(cwd)) ?? "";
|
|
33
|
+
if (baseCommit.length === 0) {
|
|
34
|
+
log.error("No commits on HEAD yet. Create an initial commit first.");
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
const taskBranch = branchNameForTaskFile(taskFile);
|
|
38
|
+
const ctx = {
|
|
39
|
+
baseBranch,
|
|
40
|
+
baseCommit,
|
|
41
|
+
taskBranch,
|
|
42
|
+
startedAt: new Date().toISOString(),
|
|
43
|
+
};
|
|
44
|
+
const existingCtx = await readGitContext(taskFile);
|
|
45
|
+
const branchExists = await gitBranchExists(cwd, taskBranch);
|
|
46
|
+
log.info(` ${dim("task branch")} ${cyan(taskBranch)}`);
|
|
47
|
+
log.info(` ${dim("base")} ${baseBranch} @ ${baseCommit.slice(0, 7)}`);
|
|
48
|
+
if (opts.dryRun) {
|
|
49
|
+
log.info(dim(branchExists
|
|
50
|
+
? `dry-run — would git switch ${taskBranch}`
|
|
51
|
+
: `dry-run — would git switch -c ${taskBranch}`));
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
if (branchExists) {
|
|
55
|
+
await gitCheckout(cwd, taskBranch);
|
|
56
|
+
if (meta.status === "planned") {
|
|
57
|
+
await updateInlineStatus(taskFile, "in_progress");
|
|
58
|
+
}
|
|
59
|
+
if (existingCtx === null) {
|
|
60
|
+
await upsertGitContext(taskFile, ctx);
|
|
61
|
+
}
|
|
62
|
+
log.ok(`Resumed ${taskBranch}`);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
await gitCheckoutNewBranch(cwd, taskBranch, baseBranch);
|
|
66
|
+
await updateInlineStatus(taskFile, "in_progress");
|
|
67
|
+
await upsertGitContext(taskFile, ctx);
|
|
68
|
+
log.ok(`Branch ${taskBranch} ready`);
|
|
69
|
+
}
|
|
70
|
+
log.info(` ${dim("file")} ${relOrAbs(cwd, taskFile)}`);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
package/dist/lib/task-summary.js
CHANGED
|
@@ -1,27 +1,12 @@
|
|
|
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
1
|
import { isPlaceholderContent, readSection } from "./task.js";
|
|
18
|
-
|
|
19
|
-
const PLACEHOLDER_RE = new RegExp(String.raw `^\(.*(not yet|fill in|unassigned|scaffold|\uBBF8\uC218\uD589|\uBBF8\uC815|\uCC44\uC6CC\uB77C).*\)$`, "i");
|
|
2
|
+
const PLACEHOLDER_RE = new RegExp(String.raw `^\(.*(not yet|fill in|unassigned|scaffold|\uBBF8\uC218\uD589).*\)$`, "i");
|
|
20
3
|
function stripBullet(line) {
|
|
21
4
|
return line.replace(/^\s*(?:[-*+]|\d+\.)\s+/, "").trim();
|
|
22
5
|
}
|
|
23
|
-
function
|
|
24
|
-
|
|
6
|
+
function truncate(text, limit) {
|
|
7
|
+
if (text.length <= limit)
|
|
8
|
+
return text;
|
|
9
|
+
return `${text.slice(0, limit - 1)}…`;
|
|
25
10
|
}
|
|
26
11
|
function compressBlank(lines) {
|
|
27
12
|
const out = [];
|
|
@@ -39,19 +24,12 @@ function compressBlank(lines) {
|
|
|
39
24
|
}
|
|
40
25
|
return out.join("\n").trim();
|
|
41
26
|
}
|
|
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) {
|
|
27
|
+
export function summarizeSection(body, title, limit = 280) {
|
|
47
28
|
const raw = readSection(body, title);
|
|
48
|
-
if (raw.length === 0)
|
|
29
|
+
if (raw.length === 0 || isPlaceholderContent(raw))
|
|
49
30
|
return "";
|
|
50
|
-
if (isPlaceholderContent(raw))
|
|
51
|
-
return "";
|
|
52
|
-
const lines = raw.split("\n");
|
|
53
31
|
const cleaned = [];
|
|
54
|
-
for (const line of
|
|
32
|
+
for (const line of raw.split("\n")) {
|
|
55
33
|
const t = line.trim();
|
|
56
34
|
if (t.length === 0) {
|
|
57
35
|
cleaned.push("");
|
|
@@ -59,218 +37,13 @@ export function summarizeSection(body, title, limit) {
|
|
|
59
37
|
}
|
|
60
38
|
if (PLACEHOLDER_RE.test(t))
|
|
61
39
|
continue;
|
|
62
|
-
if (
|
|
40
|
+
if (/^#{1,6}\s+/.test(t))
|
|
63
41
|
continue;
|
|
64
42
|
cleaned.push(stripBullet(t));
|
|
65
43
|
}
|
|
66
|
-
|
|
67
|
-
return truncate(compact, limit);
|
|
44
|
+
return truncate(compressBlank(cleaned), limit);
|
|
68
45
|
}
|
|
69
|
-
export function summarizeGoal(body, limit) {
|
|
46
|
+
export function summarizeGoal(body, limit = 280) {
|
|
70
47
|
const goal = summarizeSection(body, "Goal", limit);
|
|
71
|
-
|
|
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
|
-
`;
|
|
48
|
+
return goal.length > 0 ? goal : summarizeSection(body, "Scope", limit);
|
|
276
49
|
}
|