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
@@ -56,10 +56,13 @@ import {
56
56
  AGENTS_BLOCK_OPEN_SRC,
57
57
  AGENTS_BLOCK_CLOSE,
58
58
  agentsBlockVersion,
59
+ newerBlockWords,
60
+ isNewerBlock,
59
61
  findAgentsBlock,
60
62
  statusLineLauncherPath,
61
63
  LAUNCHER_HEADER,
62
64
  installedPluginEntries,
65
+ projectCopies,
63
66
  selectCopy,
64
67
  takesNoRegister,
65
68
  copyUpdateAdvice,
@@ -98,7 +101,9 @@ import {
98
101
  configPath as harnessConfigPath,
99
102
  packageCommand,
100
103
  invocation,
104
+ harnessForOverlay,
101
105
  } from "./harness.mjs";
106
+ import { effortSpec } from "./models.mjs";
102
107
 
103
108
  // A remedy used to interpolate the surface's harness variable here. It cannot:
104
109
  // measured 2026-09-06, NO harness gives its Bash tool that variable, and a
@@ -366,7 +371,7 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
366
371
  // A harness without a status line hears nothing about one, whatever the
367
372
  // shared binding asks for (generation spec, contract 18).
368
373
  if (harness?.surfaces?.statusline?.supported === false) return out;
369
- const local = hostSettingsPath(proj);
374
+ const local = hostSettingsPath(proj, process.env, harness);
370
375
  let cur = null;
371
376
  if (existsSync(local)) {
372
377
  try {
@@ -524,10 +529,10 @@ export function checkPendingUpgrade(proj, home = homedir(), root = pluginRoot(),
524
529
  // spec's 2026-10-03 amendment, case S1).
525
530
  let wired = null;
526
531
  try {
527
- wired = JSON.parse(readFileSync(hostSettingsPath(proj), "utf8"))?.statusLine?.command || null;
532
+ wired = JSON.parse(readFileSync(hostSettingsPath(proj, process.env, harness), "utf8"))?.statusLine?.command || null;
528
533
  } catch {}
529
534
  if (!wired || !statusLineIsOurWiring(wired, proj, home, root)) return [];
530
- const lp = pickExisting(statusLineLauncherPath(proj), legacyStatusLineLauncherPath(proj));
535
+ const lp = pickExisting(statusLineLauncherPath(proj, harness?.id), legacyStatusLineLauncherPath(proj));
531
536
  let text;
532
537
  try {
533
538
  text = readFileSync(lp, "utf8");
@@ -564,6 +569,7 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
564
569
  let unclosedFiles = 0;
565
570
  const perFile = {};
566
571
  const staleVersions = [];
572
+ const newerVersions = [];
567
573
  for (const name of ["CLAUDE.md", "AGENTS.md"]) {
568
574
  const p = join(proj, name);
569
575
  if (!existsSync(p)) continue;
@@ -607,7 +613,10 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
607
613
  }
608
614
  for (const m of text.matchAll(AGENT_BLOCK_MARKER)) {
609
615
  const v = parseInt(m[1], 10);
610
- if (v !== AGENT_BLOCK_VERSION) staleVersions.push({ file: name, v });
616
+ // A marker above this copy's template is never stale: this copy writes
617
+ // no block down (the install spec, contract 6, as amended 2026-10-14).
618
+ if (isNewerBlock(v, AGENT_BLOCK_VERSION)) newerVersions.push({ file: name, v });
619
+ else if (v !== AGENT_BLOCK_VERSION) staleVersions.push({ file: name, v });
611
620
  }
612
621
  }
613
622
  if (blocks === 0) {
@@ -664,6 +673,22 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
664
673
  );
665
674
  out.push(refuses ? f : moveRepairs(f, fact));
666
675
  }
676
+ // A newer block: an info in the install report only — the startup line
677
+ // counts issues alone and offers none of this — and only for one
678
+ // well-formed block. Its way out is the running copy's own bin from a
679
+ // terminal, never the session's command, whose copy would write its own
680
+ // version. Not folded into the layout move, which leaves it as it is too.
681
+ if (blocks === 1 && !refuses)
682
+ for (const s of newerVersions)
683
+ out.push(
684
+ finding(
685
+ "install",
686
+ "info",
687
+ "agents-block",
688
+ `Agents block in ${s.file} is ${newerBlockWords(s.v, AGENT_BLOCK_VERSION)}: install, upgrade and setup leave it as it is. To write this copy's v${AGENT_BLOCK_VERSION} over it ${fromTerminalIn(proj)}:\nnode "${join(root, "bin", "projectstore.mjs")}" install --harness ${newerReader(proj, s.file, env)} --surface agents_block`,
689
+ s.file,
690
+ ),
691
+ );
667
692
  // Placement, held to the predicate install plans from (the install spec,
668
693
  // contract 6 as amended after the rc.3 tag): one well-formed block, seen by
669
694
  // every harness the project uses. rc.1 and rc.2 left a block in an
@@ -708,6 +733,19 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
708
733
  return out;
709
734
  }
710
735
 
736
+ // The harness a newer block's way out names: the one this run identified,
737
+ // else one the project uses whose files hold the block, else the source
738
+ // harness — never Claude Code's by default in a Codex-only project, whose
739
+ // install would create CLAUDE.md and overwrite nothing.
740
+ function newerReader(proj, file, env) {
741
+ const identified = identifiedHarnessId(env);
742
+ if (identified) return identified;
743
+ const used = detectHarnesses(proj, { state: true }).map((d) => d.id);
744
+ return (
745
+ used.find((id) => (loadHarness(id)?.surfaces?.agents_block?.files || []).includes(file)) || sourceHarness()?.id
746
+ );
747
+ }
748
+
711
749
  // While the layout move is pending, the findings the move itself repairs fold
712
750
  // into it instead of being counted beside it (the layout spec, contract 7 as
713
751
  // amended after the rc.3 tag; the upgrade story's O1): the stale block, which
@@ -891,7 +929,7 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
891
929
  "install",
892
930
  "issue",
893
931
  "overlay-forbidden-key",
894
- `${where} carries \`${k}\`, which an overlay may not: only agents.default.model and agents.per_agent.<name>.model are read; the key is ignored.`,
932
+ `${where} carries \`${k}\`, which an overlay may not: only agents.default.model, agents.default.effort, agents.per_agent.<name>.model and agents.per_agent.<name>.effort are read; the key is ignored.`,
895
933
  where,
896
934
  ),
897
935
  );
