projectstore-claude 0.30.6 โ†’ 0.30.7

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.
@@ -12,7 +12,7 @@
12
12
  "name": "projectstore",
13
13
  "displayName": "projectstore",
14
14
  "description": "๐Ÿ“š Your agent runs the project through a verified loop: task โ†’ artifact (ADR ยท spec ยท epic ยท story) โ†’ adversarial critic โ†’ backlog โ†’ planner โ†’ reviewer โ†’ done. Plain markdown in an Obsidian-friendly vault, every write approved by you โ€” and any model can pick the project up tomorrow.",
15
- "version": "0.30.6",
15
+ "version": "0.30.7",
16
16
  "author": {
17
17
  "name": "Evgenii Konev",
18
18
  "email": "ekonev@smartandpoint.com",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "projectstore",
3
3
  "displayName": "projectstore",
4
- "version": "0.30.6",
4
+ "version": "0.30.7",
5
5
  "description": "Your agent runs the project through a verified loop: task โ†’ artifact (ADR / spec / epic / story) โ†’ adversarial critic โ†’ backlog โ†’ planner โ†’ reviewer โ†’ done. Plain markdown in git โ€” any model can pick the project up tomorrow.",
6
6
  "author": {
7
7
  "name": "Evgenii Konev @ SmartAndPoint",
@@ -223,7 +223,7 @@ npx projectstore configure
223
223
 
224
224
  At a terminal it opens a settings menu that works like a monitor's: โ†‘ and โ†“ move, โ†’ opens a group or a setting in a frame beside it, space chooses, โ† goes back, and Enter shows the plan. A second Enter writes it. Under the frames, a few lines say what the row under the cursor means. The settings:
225
225
 
226
- - each agent's model and effort, per harness. Claude Code offers its aliases: `fable`, `opus`, `sonnet` and `haiku`. Codex offers its own list of models, with its descriptions. With `ANTHROPIC_API_KEY` set, each alias also names the newest model of its line. A list is kept for a day in projectstore's user cache, and "refresh the list" asks again. Each agent's effort sits under its model: Claude Code's levels, `low` to `max`, or the levels that Codex model lists. With none set, a Claude Code agent runs at its frontmatter's level (`max`, the clerk `low`) and a Codex one at its model's own default;
226
+ - each agent's model and effort, per harness. Claude Code offers its aliases: `fable`, `opus`, `sonnet` and `haiku`. Codex offers its own list of models, with its descriptions, kept for a day in projectstore's user cache; "refresh the list" asks Codex again. Each agent's effort sits under its model: Claude Code's levels, `low` to `max`, or the levels that Codex model lists. With none set, a Claude Code agent runs at its frontmatter's level (`max`, the clerk `low`) and a Codex one at its model's own default;
227
227
  - the status line and the band above the prompt (Claude Code);
228
228
  - the vault's template language, the default author and tags;
229
229
  - the vault's spec policy and lifecycle gates.
@@ -393,7 +393,7 @@ The deep dive โ€” real session files, measured payloads, how every mechanism wor
393
393
 
394
394
  ## What ProjectStore runs, writes, sends and fetches
395
395
 
396
- ProjectStore has no server, no account and no telemetry. It collects nothing about you, your project or your vault. Its own code makes three requests, the ones under "What it sends and fetches" below, and each one is made by a command you run, never by a hook. One program it runs makes a request of its own: Codex's model command, which refreshes Codex's catalog with your Codex login. This list covers everything in the package, and a [test](https://github.com/SmartAndPoint/ProjectStore/blob/main/tests/disclosure.test.mjs) fails when a shipped module that starts a program or makes a request is not accounted for here.
396
+ ProjectStore has no server, no account and no telemetry. It collects nothing about you, your project or your vault, and it reads no credential from your environment. Its own code makes two requests, the ones under "What it sends and fetches" below, and each one is made by a command you run, never by a hook. One program it runs makes a request of its own: Codex's model command, which refreshes Codex's catalog with your Codex login. This list covers everything in the package, and a [test](https://github.com/SmartAndPoint/ProjectStore/blob/main/tests/disclosure.test.mjs) fails when a shipped module that starts a program or makes a request is not accounted for here.
397
397
 
398
398
  ### What it runs
399
399
 
@@ -420,7 +420,6 @@ ProjectStore never changes Claude Code's or Codex's permission settings.
420
420
 
421
421
  - **The npm registry**, `https://registry.npmjs.org/` or the registry your npm is set to. One `GET <registry>/projectstore` request with no credentials and a 2-second limit asks for the newest release. Setup, install and upgrade make it when they run from npm (npx or a global install) or from a git checkout. They never make it from the copy that Claude Code or Codex loads, and never in a dry run. The answer is kept for 24 hours, and `PROJECTSTORE_NO_UPDATE_CHECK=1` turns the request off.
422
422
  - **The Codex package.** The `npm install` above fetches `projectstore-codex` from the registry your npm is set to, with npm's own settings, only when setup, install or upgrade sets up Codex.
423
- - **Anthropic's model list**, `https://api.anthropic.com/v1/models`, or the same path under `$ANTHROPIC_BASE_URL` when that is set. Only the model menu of `configure` at a terminal sends it, only when `ANTHROPIC_API_KEY` is set, and only when the model list is missing or older than a day, or you ask to refresh it. The request carries your key in its `x-api-key` header and nothing else of yours. Only the model names, labels and families of the answer are kept, for a day. Without the variable nothing is sent, and the menu offers Claude Code's aliases.
424
423
 
425
424
  ProjectStore's own code sends nothing else: the hooks, the MCP server, the band and the status line open no connection. Two programs it runs may connect on their own. When the model menu reads Codex's list, `codex debug models` refreshes Codex's catalog with your Codex login. The status line command you had before is yours, and it runs as you set it.
426
425
 
@@ -649,30 +649,8 @@
649
649
  "description": "Follows the newest Haiku model: Claude Code resolves the alias when it starts the agent."
650
650
  }
651
651
  ],
652
- "static_reason": "The enum above, in the order the menu lists it. A line the catalog below reports that this list lacks is never offered: a Claude Code release that adds an alias is named with `--set`, with the warning, until this list learns it.",
653
- "catalogs": [
654
- {
655
- "kind": "http",
656
- "source": "the Anthropic API",
657
- "caption": "Anthropic API",
658
- "url": "https://api.anthropic.com/v1/models?limit=1000",
659
- "base_env": "ANTHROPIC_BASE_URL",
660
- "path": "/v1/models?limit=1000",
661
- "key_env": "ANTHROPIC_API_KEY",
662
- "key_header": "x-api-key",
663
- "headers": { "anthropic-version": "2023-06-01" },
664
- "fields": {
665
- "list": "data",
666
- "id": "id",
667
- "label": "display_name",
668
- "family": "line",
669
- "order": "created_at",
670
- "order_desc": true,
671
- "keep_when": { "lifecycle": "active" }
672
- },
673
- "reason": "Measured 2026-10-13: 14 models in 0.5โ€“0.7 s, each with id, display_name, created_at, line (fable, opus, sonnet, haiku) and lifecycle. `line` is the family an alias follows, so the newest active entry of a line is the best local account of what that alias starts; Claude Code resolves the alias itself. Read only when a model list opens with a stale cache, or on refresh, and only with ANTHROPIC_API_KEY set; ANTHROPIC_BASE_URL, when set, is the base the key belongs to."
674
- }
675
- ]
652
+ "static_reason": "The enum above, in the order the menu lists it. It is the whole list: Anthropic's model list takes a key, and ProjectStore reads no credential from the environment (the 0.30.7 story, PS-DIRECTORY), so no catalog is declared and the menu asks nothing. A Claude Code release that adds an alias is named with `--set`, with the warning, until this list learns it.",
653
+ "catalogs": []
676
654
  }
677
655
  }
