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.
@@ -302,9 +302,7 @@ export function hostPresence(manifest, { env = process.env, home = homedir() } =
302
302
  x.cli.bin,
303
303
  )?.[1] || null;
304
304
  const bin = s ? s.cli.bin : null;
305
- const r = manifest?.runtime || {};
306
- const fromEnv = r.home_env && env ? env[r.home_env] : null;
307
- const homeDir = fromEnv || (home && r.home_default ? join(home, r.home_default) : null);
305
+ const homeDir = harnessHome(manifest, { env, home });
308
306
  let present = false;
309
307
  if (homeDir) {
310
308
  try {
@@ -314,6 +312,15 @@ export function hostPresence(manifest, { env = process.env, home = homedir() } =
314
312
  return { cli: bin ? whichOnPath(bin, env || {}) : null, home: present ? homeDir : null, bin, homeDir };
315
313
  }
316
314
 
315
+ // A harness's own home directory, as the harness resolves it: its home
316
+ // variable (runtime.home_env) when set, else home_default under the home
317
+ // directory, else null. Whether it exists is the caller's question.
318
+ export function harnessHome(manifest, { env = process.env, home = homedir() } = {}) {
319
+ const r = manifest?.runtime || {};
320
+ const fromEnv = r.home_env && env ? env[r.home_env] : null;
321
+ return fromEnv || (home && r.home_default ? join(home, r.home_default) : null);
322
+ }
323
+
317
324
  // The agents block of a RUN (the story "One run plans the agents block once,
318
325
  // and a bare uninstall selects every harness the project uses", rules 1–2):
319
326
  // the files every manifest of the run names for it, in the FIRST manifest's
@@ -703,6 +710,9 @@ export function cachePaths({ env = process.env, home = homedir() } = {}) {
703
710
  fetch: root ? join(root, "fetch") : null,
704
711
  fetchRun: (id) => (root ? join(root, "fetch", id) : null),
705
712
  registry: root ? join(root, "registry.json") : null,
713
+ // A harness's model list, as its last live read answered (the ADR "Agent
714
+ // model lists are read from each harness's own catalog…", decision 4).
715
+ models: (id) => (root && id ? join(root, "models", `${id}.json`) : null),
706
716
  from: fromEnv ? variable : "the home directory",
707
717
  variable,
708
718
  };
@@ -779,6 +789,14 @@ export function hasBand(manifest) {
779
789
  return manifest?.surfaces?.band?.supported === true;
780
790
  }
781
791
 
792
+ // Whether a harness has the status-line surface: the one predicate for "a
793
+ // harness without a status line", read by configure (its spec, contract 2).
794
+ // Install's own test is the same: a surface declared and not unsupported.
795
+ export function hasStatusLine(manifest) {
796
+ const s = manifest?.surfaces?.statusline;
797
+ return Boolean(s) && s.supported !== false;
798
+ }
799
+
782
800
  // The session id the host hands a tool process, by the manifest's name for
783
801
  // it (runtime.session_id_env), or null where the harness names none or the
784
802
  // process got none.
@@ -1678,15 +1678,21 @@ function planKeepRecord(ctx) {
1678
1678
  });
1679
1679
  }
1680
1680
 
1681
- // The status line's flag (the setup story, rule 9): setup's answer stored as
1682
- // `statusline.enabled`, the key bind's interview writes; every other key of
1683
- // a `statusline` object is kept. Planned for the run's harness that has the
1684
- // status-line surface, when the binding the run leaves does not already say
1685
- // the same; only when there is a binding to record it in.
1681
+ // The status line's flag (the setup story, rule 9; the configure spec,
1682
+ // contract 6): `statusline.enabled`, from setup's answer or from a run that
1683
+ // names the surface — install records on, uninstall off. This item is the
1684
+ // flag's one writer; every other key of a `statusline` object is kept.
1685
+ // Planned for the run's harness that has the status-line surface, when the
1686
+ // binding the run leaves does not already say the same; only when there is a
1687
+ // binding to record it in.
1686
1688
  const STATUSLINE_FLAG = "statusline.enabled";
