projectstore-claude 0.29.2 → 0.29.3

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 (57) hide show
  1. package/README.md +1 -1
  2. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  3. package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
  4. package/node_modules/projectstore/README.md +27 -7
  5. package/node_modules/projectstore/commands/bar.md +30 -0
  6. package/node_modules/projectstore/commands/bind.md +14 -7
  7. package/node_modules/projectstore/commands/scaffold.md +24 -16
  8. package/node_modules/projectstore/commands/status.md +1 -1
  9. package/node_modules/projectstore/commands/statusline.md +1 -1
  10. package/node_modules/projectstore/docs/alongside.md +167 -0
  11. package/node_modules/projectstore/docs/extending.md +17 -3
  12. package/node_modules/projectstore/docs/getting-started.md +18 -9
  13. package/node_modules/projectstore/docs/harnesses.md +1 -1
  14. package/node_modules/projectstore/docs/how-it-works.md +7 -0
  15. package/node_modules/projectstore/harnesses/claude-code.json +18 -3
  16. package/node_modules/projectstore/harnesses/codex.json +12 -1
  17. package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
  18. package/node_modules/projectstore/hooks/band.tsx +594 -0
  19. package/node_modules/projectstore/hooks/hooks.json +1 -0
  20. package/node_modules/projectstore/hooks/session-start.mjs +6 -2
  21. package/node_modules/projectstore/package.json +2 -1
  22. package/node_modules/projectstore/scaffold/headings.json +8 -0
  23. package/node_modules/projectstore/scripts/band-shared.mjs +329 -0
  24. package/node_modules/projectstore/scripts/band.mjs +156 -0
  25. package/node_modules/projectstore/scripts/binding.mjs +157 -17
  26. package/node_modules/projectstore/scripts/build-adapters.mjs +26 -7
  27. package/node_modules/projectstore/scripts/cli.mjs +545 -179
  28. package/node_modules/projectstore/scripts/convergence.mjs +468 -0
  29. package/node_modules/projectstore/scripts/doctor-report.mjs +185 -0
  30. package/node_modules/projectstore/scripts/doctor.mjs +80 -62
  31. package/node_modules/projectstore/scripts/fetch-shell.mjs +283 -0
  32. package/node_modules/projectstore/scripts/harness.mjs +178 -7
  33. package/node_modules/projectstore/scripts/install-harness.mjs +1488 -257
  34. package/node_modules/projectstore/scripts/kanban.mjs +4 -20
  35. package/node_modules/projectstore/scripts/lib.mjs +257 -58
  36. package/node_modules/projectstore/scripts/portable-registration.mjs +62 -15
  37. package/node_modules/projectstore/scripts/query.mjs +83 -12
  38. package/node_modules/projectstore/scripts/run-host.mjs +94 -0
  39. package/node_modules/projectstore/scripts/scaffold.mjs +183 -0
  40. package/node_modules/projectstore/scripts/stages.mjs +80 -0
  41. package/node_modules/projectstore/scripts/statusline.mjs +25 -2
  42. package/node_modules/projectstore/scripts/surfaces.mjs +78 -13
  43. package/node_modules/projectstore/scripts/term.mjs +729 -39
  44. package/node_modules/projectstore/scripts/version-guard.mjs +5 -0
  45. package/node_modules/projectstore/templates/de/strings.json +63 -1
  46. package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
  47. package/node_modules/projectstore/templates/en/strings.json +63 -1
  48. package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
  49. package/node_modules/projectstore/templates/es/strings.json +63 -1
  50. package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
  51. package/node_modules/projectstore/templates/fr/strings.json +63 -1
  52. package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
  53. package/node_modules/projectstore/templates/ru/strings.json +63 -1
  54. package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
  55. package/node_modules/projectstore/templates/zh/strings.json +63 -1
  56. package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
  57. package/package.json +2 -2
