@goodtek/vibeops 0.4.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +198 -1
- package/README.md +62 -402
- package/dist/bootstrap/installer.js +4 -16
- package/dist/cli.js +52 -244
- package/dist/commands/init.js +3 -3
- package/dist/commands/status.js +145 -8
- package/dist/commands/task-add.js +100 -0
- package/dist/commands/task-done.js +117 -74
- package/dist/lib/config.js +2 -118
- package/dist/lib/git.js +190 -8
- package/dist/lib/llm-complete.js +62 -0
- package/dist/lib/paths.js +7 -3
- package/dist/lib/plan-llm-openai.js +0 -63
- package/dist/lib/resolve-task.js +34 -0
- package/dist/lib/slug.js +11 -0
- package/dist/lib/task-add-llm.js +81 -0
- package/dist/lib/task-context.js +68 -0
- package/dist/lib/task-done-llm.js +148 -0
- package/dist/lib/task-merge.js +115 -0
- package/dist/lib/task-scaffold.js +70 -92
- package/dist/lib/task-start.js +72 -0
- package/dist/lib/task-summary.js +12 -239
- package/dist/lib/task.js +80 -37
- package/dist/types/config.js +0 -16
- package/package.json +3 -4
- package/templates/.cursor/rules/01-task-source-of-truth.mdc +12 -0
- package/templates/.cursor/rules/02-git-safety.mdc +11 -0
- package/templates/.cursor/rules/03-docs-on-done.mdc +13 -0
- package/templates/.cursor/skills/implement-task/SKILL.md +22 -0
- package/templates/.cursor/skills/plan-task/SKILL.md +19 -0
- package/templates/AGENTS.md +26 -87
- package/templates/docs/logs/README.md +4 -35
- package/templates/docs/project/03-architecture.md +10 -29
- package/templates/docs/project/05-current-state.md +7 -27
- package/templates/docs/project/06-decisions.md +6 -17
- package/templates/docs/tasks/TASK-000-template.md +9 -44
- package/dist/agent/loader.js +0 -71
- package/dist/agent/prompt.js +0 -66
- package/dist/commands/agent-list.js +0 -32
- package/dist/commands/agent-prompt.js +0 -59
- package/dist/commands/agent-show.js +0 -26
- package/dist/commands/github-init.js +0 -554
- package/dist/commands/github-status.js +0 -164
- package/dist/commands/notion-init.js +0 -764
- package/dist/commands/notion-sync.js +0 -405
- package/dist/commands/notion-test.js +0 -595
- package/dist/commands/plan.js +0 -343
- package/dist/commands/task-check.js +0 -155
- package/dist/commands/task-generate.js +0 -206
- package/dist/commands/task-pull.js +0 -277
- package/dist/commands/task-rollback.js +0 -174
- package/dist/commands/task-start.js +0 -102
- package/dist/lib/brief.js +0 -356
- package/dist/lib/github-cli.js +0 -247
- package/dist/lib/notion-client.js +0 -459
- package/dist/lib/notion-discovery.js +0 -671
- package/dist/lib/notion-env.js +0 -140
- package/dist/lib/notion-mappers.js +0 -148
- package/dist/lib/notion-schema.js +0 -272
- package/dist/lib/notion-sync.js +0 -337
- package/dist/lib/notion-target.js +0 -247
- package/dist/lib/package-json.js +0 -133
- package/dist/lib/plan-apply-planner.js +0 -131
- package/dist/lib/plan-codex-pkce.js +0 -234
- package/dist/lib/plan-llm-models.js +0 -237
- package/dist/lib/plan-llm-normalize.js +0 -146
- package/dist/lib/plan-llm-session.js +0 -682
- package/dist/lib/plan-post-apply-setup.js +0 -116
- package/dist/lib/project-docs.js +0 -95
- package/dist/lib/prompt-builder.js +0 -135
- package/dist/lib/task-generator.js +0 -183
- package/dist/lib/task-prompt.js +0 -23
- package/dist/lib/task-pull.js +0 -354
- package/dist/status/collector.js +0 -103
- package/dist/status/format.js +0 -177
- package/dist/types/brief.js +0 -126
- package/templates/.cursor/rules/00-project-governance.mdc +0 -28
- package/templates/.cursor/rules/01-agent-orchestration.mdc +0 -48
- package/templates/.cursor/rules/02-task-workflow.mdc +0 -38
- package/templates/.cursor/rules/03-git-safety.mdc +0 -30
- package/templates/.cursor/rules/04-docs-update.mdc +0 -22
- package/templates/.vibeops/agents/architect.md +0 -47
- package/templates/.vibeops/agents/builder.md +0 -38
- package/templates/.vibeops/agents/docs.md +0 -54
- package/templates/.vibeops/agents/orchestrator.md +0 -40
- package/templates/.vibeops/agents/planner.md +0 -60
- package/templates/.vibeops/agents/recovery.md +0 -49
- package/templates/.vibeops/agents/reviewer.md +0 -47
- package/templates/.vibeops/agents/tester.md +0 -43
- package/templates/.vibeops/prompts/create-plan.md +0 -33
- package/templates/.vibeops/prompts/generate-tasks.md +0 -41
- package/templates/.vibeops/prompts/implement-task.md +0 -39
- package/templates/.vibeops/prompts/review-task.md +0 -34
- package/templates/.vibeops/prompts/rollback.md +0 -32
- package/templates/.vibeops/prompts/start-project.md +0 -39
- package/templates/.vibeops/workflows/notion-sync.md +0 -53
- package/templates/.vibeops/workflows/project-start.md +0 -73
- package/templates/.vibeops/workflows/rollback.md +0 -45
- package/templates/.vibeops/workflows/task-lifecycle.md +0 -71
- package/templates/docs/project/00-overview.md +0 -27
- package/templates/docs/project/01-requirements.md +0 -30
- package/templates/docs/project/02-mvp-scope.md +0 -36
- package/templates/docs/project/04-tech-stack.md +0 -29
- package/templates/docs/project/07-backlog.md +0 -23
- package/templates/docs/project/08-env.md +0 -29
- package/templates/docs/project/09-deployment.md +0 -28
package/dist/lib/paths.js
CHANGED
|
@@ -14,13 +14,17 @@ export function projectPaths(root) {
|
|
|
14
14
|
envExample: join(abs, VIBEOPS_ENV_EXAMPLE_FILE),
|
|
15
15
|
agentsMd: join(abs, "AGENTS.md"),
|
|
16
16
|
cursorRules: join(abs, ".cursor", "rules"),
|
|
17
|
+
cursorSkills: join(abs, ".cursor", "skills"),
|
|
17
18
|
docsProject: join(abs, "docs", "project"),
|
|
18
19
|
docsTasks: join(abs, "docs", "tasks"),
|
|
19
20
|
docsLogs: join(abs, "docs", "logs"),
|
|
20
21
|
vibeopsDir: join(abs, ".vibeops"),
|
|
21
|
-
vibeopsAgents: join(abs, ".vibeops", "agents"),
|
|
22
|
-
vibeopsPrompts: join(abs, ".vibeops", "prompts"),
|
|
23
|
-
vibeopsWorkflows: join(abs, ".vibeops", "workflows"),
|
|
24
22
|
gitignore: join(abs, ".gitignore"),
|
|
25
23
|
};
|
|
26
24
|
}
|
|
25
|
+
/** Project memory files updated by `vibeops task done`. */
|
|
26
|
+
export const PROJECT_MEMORY_FILES = {
|
|
27
|
+
currentState: "docs/project/05-current-state.md",
|
|
28
|
+
architecture: "docs/project/03-architecture.md",
|
|
29
|
+
decisions: "docs/project/06-decisions.md",
|
|
30
|
+
};
|
|
@@ -29,7 +29,6 @@ export async function openAiChatCompletionJson(params) {
|
|
|
29
29
|
}
|
|
30
30
|
return content.trim();
|
|
31
31
|
}
|
|
32
|
-
/** Plain assistant text (no `response_format`) — used for planner markdown apply. */
|
|
33
32
|
export async function openAiChatCompletionText(params) {
|
|
34
33
|
const key = process.env.OPENAI_API_KEY?.trim();
|
|
35
34
|
if (!key) {
|
|
@@ -70,65 +69,3 @@ export function extractJsonObject(text) {
|
|
|
70
69
|
return trimmed.slice(start, end + 1);
|
|
71
70
|
return trimmed;
|
|
72
71
|
}
|
|
73
|
-
export function parsePlanLlmTurn(jsonText) {
|
|
74
|
-
const raw = JSON.parse(jsonText);
|
|
75
|
-
const turn = raw.turn;
|
|
76
|
-
if (turn === "done") {
|
|
77
|
-
const pb = raw.projectBrief;
|
|
78
|
-
if (!pb || typeof pb !== "object" || Array.isArray(pb)) {
|
|
79
|
-
throw new Error('Invalid JSON: "done" turn requires object "projectBrief".');
|
|
80
|
-
}
|
|
81
|
-
const plannerAssumptions = raw.plannerAssumptions;
|
|
82
|
-
const pa = Array.isArray(plannerAssumptions) && plannerAssumptions.every((x) => typeof x === "string")
|
|
83
|
-
? plannerAssumptions
|
|
84
|
-
: undefined;
|
|
85
|
-
return {
|
|
86
|
-
turn: "done",
|
|
87
|
-
projectBrief: pb,
|
|
88
|
-
plannerAssumptions: pa,
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
if (turn === "confirm") {
|
|
92
|
-
const readableSummary = typeof raw.readableSummary === "string" ? raw.readableSummary.trim() : "";
|
|
93
|
-
if (readableSummary.length === 0) {
|
|
94
|
-
throw new Error('Invalid JSON: "confirm" turn needs non-empty "readableSummary" (markdown).');
|
|
95
|
-
}
|
|
96
|
-
const plannerNote = typeof raw.plannerNote === "string" ? raw.plannerNote.trim() : undefined;
|
|
97
|
-
return {
|
|
98
|
-
turn: "confirm",
|
|
99
|
-
readableSummary,
|
|
100
|
-
plannerNote: plannerNote && plannerNote.length > 0 ? plannerNote : undefined,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
if (turn === "question") {
|
|
104
|
-
const message = typeof raw.message === "string" ? raw.message : "";
|
|
105
|
-
if (message.length === 0) {
|
|
106
|
-
throw new Error('Invalid JSON: "question" turn needs non-empty "message".');
|
|
107
|
-
}
|
|
108
|
-
const options = Array.isArray(raw.options)
|
|
109
|
-
? raw.options.filter((x) => typeof x === "string" && x.trim().length > 0)
|
|
110
|
-
: [];
|
|
111
|
-
const rawQt = raw.questionType;
|
|
112
|
-
let questionType;
|
|
113
|
-
if (rawQt === "single" || rawQt === "multi" || rawQt === "text") {
|
|
114
|
-
questionType = rawQt;
|
|
115
|
-
}
|
|
116
|
-
else if (options.length >= 2) {
|
|
117
|
-
/** Model often omits or mistypes questionType — multi is the safer default when lists are offered. */
|
|
118
|
-
questionType = "multi";
|
|
119
|
-
}
|
|
120
|
-
else if (options.length === 1) {
|
|
121
|
-
questionType = "single";
|
|
122
|
-
}
|
|
123
|
-
else {
|
|
124
|
-
questionType = "text";
|
|
125
|
-
}
|
|
126
|
-
return {
|
|
127
|
-
turn: "question",
|
|
128
|
-
message,
|
|
129
|
-
questionType,
|
|
130
|
-
options: options.length > 0 ? options : undefined,
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
throw new Error('Invalid JSON: "turn" must be "question", "confirm", or "done".');
|
|
134
|
-
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { findTaskFile, loadActionableTasks, pickActiveTask, pickInProgressTask, } from "./task.js";
|
|
2
|
+
export function normalizeTaskRef(ref) {
|
|
3
|
+
const t = ref.trim();
|
|
4
|
+
if (/^task-/i.test(t) && !/^TASK-/i.test(t)) {
|
|
5
|
+
return `TASK-${t.replace(/^task-/i, "")}`;
|
|
6
|
+
}
|
|
7
|
+
return t.toUpperCase().startsWith("TASK-") ? t.toUpperCase() : `TASK-${t}`;
|
|
8
|
+
}
|
|
9
|
+
export async function resolveTask(paths, cwd, taskRef) {
|
|
10
|
+
const trimmed = taskRef?.trim();
|
|
11
|
+
if (trimmed !== undefined && trimmed.length > 0) {
|
|
12
|
+
const taskId = normalizeTaskRef(trimmed);
|
|
13
|
+
const taskFile = await findTaskFile(paths.docsTasks, taskId);
|
|
14
|
+
if (taskFile === null)
|
|
15
|
+
return null;
|
|
16
|
+
return { taskId, taskFile };
|
|
17
|
+
}
|
|
18
|
+
const tasks = await loadActionableTasks(paths.docsTasks);
|
|
19
|
+
const inProgress = pickInProgressTask(tasks);
|
|
20
|
+
if (inProgress !== null) {
|
|
21
|
+
return { taskId: inProgress.id, taskFile: inProgress.filePath };
|
|
22
|
+
}
|
|
23
|
+
const onBranch = await pickActiveTask(paths.docsTasks, cwd);
|
|
24
|
+
if (onBranch !== null && onBranch.status !== "done") {
|
|
25
|
+
return { taskId: onBranch.id, taskFile: onBranch.filePath };
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
export function taskNotFoundMessage(taskRef) {
|
|
30
|
+
if (taskRef?.trim()) {
|
|
31
|
+
return `TASK not found: ${normalizeTaskRef(taskRef)}. Check docs/tasks/.`;
|
|
32
|
+
}
|
|
33
|
+
return "No open TASK found. Run `vibeops task add` or pass `vibeops task done TASK-NNN`.";
|
|
34
|
+
}
|
package/dist/lib/slug.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Filename-safe slug (TASK-mvp-*, branch names, etc.). */
|
|
2
|
+
export function slugify(input, fallback = "task") {
|
|
3
|
+
const cleaned = input
|
|
4
|
+
.toLowerCase()
|
|
5
|
+
.normalize("NFKD")
|
|
6
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
7
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
8
|
+
.replace(/^-+|-+$/g, "")
|
|
9
|
+
.replace(/-+/g, "-");
|
|
10
|
+
return cleaned.length > 0 ? cleaned.slice(0, 80) : fallback;
|
|
11
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { join, relative } from "node:path";
|
|
2
|
+
import { readTextOrNull } from "./filesystem.js";
|
|
3
|
+
import { llmCompleteJson } from "./llm-complete.js";
|
|
4
|
+
import { projectPaths } from "./paths.js";
|
|
5
|
+
import { slugify } from "./slug.js";
|
|
6
|
+
import { buildMinimalTaskMarkdown, ensureV3Sections, normalizeTaskHeader, titleFromIdea, } from "./task-scaffold.js";
|
|
7
|
+
import { statusDisplay } from "./task.js";
|
|
8
|
+
import { extractJsonObject } from "./plan-llm-openai.js";
|
|
9
|
+
async function readSnippet(absPath, maxChars) {
|
|
10
|
+
const text = await readTextOrNull(absPath);
|
|
11
|
+
if (text === null)
|
|
12
|
+
return null;
|
|
13
|
+
const t = text.trim();
|
|
14
|
+
return t.length <= maxChars ? t : `${t.slice(0, maxChars)}\n…`;
|
|
15
|
+
}
|
|
16
|
+
export async function loadProjectContextForLlm(cwd) {
|
|
17
|
+
const paths = projectPaths(cwd);
|
|
18
|
+
const candidates = [
|
|
19
|
+
join(paths.docsProject, "05-current-state.md"),
|
|
20
|
+
join(paths.docsProject, "03-architecture.md"),
|
|
21
|
+
join(paths.docsProject, "06-decisions.md"),
|
|
22
|
+
];
|
|
23
|
+
const chunks = [];
|
|
24
|
+
for (const p of candidates) {
|
|
25
|
+
const snip = await readSnippet(p, 3000);
|
|
26
|
+
if (snip)
|
|
27
|
+
chunks.push(`### ${relative(cwd, p)}\n\n${snip}`);
|
|
28
|
+
}
|
|
29
|
+
return chunks.length > 0 ? chunks.join("\n\n") : "(no project docs yet)";
|
|
30
|
+
}
|
|
31
|
+
function systemPrompt() {
|
|
32
|
+
return `You are a VibeOps TASK author. Reply with JSON only.
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
"title": "short title without TASK- id",
|
|
36
|
+
"slug": "kebab-case-max-60-chars",
|
|
37
|
+
"markdown": "full TASK markdown"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
markdown must include sections: Status, Goal, Scope, Out of Scope, Acceptance Criteria, Test Plan, Git Context, Result, Test Result.
|
|
41
|
+
Status body: ${statusDisplay("planned")}
|
|
42
|
+
Result and Test Result: (not yet)
|
|
43
|
+
Git Context: (populated by vibeops task add)
|
|
44
|
+
Use the exact TASK id given in the user message for the # heading.`;
|
|
45
|
+
}
|
|
46
|
+
export async function llmScaffoldTask(params) {
|
|
47
|
+
try {
|
|
48
|
+
const context = await loadProjectContextForLlm(params.cwd);
|
|
49
|
+
const messages = [
|
|
50
|
+
{ role: "system", content: systemPrompt() },
|
|
51
|
+
{
|
|
52
|
+
role: "user",
|
|
53
|
+
content: [`TASK id: ${params.taskId}`, `Idea: ${params.idea}`, "", context].join("\n"),
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
const { text: raw, provider } = await llmCompleteJson(messages, { cwd: params.cwd });
|
|
57
|
+
const parsed = JSON.parse(extractJsonObject(raw));
|
|
58
|
+
const title = typeof parsed.title === "string"
|
|
59
|
+
? parsed.title.replace(/^TASK-\d+\s*[:\-]\s*/i, "").trim()
|
|
60
|
+
: "";
|
|
61
|
+
const slug = typeof parsed.slug === "string" ? slugify(parsed.slug, "task") : slugify(title, "task");
|
|
62
|
+
const markdown = typeof parsed.markdown === "string" ? parsed.markdown.trim() : "";
|
|
63
|
+
if (title.length === 0 || markdown.length === 0)
|
|
64
|
+
return null;
|
|
65
|
+
const body = ensureV3Sections(normalizeTaskHeader(markdown, params.taskId, title));
|
|
66
|
+
return { title, slug, markdown: body, provider };
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export function fallbackTaskDraft(taskId, idea) {
|
|
73
|
+
const title = titleFromIdea(idea);
|
|
74
|
+
const draft = { id: taskId, title, idea };
|
|
75
|
+
const slug = slugify(title);
|
|
76
|
+
return {
|
|
77
|
+
title,
|
|
78
|
+
slug,
|
|
79
|
+
markdown: ensureV3Sections(normalizeTaskHeader(buildMinimalTaskMarkdown(draft), taskId, title)),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { gitBranchExists, readGitInfo } from "./git.js";
|
|
3
|
+
import { readGitContext } from "./task.js";
|
|
4
|
+
import { isOnTaskBranch, loadActionableTasks, pickActiveTask, pickInProgressTask, } from "./task.js";
|
|
5
|
+
/** TASK that blocks `task add` — in progress or open on current task branch. */
|
|
6
|
+
export async function findBlockingTask(paths, cwd) {
|
|
7
|
+
const tasks = await loadActionableTasks(paths.docsTasks);
|
|
8
|
+
const inProgress = pickInProgressTask(tasks);
|
|
9
|
+
if (inProgress !== null)
|
|
10
|
+
return inProgress;
|
|
11
|
+
const git = await readGitInfo(cwd);
|
|
12
|
+
if (git.isRepo && typeof git.branch === "string" && git.branch.startsWith("task/")) {
|
|
13
|
+
for (const t of tasks) {
|
|
14
|
+
if (t.status === "done")
|
|
15
|
+
continue;
|
|
16
|
+
const ctx = await readGitContext(t.filePath);
|
|
17
|
+
if (isOnTaskBranch(git.branch, ctx))
|
|
18
|
+
return t;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
export async function pickFocusTask(paths, cwd) {
|
|
24
|
+
return pickActiveTask(paths.docsTasks, cwd);
|
|
25
|
+
}
|
|
26
|
+
export function relPath(cwd, filePath) {
|
|
27
|
+
const r = relative(cwd, filePath).replace(/\\/g, "/");
|
|
28
|
+
return r.startsWith("..") ? filePath : r;
|
|
29
|
+
}
|
|
30
|
+
export function computeNextHint(input) {
|
|
31
|
+
if (!input.isVibeopsProject)
|
|
32
|
+
return "init";
|
|
33
|
+
if (input.focus === null)
|
|
34
|
+
return "task-add";
|
|
35
|
+
if (input.focus.status === "done")
|
|
36
|
+
return "task-add";
|
|
37
|
+
if (!input.resultFilled || !input.testFilled) {
|
|
38
|
+
return input.onTaskBranch === true ? "cursor-implement" : "cursor-plan";
|
|
39
|
+
}
|
|
40
|
+
return "task-done";
|
|
41
|
+
}
|
|
42
|
+
export function hintToText(hint, cwd, task) {
|
|
43
|
+
switch (hint) {
|
|
44
|
+
case "init":
|
|
45
|
+
return "Run `vibeops init` in this directory.";
|
|
46
|
+
case "task-add":
|
|
47
|
+
return "Run `vibeops task add` to start the next slice.";
|
|
48
|
+
case "task-done":
|
|
49
|
+
return task ? `Run \`vibeops task done ${task.id}\`.` : "Run `vibeops task done`.";
|
|
50
|
+
case "cursor-plan":
|
|
51
|
+
return task
|
|
52
|
+
? `In Cursor Ask: @${relPath(cwd, task.filePath)} — refine Scope / AC.`
|
|
53
|
+
: "Open the TASK file in Cursor Ask.";
|
|
54
|
+
case "cursor-implement":
|
|
55
|
+
return task
|
|
56
|
+
? `In Cursor Agent: @${relPath(cwd, task.filePath)} — implement, then task done.`
|
|
57
|
+
: "Implement per the TASK file in Cursor.";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function listInProgressTasks(paths) {
|
|
61
|
+
return (await loadActionableTasks(paths.docsTasks)).filter((t) => t.status === "in_progress");
|
|
62
|
+
}
|
|
63
|
+
export async function taskBranchExistsFor(cwd, task) {
|
|
64
|
+
const ctx = await readGitContext(task.filePath);
|
|
65
|
+
if (ctx === null)
|
|
66
|
+
return false;
|
|
67
|
+
return gitBranchExists(cwd, ctx.taskBranch);
|
|
68
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readText, readTextOrNull, writeText } from "./filesystem.js";
|
|
3
|
+
import { llmCompleteJson } from "./llm-complete.js";
|
|
4
|
+
import { gitDiffNameOnly, readGitInfo } from "./git.js";
|
|
5
|
+
import { projectPaths, PROJECT_MEMORY_FILES } from "./paths.js";
|
|
6
|
+
import { extractJsonObject } from "./plan-llm-openai.js";
|
|
7
|
+
import { readSection, updateTaskSection } from "./task.js";
|
|
8
|
+
function localDateFilename() {
|
|
9
|
+
const d = new Date();
|
|
10
|
+
const y = d.getFullYear();
|
|
11
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
12
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
13
|
+
return `${y}-${m}-${day}.md`;
|
|
14
|
+
}
|
|
15
|
+
async function readOptional(path) {
|
|
16
|
+
return readTextOrNull(path);
|
|
17
|
+
}
|
|
18
|
+
export async function llmCompleteTaskDone(params) {
|
|
19
|
+
const git = await readGitInfo(params.cwd);
|
|
20
|
+
let diffSummary = "";
|
|
21
|
+
if (git.isRepo) {
|
|
22
|
+
const names = await gitDiffNameOnly(params.cwd);
|
|
23
|
+
diffSummary = names.slice(0, 40).join("\n") || "(no diff vs HEAD index yet)";
|
|
24
|
+
}
|
|
25
|
+
const paths = projectPaths(params.cwd);
|
|
26
|
+
const currentState = (await readOptional(join(paths.root, PROJECT_MEMORY_FILES.currentState))) ?? "";
|
|
27
|
+
const architecture = (await readOptional(join(paths.root, PROJECT_MEMORY_FILES.architecture))) ?? "";
|
|
28
|
+
const decisions = (await readOptional(join(paths.root, PROJECT_MEMORY_FILES.decisions))) ?? "";
|
|
29
|
+
const messages = [
|
|
30
|
+
{
|
|
31
|
+
role: "system",
|
|
32
|
+
content: `You close a VibeOps TASK. Reply with JSON only:
|
|
33
|
+
{
|
|
34
|
+
"result": "markdown for TASK Result section — facts, paths, commands",
|
|
35
|
+
"testResult": "markdown for Test Result — commands and outcomes",
|
|
36
|
+
"currentStateMarkdown": "full replacement for docs/project/05-current-state.md OR null to skip",
|
|
37
|
+
"decisionsAppend": "markdown bullets to append under ## Decisions in 06-decisions.md OR null",
|
|
38
|
+
"architectureUpdate": "full replacement for 03-architecture.md OR null if structure unchanged",
|
|
39
|
+
"logLine": "one line for docs/logs/YYYY-MM-DD.md"
|
|
40
|
+
}
|
|
41
|
+
Be factual. Do not invent files not in the diff.`,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
role: "user",
|
|
45
|
+
content: [
|
|
46
|
+
`TASK: ${params.taskId} — ${params.taskTitle}`,
|
|
47
|
+
`File: ${params.taskFileRel}`,
|
|
48
|
+
"",
|
|
49
|
+
"TASK body:",
|
|
50
|
+
params.taskBody.slice(0, 8000),
|
|
51
|
+
"",
|
|
52
|
+
"Changed paths (git):",
|
|
53
|
+
diffSummary,
|
|
54
|
+
"",
|
|
55
|
+
"Current 05-current-state.md:",
|
|
56
|
+
currentState.slice(0, 4000),
|
|
57
|
+
"",
|
|
58
|
+
"Current 03-architecture.md:",
|
|
59
|
+
architecture.slice(0, 4000),
|
|
60
|
+
"",
|
|
61
|
+
"Current 06-decisions.md:",
|
|
62
|
+
decisions.slice(0, 2000),
|
|
63
|
+
].join("\n"),
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
try {
|
|
67
|
+
const { text: raw, provider } = await llmCompleteJson(messages, { cwd: params.cwd });
|
|
68
|
+
const parsed = JSON.parse(extractJsonObject(raw));
|
|
69
|
+
const result = typeof parsed.result === "string" ? parsed.result.trim() : "";
|
|
70
|
+
const testResult = typeof parsed.testResult === "string" ? parsed.testResult.trim() : "";
|
|
71
|
+
if (result.length === 0 || testResult.length === 0)
|
|
72
|
+
return null;
|
|
73
|
+
return {
|
|
74
|
+
result,
|
|
75
|
+
testResult,
|
|
76
|
+
currentStateMarkdown: typeof parsed.currentStateMarkdown === "string" && parsed.currentStateMarkdown.trim().length > 0
|
|
77
|
+
? parsed.currentStateMarkdown.trim()
|
|
78
|
+
: null,
|
|
79
|
+
decisionsAppend: typeof parsed.decisionsAppend === "string" && parsed.decisionsAppend.trim().length > 0
|
|
80
|
+
? parsed.decisionsAppend.trim()
|
|
81
|
+
: null,
|
|
82
|
+
architectureUpdate: typeof parsed.architectureUpdate === "string" && parsed.architectureUpdate.trim().length > 0
|
|
83
|
+
? parsed.architectureUpdate.trim()
|
|
84
|
+
: null,
|
|
85
|
+
logLine: typeof parsed.logLine === "string" && parsed.logLine.trim().length > 0
|
|
86
|
+
? parsed.logLine.trim()
|
|
87
|
+
: `${params.taskId} done.`,
|
|
88
|
+
provider,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export async function applyTaskDoneMemory(cwd, patch) {
|
|
96
|
+
const paths = projectPaths(cwd);
|
|
97
|
+
const updated = [];
|
|
98
|
+
const skipped = [];
|
|
99
|
+
if (patch.currentStateMarkdown) {
|
|
100
|
+
const p = join(paths.root, PROJECT_MEMORY_FILES.currentState);
|
|
101
|
+
await writeText(p, `${patch.currentStateMarkdown.trimEnd()}\n`);
|
|
102
|
+
updated.push(PROJECT_MEMORY_FILES.currentState);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
skipped.push(PROJECT_MEMORY_FILES.currentState);
|
|
106
|
+
}
|
|
107
|
+
if (patch.architectureUpdate) {
|
|
108
|
+
const p = join(paths.root, PROJECT_MEMORY_FILES.architecture);
|
|
109
|
+
await writeText(p, `${patch.architectureUpdate.trimEnd()}\n`);
|
|
110
|
+
updated.push(PROJECT_MEMORY_FILES.architecture);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
skipped.push(PROJECT_MEMORY_FILES.architecture);
|
|
114
|
+
}
|
|
115
|
+
if (patch.decisionsAppend) {
|
|
116
|
+
const p = join(paths.root, PROJECT_MEMORY_FILES.decisions);
|
|
117
|
+
const existing = (await readOptional(p)) ?? "# Decisions\n\n";
|
|
118
|
+
const next = `${existing.trimEnd()}\n\n${patch.decisionsAppend.trim()}\n`;
|
|
119
|
+
await writeText(p, next);
|
|
120
|
+
updated.push(PROJECT_MEMORY_FILES.decisions);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
skipped.push(PROJECT_MEMORY_FILES.decisions);
|
|
124
|
+
}
|
|
125
|
+
const logPath = join(paths.docsLogs, localDateFilename());
|
|
126
|
+
const logExisting = (await readOptional(logPath)) ?? `# ${localDateFilename().replace(".md", "")}\n\n`;
|
|
127
|
+
await writeText(logPath, `${logExisting.trimEnd()}\n\n- ${patch.logLine}\n`);
|
|
128
|
+
updated.push(relativeLogPath(logPath, paths.root));
|
|
129
|
+
return { updated, skipped };
|
|
130
|
+
}
|
|
131
|
+
function relativeLogPath(abs, root) {
|
|
132
|
+
return abs.startsWith(root) ? abs.slice(root.length + 1) : abs;
|
|
133
|
+
}
|
|
134
|
+
export async function writeTaskResultSections(taskFile, result, testResult) {
|
|
135
|
+
await updateTaskSection(taskFile, "Result", result);
|
|
136
|
+
await updateTaskSection(taskFile, "Test Result", testResult);
|
|
137
|
+
}
|
|
138
|
+
/** Minimal fallback when LLM unavailable — git-only sections from diff. */
|
|
139
|
+
export async function fallbackResultSections(cwd, taskId, taskFile) {
|
|
140
|
+
const names = await gitDiffNameOnly(cwd);
|
|
141
|
+
const body = await readText(taskFile);
|
|
142
|
+
const goal = readSection(body, "Goal").slice(0, 200);
|
|
143
|
+
const result = [`Completed ${taskId}.`, goal.length > 0 ? `Goal: ${goal}` : "", names.length > 0 ? `Paths: ${names.slice(0, 15).join(", ")}` : ""]
|
|
144
|
+
.filter((l) => l.length > 0)
|
|
145
|
+
.join("\n");
|
|
146
|
+
const testResult = names.length > 0 ? `Changed: ${names.join(", ")}` : "Manual verification required.";
|
|
147
|
+
return { result, testResult };
|
|
148
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { gitBranchExists, gitCheckout, gitDeleteBranch, gitGovernanceOnlyDirty, gitRemoteUrl, readGitInfo, runGit, } from "./git.js";
|
|
2
|
+
import { bold, cyan, dim, log } from "./logger.js";
|
|
3
|
+
function mergeCommitMessage(taskId, taskTitle, taskBranch) {
|
|
4
|
+
const slug = taskTitle.replace(/^TASK-\d+\s*[:\-]\s*/i, "").trim() || taskId;
|
|
5
|
+
return `merge(${taskId.toLowerCase()}): ${slug} (${taskBranch})`;
|
|
6
|
+
}
|
|
7
|
+
async function gitPush(cwd, remote, ref, setUpstream) {
|
|
8
|
+
const args = ["push"];
|
|
9
|
+
if (setUpstream)
|
|
10
|
+
args.push("-u");
|
|
11
|
+
args.push(remote, ref);
|
|
12
|
+
await runGit(cwd, args);
|
|
13
|
+
}
|
|
14
|
+
export async function mergeTaskBranch(opts) {
|
|
15
|
+
const remote = opts.remote ?? "origin";
|
|
16
|
+
const { cwd, gitCtx, taskId, taskTitle } = opts;
|
|
17
|
+
const { baseBranch, taskBranch } = gitCtx;
|
|
18
|
+
if (baseBranch === taskBranch) {
|
|
19
|
+
log.error("Task branch matches base branch; nothing to merge.");
|
|
20
|
+
return { ok: false };
|
|
21
|
+
}
|
|
22
|
+
const git = await readGitInfo(cwd);
|
|
23
|
+
if (!git.isRepo) {
|
|
24
|
+
log.error("Not a git repository.");
|
|
25
|
+
return { ok: false };
|
|
26
|
+
}
|
|
27
|
+
const branchExists = await gitBranchExists(cwd, taskBranch);
|
|
28
|
+
if (!branchExists) {
|
|
29
|
+
log.error(`Local task branch not found: ${taskBranch}`);
|
|
30
|
+
return { ok: false };
|
|
31
|
+
}
|
|
32
|
+
const remoteUrl = await gitRemoteUrl(cwd, remote);
|
|
33
|
+
const hasRemote = remoteUrl !== null;
|
|
34
|
+
if (opts.dryRun) {
|
|
35
|
+
log.info(bold("dry-run — would merge:"));
|
|
36
|
+
log.info(` · checkout ${baseBranch}`);
|
|
37
|
+
log.info(` · merge --no-ff ${taskBranch}`);
|
|
38
|
+
if (hasRemote)
|
|
39
|
+
log.info(` · push ${remote} (optional)`);
|
|
40
|
+
log.info(` · delete local ${taskBranch}`);
|
|
41
|
+
return { ok: true };
|
|
42
|
+
}
|
|
43
|
+
if (git.dirty === true && opts.allowDirty !== true) {
|
|
44
|
+
const gov = await gitGovernanceOnlyDirty(cwd);
|
|
45
|
+
if (!gov.onlyGovernance) {
|
|
46
|
+
log.error("Working tree is dirty. Commit or use --allow-dirty.");
|
|
47
|
+
return { ok: false };
|
|
48
|
+
}
|
|
49
|
+
log.warn("Dirty tree is governance-only — proceeding.");
|
|
50
|
+
}
|
|
51
|
+
log.step(bold(`Merging ${cyan(taskBranch)} → ${cyan(baseBranch)}`));
|
|
52
|
+
try {
|
|
53
|
+
if (hasRemote) {
|
|
54
|
+
try {
|
|
55
|
+
await gitPush(cwd, remote, taskBranch, true);
|
|
56
|
+
await runGit(cwd, ["fetch", remote]);
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
60
|
+
log.warn(`Push skipped (${msg}). Continuing with local merge.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
await gitCheckout(cwd, baseBranch);
|
|
64
|
+
if (hasRemote) {
|
|
65
|
+
try {
|
|
66
|
+
await runGit(cwd, ["pull", remote, baseBranch]);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
log.warn(dim(`Could not pull ${remote}/${baseBranch} — merging locally.`));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
await runGit(cwd, [
|
|
73
|
+
"merge",
|
|
74
|
+
"--no-ff",
|
|
75
|
+
taskBranch,
|
|
76
|
+
"-m",
|
|
77
|
+
mergeCommitMessage(taskId, taskTitle, taskBranch),
|
|
78
|
+
]);
|
|
79
|
+
if (hasRemote) {
|
|
80
|
+
try {
|
|
81
|
+
await gitPush(cwd, remote, baseBranch, false);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
log.warn(dim(`Merge commit created locally; push ${remote} ${baseBranch} manually.`));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
log.ok(`Merged into ${baseBranch}`);
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
91
|
+
log.error(`Merge failed: ${msg}`);
|
|
92
|
+
return { ok: false };
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
await gitDeleteBranch(cwd, taskBranch, { force: false });
|
|
96
|
+
log.ok(`Deleted local branch ${taskBranch}`);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
try {
|
|
100
|
+
await gitDeleteBranch(cwd, taskBranch, { force: true });
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
log.warn(`Could not delete local branch ${taskBranch}.`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (hasRemote) {
|
|
107
|
+
try {
|
|
108
|
+
await runGit(cwd, ["push", remote, "--delete", taskBranch]);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
log.warn(dim(`Remote branch ${remote}/${taskBranch} may already be gone.`));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { ok: true };
|
|
115
|
+
}
|