create-egregore 0.18.17 → 0.18.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/local.js CHANGED
@@ -653,105 +653,138 @@ async function localFounderFlow(ui, ctx = {}) {
653
653
  // 2. Get user info
654
654
  const user = await getUser(githubToken);
655
655
 
656
- // 3. Where — we always have a gh token (the only local auth path), which
657
- // carries org read, so we can list the user's accounts and offer a picker.
658
- ui.section("Pick a home", "where should this Egregore live?");
659
- const s = ui.spinner("Checking your GitHub accounts...");
660
- const orgs = await listOrgs(githubToken);
661
- s.stop("Accounts loaded");
662
-
663
- let githubOrg, isOrg;
664
- if (orgs.length === 0) {
665
- githubOrg = user.login;
666
- isOrg = false;
667
- ui.info(`Using your personal account: ${ui.bold(githubOrg)}`);
668
- } else {
669
- const orgChoices = [{ label: user.login, description: "Personal account" }];
670
- for (const o of orgs) {
671
- orgChoices.push({ label: o.login, description: "Organization" });
656
+ // 3–6. Home → new/existing → naming → repo picker → repo names, as a
657
+ // step machine so esc walks BACKWARDS through the flow (field request:
658
+ // a wrong org pick shouldn't mean Ctrl-C and start over). Every answer
659
+ // lives in `st`, so a revisited step re-offers the previous choice.
660
+ // Creation (step 7 below) is the point of no return — no back from there.
661
+ const slugifyName = (s) => (s || "")
662
+ .toLowerCase()
663
+ .replace(/[^a-z0-9-]/g, "-")
664
+ .replace(/-+/g, "-")
665
+ .replace(/^-|-$/g, "");
666
+ const BACK = ui.BACK;
667
+ const SKIP = Symbol("step-skip"); // deterministic step — passes through in both directions
668
+
669
+ const st = {
670
+ orgs: null, // fetched once
671
+ githubOrg: null, isOrg: false,
672
+ isNewProject: null,
673
+ orgName: null, description: "", newProjectRepo: null,
674
+ selectedRepoNames: null, // raw picker selection (preserved across back)
675
+ selectedRepos: [], // enriched {name, base_branch}
676
+ repoName: null, memoryRepoName: null,
677
+ reposCache: null, // {org, repos, teamRepoCount}
678
+ };
679
+
680
+ const stepHome = async () => {
681
+ if (!st.orgs) {
682
+ ui.section("Pick a home", "where should this Egregore live?");
683
+ const s = ui.spinner("Checking your GitHub accounts...");
684
+ st.orgs = await listOrgs(githubToken);
685
+ s.stop("Accounts loaded");
686
+ }
687
+ if (st.orgs.length === 0) {
688
+ if (!st.githubOrg) {
689
+ st.githubOrg = user.login;
690
+ st.isOrg = false;
691
+ ui.info(`Using your personal account: ${ui.bold(st.githubOrg)}`);
692
+ }
693
+ return SKIP;
672
694
  }
673
695
  const orgChoice = await ui.choose(
674
696
  "Where should Egregore live?",
675
- orgChoices,
697
+ [
698
+ { label: user.login, description: "Personal account" },
699
+ ...st.orgs.map((o) => ({ label: o.login, description: "Organization" })),
700
+ ],
676
701
  "Don't see your org? Authorize it for your account on GitHub, then re-run.",
702
+ { escAction: "back" },
677
703
  );
678
- githubOrg = orgChoice.label;
679
- isOrg = githubOrg !== user.login;
680
- }
704
+ if (orgChoice === BACK) return BACK; // first step — driver clamps to a re-ask
705
+ st.githubOrg = orgChoice.label;
706
+ st.isOrg = st.githubOrg !== user.login;
707
+ return true;
708
+ };
681
709
 
682
- // 4. What — new or existing?
683
- // If the user came in via the in-repo detection and chose "install
684
- // alongside", they already told us they have an existing project — don't
685
- // re-ask. Go straight to the existing-project path.
686
- let isNewProject;
687
- if (ctx.prefilledRepo) {
688
- isNewProject = false;
689
- ui.info(`${ui.green("→")} existing project — we'll manage ${ui.cyan(ctx.prefilledRepo.localName)} plus any others you pick.`);
690
- } else {
710
+ const stepWorkType = async () => {
711
+ // In-repo detection already answered this — pass through both ways.
712
+ if (ctx.prefilledRepo) {
713
+ if (st.isNewProject === null) {
714
+ st.isNewProject = false;
715
+ ui.info(`${ui.green("→")} existing project — we'll manage ${ui.cyan(ctx.prefilledRepo.localName)} plus any others you pick.`);
716
+ }
717
+ return SKIP;
718
+ }
691
719
  const action = await ui.choose("What are you working on?", [
692
720
  { label: "Starting something new", description: "Create a project from scratch" },
693
721
  { label: "Existing project", description: "Connect repos I already have" },
694
- ]);
695
- isNewProject = action.label.startsWith("Starting");
696
- }
697
-
698
- // 5. Collect project info
699
- ui.section("Name it", "what's this Egregore called?");
700
- let orgName, description, selectedRepos = [], newProjectRepo = null;
701
-
702
- if (isNewProject) {
703
- orgName = await ui.prompt("name your Egregore (team or group name)", { default: githubOrg });
704
- description = await ui.prompt("what are you building? (one line)");
705
-
706
- // Derive the egregore + memory repo names now so we can validate the
707
- // optional project repo name against them before the user commits to it.
708
- // Matches the final derivation at line ~395 below.
709
- const slugifyRepo = (s) => s.toLowerCase()
710
- .replace(/[^a-z0-9-]/g, "-")
711
- .replace(/-+/g, "-")
712
- .replace(/^-|-$/g, "") || "egregore";
713
- const predictedRepoName = slugifyRepo(orgName);
714
- const predictedMemoryRepoName = `${predictedRepoName}-memory`;
715
-
716
- // Ask about project repo
717
- ui.note([
718
- "Your Egregore gets its own repo (memory + config) in your account.",
719
- "Have a code project too? Egregore can add a repo for it in your account.",
720
- ]);
722
+ ], undefined, { escAction: "back" });
723
+ if (action === BACK) return BACK;
724
+ st.isNewProject = action.label.startsWith("Starting");
725
+ return true;
726
+ };
721
727
 
722
- while (true) {
723
- const projectName = await ui.prompt("code project repo name (Enter to skip)");
724
- if (!projectName) {
725
- newProjectRepo = null;
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;
726
765
  break;
727
766
  }
728
- const slug = slugifyRepo(projectName);
729
- if (!slug) {
730
- ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes.");
731
- continue;
732
- }
733
- if (slug === predictedRepoName) {
734
- ui.warn(`"${slug}" is already the name of your Egregore repo. Pick a different name.`);
735
- ui.info(` ${ui.dim("suggestion:")} ${ui.cyan(`${slug}-app`)}`);
736
- continue;
737
- }
738
- if (slug === predictedMemoryRepoName) {
739
- ui.warn(`"${slug}" collides with your memory repo. Pick a different name.`);
740
- continue;
741
- }
742
- newProjectRepo = slug;
743
- break;
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;
771
+ st.description = "";
772
+ st.newProjectRepo = null;
744
773
  }
745
- } else {
746
- // "Existing project"
747
- orgName = await ui.prompt("what should we call this Egregore?", { default: githubOrg });
748
- description = "";
774
+ return true;
775
+ };
749
776
 