@@ -0,0 +1,283 @@
1
+ // projectstore — fetch-shell.mjs: the shell fetch of install and upgrade.
2
+ //
3
+ // A harness whose plugin root ships only in its distribution shell (Codex:
4
+ // its registration surface carries `condition: "distribution_root"`) cannot
5
+ // be registered from the core alone. So a core run that stages fetches that
6
+ // shell, at the core's own version, into a scratch directory under the
7
+ // user-level cache, verifies it, and plans the harness from it in the same
8
+ // plan() as every other harness — one PLAN, one question, one APPLY (the
9
+ // ADR "One front door: projectstore setup installs every harness it finds;
10
+ // shells are release artifacts, not user commands", decision 3; the story
11
+ // "Install and upgrade fetch a shell-rooted harness at the bin's own version,
12
+ // once per run; plan and uninstall never fetch", rules 1–4 and 9).
13
+ //
14
+ // rootVersion(root) → <root>/package.json's version, or null
15
+ // fetchDecision({…}) → { fetch, why } — rule 1, pure
16
+ // fetchArgv({ prefix, registry, … }) → rule 2's npm argv, plan's and the run's
17
+ // npmRegistry(projectDir, { env }) → the registry npx would see there, or null
18
+ // npmUserRegistry({ home, env }) → the user's own registry, read from home, or null
19
+ // fetchShell(job, { dir, registry }) → { ok, root, ms } or { ok: false, code, refusal }
20
+ // verifyFetched(root, { … }) → null, or the refusal naming file and values
21
+ // fetchRefusal(code, ctx) → rule 4's texts
22
+ // sweepFetchRuns(fetchDir) → the leftovers of dead runs, removed
23
+ // fetchRunId() → <pid>-<epoch ms>
24
+ //
25
+ // Nothing here decides a registration's rows: the analyser and the installer
26
+ // do, from the fetched root this module hands them. Only install-harness.mjs
27
+ // imports this file; hooks, doctor, surfaces and lib never do (the
28
+ // portability suite asserts it). Pure node, no external deps.
29
+
30
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
31
+ import { join } from "node:path";
32
+ import { registrationDeferred } from "./harness.mjs";
33
+ import { removeTreeUnder, pidAlive, whichOnPath } from "./lib.mjs";
34
+ import { isPortablePluginRoot, payloadManifest, payloadManifestPath, payloadManifestPaths } from "./portable-registration.mjs";
35
+ import { runHost, startFailure, HOST_BUDGET_MS } from "./run-host.mjs";
36
+
37
+ const readJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
38
+
39
+ // The version a shell-rooted harness is fetched at has one source: the
40
+ // package.json of the root the plan runs from — never `latest`, never a range.
41
+ export function rootVersion(root) {
42
+ const v = readJson(join(String(root || ""), "package.json"))?.version;
43
+ return typeof v === "string" && v ? v : null;
44
+ }
45
+
46
+ // The core's own package name, which the fetched shell bundles under its
47
+ // node_modules (the shells ADR: the shell pins the core exactly).
48
+ export function rootName(root) {
49
+ const n = readJson(join(String(root || ""), "package.json"))?.name;
50
+ return typeof n === "string" && n ? n : null;
51
+ }
52
+
53
+ const STAGING_VERBS = ["install", "upgrade", "setup"];
54
+
55
+ // Rule 1, as one pure decision per harness. A run fetches a harness's shell
56
+ // only when every condition holds; the first that fails is `why`, which the
57
+ // tests read and nothing prints (the row a skipped fetch shows is the
58
+ // installer's, rule 1's table).
59
+ // verb — the run stages: install or upgrade (and setup, when it lands)
60
+ // harness, s — the manifest and its registration surface
61
+ // excluded — --no-register, or a --surface that does not name it
62
+ // named — a harness was named: the confirmation without a terminal
63
+ // interactive — a person at a terminal will be asked (isInteractive, or `ask`)
64
+ // analysis — a version-only analysis of the registration
65
+ // ownShell — the run is this harness's own shell's core (rule 8)
66
+ // env — the run's: a named distribution root, a session marker or a
67
+ // registration_defer_env variable
68
+ export function fetchDecision({ verb, harness, s, excluded = false, named = false, interactive = false, analysis = null, ownShell = false, env = process.env } = {}) {
69
+ if (!STAGING_VERBS.includes(verb)) return { fetch: false, why: "verb" };
70
+ if (s?.condition !== "distribution_root" || !harness?.install?.shell) return { fetch: false, why: "not-shell-rooted" };
71
+ if (excluded) return { fetch: false, why: "excluded" };
72
+ // A set root is used as is — a root or not — and nothing is fetched.
73
+ if (env && env.PROJECTSTORE_DISTRIBUTION_ROOT) return { fetch: false, why: "root-named" };
74
+ // A bare run that cannot be asked refuses at the gate; its preview is the dry run.
75
+ if (!named && !interactive) return { fetch: false, why: "not-interactive" };
76
+ // The registration defers in the harness's own session (decision 2) — by
77
+ // its session marker or its registration_defer_env (the setup story, rule
78
+ // 15) — and the fetch is skipped for a registration that will defer.
79
+ if (registrationDeferred(env, harness)) return { fetch: false, why: "in-session" };
80
+ // The host's facts alone decide these rows; a payload would change none.
81
+ if (["unavailable", "foreign", "conflict"].includes(analysis?.state)) return { fetch: false, why: analysis.state };
82
+ // A registration newer than the running version is never staged over (the
83
+ // converge story's rule 6): its row is the newer rung's, sound or damaged,
84
+ // decided from the version and the host's facts alone.
85
+ if (analysis?.newer) return { fetch: false, why: "newer" };
86
+ // No host CLI: the row defers whatever of ours exists, so a fetch — which
87
+ // may fail offline — must not turn a deferral into a refusal (the ADR: the
88
+ // fetch is skipped for a harness whose registration will defer).
89
+ if (!analysis?.bin) return { fetch: false, why: "no-host-cli" };
90
+ if (ownShell) return { fetch: false, why: "own-shell" };
91
+ return { fetch: true, why: null };
92
+ }
93
+
94
+ // Rule 2's argv, after `npm`. One builder for plan's dry-run step (literal
95
+ // <run-id> and <registry>) and the run's own fetch. --prefix hides a
96
+ // project's .npmrc, so only the registry is forwarded; with none resolved,
97
+ // npm resolves it from user config. The four --fetch-… flags bound one
98
+ // request to about 96 s and a refused connection to about 6 s (npm 11.19's
99
+ // defaults wait 10 s, then 60 s: 70.3 s measured); the run's own budget is the
100
+ // outer bound. --ignore-scripts: no install-time code from a registry runs.
101
+ export function fetchArgv({ prefix, registry = null, shell, version }) {
102
+ return [
103
+ "install", "--prefix", prefix, "--no-save", "--ignore-scripts", "--no-audit", "--no-fund", "--no-update-notifier", "--json",
104
+ ...(registry ? ["--registry", registry] : []),
105
+ "--fetch-timeout=30000", "--fetch-retries=2", "--fetch-retry-mintimeout=1000", "--fetch-retry-maxtimeout=5000",
106
+ `${shell}@${version}`,
107
+ ];
108
+ }
109
+
110
+ // Node runs a .cmd only through a shell, and a shell splits on spaces, so on
111
+ // win32 the bin and every argument are quoted. Unverified, as the install
112
+ // spec already says of Windows.
113
+ const WIN = process.platform === "win32";
114
+ const winQuote = (a) => `"${String(a).replace(/"/g, '""')}"`;
115
+ function npmCall(argv, env) {
116
+ const resolved = whichOnPath("npm", env);
117
+ const bin = resolved || (WIN ? "npm.cmd" : "npm");
118
+ return { resolved, bin: WIN ? winQuote(bin) : bin, argv: WIN ? argv.map(winQuote) : argv, shell: WIN };
119
+ }
120
+
121
+ // The registry `npx` run in this project would see: one `npm config get
122
+ // registry` with the project as its working directory, so a registry its
123
+ // .npmrc names counts (npm walks up to the nearest package.json or
124
+ // node_modules), and an outer npx's npm_config_registry outranks it as npm
125
+ // ranks the environment above a file (measured). Null when the read fails or
126
+ // runs past its own short budget — a local read that hangs must not add a
127
+ // second 120 s to the fetch's — and the fetch then passes no --registry.
128
+ // --no-update-notifier: npm otherwise asks the registry about itself here too.
129
+ // Exported for the update notice.
130
+ export const REGISTRY_BUDGET_MS = 10000;
131
+ export async function npmRegistry(projectDir, { env = process.env, spawn, budget = REGISTRY_BUDGET_MS } = {}) {
132
+ const call = npmCall(["config", "get", "registry", "--no-update-notifier"], env);
133
+ const r = await runHost(call.bin, call.argv, { env, cwd: projectDir, budget, spawn, shell: call.shell });
134
+ if (r.error || r.status !== 0) return null;
135
+ const value = String(r.stdout || "").trim().split(/\r?\n/).pop()?.trim() || "";
136
+ return value && !/\s/.test(value) && value !== "undefined" ? value : null;
137
+ }
138
+
139
+ // The registry the user's npm resolves outside any project (the converge
140
+ // story's rule 8, as amended 2026-10-09): `npm config get registry` run from
141
+ // the home directory, with every lowercase npm_config_* variable removed from
142
+ // its environment — what an outer npx injects into its child, and a user's
143
+ // own lowercase variable too. Only the uppercase NPM_CONFIG_* survive, with
144
+ // ~/.npmrc and the global npmrc. What chooses code before the question reads
145
+ // this one: the update notice and rule 6's read. Only the previewed shell
146
+ // fetch keeps the project's registry (npmRegistry, above). Null when the read
147
+ // fails; the caller then uses the public registry.
148
+ export async function npmUserRegistry({ home = null, env = process.env, spawn, budget = REGISTRY_BUDGET_MS } = {}) {
149
+ const clean = {};
150
+ for (const [k, v] of Object.entries(env || {})) if (!k.startsWith("npm_config_")) clean[k] = v;
151
+ return npmRegistry(home || process.cwd(), { env: clean, spawn, budget });
152
+ }
153
+
154
+ // npm's --json error, which it prints on stdout: { error: { code, summary,
155
+ // detail } }. stderr is the fallback, read for its `npm error code X` line
156
+ // only — npm's raw stderr, log path included, is never shown.
157
+ export function npmError(stdout, stderr) {
158
+ try {
159
+ const e = JSON.parse(String(stdout || ""))?.error;
160
+ if (e && typeof e === "object") return { code: String(e.code || "") || null, summary: String(e.summary || "").trim(), detail: String(e.detail || "").trim() };
161
+ } catch {}
162
+ const code = String(stderr || "").match(/npm (?:error|ERR!) code (\S+)/)?.[1] || null;
163
+ const summary = String(stderr || "").split(/\r?\n/).map((l) => l.replace(/^npm (?:error|ERR!) ?/, "").trim()).find((l) => l && !/^code \S+$/.test(l) && !/complete log of this run/i.test(l)) || "";
164
+ return { code, summary, detail: "" };
165
+ }
166
+
167
+ const seconds = (ms) => `${Number((ms / 1000).toFixed(ms % 1000 ? 1 : 0))} s`;
168
+
169
+ // Rule 4's refusals, by npm's code or ours. `ctx`: pkg ("<shell>@<v>"),
170
+ // shell, registry (null: npm's own), display (the harness's display name),
171
+ // budget, summary (npm's), cause (a start failure's), path/from/variable (the
172
+ // cache). Every way out is a terminal command or a variable; none names a
173
+ // version but the one this run is at.
174
+ export function fetchRefusal(code, ctx = {}) {
175
+ const { pkg = "the shell", shell = "the shell", registry = null, display = "this harness", budget = HOST_BUDGET_MS, summary = "", cause = "", path = null, from = null, variable = null } = ctx;
176
+ const where = registry || "the registry npm resolves";
177
+ const without = `name --harness without ${display}`;
178
+ switch (code) {
179
+ case "ETARGET":
180
+ case "E404":
181
+ return `${pkg} is not on ${where} (npm ${code}); ${display} is registered from that shell at this exact version, and no other version is tried. Just released? The shell is published after the core, so retry in a few minutes. From a checkout at an unpublished version, point PROJECTSTORE_DISTRIBUTION_ROOT at the output of \`npm run shells:build -- --dev\` (dist/build/${shell}). Or ${without}.`;
182
+ // Our budget, not npm's: npm's own ETIMEDOUT (a socket) is any other code.
183
+ case "EBUDGET":
184
+ return `fetching ${pkg} from ${where} took longer than ${seconds(budget)} and was stopped. Retry, or ${without}.`;
185
+ case "FETCH_TIMEOUT":
186
+ return `npm gave up on ${ctx.url || where} (network timeout) while fetching ${pkg}. Retry, or ${without}.`;
187
+ case "E401":
188
+ case "E403":
189
+ return `${where} refused the credentials for ${pkg} (npm ${code}). A project .npmrc's token does not reach this fetch — only its registry does — so the registry's token must be in user config: ~/.npmrc, or an NPM_CONFIG_… variable. Or point PROJECTSTORE_DISTRIBUTION_ROOT at a ${shell} root obtained another way.`;
190
+ case "ESTART":
191
+ return `${cause}; ${display} is registered from its shell, which npm fetches. Put npm on PATH, or ${without}.`;
192
+ case "ENOCACHE":
193
+ return `the shell fetch has no cache directory: ${variable} is not an absolute path and the home directory is unknown. Set ${variable} to a writable directory, then run this again.`;
194
+ case "ECACHE":
195
+ return `the cache directory ${path} cannot be written (${ctx.errno || "error"}); it comes from ${from}. Set ${variable} to a writable directory, then run this again.`;
196
+ default:
197
+ return `npm could not fetch ${pkg} from ${where}: ${code || "an unknown error"}${summary ? ` — ${summary.split(/\r?\n/)[0]}` : ""}. ${display} is registered from its shell, which needs the network: retry once the registry is reachable, or ${without}.`;
198
+ }
199
+ }
200
+
201
+ // Rule 3: the fetched tree is what this run plans, before it is planned —
202
+ // the shell's name and version, a plugin root by the analyser's own predicate,
203
+ // its plugin manifest's name and version, and the bundled core at the same
204
+ // version (the pairing the shells ADR promises: the core Codex runs is the
205
+ // core this release rendered). Null when it holds; otherwise the refusal,
206
+ // naming the file and both values. Nothing is staged either way.
207
+ export function verifyFetched(root, { shell, version, pluginName, coreName }) {
208
+ const pkg = `${shell}@${version}`;
209
+ const mismatch = (file, what, got, want) => `the fetched ${pkg} is not what this run plans: ${file} has ${what} ${JSON.stringify(got ?? null)}, expected ${JSON.stringify(want)} — nothing is staged`;
210
+ const pkgFile = join(root, "package.json");
211
+ const shellPkg = readJson(pkgFile);
212
+ if (shellPkg?.name !== shell) return mismatch(pkgFile, "name", shellPkg?.name, shell);
213
+ if (shellPkg?.version !== version) return mismatch(pkgFile, "version", shellPkg?.version, version);
214
+ if (!isPortablePluginRoot(root)) return `the fetched ${pkg} is not a plugin root: neither ${payloadManifestPaths(root).join(" nor ")} exists — nothing is staged`;
215
+ // Exactly the shell's one manifest, the one the host validates: a root
216
+ // manifest would be read first and load no hooks, so it never passes.
217
+ const manifestFile = payloadManifestPath(root, "legacy");
218
+ const rootFile = payloadManifestPath(root, "root");
219
+ if (existsSync(rootFile)) return `the fetched ${pkg} carries ${rootFile}, which the host reads before ${manifestFile} and loads no hooks from — nothing is staged`;
220
+ const manifest = payloadManifest(root, { kind: "legacy" });
221
+ if (!manifest) return `the fetched ${pkg} has no readable plugin manifest at ${manifestFile} — nothing is staged`;
222
+ if (manifest.name !== pluginName) return mismatch(manifestFile, "name", manifest.name, pluginName);
223
+ if (manifest.version !== version) return mismatch(manifestFile, "version", manifest.version, version);
224
+ const coreFile = join(root, "node_modules", coreName, "package.json");
225
+ const core = readJson(coreFile);
226
+ if (core?.version !== version) return mismatch(coreFile, "version", core?.version, version);
227
+ return null;
228
+ }
229
+
230
+ // The fetch itself: npm, asynchronously, in `dir` (made by the caller), with
231
+ // the run's environment. The payload root is computed, never read from npm's
232
+ // output: npm's --json result printed it with one UUID-like segment redacted
233
+ // as *** (measured). A failure is a refusal before the plan, never a fallback.
234
+ export async function fetchShell({ harness, s, version, coreName }, { dir, registry = null, env = process.env, spawn, budget = HOST_BUDGET_MS } = {}) {
235
+ const shell = harness.install.shell;
236
+ const pkg = `${shell}@${version}`;
237
+ const ctx = { pkg, shell, registry, display: harness.display_name, budget };
238
+ const call = npmCall(fetchArgv({ prefix: dir, registry, shell, version }), env);
239
+ const t0 = Date.now();
240
+ const r = await runHost(call.bin, call.argv, { env, cwd: dir, budget, spawn, shell: call.shell });
241
+ const ms = Date.now() - t0;
242
+ if (r.error) {
243
+ if (r.error.code === "ETIMEDOUT") return { ok: false, ms, code: "ETIMEDOUT", refusal: fetchRefusal("EBUDGET", ctx) };
244
+ return { ok: false, ms, code: r.error.code || "ESTART", refusal: fetchRefusal("ESTART", { ...ctx, cause: startFailure(r.error, { bin: "npm", resolved: call.resolved, cwd: dir }) }) };
245
+ }
246
+ if (r.status !== 0) {
247
+ const e = npmError(r.stdout, r.stderr);
248
+ // npm reports a request it gave up on as FETCH_ERROR, "network timeout
249
+ // at: <url>" (measured in review, 2026-10-08), not as ETIMEDOUT.
250
+ const timeout = e.code === "FETCH_ERROR" && /network timeout at:\s*(\S+)/.exec(e.summary);
251
+ if (timeout) return { ok: false, ms, code: e.code, refusal: fetchRefusal("FETCH_TIMEOUT", { ...ctx, url: timeout[1] }) };
252
+ return { ok: false, ms, code: e.code || `exit ${r.status}`, refusal: fetchRefusal(e.code, { ...ctx, summary: e.summary }) };
253
+ }
254
+ const root = join(dir, "node_modules", shell);
255
+ const wrong = verifyFetched(root, { shell, version, pluginName: s.plugin_name, coreName });
256
+ if (wrong) return { ok: false, ms, code: "EVERIFY", refusal: wrong };
257
+ return { ok: true, ms, root };
258
+ }
259
+
260
+ // <pid>-<epoch ms>: the run that owns a scratch directory, by its name.
261
+ export function fetchRunId() {
262
+ return `${process.pid}-${Date.now()}`;
263
+ }
264
+
265
+ // Rule 9's sweep, run by the next fetching run: every <fetch>/<pid>-<ms>
266
+ // whose pid is dead goes. A live pid — ours or not (EPERM) — keeps its
267
+ // directory, which may be a run waiting at its question for days, and any
268
+ // other name stays. No age rule: a reused pid keeps a leftover until a later
269
+ // sweep, which costs about 1.4 MB, never data. An npm still writing into a
270
+ // dead run's directory is caught by a later sweep: it converges.
271
+ export function sweepFetchRuns(fetchDir) {
272
+ let names;
273
+ try { names = readdirSync(fetchDir); } catch { return []; }
274
+ const removed = [];
275
+ for (const n of names) {
276
+ const m = /^(\d+)-\d+$/.exec(n);
277
+ if (!m) continue;
278
+ const pid = Number(m[1]);
279
+ if (!pid || pid === process.pid || pidAlive(pid)) continue;
280
+ try { removeTreeUnder(join(fetchDir, n), fetchDir, { maxRetries: 3 }); removed.push(n); } catch {}
281
+ }
282
+ return removed;
283
+ }
@@ -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
 
