@turri-dev/cli 0.1.3-next.f74e98a → 0.1.4-next.246fd84

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.
@@ -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, request = {}) {
992
- const file = resolve(options.cwd, plainFile);
993
- asked.set(file, { ...asked.get(file), ...request });
994
- const states = withParseCache(() => chainsFor(file).flatMap((chain) => toBuild(chain).map((entry) => track(chain.slice(0, chain.indexOf(entry) + 1), file, request))));
995
- // Asked again, a halted codebase is tried again.
996
- for (const state of states)
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
  },
@@ -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: [], gaps: [], pitfalls: [], pulses: [] };
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
- section = ORDER.find((key) => SECTIONS[key].toLowerCase() === heading[1].trim().toLowerCase()) ?? section;
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. Build on the assumptions already there; change or remove one the moment the code",
97
- " or the specification shows it wrong.",
98
- `- ${SECTIONS.gaps}: what the specification should say and does not - a behaviour you had to decide that the`,
99
- " person would want to decide, not a detail of how. Each is put to the person, to be written into the",
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, spec gaps the person should decide, and pitfalls. Never " +
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
- /** The lines of a section added since `before` - a pulse's new spec gaps, say - without when and where they came from. */
331
- export function linesAddedSince(before, buildDir, section) {
332
- const known = new Set(before[section]);
333
- return readNotes(buildDir)[section].filter((line) => !known.has(line)).map((line) => line.replace(/^\[[^\]]*\]\s*/, ""));
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) => ` ${String(test)}`), " </acceptance tests>");
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 { linesAddedSince, NOTES_TURN, notesForSession, passedOutcome, readNotes, recordNote, withNotes } from "./notes.js";
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 notes as the agent finds them: what it adds - a spec gap above all - is told with the result.
272
- const notesBefore = readNotes(buildDir);
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
- // Read before a rollback takes the notes back: a gap the agent met is the person's to decide, whether it landed or not.
545
- specGaps: linesAddedSince(notesBefore, buildDir, "gaps"),
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. Put each to the user, and write the ones they decide into the spec:",
583
- ...gaps.map((gap) => `- ${gap}`),
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.
@@ -329,7 +329,7 @@ function detailLines(item, width, style) {
329
329
  // The rest of its text - its first line is the row - against the record's, for a change.
330
330
  const rest = item.state === "changed" ? lineDiff(item.was ?? "", item.text).slice(1) : item.text.split("\n").slice(1).map((line) => ({ was: line, now: line }));
331
331
  for (const line of rest) {
332
- const drawn = item.state === "removed" ? style.removed(strike(line.now ?? "")) : item.state === "changed" ? diffLine(line, style) : highlighted(line.now ?? "", style);
332
+ const drawn = item.state === "removed" ? style.removed(strike(line.now ?? "")) : item.state === "changed" ? diffLine(line, style) : item.state === "new" ? style.added(line.now ?? "") : highlighted(line.now ?? "", style);
333
333
  lines.push(...wrapped(drawn, width));
334
334
  }
335
335
  if (item.acceptance.length > 0) {
@@ -476,7 +476,9 @@ export function specMapLines(pages, state, width, style, _jobs = [], now = Date.
476
476
  ? style.removed(strike(first))
477
477
  : item.state === "changed" && wasFirst !== first
478
478
  ? wordDiff(wasFirst, first, style)
479
- : highlighted(first, style);
479
+ : item.state === "new"
480
+ ? style.added(first)
481
+ : highlighted(first, style);
480
482
  // Wrapped, never cut short: what follows a `…` is what a person came to read. The lines after the first hang
481
483
  // under the item's text, past its bullet.
482
484
  const [head = "", ...rest] = hanging(drawn, first, room);
@@ -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:"]),
@@ -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"];
@@ -0,0 +1,4 @@
1
+ /** pi's line on the way out that says how to resume the session, with turri in place of pi as the command to run. */
2
+ export function resumeLine(text) {
3
+ return text.replace(/^((?:\x1b\[[0-9;]*m)*To resume this session:(?:\x1b\[[0-9;]*m)*\s+)pi(?= )/m, "$1turri");
4
+ }