@turri-dev/cli 0.1.3-next.f74e98a → 0.1.4-next.318a98a
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/extension.js +61 -18
- 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 +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 +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 +2 -2
- package/docs/using.md +4 -3
- 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/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.
|
|
@@ -63,6 +63,17 @@ export function unitReviewTask(options) {
|
|
|
63
63
|
options.spec.trim(),
|
|
64
64
|
"</specification>",
|
|
65
65
|
"",
|
|
66
|
+
...(options.testingScript !== undefined
|
|
67
|
+
? [
|
|
68
|
+
"The harness runs the unit tests with this script, and it decides whether they pass. Read it for which tests it",
|
|
69
|
+
"runs and how: a test it never reaches - a file it does not pick up, a folder it does not look in - is not run, and",
|
|
70
|
+
"checks nothing.",
|
|
71
|
+
`<unit testing script path="${options.testingScript.path}">`,
|
|
72
|
+
options.testingScript.content.trimEnd(),
|
|
73
|
+
"</unit testing script>",
|
|
74
|
+
"",
|
|
75
|
+
]
|
|
76
|
+
: []),
|
|
66
77
|
...(options.cases !== undefined ? [...codeReviewCasesBlock(options.cases), ""] : []),
|
|
67
78
|
// A job that touches no functional spec is held only to that: the checks of behaviour are not its.
|
|
68
79
|
...(mechanical ? [] : ["Check that the code does what the specification states for this work:"]),
|
package/dist/release-tools.js
CHANGED
|
@@ -142,7 +142,7 @@ export function createDeployTools(options) {
|
|
|
142
142
|
},
|
|
143
143
|
});
|
|
144
144
|
// The one halt: with the pulses in the conversation, and without them for the release agent.
|
|
145
|
-
const stopTool = haltTool(options.jobs !== undefined ? { jobs: options.jobs } : {});
|
|
145
|
+
const stopTool = haltTool(options.jobs !== undefined ? { jobs: options.jobs, ...(options.workLoop !== undefined ? { workLoop: options.workLoop } : {}) } : {});
|
|
146
146
|
return [deploy, startTool, stopTool];
|
|
147
147
|
}
|
|
148
148
|
export const DEPLOY_TOOLS = ["release", "activate", "halt"];
|
package/dist/spec-tools.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
1
|
+
import { basename, relative, resolve } from "node:path";
|
|
3
2
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
4
3
|
import { Type } from "typebox";
|
|
5
|
-
import {
|
|
4
|
+
import { plainFileParser } from "./plain/parser.js";
|
|
5
|
+
import { importingModules, isImportModule } from "./regenerate/requires.js";
|
|
6
6
|
import { resolveTemplateDirs } from "./regenerate/paths.js";
|
|
7
7
|
import { applySpecDiff, plainFilesOf, revertSpecDiff } from "./regenerate/regenerate.js";
|
|
8
8
|
import { MissingFunctionalitiesError } from "./plain/errors.js";
|
|
@@ -62,24 +62,12 @@ export function createSpecDiffTools(options) {
|
|
|
62
62
|
// file that cannot be parsed. A module with no functional specs yet is not broken. An import
|
|
63
63
|
// module - definitions only, say - is not a module of its own: it is parsed as the modules
|
|
64
64
|
// that import it read it, the one the change is built for among them.
|
|
65
|
-
const importers = (file) => {
|
|
66
|
-
const name = basename(file, ".plain");
|
|
67
|
-
const dir = dirname(resolve(cwd, file));
|
|
68
|
-
return readdirSync(dir)
|
|
69
|
-
.filter((entry) => entry.endsWith(".plain"))
|
|
70
|
-
.map((entry) => join(dir, entry))
|
|
71
|
-
.filter((path) => {
|
|
72
|
-
try {
|
|
73
|
-
return (readPlainSourceMetadata(readFileSync(path, "utf8")).metadata[IMPORT_DIRECTIVE] ?? []).includes(name);
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
return false;
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
};
|
|
80
|
-
const imported = importedNames(resolveTemplateDirs(resolve(cwd, params.plain_file)));
|
|
81
65
|
const toParse = [
|
|
82
|
-
...new Set([params.plain_file, ...touched].flatMap((file) =>
|
|
66
|
+
...new Set([params.plain_file, ...touched].flatMap((file) => {
|
|
67
|
+
const path = resolve(cwd, file);
|
|
68
|
+
const importing = isImportModule(path) ? importingModules(path) : [];
|
|
69
|
+
return importing.length > 0 ? importing : [path];
|
|
70
|
+
})),
|
|
83
71
|
];
|
|
84
72
|
for (const path of toParse) {
|
|
85
73
|
const file = relative(cwd, path);
|
|
@@ -100,7 +88,8 @@ export function createSpecDiffTools(options) {
|
|
|
100
88
|
owed = false;
|
|
101
89
|
liveReview?.notice(`${params.plain_file}, after the change to ${touched.join(", ")}`);
|
|
102
90
|
// In the files, the change is work: the loop works out what is left to build and plans it.
|
|
103
|
-
|
|
91
|
+
// A spec change, not a request: a module the person halted stays halted.
|
|
92
|
+
const statuses = await loop.bringUp(params.plain_file, { specChange: true, ...(params.guidance !== undefined ? { guidance: params.guidance } : {}) });
|
|
104
93
|
const lines = [
|
|
105
94
|
result === "applied" ? `Applied the change to ${touched.join(", ")}.` : `The change to ${touched.join(", ")} was already applied.`,
|
|
106
95
|
`Building in the background:\n${describeModules(statuses)}\n`,
|
|
@@ -129,27 +118,3 @@ export function createSpecDiffTools(options) {
|
|
|
129
118
|
},
|
|
130
119
|
};
|
|
131
120
|
}
|
|
132
|
-
/** The module names some .plain file in these folders imports: import modules, not codebases. */
|
|
133
|
-
export function importedNames(dirs) {
|
|
134
|
-
const names = new Set();
|
|
135
|
-
for (const dir of dirs) {
|
|
136
|
-
let entries;
|
|
137
|
-
try {
|
|
138
|
-
entries = readdirSync(dir).filter((name) => name.endsWith(PLAIN_SOURCE_FILE_EXTENSION));
|
|
139
|
-
}
|
|
140
|
-
catch {
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
for (const entry of entries) {
|
|
144
|
-
try {
|
|
145
|
-
const imports = readPlainSourceMetadata(readFileSync(join(dir, entry), "utf8")).metadata[IMPORT_DIRECTIVE];
|
|
146
|
-
for (const name of imports ?? [])
|
|
147
|
-
names.add(name);
|
|
148
|
-
}
|
|
149
|
-
catch {
|
|
150
|
-
// A file that does not read imports nothing.
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
return names;
|
|
155
|
-
}
|