projectstore-claude 0.30.1 → 0.30.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  2. package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
  3. package/node_modules/projectstore/AGENTS.md +7 -5
  4. package/node_modules/projectstore/README.md +5 -5
  5. package/node_modules/projectstore/agents/clerk.md +2 -2
  6. package/node_modules/projectstore/commands/agents.md +38 -26
  7. package/node_modules/projectstore/commands/bind.md +1 -1
  8. package/node_modules/projectstore/commands/doctor.md +15 -3
  9. package/node_modules/projectstore/commands/reconcile.md +2 -2
  10. package/node_modules/projectstore/commands/review.md +1 -1
  11. package/node_modules/projectstore/commands/story.md +2 -1
  12. package/node_modules/projectstore/docs/getting-started.md +1 -1
  13. package/node_modules/projectstore/docs/harnesses.md +3 -2
  14. package/node_modules/projectstore/harnesses/claude-code.json +92 -2
  15. package/node_modules/projectstore/harnesses/codex.json +81 -14
  16. package/node_modules/projectstore/package.json +1 -1
  17. package/node_modules/projectstore/scripts/agents-change.mjs +64 -10
  18. package/node_modules/projectstore/scripts/build-adapters.mjs +28 -13
  19. package/node_modules/projectstore/scripts/cli.mjs +32 -9
  20. package/node_modules/projectstore/scripts/configure-menu.mjs +716 -0
  21. package/node_modules/projectstore/scripts/configure.mjs +239 -91
  22. package/node_modules/projectstore/scripts/doctor.mjs +95 -31
  23. package/node_modules/projectstore/scripts/harness.mjs +13 -3
  24. package/node_modules/projectstore/scripts/install-harness.mjs +29 -0
  25. package/node_modules/projectstore/scripts/lib.mjs +32 -9
  26. package/node_modules/projectstore/scripts/menu.mjs +746 -0
  27. package/node_modules/projectstore/scripts/models.mjs +649 -0
  28. package/node_modules/projectstore/scripts/term.mjs +167 -0
  29. package/node_modules/projectstore/templates/claude-md-block.md.tmpl +7 -5
  30. package/package.json +2 -2
