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,6 +54,11 @@ import {
54
54
  import { walkVaultFiles } from "./doctor.mjs";
55
55
  import { buildGraph } from "./graph.mjs";
56
56
  import { findStories, statusToColumn } from "./kanban.mjs";
57
+ // status's text screen is drawn with the presentation primitives; term.mjs
58
+ // has no side effect at import, so the MCP server's graph gains nothing it
59
+ // could trip on (cli.mjs imports it already).
60
+ import { PLAIN, painter, icon, heading, kv, rows, wrap, commandBlock } from "./term.mjs";
61
+ import { packageCommand } from "./harness.mjs";
57
62
 
58
63
  export const SEARCH_DEFAULT_LIMIT = 20;
59
64
  export const SEARCH_HARD_CAP = 100;
@@ -111,7 +116,10 @@ function usageError(msg) {
111
116
  export function intOption(name, value, { min = 0, max = Infinity, fallback }) {
112
117
  if (value === undefined || value === null) return fallback;
113
118
  const n = Number(value);
114
- if (!Number.isInteger(n) || n < min || n > max) throw usageError(`--${name} takes an integer${max < Infinity ? ` between ${min} and ${max}` : ` ≥ ${min}`}, not "${value}"`);
119
+ if (!Number.isInteger(n) || n < min || n > max)
120
+ throw usageError(
121
+ `--${name} takes an integer${max < Infinity ? ` between ${min} and ${max}` : ` ≥ ${min}`}, not "${value}"`,
122
+ );
115
123
  return n;
116
124
  }
117
125
 
@@ -136,7 +144,11 @@ function nodeFacts(n) {
136
144
  }
137
145
 
138
146
  function layoutOf(cfg) {
139
- try { return { layout: loadLayout(cfg.layout), error: null }; } catch (e) { return { layout: null, error: e.message }; }
147
+ try {
148
+ return { layout: loadLayout(cfg.layout), error: null };
149
+ } catch (e) {
150
+ return { layout: null, error: e.message };
151
+ }
140
152
  }
141
153
 
142
154
  // ─── status ────────────────────────────────────────────────────────────
@@ -149,10 +161,38 @@ function layoutOf(cfg) {
149
161
  // total is explainable without reading the generator.
150
162
  export function status(cfg, { project = null } = {}) {
151
163
  if (!cfg || !cfg.vault_path) {
152
- return { bound: false, project, vault_path: null, vault_exists: false, layout: null, language: null, auto_inject: null, approval_mode: null, spec_policy: null, lifecycle_gates: null, stories: null, views: null, sessions: null };
164
+ return {
165
+ bound: false,
166
+ project,
167
+ vault_path: null,
168
+ vault_exists: false,
169
+ layout: null,
170
+ language: null,
171
+ auto_inject: null,
172
+ approval_mode: null,
173
+ spec_policy: null,
174
+ lifecycle_gates: null,
175
+ stories: null,
176
+ views: null,
177
+ sessions: null,
178
+ };
153
179
  }
154
180
  const vault = vaultOf(cfg);
155
- const out = { bound: true, project, vault_path: vault, vault_exists: existsSync(vault), layout: cfg.layout || null, language: cfg.language || "en", auto_inject: cfg.auto_inject !== false, approval_mode: cfg.approval_mode || "always", spec_policy: null, lifecycle_gates: null, stories: null, views: null, sessions: null };
181
+ const out = {
182
+ bound: true,
183
+ project,
184
+ vault_path: vault,
185
+ vault_exists: existsSync(vault),
186
+ layout: cfg.layout || null,
187
+ language: cfg.language || "en",
188
+ auto_inject: cfg.auto_inject !== false,
189
+ approval_mode: cfg.approval_mode || "always",
190
+ spec_policy: null,
191
+ lifecycle_gates: null,
192
+ stories: null,
193
+ views: null,
194
+ sessions: null,
195
+ };
156
196
  if (!out.vault_exists) return out;
157
197
  const vcfg = readVaultConfig(vault);
158
198
  out.spec_policy = vcfg.spec_policy || "optional";
@@ -165,18 +205,30 @@ export function status(cfg, { project = null } = {}) {
165
205
  const { stories, skipped } = findStories(vault, epicFolder.path);
166
206
  const byStatus = {};
167
207
  for (const s of stories) byStatus[s.status] = (byStatus[s.status] || 0) + 1;
168
- const inProgress = stories.filter((s) => statusToColumn(s.status) === "In Progress")
208
+ const inProgress = stories
209
+ .filter((s) => statusToColumn(s.status) === "In Progress")
169
210
  .map((s) => ({ path: s.relPath, epic: s.epicId, title: s.title, started_at: null }));
170
211
  // started_at is not part of the finder's row; read it for the few in flight.
171
212
  for (const s of inProgress) {
172
- try { s.started_at = parseFrontmatter(readFileSync(join(vault, s.path), "utf8")).data.started_at ?? null; } catch {}
213
+ try {
214
+ s.started_at = parseFrontmatter(readFileSync(join(vault, s.path), "utf8")).data.started_at ?? null;
215
+ } catch {}
173
216
  }
174
217
  inProgress.sort((a, b) => cmp(String(b.started_at || ""), String(a.started_at || "")) || cmp(a.path, b.path));
175
- const skippedCounts = Object.fromEntries(Object.entries(skipped || {}).map(([k, v]) => [k, Array.isArray(v) ? v.length : 0]).filter(([, n]) => n > 0).sort(([a], [b]) => cmp(a, b)));
218
+ const skippedCounts = Object.fromEntries(
219
+ Object.entries(skipped || {})
220
+ .map(([k, v]) => [k, Array.isArray(v) ? v.length : 0])
221
+ .filter(([, n]) => n > 0)
222
+ .sort(([a], [b]) => cmp(a, b)),
223
+ );
176
224
  out.stories = {
177
- status: "ok", total: stories.length, by_status: Object.fromEntries(Object.entries(byStatus).sort(([a], [b]) => cmp(a, b))),
178
- in_progress: inProgress.slice(0, IN_FLIGHT_CAP), in_progress_total: inProgress.length,
179
- off_board: skippedCounts, off_board_total: Object.values(skippedCounts).reduce((a, b) => a + b, 0),
225
+ status: "ok",
226
+ total: stories.length,
227
+ by_status: Object.fromEntries(Object.entries(byStatus).sort(([a], [b]) => cmp(a, b))),
228
+ in_progress: inProgress.slice(0, IN_FLIGHT_CAP),
229
+ in_progress_total: inProgress.length,
230
+ off_board: skippedCounts,
231
+ off_board_total: Object.values(skippedCounts).reduce((a, b) => a + b, 0),
180
232
  };
181
233
  }
182
234
  // Freshness: a view is stale when any artifact is newer than it. The derived
@@ -186,35 +238,189 @@ export function status(cfg, { project = null } = {}) {
186
238
  let newest = 0;
187
239
  for (const f of walkVaultFiles(vault)) {
188
240
  if (views.includes(f.rel)) continue;
189
- try { newest = Math.max(newest, statSync(join(vault, f.rel)).mtimeMs); } catch {}
241
+ try {
242
+ newest = Math.max(newest, statSync(join(vault, f.rel)).mtimeMs);
243
+ } catch {}
190
244
  }
191
245
  out.views = {};
192
- for (const [key, file] of [["kanban", views[0]], ["code_map", views[1]], ["graph", views[2]]]) {
246
+ for (const [key, file] of [
247
+ ["kanban", views[0]],
248
+ ["code_map", views[1]],
249
+ ["graph", views[2]],
250
+ ]) {
193
251
  const p = join(vault, file);
194
252
  const exists = existsSync(p);
195
253
  const generatedAt = exists ? readGeneratedAt(p) : null;
196
254
  let viewMs = NaN;
197
- if (exists) { try { viewMs = statSync(p).mtimeMs; } catch {} }
198
- out.views[key] = { path: file, exists, generated_at: generatedAt, stale: exists ? (Number.isNaN(viewMs) ? null : viewMs < newest) : null };
255
+ if (exists) {
256
+ try {
257
+ viewMs = statSync(p).mtimeMs;
258
+ } catch {}
259
+ }
260
+ out.views[key] = {
261
+ path: file,
262
+ exists,
263
+ generated_at: generatedAt,
264
+ stale: exists ? (Number.isNaN(viewMs) ? null : viewMs < newest) : null,
265
+ };
199
266
  }
200
267
  const sessions = readActiveSessions(vault, null);
201
- out.sessions = { active: sessions.length, entries: sessions.slice(0, IN_FLIGHT_CAP).map((s) => ({ id: s.id ?? s.session_id ?? null, project_root: s.project_root ?? s.projectRoot ?? s.project ?? null, started_at: s.started_at ?? s.startedAt ?? null, last_active: s.last_active ?? s.lastActive ?? s.updated_at ?? null })) };
268
+ out.sessions = {
269
+ active: sessions.length,
270
+ entries: sessions.slice(0, IN_FLIGHT_CAP).map((s) => ({
271
+ id: s.id ?? s.session_id ?? null,
272
+ project_root: s.project_root ?? s.projectRoot ?? s.project ?? null,
273
+ started_at: s.started_at ?? s.startedAt ?? null,
274
+ last_active: s.last_active ?? s.lastActive ?? s.updated_at ?? null,
275
+ })),
276
+ };
202
277
  return out;
203
278
  }
204
279
 
205
- export function renderStatus(r) {
206
- if (!r.bound) return `Not bound${r.project ? ` — ${r.project}` : ""}. Run ${commandForm("bind", { args: "<vault>" })} in a session.\n`;
207
- const lines = [`Vault: ${r.vault_path}${r.vault_exists ? "" : " (missing)"}`, `Layout: ${r.layout} · language: ${r.language} · auto_inject: ${r.auto_inject} · approval_mode: ${r.approval_mode} · spec_policy: ${r.spec_policy} · lifecycle_gates: ${r.lifecycle_gates}`];
208
- if (r.stories && r.stories.status !== "ok") lines.push(`Stories: not counted — ${r.stories.error}`);
209
- else if (r.stories) {
210
- lines.push(`Stories: ${r.stories.total} on the board — ${Object.entries(r.stories.by_status).map(([k, v]) => `${k} ${v}`).join(", ") || "none"}${r.stories.off_board_total ? `; ${r.stories.off_board_total} off it (${Object.entries(r.stories.off_board).map(([k, v]) => `${k} ${v}`).join(", ")})` : ""}`);
211
- if (r.stories.in_progress_total) {
212
- lines.push(`In progress (${r.stories.in_progress_total}):`);
213
- for (const s of r.stories.in_progress) lines.push(` ${s.epic}: ${s.title} — ${s.path}${s.started_at ? ` (since ${s.started_at})` : ""}`);
214
- } else lines.push("In progress: nothing");
280
+ // Stands in for a space wrap() must not break at; given back after it.
281
+ const GLUE = "";
282
+
283
+ // A story's start as a date: a date stays as written; a timestamp is read in
284
+ // the reader's own zone. Anything else prints as it is.
285
+ export function startDate(at) {
286
+ const s = String(at);
287
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
288
+ const d = /^\d{4}-\d{2}-\d{2}T/.test(s) ? new Date(s) : null;
289
+ if (!d || Number.isNaN(d.getTime())) return s;
290
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
291
+ }
292
+
293
+ // status's screen (presentation spec contract 12), from status()'s result as
294
+ // it is — the envelope is untouched. The binding as key–value lines; the
295
+ // board's counts on one line; the stories in progress grouped by epic, each a
296
+ // row of its start, its title and its path, the list ending with `+N more —
297
+ // see <the kanban view's path>` when status() listed fewer than it counted;
298
+ // then the views' freshness and the sessions. No badge: that is the install
299
+ // family's and doctor's. `caps` is the stream's (term.mjs; plain by default):
300
+ // prose wraps, a long title wraps under itself and a row's note moves on a
301
+ // live terminal only, so a pipe gets one line per row. `env` names the
302
+ // session's command when nothing is bound, in the listening harness's form;
303
+ // the terminal's is the core through npx, through a shell too (the story
304
+ // "User-facing text says where to run and names no project path and no
305
+ // shell", rule 6).
306
+ export function renderStatus(r, { caps: c = PLAIN, env = process.env } = {}) {
307
+ const paint = painter(c);
308
+ const dash = icon(c, "dash"),
309
+ dot = icon(c, "dot");
310
+ const prose = (text, indent = "") => indent + wrap(text, c.live ? c.width : 0, indent + " ");
311
+ // A heading whose counts wrap only between them, after the dot: a count is
312
+ // never parted from its word. Each part's spaces are glued for wrap() and
313
+ // given back after it.
314
+ const counted = (title, parts) => {
315
+ const glued = parts.map((x) =>
316
+ Array.isArray(x) ? [x[0].split(" ").join(GLUE), x[1]] : String(x).split(" ").join(GLUE),
317
+ );
318
+ return prose(heading(c, title, glued).split(` ${dot} `).join(`${GLUE}${dot} `))
319
+ .split(GLUE)
320
+ .join(" ");
321
+ };
322
+ if (!r.bound) {
323
+ return (
324
+ [
325
+ prose(`Not bound${r.project ? ` ${dash} ${r.project}` : ""}`),
326
+ "",
327
+ prose("Next: bind it to a vault, in a session or from a terminal:"),
328
+ ...commandBlock(c, [
329
+ commandForm("bind", { args: "<vault>", env }),
330
+ packageCommand([], "bind", { args: "<vault>" }),
331
+ ]),
332
+ ].join("\n") + "\n"
333
+ );
334
+ }
335
+ const missing = r.vault_exists ? "" : ` ${paint("attention", `${icon(c, "warning")} missing`)}`;
336
+ const binding = [
337
+ ["vault_path", `${r.vault_path}${missing}`],
338
+ ["layout", r.layout],
339
+ ["language", r.language],
340
+ ["auto_inject", r.auto_inject],
341
+ ["approval_mode", r.approval_mode],
342
+ ["spec_policy", r.spec_policy],
343
+ ["lifecycle_gates", r.lifecycle_gates],
344
+ ];
345
+ const lines = [
346
+ heading(c, "Binding"),
347
+ ...kv(
348
+ c,
349
+ binding.filter(([, v]) => v !== null && v !== undefined),
350
+ ),
351
+ ];
352
+ const s = r.stories;
353
+ if (s && s.status !== "ok")
354
+ lines.push("", prose(heading(c, "Stories", [[`not counted ${dash} ${s.error}`, "attention"]])));
355
+ else if (s) {
356
+ const counts = [`${s.total} on the board`, ...Object.entries(s.by_status).map(([k, n]) => `${n} ${k}`)];
357
+ if (s.off_board_total)
358
+ counts.push([
359
+ `${s.off_board_total} off the board (${Object.entries(s.off_board)
360
+ .map(([k, n]) => `${k} ${n}`)
361
+ .join(", ")})`,
362
+ "explanation",
363
+ ]);
364
+ lines.push("", counted("Stories", counts));
365
+ const listed = s.in_progress || [];
366
+ lines.push(
367
+ "",
368
+ heading(c, "In progress", [s.in_progress_total ? String(s.in_progress_total) : ["nothing", "explanation"]]),
369
+ );
370
+ const byEpic = new Map();
371
+ for (const x of listed) {
372
+ const epic = x.epic || "";
373
+ if (!byEpic.has(epic)) byEpic.set(epic, []);
374
+ byEpic.get(epic).push(x);
375
+ }
376
+ for (const [epic, xs] of byEpic) {
377
+ if (epic) lines.push(` ${paint("emphasis", epic)}`);
378
+ // A row per story (contract 5): the dot, the day it started, its title
379
+ // and its path. On a live terminal a title wider than the row wraps at
380
+ // its spaces, under itself, and a path that does not fit moves beneath
381
+ // it, at the same column. A story with no title is its path, never
382
+ // wrapped.
383
+ lines.push(
384
+ ...rows(
385
+ c,
386
+ xs.map((x) => ({
387
+ glyph: "dot",
388
+ role: "explanation",
389
+ action: x.started_at ? startDate(x.started_at) : "",
390
+ target: x.title || x.path,
391
+ prose: Boolean(x.title),
392
+ note: x.title ? x.path : "",
393
+ })),
394
+ { indent: 4 },
395
+ ),
396
+ );
397
+ }
398
+ const more = (s.in_progress_total || 0) - listed.length;
399
+ const kanban = r.views && r.views.kanban ? r.views.kanban.path : null;
400
+ if (more > 0) lines.push(` ${paint("explanation", `+${more} more${kanban ? ` ${dash} see ${kanban}` : ""}`)}`);
215
401
  }
216
- if (r.views) lines.push(`Views: ${Object.entries(r.views).map(([k, v]) => `${k} ${!v.exists ? "missing" : v.stale === null ? "unknown" : v.stale ? "stale" : "fresh"}`).join(" · ")}`);
217
- if (r.sessions) lines.push(`Sessions active: ${r.sessions.active}`);
402
+ if (r.views) {
403
+ // A view that is stale or missing wants a reconcile: both in the attention role.
404
+ const state = (v) =>
405
+ !v.exists
406
+ ? ["missing", "attention"]
407
+ : v.stale === null
408
+ ? ["unknown", "explanation"]
409
+ : v.stale
410
+ ? ["stale", "attention"]
411
+ : ["fresh", "done"];
412
+ lines.push(
413
+ "",
414
+ counted(
415
+ "Views",
416
+ Object.values(r.views).map((v) => {
417
+ const [word, role] = state(v);
418
+ return `${v.path} ${paint(role, word)}`;
419
+ }),
420
+ ),
421
+ );
422
+ }
423
+ if (r.sessions) lines.push(heading(c, "Sessions", [`${r.sessions.active} active`]));
218
424
  return lines.join("\n") + "\n";
219
425
  }
220
426
 
@@ -229,7 +435,15 @@ export async function orientation(cfg, { budgetMs = ORIENTATION_BUDGET_MS } = {}
229
435
  const facts = await gatherVaultFacts(cfg, { budgetMs });
230
436
  const skeleton = renderVaultSkeleton(facts);
231
437
  if (facts.vaultMissing) return { facts, skeleton };
232
- const safe = { ...facts, folders: facts.folders.map((f) => ({ path: f.path, kind: f.kind, counts: f.counts, purpose: folderPurpose(f.readme, f.kind) })) };
438
+ const safe = {
439
+ ...facts,
440
+ folders: facts.folders.map((f) => ({
441
+ path: f.path,
442
+ kind: f.kind,
443
+ counts: f.counts,
444
+ purpose: folderPurpose(f.readme, f.kind),
445
+ })),
446
+ };
233
447
  return { facts: safe, skeleton };
234
448
  }
235
449
 
@@ -241,14 +455,31 @@ export async function orientation(cfg, { budgetMs = ORIENTATION_BUDGET_MS } = {}
241
455
  // (type, status) are filters and columns here, and a hit on `id:` would push
242
456
  // the prose a reader wanted past the per-file cap. Derived views are excluded
243
457
  // unless asked for: they repeat every path in the vault.
244
- export function search(cfg, query, { kinds = null, status: st = null, limit = SEARCH_DEFAULT_LIMIT, includeDerived = false, caseSensitive = false, deadlineMs = SEARCH_DEADLINE_MS } = {}) {
458
+ export function search(
459
+ cfg,
460
+ query,
461
+ {
462
+ kinds = null,
463
+ status: st = null,
464
+ limit = SEARCH_DEFAULT_LIMIT,
465
+ includeDerived = false,
466
+ caseSensitive = false,
467
+ deadlineMs = SEARCH_DEADLINE_MS,
468
+ } = {},
469
+ ) {
245
470
  if (typeof query !== "string" || !query.trim()) throw usageError("a query is required");
246
471
  const vault = vaultOf(cfg);
247
472
  const cap = intOption("limit", limit, { min: 1, max: SEARCH_HARD_CAP, fallback: SEARCH_DEFAULT_LIMIT });
248
473
  const needle = caseSensitive ? query : query.toLowerCase();
249
474
  const { layout } = layoutOf(cfg);
250
475
  let index = null;
251
- if (layout) { try { index = buildNodeIndex(cfg, layout); } catch { index = null; } }
476
+ if (layout) {
477
+ try {
478
+ index = buildNodeIndex(cfg, layout);
479
+ } catch {
480
+ index = null;
481
+ }
482
+ }
252
483
  const views = derivedViews(layout);
253
484
  const files = walkVaultFiles(vault).filter((f) => includeDerived || !views.includes(f.rel));
254
485
  files.sort((a, b) => cmp(a.rel, b.rel));
@@ -258,12 +489,19 @@ export function search(cfg, query, { kinds = null, status: st = null, limit = SE
258
489
  let timedOut = false;
259
490
  const deadline = Date.now() + deadlineMs;
260
491
  for (const f of files) {
261
- if (Date.now() > deadline) { timedOut = true; break; }
492
+ if (Date.now() > deadline) {
493
+ timedOut = true;
494
+ break;
495
+ }
262
496
  // Synchronous reads with the deadline checked between files: a vault on
263
497
  // a local disk reads in milliseconds, and a timer per file would keep the
264
498
  // process alive after the answer is written (the MCP server lives long).
265
499
  let text;
266
- try { text = readFileSync(join(vault, f.rel), "utf8"); } catch { continue; }
500
+ try {
501
+ text = readFileSync(join(vault, f.rel), "utf8");
502
+ } catch {
503
+ continue;
504
+ }
267
505
  scanned++;
268
506
  const node = index ? index.byPath.get(f.rel) : null;
269
507
  const nf = nodeFacts(node);
@@ -272,7 +510,10 @@ export function search(cfg, query, { kinds = null, status: st = null, limit = SE
272
510
  const lines = String(text).split("\n");
273
511
  // The frontmatter's line span, so its lines other than title: are skipped.
274
512
  let fmEnd = -1;
275
- if (lines[0] === "---") { const close = lines.indexOf("---", 1); fmEnd = close === -1 ? lines.length : close; }
513
+ if (lines[0] === "---") {
514
+ const close = lines.indexOf("---", 1);
515
+ fmEnd = close === -1 ? lines.length : close;
516
+ }
276
517
  const hits = [];
277
518
  for (let i = 0; i < lines.length; i++) {
278
519
  if (i <= fmEnd && !/^title:/.test(lines[i])) continue;
@@ -286,17 +527,35 @@ export function search(cfg, query, { kinds = null, status: st = null, limit = SE
286
527
  for (const h of hits.slice(0, SEARCH_PER_FILE_CAP)) all.push({ ...h, of: hits.length });
287
528
  }
288
529
  all.sort((a, b) => cmp(a.path, b.path) || a.line - b.line);
289
- return { query, matches: all.slice(0, cap), total: all.length, returned: Math.min(all.length, cap), truncated: all.length > cap, per_file_cap: SEARCH_PER_FILE_CAP, files_truncated: filesTruncated, files_scanned: scanned, status: timedOut ? "timeout" : "ok" };
530
+ return {
531
+ query,
532
+ matches: all.slice(0, cap),
533
+ total: all.length,
534
+ returned: Math.min(all.length, cap),
535
+ truncated: all.length > cap,
536
+ per_file_cap: SEARCH_PER_FILE_CAP,
537
+ files_truncated: filesTruncated,
538
+ files_scanned: scanned,
539
+ status: timedOut ? "timeout" : "ok",
540
+ };
290
541
  }
291
542
 
292
543
  export function renderSearch(r) {
293
- if (!r.matches.length) return `No matches for "${r.query}" in ${r.files_scanned} file(s)${r.status === "timeout" ? " (scan timed out — partial)" : ""}. Try a shorter or case-insensitive phrase.\n`;
544
+ if (!r.matches.length)
545
+ return `No matches for "${r.query}" in ${r.files_scanned} file(s)${r.status === "timeout" ? " (scan timed out — partial)" : ""}. Try a shorter or case-insensitive phrase.\n`;
294
546
  const groups = new Map();
295
- for (const m of r.matches) { const g = m.path.includes("/") ? m.path.split("/")[0] : "(root)"; if (!groups.has(g)) groups.set(g, []); groups.get(g).push(m); }
296
- const lines = [`${r.total} match(es) for "${r.query}"${r.truncated ? ` — showing ${r.returned}` : ""}${r.files_truncated ? ` — ${r.files_truncated} file(s) cut at ${r.per_file_cap} lines each` : ""}${r.status === "timeout" ? " (scan timed out — partial)" : ""}`];
547
+ for (const m of r.matches) {
548
+ const g = m.path.includes("/") ? m.path.split("/")[0] : "(root)";
549
+ if (!groups.has(g)) groups.set(g, []);
550
+ groups.get(g).push(m);
551
+ }
552
+ const lines = [
553
+ `${r.total} match(es) for "${r.query}"${r.truncated ? ` — showing ${r.returned}` : ""}${r.files_truncated ? ` — ${r.files_truncated} file(s) cut at ${r.per_file_cap} lines each` : ""}${r.status === "timeout" ? " (scan timed out — partial)" : ""}`,
554
+ ];
297
555
  for (const [g, ms] of [...groups.entries()].sort(([a], [b]) => cmp(a, b))) {
298
556
  lines.push("", `${g}/ (${ms.length})`);
299
- for (const m of ms) lines.push(` ${m.path}:${m.line} ${m.snippet}${m.of > r.per_file_cap ? ` [${m.of} in file]` : ""}`);
557
+ for (const m of ms)
558
+ lines.push(` ${m.path}:${m.line} ${m.snippet}${m.of > r.per_file_cap ? ` [${m.of} in file]` : ""}`);
300
559
  }
301
560
  return lines.join("\n") + "\n";
302
561
  }
@@ -311,7 +570,15 @@ export function show(cfg, path, { body = false, section = null } = {}) {
311
570
  const text = readFileSync(abs, "utf8");
312
571
  const parsed = parseFrontmatter(text);
313
572
  const fm = parsed.data;
314
- const out = { path: rel, type: fm.type ?? null, title: fm.title ?? null, status: fm.status ?? null, frontmatter: fm, lines: text.split("\n").length, bytes: Buffer.byteLength(text, "utf8") };
573
+ const out = {
574
+ path: rel,
575
+ type: fm.type ?? null,
576
+ title: fm.title ?? null,
577
+ status: fm.status ?? null,
578
+ frontmatter: fm,
579
+ lines: text.split("\n").length,
580
+ bytes: Buffer.byteLength(text, "utf8"),
581
+ };
315
582
  if (body) out.body = parsed.body;
316
583
  if (section) {
317
584
  const ids = Object.keys(loadHeadingsRegistry().headings || {}).sort();
@@ -323,7 +590,10 @@ export function show(cfg, path, { body = false, section = null } = {}) {
323
590
  }
324
591
 
325
592
  export function renderShow(r) {
326
- const lines = [`${r.path}`, `type: ${r.type ?? "—"} · title: ${r.title ?? "—"} · status: ${r.status ?? "—"} · ${r.lines} lines`];
593
+ const lines = [
594
+ `${r.path}`,
595
+ `type: ${r.type ?? "—"} · title: ${r.title ?? "—"} · status: ${r.status ?? "—"} · ${r.lines} lines`,
596
+ ];
327
597
  if (r.section) lines.push("", `## ${r.section.id}`, "", r.section.text ?? "(section not found)");
328
598
  if (r.body !== undefined) lines.push("", r.body);
329
599
  return lines.join("\n") + "\n";
@@ -347,13 +617,34 @@ export function neighbors(cfg, path, { kinds = null, direction = "both", limit =
347
617
  const self = index.get(rel);
348
618
  if (!self) throw usageError(`${rel} is not a node of the graph (not an artifact the layout knows)`);
349
619
  const want = (e) => !kinds || !kinds.length || kinds.includes(e.kind);
350
- const out = dir === "in" ? [] : g.edges.filter((e) => e.from === rel && want(e)).map((e) => ({ kind: e.kind, to: e.to, ...prefixed(nodeFacts(index.get(e.to)), "to_") }));
351
- const inn = dir === "out" ? [] : g.edges.filter((e) => e.to === rel && want(e)).map((e) => ({ kind: e.kind, from: e.from, ...prefixed(nodeFacts(index.get(e.from)), "from_") }));
620
+ const out =
621
+ dir === "in"
622
+ ? []
623
+ : g.edges
624
+ .filter((e) => e.from === rel && want(e))
625
+ .map((e) => ({ kind: e.kind, to: e.to, ...prefixed(nodeFacts(index.get(e.to)), "to_") }));
626
+ const inn =
627
+ dir === "out"
628
+ ? []
629
+ : g.edges
630
+ .filter((e) => e.to === rel && want(e))
631
+ .map((e) => ({ kind: e.kind, from: e.from, ...prefixed(nodeFacts(index.get(e.from)), "from_") }));
352
632
  out.sort((a, b) => cmp(a.kind, b.kind) || cmp(a.to, b.to));
353
633
  inn.sort((a, b) => cmp(a.kind, b.kind) || cmp(a.from, b.from));
354
634
  const byKind = {};
355
635
  for (const e of [...out, ...inn]) byKind[e.kind] = (byKind[e.kind] || 0) + 1;
356
- return { path: rel, ...nodeFacts(self), out: out.slice(0, cap), in: inn.slice(0, cap), counts: { in: inn.length, out: out.length, by_kind: Object.fromEntries(Object.entries(byKind).sort(([a], [b]) => cmp(a, b))) }, truncated: out.length > cap || inn.length > cap };
636
+ return {
637
+ path: rel,
638
+ ...nodeFacts(self),
639
+ out: out.slice(0, cap),
640
+ in: inn.slice(0, cap),
641
+ counts: {
642
+ in: inn.length,
643
+ out: out.length,
644
+ by_kind: Object.fromEntries(Object.entries(byKind).sort(([a], [b]) => cmp(a, b))),
645
+ },
646
+ truncated: out.length > cap || inn.length > cap,
647
+ };
357
648
  }
358
649
 
359
650
  function prefixed(o, p) {
@@ -361,7 +652,10 @@ function prefixed(o, p) {
361
652
  }
362
653
 
363
654
  export function renderNeighbors(r) {
364
- const lines = [`${r.path} (${r.type ?? "—"} · ${r.status ?? "—"}) ${r.title ?? ""}`.trimEnd(), `out ${r.counts.out} · in ${r.counts.in}${r.truncated ? " · truncated" : ""}`];
655
+ const lines = [
656
+ `${r.path} (${r.type ?? "—"} · ${r.status ?? "—"}) ${r.title ?? ""}`.trimEnd(),
657
+ `out ${r.counts.out} · in ${r.counts.in}${r.truncated ? " · truncated" : ""}`,
658
+ ];
365
659
  for (const e of r.out) lines.push(` → ${e.kind.padEnd(19)} ${e.to}${e.to_title ? ` (${e.to_title})` : ""}`);
366
660
  for (const e of r.in) lines.push(` ← ${e.kind.padEnd(19)} ${e.from}${e.from_title ? ` (${e.from_title})` : ""}`);
367
661
  return lines.join("\n") + "\n";
@@ -373,11 +667,19 @@ export function renderNeighbors(r) {
373
667
  // lineage — walking them returns most of the vault, so `kinds` is validated
374
668
  // against LINEAGE_KINDS — and containment is not walked from an epic down to
375
669
  // its stories unless the epic is the root.
376
- export function lineage(cfg, path, { depth = LINEAGE_DEFAULT_DEPTH, kinds = LINEAGE_KINDS, cap = LINEAGE_NODE_CAP } = {}) {
670
+ export function lineage(
671
+ cfg,
672
+ path,
673
+ { depth = LINEAGE_DEFAULT_DEPTH, kinds = LINEAGE_KINDS, cap = LINEAGE_NODE_CAP } = {},
674
+ ) {
377
675
  const vault = vaultOf(cfg);
378
676
  const rel = toVaultRel(vault, path);
379
677
  const maxDepth = intOption("depth", depth, { min: 0, max: 10, fallback: LINEAGE_DEFAULT_DEPTH });
380
- for (const k of kinds || []) if (!LINEAGE_KINDS.includes(k)) throw usageError(`--kind for lineage takes one of ${LINEAGE_KINDS.join(", ")}, not "${k}" (use graph neighbors for the other edge kinds)`);
678
+ for (const k of kinds || [])
679
+ if (!LINEAGE_KINDS.includes(k))
680
+ throw usageError(
681
+ `--kind for lineage takes one of ${LINEAGE_KINDS.join(", ")}, not "${k}" (use graph neighbors for the other edge kinds)`,
682
+ );
381
683
  const { g, index } = graphOf(cfg);
382
684
  if (!index.has(rel)) throw usageError(`${rel} is not a node of the graph`);
383
685
  const kindSet = new Set(kinds && kinds.length ? kinds : LINEAGE_KINDS);
@@ -398,26 +700,42 @@ export function lineage(cfg, path, { depth = LINEAGE_DEFAULT_DEPTH, kinds = LINE
398
700
  // siblings, and a depth of 3 would return the whole epic.
399
701
  if (e.kind === "epic-contains" && e.from === p && p !== rel) continue;
400
702
  if (!seen.has(other)) {
401
- if (seen.size >= cap) { truncated = true; continue; }
703
+ if (seen.size >= cap) {
704
+ truncated = true;
705
+ continue;
706
+ }
402
707
  seen.set(other, d);
403
708
  next.push(other);
404
709
  }
405
710
  // Recorded only once both endpoints are nodes of the result.
406
711
  const k = `${e.from} ${e.kind} ${e.to}`;
407
- if (!edgeKeys.has(k)) { edgeKeys.add(k); edges.push({ from: e.from, kind: e.kind, to: e.to }); }
712
+ if (!edgeKeys.has(k)) {
713
+ edgeKeys.add(k);
714
+ edges.push({ from: e.from, kind: e.kind, to: e.to });
715
+ }
408
716
  }
409
717
  }
410
718
  frontier = next.sort(cmp);
411
719
  }
412
- const nodes = [...seen.entries()].map(([p, distance]) => ({ path: p, ...nodeFacts(index.get(p)), distance })).sort((a, b) => a.distance - b.distance || cmp(a.path, b.path));
720
+ const nodes = [...seen.entries()]
721
+ .map(([p, distance]) => ({ path: p, ...nodeFacts(index.get(p)), distance }))
722
+ .sort((a, b) => a.distance - b.distance || cmp(a.path, b.path));
413
723
  edges.sort((a, b) => cmp(a.from, b.from) || cmp(a.kind, b.kind) || cmp(a.to, b.to));
414
724
  return { root: rel, depth: maxDepth, kinds: [...kindSet], nodes, edges, truncated };
415
725
  }
416
726
 
417
727
  export function renderLineage(r) {
418
- const lines = [`${r.root} — lineage over ${r.kinds.join(", ")} to depth ${r.depth}${r.truncated ? " (truncated)" : ""}`];
419
- for (const n of r.nodes) lines.push(` ${String(n.distance).padStart(2)} ${n.path} (${n.type ?? "—"} · ${n.status ?? "—"})${n.title ? ` ${n.title}` : ""}`);
420
- if (r.edges.length) { lines.push("", "edges:"); for (const e of r.edges) lines.push(` ${e.from} ${e.kind} ${e.to}`); }
728
+ const lines = [
729
+ `${r.root} — lineage over ${r.kinds.join(", ")} to depth ${r.depth}${r.truncated ? " (truncated)" : ""}`,
730
+ ];
731
+ for (const n of r.nodes)
732
+ lines.push(
733
+ ` ${String(n.distance).padStart(2)} ${n.path} (${n.type ?? "—"} · ${n.status ?? "—"})${n.title ? ` ${n.title}` : ""}`,
734
+ );
735
+ if (r.edges.length) {
736
+ lines.push("", "edges:");
737
+ for (const e of r.edges) lines.push(` ${e.from} ${e.kind} ${e.to}`);
738
+ }
421
739
  return lines.join("\n") + "\n";
422
740
  }
423
741
 
@@ -430,7 +748,8 @@ const normRef = (r) => String(r).replace(/\\/g, "/").replace(/^\.\//, "").replac
430
748
  // repo-relative path; the result says which reading was taken. A tie is
431
749
  // ambiguity, never a first match — as in the link resolver and doctor.
432
750
  export function codeRefs(cfg, selector, { reverse = false } = {}) {
433
- if (typeof selector !== "string" || !selector.trim()) throw usageError("a selector is required — an epic id, an artifact, or a repo path");
751
+ if (typeof selector !== "string" || !selector.trim())
752
+ throw usageError("a selector is required — an epic id, an artifact, or a repo path");
434
753
  const sel = selector.trim();
435
754
  const layout = loadLayout(cfg.layout);
436
755
  const index = buildNodeIndex(cfg, layout);
@@ -438,42 +757,92 @@ export function codeRefs(cfg, selector, { reverse = false } = {}) {
438
757
  const refsOf = (n) => {
439
758
  const refs = listOf(n.fm, "code_refs");
440
759
  if (!refs.length && typeof n.fm.code_refs === "string" && n.fm.code_refs.trim() === "") {
441
- warnings.push(`${n.path}: code_refs is written in block form, which the line-based frontmatter parser cannot read — write it as a JSON list`);
760
+ warnings.push(
761
+ `${n.path}: code_refs is written in block form, which the line-based frontmatter parser cannot read — write it as a JSON list`,
762
+ );
442
763
  }
443
764
  return refs;
444
765
  };
445
- const row = (n, matched) => ({ path: n.path, type: n.type, title: n.title, status: n.status, code_refs: refsOf(n), ...(matched !== undefined ? { matched } : {}) });
446
- const bounded = (rows) => ({ artifacts: rows.slice(0, CODE_REFS_CAP), total: rows.length, truncated: rows.length > CODE_REFS_CAP });
766
+ const row = (n, matched) => ({
767
+ path: n.path,
768
+ type: n.type,
769
+ title: n.title,
770
+ status: n.status,
771
+ code_refs: refsOf(n),
772
+ ...(matched !== undefined ? { matched } : {}),
773
+ });
774
+ const bounded = (rows) => ({
775
+ artifacts: rows.slice(0, CODE_REFS_CAP),
776
+ total: rows.length,
777
+ truncated: rows.length > CODE_REFS_CAP,
778
+ });
447
779
  const epicFolder = folderByKind(layout, "epic");
448
780
  if (!reverse) {
449
781
  // An epic by its id or its folder name — never by the stem "epic", which every epic.md shares.
450
- const epics = index.nodes.filter((n) => n.type === "epic" && (n.identity === sel || (epicFolder && n.path === `${epicFolder.path}/${sel}/epic.md`)));
451
- if (epics.length > 1) throw usageError(`"${sel}" names ${epics.length} epics: ${epics.map((n) => n.path).join(", ")}`);
782
+ const epics = index.nodes.filter(
783
+ (n) =>
784
+ n.type === "epic" && (n.identity === sel || (epicFolder && n.path === `${epicFolder.path}/${sel}/epic.md`)),
785
+ );
786
+ if (epics.length > 1)
787
+ throw usageError(`"${sel}" names ${epics.length} epics: ${epics.map((n) => n.path).join(", ")}`);
452
788
  if (epics.length === 1) {
453
789
  const epicDir = epics[0].path.replace(/\/epic\.md$/, "");
454
- const stories = index.nodes.filter((n) => n.type === "story" && n.path.startsWith(epicDir + "/")).sort((a, b) => cmp(a.path, b.path));
455
- return { selector: sel, resolved_as: "epic", ...bounded([row(epics[0]), ...stories.map((n) => row(n))]), warnings };
790
+ const stories = index.nodes
791
+ .filter((n) => n.type === "story" && n.path.startsWith(epicDir + "/"))
792
+ .sort((a, b) => cmp(a.path, b.path));
793
+ return {
794
+ selector: sel,
795
+ resolved_as: "epic",
796
+ ...bounded([row(epics[0]), ...stories.map((n) => row(n))]),
797
+ warnings,
798
+ };
456
799
  }
457
800
  const byPath = index.byPath.get(sel);
458
- const candidates = byPath ? [byPath] : [...new Set([...(index.byIdentity.get(sel.toLowerCase()) || []), ...(index.byStem.get(sel.toLowerCase()) || [])])];
459
- if (candidates.length > 1) throw usageError(`"${sel}" is ambiguous — ${candidates.map((n) => n.path).sort(cmp).join(", ")}; pass the vault path`);
460
- if (candidates.length === 1) return { selector: sel, resolved_as: "artifact", ...bounded([row(candidates[0])]), warnings };
801
+ const candidates = byPath
802
+ ? [byPath]
803
+ : [
804
+ ...new Set([
805
+ ...(index.byIdentity.get(sel.toLowerCase()) || []),
806
+ ...(index.byStem.get(sel.toLowerCase()) || []),
807
+ ]),
808
+ ];
809
+ if (candidates.length > 1)
810
+ throw usageError(
811
+ `"${sel}" is ambiguous — ${candidates
812
+ .map((n) => n.path)
813
+ .sort(cmp)
814
+ .join(", ")}; pass the vault path`,
815
+ );
816
+ if (candidates.length === 1)
817
+ return { selector: sel, resolved_as: "artifact", ...bounded([row(candidates[0])]), warnings };
461
818
  }
462
819
  const target = normRef(sel);
463
- const covering = index.nodes.map((n) => {
464
- // A ref covers the selector when they are equal, or one is a directory
465
- // prefix of the other ("scripts/" covers "scripts/cli.mjs"; asking about
466
- // "scripts/" lists whoever names a file under it). Reported as written.
467
- const matched = refsOf(n).filter((ref) => { const r = normRef(ref); return r === target || target.startsWith(r + "/") || r.startsWith(target + "/"); });
468
- return matched.length ? row(n, matched) : null;
469
- }).filter(Boolean).sort((a, b) => cmp(a.path, b.path));
820
+ const covering = index.nodes
821
+ .map((n) => {
822
+ // A ref covers the selector when they are equal, or one is a directory
823
+ // prefix of the other ("scripts/" covers "scripts/cli.mjs"; asking about
824
+ // "scripts/" lists whoever names a file under it). Reported as written.
825
+ const matched = refsOf(n).filter((ref) => {
826
+ const r = normRef(ref);
827
+ return r === target || target.startsWith(r + "/") || r.startsWith(target + "/");
828
+ });
829
+ return matched.length ? row(n, matched) : null;
830
+ })
831
+ .filter(Boolean)
832
+ .sort((a, b) => cmp(a.path, b.path));
470
833
  return { selector: sel, resolved_as: "path", ...bounded(covering), warnings };
471
834
  }
472
835
 
473
836
  export function renderCodeRefs(r) {
474
- const lines = [`${r.selector} (read as ${r.resolved_as})${r.truncated ? ` — ${r.artifacts.length} of ${r.total}` : ""}`];
837
+ const lines = [
838
+ `${r.selector} (read as ${r.resolved_as})${r.truncated ? ` — ${r.artifacts.length} of ${r.total}` : ""}`,
839
+ ];
475
840
  if (!r.artifacts.length) lines.push(" nothing maps here");
476
- for (const a of r.artifacts) lines.push(` ${a.path} (${a.type ?? "—"} · ${a.status ?? "—"})`, ` ${a.code_refs.length ? a.code_refs.join(", ") : "—"}${a.matched ? ` ← ${a.matched.join(", ")}` : ""}`);
841
+ for (const a of r.artifacts)
842
+ lines.push(
843
+ ` ${a.path} (${a.type ?? "—"} · ${a.status ?? "—"})`,
844
+ ` ${a.code_refs.length ? a.code_refs.join(", ") : "—"}${a.matched ? ` ← ${a.matched.join(", ")}` : ""}`,
845
+ );
477
846
  for (const w of r.warnings) lines.push(` ⚠ ${w}`);
478
847
  return lines.join("\n") + "\n";
479
848
  }