@@ -226,25 +226,141 @@ export function resetDetection() {
226
226
  // process" from branded environment variables, and using it here would
227
227
  // conjure .claude/ for a Codex user. Not memoised: it is per directory, and a
228
228
  // test builds a project per case.
229
- export function detectHarnesses(projectDir, { dir = MANIFEST_DIR } = {}) {
229
+ //
230
+ // `state` adds the second signal `upgrade` reads: a harness whose state
231
+ // directory of ours exists (layoutPaths(p).harnessState(id), layout spec
232
+ // contract 5). A Codex session wrote its welcome marker there, so Codex is
233
+ // used here even with no .codex/ (measured in this repository, 2026-10-08).
234
+ // Never a machine-global registration: one exists for every project on the
235
+ // machine and would select its harness everywhere. One entry per manifest,
236
+ // the directory first, in manifest order as before.
237
+ export function detectHarnesses(projectDir, { dir = MANIFEST_DIR, state = false } = {}) {
230
238
  const out = [];
239
+ const p = state ? layoutPaths(projectDir, { dir }) : null;
231
240
  for (const m of loadHarnesses(dir).values()) {
232
241
  const d = m.runtime?.harness_dir;
233
- if (d && existsSync(join(projectDir, d))) out.push({ id: m.id, why: "directory", evidence: d });
242
+ if (d && existsSync(join(projectDir, d))) { out.push({ id: m.id, why: "directory", evidence: d }); continue; }
243
+ if (!p) continue;
244
+ const at = p.harnessState(m.id);
245
+ let isDir = false;
246
+ try { isDir = statSync(at).isDirectory(); } catch {}
247
+ if (isDir) out.push({ id: m.id, why: "state", evidence: relative(projectDir, at) });
234
248
  }
235
249
  return out;
236
250
  }
237
251
 
252
+ // A pure PATH walk for a host binary — a read, not a subprocess: the analysers
253
+ // run on every doctor call. Returns the absolute path or null. It lives here,
254
+ // beside hostPresence, which needs it; lib.mjs re-exports it under the name
255
+ // it already published.
256
+ export function whichOnPath(name, env = process.env) {
257
+ const sep = process.platform === "win32" ? ";" : ":";
258
+ const exts = process.platform === "win32" ? ["", ".cmd", ".exe", ".bat"] : [""];
259
+ for (const dir of String(env.PATH || "").split(sep).filter(Boolean)) {
260
+ for (const ext of exts) {
261
+ const p = join(dir, name + ext);
262
+ try { const st = statSync(p); if (st.isFile() && (process.platform === "win32" || (st.mode & 0o111))) return p; } catch {}
263
+ }
264
+ }
265
+ return null;
266
+ }
267
+
268
+ // Whether a harness's host is on this MACHINE (the story "projectstore setup
269
+ // asks, shows one plan and installs every harness it finds, with the vault",
270
+ // rule 5): its registration surface's CLI found on PATH, and its home
271
+ // directory present — the manifest's home variable when set, else
272
+ // <home>/<home_default>. Not detectHarnesses, which answers for a PROJECT, and
273
+ // not detectHarnessId, which names the harness running this process. Setup's
274
+ // pre-tick is the one place machine presence counts; install keeps contract 8.
275
+ // Every name comes from the manifest. Read-only: a stat per PATH entry and one
276
+ // for the home.
277
+ // → { cli, home, bin, homeDir }: `cli` is the absolute path found, or null;
278
+ // `home` the directory when it exists, or null. `bin` and `homeDir` are
279
+ // what was looked for (null when the manifest names none), so a reason can
280
+ // name them when either is missing.
281
+ export function hostPresence(manifest, { env = process.env, home = homedir() } = {}) {
282
+ const s = Object.entries(manifest?.surfaces || {}).find(([k, x]) => !k.startsWith("_") && x?.kind === "registration" && x.supported !== false && typeof x.cli?.bin === "string" && x.cli.bin)?.[1] || null;
283
+ const bin = s ? s.cli.bin : null;
284
+ const r = manifest?.runtime || {};
285
+ const fromEnv = r.home_env && env ? env[r.home_env] : null;
286
+ const homeDir = fromEnv || (home && r.home_default ? join(home, r.home_default) : null);
287
+ let present = false;
288
+ if (homeDir) { try { present = statSync(homeDir).isDirectory(); } catch {} }
289
+ return { cli: bin ? whichOnPath(bin, env || {}) : null, home: present ? homeDir : null, bin, homeDir };
290
+ }
291
+
292
+ // The agents block of a RUN (the story "One run plans the agents block once,
293
+ // and a bare uninstall selects every harness the project uses", rules 1–2):
294
+ // the files every manifest of the run names for it, in the FIRST manifest's
295
+ // order. The block is written to the first of them, so it lands where every
296
+ // harness of the run can read it, natively or through the import the run
297
+ // creates. One manifest gives its own list unchanged, which is what keeps a
298
+ // single-harness plan as it was.
299
+ export function commonBlockFiles(manifests) {
300
+ const lists = manifests.map((m) => agentsBlockRow(m)?.[1].files || []);
301
+ if (!lists.length) return [];
302
+ return lists[0].filter((f) => lists.every((l) => l.includes(f)));
303
+ }
304
+
305
+ // A manifest's agents-block row as [key, surface], or null: found by its
306
+ // format, as the installer's handlers are keyed, never by the key's name.
307
+ export function agentsBlockRow(manifest) {
308
+ return Object.entries(manifest?.surfaces || {}).find(([k, s]) => !k.startsWith("_") && s?.format === "markdown-block") || null;
309
+ }
310
+
311
+ // Rule 2, for the schema test: the manifests' lists share a file, or a run of
312
+ // all of them has nowhere to write the block. Null when they do; otherwise
313
+ // the problem, naming each manifest's list.
314
+ export function blockTargetProblem(manifests) {
315
+ const withBlock = manifests.filter((m) => agentsBlockRow(m));
316
+ if (commonBlockFiles(withBlock).length) return null;
317
+ 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(", ")}`;
318
+ }
319
+
238
320
  // 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) {
321
+ // manifests, so a new harness appears in it without an edit here. With
322
+ // `state` (upgrade's set) it names both signals the selection read.
323
+ export function harnessRefusal(projectDir, { dir = MANIFEST_DIR, state = false } = {}) {
241
324
  const lines = [`No harness detected in ${projectDir}, and none named. Name one:`];
325
+ const p = state ? layoutPaths(projectDir, { dir }) : null;
242
326
  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 || "?"})`);
327
+ const by = `detected by its project directory: ${m.runtime?.harness_dir || "?"}`;
328
+ lines.push(` --harness ${m.id} (${m.display_name}; ${p ? `${by}, or by its state directory: ${relative(projectDir, p.harnessState(m.id))}` : by})`);
244
329
  }
