From 0baaff9126f06a54e399f50b2608d2432c011997 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Fri, 12 Jun 2026 08:37:48 +0200 Subject: [PATCH 01/15] refactor: extract mcp schemas and descriptions to schemas.ts --- lib/graph/tool-handlers.ts | 54 ----- lib/mcp/create-server.ts | 352 ++--------------------------- lib/mcp/schemas.ts | 451 +++++++++++++++++++++++++++++++++++++ 3 files changed, 466 insertions(+), 391 deletions(-) create mode 100644 lib/mcp/schemas.ts diff --git a/lib/graph/tool-handlers.ts b/lib/graph/tool-handlers.ts index 2a15ba75..e2937510 100644 --- a/lib/graph/tool-handlers.ts +++ b/lib/graph/tool-handlers.ts @@ -673,60 +673,6 @@ function translateError(e: unknown): ToolResult { return fail(verbose && e instanceof Error ? e.message : "Internal error"); } -// --------------------------------------------------------------------------- -// Shared descriptions (MCP tools are ground truth) -// -// Tool descriptions are loaded on every agent turn — every word is paid -// N×turns. Each line below earns its place: purpose, per-action steering, -// a critical limitation or rule, the next-call cue. Doctrine (tag -// taxonomy, AC quality, category vocab, full lifecycle table, persona) -// lives in the skill's reference files; the server steers the agent -// toward the right rule rather than restating it. -// --------------------------------------------------------------------------- - -/** Tool descriptions shared between MCP and web app. */ -export const DESCRIPTIONS = { - mymir_project: - "List, create, and update projects, plus enumerate team memberships. Spans every team the caller belongs to; no server-side session state, so pass projectId explicitly on every downstream call. " + - "list=projects (id, title, identifier, status, team chip, task counts, progress); skips empty teams; description and tag vocab fetched on demand via mymir_query type='meta'. " + - "teams=every membership (id, name, slug, role, projectCount); call before create or when list misses a team. " + - "select=confirm working project; pass returned projectId on every subsequent call. " + - "create=new project; multi-team accounts MUST pass organizationId (server rejects ambiguous calls with the team list inline; auto-resolves single-team). " + - "update=title, description, status, categories, or identifier. Renaming identifier cascades every taskRef and breaks external references (PR titles, docs, commits).", - mymir_task: - "Create, update, or delete tasks. Lifecycle: draft → planned → in_progress → in_review → done. The implementer subagent's terminal write is `in_review` (PR opened, tests green); the HOTL gate flips to `done` after PR approval. cancelled is terminal abandoned work with transparent dep semantics (dependents stay blocked through the cancelled task's own unsatisfied prereqs; populate executionRecord with rationale). " + - "create requires title (verb+noun, imperative), description (2-4 sentences; single-sentence rejected), 2-4 binary acceptanceCriteria, three tag dimensions (work-type, cross-cutting, tech), one project category. priority, estimate, and assigneeIds are first-class fields, not tags: priority (urgent / core / normal / backlog), estimate (Fibonacci story points 1/2/3/5/8/13), assigneeIds (array of team-member user UUIDs). After create: search precedents/coordinators by verb+noun+surface, wire mymir_edge, verify with mymir_query type='edges'. Bare tasks orphan from critical_path, downstream, depth='agent'. " + - "update: pass only changed fields. Array fields (acceptanceCriteria, decisions, files, assigneeIds) APPEND by default; overwriteArrays=true REPLACES them. Destructive, NO undo (history is an audit log); confirm with user first. " + - "delete: preview=true (default) shows impact; preview=false executes. Prefer status='cancelled' for abandoned scope so the rationale is preserved. " + - "Done means: executionRecord (3-5 sentences, what was built), decisions (CHOICE+WHY), files (every path), acceptanceCriteria evaluated. Open a PR if files non-empty; run mymir_analyze type='downstream' to propagate.", - mymir_edge: - "Create, update, or remove dependency edges between tasks. depends_on=source needs target's output (target must be done first). relates_to=informational link, neither blocks the other. Litmus test: removing the target makes the source impossible → depends_on; just makes it harder → relates_to. " + - "create: edge note REQUIRED and substantive; notes propagate to downstream agent context, and placeholders ('needed', 'depends') are rejected. Write it as a brief to the developer about to start the source task. " + - "update: change edgeType or note by edgeId. " + - "remove: by edgeId OR by sourceTaskId+targetTaskId+edgeType. " + - "Server rejects self-edges, duplicates, and cycles. On 'duplicate edge' (concurrent-write race): treat as success and verify with mymir_query type='edges'.", - mymir_query: - "Search and browse project data. Pick the slim tool first; reserve overview for unfamiliar projects. " + - "search=tasks by taskRef, title, or tag substring (case-insensitive, up to 20). Pass tags=[...] for exact tag match (OR-within); combine with `query` to AND-narrow. Pass category='...' for exact project-category match (closed vocabulary; unknown values rejected with the valid list inline); combines with query/tags via AND. Single-result responses include a state hint pointing to the right next call. " + - "list=every task in the project (slim, ordered by position). " + - "edges=relationships on one task (connected title, status, direction, note). " + - "meta=slim project metadata: header, description, status, categories, tag vocabulary (with usage counts), progress + status counts. No task list, no edges. Use this to look up categories before setting one, or the tag vocabulary before coining new tags. " + - "overview=full project structure: every task, every edge, full tag vocab, progress. VERY HEAVY. Reserve for unfamiliar-project orientation, decompose's pre-write coverage check, or strategic review. At most once per session. For just categories or tag vocab, use meta.", - mymir_context: - "Retrieve task context at varying depth. ALWAYS fetch context before reasoning about a task; pick the lightest depth that answers the question. " + - "summary=task header + description + counts (criteria, decisions, plan flag, edge counts) + full 1-hop edges WITH notes. The lightest depth that still carries edge notes; folds in what `mymir_query type='edges'` would give. " + - "working=detailed (criteria, decisions, 1-hop edges) for refinement and review. " + - "agent=multi-hop dependency chains with upstream execution records (~4-8K tokens); fetch BEFORE coding. " + - "planning=spec-focused (project description, prereqs, acceptance criteria, downstream specs); fetch BEFORE writing the implementation plan.", - mymir_analyze: - "Analyze the project dependency graph. All variants slim; lead with these for status, prioritization, 'what's next', 'what's stuck'. " + - "critical_path=longest dep chain (project bottleneck, minimum duration). Lead with this on continue / resume / 'guide me forward'; the most important type for prioritization. " + - "ready=planned tasks with all effective deps done (only `status='planned'` reaches this state; drafts with satisfied deps surface as `plannable`, not `ready`). Pick from `ready ∩ critical_path` for the highest-impact unblocked work. " + - "plannable=draft tasks with description + criteria, ready for planning. Fall back here when nothing is ready to code. " + - "blocked=tasks waiting on unfinished deps with blocker details. " + - "downstream=transitive dependents of one task; impact analysis before status change, refinement, or cancellation.", -} as const; - // --------------------------------------------------------------------------- // Param types // --------------------------------------------------------------------------- diff --git a/lib/mcp/create-server.ts b/lib/mcp/create-server.ts index 5be19387..a0a201db 100644 --- a/lib/mcp/create-server.ts +++ b/lib/mcp/create-server.ts @@ -1,7 +1,5 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod/v4"; import { - DESCRIPTIONS, handleProject, handleTask, handleEdge, @@ -10,36 +8,17 @@ import { handleAnalyze, } from "@/lib/graph/tool-handlers"; import type { ToolResult } from "@/lib/graph/tool-handlers"; -import { identifierSchema } from "@/lib/graph/identifier"; +import { + DESCRIPTIONS, + projectInputSchema, + taskInputSchema, + edgeInputSchema, + queryInputSchema, + contextInputSchema, + analyzeInputSchema, +} from "@/lib/mcp/schemas"; import type { AuthContext } from "@/lib/auth/context"; -/** - * Per-field anti-abuse ceilings for MCP tool inputs. These are deliberately - * GENEROUS, not content policy: agents legitimately write long unabridged - * implementation plans and execution records (the tool instructions say "do - * not summarize"), so the caps sit far above any real payload and exist only - * to stop a single field carrying tens of megabytes. The operative bound on - * total cost is the request body-size limit on the `/api/mcp` route - * (`MAX_MCP_BODY_BYTES`) — these field caps are defense in depth so no one - * field can consume the whole budget. - */ -const LIMITS = { - title: 1_000, - description: 100_000, - plan: 1_000_000, - executionRecord: 500_000, - decision: 50_000, - criterionText: 50_000, - edgeNote: 50_000, - tag: 200, - category: 200, - filePath: 4_000, - query: 2_000, - arrayItems: 1_000, - files: 5_000, - tags: 500, -} as const; - /** * Format a successful tool result as MCP content. * @param data - Result data from a tool handler. @@ -217,55 +196,7 @@ export function registerAllTools(server: McpServer, ctx: AuthContext): void { "mymir_project", { description: DESCRIPTIONS.mymir_project, - inputSchema: z.object({ - action: z - .enum(["list", "teams", "create", "select", "update"]) - .describe( - "list=projects across every team you belong to (id, title, identifier, status, team chip, task counts, progress); skips empty teams; description and tag vocab fetched on demand via mymir_query type='meta'. teams=every membership (id, name, slug, role, projectCount); call before create or when list misses a team. create=new project (requires organizationId in multi-team accounts). select=confirm working project (returns projectId). update=modify fields.", - ), - projectId: z - .uuid() - .optional() - .describe("Project UUID. Required for select and update."), - title: z - .string() - .max(LIMITS.title) - .optional() - .describe( - "Project name (2-5 words, verb-noun preferred). Required for create.", - ), - description: z - .string() - .max(LIMITS.description) - .optional() - .describe( - "3-5 sentence brief: problem, user, features, tech direction, constraints.", - ), - status: z - .enum(["brainstorming", "decomposing", "active", "archived"]) - .optional() - .describe( - "Lifecycle: brainstorming → decomposing → active → archived. Settable on create (defaults to 'brainstorming') or update.", - ), - categories: z - .array(z.string().max(LIMITS.category)) - .max(LIMITS.arrayItems) - .optional() - .describe( - "Task categories for this project (e.g. ['backend', 'frontend', 'mcp']). Drives drawer grouping in the UI.", - ), - identifier: identifierSchema - .optional() - .describe( - "Project prefix for task refs (e.g. 'MYM' yields MYM-1, MYM-2, ...). 2-12 chars, uppercase alphanumeric, unique per team. Auto-derived from title on create when omitted. On update: renames every existing task ref; external references (PR titles, docs) no longer resolve.", - ), - organizationId: z - .uuid() - .optional() - .describe( - "Target team UUID for create. REQUIRED when you're a member of more than one team; the create is rejected with the team list inline otherwise. Auto-resolved when you belong to exactly one team. Membership is verified server-side; non-member targets return 'forbidden'.", - ), - }), + inputSchema: projectInputSchema, annotations: { title: "Manage Project", readOnlyHint: false, @@ -304,157 +235,7 @@ export function registerAllTools(server: McpServer, ctx: AuthContext): void { "mymir_task", { description: DESCRIPTIONS.mymir_task, - inputSchema: z.object({ - action: z - .enum(["create", "update", "delete"]) - .describe( - "create=new task. update=modify fields (pass only what changed). delete=remove (preview by default).", - ), - taskId: z - .uuid() - .optional() - .describe( - "Task UUID (not the 'MYM-N' taskRef; refs are display-only). Required for update/delete.", - ), - projectId: z - .uuid() - .optional() - .describe( - "Project UUID. Required for create. Project's team scope is inherited.", - ), - title: z - .string() - .max(LIMITS.title) - .optional() - .describe( - "Verb+noun, imperative. Required for create (e.g. 'Implement JWT auth', not 'Auth'). Artifacts §1.", - ), - description: z - .string() - .max(LIMITS.description) - .optional() - .describe( - "2-4 sentences (up to 6-8 for genuinely complex tasks; single-sentence rejected): what + who it serves + where it fits in the architecture. Required for create. Artifacts §1.", - ), - status: z - .enum([ - "draft", - "planned", - "in_progress", - "in_review", - "done", - "cancelled", - ]) - .optional() - .describe( - "Lifecycle: draft → planned → in_progress → in_review → done. The implementer subagent's terminal write is `in_review` (PR opened, tests green); the HOTL gate flips to `done` after PR approval. cancelled = terminal abandoned work; populate executionRecord with rationale. Cancelled deps are transparent: dependents stay blocked through the cancelled task's own unsatisfied deps. Excluded from progress and critical path.", - ), - acceptanceCriteria: z - .array( - z.union([ - z.string().max(LIMITS.criterionText), - z.object({ - id: z.string().max(LIMITS.tag).optional(), - text: z.string().max(LIMITS.criterionText), - checked: z.boolean().optional(), - }), - ]), - ) - .max(LIMITS.arrayItems) - .optional() - .describe( - "2-4 binary items (reviewer answers YES/NO; single-AC and vague ACs like 'works correctly' rejected). Pass strings for new criteria, or {text, checked} objects to evaluate existing rows. Artifacts §1.", - ), - decisions: z - .array(z.string().max(LIMITS.decision)) - .max(LIMITS.arrayItems) - .optional() - .describe( - "Technical choices and constraints. One-liner per decision (CHOICE + WHY).", - ), - tags: z - .array(z.string().max(LIMITS.tag)) - .max(LIMITS.tags) - .optional() - .describe( - "Kebab-case. Every task carries three tag dimensions: exactly 1 work-type (bug/feature/refactor/docs/test/chore/perf), ≥1 cross-cutting concern (open: quality attribute or feature cluster), at most 2 tech tags (most important stack pieces touched). Priority is the `priority` field, not a tag. Do NOT tag codebase area (use category) or status. Run mymir_query type='meta' before coining new tags.", - ), - category: z - .string() - .max(LIMITS.category) - .optional() - .describe( - "Architectural layer / subsystem this task belongs to (exactly one). Reuse a project category; do not silently coin mid-task. The project's 4-8 categories are set on creation or via decompose/onboarding gates. Run mymir_query type='meta' to see them. Artifacts §4.", - ), - priority: z - .enum(["urgent", "core", "normal", "backlog"]) - .optional() - .describe( - "Priority of the task. urgent: cannot ship without; core: central to the release; normal: routine; backlog: deprioritized.", - ), - estimate: z - .union([ - z.literal(1), - z.literal(2), - z.literal(3), - z.literal(5), - z.literal(8), - z.literal(13), - ]) - .optional() - .describe( - "Fibonacci story-point estimate. 1 = trivial, 2/3 = routine, 5 = nontrivial, 8/13 = risky or multi-day. If a task feels >13, split it (artifacts §5).", - ), - assigneeIds: z - .array(z.uuid()) - .max(LIMITS.arrayItems) - .optional() - .describe( - "User UUIDs to assign to this task. Each must be a member of the project's owning team; non-members are rejected. The single-worker `in_progress` invariant still applies; assignees declare ownership / intent, not concurrent claim. APPENDS by default on update; `overwriteArrays=true` REPLACES the full set.", - ), - files: z - .array(z.string().max(LIMITS.filePath)) - .max(LIMITS.files) - .optional() - .describe( - "Repo-relative paths created or modified (no leading slash, no absolute). Pass `files=[]` when nothing was touched (unscaffolded repo, research/spec-review/decision-only); never invent paths.", - ), - implementationPlan: z - .string() - .max(LIMITS.plan) - .optional() - .describe( - "Implementation plan (markdown, unabridged; do not summarize). Pass with `status='planned'` to transition draft → planned; without the status change the task stays incomplete (lifecycle §1).", - ), - executionRecord: z - .string() - .max(LIMITS.executionRecord) - .optional() - .describe( - "3-5 sentences on HOW it was built (function names, file paths, endpoints; distinct from description=scope). For cancelled: rationale + what was tried instead. Draft tasks must not carry this. Iron Law: cite real code, omit what you cannot. Markdown. Artifacts §1.", - ), - prUrl: z - .url() - .nullable() - .optional() - .describe( - "PR URL for this task's code change. Sugar field that upserts a `task_links` row with kind derived from the URL classifier (`pull_request` for github.com/.../pull/N, gitlab.com/.../merge_requests/N). Pass alongside `status='in_review'` in the Completion Protocol payload; the composer-implementer subagent writes this in the same call as executionRecord/decisions/files/acceptanceCriteria. Pass `null` to remove an existing PR link. Other link kinds (issues, commits, docs) are user-managed via the UI; only PRs are agent-write today.", - ), - preview: z - .boolean() - .optional() - .default(true) - .describe( - "Delete only: true=show impact (default), false=actually delete.", - ), - overwriteArrays: z - .boolean() - .optional() - .default(false) - .describe( - "Update only. true=replace decisions/acceptanceCriteria/files; default false=append. Destructive, NO undo; confirm with user first.", - ), - }), + inputSchema: taskInputSchema, annotations: { title: "Manage Task", readOnlyHint: false, @@ -477,44 +258,7 @@ export function registerAllTools(server: McpServer, ctx: AuthContext): void { "mymir_edge", { description: DESCRIPTIONS.mymir_edge, - inputSchema: z.object({ - action: z - .enum(["create", "update", "remove"]) - .describe( - "create=new edge. update=modify type or note. remove=delete by edgeId or by source+target+type.", - ), - edgeId: z - .uuid() - .optional() - .describe( - "Edge UUID. Required for update. For remove: use this OR sourceTaskId+targetTaskId+edgeType.", - ), - sourceTaskId: z - .uuid() - .optional() - .describe( - "Source task UUID. Required for create. Alternative key for remove.", - ), - targetTaskId: z - .uuid() - .optional() - .describe( - "Target task UUID. Required for create. Alternative key for remove.", - ), - edgeType: z - .enum(["depends_on", "relates_to"]) - .optional() - .describe( - "depends_on = source needs target done first. relates_to = informational link, neither blocks the other. Required for create.", - ), - note: z - .string() - .max(LIMITS.edgeNote) - .optional() - .describe( - "Why this relationship exists. Propagates to agent context for downstream tasks, so write it as a brief to the developer about to start the source task: what specifically does this task get from the target? REQUIRED on create; placeholders ('needed', 'depends', 'related') are rejected.", - ), - }), + inputSchema: edgeInputSchema, annotations: { title: "Manage Edge", readOnlyHint: false, @@ -537,39 +281,7 @@ export function registerAllTools(server: McpServer, ctx: AuthContext): void { "mymir_query", { description: DESCRIPTIONS.mymir_query, - inputSchema: z.object({ - type: z - .enum(["search", "list", "edges", "meta", "overview"]) - .describe( - "search=find tasks by taskRef, title, or tag (case-insensitive, up to 20). list=all tasks ordered by position. edges=relationships on a task. meta=slim project metadata (header, categories, tag vocab with counts, progress); use to look up categories or tag vocab without overview. overview=full project structure with progress + tag vocab + every task + every edge.", - ), - query: z - .string() - .max(LIMITS.query) - .optional() - .describe( - "Search string for type='search'. Matches taskRef, title substring, or tag substring. Optional when `tags` is provided.", - ), - tags: z - .array(z.string().max(LIMITS.tag)) - .max(LIMITS.tags) - .optional() - .describe( - "Filter to tasks containing ANY of these exact tags (OR-within). Combine with `query` to narrow further. Pick from the tag vocabulary in `type='meta'`.", - ), - category: z - .string() - .max(LIMITS.category) - .optional() - .describe( - "Filter to tasks in exactly this category (AND with `query`/`tags`). Must be one of the project's categories (closed vocabulary); unknown values are rejected. Run mymir_query type='meta' for the current list.", - ), - taskId: z.uuid().optional().describe("Task UUID for type='edges'."), - projectId: z - .uuid() - .optional() - .describe("Project UUID. Required for search/list/meta/overview."), - }), + inputSchema: queryInputSchema, annotations: { title: "Query Tasks", readOnlyHint: true, @@ -592,19 +304,7 @@ export function registerAllTools(server: McpServer, ctx: AuthContext): void { "mymir_context", { description: DESCRIPTIONS.mymir_context, - inputSchema: z.object({ - taskId: z.uuid().describe("Task UUID."), - depth: z - .enum(["summary", "working", "agent", "planning", "review"]) - .default("working") - .describe( - "summary=task header + description + counts + 1-hop edges with notes (folds in `mymir_query type='edges'`). working=criteria, decisions, 1-hop edges (both depends_on and relates_to, both directions, with notes) — does NOT render executionRecord, files, or implementationPlan. agent=multi-hop deps + upstream execution records + files + downstream; renders the task's own executionRecord when status is done/cancelled (use BEFORE coding, and to read a finished task's record). planning=project description, prereqs, ACs, downstream specs (use BEFORE writing the implementation plan). review=in_review review bundle: implementationPlan alongside executionRecord, PR link surfaced, plan-vs-files drift, AC evaluation, downstream impact, review-lens prompts (security / perf / reliability / observability / codebase standards). The review subagent reads this depth.", - ), - projectId: z - .uuid() - .optional() - .describe("Project UUID. Required for 'working' depth."), - }), + inputSchema: contextInputSchema, annotations: { title: "Get Task Context", readOnlyHint: true, @@ -627,29 +327,7 @@ export function registerAllTools(server: McpServer, ctx: AuthContext): void { "mymir_analyze", { description: DESCRIPTIONS.mymir_analyze, - inputSchema: z.object({ - type: z - .enum([ - "ready", - "blocked", - "downstream", - "critical_path", - "plannable", - ]) - .describe( - "ready=planned tasks with all deps done (drafts with deps satisfied surface as plannable, not ready). blocked=waiting tasks with blocker details. downstream=transitive dependents (impact analysis before changes). critical_path=longest dep chain (project bottleneck). plannable=draft tasks with description+criteria, ready for planning.", - ), - taskId: z - .uuid() - .optional() - .describe("Task UUID. Required for 'downstream'."), - projectId: z - .uuid() - .optional() - .describe( - "Project UUID. Required for ready/blocked/critical_path/plannable.", - ), - }), + inputSchema: analyzeInputSchema, annotations: { title: "Analyze Graph", readOnlyHint: true, diff --git a/lib/mcp/schemas.ts b/lib/mcp/schemas.ts new file mode 100644 index 00000000..6a02a443 --- /dev/null +++ b/lib/mcp/schemas.ts @@ -0,0 +1,451 @@ +/** + * MCP tool input schemas, descriptions, and metadata for the 6 Mymir tools. + * + * Standalone module with no data-layer imports so it can be consumed by + * both the MCP server (lib/mcp/create-server.ts) and the docs generator + * (scripts/generate-docs.ts). + */ +import { z } from "zod/v4"; +import { identifierSchema } from "@/lib/graph/identifier"; + +/** + * Per-field anti-abuse ceilings for MCP tool inputs. These are deliberately + * GENEROUS, not content policy: agents legitimately write long unabridged + * implementation plans and execution records (the tool instructions say "do + * not summarize"), so the caps sit far above any real payload and exist only + * to stop a single field carrying tens of megabytes. The operative bound on + * total cost is the request body-size limit on the `/api/mcp` route + * (`MAX_MCP_BODY_BYTES`) — these field caps are defense in depth so no one + * field can consume the whole budget. + */ +export const LIMITS = { + title: 1_000, + description: 100_000, + plan: 1_000_000, + executionRecord: 500_000, + decision: 50_000, + criterionText: 50_000, + edgeNote: 50_000, + tag: 200, + category: 200, + filePath: 4_000, + query: 2_000, + arrayItems: 1_000, + files: 5_000, + tags: 500, +} as const; + +// --------------------------------------------------------------------------- +// Shared descriptions (MCP tools are ground truth) +// +// Tool descriptions are loaded on every agent turn — every word is paid +// N×turns. Each line below earns its place: purpose, per-action steering, +// a critical limitation or rule, the next-call cue. Doctrine (tag +// taxonomy, AC quality, category vocab, full lifecycle table, persona) +// lives in the skill's reference files; the server steers the agent +// toward the right rule rather than restating it. +// --------------------------------------------------------------------------- + +/** Tool descriptions shared between MCP and web app. */ +export const DESCRIPTIONS = { + mymir_project: + "List, create, and update projects, plus enumerate team memberships. Spans every team the caller belongs to; no server-side session state, so pass projectId explicitly on every downstream call. " + + "list=projects (id, title, identifier, status, team chip, task counts, progress); skips empty teams; description and tag vocab fetched on demand via mymir_query type='meta'. " + + "teams=every membership (id, name, slug, role, projectCount); call before create or when list misses a team. " + + "select=confirm working project; pass returned projectId on every subsequent call. " + + "create=new project; multi-team accounts MUST pass organizationId (server rejects ambiguous calls with the team list inline; auto-resolves single-team). " + + "update=title, description, status, categories, or identifier. Renaming identifier cascades every taskRef and breaks external references (PR titles, docs, commits).", + mymir_task: + "Create, update, or delete tasks. Lifecycle: draft → planned → in_progress → in_review → done. The implementer subagent's terminal write is `in_review` (PR opened, tests green); the HOTL gate flips to `done` after PR approval. cancelled is terminal abandoned work with transparent dep semantics (dependents stay blocked through the cancelled task's own unsatisfied prereqs; populate executionRecord with rationale). " + + "create requires title (verb+noun, imperative), description (2-4 sentences; single-sentence rejected), 2-4 binary acceptanceCriteria, three tag dimensions (work-type, cross-cutting, tech), one project category. priority, estimate, and assigneeIds are first-class fields, not tags: priority (urgent / core / normal / backlog), estimate (Fibonacci story points 1/2/3/5/8/13), assigneeIds (array of team-member user UUIDs). After create: search precedents/coordinators by verb+noun+surface, wire mymir_edge, verify with mymir_query type='edges'. Bare tasks orphan from critical_path, downstream, depth='agent'. " + + "update: pass only changed fields. Array fields (acceptanceCriteria, decisions, files, assigneeIds) APPEND by default; overwriteArrays=true REPLACES them. Destructive, NO undo (history is an audit log); confirm with user first. " + + "delete: preview=true (default) shows impact; preview=false executes. Prefer status='cancelled' for abandoned scope so the rationale is preserved. " + + "Done means: executionRecord (3-5 sentences, what was built), decisions (CHOICE+WHY), files (every path), acceptanceCriteria evaluated. Open a PR if files non-empty; run mymir_analyze type='downstream' to propagate.", + mymir_edge: + "Create, update, or remove dependency edges between tasks. depends_on=source needs target's output (target must be done first). relates_to=informational link, neither blocks the other. Litmus test: removing the target makes the source impossible → depends_on; just makes it harder → relates_to. " + + "create: edge note REQUIRED and substantive; notes propagate to downstream agent context, and placeholders ('needed', 'depends') are rejected. Write it as a brief to the developer about to start the source task. " + + "update: change edgeType or note by edgeId. " + + "remove: by edgeId OR by sourceTaskId+targetTaskId+edgeType. " + + "Server rejects self-edges, duplicates, and cycles. On 'duplicate edge' (concurrent-write race): treat as success and verify with mymir_query type='edges'.", + mymir_query: + "Search and browse project data. Pick the slim tool first; reserve overview for unfamiliar projects. " + + "search=tasks by taskRef, title, or tag substring (case-insensitive, up to 20). Pass tags=[...] for exact tag match (OR-within); combine with `query` to AND-narrow. Pass category='...' for exact project-category match (closed vocabulary; unknown values rejected with the valid list inline); combines with query/tags via AND. Single-result responses include a state hint pointing to the right next call. " + + "list=every task in the project (slim, ordered by position). " + + "edges=relationships on one task (connected title, status, direction, note). " + + "meta=slim project metadata: header, description, status, categories, tag vocabulary (with usage counts), progress + status counts. No task list, no edges. Use this to look up categories before setting one, or the tag vocabulary before coining new tags. " + + "overview=full project structure: every task, every edge, full tag vocab, progress. VERY HEAVY. Reserve for unfamiliar-project orientation, decompose's pre-write coverage check, or strategic review. At most once per session. For just categories or tag vocab, use meta.", + mymir_context: + "Retrieve task context at varying depth. ALWAYS fetch context before reasoning about a task; pick the lightest depth that answers the question. " + + "summary=task header + description + counts (criteria, decisions, plan flag, edge counts) + full 1-hop edges WITH notes. The lightest depth that still carries edge notes; folds in what `mymir_query type='edges'` would give. " + + "working=detailed (criteria, decisions, 1-hop edges) for refinement and review. " + + "agent=multi-hop dependency chains with upstream execution records (~4-8K tokens); fetch BEFORE coding. " + + "planning=spec-focused (project description, prereqs, acceptance criteria, downstream specs); fetch BEFORE writing the implementation plan.", + mymir_analyze: + "Analyze the project dependency graph. All variants slim; lead with these for status, prioritization, 'what's next', 'what's stuck'. " + + "critical_path=longest dep chain (project bottleneck, minimum duration). Lead with this on continue / resume / 'guide me forward'; the most important type for prioritization. " + + "ready=planned tasks with all effective deps done (only `status='planned'` reaches this state; drafts with satisfied deps surface as `plannable`, not `ready`). Pick from `ready ∩ critical_path` for the highest-impact unblocked work. " + + "plannable=draft tasks with description + criteria, ready for planning. Fall back here when nothing is ready to code. " + + "blocked=tasks waiting on unfinished deps with blocker details. " + + "downstream=transitive dependents of one task; impact analysis before status change, refinement, or cancellation.", +} as const; + +export const projectInputSchema = z.object({ + action: z + .enum(["list", "teams", "create", "select", "update"]) + .describe( + "list=projects across every team you belong to (id, title, identifier, status, team chip, task counts, progress); skips empty teams; description and tag vocab fetched on demand via mymir_query type='meta'. teams=every membership (id, name, slug, role, projectCount); call before create or when list misses a team. create=new project (requires organizationId in multi-team accounts). select=confirm working project (returns projectId). update=modify fields.", + ), + projectId: z + .uuid() + .optional() + .describe("Project UUID. Required for select and update."), + title: z + .string() + .max(LIMITS.title) + .optional() + .describe( + "Project name (2-5 words, verb-noun preferred). Required for create.", + ), + description: z + .string() + .max(LIMITS.description) + .optional() + .describe( + "3-5 sentence brief: problem, user, features, tech direction, constraints.", + ), + status: z + .enum(["brainstorming", "decomposing", "active", "archived"]) + .optional() + .describe( + "Lifecycle: brainstorming → decomposing → active → archived. Settable on create (defaults to 'brainstorming') or update.", + ), + categories: z + .array(z.string().max(LIMITS.category)) + .max(LIMITS.arrayItems) + .optional() + .describe( + "Task categories for this project (e.g. ['backend', 'frontend', 'mcp']). Drives drawer grouping in the UI.", + ), + identifier: identifierSchema + .optional() + .describe( + "Project prefix for task refs (e.g. 'MYM' yields MYM-1, MYM-2, ...). 2-12 chars, uppercase alphanumeric, unique per team. Auto-derived from title on create when omitted. On update: renames every existing task ref; external references (PR titles, docs) no longer resolve.", + ), + organizationId: z + .uuid() + .optional() + .describe( + "Target team UUID for create. REQUIRED when you're a member of more than one team; the create is rejected with the team list inline otherwise. Auto-resolved when you belong to exactly one team. Membership is verified server-side; non-member targets return 'forbidden'.", + ), +}); + +export const taskInputSchema = z.object({ + action: z + .enum(["create", "update", "delete"]) + .describe( + "create=new task. update=modify fields (pass only what changed). delete=remove (preview by default).", + ), + taskId: z + .uuid() + .optional() + .describe( + "Task UUID (not the 'MYM-N' taskRef; refs are display-only). Required for update/delete.", + ), + projectId: z + .uuid() + .optional() + .describe( + "Project UUID. Required for create. Project's team scope is inherited.", + ), + title: z + .string() + .max(LIMITS.title) + .optional() + .describe( + "Verb+noun, imperative. Required for create (e.g. 'Implement JWT auth', not 'Auth'). Artifacts §1.", + ), + description: z + .string() + .max(LIMITS.description) + .optional() + .describe( + "2-4 sentences (up to 6-8 for genuinely complex tasks; single-sentence rejected): what + who it serves + where it fits in the architecture. Required for create. Artifacts §1.", + ), + status: z + .enum([ + "draft", + "planned", + "in_progress", + "in_review", + "done", + "cancelled", + ]) + .optional() + .describe( + "Lifecycle: draft → planned → in_progress → in_review → done. The implementer subagent's terminal write is `in_review` (PR opened, tests green); the HOTL gate flips to `done` after PR approval. cancelled = terminal abandoned work; populate executionRecord with rationale. Cancelled deps are transparent: dependents stay blocked through the cancelled task's own unsatisfied deps. Excluded from progress and critical path.", + ), + acceptanceCriteria: z + .array( + z.union([ + z.string().max(LIMITS.criterionText), + z.object({ + id: z.string().max(LIMITS.tag).optional(), + text: z.string().max(LIMITS.criterionText), + checked: z.boolean().optional(), + }), + ]), + ) + .max(LIMITS.arrayItems) + .optional() + .describe( + "2-4 binary items (reviewer answers YES/NO; single-AC and vague ACs like 'works correctly' rejected). Pass strings for new criteria, or {text, checked} objects to evaluate existing rows. Artifacts §1.", + ), + decisions: z + .array(z.string().max(LIMITS.decision)) + .max(LIMITS.arrayItems) + .optional() + .describe( + "Technical choices and constraints. One-liner per decision (CHOICE + WHY).", + ), + tags: z + .array(z.string().max(LIMITS.tag)) + .max(LIMITS.tags) + .optional() + .describe( + "Kebab-case. Every task carries three tag dimensions: exactly 1 work-type (bug/feature/refactor/docs/test/chore/perf), ≥1 cross-cutting concern (open: quality attribute or feature cluster), at most 2 tech tags (most important stack pieces touched). Priority is the `priority` field, not a tag. Do NOT tag codebase area (use category) or status. Run mymir_query type='meta' before coining new tags.", + ), + category: z + .string() + .max(LIMITS.category) + .optional() + .describe( + "Architectural layer / subsystem this task belongs to (exactly one). Reuse a project category; do not silently coin mid-task. The project's 4-8 categories are set on creation or via decompose/onboarding gates. Run mymir_query type='meta' to see them. Artifacts §4.", + ), + priority: z + .enum(["urgent", "core", "normal", "backlog"]) + .optional() + .describe( + "Priority of the task. urgent: cannot ship without; core: central to the release; normal: routine; backlog: deprioritized.", + ), + estimate: z + .union([ + z.literal(1), + z.literal(2), + z.literal(3), + z.literal(5), + z.literal(8), + z.literal(13), + ]) + .optional() + .describe( + "Fibonacci story-point estimate. 1 = trivial, 2/3 = routine, 5 = nontrivial, 8/13 = risky or multi-day. If a task feels >13, split it (artifacts §5).", + ), + assigneeIds: z + .array(z.uuid()) + .max(LIMITS.arrayItems) + .optional() + .describe( + "User UUIDs to assign to this task. Each must be a member of the project's owning team; non-members are rejected. The single-worker `in_progress` invariant still applies; assignees declare ownership / intent, not concurrent claim. APPENDS by default on update; `overwriteArrays=true` REPLACES the full set.", + ), + files: z + .array(z.string().max(LIMITS.filePath)) + .max(LIMITS.files) + .optional() + .describe( + "Repo-relative paths created or modified (no leading slash, no absolute). Pass `files=[]` when nothing was touched (unscaffolded repo, research/spec-review/decision-only); never invent paths.", + ), + implementationPlan: z + .string() + .max(LIMITS.plan) + .optional() + .describe( + "Implementation plan (markdown, unabridged; do not summarize). Pass with `status='planned'` to transition draft → planned; without the status change the task stays incomplete (lifecycle §1).", + ), + executionRecord: z + .string() + .max(LIMITS.executionRecord) + .optional() + .describe( + "3-5 sentences on HOW it was built (function names, file paths, endpoints; distinct from description=scope). For cancelled: rationale + what was tried instead. Draft tasks must not carry this. Iron Law: cite real code, omit what you cannot. Markdown. Artifacts §1.", + ), + prUrl: z + .url() + .nullable() + .optional() + .describe( + "PR URL for this task's code change. Sugar field that upserts a `task_links` row with kind derived from the URL classifier (`pull_request` for github.com/.../pull/N, gitlab.com/.../merge_requests/N). Pass alongside `status='in_review'` in the Completion Protocol payload; the composer-implementer subagent writes this in the same call as executionRecord/decisions/files/acceptanceCriteria. Pass `null` to remove an existing PR link. Other link kinds (issues, commits, docs) are user-managed via the UI; only PRs are agent-write today.", + ), + preview: z + .boolean() + .optional() + .default(true) + .describe( + "Delete only: true=show impact (default), false=actually delete.", + ), + overwriteArrays: z + .boolean() + .optional() + .default(false) + .describe( + "Update only. true=replace decisions/acceptanceCriteria/files; default false=append. Destructive, NO undo; confirm with user first.", + ), +}); + +export const edgeInputSchema = z.object({ + action: z + .enum(["create", "update", "remove"]) + .describe( + "create=new edge. update=modify type or note. remove=delete by edgeId or by source+target+type.", + ), + edgeId: z + .uuid() + .optional() + .describe( + "Edge UUID. Required for update. For remove: use this OR sourceTaskId+targetTaskId+edgeType.", + ), + sourceTaskId: z + .uuid() + .optional() + .describe( + "Source task UUID. Required for create. Alternative key for remove.", + ), + targetTaskId: z + .uuid() + .optional() + .describe( + "Target task UUID. Required for create. Alternative key for remove.", + ), + edgeType: z + .enum(["depends_on", "relates_to"]) + .optional() + .describe( + "depends_on = source needs target done first. relates_to = informational link, neither blocks the other. Required for create.", + ), + note: z + .string() + .max(LIMITS.edgeNote) + .optional() + .describe( + "Why this relationship exists. Propagates to agent context for downstream tasks, so write it as a brief to the developer about to start the source task: what specifically does this task get from the target? REQUIRED on create; placeholders ('needed', 'depends', 'related') are rejected.", + ), +}); + +export const queryInputSchema = z.object({ + type: z + .enum(["search", "list", "edges", "meta", "overview"]) + .describe( + "search=find tasks by taskRef, title, or tag (case-insensitive, up to 20). list=all tasks ordered by position. edges=relationships on a task. meta=slim project metadata (header, categories, tag vocab with counts, progress); use to look up categories or tag vocab without overview. overview=full project structure with progress + tag vocab + every task + every edge.", + ), + query: z + .string() + .max(LIMITS.query) + .optional() + .describe( + "Search string for type='search'. Matches taskRef, title substring, or tag substring. Optional when `tags` is provided.", + ), + tags: z + .array(z.string().max(LIMITS.tag)) + .max(LIMITS.tags) + .optional() + .describe( + "Filter to tasks containing ANY of these exact tags (OR-within). Combine with `query` to narrow further. Pick from the tag vocabulary in `type='meta'`.", + ), + category: z + .string() + .max(LIMITS.category) + .optional() + .describe( + "Filter to tasks in exactly this category (AND with `query`/`tags`). Must be one of the project's categories (closed vocabulary); unknown values are rejected. Run mymir_query type='meta' for the current list.", + ), + taskId: z.uuid().optional().describe("Task UUID for type='edges'."), + projectId: z + .uuid() + .optional() + .describe("Project UUID. Required for search/list/meta/overview."), +}); + +export const contextInputSchema = z.object({ + taskId: z.uuid().describe("Task UUID."), + depth: z + .enum(["summary", "working", "agent", "planning", "review"]) + .default("working") + .describe( + "summary=task header + description + counts + 1-hop edges with notes (folds in `mymir_query type='edges'`). working=criteria, decisions, 1-hop edges (both depends_on and relates_to, both directions, with notes) — does NOT render executionRecord, files, or implementationPlan. agent=multi-hop deps + upstream execution records + files + downstream; renders the task's own executionRecord when status is done/cancelled (use BEFORE coding, and to read a finished task's record). planning=project description, prereqs, ACs, downstream specs (use BEFORE writing the implementation plan). review=in_review review bundle: implementationPlan alongside executionRecord, PR link surfaced, plan-vs-files drift, AC evaluation, downstream impact, review-lens prompts (security / perf / reliability / observability / codebase standards). The review subagent reads this depth.", + ), + projectId: z + .uuid() + .optional() + .describe("Project UUID. Required for 'working' depth."), +}); + +export const analyzeInputSchema = z.object({ + type: z + .enum([ + "ready", + "blocked", + "downstream", + "critical_path", + "plannable", + ]) + .describe( + "ready=planned tasks with all deps done (drafts with deps satisfied surface as plannable, not ready). blocked=waiting tasks with blocker details. downstream=transitive dependents (impact analysis before changes). critical_path=longest dep chain (project bottleneck). plannable=draft tasks with description+criteria, ready for planning.", + ), + taskId: z + .uuid() + .optional() + .describe("Task UUID. Required for 'downstream'."), + projectId: z + .uuid() + .optional() + .describe( + "Project UUID. Required for ready/blocked/critical_path/plannable.", + ), +}); + +/** One tool's docs-relevant surface. */ +export interface ToolDefinition { + name: string; + title: string; + description: string; + inputSchema: z.ZodObject; +} + +/** All 6 tools in registration order. Titles match the MCP annotations. */ +export const TOOLS: readonly ToolDefinition[] = [ + { + name: "mymir_project", + title: "Manage Project", + description: DESCRIPTIONS.mymir_project, + inputSchema: projectInputSchema, + }, + { + name: "mymir_task", + title: "Manage Task", + description: DESCRIPTIONS.mymir_task, + inputSchema: taskInputSchema, + }, + { + name: "mymir_edge", + title: "Manage Edge", + description: DESCRIPTIONS.mymir_edge, + inputSchema: edgeInputSchema, + }, + { + name: "mymir_query", + title: "Query Tasks", + description: DESCRIPTIONS.mymir_query, + inputSchema: queryInputSchema, + }, + { + name: "mymir_context", + title: "Get Task Context", + description: DESCRIPTIONS.mymir_context, + inputSchema: contextInputSchema, + }, + { + name: "mymir_analyze", + title: "Analyze Graph", + description: DESCRIPTIONS.mymir_analyze, + inputSchema: analyzeInputSchema, + }, +] as const; From b81b8dd418389f4a63b78f3184f7c0302e93df06 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Fri, 12 Jun 2026 08:43:46 +0200 Subject: [PATCH 02/15] feat: add docs generator tool page renderer --- scripts/generate-docs.ts | 156 ++++++++++++++++++++++++++++ tests/scripts/generate-docs.test.ts | 57 ++++++++++ 2 files changed, 213 insertions(+) create mode 100644 scripts/generate-docs.ts create mode 100644 tests/scripts/generate-docs.test.ts diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts new file mode 100644 index 00000000..a8e9f248 --- /dev/null +++ b/scripts/generate-docs.ts @@ -0,0 +1,156 @@ +/** + * docs:gen. Emits generated documentation into the mymir-docs content tree. + * + * Outputs: + * mcp/tools/.mdx + meta.json from lib/mcp/schemas.ts + * reference/.mdx synced mymir skill references + * reference/skills-and-agents.mdx catalog from plugin frontmatter + * + * Run: bun scripts/generate-docs.ts [--out ] + * Default out dir: ../mymir-docs/content/docs relative to the mymir repo. + */ +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { z } from "zod/v4"; +import { TOOLS, type ToolDefinition } from "../lib/mcp/schemas"; + +const GENERATED_NOTE = + "{/* Generated by `bun run docs:gen` in the mymir repo. Do not edit by hand. */}"; + +interface JsonSchemaProperty { + type?: string; + description?: string; + enum?: string[]; + items?: { type?: string; enum?: string[] }; + format?: string; +} + +interface JsonSchemaObject { + properties?: Record; + required?: string[]; +} + +/** + * Escape characters MDX would parse as JSX in prose positions. + * @param text - Raw prose. + * @returns Prose safe to embed in MDX body text. + */ +function escapeProse(text: string): string { + return text.replaceAll("<", "<").replaceAll("{", "{"); +} + +/** + * Escape prose for a markdown table cell. + * @param text - Raw prose. + * @returns Single-line, pipe-escaped, MDX-safe cell text. + */ +function escapeCell(text: string): string { + return escapeProse(text).replaceAll("|", "\\|").replaceAll("\n", " "); +} + +/** + * Quote a string for YAML frontmatter. + * @param text - Raw value. + * @returns Double-quoted YAML scalar. + */ +function yamlQuote(text: string): string { + return `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +} + +/** + * Render a JSON-schema property as a compact type expression. + * @param prop - JSON schema property. + * @returns Type string for a parameter table cell. + */ +function renderType(prop: JsonSchemaProperty): string { + if (prop.enum) return prop.enum.map((v) => `"${v}"`).join(" \\| "); + if (prop.type === "array") { + const items = prop.items; + if (items?.enum) return `(${items.enum.map((v) => `"${v}"`).join(" \\| ")})[]`; + return `${items?.type ?? "unknown"}[]`; + } + if (prop.format === "uuid") return "string (uuid)"; + return prop.type ?? "unknown"; +} + +/** + * Split an action enum's description into per-action purpose strings. + * The source format is "= ... = ...". + * @param description - The action field's .describe() text. + * @param actions - Enum values in declaration order. + * @returns One entry per action; purpose is "" when no marker is found. + */ +export function parseActions( + description: string, + actions: readonly string[], +): { action: string; purpose: string }[] { + return actions.map((action) => { + const marker = `${action}=`; + const start = description.indexOf(marker); + if (start === -1) return { action, purpose: "" }; + let end = description.length; + for (const other of actions) { + if (other === action) continue; + const idx = description.indexOf(`${other}=`, start + marker.length); + if (idx !== -1 && idx < end) end = idx; + } + const purpose = description + .slice(start + marker.length, end) + .trim() + .replace(/[.;,]\s*$/, ""); + return { action, purpose }; + }); +} + +/** + * Render one MCP tool reference page as MDX. + * @param tool - Tool definition from lib/mcp/schemas.ts. + * @returns Complete MDX document text. + */ +export function renderToolPage(tool: ToolDefinition): string { + const schema = z.toJSONSchema(tool.inputSchema) as JsonSchemaObject; + const props = schema.properties ?? {}; + const required = new Set(schema.required ?? []); + const actionProp = props.action; + const actions = actionProp?.enum ?? []; + const firstSentence = `${tool.description.split(". ")[0]}.`; + + const paramNames = Object.keys(props).sort((a, b) => { + if (a === "action") return -1; + if (b === "action") return 1; + return a.localeCompare(b); + }); + const paramRows = paramNames.map((name) => { + const prop = props[name]; + const req = required.has(name) ? "Yes" : "No"; + return `| \`${name}\` | \`${renderType(prop)}\` | ${req} | ${escapeCell(prop.description ?? "")} |`; + }); + + const actionRows = parseActions(actionProp?.description ?? "", actions).map( + ({ action, purpose }) => `| \`${action}\` | ${escapeCell(purpose)} |`, + ); + + return `--- +title: ${tool.name} +description: ${yamlQuote(firstSentence)} +--- + +${GENERATED_NOTE} + +# ${tool.name} + +${escapeProse(tool.description)} + +## Actions + +| Action | Purpose | +|---|---| +${actionRows.join("\n")} + +## Parameters + +| Name | Type | Required | Description | +|---|---|---|---| +${paramRows.join("\n")} +`; +} diff --git a/tests/scripts/generate-docs.test.ts b/tests/scripts/generate-docs.test.ts new file mode 100644 index 00000000..d8bc05ca --- /dev/null +++ b/tests/scripts/generate-docs.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { parseActions, renderToolPage } from "../../scripts/generate-docs"; +import { TOOLS } from "../../lib/mcp/schemas"; + +describe("TOOLS", () => { + test("exposes all six tools", () => { + expect(TOOLS.map((t) => t.name)).toEqual([ + "mymir_project", + "mymir_task", + "mymir_edge", + "mymir_query", + "mymir_context", + "mymir_analyze", + ]); + }); +}); + +describe("parseActions", () => { + test("splits per-action purpose text", () => { + const desc = "list=all projects. teams=memberships. create=new project."; + const parsed = parseActions(desc, ["list", "teams", "create"]); + expect(parsed).toEqual([ + { action: "list", purpose: "all projects" }, + { action: "teams", purpose: "memberships" }, + { action: "create", purpose: "new project" }, + ]); + }); +}); + +describe("renderToolPage", () => { + const page = renderToolPage(TOOLS[0]); + + test("emits frontmatter, marker, and sections", () => { + expect(page).toStartWith("---\ntitle: mymir_project\n"); + expect(page).toContain("Do not edit by hand"); + expect(page).toContain("## Actions"); + expect(page).toContain("## Parameters"); + }); + + test("lists every action and the action parameter first", () => { + for (const action of ["list", "teams", "create", "select", "update"]) { + expect(page).toContain(`| \`${action}\` |`); + } + const actionRow = page.indexOf("| `action` |"); + const projectIdRow = page.indexOf("| `projectId` |"); + expect(actionRow).toBeGreaterThan(-1); + expect(actionRow).toBeLessThan(projectIdRow); + }); + + test("is deterministic", () => { + expect(renderToolPage(TOOLS[0])).toBe(page); + }); + + test("escapes MDX-hostile characters in prose", () => { + expect(page).not.toMatch(/^[^`]*<[a-zA-Z]/m); + }); +}); From 8d1992df2d78e9106b6de753470e5fd058617235 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Fri, 12 Jun 2026 08:47:50 +0200 Subject: [PATCH 03/15] feat: add reference sync and skills catalog renderers --- scripts/generate-docs.ts | 138 ++++++++++++++++++++++++++++ tests/scripts/generate-docs.test.ts | 39 +++++++- 2 files changed, 176 insertions(+), 1 deletion(-) diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index a8e9f248..ddb4bc71 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -154,3 +154,141 @@ ${actionRows.join("\n")} ${paramRows.join("\n")} `; } + +/** Synced skill reference files and their docs metadata. */ +export const SKILL_REFERENCES = [ + { + file: "conventions.md", + slug: "conventions", + description: "Always-on quality rules for every Mymir skill and agent.", + }, + { + file: "artifacts.md", + slug: "artifacts", + description: "Quality bar for tasks, edges, tags, and categories.", + }, + { + file: "lifecycle.md", + slug: "lifecycle", + description: "Status lifecycle, Completion Protocol, and propagation rules.", + }, + { + file: "resilience.md", + slug: "resilience", + description: "Long-session discipline: persistence, resume mode, compaction.", + }, +] as const; + +/** + * Transform a skill reference markdown file into a docs MDX page. + * Content is synced verbatim; only frontmatter, a provenance banner, and + * cross-reference links are added. + * @param raw - Source file content. + * @param file - Source file name (e.g. "conventions.md"). + * @returns Complete MDX document text. + */ +export function transformReference(raw: string, file: string): string { + const ref = SKILL_REFERENCES.find((r) => r.file === file); + if (!ref) throw new Error(`unknown reference file: ${file}`); + const lines = raw.split("\n"); + const titleIndex = lines.findIndex((l) => l.startsWith("# ")); + if (titleIndex === -1) throw new Error(`no h1 in ${file}`); + const title = lines[titleIndex].slice(2).trim(); + const body = lines.slice(titleIndex + 1).join("\n").trim(); + const linked = body.replace( + /`references\/(conventions|artifacts|lifecycle|resilience)\.md`/g, + "[`references/$1.md`](/docs/reference/$1/)", + ); + return `--- +title: ${title} +description: ${yamlQuote(ref.description)} +--- + +import { Callout } from 'fumadocs-ui/components/callout'; + +${GENERATED_NOTE} + + + Canonical skill reference. This page is synced verbatim from + plugins/claude-code/skills/mymir/references/${file} in the mymir repo. + The Mymir skills and agents follow exactly this text. + + +# ${title} + +${linked} +`; +} + +/** A skill or agent entry parsed from plugin frontmatter. */ +interface PluginEntry { + name: string; + description: string; +} + +/** + * Parse the YAML frontmatter of a plugin markdown file. + * @param path - Absolute path to a SKILL.md or agent .md file. + * @returns Name and description, or null when frontmatter is incomplete. + */ +async function readFrontmatter(path: string): Promise { + const raw = await readFile(path, "utf8"); + const match = raw.match(/^---\n([\s\S]*?)\n---/); + if (!match) return null; + const parsed = Bun.YAML.parse(match[1]) as { + name?: string; + description?: string; + }; + if (!parsed.name || !parsed.description) return null; + return { name: parsed.name, description: parsed.description.trim() }; +} + +/** + * Render the skills and agents catalog page from plugin frontmatter. + * @param pluginRoot - Absolute path to plugins/claude-code. + * @returns Complete MDX document text. + */ +export async function renderCatalog(pluginRoot: string): Promise { + const skillsDir = join(pluginRoot, "skills"); + const agentsDir = join(pluginRoot, "agents"); + const skills: PluginEntry[] = []; + for (const name of (await readdir(skillsDir)).sort()) { + const entry = await readFrontmatter(join(skillsDir, name, "SKILL.md")); + if (entry) skills.push(entry); + } + const agents: PluginEntry[] = []; + for (const fileName of (await readdir(agentsDir)).sort()) { + if (!fileName.endsWith(".md")) continue; + const entry = await readFrontmatter(join(agentsDir, fileName)); + if (entry) agents.push(entry); + } + skills.sort((a, b) => + a.name === "mymir" ? -1 : b.name === "mymir" ? 1 : a.name.localeCompare(b.name), + ); + const skillSections = skills.map((s) => { + const command = s.name === "mymir" ? "/mymir" : `/mymir:${s.name}`; + return `### ${command}\n\n${escapeProse(s.description)}`; + }); + const agentSections = agents.map( + (a) => `### ${a.name}\n\n${escapeProse(a.description)}`, + ); + return `--- +title: Skills and agents +description: "Every command and agent the Mymir plugin installs, and when each one triggers." +--- + +${GENERATED_NOTE} + +# Skills and agents + +The Mymir plugin ships the same skill set on Claude Code, Codex, Cursor, and Antigravity. Commands are skills you or the model invoke; agents are dispatched by skills for focused phases of work. The descriptions below are the live trigger text from the plugin source. + +## Commands + +${skillSections.join("\n\n")} + +## Agents + +${agentSections.join("\n\n")} +`; +} diff --git a/tests/scripts/generate-docs.test.ts b/tests/scripts/generate-docs.test.ts index d8bc05ca..12e0279a 100644 --- a/tests/scripts/generate-docs.test.ts +++ b/tests/scripts/generate-docs.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { parseActions, renderToolPage } from "../../scripts/generate-docs"; +import { resolve } from "node:path"; +import { + parseActions, + renderCatalog, + renderToolPage, + transformReference, +} from "../../scripts/generate-docs"; import { TOOLS } from "../../lib/mcp/schemas"; describe("TOOLS", () => { @@ -55,3 +61,34 @@ describe("renderToolPage", () => { expect(page).not.toMatch(/^[^`]*<[a-zA-Z]/m); }); }); + +describe("transformReference", () => { + const raw = "# Mymir Conventions\n\nRead `references/artifacts.md` first.\n"; + const out = transformReference(raw, "conventions.md"); + + test("extracts the title into frontmatter and keeps the h1", () => { + expect(out).toContain("title: Mymir Conventions"); + expect(out).toContain("# Mymir Conventions"); + }); + + test("adds the canonical banner with the source path", () => { + expect(out).toContain("Canonical skill reference"); + expect(out).toContain("plugins/claude-code/skills/mymir/references/conventions.md"); + }); + + test("rewrites cross-reference links to docs urls", () => { + expect(out).toContain("[`references/artifacts.md`](/docs/reference/artifacts/)"); + }); +}); + +describe("renderCatalog", () => { + test("renders commands and agents from the real plugin", async () => { + const out = await renderCatalog( + resolve(import.meta.dir, "../../plugins/claude-code"), + ); + expect(out).toContain("### /mymir"); + expect(out).toContain("### /mymir:composer"); + expect(out).toContain("## Agents"); + expect(out).not.toMatch(/ Date: Fri, 12 Jun 2026 08:53:23 +0200 Subject: [PATCH 04/15] feat: normalize prose dashes in generated docs --- scripts/generate-docs.ts | 41 ++++++++++++++++++++++++++--- tests/scripts/generate-docs.test.ts | 38 ++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index ddb4bc71..96b62f7a 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -30,13 +30,47 @@ interface JsonSchemaObject { required?: string[]; } +/** + * Replace em/en-dashes in prose with commas or hyphens, leaving code intact. + * Fenced code blocks and inline code spans are preserved verbatim so docs + * generated from product strings satisfy the no-dash content style rule. + * @param text - Markdown or prose, possibly containing code. + * @returns Text with prose dashes normalized and code untouched. + */ +export function normalizeProseDashes(text: string): string { + const lines = text.split("\n"); + let inFence = false; + return lines + .map((line) => { + if (line.trimStart().startsWith("```")) { + inFence = !inFence; + return line; + } + if (inFence) return line; + const segments = line.split(/(`[^`]*`)/); + return segments + .map((seg) => + seg.startsWith("`") && seg.endsWith("`") + ? seg + : seg + .replace(/(\d)\s*–\s*(\d)/g, "$1-$2") + .replace(/\s*—\s*/g, ", ") + .replace(/\s*–\s*/g, ", ") + .replace(/ ,/g, ","), + ) + .join(""); + }) + .join("\n"); +} + /** * Escape characters MDX would parse as JSX in prose positions. * @param text - Raw prose. * @returns Prose safe to embed in MDX body text. */ function escapeProse(text: string): string { - return text.replaceAll("<", "<").replaceAll("{", "{"); + const normalized = normalizeProseDashes(text); + return normalized.replaceAll("<", "<").replaceAll("{", "{"); } /** @@ -193,12 +227,13 @@ export function transformReference(raw: string, file: string): string { const lines = raw.split("\n"); const titleIndex = lines.findIndex((l) => l.startsWith("# ")); if (titleIndex === -1) throw new Error(`no h1 in ${file}`); - const title = lines[titleIndex].slice(2).trim(); + const title = normalizeProseDashes(lines[titleIndex].slice(2).trim()); const body = lines.slice(titleIndex + 1).join("\n").trim(); const linked = body.replace( /`references\/(conventions|artifacts|lifecycle|resilience)\.md`/g, "[`references/$1.md`](/docs/reference/$1/)", ); + const normalizedBody = normalizeProseDashes(linked); return `--- title: ${title} description: ${yamlQuote(ref.description)} @@ -216,7 +251,7 @@ ${GENERATED_NOTE} # ${title} -${linked} +${normalizedBody} `; } diff --git a/tests/scripts/generate-docs.test.ts b/tests/scripts/generate-docs.test.ts index 12e0279a..226d35e9 100644 --- a/tests/scripts/generate-docs.test.ts +++ b/tests/scripts/generate-docs.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { resolve } from "node:path"; import { + normalizeProseDashes, parseActions, renderCatalog, renderToolPage, @@ -92,3 +93,40 @@ describe("renderCatalog", () => { expect(out).not.toMatch(/ { + test("replaces a spaced em-dash with a comma", () => { + expect(normalizeProseDashes("renders X — use BEFORE coding")).toBe( + "renders X, use BEFORE coding", + ); + }); + + test("replaces a tight em-dash with a comma and space", () => { + expect(normalizeProseDashes("a—b")).toBe("a, b"); + }); + + test("turns a numeric en-dash range into a hyphen", () => { + expect(normalizeProseDashes("estimate 3–5 points")).toBe( + "estimate 3-5 points", + ); + }); + + test("preserves dashes inside an inline code span", () => { + expect(normalizeProseDashes("use `a — b` verbatim")).toBe( + "use `a — b` verbatim", + ); + }); + + test("preserves dashes inside a fenced code block", () => { + const input = "before\n```\nfoo — bar\n3–5\n```\nafter — end"; + expect(normalizeProseDashes(input)).toBe( + "before\n```\nfoo — bar\n3–5\n```\nafter, end", + ); + }); + + test("leaves dash-free prose unchanged", () => { + expect(normalizeProseDashes("draft → planned → done")).toBe( + "draft → planned → done", + ); + }); +}); From 418bfb304f1ec84d2713a5affdd155140bf2ce68 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Fri, 12 Jun 2026 08:57:32 +0200 Subject: [PATCH 05/15] feat: add docs:gen cli and wire generation sources --- package.json | 1 + .../skills/mymir/references/artifacts.md | 6 +- .../skills/mymir/references/artifacts.md | 6 +- .../skills/mymir/references/artifacts.md | 6 +- .../skills/mymir/references/artifacts.md | 6 +- scripts/generate-docs.ts | 55 +++++++++++++++++++ 6 files changed, 68 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 1692f51a..e1e56521 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "sync:plugins": "bun run scripts/check-plugins.ts --fix", "check:version": "bun run scripts/bump-version.ts --check", "bump:version": "bun run scripts/bump-version.ts", + "docs:gen": "bun scripts/generate-docs.ts", "db:setup": "docker compose --env-file .env.local up -d --wait && docker exec -i mymir-db-1 psql -U mymir -d mymir < docker/init-auth.sql && docker exec mymir-db-1 /docker-entrypoint-initdb.d/02-rls.sh && bun run db:push && docker exec -i mymir-db-1 psql -U mymir -d mymir < docker/grants.sql && docker exec -i mymir-db-1 psql -U mymir -d mymir < docker/rls-functions.sql && docker exec -i mymir-db-1 psql -U mymir -d mymir < docker/rls-policies.sql", "db:generate": "drizzle-kit generate", "db:push": "drizzle-kit push", diff --git a/plugins/antigravity/skills/mymir/references/artifacts.md b/plugins/antigravity/skills/mymir/references/artifacts.md index b391c134..2e65f650 100644 --- a/plugins/antigravity/skills/mymir/references/artifacts.md +++ b/plugins/antigravity/skills/mymir/references/artifacts.md @@ -385,10 +385,10 @@ The text you write into Mymir is read by other engineers. It must read like an e - Em dashes (the `—` character). Use periods, commas, parentheses, or colons. - Hedging openers: "I think", "perhaps", "seems to", "might be", "arguably". - Enthusiasm: "Great question", "Awesome", "Exciting", "Love this". -- Throat-clearing: "Let me dive into", "I hope this helps", "Here's the thing", "To be honest". -- Marketing words: "comprehensive", "robust", "powerful", "leverage", "utilize", "ensure", "facilitate", "seamless", "game-changer", "best-in-class". +- Throat-clearing: `Let me dive into`, `I hope this helps`, `Here's the thing`, `To be honest`. +- Marketing words: `comprehensive`, `robust`, `powerful`, `leverage`, `utilize`, `ensure`, `facilitate`, `seamless`, `game-changer`, `best-in-class`. - Adverb-heavy openers: "Importantly", "Crucially", "Notably", "Essentially", "Basically". -- Empty filler: "It's worth noting that", "It should be mentioned", "As a matter of fact". +- Empty filler: `It's worth noting that`, `It should be mentioned`, `As a matter of fact`. - Performative summaries at the end: "I hope this helps!", "Let me know if you need anything else!" **Do:** diff --git a/plugins/claude-code/skills/mymir/references/artifacts.md b/plugins/claude-code/skills/mymir/references/artifacts.md index b391c134..2e65f650 100644 --- a/plugins/claude-code/skills/mymir/references/artifacts.md +++ b/plugins/claude-code/skills/mymir/references/artifacts.md @@ -385,10 +385,10 @@ The text you write into Mymir is read by other engineers. It must read like an e - Em dashes (the `—` character). Use periods, commas, parentheses, or colons. - Hedging openers: "I think", "perhaps", "seems to", "might be", "arguably". - Enthusiasm: "Great question", "Awesome", "Exciting", "Love this". -- Throat-clearing: "Let me dive into", "I hope this helps", "Here's the thing", "To be honest". -- Marketing words: "comprehensive", "robust", "powerful", "leverage", "utilize", "ensure", "facilitate", "seamless", "game-changer", "best-in-class". +- Throat-clearing: `Let me dive into`, `I hope this helps`, `Here's the thing`, `To be honest`. +- Marketing words: `comprehensive`, `robust`, `powerful`, `leverage`, `utilize`, `ensure`, `facilitate`, `seamless`, `game-changer`, `best-in-class`. - Adverb-heavy openers: "Importantly", "Crucially", "Notably", "Essentially", "Basically". -- Empty filler: "It's worth noting that", "It should be mentioned", "As a matter of fact". +- Empty filler: `It's worth noting that`, `It should be mentioned`, `As a matter of fact`. - Performative summaries at the end: "I hope this helps!", "Let me know if you need anything else!" **Do:** diff --git a/plugins/codex/skills/mymir/references/artifacts.md b/plugins/codex/skills/mymir/references/artifacts.md index b391c134..2e65f650 100644 --- a/plugins/codex/skills/mymir/references/artifacts.md +++ b/plugins/codex/skills/mymir/references/artifacts.md @@ -385,10 +385,10 @@ The text you write into Mymir is read by other engineers. It must read like an e - Em dashes (the `—` character). Use periods, commas, parentheses, or colons. - Hedging openers: "I think", "perhaps", "seems to", "might be", "arguably". - Enthusiasm: "Great question", "Awesome", "Exciting", "Love this". -- Throat-clearing: "Let me dive into", "I hope this helps", "Here's the thing", "To be honest". -- Marketing words: "comprehensive", "robust", "powerful", "leverage", "utilize", "ensure", "facilitate", "seamless", "game-changer", "best-in-class". +- Throat-clearing: `Let me dive into`, `I hope this helps`, `Here's the thing`, `To be honest`. +- Marketing words: `comprehensive`, `robust`, `powerful`, `leverage`, `utilize`, `ensure`, `facilitate`, `seamless`, `game-changer`, `best-in-class`. - Adverb-heavy openers: "Importantly", "Crucially", "Notably", "Essentially", "Basically". -- Empty filler: "It's worth noting that", "It should be mentioned", "As a matter of fact". +- Empty filler: `It's worth noting that`, `It should be mentioned`, `As a matter of fact`. - Performative summaries at the end: "I hope this helps!", "Let me know if you need anything else!" **Do:** diff --git a/plugins/cursor/skills/mymir/references/artifacts.md b/plugins/cursor/skills/mymir/references/artifacts.md index b391c134..2e65f650 100644 --- a/plugins/cursor/skills/mymir/references/artifacts.md +++ b/plugins/cursor/skills/mymir/references/artifacts.md @@ -385,10 +385,10 @@ The text you write into Mymir is read by other engineers. It must read like an e - Em dashes (the `—` character). Use periods, commas, parentheses, or colons. - Hedging openers: "I think", "perhaps", "seems to", "might be", "arguably". - Enthusiasm: "Great question", "Awesome", "Exciting", "Love this". -- Throat-clearing: "Let me dive into", "I hope this helps", "Here's the thing", "To be honest". -- Marketing words: "comprehensive", "robust", "powerful", "leverage", "utilize", "ensure", "facilitate", "seamless", "game-changer", "best-in-class". +- Throat-clearing: `Let me dive into`, `I hope this helps`, `Here's the thing`, `To be honest`. +- Marketing words: `comprehensive`, `robust`, `powerful`, `leverage`, `utilize`, `ensure`, `facilitate`, `seamless`, `game-changer`, `best-in-class`. - Adverb-heavy openers: "Importantly", "Crucially", "Notably", "Essentially", "Basically". -- Empty filler: "It's worth noting that", "It should be mentioned", "As a matter of fact". +- Empty filler: `It's worth noting that`, `It should be mentioned`, `As a matter of fact`. - Performative summaries at the end: "I hope this helps!", "Let me know if you need anything else!" **Do:** diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 96b62f7a..f0977458 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -327,3 +327,58 @@ ${skillSections.join("\n\n")} ${agentSections.join("\n\n")} `; } + +/** + * Parse CLI arguments. + * @returns Resolved output content directory. + */ +function parseArgs(): { out: string } { + const idx = process.argv.indexOf("--out"); + const fallback = resolve(import.meta.dir, "../../mymir-docs/content/docs"); + return { out: idx === -1 ? fallback : resolve(process.argv[idx + 1]) }; +} + +/** + * Generate all docs artifacts into the output content directory. + */ +async function main(): Promise { + const { out } = parseArgs(); + const toolsDir = join(out, "mcp", "tools"); + const referenceDir = join(out, "reference"); + await mkdir(toolsDir, { recursive: true }); + await mkdir(referenceDir, { recursive: true }); + + for (const tool of TOOLS) { + const slug = tool.name.replace("mymir_", ""); + await writeFile(join(toolsDir, `${slug}.mdx`), renderToolPage(tool)); + } + const toolSlugs = TOOLS.map((t) => t.name.replace("mymir_", "")).sort(); + await writeFile( + join(toolsDir, "meta.json"), + `${JSON.stringify({ title: "Tools", pages: toolSlugs }, null, 2)}\n`, + ); + + const refRoot = resolve( + import.meta.dir, + "../plugins/claude-code/skills/mymir/references", + ); + for (const ref of SKILL_REFERENCES) { + const raw = await readFile(join(refRoot, ref.file), "utf8"); + await writeFile( + join(referenceDir, `${ref.slug}.mdx`), + transformReference(raw, ref.file), + ); + } + + const pluginRoot = resolve(import.meta.dir, "../plugins/claude-code"); + await writeFile( + join(referenceDir, "skills-and-agents.mdx"), + await renderCatalog(pluginRoot), + ); + + console.log( + `docs:gen wrote ${TOOLS.length} tool pages, ${SKILL_REFERENCES.length} references, 1 catalog to ${out}`, + ); +} + +if (import.meta.main) await main(); From d23b6922ddbf1e6bb525ba53adf302ee0deff538 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Fri, 12 Jun 2026 09:11:29 +0200 Subject: [PATCH 06/15] fix: render every tool discriminator and correct required flags --- scripts/generate-docs.ts | 26 ++++++++++----- tests/scripts/generate-docs.test.ts | 51 +++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index f0977458..46f9f130 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -23,6 +23,9 @@ interface JsonSchemaProperty { enum?: string[]; items?: { type?: string; enum?: string[] }; format?: string; + default?: unknown; + anyOf?: JsonSchemaProperty[]; + const?: unknown; } interface JsonSchemaObject { @@ -53,9 +56,9 @@ export function normalizeProseDashes(text: string): string { seg.startsWith("`") && seg.endsWith("`") ? seg : seg - .replace(/(\d)\s*–\s*(\d)/g, "$1-$2") - .replace(/\s*—\s*/g, ", ") - .replace(/\s*–\s*/g, ", ") + .replace(/(\d)\s*[–]\s*(\d)/g, "$1-$2") + .replace(/(?<=[^\s|])\s*[—–]\s*(?=[^\s|])/g, ", ") + .replace(/[—–]/g, "-") .replace(/ ,/g, ","), ) .join(""); @@ -97,6 +100,10 @@ function yamlQuote(text: string): string { * @returns Type string for a parameter table cell. */ function renderType(prop: JsonSchemaProperty): string { + if (prop.const !== undefined) { + return typeof prop.const === "string" ? `"${prop.const}"` : `${prop.const}`; + } + if (prop.anyOf) return prop.anyOf.map(renderType).join(" \\| "); if (prop.enum) return prop.enum.map((v) => `"${v}"`).join(" \\| "); if (prop.type === "array") { const items = prop.items; @@ -145,22 +152,23 @@ export function renderToolPage(tool: ToolDefinition): string { const schema = z.toJSONSchema(tool.inputSchema) as JsonSchemaObject; const props = schema.properties ?? {}; const required = new Set(schema.required ?? []); - const actionProp = props.action; - const actions = actionProp?.enum ?? []; + const discriminatorName = Object.keys(props).find((k) => props[k]?.enum); + const discriminator = discriminatorName ? props[discriminatorName] : undefined; + const actions = discriminator?.enum ?? []; const firstSentence = `${tool.description.split(". ")[0]}.`; const paramNames = Object.keys(props).sort((a, b) => { - if (a === "action") return -1; - if (b === "action") return 1; + if (a === discriminatorName) return -1; + if (b === discriminatorName) return 1; return a.localeCompare(b); }); const paramRows = paramNames.map((name) => { const prop = props[name]; - const req = required.has(name) ? "Yes" : "No"; + const req = required.has(name) && prop.default === undefined ? "Yes" : "No"; return `| \`${name}\` | \`${renderType(prop)}\` | ${req} | ${escapeCell(prop.description ?? "")} |`; }); - const actionRows = parseActions(actionProp?.description ?? "", actions).map( + const actionRows = parseActions(discriminator?.description ?? "", actions).map( ({ action, purpose }) => `| \`${action}\` | ${escapeCell(purpose)} |`, ); diff --git a/tests/scripts/generate-docs.test.ts b/tests/scripts/generate-docs.test.ts index 226d35e9..7a46ce9f 100644 --- a/tests/scripts/generate-docs.test.ts +++ b/tests/scripts/generate-docs.test.ts @@ -130,3 +130,54 @@ describe("normalizeProseDashes", () => { ); }); }); + +describe("renderToolPage covers every tool", () => { + for (const tool of TOOLS) { + test(`${tool.name} has a populated Actions table`, () => { + const page = renderToolPage(tool); + const actionsSection = page.slice( + page.indexOf("## Actions"), + page.indexOf("## Parameters"), + ); + const bodyRows = actionsSection + .split("\n") + .filter((l) => l.startsWith("| `")); + expect(bodyRows.length).toBeGreaterThan(0); + }); + + test(`${tool.name} renders deterministically`, () => { + expect(renderToolPage(tool)).toBe(renderToolPage(tool)); + }); + } +}); + +describe("renderToolPage Required column", () => { + test("default-valued fields are not marked Required", () => { + const task = renderToolPage(TOOLS.find((t) => t.name === "mymir_task")!); + const previewRow = task + .split("\n") + .find((l) => l.startsWith("| `preview` |")); + expect(previewRow).toBeDefined(); + expect(previewRow).toContain("| No |"); + }); + + test("the discriminator field itself stays Required", () => { + const task = renderToolPage(TOOLS.find((t) => t.name === "mymir_task")!); + const actionRow = task + .split("\n") + .find((l) => l.startsWith("| `action` |")); + expect(actionRow).toContain("| Yes |"); + }); +}); + +describe("normalizeProseDashes standalone cells", () => { + test("a lone em-dash table cell becomes a single hyphen", () => { + expect(normalizeProseDashes("| `field` | — | note |")).toBe( + "| `field` | - | note |", + ); + }); + + test("prose em-dash still becomes a comma", () => { + expect(normalizeProseDashes("X — Y")).toBe("X, Y"); + }); +}); From f846775815e310926d78ebe0aa729646325584e6 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Fri, 12 Jun 2026 09:14:43 +0200 Subject: [PATCH 07/15] feat: add docs-sync workflow --- .github/workflows/docs-sync.yml | 58 +++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/docs-sync.yml diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml new file mode 100644 index 00000000..1e3bc395 --- /dev/null +++ b/.github/workflows/docs-sync.yml @@ -0,0 +1,58 @@ +name: Docs Sync + +on: + push: + branches: [main] + paths: + - "lib/mcp/**" + - "lib/graph/tool-handlers.ts" + - "plugins/claude-code/**" + - "scripts/generate-docs.ts" + workflow_dispatch: + +jobs: + sync: + name: Regenerate docs and open PR + runs-on: ubuntu-latest + if: github.repository == 'FrkAk/mymir' + concurrency: + group: docs-sync + cancel-in-progress: false + permissions: + contents: read + steps: + - name: Checkout mymir + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Checkout mymir-docs + uses: actions/checkout@v6 + with: + repository: FrkAk/mymir-docs + token: ${{ secrets.DOCS_REPO_TOKEN }} + path: docs-repo + persist-credentials: true + + - name: Regenerate docs + run: bun scripts/generate-docs.ts --out docs-repo/content/docs + + - name: Open PR if changed + uses: peter-evans/create-pull-request@v7 + with: + path: docs-repo + token: ${{ secrets.DOCS_REPO_TOKEN }} + branch: docs-sync/auto + delete-branch: true + commit-message: "docs: sync generated reference from mymir" + title: "docs: sync generated reference from mymir" + body: | + Automated regeneration triggered by FrkAk/mymir@${{ github.sha }}. + Sources: MCP schemas, tool descriptions, or plugin skill files. + signoff: false From 22ffd129e44df5a375e3faea5824bef92c573624 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Fri, 12 Jun 2026 09:14:46 +0200 Subject: [PATCH 08/15] docs: add docs impact section to pr template --- .github/pull_request_template.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9287e70d..cacd15c6 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -20,3 +20,9 @@ ## Notes for reviewer + +## Docs impact + + + +- From 54dc4838d1a1cd979f812bf9fd89f511b4121e97 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Fri, 12 Jun 2026 11:36:24 +0200 Subject: [PATCH 09/15] style: format generator files with biome --- lib/mcp/schemas.ts | 22 +++----------------- scripts/generate-docs.ts | 31 ++++++++++++++++++++--------- tests/scripts/generate-docs.test.ts | 8 ++++++-- 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/lib/mcp/schemas.ts b/lib/mcp/schemas.ts index 6a02a443..db47fcf8 100644 --- a/lib/mcp/schemas.ts +++ b/lib/mcp/schemas.ts @@ -172,14 +172,7 @@ export const taskInputSchema = z.object({ "2-4 sentences (up to 6-8 for genuinely complex tasks; single-sentence rejected): what + who it serves + where it fits in the architecture. Required for create. Artifacts §1.", ), status: z - .enum([ - "draft", - "planned", - "in_progress", - "in_review", - "done", - "cancelled", - ]) + .enum(["draft", "planned", "in_progress", "in_review", "done", "cancelled"]) .optional() .describe( "Lifecycle: draft → planned → in_progress → in_review → done. The implementer subagent's terminal write is `in_review` (PR opened, tests green); the HOTL gate flips to `done` after PR approval. cancelled = terminal abandoned work; populate executionRecord with rationale. Cancelled deps are transparent: dependents stay blocked through the cancelled task's own unsatisfied deps. Excluded from progress and critical path.", @@ -380,20 +373,11 @@ export const contextInputSchema = z.object({ export const analyzeInputSchema = z.object({ type: z - .enum([ - "ready", - "blocked", - "downstream", - "critical_path", - "plannable", - ]) + .enum(["ready", "blocked", "downstream", "critical_path", "plannable"]) .describe( "ready=planned tasks with all deps done (drafts with deps satisfied surface as plannable, not ready). blocked=waiting tasks with blocker details. downstream=transitive dependents (impact analysis before changes). critical_path=longest dep chain (project bottleneck). plannable=draft tasks with description+criteria, ready for planning.", ), - taskId: z - .uuid() - .optional() - .describe("Task UUID. Required for 'downstream'."), + taskId: z.uuid().optional().describe("Task UUID. Required for 'downstream'."), projectId: z .uuid() .optional() diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 46f9f130..8b7eeea3 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -107,7 +107,8 @@ function renderType(prop: JsonSchemaProperty): string { if (prop.enum) return prop.enum.map((v) => `"${v}"`).join(" \\| "); if (prop.type === "array") { const items = prop.items; - if (items?.enum) return `(${items.enum.map((v) => `"${v}"`).join(" \\| ")})[]`; + if (items?.enum) + return `(${items.enum.map((v) => `"${v}"`).join(" \\| ")})[]`; return `${items?.type ?? "unknown"}[]`; } if (prop.format === "uuid") return "string (uuid)"; @@ -153,7 +154,9 @@ export function renderToolPage(tool: ToolDefinition): string { const props = schema.properties ?? {}; const required = new Set(schema.required ?? []); const discriminatorName = Object.keys(props).find((k) => props[k]?.enum); - const discriminator = discriminatorName ? props[discriminatorName] : undefined; + const discriminator = discriminatorName + ? props[discriminatorName] + : undefined; const actions = discriminator?.enum ?? []; const firstSentence = `${tool.description.split(". ")[0]}.`; @@ -168,9 +171,10 @@ export function renderToolPage(tool: ToolDefinition): string { return `| \`${name}\` | \`${renderType(prop)}\` | ${req} | ${escapeCell(prop.description ?? "")} |`; }); - const actionRows = parseActions(discriminator?.description ?? "", actions).map( - ({ action, purpose }) => `| \`${action}\` | ${escapeCell(purpose)} |`, - ); + const actionRows = parseActions( + discriminator?.description ?? "", + actions, + ).map(({ action, purpose }) => `| \`${action}\` | ${escapeCell(purpose)} |`); return `--- title: ${tool.name} @@ -212,12 +216,14 @@ export const SKILL_REFERENCES = [ { file: "lifecycle.md", slug: "lifecycle", - description: "Status lifecycle, Completion Protocol, and propagation rules.", + description: + "Status lifecycle, Completion Protocol, and propagation rules.", }, { file: "resilience.md", slug: "resilience", - description: "Long-session discipline: persistence, resume mode, compaction.", + description: + "Long-session discipline: persistence, resume mode, compaction.", }, ] as const; @@ -236,7 +242,10 @@ export function transformReference(raw: string, file: string): string { const titleIndex = lines.findIndex((l) => l.startsWith("# ")); if (titleIndex === -1) throw new Error(`no h1 in ${file}`); const title = normalizeProseDashes(lines[titleIndex].slice(2).trim()); - const body = lines.slice(titleIndex + 1).join("\n").trim(); + const body = lines + .slice(titleIndex + 1) + .join("\n") + .trim(); const linked = body.replace( /`references\/(conventions|artifacts|lifecycle|resilience)\.md`/g, "[`references/$1.md`](/docs/reference/$1/)", @@ -306,7 +315,11 @@ export async function renderCatalog(pluginRoot: string): Promise { if (entry) agents.push(entry); } skills.sort((a, b) => - a.name === "mymir" ? -1 : b.name === "mymir" ? 1 : a.name.localeCompare(b.name), + a.name === "mymir" + ? -1 + : b.name === "mymir" + ? 1 + : a.name.localeCompare(b.name), ); const skillSections = skills.map((s) => { const command = s.name === "mymir" ? "/mymir" : `/mymir:${s.name}`; diff --git a/tests/scripts/generate-docs.test.ts b/tests/scripts/generate-docs.test.ts index 7a46ce9f..e75d2ff7 100644 --- a/tests/scripts/generate-docs.test.ts +++ b/tests/scripts/generate-docs.test.ts @@ -74,11 +74,15 @@ describe("transformReference", () => { test("adds the canonical banner with the source path", () => { expect(out).toContain("Canonical skill reference"); - expect(out).toContain("plugins/claude-code/skills/mymir/references/conventions.md"); + expect(out).toContain( + "plugins/claude-code/skills/mymir/references/conventions.md", + ); }); test("rewrites cross-reference links to docs urls", () => { - expect(out).toContain("[`references/artifacts.md`](/docs/reference/artifacts/)"); + expect(out).toContain( + "[`references/artifacts.md`](/docs/reference/artifacts/)", + ); }); }); From fcc51b9dd93678a52433795ebc1c07fe5cb578ff Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Fri, 12 Jun 2026 19:42:11 +0200 Subject: [PATCH 10/15] fix: keep code spans verbatim in docs prose escaping --- lib/mcp/schemas.ts | 2 +- scripts/generate-docs.ts | 14 +++++++++++--- tests/scripts/generate-docs.test.ts | 13 +++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/lib/mcp/schemas.ts b/lib/mcp/schemas.ts index db47fcf8..19d733f3 100644 --- a/lib/mcp/schemas.ts +++ b/lib/mcp/schemas.ts @@ -46,7 +46,7 @@ export const LIMITS = { // toward the right rule rather than restating it. // --------------------------------------------------------------------------- -/** Tool descriptions shared between MCP and web app. */ +/** Tool descriptions shared between the MCP server and the docs generator. */ export const DESCRIPTIONS = { mymir_project: "List, create, and update projects, plus enumerate team memberships. Spans every team the caller belongs to; no server-side session state, so pass projectId explicitly on every downstream call. " + diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 8b7eeea3..026f6e4a 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -68,12 +68,20 @@ export function normalizeProseDashes(text: string): string { /** * Escape characters MDX would parse as JSX in prose positions. + * Inline code spans are left verbatim: backticked text is literal in MDX, so + * escaping `<` or `{` inside it would render the entity instead of the source. * @param text - Raw prose. * @returns Prose safe to embed in MDX body text. */ -function escapeProse(text: string): string { - const normalized = normalizeProseDashes(text); - return normalized.replaceAll("<", "<").replaceAll("{", "{"); +export function escapeProse(text: string): string { + return normalizeProseDashes(text) + .split(/(`[^`]*`)/) + .map((seg) => + seg.startsWith("`") && seg.endsWith("`") + ? seg + : seg.replaceAll("<", "<").replaceAll("{", "{"), + ) + .join(""); } /** diff --git a/tests/scripts/generate-docs.test.ts b/tests/scripts/generate-docs.test.ts index e75d2ff7..eb29e62b 100644 --- a/tests/scripts/generate-docs.test.ts +++ b/tests/scripts/generate-docs.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { resolve } from "node:path"; import { + escapeProse, normalizeProseDashes, parseActions, renderCatalog, @@ -135,6 +136,18 @@ describe("normalizeProseDashes", () => { }); }); +describe("escapeProse", () => { + test("escapes JSX-hostile characters in prose", () => { + expect(escapeProse("Array and {x}")).toBe("Array<T> and {x}"); + }); + + test("leaves < and { inside an inline code span verbatim", () => { + expect(escapeProse("see `Array` and `{x}`")).toBe( + "see `Array` and `{x}`", + ); + }); +}); + describe("renderToolPage covers every tool", () => { for (const tool of TOOLS) { test(`${tool.name} has a populated Actions table`, () => { From 051ac25dbf363d16a2d37f503cd796aa04917a27 Mon Sep 17 00:00:00 2001 From: Ulas Can Zorer Date: Tue, 16 Jun 2026 19:41:09 +0200 Subject: [PATCH 11/15] chore: Minor changes. --- scripts/generate-docs.ts | 4 +++- tests/scripts/generate-docs.test.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 026f6e4a..33163285 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -21,7 +21,7 @@ interface JsonSchemaProperty { type?: string; description?: string; enum?: string[]; - items?: { type?: string; enum?: string[] }; + items?: { type?: string; enum?: string[]; anyOf?: JsonSchemaProperty[] }; format?: string; default?: unknown; anyOf?: JsonSchemaProperty[]; @@ -115,11 +115,13 @@ function renderType(prop: JsonSchemaProperty): string { if (prop.enum) return prop.enum.map((v) => `"${v}"`).join(" \\| "); if (prop.type === "array") { const items = prop.items; + if (items?.anyOf) return `(${items.anyOf.map(renderType).join(" \\| ")})[]`; if (items?.enum) return `(${items.enum.map((v) => `"${v}"`).join(" \\| ")})[]`; return `${items?.type ?? "unknown"}[]`; } if (prop.format === "uuid") return "string (uuid)"; + if (prop.format === "uri") return "string (url)"; return prop.type ?? "unknown"; } diff --git a/tests/scripts/generate-docs.test.ts b/tests/scripts/generate-docs.test.ts index eb29e62b..93fe18cd 100644 --- a/tests/scripts/generate-docs.test.ts +++ b/tests/scripts/generate-docs.test.ts @@ -187,6 +187,23 @@ describe("renderToolPage Required column", () => { }); }); +describe("renderToolPage union-array types", () => { + test("renders an array of a union as its members, not unknown[]", () => { + const task = renderToolPage(TOOLS.find((t) => t.name === "mymir_task")!); + const acRow = task + .split("\n") + .find((l) => l.startsWith("| `acceptanceCriteria` |")); + expect(acRow).toContain("(string \\| object)[]"); + expect(acRow).not.toContain("unknown[]"); + }); + + test("renders a url field as string (url), not bare string", () => { + const task = renderToolPage(TOOLS.find((t) => t.name === "mymir_task")!); + const prUrlRow = task.split("\n").find((l) => l.startsWith("| `prUrl` |")); + expect(prUrlRow).toContain("string (url) \\| null"); + }); +}); + describe("normalizeProseDashes standalone cells", () => { test("a lone em-dash table cell becomes a single hyphen", () => { expect(normalizeProseDashes("| `field` | — | note |")).toBe( From 9a3ae30d9b3cad3a5b88bd9f9a79cc90fff1192a Mon Sep 17 00:00:00 2001 From: Ulas Can Zorer Date: Tue, 16 Jun 2026 19:54:17 +0200 Subject: [PATCH 12/15] fix: Recovered semantic changes from main. --- lib/mcp/schemas.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mcp/schemas.ts b/lib/mcp/schemas.ts index 19d733f3..804262e6 100644 --- a/lib/mcp/schemas.ts +++ b/lib/mcp/schemas.ts @@ -363,7 +363,7 @@ export const contextInputSchema = z.object({ .enum(["summary", "working", "agent", "planning", "review"]) .default("working") .describe( - "summary=task header + description + counts + 1-hop edges with notes (folds in `mymir_query type='edges'`). working=criteria, decisions, 1-hop edges (both depends_on and relates_to, both directions, with notes) — does NOT render executionRecord, files, or implementationPlan. agent=multi-hop deps + upstream execution records + files + downstream; renders the task's own executionRecord when status is done/cancelled (use BEFORE coding, and to read a finished task's record). planning=project description, prereqs, ACs, downstream specs (use BEFORE writing the implementation plan). review=in_review review bundle: implementationPlan alongside executionRecord, PR link surfaced, plan-vs-files drift, AC evaluation, downstream impact, review-lens prompts (security / perf / reliability / observability / codebase standards). The review subagent reads this depth.", + "summary=task header + description + counts + 1-hop edges with notes (folds in `mymir_query type='edges'`). working=criteria, decisions, 1-hop edges (both depends_on and relates_to, both directions, with notes) — does NOT render executionRecord, files, or implementationPlan. agent=multi-hop deps + upstream execution records (each with its PR link) + downstream; includes a ⚠ Blocked section when direct prerequisites are unfinished; for done/cancelled tasks returns the retrospective record bundle (project, what the task was, outcome, decisions, PR link) instead of the implementation shape (use BEFORE coding, and to read a finished task's record). No bundle renders recorded file lists — the linked PR diff is the source of truth for what changed. planning=project description, prereqs, ACs, downstream specs, links, and abandoned approaches (cancelled-dep execution records with their closed-PR links) (use BEFORE writing the implementation plan). review=in_review review bundle: implementationPlan alongside executionRecord, PR link surfaced, AC evaluation, downstream impact, review-lens prompts (security / perf / reliability / observability / codebase standards); review the actual changes from the PR diff. The review subagent reads this depth.", ), projectId: z .uuid() From 82532a2a1b42aa5cd80b59b08c54cf71d5bf2b1d Mon Sep 17 00:00:00 2001 From: Ulas Can Zorer Date: Tue, 16 Jun 2026 20:35:24 +0200 Subject: [PATCH 13/15] chore: pin patched form-data/ws/hono to clear bun audit highs --- bun.lock | 13 ++++++++++--- package.json | 5 +++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 84d94b07..1a69df76 100644 --- a/bun.lock +++ b/bun.lock @@ -50,6 +50,11 @@ }, }, }, + "overrides": { + "form-data": "4.0.6", + "hono": "4.12.25", + "ws": "8.21.0", + }, "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -1021,7 +1026,7 @@ "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], - "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], @@ -1099,7 +1104,7 @@ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], - "hono": ["hono@4.12.18", "", {}, "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ=="], + "hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], @@ -1745,7 +1750,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], @@ -1861,6 +1866,8 @@ "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "form-data/hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], diff --git a/package.json b/package.json index e1e56521..113071b5 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,11 @@ "typescript": "6.0.3", "zod": "4.4.3" }, + "overrides": { + "form-data": "4.0.6", + "ws": "8.21.0", + "hono": "4.12.25" + }, "devDependencies": { "@biomejs/biome": "2.4.16", "@cloudflare/workers-types": "^4.20260604.1", From 4dd08240d4bd5cb3e636d4699ce66dd6b0320d64 Mon Sep 17 00:00:00 2001 From: Ulas Can Zorer Date: Fri, 19 Jun 2026 15:30:30 +0200 Subject: [PATCH 14/15] chore: Plugin sync. --- plugins/claude-code/skills/composer/references/sources.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/claude-code/skills/composer/references/sources.json b/plugins/claude-code/skills/composer/references/sources.json index 1f351974..c772ed45 100644 --- a/plugins/claude-code/skills/composer/references/sources.json +++ b/plugins/claude-code/skills/composer/references/sources.json @@ -2,7 +2,7 @@ "_comment": "Canonical-source hash pins for the composer phase extracts in this directory. The extracts hand-mirror sections of these files; scripts/check-plugins.ts fails CI when a pinned file changes, until the extracts are reviewed and the pin refreshed via `bun run sync:plugins`.", "pins": { "plugins/claude-code/skills/piyaz/references/conventions.md": "a5488e798e21f2e7b8b4ea3eb09f95c4c8323bb368c86811920ee5bf1826c994", - "plugins/claude-code/skills/piyaz/references/artifacts.md": "f4a873ca56cf6e40bf469a552009cdae2b43133b63c4800d3c3ea3118cff69ca", + "plugins/claude-code/skills/piyaz/references/artifacts.md": "c2fe5beca2c4b50b678f34b9a6e3b05591bd676359ef12222d1fb62653824e36", "plugins/claude-code/skills/piyaz/references/lifecycle.md": "22da7d66ba174ef621a41ff9ca7c9b401dfc4e29d472f13e0823633387a209da" } } From 9138725cdcb08fb3ab33e61f576e33f0723d6855 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Fri, 19 Jun 2026 18:15:46 +0200 Subject: [PATCH 15/15] fix: harden docs-sync and generator, retarget piyaz-docs --- .github/pull_request_template.md | 2 +- .github/workflows/docs-sync.yml | 7 +- lib/mcp/schemas.ts | 8 ++ scripts/generate-docs.ts | 132 ++++++++++++++++++---------- tests/scripts/generate-docs.test.ts | 59 +++++++++++-- 5 files changed, 150 insertions(+), 58 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index df281a42..d817199f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -23,6 +23,6 @@ ## Docs impact - + - diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml index 2d720789..a51140ca 100644 --- a/.github/workflows/docs-sync.yml +++ b/.github/workflows/docs-sync.yml @@ -6,6 +6,7 @@ on: paths: - "lib/mcp/**" - "lib/graph/tool-handlers.ts" + - "lib/graph/identifier.ts" - "plugins/claude-code/**" - "scripts/generate-docs.ts" workflow_dispatch: @@ -32,13 +33,13 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Checkout mymir-docs + - name: Checkout piyaz-docs uses: actions/checkout@v6 with: - repository: FrkAk/mymir-docs + repository: FrkAk/piyaz-docs token: ${{ secrets.DOCS_REPO_TOKEN }} path: docs-repo - persist-credentials: true + persist-credentials: false - name: Regenerate docs run: bun scripts/generate-docs.ts --out docs-repo/content/docs diff --git a/lib/mcp/schemas.ts b/lib/mcp/schemas.ts index b5c7c77d..55f718a5 100644 --- a/lib/mcp/schemas.ts +++ b/lib/mcp/schemas.ts @@ -392,6 +392,8 @@ export interface ToolDefinition { title: string; description: string; inputSchema: z.ZodObject; + /** Name of the enum field that selects the tool's action/variant. */ + discriminator: string; } /** All 6 tools in registration order. Titles match the MCP annotations. */ @@ -401,35 +403,41 @@ export const TOOLS: readonly ToolDefinition[] = [ title: "Manage Project", description: DESCRIPTIONS.piyaz_project, inputSchema: projectInputSchema, + discriminator: "action", }, { name: "piyaz_task", title: "Manage Task", description: DESCRIPTIONS.piyaz_task, inputSchema: taskInputSchema, + discriminator: "action", }, { name: "piyaz_edge", title: "Manage Edge", description: DESCRIPTIONS.piyaz_edge, inputSchema: edgeInputSchema, + discriminator: "action", }, { name: "piyaz_query", title: "Query Tasks", description: DESCRIPTIONS.piyaz_query, inputSchema: queryInputSchema, + discriminator: "type", }, { name: "piyaz_context", title: "Get Task Context", description: DESCRIPTIONS.piyaz_context, inputSchema: contextInputSchema, + discriminator: "depth", }, { name: "piyaz_analyze", title: "Analyze Graph", description: DESCRIPTIONS.piyaz_analyze, inputSchema: analyzeInputSchema, + discriminator: "type", }, ] as const; diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index c4d336f8..58a9c2bc 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -1,5 +1,5 @@ /** - * docs:gen. Emits generated documentation into the mymir-docs content tree. + * docs:gen. Emits generated documentation into the piyaz-docs content tree. * * Outputs: * mcp/tools/.mdx + meta.json from lib/mcp/schemas.ts @@ -7,7 +7,10 @@ * reference/skills-and-agents.mdx catalog from plugin frontmatter * * Run: bun scripts/generate-docs.ts [--out ] - * Default out dir: ../mymir-docs/content/docs relative to the piyaz repo. + * Default out dir: ../piyaz-docs/content/docs relative to the piyaz repo. + * + * Output is additive (upsert): a removed tool or reference leaves a stale + * page in the docs repo that must be deleted by hand. */ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; @@ -34,13 +37,35 @@ interface JsonSchemaObject { } /** - * Replace em/en-dashes in prose with commas or hyphens, leaving code intact. - * Fenced code blocks and inline code spans are preserved verbatim so docs - * generated from product strings satisfy the no-dash content style rule. - * @param text - Markdown or prose, possibly containing code. - * @returns Text with prose dashes normalized and code untouched. + * Apply a transform to the prose segments of text, leaving inline code spans + * (backtick-delimited) verbatim. + * @param text - Text that may contain inline code spans. + * @param transform - Applied to each segment that is not a code span. + * @returns The text with the transform applied outside code spans. */ -export function normalizeProseDashes(text: string): string { +function mapProseSegments( + text: string, + transform: (segment: string) => string, +): string { + return text + .split(/(`[^`]*`)/) + .map((seg) => + seg.startsWith("`") && seg.endsWith("`") ? seg : transform(seg), + ) + .join(""); +} + +/** + * Apply a transform to prose, skipping fenced code blocks and inline code + * spans so code examples are never rewritten. + * @param text - Markdown text that may contain fences and inline code. + * @param transform - Applied to each prose segment outside code. + * @returns The text with the transform applied only to prose. + */ +function mapProse( + text: string, + transform: (segment: string) => string, +): string { const lines = text.split("\n"); let inFence = false; return lines @@ -50,38 +75,40 @@ export function normalizeProseDashes(text: string): string { return line; } if (inFence) return line; - const segments = line.split(/(`[^`]*`)/); - return segments - .map((seg) => - seg.startsWith("`") && seg.endsWith("`") - ? seg - : seg - .replace(/(\d)\s*[–]\s*(\d)/g, "$1-$2") - .replace(/(?<=[^\s|])\s*[—–]\s*(?=[^\s|])/g, ", ") - .replace(/[—–]/g, "-") - .replace(/ ,/g, ","), - ) - .join(""); + return mapProseSegments(line, transform); }) .join("\n"); } +/** + * Replace em/en-dashes in prose with commas or hyphens, leaving code intact. + * Fenced code blocks and inline code spans are preserved verbatim so docs + * generated from product strings satisfy the no-dash content style rule. + * @param text - Markdown or prose, possibly containing code. + * @returns Text with prose dashes normalized and code untouched. + */ +export function normalizeProseDashes(text: string): string { + return mapProse(text, (seg) => + seg + .replace(/(\d)\s*[—–]\s*(\d)/g, "$1-$2") + .replace(/(?<=[^\s|])\s*[—–]\s*(?=[^\s|])/g, ", ") + .replace(/[—–]/g, "-") + .replace(/ ,/g, ","), + ); +} + /** * Escape characters MDX would parse as JSX in prose positions. - * Inline code spans are left verbatim: backticked text is literal in MDX, so - * escaping `<` or `{` inside it would render the entity instead of the source. - * @param text - Raw prose. - * @returns Prose safe to embed in MDX body text. + * Fenced code blocks and inline code spans are left verbatim: their text is + * literal in MDX, so escaping `<` or `{` there would render the entity + * instead of the source. + * @param text - Raw markdown prose, possibly multi-line with code. + * @returns Prose safe to embed in an MDX document body. */ export function escapeProse(text: string): string { - return normalizeProseDashes(text) - .split(/(`[^`]*`)/) - .map((seg) => - seg.startsWith("`") && seg.endsWith("`") - ? seg - : seg.replaceAll("<", "<").replaceAll("{", "{"), - ) - .join(""); + return mapProse(normalizeProseDashes(text), (seg) => + seg.replaceAll("<", "<").replaceAll("{", "{"), + ); } /** @@ -163,11 +190,11 @@ export function renderToolPage(tool: ToolDefinition): string { const schema = z.toJSONSchema(tool.inputSchema) as JsonSchemaObject; const props = schema.properties ?? {}; const required = new Set(schema.required ?? []); - const discriminatorName = Object.keys(props).find((k) => props[k]?.enum); - const discriminator = discriminatorName - ? props[discriminatorName] - : undefined; + const discriminatorName = tool.discriminator; + const discriminator = props[discriminatorName]; const actions = discriminator?.enum ?? []; + const actionLabel = + discriminatorName.charAt(0).toUpperCase() + discriminatorName.slice(1); const firstSentence = `${tool.description.split(". ")[0]}.`; const paramNames = Object.keys(props).sort((a, b) => { @@ -187,7 +214,7 @@ export function renderToolPage(tool: ToolDefinition): string { ).map(({ action, purpose }) => `| \`${action}\` | ${escapeCell(purpose)} |`); return `--- -title: ${tool.name} +title: ${yamlQuote(tool.name)} description: ${yamlQuote(firstSentence)} --- @@ -197,9 +224,9 @@ ${GENERATED_NOTE} ${escapeProse(tool.description)} -## Actions +## ${actionLabel}s -| Action | Purpose | +| ${actionLabel} | Purpose | |---|---| ${actionRows.join("\n")} @@ -260,9 +287,9 @@ export function transformReference(raw: string, file: string): string { /`references\/(conventions|artifacts|lifecycle|resilience)\.md`/g, "[`references/$1.md`](/docs/reference/$1/)", ); - const normalizedBody = normalizeProseDashes(linked); + const safeBody = escapeProse(linked); return `--- -title: ${title} +title: ${yamlQuote(title)} description: ${yamlQuote(ref.description)} --- @@ -271,14 +298,15 @@ import { Callout } from 'fumadocs-ui/components/callout'; ${GENERATED_NOTE} - Canonical skill reference. This page is synced verbatim from - plugins/claude-code/skills/piyaz/references/${file} in the piyaz repo. - The Piyaz skills and agents follow exactly this text. + Canonical skill reference, synced from + plugins/claude-code/skills/piyaz/references/${file} in the piyaz repo with + prose dashes normalized for the docs style guide. The Piyaz skills and + agents follow this reference. # ${title} -${normalizedBody} +${safeBody} `; } @@ -365,8 +393,9 @@ ${agentSections.join("\n\n")} */ function parseArgs(): { out: string } { const idx = process.argv.indexOf("--out"); - const fallback = resolve(import.meta.dir, "../../mymir-docs/content/docs"); - return { out: idx === -1 ? fallback : resolve(process.argv[idx + 1]) }; + const value = idx === -1 ? undefined : process.argv[idx + 1]; + const fallback = resolve(import.meta.dir, "../../piyaz-docs/content/docs"); + return { out: value ? resolve(value) : fallback }; } /** @@ -407,6 +436,15 @@ async function main(): Promise { await renderCatalog(pluginRoot), ); + const referenceSlugs = [ + ...SKILL_REFERENCES.map((ref) => ref.slug), + "skills-and-agents", + ]; + await writeFile( + join(referenceDir, "meta.json"), + `${JSON.stringify({ title: "Reference", pages: referenceSlugs }, null, 2)}\n`, + ); + console.log( `docs:gen wrote ${TOOLS.length} tool pages, ${SKILL_REFERENCES.length} references, 1 catalog to ${out}`, ); diff --git a/tests/scripts/generate-docs.test.ts b/tests/scripts/generate-docs.test.ts index 5d325502..6512eab5 100644 --- a/tests/scripts/generate-docs.test.ts +++ b/tests/scripts/generate-docs.test.ts @@ -39,7 +39,7 @@ describe("renderToolPage", () => { const page = renderToolPage(TOOLS[0]); test("emits frontmatter, marker, and sections", () => { - expect(page).toStartWith("---\ntitle: piyaz_project\n"); + expect(page).toStartWith('---\ntitle: "piyaz_project"\n'); expect(page).toContain("Do not edit by hand"); expect(page).toContain("## Actions"); expect(page).toContain("## Parameters"); @@ -69,7 +69,7 @@ describe("transformReference", () => { const out = transformReference(raw, "conventions.md"); test("extracts the title into frontmatter and keeps the h1", () => { - expect(out).toContain("title: Piyaz Conventions"); + expect(out).toContain('title: "Piyaz Conventions"'); expect(out).toContain("# Piyaz Conventions"); }); @@ -150,13 +150,14 @@ describe("escapeProse", () => { describe("renderToolPage covers every tool", () => { for (const tool of TOOLS) { - test(`${tool.name} has a populated Actions table`, () => { + test(`${tool.name} has a populated values table`, () => { const page = renderToolPage(tool); - const actionsSection = page.slice( - page.indexOf("## Actions"), - page.indexOf("## Parameters"), + const firstHeading = page.indexOf("\n## "); + const valuesSection = page.slice( + firstHeading, + page.indexOf("\n## ", firstHeading + 4), ); - const bodyRows = actionsSection + const bodyRows = valuesSection .split("\n") .filter((l) => l.startsWith("| `")); expect(bodyRows.length).toBeGreaterThan(0); @@ -204,6 +205,50 @@ describe("renderToolPage union-array types", () => { }); }); +describe("renderToolPage values-section label", () => { + test("labels the section by the discriminator, not always 'Actions'", () => { + const ctx = renderToolPage(TOOLS.find((t) => t.name === "piyaz_context")!); + expect(ctx).toContain("## Depths"); + expect(ctx).toContain("| Depth | Purpose |"); + expect(ctx).not.toContain("## Actions"); + + const analyze = renderToolPage( + TOOLS.find((t) => t.name === "piyaz_analyze")!, + ); + expect(analyze).toContain("## Types"); + expect(analyze).not.toContain("## Actions"); + + const project = renderToolPage( + TOOLS.find((t) => t.name === "piyaz_project")!, + ); + expect(project).toContain("## Actions"); + }); +}); + +describe("transformReference MDX escaping", () => { + test("escapes angle brackets and braces in prose but not in code", () => { + const raw = + "# T\n\nUse and {bar} in prose.\n\n```\nkeep raw\n```\n"; + const out = transformReference(raw, "conventions.md"); + expect(out).toContain("Use <Foo> and {bar} in prose."); + expect(out).toContain("keep raw"); + }); +}); + +describe("renderToolPage discriminator selection", () => { + test("uses the declared discriminator, not an incidental enum field", () => { + const task = renderToolPage(TOOLS.find((t) => t.name === "piyaz_task")!); + const actionsSection = task.slice( + task.indexOf("## Actions"), + task.indexOf("## Parameters"), + ); + for (const action of ["create", "update", "delete"]) { + expect(actionsSection).toContain(`| \`${action}\` |`); + } + expect(actionsSection).not.toContain("| `draft` |"); + }); +}); + describe("normalizeProseDashes standalone cells", () => { test("a lone em-dash table cell becomes a single hyphen", () => { expect(normalizeProseDashes("| `field` | — | note |")).toBe(