projectstore-codex 0.0.1 → 0.28.2
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/.codex-plugin/plugin.json +48 -0
- package/README.md +15 -7
- package/bin/projectstore-codex.mjs +88 -0
- package/hooks/hooks.json +59 -0
- package/node_modules/projectstore/.claude-plugin/marketplace.json +40 -0
- package/node_modules/projectstore/.claude-plugin/plugin.json +23 -0
- package/node_modules/projectstore/.mcp.json +14 -0
- package/node_modules/projectstore/AGENTS.md +26 -0
- package/node_modules/projectstore/LICENSE +21 -0
- package/node_modules/projectstore/README.md +284 -0
- package/node_modules/projectstore/agents/archaeologist.md +76 -0
- package/node_modules/projectstore/agents/clerk.md +93 -0
- package/node_modules/projectstore/agents/critic.md +94 -0
- package/node_modules/projectstore/agents/librarian.md +81 -0
- package/node_modules/projectstore/agents/planner.md +80 -0
- package/node_modules/projectstore/agents/reviewer.md +98 -0
- package/node_modules/projectstore/bin/projectstore.mjs +7 -0
- package/node_modules/projectstore/commands/adr.md +57 -0
- package/node_modules/projectstore/commands/agents.md +180 -0
- package/node_modules/projectstore/commands/bind.md +128 -0
- package/node_modules/projectstore/commands/codemap.md +50 -0
- package/node_modules/projectstore/commands/concept.md +17 -0
- package/node_modules/projectstore/commands/doctor.md +166 -0
- package/node_modules/projectstore/commands/epic.md +40 -0
- package/node_modules/projectstore/commands/graph.md +56 -0
- package/node_modules/projectstore/commands/kanban.md +40 -0
- package/node_modules/projectstore/commands/meeting.md +17 -0
- package/node_modules/projectstore/commands/reconcile.md +73 -0
- package/node_modules/projectstore/commands/research.md +17 -0
- package/node_modules/projectstore/commands/review.md +89 -0
- package/node_modules/projectstore/commands/runbook.md +17 -0
- package/node_modules/projectstore/commands/scaffold.md +23 -0
- package/node_modules/projectstore/commands/search.md +22 -0
- package/node_modules/projectstore/commands/spec.md +91 -0
- package/node_modules/projectstore/commands/status.md +27 -0
- package/node_modules/projectstore/commands/statusline.md +46 -0
- package/node_modules/projectstore/commands/story.md +113 -0
- package/node_modules/projectstore/docs/extending.md +172 -0
- package/node_modules/projectstore/docs/getting-started.md +133 -0
- package/node_modules/projectstore/docs/harnesses.md +176 -0
- package/node_modules/projectstore/docs/how-it-works.md +263 -0
- package/node_modules/projectstore/docs/images/loop-light.svg +94 -0
- package/node_modules/projectstore/docs/images/loop.svg +93 -0
- package/node_modules/projectstore/docs/images/statusline-hud.png +0 -0
- package/node_modules/projectstore/docs/images/team-light.svg +79 -0
- package/node_modules/projectstore/docs/images/team.svg +79 -0
- package/node_modules/projectstore/harnesses/claude-code.json +483 -0
- package/node_modules/projectstore/harnesses/codex.json +332 -0
- package/node_modules/projectstore/hooks/hooks.json +59 -0
- package/node_modules/projectstore/hooks/pre-compact.mjs +121 -0
- package/node_modules/projectstore/hooks/session-rules.mjs +63 -0
- package/node_modules/projectstore/hooks/session-start.mjs +301 -0
- package/node_modules/projectstore/hooks/session-stop.mjs +84 -0
- package/node_modules/projectstore/package.json +70 -0
- package/node_modules/projectstore/scaffold/checklists.json +88 -0
- package/node_modules/projectstore/scaffold/headings.json +171 -0
- package/node_modules/projectstore/scaffold/layouts/engineering.json +85 -0
- package/node_modules/projectstore/scripts/binding.mjs +165 -0
- package/node_modules/projectstore/scripts/build-adapters.mjs +264 -0
- package/node_modules/projectstore/scripts/cli.mjs +595 -0
- package/node_modules/projectstore/scripts/codemap.mjs +99 -0
- package/node_modules/projectstore/scripts/diff-refs.mjs +127 -0
- package/node_modules/projectstore/scripts/doctor.mjs +2127 -0
- package/node_modules/projectstore/scripts/draft.mjs +261 -0
- package/node_modules/projectstore/scripts/graph.mjs +219 -0
- package/node_modules/projectstore/scripts/harness.mjs +608 -0
- package/node_modules/projectstore/scripts/install-harness.mjs +1387 -0
- package/node_modules/projectstore/scripts/kanban.mjs +174 -0
- package/node_modules/projectstore/scripts/lib.mjs +3085 -0
- package/node_modules/projectstore/scripts/mcp.mjs +391 -0
- package/node_modules/projectstore/scripts/portable-registration.mjs +198 -0
- package/node_modules/projectstore/scripts/provenance.mjs +375 -0
- package/node_modules/projectstore/scripts/query.mjs +490 -0
- package/node_modules/projectstore/scripts/reconcile.mjs +422 -0
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +141 -0
- package/node_modules/projectstore/scripts/statusline.mjs +253 -0
- package/node_modules/projectstore/scripts/story-section.mjs +209 -0
- package/node_modules/projectstore/scripts/surfaces.mjs +421 -0
- package/node_modules/projectstore/scripts/tokens.mjs +449 -0
- package/node_modules/projectstore/scripts/touch-session.mjs +336 -0
- package/node_modules/projectstore/scripts/version-guard.mjs +261 -0
- package/node_modules/projectstore/scripts/worktree.mjs +109 -0
- package/node_modules/projectstore/skills/projectstore-decision-detector/SKILL.md +40 -0
- package/node_modules/projectstore/skills/projectstore-peer-reviewer/SKILL.md +38 -0
- package/node_modules/projectstore/skills/projectstore-story-completion/SKILL.md +50 -0
- package/node_modules/projectstore/skills/projectstore-vault-communication/SKILL.md +96 -0
- package/node_modules/projectstore/templates/claude-md-block.md.tmpl +26 -0
- package/node_modules/projectstore/templates/de/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/de/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/de/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/de/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/de/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/de/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/de/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/de/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/de/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/de/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/de/strings.json +6 -0
- package/node_modules/projectstore/templates/en/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/en/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/en/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/en/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/en/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/en/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/en/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/en/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/en/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/en/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/en/strings.json +6 -0
- package/node_modules/projectstore/templates/es/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/es/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/es/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/es/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/es/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/es/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/es/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/es/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/es/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/es/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/es/strings.json +6 -0
- package/node_modules/projectstore/templates/fr/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/fr/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/fr/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/fr/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/fr/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/fr/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/fr/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/fr/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/fr/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/fr/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/fr/strings.json +6 -0
- package/node_modules/projectstore/templates/ru/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/ru/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/ru/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/ru/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/ru/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/ru/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/ru/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/ru/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/ru/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/ru/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/ru/strings.json +6 -0
- package/node_modules/projectstore/templates/zh/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/zh/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/zh/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/zh/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/zh/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/zh/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/zh/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/zh/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/zh/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/zh/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/zh/strings.json +6 -0
- package/package.json +35 -14
- package/skills/projectstore-adr/SKILL.md +76 -0
- package/skills/projectstore-agents/SKILL.md +50 -0
- package/skills/projectstore-archaeologist/SKILL.md +109 -0
- package/skills/projectstore-bind/SKILL.md +44 -0
- package/skills/projectstore-clerk/SKILL.md +126 -0
- package/skills/projectstore-codemap/SKILL.md +69 -0
- package/skills/projectstore-concept/SKILL.md +36 -0
- package/skills/projectstore-critic/SKILL.md +127 -0
- package/skills/projectstore-decision-detector/SKILL.md +59 -0
- package/skills/projectstore-doctor/SKILL.md +33 -0
- package/skills/projectstore-epic/SKILL.md +59 -0
- package/skills/projectstore-graph/SKILL.md +75 -0
- package/skills/projectstore-kanban/SKILL.md +60 -0
- package/skills/projectstore-librarian/SKILL.md +114 -0
- package/skills/projectstore-meeting/SKILL.md +36 -0
- package/skills/projectstore-peer-reviewer/SKILL.md +57 -0
- package/skills/projectstore-planner/SKILL.md +113 -0
- package/skills/projectstore-reconcile/SKILL.md +92 -0
- package/skills/projectstore-research/SKILL.md +36 -0
- package/skills/projectstore-review/SKILL.md +108 -0
- package/skills/projectstore-reviewer/SKILL.md +131 -0
- package/skills/projectstore-runbook/SKILL.md +36 -0
- package/skills/projectstore-scaffold/SKILL.md +42 -0
- package/skills/projectstore-search/SKILL.md +41 -0
- package/skills/projectstore-spec/SKILL.md +110 -0
- package/skills/projectstore-status/SKILL.md +47 -0
- package/skills/projectstore-statusline/SKILL.md +29 -0
- package/skills/projectstore-story/SKILL.md +132 -0
- package/skills/projectstore-story-completion/SKILL.md +69 -0
- package/skills/projectstore-vault-communication/SKILL.md +115 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
// projectstore — query.mjs
|
|
2
|
+
//
|
|
3
|
+
// The read operations behind the CLI's read verbs and the MCP surface's
|
|
4
|
+
// tools — one exported function per MCP tool (MCP ADR decision 2): status,
|
|
5
|
+
// orientation, search, show (get_artifact), neighbors, lineage, codeRefs.
|
|
6
|
+
// Read-only, no stdout side effects, no writes: imported by scripts/cli.mjs;
|
|
7
|
+
// the MCP server reaches these only through cli.run(), so the envelope is
|
|
8
|
+
// built in one place — but it shares the module graph, where stdout is the
|
|
9
|
+
// protocol channel.
|
|
10
|
+
//
|
|
11
|
+
// Every result is small on purpose — a model reads it. Artifact paths in
|
|
12
|
+
// results are vault-relative and /-joined (the vault's own path, a project
|
|
13
|
+
// root and a session's project root are absolute by nature and named as
|
|
14
|
+
// such); every cap is reported (`truncated`, `returned`, `total`, a match's
|
|
15
|
+
// `of`), never silent; a scan that outruns its deadline says `status:
|
|
16
|
+
// "timeout"` rather than returning a short list that claims completeness
|
|
17
|
+
// (the skeleton spec's contract 13, applied to a model consumer). Sorting is
|
|
18
|
+
// by plain string comparison, so two runs agree byte for byte. The one
|
|
19
|
+
// deliberately unbounded result is `show --body`: the CLI's caller asked for
|
|
20
|
+
// the file; the MCP surface exposes it as a resource, not a tool result.
|
|
21
|
+
//
|
|
22
|
+
// The operations reuse the core, never re-walk it: the graph's edges come
|
|
23
|
+
// from buildGraph (the one resolver, so `neighbors` and `grep graph.md`
|
|
24
|
+
// return the same facts), stories from the kanban generator's finder, the
|
|
25
|
+
// vault walk from doctor's (a static import is safe: doctor's main() is
|
|
26
|
+
// guarded, and this module takes one pure function from it — the MCP ADR's
|
|
27
|
+
// "spawn, never import" is about running doctor, which stays cli.mjs's
|
|
28
|
+
// spawn), the orientation from the SessionStart gather. Bad input is a
|
|
29
|
+
// usage error (`code: "USAGE"`), which the front-ends turn into exit 2.
|
|
30
|
+
//
|
|
31
|
+
// Normative: MCP ADR decisions 2 and 4; the link-graph ADR's decision 4
|
|
32
|
+
// (edge kinds) and decision 7 (agents with MCP call the live index); the
|
|
33
|
+
// distribution ADR decisions 3 and 4. Pure node, no external deps.
|
|
34
|
+
|
|
35
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
36
|
+
import { join, resolve, isAbsolute, relative } from "node:path";
|
|
37
|
+
import {
|
|
38
|
+
loadLayout,
|
|
39
|
+
loadHeadingsRegistry,
|
|
40
|
+
folderByKind,
|
|
41
|
+
parseFrontmatter,
|
|
42
|
+
buildNodeIndex,
|
|
43
|
+
gatherVaultFacts,
|
|
44
|
+
renderVaultSkeleton,
|
|
45
|
+
folderPurpose,
|
|
46
|
+
truncEnd,
|
|
47
|
+
readVaultConfig,
|
|
48
|
+
readActiveSessions,
|
|
49
|
+
listOf,
|
|
50
|
+
sectionOf,
|
|
51
|
+
isInsideVault,
|
|
52
|
+
} from "./lib.mjs";
|
|
53
|
+
import { walkVaultFiles } from "./doctor.mjs";
|
|
54
|
+
import { buildGraph } from "./graph.mjs";
|
|
55
|
+
import { findStories, statusToColumn } from "./kanban.mjs";
|
|
56
|
+
|
|
57
|
+
export const SEARCH_DEFAULT_LIMIT = 20;
|
|
58
|
+
export const SEARCH_HARD_CAP = 100;
|
|
59
|
+
export const SEARCH_PER_FILE_CAP = 3;
|
|
60
|
+
export const SEARCH_DEADLINE_MS = 5000;
|
|
61
|
+
export const SNIPPET_CELL = 160;
|
|
62
|
+
export const GRAPH_EDGE_CAP = 100;
|
|
63
|
+
export const LINEAGE_KINDS = Object.freeze(["supersedes", "spec-covers", "spec-implements-adr", "epic-contains"]);
|
|
64
|
+
export const LINEAGE_DEFAULT_DEPTH = 3;
|
|
65
|
+
export const LINEAGE_NODE_CAP = 50;
|
|
66
|
+
export const CODE_REFS_CAP = 100;
|
|
67
|
+
export const IN_FLIGHT_CAP = 5;
|
|
68
|
+
export const ORIENTATION_BUDGET_MS = 5000;
|
|
69
|
+
export const DIRECTIONS = Object.freeze(["in", "out", "both"]);
|
|
70
|
+
|
|
71
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
72
|
+
|
|
73
|
+
// ─── Paths and arguments ───────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export function vaultOf(cfg) {
|
|
76
|
+
return String(cfg?.vault_path || "").replace(/\/+$/, "");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The derived views by name: the board's file is a layout value, the other
|
|
80
|
+
// two are fixed by their generators.
|
|
81
|
+
export function derivedViews(layout) {
|
|
82
|
+
return [(layout && layout.kanban && layout.kanban.file) || "kanban.md", "code-map.md", "graph.md"];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A path argument as the vault knows it: absolute paths are normalised first
|
|
86
|
+
// and must lie inside the vault (isInsideVault is a prefix test — resolve()
|
|
87
|
+
// collapses a `..` before it is asked); relative ones are vault-relative and
|
|
88
|
+
// `..` never resolves.
|
|
89
|
+
export function toVaultRel(vault, p) {
|
|
90
|
+
if (typeof p !== "string" || !p.trim()) throw usageError("a vault-relative path is required");
|
|
91
|
+
const s = p.trim().replace(/\\/g, "/");
|
|
92
|
+
if (isAbsolute(s)) {
|
|
93
|
+
const abs = resolve(s);
|
|
94
|
+
if (!isInsideVault(abs, vault)) throw usageError(`${s} is outside the vault ${vault}`);
|
|
95
|
+
return relative(vault, abs).replace(/\\/g, "/");
|
|
96
|
+
}
|
|
97
|
+
const rel = s.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
98
|
+
if (rel.split("/").some((seg) => seg === "..")) throw usageError(`${p} escapes the vault`);
|
|
99
|
+
return rel;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function usageError(msg) {
|
|
103
|
+
const e = new Error(msg);
|
|
104
|
+
e.code = "USAGE";
|
|
105
|
+
return e;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Option values are validated, not coerced: a `--depth abc` that silently
|
|
109
|
+
// became depth 0 would present the root alone as a complete answer.
|
|
110
|
+
export function intOption(name, value, { min = 0, max = Infinity, fallback }) {
|
|
111
|
+
if (value === undefined || value === null) return fallback;
|
|
112
|
+
const n = Number(value);
|
|
113
|
+
if (!Number.isInteger(n) || n < min || n > max) throw usageError(`--${name} takes an integer${max < Infinity ? ` between ${min} and ${max}` : ` ≥ ${min}`}, not "${value}"`);
|
|
114
|
+
return n;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function oneOf(name, value, allowed, fallback) {
|
|
118
|
+
if (value === undefined || value === null) return fallback;
|
|
119
|
+
if (!allowed.includes(value)) throw usageError(`--${name} takes one of ${allowed.join(", ")}, not "${value}"`);
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readGeneratedAt(p) {
|
|
124
|
+
try {
|
|
125
|
+
const head = readFileSync(p, "utf8").slice(0, 2000);
|
|
126
|
+
const m = head.match(/^generated_at:\s*(\S+)/m);
|
|
127
|
+
return m ? m[1] : null;
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function nodeFacts(n) {
|
|
134
|
+
return n ? { type: n.type, title: n.title, status: n.status } : { type: null, title: null, status: null };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function layoutOf(cfg) {
|
|
138
|
+
try { return { layout: loadLayout(cfg.layout), error: null }; } catch (e) { return { layout: null, error: e.message }; }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── status ────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
// The binding probe and the board, as facts: what is bound, what is in
|
|
144
|
+
// progress, whether the derived views are fresh. Counts come from the
|
|
145
|
+
// stories' frontmatter through the kanban generator's own finder — the view
|
|
146
|
+
// file is reported as freshness metadata only, so the two cannot disagree —
|
|
147
|
+
// and the stories the finder leaves off the board are counted too, so a
|
|
148
|
+
// total is explainable without reading the generator.
|
|
149
|
+
export function status(cfg, { project = null } = {}) {
|
|
150
|
+
if (!cfg || !cfg.vault_path) {
|
|
151
|
+
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 };
|
|
152
|
+
}
|
|
153
|
+
const vault = vaultOf(cfg);
|
|
154
|
+
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 };
|
|
155
|
+
if (!out.vault_exists) return out;
|
|
156
|
+
const vcfg = readVaultConfig(vault);
|
|
157
|
+
out.spec_policy = vcfg.spec_policy || "optional";
|
|
158
|
+
out.lifecycle_gates = vcfg.lifecycle_gates || "on";
|
|
159
|
+
const { layout, error } = layoutOf(cfg);
|
|
160
|
+
const epicFolder = layout ? folderByKind(layout, "epic") : null;
|
|
161
|
+
if (!layout) out.stories = { status: "error", error };
|
|
162
|
+
else if (!epicFolder) out.stories = { status: "error", error: `layout ${cfg.layout} has no epic folder` };
|
|
163
|
+
else {
|
|
164
|
+
const { stories, skipped } = findStories(vault, epicFolder.path);
|
|
165
|
+
const byStatus = {};
|
|
166
|
+
for (const s of stories) byStatus[s.status] = (byStatus[s.status] || 0) + 1;
|
|
167
|
+
const inProgress = stories.filter((s) => statusToColumn(s.status) === "In Progress")
|
|
168
|
+
.map((s) => ({ path: s.relPath, epic: s.epicId, title: s.title, started_at: null }));
|
|
169
|
+
// started_at is not part of the finder's row; read it for the few in flight.
|
|
170
|
+
for (const s of inProgress) {
|
|
171
|
+
try { s.started_at = parseFrontmatter(readFileSync(join(vault, s.path), "utf8")).data.started_at ?? null; } catch {}
|
|
172
|
+
}
|
|
173
|
+
inProgress.sort((a, b) => cmp(String(b.started_at || ""), String(a.started_at || "")) || cmp(a.path, b.path));
|
|
174
|
+
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)));
|
|
175
|
+
out.stories = {
|
|
176
|
+
status: "ok", total: stories.length, by_status: Object.fromEntries(Object.entries(byStatus).sort(([a], [b]) => cmp(a, b))),
|
|
177
|
+
in_progress: inProgress.slice(0, IN_FLIGHT_CAP), in_progress_total: inProgress.length,
|
|
178
|
+
off_board: skippedCounts, off_board_total: Object.values(skippedCounts).reduce((a, b) => a + b, 0),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
// Freshness: a view is stale when any artifact is newer than it. The derived
|
|
182
|
+
// views themselves are excluded from "newest" — a view is always newer than
|
|
183
|
+
// the stamp inside it, and one view must not make another look stale.
|
|
184
|
+
const views = derivedViews(layout);
|
|
185
|
+
let newest = 0;
|
|
186
|
+
for (const f of walkVaultFiles(vault)) {
|
|
187
|
+
if (views.includes(f.rel)) continue;
|
|
188
|
+
try { newest = Math.max(newest, statSync(join(vault, f.rel)).mtimeMs); } catch {}
|
|
189
|
+
}
|
|
190
|
+
out.views = {};
|
|
191
|
+
for (const [key, file] of [["kanban", views[0]], ["code_map", views[1]], ["graph", views[2]]]) {
|
|
192
|
+
const p = join(vault, file);
|
|
193
|
+
const exists = existsSync(p);
|
|
194
|
+
const generatedAt = exists ? readGeneratedAt(p) : null;
|
|
195
|
+
let viewMs = NaN;
|
|
196
|
+
if (exists) { try { viewMs = statSync(p).mtimeMs; } catch {} }
|
|
197
|
+
out.views[key] = { path: file, exists, generated_at: generatedAt, stale: exists ? (Number.isNaN(viewMs) ? null : viewMs < newest) : null };
|
|
198
|
+
}
|
|
199
|
+
const sessions = readActiveSessions(vault, null);
|
|
200
|
+
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 })) };
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function renderStatus(r) {
|
|
205
|
+
if (!r.bound) return `Not bound${r.project ? ` — ${r.project}` : ""}. Run /projectstore:bind <vault> in a session.\n`;
|
|
206
|
+
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}`];
|
|
207
|
+
if (r.stories && r.stories.status !== "ok") lines.push(`Stories: not counted — ${r.stories.error}`);
|
|
208
|
+
else if (r.stories) {
|
|
209
|
+
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(", ")})` : ""}`);
|
|
210
|
+
if (r.stories.in_progress_total) {
|
|
211
|
+
lines.push(`In progress (${r.stories.in_progress_total}):`);
|
|
212
|
+
for (const s of r.stories.in_progress) lines.push(` ${s.epic}: ${s.title} — ${s.path}${s.started_at ? ` (since ${s.started_at})` : ""}`);
|
|
213
|
+
} else lines.push("In progress: nothing");
|
|
214
|
+
}
|
|
215
|
+
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(" · ")}`);
|
|
216
|
+
if (r.sessions) lines.push(`Sessions active: ${r.sessions.active}`);
|
|
217
|
+
return lines.join("\n") + "\n";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─── orientation ───────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
// The SessionStart skeleton and the facts behind it, through the same two
|
|
223
|
+
// functions the hook calls. The budget is the caller's: the hook's 200 ms is
|
|
224
|
+
// the user's startup latency, an operator at a terminal has no such
|
|
225
|
+
// constraint. Folder READMEs are reduced to their purpose line before they
|
|
226
|
+
// enter an envelope; the whole README never leaves.
|
|
227
|
+
export async function orientation(cfg, { budgetMs = ORIENTATION_BUDGET_MS } = {}) {
|
|
228
|
+
const facts = await gatherVaultFacts(cfg, { budgetMs });
|
|
229
|
+
const skeleton = renderVaultSkeleton(facts);
|
|
230
|
+
if (facts.vaultMissing) return { facts, skeleton };
|
|
231
|
+
const safe = { ...facts, folders: facts.folders.map((f) => ({ path: f.path, kind: f.kind, counts: f.counts, purpose: folderPurpose(f.readme, f.kind) })) };
|
|
232
|
+
return { facts: safe, skeleton };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ─── search ────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
// A deterministic, bounded substring search over the vault's markdown —
|
|
238
|
+
// no shell, no regex (determinism, no ReDoS, small schema). The body and the
|
|
239
|
+
// title line are searched; the rest of the frontmatter is not — its facts
|
|
240
|
+
// (type, status) are filters and columns here, and a hit on `id:` would push
|
|
241
|
+
// the prose a reader wanted past the per-file cap. Derived views are excluded
|
|
242
|
+
// unless asked for: they repeat every path in the vault.
|
|
243
|
+
export function search(cfg, query, { kinds = null, status: st = null, limit = SEARCH_DEFAULT_LIMIT, includeDerived = false, caseSensitive = false, deadlineMs = SEARCH_DEADLINE_MS } = {}) {
|
|
244
|
+
if (typeof query !== "string" || !query.trim()) throw usageError("a query is required");
|
|
245
|
+
const vault = vaultOf(cfg);
|
|
246
|
+
const cap = intOption("limit", limit, { min: 1, max: SEARCH_HARD_CAP, fallback: SEARCH_DEFAULT_LIMIT });
|
|
247
|
+
const needle = caseSensitive ? query : query.toLowerCase();
|
|
248
|
+
const { layout } = layoutOf(cfg);
|
|
249
|
+
let index = null;
|
|
250
|
+
if (layout) { try { index = buildNodeIndex(cfg, layout); } catch { index = null; } }
|
|
251
|
+
const views = derivedViews(layout);
|
|
252
|
+
const files = walkVaultFiles(vault).filter((f) => includeDerived || !views.includes(f.rel));
|
|
253
|
+
files.sort((a, b) => cmp(a.rel, b.rel));
|
|
254
|
+
const all = [];
|
|
255
|
+
let scanned = 0;
|
|
256
|
+
let filesTruncated = 0;
|
|
257
|
+
let timedOut = false;
|
|
258
|
+
const deadline = Date.now() + deadlineMs;
|
|
259
|
+
for (const f of files) {
|
|
260
|
+
if (Date.now() > deadline) { timedOut = true; break; }
|
|
261
|
+
// Synchronous reads with the deadline checked between files: a vault on
|
|
262
|
+
// a local disk reads in milliseconds, and a timer per file would keep the
|
|
263
|
+
// process alive after the answer is written (the MCP server lives long).
|
|
264
|
+
let text;
|
|
265
|
+
try { text = readFileSync(join(vault, f.rel), "utf8"); } catch { continue; }
|
|
266
|
+
scanned++;
|
|
267
|
+
const node = index ? index.byPath.get(f.rel) : null;
|
|
268
|
+
const nf = nodeFacts(node);
|
|
269
|
+
if (kinds && kinds.length && !kinds.includes(nf.type)) continue;
|
|
270
|
+
if (st && nf.status !== st) continue;
|
|
271
|
+
const lines = String(text).split("\n");
|
|
272
|
+
// The frontmatter's line span, so its lines other than title: are skipped.
|
|
273
|
+
let fmEnd = -1;
|
|
274
|
+
if (lines[0] === "---") { const close = lines.indexOf("---", 1); fmEnd = close === -1 ? lines.length : close; }
|
|
275
|
+
const hits = [];
|
|
276
|
+
for (let i = 0; i < lines.length; i++) {
|
|
277
|
+
if (i <= fmEnd && !/^title:/.test(lines[i])) continue;
|
|
278
|
+
const hay = caseSensitive ? lines[i] : lines[i].toLowerCase();
|
|
279
|
+
const at = hay.indexOf(needle);
|
|
280
|
+
if (at === -1) continue;
|
|
281
|
+
const start = Math.max(0, at - Math.floor(SNIPPET_CELL / 3));
|
|
282
|
+
hits.push({ path: f.rel, line: i + 1, snippet: truncEnd(lines[i].slice(start).trim(), SNIPPET_CELL), ...nf });
|
|
283
|
+
}
|
|
284
|
+
if (hits.length > SEARCH_PER_FILE_CAP) filesTruncated++;
|
|
285
|
+
for (const h of hits.slice(0, SEARCH_PER_FILE_CAP)) all.push({ ...h, of: hits.length });
|
|
286
|
+
}
|
|
287
|
+
all.sort((a, b) => cmp(a.path, b.path) || a.line - b.line);
|
|
288
|
+
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" };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function renderSearch(r) {
|
|
292
|
+
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`;
|
|
293
|
+
const groups = new Map();
|
|
294
|
+
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); }
|
|
295
|
+
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)" : ""}`];
|
|
296
|
+
for (const [g, ms] of [...groups.entries()].sort(([a], [b]) => cmp(a, b))) {
|
|
297
|
+
lines.push("", `${g}/ (${ms.length})`);
|
|
298
|
+
for (const m of ms) lines.push(` ${m.path}:${m.line} ${m.snippet}${m.of > r.per_file_cap ? ` [${m.of} in file]` : ""}`);
|
|
299
|
+
}
|
|
300
|
+
return lines.join("\n") + "\n";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ─── show ──────────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
export function show(cfg, path, { body = false, section = null } = {}) {
|
|
306
|
+
const vault = vaultOf(cfg);
|
|
307
|
+
const rel = toVaultRel(vault, path);
|
|
308
|
+
const abs = join(vault, rel);
|
|
309
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) throw usageError(`${rel} does not exist in the vault`);
|
|
310
|
+
const text = readFileSync(abs, "utf8");
|
|
311
|
+
const parsed = parseFrontmatter(text);
|
|
312
|
+
const fm = parsed.data;
|
|
313
|
+
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") };
|
|
314
|
+
if (body) out.body = parsed.body;
|
|
315
|
+
if (section) {
|
|
316
|
+
const ids = Object.keys(loadHeadingsRegistry().headings || {}).sort();
|
|
317
|
+
if (!ids.includes(section)) throw usageError(`unknown section "${section}" — one of: ${ids.join(", ")}`);
|
|
318
|
+
const s = sectionOf(parsed.body, section);
|
|
319
|
+
out.section = { id: section, text: s === null ? null : s.trim() };
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function renderShow(r) {
|
|
325
|
+
const lines = [`${r.path}`, `type: ${r.type ?? "—"} · title: ${r.title ?? "—"} · status: ${r.status ?? "—"} · ${r.lines} lines`];
|
|
326
|
+
if (r.section) lines.push("", `## ${r.section.id}`, "", r.section.text ?? "(section not found)");
|
|
327
|
+
if (r.body !== undefined) lines.push("", r.body);
|
|
328
|
+
return lines.join("\n") + "\n";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ─── graph: neighbors and lineage ──────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
function graphOf(cfg) {
|
|
334
|
+
const layout = loadLayout(cfg.layout);
|
|
335
|
+
const g = buildGraph(cfg, layout);
|
|
336
|
+
const index = new Map(g.nodes.map((n) => [n.path, n]));
|
|
337
|
+
return { g, index };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function neighbors(cfg, path, { kinds = null, direction = "both", limit = GRAPH_EDGE_CAP } = {}) {
|
|
341
|
+
const vault = vaultOf(cfg);
|
|
342
|
+
const rel = toVaultRel(vault, path);
|
|
343
|
+
const dir = oneOf("direction", direction, DIRECTIONS, "both");
|
|
344
|
+
const cap = intOption("limit", limit, { min: 1, max: GRAPH_EDGE_CAP, fallback: GRAPH_EDGE_CAP });
|
|
345
|
+
const { g, index } = graphOf(cfg);
|
|
346
|
+
const self = index.get(rel);
|
|
347
|
+
if (!self) throw usageError(`${rel} is not a node of the graph (not an artifact the layout knows)`);
|
|
348
|
+
const want = (e) => !kinds || !kinds.length || kinds.includes(e.kind);
|
|
349
|
+
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_") }));
|
|
350
|
+
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_") }));
|
|
351
|
+
out.sort((a, b) => cmp(a.kind, b.kind) || cmp(a.to, b.to));
|
|
352
|
+
inn.sort((a, b) => cmp(a.kind, b.kind) || cmp(a.from, b.from));
|
|
353
|
+
const byKind = {};
|
|
354
|
+
for (const e of [...out, ...inn]) byKind[e.kind] = (byKind[e.kind] || 0) + 1;
|
|
355
|
+
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 };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function prefixed(o, p) {
|
|
359
|
+
return Object.fromEntries(Object.entries(o).map(([k, v]) => [p + k, v]));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function renderNeighbors(r) {
|
|
363
|
+
const lines = [`${r.path} (${r.type ?? "—"} · ${r.status ?? "—"}) ${r.title ?? ""}`.trimEnd(), `out ${r.counts.out} · in ${r.counts.in}${r.truncated ? " · truncated" : ""}`];
|
|
364
|
+
for (const e of r.out) lines.push(` → ${e.kind.padEnd(19)} ${e.to}${e.to_title ? ` (${e.to_title})` : ""}`);
|
|
365
|
+
for (const e of r.in) lines.push(` ← ${e.kind.padEnd(19)} ${e.from}${e.from_title ? ` (${e.from_title})` : ""}`);
|
|
366
|
+
return lines.join("\n") + "\n";
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// The typed ancestry and descent of one artifact: supersedes, spec-covers,
|
|
370
|
+
// spec-implements-adr and epic-contains, both directions, breadth-first,
|
|
371
|
+
// depth- and node-capped, cycle-safe. Body wikilinks are deliberately not
|
|
372
|
+
// lineage — walking them returns most of the vault, so `kinds` is validated
|
|
373
|
+
// against LINEAGE_KINDS — and containment is not walked from an epic down to
|
|
374
|
+
// its stories unless the epic is the root.
|
|
375
|
+
export function lineage(cfg, path, { depth = LINEAGE_DEFAULT_DEPTH, kinds = LINEAGE_KINDS, cap = LINEAGE_NODE_CAP } = {}) {
|
|
376
|
+
const vault = vaultOf(cfg);
|
|
377
|
+
const rel = toVaultRel(vault, path);
|
|
378
|
+
const maxDepth = intOption("depth", depth, { min: 0, max: 10, fallback: LINEAGE_DEFAULT_DEPTH });
|
|
379
|
+
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)`);
|
|
380
|
+
const { g, index } = graphOf(cfg);
|
|
381
|
+
if (!index.has(rel)) throw usageError(`${rel} is not a node of the graph`);
|
|
382
|
+
const kindSet = new Set(kinds && kinds.length ? kinds : LINEAGE_KINDS);
|
|
383
|
+
const typed = g.edges.filter((e) => kindSet.has(e.kind) && index.has(e.to));
|
|
384
|
+
const seen = new Map([[rel, 0]]);
|
|
385
|
+
const edges = [];
|
|
386
|
+
const edgeKeys = new Set();
|
|
387
|
+
let frontier = [rel];
|
|
388
|
+
let truncated = false;
|
|
389
|
+
for (let d = 1; d <= maxDepth && frontier.length; d++) {
|
|
390
|
+
const next = [];
|
|
391
|
+
for (const p of frontier) {
|
|
392
|
+
for (const e of typed) {
|
|
393
|
+
const other = e.from === p ? e.to : e.to === p ? e.from : null;
|
|
394
|
+
if (!other) continue;
|
|
395
|
+
// Containment is walked upward only, unless the root is the epic:
|
|
396
|
+
// from a story, its epic is lineage; the epic's other stories are
|
|
397
|
+
// siblings, and a depth of 3 would return the whole epic.
|
|
398
|
+
if (e.kind === "epic-contains" && e.from === p && p !== rel) continue;
|
|
399
|
+
if (!seen.has(other)) {
|
|
400
|
+
if (seen.size >= cap) { truncated = true; continue; }
|
|
401
|
+
seen.set(other, d);
|
|
402
|
+
next.push(other);
|
|
403
|
+
}
|
|
404
|
+
// Recorded only once both endpoints are nodes of the result.
|
|
405
|
+
const k = `${e.from} ${e.kind} ${e.to}`;
|
|
406
|
+
if (!edgeKeys.has(k)) { edgeKeys.add(k); edges.push({ from: e.from, kind: e.kind, to: e.to }); }
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
frontier = next.sort(cmp);
|
|
410
|
+
}
|
|
411
|
+
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));
|
|
412
|
+
edges.sort((a, b) => cmp(a.from, b.from) || cmp(a.kind, b.kind) || cmp(a.to, b.to));
|
|
413
|
+
return { root: rel, depth: maxDepth, kinds: [...kindSet], nodes, edges, truncated };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function renderLineage(r) {
|
|
417
|
+
const lines = [`${r.root} — lineage over ${r.kinds.join(", ")} to depth ${r.depth}${r.truncated ? " (truncated)" : ""}`];
|
|
418
|
+
for (const n of r.nodes) lines.push(` ${String(n.distance).padStart(2)} ${n.path} (${n.type ?? "—"} · ${n.status ?? "—"})${n.title ? ` ${n.title}` : ""}`);
|
|
419
|
+
if (r.edges.length) { lines.push("", "edges:"); for (const e of r.edges) lines.push(` ${e.from} ${e.kind} ${e.to}`); }
|
|
420
|
+
return lines.join("\n") + "\n";
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ─── codemap --for ─────────────────────────────────────────────────────
|
|
424
|
+
|
|
425
|
+
const normRef = (r) => String(r).replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
426
|
+
|
|
427
|
+
// Which code an artifact maps to, or which artifacts map to a path. The
|
|
428
|
+
// selector is an epic id, an artifact identity or vault path, or a
|
|
429
|
+
// repo-relative path; the result says which reading was taken. A tie is
|
|
430
|
+
// ambiguity, never a first match — as in the link resolver and doctor.
|
|
431
|
+
export function codeRefs(cfg, selector, { reverse = false } = {}) {
|
|
432
|
+
if (typeof selector !== "string" || !selector.trim()) throw usageError("a selector is required — an epic id, an artifact, or a repo path");
|
|
433
|
+
const sel = selector.trim();
|
|
434
|
+
const layout = loadLayout(cfg.layout);
|
|
435
|
+
const index = buildNodeIndex(cfg, layout);
|
|
436
|
+
const warnings = [];
|
|
437
|
+
const refsOf = (n) => {
|
|
438
|
+
const refs = listOf(n.fm, "code_refs");
|
|
439
|
+
if (!refs.length && typeof n.fm.code_refs === "string" && n.fm.code_refs.trim() === "") {
|
|
440
|
+
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`);
|
|
441
|
+
}
|
|
442
|
+
return refs;
|
|
443
|
+
};
|
|
444
|
+
const row = (n, matched) => ({ path: n.path, type: n.type, title: n.title, status: n.status, code_refs: refsOf(n), ...(matched !== undefined ? { matched } : {}) });
|
|
445
|
+
const bounded = (rows) => ({ artifacts: rows.slice(0, CODE_REFS_CAP), total: rows.length, truncated: rows.length > CODE_REFS_CAP });
|
|
446
|
+
const epicFolder = folderByKind(layout, "epic");
|
|
447
|
+
if (!reverse) {
|
|
448
|
+
// An epic by its id or its folder name — never by the stem "epic", which every epic.md shares.
|
|
449
|
+
const epics = index.nodes.filter((n) => n.type === "epic" && (n.identity === sel || (epicFolder && n.path === `${epicFolder.path}/${sel}/epic.md`)));
|
|
450
|
+
if (epics.length > 1) throw usageError(`"${sel}" names ${epics.length} epics: ${epics.map((n) => n.path).join(", ")}`);
|
|
451
|
+
if (epics.length === 1) {
|
|
452
|
+
const epicDir = epics[0].path.replace(/\/epic\.md$/, "");
|
|
453
|
+
const stories = index.nodes.filter((n) => n.type === "story" && n.path.startsWith(epicDir + "/")).sort((a, b) => cmp(a.path, b.path));
|
|
454
|
+
return { selector: sel, resolved_as: "epic", ...bounded([row(epics[0]), ...stories.map((n) => row(n))]), warnings };
|
|
455
|
+
}
|
|
456
|
+
const byPath = index.byPath.get(sel);
|
|
457
|
+
const candidates = byPath ? [byPath] : [...new Set([...(index.byIdentity.get(sel.toLowerCase()) || []), ...(index.byStem.get(sel.toLowerCase()) || [])])];
|
|
458
|
+
if (candidates.length > 1) throw usageError(`"${sel}" is ambiguous — ${candidates.map((n) => n.path).sort(cmp).join(", ")}; pass the vault path`);
|
|
459
|
+
if (candidates.length === 1) return { selector: sel, resolved_as: "artifact", ...bounded([row(candidates[0])]), warnings };
|
|
460
|
+
}
|
|
461
|
+
const target = normRef(sel);
|
|
462
|
+
const covering = index.nodes.map((n) => {
|
|
463
|
+
// A ref covers the selector when they are equal, or one is a directory
|
|
464
|
+
// prefix of the other ("scripts/" covers "scripts/cli.mjs"; asking about
|
|
465
|
+
// "scripts/" lists whoever names a file under it). Reported as written.
|
|
466
|
+
const matched = refsOf(n).filter((ref) => { const r = normRef(ref); return r === target || target.startsWith(r + "/") || r.startsWith(target + "/"); });
|
|
467
|
+
return matched.length ? row(n, matched) : null;
|
|
468
|
+
}).filter(Boolean).sort((a, b) => cmp(a.path, b.path));
|
|
469
|
+
return { selector: sel, resolved_as: "path", ...bounded(covering), warnings };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function renderCodeRefs(r) {
|
|
473
|
+
const lines = [`${r.selector} (read as ${r.resolved_as})${r.truncated ? ` — ${r.artifacts.length} of ${r.total}` : ""}`];
|
|
474
|
+
if (!r.artifacts.length) lines.push(" nothing maps here");
|
|
475
|
+
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(", ")}` : ""}`);
|
|
476
|
+
for (const w of r.warnings) lines.push(` ⚠ ${w}`);
|
|
477
|
+
return lines.join("\n") + "\n";
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ─── the table the MCP surface derives its tools from ──────────────────
|
|
481
|
+
|
|
482
|
+
export const READ_OPERATIONS = Object.freeze({
|
|
483
|
+
status: { fn: status, render: renderStatus, async: false },
|
|
484
|
+
orientation: { fn: orientation, render: (r) => r.skeleton + (r.skeleton.endsWith("\n") ? "" : "\n"), async: true },
|
|
485
|
+
search: { fn: search, render: renderSearch, async: false },
|
|
486
|
+
show: { fn: show, render: renderShow, async: false },
|
|
487
|
+
neighbors: { fn: neighbors, render: renderNeighbors, async: false },
|
|
488
|
+
lineage: { fn: lineage, render: renderLineage, async: false },
|
|
489
|
+
codeRefs: { fn: codeRefs, render: renderCodeRefs, async: false },
|
|
490
|
+
});
|