projectstore-claude 0.30.1 → 0.30.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 (30) hide show
  1. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  2. package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
  3. package/node_modules/projectstore/AGENTS.md +7 -5
  4. package/node_modules/projectstore/README.md +5 -5
  5. package/node_modules/projectstore/agents/clerk.md +2 -2
  6. package/node_modules/projectstore/commands/agents.md +38 -26
  7. package/node_modules/projectstore/commands/bind.md +1 -1
  8. package/node_modules/projectstore/commands/doctor.md +15 -3
  9. package/node_modules/projectstore/commands/reconcile.md +2 -2
  10. package/node_modules/projectstore/commands/review.md +1 -1
  11. package/node_modules/projectstore/commands/story.md +2 -1
  12. package/node_modules/projectstore/docs/getting-started.md +1 -1
  13. package/node_modules/projectstore/docs/harnesses.md +3 -2
  14. package/node_modules/projectstore/harnesses/claude-code.json +92 -2
  15. package/node_modules/projectstore/harnesses/codex.json +81 -14
  16. package/node_modules/projectstore/package.json +1 -1
  17. package/node_modules/projectstore/scripts/agents-change.mjs +64 -10
  18. package/node_modules/projectstore/scripts/build-adapters.mjs +28 -13
  19. package/node_modules/projectstore/scripts/cli.mjs +32 -9
  20. package/node_modules/projectstore/scripts/configure-menu.mjs +716 -0
  21. package/node_modules/projectstore/scripts/configure.mjs +239 -91
  22. package/node_modules/projectstore/scripts/doctor.mjs +95 -31
  23. package/node_modules/projectstore/scripts/harness.mjs +13 -3
  24. package/node_modules/projectstore/scripts/install-harness.mjs +29 -0
  25. package/node_modules/projectstore/scripts/lib.mjs +32 -9
  26. package/node_modules/projectstore/scripts/menu.mjs +746 -0
  27. package/node_modules/projectstore/scripts/models.mjs +649 -0
  28. package/node_modules/projectstore/scripts/term.mjs +167 -0
  29. package/node_modules/projectstore/templates/claude-md-block.md.tmpl +7 -5
  30. package/package.json +2 -2
