projectstore-claude 0.29.2 → 0.29.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 (76) hide show
  1. package/README.md +1 -1
  2. package/bin/projectstore-claude.mjs +1 -1
  3. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  4. package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
  5. package/node_modules/projectstore/README.md +44 -7
  6. package/node_modules/projectstore/commands/bar.md +30 -0
  7. package/node_modules/projectstore/commands/bind.md +16 -20
  8. package/node_modules/projectstore/commands/doctor.md +12 -7
  9. package/node_modules/projectstore/commands/scaffold.md +24 -16
  10. package/node_modules/projectstore/commands/status.md +1 -1
  11. package/node_modules/projectstore/commands/statusline.md +2 -2
  12. package/node_modules/projectstore/commands/story.md +15 -2
  13. package/node_modules/projectstore/docs/alongside.md +167 -0
  14. package/node_modules/projectstore/docs/extending.md +17 -3
  15. package/node_modules/projectstore/docs/getting-started.md +18 -9
  16. package/node_modules/projectstore/docs/harnesses.md +1 -1
  17. package/node_modules/projectstore/docs/how-it-works.md +12 -3
  18. package/node_modules/projectstore/harnesses/claude-code.json +23 -8
  19. package/node_modules/projectstore/harnesses/codex.json +16 -6
  20. package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
  21. package/node_modules/projectstore/hooks/band.tsx +622 -0
  22. package/node_modules/projectstore/hooks/hooks.json +1 -0
  23. package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
  24. package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
  25. package/node_modules/projectstore/hooks/session-start.mjs +56 -20
  26. package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
  27. package/node_modules/projectstore/package.json +12 -2
  28. package/node_modules/projectstore/scaffold/headings.json +8 -0
  29. package/node_modules/projectstore/scripts/band-shared.mjs +446 -0
  30. package/node_modules/projectstore/scripts/band.mjs +207 -0
  31. package/node_modules/projectstore/scripts/binding.mjs +733 -62
  32. package/node_modules/projectstore/scripts/build-adapters.mjs +98 -35
  33. package/node_modules/projectstore/scripts/cli.mjs +1463 -267
  34. package/node_modules/projectstore/scripts/codemap.mjs +22 -7
  35. package/node_modules/projectstore/scripts/convergence.mjs +638 -0
  36. package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
  37. package/node_modules/projectstore/scripts/doctor-report.mjs +253 -0
  38. package/node_modules/projectstore/scripts/doctor.mjs +1384 -420
  39. package/node_modules/projectstore/scripts/draft.mjs +5 -2
  40. package/node_modules/projectstore/scripts/fetch-shell.mjs +369 -0
  41. package/node_modules/projectstore/scripts/graph.mjs +19 -13
  42. package/node_modules/projectstore/scripts/harness.mjs +283 -37
  43. package/node_modules/projectstore/scripts/install-harness.mjs +4701 -575
  44. package/node_modules/projectstore/scripts/kanban.mjs +6 -21
  45. package/node_modules/projectstore/scripts/lib.mjs +933 -199
  46. package/node_modules/projectstore/scripts/mcp.mjs +386 -105
  47. package/node_modules/projectstore/scripts/portable-registration.mjs +276 -56
  48. package/node_modules/projectstore/scripts/provenance.mjs +26 -10
  49. package/node_modules/projectstore/scripts/query.mjs +441 -72
  50. package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
  51. package/node_modules/projectstore/scripts/run-host.mjs +139 -0
  52. package/node_modules/projectstore/scripts/scaffold.mjs +244 -0
  53. package/node_modules/projectstore/scripts/setup.mjs +987 -0
  54. package/node_modules/projectstore/scripts/stages.mjs +84 -0
  55. package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
  56. package/node_modules/projectstore/scripts/statusline.mjs +55 -14
  57. package/node_modules/projectstore/scripts/story-section.mjs +39 -17
  58. package/node_modules/projectstore/scripts/surfaces.mjs +485 -99
  59. package/node_modules/projectstore/scripts/term.mjs +1027 -52
  60. package/node_modules/projectstore/scripts/tokens.mjs +106 -28
  61. package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
  62. package/node_modules/projectstore/scripts/version-guard.mjs +79 -27
  63. package/node_modules/projectstore/scripts/worktree.mjs +16 -5
  64. package/node_modules/projectstore/templates/de/strings.json +63 -1
  65. package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
  66. package/node_modules/projectstore/templates/en/strings.json +63 -1
  67. package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
  68. package/node_modules/projectstore/templates/es/strings.json +63 -1
  69. package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
  70. package/node_modules/projectstore/templates/fr/strings.json +63 -1
  71. package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
  72. package/node_modules/projectstore/templates/ru/strings.json +63 -1
  73. package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
  74. package/node_modules/projectstore/templates/zh/strings.json +63 -1
  75. package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
  76. package/package.json +2 -2