245
330
  return lines.join("\n");
246
331
  }
247
332
 
333
+ // Inside a live session of the host, its CLI and the session both rewrite the
334
+ // same settings files on their own schedules; the registration is planned
335
+ // only from a terminal outside one. The host marks its sessions in the
336
+ // environment (manifest runtime.session_env). It lives here, beside
337
+ // detection, so the installer and the shell fetch read one predicate without
338
+ // an import cycle.
339
+ export function insideHostSession(env, harness) {
340
+ // runtime.session_env, not detect_env: a Bash tool inside a session carries
341
+ // the session marker, not the plugin-root variables a hook receives
342
+ // (measured 2026-09-05; the critic's third pass caught the first draft
343
+ // keying on detect_env, which never fired in a session).
344
+ return (harness?.runtime?.session_env || []).some((k) => env && env[k]);
345
+ }
346
+
347
+ // Whether a harness's registration — and the shell fetch that would feed it —
348
+ // defers in this environment (the setup story, rule 15: the front-door ADR's
349
+ // first open question, answered by the maintainer on 2026-10-08). It defers
350
+ // inside the harness's own session (insideHostSession, runtime.session_env),
351
+ // or while one of its runtime.registration_defer_env variables is set. That
352
+ // second list defers the registration and the fetch and nothing else:
353
+ // isInteractive, the layout move and session identity read session_env alone.
354
+ // Codex's exec tool sets CODEX_THREAD_ID in every command (measured
355
+ // 2026-10-05), while its session_env stays empty so the doctor skill's
356
+ // `upgrade --json` still moves the layout there. Keyed on the PLANNED
357
+ // harness's manifest: inside one host's session another host's registration
358
+ // does not defer.
359
+ export function registrationDeferred(env, harness) {
360
+ if (insideHostSession(env, harness)) return true;
361
+ return (harness?.runtime?.registration_defer_env || []).some((k) => env && env[k]);
362
+ }
363
+
248
364
  export function activeHarness(env = process.env, dir = MANIFEST_DIR) {
249
365
  return loadHarness(detectHarnessId(env, dir), dir) || sourceHarness(dir);
250
366
  }
