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
@@ -193,6 +193,9 @@ import {
193
193
  npmRowBeside,
194
194
  pinFor,
195
195
  coreCommand,
196
+ terminalRun,
197
+ copyBehind,
198
+ behindAdvice,
196
199
  DEFAULT_REGISTRY,
197
200
  NOTICE_BUDGET_MS,
198
201
  READ_TIMEOUT_MS,
@@ -235,6 +238,7 @@ import {
235
238
  removeTreeUnder,
236
239
  projectDirRefusal,
237
240
  fromTerminalIn,
241
+ newerBlockWords,
238
242
  registrationProvenance,
239
243
  envFlag,
240
244
  selectCopy,
@@ -461,12 +465,27 @@ function planAgentsBlock(ctx, key, s) {
461
465
  return items;
462
466
  }
463
467
 
464
- const entry = `projectstore:agents v${a.version}`;
465
468
  const current = a.current;
469
+ // A block marked above this copy's template is never rendered down (the
470
+ // install spec, contracts 4, 6 and 9, as amended 2026-10-14): a move, a
471
+ // migration or a dedupe carries its own bytes, stamped with its own marker.
472
+ const newer = a.newer || null;
473
+ const words = newer ? newerBlockWords(newer.block.v, a.version) : null;
474
+ const body = newer ? newer.block.block : a.desired;
475
+ const entry = `projectstore:agents v${newer ? newer.block.v : a.version}`;
466
476
  // Two files, one block each: resolve in favour of the preferred file —
467
477
  // register migrates, never duplicates (ADR-002 decision 3).
468
478
  for (const e of a.duplicates || [])
469
- items.push(removal(e, { state: "ours-stale", reason: `duplicate of the block in ${current.file}` }));
479
+ items.push(
480
+ removal(e, {
481
+ state: "ours-stale",
482
+ reason:
483
+ newer === e
484
+ ? `carries v${e.block.v}, newer than this copy's v${a.version}; its text replaces the block in ${current.file}`
485
+ : `duplicate of the block in ${current.file}`,
486
+ }),
487
+ );
488
+ let newerInPlace = null;
470
489
 
471
490
  if (!current) {
472
491
  const target = preferred;
@@ -501,10 +520,43 @@ function planAgentsBlock(ctx, key, s) {
501
520
  entry,
502
521
  state: "ours-absent",
503
522
  action: preferred.present ? "add" : "create",
504
- reason: ctx.readers && !current.own ? a.reason : `migrated from ${current.file}`,
523
+ reason:
524
+ ctx.readers && !current.own
525
+ ? a.reason
526
+ : `migrated from ${current.file}${newer ? ` with its own text (${words})` : ""}`,
505
527
  before: preferred.present ? preferred.text : null,
506
- after: replaceAgentsBlock(preferred.present ? preferred.text : "", a.desired),
528
+ after: replaceAgentsBlock(preferred.present ? preferred.text : "", body),
529
+ });
530
+ } else if (newer && newer !== current) {
531
+ // The duplicate carries the higher marker: its text replaces the stayer's.
532
+ items.push({
533
+ surface: key,
534
+ kind: "shared",
535
+ path: current.path,
536
+ entry,
537
+ state: "ours-stale",
538
+ action: "replace-entry",
539
+ reason: `its text is replaced by ${newer.file}'s, which carries v${newer.block.v}, newer than this copy's v${a.version}`,
540
+ before: current.text,
541
+ after: replaceAgentsBlock(current.text, body),
507
542
  });
543
+ } else if (newer) {
544
+ // It stays as it is; a named run with nothing else to place overwrites it,
545
+ // decided once the imports are known (below).
546
+ newerInPlace = {
547
+ surface: key,
548
+ kind: "shared",
549
+ path: current.path,
550
+ entry,
551
+ state: "ours-newer",
552
+ action: "skip",
553
+ reason: `${words}: left as it is — ${
554
+ ctx.takesSurface === false
555
+ ? `to write this copy's v${a.version} over it: ${coreCommand("install", { args: `--harness ${ctx.harness.id} --surface ${key}` })}`
556
+ : `name --surface ${key} to write this copy's v${a.version} over it`
557
+ }`,
558
+ };
559
+ items.push(newerInPlace);
508
560
  } else if (current.block.v === a.version && current.block.block === a.desired) {
509
561
  items.push({
510
562
  surface: key,
@@ -590,6 +642,23 @@ function planAgentsBlock(ctx, key, s) {
590
642
  after,
591
643
  });
592
644
  }
645
+ // Naming the surface confirms the overwrite only when the run has nothing
646
+ // else to do for the block: it is the only block and every import it needs
647
+ // is in place. Doctor's placement remedies name the surface too, and they
648
+ // must never downgrade (contract 9 as amended 2026-10-14).
649
+ if (
650
+ newerInPlace &&
651
+ (ctx.surfaces || []).includes(key) &&
652
+ !(a.duplicates || []).length &&
653
+ !items.some((i) => i.surface === `${key}_import`)
654
+ )
655
+ Object.assign(newerInPlace, {
656
+ entry: `projectstore:agents v${a.version}`,
657
+ action: "replace-entry",
658
+ reason: `v${current.block.v} → v${a.version}: ${words}, and --surface ${key} names it`,
659
+ before: current.text,
660
+ after: replaceAgentsBlock(current.text, a.desired),
661
+ });
593
662
  return items;
594
663
  }
595
664
 
@@ -758,7 +827,7 @@ function planPortableRegistration(ctx, key, s) {
758
827
  // registration's removal decides them.
759
828
  if (registrationDeferred(env, harness)) {
760
829
  ctx.incomplete = true;
761
- return [{ ...base, action: "skip", deferred: true, reason: deferralReason(harness, env) }];
830
+ return [{ ...base, action: "skip", deferred: true, ...deferredRun(ctx, deferralReason(harness, env)) }];
762
831
  }
763
832
  const steps = [];
764
833
  if (a.installed) steps.push(hostStep(a, s, "uninstall", fill, `explicit global removal forgets ${a.id}`));
@@ -827,7 +896,7 @@ function planPortableRegistration(ctx, key, s) {
827
896
  ...base,
828
897
  action: "skip",
829
898
  deferred: true,
830
- reason: `${a.reason ? a.reason + "; " : ""}${deferralReason(harness, env)}`,
899
+ ...deferredRun(ctx, `${a.reason ? a.reason + "; " : ""}${deferralReason(harness, env)}`),
831
900
  },
832
901
  ];
833
902
  }
@@ -932,6 +1001,29 @@ function deferralReason(harness, env) {
932
1001
  return `this runs inside a ${harness.display_name} session (${variable} is set), and the host's CLI would change the configuration that session runs on — run it from a terminal outside the session`;
933
1002
  }
934
1003
 
1004
+ // A deferral names its run (the install spec, contract 6, as amended
1005
+ // 2026-10-14): the prose as before, byte for byte, then where to run it and
1006
+ // the run's own command alone on its line — a way out, as the presentation
1007
+ // spec's contract 6 gives them, so `--json` carries it in `reason` and an
1008
+ // agent relaying the envelope relays the command.
1009
+ function deferredRun(ctx, prose) {
1010
+ // The prose already says "outside the session"; the lead adds only where.
1011
+ const where = fromTerminalIn(ctx.projectDir);
1012
+ return advised(prose, [
1013
+ {
1014
+ lead: `${where.charAt(0).toUpperCase()}${where.slice(1)}:`,
1015
+ command: terminalRun({
1016
+ verb: ctx.verb,
1017
+ harness: ctx.harness,
1018
+ version: ctx.version,
1019
+ surfaces: ctx.surfaces,
1020
+ globalRemoval: ctx.globalRemoval,
1021
+ channel: ctx.channel,
1022
+ }),
1023
+ },
1024
+ ]);
1025
+ }
1026
+
935
1027
  function planRegistration(ctx, key, s) {
936
1028
  const { projectDir, mode, root, home, env, harness } = ctx;
937
1029
  const a = analyseRegistration(projectDir, s, { root, home, harness, env });
@@ -977,7 +1069,7 @@ function planRegistration(ctx, key, s) {
977
1069
  }
978
1070
  if (deferred) {
979
1071
  ctx.incomplete = true;
980
- return [{ ...base, action: "skip", reason: inSession }];
1072
+ return [{ ...base, action: "skip", ...deferredRun(ctx, inSession) }];
981
1073
  }
982
1074
  const steps = [];
983
1075
  if (a.installed)
@@ -1109,7 +1201,14 @@ function planRegistration(ctx, key, s) {
1109
1201
  }
1110
1202
  if (needsHost && deferred) {
1111
1203
  ctx.incomplete = true;
1112
- return [{ ...base, action: "skip", deferred: true, reason: `${a.reason ? a.reason + "; " : ""}${inSession}` }];
1204
+ return [
1205
+ {
1206
+ ...base,
1207
+ action: "skip",
1208
+ deferred: true,
1209
+ ...deferredRun(ctx, `${a.reason ? a.reason + "; " : ""}${inSession}`),
1210
+ },
1211
+ ];
1113
1212
  }
1114
1213
  // Our directory newer than this package and damaged (the converge story's
1115
1214
  // rule 6): never rewritten from the older package — this checkout and every
@@ -1392,6 +1491,11 @@ function keepTarget(projectDir, ids, { root, home, env }) {
1392
1491
  function keepEnv(env, { harness, projectDir, copyRoot, harnessHome }) {
1393
1492
  const out = childEnv(env);
1394
1493
  for (const m of loadHarnesses().values()) if (m.runtime?.plugin_root_env) delete out[m.runtime.plugin_root_env];
1494
+ // An override this run inherited names THIS process's harness; in the copy
1495
+ // it would turn the copy's own home and directory reads to another harness.
1496
+ // The copy knows itself by the project-dir variable written below (the
1497
+ // install spec, contract 21, as amended 2026-10-14).
1498
+ delete out.PROJECTSTORE_HARNESS;
1395
1499
  const r = harness.runtime || {};
1396
1500
  if (r.plugin_root_env) out[r.plugin_root_env] = copyRoot;
1397
1501
  if (r.project_dir_env) out[r.project_dir_env] = projectDir;
@@ -1410,7 +1514,9 @@ const KEEP_SURFACE = "channel";
1410
1514
  // (keepArgv), its touches the writes the copy's own plan listed (`read`, made
1411
1515
  // by the prepare step) less the agents block's rows, which this run's one
1412
1516
  // block item writes before it (the story "One run plans the agents block
1413
- // once…", rule 1). No write left: no item. The other rows it can be:
1517
+ // once…", rule 1). No write left: no item — unless the copy is behind the
1518
+ // running package, when a skip carries that line (contract 21, rule 9, as
1519
+ // amended 2026-10-14), as the update row does. The other rows it can be:
1414
1520
  // - a copy that cannot run the form (lib's capability read): skip, with the
1415
1521
  // update in its channel — in the row's reason, since DONE never prints when
1416
1522
  // nothing else writes, and as DONE's next line;
@@ -1547,11 +1653,29 @@ function planKeep(ctx, target, read) {
1547
1653
  }),
1548
1654
  ];
1549
1655
  }
1550
- if (!writes.length)
1656
+ // A copy behind the running package says so (the install spec, contract
1657
+ // 21, rule 9, as amended 2026-10-14): with no write left, a skip whose
1658
+ // reason is that line — neither deferred nor incomplete, so a re-run stays
1659
+ // zero-change; with writes, a line on the update row and in DONE.
1660
+ const behind = copyBehind(copy, ctx.version);
1661
+ if (!writes.length) {
1662
+ if (behind) {
1663
+ const line = behindAdvice(copy, ctx.version);
1664
+ return [item({ action: "skip", ...advised(line, besides) }, [{ lead: line }])];
1665
+ }
1551
1666
  return beside ? [item({ action: "skip", ...advised(`${named} has nothing to change here`, besides) })] : [];
1667
+ }
1668
+ const note = behind ? [{ lead: behindAdvice(copy, ctx.version, { step: true }) }] : [];
1552
1669
  if (insideHostSession(env, harness)) {
1553
1670
  ctx.incomplete = true;
1554
- return [item({ action: "skip", deferred: true, ...advised(`${kept}; ${inSessionReason(harness)}`, besides) })];
1671
+ const prose = `${kept}; ${inSessionReason(harness)}`;
1672
+ return [
1673
+ item({
1674
+ action: "skip",
1675
+ deferred: true,
1676
+ ...advised(prose, [...note, ...deferredRun(ctx, prose).advice.blocks, ...besides]),
1677
+ }),
1678
+ ];
1555
1679
  }
1556
1680
  const step = {
1557
1681
  kind: "host",
@@ -1560,7 +1684,7 @@ function planKeep(ctx, target, read) {
1560
1684
  why: `the kept copy's own upgrade, from its own bin without registering (the layout remedy's form), read back as JSON; it re-plans from the files as they stand then, after the agents block above`,
1561
1685
  touches: writes,
1562
1686
  };
1563
- return [item({ action: "update", ...advised(kept, besides), steps: [step] })];
1687
+ return [item({ action: "update", ...advised(kept, [...note, ...besides]), steps: [step] }, note)];
1564
1688
  }
1565
1689
 
1566
1690
  // The mark (the converge story's rule 2), for the channel "npm" only: one
@@ -1889,7 +2013,7 @@ function planJsonEntry(ctx, key, s) {
1889
2013
  },
1890
2014
  ];