@@ -0,0 +1,649 @@
1
+ // projectstore — models.mjs: the models a harness's agents can be started
2
+ // with (the ADR "Agent model lists are read from each harness's own catalog,
3
+ // declared in its manifest and cached for a day"). Every harness difference
4
+ // is the manifest's agent_translation.models block: what its spawn takes,
5
+ // the floor list, and the catalogs to read, each by field paths. Nothing
6
+ // here names a harness.
7
+ //
8
+ // modelSpec(manifest) → the block, or null
9
+ // knownModels(manifest, opts) → what local reads know: offered rows,
10
+ // every known name, the source and its
11
+ // age, and the live catalog a refresh
12
+ // would ask (decisions 1, 3 and 4)
13
+ // readLive(manifest, opts) → Promise of { at, source, entries } or
14
+ // { error }: one bounded read, cached on
15
+ // success, never throwing (decision 3)
16
+ // offListNote(manifest, name, known) → the PLAN's note for a name no list
17
+ // holds, or null (decision 5)
18
+ // modelsProblem(manifest) → what is wrong with the block, or null:
19
+ // the manifest schema test's check
20
+ //
21
+ // Effort (the ADR "Agent effort is passed at spawn beside the model, from the
22
+ // overlay, in each harness's own levels") reads the same entries: the
23
+ // manifest's agent_translation.effort names the levels its spawn takes, and,
24
+ // where a catalog lists a model's own levels, their field paths.
25
+ //
26
+ // effortSpec(manifest) → the effort block, or null
27
+ // effortLevels(manifest, model, known) → the levels a model takes, from its
28
+ // catalog entry or the manifest's floor
29
+ // offLevelNote(manifest, level, model, known) → the PLAN's note for a level
30
+ // that list lacks, or null (decision 5)
31
+ // effortProblem(manifest) → what is wrong with the block, or null
32
+ //
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.
37
+
38
+ import { spawn as spawnChild } from "node:child_process";
39
+ import { mkdirSync, readFileSync, statSync } from "node:fs";
40
+ import { homedir } from "node:os";
41
+ import { dirname, join } from "node:path";
42
+ import { cachePaths, harnessHome, whichOnPath } from "./harness.mjs";
43
+ import { writeFileAtomic } from "./lib.mjs";
44
+
45
+ // A known list is fresh for a day (decision 3); a live read is bounded by 3 s.
46
+ export const FRESH_MS = 24 * 60 * 60 * 1000;
47
+ export const LIVE_TIMEOUT_MS = 3000;
48
+ // A catalog's output beyond this is not a catalog: the read stops there.
49
+ const MAX_BYTES = 16 * 1024 * 1024;
50
+
51
+ export function modelSpec(manifest) {
52
+ const m = manifest?.agent_translation?.models;
53
+ return m && typeof m === "object" ? m : null;
54
+ }
55
+
56
+ export function effortSpec(manifest) {
57
+ const e = manifest?.agent_translation?.effort;
58
+ return e && typeof e === "object" ? e : null;
59
+ }
60
+
61
+ // A level is a lowercase token: the shape --set accepts and a catalog's
62
+ // level must have to be kept.
63
+ export const LEVEL = /^[a-z][a-z0-9-]*$/;
64
+
65
+ // One field path, dotted: "data", "models", "fetched_at".
66
+ const at = (obj, path) =>
67
+ String(path || "")
68
+ .split(".")
69
+ .filter(Boolean)
70
+ .reduce((o, k) => (o && typeof o === "object" ? o[k] : undefined), obj);
71
+
72
+ // A condition { field: value }: every named field that the entry carries
73
+ // equals its value. A field the entry lacks does not count against it, so an
74
+ // older answer without the field still reads.
75
+ const matches = (entry, cond) =>
76
+ !cond || Object.entries(cond).every(([k, v]) => at(entry, k) === undefined || at(entry, k) === v);
77
+
78
+ // A catalog's fields: the block's shared ones, then the catalog's own.
79
+ const fieldsOf = (spec, catalog) => ({ ...(spec.fields || {}), ...(catalog.fields || {}) });
80
+
81
+ // A catalog's text as a terminal may draw it: control characters and
82
+ // escapes removed (the ADR, decision 4), so an answer cannot move the cursor.
83
+ // A line break or a tab reads as a space, so words never merge.
84
+ const clean = (s) =>
85
+ String(s)
86
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
87
+ .replace(/[\t\n\r\v\f]+/g, " ")
88
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, "");
89
+
90
+ // A catalog's answer as rows: { id, label, description, family, hidden },
91
+ // in the catalog's order, and `levels` and `default_level` when `levelFields`
92
+ // (the effort block's catalog paths) finds them in an entry. Entries without
93
+ // an id, and entries `keep_when` rejects, are dropped; `shown_when` only hides.
94
+ export function entriesOf(body, fields, levelFields = null) {
95
+ const list = at(body, fields.list);
96
+ if (!Array.isArray(list)) return null;
97
+ const rows = [];
98
+ list.forEach((e, k) => {
99
+ if (!e || typeof e !== "object") return;
100
+ const raw = at(e, fields.id);
101
+ const id = typeof raw === "string" ? clean(raw) : "";
102
+ if (!id) return;
103
+ if (!matches(e, fields.keep_when)) return;
104
+ const text = (p) => {
105
+ const v = p ? at(e, p) : undefined;
106
+ return typeof v === "string" ? clean(v) : "";
107
+ };
108
+ rows.push({
109
+ id,
110
+ label: text(fields.label),
111
+ description: text(fields.description),
112
+ family: text(fields.family),
113
+ hidden: fields.shown_when ? !matches(e, fields.shown_when) : false,
114
+ ...levelsOf(e, levelFields),
115
+ order: fields.order ? at(e, fields.order) : k,
116
+ k,
117
+ });
118
+ });
119
+ const desc = Boolean(fields.order_desc);
120
+ rows.sort((a, b) => {
121
+ const x = a.order,
122
+ y = b.order;
123
+ if (x === y || x === undefined || y === undefined) return a.k - b.k;
124
+ return (x < y ? -1 : 1) * (desc ? -1 : 1);
125
+ });
126
+ return rows.map(({ order: _order, k: _k, ...row }) => row);
127
+ }
128
+
129
+ // One entry's own levels, by the effort block's catalog paths: { levels:
130
+ // [{ id, description }], default_level } or nothing at all, so an entry
131
+ // without them reads exactly as it did before levels were read.
132
+ function levelsOf(e, lf) {
133
+ if (!lf || !lf.list) return {};
134
+ const list = at(e, lf.list);
135
+ if (!Array.isArray(list)) return {};
136
+ const levels = [];
137
+ for (const x of list) {
138
+ const raw = x && typeof x === "object" ? at(x, lf.id || "id") : x;
139
+ const id = typeof raw === "string" ? clean(raw) : "";
140
+ if (!LEVEL.test(id) || levels.some((l) => l.id === id)) continue;
141
+ const d = x && typeof x === "object" && lf.description ? at(x, lf.description) : undefined;
142
+ levels.push({ id, description: typeof d === "string" ? clean(d) : "" });
143
+ }
144
+ if (!levels.length) return {};
145
+ const dRaw = lf.default ? at(e, lf.default) : undefined;
146
+ const d = typeof dRaw === "string" ? clean(dRaw) : "";
147
+ return { levels, ...(levels.some((l) => l.id === d) ? { default_level: d } : {}) };
148
+ }
149
+
150
+ // A cached row's levels, cleaned again: the cache is a file anyone can edit.
151
+ function cachedLevels(x) {
152
+ if (!Array.isArray(x.levels)) return {};
153
+ return levelsOf(
154
+ { l: x.levels, d: x.default_level },
155
+ { list: "l", id: "id", description: "description", default: "d" },
156
+ );
157
+ }
158
+
159
+ const timeOf = (v) => {
160
+ const t = typeof v === "string" ? Date.parse(v) : NaN;
161
+ return Number.isFinite(t) ? t : null;
162
+ };
163
+
164
+ // The cache file of one harness: <cache root>/models/<id>.json (decision 4).
165
+ export function modelCachePath(manifest, { env = process.env, home = homedir() } = {}) {
166
+ return cachePaths({ env, home }).models(manifest?.id);
167
+ }
168
+
169
+ function readCache(manifest, opts) {
170
+ const p = modelCachePath(manifest, opts);
171
+ if (!p) return null;
172
+ try {
173
+ const c = JSON.parse(readFileSync(p, "utf8"));
174
+ const t = timeOf(c?.at);
175
+ if (t === null || !Array.isArray(c.models)) return null;
176
+ // Cleaned again: the cache is a file anyone can edit.
177
+ const entries = c.models
178
+ .filter((x) => x && typeof x.id === "string" && clean(x.id))
179
+ .map((x) => ({
180
+ id: clean(x.id),
181
+ label: clean(x.label ?? ""),
182
+ description: clean(x.description ?? ""),
183
+ family: clean(x.family ?? ""),
184
+ hidden: Boolean(x.hidden),
185
+ ...cachedLevels(x),
186
+ }));
187
+ const source = typeof c.source === "string" ? c.source : "";
188
+ const from = (modelSpec(manifest)?.catalogs || []).find((x) => x.source === source);
189
+ return { at: t, source, caption: from?.caption || source, entries };
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+
195
+ function readFileCatalog(manifest, spec, catalog, opts) {
196
+ // Under the harness's home, as the harness itself resolves it.
197
+ const base = harnessHome(manifest, opts);
198
+ if (!base || !catalog.path) return null;
199
+ const p = join(base, catalog.path);
200
+ try {
201
+ const raw = readFileSync(p, "utf8");
202
+ const fields = fieldsOf(spec, catalog);
203
+ const body = JSON.parse(raw);
204
+ const entries = entriesOf(body, fields, effortSpec(manifest)?.catalog);
205
+ if (!entries) return null;
206
+ const t = timeOf(fields.at ? at(body, fields.at) : undefined) ?? statSync(p).mtimeMs;
207
+ return { at: t, source: catalog.source || "", caption: catalog.caption || catalog.source || "", entries };
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
212
+
213
+ // Where an http catalog's key goes: the key's own base when the manifest
214
+ // names a base variable and it is set, else the manifest's URL (the ADR,
215
+ // decision 2). Never any other host.
216
+ function urlOf(catalog, env) {
217
+ const base = catalog.base_env && env?.[catalog.base_env] ? String(env[catalog.base_env]).replace(/\/+$/, "") : null;
218
+ return base && catalog.path ? `${base}${catalog.path}` : catalog.url;
219
+ }
220
+ function hostOf(catalog, env) {
221
+ try {
222
+ return new URL(urlOf(catalog, env)).host;
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+
228
+ // The catalog a refresh asks: the first `command` or `http` one that can run
229
+ // here — its key variable set, or its bin on PATH — or null, with why not.
230
+ function liveCatalog(spec, env) {
231
+ let why = null;
232
+ for (const c of spec.catalogs || []) {
233
+ if (c.kind === "http") {
234
+ if (c.key_env && !(env && env[c.key_env])) {
235
+ why = why || `no ${c.key_env}`;
236
+ continue;
237
+ }
238
+ return { catalog: c, why: null, host: hostOf(c, env) };
239
+ }
240
+ if (c.kind === "command") {
241
+ const bin = Array.isArray(c.argv) ? c.argv[0] : null;
242
+ if (!bin || !whichOnPath(bin, env || {})) {
243
+ why = why || `${bin || "the command"} not on PATH`;
244
+ continue;
245
+ }
246
+ return { catalog: c, why: null };
247
+ }
248
+ }
249
+ return { catalog: null, why };
250
+ }
251
+
252
+ // What the local reads know about one harness's models. `offered` is what a
253
+ // menu lists, in order; `known` every name any list holds, hidden ones too.
254
+ // spawn_takes "id": the newest catalog's shown entries, then static ones
255
+ // it lacks.
256
+ // spawn_takes "alias": the static aliases, each with the newest entry of its
257
+ // family in the catalog (`follows`), and nothing else.
258
+ export function knownModels(manifest, { env = process.env, home = homedir(), now = Date.now } = {}) {
259
+ const spec = modelSpec(manifest);
260
+ if (!spec) return null;
261
+ const opts = { env, home };
262
+ const candidates = [
263
+ readCache(manifest, opts),
264
+ ...(spec.catalogs || []).filter((c) => c.kind === "file").map((c) => readFileCatalog(manifest, spec, c, opts)),
265
+ ].filter(Boolean);
266
+ // A list dated in the future (a clock that moved back) is used only when
267
+ // nothing else is known, and it is stale.
268
+ const t0 = now();
269
+ const past = candidates.filter((x) => x.at <= t0);
270
+ const newest = (past.length ? past : candidates).reduce((a, b) => (!a || b.at > a.at ? b : a), null);
271
+ const entries = newest ? newest.entries : [];
272
+ const statics = (spec.static || []).filter((s) => s && typeof s.id === "string" && s.id);
273
+ // What the spawn can be given: an alias harness's aliases only — an exact
274
+ // id a catalog lists is not one of them — and every catalog id otherwise.
275
+ const known = new Set(statics.map((s) => s.id));
276
+ let offered;
277
+ if (spec.spawn_takes === "alias") {
278
+ const followed = new Map();
279
+ for (const e of entries) if (e.family && !followed.has(e.family)) followed.set(e.family, e);
280
+ offered = statics.map((s) => ({
281
+ id: s.id,
282
+ label: s.label || "",
283
+ description: s.description || "",
284
+ family: s.family || s.id,
285
+ follows: followed.get(s.family || s.id) || null,
286
+ }));
287
+ // A line the catalog reports that `static` lacks is never offered and
288
+ // never known: the spawn takes the static aliases only (decision 1).
289
+ } else {
290
+ for (const e of entries) known.add(e.id);
291
+ offered = entries.filter((e) => !e.hidden).map((e) => ({ ...e }));
292
+ for (const s of statics)
293
+ if (!offered.some((o) => o.id === s.id))
294
+ offered.push({ id: s.id, label: s.label || "", description: s.description || "" });
295
+ }
296
+ // A model's own levels, from the newest list whose entry for it names
297
+ // them: a cache written before levels were kept falls through to the
298
+ // harness's own file, so a refresh never loses what that file says.
299
+ const levels = new Map();
300
+ for (const c of [...(past.length ? past : candidates)].sort((a, b) => b.at - a.at))
301
+ for (const e of c.entries)
302
+ if (e.levels && !levels.has(e.id))
303
+ levels.set(e.id, { levels: e.levels, default: e.default_level || null, source: c.source, at: c.at });
304
+ const t = t0;
305
+ const live = liveCatalog(spec, env);
306
+ return {
307
+ spawnTakes: spec.spawn_takes === "alias" ? "alias" : "id",
308
+ aliases: spec.spawn_takes === "alias" ? statics.map((s) => s.id) : [],
309
+ offered,
310
+ known,
311
+ hidden: new Set(entries.filter((e) => e.hidden).map((e) => e.id)),
312
+ levels,
313
+ source: newest ? newest.source : "",
314
+ caption: newest ? newest.caption || newest.source : "",
315
+ at: newest ? newest.at : null,
316
+ stale: !newest || newest.at > t || t - newest.at >= FRESH_MS,
317
+ live: live.catalog
318
+ ? {
319
+ source: live.catalog.source || "",
320
+ caption: live.catalog.caption || live.catalog.source || "",
321
+ kind: live.catalog.kind,
322
+ ...(live.host ? { host: live.host } : {}),
323
+ }
324
+ : null,
325
+ why: live.why,
326
+ };
327
+ }
328
+
329
+ // The PLAN's note for a model no list holds (decision 5), or null when one
330
+ // does. `known` is knownModels' answer.
331
+ // alias harness: the name is not one of its aliases, and its spawn refuses it;
332
+ // id harness: the name is not in its newest list, as of that list's date;
333
+ // no list: nothing is known to compare with.
334
+ export function offListNote(manifest, name, known) {
335
+ const display = manifest?.display_name || manifest?.id || "this harness";
336
+ if (!name) return null;
337
+ if (!known) return `no list of ${display}'s models is known; written as typed`;
338
+ if (known.known.has(name)) return null;
339
+ if (known.spawnTakes === "alias" && known.aliases.length) {
340
+ const list = known.aliases.join(", ");
341
+ return `${name} is not one of ${display}'s aliases (${list}); written as typed, and ${display} refuses it at spawn`;
342
+ }
343
+ if (known.source && known.at) return `not in ${known.source}'s list as of ${localDate(known.at)}; written as typed`;
344
+ if (known.known.size) return `not in ${display}'s list; written as typed`;
345
+ return `no list of ${display}'s models is known; written as typed`;
346
+ }
347
+
348
+ // A date as the person's clock reads it: 2026-10-13.
349
+ const localDate = (t) => {
350
+ const d = new Date(t);
351
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
352
+ };
353
+
354
+ // The levels a model takes (the effort ADR, decisions 3 and 4): its own, when
355
+ // a list names them, else the manifest's floor. `known` is knownModels'
356
+ // answer, or null. Null when the manifest declares no effort block.
357
+ // { levels: [{ id, description }], default, from: "catalog" | "floor",
358
+ // model, source, at }
359
+ export function effortLevels(manifest, model, known) {
360
+ const spec = effortSpec(manifest);
361
+ if (!spec) return null;
362
+ const own = model ? known?.levels?.get(model) : null;
363
+ if (own) return { levels: own.levels, default: own.default, from: "catalog", model, source: own.source, at: own.at };
364
+ const levels = (spec.levels || []).filter((l) => l && typeof l.id === "string" && l.id);
365
+ return { levels, default: null, from: "floor", model: model || null, source: "", at: null };
366
+ }
367
+
368
+ // The PLAN's note for a level the list lacks (the effort ADR, decision 5), or
369
+ // null when it is listed, or when the harness declares no levels at all.
370
+ // the model's own list: "<model> does not list <level> in <source>'s list
371
+ // as of <date>; written as typed"
372
+ // the floor: "<level> is not one of <harness>'s effort levels
373
+ // (<levels>); written as typed", and, where the
374
+ // spawn takes those levels only, that it refuses it
375
+ export function offLevelNote(manifest, level, model, known) {
376
+ if (!level) return null;
377
+ const l = effortLevels(manifest, model, known);
378
+ if (!l || l.levels.some((x) => x.id === level)) return null;
379
+ const display = manifest?.display_name || manifest?.id || "this harness";
380
+ if (l.from === "catalog")
381
+ return `${l.model} does not list ${level} in ${l.source || display}'s list${l.at ? ` as of ${localDate(l.at)}` : ""}; written as typed`;
382
+ const list = l.levels.map((x) => x.id).join(", ");
383
+ return effortSpec(manifest).strict
384
+ ? `${level} is not one of ${display}'s effort levels (${list}); written as typed, and ${display} refuses it at spawn`
385
+ : `${level} is not one of ${display}'s effort levels (${list}); written as typed`;
386
+ }
387
+
388
+ // The note for a level an agent keeps when the run moves it to a model that
389
+ // does not take it (the effort ADR, decision 5), or null: "<agent> runs at
390
+ // <level>, which <model> does not list in <source>'s list as of <date>".
391
+ export function standingLevelNote(manifest, agent, level, model, known) {
392
+ if (!level) return null;
393
+ const l = effortLevels(manifest, model, known);
394
+ if (!l || l.levels.some((x) => x.id === level)) return null;
395
+ const display = manifest?.display_name || manifest?.id || "this harness";
396
+ return l.from === "catalog"
397
+ ? `${agent} runs at ${level}, which ${l.model} does not list in ${l.source || display}'s list${l.at ? ` as of ${localDate(l.at)}` : ""}`
398
+ : `${agent} runs at ${level}, which is not one of ${display}'s effort levels`;
399
+ }
400
+
401
+ // What is wrong with a manifest's models block, or null (the manifest schema
402
+ // test, generation contract 1). A manifest without the block is fine: its
403
+ // agents' models are typed by name.
404
+ export function modelsProblem(manifest) {
405
+ const spec = modelSpec(manifest);
406
+ if (!spec) return manifest?.agent_translation?.models === undefined ? null : "models is not an object";
407
+ if (!["alias", "id"].includes(spec.spawn_takes)) return `spawn_takes is ${spec.spawn_takes}, not alias or id`;
408
+ if (typeof spec.spawn_takes_reason !== "string" || !spec.spawn_takes_reason)
409
+ return "spawn_takes carries no spawn_takes_reason";
410
+ if (!Array.isArray(spec.static)) return "static is not a list";
411
+ for (const x of spec.static) if (!x || typeof x.id !== "string" || !x.id) return "a static entry has no id";
412
+ if (spec.spawn_takes === "alias" && !spec.static.length) return "an alias harness lists no alias";
413
+ if (!Array.isArray(spec.catalogs)) return "catalogs is not a list";
414
+ for (const c of spec.catalogs) {
415
+ const fields = fieldsOf(spec, c);
416
+ if (!["file", "command", "http"].includes(c?.kind)) return `a catalog's kind is ${c?.kind}`;
417
+ if (typeof c.source !== "string" || !c.source) return `a ${c.kind} catalog names no source`;
418
+ if (!fields.list || !fields.id) return `the ${c.source} catalog names no list or id field`;
419
+ if (typeof c.reason !== "string" || !c.reason) return `the ${c.source} catalog carries no reason`;
420
+ if (c.kind === "file" && (typeof c.path !== "string" || !c.path || c.path.split("/").includes("..")))
421
+ return `the ${c.source} file catalog's path is not under the harness home`;
422
+ if (c.kind === "command" && (!Array.isArray(c.argv) || !c.argv.length || c.argv.some((a) => typeof a !== "string")))
423
+ return `the ${c.source} command catalog has no argv`;
424
+ if (c.kind === "http") {
425
+ if (!/^https:\/\//.test(String(c.url))) return `the ${c.source} http catalog's url is not https`;
426
+ if (!c.key_env || !c.key_header) return `the ${c.source} http catalog names no key variable and header`;
427
+ if (c.base_env && !String(c.path || "").startsWith("/"))
428
+ return `the ${c.source} http catalog's path is not absolute`;
429
+ }
430
+ }
431
+ return null;
432
+ }
433
+
434
+ // What is wrong with a manifest's effort block, or null (the manifest schema
435
+ // test). A manifest without the block passes no effort, and is fine.
436
+ export function effortProblem(manifest) {
437
+ const spec = effortSpec(manifest);
438
+ if (!spec) return manifest?.agent_translation?.effort === undefined ? null : "effort is not an object";
439
+ const str = (v) => typeof v === "string" && v.length > 0;
440
+ const EVIDENCE = ["measured", "documented", "chosen"];
441
+ if (!str(spec.spawn_param) || !str(spec.spawn_param_reason)) return "spawn_param carries no name or no reason";
442
+ if (!EVIDENCE.includes(spec.spawn_param_evidence))
443
+ return `spawn_param_evidence is ${spec.spawn_param_evidence}, not ${EVIDENCE.join(", ")}`;
444
+ if (!Array.isArray(spec.levels) || !spec.levels.length) return "levels is not a non-empty list";
445
+ for (const l of spec.levels)
446
+ if (!l || !LEVEL.test(String(l.id)) || !str(l.description))
447
+ return `a level is not { id, description }: ${JSON.stringify(l)}`;
448
+ if (!str(spec.levels_reason)) return "levels carries no levels_reason";
449
+ if (spec.strict !== undefined && (typeof spec.strict !== "boolean" || !str(spec.strict_reason)))
450
+ return "strict is not a boolean with a strict_reason";
451
+ if (spec.catalog !== undefined) {
452
+ const c = spec.catalog;
453
+ if (!c || !str(c.list) || !str(c.id) || !str(c.reason)) return "the effort catalog names no list, id or reason";
454
+ if (!modelSpec(manifest)) return "an effort catalog without a models block has no entries to read";
455
+ }
456
+ const u = spec.unset;
457
+ if (!u || !str(u.value) || !str(u.row) || typeof u.model_default !== "boolean" || !str(u.reason))
458
+ return "unset is not { value, row, model_default, reason }";
459
+ if (spec.cheap !== undefined) {
460
+ if (!spec.levels.some((l) => l.id === spec.cheap)) return `cheap is ${spec.cheap}, not one of the levels`;
461
+ if (!str(spec.cheap_reason)) return "cheap carries no cheap_reason";
462
+ if (!EVIDENCE.includes(spec.cheap_evidence))
463
+ return `cheap_evidence is ${spec.cheap_evidence}, not ${EVIDENCE.join(", ")}`;
464
+ }
465
+ return null;
466
+ }
467
+
468
+ // One bounded fetch, settled by a timer whatever the request does after: the
469
+ // pattern of convergence.mjs's request(). The key goes in its header and
470
+ // nowhere else; nothing of the request is kept.
471
+ function readHttp(catalog, { env, fetch, timers, timeoutMs, signal }) {
472
+ return new Promise((settle) => {
473
+ if (typeof fetch !== "function") return settle({ error: "no fetch in this Node" });
474
+ let done = false,
475
+ timer = null;
476
+ const ac = typeof AbortController === "function" ? new AbortController() : null;
477
+ const finish = (r) => {
478
+ if (done) return;
479
+ done = true;
480
+ if (timer !== null)
481
+ try {
482
+ timers.clearTimeout(timer);
483
+ } catch {}
484
+ settle(r);
485
+ };
486
+ timer = timers.setTimeout(() => {
487
+ timer = null;
488
+ try {
489
+ ac?.abort();
490
+ } catch {}
491
+ finish({ error: `no answer within ${timeoutMs / 1000} s` });
492
+ }, timeoutMs);
493
+ try {
494
+ timer?.unref?.();
495
+ } catch {}
496
+ signal?.addEventListener?.("abort", () => {
497
+ try {
498
+ ac?.abort();
499
+ } catch {}
500
+ finish({ error: "stopped" });
501
+ });
502
+ const url = urlOf(catalog, env);
503
+ const headers = { accept: "application/json", ...(catalog.headers || {}) };
504
+ if (catalog.key_env && catalog.key_header) headers[catalog.key_header] = String(env?.[catalog.key_env] || "");
505
+ Promise.resolve()
506
+ .then(() => fetch(url, { headers, ...(ac ? { signal: ac.signal } : {}) }))
507
+ .then(async (res) => {
508
+ if (!res || !res.ok) {
509
+ try {
510
+ Promise.resolve(res?.body?.cancel?.()).catch(() => {});
511
+ } catch {}
512
+ const status = res?.status ?? "error";
513
+ return finish({
514
+ error: status === 401 || status === 403 ? `the key was refused (HTTP ${status})` : `HTTP ${status}`,
515
+ });
516
+ }
517
+ const size = Number(res.headers?.get?.("content-length"));
518
+ if (Number.isFinite(size) && size > MAX_BYTES) {
519
+ try {
520
+ Promise.resolve(res?.body?.cancel?.()).catch(() => {});
521
+ } catch {}
522
+ return finish({ error: "the answer is too large" });
523
+ }
524
+ try {
525
+ finish({ body: await res.json() });
526
+ } catch {
527
+ finish({ error: "the answer is not JSON" });
528
+ }
529
+ })
530
+ .catch((e) => finish({ error: String(e?.cause?.code || e?.code || e?.message || "the request failed") }));
531
+ });
532
+ }
533
+
534
+ // One bounded run of a catalog command: its stdout as JSON. Asynchronous, so
535
+ // a menu keeps drawing while it runs; killed at the bound.
536
+ function readCommand(catalog, { env, spawn, timers, timeoutMs, signal }) {
537
+ return new Promise((settle) => {
538
+ let done = false,
539
+ timer = null,
540
+ child = null;
541
+ const chunks = [];
542
+ let bytes = 0;
543
+ const finish = (r) => {
544
+ if (done) return;
545
+ done = true;
546
+ if (timer !== null)
547
+ try {
548
+ timers.clearTimeout(timer);
549
+ } catch {}
550
+ settle(r);
551
+ };
552
+ const [bin, ...args] = catalog.argv;
553
+ try {
554
+ child = spawn(bin, args, { env, stdio: ["ignore", "pipe", "ignore"] });
555
+ } catch (e) {
556
+ return finish({ error: String(e?.code || e?.message || "could not start") });
557
+ }
558
+ timer = timers.setTimeout(() => {
559
+ timer = null;
560
+ try {
561
+ child.kill("SIGTERM");
562
+ } catch {}
563
+ finish({ error: `no answer within ${timeoutMs / 1000} s` });
564
+ }, timeoutMs);
565
+ try {
566
+ timer?.unref?.();
567
+ } catch {}
568
+ signal?.addEventListener?.("abort", () => {
569
+ try {
570
+ child.kill("SIGTERM");
571
+ } catch {}
572
+ finish({ error: "stopped" });
573
+ });
574
+ child.on("error", (e) =>
575
+ finish({ error: e?.code === "ENOENT" ? `${bin} not on PATH` : String(e?.code || e?.message) }),
576
+ );
577
+ child.stdout?.on("data", (c) => {
578
+ bytes += c.length;
579
+ if (bytes > MAX_BYTES) {
580
+ try {
581
+ child.kill("SIGTERM");
582
+ } catch {}
583
+ return finish({ error: "the answer is too large" });
584
+ }
585
+ chunks.push(c);
586
+ });
587
+ child.on("close", (code, signal) => {
588
+ if (code !== 0)
589
+ return finish({
590
+ error: `${catalog.source || bin} could not refresh its list (${signal ? `killed by ${signal}` : `exited ${code}`})`,
591
+ });
592
+ try {
593
+ finish({ body: JSON.parse(Buffer.concat(chunks).toString("utf8")) });
594
+ } catch {
595
+ finish({ error: "the answer is not JSON" });
596
+ }
597
+ });
598
+ });
599
+ }
600
+
601
+ // The live read (decision 3): the first catalog that can run, once, within
602
+ // the bound. On success the rows are written to projectstore's cache — names,
603
+ // labels, descriptions and families only, never a key or a header (decision
604
+ // 4) — and returned; a failure is returned as { error } and never cached.
605
+ export async function readLive(
606
+ manifest,
607
+ {
608
+ env = process.env,
609
+ home = homedir(),
610
+ now = Date.now,
611
+ // 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"
612
+ fetch = globalThis.fetch,
613
+ spawn = spawnChild,
614
+ timers = globalThis,
615
+ timeoutMs = LIVE_TIMEOUT_MS,
616
+ signal = null,
617
+ } = {},
618
+ ) {
619
+ const spec = modelSpec(manifest);
620
+ if (!spec) return { error: "no model list for this harness" };
621
+ const { catalog, why } = liveCatalog(spec, env);
622
+ if (!catalog) return { error: why || "no catalog to ask" };
623
+ let r;
624
+ try {
625
+ r =
626
+ catalog.kind === "http"
627
+ ? await readHttp(catalog, { env, fetch, timers, timeoutMs, signal })
628
+ : await readCommand(catalog, { env, spawn, timers, timeoutMs, signal });
629
+ } catch (e) {
630
+ r = { error: String(e?.message || e) };
631
+ }
632
+ if (r.error) return { error: r.error, source: catalog.source || "" };
633
+ if (signal?.aborted) return { error: "stopped", source: catalog.source || "" };
634
+ const entries = entriesOf(r.body, fieldsOf(spec, catalog), effortSpec(manifest)?.catalog);
635
+ if (!entries) return { error: "the answer holds no list", source: catalog.source || "" };
636
+ const t = now();
637
+ const p = modelCachePath(manifest, { env, home });
638
+ if (p)
639
+ try {
640
+ mkdirSync(dirname(p), { recursive: true });
641
+ writeFileAtomic(
642
+ p,
643
+ JSON.stringify({ at: new Date(t).toISOString(), source: catalog.source || "", models: entries }, null, 2) +
644
+ "\n",
645
+ { sweep: false },
646
+ );
647
+ } catch {}
648
+ return { at: t, source: catalog.source || "", entries };
649
+ }