@@ -24,8 +24,8 @@
24
24
  //
25
25
  // Pure node, no external deps — same constraint as lib.mjs. Read-only.
26
26
 
27
- import { readFileSync, existsSync, readdirSync, realpathSync } from "node:fs";
28
- import { join, dirname, resolve, sep } from "node:path";
27
+ import { readFileSync, existsSync, readdirSync, realpathSync, statSync } from "node:fs";
28
+ import { join, dirname, resolve, sep, relative, isAbsolute } from "node:path";
29
29
  import { homedir } from "node:os";
30
30
  import { fileURLToPath } from "node:url";
31
31
 
@@ -51,9 +51,11 @@ let _cache = null;
51
51
  export function loadHarnesses(dir = MANIFEST_DIR) {
52
52
  if (_cache && _cache.dir === dir) return _cache.map;
53
53
  const map = new Map();
54
- let names = [];
54
+ let names;
55
55
  try {
56
- names = readdirSync(dir).filter((n) => n.endsWith(".json")).sort();
56
+ names = readdirSync(dir)
57
+ .filter((n) => n.endsWith(".json"))
58
+ .sort();
57
59
  } catch {
58
60
  names = [];
59
61
  }
@@ -153,7 +155,8 @@ export function identifiedHarnessId(env = process.env, dir = MANIFEST_DIR) {
153
155
  if (forced && loadHarnesses(dir).has(forced)) return forced;
154
156
  // A parent of ours decided before it named the project in the harness's own
155
157
  // vocabulary (childEnv), so its answer stands — an empty one included.
156
- if (env[IDENTIFIED_ENV] !== undefined) return loadHarnesses(dir).has(env[IDENTIFIED_ENV]) ? env[IDENTIFIED_ENV] : null;
158
+ if (env[IDENTIFIED_ENV] !== undefined)
159
+ return loadHarnesses(dir).has(env[IDENTIFIED_ENV]) ? env[IDENTIFIED_ENV] : null;
157
160
  const best = ranked(env, dir);
158
161
  if (best && best.strong) return best.id;
159
162
  for (const m of loadHarnesses(dir).values()) {
@@ -226,23 +229,175 @@ export function resetDetection() {
226
229
  // process" from branded environment variables, and using it here would
227
230
  // conjure .claude/ for a Codex user. Not memoised: it is per directory, and a
228
231
  // test builds a project per case.
229
- export function detectHarnesses(projectDir, { dir = MANIFEST_DIR } = {}) {
232
+ //
233
+ // `state` adds the second signal `upgrade` reads: a harness whose state
234
+ // directory of ours exists (layoutPaths(p).harnessState(id), layout spec
235
+ // contract 5). A Codex session wrote its welcome marker there, so Codex is
236
+ // used here even with no .codex/ (measured in this repository, 2026-10-08).
237
+ // Never a machine-global registration: one exists for every project on the
238
+ // machine and would select its harness everywhere. One entry per manifest,
239
+ // the directory first, in manifest order as before.
240
+ export function detectHarnesses(projectDir, { dir = MANIFEST_DIR, state = false } = {}) {
230
241
  const out = [];
242
+ const p = state ? layoutPaths(projectDir, { dir }) : null;
231
243
  for (const m of loadHarnesses(dir).values()) {
232
244
  const d = m.runtime?.harness_dir;
233
- if (d && existsSync(join(projectDir, d))) out.push({ id: m.id, why: "directory", evidence: d });
245
+ if (d && existsSync(join(projectDir, d))) {
246
+ out.push({ id: m.id, why: "directory", evidence: d });
247
+ continue;
248
+ }
249
+ if (!p) continue;
250
+ const at = p.harnessState(m.id);
251
+ let isDir = false;
252
+ try {
253
+ isDir = statSync(at).isDirectory();
254
+ } catch {}
255
+ if (isDir) out.push({ id: m.id, why: "state", evidence: relative(projectDir, at) });
234
256
  }
235
257
  return out;
236
258
  }
237
259
 
238
- // The refusal when nothing is detected and nothing is named — built from the
239
- // manifests, so a new harness appears in it without an edit here.
240
- export function harnessRefusal(projectDir, dir = MANIFEST_DIR) {
241
- const lines = [`No harness detected in ${projectDir}, and none named. Name one:`];
242
- for (const m of loadHarnesses(dir).values()) {
243
- lines.push(` --harness ${m.id} (${m.display_name}; detected by its project directory: ${m.runtime?.harness_dir || "?"})`);
260
+ // A pure PATH walk for a host binary — a read, not a subprocess: the analysers
261
+ // run on every doctor call. Returns the absolute path or null. It lives here,
262
+ // beside hostPresence, which needs it; lib.mjs re-exports it under the name
263
+ // it already published.
264
+ export function whichOnPath(name, env = process.env) {
265
+ const sep = process.platform === "win32" ? ";" : ":";
266
+ const exts = process.platform === "win32" ? ["", ".cmd", ".exe", ".bat"] : [""];
267
+ for (const dir of String(env.PATH || "")
268
+ .split(sep)
269
+ .filter(Boolean)) {
270
+ for (const ext of exts) {
271
+ const p = join(dir, name + ext);
272
+ try {
273
+ const st = statSync(p);
274
+ if (st.isFile() && (process.platform === "win32" || st.mode & 0o111)) return p;
275
+ } catch {}
276
+ }
277
+ }
278
+ return null;
279
+ }
280
+
281
+ // Whether a harness's host is on this MACHINE (the story "projectstore setup
282
+ // asks, shows one plan and installs every harness it finds, with the vault",
283
+ // rule 5): its registration surface's CLI found on PATH, and its home
284
+ // directory present — the manifest's home variable when set, else
285
+ // <home>/<home_default>. Not detectHarnesses, which answers for a PROJECT, and
286
+ // not detectHarnessId, which names the harness running this process. Setup's
287
+ // pre-tick is the one place machine presence counts; install keeps contract 8.
288
+ // Every name comes from the manifest. Read-only: a stat per PATH entry and one
289
+ // for the home.
290
+ // → { cli, home, bin, homeDir }: `cli` is the absolute path found, or null;
291
+ // `home` the directory when it exists, or null. `bin` and `homeDir` are
292
+ // what was looked for (null when the manifest names none), so a reason can
293
+ // name them when either is missing.
294
+ export function hostPresence(manifest, { env = process.env, home = homedir() } = {}) {
295
+ const s =
296
+ Object.entries(manifest?.surfaces || {}).find(
297
+ ([k, x]) =>
298
+ !k.startsWith("_") &&
299
+ x?.kind === "registration" &&
300
+ x.supported !== false &&
301
+ typeof x.cli?.bin === "string" &&
302
+ x.cli.bin,
303
+ )?.[1] || null;
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);
308
+ let present = false;
309
+ if (homeDir) {
310
+ try {
311
+ present = statSync(homeDir).isDirectory();
312
+ } catch {}
244
313
  }
245
- return lines.join("\n");
314
+ return { cli: bin ? whichOnPath(bin, env || {}) : null, home: present ? homeDir : null, bin, homeDir };
315
+ }
316
+
317
+ // The agents block of a RUN (the story "One run plans the agents block once,
318
+ // and a bare uninstall selects every harness the project uses", rules 1–2):
319
+ // the files every manifest of the run names for it, in the FIRST manifest's
320
+ // order. The block is written to the first of them, so it lands where every
321
+ // harness of the run can read it, natively or through the import the run
322
+ // creates. One manifest gives its own list unchanged, which is what keeps a
323
+ // single-harness plan as it was.
324
+ export function commonBlockFiles(manifests) {
325
+ const lists = manifests.map((m) => agentsBlockRow(m)?.[1].files || []);
326
+ if (!lists.length) return [];
327
+ return lists[0].filter((f) => lists.every((l) => l.includes(f)));
328
+ }
329
+
330
+ // A manifest's agents-block row as [key, surface], or null: found by its
331
+ // format, as the installer's handlers are keyed, never by the key's name.
332
+ export function agentsBlockRow(manifest) {
333
+ return (
334
+ Object.entries(manifest?.surfaces || {}).find(([k, s]) => !k.startsWith("_") && s?.format === "markdown-block") ||
335
+ null
336
+ );
337
+ }
338
+
339
+ // Rule 2, for the schema test: the manifests' lists share a file, or a run of
340
+ // all of them has nowhere to write the block. Null when they do; otherwise
341
+ // the problem, naming each manifest's list.
342
+ export function blockTargetProblem(manifests) {
343
+ const withBlock = manifests.filter((m) => agentsBlockRow(m));
344
+ if (commonBlockFiles(withBlock).length) return null;
345
+ return `the agents-block files lists share no file, so a run of every harness has no file to write the block to: ${withBlock.map((m) => `${m.id} ${JSON.stringify(agentsBlockRow(m)[1].files || [])}`).join(", ")}`;
346
+ }
347
+
348
+ // The refusal when nothing is detected and nothing is named — built from the
349
+ // manifests, so a new harness appears in it without an edit here. With
350
+ // `state` (upgrade's set) it names both signals the selection read.
351
+ export function harnessRefusal(projectDir, { dir = MANIFEST_DIR, state = false } = {}) {
352
+ const p = state ? layoutPaths(projectDir, { dir }) : null;
353
+ const rows = [...loadHarnesses(dir).values()].map((m) => {
354
+ const by = `detected by its project directory: ${m.runtime?.harness_dir || "?"}`;
355
+ return {
356
+ id: m.id,
357
+ display: m.display_name,
358
+ why: p ? `${by}, or by its state directory: ${relative(projectDir, p.harnessState(m.id))}` : by,
359
+ };
360
+ });
361
+ return [`No harness detected in ${projectDir}, and none named. Name one:`, ...harnessNameLines(rows)].join("\n");
362
+ }
363
+
364
+ // That refusal's rows, one per harness — `--harness <id>`, then its display
365
+ // name and why — shared with setup's (its rule 12), so the two cannot drift.
366
+ // The ids are padded to one width, so every reason starts in one column.
367
+ export function harnessNameLines(rows) {
368
+ const width = Math.max(0, ...rows.map((r) => r.id.length));
369
+ return rows.map((r) => ` --harness ${r.id.padEnd(width)} (${r.display}; ${r.why})`);
370
+ }
371
+
372
+ // Inside a live session of the host, its CLI and the session both rewrite the
373
+ // same settings files on their own schedules; the registration is planned
374
+ // only from a terminal outside one. The host marks its sessions in the
375
+ // environment (manifest runtime.session_env). It lives here, beside
376
+ // detection, so the installer and the shell fetch read one predicate without
377
+ // an import cycle.
378
+ export function insideHostSession(env, harness) {
379
+ // runtime.session_env, not detect_env: a Bash tool inside a session carries
380
+ // the session marker, not the plugin-root variables a hook receives
381
+ // (measured 2026-09-05; the critic's third pass caught the first draft
382
+ // keying on detect_env, which never fired in a session).
383
+ return (harness?.runtime?.session_env || []).some((k) => env && env[k]);
384
+ }
385
+
386
+ // Whether a harness's registration — and the shell fetch that would feed it —
387
+ // defers in this environment (the setup story, rule 15: the front-door ADR's
388
+ // first open question, answered by the maintainer on 2026-10-08). It defers
389
+ // inside the harness's own session (insideHostSession, runtime.session_env),
390
+ // or while one of its runtime.registration_defer_env variables is set. That
391
+ // second list defers the registration and the fetch and nothing else:
392
+ // isInteractive, the layout move and session identity read session_env alone.
393
+ // Codex's exec tool sets CODEX_THREAD_ID in every command (measured
394
+ // 2026-10-05), while its session_env stays empty so the doctor skill's
395
+ // `upgrade --json` still moves the layout there. Keyed on the PLANNED
396
+ // harness's manifest: inside one host's session another host's registration
397
+ // does not defer.
398
+ export function registrationDeferred(env, harness) {
399
+ if (insideHostSession(env, harness)) return true;
400
+ return (harness?.runtime?.registration_defer_env || []).some((k) => env && env[k]);
246
401
  }
247
402
 
248
403
  export function activeHarness(env = process.env, dir = MANIFEST_DIR) {
@@ -280,7 +435,10 @@ export function invocation(manifest, name, { kind = "commands", args = "" } = {}
280
435
  // read these same patterns.
281
436
  export function uiWordPatterns(manifest) {
282
437
  const esc = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
283
- return (manifest?.ui_vocabulary || []).map((word) => ({ word, re: new RegExp(`(?<![\\w.-])${esc(word)}(?![\\w-]|\\.\\w)`, "g") }));
438
+ return (manifest?.ui_vocabulary || []).map((word) => ({
439
+ word,
440
+ re: new RegExp(`(?<![\\w.-])${esc(word)}(?![\\w-]|\\.\\w)`, "g"),
441
+ }));
284
442
  }
285
443
 
286
444
  // The harness whose distribution shell bundles this core, by the shell's
@@ -301,7 +459,9 @@ export function bundlingShellHarnessId({ core = REPO_ROOT, dir = MANIFEST_DIR }
301
459
  const name = JSON.parse(readFileSync(join(dirname(parent), "package.json"), "utf8")).name;
302
460
  id = [...loadHarnesses(dir).values()].find((m) => m.install?.shell && m.install.shell === name)?.id ?? null;
303
461
  }
304
- } catch { id = null; }
462
+ } catch {
463
+ id = null;
464
+ }
305
465
  if (memo) _bundling = id;
306
466
  return id;
307
467
  }
@@ -325,10 +485,8 @@ function speaker(env, dir) {
325
485
  if (identified) return identified;
326
486
  const bundled = bundlingShellHarnessId({ dir });
327
487
  if (bundled) return bundled;
328
- if (env.PROJECTSTORE_SHELL) {
329
- const named = [...loadHarnesses(dir).values()].find((m) => m.install?.shell === env.PROJECTSTORE_SHELL);
330
- if (named) return named.id;
331
- }
488
+ const named = shellHarnessId(env, dir);
489
+ if (named) return named;
332
490
  return sourceHarness(dir)?.id ?? null;
333
491
  }
334
492
 
@@ -434,7 +592,13 @@ function hostPluginRoot(env) {
434
592
  // bin's child), is honoured as named. Real paths on both sides: the module's
435
593
  // own is already real, so a root under a symlink (macOS's /var and /tmp)
436
594
  // would never compare.
437
- const realOr = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
595
+ const realOr = (p) => {
596
+ try {
597
+ return realpathSync(p);
598
+ } catch {
599
+ return resolve(p);
600
+ }
601
+ };
438
602
  let _realCore = null;
439
603
  function containsThisCore(root) {
440
604
  const core = (_realCore ??= realOr(REPO_ROOT));
@@ -461,7 +625,7 @@ export function agentHome(env = process.env, home = homedir()) {
461
625
  // path is spelled — every reader and writer goes through layoutPaths(), and a
462
626
  // test greps the rest of scripts/, hooks/ and bin/ for the literals. The
463
627
  // legacy shape (.claude/projectstore.json, .claude/.projectstore/…) is named
464
- // here too, for the readers' fallback through 0.29 (layout spec, contracts
628
+ // here too, for the readers' fallback through 0.30 (layout spec, contracts
465
629
  // 0, 1 and 7); its harness directory is the source harness's own.
466
630
  export const LAYOUT = Object.freeze({
467
631
  root: ".projectstore",
@@ -516,19 +680,75 @@ export function layoutPaths(projectDir, { harnessDir = null, dir = MANIFEST_DIR
516
680
  };
517
681
  }
518
682
 
519
- // The command a user types from a terminal for this harness (the layout ADR
520
- // decision 6; layout spec contract 12): the harness's distribution shell when
521
- // the manifest names one — `npx projectstore-claude install --project "…"`,
522
- // the harness fixed by the shell — else the core with --harness. Finding
523
- // messages and deferred reasons are built here, so the installer names no
524
- // harness id and no shell name of its own. The shell's name is data
525
- // (install.shell), not `projectstore-<id>`: the id is claude-code, the
526
- // published name is projectstore-claude.
527
- export function packageCommand(harness, verb, { version = null, args = "" } = {}) {
528
- const shell = harness?.install?.shell || null;
529
- const pkg = `${shell || "projectstore"}${version ? `@${version}` : ""}`;
530
- const fixed = shell ? "" : ` --harness ${harness?.id || "<id>"}`;
531
- return `npx ${pkg} ${verb}${fixed}${args ? ` ${args}` : ""}`;
683
+ // ─── The user-level cache (layout spec contract 0, the user-level class) ─
684
+ //
685
+ // What is ours on the machine but belongs to no project and no harness home:
686
+ // the shell fetch's scratch (<root>/fetch/<pid>-<ms>/, removed when its run
687
+ // ends) and the registry answer the update notice caches for a day
688
+ // (<root>/registry.json, named now and read by no one yet). The XDG Base
689
+ // Directory spec puts a cache at $XDG_CACHE_HOME when that is an absolute
690
+ // path and ignores a relative one; an empty one is ignored too. Otherwise it
691
+ // is <home>/.cache. With neither, `root` is null and the fetch refuses. This
692
+ // is the one place either name is spelled (contract 0's grep): `from` and
693
+ // `variable` carry them into a refusal's text. env and home are always
694
+ // parameters.
695
+ export function cachePaths({ env = process.env, home = homedir() } = {}) {
696
+ const variable = "XDG_CACHE_HOME";
697
+ const xdg = env ? env[variable] : null;
698
+ const fromEnv = typeof xdg === "string" && xdg !== "" && isAbsolute(xdg);
699
+ const base = fromEnv ? xdg : home ? join(home, ".cache") : null;
700
+ const root = base ? join(base, "projectstore") : null;
701
+ return {
702
+ root,
703
+ fetch: root ? join(root, "fetch") : null,
704
+ fetchRun: (id) => (root ? join(root, "fetch", id) : null),
705
+ registry: root ? join(root, "registry.json") : null,
706
+ from: fromEnv ? variable : "the home directory",
707
+ variable,
708
+ };
709
+ }
710
+
711
+ // The core through npx: how every command a person is told to type next
712
+ // begins (the story "User-facing text says where to run and names no project
713
+ // path and no shell", rules 2 and 6). `npx` runs it with no bin on PATH.
714
+ export const CORE_NPX = "npx projectstore";
715
+
716
+ // The command a person types from a terminal (the front-door ADR, decision 5;
717
+ // the story above, rule 2): always the core, with one --harness per manifest
718
+ // in the order given — `npx projectstore[@<v>] <verb> --harness a [--harness
719
+ // b] [args]`. It names no shell, whatever the manifest's install.shell says,
720
+ // and its callers pass no --project: where to run it is their prose, `from a
721
+ // terminal in "<dir>"` (lib.mjs fromTerminalIn). The id stays because a bare
722
+ // upgrade or uninstall plans every harness the project uses. An empty array
723
+ // names none, for a verb that takes no --harness (bind, status, scaffold,
724
+ // reconcile). Finding messages, deferred reasons and DONE's tip are built
725
+ // here, so the installer spells no command of its own. The string is built
726
+ // here, not by the converge story's coreCommand, whose module imports this one.
727
+ export function packageCommand(harnesses, verb, { version = null, args = "" } = {}) {
728
+ const list = Array.isArray(harnesses) ? harnesses : [harnesses];
729
+ const ids = list.map((h) => ` --harness ${h?.id || "<id>"}`).join("");
730
+ return `${CORE_NPX}${version ? `@${version}` : ""} ${verb}${ids}${args ? ` ${args}` : ""}`;
731
+ }
732
+
733
+ // The package this run was invoked as: the shell it came through (the shell's
734
+ // bin sets PROJECTSTORE_SHELL, and fixes the harness), else the core. Only
735
+ // what describes the binary that ran uses it — the help's first line, its
736
+ // Usage lines, the fixed-harness line and the usage errors (the story above,
737
+ // rule 6). A command printed for a person to type next is the core's
738
+ // (packageCommand, CORE_NPX), through a shell too.
739
+ export function invokedAs(env = process.env) {
740
+ const shell = env.PROJECTSTORE_SHELL || null;
741
+ return { shell, cmd: shell ? `npx ${shell}` : CORE_NPX };
742
+ }
743
+
744
+ // The harness a shell fixes: the manifest whose install.shell is the name the
745
+ // shell's bin exports (PROJECTSTORE_SHELL), or null. Never speakingHarnessId,
746
+ // which a session's identity overrides: the Codex shell run inside a Claude
747
+ // Code session still installs Codex, so its help's examples name codex.
748
+ export function shellHarnessId(env = process.env, dir = MANIFEST_DIR) {
749
+ const shell = env.PROJECTSTORE_SHELL;
750
+ if (!shell) return null;
751
+ return [...loadHarnesses(dir).values()].find((m) => m.install?.shell === shell)?.id ?? null;
532
752
  }
533
753
 
534
754
  // The overlay a harness reads: <project>/.projectstore/harness/<overlay>.json —
@@ -552,6 +772,22 @@ export function harnessForOverlay(key, dir = MANIFEST_DIR) {
552
772
  return all.find((m) => m.runtime?.overlay === key) || all.find((m) => m.id === key) || null;
553
773
  }
554
774
 
775
+ // Whether a harness draws the band above the prompt (the band spec, contract
776
+ // 1): the one predicate for "a harness without a band", read by the generator
777
+ // (which keeps the module out of such a harness's tree) and by `band status`.
778
+ export function hasBand(manifest) {
779
+ return manifest?.surfaces?.band?.supported === true;
780
+ }
781
+
782
+ // The session id the host hands a tool process, by the manifest's name for
783
+ // it (runtime.session_id_env), or null where the harness names none or the
784
+ // process got none.
785
+ export function hostSessionId(env = process.env, harness = activeHarness(env)) {
786
+ const k = harness?.runtime?.session_id_env;
787
+ const v = k && env ? env[k] : null;
788
+ return typeof v === "string" && v ? v : null;
789
+ }
790
+
555
791
  // A reader's fallback: the new path when it exists, else the legacy one when
556
792
  // THAT exists, else the new path (a writer's target) — at most two existsSync
557
793
  // calls, never a directory scan (contract 1; the SessionStart budget).
@@ -681,7 +917,11 @@ export function lintPatterns(harness, dir = MANIFEST_DIR) {
681
917
  // shared_env to the derivation below would forbid Claude Code's own variable
682
918
  // inside Claude Code's own tree.
683
919
  const o = harness.runtime || {};
684
- const own = new Set([o.project_dir_env, o.plugin_root_env, o.home_env, ...(o.detect_env || []), ...(o.shared_env || [])].filter(Boolean));
920
+ const own = new Set(
921
+ [o.project_dir_env, o.plugin_root_env, o.home_env, ...(o.detect_env || []), ...(o.shared_env || [])].filter(
922
+ Boolean,
923
+ ),
924
+ );
685
925
  for (const m of loadHarnesses(dir).values()) {
686
926
  if (m.id === harness.id) continue;
687
927
  for (const t of m.tools?.write_tools || []) out.push({ pattern: `\\b${t}\\b`, class: "token", derived: true });
@@ -689,7 +929,13 @@ export function lintPatterns(harness, dir = MANIFEST_DIR) {
689
929
  // shared_env included: a name is no less branded for being set by two
690
930
  // harnesses, and it exists in no other field of the manifest that declares
691
931
  // it — CLAUDE_PLUGIN_DATA appears nowhere but codex.json's shared_env.
692
- for (const k of [r.project_dir_env, r.plugin_root_env, r.home_env, ...(r.detect_env || []), ...(r.shared_env || [])]) {
932
+ for (const k of [
933
+ r.project_dir_env,
934
+ r.plugin_root_env,
935
+ r.home_env,
936
+ ...(r.detect_env || []),
937
+ ...(r.shared_env || []),
938
+ ]) {
693
939
  if (k && !own.has(k)) out.push({ pattern: `\\b${k}\\b`, class: "token", derived: true });
694
940
  }
695
941
  }