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,179 @@
|
|
|
1
|
+
import { saveCommands } from "../commands/shared.js";
|
|
2
|
+
import { bashPath } from "../core/bash.js";
|
|
3
|
+
import { ExitCode } from "../core/errors.js";
|
|
4
|
+
import { gitPaths, headCommit, isGitRepo } from "../core/git.js";
|
|
5
|
+
import { ensureGitignore } from "../core/gitignore.js";
|
|
6
|
+
import { isTestFile } from "../digest/baseline.js";
|
|
7
|
+
import { clearActive, prepareCapture, readActive, readCapture, saveCapture, } from "../gates/capture.js";
|
|
8
|
+
import { checkContract, collectProtected, restoreContract } from "../gates/contract.js";
|
|
9
|
+
import { enforceContract } from "../gates/enforce.js";
|
|
10
|
+
import { refusal } from "../gates/gate.js";
|
|
11
|
+
import { captureIgnore } from "../gates/ignore-rules.js";
|
|
12
|
+
import { baselineFrom, runSuite, suiteCommands, unusableBaseline, } from "../gates/regression.js";
|
|
13
|
+
import { readSources, testRunners } from "../gates/runners.js";
|
|
14
|
+
import { guardState } from "../gates/state-guard.js";
|
|
15
|
+
import { t } from "../i18n/index.js";
|
|
16
|
+
import { capturedTask } from "../review/run.js";
|
|
17
|
+
import { scopePaths } from "../review/scope.js";
|
|
18
|
+
import { recordAttempt } from "../tasks/attempts.js";
|
|
19
|
+
import { verificationCommands, verificationScript } from "../tasks/schema.js";
|
|
20
|
+
import { setTaskStatus } from "../tasks/status.js";
|
|
21
|
+
export function checksFor(task, config) {
|
|
22
|
+
return {
|
|
23
|
+
commands: verificationCommands(task.body),
|
|
24
|
+
script: verificationScript(task.body),
|
|
25
|
+
suite: suiteCommands(config),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export async function recoverInterrupted(ctx, acceptance) {
|
|
29
|
+
const id = await readActive(ctx.cwd);
|
|
30
|
+
if (!id)
|
|
31
|
+
return;
|
|
32
|
+
const capture = await readCapture(ctx.cwd, id);
|
|
33
|
+
if (!capture) {
|
|
34
|
+
await clearActive(ctx.cwd);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const contract = await enforceContract(ctx, capture, acceptance);
|
|
38
|
+
await setTaskStatus(ctx.cwd, capturedTask(capture), "in_progress");
|
|
39
|
+
await clearActive(ctx.cwd);
|
|
40
|
+
if (contract.blocked)
|
|
41
|
+
ctx.prompter.warn(t("contract.recovered", { id }));
|
|
42
|
+
}
|
|
43
|
+
export async function withSuiteCommands(ctx, config) {
|
|
44
|
+
if (config.gates.regression === "off" || config.commands.test || config.commands.lint) {
|
|
45
|
+
return config;
|
|
46
|
+
}
|
|
47
|
+
const commands = await saveCommands(ctx);
|
|
48
|
+
if (!commands.test && !commands.lint)
|
|
49
|
+
ctx.prompter.info(t("regression.noCommands"));
|
|
50
|
+
return { ...config, commands };
|
|
51
|
+
}
|
|
52
|
+
export async function start(ctx, config, task, options) {
|
|
53
|
+
const bash = await bashPath();
|
|
54
|
+
const refused = refusal(checksFor(task, config), {
|
|
55
|
+
unattended: options.unattended,
|
|
56
|
+
allow: config.verify.allow,
|
|
57
|
+
...(bash ? { bash } : {}),
|
|
58
|
+
});
|
|
59
|
+
if (refused) {
|
|
60
|
+
await blockBeforeAgent(ctx, task, refused);
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
await ensureGitignore(ctx.cwd);
|
|
64
|
+
const previous = await readCapture(ctx.cwd, task.meta.id);
|
|
65
|
+
const reuse = previous && !previous.finished ? previous : undefined;
|
|
66
|
+
const skips = [...(reuse?.skips ?? [])];
|
|
67
|
+
const allow = (message) => allowOrStop(ctx, options, skips, message);
|
|
68
|
+
if (!reuse && task.meta.status === "in_progress")
|
|
69
|
+
allow(t("capture.lateStop", { id: task.meta.id }));
|
|
70
|
+
if (!reuse && !(await isGitRepo(ctx.cwd)))
|
|
71
|
+
allow(t("capture.noGit"));
|
|
72
|
+
if (reuse?.base && reuse.base !== (await headCommit(ctx.cwd))) {
|
|
73
|
+
ctx.prompter.warn(t("capture.headMoved", { id: task.meta.id }));
|
|
74
|
+
}
|
|
75
|
+
const excluded = new Set(reuse?.baseline?.excluded ?? []);
|
|
76
|
+
const fresh = !(await hasTestFiles(ctx.cwd));
|
|
77
|
+
const unrecognized = fresh
|
|
78
|
+
? []
|
|
79
|
+
: (await unrecognizedTests(ctx.cwd, config)).filter((command) => !excluded.has(command));
|
|
80
|
+
for (const command of unrecognized)
|
|
81
|
+
allow(t("regression.unknownRunner", { command }));
|
|
82
|
+
let baseline = reuse?.baseline;
|
|
83
|
+
if (config.gates.regression === "full" && baseline === undefined) {
|
|
84
|
+
if (reuse)
|
|
85
|
+
allow(t("regression.lateStop", { id: task.meta.id }));
|
|
86
|
+
baseline = await recordBaseline(ctx, config, task, { ...options, unrecognized, fresh }, skips);
|
|
87
|
+
}
|
|
88
|
+
const started = await setTaskStatus(ctx.cwd, task, "in_progress");
|
|
89
|
+
const capture = await prepareCapture(ctx.cwd, config, started);
|
|
90
|
+
const late = !reuse && task.meta.status === "in_progress";
|
|
91
|
+
Object.assign(capture, { skips, ...(baseline ? { baseline } : {}), ...(late ? { late } : {}) });
|
|
92
|
+
await saveCapture(ctx.cwd, capture);
|
|
93
|
+
return capture;
|
|
94
|
+
}
|
|
95
|
+
async function unrecognizedTests(cwd, config) {
|
|
96
|
+
const sources = await readSources(cwd);
|
|
97
|
+
return suiteCommands(config)
|
|
98
|
+
.filter((item) => item.key === "test" && testRunners(item.command, sources).length === 0)
|
|
99
|
+
.map((item) => item.command);
|
|
100
|
+
}
|
|
101
|
+
async function recordBaseline(ctx, config, task, options, skips) {
|
|
102
|
+
const suite = suiteCommands(config).filter((item) => !options.unrecognized.includes(item.command));
|
|
103
|
+
if (suite.length === 0)
|
|
104
|
+
return baselineFrom([], options.unrecognized);
|
|
105
|
+
ctx.prompter.note(suite.map((item) => `$ ${item.command}`).join("\n"), t("regression.baselineTitle"));
|
|
106
|
+
if (!ctx.flags.yes && !(await ctx.prompter.confirm(t("regression.confirm"), true))) {
|
|
107
|
+
allowOrStop(ctx, options, skips, t("regression.declinedStop"));
|
|
108
|
+
return { skipped: true, commands: {}, excluded: [] };
|
|
109
|
+
}
|
|
110
|
+
const guard = await guardState(ctx.cwd);
|
|
111
|
+
const before = await collectProtected(ctx.cwd, {
|
|
112
|
+
suite: suite.map((item) => item.command),
|
|
113
|
+
verification: verificationCommands(task.body),
|
|
114
|
+
own: scopePaths(task),
|
|
115
|
+
});
|
|
116
|
+
const results = await runSuite(ctx.cwd, suite, ctx.print, config.gates.timeoutMinutes * 60_000);
|
|
117
|
+
const tampered = await guard.verify();
|
|
118
|
+
const changed = await checkContract(ctx.cwd, {
|
|
119
|
+
...before,
|
|
120
|
+
ignore: await captureIgnore(ctx.cwd),
|
|
121
|
+
taskPath: task.path,
|
|
122
|
+
});
|
|
123
|
+
if (tampered.length > 0 || changed.length > 0) {
|
|
124
|
+
await restoreContract(ctx.cwd, changed);
|
|
125
|
+
const files = [...tampered, ...changed.map((change) => change.path)].join(", ");
|
|
126
|
+
ctx.prompter.warn(t("regression.baselineTampered", { files }));
|
|
127
|
+
ctx.prompter.outro(t("skip.stopped"));
|
|
128
|
+
throw new ExitCode(1);
|
|
129
|
+
}
|
|
130
|
+
for (const result of results.filter((item) => item.exitCode !== 0)) {
|
|
131
|
+
ctx.prompter.warn(t("regression.preexisting", { command: result.command, code: result.exitCode }));
|
|
132
|
+
}
|
|
133
|
+
const found = unusableBaseline(results, task.meta.tests === "fix");
|
|
134
|
+
const absent = options.fresh
|
|
135
|
+
? found.filter((result) => result.key === "test" && result.exitCode !== -1)
|
|
136
|
+
: [];
|
|
137
|
+
const unusable = found.filter((result) => !absent.includes(result));
|
|
138
|
+
for (const result of absent) {
|
|
139
|
+
ctx.prompter.info(t("regression.noTestsYet", { command: result.command }));
|
|
140
|
+
}
|
|
141
|
+
for (const result of unusable)
|
|
142
|
+
allowOrStop(ctx, options, skips, unusableMessage(result));
|
|
143
|
+
return baselineFrom(results, [...options.unrecognized, ...unusable.map((result) => result.command)], absent.map((result) => result.command));
|
|
144
|
+
}
|
|
145
|
+
async function hasTestFiles(cwd) {
|
|
146
|
+
const files = (await gitPaths(cwd, ["ls-files", "--cached", "--others", "--exclude-standard"])) ?? [];
|
|
147
|
+
return files.some((path) => isTestFile(path));
|
|
148
|
+
}
|
|
149
|
+
function unusableMessage(result) {
|
|
150
|
+
const vars = { command: result.command, code: result.exitCode };
|
|
151
|
+
if (result.unrecognized)
|
|
152
|
+
return t("regression.unknownRunner", vars);
|
|
153
|
+
if (result.key === "test" && result.exitCode === 0)
|
|
154
|
+
return t("regression.noCounts", vars);
|
|
155
|
+
return t("regression.unusable", vars);
|
|
156
|
+
}
|
|
157
|
+
function allowOrStop(ctx, options, skips, message) {
|
|
158
|
+
if (!options.allowSkip) {
|
|
159
|
+
ctx.prompter.warn(message);
|
|
160
|
+
ctx.prompter.outro(t("skip.stopped"));
|
|
161
|
+
throw new ExitCode(1);
|
|
162
|
+
}
|
|
163
|
+
skips.push(message);
|
|
164
|
+
ctx.prompter.warn(t("skip.used", { what: message }));
|
|
165
|
+
}
|
|
166
|
+
async function blockBeforeAgent(ctx, task, reason) {
|
|
167
|
+
ctx.prompter.warn(reason);
|
|
168
|
+
await setTaskStatus(ctx.cwd, task, "blocked");
|
|
169
|
+
await recordAttempt(ctx.cwd, task.meta.id, {
|
|
170
|
+
startedAt: new Date().toISOString(),
|
|
171
|
+
durationMs: 0,
|
|
172
|
+
headless: false,
|
|
173
|
+
outcome: "blocked",
|
|
174
|
+
stage: "refused",
|
|
175
|
+
regressions: [],
|
|
176
|
+
reason,
|
|
177
|
+
});
|
|
178
|
+
ctx.prompter.outro(t("next.refusedBlocked", { id: task.meta.id }));
|
|
179
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { MARKER } from "./parser.js";
|
|
2
|
+
export function rawBlocks(text) {
|
|
3
|
+
const blocks = [];
|
|
4
|
+
let open;
|
|
5
|
+
for (const match of text.matchAll(MARKER)) {
|
|
6
|
+
const marker = match[1] ?? "";
|
|
7
|
+
const index = match.index ?? 0;
|
|
8
|
+
if (marker.startsWith("END ")) {
|
|
9
|
+
if (open && marker.slice(4) === kindOf(open.marker)) {
|
|
10
|
+
const end = index + match[0].length;
|
|
11
|
+
blocks.push({ key: open.marker, raw: text.slice(open.start, end).trim(), closed: true });
|
|
12
|
+
}
|
|
13
|
+
open = undefined;
|
|
14
|
+
}
|
|
15
|
+
else if (!open) {
|
|
16
|
+
open = { marker, start: index };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (open)
|
|
20
|
+
blocks.push({ key: open.marker, raw: text.slice(open.start).trim(), closed: false });
|
|
21
|
+
return blocks;
|
|
22
|
+
}
|
|
23
|
+
export function findTruncation(text) {
|
|
24
|
+
const last = rawBlocks(text).at(-1);
|
|
25
|
+
if (!last || last.closed)
|
|
26
|
+
return undefined;
|
|
27
|
+
const complete = rawBlocks(text)
|
|
28
|
+
.filter((block) => block.closed)
|
|
29
|
+
.map((block) => block.raw)
|
|
30
|
+
.join("\n\n");
|
|
31
|
+
return { complete, marker: `<<<${last.key}>>>` };
|
|
32
|
+
}
|
|
33
|
+
export function mergeContinuation(complete, continuation) {
|
|
34
|
+
const seen = new Set(rawBlocks(complete).map((block) => block.key));
|
|
35
|
+
const extra = rawBlocks(continuation).filter((block) => !seen.has(block.key));
|
|
36
|
+
return [complete, ...extra.map((block) => block.raw)].filter(Boolean).join("\n\n");
|
|
37
|
+
}
|
|
38
|
+
function kindOf(marker) {
|
|
39
|
+
return marker.startsWith("FILE:") ? "FILE" : marker;
|
|
40
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readTextIfExists } from "../core/fs.js";
|
|
4
|
+
import { baseName, extensionOf, languageOf } from "../digest/files.js";
|
|
5
|
+
import { scanFiles } from "../digest/walk.js";
|
|
6
|
+
import { sectionText } from "../tasks/schema.js";
|
|
7
|
+
const CODE_SPAN = /`([^`\n]+)`([ \t]*\((?:new|nuevo|nueva)\))?/gi;
|
|
8
|
+
const NEW_INSIDE = /^(.+?)\s+\((?:new|nuevo|nueva)\)$/i;
|
|
9
|
+
const PATH_TOKEN = /^(?:\.\/)?([\w@.-]+(?:\/[\w@.[\]()-]+)*)\/?(?::(\d+)(?::\d+)?(?:-(\d+))?|#L(\d+)(?:-L?(\d+))?)?$/;
|
|
10
|
+
const DOMAIN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i;
|
|
11
|
+
const EVIDENCE_FILES = [
|
|
12
|
+
/^docs\/plan\/02-architecture\.md$/,
|
|
13
|
+
/^docs\/plan\/03-decisions\/[^/]+\.md$/,
|
|
14
|
+
];
|
|
15
|
+
const EXTENSIONS = new Set("md mdx json jsonc yaml yml toml ini cfg conf env txt lock xml gradle mod sum csv properties example sample template tmpl mk"
|
|
16
|
+
.split(" ")
|
|
17
|
+
.map((extension) => `.${extension}`));
|
|
18
|
+
const GENERATED_ROOTS = new Set([
|
|
19
|
+
"dist",
|
|
20
|
+
"build",
|
|
21
|
+
"out",
|
|
22
|
+
"target",
|
|
23
|
+
"coverage",
|
|
24
|
+
"node_modules",
|
|
25
|
+
"vendor",
|
|
26
|
+
".venv",
|
|
27
|
+
"venv",
|
|
28
|
+
"__pycache__",
|
|
29
|
+
".next",
|
|
30
|
+
".nuxt",
|
|
31
|
+
]);
|
|
32
|
+
const KNOWN_FILES = new Set([
|
|
33
|
+
"Dockerfile",
|
|
34
|
+
"Makefile",
|
|
35
|
+
"Procfile",
|
|
36
|
+
"Jenkinsfile",
|
|
37
|
+
"Gemfile",
|
|
38
|
+
"Rakefile",
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"README.md",
|
|
41
|
+
"CHANGELOG.md",
|
|
42
|
+
"package.json",
|
|
43
|
+
"tsconfig.json",
|
|
44
|
+
"pyproject.toml",
|
|
45
|
+
"requirements.txt",
|
|
46
|
+
"setup.py",
|
|
47
|
+
"go.mod",
|
|
48
|
+
"go.sum",
|
|
49
|
+
"Cargo.toml",
|
|
50
|
+
"composer.json",
|
|
51
|
+
"pom.xml",
|
|
52
|
+
"build.gradle",
|
|
53
|
+
".env.example",
|
|
54
|
+
".gitignore",
|
|
55
|
+
".gitattributes",
|
|
56
|
+
".editorconfig",
|
|
57
|
+
".dockerignore",
|
|
58
|
+
".nvmrc",
|
|
59
|
+
".npmrc",
|
|
60
|
+
".python-version",
|
|
61
|
+
".tool-versions",
|
|
62
|
+
]);
|
|
63
|
+
const TS_SOURCES = {
|
|
64
|
+
".js": [".ts", ".tsx"],
|
|
65
|
+
".jsx": [".tsx"],
|
|
66
|
+
".mjs": [".mts"],
|
|
67
|
+
".cjs": [".cts"],
|
|
68
|
+
};
|
|
69
|
+
const MAX_BARE_CANDIDATES = 20;
|
|
70
|
+
export async function findUnverified(cwd, plan) {
|
|
71
|
+
return (await checkCitations(cwd, plan)).unverified;
|
|
72
|
+
}
|
|
73
|
+
export async function checkCitations(cwd, plan) {
|
|
74
|
+
const repo = await indexRepo(cwd);
|
|
75
|
+
const planned = plannedPaths(plan.files);
|
|
76
|
+
const written = new Map([
|
|
77
|
+
...plan.files.map((file) => [file.path, file.content]),
|
|
78
|
+
...plan.tasks.map((task) => [task.path, task.text]),
|
|
79
|
+
]);
|
|
80
|
+
const roots = new Set([...repo.roots, ...[...planned].map((path) => path.split("/")[0] ?? "")]);
|
|
81
|
+
const unverified = [];
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
for (const { source, text } of evidenceSources(plan)) {
|
|
84
|
+
for (const citation of extractCitations(text, source, roots)) {
|
|
85
|
+
const key = `${source}|${citation.path}|${citation.line ?? ""}|${citation.endLine ?? ""}`;
|
|
86
|
+
if (seen.has(key))
|
|
87
|
+
continue;
|
|
88
|
+
seen.add(key);
|
|
89
|
+
const problem = await verify(cwd, citation, repo, planned, written);
|
|
90
|
+
if (problem)
|
|
91
|
+
unverified.push(problem);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { checked: seen.size, unverified };
|
|
95
|
+
}
|
|
96
|
+
export function evidenceSources(plan) {
|
|
97
|
+
return [
|
|
98
|
+
...plan.files
|
|
99
|
+
.filter((file) => EVIDENCE_FILES.some((pattern) => pattern.test(file.path)))
|
|
100
|
+
.map((file) => ({ source: file.path, text: file.content })),
|
|
101
|
+
...plan.tasks.map((task) => ({
|
|
102
|
+
source: task.path,
|
|
103
|
+
text: sectionText(task.body, "context") ?? "",
|
|
104
|
+
})),
|
|
105
|
+
];
|
|
106
|
+
}
|
|
107
|
+
export function extractCitations(text, source, roots) {
|
|
108
|
+
const citations = [];
|
|
109
|
+
for (const match of text.matchAll(CODE_SPAN)) {
|
|
110
|
+
const span = (match[1] ?? "").trim();
|
|
111
|
+
if (match[2] || NEW_INSIDE.test(span))
|
|
112
|
+
continue;
|
|
113
|
+
const citation = toCitation(span, roots);
|
|
114
|
+
if (citation)
|
|
115
|
+
citations.push({ source, ...citation });
|
|
116
|
+
}
|
|
117
|
+
return citations;
|
|
118
|
+
}
|
|
119
|
+
export function plannedPaths(files) {
|
|
120
|
+
const planned = new Set(files.map((file) => file.path));
|
|
121
|
+
for (const file of files) {
|
|
122
|
+
for (const match of file.content.matchAll(CODE_SPAN)) {
|
|
123
|
+
const span = (match[1] ?? "").trim();
|
|
124
|
+
const inside = NEW_INSIDE.exec(span)?.[1];
|
|
125
|
+
const path = normalize(match[2] ? span : inside);
|
|
126
|
+
if (path)
|
|
127
|
+
planned.add(path);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return planned;
|
|
131
|
+
}
|
|
132
|
+
export function describeUnverified(item) {
|
|
133
|
+
const detail = item.lines === undefined ? "" : ` (the file has ${item.lines} lines)`;
|
|
134
|
+
return `\`${item.text}\` in ${item.source}${detail}`;
|
|
135
|
+
}
|
|
136
|
+
function toCitation(span, roots) {
|
|
137
|
+
if (/[\s*$<>{}|]|:\/\//.test(span))
|
|
138
|
+
return undefined;
|
|
139
|
+
const match = PATH_TOKEN.exec(span);
|
|
140
|
+
const path = match?.[1];
|
|
141
|
+
if (!match || !path)
|
|
142
|
+
return undefined;
|
|
143
|
+
const segments = path.split("/");
|
|
144
|
+
const first = segments[0] ?? "";
|
|
145
|
+
const skipped = segments.some((segment) => segment === "." || segment === "..") ||
|
|
146
|
+
GENERATED_ROOTS.has(first) ||
|
|
147
|
+
(segments.length > 1 && DOMAIN.test(first));
|
|
148
|
+
if (skipped)
|
|
149
|
+
return undefined;
|
|
150
|
+
const start = match[2] ?? match[4];
|
|
151
|
+
const end = match[3] ?? match[5];
|
|
152
|
+
const line = start ? Number(start) : undefined;
|
|
153
|
+
const endLine = end ? Number(end) : undefined;
|
|
154
|
+
const checked = segments.length === 1
|
|
155
|
+
? KNOWN_FILES.has(path) || (line !== undefined && hasFileExtension(path))
|
|
156
|
+
: hasFileExtension(path) || roots.has(first);
|
|
157
|
+
if (!checked)
|
|
158
|
+
return undefined;
|
|
159
|
+
return {
|
|
160
|
+
text: span,
|
|
161
|
+
path,
|
|
162
|
+
...(line !== undefined ? { line } : {}),
|
|
163
|
+
...(endLine !== undefined ? { endLine } : {}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function hasFileExtension(path) {
|
|
167
|
+
return languageOf(path) !== undefined || EXTENSIONS.has(extensionOf(path));
|
|
168
|
+
}
|
|
169
|
+
function normalize(span) {
|
|
170
|
+
const path = span
|
|
171
|
+
?.replace(/^\.\//, "")
|
|
172
|
+
.replace(/\/$/, "")
|
|
173
|
+
.replace(/:\d+(-\d+)?$/, "");
|
|
174
|
+
return path && !/\s/.test(path) ? path : undefined;
|
|
175
|
+
}
|
|
176
|
+
async function verify(cwd, citation, repo, planned, written) {
|
|
177
|
+
const bare = !citation.path.includes("/");
|
|
178
|
+
const own = written.get(citation.path);
|
|
179
|
+
if (citation.line !== undefined && own !== undefined) {
|
|
180
|
+
const lines = own.split(/\r?\n/).length - (own.endsWith("\n") ? 1 : 0);
|
|
181
|
+
const last = citation.endLine ?? citation.line;
|
|
182
|
+
return citation.line >= 1 && last >= citation.line && last <= lines
|
|
183
|
+
? undefined
|
|
184
|
+
: { ...citation, lines };
|
|
185
|
+
}
|
|
186
|
+
if (citation.line !== undefined)
|
|
187
|
+
return verifyLines(cwd, citation, repo, bare);
|
|
188
|
+
const path = await existing(cwd, citation.path);
|
|
189
|
+
if (path || (bare && repo.names.has(citation.path)))
|
|
190
|
+
return undefined;
|
|
191
|
+
const isPlanned = planned.has(citation.path) ||
|
|
192
|
+
[...planned].some((item) => item.startsWith(`${citation.path}/`) || (bare && baseName(item) === citation.path));
|
|
193
|
+
return isPlanned ? undefined : citation;
|
|
194
|
+
}
|
|
195
|
+
async function verifyLines(cwd, citation, repo, bare) {
|
|
196
|
+
const line = citation.line ?? 0;
|
|
197
|
+
const last = citation.endLine ?? line;
|
|
198
|
+
if (line < 1 || last < line)
|
|
199
|
+
return citation;
|
|
200
|
+
const direct = await existing(cwd, citation.path);
|
|
201
|
+
const candidates = direct
|
|
202
|
+
? [direct]
|
|
203
|
+
: bare
|
|
204
|
+
? (repo.names.get(citation.path) ?? []).slice(0, MAX_BARE_CANDIDATES)
|
|
205
|
+
: [];
|
|
206
|
+
if (candidates.length === 0)
|
|
207
|
+
return citation;
|
|
208
|
+
const counts = await Promise.all(candidates.map((path) => lineCount(cwd, path)));
|
|
209
|
+
const lines = Math.max(...counts);
|
|
210
|
+
return last <= lines ? undefined : { ...citation, lines };
|
|
211
|
+
}
|
|
212
|
+
async function existing(cwd, path) {
|
|
213
|
+
for (const candidate of [path, ...sourceAlternatives(path)]) {
|
|
214
|
+
if (await stat(join(cwd, ...candidate.split("/"))).catch(() => undefined))
|
|
215
|
+
return candidate;
|
|
216
|
+
}
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
function sourceAlternatives(path) {
|
|
220
|
+
const extension = extensionOf(path);
|
|
221
|
+
const stem = path.slice(0, path.length - extension.length);
|
|
222
|
+
return (TS_SOURCES[extension] ?? []).map((replacement) => `${stem}${replacement}`);
|
|
223
|
+
}
|
|
224
|
+
async function lineCount(cwd, path) {
|
|
225
|
+
const target = join(cwd, ...path.split("/"));
|
|
226
|
+
if (!(await stat(target).catch(() => undefined))?.isFile())
|
|
227
|
+
return 0;
|
|
228
|
+
const text = (await readTextIfExists(target)) ?? "";
|
|
229
|
+
return text.split(/\r?\n/).length - (text.endsWith("\n") ? 1 : 0);
|
|
230
|
+
}
|
|
231
|
+
async function indexRepo(cwd) {
|
|
232
|
+
const { files } = await scanFiles(cwd);
|
|
233
|
+
const names = new Map();
|
|
234
|
+
for (const file of files) {
|
|
235
|
+
const name = baseName(file.path);
|
|
236
|
+
names.set(name, [...(names.get(name) ?? []), file.path]);
|
|
237
|
+
}
|
|
238
|
+
return { names, roots: new Set(files.map((file) => file.path.split("/")[0] ?? "")) };
|
|
239
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const ONLY_GROUPS = ["plan", "agents", "memory"];
|
|
2
|
+
const MEMORY_FILES = new Set(["AGENTS.md", "CLAUDE.md", "GEMINI.md"]);
|
|
3
|
+
export function groupOf(path) {
|
|
4
|
+
if (MEMORY_FILES.has(path))
|
|
5
|
+
return "memory";
|
|
6
|
+
return path.startsWith("docs/plan/") ? "plan" : "agents";
|
|
7
|
+
}
|
|
8
|
+
export function isTargeted(path, targets) {
|
|
9
|
+
if (path === "CLAUDE.md" || path.startsWith(".claude/"))
|
|
10
|
+
return targets.includes("claude-code");
|
|
11
|
+
if (path === "GEMINI.md")
|
|
12
|
+
return targets.includes("gemini");
|
|
13
|
+
if (path.startsWith(".opencode/"))
|
|
14
|
+
return targets.includes("opencode");
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
export function selectFiles(files, selection) {
|
|
18
|
+
return files.filter((file) => isTargeted(file.path, selection.targets) &&
|
|
19
|
+
(selection.only === undefined || groupOf(file.path) === selection.only));
|
|
20
|
+
}
|