projectstore-claude 0.30.1 → 0.30.2

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.
@@ -35,10 +35,10 @@ import { bandKeys, epicIds, planBand, applyBand } from "./band.mjs";
35
35
  import { planAgentsChange, applyAgentsChange } from "./agents-change.mjs";
36
36
  import { plan as planInstall, apply as applyInstall, planOptions, isInteractive } from "./install-harness.mjs";
37
37
  import { analyseLayout } from "./surfaces.mjs";
38
+ import { knownModels, offListNote } from "./models.mjs";
39
+ import { runMenu } from "./configure-menu.mjs";
40
+ import { SMALLEST } from "./menu.mjs";
38
41
 
39
- // The models the rail offers: the in-session `agents configure` presets
40
- // (commands/agents.md). Any other model is set with --set (contract 2).
41
- const MODEL_PRESETS = Object.freeze(["opus", "fable", "sonnet"]);
42
42
  const CLEAR = "-";
43
43
 
44
44
  const LANGUAGE_NOTE =
@@ -446,8 +446,17 @@ export function planChanges(f, changes, { env = process.env, home = homedir(), r
446
446
  if (change.usage) return { usage: change.usage };
447
447
  if (change.same) continue;
448
448
  const file = where(f, before.path);
449
- const display = loadHarness(h).display_name || h;
450
- if (before.agents.default !== change.next.default)
449
+ const manifest = loadHarness(h);
450
+ const display = manifest.display_name || h;
451
+ // A model no list of the harness holds is written, with a note (contract
452
+ // 8 as amended 2026-10-13): the model may be newer than the list.
453
+ // Only a model the run names gets the note: the clerk's pin does not.
454
+ const known = knownModels(manifest, { env, home });
455
+ const named = new Map(list.map((c) => [c.name, c.value]));
456
+ const offList = (name, model) => (model && named.get(name) === model ? offListNote(manifest, model, known) : null);
457
+ const notes = (...xs) => xs.filter(Boolean).join("; ") || null;
458
+ if (before.agents.default !== change.next.default) {
459
+ const listNote = offList("default", change.next.default);
451
460
  items.push({
452
461
  key: "agents.default",
453
462
  harness: h,
@@ -456,12 +465,16 @@ export function planChanges(f, changes, { env = process.env, home = homedir(), r
456
465
  before: shown(before.agents.default),
457
466
  after: shown(change.next.default),
458
467
  team: true,
468
+ note: listNote,
469
+ listNote,
459
470
  });
471
+ }
460
472
  const names = new Set([...Object.keys(before.agents.per_agent), ...Object.keys(change.next.per_agent)]);
461
473
  for (const n of [...names].sort()) {
462
474
  const a = before.agents.per_agent[n] || null;
463
475
  const b = change.next.per_agent[n] || null;
464
476
  if (a === b) continue;
477
+ const listNote = offList(n, b);
465
478
  items.push({
466
479
  key: `agents.${n}`,
467
480
  harness: h,
@@ -470,7 +483,11 @@ export function planChanges(f, changes, { env = process.env, home = homedir(), r
470
483
  before: shown(a),
471
484
  after: shown(b),
472
485
  team: true,
473
- note: change.pinnedClerk && n === "clerk" ? "pinned: the clerk stays cheap under a strong default" : null,
486
+ note: notes(
487
+ change.pinnedClerk && n === "clerk" ? "pinned: the clerk stays cheap under a strong default" : null,
488
+ listNote,
489
+ ),
490
+ listNote,
474
491
  });
475
492
  }
476
493
  if (change.clerkPinUnavailable)
@@ -711,51 +728,6 @@ export function renderPlan(f, items, { caps: c = term.PLAIN } = {}) {
711
728
 
712
729
  // ─── The run ───────────────────────────────────────────────────────────
713
730
 
714
- const railChoices = (s, f) => {
715
- if (s.rail === "bool") return ["on", "off"];
716
- if (s.rail === "language") return f.languages;
717
- if (s.rail === "policy") return ["optional", "required"];
718
- if (s.rail === "epic") return [...f.epics, { value: CLEAR, label: "release the pin" }];
719
- if (s.rail === "model") {
720
- // The presets are Claude Code's model names: offered only for a harness
721
- // whose agents carry a model (agent_translation.carries_model). Any other
722
- // harness's model is named with --set, in its own vocabulary.
723
- const list = loadHarness(s.harness)?.agent_translation?.carries_model === true ? [...MODEL_PRESETS] : [];
724
- if (s.value && !list.includes(s.value)) list.unshift(s.value);
725
- return [...list, { value: CLEAR, label: s.key === "agents.default" ? "clear" : "clear (the default decides)" }];
726
- }
727
- return [];
728
- };
729
-
730
- // The rail's answers as --set strings, so a terminal run and a named one
731
- // resolve through the same code.
732
- async function askRail(rail, f, settings) {
733
- const answerable = settings.filter((s) => s.class === "changeable" && s.rail && railChoices(s, f).length);
734
- const twoHarnesses = f.used.length > 1;
735
- const label = (s) =>
736
- twoHarnesses && s.harness
737
- ? `${loadHarness(s.harness).display_name || s.harness} · ${s.key.replace(/^agents\./, "")}`
738
- : s.key;
739
- const ticked = await rail.multi(
740
- "What do you want to change?",
741
- answerable.map((s, k) => ({ value: k, label: label(s), reason: shown(s.value) })),
742
- { min: 0, note: "space ticks a row; Enter with none ticked changes nothing" },
743
- );
744
- if (ticked === term.CANCELLED) return { cancelled: true };
745
- const sets = [];
746
- for (const k of ticked) {
747
- const s = answerable[k];
748
- const choices = railChoices(s, f);
749
- const current = s.rail === "bool" ? s.value : s.value || undefined;
750
- const v = await rail.select(label(s), choices, {
751
- default: choices.some((x) => (typeof x === "object" ? x.value : x) === current) ? current : undefined,
752
- });
753
- if (v === term.CANCELLED) return { cancelled: true };
754
- sets.push({ set: `${s.key}=${v}`, harness: s.harness || null });
755
- }
756
- return { sets };
757
- }
758
-
759
731
  // ctx: { values, project, env, stdin, stdout, ask, root, home, ownEnv,
760
732
  // nearest, proc, escapeMs, drainTicks, now }. Returns { code, ok, result }
761
733
  // or { code: 2, usage } or { code: 3, unbound: true }; the bin writes the
@@ -795,46 +767,83 @@ export async function runConfigure(ctx) {
795
767
  }));
796
768
  const interactive =
797
769
  !json && (ask ? true : isInteractive({ stdin, stdout, env, json })) && term.railAvailable(stdin, c);
770
+ // The menu needs a screen of its own size (contract 4 as amended
771
+ // 2026-10-13): smaller, the run is as without a terminal.
772
+ const fits =
773
+ (Number(stdout?.columns) || c.columns || 100) >= SMALLEST.columns && (Number(stdout?.rows) || 24) >= SMALLEST.rows;
798
774
 
799
775
  // Nothing named and no terminal: the view, and nothing written.
800
- if (!sets.length && !interactive) {
776
+ if (!sets.length && (!interactive || !fits)) {
801
777
  write(renderView(f, settings, { caps: c }));
802
778
  return { code: 0, ok: true, result: { settings: publicSettings, plan: [], wrote: [], removed: [] } };
803
779
  }
804
780
 
805
781
  let resolved;
782
+ // The menu's review screen confirms; a --set run at a terminal asks once.
783
+ let confirmed = false,
784
+ reviewed = null;
806
785
  if (sets.length) {
807
786
  resolved = resolveSets(f, sets, { harness: named[0] || null, nearest: ctx.nearest });
808
787
  if (resolved.usage) return { code: 2, usage: resolved.usage };
809
788
  write(`${term.badge(c, "projectstore configure")}\n ${tildeOf(f.project, f.home)}\n`);
810
789
  } else {
811
- write(renderView(f, settings, { caps: c }));
812
- let rail = null,
813
- a = null;
814
- try {
815
- rail = term.openRail({
816
- stdin,
817
- stdout,
818
- caps: c,
819
- proc: ctx.proc || process,
820
- head: "projectstore configure",
821
- ...(ctx.escapeMs ? { escapeMs: ctx.escapeMs } : {}),
822
- });
823
- a = await askRail(rail, f, settings);
824
- } finally {
825
- if (rail) rail.close(a?.cancelled ? `Cancelled ${term.icon(c, "dash")} nothing was written` : "");
790
+ // The settings menu (contract 4 as amended 2026-10-13). Its changes come
791
+ // back as --set strings and go through the same resolve, plan and apply;
792
+ // its review screen's second Enter is the confirmation.
793
+ const resolveAll = (list) => {
794
+ const changes = [];
795
+ for (const x of list) {
796
+ const r = resolveSets(f, [x.set], { harness: x.harness, nearest: ctx.nearest });
797
+ if (r.usage) return { usage: r.usage };
798
+ changes.push(...r.changes);
799
+ }
800
+ return { changes };
801
+ };
802
+ const a = await runMenu({
803
+ f,
804
+ settings,
805
+ stdin,
806
+ stdout,
807
+ caps: c,
808
+ proc: ctx.proc || process,
809
+ escapeMs: ctx.escapeMs,
810
+ env,
811
+ home,
812
+ now,
813
+ path: tildeOf(f.project, f.home),
814
+ dryRun,
815
+ fetch: ctx.fetch,
816
+ spawn: ctx.spawn,
817
+ timers: ctx.timers,
818
+ review: (list) => {
819
+ const r = resolveAll(list);
820
+ if (r.usage) return { ok: false, message: r.usage };
821
+ const p = planChanges(f, r.changes, { env, home, root, ownEnv: ctx.ownEnv || (() => env) });
822
+ if (p.usage) return { ok: false, message: p.usage };
823
+ if (p.refusal !== undefined) return { ok: false, message: p.refusal };
824
+ return { ok: true, items: p.items, plan: p };
825
+ },
826
+ });
827
+ const head = `${term.badge(c, "projectstore configure")}\n ${tildeOf(f.project, f.home)}\n`;
828
+ if (a.cancelled) {
829
+ write(`${head}\n Cancelled ${term.icon(c, "dash")} nothing was written\n`);
830
+ return { code: 130, ok: false, result: null };
826
831
  }
827
- if (a.cancelled) return { code: 130, ok: false, result: null };
828
- const changes = [];
829
- for (const x of a.sets) {
830
- const r = resolveSets(f, [x.set], { harness: x.harness, nearest: ctx.nearest });
831
- if (r.usage) return { code: 2, usage: r.usage };
832
- changes.push(...r.changes);
832
+ if (a.nothing) {
833
+ write(`${head}\n No change ${term.icon(c, "dash")} nothing was written\n`);
834
+ return { code: 0, ok: true, result: { settings: publicSettings, plan: [], wrote: [], removed: [] } };
833
835
  }
834
- resolved = { changes };
836
+ write(head);
837
+ const r = resolveAll(a.sets);
838
+ if (r.usage) return { code: 2, usage: r.usage };
839
+ resolved = r;
840
+ confirmed = !a.dryRun;
841
+ // What the review showed is what APPLY runs: the plan is not made again
842
+ // after its confirmation.
843
+ reviewed = a.planned || null;
835
844
  }
836
845
 
837
- const planned = planChanges(f, resolved.changes, { env, home, root, ownEnv: ctx.ownEnv || (() => env) });
846
+ const planned = reviewed || planChanges(f, resolved.changes, { env, home, root, ownEnv: ctx.ownEnv || (() => env) });
838
847
  if (planned.usage) return { code: 2, usage: planned.usage };
839
848
  const planOut = (items) =>
840
849
  items.map((i) => ({
@@ -844,6 +853,8 @@ export async function runConfigure(ctx) {
844
853
  before: i.before,
845
854
  after: i.after,
846
855
  team: i.team,
856
+ // Contract 3 as amended 2026-10-13: the off-list note only.
857
+ ...(i.listNote ? { note: i.listNote } : {}),
847
858
  }));
848
859
  if (planned.refusal !== undefined)
849
860
  return {
@@ -862,7 +873,7 @@ export async function runConfigure(ctx) {
862
873
  write("\n Nothing written: this was a dry run.\n");
863
874
  return { code: 0, ok: true, result: { ...result, dry_run: true } };
864
875
  }
865
- if (interactive) {
876
+ if (interactive && !confirmed) {
866
877
  if (stdin && !ask) await term.drainInput(stdin, { ticks: ctx.drainTicks ?? 2 });
867
878
  write("\n");
868
879
  const yes = await term.askApply(planned.items.length, { stdin, stdout, ask, caps: c });
@@ -302,9 +302,7 @@ export function hostPresence(manifest, { env = process.env, home = homedir() } =
302
302
  x.cli.bin,
303
303
  )?.[1] || null;
304
304
  const bin = s ? s.cli.bin : null;
305
- const r = manifest?.runtime || {};
306
- const fromEnv = r.home_env && env ? env[r.home_env] : null;
307
- const homeDir = fromEnv || (home && r.home_default ? join(home, r.home_default) : null);
305
+ const homeDir = harnessHome(manifest, { env, home });
308
306
  let present = false;
309
307
  if (homeDir) {
310
308
  try {
@@ -314,6 +312,15 @@ export function hostPresence(manifest, { env = process.env, home = homedir() } =
314
312
  return { cli: bin ? whichOnPath(bin, env || {}) : null, home: present ? homeDir : null, bin, homeDir };
315
313
  }
316
314
 
315
+ // A harness's own home directory, as the harness resolves it: its home
316
+ // variable (runtime.home_env) when set, else home_default under the home
317
+ // directory, else null. Whether it exists is the caller's question.
318
+ export function harnessHome(manifest, { env = process.env, home = homedir() } = {}) {
319
+ const r = manifest?.runtime || {};
320
+ const fromEnv = r.home_env && env ? env[r.home_env] : null;
321
+ return fromEnv || (home && r.home_default ? join(home, r.home_default) : null);
322
+ }
323
+
317
324
  // The agents block of a RUN (the story "One run plans the agents block once,
318
325
  // and a bare uninstall selects every harness the project uses", rules 1–2):
319
326
  // the files every manifest of the run names for it, in the FIRST manifest's
@@ -703,6 +710,9 @@ export function cachePaths({ env = process.env, home = homedir() } = {}) {
703
710
  fetch: root ? join(root, "fetch") : null,
704
711
  fetchRun: (id) => (root ? join(root, "fetch", id) : null),
705
712
  registry: root ? join(root, "registry.json") : null,
713
+ // A harness's model list, as its last live read answered (the ADR "Agent
714
+ // model lists are read from each harness's own catalog…", decision 4).
715
+ models: (id) => (root && id ? join(root, "models", `${id}.json`) : null),
706
716
  from: fromEnv ? variable : "the home directory",
707
717
  variable,
708
718
  };