projectstore-claude 0.30.2 → 0.30.4

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 (32) 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 +3 -3
  5. package/node_modules/projectstore/agents/clerk.md +2 -2
  6. package/node_modules/projectstore/commands/agents.md +43 -28
  7. package/node_modules/projectstore/commands/bind.md +1 -1
  8. package/node_modules/projectstore/commands/doctor.md +19 -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 +40 -1
  15. package/node_modules/projectstore/harnesses/codex.json +50 -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 +133 -6
  21. package/node_modules/projectstore/scripts/configure.mjs +156 -19
  22. package/node_modules/projectstore/scripts/convergence.mjs +56 -1
  23. package/node_modules/projectstore/scripts/doctor.mjs +134 -35
  24. package/node_modules/projectstore/scripts/harness.mjs +14 -9
  25. package/node_modules/projectstore/scripts/install-harness.mjs +182 -14
  26. package/node_modules/projectstore/scripts/lib.mjs +83 -24
  27. package/node_modules/projectstore/scripts/menu.mjs +9 -5
  28. package/node_modules/projectstore/scripts/models.mjs +150 -6
  29. package/node_modules/projectstore/scripts/setup.mjs +7 -5
  30. package/node_modules/projectstore/scripts/surfaces.mjs +36 -9
  31. package/node_modules/projectstore/templates/claude-md-block.md.tmpl +7 -5
  32. 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,7 +36,15 @@ 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";
38
- import { knownModels, offListNote } from "./models.mjs";
39
+ import {
40
+ knownModels,
41
+ offListNote,
42
+ effortSpec,
43
+ effortLevels,
44
+ offLevelNote,
45
+ standingLevelNote,
46
+ LEVEL,
47
+ } from "./models.mjs";
39
48
  import { runMenu } from "./configure-menu.mjs";
40
49
  import { SMALLEST } from "./menu.mjs";
41
50
 
