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.
- package/README.md +1 -1
- package/bin/projectstore-claude.mjs +1 -1
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
- package/node_modules/projectstore/README.md +44 -7
- package/node_modules/projectstore/commands/bar.md +30 -0
- package/node_modules/projectstore/commands/bind.md +16 -20
- package/node_modules/projectstore/commands/doctor.md +12 -7
- package/node_modules/projectstore/commands/scaffold.md +24 -16
- package/node_modules/projectstore/commands/status.md +1 -1
- package/node_modules/projectstore/commands/statusline.md +2 -2
- package/node_modules/projectstore/commands/story.md +15 -2
- package/node_modules/projectstore/docs/alongside.md +167 -0
- package/node_modules/projectstore/docs/extending.md +17 -3
- package/node_modules/projectstore/docs/getting-started.md +18 -9
- package/node_modules/projectstore/docs/harnesses.md +1 -1
- package/node_modules/projectstore/docs/how-it-works.md +12 -3
- package/node_modules/projectstore/harnesses/claude-code.json +23 -8
- package/node_modules/projectstore/harnesses/codex.json +16 -6
- package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
- package/node_modules/projectstore/hooks/band.tsx +622 -0
- package/node_modules/projectstore/hooks/hooks.json +1 -0
- package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
- package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
- package/node_modules/projectstore/hooks/session-start.mjs +56 -20
- package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
- package/node_modules/projectstore/package.json +12 -2
- package/node_modules/projectstore/scaffold/headings.json +8 -0
- package/node_modules/projectstore/scripts/band-shared.mjs +446 -0
- package/node_modules/projectstore/scripts/band.mjs +207 -0
- package/node_modules/projectstore/scripts/binding.mjs +733 -62
- package/node_modules/projectstore/scripts/build-adapters.mjs +98 -35
- package/node_modules/projectstore/scripts/cli.mjs +1463 -267
- package/node_modules/projectstore/scripts/codemap.mjs +22 -7
- package/node_modules/projectstore/scripts/convergence.mjs +638 -0
- package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
- package/node_modules/projectstore/scripts/doctor-report.mjs +253 -0
- package/node_modules/projectstore/scripts/doctor.mjs +1384 -420
- package/node_modules/projectstore/scripts/draft.mjs +5 -2
- package/node_modules/projectstore/scripts/fetch-shell.mjs +369 -0
- package/node_modules/projectstore/scripts/graph.mjs +19 -13
- package/node_modules/projectstore/scripts/harness.mjs +283 -37
- package/node_modules/projectstore/scripts/install-harness.mjs +4701 -575
- package/node_modules/projectstore/scripts/kanban.mjs +6 -21
- package/node_modules/projectstore/scripts/lib.mjs +933 -199
- package/node_modules/projectstore/scripts/mcp.mjs +386 -105
- package/node_modules/projectstore/scripts/portable-registration.mjs +276 -56
- package/node_modules/projectstore/scripts/provenance.mjs +26 -10
- package/node_modules/projectstore/scripts/query.mjs +441 -72
- package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
- package/node_modules/projectstore/scripts/run-host.mjs +139 -0
- package/node_modules/projectstore/scripts/scaffold.mjs +244 -0
- package/node_modules/projectstore/scripts/setup.mjs +987 -0
- package/node_modules/projectstore/scripts/stages.mjs +84 -0
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
- package/node_modules/projectstore/scripts/statusline.mjs +55 -14
- package/node_modules/projectstore/scripts/story-section.mjs +39 -17
- package/node_modules/projectstore/scripts/surfaces.mjs +485 -99
- package/node_modules/projectstore/scripts/term.mjs +1027 -52
- package/node_modules/projectstore/scripts/tokens.mjs +106 -28
- package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
- package/node_modules/projectstore/scripts/version-guard.mjs +79 -27
- package/node_modules/projectstore/scripts/worktree.mjs +16 -5
- package/node_modules/projectstore/templates/de/strings.json +63 -1
- package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/en/strings.json +63 -1
- package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/es/strings.json +63 -1
- package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/fr/strings.json +63 -1
- package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/ru/strings.json +63 -1
- package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/zh/strings.json +63 -1
- package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
- package/package.json +2 -2
|
@@ -54,7 +54,8 @@ import {
|
|
|
54
54
|
slugify,
|
|
55
55
|
findSlugCollision,
|
|
56
56
|
displayNumberOf,
|
|
57
|
-
today,
|
|
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
|
-
? [
|
|
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,
|
|
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) {
|
|
62
|
-
|
|
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(
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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)) {
|