@turri-dev/cli 0.1.3-next.26f08c5 → 0.1.3-next.420ce7e

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.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/dist/config.js +3 -2
  3. package/dist/extension.js +167 -51
  4. package/dist/identity.js +1 -1
  5. package/dist/loop-cli.js +1 -1
  6. package/dist/loop-tools.js +1 -1
  7. package/dist/pi-launch.js +5 -0
  8. package/dist/picture-row.js +21 -0
  9. package/dist/question-dialog.js +8 -3
  10. package/dist/questioning.js +1 -1
  11. package/dist/regenerate/activity.js +2 -0
  12. package/dist/regenerate/confine.js +1 -0
  13. package/dist/regenerate/conform.js +5 -5
  14. package/dist/regenerate/conformance-agents.js +220 -11
  15. package/dist/regenerate/conformance.js +91 -35
  16. package/dist/regenerate/debugger.js +27 -3
  17. package/dist/regenerate/environment-error.js +63 -9
  18. package/dist/regenerate/loop.js +22 -4
  19. package/dist/regenerate/notes.js +66 -17
  20. package/dist/regenerate/panel-dock.js +86 -0
  21. package/dist/regenerate/prompt.js +3 -1
  22. package/dist/regenerate/regenerate.js +35 -14
  23. package/dist/regenerate/regression.js +2 -1
  24. package/dist/regenerate/requires.js +55 -2
  25. package/dist/regenerate/side-panel.js +14 -4
  26. package/dist/regenerate/stop-report.js +13 -0
  27. package/dist/regenerate/test-scripts.js +45 -9
  28. package/dist/regenerate/unit-review.js +11 -0
  29. package/dist/regenerate-cli.js +1 -1
  30. package/dist/resume-line.js +4 -0
  31. package/dist/spec-tools.js +8 -44
  32. package/docs/authoring-plain.md +9 -6
  33. package/docs/config.md +3 -2
  34. package/docs/development.md +1 -0
  35. package/docs/headless.md +2 -2
  36. package/docs/regenerating.md +81 -22
  37. package/docs/skills-and-gates.md +2 -2
  38. package/docs/using.md +12 -3
  39. package/genome/skills/bud/SKILL.md +67 -36
  40. package/genome/skills/bud/references/intent.md +4 -0
  41. package/package.json +3 -3
  42. package/genome/skills/bud/references/checklist.md +0 -44
package/README.md CHANGED
@@ -212,7 +212,7 @@ It runs the interview skill, asks you about the problem, the stack and the produ
212
212
  model and cost - and the queue behind it;
213
213
  - ask to try it at any time - "let me try it" - and **`release`** puts what is built so far in
214
214
  `dist/<module>` and **`activate`** starts it, installing what it needs - nothing to approve;
215
- - the **side panel** (`ctrl+h`) takes the screen and opens on **regen** (`/regen`) - \*\*\*plain, every item marked planned, working, regenerated, validating, conform or needs you - and `p` turns it to the **pulses** (`/pulses`) - every pulse, running, queued and finished, and what each regenerates; what its agents did is in each pulse's log in `.turri/jobs/`;
215
+ - the **side panel** (`ctrl+h`) takes the screen and opens on **regen** - \*\*\*plain, every item marked planned, working, regenerated, validating, conform or needs you - and `p` turns it to the **pulses** (`/pulses`) - every pulse, running, queued and finished, and what each regenerates; what its agents did is in each pulse's log in `.turri/jobs/`;
216
216
  - **`/halt`** halts a pulse, **`/lineage`** shows every generation of a
217
217
  module, and the **`drift`** tool says what is left to regenerate;
218
218
  - **`/btw <question>`** asks turri something on the side - answered at once, even in the middle of a skill's interview
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
@@ -5,11 +5,11 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
5
5
  import { basename, dirname, join, relative, resolve } from "node:path";
6
6
  import { conversationCommand, conversationSettled, conversationWriting } from "./regenerate/conversation-writes.js";
7
7
  import { fileURLToPath } from "node:url";