@@ -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,14 +489,19 @@ 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;
@@ -452,11 +512,47 @@ export function planChanges(f, changes, { env = process.env, home = homedir(), r
452
512
  // 8 as amended 2026-10-13): the model may be newer than the list.
453
513
  // Only a model the run names gets the note: the clerk's pin does not.
454
514
  const known = knownModels(manifest, { env, home });
455
- const named = new Map(list.map((c) => [c.name, c.value]));
515
+ const named = new Map(models.map((c) => [c.name, c.value]));
456
516
  const offList = (name, model) => (model && named.get(name) === model ? offListNote(manifest, model, known) : null);
457
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
+ };
458
554
  if (before.agents.default !== change.next.default) {
459
- const listNote = offList("default", change.next.default);
555
+ const listNote = notes(offList("default", change.next.default), moved("default"));
460
556
  items.push({
461
557
  key: "agents.default",
462
558
  harness: h,
@@ -474,7 +570,7 @@ export function planChanges(f, changes, { env = process.env, home = homedir(), r
474
570
  const a = before.agents.per_agent[n] || null;
475
571
  const b = change.next.per_agent[n] || null;
476
572
  if (a === b) continue;
477
- const listNote = offList(n, b);
573
+ const listNote = notes(offList(n, b), moved(n));
478
574
  items.push({
479
575
  key: `agents.${n}`,
480
576
  harness: h,
@@ -501,7 +597,43 @@ export function planChanges(f, changes, { env = process.env, home = homedir(), r
501
597
  team: true,
502
598
  note: `${display} declares no cheap model to pin the clerk to; set agents.clerk to name one`,
503
599
  });
504
- 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 });
505
637
  }
506
638
 
507
639
  // The status line: install's own plan for the surface, with its flag item
@@ -602,7 +734,7 @@ export function applyChanges(f, steps, { env = process.env, home = homedir(), ro
602
734
  writeVaultConfig(f.vault, nextVaultConfig(now.cfg, s.patch, new Date().toISOString()));
603
735
  wrote.push(vaultConfigPath(f.vault));
604
736
  } else if (s.writer === "overlay") {
605
- wrote.push(applyAgentsChange(f.project, s.harness, s.next).path);
737
+ wrote.push(applyAgentsChange(f.project, s.harness, s.next, s.nextEfforts).path);
606
738
  } else if (s.writer === "statusline") {
607
739
  const finished = [];
608
740
  try {
@@ -640,6 +772,8 @@ const SOURCE_TEXT = Object.freeze({
640
772
  per_agent: "per agent",
641
773
  default: "default",
642
774
  frontmatter: "its frontmatter",
775
+ model_default: "its model's own default",
776
+ harness: "the harness decides",
643
777
  binding: "binding",
644
778
  vault_config: "vault config",
645
779
  unparseable: "not valid JSON: fix the file to change it",
@@ -675,9 +809,11 @@ export function renderView(f, settings, { caps: c = term.PLAIN } = {}) {
675
809
  note:
676
810
  s.class === "other-verb"
677
811
  ? "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,
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,
681
817
  })),
682
818
  ),
683
819
  );
@@ -764,6 +900,7 @@ export async function runConfigure(ctx) {
764
900
  harness: s.harness || null,
765
901
  team: s.team,
766
902
  set: s.set,
903
+ ...(s.model_default ? { model_default: s.model_default } : {}),
767
904
  }));
768
905
  const interactive =
769
906
  !json && (ask ? true : isInteractive({ stdin, stdout, env, json })) && term.railAvailable(stdin, c);
@@ -56,6 +56,7 @@ import {
56
56
  writeFileAtomic,
57
57
  envFlag,
58
58
  isEphemeralRoot,
59
+ COPY_UPDATE_STEPS,
59
60
  } from "./lib.mjs";
60
61
  import { loadHarness, loadHarnesses } from "./harness.mjs";
61
62
  import { analysePortableRegistration, portableRegistrationPaths } from "./portable-registration.mjs";
@@ -348,6 +349,34 @@ function runArgs({ harnesses = [], surfaces = null, register = true } = {}) {
348
349
  ].join(" ");
349
350
  }
350
351
 
352
+ // The run a deferral sends to a terminal (the install spec, contract 6, as
353
+ // amended 2026-10-14): the run's own verb for one harness, its --surface
354
+ // selection, --global for a global removal, and setup's --channel npm when
355
+ // that was the answer for the harness whose channel it is (setup's rule 13 —
356
+ // a keep is never repeated). `plan` writes nothing, so it names install. One
357
+ // builder for a deferred row's reason and setup's DONE, so the two agree.
358
+ export function terminalRun({
359
+ verb = "install",
360
+ harness = null,
361
+ version = null,
362
+ surfaces = null,
363
+ globalRemoval = false,
364
+ channel = null,
365
+ } = {}) {
366
+ const run = verb === "plan" ? "install" : verb;
367
+ const hostRegistration = Object.values(harness?.surfaces || {}).some(
368
+ (s) => s && typeof s === "object" && s.format === "host-plugin-registration",
369
+ );
370
+ const args = [
371
+ runArgs({ harnesses: harness ? [harness.id] : [], surfaces }),
372
+ globalRemoval ? "--global" : "",
373
+ run === "setup" && channel === "npm" && hostRegistration ? "--channel npm" : "",
374
+ ]
375
+ .filter(Boolean)
376
+ .join(" ");
377
+ return coreCommand(run, { version, args });
378
+ }
379
+
351
380
  // Stop when the registry's latest is at least a newer copy this project runs
352
381
  // — the newest such copy is named. A copy the latest does not reach, or no
353
382
  // answer, proceeds: the rows say which case it was (proceedCase). `read` is
@@ -577,9 +606,15 @@ export async function readKeepPlan(copy, { harness, projectDir, env, run, budget
577
606
  ...(Array.isArray(result.refusals) ? result.refusals : []),
578
607
  ...items.filter((i) => i.action === "refuse").map((i) => i.reason),
579
608
  ].filter((x) => typeof x === "string" && x);
609
+ // A deferred row's reason may carry its run on the lines after its prose
610
+ // (the install spec, contract 6, as amended 2026-10-14): only the prose is
611
+ // quoted inside our own sentence, never another copy's command.
580
612
  const deferred = items
581
613
  .filter((i) => i.deferred === true)
582
- .map((i) => ({ surface: String(i.surface ?? ""), reason: typeof i.reason === "string" ? i.reason : null }));
614
+ .map((i) => ({
615
+ surface: String(i.surface ?? ""),
616
+ reason: typeof i.reason === "string" ? i.reason.split("\n")[0] : null,
617
+ }));
583
618
  return {
584
619
  argv,
585
620
  envelope: true,
@@ -603,6 +638,26 @@ export function ownUpgrade(copy, harness) {
603
638
  };
604
639
  }
605
640
 
641
+ // Whether a kept copy is behind the running package (the install spec,
642
+ // contract 21, rule 9, as amended 2026-10-14): semver precedence, rc
643
+ // included. Never for a copy or a run with no semver version recorded — a
644
+ // recorded "unknown" or a sha must not rank.
645
+ export function copyBehind(copy, version) {
646
+ return isSemver(copy?.version) && isSemver(version) && cmpPrecedence(copy.version, version) < 0;
647
+ }
648
+
649
+ // What the keep says when its copy is behind: the project's surfaces are at
650
+ // the running version until the copy updates, and the update in the copy's
651
+ // channel. With a step, the copy's own upgrade runs after this run's agents
652
+ // block, and a copy shipped before this rule plans the block back.
653
+ export function behindAdvice(copy, version, { step = false } = {}) {
654
+ const head = `${copy.key} ${copy.version} is older than this package (${version})`;
655
+ const middle = step
656
+ ? `its own upgrade runs after this run's agents block and may plan the block back to its own version; until it updates, its doctor may call the block stale, and the next run may plan the block again`
657
+ : `until it updates, the agents block and anything else this run writes for the project are at ${version}, and its own doctor may call the block stale`;
658
+ return `${head}: ${middle}. Update it (${COPY_UPDATE_STEPS}).`;
659
+ }
660
+
606
661
  // This checkout's npm registration enabled beside the kept copy: the keep
607
662
  // changes no registration, so it stays; its uninstall (rule 4) drops it.
608
663
  export function npmRowBeside({ harness, id, surface }) {