builder-assistant-engineer 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.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +250 -0
  3. package/dist/analyst/prompt.js +19 -0
  4. package/dist/analyst/retry.js +38 -0
  5. package/dist/artifacts/diff.js +47 -0
  6. package/dist/artifacts/lessons.js +32 -0
  7. package/dist/artifacts/merge.js +70 -0
  8. package/dist/artifacts/write.js +44 -0
  9. package/dist/backends/agent-cli.js +174 -0
  10. package/dist/backends/api.js +171 -0
  11. package/dist/backends/index.js +10 -0
  12. package/dist/backends/manual.js +52 -0
  13. package/dist/backends/sse.js +24 -0
  14. package/dist/backends/types.js +1 -0
  15. package/dist/bin.js +3 -0
  16. package/dist/cli.js +104 -0
  17. package/dist/commands/context.js +12 -0
  18. package/dist/commands/init.js +199 -0
  19. package/dist/commands/next.js +103 -0
  20. package/dist/commands/plan.js +71 -0
  21. package/dist/commands/replan.js +37 -0
  22. package/dist/commands/review.js +37 -0
  23. package/dist/commands/shared.js +40 -0
  24. package/dist/commands/status.js +95 -0
  25. package/dist/config/commands.js +85 -0
  26. package/dist/config/schema.js +43 -0
  27. package/dist/config/settings.js +8 -0
  28. package/dist/config/store.js +38 -0
  29. package/dist/core/bash.js +105 -0
  30. package/dist/core/errors.js +14 -0
  31. package/dist/core/fs.js +35 -0
  32. package/dist/core/git.js +104 -0
  33. package/dist/core/gitignore.js +14 -0
  34. package/dist/core/json.js +13 -0
  35. package/dist/core/paths.js +14 -0
  36. package/dist/core/process.js +56 -0
  37. package/dist/core/prompt-loader.js +27 -0
  38. package/dist/core/state.js +27 -0
  39. package/dist/core/which.js +24 -0
  40. package/dist/detect/agents.js +17 -0
  41. package/dist/detect/mode.js +11 -0
  42. package/dist/digest/baseline.js +192 -0
  43. package/dist/digest/budget.js +31 -0
  44. package/dist/digest/files.js +167 -0
  45. package/dist/digest/format.js +18 -0
  46. package/dist/digest/git.js +19 -0
  47. package/dist/digest/index.js +100 -0
  48. package/dist/digest/manifests.js +133 -0
  49. package/dist/digest/read.js +28 -0
  50. package/dist/digest/redact.js +37 -0
  51. package/dist/digest/render.js +138 -0
  52. package/dist/digest/select.js +116 -0
  53. package/dist/digest/todos.js +16 -0
  54. package/dist/digest/tree.js +55 -0
  55. package/dist/digest/walk.js +72 -0
  56. package/dist/gates/capture.js +145 -0
  57. package/dist/gates/contract.js +339 -0
  58. package/dist/gates/enforce.js +143 -0
  59. package/dist/gates/findings.js +17 -0
  60. package/dist/gates/gate.js +315 -0
  61. package/dist/gates/ignore-rules.js +104 -0
  62. package/dist/gates/protected-files.js +311 -0
  63. package/dist/gates/regression.js +219 -0
  64. package/dist/gates/reports.js +349 -0
  65. package/dist/gates/results.js +295 -0
  66. package/dist/gates/runners.js +189 -0
  67. package/dist/gates/state-guard.js +56 -0
  68. package/dist/i18n/en.js +288 -0
  69. package/dist/i18n/es.js +288 -0
  70. package/dist/i18n/index.js +18 -0
  71. package/dist/interview/adaptive.js +69 -0
  72. package/dist/interview/brief.js +27 -0
  73. package/dist/interview/questions.js +18 -0
  74. package/dist/interview/render.js +19 -0
  75. package/dist/interview/reply.js +34 -0
  76. package/dist/next/attempts.js +151 -0
  77. package/dist/next/start.js +179 -0
  78. package/dist/plan/continuation.js +40 -0
  79. package/dist/plan/evidence.js +239 -0
  80. package/dist/plan/filter.js +20 -0
  81. package/dist/plan/parser.js +283 -0
  82. package/dist/plan/prior.js +32 -0
  83. package/dist/plan/questions.js +48 -0
  84. package/dist/plan/repair.js +33 -0
  85. package/dist/plan/report.js +37 -0
  86. package/dist/plan/run.js +191 -0
  87. package/dist/prompts/analyst.md +101 -0
  88. package/dist/prompts/continue.md +11 -0
  89. package/dist/prompts/fix-format.md +13 -0
  90. package/dist/prompts/fix-paths.md +23 -0
  91. package/dist/prompts/lesson.md +18 -0
  92. package/dist/prompts/retry.md +9 -0
  93. package/dist/prompts/review.md +36 -0
  94. package/dist/prompts/task.md +9 -0
  95. package/dist/review/changes.js +177 -0
  96. package/dist/review/diff.js +115 -0
  97. package/dist/review/integrity.js +150 -0
  98. package/dist/review/mechanical.js +62 -0
  99. package/dist/review/parse.js +40 -0
  100. package/dist/review/reviewer.js +23 -0
  101. package/dist/review/run.js +134 -0
  102. package/dist/review/scope.js +55 -0
  103. package/dist/review/secrets.js +163 -0
  104. package/dist/review/snapshot.js +38 -0
  105. package/dist/review/tests.js +140 -0
  106. package/dist/tasks/attempts.js +71 -0
  107. package/dist/tasks/checks.js +181 -0
  108. package/dist/tasks/frontmatter.js +58 -0
  109. package/dist/tasks/graph.js +27 -0
  110. package/dist/tasks/handoff.js +62 -0
  111. package/dist/tasks/learn.js +128 -0
  112. package/dist/tasks/load.js +37 -0
  113. package/dist/tasks/metrics.js +40 -0
  114. package/dist/tasks/runs.js +12 -0
  115. package/dist/tasks/schema.js +96 -0
  116. package/dist/tasks/select.js +15 -0
  117. package/dist/tasks/shell-words.js +176 -0
  118. package/dist/tasks/status.js +13 -0
  119. package/dist/tasks/verify.js +181 -0
  120. package/dist/ui/clack.js +46 -0
  121. package/dist/ui/prompter.js +1 -0
  122. package/package.json +64 -0
