@turri-dev/cli 0.1.3-next.6fab174 → 0.1.3-next.a5d3797

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/config.js CHANGED
@@ -189,8 +189,9 @@ export const ROLE_THINKING = {
189
189
  diagnosis: "medium",
190
190
  };
191
191
  /** The roles on the conversation's side: without their own entry they take the chat's model, not `default`. */
192
- // The release is not: it follows steps - a script, a start command - and runs on the coding model, thinking low.
193
- const CHAT_SIDE = ["chat", "chooser", "planner", "diagnosis"];
192
+ // The release is not: it follows steps - a script, a start command - and runs on the coding model, thinking low. Nor
193
+ // is the diagnosis: a post-mortem of a job that failed, read by the person - it runs on the coding model, thinking medium.
194
+ const CHAT_SIDE = ["chat", "chooser", "planner"];
194
195
  function isThinkingLevel(value) {
195
196
  return typeof value === "string" && THINKING_LEVELS.includes(value);
196
197
  }
package/dist/extension.js CHANGED
@@ -9,7 +9,7 @@ import { allocateImageId, Box, getCapabilities, getPngDimensions, isKeyRelease,
9
9
  import { defineTool } from "@earendil-works/pi-coding-agent";
10
10
  import { Type } from "typebox";
11
11
  import { CODING_MODEL_ENV } from "./chat-models.js";
12
- import { AGENT_ROLES, CONFIG_FILE, configForSpec, hasProjectConfig, LEGACY_CONFIG_FILE, projectConfigFile, stackConfigFileIn, stackOf, stacksOf, withStack, modelSpec, projectModels, readProjectConfig, roleModel, splitModelSpec } from "./config.js";
12
+ import { AGENT_ROLES, CONFIG_FILE, configForSpec, hasProjectConfig, LEGACY_CONFIG_FILE, projectConfigFile, stackConfigFileIn, stackOf, stacksOf, withStack, modelOnly, modelSpec, projectModels, readProjectConfig, roleModel, splitModelSpec } from "./config.js";
13
13
  import { skillRunDir } from "./regenerate/paths.js";
14
14
  import { sweepOrphanedGroups } from "./regenerate/shell.js";
15
15
  import { readScript, scriptAgainstMachine, stackOnMachine, toolchainsBlock } from "./stack.js";
@@ -31,7 +31,7 @@ import { whyFailed } from "./regenerate/diagnose-job.js";
31
31
  import { createWorkLoop, describeModules, isUpToDate, previewJobs } from "./regenerate/loop.js";
32
32
  import { brandBlue, plainPaint } from "./regenerate/plain-palette.js";
33
33
  import { createJobPanel, didNotPass, keyPressed, PANEL_KEY, spinner } from "./regenerate/job-panel.js";
34
- import { clickToFocus, dockedWidth, narrowChat } from "./regenerate/panel-dock.js";
34
+ import { clickToFocus, dockedWidth, narrowChat, shade, shadeChat } from "./regenerate/panel-dock.js";
35
35
  import { overPictureRow, PICTURE_AT } from "./picture-row.js";
36
36
  import { skillAboutLine, skillProgressLine } from "./skill-progress.js";
37
37
  import { createSidePanel } from "./regenerate/side-panel.js";
@@ -186,7 +186,8 @@ export default function (pi) {
186
186
  projectDir: cwd,
187
187
  harnessDir: PACKAGE_ROOT,
188
188
  jobLog,
189
- model: modelSpec(projectModels(readProjectConfig(cwd)).harness),
189
+ // The coding model the job ran on - its model alone: the diagnosis thinks as its role has it.
190
+ model: modelSpec(modelOnly(job.model ?? modelSpec(projectModels(readProjectConfig(cwd)).coding))),
190
191
  });
191
192
  return diagnosis !== undefined ? describeDiagnosis(diagnosis) : undefined;
192
193
  },
@@ -1531,7 +1532,10 @@ function registerJobTools(pi, jobs, cwd) {
1531
1532
  else
1532
1533
  toPanel();
1533
1534
  });
1535
+ // The side without the keys in the shade: the chat while the panel has them.
1536
+ const unshade = shadeChat(tui, () => panelBeside() > 0 && entry.keys);
1534
1537
  undock = () => {
1538
+ unshade();
1535
1539
  unwatch();
1536
1540
  widen();
1537
1541
  };
@@ -1624,6 +1628,17 @@ function registerJobTools(pi, jobs, cwd) {
1624
1628
  }
1625
1629
  if (!state)
1626
1630
  return undefined;
1631
+ // Beside the chat, ⌥⌘← and ⌥⌘→ go to the side they point at, whichever has the keys - on a terminal that tells
1632
+ // turri of ⌘ (the Kitty keyboard protocol). Its release goes nowhere.
1633
+ if (panelBeside() > 0 && (matchesKey(data, SIDE_KEYS.chat) || matchesKey(data, SIDE_KEYS.panel))) {
1634
+ if (!isKeyRelease(data)) {
1635
+ if (matchesKey(data, SIDE_KEYS.chat))
1636
+ toChat();
1637
+ else
1638
+ toPanel();
1639
+ }
1640
+ return { consume: true };
1641
+ }
1627
1642
  // Beside the chat, with the chat's keys: they are the chat's - PANEL_KEY among them, for the shortcut that gives
1628
1643
  // them to the panel. A click on the panel gives it the focus, and the keys with it.
1629
1644
  if (!entry.keys && entry.handle?.isFocused() !== true)
@@ -1655,8 +1670,13 @@ function registerJobTools(pi, jobs, cwd) {
1655
1670
  }, WATCH_REDRAW_MS);
1656
1671
  timer.unref?.();