@@ -516,6 +632,34 @@ export function layoutPaths(projectDir, { harnessDir = null, dir = MANIFEST_DIR
516
632
  };
517
633
  }
518
634
 
635
+ // ─── The user-level cache (layout spec contract 0, the user-level class) ─
636
+ //
637
+ // What is ours on the machine but belongs to no project and no harness home:
638
+ // the shell fetch's scratch (<root>/fetch/<pid>-<ms>/, removed when its run
639
+ // ends) and the registry answer the update notice caches for a day
640
+ // (<root>/registry.json, named now and read by no one yet). The XDG Base
641
+ // Directory spec puts a cache at $XDG_CACHE_HOME when that is an absolute
642
+ // path and ignores a relative one; an empty one is ignored too. Otherwise it
643
+ // is <home>/.cache. With neither, `root` is null and the fetch refuses. This
644
+ // is the one place either name is spelled (contract 0's grep): `from` and
645
+ // `variable` carry them into a refusal's text. env and home are always
646
+ // parameters.
647
+ export function cachePaths({ env = process.env, home = homedir() } = {}) {
648
+ const variable = "XDG_CACHE_HOME";
649
+ const xdg = env ? env[variable] : null;
650
+ const fromEnv = typeof xdg === "string" && xdg !== "" && isAbsolute(xdg);
651
+ const base = fromEnv ? xdg : (home ? join(home, ".cache") : null);
652
+ const root = base ? join(base, "projectstore") : null;
653
+ return {
654
+ root,
655
+ fetch: root ? join(root, "fetch") : null,
656
+ fetchRun: (id) => (root ? join(root, "fetch", id) : null),
657
+ registry: root ? join(root, "registry.json") : null,
658
+ from: fromEnv ? variable : "the home directory",
659
+ variable,
660
+ };
661
+ }
662
+
519
663
  // The command a user types from a terminal for this harness (the layout ADR