@@ -22,6 +22,7 @@ import {
22
22
  import {
23
23
  readOverlayAt,
24
24
  resolveAgentModel,
25
+ resolveAgentEffort,
25
26
  layoutRoster,
26
27
  readVaultConfigStrict,
27
28
  nextVaultConfig,
@@ -35,10 +36,18 @@ import { bandKeys, epicIds, planBand, applyBand } from "./band.mjs";
35
36
  import { planAgentsChange, applyAgentsChange } from "./agents-change.mjs";
36
37
  import { plan as planInstall, apply as applyInstall, planOptions, isInteractive } from "./install-harness.mjs";
37
38
  import { analyseLayout } from "./surfaces.mjs";
39
+ import {
40
+ knownModels,
41
+ offListNote,
42
+ effortSpec,
43
+ effortLevels,
44
+ offLevelNote,
45
+ standingLevelNote,
46
+ LEVEL,
47
+ } from "./models.mjs";
48
+ import { runMenu } from "./configure-menu.mjs";
49
+ import { SMALLEST } from "./menu.mjs";
38
50
 
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
51
  const CLEAR = "-";
43
52
 
44
53
  const LANGUAGE_NOTE =
@@ -76,6 +85,7 @@ export function readFacts(project, { env = process.env, root, home = homedir() }
76
85
  return {
77
86
  project,
78
87
  home,
88
+ env,
79
89
  path,
80
90
  cfg,
81
91
  used,
@@ -108,6 +118,29 @@ export function settingsOf(f) {
108
118
  const o = readOverlayAt(f.project, h.id);
109
119
  const file = where(f, o.path);
110
120
  const base = { group: `agents:${h.id}`, harness: h.id, file, class: "changeable", team: true, writer: "overlay" };
121
+ // Each model setting is followed by its effort setting, where the harness
122
+ // passes one at spawn (the effort ADR, decisions 1 and 4). An effort that
123
+ // nothing configures reads as the harness says: the frontmatter's, or the
124
+ // model's own default level, named when its catalog entry is known.
125
+ const es = effortSpec(h);
126
+ const known = es?.unset?.model_default ? knownModels(h, { env: f.env, home: f.home }) : null;
127
+ // frontmatter: the agent's own; model_default: the passed model's own
128
+ // default, named; harness: the harness's own precedence (no model passed,
129
+ // or none whose default is known).
130
+ const unsetEffort = (model) => {
131
+ if (!es?.unset?.model_default) return { source: "frontmatter" };
132
+ const d = model ? effortLevels(h, model, known)?.default : null;
133
+ return d ? { source: "model_default", model_default: d } : { source: "harness" };
134
+ };
135
+ const effortRow = (key, effort, effortSource, model) =>
136
+ es &&
137
+ out.push({
138
+ ...base,
139
+ key,
140
+ value: effort || null,
141
+ ...(o.unparseable ? { source: "unparseable" } : effort ? { source: effortSource } : unsetEffort(model)),
142
+ rail: "effort",
143
+ });
111
144
  out.push({
112
145
  ...base,
113
146
  key: "agents.default",
@@ -115,7 +148,10 @@ export function settingsOf(f) {
115
148
  source: o.unparseable ? "unparseable" : o.agents.default ? "overlay" : "frontmatter",
116
149
  rail: "model",
117
150
  });
118
- const names = [...new Set([...(f.roster || []), ...Object.keys(o.agents.per_agent)])];
151
+ effortRow("agents.default.effort", o.efforts.default, "overlay", o.agents.default);
152
+ const names = [
153
+ ...new Set([...(f.roster || []), ...Object.keys(o.agents.per_agent), ...Object.keys(o.efforts.per_agent)]),
154
+ ];
119
155
  for (const n of names) {
120
156
  const r = resolveAgentModel(f.project, n, { harness: h.id });
121
157
  out.push({
@@ -125,6 +161,8 @@ export function settingsOf(f) {
125
161
  source: o.unparseable ? "unparseable" : r.source || "frontmatter",
126
162
  rail: "model",
127
163
  });
164
+ const e = resolveAgentEffort(f.project, n, { harness: h.id });
165
+ effortRow(`agents.${n}.effort`, e.effort, e.source, r.model);
128
166
  }
129
167
  }
130
168
  if (f.slot)
@@ -239,11 +277,13 @@ function setForm(s, f) {
239
277
  ? "on|off"
240
278
  : s.key === "tags"
241
279
  ? "<a,b>"
242
- : s.key.startsWith("agents.")
243
- ? "<model>"
244
- : s.key === "band.epic"
245
- ? "<epic>"
246
- : `<${s.key}>`;
280
+ : s.rail === "effort"
281
+ ? "<level>"
282
+ : s.key.startsWith("agents.")
283
+ ? "<model>"
284
+ : s.key === "band.epic"
285
+ ? "<epic>"
286
+ : `<${s.key}>`;
247
287
  const h = s.harness && f.used.length > 1 ? ` --harness ${s.harness}` : "";
248
288
  return `--set ${s.key}=${value}${h}`;
249
289
  }
@@ -266,8 +306,10 @@ export function resolveSets(f, sets, { harness = null, nearest = null } = {}) {
266
306
  const value = String(raw)
267
307
  .slice(at + 1)
268
308
  .trim();
269
- const m = /^agents\.([a-z][a-z0-9-]*)(?:\.model)?$/.exec(key);
270
- if (m) key = `agents.${m[1]}`;
309
+ // agents.<name> and agents.<name>.model are one key; agents.<name>.effort
310
+ // is a key of its own (the effort ADR, decision 4).
311
+ const m = /^agents\.([a-z][a-z0-9-]*)(?:\.(model|effort))?$/.exec(key);
312
+ if (m) key = m[2] === "effort" ? `agents.${m[1]}.effort` : `agents.${m[1]}`;
271
313
  if (seen.has(key)) return { usage: `${key} is named twice` };
272
314
  seen.add(key);
273
315
  if (m) {
@@ -280,6 +322,19 @@ export function resolveSets(f, sets, { harness = null, nearest = null } = {}) {
280
322
  else return { usage: `name the overlay: --harness <id> (this project uses ${harnessesNamed})` };
281
323
  }
282
324
  if (!harnessIds().includes(h)) return { usage: `unknown harness: ${h} — known: ${harnessIds().join(", ")}` };
325
+ if (m[2] === "effort") {
326
+ const manifest = loadHarness(h);
327
+ const es = effortSpec(manifest);
328
+ if (!es) return { usage: `${manifest?.display_name || h} takes no effort at spawn: ${key} has nothing to set` };
329
+ // A lowercase token, or - to clear; a level the harness does not list
330
+ // is written with a note, not refused (decision 5).
331
+ if (value !== CLEAR && !LEVEL.test(value))
332
+ return {
333
+ usage: `${key} takes a level (${es.levels.map((l) => l.id).join(", ")}), or ${CLEAR} to clear it, got ${value || "nothing"}`,
334
+ };
335
+ out.push({ key, writer: "overlay", harness: h, name, field: "effort", value: value === CLEAR ? null : value });
336
+ continue;
337
+ }
283
338
  if (!value) return { usage: `${key} takes a model, or ${CLEAR} to clear it` };
284
339
  out.push({ key, writer: "overlay", harness: h, name, value: value === CLEAR ? null : value });
285
340
  continue;
@@ -434,20 +489,70 @@ export function planChanges(f, changes, { env = process.env, home = homedir(), r
434
489
  for (const [h, list] of byHarness) {
435
490
  const before = readOverlayAt(f.project, h);
436
491
  if (before.unparseable) return refuse(`${before.path} is not valid JSON — fix or remove it first`);
437
- const def = list.find((c) => c.name === "default");
492
+ const models = list.filter((c) => c.field !== "effort");
493
+ const efforts = list.filter((c) => c.field === "effort");
494
+ const def = models.find((c) => c.name === "default");
495
+ const defEffort = efforts.find((c) => c.name === "default");
438
496
  const change = planAgentsChange({
439
497
  before,
440
498
  harness: h,
441
499
  layout: f.cfg.layout,
442
500
  roster: f.roster,
443
501
  defaultModel: def ? def.value || "" : undefined,
444
- agents: list.filter((c) => c.name !== "default").map((c) => `${c.name}=${c.value || ""}`),
502
+ agents: models.filter((c) => c.name !== "default").map((c) => `${c.name}=${c.value || ""}`),
503
+ defaultEffort: defEffort ? defEffort.value || "" : undefined,
504
+ efforts: efforts.filter((c) => c.name !== "default").map((c) => `${c.name}=${c.value || ""}`),
445
505
  });
446
506
  if (change.usage) return { usage: change.usage };
447
507
  if (change.same) continue;
448
508
  const file = where(f, before.path);
449
- const display = loadHarness(h).display_name || h;
450
- if (before.agents.default !== change.next.default)
509
+ const manifest = loadHarness(h);
510
+ const display = manifest.display_name || h;
511
+ // A model no list of the harness holds is written, with a note (contract
512
+ // 8 as amended 2026-10-13): the model may be newer than the list.
513
+ // Only a model the run names gets the note: the clerk's pin does not.
514
+ const known = knownModels(manifest, { env, home });
515
+ const named = new Map(models.map((c) => [c.name, c.value]));
516
+ const offList = (name, model) => (model && named.get(name) === model ? offListNote(manifest, model, known) : null);
517
+ const notes = (...xs) => xs.filter(Boolean).join("; ") || null;
518
+ // Effort against the model each agent runs on after the run (the effort
519
+ // ADR, decision 5): a level the run names is checked on its own row; a
520
+ // model the run changes is checked against the level its agents keep, on
521
+ // the model's row; a default level the run names is checked against every
522
+ // agent that inherits it on a model of its own, on the default's row.
523
+ const ne = change.nextEfforts;
524
+ const namedEffort = new Map(efforts.map((c) => [c.name, c.value]));
525
+ const roster = [
526
+ ...new Set([...(f.roster || []), ...Object.keys(change.next.per_agent), ...Object.keys(ne.per_agent)]),
527
+ ];
528
+ const runModel = (n) => (n === "default" ? change.next.default : change.next.per_agent[n] || change.next.default);
529
+ const runEffort = (n) => (n === "default" ? ne.default : ne.per_agent[n] || ne.default) || null;
530
+ const levelNote = (n, level) =>
531
+ level && namedEffort.get(n) === level ? offLevelNote(manifest, level, runModel(n) || null, known) : null;
532
+ const inheritors = (level) =>
533
+ level && namedEffort.get("default") === level
534
+ ? roster
535
+ .filter(
536
+ (n) => !ne.per_agent[n] && change.next.per_agent[n] && change.next.per_agent[n] !== change.next.default,
537
+ )
538
+ .map((n) => standingLevelNote(manifest, n, level, runModel(n), known))
539
+ .filter(Boolean)
540
+ .join("; ") || null
541
+ : null;
542
+ // Agents a model change moves, and the level each keeps, when that level
543
+ // was not named by this run (a named one is checked on its own row).
544
+ const moved = (n) => {
545
+ const who = n === "default" ? ["default", ...roster.filter((r) => !change.next.per_agent[r])] : [n];
546
+ return (
547
+ who
548
+ .filter((a) => !namedEffort.has(a) && !(a !== "default" && !ne.per_agent[a] && namedEffort.has("default")))
549
+ .map((a) => standingLevelNote(manifest, a, runEffort(a), runModel(a) || null, known))
550
+ .filter(Boolean)
551
+ .join("; ") || null
552
+ );
553
+ };
554
+ if (before.agents.default !== change.next.default) {
555
+ const listNote = notes(offList("default", change.next.default), moved("default"));
451
556
  items.push({
452
557
  key: "agents.default",
453
558
  harness: h,
@@ -456,12 +561,16 @@ export function planChanges(f, changes, { env = process.env, home = homedir(), r
456
561
  before: shown(before.agents.default),
457
562
  after: shown(change.next.default),
458
563
  team: true,
564
+ note: listNote,
565
+ listNote,
459
566
  });
567
+ }
460
568
  const names = new Set([...Object.keys(before.agents.per_agent), ...Object.keys(change.next.per_agent)]);
461
569
  for (const n of [...names].sort()) {
462
570
  const a = before.agents.per_agent[n] || null;
463
571
  const b = change.next.per_agent[n] || null;
464
572
  if (a === b) continue;
573
+ const listNote = notes(offList(n, b), moved(n));
465
574
  items.push({
466
575
  key: `agents.${n}`,
467
576
  harness: h,
@@ -470,7 +579,11 @@ export function planChanges(f, changes, { env = process.env, home = homedir(), r
470
579
  before: shown(a),
471
580
  after: shown(b),
472
581
  team: true,
473
- note: change.pinnedClerk && n === "clerk" ? "pinned: the clerk stays cheap under a strong default" : null,
582
+ note: notes(
583
+ change.pinnedClerk && n === "clerk" ? "pinned: the clerk stays cheap under a strong default" : null,
584
+ listNote,
585
+ ),
586
+ listNote,
474
587
  });
475
588
  }
476
589
  if (change.clerkPinUnavailable)
@@ -484,7 +597,43 @@ export function planChanges(f, changes, { env = process.env, home = homedir(), r
484
597
  team: true,
485
598
  note: `${display} declares no cheap model to pin the clerk to; set agents.clerk to name one`,
486
599
  });
487
- steps.push({ writer: "overlay", harness: h, next: change.next });
600
+ const beforeEfforts = before.efforts;
601
+ if (beforeEfforts.default !== ne.default) {
602
+ const listNote = notes(levelNote("default", ne.default), inheritors(ne.default));
603
+ items.push({
604
+ key: "agents.default.effort",
605
+ harness: h,
606
+ display,
607
+ file,
608
+ before: shown(beforeEfforts.default),
609
+ after: shown(ne.default),
610
+ team: true,
611
+ note: listNote,
612
+ listNote,
613
+ });
614
+ }
615
+ const effortNames = new Set([...Object.keys(beforeEfforts.per_agent), ...Object.keys(ne.per_agent)]);
616
+ for (const n of [...effortNames].sort()) {
617
+ const a = beforeEfforts.per_agent[n] || null;
618
+ const b = ne.per_agent[n] || null;
619
+ if (a === b) continue;
620
+ const listNote = levelNote(n, b);
621
+ items.push({
622
+ key: `agents.${n}.effort`,
623
+ harness: h,
624
+ display,
625
+ file,
626
+ before: shown(a),
627
+ after: shown(b),
628
+ team: true,
629
+ note: notes(
630
+ change.pinnedClerkEffort && n === "clerk" ? "pinned: the clerk stays cheap under a strong default" : null,
631
+ listNote,
632
+ ),
633
+ listNote,
634
+ });
635
+ }
636
+ steps.push({ writer: "overlay", harness: h, next: change.next, nextEfforts: ne });
488
637
  }
489
638
 
490
639
  // The status line: install's own plan for the surface, with its flag item
@@ -585,7 +734,7 @@ export function applyChanges(f, steps, { env = process.env, home = homedir(), ro
585
734
  writeVaultConfig(f.vault, nextVaultConfig(now.cfg, s.patch, new Date().toISOString()));
586
735
  wrote.push(vaultConfigPath(f.vault));
587
736
  } else if (s.writer === "overlay") {
588
- wrote.push(applyAgentsChange(f.project, s.harness, s.next).path);
737
+ wrote.push(applyAgentsChange(f.project, s.harness, s.next, s.nextEfforts).path);
589
738
  } else if (s.writer === "statusline") {
590
739
  const finished = [];
591
740
  try {
@@ -623,6 +772,8 @@ const SOURCE_TEXT = Object.freeze({
623
772
  per_agent: "per agent",
624
773
  default: "default",
625
774
  frontmatter: "its frontmatter",
775
+ model_default: "its model's own default",
776
+ harness: "the harness decides",
626
777
  binding: "binding",
627
778
  vault_config: "vault config",
628
779
  unparseable: "not valid JSON: fix the file to change it",
@@ -658,9 +809,11 @@ export function renderView(f, settings, { caps: c = term.PLAIN } = {}) {
658
809
  note:
659
810
  s.class === "other-verb"
660
811
  ? "changed by another command, below"
661
- : s.class === "changeable" && !s.rail
662
- ? `${SOURCE_TEXT[s.source] || s.source}; change it with ${s.set}`
663
- : SOURCE_TEXT[s.source] || s.source,
812
+ : s.source === "model_default" && s.model_default
813
+ ? `${SOURCE_TEXT.model_default}, ${s.model_default}`
814
+ : s.class === "changeable" && !s.rail
815
+ ? `${SOURCE_TEXT[s.source] || s.source}; change it with ${s.set}`
816
+ : SOURCE_TEXT[s.source] || s.source,
664
817
  })),
665
818
  ),
666
819
  );
@@ -711,51 +864,6 @@ export function renderPlan(f, items, { caps: c = term.PLAIN } = {}) {
711
864
 
712
865
  // ─── The run ───────────────────────────────────────────────────────────
713
866
 
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
867
  // ctx: { values, project, env, stdin, stdout, ask, root, home, ownEnv,
760
868
  // nearest, proc, escapeMs, drainTicks, now }. Returns { code, ok, result }
761
869
  // or { code: 2, usage } or { code: 3, unbound: true }; the bin writes the
@@ -792,49 +900,87 @@ export async function runConfigure(ctx) {
792
900
  harness: s.harness || null,
793
901
  team: s.team,
794
902
  set: s.set,
903
+ ...(s.model_default ? { model_default: s.model_default } : {}),
795
904
  }));
796
905
  const interactive =
797
906
  !json && (ask ? true : isInteractive({ stdin, stdout, env, json })) && term.railAvailable(stdin, c);
907
+ // The menu needs a screen of its own size (contract 4 as amended
908
+ // 2026-10-13): smaller, the run is as without a terminal.
909
+ const fits =
910
+ (Number(stdout?.columns) || c.columns || 100) >= SMALLEST.columns && (Number(stdout?.rows) || 24) >= SMALLEST.rows;
798
911
 
799
912
  // Nothing named and no terminal: the view, and nothing written.
800
- if (!sets.length && !interactive) {
913
+ if (!sets.length && (!interactive || !fits)) {
801
914
  write(renderView(f, settings, { caps: c }));
802
915
  return { code: 0, ok: true, result: { settings: publicSettings, plan: [], wrote: [], removed: [] } };
803
916
  }
804
917
 
805
918
  let resolved;
919
+ // The menu's review screen confirms; a --set run at a terminal asks once.
920
+ let confirmed = false,
921
+ reviewed = null;
806
922
  if (sets.length) {
807
923
  resolved = resolveSets(f, sets, { harness: named[0] || null, nearest: ctx.nearest });
808
924
  if (resolved.usage) return { code: 2, usage: resolved.usage };
809
925
  write(`${term.badge(c, "projectstore configure")}\n ${tildeOf(f.project, f.home)}\n`);
810
926
  } 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` : "");
927
+ // The settings menu (contract 4 as amended 2026-10-13). Its changes come
928
+ // back as --set strings and go through the same resolve, plan and apply;
929
+ // its review screen's second Enter is the confirmation.
930
+ const resolveAll = (list) => {
931
+ const changes = [];
932
+ for (const x of list) {
933
+ const r = resolveSets(f, [x.set], { harness: x.harness, nearest: ctx.nearest });
934
+ if (r.usage) return { usage: r.usage };
935
+ changes.push(...r.changes);
936
+ }
937
+ return { changes };
938
+ };
939
+ const a = await runMenu({
940
+ f,
941
+ settings,
942
+ stdin,
943
+ stdout,
944
+ caps: c,
945
+ proc: ctx.proc || process,
946
+ escapeMs: ctx.escapeMs,
947
+ env,
948
+ home,
949
+ now,
950
+ path: tildeOf(f.project, f.home),
951
+ dryRun,
952
+ fetch: ctx.fetch,
953
+ spawn: ctx.spawn,
954
+ timers: ctx.timers,
955
+ review: (list) => {
956
+ const r = resolveAll(list);
957
+ if (r.usage) return { ok: false, message: r.usage };
958
+ const p = planChanges(f, r.changes, { env, home, root, ownEnv: ctx.ownEnv || (() => env) });
959
+ if (p.usage) return { ok: false, message: p.usage };
960
+ if (p.refusal !== undefined) return { ok: false, message: p.refusal };
961
+ return { ok: true, items: p.items, plan: p };
962
+ },
963
+ });
964
+ const head = `${term.badge(c, "projectstore configure")}\n ${tildeOf(f.project, f.home)}\n`;
965
+ if (a.cancelled) {
966
+ write(`${head}\n Cancelled ${term.icon(c, "dash")} nothing was written\n`);
967
+ return { code: 130, ok: false, result: null };
826
968
  }
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);
969
+ if (a.nothing) {
970
+ write(`${head}\n No change ${term.icon(c, "dash")} nothing was written\n`);
971
+ return { code: 0, ok: true, result: { settings: publicSettings, plan: [], wrote: [], removed: [] } };
833
972
  }
834
- resolved = { changes };
973
+ write(head);
974
+ const r = resolveAll(a.sets);
975
+ if (r.usage) return { code: 2, usage: r.usage };
976
+ resolved = r;
977
+ confirmed = !a.dryRun;
978
+ // What the review showed is what APPLY runs: the plan is not made again
979
+ // after its confirmation.
980
+ reviewed = a.planned || null;
835
981
  }
836
982
 
837
- const planned = planChanges(f, resolved.changes, { env, home, root, ownEnv: ctx.ownEnv || (() => env) });
983
+ const planned = reviewed || planChanges(f, resolved.changes, { env, home, root, ownEnv: ctx.ownEnv || (() => env) });
838
984
  if (planned.usage) return { code: 2, usage: planned.usage };
839
985
  const planOut = (items) =>
840
986
  items.map((i) => ({
@@ -844,6 +990,8 @@ export async function runConfigure(ctx) {
844
990
  before: i.before,
845
991
  after: i.after,
846
992
  team: i.team,
993
+ // Contract 3 as amended 2026-10-13: the off-list note only.
994
+ ...(i.listNote ? { note: i.listNote } : {}),
847
995
  }));
848
996
  if (planned.refusal !== undefined)
849
997
  return {
@@ -862,7 +1010,7 @@ export async function runConfigure(ctx) {
862
1010
  write("\n Nothing written: this was a dry run.\n");
863
1011
  return { code: 0, ok: true, result: { ...result, dry_run: true } };
864
1012
  }
865
- if (interactive) {
1013
+ if (interactive && !confirmed) {
866
1014
  if (stdin && !ask) await term.drainInput(stdin, { ticks: ctx.drainTicks ?? 2 });
867
1015
  write("\n");
868
1016
  const yes = await term.askApply(planned.items.length, { stdin, stdout, ask, caps: c });