projectstore-claude 0.29.2 → 0.29.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/projectstore-claude.mjs +1 -1
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
- package/node_modules/projectstore/README.md +44 -7
- package/node_modules/projectstore/commands/bar.md +30 -0
- package/node_modules/projectstore/commands/bind.md +16 -20
- package/node_modules/projectstore/commands/doctor.md +12 -7
- package/node_modules/projectstore/commands/scaffold.md +24 -16
- package/node_modules/projectstore/commands/status.md +1 -1
- package/node_modules/projectstore/commands/statusline.md +2 -2
- package/node_modules/projectstore/commands/story.md +15 -2
- package/node_modules/projectstore/docs/alongside.md +167 -0
- package/node_modules/projectstore/docs/extending.md +17 -3
- package/node_modules/projectstore/docs/getting-started.md +18 -9
- package/node_modules/projectstore/docs/harnesses.md +1 -1
- package/node_modules/projectstore/docs/how-it-works.md +12 -3
- package/node_modules/projectstore/harnesses/claude-code.json +23 -8
- package/node_modules/projectstore/harnesses/codex.json +16 -6
- package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
- package/node_modules/projectstore/hooks/band.tsx +622 -0
- package/node_modules/projectstore/hooks/hooks.json +1 -0
- package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
- package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
- package/node_modules/projectstore/hooks/session-start.mjs +56 -20
- package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
- package/node_modules/projectstore/package.json +12 -2
- package/node_modules/projectstore/scaffold/headings.json +8 -0
- package/node_modules/projectstore/scripts/band-shared.mjs +446 -0
- package/node_modules/projectstore/scripts/band.mjs +207 -0
- package/node_modules/projectstore/scripts/binding.mjs +733 -62
- package/node_modules/projectstore/scripts/build-adapters.mjs +98 -35
- package/node_modules/projectstore/scripts/cli.mjs +1463 -267
- package/node_modules/projectstore/scripts/codemap.mjs +22 -7
- package/node_modules/projectstore/scripts/convergence.mjs +638 -0
- package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
- package/node_modules/projectstore/scripts/doctor-report.mjs +253 -0
- package/node_modules/projectstore/scripts/doctor.mjs +1384 -420
- package/node_modules/projectstore/scripts/draft.mjs +5 -2
- package/node_modules/projectstore/scripts/fetch-shell.mjs +369 -0
- package/node_modules/projectstore/scripts/graph.mjs +19 -13
- package/node_modules/projectstore/scripts/harness.mjs +283 -37
- package/node_modules/projectstore/scripts/install-harness.mjs +4701 -575
- package/node_modules/projectstore/scripts/kanban.mjs +6 -21
- package/node_modules/projectstore/scripts/lib.mjs +933 -199
- package/node_modules/projectstore/scripts/mcp.mjs +386 -105
- package/node_modules/projectstore/scripts/portable-registration.mjs +276 -56
- package/node_modules/projectstore/scripts/provenance.mjs +26 -10
- package/node_modules/projectstore/scripts/query.mjs +441 -72
- package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
- package/node_modules/projectstore/scripts/run-host.mjs +139 -0
- package/node_modules/projectstore/scripts/scaffold.mjs +244 -0
- package/node_modules/projectstore/scripts/setup.mjs +987 -0
- package/node_modules/projectstore/scripts/stages.mjs +84 -0
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
- package/node_modules/projectstore/scripts/statusline.mjs +55 -14
- package/node_modules/projectstore/scripts/story-section.mjs +39 -17
- package/node_modules/projectstore/scripts/surfaces.mjs +485 -99
- package/node_modules/projectstore/scripts/term.mjs +1027 -52
- package/node_modules/projectstore/scripts/tokens.mjs +106 -28
- package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
- package/node_modules/projectstore/scripts/version-guard.mjs +79 -27
- package/node_modules/projectstore/scripts/worktree.mjs +16 -5
- package/node_modules/projectstore/templates/de/strings.json +63 -1
- package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/en/strings.json +63 -1
- package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/es/strings.json +63 -1
- package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/fr/strings.json +63 -1
- package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/ru/strings.json +63 -1
- package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/zh/strings.json +63 -1
- package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
- package/package.json +2 -2
|
@@ -54,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)
|
|
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 {
|
|
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 {
|
|
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 = {
|
|
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
|
|
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 {
|
|
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(
|
|
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",
|
|
178
|
-
|
|
179
|
-
|
|
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 {
|
|
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 [
|
|
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) {
|
|
198
|
-
|
|
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 = {
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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)
|
|
217
|
-
|
|
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 = {
|
|
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(
|
|
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) {
|
|
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) {
|
|
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 {
|
|
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] === "---") {
|
|
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 {
|
|
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)
|
|
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) {
|
|
296
|
-
|
|
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)
|
|
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 = {
|
|
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 = [
|
|
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 =
|
|
351
|
-
|
|
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 {
|
|
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 = [
|
|
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(
|
|
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 || [])
|
|
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) {
|
|
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)) {
|
|
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()]
|
|
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 = [
|
|
419
|
-
|
|
420
|
-
|
|
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())
|
|
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(
|
|
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) => ({
|
|
446
|
-
|
|
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(
|
|
451
|
-
|
|
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
|
|
455
|
-
|
|
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
|
|
459
|
-
|
|
460
|
-
|
|
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
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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 = [
|
|
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)
|
|
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
|
}
|