create-egregore 0.18.18 → 0.19.0

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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // egregore-launcher-version: 0.5.27
2
+ // egregore-launcher-version: 0.6.0
3
3
  //
4
4
  // egregore — launch and browse Egregore instances from your terminal.
5
5
  // Installed by create-egregore. Reads ~/.egregore/instances.json.
@@ -30,7 +30,7 @@ const https = require("node:https");
30
30
  const { spawnSync, spawn } = require("node:child_process");
31
31
  const readline = require("node:readline");
32
32
 
33
- const VERSION = "0.5.27";
33
+ const VERSION = "0.6.0";
34
34
  const REGISTRY = path.join(os.homedir(), ".egregore", "instances.json");
35
35
  const DEFAULT_FILE = path.join(os.homedir(), ".egregore", "default");
36
36
  const PUBLIC_NPX_PACKAGE = "create-egregore";
@@ -424,7 +424,7 @@ function runtimePickerLines(inst, runtimes, stored, cursor) {
424
424
  const labelWidth = Math.max(...runtimes.map((r) => runtimeLabel(r).length));
425
425
  const lines = [
426
426
  "",
427
- ` ${DIM}↑↓${RESET} navigate · ${DIM}enter${RESET} select · ${DIM}1-${runtimes.length}${RESET} quick select · ${DIM}esc${RESET} cancel`,
427
+ ` ${DIM}↑↓${RESET} navigate · ${DIM}enter${RESET} select · ${DIM}1-${runtimes.length}${RESET} quick select · ${DIM}q${RESET} cancel`,
428
428
  ` ${CYAN}◆${RESET} ${BOLD}Launch ${title} with which harness?${RESET}`,
429
429
  "",
430
430
  ];
@@ -494,7 +494,9 @@ function pickRuntimeInteractive(inst, runtimes, stored) {
494
494
  cleanup();
495
495
  process.exit(130);
496
496
  }
497
- if (key.name === "escape" || key.name === "q") return finish(null);
497
+ // Esc never quits (it reads as "back", and there's nowhere back to go
498
+ // from here) — q is the only cancel key besides Ctrl+C.
499
+ if (key.name === "q") return finish(null);
498
500
  if (key.name === "up" || (key.ctrl && key.name === "p") || key.name === "k") {
499
501
  cursor = (cursor - 1 + runtimes.length) % runtimes.length;
500
502
  render();
@@ -832,10 +834,9 @@ function cmdHelp() {
832
834
  ` ${DIM}n / +${RESET} create a new Egregore`,
833
835
  ` ${DIM}u${RESET} set selected as default`,
834
836
  ` ${DIM}i${RESET} invite a GitHub user to selected instance`,
835
- ` ${DIM}r${RESET} remove selected (with confirm)`,
836
- ` ${DIM}p${RESET} prune entries with missing paths (with confirm)`,
837
+ ` ${DIM}d${RESET} delete selected entry (offers cleanup of ${RED}✗${RESET} missing ones)`,
837
838
  ` ${DIM}?${RESET} toggle help overlay`,
838
- ` ${DIM}q / esc${RESET} quit browser`,
839
+ ` ${DIM}q${RESET} quit browser (esc goes back — it never quits)`,
839
840
  ``,
840
841
  ` ${BOLD}REGISTRY${RESET}`,
841
842
  ` ${DIM}instances:${RESET} ~/.egregore/instances.json`,
@@ -1463,7 +1464,7 @@ function renderRuntimePanel({ inst, cursor, width, height }) {
1463
1464
  }
1464
1465
 
1465
1466
  lines.push(emptyBoxLine(innerWidth, margin));
1466
- lines.push(boxLine(`${DIM}↑↓ navigate · enter select · 1-${runtimes.length} quick select · esc back · q quit${RESET}`, innerWidth, margin));
1467
+ lines.push(boxLine(`${DIM}↑↓ navigate · enter select · 1-${runtimes.length} quick select · ← back · q quit${RESET}`, innerWidth, margin));
1467
1468
  lines.push(`${margin}${DIM}└${"─".repeat(innerWidth)}┘${RESET}`);
1468
1469
  return lines;
1469
1470
  }
@@ -1482,6 +1483,15 @@ async function browser(args = {}) {
1482
1483
  return numberedPicker(registry, args.runtimeOverride);
1483
1484
  }
1484
1485
 
1486
+ // Entries whose directory is gone are dead registry rows. They stay listed
1487
+ // (marked ✗ missing) so the problem is visible in place — cleanup is offered
1488
+ // through `d`, never done automatically. Cached per session; refreshed after
1489
+ // every registry mutation instead of stat-ing on each keypress render.
1490
+ let stalePaths = new Set(registry.filter((e) => !fs.existsSync(e.path)).map((e) => e.path));
1491
+ const refreshStale = () => {
1492
+ stalePaths = new Set(registry.filter((e) => !fs.existsSync(e.path)).map((e) => e.path));
1493
+ };
1494
+
1485
1495
  // State
1486
1496
  const state = {
1487
1497
  filter: "",
@@ -1493,7 +1503,7 @@ async function browser(args = {}) {
1493
1503
  runtimeCursor: 0,
1494
1504
  cursor: initialCursorForCwd(registry),
1495
1505
  showHelp: false,
1496
- modal: null, // { type: "remove" | "prune", onYes, onNo }
1506
+ modal: null, // { type: "remove" | "sweep", onYes, onNo }
1497
1507
  tick: 0,
1498
1508
  message: null, // ephemeral info line (e.g. "default set to X")
1499
1509
  messageKind: "info", // "info" | "success" | "warn"
@@ -1585,7 +1595,10 @@ async function browser(args = {}) {
1585
1595
  out.push(ANSI.clearLine + "\n");
1586
1596
 
1587
1597
  // Layout: list takes top 60%, preview takes rest, footer is last 2 lines
1588
- const footerRows = 2;
1598
+ // (3 while stale entries exist — the cleanup notice gets its own row so
1599
+ // the layout doesn't jump when modals or messages come and go).
1600
+ const staleCount = stalePaths.size;
1601
+ const footerRows = staleCount > 0 ? 3 : 2;
1589
1602
  const usedRows = heroLines.length + 1;
1590
1603
  const availableRows = Math.max(8, h - usedRows - footerRows - 2);
1591
1604
  const listRows = Math.max(4, Math.floor(availableRows * 0.48));
@@ -1631,13 +1644,21 @@ async function browser(args = {}) {
1631
1644
  void origIdx;
1632
1645
  const isCursor = idx === state.cursor;
1633
1646
  const isDefault = e.slug === def;
1647
+ const isMissing = stalePaths.has(e.path);
1634
1648
  const cursorMark = isCursor ? `${CYAN}❯${RESET}` : " ";
1635
1649
  const defMark = isDefault ? `${GREEN}●${RESET}` : " ";
1636
1650
  const slug = isCursor
1637
1651
  ? `${BOLD}${CYAN}${pad(e.slug, slugW)}${RESET}`
1638
- : (isDefault ? `${BOLD}${pad(e.slug, slugW)}${RESET}` : pad(e.slug, slugW));
1639
- const name = pad(e.name || "", nameW);
1640
- const pathDim = `${DIM}${truncate(e.path, Math.max(0, listAvail - slugW - nameW - 10))}${RESET}`;
1652
+ : (isDefault
1653
+ ? `${BOLD}${pad(e.slug, slugW)}${RESET}`
1654
+ : (isMissing ? `${DIM}${pad(e.slug, slugW)}${RESET}` : pad(e.slug, slugW)));
1655
+ const name = isMissing && !isCursor
1656
+ ? `${DIM}${pad(e.name || "", nameW)}${RESET}`
1657
+ : pad(e.name || "", nameW);
1658
+ const pathAvail = Math.max(0, listAvail - slugW - nameW - 10);
1659
+ const pathDim = isMissing
1660
+ ? `${RED}✗${RESET} ${DIM}${truncate(e.path, Math.max(8, pathAvail - 12))}${RESET} ${RED}(missing)${RESET}`
1661
+ : `${DIM}${truncate(e.path, pathAvail)}${RESET}`;
1641
1662
  const line = ` ${cursorMark} ${defMark} ${slug} ${name} ${pathDim}`;
1642
1663
  out.push(M + " ".repeat(listPad - 3) + line + ANSI.clearLine + "\n");
1643
1664
  }
@@ -1661,7 +1682,9 @@ async function browser(args = {}) {
1661
1682
  // value to blockWidth-15 so every line's right edge lands two cols inside
1662
1683
  // the box border, level with the box inner content (gutter+blockWidth-2).
1663
1684
  const valW = Math.max(16, blockWidth - 15);
1664
- previewLines.push(` ${label("path")} ${truncate(selected.path, valW)}`);
1685
+ previewLines.push(stalePaths.has(selected.path)
1686
+ ? ` ${label("path")} ${RED}✗${RESET} ${truncate(selected.path, Math.max(8, valW - 12))} ${RED}(missing)${RESET}`
1687
+ : ` ${label("path")} ${truncate(selected.path, valW)}`);
1665
1688
  if (pv.org) previewLines.push(` ${label("org")} ${truncate(`${pv.org} ${DIM}(${pv.githubOrg || "?"})${RESET}`, valW)}`);
1666
1689
  if (pv.branch) previewLines.push(` ${label("branch")} ${BOLD}${pv.branch}${RESET}`);
1667
1690
  if (pv.lastCommit) previewLines.push(` ${label("last")} ${DIM}${truncate(pv.lastCommit, valW)}${RESET}`);
@@ -1690,32 +1713,54 @@ async function browser(args = {}) {
1690
1713
  // Divider
1691
1714
  out.push(DIM + M + "─".repeat(blockWidth) + RESET + ANSI.clearLine + "\n");
1692
1715
 
1693
- // Footer
1716
+ // Notice row — persistent while dead entries exist, quiet otherwise. The
1717
+ // row is reserved either way (footerRows above) so toggling modals/filter
1718
+ // doesn't shift the layout.
1719
+ if (staleCount > 0) {
1720
+ const noticeIdle = !state.modal && !state.filterMode && !state.inviteMode;
1721
+ const noun = staleCount === 1 ? "entry points to a missing folder" : "entries point to missing folders";
1722
+ out.push(noticeIdle
1723
+ ? `${M} ${YELLOW}!${RESET} ${staleCount} ${noun} — ${BOLD}d${RESET} ${DIM}to clean up${RESET}${ANSI.clearLine}\n`
1724
+ : ANSI.clearLine + "\n");
1725
+ }
1726
+
1727
+ // Footer — a status band in the banner's accent color (the terminal's
1728
+ // cyan slot) anchors the keys; the plain dim strip was easy to miss.
1729
+ // Band color doubles as a mode signal: accent = browsing, yellow = a
1730
+ // question needs answering, green = success flash. Input states
1731
+ // (filter, invite) stay unbanded so the caret keeps its contrast.
1732
+ // Content on the band is fg-black with BOLD/UNBOLD only — a RESET
1733
+ // mid-line would kill the background fill.
1734
+ const UNBOLD = E(22);
1735
+ const band = (content, bg) => colorOn
1736
+ ? `${E(bg)}${E(30)}${pad(` ${content}`, blockWidth)}${RESET}${ANSI.clearLine}`
1737
+ : ` ${stripAnsi(content)}${ANSI.clearLine}`;
1738
+ const key = (k, label) => `${BOLD}${k}${UNBOLD} ${label}`;
1694
1739
  let footer;
1695
1740
  if (state.modal) {
1696
1741
  const m = state.modal;
1697
1742
  const prompt = m.type === "remove"
1698
- ? `remove ${BOLD}${m.inst.slug}${RESET}? ${DIM}[y/N]${RESET}`
1699
- : `prune ${BOLD}${m.count}${RESET} stale entries? ${DIM}[y/N]${RESET}`;
1700
- footer = ` ${YELLOW}?${RESET} ${prompt}${ANSI.clearLine}`;
1743
+ ? `remove ${BOLD}${m.inst.slug}${UNBOLD}${m.missing ? " (folder missing)" : ""}? [y/N]`
1744
+ : `remove ${BOLD}${m.count}${UNBOLD} ${m.count === 1 ? "entry" : "entries"} with missing folders? [y/N]`;
1745
+ footer = band(`? ${prompt}`, 43);
1701
1746
  } else if (state.filterMode) {
1702
1747
  footer = ` ${DIM}typing filter · enter/esc to exit filter mode${RESET}${ANSI.clearLine}`;
1703
1748
  } else if (state.inviteMode) {
1704
1749
  const target = state.inviteInst?.slug || "selected";
1705
1750
  footer = ` ${CYAN}/invite${RESET} ${DIM}${target}:${RESET} ${state.inviteUsername}${CYAN}▌${RESET} ${DIM}enter send · esc cancel${RESET}${ANSI.clearLine}`;
1706
1751
  } else if (state.showHelp) {
1707
- footer = ` ${DIM}? toggles this hint · q quits${RESET}${ANSI.clearLine}`;
1752
+ footer = band(`${key("?", "toggles this hint")} ${key("q", "quits")}`, 46);
1708
1753
  } else if (state.message && Date.now() < state.messageUntil) {
1709
- const kindColor = state.messageKind === "success" ? GREEN : state.messageKind === "warn" ? YELLOW : CYAN;
1710
- footer = ` ${kindColor}●${RESET} ${state.message}${ANSI.clearLine}`;
1754
+ const bg = state.messageKind === "success" ? 42 : state.messageKind === "warn" ? 43 : 46;
1755
+ footer = band(`● ${stripAnsi(state.message)}`, bg);
1711
1756
  } else {
1712
1757
  footer = blockWidth < 96
1713
- ? ` ${DIM}↑↓${RESET} nav ${DIM}enter${RESET} select ${DIM}n/+${RESET} new ${DIM}i${RESET} invite ${DIM}?${RESET} help ${DIM}q${RESET} quit${ANSI.clearLine}`
1714
- : ` ${DIM}↑↓${RESET} nav ${DIM}enter${RESET} select ${DIM}n/+${RESET} new ${DIM}/${RESET} filter ${DIM}i${RESET} invite ${DIM}u${RESET} default ${DIM}r${RESET} rm ${DIM}p${RESET} prune ${DIM}?${RESET} help ${DIM}q${RESET} quit${ANSI.clearLine}`;
1758
+ ? band(`${key("↑↓", "nav")} ${key("enter", "select")} ${key("n/+", "new")} ${key("i", "invite")} ${key("d", "delete")} ${key("?", "help")} ${key("q", "quit")}`, 46)
1759
+ : band(`${key("↑↓", "nav")} ${key("enter", "select")} ${key("n/+", "new")} ${key("/", "filter")} ${key("i", "invite")} ${key("u", "default")} ${key("d", "delete")} ${key("?", "help")} ${key("q", "quit")}`, 46);
1715
1760
  }
1716
1761
  out.push(M + footer + "\n");
1717
1762
  if (state.showHelp) {
1718
- out.push(`${M} ${DIM}● default · ❯ cursor · n/+ starts guided setup · i opens /invite${RESET}${ANSI.clearLine}`);
1763
+ out.push(M + band("● default · ❯ cursor · ✗ folder missing · d deletes an entry · esc never quits", 46));
1719
1764
  } else {
1720
1765
  out.push(ANSI.clearLine);
1721
1766
  }
@@ -1788,7 +1833,9 @@ async function browser(args = {}) {
1788
1833
  if (state.runtimeInst) {
1789
1834
  const runtimes = runtimeChoicesFor(state.runtimeInst, args.runtimeOverride);
1790
1835
  if (key.ctrl && key.name === "c") { cleanup(); process.exit(130); }
1791
- if (key.name === "escape") {
1836
+ // ← is the advertised back key (enter descends, ← ascends); esc is a
1837
+ // silent alias for the reflex — it goes back, never quits.
1838
+ if (key.name === "escape" || key.name === "left") {
1792
1839
  state.runtimeInst = null;
1793
1840
  state.runtimeCursor = 0;
1794
1841
  render();
@@ -1827,16 +1874,23 @@ async function browser(args = {}) {
1827
1874
  return;
1828
1875
  }
1829
1876
 
1830
- // Modal handling takes priority
1877
+ // Modal handling takes priority. The modal is cleared BEFORE its
1878
+ // callback runs so a callback can chain a follow-up modal (the delete →
1879
+ // sweep offer). Esc is a full cancel: it closes the modal and skips the
1880
+ // chain — n/enter decline this modal but still allow the follow-up.
1831
1881
  if (state.modal) {
1882
+ const m = state.modal;
1832
1883
  if ((chunk && /^[yY]/.test(chunk))) {
1833
- state.modal.onYes();
1834
1884
  state.modal = null;
1885
+ m.onYes();
1835
1886
  render();
1836
- } else if (key.name === "escape" || (chunk && /^[nN]/.test(chunk)) || key.name === "return") {
1837
- state.modal.onNo?.();
1887
+ } else if (key.name === "escape") {
1838
1888
  state.modal = null;
1839
1889
  render();
1890
+ } else if ((chunk && /^[nN]/.test(chunk)) || key.name === "return") {
1891
+ state.modal = null;
1892
+ m.onNo?.();
1893
+ render();
1840
1894
  }
1841
1895
  return;
1842
1896
  }
@@ -1908,13 +1962,20 @@ async function browser(args = {}) {
1908
1962
 
1909
1963
  // Normal mode
1910
1964
  if (key.ctrl && key.name === "c") { cleanup(); process.exit(130); }
1911
- if (key.name === "q" || key.name === "escape") {
1965
+ // Esc never quits: at the root it only clears an active filter. Users
1966
+ // press esc expecting either "back" or "abort" — both are safe when it
1967
+ // can't eject them to the shell. A flash points at the real quit key.
1968
+ if (key.name === "escape") {
1912
1969
  if (state.filter) {
1913
1970
  state.filter = "";
1914
1971
  clampCursor();
1915
1972
  render();
1916
1973
  return;
1917
1974
  }
1975
+ flashMessage(`${BOLD}q${RESET} quits — esc never does`, "info");
1976
+ return;
1977
+ }
1978
+ if (key.name === "q") {
1918
1979
  cleanup();
1919
1980
  resolve();
1920
1981
  return;
@@ -2000,40 +2061,49 @@ async function browser(args = {}) {
2000
2061
  flashMessage(`default set to ${BOLD}${pick.slug}${RESET}`, "success");
2001
2062
  return;
2002
2063
  }
2003
- if (chunk === "r") {
2064
+ // d = the one delete verb (r kept as an unadvertised alias for muscle
2065
+ // memory). Deleting confirms first; when other entries with missing
2066
+ // folders remain, a sweep offer chains after — whether the first modal
2067
+ // was accepted or declined, so pressing d anywhere still reaches the
2068
+ // cleanup the notice line promises. Esc cancels without chaining.
2069
+ if (chunk === "d" || chunk === "r") {
2004
2070
  const pick = visible()[state.cursor]?.e;
2005
2071
  if (!pick) return;
2072
+ const offerSweep = () => {
2073
+ const stale = registry.filter((e) => !fs.existsSync(e.path));
2074
+ if (stale.length === 0) return;
2075
+ state.modal = {
2076
+ type: "sweep",
2077
+ count: stale.length,
2078
+ onYes: () => {
2079
+ const bak = backupRegistry();
2080
+ registry = registry.filter((e) => fs.existsSync(e.path));
2081
+ writeRegistry(registry);
2082
+ refreshStale();
2083
+ clampCursor();
2084
+ flashMessage(`removed ${stale.length} · ${registry.length} remain · backup: ${path.basename(bak)}`, "success");
2085
+ },
2086
+ };
2087
+ };
2088
+ const pickMissing = !fs.existsSync(pick.path);
2006
2089
  state.modal = {
2007
2090
  type: "remove",
2008
2091
  inst: pick,
2092
+ missing: pickMissing,
2009
2093
  onYes: () => {
2010
2094
  const bak = backupRegistry();
2011
2095
  registry = registry.filter((e) => e.path !== pick.path);
2012
2096
  writeRegistry(registry);
2013
2097
  previewCache.delete(pick.path);
2098
+ refreshStale();
2014
2099
  clampCursor();
2015
2100
  flashMessage(`removed ${BOLD}${pick.slug}${RESET} · backup: ${path.basename(bak)}`, "success");
2101
+ offerSweep();
2016
2102
  },
2017
- };
2018
- render();
2019
- return;
2020
- }
2021
- if (chunk === "p") {
2022
- const stale = registry.filter((e) => !fs.existsSync(e.path));
2023
- if (stale.length === 0) {
2024
- flashMessage(`registry is clean — all ${registry.length} paths exist.`, "success");
2025
- return;
2026
- }
2027
- state.modal = {
2028
- type: "prune",
2029
- count: stale.length,
2030
- onYes: () => {
2031
- const bak = backupRegistry();
2032
- registry = registry.filter((e) => fs.existsSync(e.path));
2033
- writeRegistry(registry);
2034
- clampCursor();
2035
- flashMessage(`pruned ${stale.length} · ${registry.length} remain · backup: ${path.basename(bak)}`, "success");
2036
- },
2103
+ // Declining a healthy row still routes to cleanup (the notice's d
2104
+ // lands here); declining a missing row means "leave it" — don't
2105
+ // immediately re-offer the same removal.
2106
+ onNo: () => { if (!pickMissing) offerSweep(); },
2037
2107
  };
2038
2108
  render();
2039
2109
  return;
package/lib/local.js CHANGED
@@ -539,14 +539,14 @@ async function maybeSetupTelegramGroup(ui, {
539
539
  ui.info(`Bot add link: ${ui.cyan(botLink)}`);
540
540
  openBrowser(botLink);
541
541
 
542
- await ui.prompt("Press Enter here after the bot is in the group");
542
+ await ui.prompt("Press Enter here after the bot is in the group", { hint: "enter continue · ctrl-c quit" });
543
543
  ui.note([
544
544
  "Step 4: In Telegram, copy a group invite link.",
545
545
  "Paste it below so teammate invites include the Telegram group too.",
546
546
  "If you skip this, Egregore will still work, but invites will not include Telegram.",
547
547
  ]);
548
548
 
549
- const groupLink = await ui.prompt("Telegram group join link (paste here, or Enter to skip)");
549
+ const groupLink = await ui.prompt("Telegram group join link (paste here, or Enter to skip)", { hint: "paste the link · enter skip" });
550
550
  const cleaned = groupLink.trim();
551
551
  let storedGroupLink = "";
552
552
  if (cleaned && !looksLikeUrl(cleaned)) {
@@ -725,52 +725,92 @@ async function localFounderFlow(ui, ctx = {}) {
725
725
  return true;
726
726
  };
727
727
 
728
- const stepNameProject = async () => {
729
- ui.section("Name it", "what's this Egregore called?");
730
- if (st.isNewProject) {
731
- const name = await ui.promptOrBack("name your Egregore (team or group name)", { default: st.orgName || st.githubOrg });
732
- if (name === BACK) return BACK;
733
- st.orgName = name;
734
- const desc = await ui.promptOrBack("what are you building? (one line)", { default: st.description || undefined });
735
- if (desc === BACK) return BACK;
736
- st.description = desc;
737
-
738
- // Validate the optional project repo name against the names the
739
- // Egregore/memory repos WILL get (editable later, derived for now).
740
- const predictedRepoName = slugifyName(st.orgName) || "egregore";
741
- const predictedMemoryRepoName = `${predictedRepoName}-memory`;
742
- ui.note([
743
- "Your Egregore gets its own repo (memory + config) in your account.",
744
- "Have a code project too? Egregore can add a repo for it in your account.",
745
- ]);
746
- for (;;) {
747
- const projectName = await ui.promptOrBack("code project repo name (Enter to skip)", { default: st.newProjectRepo || undefined });
748
- if (projectName === BACK) return BACK;
749
- if (!projectName) { st.newProjectRepo = null; break; }
750
- const slug = slugifyName(projectName);
751
- if (!slug) {
752
- ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes.");
753
- continue;
754
- }
755
- if (slug === predictedRepoName) {
756
- ui.warn(`"${slug}" is already the name of your Egregore repo. Pick a different name.`);
757
- ui.info(` ${ui.dim("suggestion:")} ${ui.cyan(`${slug}-app`)}`);
758
- continue;
759
- }
760
- if (slug === predictedMemoryRepoName) {
761
- ui.warn(`"${slug}" collides with your memory repo. Pick a different name.`);
762
- continue;
763
- }
764
- st.newProjectRepo = slug;
765
- break;
766
- }
767
- } else {
768
- const name = await ui.promptOrBack("what should we call this Egregore?", { default: st.orgName || st.githubOrg });
769
- if (name === BACK) return BACK;
770
- st.orgName = name;
728
+ const stepNameManifest = async () => {
729
+ // One live manifest instead of scattered naming prompts: name the thing
730
+ // first, watch the repo plan derive from it in place, override any line.
731
+ // Every collision check runs against names VISIBLE on screen — the old
732
+ // flow rejected a code-repo name for colliding with an Egregore repo
733
+ // name the user had never been shown.
734
+ console.log("");
735
+ const isNew = st.isNewProject;
736
+ const validSlug = (v) => (slugifyName(v)
737
+ ? null
738
+ : "That doesn't produce a valid repo name. Try letters, numbers, and dashes.");
739
+ const fields = [
740
+ {
741
+ key: "orgName",
742
+ label: "Egregore",
743
+ note: "team or group name",
744
+ hint: "type your preferred Egregore name or hit enter to pick the naming below",
745
+ initial: st.orgName || undefined,
746
+ derive: () => st.githubOrg,
747
+ },
748
+ {
749
+ key: "repoName",
750
+ label: "Egregore repo",
751
+ note: "config + skills",
752
+ hint: `enter keeps this name · type to change · created in ${st.githubOrg}`,
753
+ tree: true,
754
+ initial: st.repoName || undefined,
755
+ derive: (v) => slugifyName(v.orgName) || "egregore",
756
+ normalize: (raw) => slugifyName(raw),
757
+ validate: (v) => validSlug(v),
758
+ },
759
+ {
760
+ key: "memoryRepoName",
761
+ label: "memory repo",
762
+ note: "shared memory",
763
+ hint: "enter keeps this name · type to change",
764
+ tree: true,
765
+ initial: st.memoryRepoName || undefined,
766
+ derive: (v) => `${v.repoName || "egregore"}-memory`,
767
+ normalize: (raw) => slugifyName(raw),
768
+ validate: (v, others) => validSlug(v)
769
+ || (v === others.repoName ? "Memory repo can't share the Egregore repo's name." : null),
770
+ },
771
+ ...(isNew ? [{
772
+ key: "codeRepo",
773
+ label: "code repo",
774
+ note: "your project · optional",
775
+ hint: "add a repo for your code project — or enter to skip",
776
+ tree: true,
777
+ optional: true,
778
+ initial: st.newProjectRepo || undefined,
779
+ normalize: (raw) => slugifyName(raw),
780
+ validate: (v, others) => validSlug(v)
781
+ || (v === others.repoName ? `"${v}" is the Egregore repo above. Pick a different name — say, ${v}-app.` : null)
782
+ || (v === others.memoryRepoName ? `"${v}" is the memory repo above. Pick a different name.` : null),
783
+ }] : []),
784
+ ];
785
+ const result = await ui.manifestForm({
786
+ title: "Name it",
787
+ subtitle: `repos land in ${st.githubOrg}`,
788
+ fields,
789
+ });
790
+ if (result === BACK) return BACK;
791
+ const v = result.values;
792
+ st.orgName = v.orgName;
793
+ st.repoName = v.repoName;
794
+ st.memoryRepoName = v.memoryRepoName;
795
+ st.newProjectRepo = isNew ? (v.codeRepo || null) : null;
796
+ return true;
797
+ };
798
+
799
+ // The one-liner is repo metadata, not a naming decision — it lives OUTSIDE
800
+ // the manifest so enter on the name lands directly in the naming tree
801
+ // (the manifest's hint promises exactly that).
802
+ const stepDescription = async () => {
803
+ if (!st.isNewProject) {
771
804
  st.description = "";
772
- st.newProjectRepo = null;
805
+ return SKIP;
773
806
  }
807
+ console.log("");
808
+ const desc = await ui.promptOrBack("what are you building? (one line)", {
809
+ default: st.description || undefined,
810
+ hint: "one line on what you're making · enter to skip · ↑ back",
811
+ });
812
+ if (desc === BACK) return BACK;
813
+ st.description = desc;
774
814
  return true;
775
815
  };
776
816
 
@@ -891,41 +931,12 @@ async function localFounderFlow(ui, ctx = {}) {
891
931
  return true;
892
932
  };
893
933
 
894
- const stepRepoNames = async () => {
895
- // Derived names shown as editable prompts — creating repos with silently
896
- // derived names was a repeated field complaint.
897
- const derivedSlug = slugifyName(st.orgName) || "egregore";
898
- console.log("");
899
- ui.note([
900
- `Two repos will be created in ${ui.bold(st.githubOrg)}: the Egregore repo`,
901
- "(config + skills) and its shared-memory repo. Enter keeps the defaults.",
902
- ]);
903
- for (;;) {
904
- const answer = await ui.promptOrBack("Egregore repo name", { default: st.repoName || derivedSlug });
905
- if (answer === BACK) return BACK;
906
- const candidate = slugifyName(answer);
907
- if (!candidate) { ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes."); continue; }
908
- if (st.newProjectRepo && candidate === st.newProjectRepo) { ui.warn(`"${candidate}" is already your project repo. Pick a different name.`); continue; }
909
- st.repoName = candidate;
910
- break;
911
- }
912
- for (;;) {
913
- const answer = await ui.promptOrBack("memory repo name", { default: st.memoryRepoName || `${st.repoName}-memory` });
914
- if (answer === BACK) return BACK;
915
- const candidate = slugifyName(answer);
916
- if (!candidate) { ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes."); continue; }
917
- if (candidate === st.repoName) { ui.warn("Memory repo can't share the Egregore repo's name."); continue; }
918
- if (st.newProjectRepo && candidate === st.newProjectRepo) { ui.warn(`"${candidate}" is already your project repo. Pick a different name.`); continue; }
919
- st.memoryRepoName = candidate;
920
- break;
921
- }
922
- return true;
923
- };
924
-
925
934
  // Direction-aware driver: BACK rewinds one step; SKIP (deterministic
926
935
  // steps) passes through in whichever direction we're moving, so backing
927
936
  // over an auto-answered step doesn't bounce the user forward again.
928
- const steps = [stepHome, stepWorkType, stepNameProject, stepRepos, stepRepoNames];
937
+ // ALL naming (Egregore + repo plan) happens in the manifest BEFORE the
938
+ // existing-project repo picker — name the thing first, then attach repos.
939
+ const steps = [stepHome, stepWorkType, stepNameManifest, stepDescription, stepRepos];
929
940
  let stepIdx = 0;
930
941
  let stepDir = 1;
931
942
  while (stepIdx < steps.length) {
@@ -1146,7 +1157,10 @@ async function localFounderFlow(ui, ctx = {}) {
1146
1157
 
1147
1158
  // 14. "What should we call you?" + write state
1148
1159
  ui.section("Your name", "this is how teammates will see you in handoffs");
1149
- const displayName = await ui.prompt("what should we call you?", { default: user.login });
1160
+ const displayName = await ui.prompt("what should we call you?", {
1161
+ default: user.login,
1162
+ hint: `type a display name · enter keeps ${user.login}`,
1163
+ });
1150
1164
  const memberRole = await ensureInstallerMemberRole(
1151
1165
  { usage_type: "founder_group", member_role: ctx.memberRole },
1152
1166
  {},
package/lib/ui.js CHANGED
@@ -209,7 +209,19 @@ function spinner(msg) {
209
209
 
210
210
  // ── prompts ─────────────────────────────────────────────────────────
211
211
 
212
- function prompt(question, { default: defaultValue } = {}) {
212
+ // Footer band — the same visual chrome as the launcher's status band:
213
+ // accent background (the banner's color slot), dark text, padded like a
214
+ // bar. Walks with the active prompt in this scrolling wizard; bg 43
215
+ // (yellow) marks errors/questions that need answering. Any ANSI inside
216
+ // the content is stripped — a RESET mid-line would kill the fill.
217
+ function footerBand(content, bg = 46) {
218
+ const plain = String(content).replace(/\x1b\[[0-9;]*m/g, "");
219
+ if (!COLOR) return ` ${plain}`;
220
+ const w = termWidth() - 3;
221
+ return ` ${esc(bg)}${BLACK}${pad(` ${plain}`, w)}${RESET}`;
222
+ }
223
+
224
+ function promptLegacy(question, { default: defaultValue } = {}) {
213
225
  return new Promise((resolve) => {
214
226
  const rl = readline.createInterface({
215
227
  input: process.stdin,
@@ -226,18 +238,36 @@ function prompt(question, { default: defaultValue } = {}) {
226
238
  });
227
239
  }
228
240
 
229
- // Line input that can step backwards: esc resolves BACK, enter resolves the
230
- // typed value (or the default). Raw-mode single-line editor — append +
231
- // backspace only, which covers the short names these prompts collect.
232
- // Falls back to the plain readline prompt when not interactive.
233
- function promptOrBack(question, { default: defaultValue } = {}) {
234
- if (!canInteract()) return prompt(question, { default: defaultValue });
241
+ // Shared single-line editor: the input line plus ONE dim hint line pinned
242
+ // below it — the dynamic footer every wizard prompt shares, instead of
243
+ // per-label "(esc back)" clutter. opts.back controls whether stepping
244
+ // backwards is possible: with back, ↑ resolves BACK (esc is a silent
245
+ // alias); without it, ↑/esc are no-ops. Esc NEVER quits either way —
246
+ // ctrl-c is the only abort. Accepts multi-char printable chunks so paste
247
+ // works (Telegram links, usernames).
248
+ function lineInput(question, { default: defaultValue, hint, back = false } = {}) {
235
249
  return new Promise((resolve) => {
236
250
  let buf = "";
237
251
  const label = defaultValue
238
- ? ` ${CYAN}?${RESET} ${question} ${DIM}[${defaultValue}]${RESET} ${DIM}(esc back)${RESET} `
239
- : ` ${CYAN}?${RESET} ${question} ${DIM}(esc back)${RESET} `;
240
- const render = () => process.stdout.write(`\r\x1b[2K${label}${buf}`);
252
+ ? ` ${CYAN}?${RESET} ${question} ${DIM}[${defaultValue}]${RESET} `
253
+ : ` ${CYAN}?${RESET} ${question} `;
254
+ const hintLine = footerBand(hint || (back ? "enter accept · ↑ back · ctrl-c quit" : "enter accept · ctrl-c quit"));
255
+ const labelCols = visibleLen(label);
256
+ const render = () => {
257
+ // Input line, a blank margin row, then the footer band — the band
258
+ // needs breathing room or it visually fuses with the input. The real
259
+ // cursor parks at the end of the typed text so keystrokes land where
260
+ // the eye is.
261
+ process.stdout.write(
262
+ `\r\x1b[2K${label}${buf}\n` +
263
+ `\x1b[2K\n` +
264
+ `\x1b[2K${hintLine}` +
265
+ `\x1b[2A\x1b[${labelCols + buf.length + 1}G`,
266
+ );
267
+ };
268
+ const clearHint = () => {
269
+ process.stdout.write(`\x1b[1B\r\x1b[2K\x1b[1B\r\x1b[2K\x1b[2A\x1b[${labelCols + buf.length + 1}G`);
270
+ };
241
271
  const cleanup = () => {
242
272
  process.stdin.setRawMode(false);
243
273
  process.stdin.pause();
@@ -247,17 +277,21 @@ function promptOrBack(question, { default: defaultValue } = {}) {
247
277
  if (!key) return;
248
278
  if (key.ctrl && key.name === "c") {
249
279
  cleanup();
280
+ clearHint();
250
281
  process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
251
282
  process.exit(130);
252
283
  }
253
- if (key.name === "escape") {
284
+ if (key.name === "up" || key.name === "escape") {
285
+ if (!back) return; // nothing to go back to — and esc never quits
254
286
  cleanup();
287
+ clearHint();
255
288
  process.stdout.write(`\r\x1b[2K${label}${DIM}← back${RESET}\n`);
256
289
  resolve(BACK);
257
290
  return;
258
291
  }
259
292
  if (key.name === "return") {
260
293
  cleanup();
294
+ clearHint();
261
295
  process.stdout.write("\n");
262
296
  resolve(buf.trim() || (defaultValue ?? ""));
263
297
  return;
@@ -267,9 +301,12 @@ function promptOrBack(question, { default: defaultValue } = {}) {
267
301
  render();
268
302
  return;
269
303
  }
270
- if (_str && _str.length === 1 && !key.ctrl && !key.meta && _str.charCodeAt(0) >= 32) {
271
- buf += _str;
272
- render();
304
+ if (_str && !key.ctrl && !key.meta) {
305
+ const clean = _str.replace(/[\x00-\x1f\x7f]/g, "");
306
+ if (clean) {
307
+ buf += clean;
308
+ render();
309
+ }
273
310
  }
274
311
  };
275
312
  readline.emitKeypressEvents(process.stdin);
@@ -280,9 +317,26 @@ function promptOrBack(question, { default: defaultValue } = {}) {
280
317
  });
281
318
  }
282
319
 
320
+ // Standard prompt — interactive sessions get the hint-line footer, pipes
321
+ // and CI keep the plain readline fallback.
322
+ function prompt(question, opts = {}) {
323
+ if (!canInteract()) return promptLegacy(question, opts);
324
+ return lineInput(question, { ...opts, back: false });
325
+ }
326
+
327
+ // Line input that can step backwards: ↑ resolves BACK (esc is a silent
328
+ // alias), enter resolves the typed value (or the default).
329
+ function promptOrBack(question, opts = {}) {
330
+ if (!canInteract()) return promptLegacy(question, opts);
331
+ return lineInput(question, { ...opts, back: true });
332
+ }
333
+
283
334
  async function confirm(question, defaultYes = false) {
284
- const hint = defaultYes ? `${DIM}[Y/n]${RESET}` : `${DIM}[y/N]${RESET}`;
285
- const answer = await prompt(`${question} ${hint}`);
335
+ const bracket = defaultYes ? `${DIM}[Y/n]${RESET}` : `${DIM}[y/N]${RESET}`;
336
+ const hint = `y yes · n no · enter ${defaultYes ? "yes" : "no"} · ctrl-c quit`;
337
+ const answer = canInteract()
338
+ ? await lineInput(`${question} ${bracket}`, { hint, back: false })
339
+ : await promptLegacy(`${question} ${bracket}`);
286
340
  if (!answer) return defaultYes;
287
341
  return /^y(es)?$/i.test(answer);
288
342
  }
@@ -343,7 +397,7 @@ function renderPicker({ question, filterText, lines, help, input }) {
343
397
  for (const line of lines) out.push(line);
344
398
  if (help) {
345
399
  out.push("");
346
- out.push(` ${DIM}${help}${RESET}`);
400
+ out.push(footerBand(help));
347
401
  }
348
402
  const text = out.join("\n") + "\n";
349
403
  process.stdout.write(text);
@@ -376,10 +430,13 @@ function filterOptions(options, filterText, getText) {
376
430
 
377
431
  // ── back navigation ─────────────────────────────────────────────────
378
432
  //
379
- // Multi-step flows (the founder setup) let the user step backwards with
380
- // esc. Pickers and prompts opt in via `escAction: "back"` — esc then
381
- // resolves to the BACK sentinel instead of aborting the process, and the
382
- // caller's step machine rewinds. Ctrl-C always remains the hard abort.
433
+ // Multi-step flows (the founder setup) let the user step backwards. The
434
+ // advertised keys are spatial — ↑ in text fields/forms, ← in choice lists
435
+ // (enter descends, ← ascends) — with esc as a silent alias everywhere.
436
+ // Esc NEVER quits: users' terminal reflex is that esc ejects them from the
437
+ // CLI, so no esc press may ever reach the shell. Pickers and prompts opt
438
+ // in via `escAction: "back"` — back keys then resolve to the BACK sentinel
439
+ // and the caller's step machine rewinds. Ctrl-C is the only hard abort.
383
440
  const BACK = Symbol.for("egregore.ui.back");
384
441
 
385
442
  // ── choose: interactive single-select picker ────────────────────────
@@ -392,7 +449,8 @@ const BACK = Symbol.for("egregore.ui.back");
392
449
  // enter select
393
450
  // type filter (case-insensitive substring match on label + desc)
394
451
  // backspace pop filter
395
- // esc cancel — or go back with opts.escAction: "back"
452
+ // ← / esc go back with opts.escAction: "back" (esc never quits;
453
+ // a clean-state esc with no back target is a no-op)
396
454
  // ctrl-c abort process
397
455
 
398
456
  async function choose(question, options, hint, opts = {}) {
@@ -457,13 +515,13 @@ async function interactiveChoose(question, options, hint, opts = {}) {
457
515
  for (let i = start; i < end; i++) lines.push(renderRow(visible[i], i));
458
516
  if (end < total) lines.push(` ${DIM}↓ ${total - end} more · type to filter${RESET}`);
459
517
  }
460
- const escHint = opts.escAction === "back" ? `${DIM}esc${RESET} back` : `${DIM}esc${RESET} cancel`;
518
+ const backHint = opts.escAction === "back" ? ` · ${DIM}←${RESET} back` : "";
461
519
  if (!first) clearLines(lastLineCount);
462
520
  lastLineCount = renderPicker({
463
521
  question,
464
522
  filterText,
465
523
  lines,
466
- help: hint || `${DIM}↑↓${RESET} navigate · ${DIM}enter${RESET} select · type to filter · ${escHint}`,
524
+ help: hint || `${DIM}↑↓${RESET} navigate · ${DIM}enter${RESET} select · type to filter${backHint} · ${DIM}ctrl-c${RESET} quit`,
467
525
  });
468
526
  };
469
527
 
@@ -498,16 +556,20 @@ async function interactiveChoose(question, options, hint, opts = {}) {
498
556
  render();
499
557
  return;
500
558
  }
501
- if (key.name === "escape" && opts.escAction === "back") {
559
+ // ← is the advertised back key in choice lists (enter descends, ←
560
+ // ascends); esc is a silent alias. Esc NEVER quits — a clean-state esc
561
+ // with no back target does nothing, ctrl-c is the only abort.
562
+ if ((key.name === "escape" || key.name === "left") && opts.escAction === "back") {
502
563
  goBack();
503
564
  return;
504
565
  }
505
- if ((key.ctrl && key.name === "c") || key.name === "escape") {
566
+ if (key.ctrl && key.name === "c") {
506
567
  cleanup();
507
568
  unparkCursor();
508
569
  process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
509
570
  process.exit(130);
510
571
  }
572
+ if (key.name === "escape") return;
511
573
  if (key.name === "return") {
512
574
  if (visible.length > 0 && !visible[cursor].opt.disabled) finish(visible[cursor].opt);
513
575
  return;
@@ -592,7 +654,7 @@ async function legacyChoose(question, options, hint, opts = {}) {
592
654
  // ctrl-a toggle all (respecting filter)
593
655
  // enter confirm
594
656
  // type filter
595
- // esc confirm with current selection
657
+ // ← / esc go back with opts.escAction: "back" (esc never quits)
596
658
 
597
659
  async function multiSelect(question, options, opts = {}) {
598
660
  // Remote search can populate an empty pool, but only interactively —
@@ -672,7 +734,7 @@ async function interactiveMultiSelect(question, options, opts = {}) {
672
734
  if (searching && total > 0) lines.push(` ${DIM}searching…${RESET}`);
673
735
 
674
736
  const searchHint = opts.remoteSearch ? "type to search" : "type to filter";
675
- const escHint = opts.escAction === "back" ? ` · ${DIM}esc${RESET} back` : "";
737
+ const escHint = opts.escAction === "back" ? ` · ${DIM}←${RESET} back` : "";
676
738
  if (!first) clearLines(lastLineCount);
677
739
  lastLineCount = renderPicker({
678
740
  question,
@@ -758,19 +820,20 @@ async function interactiveMultiSelect(question, options, opts = {}) {
758
820
  render();
759
821
  return;
760
822
  }
761
- if (key.name === "escape" && opts.escAction === "back") {
823
+ if ((key.name === "escape" || key.name === "left") && opts.escAction === "back") {
762
824
  cleanup();
763
825
  clearLines(lastLineCount);
764
826
  process.stdout.write(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET} ${DIM}→ ← back${RESET}\n`);
765
827
  resolve(BACK);
766
828
  return;
767
829
  }
768
- if ((key.ctrl && key.name === "c") || key.name === "escape") {
830
+ if (key.ctrl && key.name === "c") {
769
831
  cleanup();
770
832
  unparkCursor();
771
833
  process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
772
834
  process.exit(130);
773
835
  }
836
+ if (key.name === "escape") return; // esc never quits — ctrl-c aborts
774
837
  if (key.name === "return") return finish();
775
838
  if (key.name === "up" || (key.ctrl && key.name === "p")) {
776
839
  cursor = (cursor - 1 + visible.length) % Math.max(1, visible.length);
@@ -853,6 +916,286 @@ async function legacyMultiSelect(question, options) {
853
916
  return [...picked];
854
917
  }
855
918
 
919
+ // ── manifestForm: live-manifest form ────────────────────────────────
920
+ //
921
+ // A vertical form rendered as one visible block — the "what gets created"
922
+ // plan IS the UI. One field is active at a time; derived fields (repo
923
+ // names from the Egregore name) fill in live as earlier answers are typed,
924
+ // so the plan builds itself in front of the user. "Back" is cursor
925
+ // movement, not a hidden step-rewind: ↑ moves to the previous field, and
926
+ // at the first field resolves BACK to the caller's step machine.
927
+ //
928
+ // fields: [{
929
+ // key value key in the resolved object
930
+ // label row label (left column)
931
+ // note dim annotation (right column, e.g. "config + skills")
932
+ // hint header guidance shown while this field is active
933
+ // (falls back to the form-level subtitle)
934
+ // initial previous answer (revisit) — kept only while it differs
935
+ // from the live derive, so edits keep cascading
936
+ // derive(vals) live default computed from earlier fields
937
+ // normalize(raw) canonicalize input (e.g. slugify) before validate
938
+ // validate(v, vals) return an error string to reject, null to accept
939
+ // optional enter on empty = skip (value "")
940
+ // tree render with ├─/└─ branch glyphs (the repo plan rows)
941
+ // }]
942
+ //
943
+ // keys: enter accept+next (submit on last) · ↑ previous field (BACK at the
944
+ // first) · ↓ next · type/backspace edit · esc = silent alias of ↑ (esc
945
+ // never quits) · ctrl-c abort
946
+ //
947
+ // resolves { values } or BACK.
948
+ async function manifestForm({ title, subtitle, fields }) {
949
+ if (!canInteract()) {
950
+ // Non-interactive fallback: plain sequential prompts, same validation.
951
+ const values = {};
952
+ for (const f of fields) {
953
+ const def = (f.initial != null && f.initial !== "") ? f.initial : (f.derive ? f.derive(values) : "");
954
+ for (;;) {
955
+ const raw = await prompt(f.label, { default: def || undefined });
956
+ const val = f.normalize ? f.normalize(raw) : String(raw || "").trim();
957
+ if (!val && f.optional) { values[f.key] = ""; break; }
958
+ const err = f.validate ? f.validate(val, values) : null;
959
+ if (err) { warn(err); continue; }
960
+ values[f.key] = val;
961
+ break;
962
+ }
963
+ }
964
+ return { values };
965
+ }
966
+
967
+ return new Promise((resolve) => {
968
+ // entered[key] is set only when the user TYPED a value — accepting a
969
+ // derived ghost with enter leaves the field derived, so going back up
970
+ // and changing the name re-derives everything the user didn't override.
971
+ const entered = {};
972
+ let active = 0;
973
+ let buf = null; // null = untouched → the ghost (derive/initial) shows
974
+ let error = null;
975
+ let confirmed = -1; // highest field index confirmed with enter
976
+ let lastRows = 0;
977
+
978
+ for (let i = 0; i < fields.length; i++) {
979
+ const f = fields[i];
980
+ if (f.initial != null && f.initial !== "") entered[f.key] = f.initial;
981
+ }
982
+ // Revisit case: initial values that match what derivation would produce
983
+ // are dropped back to derived so the cascade stays live.
984
+ {
985
+ const vals = {};
986
+ for (const f of fields) {
987
+ const derived = f.derive ? f.derive(vals) : "";
988
+ if (entered[f.key] != null && entered[f.key] === derived) delete entered[f.key];
989
+ vals[f.key] = entered[f.key] != null ? entered[f.key] : derived;
990
+ }
991
+ if (Object.keys(entered).length > 0) confirmed = fields.length - 1;
992
+ }
993
+
994
+ // Resolve every field's current value in order; the active field's
995
+ // in-progress buffer feeds later derives so typing the name updates the
996
+ // repo rows on every keystroke.
997
+ const computeVals = () => {
998
+ const vals = {};
999
+ for (let i = 0; i < fields.length; i++) {
1000
+ const f = fields[i];
1001
+ let v;
1002
+ if (i === active && buf != null) v = f.normalize ? f.normalize(buf) : buf.trim();
1003
+ else if (entered[f.key] != null) v = entered[f.key];
1004
+ else v = f.derive ? f.derive(vals) : "";
1005
+ vals[f.key] = v || "";
1006
+ }
1007
+ return vals;
1008
+ };
1009
+
1010
+ const ghostFor = (i, vals) => {
1011
+ const f = fields[i];
1012
+ if (entered[f.key] != null) return entered[f.key];
1013
+ return f.derive ? f.derive(vals) : "";
1014
+ };
1015
+
1016
+ const labelW = Math.max(...fields.map((f) => f.label.length + (f.tree ? 3 : 0)));
1017
+ const lastTreeIdx = fields.reduce((acc, f, i) => (f.tree ? i : acc), -1);
1018
+
1019
+ const rowLine = (i, vals) => {
1020
+ const f = fields[i];
1021
+ const glyph = f.tree ? (i === lastTreeIdx ? "└─ " : "├─ ") : "";
1022
+ const labelText = pad(`${glyph}${f.label}`, labelW);
1023
+ const isActive = i === active;
1024
+ const isDone = i <= confirmed && !isActive;
1025
+ const mark = isActive ? `${CYAN}❯${RESET}` : (isDone ? `${GREEN}✓${RESET}` : " ");
1026
+ const note = f.note ? ` ${DIM}${f.note}${RESET}` : "";
1027
+ let value;
1028
+ if (isActive) {
1029
+ // No drawn caret — the REAL terminal cursor is parked here after
1030
+ // render, so it blinks where keystrokes land.
1031
+ if (buf != null) {
1032
+ value = buf;
1033
+ } else {
1034
+ const ghost = ghostFor(i, vals);
1035
+ value = ghost
1036
+ ? `${DIM}${ghost}${RESET}`
1037
+ : (f.optional ? `${DIM}(enter to skip)${RESET}` : "");
1038
+ }
1039
+ } else {
1040
+ const v = vals[f.key];
1041
+ if (!v) value = `${DIM}${f.optional && isDone ? "— skipped" : "·"}${RESET}`;
1042
+ else if (entered[f.key] != null || isDone) value = `${BOLD}${v}${RESET}`;
1043
+ else value = `${DIM}${v}${RESET}`;
1044
+ }
1045
+ const valueW = 26;
1046
+ return ` ${mark} ${DIM}${labelText}${RESET} ${pad(value, valueW)}${note}`;
1047
+ };
1048
+
1049
+ const hintLine = () => {
1050
+ const backTarget = active === 0 ? "back" : "previous";
1051
+ return footerBand(`enter ${active === fields.length - 1 ? "finish" : "next"} · ↑ ${backTarget} · type to edit · ctrl-c quit`);
1052
+ };
1053
+
1054
+ const render = (first = false) => {
1055
+ const vals = computeVals();
1056
+ const lines = [];
1057
+ // Header guidance is contextual: each field can carry its own hint
1058
+ // ("type your preferred Egregore name or hit enter…"), so the line
1059
+ // always says what to do RIGHT NOW instead of describing the form.
1060
+ const guidance = fields[active].hint || subtitle;
1061
+ lines.push(` ${CYAN}◆${RESET} ${BOLD}${title}${RESET}${guidance ? ` ${DIM}${guidance}${RESET}` : ""}`);
1062
+ lines.push("");
1063
+ for (let i = 0; i < fields.length; i++) lines.push(rowLine(i, vals));
1064
+ lines.push("");
1065
+ lines.push(error ? footerBand(`! ${error}`, 43) : hintLine());
1066
+ if (!first) clearLines(lastRows);
1067
+ const width = process.stdout.columns || 80;
1068
+ const text = lines.join("\n") + "\n";
1069
+ process.stdout.write(CURSOR_HIDE + text);
1070
+ lastRows = lines.reduce((sum, line) => sum + visualRows(line, width), 0);
1071
+ // Park the REAL terminal cursor at the active field's edit point so it
1072
+ // blinks there — the strongest "type here" affordance a terminal has.
1073
+ // Row math is visual-row aware (the header hint may wrap); the active
1074
+ // field row itself is short and assumed unwrapped.
1075
+ const activeLine = 2 + active;
1076
+ let rowsUp = 1; // the trailing newline after the block
1077
+ for (let i = activeLine + 1; i < lines.length; i++) rowsUp += visualRows(lines[i], width);
1078
+ const col = labelW + 9 + (buf != null ? visibleLen(buf) : 0);
1079
+ process.stdout.write(`\x1b[${rowsUp}A\x1b[${col}G${CURSOR_SHOW}`);
1080
+ _parkedRows = rowsUp; // clearLines() unparks before rewinding
1081
+ };
1082
+
1083
+ const cleanup = () => {
1084
+ process.stdin.setRawMode(false);
1085
+ process.stdin.pause();
1086
+ process.stdin.removeListener("keypress", onKey);
1087
+ process.stdout.write(CURSOR_SHOW);
1088
+ };
1089
+
1090
+ // Transcript receipt: the final plan stays in scrollback as a static
1091
+ // tree, so the record of what was named survives the redraw region.
1092
+ const receipt = (vals) => {
1093
+ const lines = [` ${CYAN}◆${RESET} ${BOLD}${title}${RESET}`];
1094
+ for (let i = 0; i < fields.length; i++) {
1095
+ const f = fields[i];
1096
+ if (!vals[f.key]) continue;
1097
+ const glyph = f.tree ? (i === lastTreeIdx ? "└─ " : "├─ ") : "";
1098
+ const note = f.note ? ` ${DIM}${f.note}${RESET}` : "";
1099
+ lines.push(` ${DIM}${pad(`${glyph}${f.label}`, labelW)}${RESET} ${pad(`${BOLD}${vals[f.key]}${RESET}`, 26)}${note}`);
1100
+ }
1101
+ return lines.join("\n") + "\n";
1102
+ };
1103
+
1104
+ const commitActive = () => {
1105
+ const f = fields[active];
1106
+ const vals = computeVals();
1107
+ const typed = buf != null && buf.trim() !== "";
1108
+ const value = vals[f.key];
1109
+ if (!value && !f.optional) {
1110
+ error = `${f.label} can't be empty.`;
1111
+ return false;
1112
+ }
1113
+ if (value && f.validate) {
1114
+ // Validate against the other fields' values, not our own in-progress one.
1115
+ const others = { ...vals };
1116
+ delete others[f.key];
1117
+ const err = f.validate(value, others);
1118
+ if (err) { error = err; return false; }
1119
+ }
1120
+ if (typed) entered[f.key] = value;
1121
+ else if (buf != null && buf.trim() === "" && f.optional) entered[f.key] = "";
1122
+ error = null;
1123
+ confirmed = Math.max(confirmed, active);
1124
+ return true;
1125
+ };
1126
+
1127
+ const onKey = (_str, key) => {
1128
+ if (!key) return;
1129
+ if (key.ctrl && key.name === "c") {
1130
+ cleanup();
1131
+ clearLines(lastRows);
1132
+ process.stdout.write(` ${DIM}(cancelled)${RESET}\n`);
1133
+ process.exit(130);
1134
+ }
1135
+ // ↑ = previous field; at the first field it leaves the form (BACK to
1136
+ // the previous wizard step). Esc is a silent alias — it never quits.
1137
+ if (key.name === "up" || key.name === "escape") {
1138
+ error = null;
1139
+ if (active === 0) {
1140
+ cleanup();
1141
+ clearLines(lastRows);
1142
+ process.stdout.write(` ${CYAN}◆${RESET} ${BOLD}${title}${RESET} ${DIM}→ ← back${RESET}\n`);
1143
+ resolve(BACK);
1144
+ return;
1145
+ }
1146
+ buf = null;
1147
+ active -= 1;
1148
+ render();
1149
+ return;
1150
+ }
1151
+ if (key.name === "return" || key.name === "down") {
1152
+ if (key.name === "down" && active === fields.length - 1) return;
1153
+ if (!commitActive()) { render(); return; }
1154
+ if (active === fields.length - 1) {
1155
+ const vals = computeVals();
1156
+ cleanup();
1157
+ clearLines(lastRows);
1158
+ process.stdout.write(receipt(vals));
1159
+ resolve({ values: vals });
1160
+ return;
1161
+ }
1162
+ buf = null;
1163
+ active += 1;
1164
+ render();
1165
+ return;
1166
+ }
1167
+ if (key.name === "backspace") {
1168
+ // Backspace on an untouched field materializes the ghost for editing.
1169
+ if (buf == null) {
1170
+ const vals = computeVals();
1171
+ buf = ghostFor(active, vals).slice(0, -1);
1172
+ } else {
1173
+ buf = buf.slice(0, -1);
1174
+ }
1175
+ // Deleting everything un-touches the field — the derived suggestion
1176
+ // returns (and downstream derives recover) instead of leaving the
1177
+ // row stuck empty.
1178
+ if (buf === "") buf = null;
1179
+ error = null;
1180
+ render();
1181
+ return;
1182
+ }
1183
+ if (_str && _str.length === 1 && !key.ctrl && !key.meta && _str.charCodeAt(0) >= 32) {
1184
+ buf = (buf == null ? "" : buf) + _str;
1185
+ error = null;
1186
+ render();
1187
+ }
1188
+ };
1189
+
1190
+ readline.emitKeypressEvents(process.stdin);
1191
+ process.stdin.setRawMode(true);
1192
+ process.stdin.resume();
1193
+ process.stdin.on("keypress", onKey);
1194
+ process.stdout.write(CURSOR_HIDE);
1195
+ render(true);
1196
+ });
1197
+ }
1198
+
856
1199
  // ── commandBox ──────────────────────────────────────────────────────
857
1200
  //
858
1201
  // Preserved for back-compat; card() is the newer more-flexible version.
@@ -1113,7 +1456,7 @@ module.exports = {
1113
1456
  banner, bannerCompact, section, note, card,
1114
1457
  info, success, warn, error, step,
1115
1458
  dim, bold, cyan, green, yellow, red,
1116
- spinner, prompt, promptOrBack, choose, multiSelect, confirm, canInteract, BACK,
1459
+ spinner, prompt, promptOrBack, manifestForm, choose, multiSelect, confirm, canInteract, BACK,
1117
1460
  commandBox,
1118
1461
  canShowScreens, welcomeScreen, summaryScreen,
1119
1462
  _renderWelcomeBody, _readPkgVersion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.18.18",
3
+ "version": "0.19.0",
4
4
  "description": "Set up Egregore for your team in one command — installs the egregore launcher and your choice of Claude Code or Codex.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "scripts": {
13
13
  "build:runtime": "node scripts/build-runtime-bundle.mjs",
14
- "test": "node scripts/build-runtime-bundle.mjs --check && node --test test/context.test.js test/parse-join-target.test.js test/skip-repos.test.js test/onboarding-shortcircuit.test.js test/welcome-screen.test.js test/runtime-selection.test.js test/command-generation.test.js test/auth-gh.test.js test/gauntlet-fixes.test.js test/codex-runtime.test.js test/installer-runtime-source.test.js test/scroll-repro.test.js test/launcher-refocus-redraw.test.js test/multiselect-remote-search.test.js test/back-navigation.test.js",
14
+ "test": "node scripts/build-runtime-bundle.mjs --check && node --test test/context.test.js test/parse-join-target.test.js test/skip-repos.test.js test/onboarding-shortcircuit.test.js test/welcome-screen.test.js test/runtime-selection.test.js test/command-generation.test.js test/auth-gh.test.js test/gauntlet-fixes.test.js test/codex-runtime.test.js test/installer-runtime-source.test.js test/scroll-repro.test.js test/launcher-refocus-redraw.test.js test/multiselect-remote-search.test.js test/back-navigation.test.js test/manifest-form.test.js test/launcher-delete.test.js",
15
15
  "test:e2e": "node --test test/e2e-local.test.js",
16
16
  "test:all": "node scripts/build-runtime-bundle.mjs --check && node --test test/"
17
17
  },
@@ -11,7 +11,7 @@
11
11
  {
12
12
  "section": "Identity & Upstream",
13
13
  "action": "keep",
14
- "sourceHash": "4acbf9bc2866",
14
+ "sourceHash": "11d07e3fcb58",
15
15
  "note": "Kept; /update, /contribute, /save tokens rendered as $skill tokens."
16
16
  },
17
17
  {
@@ -62,6 +62,12 @@
62
62
  "sourceHash": "d75dc9f6d88c",
63
63
  "note": "Kept verbatim."
64
64
  },
65
+ {
66
+ "section": "Loom Routing",
67
+ "action": "replace",
68
+ "sourceHash": "b6a6ca2bc2b7",
69
+ "note": "Loom delegation rides the Claude Code Agent tool; Codex has no subagent delegation — commands run inline. Preambles and bin/loom.sh calls marked skippable."
70
+ },
65
71
  {
66
72
  "section": "Git Workflow",
67
73
  "action": "keep",
@@ -63,7 +63,7 @@ This block is the Codex-native Egregore behavioral spec, rendered from CLAUDE.md
63
63
 
64
64
  ## Identity & Upstream
65
65
 
66
- This is an Egregore instance — a downstream fork of the upstream framework at `egregore-labs/egregore`. The framework (`bin/`, `.claude/skills/`, `.claude/hooks/`, `.claude/context/`, `CLAUDE.md`, `skills/`) is synced from upstream on every session start and via `$update`. It is not authored in this repo.
66
+ This is an Egregore instance — a downstream fork of the upstream framework at `egregore-labs/egregore`. The framework (`bin/`, `.claude/skills/`, `.claude/hooks/`, `.claude/context/`, `.claude/agents/`, `loom/`, `CLAUDE.md`, `skills/`) is synced from upstream on every session start and via `$update`. It is not authored in this repo.
67
67
 
68
68
  **Where changes belong:**
69
69
  - **Framework is outdated or a skill is missing:** run `$update` first. It pulls the latest from upstream.
@@ -198,6 +198,12 @@ Always use HTTPS for git operations — `github-auth.sh` handles credential stor
198
198
 
199
199
  ---
200
200
 
201
+ ## Loom Routing
202
+
203
+ Loom routes commands across model tiers on the Claude Code runtime (`loom/routes.json` + `bin/loom.sh` + a model-pinned executor subagent). Codex has no subagent delegation — every command runs inline in the current session. Ignore "Loom routing" preambles if you encounter them in a skill spec, and skip `bin/loom.sh` calls; `loom/` and `.claude/agents/` are framework files synced for the Claude runtime.
204
+
205
+ ---
206
+
201
207
  ## Git Workflow
202
208
 
203
209
  `develop` branch model with deferred, topic-based branching. Users never interact with git directly.
@@ -224,6 +224,8 @@ Codex has no structured question tool — render each batch as compact numbered
224
224
 
225
225
  **Rules:** Max 4 questions per batch. When choices aren't mutually exclusive, say "pick any that apply".`;
226
226
 
227
+ const LOOM_ROUTING_BODY = `Loom routes commands across model tiers on the Claude Code runtime (\`loom/routes.json\` + \`bin/loom.sh\` + a model-pinned executor subagent). Codex has no subagent delegation — every command runs inline in the current session. Ignore "Loom routing" preambles if you encounter them in a skill spec, and skip \`bin/loom.sh\` calls; \`loom/\` and \`.claude/agents/\` are framework files synced for the Claude runtime.`;
228
+
227
229
  const ISOLATION_BODY = `Sessions are confined to this project + memory + managed repos. On Codex this boundary is a standing instruction, not an enforced hook — hold it yourself.
228
230
 
229
231
  - **Never modify \`~/.egregore/instances.json\`** — managed by session-start.sh
@@ -266,6 +268,11 @@ const RULES = {
266
268
  'Onboarding': { action: 'keep', note: 'Kept; /onboarding rendered as $onboarding.' },
267
269
  'Transparency Beat': { action: 'keep', note: 'Kept verbatim.' },
268
270
  'Memory': { action: 'keep', note: 'Kept verbatim.' },
271
+ 'Loom Routing': {
272
+ action: 'replace',
273
+ body: LOOM_ROUTING_BODY,
274
+ note: 'Loom delegation rides the Claude Code Agent tool; Codex has no subagent delegation — commands run inline. Preambles and bin/loom.sh calls marked skippable.',
275
+ },
269
276
  'Git Workflow': {
270
277
  action: 'keep',
271
278
  note: 'Kept; slash tokens rendered as $skill tokens. Branch diagram unchanged (code fence).',
@@ -72,12 +72,12 @@ _apply_framework_update() {
72
72
  fi
73
73
  # Apply upstream changes — checkout is idempotent, skip diff check
74
74
  # (git diff with variable path lists breaks in zsh — no word-splitting)
75
- for _p in bin/ .claude/commands/ .claude/skills/ .claude/hooks/ .claude/context/ CLAUDE.md skills/; do
75
+ for _p in bin/ .claude/commands/ .claude/skills/ .claude/hooks/ .claude/context/ .claude/agents/ loom/ CLAUDE.md skills/; do
76
76
  git checkout upstream/main -- "$_p" 2>/dev/null || true
77
77
  done
78
78
  # Only commit if there are actual changes
79
- if [ -n "$(git status --porcelain bin/ .claude/ CLAUDE.md skills/ 2>/dev/null | head -1)" ]; then
80
- git add bin/ .claude/ CLAUDE.md skills/ 2>/dev/null
79
+ if [ -n "$(git status --porcelain bin/ .claude/ loom/ CLAUDE.md skills/ 2>/dev/null | head -1)" ]; then
80
+ git add bin/ .claude/ loom/ CLAUDE.md skills/ 2>/dev/null
81
81
  EGREGORE_FRAMEWORK_UPDATE=1 git commit -m "Auto-update Egregore framework" --quiet 2>/dev/null || true
82
82
  FRAMEWORK_UPDATED="true"
83
83
  fi