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,140 @@
|
|
|
1
|
+
import { isTestFile } from "../digest/baseline.js";
|
|
2
|
+
const RUST_TEST = /#\[(?:tokio::)?test\]|#\[cfg\(test\)\]/;
|
|
3
|
+
const ASSERTION = /\bexpect\s*[({]|\bExpect\s*\(|\bassert\w*!\s*\(|\bassert\w*\s*[(]|\bassert\.\w+\s*\(|^\s*assert\s|\b(?:assert|refute)_\w+\b|\brefute\w*\s*[(\s]|\.should\b|\bshould\s*[.(]|\bshouldBe\b|\.Should\(\)|\bt\.(?:Error|Errorf|Fatal|Fatalf|Fail)\b|\bt\.(?:is|not|equal|deepEqual|true|false|truthy|falsy|ok|notOk|same|match|throws)\s*\(|\brequire\.\w+\(|\bXCTAssert\w*\(|#(?:expect|require)\s*\(|\bAssert\.\w+\(|\bassertThat\(|\bself\.assert\w*\(|\$this->assert\w*\(|\bthrow\b/;
|
|
4
|
+
const COMMENT = /^\s*(\/\/|#(?!\[)|\*|\/\*)/;
|
|
5
|
+
const DECLARATIONS = [
|
|
6
|
+
/\b(?:it|test|specify)(?:\.\w+)*\s*\(\s*(['"`])(.+?)\1/g,
|
|
7
|
+
/^\s*it\s+(['"])(.+?)\1/gm,
|
|
8
|
+
/^\s*(?:async\s+)?def\s+()(test\w*)\s*\(/gm,
|
|
9
|
+
/^\s*func\s+()(Test\w+)\s*\(/gm,
|
|
10
|
+
/^\s*func\s+()(test\w+)\s*\(/gm,
|
|
11
|
+
/^\s*test\s+(["'])(.+?)\1\s+do\b/gm,
|
|
12
|
+
/\bt\.Run\(\s*(["'`])(.+?)\1/g,
|
|
13
|
+
/\bfunction\s+()(test\w+)\s*\(/g,
|
|
14
|
+
/@(?:Test|ParameterizedTest|RepeatedTest)\b(?:\([^)]*\))?(?:\s*@[\w.]+(?:\([^)]*\))?)*\s*(?:(?:public|protected|private|internal|open|override|suspend|static|final)\s+)*(?:void|fun)\s+()`?([\w ]+?)`?\s*\(/g,
|
|
15
|
+
/\[(?:Fact|Theory|Test|TestMethod|TestCase)\b[^\]]*\](?:\s*\[[^\]]*\])*\s*(?:(?:public|private|internal|protected|static|async)\s+)*(?:void|Task)\s+()(\w+)\s*\(/g,
|
|
16
|
+
/#\[(?:tokio::)?test\](?:\s*#\[[^\]]*\])*\s*(?:pub\s+)?(?:async\s+)?fn\s+()(\w+)\s*\(/g,
|
|
17
|
+
];
|
|
18
|
+
const ANNOTATED = /#\[(?:tokio::)?test\]|@(?:Test|ParameterizedTest|RepeatedTest|TestFactory)\b|\[(?:Fact|Theory|Test|TestMethod|TestCase)\b/g;
|
|
19
|
+
const SKIP_MARKERS = [
|
|
20
|
+
[".skip", /\b(?:it|test|describe|context|suite)(?:\.\w+)*\.(?:skip|skipIf|runIf)\b/],
|
|
21
|
+
[".only", /\b(?:it|test|describe|context|suite)(?:\.\w+)*\.only\b/],
|
|
22
|
+
[".todo", /\b(?:it|test)(?:\.\w+)*\.todo\b/],
|
|
23
|
+
["pytestmark", /\bpytestmark\s*=.*pytest\.mark\.(?:skip|skipif)\b/],
|
|
24
|
+
["@pytest.mark.xfail", /\bpytestmark\s*=.*pytest\.mark\.xfail\b|@pytest\.mark\.xfail\b/],
|
|
25
|
+
["go:build ignore", /^\/\/\s*(?:go:build|\+build)\s+ignore\b/m],
|
|
26
|
+
["expected failure", /\b(?:it|test)(?:\.\w+)*\.(?:fails|failing)\b|\btest\.fail\s*\(/],
|
|
27
|
+
["@unittest.expectedFailure", /@unittest\.expectedFailure\b|@expectedFailure\b/],
|
|
28
|
+
["pytest.xfail", /\bpytest\.xfail\s*\(/],
|
|
29
|
+
["focused test", /(?:^|[^.\w$])f(?:it|describe)\s*\(/m],
|
|
30
|
+
["x-prefixed spec", /^\s*x(?:it|describe|context|specify)\s+["']/m],
|
|
31
|
+
["rspec skip", /^\s*(?:skip|pending)(?:\s+["']|\s*$|\s+do\b)/m],
|
|
32
|
+
[
|
|
33
|
+
"node:test skip",
|
|
34
|
+
/\b(?:it|test|describe|suite)\s*\([^\n]*\bskip\s*:\s*true\b|\bt\.(?:skip|todo)\s*\(/,
|
|
35
|
+
],
|
|
36
|
+
["x-prefixed test", /\bx(?:it|describe|test|context)\s*\(/],
|
|
37
|
+
["test.fixme", /\btest\.fixme\b/],
|
|
38
|
+
["@pytest.mark.skip", /@pytest\.mark\.(?:skip|skipif)\b/],
|
|
39
|
+
["pytest.skip", /\bpytest\.skip\s*\(/],
|
|
40
|
+
["@unittest.skip", /@unittest\.skip\w*/],
|
|
41
|
+
["t.Skip", /\bt\.Skip(?:f|Now)?\s*\(/],
|
|
42
|
+
["#[ignore]", /#\[ignore\b/],
|
|
43
|
+
["@Disabled", /@Disabled\b/],
|
|
44
|
+
["@Ignore", /@Ignore\b|\[Ignore\b/],
|
|
45
|
+
["this.skip", /\bthis\.skip\s*\(/],
|
|
46
|
+
["markTestSkipped", /markTestSkipped\s*\(/],
|
|
47
|
+
];
|
|
48
|
+
const RUNNER_CONFIG = /(^|\/)(vitest\.(config|workspace)|jest\.config|playwright\.config|cypress\.config|karma\.conf|vite\.config)\.[cm]?[jt]s(on)?$|(^|\/)(pytest\.ini|conftest\.py|\.mocharc(\.\w+)?|phpunit\.xml(\.dist)?)$/;
|
|
49
|
+
const EXCLUSIONS = [
|
|
50
|
+
["testPathIgnorePatterns", /testPathIgnorePatterns/],
|
|
51
|
+
["testIgnore", /\btestIgnore\b/],
|
|
52
|
+
["--deselect", /--deselect\b/],
|
|
53
|
+
["-k", /(^|\s|["'])-k(\s|=)/],
|
|
54
|
+
["norecursedirs", /\bnorecursedirs\b/],
|
|
55
|
+
["pytest_collection_modifyitems", /pytest_collection_modifyitems/],
|
|
56
|
+
["pytest_ignore_collect", /pytest_ignore_collect|collect_ignore/],
|
|
57
|
+
["testMatch", /\btestMatch\b|\btestRegex\b/],
|
|
58
|
+
["roots", /\broots\s*:/],
|
|
59
|
+
["test exclude", /\btest\s*:\s*\{[^}]*\b(?:exclude|include)\b/],
|
|
60
|
+
];
|
|
61
|
+
const SUPPRESSIONS = [
|
|
62
|
+
["@ts-ignore", /@ts-(?:ignore|expect-error|nocheck)\b/, undefined],
|
|
63
|
+
[
|
|
64
|
+
"eslint-disable",
|
|
65
|
+
/eslint-disable(?:-next-line|-line)?\b/,
|
|
66
|
+
/eslint-disable(?:-next-line|-line)?\s+[@\w/-]/,
|
|
67
|
+
],
|
|
68
|
+
["biome-ignore", /biome-ignore\b/, /biome-ignore\S*\s+\S/],
|
|
69
|
+
["type: ignore", /#\s*type:\s*ignore\b/, /#\s*type:\s*ignore\[/],
|
|
70
|
+
["noqa", /#\s*noqa\b/i, /#\s*noqa:\s*[A-Z]/i],
|
|
71
|
+
["pylint: disable", /pylint:\s*disable\b/, /pylint:\s*disable=(?!all\b)[\w-]/],
|
|
72
|
+
["nolint", /\/\/\s*nolint\b/, /\/\/\s*nolint:\w/],
|
|
73
|
+
["#[allow]", /#!?\[allow\(/, /#!?\[allow\(/],
|
|
74
|
+
[
|
|
75
|
+
"@SuppressWarnings",
|
|
76
|
+
/@SuppressWarnings\b|@Suppress\(/,
|
|
77
|
+
/@SuppressWarnings\(\s*["{]|@Suppress\(\s*"/,
|
|
78
|
+
],
|
|
79
|
+
["rubocop:disable", /rubocop:disable\b/, /rubocop:disable\s+(?!all\b)[A-Z]/],
|
|
80
|
+
[
|
|
81
|
+
"phpstan-ignore",
|
|
82
|
+
/@phpstan-ignore|@psalm-suppress/,
|
|
83
|
+
/@phpstan-ignore(?:-next-line|-line)?\s+[\w.]+|@psalm-suppress\s+\w/,
|
|
84
|
+
],
|
|
85
|
+
];
|
|
86
|
+
const DIRECTIVES = new Set(["go:build ignore"]);
|
|
87
|
+
export const EXPECTED_FAILURES = new Set([
|
|
88
|
+
"expected failure",
|
|
89
|
+
"@unittest.expectedFailure",
|
|
90
|
+
"pytest.xfail",
|
|
91
|
+
"@pytest.mark.xfail",
|
|
92
|
+
]);
|
|
93
|
+
const EXPECTED_OUTPUT = /(^|\/)__snapshots__\/|\.snap$|(^|\/)testdata\/|\.golden$|(^|\/)__fixtures__\/.*\.expected/;
|
|
94
|
+
export function suppressionLines(text) {
|
|
95
|
+
return text.split("\n").flatMap((line) => SUPPRESSIONS.filter(([, pattern]) => pattern.test(line)).map(([name, , specific]) => ({
|
|
96
|
+
name,
|
|
97
|
+
specific: specific?.test(line) ?? false,
|
|
98
|
+
})));
|
|
99
|
+
}
|
|
100
|
+
export function isExpectedOutput(path) {
|
|
101
|
+
return EXPECTED_OUTPUT.test(path);
|
|
102
|
+
}
|
|
103
|
+
export function isTestChange(item) {
|
|
104
|
+
return isTestFile(item.path) || (item.path.endsWith(".rs") && RUST_TEST.test(item.text));
|
|
105
|
+
}
|
|
106
|
+
export function addsAssertions(item) {
|
|
107
|
+
return isTestChange(item) && assertionCount(item.text) > 0;
|
|
108
|
+
}
|
|
109
|
+
export function assertionCount(text) {
|
|
110
|
+
return code(text).filter((line) => ASSERTION.test(line)).length;
|
|
111
|
+
}
|
|
112
|
+
export function testNames(text) {
|
|
113
|
+
const body = code(text).join("\n");
|
|
114
|
+
return DECLARATIONS.flatMap((pattern) => [...body.matchAll(pattern)].map((match) => match[2] ?? "")).filter(Boolean);
|
|
115
|
+
}
|
|
116
|
+
export function annotatedCount(text) {
|
|
117
|
+
return [...code(text).join("\n").matchAll(ANNOTATED)].length;
|
|
118
|
+
}
|
|
119
|
+
function code(text) {
|
|
120
|
+
return text.split("\n").filter((line) => !COMMENT.test(line));
|
|
121
|
+
}
|
|
122
|
+
export function markerCounts(text) {
|
|
123
|
+
const counts = new Map();
|
|
124
|
+
const kept = new Set(code(text));
|
|
125
|
+
for (const line of text.split("\n")) {
|
|
126
|
+
for (const [name, pattern] of SKIP_MARKERS) {
|
|
127
|
+
if (!kept.has(line) && !DIRECTIVES.has(name))
|
|
128
|
+
continue;
|
|
129
|
+
if (pattern.test(line))
|
|
130
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return counts;
|
|
134
|
+
}
|
|
135
|
+
export function isRunnerConfig(path) {
|
|
136
|
+
return RUNNER_CONFIG.test(path);
|
|
137
|
+
}
|
|
138
|
+
export function exclusionKeys(text) {
|
|
139
|
+
return EXCLUSIONS.filter(([, pattern]) => pattern.test(text)).map(([name]) => name);
|
|
140
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readTextIfExists } from "../core/fs.js";
|
|
4
|
+
import { asRecord } from "../core/json.js";
|
|
5
|
+
import { runDir } from "./runs.js";
|
|
6
|
+
export const GATE_STAGES = [
|
|
7
|
+
"agent",
|
|
8
|
+
"contract",
|
|
9
|
+
"refused",
|
|
10
|
+
"declined",
|
|
11
|
+
"verification",
|
|
12
|
+
"regression",
|
|
13
|
+
"integrity",
|
|
14
|
+
"handoff",
|
|
15
|
+
"review",
|
|
16
|
+
];
|
|
17
|
+
export const MAX_ATTEMPTS = 3;
|
|
18
|
+
const ATTEMPTS_FILE = "attempts.jsonl";
|
|
19
|
+
const OUTCOMES = new Set(["done", "failed", "blocked"]);
|
|
20
|
+
export async function recordAttempt(cwd, id, attempt) {
|
|
21
|
+
const dir = runDir(cwd, id);
|
|
22
|
+
await mkdir(dir, { recursive: true });
|
|
23
|
+
await appendFile(join(dir, ATTEMPTS_FILE), `${JSON.stringify(attempt)}\n`, "utf8");
|
|
24
|
+
}
|
|
25
|
+
export async function readAttempts(cwd, id) {
|
|
26
|
+
const text = (await readTextIfExists(join(runDir(cwd, id), ATTEMPTS_FILE))) ?? "";
|
|
27
|
+
return text
|
|
28
|
+
.split(/\r?\n/)
|
|
29
|
+
.filter((line) => line.trim() !== "")
|
|
30
|
+
.flatMap((line) => {
|
|
31
|
+
const attempt = toAttempt(line);
|
|
32
|
+
return attempt ? [attempt] : [];
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function toAttempt(line) {
|
|
36
|
+
let data;
|
|
37
|
+
try {
|
|
38
|
+
data = asRecord(JSON.parse(line));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
if (typeof data.startedAt !== "string" || !OUTCOMES.has(String(data.outcome)))
|
|
44
|
+
return undefined;
|
|
45
|
+
const stage = GATE_STAGES.find((value) => value === data.stage);
|
|
46
|
+
return {
|
|
47
|
+
startedAt: data.startedAt,
|
|
48
|
+
durationMs: typeof data.durationMs === "number" ? data.durationMs : 0,
|
|
49
|
+
headless: data.headless === true,
|
|
50
|
+
outcome: data.outcome,
|
|
51
|
+
...(stage ? { stage } : {}),
|
|
52
|
+
regressions: strings(data.regressions),
|
|
53
|
+
...(typeof data.reason === "string" ? { reason: data.reason } : {}),
|
|
54
|
+
...(Array.isArray(data.skips) ? { skips: strings(data.skips) } : {}),
|
|
55
|
+
...(Array.isArray(data.accepted) ? { accepted: strings(data.accepted) } : {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function sinceBlocked(attempts) {
|
|
59
|
+
const last = attempts.map((attempt) => attempt.outcome).lastIndexOf("blocked");
|
|
60
|
+
return attempts.slice(last + 1);
|
|
61
|
+
}
|
|
62
|
+
export async function attemptsLeft(cwd, id) {
|
|
63
|
+
return Math.max(0, MAX_ATTEMPTS - sinceBlocked(await readAttempts(cwd, id)).length);
|
|
64
|
+
}
|
|
65
|
+
export async function blockedReason(cwd, id) {
|
|
66
|
+
return (await readAttempts(cwd, id)).filter((attempt) => attempt.outcome === "blocked").at(-1)
|
|
67
|
+
?.reason;
|
|
68
|
+
}
|
|
69
|
+
function strings(value) {
|
|
70
|
+
return Array.isArray(value) ? value.map(String) : [];
|
|
71
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { logicalLines, parseLine, program } from "./shell-words.js";
|
|
2
|
+
const RUNNERS = new Set(("npm npx pnpm pnpx yarn bun bunx deno node tsx ts-node vitest jest mocha ava tap playwright cypress tsc vue-tsc eslint biome prettier stylelint " +
|
|
3
|
+
"python python3 py pytest tox nox ruff mypy pyright flake8 black isort uv poetry pipenv hatch pdm " +
|
|
4
|
+
"go gofmt golangci-lint staticcheck cargo rustc make cmake ctest ninja gradle gradlew mvn mvnw dotnet java javac kotlinc sbt " +
|
|
5
|
+
"php composer phpunit pest phpstan psalm bundle rake rspec ruby rails rubocop mix elixir swift xcodebuild flutter dart shellcheck " +
|
|
6
|
+
"turbo nx lerna rush just bazel bazelisk moon").split(" "));
|
|
7
|
+
const CHECKS = new Set(["test", "[", "[[", "grep", "egrep", "fgrep", "rg", "diff", "cmp"]);
|
|
8
|
+
const HELPERS = new Set("cd echo printf ls cat pwd true : export sleep head tail wc sort uniq tee mkdir touch cp which exit set".split(" "));
|
|
9
|
+
const TRIVIAL = new Set("echo printf ls cat pwd true : exit sleep cd export".split(" "));
|
|
10
|
+
const GIT_READ = new Set([
|
|
11
|
+
"diff",
|
|
12
|
+
"status",
|
|
13
|
+
"log",
|
|
14
|
+
"show",
|
|
15
|
+
"ls-files",
|
|
16
|
+
"grep",
|
|
17
|
+
"rev-parse",
|
|
18
|
+
"describe",
|
|
19
|
+
"blame",
|
|
20
|
+
]);
|
|
21
|
+
const DYNAMIC = new Set(["eval", "source", ".", "exec", "xargs", "command", "builtin", "env"]);
|
|
22
|
+
const RELEASING = {
|
|
23
|
+
npm: ["publish", "unpublish", "deprecate", "owner", "login", "adduser", "dist-tag"],
|
|
24
|
+
pnpm: ["publish", "login"],
|
|
25
|
+
yarn: ["publish", "npm", "login"],
|
|
26
|
+
bun: ["publish"],
|
|
27
|
+
cargo: ["publish", "yank", "owner", "login"],
|
|
28
|
+
docker: ["push", "login"],
|
|
29
|
+
podman: ["push", "login"],
|
|
30
|
+
poetry: ["publish"],
|
|
31
|
+
uv: ["publish"],
|
|
32
|
+
hatch: ["publish"],
|
|
33
|
+
pdm: ["publish"],
|
|
34
|
+
twine: ["upload"],
|
|
35
|
+
gem: ["push", "yank"],
|
|
36
|
+
dotnet: ["nuget"],
|
|
37
|
+
mvn: ["deploy", "release:perform", "release:prepare"],
|
|
38
|
+
mvnw: ["deploy", "release:perform", "release:prepare"],
|
|
39
|
+
gradle: ["publish"],
|
|
40
|
+
gradlew: ["publish"],
|
|
41
|
+
mix: ["hex.publish"],
|
|
42
|
+
dart: ["pub"],
|
|
43
|
+
flutter: ["pub"],
|
|
44
|
+
};
|
|
45
|
+
const WRAPPERS = new Set(["timeout", "nice", "xvfb-run", "nohup", "time"]);
|
|
46
|
+
const HEADERS = new Set(["for", "case", "select", "in"]);
|
|
47
|
+
const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish", "pwsh", "powershell", "cmd"]);
|
|
48
|
+
const SCRIPT_SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
|
|
49
|
+
const DOWNLOADERS = new Set(["curl", "wget"]);
|
|
50
|
+
const MASKING = /\bset\s+\+[A-Za-z]*e[A-Za-z]*\b|\bset\s+\+o\s+(?:errexit|pipefail)\b|\btrap\s+-?\s*['"]?\s*['"]?\s+ERR\b/;
|
|
51
|
+
const FALLBACK_OK = /^\s*(?:exit\s+(?:[1-9]\d*|\$\?)|false|\{[^}]*\bexit\s+(?:[1-9]\d*|\$\?)\s*;?\s*\})/;
|
|
52
|
+
export function trivialityProblems(lines) {
|
|
53
|
+
const logical = logicalLines(lines);
|
|
54
|
+
const masked = logical
|
|
55
|
+
.filter((line) => MASKING.test(line) || masksWithOperators(line))
|
|
56
|
+
.map((command) => ({ command, reason: "masks" }));
|
|
57
|
+
const checks = logical.some((line) => parseLine(line).commands.some((command) => runsOrChecks(command)));
|
|
58
|
+
const trivial = logical.length > 0 && !checks
|
|
59
|
+
? [{ command: logical.join("\n"), reason: "trivial" }]
|
|
60
|
+
: [];
|
|
61
|
+
return [...masked, ...trivial];
|
|
62
|
+
}
|
|
63
|
+
export function allowlistProblems(lines, allow) {
|
|
64
|
+
return logicalLines(lines).flatMap((line) => {
|
|
65
|
+
const parsed = parseLine(line);
|
|
66
|
+
if (parsed.substitution || /[<>]\(/.test(line)) {
|
|
67
|
+
return [{ command: line, reason: "dynamic" }];
|
|
68
|
+
}
|
|
69
|
+
const downloads = parsed.commands.some((command) => DOWNLOADERS.has(baseName(program(command).name)));
|
|
70
|
+
const bad = parsed.commands.find((command) => !allow.some((prefix) => command.text.startsWith(prefix)) && !allowed(command, downloads));
|
|
71
|
+
if (!bad)
|
|
72
|
+
return [];
|
|
73
|
+
const { name } = program(bad);
|
|
74
|
+
const dynamic = DYNAMIC.has(name) || SHELLS.has(name);
|
|
75
|
+
return [{ command: line, reason: dynamic ? "dynamic" : "notAllowed" }];
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function masksWithOperators(line) {
|
|
79
|
+
const unquoted = line.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, "''");
|
|
80
|
+
const fallbacks = unquoted.split("||").slice(1);
|
|
81
|
+
if (fallbacks.some((fallback) => !FALLBACK_OK.test(fallback) && !checksAgain(fallback))) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return /(^|[^&|])&(\s*$|\s*[;)]|\s+\S)/.test(unquoted.replace(/&&|&>|>&|\|&|\d>&\d/g, ""));
|
|
85
|
+
}
|
|
86
|
+
function checksAgain(fallback) {
|
|
87
|
+
const [first] = parseLine(fallback.trim()).commands;
|
|
88
|
+
if (!first)
|
|
89
|
+
return false;
|
|
90
|
+
const { name } = program(first);
|
|
91
|
+
return CHECKS.has(baseName(name));
|
|
92
|
+
}
|
|
93
|
+
function runsOrChecks(command) {
|
|
94
|
+
const { name, args } = unwrap(program(command));
|
|
95
|
+
const base = baseName(name);
|
|
96
|
+
if (RUNNERS.has(base) || runsScriptFile(base, args))
|
|
97
|
+
return true;
|
|
98
|
+
if (CHECKS.has(base))
|
|
99
|
+
return true;
|
|
100
|
+
if (base === "curl")
|
|
101
|
+
return args.some((arg) => /^(-[a-zA-Z]*f[a-zA-Z]*|--fail(-with-body)?)$/.test(arg));
|
|
102
|
+
if (base === "jq")
|
|
103
|
+
return args.some((arg) => /^(-[a-zA-Z]*e[a-zA-Z]*|--exit-status)$/.test(arg));
|
|
104
|
+
if (base === "git")
|
|
105
|
+
return args[0] === "diff" && args.some((arg) => arg === "--exit-code" || arg === "--quiet");
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
function allowed(command, downloads) {
|
|
109
|
+
if (HEADERS.has(command.words[0] ?? ""))
|
|
110
|
+
return true;
|
|
111
|
+
const { name, args } = unwrap(program(command));
|
|
112
|
+
const base = baseName(name);
|
|
113
|
+
if (name === "")
|
|
114
|
+
return true;
|
|
115
|
+
if (!downloads && runsScriptFile(base, args))
|
|
116
|
+
return true;
|
|
117
|
+
if (DYNAMIC.has(base) || SHELLS.has(base))
|
|
118
|
+
return false;
|
|
119
|
+
if (releases(base, args))
|
|
120
|
+
return false;
|
|
121
|
+
if (base === "rm")
|
|
122
|
+
return safeRemove(args);
|
|
123
|
+
if (base === "git")
|
|
124
|
+
return GIT_READ.has(args[0] ?? "");
|
|
125
|
+
if (base === "curl" || base === "jq")
|
|
126
|
+
return true;
|
|
127
|
+
if (!TRIVIAL.has(base) &&
|
|
128
|
+
name.includes("/") &&
|
|
129
|
+
!/(^|\/)node_modules\/\.bin\/|(^|\/)vendor\/bin\//.test(name) &&
|
|
130
|
+
!/^\.\/(gradlew|mvnw)$/.test(name)) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
return RUNNERS.has(base) || CHECKS.has(base) || HELPERS.has(base);
|
|
134
|
+
}
|
|
135
|
+
function runsScriptFile(base, args) {
|
|
136
|
+
if (!SCRIPT_SHELLS.has(base))
|
|
137
|
+
return false;
|
|
138
|
+
const script = args.find((arg) => !arg.startsWith("-"));
|
|
139
|
+
const flags = args.slice(0, script ? args.indexOf(script) : args.length);
|
|
140
|
+
if (!script || flags.some((flag) => /^-[a-zA-Z]*[cis]/.test(flag) || flag.startsWith("--"))) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
return (!script.startsWith("/") && !script.split(/[\\/]/).includes("..") && !/^[a-z]:/i.test(script));
|
|
144
|
+
}
|
|
145
|
+
function unwrap(found) {
|
|
146
|
+
const base = baseName(found.name);
|
|
147
|
+
if (!WRAPPERS.has(base))
|
|
148
|
+
return found;
|
|
149
|
+
let index = 0;
|
|
150
|
+
const args = found.args;
|
|
151
|
+
while (index < args.length && (args[index] ?? "").startsWith("-")) {
|
|
152
|
+
index += /^-(n|s|k|-signal|-kill-after|-server-args)$/.test(args[index] ?? "") ? 2 : 1;
|
|
153
|
+
}
|
|
154
|
+
if (base === "timeout")
|
|
155
|
+
index++;
|
|
156
|
+
const [name = "", ...rest] = args.slice(index);
|
|
157
|
+
return name ? unwrap({ name, args: rest }) : { name: base, args: [] };
|
|
158
|
+
}
|
|
159
|
+
function releases(base, args) {
|
|
160
|
+
if (args.includes("--dry-run"))
|
|
161
|
+
return false;
|
|
162
|
+
const subcommands = RELEASING[base];
|
|
163
|
+
const first = args.find((arg) => !arg.startsWith("-"));
|
|
164
|
+
if (subcommands && first && subcommands.includes(first))
|
|
165
|
+
return true;
|
|
166
|
+
if (!["npx", "pnpx", "bunx"].includes(base) || !first)
|
|
167
|
+
return false;
|
|
168
|
+
const rest = args.slice(args.indexOf(first) + 1);
|
|
169
|
+
return releases(baseName(first), rest);
|
|
170
|
+
}
|
|
171
|
+
function safeRemove(args) {
|
|
172
|
+
const paths = args.filter((arg) => !arg.startsWith("-"));
|
|
173
|
+
return (paths.length > 0 &&
|
|
174
|
+
paths.every((path) => !/^([/~]|[a-z]:)/i.test(path) &&
|
|
175
|
+
!path.split(/[\\/]/).includes("..") &&
|
|
176
|
+
!/^(\.|\*|\.\/?\*?)$/.test(path) &&
|
|
177
|
+
!path.includes("$")));
|
|
178
|
+
}
|
|
179
|
+
function baseName(name) {
|
|
180
|
+
return (name.split("/").at(-1) ?? name).replace(/\.(cmd|exe|bat)$/i, "");
|
|
181
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { parse } from "yaml";
|
|
2
|
+
import { asRecord } from "../core/json.js";
|
|
3
|
+
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
4
|
+
const LINE = /^([A-Za-z_][\w-]*)[ \t]*:[ \t]*(.*)$/;
|
|
5
|
+
export function splitFrontmatter(text) {
|
|
6
|
+
const match = FRONTMATTER.exec(text);
|
|
7
|
+
if (!match)
|
|
8
|
+
return undefined;
|
|
9
|
+
return { data: parseFields(match[1] ?? ""), body: text.slice(match[0].length) };
|
|
10
|
+
}
|
|
11
|
+
export function setFrontmatterFields(text, fields) {
|
|
12
|
+
const match = FRONTMATTER.exec(text);
|
|
13
|
+
if (!match)
|
|
14
|
+
return text;
|
|
15
|
+
let lines = (match[1] ?? "").split(/\r?\n/);
|
|
16
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
17
|
+
const first = lines.findIndex((line) => LINE.exec(line)?.[1] === key);
|
|
18
|
+
lines =
|
|
19
|
+
first === -1
|
|
20
|
+
? [...lines, `${key}: ${value}`]
|
|
21
|
+
: lines.flatMap((line, index) => {
|
|
22
|
+
if (index === first)
|
|
23
|
+
return [`${key}: ${value}`];
|
|
24
|
+
return LINE.exec(line)?.[1] === key ? [] : [line];
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return `---\n${lines.join("\n")}\n---\n${text.slice(match[0].length)}`;
|
|
28
|
+
}
|
|
29
|
+
function parseFields(raw) {
|
|
30
|
+
try {
|
|
31
|
+
return asRecord(parse(raw));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return parseLoose(raw);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function parseLoose(raw) {
|
|
38
|
+
const fields = {};
|
|
39
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
40
|
+
const match = LINE.exec(line);
|
|
41
|
+
if (match?.[1])
|
|
42
|
+
fields[match[1]] = looseValue((match[2] ?? "").trim());
|
|
43
|
+
}
|
|
44
|
+
return fields;
|
|
45
|
+
}
|
|
46
|
+
function looseValue(value) {
|
|
47
|
+
const list = /^\[(.*)\]$/.exec(value);
|
|
48
|
+
if (list) {
|
|
49
|
+
return (list[1] ?? "")
|
|
50
|
+
.split(",")
|
|
51
|
+
.map((item) => unquote(item.trim()))
|
|
52
|
+
.filter(Boolean);
|
|
53
|
+
}
|
|
54
|
+
return unquote(value);
|
|
55
|
+
}
|
|
56
|
+
function unquote(value) {
|
|
57
|
+
return /^(["']).*\1$/.test(value) ? value.slice(1, -1) : value;
|
|
58
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function findCycle(nodes) {
|
|
2
|
+
const edges = new Map(nodes.map((node) => [node.id, node.dependsOn]));
|
|
3
|
+
const state = new Map();
|
|
4
|
+
const stack = [];
|
|
5
|
+
const visit = (id) => {
|
|
6
|
+
if (state.get(id) === "done")
|
|
7
|
+
return undefined;
|
|
8
|
+
if (state.get(id) === "visiting")
|
|
9
|
+
return [...stack.slice(stack.indexOf(id)), id];
|
|
10
|
+
state.set(id, "visiting");
|
|
11
|
+
stack.push(id);
|
|
12
|
+
for (const next of edges.get(id) ?? []) {
|
|
13
|
+
const cycle = visit(next);
|
|
14
|
+
if (cycle)
|
|
15
|
+
return cycle;
|
|
16
|
+
}
|
|
17
|
+
stack.pop();
|
|
18
|
+
state.set(id, "done");
|
|
19
|
+
return undefined;
|
|
20
|
+
};
|
|
21
|
+
for (const node of nodes) {
|
|
22
|
+
const cycle = visit(node.id);
|
|
23
|
+
if (cycle)
|
|
24
|
+
return cycle;
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { COMMAND_KEYS } from "../config/schema.js";
|
|
2
|
+
import { compareIds } from "./load.js";
|
|
3
|
+
import { sectionTexts } from "./schema.js";
|
|
4
|
+
import { waitingOn } from "./select.js";
|
|
5
|
+
export const MAX_LOG_LINES = 8;
|
|
6
|
+
export const RECENT_LOGS = 3;
|
|
7
|
+
const LOG_HEADING = /^##[ \t]+(log|registro|bitácora|bitacora)[ \t]*$/im;
|
|
8
|
+
const COMMENT = /<!--[\s\S]*?-->/g;
|
|
9
|
+
export function logLines(task) {
|
|
10
|
+
return sectionTexts(task.body, "log")
|
|
11
|
+
.join("\n")
|
|
12
|
+
.replace(COMMENT, "")
|
|
13
|
+
.split(/\r?\n/)
|
|
14
|
+
.map((line) => line.trimEnd())
|
|
15
|
+
.filter((line) => line.trim() !== "");
|
|
16
|
+
}
|
|
17
|
+
export function withLogSection(text) {
|
|
18
|
+
return LOG_HEADING.test(text) ? text : `${text.trimEnd()}\n\n## Log\n`;
|
|
19
|
+
}
|
|
20
|
+
export function planContext(config, tasks, current, completedAt = new Map()) {
|
|
21
|
+
return [planState(tasks, current), commandsSection(config), recentNotes(tasks, completedAt)]
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.join("\n\n");
|
|
24
|
+
}
|
|
25
|
+
function planState(tasks, current) {
|
|
26
|
+
const done = tasks.filter((task) => task.meta.status === "done").length;
|
|
27
|
+
const blocked = tasks.filter((task) => task.meta.status === "blocked");
|
|
28
|
+
const others = tasks.filter((task) => task.meta.id !== current.meta.id);
|
|
29
|
+
const upNext = others
|
|
30
|
+
.filter((task) => task.meta.status === "pending")
|
|
31
|
+
.filter((task) => waitingOn(task, tasks).every((id) => id === current.meta.id))
|
|
32
|
+
.slice(0, 3);
|
|
33
|
+
const lines = [
|
|
34
|
+
`- Done: ${done} of ${tasks.length} tasks. You are on ${current.meta.id} (phase ${current.meta.phase}).`,
|
|
35
|
+
];
|
|
36
|
+
if (blocked.length > 0)
|
|
37
|
+
lines.push(`- Blocked, do not work on them: ${titles(blocked)}`);
|
|
38
|
+
if (upNext.length > 0)
|
|
39
|
+
lines.push(`- Up next after this task: ${titles(upNext)}`);
|
|
40
|
+
return ["## Where the plan stands", "", ...lines].join("\n");
|
|
41
|
+
}
|
|
42
|
+
function commandsSection(config) {
|
|
43
|
+
const lines = COMMAND_KEYS.flatMap((key) => {
|
|
44
|
+
const command = config.commands[key];
|
|
45
|
+
return command ? [`- ${key}: \`${command}\``] : [];
|
|
46
|
+
});
|
|
47
|
+
return lines.length > 0 ? ["## Project commands", "", ...lines].join("\n") : "";
|
|
48
|
+
}
|
|
49
|
+
function recentNotes(tasks, completedAt) {
|
|
50
|
+
const notes = tasks
|
|
51
|
+
.filter((task) => task.meta.status === "done" && logLines(task).length > 0)
|
|
52
|
+
.sort((a, b) => (completedAt.get(b.meta.id) ?? 0) - (completedAt.get(a.meta.id) ?? 0) ||
|
|
53
|
+
compareIds(b.meta.id, a.meta.id))
|
|
54
|
+
.slice(0, RECENT_LOGS)
|
|
55
|
+
.map((task) => `### ${task.meta.id} ${task.meta.title}\n\n${logLines(task).join("\n")}`);
|
|
56
|
+
if (notes.length === 0)
|
|
57
|
+
return "";
|
|
58
|
+
return ["## Handoff notes from the last finished tasks", ...notes].join("\n\n");
|
|
59
|
+
}
|
|
60
|
+
function titles(tasks) {
|
|
61
|
+
return tasks.map((task) => `${task.meta.id} ${task.meta.title}`).join("; ");
|
|
62
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { LANGUAGE_NAMES } from "../analyst/prompt.js";
|
|
5
|
+
import { runWithFormatRetry } from "../analyst/retry.js";
|
|
6
|
+
import { readLessons, withLessons } from "../artifacts/lessons.js";
|
|
7
|
+
import { managedBody, mergeManaged } from "../artifacts/merge.js";
|
|
8
|
+
import { FormatError, UserError } from "../core/errors.js";
|
|
9
|
+
import { readTextIfExists, writeText } from "../core/fs.js";
|
|
10
|
+
import { renderPrompt } from "../core/prompt-loader.js";
|
|
11
|
+
import { displayPath } from "../core/state.js";
|
|
12
|
+
import { truncateText } from "../digest/format.js";
|
|
13
|
+
import { capturedPrompt, trustAgentsMd } from "../gates/capture.js";
|
|
14
|
+
import { withoutLog } from "../gates/contract.js";
|
|
15
|
+
import { t } from "../i18n/index.js";
|
|
16
|
+
import { runDir } from "./runs.js";
|
|
17
|
+
const lessonSchema = z.object({ root_cause: z.string().min(1), rule: z.string().min(1) });
|
|
18
|
+
export const LESSON_FORMAT = 'JSON only: {"root_cause": "one or two sentences", "rule": "one imperative line"}';
|
|
19
|
+
const LESSON_FILE = "lesson.md";
|
|
20
|
+
const AGENTS_MD = "AGENTS.md";
|
|
21
|
+
const RECENT_RUNS = 3;
|
|
22
|
+
const MAX_RUN_CHARS = 6_000;
|
|
23
|
+
const MAX_AGENTS_MD = 20_000;
|
|
24
|
+
const REASONS = {
|
|
25
|
+
blocked: "was blocked after every automatic retry failed",
|
|
26
|
+
review: "failed its review twice",
|
|
27
|
+
};
|
|
28
|
+
export function parseLesson(text) {
|
|
29
|
+
const start = text.indexOf("{");
|
|
30
|
+
const end = text.lastIndexOf("}");
|
|
31
|
+
if (start === -1 || end < start)
|
|
32
|
+
throw new FormatError("no JSON object found");
|
|
33
|
+
let data;
|
|
34
|
+
try {
|
|
35
|
+
data = JSON.parse(text.slice(start, end + 1));
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
throw new FormatError(`invalid JSON: ${error instanceof Error ? error.message : error}`);
|
|
39
|
+
}
|
|
40
|
+
const result = lessonSchema.safeParse(data);
|
|
41
|
+
if (!result.success)
|
|
42
|
+
throw new FormatError(z.prettifyError(result.error));
|
|
43
|
+
const rule = result.data.rule
|
|
44
|
+
.split(/\r?\n/)[0]
|
|
45
|
+
?.replace(/^[-*]\s+/, "")
|
|
46
|
+
.trim() ?? "";
|
|
47
|
+
if (rule === "")
|
|
48
|
+
throw new FormatError("rule is empty");
|
|
49
|
+
return { rootCause: result.data.root_cause.trim(), rule: rule.replace(/\s+/g, " ") };
|
|
50
|
+
}
|
|
51
|
+
export async function learnFromFailure(ctx, capture, reason) {
|
|
52
|
+
const path = join(runDir(ctx.cwd, capture.id), LESSON_FILE);
|
|
53
|
+
if (capture.config.backend === "manual" || (await readTextIfExists(path)) !== undefined)
|
|
54
|
+
return;
|
|
55
|
+
const lesson = await askLesson(ctx, capture, reason);
|
|
56
|
+
if (!lesson)
|
|
57
|
+
return;
|
|
58
|
+
const { id } = capture;
|
|
59
|
+
ctx.prompter.note(t("lesson.body", { cause: lesson.rootCause, rule: lesson.rule }), t("lesson.title", { id }));
|
|
60
|
+
const approved = ctx.flags.yes || (await ctx.prompter.confirm(t("lesson.confirm"), true));
|
|
61
|
+
await writeText(path, renderLesson(id, reason, lesson, approved));
|
|
62
|
+
if (!approved) {
|
|
63
|
+
ctx.prompter.info(t("lesson.skipped", { path: displayPath(path) }));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
await addLesson(ctx.cwd, lesson.rule);
|
|
67
|
+
await trustAgentsMd(ctx.cwd, capture, withRule(capture.agentsMd, lesson.rule));
|
|
68
|
+
ctx.prompter.success(t("lesson.added"));
|
|
69
|
+
}
|
|
70
|
+
export async function addLesson(cwd, rule) {
|
|
71
|
+
const path = join(cwd, AGENTS_MD);
|
|
72
|
+
const text = withRule(await readTextIfExists(path), rule);
|
|
73
|
+
await writeText(path, text);
|
|
74
|
+
return text;
|
|
75
|
+
}
|
|
76
|
+
export function withRule(before, rule) {
|
|
77
|
+
const lessons = readLessons(before ?? "");
|
|
78
|
+
if (lessons.includes(rule))
|
|
79
|
+
return before ?? "";
|
|
80
|
+
const body = withLessons(managedBody(before ?? ""), [...lessons, rule]);
|
|
81
|
+
return mergeManaged(before, body, AGENTS_MD);
|
|
82
|
+
}
|
|
83
|
+
async function askLesson(ctx, capture, reason) {
|
|
84
|
+
const prompt = renderPrompt(capturedPrompt(capture, "lesson"), {
|
|
85
|
+
reason: REASONS[reason],
|
|
86
|
+
task: withoutLog(capture.task),
|
|
87
|
+
failures: await recentRuns(ctx.cwd, capture.id),
|
|
88
|
+
agents_md: truncateText(capture.agentsMd ?? "(none)", MAX_AGENTS_MD),
|
|
89
|
+
output_language: LANGUAGE_NAMES[capture.config.lang],
|
|
90
|
+
});
|
|
91
|
+
try {
|
|
92
|
+
return await ctx.prompter.spinner(t("lesson.asking", { id: capture.id }), () => runWithFormatRetry({
|
|
93
|
+
backend: ctx.createBackend(capture.config.backend),
|
|
94
|
+
prompt,
|
|
95
|
+
options: { cwd: ctx.cwd, access: "read" },
|
|
96
|
+
parse: parseLesson,
|
|
97
|
+
format: LESSON_FORMAT,
|
|
98
|
+
fixPrompt: capturedPrompt(capture, "fix-format"),
|
|
99
|
+
onRetry: () => ctx.prompter.warn(t("format.retrying")),
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (!(error instanceof UserError))
|
|
104
|
+
throw error;
|
|
105
|
+
ctx.prompter.warn(t("lesson.failed", { details: error.message }));
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function recentRuns(cwd, id) {
|
|
110
|
+
const dir = runDir(cwd, id);
|
|
111
|
+
const names = (await readdir(dir).catch(() => []))
|
|
112
|
+
.filter((name) => name.endsWith(".md") && name !== LESSON_FILE)
|
|
113
|
+
.sort()
|
|
114
|
+
.slice(-RECENT_RUNS);
|
|
115
|
+
const runs = await Promise.all(names.map(async (name) => truncateText((await readTextIfExists(join(dir, name))) ?? "", MAX_RUN_CHARS)));
|
|
116
|
+
return runs.join("\n\n---\n\n") || "(no run logs)";
|
|
117
|
+
}
|
|
118
|
+
function renderLesson(id, reason, lesson, approved) {
|
|
119
|
+
return [
|
|
120
|
+
`# Lesson from ${id}`,
|
|
121
|
+
"",
|
|
122
|
+
`- Reason: ${REASONS[reason]}`,
|
|
123
|
+
`- Root cause: ${lesson.rootCause}`,
|
|
124
|
+
`- Rule: ${lesson.rule}`,
|
|
125
|
+
`- Added to AGENTS.md: ${approved ? "yes" : "no"}`,
|
|
126
|
+
"",
|
|
127
|
+
].join("\n");
|
|
128
|
+
}
|