@@ -953,19 +991,42 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
953
991
  }
954
992
  }
955
993
  }
956
- // A configured name no roster agent carries runs nothing: the model never
957
- // applies. A warn, not an issue — a newer package's agent is a legitimate
958
- // reason for a committed overlay to name one this copy does not ship.
994
+ // A configured name no roster agent carries runs nothing: what it
995
+ // configures never applies. A warn, not an issue — a newer package's agent
996
+ // is a legitimate reason for a committed overlay to name one this copy does
997
+ // not ship.
998
+ // An effort the harness's levels lack fails at spawn on a harness whose
999
+ // spawn takes those levels only, and is the harness's to judge elsewhere:
1000
+ // a warn names it either way (the effort ADR, decision 7). Read against the
1001
+ // manifest's floor alone: doctor reads no model catalog.
1002
+ const levels = effortSpec(harnessForOverlay(o.id))?.levels?.map((l) => l.id) || null;
1003
+ if (levels) {
1004
+ const set = [
1005
+ ...(o.efforts.default ? [["agents.default.effort", o.efforts.default]] : []),
1006
+ ...Object.entries(o.efforts.per_agent).map(([n, v]) => [`agents.per_agent.${n}.effort`, v]),
1007
+ ];
1008
+ for (const [k, v] of set)
1009
+ if (!levels.includes(v))
1010
+ out.push(
1011
+ finding(
1012
+ "install",
1013
+ "warn",
1014
+ "overlay-unknown-effort",
1015
+ `${where} sets \`${k}\` to ${v}, which is not one of ${harnessForOverlay(o.id)?.display_name || o.id}'s effort levels (${levels.join(", ")}) — a spawn may refuse it.`,
1016
+ where,
1017
+ ),
1018
+ );
1019
+ }
959
1020
  const roster = layoutRoster(cfg);
