@goodtek/vibeops 0.7.22 → 1.1.1
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 +51 -0
- package/README.md +51 -380
- package/dist/bootstrap/installer.js +42 -20
- package/dist/bootstrap/manifest.js +23 -3
- package/dist/cli.js +69 -154
- package/dist/commands/init.js +279 -114
- package/dist/commands/llm.js +177 -0
- package/dist/commands/status.js +181 -8
- package/dist/commands/task-add.js +89 -162
- package/dist/commands/task-done.js +112 -73
- package/dist/lib/config.js +77 -113
- package/dist/lib/env.js +32 -0
- package/dist/lib/git-config.js +21 -0
- package/dist/lib/git-host.js +12 -0
- package/dist/lib/git-policy.js +67 -0
- package/dist/lib/git-remote.js +108 -0
- package/dist/lib/git.js +44 -1
- package/dist/lib/init-clients.js +55 -0
- package/dist/lib/llm-complete.js +18 -19
- package/dist/lib/llm-connect.js +150 -0
- package/dist/lib/llm-preference.js +102 -0
- package/dist/lib/llm-status.js +19 -0
- package/dist/lib/paths.js +7 -3
- package/dist/lib/plan-llm-openai.js +0 -63
- package/dist/lib/pr-create.js +66 -0
- package/dist/lib/resolve-task.js +34 -0
- package/dist/lib/task-add-llm.js +53 -233
- package/dist/lib/task-context.js +68 -0
- package/dist/lib/task-done-llm.js +155 -0
- package/dist/lib/task-pr-llm.js +50 -0
- package/dist/lib/task-push-pr.js +131 -0
- package/dist/lib/task-scaffold.js +39 -43
- package/dist/lib/task-start.js +100 -0
- package/dist/lib/task-summary.js +12 -239
- package/dist/lib/task.js +30 -24
- package/dist/types/config.js +0 -16
- package/package.json +14 -14
- package/templates/.cursor/rules/02-git-safety.mdc +12 -0
- package/templates/AGENTS.md +27 -87
- package/templates/clients/claude/CLAUDE.md +28 -0
- package/templates/clients/cursor/.cursor/rules/01-task-source-of-truth.mdc +12 -0
- package/templates/clients/cursor/.cursor/rules/02-git-safety.mdc +11 -0
- package/templates/clients/cursor/.cursor/rules/03-docs-on-done.mdc +13 -0
- package/templates/core/AGENTS.md +44 -0
- package/templates/core/docs/logs/README.md +7 -0
- package/templates/core/docs/project/03-architecture.md +15 -0
- package/templates/core/docs/project/05-current-state.md +15 -0
- package/templates/core/docs/project/06-decisions.md +9 -0
- package/templates/core/docs/tasks/TASK-000-template.md +37 -0
- package/templates/skills-shared/implement-task/SKILL.md +22 -0
- package/templates/skills-shared/plan-task/SKILL.md +19 -0
- 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/done.js +0 -160
- package/dist/commands/github-init.js +0 -554
- package/dist/commands/github-status.js +0 -164
- package/dist/commands/next.js +0 -283
- 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 -146
- package/dist/commands/rollback.js +0 -126
- package/dist/commands/start.js +0 -129
- 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/commands/task-status.js +0 -122
- package/dist/lib/brief.js +0 -356
- package/dist/lib/cursor-task-prompt.js +0 -60
- package/dist/lib/done-follow-up.js +0 -308
- package/dist/lib/github-cli.js +0 -247
- package/dist/lib/guide-state.js +0 -69
- package/dist/lib/mvp-artifacts.js +0 -204
- package/dist/lib/mvp-constants.js +0 -15
- package/dist/lib/mvp-diff-summary.js +0 -43
- package/dist/lib/mvp-done-summary.js +0 -335
- 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/resolve-command-task.js +0 -40
- package/dist/lib/task-add-build-prompt.js +0 -39
- package/dist/lib/task-add-cursor.js +0 -81
- package/dist/lib/task-generator.js +0 -183
- package/dist/lib/task-merge.js +0 -220
- package/dist/lib/task-prompt.js +0 -23
- package/dist/lib/task-pull.js +0 -354
- package/dist/lib/task-status-brief.js +0 -132
- package/dist/lib/workflow-guide.js +0 -485
- package/dist/status/collector.js +0 -103
- package/dist/status/format.js +0 -181
- 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/logs/README.md +0 -38
- 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/03-architecture.md +0 -34
- package/templates/docs/project/04-tech-stack.md +0 -29
- package/templates/docs/project/05-current-state.md +0 -35
- package/templates/docs/project/06-decisions.md +0 -20
- 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
- package/templates/docs/tasks/TASK-000-template.md +0 -72
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { llmCompleteJson } from "./llm-complete.js";
|
|
2
|
+
const SYSTEM = `You write pull/merge request titles and bodies for a software task.
|
|
3
|
+
Output valid JSON only: { "title": string, "body": string }.
|
|
4
|
+
Title: under 72 chars, include task id.
|
|
5
|
+
Body: markdown with Summary, Changes, Test plan sections. Facts only from the input.`;
|
|
6
|
+
export async function generateTaskPrWithLlm(input, cwd, preference) {
|
|
7
|
+
const user = [
|
|
8
|
+
`Task: ${input.taskId}`,
|
|
9
|
+
`Title: ${input.title}`,
|
|
10
|
+
`Base branch: ${input.baseBranch}`,
|
|
11
|
+
`Head branch: ${input.headBranch}`,
|
|
12
|
+
"",
|
|
13
|
+
"## TASK",
|
|
14
|
+
input.taskBody.slice(0, 12000),
|
|
15
|
+
"",
|
|
16
|
+
"## Diff summary",
|
|
17
|
+
input.diffSummary.slice(0, 8000) || "(no diff captured)",
|
|
18
|
+
].join("\n");
|
|
19
|
+
const messages = [
|
|
20
|
+
{ role: "system", content: SYSTEM },
|
|
21
|
+
{ role: "user", content: user },
|
|
22
|
+
];
|
|
23
|
+
try {
|
|
24
|
+
const { text: raw } = await llmCompleteJson(messages, { cwd, preference });
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
const prTitle = typeof parsed.title === "string" ? parsed.title.trim() : "";
|
|
27
|
+
const prBody = typeof parsed.body === "string" ? parsed.body.trim() : "";
|
|
28
|
+
if (!prTitle || !prBody)
|
|
29
|
+
return null;
|
|
30
|
+
return { prTitle, prBody };
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function fallbackTaskPr(input) {
|
|
37
|
+
const prTitle = `${input.taskId}: ${input.title}`.slice(0, 72);
|
|
38
|
+
const prBody = [
|
|
39
|
+
"## Summary",
|
|
40
|
+
input.title,
|
|
41
|
+
"",
|
|
42
|
+
"## Changes",
|
|
43
|
+
input.diffSummary || "(see commits on branch)",
|
|
44
|
+
"",
|
|
45
|
+
"## Test plan",
|
|
46
|
+
"- [ ] Run project test/lint commands",
|
|
47
|
+
"- [ ] Review TASK acceptance criteria",
|
|
48
|
+
].join("\n");
|
|
49
|
+
return { prTitle, prBody };
|
|
50
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { readText } from "./filesystem.js";
|
|
2
|
+
import { readConfig } from "./config.js";
|
|
3
|
+
import { detectGitHost, mergeRequestLabel } from "./git-host.js";
|
|
4
|
+
import { requireGitConfig } from "./git-config.js";
|
|
5
|
+
import { gitCommitsAhead, gitDiffNameOnly, gitLogOneline, gitPush, gitRemoteUrl, readGitInfo, } from "./git.js";
|
|
6
|
+
import { dim, log } from "./logger.js";
|
|
7
|
+
import { createMergeRequest, probeMergeRequestCli } from "./pr-create.js";
|
|
8
|
+
import { fallbackTaskPr, generateTaskPrWithLlm } from "./task-pr-llm.js";
|
|
9
|
+
import { readGitContext, readTaskFile, upsertGitContext } from "./task.js";
|
|
10
|
+
async function buildDiffSummary(cwd, gitCtx) {
|
|
11
|
+
const files = await gitDiffNameOnly(cwd, `${gitCtx.baseCommit}..HEAD`);
|
|
12
|
+
const commits = await gitLogOneline(cwd, `${gitCtx.baseCommit}..HEAD`);
|
|
13
|
+
const lines = [];
|
|
14
|
+
if (commits.length > 0) {
|
|
15
|
+
lines.push("Commits:");
|
|
16
|
+
for (const c of commits.slice(0, 20)) {
|
|
17
|
+
lines.push(`- ${c.sha} ${c.message}`);
|
|
18
|
+
}
|
|
19
|
+
if (commits.length > 20)
|
|
20
|
+
lines.push(`- … and ${commits.length - 20} more`);
|
|
21
|
+
}
|
|
22
|
+
if (files.length > 0) {
|
|
23
|
+
lines.push("", "Files:");
|
|
24
|
+
for (const f of files.slice(0, 40))
|
|
25
|
+
lines.push(`- ${f}`);
|
|
26
|
+
if (files.length > 40)
|
|
27
|
+
lines.push(`- … and ${files.length - 40} more`);
|
|
28
|
+
}
|
|
29
|
+
return lines.join("\n") || "(no commits since task start)";
|
|
30
|
+
}
|
|
31
|
+
function isExistingMergeRequestUrl(url) {
|
|
32
|
+
if (typeof url !== "string" || url.length === 0)
|
|
33
|
+
return false;
|
|
34
|
+
return url.startsWith("http://") || url.startsWith("https://");
|
|
35
|
+
}
|
|
36
|
+
/** Push task branch and open MR/PR (idempotent when URL already in Git Context). */
|
|
37
|
+
export async function finishTaskWithPullRequest(opts) {
|
|
38
|
+
let gitCfg = await requireGitConfig(opts.cwd);
|
|
39
|
+
const gitCtx = await readGitContext(opts.taskFile);
|
|
40
|
+
if (gitCtx === null) {
|
|
41
|
+
log.warn("No Git Context on TASK — push and merge request skipped.");
|
|
42
|
+
return { ok: true };
|
|
43
|
+
}
|
|
44
|
+
if (opts.dryRun) {
|
|
45
|
+
log.info(dim(`would git push -u ${gitCfg.remote} ${gitCtx.taskBranch} and open ${mergeRequestLabel(gitCfg.host)} → ${gitCtx.baseBranch}`));
|
|
46
|
+
return { ok: true, mergeRequestUrl: "(dry-run)" };
|
|
47
|
+
}
|
|
48
|
+
const remoteUrl = await gitRemoteUrl(opts.cwd, gitCfg.remote);
|
|
49
|
+
if (!remoteUrl) {
|
|
50
|
+
log.error(`Remote "${gitCfg.remote}" is not configured. Add it or re-run vibeops init.`);
|
|
51
|
+
return { ok: false };
|
|
52
|
+
}
|
|
53
|
+
const detectedHost = detectGitHost(remoteUrl);
|
|
54
|
+
if (detectedHost !== null && detectedHost !== gitCfg.host) {
|
|
55
|
+
log.warn(`Remote host (${detectedHost}) differs from .vibeops.json (${gitCfg.host}). Using ${detectedHost} for MR/PR.`);
|
|
56
|
+
gitCfg = { ...gitCfg, host: detectedHost };
|
|
57
|
+
}
|
|
58
|
+
const git = await readGitInfo(opts.cwd);
|
|
59
|
+
if (git.branch !== gitCtx.taskBranch) {
|
|
60
|
+
log.warn(`Current branch is ${git.branch ?? "(detached)"}, not ${gitCtx.taskBranch}. Push may fail.`);
|
|
61
|
+
}
|
|
62
|
+
if (isExistingMergeRequestUrl(gitCtx.mergeRequestUrl)) {
|
|
63
|
+
log.info(dim(`Merge request already recorded: ${gitCtx.mergeRequestUrl}`));
|
|
64
|
+
return { ok: true, mergeRequestUrl: gitCtx.mergeRequestUrl, pushed: true };
|
|
65
|
+
}
|
|
66
|
+
const ahead = await gitCommitsAhead(opts.cwd, gitCtx.baseCommit, "HEAD");
|
|
67
|
+
if (ahead === 0) {
|
|
68
|
+
log.warn("No commits ahead of base — pushing branch anyway.");
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
await gitPush(opts.cwd, gitCfg.remote, gitCtx.taskBranch, true);
|
|
72
|
+
log.ok(`Pushed ${gitCtx.taskBranch} → ${gitCfg.remote}`);
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
76
|
+
log.error(`git push failed: ${msg}`);
|
|
77
|
+
return { ok: false };
|
|
78
|
+
}
|
|
79
|
+
const pushedAt = new Date().toISOString();
|
|
80
|
+
if (opts.skipPr === true) {
|
|
81
|
+
await upsertGitContext(opts.taskFile, { ...gitCtx, pushedAt });
|
|
82
|
+
log.info(dim("Merge request creation skipped (--no-pr)."));
|
|
83
|
+
return { ok: true, pushed: true };
|
|
84
|
+
}
|
|
85
|
+
const cliOk = await probeMergeRequestCli(gitCfg.host);
|
|
86
|
+
if (!cliOk) {
|
|
87
|
+
const tool = gitCfg.host === "gitlab" ? "glab" : "gh";
|
|
88
|
+
await upsertGitContext(opts.taskFile, { ...gitCtx, pushedAt });
|
|
89
|
+
log.warn(`${tool} CLI not found — create the ${mergeRequestLabel(gitCfg.host)} manually: ${gitCtx.taskBranch} → ${gitCtx.baseBranch}`);
|
|
90
|
+
return { ok: true, pushed: true };
|
|
91
|
+
}
|
|
92
|
+
const config = await readConfig(opts.cwd);
|
|
93
|
+
const preference = config?.llm?.provider ?? "auto";
|
|
94
|
+
const meta = await readTaskFile(opts.taskFile);
|
|
95
|
+
const taskBody = await readText(opts.taskFile);
|
|
96
|
+
const diffSummary = await buildDiffSummary(opts.cwd, gitCtx);
|
|
97
|
+
const prInput = {
|
|
98
|
+
taskId: meta.id,
|
|
99
|
+
title: meta.title,
|
|
100
|
+
taskBody,
|
|
101
|
+
diffSummary,
|
|
102
|
+
baseBranch: gitCtx.baseBranch,
|
|
103
|
+
headBranch: gitCtx.taskBranch,
|
|
104
|
+
};
|
|
105
|
+
const llmPr = await generateTaskPrWithLlm(prInput, opts.cwd, preference);
|
|
106
|
+
const { prTitle, prBody } = llmPr ?? fallbackTaskPr(prInput);
|
|
107
|
+
try {
|
|
108
|
+
const { url } = await createMergeRequest({
|
|
109
|
+
cwd: opts.cwd,
|
|
110
|
+
host: gitCfg.host,
|
|
111
|
+
baseBranch: gitCtx.baseBranch,
|
|
112
|
+
headBranch: gitCtx.taskBranch,
|
|
113
|
+
title: prTitle,
|
|
114
|
+
body: prBody,
|
|
115
|
+
});
|
|
116
|
+
log.ok(`${mergeRequestLabel(gitCfg.host)}: ${url}`);
|
|
117
|
+
await upsertGitContext(opts.taskFile, {
|
|
118
|
+
...gitCtx,
|
|
119
|
+
mergeRequestUrl: url,
|
|
120
|
+
pushedAt,
|
|
121
|
+
});
|
|
122
|
+
return { ok: true, mergeRequestUrl: url, pushed: true };
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
126
|
+
log.error(`Could not create merge request: ${msg}`);
|
|
127
|
+
await upsertGitContext(opts.taskFile, { ...gitCtx, pushedAt });
|
|
128
|
+
log.info(dim(`Branch was pushed. Create MR manually: ${gitCtx.taskBranch} → ${gitCtx.baseBranch}`));
|
|
129
|
+
return { ok: false, pushed: true };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { basename, join } from "node:path";
|
|
2
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
|
+
];
|
|
3
14
|
export function allocateNextTaskNumber(tasks) {
|
|
4
15
|
let max = 0;
|
|
5
16
|
for (const t of tasks) {
|
|
@@ -12,67 +23,37 @@ export function allocateNextTaskNumber(tasks) {
|
|
|
12
23
|
export function formatTaskId(number) {
|
|
13
24
|
return `TASK-${String(number).padStart(3, "0")}`;
|
|
14
25
|
}
|
|
15
|
-
export function
|
|
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
|
-
: "";
|
|
26
|
+
export function buildMinimalTaskMarkdown(draft) {
|
|
19
27
|
return `# ${draft.id}: ${draft.title}
|
|
20
28
|
|
|
21
29
|
## Status
|
|
22
30
|
|
|
23
31
|
${statusDisplay("planned")}
|
|
24
32
|
|
|
25
|
-
## MVP Phase
|
|
26
|
-
|
|
27
|
-
${draft.mvpPhase}
|
|
28
|
-
|
|
29
33
|
## Goal
|
|
30
34
|
|
|
31
35
|
${draft.idea.trim()}
|
|
32
36
|
|
|
33
|
-
## Background
|
|
34
|
-
|
|
35
|
-
${draft.idea.trim()}${spawnedNote}
|
|
36
|
-
|
|
37
37
|
## Scope
|
|
38
38
|
|
|
39
39
|
- Deliver the goal above in a single focused pass.
|
|
40
40
|
|
|
41
41
|
## Out of Scope
|
|
42
42
|
|
|
43
|
-
- Unrelated
|
|
43
|
+
- Unrelated refactors and features outside this slice.
|
|
44
44
|
|
|
45
45
|
## Acceptance Criteria
|
|
46
46
|
|
|
47
|
-
1. Goal and Scope are met and verifiable
|
|
48
|
-
2. Result and Test Result
|
|
49
|
-
|
|
50
|
-
## Files to Inspect First
|
|
51
|
-
|
|
52
|
-
- (add paths before implementation)
|
|
53
|
-
|
|
54
|
-
## Expected Files to Change
|
|
55
|
-
|
|
56
|
-
- (list as you implement)
|
|
57
|
-
|
|
58
|
-
## Risks
|
|
59
|
-
|
|
60
|
-
- Scope creep from the parent TASK — keep this slice narrow.
|
|
47
|
+
1. Goal and Scope are met and verifiable via Test Plan.
|
|
48
|
+
2. Result and Test Result are filled before \`vibeops task done\`.
|
|
61
49
|
|
|
62
50
|
## Test Plan
|
|
63
51
|
|
|
64
|
-
-
|
|
65
|
-
- Project-specific checks for the change
|
|
66
|
-
|
|
67
|
-
## Rollback Plan
|
|
68
|
-
|
|
69
|
-
- \`vibeops rollback ${draft.id}\` (advisory) or revert the task branch commits.
|
|
52
|
+
- Project-specific checks for this change
|
|
70
53
|
|
|
71
|
-
##
|
|
54
|
+
## Git Context
|
|
72
55
|
|
|
73
|
-
|
|
74
|
-
2. Implement on \`vibeops start ${draft.id}\`.
|
|
75
|
-
3. Fill Result and Test Result, then \`vibeops done ${draft.id}\`.
|
|
56
|
+
(populated by \`vibeops task add\`)
|
|
76
57
|
|
|
77
58
|
## Result
|
|
78
59
|
|
|
@@ -98,13 +79,28 @@ export function uniqueTaskPath(tasksDir, taskId, baseSlug, existingPaths) {
|
|
|
98
79
|
}
|
|
99
80
|
return { slug, filePath: join(tasksDir, name) };
|
|
100
81
|
}
|
|
101
|
-
export function titleFromIdea(idea
|
|
102
|
-
const t = explicitTitle?.trim();
|
|
103
|
-
if (t && t.length > 0)
|
|
104
|
-
return t;
|
|
82
|
+
export function titleFromIdea(idea) {
|
|
105
83
|
const oneLine = idea.replace(/\s+/g, " ").trim();
|
|
106
84
|
if (oneLine.length <= 80)
|
|
107
85
|
return oneLine;
|
|
108
|
-
|
|
109
|
-
|
|
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");
|
|
104
|
+
}
|
|
105
|
+
return `${header}\n\n${markdown}`;
|
|
110
106
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { relative, resolve } from "node:path";
|
|
2
|
+
import { branchNameForTaskFile, readGitContext, readTaskFile, updateInlineStatus, upsertGitContext, } from "./task.js";
|
|
3
|
+
import { gitBranchExists, gitCheckout, gitCheckoutNewBranch, gitGovernanceOnlyDirty, gitHeadCommit, gitPullFastForwardOnly, gitRemoteBranchExists, gitSwitchToBranch, gitRemoteUrl, 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 from integration branch and mark TASK In Progress. */
|
|
10
|
+
export async function startTaskBranch(opts) {
|
|
11
|
+
const cwd = resolve(opts.cwd);
|
|
12
|
+
const taskFile = opts.taskFile;
|
|
13
|
+
const integrationBranch = opts.integrationBranch;
|
|
14
|
+
const remote = opts.remote ?? "origin";
|
|
15
|
+
const meta = await readTaskFile(taskFile);
|
|
16
|
+
const git = await readGitInfo(cwd);
|
|
17
|
+
if (!git.isRepo) {
|
|
18
|
+
if (opts.dryRun) {
|
|
19
|
+
log.info(dim("dry-run — would require a git repository"));
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
log.error("Not a git repository. Run `vibeops init` with Git setup first.");
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
if (git.dirty === true && opts.allowDirty !== true) {
|
|
26
|
+
const gov = await gitGovernanceOnlyDirty(cwd);
|
|
27
|
+
if (!gov.onlyGovernance) {
|
|
28
|
+
log.error("Git working tree is dirty. Commit or stash, or rerun with --allow-dirty.");
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
log.warn("Only governance paths are dirty — proceeding.");
|
|
32
|
+
}
|
|
33
|
+
const taskBranch = branchNameForTaskFile(taskFile);
|
|
34
|
+
const existingCtx = await readGitContext(taskFile);
|
|
35
|
+
const branchExists = await gitBranchExists(cwd, taskBranch);
|
|
36
|
+
if (opts.dryRun) {
|
|
37
|
+
log.info(` ${dim("integration")} ${integrationBranch}`);
|
|
38
|
+
log.info(` ${dim("task branch")} ${cyan(taskBranch)}`);
|
|
39
|
+
log.info(dim(branchExists
|
|
40
|
+
? `dry-run — would git switch ${taskBranch}`
|
|
41
|
+
: `dry-run — would checkout ${integrationBranch}, then git switch -c ${taskBranch}`));
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
if (!branchExists && git.branch !== integrationBranch) {
|
|
45
|
+
const ok = await gitSwitchToBranch(cwd, integrationBranch, remote);
|
|
46
|
+
if (!ok) {
|
|
47
|
+
log.error(`Integration branch "${integrationBranch}" not found locally or on ${remote}. Run vibeops init or create the branch.`);
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Ensure integration branch is up-to-date before creating a new task branch.
|
|
52
|
+
if (!branchExists) {
|
|
53
|
+
const remoteUrl = await gitRemoteUrl(cwd, remote);
|
|
54
|
+
if (remoteUrl) {
|
|
55
|
+
const hasRemoteBranch = await gitRemoteBranchExists(cwd, remote, integrationBranch);
|
|
56
|
+
if (hasRemoteBranch) {
|
|
57
|
+
try {
|
|
58
|
+
await gitPullFastForwardOnly(cwd, remote, integrationBranch);
|
|
59
|
+
log.info(dim(`Pulled latest ${remote}/${integrationBranch} (--ff-only).`));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
log.warn(`Could not fast-forward pull ${remote}/${integrationBranch}. Resolve manually, then rerun task add.`);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const baseBranch = integrationBranch;
|
|
69
|
+
const baseCommit = (await gitHeadCommit(cwd)) ?? "";
|
|
70
|
+
if (baseCommit.length === 0 && !branchExists) {
|
|
71
|
+
log.error("No commits on integration branch. Create an initial commit first.");
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
const ctx = {
|
|
75
|
+
baseBranch,
|
|
76
|
+
baseCommit: branchExists && existingCtx ? existingCtx.baseCommit : baseCommit,
|
|
77
|
+
taskBranch,
|
|
78
|
+
startedAt: existingCtx?.startedAt ?? new Date().toISOString(),
|
|
79
|
+
};
|
|
80
|
+
log.info(` ${dim("integration")} ${baseBranch} @ ${ctx.baseCommit.slice(0, 7)}`);
|
|
81
|
+
log.info(` ${dim("task branch")} ${cyan(taskBranch)}`);
|
|
82
|
+
if (branchExists) {
|
|
83
|
+
await gitCheckout(cwd, taskBranch);
|
|
84
|
+
if (meta.status === "planned") {
|
|
85
|
+
await updateInlineStatus(taskFile, "in_progress");
|
|
86
|
+
}
|
|
87
|
+
if (existingCtx === null) {
|
|
88
|
+
await upsertGitContext(taskFile, ctx);
|
|
89
|
+
}
|
|
90
|
+
log.ok(`Resumed ${taskBranch}`);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
await gitCheckoutNewBranch(cwd, taskBranch, baseBranch);
|
|
94
|
+
await updateInlineStatus(taskFile, "in_progress");
|
|
95
|
+
await upsertGitContext(taskFile, ctx);
|
|
96
|
+
log.ok(`Branch ${taskBranch} ready`);
|
|
97
|
+
}
|
|
98
|
+
log.info(` ${dim("file")} ${relOrAbs(cwd, taskFile)}`);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
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
|
}
|