@turri-dev/cli 0.1.2 → 0.1.3-next.162a2c3
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/chat-skills.js +1 -1
- package/dist/cli-options.js +82 -0
- package/dist/config.js +26 -3
- package/dist/conform-cli.js +13 -5
- package/dist/environment.js +19 -0
- package/dist/extension.js +174 -62
- package/dist/identity.js +1 -1
- package/dist/init-config.js +3 -3
- package/dist/loop-cli.js +157 -29
- package/dist/loop-tools.js +1 -1
- package/dist/pi-launch.js +18 -4
- package/dist/pi.js +14 -2
- package/dist/picture-row.js +21 -0
- package/dist/plain/errors.js +1 -1
- package/dist/plain/files.js +1 -1
- package/dist/plain/liquid.js +2 -2
- package/dist/question-dialog.js +5 -1
- package/dist/regenerate/activity.js +19 -0
- package/dist/regenerate/confine.js +1 -0
- package/dist/regenerate/conformance-agents.js +220 -11
- package/dist/regenerate/conformance.js +87 -33
- package/dist/regenerate/debugger.js +27 -3
- package/dist/regenerate/environment-error.js +63 -9
- package/dist/regenerate/loop.js +78 -6
- package/dist/regenerate/panel-dock.js +86 -0
- package/dist/regenerate/prompt.js +4 -2
- package/dist/regenerate/regenerate.js +24 -6
- package/dist/regenerate/release.js +8 -6
- package/dist/regenerate/requires.js +55 -2
- package/dist/regenerate/side-panel.js +14 -4
- package/dist/regenerate/stop-report.js +13 -0
- package/dist/regenerate/test-scripts.js +45 -9
- package/dist/regenerate/unit-review.js +11 -0
- package/dist/regenerate-cli.js +24 -29
- package/dist/resume-line.js +4 -0
- package/dist/run-report.js +289 -0
- package/dist/setup.js +35 -16
- package/dist/spec-diff-cli.js +17 -6
- package/dist/spec-tools.js +8 -44
- package/docs/config.md +25 -13
- package/docs/development.md +7 -0
- package/docs/headless.md +170 -16
- package/docs/installation.md +1 -1
- package/docs/parser.md +1 -1
- package/docs/regenerating.md +72 -17
- package/docs/using.md +16 -5
- package/genome/skills/implement-conformance-testing-script/SKILL.md +1 -1
- package/package.json +3 -3
|
@@ -4,6 +4,7 @@ import { Type } from "typebox";
|
|
|
4
4
|
import { ask, BEGIN, createExecutor } from "../pi.js";
|
|
5
5
|
import { confinedTools } from "./confine.js";
|
|
6
6
|
import { createWebTools, WEB_TOOLS } from "./web.js";
|
|
7
|
+
import { runTiming } from "./test-scripts.js";
|
|
7
8
|
/**
|
|
8
9
|
* A fresh look at a failure the coding agent keeps hitting.
|
|
9
10
|
*
|
|
@@ -24,9 +25,12 @@ export function diagnosisText(diagnosis) {
|
|
|
24
25
|
}
|
|
25
26
|
function debugPrompt(input) {
|
|
26
27
|
const script = input.script !== undefined && existsSync(input.script) ? readFileSync(input.script, "utf8") : undefined;
|
|
28
|
+
const timedOut = input.run?.timedOut === true;
|
|
27
29
|
return [
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
timedOut
|
|
31
|
+
? `The unit testing script of the codebase in ${input.buildDir} did not finish: the time limit stopped it. You look at it with fresh eyes.`
|
|
32
|
+
: `The unit tests of the codebase in ${input.buildDir} have failed the same way ${input.times} times in a row. The coding agent`,
|
|
33
|
+
...(timedOut ? [] : ["keeps changing things and the failure does not move: it has not seen the cause. You look at it with fresh eyes."]),
|
|
30
34
|
"",
|
|
31
35
|
"Find the root cause - the one thing that makes this failure happen - and the change that fixes it. Read the failing",
|
|
32
36
|
"run below, the tests it names, the code they exercise, the build files (pyproject.toml, package.json, go.mod, pom.xml,",
|
|
@@ -37,6 +41,21 @@ function debugPrompt(input) {
|
|
|
37
41
|
"",
|
|
38
42
|
"You change nothing - you only read. When you know, call submit_diagnosis: the root cause, with the evidence for it,",
|
|
39
43
|
"and the fix - which files, and exactly what to change in them.",
|
|
44
|
+
...(timedOut
|
|
45
|
+
? [
|
|
46
|
+
"",
|
|
47
|
+
"<timed out>",
|
|
48
|
+
"The run's output ends where the time limit stopped it: the test running then, or the step after the last one it",
|
|
49
|
+
"printed, is what did not finish. Find what kept it:",
|
|
50
|
+
"- something hangs - a test or the code waits on input, keeps a server in the foreground, never exits, loops, blocks",
|
|
51
|
+
" on a lock or a network call that never answers: the root cause and the fix, as for any failure;",
|
|
52
|
+
"- nothing hangs - the output shows the run making steady progress right up to the limit (installing, building, test",
|
|
53
|
+
" after test passing) and it needs more time than a test run may take: set too_slow, and say in root_cause from the",
|
|
54
|
+
" output how far it got and what took the time. No agent can give it more time: the work stops, and the person is",
|
|
55
|
+
" told to raise the limit. Judge from the output and the files, never from the time alone.",
|
|
56
|
+
"</timed out>",
|
|
57
|
+
]
|
|
58
|
+
: []),
|
|
40
59
|
"",
|
|
41
60
|
"<what the change is meant to do>",
|
|
42
61
|
input.task.trim(),
|
|
@@ -44,6 +63,7 @@ function debugPrompt(input) {
|
|
|
44
63
|
...(script !== undefined ? ["", `<testing script path="${input.script}">`, script.trimEnd(), "</testing script>"] : []),
|
|
45
64
|
"",
|
|
46
65
|
"<failing run>",
|
|
66
|
+
...(input.run !== undefined ? [runTiming(input.run), ""] : []),
|
|
47
67
|
input.failure.trimEnd().slice(-8000),
|
|
48
68
|
"</failing run>",
|
|
49
69
|
].join("\n");
|
|
@@ -58,10 +78,14 @@ export async function debugFailure(input) {
|
|
|
58
78
|
parameters: Type.Object({
|
|
59
79
|
root_cause: Type.String({ description: "The one thing that makes the failure happen, and the evidence for it: the lines of the run, the code, the docs" }),
|
|
60
80
|
fix: Type.String({ description: "The change that fixes it: which files, and exactly what to change in them" }),
|
|
81
|
+
too_slow: Type.Optional(Type.Boolean({
|
|
82
|
+
description: "Only for a run the time limit stopped: true when nothing hangs - it was making progress up to the limit and needs more time than a test run may take",
|
|
83
|
+
})),
|
|
61
84
|
}),
|
|
62
85
|
async execute(_id, params) {
|
|
63
86
|
const given = params;
|
|
64
|
-
|
|
87
|
+
// Too slow is a verdict on a run the limit stopped, and on no other.
|
|
88
|
+
found = { rootCause: given.root_cause, fix: given.fix, ...(given.too_slow === true && input.run?.timedOut === true ? { tooSlow: true } : {}) };
|
|
65
89
|
return { content: [{ type: "text", text: "Recorded." }], details: {}, terminate: true };
|
|
66
90
|
},
|
|
67
91
|
});
|
|
@@ -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}/`);
|
|
86
107
|
return [
|
|
87
|
-
`**${error.moduleName} stopped: turri's sandbox kept its agents from
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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) {
|
|
123
|
+
return [
|
|
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. */
|
|
@@ -187,6 +224,23 @@ export function toolchainError(missing, details) {
|
|
|
187
224
|
source: "harness",
|
|
188
225
|
});
|
|
189
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* A testing script the time limit stopped while it was still making progress - judged so by the judge or the debugger,
|
|
229
|
+
* from where its output stops: nothing hangs, the suite needs more time than a test run may take. No agent can give it
|
|
230
|
+
* that, so the pulse stops and the person is told how to.
|
|
231
|
+
*/
|
|
232
|
+
export function timeLimitError(run, details) {
|
|
233
|
+
const limit = Math.round(run.timeoutMs / 1000);
|
|
234
|
+
return new EnvironmentError({
|
|
235
|
+
moduleName: details.moduleName,
|
|
236
|
+
missing: `more time for a test run than its ${limit}s limit`,
|
|
237
|
+
neededBy: `the ${details.kind} testing script, \`${basename(details.script)}\``,
|
|
238
|
+
shownBy: `it ran ${Math.round((run.elapsedMs ?? run.timeoutMs) / 1000)}s and was stopped at the limit, still making progress - ${details.reason.replace(/\s+/g, " ").trim().slice(0, 400)}`,
|
|
239
|
+
fix: `give a test run more time - set TURRI_COMMAND_TIMEOUT, in seconds, above ${limit} (e.g. TURRI_COMMAND_TIMEOUT=${limit * 3}) in the ` +
|
|
240
|
+
"environment turri runs in - or make the suite faster: what it installs or builds on every run, and the tests that take longest.",
|
|
241
|
+
source: "agent",
|
|
242
|
+
});
|
|
243
|
+
}
|
|
190
244
|
/** The harness's run of a testing script, when it could not find its toolchain: the error that stops the pulse. */
|
|
191
245
|
export function missingToolchainError(run, details) {
|
|
192
246
|
const missing = missingToolchain(run);
|
package/dist/regenerate/loop.js
CHANGED
|
@@ -16,7 +16,7 @@ import { modulesRoot } from "./paths.js";
|
|
|
16
16
|
import { budgetFor, choosingWhen, configForSpec, conformanceSwitchedOn } from "../config.js";
|
|
17
17
|
import { describeSpecChange } from "./prompt.js";
|
|
18
18
|
import { resolveTemplateDirs } from "./paths.js";
|
|
19
|
-
import { builtTips, copyRequiredCode, newlyRequired, resolveRequiresChain, startFromScratch } from "./requires.js";
|
|
19
|
+
import { builtTips, copyRequiredCode, importingModules, isImportModule, newlyRequired, resolveRequiresChain, startFromScratch } from "./requires.js";
|
|
20
20
|
import { withTryItLine } from "./delivery.js";
|
|
21
21
|
import { describeSpecDiff, renderSpecDiff, specFilesOf } from "./regenerate.js";
|
|
22
22
|
import { locateDiffLines } from "./diff-view.js";
|
|
@@ -85,10 +85,82 @@ function chainsFor(plainFile) {
|
|
|
85
85
|
const tips = builtTips(plainFile);
|
|
86
86
|
return tips.length > 0 ? tips : [resolveRequiresChain(plainFile, [], undefined, { allowNoFunctionalities: true })];
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* The modules a .plain file is built as: itself - or, an import module, the modules importing it whose code is started.
|
|
90
|
+
* An import module is no codebase; one no started module imports has nothing to build yet - its reqs are built with the
|
|
91
|
+
* first module that imports it.
|
|
92
|
+
*/
|
|
93
|
+
function codebasesOf(plainFile) {
|
|
94
|
+
if (!isImportModule(plainFile))
|
|
95
|
+
return [plainFile];
|
|
96
|
+
return importingModules(plainFile).filter((file) => {
|
|
97
|
+
try {
|
|
98
|
+
return builtTips(file).length > 0 || started(resolveRequiresChain(file, [], undefined, { allowNoFunctionalities: true }).at(-1));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
88
105
|
/** How many functional specs of its own a codebase has built - none when it was only started from the code below it. */
|
|
89
106
|
function ownBuilt(entry) {
|
|
90
107
|
return (readRecord(entry.buildDir)?.[FUNCTIONAL_REQUIREMENTS] ?? []).length;
|
|
91
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* The functional specs of a spec's whole requires chain, completed or not, in the codebase each chain delivers: its
|
|
111
|
+
* furthest-built module's code - which holds the code of every module below it. A functional spec is completed when that
|
|
112
|
+
* codebase's code record, as last committed, holds it as the spec has it now - the same text, the linked files it uses
|
|
113
|
+
* holding the same, no definition it uses changed, not marked to regenerate again. A record moves only when a pulse
|
|
114
|
+
* lands, its unit tests passing (and its conformance tests, when they are on), so completed is landed and passing.
|
|
115
|
+
* The modules above the delivered codebase have nothing completed. One entry per chain; planning nothing, calling no model.
|
|
116
|
+
*/
|
|
117
|
+
export function functionalityCompletion(plainFile) {
|
|
118
|
+
return withParseCache(() => chainsFor(resolve(plainFile)).map((chain) => {
|
|
119
|
+
let delivered = -1;
|
|
120
|
+
chain.forEach((entry, index) => {
|
|
121
|
+
if (readRecord(entry.buildDir) !== null)
|
|
122
|
+
delivered = index;
|
|
123
|
+
});
|
|
124
|
+
const counts = chain.map((entry) => ({ module: entry.moduleName, specified: entry.frids.length, completed: 0, conformanceTested: 0, incomplete: [...entry.frids] }));
|
|
125
|
+
if (delivered === -1)
|
|
126
|
+
return { codebase: tipOf(chain).buildDir, modules: counts };
|
|
127
|
+
const codebase = chain.slice(0, delivered + 1);
|
|
128
|
+
const tip = tipOf(codebase);
|
|
129
|
+
const spec = chainSpec(codebase).tree;
|
|
130
|
+
const record = readRecord(tip.buildDir);
|
|
131
|
+
const changes = codeWork(record, spec);
|
|
132
|
+
// Its own: what the work loop would regenerate - new, changed, a concept it uses changed, or marked again.
|
|
133
|
+
const cases = casesOf(codeTree(record ?? {}), changes, { module: tip.moduleName });
|
|
134
|
+
const touched = new Set([...cases.add, ...cases.patch, ...cases.again].map((spec) => `${spec.module ?? tip.moduleName}:${spec.frid}`));
|
|
135
|
+
// The modules' below it: an item the record does not have as the spec has it.
|
|
136
|
+
for (const change of changes) {
|
|
137
|
+
if (change.section !== REQUIRED_FUNCTIONAL_SPECS || change.after === undefined)
|
|
138
|
+
continue;
|
|
139
|
+
try {
|
|
140
|
+
const item = JSON.parse(change.after);
|
|
141
|
+
if (item.module !== undefined && item.frid !== undefined)
|
|
142
|
+
touched.add(`${item.module}:${item.frid}`);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
/* not an item */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const specText = (module, frid) => {
|
|
149
|
+
const own = module === tip.moduleName;
|
|
150
|
+
const items = spec[own ? FUNCTIONAL_REQUIREMENTS : REQUIRED_FUNCTIONAL_SPECS] ?? [];
|
|
151
|
+
return (own ? items[Number(frid) - 1] : items.find((item) => item.module === module && item.frid === frid))?.markdown.trim();
|
|
152
|
+
};
|
|
153
|
+
codebase.forEach((entry, index) => {
|
|
154
|
+
const count = counts[index];
|
|
155
|
+
const completed = entry.frids.filter((frid) => !touched.has(`${entry.moduleName}:${frid}`));
|
|
156
|
+
const tested = (readRecord(conformanceDirFor(entry.moduleName, modulesRoot(projectDirOf(entry.plainFile))))?.[FUNCTIONAL_REQUIREMENTS] ?? []).filter((item) => item.frid !== undefined && item.markdown.trim() === specText(entry.moduleName, item.frid));
|
|
157
|
+
count.completed = completed.length;
|
|
158
|
+
count.conformanceTested = completed.filter((frid) => tested.some((item) => item.frid === frid)).length;
|
|
159
|
+
count.incomplete = entry.frids.filter((frid) => !completed.includes(frid));
|
|
160
|
+
});
|
|
161
|
+
return { codebase: tip.buildDir, modules: counts };
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
92
164
|
/** The modules of a chain to build: its furthest-built one, and those above it - or, none built, all of it. */
|
|
93
165
|
function toBuild(chain) {
|
|
94
166
|
let from = 0;
|
|
@@ -434,7 +506,7 @@ export function createWorkLoop(options) {
|
|
|
434
506
|
title: planned.title,
|
|
435
507
|
diff: shown.diff,
|
|
436
508
|
...(state.model !== undefined ? { model: state.model } : {}),
|
|
437
|
-
lanes: [...moduleLanes(tip.plainFile
|
|
509
|
+
lanes: [...moduleLanes(tip.plainFile)],
|
|
438
510
|
rerun: `turri regenerate ${relative(options.cwd, tip.plainFile) || basename(tip.plainFile)}`,
|
|
439
511
|
run: async (onDelta) => {
|
|
440
512
|
// The guardrail: a conformance job tests only code that is built - the code record, read as the job starts, has every functional spec it tests as it tests it.
|
|
@@ -934,9 +1006,10 @@ export function createWorkLoop(options) {
|
|
|
934
1006
|
};
|
|
935
1007
|
const loop = {
|
|
936
1008
|
async bringUp(plainFile, request = {}) {
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
|
|
1009
|
+
const files = codebasesOf(resolve(options.cwd, plainFile));
|
|
1010
|
+
for (const file of files)
|
|
1011
|
+
asked.set(file, { ...asked.get(file), ...request });
|
|
1012
|
+
const states = withParseCache(() => files.flatMap((file) => chainsFor(file).flatMap((chain) => toBuild(chain).map((entry) => track(chain.slice(0, chain.indexOf(entry) + 1), file, request)))));
|
|
940
1013
|
// Asked again, a halted codebase is tried again.
|
|
941
1014
|
for (const state of states)
|
|
942
1015
|
state.halted = undefined;
|
|
@@ -1313,7 +1386,6 @@ export async function previewJobs(options) {
|
|
|
1313
1386
|
registry: createJobRegistry(),
|
|
1314
1387
|
cwd: options.cwd,
|
|
1315
1388
|
dryRun: true,
|
|
1316
|
-
...(options.buildDir !== undefined ? { buildDir: options.buildDir } : {}),
|
|
1317
1389
|
...(options.model !== undefined ? { modelFor: () => options.model } : {}),
|
|
1318
1390
|
...(options.plannerModel !== undefined ? { plannerModel: options.plannerModel } : {}),
|
|
1319
1391
|
...(options.steering !== undefined ? { steering: options.steering } : {}),
|
|
@@ -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
|
+
}
|
|
@@ -24,7 +24,7 @@ function alreadyImplemented(moduleName, own, required) {
|
|
|
24
24
|
* one another, the current module marked - and each tagged with its module and its id there. A codebase holds the
|
|
25
25
|
* functionalities of every module it is built on, and each module numbers its own from 1, so
|
|
26
26
|
* "functionality 1" alone is ambiguous: the first listed may be base's 1 and the next top's 3.
|
|
27
|
-
* (
|
|
27
|
+
* (the reference implementation's implemented functional requirements prompt section, as tags.)
|
|
28
28
|
*/
|
|
29
29
|
export function functionalitiesByModule(entries, currentModule, status) {
|
|
30
30
|
// In the order given: the bottom of the chain first. A regeneration in a later module's
|
|
@@ -278,8 +278,10 @@ function itemText(serialized) {
|
|
|
278
278
|
return item;
|
|
279
279
|
const { markdown, [ACCEPTANCE_TESTS]: tests } = (item ?? {});
|
|
280
280
|
const lines = [markdown ?? JSON.stringify(item)];
|
|
281
|
+
// Each test is an item of the tree, its text in its markdown - as spec-steps.ts `itemText` reads it.
|
|
282
|
+
const testText = (test) => (typeof test === "string" ? test : (test?.markdown ?? JSON.stringify(test)));
|
|
281
283
|
if (Array.isArray(tests) && tests.length > 0)
|
|
282
|
-
lines.push(" <acceptance tests>", ...tests.map((test) => ` ${
|
|
284
|
+
lines.push(" <acceptance tests>", ...tests.map((test) => ` ${testText(test).replace(/\n/g, "\n ")}`), " </acceptance tests>");
|
|
283
285
|
return lines.join("\n");
|
|
284
286
|
}
|
|
285
287
|
const indent = (text, by) => text.split("\n").map((line) => `${by}${line}`).join("\n");
|
|
@@ -20,7 +20,7 @@ import { createWebTools, WEB_TOOLS } from "./web.js";
|
|
|
20
20
|
import { createCanItFailTool, createMyDiffTool, selfCheckNote } from "./self-check.js";
|
|
21
21
|
import { createServiceTools } from "./services.js";
|
|
22
22
|
import { createUnitGate } from "./unit-gate.js";
|
|
23
|
-
import { toolchainError } from "./environment-error.js";
|
|
23
|
+
import { timeLimitError, toolchainError } from "./environment-error.js";
|
|
24
24
|
import { workspaceBlock } from "./workspace.js";
|
|
25
25
|
import { ATTEMPTS, describeStop } from "./stop-report.js";
|
|
26
26
|
import { summarizeDiff } from "./job-panel.js";
|
|
@@ -333,6 +333,8 @@ export async function renderSpecDiff(options) {
|
|
|
333
333
|
let draftAsks = 0;
|
|
334
334
|
/** What the unit testing script needs and does not find, last run. */
|
|
335
335
|
let missing;
|
|
336
|
+
/** The unit testing script needs more time than a test run may take - the debugger's finding: the pulse stops on it. */
|
|
337
|
+
let slow;
|
|
336
338
|
/** The last round failed on the script's static checks alone: the unit tests passed. */
|
|
337
339
|
let staticOnly = false;
|
|
338
340
|
const testRemovals = [];
|
|
@@ -423,24 +425,35 @@ export async function renderSpecDiff(options) {
|
|
|
423
425
|
seenFailures.set(key, seen);
|
|
424
426
|
let prompt = verdict.prompt;
|
|
425
427
|
const circling = loop.stuck;
|
|
426
|
-
|
|
428
|
+
// A run the time limit stopped is looked at at once: whether something hangs, or the suite only needs more time.
|
|
429
|
+
const timedOut = verdict.run.timedOut === true;
|
|
430
|
+
if (circling || timedOut || (key !== undefined && seen >= STUCK_ROUNDS)) {
|
|
427
431
|
if (key !== undefined)
|
|
428
432
|
seenFailures.delete(key);
|
|
429
433
|
const times = circling ? Math.max(seen, loop.failing) : seen;
|
|
430
434
|
loop.reset();
|
|
431
435
|
stage(circling
|
|
432
436
|
? `the coding agent is going round in circles - ${times} failing runs - a debugger looks at it with fresh eyes`
|
|
433
|
-
:
|
|
437
|
+
: timedOut && seen < STUCK_ROUNDS
|
|
438
|
+
? `the unit testing script did not finish in ${Math.round(verdict.run.timeoutMs / 1000)}s - a debugger works out whether something hangs`
|
|
439
|
+
: `the same failure ${seen} times - a debugger looks at it with fresh eyes`);
|
|
434
440
|
const found = await debugFailure({
|
|
435
441
|
buildDir,
|
|
436
442
|
failure: verdict.run.output,
|
|
437
443
|
times,
|
|
438
444
|
task: steeredChange() ?? codingText ?? changeText ?? options.diff ?? "",
|
|
439
445
|
script: verdict.script,
|
|
446
|
+
run: verdict.run,
|
|
440
447
|
label: `${moduleName} spec change`,
|
|
441
448
|
...(debuggerModel !== undefined ? { model: debuggerModel } : {}),
|
|
442
449
|
onDelta: options.onDelta,
|
|
443
450
|
}).catch(() => undefined);
|
|
451
|
+
// Nothing hangs: the suite needs more time than a test run may take, and no agent can give it that.
|
|
452
|
+
if (found?.tooSlow === true) {
|
|
453
|
+
stage(`the unit testing script needs more than the ${Math.round(verdict.run.timeoutMs / 1000)}s a test run may take - stopping`);
|
|
454
|
+
slow = timeLimitError(verdict.run, { moduleName, script: verdict.script, kind: "unit", reason: found.rootCause });
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
444
457
|
if (found !== undefined) {
|
|
445
458
|
const cause = found.rootCause.split("\n")[0].slice(0, 240);
|
|
446
459
|
reportActivity(` the debugger: ${cause.slice(0, 160)}`);
|
|
@@ -486,6 +499,7 @@ export async function renderSpecDiff(options) {
|
|
|
486
499
|
...(options.cases !== undefined ? { cases: options.cases } : {}),
|
|
487
500
|
label: `${moduleName} spec change`,
|
|
488
501
|
...(scripts.runOn === "copy" ? { unitScript: scripts.unit } : {}),
|
|
502
|
+
testingScript: { path: relative(root, scripts.unit), content: readFileSync(scripts.unit, "utf8") },
|
|
489
503
|
model: options.judgeModel ?? modelSpec(inheritModel(configForSpec(tip.plainFile).judgeModel, modelOnly(options.model))),
|
|
490
504
|
send: async (next) => {
|
|
491
505
|
response = await ask(executor, next, options.onDelta);
|
|
@@ -543,13 +557,17 @@ export async function renderSpecDiff(options) {
|
|
|
543
557
|
stage(unitReviewStopped ? "the unit tests pass but still leave the change untested - undoing the change" : "unit tests NOT passing - undoing the change");
|
|
544
558
|
abandonWork(unit, moduleName, missing !== undefined
|
|
545
559
|
? "the unit testing script could not run"
|
|
546
|
-
:
|
|
547
|
-
? "the
|
|
548
|
-
:
|
|
560
|
+
: slow !== undefined
|
|
561
|
+
? "the unit testing script needs more time than a test run may take"
|
|
562
|
+
: staticOnly
|
|
563
|
+
? "the spec change did not pass its static checks"
|
|
564
|
+
: "the spec change did not pass its unit tests");
|
|
549
565
|
// The environment lacks what the testing script needs: the pulse stops, rolled back, and the person is told.
|
|
550
566
|
if (missing !== undefined) {
|
|
551
567
|
throw toolchainError(missing, { moduleName, script: scripts.unit, kind: "unit" });
|
|
552
568
|
}
|
|
569
|
+
if (slow !== undefined)
|
|
570
|
+
throw slow;
|
|
553
571
|
return { ...base, passed: false, stoppedAt: "unit tests", ...(staticOnly && !unitReviewStopped ? { staticChecks: true } : {}) };
|
|
554
572
|
}
|
|
555
573
|
stage("unit tests pass - landing the change on them");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
-
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
3
3
|
import { connect } from "node:net";
|
|
4
4
|
import { dirname, join, relative } from "node:path";
|
|
5
5
|
import { HISTORY_DIR } from "./lineage.js";
|
|
@@ -38,15 +38,17 @@ export function deployTarget(plainFile, inModule) {
|
|
|
38
38
|
throw new Error(`Nothing is built yet for ${tip.moduleName}${candidates.length > 1 ? " or the modules it requires" : ""}: there is no code to deploy. Render it first.`);
|
|
39
39
|
}
|
|
40
40
|
/**
|
|
41
|
-
* Export the module's code at its last commit into `dist/<module
|
|
42
|
-
* harness's own files (what the code is built from, the agents' notes) left out.
|
|
41
|
+
* Export the module's code at its last commit into `dist/<module>` - or `into`, `turri regenerate --build-dir` -
|
|
42
|
+
* replacing whatever was there, the harness's own files (what the code is built from, the agents' notes) left out.
|
|
43
43
|
*/
|
|
44
|
-
export function exportModule(plainFile, inModule) {
|
|
44
|
+
export function exportModule(plainFile, inModule, into) {
|
|
45
45
|
const { entry, commit, instead } = deployTarget(plainFile, inModule);
|
|
46
46
|
const moduleDir = dirname(entry.buildDir);
|
|
47
|
-
const dir = distDirFor(projectDirOf(entry.plainFile), entry.moduleName);
|
|
48
|
-
|
|
47
|
+
const dir = into ?? distDirFor(projectDirOf(entry.plainFile), entry.moduleName);
|
|
48
|
+
// Emptied, not removed: the folder may be a mount point - a container's volume - that cannot be removed.
|
|
49
49
|
mkdirSync(dir, { recursive: true });
|
|
50
|
+
for (const entry of readdirSync(dir))
|
|
51
|
+
rmSync(join(dir, entry), { recursive: true, force: true });
|
|
50
52
|
const exported = spawnSync("bash", ["-o", "pipefail", "-c", 'git --git-dir "$1" archive --format=tar "$2" | tar -x -f - -C "$3"', "export", join(moduleDir, HISTORY_DIR), `${commit}:code`, dir], { encoding: "utf8" });
|
|
51
53
|
if (exported.status !== 0)
|
|
52
54
|
throw new Error(`Could not export ${entry.moduleName}'s code at ${commit.slice(0, 8)}: ${(exported.stderr ?? "").trim()}`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
-
import { PLAIN_SOURCE_FILE_EXTENSION, plainFileParser, withParseCache } from "../plain/parser.js";
|
|
3
|
+
import { IMPORT_DIRECTIVE, PLAIN_SOURCE_FILE_EXTENSION, plainFileParser, readPlainSourceMetadata, withParseCache } from "../plain/parser.js";
|
|
4
4
|
import { getFrids, getInheritedFunctionalSpecs } from "../plain/spec.js";
|
|
5
5
|
import { buildDirUnder, modulesRoot, projectDirOf } from "./paths.js";
|
|
6
6
|
import { notesPath } from "./notes.js";
|
|
@@ -54,6 +54,59 @@ options = {}) {
|
|
|
54
54
|
visit(resolve(plainFile), []);
|
|
55
55
|
return chain;
|
|
56
56
|
}
|
|
57
|
+
/** The .plain files that import `plainFile` - read from beside it, and, kept in a `template/` folder, from the folder it is in. */
|
|
58
|
+
function importersOf(plainFile) {
|
|
59
|
+
const file = resolve(plainFile);
|
|
60
|
+
const dir = dirname(file);
|
|
61
|
+
const name = basename(file, PLAIN_SOURCE_FILE_EXTENSION);
|
|
62
|
+
const dirs = [dir, ...(basename(dir) === "template" ? [dirname(dir)] : [])];
|
|
63
|
+
return dirs.flatMap((at) => {
|
|
64
|
+
let names;
|
|
65
|
+
try {
|
|
66
|
+
names = readdirSync(at).filter((entry) => entry.endsWith(PLAIN_SOURCE_FILE_EXTENSION));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
return names
|
|
72
|
+
.map((entry) => join(at, entry))
|
|
73
|
+
.filter((path) => {
|
|
74
|
+
if (path === file || !resolveTemplateDirs(path).includes(dir))
|
|
75
|
+
return false;
|
|
76
|
+
try {
|
|
77
|
+
return (readPlainSourceMetadata(readFileSync(path, "utf8")).metadata[IMPORT_DIRECTIVE] ?? []).includes(name);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Whether a .plain file is an import module - one a .plain file imports, or one kept in a `template/` folder - rather than
|
|
87
|
+
* a module of its own. It is no codebase: its reqs and definitions are built in the modules that import it.
|
|
88
|
+
*/
|
|
89
|
+
export function isImportModule(plainFile) {
|
|
90
|
+
return basename(dirname(resolve(plainFile))) === "template" || importersOf(plainFile).length > 0;
|
|
91
|
+
}
|
|
92
|
+
/** The modules of their own that import an import module, through any import modules between. */
|
|
93
|
+
export function importingModules(plainFile) {
|
|
94
|
+
const found = new Set();
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
const walk = (file) => {
|
|
97
|
+
if (seen.has(file))
|
|
98
|
+
return;
|
|
99
|
+
seen.add(file);
|
|
100
|
+
for (const importer of importersOf(file)) {
|
|
101
|
+
if (isImportModule(importer))
|
|
102
|
+
walk(importer);
|
|
103
|
+
else
|
|
104
|
+
found.add(importer);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
walk(resolve(plainFile));
|
|
108
|
+
return [...found];
|
|
109
|
+
}
|
|
57
110
|
/**
|
|
58
111
|
* The codebase a change to `plainFile`'s module lands in: the furthest-built module whose
|
|
59
112
|
* requires chain includes it, with that chain.
|