678
656
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore",
3
- "version": "0.30.6",
3
+ "version": "0.30.7",
4
4
  "description": "Your agent runs the project through a verified loop: task โ†’ artifact (ADR / spec / epic / story) โ†’ adversarial critic โ†’ backlog โ†’ planner โ†’ reviewer โ†’ done. Plain markdown in git โ€” any model can pick the project up tomorrow.",
5
5
  "keywords": [
6
6
  "project-management",
@@ -444,16 +444,17 @@ function effortOptions(es, display, item, name, isDefault, levels, model, d, men
444
444
  return opts;
445
445
  }
446
446
 
447
- // The values frame's footer: the list's source and age, the host being
448
- // asked, or why nothing can be asked (the ADR, decisions 3 and 6).
447
+ // The values frame's footer: the list's source and age, the catalog being
448
+ // asked, or why nothing can be asked (the ADR, decisions 3 and 6). An alias
449
+ // harness with no list says that its aliases are all it offers.
449
450
  function caption(cat, now) {
450
451
  const k = cat.known;
451
452
  if (!k) return null;
452
453
  const name = k.caption || k.live?.caption || "";
453
- if (cat.busy) return { text: `asking ${k.live?.host || k.live?.caption || name}โ€ฆ`, busy: true };
454
+ if (cat.busy) return { text: `asking ${k.live?.caption || name}โ€ฆ`, busy: true };
454
455
  if (cat.error) return { text: `${k.live?.caption || name} ยท ${cat.error}` };
455
456
  if (k.at) return { text: `${name} ยท checked ${checkedWhen(k.at, now())}` };
456
- if (k.why && k.spawnTakes === "alias") return { text: `aliases only ยท ${k.why}` };
457
+ if (k.spawnTakes === "alias") return { text: k.why ? `aliases only ยท ${k.why}` : "aliases only" };
457
458
  return k.why ? { text: k.why } : null;
458
459
  }
459
460
 
@@ -503,7 +504,9 @@ function modelOptions(h, display, item, cat, { isDefault, defaultNow, menu, now,
503
504
  section: opts.length ? undefined : "",
504
505
  description: k?.hidden?.has(v)
505
506
  ? `${display} hides it from its own picker. It is kept as it is.`
506
- : `Not in ${source ? `${source}'s list` : "any list"}. It is kept as it is.`,
507
+ : k?.spawnTakes === "alias"
508
+ ? `Not one of ${display}'s aliases. It is kept as it is.`
509
+ : `Not in ${source ? `${source}'s list` : "any list"}. It is kept as it is.`,
507
510
  });
508
511
  // No free text where the spawn takes aliases: every other name fails at
509
512
  // spawn, and --set is the way to name one (the ADR, decision 5).
@@ -524,7 +527,7 @@ function modelOptions(h, display, item, cat, { isDefault, defaultNow, menu, now,
524
527
  description: cat.busy
525
528
  ? `Asking ${liveSource} for its list now.`
526
529
  : !k?.live
527
- ? `Nothing to ask: ${k?.why || "this harness declares no list to read"}.`
530
+ ? `Nothing to ask: ${k?.why || (k.spawnTakes === "alias" ? `${display}'s list is its aliases` : "this harness declares no list to read")}.`
528
531
  : cat.error
529
532
  ? `The last ask failed: ${cat.error}. The list is as it was.`
530
533
  : `Asks ${liveSource} for its list now.${k.at ? ` Last checked ${checkedWhen(k.at, now())}.` : ""} A list is kept for a day, so opening it never waits on the network.`,
@@ -612,7 +615,6 @@ export async function runMenu(ctx) {
612
615
  home,
613
616
  now,
614
617
  signal: ac.signal,
615
- ...(ctx.fetch ? { fetch: ctx.fetch } : {}),
616
618
  ...(ctx.spawn ? { spawn: ctx.spawn } : {}),
617
619
  ...(ctx.timers ? { timers: ctx.timers } : {}),
618
620
  }).then((r) => {
@@ -949,7 +949,6 @@ export async function runConfigure(ctx) {
949
949
  now,
950
950
  path: tildeOf(f.project, f.home),
951
951
  dryRun,
952
- fetch: ctx.fetch,
953
952
  spawn: ctx.spawn,
954
953
  timers: ctx.timers,
955
954
  review: (list) => {
@@ -109,8 +109,8 @@ import {
109
109
  hostRegistry,
110
110
  autoUpdateDefault,
111
111
  } from "./harness.mjs";
112
- // The pure reader, never models.mjs: that file holds a fetch, and the
113
- // SessionStart hook imports this one (the portability suite's hook fence).
112
+ // The pure reader, never models.mjs: that file starts a catalog command, and
113
+ // the SessionStart hook imports this one (the portability suite's hook fence).
114
114
  import { effortSpec } from "./model-spec.mjs";
115
115
 
116
116
  // A remedy used to interpolate the surface's harness variable here. It cannot:
@@ -6,11 +6,12 @@
6
6
  //
7
7
  // Pure readers, split from models.mjs (which re-exports them) so a hook can
8
8
  // read the manifest without loading the module that reads a live catalog.
9
- // models.mjs holds a `fetch` that sends a key from the environment; doctor,
10
- // which the SessionStart hook imports, needs only effortSpec. The portability
11
- // suite fences every hook's imports off any module with a fetch call: the
12
- // story "The MCP server's entry reaches no install verbโ€ฆ", PS-DIRECTORY,
13
- // decision 4.
9
+ // models.mjs starts a harness's catalog command as a subprocess, and reads
10
+ // no credential from the environment (the 0.30.7 story, PS-DIRECTORY);
11
+ // doctor, which the SessionStart hook imports, needs only effortSpec. The
12
+ // portability suite fences every hook's imports off any module with a fetch
13
+ // call, and holds models.mjs out of SessionStart's: the story "The MCP
14
+ // server's entry reaches no install verbโ€ฆ", PS-DIRECTORY, decision 4.
14
15
  // This file imports nothing: keep it that way.
15
16
 
16
17
  export function modelSpec(manifest) {
@@ -31,9 +31,11 @@
31
31
  // effortProblem(manifest) โ†’ what is wrong with the block, or null
32
32
  //
33
33
  // Local reads are synchronous and cheap: the manifest, a `file` catalog, and
34
- // projectstore's own cache. A `command` or `http` catalog is read only by
35
- // readLive, which a menu starts in the background. env, home, now, fetch,
36
- // spawn and timers are parameters, so no test reads the machine.
34
+ // projectstore's own cache. A `command` catalog is read only by readLive,
35
+ // which a menu starts in the background. There is no `http` kind: this module
36
+ // makes no request and reads no credential from the environment (the 0.30.7
37
+ // story, PS-DIRECTORY). env, home, now, spawn and timers are parameters, so
38
+ // no test reads the machine.
37
39
 
38
40
  import { spawn as spawnChild } from "node:child_process";
39
41
  import { mkdirSync, readFileSync, statSync } from "node:fs";
@@ -44,8 +46,8 @@ import { writeFileAtomic } from "./lib.mjs";
44
46
  import { modelSpec, effortSpec } from "./model-spec.mjs";
45
47
 
46
48
  // The two block readers live in model-spec.mjs, which imports nothing, so a
47
- // hook's imports reach them without reaching this file's fetch. Re-exported:
48
- // every caller that reads the catalog too keeps one import.
49
+ // hook's imports reach them without reaching this file's subprocess.
50
+ // Re-exported: every caller that reads the catalog too keeps one import.
49
51
  export { modelSpec, effortSpec };
50
52
 
51
53
  // A known list is fresh for a day (decision 3); a live read is bounded by 3 s.
@@ -162,6 +164,10 @@ export function modelCachePath(manifest, { env = process.env, home = homedir() }
162
164
  return cachePaths({ env, home }).models(manifest?.id);
163
165
  }
164
166
 
167
+ // The cache is read only when a catalog the manifest declares names its
168
+ // source: a cache an older release wrote from a catalog this one no longer
169
+ // declares is ignored, never deleted, since opening a list writes nothing
170
+ // (the ADR, decision 3; the 0.30.7 story, PS-DIRECTORY).
165
171
  function readCache(manifest, opts) {
166
172
  const p = modelCachePath(manifest, opts);
167
173
  if (!p) return null;
@@ -169,6 +175,9 @@ function readCache(manifest, opts) {
169
175
  const c = JSON.parse(readFileSync(p, "utf8"));
170
176
  const t = timeOf(c?.at);
171
177
  if (t === null || !Array.isArray(c.models)) return null;
178
+ const source = typeof c.source === "string" ? c.source : "";
179
+ const from = (modelSpec(manifest)?.catalogs || []).find((x) => x.source === source);
180
+ if (!from) return null;
172
181
  // Cleaned again: the cache is a file anyone can edit.
173
182
  const entries = c.models
174
183
  .filter((x) => x && typeof x.id === "string" && clean(x.id))
@@ -180,9 +189,7 @@ function readCache(manifest, opts) {
180
189
  hidden: Boolean(x.hidden),
181
190
  ...cachedLevels(x),
182
191
  }));
183
- const source = typeof c.source === "string" ? c.source : "";
184
- const from = (modelSpec(manifest)?.catalogs || []).find((x) => x.source === source);
185
- return { at: t, source, caption: from?.caption || source, entries };
192
+ return { at: t, source, caption: from.caption || source, entries };
186
193
  } catch {
187
194
  return null;
188
195
  }
@@ -206,33 +213,11 @@ function readFileCatalog(manifest, spec, catalog, opts) {
206
213
  }
207
214
  }
208
215
 
209
- // Where an http catalog's key goes: the key's own base when the manifest
210
- // names a base variable and it is set, else the manifest's URL (the ADR,
211
- // decision 2). Never any other host.
212
- function urlOf(catalog, env) {
213
- const base = catalog.base_env && env?.[catalog.base_env] ? String(env[catalog.base_env]).replace(/\/+$/, "") : null;
214
- return base && catalog.path ? `${base}${catalog.path}` : catalog.url;
215
- }
216
- function hostOf(catalog, env) {
217
- try {
218
- return new URL(urlOf(catalog, env)).host;
219
- } catch {
220
- return null;
221
- }
222
- }
223
-
224
- // The catalog a refresh asks: the first `command` or `http` one that can run
225
- // here โ€” its key variable set, or its bin on PATH โ€” or null, with why not.
216
+ // The catalog a refresh asks: the first `command` one whose bin is on PATH,
217
+ // or null, with why not.
226
218
  function liveCatalog(spec, env) {
227
219
  let why = null;
228
220
  for (const c of spec.catalogs || []) {
229
- if (c.kind === "http") {
230
- if (c.key_env && !(env && env[c.key_env])) {
231
- why = why || `no ${c.key_env}`;
232
- continue;
233
- }
234
- return { catalog: c, why: null, host: hostOf(c, env) };
235
- }
236
221
  if (c.kind === "command") {
237
222
  const bin = Array.isArray(c.argv) ? c.argv[0] : null;
238
223
  if (!bin || !whichOnPath(bin, env || {})) {
@@ -315,7 +300,6 @@ export function knownModels(manifest, { env = process.env, home = homedir(), now
315
300
  source: live.catalog.source || "",
316
301
  caption: live.catalog.caption || live.catalog.source || "",
317
302
  kind: live.catalog.kind,
318
- ...(live.host ? { host: live.host } : {}),
319
303
  }
320
304
  : null,
321
305
  why: live.why,
@@ -409,7 +393,10 @@ export function modelsProblem(manifest) {
409
393
  if (!Array.isArray(spec.catalogs)) return "catalogs is not a list";
410
394
  for (const c of spec.catalogs) {
411
395
  const fields = fieldsOf(spec, c);
412
- if (!["file", "command", "http"].includes(c?.kind)) return `a catalog's kind is ${c?.kind}`;
396
+ // The kind 0.30.6 read a key from the environment with: refused by name
397
+ // (the 0.30.7 story, PS-DIRECTORY).
398
+ if (c?.kind === "http") return "an http catalog is not read: a catalog is a file or a command";
399
+ if (!["file", "command"].includes(c?.kind)) return `a catalog's kind is ${c?.kind}`;
413
400
  if (typeof c.source !== "string" || !c.source) return `a ${c.kind} catalog names no source`;
414
401
  if (!fields.list || !fields.id) return `the ${c.source} catalog names no list or id field`;
415
402
  if (typeof c.reason !== "string" || !c.reason) return `the ${c.source} catalog carries no reason`;
@@ -417,12 +404,6 @@ export function modelsProblem(manifest) {
417
404
  return `the ${c.source} file catalog's path is not under the harness home`;
418
405
  if (c.kind === "command" && (!Array.isArray(c.argv) || !c.argv.length || c.argv.some((a) => typeof a !== "string")))
419
406
  return `the ${c.source} command catalog has no argv`;
420
- if (c.kind === "http") {
421
- if (!/^https:\/\//.test(String(c.url))) return `the ${c.source} http catalog's url is not https`;
422
- if (!c.key_env || !c.key_header) return `the ${c.source} http catalog names no key variable and header`;
423
- if (c.base_env && !String(c.path || "").startsWith("/"))
424
- return `the ${c.source} http catalog's path is not absolute`;
425
- }
426
407
  }
427
408
  return null;
428
409
  }
@@ -461,72 +442,6 @@ export function effortProblem(manifest) {
461
442
  return null;
462
443
  }
463
444
 
464
- // One bounded fetch, settled by a timer whatever the request does after: the
465
- // pattern of convergence.mjs's request(). The key goes in its header and
466
- // nowhere else; nothing of the request is kept.
467
- function readHttp(catalog, { env, fetch, timers, timeoutMs, signal }) {
468
- return new Promise((settle) => {
469
- if (typeof fetch !== "function") return settle({ error: "no fetch in this Node" });
470
- let done = false,
471
- timer = null;
472
- const ac = typeof AbortController === "function" ? new AbortController() : null;
473
- const finish = (r) => {
474
- if (done) return;
475
- done = true;
476
- if (timer !== null)
477
- try {
478
- timers.clearTimeout(timer);
479
- } catch {}
480
- settle(r);
481
- };
482
- timer = timers.setTimeout(() => {
483
- timer = null;
484
- try {
485
- ac?.abort();
486
- } catch {}
487
- finish({ error: `no answer within ${timeoutMs / 1000} s` });
488
- }, timeoutMs);
489
- try {
490
- timer?.unref?.();
491
- } catch {}
492
- signal?.addEventListener?.("abort", () => {
493
- try {
494
- ac?.abort();
495
- } catch {}
496
- finish({ error: "stopped" });
497
- });
498
- const url = urlOf(catalog, env);
499
- const headers = { accept: "application/json", ...(catalog.headers || {}) };
500
- if (catalog.key_env && catalog.key_header) headers[catalog.key_header] = String(env?.[catalog.key_env] || "");
501
- Promise.resolve()
502
- .then(() => fetch(url, { headers, ...(ac ? { signal: ac.signal } : {}) }))
503
- .then(async (res) => {
504
- if (!res || !res.ok) {
505
- try {
506
- Promise.resolve(res?.body?.cancel?.()).catch(() => {});
507
- } catch {}
508
- const status = res?.status ?? "error";
509
- return finish({
510
- error: status === 401 || status === 403 ? `the key was refused (HTTP ${status})` : `HTTP ${status}`,
511
- });
512
- }
513
- const size = Number(res.headers?.get?.("content-length"));
514
- if (Number.isFinite(size) && size > MAX_BYTES) {
515
- try {
516
- Promise.resolve(res?.body?.cancel?.()).catch(() => {});
517
- } catch {}
518
- return finish({ error: "the answer is too large" });
519
- }
520
- try {
521
- finish({ body: await res.json() });
522
- } catch {
523
- finish({ error: "the answer is not JSON" });
524
- }
525
- })
526
- .catch((e) => finish({ error: String(e?.cause?.code || e?.code || e?.message || "the request failed") }));
527
- });
528
- }
529
-
530
445
  // One bounded run of a catalog command: its stdout as JSON. Asynchronous, so
531
446
  // a menu keeps drawing while it runs; killed at the bound.
532
447
  function readCommand(catalog, { env, spawn, timers, timeoutMs, signal }) {
@@ -604,8 +519,6 @@ export async function readLive(
604
519
  env = process.env,
605
520
  home = homedir(),
606
521
  now = Date.now,
607
- // eslint-disable-next-line n/no-unsupported-features/node-builtins -- the global fetch is experimental at Node 20 and stable from 21; without it the read answers "no fetch in this Node"
608
- fetch = globalThis.fetch,
609
522
  spawn = spawnChild,
610
523
  timers = globalThis,
611
524
  timeoutMs = LIVE_TIMEOUT_MS,
@@ -618,10 +531,7 @@ export async function readLive(
618
531
  if (!catalog) return { error: why || "no catalog to ask" };
619
532
  let r;
620
533
  try {
621
- r =
622
- catalog.kind === "http"
623
- ? await readHttp(catalog, { env, fetch, timers, timeoutMs, signal })
624
- : await readCommand(catalog, { env, spawn, timers, timeoutMs, signal });
534
+ r = await readCommand(catalog, { env, spawn, timers, timeoutMs, signal });
625
535
  } catch (e) {
626
536
  r = { error: String(e?.message || e) };
627
537
  }
@@ -298,6 +298,14 @@ export function unshippedTopLevel(entries, packlist) {
298
298
  // The packlist is the second release-time invariant: what npm would actually
299
299
  // ship. Stored as sorted paths only โ€” sizes churn on every content edit and
300
300
  // would make the fixture unreviewable.
301
+ // The one package in `npm pack --json`'s answer: an array of one entry up to
302
+ // npm 11, an object keyed by the package's name from npm 12.
303
+ export function packEntry(answer) {
304
+ const [pkg] = Array.isArray(answer) ? answer : Object.values(answer ?? {});
305
+ if (!pkg || !Array.isArray(pkg.files)) throw new Error("no package entry with a file list");
306
+ return pkg;
307
+ }
308
+
301
309
  export function currentPacklist(root = ROOT) {
302
310
  const r = spawnSync("npm", ["pack", "--dry-run", "--json"], {
303
311
  cwd: root,
@@ -306,7 +314,7 @@ export function currentPacklist(root = ROOT) {
306
314
  });
307
315
  if (r.status !== 0) return { error: `npm pack failed: ${r.stderr?.trim()}` };
308
316
  try {
309
- const [pkg] = JSON.parse(r.stdout);
317
+ const pkg = packEntry(JSON.parse(r.stdout));
310
318
  return { files: pkg.files.map((f) => f.path).sort() };
311
319
  } catch (e) {
312
320
  return { error: `npm pack output unparseable: ${e.message}` };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore-claude",
3
- "version": "0.30.6",
3
+ "version": "0.30.7",
4
4
  "description": "projectstore for Claude Code: the core pinned and bundled, the harness fixed. A release artifact; the way in is npx projectstore setup",
5
5
  "keywords": [
6
6
  "projectstore",
@@ -37,7 +37,7 @@
37
37
  "README.md"
38
38
  ],
39
39
  "dependencies": {
40
- "projectstore": "=0.30.6"
40
+ "projectstore": "=0.30.7"
41
41
  },
42
42
  "bundleDependencies": [
43
43
  "projectstore"