1657
1672
  component = {
1658
- // Beside the chat, a rule down its left edge between them.
1659
- render: (width) => (panelBeside() > 0 ? view.render(width - 2).map((line) => `${theme.fg("borderMuted", "│")} ${line}`) : view.render(width)),
1673
+ // Beside the chat, a rule down its left edge between them - and the panel in the shade while the chat has the keys.
1674
+ render: (width) => {
1675
+ if (panelBeside() === 0)
1676
+ return view.render(width);
1677
+ const lines = view.render(width - 2).map((line) => `${theme.fg("borderMuted", "│")} ${line}`);
1678
+ return entry.keys ? lines : lines.map(shade);
1679
+ },
1660
1680
  // Reached only if the listener above is gone: the same, so the panel never passes a key on.
1661
1681
  handleInput: (data) => {
1662
1682
  if (isKeyRepeat(data) && matchesKey(data, PANEL_KEY))
@@ -1797,6 +1817,8 @@ export const JOB_FINISHED_REACTION = [
1797
1817
  " to do, and that \"resume\" picks it up once it is in.",
1798
1818
  "- a credential `agent-env:` names that is not there: never make one up - tell the person where it is read from.",
1799
1819
  ].join("\n");
1820
+ /** The keys that go to the chat and to the panel, side by side: ⌥⌘← and ⌥⌘→. */
1821
+ const SIDE_KEYS = { chat: "alt+super+left", panel: "alt+super+right" };
1800
1822
  /** Opens the side panel, and goes back to it from the conversation. */
1801
1823
  /** The modules in this project that have a history, by name. */
1802
1824
  function modulesWithHistory(cwd) {
package/dist/loop-cli.js CHANGED
@@ -69,7 +69,7 @@ async function runLoopReporting(options) {
69
69
  const registry = createJobRegistry({
70
70
  retries: 1,
71
71
  diagnose: async (job, failures, jobLog) => {
72
- const diagnosis = await diagnoseFailure({ job, failures, projectDir, harnessDir: PACKAGE_ROOT, jobLog, model: modelSpec(projectModels(project).harness) ?? model });
72
+ const diagnosis = await diagnoseFailure({ job, failures, projectDir, harnessDir: PACKAGE_ROOT, jobLog, model: modelSpec(modelOnly(job.model ?? model)) });
73
73
  return diagnosis !== undefined ? describeDiagnosis(diagnosis) : undefined;
74
74
  },
75
75
  onChange: (job) => {
@@ -659,15 +659,28 @@ function words(text) {
659
659
  * in the specification - or when it hides why the software failed. A test asserting what the
660
660
  * specification leaves open is the test's mistake, however plausible the assertion.
661
661
  */
662
- export function unfoundedCodeVerdict(verdict, specText) {
662
+ export function unfoundedCodeVerdict(verdict, specText, scriptText) {
663
663
  if (verdict.causeHidden === true)
664
664
  return undefined;
665
+ // What the conformance testing script needs of the build - a file, an entry point, a module - binds the code as the
666
+ // specification does: neither agent can change the script. Its own words, as they are in it.
667
+ const needs = verdict.scriptNeeds?.trim() ?? "";
668
+ if (needs !== "" && scriptText !== undefined) {
669
+ const flat = (text) => text.replace(/\s+/g, " ");
670
+ if (flat(needs).length >= 4 && flat(scriptText).includes(flat(needs)))
671
+ return undefined;
672
+ if (verdict.specSays === undefined) {
673
+ return (`Your verdict blames the code for what the conformance testing script needs, but "${needs}" is not in the script. ` +
674
+ "Call submit_verdict again: copy into script_needs the script's own words for what it needs of the build, exactly as they are in it.");
675
+ }
676
+ }
665
677
  const quote = verdict.specSays?.trim() ?? "";
666
678
  if (quote === "") {
667
679
  return ("Your verdict blames the code, but quotes no words of the specification it does not meet. Call submit_verdict " +
668
680
  "again: for code, put the specification's own words the code does not meet in spec_says, copied exactly from " +
669
- "<specification>; or set cause_hidden when the output does not show why the software failed. If the specification " +
670
- "does not say what the failing test asserts, the test is what is wrong: culprit tests.");
681
+ "<specification>; or, when the run fails on something the conformance testing script needs the build to have, " +
682
+ "its own words in script_needs; or set cause_hidden when the output does not show why the software failed. If " +
683
+ "the specification does not say what the failing test asserts, the test is what is wrong: culprit tests.");
671
684
  }
672
685
  const quoted = words(quote);
673
686
  if (quoted.trim().split(" ").length < 4 || !words(specText).includes(quoted)) {
@@ -677,6 +690,31 @@ export function unfoundedCodeVerdict(verdict, specText) {
677
690
  }
678
691
  return undefined;
679
692
  }
693
+ /**
694
+ * Why a verdict's evidence cannot be taken as it is, or nothing when it can. Asked to weigh an escalation, the judge has
695
+ * to rest its verdict on what it found itself: each piece of evidence from a file it opened, the testing script or the
696
+ * failing run - and at least one not the run alone, which every judge before it read too. What the explorer's account
697
+ * says is not evidence: it was written before the run, and may be wrong.
698
+ */
699
+ export function unfoundedEvidence(evidence, opened) {
700
+ const given = (evidence ?? []).filter((item) => item.source.trim() !== "" && item.shows.trim() !== "");
701
+ if (given.length === 0) {
702
+ return ("Your verdict gives no evidence. Call submit_verdict again with `evidence`: what you found yourself - in a file you " +
703
+ "opened, the testing script, or the failing run - each with where. What <how to invoke it> says is not evidence: it " +
704
+ "may be wrong, so check it against the testing script and the files.");
705
+ }
706
+ const inPrompt = (source) => /^(the )?(conformance )?testing script$|^(the )?failing run$/i.test(source.trim());
707
+ const unread = given.filter((item) => !inPrompt(item.source) && !opened(item.source.trim()));
708
+ if (unread.length > 0) {
709
+ return (`Your evidence cites ${unread.map((item) => item.source.trim()).join(", ")}, which you did not open. Open each file you cite ` +
710
+ "and call submit_verdict again - evidence is what you found yourself, never what <how to invoke it> says.");
711
+ }
712
+ if (given.every((item) => /failing run$/i.test(item.source.trim()))) {
713
+ return ("Your evidence is only the failing run, which the judges before you read too. Check what it points at - the testing " +
714
+ "script, the code, the tests - and call submit_verdict again with what you found there.");
715
+ }
716
+ return undefined;
717
+ }
680
718
  export async function judgeFailure(options) {
681
719
  let verdict;
682
720
  const submit = defineTool({
@@ -692,6 +730,13 @@ export async function judgeFailure(options) {
692
730
  description: "For code: the specification's own words the code does not meet, copied exactly from <specification> - the sentence, or the part of it that says what the test checks",
693
731
  })),
694
732
  cause_hidden: Type.Optional(Type.Boolean({ description: "For code: true when it is wrong because the output does not show why the software failed - it hides the cause" })),
733
+ script_needs: Type.Optional(Type.String({
734
+ 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",
735
+ })),
736
+ evidence: Type.Optional(Type.Array(Type.Object({
737
+ source: Type.String({ description: "Where: the path of a file you opened, `testing script`, or `failing run`" }),
738
+ 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`" }),
739
+ }), { description: "What you found yourself, each with where - never what <how to invoke it> claims unchecked" })),
695
740
  functionalities: Type.Optional(Type.Array(Type.Object({
696
741
  module: Type.String({ description: "The module the functionality is in" }),
697
742
  id: Type.String({ description: "Its id in that module" }),
@@ -704,6 +749,8 @@ export async function judgeFailure(options) {
704
749
  reason: given.reason,
705
750
  ...(given.spec_says?.trim() ? { specSays: given.spec_says.trim() } : {}),
706
751
  ...(given.cause_hidden === true ? { causeHidden: true } : {}),
752
+ ...(given.script_needs?.trim() ? { scriptNeeds: given.script_needs.trim() } : {}),
753
+ ...(given.evidence !== undefined && given.evidence.length > 0 ? { evidence: given.evidence } : {}),
707
754
  ...(given.functionalities ? { functionalities: conflictRefs(given.functionalities) } : {}),
708
755
  };
709
756
  return { content: [{ type: "text", text: "Recorded." }], details: {}, terminate: true };
@@ -712,10 +759,27 @@ export async function judgeFailure(options) {
712
759
  // It reads both, and changes neither: the file tools and the web - to look up what a service it calls answers -
713
760
  // and no shell. It judges from the code, the tests, the spec and the failing run's output, which it is given.
714
761
  const fence = confinedTools({ workdir: options.conformanceDir, read: [options.buildDir, options.conformanceDir], write: [], noBash: true });
762
+ // What it opens - read, listed, searched - is what its evidence can cite.
763
+ const openedPaths = new Set();
764
+ const tools = fence.tools.map((tool) => ({
765
+ ...tool,
766
+ execute: (async (...args) => {
767
+ const path = args[1]?.path;
768
+ if (typeof path === "string" && path !== "")
769
+ openedPaths.add(resolve(options.conformanceDir, path));
770
+ return tool.execute(...args);
771
+ }),
772
+ }));
773
+ const opened = (cited) => {
774
+ // A file it opened, or one in a folder it listed or searched; cited as it was opened, or relative to the code or the tests.
775
+ const candidates = [resolve(options.conformanceDir, cited), resolve(options.buildDir, cited)];
776
+ return candidates.some((path) => [...openedPaths].some((seen) => path === seen || path.startsWith(`${seen}/`)));
777
+ };
778
+ const scriptText = options.testingScripts?.map((script) => script.content).join("\n");
715
779
  const before = { code: digestFiles(options.buildDir), tests: digestFiles(options.conformanceDir) };
716
780
  const session = await createExecutor({
717
781
  cwd: options.conformanceDir,
718
- customTools: [submit, ...createWebTools(), ...fence.tools],
782
+ customTools: [submit, ...createWebTools(), ...tools],
719
783
  tools: [...fence.names, ...WEB_TOOLS, "submit_verdict"],
720
784
  model: options.model,
721
785
  label: `${options.label} judge`, role: "judge",
@@ -730,8 +794,15 @@ export async function judgeFailure(options) {
730
794
  await ask(session, "You have not called submit_verdict. Call it now: code, tests, spec or environment, and why.", options.onDelta);
731
795
  continue;
732
796
  }
797
+ // Asked to weigh an escalation, a verdict of code or tests rests on what it found itself.
798
+ const unproven = options.escalation !== undefined && (verdict.culprit === "code" || verdict.culprit === "tests") ? unfoundedEvidence(verdict.evidence, opened) : undefined;
799
+ if (unproven !== undefined) {
800
+ verdict = undefined;
801
+ await ask(session, unproven, options.onDelta);
802
+ continue;
803
+ }
733
804
  if (verdict.culprit === "code") {
734
- const unfounded = unfoundedCodeVerdict(verdict, options.spec.text);
805
+ const unfounded = unfoundedCodeVerdict(verdict, options.spec.text, scriptText);
735
806
  if (unfounded === undefined)
736
807
  break;
737
808
  verdict = undefined;
@@ -759,18 +830,26 @@ export async function judgeFailure(options) {
759
830
  }
760
831
  if (verdict?.culprit === "spec" && specConflictProblem(verdict.functionalities, options.scope) !== undefined)
761
832
  verdict = undefined;
762
- if (verdict?.culprit === "code" && unfoundedCodeVerdict(verdict, options.spec.text) !== undefined) {
833
+ // An escalation weighed without evidence escalates nothing: the tests stay the conformance agent's, as before it.
834
+ if (options.escalation !== undefined && verdict?.culprit === "code" && unfoundedEvidence(verdict.evidence, opened) !== undefined) {
835
+ return { culprit: "tests", reason: `${verdict.reason}\n\nBut the judge gave no evidence it found itself that the code is what is wrong, so the tests are looked at again.` };
836
+ }
837
+ if (verdict?.culprit === "code" && unfoundedCodeVerdict(verdict, options.spec.text, scriptText) !== undefined) {
763
838
  // Blaming the code takes the specification's own words; without them, the test asserts what the spec does not say.
764
839
  return { culprit: "tests", reason: `${verdict.reason}\n\nBut no words of the specification say so: a test that asserts what the specification does not say is what is wrong. Check each failing assertion against the specification, and assert only what it says.` };
765
840
  }
766
841
  if (verdict?.culprit === "code" && verdict.specSays !== undefined) {
767
842
  verdict = { ...verdict, reason: `${verdict.reason}\n\nThe specification says: "${verdict.specSays}"` };
768
843
  }
844
+ if (verdict?.culprit === "code" && verdict.scriptNeeds !== undefined) {
845
+ verdict = { ...verdict, reason: `${verdict.reason}\n\nThe conformance testing script needs: "${verdict.scriptNeeds}"` };
846
+ }
769
847
  // No verdict is no evidence the code is wrong: the tests are the newer work, and looked at first.
770
848
  return verdict ?? { culprit: "tests", reason: "The judge gave no verdict that could be used; check the failing tests against the specification first." };
771
849
  }
772
850
  export function judgePrompt(options) {
773
851
  const tested = options.scope.tested;
852
+ const scripts = options.testingScripts ?? [];
774
853
  return [
775
854
  specPrimer({ testReqs: true }),
776
855
  `The conformance tests of the \`${options.spec.moduleName}\` specification failed. Decide what is wrong: the code, the tests, the specification itself, or the run's environment.`,
@@ -780,7 +859,9 @@ export function judgePrompt(options) {
780
859
  "The specification is the judge, never either of them:",
781
860
  "- tests: a test asserts something the specification does not say, misreads it, or reaches the software the",
782
861
  " wrong way - a wrong command, route or setup (see <how to invoke it>).",
783
- "- code: the code does not do what the specification says, and the test checks exactly that.",
862
+ "- code: the code does not do what the specification says, and the test checks exactly that. Or the run fails",
863
+ " on something the conformance testing script needs the code to provide - a build output, an entry point, a",
864
+ " module - and the code does not provide it: quote the script's own words for it in script_needs.",
784
865
  " Only what the specification says counts: anything it leaves open - a name, coordinates, a version, a path,",
785
866
  " a package, a port, a file layout - is what the code already has. The tests follow it, and it is never",
786
867
  " code - changing the code to fit a test's guess moves what every other test points at.",
@@ -810,6 +891,19 @@ export function judgePrompt(options) {
810
891
  "verdict of code without them is not taken. What the specification leaves open is never the code's fault: a test",
811
892
  "that asserts it - a status code, a message, a format the specification does not name - is what is wrong.",
812
893
  "",
894
+ ...(scripts.length > 0
895
+ ? [
896
+ "The conformance testing script below is how every run is made, and neither agent can change it: it is never",
897
+ "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",
898
+ "checks for, a command it builds with - the code has to provide. A verdict that the script is wrong is neither",
899
+ "code nor tests: say which of the two has to change to get past it.",
900
+ "",
901
+ ]
902
+ : []),
903
+ "<how to invoke it> is the explorer's account, written before the run, and it can be wrong - a folder, a path, a",
904
+ "command. Never rest a verdict on it: check each claim you use against the testing script and the files, and give",
905
+ "in `evidence` what you found yourself, each with where.",
906
+ "",
813
907
  `You have ${JUDGE_CALLS} tool calls to decide in: read what the failure points at, not the whole codebase.`,
814
908
  "",
815
909
  "When the code and the tests could both be what is wrong, choose tests. When both are wrong, say which to fix",
@@ -832,11 +926,35 @@ export function judgePrompt(options) {
832
926
  "",
833
927
  `<how to invoke it>\n${options.invocation.trim()}\n</how to invoke it>`,
834
928
  "",
929
+ ...scripts.flatMap((script) => [`<conformance testing script path="${script.path}" run-as="${script.runs}">`, script.content.trimEnd(), "</conformance testing script>", ""]),
835
930
  `<failing run exit="${options.run.exitCode}" command="${options.run.command}">`,
836
931
  options.run.output.trimEnd(),
837
932
  "</failing run>",
933
+ ...(options.escalation !== undefined ? ["", ...escalationBlock(options.escalation)] : []),
838
934
  ].join("\n");
839
935
  }
936
+ /** For the judge: the tests were fixed for this failure, and it did not go away - is it the code's? */
937
+ function escalationBlock(escalation) {
938
+ const changed = escalation.changed.length === 0 ? "changed no file" : `changed ${escalation.changed.slice(0, 8).join(", ")}${escalation.changed.length > 8 ? ` and ${escalation.changed.length - 8} more` : ""}`;
939
+ return [
940
+ "<escalation>",
941
+ "This failure was judged the tests' before, and the conformance agent - who can change only the tests - was sent to",
942
+ `fix it. It ${changed}, and the run fails the same way. What it was told:`,
943
+ "",
944
+ escalation.reason.trim(),
945
+ "",
946
+ "Decide again, now whether the failure belongs to the code rather than the tests. It is the code's when the run",
947
+ "fails on something the code is meant to produce - a build output, an entry point, a module the testing script needs",
948
+ "- or when the tests ran and what the software does is not what the specification says. It stays the tests' when a",
949
+ "test is still wrong and the code is right: say exactly what in the tests, so the next fix is not the last one again.",
950
+ "",
951
+ "Gather the evidence yourself: open the files the failure points at - the code's build configuration and what it",
952
+ "emits, the tests - and read the testing script. Give it in `evidence`: a verdict of code or tests without evidence",
953
+ "you found is not taken. The coding agent is handed your reason and evidence: say what the code has to provide, and",
954
+ "where it now falls short (e.g. `the script runs dist/cli.js; the build emits dist/src/cli.js`).",
955
+ "</escalation>",
956
+ ];
957
+ }
840
958
  /**
841
959
  * The coding agent a conformance run sends the code to, when the judge says the code is wrong: it
842
960
  * works in the code folder, may read the tests but never changes them, and keeps the unit
@@ -917,6 +1035,43 @@ export function codeFixPrompt(options) {
917
1035
  "Fix the code, run the conformance tests and the unit tests, and finish.",
918
1036
  ].join("\n");
919
1037
  }
1038
+ /** How much of the conformance tests an escalation hands the coding agent, whole: the rest it reads itself. */
1039
+ const ESCALATED_TESTS_CHARS = 24_000;
1040
+ /**
1041
+ * An escalated failure for the code fixer: the tests were fixed for it and it did not go away, and the judge found it
1042
+ * the code's. It is handed the conformance testing script's error, the tests as they stand and the judge's evidence.
1043
+ */
1044
+ export function escalatedFixPrompt(options) {
1045
+ let room = ESCALATED_TESTS_CHARS;
1046
+ const shown = options.tests.flatMap((test) => {
1047
+ if (room <= 0)
1048
+ return [`<conformance test path="${test.path}" shown="no - read it yourself" />`];
1049
+ const content = test.content.length > room ? `${test.content.slice(0, room)}\n... (cut short - read the rest yourself)` : test.content;
1050
+ room -= content.length;
1051
+ return [`<conformance test path="${test.path}">`, content.trimEnd(), "</conformance test>"];
1052
+ });
1053
+ return [
1054
+ `The conformance tests failed (exit ${options.run.exitCode}). The conformance agent was sent to fix the tests for it, and`,
1055
+ "the run failed the same way. A judge then looked again, gathered the evidence below itself, and found that the",
1056
+ "failure belongs to the code:",
1057
+ "",
1058
+ `<why the code>\n${options.reason.trim()}\n</why the code>`,
1059
+ "",
1060
+ "<evidence>",
1061
+ ...options.evidence.map((item) => `- ${item.source.trim()}: ${item.shows.trim()}`),
1062
+ "</evidence>",
1063
+ "",
1064
+ `<failing run command="${options.run.command}">`,
1065
+ options.run.output.trimEnd(),
1066
+ "</failing run>",
1067
+ "",
1068
+ "The conformance tests as they stand - not yours to change:",
1069
+ ...shown,
1070
+ "",
1071
+ "Fix the code, run the conformance tests and the unit tests, and finish. This is the last attempt at this failure:",
1072
+ "if the run still fails the same way after your fix, the work stops.",
1073
+ ].join("\n");
1074
+ }
920
1075
  /** The run's environment judged what is wrong: the conformance agent checks the tests do not cause it, and changes nothing when they do not. */
921
1076
  export function environmentCheckPrompt(options) {
922
1077
  return [
@@ -16,7 +16,7 @@ import { createMyDiffTool } from "./self-check.js";
16
16
  import { createServiceTools } from "./services.js";
17
17
  import { missingToolchainError, toolchainError } from "./environment-error.js";
18
18
  import { workspaceBlock } from "./workspace.js";
19
- import { codeFixPrompt, conflictKey, createCodeFixer, answerAsExplorer, createAskExplorerTool, exploreInvocation, judgeFailure, SPEC_CONFLICT_RAISES, ENVIRONMENT_RAISES, environmentCheckPrompt, createTestReviewer, testReviewRequest, reviewFeedbackPrompt, testsFixPrompt, sortedRefs, } from "./conformance-agents.js";
19
+ import { codeFixPrompt, conflictKey, escalatedFixPrompt, createCodeFixer, answerAsExplorer, createAskExplorerTool, exploreInvocation, judgeFailure, SPEC_CONFLICT_RAISES, ENVIRONMENT_RAISES, environmentCheckPrompt, createTestReviewer, testReviewRequest, reviewFeedbackPrompt, testsFixPrompt, sortedRefs, } from "./conformance-agents.js";
20
20
  import { resolveRequiresChain } from "./requires.js";
21
21
  import { notesToRead, recordNote } from "./notes.js";
22
22
  import { reportSpecConflict } from "./activity.js";
@@ -194,6 +194,11 @@ export async function renderConformanceTests(options) {
194
194
  let environmentRaises = 0;
195
195
  /** How many fixes in a row - the code's or the tests' - changed no file: an agent that will not, or cannot, act on the verdict. */
196
196
  let idleFixes = 0;
197
+ /** The last fix was the tests', for a failure judged theirs: should the run fail the same way again, the judge weighs escalating it. */
198
+ let testsFix;
199
+ /** A failure escalated to the code: should the run still fail the same way after the code's fix, the run stops. */
200
+ let escalated;
201
+ let escalation;
197
202
  /**
198
203
  * One attempt at the failing run, written into the notes' pitfalls - what the next judge reads
199
204
  * first - as cause and effect: what failed, how it was judged, and what was changed; the next
@@ -414,6 +419,15 @@ export async function renderConformanceTests(options) {
414
419
  abandonWork(start, moduleName, "the conformance testing script could not run");
415
420
  throw lacking;
416
421
  }
422
+ // A failure escalated to the code, still the same after the code's fix: neither agent could fix it, and the run stops
423
+ // with why the judge found it the code's. Anything else it fails on is a new failure, judged as any other.
424
+ if (escalated !== undefined && recheck.exitCode !== 0 && failureSignature(recheck) === escalated.signature) {
425
+ escalation = { reason: escalated.reason, evidence: escalated.evidence, error: failureLine(recheck), failure: truncateOutput(recheck.output, undefined).slice(-4000) };
426
+ stage("the failure escalated to the code is still there after the coding agent's fix - stopping");
427
+ recordNote(conformanceDir, "pitfalls", `stopped after ${rounds} attempts: failed with ${failureLine(recheck)}, escalated to the code - ${escalated.reason.replace(/\s+/g, " ").slice(0, 240)} - and the code's fix did not change it`, `${label} conformance`);
428
+ break;
429
+ }
430
+ escalated = undefined;
417
431
  if (recheck.exitCode !== 0) {
418
432
  // Nothing changing: the same failure, taken the same way, round after round. Once, the explorer
419
433
  // is asked what the tests lack - most often how they reach the software; after that, it stops.
@@ -504,6 +518,12 @@ export async function renderConformanceTests(options) {
504
518
  recordNote(conformanceDir, "pitfalls", `stuck after ${rounds} attempts: failed with ${failureLine(recheck)}, and ${IDLE_FIXES} fixes in a row changed nothing`, `${label} conformance`);
505
519
  return true;
506
520
  };
521
+ // The tests were fixed for this very failure, and it is still here: the judge weighs whether it is the code's.
522
+ const signature = failureSignature(recheck);
523
+ const escalating = testsFix !== undefined && testsFix.signature === signature ? { reason: testsFix.reason, changed: testsFix.files } : undefined;
524
+ testsFix = undefined;
525
+ if (escalating !== undefined)
526
+ stage("the tests' fix did not make the failure go away - the judge weighs whether it is the code's");
507
527
  const verdict = await judgeFailure({
508
528
  buildDir,
509
529
  conformanceDir,
@@ -513,6 +533,9 @@ export async function renderConformanceTests(options) {
513
533
  scope,
514
534
  // The tests' notes - every attempt so far - and the code's: it weighs the one against the other.
515
535
  notes: [notesToRead(conformanceDir), notesToRead(buildDir)].filter((part) => part !== "").join("\n\n"),
536
+ // The script itself, so the judge checks the explorer's account against it rather than repeating it.
537
+ testingScripts,
538
+ ...(escalating !== undefined ? { escalation: escalating } : {}),
516
539
  label,
517
540
  model: judgeModel,
518
541
  onDelta: options.onDelta,
@@ -585,15 +608,34 @@ export async function renderConformanceTests(options) {
585
608
  recordAttempt(rounds, recheck, `a spec conflict between functionalities ${key}: ${verdict.reason}`, { who: "conformance agent", files: fixed.files });
586
609
  }
587
610
  else if (verdict.culprit === "code") {
588
- reportActivity(` the code: ${reason}`);
589
- stage("the code is wrong - the coding agent fixes it");
590
- // One fixer for the run, starting each fix from what the ones before it tried: its work is compacted as
591
- // it gets longer, so a session carried through the run does not re-read every earlier fix and suite output
592
- // whole.
593
- const fixed = await changing(() => askFixer(codeFixPrompt({ run: recheck, reason: verdict.reason })));
594
- recordAttempt(rounds, recheck, `the code wrong: ${verdict.reason}`, { who: "coding agent", files: fixed.files });
595
- if (idle(fixed.files))
596
- break;
611
+ if (escalating !== undefined) {
612
+ // Escalated: the coding agent is handed the script's error, the tests as they stand and the judge's evidence.
613
+ // Its fix is the last attempt at this failure - should the run fail the same way after it, the run stops.
614
+ const evidence = verdict.evidence ?? [];
615
+ reportActivity(` escalated to the code: ${reason}`);
616
+ stage("the judge finds the failure the code's - escalated to the coding agent");
617
+ const tests = listFiles(conformanceDir).map((path) => ({ path, content: readFileSync(join(conformanceDir, path), "utf8") }));
618
+ const fixed = await changing(() => askFixer(escalatedFixPrompt({ run: recheck, reason: verdict.reason, evidence, tests })));
619
+ recordAttempt(rounds, recheck, `escalated to the code: ${verdict.reason}`, { who: "coding agent", files: fixed.files });
620
+ if (fixed.files.length === 0) {
621
+ escalation = { reason: verdict.reason, evidence, error: failureLine(recheck), failure: truncateOutput(recheck.output, undefined).slice(-4000), changedNothing: true };
622
+ stage("the coding agent changed nothing for the failure escalated to it - stopping");
623
+ break;
624
+ }
625
+ idleFixes = 0;
626
+ escalated = { signature, reason: verdict.reason, evidence };
627
+ }
628
+ else {
629
+ reportActivity(` the code: ${reason}`);
630
+ stage("the code is wrong - the coding agent fixes it");
631
+ // One fixer for the run, starting each fix from what the ones before it tried: its work is compacted as
632
+ // it gets longer, so a session carried through the run does not re-read every earlier fix and suite output
633
+ // whole.
634
+ const fixed = await changing(() => askFixer(codeFixPrompt({ run: recheck, reason: verdict.reason })));
635
+ recordAttempt(rounds, recheck, `the code wrong: ${verdict.reason}`, { who: "coding agent", files: fixed.files });
636
+ if (idle(fixed.files))
637
+ break;
638
+ }
597
639
  // The fix has to build and pass its own unit tests before the suite runs again: a build
598
640
  // error is the coding agent's to fix, not a judge's to work out from the suite's output.
599
641
  for (let again = 0; again < FIX_UNIT_CHECKS; again += 1) {
@@ -613,7 +655,11 @@ export async function renderConformanceTests(options) {
613
655
  const fixed = await changing(() => askConformance(testsFixPrompt({ run: recheck, reason: verdict.reason }), options.onDelta));
614
656
  response = fixed.result;
615
657
  recordAttempt(rounds, recheck, `the tests wrong: ${verdict.reason}`, { who: "conformance agent", files: fixed.files });
616
- if (idle(fixed.files))
658
+ testsFix = { signature, reason: verdict.reason, files: fixed.files };
659
+ // A fix that changed nothing does not stop the run before the judge has weighed escalating the failure.
660
+ if (escalating === undefined)
661
+ idleFixes = fixed.files.length === 0 ? idleFixes + 1 : 0;
662
+ else if (idle(fixed.files))
617
663
  break;
618
664
  }
619
665
  continue;
@@ -772,6 +818,7 @@ export async function renderConformanceTests(options) {
772
818
  ...(specConflict !== undefined ? { specConflict } : {}),
773
819
  ...(stuck !== undefined ? { stuck } : {}),
774
820
  ...(environment !== undefined ? { environment } : {}),
821
+ ...(escalation !== undefined ? { escalation } : {}),
775
822
  ...(unlabelled !== undefined ? { unlabelled } : {}),
776
823
  ...(reviewWaived !== undefined ? { reviewWaived } : {}),
777
824
  ...(verified && green !== undefined ? { green } : {}),
@@ -9,9 +9,9 @@ import { HStack } from "@earendil-works/pi-tui";
9
9
  */
10
10
  /** Narrower than this, the chat and the panel have no room side by side: the panel takes the whole screen. */
11
11
  const SHARED_FROM = 100;
12
- /** The columns the panel takes beside the chat - 0 when the terminal is too narrow to share. */
12
+ /** The columns the panel takes beside the chat: half the screen, the rule between them in the middle - 0 when the terminal is too narrow to share. */
13
13
  export function dockedWidth(columns) {
14
- return columns < SHARED_FROM ? 0 : Math.max(48, Math.floor(columns * 0.45));
14
+ return columns < SHARED_FROM ? 0 : Math.floor(columns / 2);
15
15
  }
16
16
  /**
17
17
  * The chat narrowed by `panelWidth()` columns, read at each frame - 0 gives it the whole width. Returns what gives it the
@@ -55,3 +55,32 @@ export function clickToFocus(tui, chatWidth, side) {
55
55
  screen.handleMouseEvent = handle;
56
56
  };
57
57
  }
58
+ /**
59
+ * A line drawn faint - the side that does not have the keys, in the shade. Faint (SGR 2) holds through a line's colours;
60
+ * only a reset - SGR 0, or 22 - ends it, so it is set again after each one. A line holding an image is left as it is.
61
+ */
62
+ export function shade(line) {
63
+ // Kitty's image escape, or iTerm2's.
64
+ if (line.includes("\x1b_G") || line.includes("\x1b]1337;File="))
65
+ return line;
66
+ const again = line.replace(/\x1b\[([\d;]*)m/g, (sgr, params) => (params.split(";").some((param) => param === "" || param === "0" || param === "22") ? `${sgr}${FAINT}` : sgr));
67
+ return `${FAINT}${again}${NOT_FAINT}`;
68
+ }
69
+ const FAINT = "\x1b[2m";
70
+ const NOT_FAINT = "\x1b[22m";
71
+ /**
72
+ * The chat in the shade while `shaded()`: each of its rows drawn faint as the screen lays the overlays over it - the
73
+ * docked panel the last of them, which covers its own columns. Returns what takes the shade off.
74
+ */
75
+ export function shadeChat(tui, shaded) {
76
+ const screen = tui;
77
+ const composite = screen.compositeLineAt;
78
+ if (typeof composite !== "function")
79
+ return () => { };
80
+ screen.compositeLineAt = function (base, overlay, col, width, total) {
81
+ return composite.call(this, shaded() ? shade(base) : base, overlay, col, width, total);
82
+ };
83
+ return () => {
84
+ screen.compositeLineAt = composite;
85
+ };
86
+ }
@@ -16,7 +16,7 @@ export function createSidePanel(options) {
16
16
  const backKeys = () => {
17
17
  if (options.dock === undefined)
18
18
  return [`esc or ${options.key} back to the chat`];
19
- return options.dock.docked() ? ["s full screen", "esc to the chat", `${options.key} close`] : ["s beside the chat", `esc or ${options.key} back to the chat`];
19
+ return options.dock.docked() ? ["s full screen", "esc or ⌥⌘← to the chat", `${options.key} close`] : ["s beside the chat", `esc or ${options.key} back to the chat`];
20
20
  };
21
21
  const waitingNote = () =>
22
22
  // A question asked while it showed waits in the chat, unanswered: the person is told it is there.
@@ -89,6 +89,19 @@ export function describeStop(facts) {
89
89
  `though they were sent back for it again and again. Still missing:\n${facts.conformance.unlabelled.map((line) => ` - ${line}`).join("\n")}`;
90
90
  todo = ["If the test reqs leave open where the tests go or how they are built, say so exactly - the folder, the files, the build."];
91
91
  }
92
+ else if (facts.at === "conformance" && facts.conformance?.escalation !== undefined) {
93
+ const { error, reason, evidence, changedNothing } = facts.conformance.escalation;
94
+ why =
95
+ `its conformance tests kept failing with ${error}. The conformance agent's fix of the tests did not make it go away, so ` +
96
+ `a judge looked again and found it the code's; the coding agent ${changedNothing === true ? "changed nothing for it" : "fixed the code, and the run still failed the same way"}. ` +
97
+ `Why the judge found it the code's:\n${reason.trim().split("\n").map((line) => ` ${line}`).join("\n")}` +
98
+ (evidence.length > 0 ? `\n On the evidence:\n${evidence.map((item) => ` - ${item.source.trim()}: ${item.shows.trim().replace(/\s+/g, " ")}`).join("\n")}` : "");
99
+ todo = [
100
+ "If the code has to produce something the spec leaves open - a build output, an entry point, a module the conformance testing " +
101
+ "script runs or checks for - say exactly what in the implementation reqs: its path and how it is built.",
102
+ "If the conformance testing script expects something the spec does not ask for, change the script or the test reqs so the two agree.",
103
+ ];
104
+ }
92
105
  else if (facts.at === "conformance" && facts.conformance?.stuck !== undefined) {
93
106
  const { error, times, rounds, changedNothing } = facts.conformance.stuck;
94
107
  why =
@@ -19,7 +19,7 @@ its conformance tests too when they are on.
19
19
  --start-over start over, without asking, a codebase regenerated without a module it now requires -
20
20
  with no terminal to ask and without it, the run stops there
21
21
  --from-scratch delete the module's code and conformance tests (its lineage keeps them) and regenerate all of it
22
- --harness-model <p/id> config.turri.yaml's harness-model for this run: the planner, the conformance chooser, a diagnosis
22
+ --harness-model <p/id> config.turri.yaml's harness-model for this run: the planner and the conformance chooser
23
23
  --coding-model <p/id> its coding-model, --model too: the agents that build; p/id:level for how hard a model thinks
24
24
  --judge-model <p/id> its judge-model: the judge and the test review
25
25
  --review <true|false> its review: whether the code and its tests are reviewed once they pass
package/docs/config.md CHANGED
@@ -122,7 +122,7 @@ models: # each agent role's model, thinking and tur
122
122
  | key | what it sets | left out |
123
123
  | --- | --- | --- |
124
124
  | `module` | the `.plain` file `turri regenerate` and the chat's `regenerate` build when none is named, from the project root | a file has to be named |
125
- | `harness-model` | the model `turri` starts on - the one you talk to - the one that diagnoses a job that failed for good, and, in the conversation, the planner that cuts the work into jobs (on whatever model the chat is on when it plans); under `turri regenerate`, the planner too | pi's configured model |
125
+ | `harness-model` | the model `turri` starts on - the one you talk to - and, in the conversation, the planner that cuts the work into jobs (on whatever model the chat is on when it plans); under `turri regenerate`, the planner too | pi's configured model |
126
126
  | `coding-model` | every render: functionalities, spec diffs, conformance tests, repairs, planning spec diffs, the explorer | `harness-model` |
127
127
  | `judge-model` | the judge and the test review of a conformance run | `coding-model` |
128
128
  | `conformance-tests` | `all` (or `true`): conformance tests for every functional spec of every module with test reqs, written once each module's code is built, before anything is built on it. `chosen`: for the functional specs chosen - every one with acceptance tests, and those the chooser picks as each code pulse ends, from what that pulse regenerated. `module`: the same, but the chooser is asked once, when the whole module is regenerated, and plans the conformance pulses too. Under both, the choice is made inside the pulse, before anything is built on the module, and the pulse's report says what was chosen and why; those the person asks for with `conform` are written too; once chosen, a functional spec keeps them, and they are rewritten when it changes - see [who chooses, and when](regenerating.md#conformance-tests-in-the-work-loop). `false`: none written, and none of the existing ones run - until the conversation asks with `conform` | `false` |
@@ -158,7 +158,8 @@ wrong type, as if it were not there. `model:`, from before there were separate m
158
158
  | --- | --- |
159
159
  | `default` | every role not named below - but the conversation's side |
160
160
  | `chat` | `turri`'s model - the same as `harness-model`, which it wins over |
161
- | `chooser`, `planner`, `diagnosis` | the conversation's side: the conformance chooser of `turri regenerate`, the planner's agents, the diagnosis of a failed job. Unnamed, the chat's model |
161
+ | `chooser`, `planner` | the conversation's side: the conformance chooser of `turri regenerate`, the planner's agents. Unnamed, the chat's model |
162
+ | `diagnosis` | the diagnosis of a job that failed for good: why it did not land, and what to do. Unnamed, the coding model the job ran on, thinking medium |
162
163
  | `release` | `turri release`'s agent and release-after-pulse's: it follows steps - a script, a start command - so it runs on the coding model, thinking low |
163
164
  | `coding` | the coding agent, the code fixer of a conformance run and the repair agent - the same as `coding-model`, which it wins over. `--model` on the command line, or a model chosen in the conversation (`engine`), wins over both |
164
165
  | `conformance` | the conformance agent |
package/docs/headless.md CHANGED
@@ -81,7 +81,7 @@ Useful options - `turri regenerate --help` lists all of them:
81
81
 
82
82
  | | |
83
83
  | --- | --- |
84
- | `--harness-model <provider/id>` | `harness-model`: the planner, the conformance chooser, the diagnosis of a failed pulse |
84
+ | `--harness-model <provider/id>` | `harness-model`: the planner and the conformance chooser |
85
85
  | `--coding-model <provider/id>` | `coding-model` (`--model` too): the agents that build - with `:level` for how hard it thinks (`provider/id:high`); not given, the harness model's |
86
86
  | `--judge-model <provider/id>` | `judge-model`: the judge and the test review; not given, the coding model |
87
87
  | `--conformance-tests <all\|chosen\|module\|false>` | `conformance-tests`, with its values (`true` is `all`) |
@@ -151,7 +151,7 @@ changes meaning or goes; new fields may appear in the same version. Paths in `pu
151
151
  "conformance_tests": "all", // all | chosen | module | false
152
152
  "review": false,
153
153
  "models": {
154
- "harness": { "model": "…", "thinking": "medium" }, // the planner, the chooser, the diagnosis
154
+ "harness": { "model": "…", "thinking": "medium" }, // the planner, the chooser
155
155
  "coding": { "model": "…", "thinking": "low" },
156
156
  "judge": { "model": "…", "thinking": "medium" },
157
157
  "observed": { "coding": ["provider/id:low"], "planner": ["provider/id:medium"] } // what each role really ran on
@@ -906,10 +906,16 @@ agent is told that a failure of the software itself - it does not build or start
906
906
  spec says it must not - is not the tests' to work around. When it fails, a judge - an agent that reads
907
907
  the code and the tests, changes neither, and holds both to the spec - decides which is wrong. It has the
908
908
  file tools and the web (`web_search`, `web_fetch`), to look up what a service the code calls answers - no shell: it
909
- judges from the code, the tests, the spec and the failing run's output, and its file tools write nothing:
909
+ judges from the code, the tests, the spec, the conformance testing script word for word and the failing run's output,
910
+ and its file tools write nothing. The explorer's account of how the code is invoked is in its prompt too, marked as
911
+ written before the run and possibly wrong: the judge checks what it uses of it against the script and the files, and
912
+ gives in `evidence` what it found itself. The script is fixed - neither agent can change it - so it is never the
913
+ culprit: what it does with the tests, the tests fit; what it needs of the build, the code provides.
910
914
 
911
915
  - **the code:** the coding agent - in the code's folder, reading the tests but never changing them - fixes it,
912
- and keeps its unit tests passing;
916
+ and keeps its unit tests passing. The judge quotes the spec's own words the code does not meet, or the testing
917
+ script's for what it needs of the build (`script_needs`: a build output, an entry point, a module) - each checked
918
+ by the harness against the text it quotes;
913
919
  - **the tests:** the conformance agent corrects them to what the spec says;
914
920
  - **the spec:** two functional specs cannot both hold, whatever the code does;
915
921
  - **the environment:** neither the code nor the tests can fix it - only the person can, in what the run is given:
@@ -953,6 +959,20 @@ response, a UUID or a correlation id. Nothing changes either when the agent sent
953
959
  changes no file: two such fixes in a row, and the run stops. The conformance agent has the testing script in its prompt,
954
960
  and is told to fit the tests' own build to it.
955
961
 
962
+ **Escalation.** A failure judged the tests' goes to the conformance agent first: it owns the tests. When the run then
963
+ fails the same way - the fix changed nothing, or nothing that mattered - the judge is asked again, now whether the
964
+ failure belongs to the code rather than the tests: the run failing on something the code is meant to produce (a build
965
+ output, an entry point, a module the testing script needs), or the tests running and the behaviour itself being
966
+ wrong. Its verdict, either way, has to rest on evidence it gathered: each piece from a file it opened (the harness
967
+ keeps track of what it read, listed and searched), the testing script or the failing run - and not the run alone.
968
+ A verdict without it is sent back; a verdict of code still without it is taken as the tests'. Judged the tests'
969
+ again, the failure stays with the conformance agent, and a fix that changes nothing counts towards stopping as
970
+ before. Judged the code's, it is escalated to the coding agent, handed the script's error, the conformance tests as
971
+ they stand and the judge's reason and evidence (`the script runs dist/cli.js; the build emits dist/src/cli.js`). Its
972
+ fix has to build and pass the unit tests, then the suite runs again. Should the run still fail the same way - or the
973
+ fix change nothing - the run stops there, with the judge's reasoning and evidence in what the person is told. Each of
974
+ these runs is one of the run's 20 attempts, so a failure neither agent can fix stops in three of them.
975
+
956
976
  Every attempt is written into the working notes' pitfalls by the harness as cause and effect - what
957
977
  the run failed with, how it was judged, and which files the agent sent to fix it changed; the next
958
978
  attempt's line says what came of it - and each judge reads them first, so it does not send a
package/docs/using.md CHANGED
@@ -131,7 +131,7 @@ come waits for a free slot, and says so.
131
131
  keyboard protocol sends as a line feed, the same as `shift+enter`, and which a dialog takes for
132
132
  `enter`; both stay pi's new-line keys. `ctrl+h` is the byte old terminals sent for backspace; a
133
133
  terminal set to send `^H` for its Backspace key would open the panel on it) takes the whole screen - the panel or the chat, one at a time -
134
- or, `s`, sits beside the chat: the chat on the left, the panel in the right 45% of the screen, and `s` again
134
+ or, `s`, sits beside the chat: the chat on the left, the panel in the right half of the screen, and `s` again
135
135
  over the whole screen. It opens as it was last left, for the conversation; a terminal under 100 columns has
136
136
  no room for both and shows the panel over the whole screen.
137
137
  It opens on **regen**: the spec itself, in full, with how much of it the code has been regenerated from drawn
@@ -218,7 +218,9 @@ come waits for a free slot, and says so.
218
218
  said and did is not shown - it is in each pulse's log (see [Agent logs](#agent-logs)).
219
219
 
220
220
  `ctrl+h` again, or `esc`, closes it and goes back to the chat. Beside the chat, a click decides which side has
221
- the keys - the chat or the panel, the one clicked - and `esc` gives the chat the keys too, the panel staying and
221
+ the keys - the chat or the panel, the one clicked, or the one `⌥⌘←` / `⌥⌘→` points at (on a
222
+ terminal that passes ⌘ on, over the Kitty keyboard protocol - Ghostty, kitty, WezTerm, iTerm2; not Apple's Terminal);
223
+ the other is drawn faint - and `esc` gives the chat the keys too, the panel staying and
222
224
  following the pulses; `ctrl+h` gives the panel the keys again, and from the panel closes it. (pi's regular screen,
223
225
  `--tui-mode regular`, reads no mouse: there it is the keys alone.)
224
226
  While the panel has the keys, every key is the panel's,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turri-dev/cli",
3
- "version": "0.1.3-next.6fab174",
3
+ "version": "0.1.3-next.a5d3797",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {