@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.
Files changed (106) hide show
  1. package/CHANGELOG.md +198 -1
  2. package/README.md +62 -402
  3. package/dist/bootstrap/installer.js +4 -16
  4. package/dist/cli.js +52 -244
  5. package/dist/commands/init.js +3 -3
  6. package/dist/commands/status.js +145 -8
  7. package/dist/commands/task-add.js +100 -0
  8. package/dist/commands/task-done.js +117 -74
  9. package/dist/lib/config.js +2 -118
  10. package/dist/lib/git.js +190 -8
  11. package/dist/lib/llm-complete.js +62 -0
  12. package/dist/lib/paths.js +7 -3
  13. package/dist/lib/plan-llm-openai.js +0 -63
  14. package/dist/lib/resolve-task.js +34 -0
  15. package/dist/lib/slug.js +11 -0
  16. package/dist/lib/task-add-llm.js +81 -0
  17. package/dist/lib/task-context.js +68 -0
  18. package/dist/lib/task-done-llm.js +148 -0
  19. package/dist/lib/task-merge.js +115 -0
  20. package/dist/lib/task-scaffold.js +70 -92
  21. package/dist/lib/task-start.js +72 -0
  22. package/dist/lib/task-summary.js +12 -239
  23. package/dist/lib/task.js +80 -37
  24. package/dist/types/config.js +0 -16
  25. package/package.json +3 -4
  26. package/templates/.cursor/rules/01-task-source-of-truth.mdc +12 -0
  27. package/templates/.cursor/rules/02-git-safety.mdc +11 -0
  28. package/templates/.cursor/rules/03-docs-on-done.mdc +13 -0
  29. package/templates/.cursor/skills/implement-task/SKILL.md +22 -0
  30. package/templates/.cursor/skills/plan-task/SKILL.md +19 -0
  31. package/templates/AGENTS.md +26 -87
  32. package/templates/docs/logs/README.md +4 -35
  33. package/templates/docs/project/03-architecture.md +10 -29
  34. package/templates/docs/project/05-current-state.md +7 -27
  35. package/templates/docs/project/06-decisions.md +6 -17
  36. package/templates/docs/tasks/TASK-000-template.md +9 -44
  37. package/dist/agent/loader.js +0 -71
  38. package/dist/agent/prompt.js +0 -66
  39. package/dist/commands/agent-list.js +0 -32
  40. package/dist/commands/agent-prompt.js +0 -59
  41. package/dist/commands/agent-show.js +0 -26
  42. package/dist/commands/github-init.js +0 -554
  43. package/dist/commands/github-status.js +0 -164
  44. package/dist/commands/notion-init.js +0 -764
  45. package/dist/commands/notion-sync.js +0 -405
  46. package/dist/commands/notion-test.js +0 -595
  47. package/dist/commands/plan.js +0 -343
  48. package/dist/commands/task-check.js +0 -155
  49. package/dist/commands/task-generate.js +0 -206
  50. package/dist/commands/task-pull.js +0 -277
  51. package/dist/commands/task-rollback.js +0 -174
  52. package/dist/commands/task-start.js +0 -102
  53. package/dist/lib/brief.js +0 -356
  54. package/dist/lib/github-cli.js +0 -247
  55. package/dist/lib/notion-client.js +0 -459
  56. package/dist/lib/notion-discovery.js +0 -671
  57. package/dist/lib/notion-env.js +0 -140
  58. package/dist/lib/notion-mappers.js +0 -148
  59. package/dist/lib/notion-schema.js +0 -272
  60. package/dist/lib/notion-sync.js +0 -337
  61. package/dist/lib/notion-target.js +0 -247
  62. package/dist/lib/package-json.js +0 -133
  63. package/dist/lib/plan-apply-planner.js +0 -131
  64. package/dist/lib/plan-codex-pkce.js +0 -234
  65. package/dist/lib/plan-llm-models.js +0 -237
  66. package/dist/lib/plan-llm-normalize.js +0 -146
  67. package/dist/lib/plan-llm-session.js +0 -682
  68. package/dist/lib/plan-post-apply-setup.js +0 -116
  69. package/dist/lib/project-docs.js +0 -95
  70. package/dist/lib/prompt-builder.js +0 -135
  71. package/dist/lib/task-generator.js +0 -183
  72. package/dist/lib/task-prompt.js +0 -23
  73. package/dist/lib/task-pull.js +0 -354
  74. package/dist/status/collector.js +0 -103
  75. package/dist/status/format.js +0 -177
  76. package/dist/types/brief.js +0 -126
  77. package/templates/.cursor/rules/00-project-governance.mdc +0 -28
  78. package/templates/.cursor/rules/01-agent-orchestration.mdc +0 -48
  79. package/templates/.cursor/rules/02-task-workflow.mdc +0 -38
  80. package/templates/.cursor/rules/03-git-safety.mdc +0 -30
  81. package/templates/.cursor/rules/04-docs-update.mdc +0 -22
  82. package/templates/.vibeops/agents/architect.md +0 -47
  83. package/templates/.vibeops/agents/builder.md +0 -38
  84. package/templates/.vibeops/agents/docs.md +0 -54
  85. package/templates/.vibeops/agents/orchestrator.md +0 -40
  86. package/templates/.vibeops/agents/planner.md +0 -60
  87. package/templates/.vibeops/agents/recovery.md +0 -49
  88. package/templates/.vibeops/agents/reviewer.md +0 -47
  89. package/templates/.vibeops/agents/tester.md +0 -43
  90. package/templates/.vibeops/prompts/create-plan.md +0 -33
  91. package/templates/.vibeops/prompts/generate-tasks.md +0 -41
  92. package/templates/.vibeops/prompts/implement-task.md +0 -39
  93. package/templates/.vibeops/prompts/review-task.md +0 -34
  94. package/templates/.vibeops/prompts/rollback.md +0 -32
  95. package/templates/.vibeops/prompts/start-project.md +0 -39
  96. package/templates/.vibeops/workflows/notion-sync.md +0 -53
  97. package/templates/.vibeops/workflows/project-start.md +0 -73
  98. package/templates/.vibeops/workflows/rollback.md +0 -45
  99. package/templates/.vibeops/workflows/task-lifecycle.md +0 -71
  100. package/templates/docs/project/00-overview.md +0 -27
  101. package/templates/docs/project/01-requirements.md +0 -30
  102. package/templates/docs/project/02-mvp-scope.md +0 -36
  103. package/templates/docs/project/04-tech-stack.md +0 -29
  104. package/templates/docs/project/07-backlog.md +0 -23
  105. package/templates/docs/project/08-env.md +0 -29
  106. package/templates/docs/project/09-deployment.md +0 -28
