@goodtek/vibeops 0.7.22 → 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 +21 -1
- package/README.md +62 -382
- package/dist/bootstrap/installer.js +4 -16
- package/dist/cli.js +26 -160
- package/dist/commands/init.js +3 -3
- package/dist/commands/status.js +145 -8
- package/dist/commands/task-add.js +64 -164
- package/dist/commands/task-done.js +117 -74
- package/dist/lib/config.js +2 -118
- package/dist/lib/git.js +1 -1
- 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/task-add-llm.js +46 -233
- package/dist/lib/task-context.js +68 -0
- package/dist/lib/task-done-llm.js +148 -0
- package/dist/lib/task-merge.js +54 -159
- package/dist/lib/task-scaffold.js +39 -43
- package/dist/lib/task-start.js +72 -0
- package/dist/lib/task-summary.js +12 -239
- package/dist/lib/task.js +15 -24
- package/dist/types/config.js +0 -16
- package/package.json +3 -5
- 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/done.js +0 -160
- package/dist/commands/github-init.js +0 -554
- package/dist/commands/github-status.js +0 -164
- package/dist/commands/next.js +0 -283
- package/dist/commands/notion-init.js +0 -764
- package/dist/commands/notion-sync.js +0 -405
- package/dist/commands/notion-test.js +0 -595
- package/dist/commands/plan.js +0 -146
- package/dist/commands/rollback.js +0 -126
- package/dist/commands/start.js +0 -129
- package/dist/commands/task-check.js +0 -155
- package/dist/commands/task-generate.js +0 -206
- package/dist/commands/task-pull.js +0 -277
- package/dist/commands/task-rollback.js +0 -174
- package/dist/commands/task-start.js +0 -102
- package/dist/commands/task-status.js +0 -122
- package/dist/lib/brief.js +0 -356
- package/dist/lib/cursor-task-prompt.js +0 -60
- package/dist/lib/done-follow-up.js +0 -308
- package/dist/lib/github-cli.js +0 -247
- package/dist/lib/guide-state.js +0 -69
- package/dist/lib/mvp-artifacts.js +0 -204
- package/dist/lib/mvp-constants.js +0 -15
- package/dist/lib/mvp-diff-summary.js +0 -43
- package/dist/lib/mvp-done-summary.js +0 -335
- package/dist/lib/notion-client.js +0 -459
- package/dist/lib/notion-discovery.js +0 -671
- package/dist/lib/notion-env.js +0 -140
- package/dist/lib/notion-mappers.js +0 -148
- package/dist/lib/notion-schema.js +0 -272
- package/dist/lib/notion-sync.js +0 -337
- package/dist/lib/notion-target.js +0 -247
- package/dist/lib/package-json.js +0 -133
- package/dist/lib/plan-apply-planner.js +0 -131
- package/dist/lib/plan-codex-pkce.js +0 -234
- package/dist/lib/plan-llm-models.js +0 -237
- package/dist/lib/plan-llm-normalize.js +0 -146
- package/dist/lib/plan-llm-session.js +0 -682
- package/dist/lib/plan-post-apply-setup.js +0 -116
- package/dist/lib/project-docs.js +0 -95
- package/dist/lib/prompt-builder.js +0 -135
- package/dist/lib/resolve-command-task.js +0 -40
- package/dist/lib/task-add-build-prompt.js +0 -39
- package/dist/lib/task-add-cursor.js +0 -81
- package/dist/lib/task-generator.js +0 -183
- package/dist/lib/task-prompt.js +0 -23
- package/dist/lib/task-pull.js +0 -354
- package/dist/lib/task-status-brief.js +0 -132
- package/dist/lib/workflow-guide.js +0 -485
- package/dist/status/collector.js +0 -103
- package/dist/status/format.js +0 -181
- package/dist/types/brief.js +0 -126
- package/templates/.cursor/rules/00-project-governance.mdc +0 -28
- package/templates/.cursor/rules/01-agent-orchestration.mdc +0 -48
- package/templates/.cursor/rules/02-task-workflow.mdc +0 -38
- package/templates/.cursor/rules/03-git-safety.mdc +0 -30
- package/templates/.cursor/rules/04-docs-update.mdc +0 -22
- package/templates/.vibeops/agents/architect.md +0 -47
- package/templates/.vibeops/agents/builder.md +0 -38
- package/templates/.vibeops/agents/docs.md +0 -54
- package/templates/.vibeops/agents/orchestrator.md +0 -40
- package/templates/.vibeops/agents/planner.md +0 -60
- package/templates/.vibeops/agents/recovery.md +0 -49
- package/templates/.vibeops/agents/reviewer.md +0 -47
- package/templates/.vibeops/agents/tester.md +0 -43
- package/templates/.vibeops/prompts/create-plan.md +0 -33
- package/templates/.vibeops/prompts/generate-tasks.md +0 -41
- package/templates/.vibeops/prompts/implement-task.md +0 -39
- package/templates/.vibeops/prompts/review-task.md +0 -34
- package/templates/.vibeops/prompts/rollback.md +0 -32
- package/templates/.vibeops/prompts/start-project.md +0 -39
- package/templates/.vibeops/workflows/notion-sync.md +0 -53
- package/templates/.vibeops/workflows/project-start.md +0 -73
- package/templates/.vibeops/workflows/rollback.md +0 -45
- package/templates/.vibeops/workflows/task-lifecycle.md +0 -71
- package/templates/docs/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
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import { relative, resolve } from "node:path";
|
|
2
|
-
import { branchNameForTaskFile, findTaskFile, upsertGitContext, updateInlineStatus, } from "../lib/task.js";
|
|
3
|
-
import { detectDefaultBranch, gitBranchExists, gitCheckoutNewBranch, gitGovernanceOnlyDirty, gitHeadCommit, readGitInfo, } from "../lib/git.js";
|
|
4
|
-
import { bold, cyan, dim, log } from "../lib/logger.js";
|
|
5
|
-
import { buildTaskPromptString } from "../lib/task-prompt.js";
|
|
6
|
-
import { projectPaths } from "../lib/paths.js";
|
|
7
|
-
function relOrAbs(root, p) {
|
|
8
|
-
const r = relative(root, p);
|
|
9
|
-
return r === "" ? "." : r.startsWith("..") ? p : r;
|
|
10
|
-
}
|
|
11
|
-
export async function taskStartCommand(taskId, options = {}) {
|
|
12
|
-
const cwd = resolve(options.cwd ?? process.cwd());
|
|
13
|
-
const paths = projectPaths(cwd);
|
|
14
|
-
const agentName = options.agent ?? "builder";
|
|
15
|
-
const taskFile = await findTaskFile(paths.docsTasks, taskId);
|
|
16
|
-
if (!taskFile) {
|
|
17
|
-
log.error(`TASK not found: ${taskId} (looked in ${relOrAbs(cwd, paths.docsTasks)})`);
|
|
18
|
-
process.exitCode = 1;
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
const git = await readGitInfo(cwd);
|
|
22
|
-
if (!git.isRepo) {
|
|
23
|
-
log.error(`Not a git repository: ${cwd}`);
|
|
24
|
-
process.exitCode = 1;
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
if (git.dirty === true && options.allowDirty !== true) {
|
|
28
|
-
const gov = await gitGovernanceOnlyDirty(cwd);
|
|
29
|
-
if (!gov.onlyGovernance) {
|
|
30
|
-
log.error("Git working tree is dirty. Commit or stash first, or rerun with --allow-dirty.");
|
|
31
|
-
if (gov.nonGovernancePaths.length > 0) {
|
|
32
|
-
log.info(dim(" Non-documentation changes:"));
|
|
33
|
-
for (const p of gov.nonGovernancePaths)
|
|
34
|
-
log.info(` · ${p}`);
|
|
35
|
-
}
|
|
36
|
-
process.exitCode = 1;
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
log.warn("Uncommitted governance docs only (tasks / project / logs / .vibeops/state). Proceeding — commit when convenient.");
|
|
40
|
-
for (const p of gov.allPaths)
|
|
41
|
-
log.info(` ${dim("·")} ${p}`);
|
|
42
|
-
log.blank();
|
|
43
|
-
}
|
|
44
|
-
const baseBranch = git.branch ?? (await detectDefaultBranch(cwd)) ?? "main";
|
|
45
|
-
const baseCommit = (await gitHeadCommit(cwd)) ?? "";
|
|
46
|
-
if (baseCommit.length === 0) {
|
|
47
|
-
log.error("Failed to read HEAD commit. Repo has no commits yet?");
|
|
48
|
-
process.exitCode = 1;
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
const taskBranch = branchNameForTaskFile(taskFile);
|
|
52
|
-
const startedAt = new Date().toISOString();
|
|
53
|
-
const ctx = { baseBranch, baseCommit, taskBranch, startedAt };
|
|
54
|
-
log.info(bold(`vibeops task start ${taskId}`));
|
|
55
|
-
log.info(` ${dim("file")} ${relOrAbs(cwd, taskFile)}`);
|
|
56
|
-
log.info(` ${dim("base branch")} ${baseBranch}`);
|
|
57
|
-
log.info(` ${dim("base commit")} ${baseCommit}`);
|
|
58
|
-
log.info(` ${dim("task branch")} ${cyan(taskBranch)}`);
|
|
59
|
-
log.info(` ${dim("started at")} ${startedAt}`);
|
|
60
|
-
log.blank();
|
|
61
|
-
if (options.dryRun === true) {
|
|
62
|
-
log.info(bold("dry-run — would perform:"));
|
|
63
|
-
log.info(` · git checkout -b ${taskBranch} ${baseBranch}`);
|
|
64
|
-
log.info(` · update Status → In Progress in ${relOrAbs(cwd, taskFile)}`);
|
|
65
|
-
log.info(` · upsert "## Git Context" section in ${relOrAbs(cwd, taskFile)}`);
|
|
66
|
-
log.info(` · build Cursor prompt for agent "${agentName}"`);
|
|
67
|
-
log.blank();
|
|
68
|
-
log.info(dim("no files were written and no git command was executed."));
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
if (await gitBranchExists(cwd, taskBranch)) {
|
|
72
|
-
log.error(`Task branch already exists: ${taskBranch}`);
|
|
73
|
-
log.info(` Use a different TASK or delete the existing branch first: \`git branch -D ${taskBranch}\`.`);
|
|
74
|
-
process.exitCode = 1;
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
await gitCheckoutNewBranch(cwd, taskBranch, baseBranch);
|
|
78
|
-
log.ok(`checked out new branch: ${taskBranch}`);
|
|
79
|
-
await updateInlineStatus(taskFile, "in_progress");
|
|
80
|
-
await upsertGitContext(taskFile, ctx);
|
|
81
|
-
log.ok(`updated ${relOrAbs(cwd, taskFile)} (Status + Git Context)`);
|
|
82
|
-
log.blank();
|
|
83
|
-
const promptResult = await buildTaskPromptString({
|
|
84
|
-
projectRoot: paths.root,
|
|
85
|
-
agentsDir: paths.vibeopsAgents,
|
|
86
|
-
agentName,
|
|
87
|
-
taskFilePath: taskFile,
|
|
88
|
-
});
|
|
89
|
-
if (!promptResult.ok) {
|
|
90
|
-
log.warn(`agent "${agentName}" not found in ${relOrAbs(cwd, paths.vibeopsAgents)} — skipping prompt.`);
|
|
91
|
-
if (promptResult.available.length > 0) {
|
|
92
|
-
log.info(`Available agents: ${promptResult.available.join(", ")}`);
|
|
93
|
-
}
|
|
94
|
-
log.info(`Run \`vibeops task prompt ${taskId} --agent <name>\` later to generate the Cursor prompt.`);
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
log.info(bold(`Cursor prompt (agent: ${agentName}):`));
|
|
98
|
-
log.info(dim("─".repeat(60)));
|
|
99
|
-
log.raw(promptResult.prompt.endsWith("\n") ? promptResult.prompt : `${promptResult.prompt}\n`);
|
|
100
|
-
log.info(dim("─".repeat(60)));
|
|
101
|
-
log.info(`Copy the block above into Cursor. When done, run \`vibeops task check ${taskId}\`.`);
|
|
102
|
-
}
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import { resolve } from "node:path";
|
|
2
|
-
import { collectTaskStatusBrief, taskStatusBriefToJson, } from "../lib/task-status-brief.js";
|
|
3
|
-
import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
|
|
4
|
-
function sectionFilled(ok) {
|
|
5
|
-
return ok ? green("filled") : yellow("empty");
|
|
6
|
-
}
|
|
7
|
-
function printHuman(brief) {
|
|
8
|
-
log.blank();
|
|
9
|
-
log.info(bold("─".repeat(56)));
|
|
10
|
-
log.info(bold(" VibeOps · TASK briefing"));
|
|
11
|
-
log.info(bold("─".repeat(56)));
|
|
12
|
-
if (!brief.isVibeopsProject) {
|
|
13
|
-
log.info(` ${dim("Project")} ${yellow("not a VibeOps project")} (.vibeops.json missing)`);
|
|
14
|
-
if (brief.git.isRepo && brief.git.branch) {
|
|
15
|
-
log.info(` ${dim("Git")} ${brief.git.branch}`);
|
|
16
|
-
}
|
|
17
|
-
log.blank();
|
|
18
|
-
log.info(` ${dim("Next")} ${cyan("vibeops init")}`);
|
|
19
|
-
log.info(bold("─".repeat(56)));
|
|
20
|
-
log.blank();
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
if (brief.projectName) {
|
|
24
|
-
log.info(` ${dim("Project")} ${brief.projectName}`);
|
|
25
|
-
}
|
|
26
|
-
if (brief.counts) {
|
|
27
|
-
const c = brief.counts;
|
|
28
|
-
log.info(` ${dim("Backlog")} ${c.total} tasks · ${cyan(String(c.in_progress))} in progress · ${c.planned} planned · ${c.done} done`);
|
|
29
|
-
}
|
|
30
|
-
if (brief.inProgressTasks.length > 1) {
|
|
31
|
-
log.blank();
|
|
32
|
-
log.info(bold(" In progress (multiple)"));
|
|
33
|
-
for (const t of brief.inProgressTasks) {
|
|
34
|
-
log.info(` ${cyan(t.id)} — ${t.title || dim("(no title)")}`);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
log.blank();
|
|
38
|
-
if (brief.activeTask) {
|
|
39
|
-
const t = brief.activeTask;
|
|
40
|
-
log.info(` ${dim("Focus")} ${cyan(t.id)} — ${t.title || dim("(no title)")}`);
|
|
41
|
-
log.info(` ${dim("Status")} ${t.status}`);
|
|
42
|
-
if (t.mvpPhase)
|
|
43
|
-
log.info(` ${dim("Phase")} ${t.mvpPhase}`);
|
|
44
|
-
log.info(` ${dim("File")} ${t.file}`);
|
|
45
|
-
}
|
|
46
|
-
else if (brief.mode === "post-mvp" && brief.referenceTask) {
|
|
47
|
-
log.info(` ${dim("Focus")} ${dim("MVP/backlog complete — last:")} ${cyan(brief.referenceTask.id)}`);
|
|
48
|
-
}
|
|
49
|
-
else {
|
|
50
|
-
log.info(` ${dim("Focus")} ${dim("(none — run vibeops plan or task add)")}`);
|
|
51
|
-
}
|
|
52
|
-
if (brief.sections) {
|
|
53
|
-
log.blank();
|
|
54
|
-
log.info(bold(" Progress"));
|
|
55
|
-
if (brief.sections.goalExcerpt.length > 0) {
|
|
56
|
-
log.info(` ${dim("Goal")} ${brief.sections.goalExcerpt}`);
|
|
57
|
-
}
|
|
58
|
-
log.info(` ${dim("Result")} ${sectionFilled(brief.sections.resultFilled)} · ${dim("Test Result")} ${sectionFilled(brief.sections.testResultFilled)}`);
|
|
59
|
-
if (brief.sections.acceptanceCriteriaCount > 0) {
|
|
60
|
-
log.info(` ${dim("AC")} ${brief.sections.acceptanceCriteriaCount} acceptance criteria`);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
log.blank();
|
|
64
|
-
log.info(bold(" Git"));
|
|
65
|
-
if (!brief.git.isRepo) {
|
|
66
|
-
log.info(` ${yellow("not a git repository")}`);
|
|
67
|
-
}
|
|
68
|
-
else {
|
|
69
|
-
const branch = brief.git.branch ?? "(detached)";
|
|
70
|
-
log.info(` ${dim("HEAD")} ${branch}`);
|
|
71
|
-
if (brief.git.dirty === true)
|
|
72
|
-
log.info(` ${dim("Tree")} ${yellow("dirty")}`);
|
|
73
|
-
else if (brief.git.dirty === false)
|
|
74
|
-
log.info(` ${dim("Tree")} ${green("clean")}`);
|
|
75
|
-
if (brief.git.expectedTaskBranch) {
|
|
76
|
-
const on = brief.git.onTaskBranch === true
|
|
77
|
-
? green("yes")
|
|
78
|
-
: brief.git.onTaskBranch === false
|
|
79
|
-
? yellow("no")
|
|
80
|
-
: dim("—");
|
|
81
|
-
const exists = brief.git.taskBranchExists === true
|
|
82
|
-
? dim("exists")
|
|
83
|
-
: brief.git.taskBranchExists === false
|
|
84
|
-
? dim("not created")
|
|
85
|
-
: "";
|
|
86
|
-
log.info(` ${dim("Task br.")} ${brief.git.expectedTaskBranch} · on branch: ${on}${exists ? ` · ${exists}` : ""}`);
|
|
87
|
-
}
|
|
88
|
-
if (brief.git.baseBranch) {
|
|
89
|
-
log.info(` ${dim("Base")} ${brief.git.baseBranch}`);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
if (brief.artifacts.length > 0) {
|
|
93
|
-
log.blank();
|
|
94
|
-
log.info(bold(" Cursor artifacts"));
|
|
95
|
-
for (const a of brief.artifacts)
|
|
96
|
-
log.info(` ${cyan(a)}`);
|
|
97
|
-
}
|
|
98
|
-
log.blank();
|
|
99
|
-
log.info(bold(` Step: ${brief.workflow.stepTitle}`));
|
|
100
|
-
for (const line of brief.workflow.youDo) {
|
|
101
|
-
log.info(` ${dim("·")} ${line}`);
|
|
102
|
-
}
|
|
103
|
-
if (brief.workflow.suggestedCommand) {
|
|
104
|
-
log.blank();
|
|
105
|
-
log.info(` ${dim("Suggested")} ${cyan(brief.workflow.suggestedCommand)}`);
|
|
106
|
-
}
|
|
107
|
-
log.info(bold("─".repeat(56)));
|
|
108
|
-
log.blank();
|
|
109
|
-
}
|
|
110
|
-
export async function taskStatusCommand(opts = {}) {
|
|
111
|
-
const cwd = resolve(opts.cwd ?? process.cwd());
|
|
112
|
-
const brief = await collectTaskStatusBrief(cwd);
|
|
113
|
-
if (opts.json === true) {
|
|
114
|
-
log.raw(taskStatusBriefToJson(brief));
|
|
115
|
-
}
|
|
116
|
-
else {
|
|
117
|
-
printHuman(brief);
|
|
118
|
-
}
|
|
119
|
-
if (!brief.isVibeopsProject) {
|
|
120
|
-
process.exitCode = 1;
|
|
121
|
-
}
|
|
122
|
-
}
|
package/dist/lib/brief.js
DELETED
|
@@ -1,356 +0,0 @@
|
|
|
1
|
-
import { basename } from "node:path";
|
|
2
|
-
import { askCheckbox, askConfirm, askInput, askSelect, } from "./inquirer-helpers.js";
|
|
3
|
-
import { VERSION } from "../version.js";
|
|
4
|
-
import { AGENT_WORKFLOW_CHOICES, AUTH_REQUIREMENT_CHOICES, BACKEND_CHOICES, DATABASE_CHOICES, DB_LAYER_CHOICES, DEPLOYMENT_TARGET_CHOICES, FRONTEND_CHOICES, INTEGRATION_CHOICES, MVP_FEATURE_CHOICES, OUT_OF_SCOPE_CHOICES, PACKAGE_MANAGER_CHOICES, PROJECT_BRIEF_SCHEMA_VERSION, PROJECT_TYPE_CHOICES, RISK_AREA_CHOICES, TARGET_USER_CHOICES, } from "../types/brief.js";
|
|
5
|
-
const PLACEHOLDER_PROJECT_NAME = "Unnamed Project";
|
|
6
|
-
const PLACEHOLDER_IDEA = "(no idea provided — Planner Agent must fill this in)";
|
|
7
|
-
const PLACEHOLDER_PROBLEM = "(no core problem provided — Planner Agent must fill this in)";
|
|
8
|
-
const PLACEHOLDER_SUCCESS = "(no success criteria provided — Planner Agent must fill this in)";
|
|
9
|
-
export function parseIdea(idea) {
|
|
10
|
-
if (typeof idea !== "string")
|
|
11
|
-
return {};
|
|
12
|
-
const trimmed = idea.trim();
|
|
13
|
-
if (trimmed.length === 0)
|
|
14
|
-
return {};
|
|
15
|
-
const colonIdx = trimmed.indexOf(":");
|
|
16
|
-
if (colonIdx > 0 && colonIdx < 40) {
|
|
17
|
-
const head = trimmed.slice(0, colonIdx).trim();
|
|
18
|
-
const tail = trimmed.slice(colonIdx + 1).trim();
|
|
19
|
-
if (head.length > 0 && !head.includes(" ") && tail.length > 0) {
|
|
20
|
-
return { projectName: head, oneLineIdea: tail };
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return { oneLineIdea: trimmed };
|
|
24
|
-
}
|
|
25
|
-
function deriveProjectTypeDefault(idea) {
|
|
26
|
-
if (typeof idea === "string" && /browser/i.test(idea)) {
|
|
27
|
-
return "Browser Automation";
|
|
28
|
-
}
|
|
29
|
-
return "SaaS";
|
|
30
|
-
}
|
|
31
|
-
export async function gatherBrief(inputs) {
|
|
32
|
-
const assumptions = [];
|
|
33
|
-
const seed = inputs.seed ?? {};
|
|
34
|
-
const ideaParsed = parseIdea(inputs.idea);
|
|
35
|
-
const defaultName = seed.projectName ?? ideaParsed.projectName ?? basename(inputs.cwd) ?? PLACEHOLDER_PROJECT_NAME;
|
|
36
|
-
const projectName = await askInput({
|
|
37
|
-
message: "1/20 · Project name",
|
|
38
|
-
nonInteractive: inputs.nonInteractive,
|
|
39
|
-
default: defaultName,
|
|
40
|
-
required: !inputs.nonInteractive,
|
|
41
|
-
fallback: PLACEHOLDER_PROJECT_NAME,
|
|
42
|
-
});
|
|
43
|
-
const finalProjectName = projectName.length > 0 ? projectName : PLACEHOLDER_PROJECT_NAME;
|
|
44
|
-
if (finalProjectName === PLACEHOLDER_PROJECT_NAME) {
|
|
45
|
-
assumptions.push('projectName: directory name was empty too — defaulted to "Unnamed Project"');
|
|
46
|
-
}
|
|
47
|
-
const oneLineIdea = await askInput({
|
|
48
|
-
message: "2/20 · One-line idea",
|
|
49
|
-
nonInteractive: inputs.nonInteractive,
|
|
50
|
-
default: seed.oneLineIdea ?? ideaParsed.oneLineIdea,
|
|
51
|
-
required: !inputs.nonInteractive,
|
|
52
|
-
fallback: PLACEHOLDER_IDEA,
|
|
53
|
-
});
|
|
54
|
-
const finalIdea = oneLineIdea.length > 0 ? oneLineIdea : PLACEHOLDER_IDEA;
|
|
55
|
-
if (finalIdea === PLACEHOLDER_IDEA) {
|
|
56
|
-
assumptions.push("oneLineIdea: not provided — placeholder for Planner Agent to fill");
|
|
57
|
-
}
|
|
58
|
-
const projectType = await askSelect({
|
|
59
|
-
message: "3/20 · Project type",
|
|
60
|
-
nonInteractive: inputs.nonInteractive,
|
|
61
|
-
choices: PROJECT_TYPE_CHOICES,
|
|
62
|
-
default: seed.projectType ?? deriveProjectTypeDefault(inputs.idea ?? seed.oneLineIdea),
|
|
63
|
-
});
|
|
64
|
-
const targetUsers = await askCheckbox({
|
|
65
|
-
message: "4/20 · Target users (Space to toggle, Enter to confirm)",
|
|
66
|
-
nonInteractive: inputs.nonInteractive,
|
|
67
|
-
choices: TARGET_USER_CHOICES,
|
|
68
|
-
default: seed.targetUsers,
|
|
69
|
-
});
|
|
70
|
-
const coreProblem = await askInput({
|
|
71
|
-
message: "5/20 · Core problem",
|
|
72
|
-
nonInteractive: inputs.nonInteractive,
|
|
73
|
-
default: seed.coreProblem,
|
|
74
|
-
fallback: PLACEHOLDER_PROBLEM,
|
|
75
|
-
});
|
|
76
|
-
const finalCoreProblem = coreProblem.length > 0 ? coreProblem : PLACEHOLDER_PROBLEM;
|
|
77
|
-
if (finalCoreProblem === PLACEHOLDER_PROBLEM) {
|
|
78
|
-
assumptions.push("coreProblem: not provided — placeholder for Planner Agent to fill");
|
|
79
|
-
}
|
|
80
|
-
const mvpFeatures = await askCheckbox({
|
|
81
|
-
message: "6/20 · MVP must-have features",
|
|
82
|
-
nonInteractive: inputs.nonInteractive,
|
|
83
|
-
choices: MVP_FEATURE_CHOICES,
|
|
84
|
-
default: seed.mvpFeatures,
|
|
85
|
-
});
|
|
86
|
-
const outOfScope = await askCheckbox({
|
|
87
|
-
message: "7/20 · Out of scope for MVP",
|
|
88
|
-
nonInteractive: inputs.nonInteractive,
|
|
89
|
-
choices: OUT_OF_SCOPE_CHOICES,
|
|
90
|
-
default: seed.outOfScope,
|
|
91
|
-
});
|
|
92
|
-
const frontend = await askSelect({
|
|
93
|
-
message: "8/20 · Preferred frontend",
|
|
94
|
-
nonInteractive: inputs.nonInteractive,
|
|
95
|
-
choices: FRONTEND_CHOICES,
|
|
96
|
-
default: seed.frontend ?? "Next.js",
|
|
97
|
-
});
|
|
98
|
-
const backend = await askSelect({
|
|
99
|
-
message: "9/20 · Preferred backend",
|
|
100
|
-
nonInteractive: inputs.nonInteractive,
|
|
101
|
-
choices: BACKEND_CHOICES,
|
|
102
|
-
default: seed.backend ?? "NestJS",
|
|
103
|
-
});
|
|
104
|
-
const database = await askSelect({
|
|
105
|
-
message: "10/20 · Database",
|
|
106
|
-
nonInteractive: inputs.nonInteractive,
|
|
107
|
-
choices: DATABASE_CHOICES,
|
|
108
|
-
default: seed.database ?? "PostgreSQL",
|
|
109
|
-
});
|
|
110
|
-
const dbLayer = await askSelect({
|
|
111
|
-
message: "11/20 · ORM / DB layer",
|
|
112
|
-
nonInteractive: inputs.nonInteractive,
|
|
113
|
-
choices: DB_LAYER_CHOICES,
|
|
114
|
-
default: seed.dbLayer ?? "Drizzle",
|
|
115
|
-
});
|
|
116
|
-
const packageManager = await askSelect({
|
|
117
|
-
message: "12/20 · Package manager",
|
|
118
|
-
nonInteractive: inputs.nonInteractive,
|
|
119
|
-
choices: PACKAGE_MANAGER_CHOICES,
|
|
120
|
-
default: seed.packageManager ?? "pnpm",
|
|
121
|
-
});
|
|
122
|
-
const deploymentTargets = await askCheckbox({
|
|
123
|
-
message: "13/20 · Deployment target",
|
|
124
|
-
nonInteractive: inputs.nonInteractive,
|
|
125
|
-
choices: DEPLOYMENT_TARGET_CHOICES,
|
|
126
|
-
default: seed.deploymentTargets,
|
|
127
|
-
});
|
|
128
|
-
const authRequirements = await askCheckbox({
|
|
129
|
-
message: "14/20 · Auth requirement",
|
|
130
|
-
nonInteractive: inputs.nonInteractive,
|
|
131
|
-
choices: AUTH_REQUIREMENT_CHOICES,
|
|
132
|
-
default: seed.authRequirements,
|
|
133
|
-
});
|
|
134
|
-
const integrations = await askCheckbox({
|
|
135
|
-
message: "15/20 · External integrations",
|
|
136
|
-
nonInteractive: inputs.nonInteractive,
|
|
137
|
-
choices: INTEGRATION_CHOICES,
|
|
138
|
-
default: seed.integrations,
|
|
139
|
-
});
|
|
140
|
-
const useNotion = await askConfirm({
|
|
141
|
-
message: "16/20 · Use Notion dashboard sync?",
|
|
142
|
-
nonInteractive: inputs.nonInteractive,
|
|
143
|
-
default: seed.useNotion ?? true,
|
|
144
|
-
});
|
|
145
|
-
const useGitWorkflow = await askConfirm({
|
|
146
|
-
message: "17/20 · Use Git task branch workflow?",
|
|
147
|
-
nonInteractive: inputs.nonInteractive,
|
|
148
|
-
default: seed.useGitWorkflow ?? true,
|
|
149
|
-
});
|
|
150
|
-
const agentWorkflowLevel = await askSelect({
|
|
151
|
-
message: "18/20 · Agent workflow level",
|
|
152
|
-
nonInteractive: inputs.nonInteractive,
|
|
153
|
-
choices: AGENT_WORKFLOW_CHOICES,
|
|
154
|
-
default: seed.agentWorkflowLevel ??
|
|
155
|
-
"Advanced: Orchestrator + Planner + Architect + Builder + Tester + Reviewer + Docs + Recovery",
|
|
156
|
-
});
|
|
157
|
-
const risks = await askCheckbox({
|
|
158
|
-
message: "19/20 · Risk areas",
|
|
159
|
-
nonInteractive: inputs.nonInteractive,
|
|
160
|
-
choices: RISK_AREA_CHOICES,
|
|
161
|
-
default: seed.risks,
|
|
162
|
-
});
|
|
163
|
-
const successCriteria = await askInput({
|
|
164
|
-
message: "20/20 · Success criteria",
|
|
165
|
-
nonInteractive: inputs.nonInteractive,
|
|
166
|
-
default: seed.successCriteria,
|
|
167
|
-
fallback: PLACEHOLDER_SUCCESS,
|
|
168
|
-
});
|
|
169
|
-
const finalSuccess = successCriteria.length > 0 ? successCriteria : PLACEHOLDER_SUCCESS;
|
|
170
|
-
if (finalSuccess === PLACEHOLDER_SUCCESS) {
|
|
171
|
-
assumptions.push("successCriteria: not provided — placeholder for Planner Agent to fill");
|
|
172
|
-
}
|
|
173
|
-
const brief = {
|
|
174
|
-
projectName: finalProjectName,
|
|
175
|
-
oneLineIdea: finalIdea,
|
|
176
|
-
projectType,
|
|
177
|
-
targetUsers,
|
|
178
|
-
coreProblem: finalCoreProblem,
|
|
179
|
-
mvpFeatures,
|
|
180
|
-
outOfScope,
|
|
181
|
-
frontend,
|
|
182
|
-
backend,
|
|
183
|
-
database,
|
|
184
|
-
dbLayer,
|
|
185
|
-
packageManager,
|
|
186
|
-
deploymentTargets,
|
|
187
|
-
authRequirements,
|
|
188
|
-
integrations,
|
|
189
|
-
useNotion,
|
|
190
|
-
useGitWorkflow,
|
|
191
|
-
agentWorkflowLevel,
|
|
192
|
-
risks,
|
|
193
|
-
successCriteria: finalSuccess,
|
|
194
|
-
};
|
|
195
|
-
const meta = {
|
|
196
|
-
vibeopsVersion: VERSION,
|
|
197
|
-
generatedAt: new Date().toISOString(),
|
|
198
|
-
source: inputs.nonInteractive ? "non-interactive" : "interactive",
|
|
199
|
-
schemaVersion: PROJECT_BRIEF_SCHEMA_VERSION,
|
|
200
|
-
assumptions,
|
|
201
|
-
};
|
|
202
|
-
return { brief, meta };
|
|
203
|
-
}
|
|
204
|
-
const SECTIONS = [
|
|
205
|
-
{ num: 1, title: "Project name", field: "projectName" },
|
|
206
|
-
{ num: 2, title: "One-line idea", field: "oneLineIdea" },
|
|
207
|
-
{ num: 3, title: "Project type", field: "projectType" },
|
|
208
|
-
{ num: 4, title: "Target users", field: "targetUsers" },
|
|
209
|
-
{ num: 5, title: "Core problem", field: "coreProblem" },
|
|
210
|
-
{ num: 6, title: "MVP must-have features", field: "mvpFeatures" },
|
|
211
|
-
{ num: 7, title: "Out of scope for MVP", field: "outOfScope" },
|
|
212
|
-
{ num: 8, title: "Preferred frontend", field: "frontend" },
|
|
213
|
-
{ num: 9, title: "Preferred backend", field: "backend" },
|
|
214
|
-
{ num: 10, title: "Database", field: "database" },
|
|
215
|
-
{ num: 11, title: "ORM / DB layer", field: "dbLayer" },
|
|
216
|
-
{ num: 12, title: "Package manager", field: "packageManager" },
|
|
217
|
-
{ num: 13, title: "Deployment target", field: "deploymentTargets" },
|
|
218
|
-
{ num: 14, title: "Auth requirement", field: "authRequirements" },
|
|
219
|
-
{ num: 15, title: "External integrations", field: "integrations" },
|
|
220
|
-
{ num: 16, title: "Use Notion dashboard sync?", field: "useNotion" },
|
|
221
|
-
{ num: 17, title: "Use Git task branch workflow?", field: "useGitWorkflow" },
|
|
222
|
-
{ num: 18, title: "Agent workflow level", field: "agentWorkflowLevel" },
|
|
223
|
-
{ num: 19, title: "Risk areas", field: "risks" },
|
|
224
|
-
{ num: 20, title: "Success criteria", field: "successCriteria" },
|
|
225
|
-
];
|
|
226
|
-
function renderList(values) {
|
|
227
|
-
if (values.length === 0)
|
|
228
|
-
return "_(none)_";
|
|
229
|
-
return values.map((v) => `- ${v}`).join("\n");
|
|
230
|
-
}
|
|
231
|
-
function renderScalar(value) {
|
|
232
|
-
return value.length > 0 ? value : "_(empty)_";
|
|
233
|
-
}
|
|
234
|
-
function renderBool(value) {
|
|
235
|
-
return value ? "yes" : "no";
|
|
236
|
-
}
|
|
237
|
-
export function briefToMarkdown(brief, meta) {
|
|
238
|
-
const lines = [];
|
|
239
|
-
lines.push(`# Project Brief — ${brief.projectName}`);
|
|
240
|
-
lines.push("");
|
|
241
|
-
lines.push(`> Generated: ${meta.generatedAt} · VibeOps ${meta.vibeopsVersion} · Source: ${meta.source} · schemaVersion: ${meta.schemaVersion}`);
|
|
242
|
-
lines.push("");
|
|
243
|
-
lines.push(meta.source === "llm-openai" ||
|
|
244
|
-
meta.source === "llm-codex-oauth" ||
|
|
245
|
-
meta.source === "llm-cursor-agent"
|
|
246
|
-
? meta.source === "llm-codex-oauth"
|
|
247
|
-
? "This brief was produced by an **LLM planning session** using **Codex (ChatGPT OAuth)** (`codex login`, read from ~/.codex/auth.json). Review and edit it, then let the Planner Agent fill `docs/project/*`."
|
|
248
|
-
: meta.source === "llm-openai"
|
|
249
|
-
? "This brief was produced by an **LLM planning session** using an **OpenAI platform API key**. Review and edit it, then let the Planner Agent fill `docs/project/*`."
|
|
250
|
-
: "This brief was produced by an **LLM planning session** using the **Cursor Agent CLI**. Review and edit it, then let the Planner Agent fill `docs/project/*`."
|
|
251
|
-
: "This brief is the input for the Cursor **Planner Agent**. VibeOps collected these answers locally (or from `--from`) — the Planner Agent reads this brief, fills in `docs/project/*`, and creates the initial backlog.");
|
|
252
|
-
lines.push("");
|
|
253
|
-
for (const sec of SECTIONS) {
|
|
254
|
-
lines.push(`## ${sec.num}. ${sec.title}`);
|
|
255
|
-
lines.push("");
|
|
256
|
-
const value = brief[sec.field];
|
|
257
|
-
if (typeof value === "boolean") {
|
|
258
|
-
lines.push(renderBool(value));
|
|
259
|
-
}
|
|
260
|
-
else if (Array.isArray(value)) {
|
|
261
|
-
lines.push(renderList(value));
|
|
262
|
-
}
|
|
263
|
-
else {
|
|
264
|
-
lines.push(renderScalar(value));
|
|
265
|
-
}
|
|
266
|
-
lines.push("");
|
|
267
|
-
}
|
|
268
|
-
lines.push("## Assumptions");
|
|
269
|
-
lines.push("");
|
|
270
|
-
if (meta.assumptions.length === 0) {
|
|
271
|
-
lines.push("_(none)_");
|
|
272
|
-
}
|
|
273
|
-
else {
|
|
274
|
-
for (const a of meta.assumptions)
|
|
275
|
-
lines.push(`- ${a}`);
|
|
276
|
-
}
|
|
277
|
-
lines.push("");
|
|
278
|
-
return lines.join("\n");
|
|
279
|
-
}
|
|
280
|
-
function extractSection(md, num) {
|
|
281
|
-
const re = new RegExp(String.raw `^##\s+${num}\.\s+[^\n]*\n([\s\S]*?)(?=^##\s+|\Z)`, "m");
|
|
282
|
-
const match = md.match(re);
|
|
283
|
-
return match ? (match[1] ?? "").trim() : "";
|
|
284
|
-
}
|
|
285
|
-
function parseList(text) {
|
|
286
|
-
if (text.length === 0 || text === "_(none)_")
|
|
287
|
-
return [];
|
|
288
|
-
const items = [];
|
|
289
|
-
for (const raw of text.split("\n")) {
|
|
290
|
-
const line = raw.trim();
|
|
291
|
-
if (line.startsWith("- ")) {
|
|
292
|
-
const v = line.slice(2).trim();
|
|
293
|
-
if (v.length > 0)
|
|
294
|
-
items.push(v);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
return items;
|
|
298
|
-
}
|
|
299
|
-
function parseScalar(text) {
|
|
300
|
-
if (text === "_(empty)_")
|
|
301
|
-
return "";
|
|
302
|
-
return text.trim();
|
|
303
|
-
}
|
|
304
|
-
function parseBool(text) {
|
|
305
|
-
const t = text.trim().toLowerCase();
|
|
306
|
-
return t === "yes" || t === "true" || t === "y";
|
|
307
|
-
}
|
|
308
|
-
export function parseBriefFromMarkdown(md) {
|
|
309
|
-
const brief = {
|
|
310
|
-
projectName: parseScalar(extractSection(md, 1)),
|
|
311
|
-
oneLineIdea: parseScalar(extractSection(md, 2)),
|
|
312
|
-
projectType: parseScalar(extractSection(md, 3)),
|
|
313
|
-
targetUsers: parseList(extractSection(md, 4)),
|
|
314
|
-
coreProblem: parseScalar(extractSection(md, 5)),
|
|
315
|
-
mvpFeatures: parseList(extractSection(md, 6)),
|
|
316
|
-
outOfScope: parseList(extractSection(md, 7)),
|
|
317
|
-
frontend: parseScalar(extractSection(md, 8)),
|
|
318
|
-
backend: parseScalar(extractSection(md, 9)),
|
|
319
|
-
database: parseScalar(extractSection(md, 10)),
|
|
320
|
-
dbLayer: parseScalar(extractSection(md, 11)),
|
|
321
|
-
packageManager: parseScalar(extractSection(md, 12)),
|
|
322
|
-
deploymentTargets: parseList(extractSection(md, 13)),
|
|
323
|
-
authRequirements: parseList(extractSection(md, 14)),
|
|
324
|
-
integrations: parseList(extractSection(md, 15)),
|
|
325
|
-
useNotion: parseBool(extractSection(md, 16)),
|
|
326
|
-
useGitWorkflow: parseBool(extractSection(md, 17)),
|
|
327
|
-
agentWorkflowLevel: parseScalar(extractSection(md, 18)),
|
|
328
|
-
risks: parseList(extractSection(md, 19)),
|
|
329
|
-
successCriteria: parseScalar(extractSection(md, 20)),
|
|
330
|
-
};
|
|
331
|
-
const assumptionMatch = md.match(/^##\s+Assumptions\s*\n([\s\S]*?)(?=^##\s+|\Z)/m);
|
|
332
|
-
const assumptions = assumptionMatch && assumptionMatch[1] ? parseList(assumptionMatch[1].trim()) : [];
|
|
333
|
-
const headerMatch = md.match(/^>\s+Generated:\s+([^\s]+)\s+·\s+VibeOps\s+([^\s]+)\s+·\s+Source:\s+([^\s·]+)/m);
|
|
334
|
-
const generatedAt = headerMatch?.[1] ?? new Date().toISOString();
|
|
335
|
-
const recordedVersion = headerMatch?.[2] ?? VERSION;
|
|
336
|
-
const recordedSource = headerMatch?.[3] ?? "from-file";
|
|
337
|
-
const meta = {
|
|
338
|
-
vibeopsVersion: recordedVersion,
|
|
339
|
-
generatedAt,
|
|
340
|
-
source: recordedSource,
|
|
341
|
-
schemaVersion: PROJECT_BRIEF_SCHEMA_VERSION,
|
|
342
|
-
assumptions,
|
|
343
|
-
};
|
|
344
|
-
return { brief, meta };
|
|
345
|
-
}
|
|
346
|
-
export function findMissingRequired(brief) {
|
|
347
|
-
const missing = [];
|
|
348
|
-
if (brief.projectName.length === 0 || brief.projectName === PLACEHOLDER_PROJECT_NAME) {
|
|
349
|
-
missing.push("projectName");
|
|
350
|
-
}
|
|
351
|
-
if (brief.oneLineIdea.length === 0 ||
|
|
352
|
-
brief.oneLineIdea === PLACEHOLDER_IDEA) {
|
|
353
|
-
missing.push("oneLineIdea");
|
|
354
|
-
}
|
|
355
|
-
return missing;
|
|
356
|
-
}
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { join, relative } from "node:path";
|
|
2
|
-
import { mkdir } from "node:fs/promises";
|
|
3
|
-
import { pathExists, writeText } from "./filesystem.js";
|
|
4
|
-
import { hasNonEmptySection } from "./task.js";
|
|
5
|
-
import { VERSION } from "../version.js";
|
|
6
|
-
export function cursorImplementPromptRel(taskId) {
|
|
7
|
-
const slug = taskId.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
|
|
8
|
-
return `.vibeops/generated/cursor-implement-${slug}.md`;
|
|
9
|
-
}
|
|
10
|
-
export function buildCursorImplementPrompt(opts) {
|
|
11
|
-
const fill = [];
|
|
12
|
-
if (opts.fillResult)
|
|
13
|
-
fill.push("**Result**");
|
|
14
|
-
if (opts.fillTestResult)
|
|
15
|
-
fill.push("**Test Result**");
|
|
16
|
-
const fillLine = fill.length > 0
|
|
17
|
-
? `4. Update ${fill.join(" and ")} in \`${opts.taskPath}\` (replace Pending / (not yet) placeholders).`
|
|
18
|
-
: "";
|
|
19
|
-
return `# Implement ${opts.taskId}
|
|
20
|
-
|
|
21
|
-
Project: \`${opts.projectName}\` (VibeOps ${opts.vibeopsVersion})
|
|
22
|
-
|
|
23
|
-
Read and follow the **entire** TASK file:
|
|
24
|
-
|
|
25
|
-
\`${opts.taskPath}\`
|
|
26
|
-
|
|
27
|
-
Procedure:
|
|
28
|
-
|
|
29
|
-
1. Work only inside **Scope** and **Acceptance Criteria** in that file.
|
|
30
|
-
2. **Search** the repo for existing patterns before adding code or docs.
|
|
31
|
-
3. Do the work required by this TASK (implementation, spec, or docs — per Scope / Out of Scope).
|
|
32
|
-
${fillLine ? `${fillLine}\n` : ""}5. Do not touch other TASKs. No git push or merge.
|
|
33
|
-
|
|
34
|
-
When finished, run \`vibeops next\` and choose Refresh, then \`vibeops done ${opts.taskId}\`.
|
|
35
|
-
`;
|
|
36
|
-
}
|
|
37
|
-
/** Write \`.vibeops/generated/cursor-implement-*.md\` when Result/Test need filling. */
|
|
38
|
-
export async function ensureCursorImplementPrompt(cwd, config, task, body) {
|
|
39
|
-
const fillResult = !hasNonEmptySection(body, "Result");
|
|
40
|
-
const fillTestResult = !hasNonEmptySection(body, "Test Result");
|
|
41
|
-
if (!fillResult && !fillTestResult)
|
|
42
|
-
return null;
|
|
43
|
-
const rel = cursorImplementPromptRel(task.id);
|
|
44
|
-
const taskPath = relative(cwd, task.filePath).replace(/\\/g, "/");
|
|
45
|
-
const projectName = config?.name ?? "project";
|
|
46
|
-
const text = buildCursorImplementPrompt({
|
|
47
|
-
projectName,
|
|
48
|
-
taskId: task.id,
|
|
49
|
-
taskPath,
|
|
50
|
-
vibeopsVersion: VERSION,
|
|
51
|
-
fillResult,
|
|
52
|
-
fillTestResult,
|
|
53
|
-
});
|
|
54
|
-
const outDir = join(cwd, ".vibeops", "generated");
|
|
55
|
-
if (!(await pathExists(outDir))) {
|
|
56
|
-
await mkdir(outDir, { recursive: true });
|
|
57
|
-
}
|
|
58
|
-
await writeText(join(cwd, rel), text);
|
|
59
|
-
return { rel, fillResult, fillTestResult };
|
|
60
|
-
}
|