520
664
  // decision 6; layout spec contract 12): the harness's distribution shell when
521
665
  // the manifest names one — `npx projectstore-claude install --project "…"`,
@@ -531,6 +675,17 @@ export function packageCommand(harness, verb, { version = null, args = "" } = {}
531
675
  return `npx ${pkg} ${verb}${fixed}${args ? ` ${args}` : ""}`;
532
676
  }
533
677
 
678
+ // The package this run was invoked as, for a command it prints to be typed
679
+ // next: the shell it came through (the shell's bin sets PROJECTSTORE_SHELL,
680
+ // and fixes the harness, so a verb that takes --harness needs none), else the
681
+ // core. packageCommand() above is the install family's, per harness; this one
682
+ // is for the verbs that name no harness (bind, status, scaffold, reconcile),
683
+ // which every shell passes through. `npx` runs it with no bin on PATH.
684
+ export function invokedAs(env = process.env) {
685
+ const shell = env.PROJECTSTORE_SHELL || null;
686
+ return { shell, cmd: shell ? `npx ${shell}` : "npx projectstore" };
687
+ }
688
+
534
689
  // The overlay a harness reads: <project>/.projectstore/harness/<overlay>.json —
535
690
  // the manifest's runtime.overlay, the harness id by convention (the layout ADR,