@@ -1,98 +1,141 @@
1
- import { relative, resolve } from "node:path";
1
+ import { resolve } from "node:path";
2
+ import { askYesNo } from "../lib/inquirer-helpers.js";
2
3
  import { readText } from "../lib/filesystem.js";
3
4
  import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
4
5
  import { projectPaths } from "../lib/paths.js";
5
- import { findTaskFile, hasNonEmptySection, parseTaskFilename, pickNextTask, readGitContext, readTaskFile, scanTasks, statusDisplay, updateInlineStatus, } from "../lib/task.js";
6
- function relOrAbs(root, p) {
7
- const r = relative(root, p);
8
- return r === "" ? "." : r.startsWith("..") ? p : r;
6
+ import { resolveTask, taskNotFoundMessage } from "../lib/resolve-task.js";
7
+ import { mergeTaskBranch } from "../lib/task-merge.js";
8
+ import { applyTaskDoneMemory, fallbackResultSections, llmCompleteTaskDone, writeTaskResultSections, } from "../lib/task-done-llm.js";
9
+ import { relPath } from "../lib/task-context.js";
10
+ import { gitAddPaths, listWorkingTreeRelPaths, partitionPathsForAutoCommit, readGitInfo, runGit, } from "../lib/git.js";
11
+ import { hasNonEmptySection, markGitContextDone, parseTaskFilename, readGitContext, readTaskFile, updateInlineStatus, } from "../lib/task.js";
12
+ function commitMessageFor(taskId, title) {
13
+ const slug = title.replace(/^TASK-\d+\s*[:\-]\s*/i, "").trim() || taskId;
14
+ return `feat(${taskId.toLowerCase()}): ${slug}`;
9
15
  }
10
- function commitMessageFor(taskId, title, mvpPhase) {
11
- const trimmed = (title || taskId).trim();
12
- const slug = trimmed.replace(/^TASK-\d+\s*[·:\-]\s*/i, "").trim() || trimmed;
13
- const scope = taskId.toLowerCase();
14
- const typePrefix = typeof mvpPhase === "string" && /rollback/i.test(mvpPhase) ? "chore" : "feat";
15
- return `${typePrefix}(${scope}): ${slug}`;
16
+ async function tryCommitDirty(cwd, message, dryRun) {
17
+ const git = await readGitInfo(cwd);
18
+ if (!git.isRepo || git.dirty !== true)
19
+ return false;
20
+ const { committable, excluded } = partitionPathsForAutoCommit(await listWorkingTreeRelPaths(cwd), { unmerged: [] });
21
+ if (committable.length === 0)
22
+ return false;
23
+ if (excluded.length > 0) {
24
+ log.warn(`Skipping ${excluded.length} build artifact path(s) from auto-commit.`);
25
+ }
26
+ if (dryRun) {
27
+ log.info(dim(` would commit ${committable.length} path(s)`));
28
+ return true;
29
+ }
30
+ await gitAddPaths(cwd, committable);
31
+ await runGit(cwd, ["commit", "-q", "-m", message]);
32
+ log.ok(`Committed: ${message}`);
33
+ return true;
16
34
  }
17
- export async function taskDoneCommand(taskId, options = {}) {
35
+ export async function taskDoneCommand(taskRef, options = {}) {
18
36
  const cwd = resolve(options.cwd ?? process.cwd());
19
37
  const paths = projectPaths(cwd);
20
- const taskFile = await findTaskFile(paths.docsTasks, taskId);
21
- if (!taskFile) {
22
- log.error(`TASK not found: ${taskId} (looked in ${relOrAbs(cwd, paths.docsTasks)})`);
38
+ const dryRun = options.dryRun === true;
39
+ const interactive = !dryRun && process.stdin.isTTY === true && options.yes !== true;
40
+ const resolved = await resolveTask(paths, cwd, taskRef);
41
+ if (resolved === null) {
42
+ log.error(taskNotFoundMessage(taskRef));
23
43
  process.exitCode = 1;
24
44
  return;
25
45
  }
46
+ const { taskId, taskFile } = resolved;
26
47
  const meta = await readTaskFile(taskFile);
27
48
  const body = await readText(taskFile);
28
- log.info(bold(`vibeops task done ${meta.id}`));
29
- log.info(` ${dim("file")} ${relOrAbs(cwd, taskFile)}`);
30
- log.info(` ${dim("title")} ${meta.title || dim("(no title)")}`);
31
- log.info(` ${dim("status")} ${statusDisplay(meta.status)}`);
49
+ const relFile = relPath(cwd, taskFile);
50
+ log.info(bold(`vibeops task done ${taskId}`));
51
+ log.info(` ${dim("file")} ${relFile}`);
32
52
  log.blank();
33
- const resultOk = hasNonEmptySection(body, "Result");
34
- const testResultOk = hasNonEmptySection(body, "Test Result");
35
- const docCheck = [
36
- ["Result section", resultOk],
37
- ["Test Result section", testResultOk],
38
- ];
39
- log.info(bold("Required sections"));
40
- let missing = 0;
41
- for (const [label, ok] of docCheck) {
42
- if (!ok)
43
- missing++;
44
- log.info(` ${ok ? green("✓") : yellow("·")} ${label}${ok ? "" : dim(" (empty or placeholder)")}`);
53
+ let resultOk = hasNonEmptySection(body, "Result");
54
+ let testOk = hasNonEmptySection(body, "Test Result");
55
+ if (!resultOk || !testOk) {
56
+ log.step("LLM — Result / Test Result + project memory");
57
+ if (dryRun) {
58
+ log.info(dim(" would call LLM and patch docs/project/*"));
59
+ }
60
+ else {
61
+ const patch = await llmCompleteTaskDone({
62
+ cwd,
63
+ taskId,
64
+ taskTitle: meta.title,
65
+ taskBody: body,
66
+ taskFileRel: relFile,
67
+ });
68
+ if (patch !== null) {
69
+ await writeTaskResultSections(taskFile, patch.result, patch.testResult);
70
+ log.ok(`Result / Test Result updated (${patch.provider})`);
71
+ const memory = await applyTaskDoneMemory(cwd, patch);
72
+ for (const p of memory.updated)
73
+ log.ok(`Updated ${p}`);
74
+ for (const p of memory.skipped)
75
+ log.skip(`Skipped ${p}`);
76
+ }
77
+ else {
78
+ log.warn("LLM unavailable — using git diff fallback for Result sections.");
79
+ const fb = await fallbackResultSections(cwd, taskId, taskFile);
80
+ await writeTaskResultSections(taskFile, fb.result, fb.testResult);
81
+ }
82
+ }
83
+ const body2 = dryRun ? body : await readText(taskFile);
84
+ resultOk = hasNonEmptySection(body2, "Result");
85
+ testOk = hasNonEmptySection(body2, "Test Result");
45
86
  }
87
+ log.info(bold("Sections"));
88
+ log.info(` ${resultOk ? green("✓") : yellow("·")} Result`);
89
+ log.info(` ${testOk ? green("✓") : yellow("·")} Test Result`);
46
90
  log.blank();
47
- if (missing > 0) {
48
- log.error(`${missing} required section(s) still empty. Fill Result / Test Result first, then re-run \`vibeops task done\`.`);
91
+ if (!dryRun && (!resultOk || !testOk)) {
92
+ log.error("Fill Result and Test Result in the TASK file, then rerun.");
49
93
  process.exitCode = 1;
50
94
  return;
51
95
  }
52
- const target = options.finalize === true ? "done" : "review";
53
- const targetDisplay = statusDisplay(target);
54
- if (options.dryRun === true) {
55
- log.info(bold("dry-run — would perform:"));
56
- log.info(` · update Status → ${targetDisplay} in ${relOrAbs(cwd, taskFile)}`);
57
- log.info(` · (no git commit, no Notion call)`);
58
- log.blank();
59
- }
60
- else {
61
- await updateInlineStatus(taskFile, target);
62
- log.ok(`Status → ${targetDisplay} (${relOrAbs(cwd, taskFile)})`);
63
- log.blank();
96
+ if (dryRun) {
97
+ log.info(bold("dry-run — would:"));
98
+ if (!resultOk || !testOk) {
99
+ log.info(" · LLM fill Result / Test Result + patch docs/project/*");
100
+ }
101
+ log.info(" · Status → Done");
102
+ log.info(" · commit if dirty, merge task branch (unless --no-merge)");
103
+ return;
64
104
  }
65
- const ctx = await readGitContext(taskFile);
105
+ await updateInlineStatus(taskFile, "done");
106
+ await markGitContextDone(taskFile);
107
+ log.ok("Status → Done");
66
108
  const parts = parseTaskFilename(taskFile);
67
- const commitMsg = commitMessageFor(parts.id, meta.title, meta.mvpPhase);
68
- log.info(bold("Suggested commit"));
69
- log.info(` ${cyan(`git add -A && git commit -m "${commitMsg}"`)}`);
70
- if (ctx !== null) {
71
- log.info(` ${dim(`after commit on ${ctx.taskBranch}: open PR or run \`git switch ${ctx.baseBranch} && git merge --no-ff ${ctx.taskBranch}\``)}`);
72
- }
73
- else {
74
- log.info(` ${dim("(no Git Context section — skipping merge guidance)")}`);
109
+ const commitMsg = commitMessageFor(parts.id, meta.title);
110
+ await tryCommitDirty(cwd, commitMsg, false);
111
+ const gitCtx = await readGitContext(taskFile);
112
+ if (options.noMerge !== true && gitCtx !== null) {
113
+ let doMerge = options.yes === true || !interactive;
114
+ if (interactive) {
115
+ doMerge = await askYesNo({
116
+ message: `Merge into ${gitCtx.baseBranch} and delete ${gitCtx.taskBranch}?`,
117
+ nonInteractive: false,
118
+ defaultValue: true,
119
+ });
120
+ }
121
+ if (doMerge) {
122
+ const mergeResult = await mergeTaskBranch({
123
+ cwd,
124
+ taskId: meta.id,
125
+ taskTitle: meta.title,
126
+ gitCtx,
127
+ allowDirty: options.allowDirty,
128
+ });
129
+ if (!mergeResult.ok)
130
+ process.exitCode = 1;
131
+ }
132
+ else {
133
+ log.info(dim("Merge skipped."));
134
+ }
75
135
  }
76
- log.blank();
77
- log.info(bold("Notion"));
78
- log.info(` ${dim("TODO: run `vibeops notion sync` to push this TASK metadata.")}`);
79
- log.blank();
80
- const all = await scanTasks(paths.docsTasks);
81
- const next = pickNextTask(all.filter((t) => t.id.toUpperCase() !== meta.id.toUpperCase()));
82
- log.info(bold("Next TASK candidate"));
83
- if (next) {
84
- log.info(` → ${cyan(next.id)} — ${next.title || dim("(no title)")} ${dim(`[${statusDisplay(next.status)}]`)}`);
85
- log.info(` file: ${dim(relOrAbs(cwd, next.filePath))}`);
86
- log.info(` start: ${dim(`vibeops task start ${next.id}`)}`);
87
- }
88
- else {
89
- log.info(` ${dim("(no remaining planned / in-progress / review TASK)")}`);
136
+ else if (gitCtx === null) {
137
+ log.info(dim("No Git Context — merge skipped."));
90
138
  }
91
139
  log.blank();
92
- if (target === "review") {
93
- log.info(`Status moved to ${cyan("Review")}. After human or Reviewer Agent review, finalize with ${cyan(`vibeops task done ${meta.id} --finalize`)}.`);
94
- }
95
- else {
96
- log.ok(`${meta.id} finalized → Done.`);
97
- }
140
+ log.info(`Next: ${cyan("vibeops task add")} or ${cyan("vibeops status")}`);
98
141
  }
@@ -2,39 +2,7 @@ import { join } from "node:path";
2
2
  import { readTextOrNull, writeText } from "./filesystem.js";
3
3
  import { VIBEOPS_CONFIG_FILE } from "./paths.js";
4
4
  import { VERSION } from "../version.js";
5
- import { DEFAULT_GITHUB_CONFIG, VIBEOPS_CONFIG_SCHEMA_VERSION, } from "../types/config.js";
6
- function parseNotionSection(raw) {
7
- if (raw === null || typeof raw !== "object")
8
- return undefined;
9
- const r = raw;
10
- const out = {
11
- enabled: typeof r.enabled === "boolean" ? r.enabled : false,
12
- projectsTargetId: typeof r.projectsTargetId === "string" ? r.projectsTargetId : "",
13
- tasksTargetId: typeof r.tasksTargetId === "string" ? r.tasksTargetId : "",
14
- projectsDatabaseId: typeof r.projectsDatabaseId === "string" ? r.projectsDatabaseId : "",
15
- tasksDatabaseId: typeof r.tasksDatabaseId === "string" ? r.tasksDatabaseId : "",
16
- };
17
- return out;
18
- }
19
- function parseGithubVisibility(raw) {
20
- if (raw === "public" || raw === "private")
21
- return raw;
22
- return "";
23
- }
24
- function parseGithubSection(raw) {
25
- if (raw === null || typeof raw !== "object")
26
- return undefined;
27
- const r = raw;
28
- return {
29
- enabled: typeof r.enabled === "boolean" ? r.enabled : false,
30
- mode: r.mode === "gh-cli" ? "gh-cli" : "gh-cli",
31
- owner: typeof r.owner === "string" ? r.owner : "",
32
- repo: typeof r.repo === "string" ? r.repo : "",
33
- remote: typeof r.remote === "string" && r.remote.length > 0 ? r.remote : "origin",
34
- visibility: parseGithubVisibility(r.visibility),
35
- url: typeof r.url === "string" ? r.url : "",
36
- };
37
- }
5
+ import { VIBEOPS_CONFIG_SCHEMA_VERSION } from "../types/config.js";
38
6
  export async function readConfig(root) {
39
7
  const text = await readTextOrNull(join(root, VIBEOPS_CONFIG_FILE));
40
8
  if (text === null)
@@ -45,19 +13,12 @@ export async function readConfig(root) {
45
13
  typeof parsed.vibeopsVersion === "string" &&
46
14
  typeof parsed.createdAt === "string" &&
47
15
  parsed.schemaVersion === VIBEOPS_CONFIG_SCHEMA_VERSION) {
48
- const notion = parseNotionSection(parsed.notion);
49
- const github = parseGithubSection(parsed.github);
50
- const config = {
16
+ return {
51
17
  name: parsed.name,
52
18
  vibeopsVersion: parsed.vibeopsVersion,
53
19
  schemaVersion: VIBEOPS_CONFIG_SCHEMA_VERSION,
54
20
  createdAt: parsed.createdAt,
55
21
  };
56
- if (notion)
57
- config.notion = notion;
58
- if (github)
59
- config.github = github;
60
- return config;
61
22
  }
62
23
  return null;
63
24
  }
@@ -79,80 +40,3 @@ export function configToJson(config) {
79
40
  export async function writeConfig(root, config) {
80
41
  await writeText(join(root, VIBEOPS_CONFIG_FILE), configToJson(config));
81
42
  }
82
- /** Merge a partial notion section into an existing config without touching other fields. */
83
- export function mergeNotionConfig(base, patch) {
84
- const current = base.notion ?? {
85
- enabled: false,
86
- projectsTargetId: "",
87
- tasksTargetId: "",
88
- projectsDatabaseId: "",
89
- tasksDatabaseId: "",
90
- };
91
- const next = {
92
- enabled: patch.enabled ?? current.enabled,
93
- projectsTargetId: patch.projectsTargetId !== undefined && patch.projectsTargetId.length > 0
94
- ? patch.projectsTargetId
95
- : current.projectsTargetId,
96
- tasksTargetId: patch.tasksTargetId !== undefined && patch.tasksTargetId.length > 0
97
- ? patch.tasksTargetId
98
- : current.tasksTargetId,
99
- projectsDatabaseId: patch.projectsDatabaseId !== undefined && patch.projectsDatabaseId.length > 0
100
- ? patch.projectsDatabaseId
101
- : current.projectsDatabaseId,
102
- tasksDatabaseId: patch.tasksDatabaseId !== undefined && patch.tasksDatabaseId.length > 0
103
- ? patch.tasksDatabaseId
104
- : current.tasksDatabaseId,
105
- };
106
- const changed = base.notion === undefined ||
107
- next.enabled !== current.enabled ||
108
- next.projectsTargetId !== current.projectsTargetId ||
109
- next.tasksTargetId !== current.tasksTargetId ||
110
- next.projectsDatabaseId !== current.projectsDatabaseId ||
111
- next.tasksDatabaseId !== current.tasksDatabaseId;
112
- return { merged: { ...base, notion: next }, changed };
113
- }
114
- /** Preferred Projects target id for API calls: data_source target first, legacy DB fallback. */
115
- export function notionProjectsTargetId(notion) {
116
- return notion.projectsTargetId.length > 0
117
- ? notion.projectsTargetId
118
- : notion.projectsDatabaseId;
119
- }
120
- /** Preferred Tasks target id for API calls: data_source target first, legacy DB fallback. */
121
- export function notionTasksTargetId(notion) {
122
- return notion.tasksTargetId.length > 0 ? notion.tasksTargetId : notion.tasksDatabaseId;
123
- }
124
- /**
125
- * Merge a partial github section into an existing config without touching
126
- * other fields (notion, name, etc.). Empty strings in the patch are treated
127
- * as "no value provided" — the existing value is kept. Pass through
128
- * `enabled` / `mode` always (booleans / enums always overwrite).
129
- */
130
- export function mergeGithubConfig(base, patch) {
131
- const current = base.github ?? DEFAULT_GITHUB_CONFIG;
132
- const next = {
133
- enabled: patch.enabled ?? current.enabled,
134
- mode: patch.mode ?? current.mode,
135
- owner: patch.owner !== undefined && patch.owner.length > 0
136
- ? patch.owner
137
- : current.owner,
138
- repo: patch.repo !== undefined && patch.repo.length > 0
139
- ? patch.repo
140
- : current.repo,
141
- remote: patch.remote !== undefined && patch.remote.length > 0
142
- ? patch.remote
143
- : current.remote,
144
- visibility: patch.visibility !== undefined && patch.visibility.length > 0
145
- ? patch.visibility
146
- : current.visibility,
147
- url: patch.url !== undefined && patch.url.length > 0 ? patch.url : current.url,
148
- };
149
- const changed = base.github === undefined ||
150
- next.enabled !== current.enabled ||
151
- next.mode !== current.mode ||
152
- next.owner !== current.owner ||
153
- next.repo !== current.repo ||
154
- next.remote !== current.remote ||
155
- next.visibility !== current.visibility ||
156
- next.url !== current.url;
157
- return { merged: { ...base, github: next }, changed };
158
- }
package/dist/lib/git.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { dim, log } from "./logger.js";
3
4
  const exec = promisify(execFile);
5
+ /** Avoid `maxBuffer` errors on large `git commit` / `git add` output. */
6
+ const DEFAULT_GIT_MAX_BUFFER = 64 * 1024 * 1024;
4
7
  async function tryGit(cwd, args) {
5
8
  try {
6
9
  const { stdout } = await exec("git", args, { cwd });
@@ -10,8 +13,11 @@ async function tryGit(cwd, args) {
10
13
  return null;
11
14
  }
12
15
  }
13
- export async function runGit(cwd, args) {
14
- const { stdout, stderr } = await exec("git", args, { cwd });
16
+ export async function runGit(cwd, args, options = {}) {
17
+ const { stdout, stderr } = await exec("git", args, {
18
+ cwd,
19
+ maxBuffer: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER,
20
+ });
15
21
  return { stdout, stderr };
16
22
  }
17
23
  export async function isGitRepository(cwd) {
@@ -83,14 +89,90 @@ export async function gitCreateBranch(cwd, name, startPoint) {
83
89
  args.push(startPoint);
84
90
  await runGit(cwd, args);
85
91
  }
92
+ /**
93
+ * When only governance / VibeOps paths are dirty, stash them so `git switch` can proceed.
94
+ * Callers that switch branches should restore with {@link restoreGovernanceStashAfterSwitch}.
95
+ */
96
+ export async function stashGovernanceIfBlocking(cwd) {
97
+ const git = await readGitInfo(cwd);
98
+ if (git.dirty !== true)
99
+ return false;
100
+ const gov = await gitGovernanceOnlyDirty(cwd);
101
+ if (!gov.onlyGovernance || gov.allPaths.length === 0)
102
+ return false;
103
+ const entries = parsePorcelain(await gitStatusPorcelain(cwd));
104
+ const tracked = [];
105
+ const untracked = [];
106
+ for (const e of entries) {
107
+ if (!isGovernanceDocumentationPath(e.path))
108
+ continue;
109
+ if (e.untracked)
110
+ untracked.push(e.path);
111
+ else
112
+ tracked.push(e.path);
113
+ }
114
+ let didStash = false;
115
+ if (tracked.length > 0) {
116
+ await runGit(cwd, [
117
+ "stash",
118
+ "push",
119
+ "-m",
120
+ "vibeops: governance before branch switch",
121
+ "--",
122
+ ...tracked,
123
+ ]);
124
+ didStash = true;
125
+ }
126
+ if (untracked.length > 0) {
127
+ try {
128
+ await runGit(cwd, [
129
+ "stash",
130
+ "push",
131
+ "-u",
132
+ "-m",
133
+ "vibeops: governance untracked before branch switch",
134
+ "--",
135
+ ...untracked,
136
+ ]);
137
+ didStash = true;
138
+ }
139
+ catch {
140
+ // Untracked generated files (e.g. cursor-implement-*.md) may not stash; switch can still proceed.
141
+ }
142
+ }
143
+ return didStash;
144
+ }
145
+ /** Re-apply governance paths stashed for a branch switch (e.g. new `docs/tasks/*.md`). */
146
+ export async function restoreGovernanceStashAfterSwitch(cwd, didStash) {
147
+ if (!didStash)
148
+ return;
149
+ try {
150
+ await runGit(cwd, ["stash", "pop"]);
151
+ log.info(dim("Restored stashed governance paths on the task branch."));
152
+ }
153
+ catch (e) {
154
+ const msg = e instanceof Error ? e.message : String(e);
155
+ log.warn(`Could not restore stashed governance files (${msg}). Run ${dim("git stash pop")} manually.`);
156
+ }
157
+ }
86
158
  export async function gitCheckout(cwd, ref) {
87
- await runGit(cwd, ["checkout", ref]);
159
+ const stashed = await stashGovernanceIfBlocking(cwd);
160
+ if (stashed) {
161
+ log.info(dim("Stashed governance-only changes (.vibeops/, docs/) before branch switch."));
162
+ }
163
+ await runGit(cwd, ["switch", ref]);
164
+ await restoreGovernanceStashAfterSwitch(cwd, stashed);
88
165
  }
89
166
  export async function gitCheckoutNewBranch(cwd, name, startPoint) {
90
- const args = ["checkout", "-b", name];
167
+ const stashed = await stashGovernanceIfBlocking(cwd);
168
+ if (stashed) {
169
+ log.info(dim("Stashed governance-only changes (.vibeops/, docs/) before branch switch."));
170
+ }
171
+ const args = ["switch", "-c", name];
91
172
  if (typeof startPoint === "string" && startPoint.length > 0)
92
173
  args.push(startPoint);
93
174
  await runGit(cwd, args);
175
+ await restoreGovernanceStashAfterSwitch(cwd, stashed);
94
176
  }
95
177
  export async function gitDeleteBranch(cwd, name, opts = {}) {
96
178
  const flag = opts.force === true ? "-D" : "-d";
@@ -167,12 +249,12 @@ function parsePorcelain(lines) {
167
249
  }
168
250
  return out;
169
251
  }
170
- /** Paths under these prefixes may stay uncommitted across `task done` / doc updates. */
252
+ /** Paths under these prefixes may stay uncommitted across `start` / `done` / doc updates. */
171
253
  const GOVERNANCE_DOC_REL_PREFIXES = [
172
254
  "docs/tasks/",
173
255
  "docs/project/",
174
256
  "docs/logs/",
175
- ".vibeops/state/",
257
+ ".vibeops/",
176
258
  ];
177
259
  function normalizeRepoRelPath(p) {
178
260
  return p.replace(/\\/g, "/").replace(/^\/+/, "");
@@ -185,10 +267,110 @@ export function isGovernanceDocumentationPath(repoRelativePath) {
185
267
  }
186
268
  return false;
187
269
  }
270
+ /** Build artifacts / deps — never auto-committed by `vibeops done` cleanup. */
271
+ const AUTO_COMMIT_EXCLUDED_PREFIXES = [
272
+ "node_modules/",
273
+ ".next/",
274
+ ".pnpm-store/",
275
+ "dist/",
276
+ "build/",
277
+ ".turbo/",
278
+ "coverage/",
279
+ ".vercel/",
280
+ ".output/",
281
+ ".cache/",
282
+ ];
283
+ const AUTO_COMMIT_EXCLUDED_EXACT = new Set([
284
+ "node_modules",
285
+ ".next",
286
+ "dist",
287
+ "build",
288
+ ]);
289
+ export function isAutoCommitExcludedPath(repoRelativePath) {
290
+ const n = normalizeRepoRelPath(repoRelativePath);
291
+ if (AUTO_COMMIT_EXCLUDED_EXACT.has(n))
292
+ return true;
293
+ for (const prefix of AUTO_COMMIT_EXCLUDED_PREFIXES) {
294
+ if (n.startsWith(prefix))
295
+ return true;
296
+ }
297
+ return false;
298
+ }
299
+ export async function gitMergeInProgress(cwd) {
300
+ return (await tryGit(cwd, ["rev-parse", "-q", "--verify", "MERGE_HEAD"])) !== null;
301
+ }
302
+ /** Conflict paths from an in-progress merge (`git diff --diff-filter=U`). */
303
+ export async function listUnmergedRelPaths(cwd) {
304
+ const res = await tryGit(cwd, ["diff", "--name-only", "--diff-filter=U"]);
305
+ if (res === null)
306
+ return [];
307
+ return res.stdout
308
+ .split("\n")
309
+ .map((p) => p.trim())
310
+ .filter((p) => p.length > 0);
311
+ }
312
+ /**
313
+ * During a merge, take the incoming branch version for governance / VibeOps paths
314
+ * (typical after `vibeops done` merge conflicts on `.vibeops/generated/*`).
315
+ */
316
+ export async function tryResolveGovernanceUnmerged(cwd) {
317
+ const unmerged = await listUnmergedRelPaths(cwd);
318
+ const resolved = [];
319
+ for (const p of unmerged) {
320
+ if (!isGovernanceDocumentationPath(p))
321
+ continue;
322
+ try {
323
+ await runGit(cwd, ["checkout", "--theirs", "--", p]);
324
+ await runGit(cwd, ["add", "--", p]);
325
+ resolved.push(p);
326
+ }
327
+ catch {
328
+ // skip paths that cannot be auto-resolved
329
+ }
330
+ }
331
+ return resolved;
332
+ }
333
+ export async function listWorkingTreeRelPaths(cwd) {
334
+ const entries = parsePorcelain(await gitStatusPorcelain(cwd));
335
+ const all = new Set();
336
+ for (const e of entries) {
337
+ all.add(e.path);
338
+ if (typeof e.origPath === "string" && e.origPath.length > 0) {
339
+ all.add(e.origPath);
340
+ }
341
+ }
342
+ return [...all];
343
+ }
344
+ export function partitionPathsForAutoCommit(paths, opts = {}) {
345
+ const unmergedSet = new Set(opts.unmerged ?? []);
346
+ const committable = [];
347
+ const excluded = [];
348
+ const unmerged = [];
349
+ for (const p of paths) {
350
+ if (unmergedSet.has(p)) {
351
+ unmerged.push(p);
352
+ continue;
353
+ }
354
+ if (isAutoCommitExcludedPath(p))
355
+ excluded.push(p);
356
+ else
357
+ committable.push(p);
358
+ }
359
+ return { committable, excluded, unmerged };
360
+ }
361
+ export async function gitAddPaths(cwd, paths) {
362
+ if (paths.length === 0)
363
+ return;
364
+ const chunkSize = 80;
365
+ for (let i = 0; i < paths.length; i += chunkSize) {
366
+ const slice = paths.slice(i, i + chunkSize);
367
+ await runGit(cwd, ["add", "--", ...slice]);
368
+ }
369
+ }
188
370
  /**
189
371
  * When the index/worktree is dirty, classify whether every changed path is
190
- * limited to governance docs (TASK files, project docs, logs, task state JSON).
191
- * Used by `vibeops task start` so `task done --finalize` on main does not
372
+ * limited to governance docs and VibeOps metadata (`.vibeops/**`, docs/tasks, etc.).
373
+ * Used when switching task branches so governance paths are not left off-disk after switch.
192
374
  * block the next TASK unless application code is also dirty.
193
375
  */
194
376
  export async function gitGovernanceOnlyDirty(cwd) {
@@ -0,0 +1,62 @@
1
+ import { probeCodexOAuthFile } from "./plan-codex-auth.js";
2
+ import { codexOAuthTextCompletion } from "./plan-codex-responses.js";
3
+ import { cursorAgentPrint, extractAgentAssistantText } from "./plan-llm-cursor-agent.js";
4
+ import { probeCursorAgentCli, probeOpenAiApiKey } from "./plan-llm-detect.js";
5
+ import { extractJsonObject, openAiChatCompletionJson, openAiChatCompletionText, } from "./plan-llm-openai.js";
6
+ /** Non-interactive: Codex OAuth → Cursor Agent → OpenAI API key (same sources as `vibeops plan`). */
7
+ export async function resolveAvailableLlmProvider(cwd) {
8
+ const codex = await probeCodexOAuthFile();
9
+ if (codex.ok)
10
+ return "codex-oauth";
11
+ const cursor = await probeCursorAgentCli(cwd);
12
+ if (cursor.ok)
13
+ return "cursor-agent";
14
+ const openai = await probeOpenAiApiKey();
15
+ if (openai.ok)
16
+ return "openai";
17
+ return null;
18
+ }
19
+ function formatMessagesForCursor(messages) {
20
+ return messages
21
+ .map((m) => `### ${m.role}\n\n${m.content}`)
22
+ .join("\n\n---\n\n");
23
+ }
24
+ export async function llmCompleteText(messages, opts) {
25
+ const provider = opts.provider ?? (await resolveAvailableLlmProvider(opts.cwd));
26
+ if (provider === null) {
27
+ throw new Error("No LLM provider available. Use Codex OAuth (~/.codex/auth.json), Cursor Agent CLI (`agent login`), or OPENAI_API_KEY.");
28
+ }
29
+ if (provider === "openai") {
30
+ const text = await openAiChatCompletionText({ messages, model: opts.model });
31
+ return { text, provider };
32
+ }
33
+ if (provider === "codex-oauth") {
34
+ const text = await codexOAuthTextCompletion(messages, { model: opts.model });
35
+ return { text, provider };
36
+ }
37
+ const raw = await cursorAgentPrint({
38
+ cwd: opts.cwd,
39
+ prompt: formatMessagesForCursor(messages),
40
+ model: opts.model,
41
+ });
42
+ return { text: extractAgentAssistantText(raw), provider: "cursor-agent" };
43
+ }
44
+ export async function llmCompleteJson(messages, opts) {
45
+ const provider = opts.provider ?? (await resolveAvailableLlmProvider(opts.cwd));
46
+ if (provider === null) {
47
+ throw new Error("No LLM provider available. Use Codex OAuth (~/.codex/auth.json), Cursor Agent CLI (`agent login`), or OPENAI_API_KEY.");
48
+ }
49
+ if (provider === "openai") {
50
+ const text = await openAiChatCompletionJson({ messages, model: opts.model });
51
+ return { text, provider };
52
+ }
53
+ const jsonMessages = [
54
+ ...messages,
55
+ {
56
+ role: "user",
57
+ content: "Reply with a single JSON object only. No markdown code fences or extra prose.",
58
+ },
59
+ ];
60
+ const { text: raw } = await llmCompleteText(jsonMessages, { ...opts, provider });
61
+ return { text: extractJsonObject(raw), provider };
62
+ }