@turri-dev/cli 0.1.3-next.f74e98a → 0.1.4-next.18d69fb
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 +22 -1
- package/dist/ask.js +34 -15
- package/dist/extension.js +63 -19
- package/dist/genome.js +1 -1
- package/dist/loop-tools.js +20 -7
- package/dist/pi-launch.js +5 -0
- package/dist/question-dialog.js +9 -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 +116 -10
- 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/release-tools.js +1 -1
- package/dist/resume-line.js +4 -0
- package/dist/spec-tools.js +10 -45
- package/docs/authoring-plain.md +9 -6
- package/docs/installation.md +3 -0
- package/docs/regenerating.md +39 -13
- package/docs/skills-and-gates.md +5 -3
- package/docs/using.md +5 -4
- 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
|
@@ -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;
|
|
@@ -361,6 +378,8 @@ export function createWorkLoop(options) {
|
|
|
361
378
|
const waiting = (why) => ({ module: entry.moduleName, why: `waiting for ${entry.moduleName}: ${why}` });
|
|
362
379
|
if (below.running !== undefined)
|
|
363
380
|
return waiting(below.running.planned.track === "conformance" ? "its conformance tests are being written" : "the code it is built on is not built yet");
|
|
381
|
+
if (below.held !== undefined)
|
|
382
|
+
return waiting("you halted it - nothing is built on it until it is asked for again");
|
|
364
383
|
const spec = chainSpec(below.chain);
|
|
365
384
|
if (codeWork(builtOf(below), spec.tree).length > 0)
|
|
366
385
|
return waiting("the code it is built on is not built yet");
|
|
@@ -469,6 +488,11 @@ export function createWorkLoop(options) {
|
|
|
469
488
|
/** A dry run's first jobs, by codebase: what each would start. */
|
|
470
489
|
const firsts = new Map();
|
|
471
490
|
const startJob = (state, spec, from, planned) => {
|
|
491
|
+
// Halted while it was being planned: the plan is made, nothing starts.
|
|
492
|
+
if (state.held !== undefined) {
|
|
493
|
+
state.why = state.held.why;
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
472
496
|
const tip = tipEntry(state);
|
|
473
497
|
if (options.dryRun === true) {
|
|
474
498
|
firsts.set(tip.buildDir, { spec: planned.track === "conformance" ? spec : { ...spec, tree: planned.step.tree }, planned });
|
|
@@ -756,16 +780,37 @@ export function createWorkLoop(options) {
|
|
|
756
780
|
}
|
|
757
781
|
return states;
|
|
758
782
|
};
|
|
783
|
+
/** A codebase the loop no longer looks after: nothing of it is shown, planned or waited for. */
|
|
784
|
+
const forget = (state) => {
|
|
785
|
+
const key = tipEntry(state).buildDir;
|
|
786
|
+
modules.delete(key);
|
|
787
|
+
order.splice(order.indexOf(key), 1);
|
|
788
|
+
};
|
|
759
789
|
const lookAll = async () => {
|
|
790
|
+
// A .plain file taken out of the project is asked for no more, and a codebase whose module's file is gone is
|
|
791
|
+
// forgotten - nothing running there - rather than shown waiting for a plan it will never have.
|
|
792
|
+
for (const file of [...asked.keys()])
|
|
793
|
+
if (!existsSync(file))
|
|
794
|
+
asked.delete(file);
|
|
795
|
+
for (const key of [...order]) {
|
|
796
|
+
const state = modules.get(key);
|
|
797
|
+
if (state.running === undefined && !existsSync(tipEntry(state).plainFile))
|
|
798
|
+
forget(state);
|
|
799
|
+
}
|
|
760
800
|
const now = active();
|
|
761
801
|
looking = now;
|
|
762
802
|
for (const key of order) {
|
|
763
803
|
const state = modules.get(key);
|
|
764
|
-
// A codebase left behind - built on since - is not looked at, unless a job of it is still running
|
|
804
|
+
// A codebase left behind - built on since - is not looked at, unless a job of it is still running; what it had
|
|
805
|
+
// planned, or waited for, is no longer shown.
|
|
765
806
|
if (now.has(state) || state.running !== undefined)
|
|
766
807
|
await look(state);
|
|
767
|
-
else
|
|
808
|
+
else {
|
|
768
809
|
state.why = "left behind: the code is built on further up the chain, where changes to it are made";
|
|
810
|
+
state.waitsFor = undefined;
|
|
811
|
+
state.plan = undefined;
|
|
812
|
+
state.conformancePlan = undefined;
|
|
813
|
+
}
|
|
769
814
|
}
|
|
770
815
|
};
|
|
771
816
|
const look = async (state) => {
|
|
@@ -776,6 +821,12 @@ export function createWorkLoop(options) {
|
|
|
776
821
|
}
|
|
777
822
|
catch (error) {
|
|
778
823
|
state.why = `its spec does not parse: ${error instanceof Error ? error.message : String(error)}`;
|
|
824
|
+
// Not planned from a spec that does not parse: what it waited for, and the plan, were the spec's before.
|
|
825
|
+
state.waitsFor = undefined;
|
|
826
|
+
if (state.running === undefined) {
|
|
827
|
+
state.plan = undefined;
|
|
828
|
+
state.conformancePlan = undefined;
|
|
829
|
+
}
|
|
779
830
|
return;
|
|
780
831
|
}
|
|
781
832
|
const specKey = keyOf(spec);
|
|
@@ -828,6 +879,11 @@ export function createWorkLoop(options) {
|
|
|
828
879
|
return;
|
|
829
880
|
}
|
|
830
881
|
hashResources(state, spec);
|
|
882
|
+
if (state.held !== undefined) {
|
|
883
|
+
state.why = state.held.why;
|
|
884
|
+
state.waitsFor = undefined;
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
831
887
|
if (state.halted !== undefined) {
|
|
832
888
|
if (state.halted.specKey === specKey) {
|
|
833
889
|
state.why = state.halted.why;
|
|
@@ -988,13 +1044,18 @@ export function createWorkLoop(options) {
|
|
|
988
1044
|
return state;
|
|
989
1045
|
};
|
|
990
1046
|
const loop = {
|
|
991
|
-
async bringUp(plainFile,
|
|
992
|
-
const
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1047
|
+
async bringUp(plainFile, given = {}) {
|
|
1048
|
+
const { specChange, ...request } = given;
|
|
1049
|
+
const files = codebasesOf(resolve(options.cwd, plainFile));
|
|
1050
|
+
for (const file of files)
|
|
1051
|
+
asked.set(file, { ...asked.get(file), ...request });
|
|
1052
|
+
const states = withParseCache(() => files.flatMap((file) => chainsFor(file).flatMap((chain) => toBuild(chain).map((entry) => track(chain.slice(0, chain.indexOf(entry) + 1), file, request)))));
|
|
1053
|
+
// Asked again, a halted codebase is tried again - one the person halted too, unless only its spec changed.
|
|
1054
|
+
for (const state of states) {
|
|
997
1055
|
state.halted = undefined;
|
|
1056
|
+
if (specChange !== true)
|
|
1057
|
+
state.held = undefined;
|
|
1058
|
+
}
|
|
998
1059
|
await tick();
|
|
999
1060
|
return [...new Set(states)].map(statusOf);
|
|
1000
1061
|
},
|
|
@@ -1062,7 +1123,7 @@ export function createWorkLoop(options) {
|
|
|
1062
1123
|
report.scheduled = (state.scheduled ?? []).map((group) => ({ frids: group.specs.map((touched) => touched.frid), unbuilt: group.unbuilt }));
|
|
1063
1124
|
if (state.running !== undefined)
|
|
1064
1125
|
report.running = { jobId: state.running.jobId, title: state.running.planned.title };
|
|
1065
|
-
const halted = state.halted?.why ?? state.conformanceHalted?.why;
|
|
1126
|
+
const halted = state.held?.why ?? state.halted?.why ?? state.conformanceHalted?.why;
|
|
1066
1127
|
if (halted !== undefined)
|
|
1067
1128
|
report.why = halted;
|
|
1068
1129
|
if (state.startOver !== undefined)
|
|
@@ -1149,6 +1210,7 @@ export function createWorkLoop(options) {
|
|
|
1149
1210
|
}
|
|
1150
1211
|
state.conformanceHalted = undefined;
|
|
1151
1212
|
state.halted = undefined;
|
|
1213
|
+
state.held = undefined;
|
|
1152
1214
|
}
|
|
1153
1215
|
await tick();
|
|
1154
1216
|
return [...new Set(states)].map((state) => {
|
|
@@ -1191,6 +1253,50 @@ export function createWorkLoop(options) {
|
|
|
1191
1253
|
startAgain(entry, "asked to be regenerated from scratch");
|
|
1192
1254
|
return loop.bringUp(plainFile, request);
|
|
1193
1255
|
},
|
|
1256
|
+
halt(target, reason) {
|
|
1257
|
+
const named = target.trim();
|
|
1258
|
+
// A planned row's module - the longest name its id starts with, as a name may hold a hyphen - or a module by its name.
|
|
1259
|
+
const state = order
|
|
1260
|
+
.map((key) => modules.get(key))
|
|
1261
|
+
.filter((candidate) => {
|
|
1262
|
+
const name = tipEntry(candidate).moduleName;
|
|
1263
|
+
return named === name || named.startsWith(`planned-${name}-`);
|
|
1264
|
+
})
|
|
1265
|
+
.sort((a, b) => tipEntry(b).moduleName.length - tipEntry(a).moduleName.length)[0];
|
|
1266
|
+
if (state === undefined)
|
|
1267
|
+
return undefined;
|
|
1268
|
+
const tip = tipEntry(state);
|
|
1269
|
+
if (state.running === undefined && !existsSync(tip.plainFile)) {
|
|
1270
|
+
forget(state);
|
|
1271
|
+
asked.delete(tip.plainFile);
|
|
1272
|
+
return `${tip.moduleName} is no longer in the project - ${basename(tip.plainFile)} is gone - so nothing was to be built there: it is dropped from the regeneration.`;
|
|
1273
|
+
}
|
|
1274
|
+
const planned = statusOf(state).planned.length;
|
|
1275
|
+
const said = reason !== undefined && reason.trim() !== "" ? ` (${reason.trim()})` : "";
|
|
1276
|
+
state.held = { why: `you halted it${said} - nothing runs here until it is asked for again` };
|
|
1277
|
+
state.why = state.held.why;
|
|
1278
|
+
state.waitsFor = undefined;
|
|
1279
|
+
state.plan = undefined;
|
|
1280
|
+
state.conformancePlan = undefined;
|
|
1281
|
+
const running = state.running;
|
|
1282
|
+
if (running !== undefined && running.stopping !== true) {
|
|
1283
|
+
running.stopping = true;
|
|
1284
|
+
options.registry.stop(running.jobId, `Halted by you${said}: what it had done is rolled back, and nothing more runs in ${tip.moduleName} until it is asked for again.`);
|
|
1285
|
+
}
|
|
1286
|
+
const above = order
|
|
1287
|
+
.map((key) => modules.get(key))
|
|
1288
|
+
.filter((other) => other !== state && looking.has(other) && other.chain.some((entry) => entry.buildDir === tip.buildDir))
|
|
1289
|
+
.map((other) => tipEntry(other).moduleName);
|
|
1290
|
+
void tick();
|
|
1291
|
+
return [
|
|
1292
|
+
`Halted ${tip.moduleName}${said}:`,
|
|
1293
|
+
running !== undefined ? ` its running pulse ${running.jobId} is being stopped, and what it had done rolled back;` : "",
|
|
1294
|
+
planned > 0 ? ` its ${planned} planned pulse${planned === 1 ? "" : "s"} will not start.` : " nothing more is planned there.",
|
|
1295
|
+
" Pulses that landed stay.",
|
|
1296
|
+
above.length > 0 ? ` ${above.join(", ")} ${above.length === 1 ? "waits" : "wait"} for it.` : "",
|
|
1297
|
+
` It runs again only when asked for - regenerate ${basename(tip.plainFile)} - a spec change does not start it.`,
|
|
1298
|
+
].join("");
|
|
1299
|
+
},
|
|
1194
1300
|
status() {
|
|
1195
1301
|
return order.map((key) => statusOf(modules.get(key)));
|
|
1196
1302
|
},
|
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");
|
|
@@ -14,7 +14,7 @@ import { buildSpecDiffPrompt, describeSpecChange, reviewedSpec, sectionModules }
|
|
|
14
14
|
import { abandonWork, recordHistory, startWork } from "./lineage.js";
|
|
15
15
|
import { createWorkLedger, SUBMIT_WORK_NOTE } from "./submit-work.js";
|
|
16
16
|
import { CODER_COMPACTION } from "./compaction.js";
|
|
17
|
-
import {
|
|
17
|
+
import { gapMark, gapsSince, NOTES_TURN, notesForSession, passedOutcome, recordNote, withNotes } from "./notes.js";
|
|
18
18
|
import { createLoopWatch, createScriptTool, failureSignature, findTestScripts, projectRootFor, requireTestScripts, runUnitScript, STUCK_ROUNDS, withoutConformance, withTestingScripts, } from "./test-scripts.js";
|
|
19
19
|
import { createWebTools, WEB_TOOLS } from "./web.js";
|
|
20
20
|
import { createCanItFailTool, createMyDiffTool, selfCheckNote } from "./self-check.js";
|
|
@@ -268,8 +268,8 @@ export async function renderSpecDiff(options) {
|
|
|
268
268
|
...(options.cases !== undefined ? { cases: options.cases } : {}),
|
|
269
269
|
});
|
|
270
270
|
const digestsBefore = digestFiles(buildDir);
|
|
271
|
-
// The
|
|
272
|
-
const
|
|
271
|
+
// The spec gaps its agents report from here on are told with the result.
|
|
272
|
+
const gapsFrom = gapMark();
|
|
273
273
|
/** What the debugger found, each time it was asked: the pulse's line in the notes says it. */
|
|
274
274
|
const diagnosed = [];
|
|
275
275
|
const runs = [];
|
|
@@ -305,6 +305,7 @@ export async function renderSpecDiff(options) {
|
|
|
305
305
|
cwd: buildDir,
|
|
306
306
|
customTools: [
|
|
307
307
|
notes.tool,
|
|
308
|
+
notes.gapTool,
|
|
308
309
|
work.tool,
|
|
309
310
|
...createWebTools(),
|
|
310
311
|
createScriptTool({ kind: "unit", script: scripts.unit, run: () => runUnitScript(buildDir, scripts.unit), runs, watch: loop }),
|
|
@@ -313,7 +314,7 @@ export async function renderSpecDiff(options) {
|
|
|
313
314
|
...fence.tools,
|
|
314
315
|
createCopyResourceTool({ resources: linkedResources, dir: buildDir }),
|
|
315
316
|
],
|
|
316
|
-
tools: [...fence.names, "run_unit_tests", ...selfChecks.map((tool) => tool.name), ...services.names, "update_notes", "submit_work", "copy_resource", ...WEB_TOOLS],
|
|
317
|
+
tools: [...fence.names, "run_unit_tests", ...selfChecks.map((tool) => tool.name), ...services.names, "update_notes", "report_spec_gap", "submit_work", "copy_resource", ...WEB_TOOLS],
|
|
317
318
|
model: options.model,
|
|
318
319
|
label: `${moduleName} spec change coding agent`,
|
|
319
320
|
role: "coding",
|
|
@@ -499,6 +500,7 @@ export async function renderSpecDiff(options) {
|
|
|
499
500
|
...(options.cases !== undefined ? { cases: options.cases } : {}),
|
|
500
501
|
label: `${moduleName} spec change`,
|
|
501
502
|
...(scripts.runOn === "copy" ? { unitScript: scripts.unit } : {}),
|
|
503
|
+
testingScript: { path: relative(root, scripts.unit), content: readFileSync(scripts.unit, "utf8") },
|
|
502
504
|
model: options.judgeModel ?? modelSpec(inheritModel(configForSpec(tip.plainFile).judgeModel, modelOnly(options.model))),
|
|
503
505
|
send: async (next) => {
|
|
504
506
|
response = await ask(executor, next, options.onDelta);
|
|
@@ -541,8 +543,8 @@ export async function renderSpecDiff(options) {
|
|
|
541
543
|
testRemovals,
|
|
542
544
|
files,
|
|
543
545
|
...(steering !== undefined && steering.steers > 0 ? { steered: [...steering.notes] } : {}),
|
|
544
|
-
//
|
|
545
|
-
specGaps:
|
|
546
|
+
// A gap the agent met is the person's to decide, whether it landed or not.
|
|
547
|
+
specGaps: gapsSince(gapsFrom, [buildDir]),
|
|
546
548
|
diagnosed,
|
|
547
549
|
};
|
|
548
550
|
/** The change landed: what the code is built from is now the spec with the change in, committed with it. */
|
|
@@ -579,8 +581,10 @@ export function specGapsText(gaps) {
|
|
|
579
581
|
return [];
|
|
580
582
|
return [
|
|
581
583
|
"",
|
|
582
|
-
"Spec gaps its agents met - the spec does not say, so they decided
|
|
583
|
-
|
|
584
|
+
"Spec gaps its agents met - the spec does not say, so they decided, each as an assumption in the codebase's notes. Put " +
|
|
585
|
+
"each to the user, and write the ones they decide into the spec - the pulse that builds it is told the assumption " +
|
|
586
|
+
"it replaces:",
|
|
587
|
+
...gaps.map((gap) => `- ${gap.question} Meanwhile: ${gap.assumption}`),
|
|
584
588
|
];
|
|
585
589
|
}
|
|
586
590
|
/** What a rendered spec change did, for the agent and the person. */
|
|
@@ -21,7 +21,7 @@ import { keepingTestsUnchanged, listFiles } from "./testing.js";
|
|
|
21
21
|
* job ends on it: tests that pass on their own are not done until the whole suite passes with them.
|
|
22
22
|
*/
|
|
23
23
|
/** On top of the file tools, which are fenced to the build directory by `confine.ts`. */
|
|
24
|
-
const FIX_TOOLS = ["run_unit_tests", "run_conformance_tests", "update_notes"];
|
|
24
|
+
const FIX_TOOLS = ["run_unit_tests", "run_conformance_tests", "update_notes", "report_spec_gap"];
|
|
25
25
|
const DEFAULT_MAX_REGRESSION_ROUNDS = ATTEMPTS.regression;
|
|
26
26
|
/** The module's conformance tests, when there are any: the chain's, copied up, and its own. */
|
|
27
27
|
function conformanceDirs(conformanceRoot, moduleName) {
|
|
@@ -39,6 +39,7 @@ async function repairRegression(input) {
|
|
|
39
39
|
cwd: input.buildDir,
|
|
40
40
|
customTools: [
|
|
41
41
|
notes.tool,
|
|
42
|
+
notes.gapTool,
|
|
42
43
|
...createWebTools(),
|
|
43
44
|
...fence.tools,
|
|
44
45
|
createScriptTool({ kind: "unit", script: input.scripts.unit, run: () => runUnitScript(input.buildDir, input.scripts.unit), runs }),
|
|
@@ -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.
|