@turri-dev/cli 0.1.3-next.79e2ad0 → 0.1.3-next.e5599c1
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/README.md +1 -1
- package/dist/environment.js +19 -0
- package/dist/extension.js +131 -50
- package/dist/identity.js +1 -1
- package/dist/loop-tools.js +1 -1
- package/dist/pi-launch.js +13 -4
- package/dist/picture-row.js +21 -0
- package/dist/regenerate/confine.js +1 -0
- package/dist/regenerate/conformance-agents.js +162 -7
- package/dist/regenerate/conformance.js +58 -11
- package/dist/regenerate/environment-error.js +46 -9
- package/dist/regenerate/panel-dock.js +86 -0
- package/dist/regenerate/side-panel.js +14 -4
- package/dist/regenerate/stop-report.js +13 -0
- package/dist/regenerate/test-scripts.js +23 -8
- package/dist/run-report.js +2 -22
- package/docs/development.md +7 -0
- package/docs/regenerating.md +39 -7
- package/docs/using.md +16 -5
- package/package.json +1 -1
|
@@ -659,15 +659,28 @@ function words(text) {
|
|
|
659
659
|
* in the specification - or when it hides why the software failed. A test asserting what the
|
|
660
660
|
* specification leaves open is the test's mistake, however plausible the assertion.
|
|
661
661
|
*/
|
|
662
|
-
export function unfoundedCodeVerdict(verdict, specText) {
|
|
662
|
+
export function unfoundedCodeVerdict(verdict, specText, scriptText) {
|
|
663
663
|
if (verdict.causeHidden === true)
|
|
664
664
|
return undefined;
|
|
665
|
+
// What the conformance testing script needs of the build - a file, an entry point, a module - binds the code as the
|
|
666
|
+
// specification does: neither agent can change the script. Its own words, as they are in it.
|
|
667
|
+
const needs = verdict.scriptNeeds?.trim() ?? "";
|
|
668
|
+
if (needs !== "" && scriptText !== undefined) {
|
|
669
|
+
const flat = (text) => text.replace(/\s+/g, " ");
|
|
670
|
+
if (flat(needs).length >= 4 && flat(scriptText).includes(flat(needs)))
|
|
671
|
+
return undefined;
|
|
672
|
+
if (verdict.specSays === undefined) {
|
|
673
|
+
return (`Your verdict blames the code for what the conformance testing script needs, but "${needs}" is not in the script. ` +
|
|
674
|
+
"Call submit_verdict again: copy into script_needs the script's own words for what it needs of the build, exactly as they are in it.");
|
|
675
|
+
}
|
|
676
|
+
}
|
|
665
677
|
const quote = verdict.specSays?.trim() ?? "";
|
|
666
678
|
if (quote === "") {
|
|
667
679
|
return ("Your verdict blames the code, but quotes no words of the specification it does not meet. Call submit_verdict " +
|
|
668
680
|
"again: for code, put the specification's own words the code does not meet in spec_says, copied exactly from " +
|
|
669
|
-
"<specification>; or
|
|
670
|
-
"
|
|
681
|
+
"<specification>; or, when the run fails on something the conformance testing script needs the build to have, " +
|
|
682
|
+
"its own words in script_needs; or set cause_hidden when the output does not show why the software failed. If " +
|
|
683
|
+
"the specification does not say what the failing test asserts, the test is what is wrong: culprit tests.");
|
|
671
684
|
}
|
|
672
685
|
const quoted = words(quote);
|
|
673
686
|
if (quoted.trim().split(" ").length < 4 || !words(specText).includes(quoted)) {
|
|
@@ -677,6 +690,31 @@ export function unfoundedCodeVerdict(verdict, specText) {
|
|
|
677
690
|
}
|
|
678
691
|
return undefined;
|
|
679
692
|
}
|
|
693
|
+
/**
|
|
694
|
+
* Why a verdict's evidence cannot be taken as it is, or nothing when it can. Asked to weigh an escalation, the judge has
|
|
695
|
+
* to rest its verdict on what it found itself: each piece of evidence from a file it opened, the testing script or the
|
|
696
|
+
* failing run - and at least one not the run alone, which every judge before it read too. What the explorer's account
|
|
697
|
+
* says is not evidence: it was written before the run, and may be wrong.
|
|
698
|
+
*/
|
|
699
|
+
export function unfoundedEvidence(evidence, opened) {
|
|
700
|
+
const given = (evidence ?? []).filter((item) => item.source.trim() !== "" && item.shows.trim() !== "");
|
|
701
|
+
if (given.length === 0) {
|
|
702
|
+
return ("Your verdict gives no evidence. Call submit_verdict again with `evidence`: what you found yourself - in a file you " +
|
|
703
|
+
"opened, the testing script, or the failing run - each with where. What <how to invoke it> says is not evidence: it " +
|
|
704
|
+
"may be wrong, so check it against the testing script and the files.");
|
|
705
|
+
}
|
|
706
|
+
const inPrompt = (source) => /^(the )?(conformance )?testing script$|^(the )?failing run$/i.test(source.trim());
|
|
707
|
+
const unread = given.filter((item) => !inPrompt(item.source) && !opened(item.source.trim()));
|
|
708
|
+
if (unread.length > 0) {
|
|
709
|
+
return (`Your evidence cites ${unread.map((item) => item.source.trim()).join(", ")}, which you did not open. Open each file you cite ` +
|
|
710
|
+
"and call submit_verdict again - evidence is what you found yourself, never what <how to invoke it> says.");
|
|
711
|
+
}
|
|
712
|
+
if (given.every((item) => /failing run$/i.test(item.source.trim()))) {
|
|
713
|
+
return ("Your evidence is only the failing run, which the judges before you read too. Check what it points at - the testing " +
|
|
714
|
+
"script, the code, the tests - and call submit_verdict again with what you found there.");
|
|
715
|
+
}
|
|
716
|
+
return undefined;
|
|
717
|
+
}
|
|
680
718
|
export async function judgeFailure(options) {
|
|
681
719
|
let verdict;
|
|
682
720
|
const submit = defineTool({
|
|
@@ -692,6 +730,13 @@ export async function judgeFailure(options) {
|
|
|
692
730
|
description: "For code: the specification's own words the code does not meet, copied exactly from <specification> - the sentence, or the part of it that says what the test checks",
|
|
693
731
|
})),
|
|
694
732
|
cause_hidden: Type.Optional(Type.Boolean({ description: "For code: true when it is wrong because the output does not show why the software failed - it hides the cause" })),
|
|
733
|
+
script_needs: Type.Optional(Type.String({
|
|
734
|
+
description: "For code: the conformance testing script's own words for what it needs the build to have - the file, entry point or module the run failed on - copied exactly from the script",
|
|
735
|
+
})),
|
|
736
|
+
evidence: Type.Optional(Type.Array(Type.Object({
|
|
737
|
+
source: Type.String({ description: "Where: the path of a file you opened, `testing script`, or `failing run`" }),
|
|
738
|
+
shows: Type.String({ description: "What it shows, e.g. `the script runs node dist/cli.js` or `tsconfig.json emits src/cli.ts as dist/src/cli.js`" }),
|
|
739
|
+
}), { description: "What you found yourself, each with where - never what <how to invoke it> claims unchecked" })),
|
|
695
740
|
functionalities: Type.Optional(Type.Array(Type.Object({
|
|
696
741
|
module: Type.String({ description: "The module the functionality is in" }),
|
|
697
742
|
id: Type.String({ description: "Its id in that module" }),
|
|
@@ -704,6 +749,8 @@ export async function judgeFailure(options) {
|
|
|
704
749
|
reason: given.reason,
|
|
705
750
|
...(given.spec_says?.trim() ? { specSays: given.spec_says.trim() } : {}),
|
|
706
751
|
...(given.cause_hidden === true ? { causeHidden: true } : {}),
|
|
752
|
+
...(given.script_needs?.trim() ? { scriptNeeds: given.script_needs.trim() } : {}),
|
|
753
|
+
...(given.evidence !== undefined && given.evidence.length > 0 ? { evidence: given.evidence } : {}),
|
|
707
754
|
...(given.functionalities ? { functionalities: conflictRefs(given.functionalities) } : {}),
|
|
708
755
|
};
|
|
709
756
|
return { content: [{ type: "text", text: "Recorded." }], details: {}, terminate: true };
|
|
@@ -712,10 +759,27 @@ export async function judgeFailure(options) {
|
|
|
712
759
|
// It reads both, and changes neither: the file tools and the web - to look up what a service it calls answers -
|
|
713
760
|
// and no shell. It judges from the code, the tests, the spec and the failing run's output, which it is given.
|
|
714
761
|
const fence = confinedTools({ workdir: options.conformanceDir, read: [options.buildDir, options.conformanceDir], write: [], noBash: true });
|
|
762
|
+
// What it opens - read, listed, searched - is what its evidence can cite.
|
|
763
|
+
const openedPaths = new Set();
|
|
764
|
+
const tools = fence.tools.map((tool) => ({
|
|
765
|
+
...tool,
|
|
766
|
+
execute: (async (...args) => {
|
|
767
|
+
const path = args[1]?.path;
|
|
768
|
+
if (typeof path === "string" && path !== "")
|
|
769
|
+
openedPaths.add(resolve(options.conformanceDir, path));
|
|
770
|
+
return tool.execute(...args);
|
|
771
|
+
}),
|
|
772
|
+
}));
|
|
773
|
+
const opened = (cited) => {
|
|
774
|
+
// A file it opened, or one in a folder it listed or searched; cited as it was opened, or relative to the code or the tests.
|
|
775
|
+
const candidates = [resolve(options.conformanceDir, cited), resolve(options.buildDir, cited)];
|
|
776
|
+
return candidates.some((path) => [...openedPaths].some((seen) => path === seen || path.startsWith(`${seen}/`)));
|
|
777
|
+
};
|
|
778
|
+
const scriptText = options.testingScripts?.map((script) => script.content).join("\n");
|
|
715
779
|
const before = { code: digestFiles(options.buildDir), tests: digestFiles(options.conformanceDir) };
|
|
716
780
|
const session = await createExecutor({
|
|
717
781
|
cwd: options.conformanceDir,
|
|
718
|
-
customTools: [submit, ...createWebTools(), ...
|
|
782
|
+
customTools: [submit, ...createWebTools(), ...tools],
|
|
719
783
|
tools: [...fence.names, ...WEB_TOOLS, "submit_verdict"],
|
|
720
784
|
model: options.model,
|
|
721
785
|
label: `${options.label} judge`, role: "judge",
|
|
@@ -730,8 +794,15 @@ export async function judgeFailure(options) {
|
|
|
730
794
|
await ask(session, "You have not called submit_verdict. Call it now: code, tests, spec or environment, and why.", options.onDelta);
|
|
731
795
|
continue;
|
|
732
796
|
}
|
|
797
|
+
// Asked to weigh an escalation, a verdict of code or tests rests on what it found itself.
|
|
798
|
+
const unproven = options.escalation !== undefined && (verdict.culprit === "code" || verdict.culprit === "tests") ? unfoundedEvidence(verdict.evidence, opened) : undefined;
|
|
799
|
+
if (unproven !== undefined) {
|
|
800
|
+
verdict = undefined;
|
|
801
|
+
await ask(session, unproven, options.onDelta);
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
733
804
|
if (verdict.culprit === "code") {
|
|
734
|
-
const unfounded = unfoundedCodeVerdict(verdict, options.spec.text);
|
|
805
|
+
const unfounded = unfoundedCodeVerdict(verdict, options.spec.text, scriptText);
|
|
735
806
|
if (unfounded === undefined)
|
|
736
807
|
break;
|
|
737
808
|
verdict = undefined;
|
|
@@ -759,18 +830,26 @@ export async function judgeFailure(options) {
|
|
|
759
830
|
}
|
|
760
831
|
if (verdict?.culprit === "spec" && specConflictProblem(verdict.functionalities, options.scope) !== undefined)
|
|
761
832
|
verdict = undefined;
|
|
762
|
-
|
|
833
|
+
// An escalation weighed without evidence escalates nothing: the tests stay the conformance agent's, as before it.
|
|
834
|
+
if (options.escalation !== undefined && verdict?.culprit === "code" && unfoundedEvidence(verdict.evidence, opened) !== undefined) {
|
|
835
|
+
return { culprit: "tests", reason: `${verdict.reason}\n\nBut the judge gave no evidence it found itself that the code is what is wrong, so the tests are looked at again.` };
|
|
836
|
+
}
|
|
837
|
+
if (verdict?.culprit === "code" && unfoundedCodeVerdict(verdict, options.spec.text, scriptText) !== undefined) {
|
|
763
838
|
// Blaming the code takes the specification's own words; without them, the test asserts what the spec does not say.
|
|
764
839
|
return { culprit: "tests", reason: `${verdict.reason}\n\nBut no words of the specification say so: a test that asserts what the specification does not say is what is wrong. Check each failing assertion against the specification, and assert only what it says.` };
|
|
765
840
|
}
|
|
766
841
|
if (verdict?.culprit === "code" && verdict.specSays !== undefined) {
|
|
767
842
|
verdict = { ...verdict, reason: `${verdict.reason}\n\nThe specification says: "${verdict.specSays}"` };
|
|
768
843
|
}
|
|
844
|
+
if (verdict?.culprit === "code" && verdict.scriptNeeds !== undefined) {
|
|
845
|
+
verdict = { ...verdict, reason: `${verdict.reason}\n\nThe conformance testing script needs: "${verdict.scriptNeeds}"` };
|
|
846
|
+
}
|
|
769
847
|
// No verdict is no evidence the code is wrong: the tests are the newer work, and looked at first.
|
|
770
848
|
return verdict ?? { culprit: "tests", reason: "The judge gave no verdict that could be used; check the failing tests against the specification first." };
|
|
771
849
|
}
|
|
772
850
|
export function judgePrompt(options) {
|
|
773
851
|
const tested = options.scope.tested;
|
|
852
|
+
const scripts = options.testingScripts ?? [];
|
|
774
853
|
return [
|
|
775
854
|
specPrimer({ testReqs: true }),
|
|
776
855
|
`The conformance tests of the \`${options.spec.moduleName}\` specification failed. Decide what is wrong: the code, the tests, the specification itself, or the run's environment.`,
|
|
@@ -780,7 +859,9 @@ export function judgePrompt(options) {
|
|
|
780
859
|
"The specification is the judge, never either of them:",
|
|
781
860
|
"- tests: a test asserts something the specification does not say, misreads it, or reaches the software the",
|
|
782
861
|
" wrong way - a wrong command, route or setup (see <how to invoke it>).",
|
|
783
|
-
"- code: the code does not do what the specification says, and the test checks exactly that.",
|
|
862
|
+
"- code: the code does not do what the specification says, and the test checks exactly that. Or the run fails",
|
|
863
|
+
" on something the conformance testing script needs the code to provide - a build output, an entry point, a",
|
|
864
|
+
" module - and the code does not provide it: quote the script's own words for it in script_needs.",
|
|
784
865
|
" Only what the specification says counts: anything it leaves open - a name, coordinates, a version, a path,",
|
|
785
866
|
" a package, a port, a file layout - is what the code already has. The tests follow it, and it is never",
|
|
786
867
|
" code - changing the code to fit a test's guess moves what every other test points at.",
|
|
@@ -810,6 +891,19 @@ export function judgePrompt(options) {
|
|
|
810
891
|
"verdict of code without them is not taken. What the specification leaves open is never the code's fault: a test",
|
|
811
892
|
"that asserts it - a status code, a message, a format the specification does not name - is what is wrong.",
|
|
812
893
|
"",
|
|
894
|
+
...(scripts.length > 0
|
|
895
|
+
? [
|
|
896
|
+
"The conformance testing script below is how every run is made, and neither agent can change it: it is never",
|
|
897
|
+
"what is wrong. What it does with the tests, the tests have to fit; what it needs of the code - a file it runs or",
|
|
898
|
+
"checks for, a command it builds with - the code has to provide. A verdict that the script is wrong is neither",
|
|
899
|
+
"code nor tests: say which of the two has to change to get past it.",
|
|
900
|
+
"",
|
|
901
|
+
]
|
|
902
|
+
: []),
|
|
903
|
+
"<how to invoke it> is the explorer's account, written before the run, and it can be wrong - a folder, a path, a",
|
|
904
|
+
"command. Never rest a verdict on it: check each claim you use against the testing script and the files, and give",
|
|
905
|
+
"in `evidence` what you found yourself, each with where.",
|
|
906
|
+
"",
|
|
813
907
|
`You have ${JUDGE_CALLS} tool calls to decide in: read what the failure points at, not the whole codebase.`,
|
|
814
908
|
"",
|
|
815
909
|
"When the code and the tests could both be what is wrong, choose tests. When both are wrong, say which to fix",
|
|
@@ -832,11 +926,35 @@ export function judgePrompt(options) {
|
|
|
832
926
|
"",
|
|
833
927
|
`<how to invoke it>\n${options.invocation.trim()}\n</how to invoke it>`,
|
|
834
928
|
"",
|
|
929
|
+
...scripts.flatMap((script) => [`<conformance testing script path="${script.path}" run-as="${script.runs}">`, script.content.trimEnd(), "</conformance testing script>", ""]),
|
|
835
930
|
`<failing run exit="${options.run.exitCode}" command="${options.run.command}">`,
|
|
836
931
|
options.run.output.trimEnd(),
|
|
837
932
|
"</failing run>",
|
|
933
|
+
...(options.escalation !== undefined ? ["", ...escalationBlock(options.escalation)] : []),
|
|
838
934
|
].join("\n");
|
|
839
935
|
}
|
|
936
|
+
/** For the judge: the tests were fixed for this failure, and it did not go away - is it the code's? */
|
|
937
|
+
function escalationBlock(escalation) {
|
|
938
|
+
const changed = escalation.changed.length === 0 ? "changed no file" : `changed ${escalation.changed.slice(0, 8).join(", ")}${escalation.changed.length > 8 ? ` and ${escalation.changed.length - 8} more` : ""}`;
|
|
939
|
+
return [
|
|
940
|
+
"<escalation>",
|
|
941
|
+
"This failure was judged the tests' before, and the conformance agent - who can change only the tests - was sent to",
|
|
942
|
+
`fix it. It ${changed}, and the run fails the same way. What it was told:`,
|
|
943
|
+
"",
|
|
944
|
+
escalation.reason.trim(),
|
|
945
|
+
"",
|
|
946
|
+
"Decide again, now whether the failure belongs to the code rather than the tests. It is the code's when the run",
|
|
947
|
+
"fails on something the code is meant to produce - a build output, an entry point, a module the testing script needs",
|
|
948
|
+
"- or when the tests ran and what the software does is not what the specification says. It stays the tests' when a",
|
|
949
|
+
"test is still wrong and the code is right: say exactly what in the tests, so the next fix is not the last one again.",
|
|
950
|
+
"",
|
|
951
|
+
"Gather the evidence yourself: open the files the failure points at - the code's build configuration and what it",
|
|
952
|
+
"emits, the tests - and read the testing script. Give it in `evidence`: a verdict of code or tests without evidence",
|
|
953
|
+
"you found is not taken. The coding agent is handed your reason and evidence: say what the code has to provide, and",
|
|
954
|
+
"where it now falls short (e.g. `the script runs dist/cli.js; the build emits dist/src/cli.js`).",
|
|
955
|
+
"</escalation>",
|
|
956
|
+
];
|
|
957
|
+
}
|
|
840
958
|
/**
|
|
841
959
|
* The coding agent a conformance run sends the code to, when the judge says the code is wrong: it
|
|
842
960
|
* works in the code folder, may read the tests but never changes them, and keeps the unit
|
|
@@ -917,6 +1035,43 @@ export function codeFixPrompt(options) {
|
|
|
917
1035
|
"Fix the code, run the conformance tests and the unit tests, and finish.",
|
|
918
1036
|
].join("\n");
|
|
919
1037
|
}
|
|
1038
|
+
/** How much of the conformance tests an escalation hands the coding agent, whole: the rest it reads itself. */
|
|
1039
|
+
const ESCALATED_TESTS_CHARS = 24_000;
|
|
1040
|
+
/**
|
|
1041
|
+
* An escalated failure for the code fixer: the tests were fixed for it and it did not go away, and the judge found it
|
|
1042
|
+
* the code's. It is handed the conformance testing script's error, the tests as they stand and the judge's evidence.
|
|
1043
|
+
*/
|
|
1044
|
+
export function escalatedFixPrompt(options) {
|
|
1045
|
+
let room = ESCALATED_TESTS_CHARS;
|
|
1046
|
+
const shown = options.tests.flatMap((test) => {
|
|
1047
|
+
if (room <= 0)
|
|
1048
|
+
return [`<conformance test path="${test.path}" shown="no - read it yourself" />`];
|
|
1049
|
+
const content = test.content.length > room ? `${test.content.slice(0, room)}\n... (cut short - read the rest yourself)` : test.content;
|
|
1050
|
+
room -= content.length;
|
|
1051
|
+
return [`<conformance test path="${test.path}">`, content.trimEnd(), "</conformance test>"];
|
|
1052
|
+
});
|
|
1053
|
+
return [
|
|
1054
|
+
`The conformance tests failed (exit ${options.run.exitCode}). The conformance agent was sent to fix the tests for it, and`,
|
|
1055
|
+
"the run failed the same way. A judge then looked again, gathered the evidence below itself, and found that the",
|
|
1056
|
+
"failure belongs to the code:",
|
|
1057
|
+
"",
|
|
1058
|
+
`<why the code>\n${options.reason.trim()}\n</why the code>`,
|
|
1059
|
+
"",
|
|
1060
|
+
"<evidence>",
|
|
1061
|
+
...options.evidence.map((item) => `- ${item.source.trim()}: ${item.shows.trim()}`),
|
|
1062
|
+
"</evidence>",
|
|
1063
|
+
"",
|
|
1064
|
+
`<failing run command="${options.run.command}">`,
|
|
1065
|
+
options.run.output.trimEnd(),
|
|
1066
|
+
"</failing run>",
|
|
1067
|
+
"",
|
|
1068
|
+
"The conformance tests as they stand - not yours to change:",
|
|
1069
|
+
...shown,
|
|
1070
|
+
"",
|
|
1071
|
+
"Fix the code, run the conformance tests and the unit tests, and finish. This is the last attempt at this failure:",
|
|
1072
|
+
"if the run still fails the same way after your fix, the work stops.",
|
|
1073
|
+
].join("\n");
|
|
1074
|
+
}
|
|
920
1075
|
/** The run's environment judged what is wrong: the conformance agent checks the tests do not cause it, and changes nothing when they do not. */
|
|
921
1076
|
export function environmentCheckPrompt(options) {
|
|
922
1077
|
return [
|
|
@@ -16,7 +16,7 @@ import { createMyDiffTool } from "./self-check.js";
|
|
|
16
16
|
import { createServiceTools } from "./services.js";
|
|
17
17
|
import { missingToolchainError, toolchainError } from "./environment-error.js";
|
|
18
18
|
import { workspaceBlock } from "./workspace.js";
|
|
19
|
-
import { codeFixPrompt, conflictKey, createCodeFixer, answerAsExplorer, createAskExplorerTool, exploreInvocation, judgeFailure, SPEC_CONFLICT_RAISES, ENVIRONMENT_RAISES, environmentCheckPrompt, createTestReviewer, testReviewRequest, reviewFeedbackPrompt, testsFixPrompt, sortedRefs, } from "./conformance-agents.js";
|
|
19
|
+
import { codeFixPrompt, conflictKey, escalatedFixPrompt, createCodeFixer, answerAsExplorer, createAskExplorerTool, exploreInvocation, judgeFailure, SPEC_CONFLICT_RAISES, ENVIRONMENT_RAISES, environmentCheckPrompt, createTestReviewer, testReviewRequest, reviewFeedbackPrompt, testsFixPrompt, sortedRefs, } from "./conformance-agents.js";
|
|
20
20
|
import { resolveRequiresChain } from "./requires.js";
|
|
21
21
|
import { notesToRead, recordNote } from "./notes.js";
|
|
22
22
|
import { reportSpecConflict } from "./activity.js";
|
|
@@ -194,6 +194,11 @@ export async function renderConformanceTests(options) {
|
|
|
194
194
|
let environmentRaises = 0;
|
|
195
195
|
/** How many fixes in a row - the code's or the tests' - changed no file: an agent that will not, or cannot, act on the verdict. */
|
|
196
196
|
let idleFixes = 0;
|
|
197
|
+
/** The last fix was the tests', for a failure judged theirs: should the run fail the same way again, the judge weighs escalating it. */
|
|
198
|
+
let testsFix;
|
|
199
|
+
/** A failure escalated to the code: should the run still fail the same way after the code's fix, the run stops. */
|
|
200
|
+
let escalated;
|
|
201
|
+
let escalation;
|
|
197
202
|
/**
|
|
198
203
|
* One attempt at the failing run, written into the notes' pitfalls - what the next judge reads
|
|
199
204
|
* first - as cause and effect: what failed, how it was judged, and what was changed; the next
|
|
@@ -414,6 +419,15 @@ export async function renderConformanceTests(options) {
|
|
|
414
419
|
abandonWork(start, moduleName, "the conformance testing script could not run");
|
|
415
420
|
throw lacking;
|
|
416
421
|
}
|
|
422
|
+
// A failure escalated to the code, still the same after the code's fix: neither agent could fix it, and the run stops
|
|
423
|
+
// with why the judge found it the code's. Anything else it fails on is a new failure, judged as any other.
|
|
424
|
+
if (escalated !== undefined && recheck.exitCode !== 0 && failureSignature(recheck) === escalated.signature) {
|
|
425
|
+
escalation = { reason: escalated.reason, evidence: escalated.evidence, error: failureLine(recheck), failure: truncateOutput(recheck.output, undefined).slice(-4000) };
|
|
426
|
+
stage("the failure escalated to the code is still there after the coding agent's fix - stopping");
|
|
427
|
+
recordNote(conformanceDir, "pitfalls", `stopped after ${rounds} attempts: failed with ${failureLine(recheck)}, escalated to the code - ${escalated.reason.replace(/\s+/g, " ").slice(0, 240)} - and the code's fix did not change it`, `${label} conformance`);
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
escalated = undefined;
|
|
417
431
|
if (recheck.exitCode !== 0) {
|
|
418
432
|
// Nothing changing: the same failure, taken the same way, round after round. Once, the explorer
|
|
419
433
|
// is asked what the tests lack - most often how they reach the software; after that, it stops.
|
|
@@ -504,6 +518,12 @@ export async function renderConformanceTests(options) {
|
|
|
504
518
|
recordNote(conformanceDir, "pitfalls", `stuck after ${rounds} attempts: failed with ${failureLine(recheck)}, and ${IDLE_FIXES} fixes in a row changed nothing`, `${label} conformance`);
|
|
505
519
|
return true;
|
|
506
520
|
};
|
|
521
|
+
// The tests were fixed for this very failure, and it is still here: the judge weighs whether it is the code's.
|
|
522
|
+
const signature = failureSignature(recheck);
|
|
523
|
+
const escalating = testsFix !== undefined && testsFix.signature === signature ? { reason: testsFix.reason, changed: testsFix.files } : undefined;
|
|
524
|
+
testsFix = undefined;
|
|
525
|
+
if (escalating !== undefined)
|
|
526
|
+
stage("the tests' fix did not make the failure go away - the judge weighs whether it is the code's");
|
|
507
527
|
const verdict = await judgeFailure({
|
|
508
528
|
buildDir,
|
|
509
529
|
conformanceDir,
|
|
@@ -513,6 +533,9 @@ export async function renderConformanceTests(options) {
|
|
|
513
533
|
scope,
|
|
514
534
|
// The tests' notes - every attempt so far - and the code's: it weighs the one against the other.
|
|
515
535
|
notes: [notesToRead(conformanceDir), notesToRead(buildDir)].filter((part) => part !== "").join("\n\n"),
|
|
536
|
+
// The script itself, so the judge checks the explorer's account against it rather than repeating it.
|
|
537
|
+
testingScripts,
|
|
538
|
+
...(escalating !== undefined ? { escalation: escalating } : {}),
|
|
516
539
|
label,
|
|
517
540
|
model: judgeModel,
|
|
518
541
|
onDelta: options.onDelta,
|
|
@@ -585,15 +608,34 @@ export async function renderConformanceTests(options) {
|
|
|
585
608
|
recordAttempt(rounds, recheck, `a spec conflict between functionalities ${key}: ${verdict.reason}`, { who: "conformance agent", files: fixed.files });
|
|
586
609
|
}
|
|
587
610
|
else if (verdict.culprit === "code") {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
611
|
+
if (escalating !== undefined) {
|
|
612
|
+
// Escalated: the coding agent is handed the script's error, the tests as they stand and the judge's evidence.
|
|
613
|
+
// Its fix is the last attempt at this failure - should the run fail the same way after it, the run stops.
|
|
614
|
+
const evidence = verdict.evidence ?? [];
|
|
615
|
+
reportActivity(` escalated to the code: ${reason}`);
|
|
616
|
+
stage("the judge finds the failure the code's - escalated to the coding agent");
|
|
617
|
+
const tests = listFiles(conformanceDir).map((path) => ({ path, content: readFileSync(join(conformanceDir, path), "utf8") }));
|
|
618
|
+
const fixed = await changing(() => askFixer(escalatedFixPrompt({ run: recheck, reason: verdict.reason, evidence, tests })));
|
|
619
|
+
recordAttempt(rounds, recheck, `escalated to the code: ${verdict.reason}`, { who: "coding agent", files: fixed.files });
|
|
620
|
+
if (fixed.files.length === 0) {
|
|
621
|
+
escalation = { reason: verdict.reason, evidence, error: failureLine(recheck), failure: truncateOutput(recheck.output, undefined).slice(-4000), changedNothing: true };
|
|
622
|
+
stage("the coding agent changed nothing for the failure escalated to it - stopping");
|
|
623
|
+
break;
|
|
624
|
+
}
|
|
625
|
+
idleFixes = 0;
|
|
626
|
+
escalated = { signature, reason: verdict.reason, evidence };
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
reportActivity(` the code: ${reason}`);
|
|
630
|
+
stage("the code is wrong - the coding agent fixes it");
|
|
631
|
+
// One fixer for the run, starting each fix from what the ones before it tried: its work is compacted as
|
|
632
|
+
// it gets longer, so a session carried through the run does not re-read every earlier fix and suite output
|
|
633
|
+
// whole.
|
|
634
|
+
const fixed = await changing(() => askFixer(codeFixPrompt({ run: recheck, reason: verdict.reason })));
|
|
635
|
+
recordAttempt(rounds, recheck, `the code wrong: ${verdict.reason}`, { who: "coding agent", files: fixed.files });
|
|
636
|
+
if (idle(fixed.files))
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
597
639
|
// The fix has to build and pass its own unit tests before the suite runs again: a build
|
|
598
640
|
// error is the coding agent's to fix, not a judge's to work out from the suite's output.
|
|
599
641
|
for (let again = 0; again < FIX_UNIT_CHECKS; again += 1) {
|
|
@@ -613,7 +655,11 @@ export async function renderConformanceTests(options) {
|
|
|
613
655
|
const fixed = await changing(() => askConformance(testsFixPrompt({ run: recheck, reason: verdict.reason }), options.onDelta));
|
|
614
656
|
response = fixed.result;
|
|
615
657
|
recordAttempt(rounds, recheck, `the tests wrong: ${verdict.reason}`, { who: "conformance agent", files: fixed.files });
|
|
616
|
-
|
|
658
|
+
testsFix = { signature, reason: verdict.reason, files: fixed.files };
|
|
659
|
+
// A fix that changed nothing does not stop the run before the judge has weighed escalating the failure.
|
|
660
|
+
if (escalating === undefined)
|
|
661
|
+
idleFixes = fixed.files.length === 0 ? idleFixes + 1 : 0;
|
|
662
|
+
else if (idle(fixed.files))
|
|
617
663
|
break;
|
|
618
664
|
}
|
|
619
665
|
continue;
|
|
@@ -772,6 +818,7 @@ export async function renderConformanceTests(options) {
|
|
|
772
818
|
...(specConflict !== undefined ? { specConflict } : {}),
|
|
773
819
|
...(stuck !== undefined ? { stuck } : {}),
|
|
774
820
|
...(environment !== undefined ? { environment } : {}),
|
|
821
|
+
...(escalation !== undefined ? { escalation } : {}),
|
|
775
822
|
...(unlabelled !== undefined ? { unlabelled } : {}),
|
|
776
823
|
...(reviewWaived !== undefined ? { reviewWaived } : {}),
|
|
777
824
|
...(verified && green !== undefined ? { green } : {}),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
2
3
|
import { basename, dirname, isAbsolute, sep } from "node:path";
|
|
3
4
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import { Type } from "typebox";
|
|
@@ -35,7 +36,7 @@ export class EnvironmentError extends Error {
|
|
|
35
36
|
sandbox;
|
|
36
37
|
constructor(details) {
|
|
37
38
|
super(details.sandbox !== undefined
|
|
38
|
-
? `${details.moduleName} stopped: turri's sandbox kept its agents from ${details.sandbox.paths[0]}, which is on this machine.`
|
|
39
|
+
? `${details.moduleName} stopped: turri's sandbox kept its agents from ${details.sandbox.access === "write" ? "writing" : "reading"} ${details.sandbox.paths[0]}, which is on this machine.`
|
|
39
40
|
: `${details.moduleName} stopped: the environment lacks ${details.missing}.`);
|
|
40
41
|
this.name = "EnvironmentError";
|
|
41
42
|
this.moduleName = details.moduleName;
|
|
@@ -50,6 +51,10 @@ export class EnvironmentError extends Error {
|
|
|
50
51
|
this.sandbox = details.sandbox;
|
|
51
52
|
}
|
|
52
53
|
}
|
|
54
|
+
/** A file's name that says it is a lock: `lockfile`, `x.lock`, `x.lck`, `.lock-x`, `x.lock.y`. */
|
|
55
|
+
const LOCK_FILE = /(?:^|[._-])(?:lock|lck|lockfile)(?:$|[._-])/i;
|
|
56
|
+
/** What says the sandbox refused a write, not a read: writing, creating, a lock not taken - not a name with "lock" in it. */
|
|
57
|
+
const WRITE_REFUSED = /\b(?:writ(?:e|es|ing|able)|creat(?:e|es|ing)|acquir(?:e|ing)|(?:take|obtain|get) (?:a |the )?lock|locking|read-only file system)\b/i;
|
|
53
58
|
/** A folder named for a version - `24.16.0`, `v22.3.0`, `3.12.4` - the root of a version manager's install. */
|
|
54
59
|
const VERSION = /^v?\d+\.\d+/;
|
|
55
60
|
/** The parts of an install below its root, where the root ends: `…/24.16.0/lib/node_modules/…` ends before `lib`. */
|
|
@@ -74,27 +79,59 @@ export function sandboxGapOf(text, options = {}) {
|
|
|
74
79
|
const system = (options.platform ?? process.platform) === "darwin" ? MACOS_MACHINE : LINUX_MACHINE;
|
|
75
80
|
const within = (path, root) => path === root || path.startsWith(`${root}/`);
|
|
76
81
|
const named = [...text.matchAll(/(?:^|[\s'"`(=:])(\/[^\s'"`()<>,;]+)/g)].map((match) => match[1].replace(/[.:]+$/, ""));
|
|
77
|
-
const
|
|
78
|
-
|
|
82
|
+
const isLock = (path) => LOCK_FILE.test(basename(path));
|
|
83
|
+
// A lock file the tool could not create is not there: its folder is.
|
|
84
|
+
const found = [...new Set(named)].filter((path) => path.split("/").length > 3 &&
|
|
85
|
+
(exists(path) || (isLock(path) && exists(dirname(path)))) &&
|
|
86
|
+
!system.some((root) => within(path, root)) &&
|
|
87
|
+
(options.project === undefined || !within(path, options.project)));
|
|
88
|
+
if (found.length === 0)
|
|
89
|
+
return undefined;
|
|
90
|
+
// The lock first: it is what the build could not take.
|
|
91
|
+
const paths = [...found.filter(isLock), ...found.filter((path) => !isLock(path))];
|
|
92
|
+
const lock = isLock(paths[0]);
|
|
93
|
+
return { paths, folder: toolchainFolder(paths[0]), access: lock || WRITE_REFUSED.test(text) ? "write" : "read", ...(lock ? { lock } : {}) };
|
|
79
94
|
}
|
|
80
95
|
/**
|
|
81
96
|
* What the person is told, in the chat, at the end of `turri regenerate`, and in the job's log. `rerun`: the command that runs
|
|
82
97
|
* the work again, as the pulse knows it - the spec the person ran; without it, the module's own spec by its name.
|
|
83
98
|
*/
|
|
84
99
|
export function describeEnvironmentError(error, rerun) {
|
|
85
|
-
|
|
100
|
+
const again = `Then re-run: \`${rerun ?? `turri regenerate ${error.moduleName}.plain`}\`, or say "resume" in the chat.`;
|
|
101
|
+
const needed = `Needed by: ${error.neededBy}${error.shownBy !== undefined ? ` (${error.shownBy})` : ""}.`;
|
|
102
|
+
const gap = error.sandbox;
|
|
103
|
+
if (gap?.access === "write") {
|
|
104
|
+
const what = gap.lock === true ? `the lock file \`${gap.paths[0]}\`, which the build takes as it runs` : `\`${gap.paths[0]}\``;
|
|
105
|
+
const home = homedir();
|
|
106
|
+
const inHome = gap.paths[0] === home || gap.paths[0].startsWith(`${home}/`);
|
|
107
|
+
return [
|
|
108
|
+
`**${error.moduleName} stopped: turri's sandbox kept its agents from writing ${what} - it is on this machine; nothing is missing, and nothing needs installing.**`,
|
|
109
|
+
needed,
|
|
110
|
+
"An agent's commands write only their module's folders, their own home and temporary folder, and the package caches turri " +
|
|
111
|
+
"points the build tools at - no setting lets them write anywhere else, and `toolchains:` lets them read a folder, not write it.",
|
|
112
|
+
inHome
|
|
113
|
+
? "To fix: it is in your home, not the agents' own - the tool found your home without `$HOME`, as a JVM does for `user.home`, " +
|
|
114
|
+
"or a setting of yours points it there. Have it use `$HOME` instead - for a JVM tool, `-Duser.home=$HOME` where the build " +
|
|
115
|
+
"starts its JVM - and the agents' own home takes it."
|
|
116
|
+
: `To fix: it is in a toolchain's own install, \`${gap.folder}\`, which the build writes into as it runs - and the agents may ` +
|
|
117
|
+
"not change a toolchain. Have the build keep it elsewhere if the tool has a setting for it, or install a copy of the " +
|
|
118
|
+
"toolchain that keeps its writes in its cache.",
|
|
119
|
+
again,
|
|
120
|
+
].join("\n");
|
|
121
|
+
}
|
|
122
|
+
if (gap !== undefined) {
|
|
86
123
|
return [
|
|
87
|
-
`**${error.moduleName} stopped: turri's sandbox kept its agents from reading \`${
|
|
88
|
-
|
|
89
|
-
`To fix: let the agents read that toolchain - add its folder to \`toolchains:\` in config.yaml, keeping what is there: \`toolchains: [${
|
|
90
|
-
|
|
124
|
+
`**${error.moduleName} stopped: turri's sandbox kept its agents from reading \`${gap.paths[0]}\` - it is on this machine; nothing is missing.**`,
|
|
125
|
+
needed,
|
|
126
|
+
`To fix: let the agents read that toolchain - add its folder to \`toolchains:\` in config.turri.yaml, keeping what is there: \`toolchains: [${gap.folder}]\`. Nothing needs installing.`,
|
|
127
|
+
again,
|
|
91
128
|
].join("\n");
|
|
92
129
|
}
|
|
93
130
|
return [
|
|
94
131
|
`**${error.moduleName} stopped: the environment lacks ${error.missing}.**`,
|
|
95
132
|
`Needed by: ${error.neededBy}${error.shownBy !== undefined ? ` (${error.shownBy})` : ""}.`,
|
|
96
133
|
`To fix: ${error.fix ?? "provide it on this machine, then run it again."}`,
|
|
97
|
-
|
|
134
|
+
again,
|
|
98
135
|
].join("\n");
|
|
99
136
|
}
|
|
100
137
|
/** The name agents know the tool by - their wall text names it. */
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { HStack } from "@earendil-works/pi-tui";
|
|
2
|
+
/**
|
|
3
|
+
* The side panel beside the chat rather than over it: the chat drawn on the left in what the panel leaves it, the panel
|
|
4
|
+
* laid over the columns on the right.
|
|
5
|
+
*
|
|
6
|
+
* pi draws its screen one of two ways, and the chat is narrowed in each as it is drawn: full screen (pi's default), a
|
|
7
|
+
* layout - its root put in a row beside a filler as wide as the panel; the regular screen, the lines its render gives -
|
|
8
|
+
* asked for in fewer columns.
|
|
9
|
+
*/
|
|
10
|
+
/** Narrower than this, the chat and the panel have no room side by side: the panel takes the whole screen. */
|
|
11
|
+
const SHARED_FROM = 100;
|
|
12
|
+
/** The columns the panel takes beside the chat: half the screen, the rule between them in the middle - 0 when the terminal is too narrow to share. */
|
|
13
|
+
export function dockedWidth(columns) {
|
|
14
|
+
return columns < SHARED_FROM ? 0 : Math.floor(columns / 2);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The chat narrowed by `panelWidth()` columns, read at each frame - 0 gives it the whole width. Returns what gives it the
|
|
18
|
+
* whole width back for good.
|
|
19
|
+
*/
|
|
20
|
+
export function narrowChat(tui, panelWidth) {
|
|
21
|
+
const screen = tui;
|
|
22
|
+
if (typeof screen.setLayoutRoot === "function" && screen.layoutRoot !== undefined) {
|
|
23
|
+
const root = screen.layoutRoot;
|
|
24
|
+
// A line as wide as the panel: the row gives the filler what it measures, the chat the rest.
|
|
25
|
+
const filler = { render: () => [" ".repeat(panelWidth())], invalidate() { } };
|
|
26
|
+
screen.setLayoutRoot(new HStack([{ component: root, basis: 0, grow: 1 }, { component: filler }]));
|
|
27
|
+
return () => screen.setLayoutRoot(root);
|
|
28
|
+
}
|
|
29
|
+
// Read from the prototype: the screen's own render, not one laid over it.
|
|
30
|
+
const render = Object.getPrototypeOf(tui).render;
|
|
31
|
+
screen.render = function (width) {
|
|
32
|
+
return render.call(this, Math.max(1, width - panelWidth()));
|
|
33
|
+
};
|
|
34
|
+
return () => {
|
|
35
|
+
screen.render = render;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A press of a mouse button, as pi's full screen takes it - `side` told whether it was in the chat, left of `chatWidth()`
|
|
40
|
+
* columns, or in the panel - before the press goes on as it would have. Motion and the wheel are not presses. pi's
|
|
41
|
+
* regular screen reads no mouse: nothing is done there. Returns what takes the watch off.
|
|
42
|
+
*/
|
|
43
|
+
export function clickToFocus(tui, chatWidth, side) {
|
|
44
|
+
const screen = tui;
|
|
45
|
+
const handle = screen.handleMouseEvent;
|
|
46
|
+
if (typeof handle !== "function")
|
|
47
|
+
return () => { };
|
|
48
|
+
screen.handleMouseEvent = function (raw) {
|
|
49
|
+
// 32: motion; 64: the wheel.
|
|
50
|
+
if (raw.release !== true && (raw.button & (32 | 64)) === 0)
|
|
51
|
+
side(raw.x < chatWidth() ? "chat" : "panel");
|
|
52
|
+
return handle.call(this, raw);
|
|
53
|
+
};
|
|
54
|
+
return () => {
|
|
55
|
+
screen.handleMouseEvent = handle;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A line drawn faint - the side that does not have the keys, in the shade. Faint (SGR 2) holds through a line's colours;
|
|
60
|
+
* only a reset - SGR 0, or 22 - ends it, so it is set again after each one. A line holding an image is left as it is.
|
|
61
|
+
*/
|
|
62
|
+
export function shade(line) {
|
|
63
|
+
// Kitty's image escape, or iTerm2's.
|
|
64
|
+
if (line.includes("\x1b_G") || line.includes("\x1b]1337;File="))
|
|
65
|
+
return line;
|
|
66
|
+
const again = line.replace(/\x1b\[([\d;]*)m/g, (sgr, params) => (params.split(";").some((param) => param === "" || param === "0" || param === "22") ? `${sgr}${FAINT}` : sgr));
|
|
67
|
+
return `${FAINT}${again}${NOT_FAINT}`;
|
|
68
|
+
}
|
|
69
|
+
const FAINT = "\x1b[2m";
|
|
70
|
+
const NOT_FAINT = "\x1b[22m";
|
|
71
|
+
/**
|
|
72
|
+
* The chat in the shade while `shaded()`: each of its rows drawn faint as the screen lays the overlays over it - the
|
|
73
|
+
* docked panel the last of them, which covers its own columns. Returns what takes the shade off.
|
|
74
|
+
*/
|
|
75
|
+
export function shadeChat(tui, shaded) {
|
|
76
|
+
const screen = tui;
|
|
77
|
+
const composite = screen.compositeLineAt;
|
|
78
|
+
if (typeof composite !== "function")
|
|
79
|
+
return () => { };
|
|
80
|
+
screen.compositeLineAt = function (base, overlay, col, width, total) {
|
|
81
|
+
return composite.call(this, shaded() ? shade(base) : base, overlay, col, width, total);
|
|
82
|
+
};
|
|
83
|
+
return () => {
|
|
84
|
+
screen.compositeLineAt = composite;
|
|
85
|
+
};
|
|
86
|
+
}
|