1687
- function planStatuslineFlag(ctx, value) {
1689
+ function planStatuslineFlag(ctx, value, mode = "install") {
1688
1690
  const b = ctx.binding;
1689
1691
  if (!b || !b.bound || typeof value !== "boolean" || b.statusline === value) return null;
1692
+ const off =
1693
+ mode === "uninstall"
1694
+ ? "the status line is removed in this run, and the SessionStart hook keeps it off"
1695
+ : "the status line stays off, and setup does not ask again";
1690
1696
  return bindingItem({
1691
1697
  harness: ctx.harness.id,
1692
1698
  surface: "statusline_flag",
@@ -1697,7 +1703,7 @@ function planStatuslineFlag(ctx, value) {
1697
1703
  why: {
1698
1704
  [STATUSLINE_FLAG]: value
1699
1705
  ? "the status line is wired in this run, and the SessionStart hook keeps it current"
1700
- : "the status line stays off, and setup does not ask again",
1706
+ : off,
1701
1707
  },
1702
1708
  said: {
1703
1709
  unbound: "the project is not bound; the status line's flag is recorded once it is",
@@ -2342,12 +2348,24 @@ export function plan(
2342
2348
  const s = loadHarness(id).surfaces?.statusline;
2343
2349
  return Boolean(s) && s.supported !== false;
2344
2350
  }) || null;
2345
- const recordFor = (ctx) => {
2346
- if (mode === "uninstall") return;
2351
+ // The flag's value: setup's answer when it gave one; else, when the run
2352
+ // names the status-line surface, what the run does to it — on for install,
2353
+ // off for uninstall. Never on for a line this run neither wires nor finds
2354
+ // ours: a foreign slot, the keep's branch, a root that is never wired.
2355
+ const flagValue = (ctx, keep) => {
2356
+ if (typeof statusline === "boolean") return statusline;
2357
+ if (!(surfaces || []).includes("statusline")) return null;
2358
+ if (mode === "uninstall") return false;
2359
+ if (keep || ctx.slotForeign.has("statusline")) return null;
2360
+ const row = out.items.find((i) => i.harness === ctx.harness.id && i.surface === "statusline");
2361
+ return row && (row.action !== "skip" || row.state === "ours-current") ? true : null;
2362
+ };
2363
+ const recordFor = (ctx, { keep = false } = {}) => {
2347
2364
  if (ctx.harness.id === flagHarness) {
2348
- const flag = planStatuslineFlag(ctx, statusline);
2365
+ const flag = planStatuslineFlag(ctx, flagValue(ctx, keep), mode);
2349
2366
  if (flag) marks.push(flag);
2350
2367
  }
2368
+ if (mode === "uninstall") return;
2351
2369
  if (recordKeep && keeping && ctx.harness.id === keeping.harness.id) {
2352
2370
  const kept = planKeepRecord(ctx);
2353
2371
  if (kept) marks.push(kept);
@@ -2464,7 +2482,7 @@ export function plan(
2464
2482
  out.refusals.push(...ctx.refusals);
2465
2483
  if (ctx.incomplete) out.incomplete = true;
2466
2484
  stateFor(id, harness);
2467
- recordFor(ctx);
2485
+ recordFor(ctx, { keep: true });
2468
2486
  continue;
2469
2487
  }
2470
2488
  const hostRows = [];
@@ -90,10 +90,15 @@ export function sharedRoleForm(name) {
90
90
  return invocation(sourceHarness(), name, { kind: "agents" });
91
91
  }
92
92
  // What a welcome says about updates, and the product name a message uses.
93
- export function updateHint({ env = process.env } = {}) {
93
+ export function updateHint({ env = process.env, root = pluginRoot(), home = homedir() } = {}) {
94
94
  const m = speakingHarness(env) || {};
95
95
  // A harness that declares no hint gets none — never another harness's advice.
96
- const hint = m.update_hint || { line: "", welcome: [] };
96
+ // A copy loaded from another channel than the hint's own says that
97
+ // channel's way to update (installChannel: string tests, no file read, so
98
+ // the SessionStart budget holds): a git-marketplace copy keeps the host's
99
+ // auto-update toggle, the npm registration and a checkout get the npm form.
100
+ const channel = m.update_hint?.by_channel ? installChannel(root, { home, harness: m }) : null;
101
+ const hint = (channel && m.update_hint.by_channel[channel]) || m.update_hint || { line: "", welcome: [] };
97
102
  // `{upgrade}` is the core's upgrade for this harness, pinned to latest so it
98
103
  // runs as printed, with no --project: the hint says where to run it (the
99
104
  // story "User-facing text says where to run…", rule 2). Any hint that holds
@@ -1723,8 +1728,9 @@ export function listOf(fm, key) {
1723
1728
  // the policy survives clones and second machines), Obsidian hides it, and it
1724
1729
  // is intentionally NOT inside <vault>/.projectstore/, whose .gitignore ("*")
1725
1730
  // would defeat the whole point. Keys: spec_policy ("required"|"optional"),
1726
- // lifecycle_gates ("on"|"off"), spec_policy_since (ISO-8601, stamped when
1727
- // spec_policy first becomes "required").
1731
+ // lifecycle_gates ("on"|"off"), spec_policy_since (ISO-8601, stamped whenever
1732
+ // spec_policy becomes "required" from anything else; kept otherwise — the
1733
+ // configure spec, contract 7).
1728
1734
  export function vaultConfigPath(vault) {
1729
1735
  return join(vault, LAYOUT.vaultConfig);
1730
1736
  }
@@ -1740,8 +1746,38 @@ export function readVaultConfig(vault) {
1740
1746
  }
1741
1747
  }
1742
1748
 
1749
+ // The strict read a writer needs: a file that exists and is not a JSON
1750
+ // object is `corrupt`, never an empty config a write would then replace.
1751
+ export function readVaultConfigStrict(vault) {
1752
+ const path = vaultConfigPath(vault);
1753
+ if (!existsSync(path)) return { path, present: false, corrupt: false, cfg: {} };
1754
+ try {
1755
+ const v = JSON.parse(readFileSync(path, "utf8"));
1756
+ if (v && typeof v === "object" && !Array.isArray(v)) return { path, present: true, corrupt: false, cfg: v };
1757
+ } catch {}
1758
+ return { path, present: true, corrupt: true, cfg: null };
1759
+ }
1760
+
1761
+ // The config a change leaves, pure. A return to "required" re-stamps
1762
+ // spec_policy_since: a story done before the policy (re)took effect is exempt
1763
+ // (ADR-007 decision 6), and since every story the old stamp exempted closed
1764
+ // before the new one, no exemption is lost.
1765
+ export function nextVaultConfig(before, patch, now = new Date().toISOString()) {
1766
+ const next = { ...before, ...patch };
1767
+ if (patch.spec_policy === "required" && before.spec_policy !== "required") {
1768
+ // Never earlier than the stamp it replaces: one written ahead of this
1769
+ // clock (a hand edit, a machine whose clock ran fast) would otherwise
1770
+ // shrink the exemption the old stamp gave.
1771
+ const prev = before.spec_policy_since;
1772
+ next.spec_policy_since = prev && String(prev) > now ? prev : now;
1773
+ }
1774
+ return next;
1775
+ }
1776
+
1777
+ // The one writer of the vault config: atomic, and every key it was not asked
1778
+ // to change is kept as it was.
1743
1779
  export function writeVaultConfig(vault, cfg) {
1744
- writeFileSync(vaultConfigPath(vault), JSON.stringify(cfg, null, 2) + "\n", "utf8");
1780
+ writeFileAtomic(vaultConfigPath(vault), JSON.stringify(cfg, null, 2) + "\n");
1745
1781
  }
1746
1782
 
1747
1783
  // Legacy exemption (ADR-007 Decision 6): a story is exempt from spec-first