8
- import { allocateImageId, Box, compositeTuiLine, getCapabilities, getPngDimensions, isKeyRelease, isKeyRepeat, matchesKey, renderImage, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
8
+ import { allocateImageId, Box, getCapabilities, getPngDimensions, isKeyRelease, isKeyRepeat, matchesKey, renderImage, Text, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
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,6 +31,8 @@ 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, shade, shadeChat } from "./regenerate/panel-dock.js";
35
+ import { overPictureRow, PICTURE_AT } from "./picture-row.js";
34
36
  import { skillAboutLine, skillProgressLine } from "./skill-progress.js";
35
37
  import { createSidePanel } from "./regenerate/side-panel.js";
36
38
  import { createSpecMapSource, defaultMapFiles } from "./regenerate/spec-map.js";
@@ -88,15 +90,23 @@ aside) {
88
90
  // The question, with what the asker gives to read for it - a brief to confirm - above it; what it said of its work
89
91
  // before asking is kept in its record, never put in the dialog.
90
92
  // A dialog of turri's own: a long brief scrolls above the question, and the options always show (question-dialog.ts).
91
- const dialog = (question, context, options) => ctx.ui.custom((tui, theme, _keybindings, done) => new QuestionDialog({
92
- question,
93
- ...(context !== undefined ? { context } : {}),
94
- ...(options !== undefined ? { options } : {}),
95
- rows: () => tui.terminal.rows,
96
- style: { accent: (text) => theme.fg("accent", text), muted: (text) => theme.fg("muted", text), bold: (text) => theme.bold(text) },
97
- done,
98
- requestRender: () => tui.requestRender(),
99
- }));
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
+ });
100
110
  const asking = (asked) => runAsk(asked, {
101
111
  select: (title, options, context) => dialog(title, context, options),
102
112
  input: (title, context) => dialog(title, context),
@@ -115,6 +125,12 @@ aside) {
115
125
  }
116
126
  /** The side panel, as a question open in the chat reaches it: set once the extension has its panel. */
117
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;
118
134
  /** A widget with nothing to draw: below the editor, it takes no line. */
119
135
  const PEEK_KEY_WIDGET = "turri-peek-key";
120
136
  /**
@@ -184,7 +200,8 @@ export default function (pi) {
184
200
  projectDir: cwd,
185
201
  harnessDir: PACKAGE_ROOT,
186
202
  jobLog,
187
- model: modelSpec(projectModels(readProjectConfig(cwd)).harness),
203
+ // The coding model the job ran on - its model alone: the diagnosis thinks as its role has it.
204
+ model: modelSpec(modelOnly(job.model ?? modelSpec(projectModels(readProjectConfig(cwd)).coding))),
188
205
  });
189
206
  return diagnosis !== undefined ? describeDiagnosis(diagnosis) : undefined;
190
207
  },
@@ -1468,9 +1485,17 @@ function registerJobTools(pi, jobs, cwd) {
1468
1485
  return { content: [{ type: "text", text }], details: {} };
1469
1486
  },
1470
1487
  });
1471
- // The side panel: the regeneration - and the pulses behind p - over the whole screen. Its key (PANEL_KEY) opens it; the
1472
- // key again, or esc, closes it and goes back to the chat. While it shows, every key is its own.
1488
+ // The side panel: the regeneration - and the pulses behind p - over the whole screen, or beside the chat (s, from one to
1489
+ // the other). Its key (PANEL_KEY) opens it; the key again closes it and goes back to the chat, as esc does over the
1490
+ // screen - beside the chat, esc gives the chat its keys back and the panel stays, and the key gives them to the panel.
1491
+ // While the panel has the keys, every key is its own.
1473
1492
  let panel;
1493
+ // Beside the chat or over it: as the person last left it, for the conversation.
1494
+ let panelDocked = false;
1495
+ // The terminal the panel is drawn on, for its width: set as it opens.
1496
+ let panelTerminal;
1497
+ /** The columns the panel takes beside the chat - 0 over the whole screen, or when the terminal is too narrow to share. */
1498
+ const panelBeside = () => (panelDocked && panelTerminal !== undefined ? dockedWidth(panelTerminal.columns) : 0);
1474
1499
  // What is open in the pulse list and the map, and where their cursors are: the conversation's, as left each time it opens.
1475
1500
  const jobsTree = createJobsTreeState();
1476
1501
  const mapState = createSpecMapState();
@@ -1497,14 +1522,71 @@ function registerJobTools(pi, jobs, cwd) {
1497
1522
  }
1498
1523
  needsLooking.delete(jobs);
1499
1524
  if (panel !== undefined) {
1525
+ panel.keepChatFocus();
1500
1526
  panel.handle?.setHidden(false);
1527
+ panel.keys = true;
1501
1528
  panel.handle?.focus();
1502
1529
  return;
1503
1530
  }
1504
1531
  // Not awaited: the panel lives on while the jobs it shows run.
1505
1532
  void ctx.ui.custom((tui, theme, _keybindings, done) => {
1506
- 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
+ };
1548
+ panelTerminal = tui.terminal;
1507
1549
  let component;
1550
+ // Beside the chat: the chat drawn narrower, on the left, and a click on either side giving it the keys - undone
1551
+ // when the panel goes over the screen, or closes.
1552
+ let undock;
1553
+ const layOut = () => {
1554
+ if (panelDocked && undock === undefined) {
1555
+ const widen = narrowChat(tui, panelBeside);
1556
+ const unwatch = clickToFocus(tui, () => tui.terminal.columns - panelBeside(), (side) => {
1557
+ if (panelBeside() === 0)
1558
+ return;
1559
+ if (side === "chat")
1560
+ toChat();
1561
+ else
1562
+ toPanel();
1563
+ });
1564
+ // The side without the keys in the shade: the chat while the panel has them.
1565
+ const unshade = shadeChat(tui, () => panelBeside() > 0 && entry.keys);
1566
+ undock = () => {
1567
+ unshade();
1568
+ unwatch();
1569
+ widen();
1570
+ };
1571
+ }
1572
+ else if (!panelDocked && undock !== undefined) {
1573
+ undock();
1574
+ undock = undefined;
1575
+ }
1576
+ };
1577
+ // Beside the chat, back to it: the chat has the keys, the panel stays.
1578
+ const toChat = () => {
1579
+ entry.keys = false;
1580
+ giveBack();
1581
+ tui.requestRender();
1582
+ };
1583
+ const toPanel = () => {
1584
+ keepChatFocus();
1585
+ entry.keys = true;
1586
+ entry.handle?.focus();
1587
+ tui.requestRender();
1588
+ };
1589
+ const back = () => (panelBeside() > 0 ? toChat() : close());
1508
1590
  // Opened, it reads the files again at once: they may have changed while it was closed.
1509
1591
  specMap.refresh();
1510
1592
  const view = createSidePanel({
@@ -1514,6 +1596,14 @@ function registerJobTools(pi, jobs, cwd) {
1514
1596
  mapState,
1515
1597
  rows: () => tui.terminal.rows,
1516
1598
  key: PANEL_KEY,
1599
+ dock: {
1600
+ docked: () => panelBeside() > 0,
1601
+ toggle: () => {
1602
+ panelDocked = panelBeside() === 0;
1603
+ layOut();
1604
+ tui.requestRender();
1605
+ },
1606
+ },
1517
1607
  style: {
1518
1608
  heading: (text) => theme.bold(theme.fg("accent", text)),
1519
1609
  muted: (text) => theme.fg("muted", text),
@@ -1528,22 +1618,25 @@ function registerJobTools(pi, jobs, cwd) {
1528
1618
  // What conformance tests show does what its spec says: brand blue.
1529
1619
  conforms: brandBlue(theme),
1530
1620
  },
1531
- back: close,
1621
+ back,
1532
1622
  close,
1533
1623
  // A module that needs the person: the chat takes it up with them, in their words - what went wrong, and what now.
1534
1624
  discuss: (page) => {
1535
- close();
1625
+ back();
1536
1626
  const why = page.codebase?.stuck?.why;
1537
1627
  pi.sendUserMessage(`regen says ${page.module} needs me: ${page.status}. What went wrong, and what do we do about it?${why !== undefined ? `\n\n(turri's reason: ${why})` : ""}`, { deliverAs: "followUp" });
1538
1628
  },
1539
1629
  // Something took the keys' focus while the panel showed - a question in the chat - and waits there.
1540
1630
  waiting: () => {
1631
+ if (!entry.keys)
1632
+ return false;
1541
1633
  const focused = tui.getFocusedComponent?.() ?? null;
1542
1634
  return focused !== null && focused !== component;
1543
1635
  },
1544
1636
  });
1545
- const entry = { view, close };
1637
+ const entry = { view, close, keepChatFocus, keys: true };
1546
1638
  panel = entry;
1639
+ layOut();
1547
1640
  // Every key goes to the panel while it shows, whatever has the focus: a question the chat model asks in the
1548
1641
  // background takes the focus, but no key reaches it - an enter never answers it unseen. It waits in the chat
1549
1642
  // until the panel closes, and has the keys then. Mouse events are the screen's, before this sees them.
@@ -1565,6 +1658,22 @@ function registerJobTools(pi, jobs, cwd) {
1565
1658
  }
1566
1659
  if (!state)
1567
1660
  return undefined;
1661
+ // Beside the chat, ⌥⌘← and ⌥⌘→ go to the side they point at, whichever has the keys - on a terminal that tells
1662
+ // turri of ⌘ (the Kitty keyboard protocol). Its release goes nowhere.
1663
+ if (panelBeside() > 0 && (matchesKey(data, SIDE_KEYS.chat) || matchesKey(data, SIDE_KEYS.panel))) {
1664
+ if (!isKeyRelease(data)) {
1665
+ if (matchesKey(data, SIDE_KEYS.chat))
1666
+ toChat();
1667
+ else
1668
+ toPanel();
1669
+ }
1670
+ return { consume: true };
1671
+ }
1672
+ // Beside the chat, with the chat's keys: they are the chat's - PANEL_KEY among them, for the shortcut that gives
1673
+ // them to the panel. A click on the panel gives it the focus, and the keys with it.
1674
+ if (!entry.keys && entry.handle?.isFocused() !== true)
1675
+ return undefined;
1676
+ entry.keys = true;
1568
1677
  // A terminal on the Kitty keyboard protocol reports a key's release, and listeners are given it: the release of
1569
1678
  // the ctrl+h that opened the panel would close it again at once, and every arrow would move twice. Only
1570
1679
  // presses count - and ctrl+h held down, repeating, does not open and close it over and over.
@@ -1591,7 +1700,13 @@ function registerJobTools(pi, jobs, cwd) {
1591
1700
  }, WATCH_REDRAW_MS);
1592
1701
  timer.unref?.();
1593
1702
  component = {
1594
- render: (width) => view.render(width),
1703
+ // Beside the chat, a rule down its left edge between them - and the panel in the shade while the chat has the keys.
1704
+ render: (width) => {
1705
+ if (panelBeside() === 0)
1706
+ return view.render(width);
1707
+ const lines = view.render(width - 2).map((line) => `${theme.fg("borderMuted", "│")} ${line}`);
1708
+ return entry.keys ? lines : lines.map(shade);
1709
+ },
1595
1710
  // Reached only if the listener above is gone: the same, so the panel never passes a key on.
1596
1711
  handleInput: (data) => {
1597
1712
  if (isKeyRepeat(data) && matchesKey(data, PANEL_KEY))
@@ -1607,6 +1722,7 @@ function registerJobTools(pi, jobs, cwd) {
1607
1722
  dispose: () => {
1608
1723
  unlisten();
1609
1724
  clearInterval(timer);
1725
+ undock?.();
1610
1726
  if (panel === entry)
1611
1727
  panel = undefined;
1612
1728
  },
@@ -1614,34 +1730,36 @@ function registerJobTools(pi, jobs, cwd) {
1614
1730
  return component;
1615
1731
  }, {
1616
1732
  overlay: true,
1617
- // The whole screen: the panel or the chat, one at a time.
1618
- overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", margin: 0 },
1733
+ // The whole screen, or the columns on the right beside the chat - read at each frame, so it follows the terminal.
1734
+ overlayOptions: {
1735
+ get width() {
1736
+ return panelBeside() > 0 ? panelBeside() : "100%";
1737
+ },
1738
+ maxHeight: "100%",
1739
+ anchor: "top-right",
1740
+ margin: 0,
1741
+ },
1619
1742
  onHandle: (handle) => {
1620
1743
  if (panel !== undefined)
1621
1744
  panel.handle = handle;
1622
1745
  },
1623
1746
  });
1624
1747
  };
1625
- // The side panel's two views: /regen opens it on regen - the spec, with how far the code has been regenerated into it
1626
- // - and /pulses on every pulse, regenerating, planned and finished, and what each regenerates. What the agents did is
1627
- // in each pulse's log.
1628
- for (const [name, description, view] of [
1629
- ["regen", `regen, in the side panel: every item of the spec, built, regenerating, planned or waiting. ${PANEL_KEY} opens the panel too.`, "map"],
1630
- ["pulses", `The pulses, in the side panel: every pulse, regenerating, planned and finished, and what each regenerates. ${PANEL_KEY} opens the panel too.`, "pulses"],
1631
- ]) {
1632
- pi.registerCommand(name, {
1633
- description,
1634
- handler: async (_args, ctx) => {
1635
- if (!ctx.hasUI)
1636
- return;
1637
- openPanel(ctx, view);
1638
- },
1639
- });
1640
- }
1748
+ // /pulses opens the side panel on every pulse, regenerating, planned and finished, and what each regenerates; regen -
1749
+ // the spec, with how far the code has been regenerated into it - is PANEL_KEY's, no command of its own beside
1750
+ // /regenerate. What the agents did is in each pulse's log.
1751
+ pi.registerCommand("pulses", {
1752
+ description: `The pulses, in the side panel: every pulse, regenerating, planned and finished, and what each regenerates. ${PANEL_KEY} opens the panel too.`,
1753
+ handler: async (_args, ctx) => {
1754
+ if (!ctx.hasUI)
1755
+ return;
1756
+ openPanel(ctx, "pulses");
1757
+ },
1758
+ });
1641
1759
  panelOpeners.set(jobs, (ctx) => openPanel(ctx));
1642
1760
  questionPeek = {
1643
1761
  open: (ctx) => openPanel(ctx),
1644
- shown: () => panel !== undefined,
1762
+ shown: () => panel !== undefined && panel.keys,
1645
1763
  };
1646
1764
  pi.registerShortcut(PANEL_KEY, {
1647
1765
  description: "Peek at regen and the pulses - a question waiting in the chat waits for you - or go back to it",
@@ -1712,8 +1830,12 @@ export const JOB_FINISHED_REACTION = [
1712
1830
  "A pulse that stopped on its environment - \"stopped: turri's sandbox kept its agents from…\" or \"stopped: the",
1713
1831
  "environment lacks…\" - stops the same way again until that is fixed: never regenerate it before. Heal it, then regenerate the",
1714
1832
  "module to resume:",
1715
- "- turri's sandbox kept the agents from a folder on this machine: nothing is missing. Add the folder its report names to",
1716
- " `toolchains:` in config.turri.yaml - a list, keeping what is there - say so in a line, and resume.",
1833
+ "- turri's sandbox kept the agents from reading a folder on this machine: nothing is missing. Add the folder its report",
1834
+ " names to `toolchains:` in config.turri.yaml - a list, keeping what is there - say so in a line, and resume.",
1835
+ "- turri's sandbox kept the agents from writing a file - a lock a build tool takes, say: no setting lets them, and",
1836
+ " `toolchains:` does not - never add it there, and never resume before the fix. Do what the report says if it is the",
1837
+ " project's to change - a tool told to use `$HOME` in the build's own files - say so, and resume; otherwise tell the",
1838
+ " person, in a line or two, what the report says and that \"resume\" picks it up once it is done.",
1717
1839
  "- the machine lacks a toolchain or a tool - a JDK, Maven, Gradle, Go, uv, Node, a system library: first find out, in your",
1718
1840
  " shell, whether it is installed somewhere off the PATH (`command -v`, Homebrew's opt folders, /Library/Java/",
1719
1841
  " JavaVirtualMachines, a version manager the person uses - mise, asdf, sdkman, nvm). Then ask the person with ask_user",
@@ -1725,6 +1847,8 @@ export const JOB_FINISHED_REACTION = [
1725
1847
  " to do, and that \"resume\" picks it up once it is in.",
1726
1848
  "- a credential `agent-env:` names that is not there: never make one up - tell the person where it is read from.",
1727
1849
  ].join("\n");
1850
+ /** The keys that go to the chat and to the panel, side by side: ⌥⌘← and ⌥⌘→. */
1851
+ const SIDE_KEYS = { chat: "alt+super+left", panel: "alt+super+right" };
1728
1852
  /** Opens the side panel, and goes back to it from the conversation. */
1729
1853
  /** The modules in this project that have a history, by name. */
1730
1854
  function modulesWithHistory(cwd) {
@@ -2239,8 +2363,6 @@ function animateJellyfish(ctx) {
2239
2363
  }, { placement: "belowEditor" });
2240
2364
  }
2241
2365
  const JELLYFISH_CLOCK_KEY = "turri-jellyfish";
2242
- /** What the banner puts between a row's text and the picture after it: back to the jellyfish's column. */
2243
- const PICTURE_AT = "\x1b[2G";
2244
2366
  /** The columns the banner's picture takes from the left edge, while it shows one; 0 when it does not. */
2245
2367
  let pictureRight = 0;
2246
2368
  /**
@@ -2255,14 +2377,7 @@ function drawOverlaysBesidePicture(tui) {
2255
2377
  return;
2256
2378
  target.turriPicture = true;
2257
2379
  const original = target.compositeLineAt.bind(tui);
2258
- target.compositeLineAt = (base, overlay, col, width, total) => {
2259
- const at = base.lastIndexOf(PICTURE_AT);
2260
- const picture = at === -1 ? "" : base.slice(at + PICTURE_AT.length);
2261
- if (!/^(\x1b\[\d+A)?(\x1b_G|\x1b\]1337;File=)/.test(picture))
2262
- return original(base, overlay, col, width, total);
2263
- const drawn = compositeTuiLine(base.slice(0, at), overlay, col, width, total);
2264
- return col >= pictureRight ? `${drawn}${PICTURE_AT}${picture}` : drawn;
2265
- };
2380
+ target.compositeLineAt = (base, overlay, col, width, total) => overPictureRow(base, overlay, col, width, total, pictureRight) ?? original(base, overlay, col, width, total);
2266
2381
  }
2267
2382
  /** Columns between the jellyfish and the lines beside it. */
2268
2383
  const JELLYFISH_GAP = 3;
@@ -2294,7 +2409,8 @@ class Banner {
2294
2409
  const art = this.art;
2295
2410
  const placed = art?.kind === "image" ? this.placeImage(art, Math.max(JELLYFISH_ROWS, this.lines.length), width - 2 - JELLYFISH_GAP - BANNER_TEXT_MIN) : art?.kind === "text" && art.columns <= width - 2 - JELLYFISH_GAP - BANNER_TEXT_MIN ? art : undefined;
2296
2411
  const left = placed !== undefined ? 1 + placed.columns + JELLYFISH_GAP : 1;
2297
- const text = this.lines.map((line) => truncateToWidth(line, Math.max(1, width - 1 - left)));
2412
+ // Wrapped in the columns beside the picture, never cut short - the chat beside the docked panel is narrow.
2413
+ const text = this.lines.flatMap((line) => wrapTextWithAnsi(line, Math.max(1, width - 1 - left)));
2298
2414
  const height = Math.max(text.length, placed?.rows ?? 0);
2299
2415
  const above = Math.floor((height - text.length) / 2);
2300
2416
  const drawn = placed?.kind === "text" ? placed.frames[jellyfishFrame % placed.frames.length] : undefined;
package/dist/identity.js CHANGED
@@ -25,7 +25,7 @@ export const TURRI_IDENTITY = [
25
25
  "- A pulse is one unit of work regenerating the code - a spec diff regenerated in a codebase, or its conformance",
26
26
  " tests. It was called a job, and still is in places: the job_id a tool takes is a pulse's id, and parallel-jobs and",
27
27
  " allow-to-stop-jobs in config.turri.yaml are about pulses. \"Job\" in what you read is a pulse; say pulse.",
28
- "- regen is the side panel (/regen, or ctrl+h): the spec with each functionality and item marked built, regenerating,",
28
+ "- regen is the side panel (ctrl+h): the spec with each functionality and item marked built, regenerating,",
29
29
  " planned or waiting - and behind it the pulses (/pulses), every pulse regenerating, planned and finished. Say regen,",
30
30
  " lowercase. It was the regeneration, the job queue, or the job panel.",
31
31
  "- mutate, in a skill's activity, is write_spec_diff: a spec change written and regenerated in one step.",
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) => {
@@ -73,7 +73,7 @@ export async function loopReply(loop, statuses, wait) {
73
73
  export function stopJob(jobs, named, reason) {
74
74
  const matches = jobs.list().filter((job) => job.id.startsWith(named.trim()));
75
75
  if (matches.length === 0)
76
- return `No pulse ${named}. /regen shows them.`;
76
+ return `No pulse ${named}. /pulses shows them.`;
77
77
  if (matches.length > 1)
78
78
  return `'${named}' matches ${matches.map((job) => job.id).join(", ")}. Give more of the id.`;
79
79
  const target = matches[0];
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.
@@ -0,0 +1,21 @@
1
+ import { compositeTuiLine } from "@earendil-works/pi-tui";
2
+ /** What the banner puts between a row's text and the picture after it: back to the jellyfish's column. */
3
+ export const PICTURE_AT = "\x1b[2G";
4
+ /** The picture's escape sequence, as the banner writes it after PICTURE_AT: Kitty's, in chunks, or iTerm2's - climbing first on iTerm2. */
5
+ const PICTURE = /^(?:\x1b\[\d+A)?(?:(?:\x1b_G[^\x1b]*\x1b\\)+|\x1b\]1337;File=[^\x07]*\x07)/;
6
+ /**
7
+ * The banner's picture row with an overlay drawn over its text, the picture after it - left out while the overlay
8
+ * reaches over the picture, `pictureRight` columns from the left edge. Undefined for a row that holds no picture.
9
+ *
10
+ * What follows the picture - the padding a row narrower than the screen is given, the chat's beside the docked panel -
11
+ * is the row's text: written after the picture, from the picture's column, it would blank the row.
12
+ */
13
+ export function overPictureRow(base, overlay, col, width, total, pictureRight) {
14
+ const at = base.lastIndexOf(PICTURE_AT);
15
+ const picture = at === -1 ? null : PICTURE.exec(base.slice(at + PICTURE_AT.length));
16
+ if (picture === null)
17
+ return undefined;
18
+ const after = base.slice(at + PICTURE_AT.length + picture[0].length);
19
+ const drawn = compositeTuiLine(`${base.slice(0, at)}${after}`, overlay, col, width, total);
20
+ return col >= pictureRight ? `${drawn}${PICTURE_AT}${picture[0]}` : drawn;
21
+ }
@@ -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":
@@ -188,6 +188,7 @@ export function sandboxNote(fence) {
188
188
  const report = fence.environmentTool !== undefined
189
189
  ? `report it as an environment error with ${fence.environmentTool}`
190
190
  : "say so plainly - what is missing and what needs it - and end your turn";
191
+ lines.push("- A command refused a file outside your folder - `Operation not permitted`, `Permission denied`, a lock it could not", ` take - is these walls, not the code, and nothing you change gets past them: ${report} at once, naming the file`, " and the command that wanted it.");
191
192
  lines.push(fence.write.length > 0
192
193
  ? "- The project's dependencies are yours: declare them in the build manifest, and let the build tools fetch them."
193
194
  : "- You install nothing.", `${fence.write.length > 0 ? " Installing anything else is not" : " Nor is installing anything on the machine yours"}: no toolchains, system packages, drivers, global packages or \`sudo\`. If the`, ` machine lacks something the code needs, ${report}, name what is missing, and go no further with what depends on it.`, " The person decides.", "- Ports and processes: you may stop only what you started. If a port is taken by something else, use another port.", " `ps` may show nothing but what you started: check a server you started with `kill -0 $!`, its log, or a request to it.", "- Temporary files go in `$TMPDIR`: `/tmp` is outside your walls.");
@@ -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" };