536
691
  // decision 3). Null only when no manifest at all can be found.
@@ -552,6 +707,22 @@ export function harnessForOverlay(key, dir = MANIFEST_DIR) {
552
707
  return all.find((m) => m.runtime?.overlay === key) || all.find((m) => m.id === key) || null;
553
708
  }
554
709
 
710
+ // Whether a harness draws the band above the prompt (the band spec, contract
711
+ // 1): the one predicate for "a harness without a band", read by the generator
712
+ // (which keeps the module out of such a harness's tree) and by `band status`.
713
+ export function hasBand(manifest) {
714
+ return manifest?.surfaces?.band?.supported === true;
715
+ }
716
+
717
+ // The session id the host hands a tool process, by the manifest's name for
718
+ // it (runtime.session_id_env), or null where the harness names none or the
719
+ // process got none.
720
+ export function hostSessionId(env = process.env, harness = activeHarness(env)) {
721
+ const k = harness?.runtime?.session_id_env;
722
+ const v = k && env ? env[k] : null;
723
+ return typeof v === "string" && v ? v : null;
724
+ }
725
+
555
726
  // A reader's fallback: the new path when it exists, else the legacy one when
556
727
  // THAT exists, else the new path (a writer's target) — at most two existsSync
557
728
  // calls, never a directory scan (contract 1; the SessionStart budget).