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.
@@ -135,6 +135,46 @@ function readExisting(configPath) {
135
135
  }
136
136
  }
137
137
 
138
+ // The binding's plain settings and their one writer (the configure spec,
139
+ // contract 5). Every other key of the binding has a writer of its own: the
140
+ // vault path is rebind's, the band the band verb's, the status line's flag
141
+ // install's.
142
+ export const BINDING_SETTING_KEYS = Object.freeze([
143
+ "language",
144
+ "active_skills",
145
+ "auto_inject",
146
+ "default_author",
147
+ "tags",
148
+ ]);
149
+
150
+ // What a change to named plain keys would write: never a binding that does
151
+ // not exist (a bare file of settings would read as bound), never over one
152
+ // that does not parse.
153
+ export function planBindingKeys(projectDir, changes, { env = process.env } = {}) {
154
+ const path = harnessConfigPath(projectDir, env);
155
+ const unknown = Object.keys(changes).filter((k) => !BINDING_SETTING_KEYS.includes(k));
156
+ if (unknown.length) throw new Error(`planBindingKeys: not a binding setting: ${unknown.join(", ")}`);
157
+ const { cfg, corrupt } = readExisting(path);
158
+ if (corrupt || (cfg && (typeof cfg !== "object" || Array.isArray(cfg))))
159
+ return { path, refusal: { code: "UNREADABLE", message: `${path} is not valid JSON — fix or remove it first` } };
160
+ if (!cfg) return { path, refusal: { code: "UNBOUND", message: `${projectDir} is not bound to a vault` } };
161
+ const keys = Object.keys(changes).filter((k) => JSON.stringify(cfg[k]) !== JSON.stringify(changes[k]));
162
+ const before = Object.fromEntries(keys.map((k) => [k, cfg[k]]));
163
+ const after = Object.fromEntries(keys.map((k) => [k, changes[k]]));
164
+ return { path, keys, before, after, changed: keys.length > 0 };
165
+ }
166
+
167
+ // Applies a plan: reads the binding again and sets only the planned keys, so
168
+ // a write another writer made since the plan (the band, the flag) is kept.
169
+ export function applyBindingKeys(plan) {
170
+ if (plan.refusal || !plan.changed) return { wrote: false };
171
+ const { cfg, corrupt } = readExisting(plan.path);
172
+ if (corrupt || !cfg || typeof cfg !== "object" || Array.isArray(cfg))
173
+ throw new Error(`${plan.path} changed since the plan and no longer reads as a binding`);
174
+ writeFileAtomic(plan.path, JSON.stringify({ ...cfg, ...plan.after }, null, 2) + "\n", { sweep: false });
175
+ return { wrote: true };
176
+ }
177
+
138
178
  export const shellQuote = (s) => (/^[A-Za-z0-9_/.~-]+$/.test(s) ? s : `'${String(s).replace(/'/g, `'\\''`)}'`);
139
179
 
140
180
  // A directory with no entries at all. A `.DS_Store` makes it not empty: that
@@ -59,7 +59,6 @@ import {
59
59
  projectRootDeclared,
60
60
  childEnv,
61
61
  harnessIds,
62
- harnessForOverlay,
63
62
  pinPluginRoot,
64
63
  invokedAs,
65
64
  shellHarnessId,
@@ -72,7 +71,6 @@ import {
72
71
  readConfigAt,
73
72
  readOverlayAt,
74
73
  resolveAgentModel,
75
- writeOverlayAt,
76
74
  overlayId,
77
75
  layoutRoster,
78
76
  commandForm,
@@ -105,6 +103,7 @@ import {
105
103
  DEFAULT_LANGUAGE,
106
104
  } from "./binding.mjs";
107
105
  import { planScaffold, applyScaffold, scaffoldResult, renderScaffoldPlan } from "./scaffold.mjs";
106
+ import { planAgentsChange, applyAgentsChange } from "./agents-change.mjs";
108
107
  import {
109
108
  planBand,
110
109
  applyBand,
@@ -201,6 +200,14 @@ const UNINSTALL_OPTS = [
201
200
  JSON_OPT,
202
201
  ];
203
202
 
203
+ // configure's own --harness: it names the overlay agents.* keys write, and
204
+ // is no confirmation — naming a change is (its spec, contracts 1 and 8).
205
+ const CONFIGURE_OPTS = [
206
+ opt("set", "<key>=<value>", "change one setting; without a terminal, naming it is the confirmation", true),
207
+ opt("harness", "<id>", "the overlay agents.* keys write; other keys ignore it", true),
208
+ opt("dry-run", false, "the plan; nothing is written"),
209
+ ];
210
+
204
211
  // setup's own options (the setup story, rule 1): seven, beside the globals.
205
212
  // `--vault <path>` takes a value here where doctor's `--vault` is a flag —
206
213
  // the per-verb parse's own case.
@@ -237,6 +244,20 @@ export const VERBS = Object.freeze([
237
244
  options: [...SETUP_OPTS, JSON_OPT],
238
245
  run: runSetupVerb,
239
246
  }),
247
+ Object.freeze({
248
+ verb: "configure",
249
+ summary: "Change this project's settings after setup, behind one plan.",
250
+ usage: NO_FORMS,
251
+ module: "./configure.mjs",
252
+ wraps: "new",
253
+ how: "import",
254
+ output: "envelope",
255
+ writes: true,
256
+ requiresBinding: false,
257
+ mcp: Object.freeze([]),
258
+ options: [...CONFIGURE_OPTS, JSON_OPT],
259
+ run: runConfigureVerb,
260
+ }),
240
261
  Object.freeze({
241
262
  verb: "doctor",
242
263
  summary: "Check the install wiring and the vault's consistency.",
@@ -599,7 +620,7 @@ export const PLANNED_VERBS = Object.freeze([]);
599
620
  // setup, START is setup and uninstall, and install and bind are ADVANCED's
600
621
  // (contract 10; the setup story, rule 1).
601
622
  const HELP_GROUPS = [
602
- ["START", ["setup", "uninstall"]],
623
+ ["START", ["setup", "configure", "uninstall"]],
603
624
  ["VAULT", ["status", "search", "show", "graph", "codemap"]],
604
625
  ["CHECK AND REPAIR", ["doctor", "reconcile", "upgrade"]],
605
626
  ];
@@ -634,6 +655,12 @@ const EXAMPLES = {
634
655
  "{cmd} setup{fixed} --dry-run # the questions and the plan; nothing is written or fetched",
635
656
  "{cmd} setup{h} --vault ~/vaults/my-project # without a terminal: --harness confirms, --vault names the vault",
636
657
  ],
658
+ configure: [
659
+ "{cmd} configure # at a terminal: the settings, then what to change",
660
+ "{cmd} configure --set statusline=on",
661
+ "{cmd} configure --set agents.critic=fable{h} # an agent's model, in that harness's overlay",
662
+ "{cmd} configure --json # the settings, for scripts and agents",
663
+ ],
637
664
  install: ["{cmd} install{h}", "{cmd} plan{h} # the same plan; nothing is written"],
638
665
  upgrade: [
639
666
  "{cmd}@<version> upgrade{h} # the version you name is the version that runs",
@@ -792,7 +819,7 @@ export function usage(env = process.env, verbs = VERBS, { caps: c = term.PLAIN,
792
819
  lines.push(
793
820
  "",
794
821
  term.heading(c, "Tips"),
795
- prose("setup, install, upgrade and uninstall show a plan, then ask at a terminal."),
822
+ prose("setup, configure, install, upgrade and uninstall show a plan, then ask."),
796
823
  prose(
797
824
  `Without a terminal, ${shell ? "the verb is its own" : "--harness is the"} confirmation ${term.icon(c, "dash")} there is no --yes.`,
798
825
  ),
@@ -829,7 +856,9 @@ export function verbHelp(row, env = process.env, { caps: c = term.PLAIN } = {})
829
856
  lines.push(
830
857
  "",
831
858
  term.wrap(
832
- `${shell} names the harness itself: without a terminal, the verb is its own confirmation.`,
859
+ row.verb === "configure"
860
+ ? `${shell} names the harness itself: its overlay is the one agents.* keys write.`
861
+ : `${shell} names the harness itself: without a terminal, the verb is its own confirmation.`,
833
862
  c.live ? c.width : 0,
834
863
  " ",
835
864
  ),
@@ -1165,20 +1194,7 @@ export async function run(
1165
1194
  }
1166
1195
  const project = resolveProject({ project: values.project, env, cwd });
1167
1196
  const cfg = readConfigAt(project);
1168
- // Each form alone on its line after the lead, the terminal's in the core's
1169
- // form, the directory in the lead (the story "User-facing text says where to
1170
- // run and names no project path and no shell", rules 1, 4 and 6).
1171
- if (row.requiresBinding && !cfg)
1172
- return fail(
1173
- [
1174
- `${project} is not bound to a vault. Bind it in a session, or ${fromTerminalIn(project)}, where init creates the vault too:`,
1175
- commandForm("bind", { args: "<vault>", env }),
1176
- packageCommand([], "bind", { args: "<vault>" }),
1177
- packageCommand([], "init", { args: "<vault>" }),
1178
- ].join("\n"),
1179
- 3,
1180
- { project },
1181
- );
1197
+ if (row.requiresBinding && !cfg) return fail(notBoundText(project, env), 3, { project });
1182
1198
  try {
1183
1199
  return await row.run({
1184
1200
  row,
@@ -1486,70 +1502,17 @@ async function runAgents(ctx) {
1486
1502
  );
1487
1503
  if (!values.reset && values.default === undefined && !(values.agent || []).length)
1488
1504
  return usage("agents configure takes --default <model>, --agent <name>=<model> (repeatable) or --reset");
1489
- // --reset empties the block first; --default and --agent then apply on top of
1490
- // the emptied block — "reset, then set" is one call, and nothing given is
1491
- // silently dropped.
1492
- const base = values.reset ? { default: null, per_agent: {} } : before.agents;
1493
- const next = {
1494
- default: values.default !== undefined ? values.default || null : base.default,
1495
- per_agent: { ...base.per_agent },
1496
- };
1497
- for (const spec of values.agent || []) {
1498
- const m = /^([a-z][a-z0-9-]*)=(.*)$/.exec(spec);
1499
- if (!m) return usage(`--agent takes <name>=<model>, got ${spec}`);
1500
- // A name outside the roster would be written, listed, and never run.
1501
- if (roster && !roster.includes(m[1]))
1502
- return usage(`no agent named ${m[1]} in the ${cfg.layout} roster — known: ${roster.join(", ")}`);
1503
- if (m[2]) next.per_agent[m[1]] = m[2];
1504
- else delete next.per_agent[m[1]];
1505
- }
1506
- // The clerk transcribes; a strong default must not lift it (commands/agents.md,
1507
- // ADR-008). WHICH model it is pinned to is the harness's vocabulary, so the
1508
- // manifest names it and this file does not: pinning every harness to
1509
- // "sonnet" wrote an Anthropic model name into a Codex overlay, which is a
1510
- // model that does not exist there. A manifest with no cheap model declared
1511
- // does not pin, and the preview says so rather than inventing one.
1512
- // No fallback model here, deliberately, and it is not the same call as
1513
- // SOURCE_WRITE_TOOLS_FALLBACK: a missing manifest cannot reach this line.
1514
- // harnessIds() derives from the manifests, so with none loadable `--harness`
1515
- // is refused as unknown and `overlayId(env)` is null, and configure has
1516
- // already returned "no harness detected and none named". A literal here would
1517
- // be the deleted `"sonnet"` restored for a case that cannot happen.
1518
- let pinnedClerk = false;
1519
- let clerkPinUnavailable = false;
1520
- const clerkAsked = (values.agent || []).some((s) => s.startsWith("clerk="));
1521
- if (next.default && !next.per_agent.clerk && !clerkAsked) {
1522
- const cheap = harnessForOverlay(harness)?.agent_translation?.cheap_model || null;
1523
- if (cheap) {
1524
- next.per_agent.clerk = cheap;
1525
- pinnedClerk = true;
1526
- } else clerkPinUnavailable = true;
1527
- }
1528
- // Keys inside the agents block that the allowlist rejected (an `effort`, a
1529
- // stray shape) are rewritten out by the write and announced here; keys
1530
- // outside the block are kept, and stay doctor's to name.
1531
- const dropped = before.rejected.filter((k) => k.startsWith("agents."));
1532
- const same =
1533
- !dropped.length &&
1534
- JSON.stringify({ d: before.agents.default, p: before.agents.per_agent }) ===
1535
- JSON.stringify({ d: next.default, p: next.per_agent });
1536
- const preview = [
1537
- `agents configure — ${harness} — ${before.path}`,
1538
- ` default: ${before.agents.default || "—"} → ${next.default || "—"}`,
1539
- ];
1540
- const names = new Set([...Object.keys(before.agents.per_agent), ...Object.keys(next.per_agent)]);
1541
- for (const n of [...names].sort())
1542
- preview.push(
1543
- ` ${n}: ${before.agents.per_agent[n] || "—"} → ${next.per_agent[n] || "—"}${pinnedClerk && n === "clerk" ? " (pinned: the clerk stays cheap under a strong default)" : ""}`,
1544
- );
1545
- // Silence here would read as "the clerk is fine", and it is not: it takes the
1546
- // strong default, which is the one thing ADR-008 says must not happen to it.
1547
- if (clerkPinUnavailable)
1548
- preview.push(
1549
- ` clerk: takes the default — ${harness} declares no cheap model to pin it to, so name one with --agent clerk=<model>`,
1550
- );
1551
- if (dropped.length)
1552
- preview.push(` drops from the agents block (an overlay carries only models): ${dropped.join(", ")}`);
1505
+ const change = planAgentsChange({
1506
+ before,
1507
+ harness,
1508
+ layout: cfg && cfg.layout,
1509
+ roster,
1510
+ defaultModel: values.default,
1511
+ agents: values.agent || [],
1512
+ reset: Boolean(values.reset),
1513
+ });
1514
+ if (change.usage) return usage(change.usage);
1515
+ const { next, pinnedClerk, clerkPinUnavailable, dropped, same, preview } = change;
1553
1516
  if (same)
1554
1517
  return emit(
1555
1518
  "agents configure",
@@ -1572,8 +1535,7 @@ async function runAgents(ctx) {
1572
1535
  { harness, path: before.path, wrote: false, declined: true, agents: next, dropped },
1573
1536
  preview.join("\n") + "\n nothing written.\n",
1574
1537
  );
1575
- const path = writeOverlayAt(project, harness, next);
1576
- const after = readOverlayAt(project, harness);
1538
+ const { path, after } = applyAgentsChange(project, harness, next);
1577
1539
  return emit(
1578
1540
  "agents configure",
1579
1541
  true,
@@ -1851,6 +1813,45 @@ async function runReconcile({ values, project, env, stdin, stdout, stderr, ask }
1851
1813
  return failed ? 1 : 0;
1852
1814
  }
1853
1815
 
1816
+ // The not-bound refusal, for every verb that needs a binding and for configure.
1817
+ // Each form alone on its line after the lead, the terminal's in the core's
1818
+ // form, the directory in the lead (the story "User-facing text says where to
1819
+ // run and names no project path and no shell", rules 1, 4 and 6).
1820
+ export function notBoundText(project, env = process.env) {
1821
+ return [
1822
+ `${project} is not bound to a vault. Bind it in a session, or ${fromTerminalIn(project)}, where init creates the vault too:`,
1823
+ commandForm("bind", { args: "<vault>", env }),
1824
+ packageCommand([], "bind", { args: "<vault>" }),
1825
+ packageCommand([], "init", { args: "<vault>" }),
1826
+ ].join("\n");
1827
+ }
1828
+
1829
+ // configure (its spec): loaded on first use, like setup. The module returns a
1830
+ // usage error, an unbound project, a refusal's message, or a result; the bin
1831
+ // writes the line, the envelope and the exit code.
1832
+ async function runConfigureVerb(ctx) {
1833
+ const { runConfigure } = await import("./configure.mjs");
1834
+ const { values, env, stdout, stderr, project } = ctx;
1835
+ const r = await runConfigure({
1836
+ ...ctx,
1837
+ root: PACKAGE_ROOT,
1838
+ ownEnv: (p) => ownEnv(env, p),
1839
+ nearest,
1840
+ });
1841
+ if (r.usage) return usageFail(Object.assign(new Error(r.usage), { code: "USAGE" }), { verb: "configure", ...ctx });
1842
+ if (r.unbound) {
1843
+ const text = notBoundText(project, env);
1844
+ if (values.json)
1845
+ stdout.write(JSON.stringify(envelope("configure", project, false, { error: text, exit: 3 }), null, 2) + "\n");
1846
+ else stderr.write(text + "\n");
1847
+ return 3;
1848
+ }
1849
+ if (r.message && !values.json) stderr.write(r.message + "\n");
1850
+ if (values.json && r.result)
1851
+ stdout.write(JSON.stringify(envelope("configure", project, r.ok, r.result), null, 2) + "\n");
1852
+ return r.code;
1853
+ }
1854
+
1854
1855
  // setup (the setup story): its own module, imported lazily as the install
1855
1856
  // family is, run with this package as its root and in the environment every
1856
1857
  // installer run gets (ownEnv, for the project setup settles on). The envelope