@@ -0,0 +1,199 @@
1
+ import { hasApiCredentials } from "../backends/api.js";
2
+ import { DEFAULT_AGENT_TIMEOUT_MINUTES, DEFAULT_CHECK_TIMEOUT_MINUTES, DEFAULT_DIGEST_MAX_CHARS, MODES, TARGETS, } from "../config/schema.js";
3
+ import { readConfig, readInterview, writeConfig, writeInterview } from "../config/store.js";
4
+ import { UserError } from "../core/errors.js";
5
+ import { ensureGitignore } from "../core/gitignore.js";
6
+ import { AGENT_BACKENDS, detectInstalledAgents, pickDefaultBackend } from "../detect/agents.js";
7
+ import { detectProjectMode } from "../detect/mode.js";
8
+ import { buildDigest } from "../digest/index.js";
9
+ import { getLang, setLang, t } from "../i18n/index.js";
10
+ import { interviewPrompt, runAdaptiveRound } from "../interview/adaptive.js";
11
+ import { loadBrief } from "../interview/brief.js";
12
+ import { BASE_QUESTIONS, OBJECTIVE_LABELS, OBJECTIVES } from "../interview/questions.js";
13
+ import { renderInterview } from "../interview/render.js";
14
+ export const NEXT_COMMAND = "npx builder-assistant-engineer plan";
15
+ const BACKEND_LABELS = {
16
+ claude: "Claude Code",
17
+ opencode: "opencode",
18
+ codex: "Codex CLI",
19
+ gemini: "Gemini CLI",
20
+ };
21
+ const TARGET_LABELS = {
22
+ "claude-code": "Claude Code",
23
+ opencode: "opencode",
24
+ codex: "Codex CLI",
25
+ gemini: "Gemini CLI",
26
+ };
27
+ const TARGET_FOR = {
28
+ claude: "claude-code",
29
+ opencode: "opencode",
30
+ codex: "codex",
31
+ gemini: "gemini",
32
+ };
33
+ export async function runInit(ctx, options) {
34
+ const existing = await readConfig(ctx.cwd);
35
+ ctx.prompter.intro(t("init.intro"));
36
+ const lang = await chooseLang(ctx, existing);
37
+ setLang(lang);
38
+ const mode = await chooseMode(ctx, await detectProjectMode(ctx.cwd));
39
+ const installed = await detectInstalledAgents(ctx.env);
40
+ const config = {
41
+ version: 1,
42
+ mode,
43
+ backend: await chooseBackend(ctx, installed, existing),
44
+ targets: await chooseTargets(ctx, installed, existing),
45
+ lang,
46
+ digest: existing?.digest ?? { maxChars: DEFAULT_DIGEST_MAX_CHARS },
47
+ commands: existing?.commands ?? {},
48
+ gates: existing?.gates ?? { regression: "full", timeoutMinutes: DEFAULT_CHECK_TIMEOUT_MINUTES },
49
+ verify: existing?.verify ?? { allow: [] },
50
+ secrets: existing?.secrets ?? { allow: [] },
51
+ agent: existing?.agent ?? { timeoutMinutes: DEFAULT_AGENT_TIMEOUT_MINUTES },
52
+ };
53
+ const interview = await interviewFor(ctx, config, options);
54
+ if (interview === undefined)
55
+ return;
56
+ await writeConfig(ctx.cwd, config);
57
+ await writeInterview(ctx.cwd, interview);
58
+ await ensureGitignore(ctx.cwd);
59
+ ctx.prompter.outro(t("init.done", { command: NEXT_COMMAND }));
60
+ }
61
+ async function chooseLang(ctx, existing) {
62
+ if (ctx.flags.lang)
63
+ return ctx.flags.lang;
64
+ const initial = existing?.lang ?? getLang();
65
+ if (ctx.flags.yes)
66
+ return initial;
67
+ const choices = [
68
+ { value: "en", label: "English" },
69
+ { value: "es", label: "Español" },
70
+ ];
71
+ return ctx.prompter.select(t("init.lang"), choices, initial);
72
+ }
73
+ async function chooseMode(ctx, detected) {
74
+ if (ctx.flags.yes)
75
+ return detected;
76
+ const choices = MODES.map((mode) => ({
77
+ value: mode,
78
+ label: t(`mode.${mode}`),
79
+ hint: mode === detected ? t("init.detected") : undefined,
80
+ }));
81
+ return ctx.prompter.select(t("init.mode"), choices, detected);
82
+ }
83
+ async function chooseBackend(ctx, installed, existing) {
84
+ if (ctx.flags.backend)
85
+ return ctx.flags.backend;
86
+ const initial = existing?.backend ?? pickDefaultBackend(installed, ctx.env);
87
+ if (ctx.flags.yes)
88
+ return initial;
89
+ const backend = await ctx.prompter.select(t("init.backend"), backendChoices(ctx, installed), initial);
90
+ if (isMissingAgent(backend, installed)) {
91
+ ctx.prompter.warn(t("init.backendMissing", { command: backend }));
92
+ }
93
+ return backend;
94
+ }
95
+ function backendChoices(ctx, installed) {
96
+ const agents = [...installed, ...AGENT_BACKENDS.filter((name) => !installed.includes(name))];
97
+ return [
98
+ ...agents.map((name) => ({
99
+ value: name,
100
+ label: BACKEND_LABELS[name],
101
+ hint: t(installed.includes(name) ? "backend.hint.installed" : "backend.hint.missing"),
102
+ })),
103
+ {
104
+ value: "api",
105
+ label: "API",
106
+ hint: t(hasApiCredentials(ctx.env) ? "backend.hint.apiReady" : "backend.hint.api"),
107
+ },
108
+ { value: "manual", label: t("backend.label.manual"), hint: t("backend.hint.manual") },
109
+ ];
110
+ }
111
+ function isMissingAgent(backend, installed) {
112
+ return AGENT_BACKENDS.some((name) => name === backend && !installed.includes(name));
113
+ }
114
+ async function chooseTargets(ctx, installed, existing) {
115
+ const detected = installed.map((name) => TARGET_FOR[name]);
116
+ const initial = existing?.targets ?? (detected.length > 0 ? detected : ["claude-code"]);
117
+ if (ctx.flags.yes)
118
+ return initial;
119
+ const choices = TARGETS.map((target) => ({ value: target, label: TARGET_LABELS[target] }));
120
+ return ctx.prompter.multiselect(t("init.targets"), choices, initial);
121
+ }
122
+ async function interviewFor(ctx, config, options) {
123
+ const current = await readInterview(ctx.cwd);
124
+ if (current && options.brief === undefined && !(await wantsRedo(ctx))) {
125
+ return ctx.flags.dryRun ? finishDryRun(ctx) : current;
126
+ }
127
+ const data = await collectInterview(ctx, config.mode, options.brief);
128
+ const adaptive = !ctx.flags.yes && config.backend !== "manual";
129
+ if (!adaptive && config.backend === "manual" && !ctx.flags.yes) {
130
+ ctx.prompter.info(t("interview.adaptiveManual"));
131
+ }
132
+ if (!adaptive)
133
+ return ctx.flags.dryRun ? finishDryRun(ctx) : renderInterview(data);
134
+ const digest = await ctx.prompter.spinner(t("digest.reading"), async () => {
135
+ return (await buildDigest(ctx.cwd, { maxChars: config.digest.maxChars })).text;
136
+ });
137
+ const base = {
138
+ cwd: ctx.cwd,
139
+ projectType: config.mode,
140
+ lang: config.lang,
141
+ targets: config.targets,
142
+ digest,
143
+ canExplore: AGENT_BACKENDS.some((name) => name === config.backend),
144
+ };
145
+ if (ctx.flags.dryRun) {
146
+ ctx.print(`${await interviewPrompt(base, data)}\n`);
147
+ ctx.prompter.outro(t("init.dryRunDone"));
148
+ return undefined;
149
+ }
150
+ const backend = ctx.createBackend(config.backend);
151
+ const result = await runAdaptiveRound({ ...base, backend, prompter: ctx.prompter }, data);
152
+ return renderInterview(result);
153
+ }
154
+ async function wantsRedo(ctx) {
155
+ if (ctx.flags.yes)
156
+ return false;
157
+ return ctx.prompter.confirm(t("init.redoInterview"), false);
158
+ }
159
+ function finishDryRun(ctx) {
160
+ ctx.prompter.info(t("init.dryRunNoAi"));
161
+ ctx.prompter.outro(t("init.dryRunDone"));
162
+ return undefined;
163
+ }
164
+ async function collectInterview(ctx, mode, briefOption) {
165
+ const interactive = !ctx.flags.yes;
166
+ if (interactive)
167
+ ctx.prompter.info(t("interview.intro"));
168
+ const brief = await collectBrief(ctx, briefOption);
169
+ const objective = interactive && mode === "brownfield" ? await askObjective(ctx) : undefined;
170
+ const answers = [];
171
+ if (interactive) {
172
+ for (const key of BASE_QUESTIONS) {
173
+ answers.push({ question: t(key), answer: await ctx.prompter.text(t(key)) });
174
+ }
175
+ }
176
+ return { mode, objective, brief: brief.text, answers, followUps: [] };
177
+ }
178
+ async function collectBrief(ctx, option) {
179
+ if (option !== undefined) {
180
+ const brief = await loadBrief(ctx.cwd, option);
181
+ if (brief.files.length === 0)
182
+ throw new UserError(t("init.briefMissing", { path: option }));
183
+ return brief;
184
+ }
185
+ if (ctx.flags.yes)
186
+ return { text: "", files: [] };
187
+ const raw = await ctx.prompter.text(t("interview.brief"), t("interview.briefPlaceholder"));
188
+ const brief = await loadBrief(ctx.cwd, raw);
189
+ if (brief.files.length > 0) {
190
+ const paths = brief.files.join(", ");
191
+ ctx.prompter.success(t("interview.briefLoaded", { count: brief.files.length, paths }));
192
+ }
193
+ return brief;
194
+ }
195
+ async function askObjective(ctx) {
196
+ const choices = OBJECTIVES.map((value) => ({ value, label: t(OBJECTIVE_LABELS[value]) }));
197
+ const objective = await ctx.prompter.select(t("interview.objective"), choices, "feature");
198
+ return objective === "skip" ? undefined : t(OBJECTIVE_LABELS[objective]);
199
+ }
@@ -0,0 +1,103 @@
1
+ import { ExitCode } from "../core/errors.js";
2
+ import { loadPrompt, renderPrompt } from "../core/prompt-loader.js";
3
+ import { newAcceptance } from "../gates/findings.js";
4
+ import { suiteCommands } from "../gates/regression.js";
5
+ import { t } from "../i18n/index.js";
6
+ import { attemptOnce, headlessLoop } from "../next/attempts.js";
7
+ import { checksFor, recoverInterrupted, start, withSuiteCommands } from "../next/start.js";
8
+ import { capturedTask } from "../review/run.js";
9
+ import { MAX_ATTEMPTS, readAttempts, sinceBlocked } from "../tasks/attempts.js";
10
+ import { MAX_LOG_LINES, planContext } from "../tasks/handoff.js";
11
+ import { completionTimes, readMetrics } from "../tasks/metrics.js";
12
+ import { pickNext, waitingOn } from "../tasks/select.js";
13
+ import { CLI, isAgentBackend, loadValidTasks, requireConfig } from "./shared.js";
14
+ export async function runNext(ctx, options) {
15
+ const acceptance = newAcceptance(options.acceptFinding);
16
+ await recoverInterrupted(ctx, acceptance);
17
+ const stored = await requireConfig(ctx);
18
+ ctx.prompter.intro(t("next.intro"));
19
+ const tasks = await loadValidTasks(ctx);
20
+ const task = pickNext(tasks, await exhaustedTasks(ctx, tasks));
21
+ if (!task) {
22
+ reportNoTask(ctx, tasks);
23
+ return;
24
+ }
25
+ const config = task.meta.status === "pending" ? await withSuiteCommands(ctx, stored) : stored;
26
+ ctx.prompter.note(describeTask(task, checksFor(task, config)), `${task.meta.id} · ${task.meta.title}`);
27
+ if (ctx.flags.dryRun) {
28
+ ctx.print(`${task.text}\n`);
29
+ ctx.prompter.outro(t("next.dryRunDone"));
30
+ return;
31
+ }
32
+ const allowSkip = Boolean(options.allowSkip);
33
+ const headless = Boolean(options.headless) && isAgentBackend(config.backend);
34
+ const unattended = headless || Boolean(ctx.flags.yes);
35
+ const capture = await start(ctx, config, task, { allowSkip, unattended });
36
+ if (!capture)
37
+ throw new ExitCode(1);
38
+ const run = {
39
+ capture,
40
+ checks: checksFor(capturedTask(capture), capture.config),
41
+ approval: { granted: false },
42
+ acceptance,
43
+ allowSkip,
44
+ unattended,
45
+ tampered: [],
46
+ };
47
+ const prompt = await taskPrompt(ctx, capture, tasks);
48
+ if (options.headless && !headless)
49
+ ctx.prompter.warn(t("next.headlessNeedsAgent"));
50
+ const done = headless
51
+ ? await headlessLoop(ctx, run, prompt)
52
+ : await attemptOnce(ctx, run, prompt);
53
+ if (!done)
54
+ throw new ExitCode(1);
55
+ }
56
+ async function exhaustedTasks(ctx, tasks) {
57
+ const inProgress = tasks.filter((task) => task.meta.status === "in_progress");
58
+ const used = await Promise.all(inProgress.map(async (task) => [task.meta.id, sinceBlocked(await readAttempts(ctx.cwd, task.meta.id)).length]));
59
+ return new Set(used.filter(([, count]) => count >= MAX_ATTEMPTS).map(([id]) => id));
60
+ }
61
+ async function taskPrompt(ctx, capture, tasks) {
62
+ const task = capturedTask(capture);
63
+ const others = tasks.map((item) => (item.meta.id === task.meta.id ? task : item));
64
+ const metrics = await readMetrics(ctx.cwd, others.filter((item) => item.meta.status === "done").map((item) => item.meta.id));
65
+ return renderPrompt(await loadPrompt("task", ctx.cwd), {
66
+ context: planContext(capture.config, others, task, completionTimes(metrics)),
67
+ task: task.text.trim(),
68
+ task_path: task.path,
69
+ max_log_lines: String(MAX_LOG_LINES),
70
+ suite: suiteCommands(capture.config).length > 0
71
+ ? ", then the project's lint and test commands, which must not turn red"
72
+ : "",
73
+ });
74
+ }
75
+ function describeTask(task, checks) {
76
+ const { phase, size, risk, depends_on } = task.meta;
77
+ const deps = depends_on.length > 0 ? depends_on.join(", ") : "-";
78
+ const verify = checks.commands.map((command) => ` $ ${command}`).join("\n");
79
+ const lines = [t("next.meta", { phase, size, risk, deps }), `${t("verify.commands")}:`, verify];
80
+ if (checks.suite.length > 0) {
81
+ lines.push(`${t("regression.title")}:`, ...checks.suite.map((item) => ` $ ${item.command}`));
82
+ }
83
+ return lines.join("\n");
84
+ }
85
+ function reportNoTask(ctx, tasks) {
86
+ if (tasks.length === 0) {
87
+ ctx.prompter.outro(t("next.noPlan", { command: `${CLI} plan` }));
88
+ return;
89
+ }
90
+ if (tasks.every((task) => task.meta.status === "done")) {
91
+ ctx.prompter.outro(t("next.allDone"));
92
+ return;
93
+ }
94
+ const stuck = tasks
95
+ .filter((task) => task.meta.status !== "done")
96
+ .map((task) => {
97
+ const waiting = waitingOn(task, tasks);
98
+ const reason = task.meta.status === "blocked" ? "blocked" : `waiting on ${waiting.join(", ")}`;
99
+ return `${task.meta.id} ${task.meta.title} (${reason})`;
100
+ });
101
+ ctx.prompter.note(stuck.join("\n"), t("next.nothingReady"));
102
+ ctx.prompter.outro(t("next.unblock"));
103
+ }
@@ -0,0 +1,71 @@
1
+ import { planChanges } from "../artifacts/merge.js";
2
+ import { applyChanges, confirmChanges } from "../artifacts/write.js";
3
+ import { ensureGitignore } from "../core/gitignore.js";
4
+ import { t } from "../i18n/index.js";
5
+ import { selectFiles } from "../plan/filter.js";
6
+ import { closeQuestions } from "../plan/questions.js";
7
+ import { generatePlan } from "../plan/run.js";
8
+ import { loadTaskFiles } from "../tasks/load.js";
9
+ import { CLI, requireConfig, saveCommands } from "./shared.js";
10
+ export async function runPlan(ctx, options) {
11
+ const config = await requireConfig(ctx);
12
+ let confirmed = false;
13
+ while (await planOnce(ctx, config, options, confirmed))
14
+ confirmed = true;
15
+ }
16
+ async function planOnce(ctx, config, options, confirmed) {
17
+ ctx.prompter.intro(t("plan.intro"));
18
+ const existing = await loadTaskFiles(ctx.cwd);
19
+ if (existing.length > 0 && !confirmed && !ctx.flags.dryRun && !(await regenerate(ctx))) {
20
+ ctx.prompter.outro(t("plan.useReplan"));
21
+ return false;
22
+ }
23
+ const knownTaskIds = existing.map((task) => task.id);
24
+ const parsed = await generatePlan(ctx, config, { priorPlan: "", knownTaskIds });
25
+ if (!parsed) {
26
+ ctx.prompter.outro(t("plan.dryRunDone"));
27
+ return false;
28
+ }
29
+ const files = selectFiles(parsed.files, { only: options.only, targets: config.targets });
30
+ const replaced = options.only === undefined || options.only === "plan";
31
+ const obsolete = replaced ? obsoleteTasks(existing, parsed) : [];
32
+ const changes = [...(await planChanges(ctx.cwd, files)), ...obsolete];
33
+ const written = await writePlanFiles(ctx, changes);
34
+ await saveCommands(ctx, parsed.commands);
35
+ return reportPlan(ctx, parsed, written);
36
+ }
37
+ export function obsoleteTasks(tasks, parsed) {
38
+ const kept = new Set(parsed.tasks.map((task) => task.meta.id));
39
+ return tasks
40
+ .filter((task) => task.task?.meta.status === "pending" && !kept.has(task.id))
41
+ .map((task) => ({ path: task.path, before: task.text, after: "", kind: "delete" }));
42
+ }
43
+ export async function writePlanFiles(ctx, changes) {
44
+ const accepted = await confirmChanges(ctx, changes);
45
+ await applyChanges(ctx.cwd, accepted);
46
+ if (accepted.length > 0)
47
+ await ensureGitignore(ctx.cwd);
48
+ return accepted;
49
+ }
50
+ export async function reportPlan(ctx, parsed, written) {
51
+ ctx.prompter.note(parsed.summary, t("plan.summary"));
52
+ for (const warning of parsed.warnings)
53
+ ctx.prompter.warn(warning);
54
+ const closed = await closeQuestions(ctx, parsed.questions);
55
+ const answered = closed.some((question) => question.blocking && question.answer !== "");
56
+ if (answered && (await ctx.prompter.confirm(t("plan.rerun"), true))) {
57
+ ctx.prompter.outro(t("plan.rerunning"));
58
+ return true;
59
+ }
60
+ if (written.length === 0) {
61
+ ctx.prompter.outro(t("plan.nothingWritten"));
62
+ return false;
63
+ }
64
+ ctx.prompter.outro(t("plan.done", { count: written.length, next: `${CLI} next` }));
65
+ return false;
66
+ }
67
+ async function regenerate(ctx) {
68
+ if (ctx.flags.yes)
69
+ return true;
70
+ return ctx.prompter.confirm(t("plan.existingPlan"), false);
71
+ }
@@ -0,0 +1,37 @@
1
+ import { planChanges } from "../artifacts/merge.js";
2
+ import { t } from "../i18n/index.js";
3
+ import { selectFiles } from "../plan/filter.js";
4
+ import { buildPriorPlan } from "../plan/prior.js";
5
+ import { generatePlan } from "../plan/run.js";
6
+ import { loadTaskFiles } from "../tasks/load.js";
7
+ import { obsoleteTasks, reportPlan, writePlanFiles } from "./plan.js";
8
+ import { CLI, requireConfig, saveCommands } from "./shared.js";
9
+ const CHANGELOG = "docs/plan/CHANGELOG.md";
10
+ export async function runReplan(ctx) {
11
+ const config = await requireConfig(ctx);
12
+ while (await replanOnce(ctx, config)) { }
13
+ }
14
+ async function replanOnce(ctx, config) {
15
+ ctx.prompter.intro(t("replan.intro"));
16
+ const tasks = await loadTaskFiles(ctx.cwd);
17
+ if (tasks.length === 0) {
18
+ ctx.prompter.outro(t("replan.noPlan", { command: `${CLI} plan` }));
19
+ return false;
20
+ }
21
+ const priorPlan = await buildPriorPlan(ctx.cwd, tasks);
22
+ const parsed = await generatePlan(ctx, config, {
23
+ priorPlan,
24
+ knownTaskIds: tasks.map((task) => task.id),
25
+ });
26
+ if (!parsed) {
27
+ ctx.prompter.outro(t("plan.dryRunDone"));
28
+ return false;
29
+ }
30
+ if (!parsed.files.some((file) => file.path === CHANGELOG))
31
+ ctx.prompter.warn(t("replan.noChangelog"));
32
+ const files = selectFiles(parsed.files, { targets: config.targets });
33
+ const changes = [...(await planChanges(ctx.cwd, files)), ...obsoleteTasks(tasks, parsed)];
34
+ const written = await writePlanFiles(ctx, changes);
35
+ await saveCommands(ctx, parsed.commands);
36
+ return reportPlan(ctx, parsed, written);
37
+ }
@@ -0,0 +1,37 @@
1
+ import { ExitCode, UserError } from "../core/errors.js";
2
+ import { prepareCapture, readCapture } from "../gates/capture.js";
3
+ import { newAcceptance } from "../gates/findings.js";
4
+ import { t } from "../i18n/index.js";
5
+ import { formatFindings, reviewTask } from "../review/run.js";
6
+ import { loadValidTasks, requireConfig } from "./shared.js";
7
+ export async function runReview(ctx, taskId, options = {}) {
8
+ const config = await requireConfig(ctx);
9
+ ctx.prompter.intro(t("review.intro"));
10
+ const tasks = await loadValidTasks(ctx);
11
+ const wanted = taskId?.toUpperCase();
12
+ const task = wanted
13
+ ? tasks.find((candidate) => candidate.meta.id === wanted)
14
+ : tasks.find((candidate) => candidate.meta.status === "in_progress");
15
+ if (!task) {
16
+ throw new UserError(wanted ? t("review.unknownTask", { id: wanted }) : t("review.noTask"));
17
+ }
18
+ const stored = await readCapture(ctx.cwd, task.meta.id);
19
+ if (!stored)
20
+ ctx.prompter.warn(t("review.noCapture", { id: task.meta.id }));
21
+ const capture = stored ?? { ...(await prepareCapture(ctx.cwd, config, task)), snapshot: {} };
22
+ const result = await reviewTask(ctx, capture, [], newAcceptance(options.acceptFinding));
23
+ if (result.reason && result.status === "fail")
24
+ ctx.prompter.warn(result.reason);
25
+ if (result.findings.length > 0)
26
+ ctx.prompter.note(formatFindings(result.findings), t("review.findings"));
27
+ if (result.status === "skipped") {
28
+ ctx.prompter.outro(result.reason ?? "");
29
+ return;
30
+ }
31
+ if (result.status === "pass") {
32
+ ctx.prompter.outro(t("review.passed"));
33
+ return;
34
+ }
35
+ ctx.prompter.outro(t("review.failed", { id: task.meta.id }));
36
+ throw new ExitCode(1);
37
+ }
@@ -0,0 +1,40 @@
1
+ import { fillCommands, proposeCommands } from "../config/commands.js";
2
+ import { COMMAND_KEYS } from "../config/schema.js";
3
+ import { readConfig, writeConfig } from "../config/store.js";
4
+ import { UserError } from "../core/errors.js";
5
+ import { t } from "../i18n/index.js";
6
+ import { loadTaskFiles } from "../tasks/load.js";
7
+ export const CLI = "npx builder-assistant-engineer";
8
+ export async function requireConfig(ctx) {
9
+ const config = await readConfig(ctx.cwd);
10
+ if (!config)
11
+ throw new UserError(t("config.missing", { command: `${CLI} init` }));
12
+ return {
13
+ ...config,
14
+ backend: ctx.flags.backend ?? config.backend,
15
+ lang: ctx.flags.lang ?? config.lang,
16
+ };
17
+ }
18
+ export async function saveCommands(ctx, analyst = {}) {
19
+ const stored = await readConfig(ctx.cwd);
20
+ if (!stored)
21
+ return analyst;
22
+ const commands = fillCommands(stored.commands, analyst, await proposeCommands(ctx.cwd));
23
+ const added = COMMAND_KEYS.filter((key) => commands[key] && !stored.commands[key]);
24
+ if (added.length === 0 || ctx.flags.dryRun)
25
+ return commands;
26
+ await writeConfig(ctx.cwd, { ...stored, commands });
27
+ ctx.prompter.note(added.map((key) => `${key}: ${commands[key]}`).join("\n"), t("plan.commands"));
28
+ return commands;
29
+ }
30
+ export function isAgentBackend(backend) {
31
+ return backend !== "api" && backend !== "manual";
32
+ }
33
+ export async function loadValidTasks(ctx) {
34
+ const loaded = await loadTaskFiles(ctx.cwd);
35
+ for (const item of loaded) {
36
+ if (item.error)
37
+ ctx.prompter.warn(t("tasks.invalid", { error: item.error }));
38
+ }
39
+ return loaded.flatMap((item) => (item.task ? [item.task] : []));
40
+ }
@@ -0,0 +1,95 @@
1
+ import pc from "picocolors";
2
+ import { t } from "../i18n/index.js";
3
+ import { blockedReason } from "../tasks/attempts.js";
4
+ import { loadTaskFiles } from "../tasks/load.js";
5
+ import { formatDuration, readMetrics } from "../tasks/metrics.js";
6
+ import { orderTasks, pickNext, waitingOn } from "../tasks/select.js";
7
+ import { CLI } from "./shared.js";
8
+ const BAR_WIDTH = 20;
9
+ const TITLE_WIDTH = 56;
10
+ const SYMBOLS = {
11
+ done: pc.green("✔"),
12
+ in_progress: pc.yellow("▶"),
13
+ pending: pc.dim("○"),
14
+ blocked: pc.red("✖"),
15
+ };
16
+ export async function runStatus(ctx) {
17
+ const loaded = await loadTaskFiles(ctx.cwd);
18
+ if (loaded.length === 0) {
19
+ ctx.print(`${t("status.empty", { command: `${CLI} plan` })}\n`);
20
+ return;
21
+ }
22
+ const metrics = await readMetrics(ctx.cwd, loaded.map((item) => item.id));
23
+ const blocked = loaded.filter((item) => item.task?.meta.status === "blocked");
24
+ const reasons = new Map(await Promise.all(blocked.map(async (item) => [item.id, (await blockedReason(ctx.cwd, item.id)) ?? ""])));
25
+ ctx.print(`${renderStatus(loaded, metrics, reasons)}\n`);
26
+ }
27
+ export function renderStatus(loaded, metrics, reasons = new Map()) {
28
+ const tasks = loaded.flatMap((item) => (item.task ? [item.task] : []));
29
+ const phases = [...new Set(orderTasks(tasks).map((task) => task.meta.phase))];
30
+ const blocks = phases.map((phase) => renderPhase(phase, orderTasks(tasks).filter((task) => task.meta.phase === phase), tasks, metrics));
31
+ const invalid = loaded.filter((item) => item.error).map((item) => `${pc.red("!")} ${item.error}`);
32
+ const why = [...reasons]
33
+ .filter(([, reason]) => reason !== "")
34
+ .map(([id, reason]) => `${SYMBOLS.blocked} ${t("status.blockedReason", { id, reason })}`);
35
+ return [
36
+ ...blocks,
37
+ ...(why.length > 0 ? [why.join("\n")] : []),
38
+ ...(invalid.length > 0 ? [invalid.join("\n")] : []),
39
+ summary(tasks),
40
+ ...(metrics && metrics.attempts > 0 ? [renderMetrics(metrics)] : []),
41
+ ].join("\n\n");
42
+ }
43
+ function renderPhase(phase, tasks, all, metrics) {
44
+ const done = tasks.filter((task) => task.meta.status === "done").length;
45
+ const header = `${pc.bold(t("status.phase", { phase }))} ${bar(done, tasks.length)} ${done}/${tasks.length}`;
46
+ return [header, ...tasks.map((task) => renderTask(task, all, metrics))].join("\n");
47
+ }
48
+ function renderTask(task, all, metrics) {
49
+ const { id, size, risk, status, title } = task.meta;
50
+ const waiting = status === "pending" ? waitingOn(task, all) : [];
51
+ const note = waiting.length > 0 ? pc.dim(` ${t("status.waiting", { ids: waiting.join(", ") })}`) : "";
52
+ const label = status === "done" ? "" : pc.dim(` ${status}`);
53
+ const runs = metrics?.tasks.get(id);
54
+ const effort = runs
55
+ ? pc.dim(` ${t("status.taskRuns", { attempts: runs.attempts, time: formatDuration(runs.durationMs) })}`)
56
+ : "";
57
+ return ` ${SYMBOLS[status]} ${id} ${size} ${risk.padEnd(6)} ${truncate(title)}${label}${note}${effort}`;
58
+ }
59
+ function summary(tasks) {
60
+ const done = tasks.filter((task) => task.meta.status === "done").length;
61
+ const next = pickNext(tasks);
62
+ const nextText = next
63
+ ? t("status.next", { id: next.meta.id, title: next.meta.title })
64
+ : t("status.noNext");
65
+ return `${t("status.total", { done, total: tasks.length, percent: percent(done, tasks.length) })} · ${nextText}`;
66
+ }
67
+ function renderMetrics(metrics) {
68
+ const average = (metrics.attempts / Math.max(metrics.attempted, 1)).toFixed(1);
69
+ const lines = [
70
+ t("status.attempts", { attempts: metrics.attempts, tasks: metrics.attempted, average }),
71
+ t("status.firstAttempt", {
72
+ count: metrics.doneOnFirst,
73
+ tasks: metrics.attempted,
74
+ percent: percent(metrics.doneOnFirst, metrics.attempted),
75
+ }),
76
+ t("status.regressions", { count: metrics.regressionsCaught }),
77
+ ];
78
+ if (metrics.done > 0) {
79
+ lines.push(t("status.time", {
80
+ average: formatDuration(metrics.doneDurationMs / metrics.done),
81
+ total: formatDuration(metrics.doneDurationMs),
82
+ }));
83
+ }
84
+ return [pc.bold(t("status.metrics")), ...lines.map((line) => ` ${line}`)].join("\n");
85
+ }
86
+ function percent(part, total) {
87
+ return total > 0 ? Math.round((part / total) * 100) : 0;
88
+ }
89
+ function bar(done, total) {
90
+ const filled = total > 0 ? Math.round((done / total) * BAR_WIDTH) : 0;
91
+ return pc.green("█".repeat(filled)) + pc.dim("░".repeat(BAR_WIDTH - filled));
92
+ }
93
+ function truncate(text) {
94
+ return text.length > TITLE_WIDTH ? `${text.slice(0, TITLE_WIDTH - 1)}…` : text;
95
+ }
@@ -0,0 +1,85 @@
1
+ import { join } from "node:path";
2
+ import { readTextIfExists } from "../core/fs.js";
3
+ import { asRecord, parseObject } from "../core/json.js";
4
+ import { COMMAND_KEYS } from "./schema.js";
5
+ const NPM_SCRIPTS = {
6
+ test: ["test"],
7
+ lint: ["lint"],
8
+ typecheck: ["typecheck", "type-check", "check-types", "types"],
9
+ build: ["build"],
10
+ };
11
+ const PLACEHOLDER_TEST = /no test specified/;
12
+ const LOCKFILES = [
13
+ ["pnpm-lock.yaml", "pnpm"],
14
+ ["yarn.lock", "yarn"],
15
+ ["bun.lock", "bun"],
16
+ ["bun.lockb", "bun"],
17
+ ];
18
+ const DEFAULTS = [
19
+ ["go.mod", { test: "go test -v ./...", lint: "go vet ./...", build: "go build ./..." }],
20
+ [
21
+ "Cargo.toml",
22
+ { test: "cargo test", lint: "cargo clippy", typecheck: "cargo check", build: "cargo build" },
23
+ ],
24
+ ];
25
+ const PYTHON_TOOLS = [
26
+ ["test", /\bpytest\b/, "pytest"],
27
+ ["lint", /\bruff\b/, "ruff check ."],
28
+ ["lint", /\bflake8\b/, "flake8"],
29
+ ["typecheck", /\bmypy\b/, "mypy ."],
30
+ ["typecheck", /\bpyright\b/, "pyright"],
31
+ ];
32
+ export async function proposeCommands(cwd) {
33
+ const read = (name) => readTextIfExists(join(cwd, name));
34
+ const [pkg, makefile, ...python] = await Promise.all(["package.json", "Makefile", "pyproject.toml", "requirements.txt", "requirements-dev.txt"].map(read));
35
+ const inferred = await Promise.all(DEFAULTS.map(async ([file, commands]) => ((await read(file)) === undefined ? {} : commands)));
36
+ return fillCommands(pkg ? fromScripts(pkg, await detectRunner(cwd, pkg)) : {}, makefile ? fromMakefile(makefile) : {}, fromPython(python.join("\n")), ...inferred);
37
+ }
38
+ export function fillCommands(...sources) {
39
+ const commands = {};
40
+ for (const key of COMMAND_KEYS) {
41
+ const value = sources.map((source) => source?.[key]?.trim()).find(Boolean);
42
+ if (value)
43
+ commands[key] = value;
44
+ }
45
+ return commands;
46
+ }
47
+ function fromScripts(text, runner) {
48
+ const scripts = asRecord(parseObject(text)?.scripts);
49
+ const commands = {};
50
+ for (const key of COMMAND_KEYS) {
51
+ const name = NPM_SCRIPTS[key].find((candidate) => typeof scripts[candidate] === "string");
52
+ if (!name || (key === "test" && PLACEHOLDER_TEST.test(String(scripts[name]))))
53
+ continue;
54
+ commands[key] =
55
+ runner !== "bun" && name === "test" ? `${runner} test` : `${runner} run ${name}`;
56
+ }
57
+ return commands;
58
+ }
59
+ async function detectRunner(cwd, pkg) {
60
+ const declared = String(parseObject(pkg)?.packageManager ?? "").split("@")[0];
61
+ if (declared === "pnpm" || declared === "yarn" || declared === "bun")
62
+ return declared;
63
+ for (const [file, runner] of LOCKFILES) {
64
+ if ((await readTextIfExists(join(cwd, file))) !== undefined)
65
+ return runner;
66
+ }
67
+ return "npm";
68
+ }
69
+ function fromMakefile(text) {
70
+ const commands = {};
71
+ for (const key of COMMAND_KEYS) {
72
+ if (new RegExp(`^${key}\\s*:`, "m").test(text))
73
+ commands[key] = `make ${key}`;
74
+ }
75
+ return commands;
76
+ }
77
+ function fromPython(text) {
78
+ const lower = text.toLowerCase();
79
+ const commands = {};
80
+ for (const [key, pattern, command] of PYTHON_TOOLS) {
81
+ if (!commands[key] && pattern.test(lower))
82
+ commands[key] = command;
83
+ }
84
+ return commands;
85
+ }