@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.
Files changed (144) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +51 -380
  3. package/dist/bootstrap/installer.js +42 -20
  4. package/dist/bootstrap/manifest.js +23 -3
  5. package/dist/cli.js +69 -154
  6. package/dist/commands/init.js +279 -114
  7. package/dist/commands/llm.js +177 -0
  8. package/dist/commands/status.js +181 -8
  9. package/dist/commands/task-add.js +89 -162
  10. package/dist/commands/task-done.js +112 -73
  11. package/dist/lib/config.js +77 -113
  12. package/dist/lib/env.js +32 -0
  13. package/dist/lib/git-config.js +21 -0
  14. package/dist/lib/git-host.js +12 -0
  15. package/dist/lib/git-policy.js +67 -0
  16. package/dist/lib/git-remote.js +108 -0
  17. package/dist/lib/git.js +44 -1
  18. package/dist/lib/init-clients.js +55 -0
  19. package/dist/lib/llm-complete.js +18 -19
  20. package/dist/lib/llm-connect.js +150 -0
  21. package/dist/lib/llm-preference.js +102 -0
  22. package/dist/lib/llm-status.js +19 -0
  23. package/dist/lib/paths.js +7 -3
  24. package/dist/lib/plan-llm-openai.js +0 -63
  25. package/dist/lib/pr-create.js +66 -0
  26. package/dist/lib/resolve-task.js +34 -0
  27. package/dist/lib/task-add-llm.js +53 -233
  28. package/dist/lib/task-context.js +68 -0
  29. package/dist/lib/task-done-llm.js +155 -0
  30. package/dist/lib/task-pr-llm.js +50 -0
  31. package/dist/lib/task-push-pr.js +131 -0
  32. package/dist/lib/task-scaffold.js +39 -43
  33. package/dist/lib/task-start.js +100 -0
  34. package/dist/lib/task-summary.js +12 -239
  35. package/dist/lib/task.js +30 -24
  36. package/dist/types/config.js +0 -16
  37. package/package.json +14 -14
  38. package/templates/.cursor/rules/02-git-safety.mdc +12 -0
  39. package/templates/AGENTS.md +27 -87
  40. package/templates/clients/claude/CLAUDE.md +28 -0
  41. package/templates/clients/cursor/.cursor/rules/01-task-source-of-truth.mdc +12 -0
  42. package/templates/clients/cursor/.cursor/rules/02-git-safety.mdc +11 -0
  43. package/templates/clients/cursor/.cursor/rules/03-docs-on-done.mdc +13 -0
  44. package/templates/core/AGENTS.md +44 -0
  45. package/templates/core/docs/logs/README.md +7 -0
  46. package/templates/core/docs/project/03-architecture.md +15 -0
  47. package/templates/core/docs/project/05-current-state.md +15 -0
  48. package/templates/core/docs/project/06-decisions.md +9 -0
  49. package/templates/core/docs/tasks/TASK-000-template.md +37 -0
  50. package/templates/skills-shared/implement-task/SKILL.md +22 -0
  51. package/templates/skills-shared/plan-task/SKILL.md +19 -0
  52. package/dist/agent/loader.js +0 -71
  53. package/dist/agent/prompt.js +0 -66
  54. package/dist/commands/agent-list.js +0 -32
  55. package/dist/commands/agent-prompt.js +0 -59
  56. package/dist/commands/agent-show.js +0 -26
  57. package/dist/commands/done.js +0 -160
  58. package/dist/commands/github-init.js +0 -554
  59. package/dist/commands/github-status.js +0 -164
  60. package/dist/commands/next.js +0 -283
  61. package/dist/commands/notion-init.js +0 -764
  62. package/dist/commands/notion-sync.js +0 -405
  63. package/dist/commands/notion-test.js +0 -595
  64. package/dist/commands/plan.js +0 -146
  65. package/dist/commands/rollback.js +0 -126
  66. package/dist/commands/start.js +0 -129
  67. package/dist/commands/task-check.js +0 -155
  68. package/dist/commands/task-generate.js +0 -206
  69. package/dist/commands/task-pull.js +0 -277
  70. package/dist/commands/task-rollback.js +0 -174
  71. package/dist/commands/task-start.js +0 -102
  72. package/dist/commands/task-status.js +0 -122
  73. package/dist/lib/brief.js +0 -356
  74. package/dist/lib/cursor-task-prompt.js +0 -60
  75. package/dist/lib/done-follow-up.js +0 -308
  76. package/dist/lib/github-cli.js +0 -247
  77. package/dist/lib/guide-state.js +0 -69
  78. package/dist/lib/mvp-artifacts.js +0 -204
  79. package/dist/lib/mvp-constants.js +0 -15
  80. package/dist/lib/mvp-diff-summary.js +0 -43
  81. package/dist/lib/mvp-done-summary.js +0 -335
  82. package/dist/lib/notion-client.js +0 -459
  83. package/dist/lib/notion-discovery.js +0 -671
  84. package/dist/lib/notion-env.js +0 -140
  85. package/dist/lib/notion-mappers.js +0 -148
  86. package/dist/lib/notion-schema.js +0 -272
  87. package/dist/lib/notion-sync.js +0 -337
  88. package/dist/lib/notion-target.js +0 -247
  89. package/dist/lib/package-json.js +0 -133
  90. package/dist/lib/plan-apply-planner.js +0 -131
  91. package/dist/lib/plan-codex-pkce.js +0 -234
  92. package/dist/lib/plan-llm-models.js +0 -237
  93. package/dist/lib/plan-llm-normalize.js +0 -146
  94. package/dist/lib/plan-llm-session.js +0 -682
  95. package/dist/lib/plan-post-apply-setup.js +0 -116
  96. package/dist/lib/project-docs.js +0 -95
  97. package/dist/lib/prompt-builder.js +0 -135
  98. package/dist/lib/resolve-command-task.js +0 -40
  99. package/dist/lib/task-add-build-prompt.js +0 -39
  100. package/dist/lib/task-add-cursor.js +0 -81
  101. package/dist/lib/task-generator.js +0 -183
  102. package/dist/lib/task-merge.js +0 -220
  103. package/dist/lib/task-prompt.js +0 -23
  104. package/dist/lib/task-pull.js +0 -354
  105. package/dist/lib/task-status-brief.js +0 -132
  106. package/dist/lib/workflow-guide.js +0 -485
  107. package/dist/status/collector.js +0 -103
  108. package/dist/status/format.js +0 -181
  109. package/dist/types/brief.js +0 -126
  110. package/templates/.cursor/rules/00-project-governance.mdc +0 -28
  111. package/templates/.cursor/rules/01-agent-orchestration.mdc +0 -48
  112. package/templates/.cursor/rules/02-task-workflow.mdc +0 -38
  113. package/templates/.cursor/rules/03-git-safety.mdc +0 -30
  114. package/templates/.cursor/rules/04-docs-update.mdc +0 -22
  115. package/templates/.vibeops/agents/architect.md +0 -47
  116. package/templates/.vibeops/agents/builder.md +0 -38
  117. package/templates/.vibeops/agents/docs.md +0 -54
  118. package/templates/.vibeops/agents/orchestrator.md +0 -40
  119. package/templates/.vibeops/agents/planner.md +0 -60
  120. package/templates/.vibeops/agents/recovery.md +0 -49
  121. package/templates/.vibeops/agents/reviewer.md +0 -47
  122. package/templates/.vibeops/agents/tester.md +0 -43
  123. package/templates/.vibeops/prompts/create-plan.md +0 -33
  124. package/templates/.vibeops/prompts/generate-tasks.md +0 -41
  125. package/templates/.vibeops/prompts/implement-task.md +0 -39
  126. package/templates/.vibeops/prompts/review-task.md +0 -34
  127. package/templates/.vibeops/prompts/rollback.md +0 -32
  128. package/templates/.vibeops/prompts/start-project.md +0 -39
  129. package/templates/.vibeops/workflows/notion-sync.md +0 -53
  130. package/templates/.vibeops/workflows/project-start.md +0 -73
  131. package/templates/.vibeops/workflows/rollback.md +0 -45
  132. package/templates/.vibeops/workflows/task-lifecycle.md +0 -71
  133. package/templates/docs/logs/README.md +0 -38
  134. package/templates/docs/project/00-overview.md +0 -27
  135. package/templates/docs/project/01-requirements.md +0 -30
  136. package/templates/docs/project/02-mvp-scope.md +0 -36
  137. package/templates/docs/project/03-architecture.md +0 -34
  138. package/templates/docs/project/04-tech-stack.md +0 -29
  139. package/templates/docs/project/05-current-state.md +0 -35
  140. package/templates/docs/project/06-decisions.md +0 -20
  141. package/templates/docs/project/07-backlog.md +0 -23
  142. package/templates/docs/project/08-env.md +0 -29
  143. package/templates/docs/project/09-deployment.md +0 -28
  144. 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 buildWorkNowTaskMarkdown(draft) {
16
- const spawnedNote = draft.spawnedFrom
17
- ? `\n\nWork-now slice while **${draft.spawnedFrom}** is still open — not a pre-planned backlog item scheduled after later TASK numbers.`
18
- : "";
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 backlog items and broad refactors not required for this slice.
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 from the repo or commands in Test Plan.
48
- 2. Result and Test Result sections are filled before \`vibeops done\`.
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
- - \`vibeops task check ${draft.id}\`
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
- ## Implementation Plan
54
+ ## Git Context
72
55
 
73
- 1. Confirm Scope with the open parent TASK (if any).
74
- 2. Implement on \`vibeops start ${draft.id}\`.
75
- 3. Fill Result and Test Result, then \`vibeops done ${draft.id}\`.
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, explicitTitle) {
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
- const cut = oneLine.slice(0, 77).trimEnd();
109
- return `${cut}...`;
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
+ }
@@ -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
- 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");
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 isHeading(line) {
24
- return /^#{1,6}\s+/.test(line);
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 lines) {
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 (isHeading(t))
40
+ if (/^#{1,6}\s+/.test(t))
63
41
  continue;
64
42
  cleaned.push(stripBullet(t));
65
43
  }
66
- const compact = compressBlank(cleaned);
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
- 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
- `;
48
+ return goal.length > 0 ? goal : summarizeSection(body, "Scope", limit);
276
49
  }