1891
2015
  }
1892
- const a = analyseJsonEntry(projectDir, s, { root, home, renderRoot });
2016
+ const a = analyseJsonEntry(projectDir, s, { root, home, renderRoot, harness: ctx.harness });
1893
2017
  if (a.state === "unparseable")
1894
2018
  return [
1895
2019
  { surface: key, kind: "shared", path, entry: entryKey, state: "unparseable", action: "refuse", reason: a.reason },
@@ -2165,6 +2289,7 @@ export function plan(
2165
2289
  ensureState = false,
2166
2290
  recordKeep = false,
2167
2291
  takesSurface = true,
2292
+ verb = null,
2168
2293
  } = {},
2169
2294
  ) {
2170
2295
  projectDir = resolve(projectDir);
@@ -2409,7 +2534,17 @@ export function plan(
2409
2534
  ? blockRun.map((m) => ({ display: m.display_name, files: agentsBlockRow(m)[1].files || [] }))
2410
2535
  : null;
2411
2536
  blockItems = planAgentsBlock(
2412
- { projectDir, mode, root, harness: owner, run: blockRun, readers, layout, surfaces: surfaces || [] },
2537
+ {
2538
+ projectDir,
2539
+ mode,
2540
+ root,
2541
+ harness: owner,
2542
+ run: blockRun,
2543
+ readers,
2544
+ layout,
2545
+ surfaces: surfaces || [],
2546
+ takesSurface,
2547
+ },
2413
2548
  key,
2414
2549
  { ...s, files: commonBlockFiles(blockRun) },
2415
2550
  );
@@ -2469,6 +2604,9 @@ export function plan(
2469
2604
  channelFacts: null,
2470
2605
  refusals: [],
2471
2606
  takesSurface,
2607
+ // The verb a deferral repeats (the install spec, contract 6, as amended
2608
+ // 2026-10-14): the run's own — upgrade and setup plan in install mode.
2609
+ verb: verb || mode,
2472
2610
  };
2473
2611
  // The keep (rule 5): the run's one block first — over the whole run set,
2474
2612
  // this harness included — then the keep's one item, and none of this
@@ -2630,11 +2768,40 @@ export function plan(
2630
2768
  placeBlock();
2631
2769
  for (const item of moved.last) out.items.push({ harness: layoutHarness.id, ...item });
2632
2770
  out.items.push(...marks);
2771
+ oneAddPerFile(out.items);
2633
2772
  if (out.items.some((i) => i.action === "refuse")) out.ok = false;
2634
2773
  if (out.refusals.length) out.ok = false;
2635
2774
  return out;
2636
2775
  }
2637
2776
 
2777
+ // One add per file (the presentation spec, contract 7 as amended 2026-10-14):
2778
+ // within one plan, a file an earlier item creates is added once. A later
2779
+ // `create` or `add` on that path changes the file the run has just made, so it
2780
+ // reads `update`; its state, steps and detail stay. Each item is planned
2781
+ // against the tree before the run, so the binding the vault item writes was
2782
+ // "absent" to the status-line flag and the channel mark, and all three read
2783
+ // "add". A creator is an earlier `create` or `add` item that is neither a
2784
+ // registration (its host-owned files are the host CLI's) nor a binding row (it
2785
+ // never makes the binding: apply skips it on an unbound project, and the vault
2786
+ // item or the layout move makes the file), counted for the paths absent at
2787
+ // plan time: its own and each step's `path` — never a move step's `to`, so a
2788
+ // migrating project's binding rows keep `add`. An entry row on a file that was
2789
+ // there keeps `add`.
2790
+ function oneAddPerFile(items) {
2791
+ const created = new Set();
2792
+ const writes = (i) => i.action === "create" || i.action === "add";
2793
+ for (const item of items) {
2794
+ if (!writes(item) || item.kind === "registration") continue;
2795
+ if (item.path && created.has(resolve(item.path))) {
2796
+ item.action = "update";
2797
+ continue;
2798
+ }
2799
+ if (item.kind === "binding") continue;
2800
+ for (const p of [item.path, ...(item.steps || []).map((s) => s.path)])
2801
+ if (p && !existsSync(p)) created.add(resolve(p));
2802
+ }
2803
+ }
2804
+
2638
2805
  // ─── the layout migration (the layout ADR; layout spec contract 6) ──────
2639
2806
  //
2640
2807
  // Two items, kind `layout`. The FIRST moves what only we read — the legacy
@@ -5229,6 +5396,7 @@ export async function runVerb(verb, projectDir, opts = {}) {
5229
5396
  plan(projectDir, {
5230
5397
  ...opts,
5231
5398
  mode,
5399
+ verb,
5232
5400
  state: verb === "upgrade" || setup || (verb === "uninstall" && !opts.globalRemoval),
5233
5401
  version: prep.version,
5234
5402
  payloadRoots: prep.payloadRoots,
@@ -30,6 +30,7 @@ import {
30
30
  projectRoot as harnessProjectRoot,
31
31
  pluginRoot as harnessPluginRoot,
32
32
  agentHome as harnessAgentHome,
33
+ harnessHome,
33
34
  configPath as harnessConfigPath,
34
35
  projectConfigDir as harnessProjectConfigDir,
35
36
  detectHarnessId,
@@ -196,9 +197,11 @@ export { adoptHookInput, resetHookInput };
196
197
  // ─── Harness overlays (the layout ADR decision 3; layout spec contracts 2–4) ──
197
198
  //
198
199
  // <project>/.projectstore/harness/<id>.json carries ONE thing: the agents block
199
- // for that harness — agents.default.model and agents.per_agent.<name>.model.
200
- // Every other key is ignored on read and named for doctor (rejected). The
201
- // binding never carries agents: ADR-008's two-term chain is read from here.
200
+ // for that harness — agents.default.model, agents.default.effort,
201
+ // agents.per_agent.<name>.model and agents.per_agent.<name>.effort (the effort
202
+ // ADR, decision 1). Every other key is ignored on read and named for doctor
203
+ // (rejected). The binding never carries agents: ADR-008's two-term chain is
204
+ // read from here, once per field.
202
205
  export function readOverlayAt(projectDir, id = overlayId()) {
203
206
  // No id (no manifest at all): nothing is read, and the writer below refuses
204
207
  // the same id rather than inventing a file name for it.
@@ -209,6 +212,7 @@ export function readOverlayAt(projectDir, id = overlayId()) {
209
212
  present: Boolean(path) && existsSync(path),
210
213
  unparseable: false,
211
214
  agents: { default: null, per_agent: {} },
215
+ efforts: { default: null, per_agent: {} },
212
216
  rejected: [],
213
217
  raw: null,
214
218
  };
@@ -235,8 +239,10 @@ export function readOverlayAt(projectDir, id = overlayId()) {
235
239
  for (const k of Object.keys(a)) if (k !== "default" && k !== "per_agent") out.rejected.push(`agents.${k}`);
236
240
  if (a.default !== undefined) {
237
241
  if (a.default && typeof a.default === "object") {
238
- for (const k of Object.keys(a.default)) if (k !== "model") out.rejected.push(`agents.default.${k}`);
242
+ for (const k of Object.keys(a.default))
243
+ if (k !== "model" && k !== "effort") out.rejected.push(`agents.default.${k}`);
239
244
  if (typeof a.default.model === "string" && a.default.model) out.agents.default = a.default.model;
245
+ if (typeof a.default.effort === "string" && a.default.effort) out.efforts.default = a.default.effort;
240
246
  } else out.rejected.push("agents.default");
241
247
  }
242
248
  if (a.per_agent !== undefined) {
@@ -246,8 +252,10 @@ export function readOverlayAt(projectDir, id = overlayId()) {
246
252
  out.rejected.push(`agents.per_agent.${name}`);
247
253
  continue;
248
254
  }
249
- for (const k of Object.keys(v)) if (k !== "model") out.rejected.push(`agents.per_agent.${name}.${k}`);
255
+ for (const k of Object.keys(v))
256
+ if (k !== "model" && k !== "effort") out.rejected.push(`agents.per_agent.${name}.${k}`);
250
257
  if (typeof v.model === "string" && v.model) out.agents.per_agent[name] = v.model;
258
+ if (typeof v.effort === "string" && v.effort) out.efforts.per_agent[name] = v.effort;
251
259
  }
252
260
  } else out.rejected.push("agents.per_agent");
253
261
  }
@@ -264,9 +272,22 @@ export function resolveAgentModel(projectDir, name, { harness = overlayId() } =
264
272
  return { name, model: null, source: null, overlay: o.path, harness };
265
273
  }
266
274
 
275
+ // The same two terms for effort, on their own (the effort ADR, decision 1): an
276
+ // agent's own model does not stop it taking the default's effort. Null passes
277
+ // nothing: the frontmatter's effort, or the harness's own default, runs.
278
+ export function resolveAgentEffort(projectDir, name, { harness = overlayId() } = {}) {
279
+ const o = readOverlayAt(projectDir, harness);
280
+ const per = o.efforts.per_agent[name];
281
+ if (per) return { name, effort: per, source: "per_agent", overlay: o.path, harness };
282
+ if (o.efforts.default) return { name, effort: o.efforts.default, source: "default", overlay: o.path, harness };
283
+ return { name, effort: null, source: null, overlay: o.path, harness };
284
+ }
285
+
267
286
  // The one writer of an overlay: rewrites its agents block, keeps any other key
268
287
  // as it found it (doctor names them; this never silently drops a user's key).
269
- export function writeOverlayAt(projectDir, id, agents) {
288
+ // `efforts` left out keeps the efforts the file holds, so a caller that
289
+ // changes models only never drops one.
290
+ export function writeOverlayAt(projectDir, id, agents, efforts) {
270
291
  if (!id) throw new Error("writeOverlayAt: the overlay's harness id is required");
271
292
  const path = layoutPaths(projectDir).overlay(id);
272
293
  let raw = {};
@@ -274,10 +295,13 @@ export function writeOverlayAt(projectDir, id, agents) {
274
295
  raw = JSON.parse(readFileSync(path, "utf8"));
275
296
  } catch {}
276
297
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) raw = {};
298
+ const e = efforts === undefined ? readOverlayAt(projectDir, id).efforts : efforts;
299
+ const pair = (model, effort) => ({ ...(model ? { model } : {}), ...(effort ? { effort } : {}) });
277
300
  const block = {};
278
- if (agents.default) block.default = { model: agents.default };
279
- const names = Object.keys(agents.per_agent || {}).sort();
280
- if (names.length) block.per_agent = Object.fromEntries(names.map((n) => [n, { model: agents.per_agent[n] }]));
301
+ if (agents.default || e.default) block.default = pair(agents.default, e.default);
302
+ const names = [...new Set([...Object.keys(agents.per_agent || {}), ...Object.keys(e.per_agent || {})])].sort();
303
+ if (names.length)
304
+ block.per_agent = Object.fromEntries(names.map((n) => [n, pair(agents.per_agent?.[n], e.per_agent?.[n])]));
281
305
  const next = { ...raw };
282
306
  if (Object.keys(block).length) next.agents = block;
283
307
  else delete next.agents;
@@ -522,12 +546,18 @@ export function pidAlive(pid) {
522
546
 
523
547
  // ─── Installed-plugin resolution ───────────────────────────────────────
524
548
 
525
- // The harness's config directory. Almost always ~/.claude, but the harness's
526
- // home variable relocates it — and a consumer that hardcodes the default
527
- // silently resolves nothing for those users instead of failing loudly. The
528
- // name is kept for its callers; the variable comes from the manifest.
549
+ // Claude Code's config directory. Almost always ~/.claude, but its home
550
+ // variable relocates it — and a consumer that hardcodes the default silently
551
+ // resolves nothing for those users instead of failing loudly. The variable
552
+ // comes from the source harness's manifest, never the active harness's: with
553
+ // another harness's home variable exported (CODEX_HOME), detection makes that
554
+ // harness active in a directory with no harness folder, and every Claude Code
555
+ // path used to follow it into the other home (the install spec, contract 6,
556
+ // as amended 2026-10-14). The host this process runs in is harnessAgentHome's
557
+ // question, asked by its own callers; it stands in here only if a manifest
558
+ // ever named no home at all.
529
559
  export function claudeHome(home = homedir()) {
530
- return harnessAgentHome(process.env, home);
560
+ return harnessHome(sourceHarness(), { env: process.env, home }) || harnessAgentHome(process.env, home);
531
561
  }
532
562
 
533
563
  // The release triple only: 0.28.0-rc.2 and 0.28.0 compare equal. Right for
@@ -610,9 +640,11 @@ export function pluginEnabled(key, home = homedir(), projectDir = null) {
610
640
  enabled = user.enabledPlugins[key] !== false;
611
641
  if (projectDir) {
612
642
  // The committed project file, then the checkout's local one over it — the
613
- // host's own precedence (measured 2026-09-05, --scope local).
643
+ // host's own precedence (measured 2026-09-05, --scope local). Claude Code's
644
+ // project directory, whichever harness this process runs as.
645
+ const dir = harnessProjectConfigDir(process.env, sourceHarness());
614
646
  for (const f of ["settings.json", "settings.local.json"]) {
615
- const proj = read(join(projectDir, harnessProjectConfigDir(), f));
647
+ const proj = read(join(projectDir, dir, f));
616
648
  if (proj && proj.enabledPlugins && Object.hasOwn(proj.enabledPlugins, key))
617
649
  enabled = proj.enabledPlugins[key] !== false;
618
650
  }
@@ -735,13 +767,17 @@ export function selectCopy(home = homedir(), projectDir, { harness = sourceHarne
735
767
  return { ...copy, bin, missing, channel: installChannel(copy.path, { home, harness }) };
736
768
  }
737
769
 
770
+ // How a git-marketplace copy is updated, in its own channel: the one phrase
771
+ // every advice about a kept or older copy quotes.
772
+ export const COPY_UPDATE_STEPS = "in Claude Code: /plugin marketplace update, then /plugin update, then restart";
773
+
738
774
  // The advice for a marketplace copy that cannot run what is named for it, in
739
775
  // that copy's own channel: the host's update, then a restart. `cannot` says
740
776
  // what it cannot do, `then` what follows the update. One text for doctor's
741
777
  // layout remedy and the keep's skip row and DONE line.
742
778
  export function copyUpdateAdvice(copy, { cannot, then }) {
743
779
  const v = copy?.version ? ` (${copy.version})` : "";
744
- return `This project's projectstore plugin${v} ${cannot}: update it first (in Claude Code: /plugin marketplace update, then /plugin update, then restart), ${then}`;
780
+ return `This project's projectstore plugin${v} ${cannot}: update it first (${COPY_UPDATE_STEPS}), ${then}`;
745
781
  }
746
782
 
747
783
  // The mark a channel switch leaves in this checkout's machine-local binding
@@ -1213,8 +1249,11 @@ export function statusLineIsOurWiring(cmd, projectDir, home = homedir(), root =
1213
1249
  // into state/<harness>/ made wrong; it is named instead, as plan() resolved
1214
1250
  // it — the same string the provenance line records, so an npx render and a
1215
1251
  // cache-install render stay byte-identical.
1216
- export function renderStatusLineLauncher(tpl, root, projectDir = projectRoot(), env = process.env) {
1217
- const names = runtimeEnvNames(env);
1252
+ // `harness` is the one the launcher is FOR (the plan's harness), so a render
1253
+ // from a process another harness launched still bakes in Claude Code's names;
1254
+ // the SessionStart refresh passes none and renders for its own host.
1255
+ export function renderStatusLineLauncher(tpl, root, projectDir = projectRoot(), env = process.env, harness) {
1256
+ const names = harness ? runtimeEnvNames(env, harness) : runtimeEnvNames(env);
1218
1257
  const subs = [
1219
1258
  ['"__PROJECTSTORE_ROOT__"', JSON.stringify(root)],
1220
1259
  ['"__PROJECTSTORE_HOME_ENV__"', JSON.stringify(names.home || "")],
@@ -1258,13 +1297,20 @@ export function writeStatusLineLauncher(projectDir, root) {
1258
1297
  // The command our entry should carry for this installation: the version-free
1259
1298
  // launcher for a marketplace-cache install, the plugin's own script for a dev
1260
1299
  // checkout (its path has no version to go stale). One resolver, shared by the
1261
- // SessionStart refresh and the installer, so the two cannot disagree.
1262
- export function desiredStatusLineCommand(projectDir, root = pluginRoot(), home = homedir()) {
1300
+ // SessionStart refresh and the installer, so the two cannot disagree. The
1301
+ // installer names the harness it plans (its launcher's state directory); the
1302
+ // refresh names none and means its own host.
1303
+ export function desiredStatusLineCommand(
1304
+ projectDir,
1305
+ root = pluginRoot(),
1306
+ home = homedir(),
1307
+ harnessId = activeHarnessId(),
1308
+ ) {
1263
1309
  const launcher = isPluginCacheRoot(root, home);
1264
1310
  return {
1265
1311
  launcher,
1266
1312
  command: launcher
1267
- ? `node "${statusLineLauncherPath(projectDir)}"`
1313
+ ? `node "${statusLineLauncherPath(projectDir, harnessId)}"`
1268
1314
  : `node "${join(root, "scripts", "statusline.mjs")}"`,
1269
1315
  };
1270
1316
  }
@@ -1393,6 +1439,19 @@ export function agentsBlockVersion(tmpl = null) {
1393
1439
  return m ? Number(m[1]) : null;
1394
1440
  }
1395
1441
 
1442
+ // A block whose marker is above this copy's template (the install spec,
1443
+ // contract 6, as amended 2026-10-14): the words every surface says it with.
1444
+ // Never "written by a newer projectstore" — a hand-edited marker reads the same.
1445
+ export function newerBlockWords(v, version) {
1446
+ return `marked v${v}, newer than this copy's v${version}`;
1447
+ }
1448
+
1449
+ // Whether a marker ranks above the template's. A template with no marker
1450
+ // (or a marker that is not a number) is never outranked.
1451
+ export function isNewerBlock(v, version) {
1452
+ return Number.isInteger(v) && Number.isInteger(version) && v > version;
1453
+ }
1454
+
1396
1455
  // The first block in `text`: its version, its span, whether it closes, and how
1397
1456
  // many open markers the file carries (more than one is a duplicate).
1398
1457
  export function findAgentsBlock(text) {
@@ -3848,7 +3907,7 @@ export function writeAnchorOffer(projectDir, sessionId, rec) {
3848
3907
  // within moments of each other both pass this check and both offer the same
3849
3908
  // name — the race is narrowed, not closed, and the ADR says so.
3850
3909
  export function liveSessionNames(selfSessionId, dir = null) {
3851
- const root = dir || join(claudeHome(), "sessions");
3910
+ const root = dir || join(harnessAgentHome(process.env), "sessions");
3852
3911
  const out = [];
3853
3912
  let files;
3854
3913
  try {
@@ -3872,7 +3931,7 @@ export function liveSessionNames(selfSessionId, dir = null) {
3872
3931
 
3873
3932
  // What this session is currently called, if the harness has recorded a name.
3874
3933
  export function ownSessionName(selfSessionId, dir = null) {
3875
- const root = dir || join(claudeHome(), "sessions");
3934
+ const root = dir || join(harnessAgentHome(process.env), "sessions");
3876
3935
  let files;
3877
3936
  try {
3878
3937
  files = readdirSync(root);
@@ -12,7 +12,11 @@
12
12
  //
13
13
  // A group is { title, footer, description, items }. An item is
14
14
  // { id, label, kind: "choice" | "text" | "info", toggle, value,
15
- // display(v), tag, describe(v, changed), options(), caption() }
15
+ // display(v), tag, describe(v, changed), options(), caption(),
16
+ // indent, path }
17
+ // where `indent` draws the label that many columns in, under the row it
18
+ // belongs to (an agent's effort under its model), and `path` names the item
19
+ // where its label alone would not: a frame's title and the path line.
16
20
  // where options() answers the values, each
17
21
  // { value, label, note, description, tag, section, kind: "value" | "text" | "refresh" }
18
22
  // and caption() the values frame's footer, { text, busy } or null. A value
@@ -390,13 +394,13 @@ function valueSegs(c, m, it, mode, room) {
390
394
  }
391
395
 
392
396
  function itemRows(c, m, g, cur, mode, w) {
393
- const lw = Math.max(...g.items.map((it) => wlen(it.label))) + 1;
397
+ const lw = Math.max(...g.items.map((it) => (it.indent || 0) + wlen(it.label))) + 1;
394
398
  const room = w - 4 - 3 - lw - 1;
395
399
  return g.items.map((it, k) => [
396
400
  cursorSeg(c, k === cur, mode),
397
401
  S(m.changed(it) ? icon(c, "update") : " ", "action"),
398
402
  S(" "),
399
- S(padTo(G(c, it.label), lw), labelRole(k === cur, mode)),
403
+ S(padTo(" ".repeat(it.indent || 0) + G(c, it.label), lw), labelRole(k === cur, mode)),
400
404
  S(" "),
401
405
  ...valueSegs(c, m, it, mode, room),
402
406
  ]);
@@ -488,7 +492,7 @@ function detailFrame(c, m, it, fr, w, h, focus) {
488
492
  }
489
493
  const cap = focus && it.caption ? it.caption() : null;
490
494
  return frame(c, {
491
- title: it.label,
495
+ title: it.path || it.label,
492
496
  rows,
493
497
  w,
494
498
  h,
@@ -644,7 +648,7 @@ export function renderMenu(m, { caps: c, columns = 100, rows = 30, head = "proje
644
648
  const wideEnough = columns >= STAIRCASE.min;
645
649
  if (!wideEnough) {
646
650
  // One frame: the one the keys act on, under the whole path.
647
- const names = ["Settings", ...(level >= 1 ? [g.title] : []), ...(level >= 2 ? [it.label] : [])];
651
+ const names = ["Settings", ...(level >= 1 ? [g.title] : []), ...(level >= 2 ? [it.path || it.label] : [])];
648
652
  out([
649
653
  S(`${icon(c, "ask")} `, "action"),
650
654
  S(names.slice(0, -1).join(sep) + (names.length > 1 ? sep : ""), "explanation"),