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
@@ -54,7 +54,8 @@ import {
54
54
  slugify,
55
55
  findSlugCollision,
56
56
  displayNumberOf,
57
- today, isMain,
57
+ today,
58
+ isMain,
58
59
  commandForm,
59
60
  } from "./lib.mjs";
60
61
 
@@ -117,7 +118,9 @@ function scanCollision(dir, fileName, opts) {
117
118
 
118
119
  function digitLeadingWarnings(slug) {
119
120
  return /^\d/.test(slug)
120
- ? [`Slug "${slug}" is digit-leading — the filename visually resembles a numbered-era artifact. Machine resolution keys on id:, so this is safe, but a word-leading title avoids the ambiguity.`]
121
+ ? [
122
+ `Slug "${slug}" is digit-leading — the filename visually resembles a numbered-era artifact. Machine resolution keys on id:, so this is safe, but a word-leading title avoids the ambiguity.`,
123
+ ]
121
124
  : [];
122
125
  }
123
126
 
@@ -0,0 +1,369 @@
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 {
35
+ isPortablePluginRoot,
36
+ payloadManifest,
37
+ payloadManifestPath,
38
+ payloadManifestPaths,
39
+ } from "./portable-registration.mjs";
40
+ import { runHost, startFailure, HOST_BUDGET_MS } from "./run-host.mjs";
41
+
42
+ const readJson = (p) => {
43
+ try {
44
+ return JSON.parse(readFileSync(p, "utf8"));
45
+ } catch {
46
+ return null;
47
+ }
48
+ };
49
+
50
+ // The version a shell-rooted harness is fetched at has one source: the
51
+ // package.json of the root the plan runs from — never `latest`, never a range.
52
+ export function rootVersion(root) {
53
+ const v = readJson(join(String(root || ""), "package.json"))?.version;
54
+ return typeof v === "string" && v ? v : null;
55
+ }
56
+
57
+ // The core's own package name, which the fetched shell bundles under its
58
+ // node_modules (the shells ADR: the shell pins the core exactly).
59
+ export function rootName(root) {
60
+ const n = readJson(join(String(root || ""), "package.json"))?.name;
61
+ return typeof n === "string" && n ? n : null;
62
+ }
63
+
64
+ const STAGING_VERBS = ["install", "upgrade", "setup"];
65
+
66
+ // Rule 1, as one pure decision per harness. A run fetches a harness's shell
67
+ // only when every condition holds; the first that fails is `why`, which the
68
+ // tests read and nothing prints (the row a skipped fetch shows is the
69
+ // installer's, rule 1's table).
70
+ // verb — the run stages: install or upgrade (and setup, when it lands)
71
+ // harness, s — the manifest and its registration surface
72
+ // excluded — --no-register, or a --surface that does not name it
73
+ // named — a harness was named: the confirmation without a terminal
74
+ // interactive — a person at a terminal will be asked (isInteractive, or `ask`)
75
+ // analysis — a version-only analysis of the registration
76
+ // ownShell — the run is this harness's own shell's core (rule 8)
77
+ // env — the run's: a named distribution root, a session marker or a
78
+ // registration_defer_env variable
79
+ export function fetchDecision({
80
+ verb,
81
+ harness,
82
+ s,
83
+ excluded = false,
84
+ named = false,
85
+ interactive = false,
86
+ analysis = null,
87
+ ownShell = false,
88
+ env = process.env,
89
+ } = {}) {
90
+ if (!STAGING_VERBS.includes(verb)) return { fetch: false, why: "verb" };
91
+ if (s?.condition !== "distribution_root" || !harness?.install?.shell)
92
+ return { fetch: false, why: "not-shell-rooted" };
93
+ if (excluded) return { fetch: false, why: "excluded" };
94
+ // A set root is used as is — a root or not — and nothing is fetched.
95
+ if (env && env.PROJECTSTORE_DISTRIBUTION_ROOT) return { fetch: false, why: "root-named" };
96
+ // A bare run that cannot be asked refuses at the gate; its preview is the dry run.
97
+ if (!named && !interactive) return { fetch: false, why: "not-interactive" };
98
+ // The registration defers in the harness's own session (decision 2) — by
99
+ // its session marker or its registration_defer_env (the setup story, rule
100
+ // 15) — and the fetch is skipped for a registration that will defer.
101
+ if (registrationDeferred(env, harness)) return { fetch: false, why: "in-session" };
102
+ // The host's facts alone decide these rows; a payload would change none.
103
+ if (["unavailable", "foreign", "conflict"].includes(analysis?.state)) return { fetch: false, why: analysis.state };
104
+ // A registration newer than the running version is never staged over (the
105
+ // converge story's rule 6): its row is the newer rung's, sound or damaged,
106
+ // decided from the version and the host's facts alone.
107
+ if (analysis?.newer) return { fetch: false, why: "newer" };
108
+ // No host CLI: the row defers whatever of ours exists, so a fetch — which
109
+ // may fail offline — must not turn a deferral into a refusal (the ADR: the
110
+ // fetch is skipped for a harness whose registration will defer).
111
+ if (!analysis?.bin) return { fetch: false, why: "no-host-cli" };
112
+ if (ownShell) return { fetch: false, why: "own-shell" };
113
+ return { fetch: true, why: null };
114
+ }
115
+
116
+ // Rule 2's argv, after `npm`. One builder for plan's dry-run step (literal
117
+ // <run-id> and <registry>) and the run's own fetch. --prefix hides a
118
+ // project's .npmrc, so only the registry is forwarded; with none resolved,
119
+ // npm resolves it from user config. The four --fetch-… flags bound one
120
+ // request to about 96 s and a refused connection to about 6 s (npm 11.19's
121
+ // defaults wait 10 s, then 60 s: 70.3 s measured); the run's own budget is the
122
+ // outer bound. --ignore-scripts: no install-time code from a registry runs.
123
+ export function fetchArgv({ prefix, registry = null, shell, version }) {
124
+ return [
125
+ "install",
126
+ "--prefix",
127
+ prefix,
128
+ "--no-save",
129
+ "--ignore-scripts",
130
+ "--no-audit",
131
+ "--no-fund",
132
+ "--no-update-notifier",
133
+ "--json",
134
+ ...(registry ? ["--registry", registry] : []),
135
+ "--fetch-timeout=30000",
136
+ "--fetch-retries=2",
137
+ "--fetch-retry-mintimeout=1000",
138
+ "--fetch-retry-maxtimeout=5000",
139
+ `${shell}@${version}`,
140
+ ];
141
+ }
142
+
143
+ // Node runs a .cmd only through a shell, and a shell splits on spaces, so on
144
+ // win32 the bin and every argument are quoted. Unverified, as the install
145
+ // spec already says of Windows.
146
+ const WIN = process.platform === "win32";
147
+ const winQuote = (a) => `"${String(a).replace(/"/g, '""')}"`;
148
+ function npmCall(argv, env) {
149
+ const resolved = whichOnPath("npm", env);
150
+ const bin = resolved || (WIN ? "npm.cmd" : "npm");
151
+ return { resolved, bin: WIN ? winQuote(bin) : bin, argv: WIN ? argv.map(winQuote) : argv, shell: WIN };
152
+ }
153
+
154
+ // The registry `npx` run in this project would see: one `npm config get
155
+ // registry` with the project as its working directory, so a registry its
156
+ // .npmrc names counts (npm walks up to the nearest package.json or
157
+ // node_modules), and an outer npx's npm_config_registry outranks it as npm
158
+ // ranks the environment above a file (measured). Null when the read fails or
159
+ // runs past its own short budget — a local read that hangs must not add a
160
+ // second 120 s to the fetch's — and the fetch then passes no --registry.
161
+ // --no-update-notifier: npm otherwise asks the registry about itself here too.
162
+ // Exported for the update notice.
163
+ export const REGISTRY_BUDGET_MS = 10000;
164
+ export async function npmRegistry(projectDir, { env = process.env, spawn, budget = REGISTRY_BUDGET_MS } = {}) {
165
+ const call = npmCall(["config", "get", "registry", "--no-update-notifier"], env);
166
+ const r = await runHost(call.bin, call.argv, { env, cwd: projectDir, budget, spawn, shell: call.shell });
167
+ if (r.error || r.status !== 0) return null;
168
+ const value =
169
+ String(r.stdout || "")
170
+ .trim()
171
+ .split(/\r?\n/)
172
+ .pop()
173
+ ?.trim() || "";
174
+ return value && !/\s/.test(value) && value !== "undefined" ? value : null;
175
+ }
176
+
177
+ // The registry the user's npm resolves outside any project (the converge
178
+ // story's rule 8, as amended 2026-10-09): `npm config get registry` run from
179
+ // the home directory, with every lowercase npm_config_* variable removed from
180
+ // its environment — what an outer npx injects into its child, and a user's
181
+ // own lowercase variable too. Only the uppercase NPM_CONFIG_* survive, with
182
+ // ~/.npmrc and the global npmrc. What chooses code before the question reads
183
+ // this one: the update notice and rule 6's read. Only the previewed shell
184
+ // fetch keeps the project's registry (npmRegistry, above). Null when the read
185
+ // fails; the caller then uses the public registry.
186
+ export async function npmUserRegistry({ home = null, env = process.env, spawn, budget = REGISTRY_BUDGET_MS } = {}) {
187
+ const clean = {};
188
+ for (const [k, v] of Object.entries(env || {})) if (!k.startsWith("npm_config_")) clean[k] = v;
189
+ return npmRegistry(home || process.cwd(), { env: clean, spawn, budget });
190
+ }
191
+
192
+ // npm's --json error, which it prints on stdout: { error: { code, summary,
193
+ // detail } }. stderr is the fallback, read for its `npm error code X` line
194
+ // only — npm's raw stderr, log path included, is never shown.
195
+ export function npmError(stdout, stderr) {
196
+ try {
197
+ const e = JSON.parse(String(stdout || ""))?.error;
198
+ if (e && typeof e === "object")
199
+ return {
200
+ code: String(e.code || "") || null,
201
+ summary: String(e.summary || "").trim(),
202
+ detail: String(e.detail || "").trim(),
203
+ };
204
+ } catch {}
205
+ const code = String(stderr || "").match(/npm (?:error|ERR!) code (\S+)/)?.[1] || null;
206
+ const summary =
207
+ String(stderr || "")
208
+ .split(/\r?\n/)
209
+ .map((l) => l.replace(/^npm (?:error|ERR!) ?/, "").trim())
210
+ .find((l) => l && !/^code \S+$/.test(l) && !/complete log of this run/i.test(l)) || "";
211
+ return { code, summary, detail: "" };
212
+ }
213
+
214
+ const seconds = (ms) => `${Number((ms / 1000).toFixed(ms % 1000 ? 1 : 0))} s`;
215
+
216
+ // Rule 4's refusals, by npm's code or ours. `ctx`: pkg ("<shell>@<v>"),
217
+ // shell, registry (null: npm's own), display (the harness's display name),
218
+ // budget, summary (npm's), cause (a start failure's), path/from/variable (the
219
+ // cache). Every way out is a terminal command or a variable; none names a
220
+ // version but the one this run is at.
221
+ export function fetchRefusal(code, ctx = {}) {
222
+ const {
223
+ pkg = "the shell",
224
+ shell = "the shell",
225
+ registry = null,
226
+ display = "this harness",
227
+ budget = HOST_BUDGET_MS,
228
+ summary = "",
229
+ cause = "",
230
+ path = null,
231
+ from = null,
232
+ variable = null,
233
+ } = ctx;
234
+ const where = registry || "the registry npm resolves";
235
+ const without = `name --harness without ${display}`;
236
+ switch (code) {
237
+ case "ETARGET":
238
+ case "E404":
239
+ 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}.`;
240
+ // Our budget, not npm's: npm's own ETIMEDOUT (a socket) is any other code.
241
+ case "EBUDGET":
242
+ return `fetching ${pkg} from ${where} took longer than ${seconds(budget)} and was stopped. Retry, or ${without}.`;
243
+ case "FETCH_TIMEOUT":
244
+ return `npm gave up on ${ctx.url || where} (network timeout) while fetching ${pkg}. Retry, or ${without}.`;
245
+ case "E401":
246
+ case "E403":
247
+ 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.`;
248
+ case "ESTART":
249
+ return `${cause}; ${display} is registered from its shell, which npm fetches. Put npm on PATH, or ${without}.`;
250
+ case "ENOCACHE":
251
+ 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.`;
252
+ case "ECACHE":
253
+ 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.`;
254
+ default:
255
+ 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}.`;
256
+ }
257
+ }
258
+
259
+ // Rule 3: the fetched tree is what this run plans, before it is planned —
260
+ // the shell's name and version, a plugin root by the analyser's own predicate,
261
+ // its plugin manifest's name and version, and the bundled core at the same
262
+ // version (the pairing the shells ADR promises: the core Codex runs is the
263
+ // core this release rendered). Null when it holds; otherwise the refusal,
264
+ // naming the file and both values. Nothing is staged either way.
265
+ export function verifyFetched(root, { shell, version, pluginName, coreName }) {
266
+ const pkg = `${shell}@${version}`;
267
+ const mismatch = (file, what, got, want) =>
268
+ `the fetched ${pkg} is not what this run plans: ${file} has ${what} ${JSON.stringify(got ?? null)}, expected ${JSON.stringify(want)} — nothing is staged`;
269
+ const pkgFile = join(root, "package.json");
270
+ const shellPkg = readJson(pkgFile);
271
+ if (shellPkg?.name !== shell) return mismatch(pkgFile, "name", shellPkg?.name, shell);
272
+ if (shellPkg?.version !== version) return mismatch(pkgFile, "version", shellPkg?.version, version);
273
+ if (!isPortablePluginRoot(root))
274
+ return `the fetched ${pkg} is not a plugin root: neither ${payloadManifestPaths(root).join(" nor ")} exists — nothing is staged`;
275
+ // Exactly the shell's one manifest, the one the host validates: a root
276
+ // manifest would be read first and load no hooks, so it never passes.
277
+ const manifestFile = payloadManifestPath(root, "legacy");
278
+ const rootFile = payloadManifestPath(root, "root");
279
+ if (existsSync(rootFile))
280
+ return `the fetched ${pkg} carries ${rootFile}, which the host reads before ${manifestFile} and loads no hooks from — nothing is staged`;
281
+ const manifest = payloadManifest(root, { kind: "legacy" });
282
+ if (!manifest) return `the fetched ${pkg} has no readable plugin manifest at ${manifestFile} — nothing is staged`;
283
+ if (manifest.name !== pluginName) return mismatch(manifestFile, "name", manifest.name, pluginName);
284
+ if (manifest.version !== version) return mismatch(manifestFile, "version", manifest.version, version);
285
+ const coreFile = join(root, "node_modules", coreName, "package.json");
286
+ const core = readJson(coreFile);
287
+ if (core?.version !== version) return mismatch(coreFile, "version", core?.version, version);
288
+ return null;
289
+ }
290
+
291
+ // The fetch itself: npm, asynchronously, in `dir` (made by the caller), with
292
+ // the run's environment. The payload root is computed, never read from npm's
293
+ // output: npm's --json result printed it with one UUID-like segment redacted
294
+ // as *** (measured). A failure is a refusal before the plan, never a fallback.
295
+ export async function fetchShell(
296
+ { harness, s, version, coreName },
297
+ { dir, registry = null, env = process.env, spawn, budget = HOST_BUDGET_MS } = {},
298
+ ) {
299
+ const shell = harness.install.shell;
300
+ const pkg = `${shell}@${version}`;
301
+ const ctx = { pkg, shell, registry, display: harness.display_name, budget };
302
+ const call = npmCall(fetchArgv({ prefix: dir, registry, shell, version }), env);
303
+ const t0 = Date.now();
304
+ const r = await runHost(call.bin, call.argv, { env, cwd: dir, budget, spawn, shell: call.shell });
305
+ const ms = Date.now() - t0;
306
+ if (r.error) {
307
+ if (r.error.code === "ETIMEDOUT")
308
+ return { ok: false, ms, code: "ETIMEDOUT", refusal: fetchRefusal("EBUDGET", ctx) };
309
+ return {
310
+ ok: false,
311
+ ms,
312
+ code: r.error.code || "ESTART",
313
+ refusal: fetchRefusal("ESTART", {
314
+ ...ctx,
315
+ cause: startFailure(r.error, { bin: "npm", resolved: call.resolved, cwd: dir }),
316
+ }),
317
+ };
318
+ }
319
+ if (r.status !== 0) {
320
+ const e = npmError(r.stdout, r.stderr);
321
+ // npm reports a request it gave up on as FETCH_ERROR, "network timeout
322
+ // at: <url>" (measured in review, 2026-10-08), not as ETIMEDOUT.
323
+ const timeout = e.code === "FETCH_ERROR" && /network timeout at:\s*(\S+)/.exec(e.summary);
324
+ if (timeout)
325
+ return { ok: false, ms, code: e.code, refusal: fetchRefusal("FETCH_TIMEOUT", { ...ctx, url: timeout[1] }) };
326
+ return {
327
+ ok: false,
328
+ ms,
329
+ code: e.code || `exit ${r.status}`,
330
+ refusal: fetchRefusal(e.code, { ...ctx, summary: e.summary }),
331
+ };
332
+ }
333
+ const root = join(dir, "node_modules", shell);
334
+ const wrong = verifyFetched(root, { shell, version, pluginName: s.plugin_name, coreName });
335
+ if (wrong) return { ok: false, ms, code: "EVERIFY", refusal: wrong };
336
+ return { ok: true, ms, root };
337
+ }
338
+
339
+ // <pid>-<epoch ms>: the run that owns a scratch directory, by its name.
340
+ export function fetchRunId() {
341
+ return `${process.pid}-${Date.now()}`;
342
+ }
343
+
344
+ // Rule 9's sweep, run by the next fetching run: every <fetch>/<pid>-<ms>
345
+ // whose pid is dead goes. A live pid — ours or not (EPERM) — keeps its
346
+ // directory, which may be a run waiting at its question for days, and any
347
+ // other name stays. No age rule: a reused pid keeps a leftover until a later
348
+ // sweep, which costs about 1.4 MB, never data. An npm still writing into a
349
+ // dead run's directory is caught by a later sweep: it converges.
350
+ export function sweepFetchRuns(fetchDir) {
351
+ let names;
352
+ try {
353
+ names = readdirSync(fetchDir);
354
+ } catch {
355
+ return [];
356
+ }
357
+ const removed = [];
358
+ for (const n of names) {
359
+ const m = /^(\d+)-\d+$/.exec(n);
360
+ if (!m) continue;
361
+ const pid = Number(m[1]);
362
+ if (!pid || pid === process.pid || pidAlive(pid)) continue;
363
+ try {
364
+ removeTreeUnder(join(fetchDir, n), fetchDir, { maxRetries: 3 });
365
+ removed.push(n);
366
+ } catch {}
367
+ }
368
+ return removed;
369
+ }
@@ -23,7 +23,8 @@ import {
23
23
  buildNodeIndex,
24
24
  extractLinks,
25
25
  resolveLinkTarget,
26
- storyMatchesEntry, isMain,
26
+ storyMatchesEntry,
27
+ isMain,
27
28
  commandForm,
28
29
  } from "./lib.mjs";
29
30
  import { walkVaultFiles } from "./doctor.mjs";
@@ -39,9 +40,7 @@ function refsOf(fm, key) {
39
40
  const viaList = listOf(fm, key);
40
41
  if (viaList.length) return viaList;
41
42
  const raw = fm[key];
42
- return typeof raw === "string" && raw && raw !== "[]" && !raw.trim().startsWith("[")
43
- ? [raw.trim()]
44
- : [];
43
+ return typeof raw === "string" && raw && raw !== "[]" && !raw.trim().startsWith("[") ? [raw.trim()] : [];
45
44
  }
46
45
 
47
46
  // Resolve a qualified "<epic-id>/<story-id>" spec entry to a story node —
@@ -58,8 +57,10 @@ function resolveStoryEntry(entry, index, epicsPath) {
58
57
  if (n.type !== "story" || !n.path.startsWith(`${epicsPath}/${epicId}/`)) continue;
59
58
  const tier = storyMatchesEntry(storyId, { id: n.identity, stem: n.stem });
60
59
  if (!tier) continue;
61
- if (best === 0 || tier < best) { best = tier; hits = [n]; }
62
- else if (tier === best) hits.push(n);
60
+ if (best === 0 || tier < best) {
61
+ best = tier;
62
+ hits = [n];
63
+ } else if (tier === best) hits.push(n);
63
64
  }
64
65
  if (hits.length === 1) return { outcome: "node", node: hits[0] };
65
66
  if (hits.length > 1) return { outcome: "ambiguous", candidates: hits.map((n) => n.path).sort() };
@@ -151,8 +152,7 @@ export function buildGraph(cfg, layout, { files = null } = {}) {
151
152
  // machines and locales; minimal diffs on a synced root file.
152
153
  const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
153
154
  const nodeRows = [...index.nodes].sort((a, b) => cmp(a.path, b.path));
154
- const edgeRows = [...edges.values()].sort(
155
- (a, b) => cmp(a.from, b.from) || cmp(a.kind, b.kind) || cmp(a.to, b.to));
155
+ const edgeRows = [...edges.values()].sort((a, b) => cmp(a.from, b.from) || cmp(a.kind, b.kind) || cmp(a.to, b.to));
156
156
 
157
157
  const esc = (s) => String(s).replace(/\|/g, "\\|");
158
158
  const lines = [
@@ -208,11 +208,17 @@ function main() {
208
208
  if (!cfg) die(`No projectstore config. Run ${commandForm("bind")} first.`);
209
209
  const layout = loadLayout(cfg.layout);
210
210
  const g = buildGraph(cfg, layout);
211
- process.stdout.write(JSON.stringify({
212
- path: join(cfg.vault_path, "graph.md"),
213
- content: g.content,
214
- stats: g.stats,
215
- }, null, 2) + "\n");
211
+ process.stdout.write(
212
+ JSON.stringify(
213
+ {
214
+ path: join(cfg.vault_path, "graph.md"),
215
+ content: g.content,
216
+ stats: g.stats,
217
+ },
218
+ null,
219
+ 2,
220
+ ) + "\n",
221
+ );
216
222
  }
217
223
 
218
224
  if (isMain(import.meta.url)) {