777
+ const stepRepos = async () => {
778
+ if (st.isNewProject) {
779
+ st.selectedRepoNames = null;
780
+ st.selectedRepos = [];
781
+ return SKIP;
782
+ }
750
783
  // Auto-adopt the user's current repo if it's under the chosen GitHub owner.
751
784
  const autoAdopt =
752
785
  ctx.prefilledRepo &&
753
786
  ctx.prefilledRepo.owner &&
754
- ctx.prefilledRepo.owner.toLowerCase() === githubOrg.toLowerCase()
787
+ ctx.prefilledRepo.owner.toLowerCase() === st.githubOrg.toLowerCase()
755
788
  ? ctx.prefilledRepo.name
756
789
  : null;
757
790
 
@@ -759,47 +792,58 @@ async function localFounderFlow(ui, ctx = {}) {
759
792
  // 500 most recently updated repos, which buries a team's repos in a big
760
793
  // org — so the user's team repos are loaded too and boosted to the top,
761
794
  // and typing in the picker live-searches the WHOLE org (see
762
- // searchOrgRepos), so no repo is unreachable.
763
- const s0 = ui.spinner("Loading repos...");
764
- let repos = [];
765
- let teamRepoCount = 0;
766
- try {
767
- const [orgRepos, teams] = await Promise.all([
768
- listOrgRepos(githubToken, githubOrg, isOrg),
769
- isOrg ? listUserTeams(githubToken, githubOrg) : Promise.resolve([]),
770
- ]);
771
- const teamRepoLists = await Promise.all(
772
- teams.map((t) => listTeamRepos(githubToken, githubOrg, t.slug).catch(() => [])),
773
- );
774
- const seen = new Set();
775
- for (const list of teamRepoLists) {
776
- for (const r of list) {
795
+ // searchOrgRepos), so no repo is unreachable. The load is cached per
796
+ // owner so back-navigation doesn't refetch.
797
+ if (!st.reposCache || st.reposCache.org !== st.githubOrg) {
798
+ const s0 = ui.spinner("Loading repos...");
799
+ const repos = [];
800
+ let teamRepoCount = 0;
801
+ try {
802
+ const [orgRepos, teams] = await Promise.all([
803
+ listOrgRepos(githubToken, st.githubOrg, st.isOrg),
804
+ st.isOrg ? listUserTeams(githubToken, st.githubOrg) : Promise.resolve([]),
805
+ ]);
806
+ const teamRepoLists = await Promise.all(
807
+ teams.map((t) => listTeamRepos(githubToken, st.githubOrg, t.slug).catch(() => [])),
808
+ );
809
+ const seen = new Set();
810
+ for (const list of teamRepoLists) {
811
+ for (const r of list) {
812
+ if (seen.has(r.name)) continue;
813
+ seen.add(r.name);
814
+ repos.push(r);
815
+ }
816
+ }
817
+ teamRepoCount = repos.length;
818
+ for (const r of orgRepos) {
777
819
  if (seen.has(r.name)) continue;
778
820
  seen.add(r.name);
779
821
  repos.push(r);
780
822
  }
823
+ s0.stop(teamRepoCount > 0 ? `Loaded repos — your teams' ${teamRepoCount} first` : "Loaded repos");
824
+ } catch {
825
+ s0.stop("Could not load repos");
781
826
  }
782
- teamRepoCount = repos.length;
783
- for (const r of orgRepos) {
784
- if (seen.has(r.name)) continue;
785
- seen.add(r.name);
786
- repos.push(r);
787
- }
788
- s0.stop(teamRepoCount > 0 ? `Loaded repos — your teams' ${teamRepoCount} first` : "Loaded repos");
789
- } catch {
790
- s0.stop("Could not load repos");
827
+ st.reposCache = { org: st.githubOrg, repos, teamRepoCount };
791
828
  }
829
+ const repos = st.reposCache.repos;
792
830
 
793
831
  const filtered = autoAdopt ? repos.filter((r) => r.name !== autoAdopt) : repos;
794
832
  if (autoAdopt) {
795
833
  console.log("");
796
834
  ui.info(`${ui.cyan(autoAdopt)} will be managed automatically (your current repo)`);
797
835
  }
798
- selectedRepos = await ui.multiSelect(
836
+ const picked = await ui.multiSelect(
799
837
  "Which other repos should Egregore manage?",
800
838
  filtered,
801
- { remoteSearch: (term) => searchOrgRepos(githubToken, githubOrg, isOrg, term) },
839
+ {
840
+ remoteSearch: (term) => searchOrgRepos(githubToken, st.githubOrg, st.isOrg, term),
841
+ preselected: st.selectedRepoNames || [],
842
+ escAction: "back",
843
+ },
802
844
  );
845
+ if (picked === BACK) return BACK;
846
+ let selectedRepos = picked;
803
847
 
804
848
  // Non-interactive terminals get no live search — keep a typed-names
805
849
  // escape hatch there, validated so a typo doesn't become a ghost repo.
@@ -808,11 +852,11 @@ async function localFounderFlow(ui, ctx = {}) {
808
852
  for (const raw of (extraRaw || "").split(",")) {
809
853
  const name = raw.trim();
810
854
  if (!name || selectedRepos.includes(name)) continue;
811
- if (await repoExists(githubToken, githubOrg, name)) {
855
+ if (await repoExists(githubToken, st.githubOrg, name)) {
812
856
  selectedRepos.push(name);
813
857
  ui.success(`Added ${name}`);
814
858
  } else {
815
- ui.warn(`No repo ${githubOrg}/${name} — skipping`);
859
+ ui.warn(`No repo ${st.githubOrg}/${name} — skipping`);
816
860
  }
817
861
  }
818
862
  }
@@ -820,6 +864,7 @@ async function localFounderFlow(ui, ctx = {}) {
820
864
  selectedRepos.unshift(autoAdopt);
821
865
  }
822
866
  if (autoAdopt) skipRepos.add(autoAdopt);
867
+ st.selectedRepoNames = [...selectedRepos];
823
868
 
824
869
  // Detect default branch for each selected repo and convert to {name, base_branch} format
825
870
  if (selectedRepos.length > 0) {
@@ -827,14 +872,13 @@ async function localFounderFlow(ui, ctx = {}) {
827
872
  const reposWithBranch = [];
828
873
  for (const repoName of selectedRepos) {
829
874
  try {
830
- const info = await detectRepoBranches(githubToken, githubOrg, repoName);
875
+ const info = await detectRepoBranches(githubToken, st.githubOrg, repoName);
831
876
  reposWithBranch.push({ name: repoName, base_branch: info ? info.defaultBranch : "main" });
832
877
  } catch {
833
878
  reposWithBranch.push({ name: repoName, base_branch: "main" });
834
879
  }
835
880
  }
836
881
  s0b.stop("Checked branches");
837
- // Replace flat list with enriched objects
838
882
  selectedRepos = reposWithBranch;
839
883
  console.log("");
840
884
  ui.info("Detected branch setup:");
@@ -843,42 +887,77 @@ async function localFounderFlow(ui, ctx = {}) {
843
887
  }
844
888
  console.log("");
845
889
  }
890
+ st.selectedRepos = selectedRepos;
891
+ return true;
892
+ };
893
+
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
+ // Direction-aware driver: BACK rewinds one step; SKIP (deterministic
926
+ // steps) passes through in whichever direction we're moving, so backing
927
+ // over an auto-answered step doesn't bounce the user forward again.
928
+ const steps = [stepHome, stepWorkType, stepNameProject, stepRepos, stepRepoNames];
929
+ let stepIdx = 0;
930
+ let stepDir = 1;
931
+ while (stepIdx < steps.length) {
932
+ const result = await steps[stepIdx]();
933
+ if (result === BACK) {
934
+ stepDir = -1;
935
+ stepIdx = Math.max(0, stepIdx - 1);
936
+ continue;
937
+ }
938
+ if (result === SKIP) {
939
+ stepIdx += stepDir;
940
+ if (stepIdx < 0) { stepIdx = 0; stepDir = 1; }
941
+ continue;
942
+ }
943
+ stepDir = 1;
944
+ stepIdx++;
846
945
  }
847
946
 
848
- // 6. Derive repo names from team name — then let the founder edit them.
849
- // Creating repos with silently derived names was a repeated field
850
- // complaint: founders want to choose what lands in their org.
851
- const slugifyName = (s) => (s || "")
852
- .toLowerCase()
853
- .replace(/[^a-z0-9-]/g, "-")
854
- .replace(/-+/g, "-")
855
- .replace(/^-|-$/g, "");
947
+ const githubOrg = st.githubOrg;
948
+ const isOrg = st.isOrg;
949
+ const isNewProject = st.isNewProject;
950
+ const orgName = st.orgName;
951
+ const description = st.description;
952
+ let newProjectRepo = st.newProjectRepo;
953
+ let selectedRepos = st.selectedRepos;
954
+ const repoName = st.repoName;
955
+ const memoryRepoName = st.memoryRepoName;
856
956
  const nameSlug = slugifyName(orgName) || "egregore";
857
957
  // Org slug prefixed with GitHub owner — globally unique across all egregores
858
958
  const ghPrefix = slugifyName(githubOrg);
859
959
  const slug = ghPrefix === nameSlug ? nameSlug : `${ghPrefix}-${nameSlug}`;
860
960
 
861
- console.log("");
862
- ui.note([
863
- `Two repos will be created in ${ui.bold(githubOrg)}: the Egregore repo`,
864
- "(config + skills) and its shared-memory repo. Enter keeps the defaults.",
865
- ]);
866
- let repoName;
867
- for (;;) {
868
- repoName = slugifyName(await ui.prompt("Egregore repo name", { default: nameSlug }));
869
- if (!repoName) { ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes."); continue; }
870
- if (newProjectRepo && repoName === newProjectRepo) { ui.warn(`"${repoName}" is already your project repo. Pick a different name.`); continue; }
871
- break;
872
- }
873
- let memoryRepoName;
874
- for (;;) {
875
- memoryRepoName = slugifyName(await ui.prompt("memory repo name", { default: `${repoName}-memory` }));
876
- if (!memoryRepoName) { ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes."); continue; }
877
- if (memoryRepoName === repoName) { ui.warn("Memory repo can't share the Egregore repo's name."); continue; }
878
- if (newProjectRepo && memoryRepoName === newProjectRepo) { ui.warn(`"${memoryRepoName}" is already your project repo. Pick a different name.`); continue; }
879
- break;
880
- }
881
-
882
961
  // Guard: if egregore repo already exists with a config, don't overwrite
883
962
  console.log("");
884
963
  const s1 = ui.spinner("Checking for existing Egregore...");
package/lib/ui.js CHANGED
@@ -226,6 +226,60 @@ function prompt(question, { default: defaultValue } = {}) {
226
226
  });
227
227
  }
228
228
 
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 });
235
+ return new Promise((resolve) => {
236
+ let buf = "";
237
+ 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}`);
241
+ const cleanup = () => {
242
+ process.stdin.setRawMode(false);
243
+ process.stdin.pause();
244
+ process.stdin.removeListener("keypress", onKey);
245
+ };
246
+ const onKey = (_str, key) => {
247
+ if (!key) return;
248
+ if (key.ctrl && key.name === "c") {
249
+ cleanup();
250
+ process.stdout.write(`\n ${DIM}(cancelled)${RESET}\n`);
251
+ process.exit(130);
252
+ }
253
+ if (key.name === "escape") {
254
+ cleanup();
255
+ process.stdout.write(`\r\x1b[2K${label}${DIM}← back${RESET}\n`);
256
+ resolve(BACK);
257
+ return;
258
+ }
259
+ if (key.name === "return") {
260
+ cleanup();
261
+ process.stdout.write("\n");
262
+ resolve(buf.trim() || (defaultValue ?? ""));
263
+ return;
264
+ }
265
+ if (key.name === "backspace") {
266
+ buf = buf.slice(0, -1);
267
+ render();
268
+ return;
269
+ }
270
+ if (_str && _str.length === 1 && !key.ctrl && !key.meta && _str.charCodeAt(0) >= 32) {
271
+ buf += _str;
272
+ render();
273
+ }
274
+ };
275
+ readline.emitKeypressEvents(process.stdin);
276
+ process.stdin.setRawMode(true);
277
+ process.stdin.resume();
278
+ process.stdin.on("keypress", onKey);
279
+ render();
280
+ });
281
+ }
282
+
229
283
  async function confirm(question, defaultYes = false) {
230
284
  const hint = defaultYes ? `${DIM}[Y/n]${RESET}` : `${DIM}[y/N]${RESET}`;
231
285
  const answer = await prompt(`${question} ${hint}`);
@@ -320,24 +374,33 @@ function filterOptions(options, filterText, getText) {
320
374
  return result;
321
375
  }
322
376
 
377
+ // ── back navigation ─────────────────────────────────────────────────
378
+ //
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.
383
+ const BACK = Symbol.for("egregore.ui.back");
384
+
323
385
  // ── choose: interactive single-select picker ────────────────────────
324
386
  //
325
387
  // options: [{ label, description }]
326
- // returns: the picked option (same object reference as in options[])
388
+ // returns: the picked option (same object reference as in options[]),
389
+ // or BACK when opts.escAction === "back" and the user pressed esc
327
390
  // keys:
328
391
  // ↑ ↓ navigate
329
392
  // enter select
330
393
  // type filter (case-insensitive substring match on label + desc)
331
394
  // backspace pop filter
332
- // esc cancel (returns the first option, or throws if opts.abortOnEsc)
395
+ // esc cancel — or go back with opts.escAction: "back"
333
396
  // ctrl-c abort process
334
397
 
335
- async function choose(question, options, hint) {
336
- if (!canInteract()) return legacyChoose(question, options, hint);
337
- return interactiveChoose(question, options, hint);
398
+ async function choose(question, options, hint, opts = {}) {
399
+ if (!canInteract()) return legacyChoose(question, options, hint, opts);
400
+ return interactiveChoose(question, options, hint, opts);
338
401
  }
339
402
 
340
- async function interactiveChoose(question, options, hint) {
403
+ async function interactiveChoose(question, options, hint, opts = {}) {
341
404
  return new Promise((resolve) => {
342
405
  let filterText = "";
343
406
  let visible = filterOptions(options, filterText, (o) => `${o.label} ${o.description || ""}`);
@@ -394,12 +457,13 @@ async function interactiveChoose(question, options, hint) {
394
457
  for (let i = start; i < end; i++) lines.push(renderRow(visible[i], i));
395
458
  if (end < total) lines.push(` ${DIM}↓ ${total - end} more · type to filter${RESET}`);
396
459
  }
460
+ const escHint = opts.escAction === "back" ? `${DIM}esc${RESET} back` : `${DIM}esc${RESET} cancel`;
397
461
  if (!first) clearLines(lastLineCount);
398
462
  lastLineCount = renderPicker({
399
463
  question,
400
464
  filterText,
401
465
  lines,
402
- help: hint || `${DIM}↑↓${RESET} navigate · ${DIM}enter${RESET} select · type to filter · ${DIM}esc${RESET} cancel`,
466
+ help: hint || `${DIM}↑↓${RESET} navigate · ${DIM}enter${RESET} select · type to filter · ${escHint}`,
403
467
  });
404
468
  };
405
469
 
@@ -416,8 +480,28 @@ async function interactiveChoose(question, options, hint) {
416
480
  resolve(value);
417
481
  };
418
482
 
483
+ const goBack = () => {
484
+ cleanup();
485
+ clearLines(lastLineCount);
486
+ process.stdout.write(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET} ${DIM}→ ← back${RESET}\n`);
487
+ resolve(BACK);
488
+ };
489
+
419
490
  const onKey = (_str, key) => {
420
491
  if (!key) return;
492
+ if (key.name === "escape" && filterText) {
493
+ // fzf convention: first esc clears an active filter; only a clean
494
+ // state goes back / cancels.
495
+ filterText = "";
496
+ visible = filterOptions(options, filterText, (o) => `${o.label} ${o.description || ""}`);
497
+ cursor = firstEnabled();
498
+ render();
499
+ return;
500
+ }
501
+ if (key.name === "escape" && opts.escAction === "back") {
502
+ goBack();
503
+ return;
504
+ }
421
505
  if ((key.ctrl && key.name === "c") || key.name === "escape") {
422
506
  cleanup();
423
507
  unparkCursor();
@@ -462,7 +546,7 @@ async function interactiveChoose(question, options, hint) {
462
546
  });
463
547
  }
464
548
 
465
- async function legacyChoose(question, options, hint) {
549
+ async function legacyChoose(question, options, hint, opts = {}) {
466
550
  process.stdout.write(`\n ${CYAN}◆${RESET} ${BOLD}${question}${RESET}\n\n`);
467
551
  const labelWidth = Math.min(40, Math.max(...options.map((o) => o.label.length)));
468
552
  for (let i = 0; i < options.length; i++) {
@@ -473,8 +557,10 @@ async function legacyChoose(question, options, hint) {
473
557
  process.stdout.write(` ${DIM}${num})${RESET} ${BOLD}${paddedLabel}${RESET}${desc}\n`);
474
558
  }
475
559
  if (hint) process.stdout.write(`\n ${DIM}${hint}${RESET}\n`);
560
+ if (opts.escAction === "back") process.stdout.write(` ${DIM}type "back" to go back${RESET}\n`);
476
561
  while (true) {
477
562
  const answer = await prompt(`pick ${DIM}[1-${options.length}]${RESET}:`);
563
+ if (opts.escAction === "back" && /^(b|back)$/i.test(answer)) return BACK;
478
564
  if (!answer) { warn(`Enter a number between 1 and ${options.length}.`); continue; }
479
565
  const n = parseInt(answer, 10);
480
566
  if (n >= 1 && n <= options.length) {
@@ -523,7 +609,9 @@ async function interactiveMultiSelect(question, options, opts = {}) {
523
609
  return new Promise((resolve) => {
524
610
  let cursor = 0;
525
611
  let filterText = "";
526
- const picked = new Set();
612
+ // opts.preselected re-arms a previous selection — used when the step
613
+ // machine navigates back to the picker so choices aren't lost.
614
+ const picked = new Set((opts.preselected || []).filter((n) => options.some((o) => o.name === n)));
527
615
  // The pool grows as remote results merge in; seed order is preserved so
528
616
  // boosted entries (e.g. the user's team repos) stay on top when idle.
529
617
  const pool = [...options];
@@ -584,13 +672,14 @@ async function interactiveMultiSelect(question, options, opts = {}) {
584
672
  if (searching && total > 0) lines.push(` ${DIM}searching…${RESET}`);
585
673
 
586
674
  const searchHint = opts.remoteSearch ? "type to search" : "type to filter";
675
+ const escHint = opts.escAction === "back" ? ` · ${DIM}esc${RESET} back` : "";
587
676
  if (!first) clearLines(lastLineCount);
588
677
  lastLineCount = renderPicker({
589
678
  question,
590
679
  filterText,
591
680
  lines,
592
681
  input: { label: opts.remoteSearch ? "search" : "filter" },
593
- help: `${DIM}↑↓${RESET} nav · ${DIM}space${RESET} toggle · ${DIM}ctrl-a${RESET} all · ${DIM}enter${RESET} confirm · ${searchHint}`,
682
+ help: `${DIM}↑↓${RESET} nav · ${DIM}space${RESET} toggle · ${DIM}ctrl-a${RESET} all · ${DIM}enter${RESET} confirm · ${searchHint}${escHint}`,
594
683
  });
595
684
  };
596
685
 
@@ -661,6 +750,21 @@ async function interactiveMultiSelect(question, options, opts = {}) {
661
750
 
662
751
  const onKey = (_str, key) => {
663
752
  if (!key) return;
753
+ if (key.name === "escape" && filterText) {
754
+ filterText = "";
755
+ refreshVisible();
756
+ cursor = 0;
757
+ searching = false;
758
+ render();
759
+ return;
760
+ }
761
+ if (key.name === "escape" && opts.escAction === "back") {
762
+ cleanup();
763
+ clearLines(lastLineCount);
764
+ process.stdout.write(` ${CYAN}◆${RESET} ${BOLD}${question}${RESET} ${DIM}→ ← back${RESET}\n`);
765
+ resolve(BACK);
766
+ return;
767
+ }
664
768
  if ((key.ctrl && key.name === "c") || key.name === "escape") {
665
769
  cleanup();
666
770
  unparkCursor();
@@ -1009,7 +1113,7 @@ module.exports = {
1009
1113
  banner, bannerCompact, section, note, card,
1010
1114
  info, success, warn, error, step,
1011
1115
  dim, bold, cyan, green, yellow, red,
1012
- spinner, prompt, choose, multiSelect, confirm, canInteract,
1116
+ spinner, prompt, promptOrBack, choose, multiSelect, confirm, canInteract, BACK,
1013
1117
  commandBox,
1014
1118
  canShowScreens, welcomeScreen, summaryScreen,
1015
1119
  _renderWelcomeBody, _readPkgVersion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.18.17",
3
+ "version": "0.18.18",
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",
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",
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
  },
@@ -162,3 +162,13 @@ PROJ_HASH=$(echo -n "$SCRIPT_DIR" | md5 2>/dev/null || echo -n "$SCRIPT_DIR" | m
162
162
  echo "$EGREGORE_SESSION_ID" > "$SESSION_ID_DIR/session-${PROJ_HASH}.id"
163
163
  # Also write project-local file (CLAUDE.md reads this — no glob needed)
164
164
  echo "$EGREGORE_SESSION_ID" > "$SCRIPT_DIR/.egregore-session-id"
165
+
166
+ # Model stamp hashes the git common-dir root because telemetry.sh resolves it that way.
167
+ MODEL_REPO_ROOT=$(git -C "$SCRIPT_DIR" rev-parse --path-format=absolute --git-common-dir 2>/dev/null | sed 's|/\.git$||') || MODEL_REPO_ROOT="$SCRIPT_DIR"
168
+ MODEL_PROJ_HASH=$(echo -n "$MODEL_REPO_ROOT" | md5 2>/dev/null || echo -n "$MODEL_REPO_ROOT" | md5sum 2>/dev/null | cut -d' ' -f1)
169
+ MODEL_STAMP_FILE="$SESSION_ID_DIR/session-model-${MODEL_PROJ_HASH}"
170
+ if [ -n "${CLAUDE_MODEL:-}" ]; then
171
+ echo "$CLAUDE_MODEL" > "$MODEL_STAMP_FILE"
172
+ else
173
+ rm -f "$MODEL_STAMP_FILE"
174
+ fi
@@ -98,6 +98,13 @@ _resolve_org() {
98
98
  fi
99
99
  }
100
100
 
101
+ _resolve_proj_hash() {
102
+ # Use git common dir so worktrees hash to the same path as the main checkout
103
+ local repo_root
104
+ repo_root=$(git -C "$SCRIPT_DIR" rev-parse --path-format=absolute --git-common-dir 2>/dev/null | sed 's|/\.git$||') || repo_root="$SCRIPT_DIR"
105
+ echo -n "$repo_root" | md5 2>/dev/null || echo -n "$repo_root" | md5sum 2>/dev/null | cut -d' ' -f1
106
+ }
107
+
101
108
  _resolve_session_id() {
102
109
  if [ -n "${EGREGORE_SESSION_ID:-}" ]; then
103
110
  echo "$EGREGORE_SESSION_ID"
@@ -106,11 +113,8 @@ _resolve_session_id() {
106
113
 
107
114
  # Fallback: read from file written by session-start.sh
108
115
  # (env vars from hooks don't propagate into the Claude Code agent)
109
- # Use git common dir so worktrees hash to the same path as the main checkout
110
- local repo_root
111
- repo_root=$(git -C "$SCRIPT_DIR" rev-parse --path-format=absolute --git-common-dir 2>/dev/null | sed 's|/\.git$||') || repo_root="$SCRIPT_DIR"
112
116
  local proj_hash
113
- proj_hash=$(echo -n "$repo_root" | md5 2>/dev/null || echo -n "$repo_root" | md5sum 2>/dev/null | cut -d' ' -f1)
117
+ proj_hash=$(_resolve_proj_hash)
114
118
  local sid_file="$HOME/.egregore/session-${proj_hash}.id"
115
119
  if [ -f "$sid_file" ]; then
116
120
  cat "$sid_file" 2>/dev/null || echo "unknown"
@@ -119,6 +123,45 @@ _resolve_session_id() {
119
123
  fi
120
124
  }
121
125
 
126
+ _resolve_model() {
127
+ local proj_hash
128
+ proj_hash=$(_resolve_proj_hash)
129
+ local model_file="$HOME/.egregore/session-model-${proj_hash}"
130
+ if [ ! -s "$model_file" ]; then
131
+ return 0
132
+ fi
133
+
134
+ local model=""
135
+ IFS= read -r model < "$model_file" || true
136
+ if [ -n "$model" ]; then
137
+ printf '%s\n' "$model"
138
+ fi
139
+ }
140
+
141
+ _stamp_model_if_absent() {
142
+ local compact_data="$1"
143
+ local has_model=""
144
+ has_model=$(printf '%s' "$compact_data" | jq -e 'has("model")' 2>/dev/null || true)
145
+ if [ "$has_model" != "false" ]; then
146
+ printf '%s' "$compact_data"
147
+ return 0
148
+ fi
149
+
150
+ local model=""
151
+ model=$(_resolve_model 2>/dev/null || true)
152
+ if [ -z "$model" ]; then
153
+ printf '%s' "$compact_data"
154
+ return 0
155
+ fi
156
+
157
+ local stamped_data=""
158
+ if stamped_data=$(printf '%s' "$compact_data" | jq -c --arg m "$model" 'if has("model") then . else . + {model:$m} end' 2>/dev/null); then
159
+ printf '%s' "$stamped_data"
160
+ else
161
+ printf '%s' "$compact_data"
162
+ fi
163
+ }
164
+
122
165
  # --- emit: append event to local JSONL buffer ---
123
166
  cmd_emit() {
124
167
  local event_type="${1:?Usage: telemetry.sh emit <type> <json>}"
@@ -129,12 +172,15 @@ cmd_emit() {
129
172
  return 0
130
173
  fi
131
174
 
175
+ local ts
176
+ ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
177
+ # Compact data to single line — callers may pass pretty-printed JSON
178
+ local compact_data
179
+ compact_data=$(printf '%s' "$data" | tr -d '\n' | tr -s ' ')
180
+ compact_data=$(_stamp_model_if_absent "$compact_data")
181
+
132
182
  # Debug mode: print to stderr instead of writing
133
183
  if _is_debug; then
134
- local ts
135
- ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
136
- local compact_data
137
- compact_data=$(printf '%s' "$data" | tr -d '\n' | tr -s ' ')
138
184
  printf '{"ts":"%s","type":"%s","sid":"%s","org":"%s","user":"%s","data":%s}\n' \
139
185
  "$ts" "$event_type" "$(_resolve_session_id)" "$(_resolve_org)" "$(_resolve_user)" "$compact_data" >&2
140
186
  return 0
@@ -144,11 +190,6 @@ cmd_emit() {
144
190
  mkdir -p "$BUFFER_DIR"
145
191
 
146
192
  # Build event line (outside lock for minimal hold time)
147
- local ts
148
- ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
149
- # Compact data to single line — callers may pass pretty-printed JSON
150
- local compact_data
151
- compact_data=$(printf '%s' "$data" | tr -d '\n' | tr -s ' ')
152
193
  local line
153
194
  line=$(printf '{"ts":"%s","type":"%s","sid":"%s","org":"%s","user":"%s","data":%s}' \
154
195
  "$ts" "$event_type" "$(_resolve_session_id)" "$(_resolve_org)" "$(_resolve_user)" "$compact_data")