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.
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/analyst/prompt.js +19 -0
- package/dist/analyst/retry.js +38 -0
- package/dist/artifacts/diff.js +47 -0
- package/dist/artifacts/lessons.js +32 -0
- package/dist/artifacts/merge.js +70 -0
- package/dist/artifacts/write.js +44 -0
- package/dist/backends/agent-cli.js +174 -0
- package/dist/backends/api.js +171 -0
- package/dist/backends/index.js +10 -0
- package/dist/backends/manual.js +52 -0
- package/dist/backends/sse.js +24 -0
- package/dist/backends/types.js +1 -0
- package/dist/bin.js +3 -0
- package/dist/cli.js +104 -0
- package/dist/commands/context.js +12 -0
- package/dist/commands/init.js +199 -0
- package/dist/commands/next.js +103 -0
- package/dist/commands/plan.js +71 -0
- package/dist/commands/replan.js +37 -0
- package/dist/commands/review.js +37 -0
- package/dist/commands/shared.js +40 -0
- package/dist/commands/status.js +95 -0
- package/dist/config/commands.js +85 -0
- package/dist/config/schema.js +43 -0
- package/dist/config/settings.js +8 -0
- package/dist/config/store.js +38 -0
- package/dist/core/bash.js +105 -0
- package/dist/core/errors.js +14 -0
- package/dist/core/fs.js +35 -0
- package/dist/core/git.js +104 -0
- package/dist/core/gitignore.js +14 -0
- package/dist/core/json.js +13 -0
- package/dist/core/paths.js +14 -0
- package/dist/core/process.js +56 -0
- package/dist/core/prompt-loader.js +27 -0
- package/dist/core/state.js +27 -0
- package/dist/core/which.js +24 -0
- package/dist/detect/agents.js +17 -0
- package/dist/detect/mode.js +11 -0
- package/dist/digest/baseline.js +192 -0
- package/dist/digest/budget.js +31 -0
- package/dist/digest/files.js +167 -0
- package/dist/digest/format.js +18 -0
- package/dist/digest/git.js +19 -0
- package/dist/digest/index.js +100 -0
- package/dist/digest/manifests.js +133 -0
- package/dist/digest/read.js +28 -0
- package/dist/digest/redact.js +37 -0
- package/dist/digest/render.js +138 -0
- package/dist/digest/select.js +116 -0
- package/dist/digest/todos.js +16 -0
- package/dist/digest/tree.js +55 -0
- package/dist/digest/walk.js +72 -0
- package/dist/gates/capture.js +145 -0
- package/dist/gates/contract.js +339 -0
- package/dist/gates/enforce.js +143 -0
- package/dist/gates/findings.js +17 -0
- package/dist/gates/gate.js +315 -0
- package/dist/gates/ignore-rules.js +104 -0
- package/dist/gates/protected-files.js +311 -0
- package/dist/gates/regression.js +219 -0
- package/dist/gates/reports.js +349 -0
- package/dist/gates/results.js +295 -0
- package/dist/gates/runners.js +189 -0
- package/dist/gates/state-guard.js +56 -0
- package/dist/i18n/en.js +288 -0
- package/dist/i18n/es.js +288 -0
- package/dist/i18n/index.js +18 -0
- package/dist/interview/adaptive.js +69 -0
- package/dist/interview/brief.js +27 -0
- package/dist/interview/questions.js +18 -0
- package/dist/interview/render.js +19 -0
- package/dist/interview/reply.js +34 -0
- package/dist/next/attempts.js +151 -0
- package/dist/next/start.js +179 -0
- package/dist/plan/continuation.js +40 -0
- package/dist/plan/evidence.js +239 -0
- package/dist/plan/filter.js +20 -0
- package/dist/plan/parser.js +283 -0
- package/dist/plan/prior.js +32 -0
- package/dist/plan/questions.js +48 -0
- package/dist/plan/repair.js +33 -0
- package/dist/plan/report.js +37 -0
- package/dist/plan/run.js +191 -0
- package/dist/prompts/analyst.md +101 -0
- package/dist/prompts/continue.md +11 -0
- package/dist/prompts/fix-format.md +13 -0
- package/dist/prompts/fix-paths.md +23 -0
- package/dist/prompts/lesson.md +18 -0
- package/dist/prompts/retry.md +9 -0
- package/dist/prompts/review.md +36 -0
- package/dist/prompts/task.md +9 -0
- package/dist/review/changes.js +177 -0
- package/dist/review/diff.js +115 -0
- package/dist/review/integrity.js +150 -0
- package/dist/review/mechanical.js +62 -0
- package/dist/review/parse.js +40 -0
- package/dist/review/reviewer.js +23 -0
- package/dist/review/run.js +134 -0
- package/dist/review/scope.js +55 -0
- package/dist/review/secrets.js +163 -0
- package/dist/review/snapshot.js +38 -0
- package/dist/review/tests.js +140 -0
- package/dist/tasks/attempts.js +71 -0
- package/dist/tasks/checks.js +181 -0
- package/dist/tasks/frontmatter.js +58 -0
- package/dist/tasks/graph.js +27 -0
- package/dist/tasks/handoff.js +62 -0
- package/dist/tasks/learn.js +128 -0
- package/dist/tasks/load.js +37 -0
- package/dist/tasks/metrics.js +40 -0
- package/dist/tasks/runs.js +12 -0
- package/dist/tasks/schema.js +96 -0
- package/dist/tasks/select.js +15 -0
- package/dist/tasks/shell-words.js +176 -0
- package/dist/tasks/status.js +13 -0
- package/dist/tasks/verify.js +181 -0
- package/dist/ui/clack.js +46 -0
- package/dist/ui/prompter.js +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { FormatError } from "../core/errors.js";
|
|
4
|
+
import { splitFrontmatter } from "../tasks/frontmatter.js";
|
|
5
|
+
import { findCycle } from "../tasks/graph.js";
|
|
6
|
+
import { withLogSection } from "../tasks/handoff.js";
|
|
7
|
+
import { parseTask, TASK_PATH, taskProblems } from "../tasks/schema.js";
|
|
8
|
+
const questionSchema = z.object({
|
|
9
|
+
question: z.string().min(1),
|
|
10
|
+
why: z.string().default(""),
|
|
11
|
+
options: z.array(z.string()).optional(),
|
|
12
|
+
blocking: z.boolean().optional(),
|
|
13
|
+
});
|
|
14
|
+
const nullableCommand = z.string().nullish();
|
|
15
|
+
const configBlockSchema = z.object({
|
|
16
|
+
commands: z.object({
|
|
17
|
+
test: nullableCommand,
|
|
18
|
+
lint: nullableCommand,
|
|
19
|
+
typecheck: nullableCommand,
|
|
20
|
+
build: nullableCommand,
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
export const MARKER = /^[ \t]*<<<(SUMMARY|QUESTIONS|CONFIG|END SUMMARY|END QUESTIONS|END CONFIG|END FILE|FILE:[^>\n]*)>>>/gm;
|
|
24
|
+
const MAX_PROBLEMS = 20;
|
|
25
|
+
const ALLOWED_PATHS = [
|
|
26
|
+
/^AGENTS\.md$/,
|
|
27
|
+
/^CLAUDE\.md$/,
|
|
28
|
+
/^GEMINI\.md$/,
|
|
29
|
+
/^docs\/plan\/[A-Za-z0-9._/-]+\.md$/,
|
|
30
|
+
/^\.claude\/(agents|commands)\/[A-Za-z0-9._-]+\.md$/,
|
|
31
|
+
/^\.opencode\/(agent|command)\/[A-Za-z0-9._-]+\.md$/,
|
|
32
|
+
];
|
|
33
|
+
const AGENT_PATH = /^\.(claude\/agents|opencode\/agent)\/[^/]+\.md$/;
|
|
34
|
+
export const PLAN_FORMAT = [
|
|
35
|
+
"Emit only these blocks, nothing outside them. Paths are relative to the repo root.",
|
|
36
|
+
"<<<SUMMARY>>>",
|
|
37
|
+
"5-8 lines for the terminal",
|
|
38
|
+
"<<<END SUMMARY>>>",
|
|
39
|
+
"<<<QUESTIONS>>>",
|
|
40
|
+
'JSON array of up to 5 objects {"question", "why", "options"?, "blocking"?}; [] if none',
|
|
41
|
+
"<<<END QUESTIONS>>>",
|
|
42
|
+
"<<<CONFIG>>>",
|
|
43
|
+
'{"commands": {"test": "...", "lint": "...", "typecheck": "...", "build": "..."}} with null for commands the project will not have',
|
|
44
|
+
"<<<END CONFIG>>>",
|
|
45
|
+
"<<<FILE: path/to/file.md>>>",
|
|
46
|
+
"full file content",
|
|
47
|
+
"<<<END FILE>>>",
|
|
48
|
+
"Allowed FILE paths: AGENTS.md, CLAUDE.md, GEMINI.md, docs/plan/**/*.md, docs/plan/tasks/T-NNN-slug.md,",
|
|
49
|
+
".claude/agents/*.md, .claude/commands/*.md, .opencode/agent/*.md, .opencode/command/*.md.",
|
|
50
|
+
"Task files start with YAML frontmatter (id, title, status, phase, depends_on, size S|M|L,",
|
|
51
|
+
"risk low|medium|high, tests required|optional) and contain the sections Goal, Context, Scope, Steps, Acceptance criteria,",
|
|
52
|
+
"Verification (commands in a ```sh block, one per line), Risks and notes, and an empty Log.",
|
|
53
|
+
"Subagent files start with YAML frontmatter that includes a description.",
|
|
54
|
+
].join("\n");
|
|
55
|
+
export function parsePlan(text, options = {}) {
|
|
56
|
+
const problems = [];
|
|
57
|
+
const warnings = [];
|
|
58
|
+
const blocks = tokenize(text, problems, warnings);
|
|
59
|
+
const summary = single(blocks, "SUMMARY", problems)?.body.trim() ?? "";
|
|
60
|
+
if (summary === "" && problems.length === 0)
|
|
61
|
+
problems.push("SUMMARY is empty");
|
|
62
|
+
const questions = parseQuestions(single(blocks, "QUESTIONS", problems)?.body, problems);
|
|
63
|
+
const commands = parseConfig(blocks, warnings);
|
|
64
|
+
const files = collectFiles(blocks, problems).map((file) => TASK_PATH.test(file.path) ? { ...file, content: withLogSection(file.content) } : file);
|
|
65
|
+
const tasks = validateTasks(files, options.knownTaskIds ?? [], problems);
|
|
66
|
+
warnings.push(...missingTestPolicies(files));
|
|
67
|
+
validateAgents(files, options.requireReviewer ?? false, problems);
|
|
68
|
+
if (problems.length > 0)
|
|
69
|
+
throw new FormatError(problems.slice(0, MAX_PROBLEMS).join("\n"));
|
|
70
|
+
return { summary, questions, commands, files, tasks, warnings };
|
|
71
|
+
}
|
|
72
|
+
export function parseFileBlocks(text) {
|
|
73
|
+
const ignored = [];
|
|
74
|
+
return collectFiles(tokenize(text, ignored, ignored), ignored);
|
|
75
|
+
}
|
|
76
|
+
export function renderPlan(plan) {
|
|
77
|
+
return [
|
|
78
|
+
"<<<SUMMARY>>>",
|
|
79
|
+
plan.summary,
|
|
80
|
+
"<<<END SUMMARY>>>",
|
|
81
|
+
"<<<QUESTIONS>>>",
|
|
82
|
+
JSON.stringify(plan.questions),
|
|
83
|
+
"<<<END QUESTIONS>>>",
|
|
84
|
+
"<<<CONFIG>>>",
|
|
85
|
+
JSON.stringify({ commands: plan.commands }),
|
|
86
|
+
"<<<END CONFIG>>>",
|
|
87
|
+
...plan.files.flatMap((file) => [
|
|
88
|
+
`<<<FILE: ${file.path}>>>`,
|
|
89
|
+
file.content.trimEnd(),
|
|
90
|
+
"<<<END FILE>>>",
|
|
91
|
+
]),
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
function tokenize(text, problems, warnings) {
|
|
95
|
+
const blocks = [];
|
|
96
|
+
let open;
|
|
97
|
+
let outside = 0;
|
|
98
|
+
let cursor = 0;
|
|
99
|
+
for (const match of text.matchAll(MARKER)) {
|
|
100
|
+
const marker = match[1] ?? "";
|
|
101
|
+
const index = match.index ?? 0;
|
|
102
|
+
if (!open)
|
|
103
|
+
outside += text.slice(cursor, index).trim().length;
|
|
104
|
+
cursor = index + match[0].length;
|
|
105
|
+
if (marker.startsWith("END ")) {
|
|
106
|
+
const kind = marker.slice(4);
|
|
107
|
+
if (open?.kind !== kind) {
|
|
108
|
+
problems.push(`<<<${marker}>>> without a matching opening marker`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
blocks.push({
|
|
112
|
+
kind: open.kind,
|
|
113
|
+
path: open.path,
|
|
114
|
+
body: stripEdges(text.slice(open.end, index)),
|
|
115
|
+
});
|
|
116
|
+
open = undefined;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (open) {
|
|
120
|
+
problems.push(`<<<${marker}>>> opened before <<<END ${open.kind}>>> of ${open.path || open.kind}`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const kind = marker.startsWith("FILE:") ? "FILE" : marker;
|
|
124
|
+
open = { kind, path: marker.startsWith("FILE:") ? marker.slice(5).trim() : "", end: cursor };
|
|
125
|
+
}
|
|
126
|
+
if (open)
|
|
127
|
+
problems.push(`${open.path || open.kind} is missing <<<END ${open.kind}>>>`);
|
|
128
|
+
else
|
|
129
|
+
outside += text.slice(cursor).trim().length;
|
|
130
|
+
if (outside > 0)
|
|
131
|
+
warnings.push(`ignored ${outside} characters outside the output blocks`);
|
|
132
|
+
return blocks;
|
|
133
|
+
}
|
|
134
|
+
function single(blocks, kind, problems) {
|
|
135
|
+
const found = blocks.filter((block) => block.kind === kind);
|
|
136
|
+
if (found.length !== 1)
|
|
137
|
+
problems.push(`expected exactly one ${kind} block, found ${found.length}`);
|
|
138
|
+
return found[0];
|
|
139
|
+
}
|
|
140
|
+
function parseQuestions(body, problems) {
|
|
141
|
+
if (body === undefined)
|
|
142
|
+
return [];
|
|
143
|
+
const json = unfence(body);
|
|
144
|
+
let data;
|
|
145
|
+
try {
|
|
146
|
+
data = JSON.parse(json);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
problems.push(`QUESTIONS is not valid JSON (${error instanceof Error ? error.message : error})`);
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
const result = z.array(questionSchema).max(5).safeParse(data);
|
|
153
|
+
if (result.success)
|
|
154
|
+
return result.data;
|
|
155
|
+
problems.push(`QUESTIONS does not match the schema (${z.prettifyError(result.error)})`);
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
function parseConfig(blocks, warnings) {
|
|
159
|
+
const found = blocks.filter((block) => block.kind === "CONFIG");
|
|
160
|
+
if (found.length > 1)
|
|
161
|
+
warnings.push("ignored every CONFIG block after the first");
|
|
162
|
+
const body = found[0]?.body;
|
|
163
|
+
if (body === undefined)
|
|
164
|
+
return {};
|
|
165
|
+
let data;
|
|
166
|
+
try {
|
|
167
|
+
data = JSON.parse(unfence(body));
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
warnings.push("ignored the CONFIG block: it is not valid JSON");
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
const result = configBlockSchema.safeParse(data);
|
|
174
|
+
if (!result.success) {
|
|
175
|
+
warnings.push("ignored the CONFIG block: it does not match the schema");
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
const commands = {};
|
|
179
|
+
for (const [key, value] of Object.entries(result.data.commands)) {
|
|
180
|
+
if (value?.trim())
|
|
181
|
+
commands[key] = value.trim();
|
|
182
|
+
}
|
|
183
|
+
return commands;
|
|
184
|
+
}
|
|
185
|
+
function unfence(body) {
|
|
186
|
+
return body
|
|
187
|
+
.trim()
|
|
188
|
+
.replace(/^```(?:json)?\s*\n?/, "")
|
|
189
|
+
.replace(/\n?```$/, "");
|
|
190
|
+
}
|
|
191
|
+
function collectFiles(blocks, problems) {
|
|
192
|
+
const files = [];
|
|
193
|
+
const seen = new Set();
|
|
194
|
+
for (const block of blocks.filter((candidate) => candidate.kind === "FILE")) {
|
|
195
|
+
const path = normalizePath(block.path, problems);
|
|
196
|
+
if (!path)
|
|
197
|
+
continue;
|
|
198
|
+
if (seen.has(path))
|
|
199
|
+
problems.push(`${path} appears more than once`);
|
|
200
|
+
seen.add(path);
|
|
201
|
+
files.push({ path, content: `${block.body.trimEnd()}\n` });
|
|
202
|
+
}
|
|
203
|
+
if (!seen.has("AGENTS.md"))
|
|
204
|
+
problems.push("missing FILE block for AGENTS.md");
|
|
205
|
+
return files;
|
|
206
|
+
}
|
|
207
|
+
function normalizePath(raw, problems) {
|
|
208
|
+
const path = raw.replace(/^\.\//, "");
|
|
209
|
+
const unsafe = path === "" || path.includes("\\") || posix.isAbsolute(path) || path.split("/").includes("..");
|
|
210
|
+
if (unsafe || !ALLOWED_PATHS.some((pattern) => pattern.test(path))) {
|
|
211
|
+
problems.push(`FILE path not allowed: "${raw}"`);
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
return path;
|
|
215
|
+
}
|
|
216
|
+
function validateTasks(files, knownIds, problems) {
|
|
217
|
+
const tasks = [];
|
|
218
|
+
for (const file of files.filter((candidate) => candidate.path.startsWith("docs/plan/tasks/"))) {
|
|
219
|
+
const fileId = TASK_PATH.exec(file.path)?.[1];
|
|
220
|
+
if (!fileId) {
|
|
221
|
+
problems.push(`${file.path}: task files must be named docs/plan/tasks/T-NNN-slug.md`);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
const task = parseTask(file.path, file.content);
|
|
226
|
+
if (task.meta.id !== fileId)
|
|
227
|
+
problems.push(`${file.path}: frontmatter id ${task.meta.id} does not match the file name`);
|
|
228
|
+
problems.push(...taskProblems(task));
|
|
229
|
+
tasks.push(task);
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
if (!(error instanceof FormatError))
|
|
233
|
+
throw error;
|
|
234
|
+
problems.push(error.message);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
validateGraph(tasks, knownIds, problems);
|
|
238
|
+
return tasks;
|
|
239
|
+
}
|
|
240
|
+
function missingTestPolicies(files) {
|
|
241
|
+
return files
|
|
242
|
+
.filter((file) => TASK_PATH.test(file.path))
|
|
243
|
+
.filter((file) => safeFrontmatter(file.content)?.data.tests === undefined)
|
|
244
|
+
.map((file) => `${file.path}: no tests field in the frontmatter; assuming optional`);
|
|
245
|
+
}
|
|
246
|
+
function validateGraph(tasks, knownIds, problems) {
|
|
247
|
+
const ids = tasks.map((task) => task.meta.id);
|
|
248
|
+
const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index);
|
|
249
|
+
for (const id of new Set(duplicates))
|
|
250
|
+
problems.push(`task id ${id} is used more than once`);
|
|
251
|
+
const known = new Set([...ids, ...knownIds]);
|
|
252
|
+
for (const task of tasks) {
|
|
253
|
+
const unknown = task.meta.depends_on.filter((id) => !known.has(id));
|
|
254
|
+
if (unknown.length > 0)
|
|
255
|
+
problems.push(`${task.meta.id} depends on unknown tasks: ${unknown.join(", ")}`);
|
|
256
|
+
}
|
|
257
|
+
const cycle = findCycle(tasks.map((task) => ({ id: task.meta.id, dependsOn: task.meta.depends_on })));
|
|
258
|
+
if (cycle)
|
|
259
|
+
problems.push(`dependency cycle: ${cycle.join(" -> ")}`);
|
|
260
|
+
}
|
|
261
|
+
function validateAgents(files, requireReviewer, problems) {
|
|
262
|
+
const agents = files.filter((file) => AGENT_PATH.test(file.path));
|
|
263
|
+
for (const agent of agents) {
|
|
264
|
+
const description = safeFrontmatter(agent.content)?.data.description;
|
|
265
|
+
if (typeof description !== "string" || description.trim() === "") {
|
|
266
|
+
problems.push(`${agent.path}: frontmatter must include a description`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (requireReviewer && !agents.some((agent) => /review/i.test(posix.basename(agent.path)))) {
|
|
270
|
+
problems.push("missing the reviewer subagent (a file whose name contains 'review')");
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function safeFrontmatter(text) {
|
|
274
|
+
try {
|
|
275
|
+
return splitFrontmatter(text);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function stripEdges(text) {
|
|
282
|
+
return text.replace(/^[ \t]*\r?\n/, "").replace(/\r?\n[ \t]*$/, "");
|
|
283
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readTextIfExists } from "../core/fs.js";
|
|
3
|
+
import { truncateText } from "../digest/format.js";
|
|
4
|
+
const PLAN_DOCS = ["00-overview.md", "01-prd.md", "02-architecture.md", "04-roadmap.md"];
|
|
5
|
+
const MAX_DOC_CHARS = 12_000;
|
|
6
|
+
const MAX_PRIOR_CHARS = 80_000;
|
|
7
|
+
export const PRIOR_PLAN_RULES = "Re-emit every pending, in_progress or blocked task you keep, with its id and status unchanged. Pending tasks you leave out are deleted. Done tasks are kept as they are; do not re-emit them.";
|
|
8
|
+
export async function buildPriorPlan(cwd, tasks) {
|
|
9
|
+
const docs = await Promise.all(PLAN_DOCS.map(async (name) => {
|
|
10
|
+
const text = await readTextIfExists(join(cwd, "docs", "plan", name));
|
|
11
|
+
return text ? `### docs/plan/${name}\n\n${truncateText(text.trim(), MAX_DOC_CHARS)}` : "";
|
|
12
|
+
}));
|
|
13
|
+
const open = tasks.filter((task) => task.task?.meta.status !== "done");
|
|
14
|
+
const sections = [
|
|
15
|
+
PRIOR_PLAN_RULES,
|
|
16
|
+
["## Task status", "", ...tasks.map(statusLine)].join("\n"),
|
|
17
|
+
["## Plan documents", "", ...docs.filter(Boolean)].join("\n\n"),
|
|
18
|
+
[
|
|
19
|
+
"## Open task files",
|
|
20
|
+
"",
|
|
21
|
+
...open.map((task) => `### ${task.path}\n\n${task.text.trim()}`),
|
|
22
|
+
].join("\n\n"),
|
|
23
|
+
];
|
|
24
|
+
return truncateText(sections.join("\n\n"), MAX_PRIOR_CHARS);
|
|
25
|
+
}
|
|
26
|
+
function statusLine(task) {
|
|
27
|
+
const meta = task.task?.meta;
|
|
28
|
+
if (!meta)
|
|
29
|
+
return `- ${task.id} [invalid] ${task.path}`;
|
|
30
|
+
const deps = meta.depends_on.length > 0 ? `, depends on ${meta.depends_on.join(", ")}` : "";
|
|
31
|
+
return `- ${meta.id} [${meta.status}] ${meta.title} (phase ${meta.phase}${deps})`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readInterview, writeInterview } from "../config/store.js";
|
|
2
|
+
import { t } from "../i18n/index.js";
|
|
3
|
+
import { askQuestion } from "../interview/adaptive.js";
|
|
4
|
+
export async function closeQuestions(ctx, questions) {
|
|
5
|
+
if (questions.length === 0)
|
|
6
|
+
return [];
|
|
7
|
+
const ask = !ctx.flags.yes && !ctx.flags.dryRun;
|
|
8
|
+
const listed = ask ? questions.filter((question) => !question.blocking) : questions;
|
|
9
|
+
if (listed.length > 0)
|
|
10
|
+
ctx.prompter.note(formatQuestions(listed), t("plan.openQuestions"));
|
|
11
|
+
const closed = [];
|
|
12
|
+
for (const question of questions) {
|
|
13
|
+
const answer = ask && question.blocking ? await askQuestion(ctx.prompter, question) : "";
|
|
14
|
+
closed.push({ ...question, answer: answer.trim() });
|
|
15
|
+
}
|
|
16
|
+
await appendQuestions(ctx.cwd, closed);
|
|
17
|
+
ctx.prompter.info(t("plan.questionsSaved", { count: closed.length }));
|
|
18
|
+
return closed;
|
|
19
|
+
}
|
|
20
|
+
export function renderQuestions(questions, date) {
|
|
21
|
+
const sections = questions.map((item) => {
|
|
22
|
+
const lines = [`### ${item.question}`, ""];
|
|
23
|
+
if (item.why)
|
|
24
|
+
lines.push(`- ${t("md.why")}: ${item.why}`);
|
|
25
|
+
if (item.options?.length)
|
|
26
|
+
lines.push(`- ${t("md.options")}: ${item.options.join(" / ")}`);
|
|
27
|
+
if (item.blocking)
|
|
28
|
+
lines.push(`- ${t("md.blocking")}`);
|
|
29
|
+
lines.push(`- ${t("md.answer")}: ${item.answer || `_${t("md.open")}_`}`);
|
|
30
|
+
return lines.join("\n");
|
|
31
|
+
});
|
|
32
|
+
return [`## ${t("md.planQuestions", { date })}`, ...sections].join("\n\n");
|
|
33
|
+
}
|
|
34
|
+
async function appendQuestions(cwd, questions) {
|
|
35
|
+
const current = (await readInterview(cwd))?.trimEnd() ?? `# ${t("md.interview")}`;
|
|
36
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
37
|
+
await writeInterview(cwd, `${current}\n\n${renderQuestions(questions, date)}\n`);
|
|
38
|
+
}
|
|
39
|
+
export function formatQuestions(questions) {
|
|
40
|
+
return questions
|
|
41
|
+
.map((question, index) => {
|
|
42
|
+
const flag = question.blocking ? ` [${t("plan.blocking")}]` : "";
|
|
43
|
+
const options = question.options?.length ? `\n ${question.options.join(" / ")}` : "";
|
|
44
|
+
const why = question.why ? ` — ${question.why}` : "";
|
|
45
|
+
return `${index + 1}.${flag} ${question.question}${why}${options}`;
|
|
46
|
+
})
|
|
47
|
+
.join("\n");
|
|
48
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { rawBlocks } from "./continuation.js";
|
|
2
|
+
import { MARKER } from "./parser.js";
|
|
3
|
+
const LOOSE_MARKER = /^[ \t]*<<<[ \t]*(end[ \t]+)?(summary|questions|config|file)[ \t]*(?::[ \t]*([^>\n]*?))?[ \t]*>>>[ \t]*$/gim;
|
|
4
|
+
export function repairPlan(text, truncated) {
|
|
5
|
+
const spaced = normalizeMarkers(text);
|
|
6
|
+
const closed = truncated === false ? closeLastFile(spaced.text) : { text: spaced.text, repairs: [] };
|
|
7
|
+
return { text: closed.text, repairs: [...spaced.repairs, ...closed.repairs] };
|
|
8
|
+
}
|
|
9
|
+
function normalizeMarkers(text) {
|
|
10
|
+
const strict = new RegExp(MARKER.source, "m");
|
|
11
|
+
let count = 0;
|
|
12
|
+
const fixed = text.replace(LOOSE_MARKER, (line, end, kind, path) => {
|
|
13
|
+
if (strict.test(line))
|
|
14
|
+
return line;
|
|
15
|
+
count++;
|
|
16
|
+
const name = kind.toUpperCase();
|
|
17
|
+
if (end)
|
|
18
|
+
return `<<<END ${name}>>>`;
|
|
19
|
+
return name === "FILE" ? `<<<FILE: ${String(path ?? "").trim()}>>>` : `<<<${name}>>>`;
|
|
20
|
+
});
|
|
21
|
+
const repairs = count > 0 ? [`normalized ${count} marker(s) with extra spaces or lowercase`] : [];
|
|
22
|
+
return { text: fixed, repairs };
|
|
23
|
+
}
|
|
24
|
+
function closeLastFile(text) {
|
|
25
|
+
const last = rawBlocks(text).at(-1);
|
|
26
|
+
if (!last || last.closed || !last.key.startsWith("FILE:"))
|
|
27
|
+
return { text, repairs: [] };
|
|
28
|
+
const path = last.key.slice("FILE:".length).trim();
|
|
29
|
+
return {
|
|
30
|
+
text: `${text.trimEnd()}\n<<<END FILE>>>\n`,
|
|
31
|
+
repairs: [`closed the last FILE block (${path}) that was missing <<<END FILE>>>`],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { writeText } from "../core/fs.js";
|
|
3
|
+
import { baePaths } from "../core/paths.js";
|
|
4
|
+
export const PLAN_REPORT = "plan-report.json";
|
|
5
|
+
export function newPlanStats(backend) {
|
|
6
|
+
return {
|
|
7
|
+
backend,
|
|
8
|
+
models: [],
|
|
9
|
+
continuations: 0,
|
|
10
|
+
formatRetries: 0,
|
|
11
|
+
formatErrors: [],
|
|
12
|
+
evidenceRetries: 0,
|
|
13
|
+
unverifiedPaths: [],
|
|
14
|
+
repairs: [],
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function recordInfo(stats, info) {
|
|
18
|
+
if (info.model && !stats.models.includes(info.model))
|
|
19
|
+
stats.models.push(info.model);
|
|
20
|
+
if (info.costUsd !== undefined)
|
|
21
|
+
stats.costUsd = (stats.costUsd ?? 0) + info.costUsd;
|
|
22
|
+
}
|
|
23
|
+
export async function writePlanReport(cwd, stats, parsed, error) {
|
|
24
|
+
const report = {
|
|
25
|
+
...stats,
|
|
26
|
+
ok: parsed !== undefined,
|
|
27
|
+
files: parsed?.files.length ?? 0,
|
|
28
|
+
tasks: parsed?.tasks.length ?? 0,
|
|
29
|
+
questions: parsed?.questions.length ?? 0,
|
|
30
|
+
blockingQuestions: parsed?.questions.filter((question) => question.blocking).length ?? 0,
|
|
31
|
+
warnings: parsed?.warnings ?? [],
|
|
32
|
+
...(error === undefined
|
|
33
|
+
? {}
|
|
34
|
+
: { error: error instanceof Error ? error.message : String(error) }),
|
|
35
|
+
};
|
|
36
|
+
await writeText(join(baePaths(cwd).tmp, PLAN_REPORT), `${JSON.stringify(report, null, 2)}\n`);
|
|
37
|
+
}
|
package/dist/plan/run.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { join, relative } from "node:path";
|
|
2
|
+
import { buildAnalystPrompt } from "../analyst/prompt.js";
|
|
3
|
+
import { runWithFormatRetry } from "../analyst/retry.js";
|
|
4
|
+
import { isAgentBackend } from "../commands/shared.js";
|
|
5
|
+
import { readInterview } from "../config/store.js";
|
|
6
|
+
import { FormatError, UserError } from "../core/errors.js";
|
|
7
|
+
import { writeText } from "../core/fs.js";
|
|
8
|
+
import { baePaths } from "../core/paths.js";
|
|
9
|
+
import { loadPrompt, renderPrompt } from "../core/prompt-loader.js";
|
|
10
|
+
import { truncateText } from "../digest/format.js";
|
|
11
|
+
import { buildDigest } from "../digest/index.js";
|
|
12
|
+
import { scanFiles } from "../digest/walk.js";
|
|
13
|
+
import { t } from "../i18n/index.js";
|
|
14
|
+
import { findTruncation, mergeContinuation } from "./continuation.js";
|
|
15
|
+
import { describeUnverified, findUnverified } from "./evidence.js";
|
|
16
|
+
import { PLAN_FORMAT, parseFileBlocks, parsePlan, renderPlan } from "./parser.js";
|
|
17
|
+
import { repairPlan } from "./repair.js";
|
|
18
|
+
import { newPlanStats, recordInfo, writePlanReport } from "./report.js";
|
|
19
|
+
const MAX_CONTINUATIONS = 3;
|
|
20
|
+
const REJECTED_PLAN = "rejected-plan.md";
|
|
21
|
+
const MAX_REPO_FILES_CHARS = 20_000;
|
|
22
|
+
export async function generatePlan(ctx, config, request) {
|
|
23
|
+
const interview = (await readInterview(ctx.cwd)) ?? "";
|
|
24
|
+
if (!interview.trim())
|
|
25
|
+
ctx.prompter.warn(t("plan.noInterview"));
|
|
26
|
+
const digest = await ctx.prompter.spinner(t("digest.reading"), async () => {
|
|
27
|
+
return (await buildDigest(ctx.cwd, { maxChars: config.digest.maxChars })).text;
|
|
28
|
+
});
|
|
29
|
+
const prompt = await buildAnalystPrompt(ctx.cwd, {
|
|
30
|
+
mode: "PLAN",
|
|
31
|
+
projectType: config.mode,
|
|
32
|
+
lang: config.lang,
|
|
33
|
+
targets: config.targets,
|
|
34
|
+
interview,
|
|
35
|
+
digest,
|
|
36
|
+
canExplore: isAgentBackend(config.backend),
|
|
37
|
+
priorPlan: request.priorPlan,
|
|
38
|
+
});
|
|
39
|
+
if (ctx.flags.dryRun) {
|
|
40
|
+
ctx.print(`${prompt}\n`);
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const stats = newPlanStats(config.backend);
|
|
44
|
+
const backend = ctx.createBackend(config.backend);
|
|
45
|
+
const requireReviewer = config.targets.some((target) => target === "claude-code" || target === "opencode");
|
|
46
|
+
const parse = (text) => parsePlan(text, { knownTaskIds: request.knownTaskIds, requireReviewer });
|
|
47
|
+
try {
|
|
48
|
+
const parsed = await requestPlan(ctx, backend, parse, prompt, stats);
|
|
49
|
+
const checked = await checkEvidence(ctx, backend, parsed, parse, stats, config.mode);
|
|
50
|
+
await writePlanReport(ctx.cwd, stats, checked);
|
|
51
|
+
return checked;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
await writePlanReport(ctx.cwd, stats, undefined, error);
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function requestPlan(ctx, backend, parse, prompt, stats) {
|
|
59
|
+
return ctx.prompter.spinner(t("plan.analyzing"), (update) => {
|
|
60
|
+
const ending = {};
|
|
61
|
+
const options = {
|
|
62
|
+
cwd: ctx.cwd,
|
|
63
|
+
access: "read",
|
|
64
|
+
stream: progress(update),
|
|
65
|
+
onInfo: (info) => {
|
|
66
|
+
recordInfo(stats, info);
|
|
67
|
+
if (info.truncated !== undefined)
|
|
68
|
+
ending.truncated = info.truncated;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
const repair = (text) => repairAnswer(ctx, text, ending, stats);
|
|
72
|
+
return runWithFormatRetry({
|
|
73
|
+
backend,
|
|
74
|
+
prompt,
|
|
75
|
+
options,
|
|
76
|
+
parse,
|
|
77
|
+
format: PLAN_FORMAT,
|
|
78
|
+
onRetry: (error) => {
|
|
79
|
+
stats.formatRetries++;
|
|
80
|
+
stats.formatErrors.push(error.message);
|
|
81
|
+
ctx.prompter.warn(t("format.retrying"));
|
|
82
|
+
},
|
|
83
|
+
complete: (text) => continueTruncated(ctx, backend, { prompt, text, options, stats, repair }),
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function repairAnswer(ctx, text, ending, stats) {
|
|
88
|
+
const repaired = repairPlan(text, ending.truncated);
|
|
89
|
+
if (repaired.repairs.length > 0) {
|
|
90
|
+
stats.repairs.push(...repaired.repairs);
|
|
91
|
+
ctx.prompter.info(t("format.repaired", { repairs: repaired.repairs.join("; ") }));
|
|
92
|
+
}
|
|
93
|
+
return repaired.text;
|
|
94
|
+
}
|
|
95
|
+
async function continueTruncated(ctx, backend, answer) {
|
|
96
|
+
const { prompt, options, stats, repair } = answer;
|
|
97
|
+
let current = repair(answer.text);
|
|
98
|
+
for (let round = 0; round < MAX_CONTINUATIONS; round++) {
|
|
99
|
+
const cut = findTruncation(current);
|
|
100
|
+
if (!cut)
|
|
101
|
+
return current;
|
|
102
|
+
stats.continuations++;
|
|
103
|
+
ctx.prompter.warn(t("plan.continuing", { marker: cut.marker }));
|
|
104
|
+
const request = renderPrompt(await loadPrompt("continue", ctx.cwd), {
|
|
105
|
+
prompt,
|
|
106
|
+
partial: cut.complete,
|
|
107
|
+
next_marker: cut.marker,
|
|
108
|
+
});
|
|
109
|
+
const more = await backend.run(request, options);
|
|
110
|
+
current = repair(mergeContinuation(cut.complete, more));
|
|
111
|
+
}
|
|
112
|
+
return current;
|
|
113
|
+
}
|
|
114
|
+
async function checkEvidence(ctx, backend, parsed, parse, stats, mode) {
|
|
115
|
+
const missing = await findUnverified(ctx.cwd, parsed);
|
|
116
|
+
if (missing.length === 0)
|
|
117
|
+
return parsed;
|
|
118
|
+
stats.evidenceRetries++;
|
|
119
|
+
ctx.prompter.warn(t("evidence.retrying", { count: missing.length }));
|
|
120
|
+
const fixed = await ctx.prompter.spinner(t("evidence.fixing"), () => fixPaths(ctx, backend, parsed, missing, parse, stats));
|
|
121
|
+
const plan = fixed ?? parsed;
|
|
122
|
+
const remaining = fixed ? await findUnverified(ctx.cwd, fixed) : missing;
|
|
123
|
+
stats.unverifiedPaths = remaining.map(describeUnverified);
|
|
124
|
+
if (remaining.length === 0) {
|
|
125
|
+
ctx.prompter.success(t("evidence.fixed"));
|
|
126
|
+
return plan;
|
|
127
|
+
}
|
|
128
|
+
const lines = remaining.filter((item) => item.line !== undefined);
|
|
129
|
+
if (mode === "brownfield" && lines.length > 0) {
|
|
130
|
+
const path = join(baePaths(ctx.cwd).tmp, REJECTED_PLAN);
|
|
131
|
+
await writeText(path, renderPlan(plan));
|
|
132
|
+
throw new UserError(t("evidence.linesFailed", {
|
|
133
|
+
list: lines.map((item) => `- ${describeUnverified(item)}`).join("\n"),
|
|
134
|
+
path: relative(ctx.cwd, path).split("\\").join("/"),
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
ctx.prompter.warn(t("evidence.unverified", { count: remaining.length }));
|
|
138
|
+
const list = stats.unverifiedPaths.map((line) => `- ${line}`).join("\n");
|
|
139
|
+
return { ...plan, summary: `${plan.summary}\n\n${t("evidence.summary")}\n${list}` };
|
|
140
|
+
}
|
|
141
|
+
async function fixPaths(ctx, backend, parsed, missing, parse, stats) {
|
|
142
|
+
const sources = new Set(missing.map((item) => item.source));
|
|
143
|
+
const affected = parsed.files.filter((file) => sources.has(file.path));
|
|
144
|
+
const prompt = renderPrompt(await loadPrompt("fix-paths", ctx.cwd), {
|
|
145
|
+
paths: missing.map((item) => `- ${describeUnverified(item)}`).join("\n"),
|
|
146
|
+
files: affected
|
|
147
|
+
.map((file) => `<<<FILE: ${file.path}>>>\n${file.content.trimEnd()}\n<<<END FILE>>>`)
|
|
148
|
+
.join("\n\n"),
|
|
149
|
+
repo_files: await repoFiles(ctx.cwd),
|
|
150
|
+
});
|
|
151
|
+
let reply;
|
|
152
|
+
try {
|
|
153
|
+
reply = await backend.run(prompt, {
|
|
154
|
+
cwd: ctx.cwd,
|
|
155
|
+
access: "read",
|
|
156
|
+
onInfo: (info) => recordInfo(stats, info),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (!(error instanceof UserError))
|
|
161
|
+
throw error;
|
|
162
|
+
ctx.prompter.warn(t("evidence.retryFailed", { details: error.message }));
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
const replaced = new Map(parseFileBlocks(repairPlan(reply, undefined).text)
|
|
166
|
+
.filter((file) => sources.has(file.path))
|
|
167
|
+
.map((file) => [file.path, file]));
|
|
168
|
+
if (replaced.size === 0)
|
|
169
|
+
return undefined;
|
|
170
|
+
const files = parsed.files.map((file) => replaced.get(file.path) ?? file);
|
|
171
|
+
try {
|
|
172
|
+
return { ...parse(renderPlan({ ...parsed, files })), warnings: parsed.warnings };
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (error instanceof FormatError)
|
|
176
|
+
return undefined;
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function repoFiles(cwd) {
|
|
181
|
+
const { files } = await scanFiles(cwd);
|
|
182
|
+
const list = files.map((file) => file.path).join("\n");
|
|
183
|
+
return truncateText(list || "(no files)", MAX_REPO_FILES_CHARS);
|
|
184
|
+
}
|
|
185
|
+
function progress(update) {
|
|
186
|
+
let received = 0;
|
|
187
|
+
return (chunk) => {
|
|
188
|
+
received += chunk.length;
|
|
189
|
+
update(t("plan.progress", { chars: received.toLocaleString() }));
|
|
190
|
+
};
|
|
191
|
+
}
|