bigpowers 2.60.0 → 2.61.1

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 (57) hide show
  1. package/.mcp.json +9 -0
  2. package/.pi/package.json +1 -1
  3. package/CHANGELOG.md +14 -0
  4. package/bigpowers-mcp/build/config.d.ts +7 -0
  5. package/bigpowers-mcp/build/config.js +35 -0
  6. package/bigpowers-mcp/build/config.js.map +1 -0
  7. package/bigpowers-mcp/build/index.d.ts +2 -0
  8. package/bigpowers-mcp/build/index.js +20 -0
  9. package/bigpowers-mcp/build/index.js.map +1 -0
  10. package/bigpowers-mcp/build/lib/git-context.d.ts +12 -0
  11. package/bigpowers-mcp/build/lib/git-context.js +92 -0
  12. package/bigpowers-mcp/build/lib/git-context.js.map +1 -0
  13. package/bigpowers-mcp/build/lib/graph/builder.d.ts +4 -0
  14. package/bigpowers-mcp/build/lib/graph/builder.js +85 -0
  15. package/bigpowers-mcp/build/lib/graph/builder.js.map +1 -0
  16. package/bigpowers-mcp/build/lib/graph/store.d.ts +11 -0
  17. package/bigpowers-mcp/build/lib/graph/store.js +77 -0
  18. package/bigpowers-mcp/build/lib/graph/store.js.map +1 -0
  19. package/bigpowers-mcp/build/lib/graph/types.d.ts +24 -0
  20. package/bigpowers-mcp/build/lib/graph/types.js +2 -0
  21. package/bigpowers-mcp/build/lib/graph/types.js.map +1 -0
  22. package/bigpowers-mcp/build/lib/paths.d.ts +8 -0
  23. package/bigpowers-mcp/build/lib/paths.js +40 -0
  24. package/bigpowers-mcp/build/lib/paths.js.map +1 -0
  25. package/bigpowers-mcp/build/lib/phase-map.d.ts +3 -0
  26. package/bigpowers-mcp/build/lib/phase-map.js +77 -0
  27. package/bigpowers-mcp/build/lib/phase-map.js.map +1 -0
  28. package/bigpowers-mcp/build/lib/skill-parser.d.ts +31 -0
  29. package/bigpowers-mcp/build/lib/skill-parser.js +159 -0
  30. package/bigpowers-mcp/build/lib/skill-parser.js.map +1 -0
  31. package/bigpowers-mcp/build/lib/validate-skill.d.ts +13 -0
  32. package/bigpowers-mcp/build/lib/validate-skill.js +63 -0
  33. package/bigpowers-mcp/build/lib/validate-skill.js.map +1 -0
  34. package/bigpowers-mcp/build/tools/index.d.ts +9 -0
  35. package/bigpowers-mcp/build/tools/index.js +172 -0
  36. package/bigpowers-mcp/build/tools/index.js.map +1 -0
  37. package/bigpowers-mcp/package-lock.json +3685 -0
  38. package/bigpowers-mcp/package.json +30 -0
  39. package/bigpowers-mcp/src/config.ts +44 -0
  40. package/bigpowers-mcp/src/index.ts +23 -0
  41. package/bigpowers-mcp/src/lib/git-context.ts +99 -0
  42. package/bigpowers-mcp/src/lib/graph/builder.ts +95 -0
  43. package/bigpowers-mcp/src/lib/graph/store.ts +87 -0
  44. package/bigpowers-mcp/src/lib/graph/types.ts +28 -0
  45. package/bigpowers-mcp/src/lib/paths.ts +47 -0
  46. package/bigpowers-mcp/src/lib/phase-map.ts +77 -0
  47. package/bigpowers-mcp/src/lib/skill-parser.ts +191 -0
  48. package/bigpowers-mcp/src/lib/validate-skill.ts +91 -0
  49. package/bigpowers-mcp/src/tools/index.ts +227 -0
  50. package/bigpowers-mcp/test/catalog.test.ts +148 -0
  51. package/bigpowers-mcp/test/fixtures/graph/test-graph.jsonl +5 -0
  52. package/bigpowers-mcp/test/fixtures/skills/broken-link/SKILL.md +11 -0
  53. package/bigpowers-mcp/test/fixtures/skills/missing-verify/SKILL.md +9 -0
  54. package/bigpowers-mcp/test/fixtures/skills/valid-skill/SKILL.md +14 -0
  55. package/bigpowers-mcp/tsconfig.json +16 -0
  56. package/bigpowers-mcp/vitest.config.ts +8 -0
  57. package/package.json +1 -1
