@turri-dev/cli 0.1.3-next.f74e98a → 0.1.3
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/dist/extension.js +42 -12
- package/dist/pi-launch.js +5 -0
- package/dist/question-dialog.js +8 -3
- package/dist/questioning.js +1 -1
- package/dist/regenerate/activity.js +2 -0
- package/dist/regenerate/conform.js +5 -5
- package/dist/regenerate/conformance-agents.js +28 -8
- package/dist/regenerate/conformance.js +24 -10
- package/dist/regenerate/loop.js +22 -4
- package/dist/regenerate/notes.js +66 -17
- package/dist/regenerate/prompt.js +3 -1
- package/dist/regenerate/regenerate.js +12 -8
- package/dist/regenerate/regression.js +2 -1
- package/dist/regenerate/requires.js +55 -2
- package/dist/regenerate/unit-review.js +11 -0
- package/dist/resume-line.js +4 -0
- package/dist/spec-tools.js +8 -44
- package/docs/authoring-plain.md +9 -6
- package/docs/regenerating.md +27 -13
- package/docs/skills-and-gates.md +2 -2
- package/genome/skills/bud/SKILL.md +67 -36
- package/genome/skills/bud/references/intent.md +4 -0
- package/package.json +3 -3
- package/genome/skills/bud/references/checklist.md +0 -44
package/dist/extension.js
CHANGED
|
@@ -90,15 +90,23 @@ aside) {
|
|
|
90
90
|
// The question, with what the asker gives to read for it - a brief to confirm - above it; what it said of its work
|
|
91
91
|
// before asking is kept in its record, never put in the dialog.
|
|
92
92
|
// A dialog of turri's own: a long brief scrolls above the question, and the options always show (question-dialog.ts).
|
|
93
|
-
const dialog = (question, context, options) => ctx.ui.custom((tui, theme, _keybindings, done) =>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
93
|
+
const dialog = (question, context, options) => ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
94
|
+
const shown = new QuestionDialog({
|
|
95
|
+
question,
|
|
96
|
+
...(context !== undefined ? { context } : {}),
|
|
97
|
+
...(options !== undefined ? { options } : {}),
|
|
98
|
+
rows: () => tui.terminal.rows,
|
|
99
|
+
style: { accent: (text) => theme.fg("accent", text), muted: (text) => theme.fg("muted", text), bold: (text) => theme.bold(text) },
|
|
100
|
+
done: (answer) => {
|
|
101
|
+
if (questionShown === shown)
|
|
102
|
+
questionShown = undefined;
|
|
103
|
+
done(answer);
|
|
104
|
+
},
|
|
105
|
+
requestRender: () => tui.requestRender(),
|
|
106
|
+
});
|
|
107
|
+
questionShown = shown;
|
|
108
|
+
return shown;
|
|
109
|
+
});
|
|
102
110
|
const asking = (asked) => runAsk(asked, {
|
|
103
111
|
select: (title, options, context) => dialog(title, context, options),
|
|
104
112
|
input: (title, context) => dialog(title, context),
|
|
@@ -117,6 +125,12 @@ aside) {
|
|
|
117
125
|
}
|
|
118
126
|
/** The side panel, as a question open in the chat reaches it: set once the extension has its panel. */
|
|
119
127
|
let questionPeek;
|
|
128
|
+
/**
|
|
129
|
+
* The question open in the chat now, if any. The side panel gives the keys back to it: pi would give them to what had the
|
|
130
|
+
* focus when the panel first opened, and a panel kept beside the chat outlives that question - the keys would go to a
|
|
131
|
+
* dialog already answered, and nothing would take them.
|
|
132
|
+
*/
|
|
133
|
+
let questionShown;
|
|
120
134
|
/** A widget with nothing to draw: below the editor, it takes no line. */
|
|
121
135
|
const PEEK_KEY_WIDGET = "turri-peek-key";
|
|
122
136
|
/**
|
|
@@ -1508,6 +1522,7 @@ function registerJobTools(pi, jobs, cwd) {
|
|
|
1508
1522
|
}
|
|
1509
1523
|
needsLooking.delete(jobs);
|
|
1510
1524
|
if (panel !== undefined) {
|
|
1525
|
+
panel.keepChatFocus();
|
|
1511
1526
|
panel.handle?.setHidden(false);
|
|
1512
1527
|
panel.keys = true;
|
|
1513
1528
|
panel.handle?.focus();
|
|
@@ -1515,7 +1530,21 @@ function registerJobTools(pi, jobs, cwd) {
|
|
|
1515
1530
|
}
|
|
1516
1531
|
// Not awaited: the panel lives on while the jobs it shows run.
|
|
1517
1532
|
void ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
1518
|
-
|
|
1533
|
+
// What the chat shows when the panel takes the keys - its editor, or a pi dialog - for when it gives them back.
|
|
1534
|
+
const focusedNow = () => tui.getFocusedComponent?.() ?? null;
|
|
1535
|
+
let chatFocus = focusedNow();
|
|
1536
|
+
const keepChatFocus = () => {
|
|
1537
|
+
const focused = focusedNow();
|
|
1538
|
+
if (focused !== null && focused !== component && !(focused instanceof QuestionDialog))
|
|
1539
|
+
chatFocus = focused;
|
|
1540
|
+
};
|
|
1541
|
+
// The keys back to the chat: to the question open there now, else to what it showed - never to a question answered
|
|
1542
|
+
// while the panel was open.
|
|
1543
|
+
const giveBack = () => entry.handle?.unfocus({ target: questionShown ?? chatFocus });
|
|
1544
|
+
const close = () => {
|
|
1545
|
+
giveBack();
|
|
1546
|
+
done(undefined);
|
|
1547
|
+
};
|
|
1519
1548
|
panelTerminal = tui.terminal;
|
|
1520
1549
|
let component;
|
|
1521
1550
|
// Beside the chat: the chat drawn narrower, on the left, and a click on either side giving it the keys - undone
|
|
@@ -1548,10 +1577,11 @@ function registerJobTools(pi, jobs, cwd) {
|
|
|
1548
1577
|
// Beside the chat, back to it: the chat has the keys, the panel stays.
|
|
1549
1578
|
const toChat = () => {
|
|
1550
1579
|
entry.keys = false;
|
|
1551
|
-
|
|
1580
|
+
giveBack();
|
|
1552
1581
|
tui.requestRender();
|
|
1553
1582
|
};
|
|
1554
1583
|
const toPanel = () => {
|
|
1584
|
+
keepChatFocus();
|
|
1555
1585
|
entry.keys = true;
|
|
1556
1586
|
entry.handle?.focus();
|
|
1557
1587
|
tui.requestRender();
|
|
@@ -1604,7 +1634,7 @@ function registerJobTools(pi, jobs, cwd) {
|
|
|
1604
1634
|
return focused !== null && focused !== component;
|
|
1605
1635
|
},
|
|
1606
1636
|
});
|
|
1607
|
-
const entry = { view, close, keys: true };
|
|
1637
|
+
const entry = { view, close, keepChatFocus, keys: true };
|
|
1608
1638
|
panel = entry;
|
|
1609
1639
|
layOut();
|
|
1610
1640
|
// Every key goes to the panel while it shows, whatever has the focus: a question the chat model asks in the
|
package/dist/pi-launch.js
CHANGED
|
@@ -11,6 +11,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
11
11
|
import { main, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { ProcessTerminal } from "@earendil-works/pi-tui";
|
|
13
13
|
import "./terminal-images.js";
|
|
14
|
+
import { resumeLine } from "./resume-line.js";
|
|
14
15
|
/**
|
|
15
16
|
* pi, started the way its own CLI starts it, for `turri`.
|
|
16
17
|
*
|
|
@@ -31,6 +32,10 @@ const setTitle = ProcessTerminal.prototype.setTitle;
|
|
|
31
32
|
ProcessTerminal.prototype.setTitle = function (title) {
|
|
32
33
|
setTitle.call(this, title.replace(/^π(?= |$)/, "🪼 turri"));
|
|
33
34
|
};
|
|
35
|
+
// What pi prints on the way out to resume the session names pi - its APP_NAME, a constant - which may not be on the
|
|
36
|
+
// PATH at all: turri takes the same flags, so it is turri the line names.
|
|
37
|
+
const write = process.stdout.write.bind(process.stdout);
|
|
38
|
+
process.stdout.write = ((chunk, ...rest) => write(typeof chunk === "string" ? resumeLine(chunk) : chunk, ...rest));
|
|
34
39
|
// pi's own keys stand: a new line is shift+enter or ctrl+j. turri's side panel is on ctrl+h (job-panel.ts PANEL_KEY).
|
|
35
40
|
// What pi's CLI does before main: the process title, pi's environment markers, the HTTP
|
|
36
41
|
// dispatcher. Not exported, so loaded from the file - beside pi's entry point, wherever npm put it.
|
package/dist/question-dialog.js
CHANGED
|
@@ -58,18 +58,23 @@ export class QuestionDialog {
|
|
|
58
58
|
const choices = this.options.options ?? [];
|
|
59
59
|
const body = this.input !== undefined
|
|
60
60
|
? this.input.render(inner)
|
|
61
|
-
:
|
|
61
|
+
: // A long option wraps, its lines under its first, rather than cut off where the person cannot read it.
|
|
62
|
+
choices.flatMap((choice, index) => wrapTextWithAnsi(choice, Math.max(1, inner - 2)).map((line, at) => {
|
|
63
|
+
const shown = `${at === 0 && index === this.selected ? "›" : " "} ${line}`;
|
|
64
|
+
return index === this.selected ? style.accent(shown) : shown;
|
|
65
|
+
}));
|
|
62
66
|
const hint = this.input !== undefined
|
|
63
67
|
? "enter answers · esc dismisses"
|
|
64
68
|
: "↑↓ choose · enter picks · esc dismisses";
|
|
65
69
|
// The brief gets what the question, the options and the hints leave of the screen.
|
|
66
|
-
const fixed = question.length + body.length +
|
|
70
|
+
const fixed = question.length + body.length + 5;
|
|
67
71
|
const room = Math.max(3, this.options.rows() - KEPT_ABOVE - fixed);
|
|
68
72
|
const scrolls = brief.length > room;
|
|
69
73
|
this.window = scrolls ? room - 1 : brief.length;
|
|
70
74
|
this.maxScroll = scrolls ? brief.length - this.window : 0;
|
|
71
75
|
this.scroll = Math.min(this.scroll, this.maxScroll);
|
|
72
|
-
|
|
76
|
+
// Set apart from what is drawn above it - the job panel's hints.
|
|
77
|
+
const lines = [""];
|
|
73
78
|
if (brief.length > 0) {
|
|
74
79
|
lines.push(...brief.slice(this.scroll, this.scroll + this.window).map(pad));
|
|
75
80
|
if (scrolls) {
|
package/dist/questioning.js
CHANGED
|
@@ -43,7 +43,7 @@ export function questioningQuestion(phases) {
|
|
|
43
43
|
return {
|
|
44
44
|
question: `How many questions should I ask you in ${named}?`,
|
|
45
45
|
options: QUESTIONING_LEVELS.map((level) => level.option),
|
|
46
|
-
context: `Light, medium and thorough cap each of ${named} at 3, 5 and 10 questions - broader with fewer, so every level ` +
|
|
46
|
+
context: `Light, medium and thorough cap ${phases.length === 1 ? named : `each of ${named}`} at 3, 5 and 10 questions - broader with fewer, so every level ` +
|
|
47
47
|
"covers the same ground, only in less or more depth. What the questions leave open, turri decides itself and " +
|
|
48
48
|
"says what it chose. With best judgement, turri asks as much as it judges each phase needs. Each phase still " +
|
|
49
49
|
"ends with your confirmation of its summary.",
|
|
@@ -183,6 +183,8 @@ export function describeToolCall(name, args) {
|
|
|
183
183
|
return `± my_diff`;
|
|
184
184
|
case "update_notes":
|
|
185
185
|
return `✐ update_notes`;
|
|
186
|
+
case "report_spec_gap":
|
|
187
|
+
return `? spec gap: ${text("question") ?? ""}`.trimEnd();
|
|
186
188
|
case "copy_resource":
|
|
187
189
|
return `⎘ copy_resource ${path ?? text("to") ?? ""}`.trimEnd();
|
|
188
190
|
case "can_it_fail":
|
|
@@ -7,7 +7,7 @@ import { abandonWork, startWork } from "./lineage.js";
|
|
|
7
7
|
import { ensureRegressionPasses } from "./regression.js";
|
|
8
8
|
import { modulesRoot, projectDirOf } from "./paths.js";
|
|
9
9
|
import { applySpecDiff, snapshotSpec, specGapsText } from "./regenerate.js";
|
|
10
|
-
import {
|
|
10
|
+
import { gapMark, gapsSince } from "./notes.js";
|
|
11
11
|
import { sectionsOf } from "./state.js";
|
|
12
12
|
import { describeStop } from "./stop-report.js";
|
|
13
13
|
import { missingTestLabels } from "./test-kinds.js";
|
|
@@ -41,8 +41,8 @@ export async function renderSpecDiffConformance(options) {
|
|
|
41
41
|
// state, not to what an interrupted run left half-written. The tests and the whole suite are
|
|
42
42
|
// one unit, landed whole or undone whole.
|
|
43
43
|
const unit = startWork([buildDir, conformanceDir], moduleName);
|
|
44
|
-
// The
|
|
45
|
-
const
|
|
44
|
+
// The spec gaps its agents report from here on are told with the result.
|
|
45
|
+
const gapsFrom = gapMark();
|
|
46
46
|
const conformance = await renderConformanceTests({
|
|
47
47
|
plainFile: tip.plainFile,
|
|
48
48
|
frid: lastFrid,
|
|
@@ -58,8 +58,8 @@ export async function renderSpecDiffConformance(options) {
|
|
|
58
58
|
});
|
|
59
59
|
// What the codebase now has conformance tests for, as their labels say.
|
|
60
60
|
const tested = () => frids.filter((frid) => missingTestLabels(conformanceDir, frid, []).length === 0);
|
|
61
|
-
//
|
|
62
|
-
const base = { moduleName, buildDir, conformanceDir, diff: applied, conformance, specGaps:
|
|
61
|
+
// A gap is the person's to decide, whether the tests passed or not.
|
|
62
|
+
const base = { moduleName, buildDir, conformanceDir, diff: applied, conformance, specGaps: gapsSince(gapsFrom, [buildDir, conformanceDir]) };
|
|
63
63
|
if (!conformance.passed) {
|
|
64
64
|
abandonWork(unit, moduleName, notLandedWhy("for the spec change", conformance.unlabelled));
|
|
65
65
|
return { ...base, tested: tested(), passed: false, stoppedAt: "conformance" };
|
|
@@ -7,7 +7,7 @@ import { confinedTools } from "./confine.js";
|
|
|
7
7
|
import { ensureInModulesScratch, modulesScratchDir } from "./paths.js";
|
|
8
8
|
import { workspaceBlock } from "./workspace.js";
|
|
9
9
|
import { reportActivity } from "./activity.js";
|
|
10
|
-
import { runTiming } from "./test-scripts.js";
|
|
10
|
+
import { runTiming, withTestingScripts } from "./test-scripts.js";
|
|
11
11
|
import { createWebTools, WEB_TOOLS } from "./web.js";
|
|
12
12
|
import { diffFiles, digestFiles } from "./codebase.js";
|
|
13
13
|
import { ERROR_CAUSE_RULES, fixMethod, INTEGRITY_RULES, specPrimer } from "./prompt.js";
|
|
@@ -378,9 +378,17 @@ export function explorePrompt(buildDir, spec, scope, testingScripts, question, p
|
|
|
378
378
|
...(testingScripts !== undefined && testingScripts.length > 0
|
|
379
379
|
? [
|
|
380
380
|
"",
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
381
|
+
...(testingScripts.some((script) => script.prepare === true)
|
|
382
|
+
? [
|
|
383
|
+
"These are the project's prepare script, which the harness runs on the build first, and its conformance testing",
|
|
384
|
+
"script, which runs only once the prepare script passes and decides whether the tests pass - read both for what",
|
|
385
|
+
"they build or install before the tests run, and so what the tests can depend on:",
|
|
386
|
+
]
|
|
387
|
+
: [
|
|
388
|
+
"This is the project's conformance testing script, which the harness runs to decide whether the",
|
|
389
|
+
"tests pass - read it for what it builds or installs before the tests run, and so what the tests can depend on:",
|
|
390
|
+
]),
|
|
391
|
+
...testingScripts.flatMap((script) => ["", `<script${script.prepare === true ? ' kind="prepare environment"' : ""} path="${script.path}" run-as="${script.runs}">`, script.content.trimEnd(), "</script>"]),
|
|
384
392
|
]
|
|
385
393
|
: []),
|
|
386
394
|
...(previous !== undefined && question === undefined
|
|
@@ -704,7 +712,7 @@ export function unfoundedEvidence(evidence, opened) {
|
|
|
704
712
|
"opened, the testing script, or the failing run - each with where. What <how to invoke it> says is not evidence: it " +
|
|
705
713
|
"may be wrong, so check it against the testing script and the files.");
|
|
706
714
|
}
|
|
707
|
-
const inPrompt = (source) => /^(the )?(conformance )?testing script$|^(the )?failing run$/i.test(source.trim());
|
|
715
|
+
const inPrompt = (source) => /^(the )?(conformance )?testing script$|^(the )?prepare script$|^(the )?failing run$/i.test(source.trim());
|
|
708
716
|
const unread = given.filter((item) => !inPrompt(item.source) && !opened(item.source.trim()));
|
|
709
717
|
if (unread.length > 0) {
|
|
710
718
|
return (`Your evidence cites ${unread.map((item) => item.source.trim()).join(", ")}, which you did not open. Open each file you cite ` +
|
|
@@ -735,7 +743,7 @@ export async function judgeFailure(options) {
|
|
|
735
743
|
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",
|
|
736
744
|
})),
|
|
737
745
|
evidence: Type.Optional(Type.Array(Type.Object({
|
|
738
|
-
source: Type.String({ description: "Where: the path of a file you opened, `testing script`, or `failing run`" }),
|
|
746
|
+
source: Type.String({ description: "Where: the path of a file you opened, `testing script`, `prepare script` where there is one, or `failing run`" }),
|
|
739
747
|
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`" }),
|
|
740
748
|
}), { description: "What you found yourself, each with where - never what <how to invoke it> claims unchecked" })),
|
|
741
749
|
functionalities: Type.Optional(Type.Array(Type.Object({
|
|
@@ -900,6 +908,12 @@ export function judgePrompt(options) {
|
|
|
900
908
|
"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",
|
|
901
909
|
"checks for, a command it builds with - the code has to provide. A verdict that the script is wrong is neither",
|
|
902
910
|
"code nor tests: say which of the two has to change to get past it.",
|
|
911
|
+
...(scripts.some((script) => script.prepare === true)
|
|
912
|
+
? [
|
|
913
|
+
"The prepare script below it runs on the build before every run, and the conformance testing script only once it",
|
|
914
|
+
"passes: it binds the same way - neither agent can change it, and what it needs of the code the code provides.",
|
|
915
|
+
]
|
|
916
|
+
: []),
|
|
903
917
|
"",
|
|
904
918
|
]
|
|
905
919
|
: []),
|
|
@@ -929,7 +943,10 @@ export function judgePrompt(options) {
|
|
|
929
943
|
"",
|
|
930
944
|
`<how to invoke it>\n${options.invocation.trim()}\n</how to invoke it>`,
|
|
931
945
|
"",
|
|
932
|
-
...scripts.flatMap((script) =>
|
|
946
|
+
...scripts.flatMap((script) => {
|
|
947
|
+
const tag = script.prepare === true ? "prepare script" : "conformance testing script";
|
|
948
|
+
return [`<${tag} path="${script.path}" run-as="${script.runs}">`, script.content.trimEnd(), `</${tag}>`, ""];
|
|
949
|
+
}),
|
|
933
950
|
...(options.run.timedOut === true ? [...timedOutBlock(options.run), ""] : []),
|
|
934
951
|
`<failing run exit="${options.run.exitCode}" command="${options.run.command}">`,
|
|
935
952
|
runTiming(options.run),
|
|
@@ -1004,7 +1021,10 @@ export async function createCodeFixer(options) {
|
|
|
1004
1021
|
model: options.model,
|
|
1005
1022
|
label: `${options.label} coding agent`, role: "coding",
|
|
1006
1023
|
system: `${fence.note}\n\n${workspaceBlock({ dir: options.buildDir, hasMyDiff: options.toolNames.includes("my_diff") })}`,
|
|
1007
|
-
|
|
1024
|
+
// What the conformance testing script needs of the build, the code provides: the fixer reads it, not only a judge's quote of it.
|
|
1025
|
+
task: options.testingScripts !== undefined
|
|
1026
|
+
? withTestingScripts(codeFixerTask(options), options.testingScripts.scripts, ["conformance", "unit"], options.testingScripts.root)
|
|
1027
|
+
: codeFixerTask(options),
|
|
1008
1028
|
compaction: { instructions: CODER_COMPACTION },
|
|
1009
1029
|
...(options.checkEnvironment !== undefined ? { checkEnvironment: options.checkEnvironment } : {}),
|
|
1010
1030
|
});
|
|
@@ -9,7 +9,7 @@ import { abandonWork, diffSinceSnapshot, moduleDirOf, recordHistory, startWork,
|
|
|
9
9
|
import { createWorkLedger, describeAccounts, SUBMIT_WORK_NOTE, unanswered, unansweredPrompt } from "./submit-work.js";
|
|
10
10
|
import { CONFORMANCE_COMPACTION } from "./compaction.js";
|
|
11
11
|
import { NOTES_TURN, notesForSession, passedOutcome, readNotes, reflectOnReview, withNotes } from "./notes.js";
|
|
12
|
-
import { labelsPrompt, labelsPromptFor, misplacedConformanceTests, misplacedTestsPrompt, missingTestLabels } from "./test-kinds.js";
|
|
12
|
+
import { acceptanceLabel, acceptanceText, labelsPrompt, labelsPromptFor, misplacedConformanceTests, misplacedTestsPrompt, missingTestLabels } from "./test-kinds.js";
|
|
13
13
|
import { createScriptTool, findTestScripts, projectRootFor, requireTestScripts, runConformanceScript, runUnitScript, failureSignature, STUCK_ROUNDS, scriptFailure, staticChecksOnly, withTestingScripts, } from "./test-scripts.js";
|
|
14
14
|
import { createWebTools, WEB_TOOLS } from "./web.js";
|
|
15
15
|
import { createMyDiffTool } from "./self-check.js";
|
|
@@ -157,9 +157,11 @@ export async function renderConformanceTests(options) {
|
|
|
157
157
|
// may read the code but writes only tests. An explorer reads the code first and writes down how the
|
|
158
158
|
// software is invoked, which is what the tests are written against.
|
|
159
159
|
const label = `${moduleName} ${options.diff !== undefined ? "spec change" : frid}`;
|
|
160
|
-
const specContext = { moduleName, text: specText(specifications, moduleName, frids, required, options.change ?? options.diff, sectionModules(tree, moduleName)) };
|
|
161
160
|
// For a spec change: the functionalities it touches, whose acceptance tests are labelled, checked and reviewed.
|
|
162
161
|
const touched = forChange ? touchedFunctionalities(tree, frids, options.diff) : [];
|
|
162
|
+
// The acceptance tests the run's tests carry out: every functionality the change touches, or the one under test.
|
|
163
|
+
const carriedOut = forChange ? touched : [{ frid: frid, acceptanceTests: specifications[ACCEPTANCE_TESTS] ?? [] }];
|
|
164
|
+
const specContext = { moduleName, text: specText(specifications, moduleName, frids, required, carriedOut, options.change ?? options.diff, sectionModules(tree, moduleName)) };
|
|
163
165
|
// Any functionality of the codebase - this module's, and those of the modules it requires. A
|
|
164
166
|
// functionality's run may raise a spec conflict only with that functionality; a spec diff's, between any two.
|
|
165
167
|
const own = (forChange ? frids : frids.slice(0, frids.indexOf(frid) + 1)).map((id) => ({ module: moduleName, frid: id }));
|
|
@@ -223,11 +225,15 @@ export async function renderConformanceTests(options) {
|
|
|
223
225
|
};
|
|
224
226
|
stage(`exploring the code for how it is invoked${options.model ? ` (${options.model})` : ""}`);
|
|
225
227
|
// The explorer reads the conformance testing script too: it is how the tests are built and run, and so how they reach the software.
|
|
228
|
+
// The prepare script, where there is one, runs on the build before it - and only once it passes does the suite run.
|
|
226
229
|
const testingScripts = [
|
|
230
|
+
...(scripts.prepare !== undefined
|
|
231
|
+
? [{ path: relative(root, scripts.prepare), runs: "<script> <copy of the build>, before every run of the conformance testing script", content: readFileSync(scripts.prepare, "utf8"), prepare: true }]
|
|
232
|
+
: []),
|
|
227
233
|
{ path: relative(root, scripts.conformance), runs: "<script> <copy of the build> <copy of the whole conformance suite>", content: readFileSync(scripts.conformance, "utf8") },
|
|
228
234
|
];
|
|
229
235
|
// The acceptance tests the explorer's plan has to carry out, step by step.
|
|
230
|
-
const toCarryOut =
|
|
236
|
+
const toCarryOut = carriedOut
|
|
231
237
|
.map((item) => ({ module: moduleName, frid: item.frid, count: item.acceptanceTests.length }))
|
|
232
238
|
.filter((item) => item.count > 0);
|
|
233
239
|
let invocation = await exploreInvocation({ buildDir, spec: specContext, scope, testingScripts, acceptance: toCarryOut, label, model: options.model, onDelta: options.onDelta });
|
|
@@ -272,6 +278,7 @@ export async function renderConformanceTests(options) {
|
|
|
272
278
|
cwd: conformanceDir,
|
|
273
279
|
customTools: [
|
|
274
280
|
notes.tool,
|
|
281
|
+
notes.gapTool,
|
|
275
282
|
work.tool,
|
|
276
283
|
...createWebTools(),
|
|
277
284
|
...fence.tools,
|
|
@@ -285,7 +292,7 @@ export async function renderConformanceTests(options) {
|
|
|
285
292
|
}),
|
|
286
293
|
createCopyResourceTool({ resources: linkedResources, dir: conformanceDir }),
|
|
287
294
|
],
|
|
288
|
-
tools: [...fence.names, "run_conformance_tests", "my_diff", "ask_explorer", "update_notes", "submit_work", "copy_resource", ...WEB_TOOLS],
|
|
295
|
+
tools: [...fence.names, "run_conformance_tests", "my_diff", "ask_explorer", "update_notes", "report_spec_gap", "submit_work", "copy_resource", ...WEB_TOOLS],
|
|
289
296
|
model: options.model,
|
|
290
297
|
label: `${label} conformance agent`,
|
|
291
298
|
role: "conformance",
|
|
@@ -313,12 +320,14 @@ export async function renderConformanceTests(options) {
|
|
|
313
320
|
// The unit tests, and the conformance suite - to reproduce the failure and check the fix, seeing only its output.
|
|
314
321
|
tools: [
|
|
315
322
|
fixerNotes.tool,
|
|
323
|
+
fixerNotes.gapTool,
|
|
316
324
|
createScriptTool({ kind: "unit", script: scripts.unit, run: () => runUnitScript(buildDir, scripts.unit), runs: unitRuns }),
|
|
317
325
|
fixerConformanceRuns.tool,
|
|
318
326
|
...fixerServices.tools,
|
|
319
327
|
],
|
|
320
|
-
toolNames: ["run_unit_tests", "run_conformance_tests", ...fixerServices.names, "update_notes"],
|
|
328
|
+
toolNames: ["run_unit_tests", "run_conformance_tests", ...fixerServices.names, "update_notes", "report_spec_gap"],
|
|
321
329
|
checkEnvironment: { run: () => runUnitScript(buildDir, scripts.unit), script: scripts.unit },
|
|
330
|
+
testingScripts: { scripts, root },
|
|
322
331
|
});
|
|
323
332
|
fixerConformanceRuns.newTurn();
|
|
324
333
|
return ask(fixer, withNotes(text, buildDir), options.onDelta);
|
|
@@ -687,7 +696,7 @@ export async function renderConformanceTests(options) {
|
|
|
687
696
|
buildDir,
|
|
688
697
|
conformanceDir,
|
|
689
698
|
spec: specContext,
|
|
690
|
-
functionalities:
|
|
699
|
+
functionalities: carriedOut,
|
|
691
700
|
forChange,
|
|
692
701
|
invocation,
|
|
693
702
|
label,
|
|
@@ -947,9 +956,10 @@ export function failureLine(run) {
|
|
|
947
956
|
}
|
|
948
957
|
/**
|
|
949
958
|
* The spec as the explorer, the judge and the coding agent are shown it - its functional specs
|
|
950
|
-
* numbered with their ids, so a spec conflict can name them
|
|
959
|
+
* numbered with their ids, so a spec conflict can name them, and the acceptance tests the run carries
|
|
960
|
+
* out labelled as their tests are, so a verdict can quote them.
|
|
951
961
|
*/
|
|
952
|
-
function specText(specifications, moduleName, frids, required, diff, modules) {
|
|
962
|
+
function specText(specifications, moduleName, frids, required, acceptance, diff, modules) {
|
|
953
963
|
// Every functionality in the codebase, each with its module: the required modules' first, then this one's.
|
|
954
964
|
const numbered = (key, items) => key === FUNCTIONAL_REQUIREMENTS
|
|
955
965
|
? [
|
|
@@ -957,10 +967,14 @@ function specText(specifications, moduleName, frids, required, diff, modules) {
|
|
|
957
967
|
]
|
|
958
968
|
: items;
|
|
959
969
|
// Each section in its tag, as every agent is shown it (see specPrimer).
|
|
960
|
-
const sections = [DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS, FUNCTIONAL_REQUIREMENTS
|
|
970
|
+
const sections = [DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS, FUNCTIONAL_REQUIREMENTS]
|
|
961
971
|
.map((key) => [key, specifications[key] ?? []])
|
|
962
972
|
.filter(([, items]) => items.length > 0)
|
|
963
|
-
.map(([key, items]) => (key === FUNCTIONAL_REQUIREMENTS
|
|
973
|
+
.map(([key, items]) => (key === FUNCTIONAL_REQUIREMENTS ? section(key, numbered(key, items)) : moduleSection(key, items, moduleName, modules)));
|
|
974
|
+
// Not only the last functionality's: a spec change's run carries out the acceptance tests of every one it touches.
|
|
975
|
+
const tests = acceptance.flatMap((item) => item.acceptanceTests.map((test, index) => `${acceptanceLabel(item.frid, index + 1)}: ${acceptanceText(test)}`));
|
|
976
|
+
if (tests.length > 0)
|
|
977
|
+
sections.push(section(ACCEPTANCE_TESTS, tests));
|
|
964
978
|
if (diff !== undefined)
|
|
965
979
|
sections.push(specChangeBlock(diff));
|
|
966
980
|
return sections.join("\n\n");
|
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,6 +85,23 @@ 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;
|
|
@@ -989,9 +1006,10 @@ export function createWorkLoop(options) {
|
|
|
989
1006
|
};
|
|
990
1007
|
const loop = {
|
|
991
1008
|
async bringUp(plainFile, request = {}) {
|
|
992
|
-
const
|
|
993
|
-
|
|
994
|
-
|
|
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)))));
|
|
995
1013
|
// Asked again, a halted codebase is tried again.
|
|
996
1014
|
for (const state of states)
|
|
997
1015
|
state.halted = undefined;
|
package/dist/regenerate/notes.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { basename, dirname, join } from "node:path";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
3
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
5
|
import { reportActivity } from "./activity.js";
|
|
@@ -40,7 +40,6 @@ export const SECTIONS = {
|
|
|
40
40
|
where: "Where things are",
|
|
41
41
|
run: "How to build, run and test",
|
|
42
42
|
assumptions: "Assumptions",
|
|
43
|
-
gaps: "Spec gaps",
|
|
44
43
|
pitfalls: "Pitfalls",
|
|
45
44
|
pulses: "Pulses",
|
|
46
45
|
};
|
|
@@ -53,8 +52,13 @@ export function notesPath(buildDir) {
|
|
|
53
52
|
return join(buildDir, NOTES_FILE);
|
|
54
53
|
}
|
|
55
54
|
function empty() {
|
|
56
|
-
return { where: [], run: [], assumptions: [],
|
|
55
|
+
return { where: [], run: [], assumptions: [], pitfalls: [], pulses: [] };
|
|
57
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Sections the notes no longer have, their lines dropped as they are read: the spec gaps are the person's, reported
|
|
59
|
+
* with report_spec_gap - the choice made meanwhile is an assumption, which stays.
|
|
60
|
+
*/
|
|
61
|
+
const DROPPED = new Set(["spec gaps"]);
|
|
58
62
|
/** The notes, as lines per section. Lines under no known heading go to pitfalls, not lost. */
|
|
59
63
|
export function parseNotes(text) {
|
|
60
64
|
const notes = empty();
|
|
@@ -63,11 +67,12 @@ export function parseNotes(text) {
|
|
|
63
67
|
const line = raw.trim();
|
|
64
68
|
const heading = /^##\s+(.+)$/.exec(line);
|
|
65
69
|
if (heading) {
|
|
66
|
-
|
|
70
|
+
const named = heading[1].trim().toLowerCase();
|
|
71
|
+
section = DROPPED.has(named) ? undefined : (ORDER.find((key) => SECTIONS[key].toLowerCase() === named) ?? section ?? "pitfalls");
|
|
67
72
|
continue;
|
|
68
73
|
}
|
|
69
74
|
const item = /^[-*]\s+(.+)$/.exec(line);
|
|
70
|
-
if (item)
|
|
75
|
+
if (item && section !== undefined)
|
|
71
76
|
notes[section].push(item[1].trim());
|
|
72
77
|
}
|
|
73
78
|
return notes;
|
|
@@ -93,14 +98,14 @@ const GUIDANCE = [
|
|
|
93
98
|
`- ${SECTIONS.where}: entry points, layout, where each functionality and its tests live.`,
|
|
94
99
|
`- ${SECTIONS.run}: the commands that build, run and test it, and what had to be installed for them.`,
|
|
95
100
|
`- ${SECTIONS.assumptions}: what you assumed where the specification is silent - a format, a default, a`,
|
|
96
|
-
" choice it leaves open
|
|
97
|
-
"
|
|
98
|
-
|
|
99
|
-
"
|
|
100
|
-
" specification. One a line, as the question it leaves: \"What happens to a contact archived in HubSpot after it",
|
|
101
|
-
" was synced?\"",
|
|
101
|
+
" choice it leaves open - and the tests that assert it, when any do: \"A Dash drains Pulses at 1/s - asserted in",
|
|
102
|
+
" predators.test.ts, The Dash\". One no test asserts is written down all the same. Build on the assumptions",
|
|
103
|
+
" already there; change or remove one the moment the code or the specification shows it wrong - and the tests",
|
|
104
|
+
" it names with it.",
|
|
102
105
|
`- ${SECTIONS.pitfalls}: what went wrong and what fixed it, approaches that did not work.`,
|
|
103
106
|
`- ${SECTIONS.pulses}: written by turri as each pulse ends - read them, never write them.`,
|
|
107
|
+
"A behaviour you had to decide that the person would want to decide - not a detail of how - is not a note: report",
|
|
108
|
+
"it with report_spec_gap, with the assumption you made meanwhile.",
|
|
104
109
|
"Each line stands on its own, for someone who never saw your work. When and where it came from is added",
|
|
105
110
|
"to it for you - do not write it. Where two lines disagree, the later one is right: change the older one",
|
|
106
111
|
'rather than add a contradicting line. Leave a section with nothing new alone: "none" is not a note.',
|
|
@@ -232,7 +237,7 @@ export function createNotesTool(buildDir, onWrite, before = () => { }, source) {
|
|
|
232
237
|
label: "Update notes",
|
|
233
238
|
description: "Update the working notes on this codebase for the next agent - add a line, change one that is no longer " +
|
|
234
239
|
"true, remove one that is wrong - in its sections: where things are, how to build, run and test, " +
|
|
235
|
-
"assumptions made where the specification is silent,
|
|
240
|
+
"assumptions made where the specification is silent, and pitfalls. Never " +
|
|
236
241
|
"requirements: the specification is never an assumption.",
|
|
237
242
|
promptSnippet: "leave the next agent what you learned and assumed about this codebase",
|
|
238
243
|
parameters: Type.Object({
|
|
@@ -283,7 +288,7 @@ export function sectionNamed(name) {
|
|
|
283
288
|
/** How many times an agent that finishes without update_notes is asked to call it. */
|
|
284
289
|
export const NOTES_ASKS = 3;
|
|
285
290
|
/** The turn that asks for the notes: update_notes is the only tool it may use - the work it follows is done. */
|
|
286
|
-
export const NOTES_TURN = { onlyTools: ["update_notes"] };
|
|
291
|
+
export const NOTES_TURN = { onlyTools: ["update_notes", "report_spec_gap"] };
|
|
287
292
|
/** What the notes turn says first, once the harness's own run of `script` has passed on the work. */
|
|
288
293
|
export function passedOutcome(script) {
|
|
289
294
|
return (`The harness ran the project's ${script} on your work itself, and it passed: your work is done and accepted. ` +
|
|
@@ -327,10 +332,53 @@ export function recordPulse(buildDir, text, source) {
|
|
|
327
332
|
// The notes are a help, not the work.
|
|
328
333
|
}
|
|
329
334
|
}
|
|
330
|
-
/**
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
335
|
+
/** Every spec gap reported in this process, with the codebase it was met in: a pulse reads those since it started. */
|
|
336
|
+
const reportedGaps = [];
|
|
337
|
+
/** Where the spec gaps stand now: what `gapsSince` reads from. */
|
|
338
|
+
export function gapMark() {
|
|
339
|
+
return reportedGaps.length;
|
|
340
|
+
}
|
|
341
|
+
/** The spec gaps reported in these codebases since `mark`. */
|
|
342
|
+
export function gapsSince(mark, dirs) {
|
|
343
|
+
const wanted = new Set(dirs.map((dir) => resolve(dir)));
|
|
344
|
+
return reportedGaps.slice(mark).filter((entry) => wanted.has(entry.dir)).map((entry) => entry.gap);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* `report_spec_gap`: a behaviour the specification leaves open that the person would want to decide, put to them as
|
|
348
|
+
* the pulse ends - never into the notes, which are the next agent's. What the agent decided meanwhile goes into the
|
|
349
|
+
* notes' assumptions, so the code built on it is known for what it is until the specification settles it.
|
|
350
|
+
*/
|
|
351
|
+
export function createSpecGapTool(buildDir, source) {
|
|
352
|
+
return defineTool({
|
|
353
|
+
name: "report_spec_gap",
|
|
354
|
+
label: "Report a spec gap",
|
|
355
|
+
description: "Report a spec gap: a behaviour the specification leaves open that the person would want to decide - not a detail " +
|
|
356
|
+
"of how. Decide it meanwhile and carry on: the person is asked once the work ends, and what they decide is " +
|
|
357
|
+
"written into the specification. The assumption you made is added to the notes' assumptions for you.",
|
|
358
|
+
promptSnippet: "tell the person about a behaviour the specification leaves open, and what you decided meanwhile",
|
|
359
|
+
parameters: Type.Object({
|
|
360
|
+
question: Type.String({ description: 'The question it leaves the person, one line: "What happens to a contact archived in HubSpot after it was synced?"' }),
|
|
361
|
+
assumption: Type.String({
|
|
362
|
+
description: "What you decided meanwhile, as its line in the notes' assumptions - with the tests that assert it, when any do: " +
|
|
363
|
+
'"An archived contact is left as synced - asserted in sync.test.ts, archived contacts".',
|
|
364
|
+
}),
|
|
365
|
+
}),
|
|
366
|
+
async execute(_id, params) {
|
|
367
|
+
const question = params.question.replace(/\s+/g, " ").trim();
|
|
368
|
+
const assumption = params.assumption.replace(/\s+/g, " ").trim();
|
|
369
|
+
if (question === "" || assumption === "")
|
|
370
|
+
throw new Error("Nothing reported - a spec gap needs both the question it leaves and the assumption you made meanwhile.");
|
|
371
|
+
// However many there are, spec gaps are put to the person - never a reason to stop the pulse.
|
|
372
|
+
reportedGaps.push({ dir: resolve(buildDir), gap: { question, assumption } });
|
|
373
|
+
const known = readNotes(buildDir).assumptions.some((line) => body(line) === assumption);
|
|
374
|
+
if (!known)
|
|
375
|
+
recordNote(buildDir, "assumptions", assumption, source ?? "spec gap");
|
|
376
|
+
return {
|
|
377
|
+
content: [{ type: "text", text: `Reported. The assumption is in the notes' assumptions${known ? " already" : " now"} - do not add it again.` }],
|
|
378
|
+
details: { question, assumption },
|
|
379
|
+
};
|
|
380
|
+
},
|
|
381
|
+
});
|
|
334
382
|
}
|
|
335
383
|
/** The notes, for an agent that reads them but leaves none - a judge. Empty when there are none. */
|
|
336
384
|
export function notesToRead(buildDir) {
|
|
@@ -367,6 +415,7 @@ export function notesForSession(buildDir, source) {
|
|
|
367
415
|
return true;
|
|
368
416
|
};
|
|
369
417
|
return {
|
|
418
|
+
gapTool: createSpecGapTool(buildDir, source),
|
|
370
419
|
tool: createNotesTool(buildDir, (text) => {
|
|
371
420
|
written = true;
|
|
372
421
|
knownNotes.set(file, text);
|
|
@@ -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");
|