960
1021
  if (roster) {
961
- for (const n of Object.keys(o.agents.per_agent)) {
1022
+ for (const n of new Set([...Object.keys(o.agents.per_agent), ...Object.keys(o.efforts.per_agent)])) {
962
1023
  if (!roster.includes(n))
963
1024
  out.push(
964
1025
  finding(
965
1026
  "install",
966
1027
  "warn",
967
1028
  "overlay-unknown-agent",
968
- `${where} configures \`${n}\`, which is not in the ${cfg.layout} roster (${roster.join(", ")}) — no agent by that name runs, so the model never applies. A typo, or an agent this package does not ship yet.`,
1029
+ `${where} configures \`${n}\`, which is not in the ${cfg.layout} roster (${roster.join(", ")}) — no agent by that name runs, so what it configures never applies. A typo, or an agent this package does not ship yet.`,
969
1030
  where,
970
1031
  ),
971
1032
  );
@@ -1148,7 +1209,16 @@ export function checkVersionDrift(home = homedir(), states = [], proj = null, {
1148
1209
  const seen = [];
1149
1210
  // The registry read here is the source harness's own.
1150
1211
  const registryOf = sourceHarness()?.id || null;
1151
- for (const e of installedPluginEntries(home, proj)) {
1212
+ // The copies the host loads for this project (contract 17, amended
1213
+ // 2026-10-14): this checkout's own row or a user-scope row, never another
1214
+ // project's — the registry keeps every project's local rows, and another
1215
+ // checkout's old copy is not a drift here. Every row, not one per key: this
1216
+ // project's own row against a user-scope row is the drift contract 17 names.
1217
+ // Without a project, the user-scope rows alone.
1218
+ const rows = proj
1219
+ ? projectCopies(home, proj)
1220
+ : installedPluginEntries(home).filter((e) => e.present && e.enabled !== false && !e.projectPath);
1221
+ for (const e of rows) {
1152
1222
  // A disabled registration is not a copy anyone runs (contract 17, amended 2026-09-05).
1153
1223
  if (e.version && e.present && e.enabled !== false)
1154
1224
  seen.push({
@@ -1284,24 +1354,32 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
1284
1354
  return out;
1285
1355
  }
1286
1356
 
1287
- // ADR-008 made `effort` unconfigurable per project, which promotes this env var
1288
- // from a curiosity to the ONLY thing that can move our agents off `effort: max`.
1289
- // It beats frontmatter, so a value set for cost or latency silently drops all
1290
- // five agents below the quality floor the plugin advertises — exactly the class
1291
- // of silent downgrade doctor exists to name.
1357
+ // The variable outranks every effort projectstore passes at spawn and the
1358
+ // bundled agents' frontmatter effort: with it set, the spawn offers no effort
1359
+ // parameter at all (the effort ADR, decision 7). So a value set for cost or
1360
+ // latency silently replaces the efforts the overlay configures — exactly the
1361
+ // class of "the value configured is not the value that runs" doctor names.
1292
1362
  // The variable names come from the manifest (runtime.agent_overrides); the
1293
- // wording is projectstore's, because the claim — ADR-008's quality floor — is.
1294
- export function checkEnvEffort() {
1363
+ // overlay is read only when one is set, so a clean startup reads nothing.
1364
+ export function checkEnvEffort(proj = null) {
1295
1365
  return agentOverrides()
1296
1366
  .filter((o) => o.kind === "effort")
1297
- .map((o) =>
1298
- finding(
1367
+ .map((o) => {
1368
+ const ov = proj ? readOverlayAt(proj) : null;
1369
+ const set = ov
1370
+ ? [
1371
+ ...(ov.efforts.default ? ["agents.default.effort"] : []),
1372
+ ...Object.keys(ov.efforts.per_agent).map((n) => `agents.per_agent.${n}.effort`),
1373
+ ]
1374
+ : [];
1375
+ const where = set.length ? ` (${relative(proj, ov.path) || ov.path} sets ${set.join(", ")})` : "";
1376
+ return finding(
1299
1377
  "install",
1300
1378
  "warn",
1301
1379
  "env-effort",
1302
- `${o.env}=${o.value} is set — it overrides the bundled agents' "effort: max" frontmatter, so every projectstore agent runs at "${o.value}". Effort is not configurable per project (ADR-008); unset the variable to restore the quality floor.`,
1303
- ),
1304
- );
1380
+ `${o.env}=${o.value} is set — it outranks every effort projectstore passes at spawn${where} and the bundled agents' frontmatter, so every projectstore agent runs at "${o.value}". Unset it for the configured efforts to run.`,
1381
+ );
1382
+ });
1305
1383
  }
1306
1384
 
1307
1385
  export function checkEnvModel() {
@@ -1538,28 +1616,48 @@ export function checkGitignore(proj) {
1538
1616
  }
1539
1617
  }
1540
1618
  }
1541
- if (!existsSync(join(proj, ".git"))) return out;
1619
+ // The host's settings file is checkHostIgnored's (the layout spec, contract 9
1620
+ // as amended 2026-10-14): only git knows every rule that ignores it, and this
1621
+ // function runs in the SessionStart subset, whose budget forbids a subprocess.
1622
+ return out;
1623
+ }
1624
+
1625
+ // The line read the host-file check used before it asked git, and still uses
1626
+ // when git cannot answer: the project's own .gitignore, exact lines only.
1627
+ function hostIgnoreLineMissing(proj, env) {
1542
1628
  let lines = [];
1543
1629
  try {
1544
1630
  lines = readFileSync(join(proj, ".gitignore"), "utf8")
1545
1631
  .split("\n")
1546
1632
  .map((l) => l.trim());
1547
1633
  } catch {}
1548
- const coveredAll = lines.includes(".claude/") || lines.includes(".claude");
1549
- if (coveredAll) return out;
1550
- // Our own files are self-ignored inside .projectstore/; what is left is the host's.
1551
- const wanted = [relative(proj, hostSettingsPath(proj))];
1552
- const missing = wanted.filter((w) => !lines.includes(w));
1553
- if (!missing.length) return out;
1554
- out.push(
1634
+ if (lines.includes(".claude/") || lines.includes(".claude")) return [];
1635
+ return [relative(proj, hostSettingsPath(proj, env, sourceHarness()))].filter((w) => !lines.includes(w));
1636
+ }
1637
+
1638
+ // Whether git ignores the host's machine-local settings file (the layout spec,
1639
+ // contract 9 as amended 2026-10-14). Our own files are self-ignored inside
1640
+ // .projectstore/; what is left is the host's. Git's answer covers every rule
1641
+ // it applies — nested .gitignore files, .git/info/exclude, core.excludesFile
1642
+ // and its XDG default — where the line read saw only the project's exact
1643
+ // lines. --no-index keeps this about rules: a tracked file with a matching rule
1644
+ // is quiet here (tracking is checkTrackedRuntime's). Exit 0 is ignored, 1 is
1645
+ // not; anything else — no git on PATH, an error, a timeout — falls back to the
1646
+ // line read. Full report only: a subprocess, like checkTrackedRuntime.
1647
+ export function checkHostIgnored(proj, { env = process.env } = {}) {
1648
+ if (!existsSync(join(proj, ".git"))) return [];
1649
+ const file = relative(proj, hostSettingsPath(proj, env, sourceHarness()));
1650
+ const r = spawnSync("git", ["check-ignore", "-q", "--no-index", "--", file], { cwd: proj, env, timeout: 5000 });
1651
+ const missing = r.status === 0 ? [] : r.status === 1 ? [file] : hostIgnoreLineMissing(proj, env);
1652
+ if (!missing.length) return [];
1653
+ return [
1555
1654
  finding(
1556
1655
  "install",
1557
1656
  "warn",
1558
1657
  "gitignore",
1559
1658
  `Machine-specific files not gitignored: ${missing.join(", ")} (or ignore ".claude/" wholesale).`,
1560
1659
  ),
1561
- );
1562
- return out;
1660
+ ];
1563
1661
  }
1564
1662
 
1565
1663
  // An ignore line never untracks a file already in the index, so the project
@@ -2956,8 +3054,9 @@ export async function runInstallChecks(cfg, proj, opts = {}) {
2956
3054
  ...checkAgentsBlock(proj, { env: opts.env, root: opts.root }),
2957
3055
  ...checkOverrideCopies(proj),
2958
3056
  ...checkEnvModel(),
2959
- ...checkEnvEffort(),
3057
+ ...checkEnvEffort(proj),
2960
3058
  ...checkGitignore(proj),
3059
+ ...checkHostIgnored(proj, { env: opts.env }),
2961
3060
  ...checkTrackedRuntime(proj),
2962
3061
  ...checkVaultGit(cfg),
2963
3062
  ...checkAutoUpdate(),
@@ -3042,7 +3141,7 @@ export function runStartupChecks(cfg, proj, budgetMs = 150) {
3042
3141
  restamp: findings.some((f) => f.level === "info" && f.check === "upgrade"),
3043
3142
  }),
3044
3143
  () => checkEnvModel(),
3045
- () => checkEnvEffort(),
3144
+ () => checkEnvEffort(proj),
3046
3145
  ];
3047
3146
  for (const step of steps) {
3048
3147
  if (Date.now() - started > budgetMs) return { skipped: true, count: 0, findings: foldIntoMove(findings) };
@@ -503,10 +503,12 @@ export function speakingHarness(env = process.env, dir = MANIFEST_DIR) {
503
503
 
504
504
  // ─── Runtime names and paths ───────────────────────────────────────────
505
505
 
506
- // The three branded names the active harness uses, so a caller can say which
507
- // variable it is talking about without spelling it.
508
- export function runtimeEnvNames(env = process.env) {
509
- const r = activeHarness(env)?.runtime || {};
506
+ // The three branded names a harness uses — the active one unless the caller
507
+ // names it — so a caller can say which variable it is talking about without
508
+ // spelling it. A surface rendered FOR a harness (the status-line launcher is
509
+ // Claude Code's) names that harness, whichever process renders it.
510
+ export function runtimeEnvNames(env = process.env, harness = activeHarness(env)) {
511
+ const r = harness?.runtime || {};
510
512
  return { projectDir: r.project_dir_env || null, pluginRoot: r.plugin_root_env || null, home: r.home_env || null };
511
513
  }
512
514
 
@@ -816,17 +818,20 @@ export function pickExisting(current, legacy) {
816
818
 
817
819
  // The per-project directory the HARNESS discovers (".claude" for the source
818
820
  // layout) — how a harness is detected in a project and where its own settings
819
- // live. Not where our config is: that is layoutPaths() (2026-09-06).
820
- export function projectConfigDir(env = process.env) {
821
- return activeHarness(env)?.runtime?.harness_dir || ".claude";
821
+ // live. Not where our config is: that is layoutPaths() (2026-09-06). A
822
+ // caller reading ONE harness's settings names it: which harness launched the
823
+ // process is not whose directory that is (the install spec, contract 6, as
824
+ // amended 2026-10-14).
825
+ export function projectConfigDir(env = process.env, harness = activeHarness(env)) {
826
+ return harness?.runtime?.harness_dir || ".claude";
822
827
  }
823
828
 
824
829
  // The host's own machine-local settings file in a project (the statusline
825
830
  // surface's file in the manifest — `.claude/settings.local.json` for Claude
826
831
  // Code). A harness surface, not ours: the one `.claude` path the core may
827
832
  // build, and it builds it from the manifest (2026-09-06).
828
- export function hostSettingsPath(projectDir, env = process.env) {
829
- const h = activeHarness(env);
833
+ export function hostSettingsPath(projectDir, env = process.env, harness = activeHarness(env)) {
834
+ const h = harness;
830
835
  const file = h?.surfaces?.statusline?.file || join(h?.runtime?.harness_dir || ".claude", "settings.local.json");
831
836
  return join(projectDir, file);
832
837
  }