@goodtek/vibeops 0.2.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 +28 -0
- package/LICENSE +21 -0
- package/README.md +444 -0
- package/dist/agent/loader.js +71 -0
- package/dist/agent/prompt.js +66 -0
- package/dist/bootstrap/installer.js +149 -0
- package/dist/bootstrap/manifest.js +15 -0
- package/dist/bootstrap/substitute.js +35 -0
- package/dist/cli.js +241 -0
- package/dist/commands/agent-list.js +32 -0
- package/dist/commands/agent-prompt.js +59 -0
- package/dist/commands/agent-show.js +26 -0
- package/dist/commands/github-init.js +554 -0
- package/dist/commands/github-status.js +164 -0
- package/dist/commands/init.js +179 -0
- package/dist/commands/notion-init.js +764 -0
- package/dist/commands/notion-sync.js +405 -0
- package/dist/commands/notion-test.js +595 -0
- package/dist/commands/plan.js +114 -0
- package/dist/commands/status.js +17 -0
- package/dist/commands/task-check.js +155 -0
- package/dist/commands/task-done.js +98 -0
- package/dist/commands/task-generate.js +206 -0
- package/dist/commands/task-pull.js +277 -0
- package/dist/commands/task-rollback.js +174 -0
- package/dist/commands/task-start.js +90 -0
- package/dist/lib/brief.js +349 -0
- package/dist/lib/config.js +158 -0
- package/dist/lib/filesystem.js +67 -0
- package/dist/lib/git.js +237 -0
- package/dist/lib/github-cli.js +247 -0
- package/dist/lib/inquirer-helpers.js +111 -0
- package/dist/lib/logger.js +42 -0
- package/dist/lib/notion-client.js +459 -0
- package/dist/lib/notion-discovery.js +671 -0
- package/dist/lib/notion-env.js +140 -0
- package/dist/lib/notion-mappers.js +148 -0
- package/dist/lib/notion-schema.js +272 -0
- package/dist/lib/notion-sync.js +337 -0
- package/dist/lib/notion-target.js +247 -0
- package/dist/lib/package-json.js +133 -0
- package/dist/lib/paths.js +26 -0
- package/dist/lib/project-docs.js +95 -0
- package/dist/lib/prompt-builder.js +125 -0
- package/dist/lib/task-generator.js +183 -0
- package/dist/lib/task-prompt.js +23 -0
- package/dist/lib/task-pull.js +354 -0
- package/dist/lib/task-scaffold.js +128 -0
- package/dist/lib/task-summary.js +276 -0
- package/dist/lib/task.js +364 -0
- package/dist/status/collector.js +103 -0
- package/dist/status/format.js +177 -0
- package/dist/types/brief.js +126 -0
- package/dist/types/config.js +17 -0
- package/dist/types/task.js +1 -0
- package/dist/version.js +8 -0
- package/package.json +61 -0
- package/templates/.cursor/rules/00-project-governance.mdc +28 -0
- package/templates/.cursor/rules/01-agent-orchestration.mdc +48 -0
- package/templates/.cursor/rules/02-task-workflow.mdc +38 -0
- package/templates/.cursor/rules/03-git-safety.mdc +30 -0
- package/templates/.cursor/rules/04-docs-update.mdc +22 -0
- package/templates/.vibeops/agents/architect.md +47 -0
- package/templates/.vibeops/agents/builder.md +38 -0
- package/templates/.vibeops/agents/docs.md +54 -0
- package/templates/.vibeops/agents/orchestrator.md +40 -0
- package/templates/.vibeops/agents/planner.md +60 -0
- package/templates/.vibeops/agents/recovery.md +49 -0
- package/templates/.vibeops/agents/reviewer.md +47 -0
- package/templates/.vibeops/agents/tester.md +43 -0
- package/templates/.vibeops/prompts/create-plan.md +33 -0
- package/templates/.vibeops/prompts/generate-tasks.md +41 -0
- package/templates/.vibeops/prompts/implement-task.md +39 -0
- package/templates/.vibeops/prompts/review-task.md +34 -0
- package/templates/.vibeops/prompts/rollback.md +32 -0
- package/templates/.vibeops/prompts/start-project.md +39 -0
- package/templates/.vibeops/workflows/notion-sync.md +53 -0
- package/templates/.vibeops/workflows/project-start.md +73 -0
- package/templates/.vibeops/workflows/rollback.md +45 -0
- package/templates/.vibeops/workflows/task-lifecycle.md +71 -0
- package/templates/AGENTS.md +98 -0
- package/templates/docs/logs/README.md +38 -0
- package/templates/docs/project/00-overview.md +27 -0
- package/templates/docs/project/01-requirements.md +30 -0
- package/templates/docs/project/02-mvp-scope.md +36 -0
- package/templates/docs/project/03-architecture.md +34 -0
- package/templates/docs/project/04-tech-stack.md +29 -0
- package/templates/docs/project/05-current-state.md +35 -0
- package/templates/docs/project/06-decisions.md +20 -0
- package/templates/docs/project/07-backlog.md +23 -0
- package/templates/docs/project/08-env.md +29 -0
- package/templates/docs/project/09-deployment.md +28 -0
- package/templates/docs/tasks/TASK-000-template.md +72 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
2
|
+
import { briefToMarkdown, findMissingRequired, gatherBrief, parseBriefFromMarkdown, parseIdea, } from "../lib/brief.js";
|
|
3
|
+
import { pathExists, readText, writeText } from "../lib/filesystem.js";
|
|
4
|
+
import { bold, cyan, dim, log, yellow } from "../lib/logger.js";
|
|
5
|
+
import { buildPlanPrompt } from "../lib/prompt-builder.js";
|
|
6
|
+
const DEFAULT_BRIEF_REL = ".vibeops/brief/project-brief.md";
|
|
7
|
+
const DEFAULT_PROMPT_REL = ".vibeops/generated/plan-prompt.md";
|
|
8
|
+
function toAbsolute(root, candidate) {
|
|
9
|
+
return isAbsolute(candidate) ? candidate : resolve(root, candidate);
|
|
10
|
+
}
|
|
11
|
+
function relDisplay(root, abs) {
|
|
12
|
+
const r = relative(root, abs);
|
|
13
|
+
return r.length === 0 ? "." : r;
|
|
14
|
+
}
|
|
15
|
+
export async function planCommand(options) {
|
|
16
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
17
|
+
const nonInteractive = options.nonInteractive === true;
|
|
18
|
+
const briefAbs = join(cwd, DEFAULT_BRIEF_REL);
|
|
19
|
+
const promptAbs = typeof options.output === "string" ? toAbsolute(cwd, options.output) : join(cwd, DEFAULT_PROMPT_REL);
|
|
20
|
+
log.info(bold(`vibeops plan`));
|
|
21
|
+
log.info(dim(` cwd: ${cwd}`));
|
|
22
|
+
if (options.from)
|
|
23
|
+
log.info(dim(` from: ${options.from}`));
|
|
24
|
+
if (options.idea)
|
|
25
|
+
log.info(dim(` idea: ${options.idea}`));
|
|
26
|
+
if (nonInteractive)
|
|
27
|
+
log.info(dim(` mode: non-interactive`));
|
|
28
|
+
log.blank();
|
|
29
|
+
let bundle;
|
|
30
|
+
if (typeof options.from === "string" && options.from.length > 0) {
|
|
31
|
+
bundle = await loadFromFile({
|
|
32
|
+
cwd,
|
|
33
|
+
fromPath: toAbsolute(cwd, options.from),
|
|
34
|
+
nonInteractive,
|
|
35
|
+
idea: options.idea,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
if (!nonInteractive && process.stdin.isTTY !== true) {
|
|
40
|
+
log.error("vibeops plan requires a TTY. In CI/piped environments, pass --non-interactive or supply --from <brief.md>.");
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
log.step(nonInteractive
|
|
45
|
+
? "non-interactive: build the ProjectBrief from flag values + safe placeholders"
|
|
46
|
+
: "interactive: build the ProjectBrief from 20 questions (arrow keys · Space · Enter)");
|
|
47
|
+
log.blank();
|
|
48
|
+
bundle = await gatherBrief({
|
|
49
|
+
cwd,
|
|
50
|
+
idea: options.idea,
|
|
51
|
+
nonInteractive,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const briefMd = briefToMarkdown(bundle.brief, bundle.meta);
|
|
55
|
+
await writeText(briefAbs, briefMd);
|
|
56
|
+
log.ok(`Wrote brief: ${relDisplay(cwd, briefAbs)}`);
|
|
57
|
+
const promptMd = buildPlanPrompt({
|
|
58
|
+
brief: bundle.brief,
|
|
59
|
+
meta: bundle.meta,
|
|
60
|
+
briefRelativePath: relDisplay(cwd, briefAbs),
|
|
61
|
+
});
|
|
62
|
+
await writeText(promptAbs, promptMd);
|
|
63
|
+
log.ok(`Cursor planning prompt: ${relDisplay(cwd, promptAbs)}`);
|
|
64
|
+
if (bundle.meta.assumptions.length > 0) {
|
|
65
|
+
log.blank();
|
|
66
|
+
log.info(`${yellow("!")} ${bold("Assumptions")} (items the Planner Agent should reconfirm):`);
|
|
67
|
+
for (const a of bundle.meta.assumptions)
|
|
68
|
+
log.info(` · ${a}`);
|
|
69
|
+
}
|
|
70
|
+
log.blank();
|
|
71
|
+
log.info(bold("Next steps:"));
|
|
72
|
+
log.info(` 1) Open a new Cursor chat and paste the full contents of ${cyan(relDisplay(cwd, promptAbs))}.`);
|
|
73
|
+
log.info(` 2) Review the Planner Agent's docs/project/* + initial backlog via git diff and commit.`);
|
|
74
|
+
log.info(` 3) To revise the brief, edit ${cyan(relDisplay(cwd, briefAbs))} and regenerate the prompt with`);
|
|
75
|
+
log.info(` ${dim("vibeops plan --from " + DEFAULT_BRIEF_REL)}.`);
|
|
76
|
+
}
|
|
77
|
+
async function loadFromFile(inputs) {
|
|
78
|
+
const { cwd, fromPath, nonInteractive, idea } = inputs;
|
|
79
|
+
if (!(await pathExists(fromPath))) {
|
|
80
|
+
log.error(`--from path does not exist: ${fromPath}`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
const md = await readText(fromPath);
|
|
84
|
+
log.step(`Loading brief: ${relDisplay(cwd, fromPath)}`);
|
|
85
|
+
const parsed = parseBriefFromMarkdown(md);
|
|
86
|
+
parsed.meta.source = "from-file";
|
|
87
|
+
if (typeof idea === "string" && idea.length > 0) {
|
|
88
|
+
const ideaParsed = parseIdea(idea);
|
|
89
|
+
if (ideaParsed.projectName && parsed.brief.projectName.length === 0) {
|
|
90
|
+
parsed.brief.projectName = ideaParsed.projectName;
|
|
91
|
+
}
|
|
92
|
+
if (ideaParsed.oneLineIdea && parsed.brief.oneLineIdea.length === 0) {
|
|
93
|
+
parsed.brief.oneLineIdea = ideaParsed.oneLineIdea;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const missing = findMissingRequired(parsed.brief);
|
|
97
|
+
if (missing.length === 0) {
|
|
98
|
+
return parsed;
|
|
99
|
+
}
|
|
100
|
+
if (nonInteractive) {
|
|
101
|
+
log.warn(`Missing required fields: ${missing.join(", ")} → filling with placeholders and recording them in Assumptions.`);
|
|
102
|
+
return parsed;
|
|
103
|
+
}
|
|
104
|
+
log.warn(`Missing required fields: ${missing.join(", ")} → asking only the missing questions.`);
|
|
105
|
+
log.blank();
|
|
106
|
+
const filled = await gatherBrief({
|
|
107
|
+
cwd,
|
|
108
|
+
idea,
|
|
109
|
+
nonInteractive: false,
|
|
110
|
+
seed: parsed.brief,
|
|
111
|
+
});
|
|
112
|
+
filled.meta.source = "from-file";
|
|
113
|
+
return filled;
|
|
114
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { collectStatus } from "../status/collector.js";
|
|
3
|
+
import { printHuman, toJson } from "../status/format.js";
|
|
4
|
+
import { log } from "../lib/logger.js";
|
|
5
|
+
export async function statusCommand(options = {}) {
|
|
6
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
7
|
+
const report = await collectStatus(cwd);
|
|
8
|
+
if (options.json) {
|
|
9
|
+
log.raw(toJson(report));
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
printHuman(report);
|
|
13
|
+
}
|
|
14
|
+
if (!report.isVibeopsProject) {
|
|
15
|
+
process.exitCode = 1;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { join, relative, resolve } from "node:path";
|
|
2
|
+
import { pathExists, readText } from "../lib/filesystem.js";
|
|
3
|
+
import { gitAllChangedFilesSinceTaskStart, gitCommitsAhead, gitLogOneline, readGitInfo, } from "../lib/git.js";
|
|
4
|
+
import { bold, cyan, dim, green, log, red, yellow } from "../lib/logger.js";
|
|
5
|
+
import { projectPaths } from "../lib/paths.js";
|
|
6
|
+
import { findAcceptanceCriteria, findExpectedFiles, findTaskFile, hasNonEmptySection, readGitContext, readSection, readTaskFile, statusDisplay, } from "../lib/task.js";
|
|
7
|
+
import { buildTaskPromptString } from "../lib/task-prompt.js";
|
|
8
|
+
function relOrAbs(root, p) {
|
|
9
|
+
const r = relative(root, p);
|
|
10
|
+
return r === "" ? "." : r.startsWith("..") ? p : r;
|
|
11
|
+
}
|
|
12
|
+
function todayLogFileName() {
|
|
13
|
+
const d = new Date();
|
|
14
|
+
const y = d.getFullYear();
|
|
15
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
16
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
17
|
+
return `${y}-${m}-${day}.md`;
|
|
18
|
+
}
|
|
19
|
+
export async function taskCheckCommand(taskId, options = {}) {
|
|
20
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
21
|
+
const paths = projectPaths(cwd);
|
|
22
|
+
const agentName = options.agent ?? "reviewer";
|
|
23
|
+
let missing = 0;
|
|
24
|
+
const taskFile = await findTaskFile(paths.docsTasks, taskId);
|
|
25
|
+
if (!taskFile) {
|
|
26
|
+
log.error(`TASK not found: ${taskId} (looked in ${relOrAbs(cwd, paths.docsTasks)})`);
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const meta = await readTaskFile(taskFile);
|
|
31
|
+
const body = await readText(taskFile);
|
|
32
|
+
log.info(bold(`vibeops task check ${meta.id}`));
|
|
33
|
+
log.info(` ${dim("file")} ${relOrAbs(cwd, taskFile)}`);
|
|
34
|
+
log.info(` ${dim("title")} ${meta.title || dim("(no title)")}`);
|
|
35
|
+
log.info(` ${dim("status")} ${statusDisplay(meta.status)}`);
|
|
36
|
+
log.blank();
|
|
37
|
+
log.info(bold("Git"));
|
|
38
|
+
const git = await readGitInfo(cwd);
|
|
39
|
+
if (!git.isRepo) {
|
|
40
|
+
log.info(` ${red("✗")} not a git repository`);
|
|
41
|
+
missing++;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
log.info(` ${dim("branch")} ${git.branch ?? dim("(detached)")}`);
|
|
45
|
+
log.info(` ${dim("status")} ${git.dirty ? yellow("dirty") : green("clean")}`);
|
|
46
|
+
}
|
|
47
|
+
const ctx = await readGitContext(taskFile);
|
|
48
|
+
if (ctx === null) {
|
|
49
|
+
log.info(` ${yellow("·")} no Git Context section yet — run \`vibeops task start ${meta.id}\` first.`);
|
|
50
|
+
missing++;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
log.info(` ${dim("base")} ${ctx.baseBranch} @ ${ctx.baseCommit}`);
|
|
54
|
+
log.info(` ${dim("task branch")} ${cyan(ctx.taskBranch)}`);
|
|
55
|
+
log.info(` ${dim("started at")} ${ctx.startedAt}`);
|
|
56
|
+
if (git.isRepo) {
|
|
57
|
+
const range = `${ctx.baseCommit}..HEAD`;
|
|
58
|
+
const summary = await gitAllChangedFilesSinceTaskStart(ctx.baseCommit, cwd);
|
|
59
|
+
const ahead = await gitCommitsAhead(cwd, ctx.baseCommit);
|
|
60
|
+
log.info(` ${dim("commits ahead")} ${ahead}`);
|
|
61
|
+
log.info(` ${dim("working tree changed files")} ${summary.workingTree.length}`);
|
|
62
|
+
log.info(` ${dim("committed changed files")} ${summary.committed.length}`);
|
|
63
|
+
log.info(` ${dim("total changed files")} ${summary.all.length}`);
|
|
64
|
+
const recent = await gitLogOneline(cwd, `${range}`);
|
|
65
|
+
const head = recent.slice(0, 5);
|
|
66
|
+
if (head.length > 0) {
|
|
67
|
+
log.info(` ${dim("recent commits:")}`);
|
|
68
|
+
for (const c of head)
|
|
69
|
+
log.info(` · ${c.sha} ${c.message}`);
|
|
70
|
+
}
|
|
71
|
+
const expected = findExpectedFiles(body);
|
|
72
|
+
if (expected.length > 0) {
|
|
73
|
+
log.blank();
|
|
74
|
+
log.info(bold("Expected Files to Change vs current diff"));
|
|
75
|
+
const lower = new Set(summary.all.map((c) => c.toLowerCase()));
|
|
76
|
+
let hit = 0;
|
|
77
|
+
for (const e of expected) {
|
|
78
|
+
const present = lower.has(e.toLowerCase());
|
|
79
|
+
if (present)
|
|
80
|
+
hit++;
|
|
81
|
+
log.info(` ${present ? green("✓") : yellow("·")} ${e}${present ? "" : dim(" (not yet in diff)")}`);
|
|
82
|
+
}
|
|
83
|
+
log.info(dim(` match ${hit}/${expected.length} (${Math.round((hit / expected.length) * 100)}%)`));
|
|
84
|
+
log.info(dim(` basis: working tree(${summary.workingTree.length}) ∪ committed(${summary.committed.length}) = total(${summary.all.length}) files`));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
log.blank();
|
|
89
|
+
log.info(bold("Acceptance Criteria"));
|
|
90
|
+
const ac = findAcceptanceCriteria(body);
|
|
91
|
+
if (ac.length === 0) {
|
|
92
|
+
log.info(` ${dim("(none parsed — TASK file may not use \"1. …\" list format)")}`);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
for (let i = 0; i < ac.length; i++) {
|
|
96
|
+
log.info(` ${dim(`${i + 1}.`)} ${ac[i]}`);
|
|
97
|
+
}
|
|
98
|
+
log.info(dim(` → confirm each item manually with the diff before \`vibeops task done\`.`));
|
|
99
|
+
}
|
|
100
|
+
log.blank();
|
|
101
|
+
log.info(bold("Docs touched in this round"));
|
|
102
|
+
const docsChecks = [
|
|
103
|
+
{ label: "current-state (03)", path: join(paths.docsProject, "03-current-state.md"), required: false },
|
|
104
|
+
{ label: "current-state (05)", path: join(paths.docsProject, "05-current-state.md"), required: false },
|
|
105
|
+
{ label: `log (${todayLogFileName()})`, path: join(paths.docsLogs, todayLogFileName()), required: false },
|
|
106
|
+
];
|
|
107
|
+
let anyDoc = false;
|
|
108
|
+
for (const c of docsChecks) {
|
|
109
|
+
const present = await pathExists(c.path);
|
|
110
|
+
if (present)
|
|
111
|
+
anyDoc = true;
|
|
112
|
+
log.info(` ${present ? green("✓") : dim("·")} ${c.label} ${dim(relOrAbs(cwd, c.path))}`);
|
|
113
|
+
}
|
|
114
|
+
if (!anyDoc) {
|
|
115
|
+
log.info(` ${yellow("!")} no current-state or today's log file found — Docs Agent must update them before \`task done\`.`);
|
|
116
|
+
missing++;
|
|
117
|
+
}
|
|
118
|
+
log.blank();
|
|
119
|
+
log.info(bold("TASK Result / Test Result"));
|
|
120
|
+
const resultOk = hasNonEmptySection(body, "Result");
|
|
121
|
+
const testResultOk = hasNonEmptySection(body, "Test Result");
|
|
122
|
+
log.info(` ${resultOk ? green("✓") : yellow("·")} Result ${dim(resultOk ? readSection(body, "Result").split("\n")[0]?.slice(0, 80) ?? "" : "(empty / placeholder)")}`);
|
|
123
|
+
log.info(` ${testResultOk ? green("✓") : yellow("·")} Test Result ${dim(testResultOk ? readSection(body, "Test Result").split("\n")[0]?.slice(0, 80) ?? "" : "(empty / placeholder)")}`);
|
|
124
|
+
if (!resultOk)
|
|
125
|
+
missing++;
|
|
126
|
+
if (!testResultOk)
|
|
127
|
+
missing++;
|
|
128
|
+
log.blank();
|
|
129
|
+
log.info(bold("Summary"));
|
|
130
|
+
if (missing === 0) {
|
|
131
|
+
log.ok("All checks present. Ready for `vibeops task done`.");
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
log.info(` ${yellow("!")} ${missing} item(s) need attention before \`vibeops task done\`.`);
|
|
135
|
+
}
|
|
136
|
+
log.blank();
|
|
137
|
+
const promptResult = await buildTaskPromptString({
|
|
138
|
+
projectRoot: paths.root,
|
|
139
|
+
agentsDir: paths.vibeopsAgents,
|
|
140
|
+
agentName,
|
|
141
|
+
taskFilePath: taskFile,
|
|
142
|
+
});
|
|
143
|
+
if (promptResult.ok) {
|
|
144
|
+
log.info(bold(`Cursor prompt (agent: ${agentName}):`));
|
|
145
|
+
log.info(dim("─".repeat(60)));
|
|
146
|
+
log.raw(promptResult.prompt.endsWith("\n") ? promptResult.prompt : `${promptResult.prompt}\n`);
|
|
147
|
+
log.info(dim("─".repeat(60)));
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
log.info(dim(`(agent "${agentName}" not found — install \`.vibeops/agents/${agentName}.md\` or pass --agent <name>)`));
|
|
151
|
+
}
|
|
152
|
+
if (options.strict === true && missing > 0) {
|
|
153
|
+
process.exitCode = 1;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { relative, resolve } from "node:path";
|
|
2
|
+
import { readText } from "../lib/filesystem.js";
|
|
3
|
+
import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
|
|
4
|
+
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;
|
|
9
|
+
}
|
|
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
|
+
}
|
|
17
|
+
export async function taskDoneCommand(taskId, options = {}) {
|
|
18
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
19
|
+
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)})`);
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const meta = await readTaskFile(taskFile);
|
|
27
|
+
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)}`);
|
|
32
|
+
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)")}`);
|
|
45
|
+
}
|
|
46
|
+
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\`.`);
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
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();
|
|
64
|
+
}
|
|
65
|
+
const ctx = await readGitContext(taskFile);
|
|
66
|
+
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)")}`);
|
|
75
|
+
}
|
|
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)")}`);
|
|
90
|
+
}
|
|
91
|
+
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
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
2
|
+
import { pathExists, writeText } from "../lib/filesystem.js";
|
|
3
|
+
import { bold, cyan, dim, log, yellow } from "../lib/logger.js";
|
|
4
|
+
import { projectPaths } from "../lib/paths.js";
|
|
5
|
+
import { collectInputDocs, } from "../lib/project-docs.js";
|
|
6
|
+
import { formatTaskId, nextTaskNumber } from "../lib/task.js";
|
|
7
|
+
import { buildTaskGeneratePrompt, } from "../lib/task-generator.js";
|
|
8
|
+
import { planScaffoldEntries, renderScaffoldMarkdown, writeScaffoldFiles, } from "../lib/task-scaffold.js";
|
|
9
|
+
import { VERSION } from "../version.js";
|
|
10
|
+
const DEFAULT_PROMPT_REL = ".vibeops/generated/task-generate-prompt.md";
|
|
11
|
+
const DEFAULT_COUNT = 8;
|
|
12
|
+
const COUNT_SOFT_CAP = 20;
|
|
13
|
+
const BRIEF_REL = ".vibeops/brief/project-brief.md";
|
|
14
|
+
function toAbsolute(root, candidate) {
|
|
15
|
+
return isAbsolute(candidate) ? candidate : resolve(root, candidate);
|
|
16
|
+
}
|
|
17
|
+
function relDisplay(root, abs) {
|
|
18
|
+
const r = relative(root, abs);
|
|
19
|
+
return r.length === 0 ? "." : r;
|
|
20
|
+
}
|
|
21
|
+
function parseCount(raw) {
|
|
22
|
+
if (raw === undefined)
|
|
23
|
+
return { count: DEFAULT_COUNT, warning: false };
|
|
24
|
+
const n = typeof raw === "number" ? raw : Number.parseInt(String(raw).trim(), 10);
|
|
25
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
26
|
+
return {
|
|
27
|
+
count: DEFAULT_COUNT,
|
|
28
|
+
warning: false,
|
|
29
|
+
error: `--count must be a positive integer (got: "${String(raw)}"). Falling back to default ${DEFAULT_COUNT}.`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return { count: Math.floor(n), warning: n > COUNT_SOFT_CAP };
|
|
33
|
+
}
|
|
34
|
+
function presentInventory(docs) {
|
|
35
|
+
const present = [];
|
|
36
|
+
const missing = [];
|
|
37
|
+
if (docs.from)
|
|
38
|
+
(docs.from.content !== null ? present : missing).push(docs.from);
|
|
39
|
+
for (const slot of docs.slots)
|
|
40
|
+
(slot.content !== null ? present : missing).push(slot);
|
|
41
|
+
if (docs.brief.content !== null)
|
|
42
|
+
present.push(docs.brief);
|
|
43
|
+
else
|
|
44
|
+
missing.push(docs.brief);
|
|
45
|
+
return { present, missing };
|
|
46
|
+
}
|
|
47
|
+
export async function taskGenerateCommand(options = {}) {
|
|
48
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
49
|
+
const paths = projectPaths(cwd);
|
|
50
|
+
const dryRun = options.dryRun === true;
|
|
51
|
+
const scaffold = options.scaffold === true;
|
|
52
|
+
const briefAbs = join(cwd, BRIEF_REL);
|
|
53
|
+
const parsedCount = parseCount(options.count);
|
|
54
|
+
if (parsedCount.error)
|
|
55
|
+
log.warn(parsedCount.error);
|
|
56
|
+
const count = parsedCount.count;
|
|
57
|
+
const countWarning = parsedCount.warning;
|
|
58
|
+
if (countWarning) {
|
|
59
|
+
log.warn(`--count ${count} is large (soft cap ${COUNT_SOFT_CAP}). Continuing, but the Planner Agent may push back.`);
|
|
60
|
+
}
|
|
61
|
+
const fromAbs = options.from ? toAbsolute(cwd, options.from) : undefined;
|
|
62
|
+
if (typeof fromAbs === "string" && !(await pathExists(fromAbs))) {
|
|
63
|
+
log.error(`--from path not found: ${fromAbs}`);
|
|
64
|
+
log.info(`If you meant a relative path, run from the project root or pass --cwd <path>.`);
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
log.info(bold(scaffold ? "vibeops task generate --scaffold" : "vibeops task generate"));
|
|
69
|
+
log.info(` ${dim("cwd")} ${cwd}`);
|
|
70
|
+
log.info(` ${dim("mode")} ${scaffold ? "scaffold (write skeleton TASK files)" : "prompt (build Cursor prompt)"}`);
|
|
71
|
+
if (options.phase)
|
|
72
|
+
log.info(` ${dim("phase")} ${options.phase}`);
|
|
73
|
+
log.info(` ${dim("count")} ${count}${countWarning ? yellow(" (above soft cap)") : ""}`);
|
|
74
|
+
if (fromAbs)
|
|
75
|
+
log.info(` ${dim("--from")} ${relDisplay(cwd, fromAbs)}`);
|
|
76
|
+
if (dryRun)
|
|
77
|
+
log.info(` ${dim("dry-run")} on (no file writes)`);
|
|
78
|
+
log.blank();
|
|
79
|
+
if (scaffold) {
|
|
80
|
+
await runScaffold({
|
|
81
|
+
tasksDir: paths.docsTasks,
|
|
82
|
+
cwd,
|
|
83
|
+
count,
|
|
84
|
+
phase: options.phase,
|
|
85
|
+
dryRun,
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
await runPromptMode({
|
|
90
|
+
cwd,
|
|
91
|
+
paths,
|
|
92
|
+
options,
|
|
93
|
+
fromAbs,
|
|
94
|
+
briefAbs,
|
|
95
|
+
count,
|
|
96
|
+
countWarning,
|
|
97
|
+
dryRun,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
async function runPromptMode(inputs) {
|
|
101
|
+
const { cwd, paths, options, fromAbs, briefAbs, count, countWarning, dryRun } = inputs;
|
|
102
|
+
const promptAbs = typeof options.output === "string"
|
|
103
|
+
? toAbsolute(cwd, options.output)
|
|
104
|
+
: join(cwd, DEFAULT_PROMPT_REL);
|
|
105
|
+
const docs = await collectInputDocs({ cwd, fromPath: options.from, fromAbs, briefAbs });
|
|
106
|
+
const { present, missing } = presentInventory(docs);
|
|
107
|
+
log.info(bold("Input documents"));
|
|
108
|
+
for (const s of present)
|
|
109
|
+
log.info(` ${cyan("✓")} ${relDisplay(cwd, s.path)} ${dim(`(${s.label})`)}`);
|
|
110
|
+
for (const s of missing)
|
|
111
|
+
log.info(` ${dim("·")} ${relDisplay(cwd, s.path)} ${dim(`(${s.label} — missing)`)}`);
|
|
112
|
+
log.blank();
|
|
113
|
+
const nextNum = await nextTaskNumber(paths.docsTasks);
|
|
114
|
+
const nextId = formatTaskId(nextNum);
|
|
115
|
+
log.info(bold("Plan"));
|
|
116
|
+
log.info(` ${dim("next TASK id")} ${nextId}`);
|
|
117
|
+
log.info(` ${dim("prompt output")} ${relDisplay(cwd, promptAbs)}`);
|
|
118
|
+
log.blank();
|
|
119
|
+
if (dryRun) {
|
|
120
|
+
log.info(bold("dry-run — would perform:"));
|
|
121
|
+
log.info(` · read ${present.length} input doc(s) (skipped ${missing.length} missing slot${missing.length === 1 ? "" : "s"})`);
|
|
122
|
+
log.info(` · build Cursor prompt for ~${count} TASK starting at ${nextId}`);
|
|
123
|
+
log.info(` · write prompt to ${relDisplay(cwd, promptAbs)}`);
|
|
124
|
+
log.info(` · no LLM / Cursor / Notion / GitHub / Git call`);
|
|
125
|
+
log.blank();
|
|
126
|
+
log.info(dim("no files were written."));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const promptInputs = {
|
|
130
|
+
cwd,
|
|
131
|
+
vibeopsVersion: VERSION,
|
|
132
|
+
generatedAt: new Date().toISOString(),
|
|
133
|
+
nextTaskId: nextId,
|
|
134
|
+
count,
|
|
135
|
+
outputPath: promptAbs,
|
|
136
|
+
outputRelative: relDisplay(cwd, promptAbs),
|
|
137
|
+
docs,
|
|
138
|
+
countWarning,
|
|
139
|
+
...(typeof options.phase === "string" && options.phase.length > 0
|
|
140
|
+
? { phase: options.phase }
|
|
141
|
+
: {}),
|
|
142
|
+
};
|
|
143
|
+
const md = buildTaskGeneratePrompt(promptInputs);
|
|
144
|
+
await writeText(promptAbs, md);
|
|
145
|
+
log.ok(`Cursor task-generate prompt: ${relDisplay(cwd, promptAbs)}`);
|
|
146
|
+
log.blank();
|
|
147
|
+
log.info(bold("Next steps"));
|
|
148
|
+
log.info(` 1) Open a new Cursor chat and paste the full contents of ${cyan(relDisplay(cwd, promptAbs))}.`);
|
|
149
|
+
log.info(` 2) Review the Planner Agent's \`docs/tasks/TASK-NNN-*.md\` files with \`git diff\` and commit.`);
|
|
150
|
+
log.info(` 3) Start the first new TASK with ${cyan(`vibeops task start ${nextId}`)}.`);
|
|
151
|
+
if (missing.length > 0) {
|
|
152
|
+
log.blank();
|
|
153
|
+
log.info(`${yellow("!")} ${missing.length} input slot(s) are missing on disk. The Planner Agent will see them as "missing"; decide whether to fill them this round.`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function runScaffold(inputs) {
|
|
157
|
+
const plan = await planScaffoldEntries({
|
|
158
|
+
tasksDir: inputs.tasksDir,
|
|
159
|
+
count: inputs.count,
|
|
160
|
+
...(typeof inputs.phase === "string" && inputs.phase.length > 0
|
|
161
|
+
? { phase: inputs.phase }
|
|
162
|
+
: {}),
|
|
163
|
+
});
|
|
164
|
+
log.info(bold("Scaffold plan"));
|
|
165
|
+
log.info(` ${dim("start at")} ${formatTaskId(plan.startNumber)}`);
|
|
166
|
+
log.info(` ${dim("count")} ${plan.entries.length}`);
|
|
167
|
+
log.blank();
|
|
168
|
+
log.info(bold("Files to create"));
|
|
169
|
+
for (const e of plan.entries) {
|
|
170
|
+
log.info(` · ${relDisplay(inputs.cwd, e.absPath)} ${dim(`(${e.phase})`)}`);
|
|
171
|
+
}
|
|
172
|
+
log.blank();
|
|
173
|
+
if (inputs.dryRun) {
|
|
174
|
+
log.info(dim("dry-run — no files were written."));
|
|
175
|
+
log.info(`Skeleton preview (first entry, ${plan.entries[0]?.id ?? "TASK-???"}):`);
|
|
176
|
+
log.blank();
|
|
177
|
+
log.info(dim("─".repeat(60)));
|
|
178
|
+
log.raw(renderScaffoldMarkdown(plan.entries[0] ?? fallbackPreviewEntry(plan.startNumber, inputs.phase)));
|
|
179
|
+
log.info(dim("─".repeat(60)));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const written = await writeScaffoldFiles(plan);
|
|
183
|
+
for (const abs of written)
|
|
184
|
+
log.ok(`created ${relDisplay(inputs.cwd, abs)}`);
|
|
185
|
+
if (written.length < plan.entries.length) {
|
|
186
|
+
const skipped = plan.entries.length - written.length;
|
|
187
|
+
log.warn(`${skipped} planned file(s) already existed and were skipped (won't overwrite).`);
|
|
188
|
+
}
|
|
189
|
+
log.blank();
|
|
190
|
+
log.info(bold("Next steps"));
|
|
191
|
+
log.info(` 1) Fill the empty sections of each scaffolded TASK file (in Cursor, via the Planner or Architect Agent).`);
|
|
192
|
+
log.info(` 2) After filling, run \`git add docs/tasks/ && git commit\`.`);
|
|
193
|
+
log.info(` 3) Start the first TASK with ${cyan(`vibeops task start ${plan.entries[0]?.id ?? "TASK-???"}`)}.`);
|
|
194
|
+
}
|
|
195
|
+
function fallbackPreviewEntry(start, phase) {
|
|
196
|
+
const id = formatTaskId(start);
|
|
197
|
+
return {
|
|
198
|
+
id,
|
|
199
|
+
number: start,
|
|
200
|
+
slug: "planned-task",
|
|
201
|
+
title: "(scaffolded TASK — fill in)",
|
|
202
|
+
fileName: `${id}-planned-task.md`,
|
|
203
|
+
absPath: `${id}-planned-task.md`,
|
|
204
|
+
phase: phase ?? "(unassigned)",
|
|
205
|
+
};
|
|
206
|
+
}
|