projectstore-claude 0.30.0 → 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.
@@ -0,0 +1,919 @@
1
+ // projectstore configure — the way back to a project's settings after setup
2
+ // (the spec "Changing a project's settings after setup: the configure verb,
3
+ // its setting set and one writer per key"). It reads the settings from the
4
+ // three places they live — the binding, the harness overlays, the vault's
5
+ // config — shows them, and changes the ones named through each key's one
6
+ // writer: one PLAN, one confirmation, APPLY, DONE. It never registers,
7
+ // fetches or moves the layout, so nothing it does defers inside a session
8
+ // (contract 10), and it calls the planners directly, never runVerb.
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { relative, isAbsolute } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import * as term from "./term.mjs";
13
+ import {
14
+ loadHarness,
15
+ harnessIds,
16
+ detectHarnesses,
17
+ hasBand,
18
+ hasStatusLine,
19
+ configPath as bindingPathOf,
20
+ packageCommand,
21
+ } from "./harness.mjs";
22
+ import {
23
+ readOverlayAt,
24
+ resolveAgentModel,
25
+ layoutRoster,
26
+ readVaultConfigStrict,
27
+ nextVaultConfig,
28
+ writeVaultConfig,
29
+ vaultConfigPath,
30
+ fromTerminalIn,
31
+ commandForm,
32
+ } from "./lib.mjs";
33
+ import { planBindingKeys, applyBindingKeys, languageNames } from "./binding.mjs";
34
+ import { bandKeys, epicIds, planBand, applyBand } from "./band.mjs";
35
+ import { planAgentsChange, applyAgentsChange } from "./agents-change.mjs";
36
+ import { plan as planInstall, apply as applyInstall, planOptions, isInteractive } from "./install-harness.mjs";
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";
41
+
42
+ const CLEAR = "-";
43
+
44
+ const LANGUAGE_NOTE =
45
+ "new artifacts, the derived views on their next reconcile, the plan and close sections of stories still in flight, the session header and the status line take the new language; existing artifacts and the vault's READMEs are untouched";
46
+ const STAMP_NOTE = "stories closed before now are exempt from the spec and lifecycle gates";
47
+
48
+ const parseBool = (v) => {
49
+ const s = String(v).trim().toLowerCase();
50
+ if (s === "on" || s === "true") return true;
51
+ if (s === "off" || s === "false") return false;
52
+ return null;
53
+ };
54
+ const onOff = (b) => (b === true ? "on" : b === false ? "off" : "not set");
55
+ const shown = (v) =>
56
+ v === null || v === undefined || v === "" ? "—" : Array.isArray(v) ? v.join(", ") || "—" : String(v);
57
+ const tildeOf = (p, home) =>
58
+ home && (p === home || String(p).startsWith(home + "/")) ? "~" + String(p).slice(home.length) : String(p);
59
+
60
+ // ─── The facts ─────────────────────────────────────────────────────────
61
+
62
+ // What the project holds: the binding (absent and corrupt are different
63
+ // answers — exit 3 and exit 1, contract 11), the harnesses it uses, the
64
+ // vault and its config, the roster and the languages.
65
+ export function readFacts(project, { env = process.env, root, home = homedir() } = {}) {
66
+ const path = bindingPathOf(project, env);
67
+ if (!existsSync(path)) return { unbound: true, path };
68
+ let cfg = null;
69
+ try {
70
+ cfg = JSON.parse(readFileSync(path, "utf8"));
71
+ } catch {}
72
+ if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) return { corrupt: true, path };
73
+ const used = detectHarnesses(project, { state: true }).map((d) => loadHarness(d.id));
74
+ const vault = typeof cfg.vault_path === "string" && cfg.vault_path ? cfg.vault_path : null;
75
+ const vaultExists = Boolean(vault && existsSync(vault));
76
+ return {
77
+ project,
78
+ home,
79
+ path,
80
+ cfg,
81
+ used,
82
+ vault,
83
+ vaultExists,
84
+ vaultCfg: vaultExists ? readVaultConfigStrict(vault) : null,
85
+ roster: layoutRoster(cfg, root),
86
+ languages: languageNames(root),
87
+ epics: epicIds(cfg, root),
88
+ slot: used.find(hasStatusLine) || null,
89
+ band: used.find(hasBand) || null,
90
+ };
91
+ }
92
+
93
+ // A path as the view prints it: inside the project relative to it, else
94
+ // absolute with the home as ~.
95
+ function where(f, p) {
96
+ const rel = relative(f.project, p);
97
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : tildeOf(p, f.home);
98
+ }
99
+
100
+ // ─── The setting set (contract 2) ──────────────────────────────────────
101
+
102
+ // Every shown setting, in the view's groups. `rail` is the select a terminal
103
+ // answers it with (null: --set only); `writer` names its one writer.
104
+ export function settingsOf(f) {
105
+ const out = [];
106
+ const binding = where(f, f.path);
107
+ for (const h of f.used) {
108
+ const o = readOverlayAt(f.project, h.id);
109
+ const file = where(f, o.path);
110
+ const base = { group: `agents:${h.id}`, harness: h.id, file, class: "changeable", team: true, writer: "overlay" };
111
+ out.push({
112
+ ...base,
113
+ key: "agents.default",
114
+ value: o.agents.default || null,
115
+ source: o.unparseable ? "unparseable" : o.agents.default ? "overlay" : "frontmatter",
116
+ rail: "model",
117
+ });
118
+ const names = [...new Set([...(f.roster || []), ...Object.keys(o.agents.per_agent)])];
119
+ for (const n of names) {
120
+ const r = resolveAgentModel(f.project, n, { harness: h.id });
121
+ out.push({
122
+ ...base,
123
+ key: `agents.${n}`,
124
+ value: r.model,
125
+ source: o.unparseable ? "unparseable" : r.source || "frontmatter",
126
+ rail: "model",
127
+ });
128
+ }
129
+ }
130
+ if (f.slot)
131
+ out.push({
132
+ group: "surfaces",
133
+ key: "statusline",
134
+ value: onOff(typeof f.cfg.statusline?.enabled === "boolean" ? f.cfg.statusline.enabled : null),
135
+ source: typeof f.cfg.statusline?.enabled === "boolean" ? "binding" : "default",
136
+ file: binding,
137
+ class: "changeable",
138
+ team: false,
139
+ writer: "statusline",
140
+ rail: "bool",
141
+ });
142
+ if (f.band) {
143
+ const b = bandKeys(f.cfg);
144
+ const set = f.cfg.band && typeof f.cfg.band === "object";
145
+ out.push(
146
+ {
147
+ group: "surfaces",
148
+ key: "band",
149
+ value: onOff(b.enabled),
150
+ source: set && typeof f.cfg.band.enabled === "boolean" ? "binding" : "default",
151
+ file: binding,
152
+ class: "changeable",
153
+ team: false,
154
+ writer: "band",
155
+ rail: "bool",
156
+ },
157
+ {
158
+ group: "surfaces",
159
+ key: "band.epic",
160
+ value: b.epic,
161
+ source: b.epic ? "binding" : "default",
162
+ file: binding,
163
+ class: "changeable",
164
+ team: false,
165
+ writer: "band",
166
+ rail: f.epics.length ? "epic" : null,
167
+ },
168
+ );
169
+ }
170
+ const plain = (key, value, rail, dflt) => ({
171
+ group: "vault",
172
+ key,
173
+ value,
174
+ source: Object.hasOwn(f.cfg, key) ? "binding" : "default",
175
+ file: binding,
176
+ class: "changeable",
177
+ team: false,
178
+ writer: "binding",
179
+ rail,
180
+ dflt,
181
+ });
182
+ out.push(
183
+ {
184
+ group: "vault",
185
+ key: "vault",
186
+ value: f.vault ? tildeOf(f.vault, f.home) : null,
187
+ source: "binding",
188
+ file: binding,
189
+ class: "other-verb",
190
+ team: false,
191
+ writer: null,
192
+ rail: null,
193
+ },
194
+ {
195
+ group: "vault",
196
+ key: "layout",
197
+ value: f.cfg.layout || null,
198
+ source: "binding",
199
+ file: binding,
200
+ class: "other-verb",
201
+ team: false,
202
+ writer: null,
203
+ rail: null,
204
+ },
205
+ plain("language", f.cfg.language || "en", "language"),
206
+ plain("active_skills", onOff(f.cfg.active_skills !== false), "bool"),
207
+ plain("auto_inject", onOff(f.cfg.auto_inject !== false), "bool"),
208
+ plain("default_author", f.cfg.default_author || null, null),
209
+ plain("tags", Array.isArray(f.cfg.tags) ? f.cfg.tags : [], null),
210
+ );
211
+ if (f.vault) {
212
+ const v = f.vaultCfg && !f.vaultCfg.corrupt ? f.vaultCfg.cfg : {};
213
+ const file = tildeOf(vaultConfigPath(f.vault), f.home);
214
+ const pol = (key, value, rail) => ({
215
+ group: "policy",
216
+ key,
217
+ value,
218
+ source: f.vaultCfg?.corrupt ? "unparseable" : Object.hasOwn(v, key) ? "vault_config" : "default",
219
+ file,
220
+ class: "changeable",
221
+ team: true,
222
+ writer: "vault",
223
+ rail,
224
+ });
225
+ out.push(
226
+ pol("spec_policy", v.spec_policy || "optional", "policy"),
227
+ pol("lifecycle_gates", Object.hasOwn(v, "lifecycle_gates") ? String(v.lifecycle_gates) : "not set", "bool"),
228
+ );
229
+ }
230
+ for (const s of out) s.set = setForm(s, f);
231
+ return out;
232
+ }
233
+
234
+ // The --set form a key is changed with, for the view and the envelope.
235
+ function setForm(s, f) {
236
+ if (s.class !== "changeable") return null;
237
+ const value =
238
+ s.rail === "bool"
239
+ ? "on|off"
240
+ : s.key === "tags"
241
+ ? "<a,b>"
242
+ : s.key.startsWith("agents.")
243
+ ? "<model>"
244
+ : s.key === "band.epic"
245
+ ? "<epic>"
246
+ : `<${s.key}>`;
247
+ const h = s.harness && f.used.length > 1 ? ` --harness ${s.harness}` : "";
248
+ return `--set ${s.key}=${value}${h}`;
249
+ }
250
+
251
+ // ─── Reading --set (contract 11's usage class) ─────────────────────────
252
+
253
+ // Each named change resolved against the set: its key, its harness (for an
254
+ // agents key), its value as the writer takes it. The first problem is a
255
+ // usage error (exit 2), and nothing is planned.
256
+ export function resolveSets(f, sets, { harness = null, nearest = null } = {}) {
257
+ const seen = new Set();
258
+ const out = [];
259
+ const keys = settingsOf(f);
260
+ const known = [...new Set(keys.map((s) => s.key))];
261
+ const harnessesNamed = f.used.map((h) => h.display_name || h.id).join(", ") || "none";
262
+ for (const raw of sets) {
263
+ const at = String(raw).indexOf("=");
264
+ if (at < 1) return { usage: `--set takes <key>=<value>, got ${raw}` };
265
+ let key = String(raw).slice(0, at).trim();
266
+ const value = String(raw)
267
+ .slice(at + 1)
268
+ .trim();
269
+ const m = /^agents\.([a-z][a-z0-9-]*)(?:\.model)?$/.exec(key);
270
+ if (m) key = `agents.${m[1]}`;
271
+ if (seen.has(key)) return { usage: `${key} is named twice` };
272
+ seen.add(key);
273
+ if (m) {
274
+ const name = m[1];
275
+ if (name !== "default" && f.roster && !f.roster.includes(name))
276
+ return { usage: `no agent named ${name} in the ${f.cfg.layout} roster — known: ${f.roster.join(", ")}` };
277
+ let h = harness;
278
+ if (!h) {
279
+ if (f.used.length === 1) h = f.used[0].id;
280
+ else return { usage: `name the overlay: --harness <id> (this project uses ${harnessesNamed})` };
281
+ }
282
+ if (!harnessIds().includes(h)) return { usage: `unknown harness: ${h} — known: ${harnessIds().join(", ")}` };
283
+ if (!value) return { usage: `${key} takes a model, or ${CLEAR} to clear it` };
284
+ out.push({ key, writer: "overlay", harness: h, name, value: value === CLEAR ? null : value });
285
+ continue;
286
+ }
287
+ if (key === "vault")
288
+ return {
289
+ usage: `the vault is changed by rebind, ${fromTerminalIn(f.project)}:\n${packageCommand([], "bind", { args: "<vault> --rebind" })}`,
290
+ };
291
+ if (key === "layout") return { usage: "the layout is a vault's: changing it means a new vault" };
292
+ const s = keys.find((x) => x.key === key);
293
+ if (!s) {
294
+ if (["statusline", "band", "band.epic"].includes(key))
295
+ return {
296
+ usage: `${key} is a surface of ${[...harnessIds()].map((id) => loadHarness(id)).find((x) => (key === "statusline" ? hasStatusLine(x) : hasBand(x)))?.display_name || "another harness"}; this project uses ${harnessesNamed}`,
297
+ };
298
+ if (["spec_policy", "lifecycle_gates"].includes(key))
299
+ return { usage: `${key} lives in the vault's config, and this binding names no vault` };
300
+ const hint = nearest ? nearest(key, known) : null;
301
+ return { usage: `unknown setting ${key}${hint ? ` — did you mean ${hint}?` : ""}` };
302
+ }
303
+ const bad = (set) => ({ usage: `${key} takes ${set}, got ${value || "nothing"}` });
304
+ if (s.rail === "bool") {
305
+ const b = parseBool(value);
306
+ if (b === null) return bad("on or off");
307
+ out.push({ key, writer: s.writer, value: key === "lifecycle_gates" ? (b ? "on" : "off") : b });
308
+ } else if (key === "language") {
309
+ if (!f.languages.includes(value)) return bad(f.languages.join(", "));
310
+ out.push({ key, writer: "binding", value });
311
+ } else if (key === "spec_policy") {
312
+ if (!["optional", "required"].includes(value)) return bad("optional or required");
313
+ out.push({ key, writer: "vault", value });
314
+ } else if (key === "default_author") {
315
+ if (!value) return bad("a name");
316
+ out.push({ key, writer: "binding", value });
317
+ } else if (key === "tags") {
318
+ const tags =
319
+ value === CLEAR
320
+ ? []
321
+ : value
322
+ .split(",")
323
+ .map((t) => t.trim())
324
+ .filter(Boolean);
325
+ out.push({ key, writer: "binding", value: tags });
326
+ } else if (key === "band.epic") {
327
+ if (!value) return bad(`an epic id, or ${CLEAR} to release the pin`);
328
+ if (value !== CLEAR) {
329
+ const hit = f.epics.find((id) => id.toLowerCase() === value.toLowerCase());
330
+ if (!hit) return bad(f.epics.length ? f.epics.join(", ") : "an epic id, and the vault has no epics");
331
+ out.push({ key, writer: "band", value: hit });
332
+ } else out.push({ key, writer: "band", value: null });
333
+ }
334
+ }
335
+ return { changes: out };
336
+ }
337
+
338
+ // ─── The plan ──────────────────────────────────────────────────────────
339
+
340
+ // One PLAN over the writers. Refusals here are contract 11's exit-1 class:
341
+ // nothing is written. `items` are the rows a person reads; `steps` what APPLY
342
+ // runs, in the contract's order.
343
+ export function planChanges(f, changes, { env = process.env, home = homedir(), root, ownEnv = () => env } = {}) {
344
+ const items = [];
345
+ const steps = [];
346
+ const refuse = (message) => ({ refusal: message });
347
+ const byWriter = (w) => changes.filter((c) => c.writer === w);
348
+
349
+ // The binding's plain keys.
350
+ const effective = (k) =>
351
+ k === "language"
352
+ ? (f.cfg.language ?? "en")
353
+ : k === "active_skills" || k === "auto_inject"
354
+ ? f.cfg[k] !== false
355
+ : k === "tags"
356
+ ? (f.cfg.tags ?? [])
357
+ : f.cfg[k];
358
+ const plainKeys = byWriter("binding").filter((c) => JSON.stringify(effective(c.key)) !== JSON.stringify(c.value));
359
+ if (plainKeys.length) {
360
+ const p = planBindingKeys(f.project, Object.fromEntries(plainKeys.map((c) => [c.key, c.value])), { env });
361
+ if (p.refusal) return refuse(p.refusal.message);
362
+ for (const k of p.keys)
363
+ items.push({
364
+ key: k,
365
+ file: where(f, p.path),
366
+ before: shown(
367
+ k === "active_skills" || k === "auto_inject"
368
+ ? onOff(p.before[k] !== false)
369
+ : k === "language"
370
+ ? (p.before[k] ?? "en")
371
+ : p.before[k],
372
+ ),
373
+ after: shown(k === "active_skills" || k === "auto_inject" ? onOff(p.after[k]) : p.after[k]),
374
+ team: false,
375
+ note: k === "language" ? LANGUAGE_NOTE : null,
376
+ });
377
+ if (p.changed) steps.push({ writer: "binding", plan: p, keys: p.keys });
378
+ }
379
+
380
+ // The band: planned again at APPLY for each change, so its writes and the
381
+ // binding's never overwrite one another; decided here on values, not on
382
+ // planBand's `changed`, which also flips on the hint it records.
383
+ const band = byWriter("band");
384
+ if (band.length) {
385
+ const now = bandKeys(f.cfg);
386
+ for (const c of band) {
387
+ const before = c.key === "band" ? now.enabled : now.epic;
388
+ if (before === c.value) continue;
389
+ const sub = c.key === "band" ? (c.value ? "on" : "off") : "epic";
390
+ items.push({
391
+ key: c.key,
392
+ file: where(f, f.path),
393
+ before: c.key === "band" ? onOff(before) : shown(before),
394
+ after: c.key === "band" ? onOff(c.value) : shown(c.value),
395
+ team: false,
396
+ });
397
+ steps.push({ writer: "band", sub, arg: c.key === "band.epic" ? c.value : null, key: c.key });
398
+ }
399
+ }
400
+
401
+ // The vault's config: its folder must exist (writeFileAtomic makes none),
402
+ // and a file that does not parse is never replaced.
403
+ const policy = byWriter("vault");
404
+ if (policy.length) {
405
+ if (!f.vault || !f.vaultExists)
406
+ return refuse(
407
+ `the vault ${f.vault ? tildeOf(f.vault, f.home) : "(none)"} does not exist — rebind or restore it first`,
408
+ );
409
+ const strict = readVaultConfigStrict(f.vault);
410
+ if (strict.corrupt) return refuse(`${strict.path} is not valid JSON — fix or remove it first`);
411
+ const patch = Object.fromEntries(policy.map((c) => [c.key, c.value]));
412
+ const next = nextVaultConfig(strict.cfg, patch, new Date().toISOString());
413
+ const inEffect = (k) => strict.cfg[k] ?? (k === "spec_policy" ? "optional" : undefined);
414
+ const changed = policy.filter((c) => inEffect(c.key) !== c.value);
415
+ for (const c of changed)
416
+ items.push({
417
+ key: c.key,
418
+ file: tildeOf(strict.path, f.home),
419
+ before: shown(strict.cfg[c.key] ?? (c.key === "spec_policy" ? "optional" : "not set")),
420
+ after: shown(c.value),
421
+ team: true,
422
+ note: c.key === "spec_policy" && next.spec_policy_since !== strict.cfg.spec_policy_since ? STAMP_NOTE : null,
423
+ });
424
+ if (changed.length) steps.push({ writer: "vault", patch, keys: changed.map((c) => c.key) });
425
+ }
426
+
427
+ // The overlays: one agents change per harness, through the core
428
+ // `agents configure` uses, so the clerk pin and the precedence agree.
429
+ const byHarness = new Map();
430
+ for (const c of byWriter("overlay")) {
431
+ if (!byHarness.has(c.harness)) byHarness.set(c.harness, []);
432
+ byHarness.get(c.harness).push(c);
433
+ }
434
+ for (const [h, list] of byHarness) {
435
+ const before = readOverlayAt(f.project, h);
436
+ 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");
438
+ const change = planAgentsChange({
439
+ before,
440
+ harness: h,
441
+ layout: f.cfg.layout,
442
+ roster: f.roster,
443
+ defaultModel: def ? def.value || "" : undefined,
444
+ agents: list.filter((c) => c.name !== "default").map((c) => `${c.name}=${c.value || ""}`),
445
+ });
446
+ if (change.usage) return { usage: change.usage };
447
+ if (change.same) continue;
448
+ const file = where(f, before.path);
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);
460
+ items.push({
461
+ key: "agents.default",
462
+ harness: h,
463
+ display,
464
+ file,
465
+ before: shown(before.agents.default),
466
+ after: shown(change.next.default),
467
+ team: true,
468
+ note: listNote,
469
+ listNote,
470
+ });
471
+ }
472
+ const names = new Set([...Object.keys(before.agents.per_agent), ...Object.keys(change.next.per_agent)]);
473
+ for (const n of [...names].sort()) {
474
+ const a = before.agents.per_agent[n] || null;
475
+ const b = change.next.per_agent[n] || null;
476
+ if (a === b) continue;
477
+ const listNote = offList(n, b);
478
+ items.push({
479
+ key: `agents.${n}`,
480
+ harness: h,
481
+ display,
482
+ file,
483
+ before: shown(a),
484
+ after: shown(b),
485
+ team: true,
486
+ note: notes(
487
+ change.pinnedClerk && n === "clerk" ? "pinned: the clerk stays cheap under a strong default" : null,
488
+ listNote,
489
+ ),
490
+ listNote,
491
+ });
492
+ }
493
+ if (change.clerkPinUnavailable)
494
+ items.push({
495
+ key: "agents.clerk",
496
+ harness: h,
497
+ display,
498
+ file,
499
+ before: shown(before.agents.per_agent.clerk),
500
+ after: "the default",
501
+ team: true,
502
+ note: `${display} declares no cheap model to pin the clerk to; set agents.clerk to name one`,
503
+ });
504
+ steps.push({ writer: "overlay", harness: h, next: change.next });
505
+ }
506
+
507
+ // The status line: install's own plan for the surface, with its flag item
508
+ // (contract 6). A pending layout move, a foreign slot and a root that is
509
+ // never wired are refusals; configure plans none of them.
510
+ const sl = byWriter("statusline")[0];
511
+ if (sl) {
512
+ const m = f.slot;
513
+ const current = typeof f.cfg.statusline?.enabled === "boolean" ? f.cfg.statusline.enabled : null;
514
+ if (analyseLayout(f.project, { harness: m }).pending)
515
+ return refuse(
516
+ [
517
+ `${f.project} still uses the layout from before 0.28; ${fromTerminalIn(f.project)}, move it first:`,
518
+ packageCommand([m], "upgrade"),
519
+ ].join("\n"),
520
+ );
521
+ const p = planInstall(
522
+ f.project,
523
+ planOptions({
524
+ harnesses: [m.id],
525
+ mode: sl.value ? "install" : "uninstall",
526
+ surfaces: ["statusline"],
527
+ root,
528
+ env: ownEnv(f.project),
529
+ home,
530
+ }),
531
+ );
532
+ if (!p.ok) {
533
+ // A refusal can be a row of the plan (an unparseable settings file, a
534
+ // launcher that is not ours) as well as a plan-level one.
535
+ const why = [
536
+ ...p.refusals.map((r) => r.message || r.reason || String(r)),
537
+ ...p.items
538
+ .filter((i) => i.action === "refuse")
539
+ .map((i) => `${typeof i.path === "string" ? where(f, i.path) : i.surface}: ${i.reason}`),
540
+ ].filter(Boolean);
541
+ return refuse(why.join("\n") || "the status line's plan refused");
542
+ }
543
+ const row = p.items.find((i) => i.surface === "statusline");
544
+ if (sl.value && row && row.state === "theirs")
545
+ return refuse(
546
+ `a status line ${m.display_name || m.id} did not get from us owns the slot in ${where(f, row.path)} — clear it first`,
547
+ );
548
+ if (sl.value && row && row.action === "skip" && row.state !== "ours-current")
549
+ return refuse(
550
+ [
551
+ // A copy run from a package manager's cache has no install path of its
552
+ // own to wire the line against. The copy a session runs has one: the
553
+ // in-session command reaches this same writer from it, whichever
554
+ // channel installed it; the package's registration is the other way.
555
+ `the status line cannot be wired from this copy: ${String(row.reason).replace(/ \(see the registration above\)$/, "")}. In a session in this project:`,
556
+ commandForm("statusline", { args: "on", env }),
557
+ `Or register the package ${fromTerminalIn(f.project)}:`,
558
+ packageCommand([], "setup"),
559
+ ].join("\n"),
560
+ );
561
+ const writes = p.items.filter((i) => i.action !== "skip");
562
+ if (writes.length || current !== sl.value) {
563
+ items.push({
564
+ key: "statusline",
565
+ file: writes
566
+ .map((i) => i.path)
567
+ .filter((x) => typeof x === "string")
568
+ .map((x) => where(f, x))
569
+ .join(", "),
570
+ before: onOff(current),
571
+ after: onOff(sl.value),
572
+ team: false,
573
+ });
574
+ const wires = writes.some((i) => i.surface !== "statusline_flag");
575
+ steps.push({ writer: "statusline", plan: p, restart: wires ? m.display_name || m.id : null });
576
+ }
577
+ }
578
+ return { items, steps };
579
+ }
580
+
581
+ // Runs the steps in the plan's order. An I/O failure stops the run; what was
582
+ // written and what was not are both named.
583
+ export function applyChanges(f, steps, { env = process.env, home = homedir(), root } = {}) {
584
+ const wrote = [];
585
+ const removed = [];
586
+ for (let k = 0; k < steps.length; k++) {
587
+ const s = steps[k];
588
+ try {
589
+ if (s.writer === "binding") {
590
+ applyBindingKeys(s.plan);
591
+ wrote.push(s.plan.path);
592
+ } else if (s.writer === "band") {
593
+ const p = planBand(f.project, s.sub, s.arg, { env, root });
594
+ if (p.refusal) throw new Error(p.refusal.message);
595
+ applyBand(p);
596
+ wrote.push(p.path);
597
+ } else if (s.writer === "vault") {
598
+ // Read again, as the binding's writer does: the vault config is shared
599
+ // by every checkout, and a change made while the question waited is kept.
600
+ const now = readVaultConfigStrict(f.vault);
601
+ if (now.corrupt) throw new Error(`${now.path} stopped being valid JSON after the plan`);
602
+ writeVaultConfig(f.vault, nextVaultConfig(now.cfg, s.patch, new Date().toISOString()));
603
+ wrote.push(vaultConfigPath(f.vault));
604
+ } else if (s.writer === "overlay") {
605
+ wrote.push(applyAgentsChange(f.project, s.harness, s.next).path);
606
+ } else if (s.writer === "statusline") {
607
+ const finished = [];
608
+ try {
609
+ const done = applyInstall(s.plan, {
610
+ env,
611
+ home,
612
+ onItem: (i, phase, rec) => {
613
+ if (phase === "end" && !rec?.failed && typeof i.path === "string")
614
+ (["remove", "prune"].includes(i.action) ? removed : finished).push(i.path);
615
+ },
616
+ });
617
+ const bad = (done || []).find((r) => r && r.failed);
618
+ if (bad) throw new Error(bad.failed.stderr || bad.failed.message || `${bad.surface} failed`);
619
+ } finally {
620
+ wrote.push(...finished);
621
+ }
622
+ }
623
+ } catch (e) {
624
+ return {
625
+ wrote: [...new Set(wrote)],
626
+ removed,
627
+ failed: { step: s.writer, message: e?.message || String(e) },
628
+ left: steps.slice(k),
629
+ };
630
+ }
631
+ }
632
+ return { wrote: [...new Set(wrote)], removed, failed: null, left: [] };
633
+ }
634
+
635
+ // ─── Rendering ─────────────────────────────────────────────────────────
636
+
637
+ // What a source token reads as in the view.
638
+ const SOURCE_TEXT = Object.freeze({
639
+ overlay: "overlay",
640
+ per_agent: "per agent",
641
+ default: "default",
642
+ frontmatter: "its frontmatter",
643
+ binding: "binding",
644
+ vault_config: "vault config",
645
+ unparseable: "not valid JSON: fix the file to change it",
646
+ });
647
+
648
+ const GROUP_TITLES = (f) => ({
649
+ surfaces: "Status line and band",
650
+ vault: "Vault and templates",
651
+ policy: "Policy",
652
+ ...Object.fromEntries(f.used.map((h) => [`agents:${h.id}`, `Agents · ${h.display_name || h.id}`])),
653
+ });
654
+
655
+ // The view (contract 3): the settings by group, each with its value and
656
+ // where it comes from; then the commands that change them, each alone.
657
+ export function renderView(f, settings, { caps: c = term.PLAIN } = {}) {
658
+ const titles = GROUP_TITLES(f);
659
+ const lines = [term.badge(c, "projectstore configure"), ` ${tildeOf(f.project, f.home)}`];
660
+ const groups = [...new Set(settings.map((s) => s.group))];
661
+ for (const g of groups) {
662
+ const list = settings.filter((s) => s.group === g);
663
+ const files = [...new Set(list.map((s) => s.file))];
664
+ const team = list.some((s) => s.team);
665
+ lines.push(
666
+ "",
667
+ `${term.heading(c, titles[g] || g)} ${term.painter(c)("explanation", files.join(", ") + (team ? " (committed)" : ""))}`,
668
+ );
669
+ lines.push(
670
+ ...term.rows(
671
+ c,
672
+ list.map((s) => ({
673
+ action: s.key,
674
+ target: shown(s.value),
675
+ note:
676
+ s.class === "other-verb"
677
+ ? "changed by another command, below"
678
+ : s.class === "changeable" && !s.rail
679
+ ? `${SOURCE_TEXT[s.source] || s.source}; change it with ${s.set}`
680
+ : SOURCE_TEXT[s.source] || s.source,
681
+ })),
682
+ ),
683
+ );
684
+ }
685
+ lines.push(
686
+ "",
687
+ ` To change a setting ${fromTerminalIn(f.project)}:`,
688
+ ...term.commandBlock(c, [
689
+ packageCommand([], "configure"),
690
+ packageCommand([], "configure", { args: "--set <key>=<value>" }),
691
+ ]),
692
+ "",
693
+ " The vault is changed by rebind, and a new vault by init:",
694
+ ...term.commandBlock(c, [
695
+ packageCommand([], "bind", { args: "<vault> --rebind" }),
696
+ packageCommand([], "init", { args: "<vault> --rebind" }),
697
+ ]),
698
+ );
699
+ return lines.join("\n") + "\n";
700
+ }
701
+
702
+ // The PLAN: one row per changed key, its file, and "team change" for a
703
+ // committed file; a note beneath where the change reaches further.
704
+ export function renderPlan(f, items, { caps: c = term.PLAIN } = {}) {
705
+ const n = items.length;
706
+ const lines = ["", term.heading(c, "Plan", [[`${n} ${n === 1 ? "change" : "changes"}`, "action"]]), ""];
707
+ const twoHarnesses = f.used.length > 1;
708
+ const rowsOf = term.rowLines(
709
+ c,
710
+ items.map((i) => ({
711
+ glyph: "update",
712
+ role: "action",
713
+ action: twoHarnesses && i.display ? `${i.display} · ${i.key}` : i.key,
714
+ target: `${i.before} ${term.icon(c, "transition")} ${i.after}`,
715
+ note: `${i.file}${i.team ? " · team change" : ""}`,
716
+ })),
717
+ );
718
+ const paint = term.painter(c);
719
+ items.forEach((i, k) => {
720
+ lines.push(...rowsOf[k]);
721
+ if (i.note) {
722
+ const width = c.live ? Math.min(Number(c.columns) || 100, 100) - 1 : 0;
723
+ lines.push(paint("explanation", " " + term.wrap(i.note, width, " ")));
724
+ }
725
+ });
726
+ return lines.join("\n") + "\n";
727
+ }
728
+
729
+ // ─── The run ───────────────────────────────────────────────────────────
730
+
731
+ // ctx: { values, project, env, stdin, stdout, ask, root, home, ownEnv,
732
+ // nearest, proc, escapeMs, drainTicks, now }. Returns { code, ok, result }
733
+ // or { code: 2, usage } or { code: 3, unbound: true }; the bin writes the
734
+ // envelope and the usage line.
735
+ export async function runConfigure(ctx) {
736
+ const { values = {}, env = process.env, stdin = null, stdout = null, ask = null } = ctx;
737
+ const project = ctx.project;
738
+ const root = ctx.root;
739
+ const home = ctx.home || homedir();
740
+ const now = ctx.now || Date.now;
741
+ const json = Boolean(values.json);
742
+ const dryRun = Boolean(values["dry-run"]);
743
+ const sets = values.set || [];
744
+ const named = (values.harness || []).filter(Boolean);
745
+ if (named.length > 1) return { code: 2, usage: "--harness names exactly one overlay; given twice" };
746
+ const write = (text) => {
747
+ if (!json && stdout && text) stdout.write(text);
748
+ };
749
+ const c = stdout ? term.caps(stdout, env) : term.PLAIN;
750
+
751
+ const f = readFacts(project, { env, root, home });
752
+ if (f.unbound) return { code: 3, unbound: true };
753
+ if (f.corrupt) {
754
+ const message = `${f.path} is not valid JSON — fix or remove it first; nothing is written`;
755
+ return { code: 1, ok: false, message, result: { error: message } };
756
+ }
757
+ const settings = settingsOf(f);
758
+ const publicSettings = settings.map((s) => ({
759
+ key: s.key,
760
+ value: s.value,
761
+ source: s.source,
762
+ file: s.file,
763
+ class: s.class,
764
+ harness: s.harness || null,
765
+ team: s.team,
766
+ set: s.set,
767
+ }));
768
+ const interactive =
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;
774
+
775
+ // Nothing named and no terminal: the view, and nothing written.
776
+ if (!sets.length && (!interactive || !fits)) {
777
+ write(renderView(f, settings, { caps: c }));
778
+ return { code: 0, ok: true, result: { settings: publicSettings, plan: [], wrote: [], removed: [] } };
779
+ }
780
+
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;
785
+ if (sets.length) {
786
+ resolved = resolveSets(f, sets, { harness: named[0] || null, nearest: ctx.nearest });
787
+ if (resolved.usage) return { code: 2, usage: resolved.usage };
788
+ write(`${term.badge(c, "projectstore configure")}\n ${tildeOf(f.project, f.home)}\n`);
789
+ } else {
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 };
831
+ }
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: [] } };
835
+ }
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;
844
+ }
845
+
846
+ const planned = reviewed || planChanges(f, resolved.changes, { env, home, root, ownEnv: ctx.ownEnv || (() => env) });
847
+ if (planned.usage) return { code: 2, usage: planned.usage };
848
+ const planOut = (items) =>
849
+ items.map((i) => ({
850
+ key: i.key,
851
+ harness: i.harness || null,
852
+ file: i.file,
853
+ before: i.before,
854
+ after: i.after,
855
+ team: i.team,
856
+ // Contract 3 as amended 2026-10-13: the off-list note only.
857
+ ...(i.listNote ? { note: i.listNote } : {}),
858
+ }));
859
+ if (planned.refusal !== undefined)
860
+ return {
861
+ code: 1,
862
+ ok: false,
863
+ message: planned.refusal,
864
+ result: { settings: publicSettings, error: planned.refusal, plan: [], wrote: [], removed: [] },
865
+ };
866
+ const result = { settings: publicSettings, plan: planOut(planned.items), wrote: [], removed: [] };
867
+ if (!planned.items.length) {
868
+ write("\n Nothing to change.\n");
869
+ return { code: 0, ok: true, result };
870
+ }
871
+ write(renderPlan(f, planned.items, { caps: c }));
872
+ if (dryRun) {
873
+ write("\n Nothing written: this was a dry run.\n");
874
+ return { code: 0, ok: true, result: { ...result, dry_run: true } };
875
+ }
876
+ if (interactive && !confirmed) {
877
+ if (stdin && !ask) await term.drainInput(stdin, { ticks: ctx.drainTicks ?? 2 });
878
+ write("\n");
879
+ const yes = await term.askApply(planned.items.length, { stdin, stdout, ask, caps: c });
880
+ if (!yes) {
881
+ write(" Nothing written.\n");
882
+ return { code: 1, ok: false, result: { ...result, declined: true } };
883
+ }
884
+ }
885
+ const t0 = now();
886
+ const r = applyChanges(f, planned.steps, { env, home, root });
887
+ result.wrote = r.wrote.map((p) => where(f, p));
888
+ result.removed = r.removed.map((p) => where(f, p));
889
+ const paint = term.painter(c);
890
+ if (r.failed) {
891
+ write(
892
+ [
893
+ "",
894
+ `${paint("error", term.icon(c, "fail"))} ${r.failed.step}: ${r.failed.message}`,
895
+ ...(r.wrote.length ? [" written:", ...r.wrote.map((p) => ` ${where(f, p)}`)] : []),
896
+ ...(r.removed.length ? [" removed:", ...r.removed.map((p) => ` ${where(f, p)}`)] : []),
897
+ ` not written: ${r.left.map((s) => s.writer).join(", ")}`,
898
+ "",
899
+ ].join("\n"),
900
+ );
901
+ return { code: 1, ok: false, result: { ...result, failed: r.failed } };
902
+ }
903
+ const restart = planned.steps.find((s) => s.writer === "statusline")?.restart || null;
904
+ const n = planned.items.length;
905
+ write(
906
+ [
907
+ "",
908
+ ...result.wrote.map((p) => ` ${paint("done", term.icon(c, "ok"))} wrote ${p}`),
909
+ ...result.removed.map((p) => ` ${paint("done", term.icon(c, "ok"))} removed ${p}`),
910
+ "",
911
+ term.heading(c, "Done", [`${n} ${n === 1 ? "change" : "changes"} in ${term.duration(now() - t0)}`]),
912
+ ...(restart
913
+ ? ["", term.heading(c, "Next"), ...term.kv(c, [[restart, `restart ${restart} in this project`]])]
914
+ : []),
915
+ "",
916
+ ].join("\n"),
917
+ );
918
+ return { code: 0, ok: true, result };
919
+ }