@turri-dev/cli 0.1.3-next.f74e98a → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/extension.js CHANGED
@@ -90,15 +90,23 @@ aside) {
90
90
  // The question, with what the asker gives to read for it - a brief to confirm - above it; what it said of its work
91
91
  // before asking is kept in its record, never put in the dialog.
92
92
  // A dialog of turri's own: a long brief scrolls above the question, and the options always show (question-dialog.ts).
93
- const dialog = (question, context, options) => ctx.ui.custom((tui, theme, _keybindings, done) => new QuestionDialog({
94
- question,
95
- ...(context !== undefined ? { context } : {}),
96
- ...(options !== undefined ? { options } : {}),
97
- rows: () => tui.terminal.rows,
98
- style: { accent: (text) => theme.fg("accent", text), muted: (text) => theme.fg("muted", text), bold: (text) => theme.bold(text) },
99
- done,
100
- requestRender: () => tui.requestRender(),
101
- }));
93
+ const dialog = (question, context, options) => ctx.ui.custom((tui, theme, _keybindings, done) => {
94
+ const shown = new QuestionDialog({
95
+ question,
96
+ ...(context !== undefined ? { context } : {}),
97
+ ...(options !== undefined ? { options } : {}),
98
+ rows: () => tui.terminal.rows,
99
+ style: { accent: (text) => theme.fg("accent", text), muted: (text) => theme.fg("muted", text), bold: (text) => theme.bold(text) },
100
+ done: (answer) => {
101
+ if (questionShown === shown)
102
+ questionShown = undefined;
103
+ done(answer);
104
+ },
105
+ requestRender: () => tui.requestRender(),
106
+ });
107
+ questionShown = shown;
108
+ return shown;
109
+ });
102
110
  const asking = (asked) => runAsk(asked, {
103
111
  select: (title, options, context) => dialog(title, context, options),
104
112
  input: (title, context) => dialog(title, context),
@@ -117,6 +125,12 @@ aside) {
117
125
  }
118
126
  /** The side panel, as a question open in the chat reaches it: set once the extension has its panel. */
119
127
  let questionPeek;
128
+ /**
129
+ * The question open in the chat now, if any. The side panel gives the keys back to it: pi would give them to what had the
130
+ * focus when the panel first opened, and a panel kept beside the chat outlives that question - the keys would go to a
131
+ * dialog already answered, and nothing would take them.
132
+ */
133
+ let questionShown;
120
134
  /** A widget with nothing to draw: below the editor, it takes no line. */
121
135
  const PEEK_KEY_WIDGET = "turri-peek-key";
122
136
  /**
@@ -1508,6 +1522,7 @@ function registerJobTools(pi, jobs, cwd) {
1508
1522
  }
1509
1523
  needsLooking.delete(jobs);
1510
1524
  if (panel !== undefined) {
1525
+ panel.keepChatFocus();
1511
1526
  panel.handle?.setHidden(false);
1512
1527
  panel.keys = true;
1513
1528
  panel.handle?.focus();
@@ -1515,7 +1530,21 @@ function registerJobTools(pi, jobs, cwd) {
1515
1530
  }
1516
1531
  // Not awaited: the panel lives on while the jobs it shows run.
1517
1532
  void ctx.ui.custom((tui, theme, _keybindings, done) => {
1518
- const close = () => done(undefined);
1533
+ // What the chat shows when the panel takes the keys - its editor, or a pi dialog - for when it gives them back.
1534
+ const focusedNow = () => tui.getFocusedComponent?.() ?? null;
1535
+ let chatFocus = focusedNow();
1536
+ const keepChatFocus = () => {
1537
+ const focused = focusedNow();
1538
+ if (focused !== null && focused !== component && !(focused instanceof QuestionDialog))
1539
+ chatFocus = focused;
1540
+ };
1541
+ // The keys back to the chat: to the question open there now, else to what it showed - never to a question answered
1542
+ // while the panel was open.
1543
+ const giveBack = () => entry.handle?.unfocus({ target: questionShown ?? chatFocus });
1544
+ const close = () => {
1545
+ giveBack();
1546
+ done(undefined);
1547
+ };
1519
1548
  panelTerminal = tui.terminal;
1520
1549
  let component;
1521
1550
  // Beside the chat: the chat drawn narrower, on the left, and a click on either side giving it the keys - undone
@@ -1548,10 +1577,11 @@ function registerJobTools(pi, jobs, cwd) {
1548
1577
  // Beside the chat, back to it: the chat has the keys, the panel stays.
1549
1578
  const toChat = () => {
1550
1579
  entry.keys = false;
1551
- entry.handle?.unfocus();
1580
+ giveBack();
1552
1581
  tui.requestRender();
1553
1582
  };
1554
1583
  const toPanel = () => {
1584
+ keepChatFocus();
1555
1585
  entry.keys = true;
1556
1586
  entry.handle?.focus();
1557
1587
  tui.requestRender();
@@ -1604,7 +1634,7 @@ function registerJobTools(pi, jobs, cwd) {
1604
1634
  return focused !== null && focused !== component;
1605
1635
  },
1606
1636
  });
1607
- const entry = { view, close, keys: true };
1637
+ const entry = { view, close, keepChatFocus, keys: true };
1608
1638
  panel = entry;
1609
1639
  layOut();
1610
1640
  // Every key goes to the panel while it shows, whatever has the focus: a question the chat model asks in the
package/dist/pi-launch.js CHANGED
@@ -11,6 +11,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
11
11
  import { main, SettingsManager } from "@earendil-works/pi-coding-agent";
12
12
  import { ProcessTerminal } from "@earendil-works/pi-tui";
13
13
  import "./terminal-images.js";
14
+ import { resumeLine } from "./resume-line.js";
14
15
  /**
15
16
  * pi, started the way its own CLI starts it, for `turri`.
16
17
  *
@@ -31,6 +32,10 @@ const setTitle = ProcessTerminal.prototype.setTitle;
31
32
  ProcessTerminal.prototype.setTitle = function (title) {
32
33
  setTitle.call(this, title.replace(/^π(?= |$)/, "🪼 turri"));
33
34
  };
35
+ // What pi prints on the way out to resume the session names pi - its APP_NAME, a constant - which may not be on the
36
+ // PATH at all: turri takes the same flags, so it is turri the line names.
37
+ const write = process.stdout.write.bind(process.stdout);
38
+ process.stdout.write = ((chunk, ...rest) => write(typeof chunk === "string" ? resumeLine(chunk) : chunk, ...rest));
34
39
  // pi's own keys stand: a new line is shift+enter or ctrl+j. turri's side panel is on ctrl+h (job-panel.ts PANEL_KEY).
35
40
  // What pi's CLI does before main: the process title, pi's environment markers, the HTTP
36
41
  // dispatcher. Not exported, so loaded from the file - beside pi's entry point, wherever npm put it.
@@ -58,18 +58,23 @@ export class QuestionDialog {
58
58
  const choices = this.options.options ?? [];
59
59
  const body = this.input !== undefined
60
60
  ? this.input.render(inner)
61
- : choices.map((choice, index) => (index === this.selected ? style.accent(`› ${choice}`) : ` ${choice}`));
61
+ : // A long option wraps, its lines under its first, rather than cut off where the person cannot read it.
62
+ choices.flatMap((choice, index) => wrapTextWithAnsi(choice, Math.max(1, inner - 2)).map((line, at) => {
63
+ const shown = `${at === 0 && index === this.selected ? "›" : " "} ${line}`;
64
+ return index === this.selected ? style.accent(shown) : shown;
65
+ }));
62
66
  const hint = this.input !== undefined
63
67
  ? "enter answers · esc dismisses"
64
68
  : "↑↓ choose · enter picks · esc dismisses";
65
69
  // The brief gets what the question, the options and the hints leave of the screen.
66
- const fixed = question.length + body.length + 4;
70
+ const fixed = question.length + body.length + 5;
67
71
  const room = Math.max(3, this.options.rows() - KEPT_ABOVE - fixed);
68
72
  const scrolls = brief.length > room;
69
73
  this.window = scrolls ? room - 1 : brief.length;
70
74
  this.maxScroll = scrolls ? brief.length - this.window : 0;
71
75
  this.scroll = Math.min(this.scroll, this.maxScroll);
72
- const lines = [];
76
+ // Set apart from what is drawn above it - the job panel's hints.
77
+ const lines = [""];
73
78
  if (brief.length > 0) {
74
79
  lines.push(...brief.slice(this.scroll, this.scroll + this.window).map(pad));
75
80
  if (scrolls) {
@@ -43,7 +43,7 @@ export function questioningQuestion(phases) {
43
43
  return {
44
44
  question: `How many questions should I ask you in ${named}?`,
45
45
  options: QUESTIONING_LEVELS.map((level) => level.option),
46
- context: `Light, medium and thorough cap each of ${named} at 3, 5 and 10 questions - broader with fewer, so every level ` +
46
+ context: `Light, medium and thorough cap ${phases.length === 1 ? named : `each of ${named}`} at 3, 5 and 10 questions - broader with fewer, so every level ` +
47
47
  "covers the same ground, only in less or more depth. What the questions leave open, turri decides itself and " +
48
48
  "says what it chose. With best judgement, turri asks as much as it judges each phase needs. Each phase still " +
49
49
  "ends with your confirmation of its summary.",
@@ -183,6 +183,8 @@ export function describeToolCall(name, args) {
183
183
  return `± my_diff`;
184
184
  case "update_notes":
185
185
  return `✐ update_notes`;
186
+ case "report_spec_gap":
187
+ return `? spec gap: ${text("question") ?? ""}`.trimEnd();
186
188
  case "copy_resource":
187
189
  return `⎘ copy_resource ${path ?? text("to") ?? ""}`.trimEnd();
188
190
  case "can_it_fail":
@@ -7,7 +7,7 @@ import { abandonWork, startWork } from "./lineage.js";
7
7
  import { ensureRegressionPasses } from "./regression.js";
8
8
  import { modulesRoot, projectDirOf } from "./paths.js";
9
9
  import { applySpecDiff, snapshotSpec, specGapsText } from "./regenerate.js";
10
- import { linesAddedSince, readNotes } from "./notes.js";
10
+ import { gapMark, gapsSince } from "./notes.js";
11
11
  import { sectionsOf } from "./state.js";
12
12
  import { describeStop } from "./stop-report.js";
13
13
  import { missingTestLabels } from "./test-kinds.js";
@@ -41,8 +41,8 @@ export async function renderSpecDiffConformance(options) {
41
41
  // state, not to what an interrupted run left half-written. The tests and the whole suite are
42
42
  // one unit, landed whole or undone whole.
43
43
  const unit = startWork([buildDir, conformanceDir], moduleName);
44
- // The notes as its agents find them: a spec gap they add is told with the result.
45
- const notesBefore = { code: readNotes(buildDir), tests: readNotes(conformanceDir) };
44
+ // The spec gaps its agents report from here on are told with the result.
45
+ const gapsFrom = gapMark();
46
46
  const conformance = await renderConformanceTests({
47
47
  plainFile: tip.plainFile,
48
48
  frid: lastFrid,
@@ -58,8 +58,8 @@ export async function renderSpecDiffConformance(options) {
58
58
  });
59
59
  // What the codebase now has conformance tests for, as their labels say.
60
60
  const tested = () => frids.filter((frid) => missingTestLabels(conformanceDir, frid, []).length === 0);
61
- // Read before a rollback takes the notes back: a gap is the person's to decide, whether the tests passed or not.
62
- const base = { moduleName, buildDir, conformanceDir, diff: applied, conformance, specGaps: [...linesAddedSince(notesBefore.code, buildDir, "gaps"), ...linesAddedSince(notesBefore.tests, conformanceDir, "gaps")] };
61
+ // A gap is the person's to decide, whether the tests passed or not.
62
+ const base = { moduleName, buildDir, conformanceDir, diff: applied, conformance, specGaps: gapsSince(gapsFrom, [buildDir, conformanceDir]) };
63
63
  if (!conformance.passed) {
64
64
  abandonWork(unit, moduleName, notLandedWhy("for the spec change", conformance.unlabelled));
65
65
  return { ...base, tested: tested(), passed: false, stoppedAt: "conformance" };
@@ -7,7 +7,7 @@ import { confinedTools } from "./confine.js";
7
7
  import { ensureInModulesScratch, modulesScratchDir } from "./paths.js";
8
8
  import { workspaceBlock } from "./workspace.js";
9
9
  import { reportActivity } from "./activity.js";
10
- import { runTiming } from "./test-scripts.js";
10
+ import { runTiming, withTestingScripts } from "./test-scripts.js";
11
11
  import { createWebTools, WEB_TOOLS } from "./web.js";
12
12
  import { diffFiles, digestFiles } from "./codebase.js";
13
13
  import { ERROR_CAUSE_RULES, fixMethod, INTEGRITY_RULES, specPrimer } from "./prompt.js";
@@ -378,9 +378,17 @@ export function explorePrompt(buildDir, spec, scope, testingScripts, question, p
378
378
  ...(testingScripts !== undefined && testingScripts.length > 0
379
379
  ? [
380
380
  "",
381
- "This is the project's conformance testing script, which the harness runs to decide whether the",
382
- "tests pass - read it for what it builds or installs before the tests run, and so what the tests can depend on:",
383
- ...testingScripts.flatMap((script) => ["", `<script path="${script.path}" run-as="${script.runs}">`, script.content.trimEnd(), "</script>"]),
381
+ ...(testingScripts.some((script) => script.prepare === true)
382
+ ? [
383
+ "These are the project's prepare script, which the harness runs on the build first, and its conformance testing",
384
+ "script, which runs only once the prepare script passes and decides whether the tests pass - read both for what",
385
+ "they build or install before the tests run, and so what the tests can depend on:",
386
+ ]
387
+ : [
388
+ "This is the project's conformance testing script, which the harness runs to decide whether the",
389
+ "tests pass - read it for what it builds or installs before the tests run, and so what the tests can depend on:",
390
+ ]),
391
+ ...testingScripts.flatMap((script) => ["", `<script${script.prepare === true ? ' kind="prepare environment"' : ""} path="${script.path}" run-as="${script.runs}">`, script.content.trimEnd(), "</script>"]),
384
392
  ]
385
393
  : []),
386
394
  ...(previous !== undefined && question === undefined
@@ -704,7 +712,7 @@ export function unfoundedEvidence(evidence, opened) {
704
712
  "opened, the testing script, or the failing run - each with where. What <how to invoke it> says is not evidence: it " +
705
713
  "may be wrong, so check it against the testing script and the files.");
706
714
  }
707
- const inPrompt = (source) => /^(the )?(conformance )?testing script$|^(the )?failing run$/i.test(source.trim());
715
+ const inPrompt = (source) => /^(the )?(conformance )?testing script$|^(the )?prepare script$|^(the )?failing run$/i.test(source.trim());
708
716
  const unread = given.filter((item) => !inPrompt(item.source) && !opened(item.source.trim()));
709
717
  if (unread.length > 0) {
710
718
  return (`Your evidence cites ${unread.map((item) => item.source.trim()).join(", ")}, which you did not open. Open each file you cite ` +
@@ -735,7 +743,7 @@ export async function judgeFailure(options) {
735
743
  description: "For code: the conformance testing script's own words for what it needs the build to have - the file, entry point or module the run failed on - copied exactly from the script",
736
744
  })),
737
745
  evidence: Type.Optional(Type.Array(Type.Object({
738
- source: Type.String({ description: "Where: the path of a file you opened, `testing script`, or `failing run`" }),
746
+ source: Type.String({ description: "Where: the path of a file you opened, `testing script`, `prepare script` where there is one, or `failing run`" }),
739
747
  shows: Type.String({ description: "What it shows, e.g. `the script runs node dist/cli.js` or `tsconfig.json emits src/cli.ts as dist/src/cli.js`" }),
740
748
  }), { description: "What you found yourself, each with where - never what <how to invoke it> claims unchecked" })),
741
749
  functionalities: Type.Optional(Type.Array(Type.Object({
@@ -900,6 +908,12 @@ export function judgePrompt(options) {
900
908
  "what is wrong. What it does with the tests, the tests have to fit; what it needs of the code - a file it runs or",
901
909
  "checks for, a command it builds with - the code has to provide. A verdict that the script is wrong is neither",
902
910
  "code nor tests: say which of the two has to change to get past it.",
911
+ ...(scripts.some((script) => script.prepare === true)
912
+ ? [
913
+ "The prepare script below it runs on the build before every run, and the conformance testing script only once it",
914
+ "passes: it binds the same way - neither agent can change it, and what it needs of the code the code provides.",
915
+ ]
916
+ : []),
903
917
  "",
904
918
  ]
905
919
  : []),
@@ -929,7 +943,10 @@ export function judgePrompt(options) {
929
943
  "",
930
944
  `<how to invoke it>\n${options.invocation.trim()}\n</how to invoke it>`,
931
945
  "",
932
- ...scripts.flatMap((script) => [`<conformance testing script path="${script.path}" run-as="${script.runs}">`, script.content.trimEnd(), "</conformance testing script>", ""]),
946
+ ...scripts.flatMap((script) => {
947
+ const tag = script.prepare === true ? "prepare script" : "conformance testing script";
948
+ return [`<${tag} path="${script.path}" run-as="${script.runs}">`, script.content.trimEnd(), `</${tag}>`, ""];
949
+ }),
933
950
  ...(options.run.timedOut === true ? [...timedOutBlock(options.run), ""] : []),
934
951
  `<failing run exit="${options.run.exitCode}" command="${options.run.command}">`,
935
952
  runTiming(options.run),
@@ -1004,7 +1021,10 @@ export async function createCodeFixer(options) {
1004
1021
  model: options.model,
1005
1022
  label: `${options.label} coding agent`, role: "coding",
1006
1023
  system: `${fence.note}\n\n${workspaceBlock({ dir: options.buildDir, hasMyDiff: options.toolNames.includes("my_diff") })}`,
1007
- task: codeFixerTask(options),
1024
+ // What the conformance testing script needs of the build, the code provides: the fixer reads it, not only a judge's quote of it.
1025
+ task: options.testingScripts !== undefined
1026
+ ? withTestingScripts(codeFixerTask(options), options.testingScripts.scripts, ["conformance", "unit"], options.testingScripts.root)
1027
+ : codeFixerTask(options),
1008
1028
  compaction: { instructions: CODER_COMPACTION },
1009
1029
  ...(options.checkEnvironment !== undefined ? { checkEnvironment: options.checkEnvironment } : {}),
1010
1030
  });
@@ -9,7 +9,7 @@ import { abandonWork, diffSinceSnapshot, moduleDirOf, recordHistory, startWork,
9
9
  import { createWorkLedger, describeAccounts, SUBMIT_WORK_NOTE, unanswered, unansweredPrompt } from "./submit-work.js";
10
10
  import { CONFORMANCE_COMPACTION } from "./compaction.js";
11
11
  import { NOTES_TURN, notesForSession, passedOutcome, readNotes, reflectOnReview, withNotes } from "./notes.js";
12
- import { labelsPrompt, labelsPromptFor, misplacedConformanceTests, misplacedTestsPrompt, missingTestLabels } from "./test-kinds.js";
12
+ import { acceptanceLabel, acceptanceText, labelsPrompt, labelsPromptFor, misplacedConformanceTests, misplacedTestsPrompt, missingTestLabels } from "./test-kinds.js";
13
13
  import { createScriptTool, findTestScripts, projectRootFor, requireTestScripts, runConformanceScript, runUnitScript, failureSignature, STUCK_ROUNDS, scriptFailure, staticChecksOnly, withTestingScripts, } from "./test-scripts.js";
14
14
  import { createWebTools, WEB_TOOLS } from "./web.js";
15
15
  import { createMyDiffTool } from "./self-check.js";
@@ -157,9 +157,11 @@ export async function renderConformanceTests(options) {
157
157
  // may read the code but writes only tests. An explorer reads the code first and writes down how the
158
158
  // software is invoked, which is what the tests are written against.
159
159
  const label = `${moduleName} ${options.diff !== undefined ? "spec change" : frid}`;
160
- const specContext = { moduleName, text: specText(specifications, moduleName, frids, required, options.change ?? options.diff, sectionModules(tree, moduleName)) };
161
160
  // For a spec change: the functionalities it touches, whose acceptance tests are labelled, checked and reviewed.
162
161
  const touched = forChange ? touchedFunctionalities(tree, frids, options.diff) : [];
162
+ // The acceptance tests the run's tests carry out: every functionality the change touches, or the one under test.
163
+ const carriedOut = forChange ? touched : [{ frid: frid, acceptanceTests: specifications[ACCEPTANCE_TESTS] ?? [] }];
164
+ const specContext = { moduleName, text: specText(specifications, moduleName, frids, required, carriedOut, options.change ?? options.diff, sectionModules(tree, moduleName)) };
163
165
  // Any functionality of the codebase - this module's, and those of the modules it requires. A
164
166
  // functionality's run may raise a spec conflict only with that functionality; a spec diff's, between any two.
165
167
  const own = (forChange ? frids : frids.slice(0, frids.indexOf(frid) + 1)).map((id) => ({ module: moduleName, frid: id }));
@@ -223,11 +225,15 @@ export async function renderConformanceTests(options) {
223
225
  };
224
226
  stage(`exploring the code for how it is invoked${options.model ? ` (${options.model})` : ""}`);
225
227
  // The explorer reads the conformance testing script too: it is how the tests are built and run, and so how they reach the software.
228
+ // The prepare script, where there is one, runs on the build before it - and only once it passes does the suite run.
226
229
  const testingScripts = [
230
+ ...(scripts.prepare !== undefined
231
+ ? [{ path: relative(root, scripts.prepare), runs: "<script> <copy of the build>, before every run of the conformance testing script", content: readFileSync(scripts.prepare, "utf8"), prepare: true }]
232
+ : []),
227
233
  { path: relative(root, scripts.conformance), runs: "<script> <copy of the build> <copy of the whole conformance suite>", content: readFileSync(scripts.conformance, "utf8") },
228
234
  ];
229
235
  // The acceptance tests the explorer's plan has to carry out, step by step.
230
- const toCarryOut = (forChange ? touched : [{ frid: frid, acceptanceTests: specifications[ACCEPTANCE_TESTS] ?? [] }])
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: forChange ? touched : [{ frid: frid, acceptanceTests: specifications[ACCEPTANCE_TESTS] ?? [] }],
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, ACCEPTANCE_TESTS]
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 || key === ACCEPTANCE_TESTS ? section(key, numbered(key, items)) : moduleSection(key, items, moduleName, modules)));
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");
@@ -16,7 +16,7 @@ import { modulesRoot } from "./paths.js";
16
16
  import { budgetFor, choosingWhen, configForSpec, conformanceSwitchedOn } from "../config.js";
17
17
  import { describeSpecChange } from "./prompt.js";
18
18
  import { resolveTemplateDirs } from "./paths.js";
19
- import { builtTips, copyRequiredCode, newlyRequired, resolveRequiresChain, startFromScratch } from "./requires.js";
19
+ import { builtTips, copyRequiredCode, importingModules, isImportModule, newlyRequired, resolveRequiresChain, startFromScratch } from "./requires.js";
20
20
  import { withTryItLine } from "./delivery.js";
21
21
  import { describeSpecDiff, renderSpecDiff, specFilesOf } from "./regenerate.js";
22
22
  import { locateDiffLines } from "./diff-view.js";
@@ -85,6 +85,23 @@ function chainsFor(plainFile) {
85
85
  const tips = builtTips(plainFile);
86
86
  return tips.length > 0 ? tips : [resolveRequiresChain(plainFile, [], undefined, { allowNoFunctionalities: true })];
87
87
  }
88
+ /**
89
+ * The modules a .plain file is built as: itself - or, an import module, the modules importing it whose code is started.
90
+ * An import module is no codebase; one no started module imports has nothing to build yet - its reqs are built with the
91
+ * first module that imports it.
92
+ */
93
+ function codebasesOf(plainFile) {
94
+ if (!isImportModule(plainFile))
95
+ return [plainFile];
96
+ return importingModules(plainFile).filter((file) => {
97
+ try {
98
+ return builtTips(file).length > 0 || started(resolveRequiresChain(file, [], undefined, { allowNoFunctionalities: true }).at(-1));
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ });
104
+ }
88
105
  /** How many functional specs of its own a codebase has built - none when it was only started from the code below it. */
89
106
  function ownBuilt(entry) {
90
107
  return (readRecord(entry.buildDir)?.[FUNCTIONAL_REQUIREMENTS] ?? []).length;
@@ -989,9 +1006,10 @@ export function createWorkLoop(options) {
989
1006
  };
990
1007
  const loop = {
991
1008
  async bringUp(plainFile, request = {}) {
992
- const 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))));
1009
+ const files = codebasesOf(resolve(options.cwd, plainFile));
1010
+ for (const file of files)
1011
+ asked.set(file, { ...asked.get(file), ...request });
1012
+ const states = withParseCache(() => files.flatMap((file) => chainsFor(file).flatMap((chain) => toBuild(chain).map((entry) => track(chain.slice(0, chain.indexOf(entry) + 1), file, request)))));
995
1013
  // Asked again, a halted codebase is tried again.
996
1014
  for (const state of states)
997
1015
  state.halted = undefined;
@@ -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");