diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c8cded7..d817199 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -20,3 +20,9 @@ ## Notes for reviewer + +## Docs impact + + + +- diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml new file mode 100644 index 0000000..a51140c --- /dev/null +++ b/.github/workflows/docs-sync.yml @@ -0,0 +1,59 @@ +name: Docs Sync + +on: + push: + branches: [main] + paths: + - "lib/mcp/**" + - "lib/graph/tool-handlers.ts" + - "lib/graph/identifier.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/piyaz' + concurrency: + group: docs-sync + cancel-in-progress: false + permissions: + contents: read + steps: + - name: Checkout piyaz + 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 piyaz-docs + uses: actions/checkout@v6 + with: + repository: FrkAk/piyaz-docs + token: ${{ secrets.DOCS_REPO_TOKEN }} + path: docs-repo + persist-credentials: false + + - 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 piyaz" + title: "docs: sync generated reference from piyaz" + body: | + Automated regeneration triggered by FrkAk/piyaz@${{ github.sha }}. + Sources: MCP schemas, tool descriptions, or plugin skill files. + signoff: false diff --git a/lib/graph/tool-handlers.ts b/lib/graph/tool-handlers.ts index 94ce0bf..1bace08 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 = { - piyaz_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 piyaz_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).", - piyaz_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 piyaz_edge, verify with piyaz_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 piyaz_analyze type='downstream' to propagate.", - piyaz_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 piyaz_query type='edges'.", - piyaz_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.", - piyaz_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 `piyaz_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.", - piyaz_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 fc6ac4e..d7dd4bf 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 { "piyaz_project", { description: DESCRIPTIONS.piyaz_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 piyaz_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 { "piyaz_task", { description: DESCRIPTIONS.piyaz_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 piyaz_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 piyaz_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 { "piyaz_edge", { description: DESCRIPTIONS.piyaz_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 { "piyaz_query", { description: DESCRIPTIONS.piyaz_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 piyaz_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 { "piyaz_context", { description: DESCRIPTIONS.piyaz_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 `piyaz_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() - .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 { "piyaz_analyze", { description: DESCRIPTIONS.piyaz_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 0000000..55f718a --- /dev/null +++ b/lib/mcp/schemas.ts @@ -0,0 +1,443 @@ +/** + * MCP tool input schemas, descriptions, and metadata for the 6 Piyaz 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 the MCP server and the docs generator. */ +export const DESCRIPTIONS = { + piyaz_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 piyaz_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).", + piyaz_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 piyaz_edge, verify with piyaz_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 piyaz_analyze type='downstream' to propagate.", + piyaz_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 piyaz_query type='edges'.", + piyaz_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.", + piyaz_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 `piyaz_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.", + piyaz_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 piyaz_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 piyaz_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 piyaz_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 piyaz_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 `piyaz_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() + .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; + /** 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. */ +export const TOOLS: readonly ToolDefinition[] = [ + { + name: "piyaz_project", + 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/package.json b/package.json index 0477a22..20a6bdf 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,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 piyaz-db-1 psql -U piyaz -d piyaz < docker/init-auth.sql && docker exec piyaz-db-1 /docker-entrypoint-initdb.d/02-rls.sh && bun run db:push && docker exec -i piyaz-db-1 psql -U piyaz -d piyaz < docker/grants.sql && docker exec -i piyaz-db-1 psql -U piyaz -d piyaz < docker/rls-functions.sql && docker exec -i piyaz-db-1 psql -U piyaz -d piyaz < docker/rls-policies.sql", "db:generate": "drizzle-kit generate", "db:push": "drizzle-kit push", diff --git a/plugins/antigravity/skills/piyaz/references/artifacts.md b/plugins/antigravity/skills/piyaz/references/artifacts.md index 8c6b87f..c9111f6 100644 --- a/plugins/antigravity/skills/piyaz/references/artifacts.md +++ b/plugins/antigravity/skills/piyaz/references/artifacts.md @@ -396,10 +396,10 @@ The text you write into Piyaz 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/composer/references/sources.json b/plugins/claude-code/skills/composer/references/sources.json index 1f35197..c772ed4 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" } } diff --git a/plugins/claude-code/skills/piyaz/references/artifacts.md b/plugins/claude-code/skills/piyaz/references/artifacts.md index 8c6b87f..c9111f6 100644 --- a/plugins/claude-code/skills/piyaz/references/artifacts.md +++ b/plugins/claude-code/skills/piyaz/references/artifacts.md @@ -396,10 +396,10 @@ The text you write into Piyaz 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/piyaz/references/artifacts.md b/plugins/codex/skills/piyaz/references/artifacts.md index 8c6b87f..c9111f6 100644 --- a/plugins/codex/skills/piyaz/references/artifacts.md +++ b/plugins/codex/skills/piyaz/references/artifacts.md @@ -396,10 +396,10 @@ The text you write into Piyaz 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/piyaz/references/artifacts.md b/plugins/cursor/skills/piyaz/references/artifacts.md index 8c6b87f..c9111f6 100644 --- a/plugins/cursor/skills/piyaz/references/artifacts.md +++ b/plugins/cursor/skills/piyaz/references/artifacts.md @@ -396,10 +396,10 @@ The text you write into Piyaz 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 new file mode 100644 index 0000000..58a9c2b --- /dev/null +++ b/scripts/generate-docs.ts @@ -0,0 +1,453 @@ +/** + * docs:gen. Emits generated documentation into the piyaz-docs content tree. + * + * Outputs: + * mcp/tools/.mdx + meta.json from lib/mcp/schemas.ts + * reference/.mdx synced piyaz skill references + * reference/skills-and-agents.mdx catalog from plugin frontmatter + * + * Run: bun scripts/generate-docs.ts [--out ] + * 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"; +import { z } from "zod/v4"; +import { TOOLS, type ToolDefinition } from "../lib/mcp/schemas"; + +const GENERATED_NOTE = + "{/* Generated by `bun run docs:gen` in the piyaz repo. Do not edit by hand. */}"; + +interface JsonSchemaProperty { + type?: string; + description?: string; + enum?: string[]; + items?: { type?: string; enum?: string[]; anyOf?: JsonSchemaProperty[] }; + format?: string; + default?: unknown; + anyOf?: JsonSchemaProperty[]; + const?: unknown; +} + +interface JsonSchemaObject { + properties?: Record; + required?: string[]; +} + +/** + * 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. + */ +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 + .map((line) => { + if (line.trimStart().startsWith("```")) { + inFence = !inFence; + return line; + } + if (inFence) return line; + 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. + * 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 mapProse(normalizeProseDashes(text), (seg) => + seg.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.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; + 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"; +} + +/** + * 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 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) => { + 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) && prop.default === undefined ? "Yes" : "No"; + return `| \`${name}\` | \`${renderType(prop)}\` | ${req} | ${escapeCell(prop.description ?? "")} |`; + }); + + const actionRows = parseActions( + discriminator?.description ?? "", + actions, + ).map(({ action, purpose }) => `| \`${action}\` | ${escapeCell(purpose)} |`); + + return `--- +title: ${yamlQuote(tool.name)} +description: ${yamlQuote(firstSentence)} +--- + +${GENERATED_NOTE} + +# ${tool.name} + +${escapeProse(tool.description)} + +## ${actionLabel}s + +| ${actionLabel} | Purpose | +|---|---| +${actionRows.join("\n")} + +## Parameters + +| Name | Type | Required | Description | +|---|---|---|---| +${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 Piyaz 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 = 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 safeBody = escapeProse(linked); + return `--- +title: ${yamlQuote(title)} +description: ${yamlQuote(ref.description)} +--- + +import { Callout } from 'fumadocs-ui/components/callout'; + +${GENERATED_NOTE} + + + 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} + +${safeBody} +`; +} + +/** 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 === "piyaz" + ? -1 + : b.name === "piyaz" + ? 1 + : a.name.localeCompare(b.name), + ); + const skillSections = skills.map((s) => { + const command = s.name === "piyaz" ? "/piyaz" : `/piyaz:${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 Piyaz plugin installs, and when each one triggers." +--- + +${GENERATED_NOTE} + +# Skills and agents + +The Piyaz 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")} +`; +} + +/** + * Parse CLI arguments. + * @returns Resolved output content directory. + */ +function parseArgs(): { out: string } { + const idx = process.argv.indexOf("--out"); + 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 }; +} + +/** + * 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("piyaz_", ""); + await writeFile(join(toolsDir, `${slug}.mdx`), renderToolPage(tool)); + } + const toolSlugs = TOOLS.map((t) => t.name.replace("piyaz_", "")).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/piyaz/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), + ); + + 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}`, + ); +} + +if (import.meta.main) await main(); diff --git a/tests/scripts/generate-docs.test.ts b/tests/scripts/generate-docs.test.ts new file mode 100644 index 0000000..6512eab --- /dev/null +++ b/tests/scripts/generate-docs.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { + escapeProse, + normalizeProseDashes, + parseActions, + renderCatalog, + renderToolPage, + transformReference, +} from "../../scripts/generate-docs"; +import { TOOLS } from "../../lib/mcp/schemas"; + +describe("TOOLS", () => { + test("exposes all six tools", () => { + expect(TOOLS.map((t) => t.name)).toEqual([ + "piyaz_project", + "piyaz_task", + "piyaz_edge", + "piyaz_query", + "piyaz_context", + "piyaz_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: "piyaz_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); + }); +}); + +describe("transformReference", () => { + const raw = "# Piyaz 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: "Piyaz Conventions"'); + expect(out).toContain("# Piyaz Conventions"); + }); + + test("adds the canonical banner with the source path", () => { + expect(out).toContain("Canonical skill reference"); + expect(out).toContain( + "plugins/claude-code/skills/piyaz/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("### /piyaz"); + expect(out).toContain("### /piyaz:composer"); + expect(out).toContain("## Agents"); + 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", + ); + }); +}); + +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 values table`, () => { + const page = renderToolPage(tool); + const firstHeading = page.indexOf("\n## "); + const valuesSection = page.slice( + firstHeading, + page.indexOf("\n## ", firstHeading + 4), + ); + const bodyRows = valuesSection + .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 === "piyaz_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 === "piyaz_task")!); + const actionRow = task + .split("\n") + .find((l) => l.startsWith("| `action` |")); + expect(actionRow).toContain("| Yes |"); + }); +}); + +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 === "piyaz_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 === "piyaz_task")!); + const prUrlRow = task.split("\n").find((l) => l.startsWith("| `prUrl` |")); + expect(prUrlRow).toContain("string (url) \\| null"); + }); +}); + +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( + "| `field` | - | note |", + ); + }); + + test("prose em-dash still becomes a comma", () => { + expect(normalizeProseDashes("X — Y")).toBe("X, Y"); + }); +});