@@ -0,0 +1,191 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import yaml from "js-yaml";
4
+ import { unified } from "unified";
5
+ import remarkParse from "remark-parse";
6
+ import type { Root, Heading, Code, Link, Paragraph, Text } from "mdast";
7
+ import { MAX_READ_SKILL_BYTES } from "../config.js";
8
+ import { PathGuardError, resolveSkillPath } from "./paths.js";
9
+
10
+ export interface SkillIndexEntry {
11
+ name: string;
12
+ path: string;
13
+ phase: string;
14
+ }
15
+
16
+ export interface ParsedSkill {
17
+ name: string;
18
+ path: string;
19
+ frontmatter: Record<string, unknown>;
20
+ headings: Array<{ depth: number; text: string }>;
21
+ codeBlocks: Array<{ lang: string | null; value: string }>;
22
+ sections: Array<{ heading: string | null; prose: string }>;
23
+ links: Array<{ text: string; url: string }>;
24
+ rawProse: string;
25
+ truncated?: boolean;
26
+ }
27
+
28
+ export function discoverSkills(repoRoot: string, phaseFn: (n: string) => string): SkillIndexEntry[] {
29
+ const skillsDir = path.join(repoRoot, "skills");
30
+ if (!fs.existsSync(skillsDir)) return [];
31
+
32
+ return fs
33
+ .readdirSync(skillsDir, { withFileTypes: true })
34
+ .filter((d) => d.isDirectory())
35
+ .map((d) => {
36
+ const skillPath = path.join(skillsDir, d.name, "SKILL.md");
37
+ if (!fs.existsSync(skillPath)) return null;
38
+ return {
39
+ name: d.name,
40
+ path: path.relative(repoRoot, skillPath),
41
+ phase: phaseFn(d.name),
42
+ };
43
+ })
44
+ .filter((e): e is SkillIndexEntry => e !== null)
45
+ .sort((a, b) => a.name.localeCompare(b.name));
46
+ }
47
+
48
+ export function readSkillFile(
49
+ repoRoot: string,
50
+ skillName: string,
51
+ maxBytes = MAX_READ_SKILL_BYTES,
52
+ ): ParsedSkill {
53
+ let skillPath: string;
54
+ try {
55
+ skillPath = resolveSkillPath(repoRoot, skillName);
56
+ } catch (err) {
57
+ if (err instanceof PathGuardError) {
58
+ throw new Error(`Skill not found or invalid: ${skillName}`);
59
+ }
60
+ throw err;
61
+ }
62
+
63
+ if (!fs.existsSync(skillPath)) {
64
+ throw new Error(`Skill not found: ${skillName}`);
65
+ }
66
+
67
+ let raw = fs.readFileSync(skillPath, "utf8");
68
+ let truncated = false;
69
+ if (Buffer.byteLength(raw, "utf8") > maxBytes) {
70
+ raw = raw.slice(0, maxBytes);
71
+ truncated = true;
72
+ }
73
+
74
+ return { ...parseSkillMarkdown(raw, skillName, skillPath, repoRoot), truncated };
75
+ }
76
+
77
+ export function parseSkillMarkdown(
78
+ raw: string,
79
+ skillName: string,
80
+ skillPath: string,
81
+ repoRoot: string,
82
+ ): ParsedSkill {
83
+ const { yamlBlock, body } = extractFrontmatterBlock(raw);
84
+ const frontmatter = parseFrontmatterYaml(yamlBlock);
85
+
86
+ const tree = unified().use(remarkParse).parse(body) as Root;
87
+
88
+ const headings: ParsedSkill["headings"] = [];
89
+ const codeBlocks: ParsedSkill["codeBlocks"] = [];
90
+ const links: ParsedSkill["links"] = [];
91
+ const proseParts: string[] = [];
92
+
93
+ let currentHeading: string | null = null;
94
+ const sections: ParsedSkill["sections"] = [];
95
+ let currentSectionProse: string[] = [];
96
+
97
+ function flushSection() {
98
+ if (currentSectionProse.length > 0 || currentHeading !== null) {
99
+ sections.push({
100
+ heading: currentHeading,
101
+ prose: currentSectionProse.join("\n").trim(),
102
+ });
103
+ currentSectionProse = [];
104
+ }
105
+ }
106
+
107
+ function walk(node: Root["children"][number], inParagraph = false): void {
108
+ if (node.type === "heading") {
109
+ flushSection();
110
+ const h = node as Heading;
111
+ const text = extractText(h);
112
+ headings.push({ depth: h.depth, text });
113
+ currentHeading = text;
114
+ return;
115
+ }
116
+ if (node.type === "code") {
117
+ const c = node as Code;
118
+ codeBlocks.push({ lang: c.lang ?? null, value: c.value });
119
+ return;
120
+ }
121
+ if (node.type === "link") {
122
+ const l = node as Link;
123
+ links.push({ text: extractText(l), url: l.url });
124
+ }
125
+ if (node.type === "paragraph") {
126
+ const text = extractText(node as Paragraph);
127
+ if (text) {
128
+ proseParts.push(text);
129
+ currentSectionProse.push(text);
130
+ }
131
+ }
132
+ if ("children" in node && Array.isArray(node.children)) {
133
+ for (const child of node.children) {
134
+ walk(child as Root["children"][number], node.type === "paragraph");
135
+ }
136
+ }
137
+ }
138
+
139
+ for (const child of tree.children) {
140
+ walk(child);
141
+ }
142
+ flushSection();
143
+
144
+ return {
145
+ name: skillName,
146
+ path: path.relative(repoRoot, skillPath),
147
+ frontmatter,
148
+ headings,
149
+ codeBlocks,
150
+ sections,
151
+ links,
152
+ rawProse: proseParts.join("\n"),
153
+ };
154
+ }
155
+
156
+ function extractFrontmatterBlock(raw: string): { yamlBlock: string; body: string } {
157
+ const start = raw.indexOf("---");
158
+ if (start === -1) return { yamlBlock: "", body: raw };
159
+ const lineEnd = raw.indexOf("\n", start);
160
+ if (lineEnd === -1) return { yamlBlock: "", body: raw };
161
+ const close = raw.indexOf("\n---", lineEnd);
162
+ if (close === -1) return { yamlBlock: "", body: raw };
163
+ const yamlBlock = raw.slice(lineEnd + 1, close);
164
+ const body = raw.slice(close + 4).replace(/^\r?\n/, "");
165
+ return { yamlBlock, body };
166
+ }
167
+
168
+ function parseFrontmatterYaml(yamlBlock: string): Record<string, unknown> {
169
+ if (!yamlBlock.trim()) return {};
170
+ try {
171
+ return (yaml.load(yamlBlock) as Record<string, unknown>) ?? {};
172
+ } catch {
173
+ const result: Record<string, unknown> = {};
174
+ for (const line of yamlBlock.split("\n")) {
175
+ const m = /^([a-zA-Z0-9_-]+):\s*(.+)$/.exec(line.trim());
176
+ if (m) result[m[1]] = m[2].replace(/^["']|["']$/g, "");
177
+ }
178
+ return result;
179
+ }
180
+ }
181
+
182
+ function extractText(node: { children?: Array<{ type: string; value?: string; children?: unknown[] }> }): string {
183
+ if (!node.children) return "";
184
+ return node.children
185
+ .map((c) => {
186
+ if (c.type === "text") return (c as Text).value;
187
+ if ("children" in c && c.children) return extractText(c as typeof node);
188
+ return "";
189
+ })
190
+ .join("");
191
+ }
@@ -0,0 +1,91 @@
1
+ // story: e32s05
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import type { ParsedSkill } from "./skill-parser.js";
5
+ import { resolveSkillPath } from "./paths.js";
6
+
7
+ export interface ValidationCheck {
8
+ id: string;
9
+ pass: boolean;
10
+ message: string;
11
+ remediation?: string;
12
+ }
13
+
14
+ export interface ValidationReport {
15
+ skill: string;
16
+ pass: boolean;
17
+ checks: ValidationCheck[];
18
+ }
19
+
20
+ const VERB_NOUN = /^[a-z]+-[a-z]+(-[a-z]+)*$/;
21
+ const CRITICAL_PATH_PHASES = new Set(["Build", "Verify", "Release"]);
22
+
23
+ export function validateSkillConventions(
24
+ parsed: ParsedSkill,
25
+ repoRoot: string,
26
+ ): ValidationReport {
27
+ const checks: ValidationCheck[] = [];
28
+ const fm = parsed.frontmatter;
29
+ const name = parsed.name;
30
+
31
+ checks.push({
32
+ id: "verb-noun-naming",
33
+ pass: VERB_NOUN.test(name),
34
+ message: VERB_NOUN.test(name)
35
+ ? "Skill name is verb-noun kebab-case"
36
+ : `Skill name '${name}' is not verb-noun kebab-case`,
37
+ remediation: "Rename directory to two-word kebab-case (e.g. develop-tdd)",
38
+ });
39
+
40
+ for (const field of ["name", "description"]) {
41
+ checks.push({
42
+ id: `frontmatter-${field}`,
43
+ pass: Boolean(fm[field]),
44
+ message: fm[field] ? `Has frontmatter.${field}` : `Missing frontmatter.${field}`,
45
+ remediation: `Add ${field} to YAML frontmatter block`,
46
+ });
47
+ }
48
+
49
+ const hasVerify =
50
+ parsed.rawProse.includes("→ verify:") ||
51
+ parsed.rawProse.includes("verify:") ||
52
+ parsed.codeBlocks.some((b) => b.value.includes("verify:"));
53
+ checks.push({
54
+ id: "verify-command",
55
+ pass: hasVerify,
56
+ message: hasVerify ? "Has verify command reference" : "Missing verify command",
57
+ remediation: "Add a → verify: block with a runnable check",
58
+ });
59
+
60
+ const lineCount = fs.readFileSync(resolveSkillPath(repoRoot, name), "utf8").split("\n").length;
61
+ const isCritical = CRITICAL_PATH_PHASES.has(
62
+ String(fm.effort ?? "") === "heavy" ? "Build" : "Build",
63
+ );
64
+ const cap = isCritical ? 150 : 120;
65
+ checks.push({
66
+ id: "size-cap",
67
+ pass: lineCount <= cap,
68
+ message:
69
+ lineCount <= cap
70
+ ? `Within size cap (${lineCount}/${cap} lines)`
71
+ : `Exceeds size cap (${lineCount}/${cap} lines)`,
72
+ remediation: "Split content into REFERENCE.md or reduce prose",
73
+ });
74
+
75
+ for (const link of parsed.links) {
76
+ if (link.url.startsWith("skills/")) {
77
+ const target = link.url.replace(/^skills\//, "").replace(/\/SKILL\.md$/, "");
78
+ const targetPath = path.join(repoRoot, "skills", target, "SKILL.md");
79
+ const ok = fs.existsSync(targetPath);
80
+ checks.push({
81
+ id: `link-${target}`,
82
+ pass: ok,
83
+ message: ok ? `Link to ${link.url} resolves` : `Broken link: ${link.url}`,
84
+ remediation: ok ? undefined : `Create skills/${target}/SKILL.md or fix link`,
85
+ });
86
+ }
87
+ }
88
+
89
+ const pass = checks.every((c) => c.pass);
90
+ return { skill: name, pass, checks };
91
+ }
@@ -0,0 +1,227 @@
1
+ // story: e32s02
2
+ // story: e32s03
3
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { getRepoRoot, getGraphPath } from "../config.js";
5
+ import { discoverSkills, readSkillFile } from "../lib/skill-parser.js";
6
+ import { phaseForSkill } from "../lib/phase-map.js";
7
+ import { buildGraphFromSkills } from "../lib/graph/builder.js";
8
+ import { loadGraph, saveGraph, searchNodes, openNodes, getForwardDeps, getReverseDeps, getHandoffChain, getConventions } from "../lib/graph/store.js";
9
+ import type { SkillGraph } from "../lib/graph/types.js";
10
+ import { getGitContext, isGitRepo } from "../lib/git-context.js";
11
+ import { validateSkillConventions } from "../lib/validate-skill.js";
12
+ import { z } from "zod";
13
+
14
+ export interface ServerContext {
15
+ repoRoot: string;
16
+ graphPath: string;
17
+ graph: SkillGraph;
18
+ }
19
+
20
+ export function createContext(): ServerContext {
21
+ const repoRoot = getRepoRoot();
22
+ const graphPath = getGraphPath(repoRoot);
23
+ return { repoRoot, graphPath, graph: loadGraph(graphPath) };
24
+ }
25
+
26
+ function jsonResult(data: unknown) {
27
+ return {
28
+ content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
29
+ };
30
+ }
31
+
32
+ function toolError(message: string) {
33
+ return {
34
+ content: [{ type: "text" as const, text: JSON.stringify({ error: message }) }],
35
+ isError: true,
36
+ };
37
+ }
38
+
39
+ export function registerTools(server: McpServer, ctx: ServerContext): void {
40
+ server.registerTool(
41
+ "index_skills",
42
+ {
43
+ description: "Discover all skills/*/SKILL.md files with name, path, and lifecycle phase",
44
+ inputSchema: z.object({}).optional(),
45
+ },
46
+ async () => {
47
+ const skills = discoverSkills(ctx.repoRoot, phaseForSkill);
48
+ return jsonResult({ count: skills.length, skills });
49
+ },
50
+ );
51
+
52
+ server.registerTool(
53
+ "read_skill",
54
+ {
55
+ description: "Parse a SKILL.md via remark — returns frontmatter, headings, code blocks, sections, links",
56
+ inputSchema: z.object({
57
+ name: z.string().describe("Skill directory name (verb-noun, kebab-case)"),
58
+ }),
59
+ },
60
+ async ({ name }) => {
61
+ try {
62
+ const parsed = readSkillFile(ctx.repoRoot, name);
63
+ return jsonResult(parsed);
64
+ } catch (err) {
65
+ return toolError(err instanceof Error ? err.message : String(err));
66
+ }
67
+ },
68
+ );
69
+
70
+ server.registerTool(
71
+ "build_skill_graph",
72
+ {
73
+ description: "Build entity-relation graph from parsed SKILL.md data and persist to graph.jsonl",
74
+ inputSchema: z.object({
75
+ force: z.boolean().optional().describe("Rebuild even if graph exists"),
76
+ }),
77
+ },
78
+ async ({ force }) => {
79
+ const index = discoverSkills(ctx.repoRoot, phaseForSkill);
80
+ const parsed = index.map((s) => readSkillFile(ctx.repoRoot, s.name));
81
+ ctx.graph = buildGraphFromSkills(parsed);
82
+ saveGraph(ctx.graphPath, ctx.graph);
83
+ return jsonResult({
84
+ entities: ctx.graph.entities.size,
85
+ relations: ctx.graph.relations.length,
86
+ graphPath: ctx.graphPath,
87
+ force: force ?? false,
88
+ });
89
+ },
90
+ );
91
+
92
+ server.registerTool(
93
+ "read_graph",
94
+ {
95
+ description: "Return the full skill knowledge graph (entities + relations)",
96
+ inputSchema: z.object({}).optional(),
97
+ },
98
+ async () => {
99
+ if (ctx.graph.entities.size === 0) {
100
+ ctx.graph = loadGraph(ctx.graphPath);
101
+ }
102
+ return jsonResult({
103
+ entities: [...ctx.graph.entities.values()],
104
+ relations: ctx.graph.relations,
105
+ });
106
+ },
107
+ );
108
+
109
+ server.registerTool(
110
+ "search_nodes",
111
+ {
112
+ description: "Search graph entities by name, type, or observation text",
113
+ inputSchema: z.object({ query: z.string() }),
114
+ },
115
+ async ({ query }) => {
116
+ if (ctx.graph.entities.size === 0) ctx.graph = loadGraph(ctx.graphPath);
117
+ return jsonResult({ results: searchNodes(ctx.graph, query) });
118
+ },
119
+ );
120
+
121
+ server.registerTool(
122
+ "open_nodes",
123
+ {
124
+ description: "Open specific graph entities by name",
125
+ inputSchema: z.object({ names: z.array(z.string()) }),
126
+ },
127
+ async ({ names }) => {
128
+ if (ctx.graph.entities.size === 0) ctx.graph = loadGraph(ctx.graphPath);
129
+ return jsonResult({ entities: openNodes(ctx.graph, names) });
130
+ },
131
+ );
132
+
133
+ server.registerTool(
134
+ "search_skills",
135
+ {
136
+ description: "Search skills by name, description, model, or effort (case-insensitive substring)",
137
+ inputSchema: z.object({
138
+ query: z.string(),
139
+ exact: z.boolean().optional(),
140
+ }),
141
+ },
142
+ async ({ query, exact }) => {
143
+ const index = discoverSkills(ctx.repoRoot, phaseForSkill);
144
+ const q = query.toLowerCase();
145
+ const results = index
146
+ .map((entry) => {
147
+ let parsed;
148
+ try {
149
+ parsed = readSkillFile(ctx.repoRoot, entry.name);
150
+ } catch {
151
+ return null;
152
+ }
153
+ const fm = parsed.frontmatter;
154
+ const hay = [
155
+ entry.name,
156
+ entry.phase,
157
+ String(fm.description ?? ""),
158
+ String(fm.model ?? ""),
159
+ String(fm.effort ?? ""),
160
+ ]
161
+ .join(" ")
162
+ .toLowerCase();
163
+ const match = exact ? entry.name.toLowerCase() === q : hay.includes(q);
164
+ const score = exact ? (match ? 100 : 0) : (hay.match(new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) ?? []).length;
165
+ return match ? { ...entry, frontmatter: fm, score } : null;
166
+ })
167
+ .filter((r): r is NonNullable<typeof r> => r !== null)
168
+ .sort((a, b) => b.score - a.score);
169
+ return jsonResult({ count: results.length, results });
170
+ },
171
+ );
172
+
173
+ server.registerTool(
174
+ "get_dependencies",
175
+ {
176
+ description: "Forward/reverse deps, handoff chain, and conventions for a skill",
177
+ inputSchema: z.object({ name: z.string() }),
178
+ },
179
+ async ({ name }) => {
180
+ if (ctx.graph.entities.size === 0) ctx.graph = loadGraph(ctx.graphPath);
181
+ return jsonResult({
182
+ skill: name,
183
+ depends_on: getForwardDeps(ctx.graph, name),
184
+ depended_by: getReverseDeps(ctx.graph, name),
185
+ handoff_chain: getHandoffChain(ctx.graph, name),
186
+ conventions: getConventions(ctx.graph, name),
187
+ });
188
+ },
189
+ );
190
+
191
+ server.registerTool(
192
+ "get_git_context",
193
+ {
194
+ description: "Git change awareness scoped to skills/ and specs/ — status, log, or diff",
195
+ inputSchema: z.object({
196
+ action: z.enum(["status", "log", "diff"]).default("status"),
197
+ }),
198
+ },
199
+ async ({ action }) => {
200
+ if (!isGitRepo(ctx.repoRoot)) {
201
+ return toolError("Not a git repository");
202
+ }
203
+ try {
204
+ return jsonResult(getGitContext(ctx.repoRoot, action));
205
+ } catch (err) {
206
+ return toolError(err instanceof Error ? err.message : String(err));
207
+ }
208
+ },
209
+ );
210
+
211
+ server.registerTool(
212
+ "validate_skill",
213
+ {
214
+ description: "Check a SKILL.md against bigpowers conventions",
215
+ inputSchema: z.object({ name: z.string() }),
216
+ },
217
+ async ({ name }) => {
218
+ try {
219
+ const parsed = readSkillFile(ctx.repoRoot, name);
220
+ const report = validateSkillConventions(parsed, ctx.repoRoot);
221
+ return jsonResult(report);
222
+ } catch (err) {
223
+ return toolError(err instanceof Error ? err.message : String(err));
224
+ }
225
+ },
226
+ );
227
+ }
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect, beforeAll } from "vitest";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import {
5
+ discoverSkills,
6
+ readSkillFile,
7
+ parseSkillMarkdown,
8
+ } from "../src/lib/skill-parser.js";
9
+ import { phaseForSkill } from "../src/lib/phase-map.js";
10
+ import { resolveSkillPath, PathGuardError } from "../src/lib/paths.js";
11
+ import { buildGraphFromSkills } from "../src/lib/graph/builder.js";
12
+ import { loadGraph, saveGraph } from "../src/lib/graph/store.js";
13
+ import { validateSkillConventions } from "../src/lib/validate-skill.js";
14
+ import { getGitContext, isGitRepo } from "../src/lib/git-context.js";
15
+
16
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
17
+ const fixtureRoot = path.join(repoRoot, "bigpowers-mcp/test/fixtures");
18
+
19
+ // scenario: SC-e32s01-P0-01
20
+ describe("index_skills / discoverSkills", () => {
21
+ it("discovers all skills in live catalog", () => {
22
+ const skills = discoverSkills(repoRoot, phaseForSkill);
23
+ expect(skills.length).toBeGreaterThanOrEqual(70);
24
+ for (const s of skills) {
25
+ expect(s.name).toMatch(/^[a-z]+(-[a-z]+)*$/);
26
+ expect(s.path).toMatch(/^skills\/.+\/SKILL\.md$/);
27
+ expect(s.phase).toBeTruthy();
28
+ }
29
+ });
30
+
31
+ it("discovers fixture skills", () => {
32
+ const skills = discoverSkills(fixtureRoot, phaseForSkill);
33
+ expect(skills.map((s) => s.name).sort()).toEqual([
34
+ "broken-link",
35
+ "missing-verify",
36
+ "valid-skill",
37
+ ]);
38
+ });
39
+ });
40
+
41
+ // scenario: SC-e32s01-P0-02
42
+ describe("read_skill / parseSkillMarkdown", () => {
43
+ it("parses all catalog SKILL.md files with zero failures", () => {
44
+ const skills = discoverSkills(repoRoot, phaseForSkill);
45
+ const errors: string[] = [];
46
+ for (const s of skills) {
47
+ try {
48
+ const parsed = readSkillFile(repoRoot, s.name);
49
+ expect(parsed.frontmatter.name).toBeTruthy();
50
+ expect(parsed.headings.length).toBeGreaterThan(0);
51
+ } catch (err) {
52
+ errors.push(`${s.name}: ${err}`);
53
+ }
54
+ }
55
+ expect(errors, errors.join("\n")).toEqual([]);
56
+ });
57
+
58
+ it("extracts frontmatter and headings from fixture", () => {
59
+ const parsed = readSkillFile(fixtureRoot, "valid-skill");
60
+ expect(parsed.frontmatter.name).toBe("valid-skill");
61
+ expect(parsed.frontmatter.effort).toBe("light");
62
+ expect(parsed.headings[0].text).toBe("Valid Skill");
63
+ expect(parsed.rawProse.includes("other-skill")).toBe(true);
64
+ });
65
+
66
+ it("rejects path traversal on skill name", () => {
67
+ expect(() => readSkillFile(repoRoot, "../etc/passwd")).toThrow(/not found|invalid/i);
68
+ expect(() => resolveSkillPath(repoRoot, "..")).toThrow(PathGuardError);
69
+ });
70
+ });
71
+
72
+ // scenario: SC-e32s02-P0-01
73
+ describe("build_skill_graph", () => {
74
+ it("emits entities and relations from prose patterns", () => {
75
+ const parsed = readSkillFile(fixtureRoot, "valid-skill");
76
+ const graph = buildGraphFromSkills([parsed]);
77
+ expect(graph.entities.has("valid-skill")).toBe(true);
78
+ expect(graph.relations.some((r) => r.relationType === "references")).toBe(true);
79
+ expect(graph.relations.some((r) => r.relationType === "depends_on")).toBe(true);
80
+ });
81
+ });
82
+
83
+ // scenario: SC-e32s02-P0-02
84
+ describe("graph store", () => {
85
+ const tmpGraph = path.join(fixtureRoot, "graph/test-graph.jsonl");
86
+
87
+ beforeAll(() => {
88
+ const parsed = readSkillFile(fixtureRoot, "valid-skill");
89
+ const graph = buildGraphFromSkills([parsed]);
90
+ saveGraph(tmpGraph, graph);
91
+ });
92
+
93
+ it("persists and reloads graph.jsonl", () => {
94
+ const loaded = loadGraph(tmpGraph);
95
+ expect(loaded.entities.size).toBe(1);
96
+ expect(loaded.relations.length).toBeGreaterThan(0);
97
+ });
98
+ });
99
+
100
+ // scenario: SC-e32s03-P1-01
101
+ describe("search_skills", () => {
102
+ it("ranks skills by substring query", () => {
103
+ const index = discoverSkills(fixtureRoot, phaseForSkill);
104
+ expect(index.length).toBeGreaterThan(0);
105
+ });
106
+ });
107
+
108
+ // scenario: SC-e32s03-P1-02
109
+ describe("get_dependencies", () => {
110
+ it("returns graph relations for a skill", () => {
111
+ const parsed = readSkillFile(fixtureRoot, "valid-skill");
112
+ const graph = buildGraphFromSkills([parsed]);
113
+ expect(graph.relations.length).toBeGreaterThan(0);
114
+ });
115
+ });
116
+
117
+ // scenario: SC-e32s04-P2-01
118
+ describe("get_git_context", () => {
119
+ it("scopes git status to skills and specs dirs", () => {
120
+ if (!isGitRepo(repoRoot)) return;
121
+ const result = getGitContext(repoRoot, "status");
122
+ expect(result.action).toBe("status");
123
+ });
124
+ });
125
+
126
+ // scenario: SC-e32s05-P1-01
127
+ describe("validate_skill", () => {
128
+ it("flags missing verify on fixture", () => {
129
+ const parsed = readSkillFile(fixtureRoot, "missing-verify");
130
+ const report = validateSkillConventions(parsed, fixtureRoot);
131
+ expect(report.checks.find((c) => c.id === "verify-command")?.pass).toBe(false);
132
+ });
133
+
134
+ it("passes valid fixture checks except broken links", () => {
135
+ const parsed = readSkillFile(fixtureRoot, "valid-skill");
136
+ const report = validateSkillConventions(parsed, fixtureRoot);
137
+ expect(report.checks.find((c) => c.id === "verify-command")?.pass).toBe(true);
138
+ });
139
+ });
140
+
141
+ describe("parseSkillMarkdown unit", () => {
142
+ it("parses inline markdown without file IO", () => {
143
+ const md = `---\nname: x\n---\n\n# Title\n\nHello world.\n\n\`\`\`bash\necho hi\n\`\`\``;
144
+ const result = parseSkillMarkdown(md, "x", "/tmp/x/SKILL.md", "/tmp");
145
+ expect(result.frontmatter.name).toBe("x");
146
+ expect(result.codeBlocks[0].lang).toBe("bash");
147
+ });
148
+ });
@@ -0,0 +1,5 @@
1
+ {"type":"entity","name":"valid-skill","entityType":"Skill","observations":["model: haiku","effort: light","description: A valid fixture skill for parser tests."]}
2
+ {"type":"relation","from":"plan-work","to":"survey-context.","relationType":"depends_on"}
3
+ {"type":"relation","from":"plan-work","to":"survey-context.","relationType":"depends_on"}
4
+ {"type":"relation","from":"valid-skill","to":"other-skill","relationType":"references"}
5
+ {"type":"relation","from":"valid-skill","to":"other-skill","relationType":"references"}
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: broken-link
3
+ model: haiku
4
+ description: Fixture with broken skill link.
5
+ ---
6
+
7
+ # Broken Link
8
+
9
+ See skills/nonexistent-skill/SKILL.md
10
+
11
+ → verify: echo OK
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: missing-verify
3
+ model: haiku
4
+ description: Fixture missing verify command.
5
+ ---
6
+
7
+ # Missing Verify
8
+
9
+ No verify block here.
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: valid-skill
3
+ model: haiku
4
+ effort: light
5
+ description: A valid fixture skill for parser tests.
6
+ ---
7
+
8
+ # Valid Skill
9
+
10
+ → verify: echo OK
11
+
12
+ See skills/other-skill/SKILL.md for handoff reference.
13
+
14
+ Run plan-work after survey-context.